import { loadConfig } from "../config/config.js"; import { mediaKindFromMime } from "../media/constants.js"; import { saveMediaBuffer } from "../media/store.js"; import { loadWebMedia } from "../web/media.js"; import { signalRpcRequest } from "./client.js"; export type SignalSendOpts = { baseUrl?: string; account?: string; mediaUrl?: string; maxBytes?: number; timeoutMs?: number; }; export type SignalSendResult = { messageId: string; timestamp?: number; }; type SignalTarget = | { type: "recipient"; recipient: string } | { type: "group"; groupId: string } | { type: "username"; username: string }; function resolveBaseUrl(explicit?: string): string { const cfg = loadConfig(); const signalCfg = cfg.signal; if (explicit?.trim()) return explicit.trim(); if (signalCfg?.httpUrl?.trim()) return signalCfg.httpUrl.trim(); const host = signalCfg?.httpHost?.trim() || "127.0.0.1"; const port = signalCfg?.httpPort ?? 8080; return `http://${host}:${port}`; } function resolveAccount(explicit?: string): string | undefined { const cfg = loadConfig(); const signalCfg = cfg.signal; const account = explicit?.trim() || signalCfg?.account?.trim(); return account || undefined; } function parseTarget(raw: string): SignalTarget { let value = raw.trim(); if (!value) throw new Error("Signal recipient is required"); const lower = value.toLowerCase(); if (lower.startsWith("signal:")) { value = value.slice("signal:".length).trim(); } const normalized = value.toLowerCase(); if (normalized.startsWith("group:")) { return { type: "group", groupId: value.slice("group:".length).trim() }; } if (normalized.startsWith("username:")) { return { type: "username", username: value.slice("username:".length).trim(), }; } if (normalized.startsWith("u:")) { return { type: "username", username: value.trim() }; } return { type: "recipient", recipient: value }; } async function resolveAttachment( mediaUrl: string, maxBytes: number, ): Promise<{ path: string; contentType?: string }> { const media = await loadWebMedia(mediaUrl, maxBytes); const saved = await saveMediaBuffer( media.buffer, media.contentType ?? undefined, "outbound", maxBytes, ); return { path: saved.path, contentType: saved.contentType }; } export async function sendMessageSignal( to: string, text: string, opts: SignalSendOpts = {}, ): Promise { const baseUrl = resolveBaseUrl(opts.baseUrl); const account = resolveAccount(opts.account); const target = parseTarget(to); let message = text ?? ""; const maxBytes = opts.maxBytes ?? 8 * 1024 * 1024; let attachments: string[] | undefined; if (opts.mediaUrl?.trim()) { const resolved = await resolveAttachment(opts.mediaUrl.trim(), maxBytes); attachments = [resolved.path]; const kind = mediaKindFromMime(resolved.contentType ?? undefined); if (!message && kind) { // Avoid sending an empty body when only attachments exist. message = kind === "image" ? "" : ``; } } if (!message.trim() && (!attachments || attachments.length === 0)) { throw new Error("Signal send requires text or media"); } const params: Record = { message }; if (account) params.account = account; if (attachments && attachments.length > 0) { params.attachments = attachments; } if (target.type === "recipient") { params.recipient = [target.recipient]; } else if (target.type === "group") { params.groupId = target.groupId; } else if (target.type === "username") { params.username = [target.username]; } const result = await signalRpcRequest<{ timestamp?: number }>( "send", params, { baseUrl, timeoutMs: opts.timeoutMs }, ); const timestamp = result?.timestamp; return { messageId: timestamp ? String(timestamp) : "unknown", timestamp, }; }