diff --git a/src/channels/index.ts b/src/channels/index.ts index b75016f..e9b3bd1 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 f5dabfa..0000000 --- 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 20cba81..0000000 --- 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, - }); - }, -});