Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
158 changes: 158 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,152 @@ 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
if (this.deps.threadStore.updatePreferredCats) {
await this.deps.threadStore.updatePreferredCats(binding.threadId, [catId]);
}
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 Fail /focus when preferred-cat persistence is unavailable

This branch silently skips persistence if threadStore.updatePreferredCats is missing, but the handler still returns a success message (已设置首选猫). In any deployment or test wiring that uses a thread store without that optional method, /focus appears to succeed while routing behavior never changes, creating a hard-to-debug false-positive state.

Useful? React with 👍 / 👎.


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;
}
}
56 changes: 54 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,21 @@ 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) {
// Use first preferred cat
targetCatId = preferredCats[0];
preferredCatsApplied = true;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Validate preferredCats before forcing routing target

The connector router now takes preferredCats[0] as the target without checking whether that cat is currently routable. If a thread stores a stale/disabled cat ID (for example after catalog or provider changes), every non-mention message in that thread is forced to an invalid target instead of falling back, causing invocation failures rather than a reply.

Useful? React with 👍 / 👎.

}
}

// 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