fix(imessage): preserve provider reply parent GUIDs (#129917)

This commit is contained in:
Peter Steinberger
2026-08-25 23:18:55 -07:00
committed by GitHub
parent 7b3fb42617
commit e3b68dd166
7 changed files with 131 additions and 50 deletions
@@ -198,27 +198,36 @@ describe("imessage monitor gating + envelope builders", () => {
expect(ctxPayload.MessageSidFull).toBe("full-message-guid");
});
it("includes reply-to context fields + suffix", async () => {
const cfg = baseCfg();
const message: IMessagePayload = {
id: 5,
chat_id: 55,
sender: "+15550001111",
is_from_me: false,
text: "replying now",
is_group: false,
reply_to_id: 9001,
reply_to_text: "original message",
reply_to_sender: "+15559998888",
};
const ctxPayload = await buildDispatchContextPayload({ cfg, message });
it.each([
{ parent: { reply_to_guid: "reply-parent" }, expected: "reply-parent" },
{ parent: { thread_originator_guid: "thread-parent" }, expected: "thread-parent" },
{
parent: { thread_originator_guid: "thread-parent", reply_to_guid: "reply-parent" },
expected: "thread-parent",
},
])(
"includes the authoritative reply parent, context fields, and suffix",
async ({ parent, expected }) => {
const message: IMessagePayload = {
id: 5,
chat_id: 55,
sender: "+15550001111",
is_from_me: false,
text: "replying now",
is_group: false,
...parent,
reply_to_text: "original message",
reply_to_sender: "+15559998888",
};
const ctxPayload = await buildDispatchContextPayload({ cfg: baseCfg(), message });
expect(ctxPayload.ReplyToId).toBe("9001");
expect(ctxPayload.ReplyToBody).toBe("original message");
expect(ctxPayload.ReplyToSender).toBe("+15559998888");
expect(ctxPayload.Body ?? "").toContain("[Replying to +15559998888 id:9001]");
expect(ctxPayload.Body ?? "").toContain("original message");
});
expect(ctxPayload.ReplyToId).toBe(expected);
expect(ctxPayload.ReplyToBody).toBe("original message");
expect(ctxPayload.ReplyToSender).toBe("+15559998888");
expect(ctxPayload.Body ?? "").toContain(`[Replying to +15559998888 id:${expected}]`);
expect(ctxPayload.Body ?? "").toContain("original message");
},
);
it("drops group reply context from non-allowlisted senders in allowlist mode", async () => {
const cfg = baseCfg();
@@ -234,7 +243,7 @@ describe("imessage monitor gating + envelope builders", () => {
is_from_me: false,
text: "@openclaw replying now",
is_group: true,
reply_to_id: 9001,
reply_to_guid: "parent-9001",
reply_to_text: "blocked quote",
reply_to_sender: "+15559998888",
};
@@ -266,7 +275,7 @@ describe("imessage monitor gating + envelope builders", () => {
is_from_me: false,
text: "@openclaw replying now",
is_group: true,
reply_to_id: 9001,
reply_to_guid: "parent-9001",
reply_to_text: "quoted context",
reply_to_sender: "+15559998888",
};
@@ -278,10 +287,10 @@ describe("imessage monitor gating + envelope builders", () => {
groupPolicy: "allowlist",
});
expect(ctxPayload.ReplyToId).toBe("9001");
expect(ctxPayload.ReplyToId).toBe("parent-9001");
expect(ctxPayload.ReplyToBody).toBe("quoted context");
expect(ctxPayload.ReplyToSender).toBe("+15559998888");
expect(ctxPayload.Body ?? "").toContain("[Replying to +15559998888 id:9001]");
expect(ctxPayload.Body ?? "").toContain("[Replying to +15559998888 id:parent-9001]");
});
it("keeps group reply context when the group allowlist matches an access group", async () => {
@@ -304,7 +313,7 @@ describe("imessage monitor gating + envelope builders", () => {
is_from_me: false,
text: "@openclaw replying now",
is_group: true,
reply_to_id: 9002,
reply_to_guid: "parent-9002",
reply_to_text: "own quoted context",
reply_to_sender: "+15559998888",
};
@@ -316,10 +325,10 @@ describe("imessage monitor gating + envelope builders", () => {
groupPolicy: "allowlist",
});
expect(ctxPayload.ReplyToId).toBe("9002");
expect(ctxPayload.ReplyToId).toBe("parent-9002");
expect(ctxPayload.ReplyToBody).toBe("own quoted context");
expect(ctxPayload.ReplyToSender).toBe("+15559998888");
expect(ctxPayload.Body ?? "").toContain("[Replying to +15559998888 id:9002]");
expect(ctxPayload.Body ?? "").toContain("[Replying to +15559998888 id:parent-9002]");
});
it("keeps group reply context in allowlist_quote mode", async () => {
@@ -336,7 +345,7 @@ describe("imessage monitor gating + envelope builders", () => {
is_from_me: false,
text: "@openclaw replying now",
is_group: true,
reply_to_id: 9001,
reply_to_guid: "parent-9001",
reply_to_text: "quoted context",
reply_to_sender: "+15559998888",
};
@@ -348,10 +357,10 @@ describe("imessage monitor gating + envelope builders", () => {
groupPolicy: "allowlist",
});
expect(ctxPayload.ReplyToId).toBe("9001");
expect(ctxPayload.ReplyToId).toBe("parent-9001");
expect(ctxPayload.ReplyToBody).toBe("quoted context");
expect(ctxPayload.ReplyToSender).toBe("+15559998888");
expect(ctxPayload.Body ?? "").toContain("[Replying to +15559998888 id:9001]");
expect(ctxPayload.Body ?? "").toContain("[Replying to +15559998888 id:parent-9001]");
});
it("treats configured chat_id as a group session even when is_group is false", async () => {
@@ -129,17 +129,63 @@ describe("combineIMessagePayloads", () => {
const reply = makePayload({
text: "follow-up",
guid: "row-2",
reply_to_id: "parent-msg",
reply_to_guid: "parent-msg",
reply_to_text: "earlier",
reply_to_sender: "+15555550199",
});
const merged = combineIMessagePayloads([noReply, reply]);
expect(merged.reply_to_id).toBe("parent-msg");
expect(merged.reply_to_guid).toBe("parent-msg");
expect(merged.reply_to_text).toBe("earlier");
expect(merged.reply_to_sender).toBe("+15555550199");
});
it.each([
{ reply_to_guid: "reply-parent" },
{ thread_originator_guid: "thread-parent" },
{ reply_to_guid: "reply-parent", thread_originator_guid: "thread-parent" },
])("preserves the complete real provider reply tuple from a later row", (parent) => {
const first = makePayload({ text: "hello", guid: "row-1" });
const reply = makePayload({
text: "follow-up",
guid: "row-2",
...parent,
reply_to_text: "the original question",
reply_to_sender: "+15555550199",
});
const merged = combineIMessagePayloads([first, reply]);
expect(merged).toMatchObject({
...parent,
reply_to_text: "the original question",
reply_to_sender: "+15555550199",
});
});
it("keeps the parent GUID and quote metadata from the same reply row", () => {
const first = makePayload({
text: "first",
guid: "row-1",
reply_to_text: "unrelated stale quote",
reply_to_sender: "+15555550001",
});
const reply = makePayload({
text: "second",
guid: "row-2",
thread_originator_guid: "thread-parent",
reply_to_guid: "reply-parent",
reply_to_text: "actual parent question",
reply_to_sender: "+15555550199",
});
expect(combineIMessagePayloads([first, reply])).toMatchObject({
thread_originator_guid: "thread-parent",
reply_to_guid: "reply-parent",
reply_to_text: "actual parent question",
reply_to_sender: "+15555550199",
});
});
it("does not set coalescedMessageGuids when no entry carries a GUID", () => {
const a = makePayload({ text: "a", guid: null });
const b = makePayload({ text: "b", guid: null });
+9 -5
View File
@@ -120,17 +120,21 @@ export function combineIMessagePayloads(payloads: IMessagePayload[]): CoalescedI
coalescedMessageGuids.push(guid);
}
// Reply context: prefer any entry that carries one.
const entryWithReply = payloads.find((p) => p.reply_to_id != null);
// Keep both parent GUIDs and their quote fields attached to the same source.
const reply =
payloads.find(
(payload) => payload.thread_originator_guid != null || payload.reply_to_guid != null,
) ?? first;
return {
...first,
text: combinedText,
attachments: allAttachments.length > 0 ? allAttachments : null,
created_at: latestCreatedAt,
reply_to_id: entryWithReply?.reply_to_id ?? first.reply_to_id ?? null,
reply_to_text: entryWithReply?.reply_to_text ?? first.reply_to_text ?? null,
reply_to_sender: entryWithReply?.reply_to_sender ?? first.reply_to_sender ?? null,
thread_originator_guid: reply.thread_originator_guid ?? null,
reply_to_guid: reply.reply_to_guid ?? null,
reply_to_text: reply.reply_to_text ?? null,
reply_to_sender: reply.reply_to_sender ?? null,
coalescedMessageGuids: coalescedMessageGuids.length > 0 ? coalescedMessageGuids : undefined,
coalescedCatchupCursor:
Number.isFinite(maxRowid) && Number.isFinite(maxDateMs)
@@ -183,7 +183,9 @@ function describeReplyContext(message: IMessagePayload): IMessageReplyContext |
if (!body) {
return null;
}
const id = normalizeReplyField(message.reply_to_id);
const id =
normalizeReplyField(message.thread_originator_guid) ??
normalizeReplyField(message.reply_to_guid);
const sender = normalizeReplyField(message.reply_to_sender);
return { body, id, sender };
}
@@ -15,7 +15,7 @@ describe("parseIMessageNotification", () => {
destination_caller_id: null,
is_from_me: false,
text: wrappedText,
reply_to_id: null,
reply_to_guid: null,
reply_to_text: wrappedReply,
reply_to_sender: null,
created_at: null,
@@ -68,6 +68,34 @@ describe("parseIMessageNotification", () => {
expect(parsed?.reacted_to_guid).toBe("target-guid");
});
it("preserves the provider's thread-originator and direct-reply GUIDs", () => {
const parsed = parseIMessageNotification({
message: {
guid: "message-guid",
thread_originator_guid: "thread-parent",
reply_to_guid: "reply-parent",
reply_to_text: "parent question",
reply_to_sender: "+10000000000",
},
});
expect(parsed).toMatchObject({
thread_originator_guid: "thread-parent",
reply_to_guid: "reply-parent",
reply_to_text: "parent question",
reply_to_sender: "+10000000000",
});
});
it.each([42, true, {}, ["thread-parent"]])(
"rejects malformed provider thread-originator GUIDs",
(threadOriginatorGuid) => {
expect(
parseIMessageNotification({ message: { thread_originator_guid: threadOriginatorGuid } }),
).toBeNull();
},
);
it("accepts iMessage attachment transfer_name and uti metadata", () => {
const parsed = parseIMessageNotification({
message: {
@@ -7,12 +7,6 @@ function isOptionalString(value: unknown): value is string | null | undefined {
return value === undefined || value === null || typeof value === "string";
}
function isOptionalStringOrNumber(value: unknown): value is string | number | null | undefined {
return (
value === undefined || value === null || typeof value === "string" || typeof value === "number"
);
}
function isOptionalNumber(value: unknown): value is number | null | undefined {
return value === undefined || value === null || typeof value === "number";
}
@@ -68,7 +62,7 @@ export function parseIMessageNotification(raw: unknown): IMessagePayload | null
!isOptionalString(message.destination_caller_id) ||
!isOptionalBoolean(message.is_from_me) ||
!isOptionalString(message.text) ||
!isOptionalStringOrNumber(message.reply_to_id) ||
!isOptionalString(message.thread_originator_guid) ||
!isOptionalString(message.reply_to_guid) ||
!isOptionalString(message.reply_to_text) ||
!isOptionalString(message.reply_to_sender) ||
+2 -4
View File
@@ -46,10 +46,8 @@ export type IMessagePayload = {
destination_caller_id?: string | null;
is_from_me?: boolean | null;
text?: string | null;
reply_to_id?: number | string | null;
// imsg emits the replied-to message's GUID here (its inbound events carry
// `reply_to_guid`, not a numeric `reply_to_id`); the poll-comment fold matches
// a caption's `reply_to_guid` against the poll balloon's guid.
// Thread origin owns quote identity; direct reply GUID also links poll captions.
thread_originator_guid?: string | null;
reply_to_guid?: string | null;
reply_to_text?: string | null;
reply_to_sender?: string | null;