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) <noreply@anthropic.com>
This commit is contained in:
gavrielc
2026-04-23 22:54:27 +03:00
parent 57eeed6cb6
commit 5f3bd9c880
5 changed files with 375 additions and 104 deletions

View File

@@ -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 <id>
```
(Find the device id with `signal-cli -a +1YOURNUMBER listDevices`.)

View File

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

View File

@@ -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`.

View File

@@ -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<typeof vi.fn>;
const adapter = createAdapter();
await adapter.setup(createMockSetup());
warnMock.mockClear();
await adapter.deliver('+15555550123', null, {
kind: 'text',
content: { text: 'with an attachment' },
files: [{ filename: 'hi.txt', data: Buffer.from('hi') }],
});
const sendCalls = getRpcCallsForMethod('send');
expect(sendCalls.length).toBeGreaterThan(0);
expect(warnMock).toHaveBeenCalledWith(
'Signal: outbound files not supported, dropping',
expect.objectContaining({ platformId: '+15555550123', count: 1 }),
);
await adapter.teardown();
});
});
// --- setTyping ---

View File

@@ -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<void> {
this.onNotification = onNotification ?? null;
connect(handlers?: {
onNotification?: (method: string, params: unknown) => void;
onClose?: () => void;
}): Promise<void> {
this.onNotification = handlers?.onNotification ?? null;
this.onClose = handlers?.onClose ?? null;
return new Promise((resolve, reject) => {
const sock = createConnection(this.port, this.host, () => {
this.socket = sock;
@@ -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<boolean> {
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<boolean> {
const ECHO_TTL_MS = 10_000;
/**
* Per-recipient dedup for messages we sent ourselves.
*
* signal-cli echoes our own outbound back via syncMessage (and, for Note to
* Self, via sentMessage-with-self-destination). Without dedup, the agent sees
* its own replies as new inbound and loops. We remember `(platformId, text)`
* briefly after every send, and drop the first match within TTL.
*
* Keying on text alone is not enough: if we send "hi" to Alice and Bob then
* sends "hi" from a different chat, Bob's real message gets silently dropped.
*/
class EchoCache {
private entries = new Map<string, number>();
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<void> {
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<string | undefined> {
if (message.files && message.files.length > 0) {
// Native adapter doesn't yet forward file uploads to signal-cli's
// `send --attachment`. Don't silently swallow — operators need to see
// that an attachment was requested but not sent.
log.warn('Signal: outbound files not supported, dropping', {
platformId,
count: message.files.length,
filenames: message.files.map((f) => f.filename),
});
}
const content = message.content as Record<string, unknown> | string | undefined;
let text: string | null = null;
if (typeof content === 'string') {
@@ -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' });