diff --git a/.claude/skills/add-signal/SKILL.md b/.claude/skills/add-signal/SKILL.md new file mode 100644 index 0000000..92c7800 --- /dev/null +++ b/.claude/skills/add-signal/SKILL.md @@ -0,0 +1,121 @@ +--- +name: add-signal +description: Add Signal channel integration via signal-cli TCP daemon. Native adapter — no Chat SDK bridge. +--- + +# Add Signal Channel + +Adds Signal messaging support via a native adapter that communicates with a [signal-cli](https://github.com/AsamK/signal-cli) TCP daemon using JSON-RPC. + +## Prerequisites + +- **signal-cli** installed and a Signal account linked + - macOS: `brew install signal-cli` + - Linux: download from [GitHub releases](https://github.com/AsamK/signal-cli/releases) + - Link your account: `signal-cli -a +1YOURNUMBER link` (follow the QR instructions) + +## Install + +### Pre-flight (idempotent) + +Skip to **Credentials** if all of these are already in place: + +- `src/channels/signal.ts` exists +- `src/channels/signal.test.ts` exists +- `src/channels/index.ts` contains `import './signal.js';` + +Otherwise continue. Every step below is safe to re-run. + +### 1. Fetch the skill branch + +```bash +git fetch origin skill/signal +``` + +### 2. Copy the adapter and tests + +```bash +git show origin/skill/signal:src/channels/signal.ts > src/channels/signal.ts +git show origin/skill/signal:src/channels/signal.test.ts > src/channels/signal.test.ts +``` + +### 3. Append the self-registration import + +Append to `src/channels/index.ts` (skip if the line is already present): + +```typescript +import './signal.js'; +``` + +### 4. Build + +```bash +pnpm run build +``` + +No npm packages to install — the adapter uses only Node.js builtins (`node:net`, `node:child_process`, `node:fs`). + +## Credentials + +Add to `.env`: + +```env +SIGNAL_ACCOUNT=+1YOURNUMBER +``` + +### Optional settings + +```env +# TCP daemon host and port (default: 127.0.0.1:7583) +SIGNAL_HTTP_HOST=127.0.0.1 +SIGNAL_HTTP_PORT=7583 + +# Whether NanoClaw manages the daemon lifecycle (default: true) +# Set to false if you run signal-cli daemon externally +SIGNAL_MANAGE_DAEMON=true + +# signal-cli data directory (default: ~/.local/share/signal-cli) +SIGNAL_DATA_DIR=~/.local/share/signal-cli +``` + +### Sync to container + +```bash +mkdir -p data/env && cp .env data/env/env +``` + +### Restart + +```bash +# macOS +launchctl kickstart -k gui/$(id -u)/com.nanoclaw + +# Linux +systemctl --user restart nanoclaw +``` + +## Next Steps + +Run `/init-first-agent` to create an agent and wire it to your Signal DM. Signal is direct-addressable — your phone number is the platform ID: + +- **User ID**: your Signal phone number (e.g. `+15551234567`) +- **Platform ID**: same as user ID for DMs (e.g. `+15551234567`) +- **For group chats**: use `group:` — find group IDs via `signal-cli -a +1YOURNUMBER listGroups` + +`/init-first-agent` handles user creation, owner role, agent group, messaging group wiring, and the welcome DM. It's idempotent — safe to run again for additional agents. + +## Channel Info + +| Field | Value | +|-------|-------| +| **Type** | `signal` | +| **Thread support** | No (Signal has no thread model) | +| **Platform ID format** | DM: `+15555550123` / Group: `group:` | +| **Mention detection** | Text-match against agent group name (no SDK-level mentions) | +| **Typing indicators** | DMs only | +| **Typical use** | Personal assistant via Signal DMs or small group chats | +| **Isolation** | Recommended: one agent per Signal account | + +### Voice Messages + +Voice attachments are detected but not transcribed by default. The agent receives `[Voice Message]` as the message text. Run `/add-voice-transcription` to enable automatic local transcription via parakeet-mlx. diff --git a/scripts/init-first-agent.ts b/scripts/init-first-agent.ts index dcb99b5..fc61b9c 100644 --- a/scripts/init-first-agent.ts +++ b/scripts/init-first-agent.ts @@ -137,13 +137,29 @@ function namespacedUserId(channel: string, raw: string): string { return raw.includes(':') ? raw : `${channel}:${raw}`; } +/** + * Determine whether a platform ID needs a channel-type prefix. + * + * Chat SDK adapters (Telegram, Discord, Slack, Teams, etc.) namespace their + * platform IDs with a channel prefix: "telegram:123456", "discord:guild:chan". + * The router stores `channel_type` and `platform_id` in separate columns, but + * Chat SDK adapters send the prefixed form as the platform_id, so this script + * must match that format. + * + * Native adapters (Signal, WhatsApp) use their own ID formats and send them + * as-is — no channel prefix. Signal sends raw phone numbers (+15551234567) + * for DMs and "group:" for group chats. WhatsApp sends JIDs containing + * '@' (@s.whatsapp.net, @g.us). Prefixing these would cause + * a mismatch between what the adapter sends and what the DB stores, breaking + * message routing. + */ function namespacedPlatformId(channel: string, raw: string): string { if (raw.startsWith(`${channel}:`)) return raw; - // Adapters using native JID format (WhatsApp: @s.whatsapp.net, - // @g.us) store platform_id without a channel prefix. The '@' is - // the discriminator — telegram/discord platform_ids don't contain it - // except after a channel prefix, which is already handled above. + // Native WhatsApp JIDs contain '@' — no prefix needed. if (raw.includes('@')) return raw; + // Native Signal IDs: phone numbers (+...) and group IDs (group:...). + if (raw.startsWith('+') || raw.startsWith('group:')) return raw; + // Chat SDK adapters — add the channel prefix. return `${channel}:${raw}`; } diff --git a/src/channels/index.ts b/src/channels/index.ts index e9b3bd1..b75016f 100644 --- a/src/channels/index.ts +++ b/src/channels/index.ts @@ -7,3 +7,4 @@ // self-registration import below. import './cli.js'; +import './signal.js'; diff --git a/src/channels/signal.test.ts b/src/channels/signal.test.ts new file mode 100644 index 0000000..c7ffff1 --- /dev/null +++ b/src/channels/signal.test.ts @@ -0,0 +1,627 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; + +// --- Mocks --- + +vi.mock('./channel-registry.js', () => ({ registerChannelAdapter: vi.fn() })); +vi.mock('../env.js', () => ({ readEnvFile: vi.fn(() => ({})) })); +vi.mock('../log.js', () => ({ + log: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock('node:child_process', () => ({ + spawn: vi.fn(), + execFileSync: vi.fn(), +})); + +// --- TCP socket mock --- + +import { EventEmitter } from 'events'; + +const tcpRef = vi.hoisted(() => ({ + rpcResponses: new Map(), + fakeSocket: null as any, +})); + +function createFakeSocket(): EventEmitter & { + write: ReturnType; + destroy: ReturnType; + destroyed: boolean; +} { + const sock = new EventEmitter() as any; + sock.destroyed = false; + sock.destroy = vi.fn(() => { + sock.destroyed = true; + sock.emit('close'); + }); + sock.write = vi.fn((data: string) => { + try { + const req = JSON.parse(data.trim()); + const result = tcpRef.rpcResponses.get(req.method) ?? { ok: true }; + const response = JSON.stringify({ jsonrpc: '2.0', id: req.id, result }) + '\n'; + setImmediate(() => sock.emit('data', Buffer.from(response))); + } catch { + /* ignore */ + } + }); + return sock; +} + +vi.mock('node:net', () => ({ + createConnection: vi.fn((_port: number, _host: string, cb?: () => void) => { + const sock = createFakeSocket(); + tcpRef.fakeSocket = sock; + if (cb) setImmediate(cb); + return sock; + }), +})); + +import type { ChannelSetup } from './adapter.js'; +import { createSignalAdapter } from './signal.js'; + +// --- Test helpers --- + +function createMockSetup() { + return { + onInbound: vi.fn() as unknown as ChannelSetup['onInbound'] & ReturnType, + onInboundEvent: vi.fn() as unknown as ChannelSetup['onInboundEvent'] & ReturnType, + onMetadata: vi.fn() as unknown as ChannelSetup['onMetadata'] & ReturnType, + onAction: vi.fn() as unknown as ChannelSetup['onAction'] & ReturnType, + }; +} + +function createAdapter() { + return createSignalAdapter({ + cliPath: 'signal-cli', + account: '+15551234567', + tcpHost: '127.0.0.1', + tcpPort: 7583, + manageDaemon: false, + signalDataDir: '/tmp/signal-cli-test-data', + }); +} + +function getRpcCalls(): Array<{ + method: string; + params: Record; + id: string; +}> { + if (!tcpRef.fakeSocket) return []; + return tcpRef.fakeSocket.write.mock.calls + .map((c: any[]) => { + try { + return JSON.parse(c[0].trim()); + } catch { + return null; + } + }) + .filter(Boolean); +} + +function getRpcCallsForMethod(method: string) { + return getRpcCalls().filter((c) => c.method === method); +} + +function pushEvent(envelope: Record) { + if (!tcpRef.fakeSocket) throw new Error('TCP socket not connected'); + const notification = + JSON.stringify({ + jsonrpc: '2.0', + method: 'receive', + params: { envelope }, + }) + '\n'; + tcpRef.fakeSocket.emit('data', Buffer.from(notification)); +} + +// --- Tests --- + +describe('SignalAdapter', () => { + beforeEach(() => { + vi.clearAllMocks(); + tcpRef.rpcResponses.clear(); + tcpRef.fakeSocket = null; + tcpRef.rpcResponses.set('send', { timestamp: 1234567890 }); + tcpRef.rpcResponses.set('sendTyping', {}); + }); + + afterEach(() => { + try { + tcpRef.fakeSocket?.destroy(); + } catch { + // already closed + } + }); + + // --- Connection lifecycle --- + + describe('connection lifecycle', () => { + it('connects when daemon is reachable', async () => { + const adapter = createAdapter(); + const cfg = createMockSetup(); + await adapter.setup(cfg); + + expect(adapter.isConnected()).toBe(true); + expect(tcpRef.fakeSocket).not.toBeNull(); + + await adapter.teardown(); + }); + + it('isConnected() returns false before setup', () => { + const adapter = createAdapter(); + expect(adapter.isConnected()).toBe(false); + }); + + it('disconnects cleanly', async () => { + const adapter = createAdapter(); + await adapter.setup(createMockSetup()); + expect(adapter.isConnected()).toBe(true); + + await adapter.teardown(); + expect(adapter.isConnected()).toBe(false); + }); + + it('throws NetworkError if daemon is unreachable', async () => { + const { createConnection } = await import('node:net'); + vi.mocked(createConnection).mockImplementationOnce((...args: any[]) => { + const sock = createFakeSocket(); + setImmediate(() => sock.emit('error', new Error('Connection refused'))); + return sock as any; + }); + + const adapter = createAdapter(); + await expect(adapter.setup(createMockSetup())).rejects.toThrow(/not reachable/); + }); + }); + + // --- Inbound message handling --- + + describe('inbound message handling', () => { + it('delivers DM via onInbound', async () => { + const adapter = createAdapter(); + const cfg = createMockSetup(); + await adapter.setup(cfg); + + pushEvent({ + sourceNumber: '+15555550123', + sourceName: 'Alice', + dataMessage: { + timestamp: 1700000000000, + message: 'Hello from Signal', + }, + }); + + await new Promise((r) => setTimeout(r, 50)); + + expect(cfg.onMetadata).toHaveBeenCalledWith('+15555550123', 'Alice', false); + expect(cfg.onInbound).toHaveBeenCalledWith( + '+15555550123', + null, + expect.objectContaining({ + id: '1700000000000', + kind: 'chat', + content: expect.objectContaining({ + text: 'Hello from Signal', + sender: '+15555550123', + senderName: 'Alice', + }), + }), + ); + + await adapter.teardown(); + }); + + it('delivers group message with group platformId', async () => { + const adapter = createAdapter(); + const cfg = createMockSetup(); + await adapter.setup(cfg); + + pushEvent({ + sourceNumber: '+15555550999', + sourceName: 'Bob', + dataMessage: { + timestamp: 1700000000000, + message: 'Group hello', + groupInfo: { groupId: 'abc123', groupName: 'Family' }, + }, + }); + + await new Promise((r) => setTimeout(r, 50)); + + expect(cfg.onMetadata).toHaveBeenCalledWith('group:abc123', 'Family', true); + expect(cfg.onInbound).toHaveBeenCalledWith( + 'group:abc123', + null, + expect.objectContaining({ + content: expect.objectContaining({ + text: 'Group hello', + sender: '+15555550999', + }), + }), + ); + + await adapter.teardown(); + }); + + it('skips sync messages (own outbound)', async () => { + const adapter = createAdapter(); + const cfg = createMockSetup(); + await adapter.setup(cfg); + + pushEvent({ + sourceNumber: '+15551234567', + syncMessage: { + sentMessage: { + timestamp: 1700000000000, + message: 'My own message', + destination: '+15555550123', + }, + }, + }); + + await new Promise((r) => setTimeout(r, 50)); + expect(cfg.onInbound).not.toHaveBeenCalled(); + + await adapter.teardown(); + }); + + it('processes Note to Self sync messages as inbound', async () => { + const adapter = createAdapter(); + const cfg = createMockSetup(); + await adapter.setup(cfg); + + pushEvent({ + sourceNumber: '+15551234567', + syncMessage: { + sentMessage: { + timestamp: 1700000000000, + message: 'Hello Bee', + destinationNumber: '+15551234567', + }, + }, + }); + + await new Promise((r) => setTimeout(r, 50)); + expect(cfg.onInbound).toHaveBeenCalledWith( + '+15551234567', + null, + expect.objectContaining({ + content: expect.objectContaining({ + text: 'Hello Bee', + senderName: 'Me', + isFromMe: true, + }), + }), + ); + + await adapter.teardown(); + }); + + it('skips empty messages', async () => { + const adapter = createAdapter(); + const cfg = createMockSetup(); + await adapter.setup(cfg); + + pushEvent({ + sourceNumber: '+15555550123', + dataMessage: { timestamp: 1700000000000, message: ' ' }, + }); + + await new Promise((r) => setTimeout(r, 50)); + expect(cfg.onInbound).not.toHaveBeenCalled(); + + await adapter.teardown(); + }); + + it('skips echoed outbound messages', async () => { + const adapter = createAdapter(); + const cfg = createMockSetup(); + await adapter.setup(cfg); + + await adapter.deliver('+15555550123', null, { + kind: 'text', + content: { text: 'Echo test' }, + }); + + pushEvent({ + sourceNumber: '+15555550123', + dataMessage: { timestamp: 1700000000000, message: 'Echo test' }, + }); + + await new Promise((r) => setTimeout(r, 50)); + expect(cfg.onInbound).not.toHaveBeenCalled(); + + await adapter.teardown(); + }); + + it('skips messages with attachments but no text', async () => { + const adapter = createAdapter(); + const cfg = createMockSetup(); + await adapter.setup(cfg); + + pushEvent({ + sourceNumber: '+15555550123', + sourceName: 'Alice', + dataMessage: { + timestamp: 1700000000000, + attachments: [{ id: 'att123abc', contentType: 'image/jpeg', size: 50000 }], + }, + }); + + await new Promise((r) => setTimeout(r, 50)); + expect(cfg.onInbound).not.toHaveBeenCalled(); + + await adapter.teardown(); + }); + }); + + // --- Quote context --- + + describe('quote context', () => { + it('populates reply_to fields from quoted messages', async () => { + const adapter = createAdapter(); + const cfg = createMockSetup(); + await adapter.setup(cfg); + + pushEvent({ + sourceNumber: '+15555550123', + sourceName: 'Alice', + dataMessage: { + timestamp: 1700000000000, + message: 'I disagree', + quote: { + id: 1699999999000, + authorNumber: '+15555550888', + text: 'Pineapple belongs on pizza', + }, + }, + }); + + await new Promise((r) => setTimeout(r, 50)); + expect(cfg.onInbound).toHaveBeenCalledWith( + '+15555550123', + null, + expect.objectContaining({ + content: expect.objectContaining({ + text: 'I disagree', + replyToSenderName: '+15555550888', + replyToMessageContent: 'Pineapple belongs on pizza', + replyToMessageId: '1699999999000', + }), + }), + ); + + await adapter.teardown(); + }); + }); + + // --- deliver --- + + describe('deliver', () => { + it('sends DM via TCP RPC', async () => { + const adapter = createAdapter(); + await adapter.setup(createMockSetup()); + + await adapter.deliver('+15555550123', null, { + kind: 'text', + content: { text: 'Hello' }, + }); + + const sendCalls = getRpcCallsForMethod('send'); + expect(sendCalls.length).toBeGreaterThan(0); + + const last = sendCalls[sendCalls.length - 1]; + expect(last.params).toEqual( + expect.objectContaining({ + recipient: ['+15555550123'], + message: 'Hello', + account: '+15551234567', + }), + ); + + await adapter.teardown(); + }); + + it('sends group message via groupId', async () => { + const adapter = createAdapter(); + await adapter.setup(createMockSetup()); + + await adapter.deliver('group:abc123', null, { + kind: 'text', + content: { text: 'Group msg' }, + }); + + const sendCalls = getRpcCallsForMethod('send'); + const last = sendCalls[sendCalls.length - 1]; + expect(last.params).toEqual( + expect.objectContaining({ + groupId: 'abc123', + message: 'Group msg', + }), + ); + + await adapter.teardown(); + }); + + it('chunks long messages', async () => { + const adapter = createAdapter(); + await adapter.setup(createMockSetup()); + + const longText = 'x'.repeat(5000); + await adapter.deliver('+15555550123', null, { + kind: 'text', + content: { text: longText }, + }); + + const sendCalls = getRpcCallsForMethod('send'); + expect(sendCalls.length).toBeGreaterThan(1); + + await adapter.teardown(); + }); + + it('extracts text from string content', async () => { + const adapter = createAdapter(); + await adapter.setup(createMockSetup()); + + await adapter.deliver('+15555550123', null, { + kind: 'text', + content: 'Plain string content', + }); + + const sendCalls = getRpcCallsForMethod('send'); + expect(sendCalls.length).toBeGreaterThan(0); + const last = sendCalls[sendCalls.length - 1]; + expect(last.params.message).toBe('Plain string content'); + + await adapter.teardown(); + }); + }); + + // --- Text styles --- + + describe('text styles', () => { + it('sends bold text with textStyle parameter', async () => { + const adapter = createAdapter(); + await adapter.setup(createMockSetup()); + tcpRef.fakeSocket.write.mockClear(); + + await adapter.deliver('+15555550123', null, { + kind: 'text', + content: { text: 'Hello **world**' }, + }); + + const sendCalls = getRpcCallsForMethod('send'); + expect(sendCalls.length).toBeGreaterThan(0); + const last = sendCalls[sendCalls.length - 1]; + expect(last.params.message).toBe('Hello world'); + expect(last.params.textStyle).toEqual(['6:5:BOLD']); + + await adapter.teardown(); + }); + + it('sends inline code with MONOSPACE style', async () => { + const adapter = createAdapter(); + await adapter.setup(createMockSetup()); + tcpRef.fakeSocket.write.mockClear(); + + await adapter.deliver('+15555550123', null, { + kind: 'text', + content: { text: 'Run `npm test` now' }, + }); + + const sendCalls = getRpcCallsForMethod('send'); + const last = sendCalls[sendCalls.length - 1]; + expect(last.params.message).toBe('Run npm test now'); + expect(last.params.textStyle).toEqual(['4:8:MONOSPACE']); + + await adapter.teardown(); + }); + + it('sends plain text without textStyle', async () => { + const adapter = createAdapter(); + await adapter.setup(createMockSetup()); + tcpRef.fakeSocket.write.mockClear(); + + await adapter.deliver('+15555550123', null, { + kind: 'text', + content: { text: 'No formatting here' }, + }); + + const sendCalls = getRpcCallsForMethod('send'); + const last = sendCalls[sendCalls.length - 1]; + expect(last.params.message).toBe('No formatting here'); + expect(last.params.textStyle).toBeUndefined(); + + await adapter.teardown(); + }); + + it('falls back to original markup when textStyle is rejected', async () => { + const adapter = createAdapter(); + await adapter.setup(createMockSetup()); + + let sendCount = 0; + tcpRef.fakeSocket.write.mockImplementation((data: string) => { + try { + const req = JSON.parse(data.trim()); + if (req.method === 'send') { + sendCount++; + if (sendCount === 1) { + const response = + JSON.stringify({ + jsonrpc: '2.0', + id: req.id, + error: { message: 'Unknown parameter: textStyle' }, + }) + '\n'; + setImmediate(() => tcpRef.fakeSocket.emit('data', Buffer.from(response))); + return; + } + } + const response = + JSON.stringify({ + jsonrpc: '2.0', + id: req.id, + result: { ok: true }, + }) + '\n'; + setImmediate(() => tcpRef.fakeSocket.emit('data', Buffer.from(response))); + } catch { + /* ignore */ + } + }); + + await adapter.deliver('+15555550123', null, { + kind: 'text', + content: { text: 'Hello **world**' }, + }); + + const sendCalls = getRpcCallsForMethod('send'); + expect(sendCalls.length).toBe(2); + expect(sendCalls[1].params.message).toBe('Hello **world**'); + expect(sendCalls[1].params.textStyle).toBeUndefined(); + + await adapter.teardown(); + }); + }); + + // --- setTyping --- + + describe('setTyping', () => { + it('sends typing indicator for DMs', async () => { + const adapter = createAdapter(); + await adapter.setup(createMockSetup()); + + await adapter.setTyping!('+15555550123', null); + + expect(getRpcCallsForMethod('sendTyping')).toHaveLength(1); + + await adapter.teardown(); + }); + + it('skips typing for groups', async () => { + const adapter = createAdapter(); + await adapter.setup(createMockSetup()); + + await adapter.setTyping!('group:abc123', null); + + expect(getRpcCallsForMethod('sendTyping')).toHaveLength(0); + + await adapter.teardown(); + }); + }); + + // --- Adapter properties --- + + describe('adapter properties', () => { + it('has channelType "signal"', () => { + const adapter = createAdapter(); + expect(adapter.channelType).toBe('signal'); + }); + + it('does not support threads', () => { + const adapter = createAdapter(); + expect(adapter.supportsThreads).toBe(false); + }); + }); +}); diff --git a/src/channels/signal.ts b/src/channels/signal.ts new file mode 100644 index 0000000..300b7a6 --- /dev/null +++ b/src/channels/signal.ts @@ -0,0 +1,744 @@ +/** + * Signal channel adapter for NanoClaw v2. + * + * Uses signal-cli's TCP JSON-RPC daemon for bidirectional messaging. + * Requires signal-cli (https://github.com/AsamK/signal-cli) installed + * and a linked account. + * + * Ported from v1 — see v1 source for commit history. + */ +import { execFileSync, spawn } from 'node:child_process'; +import { readFileSync, existsSync } from 'node:fs'; +import { createConnection, type Socket } from 'node:net'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +import type { ChannelAdapter, ChannelSetup, InboundMessage, OutboundMessage } from './adapter.js'; +import { registerChannelAdapter } from './channel-registry.js'; +import { readEnvFile } from '../env.js'; +import { log } from '../log.js'; + +// --------------------------------------------------------------------------- +// Signal CLI daemon management +// --------------------------------------------------------------------------- + +interface DaemonHandle { + stop: () => void; + exited: Promise; + isExited: () => boolean; +} + +function spawnSignalDaemon(cliPath: string, account: string, host: string, port: number): DaemonHandle { + const args: string[] = []; + if (account) args.push('-a', account); + args.push('daemon', '--tcp', `${host}:${port}`, '--no-receive-stdout'); + args.push('--receive-mode', 'on-start'); + + const child = spawn(cliPath, args, { stdio: ['ignore', 'pipe', 'pipe'] }); + let exited = false; + + const exitedPromise = new Promise((resolve) => { + child.once('exit', (code, signal) => { + exited = true; + if (code !== 0 && code !== null) { + const reason = signal ? `signal ${signal}` : `code ${code}`; + log.error('signal-cli daemon exited', { reason }); + } + resolve(); + }); + child.on('error', (err) => { + exited = true; + log.error('signal-cli spawn error', { err }); + resolve(); + }); + }); + + child.stdout?.on('data', (data: Buffer) => { + for (const line of data.toString().split(/\r?\n/)) { + if (line.trim()) log.debug('signal-cli stdout', { line: line.trim() }); + } + }); + child.stderr?.on('data', (data: Buffer) => { + for (const line of data.toString().split(/\r?\n/)) { + if (!line.trim()) continue; + if (/\b(ERROR|WARN|FAILED|SEVERE)\b/i.test(line)) { + log.warn('signal-cli stderr', { line: line.trim() }); + } else { + log.debug('signal-cli stderr', { line: line.trim() }); + } + } + }); + + return { + stop: () => { + if (!child.killed && !exited) child.kill('SIGTERM'); + }, + exited: exitedPromise, + isExited: () => exited, + }; +} + +// --------------------------------------------------------------------------- +// TCP JSON-RPC client for signal-cli daemon (--tcp mode) +// +// signal-cli 0.14.x --tcp exposes a newline-delimited JSON-RPC socket. +// Requests are sent as JSON + newline; responses and push notifications +// (inbound messages) arrive the same way. +// --------------------------------------------------------------------------- + +const RPC_TIMEOUT_MS = 15_000; + +class SignalTcpClient { + private socket: Socket | null = null; + private buffer = ''; + private pending = new Map< + string, + { + resolve: (value: unknown) => void; + reject: (err: Error) => void; + timer: ReturnType; + } + >(); + private onNotification: ((method: string, params: unknown) => void) | null = null; + + constructor( + private host: string, + private port: number, + ) {} + + connect(onNotification?: (method: string, params: unknown) => void): Promise { + this.onNotification = onNotification ?? null; + return new Promise((resolve, reject) => { + const sock = createConnection(this.port, this.host, () => { + this.socket = sock; + resolve(); + }); + sock.on('error', (err) => { + if (!this.socket) { + reject(err); + return; + } + log.warn('Signal TCP socket error', { err }); + }); + sock.on('data', (chunk) => this.onData(chunk)); + sock.on('close', () => { + this.socket = null; + for (const [, p] of this.pending) { + clearTimeout(p.timer); + p.reject(new Error('Signal TCP connection closed')); + } + this.pending.clear(); + }); + }); + } + + async rpc(method: string, params?: Record): Promise { + if (!this.socket) throw new Error('Signal TCP not connected'); + const id = Math.random().toString(36).slice(2); + const msg = JSON.stringify({ jsonrpc: '2.0', method, params, id }) + '\n'; + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(id); + reject(new Error(`Signal RPC timeout: ${method}`)); + }, RPC_TIMEOUT_MS); + + this.pending.set(id, { + resolve: resolve as (v: unknown) => void, + reject, + timer, + }); + this.socket!.write(msg); + }); + } + + close() { + this.socket?.destroy(); + this.socket = null; + } + + isConnected(): boolean { + return this.socket !== null && !this.socket.destroyed; + } + + private onData(chunk: Buffer) { + this.buffer += chunk.toString(); + let newlineIdx = this.buffer.indexOf('\n'); + while (newlineIdx !== -1) { + const line = this.buffer.slice(0, newlineIdx).trim(); + this.buffer = this.buffer.slice(newlineIdx + 1); + if (line) this.handleLine(line); + newlineIdx = this.buffer.indexOf('\n'); + } + } + + private handleLine(line: string) { + let parsed: any; + try { + parsed = JSON.parse(line); + } catch { + log.debug('Signal TCP: unparseable line', { line: line.slice(0, 200) }); + return; + } + + if (parsed.id && this.pending.has(parsed.id)) { + const p = this.pending.get(parsed.id)!; + this.pending.delete(parsed.id); + clearTimeout(p.timer); + if (parsed.error) { + p.reject(new Error(parsed.error.message ?? 'Signal RPC error')); + } else { + p.resolve(parsed.result); + } + return; + } + + if (parsed.method && this.onNotification) { + this.onNotification(parsed.method, parsed.params); + } + } +} + +async function signalTcpCheck(host: string, port: number): Promise { + return new Promise((resolve) => { + const sock = createConnection(port, host, () => { + sock.destroy(); + resolve(true); + }); + sock.on('error', () => resolve(false)); + setTimeout(() => { + sock.destroy(); + resolve(false); + }, 5000); + }); +} + +// --------------------------------------------------------------------------- +// Echo cache +// --------------------------------------------------------------------------- + +const ECHO_TTL_MS = 10_000; + +class EchoCache { + private entries = new Map(); + + remember(text: string) { + const key = text.trim(); + if (!key) return; + this.entries.set(key, Date.now()); + this.cleanup(); + } + + isEcho(text: string): boolean { + const key = text.trim(); + if (!key) return false; + const ts = this.entries.get(key); + if (!ts) return false; + if (Date.now() - ts > ECHO_TTL_MS) { + this.entries.delete(key); + return false; + } + this.entries.delete(key); + return true; + } + + private cleanup() { + const now = Date.now(); + for (const [key, ts] of this.entries) { + if (now - ts > ECHO_TTL_MS) this.entries.delete(key); + } + } +} + +// --------------------------------------------------------------------------- +// Signal envelope types +// --------------------------------------------------------------------------- + +interface SignalQuote { + id?: number; + authorNumber?: string; + authorUuid?: string; + text?: string; +} + +interface SignalDataMessage { + timestamp?: number; + message?: string; + groupInfo?: { groupId?: string; groupName?: string; type?: string }; + quote?: SignalQuote; + attachments?: Array<{ + id?: string; + contentType?: string; + filename?: string; + size?: number; + }>; +} + +interface SignalEnvelope { + source?: string; + sourceName?: string; + sourceNumber?: string; + sourceUuid?: string; + dataMessage?: SignalDataMessage; + syncMessage?: { + sentMessage?: SignalDataMessage & { + destination?: string; + destinationNumber?: string; + }; + }; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function chunkText(text: string, limit: number): string[] { + const chunks: string[] = []; + let remaining = text; + while (remaining.length > 0) { + if (remaining.length <= limit) { + chunks.push(remaining); + break; + } + let splitAt = remaining.lastIndexOf('\n', limit); + if (splitAt <= 0) splitAt = limit; + chunks.push(remaining.slice(0, splitAt)); + remaining = remaining.slice(splitAt).replace(/^\n/, ''); + } + return chunks; +} + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +// --------------------------------------------------------------------------- +// Signal text styles — convert Markdown to Signal's offset-based formatting +// --------------------------------------------------------------------------- + +interface SignalTextStyle { + style: 'BOLD' | 'ITALIC' | 'STRIKETHROUGH' | 'MONOSPACE' | 'SPOILER'; + start: number; + length: number; +} + +interface StyledText { + text: string; + textStyles: SignalTextStyle[]; +} + +function parseSignalStyles(input: string): StyledText { + const styles: SignalTextStyle[] = []; + + const patterns: Array<{ + regex: RegExp; + style: SignalTextStyle['style']; + }> = [ + { regex: /```([\s\S]*?)```/g, style: 'MONOSPACE' }, + { regex: /`([^`]+)`/g, style: 'MONOSPACE' }, + { regex: /\*\*(.+?)\*\*/g, style: 'BOLD' }, + { regex: /\*(.+?)\*/g, style: 'BOLD' }, + { regex: /_(.+?)_/g, style: 'ITALIC' }, + { regex: /~~(.+?)~~/g, style: 'STRIKETHROUGH' }, + { regex: /\|\|(.+?)\|\|/g, style: 'SPOILER' }, + ]; + + let text = input; + + for (const { regex, style } of patterns) { + const nextText: string[] = []; + let lastIndex = 0; + let offset = 0; + + for (const match of text.matchAll(regex)) { + const fullMatch = match[0]; + const innerText = match[1]; + const matchStart = match.index!; + + nextText.push(text.slice(lastIndex, matchStart)); + const plainStart = matchStart - offset; + + nextText.push(innerText); + styles.push({ style, start: plainStart, length: innerText.length }); + + const stripped = fullMatch.length - innerText.length; + offset += stripped; + lastIndex = matchStart + fullMatch.length; + } + + nextText.push(text.slice(lastIndex)); + text = nextText.join(''); + } + + return { text, textStyles: styles }; +} + +// --------------------------------------------------------------------------- +// SignalAdapter — v2 ChannelAdapter implementation +// --------------------------------------------------------------------------- + +/** + * Platform ID format: + * DM: phone number or UUID (e.g. "+15555550123") + * Group: "group:" (e.g. "group:abc123") + * + * channelType is always "signal". The router combines channelType + platformId + * to look up or create the messaging_group. + */ +export function createSignalAdapter(config: { + cliPath: string; + account: string; + tcpHost: string; + tcpPort: number; + manageDaemon: boolean; + signalDataDir: string; +}): ChannelAdapter { + let daemon: DaemonHandle | null = null; + let tcp: SignalTcpClient | null = null; + let connected = false; + const echoCache = new EchoCache(); + let setup: ChannelSetup | null = null; + + // -- inbound handling -- + + function handleNotification(method: string, params: unknown): void { + if (method === 'receive') { + const envelope = (params as any)?.envelope; + if (envelope) { + handleEnvelope(envelope).catch((err) => { + log.error('Signal: error handling envelope', { err }); + }); + } + } + } + + async function handleEnvelope(envelope: SignalEnvelope): Promise { + if (!setup) return; + + // Sync messages (sent from another device) + const syncSent = envelope.syncMessage?.sentMessage; + if (syncSent) { + const dest = (syncSent.destinationNumber ?? syncSent.destination ?? '').trim(); + // "Note to Self" — destination is our own account + if (dest === config.account) { + const text = (syncSent.message ?? '').trim(); + if (!text) return; + if (echoCache.isEcho(text)) return; + const platformId = config.account; + const timestamp = syncSent.timestamp ? new Date(syncSent.timestamp).toISOString() : new Date().toISOString(); + + setup.onMetadata(platformId, 'Note to Self', false); + + const msg: InboundMessage = { + id: String(syncSent.timestamp ?? Date.now()), + kind: 'chat', + content: { + text, + sender: config.account, + senderId: `signal:${config.account}`, + senderName: 'Me', + isFromMe: true, + ...(syncSent.quote ? quoteToContent(syncSent.quote) : {}), + }, + timestamp, + }; + await setup.onInbound(platformId, null, msg); + return; + } + // Other sync messages are our outbound — skip + return; + } + + const dataMessage = envelope.dataMessage; + if (!dataMessage) return; + + const text = (dataMessage.message ?? '').trim(); + + // Check for voice attachments + const hasVoice = !text && dataMessage.attachments?.some((a) => a.contentType?.startsWith('audio/')); + + if (!text && !hasVoice) return; + + const sender = (envelope.sourceNumber ?? envelope.sourceUuid ?? envelope.source ?? '').trim(); + if (!sender) return; + + if (text && echoCache.isEcho(text)) { + log.debug('Signal: skipping echo'); + return; + } + + const senderName = (envelope.sourceName?.trim() || sender).trim(); + const groupInfo = dataMessage.groupInfo; + const isGroup = Boolean(groupInfo?.groupId); + const groupId = groupInfo?.groupId; + + const platformId = isGroup ? `group:${groupId}` : sender; + const timestamp = dataMessage.timestamp ? new Date(dataMessage.timestamp).toISOString() : new Date().toISOString(); + + const chatName = groupInfo?.groupName ?? (isGroup ? `Group ${groupId?.slice(0, 8)}` : senderName); + + setup.onMetadata(platformId, chatName, isGroup); + + let content = text; + + // Voice attachment — log path, deliver placeholder text. + // v2 does not have built-in transcription; a future MCP tool could handle this. + if (hasVoice) { + const audio = dataMessage.attachments?.find((a) => a.contentType?.startsWith('audio/')); + if (audio?.id) { + const attachmentPath = join(config.signalDataDir, 'attachments', audio.id); + if (existsSync(attachmentPath)) { + log.info('Signal: voice attachment received', { + platformId, + attachmentId: audio.id, + path: attachmentPath, + }); + content = '[Voice Message]'; + } else { + log.warn('Signal: voice attachment file not found', { + id: audio.id, + path: attachmentPath, + }); + content = '[Voice Message - file not found]'; + } + } else { + content = '[Voice Message]'; + } + } + + const msg: InboundMessage = { + id: String(dataMessage.timestamp ?? Date.now()), + kind: 'chat', + content: { + text: content, + sender, + senderId: `signal:${sender}`, + senderName, + ...(dataMessage.quote ? quoteToContent(dataMessage.quote) : {}), + }, + timestamp, + }; + await setup.onInbound(platformId, null, msg); + + log.info('Signal message received', { platformId, sender: senderName }); + } + + function quoteToContent(quote: SignalQuote): Record { + return { + replyToSenderName: quote.authorNumber ?? 'someone', + replyToMessageContent: quote.text || undefined, + replyToMessageId: quote.id ? String(quote.id) : undefined, + }; + } + + // -- send helpers -- + + async function sendText(platformId: string, text: string): Promise { + if (!connected || !tcp) return; + + echoCache.remember(text); + + const MAX_CHUNK = 4000; + const chunks = text.length <= MAX_CHUNK ? [text] : chunkText(text, MAX_CHUNK); + + for (const chunk of chunks) { + try { + const { text: plainText, textStyles } = parseSignalStyles(chunk); + const params: Record = { message: plainText }; + if (config.account) params.account = config.account; + if (textStyles.length > 0) { + params.textStyle = textStyles.map((s) => `${s.start}:${s.length}:${s.style}`); + } + + if (platformId.startsWith('group:')) { + params.groupId = platformId.slice('group:'.length); + } else { + params.recipient = [platformId]; + } + + try { + await tcp.rpc('send', params); + } catch (styledErr) { + if (textStyles.length > 0) { + log.debug('Signal: textStyle rejected, retrying with markup'); + delete params.textStyle; + params.message = chunk; + await tcp.rpc('send', params); + } else { + throw styledErr; + } + } + } catch (err) { + log.error('Signal: send failed', { platformId, err }); + } + } + + log.info('Signal message sent', { platformId, length: text.length }); + } + + async function waitForDaemon(): Promise { + const maxWait = 30_000; + const pollInterval = 1000; + const start = Date.now(); + + while (Date.now() - start < maxWait) { + if (daemon?.isExited()) return false; + const ok = await signalTcpCheck(config.tcpHost, config.tcpPort); + if (ok) return true; + await sleep(pollInterval); + } + return false; + } + + // -- adapter -- + + const adapter: ChannelAdapter = { + name: 'signal', + channelType: 'signal', + supportsThreads: false, + + async setup(cfg: ChannelSetup): Promise { + setup = cfg; + + if (config.manageDaemon) { + daemon = spawnSignalDaemon(config.cliPath, config.account, config.tcpHost, config.tcpPort); + const ready = await waitForDaemon(); + if (!ready) { + daemon.stop(); + throw new Error('Signal daemon failed to start. Is signal-cli installed and your account linked?'); + } + } else { + const ok = await signalTcpCheck(config.tcpHost, config.tcpPort); + if (!ok) { + const err = new Error( + `Signal daemon not reachable at ${config.tcpHost}:${config.tcpPort}. Start it manually or set SIGNAL_MANAGE_DAEMON=true`, + ); + (err as any).name = 'NetworkError'; + throw err; + } + } + + tcp = new SignalTcpClient(config.tcpHost, config.tcpPort); + await tcp.connect(handleNotification); + + try { + await tcp.rpc('updateProfile', { + name: 'NanoClaw', + account: config.account, + }); + } catch { + log.debug('Signal: could not set profile name'); + } + + try { + await tcp.rpc('updateConfiguration', { + typingIndicators: true, + account: config.account, + }); + } catch { + log.debug('Signal: could not enable typing indicators'); + } + + connected = true; + log.info('Signal channel connected', { + account: config.account, + host: config.tcpHost, + port: config.tcpPort, + }); + }, + + async teardown(): Promise { + connected = false; + tcp?.close(); + tcp = null; + if (daemon && config.manageDaemon) { + daemon.stop(); + await daemon.exited; + } + daemon = null; + log.info('Signal channel disconnected'); + }, + + isConnected(): boolean { + return connected; + }, + + async deliver(platformId: string, _threadId: string | null, message: OutboundMessage): Promise { + const content = message.content as Record | string | undefined; + let text: string | null = null; + if (typeof content === 'string') { + text = content; + } else if (content && typeof content === 'object' && typeof content.text === 'string') { + text = content.text; + } + if (!text) return undefined; + + await sendText(platformId, text); + return undefined; + }, + + async setTyping(platformId: string, _threadId: string | null): Promise { + if (!connected || !tcp) return; + if (platformId.startsWith('group:')) return; + + try { + const params: Record = { recipient: [platformId] }; + if (config.account) params.account = config.account; + await tcp.rpc('sendTyping', params); + } catch (err) { + log.debug('Signal: typing indicator failed', { platformId, err }); + } + }, + }; + + return adapter; +} + +// --------------------------------------------------------------------------- +// Self-registration +// --------------------------------------------------------------------------- + +const DEFAULT_TCP_HOST = '127.0.0.1'; +const DEFAULT_TCP_PORT = 7583; + +registerChannelAdapter('signal', { + factory: () => { + const envVars = readEnvFile([ + 'SIGNAL_ACCOUNT', + 'SIGNAL_HTTP_HOST', + 'SIGNAL_HTTP_PORT', + 'SIGNAL_MANAGE_DAEMON', + 'SIGNAL_DATA_DIR', + ]); + + const account = process.env.SIGNAL_ACCOUNT || envVars.SIGNAL_ACCOUNT || ''; + if (!account) { + log.debug('Signal: SIGNAL_ACCOUNT not set, skipping channel'); + return null; + } + + const cliPath = 'signal-cli'; + const tcpHost = process.env.SIGNAL_HTTP_HOST || envVars.SIGNAL_HTTP_HOST || DEFAULT_TCP_HOST; + const tcpPort = parseInt(process.env.SIGNAL_HTTP_PORT || envVars.SIGNAL_HTTP_PORT || String(DEFAULT_TCP_PORT), 10); + const manageDaemon = (process.env.SIGNAL_MANAGE_DAEMON || envVars.SIGNAL_MANAGE_DAEMON || 'true') === 'true'; + + const signalDataDir = + process.env.SIGNAL_DATA_DIR || envVars.SIGNAL_DATA_DIR || join(homedir(), '.local', 'share', 'signal-cli'); + + if (manageDaemon && cliPath === 'signal-cli') { + try { + execFileSync('which', ['signal-cli'], { stdio: 'ignore' }); + } catch { + log.debug('Signal: signal-cli binary not found, skipping channel'); + return null; + } + } + + return createSignalAdapter({ + cliPath, + account, + tcpHost, + tcpPort, + manageDaemon, + signalDataDir, + }); + }, +});