import { execFile } from 'child_process'; import type { InboundMessage, OutboundMessage, ChannelAdapter, ChannelStatus, } from '../types.js'; import { allowTrustedOrPairedSender, buildResetInboundMessage, isAllowedByAllowlist, normalizeResetCommandText, shouldIgnoreForMissingMention, splitMessage, } from '../utils.js'; import type { PairingManager } from '../pairing.js'; export interface SignalAdapterConfig { /** Primary Signal account identifier used by signal-cli (-u). */ account: string; /** Path to signal-cli binary. */ signalCliPath?: string; /** Allowed direct-message sender numbers. Empty/undefined = allow all DMs. */ allowedNumbers?: string[]; /** Allowed group IDs. Empty/undefined = no groups allowed. */ allowedGroupIds?: string[]; /** Require mention in group chats (default: true). */ requireMention?: boolean; /** Mention token used for group mention detection (default: flynn). */ mentionName?: string; /** Poll interval for receive loop (default: 5000ms). */ pollIntervalMs?: number; /** Timeout for send/receive CLI calls (default: 15000ms). */ sendTimeoutMs?: number; /** Optional pairing manager for DM pairing codes. */ pairingManager?: PairingManager; } interface SignalEnvelope { envelope?: { source?: string; sourceName?: string; timestamp?: number; dataMessage?: { message?: string; body?: string; groupInfo?: { groupId?: string }; groupId?: string; }; }; } const MAX_MESSAGE_LENGTH = 3500; const DEFAULT_POLL_INTERVAL_MS = 5000; const DEFAULT_TIMEOUT_MS = 15000; export class SignalAdapter implements ChannelAdapter { readonly name = 'signal'; private _status: ChannelStatus = 'disconnected'; private messageHandler?: (msg: InboundMessage) => void; private readonly config: SignalAdapterConfig; private pollTimer: NodeJS.Timeout | null = null; private polling = false; get status(): ChannelStatus { return this._status; } constructor(config: SignalAdapterConfig) { this.config = config; } onMessage(handler: (msg: InboundMessage) => void): void { this.messageHandler = handler; } async connect(): Promise { this._status = 'connecting'; try { await this.execSignal(['--version']); this._status = 'connected'; const interval = this.config.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS; this.pollTimer = setInterval(() => { void this.pollOnce(); }, interval); void this.pollOnce(); console.log(`Signal adapter connected (${this.config.account})`); } catch (error) { this._status = 'error'; throw error; } } async disconnect(): Promise { if (this.pollTimer) { clearInterval(this.pollTimer); this.pollTimer = null; } this._status = 'disconnected'; } async send(peerId: string, message: OutboundMessage): Promise { if (this._status !== 'connected') { throw new Error('Signal adapter not connected'); } const text = message.text.trim(); if (text.length > 0) { const chunks = text.length > MAX_MESSAGE_LENGTH ? splitMessage(text, MAX_MESSAGE_LENGTH) : [text]; for (const chunk of chunks) { await this.sendText(peerId, chunk); } } if (message.attachments && message.attachments.length > 0) { for (const a of message.attachments) { if (a.url) { const line = a.filename ? `${a.filename}: ${a.url}` : a.url; await this.sendText(peerId, line); } else if (a.data) { // Keep adapter minimal and robust: no temp-file attachment upload in this pass. console.warn(`Signal: skipping attachment data (${a.mimeType}) — upload not implemented`); } } } } private async sendText(peerId: string, text: string): Promise { if (!text.trim()) { return; } const args = ['-u', this.config.account, 'send', '-m', text]; const groupId = this.extractGroupId(peerId); if (groupId) { args.push('-g', groupId); } else { args.push(peerId); } await this.execSignal(args); } private async pollOnce(): Promise { if (this.polling || !this.messageHandler || this._status !== 'connected') { return; } this.polling = true; try { const output = await this.execSignal([ '-u', this.config.account, '-o', 'json', 'receive', '--timeout', '1', ]); await this.processReceiveOutput(output); } catch (error) { if (this._status === 'connected') { const msg = error instanceof Error ? error.message : String(error); console.warn(`Signal receive failed: ${msg}`); } } finally { this.polling = false; } } private async processReceiveOutput(output: string): Promise { if (!this.messageHandler) { return; } const trimmed = output.trim(); if (!trimmed) { return; } const payloads: unknown[] = []; if (trimmed.startsWith('[')) { try { const parsed = JSON.parse(trimmed); if (Array.isArray(parsed)) { payloads.push(...parsed); } } catch { // Fall through to line-based parsing. } } if (payloads.length === 0) { for (const line of trimmed.split('\n')) { const text = line.trim(); if (!text) { continue; } try { payloads.push(JSON.parse(text)); } catch { // Ignore non-JSON lines from signal-cli output. } } } for (const payload of payloads) { const inbound = await this.toInboundMessage(payload); if (inbound) { this.messageHandler(inbound); } } } private async toInboundMessage(payload: unknown): Promise { const data = payload as SignalEnvelope & Record; const envelope = (data.envelope ?? data) as Record; const dataMessage = (envelope.dataMessage ?? data.dataMessage) as Record | undefined; const rawText = String(dataMessage?.message ?? dataMessage?.body ?? '').trim(); if (!rawText) { return null; } const source = this.normalizeNumber(String(envelope.source ?? '')); const sourceName = typeof envelope.sourceName === 'string' ? envelope.sourceName : undefined; const groupIdRaw = dataMessage?.groupInfo && typeof dataMessage.groupInfo === 'object' ? String((dataMessage.groupInfo as { groupId?: unknown }).groupId ?? '') : String(dataMessage?.groupId ?? ''); const groupId = groupIdRaw.trim(); const isGroup = groupId.length > 0; let text = rawText; let senderId = source; if (isGroup) { if (!this.config.allowedGroupIds || this.config.allowedGroupIds.length === 0) { return null; } if (!this.config.allowedGroupIds.includes(groupId)) { return null; } const mentionName = (this.config.mentionName ?? 'flynn').trim(); const mentionPattern = mentionName.length > 0 ? new RegExp(`(?:^|\\s)@?${escapeRegex(mentionName)}(?:\\b|:)`, 'i') : null; const mentionsBot = mentionPattern ? mentionPattern.test(text) : false; if (shouldIgnoreForMissingMention({ requireMention: this.config.requireMention, defaultRequireMention: true, mentionsBot, })) { return null; } if (mentionPattern) { text = text.replace(new RegExp(`^\\s*@?${escapeRegex(mentionName)}(?:\\b|:)\\s*`, 'i'), '').trim(); } senderId = `group:${groupId}`; } else { if (!source) { return null; } const trusted = isAllowedByAllowlist(source, this.config.allowedNumbers); const allowed = await allowTrustedOrPairedSender({ pairingManager: this.config.pairingManager, channel: 'signal', senderId: source, text, isTrusted: trusted, }); if (!allowed) { return null; } } const normalizedText = normalizeResetCommandText(text); const timestamp = typeof envelope.timestamp === 'number' ? envelope.timestamp : Date.now(); const id = `${senderId}:${timestamp}`; if (normalizedText === '!reset') { return buildResetInboundMessage({ id, channel: 'signal', senderId, senderName: sourceName, timestamp, }); } return { id, channel: 'signal', senderId, senderName: sourceName, text: normalizedText, timestamp, metadata: { source, groupId: groupId || undefined, }, }; } private execSignal(args: string[]): Promise { const command = this.config.signalCliPath ?? 'signal-cli'; const timeout = this.config.sendTimeoutMs ?? DEFAULT_TIMEOUT_MS; return new Promise((resolve, reject) => { execFile(command, args, { timeout }, (error, stdout, stderr) => { if (error) { reject(new Error(`${command} ${args.join(' ')} failed: ${stderr || error.message}`)); return; } resolve(stdout.trim()); }); }); } private extractGroupId(peerId: string): string | null { if (!peerId.startsWith('group:')) { return null; } const groupId = peerId.slice('group:'.length).trim(); return groupId.length > 0 ? groupId : null; } private normalizeNumber(value: string): string { return value.trim().replace(/[^\d+]/g, ''); } } function escapeRegex(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }