mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
fix(imessage): dedupe reflected self-chat duplicates (#38440)
* iMessage: drop reflected self-chat duplicates * Changelog: add iMessage self-chat echo dedupe entry * iMessage: keep self-chat dedupe scoped to final group identity * iMessage: harden self-chat cache * iMessage: sanitize self-chat duplicate logs * iMessage: scope group self-chat dedupe by sender * iMessage: move self-chat cache identity into cache * iMessage: hash full self-chat text * Update CHANGELOG.md
This commit is contained in:
@@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai
|
||||
### Fixes
|
||||
|
||||
- macOS/Reminders: add the missing `NSRemindersUsageDescription` to the bundled app so `apple-reminders` can trigger the system permission prompt from OpenClaw.app. (#8559) Thanks @dinakars777.
|
||||
- iMessage/self-chat echo dedupe: drop reflected duplicate copies only when a matching `is_from_me` event was just seen for the same chat, text, and `created_at`, preventing self-chat loops without broad text-only suppression. Related to #32166. (#38440) Thanks @vincentkoc.
|
||||
|
||||
## 2026.3.11
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { sanitizeTerminalText } from "../../terminal/safe-text.js";
|
||||
import {
|
||||
describeIMessageEchoDropLog,
|
||||
resolveIMessageInboundDecision,
|
||||
} from "./inbound-processing.js";
|
||||
import { createSelfChatCache } from "./self-chat-cache.js";
|
||||
|
||||
describe("resolveIMessageInboundDecision echo detection", () => {
|
||||
const cfg = {} as OpenClawConfig;
|
||||
@@ -46,6 +48,324 @@ describe("resolveIMessageInboundDecision echo detection", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("drops reflected self-chat duplicates after seeing the from-me copy", () => {
|
||||
const selfChatCache = createSelfChatCache();
|
||||
const createdAt = "2026-03-02T20:58:10.649Z";
|
||||
|
||||
expect(
|
||||
resolveIMessageInboundDecision({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
message: {
|
||||
id: 9641,
|
||||
sender: "+15555550123",
|
||||
text: "Do you want to report this issue?",
|
||||
created_at: createdAt,
|
||||
is_from_me: true,
|
||||
is_group: false,
|
||||
},
|
||||
opts: undefined,
|
||||
messageText: "Do you want to report this issue?",
|
||||
bodyText: "Do you want to report this issue?",
|
||||
allowFrom: [],
|
||||
groupAllowFrom: [],
|
||||
groupPolicy: "open",
|
||||
dmPolicy: "open",
|
||||
storeAllowFrom: [],
|
||||
historyLimit: 0,
|
||||
groupHistories: new Map(),
|
||||
echoCache: undefined,
|
||||
selfChatCache,
|
||||
logVerbose: undefined,
|
||||
}),
|
||||
).toEqual({ kind: "drop", reason: "from me" });
|
||||
|
||||
expect(
|
||||
resolveIMessageInboundDecision({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
message: {
|
||||
id: 9642,
|
||||
sender: "+15555550123",
|
||||
text: "Do you want to report this issue?",
|
||||
created_at: createdAt,
|
||||
is_from_me: false,
|
||||
is_group: false,
|
||||
},
|
||||
opts: undefined,
|
||||
messageText: "Do you want to report this issue?",
|
||||
bodyText: "Do you want to report this issue?",
|
||||
allowFrom: [],
|
||||
groupAllowFrom: [],
|
||||
groupPolicy: "open",
|
||||
dmPolicy: "open",
|
||||
storeAllowFrom: [],
|
||||
historyLimit: 0,
|
||||
groupHistories: new Map(),
|
||||
echoCache: undefined,
|
||||
selfChatCache,
|
||||
logVerbose: undefined,
|
||||
}),
|
||||
).toEqual({ kind: "drop", reason: "self-chat echo" });
|
||||
});
|
||||
|
||||
it("does not drop same-text messages when created_at differs", () => {
|
||||
const selfChatCache = createSelfChatCache();
|
||||
|
||||
resolveIMessageInboundDecision({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
message: {
|
||||
id: 9641,
|
||||
sender: "+15555550123",
|
||||
text: "ok",
|
||||
created_at: "2026-03-02T20:58:10.649Z",
|
||||
is_from_me: true,
|
||||
is_group: false,
|
||||
},
|
||||
opts: undefined,
|
||||
messageText: "ok",
|
||||
bodyText: "ok",
|
||||
allowFrom: [],
|
||||
groupAllowFrom: [],
|
||||
groupPolicy: "open",
|
||||
dmPolicy: "open",
|
||||
storeAllowFrom: [],
|
||||
historyLimit: 0,
|
||||
groupHistories: new Map(),
|
||||
echoCache: undefined,
|
||||
selfChatCache,
|
||||
logVerbose: undefined,
|
||||
});
|
||||
|
||||
const decision = resolveIMessageInboundDecision({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
message: {
|
||||
id: 9642,
|
||||
sender: "+15555550123",
|
||||
text: "ok",
|
||||
created_at: "2026-03-02T20:58:11.649Z",
|
||||
is_from_me: false,
|
||||
is_group: false,
|
||||
},
|
||||
opts: undefined,
|
||||
messageText: "ok",
|
||||
bodyText: "ok",
|
||||
allowFrom: [],
|
||||
groupAllowFrom: [],
|
||||
groupPolicy: "open",
|
||||
dmPolicy: "open",
|
||||
storeAllowFrom: [],
|
||||
historyLimit: 0,
|
||||
groupHistories: new Map(),
|
||||
echoCache: undefined,
|
||||
selfChatCache,
|
||||
logVerbose: undefined,
|
||||
});
|
||||
|
||||
expect(decision.kind).toBe("dispatch");
|
||||
});
|
||||
|
||||
it("keeps self-chat cache scoped to configured group threads", () => {
|
||||
const selfChatCache = createSelfChatCache();
|
||||
const groupedCfg = {
|
||||
channels: {
|
||||
imessage: {
|
||||
groups: {
|
||||
"123": {},
|
||||
"456": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const createdAt = "2026-03-02T20:58:10.649Z";
|
||||
|
||||
expect(
|
||||
resolveIMessageInboundDecision({
|
||||
cfg: groupedCfg,
|
||||
accountId: "default",
|
||||
message: {
|
||||
id: 9701,
|
||||
chat_id: 123,
|
||||
sender: "+15555550123",
|
||||
text: "same text",
|
||||
created_at: createdAt,
|
||||
is_from_me: true,
|
||||
is_group: false,
|
||||
},
|
||||
opts: undefined,
|
||||
messageText: "same text",
|
||||
bodyText: "same text",
|
||||
allowFrom: [],
|
||||
groupAllowFrom: [],
|
||||
groupPolicy: "open",
|
||||
dmPolicy: "open",
|
||||
storeAllowFrom: [],
|
||||
historyLimit: 0,
|
||||
groupHistories: new Map(),
|
||||
echoCache: undefined,
|
||||
selfChatCache,
|
||||
logVerbose: undefined,
|
||||
}),
|
||||
).toEqual({ kind: "drop", reason: "from me" });
|
||||
|
||||
const decision = resolveIMessageInboundDecision({
|
||||
cfg: groupedCfg,
|
||||
accountId: "default",
|
||||
message: {
|
||||
id: 9702,
|
||||
chat_id: 456,
|
||||
sender: "+15555550123",
|
||||
text: "same text",
|
||||
created_at: createdAt,
|
||||
is_from_me: false,
|
||||
is_group: false,
|
||||
},
|
||||
opts: undefined,
|
||||
messageText: "same text",
|
||||
bodyText: "same text",
|
||||
allowFrom: [],
|
||||
groupAllowFrom: [],
|
||||
groupPolicy: "open",
|
||||
dmPolicy: "open",
|
||||
storeAllowFrom: [],
|
||||
historyLimit: 0,
|
||||
groupHistories: new Map(),
|
||||
echoCache: undefined,
|
||||
selfChatCache,
|
||||
logVerbose: undefined,
|
||||
});
|
||||
|
||||
expect(decision.kind).toBe("dispatch");
|
||||
});
|
||||
|
||||
it("does not drop other participants in the same group thread", () => {
|
||||
const selfChatCache = createSelfChatCache();
|
||||
const createdAt = "2026-03-02T20:58:10.649Z";
|
||||
|
||||
expect(
|
||||
resolveIMessageInboundDecision({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
message: {
|
||||
id: 9751,
|
||||
chat_id: 123,
|
||||
sender: "+15555550123",
|
||||
text: "same text",
|
||||
created_at: createdAt,
|
||||
is_from_me: true,
|
||||
is_group: true,
|
||||
},
|
||||
opts: undefined,
|
||||
messageText: "same text",
|
||||
bodyText: "same text",
|
||||
allowFrom: [],
|
||||
groupAllowFrom: [],
|
||||
groupPolicy: "open",
|
||||
dmPolicy: "open",
|
||||
storeAllowFrom: [],
|
||||
historyLimit: 0,
|
||||
groupHistories: new Map(),
|
||||
echoCache: undefined,
|
||||
selfChatCache,
|
||||
logVerbose: undefined,
|
||||
}),
|
||||
).toEqual({ kind: "drop", reason: "from me" });
|
||||
|
||||
const decision = resolveIMessageInboundDecision({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
message: {
|
||||
id: 9752,
|
||||
chat_id: 123,
|
||||
sender: "+15555550999",
|
||||
text: "same text",
|
||||
created_at: createdAt,
|
||||
is_from_me: false,
|
||||
is_group: true,
|
||||
},
|
||||
opts: undefined,
|
||||
messageText: "same text",
|
||||
bodyText: "same text",
|
||||
allowFrom: [],
|
||||
groupAllowFrom: [],
|
||||
groupPolicy: "open",
|
||||
dmPolicy: "open",
|
||||
storeAllowFrom: [],
|
||||
historyLimit: 0,
|
||||
groupHistories: new Map(),
|
||||
echoCache: undefined,
|
||||
selfChatCache,
|
||||
logVerbose: undefined,
|
||||
});
|
||||
|
||||
expect(decision.kind).toBe("dispatch");
|
||||
});
|
||||
|
||||
it("sanitizes reflected duplicate previews before logging", () => {
|
||||
const selfChatCache = createSelfChatCache();
|
||||
const logVerbose = vi.fn();
|
||||
const createdAt = "2026-03-02T20:58:10.649Z";
|
||||
const bodyText = "line-1\nline-2\t\u001b[31mred";
|
||||
|
||||
resolveIMessageInboundDecision({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
message: {
|
||||
id: 9801,
|
||||
sender: "+15555550123",
|
||||
text: bodyText,
|
||||
created_at: createdAt,
|
||||
is_from_me: true,
|
||||
is_group: false,
|
||||
},
|
||||
opts: undefined,
|
||||
messageText: bodyText,
|
||||
bodyText,
|
||||
allowFrom: [],
|
||||
groupAllowFrom: [],
|
||||
groupPolicy: "open",
|
||||
dmPolicy: "open",
|
||||
storeAllowFrom: [],
|
||||
historyLimit: 0,
|
||||
groupHistories: new Map(),
|
||||
echoCache: undefined,
|
||||
selfChatCache,
|
||||
logVerbose,
|
||||
});
|
||||
|
||||
resolveIMessageInboundDecision({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
message: {
|
||||
id: 9802,
|
||||
sender: "+15555550123",
|
||||
text: bodyText,
|
||||
created_at: createdAt,
|
||||
is_from_me: false,
|
||||
is_group: false,
|
||||
},
|
||||
opts: undefined,
|
||||
messageText: bodyText,
|
||||
bodyText,
|
||||
allowFrom: [],
|
||||
groupAllowFrom: [],
|
||||
groupPolicy: "open",
|
||||
dmPolicy: "open",
|
||||
storeAllowFrom: [],
|
||||
historyLimit: 0,
|
||||
groupHistories: new Map(),
|
||||
echoCache: undefined,
|
||||
selfChatCache,
|
||||
logVerbose,
|
||||
});
|
||||
|
||||
expect(logVerbose).toHaveBeenCalledWith(
|
||||
`imessage: dropping self-chat reflected duplicate: "${sanitizeTerminalText(bodyText)}"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("describeIMessageEchoDropLog", () => {
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
DM_GROUP_ACCESS_REASON,
|
||||
resolveDmGroupAccessWithLists,
|
||||
} from "../../security/dm-policy-shared.js";
|
||||
import { sanitizeTerminalText } from "../../terminal/safe-text.js";
|
||||
import { truncateUtf16Safe } from "../../utils.js";
|
||||
import {
|
||||
formatIMessageChatTarget,
|
||||
@@ -31,6 +32,7 @@ import {
|
||||
normalizeIMessageHandle,
|
||||
} from "../targets.js";
|
||||
import { detectReflectedContent } from "./reflection-guard.js";
|
||||
import type { SelfChatCache } from "./self-chat-cache.js";
|
||||
import type { MonitorIMessageOpts, IMessagePayload } from "./types.js";
|
||||
|
||||
type IMessageReplyContext = {
|
||||
@@ -101,6 +103,7 @@ export function resolveIMessageInboundDecision(params: {
|
||||
historyLimit: number;
|
||||
groupHistories: Map<string, HistoryEntry[]>;
|
||||
echoCache?: { has: (scope: string, lookup: { text?: string; messageId?: string }) => boolean };
|
||||
selfChatCache?: SelfChatCache;
|
||||
logVerbose?: (msg: string) => void;
|
||||
}): IMessageInboundDecision {
|
||||
const senderRaw = params.message.sender ?? "";
|
||||
@@ -109,13 +112,10 @@ export function resolveIMessageInboundDecision(params: {
|
||||
return { kind: "drop", reason: "missing sender" };
|
||||
}
|
||||
const senderNormalized = normalizeIMessageHandle(sender);
|
||||
if (params.message.is_from_me) {
|
||||
return { kind: "drop", reason: "from me" };
|
||||
}
|
||||
|
||||
const chatId = params.message.chat_id ?? undefined;
|
||||
const chatGuid = params.message.chat_guid ?? undefined;
|
||||
const chatIdentifier = params.message.chat_identifier ?? undefined;
|
||||
const createdAt = params.message.created_at ? Date.parse(params.message.created_at) : undefined;
|
||||
|
||||
const groupIdCandidate = chatId !== undefined ? String(chatId) : undefined;
|
||||
const groupListPolicy = groupIdCandidate
|
||||
@@ -138,6 +138,18 @@ export function resolveIMessageInboundDecision(params: {
|
||||
groupIdCandidate && groupListPolicy.allowlistEnabled && groupListPolicy.groupConfig,
|
||||
);
|
||||
const isGroup = Boolean(params.message.is_group) || treatAsGroupByConfig;
|
||||
const selfChatLookup = {
|
||||
accountId: params.accountId,
|
||||
isGroup,
|
||||
chatId,
|
||||
sender,
|
||||
text: params.bodyText,
|
||||
createdAt,
|
||||
};
|
||||
if (params.message.is_from_me) {
|
||||
params.selfChatCache?.remember(selfChatLookup);
|
||||
return { kind: "drop", reason: "from me" };
|
||||
}
|
||||
if (isGroup && !chatId) {
|
||||
return { kind: "drop", reason: "group without chat_id" };
|
||||
}
|
||||
@@ -215,6 +227,17 @@ export function resolveIMessageInboundDecision(params: {
|
||||
return { kind: "drop", reason: "empty body" };
|
||||
}
|
||||
|
||||
if (
|
||||
params.selfChatCache?.has({
|
||||
...selfChatLookup,
|
||||
text: bodyText,
|
||||
})
|
||||
) {
|
||||
const preview = sanitizeTerminalText(truncateUtf16Safe(bodyText, 50));
|
||||
params.logVerbose?.(`imessage: dropping self-chat reflected duplicate: "${preview}"`);
|
||||
return { kind: "drop", reason: "self-chat echo" };
|
||||
}
|
||||
|
||||
// Echo detection: check if the received message matches a recently sent message.
|
||||
// Scope by conversation so same text in different chats is not conflated.
|
||||
const inboundMessageId = params.message.id != null ? String(params.message.id) : undefined;
|
||||
@@ -250,7 +273,6 @@ export function resolveIMessageInboundDecision(params: {
|
||||
}
|
||||
|
||||
const replyContext = describeReplyContext(params.message);
|
||||
const createdAt = params.message.created_at ? Date.parse(params.message.created_at) : undefined;
|
||||
const historyKey = isGroup
|
||||
? String(chatId ?? chatGuid ?? chatIdentifier ?? "unknown")
|
||||
: undefined;
|
||||
|
||||
@@ -53,6 +53,7 @@ import {
|
||||
import { createLoopRateLimiter } from "./loop-rate-limiter.js";
|
||||
import { parseIMessageNotification } from "./parse-notification.js";
|
||||
import { normalizeAllowList, resolveRuntime } from "./runtime.js";
|
||||
import { createSelfChatCache } from "./self-chat-cache.js";
|
||||
import type { IMessagePayload, MonitorIMessageOpts } from "./types.js";
|
||||
|
||||
/**
|
||||
@@ -99,6 +100,7 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
|
||||
);
|
||||
const groupHistories = new Map<string, HistoryEntry[]>();
|
||||
const sentMessageCache = createSentMessageCache();
|
||||
const selfChatCache = createSelfChatCache();
|
||||
const loopRateLimiter = createLoopRateLimiter();
|
||||
const textLimit = resolveTextChunkLimit(cfg, "imessage", accountInfo.accountId);
|
||||
const allowFrom = normalizeAllowList(opts.allowFrom ?? imessageCfg.allowFrom);
|
||||
@@ -252,6 +254,7 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
|
||||
historyLimit,
|
||||
groupHistories,
|
||||
echoCache: sentMessageCache,
|
||||
selfChatCache,
|
||||
logVerbose,
|
||||
});
|
||||
|
||||
@@ -267,6 +270,7 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
|
||||
// are normal and should not escalate.
|
||||
const isLoopDrop =
|
||||
decision.reason === "echo" ||
|
||||
decision.reason === "self-chat echo" ||
|
||||
decision.reason === "reflected assistant content" ||
|
||||
decision.reason === "from me";
|
||||
if (isLoopDrop) {
|
||||
|
||||
76
src/imessage/monitor/self-chat-cache.test.ts
Normal file
76
src/imessage/monitor/self-chat-cache.test.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createSelfChatCache } from "./self-chat-cache.js";
|
||||
|
||||
describe("createSelfChatCache", () => {
|
||||
const directLookup = {
|
||||
accountId: "default",
|
||||
sender: "+15555550123",
|
||||
isGroup: false,
|
||||
} as const;
|
||||
|
||||
it("matches repeated lookups for the same scope, timestamp, and text", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-03-07T00:00:00Z"));
|
||||
|
||||
const cache = createSelfChatCache();
|
||||
cache.remember({
|
||||
...directLookup,
|
||||
text: " hello\r\nworld ",
|
||||
createdAt: 123,
|
||||
});
|
||||
|
||||
expect(
|
||||
cache.has({
|
||||
...directLookup,
|
||||
text: "hello\nworld",
|
||||
createdAt: 123,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("expires entries after the ttl window", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-03-07T00:00:00Z"));
|
||||
|
||||
const cache = createSelfChatCache();
|
||||
cache.remember({ ...directLookup, text: "hello", createdAt: 123 });
|
||||
|
||||
vi.advanceTimersByTime(11_001);
|
||||
|
||||
expect(cache.has({ ...directLookup, text: "hello", createdAt: 123 })).toBe(false);
|
||||
});
|
||||
|
||||
it("evicts older entries when the cache exceeds its cap", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-03-07T00:00:00Z"));
|
||||
|
||||
const cache = createSelfChatCache();
|
||||
for (let i = 0; i < 513; i += 1) {
|
||||
cache.remember({
|
||||
...directLookup,
|
||||
text: `message-${i}`,
|
||||
createdAt: i,
|
||||
});
|
||||
vi.advanceTimersByTime(1_001);
|
||||
}
|
||||
|
||||
expect(cache.has({ ...directLookup, text: "message-0", createdAt: 0 })).toBe(false);
|
||||
expect(cache.has({ ...directLookup, text: "message-512", createdAt: 512 })).toBe(true);
|
||||
});
|
||||
|
||||
it("does not collide long texts that differ only in the middle", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-03-07T00:00:00Z"));
|
||||
|
||||
const cache = createSelfChatCache();
|
||||
const prefix = "a".repeat(256);
|
||||
const suffix = "b".repeat(256);
|
||||
const longTextA = `${prefix}${"x".repeat(300)}${suffix}`;
|
||||
const longTextB = `${prefix}${"y".repeat(300)}${suffix}`;
|
||||
|
||||
cache.remember({ ...directLookup, text: longTextA, createdAt: 123 });
|
||||
|
||||
expect(cache.has({ ...directLookup, text: longTextA, createdAt: 123 })).toBe(true);
|
||||
expect(cache.has({ ...directLookup, text: longTextB, createdAt: 123 })).toBe(false);
|
||||
});
|
||||
});
|
||||
103
src/imessage/monitor/self-chat-cache.ts
Normal file
103
src/imessage/monitor/self-chat-cache.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
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();
|
||||
}
|
||||
Reference in New Issue
Block a user