mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-10 08:52:54 +00:00
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:
@@ -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
|
||||
|
||||
102
extensions/imessage/src/monitor/dm-history.test.ts
Normal file
102
extensions/imessage/src/monitor/dm-history.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
166
extensions/imessage/src/monitor/dm-history.ts
Normal file
166
extensions/imessage/src/monitor/dm-history.ts
Normal 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"),
|
||||
};
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user