Skip to content

Commit 0b31695

Browse files
authored
Merge pull request #2733 from nanocoai/feat/channel-instances
feat(channels): native channel-instance dimension — multi-bot substrate
2 parents 421f870 + fccaadf commit 0b31695

28 files changed

Lines changed: 1659 additions & 110 deletions

.claude/skills/add-github/SKILL.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,8 +111,8 @@ Run `/manage-channels` to wire the GitHub channel to an agent group, or insert m
111111

112112
```sql
113113
-- Create messaging group (one per repo)
114-
INSERT INTO messaging_groups (id, channel_type, platform_id, name, is_group, unknown_sender_policy, created_at)
115-
VALUES ('mg-github-myrepo', 'github', 'github:owner/repo', 'owner/repo', 1, '<policy>', datetime('now'));
114+
INSERT INTO messaging_groups (id, channel_type, platform_id, instance, name, is_group, unknown_sender_policy, created_at)
115+
VALUES ('mg-github-myrepo', 'github', 'github:owner/repo', 'github', 'owner/repo', 1, '<policy>', datetime('now'));
116116

117117
-- Wire to agent group
118118
INSERT INTO messaging_group_agents (id, messaging_group_id, agent_group_id, trigger_rules, response_scope, session_mode, priority, created_at)

.claude/skills/add-linear/SKILL.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,8 +119,8 @@ Run `/manage-channels` to wire the Linear channel to an agent group, or insert m
119119

120120
```sql
121121
-- Create messaging group (one per team)
122-
INSERT INTO messaging_groups (id, channel_type, platform_id, name, is_group, unknown_sender_policy, created_at)
123-
VALUES ('mg-linear-eng', 'linear', 'linear:ENG', 'Engineering', 1, 'public', datetime('now'));
122+
INSERT INTO messaging_groups (id, channel_type, platform_id, instance, name, is_group, unknown_sender_policy, created_at)
123+
VALUES ('mg-linear-eng', 'linear', 'linear:ENG', 'linear', 'Engineering', 1, 'public', datetime('now'));
124124

125125
-- Wire to agent group
126126
INSERT INTO messaging_group_agents (id, messaging_group_id, agent_group_id, trigger_rules, response_scope, session_mode, priority, created_at)

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ user_dms (user_id, channel_type, messaging_group_id) — cold-DM cache
3333
3434
agent_groups (workspace, memory, CLAUDE.md, personality, container config)
3535
↕ many-to-many via messaging_group_agents (session_mode, trigger_rules, priority)
36-
messaging_groups (one chat/channel on one platform; unknown_sender_policy)
36+
messaging_groups (one chat/channel on one platform; instance = adapter-instance name, defaults to channel_type; unknown_sender_policy)
3737
3838
sessions (agent_group_id + messaging_group_id + thread_id → per-session container)
3939
```

docs/architecture.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -668,15 +668,19 @@ CREATE TABLE agent_groups (
668668
);
669669

670670
-- Platform groups/channels (WhatsApp group, Slack channel, Discord channel, email thread, etc.)
671+
-- One row per chat PER ADAPTER INSTANCE. instance defaults to channel_type
672+
-- (the "default instance"), so single-instance installs never see it.
671673
CREATE TABLE messaging_groups (
672674
id TEXT PRIMARY KEY,
673675
channel_type TEXT NOT NULL, -- 'whatsapp', 'slack', 'discord', 'telegram', 'email'
674676
platform_id TEXT NOT NULL, -- platform-specific ID (JID, channel ID, etc.)
677+
instance TEXT NOT NULL, -- adapter-instance name; default = channel_type
675678
name TEXT,
676679
is_group INTEGER DEFAULT 0,
677680
unknown_sender_policy TEXT NOT NULL DEFAULT 'strict', -- 'strict' | 'request_approval' | 'public'
678681
created_at TEXT NOT NULL,
679-
UNIQUE(channel_type, platform_id)
682+
denied_at TEXT,
683+
UNIQUE(channel_type, platform_id, instance)
680684
);
681685

682686
-- Users (messaging platform identities, namespaced "<channel_type>:<handle>")

docs/db-central.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,21 +27,24 @@ CREATE TABLE agent_groups (
2727

2828
### 1.2 `messaging_groups`
2929

30-
One row per platform chat (one WhatsApp group, one Slack channel, one 1:1 DM, etc.).
30+
One row per platform chat (one WhatsApp group, one Slack channel, one 1:1 DM, etc.) per adapter instance.
3131

3232
```sql
3333
CREATE TABLE messaging_groups (
3434
id TEXT PRIMARY KEY,
3535
channel_type TEXT NOT NULL,
3636
platform_id TEXT NOT NULL,
37+
instance TEXT NOT NULL,
3738
name TEXT,
3839
is_group INTEGER DEFAULT 0,
3940
unknown_sender_policy TEXT NOT NULL DEFAULT 'strict',
4041
created_at TEXT NOT NULL,
41-
UNIQUE(channel_type, platform_id)
42+
denied_at TEXT,
43+
UNIQUE(channel_type, platform_id, instance)
4244
);
4345
```
4446

47+
- `instance`: adapter-instance name — N adapters of one platform (e.g. three Slack apps in one workspace) each own their rows. The default instance IS the channel type: migration 016 backfills `instance = channel_type` and `createMessagingGroup` stamps the same default, so single-instance installs never see the dimension. Inbound lookups are exact-on-instance (an unknown named instance auto-creates its own row); outbound lookups resolve default-instance-first.
4548
- `unknown_sender_policy`: `strict` (drop), `request_approval` (ask admin), `public` (allow).
4649
- **Readers:** `src/router.ts`, `src/delivery.ts`, `src/session-manager.ts`
4750
- **Writers:** `src/db/messaging-groups.ts`, channel setup flows
@@ -134,7 +137,7 @@ CREATE TABLE user_dms (
134137
);
135138
```
136139

