Files
openclaw/extensions/bluebubbles/src/history.ts
2026-03-04 02:35:12 -05:00

178 lines
4.9 KiB
TypeScript

import type { OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles";
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 BlueBubblesHistoryFetchResult = {
entries: BlueBubblesHistoryEntry[];
/**
* True when at least one API path returned a recognized response shape.
* False means all attempts failed or returned unusable data.
*/
resolved: boolean;
};
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);
}
const MAX_HISTORY_FETCH_LIMIT = 100;
const HISTORY_SCAN_MULTIPLIER = 8;
const MAX_HISTORY_SCAN_MESSAGES = 500;
const MAX_HISTORY_BODY_CHARS = 2_000;
function clampHistoryLimit(limit: number): number {
if (!Number.isFinite(limit)) {
return 0;
}
const normalized = Math.floor(limit);
if (normalized <= 0) {
return 0;
}
return Math.min(normalized, MAX_HISTORY_FETCH_LIMIT);
}
function truncateHistoryBody(text: string): string {
if (text.length <= MAX_HISTORY_BODY_CHARS) {
return text;
}
return `${text.slice(0, MAX_HISTORY_BODY_CHARS).trimEnd()}...`;
}
/**
* 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<BlueBubblesHistoryFetchResult> {
const effectiveLimit = clampHistoryLimit(limit);
if (!chatIdentifier.trim() || effectiveLimit <= 0) {
return { entries: [], resolved: true };
}
let baseUrl: string;
let password: string;
try {
({ baseUrl, password } = resolveAccount(opts));
} catch {
return { entries: [], resolved: false };
}
// Try different common API patterns for fetching messages
const possiblePaths = [
`/api/v1/chat/${encodeURIComponent(chatIdentifier)}/messages?limit=${effectiveLimit}&sort=DESC`,
`/api/v1/messages?chatGuid=${encodeURIComponent(chatIdentifier)}&limit=${effectiveLimit}`,
`/api/v1/chat/${encodeURIComponent(chatIdentifier)}/message?limit=${effectiveLimit}`,
];
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[] = [];
const maxScannedMessages = Math.min(
Math.max(effectiveLimit * HISTORY_SCAN_MULTIPLIER, effectiveLimit),
MAX_HISTORY_SCAN_MESSAGES,
);
for (let i = 0; i < messages.length && i < maxScannedMessages; i++) {
const item = messages[i];
const msg = item as BlueBubblesMessageData;
// Skip messages without text content
const text = msg.text?.trim();
if (!text) {
continue;
}
const sender = msg.is_from_me
? "me"
: msg.sender?.display_name || msg.sender?.address || msg.handle_id || "Unknown";
const timestamp = msg.date_created || msg.date_delivered;
historyEntries.push({
sender,
body: truncateHistoryBody(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 {
entries: historyEntries.slice(0, effectiveLimit), // Ensure we don't exceed the requested limit
resolved: true,
};
} catch (error) {
// Continue to next path
continue;
}
}
// If none of the API paths worked, return empty history
return { entries: [], resolved: false };
}