diff --git a/src/secscore-connector.test.ts b/src/secscore-connector.test.ts new file mode 100644 index 0000000..e670001 --- /dev/null +++ b/src/secscore-connector.test.ts @@ -0,0 +1,471 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import http from "node:http"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import AdmZip from "adm-zip"; +import { AuditStore } from "./audit.js"; +import { PluginManager } from "./plugin-manager.js"; +import { SecAgentRuntime, type TraceEvent } from "./runtime.js"; +import type { SecAgentConfig } from "./types.js"; + +/** + * End-to-end tests for the secscore-connector plugin (SECTL/SecScore-SecAgent-Connector). + * + * The connector is a pure Sync-Server client: it never talks to a local SecScore + * install or creates a local database. These tests therefore stand in for both the + * model (scripted SSE tool-call turns) and the SecScore backend (an in-memory fake + * Sync Server exposing /v1/classes, /v1/snapshot, /v1/sync and /v1/operations). + * + * The plugin package under test is pinned as fixtures under + * src/test-fixtures/secscore-connector/ so any upstream change to the tool keys, + * argument names, permissions or Skill auto-load pattern fails these tests. + */ + +interface SeedStudent { name: string; group: string; score: number } +interface ServerStudent { student_id: string; name: string; group_name: string; score: number; reward_points: number } +interface FakeSecScoreServer { port: number; close(): Promise; state(): ServerStudent[] } + +type ModelToolCall = { id: string; name: string; args: Record }; +type ModelTurn = { toolCalls?: ModelToolCall[]; answer?: string }; + +const CLASSES = [{ id: "class-1", name: "三年级二班" }]; +const DEFAULT_STUDENTS: SeedStudent[] = [ + { name: "小明", group: "一组", score: 12 }, + { name: "小张", group: "一组", score: 10 }, + { name: "小泽", group: "一组", score: 9 }, + { name: "王强", group: "一组", score: 55 }, + { name: "小李", group: "二组", score: 60 }, + { name: "小红", group: "二组", score: 8 }, + { name: "小刚", group: "二组", score: 7 }, + { name: "小芳", group: "二组", score: 5 }, +]; + +/** Locates the pinned plugin fixtures from both the compiled dist/ and source src/ layout. */ +function fixtureDir(): string { + const here = path.dirname(fileURLToPath(import.meta.url)); + const candidates = [ + path.join(here, "test-fixtures", "secscore-connector"), + path.resolve(here, "..", "src", "test-fixtures", "secscore-connector"), + path.join(process.cwd(), "src", "test-fixtures", "secscore-connector"), + ]; + for (const candidate of candidates) { + if (fs.existsSync(path.join(candidate, "secagent-plugin.json"))) return candidate; + } + throw new Error("找不到 secscore-connector 测试夹具目录"); +} + +/** Mirrors the connector's FNV-1a student ID derivation so fake balances line up. */ +function deterministicStudentId(name: string): string { + let hash = 2166136261; + for (const char of name) { + hash ^= char.charCodeAt(0); + hash = Math.imul(hash, 16777619); + } + const hex = Math.abs(hash).toString(16).padStart(8, "0"); + return `${hex}-0000-5000-8000-${hex}${hex.slice(0, 4)}`; +} + +function readBody(req: http.IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + req.on("data", (chunk: Buffer) => chunks.push(chunk)); + req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); + req.on("error", reject); + }); +} + +/** In-memory SecScore Sync Server implementing the endpoints the connector calls. */ +async function fakeSecScoreServer(classes: Array<{ id: string; name: string }>, seeds: SeedStudent[]): Promise { + const studentsById = new Map(); + const students: ServerStudent[] = []; + for (const seed of seeds) { + const studentId = deterministicStudentId(seed.name); + const student: ServerStudent = { student_id: studentId, name: seed.name, group_name: seed.group, score: seed.score, reward_points: seed.score }; + // Same-name seeds keep their own snapshot entry (the plugin rejects ambiguous names + // itself), while the operation lookup map is keyed by the deterministic student ID. + students.push(student); + studentsById.set(studentId, student); + } + let changeSeq = 0; + const balances = (): Array<{ student_id: string; score: number; reward_points: number }> => + students.map(({ student_id, score, reward_points }) => ({ student_id, score, reward_points })); + let port = 0; + const server = http.createServer((req, res) => { + const url = new URL(req.url || "/", `http://127.0.0.1:${port}`); + const send = (status: number, body: unknown): void => { + res.writeHead(status, { "Content-Type": "application/json" }); + res.end(JSON.stringify(body)); + }; + if (req.method === "GET" && url.pathname === "/v1/classes") return send(200, { classes }); + if (req.method === "GET" && url.pathname === "/v1/snapshot") { + return send(200, { snapshot: { students: students.map(({ name, group_name, score }) => ({ name, group_name, score })) } }); + } + if (req.method === "POST" && url.pathname === "/v1/sync") { + return send(200, { server_change_seq: changeSeq, balances: balances() }); + } + if (req.method === "POST" && url.pathname === "/v1/operations") { + void readBody(req).then((raw) => { + const body = JSON.parse(raw) as { operation?: { entity_id?: string; payload?: { score_delta?: number } } }; + const operation = body.operation || {}; + const student = operation.entity_id ? studentsById.get(operation.entity_id) : undefined; + if (!student) return send(404, { error: "找不到学生" }); + const delta = Number(operation.payload?.score_delta ?? 0); + student.score += delta; + student.reward_points += delta; + changeSeq += 1; + send(200, { server_change_seq: changeSeq, accepted_operations: [{ server_change_seq: changeSeq }], balances: balances() }); + }).catch((error) => send(400, { error: error instanceof Error ? error.message : String(error) })); + return; + } + send(404, { error: `未知端点 ${req.method} ${url.pathname}` }); + }); + port = await new Promise((resolve, reject) => { + server.on("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + resolve(typeof address === "object" && address ? address.port : 0); + }); + }); + return { + port, + close: async () => { + server.closeAllConnections?.(); + await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); + }, + state: () => [...students], + }; +} + +/** Serializes one scripted model turn into an OpenAI-compatible SSE response body. */ +function sseBody(turn: ModelTurn): string { + if (turn.toolCalls?.length) { + const calls = turn.toolCalls.map((call, index) => + `{"index":${index},"id":${JSON.stringify(call.id)},"type":"function","function":{"name":${JSON.stringify(call.name)},"arguments":${JSON.stringify(JSON.stringify(call.args))}}}` + ); + return `data: {"choices":[{"delta":{"tool_calls":[${calls.join(",")}]}}]}\n\ndata: [DONE]\n\n`; + } + return `data: {"choices":[{"delta":{"content":${JSON.stringify(turn.answer ?? "")}}}]}\n\ndata: [DONE]\n\n`; +} + +class SecScoreHarness { + readonly workspace: string; + readonly manager: PluginManager; + readonly audit: AuditStore; + readonly runtime: SecAgentRuntime; + readonly traces: TraceEvent[]; + readonly server: FakeSecScoreServer; + readonly modelBodies: string[]; + readonly result: Awaited>; + private readonly restoreEnv: () => void; + private readonly restoreFetch: () => void; + private closed = false; + + private constructor( + workspace: string, + manager: PluginManager, + audit: AuditStore, + runtime: SecAgentRuntime, + traces: TraceEvent[], + server: FakeSecScoreServer, + modelBodies: string[], + result: SecScoreHarness["result"], + restoreEnv: () => void, + restoreFetch: () => void + ) { + this.workspace = workspace; + this.manager = manager; + this.audit = audit; + this.runtime = runtime; + this.traces = traces; + this.server = server; + this.modelBodies = modelBodies; + this.result = result; + this.restoreEnv = restoreEnv; + this.restoreFetch = restoreFetch; + } + + static async create(prompt: string, turns: ModelTurn[], students: SeedStudent[]): Promise { + const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "secagent-secscore-")); + const originalFetch = globalThis.fetch; + const envKeys = ["SECSCORE_SYNC_SERVER_URL", "SECSCORE_SYNC_API_URL", "TEST_MODEL_KEY", "SECTL_OFFICIAL_API_URL", "SECTL_OFFICIAL_CLIENT_ID"] as const; + const previousEnv = Object.fromEntries(envKeys.map((key) => [key, process.env[key]])) as Record<(typeof envKeys)[number], string | undefined>; + let server: FakeSecScoreServer | undefined; + let manager: PluginManager | undefined; + let audit: AuditStore | undefined; + let runtime: SecAgentRuntime | undefined; + const restoreEnv = (): void => { + for (const key of envKeys) { + const value = previousEnv[key]; + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + }; + try { + const archivePath = path.join(workspace, "secscore-connector.zip"); + const fixtures = fixtureDir(); + const archive = new AdmZip(); + for (const file of ["main.mjs", "secagent-plugin.json", "icon.svg"]) archive.addFile(file, fs.readFileSync(path.join(fixtures, file))); + archive.addFile("skills/secscore/SKILL.md", fs.readFileSync(path.join(fixtures, "skills", "secscore", "SKILL.md"))); + archive.writeZip(archivePath); + + server = await fakeSecScoreServer(CLASSES, students); + const serverPort = server.port; + process.env.SECSCORE_SYNC_SERVER_URL = `http://127.0.0.1:${serverPort}`; + process.env.TEST_MODEL_KEY = "test-key"; + delete process.env.SECSCORE_SYNC_API_URL; + delete process.env.SECTL_OFFICIAL_API_URL; + delete process.env.SECTL_OFFICIAL_CLIENT_ID; + + const modelBodies: string[] = []; + let modelRequestCount = 0; + globalThis.fetch = async (input, init) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : String(input); + if (url.includes("/chat/completions")) { + const body = sseBody(turns[modelRequestCount]); + if (body === undefined) throw new Error(`模型请求次数超出脚本:第 ${modelRequestCount + 1} 次`); + modelBodies.push(String(init?.body ?? "")); + modelRequestCount += 1; + return new Response(body, { status: 200, headers: { "Content-Type": "text/event-stream" } }); + } + if (url.startsWith(`http://127.0.0.1:${serverPort}`)) return originalFetch(input, init); + throw new Error(`测试中出现了未预期的网络请求:${url}`); + }; + + manager = new PluginManager(workspace, { + getSession: async () => ({ accessToken: "test-token", userId: "u1", email: "teacher@example.com", name: "测试老师" }), + oauthLogin: async () => { throw new Error("测试中不应触发 OAuth 登录"); }, + }); + audit = new AuditStore(workspace); + await manager.initialize(); + await manager.install(archivePath); + const config = { + workspace, + agent: { + provider: "openai-compatible", + model: "unused", + apiKeyEnv: "TEST_MODEL_KEY", + baseUrl: "http://127.0.0.1:1", + endpoint: "/chat/completions", + maxTokens: 200, + systemPrompt: "unused", + }, + mcp: { servers: {} }, + version: 1, + } as SecAgentConfig; + const traces: TraceEvent[] = []; + runtime = new SecAgentRuntime(config, audit, manager.getSkills(), (event) => traces.push(event), manager); + const result = await runtime.run(prompt, "high", [{ role: "user", content: prompt }]); + return new SecScoreHarness(workspace, manager, audit, runtime, traces, server, modelBodies, result, restoreEnv, () => { globalThis.fetch = originalFetch; }); + } catch (error) { + globalThis.fetch = originalFetch; + restoreEnv(); + await runtime?.close().catch(() => undefined); + audit?.close(); + await manager?.shutdown().catch(() => undefined); + await server?.close().catch(() => undefined); + fs.rmSync(workspace, { recursive: true, force: true }); + throw error; + } + } + + async close(): Promise { + if (this.closed) return; + this.closed = true; + await this.runtime.close().catch(() => undefined); + this.audit.close(); + await this.manager.shutdown().catch(() => undefined); + await this.server.close().catch(() => undefined); + this.restoreEnv(); + this.restoreFetch(); + fs.rmSync(this.workspace, { recursive: true, force: true }); + } + + toolCalls(): Array<{ name: string; arguments: Record }> { + return this.traces.filter((event) => event.stage === "mcp.tools/call").map((event) => event.data as { name: string; arguments: Record }); + } + + listedTools(): Array<{ key: string; hidden: boolean }> { + const event = this.traces.find((trace) => trace.stage === "mcp.tools/list"); + return (event?.data as Array<{ key: string; hidden: boolean }>) || []; + } +} + +test("查询单个同学积分(小明有几分):隐藏工具 + 正确分数", async () => { + const harness = await SecScoreHarness.create( + "小明有几分", + [ + { toolCalls: [{ id: "call-find", name: "secagent__call_hidden_tool", args: { name: "secscore-connector__find_students", arguments: { query: "小明" } } }] }, + { answer: "小明当前有 12 分。" }, + ], + DEFAULT_STUDENTS + ); + try { + assert.match(harness.result.message, /小明/); + assert.match(harness.result.message, /12/); + + // Plugin tool visibility contract: add_score is visible, everything else hidden. + const listed = harness.listedTools(); + assert.equal(listed.find((tool) => tool.key === "secscore-connector__add_score")?.hidden, false); + for (const key of ["secscore-connector__list_students", "secscore-connector__find_students", "secscore-connector__list_groups", "secscore-connector__list_group_members"]) { + assert.equal(listed.find((tool) => tool.key === key)?.hidden, true, `${key} 应为隐藏工具`); + } + + const calls = harness.toolCalls(); + assert.equal(calls.length, 1); + // 模型脚本通过 secagent__call_hidden_tool 包装调用隐藏工具,运行时按解析后的 key 执行。 + assert.match(harness.modelBodies[0], /secagent__call_hidden_tool/); + assert.equal(calls[0].name, "secscore-connector__find_students"); + assert.deepEqual(calls[0].arguments, { query: "小明" }); + assert.equal(calls.some((call) => call.name === "secscore-connector__add_score"), false); + + assert.equal(harness.server.state().find((item) => item.name === "小明")?.score, 12); + assert.equal(harness.audit.list().some((record) => record.tool === "secscore-connector__find_students"), true); + } finally { + await harness.close(); + } +}); + +test("批量加分(给小明小张和小泽加两份):三次 add_score 同步到云端", async () => { + const harness = await SecScoreHarness.create( + "给小明小张和小泽加两份,昨天主动帮忙值日了", + [ + { + toolCalls: [ + { id: "call-1", name: "secscore-connector__add_score", args: { student_name: "小明", score: 2, reason: "昨天主动帮忙值日了" } }, + { id: "call-2", name: "secscore-connector__add_score", args: { student_name: "小张", score: 2, reason: "昨天主动帮忙值日了" } }, + { id: "call-3", name: "secscore-connector__add_score", args: { student_name: "小泽", score: 2, reason: "昨天主动帮忙值日了" } }, + ], + }, + { answer: "已给小明、小张、小泽各加 2 分,原因:昨天主动帮忙值日了。" }, + ], + DEFAULT_STUDENTS + ); + try { + const calls = harness.toolCalls(); + assert.deepEqual(calls.map((call) => call.name), ["secscore-connector__add_score", "secscore-connector__add_score", "secscore-connector__add_score"]); + assert.deepEqual(calls.map((call) => call.arguments.student_name), ["小明", "小张", "小泽"]); + for (const call of calls) { + assert.equal(call.arguments.score, 2); + assert.equal(call.arguments.reason, "昨天主动帮忙值日了"); + } + + const state = harness.server.state(); + assert.equal(state.find((item) => item.name === "小明")?.score, 14); + assert.equal(state.find((item) => item.name === "小张")?.score, 12); + assert.equal(state.find((item) => item.name === "小泽")?.score, 11); + assert.equal(state.find((item) => item.name === "小李")?.score, 60); + + const addScoreAudits = harness.audit.list().filter((record) => record.tool === "secscore-connector__add_score"); + assert.equal(addScoreAudits.length, 3); + const auditedNames = addScoreAudits.map((record) => (JSON.parse(record.params || "{}") as { student_name: string }).student_name).sort(); + assert.deepEqual(auditedNames, ["小明", "小张", "小泽"].sort()); + for (const record of addScoreAudits) { + const params = JSON.parse(record.params || "{}") as { score: number; reason: string }; + assert.equal(params.score, 2); + assert.equal(params.reason, "昨天主动帮忙值日了"); + } + + assert.match(harness.result.message, /小明/); + assert.match(harness.result.message, /小张/); + assert.match(harness.result.message, /小泽/); + } finally { + await harness.close(); + } +}); + +test("按分组加分(给一组所有人加一分):先查分组再逐个加分", async () => { + const harness = await SecScoreHarness.create( + "给一组所有人加一分", + [ + { toolCalls: [{ id: "call-groups", name: "secagent__call_hidden_tool", args: { name: "secscore-connector__list_group_members", arguments: { group_name: "一组" } } }] }, + { + toolCalls: [ + { id: "call-a", name: "secscore-connector__add_score", args: { student_name: "小明", score: 1, reason: "给一组所有人加一分" } }, + { id: "call-b", name: "secscore-connector__add_score", args: { student_name: "小张", score: 1, reason: "给一组所有人加一分" } }, + { id: "call-c", name: "secscore-connector__add_score", args: { student_name: "小泽", score: 1, reason: "给一组所有人加一分" } }, + { id: "call-d", name: "secscore-connector__add_score", args: { student_name: "王强", score: 1, reason: "给一组所有人加一分" } }, + ], + }, + { answer: "已给一组全部 4 名同学各加 1 分。" }, + ], + DEFAULT_STUDENTS + ); + try { + const calls = harness.toolCalls(); + assert.equal(calls.length, 5); + assert.equal(calls[0].name, "secscore-connector__list_group_members"); + assert.deepEqual(calls[0].arguments, { group_name: "一组" }); + const addCalls = calls.slice(1); + assert.deepEqual(addCalls.map((call) => call.arguments.student_name), ["小明", "小张", "小泽", "王强"]); + for (const call of addCalls) assert.equal(call.arguments.score, 1); + + const state = harness.server.state(); + assert.equal(state.find((item) => item.name === "小明")?.score, 13); + assert.equal(state.find((item) => item.name === "小张")?.score, 11); + assert.equal(state.find((item) => item.name === "小泽")?.score, 10); + assert.equal(state.find((item) => item.name === "王强")?.score, 56); + assert.equal(state.find((item) => item.name === "小李")?.score, 60); + } finally { + await harness.close(); + } +}); + +test("总积分超过50的有哪些人:自动加载 Skill 且只查询不加分", async () => { + const harness = await SecScoreHarness.create( + "总积分超过50的有哪些人", + [ + { toolCalls: [{ id: "call-list", name: "secagent__call_hidden_tool", args: { name: "secscore-connector__list_students", arguments: {} } }] }, + { answer: "总积分超过 50 的同学有:王强(55 分)、小李(60 分)。" }, + ], + DEFAULT_STUDENTS + ); + try { + assert.match(harness.result.message, /王强/); + assert.match(harness.result.message, /小李/); + + const autoLoads = harness.traces.filter((event) => event.stage === "secagent.skills/auto-load").flatMap((event) => (event.data as Array<{ name: string }>).map((skill) => skill.name)); + assert.ok(autoLoads.includes("secscore-connector/secscore"), "包含“积分”的提示词应自动加载 secscore Skill"); + + const firstBody = harness.modelBodies[0]; + assert.match(firstBody, /已自动加载 Skill/); + assert.match(firstBody, /secscore-connector\/secscore/); + + const calls = harness.toolCalls(); + assert.deepEqual(calls.map((call) => call.name), ["secscore-connector__list_students"]); + assert.deepEqual(calls[0].arguments, {}); + assert.equal(calls.some((call) => call.name === "secscore-connector__add_score"), false); + } finally { + await harness.close(); + } +}); + +test("同名同学时加分失败:返回错误原因且云端积分不变", async () => { + const students: SeedStudent[] = [ + { name: "小明", group: "一组", score: 12 }, + { name: "小明", group: "二组", score: 20 }, + ]; + const harness = await SecScoreHarness.create( + "给小明加一分", + [ + { toolCalls: [{ id: "call-1", name: "secscore-connector__add_score", args: { student_name: "小明", score: 1, reason: "值日" } }] }, + { answer: "发现两位同名同学,请补充更完整姓名后再操作。" }, + ], + students + ); + try { + const calls = harness.toolCalls(); + assert.equal(calls.length, 1); + assert.equal(calls[0].name, "secscore-connector__add_score"); + // 第二轮模型请求必须带回工具失败的错误结果。 + assert.match(harness.modelBodies[1], /同名/); + // 云端没有收到任何 operations,积分保持不变。 + const state = harness.server.state(); + assert.deepEqual(state.map((item) => item.score).sort(), [12, 20]); + assert.equal(harness.audit.list().some((record) => record.tool === "secscore-connector__add_score"), false); + } finally { + await harness.close(); + } +}); diff --git a/src/test-fixtures/secscore-connector/icon.svg b/src/test-fixtures/secscore-connector/icon.svg new file mode 100644 index 0000000..ff6022f --- /dev/null +++ b/src/test-fixtures/secscore-connector/icon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/test-fixtures/secscore-connector/main.mjs b/src/test-fixtures/secscore-connector/main.mjs new file mode 100644 index 0000000..17283b1 --- /dev/null +++ b/src/test-fixtures/secscore-connector/main.mjs @@ -0,0 +1,294 @@ +const DEFAULT_SERVER_URL = "https://secscore-api.sectl.cn"; +const SKILL_PATH = "skills/secscore"; +const PAGE_ID = "secscore"; +// SecScore requests must load the complete Skill before the model chooses a +// tool. Keep the matcher tolerant of natural Chinese phrasing and English +// product names, including 加分/减分/扣分 variants. +const SCORE_AMOUNT_PATTERN = "(?:[+-]?(?:\\d+(?:\\.\\d+)?|[零〇一二两三四五六七八九十百千万亿]+))"; +const SKILL_AUTO_LOAD_PATTERN = new RegExp(`SecScore|Sec\\s*Score|积分|加(?:\\s*${SCORE_AMOUNT_PATTERN}\\s*)?分|加点|奖励(?:\\s*${SCORE_AMOUNT_PATTERN}\\s*)?分|减(?:\\s*${SCORE_AMOUNT_PATTERN}\\s*)?分|扣(?:\\s*${SCORE_AMOUNT_PATTERN}\\s*)?分|扣点|罚分|积分榜|积分查询`, "iu"); + +const serverUrl = () => (process.env.SECSCORE_SYNC_SERVER_URL || process.env.SECSCORE_SYNC_API_URL || DEFAULT_SERVER_URL).replace(/\/$/, ""); +const newId = () => crypto.randomUUID(); +const normalized = (value) => String(value ?? "").trim(); + +function deterministicStudentId(name) { + let hash = 2166136261; + for (const char of name) { + hash ^= char.charCodeAt(0); + hash = Math.imul(hash, 16777619); + } + const hex = Math.abs(hash).toString(16).padStart(8, "0"); + return `${hex}-0000-5000-8000-${hex}${hex.slice(0, 4)}`; +} + +export async function activate(api) { + const accounts = new Map(); + const classesByAccount = new Map(); + const savedConfig = api.getConfig(); + const selected = { + accountId: normalized(savedConfig.accountId), + classId: normalized(savedConfig.classId), + }; + const saveSelection = () => api.setConfig({ accountId: selected.accountId, classId: selected.classId }); + const devices = new Map(); + const counters = new Map(); + let registered = false; + let currentSession = null; + + const request = async (path, token, init = {}) => { + if (!token) throw new Error("没有可用的 SECTL 登录态,请先在 SecScore 操作设置页登录"); + const response = await api.fetch(`${serverUrl()}${path}`, { + ...init, + headers: { Accept: "application/json", Authorization: `Bearer ${token}`, ...(init.headers || {}) }, + signal: AbortSignal.timeout(15000), + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(payload?.error || payload?.detail || `SecScore 云端请求失败(HTTP ${response.status})`); + return payload; + }; + + const normalizeSession = async (session) => { + if (!session?.accessToken) return null; + const relayUrl = (process.env.SECTL_OFFICIAL_API_URL || "").replace(/\/$/, ""); + const clientId = process.env.SECTL_OFFICIAL_CLIENT_ID || ""; + const platformId = process.env.SECTL_OFFICIAL_PLATFORM_ID || clientId; + if (!relayUrl || !clientId) return session; + const introspection = await api.fetch(`${relayUrl}/auth/introspect`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token: session.accessToken, client_id: clientId }), + signal: AbortSignal.timeout(10000), + }).catch(() => null); + const introspectionPayload = introspection ? await introspection.json().catch(() => ({})) : {}; + if (introspection?.ok && introspectionPayload?.active === true && introspectionPayload?.user_id) { + return { ...session, userId: session.userId || introspectionPayload.user_id, email: session.email || introspectionPayload.email, name: session.name || introspectionPayload.name }; + } + const exchange = await api.fetch(`${relayUrl}/auth/oauth`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ access_token: session.accessToken, client_id: clientId, platform_id: platformId }), + signal: AbortSignal.timeout(15000), + }); + const exchangePayload = await exchange.json().catch(() => ({})); + if (!exchange.ok || !exchangePayload?.access_token) throw new Error(exchangePayload?.detail || "无法将 SECTL 登录态转换为官方 Relay 登录态"); + return { accessToken: exchangePayload.access_token, userId: exchangePayload.user?.id, email: exchangePayload.user?.email, name: exchangePayload.user?.name }; + }; + + const refreshCurrentSession = async () => { + const rawSession = await api.getSectlSession().catch(() => null); + const session = await normalizeSession(rawSession); + currentSession = session; + if (!session?.accessToken) return null; + const id = session.userId || session.email || "current"; + const existing = accounts.get(id); + accounts.set(id, { id, email: session.email || "", name: session.name || session.email || "当前登录账号", accessToken: session.accessToken, source: existing?.source || "current" }); + if (!selected.accountId || !accounts.has(selected.accountId)) { + selected.accountId = id; + selected.classId = ""; + } + saveSelection(); + return session; + }; + + const accountView = (account) => ({ id: account.id, email: account.email, name: account.name, source: account.source }); + const activeAccount = (accountId) => { + const id = normalized(accountId) || selected.accountId; + const account = accounts.get(id); + if (!account) throw new Error("尚未选择 SecScore 账号,请先在设置页选择或登录账号"); + selected.accountId = id; + saveSelection(); + return account; + }; + const classesFor = (account) => classesByAccount.get(account.id) || []; + const activeClass = (accountId, classId) => { + const account = activeAccount(accountId); + const id = normalized(classId) || selected.classId; + const item = classesFor(account).find((entry) => entry.id === id); + if (!item) throw new Error("尚未选择班级,请先在 SecScore 操作设置页选择班级"); + selected.classId = id; + saveSelection(); + return { account, class: item }; + }; + const loadClasses = async (accountId) => { + const account = activeAccount(accountId); + const classes = await request("/v1/classes", account.accessToken); + const list = Array.isArray(classes) ? classes : classes.classes; + const value = (Array.isArray(list) ? list : []).filter((item) => item && typeof item === "object").map((item) => ({ ...item, id: normalized(item.id) })).filter((item) => item.id); + classesByAccount.set(account.id, value); + if (!value.some((item) => item.id === selected.classId)) selected.classId = value[0]?.id || ""; + saveSelection(); + return value; + }; + const deviceFor = (accountId, classId) => { + const key = `${accountId}:${classId}`; + if (!devices.has(key)) devices.set(key, newId()); + return devices.get(key); + }; + const nextCounter = (accountId, classId) => { + const key = `${accountId}:${classId}`; + const value = (counters.get(key) || 0) + 1; + counters.set(key, value); + return value; + }; + + const readClass = async (accountId, classId) => { + const { account, class: classInfo } = activeClass(accountId, classId); + const snapshotResponse = await request(`/v1/snapshot?class_id=${encodeURIComponent(classInfo.id)}`, account.accessToken); + const snapshot = snapshotResponse?.snapshot || {}; + const syncResponse = await request("/v1/sync", account.accessToken, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ class_id: classInfo.id, device_id: deviceFor(account.id, classInfo.id), last_server_change_seq: 0, operations: [], limit: 2000 }), + }); + const balances = new Map((syncResponse?.balances || []).map((item) => [item.student_id, item])); + const students = (Array.isArray(snapshot.students) ? snapshot.students : []).map((student) => { + const name = normalized(student.name || student.student_name); + const studentId = deterministicStudentId(name); + const balance = balances.get(studentId); + return { + student_id: studentId, + name, + group_name: normalized(student.group_name || student.group || "") || null, + score: Number(balance?.score ?? student.score ?? 0), + reward_points: Number(balance?.reward_points ?? student.reward_points ?? 0), + }; + }).filter((student) => student.name); + return { account, class: classInfo, students, snapshot, serverChangeSeq: syncResponse?.server_change_seq || 0 }; + }; + + const findStudent = async (args = {}) => { + const data = await readClass(args.account_id, args.class_id); + const query = normalized(args.query || args.student_name); + const exact = data.students.filter((student) => student.name === query); + const matches = exact.length ? exact : data.students.filter((student) => student.name.includes(query)); + return { data, matches }; + }; + + const listStudents = async (args = {}) => { + const { students, class: classInfo } = await readClass(args.account_id, args.class_id); + const query = normalized(args.query || args.student_name); + const group = normalized(args.group_name || args.group); + const limit = Math.min(Math.max(Number(args.limit || 1000), 1), 2000); + return { class: { id: classInfo.id, name: classInfo.name }, students: students.filter((student) => (!query || student.name.includes(query)) && (!group || student.group_name === group)).slice(0, limit) }; + }; + + const callAction = async (action, args = {}) => { + if (action === "get_state") { + await refreshCurrentSession().catch(() => null); + const account = selected.accountId ? accounts.get(selected.accountId) : null; + const classes = account ? (classesFor(account).length ? classesFor(account) : await loadClasses(account.id).catch(() => [])) : []; + return { serverUrl: serverUrl(), accounts: [...accounts.values()].map(accountView), selectedAccountId: selected.accountId, selectedClassId: selected.classId, classes, hasCurrentSession: Boolean(currentSession?.accessToken) }; + } + if (action === "oauth_login") { + const session = await normalizeSession(await api.sectlOAuthLogin()); + const id = session.userId || session.email || newId(); + accounts.set(id, { id, email: session.email || "", name: session.name || session.email || "SECTL 账号", accessToken: session.accessToken, source: "oauth" }); + selected.accountId = id; + selected.classId = ""; + const classes = await loadClasses(id); + return { account: accountView(accounts.get(id)), classes, selectedAccountId: id, selectedClassId: selected.classId }; + } + if (action === "select_account") { + const account = activeAccount(args.account_id); + selected.classId = ""; + const classes = await loadClasses(account.id); + saveSelection(); + return { classes, selectedAccountId: account.id, selectedClassId: selected.classId }; + } + if (action === "list_classes") return { classes: await loadClasses(args.account_id) }; + if (action === "select_class") { + const account = activeAccount(args.account_id); + const classes = classesFor(account).length ? classesFor(account) : await loadClasses(account.id); + const item = classes.find((entry) => entry.id === normalized(args.class_id)); + if (!item) throw new Error("找不到所选班级"); + selected.classId = item.id; + saveSelection(); + return { class: item, selectedAccountId: account.id, selectedClassId: item.id }; + } + if (action === "refresh") { + const account = activeAccount(args.account_id); + const classes = await loadClasses(account.id); + return { accounts: [...accounts.values()].map(accountView), classes, selectedAccountId: account.id, selectedClassId: selected.classId }; + } + if (action === "remove_account") { + const id = normalized(args.account_id); + if (id && accounts.get(id)?.source !== "current") accounts.delete(id); + if (!accounts.has(selected.accountId)) { selected.accountId = [...accounts.keys()][0] || ""; selected.classId = ""; } + saveSelection(); + return callAction("get_state"); + } + throw new Error(`未知的 SecScore 设置操作:${action}`); + }; + + const addScore = async (args = {}) => { + const score = Number(args.score ?? args.delta); + const reason = normalized(args.reason || args.reason_content); + const studentName = normalized(args.student_name || args.studentName); + if (!Number.isInteger(score) || score === 0) throw new Error("score 必须是非零整数,可用负数表示扣分"); + if (!reason) throw new Error("reason 不能为空"); + if (!studentName) throw new Error("student_name 不能为空"); + const { data, matches } = await findStudent({ ...args, query: studentName }); + if (matches.length !== 1) throw new Error(matches.length ? `找到多个同名或相似同学:${matches.map((item) => item.name).join("、")},请提供更完整姓名` : `找不到同学:${studentName}`); + const student = matches[0]; + const clientSeq = nextCounter(data.account.id, data.class.id); + const operationId = newId(); + const response = await request("/v1/operations", data.account.accessToken, { + method: "POST", + headers: { "Content-Type": "application/json", "X-Request-Id": operationId }, + body: JSON.stringify({ + class_id: data.class.id, + device_id: deviceFor(data.account.id, data.class.id), + last_server_change_seq: data.serverChangeSeq, + operation: { + op_id: operationId, + client_seq: clientSeq, + lamport: clientSeq, + entity_type: "student", + entity_id: student.student_id, + operation_type: "score.adjust", + payload: { student_name: student.name, reason_content: reason, score_delta: score, reward_delta: score }, + client_created_at: new Date().toISOString(), + }, + }), + }); + const balance = (response.balances || []).find((item) => item.student_id === student.student_id); + return { ok: true, operation_id: operationId, class: { id: data.class.id, name: data.class.name }, student: student.name, student_id: student.student_id, score_delta: score, reason, previous_score: student.score, current_score: Number(balance?.score ?? student.score + score), server_change_seq: response.accepted_operations?.[0]?.server_change_seq || response.server_change_seq }; + }; + + api.registerTool({ name: "add_score", description: "在当前选定的 SecScore 班级中给一名同学加分或扣分,并将操作直接同步到云端。", hidden: false, inputSchema: { type: "object", additionalProperties: false, required: ["student_name", "score", "reason"], properties: { student_name: { type: "string", description: "同学完整姓名" }, score: { type: "integer", description: "分值,正数加分,负数扣分" }, reason: { type: "string", description: "加减分理由" }, account_id: { type: "string", description: "可选,设置页已选账号的 ID" }, class_id: { type: "string", description: "可选,设置页已选班级的 ID" } } } }, addScore); + api.registerTool({ name: "list_students", description: "列出当前 SecScore 班级的同学及实时积分。", hidden: true, inputSchema: { type: "object", additionalProperties: false, properties: { query: { type: "string" }, group_name: { type: "string" }, limit: { type: "integer" }, account_id: { type: "string" }, class_id: { type: "string" } } } }, listStudents); + api.registerTool({ name: "find_students", description: "按姓名搜索当前 SecScore 班级的同学。", hidden: true, inputSchema: { type: "object", additionalProperties: false, required: ["query"], properties: { query: { type: "string" }, account_id: { type: "string" }, class_id: { type: "string" } } } }, async (args) => (await findStudent(args)).matches); + api.registerTool({ name: "list_groups", description: "列出当前 SecScore 班级的分组及每组人数。", hidden: true, inputSchema: { type: "object", additionalProperties: false, properties: { account_id: { type: "string" }, class_id: { type: "string" } } } }, async (args) => { const result = await listStudents({ ...args, limit: 2000 }); const groups = new Map(); for (const student of result.students) { const name = student.group_name || "未分组"; groups.set(name, (groups.get(name) || 0) + 1); } return [...groups.entries()].map(([name, count]) => ({ name, count })); }); + api.registerTool({ name: "list_group_members", description: "列出当前 SecScore 班级指定分组内的同学。", hidden: true, inputSchema: { type: "object", additionalProperties: false, required: ["group_name"], properties: { group_name: { type: "string" }, account_id: { type: "string" }, class_id: { type: "string" } } } }, async (args) => listStudents({ ...args, limit: 2000 })); + api.registerSkill(SKILL_PATH, SKILL_AUTO_LOAD_PATTERN); + api.registerSettingsHandler(PAGE_ID, callAction); + registered = true; + let refreshPromise; + const refreshConnection = async () => { + if (refreshPromise) return refreshPromise; + refreshPromise = (async () => { + const session = await refreshCurrentSession(); + if (!session?.accessToken || !selected.accountId) { + api.setStatus("SecScore 工具已加载,等待 SECTL 登录"); + return; + } + const classes = await loadClasses(selected.accountId); + api.setStatus(`SecScore 已连接(${classes.length} 个班级,${registered ? "工具已就绪" : ""})`); + })().catch((error) => { + api.setStatus(`SecScore 已加载但云端未连接:${error instanceof Error ? error.message : String(error)}`, "error"); + }).finally(() => { refreshPromise = undefined; }); + return refreshPromise; + }; + await refreshConnection(); + const timer = setInterval(() => { void refreshConnection(); }, 30_000); + timer.unref?.(); + + return () => { + if (!registered) return; + clearInterval(timer); + for (const name of ["add_score", "list_students", "find_students", "list_groups", "list_group_members"]) api.unregisterTool(name); + api.unregisterSkill("secscore"); + api.unregisterSettingsHandler(PAGE_ID); + registered = false; + }; +} diff --git a/src/test-fixtures/secscore-connector/secagent-plugin.json b/src/test-fixtures/secscore-connector/secagent-plugin.json new file mode 100644 index 0000000..37edc2e --- /dev/null +++ b/src/test-fixtures/secscore-connector/secagent-plugin.json @@ -0,0 +1,13 @@ +{ + "apiVersion": 1, + "id": "secscore-connector", + "name": "SecScore 积分操作", + "version": "2.1.6", + "main": "main.mjs", + "icon": "icon.svg", + "description": "让 SecAgent 可以查询 SecScore 班级积分,并执行加分、减分和扣分操作", + "permissions": ["agent.tools", "agent.skills", "agent.settings", "network.http"], + "settingsPages": [ + { "id": "secscore", "title": "SecScore 积分操作", "description": "选择 SecScore 账号和班级" } + ] +} diff --git a/src/test-fixtures/secscore-connector/skills/secscore/SKILL.md b/src/test-fixtures/secscore-connector/skills/secscore/SKILL.md new file mode 100644 index 0000000..35987ae --- /dev/null +++ b/src/test-fixtures/secscore-connector/skills/secscore/SKILL.md @@ -0,0 +1,42 @@ +--- +name: secscore +description: 使用 SecScore 云端班级工具给同学加分或扣分 +--- + +# SecScore 操作 + +插件启动时会自动读取当前 SECTL 登录态,并加载已保存的账号和班级;首次登录或需要切换多个账号/班级时,仍可在 SecAgent 设置中的“SecScore 操作”页完成选择。账号默认使用当前 SECTL 登录账号,也可以在该页通过 OAuth 登录其它账号。 + +## 给同学加减分 + +调用 `secscore-connector__add_score`,参数如下: + +```json +{ + "student_name": "同学完整姓名", + "score": 2, + "reason": "课堂表现积极" +} +``` + +`score` 为整数,正数表示加分,负数表示扣分。`reason` 必须说明原因。调用前确认同学姓名、分值和理由;同名时先让用户补充更完整的姓名。成功后向用户说明云端已同步,并报告变更前后分数。 + +## 查询同学和分组 + +以下工具是隐藏工具,不会直接出现在工具列表中,必须通过 `secagent__call_hidden_tool` 调用。`name` 必须使用完整工具 key,不能自行改名: + +- `secscore-connector__list_students`:列出当前班级同学。参数可选 `query`、`group_name`、`limit`、`account_id`、`class_id`。 +- `secscore-connector__find_students`:按姓名搜索同学。参数 `query` 必填,可选 `account_id`、`class_id`。 +- `secscore-connector__list_groups`:列出当前班级分组和每组人数。参数可选 `account_id`、`class_id`。 +- `secscore-connector__list_group_members`:列出指定分组成员。参数 `group_name` 必填,可选 `account_id`、`class_id`。 + +例如,查询当前班级全部同学: + +```json +{ + "name": "secscore-connector__list_students", + "arguments": {} +} +``` + +如果隐藏工具返回 `error`,必须把失败原因告诉用户,不能宣称操作已完成。