137-
Populated lazily by `ensureUserDm()` in `src/user-dm.ts`.
140+
Populated lazily by `ensureUserDm()` in `src/user-dm.ts`. Cold DMs resolve via the channel's default adapter instance — `PRIMARY KEY (user_id, channel_type)` is per-platform, not per-instance.
138141

139142
### 1.8 `sessions`
140143

src/channels/adapter.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@ export interface DeliveryAddress {
4444
*/
4545
export interface InboundEvent {
4646
channelType: string;
47+
/** Receiving adapter instance; stamped host-side (src/index.ts onInbound).
48+
* Absent (e.g. CLI onInboundEvent) means the default instance (= channelType). */
49+
instance?: string;
4750
platformId: string;
4851
threadId: string | null;
4952
message: {
@@ -112,6 +115,15 @@ export interface ChannelAdapter {
112115
name: string;
113116
channelType: string;
114117

118+
/**
119+
* Adapter-instance name — distinguishes N adapters of one platform
120+
* (e.g. three Slack apps in one workspace). Defaults to channelType.
121+
* channelType stays the SEMANTIC platform key (user ids '<channelType>:<handle>',
122+
* formatting, container config); instance is a host-side routing key only.
123+
* Must be unique across active adapters and URL-safe (no '/', '?', ':').
124+
*/
125+
instance?: string;
126+
115127
/**
116128
* Whether this adapter models conversations as threads.
117129
*

src/channels/channel-registry.test.ts

Lines changed: 118 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,19 +30,24 @@ function now() {
3030
/** Create a mock ChannelAdapter for testing. */
3131
function createMockAdapter(
3232
channelType: string,
33-
): ChannelAdapter & { delivered: OutboundMessage[]; inbound: InboundMessage[] } {
33+
instance?: string,
34+
): ChannelAdapter & { delivered: OutboundMessage[]; inbound: InboundMessage[]; setupTimes: number[] } {
3435
const delivered: OutboundMessage[] = [];
3536
const inbound: InboundMessage[] = [];
37+
const setupTimes: number[] = [];
3638
let setupConfig: ChannelSetup | null = null;
3739

3840
return {
39-
name: channelType,
41+
name: instance ?? channelType,
4042
channelType,
43+
instance,
4144
supportsThreads: false,
4245
delivered,
4346
inbound,
47+
setupTimes,
4448

4549
async setup(config: ChannelSetup) {
50+
setupTimes.push(Date.now());
4651
setupConfig = config;
4752
},
4853

@@ -117,6 +122,117 @@ describe('channel registry', () => {
117122
});
118123
});
119124

125+
describe('channel registry — instance keying', () => {
126+
// Fresh module per test: the registry and activeAdapters maps are
127+
// module-level, and these arms register conflicting same-channelType
128+
// adapters that must not leak across tests.
129+
beforeEach(() => {
130+
vi.resetModules();
131+
});
132+
133+
afterEach(async () => {
134+
const { teardownChannelAdapters } = await import('./channel-registry.js');
135+
await teardownChannelAdapters();
136+
// Drop this test's registrations so later describe blocks (which import
137+
// the registry without resetting) start from an empty registry instead
138+
// of inheriting same-channelType pairs.
139+
vi.resetModules();
140+
});
141+
142+
const mockSetup = () => ({
143+
onInbound: () => {},
144+
onInboundEvent: () => {},
145+
onMetadata: () => {},
146+
onAction: () => {},
147+
});
148+
149+
it('keys two same-channelType adapters by instance — both resolvable', async () => {
150+
const reg = await import('./channel-registry.js');
151+
const worker = createMockAdapter('slack', 'slack-worker');
152+
const tester = createMockAdapter('slack', 'slack-tester');
153+
reg.registerChannelAdapter('slack-worker', { factory: () => worker });
154+
reg.registerChannelAdapter('slack-tester', { factory: () => tester });
155+
156+
await reg.initChannelAdapters(mockSetup);
157+
158+
expect(reg.getChannelAdapter('slack-worker')).toBe(worker);
159+
expect(reg.getChannelAdapter('slack-tester')).toBe(tester);
160+
expect(reg.getActiveAdapters()).toHaveLength(2);
161+
});
162+
163+
it('resolves channelType to the default-instance adapter when one exists, else first-registered', async () => {
164+
const reg = await import('./channel-registry.js');
165+
const named = createMockAdapter('slack', 'slack-tester');
166+
const unnamed = createMockAdapter('slack');
167+
reg.registerChannelAdapter('slack-tester', { factory: () => named });
168+
reg.registerChannelAdapter('slack', { factory: () => unnamed });
169+
170+
await reg.initChannelAdapters(mockSetup);
171+
172+
// Exact key (default instance keyed by channelType) beats the fallback
173+
// scan, even though the named sibling registered first.
174+
expect(reg.getChannelAdapter('slack')).toBe(unnamed);
175+
176+
// With ONLY named instances active, channelType still resolves —
177+
// deterministic first-registered fallback.
178+
await reg.teardownChannelAdapters();
179+
vi.resetModules();
180+
const reg2 = await import('./channel-registry.js');
181+
const first = createMockAdapter('slack', 'slack-tester');
182+
const second = createMockAdapter('slack', 'slack-worker');
183+
reg2.registerChannelAdapter('slack-tester', { factory: () => first });
184+
reg2.registerChannelAdapter('slack-worker', { factory: () => second });
185+
await reg2.initChannelAdapters(mockSetup);
186+
expect(reg2.getChannelAdapter('slack')).toBe(first);
187+
});
188+
189+
it('does NOT reroute default-instance outbound through a named sibling when the default adapter is missing', async () => {
190+
// The default Slack app is offline (token rotated, factory returned
191+
// null, …) while a named sibling boots fine. Outbound for the default
192+
// instance must get the offline-adapter handling (drop into the retry
193+
// path) — NEVER a cross-identity send through the sibling bot.
194+
const reg = await import('./channel-registry.js');
195+
const tester = createMockAdapter('slack', 'slack-tester');
196+
reg.registerChannelAdapter('slack-tester', { factory: () => tester });
197+
reg.registerChannelAdapter('slack', { factory: () => null });
198+
199+
await reg.initChannelAdapters(mockSetup);
200+
201+
// Exact lookup (delivery/typing path): the default key resolves nothing.
202+
expect(reg.getChannelAdapterExact('slack')).toBeUndefined();
203+
// Fallback-capable lookup (channelType-only callers) still resolves.
204+
expect(reg.getChannelAdapter('slack')).toBe(tester);
205+
206+
// The delivery bridge dispatches by exact key: a default-instance
207+
// message (instance === channelType after backfill) is dropped, not
208+
// delivered through the sibling's identity.
209+
const bridge = reg.createChannelDeliveryAdapter();
210+
const result = await bridge.deliver(
211+
'slack',
212+
'slack:C1',
213+
null,
214+
'chat',
215+
JSON.stringify({ text: 'to the default bot' }),
216+
undefined,
217+
'slack',
218+
);
219+
expect(result).toBeUndefined();
220+
expect(tester.delivered).toHaveLength(0);
221+
222+
// Sanity: the same bridge DOES deliver when the exact instance is live.
223+
await bridge.deliver(
224+
'slack',
225+
'slack:C1',
226+
null,
227+
'chat',
228+
JSON.stringify({ text: 'to the tester bot' }),
229+
undefined,
230+
'slack-tester',
231+
);
232+
expect(tester.delivered).toHaveLength(1);
233+
});
234+
});
235+
120236
describe('channel + router integration', () => {
121237
beforeEach(async () => {
122238
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });

src/channels/channel-registry.ts

Lines changed: 85 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
* Channels self-register on import. The host calls initChannelAdapters() at startup
55
* to instantiate and set up all registered adapters.
66
*/
7-
import type { ChannelAdapter, ChannelRegistration, ChannelSetup } from './adapter.js';
7+
import type { ChannelAdapter, ChannelRegistration, ChannelSetup, OutboundFile } from './adapter.js';
8+
import type { ChannelDeliveryAdapter } from '../delivery.js';
89
import { log } from '../log.js';
910

1011
const SETUP_RETRY_DELAYS_MS = [2000, 5000, 10000];
@@ -26,9 +27,79 @@ export function registerChannelAdapter(name: string, registration: ChannelRegist
2627
registry.set(name, registration);
2728
}
2829

29-
/** Get a live adapter by channel type. */
30-
export function getChannelAdapter(channelType: string): ChannelAdapter | undefined {
31-
return activeAdapters.get(channelType);
30+
/** Get a live adapter by its EXACT registry key (instance name; default
31+
* instances are keyed by channelType itself). No channelType fallback —
32+
* callers that address a specific instance (outbound delivery, typing)
33+
* must never be rerouted through a sibling instance: that would send
34+
* through the wrong bot identity with the wrong token. A missing key
35+
* means the owning adapter is offline; callers apply their normal
36+
* offline-adapter handling. */
37+
export function getChannelAdapterExact(key: string): ChannelAdapter | undefined {
38+
return activeAdapters.get(key);
39+
}
40+
41+
/** Get a live adapter by instance name, falling back to any adapter of the
42+
* given channel type. The fallback exists ONLY for channelType-only callers
43+
* (user-id prefix resolution and cold DMs in user-dm.ts, approval delivery
44+
* in channel-approval.ts, the router's thread-policy probe when an event
45+
* carries no instance) — they must still resolve when every instance of a
46+
* platform is named. First registered wins (Map insertion order,
47+
* deterministic). Default instances are keyed by channelType itself, so
48+
* single-instance installs always hit the exact-key path. Instance-addressed
49+
* dispatch (delivery, typing) must use getChannelAdapterExact instead. */
50+
export function getChannelAdapter(key: string): ChannelAdapter | undefined {
51+
const exact = activeAdapters.get(key);
52+
if (exact) return exact;
53+
for (const [registryKey, adapter] of activeAdapters) {
54+
if (adapter.channelType === key) {
55+
log.warn('Channel adapter fallback: requested key resolved through a differently-keyed instance', {
56+
requested: key,
57+
resolvedKey: registryKey,
58+
});
59+
return adapter;
60+
}
61+
}
62+
return undefined;
63+
}
64+
65+
/**
66+
* Build the host's outbound delivery bridge: dispatches delivery-poll and
67+
* typing traffic into the adapter registry. Resolution is EXACT-key only —
68+
* `instance ?? channelType`. For default-instance messaging_groups rows the
69+
* stored instance IS the channelType, which matches default-registered
70+
* adapters, so single-instance behavior is unchanged. A named instance whose
71+
* adapter is offline gets the normal offline-adapter handling (warn + drop
72+
* into the delivery retry path) — never a cross-identity send through a
73+
* sibling bot of the same platform.
74+
*/
75+
export function createChannelDeliveryAdapter(): ChannelDeliveryAdapter {
76+
return {
77+
async deliver(
78+
channelType: string,
79+
platformId: string,
80+
threadId: string | null,
81+
kind: string,
82+
content: string,
83+
files?: OutboundFile[],
84+
instance?: string,
85+
): Promise<string | undefined> {
86+
const adapter = getChannelAdapterExact(instance ?? channelType);
87+
if (!adapter) {
88+
log.warn('No adapter for channel type', { channelType, instance });
89+
return;
90+
}
91+
return adapter.deliver(platformId, threadId, { kind, content: JSON.parse(content), files });
92+
},
93+
async setTyping(
94+
channelType: string,
95+
platformId: string,
96+
threadId: string | null,
97+
instance?: string,
98+
): Promise<void> {
99+
const adapter = getChannelAdapterExact(instance ?? channelType);
100+
await adapter?.setTyping?.(platformId, threadId);
101+
},
102+
};
32103
}
33104

34105
/** Get all active adapters. */
@@ -85,8 +156,16 @@ export async function initChannelAdapters(setupFn: (adapter: ChannelAdapter) =>
85156
throw err;
86157
}
87158
}
88-
activeAdapters.set(adapter.channelType, adapter);
89-
log.info('Channel adapter started', { channel: name, type: adapter.channelType });
159+
// Adapters key by instance (default instance = channelType), so N
160+
// instances of one platform coexist. Duplicate keys warn instead of
161+
// throwing — boot stays resilient, matching the historical silent
162+
// last-write-wins, but now visibly.
163+
const key = adapter.instance ?? adapter.channelType;
164+
if (activeAdapters.has(key)) {
165+
log.warn('Duplicate adapter instance key — overwriting previous adapter', { key, channel: name });
166+
}
167+
activeAdapters.set(key, adapter);
168+
log.info('Channel adapter started', { channel: name, type: adapter.channelType, instance: key });
90169
} catch (err) {
91170
log.error('Failed to start channel adapter', { channel: name, err });
92171
}

0 commit comments

Comments
 (0)