From c6d2f45f93d3189d0206aecb614e44e64da5afb5 Mon Sep 17 00:00:00 2001 From: Doug Daniels Date: Thu, 23 Apr 2026 14:37:10 -0400 Subject: [PATCH 1/3] feat: add Signal channel adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Native Signal adapter using signal-cli TCP JSON-RPC daemon. No Chat SDK bridge or npm dependencies — uses only Node.js builtins. Features: - DM and group message support - Voice message detection (placeholder text; transcription via /add-voice-transcription skill) - Typing indicators (DMs only) - Mention detection via text match - Managed daemon lifecycle (auto-start/stop signal-cli) - Echo suppression for outbound messages Also fixes init-first-agent.ts to skip channel-prefixing for phone numbers (+...) and Signal group IDs (group:...), which are native platform IDs that adapters send without a channel prefix. Install via /add-signal skill. Uses /init-first-agent for channel wiring. Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/skills/add-signal/SKILL.md | 121 +++++ scripts/init-first-agent.ts | 24 +- src/channels/index.ts | 1 + src/channels/signal.test.ts | 627 ++++++++++++++++++++++++ src/channels/signal.ts | 744 +++++++++++++++++++++++++++++ 5 files changed, 1513 insertions(+), 4 deletions(-) create mode 100644 .claude/skills/add-signal/SKILL.md create mode 100644 src/channels/signal.test.ts create mode 100644 src/channels/signal.ts diff --git a/.claude/skills/add-signal/SKILL.md b/.claude/skills/add-signal/SKILL.md new file mode 100644 index 000000000..92c78004b --- /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 dcb99b511..fc61b9c75 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 e9b3bd1b7..b75016f68 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 000000000..c7ffff1d7 --- /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 000000000..300b7a6d0 --- /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, + }); + }, +}); From 5f3bd9c880a06881fa66896d5f182df3eb3d97d5 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Thu, 23 Apr 2026 22:54:27 +0300 Subject: [PATCH 2/3] fix(signal): address review feedback from #1953 Correctness fixes: - parseSignalStyles now uses a recursive walker so nested styles (e.g. **bold with `code` inside**) produce correct offsets against the final plain text. Previous impl recorded styles against intermediate text and didn't reindex when later passes stripped prefix characters. - *single-asterisk* maps to ITALIC (was BOLD, divergent from standard Markdown). _underscore_ also maps to ITALIC. - EchoCache keys on (platformId, text) so an outbound "hi" to Alice no longer drops a real "hi" inbound from Bob. - On TCP socket close, flip adapter connected=false and log a warning so operators see lost daemon connections instead of silently failing sends. - signalTcpCheck clears its 5s timeout on success so successful checks don't leak a setTimeout handle. Config hygiene: - Rename SIGNAL_HTTP_HOST/PORT to SIGNAL_TCP_HOST/PORT (transport is TCP JSON-RPC, not HTTP) and add SIGNAL_CLI_PATH for non-PATH installs. - Remove unused readFileSync import. - Log a warning in deliver() when outbound files are dropped (native adapter doesn't forward attachments to signal-cli yet). Tests: - Nested style offset correctness - *italic* and _italic_ ITALIC mapping - Cross-recipient echo isolation - Same-recipient echo still suppressed - isConnected() flips on socket close - Outbound-files warn-and-drop path SKILL.md realigned to the add-telegram / add-whatsapp template: fetches from the `channels` branch (not a `skill/*` branch), lists pre-flight idempotency checks, adds Features / Troubleshooting sections. Added VERIFY.md and REMOVE.md siblings. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/add-signal/REMOVE.md | 13 ++ .claude/skills/add-signal/SKILL.md | 103 ++++++++------ .claude/skills/add-signal/VERIFY.md | 5 + src/channels/signal.test.ts | 159 ++++++++++++++++++++++ src/channels/signal.ts | 199 +++++++++++++++++++--------- 5 files changed, 375 insertions(+), 104 deletions(-) create mode 100644 .claude/skills/add-signal/REMOVE.md create mode 100644 .claude/skills/add-signal/VERIFY.md diff --git a/.claude/skills/add-signal/REMOVE.md b/.claude/skills/add-signal/REMOVE.md new file mode 100644 index 000000000..db37ade8e --- /dev/null +++ b/.claude/skills/add-signal/REMOVE.md @@ -0,0 +1,13 @@ +# Remove Signal + +1. Comment out `import './signal.js'` in `src/channels/index.ts` +2. Remove `SIGNAL_ACCOUNT` (and any other `SIGNAL_*` vars) from `.env` +3. Rebuild and restart + +If you also want to unlink the Signal account from `signal-cli`: + +```bash +signal-cli -a +1YOURNUMBER removeDevice --deviceId +``` + +(Find the device id with `signal-cli -a +1YOURNUMBER listDevices`.) diff --git a/.claude/skills/add-signal/SKILL.md b/.claude/skills/add-signal/SKILL.md index 92c78004b..e6d41aa67 100644 --- a/.claude/skills/add-signal/SKILL.md +++ b/.claude/skills/add-signal/SKILL.md @@ -5,38 +5,40 @@ description: Add Signal channel integration via signal-cli TCP daemon. Native ad # 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. +Adds Signal messaging support via a native adapter that speaks JSON-RPC to a [signal-cli](https://github.com/AsamK/signal-cli) TCP daemon. No Chat SDK bridge, no npm deps — only Node.js builtins. ## 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) +`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 +NanoClaw doesn't ship channels in trunk. This skill copies the Signal adapter and its tests in from the `channels` branch. + ### 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/signal.ts` and `src/channels/signal.test.ts` both exist - `src/channels/index.ts` contains `import './signal.js';` Otherwise continue. Every step below is safe to re-run. -### 1. Fetch the skill branch +### 1. Fetch the channels branch ```bash -git fetch origin skill/signal +git fetch origin channels ``` ### 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 +git show origin/channels:src/channels/signal.ts > src/channels/signal.ts +git show origin/channels:src/channels/signal.test.ts > src/channels/signal.test.ts ``` ### 3. Append the self-registration import @@ -59,30 +61,31 @@ No npm packages to install — the adapter uses only Node.js builtins (`node:net Add to `.env`: -```env +```bash SIGNAL_ACCOUNT=+1YOURNUMBER ``` ### Optional settings -```env +```bash # TCP daemon host and port (default: 127.0.0.1:7583) -SIGNAL_HTTP_HOST=127.0.0.1 -SIGNAL_HTTP_PORT=7583 +SIGNAL_TCP_HOST=127.0.0.1 +SIGNAL_TCP_PORT=7583 -# Whether NanoClaw manages the daemon lifecycle (default: true) -# Set to false if you run signal-cli daemon externally +# Path to the signal-cli binary (default: resolved on PATH) +SIGNAL_CLI_PATH=/usr/local/bin/signal-cli + +# 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 +**Security note:** keep the TCP host on `127.0.0.1`. The daemon has no auth — binding it to a public interface would expose your full Signal account to the network. -```bash -mkdir -p data/env && cp .env data/env/env -``` +Sync to container: `mkdir -p data/env && cp .env data/env/env` ### Restart @@ -96,26 +99,50 @@ 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: +If you're in the middle of `/setup`, return to the setup flow now. -- **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. +Otherwise, run `/init-first-agent` to create an agent and wire it to your Signal DM, or `/manage-channels` to wire this channel to an existing agent group. Signal is direct-addressable — your phone number is the platform ID. ## 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 | +- **type**: `signal` +- **terminology**: Signal has "chats" (1:1 DMs) and "groups." +- **how-to-find-id**: DMs use your phone number (e.g. `+15555550123`). Groups use `group:` — find group IDs via `signal-cli -a +1YOURNUMBER listGroups`. +- **supports-threads**: no +- **typical-use**: Personal assistant via Signal DMs or small group chats +- **default-isolation**: One agent per Signal account. Multiple chats with the same operator can share an agent group; groups with other people should typically be separate. -### Voice Messages +### Features -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. +- Markdown formatting — `**bold**`, `*italic*` / `_italic_`, `` `code` ``, ` ```code fence``` `, `~~strike~~`, `||spoiler||` (converted to Signal's offset-based text styles) +- Quoted replies — `replyTo*` fields populated from Signal quotes +- Typing indicators — DMs only (Signal doesn't support group typing) +- Echo suppression — outbound messages are matched on `(platformId, text)` within a 10 s TTL to avoid syncMessage loops +- Note to Self — messages you send to your own account from another device route to the agent as inbound with `isFromMe: true` +- Voice attachments — detected but not transcribed by default; the agent receives `[Voice Message]` placeholder text. Run `/add-voice-transcription` for local transcription via parakeet-mlx + +Not supported yet: outbound file attachments (logged and dropped), edit/delete messages, reactions. + +## Troubleshooting + +### Daemon not reachable + +```bash +grep "Signal" logs/nanoclaw.log | tail +``` + +If you see `Signal daemon failed to start. Is signal-cli installed and your account linked?`: +- Confirm `signal-cli` is on PATH (or set `SIGNAL_CLI_PATH`) +- Confirm the account is linked: `signal-cli -a +1YOURNUMBER listIdentities` should succeed without prompting + +If you see `Signal daemon not reachable at 127.0.0.1:7583` and `SIGNAL_MANAGE_DAEMON=false`, start the daemon yourself: `signal-cli -a +1YOURNUMBER daemon --tcp 127.0.0.1:7583`. + +### Bot not responding + +1. Channel initialized: `grep "Signal channel connected" logs/nanoclaw.log | tail -1` +2. Channel wired: `sqlite3 data/v2.db "SELECT mg.platform_id, mg.name FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id = mga.messaging_group_id WHERE mg.channel_type='signal'"` +3. Service running: `launchctl print gui/$(id -u)/com.nanoclaw` (macOS) / `systemctl --user status nanoclaw` (Linux) + +### Lost connection mid-session + +If you see `Signal channel lost TCP connection to signal-cli daemon` in the logs, the daemon dropped us. There's no auto-reconnect yet — restart the service to re-establish. diff --git a/.claude/skills/add-signal/VERIFY.md b/.claude/skills/add-signal/VERIFY.md new file mode 100644 index 000000000..b1ae8518c --- /dev/null +++ b/.claude/skills/add-signal/VERIFY.md @@ -0,0 +1,5 @@ +# Verify Signal + +Send a message to your own Signal number (Note to Self) from another device, or have someone send your linked number a DM. The bot should respond within a few seconds. + +If nothing happens, tail `logs/nanoclaw.log` for `Signal channel connected` and `Signal message received`. diff --git a/src/channels/signal.test.ts b/src/channels/signal.test.ts index c7ffff1d7..f5dabfa52 100644 --- a/src/channels/signal.test.ts +++ b/src/channels/signal.test.ts @@ -583,6 +583,165 @@ describe('SignalAdapter', () => { await adapter.teardown(); }); + + it('tracks nested styles with correct offsets', async () => { + const adapter = createAdapter(); + await adapter.setup(createMockSetup()); + tcpRef.fakeSocket.write.mockClear(); + + await adapter.deliver('+15555550123', null, { + kind: 'text', + content: { text: '**bold with `code` inside**' }, + }); + + const sendCalls = getRpcCallsForMethod('send'); + const last = sendCalls[sendCalls.length - 1]; + expect(last.params.message).toBe('bold with code inside'); + // BOLD covers the full inner span, MONOSPACE points at "code" in the + // final plain text (offset 10, length 4) — not the intermediate text. + const styles = (last.params.textStyle as string[]).slice().sort(); + expect(styles).toEqual(['0:21:BOLD', '10:4:MONOSPACE']); + + await adapter.teardown(); + }); + + it('maps *single-asterisk* to ITALIC', 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'); + const last = sendCalls[sendCalls.length - 1]; + expect(last.params.message).toBe('Hello world'); + expect(last.params.textStyle).toEqual(['6:5:ITALIC']); + + await adapter.teardown(); + }); + + it('maps _underscore_ to ITALIC', async () => { + const adapter = createAdapter(); + await adapter.setup(createMockSetup()); + tcpRef.fakeSocket.write.mockClear(); + + await adapter.deliver('+15555550123', null, { + kind: 'text', + content: { text: 'hey _there_' }, + }); + + const sendCalls = getRpcCallsForMethod('send'); + const last = sendCalls[sendCalls.length - 1]; + expect(last.params.message).toBe('hey there'); + expect(last.params.textStyle).toEqual(['4:5:ITALIC']); + + await adapter.teardown(); + }); + }); + + // --- Echo cache --- + + describe('echo cache', () => { + it('does not drop same-text inbound from a different recipient', async () => { + // Bot sends "Hello" to Alice. Immediately after, Bob sends "Hello" from + // a different DM. Bob's message must still route — the earlier echo key + // was scoped to Alice. + const adapter = createAdapter(); + const cfg = createMockSetup(); + await adapter.setup(cfg); + + await adapter.deliver('+15555550123', null, { + kind: 'text', + content: { text: 'Hello' }, + }); + + pushEvent({ + sourceNumber: '+15555550999', + sourceName: 'Bob', + dataMessage: { timestamp: 1700000000000, message: 'Hello' }, + }); + + await new Promise((r) => setTimeout(r, 50)); + expect(cfg.onInbound).toHaveBeenCalledWith( + '+15555550999', + null, + expect.objectContaining({ + content: expect.objectContaining({ text: 'Hello', sender: '+15555550999' }), + }), + ); + + await adapter.teardown(); + }); + + it('still skips echo on the same recipient', 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(); + }); + }); + + // --- Connection drop --- + + describe('connection drop', () => { + it('flips isConnected to false when the socket closes', async () => { + const adapter = createAdapter(); + await adapter.setup(createMockSetup()); + expect(adapter.isConnected()).toBe(true); + + // Simulate the daemon dropping the TCP connection. + tcpRef.fakeSocket.destroy(); + await new Promise((r) => setTimeout(r, 20)); + + expect(adapter.isConnected()).toBe(false); + + await adapter.teardown(); + }); + }); + + // --- Outbound files --- + + describe('outbound files', () => { + it('logs a warning and drops unsupported file attachments', async () => { + const { log } = await import('../log.js'); + const warnMock = log.warn as unknown as ReturnType; + + const adapter = createAdapter(); + await adapter.setup(createMockSetup()); + warnMock.mockClear(); + + await adapter.deliver('+15555550123', null, { + kind: 'text', + content: { text: 'with an attachment' }, + files: [{ filename: 'hi.txt', data: Buffer.from('hi') }], + }); + + const sendCalls = getRpcCallsForMethod('send'); + expect(sendCalls.length).toBeGreaterThan(0); + expect(warnMock).toHaveBeenCalledWith( + 'Signal: outbound files not supported, dropping', + expect.objectContaining({ platformId: '+15555550123', count: 1 }), + ); + + await adapter.teardown(); + }); }); // --- setTyping --- diff --git a/src/channels/signal.ts b/src/channels/signal.ts index 300b7a6d0..20cba8162 100644 --- a/src/channels/signal.ts +++ b/src/channels/signal.ts @@ -8,7 +8,7 @@ * Ported from v1 — see v1 source for commit history. */ import { execFileSync, spawn } from 'node:child_process'; -import { readFileSync, existsSync } from 'node:fs'; +import { existsSync } from 'node:fs'; import { createConnection, type Socket } from 'node:net'; import { homedir } from 'node:os'; import { join } from 'node:path'; @@ -100,14 +100,19 @@ class SignalTcpClient { } >(); private onNotification: ((method: string, params: unknown) => void) | null = null; + private onClose: (() => void) | null = null; constructor( private host: string, private port: number, ) {} - connect(onNotification?: (method: string, params: unknown) => void): Promise { - this.onNotification = onNotification ?? null; + connect(handlers?: { + onNotification?: (method: string, params: unknown) => void; + onClose?: () => void; + }): Promise { + this.onNotification = handlers?.onNotification ?? null; + this.onClose = handlers?.onClose ?? null; return new Promise((resolve, reject) => { const sock = createConnection(this.port, this.host, () => { this.socket = sock; @@ -122,12 +127,14 @@ class SignalTcpClient { }); sock.on('data', (chunk) => this.onData(chunk)); sock.on('close', () => { + const wasConnected = this.socket !== null; this.socket = null; for (const [, p] of this.pending) { clearTimeout(p.timer); p.reject(new Error('Signal TCP connection closed')); } this.pending.clear(); + if (wasConnected) this.onClose?.(); }); }); } @@ -201,15 +208,17 @@ class SignalTcpClient { async function signalTcpCheck(host: string, port: number): Promise { return new Promise((resolve) => { - const sock = createConnection(port, host, () => { + let settled = false; + const finish = (result: boolean) => { + if (settled) return; + settled = true; + clearTimeout(timer); sock.destroy(); - resolve(true); - }); - sock.on('error', () => resolve(false)); - setTimeout(() => { - sock.destroy(); - resolve(false); - }, 5000); + resolve(result); + }; + const sock = createConnection(port, host, () => finish(true)); + sock.on('error', () => finish(false)); + const timer = setTimeout(() => finish(false), 5000); }); } @@ -219,19 +228,35 @@ async function signalTcpCheck(host: string, port: number): Promise { const ECHO_TTL_MS = 10_000; +/** + * Per-recipient dedup for messages we sent ourselves. + * + * signal-cli echoes our own outbound back via syncMessage (and, for Note to + * Self, via sentMessage-with-self-destination). Without dedup, the agent sees + * its own replies as new inbound and loops. We remember `(platformId, text)` + * briefly after every send, and drop the first match within TTL. + * + * Keying on text alone is not enough: if we send "hi" to Alice and Bob then + * sends "hi" from a different chat, Bob's real message gets silently dropped. + */ class EchoCache { private entries = new Map(); - remember(text: string) { - const key = text.trim(); - if (!key) return; - this.entries.set(key, Date.now()); + private keyFor(platformId: string, text: string): string { + return `${platformId}\x00${text.trim()}`; + } + + remember(platformId: string, text: string): void { + const trimmed = text.trim(); + if (!trimmed) return; + this.entries.set(this.keyFor(platformId, trimmed), Date.now()); this.cleanup(); } - isEcho(text: string): boolean { - const key = text.trim(); - if (!key) return false; + isEcho(platformId: string, text: string): boolean { + const trimmed = text.trim(); + if (!trimmed) return false; + const key = this.keyFor(platformId, trimmed); const ts = this.entries.get(key); if (!ts) return false; if (Date.now() - ts > ECHO_TTL_MS) { @@ -242,7 +267,7 @@ class EchoCache { return true; } - private cleanup() { + private cleanup(): void { const now = Date.now(); for (const [key, ts] of this.entries) { if (now - ts > ECHO_TTL_MS) this.entries.delete(key); @@ -325,49 +350,61 @@ interface StyledText { textStyles: SignalTextStyle[]; } +/** + * Convert Markdown-ish input to Signal's offset-based style ranges. + * + * Walks the input recursively: at each level we find the leftmost matching + * pattern, descend into its captured inner text (so `**bold with \`code\` + * inside**` stays bold-plus-monospace rather than leaking stripped markers), + * then continue past the match. Style offsets are recorded against the + * *output* text length as it's built, so nested styles always point at the + * right span of the final plain text. + */ 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' }, + // Ordering matters: longer/greedier delimiters first so `` ``` `` beats + // `` ` ``, `**` beats `*`. The italic-`*` pattern refuses to start on + // whitespace so `*` isn't mistakenly opened on " * " in list-like text. + const patterns: Array<{ regex: RegExp; style: SignalTextStyle['style'] }> = [ + { regex: /```([\s\S]+?)```/, style: 'MONOSPACE' }, + { regex: /`([^`]+)`/, style: 'MONOSPACE' }, + { regex: /\*\*([^]+?)\*\*/, style: 'BOLD' }, + { regex: /~~([^]+?)~~/, style: 'STRIKETHROUGH' }, + { regex: /\|\|([^]+?)\|\|/, style: 'SPOILER' }, + { regex: /\*([^*\s][^*]*?)\*/, style: 'ITALIC' }, + { regex: /_([^_\s][^_]*?)_/, style: 'ITALIC' }, ]; - 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; + function walk(segment: string, outputBase: number): string { + let earliest: { start: number; match: RegExpExecArray; style: SignalTextStyle['style'] } | null = null; + for (const { regex, style } of patterns) { + const m = regex.exec(segment); + if (!m) continue; + if (earliest === null || m.index < earliest.start) { + earliest = { start: m.index, match: m, style }; + } } + if (!earliest) return segment; - nextText.push(text.slice(lastIndex)); - text = nextText.join(''); + const before = segment.slice(0, earliest.start); + const fullMatch = earliest.match[0]; + const inner = earliest.match[1]; + const afterStart = earliest.start + fullMatch.length; + const after = segment.slice(afterStart); + + const innerOut = walk(inner, outputBase + before.length); + styles.push({ + style: earliest.style, + start: outputBase + before.length, + length: innerOut.length, + }); + const afterOut = walk(after, outputBase + before.length + innerOut.length); + + return before + innerOut + afterOut; } + const text = walk(input, 0); return { text, textStyles: styles }; } @@ -421,8 +458,8 @@ export function createSignalAdapter(config: { if (dest === config.account) { const text = (syncSent.message ?? '').trim(); if (!text) return; - if (echoCache.isEcho(text)) return; const platformId = config.account; + if (echoCache.isEcho(platformId, text)) return; const timestamp = syncSent.timestamp ? new Date(syncSent.timestamp).toISOString() : new Date().toISOString(); setup.onMetadata(platformId, 'Note to Self', false); @@ -460,17 +497,17 @@ export function createSignalAdapter(config: { 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; + + if (text && echoCache.isEcho(platformId, text)) { + log.debug('Signal: skipping echo', { platformId }); + return; + } const timestamp = dataMessage.timestamp ? new Date(dataMessage.timestamp).toISOString() : new Date().toISOString(); const chatName = groupInfo?.groupName ?? (isGroup ? `Group ${groupId?.slice(0, 8)}` : senderName); @@ -534,7 +571,7 @@ export function createSignalAdapter(config: { async function sendText(platformId: string, text: string): Promise { if (!connected || !tcp) return; - echoCache.remember(text); + echoCache.remember(platformId, text); const MAX_CHUNK = 4000; const chunks = text.length <= MAX_CHUNK ? [text] : chunkText(text, MAX_CHUNK); @@ -617,7 +654,22 @@ export function createSignalAdapter(config: { } tcp = new SignalTcpClient(config.tcpHost, config.tcpPort); - await tcp.connect(handleNotification); + await tcp.connect({ + onNotification: handleNotification, + // Signal the adapter that the daemon dropped us. No auto-reconnect yet + // — subsequent deliver/setTyping calls short-circuit on `connected` + // and log rather than throw into the retry loop. Operators see this in + // logs/nanoclaw.log and can restart the service. + onClose: () => { + if (!connected) return; + connected = false; + log.warn('Signal channel lost TCP connection to signal-cli daemon', { + account: config.account, + host: config.tcpHost, + port: config.tcpPort, + }); + }, + }); try { await tcp.rpc('updateProfile', { @@ -662,6 +714,17 @@ export function createSignalAdapter(config: { }, async deliver(platformId: string, _threadId: string | null, message: OutboundMessage): Promise { + if (message.files && message.files.length > 0) { + // Native adapter doesn't yet forward file uploads to signal-cli's + // `send --attachment`. Don't silently swallow — operators need to see + // that an attachment was requested but not sent. + log.warn('Signal: outbound files not supported, dropping', { + platformId, + count: message.files.length, + filenames: message.files.map((f) => f.filename), + }); + } + const content = message.content as Record | string | undefined; let text: string | null = null; if (typeof content === 'string') { @@ -703,8 +766,9 @@ registerChannelAdapter('signal', { factory: () => { const envVars = readEnvFile([ 'SIGNAL_ACCOUNT', - 'SIGNAL_HTTP_HOST', - 'SIGNAL_HTTP_PORT', + 'SIGNAL_TCP_HOST', + 'SIGNAL_TCP_PORT', + 'SIGNAL_CLI_PATH', 'SIGNAL_MANAGE_DAEMON', 'SIGNAL_DATA_DIR', ]); @@ -715,14 +779,17 @@ registerChannelAdapter('signal', { 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 cliPath = process.env.SIGNAL_CLI_PATH || envVars.SIGNAL_CLI_PATH || 'signal-cli'; + const tcpHost = process.env.SIGNAL_TCP_HOST || envVars.SIGNAL_TCP_HOST || DEFAULT_TCP_HOST; + const tcpPort = parseInt(process.env.SIGNAL_TCP_PORT || envVars.SIGNAL_TCP_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'); + // Only check for `signal-cli` on PATH when the operator left cliPath at + // the default AND asked us to manage the daemon. A custom absolute path + // is treated as an explicit promise and spawn will surface its own ENOENT. if (manageDaemon && cliPath === 'signal-cli') { try { execFileSync('which', ['signal-cli'], { stdio: 'ignore' }); From 2fd2bf3bdee3405b96e4db19ed71a771d36a588c Mon Sep 17 00:00:00 2001 From: gavrielc Date: Thu, 23 Apr 2026 22:56:31 +0300 Subject: [PATCH 3/3] chore(signal): move adapter source to channels branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signal adapter source (src/channels/signal.ts + signal.test.ts) now lives on the `channels` branch alongside all other channel adapters, per the trunk/channels split documented in CLAUDE.md and CONTRIBUTING.md ("Trunk does not ship any specific channel adapter"). The /add-signal skill fetches the file from origin/channels like every other channel. This PR to main therefore carries only: - .claude/skills/add-signal/{SKILL,VERIFY,REMOVE}.md — the skill itself - scripts/init-first-agent.ts — unrelated infra fix that benefits any native-ID channel (Signal, WhatsApp) by skipping the channel-prefix on platform IDs that already have their own format The fixed adapter source + tests were pushed to the channels branch in a parallel commit. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/channels/index.ts | 1 - src/channels/signal.test.ts | 786 ---------------------------------- src/channels/signal.ts | 811 ------------------------------------ 3 files changed, 1598 deletions(-) delete mode 100644 src/channels/signal.test.ts delete mode 100644 src/channels/signal.ts diff --git a/src/channels/index.ts b/src/channels/index.ts index b75016f68..e9b3bd1b7 100644 --- a/src/channels/index.ts +++ b/src/channels/index.ts @@ -7,4 +7,3 @@ // self-registration import below. import './cli.js'; -import './signal.js'; diff --git a/src/channels/signal.test.ts b/src/channels/signal.test.ts deleted file mode 100644 index f5dabfa52..000000000 --- a/src/channels/signal.test.ts +++ /dev/null @@ -1,786 +0,0 @@ -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(); - }); - - it('tracks nested styles with correct offsets', async () => { - const adapter = createAdapter(); - await adapter.setup(createMockSetup()); - tcpRef.fakeSocket.write.mockClear(); - - await adapter.deliver('+15555550123', null, { - kind: 'text', - content: { text: '**bold with `code` inside**' }, - }); - - const sendCalls = getRpcCallsForMethod('send'); - const last = sendCalls[sendCalls.length - 1]; - expect(last.params.message).toBe('bold with code inside'); - // BOLD covers the full inner span, MONOSPACE points at "code" in the - // final plain text (offset 10, length 4) — not the intermediate text. - const styles = (last.params.textStyle as string[]).slice().sort(); - expect(styles).toEqual(['0:21:BOLD', '10:4:MONOSPACE']); - - await adapter.teardown(); - }); - - it('maps *single-asterisk* to ITALIC', 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'); - const last = sendCalls[sendCalls.length - 1]; - expect(last.params.message).toBe('Hello world'); - expect(last.params.textStyle).toEqual(['6:5:ITALIC']); - - await adapter.teardown(); - }); - - it('maps _underscore_ to ITALIC', async () => { - const adapter = createAdapter(); - await adapter.setup(createMockSetup()); - tcpRef.fakeSocket.write.mockClear(); - - await adapter.deliver('+15555550123', null, { - kind: 'text', - content: { text: 'hey _there_' }, - }); - - const sendCalls = getRpcCallsForMethod('send'); - const last = sendCalls[sendCalls.length - 1]; - expect(last.params.message).toBe('hey there'); - expect(last.params.textStyle).toEqual(['4:5:ITALIC']); - - await adapter.teardown(); - }); - }); - - // --- Echo cache --- - - describe('echo cache', () => { - it('does not drop same-text inbound from a different recipient', async () => { - // Bot sends "Hello" to Alice. Immediately after, Bob sends "Hello" from - // a different DM. Bob's message must still route — the earlier echo key - // was scoped to Alice. - const adapter = createAdapter(); - const cfg = createMockSetup(); - await adapter.setup(cfg); - - await adapter.deliver('+15555550123', null, { - kind: 'text', - content: { text: 'Hello' }, - }); - - pushEvent({ - sourceNumber: '+15555550999', - sourceName: 'Bob', - dataMessage: { timestamp: 1700000000000, message: 'Hello' }, - }); - - await new Promise((r) => setTimeout(r, 50)); - expect(cfg.onInbound).toHaveBeenCalledWith( - '+15555550999', - null, - expect.objectContaining({ - content: expect.objectContaining({ text: 'Hello', sender: '+15555550999' }), - }), - ); - - await adapter.teardown(); - }); - - it('still skips echo on the same recipient', 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(); - }); - }); - - // --- Connection drop --- - - describe('connection drop', () => { - it('flips isConnected to false when the socket closes', async () => { - const adapter = createAdapter(); - await adapter.setup(createMockSetup()); - expect(adapter.isConnected()).toBe(true); - - // Simulate the daemon dropping the TCP connection. - tcpRef.fakeSocket.destroy(); - await new Promise((r) => setTimeout(r, 20)); - - expect(adapter.isConnected()).toBe(false); - - await adapter.teardown(); - }); - }); - - // --- Outbound files --- - - describe('outbound files', () => { - it('logs a warning and drops unsupported file attachments', async () => { - const { log } = await import('../log.js'); - const warnMock = log.warn as unknown as ReturnType; - - const adapter = createAdapter(); - await adapter.setup(createMockSetup()); - warnMock.mockClear(); - - await adapter.deliver('+15555550123', null, { - kind: 'text', - content: { text: 'with an attachment' }, - files: [{ filename: 'hi.txt', data: Buffer.from('hi') }], - }); - - const sendCalls = getRpcCallsForMethod('send'); - expect(sendCalls.length).toBeGreaterThan(0); - expect(warnMock).toHaveBeenCalledWith( - 'Signal: outbound files not supported, dropping', - expect.objectContaining({ platformId: '+15555550123', count: 1 }), - ); - - 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 deleted file mode 100644 index 20cba8162..000000000 --- a/src/channels/signal.ts +++ /dev/null @@ -1,811 +0,0 @@ -/** - * 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 { 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; - private onClose: (() => void) | null = null; - - constructor( - private host: string, - private port: number, - ) {} - - connect(handlers?: { - onNotification?: (method: string, params: unknown) => void; - onClose?: () => void; - }): Promise { - this.onNotification = handlers?.onNotification ?? null; - this.onClose = handlers?.onClose ?? 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', () => { - const wasConnected = this.socket !== null; - this.socket = null; - for (const [, p] of this.pending) { - clearTimeout(p.timer); - p.reject(new Error('Signal TCP connection closed')); - } - this.pending.clear(); - if (wasConnected) this.onClose?.(); - }); - }); - } - - 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) => { - let settled = false; - const finish = (result: boolean) => { - if (settled) return; - settled = true; - clearTimeout(timer); - sock.destroy(); - resolve(result); - }; - const sock = createConnection(port, host, () => finish(true)); - sock.on('error', () => finish(false)); - const timer = setTimeout(() => finish(false), 5000); - }); -} - -// --------------------------------------------------------------------------- -// Echo cache -// --------------------------------------------------------------------------- - -const ECHO_TTL_MS = 10_000; - -/** - * Per-recipient dedup for messages we sent ourselves. - * - * signal-cli echoes our own outbound back via syncMessage (and, for Note to - * Self, via sentMessage-with-self-destination). Without dedup, the agent sees - * its own replies as new inbound and loops. We remember `(platformId, text)` - * briefly after every send, and drop the first match within TTL. - * - * Keying on text alone is not enough: if we send "hi" to Alice and Bob then - * sends "hi" from a different chat, Bob's real message gets silently dropped. - */ -class EchoCache { - private entries = new Map(); - - private keyFor(platformId: string, text: string): string { - return `${platformId}\x00${text.trim()}`; - } - - remember(platformId: string, text: string): void { - const trimmed = text.trim(); - if (!trimmed) return; - this.entries.set(this.keyFor(platformId, trimmed), Date.now()); - this.cleanup(); - } - - isEcho(platformId: string, text: string): boolean { - const trimmed = text.trim(); - if (!trimmed) return false; - const key = this.keyFor(platformId, trimmed); - 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(): void { - 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[]; -} - -/** - * Convert Markdown-ish input to Signal's offset-based style ranges. - * - * Walks the input recursively: at each level we find the leftmost matching - * pattern, descend into its captured inner text (so `**bold with \`code\` - * inside**` stays bold-plus-monospace rather than leaking stripped markers), - * then continue past the match. Style offsets are recorded against the - * *output* text length as it's built, so nested styles always point at the - * right span of the final plain text. - */ -function parseSignalStyles(input: string): StyledText { - const styles: SignalTextStyle[] = []; - - // Ordering matters: longer/greedier delimiters first so `` ``` `` beats - // `` ` ``, `**` beats `*`. The italic-`*` pattern refuses to start on - // whitespace so `*` isn't mistakenly opened on " * " in list-like text. - const patterns: Array<{ regex: RegExp; style: SignalTextStyle['style'] }> = [ - { regex: /```([\s\S]+?)```/, style: 'MONOSPACE' }, - { regex: /`([^`]+)`/, style: 'MONOSPACE' }, - { regex: /\*\*([^]+?)\*\*/, style: 'BOLD' }, - { regex: /~~([^]+?)~~/, style: 'STRIKETHROUGH' }, - { regex: /\|\|([^]+?)\|\|/, style: 'SPOILER' }, - { regex: /\*([^*\s][^*]*?)\*/, style: 'ITALIC' }, - { regex: /_([^_\s][^_]*?)_/, style: 'ITALIC' }, - ]; - - function walk(segment: string, outputBase: number): string { - let earliest: { start: number; match: RegExpExecArray; style: SignalTextStyle['style'] } | null = null; - for (const { regex, style } of patterns) { - const m = regex.exec(segment); - if (!m) continue; - if (earliest === null || m.index < earliest.start) { - earliest = { start: m.index, match: m, style }; - } - } - if (!earliest) return segment; - - const before = segment.slice(0, earliest.start); - const fullMatch = earliest.match[0]; - const inner = earliest.match[1]; - const afterStart = earliest.start + fullMatch.length; - const after = segment.slice(afterStart); - - const innerOut = walk(inner, outputBase + before.length); - styles.push({ - style: earliest.style, - start: outputBase + before.length, - length: innerOut.length, - }); - const afterOut = walk(after, outputBase + before.length + innerOut.length); - - return before + innerOut + afterOut; - } - - const text = walk(input, 0); - 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; - const platformId = config.account; - if (echoCache.isEcho(platformId, text)) return; - 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; - - 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; - - if (text && echoCache.isEcho(platformId, text)) { - log.debug('Signal: skipping echo', { platformId }); - return; - } - 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(platformId, 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({ - onNotification: handleNotification, - // Signal the adapter that the daemon dropped us. No auto-reconnect yet - // — subsequent deliver/setTyping calls short-circuit on `connected` - // and log rather than throw into the retry loop. Operators see this in - // logs/nanoclaw.log and can restart the service. - onClose: () => { - if (!connected) return; - connected = false; - log.warn('Signal channel lost TCP connection to signal-cli daemon', { - account: config.account, - host: config.tcpHost, - port: config.tcpPort, - }); - }, - }); - - 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 { - if (message.files && message.files.length > 0) { - // Native adapter doesn't yet forward file uploads to signal-cli's - // `send --attachment`. Don't silently swallow — operators need to see - // that an attachment was requested but not sent. - log.warn('Signal: outbound files not supported, dropping', { - platformId, - count: message.files.length, - filenames: message.files.map((f) => f.filename), - }); - } - - 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_TCP_HOST', - 'SIGNAL_TCP_PORT', - 'SIGNAL_CLI_PATH', - '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 = process.env.SIGNAL_CLI_PATH || envVars.SIGNAL_CLI_PATH || 'signal-cli'; - const tcpHost = process.env.SIGNAL_TCP_HOST || envVars.SIGNAL_TCP_HOST || DEFAULT_TCP_HOST; - const tcpPort = parseInt(process.env.SIGNAL_TCP_PORT || envVars.SIGNAL_TCP_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'); - - // Only check for `signal-cli` on PATH when the operator left cliPath at - // the default AND asked us to manage the daemon. A custom absolute path - // is treated as an explicit promise and spawn will surface its own ENOENT. - 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, - }); - }, -});