v2 phase 4: channel adapter interface, registry, and host wiring

ChannelAdapter interface with setup/deliver/teardown/setTyping lifecycle.
Self-registration pattern via channel-registry. Host wiring in index-v2
bridges inbound messages to routeInbound and outbound delivery to adapters.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
gavrielc
2026-04-09 00:10:46 +03:00
parent d35386a46e
commit 7201fe5032
6 changed files with 483 additions and 9 deletions

79
src/channels/adapter.ts Normal file
View File

@@ -0,0 +1,79 @@
/**
* v2 Channel Adapter interface.
*
* Channel adapters bridge NanoClaw with messaging platforms (Discord, Slack, etc.).
* Two patterns: native adapters (implement directly) or Chat SDK bridge (wrap a Chat SDK adapter).
*/
/** Configuration for a registered conversation (messaging group + agent wiring). */
export interface ConversationConfig {
platformId: string;
agentGroupId: string;
triggerPattern?: string; // regex string (for native channels)
requiresTrigger: boolean;
sessionMode: 'shared' | 'per-thread';
}
/** Passed to the adapter at setup time. */
export interface ChannelSetup {
/** Known conversations from central DB. */
conversations: ConversationConfig[];
/** Called when an inbound message arrives from the platform. */
onInbound(platformId: string, threadId: string | null, message: InboundMessage): void;
/** Called when the adapter discovers metadata about a conversation. */
onMetadata(platformId: string, name?: string, isGroup?: boolean): void;
}
/** Inbound message from adapter to host. */
export interface InboundMessage {
id: string;
kind: 'chat' | 'chat-sdk';
content: unknown; // JS object — host will JSON.stringify before writing to session DB
timestamp: string;
}
/** Outbound message from host to adapter. */
export interface OutboundMessage {
kind: string;
content: unknown; // parsed JSON from messages_out
}
/** Discovered conversation info (from syncConversations). */
export interface ConversationInfo {
platformId: string;
name: string;
isGroup: boolean;
}
/** The v2 channel adapter contract. */
export interface ChannelAdapter {
name: string;
channelType: string;
// Lifecycle
setup(config: ChannelSetup): Promise<void>;
teardown(): Promise<void>;
isConnected(): boolean;
// Outbound delivery
deliver(platformId: string, threadId: string | null, message: OutboundMessage): Promise<void>;
// Optional
setTyping?(platformId: string, threadId: string | null): Promise<void>;
syncConversations?(): Promise<ConversationInfo[]>;
updateConversations?(conversations: ConversationConfig[]): void;
}
/** Factory function that creates a channel adapter (returns null if credentials missing). */
export type ChannelAdapterFactory = () => ChannelAdapter | null;
/** Registration entry for a channel adapter. */
export interface ChannelRegistration {
factory: ChannelAdapterFactory;
containerConfig?: {
mounts?: Array<{ hostPath: string; containerPath: string; readonly: boolean }>;
env?: Record<string, string>;
};
}

View File

