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:
Vincent Koc
2026-03-12 02:27:35 -04:00
committed by GitHub
parent 8baf55d8ed
commit 12dc299cde
6 changed files with 531 additions and 5 deletions

View File

@@ -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

View File

@@ -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", () => {

View File

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

View File

@@ -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) {

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

View 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();
}