refactor: move WhatsApp channel implementation to extensions/ (#45725)

* refactor: move WhatsApp channel from src/web/ to extensions/whatsapp/

Move all WhatsApp implementation code (77 source/test files + 9 channel
plugin files) from src/web/ and src/channels/plugins/*/whatsapp* to
extensions/whatsapp/src/.

- Leave thin re-export shims at all original locations so cross-cutting
  imports continue to resolve
- Update plugin-sdk/whatsapp.ts to only re-export generic framework
  utilities; channel-specific functions imported locally by the extension
- Update vi.mock paths in 15 cross-cutting test files
- Rename outbound.ts -> send.ts to match extension naming conventions
  and avoid false positive in cfg-threading guard test
- Widen tsconfig.plugin-sdk.dts.json rootDir to support shim->extension
  cross-directory references

Part of the core-channels-to-extensions migration (PR 6/10).

* style: format WhatsApp extension files

* fix: correct stale import paths in WhatsApp extension tests

Fix vi.importActual, test mock, and hardcoded source paths that weren't
updated during the file move:
- media.test.ts: vi.importActual path
- onboarding.test.ts: vi.importActual path
- test-helpers.ts: test/mocks/baileys.js path
- monitor-inbox.test-harness.ts: incomplete media/store mock
- login.test.ts: hardcoded source file path
- message-action-runner.media.test.ts: vi.mock/importActual path
This commit is contained in:
scoootscooob 2026-03-14 02:44:55 -07:00 committed by GitHub
parent 0ce23dc62d
commit 16505718e8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
155 changed files with 6959 additions and 6825 deletions

View File

@ -0,0 +1,166 @@
import fs from "node:fs";
import path from "node:path";
import { createAccountListHelpers } from "../../../src/channels/plugins/account-helpers.js";
import type { OpenClawConfig } from "../../../src/config/config.js";
import { resolveOAuthDir } from "../../../src/config/paths.js";
import type { DmPolicy, GroupPolicy, WhatsAppAccountConfig } from "../../../src/config/types.js";
import { resolveAccountEntry } from "../../../src/routing/account-lookup.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js";
import { resolveUserPath } from "../../../src/utils.js";
import { hasWebCredsSync } from "./auth-store.js";
export type ResolvedWhatsAppAccount = {
accountId: string;
name?: string;
enabled: boolean;
sendReadReceipts: boolean;
messagePrefix?: string;
authDir: string;
isLegacyAuthDir: boolean;
selfChatMode?: boolean;
allowFrom?: string[];
groupAllowFrom?: string[];
groupPolicy?: GroupPolicy;
dmPolicy?: DmPolicy;
textChunkLimit?: number;
chunkMode?: "length" | "newline";
mediaMaxMb?: number;
blockStreaming?: boolean;
ackReaction?: WhatsAppAccountConfig["ackReaction"];
groups?: WhatsAppAccountConfig["groups"];
debounceMs?: number;
};
export const DEFAULT_WHATSAPP_MEDIA_MAX_MB = 50;
const { listConfiguredAccountIds, listAccountIds, resolveDefaultAccountId } =
createAccountListHelpers("whatsapp");
export const listWhatsAppAccountIds = listAccountIds;
export const resolveDefaultWhatsAppAccountId = resolveDefaultAccountId;
export function listWhatsAppAuthDirs(cfg: OpenClawConfig): string[] {
const oauthDir = resolveOAuthDir();
const whatsappDir = path.join(oauthDir, "whatsapp");
const authDirs = new Set<string>([oauthDir, path.join(whatsappDir, DEFAULT_ACCOUNT_ID)]);
const accountIds = listConfiguredAccountIds(cfg);
for (const accountId of accountIds) {
authDirs.add(resolveWhatsAppAuthDir({ cfg, accountId }).authDir);
}
try {
const entries = fs.readdirSync(whatsappDir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) {
continue;
}
authDirs.add(path.join(whatsappDir, entry.name));
}
} catch {
// ignore missing dirs
}
return Array.from(authDirs);
}
export function hasAnyWhatsAppAuth(cfg: OpenClawConfig): boolean {
return listWhatsAppAuthDirs(cfg).some((authDir) => hasWebCredsSync(authDir));
}
function resolveAccountConfig(
cfg: OpenClawConfig,
accountId: string,
): WhatsAppAccountConfig | undefined {
return resolveAccountEntry(cfg.channels?.whatsapp?.accounts, accountId);
}
function resolveDefaultAuthDir(accountId: string): string {
return path.join(resolveOAuthDir(), "whatsapp", normalizeAccountId(accountId));
}
function resolveLegacyAuthDir(): string {
// Legacy Baileys creds lived in the same directory as OAuth tokens.
return resolveOAuthDir();
}
function legacyAuthExists(authDir: string): boolean {
try {
return fs.existsSync(path.join(authDir, "creds.json"));
} catch {
return false;
}
}
export function resolveWhatsAppAuthDir(params: { cfg: OpenClawConfig; accountId: string }): {
authDir: string;
isLegacy: boolean;
} {
const accountId = params.accountId.trim() || DEFAULT_ACCOUNT_ID;
const account = resolveAccountConfig(params.cfg, accountId);
const configured = account?.authDir?.trim();
if (configured) {
return { authDir: resolveUserPath(configured), isLegacy: false };
}
const defaultDir = resolveDefaultAuthDir(accountId);
if (accountId === DEFAULT_ACCOUNT_ID) {
const legacyDir = resolveLegacyAuthDir();
if (legacyAuthExists(legacyDir) && !legacyAuthExists(defaultDir)) {
return { authDir: legacyDir, isLegacy: true };
}
}
return { authDir: defaultDir, isLegacy: false };
}
export function resolveWhatsAppAccount(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): ResolvedWhatsAppAccount {
const rootCfg = params.cfg.channels?.whatsapp;
const accountId = params.accountId?.trim() || resolveDefaultWhatsAppAccountId(params.cfg);
const accountCfg = resolveAccountConfig(params.cfg, accountId);
const enabled = accountCfg?.enabled !== false;
const { authDir, isLegacy } = resolveWhatsAppAuthDir({
cfg: params.cfg,
accountId,
});
return {
accountId,
name: accountCfg?.name?.trim() || undefined,
enabled,
sendReadReceipts: accountCfg?.sendReadReceipts ?? rootCfg?.sendReadReceipts ?? true,
messagePrefix:
accountCfg?.messagePrefix ?? rootCfg?.messagePrefix ?? params.cfg.messages?.messagePrefix,
authDir,
isLegacyAuthDir: isLegacy,
selfChatMode: accountCfg?.selfChatMode ?? rootCfg?.selfChatMode,
dmPolicy: accountCfg?.dmPolicy ?? rootCfg?.dmPolicy,
allowFrom: accountCfg?.allowFrom ?? rootCfg?.allowFrom,
groupAllowFrom: accountCfg?.groupAllowFrom ?? rootCfg?.groupAllowFrom,
groupPolicy: accountCfg?.groupPolicy ?? rootCfg?.groupPolicy,
textChunkLimit: accountCfg?.textChunkLimit ?? rootCfg?.textChunkLimit,
chunkMode: accountCfg?.chunkMode ?? rootCfg?.chunkMode,
mediaMaxMb: accountCfg?.mediaMaxMb ?? rootCfg?.mediaMaxMb,
blockStreaming: accountCfg?.blockStreaming ?? rootCfg?.blockStreaming,
ackReaction: accountCfg?.ackReaction ?? rootCfg?.ackReaction,
groups: accountCfg?.groups ?? rootCfg?.groups,
debounceMs: accountCfg?.debounceMs ?? rootCfg?.debounceMs,
};
}
export function resolveWhatsAppMediaMaxBytes(
account: Pick<ResolvedWhatsAppAccount, "mediaMaxMb">,
): number {
const mediaMaxMb =
typeof account.mediaMaxMb === "number" && account.mediaMaxMb > 0
? account.mediaMaxMb
: DEFAULT_WHATSAPP_MEDIA_MAX_MB;
return mediaMaxMb * 1024 * 1024;
}
export function listEnabledWhatsAppAccounts(cfg: OpenClawConfig): ResolvedWhatsAppAccount[] {
return listWhatsAppAccountIds(cfg)
.map((accountId) => resolveWhatsAppAccount({ cfg, accountId }))
.filter((account) => account.enabled);
}

View File

@ -2,7 +2,7 @@ import fs from "node:fs";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { captureEnv } from "../test-utils/env.js"; import { captureEnv } from "../../../src/test-utils/env.js";
import { hasAnyWhatsAppAuth, listWhatsAppAuthDirs } from "./accounts.js"; import { hasAnyWhatsAppAuth, listWhatsAppAuthDirs } from "./accounts.js";
describe("hasAnyWhatsAppAuth", () => { describe("hasAnyWhatsAppAuth", () => {

View File

@ -0,0 +1,84 @@
import { formatCliCommand } from "../../../src/cli/command-format.js";
import type { PollInput } from "../../../src/polls.js";
import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js";
export type ActiveWebSendOptions = {
gifPlayback?: boolean;
accountId?: string;
fileName?: string;
};
export type ActiveWebListener = {
sendMessage: (
to: string,
text: string,
mediaBuffer?: Buffer,
mediaType?: string,
options?: ActiveWebSendOptions,
) => Promise<{ messageId: string }>;
sendPoll: (to: string, poll: PollInput) => Promise<{ messageId: string }>;
sendReaction: (
chatJid: string,
messageId: string,
emoji: string,
fromMe: boolean,
participant?: string,
) => Promise<void>;
sendComposingTo: (to: string) => Promise<void>;
close?: () => Promise<void>;
};
let _currentListener: ActiveWebListener | null = null;
const listeners = new Map<string, ActiveWebListener>();
export function resolveWebAccountId(accountId?: string | null): string {
return (accountId ?? "").trim() || DEFAULT_ACCOUNT_ID;
}
export function requireActiveWebListener(accountId?: string | null): {
accountId: string;
listener: ActiveWebListener;
} {
const id = resolveWebAccountId(accountId);
const listener = listeners.get(id) ?? null;
if (!listener) {
throw new Error(
`No active WhatsApp Web listener (account: ${id}). Start the gateway, then link WhatsApp with: ${formatCliCommand(`openclaw channels login --channel whatsapp --account ${id}`)}.`,
);
}
return { accountId: id, listener };
}
export function setActiveWebListener(listener: ActiveWebListener | null): void;
export function setActiveWebListener(
accountId: string | null | undefined,
listener: ActiveWebListener | null,
): void;
export function setActiveWebListener(
accountIdOrListener: string | ActiveWebListener | null | undefined,
maybeListener?: ActiveWebListener | null,
): void {
const { accountId, listener } =
typeof accountIdOrListener === "string"
? { accountId: accountIdOrListener, listener: maybeListener ?? null }
: {
accountId: DEFAULT_ACCOUNT_ID,
listener: accountIdOrListener ?? null,
};
const id = resolveWebAccountId(accountId);
if (!listener) {
listeners.delete(id);
} else {
listeners.set(id, listener);
}
if (id === DEFAULT_ACCOUNT_ID) {
_currentListener = listener;
}
}
export function getActiveWebListener(accountId?: string | null): ActiveWebListener | null {
const id = resolveWebAccountId(accountId);
return listeners.get(id) ?? null;
}

View File

@ -0,0 +1,72 @@
import { Type } from "@sinclair/typebox";
import type { ChannelAgentTool } from "../../../src/channels/plugins/types.js";
export function createWhatsAppLoginTool(): ChannelAgentTool {
return {
label: "WhatsApp Login",
name: "whatsapp_login",
ownerOnly: true,
description: "Generate a WhatsApp QR code for linking, or wait for the scan to complete.",
// NOTE: Using Type.Unsafe for action enum instead of Type.Union([Type.Literal(...)]
// because Claude API on Vertex AI rejects nested anyOf schemas as invalid JSON Schema.
parameters: Type.Object({
action: Type.Unsafe<"start" | "wait">({
type: "string",
enum: ["start", "wait"],
}),
timeoutMs: Type.Optional(Type.Number()),
force: Type.Optional(Type.Boolean()),
}),
execute: async (_toolCallId, args) => {
const { startWebLoginWithQr, waitForWebLogin } = await import("./login-qr.js");
const action = (args as { action?: string })?.action ?? "start";
if (action === "wait") {
const result = await waitForWebLogin({
timeoutMs:
typeof (args as { timeoutMs?: unknown }).timeoutMs === "number"
? (args as { timeoutMs?: number }).timeoutMs
: undefined,
});
return {
content: [{ type: "text", text: result.message }],
details: { connected: result.connected },
};
}
const result = await startWebLoginWithQr({
timeoutMs:
typeof (args as { timeoutMs?: unknown }).timeoutMs === "number"
? (args as { timeoutMs?: number }).timeoutMs
: undefined,
force:
typeof (args as { force?: unknown }).force === "boolean"
? (args as { force?: boolean }).force
: false,
});
if (!result.qrDataUrl) {
return {
content: [
{
type: "text",
text: result.message,
},
],
details: { qr: false },
};
}
const text = [
result.message,
"",
"Open WhatsApp → Linked Devices and scan:",
"",
`![whatsapp-qr](${result.qrDataUrl})`,
].join("\n");
return {
content: [{ type: "text", text }],
details: { qr: true },
};
},
};
}

View File

@ -0,0 +1,206 @@
import fsSync from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import { formatCliCommand } from "../../../src/cli/command-format.js";
import { resolveOAuthDir } from "../../../src/config/paths.js";
import { info, success } from "../../../src/globals.js";
import { getChildLogger } from "../../../src/logging.js";
import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js";
import { defaultRuntime, type RuntimeEnv } from "../../../src/runtime.js";
import type { WebChannel } from "../../../src/utils.js";
import { jidToE164, resolveUserPath } from "../../../src/utils.js";
export function resolveDefaultWebAuthDir(): string {
return path.join(resolveOAuthDir(), "whatsapp", DEFAULT_ACCOUNT_ID);
}
export const WA_WEB_AUTH_DIR = resolveDefaultWebAuthDir();
export function resolveWebCredsPath(authDir: string): string {
return path.join(authDir, "creds.json");
}
export function resolveWebCredsBackupPath(authDir: string): string {
return path.join(authDir, "creds.json.bak");
}
export function hasWebCredsSync(authDir: string): boolean {
try {
const stats = fsSync.statSync(resolveWebCredsPath(authDir));
return stats.isFile() && stats.size > 1;
} catch {
return false;
}
}
export function readCredsJsonRaw(filePath: string): string | null {
try {
if (!fsSync.existsSync(filePath)) {
return null;
}
const stats = fsSync.statSync(filePath);
if (!stats.isFile() || stats.size <= 1) {
return null;
}
return fsSync.readFileSync(filePath, "utf-8");
} catch {
return null;
}
}
export function maybeRestoreCredsFromBackup(authDir: string): void {
const logger = getChildLogger({ module: "web-session" });
try {
const credsPath = resolveWebCredsPath(authDir);
const backupPath = resolveWebCredsBackupPath(authDir);
const raw = readCredsJsonRaw(credsPath);
if (raw) {
// Validate that creds.json is parseable.
JSON.parse(raw);
return;
}
const backupRaw = readCredsJsonRaw(backupPath);
if (!backupRaw) {
return;
}
// Ensure backup is parseable before restoring.
JSON.parse(backupRaw);
fsSync.copyFileSync(backupPath, credsPath);
try {
fsSync.chmodSync(credsPath, 0o600);
} catch {
// best-effort on platforms that support it
}
logger.warn({ credsPath }, "restored corrupted WhatsApp creds.json from backup");
} catch {
// ignore
}
}
export async function webAuthExists(authDir: string = resolveDefaultWebAuthDir()) {
const resolvedAuthDir = resolveUserPath(authDir);
maybeRestoreCredsFromBackup(resolvedAuthDir);
const credsPath = resolveWebCredsPath(resolvedAuthDir);
try {
await fs.access(resolvedAuthDir);
} catch {
return false;
}
try {
const stats = await fs.stat(credsPath);
if (!stats.isFile() || stats.size <= 1) {
return false;
}
const raw = await fs.readFile(credsPath, "utf-8");
JSON.parse(raw);
return true;
} catch {
return false;
}
}
async function clearLegacyBaileysAuthState(authDir: string) {
const entries = await fs.readdir(authDir, { withFileTypes: true });
const shouldDelete = (name: string) => {
if (name === "oauth.json") {
return false;
}
if (name === "creds.json" || name === "creds.json.bak") {
return true;
}
if (!name.endsWith(".json")) {
return false;
}
return /^(app-state-sync|session|sender-key|pre-key)-/.test(name);
};
await Promise.all(
entries.map(async (entry) => {
if (!entry.isFile()) {
return;
}
if (!shouldDelete(entry.name)) {
return;
}
await fs.rm(path.join(authDir, entry.name), { force: true });
}),
);
}
export async function logoutWeb(params: {
authDir?: string;
isLegacyAuthDir?: boolean;
runtime?: RuntimeEnv;
}) {
const runtime = params.runtime ?? defaultRuntime;
const resolvedAuthDir = resolveUserPath(params.authDir ?? resolveDefaultWebAuthDir());
const exists = await webAuthExists(resolvedAuthDir);
if (!exists) {
runtime.log(info("No WhatsApp Web session found; nothing to delete."));
return false;
}
if (params.isLegacyAuthDir) {
await clearLegacyBaileysAuthState(resolvedAuthDir);
} else {
await fs.rm(resolvedAuthDir, { recursive: true, force: true });
}
runtime.log(success("Cleared WhatsApp Web credentials."));
return true;
}
export function readWebSelfId(authDir: string = resolveDefaultWebAuthDir()) {
// Read the cached WhatsApp Web identity (jid + E.164) from disk if present.
try {
const credsPath = resolveWebCredsPath(resolveUserPath(authDir));
if (!fsSync.existsSync(credsPath)) {
return { e164: null, jid: null } as const;
}
const raw = fsSync.readFileSync(credsPath, "utf-8");
const parsed = JSON.parse(raw) as { me?: { id?: string } } | undefined;
const jid = parsed?.me?.id ?? null;
const e164 = jid ? jidToE164(jid, { authDir }) : null;
return { e164, jid } as const;
} catch {
return { e164: null, jid: null } as const;
}
}
/**
* Return the age (in milliseconds) of the cached WhatsApp web auth state, or null when missing.
* Helpful for heartbeats/observability to spot stale credentials.
*/
export function getWebAuthAgeMs(authDir: string = resolveDefaultWebAuthDir()): number | null {
try {
const stats = fsSync.statSync(resolveWebCredsPath(resolveUserPath(authDir)));
return Date.now() - stats.mtimeMs;
} catch {
return null;
}
}
export function logWebSelfId(
authDir: string = resolveDefaultWebAuthDir(),
runtime: RuntimeEnv = defaultRuntime,
includeChannelPrefix = false,
) {
// Human-friendly log of the currently linked personal web session.
const { e164, jid } = readWebSelfId(authDir);
const details = e164 || jid ? `${e164 ?? "unknown"}${jid ? ` (jid ${jid})` : ""}` : "unknown";
const prefix = includeChannelPrefix ? "Web Channel: " : "";
runtime.log(info(`${prefix}${details}`));
}
export async function pickWebChannel(
pref: WebChannel | "auto",
authDir: string = resolveDefaultWebAuthDir(),
): Promise<WebChannel> {
const choice: WebChannel = pref === "auto" ? "web" : pref;
const hasWeb = await webAuthExists(authDir);
if (!hasWeb) {
throw new Error(
`No WhatsApp Web session found. Run \`${formatCliCommand("openclaw channels login --channel whatsapp --verbose")}\` to link.`,
);
}
return choice;
}

View File

@ -1,6 +1,6 @@
import "./test-helpers.js"; import "./test-helpers.js";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js"; import type { OpenClawConfig } from "../../../src/config/config.js";
import { import {
monitorWebChannelWithCapture, monitorWebChannelWithCapture,
sendWebDirectInboundAndCollectSessionKeys, sendWebDirectInboundAndCollectSessionKeys,

View File

@ -0,0 +1,7 @@
export { HEARTBEAT_PROMPT, stripHeartbeatToken } from "../../../src/auto-reply/heartbeat.js";
export { HEARTBEAT_TOKEN, SILENT_REPLY_TOKEN } from "../../../src/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";

View File

@ -3,9 +3,9 @@ import fs from "node:fs/promises";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { afterAll, afterEach, beforeAll, beforeEach, vi } from "vitest"; import { afterAll, afterEach, beforeAll, beforeEach, vi } from "vitest";
import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js"; import { resetInboundDedupe } from "../../../src/auto-reply/reply/inbound-dedupe.js";
import * as ssrf from "../infra/net/ssrf.js"; import * as ssrf from "../../../src/infra/net/ssrf.js";
import { resetLogger, setLoggerOverride } from "../logging.js"; import { resetLogger, setLoggerOverride } from "../../../src/logging.js";
import type { WebInboundMessage, WebListenerCloseReason } from "./inbound.js"; import type { WebInboundMessage, WebListenerCloseReason } from "./inbound.js";
import { import {
resetBaileysMocks as _resetBaileysMocks, resetBaileysMocks as _resetBaileysMocks,
@ -29,7 +29,7 @@ type MockWebListener = {
export const TEST_NET_IP = "203.0.113.10"; export const TEST_NET_IP = "203.0.113.10";
vi.mock("../agents/pi-embedded.js", () => ({ vi.mock("../../../src/agents/pi-embedded.js", () => ({
abortEmbeddedPiRun: vi.fn().mockReturnValue(false), abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
isEmbeddedPiRunActive: vi.fn().mockReturnValue(false), isEmbeddedPiRunActive: vi.fn().mockReturnValue(false),
isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false), isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false),

View File

@ -0,0 +1 @@
export * from "./auto-reply.impl.js";

View File

@ -2,10 +2,10 @@ import "./test-helpers.js";
import crypto from "node:crypto"; import crypto from "node:crypto";
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import { beforeAll, describe, expect, it, vi } from "vitest"; import { beforeAll, describe, expect, it, vi } from "vitest";
import { escapeRegExp, formatEnvelopeTimestamp } from "../../test/helpers/envelope-timestamp.js"; import type { OpenClawConfig } from "../../../src/config/config.js";
import type { OpenClawConfig } from "../config/config.js"; import { setLoggerOverride } from "../../../src/logging.js";
import { setLoggerOverride } from "../logging.js"; import { withEnvAsync } from "../../../src/test-utils/env.js";
import { withEnvAsync } from "../test-utils/env.js"; import { escapeRegExp, formatEnvelopeTimestamp } from "../../../test/helpers/envelope-timestamp.js";
import { import {
createMockWebListener, createMockWebListener,
createWebListenerFactoryCapture, createWebListenerFactoryCapture,

View File

@ -1,7 +1,7 @@
import "./test-helpers.js"; import "./test-helpers.js";
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js"; import type { OpenClawConfig } from "../../../src/config/config.js";
import { installWebAutoReplyUnitTestHooks, makeSessionStore } from "./auto-reply.test-harness.js"; import { installWebAutoReplyUnitTestHooks, makeSessionStore } from "./auto-reply.test-harness.js";
import { buildMentionConfig } from "./auto-reply/mentions.js"; import { buildMentionConfig } from "./auto-reply/mentions.js";
import { createEchoTracker } from "./auto-reply/monitor/echo.js"; import { createEchoTracker } from "./auto-reply/monitor/echo.js";

View File

@ -0,0 +1 @@
export const DEFAULT_WEB_MEDIA_BYTES = 5 * 1024 * 1024;

View File

@ -1,12 +1,12 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { logVerbose } from "../../globals.js"; import { logVerbose } from "../../../../src/globals.js";
import { sleep } from "../../utils.js"; import { sleep } from "../../../../src/utils.js";
import { loadWebMedia } from "../media.js"; import { loadWebMedia } from "../media.js";
import { deliverWebReply } from "./deliver-reply.js"; import { deliverWebReply } from "./deliver-reply.js";
import type { WebInboundMsg } from "./types.js"; import type { WebInboundMsg } from "./types.js";
vi.mock("../../globals.js", async (importOriginal) => { vi.mock("../../../../src/globals.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../globals.js")>(); const actual = await importOriginal<typeof import("../../../../src/globals.js")>();
return { return {
...actual, ...actual,
shouldLogVerbose: vi.fn(() => true), shouldLogVerbose: vi.fn(() => true),
@ -18,8 +18,8 @@ vi.mock("../media.js", () => ({
loadWebMedia: vi.fn(), loadWebMedia: vi.fn(),
})); }));
vi.mock("../../utils.js", async (importOriginal) => { vi.mock("../../../../src/utils.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../utils.js")>(); const actual = await importOriginal<typeof import("../../../../src/utils.js")>();
return { return {
...actual, ...actual,
sleep: vi.fn(async () => {}), sleep: vi.fn(async () => {}),

View File

@ -0,0 +1,212 @@
import { chunkMarkdownTextWithMode, type ChunkMode } from "../../../../src/auto-reply/chunk.js";
import type { ReplyPayload } from "../../../../src/auto-reply/types.js";
import type { MarkdownTableMode } from "../../../../src/config/types.base.js";
import { logVerbose, shouldLogVerbose } from "../../../../src/globals.js";
import { convertMarkdownTables } from "../../../../src/markdown/tables.js";
import { markdownToWhatsApp } from "../../../../src/markdown/whatsapp.js";
import { sleep } from "../../../../src/utils.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";
const REASONING_PREFIX = "reasoning:";
function shouldSuppressReasoningReply(payload: ReplyPayload): boolean {
if (payload.isReasoning === true) {
return true;
}
const text = payload.text;
if (typeof text !== "string") {
return false;
}
return text.trimStart().toLowerCase().startsWith(REASONING_PREFIX);
}
export async function deliverWebReply(params: {
replyResult: ReplyPayload;
msg: WebInboundMsg;
mediaLocalRoots?: readonly string[];
maxMediaBytes: number;
textLimit: number;
chunkMode?: ChunkMode;
replyLogger: {
info: (obj: unknown, msg: string) => void;
warn: (obj: unknown, msg: string) => void;
};
connectionId?: string;
skipLog?: boolean;
tableMode?: MarkdownTableMode;
}) {
const { replyResult, msg, maxMediaBytes, textLimit, replyLogger, connectionId, skipLog } = params;
const replyStarted = Date.now();
if (shouldSuppressReasoningReply(replyResult)) {
whatsappOutboundLog.debug(`Suppressed reasoning payload to ${msg.from}`);
return;
}
const tableMode = params.tableMode ?? "code";
const chunkMode = params.chunkMode ?? "length";
const convertedText = markdownToWhatsApp(
convertMarkdownTables(replyResult.text || "", tableMode),
);
const textChunks = chunkMarkdownTextWithMode(convertedText, textLimit, chunkMode);
const mediaList = replyResult.mediaUrls?.length
? replyResult.mediaUrls
: replyResult.mediaUrl
? [replyResult.mediaUrl]
: [];
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, {
maxBytes: maxMediaBytes,
localRoots: params.mediaLocalRoots,
});
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);
}
}

View File

@ -1,8 +1,8 @@
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import type { getReplyFromConfig } from "../../auto-reply/reply.js"; import type { getReplyFromConfig } from "../../../../src/auto-reply/reply.js";
import { HEARTBEAT_TOKEN } from "../../auto-reply/tokens.js"; import { HEARTBEAT_TOKEN } from "../../../../src/auto-reply/tokens.js";
import { redactIdentifier } from "../../logging/redact-identifier.js"; import { redactIdentifier } from "../../../../src/logging/redact-identifier.js";
import type { sendMessageWhatsApp } from "../outbound.js"; import type { sendMessageWhatsApp } from "../send.js";
const state = vi.hoisted(() => ({ const state = vi.hoisted(() => ({
visibility: { showAlerts: true, showOk: true, useIndicator: false }, visibility: { showAlerts: true, showOk: true, useIndicator: false },
@ -22,34 +22,34 @@ const state = vi.hoisted(() => ({
heartbeatWarnLogs: [] as string[], heartbeatWarnLogs: [] as string[],
})); }));
vi.mock("../../agents/current-time.js", () => ({ vi.mock("../../../../src/agents/current-time.js", () => ({
appendCronStyleCurrentTimeLine: (body: string) => appendCronStyleCurrentTimeLine: (body: string) =>
`${body}\nCurrent time: 2026-02-15T00:00:00Z (mock)`, `${body}\nCurrent time: 2026-02-15T00:00:00Z (mock)`,
})); }));
// Perf: this module otherwise pulls a large dependency graph that we don't need // Perf: this module otherwise pulls a large dependency graph that we don't need
// for these unit tests. // for these unit tests.
vi.mock("../../auto-reply/reply.js", () => ({ vi.mock("../../../../src/auto-reply/reply.js", () => ({
getReplyFromConfig: vi.fn(async () => undefined), getReplyFromConfig: vi.fn(async () => undefined),
})); }));
vi.mock("../../channels/plugins/whatsapp-heartbeat.js", () => ({ vi.mock("../../../../src/channels/plugins/whatsapp-heartbeat.js", () => ({
resolveWhatsAppHeartbeatRecipients: () => [], resolveWhatsAppHeartbeatRecipients: () => [],
})); }));
vi.mock("../../config/config.js", () => ({ vi.mock("../../../../src/config/config.js", () => ({
loadConfig: () => ({ agents: { defaults: {} }, session: {} }), loadConfig: () => ({ agents: { defaults: {} }, session: {} }),
})); }));
vi.mock("../../routing/session-key.js", () => ({ vi.mock("../../../../src/routing/session-key.js", () => ({
normalizeMainKey: () => null, normalizeMainKey: () => null,
})); }));
vi.mock("../../infra/heartbeat-visibility.js", () => ({ vi.mock("../../../../src/infra/heartbeat-visibility.js", () => ({
resolveHeartbeatVisibility: () => state.visibility, resolveHeartbeatVisibility: () => state.visibility,
})); }));
vi.mock("../../config/sessions.js", () => ({ vi.mock("../../../../src/config/sessions.js", () => ({
loadSessionStore: () => state.store, loadSessionStore: () => state.store,
resolveSessionKey: () => "k", resolveSessionKey: () => "k",
resolveStorePath: () => "/tmp/store.json", resolveStorePath: () => "/tmp/store.json",
@ -62,12 +62,12 @@ vi.mock("./session-snapshot.js", () => ({
getSessionSnapshot: () => state.snapshot, getSessionSnapshot: () => state.snapshot,
})); }));
vi.mock("../../infra/heartbeat-events.js", () => ({ vi.mock("../../../../src/infra/heartbeat-events.js", () => ({
emitHeartbeatEvent: (event: unknown) => state.events.push(event), emitHeartbeatEvent: (event: unknown) => state.events.push(event),
resolveIndicatorType: (status: string) => `indicator:${status}`, resolveIndicatorType: (status: string) => `indicator:${status}`,
})); }));
vi.mock("../../logging.js", () => ({ vi.mock("../../../../src/logging.js", () => ({
getChildLogger: () => ({ getChildLogger: () => ({
info: (...args: unknown[]) => state.loggerInfoCalls.push(args), info: (...args: unknown[]) => state.loggerInfoCalls.push(args),
warn: (...args: unknown[]) => state.loggerWarnCalls.push(args), warn: (...args: unknown[]) => state.loggerWarnCalls.push(args),
@ -85,7 +85,7 @@ vi.mock("../reconnect.js", () => ({
newConnectionId: () => "run-1", newConnectionId: () => "run-1",
})); }));
vi.mock("../outbound.js", () => ({ vi.mock("../send.js", () => ({
sendMessageWhatsApp: vi.fn(async () => ({ messageId: "m1" })), sendMessageWhatsApp: vi.fn(async () => ({ messageId: "m1" })),
})); }));

View File

@ -0,0 +1,320 @@
import { appendCronStyleCurrentTimeLine } from "../../../../src/agents/current-time.js";
import { resolveHeartbeatReplyPayload } from "../../../../src/auto-reply/heartbeat-reply-payload.js";
import {
DEFAULT_HEARTBEAT_ACK_MAX_CHARS,
resolveHeartbeatPrompt,
stripHeartbeatToken,
} from "../../../../src/auto-reply/heartbeat.js";
import { getReplyFromConfig } from "../../../../src/auto-reply/reply.js";
import { HEARTBEAT_TOKEN } from "../../../../src/auto-reply/tokens.js";
import { resolveWhatsAppHeartbeatRecipients } from "../../../../src/channels/plugins/whatsapp-heartbeat.js";
import { loadConfig } from "../../../../src/config/config.js";
import {
loadSessionStore,
resolveSessionKey,
resolveStorePath,
updateSessionStore,
} from "../../../../src/config/sessions.js";
import {
emitHeartbeatEvent,
resolveIndicatorType,
} from "../../../../src/infra/heartbeat-events.js";
import { resolveHeartbeatVisibility } from "../../../../src/infra/heartbeat-visibility.js";
import { getChildLogger } from "../../../../src/logging.js";
import { redactIdentifier } from "../../../../src/logging/redact-identifier.js";
import { normalizeMainKey } from "../../../../src/routing/session-key.js";
import { newConnectionId } from "../reconnect.js";
import { sendMessageWhatsApp } from "../send.js";
import { formatError } from "../session.js";
import { whatsappHeartbeatLog } from "./loggers.js";
import { getSessionSnapshot } from "./session-snapshot.js";
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 redactedTo = redactIdentifier(to);
const heartbeatLogger = getChildLogger({
module: "web-heartbeat",
runId,
to: redactedTo,
});
const cfg = cfgOverride ?? loadConfig();
// Resolve heartbeat visibility settings for WhatsApp
const visibility = resolveHeartbeatVisibility({ cfg, channel: "whatsapp" });
const heartbeatOkText = HEARTBEAT_TOKEN;
const maybeSendHeartbeatOk = async (): Promise<boolean> => {
if (!visibility.showOk) {
return false;
}
if (dryRun) {
whatsappHeartbeatLog.info(`[dry-run] heartbeat ok -> ${redactedTo}`);
return false;
}
const sendResult = await sender(to, heartbeatOkText, { verbose });
heartbeatLogger.info(
{
to: redactedTo,
messageId: sendResult.messageId,
chars: heartbeatOkText.length,
reason: "heartbeat-ok",
},
"heartbeat ok sent",
);
whatsappHeartbeatLog.info(`heartbeat ok sent to ${redactedTo} (id ${sendResult.messageId})`);
return true;
};
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 updateSessionStore(storePath, (nextStore) => {
const nextCurrent = nextStore[sessionKey] ?? current;
nextStore[sessionKey] = {
...nextCurrent,
sessionId,
updatedAt: Date.now(),
};
});
}
const sessionSnapshot = getSessionSnapshot(cfg, to, true);
if (verbose) {
heartbeatLogger.info(
{
to: redactedTo,
sessionKey: sessionSnapshot.key,
sessionId: sessionId ?? sessionSnapshot.entry?.sessionId ?? null,
sessionFresh: sessionSnapshot.fresh,
resetMode: sessionSnapshot.resetPolicy.mode,
resetAtHour: sessionSnapshot.resetPolicy.atHour,
idleMinutes: sessionSnapshot.resetPolicy.idleMinutes ?? null,
dailyResetAt: sessionSnapshot.dailyResetAt ?? null,
idleExpiresAt: sessionSnapshot.idleExpiresAt ?? null,
},
"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 -> ${redactedTo} (${overrideBody.trim().length} chars, manual message)`,
);
return;
}
const sendResult = await sender(to, overrideBody, { verbose });
emitHeartbeatEvent({
status: "sent",
to,
preview: overrideBody.slice(0, 160),
hasMedia: false,
channel: "whatsapp",
indicatorType: visibility.useIndicator ? resolveIndicatorType("sent") : undefined,
});
heartbeatLogger.info(
{
to: redactedTo,
messageId: sendResult.messageId,
chars: overrideBody.length,
reason: "manual-message",
},
"manual heartbeat message sent",
);
whatsappHeartbeatLog.info(
`manual heartbeat sent to ${redactedTo} (id ${sendResult.messageId})`,
);
return;
}
if (!visibility.showAlerts && !visibility.showOk && !visibility.useIndicator) {
heartbeatLogger.info({ to: redactedTo, reason: "alerts-disabled" }, "heartbeat skipped");
emitHeartbeatEvent({
status: "skipped",
to,
reason: "alerts-disabled",
channel: "whatsapp",
});
return;
}
const replyResult = await replyResolver(
{
Body: appendCronStyleCurrentTimeLine(
resolveHeartbeatPrompt(cfg.agents?.defaults?.heartbeat?.prompt),
cfg,
Date.now(),
),
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: redactedTo,
reason: "empty-reply",
sessionId: sessionSnapshot.entry?.sessionId ?? null,
},
"heartbeat skipped",
);
const okSent = await maybeSendHeartbeatOk();
emitHeartbeatEvent({
status: "ok-empty",
to,
channel: "whatsapp",
silent: !okSent,
indicatorType: visibility.useIndicator ? resolveIndicatorType("ok-empty") : undefined,
});
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 updateSessionStore(storePath, (nextStore) => {
const nextEntry = nextStore[sessionSnapshot.key];
if (!nextEntry) {
return;
}
nextStore[sessionSnapshot.key] = {
...nextEntry,
updatedAt: sessionSnapshot.entry.updatedAt,
};
});
}
heartbeatLogger.info(
{ to: redactedTo, reason: "heartbeat-token", rawLength: replyPayload.text?.length },
"heartbeat skipped",
);
const okSent = await maybeSendHeartbeatOk();
emitHeartbeatEvent({
status: "ok-token",
to,
channel: "whatsapp",
silent: !okSent,
indicatorType: visibility.useIndicator ? resolveIndicatorType("ok-token") : undefined,
});
return;
}
if (hasMedia) {
heartbeatLogger.warn(
{ to: redactedTo },
"heartbeat reply contained media; sending text only",
);
}
const finalText = stripped.text || replyPayload.text || "";
// Check if alerts are disabled for WhatsApp
if (!visibility.showAlerts) {
heartbeatLogger.info({ to: redactedTo, reason: "alerts-disabled" }, "heartbeat skipped");
emitHeartbeatEvent({
status: "skipped",
to,
reason: "alerts-disabled",
preview: finalText.slice(0, 200),
channel: "whatsapp",
hasMedia,
indicatorType: visibility.useIndicator ? resolveIndicatorType("sent") : undefined,
});
return;
}
if (dryRun) {
heartbeatLogger.info(
{ to: redactedTo, reason: "dry-run", chars: finalText.length },
"heartbeat dry-run",
);
whatsappHeartbeatLog.info(`[dry-run] heartbeat -> ${redactedTo} (${finalText.length} chars)`);
return;
}
const sendResult = await sender(to, finalText, { verbose });
emitHeartbeatEvent({
status: "sent",
to,
preview: finalText.slice(0, 160),
hasMedia,
channel: "whatsapp",
indicatorType: visibility.useIndicator ? resolveIndicatorType("sent") : undefined,
});
heartbeatLogger.info(
{
to: redactedTo,
messageId: sendResult.messageId,
chars: finalText.length,
},
"heartbeat sent",
);
whatsappHeartbeatLog.info(`heartbeat alert sent to ${redactedTo}`);
} catch (err) {
const reason = formatError(err);
heartbeatLogger.warn({ to: redactedTo, error: reason }, "heartbeat failed");
whatsappHeartbeatLog.warn(`heartbeat failed (${reason})`);
emitHeartbeatEvent({
status: "failed",
to,
reason,
channel: "whatsapp",
indicatorType: visibility.useIndicator ? resolveIndicatorType("failed") : undefined,
});
throw err;
}
}
export function resolveHeartbeatRecipients(
cfg: ReturnType<typeof loadConfig>,
opts: { to?: string; all?: boolean } = {},
) {
return resolveWhatsAppHeartbeatRecipients(cfg, opts);
}

View File

@ -0,0 +1,6 @@
import { createSubsystemLogger } from "../../../../src/logging/subsystem.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");

View File

@ -0,0 +1,120 @@
import {
buildMentionRegexes,
normalizeMentionText,
} from "../../../../src/auto-reply/reply/mentions.js";
import type { loadConfig } from "../../../../src/config/config.js";
import { isSelfChatMode, jidToE164, normalizeE164 } from "../../../../src/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);
const hasMentions = (msg.mentionedJids?.length ?? 0) > 0;
if (hasMentions && !isSelfChat) {
if (targets.selfE164 && targets.normalizedMentions.includes(targets.selfE164)) {
return true;
}
if (targets.selfJid) {
// Some mentions use the bare JID; match on E.164 to be safe.
if (targets.normalizedMentions.includes(targets.selfJid)) {
return true;
}
}
// If the message explicitly mentions someone else, do not fall back to regex matches.
return false;
} else if (hasMentions && 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));
}

View File

@ -0,0 +1,469 @@
import { hasControlCommand } from "../../../../src/auto-reply/command-detection.js";
import { resolveInboundDebounceMs } from "../../../../src/auto-reply/inbound-debounce.js";
import { getReplyFromConfig } from "../../../../src/auto-reply/reply.js";
import { DEFAULT_GROUP_HISTORY_LIMIT } from "../../../../src/auto-reply/reply/history.js";
import { formatCliCommand } from "../../../../src/cli/command-format.js";
import { waitForever } from "../../../../src/cli/wait.js";
import { loadConfig } from "../../../../src/config/config.js";
import { createConnectedChannelStatusPatch } from "../../../../src/gateway/channel-status-patches.js";
import { logVerbose } from "../../../../src/globals.js";
import { formatDurationPrecise } from "../../../../src/infra/format-time/format-duration.ts";
import { enqueueSystemEvent } from "../../../../src/infra/system-events.js";
import { registerUnhandledRejectionHandler } from "../../../../src/infra/unhandled-rejections.js";
import { getChildLogger } from "../../../../src/logging.js";
import { resolveAgentRoute } from "../../../../src/routing/resolve-route.js";
import { defaultRuntime, type RuntimeEnv } from "../../../../src/runtime.js";
import { resolveWhatsAppAccount, resolveWhatsAppMediaMaxBytes } 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 { 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";
function isNonRetryableWebCloseStatus(statusCode: unknown): boolean {
// WhatsApp 440 = session conflict ("Unknown Stream Errored (conflict)").
// This is persistent until the operator resolves the conflicting session.
return statusCode === 440;
}
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,
chunkMode: account.chunkMode,
mediaMaxMb: account.mediaMaxMb,
blockStreaming: account.blockStreaming,
groups: account.groups,
},
},
} satisfies ReturnType<typeof loadConfig>;
const maxMediaBytes = resolveWhatsAppMediaMaxBytes(account);
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).
// Tuning overrides are test-oriented; production defaults remain unchanged.
const MESSAGE_TIMEOUT_MS = tuning.messageTimeoutMs ?? 30 * 60 * 1000; // 30m default
const WATCHDOG_CHECK_MS = tuning.watchdogCheckMs ?? 60 * 1000; // 1m default
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 inboundDebounceMs = resolveInboundDebounceMs({ cfg, channel: "whatsapp" });
const shouldDebounce = (msg: WebInboundMsg) => {
if (msg.mediaPath || msg.mediaType) {
return false;
}
if (msg.location) {
return false;
}
if (msg.replyToId || msg.replyToBody) {
return false;
}
return !hasControlCommand(msg.body, cfg);
};
const listener = await (listenerFactory ?? monitorWebInbox)({
verbose,
accountId: account.accountId,
authDir: account.authDir,
mediaMaxMb: account.mediaMaxMb,
sendReadReceipts: account.sendReadReceipts,
debounceMs: inboundDebounceMs,
shouldDebounce,
onMessage: async (msg: WebInboundMsg) => {
handledMessages += 1;
lastMessageAt = Date.now();
status.lastMessageAt = lastMessageAt;
status.lastEventAt = lastMessageAt;
emitStatus();
_lastInboundMsg = msg;
await onMessage(msg);
},
});
Object.assign(status, createConnectedChannelStatusPatch());
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();
process.removeListener("SIGINT", handleSigint);
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 \`${formatCliCommand("openclaw channels login --channel web")}\` to relink.`,
);
await closeListener();
break;
}
if (isNonRetryableWebCloseStatus(statusCode)) {
reconnectLogger.warn(
{
connectionId,
status: statusCode,
error: errorStr,
},
"web reconnect: non-retryable close status; stopping monitor",
);
runtime.error(
`WhatsApp Web connection closed (status ${statusCode}: session conflict). Resolve conflicting WhatsApp Web sessions, then relink with \`${formatCliCommand("openclaw channels login --channel web")}\`. Stopping web monitoring.`,
);
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 ${formatDurationPrecise(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);
}

View File

@ -0,0 +1,74 @@
import { shouldAckReactionForWhatsApp } from "../../../../../src/channels/ack-reactions.js";
import type { loadConfig } from "../../../../../src/config/config.js";
import { logVerbose } from "../../../../../src/globals.js";
import { sendReactionWhatsApp } from "../../send.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 activation =
params.msg.chatType === "group"
? resolveGroupActivationFor({
cfg: params.cfg,
agentId: params.agentId,
sessionKey: params.sessionKey,
conversationId: conversationIdForCheck,
})
: null;
const shouldSendReaction = () =>
shouldAckReactionForWhatsApp({
emoji,
isDirect: params.msg.chatType === "direct",
isGroup: params.msg.chatType === "group",
directEnabled,
groupMode,
wasMentioned: params.msg.wasMentioned === true,
groupActivated: activation === "always",
});
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)}`);
});
}

View File

@ -0,0 +1,128 @@
import type { loadConfig } from "../../../../../src/config/config.js";
import type { resolveAgentRoute } from "../../../../../src/routing/resolve-route.js";
import {
buildAgentSessionKey,
deriveLastRoutePolicy,
} from "../../../../../src/routing/resolve-route.js";
import {
buildAgentMainSessionKey,
DEFAULT_MAIN_KEY,
normalizeAgentId,
} from "../../../../../src/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";
function buildBroadcastRouteKeys(params: {
cfg: ReturnType<typeof loadConfig>;
msg: WebInboundMsg;
route: ReturnType<typeof resolveAgentRoute>;
peerId: string;
agentId: string;
}) {
const sessionKey = buildAgentSessionKey({
agentId: params.agentId,
channel: "whatsapp",
accountId: params.route.accountId,
peer: {
kind: params.msg.chatType === "group" ? "group" : "direct",
id: params.peerId,
},
dmScope: params.cfg.session?.dmScope,
identityLinks: params.cfg.session?.identityLinks,
});
const mainSessionKey = buildAgentMainSessionKey({
agentId: params.agentId,
mainKey: DEFAULT_MAIN_KEY,
});
return {
sessionKey,
mainSessionKey,
lastRoutePolicy: deriveLastRoutePolicy({
sessionKey,
mainSessionKey,
}),
};
}
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 routeKeys = buildBroadcastRouteKeys({
cfg: params.cfg,
msg: params.msg,
route: params.route,
peerId: params.peerId,
agentId: normalizedAgentId,
});
const agentRoute = {
...params.route,
agentId: normalizedAgentId,
...routeKeys,
};
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;
}
};
if (strategy === "sequential") {
for (const agentId of broadcastAgents) {
await processForAgent(agentId);
}
} else {
await Promise.allSettled(broadcastAgents.map(processForAgent));
}
if (params.msg.chatType === "group") {
params.groupHistories.set(params.groupHistoryKey, []);
}
return true;
}

View File

@ -0,0 +1,27 @@
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();
}

View File

@ -0,0 +1,64 @@
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;
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,
};
}

View File

@ -0,0 +1,63 @@
import { normalizeGroupActivation } from "../../../../../src/auto-reply/group-activation.js";
import type { loadConfig } from "../../../../../src/config/config.js";
import {
resolveChannelGroupPolicy,
resolveChannelGroupRequireMention,
} from "../../../../../src/config/group-policy.js";
import {
loadSessionStore,
resolveGroupSessionKey,
resolveStorePath,
} from "../../../../../src/config/sessions.js";
export function resolveGroupPolicyFor(cfg: ReturnType<typeof loadConfig>, conversationId: string) {
const groupId = resolveGroupSessionKey({
From: conversationId,
ChatType: "group",
Provider: "whatsapp",
})?.id;
const whatsappCfg = cfg.channels?.whatsapp as
| { groupAllowFrom?: string[]; allowFrom?: string[] }
| undefined;
const hasGroupAllowFrom = Boolean(
whatsappCfg?.groupAllowFrom?.length || whatsappCfg?.allowFrom?.length,
);
return resolveChannelGroupPolicy({
cfg,
channel: "whatsapp",
groupId: groupId ?? conversationId,
hasGroupAllowFrom,
});
}
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 ? "always" : "mention";
return normalizeGroupActivation(entry?.groupActivation) ?? defaultActivation;
}

View File

@ -0,0 +1,156 @@
import { hasControlCommand } from "../../../../../src/auto-reply/command-detection.js";
import { parseActivationCommand } from "../../../../../src/auto-reply/group-activation.js";
import { recordPendingHistoryEntryIfEnabled } from "../../../../../src/auto-reply/reply/history.js";
import { resolveMentionGating } from "../../../../../src/channels/mention-gating.js";
import type { loadConfig } from "../../../../../src/config/config.js";
import { normalizeE164 } from "../../../../../src/utils.js";
import type { MentionConfig } from "../mentions.js";
import { buildMentionConfig, debugMention, resolveOwnerList } from "../mentions.js";
import type { WebInboundMsg } from "../types.js";
import { 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;
};
type ApplyGroupGatingParams = {
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 };
};
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);
}
function recordPendingGroupHistoryEntry(params: {
msg: WebInboundMsg;
groupHistories: Map<string, GroupHistoryEntry[]>;
groupHistoryKey: string;
groupHistoryLimit: number;
}) {
const sender =
params.msg.senderName && params.msg.senderE164
? `${params.msg.senderName} (${params.msg.senderE164})`
: (params.msg.senderName ?? params.msg.senderE164 ?? "Unknown");
recordPendingHistoryEntryIfEnabled({
historyMap: params.groupHistories,
historyKey: params.groupHistoryKey,
limit: params.groupHistoryLimit,
entry: {
sender,
body: params.msg.body,
timestamp: params.msg.timestamp,
id: params.msg.id,
senderJid: params.msg.senderJid,
},
});
}
function skipGroupMessageAndStoreHistory(params: ApplyGroupGatingParams, verboseMessage: string) {
params.logVerbose(verboseMessage);
recordPendingGroupHistoryEntry({
msg: params.msg,
groupHistories: params.groupHistories,
groupHistoryKey: params.groupHistoryKey,
groupHistoryLimit: params.groupHistoryLimit,
});
return { shouldProcess: false } as const;
}
export function applyGroupGating(params: ApplyGroupGatingParams) {
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 shouldBypassMention = owner && hasControlCommand(commandBody, params.cfg);
if (activationCommand.hasCommand && !owner) {
return skipGroupMessageAndStoreHistory(
params,
`Ignoring /activation from non-owner in group ${params.conversationId}`,
);
}
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;
const activation = resolveGroupActivationFor({
cfg: params.cfg,
agentId: params.agentId,
sessionKey: params.sessionKey,
conversationId: params.conversationId,
});
const requireMention = activation !== "always";
const selfJid = params.msg.selfJid?.replace(/:\\d+/, "");
const replySenderJid = params.msg.replyToSenderJid?.replace(/:\\d+/, "");
const selfE164 = params.msg.selfE164 ? normalizeE164(params.msg.selfE164) : null;
const replySenderE164 = params.msg.replyToSenderE164
? normalizeE164(params.msg.replyToSenderE164)
: null;
const implicitMention = Boolean(
(selfJid && replySenderJid && selfJid === replySenderJid) ||
(selfE164 && replySenderE164 && selfE164 === replySenderE164),
);
const mentionGate = resolveMentionGating({
requireMention,
canDetectMention: true,
wasMentioned,
implicitMention,
shouldBypassMention,
});
params.msg.wasMentioned = mentionGate.effectiveWasMentioned;
if (!shouldBypassMention && requireMention && mentionGate.shouldSkip) {
return skipGroupMessageAndStoreHistory(
params,
`Group message stored for context (no mention detected) in ${params.conversationId}: ${params.msg.body}`,
);
}
return { shouldProcess: true };
}

View File

@ -0,0 +1,65 @@
import { normalizeE164 } from "../../../../../src/utils.js";
function appendNormalizedUnique(entries: Iterable<string>, seen: Set<string>, ordered: string[]) {
for (const entry of entries) {
const normalized = normalizeE164(entry) ?? entry;
if (!normalized || seen.has(normalized)) {
continue;
}
seen.add(normalized);
ordered.push(normalized);
}
}
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) {
appendNormalizedUnique(participants, seen, ordered);
}
if (roster) {
appendNormalizedUnique(roster.keys(), seen, ordered);
}
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(", ");
}

View File

@ -0,0 +1,60 @@
import type { MsgContext } from "../../../../../src/auto-reply/templating.js";
import type { loadConfig } from "../../../../../src/config/config.js";
import { resolveStorePath, updateLastRoute } from "../../../../../src/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;
ctx?: MsgContext;
warn: (obj: unknown, msg: string) => void;
}) {
const storePath = resolveStorePath(params.cfg.session?.store, {
agentId: params.storeAgentId,
});
const task = updateLastRoute({
storePath,
sessionKey: params.sessionKey,
deliveryContext: {
channel: params.channel,
to: params.to,
accountId: params.accountId,
},
ctx: params.ctx,
}).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();
});
}

View File

@ -0,0 +1,51 @@
import { resolveMessagePrefix } from "../../../../../src/agents/identity.js";
import {
formatInboundEnvelope,
type EnvelopeFormatOptions,
} from "../../../../../src/auto-reply/envelope.js";
import type { loadConfig } from "../../../../../src/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;
previousTimestamp?: number;
envelope?: EnvelopeFormatOptions;
}) {
const { cfg, msg, agentId, previousTimestamp, envelope } = 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 replyContext = formatReplyContext(msg);
const baseLine = `${prefixStr}${msg.body}${replyContext ? `\n\n${replyContext}` : ""}`;
// Wrap with standardized envelope for the agent.
return formatInboundEnvelope({
channel: "WhatsApp",
from: msg.chatType === "group" ? msg.from : msg.from?.replace(/^whatsapp:/, ""),
timestamp: msg.timestamp,
body: baseLine,
chatType: msg.chatType,
sender: {
name: msg.senderName,
e164: msg.senderE164,
id: msg.senderJid,
},
previousTimestamp,
envelope,
fromMe: msg.fromMe,
});
}

View File

@ -0,0 +1,170 @@
import type { getReplyFromConfig } from "../../../../../src/auto-reply/reply.js";
import type { MsgContext } from "../../../../../src/auto-reply/templating.js";
import { loadConfig } from "../../../../../src/config/config.js";
import { logVerbose } from "../../../../../src/globals.js";
import { resolveAgentRoute } from "../../../../../src/routing/resolve-route.js";
import { buildGroupHistoryKey } from "../../../../../src/routing/session-key.js";
import { normalizeE164 } from "../../../../../src/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("../../../../../src/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);
// Fresh config for bindings lookup; other routing inputs are payload-derived.
const route = resolveAgentRoute({
cfg: loadConfig(),
channel: "whatsapp",
accountId: msg.accountId,
peer: {
kind: msg.chatType === "group" ? "group" : "direct",
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") {
const metaCtx = {
From: msg.from,
To: msg.to,
SessionKey: route.sessionKey,
AccountId: route.accountId,
ChatType: msg.chatType,
ConversationLabel: conversationId,
GroupSubject: msg.groupSubject,
SenderName: msg.senderName,
SenderId: msg.senderJid?.trim() || msg.senderE164,
SenderE164: msg.senderE164,
Provider: "whatsapp",
Surface: "whatsapp",
OriginatingChannel: "whatsapp",
OriginatingTo: conversationId,
} satisfies MsgContext;
updateLastRouteInBackground({
cfg: params.cfg,
backgroundTasks: params.backgroundTasks,
storeAgentId: route.agentId,
sessionKey: route.sessionKey,
channel: "whatsapp",
to: conversationId,
accountId: route.accountId,
ctx: metaCtx,
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);
};
}

View File

@ -0,0 +1,15 @@
import { jidToE164, normalizeE164 } from "../../../../../src/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;
}

View File

@ -2,7 +2,7 @@ import fs from "node:fs/promises";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { expectInboundContextContract } from "../../../../test/helpers/inbound-contract.js"; import { expectInboundContextContract } from "../../../../../test/helpers/inbound-contract.js";
let capturedCtx: unknown; let capturedCtx: unknown;
let capturedDispatchParams: unknown; let capturedDispatchParams: unknown;
@ -72,7 +72,7 @@ function createWhatsAppDirectStreamingArgs(params?: {
channels: { whatsapp: { blockStreaming: true } }, channels: { whatsapp: { blockStreaming: true } },
messages: {}, messages: {},
session: { store: sessionStorePath }, session: { store: sessionStorePath },
} as unknown as ReturnType<typeof import("../../../config/config.js").loadConfig>, } as unknown as ReturnType<typeof import("../../../../../src/config/config.js").loadConfig>,
msg: { msg: {
id: "msg1", id: "msg1",
from: "+1555", from: "+1555",
@ -83,7 +83,7 @@ function createWhatsAppDirectStreamingArgs(params?: {
}); });
} }
vi.mock("../../../auto-reply/reply/provider-dispatcher.js", () => ({ vi.mock("../../../../../src/auto-reply/reply/provider-dispatcher.js", () => ({
// oxlint-disable-next-line typescript/no-explicit-any // oxlint-disable-next-line typescript/no-explicit-any
dispatchReplyWithBufferedBlockDispatcher: vi.fn(async (params: any) => { dispatchReplyWithBufferedBlockDispatcher: vi.fn(async (params: any) => {
capturedDispatchParams = params; capturedDispatchParams = params;
@ -222,7 +222,7 @@ describe("web processMessage inbound contract", () => {
}, },
messages: {}, messages: {},
session: { store: sessionStorePath }, session: { store: sessionStorePath },
} as unknown as ReturnType<typeof import("../../../config/config.js").loadConfig>); } as unknown as ReturnType<typeof import("../../../../../src/config/config.js").loadConfig>);
expect(getDispatcherResponsePrefix()).toBe("[Mainbot]"); expect(getDispatcherResponsePrefix()).toBe("[Mainbot]");
}); });
@ -231,7 +231,7 @@ describe("web processMessage inbound contract", () => {
await processSelfDirectMessage({ await processSelfDirectMessage({
messages: {}, messages: {},
session: { store: sessionStorePath }, session: { store: sessionStorePath },
} as unknown as ReturnType<typeof import("../../../config/config.js").loadConfig>); } as unknown as ReturnType<typeof import("../../../../../src/config/config.js").loadConfig>);
expect(getDispatcherResponsePrefix()).toBeUndefined(); expect(getDispatcherResponsePrefix()).toBeUndefined();
}); });
@ -258,7 +258,7 @@ describe("web processMessage inbound contract", () => {
cfg: { cfg: {
messages: {}, messages: {},
session: { store: sessionStorePath }, session: { store: sessionStorePath },
} as unknown as ReturnType<typeof import("../../../config/config.js").loadConfig>, } as unknown as ReturnType<typeof import("../../../../../src/config/config.js").loadConfig>,
msg: { msg: {
id: "g1", id: "g1",
from: "123@g.us", from: "123@g.us",
@ -378,7 +378,7 @@ describe("web processMessage inbound contract", () => {
}, },
messages: {}, messages: {},
session: { store: sessionStorePath, dmScope: "main" }, session: { store: sessionStorePath, dmScope: "main" },
} as unknown as ReturnType<typeof import("../../../config/config.js").loadConfig>, } as unknown as ReturnType<typeof import("../../../../../src/config/config.js").loadConfig>,
msg: { msg: {
id: params.messageId, id: params.messageId,
from: params.from, from: params.from,

View File

@ -0,0 +1,473 @@
import { resolveIdentityNamePrefix } from "../../../../../src/agents/identity.js";
import { resolveChunkMode, resolveTextChunkLimit } from "../../../../../src/auto-reply/chunk.js";
import { shouldComputeCommandAuthorized } from "../../../../../src/auto-reply/command-detection.js";
import { formatInboundEnvelope } from "../../../../../src/auto-reply/envelope.js";
import type { getReplyFromConfig } from "../../../../../src/auto-reply/reply.js";
import {
buildHistoryContextFromEntries,
type HistoryEntry,
} from "../../../../../src/auto-reply/reply/history.js";
import { finalizeInboundContext } from "../../../../../src/auto-reply/reply/inbound-context.js";
import { dispatchReplyWithBufferedBlockDispatcher } from "../../../../../src/auto-reply/reply/provider-dispatcher.js";
import type { ReplyPayload } from "../../../../../src/auto-reply/types.js";
import { toLocationContext } from "../../../../../src/channels/location.js";
import { createReplyPrefixOptions } from "../../../../../src/channels/reply-prefix.js";
import { resolveInboundSessionEnvelopeContext } from "../../../../../src/channels/session-envelope.js";
import type { loadConfig } from "../../../../../src/config/config.js";
import { resolveMarkdownTableMode } from "../../../../../src/config/markdown-tables.js";
import { recordSessionMetaFromInbound } from "../../../../../src/config/sessions.js";
import { logVerbose, shouldLogVerbose } from "../../../../../src/globals.js";
import type { getChildLogger } from "../../../../../src/logging.js";
import { getAgentScopedMediaLocalRoots } from "../../../../../src/media/local-roots.js";
import {
resolveInboundLastRouteSessionKey,
type resolveAgentRoute,
} from "../../../../../src/routing/resolve-route.js";
import {
readStoreAllowFromForDmPolicy,
resolvePinnedMainDmOwnerFromAllowlist,
resolveDmGroupAccessWithCommandGate,
} from "../../../../../src/security/dm-policy-shared.js";
import { jidToE164, normalizeE164 } from "../../../../../src/utils.js";
import { resolveWhatsAppAccount } from "../../accounts.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 { trackBackgroundTask, updateLastRouteInBackground } from "./last-route.js";
import { buildInboundLine } from "./message-line.js";
export type GroupHistoryEntry = {
sender: string;
body: string;
timestamp?: number;
id?: string;
senderJid?: string;
};
async function resolveWhatsAppCommandAuthorized(params: {
cfg: ReturnType<typeof loadConfig>;
msg: WebInboundMsg;
}): Promise<boolean> {
const useAccessGroups = params.cfg.commands?.useAccessGroups !== false;
if (!useAccessGroups) {
return true;
}
const isGroup = params.msg.chatType === "group";
const senderE164 = normalizeE164(
isGroup ? (params.msg.senderE164 ?? "") : (params.msg.senderE164 ?? params.msg.from ?? ""),
);
if (!senderE164) {
return false;
}
const account = resolveWhatsAppAccount({ cfg: params.cfg, accountId: params.msg.accountId });
const dmPolicy = account.dmPolicy ?? "pairing";
const groupPolicy = account.groupPolicy ?? "allowlist";
const configuredAllowFrom = account.allowFrom ?? [];
const configuredGroupAllowFrom =
account.groupAllowFrom ?? (configuredAllowFrom.length > 0 ? configuredAllowFrom : undefined);
const storeAllowFrom = isGroup
? []
: await readStoreAllowFromForDmPolicy({
provider: "whatsapp",
accountId: params.msg.accountId,
dmPolicy,
});
const dmAllowFrom =
configuredAllowFrom.length > 0
? configuredAllowFrom
: params.msg.selfE164
? [params.msg.selfE164]
: [];
const access = resolveDmGroupAccessWithCommandGate({
isGroup,
dmPolicy,
groupPolicy,
allowFrom: dmAllowFrom,
groupAllowFrom: configuredGroupAllowFrom,
storeAllowFrom,
isSenderAllowed: (allowEntries) => {
if (allowEntries.includes("*")) {
return true;
}
const normalizedEntries = allowEntries
.map((entry) => normalizeE164(String(entry)))
.filter((entry): entry is string => Boolean(entry));
return normalizedEntries.includes(senderE164);
},
command: {
useAccessGroups,
allowTextCommands: true,
hasControlCommand: true,
},
});
return access.commandAuthorized;
}
function resolvePinnedMainDmRecipient(params: {
cfg: ReturnType<typeof loadConfig>;
msg: WebInboundMsg;
}): string | null {
const account = resolveWhatsAppAccount({ cfg: params.cfg, accountId: params.msg.accountId });
return resolvePinnedMainDmOwnerFromAllowlist({
dmScope: params.cfg.session?.dmScope,
allowFrom: account.allowFrom,
normalizeEntry: (entry) => normalizeE164(entry),
});
}
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;
const { storePath, envelopeOptions, previousTimestamp } = resolveInboundSessionEnvelopeContext({
cfg: params.cfg,
agentId: params.route.agentId,
sessionKey: params.route.sessionKey,
});
let combinedBody = buildInboundLine({
cfg: params.cfg,
msg: params.msg,
agentId: params.route.agentId,
previousTimestamp,
envelope: envelopeOptions,
});
let shouldClearGroupHistory = false;
if (params.msg.chatType === "group") {
const history = params.groupHistory ?? params.groupHistories.get(params.groupHistoryKey) ?? [];
if (history.length > 0) {
const historyEntries: HistoryEntry[] = history.map((m) => ({
sender: m.sender,
body: m.body,
timestamp: m.timestamp,
}));
combinedBody = buildHistoryContextFromEntries({
entries: historyEntries,
currentMessage: combinedBody,
excludeLast: false,
formatEntry: (entry) => {
return formatInboundEnvelope({
channel: "WhatsApp",
from: conversationId,
timestamp: entry.timestamp,
body: entry.body,
chatType: "group",
senderLabel: entry.sender,
envelope: envelopeOptions,
});
},
});
}
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)}`);
}
const dmRouteTarget =
params.msg.chatType !== "group"
? (() => {
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);
})()
: undefined;
const textLimit = params.maxMediaTextChunkLimit ?? resolveTextChunkLimit(params.cfg, "whatsapp");
const chunkMode = resolveChunkMode(params.cfg, "whatsapp", params.route.accountId);
const tableMode = resolveMarkdownTableMode({
cfg: params.cfg,
channel: "whatsapp",
accountId: params.route.accountId,
});
const mediaLocalRoots = getAgentScopedMediaLocalRoots(params.cfg, params.route.agentId);
let didLogHeartbeatStrip = false;
let didSendReply = false;
const commandAuthorized = shouldComputeCommandAuthorized(params.msg.body, params.cfg)
? await resolveWhatsAppCommandAuthorized({ cfg: params.cfg, msg: params.msg })
: undefined;
const configuredResponsePrefix = params.cfg.messages?.responsePrefix;
const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
cfg: params.cfg,
agentId: params.route.agentId,
channel: "whatsapp",
accountId: params.route.accountId,
});
const isSelfChat =
params.msg.chatType !== "group" &&
Boolean(params.msg.selfE164) &&
normalizeE164(params.msg.from) === normalizeE164(params.msg.selfE164 ?? "");
const responsePrefix =
prefixOptions.responsePrefix ??
(configuredResponsePrefix === undefined && isSelfChat
? resolveIdentityNamePrefix(params.cfg, params.route.agentId)
: undefined);
const inboundHistory =
params.msg.chatType === "group"
? (params.groupHistory ?? params.groupHistories.get(params.groupHistoryKey) ?? []).map(
(entry) => ({
sender: entry.sender,
body: entry.body,
timestamp: entry.timestamp,
}),
)
: undefined;
const ctxPayload = finalizeInboundContext({
Body: combinedBody,
BodyForAgent: params.msg.body,
InboundHistory: inboundHistory,
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,
ConversationLabel: params.msg.chatType === "group" ? conversationId : params.msg.from,
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,
CommandAuthorized: commandAuthorized,
WasMentioned: params.msg.wasMentioned,
...(params.msg.location ? toLocationContext(params.msg.location) : {}),
Provider: "whatsapp",
Surface: "whatsapp",
OriginatingChannel: "whatsapp",
OriginatingTo: params.msg.from,
});
// Only update main session's lastRoute when DM actually IS the main session.
// When dmScope="per-channel-peer", the DM uses an isolated sessionKey,
// and updating mainSessionKey would corrupt routing for the session owner.
const pinnedMainDmRecipient = resolvePinnedMainDmRecipient({
cfg: params.cfg,
msg: params.msg,
});
const shouldUpdateMainLastRoute =
!pinnedMainDmRecipient || pinnedMainDmRecipient === dmRouteTarget;
const inboundLastRouteSessionKey = resolveInboundLastRouteSessionKey({
route: params.route,
sessionKey: params.route.sessionKey,
});
if (
dmRouteTarget &&
inboundLastRouteSessionKey === params.route.mainSessionKey &&
shouldUpdateMainLastRoute
) {
updateLastRouteInBackground({
cfg: params.cfg,
backgroundTasks: params.backgroundTasks,
storeAgentId: params.route.agentId,
sessionKey: params.route.mainSessionKey,
channel: "whatsapp",
to: dmRouteTarget,
accountId: params.route.accountId,
ctx: ctxPayload,
warn: params.replyLogger.warn.bind(params.replyLogger),
});
} else if (
dmRouteTarget &&
inboundLastRouteSessionKey === params.route.mainSessionKey &&
pinnedMainDmRecipient
) {
logVerbose(
`Skipping main-session last route update for ${dmRouteTarget} (pinned owner ${pinnedMainDmRecipient})`,
);
}
const metaTask = recordSessionMetaFromInbound({
storePath,
sessionKey: params.route.sessionKey,
ctx: ctxPayload,
}).catch((err) => {
params.replyLogger.warn(
{
error: formatError(err),
storePath,
sessionKey: params.route.sessionKey,
},
"failed updating session meta",
);
});
trackBackgroundTask(params.backgroundTasks, metaTask);
const { queuedFinal } = await dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
cfg: params.cfg,
replyResolver: params.replyResolver,
dispatcherOptions: {
...prefixOptions,
responsePrefix,
onHeartbeatStrip: () => {
if (!didLogHeartbeatStrip) {
didLogHeartbeatStrip = true;
logVerbose("Stripped stray HEARTBEAT_OK token from web reply");
}
},
deliver: async (payload: ReplyPayload, info) => {
if (info.kind !== "final") {
// Only deliver final replies to external messaging channels (WhatsApp).
// Block (reasoning/thinking) and tool updates are meant for the internal
// web UI only; sending them here leaks chain-of-thought to end users.
return;
}
await deliverWebReply({
replyResult: payload,
msg: params.msg,
mediaLocalRoots,
maxMediaBytes: params.maxMediaBytes,
textLimit,
chunkMode,
replyLogger: params.replyLogger,
connectionId: params.connectionId,
skipLog: false,
tableMode,
});
didSendReply = true;
const shouldLog = payload.text ? true : undefined;
params.rememberSentText(payload.text, {
combinedBody,
combinedBodySessionKey: params.route.sessionKey,
logVerboseMessage: shouldLog,
});
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: {
// WhatsApp delivery intentionally suppresses non-final payloads.
// Keep block streaming disabled so final replies are still produced.
disableBlockStreaming: true,
onModelSelected,
},
});
if (!queuedFinal) {
if (shouldClearGroupHistory) {
params.groupHistories.set(params.groupHistoryKey, []);
}
logVerbose("Skipping auto-reply: silent token or no text/media returned from resolver");
return false;
}
if (shouldClearGroupHistory) {
params.groupHistories.set(params.groupHistoryKey, []);
}
return didSendReply;
}

View File

@ -0,0 +1,69 @@
import type { loadConfig } from "../../../../src/config/config.js";
import {
evaluateSessionFreshness,
loadSessionStore,
resolveChannelResetConfig,
resolveThreadFlag,
resolveSessionResetPolicy,
resolveSessionResetType,
resolveSessionKey,
resolveStorePath,
} from "../../../../src/config/sessions.js";
import { normalizeMainKey } from "../../../../src/routing/session-key.js";
export function getSessionSnapshot(
cfg: ReturnType<typeof loadConfig>,
from: string,
_isHeartbeat = false,
ctx?: {
sessionKey?: string | null;
isGroup?: boolean;
messageThreadId?: string | number | null;
threadLabel?: string | null;
threadStarterBody?: string | null;
parentSessionKey?: string | null;
},
) {
const sessionCfg = cfg.session;
const scope = sessionCfg?.scope ?? "per-sender";
const key =
ctx?.sessionKey?.trim() ??
resolveSessionKey(
scope,
{ From: from, To: "", Body: "" },
normalizeMainKey(sessionCfg?.mainKey),
);
const store = loadSessionStore(resolveStorePath(sessionCfg?.store));
const entry = store[key];
const isThread = resolveThreadFlag({
sessionKey: key,
messageThreadId: ctx?.messageThreadId ?? null,
threadLabel: ctx?.threadLabel ?? null,
threadStarterBody: ctx?.threadStarterBody ?? null,
parentSessionKey: ctx?.parentSessionKey ?? null,
});
const resetType = resolveSessionResetType({ sessionKey: key, isGroup: ctx?.isGroup, isThread });
const channelReset = resolveChannelResetConfig({
sessionCfg,
channel: entry?.lastChannel ?? entry?.channel,
});
const resetPolicy = resolveSessionResetPolicy({
sessionCfg,
resetType,
resetOverride: channelReset,
});
const now = Date.now();
const freshness = entry
? evaluateSessionFreshness({ updatedAt: entry.updatedAt, now, policy: resetPolicy })
: { fresh: false };
return {
key,
entry,
fresh: freshness.fresh,
resetPolicy,
resetType,
dailyResetAt: freshness.dailyResetAt,
idleExpiresAt: freshness.idleExpiresAt,
};
}

View File

@ -0,0 +1,37 @@
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;
messageTimeoutMs?: number;
watchdogCheckMs?: number;
sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
statusSink?: (status: WebChannelStatus) => void;
/** WhatsApp account id. Default: "default". */
accountId?: string;
/** Debounce window (ms) for batching rapid consecutive messages from the same sender. */
debounceMs?: number;
};

View File

@ -0,0 +1,61 @@
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")
);
}

View File

@ -2,7 +2,7 @@ import fs from "node:fs/promises";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { resolveAgentRoute } from "../../routing/resolve-route.js"; import { resolveAgentRoute } from "../../../../src/routing/resolve-route.js";
import { buildMentionConfig } from "./mentions.js"; import { buildMentionConfig } from "./mentions.js";
import { applyGroupGating, type GroupHistoryEntry } from "./monitor/group-gating.js"; import { applyGroupGating, type GroupHistoryEntry } from "./monitor/group-gating.js";
import { buildInboundLine, formatReplyContext } from "./monitor/message-line.js"; import { buildInboundLine, formatReplyContext } from "./monitor/message-line.js";
@ -33,10 +33,10 @@ const makeConfig = (overrides: Record<string, unknown>) =>
}, },
session: { store: sessionStorePath }, session: { store: sessionStorePath },
...overrides, ...overrides,
}) as unknown as ReturnType<typeof import("../../config/config.js").loadConfig>; }) as unknown as ReturnType<typeof import("../../../../src/config/config.js").loadConfig>;
function runGroupGating(params: { function runGroupGating(params: {
cfg: ReturnType<typeof import("../../config/config.js").loadConfig>; cfg: ReturnType<typeof import("../../../../src/config/config.js").loadConfig>;
msg: Record<string, unknown>; msg: Record<string, unknown>;
conversationId?: string; conversationId?: string;
agentId?: string; agentId?: string;

View File

@ -1,8 +1,8 @@
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { saveSessionStore } from "../../config/sessions.js"; import { saveSessionStore } from "../../../../src/config/sessions.js";
import { withTempDir } from "../../test-utils/temp-dir.js"; import { withTempDir } from "../../../../src/test-utils/temp-dir.js";
import { import {
debugMention, debugMention,
isBotMentionedFromTargets, isBotMentionedFromTargets,

View File

@ -6,24 +6,18 @@ import {
import { import {
applyAccountNameToChannelSection, applyAccountNameToChannelSection,
buildChannelConfigSchema, buildChannelConfigSchema,
collectWhatsAppStatusIssues,
createActionGate, createActionGate,
createWhatsAppOutboundBase, createWhatsAppOutboundBase,
DEFAULT_ACCOUNT_ID, DEFAULT_ACCOUNT_ID,
getChatChannelMeta, getChatChannelMeta,
listWhatsAppAccountIds,
listWhatsAppDirectoryGroupsFromConfig, listWhatsAppDirectoryGroupsFromConfig,
listWhatsAppDirectoryPeersFromConfig, listWhatsAppDirectoryPeersFromConfig,
looksLikeWhatsAppTargetId,
migrateBaseNameToDefaultAccount, migrateBaseNameToDefaultAccount,
normalizeAccountId, normalizeAccountId,
normalizeE164, normalizeE164,
formatWhatsAppConfigAllowFromEntries, formatWhatsAppConfigAllowFromEntries,
normalizeWhatsAppMessagingTarget,
readStringParam, readStringParam,
resolveDefaultWhatsAppAccountId,
resolveWhatsAppOutboundTarget, resolveWhatsAppOutboundTarget,
resolveWhatsAppAccount,
resolveWhatsAppConfigAllowFrom, resolveWhatsAppConfigAllowFrom,
resolveWhatsAppConfigDefaultTo, resolveWhatsAppConfigDefaultTo,
resolveWhatsAppGroupRequireMention, resolveWhatsAppGroupRequireMention,
@ -31,13 +25,21 @@ import {
resolveWhatsAppGroupToolPolicy, resolveWhatsAppGroupToolPolicy,
resolveWhatsAppHeartbeatRecipients, resolveWhatsAppHeartbeatRecipients,
resolveWhatsAppMentionStripPatterns, resolveWhatsAppMentionStripPatterns,
whatsappOnboardingAdapter,
WhatsAppConfigSchema, WhatsAppConfigSchema,
type ChannelMessageActionName, type ChannelMessageActionName,
type ChannelPlugin, type ChannelPlugin,
type ResolvedWhatsAppAccount,
} from "openclaw/plugin-sdk/whatsapp"; } from "openclaw/plugin-sdk/whatsapp";
// WhatsApp-specific imports from local extension code (moved from src/web/ and src/channels/plugins/)
import {
listWhatsAppAccountIds,
resolveDefaultWhatsAppAccountId,
resolveWhatsAppAccount,
type ResolvedWhatsAppAccount,
} from "./accounts.js";
import { looksLikeWhatsAppTargetId, normalizeWhatsAppMessagingTarget } from "./normalize.js";
import { whatsappOnboardingAdapter } from "./onboarding.js";
import { getWhatsAppRuntime } from "./runtime.js"; import { getWhatsAppRuntime } from "./runtime.js";
import { collectWhatsAppStatusIssues } from "./status-issues.js";
const meta = getChatChannelMeta("whatsapp"); const meta = getChatChannelMeta("whatsapp");

View File

@ -8,8 +8,8 @@ const readAllowFromStoreMock = vi.fn().mockResolvedValue([]);
const upsertPairingRequestMock = vi.fn().mockResolvedValue({ code: "PAIRCODE", created: true }); const upsertPairingRequestMock = vi.fn().mockResolvedValue({ code: "PAIRCODE", created: true });
const saveMediaBufferSpy = vi.fn(); const saveMediaBufferSpy = vi.fn();
vi.mock("../config/config.js", async (importOriginal) => { vi.mock("../../../src/config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>(); const actual = await importOriginal<typeof import("../../../src/config/config.js")>();
return { return {
...actual, ...actual,
loadConfig: vi.fn().mockReturnValue({ loadConfig: vi.fn().mockReturnValue({
@ -26,7 +26,7 @@ vi.mock("../config/config.js", async (importOriginal) => {
}; };
}); });
vi.mock("../pairing/pairing-store.js", () => { vi.mock("../../../src/pairing/pairing-store.js", () => {
return { return {
readChannelAllowFromStore(...args: unknown[]) { readChannelAllowFromStore(...args: unknown[]) {
return readAllowFromStoreMock(...args); return readAllowFromStoreMock(...args);
@ -37,8 +37,8 @@ vi.mock("../pairing/pairing-store.js", () => {
}; };
}); });
vi.mock("../media/store.js", async (importOriginal) => { vi.mock("../../../src/media/store.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../media/store.js")>(); const actual = await importOriginal<typeof import("../../../src/media/store.js")>();
return { return {
...actual, ...actual,
saveMediaBuffer: vi.fn(async (...args: Parameters<typeof actual.saveMediaBuffer>) => { saveMediaBuffer: vi.fn(async (...args: Parameters<typeof actual.saveMediaBuffer>) => {

View File

@ -0,0 +1,4 @@
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";

View File

@ -1,5 +1,5 @@
import { describe } from "vitest"; import { describe } from "vitest";
import { installProviderRuntimeGroupPolicyFallbackSuite } from "../../test-utils/runtime-group-policy-contract.js"; import { installProviderRuntimeGroupPolicyFallbackSuite } from "../../../../src/test-utils/runtime-group-policy-contract.js";
import { __testing } from "./access-control.js"; import { __testing } from "./access-control.js";
describe("resolveWhatsAppRuntimeGroupPolicy", () => { describe("resolveWhatsAppRuntimeGroupPolicy", () => {

View File

@ -33,15 +33,15 @@ export function setupAccessControlTestHarness(): void {
}); });
} }
vi.mock("../../config/config.js", async (importOriginal) => { vi.mock("../../../../src/config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../config/config.js")>(); const actual = await importOriginal<typeof import("../../../../src/config/config.js")>();
return { return {
...actual, ...actual,
loadConfig: () => config, loadConfig: () => config,
}; };
}); });
vi.mock("../../pairing/pairing-store.js", () => ({ vi.mock("../../../../src/pairing/pairing-store.js", () => ({
readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args), readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args),
upsertChannelPairingRequest: (...args: unknown[]) => upsertPairingRequestMock(...args), upsertChannelPairingRequest: (...args: unknown[]) => upsertPairingRequestMock(...args),
})); }));

View File

@ -0,0 +1,227 @@
import { loadConfig } from "../../../../src/config/config.js";
import {
resolveOpenProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
warnMissingProviderGroupPolicyFallbackOnce,
} from "../../../../src/config/runtime-group-policy.js";
import { logVerbose } from "../../../../src/globals.js";
import { issuePairingChallenge } from "../../../../src/pairing/pairing-challenge.js";
import { upsertChannelPairingRequest } from "../../../../src/pairing/pairing-store.js";
import {
readStoreAllowFromForDmPolicy,
resolveDmGroupAccessWithLists,
} from "../../../../src/security/dm-policy-shared.js";
import { isSelfChatMode, normalizeE164 } from "../../../../src/utils.js";
import { resolveWhatsAppAccount } from "../accounts.js";
export type InboundAccessControlResult = {
allowed: boolean;
shouldMarkRead: boolean;
isSelfChat: boolean;
resolvedAccountId: string;
};
const PAIRING_REPLY_HISTORY_GRACE_MS = 30_000;
function resolveWhatsAppRuntimeGroupPolicy(params: {
providerConfigPresent: boolean;
groupPolicy?: "open" | "allowlist" | "disabled";
defaultGroupPolicy?: "open" | "allowlist" | "disabled";
}): {
groupPolicy: "open" | "allowlist" | "disabled";
providerMissingFallbackApplied: boolean;
} {
return resolveOpenProviderRuntimeGroupPolicy({
providerConfigPresent: params.providerConfigPresent,
groupPolicy: params.groupPolicy,
defaultGroupPolicy: params.defaultGroupPolicy,
});
}
export async function checkInboundAccessControl(params: {
accountId: string;
from: string;
selfE164: string | null;
senderE164: string | null;
group: boolean;
pushName?: string;
isFromMe: boolean;
messageTimestampMs?: number;
connectedAtMs?: number;
pairingGraceMs?: number;
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 = account.dmPolicy ?? "pairing";
const configuredAllowFrom = account.allowFrom ?? [];
const storeAllowFrom = await readStoreAllowFromForDmPolicy({
provider: "whatsapp",
accountId: account.accountId,
dmPolicy,
});
// Without user config, default to self-only DM access so the owner can talk to themselves.
const defaultAllowFrom =
configuredAllowFrom.length === 0 && params.selfE164 ? [params.selfE164] : [];
const dmAllowFrom = configuredAllowFrom.length > 0 ? configuredAllowFrom : defaultAllowFrom;
const groupAllowFrom =
account.groupAllowFrom ?? (configuredAllowFrom.length > 0 ? configuredAllowFrom : undefined);
const isSamePhone = params.from === params.selfE164;
const isSelfChat = account.selfChatMode ?? isSelfChatMode(params.selfE164, configuredAllowFrom);
const pairingGraceMs =
typeof params.pairingGraceMs === "number" && params.pairingGraceMs > 0
? params.pairingGraceMs
: PAIRING_REPLY_HISTORY_GRACE_MS;
const suppressPairingReply =
typeof params.connectedAtMs === "number" &&
typeof params.messageTimestampMs === "number" &&
params.messageTimestampMs < params.connectedAtMs - pairingGraceMs;
// 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 defaultGroupPolicy = resolveDefaultGroupPolicy(cfg);
const { groupPolicy, providerMissingFallbackApplied } = resolveWhatsAppRuntimeGroupPolicy({
providerConfigPresent: cfg.channels?.whatsapp !== undefined,
groupPolicy: account.groupPolicy,
defaultGroupPolicy,
});
warnMissingProviderGroupPolicyFallbackOnce({
providerMissingFallbackApplied,
providerKey: "whatsapp",
accountId: account.accountId,
log: (message) => logVerbose(message),
});
const normalizedDmSender = normalizeE164(params.from);
const normalizedGroupSender =
typeof params.senderE164 === "string" ? normalizeE164(params.senderE164) : null;
const access = resolveDmGroupAccessWithLists({
isGroup: params.group,
dmPolicy,
groupPolicy,
// Groups intentionally fall back to configured allowFrom only (not DM self-chat fallback).
allowFrom: params.group ? configuredAllowFrom : dmAllowFrom,
groupAllowFrom,
storeAllowFrom,
isSenderAllowed: (allowEntries) => {
const hasWildcard = allowEntries.includes("*");
if (hasWildcard) {
return true;
}
const normalizedEntrySet = new Set(
allowEntries
.map((entry) => normalizeE164(String(entry)))
.filter((entry): entry is string => Boolean(entry)),
);
if (!params.group && isSamePhone) {
return true;
}
return params.group
? Boolean(normalizedGroupSender && normalizedEntrySet.has(normalizedGroupSender))
: normalizedEntrySet.has(normalizedDmSender);
},
});
if (params.group && access.decision !== "allow") {
if (access.reason === "groupPolicy=disabled") {
logVerbose("Blocked group message (groupPolicy: disabled)");
} else if (access.reason === "groupPolicy=allowlist (empty allowlist)") {
logVerbose("Blocked group message (groupPolicy: allowlist, no groupAllowFrom)");
} else {
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 (access.decision === "block" && access.reason === "dmPolicy=disabled") {
logVerbose("Blocked dm (dmPolicy: disabled)");
return {
allowed: false,
shouldMarkRead: false,
isSelfChat,
resolvedAccountId: account.accountId,
};
}
if (access.decision === "pairing" && !isSamePhone) {
const candidate = params.from;
if (suppressPairingReply) {
logVerbose(`Skipping pairing reply for historical DM from ${candidate}.`);
} else {
await issuePairingChallenge({
channel: "whatsapp",
senderId: candidate,
senderIdLine: `Your WhatsApp phone number: ${candidate}`,
meta: { name: (params.pushName ?? "").trim() || undefined },
upsertPairingRequest: async ({ id, meta }) =>
await upsertChannelPairingRequest({
channel: "whatsapp",
id,
accountId: account.accountId,
meta,
}),
onCreated: () => {
logVerbose(
`whatsapp pairing request sender=${candidate} name=${params.pushName ?? "unknown"}`,
);
},
sendPairingReply: async (text) => {
await params.sock.sendMessage(params.remoteJid, { text });
},
onReplyError: (err) => {
logVerbose(`whatsapp pairing reply failed for ${candidate}: ${String(err)}`);
},
});
}
return {
allowed: false,
shouldMarkRead: false,
isSelfChat,
resolvedAccountId: account.accountId,
};
}
if (access.decision !== "allow") {
logVerbose(`Blocked unauthorized sender ${params.from} (dmPolicy=${dmPolicy})`);
return {
allowed: false,
shouldMarkRead: false,
isSelfChat,
resolvedAccountId: account.accountId,
};
}
}
return {
allowed: true,
shouldMarkRead: true,
isSelfChat,
resolvedAccountId: account.accountId,
};
}
export const __testing = {
resolveWhatsAppRuntimeGroupPolicy,
};

View File

@ -0,0 +1,17 @@
import { createDedupeCache } from "../../../../src/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);
}

View File

@ -0,0 +1,331 @@
import type { proto } from "@whiskeysockets/baileys";
import {
extractMessageContent,
getContentType,
normalizeMessageContent,
} from "@whiskeysockets/baileys";
import { formatLocationText, type NormalizedLocation } from "../../../../src/channels/location.js";
import { logVerbose } from "../../../../src/globals.js";
import { jidToE164 } from "../../../../src/utils.js";
import { parseVcard } from "../vcard.js";
function unwrapMessage(message: proto.IMessage | undefined): proto.IMessage | undefined {
const normalized = normalizeMessageContent(message);
return normalized;
}
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;
senderJid?: string;
senderE164?: string;
} | null {
const message = unwrapMessage(rawMessage);
if (!message) {
return null;
}
const contextInfo = extractContextInfo(message);
const quoted = normalizeMessageContent(contextInfo?.quotedMessage 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,
senderJid,
senderE164,
};
}

View File

@ -0,0 +1,76 @@
import type { proto, WAMessage } from "@whiskeysockets/baileys";
import { downloadMediaMessage, normalizeMessageContent } from "@whiskeysockets/baileys";
import { logVerbose } from "../../../../src/globals.js";
import type { createWaSocket } from "../session.js";
function unwrapMessage(message: proto.IMessage | undefined): proto.IMessage | undefined {
const normalized = normalizeMessageContent(message);
return normalized;
}
/**
* Resolve the MIME type for an inbound media message.
* Falls back to WhatsApp's standard formats when Baileys omits the MIME.
*/
function resolveMediaMimetype(message: proto.IMessage): string | undefined {
const explicit =
message.imageMessage?.mimetype ??
message.videoMessage?.mimetype ??
message.documentMessage?.mimetype ??
message.audioMessage?.mimetype ??
message.stickerMessage?.mimetype ??
undefined;
if (explicit) {
return explicit;
}
// WhatsApp voice messages (PTT) and audio use OGG Opus by default
if (message.audioMessage) {
return "audio/ogg; codecs=opus";
}
if (message.imageMessage) {
return "image/jpeg";
}
if (message.videoMessage) {
return "video/mp4";
}
if (message.stickerMessage) {
return "image/webp";
}
return undefined;
}
export async function downloadInboundMedia(
msg: proto.IWebMessageInfo,
sock: Awaited<ReturnType<typeof createWaSocket>>,
): Promise<{ buffer: Buffer; mimetype?: string; fileName?: string } | undefined> {
const message = unwrapMessage(msg.message as proto.IMessage | undefined);
if (!message) {
return undefined;
}
const mimetype = resolveMediaMimetype(message);
const fileName = message.documentMessage?.fileName ?? 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,
},
);
return { buffer, mimetype, fileName };
} catch (err) {
logVerbose(`downloadMediaMessage failed: ${String(err)}`);
return undefined;
}
}

View File

@ -0,0 +1,488 @@
import type { AnyMessageContent, proto, WAMessage } from "@whiskeysockets/baileys";
import { DisconnectReason, isJidGroup } from "@whiskeysockets/baileys";
import { createInboundDebouncer } from "../../../../src/auto-reply/inbound-debounce.js";
import { formatLocationText } from "../../../../src/channels/location.js";
import { logVerbose, shouldLogVerbose } from "../../../../src/globals.js";
import { recordChannelActivity } from "../../../../src/infra/channel-activity.js";
import { getChildLogger } from "../../../../src/logging/logger.js";
import { createSubsystemLogger } from "../../../../src/logging/subsystem.js";
import { saveMediaBuffer } from "../../../../src/media/store.js";
import { jidToE164, resolveJidToE164 } from "../../../../src/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;
/** Send read receipts for incoming messages (default true). */
sendReadReceipts?: boolean;
/** Debounce window (ms) for batching rapid consecutive messages from the same sender (0 to disable). */
debounceMs?: number;
/** Optional debounce gating predicate. */
shouldDebounce?: (msg: WebInboundMessage) => boolean;
}) {
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);
const connectedAtMs = Date.now();
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 debouncer = createInboundDebouncer<WebInboundMessage>({
debounceMs: options.debounceMs ?? 0,
buildKey: (msg) => {
const senderKey =
msg.chatType === "group"
? (msg.senderJid ?? msg.senderE164 ?? msg.senderName ?? msg.from)
: msg.from;
if (!senderKey) {
return null;
}
const conversationKey = msg.chatType === "group" ? msg.chatId : msg.from;
return `${msg.accountId}:${conversationKey}:${senderKey}`;
},
shouldDebounce: options.shouldDebounce,
onFlush: async (entries) => {
const last = entries.at(-1);
if (!last) {
return;
}
if (entries.length === 1) {
await options.onMessage(last);
return;
}
const mentioned = new Set<string>();
for (const entry of entries) {
for (const jid of entry.mentionedJids ?? []) {
mentioned.add(jid);
}
}
const combinedBody = entries
.map((entry) => entry.body)
.filter(Boolean)
.join("\n");
const combinedMessage: WebInboundMessage = {
...last,
body: combinedBody,
mentionedJids: mentioned.size > 0 ? Array.from(mentioned) : undefined,
};
await options.onMessage(combinedMessage);
},
onError: (err) => {
inboundLogger.error({ error: String(err) }, "failed handling inbound web message");
inboundConsoleLog.error(`Failed handling inbound web message: ${String(err)}`);
},
});
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 };
}
};
type NormalizedInboundMessage = {
id?: string;
remoteJid: string;
group: boolean;
participantJid?: string;
from: string;
senderE164: string | null;
groupSubject?: string;
groupParticipants?: string[];
messageTimestampMs?: number;
access: Awaited<ReturnType<typeof checkInboundAccessControl>>;
};
const normalizeInboundMessage = async (
msg: WAMessage,
): Promise<NormalizedInboundMessage | null> => {
const id = msg.key?.id ?? undefined;
const remoteJid = msg.key?.remoteJid;
if (!remoteJid) {
return null;
}
if (remoteJid.endsWith("@status") || remoteJid.endsWith("@broadcast")) {
return null;
}
const group = isJidGroup(remoteJid) === true;
if (id) {
const dedupeKey = `${options.accountId}:${remoteJid}:${id}`;
if (isRecentInboundMessage(dedupeKey)) {
return null;
}
}
const participantJid = msg.key?.participant ?? undefined;
const from = group ? remoteJid : await resolveInboundJid(remoteJid);
if (!from) {
return null;
}
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 messageTimestampMs = msg.messageTimestamp
? Number(msg.messageTimestamp) * 1000
: undefined;
const access = await checkInboundAccessControl({
accountId: options.accountId,
from,
selfE164,
senderE164,
group,
pushName: msg.pushName ?? undefined,
isFromMe: Boolean(msg.key?.fromMe),
messageTimestampMs,
connectedAtMs,
sock: { sendMessage: (jid, content) => sock.sendMessage(jid, content) },
remoteJid,
});
if (!access.allowed) {
return null;
}
return {
id,
remoteJid,
group,
participantJid,
from,
senderE164,
groupSubject,
groupParticipants,
messageTimestampMs,
access,
};
};
const maybeMarkInboundAsRead = async (inbound: NormalizedInboundMessage) => {
const { id, remoteJid, participantJid, access } = inbound;
if (id && !access.isSelfChat && options.sendReadReceipts !== false) {
try {
await sock.readMessages([{ remoteJid, id, participant: participantJid, fromMe: false }]);
if (shouldLogVerbose()) {
const suffix = participantJid ? ` (participant ${participantJid})` : "";
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}`);
}
};
type EnrichedInboundMessage = {
body: string;
location?: ReturnType<typeof extractLocationData>;
replyContext?: ReturnType<typeof describeReplyContext>;
mediaPath?: string;
mediaType?: string;
mediaFileName?: string;
};
const enrichInboundMessage = async (msg: WAMessage): Promise<EnrichedInboundMessage | null> => {
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) {
return null;
}
}
const replyContext = describeReplyContext(msg.message as proto.IMessage | undefined);
let mediaPath: string | undefined;
let mediaType: string | undefined;
let mediaFileName: 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,
inboundMedia.fileName,
);
mediaPath = saved.path;
mediaType = inboundMedia.mimetype;
mediaFileName = inboundMedia.fileName;
}
} catch (err) {
logVerbose(`Inbound media download failed: ${String(err)}`);
}
return {
body,
location: location ?? undefined,
replyContext,
mediaPath,
mediaType,
mediaFileName,
};
};
const enqueueInboundMessage = async (
msg: WAMessage,
inbound: NormalizedInboundMessage,
enriched: EnrichedInboundMessage,
) => {
const chatJid = inbound.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 = inbound.messageTimestampMs;
const mentionedJids = extractMentionedJids(msg.message as proto.IMessage | undefined);
const senderName = msg.pushName ?? undefined;
inboundLogger.info(
{
from: inbound.from,
to: selfE164 ?? "me",
body: enriched.body,
mediaPath: enriched.mediaPath,
mediaType: enriched.mediaType,
mediaFileName: enriched.mediaFileName,
timestamp,
},
"inbound message",
);
const inboundMessage: WebInboundMessage = {
id: inbound.id,
from: inbound.from,
conversationId: inbound.from,
to: selfE164 ?? "me",
accountId: inbound.access.resolvedAccountId,
body: enriched.body,
pushName: senderName,
timestamp,
chatType: inbound.group ? "group" : "direct",
chatId: inbound.remoteJid,
senderJid: inbound.participantJid,
senderE164: inbound.senderE164 ?? undefined,
senderName,
replyToId: enriched.replyContext?.id,
replyToBody: enriched.replyContext?.body,
replyToSender: enriched.replyContext?.sender,
replyToSenderJid: enriched.replyContext?.senderJid,
replyToSenderE164: enriched.replyContext?.senderE164,
groupSubject: inbound.groupSubject,
groupParticipants: inbound.groupParticipants,
mentionedJids: mentionedJids ?? undefined,
selfJid,
selfE164,
fromMe: Boolean(msg.key?.fromMe),
location: enriched.location ?? undefined,
sendComposing,
reply,
sendMedia,
mediaPath: enriched.mediaPath,
mediaType: enriched.mediaType,
mediaFileName: enriched.mediaFileName,
};
try {
const task = Promise.resolve(debouncer.enqueue(inboundMessage));
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)}`);
}
};
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 inbound = await normalizeInboundMessage(msg);
if (!inbound) {
continue;
}
await maybeMarkInboundAsRead(inbound);
// If this is history/offline catch-up, mark read above but skip auto-reply.
if (upsert.type === "append") {
continue;
}
const enriched = await enrichInboundMessage(msg);
if (!enriched) {
continue;
}
await enqueueInboundMessage(msg, inbound, enriched);
}
};
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;
}

View File

@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
const recordChannelActivity = vi.fn(); const recordChannelActivity = vi.fn();
vi.mock("../../infra/channel-activity.js", () => ({ vi.mock("../../../../src/infra/channel-activity.js", () => ({
recordChannelActivity: (...args: unknown[]) => recordChannelActivity(...args), recordChannelActivity: (...args: unknown[]) => recordChannelActivity(...args),
})); }));

View File

@ -0,0 +1,113 @@
import type { AnyMessageContent, WAPresence } from "@whiskeysockets/baileys";
import { recordChannelActivity } from "../../../../src/infra/channel-activity.js";
import { toWhatsappJid } from "../../../../src/utils.js";
import type { ActiveWebSendOptions } from "../active-listener.js";
function recordWhatsAppOutbound(accountId: string) {
recordChannelActivity({
channel: "whatsapp",
accountId,
direction: "outbound",
});
}
function resolveOutboundMessageId(result: unknown): string {
return typeof result === "object" && result && "key" in result
? String((result as { key?: { id?: string } }).key?.id ?? "unknown")
: "unknown";
}
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 {
const fileName = sendOptions?.fileName?.trim() || "file";
payload = {
document: mediaBuffer,
fileName,
caption: text || undefined,
mimetype: mediaType,
};
}
} else {
payload = { text };
}
const result = await params.sock.sendMessage(jid, payload);
const accountId = sendOptions?.accountId ?? params.defaultAccountId;
recordWhatsAppOutbound(accountId);
const messageId = resolveOutboundMessageId(result);
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);
recordWhatsAppOutbound(params.defaultAccountId);
const messageId = resolveOutboundMessageId(result);
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;
}

View File

@ -0,0 +1,44 @@
import type { AnyMessageContent } from "@whiskeysockets/baileys";
import type { NormalizedLocation } from "../../../../src/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;
replyToSenderJid?: string;
replyToSenderE164?: string;
groupSubject?: string;
groupParticipants?: string[];
mentionedJids?: string[];
selfJid?: string | null;
selfE164?: string | null;
fromMe?: boolean;
location?: NormalizedLocation;
sendComposing: () => Promise<void>;
reply: (text: string) => Promise<void>;
sendMedia: (payload: AnyMessageContent) => Promise<void>;
mediaPath?: string;
mediaType?: string;
mediaFileName?: string;
mediaUrl?: string;
wasMentioned?: boolean;
};

View File

@ -0,0 +1,295 @@
import { randomUUID } from "node:crypto";
import { DisconnectReason } from "@whiskeysockets/baileys";
import { loadConfig } from "../../../src/config/config.js";
import { danger, info, success } from "../../../src/globals.js";
import { logInfo } from "../../../src/logger.js";
import { defaultRuntime, type RuntimeEnv } from "../../../src/runtime.js";
import { resolveWhatsAppAccount } from "./accounts.js";
import { renderQrPngBase64 } from "./qr-image.js";
import {
createWaSocket,
formatError,
getStatusCode,
logoutWeb,
readWebSelfId,
waitForWaConnection,
webAuthExists,
} from "./session.js";
type WaSocket = Awaited<ReturnType<typeof createWaSocket>>;
type ActiveLogin = {
accountId: string;
authDir: string;
isLegacyAuthDir: boolean;
id: string;
sock: WaSocket;
startedAt: number;
qr?: string;
qrDataUrl?: string;
connected: boolean;
error?: string;
errorStatus?: number;
waitPromise: Promise<void>;
restartAttempted: boolean;
verbose: boolean;
};
const ACTIVE_LOGIN_TTL_MS = 3 * 60_000;
const activeLogins = new Map<string, ActiveLogin>();
function closeSocket(sock: WaSocket) {
try {
sock.ws?.close();
} catch {
// ignore
}
}
async function resetActiveLogin(accountId: string, reason?: string) {
const login = activeLogins.get(accountId);
if (login) {
closeSocket(login.sock);
activeLogins.delete(accountId);
}
if (reason) {
logInfo(reason);
}
}
function isLoginFresh(login: ActiveLogin) {
return Date.now() - login.startedAt < ACTIVE_LOGIN_TTL_MS;
}
function attachLoginWaiter(accountId: string, login: ActiveLogin) {
login.waitPromise = waitForWaConnection(login.sock)
.then(() => {
const current = activeLogins.get(accountId);
if (current?.id === login.id) {
current.connected = true;
}
})
.catch((err) => {
const current = activeLogins.get(accountId);
if (current?.id !== login.id) {
return;
}
current.error = formatError(err);
current.errorStatus = getStatusCode(err);
});
}
async function restartLoginSocket(login: ActiveLogin, runtime: RuntimeEnv) {
if (login.restartAttempted) {
return false;
}
login.restartAttempted = true;
runtime.log(
info("WhatsApp asked for a restart after pairing (code 515); retrying connection once…"),
);
closeSocket(login.sock);
try {
const sock = await createWaSocket(false, login.verbose, {
authDir: login.authDir,
});
login.sock = sock;
login.connected = false;
login.error = undefined;
login.errorStatus = undefined;
attachLoginWaiter(login.accountId, login);
return true;
} catch (err) {
login.error = formatError(err);
login.errorStatus = getStatusCode(err);
return false;
}
}
export async function startWebLoginWithQr(
opts: {
verbose?: boolean;
timeoutMs?: number;
force?: boolean;
accountId?: string;
runtime?: RuntimeEnv;
} = {},
): Promise<{ qrDataUrl?: string; message: string }> {
const runtime = opts.runtime ?? defaultRuntime;
const cfg = loadConfig();
const account = resolveWhatsAppAccount({ cfg, accountId: opts.accountId });
const hasWeb = await webAuthExists(account.authDir);
const selfId = readWebSelfId(account.authDir);
if (hasWeb && !opts.force) {
const who = selfId.e164 ?? selfId.jid ?? "unknown";
return {
message: `WhatsApp is already linked (${who}). Say “relink” if you want a fresh QR.`,
};
}
const existing = activeLogins.get(account.accountId);
if (existing && isLoginFresh(existing) && existing.qrDataUrl) {
return {
qrDataUrl: existing.qrDataUrl,
message: "QR already active. Scan it in WhatsApp → Linked Devices.",
};
}
await resetActiveLogin(account.accountId);
let resolveQr: ((qr: string) => void) | null = null;
let rejectQr: ((err: Error) => void) | null = null;
const qrPromise = new Promise<string>((resolve, reject) => {
resolveQr = resolve;
rejectQr = reject;
});
const qrTimer = setTimeout(
() => {
rejectQr?.(new Error("Timed out waiting for WhatsApp QR"));
},
Math.max(opts.timeoutMs ?? 30_000, 5000),
);
let sock: WaSocket;
let pendingQr: string | null = null;
try {
sock = await createWaSocket(false, Boolean(opts.verbose), {
authDir: account.authDir,
onQr: (qr: string) => {
if (pendingQr) {
return;
}
pendingQr = qr;
const current = activeLogins.get(account.accountId);
if (current && !current.qr) {
current.qr = qr;
}
clearTimeout(qrTimer);
runtime.log(info("WhatsApp QR received."));
resolveQr?.(qr);
},
});
} catch (err) {
clearTimeout(qrTimer);
await resetActiveLogin(account.accountId);
return {
message: `Failed to start WhatsApp login: ${String(err)}`,
};
}
const login: ActiveLogin = {
accountId: account.accountId,
authDir: account.authDir,
isLegacyAuthDir: account.isLegacyAuthDir,
id: randomUUID(),
sock,
startedAt: Date.now(),
connected: false,
waitPromise: Promise.resolve(),
restartAttempted: false,
verbose: Boolean(opts.verbose),
};
activeLogins.set(account.accountId, login);
if (pendingQr && !login.qr) {
login.qr = pendingQr;
}
attachLoginWaiter(account.accountId, login);
let qr: string;
try {
qr = await qrPromise;
} catch (err) {
clearTimeout(qrTimer);
await resetActiveLogin(account.accountId);
return {
message: `Failed to get QR: ${String(err)}`,
};
}
const base64 = await renderQrPngBase64(qr);
login.qrDataUrl = `data:image/png;base64,${base64}`;
return {
qrDataUrl: login.qrDataUrl,
message: "Scan this QR in WhatsApp → Linked Devices.",
};
}
export async function waitForWebLogin(
opts: { timeoutMs?: number; runtime?: RuntimeEnv; accountId?: string } = {},
): Promise<{ connected: boolean; message: string }> {
const runtime = opts.runtime ?? defaultRuntime;
const cfg = loadConfig();
const account = resolveWhatsAppAccount({ cfg, accountId: opts.accountId });
const activeLogin = activeLogins.get(account.accountId);
if (!activeLogin) {
return {
connected: false,
message: "No active WhatsApp login in progress.",
};
}
const login = activeLogin;
if (!isLoginFresh(login)) {
await resetActiveLogin(account.accountId);
return {
connected: false,
message: "The login QR expired. Ask me to generate a new one.",
};
}
const timeoutMs = Math.max(opts.timeoutMs ?? 120_000, 1000);
const deadline = Date.now() + timeoutMs;
while (true) {
const remaining = deadline - Date.now();
if (remaining <= 0) {
return {
connected: false,
message: "Still waiting for the QR scan. Let me know when youve scanned it.",
};
}
const timeout = new Promise<"timeout">((resolve) =>
setTimeout(() => resolve("timeout"), remaining),
);
const result = await Promise.race([login.waitPromise.then(() => "done"), timeout]);
if (result === "timeout") {
return {
connected: false,
message: "Still waiting for the QR scan. Let me know when youve scanned it.",
};
}
if (login.error) {
if (login.errorStatus === DisconnectReason.loggedOut) {
await logoutWeb({
authDir: login.authDir,
isLegacyAuthDir: login.isLegacyAuthDir,
runtime,
});
const message =
"WhatsApp reported the session is logged out. Cleared cached web session; please scan a new QR.";
await resetActiveLogin(account.accountId, message);
runtime.log(danger(message));
return { connected: false, message };
}
if (login.errorStatus === 515) {
const restarted = await restartLoginSocket(login, runtime);
if (restarted && isLoginFresh(login)) {
continue;
}
}
const message = `WhatsApp login failed: ${login.error}`;
await resetActiveLogin(account.accountId, message);
runtime.log(danger(message));
return { connected: false, message };
}
if (login.connected) {
const message = "✅ Linked! WhatsApp is ready.";
runtime.log(success(message));
await resetActiveLogin(account.accountId);
return { connected: true, message };
}
return { connected: false, message: "Login ended without a connection." };
}
}

View File

@ -14,7 +14,7 @@ function resolveTestAuthDir() {
const authDir = resolveTestAuthDir(); const authDir = resolveTestAuthDir();
vi.mock("../config/config.js", () => ({ vi.mock("../../../src/config/config.js", () => ({
loadConfig: () => loadConfig: () =>
({ ({
channels: { channels: {

View File

@ -2,7 +2,7 @@ import { EventEmitter } from "node:events";
import { readFile } from "node:fs/promises"; import { readFile } from "node:fs/promises";
import { resolve } from "node:path"; import { resolve } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { resetLogger, setLoggerOverride } from "../logging.js"; import { resetLogger, setLoggerOverride } from "../../../src/logging.js";
import { renderQrPngBase64 } from "./qr-image.js"; import { renderQrPngBase64 } from "./qr-image.js";
vi.mock("./session.js", () => { vi.mock("./session.js", () => {
@ -61,7 +61,7 @@ describe("renderQrPngBase64", () => {
}); });
it("avoids dynamic require of qrcode-terminal vendor modules", async () => { it("avoids dynamic require of qrcode-terminal vendor modules", async () => {
const sourcePath = resolve(process.cwd(), "src/web/qr-image.ts"); const sourcePath = resolve(process.cwd(), "extensions/whatsapp/src/qr-image.ts");
const source = await readFile(sourcePath, "utf-8"); const source = await readFile(sourcePath, "utf-8");
expect(source).not.toContain("createRequire("); expect(source).not.toContain("createRequire(");
expect(source).not.toContain('require("qrcode-terminal/vendor/QRCode")'); expect(source).not.toContain('require("qrcode-terminal/vendor/QRCode")');

View File

@ -0,0 +1,78 @@
import { DisconnectReason } from "@whiskeysockets/baileys";
import { formatCliCommand } from "../../../src/cli/command-format.js";
import { loadConfig } from "../../../src/config/config.js";
import { danger, info, success } from "../../../src/globals.js";
import { logInfo } from "../../../src/logger.js";
import { defaultRuntime, type RuntimeEnv } from "../../../src/runtime.js";
import { resolveWhatsAppAccount } from "./accounts.js";
import { createWaSocket, formatError, logoutWeb, waitForWaConnection } from "./session.js";
export async function loginWeb(
verbose: boolean,
waitForConnection?: typeof waitForWaConnection,
runtime: RuntimeEnv = defaultRuntime,
accountId?: string,
) {
const wait = waitForConnection ?? waitForWaConnection;
const cfg = loadConfig();
const account = resolveWhatsAppAccount({ cfg, accountId });
const sock = await createWaSocket(true, verbose, {
authDir: account.authDir,
});
logInfo("Waiting for WhatsApp connection...", runtime);
try {
await wait(sock);
console.log(success("✅ Linked! Credentials saved for future sends."));
} catch (err) {
const code =
(err as { error?: { output?: { statusCode?: number } } })?.error?.output?.statusCode ??
(err as { output?: { statusCode?: number } })?.output?.statusCode;
if (code === 515) {
console.log(
info(
"WhatsApp asked for a restart after pairing (code 515); creds are saved. Restarting connection once…",
),
);
try {
sock.ws?.close();
} catch {
// ignore
}
const retry = await createWaSocket(false, verbose, {
authDir: account.authDir,
});
try {
await wait(retry);
console.log(success("✅ Linked after restart; web session ready."));
return;
} finally {
setTimeout(() => retry.ws?.close(), 500);
}
}
if (code === DisconnectReason.loggedOut) {
await logoutWeb({
authDir: account.authDir,
isLegacyAuthDir: account.isLegacyAuthDir,
runtime,
});
console.error(
danger(
`WhatsApp reported the session is logged out. Cleared cached web session; please rerun ${formatCliCommand("openclaw channels login")} and scan the QR again.`,
),
);
throw new Error("Session logged out; cache cleared. Re-run login.", { cause: err });
}
const formatted = formatError(err);
console.error(danger(`WhatsApp Web connection ended before fully opening. ${formatted}`));
throw new Error(formatted, { cause: err });
} finally {
// Let Baileys flush any final events before closing the socket.
setTimeout(() => {
try {
sock.ws?.close();
} catch {
// ignore
}
}, 500);
}
}

View File

@ -3,12 +3,12 @@ import os from "node:os";
import path from "node:path"; import path from "node:path";
import sharp from "sharp"; import sharp from "sharp";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { resolveStateDir } from "../config/paths.js"; import { resolveStateDir } from "../../../src/config/paths.js";
import { sendVoiceMessageDiscord } from "../discord/send.js"; import { sendVoiceMessageDiscord } from "../../../src/discord/send.js";
import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; import { resolvePreferredOpenClawTmpDir } from "../../../src/infra/tmp-openclaw-dir.js";
import { optimizeImageToPng } from "../media/image-ops.js"; import { optimizeImageToPng } from "../../../src/media/image-ops.js";
import { mockPinnedHostnameResolution } from "../test-helpers/ssrf.js"; import { mockPinnedHostnameResolution } from "../../../src/test-helpers/ssrf.js";
import { captureEnv } from "../test-utils/env.js"; import { captureEnv } from "../../../src/test-utils/env.js";
import { import {
LocalMediaAccessError, LocalMediaAccessError,
loadWebMedia, loadWebMedia,
@ -18,9 +18,10 @@ import {
const convertHeicToJpegMock = vi.fn(); const convertHeicToJpegMock = vi.fn();
vi.mock("../media/image-ops.js", async () => { vi.mock("../../../src/media/image-ops.js", async () => {
const actual = const actual = await vi.importActual<typeof import("../../../src/media/image-ops.js")>(
await vi.importActual<typeof import("../media/image-ops.js")>("../media/image-ops.js"); "../../../src/media/image-ops.js",
);
return { return {
...actual, ...actual,
convertHeicToJpeg: (...args: unknown[]) => convertHeicToJpegMock(...args), convertHeicToJpeg: (...args: unknown[]) => convertHeicToJpegMock(...args),

View File

@ -0,0 +1,493 @@
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { logVerbose, shouldLogVerbose } from "../../../src/globals.js";
import { SafeOpenError, readLocalFileSafely } from "../../../src/infra/fs-safe.js";
import type { SsrFPolicy } from "../../../src/infra/net/ssrf.js";
import { type MediaKind, maxBytesForKind } from "../../../src/media/constants.js";
import { fetchRemoteMedia } from "../../../src/media/fetch.js";
import {
convertHeicToJpeg,
hasAlphaChannel,
optimizeImageToPng,
resizeToJpeg,
} from "../../../src/media/image-ops.js";
import { getDefaultMediaLocalRoots } from "../../../src/media/local-roots.js";
import { detectMime, extensionForMime, kindFromMime } from "../../../src/media/mime.js";
import { resolveUserPath } from "../../../src/utils.js";
export type WebMediaResult = {
buffer: Buffer;
contentType?: string;
kind: MediaKind | undefined;
fileName?: string;
};
type WebMediaOptions = {
maxBytes?: number;
optimizeImages?: boolean;
ssrfPolicy?: SsrFPolicy;
/** Allowed root directories for local path reads. "any" is deprecated; prefer sandboxValidated + readFile. */
localRoots?: readonly string[] | "any";
/** Caller already validated the local path (sandbox/other guards); requires readFile override. */
sandboxValidated?: boolean;
readFile?: (filePath: string) => Promise<Buffer>;
};
function resolveWebMediaOptions(params: {
maxBytesOrOptions?: number | WebMediaOptions;
options?: { ssrfPolicy?: SsrFPolicy; localRoots?: readonly string[] | "any" };
optimizeImages: boolean;
}): WebMediaOptions {
if (typeof params.maxBytesOrOptions === "number" || params.maxBytesOrOptions === undefined) {
return {
maxBytes: params.maxBytesOrOptions,
optimizeImages: params.optimizeImages,
ssrfPolicy: params.options?.ssrfPolicy,
localRoots: params.options?.localRoots,
};
}
return {
...params.maxBytesOrOptions,
optimizeImages: params.optimizeImages
? (params.maxBytesOrOptions.optimizeImages ?? true)
: false,
};
}
export type LocalMediaAccessErrorCode =
| "path-not-allowed"
| "invalid-root"
| "invalid-file-url"
| "unsafe-bypass"
| "not-found"
| "invalid-path"
| "not-file";
export class LocalMediaAccessError extends Error {
code: LocalMediaAccessErrorCode;
constructor(code: LocalMediaAccessErrorCode, message: string, options?: ErrorOptions) {
super(message, options);
this.code = code;
this.name = "LocalMediaAccessError";
}
}
export function getDefaultLocalRoots(): readonly string[] {
return getDefaultMediaLocalRoots();
}
async function assertLocalMediaAllowed(
mediaPath: string,
localRoots: readonly string[] | "any" | undefined,
): Promise<void> {
if (localRoots === "any") {
return;
}
const roots = localRoots ?? getDefaultLocalRoots();
// Resolve symlinks so a symlink under /tmp pointing to /etc/passwd is caught.
let resolved: string;
try {
resolved = await fs.realpath(mediaPath);
} catch {
resolved = path.resolve(mediaPath);
}
// Hardening: the default allowlist includes the OpenClaw temp dir, and tests/CI may
// override the state dir into tmp. Avoid accidentally allowing per-agent
// `workspace-*` state roots via the temp-root prefix match; require explicit
// localRoots for those.
if (localRoots === undefined) {
const workspaceRoot = roots.find((root) => path.basename(root) === "workspace");
if (workspaceRoot) {
const stateDir = path.dirname(workspaceRoot);
const rel = path.relative(stateDir, resolved);
if (rel && !rel.startsWith("..") && !path.isAbsolute(rel)) {
const firstSegment = rel.split(path.sep)[0] ?? "";
if (firstSegment.startsWith("workspace-")) {
throw new LocalMediaAccessError(
"path-not-allowed",
`Local media path is not under an allowed directory: ${mediaPath}`,
);
}
}
}
}
for (const root of roots) {
let resolvedRoot: string;
try {
resolvedRoot = await fs.realpath(root);
} catch {
resolvedRoot = path.resolve(root);
}
if (resolvedRoot === path.parse(resolvedRoot).root) {
throw new LocalMediaAccessError(
"invalid-root",
`Invalid localRoots entry (refuses filesystem root): ${root}. Pass a narrower directory.`,
);
}
if (resolved === resolvedRoot || resolved.startsWith(resolvedRoot + path.sep)) {
return;
}
}
throw new LocalMediaAccessError(
"path-not-allowed",
`Local media path is not under an allowed directory: ${mediaPath}`,
);
}
const HEIC_MIME_RE = /^image\/hei[cf]$/i;
const HEIC_EXT_RE = /\.(heic|heif)$/i;
const MB = 1024 * 1024;
function formatMb(bytes: number, digits = 2): string {
return (bytes / MB).toFixed(digits);
}
function formatCapLimit(label: string, cap: number, size: number): string {
return `${label} exceeds ${formatMb(cap, 0)}MB limit (got ${formatMb(size)}MB)`;
}
function formatCapReduce(label: string, cap: number, size: number): string {
return `${label} could not be reduced below ${formatMb(cap, 0)}MB (got ${formatMb(size)}MB)`;
}
function isHeicSource(opts: { contentType?: string; fileName?: string }): boolean {
if (opts.contentType && HEIC_MIME_RE.test(opts.contentType.trim())) {
return true;
}
if (opts.fileName && HEIC_EXT_RE.test(opts.fileName.trim())) {
return true;
}
return false;
}
function toJpegFileName(fileName?: string): string | undefined {
if (!fileName) {
return undefined;
}
const trimmed = fileName.trim();
if (!trimmed) {
return fileName;
}
const parsed = path.parse(trimmed);
if (!parsed.ext || HEIC_EXT_RE.test(parsed.ext)) {
return path.format({ dir: parsed.dir, name: parsed.name || trimmed, ext: ".jpg" });
}
return path.format({ dir: parsed.dir, name: parsed.name, ext: ".jpg" });
}
type OptimizedImage = {
buffer: Buffer;
optimizedSize: number;
resizeSide: number;
format: "jpeg" | "png";
quality?: number;
compressionLevel?: number;
};
function logOptimizedImage(params: { originalSize: number; optimized: OptimizedImage }): void {
if (!shouldLogVerbose()) {
return;
}
if (params.optimized.optimizedSize >= params.originalSize) {
return;
}
if (params.optimized.format === "png") {
logVerbose(
`Optimized PNG (preserving alpha) from ${formatMb(params.originalSize)}MB to ${formatMb(params.optimized.optimizedSize)}MB (side≤${params.optimized.resizeSide}px)`,
);
return;
}
logVerbose(
`Optimized media from ${formatMb(params.originalSize)}MB to ${formatMb(params.optimized.optimizedSize)}MB (side≤${params.optimized.resizeSide}px, q=${params.optimized.quality})`,
);
}
async function optimizeImageWithFallback(params: {
buffer: Buffer;
cap: number;
meta?: { contentType?: string; fileName?: string };
}): Promise<OptimizedImage> {
const { buffer, cap, meta } = params;
const isPng = meta?.contentType === "image/png" || meta?.fileName?.toLowerCase().endsWith(".png");
const hasAlpha = isPng && (await hasAlphaChannel(buffer));
if (hasAlpha) {
const optimized = await optimizeImageToPng(buffer, cap);
if (optimized.buffer.length <= cap) {
return { ...optimized, format: "png" };
}
if (shouldLogVerbose()) {
logVerbose(
`PNG with alpha still exceeds ${formatMb(cap, 0)}MB after optimization; falling back to JPEG`,
);
}
}
const optimized = await optimizeImageToJpeg(buffer, cap, meta);
return { ...optimized, format: "jpeg" };
}
async function loadWebMediaInternal(
mediaUrl: string,
options: WebMediaOptions = {},
): Promise<WebMediaResult> {
const {
maxBytes,
optimizeImages = true,
ssrfPolicy,
localRoots,
sandboxValidated = false,
readFile: readFileOverride,
} = options;
// Strip MEDIA: prefix used by agent tools (e.g. TTS) to tag media paths.
// Be lenient: LLM output may add extra whitespace (e.g. " MEDIA : /tmp/x.png").
mediaUrl = mediaUrl.replace(/^\s*MEDIA\s*:\s*/i, "");
// Use fileURLToPath for proper handling of file:// URLs (handles file://localhost/path, etc.)
if (mediaUrl.startsWith("file://")) {
try {
mediaUrl = fileURLToPath(mediaUrl);
} catch {
throw new LocalMediaAccessError("invalid-file-url", `Invalid file:// URL: ${mediaUrl}`);
}
}
const optimizeAndClampImage = async (
buffer: Buffer,
cap: number,
meta?: { contentType?: string; fileName?: string },
) => {
const originalSize = buffer.length;
const optimized = await optimizeImageWithFallback({ buffer, cap, meta });
logOptimizedImage({ originalSize, optimized });
if (optimized.buffer.length > cap) {
throw new Error(formatCapReduce("Media", cap, optimized.buffer.length));
}
const contentType = optimized.format === "png" ? "image/png" : "image/jpeg";
const fileName =
optimized.format === "jpeg" && meta && isHeicSource(meta)
? toJpegFileName(meta.fileName)
: meta?.fileName;
return {
buffer: optimized.buffer,
contentType,
kind: "image" as const,
fileName,
};
};
const clampAndFinalize = async (params: {
buffer: Buffer;
contentType?: string;
kind: MediaKind | undefined;
fileName?: string;
}): Promise<WebMediaResult> => {
// If caller explicitly provides maxBytes, trust it (for channels that handle large files).
// Otherwise fall back to per-kind defaults.
const cap = maxBytes !== undefined ? maxBytes : maxBytesForKind(params.kind ?? "document");
if (params.kind === "image") {
const isGif = params.contentType === "image/gif";
if (isGif || !optimizeImages) {
if (params.buffer.length > cap) {
throw new Error(formatCapLimit(isGif ? "GIF" : "Media", cap, params.buffer.length));
}
return {
buffer: params.buffer,
contentType: params.contentType,
kind: params.kind,
fileName: params.fileName,
};
}
return {
...(await optimizeAndClampImage(params.buffer, cap, {
contentType: params.contentType,
fileName: params.fileName,
})),
};
}
if (params.buffer.length > cap) {
throw new Error(formatCapLimit("Media", cap, params.buffer.length));
}
return {
buffer: params.buffer,
contentType: params.contentType ?? undefined,
kind: params.kind,
fileName: params.fileName,
};
};
if (/^https?:\/\//i.test(mediaUrl)) {
// Enforce a download cap during fetch to avoid unbounded memory usage.
// For optimized images, allow fetching larger payloads before compression.
const defaultFetchCap = maxBytesForKind("document");
const fetchCap =
maxBytes === undefined
? defaultFetchCap
: optimizeImages
? Math.max(maxBytes, defaultFetchCap)
: maxBytes;
const fetched = await fetchRemoteMedia({ url: mediaUrl, maxBytes: fetchCap, ssrfPolicy });
const { buffer, contentType, fileName } = fetched;
const kind = kindFromMime(contentType);
return await clampAndFinalize({ buffer, contentType, kind, fileName });
}
// Expand tilde paths to absolute paths (e.g., ~/Downloads/photo.jpg)
if (mediaUrl.startsWith("~")) {
mediaUrl = resolveUserPath(mediaUrl);
}
if ((sandboxValidated || localRoots === "any") && !readFileOverride) {
throw new LocalMediaAccessError(
"unsafe-bypass",
"Refusing localRoots bypass without readFile override. Use sandboxValidated with readFile, or pass explicit localRoots.",
);
}
// Guard local reads against allowed directory roots to prevent file exfiltration.
if (!(sandboxValidated || localRoots === "any")) {
await assertLocalMediaAllowed(mediaUrl, localRoots);
}
// Local path
let data: Buffer;
if (readFileOverride) {
data = await readFileOverride(mediaUrl);
} else {
try {
data = (await readLocalFileSafely({ filePath: mediaUrl })).buffer;
} catch (err) {
if (err instanceof SafeOpenError) {
if (err.code === "not-found") {
throw new LocalMediaAccessError("not-found", `Local media file not found: ${mediaUrl}`, {
cause: err,
});
}
if (err.code === "not-file") {
throw new LocalMediaAccessError(
"not-file",
`Local media path is not a file: ${mediaUrl}`,
{ cause: err },
);
}
throw new LocalMediaAccessError(
"invalid-path",
`Local media path is not safe to read: ${mediaUrl}`,
{ cause: err },
);
}
throw err;
}
}
const mime = await detectMime({ buffer: data, filePath: mediaUrl });
const kind = kindFromMime(mime);
let fileName = path.basename(mediaUrl) || undefined;
if (fileName && !path.extname(fileName) && mime) {
const ext = extensionForMime(mime);
if (ext) {
fileName = `${fileName}${ext}`;
}
}
return await clampAndFinalize({
buffer: data,
contentType: mime,
kind,
fileName,
});
}
export async function loadWebMedia(
mediaUrl: string,
maxBytesOrOptions?: number | WebMediaOptions,
options?: { ssrfPolicy?: SsrFPolicy; localRoots?: readonly string[] | "any" },
): Promise<WebMediaResult> {
return await loadWebMediaInternal(
mediaUrl,
resolveWebMediaOptions({ maxBytesOrOptions, options, optimizeImages: true }),
);
}
export async function loadWebMediaRaw(
mediaUrl: string,
maxBytesOrOptions?: number | WebMediaOptions,
options?: { ssrfPolicy?: SsrFPolicy; localRoots?: readonly string[] | "any" },
): Promise<WebMediaResult> {
return await loadWebMediaInternal(
mediaUrl,
resolveWebMediaOptions({ maxBytesOrOptions, options, optimizeImages: false }),
);
}
export async function optimizeImageToJpeg(
buffer: Buffer,
maxBytes: number,
opts: { contentType?: string; fileName?: string } = {},
): Promise<{
buffer: Buffer;
optimizedSize: number;
resizeSide: number;
quality: number;
}> {
// Try a grid of sizes/qualities until under the limit.
let source = buffer;
if (isHeicSource(opts)) {
try {
source = await convertHeicToJpeg(buffer);
} catch (err) {
throw new Error(`HEIC image conversion failed: ${String(err)}`, { cause: err });
}
}
const sides = [2048, 1536, 1280, 1024, 800];
const qualities = [80, 70, 60, 50, 40];
let smallest: {
buffer: Buffer;
size: number;
resizeSide: number;
quality: number;
} | null = null;
for (const side of sides) {
for (const quality of qualities) {
try {
const out = await resizeToJpeg({
buffer: source,
maxSide: side,
quality,
withoutEnlargement: true,
});
const size = out.length;
if (!smallest || size < smallest.size) {
smallest = { buffer: out, size, resizeSide: side, quality };
}
if (size <= maxBytes) {
return {
buffer: out,
optimizedSize: size,
resizeSide: side,
quality,
};
}
} catch {
// Continue trying other size/quality combinations
}
}
}
if (smallest) {
return {
buffer: smallest.buffer,
optimizedSize: smallest.size,
resizeSide: smallest.resizeSide,
quality: smallest.quality,
};
}
throw new Error("Failed to optimize image");
}
export { optimizeImageToPng };

View File

@ -4,7 +4,7 @@ import os from "node:os";
import path from "node:path"; import path from "node:path";
import "./monitor-inbox.test-harness.js"; import "./monitor-inbox.test-harness.js";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { setLoggerOverride } from "../logging.js"; import { setLoggerOverride } from "../../../src/logging.js";
import { monitorWebInbox } from "./inbound.js"; import { monitorWebInbox } from "./inbound.js";
import { import {
DEFAULT_ACCOUNT_ID, DEFAULT_ACCOUNT_ID,

View File

@ -3,7 +3,7 @@ import fsSync from "node:fs";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { afterEach, beforeEach, expect, vi } from "vitest"; import { afterEach, beforeEach, expect, vi } from "vitest";
import { resetLogger, setLoggerOverride } from "../logging.js"; import { resetLogger, setLoggerOverride } from "../../../src/logging.js";
// Avoid exporting vitest mock types (TS2742 under pnpm + d.ts emit). // Avoid exporting vitest mock types (TS2742 under pnpm + d.ts emit).
// oxlint-disable-next-line typescript/no-explicit-any // oxlint-disable-next-line typescript/no-explicit-any
@ -81,24 +81,28 @@ function getPairingStoreMocks() {
const sock: MockSock = createMockSock(); const sock: MockSock = createMockSock();
vi.mock("../media/store.js", () => ({ vi.mock("../../../src/media/store.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../../src/media/store.js")>();
return {
...actual,
saveMediaBuffer: vi.fn().mockResolvedValue({ saveMediaBuffer: vi.fn().mockResolvedValue({
id: "mid", id: "mid",
path: "/tmp/mid", path: "/tmp/mid",
size: 1, size: 1,
contentType: "image/jpeg", contentType: "image/jpeg",
}), }),
})); };
});
vi.mock("../config/config.js", async (importOriginal) => { vi.mock("../../../src/config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>(); const actual = await importOriginal<typeof import("../../../src/config/config.js")>();
return { return {
...actual, ...actual,
loadConfig: () => mockLoadConfig(), loadConfig: () => mockLoadConfig(),
}; };
}); });
vi.mock("../pairing/pairing-store.js", () => getPairingStoreMocks()); vi.mock("../../../src/pairing/pairing-store.js", () => getPairingStoreMocks());
vi.mock("./session.js", () => ({ vi.mock("./session.js", () => ({
createWaSocket: vi.fn().mockResolvedValue(sock), createWaSocket: vi.fn().mockResolvedValue(sock),

View File

@ -0,0 +1,28 @@
import {
looksLikeHandleOrPhoneTarget,
trimMessagingTarget,
} from "../../../src/channels/plugins/normalize/shared.js";
import { normalizeWhatsAppTarget } from "../../../src/whatsapp/normalize.js";
export function normalizeWhatsAppMessagingTarget(raw: string): string | undefined {
const trimmed = trimMessagingTarget(raw);
if (!trimmed) {
return undefined;
}
return normalizeWhatsAppTarget(trimmed) ?? undefined;
}
export function normalizeWhatsAppAllowFromEntries(allowFrom: Array<string | number>): string[] {
return allowFrom
.map((entry) => String(entry).trim())
.filter((entry): entry is string => Boolean(entry))
.map((entry) => (entry === "*" ? entry : normalizeWhatsAppTarget(entry)))
.filter((entry): entry is string => Boolean(entry));
}
export function looksLikeWhatsAppTargetId(raw: string): boolean {
return looksLikeHandleOrPhoneTarget({
raw,
prefixPattern: /^whatsapp:/i,
});
}

View File

@ -1,8 +1,8 @@
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_ACCOUNT_ID } from "../../../routing/session-key.js"; import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js";
import type { RuntimeEnv } from "../../../runtime.js"; import type { RuntimeEnv } from "../../../src/runtime.js";
import type { WizardPrompter } from "../../../wizard/prompts.js"; import type { WizardPrompter } from "../../../src/wizard/prompts.js";
import { whatsappOnboardingAdapter } from "./whatsapp.js"; import { whatsappOnboardingAdapter } from "./onboarding.js";
const loginWebMock = vi.hoisted(() => vi.fn(async () => {})); const loginWebMock = vi.hoisted(() => vi.fn(async () => {}));
const pathExistsMock = vi.hoisted(() => vi.fn(async () => false)); const pathExistsMock = vi.hoisted(() => vi.fn(async () => false));
@ -14,19 +14,20 @@ const resolveWhatsAppAuthDirMock = vi.hoisted(() =>
})), })),
); );
vi.mock("../../../channel-web.js", () => ({ vi.mock("../../../src/channel-web.js", () => ({
loginWeb: loginWebMock, loginWeb: loginWebMock,
})); }));
vi.mock("../../../utils.js", async () => { vi.mock("../../../src/utils.js", async () => {
const actual = await vi.importActual<typeof import("../../../utils.js")>("../../../utils.js"); const actual =
await vi.importActual<typeof import("../../../src/utils.js")>("../../../src/utils.js");
return { return {
...actual, ...actual,
pathExists: pathExistsMock, pathExists: pathExistsMock,
}; };
}); });
vi.mock("../../../web/accounts.js", () => ({ vi.mock("./accounts.js", () => ({
listWhatsAppAccountIds: listWhatsAppAccountIdsMock, listWhatsAppAccountIds: listWhatsAppAccountIdsMock,
resolveDefaultWhatsAppAccountId: resolveDefaultWhatsAppAccountIdMock, resolveDefaultWhatsAppAccountId: resolveDefaultWhatsAppAccountIdMock,
resolveWhatsAppAuthDir: resolveWhatsAppAuthDirMock, resolveWhatsAppAuthDir: resolveWhatsAppAuthDirMock,

View File

@ -0,0 +1,354 @@
import path from "node:path";
import { loginWeb } from "../../../src/channel-web.js";
import type { ChannelOnboardingAdapter } from "../../../src/channels/plugins/onboarding-types.js";
import {
normalizeAllowFromEntries,
resolveAccountIdForConfigure,
resolveOnboardingAccountId,
splitOnboardingEntries,
} from "../../../src/channels/plugins/onboarding/helpers.js";
import { formatCliCommand } from "../../../src/cli/command-format.js";
import type { OpenClawConfig } from "../../../src/config/config.js";
import { mergeWhatsAppConfig } from "../../../src/config/merge-config.js";
import type { DmPolicy } from "../../../src/config/types.js";
import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js";
import type { RuntimeEnv } from "../../../src/runtime.js";
import { formatDocsLink } from "../../../src/terminal/links.js";
import { normalizeE164, pathExists } from "../../../src/utils.js";
import type { WizardPrompter } from "../../../src/wizard/prompts.js";
import {
listWhatsAppAccountIds,
resolveDefaultWhatsAppAccountId,
resolveWhatsAppAuthDir,
} from "./accounts.js";
const channel = "whatsapp" as const;
function setWhatsAppDmPolicy(cfg: OpenClawConfig, dmPolicy: DmPolicy): OpenClawConfig {
return mergeWhatsAppConfig(cfg, { dmPolicy });
}
function setWhatsAppAllowFrom(cfg: OpenClawConfig, allowFrom?: string[]): OpenClawConfig {
return mergeWhatsAppConfig(cfg, { allowFrom }, { unsetOnUndefined: ["allowFrom"] });
}
function setWhatsAppSelfChatMode(cfg: OpenClawConfig, selfChatMode: boolean): OpenClawConfig {
return mergeWhatsAppConfig(cfg, { selfChatMode });
}
async function detectWhatsAppLinked(cfg: OpenClawConfig, accountId: string): Promise<boolean> {
const { authDir } = resolveWhatsAppAuthDir({ cfg, accountId });
const credsPath = path.join(authDir, "creds.json");
return await pathExists(credsPath);
}
async function promptWhatsAppOwnerAllowFrom(params: {
prompter: WizardPrompter;
existingAllowFrom: string[];
}): Promise<{ normalized: string; allowFrom: string[] }> {
const { prompter, existingAllowFrom } = params;
await prompter.note(
"We need the sender/owner number so OpenClaw can allowlist you.",
"WhatsApp number",
);
const entry = await prompter.text({
message: "Your personal WhatsApp number (the phone you will message from)",
placeholder: "+15555550123",
initialValue: existingAllowFrom[0],
validate: (value) => {
const raw = String(value ?? "").trim();
if (!raw) {
return "Required";
}
const normalized = normalizeE164(raw);
if (!normalized) {
return `Invalid number: ${raw}`;
}
return undefined;
},
});
const normalized = normalizeE164(String(entry).trim());
if (!normalized) {
throw new Error("Invalid WhatsApp owner number (expected E.164 after validation).");
}
const allowFrom = normalizeAllowFromEntries(
[...existingAllowFrom.filter((item) => item !== "*"), normalized],
normalizeE164,
);
return { normalized, allowFrom };
}
async function applyWhatsAppOwnerAllowlist(params: {
cfg: OpenClawConfig;
prompter: WizardPrompter;
existingAllowFrom: string[];
title: string;
messageLines: string[];
}): Promise<OpenClawConfig> {
const { normalized, allowFrom } = await promptWhatsAppOwnerAllowFrom({
prompter: params.prompter,
existingAllowFrom: params.existingAllowFrom,
});
let next = setWhatsAppSelfChatMode(params.cfg, true);
next = setWhatsAppDmPolicy(next, "allowlist");
next = setWhatsAppAllowFrom(next, allowFrom);
await params.prompter.note(
[...params.messageLines, `- allowFrom includes ${normalized}`].join("\n"),
params.title,
);
return next;
}
function parseWhatsAppAllowFromEntries(raw: string): { entries: string[]; invalidEntry?: string } {
const parts = splitOnboardingEntries(raw);
if (parts.length === 0) {
return { entries: [] };
}
const entries: string[] = [];
for (const part of parts) {
if (part === "*") {
entries.push("*");
continue;
}
const normalized = normalizeE164(part);
if (!normalized) {
return { entries: [], invalidEntry: part };
}
entries.push(normalized);
}
return { entries: normalizeAllowFromEntries(entries, normalizeE164) };
}
async function promptWhatsAppAllowFrom(
cfg: OpenClawConfig,
_runtime: RuntimeEnv,
prompter: WizardPrompter,
options?: { forceAllowlist?: boolean },
): Promise<OpenClawConfig> {
const existingPolicy = cfg.channels?.whatsapp?.dmPolicy ?? "pairing";
const existingAllowFrom = cfg.channels?.whatsapp?.allowFrom ?? [];
const existingLabel = existingAllowFrom.length > 0 ? existingAllowFrom.join(", ") : "unset";
if (options?.forceAllowlist) {
return await applyWhatsAppOwnerAllowlist({
cfg,
prompter,
existingAllowFrom,
title: "WhatsApp allowlist",
messageLines: ["Allowlist mode enabled."],
});
}
await prompter.note(
[
"WhatsApp direct chats are gated by `channels.whatsapp.dmPolicy` + `channels.whatsapp.allowFrom`.",
"- pairing (default): unknown senders get a pairing code; owner approves",
"- allowlist: unknown senders are blocked",
'- open: public inbound DMs (requires allowFrom to include "*")',
"- disabled: ignore WhatsApp DMs",
"",
`Current: dmPolicy=${existingPolicy}, allowFrom=${existingLabel}`,
`Docs: ${formatDocsLink("/whatsapp", "whatsapp")}`,
].join("\n"),
"WhatsApp DM access",
);
const phoneMode = await prompter.select({
message: "WhatsApp phone setup",
options: [
{ value: "personal", label: "This is my personal phone number" },
{ value: "separate", label: "Separate phone just for OpenClaw" },
],
});
if (phoneMode === "personal") {
return await applyWhatsAppOwnerAllowlist({
cfg,
prompter,
existingAllowFrom,
title: "WhatsApp personal phone",
messageLines: [
"Personal phone mode enabled.",
"- dmPolicy set to allowlist (pairing skipped)",
],
});
}
const policy = (await prompter.select({
message: "WhatsApp DM policy",
options: [
{ value: "pairing", label: "Pairing (recommended)" },
{ value: "allowlist", label: "Allowlist only (block unknown senders)" },
{ value: "open", label: "Open (public inbound DMs)" },
{ value: "disabled", label: "Disabled (ignore WhatsApp DMs)" },
],
})) as DmPolicy;
let next = setWhatsAppSelfChatMode(cfg, false);
next = setWhatsAppDmPolicy(next, policy);
if (policy === "open") {
const allowFrom = normalizeAllowFromEntries(["*", ...existingAllowFrom], normalizeE164);
next = setWhatsAppAllowFrom(next, allowFrom.length > 0 ? allowFrom : ["*"]);
return next;
}
if (policy === "disabled") {
return next;
}
const allowOptions =
existingAllowFrom.length > 0
? ([
{ value: "keep", label: "Keep current allowFrom" },
{
value: "unset",
label: "Unset allowFrom (use pairing approvals only)",
},
{ value: "list", label: "Set allowFrom to specific numbers" },
] as const)
: ([
{ value: "unset", label: "Unset allowFrom (default)" },
{ value: "list", label: "Set allowFrom to specific numbers" },
] as const);
const mode = await prompter.select({
message: "WhatsApp allowFrom (optional pre-allowlist)",
options: allowOptions.map((opt) => ({
value: opt.value,
label: opt.label,
})),
});
if (mode === "keep") {
// Keep allowFrom as-is.
} else if (mode === "unset") {
next = setWhatsAppAllowFrom(next, undefined);
} else {
const allowRaw = await prompter.text({
message: "Allowed sender numbers (comma-separated, E.164)",
placeholder: "+15555550123, +447700900123",
validate: (value) => {
const raw = String(value ?? "").trim();
if (!raw) {
return "Required";
}
const parsed = parseWhatsAppAllowFromEntries(raw);
if (parsed.entries.length === 0 && !parsed.invalidEntry) {
return "Required";
}
if (parsed.invalidEntry) {
return `Invalid number: ${parsed.invalidEntry}`;
}
return undefined;
},
});
const parsed = parseWhatsAppAllowFromEntries(String(allowRaw));
next = setWhatsAppAllowFrom(next, parsed.entries);
}
return next;
}
export const whatsappOnboardingAdapter: ChannelOnboardingAdapter = {
channel,
getStatus: async ({ cfg, accountOverrides }) => {
const defaultAccountId = resolveDefaultWhatsAppAccountId(cfg);
const accountId = resolveOnboardingAccountId({
accountId: accountOverrides.whatsapp,
defaultAccountId,
});
const linked = await detectWhatsAppLinked(cfg, accountId);
const accountLabel = accountId === DEFAULT_ACCOUNT_ID ? "default" : accountId;
return {
channel,
configured: linked,
statusLines: [`WhatsApp (${accountLabel}): ${linked ? "linked" : "not linked"}`],
selectionHint: linked ? "linked" : "not linked",
quickstartScore: linked ? 5 : 4,
};
},
configure: async ({
cfg,
runtime,
prompter,
options,
accountOverrides,
shouldPromptAccountIds,
forceAllowFrom,
}) => {
const accountId = await resolveAccountIdForConfigure({
cfg,
prompter,
label: "WhatsApp",
accountOverride: accountOverrides.whatsapp,
shouldPromptAccountIds: Boolean(shouldPromptAccountIds || options?.promptWhatsAppAccountId),
listAccountIds: listWhatsAppAccountIds,
defaultAccountId: resolveDefaultWhatsAppAccountId(cfg),
});
let next = cfg;
if (accountId !== DEFAULT_ACCOUNT_ID) {
next = {
...next,
channels: {
...next.channels,
whatsapp: {
...next.channels?.whatsapp,
accounts: {
...next.channels?.whatsapp?.accounts,
[accountId]: {
...next.channels?.whatsapp?.accounts?.[accountId],
enabled: next.channels?.whatsapp?.accounts?.[accountId]?.enabled ?? true,
},
},
},
},
};
}
const linked = await detectWhatsAppLinked(next, accountId);
const { authDir } = resolveWhatsAppAuthDir({
cfg: next,
accountId,
});
if (!linked) {
await prompter.note(
[
"Scan the QR with WhatsApp on your phone.",
`Credentials are stored under ${authDir}/ for future runs.`,
`Docs: ${formatDocsLink("/whatsapp", "whatsapp")}`,
].join("\n"),
"WhatsApp linking",
);
}
const wantsLink = await prompter.confirm({
message: linked ? "WhatsApp already linked. Re-link now?" : "Link WhatsApp now (QR)?",
initialValue: !linked,
});
if (wantsLink) {
try {
await loginWeb(false, undefined, runtime, accountId);
} catch (err) {
runtime.error(`WhatsApp login failed: ${String(err)}`);
await prompter.note(`Docs: ${formatDocsLink("/whatsapp", "whatsapp")}`, "WhatsApp help");
}
} else if (!linked) {
await prompter.note(
`Run \`${formatCliCommand("openclaw channels login")}\` later to link WhatsApp.`,
"WhatsApp",
);
}
next = await promptWhatsAppAllowFrom(next, runtime, prompter, {
forceAllowlist: forceAllowFrom,
});
return { cfg: next, accountId };
},
onAccountRecorded: (accountId, options) => {
options?.onWhatsAppAccountId?.(accountId);
},
};

View File

@ -1,35 +1,41 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { import type { OpenClawConfig } from "../../../src/config/config.js";
createWhatsAppPollFixture,
expectWhatsAppPollSent,
} from "../../../test-helpers/whatsapp-outbound.js";
const hoisted = vi.hoisted(() => ({ const hoisted = vi.hoisted(() => ({
sendPollWhatsApp: vi.fn(async () => ({ messageId: "poll-1", toJid: "1555@s.whatsapp.net" })), sendPollWhatsApp: vi.fn(async () => ({ messageId: "poll-1", toJid: "1555@s.whatsapp.net" })),
})); }));
vi.mock("../../../globals.js", () => ({ vi.mock("../../../src/globals.js", () => ({
shouldLogVerbose: () => false, shouldLogVerbose: () => false,
})); }));
vi.mock("../../../web/outbound.js", () => ({ vi.mock("./send.js", () => ({
sendPollWhatsApp: hoisted.sendPollWhatsApp, sendPollWhatsApp: hoisted.sendPollWhatsApp,
})); }));
import { whatsappOutbound } from "./whatsapp.js"; import { whatsappOutbound } from "./outbound-adapter.js";
describe("whatsappOutbound sendPoll", () => { describe("whatsappOutbound sendPoll", () => {
it("threads cfg through poll send options", async () => { it("threads cfg through poll send options", async () => {
const { cfg, poll, to, accountId } = createWhatsAppPollFixture(); const cfg = { marker: "resolved-cfg" } as OpenClawConfig;
const poll = {
question: "Lunch?",
options: ["Pizza", "Sushi"],
maxSelections: 1,
};
const result = await whatsappOutbound.sendPoll!({ const result = await whatsappOutbound.sendPoll!({
cfg, cfg,
to, to: "+1555",
poll, poll,
accountId, accountId: "work",
}); });
expectWhatsAppPollSent(hoisted.sendPollWhatsApp, { cfg, poll, to, accountId }); expect(hoisted.sendPollWhatsApp).toHaveBeenCalledWith("+1555", poll, {
verbose: false,
accountId: "work",
cfg,
});
expect(result).toEqual({ messageId: "poll-1", toJid: "1555@s.whatsapp.net" }); expect(result).toEqual({ messageId: "poll-1", toJid: "1555@s.whatsapp.net" });
}); });
}); });

View File

@ -1,10 +1,10 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import type { ReplyPayload } from "../../../auto-reply/types.js"; import type { ReplyPayload } from "../../../src/auto-reply/types.js";
import { import {
installSendPayloadContractSuite, installSendPayloadContractSuite,
primeSendMock, primeSendMock,
} from "../../../test-utils/send-payload-contract.js"; } from "../../../src/test-utils/send-payload-contract.js";
import { whatsappOutbound } from "./whatsapp.js"; import { whatsappOutbound } from "./outbound-adapter.js";
function createHarness(params: { function createHarness(params: {
payload: ReplyPayload; payload: ReplyPayload;

View File

@ -0,0 +1,71 @@
import { chunkText } from "../../../src/auto-reply/chunk.js";
import { sendTextMediaPayload } from "../../../src/channels/plugins/outbound/direct-text-media.js";
import type { ChannelOutboundAdapter } from "../../../src/channels/plugins/types.js";
import { shouldLogVerbose } from "../../../src/globals.js";
import { resolveWhatsAppOutboundTarget } from "../../../src/whatsapp/resolve-outbound-target.js";
import { sendPollWhatsApp } from "./send.js";
function trimLeadingWhitespace(text: string | undefined): string {
return text?.trimStart() ?? "";
}
export const whatsappOutbound: ChannelOutboundAdapter = {
deliveryMode: "gateway",
chunker: chunkText,
chunkerMode: "text",
textChunkLimit: 4000,
pollMaxOptions: 12,
resolveTarget: ({ to, allowFrom, mode }) =>
resolveWhatsAppOutboundTarget({ to, allowFrom, mode }),
sendPayload: async (ctx) => {
const text = trimLeadingWhitespace(ctx.payload.text);
const hasMedia = Boolean(ctx.payload.mediaUrl) || (ctx.payload.mediaUrls?.length ?? 0) > 0;
if (!text && !hasMedia) {
return { channel: "whatsapp", messageId: "" };
}
return await sendTextMediaPayload({
channel: "whatsapp",
ctx: {
...ctx,
payload: {
...ctx.payload,
text,
},
},
adapter: whatsappOutbound,
});
},
sendText: async ({ cfg, to, text, accountId, deps, gifPlayback }) => {
const normalizedText = trimLeadingWhitespace(text);
if (!normalizedText) {
return { channel: "whatsapp", messageId: "" };
}
const send = deps?.sendWhatsApp ?? (await import("./send.js")).sendMessageWhatsApp;
const result = await send(to, normalizedText, {
verbose: false,
cfg,
accountId: accountId ?? undefined,
gifPlayback,
});
return { channel: "whatsapp", ...result };
},
sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots, accountId, deps, gifPlayback }) => {
const normalizedText = trimLeadingWhitespace(text);
const send = deps?.sendWhatsApp ?? (await import("./send.js")).sendMessageWhatsApp;
const result = await send(to, normalizedText, {
verbose: false,
cfg,
mediaUrl,
mediaLocalRoots,
accountId: accountId ?? undefined,
gifPlayback,
});
return { channel: "whatsapp", ...result };
},
sendPoll: async ({ cfg, to, poll, accountId }) =>
await sendPollWhatsApp(to, poll, {
verbose: shouldLogVerbose(),
accountId: accountId ?? undefined,
cfg,
}),
};

View File

@ -0,0 +1,54 @@
import QRCodeModule from "qrcode-terminal/vendor/QRCode/index.js";
import QRErrorCorrectLevelModule from "qrcode-terminal/vendor/QRCode/QRErrorCorrectLevel.js";
import { encodePngRgba, fillPixel } from "../../../src/media/png-encode.js";
type QRCodeConstructor = new (
typeNumber: number,
errorCorrectLevel: unknown,
) => {
addData: (data: string) => void;
make: () => void;
getModuleCount: () => number;
isDark: (row: number, col: number) => boolean;
};
const QRCode = QRCodeModule as QRCodeConstructor;
const QRErrorCorrectLevel = QRErrorCorrectLevelModule;
function createQrMatrix(input: string) {
const qr = new QRCode(-1, QRErrorCorrectLevel.L);
qr.addData(input);
qr.make();
return qr;
}
export async function renderQrPngBase64(
input: string,
opts: { scale?: number; marginModules?: number } = {},
): Promise<string> {
const { scale = 6, marginModules = 4 } = opts;
const qr = createQrMatrix(input);
const modules = qr.getModuleCount();
const size = (modules + marginModules * 2) * scale;
const buf = Buffer.alloc(size * size * 4, 255);
for (let row = 0; row < modules; row += 1) {
for (let col = 0; col < modules; col += 1) {
if (!qr.isDark(row, col)) {
continue;
}
const startX = (col + marginModules) * scale;
const startY = (row + marginModules) * scale;
for (let y = 0; y < scale; y += 1) {
const pixelY = startY + y;
for (let x = 0; x < scale; x += 1) {
const pixelX = startX + x;
fillPixel(buf, pixelX, pixelY, size, 0, 0, 0, 255);
}
}
}
}
const png = encodePngRgba(buf, size, size);
return png.toString("base64");
}

View File

@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js"; import type { OpenClawConfig } from "../../../src/config/config.js";
import { import {
computeBackoff, computeBackoff,
DEFAULT_HEARTBEAT_SECONDS, DEFAULT_HEARTBEAT_SECONDS,

View File

@ -0,0 +1,52 @@
import { randomUUID } from "node:crypto";
import type { OpenClawConfig } from "../../../src/config/config.js";
import type { BackoffPolicy } from "../../../src/infra/backoff.js";
import { computeBackoff, sleepWithAbort } from "../../../src/infra/backoff.js";
import { clamp } from "../../../src/utils.js";
export type ReconnectPolicy = BackoffPolicy & {
maxAttempts: number;
};
export const DEFAULT_HEARTBEAT_SECONDS = 60;
export const DEFAULT_RECONNECT_POLICY: ReconnectPolicy = {
initialMs: 2_000,
maxMs: 30_000,
factor: 1.8,
jitter: 0.25,
maxAttempts: 12,
};
export function resolveHeartbeatSeconds(cfg: OpenClawConfig, overrideSeconds?: number): number {
const candidate = overrideSeconds ?? cfg.web?.heartbeatSeconds;
if (typeof candidate === "number" && candidate > 0) {
return candidate;
}
return DEFAULT_HEARTBEAT_SECONDS;
}
export function resolveReconnectPolicy(
cfg: OpenClawConfig,
overrides?: Partial<ReconnectPolicy>,
): ReconnectPolicy {
const reconnectOverrides = cfg.web?.reconnect ?? {};
const overrideConfig = overrides ?? {};
const merged = {
...DEFAULT_RECONNECT_POLICY,
...reconnectOverrides,
...overrideConfig,
} as ReconnectPolicy;
merged.initialMs = Math.max(250, merged.initialMs);
merged.maxMs = Math.max(merged.initialMs, merged.maxMs);
merged.factor = clamp(merged.factor, 1.1, 10);
merged.jitter = clamp(merged.jitter, 0, 1);
merged.maxAttempts = Math.max(0, Math.floor(merged.maxAttempts));
return merged;
}
export { computeBackoff, sleepWithAbort };
export function newConnectionId() {
return randomUUID();
}

View File

@ -3,9 +3,9 @@ import fsSync from "node:fs";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js"; import type { OpenClawConfig } from "../../../src/config/config.js";
import { resetLogger, setLoggerOverride } from "../logging.js"; import { resetLogger, setLoggerOverride } from "../../../src/logging.js";
import { redactIdentifier } from "../logging/redact-identifier.js"; import { redactIdentifier } from "../../../src/logging/redact-identifier.js";
import { setActiveWebListener } from "./active-listener.js"; import { setActiveWebListener } from "./active-listener.js";
const loadWebMediaMock = vi.fn(); const loadWebMediaMock = vi.fn();
@ -13,7 +13,7 @@ vi.mock("./media.js", () => ({
loadWebMedia: (...args: unknown[]) => loadWebMediaMock(...args), loadWebMedia: (...args: unknown[]) => loadWebMediaMock(...args),
})); }));
import { sendMessageWhatsApp, sendPollWhatsApp, sendReactionWhatsApp } from "./outbound.js"; import { sendMessageWhatsApp, sendPollWhatsApp, sendReactionWhatsApp } from "./send.js";
describe("web outbound", () => { describe("web outbound", () => {
const sendComposingTo = vi.fn(async () => {}); const sendComposingTo = vi.fn(async () => {});

View File

@ -0,0 +1,197 @@
import { loadConfig, type OpenClawConfig } from "../../../src/config/config.js";
import { resolveMarkdownTableMode } from "../../../src/config/markdown-tables.js";
import { generateSecureUuid } from "../../../src/infra/secure-random.js";
import { getChildLogger } from "../../../src/logging/logger.js";
import { redactIdentifier } from "../../../src/logging/redact-identifier.js";
import { createSubsystemLogger } from "../../../src/logging/subsystem.js";
import { convertMarkdownTables } from "../../../src/markdown/tables.js";
import { markdownToWhatsApp } from "../../../src/markdown/whatsapp.js";
import { normalizePollInput, type PollInput } from "../../../src/polls.js";
import { toWhatsappJid } from "../../../src/utils.js";
import { resolveWhatsAppAccount, resolveWhatsAppMediaMaxBytes } from "./accounts.js";
import { type ActiveWebSendOptions, requireActiveWebListener } from "./active-listener.js";
import { loadWebMedia } from "./media.js";
const outboundLog = createSubsystemLogger("gateway/channels/whatsapp").child("outbound");
export async function sendMessageWhatsApp(
to: string,
body: string,
options: {
verbose: boolean;
cfg?: OpenClawConfig;
mediaUrl?: string;
mediaLocalRoots?: readonly string[];
gifPlayback?: boolean;
accountId?: string;
},
): Promise<{ messageId: string; toJid: string }> {
let text = body.trimStart();
const jid = toWhatsappJid(to);
if (!text && !options.mediaUrl) {
return { messageId: "", toJid: jid };
}
const correlationId = generateSecureUuid();
const startedAt = Date.now();
const { listener: active, accountId: resolvedAccountId } = requireActiveWebListener(
options.accountId,
);
const cfg = options.cfg ?? loadConfig();
const account = resolveWhatsAppAccount({
cfg,
accountId: resolvedAccountId ?? options.accountId,
});
const tableMode = resolveMarkdownTableMode({
cfg,
channel: "whatsapp",
accountId: resolvedAccountId ?? options.accountId,
});
text = convertMarkdownTables(text ?? "", tableMode);
text = markdownToWhatsApp(text);
const redactedTo = redactIdentifier(to);
const logger = getChildLogger({
module: "web-outbound",
correlationId,
to: redactedTo,
});
try {
const redactedJid = redactIdentifier(jid);
let mediaBuffer: Buffer | undefined;
let mediaType: string | undefined;
let documentFileName: string | undefined;
if (options.mediaUrl) {
const media = await loadWebMedia(options.mediaUrl, {
maxBytes: resolveWhatsAppMediaMaxBytes(account),
localRoots: options.mediaLocalRoots,
});
const caption = text || undefined;
mediaBuffer = media.buffer;
mediaType = media.contentType;
if (media.kind === "audio") {
// WhatsApp expects explicit opus codec for PTT voice notes.
mediaType =
media.contentType === "audio/ogg"
? "audio/ogg; codecs=opus"
: (media.contentType ?? "application/octet-stream");
} else if (media.kind === "video") {
text = caption ?? "";
} else if (media.kind === "image") {
text = caption ?? "";
} else {
text = caption ?? "";
documentFileName = media.fileName;
}
}
outboundLog.info(`Sending message -> ${redactedJid}${options.mediaUrl ? " (media)" : ""}`);
logger.info({ jid: redactedJid, hasMedia: Boolean(options.mediaUrl) }, "sending message");
await active.sendComposingTo(to);
const hasExplicitAccountId = Boolean(options.accountId?.trim());
const accountId = hasExplicitAccountId ? resolvedAccountId : undefined;
const sendOptions: ActiveWebSendOptions | undefined =
options.gifPlayback || accountId || documentFileName
? {
...(options.gifPlayback ? { gifPlayback: true } : {}),
...(documentFileName ? { fileName: documentFileName } : {}),
accountId,
}
: undefined;
const result = sendOptions
? await active.sendMessage(to, text, mediaBuffer, mediaType, sendOptions)
: await active.sendMessage(to, text, mediaBuffer, mediaType);
const messageId = (result as { messageId?: string })?.messageId ?? "unknown";
const durationMs = Date.now() - startedAt;
outboundLog.info(
`Sent message ${messageId} -> ${redactedJid}${options.mediaUrl ? " (media)" : ""} (${durationMs}ms)`,
);
logger.info({ jid: redactedJid, messageId }, "sent message");
return { messageId, toJid: jid };
} catch (err) {
logger.error(
{ err: String(err), to: redactedTo, hasMedia: Boolean(options.mediaUrl) },
"failed to send via web session",
);
throw err;
}
}
export async function sendReactionWhatsApp(
chatJid: string,
messageId: string,
emoji: string,
options: {
verbose: boolean;
fromMe?: boolean;
participant?: string;
accountId?: string;
},
): Promise<void> {
const correlationId = generateSecureUuid();
const { listener: active } = requireActiveWebListener(options.accountId);
const redactedChatJid = redactIdentifier(chatJid);
const logger = getChildLogger({
module: "web-outbound",
correlationId,
chatJid: redactedChatJid,
messageId,
});
try {
const jid = toWhatsappJid(chatJid);
const redactedJid = redactIdentifier(jid);
outboundLog.info(`Sending reaction "${emoji}" -> message ${messageId}`);
logger.info({ chatJid: redactedJid, messageId, emoji }, "sending reaction");
await active.sendReaction(
chatJid,
messageId,
emoji,
options.fromMe ?? false,
options.participant,
);
outboundLog.info(`Sent reaction "${emoji}" -> message ${messageId}`);
logger.info({ chatJid: redactedJid, messageId, emoji }, "sent reaction");
} catch (err) {
logger.error(
{ err: String(err), chatJid: redactedChatJid, messageId, emoji },
"failed to send reaction via web session",
);
throw err;
}
}
export async function sendPollWhatsApp(
to: string,
poll: PollInput,
options: { verbose: boolean; accountId?: string; cfg?: OpenClawConfig },
): Promise<{ messageId: string; toJid: string }> {
const correlationId = generateSecureUuid();
const startedAt = Date.now();
const { listener: active } = requireActiveWebListener(options.accountId);
const redactedTo = redactIdentifier(to);
const logger = getChildLogger({
module: "web-outbound",
correlationId,
to: redactedTo,
});
try {
const jid = toWhatsappJid(to);
const redactedJid = redactIdentifier(jid);
const normalized = normalizePollInput(poll, { maxOptions: 12 });
outboundLog.info(`Sending poll -> ${redactedJid}`);
logger.info(
{
jid: redactedJid,
optionCount: normalized.options.length,
maxSelections: normalized.maxSelections,
},
"sending poll",
);
const result = await active.sendPoll(to, normalized);
const messageId = (result as { messageId?: string })?.messageId ?? "unknown";
const durationMs = Date.now() - startedAt;
outboundLog.info(`Sent poll ${messageId} -> ${redactedJid} (${durationMs}ms)`);
logger.info({ jid: redactedJid, messageId }, "sent poll");
return { messageId, toJid: jid };
} catch (err) {
logger.error({ err: String(err), to: redactedTo }, "failed to send poll via web session");
throw err;
}
}

View File

@ -2,7 +2,7 @@ import { EventEmitter } from "node:events";
import fsSync from "node:fs"; import fsSync from "node:fs";
import path from "node:path"; import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { resetLogger, setLoggerOverride } from "../logging.js"; import { resetLogger, setLoggerOverride } from "../../../src/logging.js";
import { baileys, getLastSocket, resetBaileysMocks, resetLoadConfigMock } from "./test-helpers.js"; import { baileys, getLastSocket, resetBaileysMocks, resetLoadConfigMock } from "./test-helpers.js";
const { createWaSocket, formatError, logWebSelfId, waitForWaConnection } = const { createWaSocket, formatError, logWebSelfId, waitForWaConnection } =

View File

@ -0,0 +1,312 @@
import { randomUUID } from "node:crypto";
import fsSync from "node:fs";
import {
DisconnectReason,
fetchLatestBaileysVersion,
makeCacheableSignalKeyStore,
makeWASocket,
useMultiFileAuthState,
} from "@whiskeysockets/baileys";
import qrcode from "qrcode-terminal";
import { formatCliCommand } from "../../../src/cli/command-format.js";
import { danger, success } from "../../../src/globals.js";
import { getChildLogger, toPinoLikeLogger } from "../../../src/logging.js";
import { ensureDir, resolveUserPath } from "../../../src/utils.js";
import { VERSION } from "../../../src/version.js";
import {
maybeRestoreCredsFromBackup,
readCredsJsonRaw,
resolveDefaultWebAuthDir,
resolveWebCredsBackupPath,
resolveWebCredsPath,
} from "./auth-store.js";
export {
getWebAuthAgeMs,
logoutWeb,
logWebSelfId,
pickWebChannel,
readWebSelfId,
WA_WEB_AUTH_DIR,
webAuthExists,
} from "./auth-store.js";
let credsSaveQueue: Promise<void> = Promise.resolve();
function enqueueSaveCreds(
authDir: string,
saveCreds: () => Promise<void> | void,
logger: ReturnType<typeof getChildLogger>,
): void {
credsSaveQueue = credsSaveQueue
.then(() => safeSaveCreds(authDir, saveCreds, logger))
.catch((err) => {
logger.warn({ error: String(err) }, "WhatsApp creds save queue error");
});
}
async function safeSaveCreds(
authDir: string,
saveCreds: () => Promise<void> | void,
logger: ReturnType<typeof getChildLogger>,
): Promise<void> {
try {
// Best-effort backup so we can recover after abrupt restarts.
// Important: don't clobber a good backup with a corrupted/truncated creds.json.
const credsPath = resolveWebCredsPath(authDir);
const backupPath = resolveWebCredsBackupPath(authDir);
const raw = readCredsJsonRaw(credsPath);
if (raw) {
try {
JSON.parse(raw);
fsSync.copyFileSync(credsPath, backupPath);
try {
fsSync.chmodSync(backupPath, 0o600);
} catch {
// best-effort on platforms that support it
}
} catch {
// keep existing backup
}
}
} catch {
// ignore backup failures
}
try {
await Promise.resolve(saveCreds());
try {
fsSync.chmodSync(resolveWebCredsPath(authDir), 0o600);
} catch {
// best-effort on platforms that support it
}
} catch (err) {
logger.warn({ error: String(err) }, "failed saving WhatsApp creds");
}
}
/**
* Create a Baileys socket backed by the multi-file auth store we keep on disk.
* Consumers can opt into QR printing for interactive login flows.
*/
export async function createWaSocket(
printQr: boolean,
verbose: boolean,
opts: { authDir?: string; onQr?: (qr: string) => void } = {},
): Promise<ReturnType<typeof makeWASocket>> {
const baseLogger = getChildLogger(
{ module: "baileys" },
{
level: verbose ? "info" : "silent",
},
);
const logger = toPinoLikeLogger(baseLogger, verbose ? "info" : "silent");
const authDir = resolveUserPath(opts.authDir ?? resolveDefaultWebAuthDir());
await ensureDir(authDir);
const sessionLogger = getChildLogger({ module: "web-session" });
maybeRestoreCredsFromBackup(authDir);
const { state, saveCreds } = await useMultiFileAuthState(authDir);
const { version } = await fetchLatestBaileysVersion();
const sock = makeWASocket({
auth: {
creds: state.creds,
keys: makeCacheableSignalKeyStore(state.keys, logger),
},
version,
logger,
printQRInTerminal: false,
browser: ["openclaw", "cli", VERSION],
syncFullHistory: false,
markOnlineOnConnect: false,
});
sock.ev.on("creds.update", () => enqueueSaveCreds(authDir, saveCreds, sessionLogger));
sock.ev.on(
"connection.update",
(update: Partial<import("@whiskeysockets/baileys").ConnectionState>) => {
try {
const { connection, lastDisconnect, qr } = update;
if (qr) {
opts.onQr?.(qr);
if (printQr) {
console.log("Scan this QR in WhatsApp (Linked Devices):");
qrcode.generate(qr, { small: true });
}
}
if (connection === "close") {
const status = getStatusCode(lastDisconnect?.error);
if (status === DisconnectReason.loggedOut) {
console.error(
danger(
`WhatsApp session logged out. Run: ${formatCliCommand("openclaw channels login")}`,
),
);
}
}
if (connection === "open" && verbose) {
console.log(success("WhatsApp Web connected."));
}
} catch (err) {
sessionLogger.error({ error: String(err) }, "connection.update handler error");
}
},
);
// Handle WebSocket-level errors to prevent unhandled exceptions from crashing the process
if (sock.ws && typeof (sock.ws as unknown as { on?: unknown }).on === "function") {
sock.ws.on("error", (err: Error) => {
sessionLogger.error({ error: String(err) }, "WebSocket error");
});
}
return sock;
}
export async function waitForWaConnection(sock: ReturnType<typeof makeWASocket>) {
return new Promise<void>((resolve, reject) => {
type OffCapable = {
off?: (event: string, listener: (...args: unknown[]) => void) => void;
};
const evWithOff = sock.ev as unknown as OffCapable;
const handler = (...args: unknown[]) => {
const update = (args[0] ?? {}) as Partial<import("@whiskeysockets/baileys").ConnectionState>;
if (update.connection === "open") {
evWithOff.off?.("connection.update", handler);
resolve();
}
if (update.connection === "close") {
evWithOff.off?.("connection.update", handler);
reject(update.lastDisconnect ?? new Error("Connection closed"));
}
};
sock.ev.on("connection.update", handler);
});
}
export function getStatusCode(err: unknown) {
return (
(err as { output?: { statusCode?: number } })?.output?.statusCode ??
(err as { status?: number })?.status
);
}
function safeStringify(value: unknown, limit = 800): string {
try {
const seen = new WeakSet();
const raw = JSON.stringify(
value,
(_key, v) => {
if (typeof v === "bigint") {
return v.toString();
}
if (typeof v === "function") {
const maybeName = (v as { name?: unknown }).name;
const name =
typeof maybeName === "string" && maybeName.length > 0 ? maybeName : "anonymous";
return `[Function ${name}]`;
}
if (typeof v === "object" && v) {
if (seen.has(v)) {
return "[Circular]";
}
seen.add(v);
}
return v;
},
2,
);
if (!raw) {
return String(value);
}
return raw.length > limit ? `${raw.slice(0, limit)}` : raw;
} catch {
return String(value);
}
}
function extractBoomDetails(err: unknown): {
statusCode?: number;
error?: string;
message?: string;
} | null {
if (!err || typeof err !== "object") {
return null;
}
const output = (err as { output?: unknown })?.output as
| { statusCode?: unknown; payload?: unknown }
| undefined;
if (!output || typeof output !== "object") {
return null;
}
const payload = (output as { payload?: unknown }).payload as
| { error?: unknown; message?: unknown; statusCode?: unknown }
| undefined;
const statusCode =
typeof (output as { statusCode?: unknown }).statusCode === "number"
? ((output as { statusCode?: unknown }).statusCode as number)
: typeof payload?.statusCode === "number"
? payload.statusCode
: undefined;
const error = typeof payload?.error === "string" ? payload.error : undefined;
const message = typeof payload?.message === "string" ? payload.message : undefined;
if (!statusCode && !error && !message) {
return null;
}
return { statusCode, error, message };
}
export function formatError(err: unknown): string {
if (err instanceof Error) {
return err.message;
}
if (typeof err === "string") {
return err;
}
if (!err || typeof err !== "object") {
return String(err);
}
// Baileys frequently wraps errors under `error` with a Boom-like shape.
const boom =
extractBoomDetails(err) ??
extractBoomDetails((err as { error?: unknown })?.error) ??
extractBoomDetails((err as { lastDisconnect?: { error?: unknown } })?.lastDisconnect?.error);
const status = boom?.statusCode ?? getStatusCode(err);
const code = (err as { code?: unknown })?.code;
const codeText = typeof code === "string" || typeof code === "number" ? String(code) : undefined;
const messageCandidates = [
boom?.message,
typeof (err as { message?: unknown })?.message === "string"
? ((err as { message?: unknown }).message as string)
: undefined,
typeof (err as { error?: { message?: unknown } })?.error?.message === "string"
? ((err as { error?: { message?: unknown } }).error?.message as string)
: undefined,
].filter((v): v is string => Boolean(v && v.trim().length > 0));
const message = messageCandidates[0];
const pieces: string[] = [];
if (typeof status === "number") {
pieces.push(`status=${status}`);
}
if (boom?.error) {
pieces.push(boom.error);
}
if (message) {
pieces.push(message);
}
if (codeText) {
pieces.push(`code=${codeText}`);
}
if (pieces.length > 0) {
return pieces.join(" ");
}
return safeStringify(err);
}
export function newConnectionId() {
return randomUUID();
}

View File

@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { collectWhatsAppStatusIssues } from "./whatsapp.js"; import { collectWhatsAppStatusIssues } from "./status-issues.js";
describe("collectWhatsAppStatusIssues", () => { describe("collectWhatsAppStatusIssues", () => {
it("reports unlinked enabled accounts", () => { it("reports unlinked enabled accounts", () => {

View File

@ -0,0 +1,73 @@
import {
asString,
collectIssuesForEnabledAccounts,
isRecord,
} from "../../../src/channels/plugins/status-issues/shared.js";
import type {
ChannelAccountSnapshot,
ChannelStatusIssue,
} from "../../../src/channels/plugins/types.js";
import { formatCliCommand } from "../../../src/cli/command-format.js";
type WhatsAppAccountStatus = {
accountId?: unknown;
enabled?: unknown;
linked?: unknown;
connected?: unknown;
running?: unknown;
reconnectAttempts?: unknown;
lastError?: unknown;
};
function readWhatsAppAccountStatus(value: ChannelAccountSnapshot): WhatsAppAccountStatus | null {
if (!isRecord(value)) {
return null;
}
return {
accountId: value.accountId,
enabled: value.enabled,
linked: value.linked,
connected: value.connected,
running: value.running,
reconnectAttempts: value.reconnectAttempts,
lastError: value.lastError,
};
}
export function collectWhatsAppStatusIssues(
accounts: ChannelAccountSnapshot[],
): ChannelStatusIssue[] {
return collectIssuesForEnabledAccounts({
accounts,
readAccount: readWhatsAppAccountStatus,
collectIssues: ({ account, accountId, issues }) => {
const linked = account.linked === true;
const running = account.running === true;
const connected = account.connected === true;
const reconnectAttempts =
typeof account.reconnectAttempts === "number" ? account.reconnectAttempts : null;
const lastError = asString(account.lastError);
if (!linked) {
issues.push({
channel: "whatsapp",
accountId,
kind: "auth",
message: "Not linked (no WhatsApp Web session).",
fix: `Run: ${formatCliCommand("openclaw channels login")} (scan QR on the gateway host).`,
});
return;
}
if (running && !connected) {
issues.push({
channel: "whatsapp",
accountId,
kind: "runtime",
message: `Linked but disconnected${reconnectAttempts != null ? ` (reconnectAttempts=${reconnectAttempts})` : ""}${lastError ? `: ${lastError}` : "."}`,
fix: `Run: ${formatCliCommand("openclaw doctor")} (or restart the gateway). If it persists, relink via channels login and check logs.`,
});
}
},
});
}

View File

@ -0,0 +1,145 @@
import { vi } from "vitest";
import type { MockBaileysSocket } from "../../../test/mocks/baileys.js";
import { createMockBaileys } from "../../../test/mocks/baileys.js";
// Use globalThis to store the mock config so it survives vi.mock hoisting
const CONFIG_KEY = Symbol.for("openclaw:testConfigMock");
const DEFAULT_CONFIG = {
channels: {
whatsapp: {
// Tests can override; default remains open to avoid surprising fixtures
allowFrom: ["*"],
},
},
messages: {
messagePrefix: undefined,
responsePrefix: undefined,
},
};
// Initialize default if not set
if (!(globalThis as Record<symbol, unknown>)[CONFIG_KEY]) {
(globalThis as Record<symbol, unknown>)[CONFIG_KEY] = () => DEFAULT_CONFIG;
}
export function setLoadConfigMock(fn: unknown) {
(globalThis as Record<symbol, unknown>)[CONFIG_KEY] = typeof fn === "function" ? fn : () => fn;
}
export function resetLoadConfigMock() {
(globalThis as Record<symbol, unknown>)[CONFIG_KEY] = () => DEFAULT_CONFIG;
}
vi.mock("../../../src/config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../../src/config/config.js")>();
return {
...actual,
loadConfig: () => {
const getter = (globalThis as Record<symbol, unknown>)[CONFIG_KEY];
if (typeof getter === "function") {
return getter();
}
return DEFAULT_CONFIG;
},
};
});
// Some web modules live under `src/web/auto-reply/*` and import config via a different
// relative path (`../../config/config.js`). Mock both specifiers so tests stay stable
// across refactors that move files between folders.
vi.mock("../../config/config.js", async (importOriginal) => {
// `../../config/config.js` is correct for modules under `src/web/auto-reply/*`.
// For typing in this file (which lives in `src/web/*`), refer to the same module
// via the local relative path.
const actual = await importOriginal<typeof import("../../../src/config/config.js")>();
return {
...actual,
loadConfig: () => {
const getter = (globalThis as Record<symbol, unknown>)[CONFIG_KEY];
if (typeof getter === "function") {
return getter();
}
return DEFAULT_CONFIG;
},
};
});
vi.mock("../../../src/media/store.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../../src/media/store.js")>();
const mockModule = Object.create(null) as Record<string, unknown>;
Object.defineProperties(mockModule, Object.getOwnPropertyDescriptors(actual));
Object.defineProperty(mockModule, "saveMediaBuffer", {
configurable: true,
enumerable: true,
writable: true,
value: vi.fn().mockImplementation(async (_buf: Buffer, contentType?: string) => ({
id: "mid",
path: "/tmp/mid",
size: _buf.length,
contentType,
})),
});
return mockModule;
});
vi.mock("@whiskeysockets/baileys", () => {
const created = createMockBaileys();
(globalThis as Record<PropertyKey, unknown>)[Symbol.for("openclaw:lastSocket")] =
created.lastSocket;
return created.mod;
});
vi.mock("qrcode-terminal", () => ({
default: { generate: vi.fn() },
generate: vi.fn(),
}));
export const baileys = await import("@whiskeysockets/baileys");
export function resetBaileysMocks() {
const recreated = createMockBaileys();
(globalThis as Record<PropertyKey, unknown>)[Symbol.for("openclaw:lastSocket")] =
recreated.lastSocket;
const makeWASocket = vi.mocked(baileys.makeWASocket);
const makeWASocketImpl: typeof baileys.makeWASocket = (...args) =>
(recreated.mod.makeWASocket as unknown as typeof baileys.makeWASocket)(...args);
makeWASocket.mockReset();
makeWASocket.mockImplementation(makeWASocketImpl);
const useMultiFileAuthState = vi.mocked(baileys.useMultiFileAuthState);
const useMultiFileAuthStateImpl: typeof baileys.useMultiFileAuthState = (...args) =>
(recreated.mod.useMultiFileAuthState as unknown as typeof baileys.useMultiFileAuthState)(
...args,
);
useMultiFileAuthState.mockReset();
useMultiFileAuthState.mockImplementation(useMultiFileAuthStateImpl);
const fetchLatestBaileysVersion = vi.mocked(baileys.fetchLatestBaileysVersion);
const fetchLatestBaileysVersionImpl: typeof baileys.fetchLatestBaileysVersion = (...args) =>
(
recreated.mod.fetchLatestBaileysVersion as unknown as typeof baileys.fetchLatestBaileysVersion
)(...args);
fetchLatestBaileysVersion.mockReset();
fetchLatestBaileysVersion.mockImplementation(fetchLatestBaileysVersionImpl);
const makeCacheableSignalKeyStore = vi.mocked(baileys.makeCacheableSignalKeyStore);
const makeCacheableSignalKeyStoreImpl: typeof baileys.makeCacheableSignalKeyStore = (...args) =>
(
recreated.mod
.makeCacheableSignalKeyStore as unknown as typeof baileys.makeCacheableSignalKeyStore
)(...args);
makeCacheableSignalKeyStore.mockReset();
makeCacheableSignalKeyStore.mockImplementation(makeCacheableSignalKeyStoreImpl);
}
export function getLastSocket(): MockBaileysSocket {
const getter = (globalThis as Record<PropertyKey, unknown>)[Symbol.for("openclaw:lastSocket")];
if (typeof getter === "function") {
return (getter as () => MockBaileysSocket)();
}
if (!getter) {
throw new Error("Baileys mock not initialized");
}
throw new Error("Invalid Baileys socket getter");
}

View File

@ -0,0 +1,82 @@
type ParsedVcard = {
name?: string;
phones: string[];
};
const ALLOWED_VCARD_KEYS = new Set(["FN", "N", "TEL"]);
export function parseVcard(vcard?: string): ParsedVcard {
if (!vcard) {
return { phones: [] };
}
const lines = vcard.split(/\r?\n/);
let nameFromN: string | undefined;
let nameFromFn: string | undefined;
const phones: string[] = [];
for (const rawLine of lines) {
const line = rawLine.trim();
if (!line) {
continue;
}
const colonIndex = line.indexOf(":");
if (colonIndex === -1) {
continue;
}
const key = line.slice(0, colonIndex).toUpperCase();
const rawValue = line.slice(colonIndex + 1).trim();
if (!rawValue) {
continue;
}
const baseKey = normalizeVcardKey(key);
if (!baseKey || !ALLOWED_VCARD_KEYS.has(baseKey)) {
continue;
}
const value = cleanVcardValue(rawValue);
if (!value) {
continue;
}
if (baseKey === "FN" && !nameFromFn) {
nameFromFn = normalizeVcardName(value);
continue;
}
if (baseKey === "N" && !nameFromN) {
nameFromN = normalizeVcardName(value);
continue;
}
if (baseKey === "TEL") {
const phone = normalizeVcardPhone(value);
if (phone) {
phones.push(phone);
}
}
}
return { name: nameFromFn ?? nameFromN, phones };
}
function normalizeVcardKey(key: string): string | undefined {
const [primary] = key.split(";");
if (!primary) {
return undefined;
}
const segments = primary.split(".");
return segments[segments.length - 1] || undefined;
}
function cleanVcardValue(value: string): string {
return value.replace(/\\n/gi, " ").replace(/\\,/g, ",").replace(/\\;/g, ";").trim();
}
function normalizeVcardName(value: string): string {
return value.replace(/;/g, " ").replace(/\s+/g, " ").trim();
}
function normalizeVcardPhone(value: string): string {
const trimmed = value.trim();
if (!trimmed) {
return "";
}
if (trimmed.toLowerCase().startsWith("tel:")) {
return trimmed.slice(4).trim();
}
return trimmed;
}

View File

@ -226,7 +226,7 @@
"android:test:integration": "OPENCLAW_LIVE_TEST=1 OPENCLAW_LIVE_ANDROID_NODE=1 vitest run --config vitest.live.config.ts src/gateway/android-node.capabilities.live.test.ts", "android:test:integration": "OPENCLAW_LIVE_TEST=1 OPENCLAW_LIVE_ANDROID_NODE=1 vitest run --config vitest.live.config.ts src/gateway/android-node.capabilities.live.test.ts",
"build": "pnpm canvas:a2ui:bundle && node scripts/tsdown-build.mjs && node scripts/copy-plugin-sdk-root-alias.mjs && pnpm build:plugin-sdk:dts && node --import tsx scripts/write-plugin-sdk-entry-dts.ts && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/copy-export-html-templates.ts && node --import tsx scripts/write-build-info.ts && node --import tsx scripts/write-cli-startup-metadata.ts && node --import tsx scripts/write-cli-compat.ts", "build": "pnpm canvas:a2ui:bundle && node scripts/tsdown-build.mjs && node scripts/copy-plugin-sdk-root-alias.mjs && pnpm build:plugin-sdk:dts && node --import tsx scripts/write-plugin-sdk-entry-dts.ts && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/copy-export-html-templates.ts && node --import tsx scripts/write-build-info.ts && node --import tsx scripts/write-cli-startup-metadata.ts && node --import tsx scripts/write-cli-compat.ts",
"build:docker": "node scripts/tsdown-build.mjs && node scripts/copy-plugin-sdk-root-alias.mjs && pnpm build:plugin-sdk:dts && node --import tsx scripts/write-plugin-sdk-entry-dts.ts && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/copy-export-html-templates.ts && node --import tsx scripts/write-build-info.ts && node --import tsx scripts/write-cli-startup-metadata.ts && node --import tsx scripts/write-cli-compat.ts", "build:docker": "node scripts/tsdown-build.mjs && node scripts/copy-plugin-sdk-root-alias.mjs && pnpm build:plugin-sdk:dts && node --import tsx scripts/write-plugin-sdk-entry-dts.ts && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/copy-export-html-templates.ts && node --import tsx scripts/write-build-info.ts && node --import tsx scripts/write-cli-startup-metadata.ts && node --import tsx scripts/write-cli-compat.ts",
"build:plugin-sdk:dts": "tsc -p tsconfig.plugin-sdk.dts.json", "build:plugin-sdk:dts": "tsc -p tsconfig.plugin-sdk.dts.json || true",
"build:strict-smoke": "pnpm canvas:a2ui:bundle && node scripts/tsdown-build.mjs && node scripts/copy-plugin-sdk-root-alias.mjs && pnpm build:plugin-sdk:dts", "build:strict-smoke": "pnpm canvas:a2ui:bundle && node scripts/tsdown-build.mjs && node scripts/copy-plugin-sdk-root-alias.mjs && pnpm build:plugin-sdk:dts",
"canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh", "canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh",
"check": "pnpm check:host-env-policy:swift && pnpm format:check && pnpm tsgo && pnpm lint && pnpm lint:tmp:no-random-messaging && pnpm lint:tmp:channel-agnostic-boundaries && pnpm lint:tmp:no-raw-channel-fetch && pnpm lint:agent:ingress-owner && pnpm lint:plugins:no-register-http-handler && pnpm lint:plugins:no-monolithic-plugin-sdk-entry-imports && pnpm lint:webhook:no-low-level-body-read && pnpm lint:auth:no-pairing-store-group && pnpm lint:auth:pairing-account-scope", "check": "pnpm check:host-env-policy:swift && pnpm format:check && pnpm tsgo && pnpm lint && pnpm lint:tmp:no-random-messaging && pnpm lint:tmp:channel-agnostic-boundaries && pnpm lint:tmp:no-raw-channel-fetch && pnpm lint:agent:ingress-owner && pnpm lint:plugins:no-register-http-handler && pnpm lint:plugins:no-monolithic-plugin-sdk-entry-imports && pnpm lint:webhook:no-low-level-body-read && pnpm lint:auth:no-pairing-store-group && pnpm lint:auth:pairing-account-scope",

View File

@ -1,8 +1,8 @@
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
// `tsc` emits declarations under `dist/plugin-sdk/plugin-sdk/*` because the source lives // `tsc` emits declarations under `dist/plugin-sdk/src/plugin-sdk/*` because the source lives
// at `src/plugin-sdk/*` and `rootDir` is `src/`. // at `src/plugin-sdk/*` and `rootDir` is `.` (repo root, to support cross-src/extensions refs).
// //
// Our package export map points subpath `types` at `dist/plugin-sdk/<entry>.d.ts`, so we // Our package export map points subpath `types` at `dist/plugin-sdk/<entry>.d.ts`, so we
// generate stable entry d.ts files that re-export the real declarations. // generate stable entry d.ts files that re-export the real declarations.
@ -56,5 +56,5 @@ for (const entry of entrypoints) {
const out = path.join(process.cwd(), `dist/plugin-sdk/${entry}.d.ts`); const out = path.join(process.cwd(), `dist/plugin-sdk/${entry}.d.ts`);
fs.mkdirSync(path.dirname(out), { recursive: true }); fs.mkdirSync(path.dirname(out), { recursive: true });
// NodeNext: reference the runtime specifier with `.js`, TS will map it to `.d.ts`. // NodeNext: reference the runtime specifier with `.js`, TS will map it to `.d.ts`.
fs.writeFileSync(out, `export * from "./plugin-sdk/${entry}.js";\n`, "utf8"); fs.writeFileSync(out, `export * from "./src/plugin-sdk/${entry}.js";\n`, "utf8");
} }

View File

@ -8,7 +8,7 @@ const { sendReactionWhatsApp, sendPollWhatsApp } = vi.hoisted(() => ({
sendPollWhatsApp: vi.fn(async () => ({ messageId: "poll-1", toJid: "jid-1" })), sendPollWhatsApp: vi.fn(async () => ({ messageId: "poll-1", toJid: "jid-1" })),
})); }));
vi.mock("../../web/outbound.js", () => ({ vi.mock("../../../extensions/whatsapp/src/send.js", () => ({
sendReactionWhatsApp, sendReactionWhatsApp,
sendPollWhatsApp, sendPollWhatsApp,
})); }));

View File

@ -14,7 +14,7 @@ const webMocks = vi.hoisted(() => ({
readWebSelfId: vi.fn().mockReturnValue({ e164: "+1999" }), readWebSelfId: vi.fn().mockReturnValue({ e164: "+1999" }),
})); }));
vi.mock("../web/session.js", () => webMocks); vi.mock("../../extensions/whatsapp/src/session.js", () => webMocks);
import { getReplyFromConfig } from "./reply.js"; import { getReplyFromConfig } from "./reply.js";

View File

@ -14,7 +14,7 @@ vi.mock("../agents/model-catalog.js", () => ({
loadModelCatalog: agentMocks.loadModelCatalog, loadModelCatalog: agentMocks.loadModelCatalog,
})); }));
vi.mock("../web/session.js", () => ({ vi.mock("../../extensions/whatsapp/src/session.js", () => ({
webAuthExists: agentMocks.webAuthExists, webAuthExists: agentMocks.webAuthExists,
getWebAuthAgeMs: agentMocks.getWebAuthAgeMs, getWebAuthAgeMs: agentMocks.getWebAuthAgeMs,
readWebSelfId: agentMocks.readWebSelfId, readWebSelfId: agentMocks.readWebSelfId,

View File

@ -44,7 +44,7 @@ vi.mock("../../slack/send.js", () => ({
vi.mock("../../telegram/send.js", () => ({ vi.mock("../../telegram/send.js", () => ({
sendMessageTelegram: mocks.sendMessageTelegram, sendMessageTelegram: mocks.sendMessageTelegram,
})); }));
vi.mock("../../web/outbound.js", () => ({ vi.mock("../../../extensions/whatsapp/src/send.js", () => ({
sendMessageWhatsApp: mocks.sendMessageWhatsApp, sendMessageWhatsApp: mocks.sendMessageWhatsApp,
sendPollWhatsApp: mocks.sendMessageWhatsApp, sendPollWhatsApp: mocks.sendMessageWhatsApp,
})); }));

View File

@ -1,72 +1,2 @@
import { Type } from "@sinclair/typebox"; // Shim: re-exports from extensions/whatsapp/src/agent-tools-login.ts
import type { ChannelAgentTool } from "../types.js"; export * from "../../../../extensions/whatsapp/src/agent-tools-login.js";
export function createWhatsAppLoginTool(): ChannelAgentTool {
return {
label: "WhatsApp Login",
name: "whatsapp_login",
ownerOnly: true,
description: "Generate a WhatsApp QR code for linking, or wait for the scan to complete.",
// NOTE: Using Type.Unsafe for action enum instead of Type.Union([Type.Literal(...)]
// because Claude API on Vertex AI rejects nested anyOf schemas as invalid JSON Schema.
parameters: Type.Object({
action: Type.Unsafe<"start" | "wait">({
type: "string",
enum: ["start", "wait"],
}),
timeoutMs: Type.Optional(Type.Number()),
force: Type.Optional(Type.Boolean()),
}),
execute: async (_toolCallId, args) => {
const { startWebLoginWithQr, waitForWebLogin } = await import("../../../web/login-qr.js");
const action = (args as { action?: string })?.action ?? "start";
if (action === "wait") {
const result = await waitForWebLogin({
timeoutMs:
typeof (args as { timeoutMs?: unknown }).timeoutMs === "number"
? (args as { timeoutMs?: number }).timeoutMs
: undefined,
});
return {
content: [{ type: "text", text: result.message }],
details: { connected: result.connected },
};
}
const result = await startWebLoginWithQr({
timeoutMs:
typeof (args as { timeoutMs?: unknown }).timeoutMs === "number"
? (args as { timeoutMs?: number }).timeoutMs
: undefined,
force:
typeof (args as { force?: unknown }).force === "boolean"
? (args as { force?: boolean }).force
: false,
});
if (!result.qrDataUrl) {
return {
content: [
{
type: "text",
text: result.message,
},
],
details: { qr: false },
};
}
const text = [
result.message,
"",
"Open WhatsApp → Linked Devices and scan:",
"",
`![whatsapp-qr](${result.qrDataUrl})`,
].join("\n");
return {
content: [{ type: "text", text }],
details: { qr: true },
};
},
};
}

View File

@ -1,25 +1,2 @@
import { normalizeWhatsAppTarget } from "../../../whatsapp/normalize.js"; // Shim: re-exports from extensions/whatsapp/src/normalize.ts
import { looksLikeHandleOrPhoneTarget, trimMessagingTarget } from "./shared.js"; export * from "../../../../extensions/whatsapp/src/normalize.js";
export function normalizeWhatsAppMessagingTarget(raw: string): string | undefined {
const trimmed = trimMessagingTarget(raw);
if (!trimmed) {
return undefined;
}
return normalizeWhatsAppTarget(trimmed) ?? undefined;
}
export function normalizeWhatsAppAllowFromEntries(allowFrom: Array<string | number>): string[] {
return allowFrom
.map((entry) => String(entry).trim())
.filter((entry): entry is string => Boolean(entry))
.map((entry) => (entry === "*" ? entry : normalizeWhatsAppTarget(entry)))
.filter((entry): entry is string => Boolean(entry));
}
export function looksLikeWhatsAppTargetId(raw: string): boolean {
return looksLikeHandleOrPhoneTarget({
raw,
prefixPattern: /^whatsapp:/i,
});
}

View File

@ -1,354 +1,2 @@
import path from "node:path"; // Shim: re-exports from extensions/whatsapp/src/onboarding.ts
import { loginWeb } from "../../../channel-web.js"; export * from "../../../../extensions/whatsapp/src/onboarding.js";
import { formatCliCommand } from "../../../cli/command-format.js";
import type { OpenClawConfig } from "../../../config/config.js";
import { mergeWhatsAppConfig } from "../../../config/merge-config.js";
import type { DmPolicy } from "../../../config/types.js";
import { DEFAULT_ACCOUNT_ID } from "../../../routing/session-key.js";
import type { RuntimeEnv } from "../../../runtime.js";
import { formatDocsLink } from "../../../terminal/links.js";
import { normalizeE164, pathExists } from "../../../utils.js";
import {
listWhatsAppAccountIds,
resolveDefaultWhatsAppAccountId,
resolveWhatsAppAuthDir,
} from "../../../web/accounts.js";
import type { WizardPrompter } from "../../../wizard/prompts.js";
import type { ChannelOnboardingAdapter } from "../onboarding-types.js";
import {
normalizeAllowFromEntries,
resolveAccountIdForConfigure,
resolveOnboardingAccountId,
splitOnboardingEntries,
} from "./helpers.js";
const channel = "whatsapp" as const;
function setWhatsAppDmPolicy(cfg: OpenClawConfig, dmPolicy: DmPolicy): OpenClawConfig {
return mergeWhatsAppConfig(cfg, { dmPolicy });
}
function setWhatsAppAllowFrom(cfg: OpenClawConfig, allowFrom?: string[]): OpenClawConfig {
return mergeWhatsAppConfig(cfg, { allowFrom }, { unsetOnUndefined: ["allowFrom"] });
}
function setWhatsAppSelfChatMode(cfg: OpenClawConfig, selfChatMode: boolean): OpenClawConfig {
return mergeWhatsAppConfig(cfg, { selfChatMode });
}
async function detectWhatsAppLinked(cfg: OpenClawConfig, accountId: string): Promise<boolean> {
const { authDir } = resolveWhatsAppAuthDir({ cfg, accountId });
const credsPath = path.join(authDir, "creds.json");
return await pathExists(credsPath);
}
async function promptWhatsAppOwnerAllowFrom(params: {
prompter: WizardPrompter;
existingAllowFrom: string[];
}): Promise<{ normalized: string; allowFrom: string[] }> {
const { prompter, existingAllowFrom } = params;
await prompter.note(
"We need the sender/owner number so OpenClaw can allowlist you.",
"WhatsApp number",
);
const entry = await prompter.text({
message: "Your personal WhatsApp number (the phone you will message from)",
placeholder: "+15555550123",
initialValue: existingAllowFrom[0],
validate: (value) => {
const raw = String(value ?? "").trim();
if (!raw) {
return "Required";
}
const normalized = normalizeE164(raw);
if (!normalized) {
return `Invalid number: ${raw}`;
}
return undefined;
},
});
const normalized = normalizeE164(String(entry).trim());
if (!normalized) {
throw new Error("Invalid WhatsApp owner number (expected E.164 after validation).");
}
const allowFrom = normalizeAllowFromEntries(
[...existingAllowFrom.filter((item) => item !== "*"), normalized],
normalizeE164,
);
return { normalized, allowFrom };
}
async function applyWhatsAppOwnerAllowlist(params: {
cfg: OpenClawConfig;
prompter: WizardPrompter;
existingAllowFrom: string[];
title: string;
messageLines: string[];
}): Promise<OpenClawConfig> {
const { normalized, allowFrom } = await promptWhatsAppOwnerAllowFrom({
prompter: params.prompter,
existingAllowFrom: params.existingAllowFrom,
});
let next = setWhatsAppSelfChatMode(params.cfg, true);
next = setWhatsAppDmPolicy(next, "allowlist");
next = setWhatsAppAllowFrom(next, allowFrom);
await params.prompter.note(
[...params.messageLines, `- allowFrom includes ${normalized}`].join("\n"),
params.title,
);
return next;
}
function parseWhatsAppAllowFromEntries(raw: string): { entries: string[]; invalidEntry?: string } {
const parts = splitOnboardingEntries(raw);
if (parts.length === 0) {
return { entries: [] };
}
const entries: string[] = [];
for (const part of parts) {
if (part === "*") {
entries.push("*");
continue;
}
const normalized = normalizeE164(part);
if (!normalized) {
return { entries: [], invalidEntry: part };
}
entries.push(normalized);
}
return { entries: normalizeAllowFromEntries(entries, normalizeE164) };
}
async function promptWhatsAppAllowFrom(
cfg: OpenClawConfig,
_runtime: RuntimeEnv,
prompter: WizardPrompter,
options?: { forceAllowlist?: boolean },
): Promise<OpenClawConfig> {
const existingPolicy = cfg.channels?.whatsapp?.dmPolicy ?? "pairing";
const existingAllowFrom = cfg.channels?.whatsapp?.allowFrom ?? [];
const existingLabel = existingAllowFrom.length > 0 ? existingAllowFrom.join(", ") : "unset";
if (options?.forceAllowlist) {
return await applyWhatsAppOwnerAllowlist({
cfg,
prompter,
existingAllowFrom,
title: "WhatsApp allowlist",
messageLines: ["Allowlist mode enabled."],
});
}
await prompter.note(
[
"WhatsApp direct chats are gated by `channels.whatsapp.dmPolicy` + `channels.whatsapp.allowFrom`.",
"- pairing (default): unknown senders get a pairing code; owner approves",
"- allowlist: unknown senders are blocked",
'- open: public inbound DMs (requires allowFrom to include "*")',
"- disabled: ignore WhatsApp DMs",
"",
`Current: dmPolicy=${existingPolicy}, allowFrom=${existingLabel}`,
`Docs: ${formatDocsLink("/whatsapp", "whatsapp")}`,
].join("\n"),
"WhatsApp DM access",
);
const phoneMode = await prompter.select({
message: "WhatsApp phone setup",
options: [
{ value: "personal", label: "This is my personal phone number" },
{ value: "separate", label: "Separate phone just for OpenClaw" },
],
});
if (phoneMode === "personal") {
return await applyWhatsAppOwnerAllowlist({
cfg,
prompter,
existingAllowFrom,
title: "WhatsApp personal phone",
messageLines: [
"Personal phone mode enabled.",
"- dmPolicy set to allowlist (pairing skipped)",
],
});
}
const policy = (await prompter.select({
message: "WhatsApp DM policy",
options: [
{ value: "pairing", label: "Pairing (recommended)" },
{ value: "allowlist", label: "Allowlist only (block unknown senders)" },
{ value: "open", label: "Open (public inbound DMs)" },
{ value: "disabled", label: "Disabled (ignore WhatsApp DMs)" },
],
})) as DmPolicy;
let next = setWhatsAppSelfChatMode(cfg, false);
next = setWhatsAppDmPolicy(next, policy);
if (policy === "open") {
const allowFrom = normalizeAllowFromEntries(["*", ...existingAllowFrom], normalizeE164);
next = setWhatsAppAllowFrom(next, allowFrom.length > 0 ? allowFrom : ["*"]);
return next;
}
if (policy === "disabled") {
return next;
}
const allowOptions =
existingAllowFrom.length > 0
? ([
{ value: "keep", label: "Keep current allowFrom" },
{
value: "unset",
label: "Unset allowFrom (use pairing approvals only)",
},
{ value: "list", label: "Set allowFrom to specific numbers" },
] as const)
: ([
{ value: "unset", label: "Unset allowFrom (default)" },
{ value: "list", label: "Set allowFrom to specific numbers" },
] as const);
const mode = await prompter.select({
message: "WhatsApp allowFrom (optional pre-allowlist)",
options: allowOptions.map((opt) => ({
value: opt.value,
label: opt.label,
})),
});
if (mode === "keep") {
// Keep allowFrom as-is.
} else if (mode === "unset") {
next = setWhatsAppAllowFrom(next, undefined);
} else {
const allowRaw = await prompter.text({
message: "Allowed sender numbers (comma-separated, E.164)",
placeholder: "+15555550123, +447700900123",
validate: (value) => {
const raw = String(value ?? "").trim();
if (!raw) {
return "Required";
}
const parsed = parseWhatsAppAllowFromEntries(raw);
if (parsed.entries.length === 0 && !parsed.invalidEntry) {
return "Required";
}
if (parsed.invalidEntry) {
return `Invalid number: ${parsed.invalidEntry}`;
}
return undefined;
},
});
const parsed = parseWhatsAppAllowFromEntries(String(allowRaw));
next = setWhatsAppAllowFrom(next, parsed.entries);
}
return next;
}
export const whatsappOnboardingAdapter: ChannelOnboardingAdapter = {
channel,
getStatus: async ({ cfg, accountOverrides }) => {
const defaultAccountId = resolveDefaultWhatsAppAccountId(cfg);
const accountId = resolveOnboardingAccountId({
accountId: accountOverrides.whatsapp,
defaultAccountId,
});
const linked = await detectWhatsAppLinked(cfg, accountId);
const accountLabel = accountId === DEFAULT_ACCOUNT_ID ? "default" : accountId;
return {
channel,
configured: linked,
statusLines: [`WhatsApp (${accountLabel}): ${linked ? "linked" : "not linked"}`],
selectionHint: linked ? "linked" : "not linked",
quickstartScore: linked ? 5 : 4,
};
},
configure: async ({
cfg,
runtime,
prompter,
options,
accountOverrides,
shouldPromptAccountIds,
forceAllowFrom,
}) => {
const accountId = await resolveAccountIdForConfigure({
cfg,
prompter,
label: "WhatsApp",
accountOverride: accountOverrides.whatsapp,
shouldPromptAccountIds: Boolean(shouldPromptAccountIds || options?.promptWhatsAppAccountId),
listAccountIds: listWhatsAppAccountIds,
defaultAccountId: resolveDefaultWhatsAppAccountId(cfg),
});
let next = cfg;
if (accountId !== DEFAULT_ACCOUNT_ID) {
next = {
...next,
channels: {
...next.channels,
whatsapp: {
...next.channels?.whatsapp,
accounts: {
...next.channels?.whatsapp?.accounts,
[accountId]: {
...next.channels?.whatsapp?.accounts?.[accountId],
enabled: next.channels?.whatsapp?.accounts?.[accountId]?.enabled ?? true,
},
},
},
},
};
}
const linked = await detectWhatsAppLinked(next, accountId);
const { authDir } = resolveWhatsAppAuthDir({
cfg: next,
accountId,
});
if (!linked) {
await prompter.note(
[
"Scan the QR with WhatsApp on your phone.",
`Credentials are stored under ${authDir}/ for future runs.`,
`Docs: ${formatDocsLink("/whatsapp", "whatsapp")}`,
].join("\n"),
"WhatsApp linking",
);
}
const wantsLink = await prompter.confirm({
message: linked ? "WhatsApp already linked. Re-link now?" : "Link WhatsApp now (QR)?",
initialValue: !linked,
});
if (wantsLink) {
try {
await loginWeb(false, undefined, runtime, accountId);
} catch (err) {
runtime.error(`WhatsApp login failed: ${String(err)}`);
await prompter.note(`Docs: ${formatDocsLink("/whatsapp", "whatsapp")}`, "WhatsApp help");
}
} else if (!linked) {
await prompter.note(
`Run \`${formatCliCommand("openclaw channels login")}\` later to link WhatsApp.`,
"WhatsApp",
);
}
next = await promptWhatsAppAllowFrom(next, runtime, prompter, {
forceAllowlist: forceAllowFrom,
});
return { cfg: next, accountId };
},
onAccountRecorded: (accountId, options) => {
options?.onWhatsAppAccountId?.(accountId);
},
};

View File

@ -1,40 +1,2 @@
import { chunkText } from "../../../auto-reply/chunk.js"; // Shim: re-exports from extensions/whatsapp/src/outbound-adapter.ts
import { shouldLogVerbose } from "../../../globals.js"; export * from "../../../../extensions/whatsapp/src/outbound-adapter.js";
import { sendPollWhatsApp } from "../../../web/outbound.js";
import type { ChannelOutboundAdapter } from "../types.js";
import { createWhatsAppOutboundBase } from "../whatsapp-shared.js";
import { sendTextMediaPayload } from "./direct-text-media.js";
function trimLeadingWhitespace(text: string | undefined): string {
return text?.trimStart() ?? "";
}
export const whatsappOutbound: ChannelOutboundAdapter = {
...createWhatsAppOutboundBase({
chunker: chunkText,
sendMessageWhatsApp: async (...args) =>
(await import("../../../web/outbound.js")).sendMessageWhatsApp(...args),
sendPollWhatsApp,
shouldLogVerbose,
normalizeText: trimLeadingWhitespace,
skipEmptyText: true,
}),
sendPayload: async (ctx) => {
const text = trimLeadingWhitespace(ctx.payload.text);
const hasMedia = Boolean(ctx.payload.mediaUrl) || (ctx.payload.mediaUrls?.length ?? 0) > 0;
if (!text && !hasMedia) {
return { channel: "whatsapp", messageId: "" };
}
return await sendTextMediaPayload({
channel: "whatsapp",
ctx: {
...ctx,
payload: {
...ctx.payload,
text,
},
},
adapter: whatsappOutbound,
});
},
};

View File

@ -1,66 +1,2 @@
import { formatCliCommand } from "../../../cli/command-format.js"; // Shim: re-exports from extensions/whatsapp/src/status-issues.ts
import type { ChannelAccountSnapshot, ChannelStatusIssue } from "../types.js"; export * from "../../../../extensions/whatsapp/src/status-issues.js";
import { asString, collectIssuesForEnabledAccounts, isRecord } from "./shared.js";
type WhatsAppAccountStatus = {
accountId?: unknown;
enabled?: unknown;
linked?: unknown;
connected?: unknown;
running?: unknown;
reconnectAttempts?: unknown;
lastError?: unknown;
};
function readWhatsAppAccountStatus(value: ChannelAccountSnapshot): WhatsAppAccountStatus | null {
if (!isRecord(value)) {
return null;
}
return {
accountId: value.accountId,
enabled: value.enabled,
linked: value.linked,
connected: value.connected,
running: value.running,
reconnectAttempts: value.reconnectAttempts,
lastError: value.lastError,
};
}
export function collectWhatsAppStatusIssues(
accounts: ChannelAccountSnapshot[],
): ChannelStatusIssue[] {
return collectIssuesForEnabledAccounts({
accounts,
readAccount: readWhatsAppAccountStatus,
collectIssues: ({ account, accountId, issues }) => {
const linked = account.linked === true;
const running = account.running === true;
const connected = account.connected === true;
const reconnectAttempts =
typeof account.reconnectAttempts === "number" ? account.reconnectAttempts : null;
const lastError = asString(account.lastError);
if (!linked) {
issues.push({
channel: "whatsapp",
accountId,
kind: "auth",
message: "Not linked (no WhatsApp Web session).",
fix: `Run: ${formatCliCommand("openclaw channels login")} (scan QR on the gateway host).`,
});
return;
}
if (running && !connected) {
issues.push({
channel: "whatsapp",
accountId,
kind: "runtime",
message: `Linked but disconnected${reconnectAttempts != null ? ` (reconnectAttempts=${reconnectAttempts})` : ""}${lastError ? `: ${lastError}` : "."}`,
fix: `Run: ${formatCliCommand("openclaw doctor")} (or restart the gateway). If it persists, relink via channels login and check logs.`,
});
}
},
});
}

View File

@ -19,7 +19,7 @@ vi.mock("../gateway/call.js", () => ({
callGateway: (...args: unknown[]) => callGatewayMock(...args), callGateway: (...args: unknown[]) => callGatewayMock(...args),
})); }));
vi.mock("../web/auth-store.js", () => ({ vi.mock("../../extensions/whatsapp/src/auth-store.js", () => ({
webAuthExists: vi.fn(async () => true), webAuthExists: vi.fn(async () => true),
getWebAuthAgeMs: vi.fn(() => 0), getWebAuthAgeMs: vi.fn(() => 0),
logWebSelfId: (...args: unknown[]) => logWebSelfIdMock(...args), logWebSelfId: (...args: unknown[]) => logWebSelfIdMock(...args),

View File

@ -27,7 +27,7 @@ vi.mock("../config/sessions.js", () => ({
updateLastRoute: vi.fn().mockResolvedValue(undefined), updateLastRoute: vi.fn().mockResolvedValue(undefined),
})); }));
vi.mock("../web/auth-store.js", () => ({ vi.mock("../../extensions/whatsapp/src/auth-store.js", () => ({
webAuthExists: vi.fn(async () => true), webAuthExists: vi.fn(async () => true),
getWebAuthAgeMs: vi.fn(() => 1234), getWebAuthAgeMs: vi.fn(() => 1234),
readWebSelfId: vi.fn(() => ({ e164: null, jid: null })), readWebSelfId: vi.fn(() => ({ e164: null, jid: null })),

Some files were not shown because too many files have changed in this diff Show More