refactor(src): split oversized modules
This commit is contained in:
245
src/web/auto-reply/heartbeat-runner.ts
Normal file
245
src/web/auto-reply/heartbeat-runner.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
import {
|
||||
DEFAULT_HEARTBEAT_ACK_MAX_CHARS,
|
||||
resolveHeartbeatPrompt,
|
||||
stripHeartbeatToken,
|
||||
} from "../../auto-reply/heartbeat.js";
|
||||
import { getReplyFromConfig } from "../../auto-reply/reply.js";
|
||||
import type { ReplyPayload } from "../../auto-reply/types.js";
|
||||
import { resolveWhatsAppHeartbeatRecipients } from "../../channels/plugins/whatsapp-heartbeat.js";
|
||||
import { loadConfig } from "../../config/config.js";
|
||||
import {
|
||||
loadSessionStore,
|
||||
resolveSessionKey,
|
||||
resolveStorePath,
|
||||
saveSessionStore,
|
||||
} from "../../config/sessions.js";
|
||||
import { emitHeartbeatEvent } from "../../infra/heartbeat-events.js";
|
||||
import { getChildLogger } from "../../logging.js";
|
||||
import { normalizeMainKey } from "../../routing/session-key.js";
|
||||
import { sendMessageWhatsApp } from "../outbound.js";
|
||||
import { newConnectionId } from "../reconnect.js";
|
||||
import { formatError } from "../session.js";
|
||||
import { whatsappHeartbeatLog } from "./loggers.js";
|
||||
import { getSessionSnapshot } from "./session-snapshot.js";
|
||||
import { elide } from "./util.js";
|
||||
|
||||
function resolveHeartbeatReplyPayload(
|
||||
replyResult: ReplyPayload | ReplyPayload[] | undefined,
|
||||
): ReplyPayload | undefined {
|
||||
if (!replyResult) return undefined;
|
||||
if (!Array.isArray(replyResult)) return replyResult;
|
||||
for (let idx = replyResult.length - 1; idx >= 0; idx -= 1) {
|
||||
const payload = replyResult[idx];
|
||||
if (!payload) continue;
|
||||
if (
|
||||
payload.text ||
|
||||
payload.mediaUrl ||
|
||||
(payload.mediaUrls && payload.mediaUrls.length > 0)
|
||||
) {
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function runWebHeartbeatOnce(opts: {
|
||||
cfg?: ReturnType<typeof loadConfig>;
|
||||
to: string;
|
||||
verbose?: boolean;
|
||||
replyResolver?: typeof getReplyFromConfig;
|
||||
sender?: typeof sendMessageWhatsApp;
|
||||
sessionId?: string;
|
||||
overrideBody?: string;
|
||||
dryRun?: boolean;
|
||||
}) {
|
||||
const {
|
||||
cfg: cfgOverride,
|
||||
to,
|
||||
verbose = false,
|
||||
sessionId,
|
||||
overrideBody,
|
||||
dryRun = false,
|
||||
} = opts;
|
||||
const replyResolver = opts.replyResolver ?? getReplyFromConfig;
|
||||
const sender = opts.sender ?? sendMessageWhatsApp;
|
||||
const runId = newConnectionId();
|
||||
const heartbeatLogger = getChildLogger({
|
||||
module: "web-heartbeat",
|
||||
runId,
|
||||
to,
|
||||
});
|
||||
|
||||
const cfg = cfgOverride ?? loadConfig();
|
||||
const sessionCfg = cfg.session;
|
||||
const sessionScope = sessionCfg?.scope ?? "per-sender";
|
||||
const mainKey = normalizeMainKey(sessionCfg?.mainKey);
|
||||
const sessionKey = resolveSessionKey(sessionScope, { From: to }, mainKey);
|
||||
if (sessionId) {
|
||||
const storePath = resolveStorePath(cfg.session?.store);
|
||||
const store = loadSessionStore(storePath);
|
||||
const current = store[sessionKey] ?? {};
|
||||
store[sessionKey] = {
|
||||
...current,
|
||||
sessionId,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
await saveSessionStore(storePath, store);
|
||||
}
|
||||
const sessionSnapshot = getSessionSnapshot(cfg, to, true);
|
||||
if (verbose) {
|
||||
heartbeatLogger.info(
|
||||
{
|
||||
to,
|
||||
sessionKey: sessionSnapshot.key,
|
||||
sessionId: sessionId ?? sessionSnapshot.entry?.sessionId ?? null,
|
||||
sessionFresh: sessionSnapshot.fresh,
|
||||
idleMinutes: sessionSnapshot.idleMinutes,
|
||||
},
|
||||
"heartbeat session snapshot",
|
||||
);
|
||||
}
|
||||
|
||||
if (overrideBody && overrideBody.trim().length === 0) {
|
||||
throw new Error("Override body must be non-empty when provided.");
|
||||
}
|
||||
|
||||
try {
|
||||
if (overrideBody) {
|
||||
if (dryRun) {
|
||||
whatsappHeartbeatLog.info(
|
||||
`[dry-run] web send -> ${to}: ${elide(overrideBody.trim(), 200)} (manual message)`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const sendResult = await sender(to, overrideBody, { verbose });
|
||||
emitHeartbeatEvent({
|
||||
status: "sent",
|
||||
to,
|
||||
preview: overrideBody.slice(0, 160),
|
||||
hasMedia: false,
|
||||
});
|
||||
heartbeatLogger.info(
|
||||
{
|
||||
to,
|
||||
messageId: sendResult.messageId,
|
||||
chars: overrideBody.length,
|
||||
reason: "manual-message",
|
||||
},
|
||||
"manual heartbeat message sent",
|
||||
);
|
||||
whatsappHeartbeatLog.info(
|
||||
`manual heartbeat sent to ${to} (id ${sendResult.messageId})`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const replyResult = await replyResolver(
|
||||
{
|
||||
Body: resolveHeartbeatPrompt(cfg.agents?.defaults?.heartbeat?.prompt),
|
||||
From: to,
|
||||
To: to,
|
||||
MessageSid: sessionId ?? sessionSnapshot.entry?.sessionId,
|
||||
},
|
||||
{ isHeartbeat: true },
|
||||
cfg,
|
||||
);
|
||||
const replyPayload = resolveHeartbeatReplyPayload(replyResult);
|
||||
|
||||
if (
|
||||
!replyPayload ||
|
||||
(!replyPayload.text &&
|
||||
!replyPayload.mediaUrl &&
|
||||
!replyPayload.mediaUrls?.length)
|
||||
) {
|
||||
heartbeatLogger.info(
|
||||
{
|
||||
to,
|
||||
reason: "empty-reply",
|
||||
sessionId: sessionSnapshot.entry?.sessionId ?? null,
|
||||
},
|
||||
"heartbeat skipped",
|
||||
);
|
||||
emitHeartbeatEvent({ status: "ok-empty", to });
|
||||
return;
|
||||
}
|
||||
|
||||
const hasMedia = Boolean(
|
||||
replyPayload.mediaUrl || (replyPayload.mediaUrls?.length ?? 0) > 0,
|
||||
);
|
||||
const ackMaxChars = Math.max(
|
||||
0,
|
||||
cfg.agents?.defaults?.heartbeat?.ackMaxChars ??
|
||||
DEFAULT_HEARTBEAT_ACK_MAX_CHARS,
|
||||
);
|
||||
const stripped = stripHeartbeatToken(replyPayload.text, {
|
||||
mode: "heartbeat",
|
||||
maxAckChars: ackMaxChars,
|
||||
});
|
||||
if (stripped.shouldSkip && !hasMedia) {
|
||||
// Don't let heartbeats keep sessions alive: restore previous updatedAt so idle expiry still works.
|
||||
const storePath = resolveStorePath(cfg.session?.store);
|
||||
const store = loadSessionStore(storePath);
|
||||
if (sessionSnapshot.entry && store[sessionSnapshot.key]) {
|
||||
store[sessionSnapshot.key].updatedAt = sessionSnapshot.entry.updatedAt;
|
||||
await saveSessionStore(storePath, store);
|
||||
}
|
||||
|
||||
heartbeatLogger.info(
|
||||
{ to, reason: "heartbeat-token", rawLength: replyPayload.text?.length },
|
||||
"heartbeat skipped",
|
||||
);
|
||||
emitHeartbeatEvent({ status: "ok-token", to });
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasMedia) {
|
||||
heartbeatLogger.warn(
|
||||
{ to },
|
||||
"heartbeat reply contained media; sending text only",
|
||||
);
|
||||
}
|
||||
|
||||
const finalText = stripped.text || replyPayload.text || "";
|
||||
if (dryRun) {
|
||||
heartbeatLogger.info(
|
||||
{ to, reason: "dry-run", chars: finalText.length },
|
||||
"heartbeat dry-run",
|
||||
);
|
||||
whatsappHeartbeatLog.info(
|
||||
`[dry-run] heartbeat -> ${to}: ${elide(finalText, 200)}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const sendResult = await sender(to, finalText, { verbose });
|
||||
emitHeartbeatEvent({
|
||||
status: "sent",
|
||||
to,
|
||||
preview: finalText.slice(0, 160),
|
||||
hasMedia,
|
||||
});
|
||||
heartbeatLogger.info(
|
||||
{
|
||||
to,
|
||||
messageId: sendResult.messageId,
|
||||
chars: finalText.length,
|
||||
preview: elide(finalText, 140),
|
||||
},
|
||||
"heartbeat sent",
|
||||
);
|
||||
whatsappHeartbeatLog.info(`heartbeat alert sent to ${to}`);
|
||||
} catch (err) {
|
||||
const reason = formatError(err);
|
||||
heartbeatLogger.warn({ to, error: reason }, "heartbeat failed");
|
||||
whatsappHeartbeatLog.warn(`heartbeat failed (${reason})`);
|
||||
emitHeartbeatEvent({ status: "failed", to, reason });
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveHeartbeatRecipients(
|
||||
cfg: ReturnType<typeof loadConfig>,
|
||||
opts: { to?: string; all?: boolean } = {},
|
||||
) {
|
||||
return resolveWhatsAppHeartbeatRecipients(cfg, opts);
|
||||
}
|
||||
Reference in New Issue
Block a user