mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-07-12 19:00:58 +08:00
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:
@@ -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', () => {
|
||||
|
||||
@@ -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
@@ -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', {
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user