mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(imessage): settle native inbound reply delivery (#117282)
Co-authored-by: Peter Steinberger <steipete@macos.shared>
This commit is contained in:
committed by
GitHub
parent
0cec56df26
commit
338a7ccb2f
@@ -1,8 +1,22 @@
|
||||
// Imessage tests cover monitor.plugin payload plugin behavior.
|
||||
import path from "node:path";
|
||||
import * as channelInbound from "openclaw/plugin-sdk/channel-inbound";
|
||||
import {
|
||||
addTestHook,
|
||||
createEmptyPluginRegistry,
|
||||
createTestInboundDebounceFlush,
|
||||
initializeGlobalHookRunner,
|
||||
resetGlobalHookRunner,
|
||||
} from "openclaw/plugin-sdk/channel-test-helpers";
|
||||
import { recordInboundSession } from "openclaw/plugin-sdk/conversation-runtime";
|
||||
import type { dispatchReplyWithBufferedBlockDispatcher } from "openclaw/plugin-sdk/reply-runtime";
|
||||
import { resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime";
|
||||
import type { waitForTransportReady } from "openclaw/plugin-sdk/transport-ready-runtime";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { createIMessageRpcClient } from "./client.js";
|
||||
import { monitorIMessageProvider } from "./monitor.js";
|
||||
import { setCachedIMessagePrivateApiStatus } from "./private-api-status.js";
|
||||
import { getIMessageRuntime } from "./runtime.js";
|
||||
import { installIMessageStateRuntimeForTest } from "./test-support/runtime.js";
|
||||
|
||||
const waitForTransportReadyMock = vi.hoisted(() =>
|
||||
@@ -10,6 +24,7 @@ const waitForTransportReadyMock = vi.hoisted(() =>
|
||||
);
|
||||
const createIMessageRpcClientMock = vi.hoisted(() => vi.fn<typeof createIMessageRpcClient>());
|
||||
const shouldDebounceTextInboundMock = vi.hoisted(() => vi.fn(() => false));
|
||||
const directDeliveryProof = vi.hoisted(() => ({ flush: false }));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/transport-ready-runtime", () => ({
|
||||
waitForTransportReady: waitForTransportReadyMock,
|
||||
@@ -20,10 +35,19 @@ vi.mock("openclaw/plugin-sdk/channel-inbound", async (importOriginal) => {
|
||||
return {
|
||||
...actual,
|
||||
createChannelInboundDebouncer: vi.fn(
|
||||
(opts: { shouldDebounce: (entry: unknown) => boolean }) => ({
|
||||
(opts: {
|
||||
shouldDebounce: (entry: unknown) => boolean;
|
||||
onFlush: (
|
||||
entries: unknown[],
|
||||
createFlush: typeof createTestInboundDebounceFlush,
|
||||
) => { completion: Promise<void> };
|
||||
}) => ({
|
||||
debouncer: {
|
||||
enqueue: async (entry: unknown) => {
|
||||
opts.shouldDebounce(entry);
|
||||
if (directDeliveryProof.flush) {
|
||||
await opts.onFlush([entry], createTestInboundDebounceFlush).completion;
|
||||
}
|
||||
},
|
||||
},
|
||||
}),
|
||||
@@ -46,6 +70,12 @@ describe("iMessage plugin payload attachments", () => {
|
||||
waitForTransportReadyMock.mockReset().mockResolvedValue(undefined);
|
||||
createIMessageRpcClientMock.mockReset();
|
||||
shouldDebounceTextInboundMock.mockReset().mockReturnValue(false);
|
||||
directDeliveryProof.flush = false;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetGlobalHookRunner();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("does not count Apple rich-link plugin payloads as user media", async () => {
|
||||
@@ -103,4 +133,161 @@ describe("iMessage plugin payload attachments", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ kind: "tool", text: "provider-visible tool result", visible: true },
|
||||
{ kind: "block", text: "provider-visible streamed block", visible: true },
|
||||
{ kind: "tool", text: "<thinking>private reasoning</thinking>", visible: false },
|
||||
] as const)(
|
||||
"settles direct $kind delivery through actual provider, hooks, and SQLite ($visible)",
|
||||
async ({ kind, text, visible }) => {
|
||||
directDeliveryProof.flush = true;
|
||||
const messageSent = vi.fn();
|
||||
const registry = createEmptyPluginRegistry();
|
||||
addTestHook({
|
||||
registry,
|
||||
pluginId: "imessage-monitor-proof",
|
||||
hookName: "message_sent",
|
||||
handler: messageSent,
|
||||
});
|
||||
initializeGlobalHookRunner(registry);
|
||||
setCachedIMessagePrivateApiStatus("imsg", {
|
||||
available: true,
|
||||
v2Ready: true,
|
||||
selectors: {},
|
||||
rpcMethods: ["watch.subscribe", "send"],
|
||||
});
|
||||
|
||||
const dispatch = vi.fn<typeof dispatchReplyWithBufferedBlockDispatcher>(async (params) => {
|
||||
const settled = await params.dispatcherOptions.deliver({ text }, { kind });
|
||||
expect(settled).toMatchObject(
|
||||
visible
|
||||
? {
|
||||
visibleReplySent: true,
|
||||
messageIds: [`native-${kind}-guid`],
|
||||
receipt: { platformMessageIds: [`native-${kind}-guid`] },
|
||||
content: text,
|
||||
}
|
||||
: { visibleReplySent: false, suppression: { reason: "no_visible_result" } },
|
||||
);
|
||||
return {
|
||||
queuedFinal: false,
|
||||
counts: { tool: kind === "tool" ? 1 : 0, block: kind === "block" ? 1 : 0, final: 0 },
|
||||
};
|
||||
});
|
||||
const runActual = channelInbound.runChannelInboundEvent;
|
||||
vi.spyOn(channelInbound, "runChannelInboundEvent").mockImplementation(async (params) =>
|
||||
runActual({
|
||||
...params,
|
||||
adapter: {
|
||||
...params.adapter,
|
||||
resolveTurn: async (input, eventClass, preflight) => {
|
||||
const turn = await params.adapter.resolveTurn(input, eventClass, preflight);
|
||||
if (!("route" in turn) || !("delivery" in turn)) {
|
||||
throw new Error("expected assembled iMessage delivery turn");
|
||||
}
|
||||
const { route, ...resolvedTurn } = turn;
|
||||
return {
|
||||
...resolvedTurn,
|
||||
agentId: route.agentId,
|
||||
routeSessionKey: route.sessionKey,
|
||||
storePath: resolveStorePath(turn.cfg.session?.store, { agentId: route.agentId }),
|
||||
recordInboundSession,
|
||||
dispatchReplyWithBufferedBlockDispatcher: dispatch,
|
||||
};
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const nativeClient = {
|
||||
request: vi.fn(async (method: string) => {
|
||||
if (method !== "send") {
|
||||
throw new Error(`unexpected native iMessage method ${method}`);
|
||||
}
|
||||
return { guid: `native-${kind}-guid`, status: "sent" };
|
||||
}),
|
||||
stop: vi.fn(async () => {}),
|
||||
};
|
||||
let onNotification: ((message: { method: string; params: unknown }) => void) | undefined;
|
||||
const watchClient = {
|
||||
request: vi.fn(async () => ({ subscription: 1 })),
|
||||
waitForClose: vi.fn(async () => {
|
||||
onNotification?.({
|
||||
method: "message",
|
||||
params: {
|
||||
message: {
|
||||
id: 91,
|
||||
guid: `monitor-${kind}-${visible}-guid`,
|
||||
chat_id: 123,
|
||||
sender: "+15550001111",
|
||||
is_from_me: false,
|
||||
text: "exercise actual direct delivery",
|
||||
is_group: false,
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}),
|
||||
stop: vi.fn(async () => {}),
|
||||
};
|
||||
createIMessageRpcClientMock.mockImplementation(async (params) => {
|
||||
if (params?.onNotification) {
|
||||
onNotification = params.onNotification;
|
||||
return watchClient as never;
|
||||
}
|
||||
return nativeClient as never;
|
||||
});
|
||||
|
||||
await monitorIMessageProvider({
|
||||
config: {
|
||||
channels: {
|
||||
imessage: {
|
||||
dmPolicy: "allowlist",
|
||||
allowFrom: ["+15550001111"],
|
||||
sendReadReceipts: false,
|
||||
},
|
||||
},
|
||||
messages: { inbound: { debounceMs: 0 } },
|
||||
session: { mainKey: "main" },
|
||||
} as never,
|
||||
runtime: { error: vi.fn(), exit: vi.fn(), log: vi.fn() },
|
||||
});
|
||||
|
||||
if (!visible) {
|
||||
expect(nativeClient.request).not.toHaveBeenCalled();
|
||||
expect(messageSent).not.toHaveBeenCalled();
|
||||
return;
|
||||
}
|
||||
await vi.waitFor(() => {
|
||||
expect(messageSent).toHaveBeenCalledOnce();
|
||||
});
|
||||
expect(messageSent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ content: text, success: true, messageId: `native-${kind}-guid` }),
|
||||
expect.objectContaining({ channelId: "imessage" }),
|
||||
);
|
||||
|
||||
const { DatabaseSync } = await import("node:sqlite");
|
||||
const database = new DatabaseSync(
|
||||
path.join(getIMessageRuntime().state.resolveStateDir(), "state", "openclaw.sqlite"),
|
||||
{ readOnly: true },
|
||||
);
|
||||
try {
|
||||
const persisted = database
|
||||
.prepare(
|
||||
"SELECT value_json FROM plugin_state_entries WHERE plugin_id = ? AND namespace = ?",
|
||||
)
|
||||
.all("imessage", "imessage.sent-echoes");
|
||||
expect(persisted).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ value_json: expect.stringContaining(`native-${kind}-guid`) }),
|
||||
]),
|
||||
);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
// Imessage tests cover deliver plugin behavior.
|
||||
import {
|
||||
createChannelPartialDeliveryError,
|
||||
isChannelPartialDeliveryError,
|
||||
} from "openclaw/plugin-sdk/channel-inbound";
|
||||
import { createMessageReceiptFromOutboundResults } from "openclaw/plugin-sdk/channel-outbound";
|
||||
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
@@ -6,6 +11,10 @@ const sendMessageIMessageMock = vi.hoisted(() =>
|
||||
vi.fn().mockImplementation(async (_to: string, message: string) => ({
|
||||
messageId: "imsg-1",
|
||||
sentText: message,
|
||||
receipt: createMessageReceiptFromOutboundResults({
|
||||
results: [{ channel: "imessage", messageId: "imsg-1" }],
|
||||
kind: "text",
|
||||
}),
|
||||
})),
|
||||
);
|
||||
const chunkTextWithModeMock = vi.hoisted(() => vi.fn((text: string) => [text]));
|
||||
@@ -13,6 +22,13 @@ const resolveChunkModeMock = vi.hoisted(() => vi.fn(() => "length"));
|
||||
const convertMarkdownTablesMock = vi.hoisted(() => vi.fn((text: string) => text));
|
||||
const resolveMarkdownTableModeMock = vi.hoisted(() => vi.fn(() => "code"));
|
||||
|
||||
function createTestIMessageReceipt(messageId: string, kind: "text" | "media" = "text") {
|
||||
return createMessageReceiptFromOutboundResults({
|
||||
results: [{ channel: "imessage", messageId }],
|
||||
kind,
|
||||
});
|
||||
}
|
||||
|
||||
vi.mock("../send.js", () => ({
|
||||
sendMessageIMessage: (to: string, message: string, opts?: unknown) =>
|
||||
sendMessageIMessageMock(to, message, opts),
|
||||
@@ -25,15 +41,15 @@ vi.mock("./deliver.runtime.js", () => ({
|
||||
convertMarkdownTables: (text: string) => convertMarkdownTablesMock(text),
|
||||
}));
|
||||
|
||||
let deliverReplies: typeof import("./deliver.js").deliverReplies;
|
||||
let deliverIMessageReply: typeof import("./deliver.js").deliverIMessageReply;
|
||||
let createIMessageEchoCachingSend: typeof import("./deliver.js").createIMessageEchoCachingSend;
|
||||
|
||||
describe("deliverReplies", () => {
|
||||
describe("deliverIMessageReply", () => {
|
||||
const IMESSAGE_TEST_CFG = { channels: { imessage: { accounts: { default: {} } } } };
|
||||
const runtime = { log: vi.fn(), error: vi.fn() } as unknown as RuntimeEnv;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ createIMessageEchoCachingSend, deliverReplies } = await import("./deliver.js"));
|
||||
({ createIMessageEchoCachingSend, deliverIMessageReply } = await import("./deliver.js"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -50,9 +66,9 @@ describe("deliverReplies", () => {
|
||||
it("sends monitor text chunks without reusing the watch rpc client", async () => {
|
||||
chunkTextWithModeMock.mockImplementation((text: string) => text.split("|"));
|
||||
|
||||
await deliverReplies({
|
||||
await deliverIMessageReply({
|
||||
cfg: IMESSAGE_TEST_CFG,
|
||||
replies: [{ text: "first|second", replyToId: "reply-1" }],
|
||||
payload: { text: "first|second", replyToId: "reply-1" },
|
||||
target: "chat_id:10",
|
||||
accountId: "default",
|
||||
runtime,
|
||||
@@ -86,15 +102,13 @@ describe("deliverReplies", () => {
|
||||
});
|
||||
|
||||
it("propagates payload replyToId through media sends", async () => {
|
||||
await deliverReplies({
|
||||
await deliverIMessageReply({
|
||||
cfg: IMESSAGE_TEST_CFG,
|
||||
replies: [
|
||||
{
|
||||
text: "caption",
|
||||
mediaUrls: ["https://example.com/a.jpg", "https://example.com/b.jpg"],
|
||||
replyToId: "reply-2",
|
||||
},
|
||||
],
|
||||
payload: {
|
||||
text: "caption",
|
||||
mediaUrls: ["https://example.com/a.jpg", "https://example.com/b.jpg"],
|
||||
replyToId: "reply-2",
|
||||
},
|
||||
target: "chat_id:20",
|
||||
accountId: "acct-2",
|
||||
runtime,
|
||||
@@ -130,9 +144,9 @@ describe("deliverReplies", () => {
|
||||
});
|
||||
|
||||
it("forwards voice-note payloads to the canonical iMessage media sender", async () => {
|
||||
await deliverReplies({
|
||||
await deliverIMessageReply({
|
||||
cfg: IMESSAGE_TEST_CFG,
|
||||
replies: [{ mediaUrl: "https://example.com/voice.caf", audioAsVoice: true }],
|
||||
payload: { mediaUrl: "https://example.com/voice.caf", audioAsVoice: true },
|
||||
target: "chat_id:20",
|
||||
accountId: "acct-2",
|
||||
runtime,
|
||||
@@ -160,6 +174,7 @@ describe("deliverReplies", () => {
|
||||
sendMessageIMessageMock.mockResolvedValueOnce({
|
||||
messageId: "imsg-durable-1",
|
||||
sentText: "durable hello",
|
||||
receipt: createTestIMessageReceipt("imsg-durable-1"),
|
||||
});
|
||||
|
||||
await send("chat_id:50", "durable hello", {
|
||||
@@ -192,6 +207,7 @@ describe("deliverReplies", () => {
|
||||
sendMessageIMessageMock.mockResolvedValueOnce({
|
||||
messageId: "imsg-durable-2",
|
||||
sentText: "Visible reply",
|
||||
receipt: createTestIMessageReceipt("imsg-durable-2"),
|
||||
});
|
||||
|
||||
await send("chat_id:60", "<thinking>hidden</thinking>\nVisible reply\nassistant:", {
|
||||
@@ -221,12 +237,20 @@ describe("deliverReplies", () => {
|
||||
const remember = vi.fn();
|
||||
chunkTextWithModeMock.mockImplementation((text: string) => text.split("|"));
|
||||
sendMessageIMessageMock
|
||||
.mockResolvedValueOnce({ messageId: "imsg-1", sentText: "first" })
|
||||
.mockResolvedValueOnce({ messageId: "imsg-2", sentText: "second" });
|
||||
.mockResolvedValueOnce({
|
||||
messageId: "imsg-1",
|
||||
sentText: "first",
|
||||
receipt: createTestIMessageReceipt("imsg-1"),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
messageId: "imsg-2",
|
||||
sentText: "second",
|
||||
receipt: createTestIMessageReceipt("imsg-2"),
|
||||
});
|
||||
|
||||
await deliverReplies({
|
||||
await deliverIMessageReply({
|
||||
cfg: IMESSAGE_TEST_CFG,
|
||||
replies: [{ text: "first|second" }],
|
||||
payload: { text: "first|second" },
|
||||
target: "chat_id:30",
|
||||
accountId: "acct-3",
|
||||
runtime,
|
||||
@@ -251,11 +275,12 @@ describe("deliverReplies", () => {
|
||||
messageId: "imsg-media-1",
|
||||
sentText: "",
|
||||
echoMedia: { contentType: "image/jpeg", kind: "image" },
|
||||
receipt: createTestIMessageReceipt("imsg-media-1", "media"),
|
||||
});
|
||||
|
||||
await deliverReplies({
|
||||
await deliverIMessageReply({
|
||||
cfg: IMESSAGE_TEST_CFG,
|
||||
replies: [{ mediaUrls: ["https://example.com/a.jpg"] }],
|
||||
payload: { mediaUrls: ["https://example.com/a.jpg"] },
|
||||
target: "chat_id:40",
|
||||
accountId: "acct-4",
|
||||
runtime,
|
||||
@@ -269,4 +294,113 @@ describe("deliverReplies", () => {
|
||||
messageId: "imsg-media-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns every accepted native chunk without inventing failed thread metadata", async () => {
|
||||
chunkTextWithModeMock.mockImplementation((text: string) => text.split("|"));
|
||||
sendMessageIMessageMock
|
||||
.mockResolvedValueOnce({
|
||||
messageId: "accepted-first",
|
||||
sentText: "first",
|
||||
receipt: createTestIMessageReceipt("accepted-first"),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
messageId: "accepted-second",
|
||||
sentText: "second",
|
||||
receipt: createTestIMessageReceipt("accepted-second"),
|
||||
});
|
||||
|
||||
const delivered = await deliverIMessageReply({
|
||||
cfg: IMESSAGE_TEST_CFG,
|
||||
payload: { text: "first|second", replyToId: "unsupported-thread" },
|
||||
target: "chat_id:70",
|
||||
accountId: "default",
|
||||
runtime,
|
||||
maxBytes: 4096,
|
||||
textLimit: 4000,
|
||||
});
|
||||
|
||||
expect(delivered).toMatchObject({
|
||||
visibleReplySent: true,
|
||||
messageIds: ["accepted-first", "accepted-second"],
|
||||
content: "first\nsecond",
|
||||
receipt: { platformMessageIds: ["accepted-first", "accepted-second"] },
|
||||
});
|
||||
expect(delivered?.receipt).not.toHaveProperty("replyToId");
|
||||
});
|
||||
|
||||
it("preserves earlier media receipts when a later native caption fails", async () => {
|
||||
const firstReceipt = createMessageReceiptFromOutboundResults({
|
||||
results: [
|
||||
{ channel: "imessage", messageId: "first-attachment" },
|
||||
{ channel: "imessage", messageId: "first-caption" },
|
||||
],
|
||||
kind: "media",
|
||||
});
|
||||
const lastReceipt = createTestIMessageReceipt("second-attachment", "media");
|
||||
sendMessageIMessageMock
|
||||
.mockResolvedValueOnce({
|
||||
messageId: "first-attachment",
|
||||
sentText: "visible caption",
|
||||
receipt: firstReceipt,
|
||||
})
|
||||
.mockRejectedValueOnce(
|
||||
createChannelPartialDeliveryError(new Error("caption rejected"), {
|
||||
messageIds: ["second-attachment"],
|
||||
receipt: lastReceipt,
|
||||
visibleReplySent: true,
|
||||
content: "",
|
||||
}),
|
||||
);
|
||||
|
||||
let observed: unknown;
|
||||
try {
|
||||
await deliverIMessageReply({
|
||||
cfg: IMESSAGE_TEST_CFG,
|
||||
payload: {
|
||||
text: "visible caption",
|
||||
mediaUrls: ["https://example.com/first.jpg", "https://example.com/second.jpg"],
|
||||
replyToId: "unsupported-thread",
|
||||
},
|
||||
target: "chat_id:80",
|
||||
accountId: "default",
|
||||
runtime,
|
||||
maxBytes: 4096,
|
||||
textLimit: 4000,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
observed = error;
|
||||
}
|
||||
|
||||
expect(isChannelPartialDeliveryError(observed)).toBe(true);
|
||||
if (!isChannelPartialDeliveryError(observed)) {
|
||||
throw new Error("expected canonical partial delivery error");
|
||||
}
|
||||
expect(observed.deliveryResult).toMatchObject({
|
||||
visibleReplySent: true,
|
||||
messageIds: ["first-attachment", "first-caption", "second-attachment"],
|
||||
content: "visible caption",
|
||||
receipt: {
|
||||
platformMessageIds: ["first-attachment", "first-caption", "second-attachment"],
|
||||
},
|
||||
});
|
||||
expect(observed.deliveryResult.receipt).not.toHaveProperty("replyToId");
|
||||
});
|
||||
|
||||
it("returns a recorded non-visible outcome when sanitization removes the complete reply", async () => {
|
||||
const delivered = await deliverIMessageReply({
|
||||
cfg: IMESSAGE_TEST_CFG,
|
||||
payload: { text: "<thinking>private reasoning</thinking>" },
|
||||
target: "chat_id:90",
|
||||
accountId: "default",
|
||||
runtime,
|
||||
maxBytes: 4096,
|
||||
textLimit: 4000,
|
||||
});
|
||||
|
||||
expect(sendMessageIMessageMock).not.toHaveBeenCalled();
|
||||
expect(delivered).toEqual({
|
||||
visibleReplySent: false,
|
||||
suppression: { reason: "no_visible_result" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
// Imessage plugin module implements deliver behavior.
|
||||
import {
|
||||
createChannelPartialDeliveryError,
|
||||
isChannelPartialDeliveryError,
|
||||
} from "openclaw/plugin-sdk/channel-inbound";
|
||||
import {
|
||||
createMessageReceiptFromOutboundResults,
|
||||
listMessageReceiptPlatformIds,
|
||||
} from "openclaw/plugin-sdk/channel-outbound";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import {
|
||||
deliverTextOrMediaReply,
|
||||
@@ -16,9 +24,9 @@ import {
|
||||
import type { SentMessageCache } from "./echo-cache.js";
|
||||
import { sanitizeOutboundText } from "./sanitize-outbound.js";
|
||||
|
||||
export async function deliverReplies(params: {
|
||||
export async function deliverIMessageReply(params: {
|
||||
cfg: OpenClawConfig;
|
||||
replies: ReplyPayload[];
|
||||
payload: ReplyPayload;
|
||||
target: string;
|
||||
accountId?: string;
|
||||
runtime: RuntimeEnv;
|
||||
@@ -26,7 +34,7 @@ export async function deliverReplies(params: {
|
||||
textLimit: number;
|
||||
sentMessageCache?: Pick<SentMessageCache, "remember">;
|
||||
}) {
|
||||
const { replies, target, runtime, maxBytes, textLimit, accountId, sentMessageCache } = params;
|
||||
const { payload, target, runtime, maxBytes, textLimit, accountId, sentMessageCache } = params;
|
||||
const scope = `${accountId ?? ""}:${target}`;
|
||||
const { cfg } = params;
|
||||
const tableMode = resolveMarkdownTableMode({
|
||||
@@ -35,50 +43,81 @@ export async function deliverReplies(params: {
|
||||
accountId,
|
||||
});
|
||||
const chunkMode = resolveChunkMode(cfg, "imessage", accountId);
|
||||
for (const payload of replies) {
|
||||
const rawText = sanitizeOutboundText(payload.text ?? "");
|
||||
const reply = resolveSendableOutboundReplyParts(payload, {
|
||||
text: convertMarkdownTables(rawText, tableMode),
|
||||
const rawText = sanitizeOutboundText(payload.text ?? "");
|
||||
const reply = resolveSendableOutboundReplyParts(payload, {
|
||||
text: convertMarkdownTables(rawText, tableMode),
|
||||
});
|
||||
const accepted: Awaited<ReturnType<typeof sendMessageIMessage>>[] = [];
|
||||
const sendAccepted = async (text: string, mediaUrl?: string) => {
|
||||
const sent = await sendMessageIMessage(target, text, {
|
||||
config: cfg,
|
||||
...(mediaUrl ? { mediaUrl, ...(payload.audioAsVoice ? { audioAsVoice: true } : {}) } : {}),
|
||||
maxBytes,
|
||||
accountId,
|
||||
replyToId: payload.replyToId,
|
||||
});
|
||||
const delivered = await deliverTextOrMediaReply({
|
||||
accepted.push(sent);
|
||||
const echoText = sent.echoText ?? (sent.sentText || undefined);
|
||||
sentMessageCache?.remember(scope, {
|
||||
...(echoText ? { text: echoText } : {}),
|
||||
...(sent.echoMedia ? { media: sent.echoMedia } : {}),
|
||||
messageId: sent.messageId,
|
||||
});
|
||||
};
|
||||
let delivered: Awaited<ReturnType<typeof deliverTextOrMediaReply>>;
|
||||
try {
|
||||
delivered = await deliverTextOrMediaReply({
|
||||
payload,
|
||||
text: reply.text,
|
||||
chunkText: (value) => chunkTextWithMode(value, textLimit, chunkMode),
|
||||
sendText: async (chunk) => {
|
||||
const sent = await sendMessageIMessage(target, chunk, {
|
||||
config: params.cfg,
|
||||
maxBytes,
|
||||
accountId,
|
||||
replyToId: payload.replyToId,
|
||||
});
|
||||
const echoText = sent.echoText ?? sent.sentText;
|
||||
sentMessageCache?.remember(scope, {
|
||||
...(echoText ? { text: echoText } : {}),
|
||||
...(sent.echoMedia ? { media: sent.echoMedia } : {}),
|
||||
messageId: sent.messageId,
|
||||
});
|
||||
},
|
||||
sendMedia: async ({ mediaUrl, caption }) => {
|
||||
const sent = await sendMessageIMessage(target, caption ?? "", {
|
||||
config: params.cfg,
|
||||
mediaUrl,
|
||||
...(payload.audioAsVoice ? { audioAsVoice: true } : {}),
|
||||
maxBytes,
|
||||
accountId,
|
||||
replyToId: payload.replyToId,
|
||||
});
|
||||
const echoText = sent.echoText ?? (sent.sentText || undefined);
|
||||
sentMessageCache?.remember(scope, {
|
||||
...(echoText ? { text: echoText } : {}),
|
||||
...(sent.echoMedia ? { media: sent.echoMedia } : {}),
|
||||
messageId: sent.messageId,
|
||||
});
|
||||
},
|
||||
sendText: sendAccepted,
|
||||
sendMedia: ({ mediaUrl, caption }) => sendAccepted(caption ?? "", mediaUrl),
|
||||
});
|
||||
if (delivered !== "empty") {
|
||||
runtime.log?.(`imessage: delivered reply to ${target}`);
|
||||
} catch (error: unknown) {
|
||||
const partial = isChannelPartialDeliveryError(error) ? error.deliveryResult : undefined;
|
||||
if (accepted.length === 0 && partial?.visibleReplySent !== true) {
|
||||
throw error;
|
||||
}
|
||||
// A native attachment can settle before its caption rejects; preserve every
|
||||
// previously accepted receipt plus that nested provider-visible subset.
|
||||
const receipt = createMessageReceiptFromOutboundResults({
|
||||
results: [
|
||||
...accepted.map((result) => ({ receipt: result.receipt })),
|
||||
...(partial?.receipt
|
||||
? [{ receipt: partial.receipt }]
|
||||
: (partial?.messageIds ?? []).map((messageId) => ({ messageId }))),
|
||||
],
|
||||
kind: reply.mediaUrls.length > 0 ? "media" : "text",
|
||||
});
|
||||
throw createChannelPartialDeliveryError(error, {
|
||||
messageIds: listMessageReceiptPlatformIds(receipt),
|
||||
receipt,
|
||||
visibleReplySent: true,
|
||||
content: [...accepted.map((result) => result.sentText), partial?.content]
|
||||
.filter(Boolean)
|
||||
.join("\n"),
|
||||
});
|
||||
}
|
||||
if (delivered === "empty") {
|
||||
return {
|
||||
visibleReplySent: false as const,
|
||||
suppression: { reason: "no_visible_result" as const },
|
||||
};
|
||||
}
|
||||
const receipt = createMessageReceiptFromOutboundResults({
|
||||
results: accepted.map((result) => ({ receipt: result.receipt })),
|
||||
kind: delivered,
|
||||
});
|
||||
runtime.log?.(`imessage: delivered reply to ${target}`);
|
||||
return {
|
||||
messageIds: listMessageReceiptPlatformIds(receipt),
|
||||
receipt,
|
||||
visibleReplySent: true as const,
|
||||
content: accepted
|
||||
.map((result) => result.sentText)
|
||||
.filter(Boolean)
|
||||
.join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
export function createIMessageEchoCachingSend(params: {
|
||||
|
||||
@@ -78,7 +78,7 @@ import { runIMessageCatchup } from "./catchup-bridge.js";
|
||||
import { advanceIMessageCatchupCursor, resolveCatchupConfig } from "./catchup.js";
|
||||
import { combineIMessagePayloads } from "./coalesce.js";
|
||||
import { repairIMessageConversationAnchor } from "./conversation-repair.js";
|
||||
import { createIMessageEchoCachingSend, deliverReplies } from "./deliver.js";
|
||||
import { createIMessageEchoCachingSend, deliverIMessageReply } from "./deliver.js";
|
||||
import { resolveIMessageDmHistoryContext, resolveIMessageDmHistoryLimit } from "./dm-history.js";
|
||||
import { createIMessageThrottledDropDiagnosticCache } from "./drop-diagnostic-cache.js";
|
||||
import { createSentMessageCache } from "./echo-cache.js";
|
||||
@@ -1163,15 +1163,19 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
|
||||
},
|
||||
}
|
||||
: false,
|
||||
deliver: async (payload: Parameters<typeof deliverReplies>[0]["replies"][number]) => {
|
||||
observeMessageSent: true,
|
||||
deliver: async (payload: Parameters<typeof deliverIMessageReply>[0]["payload"]) => {
|
||||
const target = ctxPayload.To;
|
||||
if (!target) {
|
||||
runtime.error?.(danger("imessage: missing delivery target"));
|
||||
return;
|
||||
return {
|
||||
visibleReplySent: false,
|
||||
suppression: { reason: "no_visible_result" },
|
||||
} as const;
|
||||
}
|
||||
await deliverReplies({
|
||||
return await deliverIMessageReply({
|
||||
cfg,
|
||||
replies: [payload],
|
||||
payload,
|
||||
target,
|
||||
accountId: accountInfo.accountId,
|
||||
runtime,
|
||||
|
||||
Reference in New Issue
Block a user