mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
fix(imessage): preserve provider reply parent GUIDs (#129917)
This commit is contained in:
committed by
GitHub
parent
7b3fb42617
commit
e3b68dd166
@@ -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 });
|
||||
|
||||
@@ -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) ||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user