diff --git a/packages/api/src/infrastructure/connectors/ConnectorCommandLayer.ts b/packages/api/src/infrastructure/connectors/ConnectorCommandLayer.ts index 9d84e1002..1eee7f8c2 100644 --- a/packages/api/src/infrastructure/connectors/ConnectorCommandLayer.ts +++ b/packages/api/src/infrastructure/connectors/ConnectorCommandLayer.ts @@ -28,6 +28,8 @@ export interface CommandResult { | 'commands' | 'cats' | 'status' + | 'focus' + | 'ask' | 'not-command'; readonly response?: string; readonly newActiveThreadId?: string; @@ -35,6 +37,8 @@ export interface CommandResult { readonly contextThreadId?: string; /** Message content to forward to target thread after switching (used by /thread) */ readonly forwardContent?: string; + /** For /ask: the catId to route this message to */ + readonly targetCatId?: string; } interface ThreadEntry { @@ -56,6 +60,8 @@ export interface ConnectorCommandLayerDeps { | Promise<{ id: string; title?: string | null; createdAt?: number } | null>; /** List threads owned by userId (sorted by lastActiveAt desc). Phase C: cross-platform thread view */ list(userId: string): ThreadEntry[] | Promise; + /** Update preferredCats for a thread */ + updatePreferredCats?(threadId: string, catIds: string[]): void | Promise; }; /** Phase D: optional backlog store for feat-number matching in /use */ readonly backlogStore?: { @@ -136,6 +142,10 @@ export class ConnectorCommandLayer { return this.handleAllowGroup(connectorId, externalChatId, senderId, cmdArgs); case '/deny-group': return this.handleDenyGroup(connectorId, externalChatId, senderId, cmdArgs); + case '/focus': + return this.handleFocus(connectorId, externalChatId, cmdArgs); + case '/ask': + return this.handleAsk(connectorId, externalChatId, cmdArgs); default: // F142-B: unrecognized commands flow to cat (AC-B4) return { kind: 'not-command' }; } @@ -348,4 +358,158 @@ export class ConnectorCommandLayer { : `⚠️ 群 ${targetChatId.slice(-8)} 不在白名单中`, }; } + + // --- Phase F: focus/ask commands for @-free routing --- + + private async handleFocus( + connectorId: string, + externalChatId: string, + catArg?: string, + ): Promise { + const binding = await this.deps.bindingStore.getByExternal(connectorId, externalChatId); + if (!binding) { + return { + kind: 'focus', + response: '⚠️ 当前没有绑定 thread,请先用 /new 创建或 /use 切换。', + }; + } + + if (!catArg) { + const thread = await this.deps.threadStore.get(binding.threadId); + const preferredCats = (thread as { preferredCats?: string[] })?.preferredCats; + if (preferredCats && preferredCats.length > 0) { + const roster = this.deps.catRoster ?? {}; + const names = preferredCats.map((id) => roster[id]?.displayName ?? id); + return { + kind: 'focus', + contextThreadId: binding.threadId, + response: `🎯 当前首选猫:${names.join('、')}`, + }; + } + return { + kind: 'focus', + contextThreadId: binding.threadId, + response: '🎯 当前没有设置首选猫。\n用法: /focus <猫名>(如: /focus opus)', + }; + } + + // Normalize catArg: handle common aliases + const catId = this.normalizeCatId(catArg); + if (!catId) { + return { + kind: 'focus', + response: `❌ 找不到猫 "${catArg}"。\n用 /cats 查看可用猫猫。`, + }; + } + + // Update preferredCats - fail if persistence unavailable + if (!this.deps.threadStore.updatePreferredCats) { + const roster = this.deps.catRoster ?? {}; + const displayName = roster[catId]?.displayName ?? catId; + return { + kind: 'focus', + response: `⚠️ 无法设置首选猫:${displayName}。\n\n当前环境不支持持久化存储,/focus 功能需要 threadStore.updatePreferredCats 方法。`, + }; + } + await this.deps.threadStore.updatePreferredCats(binding.threadId, [catId]); + + const roster = this.deps.catRoster ?? {}; + const displayName = roster[catId]?.displayName ?? catId; + return { + kind: 'focus', + contextThreadId: binding.threadId, + response: `🎯 已设置首选猫:${displayName}\n\n后续消息会默认发给它。用 /focus 不带参数可查看,用 /new 切换到新 thread 清除。`, + }; + } + + private async handleAsk( + connectorId: string, + externalChatId: string, + args?: string, + ): Promise { + if (!args) { + return { + kind: 'ask', + response: '❌ 用法: /ask <猫名> <消息>\n示例: /ask opus 帮我 review 这段代码', + }; + } + + // Parse cat name and message + const parts = args.trim().split(/\s+/); + if (parts.length < 2) { + return { + kind: 'ask', + response: '❌ 用法: /ask <猫名> <消息>\n示例: /ask opus 帮我 review 这段代码', + }; + } + + const catArg = parts[0]; + const message = parts.slice(1).join(' '); + + // Normalize catArg + const catId = this.normalizeCatId(catArg); + if (!catId) { + return { + kind: 'ask', + response: `❌ 找不到猫 "${catArg}"。\n用 /cats 查看可用猫猫。`, + }; + } + + // Get binding for contextThreadId + const binding = await this.deps.bindingStore.getByExternal(connectorId, externalChatId); + if (!binding) { + const roster = this.deps.catRoster ?? {}; + const displayName = roster[catId]?.displayName ?? catId; + return { + kind: 'ask', + response: `⚠️ 当前没有绑定 thread,无法发送消息给 ${displayName}。\n请先用 /new 创建或 /use 切换到已有 thread。`, + }; + } + + const roster = this.deps.catRoster ?? {}; + const displayName = roster[catId]?.displayName ?? catId; + + return { + kind: 'ask', + targetCatId: catId, + contextThreadId: binding.threadId, + response: `📨 → ${displayName}(单次定向,不改变默认猫)`, + forwardContent: message, + }; + } + + /** Normalize common cat name aliases to canonical catId */ + private normalizeCatId(input: string): string | null { + const normalized = input.toLowerCase().trim(); + const roster = this.deps.catRoster ?? {}; + + // Direct match + if (roster[normalized]) return normalized; + + // Alias mapping + const aliasMap: Record = { + '宪宪': 'opus', + '布偶猫': 'opus', + 'opus-46': 'opus', + 'opus46': 'opus', + '砚砚': 'codex', + '缅因猫': 'codex', + '烁烁': 'gemini', + '暹罗猫': 'gemini', + 'sonnet': 'sonnet', + 'spark': 'spark', + }; + + const mapped = aliasMap[normalized]; + if (mapped && roster[mapped]) return mapped; + + // Try partial match (case-insensitive) + for (const [id, entry] of Object.entries(roster)) { + if (id.toLowerCase().startsWith(normalized) || entry.displayName?.toLowerCase().includes(normalized)) { + return id; + } + } + + return null; + } } diff --git a/packages/api/src/infrastructure/connectors/ConnectorRouter.ts b/packages/api/src/infrastructure/connectors/ConnectorRouter.ts index fb3b225f0..9b4347a66 100644 --- a/packages/api/src/infrastructure/connectors/ConnectorRouter.ts +++ b/packages/api/src/infrastructure/connectors/ConnectorRouter.ts @@ -15,7 +15,7 @@ */ import type { CatId, ConnectorSource, MessageContent } from '@cat-cafe/shared'; -import { catRegistry, getConnectorDefinition } from '@cat-cafe/shared'; +import { catRegistry, createCatId, getConnectorDefinition } from '@cat-cafe/shared'; import type { FastifyBaseLogger } from 'fastify'; import { findMonorepoRoot } from '../../utils/monorepo-root.js'; import type { ConnectorCommandLayer } from './ConnectorCommandLayer.js'; @@ -299,6 +299,44 @@ export class ConnectorRouter { return { kind: 'routed', threadId: fwdThreadId, messageId: fwdStored.id }; } + // /ask: forward message content to current binding's thread with explicit target cat + if (cmdResult.kind === 'ask' && cmdResult.forwardContent && cmdResult.targetCatId) { + const fwdText = cmdResult.forwardContent; + // Convert string to CatId + const targetCatId: CatId = createCatId(cmdResult.targetCatId); + // Use current binding thread or hub thread + const fwdThreadId = cmdResult.contextThreadId ?? hubThreadId; + if (!fwdThreadId) { + log.warn({ connectorId }, '[ConnectorRouter] /ask: no thread to forward to'); + return { kind: 'skipped', reason: 'ask_no_thread' }; + } + const def2 = getConnectorDefinition(connectorId); + const fwdSource: ConnectorSource = { + connector: connectorId, + label: def2?.displayName ?? connectorId, + icon: def2?.icon ?? 'message', + }; + const fwdTimestamp = Date.now(); + const fwdStored = await messageStore.append({ + threadId: fwdThreadId, + userId: this.opts.defaultUserId, + catId: null, + content: fwdText, + source: fwdSource, + mentions: [targetCatId], + timestamp: fwdTimestamp, + }); + emitConnectorMessage(socketManager, fwdThreadId, { + id: fwdStored.id, + content: fwdText, + source: fwdSource, + timestamp: fwdTimestamp, + }); + invokeTrigger.trigger(fwdThreadId, targetCatId, this.opts.defaultUserId, fwdText, fwdStored.id); + log.info({ connectorId, threadId: fwdThreadId, targetCatId }, '[ConnectorRouter] /ask message forwarded'); + return { kind: 'routed', threadId: fwdThreadId, messageId: fwdStored.id }; + } + const result: RouteResult = { kind: 'command' }; if (hubThreadId) (result as { threadId?: string }).threadId = hubThreadId; if (stored?.responseId) (result as { messageId?: string }).messageId = stored.responseId; @@ -351,7 +389,25 @@ export class ConnectorRouter { const mentionPatterns = this.getMentionPatterns(); const mentionResult = parseMentions(resolvedText, mentionPatterns, this.opts.defaultCatId); let targetCatId = mentionResult.targetCatId; - if (!mentionResult.matched && this.opts.threadStore.getParticipantsWithActivity) { + + // If no @mention, check preferredCats first (P1: @-free routing) + let preferredCatsApplied = false; + if (!mentionResult.matched && this.opts.threadStore.get) { + const thread = await this.opts.threadStore.get(binding.threadId); + const preferredCats = (thread as { preferredCats?: CatId[] })?.preferredCats; + if (preferredCats && preferredCats.length > 0) { + const preferredCatId = preferredCats[0]; + // Validate preferred cat exists in registry (skip stale/disabled cats) + if (catRegistry.tryGet(preferredCatId)) { + targetCatId = preferredCatId; + preferredCatsApplied = true; + } + // If preferred cat doesn't exist, fall through to last-active cat + } + } + + // Fallback: last-active cat in thread (only if no mention AND preferredCats not set) + if (!mentionResult.matched && !preferredCatsApplied && this.opts.threadStore.getParticipantsWithActivity) { const participants = await this.opts.threadStore.getParticipantsWithActivity(binding.threadId); const lastActive = participants .filter((p) => p.messageCount > 0) diff --git a/packages/api/src/infrastructure/connectors/connector-command-helpers.ts b/packages/api/src/infrastructure/connectors/connector-command-helpers.ts index 42f65a6de..a93298b01 100644 --- a/packages/api/src/infrastructure/connectors/connector-command-helpers.ts +++ b/packages/api/src/infrastructure/connectors/connector-command-helpers.ts @@ -53,6 +53,8 @@ const FALLBACK_COMMANDS = [ { cmd: '/use ', desc: '切换到指定 thread' }, { cmd: '/thread <消息>', desc: '切换并发送消息' }, { cmd: '/unbind', desc: '解除当前绑定' }, + { cmd: '/focus <猫名>', desc: '设置当前 thread 的首选猫' }, + { cmd: '/ask <猫名> <消息>', desc: '单次定向:让指定猫回复这条消息' }, ]; export function buildCommandsList(registry?: CommandRegistry): CommandResult { diff --git a/packages/shared/src/core-commands.ts b/packages/shared/src/core-commands.ts index dd7453cac..d976bad6f 100644 --- a/packages/shared/src/core-commands.ts +++ b/packages/shared/src/core-commands.ts @@ -246,4 +246,20 @@ export const CORE_COMMANDS: readonly SlashCommandDefinition[] = [ surface: 'connector', source: 'core', }, + { + name: '/focus', + usage: '/focus <猫名>', + description: '设置当前 thread 的首选猫', + category: 'connector', + surface: 'connector', + source: 'core', + }, + { + name: '/ask', + usage: '/ask <猫名> <消息>', + description: '单次定向:让指定猫回复这条消息', + category: 'connector', + surface: 'connector', + source: 'core', + }, ] as const;