openclaw/extensions/bluebubbles/src/monitor-self-chat-cache.ts

128 lines
3.4 KiB
TypeScript

import { createHash } from "node:crypto";
import { extractHandleFromChatGuid, normalizeBlueBubblesHandle } from "./targets.js";
type SelfChatCacheKeyParts = {
accountId: string;
chatGuid?: string;
chatIdentifier?: string;
chatId?: number;
senderId: string;
};
type SelfChatLookup = SelfChatCacheKeyParts & {
body?: string;
timestamp?: number;
};
const SELF_CHAT_TTL_MS = 10_000;
const MAX_SELF_CHAT_CACHE_ENTRIES = 512;
const CLEANUP_MIN_INTERVAL_MS = 1_000;
const MAX_SELF_CHAT_BODY_CHARS = 32_768;
const cache = new Map<string, number>();
let lastCleanupAt = 0;
function normalizeBody(body: string | undefined): string | null {
if (!body) {
return null;
}
const bounded =
body.length > MAX_SELF_CHAT_BODY_CHARS ? body.slice(0, MAX_SELF_CHAT_BODY_CHARS) : body;
const normalized = bounded.replace(/\r\n?/g, "\n").trim();
return normalized ? normalized : null;
}
function isUsableTimestamp(timestamp: number | undefined): timestamp is number {
return typeof timestamp === "number" && Number.isFinite(timestamp);
}
function digestText(text: string): string {
return createHash("sha256").update(text).digest("base64url");
}
function trimOrUndefined(value?: string | null): string | undefined {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}
function resolveCanonicalChatTarget(parts: SelfChatCacheKeyParts): string | null {
const handleFromGuid = parts.chatGuid ? extractHandleFromChatGuid(parts.chatGuid) : null;
if (handleFromGuid) {
return handleFromGuid;
}
const normalizedIdentifier = normalizeBlueBubblesHandle(parts.chatIdentifier ?? "");
if (normalizedIdentifier) {
return normalizedIdentifier;
}
return (
trimOrUndefined(parts.chatGuid) ??
trimOrUndefined(parts.chatIdentifier) ??
(typeof parts.chatId === "number" ? String(parts.chatId) : null)
);
}
function buildScope(parts: SelfChatCacheKeyParts): string {
const target = resolveCanonicalChatTarget(parts) ?? parts.senderId;
return `${parts.accountId}:${target}`;
}
function cleanupExpired(now = Date.now()): void {
if (
lastCleanupAt !== 0 &&
now >= lastCleanupAt &&
now - lastCleanupAt < CLEANUP_MIN_INTERVAL_MS
) {
return;
}
lastCleanupAt = now;
for (const [key, seenAt] of cache.entries()) {
if (now - seenAt > SELF_CHAT_TTL_MS) {
cache.delete(key);
}
}
}
function enforceSizeCap(): void {
while (cache.size > MAX_SELF_CHAT_CACHE_ENTRIES) {
const oldestKey = cache.keys().next().value;
if (typeof oldestKey !== "string") {
break;
}
cache.delete(oldestKey);
}
}
function buildKey(lookup: SelfChatLookup): string | null {
const body = normalizeBody(lookup.body);
if (!body || !isUsableTimestamp(lookup.timestamp)) {
return null;
}
return `${buildScope(lookup)}:${lookup.timestamp}:${digestText(body)}`;
}
export function rememberBlueBubblesSelfChatCopy(lookup: SelfChatLookup): void {
cleanupExpired();
const key = buildKey(lookup);
if (!key) {
return;
}
cache.set(key, Date.now());
enforceSizeCap();
}
export function hasBlueBubblesSelfChatCopy(lookup: SelfChatLookup): boolean {
cleanupExpired();
const key = buildKey(lookup);
if (!key) {
return false;
}
const seenAt = cache.get(key);
return typeof seenAt === "number" && Date.now() - seenAt <= SELF_CHAT_TTL_MS;
}
export function resetBlueBubblesSelfChatCache(): void {
cache.clear();
lastCleanupAt = 0;
}