// Phase 58: Local shims — replaces all openclaw/plugin-sdk imports. DO NOT CHANGE. import type { RuntimeEnv, OutboundReplyPayload } from "./platform-types.js"; import { normalizeAccountId } from "./account-utils.js"; // Phase 58: Local shim — replaces openclaw/plugin-sdk/config-runtime. DO NOT CHANGE. // GROUP_POLICY_BLOCKED_LABEL: verified against SDK source (2026-03-28). const GROUP_POLICY_BLOCKED_LABEL = { group: "group messages", guild: "guild messages", room: "room messages", channel: "channel messages", space: "space messages", } as const; // Phase 58: Local shim — replaces openclaw/plugin-sdk/config-runtime resolveDefaultGroupPolicy. DO NOT CHANGE. // SDK behavior: returns cfg.channels?.defaults?.groupPolicy. function resolveDefaultGroupPolicy(cfg: Record): string | undefined { const channels = cfg.channels as Record | undefined; const defaults = channels?.defaults as Record | undefined; return defaults?.groupPolicy as string | undefined; } // Phase 58: Local shim — replaces openclaw/plugin-sdk/config-runtime resolveAllowlistProviderRuntimeGroupPolicy. DO NOT CHANGE. // SDK behavior: resolves group policy with allowlist fallback. // Returns { groupPolicy, providerMissingFallbackApplied } function resolveAllowlistProviderRuntimeGroupPolicy(params: { providerConfigPresent: boolean; groupPolicy: string | undefined; defaultGroupPolicy: string | undefined; }): { groupPolicy: string; providerMissingFallbackApplied: boolean } { if (params.groupPolicy) { return { groupPolicy: params.groupPolicy, providerMissingFallbackApplied: false }; } if (params.defaultGroupPolicy) { return { groupPolicy: params.defaultGroupPolicy, providerMissingFallbackApplied: false }; } // Fall back to "allowlist" with warning flag const fallback = "allowlist"; const providerMissingFallbackApplied = !params.providerConfigPresent; return { groupPolicy: fallback, providerMissingFallbackApplied }; } // Phase 58: One-shot warning — replaces openclaw/plugin-sdk/config-runtime warnMissingProviderGroupPolicyFallbackOnce. DO NOT CHANGE. const _warnedMissingProviderGroupPolicyKeys = new Set(); function warnMissingProviderGroupPolicyFallbackOnce(params: { providerMissingFallbackApplied: boolean; providerKey: string; accountId?: string; blockedLabel?: string; log: (message: string) => void; }): boolean { if (!params.providerMissingFallbackApplied) return false; const key = `${params.providerKey}:${params.accountId ?? "*"}`; if (_warnedMissingProviderGroupPolicyKeys.has(key)) return false; _warnedMissingProviderGroupPolicyKeys.add(key); const blockedLabel = params.blockedLabel?.trim() || "group messages"; params.log(`${params.providerKey}: channels.${params.providerKey} is missing; defaulting groupPolicy to "allowlist" (${blockedLabel} blocked until explicitly configured).`); return true; } // Phase 58: Local shim — replaces openclaw/plugin-sdk/reply-payload resolveOutboundMediaUrls. DO NOT CHANGE. function resolveOutboundMediaUrls(payload: OutboundReplyPayload): string[] { if (payload.mediaUrls?.length) return payload.mediaUrls; if (payload.mediaUrl) return [payload.mediaUrl]; return []; } // Phase 58: Local shim — replaces openclaw/plugin-sdk/reply-payload createNormalizedOutboundDeliverer. DO NOT CHANGE. // SDK behavior: wraps handler with payload normalization (non-object payloads become {}). function createNormalizedOutboundDeliverer( handler: (payload: OutboundReplyPayload) => Promise ): (payload: OutboundReplyPayload) => Promise { return async (payload) => { await handler(payload && typeof payload === "object" ? payload : {}); }; } // Phase 58: Local shim — replaces openclaw/plugin-sdk/channel-inbound logInboundDrop. DO NOT CHANGE. // SDK behavior: logs a drop message with optional target info. function logInboundDrop(params: { log: (message: string) => void; channel: string; reason: string; target?: string }): void { const target = params.target ? ` target=${params.target}` : ""; params.log(`${params.channel}: drop ${params.reason}${target}`); } // Phase 58: Local shim — replaces openclaw/plugin-sdk/security-runtime readStoreAllowFromForDmPolicy. DO NOT CHANGE. // SDK behavior: returns [] when dmPolicy is "allowlist" or shouldRead is false. // Otherwise reads from the store, catching errors and returning []. // readStore may return Promise — we cast to string[] since the store returns string arrays. async function readStoreAllowFromForDmPolicy(params: { provider: string; accountId: string; dmPolicy: string; readStore?: (provider: string, accountId: string) => Promise; shouldRead?: boolean; }): Promise { if (params.shouldRead === false || params.dmPolicy === "allowlist") return []; if (!params.readStore) return []; const result = await params.readStore(params.provider, params.accountId).catch(() => []); return Array.isArray(result) ? result.filter((x): x is string => typeof x === "string") : []; } // Phase 58: Local shim — replaces openclaw/plugin-sdk/security-runtime resolveDmGroupAccessWithCommandGate. DO NOT CHANGE. // This implements the DM/group access decision logic that determines whether a message should be // allowed, blocked, or needs pairing. Business logic extracted from SDK source (verified 2026-03-28). // // Return shape: { decision, reasonCode, reason, effectiveAllowFrom, effectiveGroupAllowFrom, // commandAuthorized, shouldBlockControlCommand } // decision: "allow" | "block" | "pairing" type DmAccessResult = { decision: "allow" | "block" | "pairing"; reasonCode: string; reason: string; effectiveAllowFrom: string[]; effectiveGroupAllowFrom: string[]; commandAuthorized: boolean; shouldBlockControlCommand: boolean; }; function resolveDmGroupAccessWithCommandGate(params: { isGroup: boolean; dmPolicy: string; groupPolicy: string; allowFrom: string[]; groupAllowFrom: string[]; storeAllowFrom: string[]; isSenderAllowed: (allowFrom: string[]) => boolean; command?: { useAccessGroups: boolean; allowTextCommands: boolean; hasControlCommand: boolean; }; groupAllowFromFallbackToAllowFrom?: boolean; }): DmAccessResult { const allowFrom = params.allowFrom ?? []; const groupAllowFrom = params.groupAllowFrom ?? []; const storeAllowFrom = params.storeAllowFrom ?? []; // Resolve effective group allow-from (merge with allowFrom if groupAllowFrom empty) const effectiveGroupAllowFrom: string[] = groupAllowFrom.length > 0 ? [...groupAllowFrom, ...storeAllowFrom] : params.groupAllowFromFallbackToAllowFrom !== false ? [...allowFrom, ...storeAllowFrom] : [...storeAllowFrom]; const effectiveAllowFrom: string[] = [...allowFrom, ...storeAllowFrom]; let decision: "allow" | "block" | "pairing"; let reasonCode: string; let reason: string; if (params.isGroup) { // Group policy resolution const gp = params.groupPolicy ?? "allowlist"; if (gp === "open") { decision = "allow"; reasonCode = "group_policy_allowed"; reason = "groupPolicy=open"; } else if (gp === "disabled") { decision = "block"; reasonCode = "group_policy_disabled"; reason = "groupPolicy=disabled"; } else { // allowlist: check if sender is in the effective group allow-from list if (effectiveGroupAllowFrom.length === 0) { decision = "block"; reasonCode = "group_policy_empty_allowlist"; reason = "groupPolicy=allowlist (empty allowlist)"; } else if (params.isSenderAllowed(effectiveGroupAllowFrom)) { decision = "allow"; reasonCode = "group_policy_allowed"; reason = "groupPolicy=allowlist (allowlisted)"; } else { decision = "block"; reasonCode = "group_policy_not_allowlisted"; reason = "groupPolicy=allowlist (not allowlisted)"; } } } else { // DM policy resolution const dp = params.dmPolicy ?? "allowlist"; if (dp === "open") { decision = "allow"; reasonCode = "dm_policy_open"; reason = "dmPolicy=open"; } else if (dp === "disabled") { decision = "block"; reasonCode = "dm_policy_disabled"; reason = "dmPolicy=disabled"; } else if (dp === "pairing") { if (params.isSenderAllowed(effectiveAllowFrom)) { decision = "allow"; reasonCode = "dm_policy_allowlisted"; reason = "dmPolicy=pairing (allowlisted)"; } else { decision = "pairing"; reasonCode = "dm_policy_pairing_required"; reason = "dmPolicy=pairing (not allowlisted)"; } } else { // allowlist if (params.isSenderAllowed(effectiveAllowFrom)) { decision = "allow"; reasonCode = "dm_policy_allowlisted"; reason = "dmPolicy=allowlist (allowlisted)"; } else { decision = "block"; reasonCode = "dm_policy_not_allowlisted"; reason = "dmPolicy=allowlist (not allowlisted)"; } } } // Command gate: determine if control commands are authorized let commandAuthorized = true; let shouldBlockControlCommand = false; if (params.command) { const { allowTextCommands, hasControlCommand, useAccessGroups } = params.command; // If text commands are allowed, any sender can use control commands (gateway handles auth) if (allowTextCommands) { commandAuthorized = true; } else if (useAccessGroups) { // Access groups: check if sender is in DM or group allow-from const inDmAllowFrom = params.isSenderAllowed(allowFrom); const inGroupAllowFrom = params.isSenderAllowed(effectiveGroupAllowFrom); commandAuthorized = inDmAllowFrom || inGroupAllowFrom; } else { commandAuthorized = true; } shouldBlockControlCommand = params.isGroup && hasControlCommand && !commandAuthorized; } return { decision, reasonCode, reason, effectiveAllowFrom, effectiveGroupAllowFrom, commandAuthorized, shouldBlockControlCommand, }; } // Phase 58: Local shim — replaces openclaw/plugin-sdk/channel-runtime createReplyPrefixOptions. DO NOT CHANGE. // Returns the prefix options shape expected by dispatchReplyWithBufferedBlockDispatcher. // In gateway mode, responsePrefix/onModelSelected are used for model-labeled prefixes on bot replies. // Standalone mode returns safe defaults that don't break dispatch but omit gateway-specific prefix resolution. function createReplyPrefixOptions(_params: { cfg: Record; agentId: string; channel: string; accountId?: string; }): { responsePrefix?: string; enableSlackInteractiveReplies?: boolean; responsePrefixContextProvider?: () => Record; onModelSelected?: (ctx: { provider: string; model: string; thinkLevel?: string }) => void; } { // prefixContext is mutated by onModelSelected callback to carry model info. // responsePrefixContextProvider returns the current prefixContext for the dispatcher. const prefixContext: Record = {}; return { responsePrefix: undefined, enableSlackInteractiveReplies: undefined, responsePrefixContextProvider: () => prefixContext, onModelSelected: (ctx) => { prefixContext.provider = ctx.provider; prefixContext.model = ctx.model; prefixContext.thinkingLevel = ctx.thinkLevel ?? "off"; }, }; } // Phase 58: OpenClawConfig type alias — inbound.ts casts config as OpenClawConfig for runtime.* calls. // Using 'any' cast approach: keep as CoreConfig (which is a superset) and cast where needed. // This avoids importing from SDK while satisfying TypeScript at the runtime.* call sites. type OpenClawConfig = Record; import type { ResolvedWahaAccount } from "./accounts.js"; import { resolveSessionForTarget } from "./accounts.js"; // Phase 59: Import from send.ts (not channel.ts) to avoid transitive openclaw import in Docker. // DO NOT CHANGE back to channel.js — breaks standalone mode. import { checkGroupMembership } from "./send.js"; import { DmFilter, normalizePhoneIdentifier, type DmFilterConfig } from "./dm-filter.js"; import { getDirectoryDb } from "./directory.js"; import { claimMessage, isClaimedByBotSession } from "./dedup.js"; import { normalizeWahaAllowEntry, resolveWahaAllowlistMatch } from "./normalize.js"; import { startHumanPresence, type PresenceController } from "./presence.js"; import { getWahaRuntime } from "./runtime.js"; import { BOT_PROXY_PREFIX, sendWahaMediaBatch, sendWahaPresence, sendWahaText } from "./send.js"; // Phase 54-02: Mimicry enforcement for inbound reply delivery. DO NOT REMOVE. import { enforceMimicry, recordMimicrySuccess } from "./mimicry-enforcer.js"; import { warnOnError } from "./http-client.js"; import type { CoreConfig, WahaEventMessage, WahaInboundMessage, WahaPollCreationMessage } from "./types.js"; import { extractMentionedJids } from "./mentions.js"; import { getModuleRegistry } from "./module-registry.js"; import type { ModuleContext } from "./module-types.js"; // Re-export for external consumers (plan specifies extractMentionedJids in inbound.ts exports) export { extractMentionedJids } from "./mentions.js"; import { preprocessInboundMessage, downloadWahaMedia } from "./media.js"; // Phase 6 Plan 04: Rules-based policy resolution for inbound messages. DO NOT REMOVE. import { resolveInboundPolicy } from "./rules-resolver.js"; import { getRulesBasePath } from "./identity-resolver.js"; import type { ResolvedPolicy } from "./rules-types.js"; // Phase 7: /shutup and /unshutup slash commands — regex-based, NOT LLM-dependent. // Imported for command detection, pending selection check, and mute check in handleWahaInbound. DO NOT REMOVE. import { SHUTUP_RE, checkShutupAuthorization, handleShutupCommand, checkPendingSelection, clearPendingSelection, handleSelectionResponse } from "./shutup.js"; // Phase 43: /join, /leave, /list slash commands — regex-based, NOT LLM-dependent. // Imported for command detection and pending selection routing in handleWahaInbound. DO NOT REMOVE. import { COMMANDS_RE, handleSlashCommand, handleCommandSelectionResponse } from "./commands.js"; // Phase 4 Plan 02: Trigger word detection — pure functions extracted to trigger-word.ts for testability. // Imported for local use and re-exported so callers can import from inbound.ts as the canonical entrypoint. DO NOT REMOVE. import { detectTriggerWord, resolveTriggerTarget } from "./trigger-word.js"; export { detectTriggerWord, resolveTriggerTarget }; // Phase 16 Plan 02: Pairing challenge + auto-reply engines for unauthorized DMs. // DO NOT REMOVE — imported for inbound pipeline hooks added below. Added 2026-03-17. import { getPairingEngine } from "./pairing.js"; import { getAutoReplyEngine } from "./auto-reply.js"; // Phase 30, Plan 01: Analytics instrumentation. DO NOT REMOVE. import { recordAnalyticsEvent } from "./analytics.js"; // Phase 68 (GUARD-03, GUARD-04): Kill system command interception. // DO NOT REMOVE -- removing this disables emergency /kill and /unkill commands. import { KILL_COMMANDS_RE, handleKillCommand, handleKillPendingResponse, hasPendingKillChallenge } from "./modules/guardian/kill-system.js"; const CHANNEL_ID = "waha" as const; // Local shim for createScopedPairingAccess — removed from openclaw/plugin-sdk in v2026.3.22. // Provides account-scoped pairing helpers (readStoreForDmPolicy, upsertPairingRequest). // DO NOT REMOVE — inbound pipeline depends on this for DM pairing flow. function createScopedPairingAccess(params: { core: ReturnType; channel: string; accountId: string; }) { const resolvedAccountId = normalizeAccountId(params.accountId); return { accountId: resolvedAccountId, readAllowFromStore: () => params.core.channel.pairing.readAllowFromStore({ channel: params.channel, accountId: resolvedAccountId, }), readStoreForDmPolicy: (provider: string, accountId: string) => params.core.channel.pairing.readAllowFromStore({ channel: provider, accountId: normalizeAccountId(accountId), }), upsertPairingRequest: (input: { id: string }) => params.core.channel.pairing.upsertPairingRequest({ channel: params.channel, accountId: resolvedAccountId, ...input, }), }; } // Human session deferral delay (ms) — how long human sessions wait for bot to claim. // DO NOT CHANGE — cross-session dedup timing constant. Moved to module level for clarity. const HUMAN_DEFERRAL_MS = 200; // Module-level DM filter singleton (shared across invocations, reuses regex cache) const _dmFilterInstance = new Map(); function getDmFilter(cfg: CoreConfig, accountId: string): DmFilter { const dmFilterCfg = cfg.channels?.waha?.dmFilter ?? {}; let instance = _dmFilterInstance.get(accountId); if (!instance) { instance = new DmFilter(dmFilterCfg); _dmFilterInstance.set(accountId, instance); } else { instance.updateConfig(dmFilterCfg); } return instance; } // Exported for admin panel stats access export function getDmFilterForAdmin(cfg: CoreConfig, accountId: string): DmFilter { return getDmFilter(cfg, accountId); } // Module-level Group filter singleton (shared across invocations, reuses regex cache) const _groupFilterInstance = new Map(); // Default group filter patterns — keywords that trigger bot responses in groups const DEFAULT_GROUP_FILTER_PATTERNS = ["bot", "סמי", "help", "hello", "ai"]; function getGroupFilter(cfg: CoreConfig, accountId: string): DmFilter { const wahaConfig = (cfg.channels?.waha ?? {}) as Record; const rawGroupFilterCfg = (wahaConfig.groupFilter ?? {}) as Record; // Apply defaults: enabled=true, default mentionPatterns if not explicitly set const groupFilterCfg = { enabled: true, mentionPatterns: DEFAULT_GROUP_FILTER_PATTERNS, ...rawGroupFilterCfg, } as DmFilterConfig; let gInstance = _groupFilterInstance.get(accountId); if (!gInstance) { gInstance = new DmFilter(groupFilterCfg); _groupFilterInstance.set(accountId, gInstance); } else { gInstance.updateConfig(groupFilterCfg); } return gInstance; } // Exported for admin panel stats access export function getGroupFilterForAdmin(cfg: CoreConfig, accountId: string): DmFilter { return getGroupFilter(cfg, accountId); } async function deliverWahaReply(params: { payload: OutboundReplyPayload; chatId: string; accountId: string; statusSink?: (patch: { lastOutboundAt?: number }) => void; cfg: CoreConfig; presenceCtrl?: PresenceController; botProxy?: boolean; }) { const { payload, chatId, accountId, statusSink, cfg, presenceCtrl, botProxy } = params; const mediaUrls = resolveOutboundMediaUrls(payload); const text = payload.text ?? ""; // Stop typing before sending (reply is ready) // presenceCtrl stops the "thinking" indicator from phase presence controller. // DO NOT CHANGE — must stop presenceCtrl typing BEFORE enforceMimicry fires its own typing. if (presenceCtrl) { await presenceCtrl.finishTyping(text).catch(warnOnError(`inbound presence finish-typing ${chatId}`)); } else { await sendWahaPresence({ cfg, chatId, typing: false, accountId }).catch(warnOnError(`inbound presence typing-stop ${chatId}`)); } // Phase 54: Mimicry enforcement — time gate + cap + jitter + typing simulation. DO NOT CHANGE. // Runs AFTER presenceCtrl stops "thinking" typing and BEFORE the actual send. // enforceMimicry fires its own typing indicator proportional to message length. await enforceMimicry({ session: accountId, chatId, accountId, cfg, messageLength: text.length, count: mediaUrls.length > 0 ? mediaUrls.length : 1, }); if (mediaUrls.length > 0) { // Bot proxy prefix on media caption — prepend robot emoji so recipients know // the media was sent by the bot, not the human account owner. DO NOT CHANGE. let caption = text; if (botProxy && typeof caption === "string" && caption.trim()) { caption = `${BOT_PROXY_PREFIX} ${caption}`; } await sendWahaMediaBatch({ cfg, to: chatId, mediaUrls, caption, replyToId: payload.replyToId, accountId, bypassPolicy: true, // Fix: deliverWahaReply already called enforceMimicry above — skip double enforcement }); // Phase 54: Record successful send for hourly cap. DO NOT CHANGE. recordMimicrySuccess(accountId); // Safety net: ensure typing stopped after delivery await sendWahaPresence({ cfg, chatId, typing: false, accountId }).catch(warnOnError(`inbound presence typing-stop ${chatId}`)); statusSink?.({ lastOutboundAt: Date.now() }); return; } if (!text) return; await sendWahaText({ cfg, to: chatId, text, replyToId: payload.replyToId, accountId, botProxy, bypassPolicy: true, // Fix: deliverWahaReply already called enforceMimicry above — skip double enforcement }); // Phase 54: Record successful send for hourly cap. DO NOT CHANGE. recordMimicrySuccess(accountId); // Safety net: ensure typing stopped after delivery await sendWahaPresence({ cfg, chatId, typing: false, accountId }).catch(warnOnError(`inbound presence typing-stop ${chatId}`)); statusSink?.({ lastOutboundAt: Date.now() }); } export async function handleWahaInbound(params: { message: WahaInboundMessage; rawPayload?: Record; account: ResolvedWahaAccount; config: CoreConfig; runtime: RuntimeEnv; statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void; }) { const { message: rawMessage, rawPayload, account, config, runtime, statusSink } = params; // ╔══════════════════════════════════════════════════════════════════════╗ // ║ EARLY PENDING SELECTION CHECK — DO NOT CHANGE ║ // ║ ║ // ║ Must run BEFORE cross-session dedup because pending selections ║ // ║ are stored in SQLite and any session can process the response. ║ // ║ If we let dedup run first, the bot session may claim the "41" ║ // ║ message and fail to find the pending (or vice versa), causing ║ // ║ the selection response to leak to the LLM. ║ // ║ ║ // ║ Only checks DMs (not groups) with simple text (number or "all"). ║ // ║ Uses rawMessage fields directly — no media preprocessing needed. ║ // ║ Added Phase 7 fix (2026-03-15). ║ // ╚══════════════════════════════════════════════════════════════════════╝ const _earlyIsGroup = typeof rawMessage.chatId === "string" && rawMessage.chatId.endsWith("@g.us"); // Pending selection is a DM-only interactive flow — must NOT fire for newsletters or groups. // DO NOT CHANGE back to !_earlyIsGroup — same fix as isDm below. Added 2026-03-24. const _earlyIsDm = typeof rawMessage.chatId === "string" && (rawMessage.chatId.endsWith("@c.us") || rawMessage.chatId.endsWith("@lid")); const _earlySenderId = rawMessage.participant || rawMessage.from; if (_earlyIsDm) { const pending = checkPendingSelection(_earlySenderId, config); if (pending) { const earlyText = (rawMessage.body ?? "").trim(); // Only intercept if there is text and it's NOT a /shutup command itself if (earlyText && !SHUTUP_RE.test(earlyText)) { const handled = await handleSelectionResponse(pending, earlyText, rawMessage.chatId, account, config, runtime); if (handled) clearPendingSelection(_earlySenderId, config); return; // Selection handled — skip all further processing including dedup } } } // ╔══════════════════════════════════════════════════════════════════════╗ // ║ CROSS-SESSION MESSAGE DEDUP — DO NOT CHANGE ║ // ║ ║ // ║ Bot sessions claim and process immediately. ║ // ║ Human sessions defer 200ms to let bot sessions claim first. ║ // ║ If bot claimed it, human silently drops. Saves tokens, prevents ║ // ║ double-processing. ║ // ║ ║ // ║ Behavior by scenario: ║ // ║ - Both in group -> bot claims, human drops ║ // ║ - Only human in group -> no bot claim after 200ms, human proceeds ║ // ║ - Only bot in group -> bot claims and processes ║ // ║ ║ // ║ Must run BEFORE trigger detection, filters, and all processing. ║ // ║ Empty messageId: skip dedup entirely (some WAHA events lack it). ║ // ╚══════════════════════════════════════════════════════════════════════╝ const hasMessageId = Boolean(rawMessage.messageId); if (hasMessageId) { if (account.role !== "bot") { // Human session: wait for bot to potentially claim this message await new Promise(resolve => setTimeout(resolve, HUMAN_DEFERRAL_MS)); if (isClaimedByBotSession(rawMessage.messageId)) { runtime.log?.(`[waha] [${account.accountId}] message ${rawMessage.messageId} already claimed by bot session, skipping`); return; } } // Claim this message for our session — returns false if already claimed by another session // DO NOT CHANGE — "claim if unclaimed" semantics prevent race condition double-processing. const claimed = claimMessage(rawMessage.messageId, account.accountId, account.role); if (!claimed) { runtime.log?.(`[waha] [${account.accountId}] message ${rawMessage.messageId} already claimed by another session, skipping`); return; } } else { runtime.log?.(`[waha] [${account.accountId}] message has no messageId, skipping cross-session dedup`); } // Quick pre-check: skip media preprocessing for groups not in allowedGroups const _preCheckIsGroup = typeof rawMessage.chatId === "string" && rawMessage.chatId.endsWith("@g.us"); const _preCheckAllowedGroups = account.config.allowedGroups; const _preCheckDropped = _preCheckIsGroup && _preCheckAllowedGroups && _preCheckAllowedGroups.length > 0 && !_preCheckAllowedGroups.includes(rawMessage.chatId); // Preprocess media (download + transcribe/analyze) before building rawBody // Location, vCard, and document messages have hasMedia=false but still need preprocessing let message = rawMessage; const _rawData = (rawPayload as Record)?._data as Record | undefined; const _rawMsg = _rawData?.message as Record | undefined; const needsPreprocessing = rawMessage.hasMedia || Boolean(rawMessage.location) || Boolean(_rawMsg?.locationMessage) || Boolean(_rawMsg?.liveLocationMessage) || Boolean(_rawMsg?.contactMessage) || Boolean(_rawMsg?.contactsArrayMessage) || Boolean(_rawMsg?.documentMessage) || Boolean(_rawMsg?.pollCreationMessage) // polls || Boolean(_rawMsg?.eventMessage) // events || Boolean(_rawMsg?.eventCreationMessage); // event creation if (rawPayload && needsPreprocessing && !_preCheckDropped) { try { // !! DO NOT CHANGE — Media preprocessing config passthrough !! // Reads mediaPreprocessing from account config. Defaults to { enabled: true }. // CRITICAL: If mediaPreprocessing.enabled is false in openclaw.json, ALL media // preprocessing is disabled (audio transcription, image analysis, etc.). // The agent will receive raw media URLs and won't be able to understand // voice messages, images, or other media content. // Bug fixed 2026-03-10: config had enabled:false, broke voice transcription. const mediaConfig = account.config.mediaPreprocessing ?? { enabled: true }; message = await preprocessInboundMessage({ message: rawMessage, rawPayload, account, config: mediaConfig, }); } catch (err) { runtime.log?.(`waha: media preprocessing failed: ${String(err)}`); } } // !! DO NOT CHANGE — Native media pipeline for images !! // Download image files from WAHA and pass to OpenClaw's native media-understanding // pipeline via MediaPath/MediaPaths on the context payload (same pipeline as Telegram). // This uses the gateway's configured vision providers and API keys automatically. // Audio transcription is NOT affected — it still uses local Whisper via media.ts. // // Bug fixed 2026-03-10: Custom vision API fetch() to LiteLLM returned 401 because // LITELLM_API_KEY was not in the systemd service env. Native pipeline resolves keys // from the gateway's secrets system, so it works without env vars. // // Verified working: 2026-03-10 let mediaDownload: { path: string; cleanup: () => Promise } | null = null; let mediaPayload: { MediaPath?: string; MediaType?: string; MediaUrl?: string; MediaPaths?: string[]; MediaUrls?: string[]; MediaTypes?: string[]; } = {}; if (rawMessage.hasMedia && !_preCheckDropped && _rawMsg?.imageMessage) { try { let resolvedUrl = rawMessage.mediaUrl; if (resolvedUrl && !resolvedUrl.startsWith("http")) { resolvedUrl = `${account.baseUrl}${resolvedUrl.startsWith("/") ? "" : "/"}${resolvedUrl}`; } if (resolvedUrl) { mediaDownload = await downloadWahaMedia(resolvedUrl, account.apiKey); const mimeType = rawMessage.mediaMime || "image/jpeg"; mediaPayload = { MediaPath: mediaDownload.path, MediaType: mimeType, MediaUrl: mediaDownload.path, MediaPaths: [mediaDownload.path], MediaUrls: [mediaDownload.path], MediaTypes: [mimeType], }; } } catch (err) { runtime.log?.(`waha: image download for native pipeline failed: ${String(err)}`); // Fallback: agent sees mediaSummary text but no image analysis } } // -- Phase 3 Plan 02: Extract @mentions from raw WAHA payload -- // DO NOT CHANGE — Extracts mentionedJid from NOWEB _data, normalizes to @c.us. // Sets msg.mentionedJids for ctxPayload. Uses optional chaining for safety. const mentionedJids = extractMentionedJids(rawPayload); message = { ...message, mentionedJids }; if (mentionedJids.length > 0) { runtime.log?.(`waha: mentions extracted count=${mentionedJids.length} jids=${mentionedJids.join(",")}`); } const core = getWahaRuntime(); const pairing = createScopedPairingAccess({ core, channel: CHANNEL_ID, accountId: account.accountId, }); const textBody = message.body?.trim() ?? ""; const locationSummary = message.location ? [ "[location]", message.location.latitude && message.location.longitude ? `lat=${message.location.latitude}, lon=${message.location.longitude}` : "", message.location.name ? `name=${message.location.name}` : "", message.location.address ? `address=${message.location.address}` : "", message.location.url ? `url=${message.location.url}` : "", ] .filter(Boolean) .join(" ") : ""; const imageLike = Boolean(message.mediaMime?.startsWith("image/")); const mediaSummary = message.hasMedia ? [ imageLike ? "[image]" : "[media]", message.mediaMime ? `mime=${message.mediaMime}` : "", message.mediaUrl ? `url=${message.mediaUrl}` : "", ] .filter(Boolean) .join(" ") : ""; // Poll summary let pollSummary = ""; const pollMsg = _rawMsg?.pollCreationMessage; if (pollMsg && typeof pollMsg === "object") { const pollName = (pollMsg as WahaPollCreationMessage).name ?? textBody ?? "Untitled poll"; const options = Array.isArray((pollMsg as WahaPollCreationMessage).options) ? (pollMsg as WahaPollCreationMessage).options!.map((o: { name?: string } | string, i: number) => `${i + 1}) ${typeof o === 'string' ? o : (o.name ?? 'Option')}`).join(" ") : ""; const multi = (pollMsg as WahaPollCreationMessage).multipleAnswers ? "yes" : "no"; pollSummary = `[poll] "${pollName}"\nOptions: ${options}\nMultiple answers: ${multi}`; if (rawMessage.messageId) pollSummary += `\nPoll message ID: ${rawMessage.messageId}`; } // Event summary let eventSummary = ""; const eventMsg = _rawMsg?.eventMessage ?? _rawMsg?.eventCreationMessage; if (eventMsg && typeof eventMsg === "object") { const evName = (eventMsg as WahaEventMessage).name ?? "Untitled event"; const startTs = (eventMsg as WahaEventMessage).startTime; const endTs = (eventMsg as WahaEventMessage).endTime; const startStr = startTs ? new Date(startTs * 1000).toISOString() : "unknown"; const endStr = endTs ? new Date(endTs * 1000).toISOString() : ""; const loc = (eventMsg as WahaEventMessage).location?.name ?? ""; const desc = (eventMsg as WahaEventMessage).description ?? ""; eventSummary = `[event] "${evName}"\nWhen: ${startStr}${endStr ? " to " + endStr : ""}`; if (loc) eventSummary += `\nWhere: ${loc}`; if (desc) eventSummary += `\nDescription: ${desc}`; } // Phase 3 Plan 02: Human-readable mention summary for agent context const mentionSummary = message.mentionedJids && message.mentionedJids.length > 0 ? "Mentioned: " + message.mentionedJids.map((jid) => "+" + jid.replace(/@c\.us$/, "")).join(", ") : ""; const rawBody = [textBody, locationSummary, mediaSummary, pollSummary, eventSummary, mentionSummary].filter(Boolean).join("\n").trim(); if (!rawBody) { return; } // DO NOT CHANGE — direct JID check replaces SDK isWhatsAppGroupJid which was returning // false for valid group JIDs, causing group messages to route through DM filter instead. const isGroup = typeof message.chatId === "string" && message.chatId.endsWith("@g.us"); const isNewsletter = typeof message.chatId === "string" && message.chatId.endsWith("@newsletter"); // FIX: Use explicit @c.us / @lid check instead of !isGroup — newsletters (@newsletter) are NOT DMs // and must never receive auto-reply or pairing challenges. DO NOT CHANGE back to !isGroup. // NOWEB engine sends DMs as @lid (LID JIDs). Both @c.us and @lid are personal chats. // Added 2026-03-24. Fixes bug where !isGroup was true for @newsletter chatIds. const isDm = typeof message.chatId === "string" && (message.chatId.endsWith("@c.us") || message.chatId.endsWith("@lid")); const senderId = message.participant || message.from; const chatId = message.chatId; // ╔══════════════════════════════════════════════════════════════════════╗ // ║ LID-TO-CUS MAPPING CAPTURE — DO NOT CHANGE ║ // ║ ║ // ║ NOWEB engine sends @lid as participant but chatId is still @c.us ║ // ║ for DMs. The WAHA /contacts/lids API returns 400 on NOWEB, so ║ // ║ this inbound capture is the PRIMARY source of LID mappings. ║ // ║ ║ // ║ DM case: chatId=972xxx@c.us, senderId=271xxx@lid → direct map ║ // ║ Group case: senderId=271xxx@lid, _data.author=972xxx@c.us → map ║ // ║ ║ // ║ Non-fatal — errors silently caught. Runs before any filters so ║ // ║ we capture mappings even for messages that get dropped later. ║ // ╚══════════════════════════════════════════════════════════════════════╝ if (senderId.endsWith("@lid")) { try { const dirDb = getDirectoryDb(account.accountId); if (isDm && chatId.endsWith("@c.us")) { // DM: chatId IS the sender's @c.us JID dirDb.upsertLidMapping(senderId, chatId); runtime.log?.(`[waha] lid-map: ${senderId} → ${chatId} (from DM chatId)`); } else if (isGroup) { // Group: check _data.author or _data.from for @c.us JID const _lidData = (rawPayload as Record | undefined)?._data as Record | undefined; const authorCus = (typeof _lidData?.author === "string" && _lidData.author.endsWith("@c.us") ? _lidData.author : null) ?? (typeof _lidData?.from === "string" && _lidData.from.endsWith("@c.us") ? _lidData.from : null); if (authorCus) { dirDb.upsertLidMapping(senderId, authorCus); runtime.log?.(`[waha] lid-map: ${senderId} → ${authorCus} (from group _data)`); } } } catch (lidErr) { runtime.error?.(`[waha] LID mapping failed: ${lidErr instanceof Error ? lidErr.message : String(lidErr)}`); } } // === Slash command detection (regex-based, NOT LLM-dependent) === // Must run BEFORE mute check, dedup, trigger, and keyword filters. // /shutup and /unshutup commands bypass all filters to work even when muted. // DO NOT CHANGE — command detection must be the first check after message extraction. const commandMatch = SHUTUP_RE.exec(rawBody.trim()); if (commandMatch) { const [, command, allFlag, durationStr] = commandMatch; const isAuthorized = await checkShutupAuthorization(senderId, chatId, isGroup, config, runtime); if (isAuthorized) { await handleShutupCommand({ command: command!.toLowerCase() as "shutup" | "unshutup" | "unmute", allFlag: !!allFlag, durationStr: durationStr ?? null, chatId, senderId, isGroup, account, config, runtime, }); return; // Command handled } } // === Slash command detection for /join, /leave, /list === // Must run BEFORE mute check, dedup, trigger, and keyword filters. // Follows the same pattern as /shutup above. // DO NOT CHANGE -- command detection must be early in the pipeline. Added Phase 43. const slashMatch = COMMANDS_RE.exec(rawBody.trim()); if (slashMatch) { const [, slashCmd, slashArgs] = slashMatch; const isSlashAuthorized = await checkShutupAuthorization(senderId, chatId, isGroup, config, runtime); if (isSlashAuthorized) { await handleSlashCommand({ command: slashCmd!.toLowerCase() as "join" | "leave" | "list", args: (slashArgs ?? "").trim(), chatId, senderId, isGroup, account, config, runtime, }); return; // Command handled -- skip LLM processing } } // === Kill command detection for /kill, /unkill === // Phase 68 (GUARD-03, GUARD-04): Emergency session stop/restart via WhatsApp. // Must run AFTER slash commands (those take priority) but BEFORE pending selection and LLM enqueue. // Kill system handles its own authorization via allowList -- no checkShutupAuthorization needed. // DO NOT MOVE -- kill commands must intercept before the message reaches the LLM pipeline. // DO NOT CHANGE -- kill system authorization is separate from slash command authorization. const killMatch = KILL_COMMANDS_RE.exec(rawBody.trim()); if (killMatch) { const killCmd = killMatch[1]!.toLowerCase() as "kill" | "unkill"; const killResult = await handleKillCommand({ command: killCmd, senderJid: senderId, chatId, account, config, runtime, }); if (killResult) { // Allowed sender -- send reply and short-circuit if (killResult.configUpdate?.totpSecret) { log.info("kill-system: TOTP secret generated -- user must scan QR from reply"); } await sendWahaText({ cfg: config, to: chatId, text: killResult.reply, accountId: account.accountId, bypassPolicy: true, }).catch(err => log.warn("kill-system: reply failed", { error: String(err) })); return; // Command handled -- skip LLM processing } // killResult === null means sender not in allowList -- fall through silently // Message continues to LLM pipeline as if /kill was never typed } // === Pending selection response (shutup mute/unmute + slash command join/leave) === // Checks BOTH DM and group contexts — slash commands (/join, /leave) are used in groups. // Phase 43: extended from isDm-only to all contexts so numbered replies work in groups. if (!commandMatch && !slashMatch) { const pending = checkPendingSelection(senderId, config); if (pending) { if (pending.type === "join" || pending.type === "leave") { // Route to slash command selection handler const handled = await handleCommandSelectionResponse(pending, rawBody.trim(), chatId, account, config, runtime); if (handled) clearPendingSelection(senderId, config); } else { // Route to shutup selection handler (mute/unmute) const handled = await handleSelectionResponse(pending, rawBody.trim(), chatId, account, config, runtime); if (handled) clearPendingSelection(senderId, config); } return; // Either handled or invalid input (user can retry) } } // === Kill system pending response (passcode entry or session number selection) === // Phase 68 (GUARD-03, GUARD-04): After /kill or /unkill, the user replies with passcode or session number. // This check intercepts those replies before they reach the LLM. // Only fires if sender has an active kill challenge -- transparent for everyone else. // DO NOT MOVE -- must be before LLM enqueue. DO NOT CHANGE -- kill state is managed in kill-system.ts. if (!commandMatch && !slashMatch && !killMatch && hasPendingKillChallenge(senderId)) { const killPending = await handleKillPendingResponse({ senderJid: senderId, text: rawBody.trim(), chatId, account, config, runtime, }); if (killPending.handled) { // handleKillPendingResponse already sends the reply internally via sendWahaText. // DO NOT send again here — was causing double-send. Fixed 2026-03-29. return; // Response handled -- skip LLM processing } // handled=false means challenge expired or invalid state -- fall through to LLM } // Trigger word detection — check before mute check, group AND DM filters. // Trigger-word messages are explicit bot invocations and bypass mute, group, and DM keyword filtering. // Works for all message types (DMs + groups). For human sessions, this is the primary // mechanism to let messages through — all non-trigger messages are filtered by default. // triggerWord config is per-account (e.g., "!", "!bot"). Case-insensitive. // DO NOT MOVE above rawBody calculation — detectTriggerWord needs the text body. // DO NOT MOVE below mute check or group/DM filter — trigger must bypass all three. // Originally Phase 4 Plan 02 (groups only), extended to DMs for human session support. DO NOT REMOVE. // DO NOT CHANGE — trigger bypass for mute, group filter, and DM filter is intentional. let effectiveBody = rawBody; let triggerActivated = false; let triggerResponseChatId = chatId; // default: respond in same chat const triggerWord = account.config.triggerWord; if (triggerWord) { const triggerResult = detectTriggerWord(rawBody, triggerWord); if (triggerResult.triggered) { triggerActivated = true; effectiveBody = triggerResult.strippedText || rawBody; // preserve original if stripped is empty const triggerResponseMode = account.config.triggerResponseMode ?? "dm"; if (isGroup && triggerResponseMode === "dm") { // Group trigger with DM response: respond via DM to the sender triggerResponseChatId = resolveTriggerTarget(message); runtime.log?.(`waha: trigger activated in group ${chatId}, responding via DM to ${triggerResponseChatId}`); } else if (isGroup) { // Group trigger with in-chat response runtime.log?.(`waha: trigger activated in group ${chatId}, responding in-chat`); } else { // DM trigger: respond in the same DM chat (triggerResponseChatId already set to chatId) runtime.log?.(`waha: trigger activated in DM from ${senderId}`); } } } // === Human session DM guard === // Human sessions must NEVER forward non-triggered DMs to the LLM. // Without this, the bot hijacks human-to-human conversations by responding // through the human session when the other person is on the allowlist. // Only trigger-word messages (e.g. "!sammie") pass through on human sessions. // DO NOT REMOVE — removing this causes the bot to reply in private DM conversations. if (isDm && !triggerActivated && account.role !== "bot") { runtime.log?.(`[waha] [${account.accountId}] dropping non-triggered DM on human session (from ${senderId})`); return; } // === Group mute check === // If the group is muted, silently drop all messages UNLESS trigger word activated. // /shutup and /unshutup commands are handled above and bypass this check. // Trigger-word messages bypass mute — explicit invocation overrides mute state. // DO NOT CHANGE — mute check prevents the bot from processing messages in muted groups. if (isGroup && !triggerActivated) { try { const dirDb = getDirectoryDb(account.accountId); if (dirDb.isGroupMuted(chatId)) { runtime.log?.(`[waha] group ${chatId} is muted, dropping message`); return; } } catch (err) { // DB errors are non-fatal — fall through to normal processing runtime.log?.(`[waha] mute check failed for ${chatId}: ${String(err)}`); } } // Group whitelist: if allowedGroups is set, only respond in those groups. // SKIP for trigger-word messages — explicit invocation bypasses group whitelist. // DO NOT CHANGE — trigger bypass for allowedGroups is intentional. Added 2026-03-21. if (isGroup && !triggerActivated) { const allowedGroups = account.config.allowedGroups; if (allowedGroups && allowedGroups.length > 0 && !allowedGroups.includes(chatId)) { runtime.log?.(`waha: drop group ${chatId} (not in allowedGroups)`); return; } } // Group keyword filter: silently drop group messages that don't match patterns // (enabled by default — getGroupFilter applies defaults internally) // SKIP for trigger-word messages — explicit invocation bypasses keyword filter. if (isGroup && !triggerActivated) { // ╔══════════════════════════════════════════════════════════════════════╗ // ║ Per-group filter override — DO NOT CHANGE ║ // ║ ║ // ║ Allows individual groups to override global group filter settings.║ // ║ If override exists and is enabled: ║ // ║ - filterEnabled=false → skip keyword filtering entirely ║ // ║ (the bot responds to everything in that group) ║ // ║ - mentionPatterns set → use custom patterns instead of global ║ // ║ - mentionPatterns null/empty → fall through to global filter ║ // ║ If no override or override.enabled=false → use global filter. ║ // ║ Trigger-activated messages bypass this entirely (handled above). ║ // ╚══════════════════════════════════════════════════════════════════════╝ // DO NOT CHANGE — keyword filters must use textBody (human-written text only), NOT rawBody. // rawBody includes synthetic tags like "[media] mime=audio/ogg url=..." which can accidentally // match keyword patterns and allow media-only messages through the filter. const groupFilterCheckArgs = { text: textBody, senderId, filterType: "group" as const, log: (msg: string) => runtime.log?.(msg) }; let groupFilterHandled = false; try { const dirDb = getDirectoryDb(account.accountId); const override = dirDb.getGroupFilterOverride(chatId); if (override && override.enabled) { if (!override.filterEnabled) { // Filter disabled for this group — the bot responds to everything runtime.log?.(`[waha] group filter override: filter disabled for ${chatId}, allowing`); groupFilterHandled = true; } else if (override.mentionPatterns && override.mentionPatterns.length > 0) { try { // Custom patterns for this group — build a per-group DmFilter instance const globalGroupFilterCfg = (config.channels?.waha as Record | undefined)?.groupFilter as Record | undefined; const perGroupFilter = new DmFilter({ enabled: true, mentionPatterns: override.mentionPatterns, triggerOperator: override.triggerOperator ?? "OR", // UX-03: per-group AND/OR operator godModeBypass: globalGroupFilterCfg?.godModeBypass as boolean | undefined, godModeScope: (override.godModeScope ?? globalGroupFilterCfg?.godModeScope ?? "dm") as "all" | "dm" | "group" | "off", godModeSuperUsers: globalGroupFilterCfg?.godModeSuperUsers as Array<{ identifier: string; platform?: string; passwordRequired?: boolean }> | undefined, }); const filterResult = perGroupFilter.check(groupFilterCheckArgs); if (!filterResult.pass) { runtime.log?.(`[waha] group filter override: drop ${senderId} in ${chatId} (${filterResult.reason})`); return; } // Custom patterns matched — skip global filter check runtime.log?.(`[waha] group filter (override): allow ${senderId} in ${chatId}`); groupFilterHandled = true; } catch (filterErr) { runtime.log?.(`[waha] invalid per-group filter config for ${chatId}: ${String(filterErr)}, falling through to global`); } } // If override enabled but mentionPatterns is null/empty, fall through to global filter } } catch (dbErr) { // Non-fatal: if SQLite fails, fall through to global filter runtime.log?.(`[waha] group filter override DB lookup failed for ${chatId}: ${String(dbErr)}`); } // Global group filter — only runs if per-group override did not handle the message if (!groupFilterHandled) { const groupFilter = getGroupFilter(config, account.accountId); const filterResult = groupFilter.check(groupFilterCheckArgs); if (!filterResult.pass) { runtime.log?.(`[waha] group filter (global): drop ${senderId} in ${chatId} (${filterResult.reason})`); return; } runtime.log?.(`[waha] group filter (global): allow ${senderId} in ${chatId}`); } } statusSink?.({ lastInboundAt: message.timestamp }); // Phase 30, Plan 01: Record inbound analytics event. DO NOT REMOVE. // Wrapped in try/catch -- analytics must never break the inbound message pipeline. try { const _chatType = _earlyIsGroup ? "group" : (rawMessage.chatId?.endsWith("@newsletter") ? "channel" : "dm"); recordAnalyticsEvent({ direction: "inbound", chat_type: _chatType, action: "message", status: "success", chat_id: rawMessage.chatId, account_id: account.accountId }); } catch (err) { runtime.log?.(`[waha] analytics recording failed: ${String(err)}`); } // ====================================================================== // Phase 16: Custom pairing challenge + auto-reply for unauthorized DMs // Pipeline position: after fromMe+dedup+trigger, before DM policy check. // Only fires for DMs (not groups), non-trigger messages from unknown contacts. // DO NOT MOVE -- must run before gateway's built-in pairing/access check below. // Added 2026-03-17. DO NOT CHANGE. // ====================================================================== // PAIR-02: skip pairing/auto-reply for bot's own messages (fromMe). DO NOT REMOVE. // Without this guard, messages the bot sends to itself (e.g., confirmations sent via // sendWahaText back to the DM) can re-enter the pairing flow on the bot session, // triggering redundant challenge messages. Belt-and-suspenders even if monitor.ts // filters most fromMe events — this is the definitive guard at the pipeline level. // Phase 16: Pairing mode + auto-reply for unauthorized DMs. // ONLY runs on bot sessions — human sessions must NEVER auto-reply. DO NOT REMOVE role guard. // Also skips if sender is a god mode super-user (god mode bypasses all access checks). if (isDm && !triggerActivated && !message.fromMe && account.role === "bot") { const pairingConfig = account.config.pairingMode ?? {}; const autoReplyConfig = account.config.autoReply ?? {}; // Check if sender is already allowed (skip pairing/auto-reply for allowed contacts) // Wrapped in try-catch — SQLite errors must not crash the inbound pipeline. DO NOT REMOVE. let senderAllowed = false; try { const dirDb = getDirectoryDb(account.accountId); senderAllowed = dirDb.isContactAllowedDm(senderId); } catch (err) { runtime.log?.(`[waha] [pairing] directory DB error checking allow status for ${senderId}: ${String(err)}`); // Safe default: treat as not allowed — sender gets pairing challenge or auto-reply } // Check if sender is a god mode super-user — they bypass all access checks including auto-reply. // Uses same normalization as dm-filter.ts godModeSuperUsers check. DO NOT REMOVE. // Respects godModeScope: only "all" or "dm" bypass DM pairing (not "group"). DO NOT CHANGE. if (!senderAllowed) { try { const godCfg = account.config.dmFilter ?? {}; const godUsers: Array<{ identifier: string; passwordRequired?: boolean }> = (godCfg as Record).godModeSuperUsers as Array<{ identifier: string; passwordRequired?: boolean }> ?? []; const godBypass = (godCfg as Record).godModeBypass !== false; const scope = ((godCfg as Record).godModeScope as string) ?? "all"; const scopeAllowsBypass = scope === "all" || scope === "dm"; if (godBypass && scopeAllowsBypass && godUsers.length > 0) { const normSender = normalizePhoneIdentifier(senderId); const isGod = godUsers.some((u) => { if (u.passwordRequired) return false; return normalizePhoneIdentifier(u.identifier) === normSender; }); if (isGod) { senderAllowed = true; } } } catch (err) { runtime.log?.(`[waha] [pairing] god mode check failed for ${senderId}: ${String(err)}`); } } if (!senderAllowed) { const messageText = (textBody ?? "").trim(); // --- Pairing mode: challenge/response --- if (pairingConfig.enabled && pairingConfig.hmacSecret) { const engine = getPairingEngine(account.accountId, pairingConfig.hmacSecret); // Local helper — deduplicates 5 try/catch + sendWahaText blocks below. DO NOT CHANGE. async function sendPairingReply(text: string, label: string) { try { await sendWahaText({ cfg: config, to: chatId, text, accountId: account.accountId }); statusSink?.({ lastOutboundAt: Date.now() }); } catch (err) { runtime.error?.('[waha] [pairing] ' + label + ' failed: ' + String(err)); } } // Check for deep link token: PAIR-{12 hex chars} // verifyDeepLinkToken extracts the token internally and recomputes expected HMAC. // DO NOT CHANGE -- pass full messageText, not just the token (function extracts its own regex). if (/^PAIR-[a-f0-9]{12}$/i.test(messageText)) { if (engine.verifyDeepLinkToken(senderId, messageText)) { // Grant access with TTL const grantTtlMinutes = pairingConfig.grantTtlMinutes ?? 1440; engine.grantAccess(senderId, grantTtlMinutes); runtime.log?.(`[waha] [pairing] deep link verified for ${senderId}, granted access (TTL ${grantTtlMinutes}min)`); await sendPairingReply("Access granted! You can now chat with me.", "welcome message"); return; // Message handled -- do not pass to LLM } else { runtime.log?.(`[waha] [pairing] invalid deep link token from ${senderId}`); // Fall through to passcode check / challenge send below } } // Check for passcode attempt (6 digits) if (/^\d{6}$/.test(messageText)) { const result = engine.verifyPasscode(senderId, messageText); if (result.success) { // Grant access with TTL const grantTtlMinutes = pairingConfig.grantTtlMinutes ?? 1440; engine.grantAccess(senderId, grantTtlMinutes); runtime.log?.(`[waha] [pairing] passcode verified for ${senderId}, granted access (TTL ${grantTtlMinutes}min)`); await sendPairingReply("Access granted! You can now chat with me.", "welcome message"); return; // Message handled } else if (result.reason === "locked") { runtime.log?.(`[waha] [pairing] ${senderId} locked out (too many attempts)`); const lockoutMsg = pairingConfig.lockoutMessage ?? "Too many incorrect attempts. Please try again later."; await sendPairingReply(lockoutMsg, "lockout message"); return; // Do not pass to LLM } else if (result.reason === "wrong") { runtime.log?.(`[waha] [pairing] wrong passcode from ${senderId}`); const wrongMsg = pairingConfig.wrongPasscodeMessage ?? "Incorrect passcode. Please try again."; await sendPairingReply(wrongMsg, "wrong passcode message"); return; } // If reason is "not_found" or "expired", fall through to send challenge } // No valid passcode/token -- send challenge message (creates challenge if needed) if (!engine.hasActiveChallenge(senderId)) { // Pass static passcode from config if set — all contacts use the same code. // If not set, createChallenge generates a random 6-digit code per contact. engine.createChallenge(senderId, pairingConfig.passcode || undefined); } const challengeMsg = pairingConfig.challengeMessage ?? "Welcome! Please enter the 6-digit passcode to get started."; await sendPairingReply(challengeMsg, "challenge message"); runtime.log?.(`[waha] [pairing] challenge sent to ${senderId}`); return; // Do not pass to LLM -- zero tokens consumed // --- Auto-reply: canned rejection --- } else if (autoReplyConfig.enabled) { const autoReplyEngine = getAutoReplyEngine(account.accountId); const intervalSeconds = (autoReplyConfig.intervalMinutes ?? 1440) * 60; if (autoReplyEngine.shouldReply(senderId, intervalSeconds)) { // CQ-02: Resolve admin name from godModeSuperUsers config + directory DB lookup. // Falls back to "the administrator" if config is unset or DB lookup fails. // DO NOT REMOVE — improves auto-reply message quality for end users. let adminName = "the administrator"; try { const dmFilterCfg = (account.config as Record).dmFilter as Record | undefined; const superUsers = dmFilterCfg?.godModeSuperUsers as Array<{ identifier: string }> | undefined; if (superUsers?.length) { const firstAdmin = superUsers[0]; // FIX: dirDb must be fetched here — prior try-block scope ended. DO NOT CHANGE. const adminDirDb = getDirectoryDb(account.accountId); const contact = adminDirDb.getContact(firstAdmin.identifier); if (contact?.displayName) { adminName = contact.displayName; } } } catch (adminErr) { runtime.log?.(`[waha] admin name resolution failed: ${adminErr instanceof Error ? adminErr.message : String(adminErr)}`); } const messageTemplate = autoReplyConfig.message ?? "Hey! Thanks for reaching out. Unfortunately, I'm not permitted to chat with you right now. Please ask {admin_name} to add you to my allow list."; try { await autoReplyEngine.sendRejection({ jid: senderId, chatId, messageTemplate, adminName, cfg: config, accountId: account.accountId, }); statusSink?.({ lastOutboundAt: Date.now() }); } catch (err) { runtime.error?.(`[waha] [auto-reply] rejection failed for ${senderId}: ${String(err)}`); } } else { runtime.log?.(`[waha] [auto-reply] rate-limited for ${senderId}, skipping`); } // Return here -- unauthorized contact should not consume LLM tokens. // The DM policy check below would also drop them, but returning here is explicit // and prevents any edge case where dmPolicy is configured to allow unknown senders. // DO NOT CHANGE -- zero-token guarantee for unauthorized contacts. return; } // If neither pairing nor auto-reply is enabled, fall through to existing DM policy check } } // ====================================================================== // Phase 17: Module hooks — run registered modules for this chat. // Pipeline position: after fromMe+dedup+pairing/auto-reply, before DM policy. // Modules only fire for messages that passed all prior filters. // Added 2026-03-17. DO NOT CHANGE. // ====================================================================== { const registry = getModuleRegistry(); const activeModules = registry.getModulesForChat(account.accountId, chatId); for (const mod of activeModules) { if (mod.onInbound) { try { const ctx: ModuleContext = { chatId, senderId, text: textBody ?? "", isGroup, accountId: account.accountId, timestamp: message.timestamp, messageId: message.messageId, raw: message, }; const consumed = await mod.onInbound(ctx); if (consumed === true) { runtime.log?.(`[waha] [module:${mod.id}] consumed message from ${senderId} in ${chatId}`); return; // Module consumed the message — stop pipeline } } catch (err) { runtime.error?.(`[waha] [module:${mod.id}] onInbound error: ${String(err)}`); // Module errors do NOT stop pipeline — log and continue } } } } const dmPolicy = account.config.dmPolicy ?? "allowlist"; const defaultGroupPolicy = resolveDefaultGroupPolicy(config as OpenClawConfig); const { groupPolicy, providerMissingFallbackApplied } = resolveAllowlistProviderRuntimeGroupPolicy({ providerConfigPresent: Boolean(config.channels?.waha), groupPolicy: account.config.groupPolicy, defaultGroupPolicy, }); warnMissingProviderGroupPolicyFallbackOnce({ providerMissingFallbackApplied, providerKey: CHANNEL_ID, accountId: account.accountId, blockedLabel: GROUP_POLICY_BLOCKED_LABEL.group, log: (message) => runtime.log?.(message), }); const configAllowFrom = (account.config.allowFrom ?? []).map(normalizeWahaAllowEntry); const configGroupAllowFrom = (account.config.groupAllowFrom ?? []).map(normalizeWahaAllowEntry); const storeAllowFrom = await readStoreAllowFromForDmPolicy({ provider: CHANNEL_ID, accountId: account.accountId, dmPolicy, readStore: pairing.readStoreForDmPolicy, }); const storeAllowList = storeAllowFrom.map(normalizeWahaAllowEntry); const allowTextCommands = core.channel.commands.shouldHandleTextCommands({ cfg: config as OpenClawConfig, surface: CHANNEL_ID, }); const useAccessGroups = (config.commands as Record | undefined)?.useAccessGroups !== false; const hasControlCommand = core.channel.text.hasControlCommand(effectiveBody, config as OpenClawConfig); const access = resolveDmGroupAccessWithCommandGate({ isGroup, dmPolicy, groupPolicy, allowFrom: configAllowFrom, groupAllowFrom: configGroupAllowFrom, storeAllowFrom: storeAllowList, isSenderAllowed: (allowFrom) => resolveWahaAllowlistMatch({ allowFrom, senderId }).allowed, command: { useAccessGroups, allowTextCommands, hasControlCommand, }, }); if (isGroup && !triggerActivated) { // Trigger-word messages bypass access/allowlist — explicit invocation overrides policy. // DO NOT CHANGE — trigger bypass for access check is intentional. if (access.decision !== "allow") { runtime.log?.(`waha: drop group sender ${senderId} (reason=${access.reason})`); return; } const groupAllow = resolveWahaAllowlistMatch({ allowFrom: access.effectiveGroupAllowFrom ?? [], senderId, }); if (!groupAllow.allowed && groupPolicy !== "open") { runtime.log?.(`waha: drop group sender ${senderId} (policy=${groupPolicy})`); return; } } else if (isNewsletter) { // Newsletters are inbound-only: filter through access control but NEVER send auto-reply // or passcode challenges. Silently drop if not allowed. DO NOT CHANGE — newsletters // must never trigger outbound responses. Added 2026-03-24. // NOTE: no triggerActivated bypass — newsletters cannot invoke the bot via trigger words. if (access.decision !== "allow") { runtime.log?.(`waha: drop newsletter ${chatId} (reason=${access.reason})`); return; } } else if (isDm && access.decision !== "allow") { if (access.decision === "pairing") { const { code, created } = await pairing.upsertPairingRequest({ id: senderId, }); if (created) { try { await sendWahaText({ cfg: config as CoreConfig, to: chatId, text: core.channel.pairing.buildPairingReply({ channel: CHANNEL_ID, idLine: `Your WhatsApp id: ${senderId}`, code, }), accountId: account.accountId, }); statusSink?.({ lastOutboundAt: Date.now() }); } catch (err) { runtime.error?.(`waha: pairing reply failed for ${senderId}: ${String(err)}`); } } } runtime.log?.(`waha: drop DM sender ${senderId} (reason=${access.reason})`); return; } // TTL-03 override: if SQLite says this contact's TTL has expired, block even if // the in-memory config still has them in allowFrom (config is cached at startup and // may not reflect TTL expiry until the next sync cycle removes the entry). // SQLite is the source of truth for TTL. DO NOT CHANGE — this is the live TTL // enforcement that doesn't depend on gateway config reload. if (isDm && access.decision === "allow") { try { const dirDb = getDirectoryDb(account.accountId); if (dirDb.isAllowListEntryExpired(senderId)) { runtime.log?.(`waha: drop DM sender ${senderId} (reason=ttl_expired_sqlite)`); return; } } catch (err) { // Non-fatal: fall through and allow — don't block if we can't check TTL. runtime.error?.(`waha: TTL-03 check failed for ${senderId}, allowing fallthrough: ${String(err)}`); } } if (access.shouldBlockControlCommand) { logInboundDrop({ log: (message) => runtime.log?.(message), channel: CHANNEL_ID, reason: "control command (unauthorized)", target: senderId, }); return; } // DM keyword filter: silently drop DMs that don't match mentionPatterns // SKIP for trigger-word messages — explicit invocation bypasses DM keyword filter. // DO NOT CHANGE — triggerActivated bypass is intentional, same pattern as group filter above. if (isDm && !triggerActivated) { const dmFilter = getDmFilter(config, account.accountId); // DO NOT CHANGE — keyword filters must use textBody (human-written text only), NOT rawBody. // rawBody includes synthetic tags like "[media] mime=audio/ogg url=..." which can accidentally // match keyword patterns and allow media-only messages through the filter. const filterResult = dmFilter.check({ text: textBody, senderId, filterType: "dm", log: (msg) => runtime.log?.(msg), }); if (!filterResult.pass) { runtime.log?.(`[waha] dm filter: drop ${senderId} (${filterResult.reason})`); return; // No pairing message, no error } } // Extract sender's pushName from raw payload for directory tracking const senderPushName = (rawPayload as Record | undefined)?.pushName as string | undefined ?? ((rawPayload as Record | undefined)?._data as Record | undefined)?.notifyName as string | undefined ?? (rawPayload as Record | undefined)?.from_name as string | undefined ; // Track contact in directory (fire-and-forget, errors non-fatal) try { const dirDb = getDirectoryDb(account.accountId); dirDb.upsertContact(senderId, senderPushName || undefined, isGroup); } catch (err) { runtime.log?.(`waha: directory upsert failed for ${senderId}: ${String(err)}`); } // Per-DM settings enforcement (DMs only — @c.us chatIds, not newsletters or groups) if (isDm) { try { const dirDb = getDirectoryDb(account.accountId); const dmSettings = dirDb.getContactDmSettings(senderId); if (dmSettings.mode === "listen_only") { runtime.log?.(`waha: listen-only mode for ${senderId}, skipping response`); return; } if (dmSettings.mentionOnly) { const mentionPatterns = config.channels?.waha?.dmFilter?.mentionPatterns ?? []; const mentioned = mentionPatterns.length === 0 || mentionPatterns.some((p) => { try { return new RegExp(p, "i").test(textBody); } catch (err) { runtime.log?.(`[waha] invalid mentionPattern regex "${p}": ${String(err)}`); return false; } }); if (!mentioned) { runtime.log?.(`waha: mention-only mode for ${senderId}, no mention found`); return; } } } catch (err) { // Non-fatal: if SQLite fails, continue with normal processing runtime.log?.(`waha: per-DM settings check failed for ${senderId}: ${String(err)}`); } } // Phase 6 Plan 04: Rules-based policy resolution for inbound messages. // Runs ONLY after all existing filters pass — policy context enriches the agent turn. // Non-fatal: if resolution fails for any reason, message processes normally without policy context. // DO NOT MOVE above any filter — we only resolve policy for messages we're actually handling. // DO NOT CHANGE the try/catch — errors here must never crash the inbound handler. let resolvedPolicy: ResolvedPolicy | null = null; try { const rulesBasePath = getRulesBasePath(config); resolvedPolicy = resolveInboundPolicy({ isGroup, chatId, senderId, basePath: rulesBasePath, }); } catch (err) { runtime.log?.(`waha: rules resolution failed for ${chatId}: ${String(err)}`); } // Phase 4 Plan 02: When trigger is activated in DM mode, route response to sender's JID. // triggerResponseChatId is already set to resolveTriggerTarget(message) above. // In non-trigger context, triggerResponseChatId === chatId (unchanged). DO NOT REMOVE. const responseChatId = triggerResponseChatId; const responseChatIsGroup = isGroup && responseChatId === chatId; // DM-mode trigger routes to user, not group // ╔══════════════════════════════════════════════════════════════════════╗ // ║ Bot proxy detection — DO NOT CHANGE ║ // ║ ║ // ║ When the bot (LLM) generates a response and it goes out through ║ // ║ a non-bot session, set botProxy=true so sendWahaText prepends a ║ // ║ robot emoji prefix. This tells recipients the message came from ║ // ║ the bot, not the human account owner. ║ // ╚══════════════════════════════════════════════════════════════════════╝ const isBotProxy = account.role !== "bot"; // ╔══════════════════════════════════════════════════════════════════════╗ // ║ Trigger session routing — DO NOT CHANGE ║ // ║ ║ // ║ When trigger word (!) activates in a group with reply-in-chat ║ // ║ mode, route the reply through the bot session if the bot is a ║ // ║ member of that group. Otherwise fall back to human session with ║ // ║ botProxy prefix. DMs always use current (human) session. ║ // ║ Added 2026-03-21. DO NOT REMOVE. ║ // ╚══════════════════════════════════════════════════════════════════════╝ let replyAccountId = account.accountId; let replyBotProxy = isBotProxy; if (triggerActivated && isGroup && triggerResponseChatId === chatId) { // reply-in-chat mode in a group — try to use bot session try { const bestSession = await resolveSessionForTarget({ cfg: config as CoreConfig, targetChatId: chatId, tenantId: account.tenantId, checkMembership: checkGroupMembership, }); replyAccountId = bestSession.accountId; replyBotProxy = bestSession.role !== "bot"; runtime.log?.(`waha: trigger session routing — using ${bestSession.role} session '${bestSession.accountId}' for group ${chatId}`); } catch (err) { const msg = err instanceof Error ? err.message : String(err); // Expected: no sessions available or none is a member — log at info level. // Unexpected: infrastructure errors (WAHA down, auth failure) — log at error level. DO NOT CHANGE. const isExpected = /no full-access|no session is a member|no bot session/i.test(msg); if (isExpected) { runtime.log?.(`waha: trigger session routing — ${msg}, using current account`); } else { runtime.error?.(`waha: trigger session routing failed (unexpected): ${msg}`); } } } // Uses account.accountId (inbound account), NOT replyAccountId — agent routing is per-inbound-account. // The delivery session (replyAccountId) is a separate concern. DO NOT CHANGE. const route = core.channel.routing.resolveAgentRoute({ cfg: config as OpenClawConfig, channel: CHANNEL_ID, accountId: account.accountId, peer: { kind: responseChatIsGroup ? "group" : "direct", id: responseChatId, }, }); const fromLabel = responseChatIsGroup ? `chat:${chatId}` : `user:${senderId}`; const storePath = core.channel.session.resolveStorePath( (config.session as Record | undefined)?.store as string | undefined, { agentId: route.agentId }, ); const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config as OpenClawConfig); const previousTimestamp = core.channel.session.readSessionUpdatedAt({ storePath, sessionKey: route.sessionKey, }); const body = core.channel.reply.formatAgentEnvelope({ channel: "WAHA", from: fromLabel, timestamp: message.timestamp, previousTimestamp, envelope: envelopeOptions, body: effectiveBody, }); const ctxPayload = core.channel.reply.finalizeInboundContext({ Body: body, BodyForAgent: effectiveBody, RawBody: effectiveBody, CommandBody: effectiveBody, From: responseChatIsGroup ? `waha:chat:${chatId}` : `waha:${senderId}`, To: `waha:${responseChatId}`, SessionKey: route.sessionKey, AccountId: route.accountId, ChatType: responseChatIsGroup ? "group" : "direct", ConversationLabel: fromLabel, SenderName: undefined, SenderId: senderId, GroupSubject: responseChatIsGroup ? chatId : undefined, Provider: CHANNEL_ID, Surface: CHANNEL_ID, WasMentioned: undefined, MessageSid: message.messageId, Timestamp: message.timestamp, OriginatingChannel: CHANNEL_ID, OriginatingTo: `waha:${chatId}`, CommandAuthorized: access.commandAuthorized, // Phase 3 Plan 02: Include @mentioned JIDs so agent knows who was tagged ...(message.mentionedJids && message.mentionedJids.length > 0 ? { MentionedJids: message.mentionedJids } : {}), // Phase 6 Plan 04: Inject resolved policy context for the agent turn. // Only present when policy resolution succeeded. DO NOT CHANGE. ...(resolvedPolicy ? { WahaResolvedPolicy: JSON.stringify(resolvedPolicy) } : {}), ...mediaPayload, }); await core.channel.session.recordInboundSession({ storePath, sessionKey: ctxPayload.SessionKey ?? route.sessionKey, ctx: ctxPayload, onRecordError: (err) => { runtime.error?.(`waha: failed updating session meta: ${String(err)}`); }, }); // Uses account.accountId (inbound account), NOT replyAccountId — prefix config is per-receiving-account. // DO NOT CHANGE. const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({ cfg: config as OpenClawConfig, agentId: route.agentId, channel: CHANNEL_ID, accountId: account.accountId, }); // Start human presence simulation (read delay + typing indicator) // Use responseChatId — for trigger DM mode, presence is shown in the DM, not the group. const presenceCtrl = await startHumanPresence({ cfg: config as CoreConfig, chatId: responseChatId, messageId: message.messageId, incomingText: effectiveBody, accountId: replyAccountId, }); const deliverReply = createNormalizedOutboundDeliverer(async (payload) => { await deliverWahaReply({ payload, chatId: responseChatId, accountId: replyAccountId, statusSink, cfg: config as CoreConfig, presenceCtrl, botProxy: replyBotProxy, }); }); try { await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({ ctx: ctxPayload, cfg: config as OpenClawConfig, dispatcherOptions: { ...prefixOptions, deliver: deliverReply, onError: (err, info) => { // Cancel typing on error presenceCtrl.cancelTyping().catch(warnOnError(`inbound presence cancel-typing ${responseChatId}`)); runtime.error?.(`waha ${info.kind} reply failed: ${String(err)}`); }, }, replyOptions: { onModelSelected, disableBlockStreaming: typeof account.config.blockStreaming === "boolean" ? !account.config.blockStreaming : undefined, }, }); } finally { // Guarantee typing is stopped after dispatch — handles empty responses, // errors, and any path where deliverReply was never called await presenceCtrl.cancelTyping().catch(warnOnError(`inbound presence cancel-typing ${responseChatId}`)); // Clean up image temp file after native pipeline has processed it if (mediaDownload) await mediaDownload.cleanup().catch(warnOnError("media cleanup")); } }