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
This commit is contained in:
Ryan Mac Mini
2026-02-18 14:14:35 -05:00
parent dff61a10e1
commit 0a05cc7f81
2 changed files with 190 additions and 0 deletions

View File

@@ -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<BlueBubblesHistoryEntry[]> {
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,
}));
}

View File

@@ -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<string>();
@@ -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,