feat: add Signal channel adapter

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) <noreply@anthropic.com>
This commit is contained in:
Doug Daniels
2026-04-23 14:37:10 -04:00
parent e5a7a33084
commit c6d2f45f93
5 changed files with 1513 additions and 4 deletions

View File

@@ -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:<groupId>` — 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:<groupId>` |
| **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.

View File

@@ -137,13 +137,29 @@ function namespacedUserId(channel: string, raw: string): string {
return raw.includes(':') ? raw : `${channel}:${raw}`; 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:<id>" for group chats. WhatsApp sends JIDs containing
* '@' (<phone>@s.whatsapp.net, <groupId>@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 { function namespacedPlatformId(channel: string, raw: string): string {
if (raw.startsWith(`${channel}:`)) return raw; if (raw.startsWith(`${channel}:`)) return raw;
// Adapters using native JID format (WhatsApp: <phone>@s.whatsapp.net, // Native WhatsApp JIDs contain '@' — no prefix needed.
// <groupId>@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.
if (raw.includes('@')) return raw; 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}`; return `${channel}:${raw}`;
} }

View File

@@ -7,3 +7,4 @@
// self-registration import below. // self-registration import below.
import './cli.js'; import './cli.js';
import './signal.js';

627
src/channels/signal.test.ts Normal file
View File

@@ -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<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();
});
});
// --- 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);
});
});
});

744
src/channels/signal.ts Normal file
View File

@@ -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<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;
constructor(
private host: string,
private port: number,
) {}
connect(onNotification?: (method: string, params: unknown) => void): Promise<void> {
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<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) => {
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<string, number>();
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<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[];
}
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:<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;
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<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(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(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<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> {
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_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,
});
},
});