diff --git a/docs/configuration.md b/docs/configuration.md index 234aeaa..562b38d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -92,6 +92,7 @@ channels: signal: enabled: true phone: "+1234567890" + readReceipts: true selfChat: true dmPolicy: pairing @@ -239,6 +240,7 @@ agents: channels: signal: phone: "+1234567890" + readReceipts: true selfChat: true whatsapp: enabled: true @@ -521,6 +523,7 @@ For dedicated bot numbers (`selfChat: false`), onboarding defaults to **allowlis | Option | Type | Description | |--------|------|-------------| | `phone` | string | Phone number with + prefix | +| `readReceipts` | boolean | Send read receipts for incoming messages (default: `true`) | | `selfChat` | boolean | `true` = only "Note to Self" works | ## Features Configuration @@ -1052,6 +1055,7 @@ Reference: | `WHATSAPP_ENABLED` | `channels.whatsapp.enabled` | | `WHATSAPP_SELF_CHAT_MODE` | `channels.whatsapp.selfChat` | | `SIGNAL_PHONE_NUMBER` | `channels.signal.phone` | +| `SIGNAL_READ_RECEIPTS` | `channels.signal.readReceipts` | | `OPENAI_API_KEY` | `transcription.apiKey` | | `GMAIL_ACCOUNT` | `polling.gmail.account` (comma-separated list allowed) | | `POLLING_INTERVAL_MS` | `polling.intervalMs` | diff --git a/docs/signal-setup.md b/docs/signal-setup.md index 7be96c4..ce412fd 100644 --- a/docs/signal-setup.md +++ b/docs/signal-setup.md @@ -79,6 +79,9 @@ SIGNAL_PHONE_NUMBER=+17075204676 # Optional: Self-chat mode for "Note to Self" (default: true) # SIGNAL_SELF_CHAT_MODE=true + +# Optional: Send read receipts for incoming messages (default: true) +# SIGNAL_READ_RECEIPTS=true ``` **Note:** For personal numbers (`selfChatMode: true`), `dmPolicy` is ignored - only you can message via "Note to Self". For dedicated bot numbers, onboarding defaults to `allowlist`. @@ -97,6 +100,7 @@ The daemon runs on port 8090 by default to avoid conflicts with other services. - **Direct Messages** - Receive and respond to DMs - **Note to Self** - Use Signal's "Note to Self" feature to message yourself (selfChatMode) - **Allowlist** - For dedicated numbers, only pre-approved phone numbers can message +- **Read Receipts** - Enabled by default (disable with `SIGNAL_READ_RECEIPTS=false`) ## Troubleshooting diff --git a/src/channels/factory.ts b/src/channels/factory.ts index 2f05b3c..053c185 100644 --- a/src/channels/factory.ts +++ b/src/channels/factory.ts @@ -94,6 +94,7 @@ const SHARED_CHANNEL_BUILDERS: SharedChannelBuilder[] = [ cliPath: signal.cliPath || process.env.SIGNAL_CLI_PATH || 'signal-cli', httpHost: signal.httpHost || process.env.SIGNAL_HTTP_HOST || '127.0.0.1', httpPort: signal.httpPort || parseInt(process.env.SIGNAL_HTTP_PORT || '8090', 10), + readReceipts: signal.readReceipts ?? (process.env.SIGNAL_READ_RECEIPTS !== 'false'), dmPolicy: signal.dmPolicy || 'pairing', allowedUsers: nonEmpty(signal.allowedUsers), selfChatMode, diff --git a/src/channels/signal.test.ts b/src/channels/signal.test.ts index 499eff3..98c5f46 100644 --- a/src/channels/signal.test.ts +++ b/src/channels/signal.test.ts @@ -1,6 +1,13 @@ import { describe, it, expect, vi } from 'vitest'; import { SignalAdapter } from './signal.js'; +type SignalAdapterWithInternals = { + config: { + readReceipts?: boolean; + }; + buildDaemonArgs: () => string[]; +}; + describe('SignalAdapter sendFile', () => { function createAdapter(phone = '+15555555555') { return new SignalAdapter({ phoneNumber: phone }); @@ -87,3 +94,22 @@ describe('SignalAdapter sendFile', () => { expect(result.messageId).toBe('unknown'); }); }); + +describe('SignalAdapter read receipts', () => { + it('defaults readReceipts to true', () => { + const adapter = new SignalAdapter({ phoneNumber: '+15555555555' }); + const internal = adapter as unknown as SignalAdapterWithInternals; + expect(internal.config.readReceipts).toBe(true); + expect(internal.buildDaemonArgs()).toContain('--send-read-receipts'); + }); + + it('omits daemon read receipts flag when disabled', () => { + const adapter = new SignalAdapter({ + phoneNumber: '+15555555555', + readReceipts: false, + }); + const internal = adapter as unknown as SignalAdapterWithInternals; + expect(internal.config.readReceipts).toBe(false); + expect(internal.buildDaemonArgs()).not.toContain('--send-read-receipts'); + }); +}); diff --git a/src/channels/signal.ts b/src/channels/signal.ts index c2e79e5..763a9d3 100644 --- a/src/channels/signal.ts +++ b/src/channels/signal.ts @@ -35,6 +35,7 @@ export interface SignalConfig { httpHost?: string; // Daemon HTTP host (default: "127.0.0.1") httpPort?: number; // Daemon HTTP port (default: 8090) startupTimeoutMs?: number; // Max time to wait for daemon startup (default: 30000) + readReceipts?: boolean; // Send read receipts for incoming messages (default: true) // Security dmPolicy?: DmPolicy; // 'pairing' (default), 'allowlist', or 'open' allowedUsers?: string[]; // Phone numbers (config allowlist) @@ -171,6 +172,7 @@ export class SignalAdapter implements ChannelAdapter { this.config = { ...config, dmPolicy: config.dmPolicy || 'pairing', + readReceipts: config.readReceipts !== false, // Default true selfChatMode: config.selfChatMode !== false, // Default true }; const host = config.httpHost || '127.0.0.1'; @@ -394,21 +396,28 @@ This code expires in 1 hour.`; } // --- Private methods --- - - private async startDaemon(): Promise { - const cliPath = this.config.cliPath || 'signal-cli'; - const host = this.config.httpHost || '127.0.0.1'; - const port = this.config.httpPort || 8090; - + + private buildDaemonArgs(): string[] { const args: string[] = []; - + if (this.config.phoneNumber) { args.push('-a', this.config.phoneNumber); } - + args.push('daemon'); - args.push('--http', `${host}:${port}`); + args.push('--http', `${this.config.httpHost || '127.0.0.1'}:${this.config.httpPort || 8090}`); args.push('--no-receive-stdout'); + + if (this.config.readReceipts !== false) { + args.push('--send-read-receipts'); + } + + return args; + } + + private async startDaemon(): Promise { + const cliPath = this.config.cliPath || 'signal-cli'; + const args = this.buildDaemonArgs(); log.info(`Spawning: ${cliPath} ${args.join(' ')}`); diff --git a/src/config/io.ts b/src/config/io.ts index 7c8bb63..48426a5 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -379,6 +379,10 @@ export function configToEnv(config: LettaBotConfig): Record { } if (config.channels.signal?.phone) { env.SIGNAL_PHONE_NUMBER = config.channels.signal.phone; + // Signal readReceipts defaults to true, so only set env if explicitly false + if (config.channels.signal.readReceipts === false) { + env.SIGNAL_READ_RECEIPTS = 'false'; + } // Signal selfChat defaults to true, so only set env if explicitly false if (config.channels.signal.selfChat === false) { env.SIGNAL_SELF_CHAT_MODE = 'false'; diff --git a/src/config/normalize.test.ts b/src/config/normalize.test.ts index 4dcbca7..b6e7470 100644 --- a/src/config/normalize.test.ts +++ b/src/config/normalize.test.ts @@ -27,7 +27,7 @@ describe('normalizeAgents', () => { 'TELEGRAM_BOT_TOKEN', 'TELEGRAM_DM_POLICY', 'TELEGRAM_ALLOWED_USERS', 'SLACK_BOT_TOKEN', 'SLACK_APP_TOKEN', 'SLACK_DM_POLICY', 'SLACK_ALLOWED_USERS', 'WHATSAPP_ENABLED', 'WHATSAPP_SELF_CHAT_MODE', 'WHATSAPP_DM_POLICY', 'WHATSAPP_ALLOWED_USERS', - 'SIGNAL_PHONE_NUMBER', 'SIGNAL_SELF_CHAT_MODE', 'SIGNAL_DM_POLICY', 'SIGNAL_ALLOWED_USERS', + 'SIGNAL_PHONE_NUMBER', 'SIGNAL_SELF_CHAT_MODE', 'SIGNAL_READ_RECEIPTS', 'SIGNAL_DM_POLICY', 'SIGNAL_ALLOWED_USERS', 'DISCORD_BOT_TOKEN', 'DISCORD_DM_POLICY', 'DISCORD_ALLOWED_USERS', 'BLUESKY_WANTED_DIDS', 'BLUESKY_WANTED_COLLECTIONS', 'BLUESKY_JETSTREAM_URL', 'BLUESKY_CURSOR', 'BLUESKY_HANDLE', 'BLUESKY_APP_PASSWORD', 'BLUESKY_SERVICE_URL', 'BLUESKY_APPVIEW_URL', @@ -594,9 +594,25 @@ describe('normalizeAgents', () => { expect(agents[0].channels.slack?.appToken).toBe('slack-app'); expect(agents[0].channels.whatsapp?.enabled).toBe(true); expect(agents[0].channels.signal?.phone).toBe('+1234567890'); + expect(agents[0].channels.signal?.readReceipts).toBe(true); expect(agents[0].channels.discord?.token).toBe('discord-token'); }); + it('should allow disabling Signal read receipts via env var', () => { + process.env.SIGNAL_PHONE_NUMBER = '+1234567890'; + process.env.SIGNAL_READ_RECEIPTS = 'false'; + + const config: LettaBotConfig = { + server: { mode: 'cloud' }, + agent: { name: 'TestBot', model: 'test' }, + channels: {}, + }; + + const agents = normalizeAgents(config); + + expect(agents[0].channels.signal?.readReceipts).toBe(false); + }); + it('should pick up allowedUsers from env vars for all channels', () => { process.env.TELEGRAM_BOT_TOKEN = 'tg-token'; process.env.TELEGRAM_DM_POLICY = 'allowlist'; diff --git a/src/config/types.ts b/src/config/types.ts index 3cdea95..a21a41f 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -374,6 +374,7 @@ export interface SignalConfig { cliPath?: string; // Path to signal-cli binary (default: "signal-cli") httpHost?: string; // Daemon HTTP host (default: "127.0.0.1") httpPort?: number; // Daemon HTTP port (default: 8090) + readReceipts?: boolean; // Send read receipts for incoming messages (default: true) selfChat?: boolean; dmPolicy?: 'pairing' | 'allowlist' | 'open'; allowedUsers?: string[]; @@ -707,6 +708,7 @@ export function normalizeAgents(config: LettaBotConfig): AgentConfig[] { channels.signal = { enabled: true, phone: process.env.SIGNAL_PHONE_NUMBER, + readReceipts: process.env.SIGNAL_READ_RECEIPTS !== 'false', selfChat: process.env.SIGNAL_SELF_CHAT_MODE !== 'false', dmPolicy: (process.env.SIGNAL_DM_POLICY as 'pairing' | 'allowlist' | 'open') || 'pairing', allowedUsers: parseList(process.env.SIGNAL_ALLOWED_USERS),