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/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 3b90b7a..e232915 100644 --- a/src/handlers/p2p.ts +++ b/src/handlers/p2p.ts @@ -1,812 +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, type DirectorySource } 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; - const knownDirectorySet = new Set(chatSessionStore.getKnownDirectories()); - if (userConfig.enableManualSessionBind) { - try { - const 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; - 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 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: Awaited> | null = null; - 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(); 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..6c51f8d 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; @@ -329,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(); @@ -1404,6 +1424,72 @@ class OpencodeClientWrapper extends EventEmitter { return agents; } + // 获取可用命令列表(slash command) + async getCommands(): Promise { + // 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 工具) // answers 是一个二维数组: [[第一个问题的答案们], [第二个问题的答案们], ...] // 每个答案是选项的 label 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'); + }); +});