Compare commits

..

13 Commits

Author SHA1 Message Date
gavrielc 27e8e06538 PR9: emit platform mention signals from signal/wechat, align telegram pairing
- signal: top-level isGroup + isMention on InboundMessage (DM->true,
  group->dataMessage.mentions matched against config.account via new
  exported computeSignalIsMention); declaration flipped to group
  'mention' + mentions 'platform'
- wechat: hoist isGroup to top level and set isMention=true for DMs;
  declaration flipped to mentions 'dm-only' (group stays name-pattern)
- telegram: pairing-created messaging groups read TELEGRAM_DEFAULTS
  unknownSenderPolicy per context instead of literal 'strict'
- discord maxTextLength:2000 confirmed already landed in PR7; EXPECTED
  table in channel-declarations.test.ts updated for signal/wechat

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PJkDAnLGWJUqjgJNXpyqGZ
2026-07-10 22:04:13 +03:00
gavrielc ac99e907a1 PR8: make WhatsApp adapter shared-number aware, drop core config imports
- 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 '<Name>: ' 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PJkDAnLGWJUqjgJNXpyqGZ
2026-07-10 21:57:13 +03:00
gavrielc cdf8142911 PR7: declare ChannelDefaults on every channels-branch adapter
- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PJkDAnLGWJUqjgJNXpyqGZ
2026-07-10 21:50:12 +03:00
gavrielc 91e64837c5 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PJkDAnLGWJUqjgJNXpyqGZ
2026-07-10 21:35:03 +03:00
gavrielc 432b3a60fe Merge pull request #2922 from OowhitecatoO/fix/discord-forwarded-snapshots
fix(discord): unwrap forwarded-message snapshots so agents see forwarded content
2026-07-07 23:09:14 +03:00
WhiteCat 437a5f0642 fix(discord): unwrap forwarded-message snapshots so agents see forwarded content
Discord forwards carry their text and attachments in message_snapshots with
an empty top-level content (message_reference.type 1), so the adapter
produced an empty inbound message. Unwrap the snapshot into
content/attachments before handleForwardedMessage so text, attachment
download, and formatting all ride the existing path. Snapshots carry no
author, so forwarded content is labeled "[Forwarded message]".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-07-04 11:18:59 +08:00
gavrielc 90dd87df73 Merge pull request #2891 from avri-schneider/resolve-channel-name
feat(channels): add optional resolveChannelName to ChannelAdapter interface
2026-06-30 23:17:14 +03:00
gavrielc 102a96a937 Merge pull request #2895 from echarrod/fix/whatsapp-media-reupload-request
fix(whatsapp): recover inbound media download via reuploadRequest
2026-06-30 23:14:47 +03:00
Ed Harrod 37dbfd1ae8 fix(whatsapp): recover inbound media download via reuploadRequest
downloadInboundMedia called downloadMediaMessage without the reuploadRequest
recovery context, so a "Failed to fetch stream" from the direct CDN fetch
(common around reconnects / on expired media URLs) was unrecoverable. The
catch block then silently dropped the attachment — and uncaptioned media
messages entirely (the empty-message guard skips them).

Pass { reuploadRequest: sock.updateMediaMessage, logger: baileysLogger } so
Baileys can ask WhatsApp to re-upload the media when the direct fetch fails,
and surface a "[<type> could not be downloaded]" note when a download
genuinely fails so the agent knows media was sent instead of dropping it
silently.

