fix(imessage): detect self-chat echoes to prevent infinite loops (#8680)
This commit is contained in:
@@ -7,6 +7,10 @@ import { resolveMarkdownTableMode } from "../../config/markdown-tables.js";
|
||||
import { convertMarkdownTables } from "../../markdown/tables.js";
|
||||
import { sendMessageIMessage } from "../send.js";
|
||||
|
||||
type SentMessageCache = {
|
||||
remember: (scope: string, text: string) => void;
|
||||
};
|
||||
|
||||
export async function deliverReplies(params: {
|
||||
replies: ReplyPayload[];
|
||||
target: string;
|
||||
@@ -15,8 +19,11 @@ export async function deliverReplies(params: {
|
||||
runtime: RuntimeEnv;
|
||||
maxBytes: number;
|
||||
textLimit: number;
|
||||
sentMessageCache?: SentMessageCache;
|
||||
}) {
|
||||
const { replies, target, client, runtime, maxBytes, textLimit, accountId } = params;
|
||||
const { replies, target, client, runtime, maxBytes, textLimit, accountId, sentMessageCache } =
|
||||
params;
|
||||
const scope = `${accountId ?? ""}:${target}`;
|
||||
const cfg = loadConfig();
|
||||
const tableMode = resolveMarkdownTableMode({
|
||||
cfg,
|
||||
@@ -32,12 +39,14 @@ export async function deliverReplies(params: {
|
||||
continue;
|
||||
}
|
||||
if (mediaList.length === 0) {
|
||||
sentMessageCache?.remember(scope, text);
|
||||
for (const chunk of chunkTextWithMode(text, textLimit, chunkMode)) {
|
||||
await sendMessageIMessage(target, chunk, {
|
||||
maxBytes,
|
||||
client,
|
||||
accountId,
|
||||
});
|
||||
sentMessageCache?.remember(scope, chunk);
|
||||
}
|
||||
} else {
|
||||
let first = true;
|
||||
@@ -50,6 +59,9 @@ export async function deliverReplies(params: {
|
||||
client,
|
||||
accountId,
|
||||
});
|
||||
if (caption) {
|
||||
sentMessageCache?.remember(scope, caption);
|
||||
}
|
||||
}
|
||||
}
|
||||
runtime.log?.(`imessage: delivered reply to ${target}`);
|
||||
|
||||
@@ -111,6 +111,51 @@ function describeReplyContext(message: IMessagePayload): IMessageReplyContext |
|
||||
return { body, id, sender };
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache for recently sent messages, used for echo detection.
|
||||
* Keys are scoped by conversation (accountId:target) so the same text in different chats is not conflated.
|
||||
* Entries expire after 5 seconds; we do not forget on match so multiple echo deliveries are all filtered.
|
||||
*/
|
||||
class SentMessageCache {
|
||||
private cache = new Map<string, number>();
|
||||
private readonly ttlMs = 5000; // 5 seconds
|
||||
|
||||
remember(scope: string, text: string): void {
|
||||
if (!text?.trim()) {
|
||||
return;
|
||||
}
|
||||
const key = `${scope}:${text.trim()}`;
|
||||
this.cache.set(key, Date.now());
|
||||
this.cleanup();
|
||||
}
|
||||
|
||||
has(scope: string, text: string): boolean {
|
||||
if (!text?.trim()) {
|
||||
return false;
|
||||
}
|
||||
const key = `${scope}:${text.trim()}`;
|
||||
const timestamp = this.cache.get(key);
|
||||
if (!timestamp) {
|
||||
return false;
|
||||
}
|
||||
const age = Date.now() - timestamp;
|
||||
if (age > this.ttlMs) {
|
||||
this.cache.delete(key);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private cleanup(): void {
|
||||
const now = Date.now();
|
||||
for (const [text, timestamp] of this.cache.entries()) {
|
||||
if (now - timestamp > this.ttlMs) {
|
||||
this.cache.delete(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): Promise<void> {
|
||||
const runtime = resolveRuntime(opts);
|
||||
const cfg = opts.config ?? loadConfig();
|
||||
@@ -126,6 +171,7 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
|
||||
DEFAULT_GROUP_HISTORY_LIMIT,
|
||||
);
|
||||
const groupHistories = new Map<string, HistoryEntry[]>();
|
||||
const sentMessageCache = new SentMessageCache();
|
||||
const textLimit = resolveTextChunkLimit(cfg, "imessage", accountInfo.accountId);
|
||||
const allowFrom = normalizeAllowList(opts.allowFrom ?? imessageCfg.allowFrom);
|
||||
const groupAllowFrom = normalizeAllowList(
|
||||
@@ -347,6 +393,17 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
|
||||
});
|
||||
const mentionRegexes = buildMentionRegexes(cfg, route.agentId);
|
||||
const messageText = (message.text ?? "").trim();
|
||||
|
||||
// Echo detection: check if the received message matches a recently sent message (within 5 seconds).
|
||||
// Scope by conversation so same text in different chats is not conflated.
|
||||
const echoScope = `${accountInfo.accountId}:${isGroup ? formatIMessageChatTarget(chatId) : `imessage:${sender}`}`;
|
||||
if (messageText && sentMessageCache.has(echoScope, messageText)) {
|
||||
logVerbose(
|
||||
`imessage: skipping echo message (matches recently sent text within 5s): "${truncateUtf16Safe(messageText, 50)}"`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const attachments = includeAttachments ? (message.attachments ?? []) : [];
|
||||
// Filter to valid attachments with paths
|
||||
const validAttachments = attachments.filter((entry) => entry?.original_path && !entry?.missing);
|
||||
@@ -568,6 +625,7 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
|
||||
runtime,
|
||||
maxBytes: mediaMaxBytes,
|
||||
textLimit,
|
||||
sentMessageCache,
|
||||
});
|
||||
},
|
||||
onError: (err, info) => {
|
||||
|
||||
Reference in New Issue
Block a user