mirror of https://github.com/openclaw/openclaw.git
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:
parent
0ce23dc62d
commit
16505718e8
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@ import fs from "node:fs";
|
|||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
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";
|
||||
|
||||
describe("hasAnyWhatsAppAuth", () => {
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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:",
|
||||
"",
|
||||
``,
|
||||
].join("\n");
|
||||
return {
|
||||
content: [{ type: "text", text }],
|
||||
details: { qr: true },
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import "./test-helpers.js";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
import {
|
||||
monitorWebChannelWithCapture,
|
||||
sendWebDirectInboundAndCollectSessionKeys,
|
||||
|
|
@ -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";
|
||||
|
|
@ -3,9 +3,9 @@ import fs from "node:fs/promises";
|
|||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, vi } from "vitest";
|
||||
import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js";
|
||||
import * as ssrf from "../infra/net/ssrf.js";
|
||||
import { resetLogger, setLoggerOverride } from "../logging.js";
|
||||
import { resetInboundDedupe } from "../../../src/auto-reply/reply/inbound-dedupe.js";
|
||||
import * as ssrf from "../../../src/infra/net/ssrf.js";
|
||||
import { resetLogger, setLoggerOverride } from "../../../src/logging.js";
|
||||
import type { WebInboundMessage, WebListenerCloseReason } from "./inbound.js";
|
||||
import {
|
||||
resetBaileysMocks as _resetBaileysMocks,
|
||||
|
|
@ -29,7 +29,7 @@ type MockWebListener = {
|
|||
|
||||
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),
|
||||
isEmbeddedPiRunActive: vi.fn().mockReturnValue(false),
|
||||
isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false),
|
||||
|
|
@ -0,0 +1 @@
|
|||
export * from "./auto-reply.impl.js";
|
||||
|
|
@ -2,10 +2,10 @@ import "./test-helpers.js";
|
|||
import crypto from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { escapeRegExp, formatEnvelopeTimestamp } from "../../test/helpers/envelope-timestamp.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { setLoggerOverride } from "../logging.js";
|
||||
import { withEnvAsync } from "../test-utils/env.js";
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
import { setLoggerOverride } from "../../../src/logging.js";
|
||||
import { withEnvAsync } from "../../../src/test-utils/env.js";
|
||||
import { escapeRegExp, formatEnvelopeTimestamp } from "../../../test/helpers/envelope-timestamp.js";
|
||||
import {
|
||||
createMockWebListener,
|
||||
createWebListenerFactoryCapture,
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import "./test-helpers.js";
|
||||
import fs from "node:fs/promises";
|
||||
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 { buildMentionConfig } from "./auto-reply/mentions.js";
|
||||
import { createEchoTracker } from "./auto-reply/monitor/echo.js";
|
||||
|
|
@ -0,0 +1 @@
|
|||
export const DEFAULT_WEB_MEDIA_BYTES = 5 * 1024 * 1024;
|
||||
|
|
@ -1,12 +1,12 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import { sleep } from "../../utils.js";
|
||||
import { logVerbose } from "../../../../src/globals.js";
|
||||
import { sleep } from "../../../../src/utils.js";
|
||||
import { loadWebMedia } from "../media.js";
|
||||
import { deliverWebReply } from "./deliver-reply.js";
|
||||
import type { WebInboundMsg } from "./types.js";
|
||||
|
||||
vi.mock("../../globals.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../globals.js")>();
|
||||
vi.mock("../../../../src/globals.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../../../src/globals.js")>();
|
||||
return {
|
||||
...actual,
|
||||
shouldLogVerbose: vi.fn(() => true),
|
||||
|
|
@ -18,8 +18,8 @@ vi.mock("../media.js", () => ({
|
|||
loadWebMedia: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../utils.js")>();
|
||||
vi.mock("../../../../src/utils.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../../../src/utils.js")>();
|
||||
return {
|
||||
...actual,
|
||||
sleep: vi.fn(async () => {}),
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { getReplyFromConfig } from "../../auto-reply/reply.js";
|
||||
import { HEARTBEAT_TOKEN } from "../../auto-reply/tokens.js";
|
||||
import { redactIdentifier } from "../../logging/redact-identifier.js";
|
||||
import type { sendMessageWhatsApp } from "../outbound.js";
|
||||
import type { getReplyFromConfig } from "../../../../src/auto-reply/reply.js";
|
||||
import { HEARTBEAT_TOKEN } from "../../../../src/auto-reply/tokens.js";
|
||||
import { redactIdentifier } from "../../../../src/logging/redact-identifier.js";
|
||||
import type { sendMessageWhatsApp } from "../send.js";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
visibility: { showAlerts: true, showOk: true, useIndicator: false },
|
||||
|
|
@ -22,34 +22,34 @@ const state = vi.hoisted(() => ({
|
|||
heartbeatWarnLogs: [] as string[],
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/current-time.js", () => ({
|
||||
vi.mock("../../../../src/agents/current-time.js", () => ({
|
||||
appendCronStyleCurrentTimeLine: (body: string) =>
|
||||
`${body}\nCurrent time: 2026-02-15T00:00:00Z (mock)`,
|
||||
}));
|
||||
|
||||
// Perf: this module otherwise pulls a large dependency graph that we don't need
|
||||
// for these unit tests.
|
||||
vi.mock("../../auto-reply/reply.js", () => ({
|
||||
vi.mock("../../../../src/auto-reply/reply.js", () => ({
|
||||
getReplyFromConfig: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("../../channels/plugins/whatsapp-heartbeat.js", () => ({
|
||||
vi.mock("../../../../src/channels/plugins/whatsapp-heartbeat.js", () => ({
|
||||
resolveWhatsAppHeartbeatRecipients: () => [],
|
||||
}));
|
||||
|
||||
vi.mock("../../config/config.js", () => ({
|
||||
vi.mock("../../../../src/config/config.js", () => ({
|
||||
loadConfig: () => ({ agents: { defaults: {} }, session: {} }),
|
||||
}));
|
||||
|
||||
vi.mock("../../routing/session-key.js", () => ({
|
||||
vi.mock("../../../../src/routing/session-key.js", () => ({
|
||||
normalizeMainKey: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../../infra/heartbeat-visibility.js", () => ({
|
||||
vi.mock("../../../../src/infra/heartbeat-visibility.js", () => ({
|
||||
resolveHeartbeatVisibility: () => state.visibility,
|
||||
}));
|
||||
|
||||
vi.mock("../../config/sessions.js", () => ({
|
||||
vi.mock("../../../../src/config/sessions.js", () => ({
|
||||
loadSessionStore: () => state.store,
|
||||
resolveSessionKey: () => "k",
|
||||
resolveStorePath: () => "/tmp/store.json",
|
||||
|
|
@ -62,12 +62,12 @@ vi.mock("./session-snapshot.js", () => ({
|
|||
getSessionSnapshot: () => state.snapshot,
|
||||
}));
|
||||
|
||||
vi.mock("../../infra/heartbeat-events.js", () => ({
|
||||
vi.mock("../../../../src/infra/heartbeat-events.js", () => ({
|
||||
emitHeartbeatEvent: (event: unknown) => state.events.push(event),
|
||||
resolveIndicatorType: (status: string) => `indicator:${status}`,
|
||||
}));
|
||||
|
||||
vi.mock("../../logging.js", () => ({
|
||||
vi.mock("../../../../src/logging.js", () => ({
|
||||
getChildLogger: () => ({
|
||||
info: (...args: unknown[]) => state.loggerInfoCalls.push(args),
|
||||
warn: (...args: unknown[]) => state.loggerWarnCalls.push(args),
|
||||
|
|
@ -85,7 +85,7 @@ vi.mock("../reconnect.js", () => ({
|
|||
newConnectionId: () => "run-1",
|
||||
}));
|
||||
|
||||
vi.mock("../outbound.js", () => ({
|
||||
vi.mock("../send.js", () => ({
|
||||
sendMessageWhatsApp: vi.fn(async () => ({ messageId: "m1" })),
|
||||
}));
|
||||
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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)}`);
|
||||
});
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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(", ");
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@ import fs from "node:fs/promises";
|
|||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { expectInboundContextContract } from "../../../../test/helpers/inbound-contract.js";
|
||||
import { expectInboundContextContract } from "../../../../../test/helpers/inbound-contract.js";
|
||||
|
||||
let capturedCtx: unknown;
|
||||
let capturedDispatchParams: unknown;
|
||||
|
|
@ -72,7 +72,7 @@ function createWhatsAppDirectStreamingArgs(params?: {
|
|||
channels: { whatsapp: { blockStreaming: true } },
|
||||
messages: {},
|
||||
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: {
|
||||
id: "msg1",
|
||||
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
|
||||
dispatchReplyWithBufferedBlockDispatcher: vi.fn(async (params: any) => {
|
||||
capturedDispatchParams = params;
|
||||
|
|
@ -222,7 +222,7 @@ describe("web processMessage inbound contract", () => {
|
|||
},
|
||||
messages: {},
|
||||
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]");
|
||||
});
|
||||
|
|
@ -231,7 +231,7 @@ describe("web processMessage inbound contract", () => {
|
|||
await processSelfDirectMessage({
|
||||
messages: {},
|
||||
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();
|
||||
});
|
||||
|
|
@ -258,7 +258,7 @@ describe("web processMessage inbound contract", () => {
|
|||
cfg: {
|
||||
messages: {},
|
||||
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: {
|
||||
id: "g1",
|
||||
from: "123@g.us",
|
||||
|
|
@ -378,7 +378,7 @@ describe("web processMessage inbound contract", () => {
|
|||
},
|
||||
messages: {},
|
||||
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: {
|
||||
id: params.messageId,
|
||||
from: params.from,
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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")
|
||||
);
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@ import fs from "node:fs/promises";
|
|||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
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 { applyGroupGating, type GroupHistoryEntry } from "./monitor/group-gating.js";
|
||||
import { buildInboundLine, formatReplyContext } from "./monitor/message-line.js";
|
||||
|
|
@ -33,10 +33,10 @@ const makeConfig = (overrides: Record<string, unknown>) =>
|
|||
},
|
||||
session: { store: sessionStorePath },
|
||||
...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: {
|
||||
cfg: ReturnType<typeof import("../../config/config.js").loadConfig>;
|
||||
cfg: ReturnType<typeof import("../../../../src/config/config.js").loadConfig>;
|
||||
msg: Record<string, unknown>;
|
||||
conversationId?: string;
|
||||
agentId?: string;
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { saveSessionStore } from "../../config/sessions.js";
|
||||
import { withTempDir } from "../../test-utils/temp-dir.js";
|
||||
import { saveSessionStore } from "../../../../src/config/sessions.js";
|
||||
import { withTempDir } from "../../../../src/test-utils/temp-dir.js";
|
||||
import {
|
||||
debugMention,
|
||||
isBotMentionedFromTargets,
|
||||
|
|
@ -6,24 +6,18 @@ import {
|
|||
import {
|
||||
applyAccountNameToChannelSection,
|
||||
buildChannelConfigSchema,
|
||||
collectWhatsAppStatusIssues,
|
||||
createActionGate,
|
||||
createWhatsAppOutboundBase,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
getChatChannelMeta,
|
||||
listWhatsAppAccountIds,
|
||||
listWhatsAppDirectoryGroupsFromConfig,
|
||||
listWhatsAppDirectoryPeersFromConfig,
|
||||
looksLikeWhatsAppTargetId,
|
||||
migrateBaseNameToDefaultAccount,
|
||||
normalizeAccountId,
|
||||
normalizeE164,
|
||||
formatWhatsAppConfigAllowFromEntries,
|
||||
normalizeWhatsAppMessagingTarget,
|
||||
readStringParam,
|
||||
resolveDefaultWhatsAppAccountId,
|
||||
resolveWhatsAppOutboundTarget,
|
||||
resolveWhatsAppAccount,
|
||||
resolveWhatsAppConfigAllowFrom,
|
||||
resolveWhatsAppConfigDefaultTo,
|
||||
resolveWhatsAppGroupRequireMention,
|
||||
|
|
@ -31,13 +25,21 @@ import {
|
|||
resolveWhatsAppGroupToolPolicy,
|
||||
resolveWhatsAppHeartbeatRecipients,
|
||||
resolveWhatsAppMentionStripPatterns,
|
||||
whatsappOnboardingAdapter,
|
||||
WhatsAppConfigSchema,
|
||||
type ChannelMessageActionName,
|
||||
type ChannelPlugin,
|
||||
type ResolvedWhatsAppAccount,
|
||||
} 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 { collectWhatsAppStatusIssues } from "./status-issues.js";
|
||||
|
||||
const meta = getChatChannelMeta("whatsapp");
|
||||
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@ const readAllowFromStoreMock = vi.fn().mockResolvedValue([]);
|
|||
const upsertPairingRequestMock = vi.fn().mockResolvedValue({ code: "PAIRCODE", created: true });
|
||||
const saveMediaBufferSpy = vi.fn();
|
||||
|
||||
vi.mock("../config/config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../config/config.js")>();
|
||||
vi.mock("../../../src/config/config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../../src/config/config.js")>();
|
||||
return {
|
||||
...actual,
|
||||
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 {
|
||||
readChannelAllowFromStore(...args: unknown[]) {
|
||||
return readAllowFromStoreMock(...args);
|
||||
|
|
@ -37,8 +37,8 @@ vi.mock("../pairing/pairing-store.js", () => {
|
|||
};
|
||||
});
|
||||
|
||||
vi.mock("../media/store.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../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(async (...args: Parameters<typeof actual.saveMediaBuffer>) => {
|
||||
|
|
@ -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";
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
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";
|
||||
|
||||
describe("resolveWhatsAppRuntimeGroupPolicy", () => {
|
||||
|
|
@ -33,15 +33,15 @@ export function setupAccessControlTestHarness(): void {
|
|||
});
|
||||
}
|
||||
|
||||
vi.mock("../../config/config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../config/config.js")>();
|
||||
vi.mock("../../../../src/config/config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../../../src/config/config.js")>();
|
||||
return {
|
||||
...actual,
|
||||
loadConfig: () => config,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../pairing/pairing-store.js", () => ({
|
||||
vi.mock("../../../../src/pairing/pairing-store.js", () => ({
|
||||
readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args),
|
||||
upsertChannelPairingRequest: (...args: unknown[]) => upsertPairingRequestMock(...args),
|
||||
}));
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const recordChannelActivity = vi.fn();
|
||||
vi.mock("../../infra/channel-activity.js", () => ({
|
||||
vi.mock("../../../../src/infra/channel-activity.js", () => ({
|
||||
recordChannelActivity: (...args: unknown[]) => recordChannelActivity(...args),
|
||||
}));
|
||||
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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 you’ve 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 you’ve 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." };
|
||||
}
|
||||
}
|
||||
|
|
@ -14,7 +14,7 @@ function resolveTestAuthDir() {
|
|||
|
||||
const authDir = resolveTestAuthDir();
|
||||
|
||||
vi.mock("../config/config.js", () => ({
|
||||
vi.mock("../../../src/config/config.js", () => ({
|
||||
loadConfig: () =>
|
||||
({
|
||||
channels: {
|
||||
|
|
@ -2,7 +2,7 @@ import { EventEmitter } from "node:events";
|
|||
import { readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
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";
|
||||
|
||||
vi.mock("./session.js", () => {
|
||||
|
|
@ -61,7 +61,7 @@ describe("renderQrPngBase64", () => {
|
|||
});
|
||||
|
||||
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");
|
||||
expect(source).not.toContain("createRequire(");
|
||||
expect(source).not.toContain('require("qrcode-terminal/vendor/QRCode")');
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -3,12 +3,12 @@ import os from "node:os";
|
|||
import path from "node:path";
|
||||
import sharp from "sharp";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { resolveStateDir } from "../config/paths.js";
|
||||
import { sendVoiceMessageDiscord } from "../discord/send.js";
|
||||
import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
|
||||
import { optimizeImageToPng } from "../media/image-ops.js";
|
||||
import { mockPinnedHostnameResolution } from "../test-helpers/ssrf.js";
|
||||
import { captureEnv } from "../test-utils/env.js";
|
||||
import { resolveStateDir } from "../../../src/config/paths.js";
|
||||
import { sendVoiceMessageDiscord } from "../../../src/discord/send.js";
|
||||
import { resolvePreferredOpenClawTmpDir } from "../../../src/infra/tmp-openclaw-dir.js";
|
||||
import { optimizeImageToPng } from "../../../src/media/image-ops.js";
|
||||
import { mockPinnedHostnameResolution } from "../../../src/test-helpers/ssrf.js";
|
||||
import { captureEnv } from "../../../src/test-utils/env.js";
|
||||
import {
|
||||
LocalMediaAccessError,
|
||||
loadWebMedia,
|
||||
|
|
@ -18,9 +18,10 @@ import {
|
|||
|
||||
const convertHeicToJpegMock = vi.fn();
|
||||
|
||||
vi.mock("../media/image-ops.js", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("../media/image-ops.js")>("../media/image-ops.js");
|
||||
vi.mock("../../../src/media/image-ops.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../../../src/media/image-ops.js")>(
|
||||
"../../../src/media/image-ops.js",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
convertHeicToJpeg: (...args: unknown[]) => convertHeicToJpegMock(...args),
|
||||
|
|
@ -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 };
|
||||
|
|
@ -4,7 +4,7 @@ import os from "node:os";
|
|||
import path from "node:path";
|
||||
import "./monitor-inbox.test-harness.js";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { setLoggerOverride } from "../logging.js";
|
||||
import { setLoggerOverride } from "../../../src/logging.js";
|
||||
import { monitorWebInbox } from "./inbound.js";
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
|
|
@ -3,7 +3,7 @@ import fsSync from "node:fs";
|
|||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
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).
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
|
|
@ -81,24 +81,28 @@ function getPairingStoreMocks() {
|
|||
|
||||
const sock: MockSock = createMockSock();
|
||||
|
||||
vi.mock("../media/store.js", () => ({
|
||||
saveMediaBuffer: vi.fn().mockResolvedValue({
|
||||
id: "mid",
|
||||
path: "/tmp/mid",
|
||||
size: 1,
|
||||
contentType: "image/jpeg",
|
||||
}),
|
||||
}));
|
||||
vi.mock("../../../src/media/store.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../../src/media/store.js")>();
|
||||
return {
|
||||
...actual,
|
||||
saveMediaBuffer: vi.fn().mockResolvedValue({
|
||||
id: "mid",
|
||||
path: "/tmp/mid",
|
||||
size: 1,
|
||||
contentType: "image/jpeg",
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../config/config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../config/config.js")>();
|
||||
vi.mock("../../../src/config/config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../../src/config/config.js")>();
|
||||
return {
|
||||
...actual,
|
||||
loadConfig: () => mockLoadConfig(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../pairing/pairing-store.js", () => getPairingStoreMocks());
|
||||
vi.mock("../../../src/pairing/pairing-store.js", () => getPairingStoreMocks());
|
||||
|
||||
vi.mock("./session.js", () => ({
|
||||
createWaSocket: vi.fn().mockResolvedValue(sock),
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { DEFAULT_ACCOUNT_ID } from "../../../routing/session-key.js";
|
||||
import type { RuntimeEnv } from "../../../runtime.js";
|
||||
import type { WizardPrompter } from "../../../wizard/prompts.js";
|
||||
import { whatsappOnboardingAdapter } from "./whatsapp.js";
|
||||
import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js";
|
||||
import type { RuntimeEnv } from "../../../src/runtime.js";
|
||||
import type { WizardPrompter } from "../../../src/wizard/prompts.js";
|
||||
import { whatsappOnboardingAdapter } from "./onboarding.js";
|
||||
|
||||
const loginWebMock = vi.hoisted(() => vi.fn(async () => {}));
|
||||
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,
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../../../utils.js")>("../../../utils.js");
|
||||
vi.mock("../../../src/utils.js", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("../../../src/utils.js")>("../../../src/utils.js");
|
||||
return {
|
||||
...actual,
|
||||
pathExists: pathExistsMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../../web/accounts.js", () => ({
|
||||
vi.mock("./accounts.js", () => ({
|
||||
listWhatsAppAccountIds: listWhatsAppAccountIdsMock,
|
||||
resolveDefaultWhatsAppAccountId: resolveDefaultWhatsAppAccountIdMock,
|
||||
resolveWhatsAppAuthDir: resolveWhatsAppAuthDirMock,
|
||||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
|
|
@ -1,35 +1,41 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createWhatsAppPollFixture,
|
||||
expectWhatsAppPollSent,
|
||||
} from "../../../test-helpers/whatsapp-outbound.js";
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
sendPollWhatsApp: vi.fn(async () => ({ messageId: "poll-1", toJid: "1555@s.whatsapp.net" })),
|
||||
}));
|
||||
|
||||
vi.mock("../../../globals.js", () => ({
|
||||
vi.mock("../../../src/globals.js", () => ({
|
||||
shouldLogVerbose: () => false,
|
||||
}));
|
||||
|
||||
vi.mock("../../../web/outbound.js", () => ({
|
||||
vi.mock("./send.js", () => ({
|
||||
sendPollWhatsApp: hoisted.sendPollWhatsApp,
|
||||
}));
|
||||
|
||||
import { whatsappOutbound } from "./whatsapp.js";
|
||||
import { whatsappOutbound } from "./outbound-adapter.js";
|
||||
|
||||
describe("whatsappOutbound sendPoll", () => {
|
||||
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!({
|
||||
cfg,
|
||||
to,
|
||||
to: "+1555",
|
||||
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" });
|
||||
});
|
||||
});
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
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 {
|
||||
installSendPayloadContractSuite,
|
||||
primeSendMock,
|
||||
} from "../../../test-utils/send-payload-contract.js";
|
||||
import { whatsappOutbound } from "./whatsapp.js";
|
||||
} from "../../../src/test-utils/send-payload-contract.js";
|
||||
import { whatsappOutbound } from "./outbound-adapter.js";
|
||||
|
||||
function createHarness(params: {
|
||||
payload: ReplyPayload;
|
||||
|
|
@ -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,
|
||||
}),
|
||||
};
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
import {
|
||||
computeBackoff,
|
||||
DEFAULT_HEARTBEAT_SECONDS,
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -3,9 +3,9 @@ import fsSync from "node:fs";
|
|||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { resetLogger, setLoggerOverride } from "../logging.js";
|
||||
import { redactIdentifier } from "../logging/redact-identifier.js";
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
import { resetLogger, setLoggerOverride } from "../../../src/logging.js";
|
||||
import { redactIdentifier } from "../../../src/logging/redact-identifier.js";
|
||||
import { setActiveWebListener } from "./active-listener.js";
|
||||
|
||||
const loadWebMediaMock = vi.fn();
|
||||
|
|
@ -13,7 +13,7 @@ vi.mock("./media.js", () => ({
|
|||
loadWebMedia: (...args: unknown[]) => loadWebMediaMock(...args),
|
||||
}));
|
||||
|
||||
import { sendMessageWhatsApp, sendPollWhatsApp, sendReactionWhatsApp } from "./outbound.js";
|
||||
import { sendMessageWhatsApp, sendPollWhatsApp, sendReactionWhatsApp } from "./send.js";
|
||||
|
||||
describe("web outbound", () => {
|
||||
const sendComposingTo = vi.fn(async () => {});
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@ import { EventEmitter } from "node:events";
|
|||
import fsSync from "node:fs";
|
||||
import path from "node:path";
|
||||
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";
|
||||
|
||||
const { createWaSocket, formatError, logWebSelfId, waitForWaConnection } =
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { collectWhatsAppStatusIssues } from "./whatsapp.js";
|
||||
import { collectWhatsAppStatusIssues } from "./status-issues.js";
|
||||
|
||||
describe("collectWhatsAppStatusIssues", () => {
|
||||
it("reports unlinked enabled accounts", () => {
|
||||
|
|
@ -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.`,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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",
|
||||
"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: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",
|
||||
"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",
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
// `tsc` emits declarations under `dist/plugin-sdk/plugin-sdk/*` because the source lives
|
||||
// at `src/plugin-sdk/*` and `rootDir` is `src/`.
|
||||
// `tsc` emits declarations under `dist/plugin-sdk/src/plugin-sdk/*` because the source lives
|
||||
// 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
|
||||
// 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`);
|
||||
fs.mkdirSync(path.dirname(out), { recursive: true });
|
||||
// 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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ const { sendReactionWhatsApp, sendPollWhatsApp } = vi.hoisted(() => ({
|
|||
sendPollWhatsApp: vi.fn(async () => ({ messageId: "poll-1", toJid: "jid-1" })),
|
||||
}));
|
||||
|
||||
vi.mock("../../web/outbound.js", () => ({
|
||||
vi.mock("../../../extensions/whatsapp/src/send.js", () => ({
|
||||
sendReactionWhatsApp,
|
||||
sendPollWhatsApp,
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ const webMocks = vi.hoisted(() => ({
|
|||
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";
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ vi.mock("../agents/model-catalog.js", () => ({
|
|||
loadModelCatalog: agentMocks.loadModelCatalog,
|
||||
}));
|
||||
|
||||
vi.mock("../web/session.js", () => ({
|
||||
vi.mock("../../extensions/whatsapp/src/session.js", () => ({
|
||||
webAuthExists: agentMocks.webAuthExists,
|
||||
getWebAuthAgeMs: agentMocks.getWebAuthAgeMs,
|
||||
readWebSelfId: agentMocks.readWebSelfId,
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ vi.mock("../../slack/send.js", () => ({
|
|||
vi.mock("../../telegram/send.js", () => ({
|
||||
sendMessageTelegram: mocks.sendMessageTelegram,
|
||||
}));
|
||||
vi.mock("../../web/outbound.js", () => ({
|
||||
vi.mock("../../../extensions/whatsapp/src/send.js", () => ({
|
||||
sendMessageWhatsApp: mocks.sendMessageWhatsApp,
|
||||
sendPollWhatsApp: mocks.sendMessageWhatsApp,
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -1,72 +1,2 @@
|
|||
import { Type } from "@sinclair/typebox";
|
||||
import type { ChannelAgentTool } from "../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("../../../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:",
|
||||
"",
|
||||
``,
|
||||
].join("\n");
|
||||
return {
|
||||
content: [{ type: "text", text }],
|
||||
details: { qr: true },
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
// Shim: re-exports from extensions/whatsapp/src/agent-tools-login.ts
|
||||
export * from "../../../../extensions/whatsapp/src/agent-tools-login.js";
|
||||
|
|
|
|||
|
|
@ -1,25 +1,2 @@
|
|||
import { normalizeWhatsAppTarget } from "../../../whatsapp/normalize.js";
|
||||
import { looksLikeHandleOrPhoneTarget, trimMessagingTarget } from "./shared.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,
|
||||
});
|
||||
}
|
||||
// Shim: re-exports from extensions/whatsapp/src/normalize.ts
|
||||
export * from "../../../../extensions/whatsapp/src/normalize.js";
|
||||
|
|
|
|||
|
|
@ -1,354 +1,2 @@
|
|||
import path from "node:path";
|
||||
import { loginWeb } from "../../../channel-web.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);
|
||||
},
|
||||
};
|
||||
// Shim: re-exports from extensions/whatsapp/src/onboarding.ts
|
||||
export * from "../../../../extensions/whatsapp/src/onboarding.js";
|
||||
|
|
|
|||
|
|
@ -1,40 +1,2 @@
|
|||
import { chunkText } from "../../../auto-reply/chunk.js";
|
||||
import { shouldLogVerbose } from "../../../globals.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,
|
||||
});
|
||||
},
|
||||
};
|
||||
// Shim: re-exports from extensions/whatsapp/src/outbound-adapter.ts
|
||||
export * from "../../../../extensions/whatsapp/src/outbound-adapter.js";
|
||||
|
|
|
|||
|
|
@ -1,66 +1,2 @@
|
|||
import { formatCliCommand } from "../../../cli/command-format.js";
|
||||
import type { ChannelAccountSnapshot, ChannelStatusIssue } from "../types.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.`,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
// Shim: re-exports from extensions/whatsapp/src/status-issues.ts
|
||||
export * from "../../../../extensions/whatsapp/src/status-issues.js";
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ vi.mock("../gateway/call.js", () => ({
|
|||
callGateway: (...args: unknown[]) => callGatewayMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../web/auth-store.js", () => ({
|
||||
vi.mock("../../extensions/whatsapp/src/auth-store.js", () => ({
|
||||
webAuthExists: vi.fn(async () => true),
|
||||
getWebAuthAgeMs: vi.fn(() => 0),
|
||||
logWebSelfId: (...args: unknown[]) => logWebSelfIdMock(...args),
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ vi.mock("../config/sessions.js", () => ({
|
|||
updateLastRoute: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("../web/auth-store.js", () => ({
|
||||
vi.mock("../../extensions/whatsapp/src/auth-store.js", () => ({
|
||||
webAuthExists: vi.fn(async () => true),
|
||||
getWebAuthAgeMs: vi.fn(() => 1234),
|
||||
readWebSelfId: vi.fn(() => ({ e164: null, jid: null })),
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue