From 0269c6032da7938bc00a5818777664f7b71aef79 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Mon, 16 Feb 2026 01:54:54 -0800 Subject: [PATCH] feat(channels): add signal-cli channel adapter --- README.md | 13 +- config/default.yaml | 11 + src/channels/index.ts | 1 + src/channels/signal/adapter.test.ts | 134 +++++++++++ src/channels/signal/adapter.ts | 332 ++++++++++++++++++++++++++ src/channels/signal/index.ts | 1 + src/config/schema.test.ts | 31 +++ src/config/schema.ts | 13 + src/daemon/channels.ts | 18 +- src/gateway/handlers/services.test.ts | 2 + src/gateway/handlers/services.ts | 1 + 11 files changed, 555 insertions(+), 2 deletions(-) create mode 100644 src/channels/signal/adapter.test.ts create mode 100644 src/channels/signal/adapter.ts create mode 100644 src/channels/signal/index.ts diff --git a/README.md b/README.md index 9e00d91..2e907ea 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Self-hosted personal AI assistant with Telegram and Terminal interfaces. - **Multi-Frontend**: Telegram bot + Terminal UI (minimal & fullscreen modes) + Web UI dashboard - **Multi-Model**: Anthropic Claude, OpenAI, GitHub Copilot, Gemini, Bedrock, Zhipu AI (GLM), xAI (Grok), Ollama, llama.cpp with intelligent routing -- **Multi-Channel**: Telegram, Discord, Slack, WhatsApp with unified adapter interface +- **Multi-Channel**: Telegram, Discord, Slack, WhatsApp, Matrix, and Signal with unified adapter interface - **Web Dashboard**: SPA control panel with health monitoring, chat, session browser, usage stats, and settings editor - **Model Switching**: Switch between cloud/local models on demand - **Session Persistence**: SQLite-backed conversation history @@ -139,6 +139,17 @@ matrix: allowed_room_ids: ["!room1:example.org"] require_mention: true +# Optional: Signal (signal-cli) +signal: + account: "+15551234567" + signal_cli_path: "signal-cli" + allowed_numbers: ["+15550001111"] + allowed_group_ids: [] + require_mention: true + mention_name: "flynn" + poll_interval_ms: 5000 + send_timeout_ms: 15000 + models: default: provider: anthropic diff --git a/config/default.yaml b/config/default.yaml index 2df5620..bf2fce5 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -9,6 +9,17 @@ telegram: bot_token: ${FLYNN_TELEGRAM_TOKEN} allowed_chat_ids: [] # Add your Telegram chat ID +# Optional: Signal via signal-cli +# signal: +# account: "+15551234567" +# signal_cli_path: signal-cli +# allowed_numbers: [] # Empty = allow all DMs +# allowed_group_ids: [] # Empty = no groups +# require_mention: true +# mention_name: flynn +# poll_interval_ms: 5000 +# send_timeout_ms: 15000 + server: # Tailscale Serve config (optional). Enable `serve: true` to expose the # gateway to your tailnet via `tailscale serve`. diff --git a/src/channels/index.ts b/src/channels/index.ts index 198d4f0..44b4917 100644 --- a/src/channels/index.ts +++ b/src/channels/index.ts @@ -16,4 +16,5 @@ export { DiscordAdapter, type DiscordAdapterConfig } from './discord/index.js'; export { SlackAdapter, type SlackAdapterConfig } from './slack/index.js'; export { WhatsAppAdapter, type WhatsAppAdapterConfig } from './whatsapp/index.js'; export { MatrixAdapter, type MatrixAdapterConfig } from './matrix/index.js'; +export { SignalAdapter, type SignalAdapterConfig } from './signal/index.js'; export { PairingManager, type PairingConfig, type PairingStore, type ApprovedSender } from './pairing.js'; diff --git a/src/channels/signal/adapter.test.ts b/src/channels/signal/adapter.test.ts new file mode 100644 index 0000000..a3d968f --- /dev/null +++ b/src/channels/signal/adapter.test.ts @@ -0,0 +1,134 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { execFile } from 'child_process'; +import type { ChildProcess } from 'child_process'; + +import { SignalAdapter } from './adapter.js'; +import type { InboundMessage } from '../types.js'; + +vi.mock('child_process', () => ({ + execFile: vi.fn(), +})); + +const mockExecFile = vi.mocked(execFile); +type ExecFileCallback = NonNullable[3]>; + +function mockChildProcess(): ChildProcess { + return {} as ChildProcess; +} + +function mockExecFileOnce(impl: (callback: ExecFileCallback) => void): void { + mockExecFile.mockImplementationOnce((_cmd, _args, _opts, callback) => { + if (typeof callback === 'function') { + impl(callback as ExecFileCallback); + } + return mockChildProcess(); + }); +} + +describe('SignalAdapter', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('has name signal and starts disconnected', () => { + const adapter = new SignalAdapter({ account: '+15551234567' }); + expect(adapter.name).toBe('signal'); + expect(adapter.status).toBe('disconnected'); + }); + + it('connect checks signal-cli availability', async () => { + const adapter = new SignalAdapter({ + account: '+15551234567', + pollIntervalMs: 60000, + }); + mockExecFileOnce((callback) => callback(null, 'signal-cli 0.13.2', '')); + mockExecFileOnce((callback) => callback(null, '', '')); + + await adapter.connect(); + await adapter.disconnect(); + + expect(mockExecFile).toHaveBeenCalled(); + const versionCall = mockExecFile.mock.calls[0]; + expect(versionCall[0]).toBe('signal-cli'); + expect(versionCall[1]).toEqual(['--version']); + }); + + it('send uses -g for group peers', async () => { + const adapter = new SignalAdapter({ account: '+15551234567' }); + mockExecFileOnce((callback) => callback(null, 'signal-cli 0.13.2', '')); + mockExecFileOnce((callback) => callback(null, '', '')); + mockExecFileOnce((callback) => callback(null, '', '')); + + await adapter.connect(); + await adapter.send('group:abcd1234', { text: 'Hello group' }); + await adapter.disconnect(); + + const sendCall = mockExecFile.mock.calls.find((call) => Array.isArray(call[1]) && call[1].includes('send')); + expect(sendCall).toBeDefined(); + expect(sendCall?.[1]).toEqual(['-u', '+15551234567', 'send', '-m', 'Hello group', '-g', 'abcd1234']); + }); + + it('parses DM receive payload and forwards inbound message', async () => { + const adapter = new SignalAdapter({ + account: '+15551234567', + allowedNumbers: ['+15550001111'], + }); + const messages: InboundMessage[] = []; + adapter.onMessage((msg) => { + messages.push(msg); + }); + + const run = adapter as unknown as { + processReceiveOutput: (output: string) => Promise; + }; + await run.processReceiveOutput( + JSON.stringify({ + envelope: { + source: '+15550001111', + sourceName: 'Alice', + timestamp: 1700000000000, + dataMessage: { message: 'hello from signal' }, + }, + }), + ); + + expect(messages).toHaveLength(1); + expect(messages[0].channel).toBe('signal'); + expect(messages[0].senderId).toBe('+15550001111'); + expect(messages[0].text).toBe('hello from signal'); + }); + + it('requires mention in groups and strips leading mention token', async () => { + const adapter = new SignalAdapter({ + account: '+15551234567', + allowedGroupIds: ['grp1'], + mentionName: 'flynn', + requireMention: true, + }); + const messages: InboundMessage[] = []; + adapter.onMessage((msg) => { + messages.push(msg); + }); + + const run = adapter as unknown as { + processReceiveOutput: (output: string) => Promise; + }; + + await run.processReceiveOutput( + JSON.stringify({ + envelope: { + source: '+15550001111', + timestamp: 1700000000000, + dataMessage: { + message: '@flynn check status', + groupInfo: { groupId: 'grp1' }, + }, + }, + }), + ); + + expect(messages).toHaveLength(1); + expect(messages[0].senderId).toBe('group:grp1'); + expect(messages[0].text).toBe('check status'); + }); +}); diff --git a/src/channels/signal/adapter.ts b/src/channels/signal/adapter.ts new file mode 100644 index 0000000..de1b808 --- /dev/null +++ b/src/channels/signal/adapter.ts @@ -0,0 +1,332 @@ +import { execFile } from 'child_process'; + +import type { + InboundMessage, + OutboundMessage, + ChannelAdapter, + ChannelStatus, +} from '../types.js'; +import { + allowTrustedOrPairedSender, + buildResetInboundMessage, + isAllowedByAllowlist, + normalizeResetCommandText, + shouldIgnoreForMissingMention, + splitMessage, +} from '../utils.js'; +import type { PairingManager } from '../pairing.js'; + +export interface SignalAdapterConfig { + /** Primary Signal account identifier used by signal-cli (-u). */ + account: string; + /** Path to signal-cli binary. */ + signalCliPath?: string; + /** Allowed direct-message sender numbers. Empty/undefined = allow all DMs. */ + allowedNumbers?: string[]; + /** Allowed group IDs. Empty/undefined = no groups allowed. */ + allowedGroupIds?: string[]; + /** Require mention in group chats (default: true). */ + requireMention?: boolean; + /** Mention token used for group mention detection (default: flynn). */ + mentionName?: string; + /** Poll interval for receive loop (default: 5000ms). */ + pollIntervalMs?: number; + /** Timeout for send/receive CLI calls (default: 15000ms). */ + sendTimeoutMs?: number; + /** Optional pairing manager for DM pairing codes. */ + pairingManager?: PairingManager; +} + +interface SignalEnvelope { + envelope?: { + source?: string; + sourceName?: string; + timestamp?: number; + dataMessage?: { + message?: string; + body?: string; + groupInfo?: { groupId?: string }; + groupId?: string; + }; + }; +} + +const MAX_MESSAGE_LENGTH = 3500; +const DEFAULT_POLL_INTERVAL_MS = 5000; +const DEFAULT_TIMEOUT_MS = 15000; + +export class SignalAdapter implements ChannelAdapter { + readonly name = 'signal'; + + private _status: ChannelStatus = 'disconnected'; + private messageHandler?: (msg: InboundMessage) => void; + private readonly config: SignalAdapterConfig; + private pollTimer: NodeJS.Timeout | null = null; + private polling = false; + + get status(): ChannelStatus { + return this._status; + } + + constructor(config: SignalAdapterConfig) { + this.config = config; + } + + onMessage(handler: (msg: InboundMessage) => void): void { + this.messageHandler = handler; + } + + async connect(): Promise { + this._status = 'connecting'; + try { + await this.execSignal(['--version']); + this._status = 'connected'; + const interval = this.config.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS; + this.pollTimer = setInterval(() => { + void this.pollOnce(); + }, interval); + void this.pollOnce(); + console.log(`Signal adapter connected (${this.config.account})`); + } catch (error) { + this._status = 'error'; + throw error; + } + } + + async disconnect(): Promise { + if (this.pollTimer) { + clearInterval(this.pollTimer); + this.pollTimer = null; + } + this._status = 'disconnected'; + } + + async send(peerId: string, message: OutboundMessage): Promise { + if (this._status !== 'connected') { + throw new Error('Signal adapter not connected'); + } + + const text = message.text.trim(); + if (text.length > 0) { + const chunks = text.length > MAX_MESSAGE_LENGTH ? splitMessage(text, MAX_MESSAGE_LENGTH) : [text]; + for (const chunk of chunks) { + await this.sendText(peerId, chunk); + } + } + + if (message.attachments && message.attachments.length > 0) { + for (const a of message.attachments) { + if (a.url) { + const line = a.filename ? `${a.filename}: ${a.url}` : a.url; + await this.sendText(peerId, line); + } else if (a.data) { + // Keep adapter minimal and robust: no temp-file attachment upload in this pass. + console.warn(`Signal: skipping attachment data (${a.mimeType}) — upload not implemented`); + } + } + } + } + + private async sendText(peerId: string, text: string): Promise { + if (!text.trim()) { + return; + } + const args = ['-u', this.config.account, 'send', '-m', text]; + const groupId = this.extractGroupId(peerId); + if (groupId) { + args.push('-g', groupId); + } else { + args.push(peerId); + } + await this.execSignal(args); + } + + private async pollOnce(): Promise { + if (this.polling || !this.messageHandler || this._status !== 'connected') { + return; + } + + this.polling = true; + try { + const output = await this.execSignal([ + '-u', + this.config.account, + '-o', + 'json', + 'receive', + '--timeout', + '1', + ]); + await this.processReceiveOutput(output); + } catch (error) { + if (this._status === 'connected') { + const msg = error instanceof Error ? error.message : String(error); + console.warn(`Signal receive failed: ${msg}`); + } + } finally { + this.polling = false; + } + } + + private async processReceiveOutput(output: string): Promise { + if (!this.messageHandler) { + return; + } + const trimmed = output.trim(); + if (!trimmed) { + return; + } + + const payloads: unknown[] = []; + if (trimmed.startsWith('[')) { + try { + const parsed = JSON.parse(trimmed); + if (Array.isArray(parsed)) { + payloads.push(...parsed); + } + } catch { + // Fall through to line-based parsing. + } + } + + if (payloads.length === 0) { + for (const line of trimmed.split('\n')) { + const text = line.trim(); + if (!text) { + continue; + } + try { + payloads.push(JSON.parse(text)); + } catch { + // Ignore non-JSON lines from signal-cli output. + } + } + } + + for (const payload of payloads) { + const inbound = await this.toInboundMessage(payload); + if (inbound) { + this.messageHandler(inbound); + } + } + } + + private async toInboundMessage(payload: unknown): Promise { + const data = payload as SignalEnvelope & Record; + const envelope = (data.envelope ?? data) as Record; + const dataMessage = (envelope.dataMessage ?? data.dataMessage) as Record | undefined; + + const rawText = String(dataMessage?.message ?? dataMessage?.body ?? '').trim(); + if (!rawText) { + return null; + } + + const source = this.normalizeNumber(String(envelope.source ?? '')); + const sourceName = typeof envelope.sourceName === 'string' ? envelope.sourceName : undefined; + const groupIdRaw = dataMessage?.groupInfo && typeof dataMessage.groupInfo === 'object' + ? String((dataMessage.groupInfo as { groupId?: unknown }).groupId ?? '') + : String(dataMessage?.groupId ?? ''); + const groupId = groupIdRaw.trim(); + const isGroup = groupId.length > 0; + + let text = rawText; + let senderId = source; + + if (isGroup) { + if (!this.config.allowedGroupIds || this.config.allowedGroupIds.length === 0) { + return null; + } + if (!this.config.allowedGroupIds.includes(groupId)) { + return null; + } + + const mentionName = (this.config.mentionName ?? 'flynn').trim(); + const mentionPattern = mentionName.length > 0 + ? new RegExp(`(?:^|\\s)@?${escapeRegex(mentionName)}(?:\\b|:)`, 'i') + : null; + const mentionsBot = mentionPattern ? mentionPattern.test(text) : false; + if (shouldIgnoreForMissingMention({ + requireMention: this.config.requireMention, + defaultRequireMention: true, + mentionsBot, + })) { + return null; + } + if (mentionPattern) { + text = text.replace(new RegExp(`^\\s*@?${escapeRegex(mentionName)}(?:\\b|:)\\s*`, 'i'), '').trim(); + } + senderId = `group:${groupId}`; + } else { + if (!source) { + return null; + } + const trusted = isAllowedByAllowlist(source, this.config.allowedNumbers); + const allowed = await allowTrustedOrPairedSender({ + pairingManager: this.config.pairingManager, + channel: 'signal', + senderId: source, + text, + isTrusted: trusted, + }); + if (!allowed) { + return null; + } + } + + const normalizedText = normalizeResetCommandText(text); + const timestamp = typeof envelope.timestamp === 'number' ? envelope.timestamp : Date.now(); + const id = `${senderId}:${timestamp}`; + if (normalizedText === '!reset') { + return buildResetInboundMessage({ + id, + channel: 'signal', + senderId, + senderName: sourceName, + timestamp, + }); + } + + return { + id, + channel: 'signal', + senderId, + senderName: sourceName, + text: normalizedText, + timestamp, + metadata: { + source, + groupId: groupId || undefined, + }, + }; + } + + private execSignal(args: string[]): Promise { + const command = this.config.signalCliPath ?? 'signal-cli'; + const timeout = this.config.sendTimeoutMs ?? DEFAULT_TIMEOUT_MS; + return new Promise((resolve, reject) => { + execFile(command, args, { timeout }, (error, stdout, stderr) => { + if (error) { + reject(new Error(`${command} ${args.join(' ')} failed: ${stderr || error.message}`)); + return; + } + resolve(stdout.trim()); + }); + }); + } + + private extractGroupId(peerId: string): string | null { + if (!peerId.startsWith('group:')) { + return null; + } + const groupId = peerId.slice('group:'.length).trim(); + return groupId.length > 0 ? groupId : null; + } + + private normalizeNumber(value: string): string { + return value.trim().replace(/[^\d+]/g, ''); + } +} + +function escapeRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} diff --git a/src/channels/signal/index.ts b/src/channels/signal/index.ts new file mode 100644 index 0000000..1c4bd09 --- /dev/null +++ b/src/channels/signal/index.ts @@ -0,0 +1 @@ +export { SignalAdapter, type SignalAdapterConfig } from './adapter.js'; diff --git a/src/config/schema.test.ts b/src/config/schema.test.ts index 798956f..cf95733 100644 --- a/src/config/schema.test.ts +++ b/src/config/schema.test.ts @@ -319,6 +319,37 @@ describe('configSchema — matrix', () => { }); }); +describe('configSchema — signal', () => { + const minimalConfig = { + telegram: { bot_token: 'test', allowed_chat_ids: [1] }, + models: { default: { provider: 'anthropic', model: 'claude-3' } }, + }; + + it('accepts signal config and defaults polling fields', () => { + const result = configSchema.parse({ + ...minimalConfig, + signal: { + account: '+15551234567', + }, + }); + + expect(result.signal).toBeDefined(); + if (!result.signal) { + throw new Error('Expected signal config'); + } + expect(result.signal.account).toBe('+15551234567'); + expect(result.signal.signal_cli_path).toBe('signal-cli'); + expect(result.signal.poll_interval_ms).toBe(5000); + expect(result.signal.send_timeout_ms).toBe(15000); + expect(result.signal.require_mention).toBe(true); + }); + + it('signal config is optional', () => { + const result = configSchema.parse(minimalConfig); + expect(result.signal).toBeUndefined(); + }); +}); + describe('configSchema — whatsapp', () => { const minimalConfig = { telegram: { bot_token: 'test', allowed_chat_ids: [1] }, diff --git a/src/config/schema.ts b/src/config/schema.ts index b4a17f4..22deb80 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -400,6 +400,17 @@ const matrixSchema = z.object({ display_name: z.string().optional(), }).optional(); +const signalSchema = z.object({ + account: z.string().min(1, 'Signal account is required'), + signal_cli_path: z.string().default('signal-cli'), + allowed_numbers: z.array(z.string()).default([]), + allowed_group_ids: z.array(z.string()).default([]), + require_mention: z.boolean().default(true), + mention_name: z.string().default('flynn'), + poll_interval_ms: z.number().min(1000).max(60000).default(5000), + send_timeout_ms: z.number().min(1000).max(60000).default(15000), +}).optional(); + const browserSchema = z.object({ enabled: z.boolean().default(false), executable_path: z.string().optional(), @@ -566,6 +577,7 @@ export const configSchema = z.object({ slack: slackSchema, whatsapp: whatsappSchema, matrix: matrixSchema, + signal: signalSchema, server: serverSchema.default({}), models: modelsSchema, backends: backendsSchema.default({}), @@ -610,6 +622,7 @@ export type DiscordConfig = z.infer; export type SlackConfig = z.infer; export type WhatsAppConfig = z.infer; export type MatrixConfig = z.infer; +export type SignalConfig = z.infer; export type RetryPolicyConfig = z.infer; export type ContextLevel = z.infer; export type PromptConfig = z.infer; diff --git a/src/daemon/channels.ts b/src/daemon/channels.ts index 46d4e4a..d4c30e6 100644 --- a/src/daemon/channels.ts +++ b/src/daemon/channels.ts @@ -1,6 +1,6 @@ import type { Config } from '../config/index.js'; import type { HookEngine } from '../hooks/index.js'; -import { ChannelRegistry, TelegramAdapter, WebChatAdapter, DiscordAdapter, SlackAdapter, WhatsAppAdapter, MatrixAdapter, PairingManager } from '../channels/index.js'; +import { ChannelRegistry, TelegramAdapter, WebChatAdapter, DiscordAdapter, SlackAdapter, WhatsAppAdapter, MatrixAdapter, SignalAdapter, PairingManager } from '../channels/index.js'; import { CronScheduler, WebhookHandler, GmailWatcher } from '../automation/index.js'; import type { GatewayServer } from '../gateway/index.js'; @@ -85,6 +85,22 @@ export function registerChannels(deps: ChannelsDeps): ChannelsResult { channelRegistry.register(matrixAdapter); } + // Register Signal adapter (if configured) + if (config.signal) { + const signalAdapter = new SignalAdapter({ + account: config.signal.account, + signalCliPath: config.signal.signal_cli_path, + allowedNumbers: config.signal.allowed_numbers.length > 0 ? config.signal.allowed_numbers : undefined, + allowedGroupIds: config.signal.allowed_group_ids.length > 0 ? config.signal.allowed_group_ids : undefined, + requireMention: config.signal.require_mention, + mentionName: config.signal.mention_name, + pollIntervalMs: config.signal.poll_interval_ms, + sendTimeoutMs: config.signal.send_timeout_ms, + pairingManager, + }); + channelRegistry.register(signalAdapter); + } + // Register WebChat adapter (wraps the gateway) const webChatAdapter = new WebChatAdapter({ gateway }); channelRegistry.register(webChatAdapter); diff --git a/src/gateway/handlers/services.test.ts b/src/gateway/handlers/services.test.ts index 2f49264..1ae8953 100644 --- a/src/gateway/handlers/services.test.ts +++ b/src/gateway/handlers/services.test.ts @@ -30,6 +30,7 @@ function makeBaseConfig(): Config { slack: undefined, whatsapp: undefined, matrix: undefined, + signal: undefined, } as unknown as Config; } @@ -43,6 +44,7 @@ describe('discoverServices', () => { expect(services).toEqual(expect.arrayContaining([ expect.objectContaining({ name: 'telegram', status: 'not_configured' }), expect.objectContaining({ name: 'matrix', status: 'not_configured' }), + expect.objectContaining({ name: 'signal', status: 'not_configured' }), expect.objectContaining({ name: 'cron', status: 'not_configured' }), expect.objectContaining({ name: 'mcp', status: 'not_configured' }), expect.objectContaining({ name: 'web_search', status: 'configured' }), diff --git a/src/gateway/handlers/services.ts b/src/gateway/handlers/services.ts index 539a39d..c9ee02d 100644 --- a/src/gateway/handlers/services.ts +++ b/src/gateway/handlers/services.ts @@ -53,6 +53,7 @@ export function discoverServices( { key: 'slack', name: 'slack', description: 'Slack app' }, { key: 'whatsapp', name: 'whatsapp', description: 'WhatsApp gateway' }, { key: 'matrix', name: 'matrix', description: 'Matrix bot' }, + { key: 'signal', name: 'signal', description: 'Signal bot (signal-cli)' }, ]; for (const { key, name, description } of channelConfigs) {