UNPKG

@gguf/claw

Version:

WhatsApp gateway CLI (Baileys web) with Pi RPC agent

1,545 lines (1,420 loc) 83 kB
import type { IncomingMessage, ServerResponse } from "node:http"; import type { OpenClawConfig } from "openclaw/plugin-sdk"; import { logAckFailure, logInboundDrop, logTypingFailure, resolveAckReaction, resolveControlCommandGate, } from "openclaw/plugin-sdk"; import type { ResolvedBlueBubblesAccount } from "./accounts.js"; import type { BlueBubblesAccountConfig, BlueBubblesAttachment } from "./types.js"; import { downloadBlueBubblesAttachment } from "./attachments.js"; import { markBlueBubblesChatRead, sendBlueBubblesTyping } from "./chat.js"; import { sendBlueBubblesMedia } from "./media-send.js"; import { fetchBlueBubblesServerInfo } from "./probe.js"; import { normalizeBlueBubblesReactionInput, sendBlueBubblesReaction } from "./reactions.js"; import { getBlueBubblesRuntime } from "./runtime.js"; import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js"; import { formatBlueBubblesChatTarget, isAllowedBlueBubblesSender, normalizeBlueBubblesHandle, } from "./targets.js"; export type BlueBubblesRuntimeEnv = { log?: (message: string) => void; error?: (message: string) => void; }; export type BlueBubblesMonitorOptions = { account: ResolvedBlueBubblesAccount; config: OpenClawConfig; runtime: BlueBubblesRuntimeEnv; abortSignal: AbortSignal; statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void; webhookPath?: string; }; const DEFAULT_WEBHOOK_PATH = "/bluebubbles-webhook"; const DEFAULT_TEXT_LIMIT = 4000; const invalidAckReactions = new Set<string>(); const REPLY_CACHE_MAX = 2000; const REPLY_CACHE_TTL_MS = 6 * 60 * 60 * 1000; type BlueBubblesReplyCacheEntry = { accountId: string; messageId: string; shortId: string; chatGuid?: string; chatIdentifier?: string; chatId?: number; senderLabel?: string; body?: string; timestamp: number; }; // Best-effort cache for resolving reply context when BlueBubbles webhooks omit sender/body. const blueBubblesReplyCacheByMessageId = new Map<string, BlueBubblesReplyCacheEntry>(); // Bidirectional maps for short ID ↔ message GUID resolution (token savings optimization) const blueBubblesShortIdToUuid = new Map<string, string>(); const blueBubblesUuidToShortId = new Map<string, string>(); let blueBubblesShortIdCounter = 0; function trimOrUndefined(value?: string | null): string | undefined { const trimmed = value?.trim(); return trimmed ? trimmed : undefined; } function generateShortId(): string { blueBubblesShortIdCounter += 1; return String(blueBubblesShortIdCounter); } function rememberBlueBubblesReplyCache( entry: Omit<BlueBubblesReplyCacheEntry, "shortId">, ): BlueBubblesReplyCacheEntry { const messageId = entry.messageId.trim(); if (!messageId) { return { ...entry, shortId: "" }; } // Check if we already have a short ID for this GUID let shortId = blueBubblesUuidToShortId.get(messageId); if (!shortId) { shortId = generateShortId(); blueBubblesShortIdToUuid.set(shortId, messageId); blueBubblesUuidToShortId.set(messageId, shortId); } const fullEntry: BlueBubblesReplyCacheEntry = { ...entry, messageId, shortId }; // Refresh insertion order. blueBubblesReplyCacheByMessageId.delete(messageId); blueBubblesReplyCacheByMessageId.set(messageId, fullEntry); // Opportunistic prune. const cutoff = Date.now() - REPLY_CACHE_TTL_MS; for (const [key, value] of blueBubblesReplyCacheByMessageId) { if (value.timestamp < cutoff) { blueBubblesReplyCacheByMessageId.delete(key); // Clean up short ID mappings for expired entries if (value.shortId) { blueBubblesShortIdToUuid.delete(value.shortId); blueBubblesUuidToShortId.delete(key); } continue; } break; } while (blueBubblesReplyCacheByMessageId.size > REPLY_CACHE_MAX) { const oldest = blueBubblesReplyCacheByMessageId.keys().next().value as string | undefined; if (!oldest) { break; } const oldEntry = blueBubblesReplyCacheByMessageId.get(oldest); blueBubblesReplyCacheByMessageId.delete(oldest); // Clean up short ID mappings for evicted entries if (oldEntry?.shortId) { blueBubblesShortIdToUuid.delete(oldEntry.shortId); blueBubblesUuidToShortId.delete(oldest); } } return fullEntry; } /** * Resolves a short message ID (e.g., "1", "2") to a full BlueBubbles GUID. * Returns the input unchanged if it's already a GUID or not found in the mapping. */ export function resolveBlueBubblesMessageId( shortOrUuid: string, opts?: { requireKnownShortId?: boolean }, ): string { const trimmed = shortOrUuid.trim(); if (!trimmed) { return trimmed; } // If it looks like a short ID (numeric), try to resolve it if (/^\d+$/.test(trimmed)) { const uuid = blueBubblesShortIdToUuid.get(trimmed); if (uuid) { return uuid; } if (opts?.requireKnownShortId) { throw new Error( `BlueBubbles short message id "${trimmed}" is no longer available. Use MessageSidFull.`, ); } } // Return as-is (either already a UUID or not found) return trimmed; } /** * Resets the short ID state. Only use in tests. * @internal */ export function _resetBlueBubblesShortIdState(): void { blueBubblesShortIdToUuid.clear(); blueBubblesUuidToShortId.clear(); blueBubblesReplyCacheByMessageId.clear(); blueBubblesShortIdCounter = 0; } /** * Gets the short ID for a message GUID, if one exists. */ function getShortIdForUuid(uuid: string): string | undefined { return blueBubblesUuidToShortId.get(uuid.trim()); } function resolveReplyContextFromCache(params: { accountId: string; replyToId: string; chatGuid?: string; chatIdentifier?: string; chatId?: number; }): BlueBubblesReplyCacheEntry | null { const replyToId = params.replyToId.trim(); if (!replyToId) { return null; } const cached = blueBubblesReplyCacheByMessageId.get(replyToId); if (!cached) { return null; } if (cached.accountId !== params.accountId) { return null; } const cutoff = Date.now() - REPLY_CACHE_TTL_MS; if (cached.timestamp < cutoff) { blueBubblesReplyCacheByMessageId.delete(replyToId); return null; } const chatGuid = trimOrUndefined(params.chatGuid); const chatIdentifier = trimOrUndefined(params.chatIdentifier); const cachedChatGuid = trimOrUndefined(cached.chatGuid); const cachedChatIdentifier = trimOrUndefined(cached.chatIdentifier); const chatId = typeof params.chatId === "number" ? params.chatId : undefined; const cachedChatId = typeof cached.chatId === "number" ? cached.chatId : undefined; // Avoid cross-chat collisions if we have identifiers. if (chatGuid && cachedChatGuid && chatGuid !== cachedChatGuid) { return null; } if ( !chatGuid && chatIdentifier && cachedChatIdentifier && chatIdentifier !== cachedChatIdentifier ) { return null; } if (!chatGuid && !chatIdentifier && chatId && cachedChatId && chatId !== cachedChatId) { return null; } return cached; } type BlueBubblesCoreRuntime = ReturnType<typeof getBlueBubblesRuntime>; function logVerbose( core: BlueBubblesCoreRuntime, runtime: BlueBubblesRuntimeEnv, message: string, ): void { if (core.logging.shouldLogVerbose()) { runtime.log?.(`[bluebubbles] ${message}`); } } function logGroupAllowlistHint(params: { runtime: BlueBubblesRuntimeEnv; reason: string; entry: string | null; chatName?: string; accountId?: string; }): void { const log = params.runtime.log ?? console.log; const nameHint = params.chatName ? ` (group name: ${params.chatName})` : ""; const accountHint = params.accountId ? ` (or channels.bluebubbles.accounts.${params.accountId}.groupAllowFrom)` : ""; if (params.entry) { log( `[bluebubbles] group message blocked (${params.reason}). Allow this group by adding ` + `"${params.entry}" to channels.bluebubbles.groupAllowFrom${nameHint}.`, ); log( `[bluebubbles] add to config: channels.bluebubbles.groupAllowFrom=["${params.entry}"]${accountHint}.`, ); return; } log( `[bluebubbles] group message blocked (${params.reason}). Allow groups by setting ` + `channels.bluebubbles.groupPolicy="open" or adding a group id to ` + `channels.bluebubbles.groupAllowFrom${accountHint}${nameHint}.`, ); } type WebhookTarget = { account: ResolvedBlueBubblesAccount; config: OpenClawConfig; runtime: BlueBubblesRuntimeEnv; core: BlueBubblesCoreRuntime; path: string; statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void; }; /** * Entry type for debouncing inbound messages. * Captures the normalized message and its target for later combined processing. */ type BlueBubblesDebounceEntry = { message: NormalizedWebhookMessage; target: WebhookTarget; }; /** * Default debounce window for inbound message coalescing (ms). * This helps combine URL text + link preview balloon messages that BlueBubbles * sends as separate webhook events when no explicit inbound debounce config exists. */ const DEFAULT_INBOUND_DEBOUNCE_MS = 500; /** * Combines multiple debounced messages into a single message for processing. * Used when multiple webhook events arrive within the debounce window. */ function combineDebounceEntries(entries: BlueBubblesDebounceEntry[]): NormalizedWebhookMessage { if (entries.length === 0) { throw new Error("Cannot combine empty entries"); } if (entries.length === 1) { return entries[0].message; } // Use the first message as the base (typically the text message) const first = entries[0].message; // Combine text from all entries, filtering out duplicates and empty strings const seenTexts = new Set<string>(); const textParts: string[] = []; for (const entry of entries) { const text = entry.message.text.trim(); if (!text) { continue; } // Skip duplicate text (URL might be in both text message and balloon) const normalizedText = text.toLowerCase(); if (seenTexts.has(normalizedText)) { continue; } seenTexts.add(normalizedText); textParts.push(text); } // Merge attachments from all entries const allAttachments = entries.flatMap((e) => e.message.attachments ?? []); // Use the latest timestamp const timestamps = entries .map((e) => e.message.timestamp) .filter((t): t is number => typeof t === "number"); const latestTimestamp = timestamps.length > 0 ? Math.max(...timestamps) : first.timestamp; // Collect all message IDs for reference const messageIds = entries .map((e) => e.message.messageId) .filter((id): id is string => Boolean(id)); // Prefer reply context from any entry that has it const entryWithReply = entries.find((e) => e.message.replyToId); return { ...first, text: textParts.join(" "), attachments: allAttachments.length > 0 ? allAttachments : first.attachments, timestamp: latestTimestamp, // Use first message's ID as primary (for reply reference), but we've coalesced others messageId: messageIds[0] ?? first.messageId, // Preserve reply context if present replyToId: entryWithReply?.message.replyToId ?? first.replyToId, replyToBody: entryWithReply?.message.replyToBody ?? first.replyToBody, replyToSender: entryWithReply?.message.replyToSender ?? first.replyToSender, // Clear balloonBundleId since we've combined (the combined message is no longer just a balloon) balloonBundleId: undefined, }; } const webhookTargets = new Map<string, WebhookTarget[]>(); /** * Maps webhook targets to their inbound debouncers. * Each target gets its own debouncer keyed by a unique identifier. */ const targetDebouncers = new Map< WebhookTarget, ReturnType<BlueBubblesCoreRuntime["channel"]["debounce"]["createInboundDebouncer"]> >(); function resolveBlueBubblesDebounceMs( config: OpenClawConfig, core: BlueBubblesCoreRuntime, ): number { const inbound = config.messages?.inbound; const hasExplicitDebounce = typeof inbound?.debounceMs === "number" || typeof inbound?.byChannel?.bluebubbles === "number"; if (!hasExplicitDebounce) { return DEFAULT_INBOUND_DEBOUNCE_MS; } return core.channel.debounce.resolveInboundDebounceMs({ cfg: config, channel: "bluebubbles" }); } /** * Creates or retrieves a debouncer for a webhook target. */ function getOrCreateDebouncer(target: WebhookTarget) { const existing = targetDebouncers.get(target); if (existing) { return existing; } const { account, config, runtime, core } = target; const debouncer = core.channel.debounce.createInboundDebouncer<BlueBubblesDebounceEntry>({ debounceMs: resolveBlueBubblesDebounceMs(config, core), buildKey: (entry) => { const msg = entry.message; // Prefer stable, shared identifiers to coalesce rapid-fire webhook events for the // same message (e.g., text-only then text+attachment). // // For balloons (URL previews, stickers, etc), BlueBubbles often uses a different // messageId than the originating text. When present, key by associatedMessageGuid // to keep text + balloon coalescing working. const balloonBundleId = msg.balloonBundleId?.trim(); const associatedMessageGuid = msg.associatedMessageGuid?.trim(); if (balloonBundleId && associatedMessageGuid) { return `bluebubbles:${account.accountId}:balloon:${associatedMessageGuid}`; } const messageId = msg.messageId?.trim(); if (messageId) { return `bluebubbles:${account.accountId}:msg:${messageId}`; } const chatKey = msg.chatGuid?.trim() ?? msg.chatIdentifier?.trim() ?? (msg.chatId ? String(msg.chatId) : "dm"); return `bluebubbles:${account.accountId}:${chatKey}:${msg.senderId}`; }, shouldDebounce: (entry) => { const msg = entry.message; // Skip debouncing for from-me messages (they're just cached, not processed) if (msg.fromMe) { return false; } // Skip debouncing for control commands - process immediately if (core.channel.text.hasControlCommand(msg.text, config)) { return false; } // Debounce all other messages to coalesce rapid-fire webhook events // (e.g., text+image arriving as separate webhooks for the same messageId) return true; }, onFlush: async (entries) => { if (entries.length === 0) { return; } // Use target from first entry (all entries have same target due to key structure) const flushTarget = entries[0].target; if (entries.length === 1) { // Single message - process normally await processMessage(entries[0].message, flushTarget); return; } // Multiple messages - combine and process const combined = combineDebounceEntries(entries); if (core.logging.shouldLogVerbose()) { const count = entries.length; const preview = combined.text.slice(0, 50); runtime.log?.( `[bluebubbles] coalesced ${count} messages: "${preview}${combined.text.length > 50 ? "..." : ""}"`, ); } await processMessage(combined, flushTarget); }, onError: (err) => { runtime.error?.(`[${account.accountId}] [bluebubbles] debounce flush failed: ${String(err)}`); }, }); targetDebouncers.set(target, debouncer); return debouncer; } /** * Removes a debouncer for a target (called during unregistration). */ function removeDebouncer(target: WebhookTarget): void { targetDebouncers.delete(target); } function normalizeWebhookPath(raw: string): string { const trimmed = raw.trim(); if (!trimmed) { return "/"; } const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`; if (withSlash.length > 1 && withSlash.endsWith("/")) { return withSlash.slice(0, -1); } return withSlash; } export function registerBlueBubblesWebhookTarget(target: WebhookTarget): () => void { const key = normalizeWebhookPath(target.path); const normalizedTarget = { ...target, path: key }; const existing = webhookTargets.get(key) ?? []; const next = [...existing, normalizedTarget]; webhookTargets.set(key, next); return () => { const updated = (webhookTargets.get(key) ?? []).filter((entry) => entry !== normalizedTarget); if (updated.length > 0) { webhookTargets.set(key, updated); } else { webhookTargets.delete(key); } // Clean up debouncer when target is unregistered removeDebouncer(normalizedTarget); }; } async function readJsonBody(req: IncomingMessage, maxBytes: number) { const chunks: Buffer[] = []; let total = 0; return await new Promise<{ ok: boolean; value?: unknown; error?: string }>((resolve) => { req.on("data", (chunk: Buffer) => { total += chunk.length; if (total > maxBytes) { resolve({ ok: false, error: "payload too large" }); req.destroy(); return; } chunks.push(chunk); }); req.on("end", () => { try { const raw = Buffer.concat(chunks).toString("utf8"); if (!raw.trim()) { resolve({ ok: false, error: "empty payload" }); return; } try { resolve({ ok: true, value: JSON.parse(raw) as unknown }); return; } catch { const params = new URLSearchParams(raw); const payload = params.get("payload") ?? params.get("data") ?? params.get("message"); if (payload) { resolve({ ok: true, value: JSON.parse(payload) as unknown }); return; } throw new Error("invalid json"); } } catch (err) { resolve({ ok: false, error: err instanceof Error ? err.message : String(err) }); } }); req.on("error", (err) => { resolve({ ok: false, error: err instanceof Error ? err.message : String(err) }); }); }); } function asRecord(value: unknown): Record<string, unknown> | null { return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : null; } function readString(record: Record<string, unknown> | null, key: string): string | undefined { if (!record) { return undefined; } const value = record[key]; return typeof value === "string" ? value : undefined; } function readNumber(record: Record<string, unknown> | null, key: string): number | undefined { if (!record) { return undefined; } const value = record[key]; return typeof value === "number" && Number.isFinite(value) ? value : undefined; } function readBoolean(record: Record<string, unknown> | null, key: string): boolean | undefined { if (!record) { return undefined; } const value = record[key]; return typeof value === "boolean" ? value : undefined; } function extractAttachments(message: Record<string, unknown>): BlueBubblesAttachment[] { const raw = message["attachments"]; if (!Array.isArray(raw)) { return []; } const out: BlueBubblesAttachment[] = []; for (const entry of raw) { const record = asRecord(entry); if (!record) { continue; } out.push({ guid: readString(record, "guid"), uti: readString(record, "uti"), mimeType: readString(record, "mimeType") ?? readString(record, "mime_type"), transferName: readString(record, "transferName") ?? readString(record, "transfer_name"), totalBytes: readNumberLike(record, "totalBytes") ?? readNumberLike(record, "total_bytes"), height: readNumberLike(record, "height"), width: readNumberLike(record, "width"), originalROWID: readNumberLike(record, "originalROWID") ?? readNumberLike(record, "rowid"), }); } return out; } function buildAttachmentPlaceholder(attachments: BlueBubblesAttachment[]): string { if (attachments.length === 0) { return ""; } const mimeTypes = attachments.map((entry) => entry.mimeType ?? ""); const allImages = mimeTypes.every((entry) => entry.startsWith("image/")); const allVideos = mimeTypes.every((entry) => entry.startsWith("video/")); const allAudio = mimeTypes.every((entry) => entry.startsWith("audio/")); const tag = allImages ? "<media:image>" : allVideos ? "<media:video>" : allAudio ? "<media:audio>" : "<media:attachment>"; const label = allImages ? "image" : allVideos ? "video" : allAudio ? "audio" : "file"; const suffix = attachments.length === 1 ? label : `${label}s`; return `${tag} (${attachments.length} ${suffix})`; } function buildMessagePlaceholder(message: NormalizedWebhookMessage): string { const attachmentPlaceholder = buildAttachmentPlaceholder(message.attachments ?? []); if (attachmentPlaceholder) { return attachmentPlaceholder; } if (message.balloonBundleId) { return "<media:sticker>"; } return ""; } // Returns inline reply tag like "[[reply_to:4]]" for prepending to message body function formatReplyTag(message: { replyToId?: string; replyToShortId?: string }): string | null { // Prefer short ID const rawId = message.replyToShortId || message.replyToId; if (!rawId) { return null; } return `[[reply_to:${rawId}]]`; } function readNumberLike(record: Record<string, unknown> | null, key: string): number | undefined { if (!record) { return undefined; } const value = record[key]; if (typeof value === "number" && Number.isFinite(value)) { return value; } if (typeof value === "string") { const parsed = Number.parseFloat(value); if (Number.isFinite(parsed)) { return parsed; } } return undefined; } function extractReplyMetadata(message: Record<string, unknown>): { replyToId?: string; replyToBody?: string; replyToSender?: string; } { const replyRaw = message["replyTo"] ?? message["reply_to"] ?? message["replyToMessage"] ?? message["reply_to_message"] ?? message["repliedMessage"] ?? message["quotedMessage"] ?? message["associatedMessage"] ?? message["reply"]; const replyRecord = asRecord(replyRaw); const replyHandle = asRecord(replyRecord?.["handle"]) ?? asRecord(replyRecord?.["sender"]) ?? null; const replySenderRaw = readString(replyHandle, "address") ?? readString(replyHandle, "handle") ?? readString(replyHandle, "id") ?? readString(replyRecord, "senderId") ?? readString(replyRecord, "sender") ?? readString(replyRecord, "from"); const normalizedSender = replySenderRaw ? normalizeBlueBubblesHandle(replySenderRaw) || replySenderRaw.trim() : undefined; const replyToBody = readString(replyRecord, "text") ?? readString(replyRecord, "body") ?? readString(replyRecord, "message") ?? readString(replyRecord, "subject") ?? undefined; const directReplyId = readString(message, "replyToMessageGuid") ?? readString(message, "replyToGuid") ?? readString(message, "replyGuid") ?? readString(message, "selectedMessageGuid") ?? readString(message, "selectedMessageId") ?? readString(message, "replyToMessageId") ?? readString(message, "replyId") ?? readString(replyRecord, "guid") ?? readString(replyRecord, "id") ?? readString(replyRecord, "messageId"); const associatedType = readNumberLike(message, "associatedMessageType") ?? readNumberLike(message, "associated_message_type"); const associatedGuid = readString(message, "associatedMessageGuid") ?? readString(message, "associated_message_guid") ?? readString(message, "associatedMessageId"); const isReactionAssociation = typeof associatedType === "number" && REACTION_TYPE_MAP.has(associatedType); const replyToId = directReplyId ?? (!isReactionAssociation ? associatedGuid : undefined); const threadOriginatorGuid = readString(message, "threadOriginatorGuid"); const messageGuid = readString(message, "guid"); const fallbackReplyId = !replyToId && threadOriginatorGuid && threadOriginatorGuid !== messageGuid ? threadOriginatorGuid : undefined; return { replyToId: (replyToId ?? fallbackReplyId)?.trim() || undefined, replyToBody: replyToBody?.trim() || undefined, replyToSender: normalizedSender || undefined, }; } function readFirstChatRecord(message: Record<string, unknown>): Record<string, unknown> | null { const chats = message["chats"]; if (!Array.isArray(chats) || chats.length === 0) { return null; } const first = chats[0]; return asRecord(first); } function normalizeParticipantEntry(entry: unknown): BlueBubblesParticipant | null { if (typeof entry === "string" || typeof entry === "number") { const raw = String(entry).trim(); if (!raw) { return null; } const normalized = normalizeBlueBubblesHandle(raw) || raw; return normalized ? { id: normalized } : null; } const record = asRecord(entry); if (!record) { return null; } const nestedHandle = asRecord(record["handle"]) ?? asRecord(record["sender"]) ?? asRecord(record["contact"]) ?? null; const idRaw = readString(record, "address") ?? readString(record, "handle") ?? readString(record, "id") ?? readString(record, "phoneNumber") ?? readString(record, "phone_number") ?? readString(record, "email") ?? readString(nestedHandle, "address") ?? readString(nestedHandle, "handle") ?? readString(nestedHandle, "id"); const nameRaw = readString(record, "displayName") ?? readString(record, "name") ?? readString(record, "title") ?? readString(nestedHandle, "displayName") ?? readString(nestedHandle, "name"); const normalizedId = idRaw ? normalizeBlueBubblesHandle(idRaw) || idRaw.trim() : ""; if (!normalizedId) { return null; } const name = nameRaw?.trim() || undefined; return { id: normalizedId, name }; } function normalizeParticipantList(raw: unknown): BlueBubblesParticipant[] { if (!Array.isArray(raw) || raw.length === 0) { return []; } const seen = new Set<string>(); const output: BlueBubblesParticipant[] = []; for (const entry of raw) { const normalized = normalizeParticipantEntry(entry); if (!normalized?.id) { continue; } const key = normalized.id.toLowerCase(); if (seen.has(key)) { continue; } seen.add(key); output.push(normalized); } return output; } function formatGroupMembers(params: { participants?: BlueBubblesParticipant[]; fallback?: BlueBubblesParticipant; }): string | undefined { const seen = new Set<string>(); const ordered: BlueBubblesParticipant[] = []; for (const entry of params.participants ?? []) { if (!entry?.id) { continue; } const key = entry.id.toLowerCase(); if (seen.has(key)) { continue; } seen.add(key); ordered.push(entry); } if (ordered.length === 0 && params.fallback?.id) { ordered.push(params.fallback); } if (ordered.length === 0) { return undefined; } return ordered.map((entry) => (entry.name ? `${entry.name} (${entry.id})` : entry.id)).join(", "); } function resolveGroupFlagFromChatGuid(chatGuid?: string | null): boolean | undefined { const guid = chatGuid?.trim(); if (!guid) { return undefined; } const parts = guid.split(";"); if (parts.length >= 3) { if (parts[1] === "+") { return true; } if (parts[1] === "-") { return false; } } if (guid.includes(";+;")) { return true; } if (guid.includes(";-;")) { return false; } return undefined; } function extractChatIdentifierFromChatGuid(chatGuid?: string | null): string | undefined { const guid = chatGuid?.trim(); if (!guid) { return undefined; } const parts = guid.split(";"); if (parts.length < 3) { return undefined; } const identifier = parts[2]?.trim(); return identifier || undefined; } function formatGroupAllowlistEntry(params: { chatGuid?: string; chatId?: number; chatIdentifier?: string; }): string | null { const guid = params.chatGuid?.trim(); if (guid) { return `chat_guid:${guid}`; } const chatId = params.chatId; if (typeof chatId === "number" && Number.isFinite(chatId)) { return `chat_id:${chatId}`; } const identifier = params.chatIdentifier?.trim(); if (identifier) { return `chat_identifier:${identifier}`; } return null; } type BlueBubblesParticipant = { id: string; name?: string; }; type NormalizedWebhookMessage = { text: string; senderId: string; senderName?: string; messageId?: string; timestamp?: number; isGroup: boolean; chatId?: number; chatGuid?: string; chatIdentifier?: string; chatName?: string; fromMe?: boolean; attachments?: BlueBubblesAttachment[]; balloonBundleId?: string; associatedMessageGuid?: string; associatedMessageType?: number; associatedMessageEmoji?: string; isTapback?: boolean; participants?: BlueBubblesParticipant[]; replyToId?: string; replyToBody?: string; replyToSender?: string; }; type NormalizedWebhookReaction = { action: "added" | "removed"; emoji: string; senderId: string; senderName?: string; messageId: string; timestamp?: number; isGroup: boolean; chatId?: number; chatGuid?: string; chatIdentifier?: string; chatName?: string; fromMe?: boolean; }; const REACTION_TYPE_MAP = new Map<number, { emoji: string; action: "added" | "removed" }>([ [2000, { emoji: "❤️", action: "added" }], [2001, { emoji: "👍", action: "added" }], [2002, { emoji: "👎", action: "added" }], [2003, { emoji: "😂", action: "added" }], [2004, { emoji: "‼️", action: "added" }], [2005, { emoji: "❓", action: "added" }], [3000, { emoji: "❤️", action: "removed" }], [3001, { emoji: "👍", action: "removed" }], [3002, { emoji: "👎", action: "removed" }], [3003, { emoji: "😂", action: "removed" }], [3004, { emoji: "‼️", action: "removed" }], [3005, { emoji: "❓", action: "removed" }], ]); // Maps tapback text patterns (e.g., "Loved", "Liked") to emoji + action const TAPBACK_TEXT_MAP = new Map<string, { emoji: string; action: "added" | "removed" }>([ ["loved", { emoji: "❤️", action: "added" }], ["liked", { emoji: "👍", action: "added" }], ["disliked", { emoji: "👎", action: "added" }], ["laughed at", { emoji: "😂", action: "added" }], ["emphasized", { emoji: "‼️", action: "added" }], ["questioned", { emoji: "❓", action: "added" }], // Removal patterns (e.g., "Removed a heart from") ["removed a heart from", { emoji: "❤️", action: "removed" }], ["removed a like from", { emoji: "👍", action: "removed" }], ["removed a dislike from", { emoji: "👎", action: "removed" }], ["removed a laugh from", { emoji: "😂", action: "removed" }], ["removed an emphasis from", { emoji: "‼️", action: "removed" }], ["removed a question from", { emoji: "❓", action: "removed" }], ]); const TAPBACK_EMOJI_REGEX = /(?:\p{Regional_Indicator}{2})|(?:[0-9#*]\uFE0F?\u20E3)|(?:\p{Extended_Pictographic}(?:\uFE0F|\uFE0E)?(?:\p{Emoji_Modifier})?(?:\u200D\p{Extended_Pictographic}(?:\uFE0F|\uFE0E)?(?:\p{Emoji_Modifier})?)*)/u; function extractFirstEmoji(text: string): string | null { const match = text.match(TAPBACK_EMOJI_REGEX); return match ? match[0] : null; } function extractQuotedTapbackText(text: string): string | null { const match = text.match(/[“"]([^”"]+)[”"]/s); return match ? match[1] : null; } function isTapbackAssociatedType(type: number | undefined): boolean { return typeof type === "number" && Number.isFinite(type) && type >= 2000 && type < 4000; } function resolveTapbackActionHint(type: number | undefined): "added" | "removed" | undefined { if (typeof type !== "number" || !Number.isFinite(type)) { return undefined; } if (type >= 3000 && type < 4000) { return "removed"; } if (type >= 2000 && type < 3000) { return "added"; } return undefined; } function resolveTapbackContext(message: NormalizedWebhookMessage): { emojiHint?: string; actionHint?: "added" | "removed"; replyToId?: string; } | null { const associatedType = message.associatedMessageType; const hasTapbackType = isTapbackAssociatedType(associatedType); const hasTapbackMarker = Boolean(message.associatedMessageEmoji) || Boolean(message.isTapback); if (!hasTapbackType && !hasTapbackMarker) { return null; } const replyToId = message.associatedMessageGuid?.trim() || message.replyToId?.trim() || undefined; const actionHint = resolveTapbackActionHint(associatedType); const emojiHint = message.associatedMessageEmoji?.trim() || REACTION_TYPE_MAP.get(associatedType ?? -1)?.emoji; return { emojiHint, actionHint, replyToId }; } // Detects tapback text patterns like 'Loved "message"' and converts to structured format function parseTapbackText(params: { text: string; emojiHint?: string; actionHint?: "added" | "removed"; requireQuoted?: boolean; }): { emoji: string; action: "added" | "removed"; quotedText: string; } | null { const trimmed = params.text.trim(); const lower = trimmed.toLowerCase(); if (!trimmed) { return null; } for (const [pattern, { emoji, action }] of TAPBACK_TEXT_MAP) { if (lower.startsWith(pattern)) { // Extract quoted text if present (e.g., 'Loved "hello"' -> "hello") const afterPattern = trimmed.slice(pattern.length).trim(); if (params.requireQuoted) { const strictMatch = afterPattern.match(/^[“"](.+)[”"]$/s); if (!strictMatch) { return null; } return { emoji, action, quotedText: strictMatch[1] }; } const quotedText = extractQuotedTapbackText(afterPattern) ?? extractQuotedTapbackText(trimmed) ?? afterPattern; return { emoji, action, quotedText }; } } if (lower.startsWith("reacted")) { const emoji = extractFirstEmoji(trimmed) ?? params.emojiHint; if (!emoji) { return null; } const quotedText = extractQuotedTapbackText(trimmed); if (params.requireQuoted && !quotedText) { return null; } const fallback = trimmed.slice("reacted".length).trim(); return { emoji, action: params.actionHint ?? "added", quotedText: quotedText ?? fallback }; } if (lower.startsWith("removed")) { const emoji = extractFirstEmoji(trimmed) ?? params.emojiHint; if (!emoji) { return null; } const quotedText = extractQuotedTapbackText(trimmed); if (params.requireQuoted && !quotedText) { return null; } const fallback = trimmed.slice("removed".length).trim(); return { emoji, action: params.actionHint ?? "removed", quotedText: quotedText ?? fallback }; } return null; } function maskSecret(value: string): string { if (value.length <= 6) { return "***"; } return `${value.slice(0, 2)}***${value.slice(-2)}`; } function resolveBlueBubblesAckReaction(params: { cfg: OpenClawConfig; agentId: string; core: BlueBubblesCoreRuntime; runtime: BlueBubblesRuntimeEnv; }): string | null { const raw = resolveAckReaction(params.cfg, params.agentId).trim(); if (!raw) { return null; } try { normalizeBlueBubblesReactionInput(raw); return raw; } catch { const key = raw.toLowerCase(); if (!invalidAckReactions.has(key)) { invalidAckReactions.add(key); logVerbose( params.core, params.runtime, `ack reaction skipped (unsupported for BlueBubbles): ${raw}`, ); } return null; } } function extractMessagePayload(payload: Record<string, unknown>): Record<string, unknown> | null { const dataRaw = payload.data ?? payload.payload ?? payload.event; const data = asRecord(dataRaw) ?? (typeof dataRaw === "string" ? (asRecord(JSON.parse(dataRaw)) ?? null) : null); const messageRaw = payload.message ?? data?.message ?? data; const message = asRecord(messageRaw) ?? (typeof messageRaw === "string" ? (asRecord(JSON.parse(messageRaw)) ?? null) : null); if (!message) { return null; } return message; } function normalizeWebhookMessage( payload: Record<string, unknown>, ): NormalizedWebhookMessage | null { const message = extractMessagePayload(payload); if (!message) { return null; } const text = readString(message, "text") ?? readString(message, "body") ?? readString(message, "subject") ?? ""; const handleValue = message.handle ?? message.sender; const handle = asRecord(handleValue) ?? (typeof handleValue === "string" ? { address: handleValue } : null); const senderId = readString(handle, "address") ?? readString(handle, "handle") ?? readString(handle, "id") ?? readString(message, "senderId") ?? readString(message, "sender") ?? readString(message, "from") ?? ""; const senderName = readString(handle, "displayName") ?? readString(handle, "name") ?? readString(message, "senderName") ?? undefined; const chat = asRecord(message.chat) ?? asRecord(message.conversation) ?? null; const chatFromList = readFirstChatRecord(message); const chatGuid = readString(message, "chatGuid") ?? readString(message, "chat_guid") ?? readString(chat, "chatGuid") ?? readString(chat, "chat_guid") ?? readString(chat, "guid") ?? readString(chatFromList, "chatGuid") ?? readString(chatFromList, "chat_guid") ?? readString(chatFromList, "guid"); const chatIdentifier = readString(message, "chatIdentifier") ?? readString(message, "chat_identifier") ?? readString(chat, "chatIdentifier") ?? readString(chat, "chat_identifier") ?? readString(chat, "identifier") ?? readString(chatFromList, "chatIdentifier") ?? readString(chatFromList, "chat_identifier") ?? readString(chatFromList, "identifier") ?? extractChatIdentifierFromChatGuid(chatGuid); const chatId = readNumberLike(message, "chatId") ?? readNumberLike(message, "chat_id") ?? readNumberLike(chat, "chatId") ?? readNumberLike(chat, "chat_id") ?? readNumberLike(chat, "id") ?? readNumberLike(chatFromList, "chatId") ?? readNumberLike(chatFromList, "chat_id") ?? readNumberLike(chatFromList, "id"); const chatName = readString(message, "chatName") ?? readString(chat, "displayName") ?? readString(chat, "name") ?? readString(chatFromList, "displayName") ?? readString(chatFromList, "name") ?? undefined; const chatParticipants = chat ? chat["participants"] : undefined; const messageParticipants = message["participants"]; const chatsParticipants = chatFromList ? chatFromList["participants"] : undefined; const participants = Array.isArray(chatParticipants) ? chatParticipants : Array.isArray(messageParticipants) ? messageParticipants : Array.isArray(chatsParticipants) ? chatsParticipants : []; const normalizedParticipants = normalizeParticipantList(participants); const participantsCount = participants.length; const groupFromChatGuid = resolveGroupFlagFromChatGuid(chatGuid); const explicitIsGroup = readBoolean(message, "isGroup") ?? readBoolean(message, "is_group") ?? readBoolean(chat, "isGroup") ?? readBoolean(message, "group"); const isGroup = typeof groupFromChatGuid === "boolean" ? groupFromChatGuid : (explicitIsGroup ?? participantsCount > 2); const fromMe = readBoolean(message, "isFromMe") ?? readBoolean(message, "is_from_me"); const messageId = readString(message, "guid") ?? readString(message, "id") ?? readString(message, "messageId") ?? undefined; const balloonBundleId = readString(message, "balloonBundleId"); const associatedMessageGuid = readString(message, "associatedMessageGuid") ?? readString(message, "associated_message_guid") ?? readString(message, "associatedMessageId") ?? undefined; const associatedMessageType = readNumberLike(message, "associatedMessageType") ?? readNumberLike(message, "associated_message_type"); const associatedMessageEmoji = readString(message, "associatedMessageEmoji") ?? readString(message, "associated_message_emoji") ?? readString(message, "reactionEmoji") ?? readString(message, "reaction_emoji") ?? undefined; const isTapback = readBoolean(message, "isTapback") ?? readBoolean(message, "is_tapback") ?? readBoolean(message, "tapback") ?? undefined; const timestampRaw = readNumber(message, "date") ?? readNumber(message, "dateCreated") ?? readNumber(message, "timestamp"); const timestamp = typeof timestampRaw === "number" ? timestampRaw > 1_000_000_000_000 ? timestampRaw : timestampRaw * 1000 : undefined; const normalizedSender = normalizeBlueBubblesHandle(senderId); if (!normalizedSender) { return null; } const replyMetadata = extractReplyMetadata(message); return { text, senderId: normalizedSender, senderName, messageId, timestamp, isGroup, chatId, chatGuid, chatIdentifier, chatName, fromMe, attachments: extractAttachments(message), balloonBundleId, associatedMessageGuid, associatedMessageType, associatedMessageEmoji, isTapback, participants: normalizedParticipants, replyToId: replyMetadata.replyToId, replyToBody: replyMetadata.replyToBody, replyToSender: replyMetadata.replyToSender, }; } function normalizeWebhookReaction( payload: Record<string, unknown>, ): NormalizedWebhookReaction | null { const message = extractMessagePayload(payload); if (!message) { return null; } const associatedGuid = readString(message, "associatedMessageGuid") ?? readString(message, "associated_message_guid") ?? readString(message, "associatedMessageId"); const associatedType = readNumberLike(message, "associatedMessageType") ?? readNumberLike(message, "associated_message_type"); if (!associatedGuid || associatedType === undefined) { return null; } const mapping = REACTION_TYPE_MAP.get(associatedType); const associatedEmoji = readString(message, "associatedMessageEmoji") ?? readString(message, "associated_message_emoji") ?? readString(message, "reactionEmoji") ?? readString(message, "reaction_emoji"); const emoji = (associatedEmoji?.trim() || mapping?.emoji) ?? `reaction:${associatedType}`; const action = mapping?.action ?? resolveTapbackActionHint(associatedType) ?? "added"; const handleValue = message.handle ?? message.sender; const handle = asRecord(handleValue) ?? (typeof handleValue === "string" ? { address: handleValue } : null); const senderId = readString(handle, "address") ?? readString(handle, "handle") ?? readString(handle, "id") ?? readString(message, "senderId") ?? readString(message, "sender") ?? readString(message, "from") ?? ""; const senderName = readString(handle, "displayName") ?? readString(handle, "name") ?? readString(message, "senderName") ?? undefined; const chat = asRecord(message.chat) ?? asRecord(message.conversation) ?? null; const chatFromList = readFirstChatRecord(message); const chatGuid = readString(message, "chatGuid") ?? readString(message, "chat_guid") ?? readString(chat, "chatGuid") ?? readString(chat, "chat_guid") ?? readString(chat, "guid") ?? readString(chatFromList, "chatGuid") ?? readString(chatFromList, "chat_guid") ?? readString(chatFromList, "guid"); const chatIdentifier = readString(message, "chatIdentifier") ?? readString(message, "chat_identifier") ?? readString(chat, "chatIdentifier") ?? readString(chat, "chat_identifier") ?? readString(chat, "identifier") ?? readString(chatFromList, "chatIdentifier") ?? readString(chatFromList, "chat_identifier") ?? readString(chatFromList, "identifier") ?? extractChatIdentifierFromChatGuid(chatGuid); const chatId = readNumberLike(message, "chatId") ?? readNumberLike(message, "chat_id") ?? readNumberLike(chat, "chatId") ?? readNumberLike(chat, "chat_id") ?? readNumberLike(chat, "id") ?? readNumberLike(chatFromList, "chatId") ?? readNumberLike(chatFromList, "chat_id") ?? readNumberLike(chatFromList, "id"); const chatName = readString(message, "chatName") ?? readString(chat, "displayName") ?? readString(chat, "name") ?? readString(chatFromList, "displayName") ?? readString(chatFromList, "name") ?? undefined; const chatParticipants = chat ? chat["participants"] : undefined; const messageParticipants = message["participants"]; const chatsParticipants = chatFromList ? chatFromList["participants"] : undefined; const participants = Array.isArray(chatParticipants) ? chatParticipants : Array.isArray(messageParticipants) ? messageParticipants : Array.isArray(chatsParticipants) ? chatsParticipants : []; const participantsCount = participants.length; const groupFromChatGuid = resolveGroupFlagFromChatGuid(chatGuid); const explicitIsGroup = readBoolean(message, "isGroup") ?? readBoolean(message, "is_group") ?? readBoolean(chat, "isGroup") ?? readBoolean(message, "group"); const isGroup = typeof groupFromChatGuid === "boolean" ? groupFromChatGuid : (explicitIsGroup ?? participantsCount > 2); const fromMe = readBoolean(message, "isFromMe") ?? readBoolean(message, "is_from_me"); const timestampRaw = readNumberLike(message, "date") ?? readNumberLike(message, "dateCreated") ?? readNumberLike(message, "timestamp"); const timestamp = typeof timestampRaw === "number" ? timestampRaw > 1_000_000_000_000 ? timestampRaw : timestampRaw * 1000 : undefined; const normalizedSender = normalizeBlueBubblesHandle(senderId); if (!normalizedSender) { return null; } return { action, emoji, senderId: normalizedSender, senderName, messageId: associatedGuid, timestamp, isGroup, chatId, chatGuid, chatIdentifier, chatName, fromMe, }; } export async function handleBlueBubblesWebhookRequest( req: IncomingMessage, res: ServerResponse, ): Promise<boolean> { const url = new URL(req.url ?? "/", "http://localhost"); const path = normalizeWebhookPath(url.pathname); const targets = webhookTargets.get(path); if (!targets || targets.length === 0) { return false; } if (req.method !== "POST") { res.statusCode = 405; res.setHeader("Allow", "POST"); res.end("Method Not Allowed"); return true; } const body = await readJsonBody(req, 1024 * 1024); if (!body.ok) { res.statusCode = body.error === "payload too large" ? 413 : 400; res.end(body.error ?? "invalid payload"); console.warn(`[bluebubbles] webhook rejected: ${body.error ?? "invalid payload"}`); return true; } const payload = asRecord(body.value) ?? {}; const firstTarget = targets[0]; if (firstTarget) { logVerbose( firstTarget.core, firstTarget.runtime, `webhook received path=${path} keys=${Object.keys(payload).join(",") || "none"}`, ); } const eventTypeRaw = payload.type; const eventType = typeof eventTypeRaw === "string" ? eventTypeRaw.trim() : ""; const allowedEventTypes = new Set([ "new-message", "updated-message", "message-reaction", "reaction", ]); if (eventType && !allowedEventTypes.has(eventType)) { res.statusCode = 200; res.end("ok"); if (firstTarget) { logVerbose(firstTarget.core, firstTarget.runtime, `webhook ignored type=${eventType}`); } return true; } const reaction = normalizeWebhookReaction(payload); if ( (eventType === "updated-message" || eventType === "message-reaction" || eventType === "reaction") && !reaction ) { res.statusCode = 200; res.end("ok"); if (firstTarget) { logVerbose( firstTarget.core, firstTarget.runtime, `webhook ignored ${eventType || "event"} without reaction`, ); } return true; } const message = reaction ? null : normalizeWebhookMessage(payload); if (!message && !reaction) { res.statusCode = 400; res.end("invalid payload"); console.warn("[bluebubbles] webhook rejected: unable to parse message payload"); return true; } const matching = targets.filter((target) => { const token = target.account.config.password?.trim(); if (!token) { return true; } const guidParam = url.searchParams.get("guid") ?? url.searchParams.get("password"); const headerToken = req.headers["x-guid"] ?? req.headers["x-password"] ?? req.headers["x-bluebubbles-guid"] ?? req.headers["authorization"]; const guid = (Array.isArray(headerToken) ? headerToken[0] : headerToken) ?? guidParam ?? ""; if (guid && guid.trim() === token) { return true; } const remote = req.socket?.remoteAddress ?? ""; if (remote === "127.0.0.1" || remote === "::1" || remote === "::ffff:127.0.0.1") { return true; } return false; }); if (matching.length === 0) { res.statusCode = 401; res.end("unauthorized"); console.warn( `[bluebubbles] webhook rejected: unauthorized guid=${maskSecret(url.searchParams.get("guid") ?? url.searchParams.get("password") ?? "")}`, ); return true; } for (const target of matching) { target.statusSink?.({ lastInboundAt: Date.now() }); if (reaction) { processReaction(reaction, target).catch((err) => { target.runtime.error?.( `[${target.account.accountId}] BlueBubbles reaction failed: ${String(err)}`, ); }); } else if (message) { // Route messages through debouncer to coalesce rapid-fire events // (e.g., text message + URL balloon arriving as separate webhooks) const debouncer = getOrCreateDebouncer(target); debouncer.enqueue({ message, target }).catch((err) => { target.runtime.error?.( `[${target.account.accountId}] BlueBubbles