mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-07-12 19:00:58 +08:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 27e8e06538 | |||
| ac99e907a1 | |||
| cdf8142911 | |||
| 91e64837c5 | |||
| 432b3a60fe | |||
| 437a5f0642 | |||
| 90dd87df73 | |||
| 102a96a937 | |||
| 37dbfd1ae8 | |||
| 20a3df0e4d | |||
| fdbfb6a8ff | |||
| 4884bb97f1 | |||
| 940bdb6437 |
+80
-1
@@ -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>;
|
||||
|
||||
@@ -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('.');
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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
@@ -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 (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: '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
@@ -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
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user