From 5f3bd9c880a06881fa66896d5f182df3eb3d97d5 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Thu, 23 Apr 2026 22:54:27 +0300 Subject: [PATCH] fix(signal): address review feedback from #1953 Correctness fixes: - parseSignalStyles now uses a recursive walker so nested styles (e.g. **bold with `code` inside**) produce correct offsets against the final plain text. Previous impl recorded styles against intermediate text and didn't reindex when later passes stripped prefix characters. - *single-asterisk* maps to ITALIC (was BOLD, divergent from standard Markdown). _underscore_ also maps to ITALIC. - EchoCache keys on (platformId, text) so an outbound "hi" to Alice no longer drops a real "hi" inbound from Bob. - On TCP socket close, flip adapter connected=false and log a warning so operators see lost daemon connections instead of silently failing sends. - signalTcpCheck clears its 5s timeout on success so successful checks don't leak a setTimeout handle. Config hygiene: - Rename SIGNAL_HTTP_HOST/PORT to SIGNAL_TCP_HOST/PORT (transport is TCP JSON-RPC, not HTTP) and add SIGNAL_CLI_PATH for non-PATH installs. - Remove unused readFileSync import. - Log a warning in deliver() when outbound files are dropped (native adapter doesn't forward attachments to signal-cli yet). Tests: - Nested style offset correctness - *italic* and _italic_ ITALIC mapping - Cross-recipient echo isolation - Same-recipient echo still suppressed - isConnected() flips on socket close - Outbound-files warn-and-drop path SKILL.md realigned to the add-telegram / add-whatsapp template: fetches from the `channels` branch (not a `skill/*` branch), lists pre-flight idempotency checks, adds Features / Troubleshooting sections. Added VERIFY.md and REMOVE.md siblings. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/add-signal/REMOVE.md | 13 ++ .claude/skills/add-signal/SKILL.md | 103 ++++++++------ .claude/skills/add-signal/VERIFY.md | 5 + src/channels/signal.test.ts | 159 ++++++++++++++++++++++ src/channels/signal.ts | 199 +++++++++++++++++++--------- 5 files changed, 375 insertions(+), 104 deletions(-) create mode 100644 .claude/skills/add-signal/REMOVE.md create mode 100644 .claude/skills/add-signal/VERIFY.md diff --git a/.claude/skills/add-signal/REMOVE.md b/.claude/skills/add-signal/REMOVE.md new file mode 100644 index 0000000..db37ade --- /dev/null +++ b/.claude/skills/add-signal/REMOVE.md @@ -0,0 +1,13 @@ +# Remove Signal + +1. Comment out `import './signal.js'` in `src/channels/index.ts` +2. Remove `SIGNAL_ACCOUNT` (and any other `SIGNAL_*` vars) from `.env` +3. Rebuild and restart + +If you also want to unlink the Signal account from `signal-cli`: + +```bash +signal-cli -a +1YOURNUMBER removeDevice --deviceId +``` + +(Find the device id with `signal-cli -a +1YOURNUMBER listDevices`.) diff --git a/.claude/skills/add-signal/SKILL.md b/.claude/skills/add-signal/SKILL.md index 92c7800..e6d41aa 100644 --- a/.claude/skills/add-signal/SKILL.md +++ b/.claude/skills/add-signal/SKILL.md @@ -5,38 +5,40 @@ description: Add Signal channel integration via signal-cli TCP daemon. Native ad # 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. +Adds Signal messaging support via a native adapter that speaks JSON-RPC to a [signal-cli](https://github.com/AsamK/signal-cli) TCP daemon. No Chat SDK bridge, no npm deps — only Node.js builtins. ## 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) +`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 +NanoClaw doesn't ship channels in trunk. This skill copies the Signal adapter and its tests in from the `channels` branch. + ### 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/signal.ts` and `src/channels/signal.test.ts` both exist - `src/channels/index.ts` contains `import './signal.js';` Otherwise continue. Every step below is safe to re-run. -### 1. Fetch the skill branch +### 1. Fetch the channels branch ```bash -git fetch origin skill/signal +git fetch origin channels ``` ### 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 +git show origin/channels:src/channels/signal.ts > src/channels/signal.ts +git show origin/channels:src/channels/signal.test.ts > src/channels/signal.test.ts ``` ### 3. Append the self-registration import @@ -59,30 +61,31 @@ No npm packages to install — the adapter uses only Node.js builtins (`node:net Add to `.env`: -```env +```bash SIGNAL_ACCOUNT=+1YOURNUMBER ``` ### Optional settings -```env +```bash # TCP daemon host and port (default: 127.0.0.1:7583) -SIGNAL_HTTP_HOST=127.0.0.1 -SIGNAL_HTTP_PORT=7583 +SIGNAL_TCP_HOST=127.0.0.1 +SIGNAL_TCP_PORT=7583 -# Whether NanoClaw manages the daemon lifecycle (default: true) -# Set to false if you run signal-cli daemon externally +# Path to the signal-cli binary (default: resolved on PATH) +SIGNAL_CLI_PATH=/usr/local/bin/signal-cli + +# 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 +**Security note:** keep the TCP host on `127.0.0.1`. The daemon has no auth — binding it to a public interface would expose your full Signal account to the network. -```bash -mkdir -p data/env && cp .env data/env/env -``` +Sync to container: `mkdir -p data/env && cp .env data/env/env` ### Restart @@ -96,26 +99,50 @@ 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: +If you're in the middle of `/setup`, return to the setup flow now. -- **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:` — 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. +Otherwise, run `/init-first-agent` to create an agent and wire it to your Signal DM, or `/manage-channels` to wire this channel to an existing agent group. Signal is direct-addressable — your phone number is the platform ID. ## Channel Info -| Field | Value | -|-------|-------| -| **Type** | `signal` | -| **Thread support** | No (Signal has no thread model) | -| **Platform ID format** | DM: `+15555550123` / Group: `group:` | -| **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 | +- **type**: `signal` +- **terminology**: Signal has "chats" (1:1 DMs) and "groups." +- **how-to-find-id**: DMs use your phone number (e.g. `+15555550123`). Groups use `group:` — find group IDs via `signal-cli -a +1YOURNUMBER listGroups`. +- **supports-threads**: no +- **typical-use**: Personal assistant via Signal DMs or small group chats +- **default-isolation**: One agent per Signal account. Multiple chats with the same operator can share an agent group; groups with other people should typically be separate. -### Voice Messages +### Features -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. +- Markdown formatting — `**bold**`, `*italic*` / `_italic_`, `` `code` ``, ` ```code fence``` `, `~~strike~~`, `||spoiler||` (converted to Signal's offset-based text styles) +- Quoted replies — `replyTo*` fields populated from Signal quotes +- Typing indicators — DMs only (Signal doesn't support group typing) +- Echo suppression — outbound messages are matched on `(platformId, text)` within a 10 s TTL to avoid syncMessage loops +- Note to Self — messages you send to your own account from another device route to the agent as inbound with `isFromMe: true` +- Voice attachments — detected but not transcribed by default; the agent receives `[Voice Message]` placeholder text. Run `/add-voice-transcription` for local transcription via parakeet-mlx + +Not supported yet: outbound file attachments (logged and dropped), edit/delete messages, reactions. + +## Troubleshooting + +### Daemon not reachable + +```bash +grep "Signal" logs/nanoclaw.log | tail +``` + +If you see `Signal daemon failed to start. Is signal-cli installed and your account linked?`: +- Confirm `signal-cli` is on PATH (or set `SIGNAL_CLI_PATH`) +- Confirm the account is linked: `signal-cli -a +1YOURNUMBER listIdentities` should succeed without prompting + +If you see `Signal daemon not reachable at 127.0.0.1:7583` and `SIGNAL_MANAGE_DAEMON=false`, start the daemon yourself: `signal-cli -a +1YOURNUMBER daemon --tcp 127.0.0.1:7583`. + +### Bot not responding + +1. Channel initialized: `grep "Signal channel connected" logs/nanoclaw.log | tail -1` +2. Channel wired: `sqlite3 data/v2.db "SELECT mg.platform_id, mg.name FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id = mga.messaging_group_id WHERE mg.channel_type='signal'"` +3. Service running: `launchctl print gui/$(id -u)/com.nanoclaw` (macOS) / `systemctl --user status nanoclaw` (Linux) + +### Lost connection mid-session + +If you see `Signal channel lost TCP connection to signal-cli daemon` in the logs, the daemon dropped us. There's no auto-reconnect yet — restart the service to re-establish. diff --git a/.claude/skills/add-signal/VERIFY.md b/.claude/skills/add-signal/VERIFY.md new file mode 100644 index 0000000..b1ae851 --- /dev/null +++ b/.claude/skills/add-signal/VERIFY.md @@ -0,0 +1,5 @@ +# Verify Signal + +Send a message to your own Signal number (Note to Self) from another device, or have someone send your linked number a DM. The bot should respond within a few seconds. + +If nothing happens, tail `logs/nanoclaw.log` for `Signal channel connected` and `Signal message received`. diff --git a/src/channels/signal.test.ts b/src/channels/signal.test.ts index c7ffff1..f5dabfa 100644 --- a/src/channels/signal.test.ts +++ b/src/channels/signal.test.ts @@ -583,6 +583,165 @@ describe('SignalAdapter', () => { await adapter.teardown(); }); + + it('tracks nested styles with correct offsets', async () => { + const adapter = createAdapter(); + await adapter.setup(createMockSetup()); + tcpRef.fakeSocket.write.mockClear(); + + await adapter.deliver('+15555550123', null, { + kind: 'text', + content: { text: '**bold with `code` inside**' }, + }); + + const sendCalls = getRpcCallsForMethod('send'); + const last = sendCalls[sendCalls.length - 1]; + expect(last.params.message).toBe('bold with code inside'); + // BOLD covers the full inner span, MONOSPACE points at "code" in the + // final plain text (offset 10, length 4) — not the intermediate text. + const styles = (last.params.textStyle as string[]).slice().sort(); + expect(styles).toEqual(['0:21:BOLD', '10:4:MONOSPACE']); + + await adapter.teardown(); + }); + + it('maps *single-asterisk* to ITALIC', async () => { + const adapter = createAdapter(); + await adapter.setup(createMockSetup()); + tcpRef.fakeSocket.write.mockClear(); + + await adapter.deliver('+15555550123', null, { + kind: 'text', + content: { text: 'Hello *world*' }, + }); + + const sendCalls = getRpcCallsForMethod('send'); + const last = sendCalls[sendCalls.length - 1]; + expect(last.params.message).toBe('Hello world'); + expect(last.params.textStyle).toEqual(['6:5:ITALIC']); + + await adapter.teardown(); + }); + + it('maps _underscore_ to ITALIC', async () => { + const adapter = createAdapter(); + await adapter.setup(createMockSetup()); + tcpRef.fakeSocket.write.mockClear(); + + await adapter.deliver('+15555550123', null, { + kind: 'text', + content: { text: 'hey _there_' }, + }); + + const sendCalls = getRpcCallsForMethod('send'); + const last = sendCalls[sendCalls.length - 1]; + expect(last.params.message).toBe('hey there'); + expect(last.params.textStyle).toEqual(['4:5:ITALIC']); + + await adapter.teardown(); + }); + }); + + // --- Echo cache --- + + describe('echo cache', () => { + it('does not drop same-text inbound from a different recipient', async () => { + // Bot sends "Hello" to Alice. Immediately after, Bob sends "Hello" from + // a different DM. Bob's message must still route — the earlier echo key + // was scoped to Alice. + const adapter = createAdapter(); + const cfg = createMockSetup(); + await adapter.setup(cfg); + + await adapter.deliver('+15555550123', null, { + kind: 'text', + content: { text: 'Hello' }, + }); + + pushEvent({ + sourceNumber: '+15555550999', + sourceName: 'Bob', + dataMessage: { timestamp: 1700000000000, message: 'Hello' }, + }); + + await new Promise((r) => setTimeout(r, 50)); + expect(cfg.onInbound).toHaveBeenCalledWith( + '+15555550999', + null, + expect.objectContaining({ + content: expect.objectContaining({ text: 'Hello', sender: '+15555550999' }), + }), + ); + + await adapter.teardown(); + }); + + it('still skips echo on the same recipient', async () => { + const adapter = createAdapter(); + const cfg = createMockSetup(); + await adapter.setup(cfg); + + await adapter.deliver('+15555550123', null, { + kind: 'text', + content: { text: 'Echo test' }, + }); + + pushEvent({ + sourceNumber: '+15555550123', + dataMessage: { timestamp: 1700000000000, message: 'Echo test' }, + }); + + await new Promise((r) => setTimeout(r, 50)); + expect(cfg.onInbound).not.toHaveBeenCalled(); + + await adapter.teardown(); + }); + }); + + // --- Connection drop --- + + describe('connection drop', () => { + it('flips isConnected to false when the socket closes', async () => { + const adapter = createAdapter(); + await adapter.setup(createMockSetup()); + expect(adapter.isConnected()).toBe(true); + + // Simulate the daemon dropping the TCP connection. + tcpRef.fakeSocket.destroy(); + await new Promise((r) => setTimeout(r, 20)); + + expect(adapter.isConnected()).toBe(false); + + await adapter.teardown(); + }); + }); + + // --- Outbound files --- + + describe('outbound files', () => { + it('logs a warning and drops unsupported file attachments', async () => { + const { log } = await import('../log.js'); + const warnMock = log.warn as unknown as ReturnType; + + const adapter = createAdapter(); + await adapter.setup(createMockSetup()); + warnMock.mockClear(); + + await adapter.deliver('+15555550123', null, { + kind: 'text', + content: { text: 'with an attachment' }, + files: [{ filename: 'hi.txt', data: Buffer.from('hi') }], + }); + + const sendCalls = getRpcCallsForMethod('send'); + expect(sendCalls.length).toBeGreaterThan(0); + expect(warnMock).toHaveBeenCalledWith( + 'Signal: outbound files not supported, dropping', + expect.objectContaining({ platformId: '+15555550123', count: 1 }), + ); + + await adapter.teardown(); + }); }); // --- setTyping --- diff --git a/src/channels/signal.ts b/src/channels/signal.ts index 300b7a6..20cba81 100644 --- a/src/channels/signal.ts +++ b/src/channels/signal.ts @@ -8,7 +8,7 @@ * Ported from v1 — see v1 source for commit history. */ import { execFileSync, spawn } from 'node:child_process'; -import { readFileSync, existsSync } from 'node:fs'; +import { existsSync } from 'node:fs'; import { createConnection, type Socket } from 'node:net'; import { homedir } from 'node:os'; import { join } from 'node:path'; @@ -100,14 +100,19 @@ class SignalTcpClient { } >(); private onNotification: ((method: string, params: unknown) => void) | null = null; + private onClose: (() => void) | null = null; constructor( private host: string, private port: number, ) {} - connect(onNotification?: (method: string, params: unknown) => void): Promise { - this.onNotification = onNotification ?? null; + connect(handlers?: { + onNotification?: (method: string, params: unknown) => void; + onClose?: () => void; + }): Promise { + this.onNotification = handlers?.onNotification ?? null; + this.onClose = handlers?.onClose ?? null; return new Promise((resolve, reject) => { const sock = createConnection(this.port, this.host, () => { this.socket = sock; @@ -122,12 +127,14 @@ class SignalTcpClient { }); sock.on('data', (chunk) => this.onData(chunk)); sock.on('close', () => { + const wasConnected = this.socket !== null; this.socket = null; for (const [, p] of this.pending) { clearTimeout(p.timer); p.reject(new Error('Signal TCP connection closed')); } this.pending.clear(); + if (wasConnected) this.onClose?.(); }); }); } @@ -201,15 +208,17 @@ class SignalTcpClient { async function signalTcpCheck(host: string, port: number): Promise { return new Promise((resolve) => { - const sock = createConnection(port, host, () => { + let settled = false; + const finish = (result: boolean) => { + if (settled) return; + settled = true; + clearTimeout(timer); sock.destroy(); - resolve(true); - }); - sock.on('error', () => resolve(false)); - setTimeout(() => { - sock.destroy(); - resolve(false); - }, 5000); + resolve(result); + }; + const sock = createConnection(port, host, () => finish(true)); + sock.on('error', () => finish(false)); + const timer = setTimeout(() => finish(false), 5000); }); } @@ -219,19 +228,35 @@ async function signalTcpCheck(host: string, port: number): Promise { const ECHO_TTL_MS = 10_000; +/** + * Per-recipient dedup for messages we sent ourselves. + * + * signal-cli echoes our own outbound back via syncMessage (and, for Note to + * Self, via sentMessage-with-self-destination). Without dedup, the agent sees + * its own replies as new inbound and loops. We remember `(platformId, text)` + * briefly after every send, and drop the first match within TTL. + * + * Keying on text alone is not enough: if we send "hi" to Alice and Bob then + * sends "hi" from a different chat, Bob's real message gets silently dropped. + */ class EchoCache { private entries = new Map(); - remember(text: string) { - const key = text.trim(); - if (!key) return; - this.entries.set(key, Date.now()); + private keyFor(platformId: string, text: string): string { + return `${platformId}\x00${text.trim()}`; + } + + remember(platformId: string, text: string): void { + const trimmed = text.trim(); + if (!trimmed) return; + this.entries.set(this.keyFor(platformId, trimmed), Date.now()); this.cleanup(); } - isEcho(text: string): boolean { - const key = text.trim(); - if (!key) return false; + isEcho(platformId: string, text: string): boolean { + const trimmed = text.trim(); + if (!trimmed) return false; + const key = this.keyFor(platformId, trimmed); const ts = this.entries.get(key); if (!ts) return false; if (Date.now() - ts > ECHO_TTL_MS) { @@ -242,7 +267,7 @@ class EchoCache { return true; } - private cleanup() { + private cleanup(): void { const now = Date.now(); for (const [key, ts] of this.entries) { if (now - ts > ECHO_TTL_MS) this.entries.delete(key); @@ -325,49 +350,61 @@ interface StyledText { textStyles: SignalTextStyle[]; } +/** + * Convert Markdown-ish input to Signal's offset-based style ranges. + * + * Walks the input recursively: at each level we find the leftmost matching + * pattern, descend into its captured inner text (so `**bold with \`code\` + * inside**` stays bold-plus-monospace rather than leaking stripped markers), + * then continue past the match. Style offsets are recorded against the + * *output* text length as it's built, so nested styles always point at the + * right span of the final plain text. + */ function parseSignalStyles(input: string): StyledText { const styles: SignalTextStyle[] = []; - 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' }, + // Ordering matters: longer/greedier delimiters first so `` ``` `` beats + // `` ` ``, `**` beats `*`. The italic-`*` pattern refuses to start on + // whitespace so `*` isn't mistakenly opened on " * " in list-like text. + const patterns: Array<{ regex: RegExp; style: SignalTextStyle['style'] }> = [ + { regex: /```([\s\S]+?)```/, style: 'MONOSPACE' }, + { regex: /`([^`]+)`/, style: 'MONOSPACE' }, + { regex: /\*\*([^]+?)\*\*/, style: 'BOLD' }, + { regex: /~~([^]+?)~~/, style: 'STRIKETHROUGH' }, + { regex: /\|\|([^]+?)\|\|/, style: 'SPOILER' }, + { regex: /\*([^*\s][^*]*?)\*/, style: 'ITALIC' }, + { regex: /_([^_\s][^_]*?)_/, style: 'ITALIC' }, ]; - 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; + function walk(segment: string, outputBase: number): string { + let earliest: { start: number; match: RegExpExecArray; style: SignalTextStyle['style'] } | null = null; + for (const { regex, style } of patterns) { + const m = regex.exec(segment); + if (!m) continue; + if (earliest === null || m.index < earliest.start) { + earliest = { start: m.index, match: m, style }; + } } + if (!earliest) return segment; - nextText.push(text.slice(lastIndex)); - text = nextText.join(''); + const before = segment.slice(0, earliest.start); + const fullMatch = earliest.match[0]; + const inner = earliest.match[1]; + const afterStart = earliest.start + fullMatch.length; + const after = segment.slice(afterStart); + + const innerOut = walk(inner, outputBase + before.length); + styles.push({ + style: earliest.style, + start: outputBase + before.length, + length: innerOut.length, + }); + const afterOut = walk(after, outputBase + before.length + innerOut.length); + + return before + innerOut + afterOut; } + const text = walk(input, 0); return { text, textStyles: styles }; } @@ -421,8 +458,8 @@ export function createSignalAdapter(config: { if (dest === config.account) { const text = (syncSent.message ?? '').trim(); if (!text) return; - if (echoCache.isEcho(text)) return; const platformId = config.account; + if (echoCache.isEcho(platformId, text)) return; const timestamp = syncSent.timestamp ? new Date(syncSent.timestamp).toISOString() : new Date().toISOString(); setup.onMetadata(platformId, 'Note to Self', false); @@ -460,17 +497,17 @@ export function createSignalAdapter(config: { 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; + + if (text && echoCache.isEcho(platformId, text)) { + log.debug('Signal: skipping echo', { platformId }); + return; + } const timestamp = dataMessage.timestamp ? new Date(dataMessage.timestamp).toISOString() : new Date().toISOString(); const chatName = groupInfo?.groupName ?? (isGroup ? `Group ${groupId?.slice(0, 8)}` : senderName); @@ -534,7 +571,7 @@ export function createSignalAdapter(config: { async function sendText(platformId: string, text: string): Promise { if (!connected || !tcp) return; - echoCache.remember(text); + echoCache.remember(platformId, text); const MAX_CHUNK = 4000; const chunks = text.length <= MAX_CHUNK ? [text] : chunkText(text, MAX_CHUNK); @@ -617,7 +654,22 @@ export function createSignalAdapter(config: { } tcp = new SignalTcpClient(config.tcpHost, config.tcpPort); - await tcp.connect(handleNotification); + await tcp.connect({ + onNotification: handleNotification, + // Signal the adapter that the daemon dropped us. No auto-reconnect yet + // — subsequent deliver/setTyping calls short-circuit on `connected` + // and log rather than throw into the retry loop. Operators see this in + // logs/nanoclaw.log and can restart the service. + onClose: () => { + if (!connected) return; + connected = false; + log.warn('Signal channel lost TCP connection to signal-cli daemon', { + account: config.account, + host: config.tcpHost, + port: config.tcpPort, + }); + }, + }); try { await tcp.rpc('updateProfile', { @@ -662,6 +714,17 @@ export function createSignalAdapter(config: { }, async deliver(platformId: string, _threadId: string | null, message: OutboundMessage): Promise { + if (message.files && message.files.length > 0) { + // Native adapter doesn't yet forward file uploads to signal-cli's + // `send --attachment`. Don't silently swallow — operators need to see + // that an attachment was requested but not sent. + log.warn('Signal: outbound files not supported, dropping', { + platformId, + count: message.files.length, + filenames: message.files.map((f) => f.filename), + }); + } + const content = message.content as Record | string | undefined; let text: string | null = null; if (typeof content === 'string') { @@ -703,8 +766,9 @@ registerChannelAdapter('signal', { factory: () => { const envVars = readEnvFile([ 'SIGNAL_ACCOUNT', - 'SIGNAL_HTTP_HOST', - 'SIGNAL_HTTP_PORT', + 'SIGNAL_TCP_HOST', + 'SIGNAL_TCP_PORT', + 'SIGNAL_CLI_PATH', 'SIGNAL_MANAGE_DAEMON', 'SIGNAL_DATA_DIR', ]); @@ -715,14 +779,17 @@ registerChannelAdapter('signal', { 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 cliPath = process.env.SIGNAL_CLI_PATH || envVars.SIGNAL_CLI_PATH || 'signal-cli'; + const tcpHost = process.env.SIGNAL_TCP_HOST || envVars.SIGNAL_TCP_HOST || DEFAULT_TCP_HOST; + const tcpPort = parseInt(process.env.SIGNAL_TCP_PORT || envVars.SIGNAL_TCP_PORT || String(DEFAULT_TCP_PORT), 10); const manageDaemon = (process.env.SIGNAL_MANAGE_DAEMON || envVars.SIGNAL_MANAGE_DAEMON || 'true') === 'true'; const signalDataDir = process.env.SIGNAL_DATA_DIR || envVars.SIGNAL_DATA_DIR || join(homedir(), '.local', 'share', 'signal-cli'); + // Only check for `signal-cli` on PATH when the operator left cliPath at + // the default AND asked us to manage the daemon. A custom absolute path + // is treated as an explicit promise and spawn will surface its own ENOENT. if (manageDaemon && cliPath === 'signal-cli') { try { execFileSync('which', ['signal-cli'], { stdio: 'ignore' });