feat(web): batch inbound messages
This commit is contained in:
@@ -25,6 +25,8 @@ import {
|
|||||||
stripHeartbeatToken,
|
stripHeartbeatToken,
|
||||||
} from "./auto-reply.js";
|
} from "./auto-reply.js";
|
||||||
import type { sendMessageWeb } from "./outbound.js";
|
import type { sendMessageWeb } from "./outbound.js";
|
||||||
|
import * as commandQueue from "../process/command-queue.js";
|
||||||
|
import { getQueueSize } from "../process/command-queue.js";
|
||||||
|
|
||||||
const makeSessionStore = async (
|
const makeSessionStore = async (
|
||||||
entries: Record<string, unknown> = {},
|
entries: Record<string, unknown> = {},
|
||||||
@@ -573,6 +575,78 @@ describe("web auto-reply", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("batches inbound messages while queue is busy and preserves timestamps", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const originalMax = process.getMaxListeners();
|
||||||
|
process.setMaxListeners?.(1); // force low to confirm bump
|
||||||
|
|
||||||
|
const sendMedia = vi.fn();
|
||||||
|
const reply = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const sendComposing = vi.fn();
|
||||||
|
const resolver = vi.fn().mockResolvedValue({ text: "batched" });
|
||||||
|
|
||||||
|
let capturedOnMessage:
|
||||||
|
| ((msg: import("./inbound.js").WebInboundMessage) => Promise<void>)
|
||||||
|
| undefined;
|
||||||
|
const listenerFactory = async (opts: {
|
||||||
|
onMessage: (
|
||||||
|
msg: import("./inbound.js").WebInboundMessage,
|
||||||
|
) => Promise<void>;
|
||||||
|
}) => {
|
||||||
|
capturedOnMessage = opts.onMessage;
|
||||||
|
return { close: vi.fn() };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Queue starts busy, then frees after one polling tick.
|
||||||
|
let queueBusy = true;
|
||||||
|
const queueSpy = vi
|
||||||
|
.spyOn(commandQueue, "getQueueSize")
|
||||||
|
.mockImplementation(() => (queueBusy ? 1 : 0));
|
||||||
|
|
||||||
|
setLoadConfigMock(() => ({ inbound: { timestampPrefix: "UTC" } }));
|
||||||
|
|
||||||
|
await monitorWebProvider(false, listenerFactory, false, resolver);
|
||||||
|
expect(capturedOnMessage).toBeDefined();
|
||||||
|
|
||||||
|
// Two messages from the same sender with fixed timestamps
|
||||||
|
await capturedOnMessage?.({
|
||||||
|
body: "first",
|
||||||
|
from: "+1",
|
||||||
|
to: "+2",
|
||||||
|
id: "m1",
|
||||||
|
timestamp: 1735689600000, // Jan 1 2025 00:00:00 UTC
|
||||||
|
sendComposing,
|
||||||
|
reply,
|
||||||
|
sendMedia,
|
||||||
|
});
|
||||||
|
await capturedOnMessage?.({
|
||||||
|
body: "second",
|
||||||
|
from: "+1",
|
||||||
|
to: "+2",
|
||||||
|
id: "m2",
|
||||||
|
timestamp: 1735693200000, // Jan 1 2025 01:00:00 UTC
|
||||||
|
sendComposing,
|
||||||
|
reply,
|
||||||
|
sendMedia,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Let the queued batch flush once the queue is free
|
||||||
|
queueBusy = false;
|
||||||
|
vi.advanceTimersByTime(200);
|
||||||
|
|
||||||
|
expect(resolver).toHaveBeenCalledTimes(1);
|
||||||
|
const args = resolver.mock.calls[0][0];
|
||||||
|
expect(args.Body).toContain("[Jan 1 00:00] [warelay] first");
|
||||||
|
expect(args.Body).toContain("[Jan 1 01:00] [warelay] second");
|
||||||
|
|
||||||
|
// Max listeners bumped to avoid warnings in multi-instance test runs
|
||||||
|
expect(process.getMaxListeners?.()).toBeGreaterThanOrEqual(50);
|
||||||
|
|
||||||
|
queueSpy.mockRestore();
|
||||||
|
process.setMaxListeners?.(originalMax);
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
it("falls back to text when media send fails", async () => {
|
it("falls back to text when media send fails", async () => {
|
||||||
const sendMedia = vi.fn().mockRejectedValue(new Error("boom"));
|
const sendMedia = vi.fn().mockRejectedValue(new Error("boom"));
|
||||||
const reply = vi.fn().mockResolvedValue(undefined);
|
const reply = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|||||||
@@ -515,6 +515,13 @@ export async function monitorWebProvider(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Avoid noisy MaxListenersExceeded warnings in test environments where
|
||||||
|
// multiple relay instances may be constructed.
|
||||||
|
const currentMaxListeners = process.getMaxListeners?.() ?? 10;
|
||||||
|
if (process.setMaxListeners && currentMaxListeners < 50) {
|
||||||
|
process.setMaxListeners(50);
|
||||||
|
}
|
||||||
|
|
||||||
let sigintStop = false;
|
let sigintStop = false;
|
||||||
const handleSigint = () => {
|
const handleSigint = () => {
|
||||||
sigintStop = true;
|
sigintStop = true;
|
||||||
@@ -544,35 +551,179 @@ export async function monitorWebProvider(
|
|||||||
const MESSAGE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes without any messages
|
const MESSAGE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes without any messages
|
||||||
const WATCHDOG_CHECK_MS = 60 * 1000; // Check every minute
|
const WATCHDOG_CHECK_MS = 60 * 1000; // Check every minute
|
||||||
|
|
||||||
|
// Batch inbound messages while command queue is busy, then send one
|
||||||
|
// combined prompt with per-message timestamps (inbound-only behavior).
|
||||||
|
type PendingBatch = { messages: WebInboundMsg[]; timer?: NodeJS.Timeout };
|
||||||
|
const pendingBatches = new Map<string, PendingBatch>();
|
||||||
|
|
||||||
|
const formatTimestamp = (ts?: number) => {
|
||||||
|
const tsCfg = cfg.inbound?.timestampPrefix;
|
||||||
|
const tsEnabled = tsCfg !== false; // default true
|
||||||
|
if (!tsEnabled) return "";
|
||||||
|
const tz = typeof tsCfg === "string" ? tsCfg : "UTC";
|
||||||
|
const date = ts ? new Date(ts) : new Date();
|
||||||
|
try {
|
||||||
|
return `[${date.toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: tz })} ${date.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false, timeZone: tz })}] `;
|
||||||
|
} catch {
|
||||||
|
return `[${date.toISOString().slice(5, 16).replace("T", " ")}] `;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildLine = (msg: WebInboundMsg) => {
|
||||||
|
// Build message prefix: explicit config > default based on allowFrom
|
||||||
|
let messagePrefix = cfg.inbound?.messagePrefix;
|
||||||
|
if (messagePrefix === undefined) {
|
||||||
|
const hasAllowFrom = (cfg.inbound?.allowFrom?.length ?? 0) > 0;
|
||||||
|
messagePrefix = hasAllowFrom ? "" : "[warelay]";
|
||||||
|
}
|
||||||
|
const prefixStr = messagePrefix ? `${messagePrefix} ` : "";
|
||||||
|
return `${formatTimestamp(msg.timestamp)}${prefixStr}${msg.body}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const processBatch = async (from: string) => {
|
||||||
|
const batch = pendingBatches.get(from);
|
||||||
|
if (!batch || batch.messages.length === 0) return;
|
||||||
|
if (getQueueSize() > 0) {
|
||||||
|
// Wait until command queue is free to run the combined prompt.
|
||||||
|
batch.timer = setTimeout(() => void processBatch(from), 150);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pendingBatches.delete(from);
|
||||||
|
|
||||||
|
const messages = batch.messages;
|
||||||
|
const latest = messages[messages.length - 1];
|
||||||
|
const combinedBody = messages.map(buildLine).join("\n");
|
||||||
|
|
||||||
|
// Echo detection uses combined body so we don't respond twice.
|
||||||
|
if (recentlySent.has(combinedBody)) {
|
||||||
|
logVerbose(`Skipping auto-reply: detected echo for combined batch`);
|
||||||
|
recentlySent.delete(combinedBody);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const correlationId = latest.id ?? newConnectionId();
|
||||||
|
replyLogger.info(
|
||||||
|
{
|
||||||
|
connectionId,
|
||||||
|
correlationId,
|
||||||
|
from,
|
||||||
|
to: latest.to,
|
||||||
|
body: combinedBody,
|
||||||
|
mediaType: latest.mediaType ?? null,
|
||||||
|
mediaPath: latest.mediaPath ?? null,
|
||||||
|
batchSize: messages.length,
|
||||||
|
},
|
||||||
|
"inbound web message (batched)",
|
||||||
|
);
|
||||||
|
|
||||||
|
const tsDisplay = latest.timestamp
|
||||||
|
? new Date(latest.timestamp).toISOString()
|
||||||
|
: new Date().toISOString();
|
||||||
|
console.log(`\n[${tsDisplay}] ${from} -> ${latest.to}: ${combinedBody}`);
|
||||||
|
|
||||||
|
const replyResult = await (replyResolver ?? getReplyFromConfig)(
|
||||||
|
{
|
||||||
|
Body: combinedBody,
|
||||||
|
From: latest.from,
|
||||||
|
To: latest.to,
|
||||||
|
MessageSid: latest.id,
|
||||||
|
MediaPath: latest.mediaPath,
|
||||||
|
MediaUrl: latest.mediaUrl,
|
||||||
|
MediaType: latest.mediaType,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onReplyStart: latest.sendComposing,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
!replyResult ||
|
||||||
|
(!replyResult.text &&
|
||||||
|
!replyResult.mediaUrl &&
|
||||||
|
!replyResult.mediaUrls?.length)
|
||||||
|
) {
|
||||||
|
logVerbose("Skipping auto-reply: no text/media returned from resolver");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply response prefix if configured (skip for HEARTBEAT_OK to preserve exact match)
|
||||||
|
const responsePrefix = cfg.inbound?.responsePrefix;
|
||||||
|
if (
|
||||||
|
responsePrefix &&
|
||||||
|
replyResult.text &&
|
||||||
|
replyResult.text.trim() !== HEARTBEAT_TOKEN
|
||||||
|
) {
|
||||||
|
if (!replyResult.text.startsWith(responsePrefix)) {
|
||||||
|
replyResult.text = `${responsePrefix} ${replyResult.text}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deliverWebReply({
|
||||||
|
replyResult,
|
||||||
|
msg: latest,
|
||||||
|
maxMediaBytes,
|
||||||
|
replyLogger,
|
||||||
|
runtime,
|
||||||
|
connectionId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (replyResult.text) {
|
||||||
|
recentlySent.add(replyResult.text);
|
||||||
|
recentlySent.add(combinedBody); // Prevent echo on the batch text itself
|
||||||
|
logVerbose(
|
||||||
|
`Added to echo detection set (size now: ${recentlySent.size}): ${replyResult.text.substring(0, 50)}...`,
|
||||||
|
);
|
||||||
|
if (recentlySent.size > MAX_RECENT_MESSAGES) {
|
||||||
|
const firstKey = recentlySent.values().next().value;
|
||||||
|
if (firstKey) recentlySent.delete(firstKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isVerbose()) {
|
||||||
|
console.log(
|
||||||
|
success(
|
||||||
|
`↩️ Auto-replied to ${from} (web${replyResult.mediaUrl || replyResult.mediaUrls?.length ? ", media" : ""}; batched ${messages.length})`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
success(
|
||||||
|
`↩️ ${replyResult.text ?? "<media>"}${replyResult.mediaUrl || replyResult.mediaUrls?.length ? " (media)" : ""}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(
|
||||||
|
danger(
|
||||||
|
`Failed sending web auto-reply to ${from}: ${String(err)}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const enqueueBatch = async (msg: WebInboundMsg) => {
|
||||||
|
const bucket = pendingBatches.get(msg.from) ?? { messages: [] };
|
||||||
|
bucket.messages.push(msg);
|
||||||
|
pendingBatches.set(msg.from, bucket);
|
||||||
|
|
||||||
|
// Process immediately when queue is free; otherwise wait until it drains.
|
||||||
|
if (getQueueSize() === 0) {
|
||||||
|
await processBatch(msg.from);
|
||||||
|
} else {
|
||||||
|
bucket.timer = bucket.timer ?? setTimeout(() => void processBatch(msg.from), 150);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const listener = await (listenerFactory ?? monitorWebInbox)({
|
const listener = await (listenerFactory ?? monitorWebInbox)({
|
||||||
verbose,
|
verbose,
|
||||||
onMessage: async (msg) => {
|
onMessage: async (msg) => {
|
||||||
// Also add IPC-sent messages to echo detection
|
|
||||||
// (this is handled below in the IPC sendHandler)
|
|
||||||
handledMessages += 1;
|
handledMessages += 1;
|
||||||
lastMessageAt = Date.now();
|
lastMessageAt = Date.now();
|
||||||
const ts = msg.timestamp
|
lastInboundMsg = msg;
|
||||||
? new Date(msg.timestamp).toISOString()
|
|
||||||
: new Date().toISOString();
|
|
||||||
const correlationId = msg.id ?? newConnectionId();
|
|
||||||
replyLogger.info(
|
|
||||||
{
|
|
||||||
connectionId,
|
|
||||||
correlationId,
|
|
||||||
from: msg.from,
|
|
||||||
to: msg.to,
|
|
||||||
body: msg.body,
|
|
||||||
mediaType: msg.mediaType ?? null,
|
|
||||||
mediaPath: msg.mediaPath ?? null,
|
|
||||||
},
|
|
||||||
"inbound web message",
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log(`\n[${ts}] ${msg.from} -> ${msg.to}: ${msg.body}`);
|
// Same-phone mode logging retained
|
||||||
|
if (msg.from === msg.to) {
|
||||||
// Detect same-phone mode (self-messaging)
|
|
||||||
const isSamePhoneMode = msg.from === msg.to;
|
|
||||||
if (isSamePhoneMode) {
|
|
||||||
logVerbose(`📱 Same-phone mode detected (from === to: ${msg.from})`);
|
logVerbose(`📱 Same-phone mode detected (from === to: ${msg.from})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -582,121 +733,11 @@ export async function monitorWebProvider(
|
|||||||
logVerbose(
|
logVerbose(
|
||||||
`Skipping auto-reply: detected echo (message matches recently sent text)`,
|
`Skipping auto-reply: detected echo (message matches recently sent text)`,
|
||||||
);
|
);
|
||||||
recentlySent.delete(msg.body); // Remove from set to allow future identical messages
|
recentlySent.delete(msg.body);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
logVerbose(
|
return enqueueBatch(msg);
|
||||||
`Echo check: message not in recent set (size: ${recentlySent.size})`,
|
|
||||||
);
|
|
||||||
|
|
||||||
lastInboundMsg = msg;
|
|
||||||
|
|
||||||
// Build timestamp prefix (default: enabled with UTC)
|
|
||||||
// Can be: true (UTC), false (disabled), or "America/New_York" (custom timezone)
|
|
||||||
let timestampStr = "";
|
|
||||||
const tsCfg = cfg.inbound?.timestampPrefix;
|
|
||||||
const tsEnabled = tsCfg !== false; // default true
|
|
||||||
if (tsEnabled) {
|
|
||||||
const tz = typeof tsCfg === "string" ? tsCfg : "UTC";
|
|
||||||
const now = new Date();
|
|
||||||
try {
|
|
||||||
// Format: "Nov 29 06:30" - compact but informative
|
|
||||||
timestampStr = `[${now.toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: tz })} ${now.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false, timeZone: tz })}] `;
|
|
||||||
} catch {
|
|
||||||
// Fallback to UTC if timezone invalid
|
|
||||||
timestampStr = `[${now.toISOString().slice(5, 16).replace("T", " ")}] `;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build message prefix: explicit config > default based on allowFrom
|
|
||||||
// If allowFrom is configured, user likely has a specific setup - no default prefix
|
|
||||||
// If no allowFrom, add "[warelay]" so AI knows it's coming through warelay
|
|
||||||
let messagePrefix = cfg.inbound?.messagePrefix;
|
|
||||||
if (messagePrefix === undefined) {
|
|
||||||
const hasAllowFrom = (cfg.inbound?.allowFrom?.length ?? 0) > 0;
|
|
||||||
messagePrefix = hasAllowFrom ? "" : "[warelay]";
|
|
||||||
}
|
|
||||||
const prefixStr = messagePrefix ? `${messagePrefix} ` : "";
|
|
||||||
const bodyForCommand = `${timestampStr}${prefixStr}${msg.body}`;
|
|
||||||
|
|
||||||
const replyResult = await (replyResolver ?? getReplyFromConfig)(
|
|
||||||
{
|
|
||||||
Body: bodyForCommand,
|
|
||||||
From: msg.from,
|
|
||||||
To: msg.to,
|
|
||||||
MessageSid: msg.id,
|
|
||||||
MediaPath: msg.mediaPath,
|
|
||||||
MediaUrl: msg.mediaUrl,
|
|
||||||
MediaType: msg.mediaType,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
onReplyStart: msg.sendComposing,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if (
|
|
||||||
!replyResult ||
|
|
||||||
(!replyResult.text &&
|
|
||||||
!replyResult.mediaUrl &&
|
|
||||||
!replyResult.mediaUrls?.length)
|
|
||||||
) {
|
|
||||||
logVerbose(
|
|
||||||
"Skipping auto-reply: no text/media returned from resolver",
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Apply response prefix if configured (skip for HEARTBEAT_OK to preserve exact match)
|
|
||||||
const responsePrefix = cfg.inbound?.responsePrefix;
|
|
||||||
if (responsePrefix && replyResult.text && replyResult.text.trim() !== HEARTBEAT_TOKEN) {
|
|
||||||
// Only add prefix if not already present
|
|
||||||
if (!replyResult.text.startsWith(responsePrefix)) {
|
|
||||||
replyResult.text = `${responsePrefix} ${replyResult.text}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await deliverWebReply({
|
|
||||||
replyResult,
|
|
||||||
msg,
|
|
||||||
maxMediaBytes,
|
|
||||||
replyLogger,
|
|
||||||
runtime,
|
|
||||||
connectionId,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Track sent message to prevent echo loops
|
|
||||||
if (replyResult.text) {
|
|
||||||
recentlySent.add(replyResult.text);
|
|
||||||
logVerbose(
|
|
||||||
`Added to echo detection set (size now: ${recentlySent.size}): ${replyResult.text.substring(0, 50)}...`,
|
|
||||||
);
|
|
||||||
// Keep set bounded - remove oldest if too large
|
|
||||||
if (recentlySent.size > MAX_RECENT_MESSAGES) {
|
|
||||||
const firstKey = recentlySent.values().next().value;
|
|
||||||
if (firstKey) recentlySent.delete(firstKey);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isVerbose()) {
|
|
||||||
console.log(
|
|
||||||
success(
|
|
||||||
`↩️ Auto-replied to ${msg.from} (web${replyResult.mediaUrl || replyResult.mediaUrls?.length ? ", media" : ""})`,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
console.log(
|
|
||||||
success(
|
|
||||||
`↩️ ${replyResult.text ?? "<media>"}${replyResult.mediaUrl || replyResult.mediaUrls?.length ? " (media)" : ""}`,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error(
|
|
||||||
danger(
|
|
||||||
`Failed sending web auto-reply to ${msg.from}: ${String(err)}`,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user