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:
121
.claude/skills/add-signal/SKILL.md
Normal file
121
.claude/skills/add-signal/SKILL.md
Normal 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.
|
||||
@@ -137,13 +137,29 @@ function namespacedUserId(channel: string, raw: string): string {
|
||||
return raw.includes(':') ? raw : `${channel}:${raw}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether a platform ID needs a channel-type prefix.
|
||||
*
|
||||
* Chat SDK adapters (Telegram, Discord, Slack, Teams, etc.) namespace their
|
||||
* platform IDs with a channel prefix: "telegram:123456", "discord:guild:chan".
|
||||
* The router stores `channel_type` and `platform_id` in separate columns, but
|
||||
* Chat SDK adapters send the prefixed form as the platform_id, so this script
|
||||
* must match that format.
|
||||
*
|
||||
* Native adapters (Signal, WhatsApp) use their own ID formats and send them
|
||||
* as-is — no channel prefix. Signal sends raw phone numbers (+15551234567)
|
||||
* for DMs and "group:<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 {
|
||||
if (raw.startsWith(`${channel}:`)) return raw;
|
||||
// Adapters using native JID format (WhatsApp: <phone>@s.whatsapp.net,
|
||||
// <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.
|
||||
// Native WhatsApp JIDs contain '@' — no prefix needed.
|
||||
if (raw.includes('@')) return raw;
|
||||
// Native Signal IDs: phone numbers (+...) and group IDs (group:...).
|
||||
if (raw.startsWith('+') || raw.startsWith('group:')) return raw;
|
||||
// Chat SDK adapters — add the channel prefix.
|
||||
return `${channel}:${raw}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,3 +7,4 @@
|
||||
// self-registration import below.
|
||||
|
||||
import './cli.js';
|
||||
import './signal.js';
|
||||
|
||||
627
src/channels/signal.test.ts
Normal file
627
src/channels/signal.test.ts
Normal 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
744
src/channels/signal.ts
Normal 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,
|
||||
});
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user