444 lines
13 KiB
TypeScript
444 lines
13 KiB
TypeScript
import { chunkText } from "../auto-reply/chunk.js";
|
|
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 { 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 { signalCheck, 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;
|
|
};
|
|
|
|
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 ?? [];
|
|
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 waitForSignalDaemonReady(params: {
|
|
baseUrl: string;
|
|
abortSignal?: AbortSignal;
|
|
timeoutMs: number;
|
|
runtime: RuntimeEnv;
|
|
}): Promise<void> {
|
|
const started = Date.now();
|
|
let lastError: string | null = null;
|
|
|
|
while (Date.now() - started < params.timeoutMs) {
|
|
if (params.abortSignal?.aborted) return;
|
|
const res = await signalCheck(params.baseUrl, 1000);
|
|
if (res.ok) return;
|
|
lastError =
|
|
res.error ?? (res.status ? `HTTP ${res.status}` : "unreachable");
|
|
await new Promise((r) => setTimeout(r, 150));
|
|
}
|
|
|
|
params.runtime.error?.(
|
|
danger(
|
|
`daemon not ready after ${params.timeoutMs}ms (${lastError ?? "unknown error"})`,
|
|
),
|
|
);
|
|
throw new Error(`signal daemon not ready (${lastError ?? "unknown error"})`);
|
|
}
|
|
|
|
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?.(`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;
|
|
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 {
|
|
if (daemonHandle) {
|
|
await waitForSignalDaemonReady({
|
|
baseUrl,
|
|
abortSignal: opts.abortSignal,
|
|
timeoutMs: 10_000,
|
|
runtime,
|
|
});
|
|
}
|
|
|
|
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?.(`failed to parse event: ${String(err)}`);
|
|
return;
|
|
}
|
|
if (payload?.exception?.message) {
|
|
runtime.error?.(`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(`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}"`,
|
|
);
|
|
}
|
|
|
|
let blockSendChain: Promise<void> = Promise.resolve();
|
|
const sendBlockReply = (payload: ReplyPayload) => {
|
|
if (
|
|
!payload?.text &&
|
|
!payload?.mediaUrl &&
|
|
!(payload?.mediaUrls?.length ?? 0)
|
|
) {
|
|
return;
|
|
}
|
|
blockSendChain = blockSendChain
|
|
.then(async () => {
|
|
await deliverReplies({
|
|
replies: [payload],
|
|
target: ctxPayload.To,
|
|
baseUrl,
|
|
account,
|
|
runtime,
|
|
maxBytes: mediaMaxBytes,
|
|
});
|
|
})
|
|
.catch((err) => {
|
|
runtime.error?.(
|
|
danger(`signal block reply failed: ${String(err)}`),
|
|
);
|
|
});
|
|
};
|
|
|
|
const replyResult = await getReplyFromConfig(
|
|
ctxPayload,
|
|
{ onBlockReply: sendBlockReply },
|
|
cfg,
|
|
);
|
|
const replies = replyResult
|
|
? Array.isArray(replyResult)
|
|
? replyResult
|
|
: [replyResult]
|
|
: [];
|
|
await blockSendChain;
|
|
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?.(`event handler failed: ${String(err)}`);
|
|
});
|
|
},
|
|
});
|
|
} catch (err) {
|
|
if (opts.abortSignal?.aborted) return;
|
|
throw err;
|
|
} finally {
|
|
opts.abortSignal?.removeEventListener("abort", onAbort);
|
|
daemonHandle?.stop();
|
|
}
|
|
}
|