mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-29 04:23:36 +00:00
* fix(bluebubbles): restore inbound image attachments and accept updated-message events Four interconnected fixes for BlueBubbles inbound media: 1. Strip bundled-undici dispatcher from non-SSRF fetch path so attachment downloads no longer silently fail on Node 22+ (#64105, #61861) 2. Accept updated-message webhook events that carry attachments instead of filtering them as non-reaction events (#65430) 3. Include eventType in the persistent GUID dedup key so updated-message follow-ups are not rejected as duplicates of the original new-message (#52277) 4. Retry attachment fetch from BB API (2s delay) when the initial webhook arrives with an empty attachments array — image-only messages and updated-message events only (#67437) Closes #64105, closes #61861, closes #65430. * fix(bluebubbles): resolve review findings — SSRF policy, reuse extractAttachments, add tests - F1 (BLOCKER): pass undefined instead of {} for SSRF policy when allowPrivateNetwork is false, so localhost BB servers are not blocked. - F2 (IMPORTANT): reuse exported extractAttachments() from monitor-normalize instead of duplicating field extraction logic. - F3 (IMPORTANT): simplify asRecord(asRecord(payload)?.data) to asRecord(payload.data) since payload is already Record<string, unknown>. - F4 (NIT): bind retryMessageId before the guard to eliminate non-null assertion. - F5 (IMPORTANT): add 4 tests for fetchBlueBubblesMessageAttachments covering success, non-ok HTTP, empty data, and guid-less entries. - Add CHANGELOG entry for the user-facing fix. * fix(ci): update raw-fetch allowlist line number after dispatcher strip * fix(bluebubbles): resolve PR review findings (#67510) - monitor-processing: move attachment retry into the !rawBody guard so image-only new-message events that arrive with empty attachments and empty text are recovered via a BB API refetch before being dropped. The existing retry block at the end of processMessageAfterDedupe was unreachable for this case because the !rawBody early-return fired first. (Greptile) - monitor: derive isAttachmentUpdate from the normalized message shape instead of raw payload.data.attachments so updated-message webhooks with attachments under wrapper formats (payload.message, JSON-string payloads) are correctly routed through for processing instead of silently filtered. (Codex) - types: use bundled-undici fetch when init.dispatcher is present so the SSRF guard's DNS-pinning dispatcher is preserved when this function is called as fetchImpl from guarded callers (e.g. the attachment download path via fetchRemoteMedia). Falls back to globalThis.fetch when no dispatcher is present so tests that stub globalThis.fetch keep working. (Codex) - attachments: blueBubblesPolicy returns undefined for the non-private case (matching monitor-processing's helper) so sendBlueBubblesAttachment stops routing localhost BB through the SSRF guard. (Greptile) - scripts/check-no-raw-channel-fetch: bump the types.ts allowlist line to match the restructured non-SSRF branch. * fix(bluebubbles): move attachment retry before rawBody guard, fix stale log Move the attachment retry block (2s BB API refetch for empty attachments) before the !rawBody early-return guard. Previously, image-only messages with text='' and attachments=[] would be dropped by the !rawBody check before the retry could fire, making fix #4 dead code for its primary use-case. Now the retry runs first and recomputes the placeholder from resolved attachments so rawBody becomes non-empty when media is found. Also fix stale log message that still said 'without reaction' after the filter was expanded to pass through attachment updates. * fix(bluebubbles): revert undici import, restore dispatcher-strip approach Revert the @claude bot's undici import in types.ts — it introduced a direct 'undici' dependency that is not declared in the BB extension's package.json and would break isolated plugin installs. Restore the original dispatcher-strip approach which is correct: the SSRF guard already completed validation upstream before calling this function as fetchImpl, so stripping the dispatcher does not weaken security. * fix(bluebubbles): remove dead empty-body recovery block in !rawBody guard The empty-body attachment-recovery block added in the earlier PR revision is now redundant because the main retry block was moved above the rawBody computation in0d7d1c4208. Worse, that leftover block reassigned the (now-const) placeholder variable, throwing `TypeError: Assignment to constant variable` at runtime for image-only messages — breaking the very recovery path it was meant to protect (flagged by Codex on 4bfc2777). Remove the dead block; the up-front retry already handles the image-only case by recovering attachments before the rawBody computation, so once we reach the !rawBody guard with an empty body it is genuinely empty and should drop as before. * fix(ci): update raw-fetch allowlist line after dispatcher-strip revert279dba17d2reverted types.ts back to the dispatcher-strip approach, which put the `fetch(url, ...)` call at line 189 instead of line 198. Bump the allowlist entry to match so `lint:tmp:no-raw-channel-fetch` stops failing check-additional. * test(pdf-tool): update stale opus-4-6 constant to opus-4-7 `628b454eff feat: default Anthropic to Opus 4.7` bumped the bundled anthropic image default to `claude-opus-4-7` but missed updating the `ANTHROPIC_PDF_MODEL` constant in pdf-tool.model-config.test.ts. The tests now fail on any PR that runs the `checks-node-agentic-agents-plugins` shard because the resolver returns 4-7 while the test asserts 4-6. Bump the constant to 4-7 to match the bundled default. --------- Co-authored-by: Lobster <10343873+omarshahine@users.noreply.github.com>
850 lines
26 KiB
TypeScript
850 lines
26 KiB
TypeScript
import { parseFiniteNumber } from "openclaw/plugin-sdk/infra-runtime";
|
|
import {
|
|
asNullableRecord,
|
|
normalizeLowercaseStringOrEmpty,
|
|
normalizeOptionalString,
|
|
readStringField,
|
|
} from "openclaw/plugin-sdk/text-runtime";
|
|
import { extractHandleFromChatGuid, normalizeBlueBubblesHandle } from "./targets.js";
|
|
import type { BlueBubblesAttachment } from "./types.js";
|
|
|
|
export const asRecord = asNullableRecord;
|
|
const readString = readStringField;
|
|
|
|
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 readNumberLike(record: Record<string, unknown> | null, key: string): number | undefined {
|
|
if (!record) {
|
|
return undefined;
|
|
}
|
|
return parseFiniteNumber(record[key]);
|
|
}
|
|
|
|
export 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})`;
|
|
}
|
|
|
|
export 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
|
|
export 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 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: normalizeOptionalString(replyToId ?? fallbackReplyId),
|
|
replyToBody: normalizeOptionalString(replyToBody),
|
|
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 readParticipantEntries(record: Record<string, unknown> | null): unknown[] | undefined {
|
|
if (!record) {
|
|
return undefined;
|
|
}
|
|
const participants = record["participants"];
|
|
if (Array.isArray(participants)) {
|
|
return participants;
|
|
}
|
|
const handles = record["handles"];
|
|
if (Array.isArray(handles)) {
|
|
return handles;
|
|
}
|
|
const participantHandles = record["participantHandles"];
|
|
if (Array.isArray(participantHandles)) {
|
|
return participantHandles;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function extractSenderInfo(message: Record<string, unknown>): {
|
|
senderId: string;
|
|
senderIdExplicit: boolean;
|
|
senderName?: string;
|
|
} {
|
|
const handleValue = message.handle ?? message.sender;
|
|
const handle =
|
|
asRecord(handleValue) ?? (typeof handleValue === "string" ? { address: handleValue } : null);
|
|
const senderIdRaw =
|
|
readString(handle, "address") ??
|
|
readString(handle, "handle") ??
|
|
readString(handle, "id") ??
|
|
readString(message, "senderId") ??
|
|
readString(message, "sender") ??
|
|
readString(message, "from") ??
|
|
"";
|
|
const senderId = senderIdRaw.trim();
|
|
const senderName =
|
|
readString(handle, "displayName") ??
|
|
readString(handle, "name") ??
|
|
readString(message, "senderName") ??
|
|
undefined;
|
|
|
|
return {
|
|
senderId,
|
|
senderIdExplicit: Boolean(senderId),
|
|
senderName,
|
|
};
|
|
}
|
|
|
|
function extractChatContext(message: Record<string, unknown>): {
|
|
chatGuid?: string;
|
|
chatIdentifier?: string;
|
|
chatId?: number;
|
|
chatName?: string;
|
|
isGroup: boolean;
|
|
participants: unknown[];
|
|
} {
|
|
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 participants =
|
|
readParticipantEntries(chat) ??
|
|
readParticipantEntries(message) ??
|
|
readParticipantEntries(chatFromList) ??
|
|
[];
|
|
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);
|
|
|
|
return {
|
|
chatGuid,
|
|
chatIdentifier,
|
|
chatId,
|
|
chatName,
|
|
isGroup,
|
|
participants,
|
|
};
|
|
}
|
|
|
|
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 = normalizeOptionalString(nameRaw);
|
|
return { id: normalizedId, name };
|
|
}
|
|
|
|
export function normalizeParticipantList(raw: unknown): BlueBubblesParticipant[] {
|
|
const entries = Array.isArray(raw) ? raw : (readParticipantEntries(asRecord(raw)) ?? []);
|
|
if (entries.length === 0) {
|
|
return [];
|
|
}
|
|
const seen = new Set<string>();
|
|
const output: BlueBubblesParticipant[] = [];
|
|
for (const entry of entries) {
|
|
const normalized = normalizeParticipantEntry(entry);
|
|
if (!normalized?.id) {
|
|
continue;
|
|
}
|
|
const key = normalizeLowercaseStringOrEmpty(normalized.id);
|
|
if (seen.has(key)) {
|
|
continue;
|
|
}
|
|
seen.add(key);
|
|
output.push(normalized);
|
|
}
|
|
return output;
|
|
}
|
|
|
|
export 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 = normalizeLowercaseStringOrEmpty(entry.id);
|
|
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(", ");
|
|
}
|
|
|
|
export 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;
|
|
}
|
|
|
|
export 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;
|
|
}
|
|
|
|
export type BlueBubblesParticipant = {
|
|
id: string;
|
|
name?: string;
|
|
};
|
|
|
|
export type NormalizedWebhookMessage = {
|
|
text: string;
|
|
senderId: string;
|
|
senderIdExplicit: boolean;
|
|
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;
|
|
/** Webhook event type preserved for dedup key differentiation. */
|
|
eventType?: string;
|
|
};
|
|
|
|
export type NormalizedWebhookReaction = {
|
|
action: "added" | "removed";
|
|
emoji: string;
|
|
senderId: string;
|
|
senderIdExplicit: boolean;
|
|
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;
|
|
}
|
|
|
|
export 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 =
|
|
normalizeOptionalString(message.associatedMessageGuid) ??
|
|
normalizeOptionalString(message.replyToId);
|
|
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
|
|
export 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 = normalizeLowercaseStringOrEmpty(trimmed);
|
|
if (!trimmed) {
|
|
return null;
|
|
}
|
|
|
|
const parseLeadingReactionAction = (
|
|
prefix: "reacted" | "removed",
|
|
defaultAction: "added" | "removed",
|
|
) => {
|
|
if (!lower.startsWith(prefix)) {
|
|
return null;
|
|
}
|
|
const emoji = extractFirstEmoji(trimmed) ?? params.emojiHint;
|
|
if (!emoji) {
|
|
return null;
|
|
}
|
|
const quotedText = extractQuotedTapbackText(trimmed);
|
|
if (params.requireQuoted && !quotedText) {
|
|
return null;
|
|
}
|
|
const fallback = trimmed.slice(prefix.length).trim();
|
|
return {
|
|
emoji,
|
|
action: params.actionHint ?? defaultAction,
|
|
quotedText: quotedText ?? fallback,
|
|
};
|
|
};
|
|
|
|
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 };
|
|
}
|
|
}
|
|
|
|
const reacted = parseLeadingReactionAction("reacted", "added");
|
|
if (reacted) {
|
|
return reacted;
|
|
}
|
|
|
|
const removed = parseLeadingReactionAction("removed", "removed");
|
|
if (removed) {
|
|
return removed;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function extractMessagePayload(payload: Record<string, unknown>): Record<string, unknown> | null {
|
|
const parseRecord = (value: unknown): Record<string, unknown> | null => {
|
|
const record = asRecord(value);
|
|
if (record) {
|
|
return record;
|
|
}
|
|
if (Array.isArray(value)) {
|
|
for (const entry of value) {
|
|
const parsedEntry = parseRecord(entry);
|
|
if (parsedEntry) {
|
|
return parsedEntry;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
if (typeof value !== "string") {
|
|
return null;
|
|
}
|
|
const trimmed = value.trim();
|
|
if (!trimmed) {
|
|
return null;
|
|
}
|
|
try {
|
|
return parseRecord(JSON.parse(trimmed));
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const dataRaw = payload.data ?? payload.payload ?? payload.event;
|
|
const data = parseRecord(dataRaw);
|
|
const messageRaw = payload.message ?? data?.message ?? data;
|
|
const message = parseRecord(messageRaw);
|
|
if (message) {
|
|
return message;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function normalizeWebhookMessage(
|
|
payload: Record<string, unknown>,
|
|
options?: { eventType?: string },
|
|
): NormalizedWebhookMessage | null {
|
|
const message = extractMessagePayload(payload);
|
|
if (!message) {
|
|
return null;
|
|
}
|
|
|
|
const text =
|
|
readString(message, "text") ??
|
|
readString(message, "body") ??
|
|
readString(message, "subject") ??
|
|
"";
|
|
|
|
const { senderId, senderIdExplicit, senderName } = extractSenderInfo(message);
|
|
const { chatGuid, chatIdentifier, chatId, chatName, isGroup, participants } =
|
|
extractChatContext(message);
|
|
const normalizedParticipants = normalizeParticipantList(participants);
|
|
|
|
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;
|
|
|
|
// BlueBubbles may omit `handle` in webhook payloads; for DM chat GUIDs we can still infer sender.
|
|
const senderFallbackFromChatGuid =
|
|
!senderIdExplicit && !isGroup && chatGuid ? extractHandleFromChatGuid(chatGuid) : null;
|
|
const normalizedSender = normalizeBlueBubblesHandle(senderId || senderFallbackFromChatGuid || "");
|
|
if (!normalizedSender) {
|
|
return null;
|
|
}
|
|
const replyMetadata = extractReplyMetadata(message);
|
|
|
|
return {
|
|
text,
|
|
senderId: normalizedSender,
|
|
senderIdExplicit,
|
|
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,
|
|
eventType: options?.eventType,
|
|
};
|
|
}
|
|
|
|
export 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 { senderId, senderIdExplicit, senderName } = extractSenderInfo(message);
|
|
const { chatGuid, chatIdentifier, chatId, chatName, isGroup } = extractChatContext(message);
|
|
|
|
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 senderFallbackFromChatGuid =
|
|
!senderIdExplicit && !isGroup && chatGuid ? extractHandleFromChatGuid(chatGuid) : null;
|
|
const normalizedSender = normalizeBlueBubblesHandle(senderId || senderFallbackFromChatGuid || "");
|
|
if (!normalizedSender) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
action,
|
|
emoji,
|
|
senderId: normalizedSender,
|
|
senderIdExplicit,
|
|
senderName,
|
|
messageId: associatedGuid,
|
|
timestamp,
|
|
isGroup,
|
|
chatId,
|
|
chatGuid,
|
|
chatIdentifier,
|
|
chatName,
|
|
fromMe,
|
|
};
|
|
}
|