From 351e8ea3008c5390343193aa0102204d7fd51ab2 Mon Sep 17 00:00:00 2001 From: HNGM-HP <542869290@qq.com> Date: Sun, 8 Mar 2026 20:59:57 +0800 Subject: [PATCH 1/5] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=BE=A4=E8=81=8A?= =?UTF-8?q?=E9=9C=80@=E5=9C=BA=E6=99=AF=E4=B8=8B=E6=9D=83=E9=99=90?= =?UTF-8?q?=E6=96=87=E6=9C=AC=E7=A1=AE=E8=AE=A4=E8=A2=AB=E6=8B=A6=E6=88=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/router/action-handlers.ts | 8 ++++ src/router/root-router.ts | 69 +++++++++++++++++++------------ tests/root-router-mention.test.ts | 18 ++++++++ 3 files changed, 68 insertions(+), 27 deletions(-) diff --git a/src/router/action-handlers.ts b/src/router/action-handlers.ts index d99f877..6273ab8 100644 --- a/src/router/action-handlers.ts +++ b/src/router/action-handlers.ts @@ -187,6 +187,10 @@ export function createPermissionActionCallbacks( }; } + console.log( + `[权限] 卡片响应成功: session=${sessionId}, permission=${permissionId}, allow=${allow}, remember=${remember}` + ); + const permissionChatId = chatSessionStore.getChatId(sessionId); if (permissionChatId) { const bufferKey = `chat:${permissionChatId}`; @@ -246,6 +250,10 @@ export function createPermissionActionCallbacks( return true; } + console.log( + `[权限] 文本响应成功: chat=${event.chatId}, session=${pending.sessionId}, permission=${pending.permissionId}, allow=${decision.allow}, remember=${decision.remember}` + ); + const removed = permissionHandler.resolveForChat(event.chatId, pending.permissionId); const bufferKey = `chat:${event.chatId}`; if (!outputBuffer.get(bufferKey)) { diff --git a/src/router/root-router.ts b/src/router/root-router.ts index 2726216..5678dd8 100644 --- a/src/router/root-router.ts +++ b/src/router/root-router.ts @@ -116,50 +116,65 @@ export class RootRouter { */ async onMessage(event: FeishuMessageEvent | PlatformMessageEvent): Promise { const feishuEvent = event as FeishuMessageEvent; + try { + if (feishuEvent.chatType === 'group' && this.permissionCallbacks) { + const handled = await this.permissionCallbacks.tryHandlePendingPermissionByText(feishuEvent); + if (handled) { + if (routerConfig.mode === 'dual') { + const sessionId = chatSessionStore.getSessionId(feishuEvent.chatId) ?? 'none'; + const conversationKey = `feishu:${feishuEvent.chatId}`; + console.log(JSON.stringify({ + type: '[Router][dual]', + event: 'onMessage', + platform: 'feishu', + conversationKey, + sessionId, + routeDecision: 'permission_text', + chatType: feishuEvent.chatType, + chatId: feishuEvent.chatId, + })); + } + return; + } + } + + if (this.shouldSkipGroupMessage(feishuEvent)) { + if (routerConfig.mode === 'dual') { + const sessionId = chatSessionStore.getSessionId(feishuEvent.chatId) ?? 'none'; + const conversationKey = `feishu:${feishuEvent.chatId}`; + console.log(JSON.stringify({ + type: '[Router][dual]', + event: 'onMessage', + platform: 'feishu', + conversationKey, + sessionId, + routeDecision: 'group_skip_no_mention', + chatType: feishuEvent.chatType, + chatId: feishuEvent.chatId, + })); + } + return; + } - if (this.shouldSkipGroupMessage(feishuEvent)) { if (routerConfig.mode === 'dual') { const sessionId = chatSessionStore.getSessionId(feishuEvent.chatId) ?? 'none'; const conversationKey = `feishu:${feishuEvent.chatId}`; + const routeDecision = feishuEvent.chatType === 'p2p' ? 'p2p' : 'group'; console.log(JSON.stringify({ type: '[Router][dual]', event: 'onMessage', platform: 'feishu', conversationKey, sessionId, - routeDecision: 'group_skip_no_mention', + routeDecision, chatType: feishuEvent.chatType, - chatId: feishuEvent.chatId, + chatId: feishuEvent.chatId })); } - return; - } - if (routerConfig.mode === 'dual') { - const sessionId = chatSessionStore.getSessionId(feishuEvent.chatId) ?? 'none'; - const conversationKey = `feishu:${feishuEvent.chatId}`; - const routeDecision = feishuEvent.chatType === 'p2p' ? 'p2p' : 'group'; - console.log(JSON.stringify({ - type: '[Router][dual]', - event: 'onMessage', - platform: 'feishu', - conversationKey, - sessionId, - routeDecision, - chatType: feishuEvent.chatType, - chatId: feishuEvent.chatId - })); - } - - try { if (feishuEvent.chatType === 'p2p') { await p2pHandler.handleMessage(feishuEvent); } else if (feishuEvent.chatType === 'group') { - // 权限文本处理委托给回调 - if (this.permissionCallbacks) { - const handled = await this.permissionCallbacks.tryHandlePendingPermissionByText(feishuEvent); - if (handled) return; - } await groupHandler.handleMessage(feishuEvent); } } catch (error) { diff --git a/tests/root-router-mention.test.ts b/tests/root-router-mention.test.ts index e667928..5eea86e 100644 --- a/tests/root-router-mention.test.ts +++ b/tests/root-router-mention.test.ts @@ -93,4 +93,22 @@ describe('RootRouter mention gate', () => { await rootRouter.onMessage({ ...baseEvent, mentions: undefined }); expect(spy).toHaveBeenCalledTimes(1); }); + + it('GROUP_REQUIRE_MENTION=true 时应优先处理权限文本回调', async () => { + backupEnv(); + process.env.GROUP_REQUIRE_MENTION = 'true'; + + const { rootRouter, groupHandler } = await loadRouterAndGroup(); + const groupSpy = vi.spyOn(groupHandler, 'handleMessage').mockResolvedValue(undefined); + const permissionSpy = vi.fn().mockResolvedValue(true); + + rootRouter.setPermissionCallbacks({ + handlePermissionAction: async () => ({ msg: 'ok' }), + tryHandlePendingPermissionByText: permissionSpy, + }); + + await rootRouter.onMessage({ ...baseEvent, mentions: undefined, content: '允许' }); + expect(permissionSpy).toHaveBeenCalledTimes(1); + expect(groupSpy).not.toHaveBeenCalled(); + }); }); From ad03abd1a593fb1d6b36ecae538a9ac2557581b0 Mon Sep 17 00:00:00 2001 From: HNGM-HP <542869290@qq.com> Date: Sun, 8 Mar 2026 21:08:32 +0800 Subject: [PATCH 2/5] =?UTF-8?q?=E7=BB=9F=E4=B8=80=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E5=8F=B7=E4=B8=BA=20v2.8.3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 14 +++++++------- assets/docs/architecture.md | 2 +- assets/docs/sdk-api.md | 2 +- assets/docs/workspace-guide.md | 2 +- package-lock.json | 4 ++-- package.json | 2 +- src/index.ts | 2 +- 7 files changed, 14 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 62a7807..8f7ed14 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ -# 飞书 × OpenCode 桥接服务 v2.8.3-beta (Group) +# 飞书 × OpenCode 桥接服务 v2.8.3 (Group) -[![v2.8.3--beta](https://img.shields.io/badge/v2.8.3--beta-3178C6)]() +[![v2.8.3](https://img.shields.io/badge/v2.8.3-3178C6)]() [![Node.js >= 18](https://img.shields.io/badge/Node.js-%3E%3D18-339933?logo=node.js&logoColor=white)](https://nodejs.org/) [![TypeScript](https://img.shields.io/badge/TypeScript-5.x-3178C6?logo=typescript&logoColor=white)](https://www.typescriptlang.org/) [![License: GPLv3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0) -这不是“换个文案”的小版本,而是一次架构换代。`v2.8.3-beta` 将桥接核心从“单文件堆逻辑”重构为“平台适配层 + 根路由器 + OpenCode 事件中枢 + 领域处理器”的分层体系,重点解决跨平台扩展、权限闭环稳定性、目录实例一致性和线上可维护性。 +这不是“换个文案”的小版本,而是一次架构换代。`v2.8.3` 将桥接核心从“单文件堆逻辑”重构为“平台适配层 + 根路由器 + OpenCode 事件中枢 + 领域处理器”的分层体系,重点解决跨平台扩展、权限闭环稳定性、目录实例一致性和线上可维护性。 ## 🎯 先看痛点 @@ -24,7 +24,7 @@ > 结论:如果目标只是“在聊天里接一个 AI”,很多桥接都能满足;如果你要的是“权限/提问/会话/目录/回滚/运维”一体化工程链路,这个项目更适合生产化长期使用。 -| 维度 | OpenClaw / 同类桥接常见形态 | 本项目(v2.8.3-beta) | +| 维度 | OpenClaw / 同类桥接常见形态 | 本项目(v2.8.3) | |---|---|---| | 架构形态 | 消息通路优先,功能按需叠加 | 平台适配 + RootRouter + EventHub + Domain 分层 | | 平台扩展 | 单平台能力迁移成本高 | Feishu / Discord 平台差异显式建模,能力独立演进 | @@ -207,7 +207,7 @@ flowchart TB - [项目架构](assets/docs/architecture.md) - [OpenCode-sdk-api](assets/docs/sdk-api.md) -### 分层说明(v2.8.3-beta) +### 分层说明(v2.8.3) 1. **平台接入层(Adapter)** - Feishu: 长连接事件 + 卡片交互,能力完整(权限/问题/流式卡片)。 @@ -434,7 +434,7 @@ node scripts/deploy.mjs status - 用户可通过 `/session new frontend` 使用别名创建会话,无需记忆完整路径。 - 别名路径同样受 `ALLOWED_DIRECTORIES` 约束。 -### Discord 接入说明(v2.8.3-beta) +### Discord 接入说明(v2.8.3) 启用 Discord 需要同时满足: @@ -670,7 +670,7 @@ Discord 侧推荐命令(优先 `///` 前缀,避免与原生 Slash 冲突) - 目录优先级:显式指定 > 项目别名 > 群默认 > 全局默认 > OpenCode 服务端默认。 ## 🚀 灰度部署与回滚 SOP -> **注意**: 本章节适用于 v2.8.3-beta+ 版本,涉及路由器模式的灰度升级流程。 +> **注意**: 本章节适用于 v2.8.3 版本,涉及路由器模式的灰度升级流程。 ### 1. 路由器模式配置 diff --git a/assets/docs/architecture.md b/assets/docs/architecture.md index 7c44235..3d0c6cb 100644 --- a/assets/docs/architecture.md +++ b/assets/docs/architecture.md @@ -1,4 +1,4 @@ -# 飞书 × OpenCode 桥接架构(v2.8.3-beta) +# 飞书 × OpenCode 桥接架构(v2.8.3) 本文档描述当前版本的真实运行架构,重点说明平台接入、路由调度、事件闭环、目录一致性和可运维策略。 diff --git a/assets/docs/sdk-api.md b/assets/docs/sdk-api.md index c4ffa4d..db1b834 100644 --- a/assets/docs/sdk-api.md +++ b/assets/docs/sdk-api.md @@ -1,4 +1,4 @@ -# OpenCode SDK 集成说明(桥接侧,v2.8.3-beta) +# OpenCode SDK 集成说明(桥接侧,v2.8.3) 本文档不是官方 SDK 全量手册,而是本项目在 `src/opencode/client.ts` 中的实际封装与调用约定。 diff --git a/assets/docs/workspace-guide.md b/assets/docs/workspace-guide.md index 15728cd..f57c15d 100644 --- a/assets/docs/workspace-guide.md +++ b/assets/docs/workspace-guide.md @@ -1,4 +1,4 @@ -# 工作目录与项目策略指南(v2.8.3-beta) +# 工作目录与项目策略指南(v2.8.3) 本文档说明桥接服务当前的工作目录策略、命令入口和安全约束。 diff --git a/package-lock.json b/package-lock.json index 3ccb5af..178c35c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "feishu-opencode-bridge", - "version": "2.8.3-beta", + "version": "2.8.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "feishu-opencode-bridge", - "version": "2.8.3-beta", + "version": "2.8.3", "license": "GPLv3", "dependencies": { "@larksuiteoapi/node-sdk": "^1.36.3", diff --git a/package.json b/package.json index c1216cb..c782757 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "feishu-opencode-bridge", - "version": "2.8.3-beta", + "version": "2.8.3", "type": "module", "description": "飞书机器人 × OpenCode 桥接服务,通过飞书聊天控制OpenCode", "main": "dist/index.js", diff --git a/src/index.ts b/src/index.ts index d99e983..d7a7535 100644 --- a/src/index.ts +++ b/src/index.ts @@ -31,7 +31,7 @@ import { async function main() { console.log('╔════════════════════════════════════════════════╗'); - console.log('║ 飞书 × OpenCode 桥接服务 v2.8.3-beta (Group) ║'); + console.log('║ 飞书 × OpenCode 桥接服务 v2.8.3 (Group) ║'); console.log('╚════════════════════════════════════════════════╝'); // 1. 验证配置 From 0ef2522c405d853be3ae367cabb4bb62a453e19d Mon Sep 17 00:00:00 2001 From: pengbo Date: Sat, 28 Feb 2026 17:50:36 +0800 Subject: [PATCH 3/5] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E5=91=BD?= =?UTF-8?q?=E4=BB=A4=E4=B8=AD=E5=BF=83=E4=B8=8E=E5=8F=8C=E6=96=9C=E6=9D=A0?= =?UTF-8?q?=E9=80=8F=E4=BC=A0=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 // 前缀命令解析,支持命名空间透传(如 //superpowers:brainstorming) - 新增 /commands 命令,生成命令清单 Markdown 文件并发送到飞书 - 新增 command-doc.ts 命令文档生成器,输出表格格式(分组、分列) - 新增 OpencodeClient.getCommands 方法,动态获取可用命令列表 - 新增系统生成文件豁免机制,绕过 ALLOWED_DIRECTORIES 白名单校验 - 修复透传命令大小写问题,保留原始命令名(OpenCode 区分大小写) - README 新增命令中心和双斜杠透传使用说明 --- README.md | 2 + src/commands/command-doc.ts | 54 ++++++++++++++++++ src/commands/parser.ts | 60 +++++++++++++++++++- src/feishu/cards.ts | 3 +- src/handlers/command.ts | 109 ++++++++++++++++++++++++++++++++++++ src/handlers/file-sender.ts | 26 +++++++-- src/handlers/p2p.ts | 34 ++++------- src/index.ts | 13 +++++ src/opencode/client.ts | 19 +++++++ 9 files changed, 289 insertions(+), 31 deletions(-) create mode 100644 src/commands/command-doc.ts diff --git a/README.md b/README.md index 544f75a..d4f4440 100644 --- a/README.md +++ b/README.md @@ -823,6 +823,7 @@ npm test -- tests/reliability-rescue.e2e.test.ts |---|---| | `/help` | 查看帮助 | | `/panel` | 打开控制面板(模型、角色、强度状态、停止、撤回) | +| `/commands` | 生成并发送最新命令清单文件 | | `/model` | 查看当前模型 | | `/model ` | 切换模型(支持 `provider/model`) | | `/effort` | 查看当前会话推理强度与当前模型可选档位 | @@ -853,6 +854,7 @@ npm test -- tests/reliability-rescue.e2e.test.ts | `/clear free session ` / `/clear_free_session ` | 删除指定 OpenCode 会话,并移除所有本地绑定映射与该会话绑定的 Cron | | `/compact` | 调用 OpenCode summarize,压缩当前会话上下文 | | `!` | 透传白名单 shell 命令(如 `!ls`、`!pwd`、`!mkdir`、`!git status`) | +| `//<命令名>` | 透传命名空间 slash 命令(如 `//superpowers:brainstorming`) | | `/create_chat` / `/建群` | 私聊中调出建群卡片(下拉选择后点击"创建群聊"生效) | | `/send <绝对路径>` | 发送指定路径的文件到当前群聊 | | `/restart opencode` | 重启本地 OpenCode 进程(仅 loopback) | diff --git a/src/commands/command-doc.ts b/src/commands/command-doc.ts new file mode 100644 index 0000000..ba17fea --- /dev/null +++ b/src/commands/command-doc.ts @@ -0,0 +1,54 @@ +import { mkdir, writeFile } from 'fs/promises'; +import path from 'path'; + +export interface CommandDocItem { + name: string; + description?: string; + source?: string; +} + +export interface CommandDocData { + updatedAt: string; + total: number; + groups: Record; +} + +export const COMMAND_DOC_PATH = path.resolve('docs', 'generated', 'commands.md'); + +export async function writeCommandDoc(data: CommandDocData): Promise { + await mkdir(path.dirname(COMMAND_DOC_PATH), { recursive: true }); + const lines: string[] = []; + lines.push('# OpenCode 命令清单'); + lines.push(''); + lines.push(`更新时间:${data.updatedAt}`); + lines.push(`命令总数:${data.total}`); + lines.push(''); + + const groupConfig: Array<{ key: string; title: string }> = [ + { key: 'command', title: '内置命令' }, + { key: 'mcp', title: 'MCP 命令' }, + { key: 'skill', title: '技能命令' }, + { key: 'other', title: '其他' }, + ]; + + for (const { key, title } of groupConfig) { + const items = data.groups[key] || []; + if (items.length === 0) continue; + lines.push(`## ${title}`); + lines.push(`共 ${items.length} 条`); + lines.push(''); + lines.push('| 命令 | 描述 |'); + lines.push('| --- | --- |'); + for (const item of items) { + const desc = item.description || '-'; + // 表格内换行用
,管道符转义 + const safeDesc = desc.replace(/\|/g, '\\|').replace(/\n/g, '
'); + lines.push(`| \`/${item.name}\` | ${safeDesc} |`); + } + lines.push(''); + } + + const content = `${lines.join('\n').trim()}\n`; + await writeFile(COMMAND_DOC_PATH, content, 'utf8'); + return COMMAND_DOC_PATH; +} diff --git a/src/commands/parser.ts b/src/commands/parser.ts index 6732ae0..d222af3 100644 --- a/src/commands/parser.ts +++ b/src/commands/parser.ts @@ -17,6 +17,7 @@ export type CommandType = | 'session' // 会话操作 | 'project' // 项目/目录操作 | 'sessions' // 列出会话 + | 'commands' // 列出可用命令 | 'clear' // 清空对话 | 'panel' // 控制面板 | 'effort' // 调整推理强度 @@ -95,6 +96,44 @@ function isSlashCommandToken(token: string): boolean { return /^[\p{L}\p{N}_.?-]+$/u.test(normalized); } +function isDoubleSlashCommandToken(token: string): boolean { + const normalized = token.trim(); + if (!normalized) { + return false; + } + + if (normalized.includes('/') || normalized.includes('\\')) { + return false; + } + + // 双斜杠透传允许命名空间(:) + return /^[\p{L}\p{N}_.?:-]+$/u.test(normalized); +} + +function parseDoubleSlashCommand(trimmed: string): ParsedCommand | null { + if (!trimmed.startsWith('//')) { + return null; + } + + const body = trimmed.slice(2).trimStart(); + if (!body || body.includes('\n')) { + return null; + } + + const parts = body.split(/\s+/); + const first = parts[0]?.trim(); + if (!first || !isDoubleSlashCommandToken(first)) { + return null; + } + + return { + type: 'command', + commandName: first, // 保留原始大小写,OpenCode 命令区分大小写 + commandArgs: parts.slice(1).join(' '), + commandPrefix: '/', + }; +} + function parseBangShellCommand(trimmed: string): ParsedCommand | null { if (!trimmed.startsWith('!')) { return null; @@ -136,6 +175,12 @@ export function parseCommand(text: string): ParsedCommand { const trimmed = text.trim(); const lower = trimmed.toLowerCase(); + // //xxx:yyy 透传命令(用于支持带命名空间的 slash) + const doubleSlashCommand = parseDoubleSlashCommand(trimmed); + if (doubleSlashCommand) { + return doubleSlashCommand; + } + // ! 开头的 shell 透传(白名单) const bangCommand = parseBangShellCommand(trimmed); if (bangCommand) { @@ -186,7 +231,8 @@ export function parseCommand(text: string): ParsedCommand { return { type: 'prompt', text: trimmed }; } - const cmd = parts[0].toLowerCase(); + const originalCmd = parts[0]; // 保留原始大小写 + const cmd = originalCmd.toLowerCase(); // 用于 switch 匹配 const args = parts.slice(1); const rawArgsText = body.slice(parts[0].length).trim(); @@ -271,6 +317,12 @@ export function parseCommand(text: string): ParsedCommand { case 'list': return { type: 'sessions', listAll: args.length > 0 && args[0].toLowerCase() === 'all' }; + case 'commands': + case 'slash': + case 'slash-commands': + case 'slash_commands': + return { type: 'commands' }; + case 'project': if (args.length === 0) { return { type: 'project', projectAction: 'list' }; @@ -391,10 +443,10 @@ export function parseCommand(text: string): ParsedCommand { } default: - // 未知命令透传到OpenCode + // 未知命令透传到OpenCode(保留原始大小写) return { type: 'command', - commandName: cmd, + commandName: originalCmd, commandArgs: args.join(' '), commandPrefix: '/', }; @@ -447,6 +499,7 @@ export function getHelpText(): string { • \`/project list\` 列出可用项目;\`/project default\` 查看/设置/清除群默认项目 • \`/clear\` 等价 \`/session new\`;\`/clear free session\` 清理空闲群聊并手动扫描僵尸 Cron • \`/status\` 查看当前绑定状态和群聊生命周期信息 + • \`/commands\` 生成并发送最新命令清单文件 💡 **提示** • 切换的模型/角色仅对**当前会话**生效。 @@ -454,6 +507,7 @@ export function getHelpText(): string { • \`/cron\` 支持自然语言,复杂语义默认交给 OpenCode 解析后再落盘为调度任务。 • Cron 默认绑定创建它的聊天窗口与当前 OpenCode 会话;如果当前聊天没有绑定会话,将拒绝创建。 • 其他未知 \`/xxx\` 命令会自动透传给 OpenCode(会话已绑定时生效)。 + • 支持 \`//xxx\` 形式透传命名空间命令(如 \`//superpowers:brainstorming\`)。 • 支持透传白名单 shell 命令:\`!cd\`、\`!ls\`、\`!mkdir\`、\`!rm\`、\`!cp\`、\`!mv\`、\`!git\` 等;\`!vi\` / \`!vim\` / \`!nano\` 不会透传。 • 如果遇到问题,试着使用 \`/panel\` 面板操作更方便。 diff --git a/src/feishu/cards.ts b/src/feishu/cards.ts index d11ec03..5c8eb72 100644 --- a/src/feishu/cards.ts +++ b/src/feishu/cards.ts @@ -541,9 +541,10 @@ function buildCreateChatSelectorElements(data: CreateChatCardData): object[] { }); // 3. 工作项目选择器(可选) + const projectCandidates = data.projectOptions || []; const projectOpts = [ { text: { tag: 'plain_text', content: '跟随默认项目' }, value: '__default__' }, - ...(data.projectOptions || []).map(project => ({ + ...projectCandidates.map(project => ({ text: { tag: 'plain_text', content: `${project.name}(${project.directory.length > 40 ? '...' + project.directory.slice(-37) : project.directory})`, diff --git a/src/handlers/command.ts b/src/handlers/command.ts index aa210b2..2b5657c 100644 --- a/src/handlers/command.ts +++ b/src/handlers/command.ts @@ -11,6 +11,7 @@ import { } from '../opencode/client.js'; import { chatSessionStore } from '../store/chat-session.js'; import { buildControlCard, buildStatusCard } from '../feishu/cards.js'; +import { writeCommandDoc, type CommandDocData } from '../commands/command-doc.js'; import { modelConfig, userConfig } from '../config.js'; import { sendFileToFeishu } from './file-sender.js'; import { lifecycleHandler } from './lifecycle.js'; @@ -836,6 +837,10 @@ export class CommandHandler { case 'panel': await this.handlePanel(chatId, messageId, context.chatType); break; + + case 'commands': + await this.handleCommandsCard(chatId, messageId); + break; case 'sessions': await this.handleListSessions(chatId, messageId, command.listAll); @@ -1818,6 +1823,110 @@ export class CommandHandler { }); } + private buildCommandListText(commands: Array<{ name: string; description?: string; source?: string; hints?: string[] }>): string { + if (!commands || commands.length === 0) { + return '当前未获取到可用命令列表。'; + } + + const groups: Record> = { + command: [], + mcp: [], + skill: [], + other: [], + }; + + for (const cmd of commands) { + const source = cmd.source || 'other'; + const target = groups[source] || groups.other; + target.push(cmd); + } + + const lines: string[] = ['📚 OpenCode 可用命令']; + const pushGroup = (label: string, items: Array<{ name: string; description?: string; hints?: string[] }>): void => { + if (items.length === 0) return; + const sorted = items.slice().sort((a, b) => a.name.localeCompare(b.name)); + lines.push(`\n【${label}】`); + for (const item of sorted) { + const desc = item.description ? ` - ${item.description}` : ''; + lines.push(`• /${item.name}${desc}`); + } + }; + + pushGroup('内置', groups.command); + pushGroup('MCP', groups.mcp); + pushGroup('技能', groups.skill); + pushGroup('其他', groups.other); + + lines.push('\n💡 支持 // 命名空间透传,例如://superpowers:brainstorming'); + return lines.join('\n'); + } + + private splitLongText(text: string, maxLength: number): string[] { + const trimmed = text.trim(); + if (!trimmed) { + return []; + } + + if (trimmed.length <= maxLength) { + return [trimmed]; + } + + const lines = trimmed.split('\n'); + const chunks: string[] = []; + let buffer = ''; + for (const line of lines) { + const next = buffer ? `${buffer}\n${line}` : line; + if (next.length > maxLength && buffer) { + chunks.push(buffer); + buffer = line; + } else { + buffer = next; + } + } + + if (buffer.trim().length > 0) { + chunks.push(buffer); + } + + return chunks; + } + + private async handleCommandsCard(chatId: string, messageId: string): Promise { + try { + const commands = await opencodeClient.getCommands(); + const groups: Record> = { + command: [], + mcp: [], + skill: [], + other: [], + }; + for (const cmd of commands) { + const source = cmd.source || 'other'; + const target = groups[source] || groups.other; + target.push(cmd); + } + + const docData: CommandDocData = { + updatedAt: new Date().toLocaleString('zh-CN', { hour12: false }), + total: commands.length, + groups, + }; + + const docPath = await writeCommandDoc(docData); + const result = await sendFileToFeishu({ filePath: docPath, chatId }); + if (!result.success) { + await feishuClient.reply(messageId, `❌ 命令清单发送失败: ${result.error || '未知错误'}`); + return; + } + + await feishuClient.reply(messageId, `✅ 命令清单已发送:${result.fileName || 'commands.md'}`); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + await feishuClient.reply(messageId, `❌ 获取命令列表失败: ${errorMessage}`); + } + } + + public async pushPanelCard(chatId: string, chatType: 'p2p' | 'group' = 'group'): Promise { const card = await this.buildPanelCard(chatId, chatType); await feishuClient.sendCard(chatId, card); diff --git a/src/handlers/file-sender.ts b/src/handlers/file-sender.ts index 16baefe..ac93405 100644 --- a/src/handlers/file-sender.ts +++ b/src/handlers/file-sender.ts @@ -3,6 +3,12 @@ import { promises as fsp } from 'fs'; import * as path from 'path'; import { feishuClient } from '../feishu/client.js'; import { DirectoryPolicy } from '../utils/directory-policy.js'; +import { COMMAND_DOC_PATH } from '../commands/command-doc.js'; + +// 系统生成文件豁免列表(绕过 ALLOWED_DIRECTORIES 校验) +const SYSTEM_GENERATED_PATHS: Set = new Set([ + path.resolve(COMMAND_DOC_PATH), +]); // 敏感文件名黑名单(基于文件名模式匹配) const SENSITIVE_NAME_PATTERNS = [ @@ -41,26 +47,38 @@ const SENSITIVE_PATH_PREFIXES = [ * 注意:resolvedPath 必须已经经过 path.resolve() 处理(绝对路径)。 */ export function validateFilePath(resolvedPath: string): { safe: boolean; reason?: string } { - // 0. 允许目录白名单校验(未配置时直接拒绝) + // 0. 系统生成文件豁免(绕过白名单校验) + if (SYSTEM_GENERATED_PATHS.has(resolvedPath)) { + // 仍需检查敏感文件名 + const basename = path.basename(resolvedPath); + for (const pattern of SENSITIVE_NAME_PATTERNS) { + if (pattern.test(basename)) { + return { safe: false, reason: `拒绝发送敏感文件: ${basename}` }; + } + } + return { safe: true }; + } + + // 1. 允许目录白名单校验(未配置时直接拒绝) if (!DirectoryPolicy.isAllowedPath(resolvedPath)) { return { safe: false, reason: '路径不在允许的工作目录范围内' }; } const basename = path.basename(resolvedPath); - // 1. 精确文件名匹配 + // 2. 精确文件名匹配 if (SENSITIVE_EXACT_NAMES.has(basename)) { return { safe: false, reason: `拒绝发送敏感文件: ${basename}` }; } - // 2. 文件名模式匹配 + // 3. 文件名模式匹配 for (const pattern of SENSITIVE_NAME_PATTERNS) { if (pattern.test(basename)) { return { safe: false, reason: `拒绝发送敏感文件: ${basename}` }; } } - // 3. 路径目录黑名单(统一转为正斜杠以兼容 Windows 路径格式) + // 4. 路径目录黑名单(统一转为正斜杠以兼容 Windows 路径格式) const normalizedPath = resolvedPath.replace(/\\/g, '/'); for (const prefix of SENSITIVE_PATH_PREFIXES) { if (normalizedPath.includes(prefix)) { diff --git a/src/handlers/p2p.ts b/src/handlers/p2p.ts index 3b90b7a..dc90d20 100644 --- a/src/handlers/p2p.ts +++ b/src/handlers/p2p.ts @@ -8,7 +8,7 @@ import { type CreateChatCardData, type CreateChatSessionOption, } from '../feishu/cards.js'; -import { DirectoryPolicy, type DirectorySource } from '../utils/directory-policy.js'; +import { DirectoryPolicy } from '../utils/directory-policy.js'; import { buildSessionTimestamp } from '../utils/session-title.js'; import { parseCommand, getHelpText, type ParsedCommand } from '../commands/parser.js'; import { commandHandler } from './command.js'; @@ -297,10 +297,11 @@ private getSessionOptionLabel(session: OpencodeSession, highlightWorkspace: bool ]; let totalSessionCount = 0; - const knownDirectorySet = new Set(chatSessionStore.getKnownDirectories()); + let projectOptions: Array<{ name: string; directory: string; source: 'alias' | 'history' }> = []; + let sessions: OpencodeSession[] = []; if (userConfig.enableManualSessionBind) { try { - const sessions = this.sortSessionsForCreateChat(await opencodeClient.listSessionsAcrossProjects()); + sessions = this.sortSessionsForCreateChat(await opencodeClient.listSessionsAcrossProjects()); totalSessionCount = sessions.length; let previousDirectory = ''; @@ -311,31 +312,18 @@ private getSessionOptionLabel(session: OpencodeSession, highlightWorkspace: bool value: session.id, }); previousDirectory = directory; - if (session.directory) { - knownDirectorySet.add(session.directory); - } } } catch (error) { console.warn('[P2P] 加载 OpenCode 会话列表失败,建群卡片将仅显示新建选项:', error); } } - if (directoryConfig.defaultWorkDirectory) { - knownDirectorySet.add(directoryConfig.defaultWorkDirectory); - } - for (const directory of directoryConfig.allowedDirectories) { - if (directory) { - knownDirectorySet.add(directory); - } - } - - const availableProjects: Array<{ name: string; directory: string; source: DirectorySource }> = - DirectoryPolicy.listAvailableProjects([...knownDirectorySet]); - const projectOptions: Array<{ name: string; directory: string; source: 'alias' | 'history' }> = - availableProjects.map((project: { name: string; directory: string; source: DirectorySource }) => ({ - ...project, - source: project.source === 'alias' ? 'alias' : 'history', - })); + const storeKnownDirs = chatSessionStore.getKnownDirectories(); + const knownDirs = [...new Set([...storeKnownDirs, ...sessions.map(session => session.directory).filter(Boolean)])]; + projectOptions = DirectoryPolicy.listAvailableProjects(knownDirs).map(project => ({ + ...project, + source: project.source === 'alias' ? 'alias' : 'history', + })); const hasSelected = sessionOptions.some(option => option.value === selectedSessionId); return { @@ -614,7 +602,7 @@ private getSessionOptionLabel(session: OpencodeSession, highlightWorkspace: bool protectSessionDelete = true; targetDirectory = selectedSession.directory; } else { - let session: Awaited> | null = null; + let session; try { session = await opencodeClient.createSession(sessionTitle, effectiveDir); } catch (error) { diff --git a/src/index.ts b/src/index.ts index b03cece..e8e3fed 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1860,6 +1860,19 @@ console.log('║ 飞书 × OpenCode 桥接服务 v2.9.0 (Group) ║'); // Discord 启动失败不影响 Feishu 流程 } // 8. 启动飞书客户端 + feishuClient.setCardActionHandler(async (event) => { + const actionValue = event.action?.value; + const action = actionValue && typeof actionValue === 'object' + ? (actionValue as Record).action + : undefined; + const actionName = typeof action === 'string' ? action : ''; + + if (actionName.startsWith('create_chat')) { + return await p2pHandler.handleCardAction(event); + } + + return await cardActionHandler.handle(event); + }); await feishuClient.start(); // 9. 启动清理检查 diff --git a/src/opencode/client.ts b/src/opencode/client.ts index 2ec7562..0a926b6 100644 --- a/src/opencode/client.ts +++ b/src/opencode/client.ts @@ -240,6 +240,17 @@ export interface OpencodeAgentInfo { native?: boolean; } +export interface OpencodeCommandInfo { + name: string; + description?: string; + agent?: string; + model?: string; + source?: 'command' | 'mcp' | 'skill'; + template: string; + subtask?: boolean; + hints: string[]; +} + export interface OpencodeAgentConfig { description?: string; mode?: AgentMode; @@ -1404,6 +1415,14 @@ class OpencodeClientWrapper extends EventEmitter { return agents; } + // 获取可用命令列表(slash command) + async getCommands(): Promise { + const client = this.getClient(); + const result = await client.command.list(); + const raw = Array.isArray(result.data) ? result.data : []; + return raw as OpencodeCommandInfo[]; + } + // 回复问题 (question 工具) // answers 是一个二维数组: [[第一个问题的答案们], [第二个问题的答案们], ...] // 每个答案是选项的 label From 2b8eb193f385e9eac7338d012e8caf3402a5c8b5 Mon Sep 17 00:00:00 2001 From: pengbo Date: Mon, 16 Mar 2026 13:58:49 +0800 Subject: [PATCH 4/5] =?UTF-8?q?refactor:=20=E4=BC=98=E5=8C=96=E5=91=BD?= =?UTF-8?q?=E4=BB=A4=E5=88=97=E8=A1=A8=E5=8A=A0=E8=BD=BD=E4=B8=8E=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E5=8D=95=E5=85=83=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 为 getCommands() 添加 5 分钟缓存机制,减少重复 API 调用 - 实现 OpenCode 版本兼容性检查,首次调用时自动检测 API 可用性 - 添加 clearCommandsCache() 方法用于手动刷新缓存 - 新增双斜杠透传功能单元测试,覆盖 23 个测试用例 - 测试覆盖包括:命名空间解析、大小写保留、参数处理、边界情况 技术细节: - 缓存使用 TTL 策略,5 分钟后自动失效 - 版本检查通过实际调用 command.list API 验证兼容性 - 测试用例覆盖 parseDoubleSlashCommand 的所有主要场景 - 遵循项目 TypeScript 编码规范,无类型错误 --- src/opencode/client.ts | 75 ++++++++- .../parser-double-slash-passthrough.test.ts | 157 ++++++++++++++++++ 2 files changed, 228 insertions(+), 4 deletions(-) create mode 100644 tests/unit/commands/parser-double-slash-passthrough.test.ts diff --git a/src/opencode/client.ts b/src/opencode/client.ts index 0a926b6..6c51f8d 100644 --- a/src/opencode/client.ts +++ b/src/opencode/client.ts @@ -340,6 +340,15 @@ class OpencodeClientWrapper extends EventEmitter { private directoryEventStreams: Map = new Map(); // 防止并发调用 ensureDirectoryEventStream 对同一目录建立多条 SSE 连接 private pendingDirectoryStreams: Map> = new Map(); + + // 命令列表缓存(5 分钟 TTL) + private commandsCache: OpencodeCommandInfo[] | null = null; + private commandsCacheTimestamp: number = 0; + private readonly COMMANDS_CACHE_TTL = 5 * 60 * 1000; // 5 分钟 + + // OpenCode 版本信息缓存 + private opencodeVersion: string | null = null; + private versionChecked = false; constructor() { super(); @@ -1417,10 +1426,68 @@ class OpencodeClientWrapper extends EventEmitter { // 获取可用命令列表(slash command) async getCommands(): Promise { - const client = this.getClient(); - const result = await client.command.list(); - const raw = Array.isArray(result.data) ? result.data : []; - return raw as OpencodeCommandInfo[]; + // 1. 检查缓存是否有效 + const now = Date.now(); + if (this.commandsCache && (now - this.commandsCacheTimestamp) < this.COMMANDS_CACHE_TTL) { + return this.commandsCache; + } + + // 2. 版本兼容性检查(仅首次调用时) + if (!this.versionChecked) { + const versionOk = await this.checkOpenCodeVersion(); + if (!versionOk) { + console.warn('[OpenCode] 当前版本可能不支持 command.list API,继续尝试调用...'); + } + this.versionChecked = true; + } + + // 3. 调用 API 获取命令列表 + try { + const client = this.getClient(); + const result = await client.command.list(); + const raw = Array.isArray(result.data) ? result.data : []; + const commands = raw as OpencodeCommandInfo[]; + + // 4. 更新缓存 + this.commandsCache = commands; + this.commandsCacheTimestamp = now; + + return commands; + } catch (error) { + console.error('[OpenCode] 获取命令列表失败:', error); + throw error; // 抛出错误让调用者处理 + } + } + + // 检查 OpenCode 版本兼容性 + private async checkOpenCodeVersion(): Promise { + try { + const client = this.getClient(); + // 尝试调用 command.list 来检测 API 是否可用 + const result = await client.command.list(); + // 如果调用成功且有数据返回,说明 API 可用 + if (result.data && Array.isArray(result.data)) { + return true; + } + // 如果返回空数组,也说明 API 可用(只是没有命令) + return result.data !== undefined; + } catch (error) { + // 如果调用失败,说明 API 不可用或版本不兼容 + const errorMessage = error instanceof Error ? error.message : String(error); + console.warn('[OpenCode] command.list API 不可用:', errorMessage); + return false; + } + } + + // 获取 OpenCode 版本信息(供外部调用) + public getOpencodeVersion(): string | null { + return this.opencodeVersion; + } + + // 清除命令缓存(用于强制刷新) + public clearCommandsCache(): void { + this.commandsCache = null; + this.commandsCacheTimestamp = 0; } // 回复问题 (question 工具) diff --git a/tests/unit/commands/parser-double-slash-passthrough.test.ts b/tests/unit/commands/parser-double-slash-passthrough.test.ts new file mode 100644 index 0000000..ef844d1 --- /dev/null +++ b/tests/unit/commands/parser-double-slash-passthrough.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from 'vitest'; +import { parseCommand, type ParsedCommand } from '../../../src/commands/parser.js'; + +describe('parseCommand - 双斜杠透传', () => { + describe('parseDoubleSlashCommand', () => { + it('应该解析带命名空间的双斜杠命令', () => { + const result = parseCommand('//superpowers:brainstorming'); + expect(result).toEqual({ + type: 'command', + commandName: 'superpowers:brainstorming', + commandArgs: '', + commandPrefix: '/', + }); + }); + + it('应该保留命令的原始大小写', () => { + const result1 = parseCommand('//Build'); + expect(result1.commandName).toBe('Build'); + + const result2 = parseCommand('//superpowers:Brainstorming'); + expect(result2.commandName).toBe('superpowers:Brainstorming'); + }); + + it('应该解析带参数的双斜杠命令', () => { + const result = parseCommand('//superpowers:brainstorming arg1 arg2'); + expect(result).toEqual({ + type: 'command', + commandName: 'superpowers:brainstorming', + commandArgs: 'arg1 arg2', + commandPrefix: '/', + }); + }); + + it('应该解析不带命名空间的双斜杠命令', () => { + const result = parseCommand('//build'); + expect(result).toEqual({ + type: 'command', + commandName: 'build', + commandArgs: '', + commandPrefix: '/', + }); + }); + + it('空双斜杠应该返回 null', () => { + const result = parseCommand('//'); + expect(result.type).toBe('prompt'); + }); + + it('三斜杠不应该被解析为双斜杠命令', () => { + const result = parseCommand('///test'); + expect(result.type).not.toBe('command'); + }); + + it('双斜杠后跟空格和参数应该正确解析', () => { + const result = parseCommand('//plan 帮我分析这个需求'); + expect(result.commandName).toBe('plan'); + expect(result.commandArgs).toBe('帮我分析这个需求'); + }); + + it('双斜杠命令中包含中文应该正确解析', () => { + const result = parseCommand('//创建角色'); + expect(result.commandName).toBe('创建角色'); + }); + + it('双斜杠后跟路径不应该被解析为命令', () => { + const result = parseCommand('//tmp/test'); + expect(result.type).toBe('prompt'); + }); + + it('双斜杠后跟多个连续空格应该正确处理', () => { + const result = parseCommand('//build arg1 arg2'); + expect(result.commandName).toBe('build'); + // 参数中的多个空格会被规范化为单个空格(这是正确的行为) + expect(result.commandArgs).toBe('arg1 arg2'); + }); + }); + + describe('双斜杠与单斜杠的区别', () => { + it('单斜杠命令应该转换为小写', () => { + const result = parseCommand('/BUILD'); + expect(result).toEqual({ + type: 'command', + commandName: 'BUILD', // 保留原始大小写用于透传 + commandArgs: '', + commandPrefix: '/', + }); + }); + + it('单斜杠不允许冒号字符', () => { + const result = parseCommand('/superpowers:brainstorming'); + // 单斜杠不允许冒号(isSlashCommandToken 会拒绝),所以会被当作 prompt + expect(result.type).toBe('prompt'); + }); + + it('双斜杠专门用于命名空间命令', () => { + const result = parseCommand('//superpowers:brainstorming'); + expect(result.type).toBe('command'); + expect(result.commandName).toBe('superpowers:brainstorming'); + }); + }); +}); + +describe('parseCommand - /commands 命令', () => { + it('应该解析 /commands 命令', () => { + const result = parseCommand('/commands'); + expect(result).toEqual({ + type: 'commands', + }); + }); + + it('应该解析 /slash 命令(别名)', () => { + const result = parseCommand('/slash'); + expect(result.type).toBe('commands'); + }); + + it('应该解析 /slash-commands 命令(别名)', () => { + const result = parseCommand('/slash-commands'); + expect(result.type).toBe('commands'); + }); + + it('应该解析 /slash_commands 命令(别名)', () => { + const result = parseCommand('/slash_commands'); + expect(result.type).toBe('commands'); + }); + + it('/commands 带参数应该忽略参数', () => { + const result = parseCommand('/commands extra'); + expect(result.type).toBe('commands'); + }); +}); + +describe('parseCommand - 边界情况', () => { + it('空字符串应该返回 prompt 类型', () => { + const result = parseCommand(''); + expect(result.type).toBe('prompt'); + }); + + it('纯空格应该返回 prompt 类型', () => { + const result = parseCommand(' '); + expect(result.type).toBe('prompt'); + }); + + it('双斜杠后跟换行应该返回 null', () => { + const result = parseCommand('//test\narg'); + expect(result.type).toBe('prompt'); + }); + + it('双斜杠命令中包含特殊字符应该正确解析', () => { + const result = parseCommand('//test-command_v1.0'); + expect(result.commandName).toBe('test-command_v1.0'); + }); + + it('双斜杠命令中包含数字应该正确解析', () => { + const result = parseCommand('//test123'); + expect(result.commandName).toBe('test123'); + }); +}); From 28f4beca6ab43902fae6de554cbb0ea5019fd861 Mon Sep 17 00:00:00 2001 From: pengbo Date: Mon, 16 Mar 2026 15:48:31 +0800 Subject: [PATCH 5/5] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=BB=BA=E7=BE=A4?= =?UTF-8?q?=E5=90=8E=E7=AB=8B=E5=8D=B3=E8=A2=AB=E8=A7=A3=E6=95=A3=E5=92=8C?= =?UTF-8?q?=E5=9B=9E=E8=B0=83=E8=B6=85=E6=97=B6=E9=97=AE=E9=A2=98=EF=BC=88?= =?UTF-8?q?=E4=B8=89=E9=87=8D=E4=BF=9D=E6=8A=A4=E6=9C=BA=E5=88=B6=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题表现: 1. 私聊 /create_chat 创建群后立即被自动解散 2. 飞书弹出"目标回调服务超时未响应"警告(虽然群实际创建成功) 根本原因: - 飞书 createChat API 的成员添加是异步操作,有同步延迟 - lifecycle.ts 的孤儿映射清理在启动时执行,新群未出现在 getUserChats() 返回列表中 - 飞书卡片回调响应限制为 3 秒,但建群流程涉及多个耗时异步操作 修复方案(三重保护): 1️. p2p.ts - ensureUserInGroup 添加重试机制 - 最多重试 3 次,每次间隔 500ms(总等待 1.5 秒) - 等待飞书 API 完成成员列表同步 - 最后一次重试失败后尝试手动拉取用户作为兜底 2️. lifecycle.ts - runCleanupScan 添加新群保护窗口 - 清理孤儿映射前检查群的创建时间 - 如果群创建时间在 30 秒内,跳过清理 - 给飞书 API 足够的时间完成同步 - 保护窗口结束后,真正的孤儿仍会被清理 3️. p2p.ts - handleCardAction 异步执行建群逻辑 - 使用 setImmediate 将建群逻辑改为后台异步执行 - 立即返回响应给飞书,避免回调超时(限制 3 秒) - 后台执行失败记录日志,建群成功后在新群发送欢迎消息 技术细节: - 检查 session.createdAt 字段判断群的创建时间 - 保护窗口:30 秒 - 重试延迟:500ms * 3 = 最多等待 1.5 秒 - setImmediate 将回调加入事件队列下一个 tick 执行 测试验证: - 编译检查通过 - 建议测试:私聊 /create_chat 提交表单 - 预期行为:群创建成功,无超时警告,不会被自动解散 --- src/handlers/lifecycle.ts | 12 + src/handlers/p2p.ts | 1643 +++++++++++++++++++------------------ 2 files changed, 855 insertions(+), 800 deletions(-) diff --git a/src/handlers/lifecycle.ts b/src/handlers/lifecycle.ts index 7a8b15b..72aa604 100644 --- a/src/handlers/lifecycle.ts +++ b/src/handlers/lifecycle.ts @@ -46,6 +46,18 @@ export class LifecycleHandler { } else { for (const mappedChatId of feishuChatIds) { if (activeChatIdSet.has(mappedChatId)) continue; + + // 新增:跳过最近创建的群聊(保护窗口 30 秒),避免飞书 API 同步延迟导致误删 + const session = chatSessionStore.getSession(mappedChatId); + if (session && session.createdAt) { + const ageMs = Date.now() - session.createdAt; + const protectionWindowMs = 30 * 1000; + if (ageMs < protectionWindowMs) { + console.log('[Lifecycle] 跳过新创建群聊(保护窗口内): chat=' + mappedChatId); + continue; + } + } + if (!chatSessionStore.isGroupChatSession(mappedChatId)) { continue; } diff --git a/src/handlers/p2p.ts b/src/handlers/p2p.ts index dc90d20..e232915 100644 --- a/src/handlers/p2p.ts +++ b/src/handlers/p2p.ts @@ -1,800 +1,843 @@ -import { feishuClient, type FeishuMessageEvent, type FeishuCardActionEvent } from '../feishu/client.js'; -import { opencodeClient } from '../opencode/client.js'; -import { chatSessionStore } from '../store/chat-session.js'; -import { - buildCreateChatCard, - buildWelcomeCard, - CREATE_CHAT_NEW_SESSION_VALUE, - type CreateChatCardData, - type CreateChatSessionOption, -} from '../feishu/cards.js'; -import { DirectoryPolicy } from '../utils/directory-policy.js'; -import { buildSessionTimestamp } from '../utils/session-title.js'; -import { parseCommand, getHelpText, type ParsedCommand } from '../commands/parser.js'; -import { commandHandler } from './command.js'; -import { groupHandler } from './group.js'; -import { directoryConfig, userConfig } from '../config.js'; - -interface EnsurePrivateSessionResult { - firstBinding: boolean; -} - -type OpencodeSession = Awaited>[number]; - -const CREATE_CHAT_OPTION_LIMIT = 100; -const CREATE_CHAT_EXISTING_LIMIT = CREATE_CHAT_OPTION_LIMIT - 1; - -export class P2PHandler { - private static readonly CARD_SELECTION_TTL_MS = 10 * 60 * 1000; // 10 分钟 - - private createChatSelectionMap: Map = new Map(); - private createChatProjectMap: Map = new Map(); - private createChatDirectoryInputMap: Map = new Map(); - private createChatNameInputMap: Map = new Map(); - - private async safeReply( - messageId: string | undefined, - chatId: string | undefined, - text: string - ): Promise { - if (messageId) { - await feishuClient.reply(messageId, text); - return true; - } - - if (chatId) { - await feishuClient.sendText(chatId, text); - return true; - } - - return false; - } - - private getStringValue(value: unknown): string | undefined { - if (typeof value !== 'string') return undefined; - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : undefined; - } - - private getCardActionOption(event: FeishuCardActionEvent): string | undefined { - const actionRecord = event.action as unknown as Record; - const option = actionRecord.option; - if (typeof option === 'string') { - return this.getStringValue(option); - } - - if (option && typeof option === 'object') { - const optionRecord = option as Record; - return this.getStringValue(optionRecord.value) || this.getStringValue(optionRecord.key); - } - - return undefined; - } - - private getCardActionInputValue(event: FeishuCardActionEvent): string | undefined { - const actionRecord = event.action as unknown as Record; - const rawValue = actionRecord.value; - const actionValue = rawValue && typeof rawValue === 'object' - ? rawValue as Record - : {}; - - return this.getStringValue(actionValue.inputValue) - || this.getStringValue(actionValue.input_value) - || this.getStringValue(actionValue.value) - || this.getStringValue(actionValue.text) - || this.getStringValue(actionRecord.inputValue) - || this.getStringValue(actionRecord.input_value) - || this.getStringValue(actionRecord.value); - } - - private getCreateChatSelectionKeys(chatId?: string, messageId?: string, openId?: string): string[] { - const keys: string[] = []; - const normalizedMessageId = this.getStringValue(messageId); - const normalizedChatId = this.getStringValue(chatId); - const normalizedOpenId = this.getStringValue(openId); - - if (normalizedMessageId) { - keys.push(`msg:${normalizedMessageId}`); - } - if (normalizedChatId && normalizedOpenId) { - keys.push(`chat:${normalizedChatId}:user:${normalizedOpenId}`); - } - - return keys; - } - - private rememberCreateChatSelection( - selectedSessionId: string, - chatId?: string, - messageId?: string, - openId?: string - ): void { - const normalized = this.getStringValue(selectedSessionId); - if (!normalized) return; - - const keys = this.getCreateChatSelectionKeys(chatId, messageId, openId); - for (const key of keys) { - this.createChatSelectionMap.set(key, normalized); - } - } - - private getRememberedCreateChatSelection(chatId?: string, messageId?: string, openId?: string): string | undefined { - const keys = this.getCreateChatSelectionKeys(chatId, messageId, openId); - for (const key of keys) { - const selected = this.createChatSelectionMap.get(key); - if (selected) { - return selected; - } - } - return undefined; - } - - private clearCreateChatSelection(chatId?: string, messageId?: string, openId?: string): void { - const keys = this.getCreateChatSelectionKeys(chatId, messageId, openId); - for (const key of keys) { - this.createChatSelectionMap.delete(key); - } - } - - private rememberCreateChatProjectSelection( - project: string, - chatId: string, - messageId?: string | null, - openId?: string - ): void { - const key = `${chatId}:${openId || ''}`; - this.createChatProjectMap.set(key, { - value: project, - expiresAt: Date.now() + P2PHandler.CARD_SELECTION_TTL_MS, - }); - } - - private getRememberedCreateChatProjectSelection( - chatId: string, - messageId?: string | null, - openId?: string - ): string | undefined { - const key = `${chatId}:${openId || ''}`; - const entry = this.createChatProjectMap.get(key); - if (!entry) { - return undefined; - } - if (entry.expiresAt <= Date.now()) { - this.createChatProjectMap.delete(key); - return undefined; - } - return entry.value; - } - - private clearCreateChatProjectSelection( - chatId: string, - messageId?: string | null, - openId?: string - ): void { - const key = `${chatId}:${openId || ''}`; - this.createChatProjectMap.delete(key); - } - - private rememberCreateChatDirectoryInput( - value: string, - chatId: string, - messageId?: string | null, - openId?: string - ): void { - const key = `${chatId}:${openId || ''}`; - this.createChatDirectoryInputMap.set(key, { - value, - expiresAt: Date.now() + P2PHandler.CARD_SELECTION_TTL_MS, - }); - } - - private getRememberedCreateChatDirectoryInput( - chatId: string, - messageId?: string | null, - openId?: string - ): string | undefined { - const key = `${chatId}:${openId || ''}`; - const entry = this.createChatDirectoryInputMap.get(key); - if (!entry) { - return undefined; - } - if (entry.expiresAt <= Date.now()) { - this.createChatDirectoryInputMap.delete(key); - return undefined; - } - return entry.value; - } - - private clearCreateChatDirectoryInput( - chatId: string, - messageId?: string | null, - openId?: string - ): void { - const key = `${chatId}:${openId || ''}`; - this.createChatDirectoryInputMap.delete(key); - } - - private rememberCreateChatNameInput( - value: string, - chatId: string, - messageId?: string | null, - openId?: string - ): void { - const key = `${chatId}:${openId || ''}`; - this.createChatNameInputMap.set(key, { - value, - expiresAt: Date.now() + P2PHandler.CARD_SELECTION_TTL_MS, - }); - } - - private getRememberedCreateChatNameInput( - chatId: string, - messageId?: string | null, - openId?: string - ): string | undefined { - const key = `${chatId}:${openId || ''}`; - const entry = this.createChatNameInputMap.get(key); - if (!entry) { - return undefined; - } - if (entry.expiresAt <= Date.now()) { - this.createChatNameInputMap.delete(key); - return undefined; - } - return entry.value; - } - - private clearCreateChatNameInput( - chatId: string, - messageId?: string | null, - openId?: string - ): void { - const key = `${chatId}:${openId || ''}`; - this.createChatNameInputMap.delete(key); - } - - - private getSessionDirectory(session: OpencodeSession): string { - return typeof session.directory === 'string' && session.directory.trim().length > 0 - ? session.directory.trim() - : '/'; - } -private getSessionOptionLabel(session: OpencodeSession, highlightWorkspace: boolean): string { - const title = typeof session.title === 'string' && session.title.trim().length > 0 - ? session.title.trim() - : '未命名会话'; - const compactTitle = title.length > 24 ? `${title.slice(0, 24)}...` : title; - const directory = this.getSessionDirectory(session); - const compactDirectory = directory.length > 18 ? `...${directory.slice(-18)}` : directory; - const shortId = session.id.slice(0, 8); - const workspaceLabel = highlightWorkspace ? `【${compactDirectory}】` : compactDirectory; - return `${workspaceLabel} / ${shortId} / ${compactTitle}`; - } - - private sortSessionsForCreateChat(sessions: OpencodeSession[]): OpencodeSession[] { - return [...sessions].sort((a, b) => { - const directoryCompare = this.getSessionDirectory(a).localeCompare(this.getSessionDirectory(b), 'zh-Hans-CN'); - if (directoryCompare !== 0) { - return directoryCompare; - } - - const left = b.time?.updated ?? b.time?.created ?? 0; - const right = a.time?.updated ?? a.time?.created ?? 0; - if (left !== right) { - return left - right; - } - - return a.id.localeCompare(b.id, 'en'); - }); - } - - private async buildCreateChatCardData(selectedSessionId?: string): Promise { - const sessionOptions: CreateChatSessionOption[] = [ - { - label: '新建 OpenCode 会话(默认)', - value: CREATE_CHAT_NEW_SESSION_VALUE, - }, - ]; - - let totalSessionCount = 0; - let projectOptions: Array<{ name: string; directory: string; source: 'alias' | 'history' }> = []; - let sessions: OpencodeSession[] = []; - if (userConfig.enableManualSessionBind) { - try { - sessions = this.sortSessionsForCreateChat(await opencodeClient.listSessionsAcrossProjects()); - totalSessionCount = sessions.length; - - let previousDirectory = ''; - for (const session of sessions.slice(0, CREATE_CHAT_EXISTING_LIMIT)) { - const directory = this.getSessionDirectory(session); - sessionOptions.push({ - label: this.getSessionOptionLabel(session, directory !== previousDirectory), - value: session.id, - }); - previousDirectory = directory; - } - } catch (error) { - console.warn('[P2P] 加载 OpenCode 会话列表失败,建群卡片将仅显示新建选项:', error); - } - } - - const storeKnownDirs = chatSessionStore.getKnownDirectories(); - const knownDirs = [...new Set([...storeKnownDirs, ...sessions.map(session => session.directory).filter(Boolean)])]; - projectOptions = DirectoryPolicy.listAvailableProjects(knownDirs).map(project => ({ - ...project, - source: project.source === 'alias' ? 'alias' : 'history', - })); - - const hasSelected = sessionOptions.some(option => option.value === selectedSessionId); - return { - selectedSessionId: hasSelected ? selectedSessionId : CREATE_CHAT_NEW_SESSION_VALUE, - sessionOptions, - totalSessionCount, - manualBindEnabled: userConfig.enableManualSessionBind, - projectOptions, - allowCustomPath: directoryConfig.isAllowlistEnforced, - }; - } - - private async pushCreateChatCard( - chatId: string, - messageId?: string, - selectedSessionId?: string, - openId?: string - ): Promise { - const cardData = await this.buildCreateChatCardData(selectedSessionId); - const card = buildCreateChatCard(cardData); - let sentCardMessageId: string | null = null; - if (messageId) { - sentCardMessageId = await feishuClient.replyCard(messageId, card); - } else { - sentCardMessageId = await feishuClient.sendCard(chatId, card); - } - - this.rememberCreateChatSelection( - selectedSessionId || CREATE_CHAT_NEW_SESSION_VALUE, - chatId, - sentCardMessageId || messageId, - openId - ); - } - - private getPrivateSessionShortId(openId: string): string { - const normalized = openId.startsWith('ou_') ? openId.slice(3) : openId; - return normalized.slice(0, 4); - } - - private getPrivateSessionTitle(_openId: string): string { - return `私聊-${buildSessionTimestamp()}`; - } - - private isCreateGroupCommand(text: string): boolean { - const trimmed = text.trim(); - const lowered = trimmed.toLowerCase(); - return ( - lowered === '/create_chat' || - lowered === '/create-chat' || - lowered === '/chat new' || - lowered === '/group new' || - trimmed === '/建群' || - trimmed === '建群' - ); - } - - private async isSessionMissingInOpenCode(sessionId: string): Promise { - try { - const session = await opencodeClient.findSessionAcrossProjects(sessionId); - return !session; - } catch (error) { - console.warn('[P2P] 校验会话存在性失败,保持当前绑定:', error); - return false; - } - } - - private async ensurePrivateSession(chatId: string, senderId: string): Promise { - const current = chatSessionStore.getSession(chatId); - if (current?.sessionId) { - const missing = await this.isSessionMissingInOpenCode(current.sessionId); - if (!missing) { - return { - firstBinding: false, - }; - } - - console.log(`[P2P] 检测到绑定会话已删除,重新初始化: chat=${chatId}, session=${current.sessionId}`); - chatSessionStore.removeSession(chatId); - } - - try { - const sessionTitle = this.getPrivateSessionTitle(senderId); - const chatDefault = chatSessionStore.getSession(chatId)?.defaultDirectory; - const dirResult = DirectoryPolicy.resolve({ chatDefaultDirectory: chatDefault }); - const effectiveDir = dirResult.ok && dirResult.source !== 'server_default' ? dirResult.directory : undefined; - const session = await opencodeClient.createSession(sessionTitle, effectiveDir); - chatSessionStore.setSession(chatId, session.id, senderId, sessionTitle, { chatType: 'p2p', resolvedDirectory: session.directory }); - return { - firstBinding: true, - }; - } catch (error) { - console.error('[P2P] 初始化私聊会话失败:', error); - return null; - } - } - - private shouldSkipImmediateCommand(command: ParsedCommand): boolean { - if (command.type === 'help' || command.type === 'panel') { - return true; - } - - return command.type === 'session' && command.sessionAction === 'new'; - } - - private async pushFirstContactGuidance(chatId: string, senderId: string, messageId: string): Promise { - const createChatData = await this.buildCreateChatCardData(); - const card = buildWelcomeCard(senderId, createChatData); - const welcomeCardMessageId = await feishuClient.sendCard(chatId, card); - this.rememberCreateChatSelection( - CREATE_CHAT_NEW_SESSION_VALUE, - chatId, - welcomeCardMessageId || undefined, - senderId - ); - await this.safeReply(messageId, chatId, getHelpText()); - - try { - await commandHandler.pushPanelCard(chatId, 'p2p'); - } catch (error) { - console.warn('[P2P] 发送私聊控制面板失败:', error); - } - } - - // 处理私聊消息 - async handleMessage(event: FeishuMessageEvent): Promise { - const { chatId, content, senderId, messageId } = event; - const trimmedContent = content.trim(); - - // 1. 检查命令 - const command = parseCommand(content); - - // 2. 首次私聊(或绑定会话在 OpenCode 中已被删除)时,自动初始化并推送引导 - const ensured = await this.ensurePrivateSession(chatId, senderId); - if (!ensured) { - await this.safeReply(messageId, chatId, '❌ 初始化私聊会话失败,请稍后重试'); - return; - } - - if (ensured.firstBinding) { - await this.pushFirstContactGuidance(chatId, senderId, messageId); - if (this.shouldSkipImmediateCommand(command)) { - return; - } - } - - // 3.1 私聊专属建群快捷命令 - if (this.isCreateGroupCommand(trimmedContent)) { - await this.pushCreateChatCard(chatId, messageId, CREATE_CHAT_NEW_SESSION_VALUE, senderId); - return; - } - - // 3. 私聊命令 - if (command.type !== 'prompt') { - console.log(`[P2P] 收到命令: ${command.type}`); - await commandHandler.handle(command, { - chatId, - messageId, - senderId, - chatType: 'p2p' - }); - return; - } - - // 4. 私聊普通消息:按群聊同样逻辑转发到 OpenCode - console.log(`[P2P] 收到私聊消息: user=${senderId}, content=${content.slice(0, 20)}...`); - await groupHandler.handleMessage(event); - } - - private async ensureUserInGroup( - chatId: string, - openId: string, - invalidUserIds: string[] - ): Promise<{ ok: boolean; message?: string }> { - const userInvalidOnCreate = invalidUserIds.includes(openId); - if (userInvalidOnCreate) { - console.warn(`[P2P] 用户 ${openId} 在创建群时被标记为无效,尝试手动拉取...`); - } - - let members = await feishuClient.getChatMembers(chatId); - if (members.includes(openId)) { - return { ok: true }; - } - - console.warn(`[P2P] 用户 ${openId} 未在新建群 ${chatId} 中,尝试手动拉取...`); - const added = await feishuClient.addChatMembers(chatId, [openId]); - if (!added) { - return { - ok: false, - message: '❌ 无法将您添加到群聊。请确保机器人具有"获取群组信息"和"更新群组信息"权限,且您在机器人的可见范围内。', - }; - } - - members = await feishuClient.getChatMembers(chatId); - if (!members.includes(openId)) { - return { - ok: false, - message: '❌ 创建群聊异常:无法确认成员状态,已自动清理无效群。', - }; - } - - return { ok: true }; - } - - private async findSessionById(sessionId: string): Promise { - try { - return await opencodeClient.findSessionAcrossProjects(sessionId); - } catch (error) { - console.warn('[P2P] 查询 OpenCode 会话列表失败:', error); - return null; - } - } - - private async createGroupWithSessionSelection( - openId: string, - selectedSessionId: string, - chatId?: string, - messageId?: string, - rawDirectory?: string, - customChatName?: string - ): Promise { - const bindExistingSession = selectedSessionId !== CREATE_CHAT_NEW_SESSION_VALUE; - if (bindExistingSession && !userConfig.enableManualSessionBind) { - await this.safeReply(messageId, chatId, '❌ 当前环境未开启"绑定已有会话"能力'); - return; - } - - let effectiveDir: string | undefined; - if (!bindExistingSession && rawDirectory) { - const dirResult = DirectoryPolicy.resolve({ explicitDirectory: rawDirectory }); - if (!dirResult.ok) { - console.warn(`[P2P] 建群目录校验失败: ${dirResult.internalDetail || dirResult.code}`); - await this.safeReply(messageId, chatId, dirResult.userMessage); - return; - } - effectiveDir = dirResult.source === 'server_default' ? undefined : dirResult.directory; - } - - console.log(`[P2P] 用户 ${openId} 请求创建新会话群,模式=${bindExistingSession ? '绑定已有会话' : '新建会话'}`); - - // 使用用户指定的群名,或自动生成 - const chatName = customChatName || `会话-${Date.now().toString().slice(-6)}`; - const createResult = await feishuClient.createChat(chatName, [openId], '由 OpenCode 自动创建的会话群'); - if (!createResult.chatId) { - await this.safeReply(messageId, chatId, '❌ 创建群聊失败,请重试'); - return; - } - - const newChatId = createResult.chatId; - console.log(`[P2P] 群聊已创建,ID: ${newChatId}`); - - const userInGroup = await this.ensureUserInGroup(newChatId, openId, createResult.invalidUserIds); - if (!userInGroup.ok) { - await feishuClient.disbandChat(newChatId); - await this.safeReply(messageId, chatId, userInGroup.message || '❌ 创建群聊失败,请重试'); - return; - } - - console.log(`[P2P] 用户 ${openId} 已确认在群 ${newChatId} 中`); - - let targetSessionId = ''; - let sessionTitle = `飞书群聊: ${chatName}`; - let protectSessionDelete = false; - let targetDirectory: string | undefined; - - if (bindExistingSession) { - const selectedSession = await this.findSessionById(selectedSessionId); - if (!selectedSession) { - await feishuClient.disbandChat(newChatId); - await this.safeReply(messageId, chatId, `❌ 未找到会话: ${selectedSessionId},请重新选择`); - return; - } - - targetSessionId = selectedSession.id; - sessionTitle = selectedSession.title || sessionTitle; - protectSessionDelete = true; - targetDirectory = selectedSession.directory; - } else { - let session; - try { - session = await opencodeClient.createSession(sessionTitle, effectiveDir); - } catch (error) { - console.error('[P2P] 创建 OpenCode 会话异常:', error); - await feishuClient.disbandChat(newChatId); - await this.safeReply(messageId, chatId, '❌ 创建 OpenCode 会话失败,请重试'); - return; - } - if (!session) { - await feishuClient.disbandChat(newChatId); - await this.safeReply(messageId, chatId, '❌ 创建 OpenCode 会话失败,请重试'); - return; - } - targetSessionId = session.id; - targetDirectory = session.directory; - } - - const previousChatId = chatSessionStore.getChatId(targetSessionId); - if (previousChatId && previousChatId !== newChatId) { - chatSessionStore.removeSession(previousChatId); - console.log(`[P2P] 已迁移会话绑定: session=${targetSessionId}, from=${previousChatId}, to=${newChatId}`); - } - - chatSessionStore.setSession( - newChatId, - targetSessionId, - openId, - sessionTitle, - { protectSessionDelete, chatType: 'group', resolvedDirectory: targetDirectory } - ); - // 建群时指定的目录同时设为群默认,后续 /session new 无参数时自动继承 - if (targetDirectory) { - chatSessionStore.updateConfig(newChatId, { defaultDirectory: targetDirectory }); - } - console.log(`[P2P] 已绑定会话: Chat=${newChatId}, Session=${targetSessionId}`); - - const noticeLines = ['✅ 会话群已创建!', '正在为您跳转...']; - if (bindExistingSession) { - noticeLines.push('🔒 该会话已开启“删除保护”:自动清理不会删除 OpenCode 会话。'); - } - if (previousChatId && previousChatId !== newChatId) { - noticeLines.push('🔁 已将该会话从旧群迁移到当前新群。'); - } - await this.safeReply(messageId, chatId, noticeLines.join('\n')); - - const onboardingText = bindExistingSession - ? [ - '🔗 已绑定已有 OpenCode 会话,直接发送需求即可继续之前上下文。', - '🎭 使用 /panel 选择角色,使用 /help 查看完整命令。', - ].join('\n') - : [ - '👋 会话已就绪,直接发送需求即可开始。', - '🎭 使用 /panel 选择角色,使用 /help 查看完整命令。', - '🧩 可创建自定义角色:创建角色 名称=旅行助手; 描述=擅长规划行程; 类型=主; 工具=webfetch', - ].join('\n'); - await feishuClient.sendText(newChatId, onboardingText); - - try { - await commandHandler.pushPanelCard(newChatId); - } catch (error) { - console.warn('[P2P] 发送开场控制面板失败:', error); - } - - } - - // 处理私聊中的卡片动作 - async handleCardAction(event: FeishuCardActionEvent): Promise { - const { openId, chatId, messageId } = event; - const actionValue = event.action.value && typeof event.action.value === 'object' - ? event.action.value - : {}; - const actionTag = this.getStringValue(actionValue.action); - - if (!actionTag) { - return; - } - - if (!chatId) { - return { - toast: { - type: 'error', - content: '无法定位私聊会话', - i18n_content: { zh_cn: '无法定位私聊会话', en_us: 'Failed to locate private chat' }, - }, - }; - } - - if (actionTag === 'create_chat') { - await this.pushCreateChatCard(chatId, messageId, CREATE_CHAT_NEW_SESSION_VALUE, openId); - return { - toast: { - type: 'success', - content: '已打开建群选项', - i18n_content: { zh_cn: '已打开建群选项', en_us: 'Create chat options opened' }, - }, - }; - } - - if (actionTag === 'create_chat_select') { - const selectedSessionId = - this.getCardActionOption(event) || - this.getStringValue(actionValue.selectedSessionId) || - this.getStringValue(actionValue.selected) || - CREATE_CHAT_NEW_SESSION_VALUE; - - this.rememberCreateChatSelection(selectedSessionId, chatId, messageId, openId); - return { - toast: { - type: 'success', - content: '已记录会话选择', - i18n_content: { zh_cn: '已记录会话选择', en_us: 'Session selection saved' }, - }, - }; - } - - if (actionTag === 'create_chat_project_select') { - const selectedProject = - this.getCardActionOption(event) || - this.getStringValue(actionValue.selectedProject) || - '__default__'; - this.rememberCreateChatProjectSelection(selectedProject, chatId, messageId, openId); - return { - toast: { - type: 'success', - content: '已记录项目选择', - i18n_content: { zh_cn: '已记录项目选择', en_us: 'Project selection saved' }, - }, - }; - } - - if (actionTag === 'create_chat_directory_input') { - const inputValue = this.getCardActionOption(event) - || this.getCardActionInputValue(event) - || ''; - if (inputValue) { - this.rememberCreateChatDirectoryInput(inputValue, chatId, messageId, openId); - } - // 输入不需要 toast - return; - } - - if (actionTag === 'create_chat_name_input') { - const inputValue = this.getCardActionOption(event) - || this.getCardActionInputValue(event) - || ''; - if (inputValue) { - this.rememberCreateChatNameInput(inputValue, chatId, messageId, openId); - } - // 输入不需要 toast - return; - } - - if (actionTag === 'create_chat_submit') { - // form 容器提交时,所有 input/select_static 值均在 action.form_value 中 - const eventAny = event as unknown as { action?: { form_value?: Record } }; - const formValue = eventAny.action?.form_value; - - // 会话来源:优先从 form_value 读,回退到记忆 map - const selectedSessionId = - formValue?.session_source?.trim() || - this.getRememberedCreateChatSelection(chatId, messageId, openId) || - this.getStringValue(actionValue.selectedSessionId) || - CREATE_CHAT_NEW_SESSION_VALUE; - - // 工作项目:优先从 form_value 读,回退到记忆 map - const selectedProject = - formValue?.project_source?.trim() || - this.getRememberedCreateChatProjectSelection(chatId, messageId, openId); - - // 自定义工作目录 - const customPath = formValue?.custom_directory?.trim() - || this.getRememberedCreateChatDirectoryInput(chatId, messageId, openId) - || ''; - - // 群名:优先从 form_value 读,回退到记忆 map - const customChatName = - formValue?.chat_name?.trim() || - this.getRememberedCreateChatNameInput(chatId, messageId, openId) || - undefined; - - const rawDirectory = selectedProject && selectedProject !== '__default__' - ? selectedProject - : (customPath || undefined); - - this.clearCreateChatSelection(chatId, messageId, openId); - this.clearCreateChatProjectSelection(chatId, messageId, openId); - this.clearCreateChatDirectoryInput(chatId, messageId, openId); - this.clearCreateChatNameInput(chatId, messageId, openId); - await this.createGroupWithSessionSelection(openId, selectedSessionId, chatId, messageId, rawDirectory, customChatName); - return; - } - } -} - -export const p2pHandler = new P2PHandler(); +import { feishuClient, type FeishuMessageEvent, type FeishuCardActionEvent } from '../feishu/client.js'; +import { opencodeClient } from '../opencode/client.js'; +import { chatSessionStore } from '../store/chat-session.js'; +import { + buildCreateChatCard, + buildWelcomeCard, + CREATE_CHAT_NEW_SESSION_VALUE, + type CreateChatCardData, + type CreateChatSessionOption, +} from '../feishu/cards.js'; +import { DirectoryPolicy } from '../utils/directory-policy.js'; +import { buildSessionTimestamp } from '../utils/session-title.js'; +import { parseCommand, getHelpText, type ParsedCommand } from '../commands/parser.js'; +import { commandHandler } from './command.js'; +import { groupHandler } from './group.js'; +import { directoryConfig, userConfig } from '../config.js'; + +interface EnsurePrivateSessionResult { + firstBinding: boolean; +} + +type OpencodeSession = Awaited>[number]; + +const CREATE_CHAT_OPTION_LIMIT = 100; +const CREATE_CHAT_EXISTING_LIMIT = CREATE_CHAT_OPTION_LIMIT - 1; + +export class P2PHandler { + private static readonly CARD_SELECTION_TTL_MS = 10 * 60 * 1000; // 10 分钟 + + private createChatSelectionMap: Map = new Map(); + private createChatProjectMap: Map = new Map(); + private createChatDirectoryInputMap: Map = new Map(); + private createChatNameInputMap: Map = new Map(); + + private async safeReply( + messageId: string | undefined, + chatId: string | undefined, + text: string + ): Promise { + if (messageId) { + await feishuClient.reply(messageId, text); + return true; + } + + if (chatId) { + await feishuClient.sendText(chatId, text); + return true; + } + + return false; + } + + private getStringValue(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; + } + + private getCardActionOption(event: FeishuCardActionEvent): string | undefined { + const actionRecord = event.action as unknown as Record; + const option = actionRecord.option; + if (typeof option === 'string') { + return this.getStringValue(option); + } + + if (option && typeof option === 'object') { + const optionRecord = option as Record; + return this.getStringValue(optionRecord.value) || this.getStringValue(optionRecord.key); + } + + return undefined; + } + + private getCardActionInputValue(event: FeishuCardActionEvent): string | undefined { + const actionRecord = event.action as unknown as Record; + const rawValue = actionRecord.value; + const actionValue = rawValue && typeof rawValue === 'object' + ? rawValue as Record + : {}; + + return this.getStringValue(actionValue.inputValue) + || this.getStringValue(actionValue.input_value) + || this.getStringValue(actionValue.value) + || this.getStringValue(actionValue.text) + || this.getStringValue(actionRecord.inputValue) + || this.getStringValue(actionRecord.input_value) + || this.getStringValue(actionRecord.value); + } + + private getCreateChatSelectionKeys(chatId?: string, messageId?: string, openId?: string): string[] { + const keys: string[] = []; + const normalizedMessageId = this.getStringValue(messageId); + const normalizedChatId = this.getStringValue(chatId); + const normalizedOpenId = this.getStringValue(openId); + + if (normalizedMessageId) { + keys.push(`msg:${normalizedMessageId}`); + } + if (normalizedChatId && normalizedOpenId) { + keys.push(`chat:${normalizedChatId}:user:${normalizedOpenId}`); + } + + return keys; + } + + private rememberCreateChatSelection( + selectedSessionId: string, + chatId?: string, + messageId?: string, + openId?: string + ): void { + const normalized = this.getStringValue(selectedSessionId); + if (!normalized) return; + + const keys = this.getCreateChatSelectionKeys(chatId, messageId, openId); + for (const key of keys) { + this.createChatSelectionMap.set(key, normalized); + } + } + + private getRememberedCreateChatSelection(chatId?: string, messageId?: string, openId?: string): string | undefined { + const keys = this.getCreateChatSelectionKeys(chatId, messageId, openId); + for (const key of keys) { + const selected = this.createChatSelectionMap.get(key); + if (selected) { + return selected; + } + } + return undefined; + } + + private clearCreateChatSelection(chatId?: string, messageId?: string, openId?: string): void { + const keys = this.getCreateChatSelectionKeys(chatId, messageId, openId); + for (const key of keys) { + this.createChatSelectionMap.delete(key); + } + } + + private rememberCreateChatProjectSelection( + project: string, + chatId: string, + messageId?: string | null, + openId?: string + ): void { + const key = `${chatId}:${openId || ''}`; + this.createChatProjectMap.set(key, { + value: project, + expiresAt: Date.now() + P2PHandler.CARD_SELECTION_TTL_MS, + }); + } + + private getRememberedCreateChatProjectSelection( + chatId: string, + messageId?: string | null, + openId?: string + ): string | undefined { + const key = `${chatId}:${openId || ''}`; + const entry = this.createChatProjectMap.get(key); + if (!entry) { + return undefined; + } + if (entry.expiresAt <= Date.now()) { + this.createChatProjectMap.delete(key); + return undefined; + } + return entry.value; + } + + private clearCreateChatProjectSelection( + chatId: string, + messageId?: string | null, + openId?: string + ): void { + const key = `${chatId}:${openId || ''}`; + this.createChatProjectMap.delete(key); + } + + private rememberCreateChatDirectoryInput( + value: string, + chatId: string, + messageId?: string | null, + openId?: string + ): void { + const key = `${chatId}:${openId || ''}`; + this.createChatDirectoryInputMap.set(key, { + value, + expiresAt: Date.now() + P2PHandler.CARD_SELECTION_TTL_MS, + }); + } + + private getRememberedCreateChatDirectoryInput( + chatId: string, + messageId?: string | null, + openId?: string + ): string | undefined { + const key = `${chatId}:${openId || ''}`; + const entry = this.createChatDirectoryInputMap.get(key); + if (!entry) { + return undefined; + } + if (entry.expiresAt <= Date.now()) { + this.createChatDirectoryInputMap.delete(key); + return undefined; + } + return entry.value; + } + + private clearCreateChatDirectoryInput( + chatId: string, + messageId?: string | null, + openId?: string + ): void { + const key = `${chatId}:${openId || ''}`; + this.createChatDirectoryInputMap.delete(key); + } + + private rememberCreateChatNameInput( + value: string, + chatId: string, + messageId?: string | null, + openId?: string + ): void { + const key = `${chatId}:${openId || ''}`; + this.createChatNameInputMap.set(key, { + value, + expiresAt: Date.now() + P2PHandler.CARD_SELECTION_TTL_MS, + }); + } + + private getRememberedCreateChatNameInput( + chatId: string, + messageId?: string | null, + openId?: string + ): string | undefined { + const key = `${chatId}:${openId || ''}`; + const entry = this.createChatNameInputMap.get(key); + if (!entry) { + return undefined; + } + if (entry.expiresAt <= Date.now()) { + this.createChatNameInputMap.delete(key); + return undefined; + } + return entry.value; + } + + private clearCreateChatNameInput( + chatId: string, + messageId?: string | null, + openId?: string + ): void { + const key = `${chatId}:${openId || ''}`; + this.createChatNameInputMap.delete(key); + } + + + private getSessionDirectory(session: OpencodeSession): string { + return typeof session.directory === 'string' && session.directory.trim().length > 0 + ? session.directory.trim() + : '/'; + } +private getSessionOptionLabel(session: OpencodeSession, highlightWorkspace: boolean): string { + const title = typeof session.title === 'string' && session.title.trim().length > 0 + ? session.title.trim() + : '未命名会话'; + const compactTitle = title.length > 24 ? `${title.slice(0, 24)}...` : title; + const directory = this.getSessionDirectory(session); + const compactDirectory = directory.length > 18 ? `...${directory.slice(-18)}` : directory; + const shortId = session.id.slice(0, 8); + const workspaceLabel = highlightWorkspace ? `【${compactDirectory}】` : compactDirectory; + return `${workspaceLabel} / ${shortId} / ${compactTitle}`; + } + + private sortSessionsForCreateChat(sessions: OpencodeSession[]): OpencodeSession[] { + return [...sessions].sort((a, b) => { + const directoryCompare = this.getSessionDirectory(a).localeCompare(this.getSessionDirectory(b), 'zh-Hans-CN'); + if (directoryCompare !== 0) { + return directoryCompare; + } + + const left = b.time?.updated ?? b.time?.created ?? 0; + const right = a.time?.updated ?? a.time?.created ?? 0; + if (left !== right) { + return left - right; + } + + return a.id.localeCompare(b.id, 'en'); + }); + } + + private async buildCreateChatCardData(selectedSessionId?: string): Promise { + const sessionOptions: CreateChatSessionOption[] = [ + { + label: '新建 OpenCode 会话(默认)', + value: CREATE_CHAT_NEW_SESSION_VALUE, + }, + ]; + + let totalSessionCount = 0; + let projectOptions: Array<{ name: string; directory: string; source: 'alias' | 'history' }> = []; + let sessions: OpencodeSession[] = []; + if (userConfig.enableManualSessionBind) { + try { + sessions = this.sortSessionsForCreateChat(await opencodeClient.listSessionsAcrossProjects()); + totalSessionCount = sessions.length; + + let previousDirectory = ''; + for (const session of sessions.slice(0, CREATE_CHAT_EXISTING_LIMIT)) { + const directory = this.getSessionDirectory(session); + sessionOptions.push({ + label: this.getSessionOptionLabel(session, directory !== previousDirectory), + value: session.id, + }); + previousDirectory = directory; + } + } catch (error) { + console.warn('[P2P] 加载 OpenCode 会话列表失败,建群卡片将仅显示新建选项:', error); + } + } + + const storeKnownDirs = chatSessionStore.getKnownDirectories(); + const knownDirs = [...new Set([...storeKnownDirs, ...sessions.map(session => session.directory).filter(Boolean)])]; + projectOptions = DirectoryPolicy.listAvailableProjects(knownDirs).map(project => ({ + ...project, + source: project.source === 'alias' ? 'alias' : 'history', + })); + + const hasSelected = sessionOptions.some(option => option.value === selectedSessionId); + return { + selectedSessionId: hasSelected ? selectedSessionId : CREATE_CHAT_NEW_SESSION_VALUE, + sessionOptions, + totalSessionCount, + manualBindEnabled: userConfig.enableManualSessionBind, + projectOptions, + allowCustomPath: directoryConfig.isAllowlistEnforced, + }; + } + + private async pushCreateChatCard( + chatId: string, + messageId?: string, + selectedSessionId?: string, + openId?: string + ): Promise { + const cardData = await this.buildCreateChatCardData(selectedSessionId); + const card = buildCreateChatCard(cardData); + let sentCardMessageId: string | null = null; + if (messageId) { + sentCardMessageId = await feishuClient.replyCard(messageId, card); + } else { + sentCardMessageId = await feishuClient.sendCard(chatId, card); + } + + this.rememberCreateChatSelection( + selectedSessionId || CREATE_CHAT_NEW_SESSION_VALUE, + chatId, + sentCardMessageId || messageId, + openId + ); + } + + private getPrivateSessionShortId(openId: string): string { + const normalized = openId.startsWith('ou_') ? openId.slice(3) : openId; + return normalized.slice(0, 4); + } + + private getPrivateSessionTitle(_openId: string): string { + return `私聊-${buildSessionTimestamp()}`; + } + + private isCreateGroupCommand(text: string): boolean { + const trimmed = text.trim(); + const lowered = trimmed.toLowerCase(); + return ( + lowered === '/create_chat' || + lowered === '/create-chat' || + lowered === '/chat new' || + lowered === '/group new' || + trimmed === '/建群' || + trimmed === '建群' + ); + } + + private async isSessionMissingInOpenCode(sessionId: string): Promise { + try { + const session = await opencodeClient.findSessionAcrossProjects(sessionId); + return !session; + } catch (error) { + console.warn('[P2P] 校验会话存在性失败,保持当前绑定:', error); + return false; + } + } + + private async ensurePrivateSession(chatId: string, senderId: string): Promise { + const current = chatSessionStore.getSession(chatId); + if (current?.sessionId) { + const missing = await this.isSessionMissingInOpenCode(current.sessionId); + if (!missing) { + return { + firstBinding: false, + }; + } + + console.log(`[P2P] 检测到绑定会话已删除,重新初始化: chat=${chatId}, session=${current.sessionId}`); + chatSessionStore.removeSession(chatId); + } + + try { + const sessionTitle = this.getPrivateSessionTitle(senderId); + const chatDefault = chatSessionStore.getSession(chatId)?.defaultDirectory; + const dirResult = DirectoryPolicy.resolve({ chatDefaultDirectory: chatDefault }); + const effectiveDir = dirResult.ok && dirResult.source !== 'server_default' ? dirResult.directory : undefined; + const session = await opencodeClient.createSession(sessionTitle, effectiveDir); + chatSessionStore.setSession(chatId, session.id, senderId, sessionTitle, { chatType: 'p2p', resolvedDirectory: session.directory }); + return { + firstBinding: true, + }; + } catch (error) { + console.error('[P2P] 初始化私聊会话失败:', error); + return null; + } + } + + private shouldSkipImmediateCommand(command: ParsedCommand): boolean { + if (command.type === 'help' || command.type === 'panel') { + return true; + } + + return command.type === 'session' && command.sessionAction === 'new'; + } + + private async pushFirstContactGuidance(chatId: string, senderId: string, messageId: string): Promise { + const createChatData = await this.buildCreateChatCardData(); + const card = buildWelcomeCard(senderId, createChatData); + const welcomeCardMessageId = await feishuClient.sendCard(chatId, card); + this.rememberCreateChatSelection( + CREATE_CHAT_NEW_SESSION_VALUE, + chatId, + welcomeCardMessageId || undefined, + senderId + ); + await this.safeReply(messageId, chatId, getHelpText()); + + try { + await commandHandler.pushPanelCard(chatId, 'p2p'); + } catch (error) { + console.warn('[P2P] 发送私聊控制面板失败:', error); + } + } + + // 处理私聊消息 + async handleMessage(event: FeishuMessageEvent): Promise { + const { chatId, content, senderId, messageId } = event; + const trimmedContent = content.trim(); + + // 1. 检查命令 + const command = parseCommand(content); + + // 2. 首次私聊(或绑定会话在 OpenCode 中已被删除)时,自动初始化并推送引导 + const ensured = await this.ensurePrivateSession(chatId, senderId); + if (!ensured) { + await this.safeReply(messageId, chatId, '❌ 初始化私聊会话失败,请稍后重试'); + return; + } + + if (ensured.firstBinding) { + await this.pushFirstContactGuidance(chatId, senderId, messageId); + if (this.shouldSkipImmediateCommand(command)) { + return; + } + } + + // 3.1 私聊专属建群快捷命令 + if (this.isCreateGroupCommand(trimmedContent)) { + await this.pushCreateChatCard(chatId, messageId, CREATE_CHAT_NEW_SESSION_VALUE, senderId); + return; + } + + // 3. 私聊命令 + if (command.type !== 'prompt') { + console.log(`[P2P] 收到命令: ${command.type}`); + await commandHandler.handle(command, { + chatId, + messageId, + senderId, + chatType: 'p2p' + }); + return; + } + + // 4. 私聊普通消息:按群聊同样逻辑转发到 OpenCode + console.log(`[P2P] 收到私聊消息: user=${senderId}, content=${content.slice(0, 20)}...`); + await groupHandler.handleMessage(event); + } + + private async ensureUserInGroup( + chatId: string, + openId: string, + invalidUserIds: string[] + ): Promise<{ ok: boolean; message?: string }> { + const userInvalidOnCreate = invalidUserIds.includes(openId); + if (userInvalidOnCreate) { + console.warn(`[P2P] 用户 ${openId} 在创建群时被标记为无效,尝试手动拉取...`); + } + + // 重试机制:飞书 createChat API 的成员添加可能是异步的,需要等待同步完成 + const maxRetries = 3; + const retryDelayMs = 500; + + for (let attempt = 0; attempt < maxRetries; attempt++) { + let members = await feishuClient.getChatMembers(chatId); + if (members.includes(openId)) { + if (attempt > 0) { + console.log(`[P2P] 重试 ${attempt + 1}/${maxRetries} 次后确认用户 ${openId} 在群 ${chatId} 中`); + } + return { ok: true }; + } + + // 如果是首次检查,且创建时被标记为无效,直接尝试手动拉取 + if (attempt === 0 && userInvalidOnCreate) { + console.warn(`[P2P] 用户 ${openId} 未在新建群 ${chatId} 中,尝试手动拉取...`); + const added = await feishuClient.addChatMembers(chatId, [openId]); + if (!added) { + return { + ok: false, + message: '❌ 无法将您添加到群聊。请确保机器人具有"获取群组信息"和"更新群组信息"权限,且您在机器人的可见范围内。', + }; + } + // 拉取成功后继续检查 + } + + // 如果不是最后一次重试,等待后重试 + if (attempt < maxRetries - 1) { + console.warn(`[P2P] 用户 ${openId} 尚未在群 ${chatId} 中,等待 ${retryDelayMs}ms 后重试 (${attempt + 1}/${maxRetries})...`); + await new Promise(resolve => setTimeout(resolve, retryDelayMs)); + continue; + } + + // 最后一次重试仍然失败,尝试手动拉取作为兜底 + console.warn(`[P2P] 重试 ${maxRetries} 次后用户 ${openId} 仍不在群 ${chatId} 中,最后一次尝试手动拉取...`); + const added = await feishuClient.addChatMembers(chatId, [openId]); + if (!added) { + return { + ok: false, + message: '❌ 无法将您添加到群聊。请确保机器人具有"获取群组信息"和"更新群组信息"权限,且您在机器人的可见范围内。', + }; + } + + // 手动拉取后再次检查 + members = await feishuClient.getChatMembers(chatId); + if (members.includes(openId)) { + return { ok: true }; + } + + return { + ok: false, + message: '❌ 创建群聊异常:无法确认成员状态,已自动清理无效群。', + }; + } + + // 理论上不会到达这里 + return { ok: false, message: '❌ 未知错误' }; + } + + private async findSessionById(sessionId: string): Promise { + try { + return await opencodeClient.findSessionAcrossProjects(sessionId); + } catch (error) { + console.warn('[P2P] 查询 OpenCode 会话列表失败:', error); + return null; + } + } + + private async createGroupWithSessionSelection( + openId: string, + selectedSessionId: string, + chatId?: string, + messageId?: string, + rawDirectory?: string, + customChatName?: string + ): Promise { + const bindExistingSession = selectedSessionId !== CREATE_CHAT_NEW_SESSION_VALUE; + if (bindExistingSession && !userConfig.enableManualSessionBind) { + await this.safeReply(messageId, chatId, '❌ 当前环境未开启"绑定已有会话"能力'); + return; + } + + let effectiveDir: string | undefined; + if (!bindExistingSession && rawDirectory) { + const dirResult = DirectoryPolicy.resolve({ explicitDirectory: rawDirectory }); + if (!dirResult.ok) { + console.warn(`[P2P] 建群目录校验失败: ${dirResult.internalDetail || dirResult.code}`); + await this.safeReply(messageId, chatId, dirResult.userMessage); + return; + } + effectiveDir = dirResult.source === 'server_default' ? undefined : dirResult.directory; + } + + console.log(`[P2P] 用户 ${openId} 请求创建新会话群,模式=${bindExistingSession ? '绑定已有会话' : '新建会话'}`); + + // 使用用户指定的群名,或自动生成 + const chatName = customChatName || `会话-${Date.now().toString().slice(-6)}`; + const createResult = await feishuClient.createChat(chatName, [openId], '由 OpenCode 自动创建的会话群'); + if (!createResult.chatId) { + await this.safeReply(messageId, chatId, '❌ 创建群聊失败,请重试'); + return; + } + + const newChatId = createResult.chatId; + console.log(`[P2P] 群聊已创建,ID: ${newChatId}`); + + const userInGroup = await this.ensureUserInGroup(newChatId, openId, createResult.invalidUserIds); + if (!userInGroup.ok) { + await feishuClient.disbandChat(newChatId); + await this.safeReply(messageId, chatId, userInGroup.message || '❌ 创建群聊失败,请重试'); + return; + } + + console.log(`[P2P] 用户 ${openId} 已确认在群 ${newChatId} 中`); + + let targetSessionId = ''; + let sessionTitle = `飞书群聊: ${chatName}`; + let protectSessionDelete = false; + let targetDirectory: string | undefined; + + if (bindExistingSession) { + const selectedSession = await this.findSessionById(selectedSessionId); + if (!selectedSession) { + await feishuClient.disbandChat(newChatId); + await this.safeReply(messageId, chatId, `❌ 未找到会话: ${selectedSessionId},请重新选择`); + return; + } + + targetSessionId = selectedSession.id; + sessionTitle = selectedSession.title || sessionTitle; + protectSessionDelete = true; + targetDirectory = selectedSession.directory; + } else { + let session; + try { + session = await opencodeClient.createSession(sessionTitle, effectiveDir); + } catch (error) { + console.error('[P2P] 创建 OpenCode 会话异常:', error); + await feishuClient.disbandChat(newChatId); + await this.safeReply(messageId, chatId, '❌ 创建 OpenCode 会话失败,请重试'); + return; + } + if (!session) { + await feishuClient.disbandChat(newChatId); + await this.safeReply(messageId, chatId, '❌ 创建 OpenCode 会话失败,请重试'); + return; + } + targetSessionId = session.id; + targetDirectory = session.directory; + } + + const previousChatId = chatSessionStore.getChatId(targetSessionId); + if (previousChatId && previousChatId !== newChatId) { + chatSessionStore.removeSession(previousChatId); + console.log(`[P2P] 已迁移会话绑定: session=${targetSessionId}, from=${previousChatId}, to=${newChatId}`); + } + + chatSessionStore.setSession( + newChatId, + targetSessionId, + openId, + sessionTitle, + { protectSessionDelete, chatType: 'group', resolvedDirectory: targetDirectory } + ); + // 建群时指定的目录同时设为群默认,后续 /session new 无参数时自动继承 + if (targetDirectory) { + chatSessionStore.updateConfig(newChatId, { defaultDirectory: targetDirectory }); + } + console.log(`[P2P] 已绑定会话: Chat=${newChatId}, Session=${targetSessionId}`); + + const noticeLines = ['✅ 会话群已创建!', '正在为您跳转...']; + if (bindExistingSession) { + noticeLines.push('🔒 该会话已开启“删除保护”:自动清理不会删除 OpenCode 会话。'); + } + if (previousChatId && previousChatId !== newChatId) { + noticeLines.push('🔁 已将该会话从旧群迁移到当前新群。'); + } + await this.safeReply(messageId, chatId, noticeLines.join('\n')); + + const onboardingText = bindExistingSession + ? [ + '🔗 已绑定已有 OpenCode 会话,直接发送需求即可继续之前上下文。', + '🎭 使用 /panel 选择角色,使用 /help 查看完整命令。', + ].join('\n') + : [ + '👋 会话已就绪,直接发送需求即可开始。', + '🎭 使用 /panel 选择角色,使用 /help 查看完整命令。', + '🧩 可创建自定义角色:创建角色 名称=旅行助手; 描述=擅长规划行程; 类型=主; 工具=webfetch', + ].join('\n'); + await feishuClient.sendText(newChatId, onboardingText); + + try { + await commandHandler.pushPanelCard(newChatId); + } catch (error) { + console.warn('[P2P] 发送开场控制面板失败:', error); + } + + } + + // 处理私聊中的卡片动作 + async handleCardAction(event: FeishuCardActionEvent): Promise { + const { openId, chatId, messageId } = event; + const actionValue = event.action.value && typeof event.action.value === 'object' + ? event.action.value + : {}; + const actionTag = this.getStringValue(actionValue.action); + + if (!actionTag) { + return; + } + + if (!chatId) { + return { + toast: { + type: 'error', + content: '无法定位私聊会话', + i18n_content: { zh_cn: '无法定位私聊会话', en_us: 'Failed to locate private chat' }, + }, + }; + } + + if (actionTag === 'create_chat') { + await this.pushCreateChatCard(chatId, messageId, CREATE_CHAT_NEW_SESSION_VALUE, openId); + return { + toast: { + type: 'success', + content: '已打开建群选项', + i18n_content: { zh_cn: '已打开建群选项', en_us: 'Create chat options opened' }, + }, + }; + } + + if (actionTag === 'create_chat_select') { + const selectedSessionId = + this.getCardActionOption(event) || + this.getStringValue(actionValue.selectedSessionId) || + this.getStringValue(actionValue.selected) || + CREATE_CHAT_NEW_SESSION_VALUE; + + this.rememberCreateChatSelection(selectedSessionId, chatId, messageId, openId); + return { + toast: { + type: 'success', + content: '已记录会话选择', + i18n_content: { zh_cn: '已记录会话选择', en_us: 'Session selection saved' }, + }, + }; + } + + if (actionTag === 'create_chat_project_select') { + const selectedProject = + this.getCardActionOption(event) || + this.getStringValue(actionValue.selectedProject) || + '__default__'; + this.rememberCreateChatProjectSelection(selectedProject, chatId, messageId, openId); + return { + toast: { + type: 'success', + content: '已记录项目选择', + i18n_content: { zh_cn: '已记录项目选择', en_us: 'Project selection saved' }, + }, + }; + } + + if (actionTag === 'create_chat_directory_input') { + const inputValue = this.getCardActionOption(event) + || this.getCardActionInputValue(event) + || ''; + if (inputValue) { + this.rememberCreateChatDirectoryInput(inputValue, chatId, messageId, openId); + } + // 输入不需要 toast + return; + } + + if (actionTag === 'create_chat_name_input') { + const inputValue = this.getCardActionOption(event) + || this.getCardActionInputValue(event) + || ''; + if (inputValue) { + this.rememberCreateChatNameInput(inputValue, chatId, messageId, openId); + } + // 输入不需要 toast + return; + } + + if (actionTag === 'create_chat_submit') { + // form 容器提交时,所有 input/select_static 值均在 action.form_value 中 + const eventAny = event as unknown as { action?: { form_value?: Record } }; + const formValue = eventAny.action?.form_value; + + // 会话来源:优先从 form_value 读,回退到记忆 map + const selectedSessionId = + formValue?.session_source?.trim() || + this.getRememberedCreateChatSelection(chatId, messageId, openId) || + this.getStringValue(actionValue.selectedSessionId) || + CREATE_CHAT_NEW_SESSION_VALUE; + + // 工作项目:优先从 form_value 读,回退到记忆 map + const selectedProject = + formValue?.project_source?.trim() || + this.getRememberedCreateChatProjectSelection(chatId, messageId, openId); + + // 自定义工作目录 + const customPath = formValue?.custom_directory?.trim() + || this.getRememberedCreateChatDirectoryInput(chatId, messageId, openId) + || ''; + + // 群名:优先从 form_value 读,回退到记忆 map + const customChatName = + formValue?.chat_name?.trim() || + this.getRememberedCreateChatNameInput(chatId, messageId, openId) || + undefined; + + const rawDirectory = selectedProject && selectedProject !== '__default__' + ? selectedProject + : (customPath || undefined); + + this.clearCreateChatSelection(chatId, messageId, openId); + this.clearCreateChatProjectSelection(chatId, messageId, openId); + this.clearCreateChatDirectoryInput(chatId, messageId, openId); + this.clearCreateChatNameInput(chatId, messageId, openId); + + // 🔧 异步执行建群逻辑,避免飞书回调超时(限制 3 秒) + // 后台执行,不阻塞响应 + setImmediate(() => { + this.createGroupWithSessionSelection(openId, selectedSessionId, chatId, messageId, rawDirectory, customChatName) + .catch(error => { + console.error('[P2P] 后台建群失败:', error); + }); + }); + + return; + } + } +} + +export const p2pHandler = new P2PHandler();