refactor(src): split oversized modules
This commit is contained in:
BIN
src/web/.DS_Store
vendored
Normal file
BIN
src/web/.DS_Store
vendored
Normal file
Binary file not shown.
@@ -1,303 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ClawdbotConfig } from "../config/types.js";
|
||||
|
||||
describe("WhatsApp ack reaction logic", () => {
|
||||
// Helper to simulate the logic from auto-reply.ts
|
||||
function shouldSendReaction(
|
||||
cfg: ClawdbotConfig,
|
||||
msg: {
|
||||
id?: string;
|
||||
chatType: "direct" | "group";
|
||||
wasMentioned?: boolean;
|
||||
},
|
||||
groupActivation?: "always" | "mention",
|
||||
): boolean {
|
||||
const ackConfig = cfg.channels?.whatsapp?.ackReaction;
|
||||
const emoji = (ackConfig?.emoji ?? "").trim();
|
||||
const directEnabled = ackConfig?.direct ?? true;
|
||||
const groupMode = ackConfig?.group ?? "mentions";
|
||||
|
||||
if (!emoji) return false;
|
||||
if (!msg.id) return false;
|
||||
|
||||
// Direct chat logic
|
||||
if (msg.chatType === "direct") {
|
||||
return directEnabled;
|
||||
}
|
||||
|
||||
// Group chat logic
|
||||
if (msg.chatType === "group") {
|
||||
if (groupMode === "never") return false;
|
||||
if (groupMode === "always") return true;
|
||||
if (groupMode === "mentions") {
|
||||
// If group activation is "always", always react
|
||||
if (groupActivation === "always") return true;
|
||||
// Otherwise, only react if bot was mentioned
|
||||
return msg.wasMentioned === true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
describe("direct chat", () => {
|
||||
it("should react when direct=true", () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: { whatsapp: { ackReaction: { emoji: "👀", direct: true } } },
|
||||
};
|
||||
expect(
|
||||
shouldSendReaction(cfg, {
|
||||
id: "msg1",
|
||||
chatType: "direct",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("should not react when direct=false", () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: { whatsapp: { ackReaction: { emoji: "👀", direct: false } } },
|
||||
};
|
||||
expect(
|
||||
shouldSendReaction(cfg, {
|
||||
id: "msg1",
|
||||
chatType: "direct",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("should not react when emoji is empty", () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: { whatsapp: { ackReaction: { emoji: "", direct: true } } },
|
||||
};
|
||||
expect(
|
||||
shouldSendReaction(cfg, {
|
||||
id: "msg1",
|
||||
chatType: "direct",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("should not react when message id is missing", () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: { whatsapp: { ackReaction: { emoji: "👀", direct: true } } },
|
||||
};
|
||||
expect(
|
||||
shouldSendReaction(cfg, {
|
||||
chatType: "direct",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("group chat - always mode", () => {
|
||||
it("should react to all messages when group=always", () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: {
|
||||
whatsapp: { ackReaction: { emoji: "👀", group: "always" } },
|
||||
},
|
||||
};
|
||||
expect(
|
||||
shouldSendReaction(cfg, {
|
||||
id: "msg1",
|
||||
chatType: "group",
|
||||
wasMentioned: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("should react even with mention when group=always", () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: {
|
||||
whatsapp: { ackReaction: { emoji: "👀", group: "always" } },
|
||||
},
|
||||
};
|
||||
expect(
|
||||
shouldSendReaction(cfg, {
|
||||
id: "msg1",
|
||||
chatType: "group",
|
||||
wasMentioned: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("group chat - mentions mode", () => {
|
||||
it("should react when mentioned", () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: {
|
||||
whatsapp: { ackReaction: { emoji: "👀", group: "mentions" } },
|
||||
},
|
||||
};
|
||||
expect(
|
||||
shouldSendReaction(cfg, {
|
||||
id: "msg1",
|
||||
chatType: "group",
|
||||
wasMentioned: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("should not react when not mentioned", () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: {
|
||||
whatsapp: { ackReaction: { emoji: "👀", group: "mentions" } },
|
||||
},
|
||||
};
|
||||
expect(
|
||||
shouldSendReaction(
|
||||
cfg,
|
||||
{
|
||||
id: "msg1",
|
||||
chatType: "group",
|
||||
wasMentioned: false,
|
||||
},
|
||||
"mention", // group activation
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("should react to all messages when group activation is always", () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: {
|
||||
whatsapp: { ackReaction: { emoji: "👀", group: "mentions" } },
|
||||
},
|
||||
};
|
||||
expect(
|
||||
shouldSendReaction(
|
||||
cfg,
|
||||
{
|
||||
id: "msg1",
|
||||
chatType: "group",
|
||||
wasMentioned: false,
|
||||
},
|
||||
"always", // group has requireMention=false
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("group chat - never mode", () => {
|
||||
it("should not react even with mention", () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: {
|
||||
whatsapp: { ackReaction: { emoji: "👀", group: "never" } },
|
||||
},
|
||||
};
|
||||
expect(
|
||||
shouldSendReaction(cfg, {
|
||||
id: "msg1",
|
||||
chatType: "group",
|
||||
wasMentioned: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("should not react without mention", () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: {
|
||||
whatsapp: { ackReaction: { emoji: "👀", group: "never" } },
|
||||
},
|
||||
};
|
||||
expect(
|
||||
shouldSendReaction(cfg, {
|
||||
id: "msg1",
|
||||
chatType: "group",
|
||||
wasMentioned: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("combinations", () => {
|
||||
it("direct=false, group=always: only groups", () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: {
|
||||
whatsapp: {
|
||||
ackReaction: { emoji: "✅", direct: false, group: "always" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(shouldSendReaction(cfg, { id: "m1", chatType: "direct" })).toBe(
|
||||
false,
|
||||
);
|
||||
|
||||
expect(
|
||||
shouldSendReaction(cfg, {
|
||||
id: "m2",
|
||||
chatType: "group",
|
||||
wasMentioned: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("direct=true, group=never: only direct", () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: {
|
||||
whatsapp: {
|
||||
ackReaction: { emoji: "🤖", direct: true, group: "never" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(shouldSendReaction(cfg, { id: "m1", chatType: "direct" })).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
expect(
|
||||
shouldSendReaction(cfg, {
|
||||
id: "m2",
|
||||
chatType: "group",
|
||||
wasMentioned: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("defaults", () => {
|
||||
it("should default direct=true", () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: { whatsapp: { ackReaction: { emoji: "👀" } } },
|
||||
};
|
||||
expect(shouldSendReaction(cfg, { id: "m1", chatType: "direct" })).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("should default group=mentions", () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: { whatsapp: { ackReaction: { emoji: "👀" } } },
|
||||
};
|
||||
|
||||
expect(
|
||||
shouldSendReaction(cfg, {
|
||||
id: "m1",
|
||||
chatType: "group",
|
||||
wasMentioned: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
shouldSendReaction(cfg, {
|
||||
id: "m2",
|
||||
chatType: "group",
|
||||
wasMentioned: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("legacy config is ignored", () => {
|
||||
it("does not use messages.ackReaction for WhatsApp", () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
messages: { ackReaction: "👀", ackReactionScope: "all" },
|
||||
};
|
||||
expect(
|
||||
shouldSendReaction(cfg, {
|
||||
id: "m1",
|
||||
chatType: "direct",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
324
src/web/auto-reply.broadcast-groups.part-1.test.ts
Normal file
324
src/web/auto-reply.broadcast-groups.part-1.test.ts
Normal file
@@ -0,0 +1,324 @@
|
||||
import "./test-helpers.js";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../agents/pi-embedded.js", () => ({
|
||||
abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
|
||||
isEmbeddedPiRunActive: vi.fn().mockReturnValue(false),
|
||||
isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false),
|
||||
runEmbeddedPiAgent: vi.fn(),
|
||||
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
|
||||
resolveEmbeddedSessionLane: (key: string) =>
|
||||
`session:${key.trim() || "main"}`,
|
||||
}));
|
||||
|
||||
import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { monitorWebChannel } from "./auto-reply.js";
|
||||
import { resetLoadConfigMock, setLoadConfigMock } from "./test-helpers.js";
|
||||
|
||||
let previousHome: string | undefined;
|
||||
let tempHome: string | undefined;
|
||||
|
||||
const rmDirWithRetries = async (dir: string): Promise<void> => {
|
||||
// Some tests can leave async session-store writes in-flight; recursive deletion can race and throw ENOTEMPTY.
|
||||
for (let attempt = 0; attempt < 10; attempt += 1) {
|
||||
try {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
return;
|
||||
} catch (err) {
|
||||
const code =
|
||||
err && typeof err === "object" && "code" in err
|
||||
? String((err as { code?: unknown }).code)
|
||||
: null;
|
||||
if (code === "ENOTEMPTY" || code === "EBUSY" || code === "EPERM") {
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
resetInboundDedupe();
|
||||
previousHome = process.env.HOME;
|
||||
tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-web-home-"));
|
||||
process.env.HOME = tempHome;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
process.env.HOME = previousHome;
|
||||
if (tempHome) {
|
||||
await rmDirWithRetries(tempHome);
|
||||
tempHome = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
const _makeSessionStore = async (
|
||||
entries: Record<string, unknown> = {},
|
||||
): Promise<{ storePath: string; cleanup: () => Promise<void> }> => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-session-"));
|
||||
const storePath = path.join(dir, "sessions.json");
|
||||
await fs.writeFile(storePath, JSON.stringify(entries));
|
||||
const cleanup = async () => {
|
||||
// Session store writes can be in-flight when the test finishes (e.g. updateLastRoute
|
||||
// after a message flush). `fs.rm({ recursive })` can race and throw ENOTEMPTY.
|
||||
for (let attempt = 0; attempt < 10; attempt += 1) {
|
||||
try {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
return;
|
||||
} catch (err) {
|
||||
const code =
|
||||
err && typeof err === "object" && "code" in err
|
||||
? String((err as { code?: unknown }).code)
|
||||
: null;
|
||||
if (code === "ENOTEMPTY" || code === "EBUSY" || code === "EPERM") {
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
};
|
||||
return {
|
||||
storePath,
|
||||
cleanup,
|
||||
};
|
||||
};
|
||||
|
||||
describe("broadcast groups", () => {
|
||||
it("broadcasts sequentially in configured order", async () => {
|
||||
setLoadConfigMock({
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
agents: {
|
||||
defaults: { maxConcurrent: 10 },
|
||||
list: [{ id: "alfred" }, { id: "baerbel" }],
|
||||
},
|
||||
broadcast: {
|
||||
strategy: "sequential",
|
||||
"+1000": ["alfred", "baerbel"],
|
||||
},
|
||||
} satisfies ClawdbotConfig);
|
||||
|
||||
const sendMedia = vi.fn();
|
||||
const reply = vi.fn().mockResolvedValue(undefined);
|
||||
const sendComposing = vi.fn();
|
||||
const seen: string[] = [];
|
||||
const resolver = vi.fn(async (ctx: { SessionKey?: unknown }) => {
|
||||
seen.push(String(ctx.SessionKey));
|
||||
return { text: "ok" };
|
||||
});
|
||||
|
||||
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() };
|
||||
};
|
||||
|
||||
await monitorWebChannel(false, listenerFactory, false, resolver);
|
||||
expect(capturedOnMessage).toBeDefined();
|
||||
|
||||
await capturedOnMessage?.({
|
||||
id: "m1",
|
||||
from: "+1000",
|
||||
conversationId: "+1000",
|
||||
to: "+2000",
|
||||
body: "hello",
|
||||
timestamp: Date.now(),
|
||||
chatType: "direct",
|
||||
chatId: "direct:+1000",
|
||||
sendComposing,
|
||||
reply,
|
||||
sendMedia,
|
||||
});
|
||||
|
||||
expect(resolver).toHaveBeenCalledTimes(2);
|
||||
expect(seen[0]).toContain("agent:alfred:");
|
||||
expect(seen[1]).toContain("agent:baerbel:");
|
||||
resetLoadConfigMock();
|
||||
});
|
||||
it("shares group history across broadcast agents and clears after replying", async () => {
|
||||
setLoadConfigMock({
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
agents: {
|
||||
defaults: { maxConcurrent: 10 },
|
||||
list: [{ id: "alfred" }, { id: "baerbel" }],
|
||||
},
|
||||
broadcast: {
|
||||
strategy: "sequential",
|
||||
"123@g.us": ["alfred", "baerbel"],
|
||||
},
|
||||
} satisfies ClawdbotConfig);
|
||||
|
||||
const sendMedia = vi.fn();
|
||||
const reply = vi.fn().mockResolvedValue(undefined);
|
||||
const sendComposing = vi.fn();
|
||||
const resolver = vi.fn().mockResolvedValue({ text: "ok" });
|
||||
|
||||
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() };
|
||||
};
|
||||
|
||||
await monitorWebChannel(false, listenerFactory, false, resolver);
|
||||
expect(capturedOnMessage).toBeDefined();
|
||||
|
||||
await capturedOnMessage?.({
|
||||
body: "hello group",
|
||||
from: "123@g.us",
|
||||
conversationId: "123@g.us",
|
||||
chatId: "123@g.us",
|
||||
chatType: "group",
|
||||
to: "+2",
|
||||
id: "g1",
|
||||
senderE164: "+111",
|
||||
senderName: "Alice",
|
||||
selfE164: "+999",
|
||||
sendComposing,
|
||||
reply,
|
||||
sendMedia,
|
||||
});
|
||||
|
||||
expect(resolver).not.toHaveBeenCalled();
|
||||
|
||||
await capturedOnMessage?.({
|
||||
body: "@bot ping",
|
||||
from: "123@g.us",
|
||||
conversationId: "123@g.us",
|
||||
chatId: "123@g.us",
|
||||
chatType: "group",
|
||||
to: "+2",
|
||||
id: "g2",
|
||||
senderE164: "+222",
|
||||
senderName: "Bob",
|
||||
mentionedJids: ["999@s.whatsapp.net"],
|
||||
selfE164: "+999",
|
||||
selfJid: "999@s.whatsapp.net",
|
||||
sendComposing,
|
||||
reply,
|
||||
sendMedia,
|
||||
});
|
||||
|
||||
expect(resolver).toHaveBeenCalledTimes(2);
|
||||
for (const call of resolver.mock.calls.slice(0, 2)) {
|
||||
const payload = call[0] as { Body: string };
|
||||
expect(payload.Body).toContain("Chat messages since your last reply");
|
||||
expect(payload.Body).toContain("Alice (+111): hello group");
|
||||
expect(payload.Body).toContain("[message_id: g1]");
|
||||
expect(payload.Body).toContain("@bot ping");
|
||||
expect(payload.Body).toContain("[from: Bob (+222)]");
|
||||
}
|
||||
|
||||
await capturedOnMessage?.({
|
||||
body: "@bot ping 2",
|
||||
from: "123@g.us",
|
||||
conversationId: "123@g.us",
|
||||
chatId: "123@g.us",
|
||||
chatType: "group",
|
||||
to: "+2",
|
||||
id: "g3",
|
||||
senderE164: "+333",
|
||||
senderName: "Clara",
|
||||
mentionedJids: ["999@s.whatsapp.net"],
|
||||
selfE164: "+999",
|
||||
selfJid: "999@s.whatsapp.net",
|
||||
sendComposing,
|
||||
reply,
|
||||
sendMedia,
|
||||
});
|
||||
|
||||
expect(resolver).toHaveBeenCalledTimes(4);
|
||||
for (const call of resolver.mock.calls.slice(2, 4)) {
|
||||
const payload = call[0] as { Body: string };
|
||||
expect(payload.Body).not.toContain("Alice (+111): hello group");
|
||||
expect(payload.Body).not.toContain("Chat messages since your last reply");
|
||||
}
|
||||
|
||||
resetLoadConfigMock();
|
||||
});
|
||||
it("broadcasts in parallel by default", async () => {
|
||||
setLoadConfigMock({
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
agents: {
|
||||
defaults: { maxConcurrent: 10 },
|
||||
list: [{ id: "alfred" }, { id: "baerbel" }],
|
||||
},
|
||||
broadcast: {
|
||||
strategy: "parallel",
|
||||
"+1000": ["alfred", "baerbel"],
|
||||
},
|
||||
} satisfies ClawdbotConfig);
|
||||
|
||||
const sendMedia = vi.fn();
|
||||
const reply = vi.fn().mockResolvedValue(undefined);
|
||||
const sendComposing = vi.fn();
|
||||
|
||||
let started = 0;
|
||||
let release: (() => void) | undefined;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
|
||||
const resolver = vi.fn(async () => {
|
||||
started += 1;
|
||||
if (started < 2) {
|
||||
await gate;
|
||||
} else {
|
||||
release?.();
|
||||
}
|
||||
return { text: "ok" };
|
||||
});
|
||||
|
||||
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() };
|
||||
};
|
||||
|
||||
await monitorWebChannel(false, listenerFactory, false, resolver);
|
||||
expect(capturedOnMessage).toBeDefined();
|
||||
|
||||
await capturedOnMessage?.({
|
||||
id: "m1",
|
||||
from: "+1000",
|
||||
conversationId: "+1000",
|
||||
to: "+2000",
|
||||
body: "hello",
|
||||
timestamp: Date.now(),
|
||||
chatType: "direct",
|
||||
chatId: "direct:+1000",
|
||||
sendComposing,
|
||||
reply,
|
||||
sendMedia,
|
||||
});
|
||||
|
||||
expect(resolver).toHaveBeenCalledTimes(2);
|
||||
resetLoadConfigMock();
|
||||
});
|
||||
});
|
||||
151
src/web/auto-reply.broadcast-groups.part-2.test.ts
Normal file
151
src/web/auto-reply.broadcast-groups.part-2.test.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import "./test-helpers.js";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../agents/pi-embedded.js", () => ({
|
||||
abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
|
||||
isEmbeddedPiRunActive: vi.fn().mockReturnValue(false),
|
||||
isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false),
|
||||
runEmbeddedPiAgent: vi.fn(),
|
||||
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
|
||||
resolveEmbeddedSessionLane: (key: string) =>
|
||||
`session:${key.trim() || "main"}`,
|
||||
}));
|
||||
|
||||
import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { monitorWebChannel } from "./auto-reply.js";
|
||||
import { resetLoadConfigMock, setLoadConfigMock } from "./test-helpers.js";
|
||||
|
||||
let previousHome: string | undefined;
|
||||
let tempHome: string | undefined;
|
||||
|
||||
const rmDirWithRetries = async (dir: string): Promise<void> => {
|
||||
// Some tests can leave async session-store writes in-flight; recursive deletion can race and throw ENOTEMPTY.
|
||||
for (let attempt = 0; attempt < 10; attempt += 1) {
|
||||
try {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
return;
|
||||
} catch (err) {
|
||||
const code =
|
||||
err && typeof err === "object" && "code" in err
|
||||
? String((err as { code?: unknown }).code)
|
||||
: null;
|
||||
if (code === "ENOTEMPTY" || code === "EBUSY" || code === "EPERM") {
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
resetInboundDedupe();
|
||||
previousHome = process.env.HOME;
|
||||
tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-web-home-"));
|
||||
process.env.HOME = tempHome;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
process.env.HOME = previousHome;
|
||||
if (tempHome) {
|
||||
await rmDirWithRetries(tempHome);
|
||||
tempHome = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
const _makeSessionStore = async (
|
||||
entries: Record<string, unknown> = {},
|
||||
): Promise<{ storePath: string; cleanup: () => Promise<void> }> => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-session-"));
|
||||
const storePath = path.join(dir, "sessions.json");
|
||||
await fs.writeFile(storePath, JSON.stringify(entries));
|
||||
const cleanup = async () => {
|
||||
// Session store writes can be in-flight when the test finishes (e.g. updateLastRoute
|
||||
// after a message flush). `fs.rm({ recursive })` can race and throw ENOTEMPTY.
|
||||
for (let attempt = 0; attempt < 10; attempt += 1) {
|
||||
try {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
return;
|
||||
} catch (err) {
|
||||
const code =
|
||||
err && typeof err === "object" && "code" in err
|
||||
? String((err as { code?: unknown }).code)
|
||||
: null;
|
||||
if (code === "ENOTEMPTY" || code === "EBUSY" || code === "EPERM") {
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
};
|
||||
return {
|
||||
storePath,
|
||||
cleanup,
|
||||
};
|
||||
};
|
||||
|
||||
describe("broadcast groups", () => {
|
||||
it("skips unknown broadcast agent ids when agents.list is present", async () => {
|
||||
setLoadConfigMock({
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
agents: {
|
||||
defaults: { maxConcurrent: 10 },
|
||||
list: [{ id: "alfred" }],
|
||||
},
|
||||
broadcast: {
|
||||
"+1000": ["alfred", "missing"],
|
||||
},
|
||||
} satisfies ClawdbotConfig);
|
||||
|
||||
const sendMedia = vi.fn();
|
||||
const reply = vi.fn().mockResolvedValue(undefined);
|
||||
const sendComposing = vi.fn();
|
||||
const seen: string[] = [];
|
||||
const resolver = vi.fn(async (ctx: { SessionKey?: unknown }) => {
|
||||
seen.push(String(ctx.SessionKey));
|
||||
return { text: "ok" };
|
||||
});
|
||||
|
||||
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() };
|
||||
};
|
||||
|
||||
await monitorWebChannel(false, listenerFactory, false, resolver);
|
||||
expect(capturedOnMessage).toBeDefined();
|
||||
|
||||
await capturedOnMessage?.({
|
||||
id: "m1",
|
||||
from: "+1000",
|
||||
conversationId: "+1000",
|
||||
to: "+2000",
|
||||
body: "hello",
|
||||
timestamp: Date.now(),
|
||||
chatType: "direct",
|
||||
chatId: "direct:+1000",
|
||||
sendComposing,
|
||||
reply,
|
||||
sendMedia,
|
||||
});
|
||||
|
||||
expect(resolver).toHaveBeenCalledTimes(1);
|
||||
expect(seen[0]).toContain("agent:alfred:");
|
||||
resetLoadConfigMock();
|
||||
});
|
||||
});
|
||||
13
src/web/auto-reply.impl.ts
Normal file
13
src/web/auto-reply.impl.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export {
|
||||
HEARTBEAT_PROMPT,
|
||||
stripHeartbeatToken,
|
||||
} from "../auto-reply/heartbeat.js";
|
||||
export { HEARTBEAT_TOKEN, SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
|
||||
|
||||
export { DEFAULT_WEB_MEDIA_BYTES } from "./auto-reply/constants.js";
|
||||
export {
|
||||
resolveHeartbeatRecipients,
|
||||
runWebHeartbeatOnce,
|
||||
} from "./auto-reply/heartbeat-runner.js";
|
||||
export { monitorWebChannel } from "./auto-reply/monitor.js";
|
||||
export type { WebChannelStatus, WebMonitorTuning } from "./auto-reply/types.js";
|
||||
350
src/web/auto-reply.partial-reply-gating.test.ts
Normal file
350
src/web/auto-reply.partial-reply-gating.test.ts
Normal file
@@ -0,0 +1,350 @@
|
||||
import "./test-helpers.js";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../agents/pi-embedded.js", () => ({
|
||||
abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
|
||||
isEmbeddedPiRunActive: vi.fn().mockReturnValue(false),
|
||||
isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false),
|
||||
runEmbeddedPiAgent: vi.fn(),
|
||||
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
|
||||
resolveEmbeddedSessionLane: (key: string) =>
|
||||
`session:${key.trim() || "main"}`,
|
||||
}));
|
||||
|
||||
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
|
||||
import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js";
|
||||
import { getReplyFromConfig } from "../auto-reply/reply.js";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { monitorWebChannel } from "./auto-reply.js";
|
||||
import { resetLoadConfigMock, setLoadConfigMock } from "./test-helpers.js";
|
||||
|
||||
let previousHome: string | undefined;
|
||||
let tempHome: string | undefined;
|
||||
|
||||
const rmDirWithRetries = async (dir: string): Promise<void> => {
|
||||
// Some tests can leave async session-store writes in-flight; recursive deletion can race and throw ENOTEMPTY.
|
||||
for (let attempt = 0; attempt < 10; attempt += 1) {
|
||||
try {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
return;
|
||||
} catch (err) {
|
||||
const code =
|
||||
err && typeof err === "object" && "code" in err
|
||||
? String((err as { code?: unknown }).code)
|
||||
: null;
|
||||
if (code === "ENOTEMPTY" || code === "EBUSY" || code === "EPERM") {
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
resetInboundDedupe();
|
||||
previousHome = process.env.HOME;
|
||||
tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-web-home-"));
|
||||
process.env.HOME = tempHome;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
process.env.HOME = previousHome;
|
||||
if (tempHome) {
|
||||
await rmDirWithRetries(tempHome);
|
||||
tempHome = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
const makeSessionStore = async (
|
||||
entries: Record<string, unknown> = {},
|
||||
): Promise<{ storePath: string; cleanup: () => Promise<void> }> => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-session-"));
|
||||
const storePath = path.join(dir, "sessions.json");
|
||||
await fs.writeFile(storePath, JSON.stringify(entries));
|
||||
const cleanup = async () => {
|
||||
// Session store writes can be in-flight when the test finishes (e.g. updateLastRoute
|
||||
// after a message flush). `fs.rm({ recursive })` can race and throw ENOTEMPTY.
|
||||
for (let attempt = 0; attempt < 10; attempt += 1) {
|
||||
try {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
return;
|
||||
} catch (err) {
|
||||
const code =
|
||||
err && typeof err === "object" && "code" in err
|
||||
? String((err as { code?: unknown }).code)
|
||||
: null;
|
||||
if (code === "ENOTEMPTY" || code === "EBUSY" || code === "EPERM") {
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
};
|
||||
return {
|
||||
storePath,
|
||||
cleanup,
|
||||
};
|
||||
};
|
||||
|
||||
describe("partial reply gating", () => {
|
||||
it("does not send partial replies for WhatsApp provider", async () => {
|
||||
const reply = vi.fn().mockResolvedValue(undefined);
|
||||
const sendComposing = vi.fn().mockResolvedValue(undefined);
|
||||
const sendMedia = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const replyResolver = vi.fn().mockResolvedValue({ text: "final reply" });
|
||||
|
||||
const mockConfig: ClawdbotConfig = {
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
};
|
||||
|
||||
setLoadConfigMock(mockConfig);
|
||||
|
||||
await monitorWebChannel(
|
||||
false,
|
||||
async ({ onMessage }) => {
|
||||
await onMessage({
|
||||
id: "m1",
|
||||
from: "+1000",
|
||||
conversationId: "+1000",
|
||||
to: "+2000",
|
||||
body: "hello",
|
||||
timestamp: Date.now(),
|
||||
chatType: "direct",
|
||||
chatId: "direct:+1000",
|
||||
sendComposing,
|
||||
reply,
|
||||
sendMedia,
|
||||
});
|
||||
return { close: vi.fn().mockResolvedValue(undefined) };
|
||||
},
|
||||
false,
|
||||
replyResolver,
|
||||
);
|
||||
|
||||
resetLoadConfigMock();
|
||||
|
||||
expect(replyResolver).toHaveBeenCalledTimes(1);
|
||||
const resolverOptions = replyResolver.mock.calls[0]?.[1] ?? {};
|
||||
expect("onPartialReply" in resolverOptions).toBe(false);
|
||||
expect(reply).toHaveBeenCalledTimes(1);
|
||||
expect(reply).toHaveBeenCalledWith("final reply");
|
||||
});
|
||||
it("falls back from empty senderJid to senderE164 for SenderId", async () => {
|
||||
const reply = vi.fn().mockResolvedValue(undefined);
|
||||
const sendComposing = vi.fn().mockResolvedValue(undefined);
|
||||
const sendMedia = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const replyResolver = vi.fn().mockResolvedValue({ text: "final reply" });
|
||||
|
||||
const mockConfig: ClawdbotConfig = {
|
||||
channels: {
|
||||
whatsapp: {
|
||||
allowFrom: ["*"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
setLoadConfigMock(mockConfig);
|
||||
|
||||
await monitorWebChannel(
|
||||
false,
|
||||
async ({ onMessage }) => {
|
||||
await onMessage({
|
||||
id: "m1",
|
||||
from: "+1000",
|
||||
conversationId: "+1000",
|
||||
to: "+2000",
|
||||
body: "hello",
|
||||
timestamp: Date.now(),
|
||||
chatType: "direct",
|
||||
chatId: "direct:+1000",
|
||||
senderJid: "",
|
||||
senderE164: "+1000",
|
||||
sendComposing,
|
||||
reply,
|
||||
sendMedia,
|
||||
});
|
||||
return { close: vi.fn().mockResolvedValue(undefined) };
|
||||
},
|
||||
false,
|
||||
replyResolver,
|
||||
);
|
||||
|
||||
resetLoadConfigMock();
|
||||
|
||||
expect(replyResolver).toHaveBeenCalledTimes(1);
|
||||
const ctx = replyResolver.mock.calls[0]?.[0] ?? {};
|
||||
expect(ctx.SenderE164).toBe("+1000");
|
||||
expect(ctx.SenderId).toBe("+1000");
|
||||
});
|
||||
it("updates last-route for direct chats without senderE164", async () => {
|
||||
const now = Date.now();
|
||||
const mainSessionKey = "agent:main:main";
|
||||
const store = await makeSessionStore({
|
||||
[mainSessionKey]: { sessionId: "sid", updatedAt: now - 1 },
|
||||
});
|
||||
|
||||
const replyResolver = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const mockConfig: ClawdbotConfig = {
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
session: { store: store.storePath },
|
||||
};
|
||||
|
||||
setLoadConfigMock(mockConfig);
|
||||
|
||||
await monitorWebChannel(
|
||||
false,
|
||||
async ({ onMessage }) => {
|
||||
await onMessage({
|
||||
id: "m1",
|
||||
from: "+1000",
|
||||
conversationId: "+1000",
|
||||
to: "+2000",
|
||||
body: "hello",
|
||||
timestamp: now,
|
||||
chatType: "direct",
|
||||
chatId: "direct:+1000",
|
||||
sendComposing: vi.fn().mockResolvedValue(undefined),
|
||||
reply: vi.fn().mockResolvedValue(undefined),
|
||||
sendMedia: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
return { close: vi.fn().mockResolvedValue(undefined) };
|
||||
},
|
||||
false,
|
||||
replyResolver,
|
||||
);
|
||||
|
||||
let stored: Record<
|
||||
string,
|
||||
{ lastChannel?: string; lastTo?: string }
|
||||
> | null = null;
|
||||
for (let attempt = 0; attempt < 50; attempt += 1) {
|
||||
stored = JSON.parse(await fs.readFile(store.storePath, "utf8")) as Record<
|
||||
string,
|
||||
{ lastChannel?: string; lastTo?: string }
|
||||
>;
|
||||
if (stored[mainSessionKey]?.lastChannel && stored[mainSessionKey]?.lastTo)
|
||||
break;
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
if (!stored) throw new Error("store not loaded");
|
||||
expect(stored[mainSessionKey]?.lastChannel).toBe("whatsapp");
|
||||
expect(stored[mainSessionKey]?.lastTo).toBe("+1000");
|
||||
|
||||
resetLoadConfigMock();
|
||||
await store.cleanup();
|
||||
});
|
||||
it("updates last-route for group chats with account id", async () => {
|
||||
const now = Date.now();
|
||||
const groupSessionKey = "agent:main:whatsapp:group:123@g.us";
|
||||
const store = await makeSessionStore({
|
||||
[groupSessionKey]: { sessionId: "sid", updatedAt: now - 1 },
|
||||
});
|
||||
|
||||
const replyResolver = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const mockConfig: ClawdbotConfig = {
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
session: { store: store.storePath },
|
||||
};
|
||||
|
||||
setLoadConfigMock(mockConfig);
|
||||
|
||||
await monitorWebChannel(
|
||||
false,
|
||||
async ({ onMessage }) => {
|
||||
await onMessage({
|
||||
id: "g1",
|
||||
from: "123@g.us",
|
||||
conversationId: "123@g.us",
|
||||
to: "+2000",
|
||||
body: "hello",
|
||||
timestamp: now,
|
||||
chatType: "group",
|
||||
chatId: "123@g.us",
|
||||
accountId: "work",
|
||||
senderE164: "+1000",
|
||||
senderName: "Alice",
|
||||
selfE164: "+2000",
|
||||
sendComposing: vi.fn().mockResolvedValue(undefined),
|
||||
reply: vi.fn().mockResolvedValue(undefined),
|
||||
sendMedia: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
return { close: vi.fn().mockResolvedValue(undefined) };
|
||||
},
|
||||
false,
|
||||
replyResolver,
|
||||
);
|
||||
|
||||
let stored: Record<
|
||||
string,
|
||||
{ lastChannel?: string; lastTo?: string; lastAccountId?: string }
|
||||
> | null = null;
|
||||
for (let attempt = 0; attempt < 50; attempt += 1) {
|
||||
stored = JSON.parse(await fs.readFile(store.storePath, "utf8")) as Record<
|
||||
string,
|
||||
{ lastChannel?: string; lastTo?: string; lastAccountId?: string }
|
||||
>;
|
||||
if (
|
||||
stored[groupSessionKey]?.lastChannel &&
|
||||
stored[groupSessionKey]?.lastTo &&
|
||||
stored[groupSessionKey]?.lastAccountId
|
||||
)
|
||||
break;
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
if (!stored) throw new Error("store not loaded");
|
||||
expect(stored[groupSessionKey]?.lastChannel).toBe("whatsapp");
|
||||
expect(stored[groupSessionKey]?.lastTo).toBe("123@g.us");
|
||||
expect(stored[groupSessionKey]?.lastAccountId).toBe("work");
|
||||
|
||||
resetLoadConfigMock();
|
||||
await store.cleanup();
|
||||
});
|
||||
it("defaults to self-only when no config is present", async () => {
|
||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
||||
payloads: [{ text: "ok" }],
|
||||
meta: {
|
||||
durationMs: 1,
|
||||
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
||||
},
|
||||
});
|
||||
|
||||
// Not self: should be blocked
|
||||
const blocked = await getReplyFromConfig(
|
||||
{
|
||||
Body: "hi",
|
||||
From: "whatsapp:+999",
|
||||
To: "whatsapp:+123",
|
||||
},
|
||||
undefined,
|
||||
{},
|
||||
);
|
||||
expect(blocked).toBeUndefined();
|
||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||
|
||||
// Self: should be allowed
|
||||
const allowed = await getReplyFromConfig(
|
||||
{
|
||||
Body: "hi",
|
||||
From: "whatsapp:+123",
|
||||
To: "whatsapp:+123",
|
||||
},
|
||||
undefined,
|
||||
{},
|
||||
);
|
||||
expect(allowed).toMatchObject({ text: "ok", audioAsVoice: false });
|
||||
expect(runEmbeddedPiAgent).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
150
src/web/auto-reply.typing-controller-idle.test.ts
Normal file
150
src/web/auto-reply.typing-controller-idle.test.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import "./test-helpers.js";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../agents/pi-embedded.js", () => ({
|
||||
abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
|
||||
isEmbeddedPiRunActive: vi.fn().mockReturnValue(false),
|
||||
isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false),
|
||||
runEmbeddedPiAgent: vi.fn(),
|
||||
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
|
||||
resolveEmbeddedSessionLane: (key: string) =>
|
||||
`session:${key.trim() || "main"}`,
|
||||
}));
|
||||
|
||||
import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { monitorWebChannel } from "./auto-reply.js";
|
||||
import { resetLoadConfigMock, setLoadConfigMock } from "./test-helpers.js";
|
||||
|
||||
let previousHome: string | undefined;
|
||||
let tempHome: string | undefined;
|
||||
|
||||
const rmDirWithRetries = async (dir: string): Promise<void> => {
|
||||
// Some tests can leave async session-store writes in-flight; recursive deletion can race and throw ENOTEMPTY.
|
||||
for (let attempt = 0; attempt < 10; attempt += 1) {
|
||||
try {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
return;
|
||||
} catch (err) {
|
||||
const code =
|
||||
err && typeof err === "object" && "code" in err
|
||||
? String((err as { code?: unknown }).code)
|
||||
: null;
|
||||
if (code === "ENOTEMPTY" || code === "EBUSY" || code === "EPERM") {
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
resetInboundDedupe();
|
||||
previousHome = process.env.HOME;
|
||||
tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-web-home-"));
|
||||
process.env.HOME = tempHome;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
process.env.HOME = previousHome;
|
||||
if (tempHome) {
|
||||
await rmDirWithRetries(tempHome);
|
||||
tempHome = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
const _makeSessionStore = async (
|
||||
entries: Record<string, unknown> = {},
|
||||
): Promise<{ storePath: string; cleanup: () => Promise<void> }> => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-session-"));
|
||||
const storePath = path.join(dir, "sessions.json");
|
||||
await fs.writeFile(storePath, JSON.stringify(entries));
|
||||
const cleanup = async () => {
|
||||
// Session store writes can be in-flight when the test finishes (e.g. updateLastRoute
|
||||
// after a message flush). `fs.rm({ recursive })` can race and throw ENOTEMPTY.
|
||||
for (let attempt = 0; attempt < 10; attempt += 1) {
|
||||
try {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
return;
|
||||
} catch (err) {
|
||||
const code =
|
||||
err && typeof err === "object" && "code" in err
|
||||
? String((err as { code?: unknown }).code)
|
||||
: null;
|
||||
if (code === "ENOTEMPTY" || code === "EBUSY" || code === "EPERM") {
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
};
|
||||
return {
|
||||
storePath,
|
||||
cleanup,
|
||||
};
|
||||
};
|
||||
|
||||
describe("typing controller idle", () => {
|
||||
it("marks dispatch idle after replies flush", async () => {
|
||||
const markDispatchIdle = vi.fn();
|
||||
const typingMock = {
|
||||
onReplyStart: vi.fn(async () => {}),
|
||||
startTypingLoop: vi.fn(async () => {}),
|
||||
startTypingOnText: vi.fn(async () => {}),
|
||||
refreshTypingTtl: vi.fn(),
|
||||
isActive: vi.fn(() => false),
|
||||
markRunComplete: vi.fn(),
|
||||
markDispatchIdle,
|
||||
cleanup: vi.fn(),
|
||||
};
|
||||
const reply = vi.fn().mockResolvedValue(undefined);
|
||||
const sendComposing = vi.fn().mockResolvedValue(undefined);
|
||||
const sendMedia = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const replyResolver = vi.fn().mockImplementation(async (_ctx, opts) => {
|
||||
opts?.onTypingController?.(typingMock);
|
||||
return { text: "final reply" };
|
||||
});
|
||||
|
||||
const mockConfig: ClawdbotConfig = {
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
};
|
||||
|
||||
setLoadConfigMock(mockConfig);
|
||||
|
||||
await monitorWebChannel(
|
||||
false,
|
||||
async ({ onMessage }) => {
|
||||
await onMessage({
|
||||
id: "m1",
|
||||
from: "+1000",
|
||||
conversationId: "+1000",
|
||||
to: "+2000",
|
||||
body: "hello",
|
||||
timestamp: Date.now(),
|
||||
chatType: "direct",
|
||||
chatId: "direct:+1000",
|
||||
sendComposing,
|
||||
reply,
|
||||
sendMedia,
|
||||
});
|
||||
return { close: vi.fn().mockResolvedValue(undefined) };
|
||||
},
|
||||
false,
|
||||
replyResolver,
|
||||
);
|
||||
|
||||
resetLoadConfigMock();
|
||||
|
||||
expect(markDispatchIdle).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
368
src/web/auto-reply.web-auto-reply.part-1.test.ts
Normal file
368
src/web/auto-reply.web-auto-reply.part-1.test.ts
Normal file
@@ -0,0 +1,368 @@
|
||||
import "./test-helpers.js";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../agents/pi-embedded.js", () => ({
|
||||
abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
|
||||
isEmbeddedPiRunActive: vi.fn().mockReturnValue(false),
|
||||
isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false),
|
||||
runEmbeddedPiAgent: vi.fn(),
|
||||
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
|
||||
resolveEmbeddedSessionLane: (key: string) =>
|
||||
`session:${key.trim() || "main"}`,
|
||||
}));
|
||||
|
||||
import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js";
|
||||
import { resetLogger, setLoggerOverride } from "../logging.js";
|
||||
import { monitorWebChannel } from "./auto-reply.js";
|
||||
import {
|
||||
resetBaileysMocks,
|
||||
resetLoadConfigMock,
|
||||
setLoadConfigMock,
|
||||
} from "./test-helpers.js";
|
||||
|
||||
let previousHome: string | undefined;
|
||||
let tempHome: string | undefined;
|
||||
|
||||
const rmDirWithRetries = async (dir: string): Promise<void> => {
|
||||
// Some tests can leave async session-store writes in-flight; recursive deletion can race and throw ENOTEMPTY.
|
||||
for (let attempt = 0; attempt < 10; attempt += 1) {
|
||||
try {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
return;
|
||||
} catch (err) {
|
||||
const code =
|
||||
err && typeof err === "object" && "code" in err
|
||||
? String((err as { code?: unknown }).code)
|
||||
: null;
|
||||
if (code === "ENOTEMPTY" || code === "EBUSY" || code === "EPERM") {
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
resetInboundDedupe();
|
||||
previousHome = process.env.HOME;
|
||||
tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-web-home-"));
|
||||
process.env.HOME = tempHome;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
process.env.HOME = previousHome;
|
||||
if (tempHome) {
|
||||
await rmDirWithRetries(tempHome);
|
||||
tempHome = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
const makeSessionStore = async (
|
||||
entries: Record<string, unknown> = {},
|
||||
): Promise<{ storePath: string; cleanup: () => Promise<void> }> => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-session-"));
|
||||
const storePath = path.join(dir, "sessions.json");
|
||||
await fs.writeFile(storePath, JSON.stringify(entries));
|
||||
const cleanup = async () => {
|
||||
// Session store writes can be in-flight when the test finishes (e.g. updateLastRoute
|
||||
// after a message flush). `fs.rm({ recursive })` can race and throw ENOTEMPTY.
|
||||
for (let attempt = 0; attempt < 10; attempt += 1) {
|
||||
try {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
return;
|
||||
} catch (err) {
|
||||
const code =
|
||||
err && typeof err === "object" && "code" in err
|
||||
? String((err as { code?: unknown }).code)
|
||||
: null;
|
||||
if (code === "ENOTEMPTY" || code === "EBUSY" || code === "EPERM") {
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
};
|
||||
return {
|
||||
storePath,
|
||||
cleanup,
|
||||
};
|
||||
};
|
||||
|
||||
describe("web auto-reply", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resetBaileysMocks();
|
||||
resetLoadConfigMock();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetLogger();
|
||||
setLoggerOverride(null);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("reconnects after a connection close", async () => {
|
||||
const closeResolvers: Array<() => void> = [];
|
||||
const sleep = vi.fn(async () => {});
|
||||
const listenerFactory = vi.fn(async () => {
|
||||
let _resolve!: () => void;
|
||||
const onClose = new Promise<void>((res) => {
|
||||
_resolve = res;
|
||||
closeResolvers.push(res);
|
||||
});
|
||||
return { close: vi.fn(), onClose };
|
||||
});
|
||||
const runtime = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
};
|
||||
const controller = new AbortController();
|
||||
const run = monitorWebChannel(
|
||||
false,
|
||||
listenerFactory,
|
||||
true,
|
||||
async () => ({ text: "ok" }),
|
||||
runtime as never,
|
||||
controller.signal,
|
||||
{
|
||||
heartbeatSeconds: 1,
|
||||
reconnect: { initialMs: 10, maxMs: 10, maxAttempts: 3, factor: 1.1 },
|
||||
sleep,
|
||||
},
|
||||
);
|
||||
|
||||
await Promise.resolve();
|
||||
expect(listenerFactory).toHaveBeenCalledTimes(1);
|
||||
|
||||
closeResolvers[0]?.();
|
||||
const waitForSecondCall = async () => {
|
||||
const started = Date.now();
|
||||
while (
|
||||
listenerFactory.mock.calls.length < 2 &&
|
||||
Date.now() - started < 200
|
||||
) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
};
|
||||
await waitForSecondCall();
|
||||
expect(listenerFactory).toHaveBeenCalledTimes(2);
|
||||
expect(runtime.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Retry 1"),
|
||||
);
|
||||
|
||||
controller.abort();
|
||||
closeResolvers[1]?.();
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
await run;
|
||||
});
|
||||
it("forces reconnect when watchdog closes without onClose", async () => {
|
||||
vi.useFakeTimers();
|
||||
const sleep = vi.fn(async () => {});
|
||||
const closeResolvers: Array<(reason: unknown) => void> = [];
|
||||
let capturedOnMessage:
|
||||
| ((msg: import("./inbound.js").WebInboundMessage) => Promise<void>)
|
||||
| undefined;
|
||||
const listenerFactory = vi.fn(
|
||||
async (opts: {
|
||||
onMessage: (
|
||||
msg: import("./inbound.js").WebInboundMessage,
|
||||
) => Promise<void>;
|
||||
}) => {
|
||||
capturedOnMessage = opts.onMessage;
|
||||
let resolveClose: (reason: unknown) => void = () => {};
|
||||
const onClose = new Promise<unknown>((res) => {
|
||||
resolveClose = res;
|
||||
closeResolvers.push(res);
|
||||
});
|
||||
return {
|
||||
close: vi.fn(),
|
||||
onClose,
|
||||
signalClose: (reason?: unknown) => resolveClose(reason),
|
||||
};
|
||||
},
|
||||
);
|
||||
const runtime = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
};
|
||||
const controller = new AbortController();
|
||||
const run = monitorWebChannel(
|
||||
false,
|
||||
listenerFactory,
|
||||
true,
|
||||
async () => ({ text: "ok" }),
|
||||
runtime as never,
|
||||
controller.signal,
|
||||
{
|
||||
heartbeatSeconds: 1,
|
||||
reconnect: { initialMs: 10, maxMs: 10, maxAttempts: 3, factor: 1.1 },
|
||||
sleep,
|
||||
},
|
||||
);
|
||||
|
||||
await Promise.resolve();
|
||||
expect(listenerFactory).toHaveBeenCalledTimes(1);
|
||||
|
||||
const reply = vi.fn().mockResolvedValue(undefined);
|
||||
const sendComposing = vi.fn();
|
||||
const sendMedia = vi.fn();
|
||||
await capturedOnMessage?.({
|
||||
body: "hi",
|
||||
from: "+1",
|
||||
to: "+2",
|
||||
id: "m1",
|
||||
sendComposing,
|
||||
reply,
|
||||
sendMedia,
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(31 * 60 * 1000);
|
||||
await Promise.resolve();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
await Promise.resolve();
|
||||
expect(listenerFactory).toHaveBeenCalledTimes(2);
|
||||
|
||||
controller.abort();
|
||||
closeResolvers[1]?.({ status: 499, isLoggedOut: false });
|
||||
await Promise.resolve();
|
||||
await run;
|
||||
}, 15_000);
|
||||
|
||||
it(
|
||||
"stops after hitting max reconnect attempts",
|
||||
{ timeout: 20000 },
|
||||
async () => {
|
||||
const closeResolvers: Array<() => void> = [];
|
||||
const sleep = vi.fn(async () => {});
|
||||
const listenerFactory = vi.fn(async () => {
|
||||
const onClose = new Promise<void>((res) => closeResolvers.push(res));
|
||||
return { close: vi.fn(), onClose };
|
||||
});
|
||||
const runtime = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
};
|
||||
|
||||
const run = monitorWebChannel(
|
||||
false,
|
||||
listenerFactory,
|
||||
true,
|
||||
async () => ({ text: "ok" }),
|
||||
runtime as never,
|
||||
undefined,
|
||||
{
|
||||
heartbeatSeconds: 1,
|
||||
reconnect: { initialMs: 5, maxMs: 5, maxAttempts: 2, factor: 1.1 },
|
||||
sleep,
|
||||
},
|
||||
);
|
||||
|
||||
await Promise.resolve();
|
||||
expect(listenerFactory).toHaveBeenCalledTimes(1);
|
||||
|
||||
closeResolvers.shift()?.();
|
||||
await new Promise((resolve) => setTimeout(resolve, 15));
|
||||
expect(listenerFactory).toHaveBeenCalledTimes(2);
|
||||
|
||||
closeResolvers.shift()?.();
|
||||
await new Promise((resolve) => setTimeout(resolve, 15));
|
||||
await run;
|
||||
|
||||
expect(runtime.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining("max attempts reached"),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("processes inbound messages without batching and preserves timestamps", async () => {
|
||||
const originalTz = process.env.TZ;
|
||||
process.env.TZ = "Europe/Vienna";
|
||||
|
||||
const originalMax = process.getMaxListeners();
|
||||
process.setMaxListeners?.(1); // force low to confirm bump
|
||||
|
||||
const store = await makeSessionStore({
|
||||
main: { sessionId: "sid", updatedAt: Date.now() },
|
||||
});
|
||||
|
||||
try {
|
||||
const sendMedia = vi.fn();
|
||||
const reply = vi.fn().mockResolvedValue(undefined);
|
||||
const sendComposing = vi.fn();
|
||||
const resolver = vi.fn().mockResolvedValue({ text: "ok" });
|
||||
|
||||
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() };
|
||||
};
|
||||
|
||||
setLoadConfigMock(() => ({
|
||||
session: { store: store.storePath },
|
||||
}));
|
||||
|
||||
await monitorWebChannel(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,
|
||||
});
|
||||
|
||||
expect(resolver).toHaveBeenCalledTimes(2);
|
||||
const firstArgs = resolver.mock.calls[0][0];
|
||||
const secondArgs = resolver.mock.calls[1][0];
|
||||
expect(firstArgs.Body).toContain(
|
||||
"[WhatsApp +1 2025-01-01T00:00Z] [clawdbot] first",
|
||||
);
|
||||
expect(firstArgs.Body).not.toContain("second");
|
||||
expect(secondArgs.Body).toContain(
|
||||
"[WhatsApp +1 2025-01-01T01:00Z] [clawdbot] second",
|
||||
);
|
||||
expect(secondArgs.Body).not.toContain("first");
|
||||
|
||||
// Max listeners bumped to avoid warnings in multi-instance test runs
|
||||
expect(process.getMaxListeners?.()).toBeGreaterThanOrEqual(50);
|
||||
} finally {
|
||||
process.setMaxListeners?.(originalMax);
|
||||
process.env.TZ = originalTz;
|
||||
await store.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
293
src/web/auto-reply.web-auto-reply.part-2.test.ts
Normal file
293
src/web/auto-reply.web-auto-reply.part-2.test.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
import "./test-helpers.js";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import sharp from "sharp";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../agents/pi-embedded.js", () => ({
|
||||
abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
|
||||
isEmbeddedPiRunActive: vi.fn().mockReturnValue(false),
|
||||
isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false),
|
||||
runEmbeddedPiAgent: vi.fn(),
|
||||
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
|
||||
resolveEmbeddedSessionLane: (key: string) =>
|
||||
`session:${key.trim() || "main"}`,
|
||||
}));
|
||||
|
||||
import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js";
|
||||
import { resetLogger, setLoggerOverride } from "../logging.js";
|
||||
import { monitorWebChannel } from "./auto-reply.js";
|
||||
import { resetBaileysMocks, resetLoadConfigMock } from "./test-helpers.js";
|
||||
|
||||
let previousHome: string | undefined;
|
||||
let tempHome: string | undefined;
|
||||
|
||||
const rmDirWithRetries = async (dir: string): Promise<void> => {
|
||||
// Some tests can leave async session-store writes in-flight; recursive deletion can race and throw ENOTEMPTY.
|
||||
for (let attempt = 0; attempt < 10; attempt += 1) {
|
||||
try {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
return;
|
||||
} catch (err) {
|
||||
const code =
|
||||
err && typeof err === "object" && "code" in err
|
||||
? String((err as { code?: unknown }).code)
|
||||
: null;
|
||||
if (code === "ENOTEMPTY" || code === "EBUSY" || code === "EPERM") {
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
resetInboundDedupe();
|
||||
previousHome = process.env.HOME;
|
||||
tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-web-home-"));
|
||||
process.env.HOME = tempHome;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
process.env.HOME = previousHome;
|
||||
if (tempHome) {
|
||||
await rmDirWithRetries(tempHome);
|
||||
tempHome = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
const _makeSessionStore = async (
|
||||
entries: Record<string, unknown> = {},
|
||||
): Promise<{ storePath: string; cleanup: () => Promise<void> }> => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-session-"));
|
||||
const storePath = path.join(dir, "sessions.json");
|
||||
await fs.writeFile(storePath, JSON.stringify(entries));
|
||||
const cleanup = async () => {
|
||||
// Session store writes can be in-flight when the test finishes (e.g. updateLastRoute
|
||||
// after a message flush). `fs.rm({ recursive })` can race and throw ENOTEMPTY.
|
||||
for (let attempt = 0; attempt < 10; attempt += 1) {
|
||||
try {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
return;
|
||||
} catch (err) {
|
||||
const code =
|
||||
err && typeof err === "object" && "code" in err
|
||||
? String((err as { code?: unknown }).code)
|
||||
: null;
|
||||
if (code === "ENOTEMPTY" || code === "EBUSY" || code === "EPERM") {
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
};
|
||||
return {
|
||||
storePath,
|
||||
cleanup,
|
||||
};
|
||||
};
|
||||
|
||||
describe("web auto-reply", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resetBaileysMocks();
|
||||
resetLoadConfigMock();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetLogger();
|
||||
setLoggerOverride(null);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("falls back to text when media send fails", async () => {
|
||||
const sendMedia = vi.fn().mockRejectedValue(new Error("boom"));
|
||||
const reply = vi.fn().mockResolvedValue(undefined);
|
||||
const sendComposing = vi.fn();
|
||||
const resolver = vi.fn().mockResolvedValue({
|
||||
text: "hi",
|
||||
mediaUrl: "https://example.com/img.png",
|
||||
});
|
||||
|
||||
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() };
|
||||
};
|
||||
|
||||
const smallPng = await sharp({
|
||||
create: {
|
||||
width: 200,
|
||||
height: 200,
|
||||
channels: 3,
|
||||
background: { r: 0, g: 255, b: 0 },
|
||||
},
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
body: true,
|
||||
arrayBuffer: async () =>
|
||||
smallPng.buffer.slice(
|
||||
smallPng.byteOffset,
|
||||
smallPng.byteOffset + smallPng.byteLength,
|
||||
),
|
||||
headers: { get: () => "image/png" },
|
||||
status: 200,
|
||||
} as Response);
|
||||
|
||||
await monitorWebChannel(false, listenerFactory, false, resolver);
|
||||
|
||||
expect(capturedOnMessage).toBeDefined();
|
||||
await capturedOnMessage?.({
|
||||
body: "hello",
|
||||
from: "+1",
|
||||
to: "+2",
|
||||
id: "msg1",
|
||||
sendComposing,
|
||||
reply,
|
||||
sendMedia,
|
||||
});
|
||||
|
||||
expect(sendMedia).toHaveBeenCalledTimes(1);
|
||||
const fallback = reply.mock.calls[0]?.[0] as string;
|
||||
expect(fallback).toContain("hi");
|
||||
expect(fallback).toContain("Media failed");
|
||||
fetchMock.mockRestore();
|
||||
});
|
||||
it("returns a warning when remote media fetch 404s", async () => {
|
||||
const sendMedia = vi.fn();
|
||||
const reply = vi.fn().mockResolvedValue(undefined);
|
||||
const sendComposing = vi.fn();
|
||||
const resolver = vi.fn().mockResolvedValue({
|
||||
text: "caption",
|
||||
mediaUrl: "https://example.com/missing.jpg",
|
||||
});
|
||||
|
||||
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() };
|
||||
};
|
||||
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404,
|
||||
body: null,
|
||||
arrayBuffer: async () => new ArrayBuffer(0),
|
||||
headers: { get: () => "text/plain" },
|
||||
} as unknown as Response);
|
||||
|
||||
await monitorWebChannel(false, listenerFactory, false, resolver);
|
||||
expect(capturedOnMessage).toBeDefined();
|
||||
|
||||
await capturedOnMessage?.({
|
||||
body: "hello",
|
||||
from: "+1",
|
||||
to: "+2",
|
||||
id: "msg1",
|
||||
sendComposing,
|
||||
reply,
|
||||
sendMedia,
|
||||
});
|
||||
|
||||
expect(sendMedia).not.toHaveBeenCalled();
|
||||
const fallback = reply.mock.calls[0]?.[0] as string;
|
||||
expect(fallback).toContain("caption");
|
||||
expect(fallback).toContain("Media failed");
|
||||
expect(fallback).toContain("404");
|
||||
|
||||
fetchMock.mockRestore();
|
||||
});
|
||||
it("compresses media over 5MB and still sends it", async () => {
|
||||
const sendMedia = vi.fn();
|
||||
const reply = vi.fn().mockResolvedValue(undefined);
|
||||
const sendComposing = vi.fn();
|
||||
const resolver = vi.fn().mockResolvedValue({
|
||||
text: "hi",
|
||||
mediaUrl: "https://example.com/big.png",
|
||||
});
|
||||
|
||||
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() };
|
||||
};
|
||||
|
||||
const bigPng = await sharp({
|
||||
create: {
|
||||
width: 3200,
|
||||
height: 3200,
|
||||
channels: 3,
|
||||
background: { r: 255, g: 0, b: 0 },
|
||||
},
|
||||
})
|
||||
.png({ compressionLevel: 0 })
|
||||
.toBuffer();
|
||||
expect(bigPng.length).toBeGreaterThan(5 * 1024 * 1024);
|
||||
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
body: true,
|
||||
arrayBuffer: async () =>
|
||||
bigPng.buffer.slice(
|
||||
bigPng.byteOffset,
|
||||
bigPng.byteOffset + bigPng.byteLength,
|
||||
),
|
||||
headers: { get: () => "image/png" },
|
||||
status: 200,
|
||||
} as Response);
|
||||
|
||||
await monitorWebChannel(false, listenerFactory, false, resolver);
|
||||
expect(capturedOnMessage).toBeDefined();
|
||||
|
||||
await capturedOnMessage?.({
|
||||
body: "hello",
|
||||
from: "+1",
|
||||
to: "+2",
|
||||
id: "msg1",
|
||||
sendComposing,
|
||||
reply,
|
||||
sendMedia,
|
||||
});
|
||||
|
||||
expect(sendMedia).toHaveBeenCalledTimes(1);
|
||||
const payload = sendMedia.mock.calls[0][0] as {
|
||||
image: Buffer;
|
||||
caption?: string;
|
||||
mimetype?: string;
|
||||
};
|
||||
expect(payload.image.length).toBeLessThanOrEqual(5 * 1024 * 1024);
|
||||
expect(payload.mimetype).toBe("image/jpeg");
|
||||
// Should not fall back to separate text reply because caption is used.
|
||||
expect(reply).not.toHaveBeenCalled();
|
||||
|
||||
fetchMock.mockRestore();
|
||||
});
|
||||
});
|
||||
344
src/web/auto-reply.web-auto-reply.part-3.test.ts
Normal file
344
src/web/auto-reply.web-auto-reply.part-3.test.ts
Normal file
@@ -0,0 +1,344 @@
|
||||
import "./test-helpers.js";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import sharp from "sharp";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../agents/pi-embedded.js", () => ({
|
||||
abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
|
||||
isEmbeddedPiRunActive: vi.fn().mockReturnValue(false),
|
||||
isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false),
|
||||
runEmbeddedPiAgent: vi.fn(),
|
||||
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
|
||||
resolveEmbeddedSessionLane: (key: string) =>
|
||||
`session:${key.trim() || "main"}`,
|
||||
}));
|
||||
|
||||
import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js";
|
||||
import { resetLogger, setLoggerOverride } from "../logging.js";
|
||||
import { monitorWebChannel } from "./auto-reply.js";
|
||||
import {
|
||||
resetBaileysMocks,
|
||||
resetLoadConfigMock,
|
||||
setLoadConfigMock,
|
||||
} from "./test-helpers.js";
|
||||
|
||||
let previousHome: string | undefined;
|
||||
let tempHome: string | undefined;
|
||||
|
||||
const rmDirWithRetries = async (dir: string): Promise<void> => {
|
||||
// Some tests can leave async session-store writes in-flight; recursive deletion can race and throw ENOTEMPTY.
|
||||
for (let attempt = 0; attempt < 10; attempt += 1) {
|
||||
try {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
return;
|
||||
} catch (err) {
|
||||
const code =
|
||||
err && typeof err === "object" && "code" in err
|
||||
? String((err as { code?: unknown }).code)
|
||||
: null;
|
||||
if (code === "ENOTEMPTY" || code === "EBUSY" || code === "EPERM") {
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
resetInboundDedupe();
|
||||
previousHome = process.env.HOME;
|
||||
tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-web-home-"));
|
||||
process.env.HOME = tempHome;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
process.env.HOME = previousHome;
|
||||
if (tempHome) {
|
||||
await rmDirWithRetries(tempHome);
|
||||
tempHome = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
const _makeSessionStore = async (
|
||||
entries: Record<string, unknown> = {},
|
||||
): Promise<{ storePath: string; cleanup: () => Promise<void> }> => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-session-"));
|
||||
const storePath = path.join(dir, "sessions.json");
|
||||
await fs.writeFile(storePath, JSON.stringify(entries));
|
||||
const cleanup = async () => {
|
||||
// Session store writes can be in-flight when the test finishes (e.g. updateLastRoute
|
||||
// after a message flush). `fs.rm({ recursive })` can race and throw ENOTEMPTY.
|
||||
for (let attempt = 0; attempt < 10; attempt += 1) {
|
||||
try {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
return;
|
||||
} catch (err) {
|
||||
const code =
|
||||
err && typeof err === "object" && "code" in err
|
||||
? String((err as { code?: unknown }).code)
|
||||
: null;
|
||||
if (code === "ENOTEMPTY" || code === "EBUSY" || code === "EPERM") {
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
};
|
||||
return {
|
||||
storePath,
|
||||
cleanup,
|
||||
};
|
||||
};
|
||||
|
||||
describe("web auto-reply", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resetBaileysMocks();
|
||||
resetLoadConfigMock();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetLogger();
|
||||
setLoggerOverride(null);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it(
|
||||
"compresses common formats to jpeg under the cap",
|
||||
{ timeout: 45_000 },
|
||||
async () => {
|
||||
const formats = [
|
||||
{
|
||||
name: "png",
|
||||
mime: "image/png",
|
||||
make: (buf: Buffer, opts: { width: number; height: number }) =>
|
||||
sharp(buf, {
|
||||
raw: { width: opts.width, height: opts.height, channels: 3 },
|
||||
})
|
||||
.png({ compressionLevel: 0 })
|
||||
.toBuffer(),
|
||||
},
|
||||
{
|
||||
name: "jpeg",
|
||||
mime: "image/jpeg",
|
||||
make: (buf: Buffer, opts: { width: number; height: number }) =>
|
||||
sharp(buf, {
|
||||
raw: { width: opts.width, height: opts.height, channels: 3 },
|
||||
})
|
||||
.jpeg({ quality: 100, chromaSubsampling: "4:4:4" })
|
||||
.toBuffer(),
|
||||
},
|
||||
{
|
||||
name: "webp",
|
||||
mime: "image/webp",
|
||||
make: (buf: Buffer, opts: { width: number; height: number }) =>
|
||||
sharp(buf, {
|
||||
raw: { width: opts.width, height: opts.height, channels: 3 },
|
||||
})
|
||||
.webp({ quality: 100 })
|
||||
.toBuffer(),
|
||||
},
|
||||
] as const;
|
||||
|
||||
for (const fmt of formats) {
|
||||
// Force a small cap to ensure compression is exercised for every format.
|
||||
setLoadConfigMock(() => ({ agents: { defaults: { mediaMaxMb: 1 } } }));
|
||||
const sendMedia = vi.fn();
|
||||
const reply = vi.fn().mockResolvedValue(undefined);
|
||||
const sendComposing = vi.fn();
|
||||
const resolver = vi.fn().mockResolvedValue({
|
||||
text: "hi",
|
||||
mediaUrl: `https://example.com/big.${fmt.name}`,
|
||||
});
|
||||
|
||||
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() };
|
||||
};
|
||||
|
||||
const width = 1200;
|
||||
const height = 1200;
|
||||
const raw = crypto.randomBytes(width * height * 3);
|
||||
const big = await fmt.make(raw, { width, height });
|
||||
expect(big.length).toBeGreaterThan(1 * 1024 * 1024);
|
||||
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
body: true,
|
||||
arrayBuffer: async () =>
|
||||
big.buffer.slice(big.byteOffset, big.byteOffset + big.byteLength),
|
||||
headers: { get: () => fmt.mime },
|
||||
status: 200,
|
||||
} as Response);
|
||||
|
||||
await monitorWebChannel(false, listenerFactory, false, resolver);
|
||||
expect(capturedOnMessage).toBeDefined();
|
||||
|
||||
await capturedOnMessage?.({
|
||||
body: "hello",
|
||||
from: "+1",
|
||||
to: "+2",
|
||||
id: `msg-${fmt.name}`,
|
||||
sendComposing,
|
||||
reply,
|
||||
sendMedia,
|
||||
});
|
||||
|
||||
expect(sendMedia).toHaveBeenCalledTimes(1);
|
||||
const payload = sendMedia.mock.calls[0][0] as {
|
||||
image: Buffer;
|
||||
mimetype?: string;
|
||||
};
|
||||
expect(payload.image.length).toBeLessThanOrEqual(1 * 1024 * 1024);
|
||||
expect(payload.mimetype).toBe("image/jpeg");
|
||||
expect(reply).not.toHaveBeenCalled();
|
||||
|
||||
fetchMock.mockRestore();
|
||||
resetLoadConfigMock();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("honors mediaMaxMb from config", async () => {
|
||||
setLoadConfigMock(() => ({ agents: { defaults: { mediaMaxMb: 1 } } }));
|
||||
const sendMedia = vi.fn();
|
||||
const reply = vi.fn().mockResolvedValue(undefined);
|
||||
const sendComposing = vi.fn();
|
||||
const resolver = vi.fn().mockResolvedValue({
|
||||
text: "hi",
|
||||
mediaUrl: "https://example.com/big.png",
|
||||
});
|
||||
|
||||
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() };
|
||||
};
|
||||
|
||||
const bigPng = await sharp({
|
||||
create: {
|
||||
width: 2600,
|
||||
height: 2600,
|
||||
channels: 3,
|
||||
background: { r: 0, g: 0, b: 255 },
|
||||
},
|
||||
})
|
||||
.png({ compressionLevel: 0 })
|
||||
.toBuffer();
|
||||
expect(bigPng.length).toBeGreaterThan(1 * 1024 * 1024);
|
||||
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
body: true,
|
||||
arrayBuffer: async () =>
|
||||
bigPng.buffer.slice(
|
||||
bigPng.byteOffset,
|
||||
bigPng.byteOffset + bigPng.byteLength,
|
||||
),
|
||||
headers: { get: () => "image/png" },
|
||||
status: 200,
|
||||
} as Response);
|
||||
|
||||
await monitorWebChannel(false, listenerFactory, false, resolver);
|
||||
expect(capturedOnMessage).toBeDefined();
|
||||
|
||||
await capturedOnMessage?.({
|
||||
body: "hello",
|
||||
from: "+1",
|
||||
to: "+2",
|
||||
id: "msg1",
|
||||
sendComposing,
|
||||
reply,
|
||||
sendMedia,
|
||||
});
|
||||
|
||||
expect(sendMedia).toHaveBeenCalledTimes(1);
|
||||
const payload = sendMedia.mock.calls[0][0] as {
|
||||
image: Buffer;
|
||||
caption?: string;
|
||||
mimetype?: string;
|
||||
};
|
||||
expect(payload.image.length).toBeLessThanOrEqual(1 * 1024 * 1024);
|
||||
expect(payload.mimetype).toBe("image/jpeg");
|
||||
expect(reply).not.toHaveBeenCalled();
|
||||
|
||||
fetchMock.mockRestore();
|
||||
});
|
||||
it("falls back to text when media is unsupported", async () => {
|
||||
const sendMedia = vi.fn();
|
||||
const reply = vi.fn().mockResolvedValue(undefined);
|
||||
const sendComposing = vi.fn();
|
||||
const resolver = vi.fn().mockResolvedValue({
|
||||
text: "hi",
|
||||
mediaUrl: "https://example.com/file.pdf",
|
||||
});
|
||||
|
||||
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() };
|
||||
};
|
||||
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
body: true,
|
||||
arrayBuffer: async () => Buffer.from("%PDF-1.4").buffer,
|
||||
headers: { get: () => "application/pdf" },
|
||||
status: 200,
|
||||
} as Response);
|
||||
|
||||
await monitorWebChannel(false, listenerFactory, false, resolver);
|
||||
expect(capturedOnMessage).toBeDefined();
|
||||
|
||||
await capturedOnMessage?.({
|
||||
body: "hello",
|
||||
from: "+1",
|
||||
to: "+2",
|
||||
id: "msg-pdf",
|
||||
sendComposing,
|
||||
reply,
|
||||
sendMedia,
|
||||
});
|
||||
|
||||
expect(sendMedia).toHaveBeenCalledTimes(1);
|
||||
const payload = sendMedia.mock.calls[0][0] as {
|
||||
document?: Buffer;
|
||||
caption?: string;
|
||||
fileName?: string;
|
||||
};
|
||||
expect(payload.document).toBeInstanceOf(Buffer);
|
||||
expect(payload.fileName).toBe("file.pdf");
|
||||
expect(payload.caption).toBe("hi");
|
||||
expect(reply).not.toHaveBeenCalled();
|
||||
|
||||
fetchMock.mockRestore();
|
||||
});
|
||||
});
|
||||
363
src/web/auto-reply.web-auto-reply.part-4.test.ts
Normal file
363
src/web/auto-reply.web-auto-reply.part-4.test.ts
Normal file
@@ -0,0 +1,363 @@
|
||||
import "./test-helpers.js";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../agents/pi-embedded.js", () => ({
|
||||
abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
|
||||
isEmbeddedPiRunActive: vi.fn().mockReturnValue(false),
|
||||
isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false),
|
||||
runEmbeddedPiAgent: vi.fn(),
|
||||
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
|
||||
resolveEmbeddedSessionLane: (key: string) =>
|
||||
`session:${key.trim() || "main"}`,
|
||||
}));
|
||||
|
||||
import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js";
|
||||
import { resetLogger, setLoggerOverride } from "../logging.js";
|
||||
import { monitorWebChannel } from "./auto-reply.js";
|
||||
import {
|
||||
resetBaileysMocks,
|
||||
resetLoadConfigMock,
|
||||
setLoadConfigMock,
|
||||
} from "./test-helpers.js";
|
||||
|
||||
let previousHome: string | undefined;
|
||||
let tempHome: string | undefined;
|
||||
|
||||
const rmDirWithRetries = async (dir: string): Promise<void> => {
|
||||
// Some tests can leave async session-store writes in-flight; recursive deletion can race and throw ENOTEMPTY.
|
||||
for (let attempt = 0; attempt < 10; attempt += 1) {
|
||||
try {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
return;
|
||||
} catch (err) {
|
||||
const code =
|
||||
err && typeof err === "object" && "code" in err
|
||||
? String((err as { code?: unknown }).code)
|
||||
: null;
|
||||
if (code === "ENOTEMPTY" || code === "EBUSY" || code === "EPERM") {
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
resetInboundDedupe();
|
||||
previousHome = process.env.HOME;
|
||||
tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-web-home-"));
|
||||
process.env.HOME = tempHome;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
process.env.HOME = previousHome;
|
||||
if (tempHome) {
|
||||
await rmDirWithRetries(tempHome);
|
||||
tempHome = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
const _makeSessionStore = async (
|
||||
entries: Record<string, unknown> = {},
|
||||
): Promise<{ storePath: string; cleanup: () => Promise<void> }> => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-session-"));
|
||||
const storePath = path.join(dir, "sessions.json");
|
||||
await fs.writeFile(storePath, JSON.stringify(entries));
|
||||
const cleanup = async () => {
|
||||
// Session store writes can be in-flight when the test finishes (e.g. updateLastRoute
|
||||
// after a message flush). `fs.rm({ recursive })` can race and throw ENOTEMPTY.
|
||||
for (let attempt = 0; attempt < 10; attempt += 1) {
|
||||
try {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
return;
|
||||
} catch (err) {
|
||||
const code =
|
||||
err && typeof err === "object" && "code" in err
|
||||
? String((err as { code?: unknown }).code)
|
||||
: null;
|
||||
if (code === "ENOTEMPTY" || code === "EBUSY" || code === "EPERM") {
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
};
|
||||
return {
|
||||
storePath,
|
||||
cleanup,
|
||||
};
|
||||
};
|
||||
|
||||
describe("web auto-reply", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resetBaileysMocks();
|
||||
resetLoadConfigMock();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetLogger();
|
||||
setLoggerOverride(null);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("requires mention in group chats and injects history when replying", async () => {
|
||||
const sendMedia = vi.fn();
|
||||
const reply = vi.fn().mockResolvedValue(undefined);
|
||||
const sendComposing = vi.fn();
|
||||
const resolver = vi.fn().mockResolvedValue({ text: "ok" });
|
||||
|
||||
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() };
|
||||
};
|
||||
|
||||
await monitorWebChannel(false, listenerFactory, false, resolver);
|
||||
expect(capturedOnMessage).toBeDefined();
|
||||
|
||||
await capturedOnMessage?.({
|
||||
body: "hello group",
|
||||
from: "123@g.us",
|
||||
conversationId: "123@g.us",
|
||||
chatId: "123@g.us",
|
||||
chatType: "group",
|
||||
to: "+2",
|
||||
id: "g1",
|
||||
senderE164: "+111",
|
||||
senderName: "Alice",
|
||||
selfE164: "+999",
|
||||
sendComposing,
|
||||
reply,
|
||||
sendMedia,
|
||||
});
|
||||
|
||||
expect(resolver).not.toHaveBeenCalled();
|
||||
|
||||
await capturedOnMessage?.({
|
||||
body: "@bot ping",
|
||||
from: "123@g.us",
|
||||
conversationId: "123@g.us",
|
||||
chatId: "123@g.us",
|
||||
chatType: "group",
|
||||
to: "+2",
|
||||
id: "g2",
|
||||
senderE164: "+222",
|
||||
senderName: "Bob",
|
||||
mentionedJids: ["999@s.whatsapp.net"],
|
||||
selfE164: "+999",
|
||||
selfJid: "999@s.whatsapp.net",
|
||||
sendComposing,
|
||||
reply,
|
||||
sendMedia,
|
||||
});
|
||||
|
||||
expect(resolver).toHaveBeenCalledTimes(1);
|
||||
const payload = resolver.mock.calls[0][0];
|
||||
expect(payload.Body).toContain("Chat messages since your last reply");
|
||||
expect(payload.Body).toContain("Alice (+111): hello group");
|
||||
expect(payload.Body).toContain("[message_id: g1]");
|
||||
expect(payload.Body).toContain("@bot ping");
|
||||
expect(payload.Body).toContain("[from: Bob (+222)]");
|
||||
});
|
||||
it("detects LID mentions using authDir mapping", async () => {
|
||||
const sendMedia = vi.fn();
|
||||
const reply = vi.fn().mockResolvedValue(undefined);
|
||||
const sendComposing = vi.fn();
|
||||
const resolver = vi.fn().mockResolvedValue({ text: "ok" });
|
||||
|
||||
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() };
|
||||
};
|
||||
|
||||
const authDir = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), "clawdbot-wa-auth-"),
|
||||
);
|
||||
|
||||
try {
|
||||
await fs.writeFile(
|
||||
path.join(authDir, "lid-mapping-555_reverse.json"),
|
||||
JSON.stringify("15551234"),
|
||||
);
|
||||
|
||||
setLoadConfigMock(() => ({
|
||||
channels: {
|
||||
whatsapp: {
|
||||
allowFrom: ["*"],
|
||||
accounts: {
|
||||
default: { authDir },
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
await monitorWebChannel(false, listenerFactory, false, resolver);
|
||||
expect(capturedOnMessage).toBeDefined();
|
||||
|
||||
await capturedOnMessage?.({
|
||||
body: "hello group",
|
||||
from: "123@g.us",
|
||||
conversationId: "123@g.us",
|
||||
chatId: "123@g.us",
|
||||
chatType: "group",
|
||||
to: "+2",
|
||||
id: "g1",
|
||||
senderE164: "+111",
|
||||
senderName: "Alice",
|
||||
selfE164: "+15551234",
|
||||
sendComposing,
|
||||
reply,
|
||||
sendMedia,
|
||||
});
|
||||
|
||||
await capturedOnMessage?.({
|
||||
body: "@bot ping",
|
||||
from: "123@g.us",
|
||||
conversationId: "123@g.us",
|
||||
chatId: "123@g.us",
|
||||
chatType: "group",
|
||||
to: "+2",
|
||||
id: "g2",
|
||||
senderE164: "+222",
|
||||
senderName: "Bob",
|
||||
mentionedJids: ["555@lid"],
|
||||
selfE164: "+15551234",
|
||||
selfJid: "15551234@s.whatsapp.net",
|
||||
sendComposing,
|
||||
reply,
|
||||
sendMedia,
|
||||
});
|
||||
|
||||
expect(resolver).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
resetLoadConfigMock();
|
||||
await rmDirWithRetries(authDir);
|
||||
}
|
||||
});
|
||||
it("derives self E.164 from LID selfJid for mention gating", async () => {
|
||||
const sendMedia = vi.fn();
|
||||
const reply = vi.fn().mockResolvedValue(undefined);
|
||||
const sendComposing = vi.fn();
|
||||
const resolver = vi.fn().mockResolvedValue({ text: "ok" });
|
||||
|
||||
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() };
|
||||
};
|
||||
|
||||
const authDir = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), "clawdbot-wa-auth-"),
|
||||
);
|
||||
|
||||
try {
|
||||
await fs.writeFile(
|
||||
path.join(authDir, "lid-mapping-777_reverse.json"),
|
||||
JSON.stringify("15550077"),
|
||||
);
|
||||
|
||||
setLoadConfigMock(() => ({
|
||||
channels: {
|
||||
whatsapp: {
|
||||
allowFrom: ["*"],
|
||||
accounts: {
|
||||
default: { authDir },
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
await monitorWebChannel(false, listenerFactory, false, resolver);
|
||||
expect(capturedOnMessage).toBeDefined();
|
||||
|
||||
await capturedOnMessage?.({
|
||||
body: "@bot ping",
|
||||
from: "123@g.us",
|
||||
conversationId: "123@g.us",
|
||||
chatId: "123@g.us",
|
||||
chatType: "group",
|
||||
to: "+2",
|
||||
id: "g3",
|
||||
senderE164: "+333",
|
||||
senderName: "Cara",
|
||||
mentionedJids: ["777@lid"],
|
||||
selfJid: "777@lid",
|
||||
sendComposing,
|
||||
reply,
|
||||
sendMedia,
|
||||
});
|
||||
|
||||
expect(resolver).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
resetLoadConfigMock();
|
||||
await rmDirWithRetries(authDir);
|
||||
}
|
||||
});
|
||||
it("sets OriginatingTo to the sender for queued routing", async () => {
|
||||
const sendMedia = vi.fn();
|
||||
const reply = vi.fn().mockResolvedValue(undefined);
|
||||
const sendComposing = vi.fn();
|
||||
const resolver = vi.fn().mockResolvedValue({ text: "ok" });
|
||||
|
||||
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() };
|
||||
};
|
||||
|
||||
await monitorWebChannel(false, listenerFactory, false, resolver);
|
||||
expect(capturedOnMessage).toBeDefined();
|
||||
|
||||
await capturedOnMessage?.({
|
||||
body: "hello",
|
||||
from: "+15551234567",
|
||||
to: "+19998887777",
|
||||
id: "m-originating",
|
||||
sendComposing,
|
||||
reply,
|
||||
sendMedia,
|
||||
});
|
||||
|
||||
expect(resolver).toHaveBeenCalledTimes(1);
|
||||
const payload = resolver.mock.calls[0][0];
|
||||
expect(payload.OriginatingChannel).toBe("whatsapp");
|
||||
expect(payload.OriginatingTo).toBe("+15551234567");
|
||||
expect(payload.To).toBe("+19998887777");
|
||||
expect(payload.OriginatingTo).not.toBe(payload.To);
|
||||
});
|
||||
});
|
||||
352
src/web/auto-reply.web-auto-reply.part-5.test.ts
Normal file
352
src/web/auto-reply.web-auto-reply.part-5.test.ts
Normal file
@@ -0,0 +1,352 @@
|
||||
import "./test-helpers.js";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../agents/pi-embedded.js", () => ({
|
||||
abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
|
||||
isEmbeddedPiRunActive: vi.fn().mockReturnValue(false),
|
||||
isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false),
|
||||
runEmbeddedPiAgent: vi.fn(),
|
||||
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
|
||||
resolveEmbeddedSessionLane: (key: string) =>
|
||||
`session:${key.trim() || "main"}`,
|
||||
}));
|
||||
|
||||
import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js";
|
||||
import { resetLogger, setLoggerOverride } from "../logging.js";
|
||||
import { monitorWebChannel } from "./auto-reply.js";
|
||||
import {
|
||||
resetBaileysMocks,
|
||||
resetLoadConfigMock,
|
||||
setLoadConfigMock,
|
||||
} from "./test-helpers.js";
|
||||
|
||||
let previousHome: string | undefined;
|
||||
let tempHome: string | undefined;
|
||||
|
||||
const rmDirWithRetries = async (dir: string): Promise<void> => {
|
||||
// Some tests can leave async session-store writes in-flight; recursive deletion can race and throw ENOTEMPTY.
|
||||
for (let attempt = 0; attempt < 10; attempt += 1) {
|
||||
try {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
return;
|
||||
} catch (err) {
|
||||
const code =
|
||||
err && typeof err === "object" && "code" in err
|
||||
? String((err as { code?: unknown }).code)
|
||||
: null;
|
||||
if (code === "ENOTEMPTY" || code === "EBUSY" || code === "EPERM") {
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
resetInboundDedupe();
|
||||
previousHome = process.env.HOME;
|
||||
tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-web-home-"));
|
||||
process.env.HOME = tempHome;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
process.env.HOME = previousHome;
|
||||
if (tempHome) {
|
||||
await rmDirWithRetries(tempHome);
|
||||
tempHome = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
const _makeSessionStore = async (
|
||||
entries: Record<string, unknown> = {},
|
||||
): Promise<{ storePath: string; cleanup: () => Promise<void> }> => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-session-"));
|
||||
const storePath = path.join(dir, "sessions.json");
|
||||
await fs.writeFile(storePath, JSON.stringify(entries));
|
||||
const cleanup = async () => {
|
||||
// Session store writes can be in-flight when the test finishes (e.g. updateLastRoute
|
||||
// after a message flush). `fs.rm({ recursive })` can race and throw ENOTEMPTY.
|
||||
for (let attempt = 0; attempt < 10; attempt += 1) {
|
||||
try {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
return;
|
||||
} catch (err) {
|
||||
const code =
|
||||
err && typeof err === "object" && "code" in err
|
||||
? String((err as { code?: unknown }).code)
|
||||
: null;
|
||||
if (code === "ENOTEMPTY" || code === "EBUSY" || code === "EPERM") {
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
};
|
||||
return {
|
||||
storePath,
|
||||
cleanup,
|
||||
};
|
||||
};
|
||||
|
||||
describe("web auto-reply", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resetBaileysMocks();
|
||||
resetLoadConfigMock();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetLogger();
|
||||
setLoggerOverride(null);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("uses per-agent mention patterns for group gating", async () => {
|
||||
const sendMedia = vi.fn();
|
||||
const reply = vi.fn().mockResolvedValue(undefined);
|
||||
const sendComposing = vi.fn();
|
||||
const resolver = vi.fn().mockResolvedValue({ text: "ok" });
|
||||
|
||||
setLoadConfigMock(() => ({
|
||||
channels: {
|
||||
whatsapp: {
|
||||
allowFrom: ["*"],
|
||||
groups: { "*": { requireMention: true } },
|
||||
},
|
||||
},
|
||||
messages: {
|
||||
groupChat: { mentionPatterns: ["@global"] },
|
||||
},
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "work",
|
||||
groupChat: { mentionPatterns: ["@workbot"] },
|
||||
},
|
||||
],
|
||||
},
|
||||
bindings: [
|
||||
{
|
||||
agentId: "work",
|
||||
match: {
|
||||
provider: "whatsapp",
|
||||
peer: { kind: "group", id: "123@g.us" },
|
||||
},
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
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() };
|
||||
};
|
||||
|
||||
await monitorWebChannel(false, listenerFactory, false, resolver);
|
||||
expect(capturedOnMessage).toBeDefined();
|
||||
|
||||
await capturedOnMessage?.({
|
||||
body: "@global ping",
|
||||
from: "123@g.us",
|
||||
conversationId: "123@g.us",
|
||||
chatId: "123@g.us",
|
||||
chatType: "group",
|
||||
to: "+2",
|
||||
id: "g1",
|
||||
senderE164: "+111",
|
||||
senderName: "Alice",
|
||||
selfE164: "+999",
|
||||
sendComposing,
|
||||
reply,
|
||||
sendMedia,
|
||||
});
|
||||
expect(resolver).not.toHaveBeenCalled();
|
||||
|
||||
await capturedOnMessage?.({
|
||||
body: "@workbot ping",
|
||||
from: "123@g.us",
|
||||
conversationId: "123@g.us",
|
||||
chatId: "123@g.us",
|
||||
chatType: "group",
|
||||
to: "+2",
|
||||
id: "g2",
|
||||
senderE164: "+222",
|
||||
senderName: "Bob",
|
||||
selfE164: "+999",
|
||||
sendComposing,
|
||||
reply,
|
||||
sendMedia,
|
||||
});
|
||||
expect(resolver).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it("allows group messages when whatsapp groups default disables mention gating", async () => {
|
||||
const sendMedia = vi.fn();
|
||||
const reply = vi.fn().mockResolvedValue(undefined);
|
||||
const sendComposing = vi.fn();
|
||||
const resolver = vi.fn().mockResolvedValue({ text: "ok" });
|
||||
|
||||
setLoadConfigMock(() => ({
|
||||
channels: {
|
||||
whatsapp: {
|
||||
allowFrom: ["*"],
|
||||
groups: { "*": { requireMention: false } },
|
||||
},
|
||||
},
|
||||
messages: { groupChat: { mentionPatterns: ["@clawd"] } },
|
||||
}));
|
||||
|
||||
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() };
|
||||
};
|
||||
|
||||
await monitorWebChannel(false, listenerFactory, false, resolver);
|
||||
expect(capturedOnMessage).toBeDefined();
|
||||
|
||||
await capturedOnMessage?.({
|
||||
body: "hello group",
|
||||
from: "123@g.us",
|
||||
conversationId: "123@g.us",
|
||||
chatId: "123@g.us",
|
||||
chatType: "group",
|
||||
to: "+2",
|
||||
id: "g-default-off",
|
||||
senderE164: "+111",
|
||||
senderName: "Alice",
|
||||
selfE164: "+999",
|
||||
sendComposing,
|
||||
reply,
|
||||
sendMedia,
|
||||
});
|
||||
|
||||
expect(resolver).toHaveBeenCalledTimes(1);
|
||||
resetLoadConfigMock();
|
||||
});
|
||||
it("blocks group messages when whatsapp groups is set without a wildcard", async () => {
|
||||
const sendMedia = vi.fn();
|
||||
const reply = vi.fn().mockResolvedValue(undefined);
|
||||
const sendComposing = vi.fn();
|
||||
const resolver = vi.fn().mockResolvedValue({ text: "ok" });
|
||||
|
||||
setLoadConfigMock(() => ({
|
||||
channels: {
|
||||
whatsapp: {
|
||||
allowFrom: ["*"],
|
||||
groups: { "999@g.us": { requireMention: false } },
|
||||
},
|
||||
},
|
||||
messages: { groupChat: { mentionPatterns: ["@clawd"] } },
|
||||
}));
|
||||
|
||||
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() };
|
||||
};
|
||||
|
||||
await monitorWebChannel(false, listenerFactory, false, resolver);
|
||||
expect(capturedOnMessage).toBeDefined();
|
||||
|
||||
await capturedOnMessage?.({
|
||||
body: "@clawd hello",
|
||||
from: "123@g.us",
|
||||
conversationId: "123@g.us",
|
||||
chatId: "123@g.us",
|
||||
chatType: "group",
|
||||
to: "+2",
|
||||
id: "g-allowlist-block",
|
||||
senderE164: "+111",
|
||||
senderName: "Alice",
|
||||
mentionedJids: ["999@s.whatsapp.net"],
|
||||
selfE164: "+999",
|
||||
selfJid: "999@s.whatsapp.net",
|
||||
sendComposing,
|
||||
reply,
|
||||
sendMedia,
|
||||
});
|
||||
|
||||
expect(resolver).not.toHaveBeenCalled();
|
||||
resetLoadConfigMock();
|
||||
});
|
||||
it("honors per-group mention overrides when conversationId uses session key", async () => {
|
||||
const sendMedia = vi.fn();
|
||||
const reply = vi.fn().mockResolvedValue(undefined);
|
||||
const sendComposing = vi.fn();
|
||||
const resolver = vi.fn().mockResolvedValue({ text: "ok" });
|
||||
|
||||
setLoadConfigMock(() => ({
|
||||
channels: {
|
||||
whatsapp: {
|
||||
allowFrom: ["*"],
|
||||
groups: {
|
||||
"*": { requireMention: true },
|
||||
"123@g.us": { requireMention: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
messages: { groupChat: { mentionPatterns: ["@clawd"] } },
|
||||
}));
|
||||
|
||||
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() };
|
||||
};
|
||||
|
||||
await monitorWebChannel(false, listenerFactory, false, resolver);
|
||||
expect(capturedOnMessage).toBeDefined();
|
||||
|
||||
await capturedOnMessage?.({
|
||||
body: "hello group",
|
||||
from: "whatsapp:group:123@g.us",
|
||||
conversationId: "whatsapp:group:123@g.us",
|
||||
chatId: "123@g.us",
|
||||
chatType: "group",
|
||||
to: "+2",
|
||||
id: "g-per-group-session-key",
|
||||
senderE164: "+111",
|
||||
senderName: "Alice",
|
||||
selfE164: "+999",
|
||||
sendComposing,
|
||||
reply,
|
||||
sendMedia,
|
||||
});
|
||||
|
||||
expect(resolver).toHaveBeenCalledTimes(1);
|
||||
resetLoadConfigMock();
|
||||
});
|
||||
});
|
||||
354
src/web/auto-reply.web-auto-reply.part-6.test.ts
Normal file
354
src/web/auto-reply.web-auto-reply.part-6.test.ts
Normal file
@@ -0,0 +1,354 @@
|
||||
import "./test-helpers.js";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../agents/pi-embedded.js", () => ({
|
||||
abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
|
||||
isEmbeddedPiRunActive: vi.fn().mockReturnValue(false),
|
||||
isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false),
|
||||
runEmbeddedPiAgent: vi.fn(),
|
||||
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
|
||||
resolveEmbeddedSessionLane: (key: string) =>
|
||||
`session:${key.trim() || "main"}`,
|
||||
}));
|
||||
|
||||
import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js";
|
||||
import { resetLogger, setLoggerOverride } from "../logging.js";
|
||||
import { monitorWebChannel, SILENT_REPLY_TOKEN } from "./auto-reply.js";
|
||||
import {
|
||||
resetBaileysMocks,
|
||||
resetLoadConfigMock,
|
||||
setLoadConfigMock,
|
||||
} from "./test-helpers.js";
|
||||
|
||||
let previousHome: string | undefined;
|
||||
let tempHome: string | undefined;
|
||||
|
||||
const rmDirWithRetries = async (dir: string): Promise<void> => {
|
||||
// Some tests can leave async session-store writes in-flight; recursive deletion can race and throw ENOTEMPTY.
|
||||
for (let attempt = 0; attempt < 10; attempt += 1) {
|
||||
try {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
return;
|
||||
} catch (err) {
|
||||
const code =
|
||||
err && typeof err === "object" && "code" in err
|
||||
? String((err as { code?: unknown }).code)
|
||||
: null;
|
||||
if (code === "ENOTEMPTY" || code === "EBUSY" || code === "EPERM") {
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
resetInboundDedupe();
|
||||
previousHome = process.env.HOME;
|
||||
tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-web-home-"));
|
||||
process.env.HOME = tempHome;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
process.env.HOME = previousHome;
|
||||
if (tempHome) {
|
||||
await rmDirWithRetries(tempHome);
|
||||
tempHome = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
const makeSessionStore = async (
|
||||
entries: Record<string, unknown> = {},
|
||||
): Promise<{ storePath: string; cleanup: () => Promise<void> }> => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-session-"));
|
||||
const storePath = path.join(dir, "sessions.json");
|
||||
await fs.writeFile(storePath, JSON.stringify(entries));
|
||||
const cleanup = async () => {
|
||||
// Session store writes can be in-flight when the test finishes (e.g. updateLastRoute
|
||||
// after a message flush). `fs.rm({ recursive })` can race and throw ENOTEMPTY.
|
||||
for (let attempt = 0; attempt < 10; attempt += 1) {
|
||||
try {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
return;
|
||||
} catch (err) {
|
||||
const code =
|
||||
err && typeof err === "object" && "code" in err
|
||||
? String((err as { code?: unknown }).code)
|
||||
: null;
|
||||
if (code === "ENOTEMPTY" || code === "EBUSY" || code === "EPERM") {
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
};
|
||||
return {
|
||||
storePath,
|
||||
cleanup,
|
||||
};
|
||||
};
|
||||
|
||||
describe("web auto-reply", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resetBaileysMocks();
|
||||
resetLoadConfigMock();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetLogger();
|
||||
setLoggerOverride(null);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("supports always-on group activation with silent token and preserves history", async () => {
|
||||
const sendMedia = vi.fn();
|
||||
const reply = vi.fn().mockResolvedValue(undefined);
|
||||
const sendComposing = vi.fn();
|
||||
const resolver = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ text: SILENT_REPLY_TOKEN })
|
||||
.mockResolvedValueOnce({ text: "ok" });
|
||||
|
||||
const { storePath, cleanup } = await makeSessionStore({
|
||||
"agent:main:whatsapp:group:123@g.us": {
|
||||
sessionId: "g-1",
|
||||
updatedAt: Date.now(),
|
||||
groupActivation: "always",
|
||||
},
|
||||
});
|
||||
|
||||
setLoadConfigMock(() => ({
|
||||
messages: {
|
||||
groupChat: { mentionPatterns: ["@clawd"] },
|
||||
},
|
||||
session: { store: storePath },
|
||||
}));
|
||||
|
||||
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() };
|
||||
};
|
||||
|
||||
await monitorWebChannel(false, listenerFactory, false, resolver);
|
||||
expect(capturedOnMessage).toBeDefined();
|
||||
|
||||
await capturedOnMessage?.({
|
||||
body: "first",
|
||||
from: "123@g.us",
|
||||
conversationId: "123@g.us",
|
||||
chatId: "123@g.us",
|
||||
chatType: "group",
|
||||
to: "+2",
|
||||
id: "g-always-1",
|
||||
senderE164: "+111",
|
||||
senderName: "Alice",
|
||||
selfE164: "+999",
|
||||
sendComposing,
|
||||
reply,
|
||||
sendMedia,
|
||||
});
|
||||
|
||||
expect(resolver).toHaveBeenCalledTimes(1);
|
||||
expect(reply).not.toHaveBeenCalled();
|
||||
|
||||
await capturedOnMessage?.({
|
||||
body: "second",
|
||||
from: "123@g.us",
|
||||
conversationId: "123@g.us",
|
||||
chatId: "123@g.us",
|
||||
chatType: "group",
|
||||
to: "+2",
|
||||
id: "g-always-2",
|
||||
senderE164: "+222",
|
||||
senderName: "Bob",
|
||||
selfE164: "+999",
|
||||
sendComposing,
|
||||
reply,
|
||||
sendMedia,
|
||||
});
|
||||
|
||||
expect(resolver).toHaveBeenCalledTimes(2);
|
||||
const payload = resolver.mock.calls[1][0];
|
||||
expect(payload.Body).toContain("Chat messages since your last reply");
|
||||
expect(payload.Body).toContain("Alice (+111): first");
|
||||
expect(payload.Body).toContain("[message_id: g-always-1]");
|
||||
expect(payload.Body).toContain("Bob: second");
|
||||
expect(reply).toHaveBeenCalledTimes(1);
|
||||
|
||||
await cleanup();
|
||||
resetLoadConfigMock();
|
||||
});
|
||||
it("ignores JID mentions in self-chat mode (group chats)", async () => {
|
||||
const sendMedia = vi.fn();
|
||||
const reply = vi.fn().mockResolvedValue(undefined);
|
||||
const sendComposing = vi.fn();
|
||||
const resolver = vi.fn().mockResolvedValue({ text: "ok" });
|
||||
|
||||
setLoadConfigMock(() => ({
|
||||
channels: {
|
||||
whatsapp: {
|
||||
// Self-chat heuristic: allowFrom includes selfE164.
|
||||
allowFrom: ["+999"],
|
||||
groups: { "*": { requireMention: true } },
|
||||
},
|
||||
},
|
||||
messages: {
|
||||
groupChat: {
|
||||
mentionPatterns: ["\\bclawd\\b"],
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
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() };
|
||||
};
|
||||
|
||||
await monitorWebChannel(false, listenerFactory, false, resolver);
|
||||
expect(capturedOnMessage).toBeDefined();
|
||||
|
||||
// WhatsApp @mention of the owner should NOT trigger the bot in self-chat mode.
|
||||
await capturedOnMessage?.({
|
||||
body: "@owner ping",
|
||||
from: "123@g.us",
|
||||
conversationId: "123@g.us",
|
||||
chatId: "123@g.us",
|
||||
chatType: "group",
|
||||
to: "+2",
|
||||
id: "g-self-1",
|
||||
senderE164: "+111",
|
||||
senderName: "Alice",
|
||||
mentionedJids: ["999@s.whatsapp.net"],
|
||||
selfE164: "+999",
|
||||
selfJid: "999@s.whatsapp.net",
|
||||
sendComposing,
|
||||
reply,
|
||||
sendMedia,
|
||||
});
|
||||
|
||||
expect(resolver).not.toHaveBeenCalled();
|
||||
|
||||
// Text-based mentionPatterns still work (user can type "clawd" explicitly).
|
||||
await capturedOnMessage?.({
|
||||
body: "clawd ping",
|
||||
from: "123@g.us",
|
||||
conversationId: "123@g.us",
|
||||
chatId: "123@g.us",
|
||||
chatType: "group",
|
||||
to: "+2",
|
||||
id: "g-self-2",
|
||||
senderE164: "+222",
|
||||
senderName: "Bob",
|
||||
selfE164: "+999",
|
||||
selfJid: "999@s.whatsapp.net",
|
||||
sendComposing,
|
||||
reply,
|
||||
sendMedia,
|
||||
});
|
||||
|
||||
expect(resolver).toHaveBeenCalledTimes(1);
|
||||
|
||||
resetLoadConfigMock();
|
||||
});
|
||||
it("emits heartbeat logs with connection metadata", async () => {
|
||||
vi.useFakeTimers();
|
||||
const logPath = `/tmp/clawdbot-heartbeat-${crypto.randomUUID()}.log`;
|
||||
setLoggerOverride({ level: "trace", file: logPath });
|
||||
|
||||
const runtime = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
};
|
||||
|
||||
const controller = new AbortController();
|
||||
const listenerFactory = vi.fn(async () => {
|
||||
const onClose = new Promise<void>(() => {
|
||||
// never resolves; abort will short-circuit
|
||||
});
|
||||
return { close: vi.fn(), onClose };
|
||||
});
|
||||
|
||||
const run = monitorWebChannel(
|
||||
false,
|
||||
listenerFactory,
|
||||
true,
|
||||
async () => ({ text: "ok" }),
|
||||
runtime as never,
|
||||
controller.signal,
|
||||
{
|
||||
heartbeatSeconds: 1,
|
||||
reconnect: { initialMs: 5, maxMs: 5, maxAttempts: 1, factor: 1.1 },
|
||||
},
|
||||
);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
controller.abort();
|
||||
await vi.runAllTimersAsync();
|
||||
await run.catch(() => {});
|
||||
|
||||
const content = await fs.readFile(logPath, "utf-8");
|
||||
expect(content).toMatch(/web-heartbeat/);
|
||||
expect(content).toMatch(/connectionId/);
|
||||
expect(content).toMatch(/messagesHandled/);
|
||||
});
|
||||
it("logs outbound replies to file", async () => {
|
||||
const logPath = `/tmp/clawdbot-log-test-${crypto.randomUUID()}.log`;
|
||||
setLoggerOverride({ level: "trace", file: logPath });
|
||||
|
||||
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() };
|
||||
};
|
||||
|
||||
const resolver = vi.fn().mockResolvedValue({ text: "auto" });
|
||||
await monitorWebChannel(false, listenerFactory, false, resolver);
|
||||
expect(capturedOnMessage).toBeDefined();
|
||||
|
||||
await capturedOnMessage?.({
|
||||
body: "hello",
|
||||
from: "+1",
|
||||
to: "+2",
|
||||
id: "msg1",
|
||||
sendComposing: vi.fn(),
|
||||
reply: vi.fn(),
|
||||
sendMedia: vi.fn(),
|
||||
});
|
||||
|
||||
const content = await fs.readFile(logPath, "utf-8");
|
||||
expect(content).toMatch(/web-auto-reply/);
|
||||
expect(content).toMatch(/auto/);
|
||||
});
|
||||
});
|
||||
357
src/web/auto-reply.web-auto-reply.part-7.test.ts
Normal file
357
src/web/auto-reply.web-auto-reply.part-7.test.ts
Normal file
@@ -0,0 +1,357 @@
|
||||
import "./test-helpers.js";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../agents/pi-embedded.js", () => ({
|
||||
abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
|
||||
isEmbeddedPiRunActive: vi.fn().mockReturnValue(false),
|
||||
isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false),
|
||||
runEmbeddedPiAgent: vi.fn(),
|
||||
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
|
||||
resolveEmbeddedSessionLane: (key: string) =>
|
||||
`session:${key.trim() || "main"}`,
|
||||
}));
|
||||
|
||||
import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js";
|
||||
import { resetLogger, setLoggerOverride } from "../logging.js";
|
||||
import { HEARTBEAT_TOKEN, monitorWebChannel } from "./auto-reply.js";
|
||||
import {
|
||||
resetBaileysMocks,
|
||||
resetLoadConfigMock,
|
||||
setLoadConfigMock,
|
||||
} from "./test-helpers.js";
|
||||
|
||||
let previousHome: string | undefined;
|
||||
let tempHome: string | undefined;
|
||||
|
||||
const rmDirWithRetries = async (dir: string): Promise<void> => {
|
||||
// Some tests can leave async session-store writes in-flight; recursive deletion can race and throw ENOTEMPTY.
|
||||
for (let attempt = 0; attempt < 10; attempt += 1) {
|
||||
try {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
return;
|
||||
} catch (err) {
|
||||
const code =
|
||||
err && typeof err === "object" && "code" in err
|
||||
? String((err as { code?: unknown }).code)
|
||||
: null;
|
||||
if (code === "ENOTEMPTY" || code === "EBUSY" || code === "EPERM") {
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
resetInboundDedupe();
|
||||
previousHome = process.env.HOME;
|
||||
tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-web-home-"));
|
||||
process.env.HOME = tempHome;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
process.env.HOME = previousHome;
|
||||
if (tempHome) {
|
||||
await rmDirWithRetries(tempHome);
|
||||
tempHome = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
const _makeSessionStore = async (
|
||||
entries: Record<string, unknown> = {},
|
||||
): Promise<{ storePath: string; cleanup: () => Promise<void> }> => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-session-"));
|
||||
const storePath = path.join(dir, "sessions.json");
|
||||
await fs.writeFile(storePath, JSON.stringify(entries));
|
||||
const cleanup = async () => {
|
||||
// Session store writes can be in-flight when the test finishes (e.g. updateLastRoute
|
||||
// after a message flush). `fs.rm({ recursive })` can race and throw ENOTEMPTY.
|
||||
for (let attempt = 0; attempt < 10; attempt += 1) {
|
||||
try {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
return;
|
||||
} catch (err) {
|
||||
const code =
|
||||
err && typeof err === "object" && "code" in err
|
||||
? String((err as { code?: unknown }).code)
|
||||
: null;
|
||||
if (code === "ENOTEMPTY" || code === "EBUSY" || code === "EPERM") {
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
};
|
||||
return {
|
||||
storePath,
|
||||
cleanup,
|
||||
};
|
||||
};
|
||||
|
||||
describe("web auto-reply", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resetBaileysMocks();
|
||||
resetLoadConfigMock();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetLogger();
|
||||
setLoggerOverride(null);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("prefixes body with same-phone marker when from === to", async () => {
|
||||
// Enable messagePrefix for same-phone mode testing
|
||||
setLoadConfigMock(() => ({
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
messages: {
|
||||
messagePrefix: "[same-phone]",
|
||||
responsePrefix: undefined,
|
||||
},
|
||||
}));
|
||||
|
||||
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() };
|
||||
};
|
||||
|
||||
const resolver = vi.fn().mockResolvedValue({ text: "reply" });
|
||||
|
||||
await monitorWebChannel(false, listenerFactory, false, resolver);
|
||||
expect(capturedOnMessage).toBeDefined();
|
||||
|
||||
await capturedOnMessage?.({
|
||||
body: "hello",
|
||||
from: "+1555",
|
||||
to: "+1555", // Same phone!
|
||||
id: "msg1",
|
||||
sendComposing: vi.fn(),
|
||||
reply: vi.fn(),
|
||||
sendMedia: vi.fn(),
|
||||
});
|
||||
|
||||
// The resolver should receive a prefixed body with the configured marker
|
||||
const callArg = resolver.mock.calls[0]?.[0] as { Body?: string };
|
||||
expect(callArg?.Body).toBeDefined();
|
||||
expect(callArg?.Body).toContain("[WhatsApp +1555");
|
||||
expect(callArg?.Body).toContain("[same-phone] hello");
|
||||
resetLoadConfigMock();
|
||||
});
|
||||
it("does not prefix body when from !== to", async () => {
|
||||
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() };
|
||||
};
|
||||
|
||||
const resolver = vi.fn().mockResolvedValue({ text: "reply" });
|
||||
|
||||
await monitorWebChannel(false, listenerFactory, false, resolver);
|
||||
expect(capturedOnMessage).toBeDefined();
|
||||
|
||||
await capturedOnMessage?.({
|
||||
body: "hello",
|
||||
from: "+1555",
|
||||
to: "+2666", // Different phones
|
||||
id: "msg1",
|
||||
sendComposing: vi.fn(),
|
||||
reply: vi.fn(),
|
||||
sendMedia: vi.fn(),
|
||||
});
|
||||
|
||||
// Body should include envelope but not the same-phone prefix
|
||||
const callArg = resolver.mock.calls[0]?.[0] as { Body?: string };
|
||||
expect(callArg?.Body).toContain("[WhatsApp +1555");
|
||||
expect(callArg?.Body).toContain("hello");
|
||||
});
|
||||
it("forwards reply-to context to resolver", async () => {
|
||||
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() };
|
||||
};
|
||||
|
||||
const resolver = vi.fn().mockResolvedValue({ text: "reply" });
|
||||
|
||||
await monitorWebChannel(false, listenerFactory, false, resolver);
|
||||
expect(capturedOnMessage).toBeDefined();
|
||||
|
||||
await capturedOnMessage?.({
|
||||
body: "hello",
|
||||
from: "+1555",
|
||||
to: "+2666",
|
||||
id: "msg1",
|
||||
replyToId: "q1",
|
||||
replyToBody: "original",
|
||||
replyToSender: "+1999",
|
||||
sendComposing: vi.fn(),
|
||||
reply: vi.fn(),
|
||||
sendMedia: vi.fn(),
|
||||
});
|
||||
|
||||
const callArg = resolver.mock.calls[0]?.[0] as {
|
||||
ReplyToId?: string;
|
||||
ReplyToBody?: string;
|
||||
ReplyToSender?: string;
|
||||
Body?: string;
|
||||
};
|
||||
expect(callArg.ReplyToId).toBe("q1");
|
||||
expect(callArg.ReplyToBody).toBe("original");
|
||||
expect(callArg.ReplyToSender).toBe("+1999");
|
||||
expect(callArg.Body).toContain("[Replying to +1999 id:q1]");
|
||||
expect(callArg.Body).toContain("original");
|
||||
});
|
||||
it("applies responsePrefix to regular replies", async () => {
|
||||
setLoadConfigMock(() => ({
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
messages: {
|
||||
messagePrefix: undefined,
|
||||
responsePrefix: "🦞",
|
||||
},
|
||||
}));
|
||||
|
||||
let capturedOnMessage:
|
||||
| ((msg: import("./inbound.js").WebInboundMessage) => Promise<void>)
|
||||
| undefined;
|
||||
const reply = vi.fn();
|
||||
const listenerFactory = async (opts: {
|
||||
onMessage: (
|
||||
msg: import("./inbound.js").WebInboundMessage,
|
||||
) => Promise<void>;
|
||||
}) => {
|
||||
capturedOnMessage = opts.onMessage;
|
||||
return { close: vi.fn() };
|
||||
};
|
||||
|
||||
const resolver = vi.fn().mockResolvedValue({ text: "hello there" });
|
||||
|
||||
await monitorWebChannel(false, listenerFactory, false, resolver);
|
||||
expect(capturedOnMessage).toBeDefined();
|
||||
|
||||
await capturedOnMessage?.({
|
||||
body: "hi",
|
||||
from: "+1555",
|
||||
to: "+2666",
|
||||
id: "msg1",
|
||||
sendComposing: vi.fn(),
|
||||
reply,
|
||||
sendMedia: vi.fn(),
|
||||
});
|
||||
|
||||
// Reply should have responsePrefix prepended
|
||||
expect(reply).toHaveBeenCalledWith("🦞 hello there");
|
||||
resetLoadConfigMock();
|
||||
});
|
||||
it("does not deliver HEARTBEAT_OK responses", async () => {
|
||||
setLoadConfigMock(() => ({
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
messages: {
|
||||
messagePrefix: undefined,
|
||||
responsePrefix: "🦞",
|
||||
},
|
||||
}));
|
||||
|
||||
let capturedOnMessage:
|
||||
| ((msg: import("./inbound.js").WebInboundMessage) => Promise<void>)
|
||||
| undefined;
|
||||
const reply = vi.fn();
|
||||
const listenerFactory = async (opts: {
|
||||
onMessage: (
|
||||
msg: import("./inbound.js").WebInboundMessage,
|
||||
) => Promise<void>;
|
||||
}) => {
|
||||
capturedOnMessage = opts.onMessage;
|
||||
return { close: vi.fn() };
|
||||
};
|
||||
|
||||
// Resolver returns exact HEARTBEAT_OK
|
||||
const resolver = vi.fn().mockResolvedValue({ text: HEARTBEAT_TOKEN });
|
||||
|
||||
await monitorWebChannel(false, listenerFactory, false, resolver);
|
||||
expect(capturedOnMessage).toBeDefined();
|
||||
|
||||
await capturedOnMessage?.({
|
||||
body: "test",
|
||||
from: "+1555",
|
||||
to: "+2666",
|
||||
id: "msg1",
|
||||
sendComposing: vi.fn(),
|
||||
reply,
|
||||
sendMedia: vi.fn(),
|
||||
});
|
||||
|
||||
expect(reply).not.toHaveBeenCalled();
|
||||
resetLoadConfigMock();
|
||||
});
|
||||
it("does not double-prefix if responsePrefix already present", async () => {
|
||||
setLoadConfigMock(() => ({
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
messages: {
|
||||
messagePrefix: undefined,
|
||||
responsePrefix: "🦞",
|
||||
},
|
||||
}));
|
||||
|
||||
let capturedOnMessage:
|
||||
| ((msg: import("./inbound.js").WebInboundMessage) => Promise<void>)
|
||||
| undefined;
|
||||
const reply = vi.fn();
|
||||
const listenerFactory = async (opts: {
|
||||
onMessage: (
|
||||
msg: import("./inbound.js").WebInboundMessage,
|
||||
) => Promise<void>;
|
||||
}) => {
|
||||
capturedOnMessage = opts.onMessage;
|
||||
return { close: vi.fn() };
|
||||
};
|
||||
|
||||
// Resolver returns text that already has prefix
|
||||
const resolver = vi.fn().mockResolvedValue({ text: "🦞 already prefixed" });
|
||||
|
||||
await monitorWebChannel(false, listenerFactory, false, resolver);
|
||||
expect(capturedOnMessage).toBeDefined();
|
||||
|
||||
await capturedOnMessage?.({
|
||||
body: "test",
|
||||
from: "+1555",
|
||||
to: "+2666",
|
||||
id: "msg1",
|
||||
sendComposing: vi.fn(),
|
||||
reply,
|
||||
sendMedia: vi.fn(),
|
||||
});
|
||||
|
||||
// Should not double-prefix
|
||||
expect(reply).toHaveBeenCalledWith("🦞 already prefixed");
|
||||
resetLoadConfigMock();
|
||||
});
|
||||
});
|
||||
284
src/web/auto-reply.web-auto-reply.part-8.test.ts
Normal file
284
src/web/auto-reply.web-auto-reply.part-8.test.ts
Normal file
@@ -0,0 +1,284 @@
|
||||
import "./test-helpers.js";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../agents/pi-embedded.js", () => ({
|
||||
abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
|
||||
isEmbeddedPiRunActive: vi.fn().mockReturnValue(false),
|
||||
isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false),
|
||||
runEmbeddedPiAgent: vi.fn(),
|
||||
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
|
||||
resolveEmbeddedSessionLane: (key: string) =>
|
||||
`session:${key.trim() || "main"}`,
|
||||
}));
|
||||
|
||||
import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js";
|
||||
import { resetLogger, setLoggerOverride } from "../logging.js";
|
||||
import { monitorWebChannel } from "./auto-reply.js";
|
||||
import {
|
||||
resetBaileysMocks,
|
||||
resetLoadConfigMock,
|
||||
setLoadConfigMock,
|
||||
} from "./test-helpers.js";
|
||||
|
||||
let previousHome: string | undefined;
|
||||
let tempHome: string | undefined;
|
||||
|
||||
const rmDirWithRetries = async (dir: string): Promise<void> => {
|
||||
// Some tests can leave async session-store writes in-flight; recursive deletion can race and throw ENOTEMPTY.
|
||||
for (let attempt = 0; attempt < 10; attempt += 1) {
|
||||
try {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
return;
|
||||
} catch (err) {
|
||||
const code =
|
||||
err && typeof err === "object" && "code" in err
|
||||
? String((err as { code?: unknown }).code)
|
||||
: null;
|
||||
if (code === "ENOTEMPTY" || code === "EBUSY" || code === "EPERM") {
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
resetInboundDedupe();
|
||||
previousHome = process.env.HOME;
|
||||
tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-web-home-"));
|
||||
process.env.HOME = tempHome;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
process.env.HOME = previousHome;
|
||||
if (tempHome) {
|
||||
await rmDirWithRetries(tempHome);
|
||||
tempHome = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
const _makeSessionStore = async (
|
||||
entries: Record<string, unknown> = {},
|
||||
): Promise<{ storePath: string; cleanup: () => Promise<void> }> => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-session-"));
|
||||
const storePath = path.join(dir, "sessions.json");
|
||||
await fs.writeFile(storePath, JSON.stringify(entries));
|
||||
const cleanup = async () => {
|
||||
// Session store writes can be in-flight when the test finishes (e.g. updateLastRoute
|
||||
// after a message flush). `fs.rm({ recursive })` can race and throw ENOTEMPTY.
|
||||
for (let attempt = 0; attempt < 10; attempt += 1) {
|
||||
try {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
return;
|
||||
} catch (err) {
|
||||
const code =
|
||||
err && typeof err === "object" && "code" in err
|
||||
? String((err as { code?: unknown }).code)
|
||||
: null;
|
||||
if (code === "ENOTEMPTY" || code === "EBUSY" || code === "EPERM") {
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
};
|
||||
return {
|
||||
storePath,
|
||||
cleanup,
|
||||
};
|
||||
};
|
||||
|
||||
describe("web auto-reply", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resetBaileysMocks();
|
||||
resetLoadConfigMock();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetLogger();
|
||||
setLoggerOverride(null);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("sends tool summaries immediately with responsePrefix", async () => {
|
||||
setLoadConfigMock(() => ({
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
messages: {
|
||||
messagePrefix: undefined,
|
||||
responsePrefix: "🦞",
|
||||
},
|
||||
}));
|
||||
|
||||
let capturedOnMessage:
|
||||
| ((msg: import("./inbound.js").WebInboundMessage) => Promise<void>)
|
||||
| undefined;
|
||||
const reply = vi.fn();
|
||||
const listenerFactory = async (opts: {
|
||||
onMessage: (
|
||||
msg: import("./inbound.js").WebInboundMessage,
|
||||
) => Promise<void>;
|
||||
}) => {
|
||||
capturedOnMessage = opts.onMessage;
|
||||
return { close: vi.fn() };
|
||||
};
|
||||
|
||||
const resolver = vi
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
async (
|
||||
_ctx,
|
||||
opts?: { onToolResult?: (r: { text: string }) => Promise<void> },
|
||||
) => {
|
||||
await opts?.onToolResult?.({ text: "🧩 tool1" });
|
||||
await opts?.onToolResult?.({ text: "🧩 tool2" });
|
||||
return { text: "final" };
|
||||
},
|
||||
);
|
||||
|
||||
await monitorWebChannel(false, listenerFactory, false, resolver);
|
||||
expect(capturedOnMessage).toBeDefined();
|
||||
|
||||
await capturedOnMessage?.({
|
||||
body: "hi",
|
||||
from: "+1555",
|
||||
to: "+2666",
|
||||
id: "msg1",
|
||||
sendComposing: vi.fn(),
|
||||
reply,
|
||||
sendMedia: vi.fn(),
|
||||
});
|
||||
|
||||
const replies = reply.mock.calls.map((call) => call[0]);
|
||||
expect(replies).toEqual(["🦞 🧩 tool1", "🦞 🧩 tool2", "🦞 final"]);
|
||||
resetLoadConfigMock();
|
||||
});
|
||||
it("uses identity.name for messagePrefix when set", async () => {
|
||||
setLoadConfigMock(() => ({
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "main",
|
||||
default: true,
|
||||
identity: { name: "Mainbot", emoji: "🦞", theme: "space lobster" },
|
||||
},
|
||||
{
|
||||
id: "rich",
|
||||
identity: { name: "Richbot", emoji: "🦁", theme: "lion bot" },
|
||||
},
|
||||
],
|
||||
},
|
||||
bindings: [
|
||||
{
|
||||
agentId: "rich",
|
||||
match: {
|
||||
channel: "whatsapp",
|
||||
peer: { kind: "dm", id: "+1555" },
|
||||
},
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
let capturedOnMessage:
|
||||
| ((msg: import("./inbound.js").WebInboundMessage) => Promise<void>)
|
||||
| undefined;
|
||||
const reply = vi.fn();
|
||||
const listenerFactory = async (opts: {
|
||||
onMessage: (
|
||||
msg: import("./inbound.js").WebInboundMessage,
|
||||
) => Promise<void>;
|
||||
}) => {
|
||||
capturedOnMessage = opts.onMessage;
|
||||
return { close: vi.fn() };
|
||||
};
|
||||
|
||||
const resolver = vi.fn().mockResolvedValue({ text: "hello" });
|
||||
|
||||
await monitorWebChannel(false, listenerFactory, false, resolver);
|
||||
expect(capturedOnMessage).toBeDefined();
|
||||
|
||||
await capturedOnMessage?.({
|
||||
body: "hi",
|
||||
from: "+1555",
|
||||
to: "+2666",
|
||||
id: "msg1",
|
||||
sendComposing: vi.fn(),
|
||||
reply,
|
||||
sendMedia: vi.fn(),
|
||||
});
|
||||
|
||||
// Check that resolver received the message with identity-based prefix
|
||||
expect(resolver).toHaveBeenCalled();
|
||||
const resolverArg = resolver.mock.calls[0][0];
|
||||
expect(resolverArg.Body).toContain("[Richbot]");
|
||||
expect(resolverArg.Body).not.toContain("[clawdbot]");
|
||||
resetLoadConfigMock();
|
||||
});
|
||||
it("does not derive responsePrefix from identity.name when unset", async () => {
|
||||
setLoadConfigMock(() => ({
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "main",
|
||||
default: true,
|
||||
identity: { name: "Mainbot", emoji: "🦞", theme: "space lobster" },
|
||||
},
|
||||
{
|
||||
id: "rich",
|
||||
identity: { name: "Richbot", emoji: "🦁", theme: "lion bot" },
|
||||
},
|
||||
],
|
||||
},
|
||||
bindings: [
|
||||
{
|
||||
agentId: "rich",
|
||||
match: {
|
||||
channel: "whatsapp",
|
||||
peer: { kind: "dm", id: "+1555" },
|
||||
},
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
let capturedOnMessage:
|
||||
| ((msg: import("./inbound.js").WebInboundMessage) => Promise<void>)
|
||||
| undefined;
|
||||
const reply = vi.fn();
|
||||
const listenerFactory = async (opts: {
|
||||
onMessage: (
|
||||
msg: import("./inbound.js").WebInboundMessage,
|
||||
) => Promise<void>;
|
||||
}) => {
|
||||
capturedOnMessage = opts.onMessage;
|
||||
return { close: vi.fn() };
|
||||
};
|
||||
|
||||
const resolver = vi.fn().mockResolvedValue({ text: "hello there" });
|
||||
|
||||
await monitorWebChannel(false, listenerFactory, false, resolver);
|
||||
expect(capturedOnMessage).toBeDefined();
|
||||
|
||||
await capturedOnMessage?.({
|
||||
body: "hi",
|
||||
from: "+1555",
|
||||
to: "+2666",
|
||||
id: "msg1",
|
||||
sendComposing: vi.fn(),
|
||||
reply,
|
||||
sendMedia: vi.fn(),
|
||||
});
|
||||
|
||||
// No implicit responsePrefix.
|
||||
expect(reply).toHaveBeenCalledWith("hello there");
|
||||
resetLoadConfigMock();
|
||||
});
|
||||
});
|
||||
1
src/web/auto-reply/constants.ts
Normal file
1
src/web/auto-reply/constants.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const DEFAULT_WEB_MEDIA_BYTES = 5 * 1024 * 1024;
|
||||
209
src/web/auto-reply/deliver-reply.ts
Normal file
209
src/web/auto-reply/deliver-reply.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import { chunkMarkdownText } from "../../auto-reply/chunk.js";
|
||||
import type { ReplyPayload } from "../../auto-reply/types.js";
|
||||
import { logVerbose, shouldLogVerbose } from "../../globals.js";
|
||||
import { loadWebMedia } from "../media.js";
|
||||
import { newConnectionId } from "../reconnect.js";
|
||||
import { formatError } from "../session.js";
|
||||
import { whatsappOutboundLog } from "./loggers.js";
|
||||
import type { WebInboundMsg } from "./types.js";
|
||||
import { elide } from "./util.js";
|
||||
|
||||
export async function deliverWebReply(params: {
|
||||
replyResult: ReplyPayload;
|
||||
msg: WebInboundMsg;
|
||||
maxMediaBytes: number;
|
||||
textLimit: number;
|
||||
replyLogger: {
|
||||
info: (obj: unknown, msg: string) => void;
|
||||
warn: (obj: unknown, msg: string) => void;
|
||||
};
|
||||
connectionId?: string;
|
||||
skipLog?: boolean;
|
||||
}) {
|
||||
const {
|
||||
replyResult,
|
||||
msg,
|
||||
maxMediaBytes,
|
||||
textLimit,
|
||||
replyLogger,
|
||||
connectionId,
|
||||
skipLog,
|
||||
} = params;
|
||||
const replyStarted = Date.now();
|
||||
const textChunks = chunkMarkdownText(replyResult.text || "", textLimit);
|
||||
const mediaList = replyResult.mediaUrls?.length
|
||||
? replyResult.mediaUrls
|
||||
: replyResult.mediaUrl
|
||||
? [replyResult.mediaUrl]
|
||||
: [];
|
||||
|
||||
const sleep = (ms: number) =>
|
||||
new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
const sendWithRetry = async (
|
||||
fn: () => Promise<unknown>,
|
||||
label: string,
|
||||
maxAttempts = 3,
|
||||
) => {
|
||||
let lastErr: unknown;
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (err) {
|
||||
lastErr = err;
|
||||
const errText = formatError(err);
|
||||
const isLast = attempt === maxAttempts;
|
||||
const shouldRetry = /closed|reset|timed\\s*out|disconnect/i.test(
|
||||
errText,
|
||||
);
|
||||
if (!shouldRetry || isLast) {
|
||||
throw err;
|
||||
}
|
||||
const backoffMs = 500 * attempt;
|
||||
logVerbose(
|
||||
`Retrying ${label} to ${msg.from} after failure (${attempt}/${maxAttempts - 1}) in ${backoffMs}ms: ${errText}`,
|
||||
);
|
||||
await sleep(backoffMs);
|
||||
}
|
||||
}
|
||||
throw lastErr;
|
||||
};
|
||||
|
||||
// Text-only replies
|
||||
if (mediaList.length === 0 && textChunks.length) {
|
||||
const totalChunks = textChunks.length;
|
||||
for (const [index, chunk] of textChunks.entries()) {
|
||||
const chunkStarted = Date.now();
|
||||
await sendWithRetry(() => msg.reply(chunk), "text");
|
||||
if (!skipLog) {
|
||||
const durationMs = Date.now() - chunkStarted;
|
||||
whatsappOutboundLog.debug(
|
||||
`Sent chunk ${index + 1}/${totalChunks} to ${msg.from} (${durationMs.toFixed(0)}ms)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
replyLogger.info(
|
||||
{
|
||||
correlationId: msg.id ?? newConnectionId(),
|
||||
connectionId: connectionId ?? null,
|
||||
to: msg.from,
|
||||
from: msg.to,
|
||||
text: elide(replyResult.text, 240),
|
||||
mediaUrl: null,
|
||||
mediaSizeBytes: null,
|
||||
mediaKind: null,
|
||||
durationMs: Date.now() - replyStarted,
|
||||
},
|
||||
"auto-reply sent (text)",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const remainingText = [...textChunks];
|
||||
|
||||
// Media (with optional caption on first item)
|
||||
for (const [index, mediaUrl] of mediaList.entries()) {
|
||||
const caption =
|
||||
index === 0 ? remainingText.shift() || undefined : undefined;
|
||||
try {
|
||||
const media = await loadWebMedia(mediaUrl, maxMediaBytes);
|
||||
if (shouldLogVerbose()) {
|
||||
logVerbose(
|
||||
`Web auto-reply media size: ${(media.buffer.length / (1024 * 1024)).toFixed(2)}MB`,
|
||||
);
|
||||
logVerbose(
|
||||
`Web auto-reply media source: ${mediaUrl} (kind ${media.kind})`,
|
||||
);
|
||||
}
|
||||
if (media.kind === "image") {
|
||||
await sendWithRetry(
|
||||
() =>
|
||||
msg.sendMedia({
|
||||
image: media.buffer,
|
||||
caption,
|
||||
mimetype: media.contentType,
|
||||
}),
|
||||
"media:image",
|
||||
);
|
||||
} else if (media.kind === "audio") {
|
||||
await sendWithRetry(
|
||||
() =>
|
||||
msg.sendMedia({
|
||||
audio: media.buffer,
|
||||
ptt: true,
|
||||
mimetype: media.contentType,
|
||||
caption,
|
||||
}),
|
||||
"media:audio",
|
||||
);
|
||||
} else if (media.kind === "video") {
|
||||
await sendWithRetry(
|
||||
() =>
|
||||
msg.sendMedia({
|
||||
video: media.buffer,
|
||||
caption,
|
||||
mimetype: media.contentType,
|
||||
}),
|
||||
"media:video",
|
||||
);
|
||||
} else {
|
||||
const fileName = media.fileName ?? mediaUrl.split("/").pop() ?? "file";
|
||||
const mimetype = media.contentType ?? "application/octet-stream";
|
||||
await sendWithRetry(
|
||||
() =>
|
||||
msg.sendMedia({
|
||||
document: media.buffer,
|
||||
fileName,
|
||||
caption,
|
||||
mimetype,
|
||||
}),
|
||||
"media:document",
|
||||
);
|
||||
}
|
||||
whatsappOutboundLog.info(
|
||||
`Sent media reply to ${msg.from} (${(media.buffer.length / (1024 * 1024)).toFixed(2)}MB)`,
|
||||
);
|
||||
replyLogger.info(
|
||||
{
|
||||
correlationId: msg.id ?? newConnectionId(),
|
||||
connectionId: connectionId ?? null,
|
||||
to: msg.from,
|
||||
from: msg.to,
|
||||
text: caption ?? null,
|
||||
mediaUrl,
|
||||
mediaSizeBytes: media.buffer.length,
|
||||
mediaKind: media.kind,
|
||||
durationMs: Date.now() - replyStarted,
|
||||
},
|
||||
"auto-reply sent (media)",
|
||||
);
|
||||
} catch (err) {
|
||||
whatsappOutboundLog.error(
|
||||
`Failed sending web media to ${msg.from}: ${formatError(err)}`,
|
||||
);
|
||||
replyLogger.warn({ err, mediaUrl }, "failed to send web media reply");
|
||||
if (index === 0) {
|
||||
const warning =
|
||||
err instanceof Error
|
||||
? `⚠️ Media failed: ${err.message}`
|
||||
: "⚠️ Media failed.";
|
||||
const fallbackTextParts = [
|
||||
remainingText.shift() ?? caption ?? "",
|
||||
warning,
|
||||
].filter(Boolean);
|
||||
const fallbackText = fallbackTextParts.join("\n");
|
||||
if (fallbackText) {
|
||||
whatsappOutboundLog.warn(
|
||||
`Media skipped; sent text-only to ${msg.from}`,
|
||||
);
|
||||
await msg.reply(fallbackText);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remaining text chunks after media
|
||||
for (const chunk of remainingText) {
|
||||
await msg.reply(chunk);
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
6
src/web/auto-reply/loggers.ts
Normal file
6
src/web/auto-reply/loggers.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createSubsystemLogger } from "../../logging.js";
|
||||
|
||||
export const whatsappLog = createSubsystemLogger("gateway/channels/whatsapp");
|
||||
export const whatsappInboundLog = whatsappLog.child("inbound");
|
||||
export const whatsappOutboundLog = whatsappLog.child("outbound");
|
||||
export const whatsappHeartbeatLog = whatsappLog.child("heartbeat");
|
||||
124
src/web/auto-reply/mentions.ts
Normal file
124
src/web/auto-reply/mentions.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import {
|
||||
buildMentionRegexes,
|
||||
normalizeMentionText,
|
||||
} from "../../auto-reply/reply/mentions.js";
|
||||
import type { loadConfig } from "../../config/config.js";
|
||||
import { isSelfChatMode, jidToE164, normalizeE164 } from "../../utils.js";
|
||||
import type { WebInboundMsg } from "./types.js";
|
||||
|
||||
export type MentionConfig = {
|
||||
mentionRegexes: RegExp[];
|
||||
allowFrom?: Array<string | number>;
|
||||
};
|
||||
|
||||
export type MentionTargets = {
|
||||
normalizedMentions: string[];
|
||||
selfE164: string | null;
|
||||
selfJid: string | null;
|
||||
};
|
||||
|
||||
export function buildMentionConfig(
|
||||
cfg: ReturnType<typeof loadConfig>,
|
||||
agentId?: string,
|
||||
): MentionConfig {
|
||||
const mentionRegexes = buildMentionRegexes(cfg, agentId);
|
||||
return { mentionRegexes, allowFrom: cfg.channels?.whatsapp?.allowFrom };
|
||||
}
|
||||
|
||||
export function resolveMentionTargets(
|
||||
msg: WebInboundMsg,
|
||||
authDir?: string,
|
||||
): MentionTargets {
|
||||
const jidOptions = authDir ? { authDir } : undefined;
|
||||
const normalizedMentions = msg.mentionedJids?.length
|
||||
? msg.mentionedJids
|
||||
.map((jid) => jidToE164(jid, jidOptions) ?? jid)
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
const selfE164 =
|
||||
msg.selfE164 ?? (msg.selfJid ? jidToE164(msg.selfJid, jidOptions) : null);
|
||||
const selfJid = msg.selfJid ? msg.selfJid.replace(/:\\d+/, "") : null;
|
||||
return { normalizedMentions, selfE164, selfJid };
|
||||
}
|
||||
|
||||
export function isBotMentionedFromTargets(
|
||||
msg: WebInboundMsg,
|
||||
mentionCfg: MentionConfig,
|
||||
targets: MentionTargets,
|
||||
): boolean {
|
||||
const clean = (text: string) =>
|
||||
// Remove zero-width and directionality markers WhatsApp injects around display names
|
||||
normalizeMentionText(text);
|
||||
|
||||
const isSelfChat = isSelfChatMode(targets.selfE164, mentionCfg.allowFrom);
|
||||
|
||||
if (msg.mentionedJids?.length && !isSelfChat) {
|
||||
if (
|
||||
targets.selfE164 &&
|
||||
targets.normalizedMentions.includes(targets.selfE164)
|
||||
)
|
||||
return true;
|
||||
if (targets.selfJid && targets.selfE164) {
|
||||
// Some mentions use the bare JID; match on E.164 to be safe.
|
||||
if (targets.normalizedMentions.includes(targets.selfJid)) return true;
|
||||
}
|
||||
} else if (msg.mentionedJids?.length && isSelfChat) {
|
||||
// Self-chat mode: ignore WhatsApp @mention JIDs, otherwise @mentioning the owner in group chats triggers the bot.
|
||||
}
|
||||
const bodyClean = clean(msg.body);
|
||||
if (mentionCfg.mentionRegexes.some((re) => re.test(bodyClean))) return true;
|
||||
|
||||
// Fallback: detect body containing our own number (with or without +, spacing)
|
||||
if (targets.selfE164) {
|
||||
const selfDigits = targets.selfE164.replace(/\D/g, "");
|
||||
if (selfDigits) {
|
||||
const bodyDigits = bodyClean.replace(/[^\d]/g, "");
|
||||
if (bodyDigits.includes(selfDigits)) return true;
|
||||
const bodyNoSpace = msg.body.replace(/[\s-]/g, "");
|
||||
const pattern = new RegExp(`\\+?${selfDigits}`, "i");
|
||||
if (pattern.test(bodyNoSpace)) return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function debugMention(
|
||||
msg: WebInboundMsg,
|
||||
mentionCfg: MentionConfig,
|
||||
authDir?: string,
|
||||
): { wasMentioned: boolean; details: Record<string, unknown> } {
|
||||
const mentionTargets = resolveMentionTargets(msg, authDir);
|
||||
const result = isBotMentionedFromTargets(msg, mentionCfg, mentionTargets);
|
||||
const details = {
|
||||
from: msg.from,
|
||||
body: msg.body,
|
||||
bodyClean: normalizeMentionText(msg.body),
|
||||
mentionedJids: msg.mentionedJids ?? null,
|
||||
normalizedMentionedJids: mentionTargets.normalizedMentions.length
|
||||
? mentionTargets.normalizedMentions
|
||||
: null,
|
||||
selfJid: msg.selfJid ?? null,
|
||||
selfJidBare: mentionTargets.selfJid,
|
||||
selfE164: msg.selfE164 ?? null,
|
||||
resolvedSelfE164: mentionTargets.selfE164,
|
||||
};
|
||||
return { wasMentioned: result, details };
|
||||
}
|
||||
|
||||
export function resolveOwnerList(
|
||||
mentionCfg: MentionConfig,
|
||||
selfE164?: string | null,
|
||||
) {
|
||||
const allowFrom = mentionCfg.allowFrom;
|
||||
const raw =
|
||||
Array.isArray(allowFrom) && allowFrom.length > 0
|
||||
? allowFrom
|
||||
: selfE164
|
||||
? [selfE164]
|
||||
: [];
|
||||
return raw
|
||||
.filter((entry): entry is string => Boolean(entry && entry !== "*"))
|
||||
.map((entry) => normalizeE164(entry))
|
||||
.filter((entry): entry is string => Boolean(entry));
|
||||
}
|
||||
441
src/web/auto-reply/monitor.ts
Normal file
441
src/web/auto-reply/monitor.ts
Normal file
@@ -0,0 +1,441 @@
|
||||
import { DEFAULT_GROUP_HISTORY_LIMIT } from "../../auto-reply/reply/history.js";
|
||||
import { getReplyFromConfig } from "../../auto-reply/reply.js";
|
||||
import { waitForever } from "../../cli/wait.js";
|
||||
import { loadConfig } from "../../config/config.js";
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import { formatDurationMs } from "../../infra/format-duration.js";
|
||||
import { enqueueSystemEvent } from "../../infra/system-events.js";
|
||||
import { registerUnhandledRejectionHandler } from "../../infra/unhandled-rejections.js";
|
||||
import { getChildLogger } from "../../logging.js";
|
||||
import { resolveAgentRoute } from "../../routing/resolve-route.js";
|
||||
import { defaultRuntime, type RuntimeEnv } from "../../runtime.js";
|
||||
import { resolveWhatsAppAccount } from "../accounts.js";
|
||||
import { setActiveWebListener } from "../active-listener.js";
|
||||
import { monitorWebInbox } from "../inbound.js";
|
||||
import {
|
||||
computeBackoff,
|
||||
newConnectionId,
|
||||
resolveHeartbeatSeconds,
|
||||
resolveReconnectPolicy,
|
||||
sleepWithAbort,
|
||||
} from "../reconnect.js";
|
||||
import { formatError, getWebAuthAgeMs, readWebSelfId } from "../session.js";
|
||||
import { DEFAULT_WEB_MEDIA_BYTES } from "./constants.js";
|
||||
import { whatsappHeartbeatLog, whatsappLog } from "./loggers.js";
|
||||
import { buildMentionConfig } from "./mentions.js";
|
||||
import { createEchoTracker } from "./monitor/echo.js";
|
||||
import { createWebOnMessageHandler } from "./monitor/on-message.js";
|
||||
import type {
|
||||
WebChannelStatus,
|
||||
WebInboundMsg,
|
||||
WebMonitorTuning,
|
||||
} from "./types.js";
|
||||
import { isLikelyWhatsAppCryptoError } from "./util.js";
|
||||
|
||||
export async function monitorWebChannel(
|
||||
verbose: boolean,
|
||||
listenerFactory: typeof monitorWebInbox | undefined = monitorWebInbox,
|
||||
keepAlive = true,
|
||||
replyResolver: typeof getReplyFromConfig | undefined = getReplyFromConfig,
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
abortSignal?: AbortSignal,
|
||||
tuning: WebMonitorTuning = {},
|
||||
) {
|
||||
const runId = newConnectionId();
|
||||
const replyLogger = getChildLogger({ module: "web-auto-reply", runId });
|
||||
const heartbeatLogger = getChildLogger({ module: "web-heartbeat", runId });
|
||||
const reconnectLogger = getChildLogger({ module: "web-reconnect", runId });
|
||||
const status: WebChannelStatus = {
|
||||
running: true,
|
||||
connected: false,
|
||||
reconnectAttempts: 0,
|
||||
lastConnectedAt: null,
|
||||
lastDisconnect: null,
|
||||
lastMessageAt: null,
|
||||
lastEventAt: null,
|
||||
lastError: null,
|
||||
};
|
||||
const emitStatus = () => {
|
||||
tuning.statusSink?.({
|
||||
...status,
|
||||
lastDisconnect: status.lastDisconnect
|
||||
? { ...status.lastDisconnect }
|
||||
: null,
|
||||
});
|
||||
};
|
||||
emitStatus();
|
||||
|
||||
const baseCfg = loadConfig();
|
||||
const account = resolveWhatsAppAccount({
|
||||
cfg: baseCfg,
|
||||
accountId: tuning.accountId,
|
||||
});
|
||||
const cfg = {
|
||||
...baseCfg,
|
||||
channels: {
|
||||
...baseCfg.channels,
|
||||
whatsapp: {
|
||||
...baseCfg.channels?.whatsapp,
|
||||
ackReaction: account.ackReaction,
|
||||
messagePrefix: account.messagePrefix,
|
||||
allowFrom: account.allowFrom,
|
||||
groupAllowFrom: account.groupAllowFrom,
|
||||
groupPolicy: account.groupPolicy,
|
||||
textChunkLimit: account.textChunkLimit,
|
||||
mediaMaxMb: account.mediaMaxMb,
|
||||
blockStreaming: account.blockStreaming,
|
||||
groups: account.groups,
|
||||
},
|
||||
},
|
||||
} satisfies ReturnType<typeof loadConfig>;
|
||||
|
||||
const configuredMaxMb = cfg.agents?.defaults?.mediaMaxMb;
|
||||
const maxMediaBytes =
|
||||
typeof configuredMaxMb === "number" && configuredMaxMb > 0
|
||||
? configuredMaxMb * 1024 * 1024
|
||||
: DEFAULT_WEB_MEDIA_BYTES;
|
||||
const heartbeatSeconds = resolveHeartbeatSeconds(
|
||||
cfg,
|
||||
tuning.heartbeatSeconds,
|
||||
);
|
||||
const reconnectPolicy = resolveReconnectPolicy(cfg, tuning.reconnect);
|
||||
const baseMentionConfig = buildMentionConfig(cfg);
|
||||
const groupHistoryLimit =
|
||||
cfg.channels?.whatsapp?.accounts?.[tuning.accountId ?? ""]?.historyLimit ??
|
||||
cfg.channels?.whatsapp?.historyLimit ??
|
||||
cfg.messages?.groupChat?.historyLimit ??
|
||||
DEFAULT_GROUP_HISTORY_LIMIT;
|
||||
const groupHistories = new Map<
|
||||
string,
|
||||
Array<{
|
||||
sender: string;
|
||||
body: string;
|
||||
timestamp?: number;
|
||||
id?: string;
|
||||
senderJid?: string;
|
||||
}>
|
||||
>();
|
||||
const groupMemberNames = new Map<string, Map<string, string>>();
|
||||
const echoTracker = createEchoTracker({ maxItems: 100, logVerbose });
|
||||
|
||||
const sleep =
|
||||
tuning.sleep ??
|
||||
((ms: number, signal?: AbortSignal) =>
|
||||
sleepWithAbort(ms, signal ?? abortSignal));
|
||||
const stopRequested = () => abortSignal?.aborted === true;
|
||||
const abortPromise =
|
||||
abortSignal &&
|
||||
new Promise<"aborted">((resolve) =>
|
||||
abortSignal.addEventListener("abort", () => resolve("aborted"), {
|
||||
once: true,
|
||||
}),
|
||||
);
|
||||
|
||||
// Avoid noisy MaxListenersExceeded warnings in test environments where
|
||||
// multiple gateway instances may be constructed.
|
||||
const currentMaxListeners = process.getMaxListeners?.() ?? 10;
|
||||
if (process.setMaxListeners && currentMaxListeners < 50) {
|
||||
process.setMaxListeners(50);
|
||||
}
|
||||
|
||||
let sigintStop = false;
|
||||
const handleSigint = () => {
|
||||
sigintStop = true;
|
||||
};
|
||||
process.once("SIGINT", handleSigint);
|
||||
|
||||
let reconnectAttempts = 0;
|
||||
|
||||
while (true) {
|
||||
if (stopRequested()) break;
|
||||
|
||||
const connectionId = newConnectionId();
|
||||
const startedAt = Date.now();
|
||||
let heartbeat: NodeJS.Timeout | null = null;
|
||||
let watchdogTimer: NodeJS.Timeout | null = null;
|
||||
let lastMessageAt: number | null = null;
|
||||
let handledMessages = 0;
|
||||
let _lastInboundMsg: WebInboundMsg | null = null;
|
||||
let unregisterUnhandled: (() => void) | null = null;
|
||||
|
||||
// Watchdog to detect stuck message processing (e.g., event emitter died)
|
||||
const MESSAGE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes without any messages
|
||||
const WATCHDOG_CHECK_MS = 60 * 1000; // Check every minute
|
||||
|
||||
const backgroundTasks = new Set<Promise<unknown>>();
|
||||
const onMessage = createWebOnMessageHandler({
|
||||
cfg,
|
||||
verbose,
|
||||
connectionId,
|
||||
maxMediaBytes,
|
||||
groupHistoryLimit,
|
||||
groupHistories,
|
||||
groupMemberNames,
|
||||
echoTracker,
|
||||
backgroundTasks,
|
||||
replyResolver: replyResolver ?? getReplyFromConfig,
|
||||
replyLogger,
|
||||
baseMentionConfig,
|
||||
account,
|
||||
});
|
||||
|
||||
const listener = await (listenerFactory ?? monitorWebInbox)({
|
||||
verbose,
|
||||
accountId: account.accountId,
|
||||
authDir: account.authDir,
|
||||
mediaMaxMb: account.mediaMaxMb,
|
||||
onMessage: async (msg: WebInboundMsg) => {
|
||||
handledMessages += 1;
|
||||
lastMessageAt = Date.now();
|
||||
status.lastMessageAt = lastMessageAt;
|
||||
status.lastEventAt = lastMessageAt;
|
||||
emitStatus();
|
||||
_lastInboundMsg = msg;
|
||||
await onMessage(msg);
|
||||
},
|
||||
});
|
||||
|
||||
status.connected = true;
|
||||
status.lastConnectedAt = Date.now();
|
||||
status.lastEventAt = status.lastConnectedAt;
|
||||
status.lastError = null;
|
||||
emitStatus();
|
||||
|
||||
// Surface a concise connection event for the next main-session turn/heartbeat.
|
||||
const { e164: selfE164 } = readWebSelfId(account.authDir);
|
||||
const connectRoute = resolveAgentRoute({
|
||||
cfg,
|
||||
channel: "whatsapp",
|
||||
accountId: account.accountId,
|
||||
});
|
||||
enqueueSystemEvent(
|
||||
`WhatsApp gateway connected${selfE164 ? ` as ${selfE164}` : ""}.`,
|
||||
{ sessionKey: connectRoute.sessionKey },
|
||||
);
|
||||
|
||||
setActiveWebListener(account.accountId, listener);
|
||||
unregisterUnhandled = registerUnhandledRejectionHandler((reason) => {
|
||||
if (!isLikelyWhatsAppCryptoError(reason)) return false;
|
||||
const errorStr = formatError(reason);
|
||||
reconnectLogger.warn(
|
||||
{ connectionId, error: errorStr },
|
||||
"web reconnect: unhandled rejection from WhatsApp socket; forcing reconnect",
|
||||
);
|
||||
listener.signalClose?.({
|
||||
status: 499,
|
||||
isLoggedOut: false,
|
||||
error: reason,
|
||||
});
|
||||
return true;
|
||||
});
|
||||
|
||||
const closeListener = async () => {
|
||||
setActiveWebListener(account.accountId, null);
|
||||
if (unregisterUnhandled) {
|
||||
unregisterUnhandled();
|
||||
unregisterUnhandled = null;
|
||||
}
|
||||
if (heartbeat) clearInterval(heartbeat);
|
||||
if (watchdogTimer) clearInterval(watchdogTimer);
|
||||
if (backgroundTasks.size > 0) {
|
||||
await Promise.allSettled(backgroundTasks);
|
||||
backgroundTasks.clear();
|
||||
}
|
||||
try {
|
||||
await listener.close();
|
||||
} catch (err) {
|
||||
logVerbose(`Socket close failed: ${formatError(err)}`);
|
||||
}
|
||||
};
|
||||
|
||||
if (keepAlive) {
|
||||
heartbeat = setInterval(() => {
|
||||
const authAgeMs = getWebAuthAgeMs(account.authDir);
|
||||
const minutesSinceLastMessage = lastMessageAt
|
||||
? Math.floor((Date.now() - lastMessageAt) / 60000)
|
||||
: null;
|
||||
|
||||
const logData = {
|
||||
connectionId,
|
||||
reconnectAttempts,
|
||||
messagesHandled: handledMessages,
|
||||
lastMessageAt,
|
||||
authAgeMs,
|
||||
uptimeMs: Date.now() - startedAt,
|
||||
...(minutesSinceLastMessage !== null && minutesSinceLastMessage > 30
|
||||
? { minutesSinceLastMessage }
|
||||
: {}),
|
||||
};
|
||||
|
||||
if (minutesSinceLastMessage && minutesSinceLastMessage > 30) {
|
||||
heartbeatLogger.warn(
|
||||
logData,
|
||||
"⚠️ web gateway heartbeat - no messages in 30+ minutes",
|
||||
);
|
||||
} else {
|
||||
heartbeatLogger.info(logData, "web gateway heartbeat");
|
||||
}
|
||||
}, heartbeatSeconds * 1000);
|
||||
|
||||
watchdogTimer = setInterval(() => {
|
||||
if (!lastMessageAt) return;
|
||||
const timeSinceLastMessage = Date.now() - lastMessageAt;
|
||||
if (timeSinceLastMessage <= MESSAGE_TIMEOUT_MS) return;
|
||||
const minutesSinceLastMessage = Math.floor(
|
||||
timeSinceLastMessage / 60000,
|
||||
);
|
||||
heartbeatLogger.warn(
|
||||
{
|
||||
connectionId,
|
||||
minutesSinceLastMessage,
|
||||
lastMessageAt: new Date(lastMessageAt),
|
||||
messagesHandled: handledMessages,
|
||||
},
|
||||
"Message timeout detected - forcing reconnect",
|
||||
);
|
||||
whatsappHeartbeatLog.warn(
|
||||
`No messages received in ${minutesSinceLastMessage}m - restarting connection`,
|
||||
);
|
||||
void closeListener().catch((err) => {
|
||||
logVerbose(`Close listener failed: ${formatError(err)}`);
|
||||
});
|
||||
listener.signalClose?.({
|
||||
status: 499,
|
||||
isLoggedOut: false,
|
||||
error: "watchdog-timeout",
|
||||
});
|
||||
}, WATCHDOG_CHECK_MS);
|
||||
}
|
||||
|
||||
whatsappLog.info("Listening for personal WhatsApp inbound messages.");
|
||||
if (process.stdout.isTTY || process.stderr.isTTY) {
|
||||
whatsappLog.raw("Ctrl+C to stop.");
|
||||
}
|
||||
|
||||
if (!keepAlive) {
|
||||
await closeListener();
|
||||
return;
|
||||
}
|
||||
|
||||
const reason = await Promise.race([
|
||||
listener.onClose?.catch((err) => {
|
||||
reconnectLogger.error(
|
||||
{ error: formatError(err) },
|
||||
"listener.onClose rejected",
|
||||
);
|
||||
return { status: 500, isLoggedOut: false, error: err };
|
||||
}) ?? waitForever(),
|
||||
abortPromise ?? waitForever(),
|
||||
]);
|
||||
|
||||
const uptimeMs = Date.now() - startedAt;
|
||||
if (uptimeMs > heartbeatSeconds * 1000) {
|
||||
reconnectAttempts = 0; // Healthy stretch; reset the backoff.
|
||||
}
|
||||
status.reconnectAttempts = reconnectAttempts;
|
||||
emitStatus();
|
||||
|
||||
if (stopRequested() || sigintStop || reason === "aborted") {
|
||||
await closeListener();
|
||||
break;
|
||||
}
|
||||
|
||||
const statusCode =
|
||||
(typeof reason === "object" && reason && "status" in reason
|
||||
? (reason as { status?: number }).status
|
||||
: undefined) ?? "unknown";
|
||||
const loggedOut =
|
||||
typeof reason === "object" &&
|
||||
reason &&
|
||||
"isLoggedOut" in reason &&
|
||||
(reason as { isLoggedOut?: boolean }).isLoggedOut;
|
||||
|
||||
const errorStr = formatError(reason);
|
||||
status.connected = false;
|
||||
status.lastEventAt = Date.now();
|
||||
status.lastDisconnect = {
|
||||
at: status.lastEventAt,
|
||||
status: typeof statusCode === "number" ? statusCode : undefined,
|
||||
error: errorStr,
|
||||
loggedOut: Boolean(loggedOut),
|
||||
};
|
||||
status.lastError = errorStr;
|
||||
status.reconnectAttempts = reconnectAttempts;
|
||||
emitStatus();
|
||||
|
||||
reconnectLogger.info(
|
||||
{
|
||||
connectionId,
|
||||
status: statusCode,
|
||||
loggedOut,
|
||||
reconnectAttempts,
|
||||
error: errorStr,
|
||||
},
|
||||
"web reconnect: connection closed",
|
||||
);
|
||||
|
||||
enqueueSystemEvent(
|
||||
`WhatsApp gateway disconnected (status ${statusCode ?? "unknown"})`,
|
||||
{ sessionKey: connectRoute.sessionKey },
|
||||
);
|
||||
|
||||
if (loggedOut) {
|
||||
runtime.error(
|
||||
"WhatsApp session logged out. Run `clawdbot channels login --channel web` to relink.",
|
||||
);
|
||||
await closeListener();
|
||||
break;
|
||||
}
|
||||
|
||||
reconnectAttempts += 1;
|
||||
status.reconnectAttempts = reconnectAttempts;
|
||||
emitStatus();
|
||||
if (
|
||||
reconnectPolicy.maxAttempts > 0 &&
|
||||
reconnectAttempts >= reconnectPolicy.maxAttempts
|
||||
) {
|
||||
reconnectLogger.warn(
|
||||
{
|
||||
connectionId,
|
||||
status: statusCode,
|
||||
reconnectAttempts,
|
||||
maxAttempts: reconnectPolicy.maxAttempts,
|
||||
},
|
||||
"web reconnect: max attempts reached; continuing in degraded mode",
|
||||
);
|
||||
runtime.error(
|
||||
`WhatsApp Web reconnect: max attempts reached (${reconnectAttempts}/${reconnectPolicy.maxAttempts}). Stopping web monitoring.`,
|
||||
);
|
||||
await closeListener();
|
||||
break;
|
||||
}
|
||||
|
||||
const delay = computeBackoff(reconnectPolicy, reconnectAttempts);
|
||||
reconnectLogger.info(
|
||||
{
|
||||
connectionId,
|
||||
status: statusCode,
|
||||
reconnectAttempts,
|
||||
maxAttempts: reconnectPolicy.maxAttempts || "unlimited",
|
||||
delayMs: delay,
|
||||
},
|
||||
"web reconnect: scheduling retry",
|
||||
);
|
||||
runtime.error(
|
||||
`WhatsApp Web connection closed (status ${statusCode}). Retry ${reconnectAttempts}/${reconnectPolicy.maxAttempts || "∞"} in ${formatDurationMs(delay)}… (${errorStr})`,
|
||||
);
|
||||
await closeListener();
|
||||
try {
|
||||
await sleep(delay, abortSignal);
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
status.running = false;
|
||||
status.connected = false;
|
||||
status.lastEventAt = Date.now();
|
||||
emitStatus();
|
||||
|
||||
process.removeListener("SIGINT", handleSigint);
|
||||
}
|
||||
76
src/web/auto-reply/monitor/ack-reaction.ts
Normal file
76
src/web/auto-reply/monitor/ack-reaction.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import type { loadConfig } from "../../../config/config.js";
|
||||
import { logVerbose } from "../../../globals.js";
|
||||
import { sendReactionWhatsApp } from "../../outbound.js";
|
||||
import { formatError } from "../../session.js";
|
||||
import type { WebInboundMsg } from "../types.js";
|
||||
import { resolveGroupActivationFor } from "./group-activation.js";
|
||||
|
||||
export function maybeSendAckReaction(params: {
|
||||
cfg: ReturnType<typeof loadConfig>;
|
||||
msg: WebInboundMsg;
|
||||
agentId: string;
|
||||
sessionKey: string;
|
||||
conversationId: string;
|
||||
verbose: boolean;
|
||||
accountId?: string;
|
||||
info: (obj: unknown, msg: string) => void;
|
||||
warn: (obj: unknown, msg: string) => void;
|
||||
}) {
|
||||
if (!params.msg.id) return;
|
||||
|
||||
const ackConfig = params.cfg.channels?.whatsapp?.ackReaction;
|
||||
const emoji = (ackConfig?.emoji ?? "").trim();
|
||||
const directEnabled = ackConfig?.direct ?? true;
|
||||
const groupMode = ackConfig?.group ?? "mentions";
|
||||
const conversationIdForCheck = params.msg.conversationId ?? params.msg.from;
|
||||
|
||||
const shouldSendReaction = () => {
|
||||
if (!emoji) return false;
|
||||
|
||||
if (params.msg.chatType === "direct") {
|
||||
return directEnabled;
|
||||
}
|
||||
|
||||
if (params.msg.chatType === "group") {
|
||||
if (groupMode === "never") return false;
|
||||
if (groupMode === "always") return true;
|
||||
if (groupMode === "mentions") {
|
||||
const activation = resolveGroupActivationFor({
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
sessionKey: params.sessionKey,
|
||||
conversationId: conversationIdForCheck,
|
||||
});
|
||||
if (activation === "always") return true;
|
||||
return params.msg.wasMentioned === true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
if (!shouldSendReaction()) return;
|
||||
|
||||
params.info(
|
||||
{ chatId: params.msg.chatId, messageId: params.msg.id, emoji },
|
||||
"sending ack reaction",
|
||||
);
|
||||
sendReactionWhatsApp(params.msg.chatId, params.msg.id, emoji, {
|
||||
verbose: params.verbose,
|
||||
fromMe: false,
|
||||
participant: params.msg.senderJid,
|
||||
accountId: params.accountId,
|
||||
}).catch((err) => {
|
||||
params.warn(
|
||||
{
|
||||
error: formatError(err),
|
||||
chatId: params.msg.chatId,
|
||||
messageId: params.msg.id,
|
||||
},
|
||||
"failed to send ack reaction",
|
||||
);
|
||||
logVerbose(
|
||||
`WhatsApp ack reaction failed for chat ${params.msg.chatId}: ${formatError(err)}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
111
src/web/auto-reply/monitor/broadcast.ts
Normal file
111
src/web/auto-reply/monitor/broadcast.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import type { loadConfig } from "../../../config/config.js";
|
||||
import type { resolveAgentRoute } from "../../../routing/resolve-route.js";
|
||||
import { buildAgentSessionKey } from "../../../routing/resolve-route.js";
|
||||
import {
|
||||
buildAgentMainSessionKey,
|
||||
DEFAULT_MAIN_KEY,
|
||||
normalizeAgentId,
|
||||
} from "../../../routing/session-key.js";
|
||||
import { formatError } from "../../session.js";
|
||||
import { whatsappInboundLog } from "../loggers.js";
|
||||
import type { WebInboundMsg } from "../types.js";
|
||||
import type { GroupHistoryEntry } from "./process-message.js";
|
||||
|
||||
export async function maybeBroadcastMessage(params: {
|
||||
cfg: ReturnType<typeof loadConfig>;
|
||||
msg: WebInboundMsg;
|
||||
peerId: string;
|
||||
route: ReturnType<typeof resolveAgentRoute>;
|
||||
groupHistoryKey: string;
|
||||
groupHistories: Map<string, GroupHistoryEntry[]>;
|
||||
processMessage: (
|
||||
msg: WebInboundMsg,
|
||||
route: ReturnType<typeof resolveAgentRoute>,
|
||||
groupHistoryKey: string,
|
||||
opts?: {
|
||||
groupHistory?: GroupHistoryEntry[];
|
||||
suppressGroupHistoryClear?: boolean;
|
||||
},
|
||||
) => Promise<boolean>;
|
||||
}) {
|
||||
const broadcastAgents = params.cfg.broadcast?.[params.peerId];
|
||||
if (!broadcastAgents || !Array.isArray(broadcastAgents)) return false;
|
||||
if (broadcastAgents.length === 0) return false;
|
||||
|
||||
const strategy = params.cfg.broadcast?.strategy || "parallel";
|
||||
whatsappInboundLog.info(
|
||||
`Broadcasting message to ${broadcastAgents.length} agents (${strategy})`,
|
||||
);
|
||||
|
||||
const agentIds = params.cfg.agents?.list?.map((agent) =>
|
||||
normalizeAgentId(agent.id),
|
||||
);
|
||||
const hasKnownAgents = (agentIds?.length ?? 0) > 0;
|
||||
const groupHistorySnapshot =
|
||||
params.msg.chatType === "group"
|
||||
? (params.groupHistories.get(params.groupHistoryKey) ?? [])
|
||||
: undefined;
|
||||
|
||||
const processForAgent = async (agentId: string): Promise<boolean> => {
|
||||
const normalizedAgentId = normalizeAgentId(agentId);
|
||||
if (hasKnownAgents && !agentIds?.includes(normalizedAgentId)) {
|
||||
whatsappInboundLog.warn(
|
||||
`Broadcast agent ${agentId} not found in agents.list; skipping`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
const agentRoute = {
|
||||
...params.route,
|
||||
agentId: normalizedAgentId,
|
||||
sessionKey: buildAgentSessionKey({
|
||||
agentId: normalizedAgentId,
|
||||
channel: "whatsapp",
|
||||
peer: {
|
||||
kind: params.msg.chatType === "group" ? "group" : "dm",
|
||||
id: params.peerId,
|
||||
},
|
||||
}),
|
||||
mainSessionKey: buildAgentMainSessionKey({
|
||||
agentId: normalizedAgentId,
|
||||
mainKey: DEFAULT_MAIN_KEY,
|
||||
}),
|
||||
};
|
||||
|
||||
try {
|
||||
return await params.processMessage(
|
||||
params.msg,
|
||||
agentRoute,
|
||||
params.groupHistoryKey,
|
||||
{
|
||||
groupHistory: groupHistorySnapshot,
|
||||
suppressGroupHistoryClear: true,
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
whatsappInboundLog.error(
|
||||
`Broadcast agent ${agentId} failed: ${formatError(err)}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
let didSendReply = false;
|
||||
if (strategy === "sequential") {
|
||||
for (const agentId of broadcastAgents) {
|
||||
if (await processForAgent(agentId)) didSendReply = true;
|
||||
}
|
||||
} else {
|
||||
const results = await Promise.allSettled(
|
||||
broadcastAgents.map(processForAgent),
|
||||
);
|
||||
didSendReply = results.some(
|
||||
(result) => result.status === "fulfilled" && result.value,
|
||||
);
|
||||
}
|
||||
|
||||
if (params.msg.chatType === "group" && didSendReply) {
|
||||
params.groupHistories.set(params.groupHistoryKey, []);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
29
src/web/auto-reply/monitor/commands.ts
Normal file
29
src/web/auto-reply/monitor/commands.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export function isStatusCommand(body: string) {
|
||||
const trimmed = body.trim().toLowerCase();
|
||||
if (!trimmed) return false;
|
||||
return (
|
||||
trimmed === "/status" ||
|
||||
trimmed === "status" ||
|
||||
trimmed.startsWith("/status ")
|
||||
);
|
||||
}
|
||||
|
||||
export function stripMentionsForCommand(
|
||||
text: string,
|
||||
mentionRegexes: RegExp[],
|
||||
selfE164?: string | null,
|
||||
) {
|
||||
let result = text;
|
||||
for (const re of mentionRegexes) {
|
||||
result = result.replace(re, " ");
|
||||
}
|
||||
if (selfE164) {
|
||||
// `selfE164` is usually like "+1234"; strip down to digits so we can match "+?1234" safely.
|
||||
const digits = selfE164.replace(/\D/g, "");
|
||||
if (digits) {
|
||||
const pattern = new RegExp(`\\+?${digits}`, "g");
|
||||
result = result.replace(pattern, " ");
|
||||
}
|
||||
}
|
||||
return result.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
63
src/web/auto-reply/monitor/echo.ts
Normal file
63
src/web/auto-reply/monitor/echo.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
export type EchoTracker = {
|
||||
rememberText: (
|
||||
text: string | undefined,
|
||||
opts: {
|
||||
combinedBody?: string;
|
||||
combinedBodySessionKey?: string;
|
||||
logVerboseMessage?: boolean;
|
||||
},
|
||||
) => void;
|
||||
has: (key: string) => boolean;
|
||||
forget: (key: string) => void;
|
||||
buildCombinedKey: (params: {
|
||||
sessionKey: string;
|
||||
combinedBody: string;
|
||||
}) => string;
|
||||
};
|
||||
|
||||
export function createEchoTracker(params: {
|
||||
maxItems?: number;
|
||||
logVerbose?: (msg: string) => void;
|
||||
}): EchoTracker {
|
||||
const recentlySent = new Set<string>();
|
||||
const maxItems = Math.max(1, params.maxItems ?? 100);
|
||||
|
||||
const buildCombinedKey = (p: { sessionKey: string; combinedBody: string }) =>
|
||||
`combined:${p.sessionKey}:${p.combinedBody}`;
|
||||
|
||||
const trim = () => {
|
||||
while (recentlySent.size > maxItems) {
|
||||
const firstKey = recentlySent.values().next().value as string | undefined;
|
||||
if (!firstKey) break;
|
||||
recentlySent.delete(firstKey);
|
||||
}
|
||||
};
|
||||
|
||||
const rememberText: EchoTracker["rememberText"] = (text, opts) => {
|
||||
if (!text) return;
|
||||
recentlySent.add(text);
|
||||
if (opts.combinedBody && opts.combinedBodySessionKey) {
|
||||
recentlySent.add(
|
||||
buildCombinedKey({
|
||||
sessionKey: opts.combinedBodySessionKey,
|
||||
combinedBody: opts.combinedBody,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (opts.logVerboseMessage) {
|
||||
params.logVerbose?.(
|
||||
`Added to echo detection set (size now: ${recentlySent.size}): ${text.substring(0, 50)}...`,
|
||||
);
|
||||
}
|
||||
trim();
|
||||
};
|
||||
|
||||
return {
|
||||
rememberText,
|
||||
has: (key) => recentlySent.has(key),
|
||||
forget: (key) => {
|
||||
recentlySent.delete(key);
|
||||
},
|
||||
buildCombinedKey,
|
||||
};
|
||||
}
|
||||
62
src/web/auto-reply/monitor/group-activation.ts
Normal file
62
src/web/auto-reply/monitor/group-activation.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { normalizeGroupActivation } from "../../../auto-reply/group-activation.js";
|
||||
import type { loadConfig } from "../../../config/config.js";
|
||||
import {
|
||||
resolveChannelGroupPolicy,
|
||||
resolveChannelGroupRequireMention,
|
||||
} from "../../../config/group-policy.js";
|
||||
import {
|
||||
loadSessionStore,
|
||||
resolveGroupSessionKey,
|
||||
resolveStorePath,
|
||||
} from "../../../config/sessions.js";
|
||||
|
||||
export function resolveGroupPolicyFor(
|
||||
cfg: ReturnType<typeof loadConfig>,
|
||||
conversationId: string,
|
||||
) {
|
||||
const groupId = resolveGroupSessionKey({
|
||||
From: conversationId,
|
||||
ChatType: "group",
|
||||
Provider: "whatsapp",
|
||||
})?.id;
|
||||
return resolveChannelGroupPolicy({
|
||||
cfg,
|
||||
channel: "whatsapp",
|
||||
groupId: groupId ?? conversationId,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveGroupRequireMentionFor(
|
||||
cfg: ReturnType<typeof loadConfig>,
|
||||
conversationId: string,
|
||||
) {
|
||||
const groupId = resolveGroupSessionKey({
|
||||
From: conversationId,
|
||||
ChatType: "group",
|
||||
Provider: "whatsapp",
|
||||
})?.id;
|
||||
return resolveChannelGroupRequireMention({
|
||||
cfg,
|
||||
channel: "whatsapp",
|
||||
groupId: groupId ?? conversationId,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveGroupActivationFor(params: {
|
||||
cfg: ReturnType<typeof loadConfig>;
|
||||
agentId: string;
|
||||
sessionKey: string;
|
||||
conversationId: string;
|
||||
}) {
|
||||
const storePath = resolveStorePath(params.cfg.session?.store, {
|
||||
agentId: params.agentId,
|
||||
});
|
||||
const store = loadSessionStore(storePath);
|
||||
const entry = store[params.sessionKey];
|
||||
const requireMention = resolveGroupRequireMentionFor(
|
||||
params.cfg,
|
||||
params.conversationId,
|
||||
);
|
||||
const defaultActivation = requireMention === false ? "always" : "mention";
|
||||
return normalizeGroupActivation(entry?.groupActivation) ?? defaultActivation;
|
||||
}
|
||||
125
src/web/auto-reply/monitor/group-gating.ts
Normal file
125
src/web/auto-reply/monitor/group-gating.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { parseActivationCommand } from "../../../auto-reply/group-activation.js";
|
||||
import type { loadConfig } from "../../../config/config.js";
|
||||
import { normalizeE164 } from "../../../utils.js";
|
||||
import type { MentionConfig } from "../mentions.js";
|
||||
import {
|
||||
buildMentionConfig,
|
||||
debugMention,
|
||||
resolveOwnerList,
|
||||
} from "../mentions.js";
|
||||
import type { WebInboundMsg } from "../types.js";
|
||||
import { isStatusCommand, stripMentionsForCommand } from "./commands.js";
|
||||
import {
|
||||
resolveGroupActivationFor,
|
||||
resolveGroupPolicyFor,
|
||||
} from "./group-activation.js";
|
||||
import { noteGroupMember } from "./group-members.js";
|
||||
|
||||
export type GroupHistoryEntry = {
|
||||
sender: string;
|
||||
body: string;
|
||||
timestamp?: number;
|
||||
id?: string;
|
||||
senderJid?: string;
|
||||
};
|
||||
|
||||
function isOwnerSender(baseMentionConfig: MentionConfig, msg: WebInboundMsg) {
|
||||
const sender = normalizeE164(msg.senderE164 ?? "");
|
||||
if (!sender) return false;
|
||||
const owners = resolveOwnerList(baseMentionConfig, msg.selfE164 ?? undefined);
|
||||
return owners.includes(sender);
|
||||
}
|
||||
|
||||
export function applyGroupGating(params: {
|
||||
cfg: ReturnType<typeof loadConfig>;
|
||||
msg: WebInboundMsg;
|
||||
conversationId: string;
|
||||
groupHistoryKey: string;
|
||||
agentId: string;
|
||||
sessionKey: string;
|
||||
baseMentionConfig: MentionConfig;
|
||||
authDir?: string;
|
||||
groupHistories: Map<string, GroupHistoryEntry[]>;
|
||||
groupHistoryLimit: number;
|
||||
groupMemberNames: Map<string, Map<string, string>>;
|
||||
logVerbose: (msg: string) => void;
|
||||
replyLogger: { debug: (obj: unknown, msg: string) => void };
|
||||
}) {
|
||||
const groupPolicy = resolveGroupPolicyFor(params.cfg, params.conversationId);
|
||||
if (groupPolicy.allowlistEnabled && !groupPolicy.allowed) {
|
||||
params.logVerbose(
|
||||
`Skipping group message ${params.conversationId} (not in allowlist)`,
|
||||
);
|
||||
return { shouldProcess: false };
|
||||
}
|
||||
|
||||
noteGroupMember(
|
||||
params.groupMemberNames,
|
||||
params.groupHistoryKey,
|
||||
params.msg.senderE164,
|
||||
params.msg.senderName,
|
||||
);
|
||||
|
||||
const mentionConfig = buildMentionConfig(params.cfg, params.agentId);
|
||||
const commandBody = stripMentionsForCommand(
|
||||
params.msg.body,
|
||||
mentionConfig.mentionRegexes,
|
||||
params.msg.selfE164,
|
||||
);
|
||||
const activationCommand = parseActivationCommand(commandBody);
|
||||
const owner = isOwnerSender(params.baseMentionConfig, params.msg);
|
||||
const statusCommand = isStatusCommand(commandBody);
|
||||
const shouldBypassMention =
|
||||
owner && (activationCommand.hasCommand || statusCommand);
|
||||
|
||||
if (activationCommand.hasCommand && !owner) {
|
||||
params.logVerbose(
|
||||
`Ignoring /activation from non-owner in group ${params.conversationId}`,
|
||||
);
|
||||
return { shouldProcess: false };
|
||||
}
|
||||
|
||||
if (!shouldBypassMention) {
|
||||
const history = params.groupHistories.get(params.groupHistoryKey) ?? [];
|
||||
const sender =
|
||||
params.msg.senderName && params.msg.senderE164
|
||||
? `${params.msg.senderName} (${params.msg.senderE164})`
|
||||
: (params.msg.senderName ?? params.msg.senderE164 ?? "Unknown");
|
||||
history.push({
|
||||
sender,
|
||||
body: params.msg.body,
|
||||
timestamp: params.msg.timestamp,
|
||||
id: params.msg.id,
|
||||
senderJid: params.msg.senderJid,
|
||||
});
|
||||
while (history.length > params.groupHistoryLimit) history.shift();
|
||||
params.groupHistories.set(params.groupHistoryKey, history);
|
||||
}
|
||||
|
||||
const mentionDebug = debugMention(params.msg, mentionConfig, params.authDir);
|
||||
params.replyLogger.debug(
|
||||
{
|
||||
conversationId: params.conversationId,
|
||||
wasMentioned: mentionDebug.wasMentioned,
|
||||
...mentionDebug.details,
|
||||
},
|
||||
"group mention debug",
|
||||
);
|
||||
const wasMentioned = mentionDebug.wasMentioned;
|
||||
params.msg.wasMentioned = wasMentioned;
|
||||
const activation = resolveGroupActivationFor({
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
sessionKey: params.sessionKey,
|
||||
conversationId: params.conversationId,
|
||||
});
|
||||
const requireMention = activation !== "always";
|
||||
if (!shouldBypassMention && requireMention && !wasMentioned) {
|
||||
params.logVerbose(
|
||||
`Group message stored for context (no mention detected) in ${params.conversationId}: ${params.msg.body}`,
|
||||
);
|
||||
return { shouldProcess: false };
|
||||
}
|
||||
|
||||
return { shouldProcess: true };
|
||||
}
|
||||
57
src/web/auto-reply/monitor/group-members.ts
Normal file
57
src/web/auto-reply/monitor/group-members.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { normalizeE164 } from "../../../utils.js";
|
||||
|
||||
export function noteGroupMember(
|
||||
groupMemberNames: Map<string, Map<string, string>>,
|
||||
conversationId: string,
|
||||
e164?: string,
|
||||
name?: string,
|
||||
) {
|
||||
if (!e164 || !name) return;
|
||||
const normalized = normalizeE164(e164);
|
||||
const key = normalized ?? e164;
|
||||
if (!key) return;
|
||||
let roster = groupMemberNames.get(conversationId);
|
||||
if (!roster) {
|
||||
roster = new Map();
|
||||
groupMemberNames.set(conversationId, roster);
|
||||
}
|
||||
roster.set(key, name);
|
||||
}
|
||||
|
||||
export function formatGroupMembers(params: {
|
||||
participants: string[] | undefined;
|
||||
roster: Map<string, string> | undefined;
|
||||
fallbackE164?: string;
|
||||
}) {
|
||||
const { participants, roster, fallbackE164 } = params;
|
||||
const seen = new Set<string>();
|
||||
const ordered: string[] = [];
|
||||
if (participants?.length) {
|
||||
for (const entry of participants) {
|
||||
if (!entry) continue;
|
||||
const normalized = normalizeE164(entry) ?? entry;
|
||||
if (!normalized || seen.has(normalized)) continue;
|
||||
seen.add(normalized);
|
||||
ordered.push(normalized);
|
||||
}
|
||||
}
|
||||
if (roster) {
|
||||
for (const entry of roster.keys()) {
|
||||
const normalized = normalizeE164(entry) ?? entry;
|
||||
if (!normalized || seen.has(normalized)) continue;
|
||||
seen.add(normalized);
|
||||
ordered.push(normalized);
|
||||
}
|
||||
}
|
||||
if (ordered.length === 0 && fallbackE164) {
|
||||
const normalized = normalizeE164(fallbackE164) ?? fallbackE164;
|
||||
if (normalized) ordered.push(normalized);
|
||||
}
|
||||
if (ordered.length === 0) return undefined;
|
||||
return ordered
|
||||
.map((entry) => {
|
||||
const name = roster?.get(entry);
|
||||
return name ? `${name} (${entry})` : entry;
|
||||
})
|
||||
.join(", ");
|
||||
}
|
||||
53
src/web/auto-reply/monitor/last-route.ts
Normal file
53
src/web/auto-reply/monitor/last-route.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import type { loadConfig } from "../../../config/config.js";
|
||||
import { resolveStorePath, updateLastRoute } from "../../../config/sessions.js";
|
||||
import { formatError } from "../../session.js";
|
||||
|
||||
export function trackBackgroundTask(
|
||||
backgroundTasks: Set<Promise<unknown>>,
|
||||
task: Promise<unknown>,
|
||||
) {
|
||||
backgroundTasks.add(task);
|
||||
void task.finally(() => {
|
||||
backgroundTasks.delete(task);
|
||||
});
|
||||
}
|
||||
|
||||
export function updateLastRouteInBackground(params: {
|
||||
cfg: ReturnType<typeof loadConfig>;
|
||||
backgroundTasks: Set<Promise<unknown>>;
|
||||
storeAgentId: string;
|
||||
sessionKey: string;
|
||||
channel: "whatsapp";
|
||||
to: string;
|
||||
accountId?: string;
|
||||
warn: (obj: unknown, msg: string) => void;
|
||||
}) {
|
||||
const storePath = resolveStorePath(params.cfg.session?.store, {
|
||||
agentId: params.storeAgentId,
|
||||
});
|
||||
const task = updateLastRoute({
|
||||
storePath,
|
||||
sessionKey: params.sessionKey,
|
||||
channel: params.channel,
|
||||
to: params.to,
|
||||
accountId: params.accountId,
|
||||
}).catch((err) => {
|
||||
params.warn(
|
||||
{
|
||||
error: formatError(err),
|
||||
storePath,
|
||||
sessionKey: params.sessionKey,
|
||||
to: params.to,
|
||||
},
|
||||
"failed updating last route",
|
||||
);
|
||||
});
|
||||
trackBackgroundTask(params.backgroundTasks, task);
|
||||
}
|
||||
|
||||
export function awaitBackgroundTasks(backgroundTasks: Set<Promise<unknown>>) {
|
||||
if (backgroundTasks.size === 0) return Promise.resolve();
|
||||
return Promise.allSettled(backgroundTasks).then(() => {
|
||||
backgroundTasks.clear();
|
||||
});
|
||||
}
|
||||
42
src/web/auto-reply/monitor/message-line.ts
Normal file
42
src/web/auto-reply/monitor/message-line.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { resolveMessagePrefix } from "../../../agents/identity.js";
|
||||
import { formatAgentEnvelope } from "../../../auto-reply/envelope.js";
|
||||
import type { loadConfig } from "../../../config/config.js";
|
||||
import type { WebInboundMsg } from "../types.js";
|
||||
|
||||
export function formatReplyContext(msg: WebInboundMsg) {
|
||||
if (!msg.replyToBody) return null;
|
||||
const sender = msg.replyToSender ?? "unknown sender";
|
||||
const idPart = msg.replyToId ? ` id:${msg.replyToId}` : "";
|
||||
return `[Replying to ${sender}${idPart}]\n${msg.replyToBody}\n[/Replying]`;
|
||||
}
|
||||
|
||||
export function buildInboundLine(params: {
|
||||
cfg: ReturnType<typeof loadConfig>;
|
||||
msg: WebInboundMsg;
|
||||
agentId: string;
|
||||
}) {
|
||||
const { cfg, msg, agentId } = params;
|
||||
// WhatsApp inbound prefix: channels.whatsapp.messagePrefix > legacy messages.messagePrefix > identity/defaults
|
||||
const messagePrefix = resolveMessagePrefix(cfg, agentId, {
|
||||
configured: cfg.channels?.whatsapp?.messagePrefix,
|
||||
hasAllowFrom: (cfg.channels?.whatsapp?.allowFrom?.length ?? 0) > 0,
|
||||
});
|
||||
const prefixStr = messagePrefix ? `${messagePrefix} ` : "";
|
||||
const senderLabel =
|
||||
msg.chatType === "group"
|
||||
? `${msg.senderName ?? msg.senderE164 ?? "Someone"}: `
|
||||
: "";
|
||||
const replyContext = formatReplyContext(msg);
|
||||
const baseLine = `${prefixStr}${senderLabel}${msg.body}${
|
||||
replyContext ? `\n\n${replyContext}` : ""
|
||||
}`;
|
||||
|
||||
// Wrap with standardized envelope for the agent.
|
||||
return formatAgentEnvelope({
|
||||
channel: "WhatsApp",
|
||||
from:
|
||||
msg.chatType === "group" ? msg.from : msg.from?.replace(/^whatsapp:/, ""),
|
||||
timestamp: msg.timestamp,
|
||||
body: baseLine,
|
||||
});
|
||||
}
|
||||
153
src/web/auto-reply/monitor/on-message.ts
Normal file
153
src/web/auto-reply/monitor/on-message.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import type { getReplyFromConfig } from "../../../auto-reply/reply.js";
|
||||
import type { loadConfig } from "../../../config/config.js";
|
||||
import { logVerbose } from "../../../globals.js";
|
||||
import { resolveAgentRoute } from "../../../routing/resolve-route.js";
|
||||
import { buildGroupHistoryKey } from "../../../routing/session-key.js";
|
||||
import { normalizeE164 } from "../../../utils.js";
|
||||
import type { MentionConfig } from "../mentions.js";
|
||||
import type { WebInboundMsg } from "../types.js";
|
||||
import { maybeBroadcastMessage } from "./broadcast.js";
|
||||
import type { EchoTracker } from "./echo.js";
|
||||
import type { GroupHistoryEntry } from "./group-gating.js";
|
||||
import { applyGroupGating } from "./group-gating.js";
|
||||
import { updateLastRouteInBackground } from "./last-route.js";
|
||||
import { resolvePeerId } from "./peer.js";
|
||||
import { processMessage } from "./process-message.js";
|
||||
|
||||
export function createWebOnMessageHandler(params: {
|
||||
cfg: ReturnType<typeof loadConfig>;
|
||||
verbose: boolean;
|
||||
connectionId: string;
|
||||
maxMediaBytes: number;
|
||||
groupHistoryLimit: number;
|
||||
groupHistories: Map<string, GroupHistoryEntry[]>;
|
||||
groupMemberNames: Map<string, Map<string, string>>;
|
||||
echoTracker: EchoTracker;
|
||||
backgroundTasks: Set<Promise<unknown>>;
|
||||
replyResolver: typeof getReplyFromConfig;
|
||||
replyLogger: ReturnType<
|
||||
typeof import("../../../logging.js")["getChildLogger"]
|
||||
>;
|
||||
baseMentionConfig: MentionConfig;
|
||||
account: { authDir?: string; accountId?: string };
|
||||
}) {
|
||||
const processForRoute = async (
|
||||
msg: WebInboundMsg,
|
||||
route: ReturnType<typeof resolveAgentRoute>,
|
||||
groupHistoryKey: string,
|
||||
opts?: {
|
||||
groupHistory?: GroupHistoryEntry[];
|
||||
suppressGroupHistoryClear?: boolean;
|
||||
},
|
||||
) =>
|
||||
processMessage({
|
||||
cfg: params.cfg,
|
||||
msg,
|
||||
route,
|
||||
groupHistoryKey,
|
||||
groupHistories: params.groupHistories,
|
||||
groupMemberNames: params.groupMemberNames,
|
||||
connectionId: params.connectionId,
|
||||
verbose: params.verbose,
|
||||
maxMediaBytes: params.maxMediaBytes,
|
||||
replyResolver: params.replyResolver,
|
||||
replyLogger: params.replyLogger,
|
||||
backgroundTasks: params.backgroundTasks,
|
||||
rememberSentText: params.echoTracker.rememberText,
|
||||
echoHas: params.echoTracker.has,
|
||||
echoForget: params.echoTracker.forget,
|
||||
buildCombinedEchoKey: params.echoTracker.buildCombinedKey,
|
||||
groupHistory: opts?.groupHistory,
|
||||
suppressGroupHistoryClear: opts?.suppressGroupHistoryClear,
|
||||
});
|
||||
|
||||
return async (msg: WebInboundMsg) => {
|
||||
const conversationId = msg.conversationId ?? msg.from;
|
||||
const peerId = resolvePeerId(msg);
|
||||
const route = resolveAgentRoute({
|
||||
cfg: params.cfg,
|
||||
channel: "whatsapp",
|
||||
accountId: msg.accountId,
|
||||
peer: {
|
||||
kind: msg.chatType === "group" ? "group" : "dm",
|
||||
id: peerId,
|
||||
},
|
||||
});
|
||||
const groupHistoryKey =
|
||||
msg.chatType === "group"
|
||||
? buildGroupHistoryKey({
|
||||
channel: "whatsapp",
|
||||
accountId: route.accountId,
|
||||
peerKind: "group",
|
||||
peerId,
|
||||
})
|
||||
: route.sessionKey;
|
||||
|
||||
// Same-phone mode logging retained
|
||||
if (msg.from === msg.to) {
|
||||
logVerbose(`📱 Same-phone mode detected (from === to: ${msg.from})`);
|
||||
}
|
||||
|
||||
// Skip if this is a message we just sent (echo detection)
|
||||
if (params.echoTracker.has(msg.body)) {
|
||||
logVerbose(
|
||||
"Skipping auto-reply: detected echo (message matches recently sent text)",
|
||||
);
|
||||
params.echoTracker.forget(msg.body);
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.chatType === "group") {
|
||||
updateLastRouteInBackground({
|
||||
cfg: params.cfg,
|
||||
backgroundTasks: params.backgroundTasks,
|
||||
storeAgentId: route.agentId,
|
||||
sessionKey: route.sessionKey,
|
||||
channel: "whatsapp",
|
||||
to: conversationId,
|
||||
accountId: route.accountId,
|
||||
warn: params.replyLogger.warn.bind(params.replyLogger),
|
||||
});
|
||||
|
||||
const gating = applyGroupGating({
|
||||
cfg: params.cfg,
|
||||
msg,
|
||||
conversationId,
|
||||
groupHistoryKey,
|
||||
agentId: route.agentId,
|
||||
sessionKey: route.sessionKey,
|
||||
baseMentionConfig: params.baseMentionConfig,
|
||||
authDir: params.account.authDir,
|
||||
groupHistories: params.groupHistories,
|
||||
groupHistoryLimit: params.groupHistoryLimit,
|
||||
groupMemberNames: params.groupMemberNames,
|
||||
logVerbose,
|
||||
replyLogger: params.replyLogger,
|
||||
});
|
||||
if (!gating.shouldProcess) return;
|
||||
} else {
|
||||
// Ensure `peerId` for DMs is stable and stored as E.164 when possible.
|
||||
if (!msg.senderE164 && peerId && peerId.startsWith("+")) {
|
||||
msg.senderE164 = normalizeE164(peerId) ?? msg.senderE164;
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcast groups: when we'd reply anyway, run multiple agents.
|
||||
// Does not bypass group mention/activation gating above.
|
||||
if (
|
||||
await maybeBroadcastMessage({
|
||||
cfg: params.cfg,
|
||||
msg,
|
||||
peerId,
|
||||
route,
|
||||
groupHistoryKey,
|
||||
groupHistories: params.groupHistories,
|
||||
processMessage: processForRoute,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
await processForRoute(msg, route, groupHistoryKey);
|
||||
};
|
||||
}
|
||||
9
src/web/auto-reply/monitor/peer.ts
Normal file
9
src/web/auto-reply/monitor/peer.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { jidToE164, normalizeE164 } from "../../../utils.js";
|
||||
import type { WebInboundMsg } from "../types.js";
|
||||
|
||||
export function resolvePeerId(msg: WebInboundMsg) {
|
||||
if (msg.chatType === "group") return msg.conversationId ?? msg.from;
|
||||
if (msg.senderE164) return normalizeE164(msg.senderE164) ?? msg.senderE164;
|
||||
if (msg.from.includes("@")) return jidToE164(msg.from) ?? msg.from;
|
||||
return normalizeE164(msg.from) ?? msg.from;
|
||||
}
|
||||
308
src/web/auto-reply/monitor/process-message.ts
Normal file
308
src/web/auto-reply/monitor/process-message.ts
Normal file
@@ -0,0 +1,308 @@
|
||||
import { resolveEffectiveMessagesConfig } from "../../../agents/identity.js";
|
||||
import { resolveTextChunkLimit } from "../../../auto-reply/chunk.js";
|
||||
import { formatAgentEnvelope } from "../../../auto-reply/envelope.js";
|
||||
import { buildHistoryContext } from "../../../auto-reply/reply/history.js";
|
||||
import { dispatchReplyWithBufferedBlockDispatcher } from "../../../auto-reply/reply/provider-dispatcher.js";
|
||||
import type { getReplyFromConfig } from "../../../auto-reply/reply.js";
|
||||
import type { ReplyPayload } from "../../../auto-reply/types.js";
|
||||
import { toLocationContext } from "../../../channels/location.js";
|
||||
import type { loadConfig } from "../../../config/config.js";
|
||||
import { logVerbose, shouldLogVerbose } from "../../../globals.js";
|
||||
import type { getChildLogger } from "../../../logging.js";
|
||||
import type { resolveAgentRoute } from "../../../routing/resolve-route.js";
|
||||
import { jidToE164, normalizeE164 } from "../../../utils.js";
|
||||
import { newConnectionId } from "../../reconnect.js";
|
||||
import { formatError } from "../../session.js";
|
||||
import { deliverWebReply } from "../deliver-reply.js";
|
||||
import { whatsappInboundLog, whatsappOutboundLog } from "../loggers.js";
|
||||
import type { WebInboundMsg } from "../types.js";
|
||||
import { elide } from "../util.js";
|
||||
import { maybeSendAckReaction } from "./ack-reaction.js";
|
||||
import { formatGroupMembers } from "./group-members.js";
|
||||
import { updateLastRouteInBackground } from "./last-route.js";
|
||||
import { buildInboundLine } from "./message-line.js";
|
||||
|
||||
export type GroupHistoryEntry = {
|
||||
sender: string;
|
||||
body: string;
|
||||
timestamp?: number;
|
||||
id?: string;
|
||||
senderJid?: string;
|
||||
};
|
||||
|
||||
export async function processMessage(params: {
|
||||
cfg: ReturnType<typeof loadConfig>;
|
||||
msg: WebInboundMsg;
|
||||
route: ReturnType<typeof resolveAgentRoute>;
|
||||
groupHistoryKey: string;
|
||||
groupHistories: Map<string, GroupHistoryEntry[]>;
|
||||
groupMemberNames: Map<string, Map<string, string>>;
|
||||
connectionId: string;
|
||||
verbose: boolean;
|
||||
maxMediaBytes: number;
|
||||
replyResolver: typeof getReplyFromConfig;
|
||||
replyLogger: ReturnType<typeof getChildLogger>;
|
||||
backgroundTasks: Set<Promise<unknown>>;
|
||||
rememberSentText: (
|
||||
text: string | undefined,
|
||||
opts: {
|
||||
combinedBody?: string;
|
||||
combinedBodySessionKey?: string;
|
||||
logVerboseMessage?: boolean;
|
||||
},
|
||||
) => void;
|
||||
echoHas: (key: string) => boolean;
|
||||
echoForget: (key: string) => void;
|
||||
buildCombinedEchoKey: (p: {
|
||||
sessionKey: string;
|
||||
combinedBody: string;
|
||||
}) => string;
|
||||
maxMediaTextChunkLimit?: number;
|
||||
groupHistory?: GroupHistoryEntry[];
|
||||
suppressGroupHistoryClear?: boolean;
|
||||
}) {
|
||||
const conversationId = params.msg.conversationId ?? params.msg.from;
|
||||
let combinedBody = buildInboundLine({
|
||||
cfg: params.cfg,
|
||||
msg: params.msg,
|
||||
agentId: params.route.agentId,
|
||||
});
|
||||
let shouldClearGroupHistory = false;
|
||||
|
||||
if (params.msg.chatType === "group") {
|
||||
const history =
|
||||
params.groupHistory ??
|
||||
params.groupHistories.get(params.groupHistoryKey) ??
|
||||
[];
|
||||
const historyWithoutCurrent =
|
||||
history.length > 0 ? history.slice(0, -1) : [];
|
||||
if (historyWithoutCurrent.length > 0) {
|
||||
const lineBreak = "\\n";
|
||||
const historyText = historyWithoutCurrent
|
||||
.map((m) => {
|
||||
const bodyWithId = m.id ? `${m.body}\n[message_id: ${m.id}]` : m.body;
|
||||
return formatAgentEnvelope({
|
||||
channel: "WhatsApp",
|
||||
from: conversationId,
|
||||
timestamp: m.timestamp,
|
||||
body: `${m.sender}: ${bodyWithId}`,
|
||||
});
|
||||
})
|
||||
.join(lineBreak);
|
||||
combinedBody = buildHistoryContext({
|
||||
historyText,
|
||||
currentMessage: combinedBody,
|
||||
lineBreak,
|
||||
});
|
||||
}
|
||||
// Always surface who sent the triggering message so the agent can address them.
|
||||
const senderLabel =
|
||||
params.msg.senderName && params.msg.senderE164
|
||||
? `${params.msg.senderName} (${params.msg.senderE164})`
|
||||
: (params.msg.senderName ?? params.msg.senderE164 ?? "Unknown");
|
||||
combinedBody = `${combinedBody}\\n[from: ${senderLabel}]`;
|
||||
shouldClearGroupHistory = !(params.suppressGroupHistoryClear ?? false);
|
||||
}
|
||||
|
||||
// Echo detection uses combined body so we don't respond twice.
|
||||
const combinedEchoKey = params.buildCombinedEchoKey({
|
||||
sessionKey: params.route.sessionKey,
|
||||
combinedBody,
|
||||
});
|
||||
if (params.echoHas(combinedEchoKey)) {
|
||||
logVerbose("Skipping auto-reply: detected echo for combined message");
|
||||
params.echoForget(combinedEchoKey);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Send ack reaction immediately upon message receipt (post-gating)
|
||||
maybeSendAckReaction({
|
||||
cfg: params.cfg,
|
||||
msg: params.msg,
|
||||
agentId: params.route.agentId,
|
||||
sessionKey: params.route.sessionKey,
|
||||
conversationId,
|
||||
verbose: params.verbose,
|
||||
accountId: params.route.accountId,
|
||||
info: params.replyLogger.info.bind(params.replyLogger),
|
||||
warn: params.replyLogger.warn.bind(params.replyLogger),
|
||||
});
|
||||
|
||||
const correlationId = params.msg.id ?? newConnectionId();
|
||||
params.replyLogger.info(
|
||||
{
|
||||
connectionId: params.connectionId,
|
||||
correlationId,
|
||||
from: params.msg.chatType === "group" ? conversationId : params.msg.from,
|
||||
to: params.msg.to,
|
||||
body: elide(combinedBody, 240),
|
||||
mediaType: params.msg.mediaType ?? null,
|
||||
mediaPath: params.msg.mediaPath ?? null,
|
||||
},
|
||||
"inbound web message",
|
||||
);
|
||||
|
||||
const fromDisplay =
|
||||
params.msg.chatType === "group" ? conversationId : params.msg.from;
|
||||
const kindLabel = params.msg.mediaType ? `, ${params.msg.mediaType}` : "";
|
||||
whatsappInboundLog.info(
|
||||
`Inbound message ${fromDisplay} -> ${params.msg.to} (${params.msg.chatType}${kindLabel}, ${combinedBody.length} chars)`,
|
||||
);
|
||||
if (shouldLogVerbose()) {
|
||||
whatsappInboundLog.debug(`Inbound body: ${elide(combinedBody, 400)}`);
|
||||
}
|
||||
|
||||
if (params.msg.chatType !== "group") {
|
||||
const to = (() => {
|
||||
if (params.msg.senderE164) return normalizeE164(params.msg.senderE164);
|
||||
// In direct chats, `msg.from` is already the canonical conversation id.
|
||||
if (params.msg.from.includes("@")) return jidToE164(params.msg.from);
|
||||
return normalizeE164(params.msg.from);
|
||||
})();
|
||||
if (to) {
|
||||
updateLastRouteInBackground({
|
||||
cfg: params.cfg,
|
||||
backgroundTasks: params.backgroundTasks,
|
||||
storeAgentId: params.route.agentId,
|
||||
sessionKey: params.route.mainSessionKey,
|
||||
channel: "whatsapp",
|
||||
to,
|
||||
accountId: params.route.accountId,
|
||||
warn: params.replyLogger.warn.bind(params.replyLogger),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const textLimit =
|
||||
params.maxMediaTextChunkLimit ??
|
||||
resolveTextChunkLimit(params.cfg, "whatsapp");
|
||||
let didLogHeartbeatStrip = false;
|
||||
let didSendReply = false;
|
||||
const responsePrefix = resolveEffectiveMessagesConfig(
|
||||
params.cfg,
|
||||
params.route.agentId,
|
||||
).responsePrefix;
|
||||
|
||||
const { queuedFinal } = await dispatchReplyWithBufferedBlockDispatcher({
|
||||
ctx: {
|
||||
Body: combinedBody,
|
||||
RawBody: params.msg.body,
|
||||
CommandBody: params.msg.body,
|
||||
From: params.msg.from,
|
||||
To: params.msg.to,
|
||||
SessionKey: params.route.sessionKey,
|
||||
AccountId: params.route.accountId,
|
||||
MessageSid: params.msg.id,
|
||||
ReplyToId: params.msg.replyToId,
|
||||
ReplyToBody: params.msg.replyToBody,
|
||||
ReplyToSender: params.msg.replyToSender,
|
||||
MediaPath: params.msg.mediaPath,
|
||||
MediaUrl: params.msg.mediaUrl,
|
||||
MediaType: params.msg.mediaType,
|
||||
ChatType: params.msg.chatType,
|
||||
GroupSubject: params.msg.groupSubject,
|
||||
GroupMembers: formatGroupMembers({
|
||||
participants: params.msg.groupParticipants,
|
||||
roster: params.groupMemberNames.get(params.groupHistoryKey),
|
||||
fallbackE164: params.msg.senderE164,
|
||||
}),
|
||||
SenderName: params.msg.senderName,
|
||||
SenderId: params.msg.senderJid?.trim() || params.msg.senderE164,
|
||||
SenderE164: params.msg.senderE164,
|
||||
WasMentioned: params.msg.wasMentioned,
|
||||
...(params.msg.location ? toLocationContext(params.msg.location) : {}),
|
||||
Provider: "whatsapp",
|
||||
Surface: "whatsapp",
|
||||
OriginatingChannel: "whatsapp",
|
||||
OriginatingTo: params.msg.from,
|
||||
},
|
||||
cfg: params.cfg,
|
||||
replyResolver: params.replyResolver,
|
||||
dispatcherOptions: {
|
||||
responsePrefix,
|
||||
onHeartbeatStrip: () => {
|
||||
if (!didLogHeartbeatStrip) {
|
||||
didLogHeartbeatStrip = true;
|
||||
logVerbose("Stripped stray HEARTBEAT_OK token from web reply");
|
||||
}
|
||||
},
|
||||
deliver: async (payload: ReplyPayload, info) => {
|
||||
await deliverWebReply({
|
||||
replyResult: payload,
|
||||
msg: params.msg,
|
||||
maxMediaBytes: params.maxMediaBytes,
|
||||
textLimit,
|
||||
replyLogger: params.replyLogger,
|
||||
connectionId: params.connectionId,
|
||||
// Tool + block updates are noisy; skip their log lines.
|
||||
skipLog: info.kind !== "final",
|
||||
});
|
||||
didSendReply = true;
|
||||
if (info.kind === "tool") {
|
||||
params.rememberSentText(payload.text, {});
|
||||
return;
|
||||
}
|
||||
const shouldLog =
|
||||
info.kind === "final" && payload.text ? true : undefined;
|
||||
params.rememberSentText(payload.text, {
|
||||
combinedBody,
|
||||
combinedBodySessionKey: params.route.sessionKey,
|
||||
logVerboseMessage: shouldLog,
|
||||
});
|
||||
if (info.kind === "final") {
|
||||
const fromDisplay =
|
||||
params.msg.chatType === "group"
|
||||
? conversationId
|
||||
: (params.msg.from ?? "unknown");
|
||||
const hasMedia = Boolean(
|
||||
payload.mediaUrl || payload.mediaUrls?.length,
|
||||
);
|
||||
whatsappOutboundLog.info(
|
||||
`Auto-replied to ${fromDisplay}${hasMedia ? " (media)" : ""}`,
|
||||
);
|
||||
if (shouldLogVerbose()) {
|
||||
const preview =
|
||||
payload.text != null ? elide(payload.text, 400) : "<media>";
|
||||
whatsappOutboundLog.debug(
|
||||
`Reply body: ${preview}${hasMedia ? " (media)" : ""}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
onError: (err, info) => {
|
||||
const label =
|
||||
info.kind === "tool"
|
||||
? "tool update"
|
||||
: info.kind === "block"
|
||||
? "block update"
|
||||
: "auto-reply";
|
||||
whatsappOutboundLog.error(
|
||||
`Failed sending web ${label} to ${params.msg.from ?? conversationId}: ${formatError(err)}`,
|
||||
);
|
||||
},
|
||||
onReplyStart: params.msg.sendComposing,
|
||||
},
|
||||
replyOptions: {
|
||||
disableBlockStreaming:
|
||||
typeof params.cfg.channels?.whatsapp?.blockStreaming === "boolean"
|
||||
? !params.cfg.channels.whatsapp.blockStreaming
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
if (!queuedFinal) {
|
||||
if (shouldClearGroupHistory && didSendReply) {
|
||||
params.groupHistories.set(params.groupHistoryKey, []);
|
||||
}
|
||||
logVerbose(
|
||||
"Skipping auto-reply: silent token or no text/media returned from resolver",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (shouldClearGroupHistory && didSendReply) {
|
||||
params.groupHistories.set(params.groupHistoryKey, []);
|
||||
}
|
||||
|
||||
return didSendReply;
|
||||
}
|
||||
34
src/web/auto-reply/session-snapshot.ts
Normal file
34
src/web/auto-reply/session-snapshot.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { loadConfig } from "../../config/config.js";
|
||||
import {
|
||||
DEFAULT_IDLE_MINUTES,
|
||||
loadSessionStore,
|
||||
resolveSessionKey,
|
||||
resolveStorePath,
|
||||
} from "../../config/sessions.js";
|
||||
import { normalizeMainKey } from "../../routing/session-key.js";
|
||||
|
||||
export function getSessionSnapshot(
|
||||
cfg: ReturnType<typeof loadConfig>,
|
||||
from: string,
|
||||
isHeartbeat = false,
|
||||
) {
|
||||
const sessionCfg = cfg.session;
|
||||
const scope = sessionCfg?.scope ?? "per-sender";
|
||||
const key = resolveSessionKey(
|
||||
scope,
|
||||
{ From: from, To: "", Body: "" },
|
||||
normalizeMainKey(sessionCfg?.mainKey),
|
||||
);
|
||||
const store = loadSessionStore(resolveStorePath(sessionCfg?.store));
|
||||
const entry = store[key];
|
||||
const idleMinutes = Math.max(
|
||||
(isHeartbeat
|
||||
? (sessionCfg?.heartbeatIdleMinutes ?? sessionCfg?.idleMinutes)
|
||||
: sessionCfg?.idleMinutes) ?? DEFAULT_IDLE_MINUTES,
|
||||
1,
|
||||
);
|
||||
const fresh = !!(
|
||||
entry && Date.now() - entry.updatedAt <= idleMinutes * 60_000
|
||||
);
|
||||
return { key, entry, fresh, idleMinutes };
|
||||
}
|
||||
33
src/web/auto-reply/types.ts
Normal file
33
src/web/auto-reply/types.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { monitorWebInbox } from "../inbound.js";
|
||||
import type { ReconnectPolicy } from "../reconnect.js";
|
||||
|
||||
export type WebInboundMsg = Parameters<
|
||||
typeof monitorWebInbox
|
||||
>[0]["onMessage"] extends (msg: infer M) => unknown
|
||||
? M
|
||||
: never;
|
||||
|
||||
export type WebChannelStatus = {
|
||||
running: boolean;
|
||||
connected: boolean;
|
||||
reconnectAttempts: number;
|
||||
lastConnectedAt?: number | null;
|
||||
lastDisconnect?: {
|
||||
at: number;
|
||||
status?: number;
|
||||
error?: string;
|
||||
loggedOut?: boolean;
|
||||
} | null;
|
||||
lastMessageAt?: number | null;
|
||||
lastEventAt?: number | null;
|
||||
lastError?: string | null;
|
||||
};
|
||||
|
||||
export type WebMonitorTuning = {
|
||||
reconnect?: Partial<ReconnectPolicy>;
|
||||
heartbeatSeconds?: number;
|
||||
sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
|
||||
statusSink?: (status: WebChannelStatus) => void;
|
||||
/** WhatsApp account id. Default: "default". */
|
||||
accountId?: string;
|
||||
};
|
||||
44
src/web/auto-reply/util.ts
Normal file
44
src/web/auto-reply/util.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
export function elide(text?: string, limit = 400) {
|
||||
if (!text) return text;
|
||||
if (text.length <= limit) return text;
|
||||
return `${text.slice(0, limit)}… (truncated ${text.length - limit} chars)`;
|
||||
}
|
||||
|
||||
export function isLikelyWhatsAppCryptoError(reason: unknown) {
|
||||
const formatReason = (value: unknown): string => {
|
||||
if (value == null) return "";
|
||||
if (typeof value === "string") return value;
|
||||
if (value instanceof Error) {
|
||||
return `${value.message}\n${value.stack ?? ""}`;
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return Object.prototype.toString.call(value);
|
||||
}
|
||||
}
|
||||
if (typeof value === "number") return String(value);
|
||||
if (typeof value === "boolean") return String(value);
|
||||
if (typeof value === "bigint") return String(value);
|
||||
if (typeof value === "symbol") return value.description ?? value.toString();
|
||||
if (typeof value === "function")
|
||||
return value.name ? `[function ${value.name}]` : "[function]";
|
||||
return Object.prototype.toString.call(value);
|
||||
};
|
||||
const raw =
|
||||
reason instanceof Error
|
||||
? `${reason.message}\n${reason.stack ?? ""}`
|
||||
: formatReason(reason);
|
||||
const haystack = raw.toLowerCase();
|
||||
const hasAuthError =
|
||||
haystack.includes("unsupported state or unable to authenticate data") ||
|
||||
haystack.includes("bad mac");
|
||||
if (!hasAuthError) return false;
|
||||
return (
|
||||
haystack.includes("@whiskeysockets/baileys") ||
|
||||
haystack.includes("baileys") ||
|
||||
haystack.includes("noise-handler") ||
|
||||
haystack.includes("aesdecryptgcm")
|
||||
);
|
||||
}
|
||||
@@ -1,986 +1,11 @@
|
||||
import type {
|
||||
AnyMessageContent,
|
||||
proto,
|
||||
WAMessage,
|
||||
} from "@whiskeysockets/baileys";
|
||||
import {
|
||||
DisconnectReason,
|
||||
downloadMediaMessage,
|
||||
extractMessageContent,
|
||||
getContentType,
|
||||
isJidGroup,
|
||||
normalizeMessageContent,
|
||||
} from "@whiskeysockets/baileys";
|
||||
import {
|
||||
formatLocationText,
|
||||
type NormalizedLocation,
|
||||
} from "../channels/location.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { logVerbose, shouldLogVerbose } from "../globals.js";
|
||||
import { recordChannelActivity } from "../infra/channel-activity.js";
|
||||
import { createDedupeCache } from "../infra/dedupe.js";
|
||||
import { createSubsystemLogger, getChildLogger } from "../logging.js";
|
||||
import { saveMediaBuffer } from "../media/store.js";
|
||||
import { buildPairingReply } from "../pairing/pairing-messages.js";
|
||||
import {
|
||||
readChannelAllowFromStore,
|
||||
upsertChannelPairingRequest,
|
||||
} from "../pairing/pairing-store.js";
|
||||
import {
|
||||
isSelfChatMode,
|
||||
jidToE164,
|
||||
normalizeE164,
|
||||
resolveJidToE164,
|
||||
toWhatsappJid,
|
||||
} from "../utils.js";
|
||||
import { resolveWhatsAppAccount } from "./accounts.js";
|
||||
import type { ActiveWebSendOptions } from "./active-listener.js";
|
||||
import {
|
||||
createWaSocket,
|
||||
getStatusCode,
|
||||
waitForWaConnection,
|
||||
} from "./session.js";
|
||||
import { parseVcard } from "./vcard.js";
|
||||
|
||||
export type WebListenerCloseReason = {
|
||||
status?: number;
|
||||
isLoggedOut: boolean;
|
||||
error?: unknown;
|
||||
};
|
||||
|
||||
const RECENT_WEB_MESSAGE_TTL_MS = 20 * 60_000;
|
||||
const RECENT_WEB_MESSAGE_MAX = 5000;
|
||||
const recentInboundMessages = createDedupeCache({
|
||||
ttlMs: RECENT_WEB_MESSAGE_TTL_MS,
|
||||
maxSize: RECENT_WEB_MESSAGE_MAX,
|
||||
});
|
||||
|
||||
export function resetWebInboundDedupe(): void {
|
||||
recentInboundMessages.clear();
|
||||
}
|
||||
|
||||
export type WebInboundMessage = {
|
||||
id?: string;
|
||||
from: string; // conversation id: E.164 for direct chats, group JID for groups
|
||||
conversationId: string; // alias for clarity (same as from)
|
||||
to: string;
|
||||
accountId: string;
|
||||
body: string;
|
||||
pushName?: string;
|
||||
timestamp?: number;
|
||||
chatType: "direct" | "group";
|
||||
chatId: string;
|
||||
senderJid?: string;
|
||||
senderE164?: string;
|
||||
senderName?: string;
|
||||
replyToId?: string;
|
||||
replyToBody?: string;
|
||||
replyToSender?: string;
|
||||
groupSubject?: string;
|
||||
groupParticipants?: string[];
|
||||
mentionedJids?: string[];
|
||||
selfJid?: string | null;
|
||||
selfE164?: string | null;
|
||||
location?: NormalizedLocation;
|
||||
sendComposing: () => Promise<void>;
|
||||
reply: (text: string) => Promise<void>;
|
||||
sendMedia: (payload: AnyMessageContent) => Promise<void>;
|
||||
mediaPath?: string;
|
||||
mediaType?: string;
|
||||
mediaUrl?: string;
|
||||
wasMentioned?: boolean;
|
||||
};
|
||||
|
||||
export async function monitorWebInbox(options: {
|
||||
verbose: boolean;
|
||||
accountId: string;
|
||||
authDir: string;
|
||||
onMessage: (msg: WebInboundMessage) => Promise<void>;
|
||||
mediaMaxMb?: number;
|
||||
}) {
|
||||
const inboundLogger = getChildLogger({ module: "web-inbound" });
|
||||
const inboundConsoleLog = createSubsystemLogger(
|
||||
"gateway/channels/whatsapp",
|
||||
).child("inbound");
|
||||
const sock = await createWaSocket(false, options.verbose, {
|
||||
authDir: options.authDir,
|
||||
});
|
||||
await waitForWaConnection(sock);
|
||||
let onCloseResolve: ((reason: WebListenerCloseReason) => void) | null = null;
|
||||
const onClose = new Promise<WebListenerCloseReason>((resolve) => {
|
||||
onCloseResolve = resolve;
|
||||
});
|
||||
const resolveClose = (reason: WebListenerCloseReason) => {
|
||||
if (!onCloseResolve) return;
|
||||
const resolver = onCloseResolve;
|
||||
onCloseResolve = null;
|
||||
resolver(reason);
|
||||
};
|
||||
try {
|
||||
// Advertise that the gateway is online right after connecting.
|
||||
await sock.sendPresenceUpdate("available");
|
||||
if (shouldLogVerbose())
|
||||
logVerbose("Sent global 'available' presence on connect");
|
||||
} catch (err) {
|
||||
logVerbose(
|
||||
`Failed to send 'available' presence on connect: ${String(err)}`,
|
||||
);
|
||||
}
|
||||
const selfJid = sock.user?.id;
|
||||
const selfE164 = selfJid ? jidToE164(selfJid) : null;
|
||||
const groupMetaCache = new Map<
|
||||
string,
|
||||
{ subject?: string; participants?: string[]; expires: number }
|
||||
>();
|
||||
const GROUP_META_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
||||
const lidLookup = sock.signalRepository?.lidMapping;
|
||||
|
||||
const resolveInboundJid = async (
|
||||
jid: string | null | undefined,
|
||||
): Promise<string | null> =>
|
||||
resolveJidToE164(jid, { authDir: options.authDir, lidLookup });
|
||||
|
||||
const getGroupMeta = async (jid: string) => {
|
||||
const cached = groupMetaCache.get(jid);
|
||||
if (cached && cached.expires > Date.now()) return cached;
|
||||
try {
|
||||
const meta = await sock.groupMetadata(jid);
|
||||
const participants =
|
||||
(
|
||||
await Promise.all(
|
||||
meta.participants?.map(async (p) => {
|
||||
const mapped = await resolveInboundJid(p.id);
|
||||
return mapped ?? p.id;
|
||||
}) ?? [],
|
||||
)
|
||||
).filter(Boolean) ?? [];
|
||||
const entry = {
|
||||
subject: meta.subject,
|
||||
participants,
|
||||
expires: Date.now() + GROUP_META_TTL_MS,
|
||||
};
|
||||
groupMetaCache.set(jid, entry);
|
||||
return entry;
|
||||
} catch (err) {
|
||||
logVerbose(`Failed to fetch group metadata for ${jid}: ${String(err)}`);
|
||||
return { expires: Date.now() + GROUP_META_TTL_MS };
|
||||
}
|
||||
};
|
||||
|
||||
const handleMessagesUpsert = async (upsert: {
|
||||
type?: string;
|
||||
messages?: Array<import("@whiskeysockets/baileys").WAMessage>;
|
||||
}) => {
|
||||
if (upsert.type !== "notify" && upsert.type !== "append") return;
|
||||
for (const msg of upsert.messages ?? []) {
|
||||
recordChannelActivity({
|
||||
channel: "whatsapp",
|
||||
accountId: options.accountId,
|
||||
direction: "inbound",
|
||||
});
|
||||
const id = msg.key?.id ?? undefined;
|
||||
// Note: not filtering fromMe here - echo detection happens in auto-reply layer
|
||||
const remoteJid = msg.key?.remoteJid;
|
||||
if (!remoteJid) continue;
|
||||
// Ignore status/broadcast traffic; we only care about direct chats.
|
||||
if (remoteJid.endsWith("@status") || remoteJid.endsWith("@broadcast"))
|
||||
continue;
|
||||
const group = isJidGroup(remoteJid);
|
||||
if (id) {
|
||||
const dedupeKey = `${options.accountId}:${remoteJid}:${id}`;
|
||||
if (recentInboundMessages.check(dedupeKey)) continue;
|
||||
}
|
||||
const participantJid = msg.key?.participant ?? undefined;
|
||||
const from = group ? remoteJid : await resolveInboundJid(remoteJid);
|
||||
// Skip if we still can't resolve an id to key conversation
|
||||
if (!from) continue;
|
||||
const senderE164 = group
|
||||
? participantJid
|
||||
? await resolveInboundJid(participantJid)
|
||||
: null
|
||||
: from;
|
||||
let groupSubject: string | undefined;
|
||||
let groupParticipants: string[] | undefined;
|
||||
if (group) {
|
||||
const meta = await getGroupMeta(remoteJid);
|
||||
groupSubject = meta.subject;
|
||||
groupParticipants = meta.participants;
|
||||
}
|
||||
|
||||
// Filter unauthorized senders early to prevent wasted processing
|
||||
// and potential session corruption from Bad MAC errors
|
||||
const cfg = loadConfig();
|
||||
const account = resolveWhatsAppAccount({
|
||||
cfg,
|
||||
accountId: options.accountId,
|
||||
});
|
||||
const dmPolicy = cfg.channels?.whatsapp?.dmPolicy ?? "pairing";
|
||||
const configuredAllowFrom = account.allowFrom;
|
||||
const storeAllowFrom = await readChannelAllowFromStore("whatsapp").catch(
|
||||
() => [],
|
||||
);
|
||||
// Without user config, default to self-only DM access so the owner can talk to themselves
|
||||
const combinedAllowFrom = Array.from(
|
||||
new Set([...(configuredAllowFrom ?? []), ...storeAllowFrom]),
|
||||
);
|
||||
const defaultAllowFrom =
|
||||
combinedAllowFrom.length === 0 && selfE164 ? [selfE164] : undefined;
|
||||
const allowFrom =
|
||||
combinedAllowFrom.length > 0 ? combinedAllowFrom : defaultAllowFrom;
|
||||
const groupAllowFrom =
|
||||
account.groupAllowFrom ??
|
||||
(configuredAllowFrom && configuredAllowFrom.length > 0
|
||||
? configuredAllowFrom
|
||||
: undefined);
|
||||
const isSamePhone = from === selfE164;
|
||||
const isSelfChat = isSelfChatMode(selfE164, configuredAllowFrom);
|
||||
const isFromMe = Boolean(msg.key?.fromMe);
|
||||
|
||||
// Pre-compute normalized allowlists for filtering
|
||||
const dmHasWildcard = allowFrom?.includes("*") ?? false;
|
||||
const normalizedAllowFrom =
|
||||
allowFrom && allowFrom.length > 0
|
||||
? allowFrom.filter((entry) => entry !== "*").map(normalizeE164)
|
||||
: [];
|
||||
const groupHasWildcard = groupAllowFrom?.includes("*") ?? false;
|
||||
const normalizedGroupAllowFrom =
|
||||
groupAllowFrom && groupAllowFrom.length > 0
|
||||
? groupAllowFrom.filter((entry) => entry !== "*").map(normalizeE164)
|
||||
: [];
|
||||
|
||||
// Group policy filtering: controls how group messages are handled
|
||||
// - "open": groups bypass allowFrom, only mention-gating applies
|
||||
// - "disabled": block all group messages entirely
|
||||
// - "allowlist": only allow group messages from senders in groupAllowFrom/allowFrom
|
||||
const groupPolicy = account.groupPolicy ?? "open";
|
||||
if (group && groupPolicy === "disabled") {
|
||||
logVerbose(`Blocked group message (groupPolicy: disabled)`);
|
||||
continue;
|
||||
}
|
||||
if (group && groupPolicy === "allowlist") {
|
||||
// For allowlist mode, the sender (participant) must be in allowFrom
|
||||
// If we can't resolve the sender E164, block the message for safety
|
||||
if (!groupAllowFrom || groupAllowFrom.length === 0) {
|
||||
logVerbose(
|
||||
"Blocked group message (groupPolicy: allowlist, no groupAllowFrom)",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const senderAllowed =
|
||||
groupHasWildcard ||
|
||||
(senderE164 != null && normalizedGroupAllowFrom.includes(senderE164));
|
||||
if (!senderAllowed) {
|
||||
logVerbose(
|
||||
`Blocked group message from ${senderE164 ?? "unknown sender"} (groupPolicy: allowlist)`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// DM access control (secure defaults): "pairing" (default) / "allowlist" / "open" / "disabled"
|
||||
if (!group) {
|
||||
if (isFromMe && !isSamePhone) {
|
||||
logVerbose("Skipping outbound DM (fromMe); no pairing reply needed.");
|
||||
continue;
|
||||
}
|
||||
if (dmPolicy === "disabled") {
|
||||
logVerbose("Blocked dm (dmPolicy: disabled)");
|
||||
continue;
|
||||
}
|
||||
if (dmPolicy !== "open" && !isSamePhone) {
|
||||
const candidate = from;
|
||||
const allowed =
|
||||
dmHasWildcard ||
|
||||
(normalizedAllowFrom.length > 0 &&
|
||||
normalizedAllowFrom.includes(candidate));
|
||||
if (!allowed) {
|
||||
if (dmPolicy === "pairing") {
|
||||
const { code, created } = await upsertChannelPairingRequest({
|
||||
channel: "whatsapp",
|
||||
id: candidate,
|
||||
meta: {
|
||||
name: (msg.pushName ?? "").trim() || undefined,
|
||||
},
|
||||
});
|
||||
if (created) {
|
||||
logVerbose(
|
||||
`whatsapp pairing request sender=${candidate} name=${msg.pushName ?? "unknown"}`,
|
||||
);
|
||||
try {
|
||||
await sock.sendMessage(remoteJid, {
|
||||
text: buildPairingReply({
|
||||
channel: "whatsapp",
|
||||
idLine: `Your WhatsApp phone number: ${candidate}`,
|
||||
code,
|
||||
}),
|
||||
});
|
||||
} catch (err) {
|
||||
logVerbose(
|
||||
`whatsapp pairing reply failed for ${candidate}: ${String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logVerbose(
|
||||
`Blocked unauthorized sender ${candidate} (dmPolicy=${dmPolicy})`,
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (id && !isSelfChat) {
|
||||
const participant = msg.key?.participant;
|
||||
try {
|
||||
await sock.readMessages([
|
||||
{ remoteJid, id, participant, fromMe: false },
|
||||
]);
|
||||
if (shouldLogVerbose()) {
|
||||
const suffix = participant ? ` (participant ${participant})` : "";
|
||||
logVerbose(
|
||||
`Marked message ${id} as read for ${remoteJid}${suffix}`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
logVerbose(`Failed to mark message ${id} read: ${String(err)}`);
|
||||
}
|
||||
} else if (id && isSelfChat && shouldLogVerbose()) {
|
||||
// Self-chat mode: never auto-send read receipts (blue ticks) on behalf of the owner.
|
||||
logVerbose(`Self-chat mode: skipping read receipt for ${id}`);
|
||||
}
|
||||
|
||||
// If this is history/offline catch-up, we marked it as read above,
|
||||
// but we skip triggering the auto-reply logic to avoid spamming old context.
|
||||
if (upsert.type === "append") continue;
|
||||
|
||||
const location = extractLocationData(msg.message ?? undefined);
|
||||
const locationText = location ? formatLocationText(location) : undefined;
|
||||
let body = extractText(msg.message ?? undefined);
|
||||
if (locationText) {
|
||||
body = [body, locationText].filter(Boolean).join("\n").trim();
|
||||
}
|
||||
if (!body) {
|
||||
body = extractMediaPlaceholder(msg.message ?? undefined);
|
||||
if (!body) continue;
|
||||
}
|
||||
const replyContext = describeReplyContext(
|
||||
msg.message as proto.IMessage | undefined,
|
||||
);
|
||||
let mediaPath: string | undefined;
|
||||
let mediaType: string | undefined;
|
||||
try {
|
||||
const inboundMedia = await downloadInboundMedia(msg, sock);
|
||||
if (inboundMedia) {
|
||||
const maxMb =
|
||||
typeof options.mediaMaxMb === "number" && options.mediaMaxMb > 0
|
||||
? options.mediaMaxMb
|
||||
: 50;
|
||||
const maxBytes = maxMb * 1024 * 1024;
|
||||
const saved = await saveMediaBuffer(
|
||||
inboundMedia.buffer,
|
||||
inboundMedia.mimetype,
|
||||
"inbound",
|
||||
maxBytes,
|
||||
);
|
||||
mediaPath = saved.path;
|
||||
mediaType = inboundMedia.mimetype;
|
||||
}
|
||||
} catch (err) {
|
||||
logVerbose(`Inbound media download failed: ${String(err)}`);
|
||||
}
|
||||
const chatJid = remoteJid;
|
||||
const sendComposing = async () => {
|
||||
try {
|
||||
await sock.sendPresenceUpdate("composing", chatJid);
|
||||
} catch (err) {
|
||||
logVerbose(`Presence update failed: ${String(err)}`);
|
||||
}
|
||||
};
|
||||
const reply = async (text: string) => {
|
||||
await sock.sendMessage(chatJid, { text });
|
||||
};
|
||||
const sendMedia = async (payload: AnyMessageContent) => {
|
||||
await sock.sendMessage(chatJid, payload);
|
||||
};
|
||||
const timestamp = msg.messageTimestamp
|
||||
? Number(msg.messageTimestamp) * 1000
|
||||
: undefined;
|
||||
const mentionedJids = extractMentionedJids(
|
||||
msg.message as proto.IMessage | undefined,
|
||||
);
|
||||
const senderName = msg.pushName ?? undefined;
|
||||
inboundLogger.info(
|
||||
{
|
||||
from,
|
||||
to: selfE164 ?? "me",
|
||||
body,
|
||||
mediaPath,
|
||||
mediaType,
|
||||
timestamp,
|
||||
},
|
||||
"inbound message",
|
||||
);
|
||||
try {
|
||||
const task = Promise.resolve(
|
||||
options.onMessage({
|
||||
id,
|
||||
from,
|
||||
conversationId: from,
|
||||
to: selfE164 ?? "me",
|
||||
accountId: account.accountId,
|
||||
body,
|
||||
pushName: senderName,
|
||||
timestamp,
|
||||
chatType: group ? "group" : "direct",
|
||||
chatId: remoteJid,
|
||||
senderJid: participantJid,
|
||||
senderE164: senderE164 ?? undefined,
|
||||
senderName,
|
||||
replyToId: replyContext?.id,
|
||||
replyToBody: replyContext?.body,
|
||||
replyToSender: replyContext?.sender,
|
||||
groupSubject,
|
||||
groupParticipants,
|
||||
mentionedJids: mentionedJids ?? undefined,
|
||||
selfJid,
|
||||
selfE164,
|
||||
location: location ?? undefined,
|
||||
sendComposing,
|
||||
reply,
|
||||
sendMedia,
|
||||
mediaPath,
|
||||
mediaType,
|
||||
}),
|
||||
);
|
||||
void task.catch((err) => {
|
||||
inboundLogger.error(
|
||||
{ error: String(err) },
|
||||
"failed handling inbound web message",
|
||||
);
|
||||
inboundConsoleLog.error(
|
||||
`Failed handling inbound web message: ${String(err)}`,
|
||||
);
|
||||
});
|
||||
} catch (err) {
|
||||
inboundLogger.error(
|
||||
{ error: String(err) },
|
||||
"failed handling inbound web message",
|
||||
);
|
||||
inboundConsoleLog.error(
|
||||
`Failed handling inbound web message: ${String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
sock.ev.on("messages.upsert", handleMessagesUpsert);
|
||||
|
||||
const handleConnectionUpdate = (
|
||||
update: Partial<import("@whiskeysockets/baileys").ConnectionState>,
|
||||
) => {
|
||||
try {
|
||||
if (update.connection === "close") {
|
||||
const status = getStatusCode(update.lastDisconnect?.error);
|
||||
resolveClose({
|
||||
status,
|
||||
isLoggedOut: status === DisconnectReason.loggedOut,
|
||||
error: update.lastDisconnect?.error,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
inboundLogger.error(
|
||||
{ error: String(err) },
|
||||
"connection.update handler error",
|
||||
);
|
||||
resolveClose({
|
||||
status: undefined,
|
||||
isLoggedOut: false,
|
||||
error: err,
|
||||
});
|
||||
}
|
||||
};
|
||||
sock.ev.on("connection.update", handleConnectionUpdate);
|
||||
|
||||
return {
|
||||
close: async () => {
|
||||
try {
|
||||
const ev = sock.ev as unknown as {
|
||||
off?: (event: string, listener: (...args: unknown[]) => void) => void;
|
||||
removeListener?: (
|
||||
event: string,
|
||||
listener: (...args: unknown[]) => void,
|
||||
) => void;
|
||||
};
|
||||
const messagesUpsertHandler = handleMessagesUpsert as unknown as (
|
||||
...args: unknown[]
|
||||
) => void;
|
||||
const connectionUpdateHandler = handleConnectionUpdate as unknown as (
|
||||
...args: unknown[]
|
||||
) => void;
|
||||
if (typeof ev.off === "function") {
|
||||
ev.off("messages.upsert", messagesUpsertHandler);
|
||||
ev.off("connection.update", connectionUpdateHandler);
|
||||
} else if (typeof ev.removeListener === "function") {
|
||||
ev.removeListener("messages.upsert", messagesUpsertHandler);
|
||||
ev.removeListener("connection.update", connectionUpdateHandler);
|
||||
}
|
||||
sock.ws?.close();
|
||||
} catch (err) {
|
||||
logVerbose(`Socket close failed: ${String(err)}`);
|
||||
}
|
||||
},
|
||||
onClose,
|
||||
signalClose: (reason?: WebListenerCloseReason) => {
|
||||
resolveClose(
|
||||
reason ?? { status: undefined, isLoggedOut: false, error: "closed" },
|
||||
);
|
||||
},
|
||||
/**
|
||||
* Send a message through this connection's socket.
|
||||
* Used by IPC to avoid creating new connections.
|
||||
*/
|
||||
sendMessage: async (
|
||||
to: string,
|
||||
text: string,
|
||||
mediaBuffer?: Buffer,
|
||||
mediaType?: string,
|
||||
sendOptions?: ActiveWebSendOptions,
|
||||
): Promise<{ messageId: string }> => {
|
||||
const jid = toWhatsappJid(to);
|
||||
let payload: AnyMessageContent;
|
||||
if (mediaBuffer && mediaType) {
|
||||
if (mediaType.startsWith("image/")) {
|
||||
payload = {
|
||||
image: mediaBuffer,
|
||||
caption: text || undefined,
|
||||
mimetype: mediaType,
|
||||
};
|
||||
} else if (mediaType.startsWith("audio/")) {
|
||||
payload = {
|
||||
audio: mediaBuffer,
|
||||
ptt: true,
|
||||
mimetype: mediaType,
|
||||
};
|
||||
} else if (mediaType.startsWith("video/")) {
|
||||
const gifPlayback = sendOptions?.gifPlayback;
|
||||
payload = {
|
||||
video: mediaBuffer,
|
||||
caption: text || undefined,
|
||||
mimetype: mediaType,
|
||||
...(gifPlayback ? { gifPlayback: true } : {}),
|
||||
};
|
||||
} else {
|
||||
payload = {
|
||||
document: mediaBuffer,
|
||||
fileName: "file",
|
||||
caption: text || undefined,
|
||||
mimetype: mediaType,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
payload = { text };
|
||||
}
|
||||
const result = await sock.sendMessage(jid, payload);
|
||||
const accountId = sendOptions?.accountId ?? options.accountId;
|
||||
recordChannelActivity({
|
||||
channel: "whatsapp",
|
||||
accountId,
|
||||
direction: "outbound",
|
||||
});
|
||||
return { messageId: result?.key?.id ?? "unknown" };
|
||||
},
|
||||
/**
|
||||
* Send a poll message through this connection's socket.
|
||||
* Used by IPC to create WhatsApp polls in groups or chats.
|
||||
*/
|
||||
sendPoll: async (
|
||||
to: string,
|
||||
poll: { question: string; options: string[]; maxSelections?: number },
|
||||
): Promise<{ messageId: string }> => {
|
||||
const jid = toWhatsappJid(to);
|
||||
const result = await sock.sendMessage(jid, {
|
||||
poll: {
|
||||
name: poll.question,
|
||||
values: poll.options,
|
||||
selectableCount: poll.maxSelections ?? 1,
|
||||
},
|
||||
});
|
||||
recordChannelActivity({
|
||||
channel: "whatsapp",
|
||||
accountId: options.accountId,
|
||||
direction: "outbound",
|
||||
});
|
||||
return { messageId: result?.key?.id ?? "unknown" };
|
||||
},
|
||||
/**
|
||||
* Send a reaction (emoji) to a specific message.
|
||||
* Pass an empty string for emoji to remove the reaction.
|
||||
*/
|
||||
sendReaction: async (
|
||||
chatJid: string,
|
||||
messageId: string,
|
||||
emoji: string,
|
||||
fromMe: boolean,
|
||||
participant?: string,
|
||||
): Promise<void> => {
|
||||
const jid = toWhatsappJid(chatJid);
|
||||
await sock.sendMessage(jid, {
|
||||
react: {
|
||||
text: emoji,
|
||||
key: {
|
||||
remoteJid: jid,
|
||||
id: messageId,
|
||||
fromMe,
|
||||
participant: participant ? toWhatsappJid(participant) : undefined,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Send typing indicator ("composing") to a chat.
|
||||
* Used after IPC send to show more messages are coming.
|
||||
*/
|
||||
sendComposingTo: async (to: string): Promise<void> => {
|
||||
const jid = toWhatsappJid(to);
|
||||
await sock.sendPresenceUpdate("composing", jid);
|
||||
},
|
||||
} as const;
|
||||
}
|
||||
|
||||
function unwrapMessage(
|
||||
message: proto.IMessage | undefined,
|
||||
): proto.IMessage | undefined {
|
||||
const normalized = normalizeMessageContent(
|
||||
message as proto.IMessage | undefined,
|
||||
);
|
||||
return normalized as proto.IMessage | undefined;
|
||||
}
|
||||
|
||||
function extractContextInfo(
|
||||
message: proto.IMessage | undefined,
|
||||
): proto.IContextInfo | undefined {
|
||||
if (!message) return undefined;
|
||||
const contentType = getContentType(message);
|
||||
const candidate = contentType
|
||||
? (message as Record<string, unknown>)[contentType]
|
||||
: undefined;
|
||||
const contextInfo =
|
||||
candidate && typeof candidate === "object" && "contextInfo" in candidate
|
||||
? (candidate as { contextInfo?: proto.IContextInfo }).contextInfo
|
||||
: undefined;
|
||||
if (contextInfo) return contextInfo;
|
||||
const fallback =
|
||||
message.extendedTextMessage?.contextInfo ??
|
||||
message.imageMessage?.contextInfo ??
|
||||
message.videoMessage?.contextInfo ??
|
||||
message.documentMessage?.contextInfo ??
|
||||
message.audioMessage?.contextInfo ??
|
||||
message.stickerMessage?.contextInfo ??
|
||||
message.buttonsResponseMessage?.contextInfo ??
|
||||
message.listResponseMessage?.contextInfo ??
|
||||
message.templateButtonReplyMessage?.contextInfo ??
|
||||
message.interactiveResponseMessage?.contextInfo ??
|
||||
message.buttonsMessage?.contextInfo ??
|
||||
message.listMessage?.contextInfo;
|
||||
if (fallback) return fallback;
|
||||
for (const value of Object.values(message)) {
|
||||
if (!value || typeof value !== "object") continue;
|
||||
if (!("contextInfo" in value)) continue;
|
||||
const candidateContext = (value as { contextInfo?: proto.IContextInfo })
|
||||
.contextInfo;
|
||||
if (candidateContext) return candidateContext;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function extractMentionedJids(
|
||||
rawMessage: proto.IMessage | undefined,
|
||||
): string[] | undefined {
|
||||
const message = unwrapMessage(rawMessage);
|
||||
if (!message) return undefined;
|
||||
|
||||
const candidates: Array<string[] | null | undefined> = [
|
||||
message.extendedTextMessage?.contextInfo?.mentionedJid,
|
||||
message.extendedTextMessage?.contextInfo?.quotedMessage?.extendedTextMessage
|
||||
?.contextInfo?.mentionedJid,
|
||||
message.imageMessage?.contextInfo?.mentionedJid,
|
||||
message.videoMessage?.contextInfo?.mentionedJid,
|
||||
message.documentMessage?.contextInfo?.mentionedJid,
|
||||
message.audioMessage?.contextInfo?.mentionedJid,
|
||||
message.stickerMessage?.contextInfo?.mentionedJid,
|
||||
message.buttonsResponseMessage?.contextInfo?.mentionedJid,
|
||||
message.listResponseMessage?.contextInfo?.mentionedJid,
|
||||
];
|
||||
|
||||
const flattened = candidates.flatMap((arr) => arr ?? []).filter(Boolean);
|
||||
if (flattened.length === 0) return undefined;
|
||||
// De-dupe
|
||||
return Array.from(new Set(flattened));
|
||||
}
|
||||
|
||||
export function extractText(
|
||||
rawMessage: proto.IMessage | undefined,
|
||||
): string | undefined {
|
||||
const message = unwrapMessage(rawMessage);
|
||||
if (!message) return undefined;
|
||||
const extracted = extractMessageContent(message);
|
||||
const candidates = [
|
||||
message,
|
||||
extracted && extracted !== message ? extracted : undefined,
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate) continue;
|
||||
if (
|
||||
typeof candidate.conversation === "string" &&
|
||||
candidate.conversation.trim()
|
||||
) {
|
||||
return candidate.conversation.trim();
|
||||
}
|
||||
const extended = candidate.extendedTextMessage?.text;
|
||||
if (extended?.trim()) return extended.trim();
|
||||
const caption =
|
||||
candidate.imageMessage?.caption ??
|
||||
candidate.videoMessage?.caption ??
|
||||
candidate.documentMessage?.caption;
|
||||
if (caption?.trim()) return caption.trim();
|
||||
}
|
||||
const contactPlaceholder =
|
||||
extractContactPlaceholder(message) ??
|
||||
(extracted && extracted !== message
|
||||
? extractContactPlaceholder(extracted as proto.IMessage | undefined)
|
||||
: undefined);
|
||||
if (contactPlaceholder) return contactPlaceholder;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function extractMediaPlaceholder(
|
||||
rawMessage: proto.IMessage | undefined,
|
||||
): string | undefined {
|
||||
const message = unwrapMessage(rawMessage);
|
||||
if (!message) return undefined;
|
||||
if (message.imageMessage) return "<media:image>";
|
||||
if (message.videoMessage) return "<media:video>";
|
||||
if (message.audioMessage) return "<media:audio>";
|
||||
if (message.documentMessage) return "<media:document>";
|
||||
if (message.stickerMessage) return "<media:sticker>";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function extractContactPlaceholder(
|
||||
rawMessage: proto.IMessage | undefined,
|
||||
): string | undefined {
|
||||
const message = unwrapMessage(rawMessage);
|
||||
if (!message) return undefined;
|
||||
const contact = message.contactMessage ?? undefined;
|
||||
if (contact) {
|
||||
const { name, phones } = describeContact({
|
||||
displayName: contact.displayName,
|
||||
vcard: contact.vcard,
|
||||
});
|
||||
return formatContactPlaceholder(name, phones);
|
||||
}
|
||||
const contactsArray = message.contactsArrayMessage?.contacts ?? undefined;
|
||||
if (!contactsArray || contactsArray.length === 0) return undefined;
|
||||
const labels = contactsArray
|
||||
.map((entry) =>
|
||||
describeContact({ displayName: entry.displayName, vcard: entry.vcard }),
|
||||
)
|
||||
.map((entry) => formatContactLabel(entry.name, entry.phones))
|
||||
.filter((value): value is string => Boolean(value));
|
||||
return formatContactsPlaceholder(labels, contactsArray.length);
|
||||
}
|
||||
|
||||
function describeContact(input: {
|
||||
displayName?: string | null;
|
||||
vcard?: string | null;
|
||||
}): { name?: string; phones: string[] } {
|
||||
const displayName = (input.displayName ?? "").trim();
|
||||
const parsed = parseVcard(input.vcard ?? undefined);
|
||||
const name = displayName || parsed.name;
|
||||
return { name, phones: parsed.phones };
|
||||
}
|
||||
|
||||
function formatContactPlaceholder(name?: string, phones?: string[]): string {
|
||||
const label = formatContactLabel(name, phones);
|
||||
if (!label) return "<contact>";
|
||||
return `<contact: ${label}>`;
|
||||
}
|
||||
|
||||
function formatContactsPlaceholder(labels: string[], total: number): string {
|
||||
const cleaned = labels.map((label) => label.trim()).filter(Boolean);
|
||||
if (cleaned.length === 0) {
|
||||
const suffix = total === 1 ? "contact" : "contacts";
|
||||
return `<contacts: ${total} ${suffix}>`;
|
||||
}
|
||||
const remaining = Math.max(total - cleaned.length, 0);
|
||||
const suffix = remaining > 0 ? ` +${remaining} more` : "";
|
||||
return `<contacts: ${cleaned.join(", ")}${suffix}>`;
|
||||
}
|
||||
|
||||
function formatContactLabel(
|
||||
name?: string,
|
||||
phones?: string[],
|
||||
): string | undefined {
|
||||
const phoneLabel = formatPhoneList(phones);
|
||||
const parts = [name, phoneLabel].filter((value): value is string =>
|
||||
Boolean(value),
|
||||
);
|
||||
if (parts.length === 0) return undefined;
|
||||
return parts.join(", ");
|
||||
}
|
||||
|
||||
function formatPhoneList(phones?: string[]): string | undefined {
|
||||
const cleaned = phones?.map((phone) => phone.trim()).filter(Boolean) ?? [];
|
||||
if (cleaned.length === 0) return undefined;
|
||||
const { shown, remaining } = summarizeList(cleaned, cleaned.length, 1);
|
||||
const [primary] = shown;
|
||||
if (!primary) return undefined;
|
||||
if (remaining === 0) return primary;
|
||||
return `${primary} (+${remaining} more)`;
|
||||
}
|
||||
|
||||
function summarizeList(
|
||||
values: string[],
|
||||
total: number,
|
||||
maxShown: number,
|
||||
): { shown: string[]; remaining: number } {
|
||||
const shown = values.slice(0, maxShown);
|
||||
const remaining = Math.max(total - shown.length, 0);
|
||||
return { shown, remaining };
|
||||
}
|
||||
|
||||
export function extractLocationData(
|
||||
rawMessage: proto.IMessage | undefined,
|
||||
): NormalizedLocation | null {
|
||||
const message = unwrapMessage(rawMessage);
|
||||
if (!message) return null;
|
||||
|
||||
const live = message.liveLocationMessage ?? undefined;
|
||||
if (live) {
|
||||
const latitudeRaw = live.degreesLatitude;
|
||||
const longitudeRaw = live.degreesLongitude;
|
||||
if (latitudeRaw != null && longitudeRaw != null) {
|
||||
const latitude = Number(latitudeRaw);
|
||||
const longitude = Number(longitudeRaw);
|
||||
if (Number.isFinite(latitude) && Number.isFinite(longitude)) {
|
||||
return {
|
||||
latitude,
|
||||
longitude,
|
||||
accuracy: live.accuracyInMeters ?? undefined,
|
||||
caption: live.caption ?? undefined,
|
||||
source: "live",
|
||||
isLive: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const location = message.locationMessage ?? undefined;
|
||||
if (location) {
|
||||
const latitudeRaw = location.degreesLatitude;
|
||||
const longitudeRaw = location.degreesLongitude;
|
||||
if (latitudeRaw != null && longitudeRaw != null) {
|
||||
const latitude = Number(latitudeRaw);
|
||||
const longitude = Number(longitudeRaw);
|
||||
if (Number.isFinite(latitude) && Number.isFinite(longitude)) {
|
||||
const isLive = Boolean(location.isLive);
|
||||
return {
|
||||
latitude,
|
||||
longitude,
|
||||
accuracy: location.accuracyInMeters ?? undefined,
|
||||
name: location.name ?? undefined,
|
||||
address: location.address ?? undefined,
|
||||
caption: location.comment ?? undefined,
|
||||
source: isLive
|
||||
? "live"
|
||||
: location.name || location.address
|
||||
? "place"
|
||||
: "pin",
|
||||
isLive,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function describeReplyContext(rawMessage: proto.IMessage | undefined): {
|
||||
id?: string;
|
||||
body: string;
|
||||
sender: string;
|
||||
} | null {
|
||||
const message = unwrapMessage(rawMessage);
|
||||
if (!message) return null;
|
||||
const contextInfo = extractContextInfo(message);
|
||||
const quoted = normalizeMessageContent(
|
||||
contextInfo?.quotedMessage as proto.IMessage | undefined,
|
||||
) as proto.IMessage | undefined;
|
||||
if (!quoted) return null;
|
||||
const location = extractLocationData(quoted);
|
||||
const locationText = location ? formatLocationText(location) : undefined;
|
||||
const text = extractText(quoted);
|
||||
let body: string | undefined = [text, locationText]
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
.trim();
|
||||
if (!body) body = extractMediaPlaceholder(quoted);
|
||||
if (!body) {
|
||||
const quotedType = quoted ? getContentType(quoted) : undefined;
|
||||
logVerbose(
|
||||
`Quoted message missing extractable body${
|
||||
quotedType ? ` (type ${quotedType})` : ""
|
||||
}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
const senderJid = contextInfo?.participant ?? undefined;
|
||||
const senderE164 = senderJid
|
||||
? (jidToE164(senderJid) ?? senderJid)
|
||||
: undefined;
|
||||
const sender = senderE164 ?? "unknown sender";
|
||||
return {
|
||||
id: contextInfo?.stanzaId ? String(contextInfo.stanzaId) : undefined,
|
||||
body,
|
||||
sender,
|
||||
};
|
||||
}
|
||||
|
||||
async function downloadInboundMedia(
|
||||
msg: proto.IWebMessageInfo,
|
||||
sock: Awaited<ReturnType<typeof createWaSocket>>,
|
||||
): Promise<{ buffer: Buffer; mimetype?: string } | undefined> {
|
||||
const message = unwrapMessage(msg.message as proto.IMessage | undefined);
|
||||
if (!message) return undefined;
|
||||
const mimetype =
|
||||
message.imageMessage?.mimetype ??
|
||||
message.videoMessage?.mimetype ??
|
||||
message.documentMessage?.mimetype ??
|
||||
message.audioMessage?.mimetype ??
|
||||
message.stickerMessage?.mimetype ??
|
||||
undefined;
|
||||
if (
|
||||
!message.imageMessage &&
|
||||
!message.videoMessage &&
|
||||
!message.documentMessage &&
|
||||
!message.audioMessage &&
|
||||
!message.stickerMessage
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const buffer = (await downloadMediaMessage(
|
||||
msg as WAMessage,
|
||||
"buffer",
|
||||
{},
|
||||
{
|
||||
reuploadRequest: sock.updateMediaMessage,
|
||||
logger: sock.logger,
|
||||
},
|
||||
)) as Buffer;
|
||||
return { buffer, mimetype };
|
||||
} catch (err) {
|
||||
logVerbose(`downloadMediaMessage failed: ${String(err)}`);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
export { resetWebInboundDedupe } from "./inbound/dedupe.js";
|
||||
export {
|
||||
extractLocationData,
|
||||
extractMediaPlaceholder,
|
||||
extractText,
|
||||
} from "./inbound/extract.js";
|
||||
export { monitorWebInbox } from "./inbound/monitor.js";
|
||||
export type {
|
||||
WebInboundMessage,
|
||||
WebListenerCloseReason,
|
||||
} from "./inbound/types.js";
|
||||
|
||||
186
src/web/inbound/access-control.ts
Normal file
186
src/web/inbound/access-control.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import { loadConfig } from "../../config/config.js";
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import { buildPairingReply } from "../../pairing/pairing-messages.js";
|
||||
import {
|
||||
readChannelAllowFromStore,
|
||||
upsertChannelPairingRequest,
|
||||
} from "../../pairing/pairing-store.js";
|
||||
import { isSelfChatMode, normalizeE164 } from "../../utils.js";
|
||||
import { resolveWhatsAppAccount } from "../accounts.js";
|
||||
|
||||
export type InboundAccessControlResult = {
|
||||
allowed: boolean;
|
||||
shouldMarkRead: boolean;
|
||||
isSelfChat: boolean;
|
||||
resolvedAccountId: string;
|
||||
};
|
||||
|
||||
export async function checkInboundAccessControl(params: {
|
||||
accountId: string;
|
||||
from: string;
|
||||
selfE164: string | null;
|
||||
senderE164: string | null;
|
||||
group: boolean;
|
||||
pushName?: string;
|
||||
isFromMe: boolean;
|
||||
sock: {
|
||||
sendMessage: (jid: string, content: { text: string }) => Promise<unknown>;
|
||||
};
|
||||
remoteJid: string;
|
||||
}): Promise<InboundAccessControlResult> {
|
||||
const cfg = loadConfig();
|
||||
const account = resolveWhatsAppAccount({
|
||||
cfg,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
const dmPolicy = cfg.channels?.whatsapp?.dmPolicy ?? "pairing";
|
||||
const configuredAllowFrom = account.allowFrom;
|
||||
const storeAllowFrom = await readChannelAllowFromStore("whatsapp").catch(
|
||||
() => [],
|
||||
);
|
||||
// Without user config, default to self-only DM access so the owner can talk to themselves.
|
||||
const combinedAllowFrom = Array.from(
|
||||
new Set([...(configuredAllowFrom ?? []), ...storeAllowFrom]),
|
||||
);
|
||||
const defaultAllowFrom =
|
||||
combinedAllowFrom.length === 0 && params.selfE164
|
||||
? [params.selfE164]
|
||||
: undefined;
|
||||
const allowFrom =
|
||||
combinedAllowFrom.length > 0 ? combinedAllowFrom : defaultAllowFrom;
|
||||
const groupAllowFrom =
|
||||
account.groupAllowFrom ??
|
||||
(configuredAllowFrom && configuredAllowFrom.length > 0
|
||||
? configuredAllowFrom
|
||||
: undefined);
|
||||
const isSamePhone = params.from === params.selfE164;
|
||||
const isSelfChat = isSelfChatMode(params.selfE164, configuredAllowFrom);
|
||||
|
||||
// Pre-compute normalized allowlists for filtering.
|
||||
const dmHasWildcard = allowFrom?.includes("*") ?? false;
|
||||
const normalizedAllowFrom =
|
||||
allowFrom && allowFrom.length > 0
|
||||
? allowFrom.filter((entry) => entry !== "*").map(normalizeE164)
|
||||
: [];
|
||||
const groupHasWildcard = groupAllowFrom?.includes("*") ?? false;
|
||||
const normalizedGroupAllowFrom =
|
||||
groupAllowFrom && groupAllowFrom.length > 0
|
||||
? groupAllowFrom.filter((entry) => entry !== "*").map(normalizeE164)
|
||||
: [];
|
||||
|
||||
// Group policy filtering:
|
||||
// - "open": groups bypass allowFrom, only mention-gating applies
|
||||
// - "disabled": block all group messages entirely
|
||||
// - "allowlist": only allow group messages from senders in groupAllowFrom/allowFrom
|
||||
const groupPolicy = account.groupPolicy ?? "open";
|
||||
if (params.group && groupPolicy === "disabled") {
|
||||
logVerbose("Blocked group message (groupPolicy: disabled)");
|
||||
return {
|
||||
allowed: false,
|
||||
shouldMarkRead: false,
|
||||
isSelfChat,
|
||||
resolvedAccountId: account.accountId,
|
||||
};
|
||||
}
|
||||
if (params.group && groupPolicy === "allowlist") {
|
||||
if (!groupAllowFrom || groupAllowFrom.length === 0) {
|
||||
logVerbose(
|
||||
"Blocked group message (groupPolicy: allowlist, no groupAllowFrom)",
|
||||
);
|
||||
return {
|
||||
allowed: false,
|
||||
shouldMarkRead: false,
|
||||
isSelfChat,
|
||||
resolvedAccountId: account.accountId,
|
||||
};
|
||||
}
|
||||
const senderAllowed =
|
||||
groupHasWildcard ||
|
||||
(params.senderE164 != null &&
|
||||
normalizedGroupAllowFrom.includes(params.senderE164));
|
||||
if (!senderAllowed) {
|
||||
logVerbose(
|
||||
`Blocked group message from ${params.senderE164 ?? "unknown sender"} (groupPolicy: allowlist)`,
|
||||
);
|
||||
return {
|
||||
allowed: false,
|
||||
shouldMarkRead: false,
|
||||
isSelfChat,
|
||||
resolvedAccountId: account.accountId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// DM access control (secure defaults): "pairing" (default) / "allowlist" / "open" / "disabled".
|
||||
if (!params.group) {
|
||||
if (params.isFromMe && !isSamePhone) {
|
||||
logVerbose("Skipping outbound DM (fromMe); no pairing reply needed.");
|
||||
return {
|
||||
allowed: false,
|
||||
shouldMarkRead: false,
|
||||
isSelfChat,
|
||||
resolvedAccountId: account.accountId,
|
||||
};
|
||||
}
|
||||
if (dmPolicy === "disabled") {
|
||||
logVerbose("Blocked dm (dmPolicy: disabled)");
|
||||
return {
|
||||
allowed: false,
|
||||
shouldMarkRead: false,
|
||||
isSelfChat,
|
||||
resolvedAccountId: account.accountId,
|
||||
};
|
||||
}
|
||||
if (dmPolicy !== "open" && !isSamePhone) {
|
||||
const candidate = params.from;
|
||||
const allowed =
|
||||
dmHasWildcard ||
|
||||
(normalizedAllowFrom.length > 0 &&
|
||||
normalizedAllowFrom.includes(candidate));
|
||||
if (!allowed) {
|
||||
if (dmPolicy === "pairing") {
|
||||
const { code, created } = await upsertChannelPairingRequest({
|
||||
channel: "whatsapp",
|
||||
id: candidate,
|
||||
meta: { name: (params.pushName ?? "").trim() || undefined },
|
||||
});
|
||||
if (created) {
|
||||
logVerbose(
|
||||
`whatsapp pairing request sender=${candidate} name=${params.pushName ?? "unknown"}`,
|
||||
);
|
||||
try {
|
||||
await params.sock.sendMessage(params.remoteJid, {
|
||||
text: buildPairingReply({
|
||||
channel: "whatsapp",
|
||||
idLine: `Your WhatsApp phone number: ${candidate}`,
|
||||
code,
|
||||
}),
|
||||
});
|
||||
} catch (err) {
|
||||
logVerbose(
|
||||
`whatsapp pairing reply failed for ${candidate}: ${String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logVerbose(
|
||||
`Blocked unauthorized sender ${candidate} (dmPolicy=${dmPolicy})`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
allowed: false,
|
||||
shouldMarkRead: false,
|
||||
isSelfChat,
|
||||
resolvedAccountId: account.accountId,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
allowed: true,
|
||||
shouldMarkRead: true,
|
||||
isSelfChat,
|
||||
resolvedAccountId: account.accountId,
|
||||
};
|
||||
}
|
||||
17
src/web/inbound/dedupe.ts
Normal file
17
src/web/inbound/dedupe.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { createDedupeCache } from "../../infra/dedupe.js";
|
||||
|
||||
const RECENT_WEB_MESSAGE_TTL_MS = 20 * 60_000;
|
||||
const RECENT_WEB_MESSAGE_MAX = 5000;
|
||||
|
||||
const recentInboundMessages = createDedupeCache({
|
||||
ttlMs: RECENT_WEB_MESSAGE_TTL_MS,
|
||||
maxSize: RECENT_WEB_MESSAGE_MAX,
|
||||
});
|
||||
|
||||
export function resetWebInboundDedupe(): void {
|
||||
recentInboundMessages.clear();
|
||||
}
|
||||
|
||||
export function isRecentInboundMessage(key: string): boolean {
|
||||
return recentInboundMessages.check(key);
|
||||
}
|
||||
311
src/web/inbound/extract.ts
Normal file
311
src/web/inbound/extract.ts
Normal file
@@ -0,0 +1,311 @@
|
||||
import type { proto } from "@whiskeysockets/baileys";
|
||||
import {
|
||||
extractMessageContent,
|
||||
getContentType,
|
||||
normalizeMessageContent,
|
||||
} from "@whiskeysockets/baileys";
|
||||
import {
|
||||
formatLocationText,
|
||||
type NormalizedLocation,
|
||||
} from "../../channels/location.js";
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import { jidToE164 } from "../../utils.js";
|
||||
import { parseVcard } from "../vcard.js";
|
||||
|
||||
function unwrapMessage(
|
||||
message: proto.IMessage | undefined,
|
||||
): proto.IMessage | undefined {
|
||||
const normalized = normalizeMessageContent(
|
||||
message as proto.IMessage | undefined,
|
||||
);
|
||||
return normalized as proto.IMessage | undefined;
|
||||
}
|
||||
|
||||
function extractContextInfo(
|
||||
message: proto.IMessage | undefined,
|
||||
): proto.IContextInfo | undefined {
|
||||
if (!message) return undefined;
|
||||
const contentType = getContentType(message);
|
||||
const candidate = contentType
|
||||
? (message as Record<string, unknown>)[contentType]
|
||||
: undefined;
|
||||
const contextInfo =
|
||||
candidate && typeof candidate === "object" && "contextInfo" in candidate
|
||||
? (candidate as { contextInfo?: proto.IContextInfo }).contextInfo
|
||||
: undefined;
|
||||
if (contextInfo) return contextInfo;
|
||||
const fallback =
|
||||
message.extendedTextMessage?.contextInfo ??
|
||||
message.imageMessage?.contextInfo ??
|
||||
message.videoMessage?.contextInfo ??
|
||||
message.documentMessage?.contextInfo ??
|
||||
message.audioMessage?.contextInfo ??
|
||||
message.stickerMessage?.contextInfo ??
|
||||
message.buttonsResponseMessage?.contextInfo ??
|
||||
message.listResponseMessage?.contextInfo ??
|
||||
message.templateButtonReplyMessage?.contextInfo ??
|
||||
message.interactiveResponseMessage?.contextInfo ??
|
||||
message.buttonsMessage?.contextInfo ??
|
||||
message.listMessage?.contextInfo;
|
||||
if (fallback) return fallback;
|
||||
for (const value of Object.values(message)) {
|
||||
if (!value || typeof value !== "object") continue;
|
||||
if (!("contextInfo" in value)) continue;
|
||||
const candidateContext = (value as { contextInfo?: proto.IContextInfo })
|
||||
.contextInfo;
|
||||
if (candidateContext) return candidateContext;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function extractMentionedJids(
|
||||
rawMessage: proto.IMessage | undefined,
|
||||
): string[] | undefined {
|
||||
const message = unwrapMessage(rawMessage);
|
||||
if (!message) return undefined;
|
||||
|
||||
const candidates: Array<string[] | null | undefined> = [
|
||||
message.extendedTextMessage?.contextInfo?.mentionedJid,
|
||||
message.extendedTextMessage?.contextInfo?.quotedMessage?.extendedTextMessage
|
||||
?.contextInfo?.mentionedJid,
|
||||
message.imageMessage?.contextInfo?.mentionedJid,
|
||||
message.videoMessage?.contextInfo?.mentionedJid,
|
||||
message.documentMessage?.contextInfo?.mentionedJid,
|
||||
message.audioMessage?.contextInfo?.mentionedJid,
|
||||
message.stickerMessage?.contextInfo?.mentionedJid,
|
||||
message.buttonsResponseMessage?.contextInfo?.mentionedJid,
|
||||
message.listResponseMessage?.contextInfo?.mentionedJid,
|
||||
];
|
||||
|
||||
const flattened = candidates.flatMap((arr) => arr ?? []).filter(Boolean);
|
||||
if (flattened.length === 0) return undefined;
|
||||
return Array.from(new Set(flattened));
|
||||
}
|
||||
|
||||
export function extractText(
|
||||
rawMessage: proto.IMessage | undefined,
|
||||
): string | undefined {
|
||||
const message = unwrapMessage(rawMessage);
|
||||
if (!message) return undefined;
|
||||
const extracted = extractMessageContent(message);
|
||||
const candidates = [
|
||||
message,
|
||||
extracted && extracted !== message ? extracted : undefined,
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate) continue;
|
||||
if (
|
||||
typeof candidate.conversation === "string" &&
|
||||
candidate.conversation.trim()
|
||||
) {
|
||||
return candidate.conversation.trim();
|
||||
}
|
||||
const extended = candidate.extendedTextMessage?.text;
|
||||
if (extended?.trim()) return extended.trim();
|
||||
const caption =
|
||||
candidate.imageMessage?.caption ??
|
||||
candidate.videoMessage?.caption ??
|
||||
candidate.documentMessage?.caption;
|
||||
if (caption?.trim()) return caption.trim();
|
||||
}
|
||||
const contactPlaceholder =
|
||||
extractContactPlaceholder(message) ??
|
||||
(extracted && extracted !== message
|
||||
? extractContactPlaceholder(extracted as proto.IMessage | undefined)
|
||||
: undefined);
|
||||
if (contactPlaceholder) return contactPlaceholder;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function extractMediaPlaceholder(
|
||||
rawMessage: proto.IMessage | undefined,
|
||||
): string | undefined {
|
||||
const message = unwrapMessage(rawMessage);
|
||||
if (!message) return undefined;
|
||||
if (message.imageMessage) return "<media:image>";
|
||||
if (message.videoMessage) return "<media:video>";
|
||||
if (message.audioMessage) return "<media:audio>";
|
||||
if (message.documentMessage) return "<media:document>";
|
||||
if (message.stickerMessage) return "<media:sticker>";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function extractContactPlaceholder(
|
||||
rawMessage: proto.IMessage | undefined,
|
||||
): string | undefined {
|
||||
const message = unwrapMessage(rawMessage);
|
||||
if (!message) return undefined;
|
||||
const contact = message.contactMessage ?? undefined;
|
||||
if (contact) {
|
||||
const { name, phones } = describeContact({
|
||||
displayName: contact.displayName,
|
||||
vcard: contact.vcard,
|
||||
});
|
||||
return formatContactPlaceholder(name, phones);
|
||||
}
|
||||
const contactsArray = message.contactsArrayMessage?.contacts ?? undefined;
|
||||
if (!contactsArray || contactsArray.length === 0) return undefined;
|
||||
const labels = contactsArray
|
||||
.map((entry) =>
|
||||
describeContact({ displayName: entry.displayName, vcard: entry.vcard }),
|
||||
)
|
||||
.map((entry) => formatContactLabel(entry.name, entry.phones))
|
||||
.filter((value): value is string => Boolean(value));
|
||||
return formatContactsPlaceholder(labels, contactsArray.length);
|
||||
}
|
||||
|
||||
function describeContact(input: {
|
||||
displayName?: string | null;
|
||||
vcard?: string | null;
|
||||
}): { name?: string; phones: string[] } {
|
||||
const displayName = (input.displayName ?? "").trim();
|
||||
const parsed = parseVcard(input.vcard ?? undefined);
|
||||
const name = displayName || parsed.name;
|
||||
return { name, phones: parsed.phones };
|
||||
}
|
||||
|
||||
function formatContactPlaceholder(name?: string, phones?: string[]): string {
|
||||
const label = formatContactLabel(name, phones);
|
||||
if (!label) return "<contact>";
|
||||
return `<contact: ${label}>`;
|
||||
}
|
||||
|
||||
function formatContactsPlaceholder(labels: string[], total: number): string {
|
||||
const cleaned = labels.map((label) => label.trim()).filter(Boolean);
|
||||
if (cleaned.length === 0) {
|
||||
const suffix = total === 1 ? "contact" : "contacts";
|
||||
return `<contacts: ${total} ${suffix}>`;
|
||||
}
|
||||
const remaining = Math.max(total - cleaned.length, 0);
|
||||
const suffix = remaining > 0 ? ` +${remaining} more` : "";
|
||||
return `<contacts: ${cleaned.join(", ")}${suffix}>`;
|
||||
}
|
||||
|
||||
function formatContactLabel(
|
||||
name?: string,
|
||||
phones?: string[],
|
||||
): string | undefined {
|
||||
const phoneLabel = formatPhoneList(phones);
|
||||
const parts = [name, phoneLabel].filter((value): value is string =>
|
||||
Boolean(value),
|
||||
);
|
||||
if (parts.length === 0) return undefined;
|
||||
return parts.join(", ");
|
||||
}
|
||||
|
||||
function formatPhoneList(phones?: string[]): string | undefined {
|
||||
const cleaned = phones?.map((phone) => phone.trim()).filter(Boolean) ?? [];
|
||||
if (cleaned.length === 0) return undefined;
|
||||
const { shown, remaining } = summarizeList(cleaned, cleaned.length, 1);
|
||||
const [primary] = shown;
|
||||
if (!primary) return undefined;
|
||||
if (remaining === 0) return primary;
|
||||
return `${primary} (+${remaining} more)`;
|
||||
}
|
||||
|
||||
function summarizeList(
|
||||
values: string[],
|
||||
total: number,
|
||||
maxShown: number,
|
||||
): { shown: string[]; remaining: number } {
|
||||
const shown = values.slice(0, maxShown);
|
||||
const remaining = Math.max(total - shown.length, 0);
|
||||
return { shown, remaining };
|
||||
}
|
||||
|
||||
export function extractLocationData(
|
||||
rawMessage: proto.IMessage | undefined,
|
||||
): NormalizedLocation | null {
|
||||
const message = unwrapMessage(rawMessage);
|
||||
if (!message) return null;
|
||||
|
||||
const live = message.liveLocationMessage ?? undefined;
|
||||
if (live) {
|
||||
const latitudeRaw = live.degreesLatitude;
|
||||
const longitudeRaw = live.degreesLongitude;
|
||||
if (latitudeRaw != null && longitudeRaw != null) {
|
||||
const latitude = Number(latitudeRaw);
|
||||
const longitude = Number(longitudeRaw);
|
||||
if (Number.isFinite(latitude) && Number.isFinite(longitude)) {
|
||||
return {
|
||||
latitude,
|
||||
longitude,
|
||||
accuracy: live.accuracyInMeters ?? undefined,
|
||||
caption: live.caption ?? undefined,
|
||||
source: "live",
|
||||
isLive: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const location = message.locationMessage ?? undefined;
|
||||
if (location) {
|
||||
const latitudeRaw = location.degreesLatitude;
|
||||
const longitudeRaw = location.degreesLongitude;
|
||||
if (latitudeRaw != null && longitudeRaw != null) {
|
||||
const latitude = Number(latitudeRaw);
|
||||
const longitude = Number(longitudeRaw);
|
||||
if (Number.isFinite(latitude) && Number.isFinite(longitude)) {
|
||||
const isLive = Boolean(location.isLive);
|
||||
return {
|
||||
latitude,
|
||||
longitude,
|
||||
accuracy: location.accuracyInMeters ?? undefined,
|
||||
name: location.name ?? undefined,
|
||||
address: location.address ?? undefined,
|
||||
caption: location.comment ?? undefined,
|
||||
source: isLive
|
||||
? "live"
|
||||
: location.name || location.address
|
||||
? "place"
|
||||
: "pin",
|
||||
isLive,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function describeReplyContext(rawMessage: proto.IMessage | undefined): {
|
||||
id?: string;
|
||||
body: string;
|
||||
sender: string;
|
||||
} | null {
|
||||
const message = unwrapMessage(rawMessage);
|
||||
if (!message) return null;
|
||||
const contextInfo = extractContextInfo(message);
|
||||
const quoted = normalizeMessageContent(
|
||||
contextInfo?.quotedMessage as proto.IMessage | undefined,
|
||||
) as proto.IMessage | undefined;
|
||||
if (!quoted) return null;
|
||||
const location = extractLocationData(quoted);
|
||||
const locationText = location ? formatLocationText(location) : undefined;
|
||||
const text = extractText(quoted);
|
||||
let body: string | undefined = [text, locationText]
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
.trim();
|
||||
if (!body) body = extractMediaPlaceholder(quoted);
|
||||
if (!body) {
|
||||
const quotedType = quoted ? getContentType(quoted) : undefined;
|
||||
logVerbose(
|
||||
`Quoted message missing extractable body${
|
||||
quotedType ? ` (type ${quotedType})` : ""
|
||||
}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
const senderJid = contextInfo?.participant ?? undefined;
|
||||
const senderE164 = senderJid
|
||||
? (jidToE164(senderJid) ?? senderJid)
|
||||
: undefined;
|
||||
const sender = senderE164 ?? "unknown sender";
|
||||
return {
|
||||
id: contextInfo?.stanzaId ? String(contextInfo.stanzaId) : undefined,
|
||||
body,
|
||||
sender,
|
||||
};
|
||||
}
|
||||
55
src/web/inbound/media.ts
Normal file
55
src/web/inbound/media.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { proto, WAMessage } from "@whiskeysockets/baileys";
|
||||
import {
|
||||
downloadMediaMessage,
|
||||
normalizeMessageContent,
|
||||
} from "@whiskeysockets/baileys";
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import type { createWaSocket } from "../session.js";
|
||||
|
||||
function unwrapMessage(
|
||||
message: proto.IMessage | undefined,
|
||||
): proto.IMessage | undefined {
|
||||
const normalized = normalizeMessageContent(
|
||||
message as proto.IMessage | undefined,
|
||||
);
|
||||
return normalized as proto.IMessage | undefined;
|
||||
}
|
||||
|
||||
export async function downloadInboundMedia(
|
||||
msg: proto.IWebMessageInfo,
|
||||
sock: Awaited<ReturnType<typeof createWaSocket>>,
|
||||
): Promise<{ buffer: Buffer; mimetype?: string } | undefined> {
|
||||
const message = unwrapMessage(msg.message as proto.IMessage | undefined);
|
||||
if (!message) return undefined;
|
||||
const mimetype =
|
||||
message.imageMessage?.mimetype ??
|
||||
message.videoMessage?.mimetype ??
|
||||
message.documentMessage?.mimetype ??
|
||||
message.audioMessage?.mimetype ??
|
||||
message.stickerMessage?.mimetype ??
|
||||
undefined;
|
||||
if (
|
||||
!message.imageMessage &&
|
||||
!message.videoMessage &&
|
||||
!message.documentMessage &&
|
||||
!message.audioMessage &&
|
||||
!message.stickerMessage
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const buffer = (await downloadMediaMessage(
|
||||
msg as WAMessage,
|
||||
"buffer",
|
||||
{},
|
||||
{
|
||||
reuploadRequest: sock.updateMediaMessage,
|
||||
logger: sock.logger,
|
||||
},
|
||||
)) as Buffer;
|
||||
return { buffer, mimetype };
|
||||
} catch (err) {
|
||||
logVerbose(`downloadMediaMessage failed: ${String(err)}`);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
373
src/web/inbound/monitor.ts
Normal file
373
src/web/inbound/monitor.ts
Normal file
@@ -0,0 +1,373 @@
|
||||
import type {
|
||||
AnyMessageContent,
|
||||
proto,
|
||||
WAMessage,
|
||||
} from "@whiskeysockets/baileys";
|
||||
import { DisconnectReason, isJidGroup } from "@whiskeysockets/baileys";
|
||||
import { formatLocationText } from "../../channels/location.js";
|
||||
import { logVerbose, shouldLogVerbose } from "../../globals.js";
|
||||
import { recordChannelActivity } from "../../infra/channel-activity.js";
|
||||
import { createSubsystemLogger, getChildLogger } from "../../logging.js";
|
||||
import { saveMediaBuffer } from "../../media/store.js";
|
||||
import { jidToE164, resolveJidToE164 } from "../../utils.js";
|
||||
import {
|
||||
createWaSocket,
|
||||
getStatusCode,
|
||||
waitForWaConnection,
|
||||
} from "../session.js";
|
||||
import { checkInboundAccessControl } from "./access-control.js";
|
||||
import { isRecentInboundMessage } from "./dedupe.js";
|
||||
import {
|
||||
describeReplyContext,
|
||||
extractLocationData,
|
||||
extractMediaPlaceholder,
|
||||
extractMentionedJids,
|
||||
extractText,
|
||||
} from "./extract.js";
|
||||
import { downloadInboundMedia } from "./media.js";
|
||||
import { createWebSendApi } from "./send-api.js";
|
||||
import type { WebInboundMessage, WebListenerCloseReason } from "./types.js";
|
||||
|
||||
export async function monitorWebInbox(options: {
|
||||
verbose: boolean;
|
||||
accountId: string;
|
||||
authDir: string;
|
||||
onMessage: (msg: WebInboundMessage) => Promise<void>;
|
||||
mediaMaxMb?: number;
|
||||
}) {
|
||||
const inboundLogger = getChildLogger({ module: "web-inbound" });
|
||||
const inboundConsoleLog = createSubsystemLogger(
|
||||
"gateway/channels/whatsapp",
|
||||
).child("inbound");
|
||||
const sock = await createWaSocket(false, options.verbose, {
|
||||
authDir: options.authDir,
|
||||
});
|
||||
await waitForWaConnection(sock);
|
||||
|
||||
let onCloseResolve: ((reason: WebListenerCloseReason) => void) | null = null;
|
||||
const onClose = new Promise<WebListenerCloseReason>((resolve) => {
|
||||
onCloseResolve = resolve;
|
||||
});
|
||||
const resolveClose = (reason: WebListenerCloseReason) => {
|
||||
if (!onCloseResolve) return;
|
||||
const resolver = onCloseResolve;
|
||||
onCloseResolve = null;
|
||||
resolver(reason);
|
||||
};
|
||||
|
||||
try {
|
||||
await sock.sendPresenceUpdate("available");
|
||||
if (shouldLogVerbose())
|
||||
logVerbose("Sent global 'available' presence on connect");
|
||||
} catch (err) {
|
||||
logVerbose(
|
||||
`Failed to send 'available' presence on connect: ${String(err)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const selfJid = sock.user?.id;
|
||||
const selfE164 = selfJid ? jidToE164(selfJid) : null;
|
||||
const groupMetaCache = new Map<
|
||||
string,
|
||||
{ subject?: string; participants?: string[]; expires: number }
|
||||
>();
|
||||
const GROUP_META_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
||||
const lidLookup = sock.signalRepository?.lidMapping;
|
||||
|
||||
const resolveInboundJid = async (
|
||||
jid: string | null | undefined,
|
||||
): Promise<string | null> =>
|
||||
resolveJidToE164(jid, { authDir: options.authDir, lidLookup });
|
||||
|
||||
const getGroupMeta = async (jid: string) => {
|
||||
const cached = groupMetaCache.get(jid);
|
||||
if (cached && cached.expires > Date.now()) return cached;
|
||||
try {
|
||||
const meta = await sock.groupMetadata(jid);
|
||||
const participants =
|
||||
(
|
||||
await Promise.all(
|
||||
meta.participants?.map(async (p) => {
|
||||
const mapped = await resolveInboundJid(p.id);
|
||||
return mapped ?? p.id;
|
||||
}) ?? [],
|
||||
)
|
||||
).filter(Boolean) ?? [];
|
||||
const entry = {
|
||||
subject: meta.subject,
|
||||
participants,
|
||||
expires: Date.now() + GROUP_META_TTL_MS,
|
||||
};
|
||||
groupMetaCache.set(jid, entry);
|
||||
return entry;
|
||||
} catch (err) {
|
||||
logVerbose(`Failed to fetch group metadata for ${jid}: ${String(err)}`);
|
||||
return { expires: Date.now() + GROUP_META_TTL_MS };
|
||||
}
|
||||
};
|
||||
|
||||
const handleMessagesUpsert = async (upsert: {
|
||||
type?: string;
|
||||
messages?: Array<WAMessage>;
|
||||
}) => {
|
||||
if (upsert.type !== "notify" && upsert.type !== "append") return;
|
||||
for (const msg of upsert.messages ?? []) {
|
||||
recordChannelActivity({
|
||||
channel: "whatsapp",
|
||||
accountId: options.accountId,
|
||||
direction: "inbound",
|
||||
});
|
||||
const id = msg.key?.id ?? undefined;
|
||||
const remoteJid = msg.key?.remoteJid;
|
||||
if (!remoteJid) continue;
|
||||
if (remoteJid.endsWith("@status") || remoteJid.endsWith("@broadcast"))
|
||||
continue;
|
||||
|
||||
const group = isJidGroup(remoteJid) === true;
|
||||
if (id) {
|
||||
const dedupeKey = `${options.accountId}:${remoteJid}:${id}`;
|
||||
if (isRecentInboundMessage(dedupeKey)) continue;
|
||||
}
|
||||
const participantJid = msg.key?.participant ?? undefined;
|
||||
const from = group ? remoteJid : await resolveInboundJid(remoteJid);
|
||||
if (!from) continue;
|
||||
const senderE164 = group
|
||||
? participantJid
|
||||
? await resolveInboundJid(participantJid)
|
||||
: null
|
||||
: from;
|
||||
|
||||
let groupSubject: string | undefined;
|
||||
let groupParticipants: string[] | undefined;
|
||||
if (group) {
|
||||
const meta = await getGroupMeta(remoteJid);
|
||||
groupSubject = meta.subject;
|
||||
groupParticipants = meta.participants;
|
||||
}
|
||||
|
||||
const access = await checkInboundAccessControl({
|
||||
accountId: options.accountId,
|
||||
from,
|
||||
selfE164,
|
||||
senderE164,
|
||||
group,
|
||||
pushName: msg.pushName ?? undefined,
|
||||
isFromMe: Boolean(msg.key?.fromMe),
|
||||
sock: { sendMessage: (jid, content) => sock.sendMessage(jid, content) },
|
||||
remoteJid,
|
||||
});
|
||||
if (!access.allowed) continue;
|
||||
|
||||
if (id && !access.isSelfChat) {
|
||||
const participant = msg.key?.participant;
|
||||
try {
|
||||
await sock.readMessages([
|
||||
{ remoteJid, id, participant, fromMe: false },
|
||||
]);
|
||||
if (shouldLogVerbose()) {
|
||||
const suffix = participant ? ` (participant ${participant})` : "";
|
||||
logVerbose(
|
||||
`Marked message ${id} as read for ${remoteJid}${suffix}`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
logVerbose(`Failed to mark message ${id} read: ${String(err)}`);
|
||||
}
|
||||
} else if (id && access.isSelfChat && shouldLogVerbose()) {
|
||||
// Self-chat mode: never auto-send read receipts (blue ticks) on behalf of the owner.
|
||||
logVerbose(`Self-chat mode: skipping read receipt for ${id}`);
|
||||
}
|
||||
|
||||
// If this is history/offline catch-up, mark read above but skip auto-reply.
|
||||
if (upsert.type === "append") continue;
|
||||
|
||||
const location = extractLocationData(msg.message ?? undefined);
|
||||
const locationText = location ? formatLocationText(location) : undefined;
|
||||
let body = extractText(msg.message ?? undefined);
|
||||
if (locationText) {
|
||||
body = [body, locationText].filter(Boolean).join("\n").trim();
|
||||
}
|
||||
if (!body) {
|
||||
body = extractMediaPlaceholder(msg.message ?? undefined);
|
||||
if (!body) continue;
|
||||
}
|
||||
const replyContext = describeReplyContext(
|
||||
msg.message as proto.IMessage | undefined,
|
||||
);
|
||||
|
||||
let mediaPath: string | undefined;
|
||||
let mediaType: string | undefined;
|
||||
try {
|
||||
const inboundMedia = await downloadInboundMedia(
|
||||
msg as proto.IWebMessageInfo,
|
||||
sock,
|
||||
);
|
||||
if (inboundMedia) {
|
||||
const maxMb =
|
||||
typeof options.mediaMaxMb === "number" && options.mediaMaxMb > 0
|
||||
? options.mediaMaxMb
|
||||
: 50;
|
||||
const maxBytes = maxMb * 1024 * 1024;
|
||||
const saved = await saveMediaBuffer(
|
||||
inboundMedia.buffer,
|
||||
inboundMedia.mimetype,
|
||||
"inbound",
|
||||
maxBytes,
|
||||
);
|
||||
mediaPath = saved.path;
|
||||
mediaType = inboundMedia.mimetype;
|
||||
}
|
||||
} catch (err) {
|
||||
logVerbose(`Inbound media download failed: ${String(err)}`);
|
||||
}
|
||||
|
||||
const chatJid = remoteJid;
|
||||
const sendComposing = async () => {
|
||||
try {
|
||||
await sock.sendPresenceUpdate("composing", chatJid);
|
||||
} catch (err) {
|
||||
logVerbose(`Presence update failed: ${String(err)}`);
|
||||
}
|
||||
};
|
||||
const reply = async (text: string) => {
|
||||
await sock.sendMessage(chatJid, { text });
|
||||
};
|
||||
const sendMedia = async (payload: AnyMessageContent) => {
|
||||
await sock.sendMessage(chatJid, payload);
|
||||
};
|
||||
const timestamp = msg.messageTimestamp
|
||||
? Number(msg.messageTimestamp) * 1000
|
||||
: undefined;
|
||||
const mentionedJids = extractMentionedJids(
|
||||
msg.message as proto.IMessage | undefined,
|
||||
);
|
||||
const senderName = msg.pushName ?? undefined;
|
||||
|
||||
inboundLogger.info(
|
||||
{ from, to: selfE164 ?? "me", body, mediaPath, mediaType, timestamp },
|
||||
"inbound message",
|
||||
);
|
||||
try {
|
||||
const task = Promise.resolve(
|
||||
options.onMessage({
|
||||
id,
|
||||
from,
|
||||
conversationId: from,
|
||||
to: selfE164 ?? "me",
|
||||
accountId: access.resolvedAccountId,
|
||||
body,
|
||||
pushName: senderName,
|
||||
timestamp,
|
||||
chatType: group ? "group" : "direct",
|
||||
chatId: remoteJid,
|
||||
senderJid: participantJid,
|
||||
senderE164: senderE164 ?? undefined,
|
||||
senderName,
|
||||
replyToId: replyContext?.id,
|
||||
replyToBody: replyContext?.body,
|
||||
replyToSender: replyContext?.sender,
|
||||
groupSubject,
|
||||
groupParticipants,
|
||||
mentionedJids: mentionedJids ?? undefined,
|
||||
selfJid,
|
||||
selfE164,
|
||||
location: location ?? undefined,
|
||||
sendComposing,
|
||||
reply,
|
||||
sendMedia,
|
||||
mediaPath,
|
||||
mediaType,
|
||||
}),
|
||||
);
|
||||
void task.catch((err) => {
|
||||
inboundLogger.error(
|
||||
{ error: String(err) },
|
||||
"failed handling inbound web message",
|
||||
);
|
||||
inboundConsoleLog.error(
|
||||
`Failed handling inbound web message: ${String(err)}`,
|
||||
);
|
||||
});
|
||||
} catch (err) {
|
||||
inboundLogger.error(
|
||||
{ error: String(err) },
|
||||
"failed handling inbound web message",
|
||||
);
|
||||
inboundConsoleLog.error(
|
||||
`Failed handling inbound web message: ${String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
sock.ev.on("messages.upsert", handleMessagesUpsert);
|
||||
|
||||
const handleConnectionUpdate = (
|
||||
update: Partial<import("@whiskeysockets/baileys").ConnectionState>,
|
||||
) => {
|
||||
try {
|
||||
if (update.connection === "close") {
|
||||
const status = getStatusCode(update.lastDisconnect?.error);
|
||||
resolveClose({
|
||||
status,
|
||||
isLoggedOut: status === DisconnectReason.loggedOut,
|
||||
error: update.lastDisconnect?.error,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
inboundLogger.error(
|
||||
{ error: String(err) },
|
||||
"connection.update handler error",
|
||||
);
|
||||
resolveClose({ status: undefined, isLoggedOut: false, error: err });
|
||||
}
|
||||
};
|
||||
sock.ev.on("connection.update", handleConnectionUpdate);
|
||||
|
||||
const sendApi = createWebSendApi({
|
||||
sock: {
|
||||
sendMessage: (jid: string, content: AnyMessageContent) =>
|
||||
sock.sendMessage(jid, content),
|
||||
sendPresenceUpdate: (presence, jid?: string) =>
|
||||
sock.sendPresenceUpdate(presence, jid),
|
||||
},
|
||||
defaultAccountId: options.accountId,
|
||||
});
|
||||
|
||||
return {
|
||||
close: async () => {
|
||||
try {
|
||||
const ev = sock.ev as unknown as {
|
||||
off?: (event: string, listener: (...args: unknown[]) => void) => void;
|
||||
removeListener?: (
|
||||
event: string,
|
||||
listener: (...args: unknown[]) => void,
|
||||
) => void;
|
||||
};
|
||||
const messagesUpsertHandler = handleMessagesUpsert as unknown as (
|
||||
...args: unknown[]
|
||||
) => void;
|
||||
const connectionUpdateHandler = handleConnectionUpdate as unknown as (
|
||||
...args: unknown[]
|
||||
) => void;
|
||||
if (typeof ev.off === "function") {
|
||||
ev.off("messages.upsert", messagesUpsertHandler);
|
||||
ev.off("connection.update", connectionUpdateHandler);
|
||||
} else if (typeof ev.removeListener === "function") {
|
||||
ev.removeListener("messages.upsert", messagesUpsertHandler);
|
||||
ev.removeListener("connection.update", connectionUpdateHandler);
|
||||
}
|
||||
sock.ws?.close();
|
||||
} catch (err) {
|
||||
logVerbose(`Socket close failed: ${String(err)}`);
|
||||
}
|
||||
},
|
||||
onClose,
|
||||
signalClose: (reason?: WebListenerCloseReason) => {
|
||||
resolveClose(
|
||||
reason ?? { status: undefined, isLoggedOut: false, error: "closed" },
|
||||
);
|
||||
},
|
||||
// IPC surface (sendMessage/sendPoll/sendReaction/sendComposingTo)
|
||||
...sendApi,
|
||||
} as const;
|
||||
}
|
||||
115
src/web/inbound/send-api.ts
Normal file
115
src/web/inbound/send-api.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import type { AnyMessageContent, WAPresence } from "@whiskeysockets/baileys";
|
||||
import { recordChannelActivity } from "../../infra/channel-activity.js";
|
||||
import { toWhatsappJid } from "../../utils.js";
|
||||
import type { ActiveWebSendOptions } from "../active-listener.js";
|
||||
|
||||
export function createWebSendApi(params: {
|
||||
sock: {
|
||||
sendMessage: (jid: string, content: AnyMessageContent) => Promise<unknown>;
|
||||
sendPresenceUpdate: (
|
||||
presence: WAPresence,
|
||||
jid?: string,
|
||||
) => Promise<unknown>;
|
||||
};
|
||||
defaultAccountId: string;
|
||||
}) {
|
||||
return {
|
||||
sendMessage: async (
|
||||
to: string,
|
||||
text: string,
|
||||
mediaBuffer?: Buffer,
|
||||
mediaType?: string,
|
||||
sendOptions?: ActiveWebSendOptions,
|
||||
): Promise<{ messageId: string }> => {
|
||||
const jid = toWhatsappJid(to);
|
||||
let payload: AnyMessageContent;
|
||||
if (mediaBuffer && mediaType) {
|
||||
if (mediaType.startsWith("image/")) {
|
||||
payload = {
|
||||
image: mediaBuffer,
|
||||
caption: text || undefined,
|
||||
mimetype: mediaType,
|
||||
};
|
||||
} else if (mediaType.startsWith("audio/")) {
|
||||
payload = { audio: mediaBuffer, ptt: true, mimetype: mediaType };
|
||||
} else if (mediaType.startsWith("video/")) {
|
||||
const gifPlayback = sendOptions?.gifPlayback;
|
||||
payload = {
|
||||
video: mediaBuffer,
|
||||
caption: text || undefined,
|
||||
mimetype: mediaType,
|
||||
...(gifPlayback ? { gifPlayback: true } : {}),
|
||||
};
|
||||
} else {
|
||||
payload = {
|
||||
document: mediaBuffer,
|
||||
fileName: "file",
|
||||
caption: text || undefined,
|
||||
mimetype: mediaType,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
payload = { text };
|
||||
}
|
||||
const result = await params.sock.sendMessage(jid, payload);
|
||||
const accountId = sendOptions?.accountId ?? params.defaultAccountId;
|
||||
recordChannelActivity({
|
||||
channel: "whatsapp",
|
||||
accountId,
|
||||
direction: "outbound",
|
||||
});
|
||||
const messageId =
|
||||
typeof result === "object" && result && "key" in result
|
||||
? String((result as { key?: { id?: string } }).key?.id ?? "unknown")
|
||||
: "unknown";
|
||||
return { messageId };
|
||||
},
|
||||
sendPoll: async (
|
||||
to: string,
|
||||
poll: { question: string; options: string[]; maxSelections?: number },
|
||||
): Promise<{ messageId: string }> => {
|
||||
const jid = toWhatsappJid(to);
|
||||
const result = await params.sock.sendMessage(jid, {
|
||||
poll: {
|
||||
name: poll.question,
|
||||
values: poll.options,
|
||||
selectableCount: poll.maxSelections ?? 1,
|
||||
},
|
||||
} as AnyMessageContent);
|
||||
recordChannelActivity({
|
||||
channel: "whatsapp",
|
||||
accountId: params.defaultAccountId,
|
||||
direction: "outbound",
|
||||
});
|
||||
const messageId =
|
||||
typeof result === "object" && result && "key" in result
|
||||
? String((result as { key?: { id?: string } }).key?.id ?? "unknown")
|
||||
: "unknown";
|
||||
return { messageId };
|
||||
},
|
||||
sendReaction: async (
|
||||
chatJid: string,
|
||||
messageId: string,
|
||||
emoji: string,
|
||||
fromMe: boolean,
|
||||
participant?: string,
|
||||
): Promise<void> => {
|
||||
const jid = toWhatsappJid(chatJid);
|
||||
await params.sock.sendMessage(jid, {
|
||||
react: {
|
||||
text: emoji,
|
||||
key: {
|
||||
remoteJid: jid,
|
||||
id: messageId,
|
||||
fromMe,
|
||||
participant: participant ? toWhatsappJid(participant) : undefined,
|
||||
},
|
||||
},
|
||||
} as AnyMessageContent);
|
||||
},
|
||||
sendComposingTo: async (to: string): Promise<void> => {
|
||||
const jid = toWhatsappJid(to);
|
||||
await params.sock.sendPresenceUpdate("composing", jid);
|
||||
},
|
||||
} as const;
|
||||
}
|
||||
40
src/web/inbound/types.ts
Normal file
40
src/web/inbound/types.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { AnyMessageContent } from "@whiskeysockets/baileys";
|
||||
import type { NormalizedLocation } from "../../channels/location.js";
|
||||
|
||||
export type WebListenerCloseReason = {
|
||||
status?: number;
|
||||
isLoggedOut: boolean;
|
||||
error?: unknown;
|
||||
};
|
||||
|
||||
export type WebInboundMessage = {
|
||||
id?: string;
|
||||
from: string; // conversation id: E.164 for direct chats, group JID for groups
|
||||
conversationId: string; // alias for clarity (same as from)
|
||||
to: string;
|
||||
accountId: string;
|
||||
body: string;
|
||||
pushName?: string;
|
||||
timestamp?: number;
|
||||
chatType: "direct" | "group";
|
||||
chatId: string;
|
||||
senderJid?: string;
|
||||
senderE164?: string;
|
||||
senderName?: string;
|
||||
replyToId?: string;
|
||||
replyToBody?: string;
|
||||
replyToSender?: string;
|
||||
groupSubject?: string;
|
||||
groupParticipants?: string[];
|
||||
mentionedJids?: string[];
|
||||
selfJid?: string | null;
|
||||
selfE164?: string | null;
|
||||
location?: NormalizedLocation;
|
||||
sendComposing: () => Promise<void>;
|
||||
reply: (text: string) => Promise<void>;
|
||||
sendMedia: (payload: AnyMessageContent) => Promise<void>;
|
||||
mediaPath?: string;
|
||||
mediaType?: string;
|
||||
mediaUrl?: string;
|
||||
wasMentioned?: boolean;
|
||||
};
|
||||
461
src/web/monitor-inbox.part-1.test.ts
Normal file
461
src/web/monitor-inbox.part-1.test.ts
Normal file
@@ -0,0 +1,461 @@
|
||||
import { vi } from "vitest";
|
||||
|
||||
vi.mock("../media/store.js", () => ({
|
||||
saveMediaBuffer: vi.fn().mockResolvedValue({
|
||||
id: "mid",
|
||||
path: "/tmp/mid",
|
||||
size: 1,
|
||||
contentType: "image/jpeg",
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockLoadConfig = vi.fn().mockReturnValue({
|
||||
channels: {
|
||||
whatsapp: {
|
||||
// Allow all in tests by default
|
||||
allowFrom: ["*"],
|
||||
},
|
||||
},
|
||||
messages: {
|
||||
messagePrefix: undefined,
|
||||
responsePrefix: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const readAllowFromStoreMock = vi.fn().mockResolvedValue([]);
|
||||
const upsertPairingRequestMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ code: "PAIRCODE", created: true });
|
||||
|
||||
vi.mock("../config/config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../config/config.js")>();
|
||||
return {
|
||||
...actual,
|
||||
loadConfig: () => mockLoadConfig(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../pairing/pairing-store.js", () => ({
|
||||
readChannelAllowFromStore: (...args: unknown[]) =>
|
||||
readAllowFromStoreMock(...args),
|
||||
upsertChannelPairingRequest: (...args: unknown[]) =>
|
||||
upsertPairingRequestMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./session.js", () => {
|
||||
const { EventEmitter } = require("node:events");
|
||||
const ev = new EventEmitter();
|
||||
const sock = {
|
||||
ev,
|
||||
ws: { close: vi.fn() },
|
||||
sendPresenceUpdate: vi.fn().mockResolvedValue(undefined),
|
||||
sendMessage: vi.fn().mockResolvedValue(undefined),
|
||||
readMessages: vi.fn().mockResolvedValue(undefined),
|
||||
updateMediaMessage: vi.fn(),
|
||||
logger: {},
|
||||
signalRepository: {
|
||||
lidMapping: {
|
||||
getPNForLID: vi.fn().mockResolvedValue(null),
|
||||
},
|
||||
},
|
||||
user: { id: "123@s.whatsapp.net" },
|
||||
};
|
||||
return {
|
||||
createWaSocket: vi.fn().mockResolvedValue(sock),
|
||||
waitForWaConnection: vi.fn().mockResolvedValue(undefined),
|
||||
getStatusCode: vi.fn(() => 500),
|
||||
};
|
||||
});
|
||||
|
||||
const { createWaSocket } = await import("./session.js");
|
||||
const _getSock = () =>
|
||||
(createWaSocket as unknown as () => Promise<ReturnType<typeof mockSock>>)();
|
||||
|
||||
import fsSync from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { resetLogger, setLoggerOverride } from "../logging.js";
|
||||
import { monitorWebInbox, resetWebInboundDedupe } from "./inbound.js";
|
||||
|
||||
const ACCOUNT_ID = "default";
|
||||
let authDir: string;
|
||||
|
||||
describe("web monitor inbox", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
readAllowFromStoreMock.mockResolvedValue([]);
|
||||
upsertPairingRequestMock.mockResolvedValue({
|
||||
code: "PAIRCODE",
|
||||
created: true,
|
||||
});
|
||||
resetWebInboundDedupe();
|
||||
authDir = fsSync.mkdtempSync(path.join(os.tmpdir(), "clawdbot-auth-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetLogger();
|
||||
setLoggerOverride(null);
|
||||
vi.useRealTimers();
|
||||
fsSync.rmSync(authDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("streams inbound messages", async () => {
|
||||
const onMessage = vi.fn(async (msg) => {
|
||||
await msg.sendComposing();
|
||||
await msg.reply("pong");
|
||||
});
|
||||
|
||||
const listener = await monitorWebInbox({
|
||||
verbose: false,
|
||||
onMessage,
|
||||
accountId: ACCOUNT_ID,
|
||||
authDir,
|
||||
});
|
||||
const sock = await createWaSocket();
|
||||
expect(sock.sendPresenceUpdate).toHaveBeenCalledWith("available");
|
||||
const upsert = {
|
||||
type: "notify",
|
||||
messages: [
|
||||
{
|
||||
key: { id: "abc", fromMe: false, remoteJid: "999@s.whatsapp.net" },
|
||||
message: { conversation: "ping" },
|
||||
messageTimestamp: 1_700_000_000,
|
||||
pushName: "Tester",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
sock.ev.emit("messages.upsert", upsert);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(onMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ body: "ping", from: "+999", to: "+123" }),
|
||||
);
|
||||
expect(sock.readMessages).toHaveBeenCalledWith([
|
||||
{
|
||||
remoteJid: "999@s.whatsapp.net",
|
||||
id: "abc",
|
||||
participant: undefined,
|
||||
fromMe: false,
|
||||
},
|
||||
]);
|
||||
expect(sock.sendPresenceUpdate).toHaveBeenCalledWith("available");
|
||||
expect(sock.sendPresenceUpdate).toHaveBeenCalledWith(
|
||||
"composing",
|
||||
"999@s.whatsapp.net",
|
||||
);
|
||||
expect(sock.sendMessage).toHaveBeenCalledWith("999@s.whatsapp.net", {
|
||||
text: "pong",
|
||||
});
|
||||
|
||||
await listener.close();
|
||||
});
|
||||
|
||||
it("deduplicates redelivered messages by id", async () => {
|
||||
const onMessage = vi.fn(async () => {
|
||||
return;
|
||||
});
|
||||
|
||||
const listener = await monitorWebInbox({
|
||||
verbose: false,
|
||||
onMessage,
|
||||
accountId: ACCOUNT_ID,
|
||||
authDir,
|
||||
});
|
||||
const sock = await createWaSocket();
|
||||
const upsert = {
|
||||
type: "notify",
|
||||
messages: [
|
||||
{
|
||||
key: { id: "abc", fromMe: false, remoteJid: "999@s.whatsapp.net" },
|
||||
message: { conversation: "ping" },
|
||||
messageTimestamp: 1_700_000_000,
|
||||
pushName: "Tester",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
sock.ev.emit("messages.upsert", upsert);
|
||||
sock.ev.emit("messages.upsert", upsert);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(onMessage).toHaveBeenCalledTimes(1);
|
||||
|
||||
await listener.close();
|
||||
});
|
||||
|
||||
it("resolves LID JIDs using Baileys LID mapping store", async () => {
|
||||
const onMessage = vi.fn(async () => {
|
||||
return;
|
||||
});
|
||||
|
||||
const listener = await monitorWebInbox({
|
||||
verbose: false,
|
||||
onMessage,
|
||||
accountId: ACCOUNT_ID,
|
||||
authDir,
|
||||
});
|
||||
const sock = await createWaSocket();
|
||||
const getPNForLID = vi.spyOn(
|
||||
sock.signalRepository.lidMapping,
|
||||
"getPNForLID",
|
||||
);
|
||||
sock.signalRepository.lidMapping.getPNForLID.mockResolvedValueOnce(
|
||||
"999:0@s.whatsapp.net",
|
||||
);
|
||||
const upsert = {
|
||||
type: "notify",
|
||||
messages: [
|
||||
{
|
||||
key: { id: "abc", fromMe: false, remoteJid: "999@lid" },
|
||||
message: { conversation: "ping" },
|
||||
messageTimestamp: 1_700_000_000,
|
||||
pushName: "Tester",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
sock.ev.emit("messages.upsert", upsert);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(getPNForLID).toHaveBeenCalledWith("999@lid");
|
||||
expect(onMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ body: "ping", from: "+999", to: "+123" }),
|
||||
);
|
||||
|
||||
await listener.close();
|
||||
});
|
||||
|
||||
it("resolves LID JIDs via authDir mapping files", async () => {
|
||||
const onMessage = vi.fn(async () => {
|
||||
return;
|
||||
});
|
||||
fsSync.writeFileSync(
|
||||
path.join(authDir, "lid-mapping-555_reverse.json"),
|
||||
JSON.stringify("1555"),
|
||||
);
|
||||
|
||||
const listener = await monitorWebInbox({
|
||||
verbose: false,
|
||||
onMessage,
|
||||
accountId: ACCOUNT_ID,
|
||||
authDir,
|
||||
});
|
||||
const sock = await createWaSocket();
|
||||
const getPNForLID = vi.spyOn(
|
||||
sock.signalRepository.lidMapping,
|
||||
"getPNForLID",
|
||||
);
|
||||
const upsert = {
|
||||
type: "notify",
|
||||
messages: [
|
||||
{
|
||||
key: { id: "abc", fromMe: false, remoteJid: "555@lid" },
|
||||
message: { conversation: "ping" },
|
||||
messageTimestamp: 1_700_000_000,
|
||||
pushName: "Tester",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
sock.ev.emit("messages.upsert", upsert);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(onMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ body: "ping", from: "+1555", to: "+123" }),
|
||||
);
|
||||
expect(getPNForLID).not.toHaveBeenCalled();
|
||||
|
||||
await listener.close();
|
||||
});
|
||||
|
||||
it("resolves group participant LID JIDs via Baileys mapping", async () => {
|
||||
const onMessage = vi.fn(async () => {
|
||||
return;
|
||||
});
|
||||
|
||||
const listener = await monitorWebInbox({
|
||||
verbose: false,
|
||||
onMessage,
|
||||
accountId: ACCOUNT_ID,
|
||||
authDir,
|
||||
});
|
||||
const sock = await createWaSocket();
|
||||
const getPNForLID = vi.spyOn(
|
||||
sock.signalRepository.lidMapping,
|
||||
"getPNForLID",
|
||||
);
|
||||
sock.signalRepository.lidMapping.getPNForLID.mockResolvedValueOnce(
|
||||
"444:0@s.whatsapp.net",
|
||||
);
|
||||
const upsert = {
|
||||
type: "notify",
|
||||
messages: [
|
||||
{
|
||||
key: {
|
||||
id: "abc",
|
||||
fromMe: false,
|
||||
remoteJid: "123@g.us",
|
||||
participant: "444@lid",
|
||||
},
|
||||
message: { conversation: "ping" },
|
||||
messageTimestamp: 1_700_000_000,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
sock.ev.emit("messages.upsert", upsert);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(getPNForLID).toHaveBeenCalledWith("444@lid");
|
||||
expect(onMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
body: "ping",
|
||||
from: "123@g.us",
|
||||
senderE164: "+444",
|
||||
chatType: "group",
|
||||
}),
|
||||
);
|
||||
|
||||
await listener.close();
|
||||
});
|
||||
|
||||
it("does not block follow-up messages when handler is pending", async () => {
|
||||
let resolveFirst: (() => void) | null = null;
|
||||
const onMessage = vi.fn(async () => {
|
||||
if (!resolveFirst) {
|
||||
await new Promise<void>((resolve) => {
|
||||
resolveFirst = resolve;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const listener = await monitorWebInbox({
|
||||
verbose: false,
|
||||
onMessage,
|
||||
accountId: ACCOUNT_ID,
|
||||
authDir,
|
||||
});
|
||||
const sock = await createWaSocket();
|
||||
const upsert = {
|
||||
type: "notify",
|
||||
messages: [
|
||||
{
|
||||
key: { id: "abc1", fromMe: false, remoteJid: "999@s.whatsapp.net" },
|
||||
message: { conversation: "ping" },
|
||||
messageTimestamp: 1_700_000_000,
|
||||
},
|
||||
{
|
||||
key: { id: "abc2", fromMe: false, remoteJid: "999@s.whatsapp.net" },
|
||||
message: { conversation: "pong" },
|
||||
messageTimestamp: 1_700_000_001,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
sock.ev.emit("messages.upsert", upsert);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(onMessage).toHaveBeenCalledTimes(2);
|
||||
|
||||
resolveFirst?.();
|
||||
await listener.close();
|
||||
});
|
||||
|
||||
it("captures reply context from quoted messages", async () => {
|
||||
const onMessage = vi.fn(async (msg) => {
|
||||
await msg.reply("pong");
|
||||
});
|
||||
|
||||
const listener = await monitorWebInbox({ verbose: false, onMessage });
|
||||
const sock = await createWaSocket();
|
||||
const upsert = {
|
||||
type: "notify",
|
||||
messages: [
|
||||
{
|
||||
key: { id: "abc", fromMe: false, remoteJid: "999@s.whatsapp.net" },
|
||||
message: {
|
||||
extendedTextMessage: {
|
||||
text: "reply",
|
||||
contextInfo: {
|
||||
stanzaId: "q1",
|
||||
participant: "111@s.whatsapp.net",
|
||||
quotedMessage: { conversation: "original" },
|
||||
},
|
||||
},
|
||||
},
|
||||
messageTimestamp: 1_700_000_000,
|
||||
pushName: "Tester",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
sock.ev.emit("messages.upsert", upsert);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(onMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
replyToId: "q1",
|
||||
replyToBody: "original",
|
||||
replyToSender: "+111",
|
||||
}),
|
||||
);
|
||||
expect(sock.sendMessage).toHaveBeenCalledWith("999@s.whatsapp.net", {
|
||||
text: "pong",
|
||||
});
|
||||
|
||||
await listener.close();
|
||||
});
|
||||
|
||||
it("captures reply context from wrapped quoted messages", async () => {
|
||||
const onMessage = vi.fn(async (msg) => {
|
||||
await msg.reply("pong");
|
||||
});
|
||||
|
||||
const listener = await monitorWebInbox({ verbose: false, onMessage });
|
||||
const sock = await createWaSocket();
|
||||
const upsert = {
|
||||
type: "notify",
|
||||
messages: [
|
||||
{
|
||||
key: { id: "abc", fromMe: false, remoteJid: "999@s.whatsapp.net" },
|
||||
message: {
|
||||
extendedTextMessage: {
|
||||
text: "reply",
|
||||
contextInfo: {
|
||||
stanzaId: "q1",
|
||||
participant: "111@s.whatsapp.net",
|
||||
quotedMessage: {
|
||||
viewOnceMessageV2Extension: {
|
||||
message: { conversation: "original" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
messageTimestamp: 1_700_000_000,
|
||||
pushName: "Tester",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
sock.ev.emit("messages.upsert", upsert);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(onMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
replyToId: "q1",
|
||||
replyToBody: "original",
|
||||
replyToSender: "+111",
|
||||
}),
|
||||
);
|
||||
expect(sock.sendMessage).toHaveBeenCalledWith("999@s.whatsapp.net", {
|
||||
text: "pong",
|
||||
});
|
||||
|
||||
await listener.close();
|
||||
});
|
||||
});
|
||||
383
src/web/monitor-inbox.part-2.test.ts
Normal file
383
src/web/monitor-inbox.part-2.test.ts
Normal file
@@ -0,0 +1,383 @@
|
||||
import { vi } from "vitest";
|
||||
|
||||
vi.mock("../media/store.js", () => ({
|
||||
saveMediaBuffer: vi.fn().mockResolvedValue({
|
||||
id: "mid",
|
||||
path: "/tmp/mid",
|
||||
size: 1,
|
||||
contentType: "image/jpeg",
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockLoadConfig = vi.fn().mockReturnValue({
|
||||
channels: {
|
||||
whatsapp: {
|
||||
// Allow all in tests by default
|
||||
allowFrom: ["*"],
|
||||
},
|
||||
},
|
||||
messages: {
|
||||
messagePrefix: undefined,
|
||||
responsePrefix: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const readAllowFromStoreMock = vi.fn().mockResolvedValue([]);
|
||||
const upsertPairingRequestMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ code: "PAIRCODE", created: true });
|
||||
|
||||
vi.mock("../config/config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../config/config.js")>();
|
||||
return {
|
||||
...actual,
|
||||
loadConfig: () => mockLoadConfig(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../pairing/pairing-store.js", () => ({
|
||||
readChannelAllowFromStore: (...args: unknown[]) =>
|
||||
readAllowFromStoreMock(...args),
|
||||
upsertChannelPairingRequest: (...args: unknown[]) =>
|
||||
upsertPairingRequestMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./session.js", () => {
|
||||
const { EventEmitter } = require("node:events");
|
||||
const ev = new EventEmitter();
|
||||
const sock = {
|
||||
ev,
|
||||
ws: { close: vi.fn() },
|
||||
sendPresenceUpdate: vi.fn().mockResolvedValue(undefined),
|
||||
sendMessage: vi.fn().mockResolvedValue(undefined),
|
||||
readMessages: vi.fn().mockResolvedValue(undefined),
|
||||
updateMediaMessage: vi.fn(),
|
||||
logger: {},
|
||||
signalRepository: {
|
||||
lidMapping: {
|
||||
getPNForLID: vi.fn().mockResolvedValue(null),
|
||||
},
|
||||
},
|
||||
user: { id: "123@s.whatsapp.net" },
|
||||
};
|
||||
return {
|
||||
createWaSocket: vi.fn().mockResolvedValue(sock),
|
||||
waitForWaConnection: vi.fn().mockResolvedValue(undefined),
|
||||
getStatusCode: vi.fn(() => 500),
|
||||
};
|
||||
});
|
||||
|
||||
const { createWaSocket } = await import("./session.js");
|
||||
const _getSock = () =>
|
||||
(createWaSocket as unknown as () => Promise<ReturnType<typeof mockSock>>)();
|
||||
|
||||
import crypto from "node:crypto";
|
||||
import fsSync from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { resetLogger, setLoggerOverride } from "../logging.js";
|
||||
import { monitorWebInbox, resetWebInboundDedupe } from "./inbound.js";
|
||||
|
||||
const _ACCOUNT_ID = "default";
|
||||
let authDir: string;
|
||||
|
||||
describe("web monitor inbox", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
readAllowFromStoreMock.mockResolvedValue([]);
|
||||
upsertPairingRequestMock.mockResolvedValue({
|
||||
code: "PAIRCODE",
|
||||
created: true,
|
||||
});
|
||||
resetWebInboundDedupe();
|
||||
authDir = fsSync.mkdtempSync(path.join(os.tmpdir(), "clawdbot-auth-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetLogger();
|
||||
setLoggerOverride(null);
|
||||
vi.useRealTimers();
|
||||
fsSync.rmSync(authDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("captures media path for image messages", async () => {
|
||||
const onMessage = vi.fn();
|
||||
const listener = await monitorWebInbox({ verbose: false, onMessage });
|
||||
const sock = await createWaSocket();
|
||||
const upsert = {
|
||||
type: "notify",
|
||||
messages: [
|
||||
{
|
||||
key: { id: "med1", fromMe: false, remoteJid: "888@s.whatsapp.net" },
|
||||
message: { imageMessage: { mimetype: "image/jpeg" } },
|
||||
messageTimestamp: 1_700_000_100,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
sock.ev.emit("messages.upsert", upsert);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(onMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
body: "<media:image>",
|
||||
}),
|
||||
);
|
||||
expect(sock.readMessages).toHaveBeenCalledWith([
|
||||
{
|
||||
remoteJid: "888@s.whatsapp.net",
|
||||
id: "med1",
|
||||
participant: undefined,
|
||||
fromMe: false,
|
||||
},
|
||||
]);
|
||||
expect(sock.sendPresenceUpdate).toHaveBeenCalledWith("available");
|
||||
await listener.close();
|
||||
});
|
||||
|
||||
it("sets gifPlayback on outbound video payloads when requested", async () => {
|
||||
const onMessage = vi.fn();
|
||||
const listener = await monitorWebInbox({ verbose: false, onMessage });
|
||||
const sock = await createWaSocket();
|
||||
const buf = Buffer.from("gifvid");
|
||||
|
||||
await listener.sendMessage("+1555", "gif", buf, "video/mp4", {
|
||||
gifPlayback: true,
|
||||
});
|
||||
|
||||
expect(sock.sendMessage).toHaveBeenCalledWith("1555@s.whatsapp.net", {
|
||||
video: buf,
|
||||
caption: "gif",
|
||||
mimetype: "video/mp4",
|
||||
gifPlayback: true,
|
||||
});
|
||||
|
||||
await listener.close();
|
||||
});
|
||||
|
||||
it("resolves onClose when the socket closes", async () => {
|
||||
const listener = await monitorWebInbox({
|
||||
verbose: false,
|
||||
onMessage: vi.fn(),
|
||||
});
|
||||
const sock = await createWaSocket();
|
||||
const reasonPromise = listener.onClose;
|
||||
sock.ev.emit("connection.update", {
|
||||
connection: "close",
|
||||
lastDisconnect: { error: { output: { statusCode: 500 } } },
|
||||
});
|
||||
await expect(reasonPromise).resolves.toEqual(
|
||||
expect.objectContaining({ status: 500, isLoggedOut: false }),
|
||||
);
|
||||
await listener.close();
|
||||
});
|
||||
|
||||
it("logs inbound bodies to file", async () => {
|
||||
const logPath = path.join(
|
||||
os.tmpdir(),
|
||||
`clawdbot-log-test-${crypto.randomUUID()}.log`,
|
||||
);
|
||||
setLoggerOverride({ level: "trace", file: logPath });
|
||||
|
||||
const onMessage = vi.fn();
|
||||
const listener = await monitorWebInbox({ verbose: false, onMessage });
|
||||
const sock = await createWaSocket();
|
||||
const upsert = {
|
||||
type: "notify",
|
||||
messages: [
|
||||
{
|
||||
key: { id: "abc", fromMe: false, remoteJid: "999@s.whatsapp.net" },
|
||||
message: { conversation: "ping" },
|
||||
messageTimestamp: 1_700_000_000,
|
||||
pushName: "Tester",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
sock.ev.emit("messages.upsert", upsert);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
const content = fsSync.readFileSync(logPath, "utf-8");
|
||||
expect(content).toMatch(/web-inbound/);
|
||||
expect(content).toMatch(/ping/);
|
||||
await listener.close();
|
||||
});
|
||||
|
||||
it("includes participant when marking group messages read", async () => {
|
||||
const onMessage = vi.fn();
|
||||
const listener = await monitorWebInbox({ verbose: false, onMessage });
|
||||
const sock = await createWaSocket();
|
||||
const upsert = {
|
||||
type: "notify",
|
||||
messages: [
|
||||
{
|
||||
key: {
|
||||
id: "grp1",
|
||||
fromMe: false,
|
||||
remoteJid: "12345-67890@g.us",
|
||||
participant: "111@s.whatsapp.net",
|
||||
},
|
||||
message: { conversation: "group ping" },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
sock.ev.emit("messages.upsert", upsert);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(sock.readMessages).toHaveBeenCalledWith([
|
||||
{
|
||||
remoteJid: "12345-67890@g.us",
|
||||
id: "grp1",
|
||||
participant: "111@s.whatsapp.net",
|
||||
fromMe: false,
|
||||
},
|
||||
]);
|
||||
await listener.close();
|
||||
});
|
||||
|
||||
it("passes through group messages with participant metadata", async () => {
|
||||
const onMessage = vi.fn();
|
||||
const listener = await monitorWebInbox({ verbose: false, onMessage });
|
||||
const sock = await createWaSocket();
|
||||
const upsert = {
|
||||
type: "notify",
|
||||
messages: [
|
||||
{
|
||||
key: {
|
||||
id: "grp2",
|
||||
fromMe: false,
|
||||
remoteJid: "99999@g.us",
|
||||
participant: "777@s.whatsapp.net",
|
||||
},
|
||||
pushName: "Alice",
|
||||
message: {
|
||||
extendedTextMessage: {
|
||||
text: "@bot ping",
|
||||
contextInfo: { mentionedJid: ["123@s.whatsapp.net"] },
|
||||
},
|
||||
},
|
||||
messageTimestamp: 1_700_000_000,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
sock.ev.emit("messages.upsert", upsert);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(onMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
chatType: "group",
|
||||
conversationId: "99999@g.us",
|
||||
senderE164: "+777",
|
||||
mentionedJids: ["123@s.whatsapp.net"],
|
||||
}),
|
||||
);
|
||||
await listener.close();
|
||||
});
|
||||
|
||||
it("unwraps ephemeral messages, preserves mentions, and still delivers group pings", async () => {
|
||||
const onMessage = vi.fn();
|
||||
const listener = await monitorWebInbox({ verbose: false, onMessage });
|
||||
const sock = await createWaSocket();
|
||||
const upsert = {
|
||||
type: "notify",
|
||||
messages: [
|
||||
{
|
||||
key: {
|
||||
id: "grp-ephem",
|
||||
fromMe: false,
|
||||
remoteJid: "424242@g.us",
|
||||
participant: "888@s.whatsapp.net",
|
||||
},
|
||||
message: {
|
||||
ephemeralMessage: {
|
||||
message: {
|
||||
extendedTextMessage: {
|
||||
text: "oh hey @Clawd UK !",
|
||||
contextInfo: { mentionedJid: ["123@s.whatsapp.net"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
sock.ev.emit("messages.upsert", upsert);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(onMessage).toHaveBeenCalledTimes(1);
|
||||
expect(onMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
chatType: "group",
|
||||
conversationId: "424242@g.us",
|
||||
body: "oh hey @Clawd UK !",
|
||||
mentionedJids: ["123@s.whatsapp.net"],
|
||||
senderE164: "+888",
|
||||
}),
|
||||
);
|
||||
|
||||
await listener.close();
|
||||
});
|
||||
|
||||
it("still forwards group messages (with sender info) even when allowFrom is restrictive", async () => {
|
||||
mockLoadConfig.mockReturnValue({
|
||||
channels: {
|
||||
whatsapp: {
|
||||
// does not include +777
|
||||
allowFrom: ["+111"],
|
||||
groupPolicy: "open",
|
||||
},
|
||||
},
|
||||
messages: {
|
||||
messagePrefix: undefined,
|
||||
responsePrefix: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const onMessage = vi.fn();
|
||||
const listener = await monitorWebInbox({ verbose: false, onMessage });
|
||||
const sock = await createWaSocket();
|
||||
const upsert = {
|
||||
type: "notify",
|
||||
messages: [
|
||||
{
|
||||
key: {
|
||||
id: "grp-allow",
|
||||
fromMe: false,
|
||||
remoteJid: "55555@g.us",
|
||||
participant: "777@s.whatsapp.net",
|
||||
},
|
||||
message: {
|
||||
extendedTextMessage: {
|
||||
text: "@bot hi",
|
||||
contextInfo: { mentionedJid: ["123@s.whatsapp.net"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
sock.ev.emit("messages.upsert", upsert);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(onMessage).toHaveBeenCalledTimes(1);
|
||||
expect(onMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
chatType: "group",
|
||||
from: "55555@g.us",
|
||||
senderE164: "+777",
|
||||
senderJid: "777@s.whatsapp.net",
|
||||
mentionedJids: ["123@s.whatsapp.net"],
|
||||
selfE164: "+123",
|
||||
selfJid: "123@s.whatsapp.net",
|
||||
}),
|
||||
);
|
||||
|
||||
await listener.close();
|
||||
});
|
||||
});
|
||||
469
src/web/monitor-inbox.part-3.test.ts
Normal file
469
src/web/monitor-inbox.part-3.test.ts
Normal file
@@ -0,0 +1,469 @@
|
||||
import { vi } from "vitest";
|
||||
|
||||
vi.mock("../media/store.js", () => ({
|
||||
saveMediaBuffer: vi.fn().mockResolvedValue({
|
||||
id: "mid",
|
||||
path: "/tmp/mid",
|
||||
size: 1,
|
||||
contentType: "image/jpeg",
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockLoadConfig = vi.fn().mockReturnValue({
|
||||
channels: {
|
||||
whatsapp: {
|
||||
// Allow all in tests by default
|
||||
allowFrom: ["*"],
|
||||
},
|
||||
},
|
||||
messages: {
|
||||
messagePrefix: undefined,
|
||||
responsePrefix: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const readAllowFromStoreMock = vi.fn().mockResolvedValue([]);
|
||||
const upsertPairingRequestMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ code: "PAIRCODE", created: true });
|
||||
|
||||
vi.mock("../config/config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../config/config.js")>();
|
||||
return {
|
||||
...actual,
|
||||
loadConfig: () => mockLoadConfig(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../pairing/pairing-store.js", () => ({
|
||||
readChannelAllowFromStore: (...args: unknown[]) =>
|
||||
readAllowFromStoreMock(...args),
|
||||
upsertChannelPairingRequest: (...args: unknown[]) =>
|
||||
upsertPairingRequestMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./session.js", () => {
|
||||
const { EventEmitter } = require("node:events");
|
||||
const ev = new EventEmitter();
|
||||
const sock = {
|
||||
ev,
|
||||
ws: { close: vi.fn() },
|
||||
sendPresenceUpdate: vi.fn().mockResolvedValue(undefined),
|
||||
sendMessage: vi.fn().mockResolvedValue(undefined),
|
||||
readMessages: vi.fn().mockResolvedValue(undefined),
|
||||
updateMediaMessage: vi.fn(),
|
||||
logger: {},
|
||||
signalRepository: {
|
||||
lidMapping: {
|
||||
getPNForLID: vi.fn().mockResolvedValue(null),
|
||||
},
|
||||
},
|
||||
user: { id: "123@s.whatsapp.net" },
|
||||
};
|
||||
return {
|
||||
createWaSocket: vi.fn().mockResolvedValue(sock),
|
||||
waitForWaConnection: vi.fn().mockResolvedValue(undefined),
|
||||
getStatusCode: vi.fn(() => 500),
|
||||
};
|
||||
});
|
||||
|
||||
const { createWaSocket } = await import("./session.js");
|
||||
const _getSock = () =>
|
||||
(createWaSocket as unknown as () => Promise<ReturnType<typeof mockSock>>)();
|
||||
|
||||
import fsSync from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { resetLogger, setLoggerOverride } from "../logging.js";
|
||||
import { monitorWebInbox, resetWebInboundDedupe } from "./inbound.js";
|
||||
|
||||
const _ACCOUNT_ID = "default";
|
||||
let authDir: string;
|
||||
|
||||
describe("web monitor inbox", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
readAllowFromStoreMock.mockResolvedValue([]);
|
||||
upsertPairingRequestMock.mockResolvedValue({
|
||||
code: "PAIRCODE",
|
||||
created: true,
|
||||
});
|
||||
resetWebInboundDedupe();
|
||||
authDir = fsSync.mkdtempSync(path.join(os.tmpdir(), "clawdbot-auth-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetLogger();
|
||||
setLoggerOverride(null);
|
||||
vi.useRealTimers();
|
||||
fsSync.rmSync(authDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("blocks messages from unauthorized senders not in allowFrom", async () => {
|
||||
// Test for auto-recovery fix: early allowFrom filtering prevents Bad MAC errors
|
||||
// from unauthorized senders corrupting sessions
|
||||
mockLoadConfig.mockReturnValue({
|
||||
channels: {
|
||||
whatsapp: {
|
||||
// Only allow +111
|
||||
allowFrom: ["+111"],
|
||||
},
|
||||
},
|
||||
messages: {
|
||||
messagePrefix: undefined,
|
||||
responsePrefix: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const onMessage = vi.fn();
|
||||
const listener = await monitorWebInbox({ verbose: false, onMessage });
|
||||
const sock = await createWaSocket();
|
||||
|
||||
// Message from unauthorized sender +999 (not in allowFrom)
|
||||
const upsert = {
|
||||
type: "notify",
|
||||
messages: [
|
||||
{
|
||||
key: {
|
||||
id: "unauth1",
|
||||
fromMe: false,
|
||||
remoteJid: "999@s.whatsapp.net",
|
||||
},
|
||||
message: { conversation: "unauthorized message" },
|
||||
messageTimestamp: 1_700_000_000,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
sock.ev.emit("messages.upsert", upsert);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
// Should NOT call onMessage for unauthorized senders
|
||||
expect(onMessage).not.toHaveBeenCalled();
|
||||
// Should NOT send read receipts for blocked senders (privacy + avoids Baileys Bad MAC churn).
|
||||
expect(sock.readMessages).not.toHaveBeenCalled();
|
||||
expect(sock.sendMessage).toHaveBeenCalledTimes(1);
|
||||
expect(sock.sendMessage).toHaveBeenCalledWith("999@s.whatsapp.net", {
|
||||
text: expect.stringContaining("Your WhatsApp phone number: +999"),
|
||||
});
|
||||
expect(sock.sendMessage).toHaveBeenCalledWith("999@s.whatsapp.net", {
|
||||
text: expect.stringContaining("Pairing code: PAIRCODE"),
|
||||
});
|
||||
|
||||
// Reset mock for other tests
|
||||
mockLoadConfig.mockReturnValue({
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
messages: {
|
||||
messagePrefix: undefined,
|
||||
responsePrefix: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
await listener.close();
|
||||
});
|
||||
|
||||
it("skips read receipts in self-chat mode", async () => {
|
||||
mockLoadConfig.mockReturnValue({
|
||||
channels: {
|
||||
whatsapp: {
|
||||
// Self-chat heuristic: allowFrom includes selfE164 (+123).
|
||||
allowFrom: ["+123"],
|
||||
},
|
||||
},
|
||||
messages: {
|
||||
messagePrefix: undefined,
|
||||
responsePrefix: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const onMessage = vi.fn();
|
||||
const listener = await monitorWebInbox({ verbose: false, onMessage });
|
||||
const sock = await createWaSocket();
|
||||
|
||||
const upsert = {
|
||||
type: "notify",
|
||||
messages: [
|
||||
{
|
||||
key: { id: "self1", fromMe: false, remoteJid: "123@s.whatsapp.net" },
|
||||
message: { conversation: "self ping" },
|
||||
messageTimestamp: 1_700_000_000,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
sock.ev.emit("messages.upsert", upsert);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(onMessage).toHaveBeenCalledTimes(1);
|
||||
expect(onMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ from: "+123", to: "+123", body: "self ping" }),
|
||||
);
|
||||
expect(sock.readMessages).not.toHaveBeenCalled();
|
||||
|
||||
// Reset mock for other tests
|
||||
mockLoadConfig.mockReturnValue({
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
messages: {
|
||||
messagePrefix: undefined,
|
||||
responsePrefix: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
await listener.close();
|
||||
});
|
||||
|
||||
it("lets group messages through even when sender not in allowFrom", async () => {
|
||||
mockLoadConfig.mockReturnValue({
|
||||
channels: { whatsapp: { allowFrom: ["+1234"], groupPolicy: "open" } },
|
||||
messages: {
|
||||
messagePrefix: undefined,
|
||||
responsePrefix: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const onMessage = vi.fn();
|
||||
const listener = await monitorWebInbox({ verbose: false, onMessage });
|
||||
const sock = await createWaSocket();
|
||||
|
||||
const upsert = {
|
||||
type: "notify",
|
||||
messages: [
|
||||
{
|
||||
key: {
|
||||
id: "grp3",
|
||||
fromMe: false,
|
||||
remoteJid: "11111@g.us",
|
||||
participant: "999@s.whatsapp.net",
|
||||
},
|
||||
message: { conversation: "unauthorized group message" },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
sock.ev.emit("messages.upsert", upsert);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(onMessage).toHaveBeenCalledTimes(1);
|
||||
const payload = onMessage.mock.calls[0][0];
|
||||
expect(payload.chatType).toBe("group");
|
||||
expect(payload.senderE164).toBe("+999");
|
||||
|
||||
await listener.close();
|
||||
});
|
||||
|
||||
it("blocks all group messages when groupPolicy is 'disabled'", async () => {
|
||||
mockLoadConfig.mockReturnValue({
|
||||
channels: { whatsapp: { allowFrom: ["+1234"], groupPolicy: "disabled" } },
|
||||
messages: {
|
||||
messagePrefix: undefined,
|
||||
responsePrefix: undefined,
|
||||
timestampPrefix: false,
|
||||
},
|
||||
});
|
||||
|
||||
const onMessage = vi.fn();
|
||||
const listener = await monitorWebInbox({ verbose: false, onMessage });
|
||||
const sock = await createWaSocket();
|
||||
|
||||
const upsert = {
|
||||
type: "notify",
|
||||
messages: [
|
||||
{
|
||||
key: {
|
||||
id: "grp-disabled",
|
||||
fromMe: false,
|
||||
remoteJid: "11111@g.us",
|
||||
participant: "999@s.whatsapp.net",
|
||||
},
|
||||
message: { conversation: "group message should be blocked" },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
sock.ev.emit("messages.upsert", upsert);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
// Should NOT call onMessage because groupPolicy is disabled
|
||||
expect(onMessage).not.toHaveBeenCalled();
|
||||
|
||||
await listener.close();
|
||||
});
|
||||
|
||||
it("blocks group messages from senders not in groupAllowFrom when groupPolicy is 'allowlist'", async () => {
|
||||
mockLoadConfig.mockReturnValue({
|
||||
channels: {
|
||||
whatsapp: {
|
||||
groupAllowFrom: ["+1234"], // Does not include +999
|
||||
groupPolicy: "allowlist",
|
||||
},
|
||||
},
|
||||
messages: {
|
||||
messagePrefix: undefined,
|
||||
responsePrefix: undefined,
|
||||
timestampPrefix: false,
|
||||
},
|
||||
});
|
||||
|
||||
const onMessage = vi.fn();
|
||||
const listener = await monitorWebInbox({ verbose: false, onMessage });
|
||||
const sock = await createWaSocket();
|
||||
|
||||
const upsert = {
|
||||
type: "notify",
|
||||
messages: [
|
||||
{
|
||||
key: {
|
||||
id: "grp-allowlist-blocked",
|
||||
fromMe: false,
|
||||
remoteJid: "11111@g.us",
|
||||
participant: "999@s.whatsapp.net",
|
||||
},
|
||||
message: { conversation: "unauthorized group sender" },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
sock.ev.emit("messages.upsert", upsert);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
// Should NOT call onMessage because sender +999 not in groupAllowFrom
|
||||
expect(onMessage).not.toHaveBeenCalled();
|
||||
|
||||
await listener.close();
|
||||
});
|
||||
|
||||
it("allows group messages from senders in groupAllowFrom when groupPolicy is 'allowlist'", async () => {
|
||||
mockLoadConfig.mockReturnValue({
|
||||
channels: {
|
||||
whatsapp: {
|
||||
groupAllowFrom: ["+15551234567"], // Includes the sender
|
||||
groupPolicy: "allowlist",
|
||||
},
|
||||
},
|
||||
messages: {
|
||||
messagePrefix: undefined,
|
||||
responsePrefix: undefined,
|
||||
timestampPrefix: false,
|
||||
},
|
||||
});
|
||||
|
||||
const onMessage = vi.fn();
|
||||
const listener = await monitorWebInbox({ verbose: false, onMessage });
|
||||
const sock = await createWaSocket();
|
||||
|
||||
const upsert = {
|
||||
type: "notify",
|
||||
messages: [
|
||||
{
|
||||
key: {
|
||||
id: "grp-allowlist-allowed",
|
||||
fromMe: false,
|
||||
remoteJid: "11111@g.us",
|
||||
participant: "15551234567@s.whatsapp.net",
|
||||
},
|
||||
message: { conversation: "authorized group sender" },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
sock.ev.emit("messages.upsert", upsert);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
// Should call onMessage because sender is in groupAllowFrom
|
||||
expect(onMessage).toHaveBeenCalledTimes(1);
|
||||
const payload = onMessage.mock.calls[0][0];
|
||||
expect(payload.chatType).toBe("group");
|
||||
expect(payload.senderE164).toBe("+15551234567");
|
||||
|
||||
await listener.close();
|
||||
});
|
||||
|
||||
it("allows all group senders with wildcard in groupPolicy allowlist", async () => {
|
||||
mockLoadConfig.mockReturnValue({
|
||||
channels: {
|
||||
whatsapp: {
|
||||
groupAllowFrom: ["*"], // Wildcard allows everyone
|
||||
groupPolicy: "allowlist",
|
||||
},
|
||||
},
|
||||
messages: {
|
||||
messagePrefix: undefined,
|
||||
responsePrefix: undefined,
|
||||
timestampPrefix: false,
|
||||
},
|
||||
});
|
||||
|
||||
const onMessage = vi.fn();
|
||||
const listener = await monitorWebInbox({ verbose: false, onMessage });
|
||||
const sock = await createWaSocket();
|
||||
|
||||
const upsert = {
|
||||
type: "notify",
|
||||
messages: [
|
||||
{
|
||||
key: {
|
||||
id: "grp-wildcard-test",
|
||||
fromMe: false,
|
||||
remoteJid: "22222@g.us",
|
||||
participant: "9999999999@s.whatsapp.net", // Random sender
|
||||
},
|
||||
message: { conversation: "wildcard group sender" },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
sock.ev.emit("messages.upsert", upsert);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
// Should call onMessage because wildcard allows all senders
|
||||
expect(onMessage).toHaveBeenCalledTimes(1);
|
||||
const payload = onMessage.mock.calls[0][0];
|
||||
expect(payload.chatType).toBe("group");
|
||||
|
||||
await listener.close();
|
||||
});
|
||||
|
||||
it("blocks group messages when groupPolicy allowlist has no groupAllowFrom", async () => {
|
||||
mockLoadConfig.mockReturnValue({
|
||||
channels: {
|
||||
whatsapp: {
|
||||
groupPolicy: "allowlist",
|
||||
},
|
||||
},
|
||||
messages: {
|
||||
messagePrefix: undefined,
|
||||
responsePrefix: undefined,
|
||||
timestampPrefix: false,
|
||||
},
|
||||
});
|
||||
|
||||
const onMessage = vi.fn();
|
||||
const listener = await monitorWebInbox({ verbose: false, onMessage });
|
||||
const sock = await createWaSocket();
|
||||
|
||||
const upsert = {
|
||||
type: "notify",
|
||||
messages: [
|
||||
{
|
||||
key: {
|
||||
id: "grp-allowlist-empty",
|
||||
fromMe: false,
|
||||
remoteJid: "11111@g.us",
|
||||
participant: "999@s.whatsapp.net",
|
||||
},
|
||||
message: { conversation: "blocked by empty allowlist" },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
sock.ev.emit("messages.upsert", upsert);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(onMessage).not.toHaveBeenCalled();
|
||||
|
||||
await listener.close();
|
||||
});
|
||||
});
|
||||
481
src/web/monitor-inbox.part-4.test.ts
Normal file
481
src/web/monitor-inbox.part-4.test.ts
Normal file
@@ -0,0 +1,481 @@
|
||||
import { vi } from "vitest";
|
||||
|
||||
vi.mock("../media/store.js", () => ({
|
||||
saveMediaBuffer: vi.fn().mockResolvedValue({
|
||||
id: "mid",
|
||||
path: "/tmp/mid",
|
||||
size: 1,
|
||||
contentType: "image/jpeg",
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockLoadConfig = vi.fn().mockReturnValue({
|
||||
channels: {
|
||||
whatsapp: {
|
||||
// Allow all in tests by default
|
||||
allowFrom: ["*"],
|
||||
},
|
||||
},
|
||||
messages: {
|
||||
messagePrefix: undefined,
|
||||
responsePrefix: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const readAllowFromStoreMock = vi.fn().mockResolvedValue([]);
|
||||
const upsertPairingRequestMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ code: "PAIRCODE", created: true });
|
||||
|
||||
vi.mock("../config/config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../config/config.js")>();
|
||||
return {
|
||||
...actual,
|
||||
loadConfig: () => mockLoadConfig(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../pairing/pairing-store.js", () => ({
|
||||
readChannelAllowFromStore: (...args: unknown[]) =>
|
||||
readAllowFromStoreMock(...args),
|
||||
upsertChannelPairingRequest: (...args: unknown[]) =>
|
||||
upsertPairingRequestMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./session.js", () => {
|
||||
const { EventEmitter } = require("node:events");
|
||||
const ev = new EventEmitter();
|
||||
const sock = {
|
||||
ev,
|
||||
ws: { close: vi.fn() },
|
||||
sendPresenceUpdate: vi.fn().mockResolvedValue(undefined),
|
||||
sendMessage: vi.fn().mockResolvedValue(undefined),
|
||||
readMessages: vi.fn().mockResolvedValue(undefined),
|
||||
updateMediaMessage: vi.fn(),
|
||||
logger: {},
|
||||
signalRepository: {
|
||||
lidMapping: {
|
||||
getPNForLID: vi.fn().mockResolvedValue(null),
|
||||
},
|
||||
},
|
||||
user: { id: "123@s.whatsapp.net" },
|
||||
};
|
||||
return {
|
||||
createWaSocket: vi.fn().mockResolvedValue(sock),
|
||||
waitForWaConnection: vi.fn().mockResolvedValue(undefined),
|
||||
getStatusCode: vi.fn(() => 500),
|
||||
};
|
||||
});
|
||||
|
||||
const { createWaSocket } = await import("./session.js");
|
||||
const _getSock = () =>
|
||||
(createWaSocket as unknown as () => Promise<ReturnType<typeof mockSock>>)();
|
||||
|
||||
import fsSync from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { resetLogger, setLoggerOverride } from "../logging.js";
|
||||
import { monitorWebInbox, resetWebInboundDedupe } from "./inbound.js";
|
||||
|
||||
const ACCOUNT_ID = "default";
|
||||
let authDir: string;
|
||||
|
||||
describe("web monitor inbox", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
readAllowFromStoreMock.mockResolvedValue([]);
|
||||
upsertPairingRequestMock.mockResolvedValue({
|
||||
code: "PAIRCODE",
|
||||
created: true,
|
||||
});
|
||||
resetWebInboundDedupe();
|
||||
authDir = fsSync.mkdtempSync(path.join(os.tmpdir(), "clawdbot-auth-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetLogger();
|
||||
setLoggerOverride(null);
|
||||
vi.useRealTimers();
|
||||
fsSync.rmSync(authDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("allows messages from senders in allowFrom list", async () => {
|
||||
mockLoadConfig.mockReturnValue({
|
||||
channels: {
|
||||
whatsapp: {
|
||||
// Allow +999
|
||||
allowFrom: ["+111", "+999"],
|
||||
},
|
||||
},
|
||||
messages: {
|
||||
messagePrefix: undefined,
|
||||
responsePrefix: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const onMessage = vi.fn();
|
||||
const listener = await monitorWebInbox({ verbose: false, onMessage });
|
||||
const sock = await createWaSocket();
|
||||
|
||||
const upsert = {
|
||||
type: "notify",
|
||||
messages: [
|
||||
{
|
||||
key: { id: "auth1", fromMe: false, remoteJid: "999@s.whatsapp.net" },
|
||||
message: { conversation: "authorized message" },
|
||||
messageTimestamp: 1_700_000_000,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
sock.ev.emit("messages.upsert", upsert);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
// Should call onMessage for authorized senders
|
||||
expect(onMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
body: "authorized message",
|
||||
from: "+999",
|
||||
senderE164: "+999",
|
||||
}),
|
||||
);
|
||||
|
||||
// Reset mock for other tests
|
||||
mockLoadConfig.mockReturnValue({
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
messages: {
|
||||
messagePrefix: undefined,
|
||||
responsePrefix: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
await listener.close();
|
||||
});
|
||||
|
||||
it("allows same-phone messages even if not in allowFrom", async () => {
|
||||
// Same-phone mode: when from === selfJid, should always be allowed
|
||||
// This allows users to message themselves even with restrictive allowFrom
|
||||
mockLoadConfig.mockReturnValue({
|
||||
channels: {
|
||||
whatsapp: {
|
||||
// Only allow +111, but self is +123
|
||||
allowFrom: ["+111"],
|
||||
},
|
||||
},
|
||||
messages: {
|
||||
messagePrefix: undefined,
|
||||
responsePrefix: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const onMessage = vi.fn();
|
||||
const listener = await monitorWebInbox({ verbose: false, onMessage });
|
||||
const sock = await createWaSocket();
|
||||
|
||||
// Message from self (sock.user.id is "123@s.whatsapp.net" in mock)
|
||||
const upsert = {
|
||||
type: "notify",
|
||||
messages: [
|
||||
{
|
||||
key: { id: "self1", fromMe: false, remoteJid: "123@s.whatsapp.net" },
|
||||
message: { conversation: "self message" },
|
||||
messageTimestamp: 1_700_000_000,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
sock.ev.emit("messages.upsert", upsert);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
// Should allow self-messages even if not in allowFrom
|
||||
expect(onMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ body: "self message", from: "+123" }),
|
||||
);
|
||||
|
||||
// Reset mock for other tests
|
||||
mockLoadConfig.mockReturnValue({
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
messages: {
|
||||
messagePrefix: undefined,
|
||||
responsePrefix: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
await listener.close();
|
||||
});
|
||||
|
||||
it("locks down when no config is present (pairing for unknown senders)", async () => {
|
||||
// No config file => locked-down defaults apply (pairing for unknown senders)
|
||||
mockLoadConfig.mockReturnValue({});
|
||||
upsertPairingRequestMock
|
||||
.mockResolvedValueOnce({ code: "PAIRCODE", created: true })
|
||||
.mockResolvedValueOnce({ code: "PAIRCODE", created: false });
|
||||
|
||||
const onMessage = vi.fn();
|
||||
const listener = await monitorWebInbox({ verbose: false, onMessage });
|
||||
const sock = await createWaSocket();
|
||||
|
||||
// Message from someone else should be blocked
|
||||
const upsertBlocked = {
|
||||
type: "notify",
|
||||
messages: [
|
||||
{
|
||||
key: {
|
||||
id: "no-config-1",
|
||||
fromMe: false,
|
||||
remoteJid: "999@s.whatsapp.net",
|
||||
},
|
||||
message: { conversation: "ping" },
|
||||
messageTimestamp: 1_700_000_000,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
sock.ev.emit("messages.upsert", upsertBlocked);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
expect(onMessage).not.toHaveBeenCalled();
|
||||
expect(sock.sendMessage).toHaveBeenCalledTimes(1);
|
||||
expect(sock.sendMessage).toHaveBeenCalledWith("999@s.whatsapp.net", {
|
||||
text: expect.stringContaining("Your WhatsApp phone number: +999"),
|
||||
});
|
||||
expect(sock.sendMessage).toHaveBeenCalledWith("999@s.whatsapp.net", {
|
||||
text: expect.stringContaining("Pairing code: PAIRCODE"),
|
||||
});
|
||||
|
||||
const upsertBlockedAgain = {
|
||||
type: "notify",
|
||||
messages: [
|
||||
{
|
||||
key: {
|
||||
id: "no-config-1b",
|
||||
fromMe: false,
|
||||
remoteJid: "999@s.whatsapp.net",
|
||||
},
|
||||
message: { conversation: "ping again" },
|
||||
messageTimestamp: 1_700_000_002,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
sock.ev.emit("messages.upsert", upsertBlockedAgain);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
expect(onMessage).not.toHaveBeenCalled();
|
||||
expect(sock.sendMessage).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Message from self should be allowed
|
||||
const upsertSelf = {
|
||||
type: "notify",
|
||||
messages: [
|
||||
{
|
||||
key: {
|
||||
id: "no-config-2",
|
||||
fromMe: false,
|
||||
remoteJid: "123@s.whatsapp.net",
|
||||
},
|
||||
message: { conversation: "self ping" },
|
||||
messageTimestamp: 1_700_000_001,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
sock.ev.emit("messages.upsert", upsertSelf);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(onMessage).toHaveBeenCalledTimes(1);
|
||||
expect(onMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
body: "self ping",
|
||||
from: "+123",
|
||||
to: "+123",
|
||||
}),
|
||||
);
|
||||
|
||||
// Reset mock for other tests
|
||||
mockLoadConfig.mockReturnValue({
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
messages: {
|
||||
messagePrefix: undefined,
|
||||
responsePrefix: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
await listener.close();
|
||||
});
|
||||
|
||||
it("skips pairing replies for outbound DMs in same-phone mode", async () => {
|
||||
mockLoadConfig.mockReturnValue({
|
||||
channels: {
|
||||
whatsapp: {
|
||||
dmPolicy: "pairing",
|
||||
selfChatMode: true,
|
||||
},
|
||||
},
|
||||
messages: {
|
||||
messagePrefix: undefined,
|
||||
responsePrefix: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const onMessage = vi.fn();
|
||||
const listener = await monitorWebInbox({ verbose: false, onMessage });
|
||||
const sock = await createWaSocket();
|
||||
|
||||
const upsert = {
|
||||
type: "notify",
|
||||
messages: [
|
||||
{
|
||||
key: {
|
||||
id: "fromme-1",
|
||||
fromMe: true,
|
||||
remoteJid: "999@s.whatsapp.net",
|
||||
},
|
||||
message: { conversation: "hello" },
|
||||
messageTimestamp: 1_700_000_000,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
sock.ev.emit("messages.upsert", upsert);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(onMessage).not.toHaveBeenCalled();
|
||||
expect(upsertPairingRequestMock).not.toHaveBeenCalled();
|
||||
expect(sock.sendMessage).not.toHaveBeenCalled();
|
||||
|
||||
mockLoadConfig.mockReturnValue({
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
messages: {
|
||||
messagePrefix: undefined,
|
||||
responsePrefix: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
await listener.close();
|
||||
});
|
||||
|
||||
it("skips pairing replies for outbound DMs when same-phone mode is disabled", async () => {
|
||||
mockLoadConfig.mockReturnValue({
|
||||
channels: {
|
||||
whatsapp: {
|
||||
dmPolicy: "pairing",
|
||||
selfChatMode: false,
|
||||
},
|
||||
},
|
||||
messages: {
|
||||
messagePrefix: undefined,
|
||||
responsePrefix: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const onMessage = vi.fn();
|
||||
const listener = await monitorWebInbox({ verbose: false, onMessage });
|
||||
const sock = await createWaSocket();
|
||||
|
||||
const upsert = {
|
||||
type: "notify",
|
||||
messages: [
|
||||
{
|
||||
key: {
|
||||
id: "fromme-2",
|
||||
fromMe: true,
|
||||
remoteJid: "999@s.whatsapp.net",
|
||||
},
|
||||
message: { conversation: "hello again" },
|
||||
messageTimestamp: 1_700_000_000,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
sock.ev.emit("messages.upsert", upsert);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(onMessage).not.toHaveBeenCalled();
|
||||
expect(upsertPairingRequestMock).not.toHaveBeenCalled();
|
||||
expect(sock.sendMessage).not.toHaveBeenCalled();
|
||||
|
||||
mockLoadConfig.mockReturnValue({
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
messages: {
|
||||
messagePrefix: undefined,
|
||||
responsePrefix: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
await listener.close();
|
||||
});
|
||||
|
||||
it("handles append messages by marking them read but skipping auto-reply", async () => {
|
||||
const onMessage = vi.fn();
|
||||
const listener = await monitorWebInbox({ verbose: false, onMessage });
|
||||
const sock = await createWaSocket();
|
||||
|
||||
const upsert = {
|
||||
type: "append",
|
||||
messages: [
|
||||
{
|
||||
key: {
|
||||
id: "history1",
|
||||
fromMe: false,
|
||||
remoteJid: "999@s.whatsapp.net",
|
||||
},
|
||||
message: { conversation: "old message" },
|
||||
messageTimestamp: 1_700_000_000,
|
||||
pushName: "History Sender",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
sock.ev.emit("messages.upsert", upsert);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
// Verify it WAS marked as read
|
||||
expect(sock.readMessages).toHaveBeenCalledWith([
|
||||
{
|
||||
remoteJid: "999@s.whatsapp.net",
|
||||
id: "history1",
|
||||
participant: undefined,
|
||||
fromMe: false,
|
||||
},
|
||||
]);
|
||||
|
||||
// Verify it WAS NOT passed to onMessage
|
||||
expect(onMessage).not.toHaveBeenCalled();
|
||||
|
||||
await listener.close();
|
||||
});
|
||||
|
||||
it("normalizes participant phone numbers to JIDs in sendReaction", async () => {
|
||||
const listener = await monitorWebInbox({
|
||||
verbose: false,
|
||||
onMessage: vi.fn(),
|
||||
accountId: ACCOUNT_ID,
|
||||
authDir,
|
||||
});
|
||||
const sock = await createWaSocket();
|
||||
|
||||
await listener.sendReaction(
|
||||
"12345@g.us",
|
||||
"msg123",
|
||||
"👍",
|
||||
false,
|
||||
"+6421000000",
|
||||
);
|
||||
|
||||
expect(sock.sendMessage).toHaveBeenCalledWith("12345@g.us", {
|
||||
react: {
|
||||
text: "👍",
|
||||
key: {
|
||||
remoteJid: "12345@g.us",
|
||||
id: "msg123",
|
||||
fromMe: false,
|
||||
participant: "6421000000@s.whatsapp.net",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await listener.close();
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user