File size: 2,830 Bytes
fc93158
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
import { createHash } from "node:crypto";
import { formatIMessageChatTarget } from "../targets.js";

type SelfChatCacheKeyParts = {
  accountId: string;
  sender: string;
  isGroup: boolean;
  chatId?: number;
};

export type SelfChatLookup = SelfChatCacheKeyParts & {
  text?: string;
  createdAt?: number;
};

export type SelfChatCache = {
  remember: (lookup: SelfChatLookup) => void;
  has: (lookup: SelfChatLookup) => boolean;
};

const SELF_CHAT_TTL_MS = 10_000;
const MAX_SELF_CHAT_CACHE_ENTRIES = 512;
const CLEANUP_MIN_INTERVAL_MS = 1_000;

function normalizeText(text: string | undefined): string | null {
  if (!text) {
    return null;
  }
  const normalized = text.replace(/\r\n?/g, "\n").trim();
  return normalized ? normalized : null;
}

function isUsableTimestamp(createdAt: number | undefined): createdAt is number {
  return typeof createdAt === "number" && Number.isFinite(createdAt);
}

function digestText(text: string): string {
  return createHash("sha256").update(text).digest("hex");
}

function buildScope(parts: SelfChatCacheKeyParts): string {
  if (!parts.isGroup) {
    return `${parts.accountId}:imessage:${parts.sender}`;
  }
  const chatTarget = formatIMessageChatTarget(parts.chatId) || "chat_id:unknown";
  return `${parts.accountId}:${chatTarget}:imessage:${parts.sender}`;
}

class DefaultSelfChatCache implements SelfChatCache {
  private cache = new Map<string, number>();
  private lastCleanupAt = 0;

  private buildKey(lookup: SelfChatLookup): string | null {
    const text = normalizeText(lookup.text);
    if (!text || !isUsableTimestamp(lookup.createdAt)) {
      return null;
    }
    return `${buildScope(lookup)}:${lookup.createdAt}:${digestText(text)}`;
  }

  remember(lookup: SelfChatLookup): void {
    const key = this.buildKey(lookup);
    if (!key) {
      return;
    }
    this.cache.set(key, Date.now());
    this.maybeCleanup();
  }

  has(lookup: SelfChatLookup): boolean {
    this.maybeCleanup();
    const key = this.buildKey(lookup);
    if (!key) {
      return false;
    }
    const timestamp = this.cache.get(key);
    return typeof timestamp === "number" && Date.now() - timestamp <= SELF_CHAT_TTL_MS;
  }

  private maybeCleanup(): void {
    const now = Date.now();
    if (now - this.lastCleanupAt < CLEANUP_MIN_INTERVAL_MS) {
      return;
    }
    this.lastCleanupAt = now;
    for (const [key, timestamp] of this.cache.entries()) {
      if (now - timestamp > SELF_CHAT_TTL_MS) {
        this.cache.delete(key);
      }
    }
    while (this.cache.size > MAX_SELF_CHAT_CACHE_ENTRIES) {
      const oldestKey = this.cache.keys().next().value;
      if (typeof oldestKey !== "string") {
        break;
      }
      this.cache.delete(oldestKey);
    }
  }
}

export function createSelfChatCache(): SelfChatCache {
  return new DefaultSelfChatCache();
}