Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 164 additions & 0 deletions packages/api/src/infrastructure/connectors/ConnectorCommandLayer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,17 @@ export interface CommandResult {
| 'commands'
| 'cats'
| 'status'
| 'focus'
| 'ask'
| 'not-command';
readonly response?: string;
readonly newActiveThreadId?: string;
/** Thread context for storing command exchange in messageStore */
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 {
Expand All @@ -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<ThreadEntry[]>;
/** Update preferredCats for a thread */
updatePreferredCats?(threadId: string, catIds: string[]): void | Promise<void>;
};
/** Phase D: optional backlog store for feat-number matching in /use */
readonly backlogStore?: {
Expand Down Expand Up @@ -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' };
}
Expand Down Expand Up @@ -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<CommandResult> {
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<CommandResult> {
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<string, string> = {
'宪宪': '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;
}
}
60 changes: 58 additions & 2 deletions packages/api/src/infrastructure/connectors/ConnectorRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ const FALLBACK_COMMANDS = [
{ cmd: '/use <F号|序号|关键词>', desc: '切换到指定 thread' },
{ cmd: '/thread <id> <消息>', desc: '切换并发送消息' },
{ cmd: '/unbind', desc: '解除当前绑定' },
{ cmd: '/focus <猫名>', desc: '设置当前 thread 的首选猫' },
{ cmd: '/ask <猫名> <消息>', desc: '单次定向:让指定猫回复这条消息' },
Comment on lines +56 to +57
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Register new connector commands in the shared registry

Adding /focus and /ask only to FALLBACK_COMMANDS does not make them visible in normal runtime, because buildCommandsList() prefers registry.listBySurface('connector') whenever a CommandRegistry is wired (the default gateway path). As a result, users can execute the new commands but /commands will still omit them in production, which breaks command discoverability.

Useful? React with 👍 / 👎.

];

export function buildCommandsList(registry?: CommandRegistry): CommandResult {
Expand Down
16 changes: 16 additions & 0 deletions packages/shared/src/core-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;