| 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; |
| } |
|
|