Fixes #2894

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KdUxK4bqbgGzFEeZhjC181
2026-06-30 16:45:07 +01:00
Avri Schneider 20a3df0e4d feat(channels): add optional resolveChannelName to ChannelAdapter interface
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 12:24:48 +03:00
Gabi Simons fdbfb6a8ff Merge pull request #2839 from nanocoai/chore/bump-chat-sdk-4.29.0-channels
chore(channels): bring Slack Socket Mode (#2837) into channels
2026-06-23 15:47:41 +03:00
gavrielc 4884bb97f1 Merge pull request #2837 from nanocoai/feat/slack-socket-mode
feat(slack): Socket Mode — adapter + guided setup (SLACK_APP_TOKEN)
2026-06-23 15:39:19 +03:00
gavrielc 940bdb6437 Merge pull request #2835 from nanocoai/chore/bump-chat-sdk-4.29.0-channels
chore(deps): bump @chat-adapter/* + chat to 4.29.0 (channels)
2026-06-23 15:27:36 +03:00
27 changed files with 1425 additions and 68 deletions
+80 -1
View File
@@ -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,11 +108,71 @@ 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;
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 '<channelType>:<handle>',
* 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.
*
@@ -163,6 +224,16 @@ export interface ChannelAdapter {
* Returning the same platform_id on repeated calls is expected.
*/
openDM?(userHandle: string): Promise<string>;
resolveChannelName?: (platformId: string) => Promise<string | null>;
/**
* 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). */
@@ -171,6 +242,14 @@ export type ChannelAdapterFactory = () => ChannelAdapter | Promise<ChannelAdapte
/** Registration entry for a channel adapter. */
export interface ChannelRegistration {
factory: ChannelAdapterFactory;
/**
* Same declaration as ChannelAdapter.defaults, resolvable WITHOUT
* instantiating the adapter — offline creation paths (setup/register.ts,
* scripts/init-first-agent.ts, ncl against a host where the factory
* returned null for missing creds) read it from the registry. Channel
* modules pass the same const here and to the adapter/bridge.
*/
defaults?: ChannelDefaults;
containerConfig?: {
mounts?: Array<{ hostPath: string; containerPath: string; readonly: boolean }>;
env?: Record<string, string>;
+104
View File
@@ -0,0 +1,104 @@
/**
* 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-<channel> 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 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 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', () => {
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('.');
});
});
}
});
+273
View File
@@ -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);
});
});
+86
View File
@@ -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;
}
+81 -3
View File
@@ -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()];
@@ -85,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 });
}
+13 -4
View File
@@ -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;
@@ -234,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', {
+22 -2
View File
@@ -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<void> {
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 });
+19 -1
View File
@@ -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<void> {
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,
});
+76
View File
@@ -0,0 +1,76 @@
import { describe, expect, it } from 'vitest';
import { unwrapForwardedSnapshot } from './discord.js';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function forwardPayload(snapshotMessage: Record<string, any> | null, overrides: Record<string, any> = {}) {
return {
id: '123',
content: '',
attachments: [],
message_reference: { type: 1, channel_id: 'c1', message_id: 'm1' },
...(snapshotMessage ? { message_snapshots: [{ message: snapshotMessage }] } : {}),
...overrides,
};
}
describe('unwrapForwardedSnapshot', () => {
it('unwraps forwarded text into content with a label', () => {
const data = forwardPayload({ content: 'hello from the past', attachments: [] });
unwrapForwardedSnapshot(data);
expect(data.content).toBe('[Forwarded message]\nhello from the past');
});
it('unwraps attachment-only forwards: label + merged attachments', () => {
const att = { filename: 'photo.png', content_type: 'image/png', size: 1234, url: 'https://cdn.example/photo.png' };
const data = forwardPayload({ content: '', attachments: [att] });
unwrapForwardedSnapshot(data);
expect(data.content).toBe('[Forwarded message]');
expect(data.attachments).toEqual([att]);
});
it('merges snapshot attachments after existing ones', () => {
const existing = { filename: 'own.txt', content_type: 'text/plain', size: 1, url: 'https://cdn.example/own.txt' };
const fwd = { filename: 'fwd.jpg', content_type: 'image/jpeg', size: 2, url: 'https://cdn.example/fwd.jpg' };
const data = forwardPayload({ content: 'look', attachments: [fwd] }, { attachments: [existing] });
unwrapForwardedSnapshot(data);
expect(data.attachments).toEqual([existing, fwd]);
expect(data.content).toBe('[Forwarded message]\nlook');
});
it('leaves plain messages untouched', () => {
const data = { id: '1', content: 'hi', attachments: [] };
unwrapForwardedSnapshot(data);
expect(data).toEqual({ id: '1', content: 'hi', attachments: [] });
});
it('leaves normal replies (type 0) untouched', () => {
const data = {
id: '1',
content: 'a reply',
attachments: [],
message_reference: { type: 0, message_id: 'm0' },
referenced_message: { content: 'original', author: { username: 'alice' } },
};
unwrapForwardedSnapshot(data);
expect(data.content).toBe('a reply');
});
it('is a no-op when a forward has no snapshots', () => {
const data = forwardPayload(null);
unwrapForwardedSnapshot(data);
expect(data.content).toBe('');
expect(data.attachments).toEqual([]);
});
it('joins multiple snapshots', () => {
const data = forwardPayload(null, {
message_snapshots: [
{ message: { content: 'one', attachments: [] } },
{ message: { content: 'two', attachments: [] } },
],
});
unwrapForwardedSnapshot(data);
expect(data.content).toBe('[Forwarded message]\none\ntwo');
});
});
+60
View File
@@ -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<string, any>): ReplyContext | null {
if (!raw.referenced_message) return null;
@@ -18,6 +31,47 @@ function extractReplyContext(raw: Record<string, any>): ReplyContext | null {
};
}
/**
* Discord message forwards carry their content in `message_snapshots`, not
* `content` (`message_reference.type === 1` means FORWARD; 0 is a normal
* reply). The adapter only reads `content`/`attachments`, so without this the
* agent sees an empty message. Unwrap the snapshot back into the payload so
* text, attachment download, and formatting all ride the existing path.
* Note: snapshots contain no author, so the original sender is unavailable.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function unwrapForwardedSnapshot(data: Record<string, any>): void {
if (data.message_reference?.type !== 1) return;
const snaps = (data.message_snapshots ?? [])
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.map((s: any) => s?.message)
.filter(Boolean);
if (snaps.length === 0) return;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const text = snaps
.map((m: any) => m.content)
.filter(Boolean)
.join('\n');
const label = '[Forwarded message]';
data.content = text ? `${label}\n${text}` : data.content || label;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const fwdAttachments = snaps.flatMap((m: any) => m.attachments ?? []);
if (fwdAttachments.length > 0) {
data.attachments = [...(data.attachments ?? []), ...fwdAttachments];
}
}
function unwrapForwards(adapter: ReturnType<typeof createDiscordAdapter>): void {
const a = adapter as unknown as {
handleForwardedMessage: (data: Record<string, unknown>, options?: unknown) => Promise<void>;
};
const orig = a.handleForwardedMessage.bind(adapter);
a.handleForwardedMessage = async (data, options) => {
unwrapForwardedSnapshot(data);
return orig(data, options);
};
}
registerChannelAdapter('discord', {
factory: () => {
const env = readEnvFile(['DISCORD_BOT_TOKEN', 'DISCORD_PUBLIC_KEY', 'DISCORD_APPLICATION_ID']);
@@ -27,12 +81,18 @@ registerChannelAdapter('discord', {
publicKey: env.DISCORD_PUBLIC_KEY,
applicationId: env.DISCORD_APPLICATION_ID,
});
unwrapForwards(discordAdapter);
return createChatSdkBridge({
adapter: discordAdapter,
concurrency: 'concurrent',
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,
});
+14 -1
View File
@@ -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<void> {
setupConfig = config;
@@ -181,6 +193,7 @@ registerChannelAdapter('emacs', {
return createEmacsAdapter({ port, authToken, platformId });
},
defaults: EMACS_DEFAULTS,
});
export { createEmacsAdapter };
+18 -1
View File
@@ -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,
});
+19 -1
View File
@@ -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,
});
+20 -1
View File
@@ -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,
});
+21 -1
View File
@@ -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,
});
+20 -1
View File
@@ -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,
});
+19 -1
View File
@@ -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,
});
+58 -1
View File
@@ -61,7 +61,7 @@ vi.mock('node:net', () => ({
}));
import type { ChannelSetup } from './adapter.js';
import { createSignalAdapter } from './signal.js';
import { computeSignalIsMention, createSignalAdapter } from './signal.js';
// --- Test helpers ---
@@ -208,6 +208,8 @@ describe('SignalAdapter', () => {
sender: '+15555550123',
senderName: 'Alice',
}),
isMention: true,
isGroup: false,
}),
);
@@ -240,12 +242,41 @@ describe('SignalAdapter', () => {
text: 'Group hello',
sender: '+15555550999',
}),
isMention: undefined,
isGroup: true,
}),
);
await adapter.teardown();
});
it('marks a group message as a mention when the linked account is tagged', async () => {
const adapter = createAdapter();
const cfg = createMockSetup();
await adapter.setup(cfg);
pushEvent({
sourceNumber: '+15555550999',
sourceName: 'Bob',
dataMessage: {
timestamp: 1700000000000,
message: ' ping',
mentions: [{ start: 0, length: 1, name: 'NanoClaw', number: '+15551234567' }],
groupInfo: { groupId: 'abc123', groupName: 'Family' },
},
});
await new Promise((r) => setTimeout(r, 50));
expect(cfg.onInbound).toHaveBeenCalledWith(
'group:abc123',
null,
expect.objectContaining({ isMention: true, isGroup: true }),
);
await adapter.teardown();
});
it('skips sync messages (own outbound)', async () => {
const adapter = createAdapter();
const cfg = createMockSetup();
@@ -959,3 +990,29 @@ describe('SignalAdapter', () => {
});
});
});
describe('computeSignalIsMention', () => {
const account = '+15551234567';
it('is true for every DM', () => {
expect(computeSignalIsMention(account, false)).toBe(true);
expect(computeSignalIsMention(account, false, [])).toBe(true);
});
it('is true in groups when the account is tagged by number', () => {
expect(computeSignalIsMention(account, true, [{ number: account }])).toBe(true);
});
it('is true in groups when the account is tagged by uuid', () => {
expect(computeSignalIsMention(account, true, [{ uuid: account }])).toBe(true);
});
it('is undefined (not false) in groups when someone else is tagged', () => {
expect(computeSignalIsMention(account, true, [{ number: '+15550000000', uuid: 'bob-uuid' }])).toBeUndefined();
});
it('is undefined in groups without mentions', () => {
expect(computeSignalIsMention(account, true)).toBeUndefined();
expect(computeSignalIsMention(account, true, [])).toBeUndefined();
});
});
+40 -2
View File
@@ -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';
@@ -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);
@@ -809,6 +831,7 @@ export function createSignalAdapter(config: {
name: 'signal',
channelType: 'signal',
supportsThreads: false,
defaults: SIGNAL_DEFAULTS,
async setup(cfg: ChannelSetup): Promise<void> {
setup = cfg;
@@ -934,6 +957,20 @@ 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'. The adapter emits
* top-level isGroup and isMention (DMtrue; groupdataMessage.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: 'mention', threads: false, unknownSenderPolicy: 'strict' },
mentions: 'platform',
};
registerChannelAdapter('signal', {
factory: () => {
const envVars = readEnvFile([
@@ -980,4 +1017,5 @@ registerChannelAdapter('signal', {
signalDataDir,
});
},
defaults: SIGNAL_DEFAULTS,
});
+24 -1
View File
@@ -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,
});
+18 -1
View File
@@ -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,
});
+18 -2
View File
@@ -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
@@ -154,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(),
});
}
@@ -209,6 +223,7 @@ registerChannelAdapter('telegram', {
concurrency: 'concurrent',
extractReplyContext,
supportsThreads: false,
defaults: TELEGRAM_DEFAULTS,
transformOutboundText: sanitizeTelegramLegacyMarkdown,
maxTextLength: 4000,
});
@@ -242,4 +257,5 @@ registerChannelAdapter('telegram', {
};
return wrapped;
},
defaults: TELEGRAM_DEFAULTS,
});
+18 -1
View File
@@ -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,
});
+21 -1
View File
@@ -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,19 @@ 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 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: 'dm-only',
};
registerChannelAdapter('wechat', {
factory: () => {
const env = readEnvFile(['WECHAT_ENABLED']);
@@ -147,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(),
};
@@ -157,6 +175,7 @@ registerChannelAdapter('wechat', {
name: 'wechat',
channelType: 'wechat',
supportsThreads: false,
defaults: WECHAT_DEFAULTS,
async setup(config: ChannelSetup) {
setupConfig = config;
@@ -218,4 +237,5 @@ registerChannelAdapter('wechat', {
return adapter;
},
defaults: WECHAT_DEFAULTS,
});
+19 -1
View File
@@ -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,
});
+121 -21
View File
@@ -1,22 +1,27 @@
/**
* 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 { computeIsMention, isBotMentionedInGroup, parseWhatsAppMentions } from './whatsapp.js';
import {
appendMediaFailureNote,
computeIsMention,
computeWhatsappDefaults,
isBotMentionedInGroup,
parseWhatsAppMentions,
resolveSharedMode,
rewriteBotLidMention,
} from './whatsapp.js';
const BOT_PHONE_JID = '15550009999@s.whatsapp.net';
const BOT_LID_USER = '987654321';
@@ -84,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 @<assistant name>', () => {
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',
});
});
});
@@ -160,3 +237,26 @@ describe('parseWhatsAppMentions', () => {
expect(mentions).toEqual(['15551234567@s.whatsapp.net']);
});
});
describe('appendMediaFailureNote', () => {
it('returns content unchanged when nothing failed', () => {
expect(appendMediaFailureNote('hello', [])).toBe('hello');
});
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]');
});
it('uses the note as the content when an uncaptioned media message fails (would otherwise be dropped)', () => {
// Regression guard: an uncaptioned image whose download fails must still
// produce a non-empty message, or the empty-message guard skips it and the
// agent never learns media was sent.
expect(appendMediaFailureNote('', ['image'])).toBe('[image could not be downloaded]');
});
it('lists each failed media type when several fail together', () => {
expect(appendMediaFailureNote('', ['image', 'document'])).toBe(
'[image could not be downloaded] [document could not be downloaded]',
);
});
});
+143 -19
View File
@@ -39,12 +39,19 @@ 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';
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' });
@@ -233,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.
*
@@ -241,11 +255,41 @@ 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 `@<assistant name>` 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
* message, when an uncaptioned image would otherwise be dropped by the
* empty-message guard. Returns `content` unchanged when nothing failed.
*/
export function appendMediaFailureNote(content: string, failures: string[]): string {
if (failures.length === 0) return content;
const note = failures.map((t) => `[${t} could not be downloaded]`).join(' ');
return content ? `${content}\n${note}` : note;
}
/** Map file extension to Baileys media message type. */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function buildMediaMessage(data: Buffer, filename: string, ext: string, caption?: string): any {
@@ -266,6 +310,51 @@ 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:
* - 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.
*
* Exported for unit testing both mode literals.
*/
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: () => {
const env = readEnvFile(['WHATSAPP_PHONE_NUMBER', 'WHATSAPP_ENABLED']);
@@ -313,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<string>();
// First-connect promise
let resolveFirstOpen: (() => void) | undefined;
let rejectFirstOpen: ((err: Error) => void) | undefined;
@@ -428,7 +520,10 @@ registerChannelAdapter('whatsapp', {
async function downloadInboundMedia(
msg: WAMessage,
normalized: any,
): Promise<Array<{ type: string; name: string; localPath: string }>> {
): Promise<{
attachments: Array<{ type: string; name: string; localPath: string }>;
failures: string[];
}> {
const mediaTypes: Array<{ key: string; type: string; ext: string }> = [
{ key: 'imageMessage', type: 'image', ext: '.jpg' },
{ key: 'videoMessage', type: 'video', ext: '.mp4' },
@@ -436,10 +531,20 @@ registerChannelAdapter('whatsapp', {
{ key: 'documentMessage', type: 'document', ext: '' },
];
const results: Array<{ type: string; name: string; localPath: string }> = [];
const failures: string[] = [];
for (const { key, type, ext } of mediaTypes) {
if (!normalized[key]) continue;
try {
const buffer = await downloadMediaMessage(msg, 'buffer', {});
// Pass reuploadRequest so Baileys can ask WhatsApp to re-upload the
// media when the direct CDN fetch fails or the media URL has expired
// (common around reconnects). Without it, a "Failed to fetch stream"
// is unrecoverable and the attachment is silently lost.
const buffer = await downloadMediaMessage(
msg,
'buffer',
{},
{ reuploadRequest: sock.updateMediaMessage, logger: baileysLogger },
);
// documentMessage.fileName is attacker-controlled and rides through
// WhatsApp's E2E channel — Meta can't sanitize it server-side. Without
// this guard, a `..`-laden fileName escapes attachDir on path.join.
@@ -460,9 +565,10 @@ registerChannelAdapter('whatsapp', {
log.info('Media downloaded', { type, filename });
} catch (err) {
log.warn('Failed to download media', { type, err });
failures.push(type);
}
}
return results;
return { attachments: results, failures };
}
async function sendRawMessage(jid: string, text: string, mentions?: string[]): Promise<string | undefined> {
@@ -694,12 +800,16 @@ 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 = await downloadInboundMedia(msg, normalized);
const { attachments, failures } = await downloadInboundMedia(msg, normalized);
// Surface failed downloads as text so the agent knows media was
// sent even when it couldn't be fetched — instead of silently
// dropping the attachment (or the whole message, if uncaptioned).
content = appendMediaFailureNote(content, failures);
// Skip empty protocol messages (no text and no attachments)
if (!content && attachments.length === 0) continue;
@@ -723,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);
@@ -753,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,
@@ -773,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) {
@@ -791,6 +913,7 @@ registerChannelAdapter('whatsapp', {
name: 'whatsapp',
channelType: 'whatsapp',
supportsThreads: false,
defaults: WHATSAPP_DEFAULTS,
async setup(hostConfig: ChannelSetup) {
setupConfig = hostConfig;
@@ -886,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);
}
},
@@ -929,4 +1052,5 @@ registerChannelAdapter('whatsapp', {
return adapter;
},
defaults: WHATSAPP_DEFAULTS,
});