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>
This commit is contained in:
Omar Shahine
2026-05-25 22:38:32 -07:00
committed by GitHub
parent 11b1b7c888
commit 3452382cc0
6 changed files with 365 additions and 6 deletions

View File

@@ -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.
</Accordion>
<Accordion title="Direct-message history">
Set `channels.imessage.dmHistoryLimit` to seed new direct-message sessions with recent decoded `imsg` history for that conversation. Use `channels.imessage.dms["<sender>"].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["<sender>"].historyLimit` still enables seeding for that sender.
</Accordion>
</AccordionGroup>
## Media, chunking, and delivery targets

View File

@@ -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");
});
});

View File

@@ -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<string, { historyLimit?: number }>;
};
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<typeof resolveEnvelopeFormatOptions>;
logVerbose?: (msg: string) => void;
}): Promise<IMessageDmHistoryContext> {
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<IMessageHistoryResult>(
"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"),
};
}

View File

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

View File

@@ -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<string, HistoryEntry[]>;
dmHistory?: IMessageDmHistoryContext;
}): {
ctxPayload: ReturnType<typeof finalizeInboundContext>;
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,

View File

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