feat: add Signal provider support
This commit is contained in:
187
src/signal/client.ts
Normal file
187
src/signal/client.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
export type SignalRpcOptions = {
|
||||
baseUrl: string;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
export type SignalRpcError = {
|
||||
code?: number;
|
||||
message?: string;
|
||||
data?: unknown;
|
||||
};
|
||||
|
||||
export type SignalRpcResponse<T> = {
|
||||
jsonrpc?: string;
|
||||
result?: T;
|
||||
error?: SignalRpcError;
|
||||
id?: string | number | null;
|
||||
};
|
||||
|
||||
export type SignalSseEvent = {
|
||||
event?: string;
|
||||
data?: string;
|
||||
id?: string;
|
||||
};
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 10_000;
|
||||
|
||||
function normalizeBaseUrl(url: string): string {
|
||||
const trimmed = url.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error("Signal base URL is required");
|
||||
}
|
||||
if (/^https?:\/\//i.test(trimmed)) return trimmed.replace(/\/+$/, "");
|
||||
return `http://${trimmed}`.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
timeoutMs: number,
|
||||
) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
return await fetch(url, { ...init, signal: controller.signal });
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
export async function signalRpcRequest<T = unknown>(
|
||||
method: string,
|
||||
params: Record<string, unknown> | undefined,
|
||||
opts: SignalRpcOptions,
|
||||
): Promise<T> {
|
||||
const baseUrl = normalizeBaseUrl(opts.baseUrl);
|
||||
const id = randomUUID();
|
||||
const body = JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
method,
|
||||
params,
|
||||
id,
|
||||
});
|
||||
const res = await fetchWithTimeout(
|
||||
`${baseUrl}/api/v1/rpc`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body,
|
||||
},
|
||||
opts.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
||||
);
|
||||
if (res.status === 201) {
|
||||
return undefined as T;
|
||||
}
|
||||
const text = await res.text();
|
||||
if (!text) {
|
||||
throw new Error(`Signal RPC empty response (status ${res.status})`);
|
||||
}
|
||||
const parsed = JSON.parse(text) as SignalRpcResponse<T>;
|
||||
if (parsed.error) {
|
||||
const code = parsed.error.code ?? "unknown";
|
||||
const msg = parsed.error.message ?? "Signal RPC error";
|
||||
throw new Error(`Signal RPC ${code}: ${msg}`);
|
||||
}
|
||||
return parsed.result as T;
|
||||
}
|
||||
|
||||
export async function signalCheck(
|
||||
baseUrl: string,
|
||||
timeoutMs = DEFAULT_TIMEOUT_MS,
|
||||
): Promise<{ ok: boolean; status?: number | null; error?: string | null }> {
|
||||
const normalized = normalizeBaseUrl(baseUrl);
|
||||
try {
|
||||
const res = await fetchWithTimeout(
|
||||
`${normalized}/api/v1/check`,
|
||||
{ method: "GET" },
|
||||
timeoutMs,
|
||||
);
|
||||
if (!res.ok) {
|
||||
return { ok: false, status: res.status, error: `HTTP ${res.status}` };
|
||||
}
|
||||
return { ok: true, status: res.status, error: null };
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
status: null,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function streamSignalEvents(params: {
|
||||
baseUrl: string;
|
||||
account?: string;
|
||||
abortSignal?: AbortSignal;
|
||||
onEvent: (event: SignalSseEvent) => void;
|
||||
}): Promise<void> {
|
||||
const baseUrl = normalizeBaseUrl(params.baseUrl);
|
||||
const url = new URL(`${baseUrl}/api/v1/events`);
|
||||
if (params.account) url.searchParams.set("account", params.account);
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: { Accept: "text/event-stream" },
|
||||
signal: params.abortSignal,
|
||||
});
|
||||
if (!res.ok || !res.body) {
|
||||
throw new Error(
|
||||
`Signal SSE failed (${res.status} ${res.statusText || "error"})`,
|
||||
);
|
||||
}
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let currentEvent: SignalSseEvent = {};
|
||||
|
||||
const flushEvent = () => {
|
||||
if (!currentEvent.data && !currentEvent.event && !currentEvent.id) return;
|
||||
params.onEvent({
|
||||
event: currentEvent.event,
|
||||
data: currentEvent.data,
|
||||
id: currentEvent.id,
|
||||
});
|
||||
currentEvent = {};
|
||||
};
|
||||
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
let lineEnd = buffer.indexOf("\n");
|
||||
while (lineEnd !== -1) {
|
||||
let line = buffer.slice(0, lineEnd);
|
||||
buffer = buffer.slice(lineEnd + 1);
|
||||
if (line.endsWith("\r")) line = line.slice(0, -1);
|
||||
|
||||
if (line === "") {
|
||||
flushEvent();
|
||||
lineEnd = buffer.indexOf("\n");
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith(":")) {
|
||||
lineEnd = buffer.indexOf("\n");
|
||||
continue;
|
||||
}
|
||||
const [rawField, ...rest] = line.split(":");
|
||||
const field = rawField.trim();
|
||||
const rawValue = rest.join(":");
|
||||
const value = rawValue.startsWith(" ") ? rawValue.slice(1) : rawValue;
|
||||
if (field === "event") {
|
||||
currentEvent.event = value;
|
||||
} else if (field === "data") {
|
||||
currentEvent.data = currentEvent.data
|
||||
? `${currentEvent.data}\n${value}`
|
||||
: value;
|
||||
} else if (field === "id") {
|
||||
currentEvent.id = value;
|
||||
}
|
||||
lineEnd = buffer.indexOf("\n");
|
||||
}
|
||||
}
|
||||
|
||||
flushEvent();
|
||||
}
|
||||
68
src/signal/daemon.ts
Normal file
68
src/signal/daemon.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
|
||||
export type SignalDaemonOpts = {
|
||||
cliPath: string;
|
||||
account?: string;
|
||||
httpHost: string;
|
||||
httpPort: number;
|
||||
receiveMode?: "on-start" | "manual";
|
||||
ignoreAttachments?: boolean;
|
||||
ignoreStories?: boolean;
|
||||
sendReadReceipts?: boolean;
|
||||
runtime?: RuntimeEnv;
|
||||
};
|
||||
|
||||
export type SignalDaemonHandle = {
|
||||
pid?: number;
|
||||
stop: () => void;
|
||||
};
|
||||
|
||||
function buildDaemonArgs(opts: SignalDaemonOpts): string[] {
|
||||
const args: string[] = [];
|
||||
if (opts.account) {
|
||||
args.push("-a", opts.account);
|
||||
}
|
||||
args.push("daemon");
|
||||
args.push("--http", `${opts.httpHost}:${opts.httpPort}`);
|
||||
args.push("--no-receive-stdout");
|
||||
|
||||
if (opts.receiveMode) {
|
||||
args.push("--receive-mode", opts.receiveMode);
|
||||
}
|
||||
if (opts.ignoreAttachments) args.push("--ignore-attachments");
|
||||
if (opts.ignoreStories) args.push("--ignore-stories");
|
||||
if (opts.sendReadReceipts) args.push("--send-read-receipts");
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
export function spawnSignalDaemon(opts: SignalDaemonOpts): SignalDaemonHandle {
|
||||
const args = buildDaemonArgs(opts);
|
||||
const child = spawn(opts.cliPath, args, {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
const log = opts.runtime?.log ?? (() => {});
|
||||
const error = opts.runtime?.error ?? (() => {});
|
||||
|
||||
child.stdout?.on("data", (data) => {
|
||||
const text = data.toString().trim();
|
||||
if (text) log(`signal-cli: ${text}`);
|
||||
});
|
||||
child.stderr?.on("data", (data) => {
|
||||
const text = data.toString().trim();
|
||||
if (text) error(`signal-cli: ${text}`);
|
||||
});
|
||||
child.on("error", (err) => {
|
||||
error(`signal-cli spawn error: ${String(err)}`);
|
||||
});
|
||||
|
||||
return {
|
||||
pid: child.pid ?? undefined,
|
||||
stop: () => {
|
||||
if (!child.killed) {
|
||||
child.kill("SIGTERM");
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
3
src/signal/index.ts
Normal file
3
src/signal/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { monitorSignalProvider } from "./monitor.js";
|
||||
export { probeSignal } from "./probe.js";
|
||||
export { sendMessageSignal } from "./send.js";
|
||||
384
src/signal/monitor.ts
Normal file
384
src/signal/monitor.ts
Normal file
@@ -0,0 +1,384 @@
|
||||
import { formatAgentEnvelope } from "../auto-reply/envelope.js";
|
||||
import { getReplyFromConfig } from "../auto-reply/reply.js";
|
||||
import type { ReplyPayload } from "../auto-reply/types.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { resolveStorePath, updateLastRoute } from "../config/sessions.js";
|
||||
import { chunkText } from "../auto-reply/chunk.js";
|
||||
import { danger, isVerbose, logVerbose } from "../globals.js";
|
||||
import { mediaKindFromMime } from "../media/constants.js";
|
||||
import { saveMediaBuffer } from "../media/store.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { normalizeE164 } from "../utils.js";
|
||||
import { signalRpcRequest, streamSignalEvents } from "./client.js";
|
||||
import { spawnSignalDaemon } from "./daemon.js";
|
||||
import { sendMessageSignal } from "./send.js";
|
||||
|
||||
type SignalEnvelope = {
|
||||
sourceNumber?: string | null;
|
||||
sourceName?: string | null;
|
||||
timestamp?: number | null;
|
||||
dataMessage?: SignalDataMessage | null;
|
||||
editMessage?: { dataMessage?: SignalDataMessage | null } | null;
|
||||
syncMessage?: unknown | null;
|
||||
};
|
||||
|
||||
type SignalDataMessage = {
|
||||
timestamp?: number;
|
||||
message?: string | null;
|
||||
attachments?: Array<SignalAttachment>;
|
||||
groupInfo?: {
|
||||
groupId?: string | null;
|
||||
groupName?: string | null;
|
||||
} | null;
|
||||
quote?: { text?: string | null } | null;
|
||||
};
|
||||
|
||||
type SignalAttachment = {
|
||||
id?: string | null;
|
||||
contentType?: string | null;
|
||||
filename?: string | null;
|
||||
size?: number | null;
|
||||
};
|
||||
|
||||
export type MonitorSignalOpts = {
|
||||
runtime?: RuntimeEnv;
|
||||
abortSignal?: AbortSignal;
|
||||
account?: string;
|
||||
baseUrl?: string;
|
||||
autoStart?: boolean;
|
||||
cliPath?: string;
|
||||
httpHost?: string;
|
||||
httpPort?: number;
|
||||
receiveMode?: "on-start" | "manual";
|
||||
ignoreAttachments?: boolean;
|
||||
ignoreStories?: boolean;
|
||||
sendReadReceipts?: boolean;
|
||||
allowFrom?: Array<string | number>;
|
||||
mediaMaxMb?: number;
|
||||
};
|
||||
|
||||
type SignalReceivePayload = {
|
||||
account?: string;
|
||||
envelope?: SignalEnvelope | null;
|
||||
exception?: { message?: string } | null;
|
||||
};
|
||||
|
||||
function resolveRuntime(opts: MonitorSignalOpts): RuntimeEnv {
|
||||
return (
|
||||
opts.runtime ?? {
|
||||
log: console.log,
|
||||
error: console.error,
|
||||
exit: (code: number): never => {
|
||||
throw new Error(`exit ${code}`);
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function resolveBaseUrl(opts: MonitorSignalOpts): string {
|
||||
const cfg = loadConfig();
|
||||
const signalCfg = cfg.signal;
|
||||
if (opts.baseUrl?.trim()) return opts.baseUrl.trim();
|
||||
if (signalCfg?.httpUrl?.trim()) return signalCfg.httpUrl.trim();
|
||||
const host = opts.httpHost ?? signalCfg?.httpHost ?? "127.0.0.1";
|
||||
const port = opts.httpPort ?? signalCfg?.httpPort ?? 8080;
|
||||
return `http://${host}:${port}`;
|
||||
}
|
||||
|
||||
function resolveAccount(opts: MonitorSignalOpts): string | undefined {
|
||||
const cfg = loadConfig();
|
||||
return opts.account?.trim() || cfg.signal?.account?.trim() || undefined;
|
||||
}
|
||||
|
||||
function resolveAllowFrom(opts: MonitorSignalOpts): string[] {
|
||||
const cfg = loadConfig();
|
||||
const raw =
|
||||
opts.allowFrom ??
|
||||
cfg.signal?.allowFrom ??
|
||||
cfg.routing?.allowFrom ??
|
||||
[];
|
||||
return raw.map((entry) => String(entry).trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function isAllowedSender(sender: string, allowFrom: string[]): boolean {
|
||||
if (allowFrom.length === 0) return true;
|
||||
if (allowFrom.includes("*")) return true;
|
||||
const normalizedAllow = allowFrom
|
||||
.map((entry) => entry.replace(/^signal:/i, ""))
|
||||
.map((entry) => normalizeE164(entry));
|
||||
const normalizedSender = normalizeE164(sender);
|
||||
return normalizedAllow.includes(normalizedSender);
|
||||
}
|
||||
|
||||
async function fetchAttachment(params: {
|
||||
baseUrl: string;
|
||||
account?: string;
|
||||
attachment: SignalAttachment;
|
||||
sender?: string;
|
||||
groupId?: string;
|
||||
maxBytes: number;
|
||||
}): Promise<{ path: string; contentType?: string } | null> {
|
||||
const { attachment } = params;
|
||||
if (!attachment?.id) return null;
|
||||
if (attachment.size && attachment.size > params.maxBytes) {
|
||||
throw new Error(
|
||||
`Signal attachment ${attachment.id} exceeds ${(params.maxBytes / (1024 * 1024)).toFixed(0)}MB limit`,
|
||||
);
|
||||
}
|
||||
const rpcParams: Record<string, unknown> = {
|
||||
id: attachment.id,
|
||||
};
|
||||
if (params.account) rpcParams.account = params.account;
|
||||
if (params.groupId) rpcParams.groupId = params.groupId;
|
||||
else if (params.sender) rpcParams.recipient = params.sender;
|
||||
else return null;
|
||||
|
||||
const result = await signalRpcRequest<{ data?: string }>(
|
||||
"getAttachment",
|
||||
rpcParams,
|
||||
{ baseUrl: params.baseUrl },
|
||||
);
|
||||
if (!result?.data) return null;
|
||||
const buffer = Buffer.from(result.data, "base64");
|
||||
const saved = await saveMediaBuffer(
|
||||
buffer,
|
||||
attachment.contentType ?? undefined,
|
||||
"inbound",
|
||||
params.maxBytes,
|
||||
);
|
||||
return { path: saved.path, contentType: saved.contentType };
|
||||
}
|
||||
|
||||
async function deliverReplies(params: {
|
||||
replies: ReplyPayload[];
|
||||
target: string;
|
||||
baseUrl: string;
|
||||
account?: string;
|
||||
runtime: RuntimeEnv;
|
||||
maxBytes: number;
|
||||
}) {
|
||||
const { replies, target, baseUrl, account, runtime, maxBytes } = params;
|
||||
for (const payload of replies) {
|
||||
const mediaList =
|
||||
payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
||||
const text = payload.text ?? "";
|
||||
if (!text && mediaList.length === 0) continue;
|
||||
if (mediaList.length === 0) {
|
||||
for (const chunk of chunkText(text, 4000)) {
|
||||
await sendMessageSignal(target, chunk, {
|
||||
baseUrl,
|
||||
account,
|
||||
maxBytes,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
let first = true;
|
||||
for (const url of mediaList) {
|
||||
const caption = first ? text : "";
|
||||
first = false;
|
||||
await sendMessageSignal(target, caption, {
|
||||
baseUrl,
|
||||
account,
|
||||
mediaUrl: url,
|
||||
maxBytes,
|
||||
});
|
||||
}
|
||||
}
|
||||
runtime.log?.(`signal: delivered reply to ${target}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function monitorSignalProvider(
|
||||
opts: MonitorSignalOpts = {},
|
||||
): Promise<void> {
|
||||
const runtime = resolveRuntime(opts);
|
||||
const cfg = loadConfig();
|
||||
const baseUrl = resolveBaseUrl(opts);
|
||||
const account = resolveAccount(opts);
|
||||
const allowFrom = resolveAllowFrom(opts);
|
||||
const mediaMaxBytes =
|
||||
(opts.mediaMaxMb ?? cfg.signal?.mediaMaxMb ?? 8) * 1024 * 1024;
|
||||
const ignoreAttachments =
|
||||
opts.ignoreAttachments ?? cfg.signal?.ignoreAttachments ?? false;
|
||||
|
||||
const autoStart =
|
||||
opts.autoStart ??
|
||||
cfg.signal?.autoStart ??
|
||||
(cfg.signal?.httpUrl ? false : true);
|
||||
let daemonHandle: ReturnType<typeof spawnSignalDaemon> | null = null;
|
||||
|
||||
if (autoStart) {
|
||||
const cliPath = opts.cliPath ?? cfg.signal?.cliPath ?? "signal-cli";
|
||||
const httpHost = opts.httpHost ?? cfg.signal?.httpHost ?? "127.0.0.1";
|
||||
const httpPort = opts.httpPort ?? cfg.signal?.httpPort ?? 8080;
|
||||
daemonHandle = spawnSignalDaemon({
|
||||
cliPath,
|
||||
account,
|
||||
httpHost,
|
||||
httpPort,
|
||||
receiveMode: opts.receiveMode ?? cfg.signal?.receiveMode,
|
||||
ignoreAttachments:
|
||||
opts.ignoreAttachments ?? cfg.signal?.ignoreAttachments,
|
||||
ignoreStories: opts.ignoreStories ?? cfg.signal?.ignoreStories,
|
||||
sendReadReceipts:
|
||||
opts.sendReadReceipts ?? cfg.signal?.sendReadReceipts,
|
||||
runtime,
|
||||
});
|
||||
}
|
||||
|
||||
const onAbort = () => {
|
||||
daemonHandle?.stop();
|
||||
};
|
||||
opts.abortSignal?.addEventListener("abort", onAbort, { once: true });
|
||||
|
||||
try {
|
||||
const handleEvent = async (event: { event?: string; data?: string }) => {
|
||||
if (event.event !== "receive" || !event.data) return;
|
||||
let payload: SignalReceivePayload | null = null;
|
||||
try {
|
||||
payload = JSON.parse(event.data) as SignalReceivePayload;
|
||||
} catch (err) {
|
||||
runtime.error?.(`signal: failed to parse event: ${String(err)}`);
|
||||
return;
|
||||
}
|
||||
if (payload?.exception?.message) {
|
||||
runtime.error?.(`signal: receive exception: ${payload.exception.message}`);
|
||||
}
|
||||
const envelope = payload?.envelope;
|
||||
if (!envelope) return;
|
||||
if (envelope.syncMessage) return;
|
||||
const dataMessage =
|
||||
envelope.dataMessage ?? envelope.editMessage?.dataMessage;
|
||||
if (!dataMessage) return;
|
||||
|
||||
const sender = envelope.sourceNumber?.trim();
|
||||
if (!sender) return;
|
||||
if (account && normalizeE164(sender) === normalizeE164(account)) {
|
||||
return;
|
||||
}
|
||||
if (!isAllowedSender(sender, allowFrom)) {
|
||||
logVerbose(`Blocked signal sender ${sender} (not in allowFrom)`);
|
||||
return;
|
||||
}
|
||||
|
||||
const groupId = dataMessage.groupInfo?.groupId ?? undefined;
|
||||
const groupName = dataMessage.groupInfo?.groupName ?? undefined;
|
||||
const isGroup = Boolean(groupId);
|
||||
const messageText = (dataMessage.message ?? "").trim();
|
||||
|
||||
let mediaPath: string | undefined;
|
||||
let mediaType: string | undefined;
|
||||
let placeholder = "";
|
||||
const firstAttachment = dataMessage.attachments?.[0];
|
||||
if (firstAttachment?.id && !ignoreAttachments) {
|
||||
try {
|
||||
const fetched = await fetchAttachment({
|
||||
baseUrl,
|
||||
account,
|
||||
attachment: firstAttachment,
|
||||
sender,
|
||||
groupId,
|
||||
maxBytes: mediaMaxBytes,
|
||||
});
|
||||
if (fetched) {
|
||||
mediaPath = fetched.path;
|
||||
mediaType = fetched.contentType ?? firstAttachment.contentType ?? undefined;
|
||||
}
|
||||
} catch (err) {
|
||||
runtime.error?.(
|
||||
danger(`signal: attachment fetch failed: ${String(err)}`),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const kind = mediaKindFromMime(mediaType ?? undefined);
|
||||
if (kind) {
|
||||
placeholder = `<media:${kind}>`;
|
||||
} else if (dataMessage.attachments?.length) {
|
||||
placeholder = "<media:attachment>";
|
||||
}
|
||||
|
||||
const bodyText =
|
||||
messageText || placeholder || dataMessage.quote?.text?.trim() || "";
|
||||
if (!bodyText) return;
|
||||
|
||||
const fromLabel = isGroup
|
||||
? `${groupName ?? "Signal Group"} id:${groupId}`
|
||||
: `${envelope.sourceName ?? sender} id:${sender}`;
|
||||
const body = formatAgentEnvelope({
|
||||
surface: "Signal",
|
||||
from: fromLabel,
|
||||
timestamp: envelope.timestamp ?? undefined,
|
||||
body: bodyText,
|
||||
});
|
||||
|
||||
const ctxPayload = {
|
||||
Body: body,
|
||||
From: isGroup ? `group:${groupId}` : `signal:${sender}`,
|
||||
To: isGroup ? `group:${groupId}` : `signal:${sender}`,
|
||||
ChatType: isGroup ? "group" : "direct",
|
||||
GroupSubject: isGroup ? groupName ?? undefined : undefined,
|
||||
SenderName: envelope.sourceName ?? sender,
|
||||
Surface: "signal" as const,
|
||||
MessageSid: envelope.timestamp ? String(envelope.timestamp) : undefined,
|
||||
Timestamp: envelope.timestamp ?? undefined,
|
||||
MediaPath: mediaPath,
|
||||
MediaType: mediaType,
|
||||
MediaUrl: mediaPath,
|
||||
};
|
||||
|
||||
if (!isGroup) {
|
||||
const sessionCfg = cfg.session;
|
||||
const mainKey = (sessionCfg?.mainKey ?? "main").trim() || "main";
|
||||
const storePath = resolveStorePath(sessionCfg?.store);
|
||||
await updateLastRoute({
|
||||
storePath,
|
||||
sessionKey: mainKey,
|
||||
channel: "signal",
|
||||
to: normalizeE164(sender),
|
||||
});
|
||||
}
|
||||
|
||||
if (isVerbose()) {
|
||||
const preview = body.slice(0, 200).replace(/\n/g, "\\n");
|
||||
logVerbose(
|
||||
`signal inbound: from=${ctxPayload.From} len=${body.length} preview="${preview}"`,
|
||||
);
|
||||
}
|
||||
|
||||
const replyResult = await getReplyFromConfig(ctxPayload, undefined, cfg);
|
||||
const replies = replyResult
|
||||
? Array.isArray(replyResult)
|
||||
? replyResult
|
||||
: [replyResult]
|
||||
: [];
|
||||
if (replies.length === 0) return;
|
||||
|
||||
await deliverReplies({
|
||||
replies,
|
||||
target: ctxPayload.To,
|
||||
baseUrl,
|
||||
account,
|
||||
runtime,
|
||||
maxBytes: mediaMaxBytes,
|
||||
});
|
||||
};
|
||||
|
||||
await streamSignalEvents({
|
||||
baseUrl,
|
||||
account,
|
||||
abortSignal: opts.abortSignal,
|
||||
onEvent: (event) => {
|
||||
void handleEvent(event).catch((err) => {
|
||||
runtime.error?.(`signal: event handler failed: ${String(err)}`);
|
||||
});
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
if (opts.abortSignal?.aborted) return;
|
||||
throw err;
|
||||
} finally {
|
||||
opts.abortSignal?.removeEventListener("abort", onAbort);
|
||||
daemonHandle?.stop();
|
||||
}
|
||||
}
|
||||
47
src/signal/probe.ts
Normal file
47
src/signal/probe.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { signalCheck, signalRpcRequest } from "./client.js";
|
||||
|
||||
export type SignalProbe = {
|
||||
ok: boolean;
|
||||
status?: number | null;
|
||||
error?: string | null;
|
||||
elapsedMs: number;
|
||||
version?: string | null;
|
||||
};
|
||||
|
||||
export async function probeSignal(
|
||||
baseUrl: string,
|
||||
timeoutMs: number,
|
||||
): Promise<SignalProbe> {
|
||||
const started = Date.now();
|
||||
const result: SignalProbe = {
|
||||
ok: false,
|
||||
status: null,
|
||||
error: null,
|
||||
elapsedMs: 0,
|
||||
version: null,
|
||||
};
|
||||
const check = await signalCheck(baseUrl, timeoutMs);
|
||||
if (!check.ok) {
|
||||
return {
|
||||
...result,
|
||||
status: check.status ?? null,
|
||||
error: check.error ?? "unreachable",
|
||||
elapsedMs: Date.now() - started,
|
||||
};
|
||||
}
|
||||
try {
|
||||
const version = await signalRpcRequest<string>("version", undefined, {
|
||||
baseUrl,
|
||||
timeoutMs,
|
||||
});
|
||||
result.version = typeof version === "string" ? version : null;
|
||||
} catch (err) {
|
||||
result.error = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
return {
|
||||
...result,
|
||||
ok: true,
|
||||
status: check.status ?? null,
|
||||
elapsedMs: Date.now() - started,
|
||||
};
|
||||
}
|
||||
125
src/signal/send.ts
Normal file
125
src/signal/send.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
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("group:")) {
|
||||
return { type: "group", groupId: value.slice("group:".length).trim() };
|
||||
}
|
||||
if (lower.startsWith("signal:")) {
|
||||
value = value.slice("signal:".length).trim();
|
||||
}
|
||||
if (lower.startsWith("username:")) {
|
||||
return { type: "username", username: value.slice("username:".length).trim() };
|
||||
}
|
||||
if (lower.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<SignalSendResult> {
|
||||
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" ? "<media:image>" : `<media:${kind}>`;
|
||||
}
|
||||
}
|
||||
|
||||
if (!message.trim() && (!attachments || attachments.length === 0)) {
|
||||
throw new Error("Signal send requires text or media");
|
||||
}
|
||||
|
||||
const params: Record<string, unknown> = { 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user