mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-23 08:18:10 +00:00
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:
145
extensions/bluebubbles/src/history.ts
Normal file
145
extensions/bluebubbles/src/history.ts
Normal 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,
|
||||
}));
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user