@gguf/claw
Version:
WhatsApp gateway CLI (Baileys web) with Pi RPC agent
1,545 lines (1,420 loc) • 83 kB
text/typescript
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