From 0a05cc7f81bf2c991b923d68ba4e9c7b93a730a9 Mon Sep 17 00:00:00 2001 From: Ryan Mac Mini Date: Wed, 18 Feb 2026 14:14:35 -0500 Subject: [PATCH] feat: implement DM history backfill for BlueBubbles - Add fetchBlueBubblesHistory function to fetch message history from API - Modify processMessage to fetch history for both groups and DMs - Use dmHistoryLimit for DMs and historyLimit for groups - Add InboundHistory field to finalizeInboundContext call Fixes #20296 --- extensions/bluebubbles/src/history.ts | 145 ++++++++++++++++++ .../bluebubbles/src/monitor-processing.ts | 45 ++++++ 2 files changed, 190 insertions(+) create mode 100644 extensions/bluebubbles/src/history.ts diff --git a/extensions/bluebubbles/src/history.ts b/extensions/bluebubbles/src/history.ts new file mode 100644 index 00000000000..29f6d7db695 --- /dev/null +++ b/extensions/bluebubbles/src/history.ts @@ -0,0 +1,145 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk"; +import { resolveBlueBubblesServerAccount } from "./account-resolve.js"; +import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js"; + +export type BlueBubblesHistoryEntry = { + sender: string; + body: string; + timestamp?: number; + messageId?: string; +}; + +export type BlueBubblesMessageData = { + guid?: string; + text?: string; + handle_id?: string; + is_from_me?: boolean; + date_created?: number; + date_delivered?: number; + associated_message_guid?: string; + sender?: { + address?: string; + display_name?: string; + }; +}; + +export type BlueBubblesChatOpts = { + serverUrl?: string; + password?: string; + accountId?: string; + timeoutMs?: number; + cfg?: OpenClawConfig; +}; + +function resolveAccount(params: BlueBubblesChatOpts) { + return resolveBlueBubblesServerAccount(params); +} + +/** + * Fetch message history from BlueBubbles API for a specific chat. + * This provides the initial backfill for both group chats and DMs. + */ +export async function fetchBlueBubblesHistory( + chatIdentifier: string, + limit: number, + opts: BlueBubblesChatOpts = {}, +): Promise { + if (!chatIdentifier.trim() || limit <= 0) { + return []; + } + + const { baseUrl, password, accountId } = resolveAccount(opts); + if (!baseUrl || !password) { + return []; + } + + // Try different common API patterns for fetching messages + const possiblePaths = [ + `/api/v1/chat/${encodeURIComponent(chatIdentifier)}/messages?limit=${limit}&sort=DESC`, + `/api/v1/messages?chatGuid=${encodeURIComponent(chatIdentifier)}&limit=${limit}`, + `/api/v1/chat/${encodeURIComponent(chatIdentifier)}/message?limit=${limit}`, + ]; + + for (const path of possiblePaths) { + try { + const url = buildBlueBubblesApiUrl({ baseUrl, path, password }); + const res = await blueBubblesFetchWithTimeout(url, { method: "GET" }, opts.timeoutMs ?? 10000); + + if (!res.ok) { + continue; // Try next path + } + + const data = await res.json().catch(() => null); + if (!data) { + continue; + } + + // Handle different response structures + let messages: unknown[] = []; + if (Array.isArray(data)) { + messages = data; + } else if (data.data && Array.isArray(data.data)) { + messages = data.data; + } else if (data.messages && Array.isArray(data.messages)) { + messages = data.messages; + } else { + continue; + } + + const historyEntries: BlueBubblesHistoryEntry[] = []; + + for (const item of messages) { + const msg = item as BlueBubblesMessageData; + + // Skip messages without text content + const text = msg.text?.trim(); + if (!text) { + continue; + } + + // Skip from-me messages to avoid duplication + if (msg.is_from_me) { + continue; + } + + const sender = msg.sender?.display_name || msg.sender?.address || msg.handle_id || "Unknown"; + const timestamp = msg.date_created || msg.date_delivered; + + historyEntries.push({ + sender, + body: text, + timestamp, + messageId: msg.guid, + }); + } + + // Sort by timestamp (oldest first for context) + historyEntries.sort((a, b) => { + const aTime = a.timestamp || 0; + const bTime = b.timestamp || 0; + return aTime - bTime; + }); + + return historyEntries.slice(0, limit); // Ensure we don't exceed the requested limit + } catch (error) { + // Continue to next path + continue; + } + } + + // If none of the API paths worked, return empty history + return []; +} + +/** + * Build inbound history array for finalizeInboundContext from history entries. + */ +export function buildInboundHistoryFromEntries( + entries: BlueBubblesHistoryEntry[], +): Array<{ sender: string; body: string; timestamp?: number }> { + return entries.map((entry) => ({ + sender: entry.sender, + body: entry.body, + timestamp: entry.timestamp, + })); +} \ No newline at end of file diff --git a/extensions/bluebubbles/src/monitor-processing.ts b/extensions/bluebubbles/src/monitor-processing.ts index 0719c548556..5d0bdd29714 100644 --- a/extensions/bluebubbles/src/monitor-processing.ts +++ b/extensions/bluebubbles/src/monitor-processing.ts @@ -37,6 +37,11 @@ import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js"; import { normalizeBlueBubblesReactionInput, sendBlueBubblesReaction } from "./reactions.js"; import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js"; import { formatBlueBubblesChatTarget, isAllowedBlueBubblesSender } from "./targets.js"; +import { + fetchBlueBubblesHistory, + buildInboundHistoryFromEntries, + type BlueBubblesHistoryEntry, +} from "./history.js"; const DEFAULT_TEXT_LIMIT = 4000; const invalidAckReactions = new Set(); @@ -813,9 +818,49 @@ export async function processMessage( .trim(); }; + // Fetch history for backfill (both groups and DMs) + const historyLimit = isGroup + ? (account.config.historyLimit ?? 0) + : (account.config.dmHistoryLimit ?? 0); + + let inboundHistory: Array<{ sender: string; body: string; timestamp?: number }> | undefined; + + if (historyLimit > 0) { + // Determine chat identifier for history fetching + const historyIdentifier = chatGuid || + chatIdentifier || + (chatId ? String(chatId) : null) || + (isGroup ? null : message.senderId); + + if (historyIdentifier) { + try { + const historyEntries = await fetchBlueBubblesHistory(historyIdentifier, historyLimit, { + cfg: config, + accountId: account.accountId, + }); + + if (historyEntries.length > 0) { + inboundHistory = buildInboundHistoryFromEntries(historyEntries); + logVerbose( + core, + runtime, + `fetched ${historyEntries.length} history messages for ${isGroup ? 'group' : 'DM'}: ${historyIdentifier}` + ); + } + } catch (err) { + logVerbose( + core, + runtime, + `history fetch failed for ${historyIdentifier}: ${String(err)}` + ); + } + } + } + const ctxPayload = core.channel.reply.finalizeInboundContext({ Body: body, BodyForAgent: rawBody, + InboundHistory: inboundHistory, RawBody: rawBody, CommandBody: rawBody, BodyForCommands: rawBody,