feat: add channel adapter abstraction with Telegram and WebChat adapters

Implement Phase 3 channel adapters that decouple message sources from
the agent via a uniform ChannelAdapter interface and ChannelRegistry.

- Add ChannelAdapter/InboundMessage/OutboundMessage types
- Add ChannelRegistry for adapter lifecycle and message routing
- Add TelegramAdapter (grammy bot, auth middleware, confirmations, chunking)
- Add WebChatAdapter (thin shim over GatewayServer)
- Refactor daemon to use ChannelRegistry with per-channel-per-user agents
- Add config.get/config.patch gateway handlers (Phase 2 loose end)
- Add system.restart gateway handler (Phase 2 loose end)
- Add implementation plans and design docs

Tests: 225 passing (33 new channel adapter + gateway handler tests)
This commit is contained in:
William Valentin
2026-02-05 20:00:36 -08:00
parent 282a15d2b9
commit aa95f2132c
19 changed files with 4123 additions and 37 deletions
+142
View File
@@ -3,6 +3,7 @@ import { createSystemHandlers } from './system.js';
import { createSessionHandlers } from './sessions.js';
import { createToolHandlers } from './tools.js';
import { createAgentHandlers } from './agent.js';
import { createConfigHandlers } from './config.js';
import { ErrorCode } from '../protocol.js';
import type { GatewayRequest, GatewayResponse, GatewayError, GatewayEvent, OutboundMessage } from '../protocol.js';
@@ -269,3 +270,144 @@ describe('agent handlers', () => {
expect((result.result as any).cancelled).toBe(true);
});
});
describe('system.restart handler', () => {
it('returns restarting:true and calls restart callback', async () => {
const restartFn = vi.fn(async () => {});
const handlers = createSystemHandlers({
startTime: Date.now(),
version: '0.1.0',
getSessionCount: () => 0,
getToolCount: () => 0,
getConnectionCount: () => 0,
restart: restartFn,
});
const req: GatewayRequest = { id: 1, method: 'system.restart' };
const result = await handlers['system.restart'](req) as GatewayResponse;
expect(result.id).toBe(1);
expect((result.result as any).restarting).toBe(true);
// Restart is called asynchronously via queueMicrotask
await new Promise<void>((resolve) => queueMicrotask(resolve));
expect(restartFn).toHaveBeenCalledOnce();
});
it('returns error when restart is not available', async () => {
const handlers = createSystemHandlers({
startTime: Date.now(),
version: '0.1.0',
getSessionCount: () => 0,
getToolCount: () => 0,
getConnectionCount: () => 0,
});
const req: GatewayRequest = { id: 2, method: 'system.restart' };
const result = await handlers['system.restart'](req) as GatewayError;
expect(result.error.code).toBe(ErrorCode.InternalError);
expect(result.error.message).toContain('not available');
});
});
describe('config handlers', () => {
function makeConfig() {
return {
telegram: { bot_token: 'secret-token-123', allowed_chat_ids: [12345] },
server: { tailscale_only: true, localhost: true, port: 18800 },
models: {
default: { provider: 'anthropic' as const, model: 'claude-3-haiku', api_key: 'sk-secret-key' },
fallback_chain: ['anthropic'],
},
backends: { claude_code: { enabled: false }, opencode: { enabled: false }, native: { enabled: true } },
hooks: { confirm: ['shell.exec'], log: [], silent: [] },
mcp: { servers: [] },
};
}
it('config.get returns redacted config', async () => {
const config = makeConfig();
const handlers = createConfigHandlers({ config: config as any });
const req: GatewayRequest = { id: 1, method: 'config.get' };
const result = await handlers['config.get'](req) as GatewayResponse;
const r = result.result as Record<string, any>;
expect(r.telegram.bot_token).toBe('***');
expect(r.models.default.api_key).toBe('***');
// Non-secret values are preserved
expect(r.server.port).toBe(18800);
expect(r.hooks.confirm).toEqual(['shell.exec']);
});
it('config.patch applies valid patches', async () => {
const config = makeConfig();
const handlers = createConfigHandlers({ config: config as any });
const req: GatewayRequest = {
id: 2,
method: 'config.patch',
params: {
patches: {
'hooks.confirm': ['shell.exec', 'file.write'],
'hooks.log': ['file.read'],
},
},
};
const result = await handlers['config.patch'](req) as GatewayResponse;
const r = result.result as { applied: string[]; rejected: string[] };
expect(r.applied).toEqual(['hooks.confirm', 'hooks.log']);
expect(r.rejected).toEqual([]);
// Verify the config was actually mutated
expect(config.hooks.confirm).toEqual(['shell.exec', 'file.write']);
expect(config.hooks.log).toEqual(['file.read']);
});
it('config.patch rejects unknown keys', async () => {
const config = makeConfig();
const handlers = createConfigHandlers({ config: config as any });
const req: GatewayRequest = {
id: 3,
method: 'config.patch',
params: {
patches: {
'telegram.bot_token': 'hacked',
'hooks.confirm': [],
},
},
};
const result = await handlers['config.patch'](req) as GatewayResponse;
const r = result.result as { applied: string[]; rejected: string[] };
expect(r.applied).toEqual(['hooks.confirm']);
expect(r.rejected).toEqual(['telegram.bot_token']);
});
it('config.patch rejects invalid value types', async () => {
const config = makeConfig();
const handlers = createConfigHandlers({ config: config as any });
const req: GatewayRequest = {
id: 4,
method: 'config.patch',
params: {
patches: {
'hooks.confirm': 'not-an-array',
},
},
};
const result = await handlers['config.patch'](req) as GatewayResponse;
const r = result.result as { applied: string[]; rejected: string[] };
expect(r.applied).toEqual([]);
expect(r.rejected).toEqual(['hooks.confirm']);
});
it('config.patch requires patches object', async () => {
const config = makeConfig();
const handlers = createConfigHandlers({ config: config as any });
const req: GatewayRequest = { id: 5, method: 'config.patch', params: {} };
const result = await handlers['config.patch'](req) as GatewayError;
expect(result.error.code).toBe(ErrorCode.InvalidRequest);
});
});