chore(signal): move adapter source to channels branch

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) <noreply@anthropic.com>
This commit is contained in:
gavrielc
2026-04-23 22:56:31 +03:00
parent 5f3bd9c880
commit 2fd2bf3bde
3 changed files with 0 additions and 1598 deletions
-1
View File
@@ -7,4 +7,3 @@
// self-registration import below.
import './cli.js';
import './signal.js';
-786
View File
@@ -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<string, unknown>(),
fakeSocket: null as any,
}));
function createFakeSocket(): EventEmitter & {
write: ReturnType<typeof vi.fn>;
destroy: ReturnType<typeof vi.fn>;
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<typeof vi.fn>,
onInboundEvent: vi.fn() as unknown as ChannelSetup['onInboundEvent'] & ReturnType<typeof vi.fn>,
onMetadata: vi.fn() as unknown as ChannelSetup['onMetadata'] & ReturnType<typeof vi.fn>,
onAction: vi.fn() as unknown as ChannelSetup['onAction'] & ReturnType<typeof vi.fn>,
};
}
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<string, unknown>;
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<string, unknown>) {
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<typeof vi.fn>;
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);
});
});
});
-811
View File
@@ -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<void>;
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<void>((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<typeof setTimeout>;
}
>();
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<void> {
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<T = unknown>(method: string, params?: Record<string, unknown>): Promise<T> {
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<T>((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<boolean> {
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<string, number>();
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<void>((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:<groupId>" (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<void> {
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<string, unknown> {
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<void> {
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<string, unknown> = { 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<boolean> {
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<void> {
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<void> {
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<string | undefined> {
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, unknown> | 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<void> {
if (!connected || !tcp) return;
if (platformId.startsWith('group:')) return;
try {
const params: Record<string, unknown> = { 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,
});
},
});