mirror of https://github.com/openclaw/openclaw.git
128 lines
3.4 KiB
TypeScript
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;
|
|
}
|