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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PJkDAnLGWJUqjgJNXpyqGZ
This commit is contained in:
gavrielc
2026-07-10 22:04:13 +03:00
parent ac99e907a1
commit 27e8e06538
5 changed files with 109 additions and 24 deletions
+6 -4
View File
@@ -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', () => {
+58 -1
View File
@@ -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();
});
});
+31 -11
View File
@@ -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', {
+4 -1
View File
@@ -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(),
});
}
+10 -7
View File
@@ -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(),
};