Files
flynn/src/channels/signal/adapter.ts
T
2026-02-16 01:54:54 -08:00

333 lines
9.5 KiB
TypeScript

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<void> {
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<void> {
if (this.pollTimer) {
clearInterval(this.pollTimer);
this.pollTimer = null;
}
this._status = 'disconnected';
}
async send(peerId: string, message: OutboundMessage): Promise<void> {
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<void> {
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<void> {
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<void> {
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<InboundMessage | null> {
const data = payload as SignalEnvelope & Record<string, unknown>;
const envelope = (data.envelope ?? data) as Record<string, unknown>;
const dataMessage = (envelope.dataMessage ?? data.dataMessage) as Record<string, unknown> | 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<string> {
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, '\\$&');
}