@@ -0,0 +1,227 @@
/**
* Tests for the v2 channel adapter registry and integration with host.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import Database from 'better-sqlite3';
import fs from 'fs';
import type { ChannelAdapter, ChannelSetup, InboundMessage, OutboundMessage } from './adapter.js';
// Mock container runner
vi.mock('../container-runner-v2.js', () => ({
wakeContainer: vi.fn().mockResolvedValue(undefined),
resetContainerIdleTimer: vi.fn(),
isContainerRunning: vi.fn().mockReturnValue(false),
getActiveContainerCount: vi.fn().mockReturnValue(0),
killContainer: vi.fn(),
}));
// Override DATA_DIR for tests
vi.mock('../config.js', async () => {
const actual = await vi.importActual('../config.js');
return { ...actual, DATA_DIR: '/tmp/nanoclaw-test-channels' };
});
const TEST_DIR = '/tmp/nanoclaw-test-channels';
function now() {
return new Date().toISOString();
}
/** Create a mock ChannelAdapter for testing. */
function createMockAdapter(channelType: string): ChannelAdapter & { delivered: OutboundMessage[]; inbound: InboundMessage[] } {
const delivered: OutboundMessage[] = [];
const inbound: InboundMessage[] = [];
let setupConfig: ChannelSetup | null = null;
return {
name: channelType,
channelType,
delivered,
inbound,
async setup(config: ChannelSetup) {
setupConfig = config;
},
async teardown() {
setupConfig = null;
},
isConnected() {
return setupConfig !== null;
},
async deliver(_platformId: string, _threadId: string | null, message: OutboundMessage) {
delivered.push(message);
},
async setTyping() {},
updateConversations() {},
};
}
describe('channel registry', () => {
// Import fresh modules for each test to avoid registry pollution
beforeEach(async () => {
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
fs.mkdirSync(TEST_DIR, { recursive: true });
});
afterEach(() => {
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
});
it('should register and retrieve channel adapters', async () => {
const { registerChannelAdapter, getRegisteredChannelNames, getChannelContainerConfig } = await import(
'./channel-registry.js'
);
registerChannelAdapter('test-channel', {
factory: () => createMockAdapter('test'),
containerConfig: {
env: { TEST_KEY: 'value' },
},
});
expect(getRegisteredChannelNames()).toContain('test-channel');
expect(getChannelContainerConfig('test-channel')).toEqual({
env: { TEST_KEY: 'value' },
});
});
it('should skip adapters that return null (missing credentials)', async () => {
const { registerChannelAdapter, initChannelAdapters, getActiveAdapters } = await import('./channel-registry.js');
registerChannelAdapter('no-creds', {
factory: () => null,
});
await initChannelAdapters(() => ({
conversations: [],
onInbound: () => {},
onMetadata: () => {},
}));
// Should not have any active adapters for channels with null factory returns
const active = getActiveAdapters();
const noCreds = active.find((a) => a.name === 'no-creds');
expect(noCreds).toBeUndefined();
});
});
describe('channel + router integration', () => {
beforeEach(async () => {
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
fs.mkdirSync(TEST_DIR, { recursive: true });
const { initTestDb, runMigrations, createAgentGroup, createMessagingGroup, createMessagingGroupAgent } =
await import('../db/index.js');
const db = initTestDb();
runMigrations(db);
createAgentGroup({
id: 'ag-1',
name: 'Test Agent',
folder: 'test-agent',
is_admin: 0,
agent_provider: null,
container_config: null,
created_at: now(),
});
createMessagingGroup({
id: 'mg-1',
channel_type: 'mock',
platform_id: 'chan-100',
name: 'Test Channel',
is_group: 1,
admin_user_id: null,
created_at: now(),
});
createMessagingGroupAgent({
id: 'mga-1',
messaging_group_id: 'mg-1',
agent_group_id: 'ag-1',
trigger_rules: null,
response_scope: 'all',
session_mode: 'shared',
priority: 0,
created_at: now(),
});
});
afterEach(async () => {
const { closeDb } = await import('../db/index.js');
closeDb();
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
});
it('should route inbound message from adapter to session DB', async () => {
const { routeInbound } = await import('../router-v2.js');
const { findSession } = await import('../db/sessions.js');
const { sessionDbPath } = await import('../session-manager.js');
// Simulate what the adapter bridge does: stringify content, call routeInbound
const inboundContent = { sender: 'TestUser', senderId: 'u1', text: 'Hello from adapter', isFromMe: false };
await routeInbound({
channelType: 'mock',
platformId: 'chan-100',
threadId: null,
message: {
id: 'msg-adapter-1',
kind: 'chat',
content: JSON.stringify(inboundContent),
timestamp: now(),
},
});
// Verify session was created and message written
const session = findSession('mg-1', null);
expect(session).toBeDefined();
const dbPath = sessionDbPath('ag-1', session!.id);
const db = new Database(dbPath);
const rows = db.prepare('SELECT * FROM messages_in').all() as Array<{ id: string; content: string }>;
db.close();
expect(rows).toHaveLength(1);
expect(JSON.parse(rows[0].content).text).toBe('Hello from adapter');
});
it('should deliver outbound message through delivery adapter bridge', async () => {
const { setDeliveryAdapter } = await import('../delivery.js');
const { getChannelAdapter, registerChannelAdapter, initChannelAdapters } = await import('./channel-registry.js');
// Register and init a mock adapter
const mockAdapter = createMockAdapter('mock');
registerChannelAdapter('mock-delivery', {
factory: () => mockAdapter,
});
await initChannelAdapters((adapter) => ({
conversations: [],
onInbound: () => {},
onMetadata: () => {},
}));
// Set up delivery adapter bridge (same pattern as index-v2.ts)
setDeliveryAdapter({
async deliver(channelType, platformId, threadId, kind, content) {
const adapter = getChannelAdapter(channelType);
if (!adapter) return;
await adapter.deliver(platformId, threadId, { kind, content: JSON.parse(content) });
},
});
// Simulate delivery
const adapter = getChannelAdapter('mock');
if (adapter) {
await adapter.deliver('chan-100', null, { kind: 'chat', content: { text: 'Agent response' } });
}
expect(mockAdapter.delivered).toHaveLength(1);
expect((mockAdapter.delivered[0].content as { text: string }).text).toBe('Agent response');
});
});

View File

@@ -0,0 +1,72 @@
/**
* v2 Channel adapter registry.
*
* 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 { log } from '../log.js';
const registry = new Map<string, ChannelRegistration>();
const activeAdapters = new Map<string, ChannelAdapter>();
/** Register a channel adapter factory. Called by channel modules on import. */
export function registerChannelAdapter(name: string, registration: ChannelRegistration): void {
registry.set(name, registration);
}
/** Get a live adapter by channel type. */
export function getChannelAdapter(channelType: string): ChannelAdapter | undefined {
return activeAdapters.get(channelType);
}
/** Get all active adapters. */
export function getActiveAdapters(): ChannelAdapter[] {
return [...activeAdapters.values()];
}
/** Get all registered channel names. */
export function getRegisteredChannelNames(): string[] {
return [...registry.keys()];
}
/** Get container config for a channel (used by container-runner for additional mounts/env). */
export function getChannelContainerConfig(name: string): ChannelRegistration['containerConfig'] {
return registry.get(name)?.containerConfig;
}
/**
* Instantiate and set up all registered channel adapters.
* Skips adapters that return null (missing credentials).
*/
export async function initChannelAdapters(setupFn: (adapter: ChannelAdapter) => ChannelSetup): Promise<void> {
for (const [name, registration] of registry) {
try {
const adapter = registration.factory();
if (!adapter) {
log.warn('Channel credentials missing, skipping', { channel: name });
continue;
}
const setup = setupFn(adapter);
await adapter.setup(setup);
activeAdapters.set(adapter.channelType, adapter);
log.info('Channel adapter started', { channel: name, type: adapter.channelType });
} catch (err) {
log.error('Failed to start channel adapter', { channel: name, err });
}
}
}
/** Tear down all active adapters. */
export async function teardownChannelAdapters(): Promise<void> {
for (const [name, adapter] of activeAdapters) {
try {
await adapter.teardown();
log.info('Channel adapter stopped', { channel: name });
} catch (err) {
log.error('Failed to stop channel adapter', { channel: name, err });
}
}
activeAdapters.clear();
}