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

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);
});
});
});