diff --git a/hub/src/feishu/slashCommands.ts b/hub/src/feishu/slashCommands.ts new file mode 100644 index 0000000..4a9ca72 --- /dev/null +++ b/hub/src/feishu/slashCommands.ts @@ -0,0 +1,274 @@ +import type { PrismaClient } from "@prisma/client"; +import type { FastifyBaseLogger } from "fastify"; +import type { ModelRegistry, RoleEntry } from "../agent/models.js"; +import type { RuntimeSettings } from "../settings/runtime.js"; +import { sendText, type FeishuRuntime } from "./client.js"; +import type { TriggerQueue } from "./triggerQueue.js"; + +export interface SlashInvocation { + readonly name: string; + readonly args: readonly string[]; +} + +export interface SlashCommandRunContext { + readonly invocation: SlashInvocation; + readonly projectId: string; + readonly chatId: string; + readonly rt: FeishuRuntime; +} + +export interface SlashCommandDefinition { + readonly name: string; + readonly usage: string; + readonly summary: string; + readonly details: readonly string[]; + run(context: SlashCommandRunContext): Promise; +} + +export interface SlashCommandRegistryDeps { + readonly prisma: PrismaClient; + readonly settings: RuntimeSettings; + readonly logger: FastifyBaseLogger; + readonly triggerQueue: TriggerQueue; +} + +export function parseSlashInvocation(prompt: string): SlashInvocation | null { + const trimmed = prompt.trim(); + if (!trimmed.startsWith("/")) return null; + const tokens = trimmed.split(/\s+/); + const rawCommand = tokens[0]; + if (rawCommand === undefined || rawCommand.length <= 1) return null; + return { + name: rawCommand.slice(1), + args: tokens.slice(1), + }; +} + +export function parseSlashHelpSubcommand(invocation: SlashInvocation): string | null { + if (invocation.name === "help") return null; + return invocation.args.length === 1 && invocation.args[0] === "help" + ? invocation.name + : null; +} + +export function createSlashCommandRegistry(deps: SlashCommandRegistryDeps): ReadonlyMap { + const commands = new Map(); + const add = (command: SlashCommandDefinition): void => { + if (commands.has(command.name)) { + throw new Error(`duplicate slash command definition: /${command.name}`); + } + commands.set(command.name, command); + }; + + add({ + name: "help", + usage: "/help [command]", + summary: "查看可用 slash 命令或单个命令说明。", + details: [ + "不创建 agent run,也不改变当前会话。", + "支持 /help new 和 /new help 两种写法。", + ], + run: async ({ invocation, projectId, chatId, rt }) => { + const registry = await deps.settings.modelRegistry({ projectId }); + await sendText(rt, chatId, formatHelpCommandInvocation(invocation, registry, commands)); + }, + }); + + add({ + name: "new", + usage: "/new", + summary: "开新会话,下次 @bot 将从头开始。", + details: [ + "归档当前未归档的 agent session。", + "不会清空当前项目的等待队列。", + ], + run: async ({ projectId, chatId, rt }) => { + await deps.prisma.agentSession.updateMany({ + where: { projectId, archivedAt: null }, + data: { archivedAt: new Date() }, + }); + await sendText(rt, chatId, "已开新会话,下次 @bot 将从头开始。"); + }, + }); + + add({ + name: "resume", + usage: "/resume", + summary: "恢复最近一次已归档的会话。", + details: [ + "只恢复当前项目最近归档的 agent session。", + "没有可恢复会话时只回复提示,不会创建 agent run。", + ], + run: async ({ projectId, chatId, rt }) => { + const latest = await deps.prisma.agentSession.findFirst({ + where: { projectId, archivedAt: { not: null } }, + orderBy: { archivedAt: "desc" }, + select: { id: true }, + }); + if (latest === null) { + await sendText(rt, chatId, "没有可恢复的会话。"); + return; + } + await deps.prisma.agentSession.update({ + where: { id: latest.id }, + data: { archivedAt: null }, + }); + await sendText(rt, chatId, "已恢复上一个会话。"); + }, + }); + + add({ + name: "reset", + usage: "/reset", + summary: "重置当前会话并清空等待队列。", + details: [ + "归档当前未归档的 agent session。", + "清空当前项目已经排队、尚未开始的触发请求。", + ], + run: async ({ projectId, chatId, rt }) => { + await deps.prisma.agentSession.updateMany({ + where: { projectId, archivedAt: null }, + data: { archivedAt: new Date() }, + }); + const cleared = deps.triggerQueue.clear(projectId); + if (cleared > 0) { + deps.logger.info({ projectId, cleared }, "feishu trigger: cleared queued triggers on reset"); + } + await sendText(rt, chatId, "已重置,下次 @bot 将从头开始。"); + }, + }); + + return commands; +} + +function formatHelpCommandInvocation( + invocation: SlashInvocation, + registry: ModelRegistry, + slashCommands: ReadonlyMap, +): string { + if (invocation.args.length > 1) { + const helpCommand = slashCommands.get("help"); + if (helpCommand === undefined) { + throw new Error("slash command registry is missing /help"); + } + return [ + "用法错误: /help 只接受一个命令名。", + "", + formatBuiltinSlashCommandHelp(helpCommand), + ].join("\n"); + } + + const target = invocation.args[0]; + if (target === undefined) return formatSlashOverview(registry, slashCommands); + const normalizedTarget = normalizeHelpTarget(target); + if (normalizedTarget === "") return formatSlashOverview(registry, slashCommands); + return formatSlashHelpTarget(normalizedTarget, registry, slashCommands); +} + +export function formatSlashHelpTarget( + target: string, + registry: ModelRegistry, + slashCommands: ReadonlyMap, +): string { + const normalizedTarget = normalizeHelpTarget(target); + if (normalizedTarget === "") return formatSlashOverview(registry, slashCommands); + return formatSlashCommandHelp(normalizedTarget, registry, slashCommands) ?? formatUnknownSlashHelp(normalizedTarget, registry, slashCommands); +} + +function normalizeHelpTarget(target: string): string { + return target.trim().replace(/^\/+/, ""); +} + +function formatSlashOverview( + registry: ModelRegistry, + slashCommands: ReadonlyMap, +): string { + const lines = [ + "可用 slash 命令:", + ...[...slashCommands.values()].map((command) => `/${command.name} - ${command.summary}`), + ]; + const roles = visibleRoleCommands(registry, slashCommands); + if (roles.length > 0) { + lines.push("", "角色命令:"); + for (const role of roles) { + lines.push(`/${role.id} <需求> - 使用“${role.label}”角色发起请求。`); + } + } else { + lines.push("", "当前没有配置角色命令。"); + } + lines.push("", "查看单个命令: /help new 或 /new help。"); + return lines.join("\n"); +} + +function formatSlashCommandHelp( + commandName: string, + registry: ModelRegistry, + slashCommands: ReadonlyMap, +): string | null { + const normalizedName = normalizeHelpTarget(commandName); + const slashCommand = slashCommands.get(normalizedName); + if (slashCommand !== undefined) return formatBuiltinSlashCommandHelp(slashCommand); + + const role = registry.role(normalizedName); + if (role === undefined) return null; + if (slashCommands.has(role.id)) return null; + return formatRoleSlashCommandHelp(role); +} + +function formatBuiltinSlashCommandHelp(command: SlashCommandDefinition): string { + const helpUsage = command.name === "help" + ? "帮助: /help help" + : `帮助: /help ${command.name} 或 /${command.name} help`; + return [ + `/${command.name}`, + command.summary, + "", + `用法: ${command.usage}`, + ...command.details.map((detail) => `- ${detail}`), + "", + helpUsage, + ].join("\n"); +} + +function formatRoleSlashCommandHelp(role: RoleEntry): string { + const lines = [ + `/${role.id}`, + `使用“${role.label}”角色发起一次 agent run。`, + "", + `用法: /${role.id} <需求>`, + "- 真正运行时仍会按当前项目的 role.trigger 授权检查。", + ]; + if (role.defaultModel !== undefined) { + lines.push(`- 默认模型: ${role.defaultModel}`); + } + lines.push(`- 工具范围: ${roleToolsDescription(role)}`, "", `帮助: /help ${role.id} 或 /${role.id} help`); + return lines.join("\n"); +} + +function roleToolsDescription(role: RoleEntry): string { + if (role.tools === undefined) return "全部已注册工具"; + if (role.tools.length === 0) return "无"; + return role.tools.join(", "); +} + +function formatUnknownSlashHelp( + commandName: string, + registry: ModelRegistry, + slashCommands: ReadonlyMap, +): string { + const normalizedName = normalizeHelpTarget(commandName); + return [ + `未知 slash 命令 /${normalizedName}。`, + "", + formatSlashOverview(registry, slashCommands), + ].join("\n"); +} + +function visibleRoleCommands( + registry: ModelRegistry, + slashCommands: ReadonlyMap, +): readonly RoleEntry[] { + return registry + .listRoles() + .filter((role) => !slashCommands.has(role.id)); +} diff --git a/hub/src/feishu/trigger.ts b/hub/src/feishu/trigger.ts index 44a5971..cb8bcbd 100644 --- a/hub/src/feishu/trigger.ts +++ b/hub/src/feishu/trigger.ts @@ -38,6 +38,7 @@ import { MessageBatcher, messageBatchKey, type MessageBatcherOptions } from "./m import { ApprovalManager } from "./approval.js"; import { SenderNameCache } from "./senderCache.js"; import { TriggerQueue, triggerQueue as defaultTriggerQueue, type QueuedTrigger } from "./triggerQueue.js"; +import { createSlashCommandRegistry, formatSlashHelpTarget, parseSlashHelpSubcommand, parseSlashInvocation } from "./slashCommands.js"; export { ApprovalManager } from "./approval.js"; export type { ApprovalResult, PendingApproval } from "./approval.js"; @@ -87,6 +88,12 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler { const runAgent = deps.runAgent ?? defaultRunAgent; const approvalManager = new ApprovalManager(); const triggerQueue = deps.triggerQueue ?? defaultTriggerQueue; + const slashCommands = createSlashCommandRegistry({ + prisma: deps.prisma, + settings: deps.settings, + logger: deps.logger, + triggerQueue, + }); const batchContexts = new Map(); // runId → AbortController for live runs. Registered when a run starts, // used by the interrupt card action to abort the SDK query, cleared in the @@ -676,48 +683,23 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler { // batcher. Session commands bypass the lock because they don't create runs; // role/unknown slash prompts still use the normal run path and queue if locked. if (cleanPrompt.startsWith("/")) { - const cmd = cleanPrompt.split(/\s+/)[0]; - switch (cmd) { - case "/new": { - await deps.prisma.agentSession.updateMany({ - where: { projectId, archivedAt: null }, - data: { archivedAt: new Date() }, - }); - await sendText(rt, chatId, "已开新会话,下次 @bot 将从头开始。"); - return; - } - case "/resume": { - const latest = await deps.prisma.agentSession.findFirst({ - where: { projectId, archivedAt: { not: null } }, - orderBy: { archivedAt: "desc" }, - select: { id: true }, - }); - if (latest === null) { - await sendText(rt, chatId, "没有可恢复的会话。"); - return; - } - await deps.prisma.agentSession.update({ - where: { id: latest.id }, - data: { archivedAt: null }, - }); - await sendText(rt, chatId, "已恢复上一个会话。"); - return; - } - case "/reset": { - await deps.prisma.agentSession.updateMany({ - where: { projectId, archivedAt: null }, - data: { archivedAt: new Date() }, - }); - const cleared = triggerQueue.clear(projectId); - if (cleared > 0) { - deps.logger.info({ projectId, cleared }, "feishu trigger: cleared queued triggers on reset"); - } - await sendText(rt, chatId, "已重置,下次 @bot 将从头开始。"); - return; - } - default: - break; + const invocation = parseSlashInvocation(cleanPrompt); + const helpSubcommandTarget = invocation === null ? null : parseSlashHelpSubcommand(invocation); + if (helpSubcommandTarget !== null) { + // Help is generated on demand because role/tool configuration is runtime data. + const models = await deps.settings.modelRegistry({ projectId }); + await sendText(rt, chatId, formatSlashHelpTarget(helpSubcommandTarget, models, slashCommands)); + return; } + + if (invocation !== null) { + const slashCommand = slashCommands.get(invocation.name); + if (slashCommand !== undefined) { + await slashCommand.run({ invocation, projectId, chatId, rt }); + return; + } + } + await startAgentRun(runContext, cleanPrompt); return; } @@ -1037,11 +1019,12 @@ async function downloadPostMessageFiles(args: { await downloadMessageFile(args.rt, args.msg.message_id, fileKey, savePath); } } + /** * Parse a leading `/` command from a prompt (e.g. `/draft 帮我写教案`). * Returns the role id and the remaining prompt with the command stripped. - * Control commands already handled upstream (`/new`, `/resume`, `/reset`) are - * ignored here — they never reach this function. + * Control/help commands already handled upstream are ignored here — they never + * reach this function. * * Unknown `/foo` that isn't a registered role: returns `null` role and the * original prompt unchanged (the slash is treated as literal text). Role diff --git a/hub/test/integration/trigger.test.ts b/hub/test/integration/trigger.test.ts index d8c3644..585fd0a 100644 --- a/hub/test/integration/trigger.test.ts +++ b/hub/test/integration/trigger.test.ts @@ -168,6 +168,121 @@ describe("trigger full lifecycle (integration)", () => { expect(await prisma.agentRun.findMany()).toHaveLength(0); }); + it("/help lists control and role commands without creating a run", async () => { + await seedProject("proj-help", "chat-help"); + const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } }); + + await trigger(makeEvent("chat-help", "@_user_1 /help"), rt); + + const helpText = rt.sentTexts.at(-1) ?? ""; + expect(helpText).toContain("可用 slash 命令"); + expect(helpText).toContain("/new"); + expect(helpText).toContain("/reset"); + expect(helpText).toContain("/draft <需求>"); + expect(helpText).toContain("/review <需求>"); + expect(runAgentCalls).toHaveLength(0); + expect(await prisma.agentRun.findMany()).toHaveLength(0); + }); + + it("/help is built from the current registry on each request", async () => { + await seedProject("proj-help-live", "chat-help-live"); + let currentModels = new InMemoryModelRegistry( + [{ id: "mock-model", label: "Mock", toolCapable: true }], + [{ id: "draft", label: "草稿", defaultModel: "mock-model", systemPrompt: undefined, tools: undefined }], + ); + const dynamicSettings: RuntimeSettings = { + async provider(providerId, scope) { + return settings.provider(providerId, scope); + }, + async modelRegistry() { + return currentModels; + }, + async runPolicy(input) { + return settings.runPolicy(input); + }, + }; + const trigger = makeTriggerHandler({ prisma, settings: dynamicSettings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } }); + + await trigger(makeEvent("chat-help-live", "@_user_1 /help"), rt); + expect(rt.sentTexts.at(-1) ?? "").not.toContain("/coach <需求>"); + + currentModels = new InMemoryModelRegistry( + [{ id: "mock-model", label: "Mock", toolCapable: true }], + [ + { id: "draft", label: "草稿", defaultModel: "mock-model", systemPrompt: undefined, tools: undefined }, + { id: "coach", label: "教练", defaultModel: "mock-model", systemPrompt: undefined, tools: [] }, + ], + ); + + await trigger(makeEvent("chat-help-live", "@_user_1 /help"), rt); + + const helpText = rt.sentTexts.at(-1) ?? ""; + expect(helpText).toContain("/coach <需求>"); + expect(runAgentCalls).toHaveLength(0); + expect(await prisma.agentRun.findMany()).toHaveLength(0); + }); + + it("/help returns command-specific help", async () => { + await seedProject("proj-help-reset", "chat-help-reset"); + const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } }); + + await trigger(makeEvent("chat-help-reset", "@_user_1 /help reset"), rt); + + const helpText = rt.sentTexts.at(-1) ?? ""; + expect(helpText).toContain("/reset"); + expect(helpText).toContain("清空当前项目已经排队"); + expect(helpText).toContain("/reset help"); + expect(runAgentCalls).toHaveLength(0); + expect(await prisma.agentRun.findMany()).toHaveLength(0); + }); + + it("control commands support a help subcommand", async () => { + await seedProject("proj-new-help", "chat-new-help"); + const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } }); + + await trigger(makeEvent("chat-new-help", "@_user_1 /new help"), rt); + + const helpText = rt.sentTexts.at(-1) ?? ""; + expect(helpText).toContain("/new"); + expect(helpText).toContain("归档当前未归档"); + expect(helpText).toContain("/help new"); + expect(rt.sentTexts).not.toContain("已开新会话,下次 @bot 将从头开始。"); + expect(runAgentCalls).toHaveLength(0); + expect(await prisma.agentRun.findMany()).toHaveLength(0); + }); + + it("role commands support a help subcommand without consuming role grants", async () => { + await seedProject("proj-role-help", "chat-role-help"); + await prisma.roleTriggerGrant.create({ + data: { projectId: "proj-role-help", roleId: "review", principalType: "USER", principalId: "ou_other" }, + }); + const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } }); + + await trigger(makeEvent("chat-role-help", "@_user_1 /review help"), rt); + + const helpText = rt.sentTexts.at(-1) ?? ""; + expect(helpText).toContain("/review"); + expect(helpText).toContain("使用“审校”角色"); + expect(helpText).toContain("工具范围: read_file"); + expect(rt.sentTexts).not.toContain("无权限使用角色 review。"); + expect(runAgentCalls).toHaveLength(0); + expect(await prisma.agentRun.findMany()).toHaveLength(0); + }); + + it("/help unknown and /unknown help report the unknown command", async () => { + await seedProject("proj-help-unknown", "chat-help-unknown"); + const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } }); + + await trigger(makeEvent("chat-help-unknown", "@_user_1 /help unknown"), rt); + expect(rt.sentTexts.at(-1) ?? "").toContain("未知 slash 命令 /unknown"); + expect(runAgentCalls).toHaveLength(0); + + await trigger(makeEvent("chat-help-unknown", "@_user_1 /unknown help"), rt); + expect(rt.sentTexts.at(-1) ?? "").toContain("未知 slash 命令 /unknown"); + expect(runAgentCalls).toHaveLength(0); + expect(await prisma.agentRun.findMany()).toHaveLength(0); + }); + it("rejects a sender without edit grant (ADR-0004)", async () => { await seedProject("proj-2", "chat-2", { role: "READ" }); const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } }); diff --git a/hub/test/unit/slash-commands.test.ts b/hub/test/unit/slash-commands.test.ts new file mode 100644 index 0000000..6a91924 --- /dev/null +++ b/hub/test/unit/slash-commands.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { parseSlashHelpSubcommand, parseSlashInvocation } from "../../src/feishu/slashCommands.js"; + +describe("slash command parser", () => { + it("parses a slash invocation without resolving it", () => { + expect(parseSlashInvocation("/new")).toEqual({ name: "new", args: [] }); + expect(parseSlashInvocation("/review 看看这节")).toEqual({ name: "review", args: ["看看这节"] }); + expect(parseSlashInvocation("写教案")).toBeNull(); + }); + + it("parses help subcommands only in the exact / help form", () => { + expect(parseSlashHelpSubcommand({ name: "new", args: ["help"] })).toBe("new"); + expect(parseSlashHelpSubcommand({ name: "unknown", args: ["help"] })).toBe("unknown"); + expect(parseSlashHelpSubcommand({ name: "new", args: ["help", "please"] })).toBeNull(); + expect(parseSlashHelpSubcommand({ name: "help", args: ["new"] })).toBeNull(); + }); +});