Include full contents of all nested repositories
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
177
openclaw/extensions/bluebubbles/src/history.ts
Normal file
177
openclaw/extensions/bluebubbles/src/history.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
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 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user