From 27e8e0653809a90a22d876c9c80bde052d73ee3a Mon Sep 17 00:00:00 2001 From: gavrielc Date: Fri, 10 Jul 2026 22:04:13 +0300 Subject: [PATCH] PR9: emit platform mention signals from signal/wechat, align telegram pairing - signal: top-level isGroup + isMention on InboundMessage (DM->true, group->dataMessage.mentions matched against config.account via new exported computeSignalIsMention); declaration flipped to group 'mention' + mentions 'platform' - wechat: hoist isGroup to top level and set isMention=true for DMs; declaration flipped to mentions 'dm-only' (group stays name-pattern) - telegram: pairing-created messaging groups read TELEGRAM_DEFAULTS unknownSenderPolicy per context instead of literal 'strict' - discord maxTextLength:2000 confirmed already landed in PR7; EXPECTED table in channel-declarations.test.ts updated for signal/wechat Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PJkDAnLGWJUqjgJNXpyqGZ --- src/channels/channel-declarations.test.ts | 10 ++-- src/channels/signal.test.ts | 59 ++++++++++++++++++++++- src/channels/signal.ts | 42 +++++++++++----- src/channels/telegram.ts | 5 +- src/channels/wechat.ts | 17 ++++--- 5 files changed, 109 insertions(+), 24 deletions(-) diff --git a/src/channels/channel-declarations.test.ts b/src/channels/channel-declarations.test.ts index a2ba17a2d..318058b42 100644 --- a/src/channels/channel-declarations.test.ts +++ b/src/channels/channel-declarations.test.ts @@ -57,11 +57,13 @@ const EXPECTED: Record< // so the shared-number declaration applies. (Dedicated mode is covered by // the adapter's own tests once PR8 lands the behavior split.) whatsapp: { groupMode: 'pattern', groupThreads: false, mentions: 'never' }, - // signal/wechat: interim 'never' declarations until the adapters emit - // top-level isGroup/isMention (see TODO(PR9) in each module). - signal: { groupMode: 'pattern', groupThreads: false, mentions: 'never' }, + // signal emits top-level isGroup/isMention (DM→true, group→account tagged); + // non-threaded, so group mode is 'mention', never sticky. + signal: { groupMode: 'mention', groupThreads: false, mentions: 'platform' }, emacs: { groupMode: 'pattern', groupThreads: false, mentions: 'never' }, - wechat: { groupMode: 'pattern', groupThreads: false, mentions: 'never' }, + // wechat emits isMention only for DMs (shared account, no group-mention + // metadata), so mention wirings are dm-only and groups stay name-pattern. + wechat: { groupMode: 'pattern', groupThreads: false, mentions: 'dm-only' }, }; describe('channel default declarations', () => { diff --git a/src/channels/signal.test.ts b/src/channels/signal.test.ts index 5d8d36948..6ab041f0d 100644 --- a/src/channels/signal.test.ts +++ b/src/channels/signal.test.ts @@ -61,7 +61,7 @@ vi.mock('node:net', () => ({ })); import type { ChannelSetup } from './adapter.js'; -import { createSignalAdapter } from './signal.js'; +import { computeSignalIsMention, createSignalAdapter } from './signal.js'; // --- Test helpers --- @@ -208,6 +208,8 @@ describe('SignalAdapter', () => { sender: '+15555550123', senderName: 'Alice', }), + isMention: true, + isGroup: false, }), ); @@ -240,12 +242,41 @@ describe('SignalAdapter', () => { text: 'Group hello', sender: '+15555550999', }), + isMention: undefined, + isGroup: true, }), ); await adapter.teardown(); }); + it('marks a group message as a mention when the linked account is tagged', async () => { + const adapter = createAdapter(); + const cfg = createMockSetup(); + await adapter.setup(cfg); + + pushEvent({ + sourceNumber: '+15555550999', + sourceName: 'Bob', + dataMessage: { + timestamp: 1700000000000, + message: ' ping', + mentions: [{ start: 0, length: 1, name: 'NanoClaw', number: '+15551234567' }], + groupInfo: { groupId: 'abc123', groupName: 'Family' }, + }, + }); + + await new Promise((r) => setTimeout(r, 50)); + + expect(cfg.onInbound).toHaveBeenCalledWith( + 'group:abc123', + null, + expect.objectContaining({ isMention: true, isGroup: true }), + ); + + await adapter.teardown(); + }); + it('skips sync messages (own outbound)', async () => { const adapter = createAdapter(); const cfg = createMockSetup(); @@ -959,3 +990,29 @@ describe('SignalAdapter', () => { }); }); }); + +describe('computeSignalIsMention', () => { + const account = '+15551234567'; + + it('is true for every DM', () => { + expect(computeSignalIsMention(account, false)).toBe(true); + expect(computeSignalIsMention(account, false, [])).toBe(true); + }); + + it('is true in groups when the account is tagged by number', () => { + expect(computeSignalIsMention(account, true, [{ number: account }])).toBe(true); + }); + + it('is true in groups when the account is tagged by uuid', () => { + expect(computeSignalIsMention(account, true, [{ uuid: account }])).toBe(true); + }); + + it('is undefined (not false) in groups when someone else is tagged', () => { + expect(computeSignalIsMention(account, true, [{ number: '+15550000000', uuid: 'bob-uuid' }])).toBeUndefined(); + }); + + it('is undefined in groups without mentions', () => { + expect(computeSignalIsMention(account, true)).toBeUndefined(); + expect(computeSignalIsMention(account, true, [])).toBeUndefined(); + }); +}); diff --git a/src/channels/signal.ts b/src/channels/signal.ts index 944405c3e..db79f30d0 100644 --- a/src/channels/signal.ts +++ b/src/channels/signal.ts @@ -288,7 +288,7 @@ interface SignalQuote { text?: string; } -interface SignalMention { +export interface SignalMention { start?: number; length?: number; uuid?: string; @@ -351,6 +351,23 @@ function resolveMentions(text: string, mentions?: SignalMention[]): string { return result; } +/** + * Platform mention signal for the router: DMs always engage (isMention=true); + * a group message counts as a mention only when the linked account itself is + * tagged. Returns undefined (not false) otherwise — the router treats + * undefined as "no platform signal" (see InboundMessage.isMention in + * adapter.ts). Matches both `number` and `uuid` against the configured + * account so either identifier shape works. + */ +export function computeSignalIsMention( + account: string, + isGroup: boolean, + mentions?: SignalMention[], +): true | undefined { + if (!isGroup) return true; + return mentions?.some((m) => m.number === account || m.uuid === account) ? true : undefined; +} + /** * Optional voice-note transcription. Tries (in order): * 1. local whisper.cpp CLI when `WHISPER_BIN` is set @@ -573,6 +590,9 @@ export function createSignalAdapter(config: { isFromMe: true, ...(syncSent.quote ? quoteToContent(syncSent.quote) : {}), }, + // Note-to-self is a DM with ourselves: same DM→mention rule. + isMention: true, + isGroup: false, timestamp, }; await setup.onInbound(platformId, null, msg); @@ -670,6 +690,8 @@ export function createSignalAdapter(config: { ...(attachmentRefs.length > 0 ? { attachments: attachmentRefs } : {}), ...(dataMessage.quote ? quoteToContent(dataMessage.quote) : {}), }, + isMention: computeSignalIsMention(config.account, isGroup, dataMessage.mentions), + isGroup, timestamp, }; await setup.onInbound(platformId, null, msg); @@ -936,19 +958,17 @@ const DEFAULT_TCP_HOST = '127.0.0.1'; const DEFAULT_TCP_PORT = 7583; /** - * Linked personal account, so auto-create is 'strict'. signal-cli DOES carry - * group-mention metadata (envelope.dataMessage.mentions), but this adapter - * does not yet emit top-level isGroup/isMention on InboundMessage — until it - * does, mention wirings could never fire, so the honest declaration is - * 'never' with a name-pattern group default. - * TODO(PR9): once the adapter emits isMention (DM→true, group→ - * dataMessage.mentions matched against config.account) and top-level isGroup, - * flip this to group { engageMode: 'mention' } + mentions: 'platform'. + * Linked personal account, so auto-create is 'strict'. The adapter emits + * top-level isGroup and isMention (DM→true; group→dataMessage.mentions + * matched against config.account via computeSignalIsMention), so platform + * mention wirings can fire. Group default is 'mention', never 'mention-sticky': + * Signal is non-threaded and sessions are never deleted, so sticky in the + * single shared session would mean engaged-forever. */ const SIGNAL_DEFAULTS: ChannelDefaults = { dm: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'strict' }, - group: { engageMode: 'pattern', engagePattern: '\\b{name}\\b', threads: false, unknownSenderPolicy: 'strict' }, - mentions: 'never', + group: { engageMode: 'mention', threads: false, unknownSenderPolicy: 'strict' }, + mentions: 'platform', }; registerChannelAdapter('signal', { diff --git a/src/channels/telegram.ts b/src/channels/telegram.ts index 6f2c1ed52..149142fe0 100644 --- a/src/channels/telegram.ts +++ b/src/channels/telegram.ts @@ -165,7 +165,10 @@ function createPairingInterceptor( platform_id: platformId, name: consumed.consumed!.name, is_group: consumed.consumed!.isGroup ? 1 : 0, - unknown_sender_policy: 'strict', + // Same context-appropriate default as router auto-create, so a + // paired chat behaves like any other telegram messaging group. + unknown_sender_policy: (consumed.consumed!.isGroup ? TELEGRAM_DEFAULTS.group : TELEGRAM_DEFAULTS.dm) + .unknownSenderPolicy, created_at: new Date().toISOString(), }); } diff --git a/src/channels/wechat.ts b/src/channels/wechat.ts index 6b548dda3..1442cd65b 100644 --- a/src/channels/wechat.ts +++ b/src/channels/wechat.ts @@ -77,17 +77,15 @@ function messageText(msg: OutboundMessage): string { /** * Shared personal account (iLink bot rides the operator's own WeChat), so - * auto-create is 'strict' and groups engage on the agent's name. The adapter - * does not yet emit top-level isGroup/isMention on InboundMessage (they only - * exist inside content today), so the honest declaration is 'never'. - * TODO(PR9): once the adapter hoists isGroup to top level and sets - * isMention=true for DMs, flip mentions to 'dm-only' (group stays - * name-pattern — WeChat group tags address the human on a shared account). + * auto-create is 'strict' and groups engage on the agent's name — group tags + * address the human, and iLink exposes no bot-mention metadata anyway. The + * adapter emits top-level isGroup and isMention=true for DMs, so mention + * wirings can fire only there: 'dm-only'. */ const WECHAT_DEFAULTS: ChannelDefaults = { dm: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'strict' }, group: { engageMode: 'pattern', engagePattern: '\\b{name}\\b', threads: false, unknownSenderPolicy: 'strict' }, - mentions: 'never', + mentions: 'dm-only', }; registerChannelAdapter('wechat', { @@ -162,6 +160,11 @@ registerChannelAdapter('wechat', { senderName: msg.from_user_id, isGroup, }, + // iLink exposes no bot-mention metadata (shared personal account), so + // only DMs carry the platform mention signal; group messages leave + // isMention undefined and rely on the name-pattern default. + ...(isGroup ? {} : { isMention: true as const }), + isGroup, timestamp: new Date(msg.create_time_ms ?? Date.now()).toISOString(), };