diff --git a/src/channels/adapter.ts b/src/channels/adapter.ts index 34b3675..bbf7f37 100644 --- a/src/channels/adapter.ts +++ b/src/channels/adapter.ts @@ -60,6 +60,21 @@ export interface InboundMessage { kind: 'chat' | 'chat-sdk'; content: unknown; // JS object — host will JSON.stringify before writing to session DB timestamp: string; + /** + * Platform-confirmed signal that this message is a mention of the bot. + * + * Set by adapters that know the platform's own mention semantics — e.g. + * the Chat SDK bridge sets it true from `onNewMention` / `onDirectMessage` + * and forwards `message.isMention` from `onSubscribedMessage`. Use this + * in the router instead of agent-name regex matching, which breaks on + * platforms where the mention text is the bot's platform username (e.g. + * Telegram's `@nanoclaw_v2_refactr_1_bot`) rather than the agent_group + * display name (e.g. `@Andy`). + * + * Adapters that don't set it (native / legacy) leave it undefined — the + * router falls back to text-match against agent_group_name. + */ + isMention?: boolean; } /** A file attachment to deliver alongside a message. */ diff --git a/src/channels/chat-sdk-bridge.ts b/src/channels/chat-sdk-bridge.ts index aa980ab..bea4c16 100644 --- a/src/channels/chat-sdk-bridge.ts +++ b/src/channels/chat-sdk-bridge.ts @@ -210,7 +210,7 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter return shouldEngage(conversations, channelId, source, text); } - async function messageToInbound(message: ChatMessage): Promise { + async function messageToInbound(message: ChatMessage, isMention: boolean): Promise { // eslint-disable-next-line @typescript-eslint/no-explicit-any const serialized = message.toJSON() as Record; @@ -266,6 +266,7 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter kind: 'chat-sdk', content: serialized, timestamp: message.metadata.dateSent.toISOString(), + isMention, }; } @@ -296,7 +297,10 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter const text = typeof message.text === 'string' ? message.text : ''; const decision = engageDecision(channelId, 'subscribed', text); if (!decision.forward) return; - await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message)); + // Subscribed path: the SDK sets message.isMention when the bot was + // @-mentioned in an already-subscribed thread (docs at + // handling-events.mdx). Forward it verbatim. + await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, message.isMention === true)); }); // @mention in an unsubscribed thread — always engage; subscribe only @@ -306,7 +310,8 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter const text = typeof message.text === 'string' ? message.text : ''; const decision = engageDecision(channelId, 'mention', text); if (!decision.forward) return; - await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message)); + // onNewMention only fires when the SDK confirms the bot was mentioned. + await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, true)); if (decision.stickySubscribe) { await thread.subscribe(); } @@ -332,7 +337,9 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter forward: decision.forward, }); if (!decision.forward) return; - await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message)); + // A DM is by definition addressed to the bot — treat as a mention + // for routing purposes. `mention` / `mention-sticky` wirings fire. + await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, true)); if (decision.stickySubscribe) { await thread.subscribe(); } @@ -357,7 +364,9 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter const text = typeof message.text === 'string' ? message.text : ''; const decision = engageDecision(channelId, 'new-message', text); if (!decision.forward) return; - await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message)); + // SDK dispatch guarantees this is a non-mention non-DM message in an + // unsubscribed thread — isMention is definitively false here. + await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, false)); }); // Handle button clicks (ask_user_question) diff --git a/src/index.ts b/src/index.ts index 4958eef..595ba1b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -83,6 +83,7 @@ async function main(): Promise { kind: message.kind, content: JSON.stringify(message.content), timestamp: message.timestamp, + isMention: message.isMention, }, }).catch((err) => { log.error('Failed to route inbound message', { channelType: adapter.channelType, err }); diff --git a/src/router.ts b/src/router.ts index cb4ee93..a3e8f06 100644 --- a/src/router.ts +++ b/src/router.ts @@ -42,6 +42,15 @@ export interface InboundEvent { kind: 'chat' | 'chat-sdk'; content: string; // JSON blob timestamp: string; + /** + * Platform-confirmed bot-mention signal forwarded from the adapter. + * When defined, it's authoritative — use this instead of text-matching + * agent_group_name, which breaks on platforms where the mention token + * is the bot's platform username (e.g. Telegram). undefined means the + * adapter doesn't provide the signal; evaluateEngage falls back to + * agent-name regex. + */ + isMention?: boolean; }; } @@ -194,6 +203,7 @@ export async function routeInbound(event: InboundEvent): Promise { // engage later. Drop policy = skip silently. const parsed = safeParseContent(event.message.content); const messageText = parsed.text ?? ''; + const isMention = event.message.isMention === true; let engagedCount = 0; let accumulatedCount = 0; @@ -202,7 +212,7 @@ export async function routeInbound(event: InboundEvent): Promise { const agentGroup = getAgentGroup(agent.agent_group_id); if (!agentGroup) continue; - const engages = evaluateEngage(agent, agentGroup, messageText, mg, event.threadId); + const engages = evaluateEngage(agent, messageText, isMention, mg, event.threadId); const accessOk = engages && (!accessGate || accessGate(event, userId, mg, agent.agent_group_id).allowed); const scopeOk = engages && (!senderScopeGate || senderScopeGate(event, userId, mg, agent).allowed); @@ -241,17 +251,26 @@ export async function routeInbound(event: InboundEvent): Promise { * Decide whether a given wired agent should engage on this message. * * 'pattern' — regex test on text; '.' = always - * 'mention' — bot must be @-mentioned by its agent-group name - * 'mention-sticky' — @mention OR an active per-thread session already - * exists for this (agent, mg, thread). The session - * existence IS our subscription state; once a thread - * has engaged us once, follow-ups arrive with no - * mention and should still fire. + * 'mention' — bot must be mentioned on the platform. Resolved by + * the adapter (SDK-level) and forwarded as + * `event.message.isMention`. Agent display name + * (`agent_group.name`) is irrelevant — users address + * the bot via its platform username (@botname on + * Telegram, user-id mention on Slack/Discord), not + * via the agent's NanoClaw-side display name. If a + * user wants to disambiguate between multiple agents + * wired to one chat, use engage_mode='pattern' with + * the disambiguator as the regex. + * 'mention-sticky' — platform mention OR an active per-thread session + * already exists for this (agent, mg, thread). The + * session existence IS our subscription state; once + * a thread has engaged us once, follow-ups arrive + * with no mention and should still fire. */ function evaluateEngage( agent: MessagingGroupAgent, - agentGroup: AgentGroup, text: string, + isMention: boolean, mg: MessagingGroup, threadId: string | null, ): boolean { @@ -267,9 +286,9 @@ function evaluateEngage( } } case 'mention': - return hasMention(text, agentGroup.name); + return isMention; case 'mention-sticky': { - if (hasMention(text, agentGroup.name)) return true; + if (isMention) return true; // Sticky follow-up: session already exists for this (agent, mg, thread) // — the thread was activated before, keep firing. if (mg.is_group === 0) return false; // DMs never use mention-sticky sensibly @@ -281,12 +300,6 @@ function evaluateEngage( } } -function hasMention(text: string, agentName: string): boolean { - if (!agentName) return false; - const escaped = agentName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - return new RegExp(`@${escaped}\\b`, 'i').test(text); -} - async function deliverToAgent( agent: MessagingGroupAgent, agentGroup: AgentGroup,