From 3452382cc092e52fe491eb08f280052eb5b7d9fa Mon Sep 17 00:00:00 2001 From: Omar Shahine Date: Mon, 25 May 2026 22:38:32 -0700 Subject: [PATCH] fix(imessage): seed direct DM history (#86706) * fix(imessage): seed direct DM history * docs(imessage): clarify DM history override seeding --------- Co-authored-by: Omar Shahine <10343873+omarshahine@users.noreply.github.com> --- docs/channels/imessage.md | 7 + .../imessage/src/monitor/dm-history.test.ts | 102 +++++++++++ extensions/imessage/src/monitor/dm-history.ts | 166 ++++++++++++++++++ .../src/monitor/inbound-processing.test.ts | 56 ++++++ .../src/monitor/inbound-processing.ts | 19 +- .../imessage/src/monitor/monitor-provider.ts | 21 +++ 6 files changed, 365 insertions(+), 6 deletions(-) create mode 100644 extensions/imessage/src/monitor/dm-history.test.ts create mode 100644 extensions/imessage/src/monitor/dm-history.ts diff --git a/docs/channels/imessage.md b/docs/channels/imessage.md index c21de4d19d9..c16ab0b6c33 100644 --- a/docs/channels/imessage.md +++ b/docs/channels/imessage.md @@ -442,6 +442,13 @@ See [ACP Agents](/tools/acp-agents) for shared ACP binding behavior. Each account can override fields such as `cliPath`, `dbPath`, `allowFrom`, `groupPolicy`, `mediaMaxMb`, history settings, and attachment root allowlists. + + + Set `channels.imessage.dmHistoryLimit` to seed new direct-message sessions with recent decoded `imsg` history for that conversation. Use `channels.imessage.dms[""].historyLimit` for per-sender overrides, including `0` to disable history for a sender. + + iMessage DM history is fetched on demand from `imsg`. Leaving `dmHistoryLimit` unset disables global DM history seeding, but a positive per-sender `channels.imessage.dms[""].historyLimit` still enables seeding for that sender. + + ## Media, chunking, and delivery targets diff --git a/extensions/imessage/src/monitor/dm-history.test.ts b/extensions/imessage/src/monitor/dm-history.test.ts new file mode 100644 index 00000000000..56f0a2e7ad3 --- /dev/null +++ b/extensions/imessage/src/monitor/dm-history.test.ts @@ -0,0 +1,102 @@ +import { resolveEnvelopeFormatOptions } from "openclaw/plugin-sdk/channel-inbound"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { describe, expect, it, vi } from "vitest"; +import type { IMessageRpcClient } from "../client.js"; +import { resolveIMessageDmHistoryContext, resolveIMessageDmHistoryLimit } from "./dm-history.js"; + +describe("resolveIMessageDmHistoryLimit", () => { + it("uses per-DM history overrides before the provider default", () => { + expect( + resolveIMessageDmHistoryLimit({ + config: { + dmHistoryLimit: 5, + dms: { + "+15555550123": { historyLimit: 2 }, + }, + }, + sender: "+1 (555) 555-0123", + senderNormalized: "+15555550123", + }), + ).toBe(2); + }); + + it("defaults to disabled when no iMessage DM history limit is configured", () => { + expect(resolveIMessageDmHistoryLimit({ config: {}, sender: "+15555550123" })).toBe(0); + }); +}); + +describe("resolveIMessageDmHistoryContext", () => { + it("fetches decoded imsg history rows and excludes the current message", async () => { + const request = vi.fn(async () => ({ + messages: [ + { + id: 8, + guid: "previous-in", + chat_id: 44, + sender: "+15555550123", + is_from_me: false, + text: "earlier inbound", + created_at: "2026-05-25T12:00:00.000Z", + is_group: false, + }, + { + id: 9, + guid: "previous-out", + chat_id: 44, + sender: null, + is_from_me: true, + text: "earlier outbound", + created_at: "2026-05-25T12:01:00.000Z", + is_group: false, + }, + { + id: 10, + guid: "current", + chat_id: 44, + sender: "+15555550123", + is_from_me: false, + text: "current", + created_at: "2026-05-25T12:02:00.000Z", + is_group: false, + }, + ], + })); + + const context = await resolveIMessageDmHistoryContext({ + client: { request } as unknown as IMessageRpcClient, + message: { + id: 10, + guid: "current", + chat_id: 44, + sender: "+15555550123", + text: "current", + is_from_me: false, + is_group: false, + }, + senderNormalized: "+15555550123", + limit: 2, + envelopeOptions: resolveEnvelopeFormatOptions({} as OpenClawConfig), + }); + + expect(request).toHaveBeenCalledWith( + "messages.history", + { chat_id: 44, limit: 3, attachments: false }, + { timeoutMs: 10_000 }, + ); + expect(context.inboundHistory).toEqual([ + { + sender: "+15555550123", + body: "earlier inbound", + timestamp: Date.parse("2026-05-25T12:00:00.000Z"), + }, + { + sender: "Me", + body: "earlier outbound", + timestamp: Date.parse("2026-05-25T12:01:00.000Z"), + }, + ]); + expect(context.body).toContain("earlier inbound"); + expect(context.body).toContain("earlier outbound"); + expect(context.body).not.toContain("current"); + }); +}); diff --git a/extensions/imessage/src/monitor/dm-history.ts b/extensions/imessage/src/monitor/dm-history.ts new file mode 100644 index 00000000000..bcd044589c9 --- /dev/null +++ b/extensions/imessage/src/monitor/dm-history.ts @@ -0,0 +1,166 @@ +import { + formatInboundEnvelope, + type resolveEnvelopeFormatOptions, +} from "openclaw/plugin-sdk/channel-inbound"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import type { IMessageRpcClient } from "../client.js"; +import { normalizeIMessageHandle } from "../targets.js"; +import { parseIMessageNotification } from "./parse-notification.js"; +import type { IMessagePayload } from "./types.js"; + +const DM_HISTORY_RPC_TIMEOUT_MS = 10_000; + +type IMessageHistoryResult = { + messages?: unknown[]; +}; + +type IMessageDmHistoryConfig = { + dmHistoryLimit?: number; + dms?: Record; +}; + +export type IMessageDmHistoryEntry = { + sender: string; + body: string; + timestamp?: number; +}; + +export type IMessageDmHistoryContext = { + body?: string; + inboundHistory?: IMessageDmHistoryEntry[]; +}; + +export function resolveIMessageDmHistoryLimit(params: { + config: IMessageDmHistoryConfig; + sender?: string; + senderNormalized?: string; +}): number { + const senderCandidates = [ + normalizeOptionalString(params.senderNormalized), + normalizeOptionalString(params.sender), + params.sender ? normalizeIMessageHandle(params.sender) : undefined, + ].filter((candidate): candidate is string => Boolean(candidate)); + + for (const candidate of senderCandidates) { + const override = params.config.dms?.[candidate]?.historyLimit; + if (override !== undefined) { + return Math.max(0, override); + } + } + + return Math.max(0, params.config.dmHistoryLimit ?? 0); +} + +function historyRowSortValue(message: IMessagePayload): number { + if (typeof message.id === "number" && Number.isFinite(message.id)) { + return message.id; + } + const createdAtMs = + typeof message.created_at === "string" ? Date.parse(message.created_at) : Number.NaN; + return Number.isFinite(createdAtMs) ? createdAtMs : 0; +} + +function isBeforeCurrentMessage(params: { + message: IMessagePayload; + currentMessage: IMessagePayload; +}): boolean { + const { message, currentMessage } = params; + if ( + typeof message.id === "number" && + typeof currentMessage.id === "number" && + Number.isFinite(message.id) && + Number.isFinite(currentMessage.id) + ) { + return message.id < currentMessage.id; + } + const guid = normalizeOptionalString(message.guid); + const currentGuid = normalizeOptionalString(currentMessage.guid); + if (guid && currentGuid) { + return guid !== currentGuid; + } + return true; +} + +function historyEntryFromMessage(message: IMessagePayload, fallbackSender: string) { + const body = normalizeOptionalString(message.text); + if (!body) { + return null; + } + const timestamp = + typeof message.created_at === "string" ? Date.parse(message.created_at) : Number.NaN; + return { + sender: + message.is_from_me === true + ? "Me" + : normalizeIMessageHandle(normalizeOptionalString(message.sender) ?? fallbackSender) || + fallbackSender, + body, + ...(Number.isFinite(timestamp) ? { timestamp } : {}), + }; +} + +export async function resolveIMessageDmHistoryContext(params: { + client: IMessageRpcClient; + message: IMessagePayload; + senderNormalized: string; + limit: number; + envelopeOptions: ReturnType; + logVerbose?: (msg: string) => void; +}): Promise { + const maxMessages = Math.max(0, Math.floor(params.limit)); + const chatId = + typeof params.message.chat_id === "number" && Number.isFinite(params.message.chat_id) + ? params.message.chat_id + : undefined; + if (maxMessages <= 0 || chatId === undefined) { + return {}; + } + + let result: IMessageHistoryResult | undefined; + try { + result = await params.client.request( + "messages.history", + { + chat_id: chatId, + limit: maxMessages + 1, + attachments: false, + }, + { timeoutMs: DM_HISTORY_RPC_TIMEOUT_MS }, + ); + } catch (err) { + params.logVerbose?.(`imessage: DM history fetch failed for chat_id=${chatId}: ${String(err)}`); + return {}; + } + + const rows = Array.isArray(result?.messages) ? result.messages : []; + const history = rows + .map((row) => parseIMessageNotification({ message: row })) + .filter((message): message is IMessagePayload => Boolean(message)) + .filter((message) => message.is_group !== true) + .filter((message) => isBeforeCurrentMessage({ message, currentMessage: params.message })) + .toSorted((a, b) => historyRowSortValue(a) - historyRowSortValue(b)) + .map((message) => historyEntryFromMessage(message, params.senderNormalized)) + .filter((entry): entry is IMessageDmHistoryEntry => Boolean(entry)) + .slice(-maxMessages); + + if (history.length === 0) { + return {}; + } + + return { + inboundHistory: history, + body: history + .map((entry) => + formatInboundEnvelope({ + channel: "iMessage", + from: entry.sender, + timestamp: entry.timestamp, + body: entry.body, + chatType: "direct", + senderLabel: entry.sender, + envelope: params.envelopeOptions, + }), + ) + .join("\n\n"), + }; +} diff --git a/extensions/imessage/src/monitor/inbound-processing.test.ts b/extensions/imessage/src/monitor/inbound-processing.test.ts index 46afe5cf407..0345286a447 100644 --- a/extensions/imessage/src/monitor/inbound-processing.test.ts +++ b/extensions/imessage/src/monitor/inbound-processing.test.ts @@ -794,6 +794,62 @@ describe("buildIMessageInboundContext", () => { expect(ctxPayload.MessageSid).toBe("1"); expect(ctxPayload.MessageSidFull).toBe("p:0/GUID-current"); }); + + it("prepends direct-message history when supplied", async () => { + const decision = await resolveIMessageInboundDecision({ + cfg: {} as OpenClawConfig, + accountId: "default", + message: { + id: 12346, + guid: "p:0/GUID-current-history", + sender: "+15555550123", + text: "current", + is_from_me: false, + is_group: false, + }, + opts: undefined, + messageText: "current", + bodyText: "current", + allowFrom: ["*"], + groupAllowFrom: [], + groupPolicy: "open", + dmPolicy: "open", + storeAllowFrom: [], + historyLimit: 0, + groupHistories: new Map(), + echoCache: undefined, + selfChatCache: undefined, + logVerbose: undefined, + }); + expect(decision.kind).toBe("dispatch"); + if (decision.kind !== "dispatch") { + return; + } + + const { ctxPayload, inboundHistory } = buildIMessageInboundContext({ + cfg: {} as OpenClawConfig, + decision, + message: { + id: 12346, + guid: "p:0/GUID-current-history", + sender: "+15555550123", + text: "current", + is_from_me: false, + is_group: false, + }, + historyLimit: 0, + groupHistories: new Map(), + dmHistory: { + body: "[iMessage from +15555550123]\nprevious\n[/iMessage]", + inboundHistory: [{ sender: "+15555550123", body: "previous" }], + }, + }); + + expect(ctxPayload.Body).toContain("previous"); + expect(ctxPayload.Body).toContain("current"); + expect(ctxPayload.InboundHistory).toEqual([{ sender: "+15555550123", body: "previous" }]); + expect(inboundHistory).toEqual([{ sender: "+15555550123", body: "previous" }]); + }); }); describe("resolveIMessageInboundDecision command auth", () => { diff --git a/extensions/imessage/src/monitor/inbound-processing.ts b/extensions/imessage/src/monitor/inbound-processing.ts index e8c6910ecc5..1dea9e5cc33 100644 --- a/extensions/imessage/src/monitor/inbound-processing.ts +++ b/extensions/imessage/src/monitor/inbound-processing.ts @@ -38,6 +38,7 @@ import { normalizeIMessageHandle, parseIMessageAllowTarget, } from "../targets.js"; +import type { IMessageDmHistoryContext } from "./dm-history.js"; import { type IMessageReactionContext, resolveIMessageReactionContext, @@ -808,6 +809,7 @@ export function buildIMessageInboundContext(params: { }; historyLimit: number; groupHistories: Map; + dmHistory?: IMessageDmHistoryContext; }): { ctxPayload: ReturnType; fromLabel: string; @@ -867,6 +869,9 @@ export function buildIMessageInboundContext(params: { }); let combinedBody = body; + if (!decision.isGroup && params.dmHistory?.body) { + combinedBody = `${params.dmHistory.body}\n\n${combinedBody}`; + } if (decision.isGroup && decision.historyKey) { const channelHistory = createChannelHistoryWindow({ historyMap: params.groupHistories }); combinedBody = channelHistory.buildPendingContext({ @@ -888,12 +893,14 @@ export function buildIMessageInboundContext(params: { const imessageTo = (decision.isGroup ? chatTarget : undefined) || `imessage:${decision.sender}`; const inboundHistory = - decision.isGroup && decision.historyKey && params.historyLimit > 0 - ? createChannelHistoryWindow({ historyMap: params.groupHistories }).buildInboundHistory({ - historyKey: decision.historyKey, - limit: params.historyLimit, - }) - : undefined; + !decision.isGroup && params.dmHistory?.inboundHistory + ? params.dmHistory.inboundHistory + : decision.isGroup && decision.historyKey && params.historyLimit > 0 + ? createChannelHistoryWindow({ historyMap: params.groupHistories }).buildInboundHistory({ + historyKey: decision.historyKey, + limit: params.historyLimit, + }) + : undefined; const ctxPayload = finalizeInboundContext({ Body: combinedBody, diff --git a/extensions/imessage/src/monitor/monitor-provider.ts b/extensions/imessage/src/monitor/monitor-provider.ts index 8780b2ffcfc..28f9af3429c 100644 --- a/extensions/imessage/src/monitor/monitor-provider.ts +++ b/extensions/imessage/src/monitor/monitor-provider.ts @@ -4,6 +4,7 @@ import { CHANNEL_APPROVAL_NATIVE_RUNTIME_CONTEXT_CAPABILITY } from "openclaw/plu import { logTypingFailure } from "openclaw/plugin-sdk/channel-feedback"; import { createChannelInboundDebouncer, + resolveEnvelopeFormatOptions, shouldDebounceTextInbound, } from "openclaw/plugin-sdk/channel-inbound"; import { @@ -59,6 +60,7 @@ import { advanceIMessageCatchupCursor, resolveCatchupConfig } from "./catchup.js import { combineIMessagePayloads } from "./coalesce.js"; import { repairIMessageConversationAnchor } from "./conversation-repair.js"; import { createIMessageEchoCachingSend, deliverReplies } from "./deliver.js"; +import { resolveIMessageDmHistoryContext, resolveIMessageDmHistoryLimit } from "./dm-history.js"; import { createSentMessageCache } from "./echo-cache.js"; import { warnGroupAllowlistDropPerChatOnce, @@ -631,6 +633,24 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P storePath, sessionKey: decision.route.sessionKey, }); + const dmHistoryLimit = !decision.isGroup + ? resolveIMessageDmHistoryLimit({ + config: imessageCfg, + sender: decision.sender, + senderNormalized: decision.senderNormalized, + }) + : 0; + const dmHistory = + !decision.isGroup && dmHistoryLimit > 0 && !previousTimestamp + ? await resolveIMessageDmHistoryContext({ + client: getActiveClient(), + message, + senderNormalized: decision.senderNormalized, + limit: dmHistoryLimit, + envelopeOptions: resolveEnvelopeFormatOptions(cfg), + logVerbose, + }) + : undefined; const { ctxPayload, chatTarget } = buildIMessageInboundContext({ cfg, decision, @@ -639,6 +659,7 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P remoteHost, historyLimit, groupHistories, + dmHistory, media: { path: mediaPath, type: mediaType,