From 91e64837c51fcb492c025ad2a4395126e212d835 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Fri, 10 Jul 2026 20:49:50 +0300 Subject: [PATCH 1/4] PR1: add channel default declarations + tiered resolution foundation - ChannelContextDefaults/ChannelDefaults types on ChannelAdapter, ChannelRegistration, and ChatSdkBridgeConfig (bridge copies verbatim like supportsThreads) - getChannelDefaults tiered lookup (live instance -> live channelType -> registration key -> registration channelType -> fallback) with behavior-faithful fallbackChannelDefaults(supportsThreads) - new channel-defaults.ts helpers: resolveWiringDefaults ({name} substitution, sticky->mention coercion, pattern validation), resolveUnknownSenderPolicy, resolveThreadPolicy (hard-AND with capability) - CLI adapter declares its defaults; fix stale isMention doc claiming a router text-match fallback Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PJkDAnLGWJUqjgJNXpyqGZ --- src/channels/adapter.ts | 71 ++++++- src/channels/channel-defaults.test.ts | 273 ++++++++++++++++++++++++++ src/channels/channel-defaults.ts | 86 ++++++++ src/channels/channel-registry.ts | 72 ++++++- src/channels/chat-sdk-bridge.ts | 9 +- src/channels/cli.ts | 24 ++- 6 files changed, 530 insertions(+), 5 deletions(-) create mode 100644 src/channels/channel-defaults.test.ts create mode 100644 src/channels/channel-defaults.ts diff --git a/src/channels/adapter.ts b/src/channels/adapter.ts index 518387f15..c6930f1a0 100644 --- a/src/channels/adapter.ts +++ b/src/channels/adapter.ts @@ -80,7 +80,8 @@ export interface InboundMessage { * 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. + * router treats undefined as "not a mention" (`isMention === true` check, + * src/router.ts). There is no text-match fallback. */ isMention?: boolean; /** True when the source is a group/channel thread, false for DMs. */ @@ -107,6 +108,57 @@ export interface ConversationInfo { isGroup: boolean; } +/** Wiring/mg defaults for one conversation context (DM vs group/channel). */ +export interface ChannelContextDefaults { + /** Default engage_mode for wirings created in this context. */ + engageMode: 'pattern' | 'mention' | 'mention-sticky'; + /** + * Default engage_pattern when engageMode === 'pattern'. May contain the + * literal token `{name}`: creation helpers replace it with the regex-escaped + * agent_group name (for platforms with no group-mention metadata, e.g. + * iMessage/DeltaChat groups, WhatsApp shared-number mode). Required iff + * engageMode === 'pattern'. + */ + engagePattern?: string; + /** + * Whether thread ids are honored in this context by default. + * true — inbound thread ids flow into messages_in and (in groups) force + * per-thread session identity; replies, typing, and cards land + * in-thread. + * false — thread ids are nulled per-wiring at router fanout; sessions + * collapse; replies land top-level. + * MUST be false when `supportsThreads` is false (capability bound; the + * router treats supportsThreads=false as a hard pre-strip regardless). + * Per-wiring override: messaging_group_agents.threads (NULL = inherit). + */ + threads: boolean; + /** + * unknown_sender_policy stamped on messaging_groups rows auto-created by + * the router or created by wizard/CLI paths in this context. + */ + unknownSenderPolicy: 'strict' | 'request_approval' | 'public'; +} + +/** + * Static per-channel declaration of wiring-time defaults. Exactly two levels + * exist: this declaration, and the per-wiring/per-mg values chosen at + * creation. Install-wide changes = edit the adapter copy (skill-installed, + * user-owned). Never persisted to the central DB. + */ +export interface ChannelDefaults { + dm: ChannelContextDefaults; + group: ChannelContextDefaults; + /** + * Which mention signal the adapter emits (InboundMessage.isMention): + * 'platform' — platform-confirmed mentions in groups; DMs flagged too. + * 'dm-only' — only DMs flagged (no group mention metadata). + * 'never' — isMention never set: auto-create/registration card never + * fires; 'mention'/'mention-sticky' wirings never engage. + * Creation surfaces must reject/warn on mention modes that can never fire. + */ + mentions: 'platform' | 'dm-only' | 'never'; +} + /** The v2 channel adapter contract. */ export interface ChannelAdapter { name: string; @@ -164,6 +216,15 @@ export interface ChannelAdapter { */ openDM?(userHandle: string): Promise; resolveChannelName?: (platformId: string) => Promise; + + /** + * Declared wiring-time defaults for this channel. Optional for backward + * compatibility with stale adapter copies; absent → core fallback + * (fallbackChannelDefaults(supportsThreads), see channel-registry.ts). + * May be computed from adapter-internal env at module load (e.g. WhatsApp + * shared-number mode), but is immutable for the process lifetime. + */ + defaults?: ChannelDefaults; } /** Factory function that creates a channel adapter (returns null if credentials missing). */ @@ -172,6 +233,14 @@ export type ChannelAdapterFactory = () => ChannelAdapter | Promise; env?: Record; diff --git a/src/channels/channel-defaults.test.ts b/src/channels/channel-defaults.test.ts new file mode 100644 index 000000000..cd3129892 --- /dev/null +++ b/src/channels/channel-defaults.test.ts @@ -0,0 +1,273 @@ +/** + * Tests for channel default declarations: getChannelDefaults tiered lookup, + * the behavior-faithful fallback, and the wiring-creation helpers. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +import type { ChannelAdapter, ChannelDefaults, ChannelSetup } from './adapter.js'; + +function makeDefaults(marker: string, threads = true): ChannelDefaults { + return { + dm: { engageMode: 'pattern', engagePattern: marker, threads, unknownSenderPolicy: 'public' }, + group: { engageMode: 'mention', threads, unknownSenderPolicy: 'strict' }, + mentions: 'platform', + }; +} + +function makeAdapter( + channelType: string, + opts: { instance?: string; supportsThreads?: boolean; defaults?: ChannelDefaults } = {}, +): ChannelAdapter { + return { + name: opts.instance ?? channelType, + channelType, + instance: opts.instance, + supportsThreads: opts.supportsThreads ?? false, + defaults: opts.defaults, + async setup(_config: ChannelSetup) {}, + async teardown() {}, + isConnected: () => true, + async deliver() { + return undefined; + }, + }; +} + +const mockSetup = () => ({ + onInbound: () => {}, + onInboundEvent: () => {}, + onMetadata: () => {}, + onAction: () => {}, +}); + +describe('getChannelDefaults — tiered lookup', () => { + // The registry and activeAdapters maps are module-level; fresh module per + // test so registrations don't leak across arms. + beforeEach(() => { + vi.resetModules(); + }); + + afterEach(async () => { + const { teardownChannelAdapters } = await import('./channel-registry.js'); + await teardownChannelAdapters(); + vi.resetModules(); + }); + + it('live adapter declaration wins over the registration declaration', async () => { + const reg = await import('./channel-registry.js'); + const liveDecl = makeDefaults('live'); + const regDecl = makeDefaults('registration'); + reg.registerChannelAdapter('mock', { + factory: () => makeAdapter('mock', { defaults: liveDecl }), + defaults: regDecl, + }); + await reg.initChannelAdapters(mockSetup); + + expect(reg.getChannelDefaults('mock')).toBe(liveDecl); + }); + + it('falls through a live channelType scan for a channelType key (live tier instance→channelType)', async () => { + const reg = await import('./channel-registry.js'); + const decl = makeDefaults('named-instance'); + reg.registerChannelAdapter('slack-tester', { + factory: () => makeAdapter('slack', { instance: 'slack-tester', defaults: decl }), + }); + await reg.initChannelAdapters(mockSetup); + + // Key is the bare channelType; only a named instance is live. + expect(reg.getChannelDefaults('slack')).toBe(decl); + }); + + it('falls through to the registration entry when the factory returned null', async () => { + const reg = await import('./channel-registry.js'); + const decl = makeDefaults('registration'); + reg.registerChannelAdapter('mock', { factory: () => null, defaults: decl }); + await reg.initChannelAdapters(mockSetup); + + expect(reg.getChannelDefaults('mock')).toBe(decl); + }); + + it('resolves a stale live instance through its channelType registration (registration tier instance→channelType)', async () => { + const reg = await import('./channel-registry.js'); + const decl = makeDefaults('platform-registration'); + // Stale adapter copy: live under a named-instance key with NO declaration; + // the platform's registration (keyed by channelType) carries one. + reg.registerChannelAdapter('slack-tester', { + factory: () => makeAdapter('slack', { instance: 'slack-tester' }), + }); + reg.registerChannelAdapter('slack', { factory: () => null, defaults: decl }); + await reg.initChannelAdapters(mockSetup); + + expect(reg.getChannelDefaults('slack-tester')).toBe(decl); + }); + + it('resolves a dead named instance through the channelType hint (registration tier instance→channelType)', async () => { + const reg = await import('./channel-registry.js'); + const decl = makeDefaults('platform-registration'); + // Nothing live at all: the named instance's factory returned null and its + // registration has no declaration — only mg.channel_type can bridge. + reg.registerChannelAdapter('slack-tester', { factory: () => null }); + reg.registerChannelAdapter('slack', { factory: () => null, defaults: decl }); + await reg.initChannelAdapters(mockSetup); + + expect(reg.getChannelDefaults('slack-tester', 'slack')).toBe(decl); + // Without the hint there is no instance→channelType mapping in the registry. + expect(reg.getChannelDefaults('slack-tester')).toEqual(reg.fallbackChannelDefaults(false)); + }); + + it('uses the live adapter supportsThreads for the fallback tier', async () => { + const reg = await import('./channel-registry.js'); + reg.registerChannelAdapter('mock', { + factory: () => makeAdapter('mock', { supportsThreads: true }), + }); + await reg.initChannelAdapters(mockSetup); + + expect(reg.getChannelDefaults('mock')).toEqual(reg.fallbackChannelDefaults(true)); + }); + + it('unknown channel type resolves the conservative fallback', async () => { + const reg = await import('./channel-registry.js'); + expect(reg.getChannelDefaults('no-such-channel')).toEqual(reg.fallbackChannelDefaults(false)); + }); +}); + +describe('fallbackChannelDefaults — behavior-faithful values', () => { + beforeEach(() => { + vi.resetModules(); + }); + + it('reproduces trunk behavior for undeclared adapters', async () => { + const { fallbackChannelDefaults } = await import('./channel-registry.js'); + expect(fallbackChannelDefaults(true)).toEqual({ + dm: { engageMode: 'pattern', engagePattern: '.', threads: true, unknownSenderPolicy: 'request_approval' }, + group: { engageMode: 'mention-sticky', threads: true, unknownSenderPolicy: 'request_approval' }, + mentions: 'platform', + }); + // threads track the raw capability in BOTH contexts so NULL-inherit + // wirings behave exactly like today's supportsThreads-derived routing. + const nonThreaded = fallbackChannelDefaults(false); + expect(nonThreaded.dm.threads).toBe(false); + expect(nonThreaded.group.threads).toBe(false); + expect(nonThreaded.group.engageMode).toBe('mention-sticky'); + }); +}); + +describe('resolveWiringDefaults', () => { + beforeEach(() => { + vi.resetModules(); + }); + + afterEach(async () => { + const { teardownChannelAdapters } = await import('./channel-registry.js'); + await teardownChannelAdapters(); + vi.resetModules(); + }); + + async function withDeclaration(defaults: ChannelDefaults) { + const reg = await import('./channel-registry.js'); + reg.registerChannelAdapter('mock', { factory: () => null, defaults }); + return import('./channel-defaults.js'); + } + + it('substitutes {name} with the regex-escaped agent group name', async () => { + const { resolveWiringDefaults } = await withDeclaration({ + dm: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'public' }, + group: { engageMode: 'pattern', engagePattern: '\\b{name}\\b', threads: false, unknownSenderPolicy: 'strict' }, + mentions: 'dm-only', + }); + + expect(resolveWiringDefaults('mock', true, 'C-3PO (dev)')).toEqual({ + engage_mode: 'pattern', + engage_pattern: '\\bC-3PO \\(dev\\)\\b', + }); + // DM context: no token, pattern passes through untouched. + expect(resolveWiringDefaults('mock', false, 'C-3PO (dev)')).toEqual({ + engage_mode: 'pattern', + engage_pattern: '.', + }); + }); + + it('coerces mention-sticky to mention when the context threads=false', async () => { + const { resolveWiringDefaults } = await withDeclaration({ + dm: { engageMode: 'mention', threads: false, unknownSenderPolicy: 'strict' }, + group: { engageMode: 'mention-sticky', threads: false, unknownSenderPolicy: 'strict' }, + mentions: 'platform', + }); + + expect(resolveWiringDefaults('mock', true, 'Andy')).toEqual({ + engage_mode: 'mention', + engage_pattern: null, + }); + }); + + it('keeps mention-sticky when the context threads=true', async () => { + const { resolveWiringDefaults } = await withDeclaration({ + dm: { engageMode: 'mention', threads: true, unknownSenderPolicy: 'strict' }, + group: { engageMode: 'mention-sticky', threads: true, unknownSenderPolicy: 'strict' }, + mentions: 'platform', + }); + + expect(resolveWiringDefaults('mock', true, 'Andy')).toEqual({ + engage_mode: 'mention-sticky', + engage_pattern: null, + }); + }); + + it('throws on a pattern-mode declaration without a pattern', async () => { + const { resolveWiringDefaults } = await withDeclaration({ + dm: { engageMode: 'pattern', threads: false, unknownSenderPolicy: 'public' }, + group: { engageMode: 'mention', threads: false, unknownSenderPolicy: 'strict' }, + mentions: 'platform', + }); + + expect(() => resolveWiringDefaults('mock', false, 'Andy')).toThrow(/without an engagePattern/); + }); +}); + +describe('resolveUnknownSenderPolicy', () => { + beforeEach(() => { + vi.resetModules(); + }); + + afterEach(() => { + vi.resetModules(); + }); + + it('selects the context policy from the declaration', async () => { + const reg = await import('./channel-registry.js'); + reg.registerChannelAdapter('mock', { + factory: () => null, + defaults: { + dm: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'public' }, + group: { engageMode: 'mention', threads: false, unknownSenderPolicy: 'strict' }, + mentions: 'platform', + }, + }); + const { resolveUnknownSenderPolicy } = await import('./channel-defaults.js'); + + expect(resolveUnknownSenderPolicy('mock', false)).toBe('public'); + expect(resolveUnknownSenderPolicy('mock', true)).toBe('strict'); + }); +}); + +describe('resolveThreadPolicy', () => { + it('ANDs the resolved value with the raw capability', async () => { + vi.resetModules(); + const { resolveThreadPolicy } = await import('./channel-defaults.js'); + const decl: ChannelDefaults = { + dm: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'strict' }, + group: { engageMode: 'mention-sticky', threads: true, unknownSenderPolicy: 'strict' }, + mentions: 'platform', + }; + + // NULL = inherit the declaration for the context. + expect(resolveThreadPolicy(null, decl, true, true)).toBe(true); + expect(resolveThreadPolicy(null, decl, false, true)).toBe(false); + // Explicit wiring value beats the declaration… + expect(resolveThreadPolicy(1, decl, false, true)).toBe(true); + expect(resolveThreadPolicy(0, decl, true, true)).toBe(false); + // …but never the capability: no opt-in on a non-threaded platform. + expect(resolveThreadPolicy(1, decl, true, false)).toBe(false); + expect(resolveThreadPolicy(null, decl, true, false)).toBe(false); + }); +}); diff --git a/src/channels/channel-defaults.ts b/src/channels/channel-defaults.ts new file mode 100644 index 000000000..3baf4d22e --- /dev/null +++ b/src/channels/channel-defaults.ts @@ -0,0 +1,86 @@ +/** + * Wiring-creation helpers over channel default declarations. + * + * Every path that creates a messaging_group_agents row (ncl, setup wizard, + * card-approval flow, bootstrap scripts) resolves its engage defaults through + * resolveWiringDefaults; every path that auto-creates a messaging_groups row + * resolves its policy through resolveUnknownSenderPolicy. The router's fanout + * consults resolveThreadPolicy at runtime — threading is the one per-wiring + * setting that stays live (NULL = inherit the declaration) rather than being + * snapshotted at creation. + * + * Context selection everywhere: isGroup = event.message.isGroup ?? + * (mg.is_group === 1) — NEVER `threadId !== null` (DM sub-threads exist on + * Slack/Discord, and non-threaded group platforms have null threadIds). + */ +import type { ChannelDefaults } from './adapter.js'; +import { getChannelDefaults } from './channel-registry.js'; + +function escapeRegex(text: string): string { + return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * Resolve the engage defaults a new wiring should be created with. + * + * @param channelKey mg.instance ?? mg.channel_type (getChannelAdapter key discipline) + * @param isGroup event.message.isGroup ?? mg.is_group === 1 — never derived from threadId + * @param agentGroupName substituted (regex-escaped) for the `{name}` token in declared patterns + * + * mention-sticky is downgraded to mention when the context's declared threads + * value is false: sticky engagement is keyed on per-thread session existence, + * so without thread ids it could engage once and never disengage. + */ +export function resolveWiringDefaults( + channelKey: string, + isGroup: boolean, + agentGroupName: string, +): { engage_mode: 'pattern' | 'mention' | 'mention-sticky'; engage_pattern: string | null } { + const decl = getChannelDefaults(channelKey); + const ctx = isGroup ? decl.group : decl.dm; + + let mode = ctx.engageMode; + if (mode === 'mention-sticky' && !ctx.threads) mode = 'mention'; + + if (mode !== 'pattern') return { engage_mode: mode, engage_pattern: null }; + + if (!ctx.engagePattern) { + throw new Error( + `Channel '${channelKey}' declares engageMode 'pattern' without an engagePattern (${isGroup ? 'group' : 'dm'} context)`, + ); + } + return { + engage_mode: 'pattern', + engage_pattern: ctx.engagePattern.replaceAll('{name}', escapeRegex(agentGroupName)), + }; +} + +/** unknown_sender_policy for a messaging_groups row created in this context. */ +export function resolveUnknownSenderPolicy( + channelKey: string, + isGroup: boolean, +): 'strict' | 'request_approval' | 'public' { + const decl = getChannelDefaults(channelKey); + return (isGroup ? decl.group : decl.dm).unknownSenderPolicy; +} + +/** + * Runtime thread policy for one wiring: does its event-derived address keep + * thread ids? wiring.threads (0/1, NULL = inherit the declaration) hard-ANDed + * with the adapter's raw capability — a wiring can opt out of threads on a + * threaded platform, never opt in on a non-threaded one. + * + * Applies ONLY to event-derived addresses. `event.replyTo` is operator intent + * from the CLI admin transport (src/channels/adapter.ts) and must never be + * nulled through this policy. + */ +export function resolveThreadPolicy( + wiringThreads: number | null, + decl: ChannelDefaults, + isGroup: boolean, + supportsThreads: boolean, +): boolean { + const inherited = (isGroup ? decl.group : decl.dm).threads; + const wanted = wiringThreads === null ? inherited : wiringThreads !== 0; + return wanted && supportsThreads; +} diff --git a/src/channels/channel-registry.ts b/src/channels/channel-registry.ts index 645c64e5a..20c03420a 100644 --- a/src/channels/channel-registry.ts +++ b/src/channels/channel-registry.ts @@ -4,7 +4,7 @@ * Channels self-register on import. The host calls initChannelAdapters() at startup * to instantiate and set up all registered adapters. */ -import type { ChannelAdapter, ChannelRegistration, ChannelSetup } from './adapter.js'; +import type { ChannelAdapter, ChannelDefaults, ChannelRegistration, ChannelSetup } from './adapter.js'; import { log } from '../log.js'; const SETUP_RETRY_DELAYS_MS = [2000, 5000, 10000]; @@ -31,6 +31,76 @@ export function getChannelAdapter(channelType: string): ChannelAdapter | undefin return activeAdapters.get(channelType); } +/** + * Behavior-faithful fallback for adapters with no `defaults` declaration + * (stale skill-installed copies, unknown channel types). Values reproduce + * what trunk did before declarations existed, so a trunk update alone + * changes nothing for undeclared adapters: + * - dm: pattern '.' (every DM message engages), router auto-create policy + * 'request_approval' (src/router.ts auto-create branch). + * - group: mention-sticky (what the card-approval flow stamped on group + * channels), same 'request_approval' policy. + * - threads follow the raw capability in BOTH contexts — a NULL (inherit) + * wiring resolved through this fallback behaves exactly like today's + * supportsThreads-derived routing. + * - mentions 'platform': never blocks a mention wiring at creation time. + */ +export function fallbackChannelDefaults(supportsThreads: boolean): ChannelDefaults { + return { + dm: { + engageMode: 'pattern', + engagePattern: '.', + threads: supportsThreads, + unknownSenderPolicy: 'request_approval', + }, + group: { + engageMode: 'mention-sticky', + threads: supportsThreads, + unknownSenderPolicy: 'request_approval', + }, + mentions: 'platform', + }; +} + +/** + * Resolve a channel's declared wiring defaults. Never returns undefined. + * + * `key` follows the same discipline as getChannelAdapter: mg.instance ?? + * mg.channel_type. Tiers, first hit wins: + * 1. live adapter, instance-exact — lets an instance carry env-computed + * declarations (e.g. WhatsApp shared-number mode); + * 2. live adapter of that channelType (mirrors getChannelAdapter's scan); + * 3. registration entry under the key — covers offline scripts and + * factories that returned null for missing creds; + * 4. registration entry under the channelType — resolved from the live + * adapter found in tiers 1-2 (a stale adapter copy without a declaration + * whose registration has one), else from the optional `channelType` + * hint, which callers holding a named-instance mg row should pass so a + * dead instance still resolves its platform's declaration; + * 5. fallbackChannelDefaults on the live adapter's capability (false when + * no adapter is live — conservative, reachable only from manual creation + * surfaces since the router never sees events for unregistered channels). + */ +export function getChannelDefaults(key: string, channelType?: string): ChannelDefaults { + let live = activeAdapters.get(key); + if (!live) { + for (const adapter of activeAdapters.values()) { + if (adapter.channelType === key) { + live = adapter; + break; + } + } + } + if (live?.defaults) return live.defaults; + + const typeKey = live?.channelType ?? channelType; + const registered = + registry.get(key)?.defaults ?? (typeKey !== undefined ? registry.get(typeKey)?.defaults : undefined); + if (registered) return registered; + + return fallbackChannelDefaults(live?.supportsThreads ?? false); +} + /** Get all active adapters. */ export function getActiveAdapters(): ChannelAdapter[] { return [...activeAdapters.values()]; diff --git a/src/channels/chat-sdk-bridge.ts b/src/channels/chat-sdk-bridge.ts index 18ab2cbf8..9ad27274e 100644 --- a/src/channels/chat-sdk-bridge.ts +++ b/src/channels/chat-sdk-bridge.ts @@ -21,7 +21,7 @@ import { SqliteStateAdapter } from '../state-sqlite.js'; import { registerWebhookAdapter } from '../webhook-server.js'; import { getAskQuestionRender } from '../db/sessions.js'; import { normalizeOptions, type NormalizedOption } from './ask-question.js'; -import type { ChannelAdapter, ChannelSetup, InboundMessage } from './adapter.js'; +import type { ChannelAdapter, ChannelDefaults, ChannelSetup, InboundMessage } from './adapter.js'; /** Adapter with optional gateway support (e.g., Discord). */ interface GatewayAdapter extends Adapter { @@ -57,6 +57,12 @@ export interface ChatSdkBridgeConfig { * way and the default depends on installation style. */ supportsThreads: boolean; + /** + * Declared wiring-time defaults for this channel. Copied verbatim onto the + * returned ChannelAdapter, exactly like supportsThreads. See + * `ChannelAdapter.defaults`. + */ + defaults?: ChannelDefaults; /** * Optional transform applied to outbound text/markdown before it reaches the * adapter. Used by channels that need to sanitize for a platform-specific @@ -194,6 +200,7 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter name: adapter.name, channelType: adapter.name, supportsThreads: config.supportsThreads, + defaults: config.defaults, async setup(hostConfig: ChannelSetup) { setupConfig = hostConfig; diff --git a/src/channels/cli.ts b/src/channels/cli.ts index ad78bea8a..b79d12489 100644 --- a/src/channels/cli.ts +++ b/src/channels/cli.ts @@ -39,11 +39,30 @@ import path from 'path'; import { DATA_DIR } from '../config.js'; import { log } from '../log.js'; -import type { ChannelAdapter, ChannelSetup, DeliveryAddress, InboundEvent, OutboundMessage } from './adapter.js'; +import type { + ChannelAdapter, + ChannelDefaults, + ChannelSetup, + DeliveryAddress, + InboundEvent, + OutboundMessage, +} from './adapter.js'; import { registerChannelAdapter } from './channel-registry.js'; const PLATFORM_ID = 'local'; +/** + * Terminal transport: every line the operator types is for the agent + * (pattern '.'), the socket is owner-only so senders are trusted ('public'), + * there is no thread or mention concept. Matches what + * scripts/init-cli-agent.ts has always created. + */ +const CLI_DEFAULTS: ChannelDefaults = { + dm: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'public' }, + group: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'public' }, + mentions: 'never', +}; + function socketPath(): string { return path.join(DATA_DIR, 'cli.sock'); } @@ -56,6 +75,7 @@ function createAdapter(): ChannelAdapter { name: 'cli', channelType: 'cli', supportsThreads: false, + defaults: CLI_DEFAULTS, async setup(config: ChannelSetup): Promise { const sock = socketPath(); @@ -273,4 +293,4 @@ function extractText(message: OutboundMessage): string | null { return null; } -registerChannelAdapter('cli', { factory: createAdapter }); +registerChannelAdapter('cli', { factory: createAdapter, defaults: CLI_DEFAULTS }); From cdf8142911e5755dbf2ab7b295f154035aedbec0 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Fri, 10 Jul 2026 21:50:12 +0300 Subject: [PATCH 2/4] PR7: declare ChannelDefaults on every channels-branch adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ChannelDefaults const per adapter module, passed to BOTH the registerChannelAdapter registration and the adapter/bridge config, per the perAdapterDefaults design table - whatsapp declaration computed at module load from ASSISTANT_HAS_OWN_NUMBER (shared personal number vs dedicated bot; behavior split itself lands in PR8) - signal + wechat declare interim mentions:'never' with group name-pattern and TODO(PR9) until the adapters emit top-level isGroup/isMention - discord bridge gains the missing maxTextLength:2000; Slack DM sub-thread rationale comment rewritten — dm.threads:false in the declaration now owns that judgment - port trunk's ChannelAdapter.instance field + instance-keyed activeAdapters so the cherry-picked PR1 tiered lookup behaves identically on this branch - channel-declarations.test.ts guards each declaration's key facts and invariants (deltachat/matrix excluded: import-time-only environment gaps) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PJkDAnLGWJUqjgJNXpyqGZ --- src/channels/adapter.ts | 9 ++ src/channels/channel-declarations.test.ts | 102 ++++++++++++++++++++++ src/channels/channel-registry.ts | 12 ++- src/channels/chat-sdk-bridge.ts | 8 +- src/channels/deltachat.ts | 20 ++++- src/channels/discord.ts | 18 ++++ src/channels/emacs.ts | 15 +++- src/channels/gchat.ts | 19 +++- src/channels/github.ts | 20 ++++- src/channels/imessage.ts | 21 ++++- src/channels/linear.ts | 22 ++++- src/channels/matrix.ts | 21 ++++- src/channels/resend.ts | 20 ++++- src/channels/signal.ts | 20 ++++- src/channels/slack.ts | 25 +++++- src/channels/teams.ts | 19 +++- src/channels/telegram.ts | 15 +++- src/channels/webex.ts | 19 +++- src/channels/wechat.ts | 19 +++- src/channels/whatsapp-cloud.ts | 20 ++++- src/channels/whatsapp.ts | 35 +++++++- 21 files changed, 458 insertions(+), 21 deletions(-) create mode 100644 src/channels/channel-declarations.test.ts diff --git a/src/channels/adapter.ts b/src/channels/adapter.ts index c6930f1a0..a2b3cb0c0 100644 --- a/src/channels/adapter.ts +++ b/src/channels/adapter.ts @@ -164,6 +164,15 @@ export interface ChannelAdapter { name: string; channelType: string; + /** + * Adapter-instance name — distinguishes N adapters of one platform + * (e.g. three Slack apps in one workspace). Defaults to channelType. + * channelType stays the SEMANTIC platform key (user ids ':', + * formatting, container config); instance is a host-side routing key only. + * Must be unique across active adapters and URL-safe (no '/', '?', ':'). + */ + instance?: string; + /** * Whether this adapter models conversations as threads. * diff --git a/src/channels/channel-declarations.test.ts b/src/channels/channel-declarations.test.ts new file mode 100644 index 000000000..a2ba17a2d --- /dev/null +++ b/src/channels/channel-declarations.test.ts @@ -0,0 +1,102 @@ +/** + * Guards the ChannelDefaults declarations every channel module registers. + * + * Each module is imported directly (not via the barrel — many imports there + * are commented out until the corresponding /add- skill installs + * them). Importing runs the top-level registerChannelAdapter(name, { …, + * defaults }) call; factories are never invoked, so getChannelDefaults + * resolves from the registration tier. + * + * Two exclusions, both import-time-only (typecheck still covers them): + * - deltachat: its runtime dep (@deltachat/stdio-rpc-server) is + * skill-installed and absent from this branch's package.json. + * - matrix: @beeper/chat-adapter-matrix's dist has an extensionless ESM + * import (matrix-js-sdk/lib/http-api/errors) that Node/vitest can't + * resolve, so the module can't be evaluated in this environment. + */ +import { describe, it, expect } from 'vitest'; + +import type { ChannelDefaults } from './adapter.js'; +import { getChannelDefaults } from './channel-registry.js'; + +import './cli.js'; +import './discord.js'; +import './slack.js'; +import './telegram.js'; +import './github.js'; +import './linear.js'; +import './gchat.js'; +import './teams.js'; +import './whatsapp-cloud.js'; +import './resend.js'; +import './webex.js'; +import './imessage.js'; +import './whatsapp.js'; +import './signal.js'; +import './emacs.js'; +import './wechat.js'; + +/** channel → key facts of its declaration (the parts that differ per channel). */ +const EXPECTED: Record< + string, + { groupMode: ChannelDefaults['group']['engageMode']; groupThreads: boolean; mentions: ChannelDefaults['mentions'] } +> = { + cli: { groupMode: 'pattern', groupThreads: false, mentions: 'never' }, + discord: { groupMode: 'mention-sticky', groupThreads: true, mentions: 'platform' }, + slack: { groupMode: 'mention-sticky', groupThreads: true, mentions: 'platform' }, + telegram: { groupMode: 'mention', groupThreads: false, mentions: 'platform' }, + github: { groupMode: 'mention', groupThreads: true, mentions: 'platform' }, + linear: { groupMode: 'pattern', groupThreads: true, mentions: 'never' }, + gchat: { groupMode: 'mention', groupThreads: true, mentions: 'platform' }, + teams: { groupMode: 'mention', groupThreads: true, mentions: 'platform' }, + 'whatsapp-cloud': { groupMode: 'mention', groupThreads: false, mentions: 'platform' }, + resend: { groupMode: 'pattern', groupThreads: false, mentions: 'dm-only' }, + webex: { groupMode: 'mention', groupThreads: true, mentions: 'platform' }, + imessage: { groupMode: 'pattern', groupThreads: false, mentions: 'dm-only' }, + // whatsapp is env-computed; the test env has no ASSISTANT_HAS_OWN_NUMBER=true + // 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' }, + emacs: { groupMode: 'pattern', groupThreads: false, mentions: 'never' }, + wechat: { groupMode: 'pattern', groupThreads: false, mentions: 'never' }, +}; + +describe('channel default declarations', () => { + for (const [channel, expected] of Object.entries(EXPECTED)) { + describe(channel, () => { + const decl = getChannelDefaults(channel); + + it('declares the expected group mode, threads, and mention capability', () => { + expect(decl.group.engageMode).toBe(expected.groupMode); + expect(decl.group.threads).toBe(expected.groupThreads); + expect(decl.mentions).toBe(expected.mentions); + }); + + it('declares a valid pattern default in every pattern-mode context', () => { + for (const ctx of [decl.dm, decl.group]) { + if (ctx.engageMode === 'pattern') { + expect(ctx.engagePattern).toBeTruthy(); + // Must compile once {name} is substituted (resolveWiringDefaults + // regex-escapes the name, so any literal stands in). + expect(() => new RegExp(ctx.engagePattern!.replaceAll('{name}', 'Agent'))).not.toThrow(); + } + } + }); + + it('never declares a mention mode the channel says can never fire', () => { + if (expected.mentions === 'never') { + expect(decl.dm.engageMode).toBe('pattern'); + expect(decl.group.engageMode).toBe('pattern'); + } + }); + + it('DMs engage on every message', () => { + expect(decl.dm.engageMode).toBe('pattern'); + expect(decl.dm.engagePattern).toBe('.'); + }); + }); + } +}); diff --git a/src/channels/channel-registry.ts b/src/channels/channel-registry.ts index 20c03420a..e269f7214 100644 --- a/src/channels/channel-registry.ts +++ b/src/channels/channel-registry.ts @@ -155,8 +155,16 @@ export async function initChannelAdapters(setupFn: (adapter: ChannelAdapter) => throw err; } } - activeAdapters.set(adapter.channelType, adapter); - log.info('Channel adapter started', { channel: name, type: adapter.channelType }); + // Adapters key by instance (default instance = channelType), so N + // instances of one platform coexist. Duplicate keys warn instead of + // throwing — boot stays resilient, matching the historical silent + // last-write-wins, but now visibly. + const key = adapter.instance ?? adapter.channelType; + if (activeAdapters.has(key)) { + log.warn('Duplicate adapter instance key — overwriting previous adapter', { key, channel: name }); + } + activeAdapters.set(key, adapter); + log.info('Channel adapter started', { channel: name, type: adapter.channelType, instance: key }); } catch (err) { log.error('Failed to start channel adapter', { channel: name, err }); } diff --git a/src/channels/chat-sdk-bridge.ts b/src/channels/chat-sdk-bridge.ts index 9ad27274e..6865ce9c8 100644 --- a/src/channels/chat-sdk-bridge.ts +++ b/src/channels/chat-sdk-bridge.ts @@ -241,9 +241,11 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter }); // DMs — by definition addressed to the bot. Thread id flows through - // so sub-thread context reaches delivery (Slack users can open threads - // inside a DM). Router collapses DM sub-threads to one session via - // is_group=0 short-circuit. + // unmodified (Slack users can open sub-threads inside a DM); whether it + // is honored is policy, not transport: the channel's declared + // dm.threads default (ChannelDefaults) or a per-wiring threads override + // decides at router fanout whether replies land in-thread or all DM + // sub-threads collapse into the one DM session. chat.onDirectMessage(async (thread, message) => { const channelId = adapter.channelIdFromThreadId(thread.id); log.info('Inbound DM received', { diff --git a/src/channels/deltachat.ts b/src/channels/deltachat.ts index c8ccaaec1..e16bb7627 100644 --- a/src/channels/deltachat.ts +++ b/src/channels/deltachat.ts @@ -21,7 +21,7 @@ import { basename, join, resolve } from 'path'; import { getDb, hasTable } from '../db/connection.js'; import { readEnvFile } from '../env.js'; import { log } from '../log.js'; -import type { ChannelAdapter, ChannelSetup, OutboundMessage } from './adapter.js'; +import type { ChannelAdapter, ChannelDefaults, ChannelSetup, OutboundMessage } from './adapter.js'; import { registerChannelAdapter } from './channel-registry.js'; import { DeltaChatOverJsonRpc } from '@deltachat/stdio-rpc-server'; @@ -85,6 +85,7 @@ function createAdapter(env: DcEnv): ChannelAdapter { name: 'deltachat', channelType: 'deltachat', supportsThreads: false, + defaults: DELTACHAT_DEFAULTS, async setup(config: ChannelSetup): Promise { const accountDir = process.env.DC_ACCOUNT_DIR ?? 'dc-account'; @@ -329,10 +330,27 @@ function createAdapter(env: DcEnv): ChannelAdapter { return adapter; } +/** + * Dedicated email identity (DC_EMAIL), so request_approval is sound. Email + * carries no mention metadata ('dm-only'; the adapter flags DMs only), so + * group wirings default to a name-pattern trigger. + */ +const DELTACHAT_DEFAULTS: ChannelDefaults = { + dm: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'request_approval' }, + group: { + engageMode: 'pattern', + engagePattern: '\\b{name}\\b', + threads: false, + unknownSenderPolicy: 'request_approval', + }, + mentions: 'dm-only', +}; + registerChannelAdapter('deltachat', { factory: () => { const env = readEnvFile([...REQUIRED_ENV, ...OPTIONAL_ENV]); if (!env.DC_EMAIL || !env.DC_PASSWORD) return null; return createAdapter(env as DcEnv); }, + defaults: DELTACHAT_DEFAULTS, }); diff --git a/src/channels/discord.ts b/src/channels/discord.ts index f9d83746c..f2bb09ec2 100644 --- a/src/channels/discord.ts +++ b/src/channels/discord.ts @@ -5,9 +5,22 @@ import { createDiscordAdapter } from '@chat-adapter/discord'; import { readEnvFile } from '../env.js'; +import type { ChannelDefaults } from './adapter.js'; import { createChatSdkBridge, type ReplyContext } from './chat-sdk-bridge.js'; import { registerChannelAdapter } from './channel-registry.js'; +/** + * Dedicated bot app on a threaded platform. group threads:true matches the + * declared supportsThreads (the skill-installed install-style knob) so + * mention-sticky engagement stays bounded per-thread. dm.threads:false — + * DM replies land top-level, one session per DM. + */ +const DISCORD_DEFAULTS: ChannelDefaults = { + dm: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'request_approval' }, + group: { engageMode: 'mention-sticky', threads: true, unknownSenderPolicy: 'request_approval' }, + mentions: 'platform', +}; + // eslint-disable-next-line @typescript-eslint/no-explicit-any function extractReplyContext(raw: Record): ReplyContext | null { if (!raw.referenced_message) return null; @@ -75,6 +88,11 @@ registerChannelAdapter('discord', { botToken: env.DISCORD_BOT_TOKEN, extractReplyContext, supportsThreads: true, + defaults: DISCORD_DEFAULTS, + // Discord rejects messages over 2000 chars; without this the bridge + // would let long agent replies fail instead of splitting them. + maxTextLength: 2000, }); }, + defaults: DISCORD_DEFAULTS, }); diff --git a/src/channels/emacs.ts b/src/channels/emacs.ts index 3f1a3d378..74cdc2178 100644 --- a/src/channels/emacs.ts +++ b/src/channels/emacs.ts @@ -14,10 +14,21 @@ import http from 'http'; import { readEnvFile } from '../env.js'; import { log } from '../log.js'; import { registerChannelAdapter } from './channel-registry.js'; -import type { ChannelAdapter, ChannelSetup, InboundMessage, OutboundMessage } from './adapter.js'; +import type { ChannelAdapter, ChannelDefaults, ChannelSetup, InboundMessage, OutboundMessage } from './adapter.js'; const OUTBOUND_BUFFER_MAX = 200; +/** + * Single-operator localhost transport, wired manually: every line is for the + * agent, senders are whoever can reach the local port ('strict' keeps + * auto-create off), no thread or mention concept. + */ +const EMACS_DEFAULTS: ChannelDefaults = { + dm: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'strict' }, + group: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'strict' }, + mentions: 'never', +}; + interface BufferedMessage { text: string; timestamp: number; @@ -101,6 +112,7 @@ function createEmacsAdapter(opts: EmacsAdapterOptions): ChannelAdapter { name: 'emacs', channelType: 'emacs', supportsThreads: false, + defaults: EMACS_DEFAULTS, async setup(config: ChannelSetup): Promise { setupConfig = config; @@ -181,6 +193,7 @@ registerChannelAdapter('emacs', { return createEmacsAdapter({ port, authToken, platformId }); }, + defaults: EMACS_DEFAULTS, }); export { createEmacsAdapter }; diff --git a/src/channels/gchat.ts b/src/channels/gchat.ts index 98fc539e5..b4f2f0f0d 100644 --- a/src/channels/gchat.ts +++ b/src/channels/gchat.ts @@ -5,9 +5,20 @@ import { createGoogleChatAdapter } from '@chat-adapter/gchat'; import { readEnvFile } from '../env.js'; +import type { ChannelDefaults } from './adapter.js'; import { createChatSdkBridge } from './chat-sdk-bridge.js'; import { registerChannelAdapter } from './channel-registry.js'; +/** + * Dedicated bot app on a threaded platform. 'mention' (not sticky) is the + * conservative group default; operators upgrade per wiring. + */ +const GCHAT_DEFAULTS: ChannelDefaults = { + dm: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'request_approval' }, + group: { engageMode: 'mention', threads: true, unknownSenderPolicy: 'request_approval' }, + mentions: 'platform', +}; + registerChannelAdapter('gchat', { factory: () => { const env = readEnvFile(['GCHAT_CREDENTIALS']); @@ -15,6 +26,12 @@ registerChannelAdapter('gchat', { const gchatAdapter = createGoogleChatAdapter({ credentials: JSON.parse(env.GCHAT_CREDENTIALS), }); - return createChatSdkBridge({ adapter: gchatAdapter, concurrency: 'concurrent', supportsThreads: true }); + return createChatSdkBridge({ + adapter: gchatAdapter, + concurrency: 'concurrent', + supportsThreads: true, + defaults: GCHAT_DEFAULTS, + }); }, + defaults: GCHAT_DEFAULTS, }); diff --git a/src/channels/github.ts b/src/channels/github.ts index 3f930a908..ab17d2c26 100644 --- a/src/channels/github.ts +++ b/src/channels/github.ts @@ -6,9 +6,21 @@ import { createGitHubAdapter } from '@chat-adapter/github'; import { readEnvFile } from '../env.js'; +import type { ChannelDefaults } from './adapter.js'; import { createChatSdkBridge } from './chat-sdk-bridge.js'; import { registerChannelAdapter } from './channel-registry.js'; +/** + * Dedicated bot identity. group threads:true — every issue/PR comment thread + * is its own conversation and session (the long-standing GitHub behavior, + * now declared instead of forced by supportsThreads alone). + */ +const GITHUB_DEFAULTS: ChannelDefaults = { + dm: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'request_approval' }, + group: { engageMode: 'mention', threads: true, unknownSenderPolicy: 'request_approval' }, + mentions: 'platform', +}; + registerChannelAdapter('github', { factory: () => { const env = readEnvFile(['GITHUB_TOKEN', 'GITHUB_WEBHOOK_SECRET', 'GITHUB_BOT_USERNAME']); @@ -18,6 +30,12 @@ registerChannelAdapter('github', { webhookSecret: env.GITHUB_WEBHOOK_SECRET, userName: env.GITHUB_BOT_USERNAME, }); - return createChatSdkBridge({ adapter: githubAdapter, concurrency: 'queue', supportsThreads: true }); + return createChatSdkBridge({ + adapter: githubAdapter, + concurrency: 'queue', + supportsThreads: true, + defaults: GITHUB_DEFAULTS, + }); }, + defaults: GITHUB_DEFAULTS, }); diff --git a/src/channels/imessage.ts b/src/channels/imessage.ts index 1ffba36db..fb9d252e4 100644 --- a/src/channels/imessage.ts +++ b/src/channels/imessage.ts @@ -6,9 +6,22 @@ import { createiMessageAdapter } from 'chat-adapter-imessage'; import { readEnvFile } from '../env.js'; +import type { ChannelDefaults } from './adapter.js'; import { createChatSdkBridge } from './chat-sdk-bridge.js'; import { registerChannelAdapter } from './channel-registry.js'; +/** + * The operator's personal Apple ID is a shared identity — strangers DMing it + * reach the human, not the bot, so auto-create stays 'strict'. iMessage + * exposes no group-mention metadata ('dm-only'); group wirings default to a + * name-pattern trigger instead ({name} = agent group name). + */ +const IMESSAGE_DEFAULTS: ChannelDefaults = { + dm: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'strict' }, + group: { engageMode: 'pattern', engagePattern: '\\b{name}\\b', threads: false, unknownSenderPolicy: 'strict' }, + mentions: 'dm-only', +}; + registerChannelAdapter('imessage', { factory: () => { const env = readEnvFile(['IMESSAGE_ENABLED', 'IMESSAGE_LOCAL', 'IMESSAGE_SERVER_URL', 'IMESSAGE_API_KEY']); @@ -24,6 +37,12 @@ registerChannelAdapter('imessage', { const imessageAdapter = Object.assign(rawAdapter, { channelIdFromThreadId: (threadId: string) => threadId, }); - return createChatSdkBridge({ adapter: imessageAdapter, concurrency: 'concurrent', supportsThreads: false }); + return createChatSdkBridge({ + adapter: imessageAdapter, + concurrency: 'concurrent', + supportsThreads: false, + defaults: IMESSAGE_DEFAULTS, + }); }, + defaults: IMESSAGE_DEFAULTS, }); diff --git a/src/channels/linear.ts b/src/channels/linear.ts index 1e85ac631..160409301 100644 --- a/src/channels/linear.ts +++ b/src/channels/linear.ts @@ -9,9 +9,23 @@ import { createLinearAdapter } from '@chat-adapter/linear'; import { readEnvFile } from '../env.js'; +import type { ChannelDefaults } from './adapter.js'; import { createChatSdkBridge } from './chat-sdk-bridge.js'; import { registerChannelAdapter } from './channel-registry.js'; +/** + * Linear OAuth apps can't be @-mentioned (see module doc), so mention modes + * can never fire — creation surfaces must refuse them ('never'). Group + * wirings default to pattern '.' (every comment engages) with per-issue + * threads. Auto-create can't fire without isMention, so unknownSenderPolicy + * is declared for uniformity only. + */ +const LINEAR_DEFAULTS: ChannelDefaults = { + dm: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'request_approval' }, + group: { engageMode: 'pattern', engagePattern: '.', threads: true, unknownSenderPolicy: 'request_approval' }, + mentions: 'never', +}; + registerChannelAdapter('linear', { factory: () => { const env = readEnvFile([ @@ -40,6 +54,12 @@ registerChannelAdapter('linear', { const teamKey = env.LINEAR_TEAM_KEY || 'default'; linearAdapter.channelIdFromThreadId = () => `linear:${teamKey}`; - return createChatSdkBridge({ adapter: linearAdapter, concurrency: 'queue', supportsThreads: true }); + return createChatSdkBridge({ + adapter: linearAdapter, + concurrency: 'queue', + supportsThreads: true, + defaults: LINEAR_DEFAULTS, + }); }, + defaults: LINEAR_DEFAULTS, }); diff --git a/src/channels/matrix.ts b/src/channels/matrix.ts index 231f6bf38..05d5583aa 100644 --- a/src/channels/matrix.ts +++ b/src/channels/matrix.ts @@ -17,9 +17,22 @@ import { createMatrixAdapter } from '@beeper/chat-adapter-matrix'; import { log } from '../log.js'; import { readEnvFile } from '../env.js'; +import type { ChannelDefaults } from './adapter.js'; import { createChatSdkBridge } from './chat-sdk-bridge.js'; import { registerChannelAdapter } from './channel-registry.js'; +/** + * Assumes a dedicated bot account on a homeserver (the common install). + * Non-threaded at the bridge level, so group engagement is 'mention', never + * sticky. Personal-account installs should edit their copy to dm 'strict' — + * install-wide changes live in this declaration by design. + */ +const MATRIX_DEFAULTS: ChannelDefaults = { + dm: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'request_approval' }, + group: { engageMode: 'mention', threads: false, unknownSenderPolicy: 'request_approval' }, + mentions: 'platform', +}; + const ENV_KEYS = [ 'MATRIX_BASE_URL', 'MATRIX_ACCESS_TOKEN', @@ -160,7 +173,12 @@ registerChannelAdapter('matrix', { } const matrixAdapter = wrapWithDmResolution(createMatrixAdapter()); - const bridge = createChatSdkBridge({ adapter: matrixAdapter, concurrency: 'concurrent', supportsThreads: false }); + const bridge = createChatSdkBridge({ + adapter: matrixAdapter, + concurrency: 'concurrent', + supportsThreads: false, + defaults: MATRIX_DEFAULTS, + }); // Matrix user IDs contain ":" (e.g. "@user:matrix.org") which the shared // permissions module interprets as already-prefixed. Wrap onInbound to @@ -203,4 +221,5 @@ registerChannelAdapter('matrix', { return bridge; }, + defaults: MATRIX_DEFAULTS, }); diff --git a/src/channels/resend.ts b/src/channels/resend.ts index 5a4565bae..9c43d6a21 100644 --- a/src/channels/resend.ts +++ b/src/channels/resend.ts @@ -5,9 +5,21 @@ import { createResendAdapter } from '@resend/chat-sdk-adapter'; import { readEnvFile } from '../env.js'; +import type { ChannelDefaults } from './adapter.js'; import { createChatSdkBridge } from './chat-sdk-bridge.js'; import { registerChannelAdapter } from './channel-registry.js'; +/** + * Email: every conversation is effectively a DM addressed to the bot's + * address — the group branch is inert but required by the type. 'dm-only' + * because email has no mention metadata. + */ +const RESEND_DEFAULTS: ChannelDefaults = { + dm: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'request_approval' }, + group: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'request_approval' }, + mentions: 'dm-only', +}; + registerChannelAdapter('resend', { factory: () => { const env = readEnvFile(['RESEND_API_KEY', 'RESEND_FROM_ADDRESS', 'RESEND_FROM_NAME', 'RESEND_WEBHOOK_SECRET']); @@ -18,6 +30,12 @@ registerChannelAdapter('resend', { fromName: env.RESEND_FROM_NAME, webhookSecret: env.RESEND_WEBHOOK_SECRET, }); - return createChatSdkBridge({ adapter: resendAdapter, concurrency: 'queue', supportsThreads: false }); + return createChatSdkBridge({ + adapter: resendAdapter, + concurrency: 'queue', + supportsThreads: false, + defaults: RESEND_DEFAULTS, + }); }, + defaults: RESEND_DEFAULTS, }); diff --git a/src/channels/signal.ts b/src/channels/signal.ts index 1f490c18b..944405c3e 100644 --- a/src/channels/signal.ts +++ b/src/channels/signal.ts @@ -13,7 +13,7 @@ import { createConnection, type Socket } from 'node:net'; import { homedir, tmpdir } from 'node:os'; import { join } from 'node:path'; -import type { ChannelAdapter, ChannelSetup, InboundMessage, OutboundMessage } from './adapter.js'; +import type { ChannelAdapter, ChannelDefaults, ChannelSetup, InboundMessage, OutboundMessage } from './adapter.js'; import { registerChannelAdapter } from './channel-registry.js'; import { readEnvFile } from '../env.js'; import { log } from '../log.js'; @@ -809,6 +809,7 @@ export function createSignalAdapter(config: { name: 'signal', channelType: 'signal', supportsThreads: false, + defaults: SIGNAL_DEFAULTS, async setup(cfg: ChannelSetup): Promise { setup = cfg; @@ -934,6 +935,22 @@ export function createSignalAdapter(config: { 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'. + */ +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', +}; + registerChannelAdapter('signal', { factory: () => { const envVars = readEnvFile([ @@ -980,4 +997,5 @@ registerChannelAdapter('signal', { signalDataDir, }); }, + defaults: SIGNAL_DEFAULTS, }); diff --git a/src/channels/slack.ts b/src/channels/slack.ts index d1e1093be..26d40add0 100644 --- a/src/channels/slack.ts +++ b/src/channels/slack.ts @@ -8,9 +8,26 @@ import { createSlackAdapter } from '@chat-adapter/slack'; import { readEnvFile } from '../env.js'; +import type { ChannelDefaults } from './adapter.js'; import { createChatSdkBridge } from './chat-sdk-bridge.js'; import { registerChannelAdapter } from './channel-registry.js'; +/** + * Dedicated bot app on a threaded platform. group threads:true keeps + * mention-sticky bounded — engagement sticks per-thread, not forever. + * dm.threads:false is a deliberate policy choice, not a capability limit: + * Slack users can open sub-threads inside a DM, but by default the agent + * replies top-level and all DM sub-threads collapse into the one DM session. + * This declaration owns that judgment (it used to be hardcoded router + * behavior); operators who want in-thread DM replies override per wiring + * with `--threads true`. + */ +const SLACK_DEFAULTS: ChannelDefaults = { + dm: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'request_approval' }, + group: { engageMode: 'mention-sticky', threads: true, unknownSenderPolicy: 'request_approval' }, + mentions: 'platform', +}; + registerChannelAdapter('slack', { factory: () => { const env = readEnvFile(['SLACK_BOT_TOKEN', 'SLACK_SIGNING_SECRET', 'SLACK_APP_TOKEN']); @@ -25,7 +42,12 @@ registerChannelAdapter('slack', { appToken: env.SLACK_APP_TOKEN, mode: useSocketMode ? 'socket' : 'webhook', }); - const bridge = createChatSdkBridge({ adapter: slackAdapter, concurrency: 'concurrent', supportsThreads: true }); + const bridge = createChatSdkBridge({ + adapter: slackAdapter, + concurrency: 'concurrent', + supportsThreads: true, + defaults: SLACK_DEFAULTS, + }); bridge.resolveChannelName = async (platformId: string) => { try { const info = await slackAdapter.fetchThread(platformId); @@ -36,4 +58,5 @@ registerChannelAdapter('slack', { }; return bridge; }, + defaults: SLACK_DEFAULTS, }); diff --git a/src/channels/teams.ts b/src/channels/teams.ts index 0ddf4bd6c..284a9feb0 100644 --- a/src/channels/teams.ts +++ b/src/channels/teams.ts @@ -5,9 +5,20 @@ import { createTeamsAdapter } from '@chat-adapter/teams'; import { readEnvFile } from '../env.js'; +import type { ChannelDefaults } from './adapter.js'; import { createChatSdkBridge } from './chat-sdk-bridge.js'; import { registerChannelAdapter } from './channel-registry.js'; +/** + * Dedicated bot app on a threaded platform. 'mention' (not sticky) is the + * conservative group default; operators upgrade per wiring. + */ +const TEAMS_DEFAULTS: ChannelDefaults = { + dm: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'request_approval' }, + group: { engageMode: 'mention', threads: true, unknownSenderPolicy: 'request_approval' }, + mentions: 'platform', +}; + registerChannelAdapter('teams', { factory: () => { const env = readEnvFile(['TEAMS_APP_ID', 'TEAMS_APP_PASSWORD', 'TEAMS_APP_TENANT_ID', 'TEAMS_APP_TYPE']); @@ -18,6 +29,12 @@ registerChannelAdapter('teams', { appType: (env.TEAMS_APP_TYPE as 'SingleTenant' | 'MultiTenant') || undefined, appTenantId: env.TEAMS_APP_TENANT_ID || undefined, }); - return createChatSdkBridge({ adapter: teamsAdapter, concurrency: 'concurrent', supportsThreads: true }); + return createChatSdkBridge({ + adapter: teamsAdapter, + concurrency: 'concurrent', + supportsThreads: true, + defaults: TEAMS_DEFAULTS, + }); }, + defaults: TEAMS_DEFAULTS, }); diff --git a/src/channels/telegram.ts b/src/channels/telegram.ts index c9307eb22..6f2c1ed52 100644 --- a/src/channels/telegram.ts +++ b/src/channels/telegram.ts @@ -13,9 +13,20 @@ import { upsertUser } from '../modules/permissions/db/users.js'; import { createChatSdkBridge, type ReplyContext } from './chat-sdk-bridge.js'; import { sanitizeTelegramLegacyMarkdown } from './telegram-markdown-sanitize.js'; import { registerChannelAdapter } from './channel-registry.js'; -import type { ChannelAdapter, ChannelSetup, InboundMessage } from './adapter.js'; +import type { ChannelAdapter, ChannelDefaults, ChannelSetup, InboundMessage } from './adapter.js'; import { tryConsume } from './telegram-pairing.js'; +/** + * Dedicated bot identity, non-threaded platform (supportsThreads:false), so + * group engagement can never be sticky-per-thread — 'mention' keeps a group + * wiring from staying engaged forever in the single shared session. + */ +const TELEGRAM_DEFAULTS: ChannelDefaults = { + dm: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'request_approval' }, + group: { engageMode: 'mention', threads: false, unknownSenderPolicy: 'request_approval' }, + mentions: 'platform', +}; + /** * Retry a one-shot operation that can fail on transient network errors at * cold-start (DNS hiccups, brief upstream outages). Exponential backoff capped @@ -209,6 +220,7 @@ registerChannelAdapter('telegram', { concurrency: 'concurrent', extractReplyContext, supportsThreads: false, + defaults: TELEGRAM_DEFAULTS, transformOutboundText: sanitizeTelegramLegacyMarkdown, maxTextLength: 4000, }); @@ -242,4 +254,5 @@ registerChannelAdapter('telegram', { }; return wrapped; }, + defaults: TELEGRAM_DEFAULTS, }); diff --git a/src/channels/webex.ts b/src/channels/webex.ts index 37b0e8e65..745fc10b8 100644 --- a/src/channels/webex.ts +++ b/src/channels/webex.ts @@ -5,9 +5,20 @@ import { createWebexAdapter } from '@bitbasti/chat-adapter-webex'; import { readEnvFile } from '../env.js'; +import type { ChannelDefaults } from './adapter.js'; import { createChatSdkBridge } from './chat-sdk-bridge.js'; import { registerChannelAdapter } from './channel-registry.js'; +/** + * Dedicated bot app on a threaded platform. 'mention' (not sticky) is the + * conservative group default; operators upgrade per wiring. + */ +const WEBEX_DEFAULTS: ChannelDefaults = { + dm: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'request_approval' }, + group: { engageMode: 'mention', threads: true, unknownSenderPolicy: 'request_approval' }, + mentions: 'platform', +}; + registerChannelAdapter('webex', { factory: () => { const env = readEnvFile(['WEBEX_BOT_TOKEN', 'WEBEX_WEBHOOK_SECRET']); @@ -16,6 +27,12 @@ registerChannelAdapter('webex', { botToken: env.WEBEX_BOT_TOKEN, webhookSecret: env.WEBEX_WEBHOOK_SECRET, }); - return createChatSdkBridge({ adapter: webexAdapter, concurrency: 'concurrent', supportsThreads: true }); + return createChatSdkBridge({ + adapter: webexAdapter, + concurrency: 'concurrent', + supportsThreads: true, + defaults: WEBEX_DEFAULTS, + }); }, + defaults: WEBEX_DEFAULTS, }); diff --git a/src/channels/wechat.ts b/src/channels/wechat.ts index 041600292..6b548dda3 100644 --- a/src/channels/wechat.ts +++ b/src/channels/wechat.ts @@ -23,7 +23,7 @@ import { readEnvFile } from '../env.js'; import { DATA_DIR } from '../config.js'; import { log } from '../log.js'; import { registerChannelAdapter } from './channel-registry.js'; -import type { ChannelAdapter, ChannelSetup, InboundMessage, OutboundMessage } from './adapter.js'; +import type { ChannelAdapter, ChannelDefaults, ChannelSetup, InboundMessage, OutboundMessage } from './adapter.js'; const DATA_SUBDIR = path.join(DATA_DIR, 'wechat'); const AUTH_FILE = path.join(DATA_SUBDIR, 'auth.json'); @@ -75,6 +75,21 @@ function messageText(msg: OutboundMessage): string { return (c.text as string) || (c.markdown as string) || JSON.stringify(msg.content); } +/** + * 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). + */ +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', +}; + registerChannelAdapter('wechat', { factory: () => { const env = readEnvFile(['WECHAT_ENABLED']); @@ -157,6 +172,7 @@ registerChannelAdapter('wechat', { name: 'wechat', channelType: 'wechat', supportsThreads: false, + defaults: WECHAT_DEFAULTS, async setup(config: ChannelSetup) { setupConfig = config; @@ -218,4 +234,5 @@ registerChannelAdapter('wechat', { return adapter; }, + defaults: WECHAT_DEFAULTS, }); diff --git a/src/channels/whatsapp-cloud.ts b/src/channels/whatsapp-cloud.ts index 9d3a5b177..acfd74b5a 100644 --- a/src/channels/whatsapp-cloud.ts +++ b/src/channels/whatsapp-cloud.ts @@ -6,9 +6,21 @@ import { createWhatsAppAdapter } from '@chat-adapter/whatsapp'; import { readEnvFile } from '../env.js'; +import type { ChannelDefaults } from './adapter.js'; import { createChatSdkBridge } from './chat-sdk-bridge.js'; import { registerChannelAdapter } from './channel-registry.js'; +/** + * Dedicated business number on the official Cloud API — non-threaded, so + * group engagement defaults to 'mention' (never sticky: one shared session + * would stay engaged forever). + */ +const WHATSAPP_CLOUD_DEFAULTS: ChannelDefaults = { + dm: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'request_approval' }, + group: { engageMode: 'mention', threads: false, unknownSenderPolicy: 'request_approval' }, + mentions: 'platform', +}; + registerChannelAdapter('whatsapp-cloud', { factory: () => { const env = readEnvFile([ @@ -24,6 +36,12 @@ registerChannelAdapter('whatsapp-cloud', { appSecret: env.WHATSAPP_APP_SECRET, verifyToken: env.WHATSAPP_VERIFY_TOKEN, }); - return createChatSdkBridge({ adapter: whatsappAdapter, concurrency: 'concurrent', supportsThreads: false }); + return createChatSdkBridge({ + adapter: whatsappAdapter, + concurrency: 'concurrent', + supportsThreads: false, + defaults: WHATSAPP_CLOUD_DEFAULTS, + }); }, + defaults: WHATSAPP_CLOUD_DEFAULTS, }); diff --git a/src/channels/whatsapp.ts b/src/channels/whatsapp.ts index a02639c1d..9d597a71c 100644 --- a/src/channels/whatsapp.ts +++ b/src/channels/whatsapp.ts @@ -44,7 +44,14 @@ import { readEnvFile } from '../env.js'; import { log } from '../log.js'; import { registerChannelAdapter } from './channel-registry.js'; import { normalizeOptions, type NormalizedOption } from './ask-question.js'; -import type { ChannelAdapter, ChannelSetup, ConversationInfo, InboundMessage, OutboundMessage } from './adapter.js'; +import type { + ChannelAdapter, + ChannelDefaults, + ChannelSetup, + ConversationInfo, + InboundMessage, + OutboundMessage, +} from './adapter.js'; const baileysLogger = pino({ level: 'silent' }); @@ -278,6 +285,30 @@ function buildMediaMessage(data: Buffer, filename: string, ext: string, caption? return { document: data, fileName: filename, caption, mimetype: 'application/octet-stream' }; } +/** + * Shared vs dedicated number changes every default, so the declaration is + * computed once at module load from the adapter's own env: + * - shared (ASSISTANT_HAS_OWN_NUMBER unset/false): the operator's personal + * number. DMs and group tags address the human, not the bot ('never'); + * groups engage on the agent's name ({name} pattern); auto-create stays + * 'strict' so strangers DMing the human can never spawn agent state. + * - dedicated: a real bot number. Groups engage on platform mentions — + * 'mention', NEVER 'mention-sticky': WhatsApp is non-threaded and sessions + * are never deleted, so sticky would mean engaged-forever. + */ +const WHATSAPP_SHARED = readEnvFile(['ASSISTANT_HAS_OWN_NUMBER']).ASSISTANT_HAS_OWN_NUMBER !== 'true'; +const WHATSAPP_DEFAULTS: ChannelDefaults = WHATSAPP_SHARED + ? { + dm: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'strict' }, + group: { engageMode: 'pattern', engagePattern: '\\b{name}\\b', threads: false, unknownSenderPolicy: 'strict' }, + mentions: 'never', + } + : { + dm: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'request_approval' }, + group: { engageMode: 'mention', threads: false, unknownSenderPolicy: 'request_approval' }, + mentions: 'platform', + }; + registerChannelAdapter('whatsapp', { factory: () => { const env = readEnvFile(['WHATSAPP_PHONE_NUMBER', 'WHATSAPP_ENABLED']); @@ -822,6 +853,7 @@ registerChannelAdapter('whatsapp', { name: 'whatsapp', channelType: 'whatsapp', supportsThreads: false, + defaults: WHATSAPP_DEFAULTS, async setup(hostConfig: ChannelSetup) { setupConfig = hostConfig; @@ -960,4 +992,5 @@ registerChannelAdapter('whatsapp', { return adapter; }, + defaults: WHATSAPP_DEFAULTS, }); From ac99e907a1a9d28583dfbcb0cadee36c7e68f181 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Fri, 10 Jul 2026 21:55:41 +0300 Subject: [PATCH 3/4] PR8: make WhatsApp adapter shared-number aware, drop core config imports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Adapter reads ASSISTANT_NAME / ASSISTANT_HAS_OWN_NUMBER from .env itself (readEnvFile at module load); shared = value !== 'true', exported as resolveSharedMode for the truth-table test - computeIsMention gains the shared flag and returns undefined for everything in shared mode (DMs address the human; owner tags tag the human), killing the approval-card spam at the source; dedicated truth table unchanged - botLid → @ASSISTANT_NAME content rewrite extracted to rewriteBotLidMention and skipped in shared mode so friend tags of the owner can't false-fire name-pattern wirings; outbound ': ' prefix and isBotMessage now key off the same flag (behaviorally identical to !ASSISTANT_HAS_OWN_NUMBER) - Once-per-chatJid debug log when shared mode forwards a non-self-chat message without isMention, for /debug discoverability Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PJkDAnLGWJUqjgJNXpyqGZ --- src/channels/whatsapp.test.ts | 116 +++++++++++++++++++++++++++------- src/channels/whatsapp.ts | 112 ++++++++++++++++++++++++-------- 2 files changed, 179 insertions(+), 49 deletions(-) diff --git a/src/channels/whatsapp.test.ts b/src/channels/whatsapp.test.ts index 95a17245d..9afc2a7c0 100644 --- a/src/channels/whatsapp.test.ts +++ b/src/channels/whatsapp.test.ts @@ -1,26 +1,26 @@ /** * Regression coverage for #2560 — group @-mentions of the bot must set - * `InboundMessage.isMention`. Before the fix, the inbound construction - * site hard-coded `isMention: !isGroup ? true : undefined`, which dropped - * every group mention on the floor and prevented the router from waking - * the agent on a mention-only trigger. + * `InboundMessage.isMention` — plus the shared-number mode split: when + * ASSISTANT_HAS_OWN_NUMBER is not explicitly 'true' the adapter runs on the + * operator's personal number, so NOTHING is a bot mention (DMs address the + * human; group tags of the owner tag the human) and the bot-LID → assistant + * name content rewrite is skipped. * * The detection logic lives in the exported pure helper `isBotMentionedInGroup`; - * the inbound site calls it with `normalized`, `botPhoneJid`, `botLidUser`. - * `isMention` is then computed as: - * - * isMention: !isGroup ? true : botMentionedInGroup ? true : undefined - * - * Both the helper and the call-site ternary are covered below so a future - * refactor that breaks either part fails this suite. + * the inbound site feeds it into `computeIsMention(shared, isGroup, mentioned)`. + * Both helpers and the mode-resolution truth table are covered below so a + * future refactor that breaks any part fails this suite. */ import { describe, it, expect } from 'vitest'; import { appendMediaFailureNote, computeIsMention, + computeWhatsappDefaults, isBotMentionedInGroup, parseWhatsAppMentions, + resolveSharedMode, + rewriteBotLidMention, } from './whatsapp.js'; const BOT_PHONE_JID = '15550009999@s.whatsapp.net'; @@ -89,19 +89,91 @@ describe('isBotMentionedInGroup (#2560)', () => { }); }); -describe('InboundMessage.isMention semantics (#2560)', () => { - it('is undefined for a group message with no bot mention', () => { - expect(computeIsMention(true, false)).toBeUndefined(); +describe('InboundMessage.isMention semantics (#2560 + shared mode)', () => { + describe('dedicated mode (bot has its own number)', () => { + it('is undefined for a group message with no bot mention', () => { + expect(computeIsMention(false, true, false)).toBeUndefined(); + }); + + it('is true for a group message where the bot is mentioned', () => { + expect(computeIsMention(false, true, true)).toBe(true); + }); + + it('is true for a DM regardless of mention state', () => { + // DMs are unconditionally mentions — the helper isn't consulted there. + expect(computeIsMention(false, false, false)).toBe(true); + expect(computeIsMention(false, false, true)).toBe(true); + }); }); - it('is true for a group message where the bot is mentioned', () => { - expect(computeIsMention(true, true)).toBe(true); + describe('shared mode (operator personal number)', () => { + it('is undefined for EVERYTHING — DMs address the human, tags tag the human', () => { + expect(computeIsMention(true, false, false)).toBeUndefined(); + expect(computeIsMention(true, false, true)).toBeUndefined(); + expect(computeIsMention(true, true, false)).toBeUndefined(); + expect(computeIsMention(true, true, true)).toBeUndefined(); + }); + }); +}); + +describe('resolveSharedMode env truth table', () => { + it('explicit "true" → dedicated (not shared)', () => { + expect(resolveSharedMode('true')).toBe(false); }); - it('is true for a DM regardless of mention state', () => { - // DMs are unconditionally mentions — the helper isn't consulted there. - expect(computeIsMention(false, false)).toBe(true); - expect(computeIsMention(false, true)).toBe(true); + it('absent or any other value → shared', () => { + expect(resolveSharedMode(undefined)).toBe(true); + expect(resolveSharedMode('')).toBe(true); + expect(resolveSharedMode('false')).toBe(true); + expect(resolveSharedMode('TRUE')).toBe(true); + expect(resolveSharedMode('1')).toBe(true); + expect(resolveSharedMode('yes')).toBe(true); + }); +}); + +describe('rewriteBotLidMention', () => { + const ASSISTANT = 'Andy'; + + it('dedicated mode rewrites a bot-LID tag into @', () => { + expect(rewriteBotLidMention(`hey @${BOT_LID_USER} status?`, false, BOT_LID_USER, ASSISTANT)).toBe( + 'hey @Andy status?', + ); + }); + + it('shared mode skips the rewrite — a tag of the owner must not become name-pattern bait', () => { + const content = `hey @${BOT_LID_USER} status?`; + expect(rewriteBotLidMention(content, true, BOT_LID_USER, ASSISTANT)).toBe(content); + }); + + it('passes content through when the bot LID is unknown', () => { + expect(rewriteBotLidMention(`hey @${BOT_LID_USER}`, false, undefined, ASSISTANT)).toBe(`hey @${BOT_LID_USER}`); + }); + + it('passes content through when there is no tag of the bot LID', () => { + expect(rewriteBotLidMention('no tags here', false, BOT_LID_USER, ASSISTANT)).toBe('no tags here'); + }); +}); + +describe('computeWhatsappDefaults', () => { + it('shared mode declares strict name-pattern defaults with no platform mentions', () => { + expect(computeWhatsappDefaults(true)).toEqual({ + dm: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'strict' }, + group: { + engageMode: 'pattern', + engagePattern: '\\b{name}\\b', + threads: false, + unknownSenderPolicy: 'strict', + }, + mentions: 'never', + }); + }); + + it('dedicated mode declares platform mentions with group engage on mention (never sticky)', () => { + expect(computeWhatsappDefaults(false)).toEqual({ + dm: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'request_approval' }, + group: { engageMode: 'mention', threads: false, unknownSenderPolicy: 'request_approval' }, + mentions: 'platform', + }); }); }); @@ -172,9 +244,7 @@ describe('appendMediaFailureNote', () => { }); it('appends the note on its own line when a captioned message has a failed download', () => { - expect(appendMediaFailureNote('check this out', ['image'])).toBe( - 'check this out\n[image could not be downloaded]', - ); + expect(appendMediaFailureNote('check this out', ['image'])).toBe('check this out\n[image could not be downloaded]'); }); it('uses the note as the content when an uncaptioned media message fails (would otherwise be dropped)', () => { diff --git a/src/channels/whatsapp.ts b/src/channels/whatsapp.ts index 9d597a71c..4bd61a15c 100644 --- a/src/channels/whatsapp.ts +++ b/src/channels/whatsapp.ts @@ -39,7 +39,7 @@ import { import type { GroupMetadata, WAMessageKey, WAMessage, WASocket } from '@whiskeysockets/baileys'; import { isSafeAttachmentName } from '../attachment-safety.js'; -import { ASSISTANT_HAS_OWN_NUMBER, ASSISTANT_NAME, DATA_DIR } from '../config.js'; +import { DATA_DIR } from '../config.js'; import { readEnvFile } from '../env.js'; import { log } from '../log.js'; import { registerChannelAdapter } from './channel-registry.js'; @@ -240,7 +240,14 @@ export function isBotMentionedInGroup( } /** - * Compute `InboundMessage.isMention` for a WhatsApp message: + * Compute `InboundMessage.isMention` for a WhatsApp message. + * + * Shared-number mode (operator's personal number): NOTHING is a mention. + * DMs are addressed to the human, and a group tag of the owner's JID/LID + * tags the human — treating either as a bot mention would auto-create + * messaging groups and fire approval cards for ordinary human traffic. + * + * Dedicated mode (bot has its own number): * - DMs are always mentions (router auto-engages on the bot's behalf). * - Group messages are mentions only when the bot is explicitly tagged. * @@ -248,11 +255,29 @@ export function isBotMentionedInGroup( * `InboundMessage` field is `isMention?: boolean` and downstream code * treats `undefined` differently than an explicit `false` (#2560). */ -export function computeIsMention(isGroup: boolean, botMentionedInGroup: boolean): true | undefined { +export function computeIsMention(shared: boolean, isGroup: boolean, botMentionedInGroup: boolean): true | undefined { + if (shared) return undefined; if (!isGroup) return true; return botMentionedInGroup ? true : undefined; } +/** + * Normalize a tag of the bot's LID into `@` so mention text + * matches name-pattern triggers. Dedicated mode only: on a shared number the + * LID belongs to the human owner, and rewriting a friend's tag of the owner + * into the assistant's name would make name-pattern wirings false-fire on + * every such tag. + */ +export function rewriteBotLidMention( + content: string, + shared: boolean, + botLidUser: string | undefined, + assistantName: string, +): string { + if (shared || !botLidUser || !content.includes(`@${botLidUser}`)) return content; + return content.replace(`@${botLidUser}`, `@${assistantName}`); +} + /** * Append a visible note for media that failed to download, so the agent knows * something was sent rather than silently losing the attachment — or the whole @@ -285,6 +310,16 @@ function buildMediaMessage(data: Buffer, filename: string, ext: string, caption? return { document: data, fileName: filename, caption, mimetype: 'application/octet-stream' }; } +/** + * Shared vs dedicated number mode. Only an explicit ASSISTANT_HAS_OWN_NUMBER=true + * means the bot has its own number (dedicated); anything else — absent, empty, + * 'false', any other string — means the bot rides the operator's personal + * number (shared). Exported for unit testing the truth table. + */ +export function resolveSharedMode(assistantHasOwnNumber: string | undefined): boolean { + return assistantHasOwnNumber !== 'true'; +} + /** * Shared vs dedicated number changes every default, so the declaration is * computed once at module load from the adapter's own env: @@ -295,19 +330,30 @@ function buildMediaMessage(data: Buffer, filename: string, ext: string, caption? * - dedicated: a real bot number. Groups engage on platform mentions — * 'mention', NEVER 'mention-sticky': WhatsApp is non-threaded and sessions * are never deleted, so sticky would mean engaged-forever. + * + * Exported for unit testing both mode literals. */ -const WHATSAPP_SHARED = readEnvFile(['ASSISTANT_HAS_OWN_NUMBER']).ASSISTANT_HAS_OWN_NUMBER !== 'true'; -const WHATSAPP_DEFAULTS: ChannelDefaults = WHATSAPP_SHARED - ? { - dm: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'strict' }, - group: { engageMode: 'pattern', engagePattern: '\\b{name}\\b', threads: false, unknownSenderPolicy: 'strict' }, - mentions: 'never', - } - : { - dm: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'request_approval' }, - group: { engageMode: 'mention', threads: false, unknownSenderPolicy: 'request_approval' }, - mentions: 'platform', - }; +export function computeWhatsappDefaults(shared: boolean): ChannelDefaults { + return shared + ? { + dm: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'strict' }, + group: { engageMode: 'pattern', engagePattern: '\\b{name}\\b', threads: false, unknownSenderPolicy: 'strict' }, + mentions: 'never', + } + : { + dm: { engageMode: 'pattern', engagePattern: '.', threads: false, unknownSenderPolicy: 'request_approval' }, + group: { engageMode: 'mention', threads: false, unknownSenderPolicy: 'request_approval' }, + mentions: 'platform', + }; +} + +// Adapter-internal env: same .env keys as always (setup/channels/whatsapp.ts +// still writes them), but read here instead of imported from core config — +// shared-number handling is channel-local. +const waEnv = readEnvFile(['ASSISTANT_NAME', 'ASSISTANT_HAS_OWN_NUMBER']); +const ASSISTANT_NAME = waEnv.ASSISTANT_NAME || 'Andy'; +const WHATSAPP_SHARED = resolveSharedMode(waEnv.ASSISTANT_HAS_OWN_NUMBER); +const WHATSAPP_DEFAULTS: ChannelDefaults = computeWhatsappDefaults(WHATSAPP_SHARED); registerChannelAdapter('whatsapp', { factory: () => { @@ -356,6 +402,9 @@ registerChannelAdapter('whatsapp', { let lastGroupSync = 0; let groupSyncTimerStarted = false; + // Chats already noted in the shared-mode once-per-chat debug log + const sharedModeLoggedChats = new Set(); + // First-connect promise let resolveFirstOpen: (() => void) | undefined; let rejectFirstOpen: ((err: Error) => void) | undefined; @@ -751,9 +800,8 @@ registerChannelAdapter('whatsapp', { ''; // Normalize bot LID mention → assistant name for trigger matching - if (botLidUser && content.includes(`@${botLidUser}`)) { - content = content.replace(`@${botLidUser}`, `@${ASSISTANT_NAME}`); - } + // (dedicated mode only — see rewriteBotLidMention) + content = rewriteBotLidMention(content, WHATSAPP_SHARED, botLidUser, ASSISTANT_NAME); // Download media attachments (images, video, audio, documents) const { attachments, failures } = await downloadInboundMedia(msg, normalized); @@ -785,7 +833,7 @@ registerChannelAdapter('whatsapp', { if (sentMessageCache.has(msg.key.id || '')) continue; } - const isBotMessage = ASSISTANT_HAS_OWN_NUMBER ? false : content.startsWith(`${ASSISTANT_NAME}:`); + const isBotMessage = WHATSAPP_SHARED ? content.startsWith(`${ASSISTANT_NAME}:`) : false; // Check if this reply answers a pending question via slash command const pending = pendingQuestions.get(chatJid); @@ -815,12 +863,13 @@ registerChannelAdapter('whatsapp', { const inbound: InboundMessage = { id: msg.key.id || `wa-${Date.now()}`, kind: 'chat', - // DMs are addressed to the bot by definition. Mark them as - // platform-confirmed mentions so the router auto-creates an - // approval-required messaging_group when the chat is unknown, - // instead of silently dropping. In groups, only an explicit - // @-mention counts. - isMention: computeIsMention(isGroup, botMentionedInGroup), + // Dedicated mode: DMs are addressed to the bot by definition. + // Mark them as platform-confirmed mentions so the router + // auto-creates an approval-required messaging_group when the + // chat is unknown, instead of silently dropping. In groups, + // only an explicit @-mention counts. Shared mode: never a + // mention — DMs and tags address the human owner. + isMention: computeIsMention(WHATSAPP_SHARED, isGroup, botMentionedInGroup), isGroup, content: { text: content, @@ -835,6 +884,17 @@ registerChannelAdapter('whatsapp', { timestamp, }; + // Discoverability for /debug: in shared mode nothing carries + // isMention, so unknown chats never auto-create messaging groups + // — traffic can look silently dropped. Note each chat once. + if (WHATSAPP_SHARED && chatJid !== botPhoneJid && !sharedModeLoggedChats.has(chatJid)) { + sharedModeLoggedChats.add(chatJid); + log.debug('Shared-number mode: forwarding chat to router without isMention', { + chatJid, + isGroup, + }); + } + // WhatsApp doesn't use threads — threadId is null setupConfig.onInbound(chatJid, null, inbound); } catch (err) { @@ -949,7 +1009,7 @@ registerChannelAdapter('whatsapp', { if (text) { const { text: formatted, mentions } = formatWhatsApp(text); - const prefixed = ASSISTANT_HAS_OWN_NUMBER ? formatted : `${ASSISTANT_NAME}: ${formatted}`; + const prefixed = WHATSAPP_SHARED ? `${ASSISTANT_NAME}: ${formatted}` : formatted; return sendRawMessage(platformId, prefixed, mentions); } }, From 27e8e0653809a90a22d876c9c80bde052d73ee3a Mon Sep 17 00:00:00 2001 From: gavrielc Date: Fri, 10 Jul 2026 22:04:13 +0300 Subject: [PATCH 4/4] 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(), };