From 05eda57b3c72e61d31a07c38df2edb3ef0843c62 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 6 May 2026 01:40:53 +0100 Subject: [PATCH] refactor: migrate bundled plugins to message lifecycle --- .../src/channel.message-adapter.test.ts | 178 +++++++++++++ extensions/bluebubbles/src/channel.ts | 214 ++++++++++++++-- .../bluebubbles/src/monitor-processing-api.ts | 2 +- .../bluebubbles/src/monitor-processing.ts | 185 +++++++++++--- extensions/bluebubbles/src/monitor.test.ts | 35 ++- extensions/bluebubbles/src/runtime-api.ts | 2 +- extensions/bluebubbles/src/send.test.ts | 41 +++ extensions/bluebubbles/src/send.ts | 73 +++++- extensions/diagnostics-otel/src/service.ts | 66 ++++- .../discord/src/channel-actions.test.ts | 53 ++++ extensions/discord/src/channel-actions.ts | 42 +++ .../src/channel.message-adapter.test.ts | 161 ++++++++++++ extensions/discord/src/channel.test.ts | 18 +- extensions/discord/src/channel.ts | 26 ++ .../src/monitor/agent-components.dispatch.ts | 8 +- .../src/monitor/message-handler.process.ts | 99 +++---- .../discord/src/monitor/monitor.test.ts | 14 +- .../src/monitor/native-command-agent-reply.ts | 4 +- .../thread-bindings.discord-api.test.ts | 6 + .../discord/src/outbound-adapter.test.ts | 32 +++ extensions/discord/src/outbound-adapter.ts | 11 + extensions/discord/src/outbound-components.ts | 7 +- extensions/discord/src/outbound-payload.ts | 76 +++++- extensions/discord/src/send.components.ts | 24 +- .../discord/src/send.creates-thread.test.ts | 19 +- extensions/discord/src/send.outbound.ts | 37 ++- extensions/discord/src/send.receipt.ts | 69 +++++ .../send.sends-basic-channel-messages.test.ts | 21 +- extensions/discord/src/send.shared.ts | 19 +- extensions/discord/src/send.types.ts | 2 + extensions/discord/src/send.voice.ts | 10 +- .../discord/src/send.webhook-activity.test.ts | 6 +- extensions/discord/src/send.webhook.ts | 12 +- extensions/feishu/runtime-api.ts | 2 +- extensions/feishu/src/channel.ts | 52 ++++ extensions/feishu/src/media.ts | 16 +- extensions/feishu/src/outbound.test.ts | 73 ++++++ extensions/feishu/src/reply-dispatcher.ts | 4 +- extensions/feishu/src/send-result.ts | 53 +++- extensions/feishu/src/send.test.ts | 3 +- extensions/feishu/src/send.ts | 14 +- extensions/feishu/src/types.ts | 2 + extensions/googlechat/runtime-api.ts | 2 +- extensions/googlechat/src/channel.adapters.ts | 51 +++- extensions/googlechat/src/channel.test.ts | 74 +++++- extensions/googlechat/src/channel.ts | 2 + .../googlechat/src/monitor-durable.test.ts | 39 +++ extensions/googlechat/src/monitor-durable.ts | 23 ++ extensions/googlechat/src/monitor.ts | 24 +- extensions/imessage/src/channel.ts | 82 +++++- .../imessage/src/imessage.test-plugin.ts | 8 + .../imessage/src/monitor/deliver.test.ts | 59 ++++- extensions/imessage/src/monitor/deliver.ts | 24 +- .../imessage/src/monitor/monitor-provider.ts | 34 ++- extensions/imessage/src/send.test.ts | 94 +++++++ extensions/imessage/src/send.ts | 54 +++- extensions/imessage/src/test-plugin.test.ts | 138 ++++++++++ extensions/irc/src/channel.ts | 2 + extensions/irc/src/inbound.ts | 6 +- extensions/irc/src/message-adapter.ts | 28 ++ extensions/irc/src/runtime-api.ts | 2 +- extensions/irc/src/send.test.ts | 116 +++++++++ extensions/irc/src/send.ts | 19 +- .../line/src/auto-reply-delivery.test.ts | 7 +- .../line/src/channel.sendPayload.test.ts | 112 +++++++- extensions/line/src/channel.ts | 3 +- extensions/line/src/monitor-durable.test.ts | 57 +++++ extensions/line/src/monitor-durable.ts | 37 +++ extensions/line/src/monitor.lifecycle.test.ts | 7 +- extensions/line/src/monitor.ts | 28 +- extensions/line/src/outbound.ts | 81 +++++- extensions/line/src/send-receipt.ts | 32 +++ extensions/line/src/send.test.ts | 8 +- extensions/line/src/send.ts | 30 +++ extensions/line/src/types.ts | 2 + extensions/line/src/webhook-node.ts | 25 +- extensions/line/src/webhook.ts | 21 +- .../src/approval-handler.runtime.test.ts | 35 ++- .../matrix/src/approval-handler.runtime.ts | 28 +- .../src/channel.message-adapter.test.ts | 169 ++++++++++++ extensions/matrix/src/channel.ts | 89 +++++-- .../matrix/src/matrix/draft-stream.test.ts | 7 +- .../matrix/src/matrix/monitor/handler.ts | 158 +++++++----- extensions/matrix/src/matrix/send.test.ts | 17 +- extensions/matrix/src/matrix/send.ts | 52 +++- extensions/matrix/src/matrix/send/types.ts | 3 +- extensions/matrix/src/runtime-api.ts | 2 +- extensions/mattermost/runtime-api.ts | 2 +- .../src/channel.message-adapter.test.ts | 151 +++++++++++ extensions/mattermost/src/channel.test.ts | 4 +- extensions/mattermost/src/channel.ts | 134 ++++++---- .../monitor.inbound-system-event.test.ts | 2 +- .../mattermost/src/mattermost/monitor.ts | 188 +++++++------- .../mattermost/src/mattermost/runtime-api.ts | 2 +- .../mattermost/src/mattermost/send.test.ts | 21 +- extensions/mattermost/src/mattermost/send.ts | 52 +++- .../mattermost/slash-http.send-config.test.ts | 2 +- .../mattermost/src/mattermost/slash-http.ts | 4 +- extensions/mattermost/src/runtime-api.ts | 2 +- extensions/msteams/runtime-api.ts | 2 +- .../src/channel.message-adapter.test.ts | 152 +++++++++++ extensions/msteams/src/channel.ts | 64 ++++- .../msteams/src/reply-dispatcher.test.ts | 18 +- extensions/msteams/src/reply-dispatcher.ts | 4 +- .../src/reply-stream-controller.test.ts | 30 ++- .../msteams/src/reply-stream-controller.ts | 86 ++++++- extensions/msteams/src/send.test.ts | 26 +- extensions/msteams/src/send.ts | 73 +++++- extensions/msteams/src/streaming-message.ts | 26 +- extensions/nextcloud-talk/runtime-api.ts | 2 +- extensions/nextcloud-talk/src/channel.ts | 28 +- .../src/inbound.behavior.test.ts | 8 +- extensions/nextcloud-talk/src/inbound.ts | 4 +- .../nextcloud-talk/src/message-adapter.ts | 28 ++ .../src/send.cfg-threading.test.ts | 103 +++++++- extensions/nextcloud-talk/src/send.ts | 34 ++- extensions/nextcloud-talk/src/types.ts | 2 + extensions/nostr/src/channel.outbound.test.ts | 32 +++ extensions/nostr/src/channel.ts | 7 + extensions/nostr/src/gateway.ts | 8 +- extensions/qa-channel/runtime-api.ts | 2 +- extensions/qa-channel/src/channel.test.ts | 43 ++++ extensions/qa-channel/src/channel.ts | 40 +++ extensions/qa-channel/src/inbound.test.ts | 20 +- extensions/qa-channel/src/inbound.ts | 4 +- extensions/qa-channel/src/runtime-api.ts | 2 +- .../qqbot/src/channel.message-adapter.test.ts | 89 +++++++ extensions/qqbot/src/channel.ts | 179 ++++++++++--- .../src/engine/messaging/outbound-types.ts | 2 + extensions/signal/src/channel.ts | 37 +++ extensions/signal/src/core.test.ts | 65 +++++ .../signal/src/monitor/event-handler.ts | 57 +++-- extensions/signal/src/send.test.ts | 111 ++++++++ extensions/signal/src/send.ts | 53 +++- extensions/slack/src/channel-actions.ts | 1 + .../slack/src/channel.message-adapter.test.ts | 187 ++++++++++++++ extensions/slack/src/channel.ts | 208 ++++++++------- extensions/slack/src/draft-stream.test.ts | 23 +- .../dispatch.preview-fallback.test.ts | 84 +++--- .../src/monitor/message-handler/dispatch.ts | 127 ++++----- .../src/monitor/message-handler/prepare.ts | 4 +- extensions/slack/src/monitor/slash.ts | 4 +- extensions/slack/src/send.blocks.test.ts | 28 +- extensions/slack/src/send.ts | 64 ++++- extensions/slack/src/send.upload.test.ts | 34 ++- extensions/synology-chat/src/channel.test.ts | 57 +++++ extensions/synology-chat/src/channel.ts | 114 +++++++-- extensions/synology-chat/src/inbound-turn.ts | 12 +- extensions/telegram/src/bot-core.ts | 1 + extensions/telegram/src/bot-deps.ts | 15 +- .../telegram/src/bot-message-dispatch.test.ts | 176 ++++++++++++- .../telegram/src/bot-message-dispatch.ts | 74 +++++- .../bot-native-commands.delivery.runtime.ts | 4 +- .../src/bot-native-commands.test-helpers.ts | 6 +- .../telegram/src/bot-native-commands.ts | 4 +- .../telegram/src/bot-update-tracker.test.ts | 38 +++ extensions/telegram/src/bot-update-tracker.ts | 81 ++++-- .../src/bot.create-telegram-bot.test.ts | 22 +- .../src/channel.message-adapter.test.ts | 210 +++++++++++++++ extensions/telegram/src/channel.ts | 144 +++++++++++ .../src/lane-delivery-text-deliverer.ts | 241 ++++++++++++------ extensions/telegram/src/lane-delivery.test.ts | 72 +++--- .../telegram/src/outbound-adapter.test.ts | 133 ++++++++++ extensions/telegram/src/outbound-adapter.ts | 20 ++ .../tlon/src/channel.message-adapter.test.ts | 125 +++++++++ extensions/tlon/src/channel.runtime.ts | 11 + extensions/tlon/src/channel.ts | 39 ++- extensions/tlon/src/monitor/index.ts | 86 ++++--- extensions/tlon/src/urbit/send.test.ts | 37 +++ extensions/tlon/src/urbit/send.ts | 62 ++++- extensions/twitch/src/monitor.ts | 35 ++- extensions/twitch/src/outbound.test.ts | 85 +++++- extensions/twitch/src/outbound.ts | 56 ++++ extensions/twitch/src/plugin.ts | 3 +- extensions/twitch/src/send.test.ts | 17 ++ extensions/twitch/src/send.ts | 60 ++++- .../whatsapp/src/auto-reply.test-harness.ts | 1 - .../src/auto-reply/deliver-reply.test.ts | 33 ++- .../whatsapp/src/auto-reply/deliver-reply.ts | 59 ++++- .../auto-reply/monitor/ack-reaction.test.ts | 1 - .../monitor/inbound-context.test.ts | 1 - .../monitor/inbound-dispatch.runtime.ts | 4 +- .../monitor/inbound-dispatch.test.ts | 161 +++++++++++- .../auto-reply/monitor/inbound-dispatch.ts | 43 +++- .../process-message.audio-preflight.test.ts | 2 +- .../monitor/process-message.test.ts | 6 +- .../src/auto-reply/monitor/process-message.ts | 4 +- .../src/auto-reply/monitor/runtime-api.ts | 6 +- .../auto-reply/web-auto-reply-monitor.test.ts | 1 - .../auto-reply/web-auto-reply-utils.test.ts | 1 - extensions/whatsapp/src/channel-outbound.ts | 51 ++++ extensions/whatsapp/src/channel.ts | 3 +- .../src/connection-controller.test.ts | 1 - .../whatsapp/src/inbound/send-api.test.ts | 10 +- .../whatsapp/src/inbound/send-result.test.ts | 56 ++++ .../whatsapp/src/inbound/send-result.ts | 56 +++- extensions/whatsapp/src/outbound-base.ts | 9 + .../src/outbound-payload.contract.test.ts | 108 ++++++++ extensions/whatsapp/src/send.test.ts | 1 - extensions/whatsapp/src/test-helpers.ts | 12 +- extensions/zalo/runtime-api.ts | 2 +- extensions/zalo/src/channel.ts | 34 +++ extensions/zalo/src/monitor-durable.test.ts | 49 ++++ extensions/zalo/src/monitor-durable.ts | 38 +++ extensions/zalo/src/monitor.ts | 37 ++- .../src/outbound-payload.contract.test.ts | 65 ++++- extensions/zalo/src/runtime-api.ts | 2 +- extensions/zalo/src/runtime-support.ts | 2 +- extensions/zalo/src/send.test.ts | 46 +++- extensions/zalo/src/send.ts | 80 +++++- extensions/zalouser/runtime-api.ts | 2 +- extensions/zalouser/src/channel.adapters.ts | 91 +++++-- .../zalouser/src/channel.sendpayload.test.ts | 75 +++++- extensions/zalouser/src/channel.test.ts | 2 +- extensions/zalouser/src/channel.ts | 2 + .../zalouser/src/monitor.group-gating.test.ts | 14 +- extensions/zalouser/src/monitor.ts | 61 +++-- extensions/zalouser/src/send-receipt.ts | 31 +++ extensions/zalouser/src/send.test.ts | 72 ++++-- extensions/zalouser/src/send.ts | 10 +- extensions/zalouser/src/tool.test.ts | 12 +- extensions/zalouser/src/types.ts | 2 + extensions/zalouser/src/zalo-js.ts | 72 +++++- 223 files changed, 8568 insertions(+), 1354 deletions(-) create mode 100644 extensions/bluebubbles/src/channel.message-adapter.test.ts create mode 100644 extensions/discord/src/channel.message-adapter.test.ts create mode 100644 extensions/discord/src/send.receipt.ts create mode 100644 extensions/googlechat/src/monitor-durable.test.ts create mode 100644 extensions/googlechat/src/monitor-durable.ts create mode 100644 extensions/imessage/src/send.test.ts create mode 100644 extensions/irc/src/message-adapter.ts create mode 100644 extensions/line/src/monitor-durable.test.ts create mode 100644 extensions/line/src/monitor-durable.ts create mode 100644 extensions/line/src/send-receipt.ts create mode 100644 extensions/matrix/src/channel.message-adapter.test.ts create mode 100644 extensions/mattermost/src/channel.message-adapter.test.ts create mode 100644 extensions/msteams/src/channel.message-adapter.test.ts create mode 100644 extensions/nextcloud-talk/src/message-adapter.ts create mode 100644 extensions/qqbot/src/channel.message-adapter.test.ts create mode 100644 extensions/signal/src/send.test.ts create mode 100644 extensions/slack/src/channel.message-adapter.test.ts create mode 100644 extensions/telegram/src/channel.message-adapter.test.ts create mode 100644 extensions/tlon/src/channel.message-adapter.test.ts create mode 100644 extensions/whatsapp/src/inbound/send-result.test.ts create mode 100644 extensions/zalo/src/monitor-durable.test.ts create mode 100644 extensions/zalo/src/monitor-durable.ts create mode 100644 extensions/zalouser/src/send-receipt.ts diff --git a/extensions/bluebubbles/src/channel.message-adapter.test.ts b/extensions/bluebubbles/src/channel.message-adapter.test.ts new file mode 100644 index 00000000000..cf9e7209afa --- /dev/null +++ b/extensions/bluebubbles/src/channel.message-adapter.test.ts @@ -0,0 +1,178 @@ +import { + createMessageReceiptFromOutboundResults, + verifyChannelMessageAdapterCapabilityProofs, +} from "openclaw/plugin-sdk/channel-message"; +import { describe, expect, it, vi } from "vitest"; +import { bluebubblesPlugin } from "./channel.js"; + +const sendMessageBlueBubblesMock = vi.hoisted(() => vi.fn()); +const sendBlueBubblesMediaMock = vi.hoisted(() => vi.fn()); +const resolveBlueBubblesMessageIdMock = vi.hoisted(() => vi.fn()); + +vi.mock("./channel.runtime.js", () => ({ + blueBubblesChannelRuntime: { + sendMessageBlueBubbles: sendMessageBlueBubblesMock, + sendBlueBubblesMedia: sendBlueBubblesMediaMock, + resolveBlueBubblesMessageId: resolveBlueBubblesMessageIdMock, + }, +})); + +describe("bluebubbles message adapter", () => { + it("declares durable text, media, and reply target capabilities with receipt proofs", async () => { + sendMessageBlueBubblesMock.mockImplementation( + async (_to: string, _text: string, opts: { replyToMessageGuid?: string } = {}) => ({ + messageId: opts.replyToMessageGuid ? "bb-reply-1" : "bb-text-1", + receipt: createMessageReceiptFromOutboundResults({ + results: [ + { + channel: "bluebubbles", + messageId: opts.replyToMessageGuid ? "bb-reply-1" : "bb-text-1", + }, + ], + kind: "text", + ...(opts.replyToMessageGuid ? { replyToId: opts.replyToMessageGuid } : {}), + }), + }), + ); + sendBlueBubblesMediaMock.mockResolvedValue({ + messageId: "bb-media-1", + receipt: createMessageReceiptFromOutboundResults({ + results: [{ channel: "bluebubbles", messageId: "bb-media-1" }], + kind: "media", + }), + }); + resolveBlueBubblesMessageIdMock.mockReturnValue("guid-reply-1"); + + await expect( + verifyChannelMessageAdapterCapabilityProofs({ + adapterName: "bluebubbles", + adapter: bluebubblesPlugin.message!, + proofs: { + text: async () => { + const result = await bluebubblesPlugin.message?.send?.text?.({ + cfg: {}, + to: "+15551234567", + text: "hello", + }); + expect(sendMessageBlueBubblesMock).toHaveBeenCalledWith("+15551234567", "hello", { + cfg: {}, + accountId: undefined, + replyToMessageGuid: undefined, + }); + expect(result?.receipt.platformMessageIds).toEqual(["bb-text-1"]); + }, + media: async () => { + const result = await bluebubblesPlugin.message?.send?.media?.({ + cfg: {}, + to: "+15551234567", + text: "image", + mediaUrl: "https://example.com/image.png", + }); + expect(sendBlueBubblesMediaMock).toHaveBeenCalledWith( + expect.objectContaining({ + to: "+15551234567", + mediaUrl: "https://example.com/image.png", + caption: "image", + }), + ); + expect(result?.receipt.platformMessageIds).toEqual(["bb-media-1"]); + }, + replyTo: async () => { + const result = await bluebubblesPlugin.message?.send?.text?.({ + cfg: {}, + to: "chat_guid:chat-1", + text: "reply", + replyToId: "short-1", + }); + expect(resolveBlueBubblesMessageIdMock).toHaveBeenCalledWith( + "short-1", + expect.objectContaining({ requireKnownShortId: true }), + ); + expect(sendMessageBlueBubblesMock).toHaveBeenCalledWith("chat_guid:chat-1", "reply", { + cfg: {}, + accountId: undefined, + replyToMessageGuid: "guid-reply-1", + }); + expect(result?.receipt.replyToId).toBe("guid-reply-1"); + }, + messageSendingHooks: async () => { + const beforeSendAttempt = vi.fn(() => "pending-1"); + const afterSendFailure = vi.fn(); + const ctx = { + cfg: {}, + kind: "text" as const, + to: "+15551234567", + text: "hello", + deps: { + bluebubblesMessageLifecycle: { + beforeSendAttempt, + afterSendFailure, + }, + }, + }; + const attemptToken = + await bluebubblesPlugin.message?.send?.lifecycle?.beforeSendAttempt?.(ctx); + await bluebubblesPlugin.message?.send?.lifecycle?.afterSendFailure?.({ + ...ctx, + error: new Error("send failed"), + attemptToken, + }); + expect(beforeSendAttempt).toHaveBeenCalledWith(ctx); + expect(afterSendFailure).toHaveBeenCalledWith( + expect.objectContaining({ + kind: "text", + attemptToken: "pending-1", + error: expect.any(Error), + }), + ); + }, + afterSendSuccess: async () => { + const beforeSendAttempt = vi.fn(() => "pending-1"); + const afterSendSuccess = vi.fn(); + const ctx = { + cfg: {}, + kind: "text" as const, + to: "+15551234567", + text: "hello", + deps: { + bluebubblesMessageLifecycle: { + beforeSendAttempt, + afterSendSuccess, + }, + }, + }; + const attemptToken = + await bluebubblesPlugin.message?.send?.lifecycle?.beforeSendAttempt?.(ctx); + await bluebubblesPlugin.message?.send?.lifecycle?.afterSendSuccess?.({ + ...ctx, + result: { + messageId: "bb-text-1", + receipt: createMessageReceiptFromOutboundResults({ + results: [{ channel: "bluebubbles", messageId: "bb-text-1" }], + kind: "text", + }), + }, + attemptToken, + }); + expect(beforeSendAttempt).toHaveBeenCalledWith(ctx); + expect(afterSendSuccess).toHaveBeenCalledWith( + expect.objectContaining({ + kind: "text", + attemptToken: "pending-1", + result: expect.objectContaining({ messageId: "bb-text-1" }), + }), + ); + }, + }, + }), + ).resolves.toEqual( + expect.arrayContaining([ + { capability: "text", status: "verified" }, + { capability: "media", status: "verified" }, + { capability: "replyTo", status: "verified" }, + { capability: "messageSendingHooks", status: "verified" }, + { capability: "afterSendSuccess", status: "verified" }, + ]), + ); + }); +}); diff --git a/extensions/bluebubbles/src/channel.ts b/extensions/bluebubbles/src/channel.ts index 4d797877a01..02b49dbd00b 100644 --- a/extensions/bluebubbles/src/channel.ts +++ b/extensions/bluebubbles/src/channel.ts @@ -2,11 +2,21 @@ import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id"; import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers"; import { createChatChannelPlugin } from "openclaw/plugin-sdk/channel-core"; import { createAccountStatusSink } from "openclaw/plugin-sdk/channel-lifecycle"; +import { + createMessageReceiptFromOutboundResults, + defineChannelMessageAdapter, + type ChannelMessageSendAttemptContext, + type ChannelMessageSendFailureContext, + type ChannelMessageSendSuccessContext, + type ChannelMessageSendResult, + type MessageReceiptPartKind, +} from "openclaw/plugin-sdk/channel-message"; import { createOpenGroupPolicyRestrictSendersWarningCollector, projectAccountWarningCollector, } from "openclaw/plugin-sdk/channel-policy"; import { buildProbeChannelStatusSummary } from "openclaw/plugin-sdk/channel-status"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime"; import { createComputedAccountStatusAdapter, @@ -61,6 +71,169 @@ const loadBlueBubblesChannelRuntime = createLazyRuntimeNamedExport( "blueBubblesChannelRuntime", ); +type BlueBubblesRuntime = Awaited>; +type BlueBubblesMediaExtras = { + mediaPath?: string; + mediaBuffer?: Uint8Array; + contentType?: string; + filename?: string; + caption?: string; +}; +type BlueBubblesMessageLifecycleDeps = { + beforeSendAttempt?: (ctx: ChannelMessageSendAttemptContext) => unknown; + afterSendSuccess?: (ctx: ChannelMessageSendSuccessContext) => Promise | void; + afterSendFailure?: (ctx: ChannelMessageSendFailureContext) => Promise | void; +}; + +function resolveBlueBubblesMessageLifecycleDeps( + ctx: + | ChannelMessageSendAttemptContext + | ChannelMessageSendSuccessContext + | ChannelMessageSendFailureContext, +): BlueBubblesMessageLifecycleDeps | undefined { + const candidate = ctx.deps?.bluebubblesMessageLifecycle; + if (!candidate || typeof candidate !== "object") { + return undefined; + } + return candidate as BlueBubblesMessageLifecycleDeps; +} + +function resolveBlueBubblesReplyToMessageGuid(params: { + runtime: BlueBubblesRuntime; + to: string; + replyToId?: string | null; +}): string | undefined { + const rawReplyToId = normalizeOptionalString(params.replyToId) ?? ""; + if (!rawReplyToId) { + return undefined; + } + return ( + params.runtime.resolveBlueBubblesMessageId(rawReplyToId, { + requireKnownShortId: true, + chatContext: buildBlueBubblesChatContextFromTarget(params.to), + }) || undefined + ); +} + +async function sendBlueBubblesTextWithRuntime(params: { + cfg: OpenClawConfig; + to: string; + text: string; + accountId?: string; + replyToId?: string | null; +}) { + const runtime = await loadBlueBubblesChannelRuntime(); + return await runtime.sendMessageBlueBubbles(params.to, params.text, { + cfg: params.cfg, + accountId: params.accountId, + replyToMessageGuid: resolveBlueBubblesReplyToMessageGuid({ + runtime, + to: params.to, + replyToId: params.replyToId, + }), + }); +} + +async function sendBlueBubblesMediaWithRuntime(params: { + cfg: OpenClawConfig; + to: string; + text?: string; + mediaUrl: string; + accountId?: string; + replyToId?: string | null; + audioAsVoice?: boolean; + extras?: BlueBubblesMediaExtras; +}) { + const runtime = await loadBlueBubblesChannelRuntime(); + return await runtime.sendBlueBubblesMedia({ + cfg: params.cfg, + to: params.to, + mediaUrl: params.mediaUrl, + mediaPath: params.extras?.mediaPath, + mediaBuffer: params.extras?.mediaBuffer, + contentType: params.extras?.contentType, + filename: params.extras?.filename, + caption: params.extras?.caption ?? params.text ?? undefined, + replyToId: + resolveBlueBubblesReplyToMessageGuid({ + runtime, + to: params.to, + replyToId: params.replyToId, + }) ?? null, + accountId: params.accountId, + asVoice: params.audioAsVoice === true, + }); +} + +function toBlueBubblesMessageSendResult( + result: { messageId?: string; receipt?: ChannelMessageSendResult["receipt"] }, + kind: MessageReceiptPartKind, + replyToId?: string | null, +): ChannelMessageSendResult { + const receipt = + result.receipt ?? + createMessageReceiptFromOutboundResults({ + results: result.messageId ? [{ channel: "bluebubbles", messageId: result.messageId }] : [], + kind, + ...(replyToId ? { replyToId } : {}), + }); + return { + messageId: result.messageId || receipt.primaryPlatformMessageId, + receipt, + }; +} + +const bluebubblesMessageAdapter = defineChannelMessageAdapter({ + id: "bluebubbles", + durableFinal: { + capabilities: { + text: true, + media: true, + replyTo: true, + messageSendingHooks: true, + afterSendSuccess: true, + }, + }, + send: { + lifecycle: { + beforeSendAttempt: async (ctx) => + await resolveBlueBubblesMessageLifecycleDeps(ctx)?.beforeSendAttempt?.(ctx), + afterSendSuccess: async (ctx) => { + await resolveBlueBubblesMessageLifecycleDeps(ctx)?.afterSendSuccess?.(ctx); + }, + afterSendFailure: async (ctx) => { + await resolveBlueBubblesMessageLifecycleDeps(ctx)?.afterSendFailure?.(ctx); + }, + }, + text: async (ctx) => + toBlueBubblesMessageSendResult( + await sendBlueBubblesTextWithRuntime({ + cfg: ctx.cfg, + to: ctx.to, + text: ctx.text, + accountId: ctx.accountId ?? undefined, + replyToId: ctx.replyToId, + }), + "text", + ctx.replyToId, + ), + media: async (ctx) => + toBlueBubblesMessageSendResult( + await sendBlueBubblesMediaWithRuntime({ + cfg: ctx.cfg, + to: ctx.to, + text: ctx.text, + mediaUrl: ctx.mediaUrl, + accountId: ctx.accountId ?? undefined, + replyToId: ctx.replyToId, + audioAsVoice: ctx.audioAsVoice, + }), + "media", + ctx.replyToId, + ), + }, +}); + const resolveBlueBubblesDmPolicy = createScopedDmSecurityResolver({ channelKey: "bluebubbles", resolvePolicy: (account) => account.config.dmPolicy, @@ -281,6 +454,7 @@ export const bluebubblesPlugin: ChannelPlugin { - const runtime = await loadBlueBubblesChannelRuntime(); - const rawReplyToId = normalizeOptionalString(replyToId) ?? ""; - const replyToMessageGuid = rawReplyToId - ? runtime.resolveBlueBubblesMessageId(rawReplyToId, { - requireKnownShortId: true, - chatContext: buildBlueBubblesChatContextFromTarget(to), - }) - : ""; - return await runtime.sendMessageBlueBubbles(to, text, { - cfg: cfg, + sendText: async ({ cfg, to, text, accountId, replyToId }) => + await sendBlueBubblesTextWithRuntime({ + cfg, + to, + text, accountId: accountId ?? undefined, - replyToMessageGuid: replyToMessageGuid || undefined, - }); - }, + replyToId, + }), sendMedia: async (ctx) => { - const runtime = await loadBlueBubblesChannelRuntime(); const { cfg, to, text, mediaUrl, accountId, replyToId, audioAsVoice } = ctx; + if (!mediaUrl) { + throw new Error("BlueBubbles media send requires mediaUrl"); + } const { mediaPath, mediaBuffer, contentType, filename, caption } = ctx as { mediaPath?: string; mediaBuffer?: Uint8Array; @@ -343,18 +512,15 @@ export const bluebubblesPlugin: ChannelPlugin { + const rawReplyToId = + privateApiEnabled && typeof payload.replyToId === "string" ? payload.replyToId.trim() : ""; + if (!rawReplyToId) { + return ""; + } + return ( + resolveBlueBubblesMessageId(rawReplyToId, { + requireKnownShortId: true, + chatContext: { + chatGuid: chatGuidForActions ?? chatGuid, + chatIdentifier, + chatId, + }, + }) || "" + ); + }; + const prepareBlueBubblesReplyPayload = (payload: ReplyPayload): ReplyPayload => { + const tableMode = core.channel.text.resolveMarkdownTableMode({ + cfg: config, + channel: "bluebubbles", + accountId: account.accountId, + }); + const text = sanitizeReplyDirectiveText( + core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode), + ); + return { + ...payload, + text, + ...(typeof payload.replyToId === "string" && !privateApiEnabled ? { replyToId: "" } : {}), + }; + }; + const canUseDurableBlueBubblesFinalDelivery = (payload: { text?: string }): boolean => { + const textLimit = + account.config.textChunkLimit && account.config.textChunkLimit > 0 + ? account.config.textChunkLimit + : DEFAULT_TEXT_LIMIT; + return (payload.text ?? "").length <= textLimit; + }; // History: in-memory rolling map with bounded API backfill retries const historyLimit = isGroup @@ -1728,42 +1768,36 @@ async function processMessageAfterDedupe( }, typingRestartDelayMs); }; try { - const { onModelSelected, typingCallbacks, ...replyPipeline } = createChannelReplyPipeline({ - cfg: config, - agentId: route.agentId, - channel: "bluebubbles", - accountId: account.accountId, - typingCallbacks: { - onReplyStart: async () => { - if (!chatGuidForActions) { - return; - } - if (!baseUrl || !password) { - return; - } - streamingActive = true; - clearTypingRestartTimer(); - try { - await sendBlueBubblesTyping(chatGuidForActions, true, { - cfg: config, - accountId: account.accountId, - }); - } catch (err) { - runtime.error?.(`[bluebubbles] typing start failed: ${sanitizeForLog(err)}`); - } - }, - onIdle: () => { - if (!chatGuidForActions) { - return; - } - if (!baseUrl || !password) { - return; - } - // Intentionally no-op for block streaming. We stop typing in finally - // after the run completes to avoid flicker between paragraph blocks. - }, + const typingCallbacks = { + onReplyStart: async () => { + if (!chatGuidForActions) { + return; + } + if (!baseUrl || !password) { + return; + } + streamingActive = true; + clearTypingRestartTimer(); + try { + await sendBlueBubblesTyping(chatGuidForActions, true, { + cfg: config, + accountId: account.accountId, + }); + } catch (err) { + runtime.error?.(`[bluebubbles] typing start failed: ${sanitizeForLog(err)}`); + } }, - }); + onIdle: () => { + if (!chatGuidForActions) { + return; + } + if (!baseUrl || !password) { + return; + } + // Intentionally no-op for block streaming. We stop typing in finally + // after the run completes to avoid flicker between paragraph blocks. + }, + }; await core.channel.turn.run({ channel: "bluebubbles", accountId: account.accountId, @@ -1789,6 +1823,76 @@ async function processMessageAfterDedupe( dispatchReplyWithBufferedBlockDispatcher: core.channel.reply.dispatchReplyWithBufferedBlockDispatcher, delivery: { + preparePayload: (payload) => prepareBlueBubblesReplyPayload(payload), + durable: (payload, info) => { + if (info.kind !== "final" || !canUseDurableBlueBubblesFinalDelivery(payload)) { + return false; + } + const replyToMessageGuid = resolveReplyToMessageGuidForPayload(payload); + return { + to: outboundTarget, + replyToId: + typeof payload.replyToId === "string" ? payload.replyToId.trim() || null : null, + deps: { + bluebubblesMessageLifecycle: { + beforeSendAttempt: (ctx: { kind: string; text?: string }) => { + const snippet = + ctx.kind === "media" + ? (ctx.text ?? "").trim() || "" + : (ctx.text ?? "").trim(); + return rememberPendingOutboundMessageId({ + accountId: account.accountId, + sessionKey: route.sessionKey, + outboundTarget, + chatGuid: chatGuidForActions ?? chatGuid, + chatIdentifier, + chatId, + snippet, + }); + }, + afterSendSuccess: (ctx: { + kind: string; + text?: string; + result?: { messageId?: string }; + attemptToken?: unknown; + }) => { + const snippet = + ctx.kind === "media" + ? (ctx.text ?? "").trim() || "" + : (ctx.text ?? "").trim(); + if ( + maybeEnqueueOutboundMessageId(ctx.result?.messageId, snippet) && + typeof ctx.attemptToken === "number" + ) { + forgetPendingOutboundMessageId(ctx.attemptToken); + } + }, + afterSendFailure: (ctx: { attemptToken?: unknown }) => { + if (typeof ctx.attemptToken === "number") { + forgetPendingOutboundMessageId(ctx.attemptToken); + } + }, + }, + }, + requiredCapabilities: deriveDurableFinalDeliveryRequirements({ + payload, + replyToId: replyToMessageGuid || null, + afterSendSuccess: true, + }), + }; + }, + onDelivered: (_payload, info, result) => { + if (!result?.deliveryIntent) { + return; + } + if (result.visibleReplySent === true) { + sentMessage = true; + statusSink?.({ lastOutboundAt: Date.now() }); + if (info.kind === "block") { + restartTypingSoon(); + } + } + }, deliver: async (payload, info) => { const rawReplyToId = privateApiEnabled && typeof payload.replyToId === "string" @@ -1932,13 +2036,14 @@ async function processMessageAfterDedupe( runtime.error?.(`BlueBubbles ${info.kind} reply failed: ${sanitizeForLog(err)}`); }, }, + replyPipeline: { + typingCallbacks, + }, dispatcherOptions: { - ...replyPipeline, - onReplyStart: typingCallbacks?.onReplyStart, - onIdle: typingCallbacks?.onIdle, + onReplyStart: typingCallbacks.onReplyStart, + onIdle: typingCallbacks.onIdle, }, replyOptions: { - onModelSelected, disableBlockStreaming: typeof account.config.blockStreaming === "boolean" ? !account.config.blockStreaming diff --git a/extensions/bluebubbles/src/monitor.test.ts b/extensions/bluebubbles/src/monitor.test.ts index 757c4c6c178..c89ca72d677 100644 --- a/extensions/bluebubbles/src/monitor.test.ts +++ b/extensions/bluebubbles/src/monitor.test.ts @@ -34,7 +34,16 @@ import { _setFetchGuardForTesting } from "./types.js"; // Mock dependencies vi.mock("./send.js", () => ({ resolveChatGuidForTarget: vi.fn().mockResolvedValue("iMessage;-;+15551234567"), - sendMessageBlueBubbles: vi.fn().mockResolvedValue({ messageId: "msg-123" }), + sendMessageBlueBubbles: vi.fn().mockResolvedValue({ + messageId: "msg-123", + receipt: { + primaryPlatformMessageId: "msg-123", + platformMessageIds: ["msg-123"], + parts: [], + sentAt: 0, + raw: [], + }, + }), })); vi.mock("./chat.js", () => ({ @@ -78,6 +87,20 @@ const DEFAULT_RESOLVED_AGENT_ROUTE: ReturnType< matchedBy: "default", }; const mockResolveAgentRoute = vi.fn(() => DEFAULT_RESOLVED_AGENT_ROUTE); + +function blueBubblesTestSendResult(messageId: string) { + const hasPlatformId = messageId && messageId !== "ok" && messageId !== "unknown"; + return { + messageId, + receipt: { + ...(hasPlatformId ? { primaryPlatformMessageId: messageId } : {}), + platformMessageIds: hasPlatformId ? [messageId] : [], + parts: [], + sentAt: 0, + raw: [], + }, + }; +} const mockBuildMentionRegexes = vi.fn(() => [/\bbert\b/i]); const mockMatchesMentionPatterns = vi.fn((text: string, regexes: RegExp[]) => regexes.some((r) => r.test(text)), @@ -2043,7 +2066,7 @@ describe("BlueBubbles webhook monitor", () => { mockEnqueueSystemEvent.mockClear(); const { sendMessageBlueBubbles } = await import("./send.js"); - vi.mocked(sendMessageBlueBubbles).mockResolvedValueOnce({ messageId: "ok" }); + vi.mocked(sendMessageBlueBubbles).mockResolvedValueOnce(blueBubblesTestSendResult("ok")); mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => { await params.dispatcherOptions.deliver({ text: "replying now" }, { kind: "final" }); @@ -2083,7 +2106,7 @@ describe("BlueBubbles webhook monitor", () => { mockEnqueueSystemEvent.mockClear(); const { sendMessageBlueBubbles } = await import("./send.js"); - vi.mocked(sendMessageBlueBubbles).mockResolvedValueOnce({ messageId: "ok" }); + vi.mocked(sendMessageBlueBubbles).mockResolvedValueOnce(blueBubblesTestSendResult("ok")); mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => { await params.dispatcherOptions.deliver({ text: "replying now" }, { kind: "final" }); @@ -2543,7 +2566,9 @@ describe("BlueBubbles webhook monitor", () => { setupWebhookTarget(); const { sendMessageBlueBubbles } = await import("./send.js"); - vi.mocked(sendMessageBlueBubbles).mockResolvedValueOnce({ messageId: "msg-self-1" }); + vi.mocked(sendMessageBlueBubbles).mockResolvedValueOnce( + blueBubblesTestSendResult("msg-self-1"), + ); mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => { await params.dispatcherOptions.deliver({ text: "replying now" }, { kind: "final" }); @@ -2693,7 +2718,7 @@ describe("BlueBubbles webhook monitor", () => { setupWebhookTarget(); const { sendMessageBlueBubbles } = await import("./send.js"); - vi.mocked(sendMessageBlueBubbles).mockResolvedValueOnce({ messageId: "ok" }); + vi.mocked(sendMessageBlueBubbles).mockResolvedValueOnce(blueBubblesTestSendResult("ok")); mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => { await params.dispatcherOptions.deliver({ text: "same text" }, { kind: "final" }); diff --git a/extensions/bluebubbles/src/runtime-api.ts b/extensions/bluebubbles/src/runtime-api.ts index 39d97eb8409..7fd3385ff21 100644 --- a/extensions/bluebubbles/src/runtime-api.ts +++ b/extensions/bluebubbles/src/runtime-api.ts @@ -39,7 +39,7 @@ export { export { readBooleanParam } from "openclaw/plugin-sdk/boolean-param"; export { mapAllowFromEntries } from "openclaw/plugin-sdk/channel-config-helpers"; export { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing"; -export { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline"; +export { createChannelMessageReplyPipeline } from "openclaw/plugin-sdk/channel-message"; export { resolveRequestUrl } from "openclaw/plugin-sdk/request-url"; export { buildProbeChannelStatusSummary } from "openclaw/plugin-sdk/channel-status"; export { stripMarkdown } from "openclaw/plugin-sdk/text-runtime"; diff --git a/extensions/bluebubbles/src/send.test.ts b/extensions/bluebubbles/src/send.test.ts index 1d08452f254..ea2e2374785 100644 --- a/extensions/bluebubbles/src/send.test.ts +++ b/extensions/bluebubbles/src/send.test.ts @@ -717,6 +717,21 @@ describe("send", () => { }); expect(result.messageId).toBe("msg-uuid-123"); + expect(result.receipt).toMatchObject({ + primaryPlatformMessageId: "msg-uuid-123", + platformMessageIds: ["msg-uuid-123"], + parts: [ + { + platformMessageId: "msg-uuid-123", + kind: "text", + raw: { + channel: "bluebubbles", + conversationId: "iMessage;-;+15551234567", + messageId: "msg-uuid-123", + }, + }, + ], + }); expect(mockFetch).toHaveBeenCalledTimes(2); const sendCall = mockFetch.mock.calls[1]; @@ -812,6 +827,16 @@ describe("send", () => { }); expect(result.messageId).toBe("new-msg-guid"); + expect(result.receipt).toMatchObject({ + primaryPlatformMessageId: "new-msg-guid", + platformMessageIds: ["new-msg-guid"], + parts: [ + { + platformMessageId: "new-msg-guid", + kind: "text", + }, + ], + }); expect(mockFetch).toHaveBeenCalledTimes(2); const createCall = mockFetch.mock.calls[1]; @@ -857,6 +882,18 @@ describe("send", () => { }); expect(result.messageId).toBe("msg-uuid-124"); + expect(result.receipt).toMatchObject({ + primaryPlatformMessageId: "msg-uuid-124", + platformMessageIds: ["msg-uuid-124"], + replyToId: "reply-guid-123", + parts: [ + { + platformMessageId: "msg-uuid-124", + kind: "text", + replyToId: "reply-guid-123", + }, + ], + }); expect(mockFetch).toHaveBeenCalledTimes(2); const sendCall = mockFetch.mock.calls[1]; @@ -1053,6 +1090,8 @@ describe("send", () => { }); expect(result.messageId).toBe("ok"); + expect(result.receipt.platformMessageIds).toEqual([]); + expect(result.receipt.parts).toEqual([]); }); it("handles invalid JSON response body", async () => { @@ -1068,6 +1107,8 @@ describe("send", () => { }); expect(result.messageId).toBe("ok"); + expect(result.receipt.platformMessageIds).toEqual([]); + expect(result.receipt.parts).toEqual([]); }); it("extracts messageId from various response formats", async () => { diff --git a/extensions/bluebubbles/src/send.ts b/extensions/bluebubbles/src/send.ts index f6e6685944a..46da91ce181 100644 --- a/extensions/bluebubbles/src/send.ts +++ b/extensions/bluebubbles/src/send.ts @@ -1,4 +1,9 @@ import crypto from "node:crypto"; +import { + createMessageReceiptFromOutboundResults, + type MessageReceipt, + type MessageReceiptSourceResult, +} from "openclaw/plugin-sdk/channel-message"; import { normalizeLowercaseStringOrEmpty, normalizeOptionalLowercaseString, @@ -35,6 +40,7 @@ export type BlueBubblesSendOpts = { export type BlueBubblesSendResult = { messageId: string; + receipt: MessageReceipt; }; /** Maps short effect names to full Apple effect IDs */ @@ -118,17 +124,61 @@ function resolvePrivateApiDecision(params: { }; } -async function parseBlueBubblesMessageResponse(res: Response): Promise { +function createBlueBubblesSendReceipt(params: { + messageId: string; + chatGuid?: string | null; + replyToMessageGuid?: string; +}): MessageReceipt { + const messageId = params.messageId.trim(); + const results: MessageReceiptSourceResult[] = + messageId && messageId !== "unknown" && messageId !== "ok" + ? [ + { + channel: "bluebubbles", + messageId, + }, + ] + : []; + if (results[0] && params.chatGuid) { + results[0].conversationId = params.chatGuid; + } + return createMessageReceiptFromOutboundResults({ + results, + kind: "text", + ...(params.replyToMessageGuid ? { replyToId: params.replyToMessageGuid } : {}), + }); +} + +async function parseBlueBubblesMessageResponse( + res: Response, + params: { chatGuid?: string | null; replyToMessageGuid?: string } = {}, +): Promise { const body = await res.text(); + let messageId = "ok"; if (!body) { - return { messageId: "ok" }; + return { + messageId, + receipt: createBlueBubblesSendReceipt({ + messageId, + ...(params.chatGuid ? { chatGuid: params.chatGuid } : {}), + ...(params.replyToMessageGuid ? { replyToMessageGuid: params.replyToMessageGuid } : {}), + }), + }; } try { const parsed = JSON.parse(body) as unknown; - return { messageId: extractBlueBubblesMessageId(parsed) }; + messageId = extractBlueBubblesMessageId(parsed); } catch { - return { messageId: "ok" }; + messageId = "ok"; } + return { + messageId, + receipt: createBlueBubblesSendReceipt({ + messageId, + ...(params.chatGuid ? { chatGuid: params.chatGuid } : {}), + ...(params.replyToMessageGuid ? { replyToMessageGuid: params.replyToMessageGuid } : {}), + }), + }; } type BlueBubblesChatRecord = Record; @@ -479,7 +529,13 @@ async function createNewChatWithMessage(params: { timeoutMs: params.timeoutMs, allowPrivateNetwork: params.allowPrivateNetwork, }); - return { messageId: result.messageId }; + return { + messageId: result.messageId, + receipt: createBlueBubblesSendReceipt({ + messageId: result.messageId, + chatGuid: result.chatGuid, + }), + }; } export async function sendMessageBlueBubbles( @@ -614,5 +670,10 @@ export async function sendMessageBlueBubbles( const errorText = await res.text(); throw new Error(`BlueBubbles send failed (${res.status}): ${errorText || "unknown"}`); } - return parseBlueBubblesMessageResponse(res); + return parseBlueBubblesMessageResponse(res, { + chatGuid, + ...(wantsReplyThread && opts.replyToMessageGuid + ? { replyToMessageGuid: opts.replyToMessageGuid } + : {}), + }); } diff --git a/extensions/diagnostics-otel/src/service.ts b/extensions/diagnostics-otel/src/service.ts index 2c69c650c5c..43f975f35f4 100644 --- a/extensions/diagnostics-otel/src/service.ts +++ b/extensions/diagnostics-otel/src/service.ts @@ -91,6 +91,10 @@ type TelemetryExporterDiagnosticEvent = Extract< DiagnosticEventPayload, { type: "telemetry.exporter" } >; +type SessionRecoveryDiagnosticEvent = Extract< + DiagnosticEventPayload, + { type: "session.recovery.requested" | "session.recovery.completed" } +>; const NO_CONTENT_CAPTURE: OtelContentCapturePolicy = { inputMessages: false, @@ -819,6 +823,27 @@ export function createDiagnosticsOtelService(): OpenClawPluginService { unit: "ms", description: "Age of stuck sessions", }); + const sessionRecoveryRequestedCounter = meter.createCounter( + "openclaw.session.recovery.requested", + { + unit: "1", + description: "Session recovery attempts requested", + }, + ); + const sessionRecoveryCompletedCounter = meter.createCounter( + "openclaw.session.recovery.completed", + { + unit: "1", + description: "Session recovery attempts completed", + }, + ); + const sessionRecoveryAgeHistogram = meter.createHistogram( + "openclaw.session.recovery.age_ms", + { + unit: "ms", + description: "Age of sessions selected for recovery", + }, + ); const runAttemptCounter = meter.createCounter("openclaw.run.attempt", { unit: "1", description: "Run attempts", @@ -1468,6 +1493,39 @@ export function createDiagnosticsOtelService(): OpenClawPluginService { span.end(); }; + const sessionRecoveryAttrs = (evt: SessionRecoveryDiagnosticEvent) => { + const attrs: Record = { "openclaw.state": evt.state }; + if (evt.reason) { + attrs["openclaw.reason"] = redactSensitiveText(evt.reason); + } + if (evt.activeWorkKind) { + attrs["openclaw.active_work_kind"] = evt.activeWorkKind; + } + return attrs; + }; + + const recordSessionRecoveryRequested = ( + evt: Extract, + ) => { + const attrs = sessionRecoveryAttrs(evt); + attrs["openclaw.action"] = evt.allowActiveAbort ? "abort" : "recover"; + sessionRecoveryRequestedCounter.add(1, attrs); + sessionRecoveryAgeHistogram.record(evt.ageMs, attrs); + }; + + const recordSessionRecoveryCompleted = ( + evt: Extract, + ) => { + const attrs = sessionRecoveryAttrs(evt); + attrs["openclaw.status"] = evt.status; + attrs["openclaw.action"] = lowCardinalityAttr(evt.action, "unknown"); + if (evt.outcomeReason) { + attrs["openclaw.reason"] = redactSensitiveText(evt.outcomeReason); + } + sessionRecoveryCompletedCounter.add(1, attrs); + sessionRecoveryAgeHistogram.record(evt.ageMs, attrs); + }; + const recordRunAttempt = (evt: Extract) => { runAttemptCounter.add(1, { "openclaw.attempt": evt.attempt }); }; @@ -2236,12 +2294,16 @@ export function createDiagnosticsOtelService(): OpenClawPluginService { return; case "session.long_running": case "session.stalled": - case "session.recovery.completed": - case "session.recovery.requested": return; case "session.stuck": recordSessionStuck(evt); return; + case "session.recovery.requested": + recordSessionRecoveryRequested(evt); + return; + case "session.recovery.completed": + recordSessionRecoveryCompleted(evt); + return; case "run.attempt": recordRunAttempt(evt); return; diff --git a/extensions/discord/src/channel-actions.test.ts b/extensions/discord/src/channel-actions.test.ts index 80b5cf4013a..554cbf25399 100644 --- a/extensions/discord/src/channel-actions.test.ts +++ b/extensions/discord/src/channel-actions.test.ts @@ -229,6 +229,59 @@ describe("discordMessageActions", () => { ).toBeNull(); }); + it("prepares Discord send payload channel data for durable core delivery", async () => { + const prepared = await discordMessageActions.prepareSendPayload?.({ + ctx: { + channel: "discord", + action: "send", + cfg: {} as OpenClawConfig, + params: { + components: { + text: "Choose", + blocks: [ + { + type: "actions", + buttons: [{ label: "Yes", callbackData: "yes" }], + }, + ], + }, + embeds: undefined, + filename: "photo.png", + }, + }, + to: "channel:123", + payload: { text: "hello", mediaUrl: "/tmp/photo.png" }, + }); + + expect(prepared).toMatchObject({ + text: "hello", + mediaUrl: "/tmp/photo.png", + channelData: { + discord: { + components: expect.objectContaining({ text: "Choose" }), + filename: "photo.png", + }, + }, + }); + }); + + it("keeps non-serializable Discord component sends on the legacy action path", async () => { + const prepared = await discordMessageActions.prepareSendPayload?.({ + ctx: { + channel: "discord", + action: "send", + cfg: {} as OpenClawConfig, + params: { + components: () => [], + }, + }, + to: "channel:123", + payload: { text: "hello" }, + }); + + expect(prepared).toBeNull(); + }); + it("delegates action handling to the Discord action handler", async () => { const cfg = { channels: { diff --git a/extensions/discord/src/channel-actions.ts b/extensions/discord/src/channel-actions.ts index 04484e00a5e..15c6ed25f7b 100644 --- a/extensions/discord/src/channel-actions.ts +++ b/extensions/discord/src/channel-actions.ts @@ -9,6 +9,7 @@ import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; import { extractToolSend } from "openclaw/plugin-sdk/tool-send"; import { inspectDiscordAccount } from "./account-inspect.js"; import { createDiscordActionGate, listDiscordAccountIds } from "./accounts.js"; +import { readDiscordComponentSpec } from "./components.js"; let discordChannelActionsRuntimePromise: | Promise @@ -175,6 +176,47 @@ export const discordMessageActions: ChannelMessageActionAdapter = { } return null; }, + prepareSendPayload: ({ ctx, payload }) => { + if (ctx.action !== "send") { + return null; + } + const rawComponents = ctx.params.components; + if (typeof rawComponents === "function") { + return null; + } + const componentSpec = + rawComponents && typeof rawComponents === "object" && !Array.isArray(rawComponents) + ? readDiscordComponentSpec(rawComponents) + : undefined; + const nativeComponents = Array.isArray(rawComponents) ? rawComponents : undefined; + const embeds = Array.isArray(ctx.params.embeds) ? ctx.params.embeds : undefined; + if ((componentSpec || nativeComponents) && embeds?.length) { + return null; + } + const filename = normalizeOptionalString(ctx.params.filename); + if (!componentSpec && !nativeComponents && !embeds?.length && !filename) { + return payload; + } + const discordData = + payload.channelData?.discord && + typeof payload.channelData.discord === "object" && + !Array.isArray(payload.channelData.discord) + ? (payload.channelData.discord as Record) + : {}; + return { + ...payload, + channelData: { + ...payload.channelData, + discord: { + ...discordData, + ...(componentSpec ? { components: componentSpec } : {}), + ...(nativeComponents ? { components: nativeComponents } : {}), + ...(embeds?.length ? { embeds } : {}), + ...(filename ? { filename } : {}), + }, + }, + }; + }, handleAction: async ({ action, params, diff --git a/extensions/discord/src/channel.message-adapter.test.ts b/extensions/discord/src/channel.message-adapter.test.ts new file mode 100644 index 00000000000..abee4fd7370 --- /dev/null +++ b/extensions/discord/src/channel.message-adapter.test.ts @@ -0,0 +1,161 @@ +import { + verifyChannelMessageAdapterCapabilityProofs, + verifyChannelMessageLiveCapabilityAdapterProofs, + verifyChannelMessageLiveFinalizerProofs, +} from "openclaw/plugin-sdk/channel-message"; +import { beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { + createDiscordOutboundHoisted, + installDiscordOutboundModuleSpies, + resetDiscordOutboundMocks, +} from "./outbound-adapter.test-harness.js"; + +const hoisted = createDiscordOutboundHoisted(); +await installDiscordOutboundModuleSpies(hoisted); + +let discordPlugin: typeof import("./channel.js").discordPlugin; + +beforeAll(async () => { + ({ discordPlugin } = await import("./channel.js")); +}); + +describe("discord channel message adapter", () => { + beforeEach(() => { + resetDiscordOutboundMocks(hoisted); + }); + + it("backs declared durable-final capabilities with outbound send proofs", async () => { + const adapter = discordPlugin.message; + expect(adapter).toBeDefined(); + + const proveText = async () => { + resetDiscordOutboundMocks(hoisted); + const result = await adapter!.send!.text!({ + cfg: {}, + to: "channel:123456", + text: "hello", + accountId: "default", + }); + expect(hoisted.sendMessageDiscordMock).toHaveBeenLastCalledWith( + "channel:123456", + "hello", + expect.objectContaining({ accountId: "default" }), + ); + expect(result.receipt.platformMessageIds).toEqual(["msg-1"]); + expect(result.receipt.parts[0]?.kind).toBe("text"); + }; + + const proveMedia = async () => { + resetDiscordOutboundMocks(hoisted); + const result = await adapter!.send!.media!({ + cfg: {}, + to: "channel:123456", + text: "caption", + mediaUrl: "https://example.com/a.png", + accountId: "default", + }); + expect(hoisted.sendMessageDiscordMock).toHaveBeenLastCalledWith( + "channel:123456", + "caption", + expect.objectContaining({ + accountId: "default", + mediaUrl: "https://example.com/a.png", + }), + ); + expect(result.receipt.parts[0]?.kind).toBe("media"); + }; + + const provePayload = async () => { + resetDiscordOutboundMocks(hoisted); + const result = await adapter!.send!.payload!({ + cfg: {}, + to: "channel:123456", + text: "payload", + payload: { text: "payload" }, + accountId: "default", + }); + expect(hoisted.sendMessageDiscordMock).toHaveBeenLastCalledWith( + "channel:123456", + "payload", + expect.objectContaining({ accountId: "default" }), + ); + expect(result.receipt.platformMessageIds).toEqual(["msg-1"]); + }; + + const proveReplyThreadSilent = async () => { + resetDiscordOutboundMocks(hoisted); + const result = await adapter!.send!.text!({ + cfg: {}, + to: "channel:parent-1", + text: "threaded", + accountId: "default", + replyToId: "reply-1", + threadId: "thread-1", + silent: true, + }); + expect(hoisted.sendMessageDiscordMock).toHaveBeenLastCalledWith( + "channel:thread-1", + "threaded", + expect.objectContaining({ + accountId: "default", + replyTo: "reply-1", + silent: true, + }), + ); + expect(result.receipt.threadId).toBe("thread-1"); + expect(result.receipt.replyToId).toBe("reply-1"); + }; + + await verifyChannelMessageAdapterCapabilityProofs({ + adapterName: "discordMessageAdapter", + adapter: adapter!, + proofs: { + text: proveText, + media: proveMedia, + payload: provePayload, + silent: proveReplyThreadSilent, + replyTo: proveReplyThreadSilent, + thread: proveReplyThreadSilent, + messageSendingHooks: () => { + expect(adapter!.send!.text).toBeTypeOf("function"); + }, + }, + }); + }); + + it("backs declared live preview finalizer capabilities with adapter proofs", async () => { + const adapter = discordPlugin.message; + + await verifyChannelMessageLiveCapabilityAdapterProofs({ + adapterName: "discordMessageAdapter", + adapter: adapter!, + proofs: { + draftPreview: () => { + expect(adapter!.live?.finalizer?.capabilities?.discardPending).toBe(true); + }, + previewFinalization: () => { + expect(adapter!.live?.finalizer?.capabilities?.finalEdit).toBe(true); + }, + progressUpdates: () => { + expect(adapter!.live?.capabilities?.draftPreview).toBe(true); + }, + }, + }); + + await verifyChannelMessageLiveFinalizerProofs({ + adapterName: "discordMessageAdapter", + adapter: adapter!, + proofs: { + finalEdit: () => { + expect(adapter!.live?.capabilities?.previewFinalization).toBe(true); + }, + normalFallback: () => { + expect(adapter!.send!.text).toBeTypeOf("function"); + }, + discardPending: () => { + expect(adapter!.live?.capabilities?.draftPreview).toBe(true); + }, + }, + }); + }); +}); diff --git a/extensions/discord/src/channel.test.ts b/extensions/discord/src/channel.test.ts index 92a14e3416f..54e448f2183 100644 --- a/extensions/discord/src/channel.test.ts +++ b/extensions/discord/src/channel.test.ts @@ -6,6 +6,7 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vite import type { ResolvedDiscordAccount } from "./accounts.js"; import type { OpenClawConfig } from "./runtime-api.js"; import * as sendModule from "./send.js"; +import { createDiscordSendReceipt } from "./send.receipt.js"; import { EMPTY_DISCORD_TEST_CONFIG } from "./test-support/config.js"; let discordPlugin: typeof import("./channel.js").discordPlugin; let setDiscordRuntime: typeof import("./runtime.js").setDiscordRuntime; @@ -18,6 +19,14 @@ const collectDiscordAuditChannelIdsMock = vi.hoisted(() => ); const sleepWithAbortMock = vi.hoisted(() => vi.fn(async () => undefined)); +function discordTestSendResult(messageId: string, channelId = "channel:thread-123") { + return { + messageId, + channelId, + receipt: createDiscordSendReceipt({ platformMessageIds: [messageId], channelId, kind: "text" }), + }; +} + vi.mock("openclaw/plugin-sdk/runtime-env", async () => { const actual = await vi.importActual( "openclaw/plugin-sdk/runtime-env", @@ -250,8 +259,8 @@ describe("discordPlugin outbound", () => { it("splits text and video into separate sends for attached outbound delivery", async () => { const sendMessageDiscord = vi .fn() - .mockResolvedValueOnce({ messageId: "text-1" }) - .mockResolvedValueOnce({ messageId: "video-1" }); + .mockResolvedValueOnce(discordTestSendResult("text-1")) + .mockResolvedValueOnce(discordTestSendResult("video-1")); const result = await discordPlugin.outbound!.sendMedia!({ cfg: EMPTY_DISCORD_TEST_CONFIG, @@ -287,10 +296,7 @@ describe("discordPlugin outbound", () => { }); it("threads poll sends through the thread target", async () => { - const sendPollDiscord = vi.fn(async () => ({ - channelId: "channel:thread-123", - messageId: "poll-1", - })); + const sendPollDiscord = vi.fn(async () => discordTestSendResult("poll-1")); const sendPollSpy = vi.spyOn(sendModule, "sendPollDiscord").mockImplementation(sendPollDiscord); try { const result = await discordPlugin.outbound!.sendPoll!({ diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index 682d8350e49..94d1509233b 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -8,6 +8,7 @@ import type { ChannelMessageToolDiscovery, } from "openclaw/plugin-sdk/channel-contract"; import { createChatChannelPlugin } from "openclaw/plugin-sdk/channel-core"; +import { createChannelMessageAdapterFromOutbound } from "openclaw/plugin-sdk/channel-message"; import { createPairingPrefixStripper } from "openclaw/plugin-sdk/channel-pairing"; import { createChannelDirectoryAdapter, @@ -81,6 +82,24 @@ import { parseDiscordTarget } from "./target-parsing.js"; const REQUIRED_DISCORD_PERMISSIONS = ["ViewChannel", "SendMessages"] as const; const DISCORD_ACCOUNT_STARTUP_STAGGER_MS = 10_000; +const discordMessageAdapter = createChannelMessageAdapterFromOutbound({ + id: "discord", + outbound: discordOutbound, + live: { + capabilities: { + draftPreview: true, + previewFinalization: true, + progressUpdates: true, + }, + finalizer: { + capabilities: { + finalEdit: true, + normalFallback: true, + discardPending: true, + }, + }, + }, +}); function startDiscordStartupProbe(params: { accountId: string; @@ -180,6 +199,12 @@ const discordMessageActions = { resolveRuntimeDiscordMessageActions()?.extractToolSend?.(ctx) ?? discordMessageActionsImpl.extractToolSend?.(ctx) ?? null, + prepareSendPayload: ( + ctx: Parameters>[0], + ) => + resolveRuntimeDiscordMessageActions()?.prepareSendPayload?.(ctx) ?? + discordMessageActionsImpl.prepareSendPayload?.(ctx) ?? + null, handleAction: async ( ctx: Parameters>[0], ) => { @@ -315,6 +340,7 @@ export const discordPlugin: ChannelPlugin listGroupsLive: (runtime) => runtime.listDiscordDirectoryGroupsLive, }), }), + message: discordMessageAdapter, resolver: { resolveTargets: async ({ cfg, accountId, inputs, kind }) => { const account = resolveDiscordAccount({ cfg, accountId }); diff --git a/extensions/discord/src/monitor/agent-components.dispatch.ts b/extensions/discord/src/monitor/agent-components.dispatch.ts index c2fa90b690b..130869be8ab 100644 --- a/extensions/discord/src/monitor/agent-components.dispatch.ts +++ b/extensions/discord/src/monitor/agent-components.dispatch.ts @@ -37,7 +37,7 @@ import { deliverDiscordReply } from "./reply-delivery.js"; let conversationRuntimePromise: Promise | undefined; let replyPipelineRuntimePromise: - | Promise + | Promise | undefined; let typingRuntimePromise: Promise | undefined; @@ -47,7 +47,7 @@ async function loadConversationRuntime() { } async function loadReplyPipelineRuntime() { - replyPipelineRuntimePromise ??= import("openclaw/plugin-sdk/channel-reply-pipeline"); + replyPipelineRuntimePromise ??= import("openclaw/plugin-sdk/channel-message"); return await replyPipelineRuntimePromise; } @@ -241,8 +241,8 @@ export async function dispatchDiscordComponentEvent(params: { const deliverTarget = `channel:${interactionCtx.channelId}`; const typingChannelId = interactionCtx.channelId; - const { createChannelReplyPipeline } = await loadReplyPipelineRuntime(); - const { onModelSelected, ...replyPipeline } = createChannelReplyPipeline({ + const { createChannelMessageReplyPipeline } = await loadReplyPipelineRuntime(); + const { onModelSelected, ...replyPipeline } = createChannelMessageReplyPipeline({ cfg: ctx.cfg, agentId, channel: "discord", diff --git a/extensions/discord/src/monitor/message-handler.process.ts b/extensions/discord/src/monitor/message-handler.process.ts index 202720005c7..4b8373b0c08 100644 --- a/extensions/discord/src/monitor/message-handler.process.ts +++ b/extensions/discord/src/monitor/message-handler.process.ts @@ -6,11 +6,12 @@ import { logTypingFailure, shouldAckReaction as shouldAckReactionGate, } from "openclaw/plugin-sdk/channel-feedback"; -import { deliverFinalizableDraftPreview } from "openclaw/plugin-sdk/channel-lifecycle"; import { - createChannelReplyPipeline, - resolveChannelSourceReplyDeliveryMode, -} from "openclaw/plugin-sdk/channel-reply-pipeline"; + createChannelMessageReplyPipeline, + defineFinalizableLivePreviewAdapter, + deliverWithFinalizableLivePreviewAdapter, + resolveChannelMessageSourceReplyDeliveryMode, +} from "openclaw/plugin-sdk/channel-message"; import { formatChannelProgressDraftLine, formatChannelProgressDraftLineForEntry, @@ -173,7 +174,7 @@ export async function processDiscordMessage( } const { createReplyDispatcherWithTyping, dispatchInboundMessage, settleReplyDispatcher } = await loadReplyRuntime(); - const sourceReplyDeliveryMode = resolveChannelSourceReplyDeliveryMode({ + const sourceReplyDeliveryMode = resolveChannelMessageSourceReplyDeliveryMode({ cfg, ctx: { ChatType: isGuildMessage ? "channel" : undefined }, }); @@ -364,7 +365,7 @@ export async function processDiscordMessage( ? deliverTarget.slice("channel:".length) : messageChannelId; - const { onModelSelected, ...replyPipeline } = createChannelReplyPipeline({ + const { onModelSelected, ...replyPipeline } = createChannelMessageReplyPipeline({ cfg, agentId: route.agentId, channel: "discord", @@ -455,39 +456,51 @@ export async function processDiscordMessage( Boolean(payload.replyToTag || payload.replyToCurrent) || (typeof finalText === "string" && /\[\[\s*reply_to(?:_current|\s*:)/i.test(finalText)); - const result = await deliverFinalizableDraftPreview({ + const result = await deliverWithFinalizableLivePreviewAdapter({ kind: info.kind, payload, - draft: { - flush: () => draftPreview.flush(), - clear: () => draftStream.clear(), - discardPending: () => draftStream.discardPending(), - seal: () => draftStream.seal(), - id: draftStream.messageId, - }, - buildFinalEdit: () => { - if ( - draftPreview.finalizedViaPreviewMessage || - hasMedia || - typeof previewFinalText !== "string" || - hasExplicitReplyDirective || - payload.isError - ) { - return undefined; - } - return { content: previewFinalText }; - }, - editFinal: async (previewMessageId, edit) => { - if (isProcessAborted(abortSignal)) { - throw new Error("process aborted"); - } - notifyFinalReplyStart(); - await editMessageDiscord(deliverChannelId, previewMessageId, edit, { - cfg, - accountId, - rest: deliveryRest, - }); - }, + adapter: defineFinalizableLivePreviewAdapter({ + draft: { + flush: () => draftPreview.flush(), + clear: () => draftStream.clear(), + discardPending: () => draftStream.discardPending(), + seal: () => draftStream.seal(), + id: draftStream.messageId, + }, + buildFinalEdit: () => { + if ( + draftPreview.finalizedViaPreviewMessage || + hasMedia || + typeof previewFinalText !== "string" || + hasExplicitReplyDirective || + payload.isError + ) { + return undefined; + } + return { content: previewFinalText }; + }, + editFinal: async (previewMessageId, edit) => { + if (isProcessAborted(abortSignal)) { + throw new Error("process aborted"); + } + notifyFinalReplyStart(); + await editMessageDiscord(deliverChannelId, previewMessageId, edit, { + cfg, + accountId, + rest: deliveryRest, + }); + }, + onPreviewFinalized: () => { + draftPreview.markPreviewFinalized(); + replyReference.markSent(); + observer?.onFinalReplyDelivered?.(); + }, + logPreviewEditFailure: (err) => { + logVerbose( + `discord: preview final edit failed; falling back to standard send (${String(err)})`, + ); + }, + }), deliverNormally: async () => { if (isProcessAborted(abortSignal)) { return false; @@ -516,18 +529,8 @@ export async function processDiscordMessage( observer?.onFinalReplyDelivered?.(); return true; }, - onPreviewFinalized: () => { - draftPreview.markPreviewFinalized(); - replyReference.markSent(); - observer?.onFinalReplyDelivered?.(); - }, - logPreviewEditFailure: (err) => { - logVerbose( - `discord: preview final edit failed; falling back to standard send (${String(err)})`, - ); - }, }); - if (result !== "normal-skipped") { + if (result.kind !== "normal-skipped") { return; } } diff --git a/extensions/discord/src/monitor/monitor.test.ts b/extensions/discord/src/monitor/monitor.test.ts index daca2befb9a..52c4afbfc26 100644 --- a/extensions/discord/src/monitor/monitor.test.ts +++ b/extensions/discord/src/monitor/monitor.test.ts @@ -9,6 +9,7 @@ import type { ModalInteraction, StringSelectMenuInteraction, } from "../internal/discord.js"; +import { createDiscordSendReceipt } from "../send.receipt.js"; import { dispatchPluginInteractiveHandlerMock, dispatchReplyMock, @@ -50,6 +51,14 @@ function getLastRecordedCtx(): Record | undefined { return params?.ctx; } +function discordTestSendResult(messageId: string, channelId = "dm-channel") { + return { + messageId, + channelId, + receipt: createDiscordSendReceipt({ platformMessageIds: [messageId], channelId, kind: "card" }), + }; +} + describe("discord component interactions", () => { let editDiscordComponentMessageMock: ReturnType; const createCfg = (): OpenClawConfig => @@ -254,10 +263,7 @@ describe("discord component interactions", () => { beforeEach(() => { editDiscordComponentMessageMock = vi .spyOn(sendComponents, "editDiscordComponentMessage") - .mockResolvedValue({ - messageId: "msg-1", - channelId: "dm-channel", - }); + .mockResolvedValue(discordTestSendResult("msg-1")); clearDiscordComponentEntries(); resetDiscordComponentRuntimeMocks(); lastDispatchCtx = undefined; diff --git a/extensions/discord/src/monitor/native-command-agent-reply.ts b/extensions/discord/src/monitor/native-command-agent-reply.ts index bfdb9e4294a..68085d4869a 100644 --- a/extensions/discord/src/monitor/native-command-agent-reply.ts +++ b/extensions/discord/src/monitor/native-command-agent-reply.ts @@ -1,5 +1,5 @@ import { resolveHumanDelayConfig } from "openclaw/plugin-sdk/agent-runtime"; -import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline"; +import { createChannelMessageReplyPipeline } from "openclaw/plugin-sdk/channel-message"; import { resolveChannelStreamingBlockEnabled } from "openclaw/plugin-sdk/channel-streaming"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime"; @@ -42,7 +42,7 @@ export async function dispatchDiscordNativeAgentReply(params: { suppressReplies?: boolean; log: ReturnType; }): Promise { - const { onModelSelected, ...replyPipeline } = createChannelReplyPipeline({ + const { onModelSelected, ...replyPipeline } = createChannelMessageReplyPipeline({ cfg: params.cfg, agentId: params.effectiveRoute.agentId, channel: "discord", diff --git a/extensions/discord/src/monitor/thread-bindings.discord-api.test.ts b/extensions/discord/src/monitor/thread-bindings.discord-api.test.ts index e41a3aaff09..0fa433d6a90 100644 --- a/extensions/discord/src/monitor/thread-bindings.discord-api.test.ts +++ b/extensions/discord/src/monitor/thread-bindings.discord-api.test.ts @@ -3,12 +3,18 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import * as discordClientModule from "../client.js"; import * as discordSendModule from "../send.js"; +import { createDiscordSendReceipt } from "../send.receipt.js"; import { EMPTY_DISCORD_TEST_CONFIG } from "../test-support/config.js"; import type { ThreadBindingRecord } from "./thread-bindings.types.js"; const DEFAULT_SEND_RESULT = { messageId: "msg-1", channelId: "thread-1", + receipt: createDiscordSendReceipt({ + platformMessageIds: ["msg-1"], + channelId: "thread-1", + kind: "text", + }), }; const restGet = vi.fn<(...args: unknown[]) => Promise>(); diff --git a/extensions/discord/src/outbound-adapter.test.ts b/extensions/discord/src/outbound-adapter.test.ts index 3acd7df3ff4..96719ca5502 100644 --- a/extensions/discord/src/outbound-adapter.test.ts +++ b/extensions/discord/src/outbound-adapter.test.ts @@ -541,6 +541,38 @@ describe("discordOutbound", () => { ).toBe("reply-1"); }); + it("sends prepared native Discord payload data through outbound delivery", async () => { + await discordOutbound.sendPayload?.({ + cfg: {}, + to: "channel:123456", + text: "", + payload: { + text: "hello", + mediaUrl: "https://example.com/photo.png", + channelData: { + discord: { + components: [{ type: 1, components: [] }], + filename: "photo.png", + }, + }, + }, + accountId: "default", + replyToId: "reply-1", + }); + + expect(hoisted.sendMessageDiscordMock).toHaveBeenCalledWith( + "channel:123456", + "hello", + expect.objectContaining({ + mediaUrl: "https://example.com/photo.png", + components: [{ type: 1, components: [] }], + filename: "photo.png", + accountId: "default", + replyTo: "reply-1", + }), + ); + }); + it("preserves explicit component payload replies when replyToMode is off", async () => { const payload = await discordOutbound.renderPresentation?.({ payload: { diff --git a/extensions/discord/src/outbound-adapter.ts b/extensions/discord/src/outbound-adapter.ts index eb25dc91605..c7d9cf3179b 100644 --- a/extensions/discord/src/outbound-adapter.ts +++ b/extensions/discord/src/outbound-adapter.ts @@ -120,6 +120,17 @@ export const discordOutbound: ChannelOutboundAdapter = { context: true, divider: true, }, + deliveryCapabilities: { + durableFinal: { + text: true, + media: true, + payload: true, + silent: true, + replyTo: true, + thread: true, + messageSendingHooks: true, + }, + }, renderPresentation: async ({ payload, presentation }) => { return await buildDiscordPresentationPayload({ payload, diff --git a/extensions/discord/src/outbound-components.ts b/extensions/discord/src/outbound-components.ts index ab5f3b3d20a..dd6e5a0c8b3 100644 --- a/extensions/discord/src/outbound-components.ts +++ b/extensions/discord/src/outbound-components.ts @@ -67,7 +67,12 @@ export async function resolveDiscordComponentSpec( | { components?: unknown; presentationComponents?: DiscordComponentMessageSpec } | undefined; const rawComponentSpec = - discordData?.presentationComponents ?? readDiscordComponentSpec(discordData?.components); + discordData?.presentationComponents ?? + (discordData?.components && + typeof discordData.components === "object" && + !Array.isArray(discordData.components) + ? readDiscordComponentSpec(discordData.components) + : null); if (rawComponentSpec) { return addPayloadTextFallback(rawComponentSpec, payload); } diff --git a/extensions/discord/src/outbound-payload.ts b/extensions/discord/src/outbound-payload.ts index c8a0dec529f..20a86616172 100644 --- a/extensions/discord/src/outbound-payload.ts +++ b/extensions/discord/src/outbound-payload.ts @@ -7,12 +7,15 @@ import { sendPayloadMediaSequenceOrFallback, sendTextMediaPayload, } from "openclaw/plugin-sdk/reply-payload"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; import { normalizeDiscordApprovalPayload } from "./outbound-approval.js"; import { resolveDiscordComponentSpec, sendDiscordComponentMessageLazy, } from "./outbound-components.js"; import { createDiscordPayloadSendContext } from "./outbound-send-context.js"; +import { createDiscordSendReceipt } from "./send.receipt.js"; +import type { DiscordSendComponents, DiscordSendEmbeds } from "./send.shared.js"; export async function sendDiscordOutboundPayload(params: { ctx: Parameters>[0]; @@ -71,6 +74,69 @@ export async function sendDiscordOutboundPayload(params: { const componentSpec = await resolveDiscordComponentSpec(payload); if (!componentSpec) { + const discordData = + payload.channelData?.discord && + typeof payload.channelData.discord === "object" && + !Array.isArray(payload.channelData.discord) + ? (payload.channelData.discord as Record) + : {}; + const nativeComponents = Array.isArray(discordData.components) + ? (discordData.components as DiscordSendComponents) + : undefined; + const embeds = Array.isArray(discordData.embeds) + ? (discordData.embeds as DiscordSendEmbeds) + : undefined; + const filename = normalizeOptionalString(discordData.filename); + if (nativeComponents || embeds?.length || filename) { + const result = await sendPayloadMediaSequenceOrFallback({ + text: payload.text ?? "", + mediaUrls, + fallbackResult: { + messageId: "", + channelId: sendContext.target, + receipt: createDiscordSendReceipt({ + platformMessageIds: [], + channelId: sendContext.target, + kind: "unknown", + }), + }, + sendNoMedia: async () => + await sendContext.withRetry( + async () => + await sendContext.send(sendContext.target, payload.text ?? "", { + verbose: false, + components: nativeComponents, + embeds, + filename, + replyTo: sendContext.resolveReplyTo(), + accountId: ctx.accountId ?? undefined, + silent: ctx.silent ?? undefined, + cfg: ctx.cfg, + ...sendContext.formatting, + }), + ), + send: async ({ text, mediaUrl, isFirst }) => + await sendContext.withRetry( + async () => + await sendContext.send(sendContext.target, text, { + verbose: false, + mediaUrl, + mediaAccess: ctx.mediaAccess, + mediaLocalRoots: ctx.mediaLocalRoots, + mediaReadFile: ctx.mediaReadFile, + components: isFirst ? nativeComponents : undefined, + embeds: isFirst ? embeds : undefined, + filename: isFirst ? filename : undefined, + replyTo: sendContext.resolveReplyTo(), + accountId: ctx.accountId ?? undefined, + silent: ctx.silent ?? undefined, + cfg: ctx.cfg, + ...sendContext.formatting, + }), + ), + }); + return attachChannelToResult("discord", result); + } return await sendTextMediaPayload({ channel: "discord", ctx: { @@ -84,7 +150,15 @@ export async function sendDiscordOutboundPayload(params: { const result = await sendPayloadMediaSequenceOrFallback({ text: payload.text ?? "", mediaUrls, - fallbackResult: { messageId: "", channelId: sendContext.target }, + fallbackResult: { + messageId: "", + channelId: sendContext.target, + receipt: createDiscordSendReceipt({ + platformMessageIds: [], + channelId: sendContext.target, + kind: "unknown", + }), + }, sendNoMedia: async () => await sendContext.withRetry( async () => diff --git a/extensions/discord/src/send.components.ts b/extensions/discord/src/send.components.ts index b85f04b7488..5f4544bdabc 100644 --- a/extensions/discord/src/send.components.ts +++ b/extensions/discord/src/send.components.ts @@ -24,6 +24,7 @@ import { import { parseAndResolveRecipient } from "./recipient-resolution.js"; import { loadOutboundMediaFromUrl } from "./runtime-api.js"; import { sendMessageDiscord } from "./send.outbound.js"; +import { createDiscordSendResult } from "./send.receipt.js"; import { buildDiscordSendError, createDiscordClient, @@ -321,10 +322,12 @@ export async function sendDiscordComponentMessage( direction: "outbound", }); - return { - messageId: result.id ?? "unknown", - channelId: result.channel_id ?? channelId, - }; + return createDiscordSendResult({ + result, + fallbackChannelId: channelId, + kind: "card", + ...(opts.replyTo ? { replyToId: opts.replyTo } : {}), + }); } export async function editDiscordComponentMessage( @@ -374,8 +377,13 @@ export async function editDiscordComponentMessage( direction: "outbound", }); - return { - messageId: result.id ?? messageId, - channelId: result.channel_id ?? channelId, - }; + return createDiscordSendResult({ + result: { + id: result.id ?? messageId, + channel_id: result.channel_id, + }, + fallbackChannelId: channelId, + kind: "card", + ...(opts.replyTo ? { replyToId: opts.replyTo } : {}), + }); } diff --git a/extensions/discord/src/send.creates-thread.test.ts b/extensions/discord/src/send.creates-thread.test.ts index c6784d50691..ed08cb03a8c 100644 --- a/extensions/discord/src/send.creates-thread.test.ts +++ b/extensions/discord/src/send.creates-thread.test.ts @@ -434,7 +434,10 @@ describe("sendStickerDiscord", () => { token: "t", content: "hiya", }); - expect(res).toEqual({ messageId: "msg1", channelId: "789" }); + expect(res).toMatchObject({ messageId: "msg1", channelId: "789" }); + expect(res.receipt.parts[0]).toEqual( + expect.objectContaining({ platformMessageId: "msg1", kind: "card" }), + ); expect(postMock).toHaveBeenCalledWith( Routes.channelMessages("789"), expect.objectContaining({ @@ -467,7 +470,10 @@ describe("sendPollDiscord", () => { token: "t", }, ); - expect(res).toEqual({ messageId: "msg1", channelId: "789" }); + expect(res).toMatchObject({ messageId: "msg1", channelId: "789" }); + expect(res.receipt.parts[0]).toEqual( + expect.objectContaining({ platformMessageId: "msg1", kind: "card" }), + ); expect(postMock).toHaveBeenCalledWith( Routes.channelMessages("789"), expect.objectContaining({ @@ -548,9 +554,13 @@ describe("retry rate limits", () => { retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 1000, jitter: 0 }, }); - await expect(promise).resolves.toEqual({ + await expect(promise).resolves.toMatchObject({ messageId: "msg1", channelId: "789", + receipt: expect.objectContaining({ + primaryPlatformMessageId: "msg1", + platformMessageIds: ["msg1"], + }), }); expect(setTimeoutSpy.mock.calls[0]?.[1]).toBe(1); } finally { @@ -598,7 +608,8 @@ describe("retry rate limits", () => { retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 }, }); - expect(result).toEqual({ messageId: "msg1", channelId: "789" }); + expect(result).toMatchObject({ messageId: "msg1", channelId: "789" }); + expect(result.receipt.platformMessageIds).toEqual(["msg1"]); expect(postMock).toHaveBeenCalledTimes(2); }); diff --git a/extensions/discord/src/send.outbound.ts b/extensions/discord/src/send.outbound.ts index fabcde6a425..0b59cc399e7 100644 --- a/extensions/discord/src/send.outbound.ts +++ b/extensions/discord/src/send.outbound.ts @@ -11,6 +11,7 @@ import { resolveDiscordAccount } from "./accounts.js"; import { createChannelMessage, createThread, type RequestClient } from "./internal/discord.js"; import { rewriteDiscordKnownMentions } from "./mentions.js"; import { parseAndResolveRecipient } from "./recipient-resolution.js"; +import { createDiscordSendResult, type DiscordReceiptResultSource } from "./send.receipt.js"; import { buildDiscordMessageRequest, buildDiscordSendError, @@ -55,10 +56,7 @@ type DiscordClientRequest = ReturnType["request"]; const DEFAULT_DISCORD_MEDIA_MAX_MB = 100; -type DiscordChannelMessageResult = { - id?: string | null; - channel_id?: string | null; -}; +type DiscordChannelMessageResult = DiscordReceiptResultSource; async function sendDiscordThreadTextChunks(params: { rest: RequestClient; @@ -105,11 +103,24 @@ function isForumLikeType(channelType?: number): boolean { function toDiscordSendResult( result: DiscordChannelMessageResult, fallbackChannelId: string, + params: { + kind?: Parameters[0]["kind"]; + threadId?: string | number; + replyToId?: string; + } = {}, ): DiscordSendResult { - return { - messageId: result.id || "unknown", - channelId: result.channel_id ?? fallbackChannelId, + const resultParams: Parameters[0] = { + result, + fallbackChannelId, + kind: params.kind ?? "text", }; + if (params.threadId != null) { + resultParams.threadId = params.threadId; + } + if (params.replyToId) { + resultParams.replyToId = params.replyToId; + } + return createDiscordSendResult(resultParams); } async function resolveDiscordSendTarget( @@ -278,10 +289,11 @@ export async function sendMessageDiscord( channel_id: resultChannelId, }, channelId, + { kind: opts.mediaUrl ? "media" : "text", threadId }, ); } - let result: { id: string; channel_id: string } | { id: string | null; channel_id: string }; + let result: DiscordChannelMessageResult; try { if (opts.mediaUrl) { result = await sendDiscordMedia( @@ -333,7 +345,10 @@ export async function sendMessageDiscord( accountId: accountInfo.accountId, direction: "outbound", }); - return toDiscordSendResult(result, channelId); + return toDiscordSendResult(result, channelId, { + kind: opts.mediaUrl ? "media" : opts.components || opts.embeds ? "card" : "text", + replyToId: opts.replyTo, + }); } export async function sendStickerDiscord( @@ -356,7 +371,7 @@ export async function sendStickerDiscord( }), "sticker", )) as { id: string; channel_id: string }; - return toDiscordSendResult(res, channelId); + return toDiscordSendResult(res, channelId, { kind: "card" }); } export async function sendPollDiscord( @@ -384,7 +399,7 @@ export async function sendPollDiscord( }), "poll", )) as { id: string; channel_id: string }; - return toDiscordSendResult(res, channelId); + return toDiscordSendResult(res, channelId, { kind: "card" }); } async function resolveDiscordStructuredSendContext( diff --git a/extensions/discord/src/send.receipt.ts b/extensions/discord/src/send.receipt.ts new file mode 100644 index 00000000000..a7c071e6323 --- /dev/null +++ b/extensions/discord/src/send.receipt.ts @@ -0,0 +1,69 @@ +import { + createMessageReceiptFromOutboundResults, + type MessageReceipt, + type MessageReceiptPartKind, + type MessageReceiptSourceResult, +} from "openclaw/plugin-sdk/channel-message"; +import type { DiscordSendResult } from "./send.types.js"; + +export type DiscordReceiptResultSource = { + id?: string | null; + channel_id?: string | null; + platformMessageIds?: readonly string[]; +}; + +export function createDiscordSendReceipt(params: { + platformMessageIds: readonly string[]; + channelId?: string; + kind: MessageReceiptPartKind; + threadId?: string; + replyToId?: string; +}): MessageReceipt { + const platformMessageIds = params.platformMessageIds + .map((messageId) => messageId.trim()) + .filter((messageId) => messageId && messageId !== "unknown"); + return createMessageReceiptFromOutboundResults({ + results: platformMessageIds.map((messageId) => { + const result: MessageReceiptSourceResult = { + channel: "discord", + messageId, + }; + if (params.channelId) { + result.channelId = params.channelId; + } + return result; + }), + kind: params.kind, + threadId: params.threadId, + replyToId: params.replyToId, + }); +} + +export function createDiscordSendResult(params: { + result: DiscordReceiptResultSource; + fallbackChannelId: string; + kind: MessageReceiptPartKind; + threadId?: string | number; + replyToId?: string; +}): DiscordSendResult { + const messageId = params.result.id || "unknown"; + const channelId = params.result.channel_id ?? params.fallbackChannelId; + const receiptParams: Parameters[0] = { + platformMessageIds: params.result.platformMessageIds?.length + ? params.result.platformMessageIds + : [messageId], + channelId, + kind: params.kind, + }; + if (params.threadId != null) { + receiptParams.threadId = String(params.threadId); + } + if (params.replyToId) { + receiptParams.replyToId = params.replyToId; + } + return { + messageId, + channelId, + receipt: createDiscordSendReceipt(receiptParams), + }; +} diff --git a/extensions/discord/src/send.sends-basic-channel-messages.test.ts b/extensions/discord/src/send.sends-basic-channel-messages.test.ts index f82e88e7a35..ee63aa66b1b 100644 --- a/extensions/discord/src/send.sends-basic-channel-messages.test.ts +++ b/extensions/discord/src/send.sends-basic-channel-messages.test.ts @@ -139,7 +139,12 @@ describe("sendMessageDiscord", () => { token: "t", cfg: DISCORD_TEST_CFG, }); - expect(res).toEqual({ messageId: "msg1", channelId: "789" }); + expect(res).toMatchObject({ messageId: "msg1", channelId: "789" }); + expect(res.receipt).toMatchObject({ + primaryPlatformMessageId: "msg1", + platformMessageIds: ["msg1"], + parts: [expect.objectContaining({ platformMessageId: "msg1", kind: "text" })], + }); expect(postMock).toHaveBeenCalledWith( Routes.channelMessages("789"), expect.objectContaining({ body: { content: "hello world" } }), @@ -245,7 +250,12 @@ describe("sendMessageDiscord", () => { token: "t", cfg: DISCORD_TEST_CFG, }); - expect(res).toEqual({ messageId: "starter1", channelId: "thread1" }); + expect(res).toMatchObject({ messageId: "starter1", channelId: "thread1" }); + expect(res.receipt).toMatchObject({ + threadId: "thread1", + platformMessageIds: ["starter1"], + parts: [expect.objectContaining({ platformMessageId: "starter1", kind: "text" })], + }); // Should POST to threads route, not channelMessages. expect(postMock).toHaveBeenCalledWith( Routes.threads("forum1"), @@ -266,7 +276,12 @@ describe("sendMessageDiscord", () => { cfg: DISCORD_TEST_CFG, mediaUrl: "file:///tmp/photo.jpg", }); - expect(res).toEqual({ messageId: "starter1", channelId: "thread1" }); + expect(res).toMatchObject({ messageId: "starter1", channelId: "thread1" }); + expect(res.receipt).toMatchObject({ + threadId: "thread1", + platformMessageIds: ["starter1"], + parts: [expect.objectContaining({ platformMessageId: "starter1", kind: "media" })], + }); expect(postMock).toHaveBeenNthCalledWith( 1, Routes.threads("forum1"), diff --git a/extensions/discord/src/send.shared.ts b/extensions/discord/src/send.shared.ts index a3e5cff2e7e..247f5563bbc 100644 --- a/extensions/discord/src/send.shared.ts +++ b/extensions/discord/src/send.shared.ts @@ -328,16 +328,21 @@ async function sendDiscordText( )) as { id: string; channel_id: string }; }; if (chunks.length === 1) { - return await sendChunk(chunks[0], true); + const result = await sendChunk(chunks[0], true); + return { ...result, platformMessageIds: result.id ? [result.id] : [] }; } + const platformMessageIds: string[] = []; let last: { id: string; channel_id: string } | null = null; for (const [index, chunk] of chunks.entries()) { last = await sendChunk(chunk, index === 0); + if (last.id) { + platformMessageIds.push(last.id); + } } if (!last) { throw new Error("Discord send failed (empty chunk result)"); } - return last; + return { ...last, platformMessageIds }; } async function sendDiscordMedia( @@ -398,11 +403,12 @@ async function sendDiscordMedia( () => createChannelMessage<{ id: string; channel_id: string }>(rest, channelId, { body }), "media", )) as { id: string; channel_id: string }; + const platformMessageIds = res.id ? [res.id] : []; for (const chunk of chunks.slice(1)) { if (!chunk.trim()) { continue; } - await sendDiscordText( + const followup = await sendDiscordText( rest, channelId, chunk, @@ -415,8 +421,13 @@ async function sendDiscordMedia( silent, maxChars, ); + for (const id of followup.platformMessageIds) { + if (id) { + platformMessageIds.push(id); + } + } } - return res; + return { ...res, platformMessageIds }; } function buildReactionIdentifier(emoji: { id?: string | null; name?: string | null }) { diff --git a/extensions/discord/src/send.types.ts b/extensions/discord/src/send.types.ts index 2dca6661947..133a4e5b363 100644 --- a/extensions/discord/src/send.types.ts +++ b/extensions/discord/src/send.types.ts @@ -1,3 +1,4 @@ +import type { MessageReceipt } from "openclaw/plugin-sdk/channel-message"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; import type { RetryConfig } from "openclaw/plugin-sdk/retry-runtime"; import type { RequestClient } from "./internal/discord.js"; @@ -29,6 +30,7 @@ export const DISCORD_MAX_EVENT_COVER_BYTES = 8 * 1024 * 1024; export type DiscordSendResult = { messageId: string; channelId: string; + receipt: MessageReceipt; }; export type DiscordRuntimeAccountContext = { diff --git a/extensions/discord/src/send.voice.ts b/extensions/discord/src/send.voice.ts index e74d264c725..34f63373e16 100644 --- a/extensions/discord/src/send.voice.ts +++ b/extensions/discord/src/send.voice.ts @@ -15,6 +15,7 @@ import { loadWebMediaRaw } from "openclaw/plugin-sdk/web-media"; import { resolveDiscordAccount } from "./accounts.js"; import type { RequestClient } from "./internal/discord.js"; import { parseAndResolveRecipient } from "./recipient-resolution.js"; +import { createDiscordSendResult } from "./send.receipt.js"; import { buildDiscordSendError, createDiscordClient, resolveChannelId } from "./send.shared.js"; import type { DiscordSendResult } from "./send.types.js"; import { @@ -38,10 +39,11 @@ function toDiscordSendResult( result: { id?: string | null; channel_id?: string | null }, fallbackChannelId: string, ): DiscordSendResult { - return { - messageId: result.id || "unknown", - channelId: result.channel_id ?? fallbackChannelId, - }; + return createDiscordSendResult({ + result, + fallbackChannelId, + kind: "voice", + }); } async function materializeVoiceMessageInput(mediaUrl: string): Promise<{ filePath: string }> { diff --git a/extensions/discord/src/send.webhook-activity.test.ts b/extensions/discord/src/send.webhook-activity.test.ts index 233ea676c05..91b9c66feae 100644 --- a/extensions/discord/src/send.webhook-activity.test.ts +++ b/extensions/discord/src/send.webhook-activity.test.ts @@ -64,9 +64,13 @@ describe("sendWebhookMessageDiscord activity", () => { threadId: "thread-1", }); - expect(result).toEqual({ + expect(result).toMatchObject({ messageId: "msg-1", channelId: "thread-1", + receipt: expect.objectContaining({ + threadId: "thread-1", + platformMessageIds: ["msg-1"], + }), }); expect(recordChannelActivityMock).toHaveBeenCalledWith({ channel: "discord", diff --git a/extensions/discord/src/send.webhook.ts b/extensions/discord/src/send.webhook.ts index 829e7790c67..4f36c4ad855 100644 --- a/extensions/discord/src/send.webhook.ts +++ b/extensions/discord/src/send.webhook.ts @@ -10,6 +10,7 @@ import { readRetryAfter, } from "./internal/rest-errors.js"; import { rewriteDiscordKnownMentions } from "./mentions.js"; +import { createDiscordSendResult } from "./send.receipt.js"; import type { DiscordSendResult } from "./send.types.js"; type DiscordWebhookSendOpts = { @@ -126,8 +127,11 @@ export async function sendWebhookMessageDiscord( } catch { // Best-effort telemetry only. } - return { - messageId: payload.id || "unknown", - channelId: payload.channel_id ? payload.channel_id : opts.threadId ? String(opts.threadId) : "", - }; + return createDiscordSendResult({ + result: payload, + fallbackChannelId: opts.threadId ? String(opts.threadId) : "", + kind: "text", + ...(opts.threadId != null ? { threadId: opts.threadId } : {}), + ...(replyTo ? { replyToId: replyTo } : {}), + }); } diff --git a/extensions/feishu/runtime-api.ts b/extensions/feishu/runtime-api.ts index e5b844809e1..f88c94e9ce6 100644 --- a/extensions/feishu/runtime-api.ts +++ b/extensions/feishu/runtime-api.ts @@ -33,7 +33,7 @@ export { } from "openclaw/plugin-sdk/channel-status"; export { buildAgentMediaPayload } from "openclaw/plugin-sdk/agent-media-payload"; export { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing"; -export { createReplyPrefixContext } from "openclaw/plugin-sdk/channel-reply-pipeline"; +export { createReplyPrefixContext } from "openclaw/plugin-sdk/channel-message"; export { evaluateSupplementalContextVisibility, filterSupplementalContextItems, diff --git a/extensions/feishu/src/channel.ts b/extensions/feishu/src/channel.ts index 9f358cb33da..eb710836618 100644 --- a/extensions/feishu/src/channel.ts +++ b/extensions/feishu/src/channel.ts @@ -9,6 +9,11 @@ import type { ChannelMessageToolDiscovery, } from "openclaw/plugin-sdk/channel-contract"; import { createChatChannelPlugin } from "openclaw/plugin-sdk/channel-core"; +import { + defineChannelMessageAdapter, + type ChannelMessageSendResult, + type MessageReceiptPartKind, +} from "openclaw/plugin-sdk/channel-message"; import { createPairingPrefixStripper } from "openclaw/plugin-sdk/channel-pairing"; import { createAllowlistProviderGroupPolicyWarningCollector, @@ -66,6 +71,7 @@ import { messageActionTargetAliases } from "./message-action-contract.js"; import { resolveFeishuGroupToolPolicy } from "./policy.js"; import { collectRuntimeConfigAssignments, secretTargetRegistryEntries } from "./secret-contract.js"; import { collectFeishuSecurityAuditFindings } from "./security-audit.js"; +import { createFeishuSendReceipt } from "./send-result.js"; import { resolveFeishuSessionConversation } from "./session-conversation.js"; import { resolveFeishuOutboundSessionRoute } from "./session-route.js"; import { feishuSetupAdapter } from "./setup-core.js"; @@ -131,6 +137,51 @@ const loadFeishuChannelRuntime = createLazyRuntimeNamedExport( "feishuChannelRuntime", ); +function toFeishuMessageSendResult( + result: { messageId?: string; chatId?: string; receipt?: ChannelMessageSendResult["receipt"] }, + kind: MessageReceiptPartKind, +): ChannelMessageSendResult { + const receipt = + result.receipt ?? + createFeishuSendReceipt({ + messageId: result.messageId, + chatId: result.chatId ?? "", + kind, + }); + return { + messageId: result.messageId || receipt.primaryPlatformMessageId, + receipt, + }; +} + +const feishuMessageAdapter = defineChannelMessageAdapter({ + id: "feishu", + durableFinal: { + capabilities: { + text: true, + media: true, + }, + }, + send: { + text: async (ctx) => { + const runtime = await loadFeishuChannelRuntime(); + const sendText = runtime.feishuOutbound.sendText; + if (!sendText) { + throw new Error("Feishu text sending is not available."); + } + return toFeishuMessageSendResult(await sendText(ctx), "text"); + }, + media: async (ctx) => { + const runtime = await loadFeishuChannelRuntime(); + const sendMedia = runtime.feishuOutbound.sendMedia; + if (!sendMedia) { + throw new Error("Feishu media sending is not available."); + } + return toFeishuMessageSendResult(await sendMedia(ctx), "media"); + }, + }, +}); + function buildFeishuPresentationCard(params: { presentation: NonNullable>; fallbackText?: string; @@ -1255,6 +1306,7 @@ export const feishuPlugin: ChannelPlugin ({ })); vi.mock("./send.js", () => ({ + editMessageFeishu: vi.fn(), + getMessageFeishu: vi.fn(), sendCardFeishu: sendCardFeishuMock, sendMessageFeishu: sendMessageFeishuMock, sendMarkdownCardFeishu: sendMarkdownCardFeishuMock, @@ -69,7 +72,9 @@ vi.mock("./comment-reaction.js", () => ({ cleanupAmbientCommentTypingReaction: cleanupAmbientCommentTypingReactionMock, })); +import { feishuPlugin } from "./channel.js"; import { feishuOutbound } from "./outbound.js"; +import { createFeishuSendReceipt } from "./send-result.js"; const sendText = feishuOutbound.sendText!; const emptyConfig: ClawdbotConfig = {}; const cardRenderConfig: ClawdbotConfig = { @@ -99,6 +104,74 @@ describe("feishuOutbound.sendText local-image auto-convert", () => { resetOutboundMocks(); }); + it("declares message adapter durable text and media with receipt proofs", async () => { + sendMessageFeishuMock.mockResolvedValue({ + messageId: "feishu-text-1", + chatId: "chat-1", + receipt: createFeishuSendReceipt({ + messageId: "feishu-text-1", + chatId: "chat-1", + kind: "text", + }), + }); + sendMediaFeishuMock.mockResolvedValue({ + messageId: "feishu-media-1", + chatId: "chat-1", + receipt: createFeishuSendReceipt({ + messageId: "feishu-media-1", + chatId: "chat-1", + kind: "media", + }), + }); + + await expect( + verifyChannelMessageAdapterCapabilityProofs({ + adapterName: "feishu", + adapter: feishuPlugin.message!, + proofs: { + text: async () => { + const result = await feishuPlugin.message?.send?.text?.({ + cfg: emptyConfig, + to: "chat:chat-1", + text: "hello", + accountId: "default", + }); + expect(sendMessageFeishuMock).toHaveBeenCalledWith( + expect.objectContaining({ + to: "chat:chat-1", + text: "hello", + accountId: "default", + }), + ); + expect(result?.receipt.platformMessageIds).toEqual(["feishu-text-1"]); + }, + media: async () => { + const result = await feishuPlugin.message?.send?.media?.({ + cfg: emptyConfig, + to: "chat:chat-1", + text: "", + mediaUrl: "https://example.com/image.png", + accountId: "default", + }); + expect(sendMediaFeishuMock).toHaveBeenCalledWith( + expect.objectContaining({ + to: "chat:chat-1", + mediaUrl: "https://example.com/image.png", + accountId: "default", + }), + ); + expect(result?.receipt.platformMessageIds).toEqual(["feishu-media-1"]); + }, + }, + }), + ).resolves.toEqual( + expect.arrayContaining([ + { capability: "text", status: "verified" }, + { capability: "media", status: "verified" }, + ]), + ); + }); + it("chunks outbound text without requiring Feishu runtime initialization", () => { const chunker = feishuOutbound.chunker; if (!chunker) { diff --git a/extensions/feishu/src/reply-dispatcher.ts b/extensions/feishu/src/reply-dispatcher.ts index d958567c68b..2c11c3e546d 100644 --- a/extensions/feishu/src/reply-dispatcher.ts +++ b/extensions/feishu/src/reply-dispatcher.ts @@ -1,5 +1,5 @@ import { logTypingFailure } from "openclaw/plugin-sdk/channel-feedback"; -import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline"; +import { createChannelMessageReplyPipeline } from "openclaw/plugin-sdk/channel-message"; import { formatChannelProgressDraftLineForEntry, isChannelProgressDraftWorkToolName, @@ -154,7 +154,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP const prefixContext = createReplyPrefixContext({ cfg, agentId }); let typingState: TypingIndicatorState | null = null; - const { typingCallbacks } = createChannelReplyPipeline({ + const { typingCallbacks } = createChannelMessageReplyPipeline({ cfg, agentId, channel: "feishu", diff --git a/extensions/feishu/src/send-result.ts b/extensions/feishu/src/send-result.ts index 996223d34fe..0b718a65df3 100644 --- a/extensions/feishu/src/send-result.ts +++ b/extensions/feishu/src/send-result.ts @@ -1,3 +1,9 @@ +import { + createMessageReceiptFromOutboundResults, + type MessageReceipt, + type MessageReceiptPartKind, +} from "openclaw/plugin-sdk/channel-message"; + type FeishuMessageApiResponse = { code?: number; msg?: string; @@ -6,6 +12,47 @@ type FeishuMessageApiResponse = { }; }; +export function resolveFeishuReceiptKind(msgType?: string): MessageReceiptPartKind { + switch (msgType) { + case "audio": + return "voice"; + case "image": + case "media": + case "file": + return "media"; + case "interactive": + return "card"; + case "post": + case "text": + return "text"; + default: + return "unknown"; + } +} + +export function createFeishuSendReceipt(params: { + messageId?: string; + chatId: string; + kind?: MessageReceiptPartKind; +}): MessageReceipt { + const messageId = params.messageId?.trim(); + const chatId = params.chatId.trim(); + return createMessageReceiptFromOutboundResults({ + results: messageId + ? [ + { + channel: "feishu", + messageId, + chatId, + conversationId: chatId, + }, + ] + : [], + ...(chatId ? { threadId: chatId } : {}), + kind: params.kind ?? "unknown", + }); +} + export function assertFeishuMessageApiSuccess( response: FeishuMessageApiResponse, errorPrefix: string, @@ -18,12 +65,16 @@ export function assertFeishuMessageApiSuccess( export function toFeishuSendResult( response: FeishuMessageApiResponse, chatId: string, + kind?: MessageReceiptPartKind, ): { messageId: string; chatId: string; + receipt: MessageReceipt; } { + const messageId = response.data?.message_id ?? "unknown"; return { - messageId: response.data?.message_id ?? "unknown", + messageId, chatId, + receipt: createFeishuSendReceipt({ messageId, chatId, kind }), }; } diff --git a/extensions/feishu/src/send.test.ts b/extensions/feishu/src/send.test.ts index c6d3940bb1d..b881bff4810 100644 --- a/extensions/feishu/src/send.test.ts +++ b/extensions/feishu/src/send.test.ts @@ -128,7 +128,8 @@ describe("getMessageFeishu", () => { channel: "feishu", }); expect(mockConvertMarkdownTables).toHaveBeenCalledWith("hello", "preserve"); - expect(result).toEqual({ messageId: "om_send", chatId: "oc_send" }); + expect(result).toMatchObject({ messageId: "om_send", chatId: "oc_send" }); + expect(result.receipt.primaryPlatformMessageId).toBe("om_send"); }); it("extracts text content from interactive card elements", async () => { diff --git a/extensions/feishu/src/send.ts b/extensions/feishu/src/send.ts index 293e34414dc..f114d8b9ee9 100644 --- a/extensions/feishu/src/send.ts +++ b/extensions/feishu/src/send.ts @@ -11,7 +11,11 @@ import { createFeishuApiError, requestFeishuApi } from "./comment-shared.js"; import type { MentionTarget } from "./mention-target.types.js"; import { buildMentionedCardContent, buildMentionedMessage } from "./mention.js"; import { parsePostContent } from "./post.js"; -import { assertFeishuMessageApiSuccess, toFeishuSendResult } from "./send-result.js"; +import { + assertFeishuMessageApiSuccess, + resolveFeishuReceiptKind, + toFeishuSendResult, +} from "./send-result.js"; import { resolveFeishuSendTarget } from "./send-target.js"; import type { FeishuChatType, FeishuMessageInfo, FeishuSendResult } from "./types.js"; @@ -132,7 +136,7 @@ async function sendFallbackDirect( { includeNestedErrorLogId: true }, ); assertFeishuMessageApiSuccess(response, errorPrefix); - return toFeishuSendResult(response, params.receiveId); + return toFeishuSendResult(response, params.receiveId, resolveFeishuReceiptKind(params.msgType)); } async function sendReplyOrFallbackDirect( @@ -188,7 +192,11 @@ async function sendReplyOrFallbackDirect( return sendFallbackDirect(client, params.directParams, params.directErrorPrefix); } assertFeishuMessageApiSuccess(response, params.replyErrorPrefix); - return toFeishuSendResult(response, params.directParams.receiveId); + return toFeishuSendResult( + response, + params.directParams.receiveId, + resolveFeishuReceiptKind(params.msgType), + ); } function normalizeCardTemplateVariable(value: unknown): string | undefined { diff --git a/extensions/feishu/src/types.ts b/extensions/feishu/src/types.ts index e23ad07de84..c75ab815b15 100644 --- a/extensions/feishu/src/types.ts +++ b/extensions/feishu/src/types.ts @@ -1,3 +1,4 @@ +import type { MessageReceipt } from "openclaw/plugin-sdk/channel-message"; import type { BaseProbeResult } from "openclaw/plugin-sdk/core"; import type { FeishuConfigSchema, FeishuAccountConfigSchema, z } from "./config-schema.js"; import type { MentionTarget } from "./mention-target.types.js"; @@ -53,6 +54,7 @@ export type FeishuMessageContext = { export type FeishuSendResult = { messageId: string; chatId: string; + receipt: MessageReceipt; }; export type FeishuChatType = "p2p" | "group" | "topic_group" | "private"; diff --git a/extensions/googlechat/runtime-api.ts b/extensions/googlechat/runtime-api.ts index 30c389b766b..66a75940514 100644 --- a/extensions/googlechat/runtime-api.ts +++ b/extensions/googlechat/runtime-api.ts @@ -21,7 +21,7 @@ export { runPassiveAccountLifecycle, } from "openclaw/plugin-sdk/channel-lifecycle"; export { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing"; -export { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline"; +export { createChannelMessageReplyPipeline } from "openclaw/plugin-sdk/channel-message"; export { evaluateGroupRouteAccessForPolicy, resolveDmGroupAccessWithLists, diff --git a/extensions/googlechat/src/channel.adapters.ts b/extensions/googlechat/src/channel.adapters.ts index d05f14c1da2..cdfcfd57760 100644 --- a/extensions/googlechat/src/channel.adapters.ts +++ b/extensions/googlechat/src/channel.adapters.ts @@ -1,4 +1,9 @@ import { adaptScopedAccountAccessor } from "openclaw/plugin-sdk/channel-config-helpers"; +import { + createMessageReceiptFromOutboundResults, + defineChannelMessageAdapter, + type MessageReceiptPartKind, +} from "openclaw/plugin-sdk/channel-message"; import { composeAccountWarningCollectors, createAllowlistProviderOpenWarningCollector, @@ -36,6 +41,28 @@ const loadGoogleChatChannelRuntime = createLazyRuntimeNamedExport( "googleChatChannelRuntime", ); +function createGoogleChatSendReceipt(params: { + messageId?: string; + chatId: string; + kind: MessageReceiptPartKind; +}) { + const messageId = params.messageId?.trim(); + return createMessageReceiptFromOutboundResults({ + results: messageId + ? [ + { + channel: "googlechat", + messageId, + chatId: params.chatId, + conversationId: params.chatId, + }, + ] + : [], + threadId: params.chatId, + kind: params.kind, + }); +} + export const formatAllowFromEntry = (entry: string) => normalizeLowercaseStringOrEmpty( entry @@ -200,9 +227,11 @@ export const googlechatOutboundAdapter = { text, thread, }); + const messageId = result?.messageName ?? ""; return { - messageId: result?.messageName ?? "", + messageId, chatId: space, + receipt: createGoogleChatSendReceipt({ messageId, chatId: space, kind: "text" }), }; }, sendMedia: async ({ @@ -284,10 +313,28 @@ export const googlechatOutboundAdapter = { ] : undefined, }); + const messageId = result?.messageName ?? ""; return { - messageId: result?.messageName ?? "", + messageId, chatId: space, + receipt: createGoogleChatSendReceipt({ messageId, chatId: space, kind: "media" }), }; }, }, }; + +export const googlechatMessageAdapter = defineChannelMessageAdapter({ + id: "googlechat", + durableFinal: { + capabilities: { + text: true, + media: true, + thread: true, + messageSendingHooks: true, + }, + }, + send: { + text: googlechatOutboundAdapter.attachedResults.sendText, + media: googlechatOutboundAdapter.attachedResults.sendMedia, + }, +}); diff --git a/extensions/googlechat/src/channel.test.ts b/extensions/googlechat/src/channel.test.ts index 409944588dd..f97f8cb765b 100644 --- a/extensions/googlechat/src/channel.test.ts +++ b/extensions/googlechat/src/channel.test.ts @@ -1,3 +1,4 @@ +import { verifyChannelMessageAdapterCapabilityProofs } from "openclaw/plugin-sdk/channel-message"; import { createDirectoryTestRuntime, expectDirectorySurface, @@ -6,6 +7,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../runtime-api.js"; import { googlechatDirectoryAdapter, + googlechatMessageAdapter, googlechatOutboundAdapter, googlechatPairingTextAdapter, googlechatSecurityAdapter, @@ -206,6 +208,70 @@ function setupRuntimeMediaMocks(params: { loadFileName: string; loadBytes: strin } describe("googlechatPlugin outbound sendMedia", () => { + it("declares message adapter durable text, media, and thread with receipt proofs", async () => { + sendGoogleChatMessageMock.mockResolvedValue({ + messageName: "spaces/AAA/messages/msg-1", + }); + uploadGoogleChatAttachmentMock.mockResolvedValue({ + attachmentUploadToken: "token-1", + }); + + const cfg = createGoogleChatCfg(); + + await expect( + verifyChannelMessageAdapterCapabilityProofs({ + adapterName: "googlechat", + adapter: googlechatMessageAdapter, + proofs: { + text: async () => { + const result = await googlechatMessageAdapter.send?.text?.({ + cfg, + to: "spaces/AAA", + text: "hello", + }); + expect(result?.receipt.parts[0]?.kind).toBe("text"); + expect(result?.receipt.platformMessageIds).toEqual(["spaces/AAA/messages/msg-1"]); + }, + media: async () => { + const result = await googlechatMessageAdapter.send?.media?.({ + cfg, + to: "spaces/AAA", + text: "image", + mediaUrl: "https://example.com/img.png", + }); + expect(result?.receipt.parts[0]?.kind).toBe("media"); + expect(result?.receipt.platformMessageIds).toEqual(["spaces/AAA/messages/msg-1"]); + }, + thread: async () => { + sendGoogleChatMessageMock.mockClear(); + await googlechatMessageAdapter.send?.text?.({ + cfg, + to: "spaces/AAA", + text: "threaded", + threadId: "thread-1", + }); + expect(sendGoogleChatMessageMock).toHaveBeenCalledWith( + expect.objectContaining({ + space: "spaces/AAA", + thread: "thread-1", + }), + ); + }, + messageSendingHooks: () => { + expect(googlechatMessageAdapter.send?.text).toBeTypeOf("function"); + }, + }, + }), + ).resolves.toEqual( + expect.arrayContaining([ + { capability: "text", status: "verified" }, + { capability: "media", status: "verified" }, + { capability: "thread", status: "verified" }, + { capability: "messageSendingHooks", status: "verified" }, + ]), + ); + }); + it("chunks outbound text without requiring Google Chat runtime initialization", () => { const chunker = googlechatOutboundAdapter.base.chunker; @@ -256,10 +322,11 @@ describe("googlechatPlugin outbound sendMedia", () => { text: "caption", }), ); - expect(result).toEqual({ + expect(result).toMatchObject({ messageId: "spaces/AAA/messages/msg-1", chatId: "spaces/AAA", }); + expect(result.receipt.primaryPlatformMessageId).toBe("spaces/AAA/messages/msg-1"); }); it("keeps remote URL media fetch on fetchRemoteMedia with maxBytes cap", async () => { @@ -305,10 +372,11 @@ describe("googlechatPlugin outbound sendMedia", () => { text: "caption", }), ); - expect(result).toEqual({ + expect(result).toMatchObject({ messageId: "spaces/AAA/messages/msg-2", chatId: "spaces/AAA", }); + expect(result.receipt.primaryPlatformMessageId).toBe("spaces/AAA/messages/msg-2"); }); }); @@ -572,7 +640,7 @@ describe("googlechatPlugin outbound cfg threading", () => { mediaLocalRoots: ["/tmp/workspace"], accountId: "default", }), - ).resolves.toEqual({ + ).resolves.toMatchObject({ messageId: "spaces/AAA/messages/msg-cold", chatId: "spaces/AAA", }); diff --git a/extensions/googlechat/src/channel.ts b/extensions/googlechat/src/channel.ts index 1b1bfa0c951..7235c16112a 100644 --- a/extensions/googlechat/src/channel.ts +++ b/extensions/googlechat/src/channel.ts @@ -17,6 +17,7 @@ import { formatAllowFromEntry, googlechatDirectoryAdapter, googlechatGroupsAdapter, + googlechatMessageAdapter, googlechatOutboundAdapter, googlechatPairingTextAdapter, googlechatSecurityAdapter, @@ -155,6 +156,7 @@ export const googlechatPlugin = createChatChannelPlugin({ }, }, directory: googlechatDirectoryAdapter, + message: googlechatMessageAdapter, resolver: { resolveTargets: async ({ inputs, kind }) => { const resolved = inputs.map((input) => { diff --git a/extensions/googlechat/src/monitor-durable.test.ts b/extensions/googlechat/src/monitor-durable.test.ts new file mode 100644 index 00000000000..62068658bfa --- /dev/null +++ b/extensions/googlechat/src/monitor-durable.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { resolveGoogleChatDurableReplyOptions } from "./monitor-durable.js"; + +describe("resolveGoogleChatDurableReplyOptions", () => { + it("enables durable final delivery when no typing preview is active", () => { + expect( + resolveGoogleChatDurableReplyOptions({ + payload: { text: "hello", replyToId: "thread-1" }, + infoKind: "final", + spaceId: "spaces/AAA", + }), + ).toEqual({ + to: "spaces/AAA", + replyToId: "thread-1", + threadId: "thread-1", + }); + }); + + it("keeps typing preview delivery on the legacy edit path", () => { + expect( + resolveGoogleChatDurableReplyOptions({ + payload: { text: "hello" }, + infoKind: "final", + spaceId: "spaces/AAA", + typingMessageName: "spaces/AAA/messages/typing", + }), + ).toBe(false); + }); + + it("does not durable-deliver non-final chunks", () => { + expect( + resolveGoogleChatDurableReplyOptions({ + payload: { text: "hello" }, + infoKind: "block", + spaceId: "spaces/AAA", + }), + ).toBe(false); + }); +}); diff --git a/extensions/googlechat/src/monitor-durable.ts b/extensions/googlechat/src/monitor-durable.ts new file mode 100644 index 00000000000..12b4400fbff --- /dev/null +++ b/extensions/googlechat/src/monitor-durable.ts @@ -0,0 +1,23 @@ +import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; + +export type GoogleChatDurableReplyOptions = { + to: string; + replyToId?: string; + threadId?: string; +}; + +export function resolveGoogleChatDurableReplyOptions(params: { + payload: ReplyPayload; + infoKind: string; + spaceId: string; + typingMessageName?: string; +}): GoogleChatDurableReplyOptions | false { + if (params.infoKind !== "final" || params.typingMessageName) { + return false; + } + const threadId = params.payload.replyToId?.trim() || undefined; + return { + to: params.spaceId, + ...(threadId ? { replyToId: threadId, threadId } : {}), + }; +} diff --git a/extensions/googlechat/src/monitor.ts b/extensions/googlechat/src/monitor.ts index 16f0f17db05..6d90564da53 100644 --- a/extensions/googlechat/src/monitor.ts +++ b/extensions/googlechat/src/monitor.ts @@ -1,7 +1,6 @@ import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/text-runtime"; import type { OpenClawConfig } from "../runtime-api.js"; import { - createChannelReplyPipeline, resolveInboundRouteEnvelopeBuilderWithRuntime, resolveWebhookPath, } from "../runtime-api.js"; @@ -9,6 +8,7 @@ import { type ResolvedGoogleChatAccount } from "./accounts.js"; import { downloadGoogleChatMedia, sendGoogleChatMessage } from "./api.js"; import { type GoogleChatAudienceType } from "./auth.js"; import { applyGoogleChatInboundAccessPolicy } from "./monitor-access.js"; +import { resolveGoogleChatDurableReplyOptions } from "./monitor-durable.js"; import { deliverGoogleChatReply } from "./monitor-reply-delivery.js"; import { registerGoogleChatWebhookTarget, @@ -281,13 +281,6 @@ async function processMessageWithPipeline(params: { } } - const { onModelSelected, ...replyPipeline } = createChannelReplyPipeline({ - cfg: config, - agentId: route.agentId, - channel: "googlechat", - accountId: route.accountId, - }); - await core.channel.turn.run({ channel: "googlechat", accountId: route.accountId, @@ -313,6 +306,13 @@ async function processMessageWithPipeline(params: { dispatchReplyWithBufferedBlockDispatcher: core.channel.reply.dispatchReplyWithBufferedBlockDispatcher, delivery: { + durable: (payload, info) => + resolveGoogleChatDurableReplyOptions({ + payload, + infoKind: info.kind, + spaceId, + typingMessageName, + }), deliver: async (payload) => { await deliverGoogleChatReply({ payload, @@ -327,16 +327,16 @@ async function processMessageWithPipeline(params: { // Only use typing message for first delivery typingMessageName = undefined; }, + onDelivered: () => { + statusSink?.({ lastOutboundAt: Date.now() }); + }, onError: (err, info) => { runtime.error?.( `[${account.accountId}] Google Chat ${info.kind} reply failed: ${String(err)}`, ); }, }, - dispatcherOptions: replyPipeline, - replyOptions: { - onModelSelected, - }, + replyPipeline: {}, record: { onRecordError: (err) => { runtime.error?.(`googlechat: failed updating session meta: ${String(err)}`); diff --git a/extensions/imessage/src/channel.ts b/extensions/imessage/src/channel.ts index 5b9aff82bc2..9275da83a98 100644 --- a/extensions/imessage/src/channel.ts +++ b/extensions/imessage/src/channel.ts @@ -1,5 +1,11 @@ import { buildDmGroupAccountAllowlistAdapter } from "openclaw/plugin-sdk/allowlist-config-edit"; import { createChatChannelPlugin } from "openclaw/plugin-sdk/channel-core"; +import { + createMessageReceiptFromOutboundResults, + defineChannelMessageAdapter, + type ChannelMessageSendResult, + type MessageReceiptPartKind, +} from "openclaw/plugin-sdk/channel-message"; import { buildPassiveProbedChannelStatusSummary } from "openclaw/plugin-sdk/extension-shared"; import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; import { sanitizeForPlainText } from "openclaw/plugin-sdk/outbound-runtime"; @@ -27,6 +33,7 @@ import { resolveIMessageGroupRequireMention, resolveIMessageGroupToolPolicy, } from "./group-policy.js"; +import { sanitizeOutboundText } from "./monitor/sanitize-outbound.js"; import type { IMessageProbe } from "./probe.js"; import { imessageSetupAdapter } from "./setup-core.js"; import { @@ -44,6 +51,70 @@ import { const loadIMessageChannelRuntime = createLazyRuntimeModule(() => import("./channel.runtime.js")); +type IMessageMessageContextExtras = { + deps?: { [channelId: string]: unknown }; +}; + +function toIMessageMessageSendResult( + result: { messageId?: string; receipt?: ChannelMessageSendResult["receipt"] }, + kind: MessageReceiptPartKind, + replyToId?: string | null, +): ChannelMessageSendResult { + const receipt = + result.receipt ?? + createMessageReceiptFromOutboundResults({ + results: result.messageId ? [{ channel: "imessage", messageId: result.messageId }] : [], + kind, + ...(replyToId ? { replyToId } : {}), + }); + return { + messageId: result.messageId || receipt.primaryPlatformMessageId, + receipt, + }; +} + +const imessageMessageAdapter = defineChannelMessageAdapter({ + id: "imessage", + durableFinal: { + capabilities: { + text: true, + media: true, + replyTo: true, + messageSendingHooks: true, + }, + }, + send: { + text: async (ctx) => { + const result = await ( + await loadIMessageChannelRuntime() + ).sendIMessageOutbound({ + cfg: ctx.cfg, + to: ctx.to, + text: ctx.text, + accountId: ctx.accountId ?? undefined, + deps: (ctx as typeof ctx & IMessageMessageContextExtras).deps, + replyToId: ctx.replyToId ?? undefined, + }); + return toIMessageMessageSendResult(result, "text", ctx.replyToId); + }, + media: async (ctx) => { + const result = await ( + await loadIMessageChannelRuntime() + ).sendIMessageOutbound({ + cfg: ctx.cfg, + to: ctx.to, + text: ctx.text, + mediaUrl: ctx.mediaUrl, + mediaLocalRoots: ctx.mediaLocalRoots, + accountId: ctx.accountId ?? undefined, + deps: (ctx as typeof ctx & IMessageMessageContextExtras).deps, + replyToId: ctx.replyToId ?? undefined, + }); + return toIMessageMessageSendResult(result, "media", ctx.replyToId); + }, + }, +}); + function buildIMessageBaseSessionKey(params: { cfg: Parameters[0]["cfg"]; agentId: string; @@ -228,6 +299,7 @@ export const imessagePlugin: ChannelPlugin sanitizeForPlainText(text), + sanitizeText: ({ text }) => sanitizeForPlainText(sanitizeOutboundText(text)), + deliveryCapabilities: { + durableFinal: { + text: true, + media: true, + replyTo: true, + messageSendingHooks: true, + }, + }, }, attachedResults: { channel: "imessage", diff --git a/extensions/imessage/src/imessage.test-plugin.ts b/extensions/imessage/src/imessage.test-plugin.ts index 8d4a5434751..cb3f716c9af 100644 --- a/extensions/imessage/src/imessage.test-plugin.ts +++ b/extensions/imessage/src/imessage.test-plugin.ts @@ -48,6 +48,14 @@ function normalizeIMessageTestHandle(raw: string): string { const defaultIMessageOutbound: ChannelOutboundAdapter = { deliveryMode: "direct", + deliveryCapabilities: { + durableFinal: { + text: true, + media: true, + replyTo: true, + messageSendingHooks: true, + }, + }, sendText: async ({ to, text, accountId, replyToId, deps, cfg }) => { const sendIMessage = resolveOutboundSendDep< ( diff --git a/extensions/imessage/src/monitor/deliver.test.ts b/extensions/imessage/src/monitor/deliver.test.ts index 8d2d7844e55..611604338ba 100644 --- a/extensions/imessage/src/monitor/deliver.test.ts +++ b/extensions/imessage/src/monitor/deliver.test.ts @@ -25,6 +25,7 @@ vi.mock("./deliver.runtime.js", () => ({ })); let deliverReplies: typeof import("./deliver.js").deliverReplies; +let createIMessageEchoCachingSend: typeof import("./deliver.js").createIMessageEchoCachingSend; describe("deliverReplies", () => { const IMESSAGE_TEST_CFG = { channels: { imessage: { accounts: { default: {} } } } }; @@ -32,7 +33,7 @@ describe("deliverReplies", () => { const client = {} as Awaited>; beforeAll(async () => { - ({ deliverReplies } = await import("./deliver.js")); + ({ createIMessageEchoCachingSend, deliverReplies } = await import("./deliver.js")); }); beforeEach(() => { @@ -128,6 +129,62 @@ describe("deliverReplies", () => { ); }); + it("records durable outbound sends in the sent-message cache", async () => { + const remember = vi.fn(); + const send = createIMessageEchoCachingSend({ + client, + accountId: "acct-5", + sentMessageCache: { remember }, + }); + sendMessageIMessageMock.mockResolvedValueOnce({ + messageId: "imsg-durable-1", + sentText: "durable hello", + }); + + await send("chat_id:50", "durable hello", { + config: IMESSAGE_TEST_CFG, + accountId: "acct-ignored", + }); + + expect(sendMessageIMessageMock).toHaveBeenCalledWith( + "chat_id:50", + "durable hello", + expect.objectContaining({ client }), + ); + expect(remember).toHaveBeenCalledWith("acct-5:chat_id:50", { + text: "durable hello", + messageId: "imsg-durable-1", + }); + }); + + it("sanitizes durable outbound text before sending", async () => { + const remember = vi.fn(); + const send = createIMessageEchoCachingSend({ + client, + accountId: "acct-6", + sentMessageCache: { remember }, + }); + sendMessageIMessageMock.mockResolvedValueOnce({ + messageId: "imsg-durable-2", + sentText: "Visible reply", + }); + + await send("chat_id:60", "hidden\nVisible reply\nassistant:", { + config: IMESSAGE_TEST_CFG, + accountId: "acct-ignored", + }); + + expect(sendMessageIMessageMock).toHaveBeenCalledWith( + "chat_id:60", + "Visible reply", + expect.objectContaining({ client }), + ); + expect(remember).toHaveBeenCalledWith("acct-6:chat_id:60", { + text: "Visible reply", + messageId: "imsg-durable-2", + }); + }); + it("records outbound text and message ids in sent-message cache (post-send only)", async () => { // Fix for #47830: remember() is called ONLY after each chunk is sent, // never with the full un-chunked text before sending begins. diff --git a/extensions/imessage/src/monitor/deliver.ts b/extensions/imessage/src/monitor/deliver.ts index 71a4e834bd2..9843280acd6 100644 --- a/extensions/imessage/src/monitor/deliver.ts +++ b/extensions/imessage/src/monitor/deliver.ts @@ -5,7 +5,7 @@ import { } from "openclaw/plugin-sdk/reply-payload"; import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; -import type { createIMessageRpcClient } from "../client.js"; +import type { IMessageRpcClient } from "../client.js"; import { sendMessageIMessage } from "../send.js"; import { chunkTextWithMode, @@ -20,7 +20,7 @@ export async function deliverReplies(params: { cfg: OpenClawConfig; replies: ReplyPayload[]; target: string; - client: Awaited>; + client: IMessageRpcClient; accountId?: string; runtime: RuntimeEnv; maxBytes: number; @@ -80,3 +80,23 @@ export async function deliverReplies(params: { } } } + +export function createIMessageEchoCachingSend(params: { + client: IMessageRpcClient; + accountId?: string; + sentMessageCache?: Pick; +}): typeof sendMessageIMessage { + return async (target, text, opts) => { + const sanitizedText = sanitizeOutboundText(text); + const sent = await sendMessageIMessage(target, sanitizedText, { + ...opts, + client: params.client, + }); + const scope = `${params.accountId ?? opts.accountId ?? ""}:${target}`; + params.sentMessageCache?.remember(scope, { + text: sent.sentText || undefined, + messageId: sent.messageId, + }); + return sent; + }; +} diff --git a/extensions/imessage/src/monitor/monitor-provider.ts b/extensions/imessage/src/monitor/monitor-provider.ts index 554805d5a67..81f4f224a51 100644 --- a/extensions/imessage/src/monitor/monitor-provider.ts +++ b/extensions/imessage/src/monitor/monitor-provider.ts @@ -4,8 +4,11 @@ import { createChannelInboundDebouncer, shouldDebounceTextInbound, } from "openclaw/plugin-sdk/channel-inbound"; +import { + deliverInboundReplyWithMessageSendContext, + createChannelMessageReplyPipeline, +} from "openclaw/plugin-sdk/channel-message"; import { createChannelPairingChallengeIssuer } from "openclaw/plugin-sdk/channel-pairing"; -import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline"; import { readChannelAllowFromStore, upsertChannelPairingRequest, @@ -41,7 +44,7 @@ import { probeIMessage } from "../probe.js"; import { sendMessageIMessage } from "../send.js"; import { normalizeIMessageHandle } from "../targets.js"; import { attachIMessageMonitorAbortHandler } from "./abort-handler.js"; -import { deliverReplies } from "./deliver.js"; +import { createIMessageEchoCachingSend, deliverReplies } from "./deliver.js"; import { createSentMessageCache } from "./echo-cache.js"; import { buildIMessageInboundContext, @@ -402,7 +405,7 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P ); } - const { onModelSelected, ...replyPipeline } = createChannelReplyPipeline({ + const { onModelSelected, ...replyPipeline } = createChannelMessageReplyPipeline({ cfg, agentId: decision.route.agentId, channel: "imessage", @@ -412,12 +415,35 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P const dispatcher = createReplyDispatcher({ ...replyPipeline, humanDelay: resolveHumanDelayConfig(cfg, decision.route.agentId), - deliver: async (payload) => { + deliver: async (payload, info) => { const target = ctxPayload.To; if (!target) { runtime.error?.(danger("imessage: missing delivery target")); return; } + const durable = await deliverInboundReplyWithMessageSendContext({ + cfg, + channel: "imessage", + accountId: accountInfo.accountId, + agentId: decision.route.agentId, + ctxPayload, + payload, + info, + to: target, + deps: { + imessage: createIMessageEchoCachingSend({ + client: getActiveClient(), + accountId: accountInfo.accountId, + sentMessageCache, + }), + }, + }); + if (durable.status === "failed") { + throw durable.error; + } + if (durable.status === "handled_visible" || durable.status === "handled_no_send") { + return; + } await deliverReplies({ cfg, replies: [payload], diff --git a/extensions/imessage/src/send.test.ts b/extensions/imessage/src/send.test.ts new file mode 100644 index 00000000000..5709c399fbe --- /dev/null +++ b/extensions/imessage/src/send.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it, vi } from "vitest"; +import type { IMessageRpcClient } from "./client.js"; +import { sendMessageIMessage } from "./send.js"; + +const IMESSAGE_TEST_CFG = { + channels: { + imessage: { + accounts: { + default: {}, + }, + }, + }, +}; + +function createClient(result: Record): IMessageRpcClient { + return { + request: vi.fn(async () => result), + stop: vi.fn(async () => {}), + } as unknown as IMessageRpcClient; +} + +describe("sendMessageIMessage receipts", () => { + it("attaches a text receipt for native send ids", async () => { + const client = createClient({ guid: "p:0/imsg-1" }); + + const result = await sendMessageIMessage("chat_id:42", "hello", { + config: IMESSAGE_TEST_CFG, + client, + replyToId: "reply-1", + }); + + expect(result).toMatchObject({ + messageId: "p:0/imsg-1", + sentText: "hello", + receipt: { + primaryPlatformMessageId: "p:0/imsg-1", + platformMessageIds: ["p:0/imsg-1"], + replyToId: "reply-1", + parts: [ + expect.objectContaining({ + platformMessageId: "p:0/imsg-1", + kind: "text", + replyToId: "reply-1", + raw: expect.objectContaining({ + channel: "imessage", + chatId: "42", + }), + }), + ], + }, + }); + }); + + it("attaches a media receipt after attachment resolution", async () => { + const client = createClient({ message_id: 12345 }); + + const result = await sendMessageIMessage("chat_guid:chat-1", "", { + config: IMESSAGE_TEST_CFG, + client, + mediaUrl: "/tmp/image.png", + resolveAttachmentImpl: async () => ({ path: "/tmp/image.png", contentType: "image/png" }), + }); + + expect(result).toMatchObject({ + messageId: "12345", + sentText: "", + receipt: { + primaryPlatformMessageId: "12345", + platformMessageIds: ["12345"], + parts: [ + expect.objectContaining({ + platformMessageId: "12345", + kind: "media", + raw: expect.objectContaining({ + conversationId: "chat-1", + }), + }), + ], + }, + }); + }); + + it("does not treat compatibility ok responses as visible platform ids", async () => { + const client = createClient({ ok: "true" }); + + const result = await sendMessageIMessage("+15551234567", "hello", { + config: IMESSAGE_TEST_CFG, + client, + }); + + expect(result.messageId).toBe("ok"); + expect(result.receipt.platformMessageIds).toEqual([]); + }); +}); diff --git a/extensions/imessage/src/send.ts b/extensions/imessage/src/send.ts index e73f4877310..227e7d6cbf7 100644 --- a/extensions/imessage/src/send.ts +++ b/extensions/imessage/src/send.ts @@ -1,3 +1,9 @@ +import { + createMessageReceiptFromOutboundResults, + type MessageReceipt, + type MessageReceiptPartKind, + type MessageReceiptSourceResult, +} from "openclaw/plugin-sdk/channel-message"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime"; import { kindFromMime } from "openclaw/plugin-sdk/media-runtime"; @@ -39,6 +45,7 @@ type IMessageSendOpts = { type IMessageSendResult = { messageId: string; sentText: string; + receipt: MessageReceipt; }; const MAX_REPLY_TO_ID_LENGTH = 256; @@ -95,6 +102,44 @@ function resolveDeliveredIMessageText(text: string, mediaContentType?: string): return kind === "image" ? "" : ``; } +function createIMessageSendReceipt(params: { + messageId: string; + target: ReturnType; + kind: MessageReceiptPartKind; + replyToId?: string; +}): MessageReceipt { + const messageId = params.messageId.trim(); + const results: MessageReceiptSourceResult[] = + messageId && messageId !== "unknown" && messageId !== "ok" + ? [ + { + channel: "imessage", + messageId, + meta: { + targetKind: params.target.kind, + }, + }, + ] + : []; + if (results[0]) { + if (params.target.kind === "chat_id") { + results[0].chatId = String(params.target.chatId); + } else if (params.target.kind === "chat_guid") { + results[0].conversationId = params.target.chatGuid; + } else if (params.target.kind === "chat_identifier") { + results[0].conversationId = params.target.chatIdentifier; + } + } + const receiptParams: Parameters[0] = { + results, + kind: params.kind, + }; + if (params.replyToId) { + receiptParams.replyToId = params.replyToId; + } + return createMessageReceiptFromOutboundResults(receiptParams); +} + export async function sendMessageIMessage( to: string, text: string, @@ -183,9 +228,16 @@ export async function sendMessageIMessage( timeoutMs: opts.timeoutMs, }); const resolvedId = resolveMessageId(result); + const messageId = resolvedId ?? (result?.ok ? "ok" : "unknown"); return { - messageId: resolvedId ?? (result?.ok ? "ok" : "unknown"), + messageId, sentText: message, + receipt: createIMessageSendReceipt({ + messageId, + target, + kind: filePath ? "media" : "text", + ...(resolvedReplyToId ? { replyToId: resolvedReplyToId } : {}), + }), }; } finally { if (shouldClose) { diff --git a/extensions/imessage/src/test-plugin.test.ts b/extensions/imessage/src/test-plugin.test.ts index d36815e4553..d5d0a49bc97 100644 --- a/extensions/imessage/src/test-plugin.test.ts +++ b/extensions/imessage/src/test-plugin.test.ts @@ -1,8 +1,14 @@ +import { + createMessageReceiptFromOutboundResults, + verifyChannelMessageAdapterCapabilityProofs, + verifyDurableFinalCapabilityProofs, +} from "openclaw/plugin-sdk/channel-message"; import { listImportedBundledPluginFacadeIds, resetFacadeRuntimeStateForTest, } from "openclaw/plugin-sdk/plugin-test-runtime"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { imessagePlugin } from "./channel.js"; import { createIMessageTestPlugin } from "./imessage.test-plugin.js"; beforeEach(() => { @@ -28,4 +34,136 @@ describe("createIMessageTestPlugin", () => { expect(plugin.messaging?.normalizeTarget?.(prefixedHandle)).toBe("+442079460958"); }); + + it("declares durable final delivery capabilities", () => { + expect(imessagePlugin.outbound?.deliveryCapabilities?.durableFinal).toEqual( + expect.objectContaining({ + text: true, + media: true, + replyTo: true, + messageSendingHooks: true, + }), + ); + expect(createIMessageTestPlugin().outbound?.deliveryCapabilities?.durableFinal).toEqual( + expect.objectContaining({ + text: true, + media: true, + replyTo: true, + messageSendingHooks: true, + }), + ); + }); + + it("backs declared durable final capabilities with delivery proofs", async () => { + const outbound = createIMessageTestPlugin().outbound!; + const sendIMessage = async () => ({ messageId: "imsg-1" }); + + await verifyDurableFinalCapabilityProofs({ + adapterName: "imessageOutbound", + capabilities: outbound.deliveryCapabilities?.durableFinal, + proofs: { + text: async () => { + await expect( + outbound.sendText?.({ + cfg: {} as never, + to: "+15551234567", + text: "hello", + deps: { imessage: sendIMessage }, + }), + ).resolves.toEqual({ channel: "imessage", messageId: "imsg-1" }); + }, + media: async () => { + await expect( + outbound.sendMedia?.({ + cfg: {} as never, + to: "+15551234567", + text: "caption", + mediaUrl: "/tmp/image.png", + mediaLocalRoots: ["/tmp"], + deps: { imessage: sendIMessage }, + }), + ).resolves.toEqual({ channel: "imessage", messageId: "imsg-1" }); + }, + replyTo: async () => { + await expect( + outbound.sendText?.({ + cfg: {} as never, + to: "+15551234567", + text: "reply", + replyToId: "reply-1", + deps: { imessage: sendIMessage }, + }), + ).resolves.toEqual({ channel: "imessage", messageId: "imsg-1" }); + }, + messageSendingHooks: () => { + expect(outbound.sendText).toBeTypeOf("function"); + }, + }, + }); + }); + + it("backs declared message adapter capabilities with delivery proofs", async () => { + const sendIMessage = async ( + _to: string, + _text: string, + opts?: { mediaUrl?: string; replyToId?: string }, + ) => { + const messageId = opts?.mediaUrl ? "imsg-media-1" : "imsg-text-1"; + return { + messageId, + sentText: opts?.mediaUrl ? "" : "hello", + receipt: createMessageReceiptFromOutboundResults({ + results: [{ channel: "imessage", messageId }], + kind: opts?.mediaUrl ? "media" : "text", + ...(opts?.replyToId ? { replyToId: opts.replyToId } : {}), + }), + }; + }; + + await verifyChannelMessageAdapterCapabilityProofs({ + adapterName: "imessageMessage", + adapter: imessagePlugin.message!, + proofs: { + text: async () => { + const result = await imessagePlugin.message?.send?.text?.({ + cfg: {} as never, + to: "+15551234567", + text: "hello", + deps: { imessage: sendIMessage }, + } as Parameters>[0] & { + deps: { imessage: typeof sendIMessage }; + }); + expect(result?.receipt.platformMessageIds).toEqual(["imsg-text-1"]); + }, + media: async () => { + const result = await imessagePlugin.message?.send?.media?.({ + cfg: {} as never, + to: "+15551234567", + text: "caption", + mediaUrl: "/tmp/image.png", + mediaLocalRoots: ["/tmp"], + deps: { imessage: sendIMessage }, + } as Parameters>[0] & { + deps: { imessage: typeof sendIMessage }; + }); + expect(result?.receipt.platformMessageIds).toEqual(["imsg-media-1"]); + }, + replyTo: async () => { + const result = await imessagePlugin.message?.send?.text?.({ + cfg: {} as never, + to: "+15551234567", + text: "reply", + replyToId: "reply-1", + deps: { imessage: sendIMessage }, + } as Parameters>[0] & { + deps: { imessage: typeof sendIMessage }; + }); + expect(result?.receipt.replyToId).toBe("reply-1"); + }, + messageSendingHooks: () => { + expect(imessagePlugin.message?.send?.text).toBeTypeOf("function"); + }, + }, + }); + }); }); diff --git a/extensions/irc/src/channel.ts b/extensions/irc/src/channel.ts index f3489264d79..f34d08a2ba3 100644 --- a/extensions/irc/src/channel.ts +++ b/extensions/irc/src/channel.ts @@ -33,6 +33,7 @@ import { import { IrcChannelConfigSchema } from "./config-schema.js"; import { collectIrcMutableAllowlistWarnings } from "./doctor.js"; import { startIrcGatewayAccount } from "./gateway.js"; +import { ircMessageAdapter } from "./message-adapter.js"; import { isChannelTarget, looksLikeIrcTargetId, @@ -240,6 +241,7 @@ export const ircPlugin: ChannelPlugin = createChat hint: "<#channel|nick>", }, }, + message: ircMessageAdapter, resolver: { resolveTargets: async ({ inputs, kind }) => { return inputs.map((input) => { diff --git a/extensions/irc/src/inbound.ts b/extensions/irc/src/inbound.ts index 595e060076f..2954a8c5590 100644 --- a/extensions/irc/src/inbound.ts +++ b/extensions/irc/src/inbound.ts @@ -333,9 +333,9 @@ export async function handleIrcInbound(params: { CommandAuthorized: commandAuthorized, }); - const { dispatchInboundReplyWithBase } = - await import("openclaw/plugin-sdk/inbound-reply-dispatch"); - await dispatchInboundReplyWithBase({ + const { dispatchChannelMessageReplyWithBase } = + await import("openclaw/plugin-sdk/channel-message"); + await dispatchChannelMessageReplyWithBase({ cfg: config as OpenClawConfig, channel: CHANNEL_ID, accountId: account.accountId, diff --git a/extensions/irc/src/message-adapter.ts b/extensions/irc/src/message-adapter.ts new file mode 100644 index 00000000000..480c1abe480 --- /dev/null +++ b/extensions/irc/src/message-adapter.ts @@ -0,0 +1,28 @@ +import { defineChannelMessageAdapter } from "openclaw/plugin-sdk/channel-message"; +import { sendMessageIrc } from "./send.js"; +import type { CoreConfig } from "./types.js"; + +export const ircMessageAdapter = defineChannelMessageAdapter({ + id: "irc", + durableFinal: { + capabilities: { + text: true, + media: true, + replyTo: true, + }, + }, + send: { + text: async ({ cfg, to, text, accountId, replyToId }) => + await sendMessageIrc(to, text, { + cfg: cfg as CoreConfig, + accountId: accountId ?? undefined, + replyTo: replyToId ?? undefined, + }), + media: async ({ cfg, to, text, mediaUrl, accountId, replyToId }) => + await sendMessageIrc(to, mediaUrl ? `${text}\n\nAttachment: ${mediaUrl}` : text, { + cfg: cfg as CoreConfig, + accountId: accountId ?? undefined, + replyTo: replyToId ?? undefined, + }), + }, +}); diff --git a/extensions/irc/src/runtime-api.ts b/extensions/irc/src/runtime-api.ts index 696227025fc..971204dc12b 100644 --- a/extensions/irc/src/runtime-api.ts +++ b/extensions/irc/src/runtime-api.ts @@ -29,7 +29,7 @@ export { resolveEffectiveAllowFromLists, } from "openclaw/plugin-sdk/channel-policy"; export { resolveControlCommandGate } from "openclaw/plugin-sdk/command-auth"; -export { dispatchInboundReplyWithBase } from "openclaw/plugin-sdk/inbound-reply-dispatch"; +export { dispatchChannelMessageReplyWithBase } from "openclaw/plugin-sdk/channel-message"; export { chunkTextForOutbound } from "openclaw/plugin-sdk/text-chunking"; export { deliverFormattedTextWithAttachments, diff --git a/extensions/irc/src/send.test.ts b/extensions/irc/src/send.test.ts index abbe1e97a87..e71d4fe0080 100644 --- a/extensions/irc/src/send.test.ts +++ b/extensions/irc/src/send.test.ts @@ -1,3 +1,4 @@ +import { verifyChannelMessageAdapterCapabilityProofs } from "openclaw/plugin-sdk/channel-message"; import { createSendCfgThreadingRuntime } from "openclaw/plugin-sdk/channel-test-helpers"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { IrcClient } from "./client.js"; @@ -62,6 +63,7 @@ vi.mock("openclaw/plugin-sdk/text-runtime", async () => { }; }); +import { ircMessageAdapter } from "./message-adapter.js"; import { sendMessageIrc } from "./send.js"; describe("sendMessageIrc cfg threading", () => { @@ -106,6 +108,21 @@ describe("sendMessageIrc cfg threading", () => { expect(result.target).toBe("#room"); expect(result.messageId).toEqual(expect.any(String)); expect(result.messageId.length).toBeGreaterThan(0); + expect(result.receipt).toMatchObject({ + primaryPlatformMessageId: "irc-msg-1", + platformMessageIds: ["irc-msg-1"], + parts: [ + { + platformMessageId: "irc-msg-1", + kind: "text", + raw: { + channel: "irc", + conversationId: "#room", + messageId: "irc-msg-1", + }, + }, + ], + }); }); it("fails hard when cfg is omitted", async () => { @@ -151,4 +168,103 @@ describe("sendMessageIrc cfg threading", () => { expect(result.messageId).toEqual(expect.any(String)); expect(result.messageId.length).toBeGreaterThan(0); }); + + it("preserves reply ids in receipts", async () => { + const providedCfg = { + channels: { + irc: { + host: "irc.example.com", + nick: "openclaw", + }, + }, + } as unknown as CoreConfig; + const client = { + isReady: vi.fn(() => true), + sendPrivmsg: vi.fn(), + } as unknown as IrcClient; + + const result = await sendMessageIrc("#room", "hello", { + cfg: providedCfg, + client, + replyTo: "irc-parent-1", + }); + + expect(client.sendPrivmsg).toHaveBeenCalledWith("#room", "hello\n\n[reply:irc-parent-1]"); + expect(result.receipt).toMatchObject({ + replyToId: "irc-parent-1", + parts: [ + { + platformMessageId: "irc-msg-1", + replyToId: "irc-parent-1", + }, + ], + }); + }); + + it("declares message adapter durable text, media, and reply with receipt proofs", async () => { + const providedCfg = { + channels: { + irc: { + host: "irc.example.com", + nick: "openclaw", + }, + }, + } as unknown as CoreConfig; + const client = { + isReady: vi.fn(() => true), + sendPrivmsg: vi.fn(), + quit: vi.fn(), + } as unknown as IrcClient & { quit: ReturnType }; + hoisted.connectIrcClient.mockResolvedValue(client); + + await expect( + verifyChannelMessageAdapterCapabilityProofs({ + adapterName: "irc", + adapter: ircMessageAdapter, + proofs: { + text: async () => { + const result = await ircMessageAdapter.send?.text?.({ + cfg: providedCfg, + to: "#room", + text: "hello", + }); + expect(result?.receipt.platformMessageIds).toEqual(["irc-msg-1"]); + expect(client.sendPrivmsg).toHaveBeenCalledWith("#room", "hello"); + }, + media: async () => { + const result = await ircMessageAdapter.send?.media?.({ + cfg: providedCfg, + to: "#room", + text: "image", + mediaUrl: "https://example.com/image.png", + }); + expect(result?.receipt.platformMessageIds).toEqual(["irc-msg-1"]); + expect(client.sendPrivmsg).toHaveBeenCalledWith( + "#room", + "image\n\nAttachment: https://example.com/image.png", + ); + }, + replyTo: async () => { + const result = await ircMessageAdapter.send?.text?.({ + cfg: providedCfg, + to: "#room", + text: "threaded", + replyToId: "parent-1", + }); + expect(result?.receipt.replyToId).toBe("parent-1"); + expect(client.sendPrivmsg).toHaveBeenCalledWith( + "#room", + "threaded\n\n[reply:parent-1]", + ); + }, + }, + }), + ).resolves.toEqual( + expect.arrayContaining([ + { capability: "text", status: "verified" }, + { capability: "media", status: "verified" }, + { capability: "replyTo", status: "verified" }, + ]), + ); + }); }); diff --git a/extensions/irc/src/send.ts b/extensions/irc/src/send.ts index 1a2befe157e..e3fcec36d73 100644 --- a/extensions/irc/src/send.ts +++ b/extensions/irc/src/send.ts @@ -1,3 +1,7 @@ +import { + createMessageReceiptFromOutboundResults, + type MessageReceipt, +} from "openclaw/plugin-sdk/channel-message"; import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime"; import { requireRuntimeConfig } from "openclaw/plugin-sdk/plugin-config-runtime"; import { convertMarkdownTables } from "openclaw/plugin-sdk/text-runtime"; @@ -21,6 +25,7 @@ type SendIrcOptions = { type SendIrcResult = { messageId: string; target: string; + receipt: MessageReceipt; }; function recordIrcOutboundActivity(accountId: string): void { @@ -94,8 +99,20 @@ export async function sendMessageIrc( recordIrcOutboundActivity(account.accountId); + const messageId = makeIrcMessageId(); return { - messageId: makeIrcMessageId(), + messageId, target, + receipt: createMessageReceiptFromOutboundResults({ + results: [ + { + channel: "irc", + messageId, + conversationId: target, + }, + ], + kind: "text", + ...(opts.replyTo ? { replyToId: opts.replyTo } : {}), + }), }; } diff --git a/extensions/line/src/auto-reply-delivery.test.ts b/extensions/line/src/auto-reply-delivery.test.ts index 3e06a7ecbf7..1790295928a 100644 --- a/extensions/line/src/auto-reply-delivery.test.ts +++ b/extensions/line/src/auto-reply-delivery.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import type { LineAutoReplyDeps } from "./auto-reply-delivery.js"; import { deliverLineAutoReply } from "./auto-reply-delivery.js"; import { sendLineReplyChunks } from "./reply-chunks.js"; +import { createLineSendReceipt } from "./send-receipt.js"; const createFlexMessage = (altText: string, contents: unknown) => ({ type: "flex" as const, @@ -45,7 +46,11 @@ describe("deliverLineAutoReply", () => { text, })); const createQuickReplyItems = vi.fn((labels: string[]) => ({ items: labels })); - const pushMessagesLine = vi.fn(async () => ({ messageId: "push", chatId: "u1" })); + const pushMessagesLine = vi.fn(async () => ({ + messageId: "push", + chatId: "u1", + receipt: createLineSendReceipt({ messageId: "push", chatId: "u1", kind: "text" }), + })); const deps: LineAutoReplyDeps = { buildTemplateMessageFromPayload: () => null, diff --git a/extensions/line/src/channel.sendPayload.test.ts b/extensions/line/src/channel.sendPayload.test.ts index 69b3950374d..18ec9d3eb52 100644 --- a/extensions/line/src/channel.sendPayload.test.ts +++ b/extensions/line/src/channel.sendPayload.test.ts @@ -1,9 +1,15 @@ +import { + verifyChannelMessageAdapterCapabilityProofs, + verifyChannelMessageReceiveAckPolicyAdapterProofs, +} from "openclaw/plugin-sdk/channel-message"; import { describe, expect, it, vi } from "vitest"; import type { OpenClawConfig, PluginRuntime } from "../api.js"; +import { linePlugin } from "./channel.js"; import { lineConfigAdapter } from "./config-adapter.js"; import { resolveLineGroupRequireMention } from "./group-policy.js"; import { lineOutboundAdapter } from "./outbound.js"; import { setLineRuntime } from "./runtime.js"; +import { createLineSendReceipt } from "./send-receipt.js"; type LineRuntimeMocks = { pushMessageLine: ReturnType; @@ -20,19 +26,24 @@ type LineRuntimeMocks = { resolveTextChunkLimit: ReturnType; }; +function lineResult(messageId: string, chatId = "c1") { + return { + messageId, + chatId, + receipt: createLineSendReceipt({ messageId, chatId, kind: "text" }), + }; +} + function createRuntime(): { runtime: PluginRuntime; mocks: LineRuntimeMocks } { - const pushMessageLine = vi.fn(async () => ({ messageId: "m-text", chatId: "c1" })); - const pushMessagesLine = vi.fn(async () => ({ messageId: "m-batch", chatId: "c1" })); - const pushFlexMessage = vi.fn(async () => ({ messageId: "m-flex", chatId: "c1" })); - const pushTemplateMessage = vi.fn(async () => ({ messageId: "m-template", chatId: "c1" })); - const pushLocationMessage = vi.fn(async () => ({ messageId: "m-loc", chatId: "c1" })); - const pushTextMessageWithQuickReplies = vi.fn(async () => ({ - messageId: "m-quick", - chatId: "c1", - })); + const pushMessageLine = vi.fn(async () => lineResult("m-text")); + const pushMessagesLine = vi.fn(async () => lineResult("m-batch")); + const pushFlexMessage = vi.fn(async () => lineResult("m-flex")); + const pushTemplateMessage = vi.fn(async () => lineResult("m-template")); + const pushLocationMessage = vi.fn(async () => lineResult("m-loc")); + const pushTextMessageWithQuickReplies = vi.fn(async () => lineResult("m-quick")); const createQuickReplyItems = vi.fn((labels: string[]) => ({ items: labels })); const buildTemplateMessageFromPayload = vi.fn(() => ({ type: "buttons" })); - const sendMessageLine = vi.fn(async () => ({ messageId: "m-media", chatId: "c1" })); + const sendMessageLine = vi.fn(async () => lineResult("m-media")); const chunkMarkdownText = vi.fn((text: string) => [text]); const resolveTextChunkLimit = vi.fn(() => 123); const resolveLineAccount = vi.fn( @@ -227,7 +238,8 @@ describe("line outbound sendPayload", () => { ["One", "Two"], { verbose: false, accountId: "default", cfg }, ); - expect(result).toEqual({ channel: "line", messageId: "m-quick", chatId: "c1" }); + expect(result).toMatchObject({ channel: "line", messageId: "m-quick", chatId: "c1" }); + expect(result.receipt?.primaryPlatformMessageId).toBe("m-quick"); }); it("sends media before quick-reply text so buttons stay visible", async () => { @@ -469,6 +481,84 @@ describe("line outbound sendPayload", () => { }), ).rejects.toThrow(/require previewimageurl/i); }); + + it("declares message adapter durable text and media with receipt proofs", async () => { + const { runtime, mocks } = createRuntime(); + setLineRuntime(runtime); + const cfg = { channels: { line: {} } } as OpenClawConfig; + + await expect( + verifyChannelMessageAdapterCapabilityProofs({ + adapterName: "line", + adapter: linePlugin.message!, + proofs: { + text: async () => { + const result = await linePlugin.message?.send?.text?.({ + cfg, + to: "line:user:U123", + text: "hello", + accountId: "primary", + }); + expect(mocks.pushMessageLine).toHaveBeenCalledWith("line:user:U123", "hello", { + verbose: false, + accountId: "primary", + cfg, + }); + expect(result?.receipt.platformMessageIds).toEqual(["m-text"]); + }, + media: async () => { + const result = await linePlugin.message?.send?.media?.({ + cfg, + to: "line:user:U123", + text: "image", + mediaUrl: "https://example.com/image.jpg", + accountId: "primary", + }); + expect(mocks.sendMessageLine).toHaveBeenCalledWith("line:user:U123", "", { + verbose: false, + mediaUrl: "https://example.com/image.jpg", + accountId: "primary", + cfg, + }); + expect(result?.receipt.platformMessageIds).toEqual(["m-media"]); + }, + messageSendingHooks: () => { + expect(linePlugin.message?.send?.text).toBeTypeOf("function"); + }, + }, + }), + ).resolves.toEqual( + expect.arrayContaining([ + { capability: "text", status: "verified" }, + { capability: "media", status: "verified" }, + { capability: "messageSendingHooks", status: "verified" }, + ]), + ); + }); + + it("declares receive ack policies for deferred LINE webhook acknowledgement", async () => { + await expect( + verifyChannelMessageReceiveAckPolicyAdapterProofs({ + adapterName: "line", + adapter: linePlugin.message!, + proofs: { + after_receive_record: () => { + expect(linePlugin.message?.receive?.supportedAckPolicies).toContain( + "after_receive_record", + ); + }, + after_agent_dispatch: () => { + expect(linePlugin.message?.receive?.defaultAckPolicy).toBe("after_agent_dispatch"); + }, + }, + }), + ).resolves.toEqual( + expect.arrayContaining([ + { policy: "after_receive_record", status: "verified" }, + { policy: "after_agent_dispatch", status: "verified" }, + ]), + ); + }); }); describe("linePlugin config.formatAllowFrom", () => { diff --git a/extensions/line/src/channel.ts b/extensions/line/src/channel.ts index 1c100f22e2b..d2efb7e553e 100644 --- a/extensions/line/src/channel.ts +++ b/extensions/line/src/channel.ts @@ -9,7 +9,7 @@ import { type ChannelPlugin, type ResolvedLineAccount } from "./channel-api.js"; import { lineChannelPluginCommon } from "./channel-shared.js"; import { lineGatewayAdapter } from "./gateway.js"; import { resolveLineGroupRequireMention } from "./group-policy.js"; -import { lineOutboundAdapter } from "./outbound.js"; +import { lineMessageAdapter, lineOutboundAdapter } from "./outbound.js"; import { hasLineDirectives, parseLineDirectives } from "./reply-payload-transform.js"; import { getLineRuntime } from "./runtime.js"; import { lineSetupAdapter } from "./setup-core.js"; @@ -72,6 +72,7 @@ export const linePlugin: ChannelPlugin = createChatChannelP setup: lineSetupAdapter, status: lineStatusAdapter, gateway: lineGatewayAdapter, + message: lineMessageAdapter, bindings: lineBindingsAdapter, conversationBindings: { defaultTopLevelPlacement: "current", diff --git a/extensions/line/src/monitor-durable.test.ts b/extensions/line/src/monitor-durable.test.ts new file mode 100644 index 00000000000..ecb411b3d8b --- /dev/null +++ b/extensions/line/src/monitor-durable.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import { resolveLineDurableReplyOptions } from "./monitor-durable.js"; + +describe("resolveLineDurableReplyOptions", () => { + it("enables durable final delivery for push-only text replies", () => { + expect( + resolveLineDurableReplyOptions({ + payload: { text: "hello" }, + infoKind: "final", + to: "U123", + replyToken: "reply-token", + replyTokenUsed: true, + }), + ).toEqual({ + to: "U123", + }); + }); + + it("keeps unused reply-token delivery on the legacy path", () => { + expect( + resolveLineDurableReplyOptions({ + payload: { text: "hello" }, + infoKind: "final", + to: "U123", + replyToken: "reply-token", + replyTokenUsed: false, + }), + ).toBe(false); + }); + + it("keeps rich, media, and non-final replies on the legacy path", () => { + expect( + resolveLineDurableReplyOptions({ + payload: { text: "hello", channelData: { line: { quickReplies: ["One"] } } }, + infoKind: "final", + to: "U123", + replyTokenUsed: true, + }), + ).toBe(false); + expect( + resolveLineDurableReplyOptions({ + payload: { text: "photo", mediaUrl: "https://example.com/image.png" }, + infoKind: "final", + to: "U123", + replyTokenUsed: true, + }), + ).toBe(false); + expect( + resolveLineDurableReplyOptions({ + payload: { text: "hello" }, + infoKind: "block", + to: "U123", + replyTokenUsed: true, + }), + ).toBe(false); + }); +}); diff --git a/extensions/line/src/monitor-durable.ts b/extensions/line/src/monitor-durable.ts new file mode 100644 index 00000000000..42036460064 --- /dev/null +++ b/extensions/line/src/monitor-durable.ts @@ -0,0 +1,37 @@ +import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; +import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; +import type { LineChannelData } from "./types.js"; + +export type LineDurableReplyOptions = { + to: string; +}; + +function hasLineChannelData(payload: ReplyPayload): boolean { + const lineData = payload.channelData?.line as LineChannelData | undefined; + return Boolean(lineData && Object.keys(lineData).length > 0); +} + +export function resolveLineDurableReplyOptions(params: { + payload: ReplyPayload; + infoKind: string; + to: string; + replyToken?: string | null; + replyTokenUsed: boolean; +}): LineDurableReplyOptions | false { + if (params.infoKind !== "final") { + return false; + } + if (params.replyToken && !params.replyTokenUsed) { + return false; + } + if (hasLineChannelData(params.payload)) { + return false; + } + const reply = resolveSendableOutboundReplyParts(params.payload); + if (reply.hasMedia || !reply.hasText) { + return false; + } + return { + to: params.to, + }; +} diff --git a/extensions/line/src/monitor.lifecycle.test.ts b/extensions/line/src/monitor.lifecycle.test.ts index bcbdef2efca..8b80687611c 100644 --- a/extensions/line/src/monitor.lifecycle.test.ts +++ b/extensions/line/src/monitor.lifecycle.test.ts @@ -3,9 +3,9 @@ import { EventEmitter } from "node:events"; import type { IncomingMessage, ServerResponse } from "node:http"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; +import { createMockIncomingRequest } from "openclaw/plugin-sdk/test-env"; import { WEBHOOK_IN_FLIGHT_DEFAULTS } from "openclaw/plugin-sdk/webhook-request-guards"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { createMockIncomingRequest } from "openclaw/plugin-sdk/test-env"; type LineNodeWebhookHandler = (req: IncomingMessage, res: ServerResponse) => Promise; @@ -52,8 +52,9 @@ vi.mock("openclaw/plugin-sdk/runtime-env", async () => { }; }); -vi.mock("openclaw/plugin-sdk/channel-reply-pipeline", () => ({ - createChannelReplyPipeline: vi.fn(() => ({})), +vi.mock("openclaw/plugin-sdk/channel-message", () => ({ + createChannelMessageReplyPipeline: vi.fn(() => ({})), + hasFinalChannelMessageReplyDispatch: vi.fn(() => false), })); vi.mock("openclaw/plugin-sdk/webhook-ingress", async () => { diff --git a/extensions/line/src/monitor.ts b/extensions/line/src/monitor.ts index 5b957f700f5..70f3c4c0fd9 100644 --- a/extensions/line/src/monitor.ts +++ b/extensions/line/src/monitor.ts @@ -1,7 +1,6 @@ import type { webhook } from "@line/bot-sdk"; -import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline"; +import { hasFinalChannelMessageReplyDispatch } from "openclaw/plugin-sdk/channel-message"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; -import { hasFinalInboundReplyDispatch } from "openclaw/plugin-sdk/inbound-reply-dispatch"; import { chunkMarkdownText } from "openclaw/plugin-sdk/reply-runtime"; import { danger, @@ -24,6 +23,7 @@ import { resolveDefaultLineAccountId } from "./accounts.js"; import { deliverLineAutoReply } from "./auto-reply-delivery.js"; import { createLineBot } from "./bot.js"; import { processLineMessage } from "./markdown-to-line.js"; +import { resolveLineDurableReplyOptions } from "./monitor-durable.js"; import { sendLineReplyChunks } from "./reply-chunks.js"; import { getLineRuntime } from "./runtime.js"; import { @@ -223,13 +223,6 @@ export async function monitorLineProvider( try { const textLimit = 5000; let replyTokenUsed = false; - const { onModelSelected, ...replyPipeline } = createChannelReplyPipeline({ - cfg: config, - agentId: route.agentId, - channel: "line", - accountId: route.accountId, - }); - const core = getLineRuntime(); const turnResult = await core.channel.turn.run({ channel: "line", @@ -252,13 +245,16 @@ export async function monitorLineProvider( dispatchReplyWithBufferedBlockDispatcher: core.channel.reply.dispatchReplyWithBufferedBlockDispatcher, record: ctx.turn.record, - dispatcherOptions: { - ...replyPipeline, - }, - replyOptions: { - onModelSelected, - }, + replyPipeline: {}, delivery: { + durable: (payload, info) => + resolveLineDurableReplyOptions({ + payload, + infoKind: info.kind, + to: ctxPayload.From, + replyToken, + replyTokenUsed, + }), deliver: async (payload) => { const lineData = (payload.channelData?.line as LineChannelData | undefined) ?? {}; @@ -317,7 +313,7 @@ export async function monitorLineProvider( }, }); const dispatchResult = turnResult.dispatched ? turnResult.dispatchResult : undefined; - if (!hasFinalInboundReplyDispatch(dispatchResult)) { + if (!hasFinalChannelMessageReplyDispatch(dispatchResult)) { logVerbose(`line: no response generated for message from ${ctxPayload.From}`); } } catch (err) { diff --git a/extensions/line/src/outbound.ts b/extensions/line/src/outbound.ts index b5703b4515d..6001ed6afbe 100644 --- a/extensions/line/src/outbound.ts +++ b/extensions/line/src/outbound.ts @@ -1,3 +1,8 @@ +import { + defineChannelMessageAdapter, + type ChannelMessageSendResult, + type MessageReceiptPartKind, +} from "openclaw/plugin-sdk/channel-message"; import { createAttachedChannelResultAdapter, createEmptyChannelResult, @@ -8,7 +13,8 @@ import { type ChannelPlugin, type ResolvedLineAccount } from "./channel-api.js"; import { resolveLineOutboundMedia, type LineOutboundMediaResolved } from "./outbound-media.js"; import { buildLineQuickReplyFallbackText } from "./quick-reply-fallback.js"; import { getLineRuntime } from "./runtime.js"; -import type { LineChannelData } from "./types.js"; +import { createLineSendReceipt } from "./send-receipt.js"; +import type { LineChannelData, LineSendResult } from "./types.js"; const loadLineOutboundRuntime = createLazyRuntimeModule(() => import("./outbound.runtime.js")); @@ -91,7 +97,7 @@ export const lineOutboundAdapter: NonNullable lineRuntime?.buildTemplateMessageFromPayload ?? outboundRuntime.buildTemplateMessageFromPayload; - let lastResult: { messageId: string; chatId: string } | null = null; + let lastResult: LineSendResult | null = null; const quickReplies = lineData.quickReplies ?? []; const hasQuickReplies = quickReplies.length > 0; const quickReply = hasQuickReplies @@ -110,7 +116,7 @@ export const lineOutboundAdapter: NonNullable cfg, accountId: accountId ?? undefined, }); - lastResult = { messageId: result.messageId, chatId: result.chatId }; + lastResult = result; } }; @@ -323,7 +329,7 @@ export const lineOutboundAdapter: NonNullable const sendText = outboundRuntime.pushMessageLine; const sendFlex = outboundRuntime.pushFlexMessage; const processed = outboundRuntime.processLineMessage(text); - let result: { messageId: string; chatId: string }; + let result: LineSendResult; if (processed.text.trim()) { result = await sendText(to, processed.text, { verbose: false, @@ -331,7 +337,11 @@ export const lineOutboundAdapter: NonNullable accountId: accountId ?? undefined, }); } else { - result = { messageId: "processed", chatId: to }; + result = { + messageId: "processed", + chatId: to, + receipt: createLineSendReceipt({ messageId: "processed", chatId: to, kind: "card" }), + }; } for (const flexMsg of processed.flexMessages) { const flexContents = flexMsg.contents; @@ -354,3 +364,64 @@ export const lineOutboundAdapter: NonNullable }), }), }; + +function toLineMessageSendResult( + result: Awaited>>, + kind: MessageReceiptPartKind, +): ChannelMessageSendResult { + const source = result as typeof result & { chatId?: string }; + const receipt = + result.receipt ?? + (result.messageId + ? createLineSendReceipt({ + messageId: result.messageId, + chatId: source.chatId ?? "", + kind, + }) + : undefined); + if (!receipt) { + throw new Error("LINE message adapter send did not return a receipt"); + } + return { + messageId: result.messageId || receipt.primaryPlatformMessageId, + receipt, + }; +} + +export const lineMessageAdapter = defineChannelMessageAdapter({ + id: "line", + durableFinal: { + capabilities: { + text: true, + media: true, + messageSendingHooks: true, + }, + }, + send: { + text: async ({ cfg, to, text, accountId }) => { + const result = await lineOutboundAdapter.sendPayload!({ + cfg, + to, + text, + accountId, + payload: { text }, + }); + return toLineMessageSendResult(result, "text"); + }, + media: async ({ cfg, to, text, mediaUrl, accountId }) => { + const result = await lineOutboundAdapter.sendPayload!({ + cfg, + to, + text, + mediaUrl, + accountId, + payload: { text, mediaUrl }, + }); + return toLineMessageSendResult(result, "media"); + }, + }, + receive: { + defaultAckPolicy: "after_agent_dispatch", + supportedAckPolicies: ["after_receive_record", "after_agent_dispatch"], + }, +}); diff --git a/extensions/line/src/send-receipt.ts b/extensions/line/src/send-receipt.ts new file mode 100644 index 00000000000..744f3d7f7e2 --- /dev/null +++ b/extensions/line/src/send-receipt.ts @@ -0,0 +1,32 @@ +import { + createMessageReceiptFromOutboundResults, + type MessageReceipt, + type MessageReceiptPartKind, +} from "openclaw/plugin-sdk/channel-message"; + +export function createLineSendReceipt(params: { + messageId: string; + chatId: string; + kind?: MessageReceiptPartKind; + messageCount?: number; +}): MessageReceipt { + const messageId = params.messageId.trim(); + const chatId = params.chatId.trim(); + return createMessageReceiptFromOutboundResults({ + results: messageId + ? [ + { + channel: "line", + messageId, + chatId, + conversationId: chatId, + meta: { + messageCount: params.messageCount ?? 1, + }, + }, + ] + : [], + ...(chatId ? { threadId: chatId } : {}), + kind: params.kind ?? "unknown", + }); +} diff --git a/extensions/line/src/send.test.ts b/extensions/line/src/send.test.ts index d0bdfabcfb5..2454235792f 100644 --- a/extensions/line/src/send.test.ts +++ b/extensions/line/src/send.test.ts @@ -165,7 +165,8 @@ describe("LINE send helpers", () => { direction: "outbound", }); expect(logVerboseMock).toHaveBeenCalledWith("line: pushed image to U123"); - expect(result).toEqual({ messageId: "push", chatId: "U123" }); + expect(result).toMatchObject({ messageId: "push", chatId: "U123" }); + expect(result.receipt.primaryPlatformMessageId).toBe("push"); }); it("replies when reply token is provided", async () => { @@ -193,7 +194,10 @@ describe("LINE send helpers", () => { ], }); expect(logVerboseMock).toHaveBeenCalledWith("line: replied to C1"); - expect(result).toEqual({ messageId: "reply", chatId: "C1" }); + expect(result).toMatchObject({ messageId: "reply", chatId: "C1" }); + expect(result.receipt.primaryPlatformMessageId).toBe("reply"); + expect(result.receipt.threadId).toBe("C1"); + expect(result.receipt.parts[0]?.kind).toBe("media"); }); it("sends video with explicit image preview URL", async () => { diff --git a/extensions/line/src/send.ts b/extensions/line/src/send.ts index 2bac388a97b..df18e18d94a 100644 --- a/extensions/line/src/send.ts +++ b/extensions/line/src/send.ts @@ -6,6 +6,7 @@ import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; import { resolveLineAccount } from "./accounts.js"; import { resolveLineChannelAccessToken } from "./channel-access-token.js"; import { validateLineMediaUrl } from "./outbound-media.js"; +import { createLineSendReceipt } from "./send-receipt.js"; import type { LineSendResult } from "./types.js"; type Message = messagingApi.Message; @@ -177,6 +178,23 @@ function recordLineOutboundActivity(accountId: string): void { }); } +function resolveLineReceiptKind(messages: readonly Message[]) { + const types = new Set(messages.map((message) => message.type)); + if (types.has("audio")) { + return "voice"; + } + if (types.has("image") || types.has("video")) { + return "media"; + } + if (types.has("flex") || types.has("template") || types.has("location")) { + return "card"; + } + if (types.has("text")) { + return "text"; + } + return "unknown"; +} + async function pushLineMessages( to: string, messages: Message[], @@ -214,6 +232,12 @@ async function pushLineMessages( return { messageId: "push", chatId, + receipt: createLineSendReceipt({ + messageId: "push", + chatId, + kind: resolveLineReceiptKind(messages), + messageCount: messages.length, + }), }; } @@ -293,6 +317,12 @@ export async function sendMessageLine( return { messageId: "reply", chatId, + receipt: createLineSendReceipt({ + messageId: "reply", + chatId, + kind: resolveLineReceiptKind(messages), + messageCount: messages.length, + }), }; } diff --git a/extensions/line/src/types.ts b/extensions/line/src/types.ts index 635d70cec22..11637bd15c7 100644 --- a/extensions/line/src/types.ts +++ b/extensions/line/src/types.ts @@ -1,4 +1,5 @@ import type { BaseProbeResult } from "openclaw/plugin-sdk/channel-contract"; +import type { MessageReceipt } from "openclaw/plugin-sdk/channel-message"; export type LineTokenSource = "config" | "env" | "file" | "none"; @@ -60,6 +61,7 @@ export interface ResolvedLineAccount { export interface LineSendResult { messageId: string; chatId: string; + receipt: MessageReceipt; } export type LineProbeResult = BaseProbeResult & { diff --git a/extensions/line/src/webhook-node.ts b/extensions/line/src/webhook-node.ts index c70fd05f4f4..eabd348f815 100644 --- a/extensions/line/src/webhook-node.ts +++ b/extensions/line/src/webhook-node.ts @@ -1,5 +1,9 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import type { webhook } from "@line/bot-sdk"; +import { + createMessageReceiveContext, + type MessageReceiveContext, +} from "openclaw/plugin-sdk/channel-message"; import { danger, logVerbose, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import { isRequestBodyLimitError, @@ -57,6 +61,7 @@ export function createLineNodeWebhookHandler(params: { return; } + let receiveContext: MessageReceiveContext | undefined; try { const signatureHeader = req.headers["x-line-signature"]; const signature = @@ -99,15 +104,29 @@ export function createLineNodeWebhookHandler(params: { params.onRequestAuthenticated?.(); + receiveContext = createMessageReceiveContext({ + id: `${Date.now()}:line:webhook`, + channel: "line", + message: body, + ackPolicy: body.events?.length ? "after_agent_dispatch" : "after_receive_record", + onAck: () => { + res.statusCode = 200; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify({ status: "ok" })); + }, + }); + if (body.events && body.events.length > 0) { logVerbose(`line: received ${body.events.length} webhook events`); await params.bot.handleWebhook(body); } - res.statusCode = 200; - res.setHeader("Content-Type", "application/json"); - res.end(JSON.stringify({ status: "ok" })); + const ackStage = body.events?.length ? "agent_dispatch" : "receive_record"; + if (receiveContext.shouldAckAfter(ackStage)) { + await receiveContext.ack(); + } } catch (err) { + await receiveContext?.nack(err); if (isRequestBodyLimitError(err, "PAYLOAD_TOO_LARGE")) { res.statusCode = 413; res.setHeader("Content-Type", "application/json"); diff --git a/extensions/line/src/webhook.ts b/extensions/line/src/webhook.ts index 318f1f8ed54..434a593cb9b 100644 --- a/extensions/line/src/webhook.ts +++ b/extensions/line/src/webhook.ts @@ -1,5 +1,9 @@ import type { webhook } from "@line/bot-sdk"; import type { NextFunction, Request, Response } from "express"; +import { + createMessageReceiveContext, + type MessageReceiveContext, +} from "openclaw/plugin-sdk/channel-message"; import { danger, logVerbose, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import { parseLineWebhookBody, validateLineSignature } from "./webhook-utils.js"; @@ -34,6 +38,7 @@ export function createLineWebhookMiddleware( const { channelSecret, onEvents, runtime } = options; return async (req: Request, res: Response, _next: NextFunction): Promise => { + let receiveContext: MessageReceiveContext | undefined; try { const signature = req.headers["x-line-signature"]; @@ -66,13 +71,27 @@ export function createLineWebhookMiddleware( return; } + receiveContext = createMessageReceiveContext({ + id: `${Date.now()}:line:webhook`, + channel: "line", + message: body, + ackPolicy: body.events?.length ? "after_agent_dispatch" : "after_receive_record", + onAck: () => { + res.status(200).json({ status: "ok" }); + }, + }); + if (body.events && body.events.length > 0) { logVerbose(`line: received ${body.events.length} webhook events`); await onEvents(body); } - res.status(200).json({ status: "ok" }); + const ackStage = body.events?.length ? "agent_dispatch" : "receive_record"; + if (receiveContext.shouldAckAfter(ackStage)) { + await receiveContext.ack(); + } } catch (err) { + await receiveContext?.nack(err); runtime?.error?.(danger(`line webhook error: ${String(err)}`)); if (!res.headersSent) { res.status(500).json({ error: "Internal server error" }); diff --git a/extensions/matrix/src/approval-handler.runtime.test.ts b/extensions/matrix/src/approval-handler.runtime.test.ts index 8c746f4be0b..00545f66085 100644 --- a/extensions/matrix/src/approval-handler.runtime.test.ts +++ b/extensions/matrix/src/approval-handler.runtime.test.ts @@ -21,6 +21,29 @@ type MatrixPendingPluginApprovalView = Extract< const MATRIX_APPROVAL_METADATA_KEY = "com.openclaw.approval"; +function buildMatrixReceipt(messageIds: readonly string[], roomId = "!room:example.org") { + return { + primaryPlatformMessageId: messageIds[0], + platformMessageIds: [...messageIds], + parts: messageIds.map((messageId, index) => ({ + platformMessageId: messageId, + kind: "text" as const, + index, + raw: { + channel: "matrix", + messageId, + roomId, + }, + })), + sentAt: 100, + raw: messageIds.map((messageId) => ({ + channel: "matrix", + messageId, + roomId, + })), + }; +} + function buildMatrixApprovalRoomTarget( roomId: string, ): MatrixDeliverPendingParams["plannedTarget"] { @@ -142,7 +165,7 @@ describe("matrixApprovalNativeRuntime", () => { const sendSingleTextMessage = vi.fn().mockResolvedValue({ messageId: "$approval", primaryMessageId: "$approval", - messageIds: ["$approval"], + receipt: buildMatrixReceipt(["$approval"]), roomId: "!room:example.org", }); const reactMessage = vi.fn().mockResolvedValue(undefined); @@ -195,7 +218,7 @@ describe("matrixApprovalNativeRuntime", () => { const sendSingleTextMessage = vi.fn().mockResolvedValue({ messageId: "$plugin-approval", primaryMessageId: "$plugin-approval", - messageIds: ["$plugin-approval"], + receipt: buildMatrixReceipt(["$plugin-approval"]), roomId: "!room:example.org", }); const reactMessage = vi.fn().mockResolvedValue(undefined); @@ -270,7 +293,7 @@ describe("matrixApprovalNativeRuntime", () => { const sendSingleTextMessage = vi.fn().mockResolvedValue({ messageId: "$approval", primaryMessageId: "$approval", - messageIds: ["$approval"], + receipt: buildMatrixReceipt(["$approval"]), roomId: "!room:example.org", }); const reactMessage = vi.fn().mockImplementation(async () => { @@ -318,8 +341,8 @@ describe("matrixApprovalNativeRuntime", () => { .mockRejectedValue(new Error("Matrix single-message text exceeds limit (5000 > 4000)")); const sendMessage = vi.fn().mockResolvedValue({ messageId: "$last", - primaryMessageId: "$primary", - messageIds: ["$primary", "$last"], + primaryMessageId: "$legacy-primary", + receipt: buildMatrixReceipt(["$primary", "$last"]), roomId: "!room:example.org", }); const reactMessage = vi.fn().mockResolvedValue(undefined); @@ -375,7 +398,7 @@ describe("matrixApprovalNativeRuntime", () => { ); expect(entry).toMatchObject({ roomId: "!room:example.org", - messageIds: ["$primary", "$last"], + platformMessageIds: ["$primary", "$last"], reactionEventId: "$primary", }); const bindPending = matrixApprovalNativeRuntime.interactions?.bindPending; diff --git a/extensions/matrix/src/approval-handler.runtime.ts b/extensions/matrix/src/approval-handler.runtime.ts index bdd314ad1be..8ebe0768bef 100644 --- a/extensions/matrix/src/approval-handler.runtime.ts +++ b/extensions/matrix/src/approval-handler.runtime.ts @@ -15,6 +15,10 @@ import type { ExecApprovalRequest, PluginApprovalRequest, } from "openclaw/plugin-sdk/approval-runtime"; +import { + listMessageReceiptPlatformIds, + resolveMessageReceiptPrimaryId, +} from "openclaw/plugin-sdk/channel-message"; import { buildMatrixApprovalReactionHint, listMatrixApprovalReactionBindings, @@ -42,7 +46,7 @@ const MATRIX_APPROVAL_METADATA_KEY = "com.openclaw.approval" as const; type PendingMessage = { roomId: string; - messageIds: readonly string[]; + platformMessageIds: readonly string[]; reactionEventId: string; }; type PreparedMatrixTarget = { @@ -147,7 +151,9 @@ function resolveHandlerContext(params: ChannelApprovalCapabilityHandlerContext): } function normalizePendingMessageIds(entry: PendingMessage): string[] { - return Array.from(new Set(entry.messageIds.map((messageId) => messageId.trim()).filter(Boolean))); + return Array.from( + new Set(entry.platformMessageIds.map((messageId) => messageId.trim()).filter(Boolean)), + ); } function normalizeReactionTargetRef(params: ReactionTargetRef): ReactionTargetRef | null { @@ -438,15 +444,15 @@ export const matrixApprovalNativeRuntime = createChannelApprovalNativeRuntimeAda extraContent: pendingPayload.extraContent, }); } - const messageIds = Array.from( - new Set( - (result.messageIds ?? [result.messageId]) - .map((messageId) => messageId.trim()) - .filter(Boolean), - ), - ); + const receiptMessageIds = listMessageReceiptPlatformIds(result.receipt); + const platformMessageIds = receiptMessageIds.length + ? receiptMessageIds + : [result.messageId.trim()].filter(Boolean); const reactionEventId = - result.primaryMessageId?.trim() || messageIds[0] || result.messageId.trim(); + resolveMessageReceiptPrimaryId(result.receipt) || + result.primaryMessageId?.trim() || + platformMessageIds[0] || + result.messageId.trim(); registerMatrixApprovalReactionTarget({ roomId: result.roomId, eventId: reactionEventId, @@ -467,7 +473,7 @@ export const matrixApprovalNativeRuntime = createChannelApprovalNativeRuntimeAda ); return { roomId: result.roomId, - messageIds, + platformMessageIds, reactionEventId, }; }, diff --git a/extensions/matrix/src/channel.message-adapter.test.ts b/extensions/matrix/src/channel.message-adapter.test.ts new file mode 100644 index 00000000000..e77b229bdea --- /dev/null +++ b/extensions/matrix/src/channel.message-adapter.test.ts @@ -0,0 +1,169 @@ +import { + verifyChannelMessageAdapterCapabilityProofs, + verifyChannelMessageLiveCapabilityAdapterProofs, + verifyChannelMessageLiveFinalizerProofs, +} from "openclaw/plugin-sdk/channel-message"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../runtime-api.js"; + +const mocks = vi.hoisted(() => ({ + sendMessageMatrix: vi.fn(), +})); + +vi.mock("./matrix/send.js", () => ({ + sendMessageMatrix: mocks.sendMessageMatrix, + sendPollMatrix: vi.fn(), + sendTypingMatrix: vi.fn(), +})); + +vi.mock("./runtime.js", () => ({ + getMatrixRuntime: () => ({ + channel: { + text: { + chunkMarkdownText: (text: string) => [text], + }, + }, + }), +})); + +import { matrixPlugin } from "./channel.js"; + +const cfg = { + channels: { + matrix: { + accessToken: "resolved-token", + }, + }, +} as OpenClawConfig; + +describe("matrix channel message adapter", () => { + beforeEach(() => { + mocks.sendMessageMatrix.mockReset(); + mocks.sendMessageMatrix.mockResolvedValue({ messageId: "$event-1", roomId: "!room:example" }); + }); + + it("backs declared durable-final capabilities with runtime outbound proofs", async () => { + const adapter = matrixPlugin.message; + expect(adapter).toBeDefined(); + + const proveText = async () => { + mocks.sendMessageMatrix.mockClear(); + const result = await adapter!.send!.text!({ + cfg, + to: "room:!room:example", + text: "hello", + accountId: "default", + }); + expect(mocks.sendMessageMatrix).toHaveBeenLastCalledWith( + "room:!room:example", + "hello", + expect.objectContaining({ cfg, accountId: "default" }), + ); + expect(result.receipt.platformMessageIds).toEqual(["$event-1"]); + expect(result.receipt.parts[0]?.kind).toBe("text"); + }; + + const proveMedia = async () => { + mocks.sendMessageMatrix.mockClear(); + const result = await adapter!.send!.media!({ + cfg, + to: "room:!room:example", + text: "caption", + mediaUrl: "file:///tmp/cat.png", + mediaLocalRoots: ["/tmp/openclaw"], + accountId: "default", + audioAsVoice: true, + }); + expect(mocks.sendMessageMatrix).toHaveBeenLastCalledWith( + "room:!room:example", + "caption", + expect.objectContaining({ + cfg, + mediaUrl: "file:///tmp/cat.png", + mediaLocalRoots: ["/tmp/openclaw"], + audioAsVoice: true, + }), + ); + expect(result.receipt.parts[0]?.kind).toBe("voice"); + }; + + const proveReplyThread = async () => { + mocks.sendMessageMatrix.mockClear(); + const result = await adapter!.send!.text!({ + cfg, + to: "room:!room:example", + text: "threaded", + accountId: "default", + replyToId: "$reply", + threadId: "$thread", + }); + expect(mocks.sendMessageMatrix).toHaveBeenLastCalledWith( + "room:!room:example", + "threaded", + expect.objectContaining({ + cfg, + replyToId: "$reply", + threadId: "$thread", + }), + ); + expect(result.receipt.replyToId).toBe("$reply"); + expect(result.receipt.threadId).toBe("$thread"); + }; + + await verifyChannelMessageAdapterCapabilityProofs({ + adapterName: "matrixMessageAdapter", + adapter: adapter!, + proofs: { + text: proveText, + media: proveMedia, + replyTo: proveReplyThread, + thread: proveReplyThread, + messageSendingHooks: () => { + expect(adapter!.send!.text).toBeTypeOf("function"); + }, + }, + }); + }); + + it("backs declared live preview finalizer capabilities with adapter proofs", async () => { + const adapter = matrixPlugin.message; + + await verifyChannelMessageLiveCapabilityAdapterProofs({ + adapterName: "matrixMessageAdapter", + adapter: adapter!, + proofs: { + draftPreview: () => { + expect(adapter!.live?.finalizer?.capabilities?.discardPending).toBe(true); + }, + previewFinalization: () => { + expect(adapter!.live?.finalizer?.capabilities?.finalEdit).toBe(true); + }, + progressUpdates: () => { + expect(adapter!.live?.capabilities?.draftPreview).toBe(true); + }, + quietFinalization: () => { + expect(adapter!.live?.finalizer?.capabilities?.previewReceipt).toBe(true); + }, + }, + }); + + await verifyChannelMessageLiveFinalizerProofs({ + adapterName: "matrixMessageAdapter", + adapter: adapter!, + proofs: { + finalEdit: () => { + expect(adapter!.live?.capabilities?.previewFinalization).toBe(true); + }, + normalFallback: () => { + expect(adapter!.send!.text).toBeTypeOf("function"); + }, + discardPending: () => { + expect(adapter!.live?.capabilities?.draftPreview).toBe(true); + }, + previewReceipt: () => { + expect(adapter!.live?.capabilities?.quietFinalization).toBe(true); + }, + }, + }); + }); +}); diff --git a/extensions/matrix/src/channel.ts b/extensions/matrix/src/channel.ts index 218bc26d06d..6497122ca6e 100644 --- a/extensions/matrix/src/channel.ts +++ b/extensions/matrix/src/channel.ts @@ -5,10 +5,12 @@ import { } from "openclaw/plugin-sdk/channel-config-helpers"; import type { ChannelDoctorAdapter } from "openclaw/plugin-sdk/channel-contract"; import { createChatChannelPlugin, type ChannelPlugin } from "openclaw/plugin-sdk/channel-core"; +import { createChannelMessageAdapterFromOutbound } from "openclaw/plugin-sdk/channel-message"; import { createAllowlistProviderOpenWarningCollector, projectAccountConfigWarningCollector, } from "openclaw/plugin-sdk/channel-policy"; +import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-send-result"; import { createScopedAccountReplyToModeResolver } from "openclaw/plugin-sdk/conversation-runtime"; import { createChannelDirectoryAdapter, @@ -319,6 +321,64 @@ function resolveMatrixDeliveryTarget(params: { return null; } +const matrixChannelOutbound: ChannelOutboundAdapter = { + deliveryMode: "direct", + chunker: chunkTextForOutbound, + chunkerMode: "markdown", + textChunkLimit: 4000, + deliveryCapabilities: { + durableFinal: { + text: true, + media: true, + replyTo: true, + thread: true, + messageSendingHooks: true, + }, + }, + shouldSuppressLocalPayloadPrompt: ({ cfg, accountId, payload }) => + shouldSuppressLocalMatrixExecApprovalPrompt({ + cfg, + accountId, + payload, + }), + ...createRuntimeOutboundDelegates({ + getRuntime: loadMatrixChannelRuntime, + sendText: { + resolve: (runtime) => runtime.matrixOutbound.sendText, + unavailableMessage: "Matrix outbound text delivery is unavailable", + }, + sendMedia: { + resolve: (runtime) => runtime.matrixOutbound.sendMedia, + unavailableMessage: "Matrix outbound media delivery is unavailable", + }, + sendPoll: { + resolve: (runtime) => runtime.matrixOutbound.sendPoll, + unavailableMessage: "Matrix outbound poll delivery is unavailable", + }, + }), +}; + +const matrixMessageAdapter = createChannelMessageAdapterFromOutbound({ + id: "matrix", + outbound: matrixChannelOutbound, + live: { + capabilities: { + draftPreview: true, + previewFinalization: true, + progressUpdates: true, + quietFinalization: true, + }, + finalizer: { + capabilities: { + finalEdit: true, + normalFallback: true, + discardPending: true, + previewReceipt: true, + }, + }, + }, +}); + export const matrixPlugin: ChannelPlugin = createChatChannelPlugin({ base: { @@ -416,6 +476,7 @@ export const matrixPlugin: ChannelPlugin = }), resolver: matrixResolverAdapter, actions: matrixMessageActions, + message: matrixMessageAdapter, secrets: { secretTargetRegistryEntries, collectRuntimeConfigAssignments, @@ -580,31 +641,5 @@ export const matrixPlugin: ChannelPlugin = }; }, }, - outbound: { - deliveryMode: "direct", - chunker: chunkTextForOutbound, - chunkerMode: "markdown", - textChunkLimit: 4000, - shouldSuppressLocalPayloadPrompt: ({ cfg, accountId, payload }) => - shouldSuppressLocalMatrixExecApprovalPrompt({ - cfg, - accountId, - payload, - }), - ...createRuntimeOutboundDelegates({ - getRuntime: loadMatrixChannelRuntime, - sendText: { - resolve: (runtime) => runtime.matrixOutbound.sendText, - unavailableMessage: "Matrix outbound text delivery is unavailable", - }, - sendMedia: { - resolve: (runtime) => runtime.matrixOutbound.sendMedia, - unavailableMessage: "Matrix outbound media delivery is unavailable", - }, - sendPoll: { - resolve: (runtime) => runtime.matrixOutbound.sendPoll, - unavailableMessage: "Matrix outbound poll delivery is unavailable", - }, - }), - }, + outbound: matrixChannelOutbound, }); diff --git a/extensions/matrix/src/matrix/draft-stream.test.ts b/extensions/matrix/src/matrix/draft-stream.test.ts index 1350162c996..5a977fd6dcf 100644 --- a/extensions/matrix/src/matrix/draft-stream.test.ts +++ b/extensions/matrix/src/matrix/draft-stream.test.ts @@ -64,7 +64,12 @@ const sendModuleMocks = vi.hoisted(() => { messageId: eventId ?? "unknown", roomId, primaryMessageId: eventId ?? "unknown", - messageIds: eventId ? [eventId] : [], + receipt: { + ...(eventId ? { primaryPlatformMessageId: eventId } : {}), + platformMessageIds: eventId ? [eventId] : [], + parts: eventId ? [{ platformMessageId: eventId, kind: "text" as const, index: 0 }] : [], + sentAt: 123, + }, }; }, ); diff --git a/extensions/matrix/src/matrix/monitor/handler.ts b/extensions/matrix/src/matrix/monitor/handler.ts index 6d0441946d6..e7b62f8717f 100644 --- a/extensions/matrix/src/matrix/monitor/handler.ts +++ b/extensions/matrix/src/matrix/monitor/handler.ts @@ -1,3 +1,9 @@ +import { + createPreviewMessageReceipt, + defineFinalizableLivePreviewAdapter, + deliverWithFinalizableLivePreviewAdapter, + type MessageReceipt, +} from "openclaw/plugin-sdk/channel-message"; import { createChannelProgressDraftGate, formatChannelProgressDraftLine, @@ -894,14 +900,14 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam return undefined; } - const _messageId = event.event_id ?? ""; - const _threadRootId = resolveMatrixThreadRootId({ event, content }); + const messageId = event.event_id ?? ""; + const threadRootId = resolveMatrixThreadRootId({ event, content }); const thread = resolveMatrixThreadRouting({ isDirectMessage, threadReplies, dmThreadReplies, - messageId: _messageId, - threadRootId: _threadRootId, + messageId, + threadRootId, }); const { route: _route, @@ -1001,7 +1007,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam sender: senderId, body: pendingHistoryBody, timestamp: eventTs ?? undefined, - messageId: _messageId, + messageId, }; roomHistoryTracker.recordPending(roomId, pendingEntry); } @@ -1116,7 +1122,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam sender: senderName, body: bodyText, timestamp: eventTs ?? undefined, - messageId: _messageId, + messageId, }) : undefined; const inboundHistory = preparedTrigger?.history; @@ -1139,9 +1145,9 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam commandBodyText, media, locationPayload, - messageId: _messageId, + messageId, triggerSnapshot, - threadRootId: _threadRootId, + threadRootId, thread, effectiveAllowFrom, effectiveGroupAllowFrom, @@ -1194,9 +1200,9 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam commandBodyText, media, locationPayload, - messageId: _messageId, + messageId, triggerSnapshot, - threadRootId: _threadRootId, + threadRootId, thread, effectiveGroupAllowFrom, effectiveRoomUsers, @@ -1233,8 +1239,8 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam kind, senderAllowed: isRoomContextSenderAllowed(contextSenderId), }).include; - let threadContext = _threadRootId - ? await resolveThreadContext({ roomId, threadRootId: _threadRootId }) + let threadContext = threadRootId + ? await resolveThreadContext({ roomId, threadRootId }) : undefined; let threadContextBlockedByPolicy = false; if ( @@ -1246,7 +1252,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam threadContext = undefined; } let replyContext: Awaited> | undefined; - if (replyToEventId && replyToEventId === _threadRootId && threadContext?.summary) { + if (replyToEventId && replyToEventId === threadRootId && threadContext?.summary) { replyContext = { replyToBody: threadContext.summary, replyToSender: threadContext.senderLabel, @@ -1254,7 +1260,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam }; } else if ( replyToEventId && - replyToEventId === _threadRootId && + replyToEventId === threadRootId && threadContextBlockedByPolicy ) { replyContext = await resolveReplyContext({ roomId, eventId: replyToEventId }); @@ -1273,7 +1279,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam const roomInfo = isRoom ? await getRoomInfo(roomId) : undefined; const roomName = roomInfo?.name; const envelopeFrom = isDirectMessage ? senderName : (roomName ?? roomId); - const textWithId = `${bodyText}\n[matrix event id: ${_messageId} room: ${roomId}]`; + const textWithId = `${bodyText}\n[matrix event id: ${messageId} room: ${roomId}]`; const storePath = core.channel.session.resolveStorePath(cfg.session?.store, { agentId: _route.agentId, }); @@ -1330,7 +1336,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam Provider: "matrix" as const, Surface: "matrix" as const, WasMentioned: isRoom ? wasMentioned : undefined, - MessageSid: _messageId, + MessageSid: messageId, ReplyToId: threadTarget ? undefined : (replyToEventId ?? undefined), ReplyToBody: replyContext?.replyToBody, ReplyToSender: replyContext?.replyToSender, @@ -1377,22 +1383,22 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam shouldBypassMention, }), ); - if (shouldAckReaction() && _messageId) { + if (shouldAckReaction() && messageId) { loadMatrixSendModule() .then(({ reactMatrixMessage }) => - reactMatrixMessage(roomId, _messageId, ackReaction, client), + reactMatrixMessage(roomId, messageId, ackReaction, client), ) .catch((err) => { logVerboseMessage(`matrix react failed for room ${roomId}: ${String(err)}`); }); } - if (_messageId) { + if (messageId) { loadMatrixSendModule() - .then(({ sendReadReceiptMatrix }) => sendReadReceiptMatrix(roomId, _messageId, client)) + .then(({ sendReadReceiptMatrix }) => sendReadReceiptMatrix(roomId, messageId, client)) .catch((err) => { logVerboseMessage( - `matrix: read receipt failed room=${roomId} id=${_messageId}: ${String(err)}`, + `matrix: read receipt failed room=${roomId} id=${messageId}: ${String(err)}`, ); }); } @@ -1443,7 +1449,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam const draftStreamingEnabled = streaming !== "off"; const quietDraftStreaming = streaming === "quiet" || streaming === "progress"; const progressDraftStreaming = streaming === "progress"; - const draftReplyToId = replyToMode !== "off" && !threadTarget ? _messageId : undefined; + const draftReplyToId = replyToMode !== "off" && !threadTarget ? messageId : undefined; const draftStream: MatrixDraftStreamHandle | undefined = draftStreamingEnabled ? await loadMatrixDraftStream().then(({ createMatrixDraftStream }) => createMatrixDraftStream({ @@ -1785,39 +1791,77 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam !payloadReplyMismatch && !mustDeliverFinalNormally ) { - try { - const requiresFinalEdit = - quietDraftStreaming || !draftStream.matchesPreparedText(payload.text); - if (requiresFinalEdit) { - const { editMessageMatrix } = await loadMatrixSendModule(); - await editMessageMatrix(roomId, draftEventId, payload.text, { - client, + const finalPreviewText = payload.text; + await deliverWithFinalizableLivePreviewAdapter< + ReplyPayload, + string, + { + text: string; + finalizeLive: boolean; + extraContent?: Record; + } + >({ + kind: "final", + payload, + adapter: defineFinalizableLivePreviewAdapter({ + draft: { + flush: async () => {}, + clear: async () => {}, + discardPending: async () => {}, + id: () => draftEventId, + }, + buildFinalEdit: () => ({ + text: finalPreviewText, + finalizeLive: !( + quietDraftStreaming || !draftStream.matchesPreparedText(finalPreviewText) + ), + ...(quietDraftStreaming + ? { extraContent: buildMatrixFinalizedPreviewContent() } + : {}), + }), + editFinal: async (_draftEventId, edit) => { + if (edit.finalizeLive) { + if (!(await draftStream.finalizeLive())) { + throw new Error("Matrix draft live finalize failed"); + } + return; + } + const { editMessageMatrix } = await loadMatrixSendModule(); + await editMessageMatrix(roomId, _draftEventId, edit.text, { + client, + cfg, + threadId: threadTarget, + accountId: _route.accountId, + extraContent: edit.extraContent, + }); + }, + createPreviewReceipt: (id): MessageReceipt => + createPreviewMessageReceipt({ + id, + ...(threadTarget ? { threadId: threadTarget } : {}), + ...(currentDraftReplyToId ? { replyToId: currentDraftReplyToId } : {}), + }), + logPreviewEditFailure: (err) => { + logVerboseMessage(`matrix: preview final edit failed: ${String(err)}`); + }, + }), + deliverNormally: async () => { + await redactMatrixDraftEvent(client, roomId, draftEventId); + await deliverMatrixReplies({ cfg, + replies: [payload], + roomId, + client, + runtime, + textLimit, + replyToMode, threadId: threadTarget, accountId: _route.accountId, - extraContent: quietDraftStreaming - ? buildMatrixFinalizedPreviewContent() - : undefined, + mediaLocalRoots, + tableMode, }); - } else if (!(await draftStream.finalizeLive())) { - throw new Error("Matrix draft live finalize failed"); - } - } catch { - await redactMatrixDraftEvent(client, roomId, draftEventId); - await deliverMatrixReplies({ - cfg, - replies: [payload], - roomId, - client, - runtime, - textLimit, - replyToMode, - threadId: threadTarget, - accountId: _route.accountId, - mediaLocalRoots, - tableMode, - }); - } + }, + }); draftConsumed = true; } else if (draftEventId && hasMedia && !payloadReplyMismatch) { let textEditOk = !mustDeliverFinalNormally; @@ -1968,7 +2012,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam raw: event, adapter: { ingest: () => ({ - id: _messageId, + id: messageId, rawText: bodyText, textForAgent: ctxPayload.BodyForAgent, textForCommands: ctxPayload.CommandBody, @@ -2108,13 +2152,13 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam if (finalReplyDeliveryFailed) { if (retryableReplyDeliveryFailed) { logVerboseMessage( - `matrix: final reply delivery failed room=${roomId} id=${_messageId}; leaving event uncommitted`, + `matrix: final reply delivery failed room=${roomId} id=${messageId}; leaving event uncommitted`, ); // Explicit retryable failures reopen replay so the same history can be retried. return; } logVerboseMessage( - `matrix: final reply delivery failed room=${roomId} id=${_messageId}; keeping replay committed`, + `matrix: final reply delivery failed room=${roomId} id=${messageId}; keeping replay committed`, ); await commitInboundEventIfClaimed(); return; @@ -2122,13 +2166,13 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam if (!queuedFinal && nonFinalReplyDeliveryFailed) { if (retryableReplyDeliveryFailed) { logVerboseMessage( - `matrix: non-final reply delivery failed room=${roomId} id=${_messageId}; leaving event uncommitted`, + `matrix: non-final reply delivery failed room=${roomId} id=${messageId}; leaving event uncommitted`, ); // Explicit retryable failures reopen replay. return; } logVerboseMessage( - `matrix: non-final reply delivery failed room=${roomId} id=${_messageId}; keeping replay committed`, + `matrix: non-final reply delivery failed room=${roomId} id=${messageId}; keeping replay committed`, ); await commitInboundEventIfClaimed(); return; @@ -2137,7 +2181,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam // Only advance to the snapshot position — messages added during async processing remain // visible for the next trigger. if (isRoom && triggerSnapshot) { - roomHistoryTracker.consumeHistory(_route.agentId, roomId, triggerSnapshot, _messageId); + roomHistoryTracker.consumeHistory(_route.agentId, roomId, triggerSnapshot, messageId); } if (!hasFinalInboundReplyDispatch({ queuedFinal, counts })) { await commitInboundEventIfClaimed(); diff --git a/extensions/matrix/src/matrix/send.test.ts b/extensions/matrix/src/matrix/send.test.ts index 4de7ee19569..cb9d6d998fa 100644 --- a/extensions/matrix/src/matrix/send.test.ts +++ b/extensions/matrix/src/matrix/send.test.ts @@ -627,7 +627,15 @@ describe("sendMessageMatrix threads", () => { roomId: "!room:example", primaryMessageId: "$m1", messageId: "$m3", - messageIds: ["$m1", "$m2", "$m3"], + receipt: { + primaryPlatformMessageId: "$m1", + platformMessageIds: ["$m1", "$m2", "$m3"], + parts: [ + expect.objectContaining({ platformMessageId: "$m1", kind: "text" }), + expect.objectContaining({ platformMessageId: "$m2", kind: "text" }), + expect.objectContaining({ platformMessageId: "$m3", kind: "text" }), + ], + }, }); }); @@ -720,7 +728,7 @@ describe("sendSingleTextMessageMatrix", () => { it("merges extra content fields into single-event sends", async () => { const { client, sendMessage } = makeClient(); - await sendSingleTextMessageMatrix("room:!room:example", "done", { + const result = await sendSingleTextMessageMatrix("room:!room:example", "done", { client, cfg: {} as never, extraContent: { [MATRIX_OPENCLAW_FINALIZED_PREVIEW_KEY]: true }, @@ -730,6 +738,11 @@ describe("sendSingleTextMessageMatrix", () => { body: "done", [MATRIX_OPENCLAW_FINALIZED_PREVIEW_KEY]: true, }); + expect(result.receipt).toMatchObject({ + primaryPlatformMessageId: "evt1", + platformMessageIds: ["evt1"], + parts: [expect.objectContaining({ platformMessageId: "evt1", kind: "text" })], + }); }); }); diff --git a/extensions/matrix/src/matrix/send.ts b/extensions/matrix/src/matrix/send.ts index e8189729c62..f81838abc29 100644 --- a/extensions/matrix/src/matrix/send.ts +++ b/extensions/matrix/src/matrix/send.ts @@ -1,3 +1,7 @@ +import { + createMessageReceiptFromOutboundResults, + type MessageReceiptPartKind, +} from "openclaw/plugin-sdk/channel-message"; import type { MarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime"; import { requireRuntimeConfig } from "openclaw/plugin-sdk/plugin-config-runtime"; import type { PollInput } from "../runtime-api.js"; @@ -66,6 +70,25 @@ type MatrixClientResolveOpts = { accountId?: string | null; }; +function createMatrixSendReceipt(params: { + roomId: string; + platformMessageIds: readonly string[]; + kind: MessageReceiptPartKind; + replyToId?: string; + threadId?: string | null; +}) { + return createMessageReceiptFromOutboundResults({ + kind: params.kind, + ...(params.replyToId ? { replyToId: params.replyToId } : {}), + ...(params.threadId ? { threadId: params.threadId } : {}), + results: params.platformMessageIds.map((messageId) => ({ + channel: "matrix", + messageId, + roomId: params.roomId, + })), + }); +} + function isMatrixClient(value: MatrixClient | MatrixClientResolveOpts): value is MatrixClient { return typeof (value as { sendEvent?: unknown }).sendEvent === "function"; } @@ -219,8 +242,9 @@ export async function sendMessageMatrix( return eventId; }; - const messageIds: string[] = []; + const platformMessageIds: string[] = []; let lastMessageId = ""; + let receiptKind: MessageReceiptPartKind = "text"; if (opts.mediaUrl) { const maxBytes = resolveMediaMaxBytes(opts.accountId, cfg); const media = await loadOutboundMediaFromUrl(opts.mediaUrl, { @@ -246,6 +270,7 @@ export async function sendMessageMatrix( fileName: media.fileName, }); const msgtype = useVoice ? MsgType.Audio : baseMsgType; + receiptKind = useVoice ? "voice" : "media"; const isImage = msgtype === MsgType.Image; const imageInfo = isImage ? await prepareImageInfo({ @@ -278,7 +303,7 @@ export async function sendMessageMatrix( const eventId = await sendContent(content); lastMessageId = eventId ?? lastMessageId; if (eventId) { - messageIds.push(eventId); + platformMessageIds.push(eventId); } const textChunks = useVoice ? chunks : rest; // Voice messages use a generic media body ("Voice message"), so keep any @@ -298,7 +323,7 @@ export async function sendMessageMatrix( const followupEventId = await sendContent(followup); lastMessageId = followupEventId ?? lastMessageId; if (followupEventId) { - messageIds.push(followupEventId); + platformMessageIds.push(followupEventId); } } } else { @@ -316,7 +341,7 @@ export async function sendMessageMatrix( const eventId = await sendContent(content); lastMessageId = eventId ?? lastMessageId; if (eventId) { - messageIds.push(eventId); + platformMessageIds.push(eventId); } } } @@ -324,8 +349,14 @@ export async function sendMessageMatrix( return { messageId: lastMessageId || "unknown", roomId, - primaryMessageId: messageIds[0] ?? (lastMessageId || "unknown"), - messageIds, + primaryMessageId: platformMessageIds[0] ?? (lastMessageId || "unknown"), + receipt: createMatrixSendReceipt({ + roomId, + platformMessageIds, + kind: receiptKind, + replyToId: opts.replyToId, + threadId, + }), }; }, ); @@ -474,11 +505,18 @@ export async function sendSingleTextMessageMatrix( (content as Record)[MSC4357_LIVE_KEY] = {}; } const eventId = await client.sendMessage(resolvedRoom, content); + const platformMessageIds = eventId ? [eventId] : []; return { messageId: eventId ?? "unknown", roomId: resolvedRoom, primaryMessageId: eventId ?? "unknown", - messageIds: eventId ? [eventId] : [], + receipt: createMatrixSendReceipt({ + roomId: resolvedRoom, + platformMessageIds, + kind: "text", + replyToId: opts.replyToId, + threadId: normalizedThreadId, + }), }; }, ); diff --git a/extensions/matrix/src/matrix/send/types.ts b/extensions/matrix/src/matrix/send/types.ts index 78e7113fd95..af010551abb 100644 --- a/extensions/matrix/src/matrix/send/types.ts +++ b/extensions/matrix/src/matrix/send/types.ts @@ -1,3 +1,4 @@ +import type { MessageReceipt } from "openclaw/plugin-sdk/channel-message"; import type { CoreConfig } from "../../types.js"; import { MATRIX_ANNOTATION_RELATION_TYPE, MATRIX_REACTION_EVENT_TYPE } from "../reaction-common.js"; import type { @@ -79,7 +80,7 @@ export type MatrixSendResult = { messageId: string; roomId: string; primaryMessageId?: string; - messageIds?: string[]; + receipt: MessageReceipt; }; export type MatrixSendOpts = { diff --git a/extensions/matrix/src/runtime-api.ts b/extensions/matrix/src/runtime-api.ts index 261b017ca91..6579f6437b2 100644 --- a/extensions/matrix/src/runtime-api.ts +++ b/extensions/matrix/src/runtime-api.ts @@ -90,7 +90,7 @@ export { export { resolveOutboundSendDep } from "openclaw/plugin-sdk/outbound-send-deps"; export { resolveAgentIdFromSessionKey } from "openclaw/plugin-sdk/routing"; export { chunkTextForOutbound } from "openclaw/plugin-sdk/text-chunking"; -export { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline"; +export { createChannelMessageReplyPipeline } from "openclaw/plugin-sdk/channel-message"; export { loadOutboundMediaFromUrl } from "openclaw/plugin-sdk/outbound-media"; export { normalizePollInput, type PollInput } from "openclaw/plugin-sdk/poll-runtime"; export { writeJsonFileAtomically } from "openclaw/plugin-sdk/json-store"; diff --git a/extensions/mattermost/runtime-api.ts b/extensions/mattermost/runtime-api.ts index 4b0a7c80e66..d2041ec6740 100644 --- a/extensions/mattermost/runtime-api.ts +++ b/extensions/mattermost/runtime-api.ts @@ -57,7 +57,7 @@ export { resolveEffectiveAllowFromLists, } from "openclaw/plugin-sdk/channel-policy"; export { evaluateSenderGroupAccessForPolicy } from "openclaw/plugin-sdk/group-access"; -export { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline"; +export { createChannelMessageReplyPipeline } from "openclaw/plugin-sdk/channel-message"; export { logTypingFailure } from "openclaw/plugin-sdk/channel-feedback"; export { loadOutboundMediaFromUrl } from "openclaw/plugin-sdk/outbound-media"; export { rawDataToString } from "openclaw/plugin-sdk/webhook-ingress"; diff --git a/extensions/mattermost/src/channel.message-adapter.test.ts b/extensions/mattermost/src/channel.message-adapter.test.ts new file mode 100644 index 00000000000..75a393e4ae8 --- /dev/null +++ b/extensions/mattermost/src/channel.message-adapter.test.ts @@ -0,0 +1,151 @@ +import { + verifyChannelMessageAdapterCapabilityProofs, + verifyChannelMessageLiveCapabilityAdapterProofs, + verifyChannelMessageLiveFinalizerProofs, +} from "openclaw/plugin-sdk/channel-message"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const sendMessageMattermostMock = vi.hoisted(() => vi.fn()); + +vi.mock("./mattermost/send.js", () => ({ + sendMessageMattermost: sendMessageMattermostMock, +})); + +import { mattermostPlugin } from "./channel.js"; + +describe("mattermost channel message adapter", () => { + beforeEach(() => { + sendMessageMattermostMock.mockReset(); + sendMessageMattermostMock.mockResolvedValue({ + messageId: "post-1", + channelId: "channel-1", + }); + }); + + it("backs declared durable-final capabilities with outbound send proofs", async () => { + const adapter = mattermostPlugin.message; + expect(adapter).toBeDefined(); + + const proveText = async () => { + sendMessageMattermostMock.mockClear(); + const result = await adapter!.send!.text!({ + cfg: {}, + to: "channel:team-1", + text: "hello", + accountId: "default", + }); + expect(sendMessageMattermostMock).toHaveBeenLastCalledWith("channel:team-1", "hello", { + cfg: {}, + accountId: "default", + replyToId: undefined, + }); + expect(result.receipt.platformMessageIds).toEqual(["post-1"]); + expect(result.receipt.parts[0]?.kind).toBe("text"); + }; + + const proveMedia = async () => { + sendMessageMattermostMock.mockClear(); + const result = await adapter!.send!.media!({ + cfg: {}, + to: "channel:team-1", + text: "caption", + mediaUrl: "https://example.com/a.png", + mediaLocalRoots: ["/tmp/media"], + accountId: "default", + }); + expect(sendMessageMattermostMock).toHaveBeenLastCalledWith("channel:team-1", "caption", { + cfg: {}, + accountId: "default", + mediaUrl: "https://example.com/a.png", + mediaLocalRoots: ["/tmp/media"], + replyToId: undefined, + }); + expect(result.receipt.parts[0]?.kind).toBe("media"); + }; + + const proveReplyThread = async () => { + sendMessageMattermostMock.mockClear(); + const result = await adapter!.send!.text!({ + cfg: {}, + to: "channel:parent-1", + text: "threaded", + accountId: "default", + threadId: "thread-1", + }); + expect(sendMessageMattermostMock).toHaveBeenLastCalledWith("channel:parent-1", "threaded", { + cfg: {}, + accountId: "default", + replyToId: "thread-1", + }); + expect(result.receipt.threadId).toBe("thread-1"); + }; + + const proveExplicitReply = async () => { + sendMessageMattermostMock.mockClear(); + const result = await adapter!.send!.text!({ + cfg: {}, + to: "channel:parent-1", + text: "reply", + accountId: "default", + replyToId: "post-parent-1", + threadId: "thread-1", + }); + expect(sendMessageMattermostMock).toHaveBeenLastCalledWith("channel:parent-1", "reply", { + cfg: {}, + accountId: "default", + replyToId: "post-parent-1", + }); + expect(result.receipt.replyToId).toBe("post-parent-1"); + }; + + await verifyChannelMessageAdapterCapabilityProofs({ + adapterName: "mattermostMessageAdapter", + adapter: adapter!, + proofs: { + text: proveText, + media: proveMedia, + replyTo: proveExplicitReply, + thread: proveReplyThread, + messageSendingHooks: () => { + expect(adapter!.send!.text).toBeTypeOf("function"); + }, + }, + }); + }); + + it("backs declared live preview finalizer capabilities with adapter proofs", async () => { + const adapter = mattermostPlugin.message; + + await verifyChannelMessageLiveCapabilityAdapterProofs({ + adapterName: "mattermostMessageAdapter", + adapter: adapter!, + proofs: { + draftPreview: () => { + expect(adapter!.live?.finalizer?.capabilities?.discardPending).toBe(true); + }, + previewFinalization: () => { + expect(adapter!.live?.finalizer?.capabilities?.finalEdit).toBe(true); + }, + progressUpdates: () => { + expect(adapter!.live?.capabilities?.draftPreview).toBe(true); + }, + }, + }); + + await verifyChannelMessageLiveFinalizerProofs({ + adapterName: "mattermostMessageAdapter", + adapter: adapter!, + proofs: { + finalEdit: () => { + expect(adapter!.live?.capabilities?.previewFinalization).toBe(true); + }, + normalFallback: () => { + expect(adapter!.send!.text).toBeTypeOf("function"); + }, + discardPending: () => { + expect(adapter!.live?.capabilities?.draftPreview).toBe(true); + }, + }, + }); + }); +}); diff --git a/extensions/mattermost/src/channel.test.ts b/extensions/mattermost/src/channel.test.ts index 6bde71aa6f8..f98d2fce64c 100644 --- a/extensions/mattermost/src/channel.test.ts +++ b/extensions/mattermost/src/channel.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../runtime-api.js"; -import { createChannelReplyPipeline } from "../runtime-api.js"; +import { createChannelMessageReplyPipeline } from "../runtime-api.js"; const { sendMessageMattermostMock, mockFetchGuard } = vi.hoisted(() => ({ sendMessageMattermostMock: vi.fn(), @@ -582,7 +582,7 @@ describe("mattermostPlugin", () => { }, }; - const prefixContext = createChannelReplyPipeline({ + const prefixContext = createChannelMessageReplyPipeline({ cfg, agentId: "main", channel: "mattermost", diff --git a/extensions/mattermost/src/channel.ts b/extensions/mattermost/src/channel.ts index c5dfcab1e1f..3458e5df81c 100644 --- a/extensions/mattermost/src/channel.ts +++ b/extensions/mattermost/src/channel.ts @@ -4,8 +4,13 @@ import type { ChannelMessageToolDiscovery, } from "openclaw/plugin-sdk/channel-contract"; import { createChatChannelPlugin } from "openclaw/plugin-sdk/channel-core"; +import { createChannelMessageAdapterFromOutbound } from "openclaw/plugin-sdk/channel-message"; import { createLoggedPairingApprovalNotifier } from "openclaw/plugin-sdk/channel-pairing"; import { createRestrictSendersChannelSecurity } from "openclaw/plugin-sdk/channel-policy"; +import { + createAttachedChannelResultAdapter, + type ChannelOutboundAdapter, +} from "openclaw/plugin-sdk/channel-send-result"; import { createChannelDirectoryAdapter } from "openclaw/plugin-sdk/directory-runtime"; import { buildPassiveProbedChannelStatusSummary } from "openclaw/plugin-sdk/extension-shared"; import { @@ -260,6 +265,83 @@ function parseMattermostReactActionParams(params: Record): { }; } +const mattermostOutbound: ChannelOutboundAdapter = { + deliveryMode: "direct", + chunker: chunkTextForOutbound, + chunkerMode: "markdown", + textChunkLimit: 4000, + deliveryCapabilities: { + durableFinal: { + text: true, + media: true, + replyTo: true, + thread: true, + messageSendingHooks: true, + }, + }, + resolveTarget: ({ to }) => { + const trimmed = to?.trim(); + if (!trimmed) { + return { + ok: false, + error: new Error( + "Delivering to Mattermost requires --to ", + ), + }; + } + return { ok: true, to: trimmed }; + }, + ...createAttachedChannelResultAdapter({ + channel: "mattermost", + sendText: async ({ cfg, to, text, accountId, replyToId, threadId }) => + await ( + await loadMattermostChannelRuntime() + ).sendMessageMattermost(to, text, { + cfg, + accountId: accountId ?? undefined, + replyToId: replyToId ?? (threadId != null ? String(threadId) : undefined), + }), + sendMedia: async ({ + cfg, + to, + text, + mediaUrl, + mediaLocalRoots, + accountId, + replyToId, + threadId, + }) => + await ( + await loadMattermostChannelRuntime() + ).sendMessageMattermost(to, text, { + cfg, + accountId: accountId ?? undefined, + mediaUrl, + mediaLocalRoots, + replyToId: replyToId ?? (threadId != null ? String(threadId) : undefined), + }), + }), +}; + +const mattermostMessageAdapter = createChannelMessageAdapterFromOutbound({ + id: "mattermost", + outbound: mattermostOutbound, + live: { + capabilities: { + draftPreview: true, + previewFinalization: true, + progressUpdates: true, + }, + finalizer: { + capabilities: { + finalEdit: true, + normalFallback: true, + discardPending: true, + }, + }, + }, +}); + export const mattermostPlugin: ChannelPlugin = createChatChannelPlugin({ base: { id: "mattermost", @@ -291,6 +373,7 @@ export const mattermostPlugin: ChannelPlugin = create resolveRequireMention: resolveMattermostGroupRequireMention, }, actions: mattermostMessageActions, + message: mattermostMessageAdapter, secrets: { secretTargetRegistryEntries, collectRuntimeConfigAssignments, @@ -431,54 +514,5 @@ export const mattermostPlugin: ChannelPlugin = create }), }, security: mattermostSecurityAdapter, - outbound: { - base: { - deliveryMode: "direct", - chunker: chunkTextForOutbound, - chunkerMode: "markdown", - textChunkLimit: 4000, - resolveTarget: ({ to }) => { - const trimmed = to?.trim(); - if (!trimmed) { - return { - ok: false, - error: new Error( - "Delivering to Mattermost requires --to ", - ), - }; - } - return { ok: true, to: trimmed }; - }, - }, - attachedResults: { - channel: "mattermost", - sendText: async ({ cfg, to, text, accountId, replyToId, threadId }) => - await ( - await loadMattermostChannelRuntime() - ).sendMessageMattermost(to, text, { - cfg, - accountId: accountId ?? undefined, - replyToId: replyToId ?? (threadId != null ? String(threadId) : undefined), - }), - sendMedia: async ({ - cfg, - to, - text, - mediaUrl, - mediaLocalRoots, - accountId, - replyToId, - threadId, - }) => - await ( - await loadMattermostChannelRuntime() - ).sendMessageMattermost(to, text, { - cfg, - accountId: accountId ?? undefined, - mediaUrl, - mediaLocalRoots, - replyToId: replyToId ?? (threadId != null ? String(threadId) : undefined), - }), - }, - }, + outbound: mattermostOutbound, }); diff --git a/extensions/mattermost/src/mattermost/monitor.inbound-system-event.test.ts b/extensions/mattermost/src/mattermost/monitor.inbound-system-event.test.ts index 058ffbbb895..5a1de8f51f9 100644 --- a/extensions/mattermost/src/mattermost/monitor.inbound-system-event.test.ts +++ b/extensions/mattermost/src/mattermost/monitor.inbound-system-event.test.ts @@ -128,7 +128,7 @@ vi.mock("./runtime-api.js", async () => { readStoreForDmPolicy: vi.fn(async () => []), upsertPairingRequest: vi.fn(async () => ({ code: "123456", created: true })), })), - createChannelReplyPipeline: vi.fn(() => ({ + createChannelMessageReplyPipeline: vi.fn(() => ({ onModelSelected: vi.fn(), typingCallbacks: {}, })), diff --git a/extensions/mattermost/src/mattermost/monitor.ts b/extensions/mattermost/src/mattermost/monitor.ts index 77a2b33189f..0bafa962d40 100644 --- a/extensions/mattermost/src/mattermost/monitor.ts +++ b/extensions/mattermost/src/mattermost/monitor.ts @@ -1,4 +1,7 @@ -import { deliverFinalizableDraftPreview } from "openclaw/plugin-sdk/channel-lifecycle"; +import { + defineFinalizableLivePreviewAdapter, + deliverWithFinalizableLivePreviewAdapter, +} from "openclaw/plugin-sdk/channel-message"; import { resolveChannelStreamingPreviewToolProgress } from "openclaw/plugin-sdk/channel-streaming"; import { createClaimableDedupe, type ClaimableDedupe } from "openclaw/plugin-sdk/persistent-dedupe"; import { isReasoningReplyPayload } from "openclaw/plugin-sdk/reply-payload"; @@ -77,7 +80,7 @@ import { buildModelsProviderData, buildPendingHistoryContextFromMap, createChannelPairingController, - createChannelReplyPipeline, + createChannelMessageReplyPipeline, DEFAULT_GROUP_HISTORY_LIMIT, DM_GROUP_ACCESS_REASON, isDangerousNameMatchingEnabled, @@ -337,49 +340,51 @@ export async function deliverMattermostReplyWithDraftPreview( return; } - await deliverFinalizableDraftPreview({ + await deliverWithFinalizableLivePreviewAdapter({ kind: params.info.kind, payload: params.payload, - draft: { - flush: params.draftStream.flush, - clear: params.draftStream.clear, - discardPending: params.draftStream.discardPending, - seal: params.draftStream.seal, - id: params.draftStream.postId, - }, - buildFinalEdit: (payload) => { - const hasMedia = Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0; - const previewFinalText = params.resolvePreviewFinalText(payload.text); + adapter: defineFinalizableLivePreviewAdapter({ + draft: { + flush: params.draftStream.flush, + clear: params.draftStream.clear, + discardPending: params.draftStream.discardPending, + seal: params.draftStream.seal, + id: params.draftStream.postId, + }, + buildFinalEdit: (payload) => { + const hasMedia = Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0; + const previewFinalText = params.resolvePreviewFinalText(payload.text); - if ( - hasMedia || - typeof previewFinalText !== "string" || - payload.isError || - !canFinalizeMattermostPreviewInPlace({ - kind: params.kind, - previewRootId: params.effectiveReplyToId, - threadRootId: params.effectiveReplyToId, - replyToId: payload.replyToId, - }) - ) { - return undefined; - } - return { message: previewFinalText }; - }, - editFinal: async (previewPostId, edit) => { - await updateMattermostPost(params.client, previewPostId, edit); - }, + if ( + hasMedia || + typeof previewFinalText !== "string" || + payload.isError || + !canFinalizeMattermostPreviewInPlace({ + kind: params.kind, + previewRootId: params.effectiveReplyToId, + threadRootId: params.effectiveReplyToId, + replyToId: payload.replyToId, + }) + ) { + return undefined; + } + return { message: previewFinalText }; + }, + editFinal: async (previewPostId, edit) => { + await updateMattermostPost(params.client, previewPostId, edit); + }, + onPreviewFinalized: () => { + params.previewState.finalizedViaPreviewPost = true; + }, + logPreviewEditFailure: (err) => { + params.logVerboseMessage( + `mattermost preview final edit failed; falling back to normal send (${String(err)})`, + ); + }, + }), deliverNormally: async () => { await params.deliverFinal(); }, - onPreviewFinalized: () => { - params.previewState.finalizedViaPreviewPost = true; - }, - logPreviewEditFailure: (err) => { - params.logVerboseMessage( - `mattermost preview final edit failed; falling back to normal send (${String(err)})`, - ); - }, }); } @@ -721,23 +726,24 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} channel: "mattermost", accountId: account.accountId, }); - const { onModelSelected, typingCallbacks, ...replyPipeline } = createChannelReplyPipeline({ - cfg, - agentId: route.agentId, - channel: "mattermost", - accountId: account.accountId, - typing: { - start: () => sendTypingIndicator(opts.channelId, threadContext.effectiveReplyToId), - onStartError: (err) => { - logTypingFailure({ - log: (message) => logger.debug?.(message), - channel: "mattermost", - target: opts.channelId, - error: err, - }); + const { onModelSelected, typingCallbacks, ...replyPipeline } = + createChannelMessageReplyPipeline({ + cfg, + agentId: route.agentId, + channel: "mattermost", + accountId: account.accountId, + typing: { + start: () => sendTypingIndicator(opts.channelId, threadContext.effectiveReplyToId), + onStartError: (err) => { + logTypingFailure({ + log: (message) => logger.debug?.(message), + channel: "mattermost", + target: opts.channelId, + error: err, + }); + }, }, - }, - }); + }); const { dispatcher, replyOptions, markDispatchIdle } = core.channel.reply.createReplyDispatcherWithTyping({ ...replyPipeline, @@ -915,25 +921,26 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} }, ); const shouldDeliverReplies = params.deliverReplies === true; - const { onModelSelected, typingCallbacks, ...replyPipeline } = createChannelReplyPipeline({ - cfg, - agentId: params.route.agentId, - channel: "mattermost", - accountId: account.accountId, - typing: shouldDeliverReplies - ? { - start: () => sendTypingIndicator(params.channelId, params.effectiveReplyToId), - onStartError: (err) => { - logTypingFailure({ - log: (message) => logger.debug?.(message), - channel: "mattermost", - target: params.channelId, - error: err, - }); - }, - } - : undefined, - }); + const { onModelSelected, typingCallbacks, ...replyPipeline } = + createChannelMessageReplyPipeline({ + cfg, + agentId: params.route.agentId, + channel: "mattermost", + accountId: account.accountId, + typing: shouldDeliverReplies + ? { + start: () => sendTypingIndicator(params.channelId, params.effectiveReplyToId), + onStartError: (err) => { + logTypingFailure({ + log: (message) => logger.debug?.(message), + channel: "mattermost", + target: params.channelId, + error: err, + }); + }, + } + : undefined, + }); const capturedTexts: string[] = []; const { dispatcher, replyOptions, markDispatchIdle } = core.channel.reply.createReplyDispatcherWithTyping({ @@ -1635,23 +1642,24 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} accountId: account.accountId, }); - const { onModelSelected, typingCallbacks, ...replyPipeline } = createChannelReplyPipeline({ - cfg, - agentId: route.agentId, - channel: "mattermost", - accountId: account.accountId, - typing: { - start: () => sendTypingIndicator(channelId, effectiveReplyToId), - onStartError: (err) => { - logTypingFailure({ - log: (message) => logger.debug?.(message), - channel: "mattermost", - target: channelId, - error: err, - }); + const { onModelSelected, typingCallbacks, ...replyPipeline } = + createChannelMessageReplyPipeline({ + cfg, + agentId: route.agentId, + channel: "mattermost", + accountId: account.accountId, + typing: { + start: () => sendTypingIndicator(channelId, effectiveReplyToId), + onStartError: (err) => { + logTypingFailure({ + log: (message) => logger.debug?.(message), + channel: "mattermost", + target: channelId, + error: err, + }); + }, }, - }, - }); + }); const draftPreviewEnabled = account.streamingMode !== "off"; const draftToolProgressEnabled = shouldUpdateMattermostDraftToolProgress(account); const suppressDefaultToolProgressMessages = diff --git a/extensions/mattermost/src/mattermost/runtime-api.ts b/extensions/mattermost/src/mattermost/runtime-api.ts index 282f895882b..fd34f53d696 100644 --- a/extensions/mattermost/src/mattermost/runtime-api.ts +++ b/extensions/mattermost/src/mattermost/runtime-api.ts @@ -19,7 +19,7 @@ export { resolveDmGroupAccessWithLists, resolveEffectiveAllowFromLists, } from "openclaw/plugin-sdk/channel-policy"; -export { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline"; +export { createChannelMessageReplyPipeline } from "openclaw/plugin-sdk/channel-message"; export { logTypingFailure } from "openclaw/plugin-sdk/channel-feedback"; export { buildModelsProviderData, diff --git a/extensions/mattermost/src/mattermost/send.test.ts b/extensions/mattermost/src/mattermost/send.test.ts index eac0670bfa1..f9a234ef470 100644 --- a/extensions/mattermost/src/mattermost/send.test.ts +++ b/extensions/mattermost/src/mattermost/send.test.ts @@ -213,14 +213,19 @@ describe("sendMessageMattermost", () => { throw new Error("Mattermost runtime not initialized"); }); - await expect( - sendMessageMattermost("channel:town-square", "hello", { - cfg: providedCfg, - accountId: "work", - }), - ).resolves.toEqual({ + const result = await sendMessageMattermost("channel:town-square", "hello", { + cfg: providedCfg, + accountId: "work", + }); + + expect(result).toMatchObject({ messageId: "post-1", channelId: "town-square", + receipt: { + primaryPlatformMessageId: "post-1", + platformMessageIds: ["post-1"], + parts: [expect.objectContaining({ platformMessageId: "post-1", kind: "text" })], + }, }); expect(mockState.loadConfig).not.toHaveBeenCalled(); }); @@ -487,6 +492,10 @@ describe("sendMessageMattermost user-first resolution", () => { expect(params.channelId).toBe("dm-channel-id"); expect(res.channelId).toBe("dm-channel-id"); expect(res.messageId).toBe("post-id"); + expect(res.receipt).toMatchObject({ + primaryPlatformMessageId: "post-id", + platformMessageIds: ["post-id"], + }); }); it("falls back to channel id when user lookup returns 404", async () => { diff --git a/extensions/mattermost/src/mattermost/send.ts b/extensions/mattermost/src/mattermost/send.ts index 4a42bff16a8..bc3a6a52bf1 100644 --- a/extensions/mattermost/src/mattermost/send.ts +++ b/extensions/mattermost/src/mattermost/send.ts @@ -1,3 +1,8 @@ +import { + createMessageReceiptFromOutboundResults, + type MessageReceipt, + type MessageReceiptPartKind, +} from "openclaw/plugin-sdk/channel-message"; import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime"; import { requireRuntimeConfig } from "openclaw/plugin-sdk/plugin-config-runtime"; import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime"; @@ -49,6 +54,7 @@ export type MattermostSendOpts = { export type MattermostSendResult = { messageId: string; channelId: string; + receipt: MessageReceipt; }; export type MattermostReplyButtons = Array< @@ -67,6 +73,39 @@ const dmChannelCache = new Map(); const getCore = () => getMattermostRuntime(); +function createMattermostSendReceipt(params: { + messageId: string; + channelId: string; + kind: MessageReceiptPartKind; + replyToId?: string; +}): MessageReceipt { + const messageIds = + params.messageId.trim() && params.messageId !== "unknown" ? [params.messageId] : []; + return createMessageReceiptFromOutboundResults({ + kind: params.kind, + ...(params.replyToId ? { replyToId: params.replyToId } : {}), + results: messageIds.map((messageId) => ({ + channel: "mattermost", + messageId, + channelId: params.channelId, + })), + }); +} + +function resolveMattermostReceiptKind(params: { + fileIds?: readonly string[]; + buttons?: readonly unknown[]; + props?: Record; +}): MessageReceiptPartKind { + if (params.fileIds?.length) { + return "media"; + } + if (params.buttons?.length || params.props) { + return "card"; + } + return "text"; +} + function recordMattermostOutboundActivity(accountId: string): void { try { getCore().channel.activity.record({ @@ -474,9 +513,20 @@ export async function sendMessageMattermost( }); recordMattermostOutboundActivity(accountId); + const messageId = post.id ?? "unknown"; return { - messageId: post.id ?? "unknown", + messageId, channelId, + receipt: createMattermostSendReceipt({ + messageId, + channelId, + kind: resolveMattermostReceiptKind({ + fileIds, + buttons: opts.buttons, + props, + }), + replyToId: opts.replyToId, + }), }; } diff --git a/extensions/mattermost/src/mattermost/slash-http.send-config.test.ts b/extensions/mattermost/src/mattermost/slash-http.send-config.test.ts index fbfdc9f2d39..6636be3bbed 100644 --- a/extensions/mattermost/src/mattermost/slash-http.send-config.test.ts +++ b/extensions/mattermost/src/mattermost/slash-http.send-config.test.ts @@ -53,7 +53,7 @@ const mockState = vi.hoisted(() => ({ vi.mock("./runtime-api.js", () => { return { buildModelsProviderData: mockState.buildModelsProviderData, - createChannelReplyPipeline: vi.fn(() => ({ + createChannelMessageReplyPipeline: vi.fn(() => ({ onModelSelected: vi.fn(), typingCallbacks: {}, })), diff --git a/extensions/mattermost/src/mattermost/slash-http.ts b/extensions/mattermost/src/mattermost/slash-http.ts index 97c61a6495b..7de7410c41a 100644 --- a/extensions/mattermost/src/mattermost/slash-http.ts +++ b/extensions/mattermost/src/mattermost/slash-http.ts @@ -30,7 +30,7 @@ import { import { deliverMattermostReplyPayload } from "./reply-delivery.js"; import { buildModelsProviderData, - createChannelReplyPipeline, + createChannelMessageReplyPipeline, isRequestBodyLimitError, logTypingFailure, readRequestBodyWithLimit, @@ -837,7 +837,7 @@ async function handleSlashCommandAsync(params: { accountId: account.accountId, }); - const { onModelSelected, typingCallbacks, ...replyPipeline } = createChannelReplyPipeline({ + const { onModelSelected, typingCallbacks, ...replyPipeline } = createChannelMessageReplyPipeline({ cfg, agentId: route.agentId, channel: "mattermost", diff --git a/extensions/mattermost/src/runtime-api.ts b/extensions/mattermost/src/runtime-api.ts index 816324d16cb..ac1aa148736 100644 --- a/extensions/mattermost/src/runtime-api.ts +++ b/extensions/mattermost/src/runtime-api.ts @@ -18,7 +18,7 @@ export { clearHistoryEntriesIfEnabled, createAccountStatusSink, createChannelPairingController, - createChannelReplyPipeline, + createChannelMessageReplyPipeline, createDedupeCache, DEFAULT_ACCOUNT_ID, DEFAULT_GROUP_HISTORY_LIMIT, diff --git a/extensions/msteams/runtime-api.ts b/extensions/msteams/runtime-api.ts index 6b5ea885c64..0d9e1349569 100644 --- a/extensions/msteams/runtime-api.ts +++ b/extensions/msteams/runtime-api.ts @@ -26,7 +26,7 @@ export { resolveSenderScopedGroupPolicy, resolveToolsBySender, } from "openclaw/plugin-sdk/channel-policy"; -export { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline"; +export { createChannelMessageReplyPipeline } from "openclaw/plugin-sdk/channel-message"; export { PAIRING_APPROVED_MESSAGE, buildProbeChannelStatusSummary, diff --git a/extensions/msteams/src/channel.message-adapter.test.ts b/extensions/msteams/src/channel.message-adapter.test.ts new file mode 100644 index 00000000000..afefb997130 --- /dev/null +++ b/extensions/msteams/src/channel.message-adapter.test.ts @@ -0,0 +1,152 @@ +import { + verifyChannelMessageAdapterCapabilityProofs, + verifyChannelMessageLiveCapabilityAdapterProofs, + verifyChannelMessageLiveFinalizerProofs, +} from "openclaw/plugin-sdk/channel-message"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../runtime-api.js"; + +const mocks = vi.hoisted(() => ({ + sendText: vi.fn(), + sendMedia: vi.fn(), + sendPoll: vi.fn(), +})); + +vi.mock("./channel.runtime.js", () => ({ + msTeamsChannelRuntime: { + msteamsOutbound: { + sendText: mocks.sendText, + sendMedia: mocks.sendMedia, + sendPoll: mocks.sendPoll, + }, + }, +})); + +import { msteamsPlugin } from "./channel.js"; + +const cfg = { + channels: { + msteams: { + appId: "resolved-app-id", + }, + }, +} as OpenClawConfig; + +describe("msteams channel message adapter", () => { + beforeEach(() => { + mocks.sendText.mockReset(); + mocks.sendMedia.mockReset(); + mocks.sendPoll.mockReset(); + mocks.sendText.mockResolvedValue({ + channel: "msteams", + messageId: "msg-1", + conversationId: "conv-1", + }); + mocks.sendMedia.mockResolvedValue({ + channel: "msteams", + messageId: "msg-media-1", + conversationId: "conv-1", + }); + }); + + it("backs declared durable-final capabilities with outbound send proofs", async () => { + const adapter = msteamsPlugin.message; + expect(adapter).toBeDefined(); + expect(adapter!.durableFinal?.capabilities?.replyTo).toBeUndefined(); + expect(adapter!.durableFinal?.capabilities?.thread).toBeUndefined(); + + const proveText = async () => { + mocks.sendText.mockClear(); + const result = await adapter!.send!.text!({ + cfg, + to: "conversation:abc", + text: "hello", + accountId: "default", + }); + expect(mocks.sendText).toHaveBeenLastCalledWith( + expect.objectContaining({ + cfg, + to: "conversation:abc", + text: "hello", + accountId: "default", + }), + ); + expect(result.receipt.platformMessageIds).toEqual(["msg-1"]); + expect(result.receipt.parts[0]?.kind).toBe("text"); + }; + + const proveMedia = async () => { + mocks.sendMedia.mockClear(); + const result = await adapter!.send!.media!({ + cfg, + to: "conversation:abc", + text: "photo", + mediaUrl: "file:///tmp/photo.png", + mediaLocalRoots: ["/tmp"], + accountId: "default", + }); + expect(mocks.sendMedia).toHaveBeenLastCalledWith( + expect.objectContaining({ + cfg, + to: "conversation:abc", + text: "photo", + mediaUrl: "file:///tmp/photo.png", + mediaLocalRoots: ["/tmp"], + }), + ); + expect(result.receipt.platformMessageIds).toEqual(["msg-media-1"]); + expect(result.receipt.parts[0]?.kind).toBe("media"); + }; + + await verifyChannelMessageAdapterCapabilityProofs({ + adapterName: "msteamsMessageAdapter", + adapter: adapter!, + proofs: { + text: proveText, + media: proveMedia, + messageSendingHooks: () => { + expect(adapter!.send!.text).toBeTypeOf("function"); + }, + }, + }); + }); + + it("backs declared live preview finalizer capabilities with adapter proofs", async () => { + const adapter = msteamsPlugin.message; + + await verifyChannelMessageLiveCapabilityAdapterProofs({ + adapterName: "msteamsMessageAdapter", + adapter: adapter!, + proofs: { + draftPreview: () => { + expect(adapter!.live?.capabilities?.nativeStreaming).toBe(true); + }, + previewFinalization: () => { + expect(adapter!.live?.finalizer?.capabilities?.finalEdit).toBe(true); + }, + progressUpdates: () => { + expect(adapter!.live?.capabilities?.draftPreview).toBe(true); + }, + nativeStreaming: () => { + expect(adapter!.live?.finalizer?.capabilities?.previewReceipt).toBe(true); + }, + }, + }); + + await verifyChannelMessageLiveFinalizerProofs({ + adapterName: "msteamsMessageAdapter", + adapter: adapter!, + proofs: { + finalEdit: () => { + expect(adapter!.live?.capabilities?.previewFinalization).toBe(true); + }, + normalFallback: () => { + expect(adapter!.send!.text).toBeTypeOf("function"); + }, + previewReceipt: () => { + expect(adapter!.live?.capabilities?.nativeStreaming).toBe(true); + }, + }, + }); + }); +}); diff --git a/extensions/msteams/src/channel.ts b/extensions/msteams/src/channel.ts index eaaaa591a06..00fab0f5d83 100644 --- a/extensions/msteams/src/channel.ts +++ b/extensions/msteams/src/channel.ts @@ -6,6 +6,7 @@ import type { ChannelMessageToolDiscovery, } from "openclaw/plugin-sdk/channel-contract"; import { createChatChannelPlugin } from "openclaw/plugin-sdk/channel-core"; +import { createChannelMessageAdapterFromOutbound } from "openclaw/plugin-sdk/channel-message"; import { createPairingPrefixStripper } from "openclaw/plugin-sdk/channel-pairing"; import { createAllowlistProviderGroupPolicyWarningCollector, @@ -22,7 +23,12 @@ import { createRuntimeOutboundDelegates } from "openclaw/plugin-sdk/outbound-run import { createComputedAccountStatusAdapter } from "openclaw/plugin-sdk/status-helpers"; import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; import { Type } from "typebox"; -import type { ChannelMessageActionName, ChannelPlugin, OpenClawConfig } from "../runtime-api.js"; +import type { + ChannelMessageActionName, + ChannelOutboundAdapter, + ChannelPlugin, + OpenClawConfig, +} from "../runtime-api.js"; import { buildProbeChannelStatusSummary, chunkTextForOutbound, @@ -402,6 +408,47 @@ function describeMSTeamsMessageTool({ }; } +const msteamsChannelOutbound: ChannelOutboundAdapter = { + deliveryMode: "direct", + chunker: chunkTextForOutbound, + chunkerMode: "markdown", + textChunkLimit: 4000, + pollMaxOptions: 12, + deliveryCapabilities: { + durableFinal: { + text: true, + media: true, + messageSendingHooks: true, + }, + }, + ...createRuntimeOutboundDelegates({ + getRuntime: loadMSTeamsChannelRuntime, + sendText: { resolve: (runtime) => runtime.msteamsOutbound.sendText }, + sendMedia: { resolve: (runtime) => runtime.msteamsOutbound.sendMedia }, + sendPoll: { resolve: (runtime) => runtime.msteamsOutbound.sendPoll }, + }), +}; + +const msteamsMessageAdapter = createChannelMessageAdapterFromOutbound({ + id: "msteams", + outbound: msteamsChannelOutbound, + live: { + capabilities: { + draftPreview: true, + previewFinalization: true, + progressUpdates: true, + nativeStreaming: true, + }, + finalizer: { + capabilities: { + finalEdit: true, + normalFallback: true, + previewReceipt: true, + }, + }, + }, +}); + export const msteamsPlugin: ChannelPlugin = createChatChannelPlugin({ base: { @@ -458,6 +505,7 @@ export const msteamsPlugin: ChannelPlugin", }, }, + message: msteamsMessageAdapter, directory: createChannelDirectoryAdapter({ self: async ({ cfg }) => { const creds = resolveMSTeamsCredentials(cfg.channels?.msteams); @@ -1120,17 +1168,5 @@ export const msteamsPlugin: ChannelPlugin runtime.msteamsOutbound.sendText }, - sendMedia: { resolve: (runtime) => runtime.msteamsOutbound.sendMedia }, - sendPoll: { resolve: (runtime) => runtime.msteamsOutbound.sendPoll }, - }), - }, + outbound: msteamsChannelOutbound, }); diff --git a/extensions/msteams/src/reply-dispatcher.test.ts b/extensions/msteams/src/reply-dispatcher.test.ts index 877d3888179..dcbd21feed2 100644 --- a/extensions/msteams/src/reply-dispatcher.test.ts +++ b/extensions/msteams/src/reply-dispatcher.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -const createChannelReplyPipelineMock = vi.hoisted(() => vi.fn()); +const createChannelMessageReplyPipelineMock = vi.hoisted(() => vi.fn()); const createReplyDispatcherWithTypingMock = vi.hoisted(() => vi.fn()); const getMSTeamsRuntimeMock = vi.hoisted(() => vi.fn()); const enqueueSystemEventMock = vi.hoisted(() => vi.fn()); @@ -20,7 +20,7 @@ const streamInstances = vi.hoisted( ); vi.mock("../runtime-api.js", () => ({ - createChannelReplyPipeline: createChannelReplyPipelineMock, + createChannelMessageReplyPipeline: createChannelMessageReplyPipelineMock, logTypingFailure: vi.fn(), resolveChannelMediaMaxBytes: vi.fn(() => 8 * 1024 * 1024), })); @@ -82,7 +82,7 @@ describe("createMSTeamsReplyDispatcher", () => { onCleanup: vi.fn(), }; - createChannelReplyPipelineMock.mockReturnValue({ + createChannelMessageReplyPipelineMock.mockReturnValue({ onModelSelected: vi.fn(), typingCallbacks, }); @@ -203,7 +203,7 @@ describe("createMSTeamsReplyDispatcher", () => { it("passes a longer keepalive TTL so the loop survives long tool chains", () => { createDispatcher("personal"); - const pipelineArgs = createChannelReplyPipelineMock.mock.calls[0]?.[0]; + const pipelineArgs = createChannelMessageReplyPipelineMock.mock.calls[0]?.[0]; expect(pipelineArgs?.typing?.keepaliveIntervalMs).toBeGreaterThan(3_000); expect(pipelineArgs?.typing?.keepaliveIntervalMs).toBeLessThanOrEqual(10_000); // Issue #59731 reports 60s+ tool chains — the default 60s TTL is too @@ -213,7 +213,7 @@ describe("createMSTeamsReplyDispatcher", () => { it("allows typing keepalive sends before any stream tokens arrive", async () => { createDispatcher("personal"); - const pipelineArgs = createChannelReplyPipelineMock.mock.calls[0]?.[0]; + const pipelineArgs = createChannelMessageReplyPipelineMock.mock.calls[0]?.[0]; const sendTyping = pipelineArgs?.typing?.start as () => Promise; // No onPartialReply has been called yet, so the stream is not active. @@ -226,7 +226,7 @@ describe("createMSTeamsReplyDispatcher", () => { it("suppresses typing keepalive sends while the stream card is actively chunking", async () => { createDispatcher("personal"); - const pipelineArgs = createChannelReplyPipelineMock.mock.calls[0]?.[0]; + const pipelineArgs = createChannelMessageReplyPipelineMock.mock.calls[0]?.[0]; const sendTyping = pipelineArgs?.typing?.start as () => Promise; // Simulate the stream actively receiving a partial chunk. While the @@ -242,7 +242,7 @@ describe("createMSTeamsReplyDispatcher", () => { it("resumes typing keepalive sends once the stream finalizes between tool rounds", async () => { createDispatcher("personal"); - const pipelineArgs = createChannelReplyPipelineMock.mock.calls[0]?.[0]; + const pipelineArgs = createChannelMessageReplyPipelineMock.mock.calls[0]?.[0]; const sendTyping = pipelineArgs?.typing?.start as () => Promise; // First segment: tokens flow, stream is active, typing is gated off. @@ -271,7 +271,7 @@ describe("createMSTeamsReplyDispatcher", () => { it("fires native typing in group chats (no stream) because the gate never applies", async () => { createDispatcher("groupchat"); - const pipelineArgs = createChannelReplyPipelineMock.mock.calls[0]?.[0]; + const pipelineArgs = createChannelMessageReplyPipelineMock.mock.calls[0]?.[0]; const sendTyping = pipelineArgs?.typing?.start as () => Promise; // In group chats we don't create a stream, so isStreamActive() always @@ -284,7 +284,7 @@ describe("createMSTeamsReplyDispatcher", () => { it("is a no-op for channel conversations (typing unsupported)", async () => { createDispatcher("channel"); - const pipelineArgs = createChannelReplyPipelineMock.mock.calls[0]?.[0]; + const pipelineArgs = createChannelMessageReplyPipelineMock.mock.calls[0]?.[0]; const sendTyping = pipelineArgs?.typing?.start as () => Promise; const contextSendActivity = getContextSendActivity(); diff --git a/extensions/msteams/src/reply-dispatcher.ts b/extensions/msteams/src/reply-dispatcher.ts index 492c439f39c..5fbbbf77ac0 100644 --- a/extensions/msteams/src/reply-dispatcher.ts +++ b/extensions/msteams/src/reply-dispatcher.ts @@ -6,7 +6,7 @@ import { } from "openclaw/plugin-sdk/channel-streaming"; import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/text-runtime"; import { - createChannelReplyPipeline, + createChannelMessageReplyPipeline, logTypingFailure, resolveChannelMediaMaxBytes, type OpenClawConfig, @@ -118,7 +118,7 @@ export function createMSTeamsReplyDispatcher(params: { } : async () => {}; - const { onModelSelected, typingCallbacks, ...replyPipeline } = createChannelReplyPipeline({ + const { onModelSelected, typingCallbacks, ...replyPipeline } = createChannelMessageReplyPipeline({ cfg: params.cfg, agentId: params.agentId, channel: "msteams", diff --git a/extensions/msteams/src/reply-stream-controller.test.ts b/extensions/msteams/src/reply-stream-controller.test.ts index e73a8c9627e..316e0f059ea 100644 --- a/extensions/msteams/src/reply-stream-controller.test.ts +++ b/extensions/msteams/src/reply-stream-controller.test.ts @@ -7,6 +7,8 @@ const streamInstances = vi.hoisted( isFinalized: boolean; isFailed: boolean; streamedLength: number; + messageId?: string; + previewStreamId?: string; sendInformativeUpdate: ReturnType; update: ReturnType; replaceInformativeWithFinal: ReturnType; @@ -20,6 +22,8 @@ vi.mock("./streaming-message.js", () => ({ isFinalized = false; isFailed = false; streamedLength = 0; + messageId: string | undefined; + previewStreamId = "preview-stream"; sendInformativeUpdate = vi.fn(async () => {}); update = vi.fn(function ( this: { hasContent: boolean; isFailed: boolean; streamedLength: number }, @@ -40,6 +44,7 @@ vi.mock("./streaming-message.js", () => ({ isFailed: boolean; isFinalized: boolean; streamedLength: number; + messageId?: string; update: (payloadText?: string) => void; }, payloadText: string, @@ -49,10 +54,12 @@ vi.mock("./streaming-message.js", () => ({ return false; } this.isFinalized = true; + this.messageId = "final-message"; return this.hasContent; }); - finalize = vi.fn(async function (this: { isFinalized: boolean }) { + finalize = vi.fn(async function (this: { isFinalized: boolean; messageId?: string }) { this.isFinalized = true; + this.messageId = "final-message"; }); constructor() { @@ -131,8 +138,11 @@ describe("createTeamsReplyStreamController", () => { ctrl.onPartialReply({ text: "Streamed text" }); await ctrl.preparePayload({ text: "Streamed text" }); + await ctrl.finalize(); expect(streamInstances[0]?.finalize).toHaveBeenCalled(); + expect(ctrl.liveState().phase).toBe("finalized"); + expect(ctrl.liveState().receipt?.primaryPlatformMessageId).toBe("final-message"); }); it("uses fallback even when onPartialReply fires after stream finalized", async () => { @@ -223,6 +233,24 @@ describe("createTeamsReplyStreamController", () => { expect(streamInstances[0]?.replaceInformativeWithFinal).toHaveBeenCalledWith(fullText); }); + it("records lifecycle receipt when progress final streaming succeeds", async () => { + streamInstances.length = 0; + const ctrl = createTeamsReplyStreamController({ + conversationType: "personal", + context: { sendActivity: vi.fn(async () => ({ id: "a" })) } as never, + feedbackLoopEnabled: false, + log: { debug: vi.fn() } as never, + msteamsConfig: { streaming: { mode: "progress" } } as never, + }); + await ctrl.noteProgressWork({ toolName: "exec" }); + await ctrl.noteProgressWork(); + + await expect(ctrl.preparePayload({ text: "complete final answer" })).resolves.toBeUndefined(); + + expect(ctrl.liveState().phase).toBe("finalized"); + expect(ctrl.liveState().receipt?.primaryPlatformMessageId).toBe("final-message"); + }); + it("falls back with full text when progress final send fails after streaming text", async () => { streamInstances.length = 0; const ctrl = createTeamsReplyStreamController({ diff --git a/extensions/msteams/src/reply-stream-controller.ts b/extensions/msteams/src/reply-stream-controller.ts index 29c188c10b7..bd0113554cb 100644 --- a/extensions/msteams/src/reply-stream-controller.ts +++ b/extensions/msteams/src/reply-stream-controller.ts @@ -1,3 +1,11 @@ +import { + createLiveMessageState, + createPreviewMessageReceipt, + defineFinalizableLivePreviewAdapter, + deliverWithFinalizableLivePreviewAdapter, + markLiveMessageFinalized, + type LiveMessageState, +} from "openclaw/plugin-sdk/channel-message"; import { createChannelProgressDraftGate, formatChannelProgressDraftText, @@ -65,6 +73,20 @@ export function createTeamsReplyStreamController(params: { let progressLines: string[] = []; let lastInformativeText = ""; let pendingFinalize: Promise | undefined; + let liveState: LiveMessageState = createLiveMessageState({ + canFinalizeInPlace: Boolean(stream), + }); + + const markStreamFinalized = () => { + if (!stream || stream.isFailed) { + return; + } + const messageId = stream.messageId ?? stream.previewStreamId; + if (!messageId) { + return; + } + liveState = markLiveMessageFinalized(liveState, createPreviewMessageReceipt({ id: messageId })); + }; const renderInformativeUpdate = async () => { if (!stream) { @@ -144,6 +166,50 @@ export function createTeamsReplyStreamController(params: { return { ...payload, text: remainingText }; }; + const finalizeProgressPayload = async ( + payload: ReplyPayload, + hasMedia: boolean, + ): Promise> => { + if (!stream || !payload.text) { + return payload; + } + const result = await deliverWithFinalizableLivePreviewAdapter({ + kind: "final", + payload, + liveState, + adapter: defineFinalizableLivePreviewAdapter({ + draft: { + flush: async () => {}, + clear: async () => {}, + id: () => stream.previewStreamId, + }, + buildFinalEdit: (candidate) => (candidate.text ? { text: candidate.text } : undefined), + editFinal: async (_previewId, edit) => { + const finalized = await stream.replaceInformativeWithFinal(edit.text); + informativeUpdateSent = false; + if (!finalized || stream.isFailed) { + throw new Error("Teams progress stream finalization failed"); + } + }, + resolveFinalizedId: (previewId) => stream.messageId ?? stream.previewStreamId ?? previewId, + createPreviewReceipt: (id) => createPreviewMessageReceipt({ id }), + onPreviewFinalized: (_id, _receipt, state) => { + liveState = state; + }, + logPreviewEditFailure: (err) => { + params.log.debug?.(`stream finalization failed: ${formatUnknownError(err)}`); + }, + }), + deliverNormally: async () => false, + }); + + return result.kind === "preview-finalized" + ? hasMedia + ? { ...payload, text: undefined } + : undefined + : payload; + }; + return { async onReplyStart(): Promise { return; @@ -183,12 +249,7 @@ export function createTeamsReplyStreamController(params: { if (!payload.text) { return payload; } - const finalized = await stream.replaceInformativeWithFinal(payload.text); - informativeUpdateSent = false; - if (!finalized || stream.isFailed) { - return payload; - } - return hasMedia ? { ...payload, text: undefined } : undefined; + return await finalizeProgressPayload(payload, hasMedia); } if (!stream || !streamReceivedTokens) { @@ -211,7 +272,9 @@ export function createTeamsReplyStreamController(params: { // subsequent text segments (after tool calls) use fallback delivery. // finalize() is idempotent; the later call in markDispatchIdle is a no-op. streamReceivedTokens = false; - pendingFinalize = stream.finalize(); + pendingFinalize = stream.finalize().then(() => { + markStreamFinalized(); + }); if (!hasMedia) { return undefined; @@ -222,13 +285,20 @@ export function createTeamsReplyStreamController(params: { async finalize(): Promise { progressDraftGate.cancel(); await pendingFinalize; - await stream?.finalize(); + if (!pendingFinalize) { + await stream?.finalize(); + markStreamFinalized(); + } }, hasStream(): boolean { return Boolean(stream); }, + liveState(): LiveMessageState { + return liveState; + }, + /** * Whether the Teams streaming card is currently receiving LLM tokens. * Used to gate side-channel keepalive activity so we don't overlay plain diff --git a/extensions/msteams/src/send.test.ts b/extensions/msteams/src/send.test.ts index 5e2a5b56a90..396edf6857e 100644 --- a/extensions/msteams/src/send.test.ts +++ b/extensions/msteams/src/send.test.ts @@ -200,7 +200,7 @@ describe("sendMessageMSTeams", () => { kind: "image", }); - await sendMessageMSTeams({ + const result = await sendMessageMSTeams({ cfg: {} as OpenClawConfig, to: "conversation:19:conversation@thread.tacv2", text: "hello", @@ -226,6 +226,11 @@ describe("sendMessageMSTeams", () => { ], }), ); + expect(result.receipt).toMatchObject({ + primaryPlatformMessageId: "message-1", + platformMessageIds: ["message-1"], + parts: [expect.objectContaining({ platformMessageId: "message-1", kind: "media" })], + }); }); it("sends with provided cfg even when Teams runtime text helpers are unavailable", async () => { @@ -238,15 +243,20 @@ describe("sendMessageMSTeams", () => { mockState.resolveMarkdownTableMode.mockReturnValue("off"); mockState.convertMarkdownTables.mockReturnValue("hello"); - await expect( - sendMessageMSTeams({ - cfg: {} as OpenClawConfig, - to: "conversation:19:conversation@thread.tacv2", - text: "hello", - }), - ).resolves.toEqual({ + const result = await sendMessageMSTeams({ + cfg: {} as OpenClawConfig, + to: "conversation:19:conversation@thread.tacv2", + text: "hello", + }); + + expect(result).toMatchObject({ messageId: "message-1", conversationId: "19:conversation@thread.tacv2", + receipt: { + primaryPlatformMessageId: "message-1", + platformMessageIds: ["message-1"], + parts: [expect.objectContaining({ platformMessageId: "message-1", kind: "text" })], + }, }); expect(mockState.resolveMarkdownTableMode).toHaveBeenCalledWith({ diff --git a/extensions/msteams/src/send.ts b/extensions/msteams/src/send.ts index 9ca161c6589..9d173f2e3c3 100644 --- a/extensions/msteams/src/send.ts +++ b/extensions/msteams/src/send.ts @@ -1,3 +1,8 @@ +import { + createMessageReceiptFromOutboundResults, + type MessageReceipt, + type MessageReceiptPartKind, +} from "openclaw/plugin-sdk/channel-message"; import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime"; import { convertMarkdownTables } from "openclaw/plugin-sdk/text-runtime"; import { loadOutboundMediaFromUrl, type OpenClawConfig } from "../runtime-api.js"; @@ -38,6 +43,7 @@ type SendMSTeamsMessageParams = { type SendMSTeamsMessageResult = { messageId: string; conversationId: string; + receipt: MessageReceipt; /** If a FileConsentCard was sent instead of the file, this contains the upload ID */ pendingUploadId?: string; }; @@ -51,6 +57,45 @@ const FILE_CONSENT_THRESHOLD_BYTES = 4 * 1024 * 1024; // 4MB */ const MSTEAMS_MAX_MEDIA_BYTES = 100 * 1024 * 1024; +function createMSTeamsSendReceipt(params: { + conversationId: string; + platformMessageIds: readonly string[]; + kind: MessageReceiptPartKind; +}) { + return createMessageReceiptFromOutboundResults({ + kind: params.kind, + results: params.platformMessageIds.map((messageId) => ({ + channel: "msteams", + messageId, + conversationId: params.conversationId, + })), + }); +} + +function createMSTeamsSendResult(params: { + conversationId: string; + messageId: string; + platformMessageIds?: readonly string[]; + kind: MessageReceiptPartKind; + pendingUploadId?: string; +}): SendMSTeamsMessageResult { + const platformMessageIds = ( + params.platformMessageIds?.length ? [...params.platformMessageIds] : [params.messageId] + ) + .map((messageId) => messageId.trim()) + .filter((messageId) => messageId && messageId !== "unknown"); + return { + messageId: params.messageId, + conversationId: params.conversationId, + receipt: createMSTeamsSendReceipt({ + conversationId: params.conversationId, + platformMessageIds, + kind: params.kind, + }), + ...(params.pendingUploadId ? { pendingUploadId: params.pendingUploadId } : {}), + }; +} + type SendMSTeamsPollParams = { /** Full config (for credentials) */ cfg: OpenClawConfig; @@ -182,11 +227,12 @@ export async function sendMessageMSTeams( log.info("sent file consent card", { conversationId, messageId, uploadId }); - return { + return createMSTeamsSendResult({ messageId, conversationId, + kind: "card", pendingUploadId: uploadId, - }; + }); } // Personal chat with small image: use base64 (only works for images) @@ -264,7 +310,11 @@ export async function sendMessageMSTeams( fileName: driveItem.name, }); - return { messageId, conversationId }; + return createMSTeamsSendResult({ + messageId, + conversationId, + kind: "media", + }); } // Fallback: no SharePoint site configured, use OneDrive with markdown link @@ -304,7 +354,11 @@ export async function sendMessageMSTeams( shareUrl: uploaded.shareUrl, }); - return { messageId, conversationId }; + return createMSTeamsSendResult({ + messageId, + conversationId, + kind: "media", + }); } catch (err) { const classification = classifyMSTeamsSendError(err); const hint = formatMSTeamsSendErrorHint(classification); @@ -339,9 +393,9 @@ async function sendTextWithMedia( mediaMaxBytes, } = ctx; - let messageIds: string[]; + let platformMessageIds: string[]; try { - messageIds = await sendMSTeamsMessages({ + platformMessageIds = await sendMSTeamsMessages({ replyStyle: "top-level", adapter, appId, @@ -365,12 +419,17 @@ async function sendTextWithMedia( ); } - const messageId = messageIds[0] ?? "unknown"; + const messageId = platformMessageIds[0] ?? "unknown"; log.info("sent proactive message", { conversationId, messageId }); return { messageId, conversationId, + receipt: createMSTeamsSendReceipt({ + conversationId, + platformMessageIds, + kind: mediaUrl ? "media" : "text", + }), }; } diff --git a/extensions/msteams/src/streaming-message.ts b/extensions/msteams/src/streaming-message.ts index dd1d486f6d2..3accc939c6f 100644 --- a/extensions/msteams/src/streaming-message.ts +++ b/extensions/msteams/src/streaming-message.ts @@ -83,6 +83,7 @@ export class TeamsHttpStream { private finalized = false; private streamFailed = false; private lastStreamedText = ""; + private finalMessageId: string | undefined = undefined; private streamStartedAt: number | undefined = undefined; private loop: DraftStreamLoop; @@ -181,9 +182,9 @@ export class TeamsHttpStream { /** * Finalize the stream — send the final message activity. */ - async finalize(): Promise { + async finalize(): Promise { if (this.finalized) { - return; + return this.finalMessageId; } this.finalized = true; this.stopped = true; @@ -195,7 +196,7 @@ export class TeamsHttpStream { // bar after its streaming timeout. Sending an empty final message fails // with 403. if (!this.accumulatedText.trim()) { - return; + return this.finalMessageId; } // If streaming failed (>4000 chars or POST errors), close the stream @@ -205,17 +206,18 @@ export class TeamsHttpStream { if (this.streamFailed) { if (this.streamId) { try { - await this.sendActivity({ + const response = await this.sendActivity({ type: "message", text: this.lastStreamedText || "", channelData: { feedbackLoopEnabled: this.feedbackLoopEnabled }, entities: [AI_GENERATED_ENTITY, buildStreamInfoEntity(this.streamId, "final")], }); + this.finalMessageId = extractId(response); } catch { // Best effort — stream will auto-close after Teams timeout } } - return; + return this.finalMessageId; } // Send final message activity. @@ -235,11 +237,13 @@ export class TeamsHttpStream { entities, }; - await this.sendActivity(finalActivity); + const response = await this.sendActivity(finalActivity); + this.finalMessageId = extractId(response); } catch (err) { this.streamFailed = true; this.onError?.(err); } + return this.finalMessageId; } /** Whether streaming successfully delivered content (at least one chunk sent, not failed). */ @@ -262,6 +266,16 @@ export class TeamsHttpStream { return this.finalized; } + /** Platform id returned by the final message activity, when available. */ + get messageId(): string | undefined { + return this.finalMessageId; + } + + /** Stream id returned by the first streaminfo activity, when available. */ + get previewStreamId(): string | undefined { + return this.streamId; + } + /** Whether streaming fell back (not used in this implementation). */ get isFallback(): boolean { return false; diff --git a/extensions/nextcloud-talk/runtime-api.ts b/extensions/nextcloud-talk/runtime-api.ts index 2ce1fdd8131..a179ef13bbe 100644 --- a/extensions/nextcloud-talk/runtime-api.ts +++ b/extensions/nextcloud-talk/runtime-api.ts @@ -23,7 +23,7 @@ export { resolveDefaultGroupPolicy, warnMissingProviderGroupPolicyFallbackOnce, } from "openclaw/plugin-sdk/runtime-group-policy"; -export { dispatchInboundReplyWithBase } from "openclaw/plugin-sdk/inbound-reply-dispatch"; +export { dispatchChannelMessageReplyWithBase } from "openclaw/plugin-sdk/channel-message"; export type { OutboundReplyPayload } from "openclaw/plugin-sdk/reply-payload"; export { deliverFormattedTextWithAttachments } from "openclaw/plugin-sdk/reply-payload"; export type { PluginRuntime } from "openclaw/plugin-sdk/runtime-store"; diff --git a/extensions/nextcloud-talk/src/channel.ts b/extensions/nextcloud-talk/src/channel.ts index 240853fa380..84d630b85c7 100644 --- a/extensions/nextcloud-talk/src/channel.ts +++ b/extensions/nextcloud-talk/src/channel.ts @@ -18,6 +18,7 @@ import { import { NextcloudTalkConfigSchema } from "./config-schema.js"; import { nextcloudTalkDoctor } from "./doctor.js"; import { nextcloudTalkGatewayAdapter } from "./gateway.js"; +import { nextcloudTalkMessageAdapter } from "./message-adapter.js"; import { looksLikeNextcloudTalkTargetId, normalizeNextcloudTalkMessagingTarget, @@ -25,7 +26,6 @@ import { import { resolveNextcloudTalkGroupToolPolicy } from "./policy.js"; import { getNextcloudTalkRuntime } from "./runtime.js"; import { collectRuntimeConfigAssignments, secretTargetRegistryEntries } from "./secret-contract.js"; -import { sendMessageNextcloudTalk } from "./send.js"; import { resolveNextcloudTalkOutboundSessionRoute } from "./session-route.js"; import { nextcloudTalkSetupAdapter } from "./setup-core.js"; import { nextcloudTalkSetupWizard } from "./setup-surface.js"; @@ -151,6 +151,7 @@ export const nextcloudTalkPlugin: ChannelPlugin = }), }), gateway: nextcloudTalkGatewayAdapter, + message: nextcloudTalkMessageAdapter, }, pairing: { text: { @@ -175,21 +176,22 @@ export const nextcloudTalkPlugin: ChannelPlugin = attachedResults: { channel: "nextcloud-talk", sendText: async ({ cfg, to, text, accountId, replyToId }) => - await sendMessageNextcloudTalk(to, text, { - accountId: accountId ?? undefined, - replyTo: replyToId ?? undefined, - cfg: cfg as CoreConfig, + await nextcloudTalkMessageAdapter.send.text({ + cfg, + to, + text, + accountId, + replyToId, }), sendMedia: async ({ cfg, to, text, mediaUrl, accountId, replyToId }) => - await sendMessageNextcloudTalk( + await nextcloudTalkMessageAdapter.send.media({ + cfg, to, - mediaUrl ? `${text}\n\nAttachment: ${mediaUrl}` : text, - { - accountId: accountId ?? undefined, - replyTo: replyToId ?? undefined, - cfg: cfg as CoreConfig, - }, - ), + text, + mediaUrl: mediaUrl ?? "", + accountId, + replyToId, + }), }, }, }); diff --git a/extensions/nextcloud-talk/src/inbound.behavior.test.ts b/extensions/nextcloud-talk/src/inbound.behavior.test.ts index 3e4bb40a459..473997dea73 100644 --- a/extensions/nextcloud-talk/src/inbound.behavior.test.ts +++ b/extensions/nextcloud-talk/src/inbound.behavior.test.ts @@ -7,7 +7,7 @@ import type { CoreConfig, NextcloudTalkInboundMessage } from "./types.js"; const { createChannelPairingControllerMock, - dispatchInboundReplyWithBaseMock, + dispatchChannelMessageReplyWithBaseMock, readStoreAllowFromForDmPolicyMock, resolveDmGroupAccessWithCommandGateMock, resolveAllowlistProviderRuntimeGroupPolicyMock, @@ -16,7 +16,7 @@ const { } = vi.hoisted(() => { return { createChannelPairingControllerMock: vi.fn(), - dispatchInboundReplyWithBaseMock: vi.fn(), + dispatchChannelMessageReplyWithBaseMock: vi.fn(), readStoreAllowFromForDmPolicyMock: vi.fn(), resolveDmGroupAccessWithCommandGateMock: vi.fn(), resolveAllowlistProviderRuntimeGroupPolicyMock: vi.fn(), @@ -33,7 +33,7 @@ vi.mock("../runtime-api.js", async () => { return { ...actual, createChannelPairingController: createChannelPairingControllerMock, - dispatchInboundReplyWithBase: dispatchInboundReplyWithBaseMock, + dispatchChannelMessageReplyWithBase: dispatchChannelMessageReplyWithBaseMock, readStoreAllowFromForDmPolicy: readStoreAllowFromForDmPolicyMock, resolveDmGroupAccessWithCommandGate: resolveDmGroupAccessWithCommandGateMock, resolveAllowlistProviderRuntimeGroupPolicy: resolveAllowlistProviderRuntimeGroupPolicyMock, @@ -196,7 +196,7 @@ describe("nextcloud-talk inbound behavior", () => { runtime, }); - expect(dispatchInboundReplyWithBaseMock).not.toHaveBeenCalled(); + expect(dispatchChannelMessageReplyWithBaseMock).not.toHaveBeenCalled(); expect(runtime.log).toHaveBeenCalledWith("nextcloud-talk: drop room room-group (no mention)"); }); }); diff --git a/extensions/nextcloud-talk/src/inbound.ts b/extensions/nextcloud-talk/src/inbound.ts index 318abdda8a1..687c72fb082 100644 --- a/extensions/nextcloud-talk/src/inbound.ts +++ b/extensions/nextcloud-talk/src/inbound.ts @@ -3,7 +3,7 @@ import { GROUP_POLICY_BLOCKED_LABEL, createChannelPairingController, deliverFormattedTextWithAttachments, - dispatchInboundReplyWithBase, + dispatchChannelMessageReplyWithBase, logInboundDrop, readStoreAllowFromForDmPolicy, resolveAllowlistProviderRuntimeGroupPolicy, @@ -286,7 +286,7 @@ export async function handleNextcloudTalkInbound(params: { CommandAuthorized: commandAuthorized, }); - await dispatchInboundReplyWithBase({ + await dispatchChannelMessageReplyWithBase({ cfg: config as OpenClawConfig, channel: CHANNEL_ID, accountId: account.accountId, diff --git a/extensions/nextcloud-talk/src/message-adapter.ts b/extensions/nextcloud-talk/src/message-adapter.ts new file mode 100644 index 00000000000..f819fe5d230 --- /dev/null +++ b/extensions/nextcloud-talk/src/message-adapter.ts @@ -0,0 +1,28 @@ +import { defineChannelMessageAdapter } from "openclaw/plugin-sdk/channel-message"; +import { sendMessageNextcloudTalk } from "./send.js"; +import type { CoreConfig } from "./types.js"; + +export const nextcloudTalkMessageAdapter = defineChannelMessageAdapter({ + id: "nextcloud-talk", + durableFinal: { + capabilities: { + text: true, + media: true, + replyTo: true, + }, + }, + send: { + text: async ({ cfg, to, text, accountId, replyToId }) => + await sendMessageNextcloudTalk(to, text, { + accountId: accountId ?? undefined, + replyTo: replyToId ?? undefined, + cfg: cfg as CoreConfig, + }), + media: async ({ cfg, to, text, mediaUrl, accountId, replyToId }) => + await sendMessageNextcloudTalk(to, mediaUrl ? `${text}\n\nAttachment: ${mediaUrl}` : text, { + accountId: accountId ?? undefined, + replyTo: replyToId ?? undefined, + cfg: cfg as CoreConfig, + }), + }, +}); diff --git a/extensions/nextcloud-talk/src/send.cfg-threading.test.ts b/extensions/nextcloud-talk/src/send.cfg-threading.test.ts index bb2e9a4eb3c..e23c07ebac6 100644 --- a/extensions/nextcloud-talk/src/send.cfg-threading.test.ts +++ b/extensions/nextcloud-talk/src/send.cfg-threading.test.ts @@ -1,7 +1,9 @@ +import { verifyChannelMessageAdapterCapabilityProofs } from "openclaw/plugin-sdk/channel-message"; import { createSendCfgThreadingRuntime, expectProvidedCfgSkipsRuntimeLoad, } from "openclaw/plugin-sdk/channel-test-helpers"; +import type { OpenClawConfig as CoreConfig } from "openclaw/plugin-sdk/config-types"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const hoisted = vi.hoisted(() => ({ @@ -36,6 +38,7 @@ vi.mock("./send.runtime.js", () => { }; }); +const { nextcloudTalkMessageAdapter } = await import("./message-adapter.js"); const { sendMessageNextcloudTalk, sendReactionNextcloudTalk } = await import("./send.js"); function expectProvidedMessageCfgThreading(cfg: unknown): void { @@ -111,11 +114,26 @@ describe("nextcloud-talk send cfg threading", () => { direction: "outbound", }); expect(fetchMock).toHaveBeenCalledTimes(1); - expect(result).toEqual({ + expect(result).toMatchObject({ messageId: "12345", roomToken: "abc123", timestamp: 1_706_000_000, }); + expect(result.receipt).toMatchObject({ + primaryPlatformMessageId: "12345", + platformMessageIds: ["12345"], + parts: [ + { + platformMessageId: "12345", + kind: "text", + raw: { + channel: "nextcloud-talk", + conversationId: "abc123", + messageId: "12345", + }, + }, + ], + }); }); it("sends with provided cfg even when the runtime store is not initialized", async () => { @@ -131,13 +149,94 @@ describe("nextcloud-talk send cfg threading", () => { }); expectProvidedMessageCfgThreading(cfg); - expect(result).toEqual({ + expect(result).toMatchObject({ messageId: "12346", roomToken: "abc123", timestamp: 1_706_000_001, }); }); + it("preserves reply ids in receipts", async () => { + const cfg = { source: "provided" } as const; + mockNextcloudMessageResponse(12347, 1_706_000_002); + + const result = await sendMessageNextcloudTalk("room:abc123", "hello", { + cfg, + accountId: "work", + replyTo: "parent-1", + }); + + expect(result.receipt).toMatchObject({ + replyToId: "parent-1", + parts: [ + { + platformMessageId: "12347", + replyToId: "parent-1", + }, + ], + }); + }); + + it("declares message adapter durable text, media, and reply with receipt proofs", async () => { + const cfg = { source: "provided" } as const; + mockNextcloudMessageResponse(22345, 1_706_000_003); + mockNextcloudMessageResponse(22346, 1_706_000_004); + mockNextcloudMessageResponse(22347, 1_706_000_005); + + await expect( + verifyChannelMessageAdapterCapabilityProofs({ + adapterName: "nextcloud-talk", + adapter: nextcloudTalkMessageAdapter, + proofs: { + text: async () => { + const result = await nextcloudTalkMessageAdapter.send?.text?.({ + cfg: cfg as CoreConfig, + to: "room:abc123", + text: "hello", + accountId: "work", + }); + expect(result?.receipt.platformMessageIds).toEqual(["22345"]); + }, + media: async () => { + const result = await nextcloudTalkMessageAdapter.send?.media?.({ + cfg: cfg as CoreConfig, + to: "room:abc123", + text: "image", + mediaUrl: "https://example.com/image.png", + accountId: "work", + }); + expect(result?.receipt.platformMessageIds).toEqual(["22346"]); + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + "https://nextcloud.example.com/ocs/v2.php/apps/spreed/api/v1/bot/abc123/message", + expect.objectContaining({ + body: JSON.stringify({ + message: "image\n\nAttachment: https://example.com/image.png", + }), + }), + ); + }, + replyTo: async () => { + const result = await nextcloudTalkMessageAdapter.send?.text?.({ + cfg: cfg as CoreConfig, + to: "room:abc123", + text: "threaded", + replyToId: "parent-1", + accountId: "work", + }); + expect(result?.receipt.replyToId).toBe("parent-1"); + }, + }, + }), + ).resolves.toEqual( + expect.arrayContaining([ + { capability: "text", status: "verified" }, + { capability: "media", status: "verified" }, + { capability: "replyTo", status: "verified" }, + ]), + ); + }); + it("fails hard for sendReaction when cfg is omitted", async () => { fetchMock.mockResolvedValueOnce(new Response("{}", { status: 200 })); diff --git a/extensions/nextcloud-talk/src/send.ts b/extensions/nextcloud-talk/src/send.ts index c7aa17f7b94..17867ad62d5 100644 --- a/extensions/nextcloud-talk/src/send.ts +++ b/extensions/nextcloud-talk/src/send.ts @@ -1,3 +1,4 @@ +import { createMessageReceiptFromOutboundResults } from "openclaw/plugin-sdk/channel-message"; import { stripNextcloudTalkTargetPrefix } from "./normalize.js"; import { convertMarkdownTables, @@ -81,6 +82,28 @@ function recordNextcloudTalkOutboundActivity(accountId: string): void { } } +function createNextcloudTalkSendReceipt(params: { + messageId: string; + roomToken: string; + replyTo?: string; +}) { + const messageId = params.messageId.trim(); + return createMessageReceiptFromOutboundResults({ + results: + messageId && messageId !== "unknown" + ? [ + { + channel: "nextcloud-talk", + messageId, + conversationId: params.roomToken, + }, + ] + : [], + kind: "text", + ...(params.replyTo ? { replyToId: params.replyTo } : {}), + }); +} + export async function sendMessageNextcloudTalk( to: string, text: string, @@ -183,7 +206,16 @@ export async function sendMessageNextcloudTalk( recordNextcloudTalkOutboundActivity(account.accountId); - return { messageId, roomToken, timestamp }; + return { + messageId, + roomToken, + receipt: createNextcloudTalkSendReceipt({ + messageId, + roomToken, + ...(opts.replyTo ? { replyTo: opts.replyTo } : {}), + }), + timestamp, + }; } finally { await release(); } diff --git a/extensions/nextcloud-talk/src/types.ts b/extensions/nextcloud-talk/src/types.ts index 08f244fbe9f..a44709b3a6e 100644 --- a/extensions/nextcloud-talk/src/types.ts +++ b/extensions/nextcloud-talk/src/types.ts @@ -1,3 +1,4 @@ +import type { MessageReceipt } from "openclaw/plugin-sdk/channel-message"; import type { BlockStreamingCoalesceConfig, DmConfig, @@ -144,6 +145,7 @@ export type NextcloudTalkWebhookPayload = { export type NextcloudTalkSendResult = { messageId: string; roomToken: string; + receipt: MessageReceipt; timestamp?: number; }; diff --git a/extensions/nostr/src/channel.outbound.test.ts b/extensions/nostr/src/channel.outbound.test.ts index 84bcf22e31e..53e3d523833 100644 --- a/extensions/nostr/src/channel.outbound.test.ts +++ b/extensions/nostr/src/channel.outbound.test.ts @@ -1,7 +1,9 @@ +import { verifyChannelMessageAdapterCapabilityProofs } from "openclaw/plugin-sdk/channel-message"; import { createStartAccountContext } from "openclaw/plugin-sdk/channel-test-helpers"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { PluginRuntime } from "../runtime-api.js"; +import { nostrPlugin } from "./channel.js"; import { nostrOutboundAdapter, startNostrGatewayAccount } from "./gateway.js"; import { setNostrRuntime } from "./runtime.js"; import { TEST_RESOLVED_PRIVATE_KEY, buildResolvedNostrAccount } from "./test-fixtures.js"; @@ -125,4 +127,34 @@ describe("nostr outbound cfg threading", () => { cleanup.stop(); }); + + it("backs declared message adapter capabilities with outbound sends", async () => { + installOutboundRuntime(); + const { cleanup, sendDm } = await startOutboundAccount(); + const adapter = nostrPlugin.message; + expect(adapter).toBeDefined(); + expect(adapter!.send?.media).toBeUndefined(); + + await verifyChannelMessageAdapterCapabilityProofs({ + adapterName: "nostrMessageAdapter", + adapter: adapter!, + proofs: { + text: async () => { + const result = await adapter!.send!.text!({ + cfg: createCfg() as OpenClawConfig, + to: "NPUB123", + text: "hello", + accountId: "default", + }); + expect(sendDm).toHaveBeenCalledWith("normalized-npub123", "hello"); + expect(result.receipt.parts[0]?.kind).toBe("text"); + }, + messageSendingHooks: () => { + expect(adapter!.send!.text).toBeTypeOf("function"); + }, + }, + }); + + cleanup.stop(); + }); }); diff --git a/extensions/nostr/src/channel.ts b/extensions/nostr/src/channel.ts index fcdcd93a094..da0e144dbbf 100644 --- a/extensions/nostr/src/channel.ts +++ b/extensions/nostr/src/channel.ts @@ -4,6 +4,7 @@ import { createTopLevelChannelConfigAdapter, } from "openclaw/plugin-sdk/channel-config-helpers"; import { createChatChannelPlugin } from "openclaw/plugin-sdk/channel-core"; +import { createChannelMessageAdapterFromOutbound } from "openclaw/plugin-sdk/channel-message"; import { buildPassiveChannelStatusSummary, buildTrafficStatusSummary, @@ -85,6 +86,11 @@ const nostrConfigAdapter = createTopLevelChannelConfigAdapter = createChatChannelPlugin({ base: { id: "nostr", @@ -137,6 +143,7 @@ export const nostrPlugin: ChannelPlugin = createChatChanne }, resolveOutboundSessionRoute: (params) => resolveNostrOutboundSessionRoute(params), }, + message: nostrMessageAdapter, status: { ...createComputedAccountStatusAdapter({ defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID), diff --git a/extensions/nostr/src/gateway.ts b/extensions/nostr/src/gateway.ts index 348617c50db..a36d9df0c8b 100644 --- a/extensions/nostr/src/gateway.ts +++ b/extensions/nostr/src/gateway.ts @@ -18,7 +18,7 @@ type NostrGatewayStart = NonNullable< >; type NostrOutboundAdapter = Pick< ChannelOutboundAdapter, - "deliveryMode" | "textChunkLimit" | "sendText" + "deliveryCapabilities" | "deliveryMode" | "textChunkLimit" | "sendText" > & { sendText: NonNullable; }; @@ -275,6 +275,12 @@ export const nostrPairingTextAdapter = { export const nostrOutboundAdapter: NostrOutboundAdapter = { deliveryMode: "direct", textChunkLimit: 4000, + deliveryCapabilities: { + durableFinal: { + text: true, + messageSendingHooks: true, + }, + }, sendText: async ({ cfg, to, text, accountId }) => { const core = getNostrRuntime(); const aid = accountId ?? resolveDefaultNostrAccountId(cfg); diff --git a/extensions/qa-channel/runtime-api.ts b/extensions/qa-channel/runtime-api.ts index 370636999e2..943612b2c1c 100644 --- a/extensions/qa-channel/runtime-api.ts +++ b/extensions/qa-channel/runtime-api.ts @@ -10,7 +10,7 @@ export { createDefaultChannelRuntimeState, createPluginRuntimeStore, defineChannelPluginEntry, - dispatchInboundReplyWithBase, + dispatchChannelMessageReplyWithBase, getChatChannelMeta, jsonResult, type OpenClawConfig, diff --git a/extensions/qa-channel/src/channel.test.ts b/extensions/qa-channel/src/channel.test.ts index 066ead1bca7..86654cd923a 100644 --- a/extensions/qa-channel/src/channel.test.ts +++ b/extensions/qa-channel/src/channel.test.ts @@ -1,3 +1,4 @@ +import { verifyChannelMessageAdapterCapabilityProofs } from "openclaw/plugin-sdk/channel-message"; import { createStartAccountContext } from "openclaw/plugin-sdk/channel-test-helpers"; import type { PluginRuntime } from "openclaw/plugin-sdk/core"; import { @@ -126,6 +127,7 @@ async function startQaChannelTestHarness(params?: { ); return { state, + baseUrl: bus.baseUrl, async stop() { abort.abort(); await task; @@ -211,6 +213,47 @@ describe("qa-channel plugin", () => { expect(route?.threadId).toBeUndefined(); }); + it("backs declared message adapter capabilities with qa bus sends", async () => { + const harness = await startQaChannelTestHarness({ allowFrom: ["*"] }); + try { + const adapter = qaChannelPlugin.message; + expect(adapter).toBeDefined(); + + const proveText = async () => { + const result = await adapter!.send!.text!({ + cfg: createQaChannelConfig({ baseUrl: harness.baseUrl, allowFrom: ["*"] }), + to: "thread:qa-room/thread-1", + text: "hello", + accountId: "default", + replyToId: "parent-1", + threadId: "thread-1", + }); + expect(result.receipt.parts[0]).toEqual( + expect.objectContaining({ + kind: "text", + replyToId: "parent-1", + threadId: "thread-1", + }), + ); + }; + + await verifyChannelMessageAdapterCapabilityProofs({ + adapterName: "qaChannelMessageAdapter", + adapter: adapter!, + proofs: { + text: proveText, + replyTo: proveText, + thread: proveText, + messageSendingHooks: () => { + expect(adapter!.send!.text).toBeTypeOf("function"); + }, + }, + }); + } finally { + await harness.stop(); + } + }); + it("roundtrips inbound DM traffic through the qa bus", { timeout: 20_000 }, async () => { const harness = await startQaChannelTestHarness({ allowFrom: ["*"] }); diff --git a/extensions/qa-channel/src/channel.ts b/extensions/qa-channel/src/channel.ts index e3c130df4d2..e41571ed361 100644 --- a/extensions/qa-channel/src/channel.ts +++ b/extensions/qa-channel/src/channel.ts @@ -3,6 +3,10 @@ import { buildThreadAwareOutboundSessionRoute, createChatChannelPlugin, } from "openclaw/plugin-sdk/channel-core"; +import { + createMessageReceiptFromOutboundResults, + defineChannelMessageAdapter, +} from "openclaw/plugin-sdk/channel-message"; import { getChatChannelMeta } from "openclaw/plugin-sdk/channel-plugin-common"; import { DEFAULT_ACCOUNT_ID, @@ -23,6 +27,41 @@ import type { CoreConfig, ResolvedQaChannelAccount } from "./types.js"; const CHANNEL_ID = "qa-channel" as const; const meta = { ...getChatChannelMeta(CHANNEL_ID) }; +const qaChannelMessageAdapter = defineChannelMessageAdapter({ + id: CHANNEL_ID, + durableFinal: { + capabilities: { + text: true, + replyTo: true, + thread: true, + messageSendingHooks: true, + }, + }, + send: { + text: async (ctx) => { + const result = await sendQaChannelText({ + cfg: ctx.cfg as CoreConfig, + accountId: ctx.accountId, + to: ctx.to, + text: ctx.text, + threadId: ctx.threadId, + replyToId: ctx.replyToId, + }); + const threadId = ctx.threadId == null ? undefined : String(ctx.threadId); + const replyToId = ctx.replyToId ?? undefined; + return { + messageId: result.messageId, + receipt: createMessageReceiptFromOutboundResults({ + results: [{ channel: CHANNEL_ID, messageId: result.messageId }], + threadId, + replyToId, + kind: "text", + }), + }; + }, + }, +}); + export const qaChannelPlugin: ChannelPlugin = createChatChannelPlugin({ base: { id: CHANNEL_ID, @@ -124,6 +163,7 @@ export const qaChannelPlugin: ChannelPlugin = createCh }, }, actions: qaChannelMessageActions, + message: qaChannelMessageAdapter, }, outbound: { base: { diff --git a/extensions/qa-channel/src/inbound.test.ts b/extensions/qa-channel/src/inbound.test.ts index 5ae27b5829f..b55217e48be 100644 --- a/extensions/qa-channel/src/inbound.test.ts +++ b/extensions/qa-channel/src/inbound.test.ts @@ -3,14 +3,18 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { setQaChannelRuntime } from "../api.js"; import { handleQaInbound, isHttpMediaUrl } from "./inbound.js"; -const dispatchInboundReplyWithBaseMock = vi.hoisted(() => vi.fn()); +const dispatchChannelMessageReplyWithBaseMock = vi.hoisted(() => vi.fn()); -vi.mock("openclaw/plugin-sdk/inbound-reply-dispatch", () => ({ - dispatchInboundReplyWithBase: dispatchInboundReplyWithBaseMock, -})); +vi.mock("openclaw/plugin-sdk/channel-message", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + dispatchChannelMessageReplyWithBase: dispatchChannelMessageReplyWithBaseMock, + }; +}); beforeEach(() => { - dispatchInboundReplyWithBaseMock.mockReset(); + dispatchChannelMessageReplyWithBaseMock.mockReset(); }); describe("isHttpMediaUrl", () => { @@ -60,7 +64,9 @@ describe("handleQaInbound", () => { }, }); - expect(dispatchInboundReplyWithBaseMock).toHaveBeenCalledTimes(1); - expect(dispatchInboundReplyWithBaseMock.mock.calls[0]?.[0].ctxPayload.WasMentioned).toBe(true); + expect(dispatchChannelMessageReplyWithBaseMock).toHaveBeenCalledTimes(1); + expect(dispatchChannelMessageReplyWithBaseMock.mock.calls[0]?.[0].ctxPayload.WasMentioned).toBe( + true, + ); }); }); diff --git a/extensions/qa-channel/src/inbound.ts b/extensions/qa-channel/src/inbound.ts index a190b6979ff..28a2a4bade8 100644 --- a/extensions/qa-channel/src/inbound.ts +++ b/extensions/qa-channel/src/inbound.ts @@ -1,5 +1,5 @@ +import { dispatchChannelMessageReplyWithBase } from "openclaw/plugin-sdk/channel-message"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; -import { dispatchInboundReplyWithBase } from "openclaw/plugin-sdk/inbound-reply-dispatch"; import { buildAgentMediaPayload, saveMediaBuffer, @@ -151,7 +151,7 @@ export async function handleQaInbound(params: { ...mediaPayload, }); - await dispatchInboundReplyWithBase({ + await dispatchChannelMessageReplyWithBase({ cfg: params.config as OpenClawConfig, channel: params.channelId, accountId: params.account.accountId, diff --git a/extensions/qa-channel/src/runtime-api.ts b/extensions/qa-channel/src/runtime-api.ts index 4a5d243bb16..ca97c5fd9e7 100644 --- a/extensions/qa-channel/src/runtime-api.ts +++ b/extensions/qa-channel/src/runtime-api.ts @@ -20,4 +20,4 @@ export { createDefaultChannelRuntimeState, } from "openclaw/plugin-sdk/status-helpers"; export { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store"; -export { dispatchInboundReplyWithBase } from "openclaw/plugin-sdk/inbound-reply-dispatch"; +export { dispatchChannelMessageReplyWithBase } from "openclaw/plugin-sdk/channel-message"; diff --git a/extensions/qqbot/src/channel.message-adapter.test.ts b/extensions/qqbot/src/channel.message-adapter.test.ts new file mode 100644 index 00000000000..10f362f4e50 --- /dev/null +++ b/extensions/qqbot/src/channel.message-adapter.test.ts @@ -0,0 +1,89 @@ +import { verifyChannelMessageAdapterCapabilityProofs } from "openclaw/plugin-sdk/channel-message"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; +import { describe, expect, it, vi } from "vitest"; +import { qqbotPlugin } from "./channel.js"; + +const sendTextMock = vi.hoisted(() => vi.fn()); +const sendMediaMock = vi.hoisted(() => vi.fn()); + +vi.mock("./bridge/gateway.js", () => ({})); +vi.mock("./engine/messaging/outbound.js", () => ({ + sendText: sendTextMock, + sendMedia: sendMediaMock, +})); + +const cfg = { + channels: { + qqbot: { + appId: "app", + clientSecret: "secret", + }, + }, +} as OpenClawConfig; + +describe("qqbot message adapter", () => { + it("declares durable text, media, and reply target capabilities with receipt proofs", async () => { + sendTextMock.mockResolvedValue({ messageId: "qq-text-1" }); + sendMediaMock.mockResolvedValue({ messageId: "qq-media-1" }); + + await expect( + verifyChannelMessageAdapterCapabilityProofs({ + adapterName: "qqbot", + adapter: qqbotPlugin.message!, + proofs: { + text: async () => { + const result = await qqbotPlugin.message?.send?.text?.({ + cfg, + to: "qqbot:c2c:user-1", + text: "hello", + }); + expect(sendTextMock).toHaveBeenCalledWith( + expect.objectContaining({ + to: "qqbot:c2c:user-1", + text: "hello", + }), + ); + expect(result?.receipt.platformMessageIds).toEqual(["qq-text-1"]); + }, + media: async () => { + const result = await qqbotPlugin.message?.send?.media?.({ + cfg, + to: "qqbot:c2c:user-1", + text: "image", + mediaUrl: "https://example.com/image.png", + }); + expect(sendMediaMock).toHaveBeenCalledWith( + expect.objectContaining({ + to: "qqbot:c2c:user-1", + text: "image", + mediaUrl: "https://example.com/image.png", + }), + ); + expect(result?.receipt.platformMessageIds).toEqual(["qq-media-1"]); + }, + replyTo: async () => { + const result = await qqbotPlugin.message?.send?.text?.({ + cfg, + to: "qqbot:group:group-1", + text: "reply", + replyToId: "msg-1", + }); + expect(sendTextMock).toHaveBeenCalledWith( + expect.objectContaining({ + to: "qqbot:group:group-1", + replyToId: "msg-1", + }), + ); + expect(result?.receipt.platformMessageIds).toEqual(["qq-text-1"]); + }, + }, + }), + ).resolves.toEqual( + expect.arrayContaining([ + { capability: "text", status: "verified" }, + { capability: "media", status: "verified" }, + { capability: "replyTo", status: "verified" }, + ]), + ); + }); +}); diff --git a/extensions/qqbot/src/channel.ts b/extensions/qqbot/src/channel.ts index e032ad6c127..d07a9999a61 100644 --- a/extensions/qqbot/src/channel.ts +++ b/extensions/qqbot/src/channel.ts @@ -1,4 +1,10 @@ import { getExecApprovalReplyMetadata } from "openclaw/plugin-sdk/approval-runtime"; +import { + createMessageReceiptFromOutboundResults, + defineChannelMessageAdapter, + type ChannelMessageSendResult, + type MessageReceiptPartKind, +} from "openclaw/plugin-sdk/channel-message"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; import type { ChannelPlugin } from "openclaw/plugin-sdk/core"; // Register the PlatformAdapter before any core/ module is used. @@ -25,12 +31,137 @@ import type { ResolvedQQBotAccount } from "./types.js"; // Shared promise so concurrent multi-account startups serialize the dynamic // import of the gateway module, avoiding an ESM circular-dependency race. -let _gatewayModulePromise: Promise | undefined; +let gatewayModulePromise: Promise | undefined; function loadGatewayModule(): Promise { - _gatewayModulePromise ??= import("./bridge/gateway.js"); - return _gatewayModulePromise; + gatewayModulePromise ??= import("./bridge/gateway.js"); + return gatewayModulePromise; } +function createQQBotSendReceipt(params: { + messageId?: string; + target: string; + kind: MessageReceiptPartKind; +}) { + const messageId = params.messageId?.trim(); + return createMessageReceiptFromOutboundResults({ + results: messageId + ? [ + { + channel: "qqbot", + messageId, + conversationId: params.target, + }, + ] + : [], + threadId: params.target, + kind: params.kind, + }); +} + +async function sendQQBotText(params: { + cfg: OpenClawConfig; + to: string; + text: string; + accountId?: string | null; + replyToId?: string | null; +}) { + // Ensure bridge/gateway.ts module-level registrations (audio adapter factory, + // platform adapter, etc.) have executed before engine code runs. + await loadGatewayModule(); + const account = resolveQQBotAccount(params.cfg, params.accountId); + const { sendText } = await import("./engine/messaging/outbound.js"); + const result = await sendText({ + to: params.to, + text: params.text, + accountId: params.accountId, + replyToId: params.replyToId, + account: toGatewayAccount(account), + }); + return { + channel: "qqbot" as const, + messageId: result.messageId ?? "", + receipt: createQQBotSendReceipt({ + messageId: result.messageId, + target: params.to, + kind: "text", + }), + meta: result.error ? { error: result.error } : undefined, + }; +} + +async function sendQQBotMedia(params: { + cfg: OpenClawConfig; + to: string; + text?: string | null; + mediaUrl?: string | null; + accountId?: string | null; + replyToId?: string | null; +}) { + // Same guard as sendText — ensure adapters are registered. + await loadGatewayModule(); + const account = resolveQQBotAccount(params.cfg, params.accountId); + const { sendMedia } = await import("./engine/messaging/outbound.js"); + const result = await sendMedia({ + to: params.to, + text: params.text ?? "", + mediaUrl: params.mediaUrl ?? "", + accountId: params.accountId, + replyToId: params.replyToId, + account: toGatewayAccount(account), + }); + return { + channel: "qqbot" as const, + messageId: result.messageId ?? "", + receipt: createQQBotSendReceipt({ + messageId: result.messageId, + target: params.to, + kind: "media", + }), + meta: result.error ? { error: result.error } : undefined, + }; +} + +function toQQBotMessageSendResult(result: Awaited>) { + return { + messageId: result.messageId, + receipt: result.receipt, + } satisfies ChannelMessageSendResult; +} + +const qqbotMessageAdapter = defineChannelMessageAdapter({ + id: "qqbot", + durableFinal: { + capabilities: { + text: true, + media: true, + replyTo: true, + }, + }, + send: { + text: async (ctx) => + toQQBotMessageSendResult( + await sendQQBotText({ + cfg: ctx.cfg, + to: ctx.to, + text: ctx.text, + accountId: ctx.accountId, + replyToId: ctx.replyToId, + }), + ), + media: async (ctx) => + toQQBotMessageSendResult( + await sendQQBotMedia({ + cfg: ctx.cfg, + to: ctx.to, + text: ctx.text, + mediaUrl: ctx.mediaUrl, + accountId: ctx.accountId, + replyToId: ctx.replyToId, + }), + ), + }, +}); + const EXEC_APPROVAL_COMMAND_RE = /\/approve(?:@[^\s]+)?\s+[A-Za-z0-9][A-Za-z0-9._:-]*\s+(?:allow-once|allow-always|always|deny)\b/i; @@ -98,6 +229,7 @@ export const qqbotPlugin: ChannelPlugin = { ...qqbotSetupAdapterShared, }, approvalCapability: getQQBotApprovalCapability(), + message: qqbotMessageAdapter, messaging: { targetPrefixes: ["qqbot"], /** Normalize common QQ Bot target formats into the canonical qqbot:... form. */ @@ -120,44 +252,23 @@ export const qqbotPlugin: ChannelPlugin = { payload, hint, }), - sendText: async ({ to, text, accountId, replyToId, cfg }) => { - // Ensure bridge/gateway.ts module-level registrations (audio adapter factory, - // platform adapter, etc.) have executed before engine code runs. - await loadGatewayModule(); - const account = resolveQQBotAccount(cfg, accountId); - const { sendText } = await import("./engine/messaging/outbound.js"); - const result = await sendText({ + sendText: async ({ to, text, accountId, replyToId, cfg }) => + await sendQQBotText({ + cfg, to, text, accountId, replyToId, - account: toGatewayAccount(account), - }); - return { - channel: "qqbot" as const, - messageId: result.messageId ?? "", - meta: result.error ? { error: result.error } : undefined, - }; - }, - sendMedia: async ({ to, text, mediaUrl, accountId, replyToId, cfg }) => { - // Same guard as sendText — ensure adapters are registered. - await loadGatewayModule(); - const account = resolveQQBotAccount(cfg, accountId); - const { sendMedia } = await import("./engine/messaging/outbound.js"); - const result = await sendMedia({ + }), + sendMedia: async ({ to, text, mediaUrl, accountId, replyToId, cfg }) => + await sendQQBotMedia({ + cfg, to, - text: text ?? "", - mediaUrl: mediaUrl ?? "", + text, + mediaUrl, accountId, replyToId, - account: toGatewayAccount(account), - }); - return { - channel: "qqbot" as const, - messageId: result.messageId ?? "", - meta: result.error ? { error: result.error } : undefined, - }; - }, + }), }, gateway: { startAccount: async (ctx) => { diff --git a/extensions/qqbot/src/engine/messaging/outbound-types.ts b/extensions/qqbot/src/engine/messaging/outbound-types.ts index ddd43366944..3a5858a906e 100644 --- a/extensions/qqbot/src/engine/messaging/outbound-types.ts +++ b/extensions/qqbot/src/engine/messaging/outbound-types.ts @@ -1,3 +1,4 @@ +import type { MessageReceipt } from "openclaw/plugin-sdk/channel-message"; import type { GatewayAccount } from "../types.js"; export interface OutboundContext { @@ -28,6 +29,7 @@ export const DEFAULT_MEDIA_SEND_ERROR = "发送失败,请稍后重试。"; export interface OutboundResult { channel: string; messageId?: string; + receipt?: MessageReceipt; timestamp?: string | number; error?: string; errorCode?: OutboundErrorCode; diff --git a/extensions/signal/src/channel.ts b/extensions/signal/src/channel.ts index 8d37c813316..0087b4c0c1e 100644 --- a/extensions/signal/src/channel.ts +++ b/extensions/signal/src/channel.ts @@ -1,6 +1,7 @@ import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id"; import { buildDmGroupAccountAllowlistAdapter } from "openclaw/plugin-sdk/allowlist-config-edit"; import { createChatChannelPlugin, type ChannelPlugin } from "openclaw/plugin-sdk/channel-core"; +import { defineChannelMessageAdapter } from "openclaw/plugin-sdk/channel-message"; import { createPairingPrefixStripper } from "openclaw/plugin-sdk/channel-pairing"; import { attachChannelToResult, @@ -93,6 +94,41 @@ async function sendSignalOutbound(params: { }); } +type SignalMessageContextExtras = { + deps?: { [channelId: string]: unknown }; +}; + +const signalMessageAdapter = defineChannelMessageAdapter({ + id: "signal", + durableFinal: { + capabilities: { + text: true, + media: true, + }, + }, + send: { + text: async (ctx) => + await sendSignalOutbound({ + cfg: ctx.cfg, + to: ctx.to, + text: ctx.text, + accountId: ctx.accountId ?? undefined, + deps: (ctx as typeof ctx & SignalMessageContextExtras).deps, + }), + media: async (ctx) => + await sendSignalOutbound({ + cfg: ctx.cfg, + to: ctx.to, + text: ctx.text, + mediaUrl: ctx.mediaUrl, + mediaLocalRoots: ctx.mediaLocalRoots, + mediaReadFile: ctx.mediaReadFile, + accountId: ctx.accountId ?? undefined, + deps: (ctx as typeof ctx & SignalMessageContextExtras).deps, + }), + }, +}); + function inferSignalTargetChatType(rawTo: string) { let to = rawTo.trim(); if (!to) { @@ -343,6 +379,7 @@ export const signalPlugin: ChannelPlugin = }); }, }, + message: signalMessageAdapter, }, pairing: { text: { diff --git a/extensions/signal/src/core.test.ts b/extensions/signal/src/core.test.ts index b0248b8c8ff..a86dfd7c77a 100644 --- a/extensions/signal/src/core.test.ts +++ b/extensions/signal/src/core.test.ts @@ -1,3 +1,7 @@ +import { + createMessageReceiptFromOutboundResults, + verifyChannelMessageAdapterCapabilityProofs, +} from "openclaw/plugin-sdk/channel-message"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; import { createPluginSetupWizardStatus } from "openclaw/plugin-sdk/plugin-test-runtime"; import { describe, expect, it, vi } from "vitest"; @@ -217,6 +221,67 @@ describe("signal outbound", () => { expect(chunker("alpha beta", 5)).toEqual(["alpha", "beta"]); }); + + it("declares message adapter durable text and media with receipt proofs", async () => { + const send = vi.fn(async (_to: string, _text: string, opts: { mediaUrl?: string } = {}) => { + const messageId = opts.mediaUrl ? "signal-media-1" : "signal-text-1"; + return { + messageId, + receipt: createMessageReceiptFromOutboundResults({ + results: [{ channel: "signal", messageId }], + kind: opts.mediaUrl ? "media" : "text", + }), + }; + }); + const deps = { signal: send }; + + await expect( + verifyChannelMessageAdapterCapabilityProofs({ + adapterName: "signal", + adapter: signalPlugin.message!, + proofs: { + text: async () => { + const result = await signalPlugin.message?.send?.text?.({ + cfg: {} as OpenClawConfig, + to: "signal:+15555550123", + text: "hello", + deps, + } as Parameters>[0] & { + deps: typeof deps; + }); + expect(send).toHaveBeenCalledWith( + "signal:+15555550123", + "hello", + expect.objectContaining({ cfg: {} }), + ); + expect(result?.receipt.platformMessageIds).toEqual(["signal-text-1"]); + }, + media: async () => { + const result = await signalPlugin.message?.send?.media?.({ + cfg: {} as OpenClawConfig, + to: "signal:+15555550123", + text: "image", + mediaUrl: "https://example.com/image.png", + deps, + } as Parameters>[0] & { + deps: typeof deps; + }); + expect(send).toHaveBeenCalledWith( + "signal:+15555550123", + "image", + expect.objectContaining({ mediaUrl: "https://example.com/image.png" }), + ); + expect(result?.receipt.platformMessageIds).toEqual(["signal-media-1"]); + }, + }, + }), + ).resolves.toEqual( + expect.arrayContaining([ + { capability: "text", status: "verified" }, + { capability: "media", status: "verified" }, + ]), + ); + }); }); describe("classifySignalCliLogLine", () => { diff --git a/extensions/signal/src/monitor/event-handler.ts b/extensions/signal/src/monitor/event-handler.ts index ef3ee0e4fd6..f9f5264dac8 100644 --- a/extensions/signal/src/monitor/event-handler.ts +++ b/extensions/signal/src/monitor/event-handler.ts @@ -11,11 +11,11 @@ import { shouldDebounceTextInbound, } from "openclaw/plugin-sdk/channel-inbound"; import { logInboundDrop } from "openclaw/plugin-sdk/channel-inbound"; +import { createChannelMessageReplyPipeline } from "openclaw/plugin-sdk/channel-message"; import { resolveChannelGroupPolicy, resolveChannelGroupRequireMention, } from "openclaw/plugin-sdk/channel-policy"; -import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline"; import { resolveControlCommandGate } from "openclaw/plugin-sdk/command-auth"; import { hasControlCommand } from "openclaw/plugin-sdk/command-auth"; import { recordInboundSession } from "openclaw/plugin-sdk/conversation-runtime"; @@ -238,39 +238,40 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { logVerbose(`signal inbound: from=${ctxPayload.From} len=${body.length} preview="${preview}"`); } - const { onModelSelected, typingCallbacks, ...replyPipeline } = createChannelReplyPipeline({ - cfg: deps.cfg, - agentId: route.agentId, - channel: "signal", - accountId: route.accountId, - typing: { - start: async () => { - if (!ctxPayload.To) { - return; - } - await sendTypingSignal(ctxPayload.To, { - cfg: deps.cfg, - baseUrl: deps.baseUrl, - account: deps.account, - accountId: deps.accountId, - }); + const { onModelSelected, typingCallbacks, ...replyPipeline } = + createChannelMessageReplyPipeline({ + cfg: deps.cfg, + agentId: route.agentId, + channel: "signal", + accountId: route.accountId, + typing: { + start: async () => { + if (!ctxPayload.To) { + return; + } + await sendTypingSignal(ctxPayload.To, { + cfg: deps.cfg, + baseUrl: deps.baseUrl, + account: deps.account, + accountId: deps.accountId, + }); + }, + onStartError: (err) => { + logTypingFailure({ + log: logVerbose, + channel: "signal", + target: ctxPayload.To ?? undefined, + error: err, + }); + }, }, - onStartError: (err) => { - logTypingFailure({ - log: logVerbose, - channel: "signal", - target: ctxPayload.To ?? undefined, - error: err, - }); - }, - }, - }); + }); const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({ ...replyPipeline, humanDelay: resolveHumanDelayConfig(deps.cfg, route.agentId), typingCallbacks, - deliver: async (payload) => { + deliver: async (payload, _info) => { await deps.deliverReplies({ cfg: deps.cfg, replies: [payload], diff --git a/extensions/signal/src/send.test.ts b/extensions/signal/src/send.test.ts new file mode 100644 index 00000000000..fb4156230d2 --- /dev/null +++ b/extensions/signal/src/send.test.ts @@ -0,0 +1,111 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const signalRpcRequestMock = vi.hoisted(() => vi.fn()); +const resolveOutboundAttachmentFromUrlMock = vi.hoisted(() => + vi.fn(async (_params: unknown) => ({ path: "/tmp/image.png", contentType: "image/png" })), +); + +vi.mock("./client.js", () => ({ + signalRpcRequest: (...args: unknown[]) => signalRpcRequestMock(...args), +})); + +vi.mock("openclaw/plugin-sdk/media-runtime", async () => { + const actual = await vi.importActual( + "openclaw/plugin-sdk/media-runtime", + ); + return { + ...actual, + resolveOutboundAttachmentFromUrl: (params: unknown) => + resolveOutboundAttachmentFromUrlMock(params), + }; +}); + +const { sendMessageSignal } = await import("./send.js"); + +const SIGNAL_TEST_CFG = { + channels: { + signal: { + accounts: { + default: { + httpUrl: "http://signal.test", + account: "+15550001111", + }, + }, + }, + }, +}; + +describe("sendMessageSignal receipts", () => { + beforeEach(() => { + signalRpcRequestMock.mockReset(); + resolveOutboundAttachmentFromUrlMock.mockClear(); + }); + + it("attaches a text receipt for timestamp results", async () => { + signalRpcRequestMock.mockResolvedValueOnce({ timestamp: 1234567890 }); + + const result = await sendMessageSignal("+15551234567", "hello", { + cfg: SIGNAL_TEST_CFG, + }); + + expect(result).toMatchObject({ + messageId: "1234567890", + timestamp: 1234567890, + receipt: { + primaryPlatformMessageId: "1234567890", + platformMessageIds: ["1234567890"], + parts: [ + expect.objectContaining({ + platformMessageId: "1234567890", + kind: "text", + raw: expect.objectContaining({ + channel: "signal", + toJid: "+15551234567", + timestamp: 1234567890, + }), + }), + ], + }, + }); + }); + + it("attaches a media receipt for attachment sends", async () => { + signalRpcRequestMock.mockResolvedValueOnce({ timestamp: 1234567891 }); + + const result = await sendMessageSignal("group:group-1", "", { + cfg: SIGNAL_TEST_CFG, + mediaUrl: "/tmp/image.png", + mediaLocalRoots: ["/tmp"], + }); + + expect(resolveOutboundAttachmentFromUrlMock).toHaveBeenCalled(); + expect(result).toMatchObject({ + messageId: "1234567891", + receipt: { + primaryPlatformMessageId: "1234567891", + platformMessageIds: ["1234567891"], + parts: [ + expect.objectContaining({ + platformMessageId: "1234567891", + kind: "media", + raw: expect.objectContaining({ + channel: "signal", + chatId: "group-1", + }), + }), + ], + }, + }); + }); + + it("does not invent platform ids when signal-cli omits a timestamp", async () => { + signalRpcRequestMock.mockResolvedValueOnce({}); + + const result = await sendMessageSignal("+15551234567", "hello", { + cfg: SIGNAL_TEST_CFG, + }); + + expect(result.messageId).toBe("unknown"); + expect(result.receipt.platformMessageIds).toEqual([]); + }); +}); diff --git a/extensions/signal/src/send.ts b/extensions/signal/src/send.ts index 28d9c4f5188..5c77624ed74 100644 --- a/extensions/signal/src/send.ts +++ b/extensions/signal/src/send.ts @@ -1,3 +1,9 @@ +import { + createMessageReceiptFromOutboundResults, + type MessageReceipt, + type MessageReceiptPartKind, + type MessageReceiptSourceResult, +} from "openclaw/plugin-sdk/channel-message"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime"; import { kindFromMime } from "openclaw/plugin-sdk/media-runtime"; @@ -30,6 +36,7 @@ export type SignalSendOpts = { export type SignalSendResult = { messageId: string; timestamp?: number; + receipt: MessageReceipt; }; export type SignalRpcOpts = Pick< @@ -122,6 +129,43 @@ function buildTargetParams( return null; } +function createSignalSendReceipt(params: { + messageId: string; + timestamp?: number; + target: SignalTarget; + kind: MessageReceiptPartKind; +}): MessageReceipt { + const messageId = params.messageId.trim(); + const results: MessageReceiptSourceResult[] = + messageId && messageId !== "unknown" + ? [ + { + channel: "signal", + messageId, + meta: { + targetType: params.target.type, + }, + }, + ] + : []; + if (results[0]) { + if (params.timestamp != null) { + results[0].timestamp = params.timestamp; + } + if (params.target.type === "group") { + results[0].chatId = params.target.groupId; + } else if (params.target.type === "recipient") { + results[0].toJid = params.target.recipient; + } else { + results[0].toJid = params.target.username; + } + } + return createMessageReceiptFromOutboundResults({ + results, + kind: params.kind, + }); +} + export async function sendMessageSignal( to: string, text: string, @@ -214,9 +258,16 @@ export async function sendMessageSignal( timeoutMs: opts.timeoutMs, }); const timestamp = result?.timestamp; + const messageId = timestamp ? String(timestamp) : "unknown"; return { - messageId: timestamp ? String(timestamp) : "unknown", + messageId, timestamp, + receipt: createSignalSendReceipt({ + messageId, + target, + kind: attachments && attachments.length > 0 ? "media" : "text", + ...(timestamp != null ? { timestamp } : {}), + }), }; } diff --git a/extensions/slack/src/channel-actions.ts b/extensions/slack/src/channel-actions.ts index 788b499cc72..a1895900b07 100644 --- a/extensions/slack/src/channel-actions.ts +++ b/extensions/slack/src/channel-actions.ts @@ -41,6 +41,7 @@ export function createSlackActions( return { describeMessageTool: describeSlackMessageTool, extractToolSend: ({ args }) => extractSlackToolSend(args), + prepareSendPayload: ({ ctx, payload }) => (ctx.action === "send" ? payload : null), handleAction: async (ctx) => { return await handleSlackMessageAction({ providerId, diff --git a/extensions/slack/src/channel.message-adapter.test.ts b/extensions/slack/src/channel.message-adapter.test.ts new file mode 100644 index 00000000000..3af195af415 --- /dev/null +++ b/extensions/slack/src/channel.message-adapter.test.ts @@ -0,0 +1,187 @@ +import { + verifyChannelMessageAdapterCapabilityProofs, + verifyChannelMessageLiveCapabilityAdapterProofs, + verifyChannelMessageLiveFinalizerProofs, +} from "openclaw/plugin-sdk/channel-message"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { slackPlugin } from "./channel.js"; +import type { OpenClawConfig } from "./runtime-api.js"; + +const cfg = { + channels: { + slack: { + botToken: "xoxb-test", + appToken: "xapp-test", + }, + }, +} as OpenClawConfig; + +describe("slack channel message adapter", () => { + const sendSlack = vi.fn(); + + beforeEach(() => { + sendSlack.mockReset(); + sendSlack.mockResolvedValue({ messageId: "msg-1", channelId: "C123" }); + }); + + it("backs declared durable-final capabilities with outbound send proofs", async () => { + const adapter = slackPlugin.message; + expect(adapter).toBeDefined(); + + const proveText = async () => { + sendSlack.mockClear(); + const result = await adapter!.send!.text!({ + cfg, + to: "C123", + text: "hello", + accountId: "default", + deps: { sendSlack }, + }); + expect(sendSlack).toHaveBeenLastCalledWith( + "C123", + "hello", + expect.objectContaining({ accountId: "default" }), + ); + expect(result.receipt.platformMessageIds).toEqual(["msg-1"]); + expect(result.receipt.parts[0]?.kind).toBe("text"); + }; + + const proveMedia = async () => { + sendSlack.mockClear(); + const result = await adapter!.send!.media!({ + cfg, + to: "C123", + text: "caption", + mediaUrl: "https://example.com/a.png", + mediaLocalRoots: ["/tmp/media"], + accountId: "default", + deps: { sendSlack }, + }); + expect(sendSlack).toHaveBeenLastCalledWith( + "C123", + "caption", + expect.objectContaining({ + accountId: "default", + mediaUrl: "https://example.com/a.png", + mediaLocalRoots: ["/tmp/media"], + }), + ); + expect(result.receipt.parts[0]?.kind).toBe("media"); + }; + + const provePayload = async () => { + sendSlack.mockClear(); + const result = await adapter!.send!.payload!({ + cfg, + to: "C123", + text: "payload", + payload: { text: "payload" }, + accountId: "default", + deps: { sendSlack }, + }); + expect(sendSlack).toHaveBeenLastCalledWith( + "C123", + "payload", + expect.objectContaining({ accountId: "default" }), + ); + expect(result.receipt.platformMessageIds).toEqual(["msg-1"]); + }; + + const proveReplyThread = async () => { + sendSlack.mockClear(); + const result = await adapter!.send!.text!({ + cfg, + to: "C123", + text: "threaded", + accountId: "default", + replyToId: "1712000000.000001", + threadId: "1712345678.123456", + deps: { sendSlack }, + }); + expect(sendSlack).toHaveBeenLastCalledWith( + "C123", + "threaded", + expect.objectContaining({ + accountId: "default", + threadTs: "1712000000.000001", + }), + ); + expect(result.receipt.replyToId).toBe("1712000000.000001"); + }; + + const proveThreadFallback = async () => { + sendSlack.mockClear(); + const result = await adapter!.send!.text!({ + cfg, + to: "C123", + text: "threaded", + accountId: "default", + threadId: "1712345678.123456", + deps: { sendSlack }, + }); + expect(sendSlack).toHaveBeenLastCalledWith( + "C123", + "threaded", + expect.objectContaining({ + accountId: "default", + threadTs: "1712345678.123456", + }), + ); + expect(result.receipt.threadId).toBe("1712345678.123456"); + }; + + await verifyChannelMessageAdapterCapabilityProofs({ + adapterName: "slackMessageAdapter", + adapter: adapter!, + proofs: { + text: proveText, + media: proveMedia, + payload: provePayload, + replyTo: proveReplyThread, + thread: proveThreadFallback, + messageSendingHooks: () => { + expect(adapter!.send!.text).toBeTypeOf("function"); + }, + }, + }); + }); + + it("backs declared live preview finalizer capabilities with adapter proofs", async () => { + const adapter = slackPlugin.message; + + await verifyChannelMessageLiveCapabilityAdapterProofs({ + adapterName: "slackMessageAdapter", + adapter: adapter!, + proofs: { + draftPreview: () => { + expect(adapter!.live?.finalizer?.capabilities?.discardPending).toBe(true); + }, + previewFinalization: () => { + expect(adapter!.live?.finalizer?.capabilities?.finalEdit).toBe(true); + }, + progressUpdates: () => { + expect(adapter!.live?.capabilities?.draftPreview).toBe(true); + }, + nativeStreaming: () => { + expect(adapter!.live?.capabilities?.previewFinalization).toBe(true); + }, + }, + }); + + await verifyChannelMessageLiveFinalizerProofs({ + adapterName: "slackMessageAdapter", + adapter: adapter!, + proofs: { + finalEdit: () => { + expect(adapter!.live?.capabilities?.previewFinalization).toBe(true); + }, + normalFallback: () => { + expect(adapter!.send!.text).toBeTypeOf("function"); + }, + discardPending: () => { + expect(adapter!.live?.capabilities?.draftPreview).toBe(true); + }, + }, + }); + }); +}); diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index 46f6d944a42..e5e299cc59d 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -8,7 +8,12 @@ import { buildThreadAwareOutboundSessionRoute, createChatChannelPlugin, } from "openclaw/plugin-sdk/channel-core"; +import { createChannelMessageAdapterFromOutbound } from "openclaw/plugin-sdk/channel-message"; import { createPairingPrefixStripper } from "openclaw/plugin-sdk/channel-pairing"; +import { + createAttachedChannelResultAdapter, + type ChannelOutboundAdapter, +} from "openclaw/plugin-sdk/channel-send-result"; import { createChannelDirectoryAdapter, createRuntimeDirectoryLiveAdapter, @@ -357,6 +362,121 @@ const resolveSlackAllowlistNames = createAccountScopedAllowlistNameResolver({ (await loadSlackResolveUsersModule()).resolveSlackUserAllowlist({ token, entries }), }); +const slackChannelOutbound: ChannelOutboundAdapter = { + deliveryMode: "direct", + chunker: null, + textChunkLimit: SLACK_TEXT_LIMIT, + deliveryCapabilities: { + durableFinal: { + text: true, + media: true, + payload: true, + replyTo: true, + thread: true, + messageSendingHooks: true, + }, + }, + shouldTreatDeliveredTextAsVisible: shouldTreatSlackDeliveredTextAsVisible, + shouldSuppressLocalPayloadPrompt: ({ cfg, accountId, payload }) => + shouldSuppressLocalSlackExecApprovalPrompt({ + cfg, + accountId, + payload, + }), + sendPayload: async (ctx) => { + const { send, threadTsValue, tokenOverride } = await resolveSlackSendContext({ + cfg: ctx.cfg, + accountId: ctx.accountId ?? undefined, + deps: ctx.deps, + replyToId: ctx.replyToId, + threadId: ctx.threadId, + }); + const { slackOutbound } = await loadSlackOutboundAdapterModule(); + return await slackOutbound.sendPayload!({ + ...ctx, + replyToId: threadTsValue, + threadId: null, + deps: { + ...ctx.deps, + slack: async ( + to: Parameters[0], + text: Parameters[1], + opts: Parameters[2], + ) => + await send(to, text, { + ...opts, + ...(tokenOverride ? { token: tokenOverride } : {}), + }), + }, + }); + }, + ...createAttachedChannelResultAdapter({ + channel: "slack", + sendText: async ({ to, text, accountId, deps, replyToId, threadId, cfg }) => { + const { send, threadTsValue, tokenOverride } = await resolveSlackSendContext({ + cfg, + accountId: accountId ?? undefined, + deps, + replyToId, + threadId, + }); + return await send(to, text, { + cfg, + threadTs: threadTsValue, + accountId: accountId ?? undefined, + ...(tokenOverride ? { token: tokenOverride } : {}), + }); + }, + sendMedia: async ({ + to, + text, + mediaUrl, + mediaLocalRoots, + accountId, + deps, + replyToId, + threadId, + cfg, + }) => { + const { send, threadTsValue, tokenOverride } = await resolveSlackSendContext({ + cfg, + accountId: accountId ?? undefined, + deps, + replyToId, + threadId, + }); + return await send(to, text, { + cfg, + mediaUrl, + mediaLocalRoots, + threadTs: threadTsValue, + accountId: accountId ?? undefined, + ...(tokenOverride ? { token: tokenOverride } : {}), + }); + }, + }), +}; + +const slackMessageAdapter = createChannelMessageAdapterFromOutbound({ + id: "slack", + outbound: slackChannelOutbound, + live: { + capabilities: { + draftPreview: true, + previewFinalization: true, + progressUpdates: true, + nativeStreaming: true, + }, + finalizer: { + capabilities: { + finalEdit: true, + normalFallback: true, + discardPending: true, + }, + }, + }, +}); + export const slackPlugin: ChannelPlugin = createChatChannelPlugin< ResolvedSlackAccount, SlackProbe @@ -490,6 +610,7 @@ export const slackPlugin: ChannelPlugin = crea await resolveSlackHandleAction() )(action, cfg as OpenClawConfig, toolContext as SlackActionContext | undefined), }), + message: slackMessageAdapter, status: createComputedAccountStatusAdapter({ defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID), buildChannelSummary: async ({ snapshot }) => { @@ -626,90 +747,5 @@ export const slackPlugin: ChannelPlugin = crea threadId: null, }), }, - outbound: { - base: { - deliveryMode: "direct", - chunker: null, - textChunkLimit: SLACK_TEXT_LIMIT, - shouldTreatDeliveredTextAsVisible: shouldTreatSlackDeliveredTextAsVisible, - shouldSuppressLocalPayloadPrompt: ({ cfg, accountId, payload }) => - shouldSuppressLocalSlackExecApprovalPrompt({ - cfg, - accountId, - payload, - }), - sendPayload: async (ctx) => { - const { send, threadTsValue, tokenOverride } = await resolveSlackSendContext({ - cfg: ctx.cfg, - accountId: ctx.accountId ?? undefined, - deps: ctx.deps, - replyToId: ctx.replyToId, - threadId: ctx.threadId, - }); - const { slackOutbound } = await loadSlackOutboundAdapterModule(); - return await slackOutbound.sendPayload!({ - ...ctx, - replyToId: threadTsValue, - threadId: null, - deps: { - ...ctx.deps, - slack: async ( - to: Parameters[0], - text: Parameters[1], - opts: Parameters[2], - ) => - await send(to, text, { - ...opts, - ...(tokenOverride ? { token: tokenOverride } : {}), - }), - }, - }); - }, - }, - attachedResults: { - channel: "slack", - sendText: async ({ to, text, accountId, deps, replyToId, threadId, cfg }) => { - const { send, threadTsValue, tokenOverride } = await resolveSlackSendContext({ - cfg, - accountId: accountId ?? undefined, - deps, - replyToId, - threadId, - }); - return await send(to, text, { - cfg, - threadTs: threadTsValue, - accountId: accountId ?? undefined, - ...(tokenOverride ? { token: tokenOverride } : {}), - }); - }, - sendMedia: async ({ - to, - text, - mediaUrl, - mediaLocalRoots, - accountId, - deps, - replyToId, - threadId, - cfg, - }) => { - const { send, threadTsValue, tokenOverride } = await resolveSlackSendContext({ - cfg, - accountId: accountId ?? undefined, - deps, - replyToId, - threadId, - }); - return await send(to, text, { - cfg, - mediaUrl, - mediaLocalRoots, - threadTs: threadTsValue, - accountId: accountId ?? undefined, - ...(tokenOverride ? { token: tokenOverride } : {}), - }); - }, - }, - }, + outbound: slackChannelOutbound, }); diff --git a/extensions/slack/src/draft-stream.test.ts b/extensions/slack/src/draft-stream.test.ts index 8186f15a7f5..643dfc50010 100644 --- a/extensions/slack/src/draft-stream.test.ts +++ b/extensions/slack/src/draft-stream.test.ts @@ -1,3 +1,4 @@ +import { createMessageReceiptFromOutboundResults } from "openclaw/plugin-sdk/channel-message"; import { describe, expect, it, vi } from "vitest"; import { createSlackDraftStream } from "./draft-stream.js"; @@ -9,6 +10,17 @@ type DraftWarnFn = NonNullable; const TEST_CFG = {}; +function slackDraftSendResult(messageId: string, channelId = "C123") { + return { + channelId, + messageId, + receipt: createMessageReceiptFromOutboundResults({ + results: [{ channel: "slack", messageId, channelId }], + kind: "preview", + }), + }; +} + function createDraftStreamHarness( params: { maxChars?: number; @@ -18,12 +30,7 @@ function createDraftStreamHarness( warn?: DraftWarnFn; } = {}, ) { - const send = - params.send ?? - vi.fn(async () => ({ - channelId: "C123", - messageId: "111.222", - })); + const send = params.send ?? vi.fn(async () => slackDraftSendResult("111.222")); const edit = params.edit ?? vi.fn(async () => {}); const remove = params.remove ?? vi.fn(async () => {}); const warn = params.warn ?? vi.fn(); @@ -96,8 +103,8 @@ describe("createSlackDraftStream", () => { it("supports forceNewMessage for subsequent assistant messages", async () => { const send = vi .fn() - .mockResolvedValueOnce({ channelId: "C123", messageId: "111.222" }) - .mockResolvedValueOnce({ channelId: "C123", messageId: "333.444" }); + .mockResolvedValueOnce(slackDraftSendResult("111.222")) + .mockResolvedValueOnce(slackDraftSendResult("333.444")); const { stream, edit } = createDraftStreamHarness({ send }); stream.update("first"); diff --git a/extensions/slack/src/monitor/message-handler/dispatch.preview-fallback.test.ts b/extensions/slack/src/monitor/message-handler/dispatch.preview-fallback.test.ts index 4e07093cda9..d4a2ff8e5f2 100644 --- a/extensions/slack/src/monitor/message-handler/dispatch.preview-fallback.test.ts +++ b/extensions/slack/src/monitor/message-handler/dispatch.preview-fallback.test.ts @@ -208,47 +208,51 @@ vi.mock("../conversation.runtime.js", () => ({ recordInboundSession: vi.fn(async () => undefined), })); -vi.mock("openclaw/plugin-sdk/channel-reply-pipeline", () => ({ - createChannelReplyPipeline: (params: { - typing?: { - start: () => Promise; - stop?: () => Promise; - onStartError: (err: unknown) => void; - onStopError?: (err: unknown) => void; - }; - }) => { - capturedTyping = params.typing; - return { - ...(params.typing - ? { - typingCallbacks: { - onReplyStart: params.typing.start, - onIdle: () => { - void params.typing?.stop?.(); +vi.mock("openclaw/plugin-sdk/channel-message", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createChannelMessageReplyPipeline: (params: { + typing?: { + start: () => Promise; + stop?: () => Promise; + onStartError: (err: unknown) => void; + onStopError?: (err: unknown) => void; + }; + }) => { + capturedTyping = params.typing; + return { + ...(params.typing + ? { + typingCallbacks: { + onReplyStart: params.typing.start, + onIdle: () => { + void params.typing?.stop?.(); + }, }, - }, - } - : {}), - onModelSelected: undefined, - }; - }, - resolveChannelSourceReplyDeliveryMode: (params: { - cfg?: { messages?: { groupChat?: { visibleReplies?: string } } }; - ctx?: { ChatType?: string }; - requested?: "automatic" | "message_tool_only"; - }) => { - if (params.requested) { - return params.requested; - } - const chatType = params.ctx?.ChatType; - if (chatType === "group" || chatType === "channel") { - return params.cfg?.messages?.groupChat?.visibleReplies === "automatic" - ? "automatic" - : "message_tool_only"; - } - return "automatic"; - }, -})); + } + : {}), + onModelSelected: undefined, + }; + }, + resolveChannelMessageSourceReplyDeliveryMode: (params: { + cfg?: { messages?: { groupChat?: { visibleReplies?: string } } }; + ctx?: { ChatType?: string }; + requested?: "automatic" | "message_tool_only"; + }) => { + if (params.requested) { + return params.requested; + } + const chatType = params.ctx?.ChatType; + if (chatType === "group" || chatType === "channel") { + return params.cfg?.messages?.groupChat?.visibleReplies === "automatic" + ? "automatic" + : "message_tool_only"; + } + return "automatic"; + }, + }; +}); vi.mock("openclaw/plugin-sdk/channel-streaming", () => ({ buildChannelProgressDraftLine: (params: { diff --git a/extensions/slack/src/monitor/message-handler/dispatch.ts b/extensions/slack/src/monitor/message-handler/dispatch.ts index ac1894b9dd0..9f6c757659e 100644 --- a/extensions/slack/src/monitor/message-handler/dispatch.ts +++ b/extensions/slack/src/monitor/message-handler/dispatch.ts @@ -7,11 +7,12 @@ import { removeAckReactionAfterReply, type StatusReactionAdapter, } from "openclaw/plugin-sdk/channel-feedback"; -import { deliverFinalizableDraftPreview } from "openclaw/plugin-sdk/channel-lifecycle"; import { - createChannelReplyPipeline, - resolveChannelSourceReplyDeliveryMode, -} from "openclaw/plugin-sdk/channel-reply-pipeline"; + createChannelMessageReplyPipeline, + defineFinalizableLivePreviewAdapter, + deliverWithFinalizableLivePreviewAdapter, + resolveChannelMessageSourceReplyDeliveryMode, +} from "openclaw/plugin-sdk/channel-message"; import { buildChannelProgressDraftLine, buildChannelProgressDraftLineForEntry, @@ -294,7 +295,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag message, replyToMode: prepared.replyToMode, }); - const sourceReplyDeliveryMode = resolveChannelSourceReplyDeliveryMode({ + const sourceReplyDeliveryMode = resolveChannelMessageSourceReplyDeliveryMode({ cfg, ctx: prepared.ctxPayload, }); @@ -369,7 +370,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag const typingTarget = statusThreadTs ? `${message.channel}/${statusThreadTs}` : message.channel; const typingReaction = ctx.typingReaction; - const { onModelSelected, ...replyPipeline } = createChannelReplyPipeline({ + const { onModelSelected, ...replyPipeline } = createChannelMessageReplyPipeline({ cfg, agentId: route.agentId, channel: "slack", @@ -783,72 +784,76 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag const slackBlocks = readSlackReplyBlocks(payload); const trimmedFinalText = reply.trimmedText; - const result = await deliverFinalizableDraftPreview({ + const result = await deliverWithFinalizableLivePreviewAdapter({ kind: info.kind, payload, - draft: draftStream - ? { - flush: draftStream.flush, - clear: draftStream.clear, - discardPending: draftStream.discardPending, - seal: draftStream.seal, - id: () => { - const channelId = draftStream.channelId(); - const messageId = draftStream.messageId(); - return channelId && messageId ? { channelId, messageId } : undefined; - }, + adapter: defineFinalizableLivePreviewAdapter({ + draft: draftStream + ? { + flush: draftStream.flush, + clear: draftStream.clear, + discardPending: draftStream.discardPending, + seal: draftStream.seal, + id: () => { + const channelId = draftStream.channelId(); + const messageId = draftStream.messageId(); + return channelId && messageId ? { channelId, messageId } : undefined; + }, + } + : undefined, + buildFinalEdit: () => { + if ( + !previewStreamingEnabled || + reply.hasMedia || + payload.isError || + (trimmedFinalText.length === 0 && !slackBlocks?.length) + ) { + return undefined; } - : undefined, - buildFinalEdit: () => { - if ( - !previewStreamingEnabled || - reply.hasMedia || - payload.isError || - (trimmedFinalText.length === 0 && !slackBlocks?.length) - ) { - return undefined; - } - return { - text: normalizeSlackOutboundText(trimmedFinalText), - blocks: slackBlocks, - threadTs: usedReplyThreadTs ?? statusThreadTs, - }; - }, - editFinal: async (preview, edit) => { - if (deliveryTracker.hasDelivered({ kind: info.kind, payload, threadTs: edit.threadTs })) { - return; - } - await finalizeSlackPreviewEdit({ - client: ctx.app.client, - token: ctx.botToken, - accountId: account.accountId, - channelId: preview.channelId, - messageId: preview.messageId, - text: edit.text, - ...(edit.blocks?.length ? { blocks: edit.blocks } : {}), - threadTs: edit.threadTs, - }); - }, + return { + text: normalizeSlackOutboundText(trimmedFinalText), + blocks: slackBlocks, + threadTs: usedReplyThreadTs ?? statusThreadTs, + }; + }, + editFinal: async (preview, edit) => { + if ( + deliveryTracker.hasDelivered({ kind: info.kind, payload, threadTs: edit.threadTs }) + ) { + return; + } + await finalizeSlackPreviewEdit({ + client: ctx.app.client, + token: ctx.botToken, + accountId: account.accountId, + channelId: preview.channelId, + messageId: preview.messageId, + text: edit.text, + ...(edit.blocks?.length ? { blocks: edit.blocks } : {}), + threadTs: edit.threadTs, + }); + }, + onPreviewFinalized: (_preview) => { + const finalThreadTs = usedReplyThreadTs ?? statusThreadTs; + observedReplyDelivery = true; + replyPlan.markSent(); + deliveryTracker.markDelivered({ kind: info.kind, payload, threadTs: finalThreadTs }); + }, + logPreviewEditFailure: (err) => { + logVerbose( + `slack: preview final edit failed; falling back to standard send (${formatErrorMessage(err)})`, + ); + }, + }), deliverNormally: async () => { await deliverNormally({ payload, kind: info.kind, }); }, - onPreviewFinalized: (_preview) => { - const finalThreadTs = usedReplyThreadTs ?? statusThreadTs; - observedReplyDelivery = true; - replyPlan.markSent(); - deliveryTracker.markDelivered({ kind: info.kind, payload, threadTs: finalThreadTs }); - }, - logPreviewEditFailure: (err) => { - logVerbose( - `slack: preview final edit failed; falling back to standard send (${formatErrorMessage(err)})`, - ); - }, }); - if (result === "preview-finalized") { + if (result.kind === "preview-finalized") { return; } }, diff --git a/extensions/slack/src/monitor/message-handler/prepare.ts b/extensions/slack/src/monitor/message-handler/prepare.ts index e9239aac268..42c82bbaafa 100644 --- a/extensions/slack/src/monitor/message-handler/prepare.ts +++ b/extensions/slack/src/monitor/message-handler/prepare.ts @@ -12,7 +12,7 @@ import { resolveEnvelopeFormatOptions, resolveInboundMentionDecision, } from "openclaw/plugin-sdk/channel-inbound"; -import { resolveChannelSourceReplyDeliveryMode } from "openclaw/plugin-sdk/channel-reply-pipeline"; +import { resolveChannelMessageSourceReplyDeliveryMode } from "openclaw/plugin-sdk/channel-message"; import { hasControlCommand } from "openclaw/plugin-sdk/command-detection"; import { resolveControlCommandGate } from "openclaw/plugin-sdk/command-gating"; import { shouldHandleTextCommands } from "openclaw/plugin-sdk/command-surface"; @@ -561,7 +561,7 @@ export async function prepareSlackMessage(params: { }); const ackReactionValue = ackReaction ?? ""; const sourceRepliesAreToolOnly = - resolveChannelSourceReplyDeliveryMode({ cfg, ctx: { ChatType: chatType } }) === + resolveChannelMessageSourceReplyDeliveryMode({ cfg, ctx: { ChatType: chatType } }) === "message_tool_only"; const statusReactionsExplicitlyEnabled = cfg.messages?.statusReactions?.enabled === true; const shouldAckReaction = () => diff --git a/extensions/slack/src/monitor/slash.ts b/extensions/slack/src/monitor/slash.ts index d99ce8d62f3..4b757883491 100644 --- a/extensions/slack/src/monitor/slash.ts +++ b/extensions/slack/src/monitor/slash.ts @@ -1,6 +1,6 @@ import type { SlackActionMiddlewareArgs, SlackCommandMiddlewareArgs } from "@slack/bolt"; import { resolveDefaultModelForAgent } from "openclaw/plugin-sdk/agent-runtime"; -import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline"; +import { createChannelMessageReplyPipeline } from "openclaw/plugin-sdk/channel-message"; import { formatCommandArgMenuTitle, resolveStoredModelOverride, @@ -728,7 +728,7 @@ export async function registerSlackMonitorSlashCommands(params: { ), }); - const { onModelSelected, ...replyPipeline } = createChannelReplyPipeline({ + const { onModelSelected, ...replyPipeline } = createChannelMessageReplyPipeline({ cfg, agentId: route.agentId, channel: "slack", diff --git a/extensions/slack/src/send.blocks.test.ts b/extensions/slack/src/send.blocks.test.ts index 6ecb1520014..2191027ed7d 100644 --- a/extensions/slack/src/send.blocks.test.ts +++ b/extensions/slack/src/send.blocks.test.ts @@ -32,6 +32,7 @@ describe("sendMessageSlack NO_REPLY guard", () => { expect(client.chat.postMessage).not.toHaveBeenCalled(); expect(result.messageId).toBe("suppressed"); + expect(result.receipt.platformMessageIds).toEqual([]); }); it("suppresses NO_REPLY with surrounding whitespace", async () => { @@ -170,7 +171,18 @@ describe("sendMessageSlack blocks", () => { blocks: [{ type: "divider" }], }), ); - expect(result).toEqual({ messageId: "171234.567", channelId: "C123" }); + expect(result).toMatchObject({ messageId: "171234.567", channelId: "C123" }); + expect(result.receipt).toMatchObject({ + primaryPlatformMessageId: "171234.567", + platformMessageIds: ["171234.567"], + parts: [ + expect.objectContaining({ + platformMessageId: "171234.567", + kind: "card", + raw: expect.objectContaining({ channel: "slack", channelId: "C123" }), + }), + ], + }); }); it("posts user-target block messages directly without conversations.open", async () => { @@ -191,7 +203,8 @@ describe("sendMessageSlack blocks", () => { text: "Shared a Block Kit message", }), ); - expect(result).toEqual({ messageId: "171234.567", channelId: "U123" }); + expect(result).toMatchObject({ messageId: "171234.567", channelId: "U123" }); + expect(result.receipt.platformMessageIds).toEqual(["171234.567"]); }); it("retries Slack postMessage DNS request errors without enabling broad write retries", async () => { @@ -207,7 +220,13 @@ describe("sendMessageSlack blocks", () => { }); expect(client.chat.postMessage).toHaveBeenCalledTimes(2); - expect(result).toEqual({ messageId: "171234.999", channelId: "C123" }); + expect(result).toMatchObject({ messageId: "171234.999", channelId: "C123" }); + expect(result.receipt.parts[0]).toEqual( + expect.objectContaining({ + platformMessageId: "171234.999", + kind: "text", + }), + ); }); it("retries Slack conversations.open DNS request errors for threaded DMs", async () => { @@ -227,7 +246,8 @@ describe("sendMessageSlack blocks", () => { expect(client.chat.postMessage).toHaveBeenCalledWith( expect.objectContaining({ channel: "D123", thread_ts: "171234.100" }), ); - expect(result).toEqual({ messageId: "171234.567", channelId: "D123" }); + expect(result).toMatchObject({ messageId: "171234.567", channelId: "D123" }); + expect(result.receipt.threadId).toBe("171234.100"); }); it("does not retry Slack platform errors", async () => { diff --git a/extensions/slack/src/send.ts b/extensions/slack/src/send.ts index ceb145e225b..27981005932 100644 --- a/extensions/slack/src/send.ts +++ b/extensions/slack/src/send.ts @@ -1,4 +1,10 @@ import { type Block, type KnownBlock, type WebClient } from "@slack/web-api"; +import { + createMessageReceiptFromOutboundResults, + type MessageReceipt, + type MessageReceiptPartKind, + type MessageReceiptSourceResult, +} from "openclaw/plugin-sdk/channel-message"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; import { withTrustedEnvProxyGuardedFetchMode } from "openclaw/plugin-sdk/fetch-runtime"; import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime"; @@ -284,8 +290,34 @@ async function postSlackMessageBestEffort(params: { export type SlackSendResult = { messageId: string; channelId: string; + receipt: MessageReceipt; }; +function createSlackSendReceipt(params: { + platformMessageIds: readonly string[]; + channelId?: string; + kind: MessageReceiptPartKind; + threadTs?: string; +}): MessageReceipt { + const platformMessageIds = params.platformMessageIds + .map((messageId) => messageId.trim()) + .filter((messageId) => messageId && messageId !== "unknown" && messageId !== "suppressed"); + return createMessageReceiptFromOutboundResults({ + results: platformMessageIds.map((messageId) => { + const result: MessageReceiptSourceResult = { + channel: "slack", + messageId, + }; + if (params.channelId) { + result.channelId = params.channelId; + } + return result; + }), + kind: params.kind, + threadId: params.threadTs, + }); +} + function resolveToken(params: { explicit?: string; accountId: string; @@ -513,7 +545,11 @@ export async function sendMessageSlack( const trimmedMessage = normalizeOptionalString(message) ?? ""; if (isSilentReplyText(trimmedMessage) && !opts.mediaUrl && !opts.blocks) { logVerbose("slack send: suppressed NO_REPLY token before API call"); - return { messageId: "suppressed", channelId: "" }; + return { + messageId: "suppressed", + channelId: "", + receipt: createSlackSendReceipt({ platformMessageIds: [], kind: "unknown" }), + }; } const blocks = opts.blocks == null ? undefined : validateSlackBlocksArray(opts.blocks); if (!trimmedMessage && !opts.mediaUrl && !blocks) { @@ -609,9 +645,16 @@ async function sendMessageSlackQueuedInner(params: { identity: opts.identity, blocks, }); + const messageId = response.ts ?? "unknown"; return { - messageId: response.ts ?? "unknown", + messageId, channelId, + receipt: createSlackSendReceipt({ + platformMessageIds: [messageId], + channelId, + kind: "card", + threadTs: opts.threadTs, + }), }; } const textLimit = resolveTextChunkLimit(cfg, "slack", account.accountId, { @@ -637,6 +680,7 @@ async function sendMessageSlackQueuedInner(params: { ? account.config.mediaMaxMb * 1024 * 1024 : undefined; + const sentMessageIds: string[] = []; let lastMessageId = ""; if (opts.mediaUrl) { const [firstChunk, ...rest] = resolvedChunks; @@ -653,6 +697,7 @@ async function sendMessageSlackQueuedInner(params: { threadTs: opts.threadTs, maxBytes: mediaMaxBytes, }); + sentMessageIds.push(lastMessageId); for (const chunk of rest) { const response = await postSlackMessageBestEffort({ client, @@ -662,6 +707,9 @@ async function sendMessageSlackQueuedInner(params: { identity: opts.identity, }); lastMessageId = response.ts ?? lastMessageId; + if (response.ts) { + sentMessageIds.push(response.ts); + } } } else { for (const chunk of resolvedChunks.length ? resolvedChunks : [""]) { @@ -673,11 +721,21 @@ async function sendMessageSlackQueuedInner(params: { identity: opts.identity, }); lastMessageId = response.ts ?? lastMessageId; + if (response.ts) { + sentMessageIds.push(response.ts); + } } } + const messageId = lastMessageId || "unknown"; return { - messageId: lastMessageId || "unknown", + messageId, channelId, + receipt: createSlackSendReceipt({ + platformMessageIds: sentMessageIds.length ? sentMessageIds : [messageId], + channelId, + kind: opts.mediaUrl ? "media" : "text", + threadTs: opts.threadTs, + }), }; } diff --git a/extensions/slack/src/send.upload.test.ts b/extensions/slack/src/send.upload.test.ts index ddbcb2d05fc..58a96067101 100644 --- a/extensions/slack/src/send.upload.test.ts +++ b/extensions/slack/src/send.upload.test.ts @@ -206,8 +206,22 @@ describe("sendMessageSlack file upload with user IDs", () => { expect(client.chat.postMessage).toHaveBeenCalledTimes(1); resolveFirst(); - await expect(first).resolves.toEqual({ channelId: "C123CHAN", messageId: "1.000" }); - await expect(second).resolves.toEqual({ channelId: "C123CHAN", messageId: "2.000" }); + await expect(first).resolves.toMatchObject({ + channelId: "C123CHAN", + messageId: "1.000", + receipt: expect.objectContaining({ + primaryPlatformMessageId: "1.000", + platformMessageIds: ["1.000"], + }), + }); + await expect(second).resolves.toMatchObject({ + channelId: "C123CHAN", + messageId: "2.000", + receipt: expect.objectContaining({ + primaryPlatformMessageId: "2.000", + platformMessageIds: ["2.000"], + }), + }); expect(client.chat.postMessage).toHaveBeenNthCalledWith( 2, expect.objectContaining({ text: "second" }), @@ -236,7 +250,7 @@ describe("sendMessageSlack file upload with user IDs", () => { it("sends file directly to channel without conversations.open", async () => { const client = createUploadTestClient(); - await sendMessageSlack("channel:C123CHAN", "chart", { + const result = await sendMessageSlack("channel:C123CHAN", "chart", { token: "xoxb-test", cfg: SLACK_TEST_CFG, client, @@ -247,6 +261,17 @@ describe("sendMessageSlack file upload with user IDs", () => { expect(client.files.completeUploadExternal).toHaveBeenCalledWith( expect.objectContaining({ channel_id: "C123CHAN" }), ); + expect(result.receipt).toMatchObject({ + primaryPlatformMessageId: "F001", + platformMessageIds: ["F001"], + parts: [ + expect.objectContaining({ + platformMessageId: "F001", + kind: "media", + raw: expect.objectContaining({ channel: "slack", channelId: "C123CHAN" }), + }), + ], + }); }); it("resolves mention-style user ID before file upload", async () => { @@ -270,7 +295,7 @@ describe("sendMessageSlack file upload with user IDs", () => { it("uploads bytes to the presigned URL and completes with thread+caption", async () => { const client = createUploadTestClient(); - await sendMessageSlack("channel:C123CHAN", "caption", { + const result = await sendMessageSlack("channel:C123CHAN", "caption", { token: "xoxb-test", cfg: SLACK_TEST_CFG, client, @@ -303,6 +328,7 @@ describe("sendMessageSlack file upload with user IDs", () => { }), ); expect(hasSlackThreadParticipation("default", "C123CHAN", "171.222")).toBe(true); + expect(result.receipt.threadId).toBe("171.222"); }); it("uses explicit upload filename and title overrides when provided", async () => { diff --git a/extensions/synology-chat/src/channel.test.ts b/extensions/synology-chat/src/channel.test.ts index f87e89a7966..909e3e40187 100644 --- a/extensions/synology-chat/src/channel.test.ts +++ b/extensions/synology-chat/src/channel.test.ts @@ -1,3 +1,4 @@ +import { verifyChannelMessageAdapterCapabilityProofs } from "openclaw/plugin-sdk/channel-message"; import { createPluginSetupWizardStatus } from "openclaw/plugin-sdk/plugin-test-runtime"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { ResolvedSynologyChatAccount } from "./types.js"; @@ -28,6 +29,7 @@ function makeSecurityAccount( const clientModule = await import("./client.js"); const gatewayRuntimeModule = await import("./gateway-runtime.js"); const mockSendMessage = vi.spyOn(clientModule, "sendMessage").mockResolvedValue(true); +const mockSendFileUrl = vi.spyOn(clientModule, "sendFileUrl").mockResolvedValue(true); const registerSynologyWebhookRouteMock = vi .spyOn(gatewayRuntimeModule, "registerSynologyWebhookRoute") .mockImplementation(() => vi.fn()); @@ -44,8 +46,10 @@ describe("createSynologyChatPlugin", () => { vi.stubEnv("SYNOLOGY_CHAT_TOKEN", ""); vi.stubEnv("SYNOLOGY_CHAT_INCOMING_URL", ""); mockSendMessage.mockClear(); + mockSendFileUrl.mockClear(); registerSynologyWebhookRouteMock.mockClear(); mockSendMessage.mockResolvedValue(true); + mockSendFileUrl.mockResolvedValue(true); registerSynologyWebhookRouteMock.mockImplementation(() => vi.fn()); }); @@ -383,6 +387,57 @@ describe("createSynologyChatPlugin", () => { }); describe("outbound", () => { + it("declares message adapter durable text and media with receipt proofs", async () => { + const plugin = createSynologyChatPlugin(); + const cfg = { + channels: { + "synology-chat": { + enabled: true, + token: "t", + incomingUrl: "https://nas/incoming", + allowInsecureSsl: true, + }, + }, + }; + + await expect( + verifyChannelMessageAdapterCapabilityProofs({ + adapterName: "synology-chat", + adapter: plugin.message, + proofs: { + text: async () => { + const result = await plugin.message.send?.text?.({ + cfg, + text: "hello", + to: "user1", + }); + expect(result?.receipt.parts[0]?.kind).toBe("text"); + expect(result?.receipt.platformMessageIds).toHaveLength(1); + }, + media: async () => { + const result = await plugin.message.send?.media?.({ + cfg, + text: "image", + mediaUrl: "https://example.com/img.png", + to: "user1", + }); + expect(result?.receipt.parts[0]?.kind).toBe("media"); + expect(result?.receipt.platformMessageIds).toHaveLength(1); + }, + messageSendingHooks: () => { + expect(plugin.message.durableFinal?.capabilities?.messageSendingHooks).toBe(true); + }, + }, + }), + ).resolves.toEqual( + expect.arrayContaining([ + { capability: "text", status: "verified" }, + { capability: "media", status: "verified" }, + { capability: "messageSendingHooks", status: "verified" }, + ]), + ); + }); + it("sendText throws when no incomingUrl", async () => { const plugin = createSynologyChatPlugin(); await expect( @@ -419,6 +474,8 @@ describe("createSynologyChatPlugin", () => { chatId: "user1", }); expect(result.messageId).toMatch(/^sc-\d+$/); + expect(result.receipt.primaryPlatformMessageId).toBe(result.messageId); + expect(result.receipt.parts[0]?.kind).toBe("text"); }); it("sendMedia throws when missing incomingUrl", async () => { diff --git a/extensions/synology-chat/src/channel.ts b/extensions/synology-chat/src/channel.ts index d979b5d21cc..3402a1867dc 100644 --- a/extensions/synology-chat/src/channel.ts +++ b/extensions/synology-chat/src/channel.ts @@ -12,13 +12,18 @@ import { } from "openclaw/plugin-sdk/channel-config-helpers"; import { createChatChannelPlugin, type ChannelPlugin } from "openclaw/plugin-sdk/channel-core"; import { waitUntilAbort } from "openclaw/plugin-sdk/channel-lifecycle"; +import { + createMessageReceiptFromOutboundResults, + defineChannelMessageAdapter, + type MessageReceipt, + type MessageReceiptPartKind, +} from "openclaw/plugin-sdk/channel-message"; import { composeWarningCollectors, createConditionalWarningCollector, projectAccountConfigWarningCollector, projectAccountWarningCollector, } from "openclaw/plugin-sdk/channel-policy"; -import { attachChannelToResult } from "openclaw/plugin-sdk/channel-send-result"; import { createEmptyChannelDirectoryAdapter } from "openclaw/plugin-sdk/directory-runtime"; import { listAccountIds, resolveAccount } from "./accounts.js"; import { synologyChatApprovalAuth } from "./approval-auth.js"; @@ -67,7 +72,7 @@ type SynologyChannelOutboundContext = { accountId?: string | null; }; type SynologyChannelSendTextContext = SynologyChannelOutboundContext & { text: string }; -type _SynologyChannelSendMediaContext = SynologyChannelOutboundContext & { mediaUrl: string }; +type SynologyChannelSendMediaContext = SynologyChannelOutboundContext & { mediaUrl: string }; type SynologySecurityWarningContext = { cfg: OpenClawConfig; account: ResolvedSynologyChatAccount; @@ -132,6 +137,7 @@ type SynologyChatOutboundResult = { channel: typeof CHANNEL_ID; messageId: string; chatId: string; + receipt: MessageReceipt; }; type SynologyChatPlugin = Omit< @@ -171,8 +177,9 @@ type SynologyChatPlugin = Omit< deliveryMode: "gateway"; textChunkLimit: number; sendText: (ctx: SynologyChannelSendTextContext) => Promise; - sendMedia: (ctx: SynologyChannelOutboundContext) => Promise; + sendMedia: (ctx: SynologyChannelSendMediaContext) => Promise; }; + message: typeof synologyChatMessageAdapter; gateway: { startAccount: (ctx: SynologyChannelGatewayContext) => Promise; stopAccount: (ctx: SynologyChannelGatewayContext) => Promise; @@ -205,6 +212,77 @@ function requireIncomingUrl(account: ResolvedSynologyChatAccount): string { return account.incomingUrl; } +function createSynologyChatSendResult(params: { + messageId: string; + chatId: string; + kind: MessageReceiptPartKind; +}): SynologyChatOutboundResult { + return { + channel: CHANNEL_ID, + messageId: params.messageId, + chatId: params.chatId, + receipt: createMessageReceiptFromOutboundResults({ + results: [ + { + channel: CHANNEL_ID, + messageId: params.messageId, + chatId: params.chatId, + conversationId: params.chatId, + }, + ], + threadId: params.chatId, + kind: params.kind, + }), + }; +} + +async function sendSynologyChatText( + ctx: SynologyChannelSendTextContext, +): Promise { + const account = resolveOutboundAccount(ctx.cfg ?? {}, ctx.accountId); + const incomingUrl = requireIncomingUrl(account); + const ok = await sendMessage(incomingUrl, ctx.text, ctx.to, account.allowInsecureSsl); + if (!ok) { + throw new Error("Failed to send message to Synology Chat"); + } + return createSynologyChatSendResult({ + messageId: `sc-${Date.now()}`, + chatId: ctx.to, + kind: "text", + }); +} + +async function sendSynologyChatMedia( + ctx: SynologyChannelSendMediaContext, +): Promise { + const account = resolveOutboundAccount(ctx.cfg ?? {}, ctx.accountId); + const incomingUrl = requireIncomingUrl(account); + const ok = await sendFileUrl(incomingUrl, ctx.mediaUrl, ctx.to, account.allowInsecureSsl); + if (!ok) { + throw new Error("Failed to send media to Synology Chat"); + } + return createSynologyChatSendResult({ + messageId: `sc-${Date.now()}`, + chatId: ctx.to, + kind: "media", + }); +} + +export const synologyChatMessageAdapter = defineChannelMessageAdapter({ + id: CHANNEL_ID, + durableFinal: { + capabilities: { + text: true, + media: true, + messageSendingHooks: true, + }, + }, + send: { + text: async (ctx) => await sendSynologyChatText(ctx), + media: async (ctx) => await sendSynologyChatMedia(ctx), + }, +}); + export function createSynologyChatPlugin(): SynologyChatPlugin { return createChatChannelPlugin({ base: { @@ -313,6 +391,7 @@ export function createSynologyChatPlugin(): SynologyChatPlugin { "- Wrap URLs with `` for user-friendly links", ], }, + message: synologyChatMessageAdapter, }, pairing: { text: { @@ -342,28 +421,15 @@ export function createSynologyChatPlugin(): SynologyChatPlugin { deliveryMode: "gateway" as const, textChunkLimit: 2000, - sendText: async ({ to, text, accountId, cfg }: SynologyChannelSendTextContext) => { - const account = resolveOutboundAccount(cfg ?? {}, accountId); - const incomingUrl = requireIncomingUrl(account); - const ok = await sendMessage(incomingUrl, text, to, account.allowInsecureSsl); - if (!ok) { - throw new Error("Failed to send message to Synology Chat"); + sendText: sendSynologyChatText, + sendMedia: async (ctx) => { + if (!ctx.mediaUrl) { + throw new Error("Synology Chat media send requires mediaUrl"); } - return attachChannelToResult(CHANNEL_ID, { messageId: `sc-${Date.now()}`, chatId: to }); - }, - - sendMedia: async ({ to, mediaUrl, accountId, cfg }: SynologyChannelOutboundContext) => { - const account = resolveOutboundAccount(cfg ?? {}, accountId); - const incomingUrl = requireIncomingUrl(account); - if (!mediaUrl) { - throw new Error("No media URL provided"); - } - - const ok = await sendFileUrl(incomingUrl, mediaUrl, to, account.allowInsecureSsl); - if (!ok) { - throw new Error("Failed to send media to Synology Chat"); - } - return attachChannelToResult(CHANNEL_ID, { messageId: `sc-${Date.now()}`, chatId: to }); + return await sendSynologyChatMedia({ + ...ctx, + mediaUrl: ctx.mediaUrl, + }); }, }, }) as unknown as SynologyChatPlugin; diff --git a/extensions/synology-chat/src/inbound-turn.ts b/extensions/synology-chat/src/inbound-turn.ts index 7f8921547f4..fdd6905c7ef 100644 --- a/extensions/synology-chat/src/inbound-turn.ts +++ b/extensions/synology-chat/src/inbound-turn.ts @@ -42,17 +42,18 @@ async function deliverSynologyChatReply(params: { account: ResolvedSynologyChatAccount; sendUserId: string; payload: { text?: string; body?: string }; -}): Promise { +}): Promise<{ visibleReplySent: boolean }> { const text = params.payload.text ?? params.payload.body; if (!text) { - return; + return { visibleReplySent: false }; } - await sendMessage( + const ok = await sendMessage( params.account.incomingUrl, text, params.sendUserId, params.account.allowInsecureSsl, ); + return { visibleReplySent: ok }; } export async function dispatchSynologyChatInboundTurn(params: { @@ -144,8 +145,11 @@ export async function dispatchSynologyChatInboundTurn(params: { dispatchReplyWithBufferedBlockDispatcher: resolved.rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher, delivery: { + durable: () => ({ + to: sendUserId, + }), deliver: async (payload) => { - await deliverSynologyChatReply({ + return await deliverSynologyChatReply({ account: params.account, sendUserId, payload, diff --git a/extensions/telegram/src/bot-core.ts b/extensions/telegram/src/bot-core.ts index 06701855ee3..196aa52affa 100644 --- a/extensions/telegram/src/bot-core.ts +++ b/extensions/telegram/src/bot-core.ts @@ -368,6 +368,7 @@ export function createTelegramBotCore( }; const updateTracker = createTelegramUpdateTracker({ initialUpdateId, + ackPolicy: "after_agent_dispatch", ...(typeof opts.updateOffset?.onUpdateId === "function" ? { onAcceptedUpdateId: opts.updateOffset.onUpdateId } : {}), diff --git a/extensions/telegram/src/bot-deps.ts b/extensions/telegram/src/bot-deps.ts index ef052594462..0cc5a06d797 100644 --- a/extensions/telegram/src/bot-deps.ts +++ b/extensions/telegram/src/bot-deps.ts @@ -1,4 +1,7 @@ -import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline"; +import { + createChannelMessageReplyPipeline, + deliverInboundReplyWithMessageSendContext, +} from "openclaw/plugin-sdk/channel-message"; import { readChannelAllowFromStore } from "openclaw/plugin-sdk/conversation-runtime"; import { upsertChannelPairingRequest } from "openclaw/plugin-sdk/conversation-runtime"; import { buildModelsProviderData } from "openclaw/plugin-sdk/models-provider-runtime"; @@ -32,9 +35,10 @@ export type TelegramBotDeps = { resolveExecApproval?: typeof resolveTelegramExecApproval; createTelegramDraftStream?: typeof createTelegramDraftStream; deliverReplies?: typeof deliverReplies; + deliverInboundReplyWithMessageSendContext?: typeof deliverInboundReplyWithMessageSendContext; emitInternalMessageSentHook?: typeof emitInternalMessageSentHook; editMessageTelegram?: typeof editMessageTelegram; - createChannelReplyPipeline?: typeof createChannelReplyPipeline; + createChannelMessageReplyPipeline?: typeof createChannelMessageReplyPipeline; }; export const defaultTelegramBotDeps: TelegramBotDeps = { @@ -83,13 +87,16 @@ export const defaultTelegramBotDeps: TelegramBotDeps = { get deliverReplies() { return deliverReplies; }, + get deliverInboundReplyWithMessageSendContext() { + return deliverInboundReplyWithMessageSendContext; + }, get emitInternalMessageSentHook() { return emitInternalMessageSentHook; }, get editMessageTelegram() { return editMessageTelegram; }, - get createChannelReplyPipeline() { - return createChannelReplyPipeline; + get createChannelMessageReplyPipeline() { + return createChannelMessageReplyPipeline; }, }; diff --git a/extensions/telegram/src/bot-message-dispatch.test.ts b/extensions/telegram/src/bot-message-dispatch.test.ts index 5f6b6088735..64e406e357f 100644 --- a/extensions/telegram/src/bot-message-dispatch.test.ts +++ b/extensions/telegram/src/bot-message-dispatch.test.ts @@ -18,6 +18,7 @@ const dispatchReplyWithBufferedBlockDispatcher = vi.hoisted(() => vi.fn<(params: DispatchReplyWithBufferedBlockDispatcherArgs) => Promise>(), ); const deliverReplies = vi.hoisted(() => vi.fn()); +const deliverInboundReplyWithMessageSendContext = vi.hoisted(() => vi.fn()); const emitInternalMessageSentHook = vi.hoisted(() => vi.fn()); const createForumTopicTelegram = vi.hoisted(() => vi.fn()); const deleteMessageTelegram = vi.hoisted(() => vi.fn()); @@ -45,7 +46,7 @@ const buildModelsProviderData = vi.hoisted(() => })), ); const listSkillCommandsForAgents = vi.hoisted(() => vi.fn(() => [])); -const createChannelReplyPipeline = vi.hoisted(() => +const createChannelMessageReplyPipeline = vi.hoisted(() => vi.fn(() => ({ responsePrefix: undefined, responsePrefixContextProvider: () => ({ identityName: undefined }), @@ -79,6 +80,14 @@ vi.mock("./draft-stream.js", () => ({ createTelegramDraftStream, })); +vi.mock("openclaw/plugin-sdk/channel-message", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + deliverInboundReplyWithMessageSendContext, + }; +}); + vi.mock("./bot/delivery.js", () => ({ deliverReplies, emitInternalMessageSentHook, @@ -146,12 +155,14 @@ const telegramDepsForTest: TelegramBotDeps = { buildModelsProviderData: buildModelsProviderData as TelegramBotDeps["buildModelsProviderData"], listSkillCommandsForAgents: listSkillCommandsForAgents as TelegramBotDeps["listSkillCommandsForAgents"], - createChannelReplyPipeline: - createChannelReplyPipeline as TelegramBotDeps["createChannelReplyPipeline"], + createChannelMessageReplyPipeline: + createChannelMessageReplyPipeline as TelegramBotDeps["createChannelMessageReplyPipeline"], wasSentByBot: wasSentByBot as TelegramBotDeps["wasSentByBot"], createTelegramDraftStream: createTelegramDraftStream as TelegramBotDeps["createTelegramDraftStream"], deliverReplies: deliverReplies as TelegramBotDeps["deliverReplies"], + deliverInboundReplyWithMessageSendContext: + deliverInboundReplyWithMessageSendContext as TelegramBotDeps["deliverInboundReplyWithMessageSendContext"], emitInternalMessageSentHook: emitInternalMessageSentHook as TelegramBotDeps["emitInternalMessageSentHook"], editMessageTelegram: editMessageTelegram as TelegramBotDeps["editMessageTelegram"], @@ -173,6 +184,7 @@ describe("dispatchTelegramMessage draft streaming", () => { createTelegramDraftStream.mockReset(); dispatchReplyWithBufferedBlockDispatcher.mockReset(); deliverReplies.mockReset(); + deliverInboundReplyWithMessageSendContext.mockReset(); emitInternalMessageSentHook.mockReset(); createForumTopicTelegram.mockReset(); deleteMessageTelegram.mockReset(); @@ -188,7 +200,7 @@ describe("dispatchTelegramMessage draft streaming", () => { enqueueSystemEvent.mockReset(); buildModelsProviderData.mockReset(); listSkillCommandsForAgents.mockReset(); - createChannelReplyPipeline.mockReset(); + createChannelMessageReplyPipeline.mockReset(); wasSentByBot.mockReset(); loadSessionStore.mockReset(); resolveStorePath.mockReset(); @@ -209,6 +221,10 @@ describe("dispatchTelegramMessage draft streaming", () => { counts: { block: 0, final: 0, tool: 0 }, }); deliverReplies.mockResolvedValue({ delivered: true }); + deliverInboundReplyWithMessageSendContext.mockResolvedValue({ + status: "unsupported", + reason: "missing_outbound_handler", + }); emitInternalMessageSentHook.mockResolvedValue(undefined); createForumTopicTelegram.mockResolvedValue({ message_thread_id: 777 }); deleteMessageTelegram.mockResolvedValue(true); @@ -231,7 +247,7 @@ describe("dispatchTelegramMessage draft streaming", () => { modelNames: new Map(), }); listSkillCommandsForAgents.mockReturnValue([]); - createChannelReplyPipeline.mockReturnValue({ + createChannelMessageReplyPipeline.mockReturnValue({ responsePrefix: undefined, responsePrefixContextProvider: () => ({ identityName: undefined }), onModelSelected: () => undefined, @@ -448,7 +464,95 @@ describe("dispatchTelegramMessage draft streaming", () => { expect(draftStream.clear).toHaveBeenCalledTimes(1); }); + it("queues final Telegram replies through outbound delivery when available", async () => { + deliverInboundReplyWithMessageSendContext.mockResolvedValue({ + status: "handled_visible", + delivery: { + messageIds: ["1001"], + visibleReplySent: true, + }, + }); + dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => { + await dispatcherOptions.deliver({ text: "Hello queued" }, { kind: "final" }); + return { queuedFinal: true }; + }); + + await dispatchWithContext({ + context: createContext({ + ctxPayload: { + SessionKey: "s1", + ChatType: "direct", + SenderId: "42", + SenderName: "Alice", + SenderUsername: "alice", + } as unknown as TelegramMessageContext["ctxPayload"], + }), + streamMode: "off", + telegramDeps: telegramDepsForTest, + }); + + expect(deliverInboundReplyWithMessageSendContext).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "telegram", + to: "123", + accountId: "default", + payload: expect.objectContaining({ text: "Hello queued" }), + info: { kind: "final" }, + replyToMode: "first", + threadId: 777, + formatting: expect.objectContaining({ textLimit: 4096, tableMode: "preserve" }), + agentId: "default", + ctxPayload: expect.objectContaining({ + SessionKey: "s1", + ChatType: "direct", + SenderId: "42", + SenderName: "Alice", + SenderUsername: "alice", + }), + }), + ); + expect(deliverReplies).not.toHaveBeenCalled(); + }); + + it("queues media-only final Telegram replies through outbound delivery when available", async () => { + deliverInboundReplyWithMessageSendContext.mockResolvedValue({ + status: "handled_visible", + delivery: { + messageIds: ["1002"], + visibleReplySent: true, + }, + }); + dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => { + await dispatcherOptions.deliver({ mediaUrl: "file:///tmp/final.png" }, { kind: "final" }); + return { queuedFinal: true }; + }); + + await dispatchWithContext({ + context: createContext(), + streamMode: "off", + telegramDeps: telegramDepsForTest, + }); + + expect(deliverInboundReplyWithMessageSendContext).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "telegram", + payload: expect.objectContaining({ mediaUrl: "file:///tmp/final.png" }), + info: { kind: "final" }, + requiredCapabilities: expect.objectContaining({ + media: true, + payload: true, + }), + }), + ); + expect(deliverReplies).not.toHaveBeenCalled(); + }); + it("skips answer draft preview for same-chat selected quotes", async () => { + deliverInboundReplyWithMessageSendContext.mockResolvedValue({ + status: "unsupported", + reason: "capability_mismatch", + capability: "nativeQuote", + }); dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => { await dispatcherOptions.deliver({ text: "Hello", replyToId: "1001" }, { kind: "final" }); return { queuedFinal: true }; @@ -478,6 +582,13 @@ describe("dispatchTelegramMessage draft streaming", () => { replyQuoteText: " quoted slice\n", }), ); + expect(deliverInboundReplyWithMessageSendContext).toHaveBeenCalledWith( + expect.objectContaining({ + requiredCapabilities: expect.objectContaining({ + nativeQuote: true, + }), + }), + ); }); it("keeps answer draft preview for current message replies with native quote candidates", async () => { @@ -1066,6 +1177,35 @@ describe("dispatchTelegramMessage draft streaming", () => { ); }); + it("queues silent error replies through durable delivery with silent preserved", async () => { + deliverInboundReplyWithMessageSendContext.mockResolvedValue({ + status: "handled_visible", + delivery: { + messageIds: ["durable-silent"], + visibleReplySent: true, + }, + }); + dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => { + await dispatcherOptions.deliver({ text: "oops", isError: true }, { kind: "final" }); + return { queuedFinal: true }; + }); + deliverReplies.mockResolvedValue({ delivered: true }); + + await dispatchWithContext({ + context: createContext(), + telegramCfg: { silentErrorReplies: true }, + }); + + expect(deliverInboundReplyWithMessageSendContext).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "telegram", + payload: expect.objectContaining({ isError: true }), + silent: true, + }), + ); + expect(deliverReplies).not.toHaveBeenCalled(); + }); + it("keeps error replies notifying by default", async () => { dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => { await dispatcherOptions.deliver({ text: "oops", isError: true }, { kind: "final" }); @@ -4327,6 +4467,13 @@ describe("dispatchTelegramMessage draft streaming", () => { }, ); deliverReplies.mockResolvedValue({ delivered: true }); + deliverInboundReplyWithMessageSendContext.mockResolvedValue({ + status: "handled_visible", + delivery: { + messageIds: ["2002"], + visibleReplySent: true, + }, + }); const preConnectErr = new Error("connect ECONNREFUSED 149.154.167.220:443"); (preConnectErr as NodeJS.ErrnoException).code = "ECONNREFUSED"; editMessageTelegram.mockRejectedValue(preConnectErr); @@ -4334,13 +4481,20 @@ describe("dispatchTelegramMessage draft streaming", () => { await dispatchWithContext({ context: createContext() }); expect(editMessageTelegram).toHaveBeenCalledTimes(1); - const deliverCalls = deliverReplies.mock.calls; - const finalTextSentViaDeliverReplies = deliverCalls.some((call: unknown[]) => - (call[0] as { replies?: Array<{ text?: string }> })?.replies?.some( - (r: { text?: string }) => r.text === "Final answer", - ), + expect(deliverInboundReplyWithMessageSendContext).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "telegram", + to: "123", + accountId: "default", + agentId: "default", + payload: expect.objectContaining({ text: "Final answer" }), + info: { kind: "final" }, + replyToMode: "first", + threadId: 777, + formatting: expect.objectContaining({ textLimit: 4096, tableMode: "preserve" }), + }), ); - expect(finalTextSentViaDeliverReplies).toBe(true); + expect(deliverReplies).not.toHaveBeenCalled(); }); it("falls back when Telegram reports the current final edit target missing", async () => { diff --git a/extensions/telegram/src/bot-message-dispatch.ts b/extensions/telegram/src/bot-message-dispatch.ts index 3bdc762aee8..a80a10480cc 100644 --- a/extensions/telegram/src/bot-message-dispatch.ts +++ b/extensions/telegram/src/bot-message-dispatch.ts @@ -5,7 +5,10 @@ import { logTypingFailure, removeAckReactionAfterReply, } from "openclaw/plugin-sdk/channel-feedback"; -import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline"; +import { + createChannelMessageReplyPipeline, + deriveDurableFinalDeliveryRequirements, +} from "openclaw/plugin-sdk/channel-message"; import { createChannelProgressDraftGate, formatChannelProgressDraftLine, @@ -821,16 +824,69 @@ export const dispatchTelegramMessage = async ({ } return { ...payload, replyToId: implicitQuoteReplyTargetId }; }; + const usesNativeTelegramQuote = (payload: ReplyPayload): boolean => { + if (replyQuoteText != null) { + return true; + } + return payload.replyToId != null && replyQuoteByMessageId[payload.replyToId] != null; + }; let lastVisibleNonPreviewDeliveryAtMs: number | undefined; - const sendPayload = async (payload: ReplyPayload) => { + const sendPayload = async ( + payload: ReplyPayload, + options?: { durable?: boolean; silent?: boolean }, + ) => { if (isDispatchSuperseded()) { return false; } + const deliverablePayload = applyQuoteReplyTarget(payload); + const silent = options?.silent ?? (silentErrorReplies && payload.isError === true); + const durableDelivery = telegramDeps.deliverInboundReplyWithMessageSendContext; + if (options?.durable && durableDelivery) { + const durable = await durableDelivery({ + cfg, + channel: "telegram", + to: String(chatId), + accountId: route.accountId, + agentId: route.agentId, + ctxPayload, + payload: deliverablePayload, + info: { kind: "final" }, + replyToMode, + threadId: threadSpec.id, + formatting: { + textLimit, + tableMode, + chunkMode, + }, + silent, + requiredCapabilities: deriveDurableFinalDeliveryRequirements({ + payload: deliverablePayload, + replyToId: deliverablePayload.replyToId, + threadId: threadSpec.id, + silent, + payloadTransport: true, + extraCapabilities: { + nativeQuote: usesNativeTelegramQuote(deliverablePayload), + }, + }), + }); + if (durable.status === "failed") { + throw durable.error; + } + if (durable.status === "handled_visible") { + deliveryState.markDelivered(); + lastVisibleNonPreviewDeliveryAtMs = Date.now(); + return true; + } + if (durable.status === "handled_no_send") { + return false; + } + } const result = await (telegramDeps.deliverReplies ?? deliverReplies)({ ...deliveryBaseOptions, - replies: [applyQuoteReplyTarget(payload)], + replies: [deliverablePayload], onVoiceRecording: sendRecordVoice, - silent: silentErrorReplies && payload.isError === true, + silent, mediaLoader: telegramDeps.loadWebMedia, }); if (result.delivered) { @@ -918,7 +974,7 @@ export const dispatchTelegramMessage = async ({ } const { onModelSelected, ...replyPipeline } = ( - telegramDeps.createChannelReplyPipeline ?? createChannelReplyPipeline + telegramDeps.createChannelMessageReplyPipeline ?? createChannelMessageReplyPipeline )({ cfg, agentId: route.agentId, @@ -1076,7 +1132,9 @@ export const dispatchTelegramMessage = async ({ const payloadWithoutSuppressedReasoning = typeof payload.text === "string" ? { ...payload, text: "" } : payload; markVisibleNonPreviewBoundary( - await sendPayload(payloadWithoutSuppressedReasoning), + await sendPayload(payloadWithoutSuppressedReasoning, { + durable: info.kind === "final", + }), ); } if (info.kind === "final") { @@ -1099,7 +1157,9 @@ export const dispatchTelegramMessage = async ({ } return; } - markVisibleNonPreviewBoundary(await sendPayload(payload)); + markVisibleNonPreviewBoundary( + await sendPayload(payload, { durable: info.kind === "final" }), + ); if (info.kind === "final") { await flushBufferedFinalAnswer(); pendingCompactionReplayBoundary = false; diff --git a/extensions/telegram/src/bot-native-commands.delivery.runtime.ts b/extensions/telegram/src/bot-native-commands.delivery.runtime.ts index 7e3fb236046..293280412a8 100644 --- a/extensions/telegram/src/bot-native-commands.delivery.runtime.ts +++ b/extensions/telegram/src/bot-native-commands.delivery.runtime.ts @@ -1,4 +1,4 @@ -import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline"; +import { createChannelMessageReplyPipeline } from "openclaw/plugin-sdk/channel-message"; import { deliverReplies, emitTelegramMessageSentHooks } from "./bot/delivery.js"; -export { createChannelReplyPipeline, deliverReplies, emitTelegramMessageSentHooks }; +export { createChannelMessageReplyPipeline, deliverReplies, emitTelegramMessageSentHooks }; diff --git a/extensions/telegram/src/bot-native-commands.test-helpers.ts b/extensions/telegram/src/bot-native-commands.test-helpers.ts index b4974bcb79e..4311c25b5ea 100644 --- a/extensions/telegram/src/bot-native-commands.test-helpers.ts +++ b/extensions/telegram/src/bot-native-commands.test-helpers.ts @@ -25,7 +25,7 @@ type EnsureConfiguredBindingRouteReadyFn = type GetAgentScopedMediaLocalRootsFn = typeof import("./bot-native-commands.runtime.js").getAgentScopedMediaLocalRoots; type CreateChannelReplyPipelineFn = - typeof import("./bot-native-commands.delivery.runtime.js").createChannelReplyPipeline; + typeof import("./bot-native-commands.delivery.runtime.js").createChannelMessageReplyPipeline; type AnyMock = MockFn<(...args: unknown[]) => unknown>; type AnyAsyncMock = MockFn<(...args: unknown[]) => Promise>; type NativeCommandHarness = { @@ -57,7 +57,7 @@ const replyPipelineMocks = vi.hoisted(() => { dispatchReplyWithBufferedBlockDispatcher: vi.fn( (async () => dispatchReplyResult) as DispatchReplyWithBufferedBlockDispatcherFn, ), - createChannelReplyPipeline: vi.fn((() => ({ + createChannelMessageReplyPipeline: vi.fn((() => ({ onModelSelected: () => {}, responsePrefixContextProvider: () => undefined, })) as unknown as CreateChannelReplyPipelineFn), @@ -84,7 +84,7 @@ vi.mock("./bot-native-commands.runtime.js", () => ({ getAgentScopedMediaLocalRoots: replyPipelineMocks.getAgentScopedMediaLocalRoots, })); vi.mock("./bot-native-commands.delivery.runtime.js", () => ({ - createChannelReplyPipeline: replyPipelineMocks.createChannelReplyPipeline, + createChannelMessageReplyPipeline: replyPipelineMocks.createChannelMessageReplyPipeline, deliverReplies: deliveryMocks.deliverReplies, emitTelegramMessageSentHooks: vi.fn(), })); diff --git a/extensions/telegram/src/bot-native-commands.ts b/extensions/telegram/src/bot-native-commands.ts index e6fb4fb772c..2d2fb37cb22 100644 --- a/extensions/telegram/src/bot-native-commands.ts +++ b/extensions/telegram/src/bot-native-commands.ts @@ -1115,9 +1115,9 @@ export const registerTelegramNativeCommands = ({ skippedNonSilent: 0, }; - const { createChannelReplyPipeline, deliverReplies } = + const { createChannelMessageReplyPipeline, deliverReplies } = await loadTelegramNativeCommandDeliveryRuntime(); - const { onModelSelected, ...replyPipeline } = createChannelReplyPipeline({ + const { onModelSelected, ...replyPipeline } = createChannelMessageReplyPipeline({ cfg: executionCfg, agentId: route.agentId, channel: "telegram", diff --git a/extensions/telegram/src/bot-update-tracker.test.ts b/extensions/telegram/src/bot-update-tracker.test.ts index 311b8a2c0df..2352bdf1f8a 100644 --- a/extensions/telegram/src/bot-update-tracker.test.ts +++ b/extensions/telegram/src/bot-update-tracker.test.ts @@ -62,6 +62,44 @@ describe("createTelegramUpdateTracker", () => { } satisfies Partial); }); + it("can persist offsets only after successful agent dispatch", async () => { + const onAcceptedUpdateId = vi.fn(); + const tracker = createTelegramUpdateTracker({ + initialUpdateId: 100, + ackPolicy: "after_agent_dispatch", + onAcceptedUpdateId, + }); + + const update101 = tracker.beginUpdate(updateCtx(101)); + if (!update101.accepted) { + throw new Error("expected update 101 to be accepted"); + } + await flushTrackerMicrotasks(); + expect(onAcceptedUpdateId).not.toHaveBeenCalled(); + + tracker.finishUpdate(update101.update, { completed: false }); + await flushTrackerMicrotasks(); + expect(onAcceptedUpdateId).not.toHaveBeenCalled(); + expect(tracker.getState()).toMatchObject({ + failedUpdateIds: [101], + highestPersistedAcceptedUpdateId: 100, + } satisfies Partial); + + const retry = tracker.beginUpdate(updateCtx(101)); + if (!retry.accepted) { + throw new Error("expected update 101 retry to be accepted"); + } + tracker.finishUpdate(retry.update, { completed: true }); + await flushTrackerMicrotasks(); + + expect(onAcceptedUpdateId).toHaveBeenCalledWith(101); + expect(tracker.getState()).toMatchObject({ + failedUpdateIds: [], + highestPersistedAcceptedUpdateId: 101, + safeCompletedUpdateId: 101, + } satisfies Partial); + }); + it("skips restart replays once the accepted offset is restored", async () => { const onAcceptedUpdateId = vi.fn(); const firstProcess = createTelegramUpdateTracker({ diff --git a/extensions/telegram/src/bot-update-tracker.ts b/extensions/telegram/src/bot-update-tracker.ts index e21f5662829..f016f6059e4 100644 --- a/extensions/telegram/src/bot-update-tracker.ts +++ b/extensions/telegram/src/bot-update-tracker.ts @@ -1,3 +1,8 @@ +import { + createMessageReceiveContext, + type MessageAckPolicy, + type MessageReceiveContext, +} from "openclaw/plugin-sdk/channel-message"; import { buildTelegramUpdateKey, createTelegramUpdateDedupe, @@ -9,6 +14,7 @@ type PersistUpdateId = (updateId: number) => void | Promise; type TelegramUpdateTrackerOptions = { initialUpdateId?: number | null; + ackPolicy?: MessageAckPolicy; onAcceptedUpdateId?: PersistUpdateId; onPersistError?: (error: unknown) => void; onSkip?: (key: string) => void; @@ -17,6 +23,7 @@ type TelegramUpdateTrackerOptions = { type AcceptedTelegramUpdate = { key?: string; updateId?: number; + receiveContext?: MessageReceiveContext; }; type BeginUpdateResult = @@ -49,6 +56,7 @@ function sortedIds(ids: Set): number[] { export function createTelegramUpdateTracker(options: TelegramUpdateTrackerOptions = {}) { const initialUpdateId = typeof options.initialUpdateId === "number" ? options.initialUpdateId : null; + const ackPolicy = options.ackPolicy ?? "after_receive_record"; const recentUpdates = createTelegramUpdateDedupe(); const pendingUpdateKeys = new Set(); const activeHandledUpdateKeys = new Map(); @@ -114,7 +122,44 @@ export function createTelegramUpdateTracker(options: TelegramUpdateTrackerOption return; } highestAcceptedUpdateId = updateId; - requestPersistAcceptedUpdateId(updateId); + }; + + function resolveSafeCompletedUpdateId() { + if (highestCompletedUpdateId === null) { + return null; + } + let safeCompletedUpdateId = highestCompletedUpdateId; + for (const updateId of pendingUpdateIds) { + if (updateId <= safeCompletedUpdateId) { + safeCompletedUpdateId = updateId - 1; + } + } + for (const updateId of failedUpdateIds) { + if (updateId <= safeCompletedUpdateId) { + safeCompletedUpdateId = updateId - 1; + } + } + return safeCompletedUpdateId; + } + + const persistUpdateIdAfterAck = async (updateId: number) => { + const persistUpdateId = + ackPolicy === "after_agent_dispatch" ? resolveSafeCompletedUpdateId() : updateId; + if (persistUpdateId !== null) { + requestPersistAcceptedUpdateId(persistUpdateId); + } + }; + + const ackUpdateAfterStage = ( + receiveContext: MessageReceiveContext | undefined, + stage: "receive_record" | "agent_dispatch", + ) => { + if (!receiveContext?.shouldAckAfter(stage)) { + return; + } + void receiveContext.ack().catch((err) => { + options.onPersistError?.(err); + }); }; const beginUpdate = (ctx: TelegramUpdateKeyContext): BeginUpdateResult => { @@ -138,15 +183,25 @@ export function createTelegramUpdateTracker(options: TelegramUpdateTrackerOption pendingUpdateKeys.add(updateKey); activeHandledUpdateKeys.set(updateKey, false); } + let receiveContext: MessageReceiveContext | undefined; if (typeof updateId === "number") { pendingUpdateIds.add(updateId); acceptUpdateId(updateId); + receiveContext = createMessageReceiveContext({ + id: updateKey ?? `telegram:update:${updateId}`, + channel: "telegram", + message: ctx, + ackPolicy, + onAck: () => persistUpdateIdAfterAck(updateId), + }); + ackUpdateAfterStage(receiveContext, "receive_record"); } return { accepted: true, update: { ...(updateKey ? { key: updateKey } : {}), ...(typeof updateId === "number" ? { updateId } : {}), + ...(receiveContext ? { receiveContext } : {}), }, }; }; @@ -166,8 +221,14 @@ export function createTelegramUpdateTracker(options: TelegramUpdateTrackerOption if (highestCompletedUpdateId === null || update.updateId > highestCompletedUpdateId) { highestCompletedUpdateId = update.updateId; } + ackUpdateAfterStage(update.receiveContext, "agent_dispatch"); } else { failedUpdateIds.add(update.updateId); + void update.receiveContext + ?.nack(new Error("Telegram update handler did not complete")) + .catch((err) => { + options.onPersistError?.(err); + }); } } }; @@ -197,24 +258,6 @@ export function createTelegramUpdateTracker(options: TelegramUpdateTrackerOption return skipped; }; - const resolveSafeCompletedUpdateId = () => { - if (highestCompletedUpdateId === null) { - return null; - } - let safeCompletedUpdateId = highestCompletedUpdateId; - for (const updateId of pendingUpdateIds) { - if (updateId <= safeCompletedUpdateId) { - safeCompletedUpdateId = updateId - 1; - } - } - for (const updateId of failedUpdateIds) { - if (updateId <= safeCompletedUpdateId) { - safeCompletedUpdateId = updateId - 1; - } - } - return safeCompletedUpdateId; - }; - const getState = (): TelegramUpdateTrackerState => ({ highestAcceptedUpdateId, highestPersistedAcceptedUpdateId, diff --git a/extensions/telegram/src/bot.create-telegram-bot.test.ts b/extensions/telegram/src/bot.create-telegram-bot.test.ts index 88bd603d0fe..4e0d1f601f8 100644 --- a/extensions/telegram/src/bot.create-telegram-bot.test.ts +++ b/extensions/telegram/src/bot.create-telegram-bot.test.ts @@ -1186,7 +1186,7 @@ describe("createTelegramBot", () => { expect(replySpy).toHaveBeenCalledTimes(1); }); - it("persists accepted update offsets before completion", async () => { + it("persists update offsets after successful dispatch completion", async () => { // For this test we need sequentialize(...) to behave like a normal middleware and call next(). sequentializeSpy.mockImplementationOnce( () => async (_ctx: unknown, next: () => Promise) => { @@ -1243,18 +1243,20 @@ describe("createTelegramBot", () => { // Start processing update 101 but keep it pending (simulates a long-running turn). const p101 = runMiddlewareChain({ update: { update_id: 101 } }, async () => update101Gate); - // Let update 101 enter the chain and persist acceptance before 102 completes. + // Let update 101 enter the chain. Telegram now persists the restart watermark only after + // the handler completes, so a crash during the pending turn can replay the update. await Promise.resolve(); - expect(onUpdateId).toHaveBeenCalledWith(101); + expect(onUpdateId).not.toHaveBeenCalled(); - // Complete update 102 while 101 is still pending. Restart replay protection is at-most-once. + // Complete update 102 while 101 is still pending. The persisted watermark must not advance + // past pending lower ids. await runMiddlewareChain({ update: { update_id: 102 } }, async () => {}); - expect(onUpdateId).toHaveBeenCalledWith(102); + expect(onUpdateId).not.toHaveBeenCalled(); releaseUpdate101?.(); await p101; - expect(onUpdateId.mock.calls.map((call) => Number(call[0]))).toEqual([101, 102]); + expect(onUpdateId.mock.calls.map((call) => Number(call[0]))).toEqual([102]); }); it("logs and swallows update watermark persistence failures", async () => { sequentializeSpy.mockImplementationOnce( @@ -1326,7 +1328,7 @@ describe("createTelegramBot", () => { } }); - it("persists failed updates once accepted while preserving same-process retries", async () => { + it("keeps failed updates unpersisted while preserving same-process retries", async () => { sequentializeSpy.mockImplementationOnce( () => async (_ctx: unknown, next: () => Promise) => { await next(); @@ -1378,12 +1380,12 @@ describe("createTelegramBot", () => { }), ).rejects.toThrow("middleware boom"); await flushTelegramTestMicrotasks(); - expect(onUpdateId).toHaveBeenCalledWith(201); + expect(onUpdateId).not.toHaveBeenCalled(); await runMiddlewareChain({ update: { update_id: 202 } }, async () => {}); await flushTelegramTestMicrotasks(); - expect(onUpdateId).toHaveBeenCalledWith(202); + expect(onUpdateId).not.toHaveBeenCalled(); const retryHandler = vi.fn(); await runMiddlewareChain({ update: { update_id: 201 } }, async () => { @@ -1392,7 +1394,7 @@ describe("createTelegramBot", () => { await flushTelegramTestMicrotasks(); expect(retryHandler).toHaveBeenCalledTimes(1); - expect(onUpdateId.mock.calls.map((call) => Number(call[0]))).toEqual([201, 202]); + expect(onUpdateId.mock.calls.map((call) => Number(call[0]))).toEqual([202]); }); it("skips replayed update ids even when the semantic update key differs", async () => { diff --git a/extensions/telegram/src/channel.message-adapter.test.ts b/extensions/telegram/src/channel.message-adapter.test.ts new file mode 100644 index 00000000000..f0a045c76a4 --- /dev/null +++ b/extensions/telegram/src/channel.message-adapter.test.ts @@ -0,0 +1,210 @@ +import { + verifyChannelMessageAdapterCapabilityProofs, + verifyChannelMessageLiveCapabilityAdapterProofs, + verifyChannelMessageLiveFinalizerProofs, + verifyChannelMessageReceiveAckPolicyAdapterProofs, +} from "openclaw/plugin-sdk/channel-message"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const sendMessageTelegramMock = vi.fn(); + +vi.mock("./send.js", () => ({ + sendMessageTelegram: (...args: unknown[]) => sendMessageTelegramMock(...args), +})); + +import { telegramPlugin } from "./channel.js"; + +describe("telegram channel message adapter", () => { + beforeEach(() => { + sendMessageTelegramMock.mockReset(); + }); + + it("backs declared durable-final capabilities with native send proofs", async () => { + const adapter = telegramPlugin.message; + expect(adapter).toBeDefined(); + + const proveText = async () => { + sendMessageTelegramMock.mockResolvedValueOnce({ messageId: "tg-text", chatId: "12345" }); + const result = await adapter!.send!.text!({ + cfg: {} as never, + to: "12345", + text: "hello", + deps: { sendTelegram: sendMessageTelegramMock }, + }); + expect(sendMessageTelegramMock).toHaveBeenLastCalledWith( + "12345", + "hello", + expect.objectContaining({ verbose: false }), + ); + expect(result.receipt.platformMessageIds).toEqual(["tg-text"]); + }; + + const proveMedia = async () => { + sendMessageTelegramMock.mockResolvedValueOnce({ messageId: "tg-media", chatId: "12345" }); + const result = await adapter!.send!.media!({ + cfg: {} as never, + to: "12345", + text: "caption", + mediaUrl: "https://example.com/a.png", + mediaLocalRoots: ["/tmp/media"], + deps: { sendTelegram: sendMessageTelegramMock }, + }); + expect(sendMessageTelegramMock).toHaveBeenLastCalledWith( + "12345", + "caption", + expect.objectContaining({ + mediaUrl: "https://example.com/a.png", + mediaLocalRoots: ["/tmp/media"], + }), + ); + expect(result.receipt.parts[0]?.kind).toBe("media"); + }; + + const provePayload = async () => { + sendMessageTelegramMock.mockResolvedValueOnce({ messageId: "tg-payload", chatId: "12345" }); + const result = await adapter!.send!.payload!({ + cfg: {} as never, + to: "12345", + text: "payload", + payload: { text: "payload" }, + deps: { sendTelegram: sendMessageTelegramMock }, + }); + expect(sendMessageTelegramMock).toHaveBeenLastCalledWith( + "12345", + "payload", + expect.objectContaining({ verbose: false }), + ); + expect(result.receipt.platformMessageIds).toEqual(["tg-payload"]); + }; + + const proveReplyThreadSilent = async () => { + sendMessageTelegramMock.mockResolvedValueOnce({ messageId: "tg-thread", chatId: "12345" }); + await adapter!.send!.text!({ + cfg: {} as never, + to: "12345", + text: "threaded", + replyToId: "900", + threadId: "12", + silent: true, + deps: { sendTelegram: sendMessageTelegramMock }, + }); + expect(sendMessageTelegramMock).toHaveBeenLastCalledWith( + "12345", + "threaded", + expect.objectContaining({ + replyToMessageId: 900, + messageThreadId: 12, + silent: true, + }), + ); + }; + + const proveBatch = async () => { + const startCallCount = sendMessageTelegramMock.mock.calls.length; + sendMessageTelegramMock + .mockResolvedValueOnce({ messageId: "tg-batch-1", chatId: "12345" }) + .mockResolvedValueOnce({ messageId: "tg-batch-2", chatId: "12345" }); + await adapter!.send!.payload!({ + cfg: {} as never, + to: "12345", + text: "batch", + payload: { + text: "batch", + mediaUrls: ["https://example.com/a.png", "https://example.com/b.png"], + }, + deps: { sendTelegram: sendMessageTelegramMock }, + }); + const batchCalls = sendMessageTelegramMock.mock.calls.slice(startCallCount); + expect(batchCalls[0]).toEqual([ + "12345", + "batch", + expect.objectContaining({ mediaUrl: "https://example.com/a.png" }), + ]); + expect(batchCalls[1]).toEqual([ + "12345", + "", + expect.objectContaining({ mediaUrl: "https://example.com/b.png" }), + ]); + }; + + await verifyChannelMessageAdapterCapabilityProofs({ + adapterName: "telegramMessageAdapter", + adapter: adapter!, + proofs: { + text: proveText, + media: proveMedia, + payload: provePayload, + silent: proveReplyThreadSilent, + replyTo: proveReplyThreadSilent, + thread: proveReplyThreadSilent, + messageSendingHooks: () => { + expect(adapter!.send!.text).toBeTypeOf("function"); + }, + batch: proveBatch, + }, + }); + }); + + it("backs declared live capabilities with adapter proofs", async () => { + const adapter = telegramPlugin.message; + expect(adapter).toBeDefined(); + + await verifyChannelMessageLiveCapabilityAdapterProofs({ + adapterName: "telegramMessageAdapter", + adapter: adapter!, + proofs: { + draftPreview: () => { + expect(adapter!.receive?.defaultAckPolicy).toBe("after_agent_dispatch"); + }, + previewFinalization: () => { + expect(adapter!.durableFinal?.capabilities?.text).toBe(true); + }, + progressUpdates: () => { + expect(adapter!.live?.capabilities?.draftPreview).toBe(true); + }, + }, + }); + }); + + it("backs declared live preview finalizer capabilities with adapter proofs", async () => { + const adapter = telegramPlugin.message; + expect(adapter).toBeDefined(); + + await verifyChannelMessageLiveFinalizerProofs({ + adapterName: "telegramMessageAdapter", + adapter: adapter!, + proofs: { + finalEdit: () => { + expect(adapter!.live?.capabilities?.previewFinalization).toBe(true); + }, + normalFallback: () => { + expect(adapter!.durableFinal?.capabilities?.text).toBe(true); + }, + previewReceipt: () => { + expect(adapter!.live?.finalizer?.capabilities?.previewReceipt).toBe(true); + }, + retainOnAmbiguousFailure: () => { + expect(adapter!.live?.finalizer?.capabilities?.retainOnAmbiguousFailure).toBe(true); + }, + }, + }); + }); + + it("backs declared receive ack policies with adapter proofs", async () => { + const adapter = telegramPlugin.message; + expect(adapter).toBeDefined(); + + await verifyChannelMessageReceiveAckPolicyAdapterProofs({ + adapterName: "telegramMessageAdapter", + adapter: adapter!, + proofs: { + after_receive_record: () => { + expect(adapter!.receive?.supportedAckPolicies).toContain("after_receive_record"); + }, + after_agent_dispatch: () => { + expect(adapter!.receive?.defaultAckPolicy).toBe("after_agent_dispatch"); + }, + }, + }); + }); +}); diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index ec8236c5cec..c7481482b68 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -11,6 +11,12 @@ import { createChatChannelPlugin, } from "openclaw/plugin-sdk/channel-core"; import { createAccountStatusSink } from "openclaw/plugin-sdk/channel-lifecycle"; +import { + createMessageReceiptFromOutboundResults, + defineChannelMessageAdapter, + type ChannelMessageSendResult, + type MessageReceiptPartKind, +} from "openclaw/plugin-sdk/channel-message"; import { createPairingPrefixStripper } from "openclaw/plugin-sdk/channel-pairing"; import { attachChannelToResult } from "openclaw/plugin-sdk/channel-send-result"; import { @@ -167,6 +173,7 @@ function buildTelegramSendOptions(params: { cfg: OpenClawConfig; mediaUrl?: string | null; mediaLocalRoots?: readonly string[] | null; + mediaReadFile?: ((filePath: string) => Promise) | null; accountId?: string | null; replyToId?: string | null; threadId?: string | number | null; @@ -179,6 +186,7 @@ function buildTelegramSendOptions(params: { cfg: params.cfg, ...(params.mediaUrl ? { mediaUrl: params.mediaUrl } : {}), ...(params.mediaLocalRoots?.length ? { mediaLocalRoots: params.mediaLocalRoots } : {}), + ...(params.mediaReadFile ? { mediaReadFile: params.mediaReadFile } : {}), messageThreadId: parseTelegramThreadId(params.threadId), replyToMessageId: parseTelegramReplyToMessageId(params.replyToId), accountId: params.accountId ?? undefined, @@ -196,11 +204,13 @@ async function sendTelegramOutbound(params: { text: string; mediaUrl?: string | null; mediaLocalRoots?: readonly string[] | null; + mediaReadFile?: ((filePath: string) => Promise) | null; accountId?: string | null; deps?: OutboundSendDeps; replyToId?: string | null; threadId?: string | number | null; silent?: boolean | null; + forceDocument?: boolean | null; gatewayClientScopes?: readonly string[] | null; }) { const send = await resolveTelegramSend(params.deps); @@ -211,15 +221,148 @@ async function sendTelegramOutbound(params: { cfg: params.cfg, mediaUrl: params.mediaUrl, mediaLocalRoots: params.mediaLocalRoots, + mediaReadFile: params.mediaReadFile, accountId: params.accountId, replyToId: params.replyToId, threadId: params.threadId, silent: params.silent, + forceDocument: params.forceDocument, gatewayClientScopes: params.gatewayClientScopes, }), ); } +type TelegramMessageSendSourceResult = { + messageId?: string; + chatId?: string; + receipt?: ChannelMessageSendResult["receipt"]; +}; + +function toTelegramMessageSendResult( + result: TelegramMessageSendSourceResult, + kind: MessageReceiptPartKind, + replyToId?: string | null, +): ChannelMessageSendResult { + const receipt = + result.receipt ?? + createMessageReceiptFromOutboundResults({ + results: result.messageId + ? [ + { + channel: "telegram", + messageId: result.messageId, + chatId: result.chatId, + }, + ] + : [], + kind, + ...(replyToId ? { replyToId } : {}), + }); + return { + messageId: result.messageId || receipt.primaryPlatformMessageId, + receipt, + }; +} + +const telegramMessageAdapter = defineChannelMessageAdapter({ + id: "telegram", + durableFinal: { + capabilities: { + text: true, + media: true, + payload: true, + silent: true, + replyTo: true, + thread: true, + messageSendingHooks: true, + batch: true, + }, + }, + live: { + capabilities: { + draftPreview: true, + previewFinalization: true, + progressUpdates: true, + }, + finalizer: { + capabilities: { + finalEdit: true, + normalFallback: true, + previewReceipt: true, + retainOnAmbiguousFailure: true, + }, + }, + }, + receive: { + defaultAckPolicy: "after_agent_dispatch", + supportedAckPolicies: ["after_receive_record", "after_agent_dispatch"], + }, + send: { + text: async (ctx) => + toTelegramMessageSendResult( + await sendTelegramOutbound({ + cfg: ctx.cfg, + to: ctx.to, + text: ctx.text, + accountId: ctx.accountId, + deps: ctx.deps, + replyToId: ctx.replyToId, + threadId: ctx.threadId, + silent: ctx.silent, + gatewayClientScopes: ctx.gatewayClientScopes, + }), + "text", + ctx.replyToId, + ), + media: async (ctx) => + toTelegramMessageSendResult( + await sendTelegramOutbound({ + cfg: ctx.cfg, + to: ctx.to, + text: ctx.text, + mediaUrl: ctx.mediaUrl, + mediaLocalRoots: ctx.mediaLocalRoots, + mediaReadFile: ctx.mediaReadFile, + accountId: ctx.accountId, + deps: ctx.deps, + replyToId: ctx.replyToId, + threadId: ctx.threadId, + silent: ctx.silent, + forceDocument: ctx.forceDocument, + gatewayClientScopes: ctx.gatewayClientScopes, + }), + "media", + ctx.replyToId, + ), + payload: async (ctx) => { + const send = await resolveTelegramSend(ctx.deps); + const result = attachChannelToResult( + "telegram", + await sendTelegramPayloadMessages({ + send, + to: ctx.to, + payload: ctx.payload, + baseOpts: { + ...buildTelegramSendOptions({ + cfg: ctx.cfg, + mediaUrl: ctx.mediaUrl, + mediaLocalRoots: ctx.mediaLocalRoots, + accountId: ctx.accountId, + replyToId: ctx.replyToId, + threadId: ctx.threadId, + silent: ctx.silent, + forceDocument: ctx.forceDocument, + gatewayClientScopes: ctx.gatewayClientScopes, + }), + ...(ctx.mediaReadFile ? { mediaReadFile: ctx.mediaReadFile } : {}), + }, + }), + ); + return toTelegramMessageSendResult(result, "unknown", ctx.replyToId); + }, + }, +}); + const telegramMessageActions: ChannelMessageActionAdapter = { resolveExecutionMode: (ctx) => getOptionalTelegramRuntime()?.channel?.telegram?.messageActions?.resolveExecutionMode?.(ctx) ?? @@ -776,6 +919,7 @@ export const telegramPlugin = createChatChannelPlugin({ listGroups: async (params) => listTelegramDirectoryGroupsFromConfig(params), }), actions: telegramMessageActions, + message: telegramMessageAdapter, status: createComputedAccountStatusAdapter({ defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID), collectStatusIssues: collectTelegramStatusIssues, diff --git a/extensions/telegram/src/lane-delivery-text-deliverer.ts b/extensions/telegram/src/lane-delivery-text-deliverer.ts index a0d97d6fc6f..ec490e123a5 100644 --- a/extensions/telegram/src/lane-delivery-text-deliverer.ts +++ b/extensions/telegram/src/lane-delivery-text-deliverer.ts @@ -1,3 +1,9 @@ +import { + createPreviewMessageReceipt, + defineFinalizableLivePreviewAdapter, + deliverWithFinalizableLivePreviewAdapter, + type MessageReceipt, +} from "openclaw/plugin-sdk/channel-message"; import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; import type { TelegramInlineButtons } from "./button-types.js"; @@ -67,13 +73,20 @@ export type LanePreviewLifecycle = "transient" | "complete"; export type LaneDeliveryResult = | { kind: "preview-finalized"; - delivery: { - content: string; - messageId?: number; - }; + delivery: LanePreviewFinalizedDelivery; } | { kind: "preview-retained" | "preview-updated" | "sent" | "skipped" }; +type LanePreviewFinalizedDelivery = { + content: string; + messageId: number; + receipt: MessageReceipt; +}; + +type LanePreviewFinalizedDeliveryInput = Omit & { + receipt?: MessageReceipt; +}; + type CreateLaneTextDelivererParams = { lanes: Record; archivedAnswerPreviews: ArchivedPreview[]; @@ -83,7 +96,10 @@ type CreateLaneTextDelivererParams = { applyTextToPayload: (payload: ReplyPayload, text: string) => ReplyPayload; applyTextToFollowUpPayload?: (payload: ReplyPayload, text: string) => ReplyPayload; splitFinalTextForPreview?: (text: string) => readonly string[]; - sendPayload: (payload: ReplyPayload) => Promise; + sendPayload: ( + payload: ReplyPayload, + options?: { durable?: boolean; silent?: boolean }, + ) => Promise; flushDraftLane: (lane: DraftLaneState) => Promise; stopDraftLane: (lane: DraftLaneState) => Promise; editPreview: (params: { @@ -151,12 +167,27 @@ type PreviewTargetResolution = { stopCreatesFirstPreview: boolean; }; +type TelegramPreviewFinalEdit = { + laneName: LaneName; + messageId: number; + text: string; + context: "final" | "update"; + previewButtons?: TelegramInlineButtons; +}; + function result( kind: LaneDeliveryResult["kind"], - delivery?: Extract["delivery"], + delivery?: LanePreviewFinalizedDeliveryInput, ): LaneDeliveryResult { if (kind === "preview-finalized") { - return { kind, delivery: delivery! }; + const finalized = delivery!; + return { + kind, + delivery: { + ...finalized, + receipt: finalized.receipt ?? createPreviewMessageReceipt({ id: finalized.messageId }), + }, + }; } return { kind }; } @@ -278,86 +309,133 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) { retainAlternatePreviewOnMissingTarget: boolean; targetPreviewText: string; }): Promise => { - try { - await params.editPreview({ + const previewEditState: { result: PreviewEditResult } = { result: "fallback" }; + const adapter = defineFinalizableLivePreviewAdapter< + { text: string }, + number, + TelegramPreviewFinalEdit + >({ + draft: { + flush: async () => {}, + clear: async () => {}, + id: () => args.messageId, + }, + buildFinalEdit: (payload) => ({ laneName: args.laneName, messageId: args.messageId, - text: args.text, - previewButtons: args.previewButtons, + text: payload.text, context: args.context, - }); - if (args.updateLaneSnapshot) { - args.lane.lastPartialText = args.text; - } - params.markDelivered(); - return "edited"; - } catch (err) { - if (isMessageNotModifiedError(err)) { - params.log( - `telegram: ${args.laneName} preview ${args.context} edit returned "message is not modified"; treating as delivered`, - ); + ...(args.previewButtons ? { previewButtons: args.previewButtons } : {}), + }), + editFinal: async (_messageId, edit) => { + try { + await params.editPreview(edit); + } catch (err) { + if (isMessageNotModifiedError(err)) { + params.log( + `telegram: ${args.laneName} preview ${args.context} edit returned "message is not modified"; treating as delivered`, + ); + return; + } + throw err; + } + }, + createPreviewReceipt: (messageId) => createPreviewMessageReceipt({ id: messageId }), + onPreviewFinalized: () => { + if (args.updateLaneSnapshot) { + args.lane.lastPartialText = args.text; + } params.markDelivered(); - return "edited"; - } - if (args.context === "final") { - if (args.finalTextAlreadyLanded) { + previewEditState.result = "edited"; + }, + handlePreviewEditError: ({ error: err }) => { + previewEditState.result = "fallback"; + if (isMessageNotModifiedError(err)) { params.log( - `telegram: ${args.laneName} preview final edit failed after stop flush; keeping existing preview (${String(err)})`, + `telegram: ${args.laneName} preview ${args.context} edit returned "message is not modified"; treating as delivered`, ); params.markDelivered(); - return "retained"; + previewEditState.result = "edited"; + return "retain"; } - if (isSafeToRetrySendError(err)) { - params.log( - `telegram: ${args.laneName} preview final edit failed before reaching Telegram; falling back to standard send (${String(err)})`, - ); - return "fallback"; - } - if (isMissingPreviewMessageError(err)) { - if (args.retainAlternatePreviewOnMissingTarget) { + if (args.context === "final") { + if (args.finalTextAlreadyLanded) { params.log( - `telegram: ${args.laneName} preview final edit target missing; keeping alternate preview without fallback (${String(err)})`, + `telegram: ${args.laneName} preview final edit failed after stop flush; keeping existing preview (${String(err)})`, ); params.markDelivered(); - return "retained"; + previewEditState.result = "retained"; + return "retain"; } + if (isSafeToRetrySendError(err)) { + params.log( + `telegram: ${args.laneName} preview final edit failed before reaching Telegram; falling back to standard send (${String(err)})`, + ); + return "fallback"; + } + if (isMissingPreviewMessageError(err)) { + if (args.retainAlternatePreviewOnMissingTarget) { + params.log( + `telegram: ${args.laneName} preview final edit target missing; keeping alternate preview without fallback (${String(err)})`, + ); + params.markDelivered(); + previewEditState.result = "retained"; + return "retain"; + } + params.log( + `telegram: ${args.laneName} preview final edit target missing with no alternate preview; falling back to standard send (${String(err)})`, + ); + return "fallback"; + } + if (isRecoverableTelegramNetworkError(err, { allowMessageMatch: true })) { + params.log( + `telegram: ${args.laneName} preview final edit may have landed despite network error; keeping existing preview (${String(err)})`, + ); + params.markDelivered(); + previewEditState.result = "retained"; + return "retain"; + } + if (isTelegramClientRejection(err)) { + params.log( + `telegram: ${args.laneName} preview final edit rejected by Telegram (client error); falling back to standard send (${String(err)})`, + ); + return "fallback"; + } + if (isIncompleteFinalPreviewPrefix(args.targetPreviewText, args.text)) { + params.log( + `telegram: ${args.laneName} preview final edit failed and existing preview is an incomplete prefix; falling back to standard send (${String(err)})`, + ); + return "fallback"; + } + // Default: ambiguous error — retain when fallback may duplicate a final + // edit that already landed or when the preview is not known-incomplete. params.log( - `telegram: ${args.laneName} preview final edit target missing with no alternate preview; falling back to standard send (${String(err)})`, - ); - return "fallback"; - } - if (isRecoverableTelegramNetworkError(err, { allowMessageMatch: true })) { - params.log( - `telegram: ${args.laneName} preview final edit may have landed despite network error; keeping existing preview (${String(err)})`, + `telegram: ${args.laneName} preview final edit failed with ambiguous error; keeping existing preview to avoid duplicate (${String(err)})`, ); params.markDelivered(); - return "retained"; + previewEditState.result = "retained"; + return "retain"; } - if (isTelegramClientRejection(err)) { - params.log( - `telegram: ${args.laneName} preview final edit rejected by Telegram (client error); falling back to standard send (${String(err)})`, - ); - return "fallback"; - } - if (isIncompleteFinalPreviewPrefix(args.targetPreviewText, args.text)) { - params.log( - `telegram: ${args.laneName} preview final edit failed and existing preview is an incomplete prefix; falling back to standard send (${String(err)})`, - ); - return "fallback"; - } - // Default: ambiguous error — retain when fallback may duplicate a final - // edit that already landed or when the preview is not known-incomplete. params.log( - `telegram: ${args.laneName} preview final edit failed with ambiguous error; keeping existing preview to avoid duplicate (${String(err)})`, + `telegram: ${args.laneName} preview ${args.context} edit failed; falling back to standard send (${String(err)})`, ); - params.markDelivered(); - return "retained"; - } - params.log( - `telegram: ${args.laneName} preview ${args.context} edit failed; falling back to standard send (${String(err)})`, - ); - return "fallback"; + return "fallback"; + }, + }); + + const delivered = await deliverWithFinalizableLivePreviewAdapter({ + kind: "final", + payload: { text: args.text }, + adapter, + deliverNormally: async () => false, + }); + if (delivered.kind === "preview-finalized" || previewEditState.result === "edited") { + return "edited"; } + if (delivered.kind === "preview-retained") { + return "retained"; + } + return "fallback"; }; const tryDeliverLongFinalThroughPreview = async (args: { lane: DraftLaneState; @@ -533,7 +611,9 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) { return undefined; } if (canEditViaPreview && shouldUseFreshFinalForPreview(lane, archivedPreview.visibleSinceMs)) { - const delivered = await params.sendPayload(params.applyTextToPayload(payload, text)); + const delivered = await params.sendPayload(params.applyTextToPayload(payload, text), { + durable: true, + }); if (delivered) { try { await params.deletePreviewMessage(archivedPreview.messageId); @@ -576,7 +656,9 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) { } // Send the replacement message first, then clean up the old preview. // This avoids the visual "disappear then reappear" flash. - const delivered = await params.sendPayload(params.applyTextToPayload(payload, text)); + const delivered = await params.sendPayload(params.applyTextToPayload(payload, text), { + durable: true, + }); // Once this archived preview is consumed by a fallback final send, delete it // regardless of deleteIfUnused. That flag only applies to unconsumed boundaries. if (delivered || archivedPreview.deleteIfUnused !== false) { @@ -640,7 +722,9 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) { } if (shouldUseFreshFinalForLane(lane)) { await params.stopDraftLane(lane); - const delivered = await params.sendPayload(params.applyTextToPayload(payload, text)); + const delivered = await params.sendPayload(params.applyTextToPayload(payload, text), { + durable: true, + }); if (delivered) { await clearActivePreviewAfterFreshFinal(lane, laneName); return result("sent"); @@ -656,18 +740,25 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) { skipRegressive: "existingOnly", context: "final", }); + const finalizedMessageId = previewMessageId ?? lane.stream?.messageId(); if (finalized === "edited") { markActivePreviewComplete(laneName); + if (typeof finalizedMessageId !== "number") { + return result("preview-retained"); + } return result("preview-finalized", { content: text, - messageId: previewMessageId ?? lane.stream?.messageId(), + messageId: finalizedMessageId, }); } if (finalized === "regressive-skipped") { markActivePreviewComplete(laneName); + if (typeof finalizedMessageId !== "number") { + return result("preview-retained"); + } return result("preview-finalized", { content: lane.lastPartialText, - messageId: previewMessageId ?? lane.stream?.messageId(), + messageId: finalizedMessageId, }); } if (finalized === "retained") { @@ -690,7 +781,9 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) { ); } await params.stopDraftLane(lane); - const delivered = await params.sendPayload(params.applyTextToPayload(payload, text)); + const delivered = await params.sendPayload(params.applyTextToPayload(payload, text), { + durable: true, + }); return delivered ? result("sent") : result("skipped"); } diff --git a/extensions/telegram/src/lane-delivery.test.ts b/extensions/telegram/src/lane-delivery.test.ts index e3bb070c6e5..3eb63ce1933 100644 --- a/extensions/telegram/src/lane-delivery.test.ts +++ b/extensions/telegram/src/lane-delivery.test.ts @@ -140,22 +140,43 @@ async function expectFinalEditFallbackToSend(params: { const result = await deliverFinalAnswer(params.harness, params.text); expect(result.kind).toBe("sent"); expect(params.harness.editPreview).toHaveBeenCalledTimes(1); - expect(params.harness.sendPayload).toHaveBeenCalledWith( - expect.objectContaining({ text: params.text }), - ); + expectSendPayloadWith(params.harness, { text: params.text }); expect(params.harness.log).toHaveBeenCalledWith( expect.stringContaining(params.expectedLogSnippet), ); } -function expectPreviewFinalized( - result: LaneDeliveryResult, -): Extract["delivery"] { +function expectSendPayloadWith( + harness: ReturnType, + expected: Partial, +) { + expect( + harness.sendPayload.mock.calls.some(([payload]) => + Object.entries(expected).every(([key, value]) => { + return (payload as Record)[key] === value; + }), + ), + ).toBe(true); +} + +function expectPreviewFinalized(result: LaneDeliveryResult): { + content: string; + messageId: number; +} { expect(result.kind).toBe("preview-finalized"); if (result.kind !== "preview-finalized") { throw new Error(`expected preview-finalized, got ${result.kind}`); } - return result.delivery; + expect(result.delivery.receipt).toEqual( + expect.objectContaining({ + primaryPlatformMessageId: String(result.delivery.messageId), + platformMessageIds: [String(result.delivery.messageId)], + }), + ); + return { + content: result.delivery.content, + messageId: result.delivery.messageId, + }; } describe("createLaneTextDeliverer", () => { @@ -290,9 +311,7 @@ describe("createLaneTextDeliverer", () => { const result = await deliverFinalAnswer(harness, HELLO_FINAL); expect(result.kind).toBe("sent"); - expect(harness.sendPayload).toHaveBeenCalledWith( - expect.objectContaining({ text: HELLO_FINAL }), - ); + expectSendPayloadWith(harness, { text: HELLO_FINAL }); expect(harness.log).toHaveBeenCalledWith( expect.stringContaining("failed before reaching Telegram; falling back"), ); @@ -320,9 +339,7 @@ describe("createLaneTextDeliverer", () => { expect(result.kind).toBe("sent"); expect(harness.editPreview).not.toHaveBeenCalled(); - expect(harness.sendPayload).toHaveBeenCalledWith( - expect.objectContaining({ text: "Short final" }), - ); + expectSendPayloadWith(harness, { text: "Short final" }); }); it("does not create a synthetic preview for final-only text", async () => { @@ -343,9 +360,7 @@ describe("createLaneTextDeliverer", () => { expect(answerStream.update).not.toHaveBeenCalled(); expect(answerStream.materialize).not.toHaveBeenCalled(); expect(harness.editPreview).not.toHaveBeenCalled(); - expect(harness.sendPayload).toHaveBeenCalledWith( - expect.objectContaining({ text: "Final only" }), - ); + expectSendPayloadWith(harness, { text: "Final only" }); }); it("keeps existing preview when final text regresses", async () => { @@ -381,7 +396,7 @@ describe("createLaneTextDeliverer", () => { expect(result.kind).toBe("sent"); expect(harness.editPreview).not.toHaveBeenCalled(); - expect(harness.sendPayload).toHaveBeenCalledWith(expect.objectContaining({ text: longText })); + expectSendPayloadWith(harness, { text: longText }); expect(harness.log).toHaveBeenCalledWith(expect.stringContaining("preview final too long")); }); @@ -429,9 +444,7 @@ describe("createLaneTextDeliverer", () => { expect(result.kind).toBe("sent"); expect(harness.stopDraftLane).toHaveBeenCalledTimes(1); - expect(harness.sendPayload).toHaveBeenCalledWith( - expect.objectContaining({ text: HELLO_FINAL }), - ); + expectSendPayloadWith(harness, { text: HELLO_FINAL }); expect(harness.editPreview).not.toHaveBeenCalled(); expect(harness.answer.stream?.clear).toHaveBeenCalledTimes(1); expect(harness.answer.stream?.forceNewMessage).toHaveBeenCalledTimes(1); @@ -486,9 +499,7 @@ describe("createLaneTextDeliverer", () => { const result = await deliverFinalAnswer(harness, HELLO_FINAL); expect(result.kind).toBe("sent"); - expect(harness.sendPayload).toHaveBeenCalledWith( - expect.objectContaining({ text: HELLO_FINAL }), - ); + expectSendPayloadWith(harness, { text: HELLO_FINAL }); expect(harness.editPreview).not.toHaveBeenCalled(); expect(harness.deletePreviewMessage).toHaveBeenCalledWith(222); }); @@ -546,9 +557,7 @@ describe("createLaneTextDeliverer", () => { const result = await deliverFinalAnswer(harness, "Complete final answer"); expect(harness.editPreview).toHaveBeenCalledTimes(1); - expect(harness.sendPayload).toHaveBeenCalledWith( - expect.objectContaining({ text: "Complete final answer" }), - ); + expectSendPayloadWith(harness, { text: "Complete final answer" }); expect(result.kind).toBe("sent"); expect(harness.deletePreviewMessage).toHaveBeenCalledWith(5555); }); @@ -640,9 +649,7 @@ describe("createLaneTextDeliverer", () => { const result = await deliverFinalAnswer(harness, HELLO_FINAL); expect(result.kind).toBe("sent"); - expect(harness.sendPayload).toHaveBeenCalledWith( - expect.objectContaining({ text: HELLO_FINAL }), - ); + expectSendPayloadWith(harness, { text: HELLO_FINAL }); }); it("retains when sendMayHaveLanded is true and a prior preview was visible", async () => { @@ -681,9 +688,10 @@ describe("createLaneTextDeliverer", () => { }); expect(result.kind).toBe("sent"); - expect(harness.sendPayload).toHaveBeenCalledWith( - expect.objectContaining({ text: "Final with media", mediaUrl: "file:///tmp/example.png" }), - ); + expectSendPayloadWith(harness, { + text: "Final with media", + mediaUrl: "file:///tmp/example.png", + }); expect(harness.deletePreviewMessage).toHaveBeenCalledWith(4444); }); }); diff --git a/extensions/telegram/src/outbound-adapter.test.ts b/extensions/telegram/src/outbound-adapter.test.ts index d66cce66387..351235449b5 100644 --- a/extensions/telegram/src/outbound-adapter.test.ts +++ b/extensions/telegram/src/outbound-adapter.test.ts @@ -1,3 +1,4 @@ +import { verifyDurableFinalCapabilityProofs } from "openclaw/plugin-sdk/channel-message"; import { beforeEach, describe, expect, it, vi } from "vitest"; const sendMessageTelegramMock = vi.fn(); @@ -123,6 +124,28 @@ describe("telegramOutbound", () => { expect(result).toEqual({ channel: "telegram", messageId: "tg-buttons", chatId: "12345" }); }); + it("forwards silent delivery options to Telegram sends", async () => { + sendMessageTelegramMock.mockResolvedValueOnce({ messageId: "tg-silent", chatId: "12345" }); + + const result = await telegramOutbound.sendPayload!({ + cfg: {} as never, + to: "12345", + text: "quiet", + payload: { text: "quiet" }, + silent: true, + deps: { sendTelegram: sendMessageTelegramMock }, + }); + + expect(sendMessageTelegramMock).toHaveBeenCalledWith( + "12345", + "quiet", + expect.objectContaining({ + silent: true, + }), + ); + expect(result).toEqual({ channel: "telegram", messageId: "tg-silent", chatId: "12345" }); + }); + it("forwards audioAsVoice payload media to Telegram voice sends", async () => { sendMessageTelegramMock.mockResolvedValueOnce({ messageId: "tg-voice", chatId: "12345" }); @@ -149,6 +172,116 @@ describe("telegramOutbound", () => { expect(result).toEqual({ channel: "telegram", messageId: "tg-voice", chatId: "12345" }); }); + it("backs declared durable final capabilities with delivery proofs", async () => { + const proveText = async () => { + sendMessageTelegramMock.mockResolvedValueOnce({ messageId: "tg-text", chatId: "12345" }); + await telegramOutbound.sendText!({ + cfg: {} as never, + to: "12345", + text: "hello", + deps: { sendTelegram: sendMessageTelegramMock }, + }); + expect(sendMessageTelegramMock).toHaveBeenLastCalledWith( + "12345", + "hello", + expect.objectContaining({ textMode: "html" }), + ); + }; + const proveMedia = async () => { + sendMessageTelegramMock.mockResolvedValueOnce({ messageId: "tg-media", chatId: "12345" }); + await telegramOutbound.sendMedia!({ + cfg: {} as never, + to: "12345", + text: "caption", + mediaUrl: "https://example.com/a.png", + deps: { sendTelegram: sendMessageTelegramMock }, + }); + expect(sendMessageTelegramMock).toHaveBeenLastCalledWith( + "12345", + "caption", + expect.objectContaining({ mediaUrl: "https://example.com/a.png" }), + ); + }; + const provePayload = async () => { + sendMessageTelegramMock.mockResolvedValueOnce({ messageId: "tg-payload", chatId: "12345" }); + await telegramOutbound.sendPayload!({ + cfg: {} as never, + to: "12345", + text: "", + payload: { text: "payload" }, + deps: { sendTelegram: sendMessageTelegramMock }, + }); + expect(sendMessageTelegramMock).toHaveBeenLastCalledWith( + "12345", + "payload", + expect.any(Object), + ); + }; + const proveReplyThreadSilent = async () => { + sendMessageTelegramMock.mockResolvedValueOnce({ messageId: "tg-thread", chatId: "12345" }); + await telegramOutbound.sendText!({ + cfg: {} as never, + to: "12345", + text: "threaded", + replyToId: "900", + threadId: "12", + silent: true, + deps: { sendTelegram: sendMessageTelegramMock }, + }); + expect(sendMessageTelegramMock).toHaveBeenLastCalledWith( + "12345", + "threaded", + expect.objectContaining({ + replyToMessageId: 900, + messageThreadId: 12, + silent: true, + }), + ); + }; + const proveBatch = async () => { + sendMessageTelegramMock + .mockResolvedValueOnce({ messageId: "tg-batch-1", chatId: "12345" }) + .mockResolvedValueOnce({ messageId: "tg-batch-2", chatId: "12345" }); + await telegramOutbound.sendPayload!({ + cfg: {} as never, + to: "12345", + text: "", + payload: { + text: "batch", + mediaUrls: ["https://example.com/a.png", "https://example.com/b.png"], + }, + deps: { sendTelegram: sendMessageTelegramMock }, + }); + expect(sendMessageTelegramMock).toHaveBeenCalledWith( + "12345", + "batch", + expect.objectContaining({ mediaUrl: "https://example.com/a.png" }), + ); + expect(sendMessageTelegramMock).toHaveBeenCalledWith( + "12345", + "", + expect.objectContaining({ mediaUrl: "https://example.com/b.png" }), + ); + }; + + await verifyDurableFinalCapabilityProofs({ + adapterName: "telegramOutbound", + capabilities: telegramOutbound.deliveryCapabilities?.durableFinal, + proofs: { + text: proveText, + media: proveMedia, + payload: provePayload, + silent: proveReplyThreadSilent, + replyTo: proveReplyThreadSilent, + thread: proveReplyThreadSilent, + messageSendingHooks: () => { + expect(telegramOutbound.sendText).toBeTypeOf("function"); + }, + batch: proveBatch, + }, + }); + }); + it("passes delivery pin notify requests to Telegram pinning", async () => { pinMessageTelegramMock.mockResolvedValueOnce({ ok: true, messageId: "tg-1", chatId: "12345" }); diff --git a/extensions/telegram/src/outbound-adapter.ts b/extensions/telegram/src/outbound-adapter.ts index 6c4bc2c4d10..074ec67c7f3 100644 --- a/extensions/telegram/src/outbound-adapter.ts +++ b/extensions/telegram/src/outbound-adapter.ts @@ -42,6 +42,7 @@ async function resolveTelegramSendContext(params: { accountId?: string | null; replyToId?: string | null; threadId?: string | number | null; + silent?: boolean; gatewayClientScopes?: readonly string[]; }): Promise<{ send: TelegramSendFn; @@ -52,6 +53,7 @@ async function resolveTelegramSendContext(params: { messageThreadId?: number; replyToMessageId?: number; accountId?: string; + silent?: boolean; gatewayClientScopes?: readonly string[]; }; }> { @@ -67,6 +69,7 @@ async function resolveTelegramSendContext(params: { messageThreadId: parseTelegramThreadId(params.threadId), replyToMessageId: parseTelegramReplyToMessageId(params.replyToId), accountId: params.accountId ?? undefined, + silent: params.silent, gatewayClientScopes: params.gatewayClientScopes, }, }; @@ -135,6 +138,17 @@ export const telegramOutbound: ChannelOutboundAdapter = { }, deliveryCapabilities: { pin: true, + durableFinal: { + text: true, + media: true, + payload: true, + silent: true, + replyTo: true, + thread: true, + nativeQuote: false, + messageSendingHooks: true, + batch: true, + }, }, renderPresentation: ({ payload, presentation }) => ({ ...payload, @@ -161,6 +175,7 @@ export const telegramOutbound: ChannelOutboundAdapter = { deps, replyToId, threadId, + silent, gatewayClientScopes, }) => { const { send, baseOpts } = await resolveTelegramSendContext({ @@ -169,6 +184,7 @@ export const telegramOutbound: ChannelOutboundAdapter = { accountId, replyToId, threadId, + silent, gatewayClientScopes, }); return await send(to, text, { @@ -187,6 +203,7 @@ export const telegramOutbound: ChannelOutboundAdapter = { replyToId, threadId, forceDocument, + silent, gatewayClientScopes, }) => { const { send, baseOpts } = await resolveTelegramSendContext({ @@ -195,6 +212,7 @@ export const telegramOutbound: ChannelOutboundAdapter = { accountId, replyToId, threadId, + silent, gatewayClientScopes, }); return await send(to, text, { @@ -217,6 +235,7 @@ export const telegramOutbound: ChannelOutboundAdapter = { replyToId, threadId, forceDocument, + silent, gatewayClientScopes, }) => { const { send, baseOpts } = await resolveTelegramSendContext({ @@ -225,6 +244,7 @@ export const telegramOutbound: ChannelOutboundAdapter = { accountId, replyToId, threadId, + silent, gatewayClientScopes, }); const result = await sendTelegramPayloadMessages({ diff --git a/extensions/tlon/src/channel.message-adapter.test.ts b/extensions/tlon/src/channel.message-adapter.test.ts new file mode 100644 index 00000000000..93e7f4855f6 --- /dev/null +++ b/extensions/tlon/src/channel.message-adapter.test.ts @@ -0,0 +1,125 @@ +import { verifyChannelMessageAdapterCapabilityProofs } from "openclaw/plugin-sdk/channel-message"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../runtime-api.js"; + +const mocks = vi.hoisted(() => ({ + sendText: vi.fn(), + sendMedia: vi.fn(), +})); + +vi.mock("./channel.runtime.js", () => ({ + tlonRuntimeOutbound: { + sendText: mocks.sendText, + sendMedia: mocks.sendMedia, + }, +})); + +import { tlonPlugin } from "./channel.js"; + +const cfg = { + channels: { + tlon: { + ship: "~zod", + url: "https://zod.example", + code: "lidlut-tabwed-pillex-ridrup", + }, + }, +} as OpenClawConfig; + +describe("tlon channel message adapter", () => { + beforeEach(() => { + mocks.sendText.mockReset(); + mocks.sendMedia.mockReset(); + mocks.sendText.mockResolvedValue({ + channel: "tlon", + messageId: "~zod/1700000000000", + conversationId: "~nec/general", + }); + mocks.sendMedia.mockResolvedValue({ + channel: "tlon", + messageId: "~zod/1700000000001", + conversationId: "~nec/general", + }); + }); + + it("backs declared durable-final capabilities with outbound send proofs", async () => { + const adapter = tlonPlugin.message; + expect(adapter).toBeDefined(); + + const proveText = async () => { + mocks.sendText.mockClear(); + const result = await adapter!.send!.text!({ + cfg, + to: "chat/~nec/general", + text: "hello", + accountId: "default", + }); + expect(mocks.sendText).toHaveBeenLastCalledWith( + expect.objectContaining({ + cfg, + to: "chat/~nec/general", + text: "hello", + accountId: "default", + }), + ); + expect(result.receipt.platformMessageIds).toEqual(["~zod/1700000000000"]); + expect(result.receipt.parts[0]?.kind).toBe("text"); + }; + + const proveMedia = async () => { + mocks.sendMedia.mockClear(); + const result = await adapter!.send!.media!({ + cfg, + to: "chat/~nec/general", + text: "image", + mediaUrl: "https://example.com/image.png", + accountId: "default", + }); + expect(mocks.sendMedia).toHaveBeenLastCalledWith( + expect.objectContaining({ + cfg, + to: "chat/~nec/general", + text: "image", + mediaUrl: "https://example.com/image.png", + accountId: "default", + }), + ); + expect(result.receipt.platformMessageIds).toEqual(["~zod/1700000000001"]); + expect(result.receipt.parts[0]?.kind).toBe("media"); + }; + + const proveReplyThread = async () => { + mocks.sendText.mockClear(); + const result = await adapter!.send!.text!({ + cfg, + to: "chat/~nec/general", + text: "threaded", + accountId: "default", + replyToId: "1700000000000", + threadId: "1700000000000", + }); + expect(mocks.sendText).toHaveBeenLastCalledWith( + expect.objectContaining({ + replyToId: "1700000000000", + threadId: "1700000000000", + }), + ); + expect(result.receipt.replyToId).toBe("1700000000000"); + expect(result.receipt.threadId).toBe("1700000000000"); + }; + + await verifyChannelMessageAdapterCapabilityProofs({ + adapterName: "tlonMessageAdapter", + adapter: adapter!, + proofs: { + text: proveText, + media: proveMedia, + replyTo: proveReplyThread, + thread: proveReplyThread, + messageSendingHooks: () => { + expect(adapter!.send!.text).toBeTypeOf("function"); + }, + }, + }); + }); +}); diff --git a/extensions/tlon/src/channel.runtime.ts b/extensions/tlon/src/channel.runtime.ts index 74dd1ac62f9..d9dcf78b2cc 100644 --- a/extensions/tlon/src/channel.runtime.ts +++ b/extensions/tlon/src/channel.runtime.ts @@ -138,6 +138,15 @@ export const tlonRuntimeOutbound: ChannelOutboundAdapter = { deliveryMode: "direct", textChunkLimit: 10000, resolveTarget: ({ to }) => resolveTlonOutboundTarget(to), + deliveryCapabilities: { + durableFinal: { + text: true, + media: true, + replyTo: true, + thread: true, + messageSendingHooks: true, + }, + }, sendText: async ({ cfg, to, text, accountId, replyToId, threadId }) => { const { account, parsed } = resolveOutboundContext({ cfg, accountId, to }); return withHttpPokeAccountApi(account, async (api) => { @@ -182,6 +191,7 @@ export const tlonRuntimeOutbound: ChannelOutboundAdapter = { fromShip, toShip: parsed.ship, story, + kind: "media", }); } return await sendGroupMessageWithStory({ @@ -191,6 +201,7 @@ export const tlonRuntimeOutbound: ChannelOutboundAdapter = { channelName: parsed.channelName, story, replyToId: resolveReplyId(replyToId, threadId), + kind: "media", }); }); }, diff --git a/extensions/tlon/src/channel.ts b/extensions/tlon/src/channel.ts index 5b101393232..a2e4c5dcd84 100644 --- a/extensions/tlon/src/channel.ts +++ b/extensions/tlon/src/channel.ts @@ -2,6 +2,8 @@ import { describeAccountSnapshot } from "openclaw/plugin-sdk/account-helpers"; import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id"; import { createHybridChannelConfigAdapter } from "openclaw/plugin-sdk/channel-config-helpers"; import { createChatChannelPlugin, type ChannelPlugin } from "openclaw/plugin-sdk/channel-core"; +import { createChannelMessageAdapterFromOutbound } from "openclaw/plugin-sdk/channel-message"; +import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-send-result"; import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; import { createRuntimeOutboundDelegates } from "openclaw/plugin-sdk/outbound-runtime"; import { @@ -58,6 +60,31 @@ const tlonConfigAdapter = createHybridChannelConfigAdapter({ allowFrom.map((entry) => normalizeShip(String(entry))).filter(Boolean), }); +const tlonChannelOutbound: ChannelOutboundAdapter = { + deliveryMode: "direct", + textChunkLimit: 10000, + resolveTarget: ({ to }) => resolveTlonOutboundTarget(to), + deliveryCapabilities: { + durableFinal: { + text: true, + media: true, + replyTo: true, + thread: true, + messageSendingHooks: true, + }, + }, + ...createRuntimeOutboundDelegates({ + getRuntime: loadTlonChannelRuntime, + sendText: { resolve: (runtime) => runtime.tlonRuntimeOutbound.sendText }, + sendMedia: { resolve: (runtime) => runtime.tlonRuntimeOutbound.sendMedia }, + }), +}; + +const tlonMessageAdapter = createChannelMessageAdapterFromOutbound({ + id: TLON_CHANNEL_ID, + outbound: tlonChannelOutbound, +}); + export const tlonPlugin = createChatChannelPlugin({ base: { id: TLON_CHANNEL_ID, @@ -113,6 +140,7 @@ export const tlonPlugin = createChatChannelPlugin({ }, resolveOutboundSessionRoute: (params) => resolveTlonOutboundSessionRoute(params), }, + message: tlonMessageAdapter, status: createComputedAccountStatusAdapter>({ defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID), collectStatusIssues: (accounts) => { @@ -160,14 +188,5 @@ export const tlonPlugin = createChatChannelPlugin({ await (await loadTlonChannelRuntime()).startTlonGatewayAccount(ctx), }, }, - outbound: { - deliveryMode: "direct", - textChunkLimit: 10000, - resolveTarget: ({ to }) => resolveTlonOutboundTarget(to), - ...createRuntimeOutboundDelegates({ - getRuntime: loadTlonChannelRuntime, - sendText: { resolve: (runtime) => runtime.tlonRuntimeOutbound.sendText }, - sendMedia: { resolve: (runtime) => runtime.tlonRuntimeOutbound.sendMedia }, - }), - }, + outbound: tlonChannelOutbound, }); diff --git a/extensions/tlon/src/monitor/index.ts b/extensions/tlon/src/monitor/index.ts index eedf086f5c6..84943b99734 100644 --- a/extensions/tlon/src/monitor/index.ts +++ b/extensions/tlon/src/monitor/index.ts @@ -580,6 +580,38 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise { + const replyText = payload.text; + if (!replyText) { + return payload; + } + if (!effectiveShowModelSig) { + return payload; + } + const extPayload = payload as { + metadata?: { model?: string }; + model?: string; + }; + const defaultModel = cfg.agents?.defaults?.model; + const modelInfo = + extPayload.metadata?.model || + extPayload.model || + (typeof defaultModel === "string" ? defaultModel : defaultModel?.primary); + return { + ...payload, + text: `${replyText}\n\n_[Generated by ${formatModelName(modelInfo)}]_`, + }; + }; + + const rememberThreadParticipation = (result: { visibleReplySent?: boolean } | void) => { + if (!isGroup || !groupChannel || !parentId || result?.visibleReplySent === false) { + return; + } + participatedThreads.add(parentId); + runtime.log?.(`[tlon] Now tracking thread for future replies: ${parentId}`); + }; await core.channel.turn.run({ channel: "tlon", @@ -606,31 +638,24 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise ({ + to: deliveryTarget, + replyToId: parentId ?? undefined, + threadId: parentId ?? undefined, + }) + : false, deliver: async (payload: ReplyPayload) => { - let replyText = payload.text; + const replyText = payload.text; if (!replyText) { - return; - } - - // Use settings store value if set, otherwise fall back to file config - const showSignature = effectiveShowModelSig; - if (showSignature) { - const extPayload = payload as { - metadata?: { model?: string }; - model?: string; - }; - const defaultModel = cfg.agents?.defaults?.model; - const modelInfo = - extPayload.metadata?.model || - extPayload.model || - (typeof defaultModel === "string" ? defaultModel : defaultModel?.primary); - replyText = `${replyText}\n\n_[Generated by ${formatModelName(modelInfo)}]_`; + return { visibleReplySent: false }; } if (isGroup && groupChannel) { const parsed = parseChannelNest(groupChannel); if (!parsed) { - return; + return { visibleReplySent: false }; } await sendGroupMessage({ api: api, @@ -640,19 +665,19 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise { + rememberThreadParticipation(result); }, onError: (err, info) => { const dispatchDuration = Date.now() - dispatchStartTime; @@ -677,7 +702,6 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise(groupChannels); - const _watchedDMs = new Set(); const refreshWatchedChannels = async (): Promise => { const discoveredChannels = await fetchAllChannels(api, runtime); diff --git a/extensions/tlon/src/urbit/send.test.ts b/extensions/tlon/src/urbit/send.test.ts index aad3a674ed1..5d6dd2f388c 100644 --- a/extensions/tlon/src/urbit/send.test.ts +++ b/extensions/tlon/src/urbit/send.test.ts @@ -34,5 +34,42 @@ describe("sendDm", () => { expect(scot).toHaveBeenCalledWith("ud", 123n); expect(poke).toHaveBeenCalledTimes(1); expect(result.messageId).toBe("~zod/mocked-ud"); + expect(result.receipt.primaryPlatformMessageId).toBe("~zod/mocked-ud"); + }); + + it("passes numeric group reply ids through aura formatting", async () => { + const { sendGroupMessage } = await import("./send.js"); + const aura = await import("@urbit/aura"); + const scot = vi.mocked(aura.scot); + scot.mockReturnValueOnce("~2024.1.1"); + vi.spyOn(Date, "now").mockReturnValue(1_700_000_000_000); + const poke = vi.fn(async () => ({})); + + const result = await sendGroupMessage({ + api: { poke }, + fromShip: "~zod", + hostShip: "~nec", + channelName: "general", + text: "threaded", + replyToId: "1700000000000", + }); + + expect(scot).toHaveBeenCalledWith("ud", 1_700_000_000_000n); + expect(poke).toHaveBeenCalledWith( + expect.objectContaining({ + json: expect.objectContaining({ + channel: expect.objectContaining({ + action: { + post: { + reply: expect.objectContaining({ + id: "~2024.1.1", + }), + }, + }, + }), + }), + }), + ); + expect(result.receipt.threadId).toBe("~nec/general"); }); }); diff --git a/extensions/tlon/src/urbit/send.ts b/extensions/tlon/src/urbit/send.ts index f8122f99e68..68c433a258f 100644 --- a/extensions/tlon/src/urbit/send.ts +++ b/extensions/tlon/src/urbit/send.ts @@ -1,4 +1,8 @@ import { scot, da } from "@urbit/aura"; +import { + createMessageReceiptFromOutboundResults, + type MessageReceiptPartKind, +} from "openclaw/plugin-sdk/channel-message"; import { markdownToStory, createImageBlock, isImageUrl, type Story } from "./story.js"; export type TlonPokeApi = { @@ -17,14 +21,39 @@ type SendStoryParams = { fromShip: string; toShip: string; story: Story; + kind?: MessageReceiptPartKind; }; +function createTlonSendReceipt(params: { + messageId: string; + conversationId: string; + kind: MessageReceiptPartKind; +}) { + return createMessageReceiptFromOutboundResults({ + results: [ + { + channel: "tlon", + messageId: params.messageId, + conversationId: params.conversationId, + }, + ], + threadId: params.conversationId, + kind: params.kind, + }); +} + export async function sendDm({ api, fromShip, toShip, text }: SendTextParams) { const story: Story = markdownToStory(text); - return sendDmWithStory({ api, fromShip, toShip, story }); + return sendDmWithStory({ api, fromShip, toShip, story, kind: "text" }); } -export async function sendDmWithStory({ api, fromShip, toShip, story }: SendStoryParams) { +export async function sendDmWithStory({ + api, + fromShip, + toShip, + story, + kind = "unknown", +}: SendStoryParams) { const sentAt = Date.now(); const idUd = scot("ud", da.fromUnix(sentAt)); const id = `${fromShip}/${idUd}`; @@ -52,7 +81,11 @@ export async function sendDmWithStory({ api, fromShip, toShip, story }: SendStor json: action, }); - return { channel: "tlon", messageId: id }; + return { + channel: "tlon", + messageId: id, + receipt: createTlonSendReceipt({ messageId: id, conversationId: toShip, kind }), + }; } type SendGroupParams = { @@ -71,6 +104,7 @@ type SendGroupStoryParams = { channelName: string; story: Story; replyToId?: string | null; + kind?: MessageReceiptPartKind; }; export async function sendGroupMessage({ @@ -82,7 +116,15 @@ export async function sendGroupMessage({ replyToId, }: SendGroupParams) { const story: Story = markdownToStory(text); - return sendGroupMessageWithStory({ api, fromShip, hostShip, channelName, story, replyToId }); + return sendGroupMessageWithStory({ + api, + fromShip, + hostShip, + channelName, + story, + replyToId, + kind: "text", + }); } export async function sendGroupMessageWithStory({ @@ -92,6 +134,7 @@ export async function sendGroupMessageWithStory({ channelName, story, replyToId, + kind = "unknown", }: SendGroupStoryParams) { const sentAt = Date.now(); @@ -148,7 +191,16 @@ export async function sendGroupMessageWithStory({ json: action, }); - return { channel: "tlon", messageId: `${fromShip}/${sentAt}` }; + const messageId = `${fromShip}/${sentAt}`; + return { + channel: "tlon", + messageId, + receipt: createTlonSendReceipt({ + messageId, + conversationId: `${hostShip}/${channelName}`, + kind, + }), + }; } /** diff --git a/extensions/twitch/src/monitor.ts b/extensions/twitch/src/monitor.ts index 5cf976bc460..683e24a8e24 100644 --- a/extensions/twitch/src/monitor.ts +++ b/extensions/twitch/src/monitor.ts @@ -5,7 +5,6 @@ * resolves agent routes, and handles replies. */ -import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline"; import type { MarkdownTableMode, OpenClawConfig } from "openclaw/plugin-sdk/config-types"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; @@ -128,12 +127,6 @@ async function processTwitchMessage(params: { channel: "twitch", accountId, }); - const { onModelSelected, ...replyPipeline } = createChannelReplyPipeline({ - cfg, - agentId: route.agentId, - channel: "twitch", - accountId, - }); return { cfg, channel: "twitch", @@ -146,8 +139,11 @@ async function processTwitchMessage(params: { dispatchReplyWithBufferedBlockDispatcher: core.channel.reply.dispatchReplyWithBufferedBlockDispatcher, delivery: { + durable: () => ({ + to: `twitch:channel:${message.channel}`, + }), deliver: async (payload) => { - await deliverTwitchReply({ + return await deliverTwitchReply({ payload, channel: message.channel, account, @@ -155,17 +151,18 @@ async function processTwitchMessage(params: { config, tableMode, runtime, - statusSink, }); }, + onDelivered: (_payload, _info, result) => { + if (result?.visibleReplySent !== false) { + statusSink?.({ lastOutboundAt: Date.now() }); + } + }, onError: (err, info) => { runtime.error?.(`Twitch ${info.kind} reply failed: ${String(err)}`); }, }, - dispatcherOptions: replyPipeline, - replyOptions: { - onModelSelected, - }, + replyPipeline: {}, record: { onRecordError: (err) => { runtime.error?.(`Failed updating session meta: ${String(err)}`); @@ -188,9 +185,8 @@ async function deliverTwitchReply(params: { config: unknown; tableMode: MarkdownTableMode; runtime: TwitchRuntimeEnv; - statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void; -}): Promise { - const { payload, channel, account, accountId, config, runtime, statusSink } = params; +}): Promise<{ visibleReplySent: boolean }> { + const { payload, channel, account, accountId, config, runtime } = params; try { const clientManager = getOrCreateClientManager(accountId, { @@ -207,21 +203,22 @@ async function deliverTwitchReply(params: { ); if (!client) { runtime.error?.(`No client available for sending reply`); - return; + return { visibleReplySent: false }; } // Send the reply if (!payload.text) { runtime.error?.(`No text to send in reply payload`); - return; + return { visibleReplySent: false }; } const textToSend = stripMarkdownForTwitch(payload.text); await client.say(channel, textToSend); - statusSink?.({ lastOutboundAt: Date.now() }); + return { visibleReplySent: true }; } catch (err) { runtime.error?.(`Failed to send reply: ${String(err)}`); + return { visibleReplySent: false }; } } diff --git a/extensions/twitch/src/outbound.test.ts b/extensions/twitch/src/outbound.test.ts index 693e843309d..2df4842bcc9 100644 --- a/extensions/twitch/src/outbound.test.ts +++ b/extensions/twitch/src/outbound.test.ts @@ -9,9 +9,13 @@ * - Abort signal handling */ +import { + createMessageReceiptFromOutboundResults, + verifyChannelMessageAdapterCapabilityProofs, +} from "openclaw/plugin-sdk/channel-message"; import { describe, expect, it, vi } from "vitest"; import { resolveTwitchAccountContext } from "./config.js"; -import { twitchOutbound } from "./outbound.js"; +import { twitchMessageAdapter, twitchOutbound } from "./outbound.js"; import { BASE_TWITCH_TEST_ACCOUNT, installTwitchTestHooks, @@ -61,6 +65,19 @@ function expectTargetError( expect(result.error.message).toContain(expectedMessage); } +function twitchTestReceipt(messageId: string) { + return createMessageReceiptFromOutboundResults({ + results: [ + { + channel: "twitch", + conversationId: "testchannel", + messageId, + }, + ], + kind: "text", + }); +} + describe("outbound", () => { const mockAccount = { ...BASE_TWITCH_TEST_ACCOUNT, @@ -102,6 +119,64 @@ describe("outbound", () => { expect(chunker("a".repeat(600), 500)).toEqual(["a".repeat(500), "a".repeat(100)]); }); + + it("declares message adapter durable text and media with receipt proofs", async () => { + const { sendMessageTwitchInternal } = await import("./send.js"); + + setupAccountContext(); + vi.mocked(sendMessageTwitchInternal).mockResolvedValue({ + ok: true, + messageId: "twitch-msg-123", + receipt: twitchTestReceipt("twitch-msg-123"), + }); + + await expect( + verifyChannelMessageAdapterCapabilityProofs({ + adapterName: "twitch", + adapter: twitchMessageAdapter, + proofs: { + text: async () => { + const result = await twitchMessageAdapter.send?.text?.({ + cfg: mockConfig, + to: "#testchannel", + text: "Hello Twitch!", + accountId: "default", + }); + expect(result?.receipt?.platformMessageIds).toEqual(["twitch-msg-123"]); + }, + media: async () => { + const result = await twitchMessageAdapter.send?.media?.({ + cfg: mockConfig, + to: "#testchannel", + text: "image", + mediaUrl: "https://example.com/image.png", + accountId: "default", + }); + expect(result?.receipt?.platformMessageIds).toEqual(["twitch-msg-123"]); + expect(sendMessageTwitchInternal).toHaveBeenLastCalledWith( + "testchannel", + "image https://example.com/image.png", + mockConfig, + "default", + true, + console, + ); + }, + messageSendingHooks: () => { + expect(twitchMessageAdapter.durableFinal?.capabilities?.messageSendingHooks).toBe( + true, + ); + }, + }, + }), + ).resolves.toEqual( + expect.arrayContaining([ + { capability: "text", status: "verified" }, + { capability: "media", status: "verified" }, + { capability: "messageSendingHooks", status: "verified" }, + ]), + ); + }); }); describe("resolveTarget", () => { @@ -230,6 +305,7 @@ describe("outbound", () => { vi.mocked(sendMessageTwitchInternal).mockResolvedValue({ ok: true, messageId: "twitch-msg-123", + receipt: twitchTestReceipt("twitch-msg-123"), }); const result = await twitchOutbound.sendText!({ @@ -241,6 +317,7 @@ describe("outbound", () => { expect(result.channel).toBe("twitch"); expect(result.messageId).toBe("twitch-msg-123"); + expect(result.receipt?.platformMessageIds).toEqual(["twitch-msg-123"]); expect(sendMessageTwitchInternal).toHaveBeenCalledWith( "testchannel", "Hello Twitch!", @@ -286,6 +363,7 @@ describe("outbound", () => { vi.mocked(sendMessageTwitchInternal).mockResolvedValue({ ok: true, messageId: "msg-456", + receipt: twitchTestReceipt("msg-456"), }); await twitchOutbound.sendText!({ @@ -332,6 +410,7 @@ describe("outbound", () => { vi.mocked(sendMessageTwitchInternal).mockResolvedValue({ ok: true, messageId: "msg-secondary", + receipt: twitchTestReceipt("msg-secondary"), }); await twitchOutbound.sendText!({ @@ -378,6 +457,7 @@ describe("outbound", () => { vi.mocked(sendMessageTwitchInternal).mockResolvedValue({ ok: false, messageId: "failed-msg", + receipt: createMessageReceiptFromOutboundResults({ results: [] }), error: "Connection lost", }); @@ -400,6 +480,7 @@ describe("outbound", () => { vi.mocked(sendMessageTwitchInternal).mockResolvedValue({ ok: true, messageId: "media-msg-123", + receipt: twitchTestReceipt("media-msg-123"), }); const result = await twitchOutbound.sendMedia!({ @@ -412,6 +493,7 @@ describe("outbound", () => { expect(result.channel).toBe("twitch"); expect(result.messageId).toBe("media-msg-123"); + expect(result.receipt?.platformMessageIds).toEqual(["media-msg-123"]); expect(sendMessageTwitchInternal).toHaveBeenCalledWith( expect.anything(), "Check this: https://example.com/image.png", @@ -429,6 +511,7 @@ describe("outbound", () => { vi.mocked(sendMessageTwitchInternal).mockResolvedValue({ ok: true, messageId: "media-only-msg", + receipt: twitchTestReceipt("media-only-msg"), }); await twitchOutbound.sendMedia!({ diff --git a/extensions/twitch/src/outbound.ts b/extensions/twitch/src/outbound.ts index bd51c18ad8b..479f72e86a3 100644 --- a/extensions/twitch/src/outbound.ts +++ b/extensions/twitch/src/outbound.ts @@ -5,6 +5,12 @@ * Supports text and media (URL) sending with markdown stripping and chunking. */ +import { + createMessageReceiptFromOutboundResults, + defineChannelMessageAdapter, + type ChannelMessageSendResult, + type MessageReceiptPartKind, +} from "openclaw/plugin-sdk/channel-message"; import { resolveTwitchAccountContext } from "./config.js"; import { sendMessageTwitchInternal } from "./send.js"; import type { @@ -25,6 +31,14 @@ export const twitchOutbound: ChannelOutboundAdapter = { /** Direct delivery mode - messages are sent immediately */ deliveryMode: "direct", + deliveryCapabilities: { + durableFinal: { + text: true, + media: true, + messageSendingHooks: true, + }, + }, + /** Twitch chat message limit is 500 characters */ textChunkLimit: 500, @@ -143,6 +157,7 @@ export const twitchOutbound: ChannelOutboundAdapter = { return { channel: "twitch", messageId: result.messageId, + receipt: result.receipt, timestamp: Date.now(), }; }, @@ -184,3 +199,44 @@ export const twitchOutbound: ChannelOutboundAdapter = { }); }, }; + +function toTwitchMessageSendResult( + result: OutboundDeliveryResult, + kind: MessageReceiptPartKind, +): ChannelMessageSendResult { + const receipt = + result.receipt ?? + createMessageReceiptFromOutboundResults({ + results: result.messageId ? [{ channel: "twitch", messageId: result.messageId }] : [], + kind, + }); + return { + messageId: result.messageId || receipt.primaryPlatformMessageId, + receipt, + }; +} + +export const twitchMessageAdapter = defineChannelMessageAdapter({ + id: "twitch", + durableFinal: { + capabilities: { + text: true, + media: true, + messageSendingHooks: true, + }, + }, + send: { + text: async (ctx) => { + if (!twitchOutbound.sendText) { + throw new Error("Twitch text sending is not available."); + } + return toTwitchMessageSendResult(await twitchOutbound.sendText(ctx), "text"); + }, + media: async (ctx) => { + if (!twitchOutbound.sendMedia) { + throw new Error("Twitch media sending is not available."); + } + return toTwitchMessageSendResult(await twitchOutbound.sendMedia(ctx), "media"); + }, + }, +}); diff --git a/extensions/twitch/src/plugin.ts b/extensions/twitch/src/plugin.ts index aa81e28aaa6..f7b9be934f5 100644 --- a/extensions/twitch/src/plugin.ts +++ b/extensions/twitch/src/plugin.ts @@ -29,7 +29,7 @@ import { resolveTwitchAccountContext, resolveTwitchSnapshotAccountId, } from "./config.js"; -import { twitchOutbound } from "./outbound.js"; +import { twitchMessageAdapter, twitchOutbound } from "./outbound.js"; import { probeTwitch } from "./probe.js"; import { resolveTwitchTargets } from "./resolver.js"; import { twitchSetupAdapter, twitchSetupWizard } from "./setup-surface.js"; @@ -78,6 +78,7 @@ export const twitchPlugin: ChannelPlugin = capabilities: { chatTypes: ["group"], }, + message: twitchMessageAdapter, configSchema: buildChannelConfigSchema(TwitchConfigSchema), config: { listAccountIds: (cfg: OpenClawConfig): string[] => listAccountIds(cfg), diff --git a/extensions/twitch/src/send.test.ts b/extensions/twitch/src/send.test.ts index cf5dede6f20..a2d747e59eb 100644 --- a/extensions/twitch/src/send.test.ts +++ b/extensions/twitch/src/send.test.ts @@ -104,6 +104,21 @@ describe("send", () => { expect(result.ok).toBe(true); expect(result.messageId).toBe("twitch-msg-123"); + expect(result.receipt).toMatchObject({ + primaryPlatformMessageId: "twitch-msg-123", + platformMessageIds: ["twitch-msg-123"], + parts: [ + { + platformMessageId: "twitch-msg-123", + kind: "text", + raw: { + channel: "twitch", + conversationId: "testchannel", + messageId: "twitch-msg-123", + }, + }, + ], + }); }); it("should strip markdown when enabled", async () => { @@ -192,6 +207,8 @@ describe("send", () => { expect(result.ok).toBe(true); expect(result.messageId).toBe("skipped"); + expect(result.receipt.platformMessageIds).toEqual([]); + expect(result.receipt.parts).toEqual([]); }); it("should return error when client manager not found", async () => { diff --git a/extensions/twitch/src/send.ts b/extensions/twitch/src/send.ts index b7c431cbca4..1728e09dbe3 100644 --- a/extensions/twitch/src/send.ts +++ b/extensions/twitch/src/send.ts @@ -5,6 +5,10 @@ * They support dependency injection via the `deps` parameter for testability. */ +import { + createMessageReceiptFromOutboundResults, + type MessageReceipt, +} from "openclaw/plugin-sdk/channel-message"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { getClientManager as getRegistryClientManager } from "./client-manager-registry.js"; @@ -20,10 +24,34 @@ export interface SendMessageResult { ok: boolean; /** The message ID (generated for tracking) */ messageId: string; + /** Receipt for visible sends; empty when no Twitch message was sent */ + receipt: MessageReceipt; /** Error message if the send failed */ error?: string; } +function createTwitchSendReceipt(params: { + messageId: string; + channel?: string | null; + visible?: boolean; +}): MessageReceipt { + const messageId = params.messageId.trim(); + const conversationId = params.channel?.trim(); + const hasVisibleMessage = params.visible === true && messageId && messageId !== "skipped"; + return createMessageReceiptFromOutboundResults({ + results: hasVisibleMessage + ? [ + { + channel: "twitch", + messageId, + ...(conversationId ? { conversationId } : {}), + }, + ] + : [], + kind: "text", + }); +} + /** * Internal send function used by the outbound adapter. * @@ -66,6 +94,7 @@ export async function sendMessageTwitchInternal( return { ok: false, messageId: generateMessageId(), + receipt: createTwitchSendReceipt({ messageId: "", channel, visible: false }), error: `Account not found: ${accountId ?? "(default)"}. Available accounts: ${availableAccountIds.join(", ") || "none"}`, }; } @@ -74,6 +103,7 @@ export async function sendMessageTwitchInternal( return { ok: false, messageId: generateMessageId(), + receipt: createTwitchSendReceipt({ messageId: "", channel, visible: false }), error: `Account ${resolvedAccountId} is not properly configured. ` + "Required: username, clientId, and token (config or env for default account).", @@ -85,15 +115,26 @@ export async function sendMessageTwitchInternal( return { ok: false, messageId: generateMessageId(), + receipt: createTwitchSendReceipt({ + messageId: "", + channel: normalizedChannel, + visible: false, + }), error: "No channel specified and no default channel in account config", }; } + const deliveryChannel = normalizeTwitchChannel(normalizedChannel); const cleanedText = stripMarkdown ? stripMarkdownForTwitch(text) : text; if (!cleanedText) { return { ok: true, messageId: "skipped", + receipt: createTwitchSendReceipt({ + messageId: "skipped", + channel: deliveryChannel, + visible: false, + }), }; } @@ -102,6 +143,11 @@ export async function sendMessageTwitchInternal( return { ok: false, messageId: generateMessageId(), + receipt: createTwitchSendReceipt({ + messageId: "", + channel: deliveryChannel, + visible: false, + }), error: `Client manager not found for account: ${resolvedAccountId}. Please start the Twitch gateway first.`, }; } @@ -109,30 +155,36 @@ export async function sendMessageTwitchInternal( try { const result = await clientManager.sendMessage( account, - normalizeTwitchChannel(normalizedChannel), + deliveryChannel, cleanedText, cfg, resolvedAccountId, ); if (!result.ok) { + const messageId = result.messageId ?? generateMessageId(); return { ok: false, - messageId: result.messageId ?? generateMessageId(), + messageId, + receipt: createTwitchSendReceipt({ messageId, channel: deliveryChannel, visible: false }), error: result.error ?? "Send failed", }; } + const messageId = result.messageId ?? generateMessageId(); return { ok: true, - messageId: result.messageId ?? generateMessageId(), + messageId, + receipt: createTwitchSendReceipt({ messageId, channel: deliveryChannel, visible: true }), }; } catch (error) { const errorMsg = formatErrorMessage(error); + const messageId = generateMessageId(); logger.error(`Failed to send message: ${errorMsg}`); return { ok: false, - messageId: generateMessageId(), + messageId, + receipt: createTwitchSendReceipt({ messageId, channel: deliveryChannel, visible: false }), error: errorMsg, }; } diff --git a/extensions/whatsapp/src/auto-reply.test-harness.ts b/extensions/whatsapp/src/auto-reply.test-harness.ts index 34436e318d6..b022d2a6c2d 100644 --- a/extensions/whatsapp/src/auto-reply.test-harness.ts +++ b/extensions/whatsapp/src/auto-reply.test-harness.ts @@ -267,7 +267,6 @@ export function createAcceptedWhatsAppSendResult( return { kind, messageId: id, - messageIds: [id], keys: [{ id }], providerAccepted: true, }; diff --git a/extensions/whatsapp/src/auto-reply/deliver-reply.test.ts b/extensions/whatsapp/src/auto-reply/deliver-reply.test.ts index d921163535a..056dac1af05 100644 --- a/extensions/whatsapp/src/auto-reply/deliver-reply.test.ts +++ b/extensions/whatsapp/src/auto-reply/deliver-reply.test.ts @@ -1,4 +1,8 @@ import fsSync from "node:fs"; +import { + createMessageReceiptFromOutboundResults, + listMessageReceiptPlatformIds, +} from "openclaw/plugin-sdk/channel-message"; import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; import { sleep } from "openclaw/plugin-sdk/text-runtime"; import { beforeAll, describe, expect, it, vi } from "vitest"; @@ -52,7 +56,10 @@ function acceptedSendResult(kind: "media" | "text", id: string) { return { kind, messageId: id, - messageIds: [id], + receipt: createMessageReceiptFromOutboundResults({ + kind, + results: [{ channel: "whatsapp", messageId: id }], + }), keys: [{ id }], providerAccepted: true, }; @@ -62,7 +69,10 @@ function unacceptedSendResult(kind: "media" | "text") { return { kind, messageId: "unknown", - messageIds: [], + receipt: createMessageReceiptFromOutboundResults({ + kind, + results: [], + }), keys: [], providerAccepted: false, }; @@ -196,7 +206,19 @@ describe("deliverWebReply", () => { expect(msg.reply).toHaveBeenNthCalledWith(2, "aaa", undefined); expect(replyLogger.info).toHaveBeenCalledWith(expect.any(Object), "auto-reply sent (text)"); expect(delivery.providerAccepted).toBe(true); - expect(delivery.messageIds).toEqual(["reply-sent-1"]); + expect(listMessageReceiptPlatformIds(delivery.receipt)).toEqual(["reply-sent-1"]); + expect(delivery.receipt).toEqual( + expect.objectContaining({ + primaryPlatformMessageId: "reply-sent-1", + platformMessageIds: ["reply-sent-1"], + }), + ); + expect(delivery.receipt.parts).toEqual([ + expect.objectContaining({ + platformMessageId: "reply-sent-1", + kind: "text", + }), + ]); }); it("reports text replies that Baileys did not accept", async () => { @@ -214,7 +236,10 @@ describe("deliverWebReply", () => { expect(msg.reply).toHaveBeenCalledTimes(1); expect(delivery).toMatchObject({ - messageIds: [], + receipt: expect.objectContaining({ + platformMessageIds: [], + parts: [], + }), providerAccepted: false, }); expect(replyLogger.warn).toHaveBeenCalledWith( diff --git a/extensions/whatsapp/src/auto-reply/deliver-reply.ts b/extensions/whatsapp/src/auto-reply/deliver-reply.ts index a0aa5495203..ef138dcd8a7 100644 --- a/extensions/whatsapp/src/auto-reply/deliver-reply.ts +++ b/extensions/whatsapp/src/auto-reply/deliver-reply.ts @@ -1,3 +1,8 @@ +import { + createMessageReceiptFromOutboundResults, + type MessageReceipt, + type MessageReceiptSourceResult, +} from "openclaw/plugin-sdk/channel-message"; import type { MarkdownTableMode } from "openclaw/plugin-sdk/config-types"; import { chunkMarkdownTextWithMode, type ChunkMode } from "openclaw/plugin-sdk/reply-chunking"; import type { ReplyPayload } from "openclaw/plugin-sdk/reply-chunking"; @@ -7,6 +12,7 @@ import { } from "openclaw/plugin-sdk/reply-payload"; import { logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env"; import type { WhatsAppSendResult } from "../inbound/send-result.js"; +import { listWhatsAppSendResultMessageIds } from "../inbound/send-result.js"; import { loadWebMedia } from "../media.js"; import { type DeliverableWhatsAppOutboundPayload, @@ -26,10 +32,57 @@ import { elide } from "./util.js"; export type WhatsAppReplyDeliveryResult = { results: WhatsAppSendResult[]; - messageIds: string[]; + receipt: MessageReceipt; providerAccepted: boolean; }; +function resolveWhatsAppReceiptKind( + results: readonly WhatsAppSendResult[], +): Parameters[0]["kind"] { + if (results.length > 0 && results.every((result) => result.kind === "text")) { + return "text"; + } + if (results.length > 0 && results.every((result) => result.kind === "media")) { + return "media"; + } + return "unknown"; +} + +function createWhatsAppReplyDeliveryReceipt( + results: readonly WhatsAppSendResult[], +): MessageReceipt { + const receiptResultsById = new Map(); + for (const result of results) { + if (result.receipt?.parts.length) { + for (const part of result.receipt.parts) { + receiptResultsById.set(part.platformMessageId, { + ...(part.raw ?? { channel: "whatsapp", messageId: part.platformMessageId }), + meta: { + ...part.raw?.meta, + kind: result.kind, + providerAccepted: result.providerAccepted, + }, + }); + } + continue; + } + for (const messageId of listWhatsAppSendResultMessageIds(result)) { + receiptResultsById.set(messageId, { + channel: "whatsapp", + messageId, + meta: { + kind: result.kind, + providerAccepted: result.providerAccepted, + }, + }); + } + } + return createMessageReceiptFromOutboundResults({ + results: [...receiptResultsById.values()], + kind: resolveWhatsAppReceiptKind(results), + }); +} + export async function deliverWebReply(params: { replyResult: ReplyPayload; normalizedReplyResult?: DeliverableWhatsAppOutboundPayload; @@ -55,10 +108,10 @@ export async function deliverWebReply(params: { } }; const finishDelivery = (): WhatsAppReplyDeliveryResult => { - const messageIds = [...new Set(sendResults.flatMap((result) => result.messageIds))]; + const receipt = createWhatsAppReplyDeliveryReceipt(sendResults); return { results: sendResults, - messageIds, + receipt, providerAccepted: sendResults.some((result) => result.providerAccepted), }; }; diff --git a/extensions/whatsapp/src/auto-reply/monitor/ack-reaction.test.ts b/extensions/whatsapp/src/auto-reply/monitor/ack-reaction.test.ts index 1b6f5e812ce..14992b6d5dd 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/ack-reaction.test.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/ack-reaction.test.ts @@ -16,7 +16,6 @@ function acceptedSendResult(kind: "media" | "text", id: string): WhatsAppSendRes return { kind, messageId: id, - messageIds: [id], keys: [{ id }], providerAccepted: true, }; diff --git a/extensions/whatsapp/src/auto-reply/monitor/inbound-context.test.ts b/extensions/whatsapp/src/auto-reply/monitor/inbound-context.test.ts index 3628139e59d..66c3df99e44 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/inbound-context.test.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/inbound-context.test.ts @@ -11,7 +11,6 @@ function acceptedSendResult(kind: "media" | "text", id: string): WhatsAppSendRes return { kind, messageId: id, - messageIds: [id], keys: [{ id }], providerAccepted: true, }; diff --git a/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.runtime.ts b/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.runtime.ts index 2c8bc79a48c..2d85d33eece 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.runtime.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.runtime.ts @@ -1,11 +1,11 @@ export { - createChannelReplyPipeline, + createChannelMessageReplyPipeline, dispatchReplyWithBufferedBlockDispatcher, finalizeInboundContext, getAgentScopedMediaLocalRoots, jidToE164, logVerbose, - resolveChannelSourceReplyDeliveryMode, + resolveChannelMessageSourceReplyDeliveryMode, resolveChunkMode, resolveIdentityNamePrefix, resolveInboundLastRouteSessionKey, diff --git a/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.test.ts b/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.test.ts index fd1786c3ab5..fa32513fae8 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.test.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.test.ts @@ -12,13 +12,27 @@ type CapturedReplyPayload = { mediaUrls?: string[]; }; -const { dispatchReplyWithBufferedBlockDispatcherMock } = vi.hoisted(() => ({ +const { + dispatchReplyWithBufferedBlockDispatcherMock, + deliverInboundReplyWithMessageSendContextMock, +} = vi.hoisted(() => ({ dispatchReplyWithBufferedBlockDispatcherMock: vi.fn(async (params: { ctx: unknown }) => { capturedDispatchParams = params; return { queuedFinal: false, counts: { tool: 0, block: 0, final: 0 } }; }), + deliverInboundReplyWithMessageSendContextMock: vi.fn<(...args: unknown[]) => Promise>( + async () => null, + ), })); +vi.mock("openclaw/plugin-sdk/channel-message", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + deliverInboundReplyWithMessageSendContext: deliverInboundReplyWithMessageSendContextMock, + }; +}); + vi.mock("./runtime-api.js", () => ({ dispatchReplyWithBufferedBlockDispatcher: dispatchReplyWithBufferedBlockDispatcherMock, finalizeInboundContext: >(ctx: T) => ({ @@ -36,7 +50,7 @@ vi.mock("./runtime-api.js", () => ({ return phone ? `+${phone}` : null; }, logVerbose: () => {}, - resolveChannelSourceReplyDeliveryMode: ({ + resolveChannelMessageSourceReplyDeliveryMode: ({ cfg, ctx, }: { @@ -102,12 +116,24 @@ function acceptedSendResult(kind: "media" | "text", id: string): WhatsAppSendRes return { kind, messageId: id, - messageIds: [id], keys: [{ id }], providerAccepted: true, }; } +function testReceipt(messageIds: string[]) { + return { + ...(messageIds[0] ? { primaryPlatformMessageId: messageIds[0] } : {}), + platformMessageIds: messageIds, + parts: messageIds.map((messageId, index) => ({ + platformMessageId: messageId, + kind: "text" as const, + index, + })), + sentAt: 123, + }; +} + function makeRoute(overrides: Partial = {}): TestRoute { return { agentId: "main", @@ -189,12 +215,11 @@ function acceptedDeliveryResult() { { kind: "text" as const, messageId: "wa-sent-1", - messageIds: ["wa-sent-1"], keys: [{ id: "wa-sent-1" }], providerAccepted: true, }, ], - messageIds: ["wa-sent-1"], + receipt: testReceipt(["wa-sent-1"]), providerAccepted: true, }; } @@ -202,7 +227,7 @@ function acceptedDeliveryResult() { function unacceptedDeliveryResult() { return { results: [], - messageIds: [], + receipt: testReceipt([]), providerAccepted: false, }; } @@ -233,6 +258,11 @@ describe("whatsapp inbound dispatch", () => { beforeEach(() => { capturedDispatchParams = undefined; dispatchReplyWithBufferedBlockDispatcherMock.mockClear(); + deliverInboundReplyWithMessageSendContextMock.mockReset(); + deliverInboundReplyWithMessageSendContextMock.mockResolvedValue({ + status: "unsupported", + reason: "missing_outbound_handler", + }); }); it("builds a finalized inbound context payload", () => { @@ -513,6 +543,125 @@ describe("whatsapp inbound dispatch", () => { expect(rememberSentText).toHaveBeenCalledTimes(4); }); + it("queues final WhatsApp payloads through durable outbound delivery", async () => { + deliverInboundReplyWithMessageSendContextMock.mockResolvedValueOnce({ + status: "handled_visible", + delivery: { + messageIds: ["wa-1"], + visibleReplySent: true, + }, + }); + const deliverReply = vi.fn(async () => acceptedDeliveryResult()); + const rememberSentText = vi.fn(); + + await dispatchBufferedReply({ + context: { Body: "incoming", SessionKey: "agent:main:whatsapp:+15551234567" }, + deliverReply, + rememberSentText, + route: makeRoute({ + accountId: "default", + agentId: "main", + sessionKey: "agent:main:whatsapp:+15551234567", + }), + }); + + const deliver = getCapturedDeliver(); + await deliver?.({ text: "final payload" }, { kind: "final" }); + + expect(deliverInboundReplyWithMessageSendContextMock).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "whatsapp", + accountId: "default", + agentId: "main", + to: "+1000", + payload: expect.objectContaining({ text: "final payload" }), + info: { kind: "final" }, + ctxPayload: expect.objectContaining({ + SessionKey: "agent:main:whatsapp:+15551234567", + }), + }), + ); + expect(deliverReply).not.toHaveBeenCalled(); + expect(rememberSentText).toHaveBeenCalledWith( + "final payload", + expect.objectContaining({ + combinedBody: "incoming", + combinedBodySessionKey: "agent:main:whatsapp:+15551234567", + }), + ); + }); + + it("does not fall back when durable WhatsApp delivery suppresses a send", async () => { + deliverInboundReplyWithMessageSendContextMock.mockResolvedValueOnce({ + status: "handled_no_send", + reason: "no_visible_result", + delivery: { + messageIds: [], + visibleReplySent: false, + }, + }); + const deliverReply = vi.fn(async () => acceptedDeliveryResult()); + const rememberSentText = vi.fn(); + + await dispatchBufferedReply({ + deliverReply, + rememberSentText, + }); + + const deliver = getCapturedDeliver(); + await deliver?.({ text: "cancelled by hook" }, { kind: "final" }); + + expect(deliverInboundReplyWithMessageSendContextMock).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "whatsapp", + payload: expect.objectContaining({ text: "cancelled by hook" }), + info: { kind: "final" }, + }), + ); + expect(deliverReply).not.toHaveBeenCalled(); + expect(rememberSentText).not.toHaveBeenCalled(); + }); + + it("keeps media replies on the WhatsApp owner delivery path", async () => { + deliverInboundReplyWithMessageSendContextMock.mockResolvedValueOnce({ + status: "handled_visible", + delivery: { + messageIds: ["wa-1"], + visibleReplySent: true, + }, + }); + const deliverReply = vi.fn(async () => acceptedDeliveryResult()); + const rememberSentText = vi.fn(); + + await dispatchBufferedReply({ + deliverReply, + rememberSentText, + }); + + const deliver = getCapturedDeliver(); + await deliver?.( + { text: "generated image", mediaUrls: ["/tmp/generated.jpg"] }, + { kind: "final" }, + ); + + expect(deliverInboundReplyWithMessageSendContextMock).not.toHaveBeenCalled(); + expect(deliverReply).toHaveBeenCalledWith( + expect.objectContaining({ + replyResult: expect.objectContaining({ + mediaUrls: ["/tmp/generated.jpg"], + text: "generated image", + }), + }), + ); + expect(rememberSentText).toHaveBeenCalledWith( + "generated image", + expect.objectContaining({ + combinedBody: "hi", + combinedBodySessionKey: "agent:main:whatsapp:direct:+1000", + }), + ); + }); + it("normalizes WhatsApp payload text before delivery and echo bookkeeping", async () => { const deliverReply = vi.fn(async () => acceptedDeliveryResult()); const rememberSentText = vi.fn(); diff --git a/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.ts b/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.ts index 71ba9a90e09..7c74baca19d 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.ts @@ -1,4 +1,6 @@ +import { deliverInboundReplyWithMessageSendContext } from "openclaw/plugin-sdk/channel-message"; import { hasVisibleInboundReplyDispatch } from "openclaw/plugin-sdk/inbound-reply-dispatch"; +import type { FinalizedMsgContext } from "openclaw/plugin-sdk/reply-runtime"; import { type DeliverableWhatsAppOutboundPayload, normalizeWhatsAppOutboundPayload, @@ -9,13 +11,13 @@ import type { WebInboundMsg } from "../types.js"; import { formatGroupMembers } from "./group-members.js"; import type { GroupHistoryEntry } from "./inbound-context.js"; import { - createChannelReplyPipeline, + createChannelMessageReplyPipeline, dispatchReplyWithBufferedBlockDispatcher, finalizeInboundContext, getAgentScopedMediaLocalRoots, jidToE164, logVerbose, - resolveChannelSourceReplyDeliveryMode, + resolveChannelMessageSourceReplyDeliveryMode, resolveChunkMode, resolveIdentityNamePrefix, resolveInboundLastRouteSessionKey, @@ -33,7 +35,7 @@ import { type ReplyLifecycleKind = "tool" | "block" | "final"; type ChannelReplyOnModelSelected = NonNullable< - ReturnType["onModelSelected"] + ReturnType["onModelSelected"] >; type WhatsAppDispatchPipeline = { @@ -318,7 +320,7 @@ export async function dispatchWhatsAppBufferedReply(params: { typeof params.context.ChatType === "string" ? params.context.ChatType : params.msg.chatType; const sourceReplyDeliveryMode = sourceReplyChatType === "group" || sourceReplyChatType === "channel" - ? resolveChannelSourceReplyDeliveryMode({ + ? resolveChannelMessageSourceReplyDeliveryMode({ cfg: params.cfg, ctx: { ChatType: sourceReplyChatType, @@ -361,6 +363,39 @@ export async function dispatchWhatsAppBufferedReply(params: { if (!reply.hasMedia && !reply.text.trim()) { return; } + if (!reply.hasMedia) { + const durable = await deliverInboundReplyWithMessageSendContext({ + cfg: params.cfg, + channel: "whatsapp", + accountId: params.route.accountId, + agentId: params.route.agentId, + ctxPayload: params.context as FinalizedMsgContext, + payload: normalizedDeliveryPayload, + info, + to: params.msg.from, + formatting: { + textLimit, + tableMode, + chunkMode, + }, + }); + if (durable.status === "failed") { + throw durable.error; + } + if (durable.status === "handled_visible") { + didSendReply = true; + const shouldLog = normalizedDeliveryPayload.text ? true : undefined; + params.rememberSentText(normalizedDeliveryPayload.text, { + combinedBody: params.context.Body as string | undefined, + combinedBodySessionKey: params.route.sessionKey, + logVerboseMessage: shouldLog, + }); + return; + } + if (durable.status === "handled_no_send") { + return; + } + } const delivery = await params.deliverReply({ replyResult: normalizedDeliveryPayload, normalizedReplyResult: normalizedDeliveryPayload, diff --git a/extensions/whatsapp/src/auto-reply/monitor/process-message.audio-preflight.test.ts b/extensions/whatsapp/src/auto-reply/monitor/process-message.audio-preflight.test.ts index a77994b38c5..de4766b4403 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/process-message.audio-preflight.test.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/process-message.audio-preflight.test.ts @@ -64,7 +64,7 @@ vi.mock("./message-line.js", () => ({ vi.mock("./runtime-api.js", () => ({ buildHistoryContextFromEntries: (_p: { currentMessage: string }) => _p.currentMessage, - createChannelReplyPipeline: () => ({ onModelSelected: undefined }), + createChannelMessageReplyPipeline: () => ({ onModelSelected: undefined }), formatInboundEnvelope: (p: { body: string }) => p.body, logVerbose: () => {}, normalizeE164: (v: string) => v, diff --git a/extensions/whatsapp/src/auto-reply/monitor/process-message.test.ts b/extensions/whatsapp/src/auto-reply/monitor/process-message.test.ts index 841323e6044..50065f53925 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/process-message.test.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/process-message.test.ts @@ -14,7 +14,6 @@ function acceptedSendResult(kind: "media" | "text", id: string): WhatsAppSendRes return { kind, messageId: id, - messageIds: [id], keys: [{ id }], providerAccepted: true, }; @@ -117,7 +116,10 @@ vi.mock("./runtime-api.js", async (importOriginal) => { return { ...actual, buildHistoryContextFromEntries: () => "hi", - createChannelReplyPipeline: () => ({ onModelSelected: () => {}, responsePrefix: undefined }), + createChannelMessageReplyPipeline: () => ({ + onModelSelected: () => {}, + responsePrefix: undefined, + }), formatInboundEnvelope: () => "hi", logVerbose: () => {}, normalizeE164: (v: string) => v, diff --git a/extensions/whatsapp/src/auto-reply/monitor/process-message.ts b/extensions/whatsapp/src/auto-reply/monitor/process-message.ts index 5f763d91d61..61e6c4920df 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/process-message.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/process-message.ts @@ -48,7 +48,7 @@ import { trackBackgroundTask, updateLastRouteInBackground } from "./last-route.j import { buildInboundLine } from "./message-line.js"; import { buildHistoryContextFromEntries, - createChannelReplyPipeline, + createChannelMessageReplyPipeline, formatInboundEnvelope, logVerbose, normalizeE164, @@ -380,7 +380,7 @@ export async function processMessage(params: { policy: inboundPolicy, }) : undefined; - const { onModelSelected, ...replyPipeline } = createChannelReplyPipeline({ + const { onModelSelected, ...replyPipeline } = createChannelMessageReplyPipeline({ cfg: params.cfg, agentId: params.route.agentId, channel: "whatsapp", diff --git a/extensions/whatsapp/src/auto-reply/monitor/runtime-api.ts b/extensions/whatsapp/src/auto-reply/monitor/runtime-api.ts index 7996e4eea66..682b7b575c7 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/runtime-api.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/runtime-api.ts @@ -3,9 +3,9 @@ export { formatInboundEnvelope } from "openclaw/plugin-sdk/channel-envelope"; export { resolveInboundSessionEnvelopeContext } from "openclaw/plugin-sdk/channel-inbound"; export { toLocationContext } from "openclaw/plugin-sdk/channel-location"; export { - createChannelReplyPipeline, - resolveChannelSourceReplyDeliveryMode, -} from "openclaw/plugin-sdk/channel-reply-pipeline"; + createChannelMessageReplyPipeline, + resolveChannelMessageSourceReplyDeliveryMode, +} from "openclaw/plugin-sdk/channel-message"; export { shouldComputeCommandAuthorized } from "openclaw/plugin-sdk/command-detection"; export { resolveChannelContextVisibilityMode } from "../config.runtime.js"; export { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime"; diff --git a/extensions/whatsapp/src/auto-reply/web-auto-reply-monitor.test.ts b/extensions/whatsapp/src/auto-reply/web-auto-reply-monitor.test.ts index 927c9d5c970..6301861d4a1 100644 --- a/extensions/whatsapp/src/auto-reply/web-auto-reply-monitor.test.ts +++ b/extensions/whatsapp/src/auto-reply/web-auto-reply-monitor.test.ts @@ -16,7 +16,6 @@ function acceptedSendResult(kind: "media" | "text", id: string): WhatsAppSendRes return { kind, messageId: id, - messageIds: [id], keys: [{ id }], providerAccepted: true, }; diff --git a/extensions/whatsapp/src/auto-reply/web-auto-reply-utils.test.ts b/extensions/whatsapp/src/auto-reply/web-auto-reply-utils.test.ts index 59fddfcd885..e7ba03eba93 100644 --- a/extensions/whatsapp/src/auto-reply/web-auto-reply-utils.test.ts +++ b/extensions/whatsapp/src/auto-reply/web-auto-reply-utils.test.ts @@ -29,7 +29,6 @@ function acceptedSendResult(kind: "media" | "text", id: string): WhatsAppSendRes return { kind, messageId: id, - messageIds: [id], keys: [{ id }], providerAccepted: true, }; diff --git a/extensions/whatsapp/src/channel-outbound.ts b/extensions/whatsapp/src/channel-outbound.ts index 55bbaa782f6..9506a11f8f5 100644 --- a/extensions/whatsapp/src/channel-outbound.ts +++ b/extensions/whatsapp/src/channel-outbound.ts @@ -1,3 +1,8 @@ +import { + createMessageReceiptFromOutboundResults, + defineChannelMessageAdapter, + type ChannelMessageSendResult, +} from "openclaw/plugin-sdk/channel-message"; import { chunkText } from "openclaw/plugin-sdk/reply-chunking"; import { createWhatsAppOutboundBase } from "./outbound-base.js"; import { normalizeWhatsAppPayloadTextPreservingIndentation } from "./outbound-media-contract.js"; @@ -34,3 +39,49 @@ export const whatsappChannelOutbound = { text: normalizeWhatsAppChannelPayloadText(payload.text), }), }; + +function toWhatsAppMessageSendResult( + result: Awaited>>, + replyToId?: string | null, +): ChannelMessageSendResult { + const source = result as typeof result & { toJid?: string }; + const receipt = + result.receipt ?? + createMessageReceiptFromOutboundResults({ + results: result.messageId + ? [ + { + channel: "whatsapp", + messageId: result.messageId, + toJid: source.toJid, + }, + ] + : [], + kind: "text", + ...(replyToId ? { replyToId } : {}), + }); + return { + messageId: result.messageId || receipt.primaryPlatformMessageId, + receipt, + }; +} + +export const whatsappMessageAdapter = defineChannelMessageAdapter({ + id: "whatsapp", + durableFinal: { + capabilities: { + text: true, + replyTo: true, + messageSendingHooks: true, + }, + }, + send: { + text: async (ctx) => + toWhatsAppMessageSendResult( + await whatsappChannelOutbound.sendText!({ + ...ctx, + }), + ctx.replyToId, + ), + }, +}); diff --git a/extensions/whatsapp/src/channel.ts b/extensions/whatsapp/src/channel.ts index e69b7806bbe..b31627a87e4 100644 --- a/extensions/whatsapp/src/channel.ts +++ b/extensions/whatsapp/src/channel.ts @@ -14,7 +14,7 @@ import { describeWhatsAppMessageActions, resolveWhatsAppAgentReactionGuidance, } from "./channel-actions.js"; -import { whatsappChannelOutbound } from "./channel-outbound.js"; +import { whatsappChannelOutbound, whatsappMessageAdapter } from "./channel-outbound.js"; import { whatsappCommandPolicy } from "./command-policy.js"; import { formatWhatsAppConfigAllowFromEntries } from "./config-accessors.js"; import { @@ -127,6 +127,7 @@ export const whatsappPlugin: ChannelPlugin = hint: "", }, }, + message: whatsappMessageAdapter, directory: { self: async ({ cfg, accountId }) => { const account = resolveWhatsAppAccount({ cfg, accountId }); diff --git a/extensions/whatsapp/src/connection-controller.test.ts b/extensions/whatsapp/src/connection-controller.test.ts index 718d5aa767a..a7e59dd2261 100644 --- a/extensions/whatsapp/src/connection-controller.test.ts +++ b/extensions/whatsapp/src/connection-controller.test.ts @@ -21,7 +21,6 @@ function acceptedSendResult(kind: WhatsAppSendKind, id: string): WhatsAppSendRes return { kind, messageId: id, - messageIds: [id], keys: [{ id }], providerAccepted: true, }; diff --git a/extensions/whatsapp/src/inbound/send-api.test.ts b/extensions/whatsapp/src/inbound/send-api.test.ts index db55fb98d88..ee8c830f66a 100644 --- a/extensions/whatsapp/src/inbound/send-api.test.ts +++ b/extensions/whatsapp/src/inbound/send-api.test.ts @@ -3,6 +3,7 @@ import type { MiscMessageGenerationOptions, WAMessage, } from "@whiskeysockets/baileys"; +import { listMessageReceiptPlatformIds } from "openclaw/plugin-sdk/channel-message"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { resolveWhatsAppOutboundMentions } from "./outbound-mentions.js"; import { createWebSendApi } from "./send-api.js"; @@ -77,9 +78,9 @@ describe("createWebSendApi", () => { expect(res).toMatchObject({ kind: "text", messageId: "msg-1", - messageIds: ["msg-1"], providerAccepted: true, }); + expect(res.receipt ? listMessageReceiptPlatformIds(res.receipt) : []).toEqual(["msg-1"]); expect(recordChannelActivity).toHaveBeenCalledWith({ channel: "whatsapp", accountId: "main", @@ -190,9 +191,12 @@ describe("createWebSendApi", () => { expect(res).toMatchObject({ kind: "media", messageId: "voice-1", - messageIds: ["voice-1", "voice-text-1"], providerAccepted: true, }); + expect(res.receipt ? listMessageReceiptPlatformIds(res.receipt) : []).toEqual([ + "voice-1", + "voice-text-1", + ]); }); it("supports video media and gifPlayback option", async () => { @@ -266,9 +270,9 @@ describe("createWebSendApi", () => { expect(res).toMatchObject({ kind: "text", messageId: "unknown", - messageIds: [], providerAccepted: false, }); + expect(res.receipt ? listMessageReceiptPlatformIds(res.receipt) : []).toEqual([]); }); it("keeps direct-chat reactions without a participant key", async () => { diff --git a/extensions/whatsapp/src/inbound/send-result.test.ts b/extensions/whatsapp/src/inbound/send-result.test.ts new file mode 100644 index 00000000000..1ac6845f532 --- /dev/null +++ b/extensions/whatsapp/src/inbound/send-result.test.ts @@ -0,0 +1,56 @@ +import type { WAMessage } from "@whiskeysockets/baileys"; +import { describe, expect, it } from "vitest"; +import { combineWhatsAppSendResults, normalizeWhatsAppSendResult } from "./send-result.js"; + +describe("WhatsApp send receipts", () => { + it("attaches receipts to accepted provider sends", () => { + const result = normalizeWhatsAppSendResult( + { + key: { + id: "wa-1", + remoteJid: "123@s.whatsapp.net", + fromMe: true, + }, + } as unknown as WAMessage, + "text", + ); + + expect(result.receipt).toMatchObject({ + primaryPlatformMessageId: "wa-1", + platformMessageIds: ["wa-1"], + parts: [ + expect.objectContaining({ + platformMessageId: "wa-1", + kind: "text", + raw: expect.objectContaining({ + channel: "whatsapp", + messageId: "wa-1", + toJid: "123@s.whatsapp.net", + }), + }), + ], + }); + }); + + it("combines receipts in provider send order", () => { + const media = normalizeWhatsAppSendResult( + { key: { id: "media-1", remoteJid: "chat@s.whatsapp.net" } } as unknown as WAMessage, + "media", + ); + const text = normalizeWhatsAppSendResult( + { key: { id: "text-1", remoteJid: "chat@s.whatsapp.net" } } as unknown as WAMessage, + "text", + ); + + const combined = combineWhatsAppSendResults("media", [media, text]); + + expect(combined.receipt).toMatchObject({ + primaryPlatformMessageId: "media-1", + platformMessageIds: ["media-1", "text-1"], + parts: [ + expect.objectContaining({ platformMessageId: "media-1", kind: "media" }), + expect.objectContaining({ platformMessageId: "text-1", kind: "media" }), + ], + }); + }); +}); diff --git a/extensions/whatsapp/src/inbound/send-result.ts b/extensions/whatsapp/src/inbound/send-result.ts index 1ed41927539..1190bd35b42 100644 --- a/extensions/whatsapp/src/inbound/send-result.ts +++ b/extensions/whatsapp/src/inbound/send-result.ts @@ -1,4 +1,11 @@ import type { WAMessage, WAMessageKey } from "@whiskeysockets/baileys"; +import { + createMessageReceiptFromOutboundResults, + listMessageReceiptPlatformIds, + type MessageReceipt, + type MessageReceiptPartKind, + type MessageReceiptSourceResult, +} from "openclaw/plugin-sdk/channel-message"; export type WhatsAppSendKind = "media" | "poll" | "reaction" | "text"; @@ -12,11 +19,40 @@ type WhatsAppSendKey = Omit< export type WhatsAppSendResult = { kind: WhatsAppSendKind; messageId: string; - messageIds: string[]; + receipt?: MessageReceipt; keys: WhatsAppSendKey[]; providerAccepted: boolean; }; +function resolveWhatsAppReceiptKind(kind: WhatsAppSendKind): MessageReceiptPartKind { + if (kind === "media" || kind === "text") { + return kind; + } + return "unknown"; +} + +function toReceiptSourceResult(key: WhatsAppSendKey): MessageReceiptSourceResult { + return { + channel: "whatsapp", + messageId: key.id, + ...(key.remoteJid ? { toJid: key.remoteJid } : {}), + meta: { + fromMe: key.fromMe, + participant: key.participant, + }, + }; +} + +function createWhatsAppSendReceipt( + kind: WhatsAppSendKind, + keys: readonly WhatsAppSendKey[], +): MessageReceipt { + return createMessageReceiptFromOutboundResults({ + kind: resolveWhatsAppReceiptKind(kind), + results: keys.map(toReceiptSourceResult), + }); +} + function normalizeKey(key: WAMessageKey | undefined): WhatsAppSendKey | undefined { const id = typeof key?.id === "string" ? key.id.trim() : ""; if (!id) { @@ -39,7 +75,7 @@ export function normalizeWhatsAppSendResult( return { kind, messageId, - messageIds: key ? [key.id] : [], + receipt: createWhatsAppSendReceipt(kind, key ? [key] : []), keys: key ? [key] : [], providerAccepted: Boolean(key), }; @@ -49,13 +85,25 @@ export function combineWhatsAppSendResults( kind: WhatsAppSendKind, results: readonly WhatsAppSendResult[], ): WhatsAppSendResult { - const messageIds = [...new Set(results.flatMap((result) => result.messageIds))]; + const messageIds = [...new Set(results.flatMap(listWhatsAppSendResultMessageIds))]; const keys = results.flatMap((result) => result.keys); return { kind, messageId: messageIds[0] ?? "unknown", - messageIds, + receipt: createWhatsAppSendReceipt(kind, keys), keys, providerAccepted: results.some((result) => result.providerAccepted), }; } + +export function listWhatsAppSendResultMessageIds(result: WhatsAppSendResult): string[] { + const receiptIds = result.receipt ? listMessageReceiptPlatformIds(result.receipt) : []; + if (receiptIds.length > 0) { + return receiptIds; + } + const keyIds = result.keys.map((key) => key.id.trim()).filter(Boolean); + if (keyIds.length > 0) { + return [...new Set(keyIds)]; + } + return []; +} diff --git a/extensions/whatsapp/src/outbound-base.ts b/extensions/whatsapp/src/outbound-base.ts index 52db72b1941..014475b6772 100644 --- a/extensions/whatsapp/src/outbound-base.ts +++ b/extensions/whatsapp/src/outbound-base.ts @@ -89,6 +89,7 @@ type WhatsAppOutboundBaseCore = Pick< | "chunkerMode" | "textChunkLimit" | "sanitizeText" + | "deliveryCapabilities" | "pollMaxOptions" | "resolveTarget" | "sendText" @@ -111,6 +112,7 @@ export function createWhatsAppOutboundBase({ | "chunkerMode" | "textChunkLimit" | "sanitizeText" + | "deliveryCapabilities" | "pollMaxOptions" | "resolveTarget" | "sendPayload" @@ -144,6 +146,13 @@ export function createWhatsAppOutboundBase({ chunkerMode: "text", textChunkLimit: 4000, sanitizeText: ({ text }) => normalizeText(text), + deliveryCapabilities: { + durableFinal: { + text: true, + replyTo: true, + messageSendingHooks: true, + }, + }, pollMaxOptions: 12, resolveTarget, ...createAttachedChannelResultAdapter({ diff --git a/extensions/whatsapp/src/outbound-payload.contract.test.ts b/extensions/whatsapp/src/outbound-payload.contract.test.ts index 48ce7b8abf8..6d97925dd6f 100644 --- a/extensions/whatsapp/src/outbound-payload.contract.test.ts +++ b/extensions/whatsapp/src/outbound-payload.contract.test.ts @@ -3,7 +3,12 @@ import { primeChannelOutboundSendMock, type OutboundPayloadHarnessParams, } from "openclaw/plugin-sdk/channel-contract-testing"; +import { + verifyChannelMessageAdapterCapabilityProofs, + verifyDurableFinalCapabilityProofs, +} from "openclaw/plugin-sdk/channel-message"; import { describe, expect, it, vi } from "vitest"; +import { whatsappMessageAdapter } from "./channel-outbound.js"; import { whatsappOutbound } from "./outbound-adapter.js"; function createWhatsAppHarness(params: OutboundPayloadHarnessParams) { @@ -58,4 +63,107 @@ describe("WhatsApp outbound payload contract", () => { }), ); }); + + it("backs declared durable final capabilities with delivery proofs", async () => { + const sendWhatsApp = vi.fn(); + primeChannelOutboundSendMock(sendWhatsApp, { messageId: "wa-1", toJid: "jid-1" }); + + const proveText = async () => { + await whatsappOutbound.sendText!({ + cfg: {} as never, + to: "5511999999999@c.us", + text: " hello ", + deps: { whatsapp: sendWhatsApp }, + }); + expect(sendWhatsApp).toHaveBeenLastCalledWith( + "5511999999999@c.us", + "hello", + expect.any(Object), + ); + }; + const proveReplyTo = async () => { + await whatsappOutbound.sendText!({ + cfg: {} as never, + to: "5511999999999@c.us", + text: "reply", + replyToId: "msg-1", + deps: { whatsapp: sendWhatsApp }, + }); + expect(sendWhatsApp).toHaveBeenLastCalledWith( + "5511999999999@c.us", + "reply", + expect.objectContaining({ + quotedMessageKey: expect.objectContaining({ + id: "msg-1", + remoteJid: "5511999999999@c.us", + }), + }), + ); + }; + + await verifyDurableFinalCapabilityProofs({ + adapterName: "whatsappOutbound", + capabilities: whatsappOutbound.deliveryCapabilities?.durableFinal, + proofs: { + text: proveText, + replyTo: proveReplyTo, + messageSendingHooks: () => { + expect(whatsappOutbound.sendText).toBeTypeOf("function"); + }, + }, + }); + }); + + it("backs declared message adapter capabilities with delivery proofs", async () => { + const sendWhatsApp = vi.fn(); + primeChannelOutboundSendMock(sendWhatsApp, { messageId: "wa-1", toJid: "jid-1" }); + + await verifyChannelMessageAdapterCapabilityProofs({ + adapterName: "whatsappMessage", + adapter: whatsappMessageAdapter, + proofs: { + text: async () => { + const result = await whatsappMessageAdapter.send.text?.({ + cfg: {} as never, + to: "5511999999999@c.us", + text: "hello", + deps: { whatsapp: sendWhatsApp }, + } as Parameters>[0] & { + deps: { whatsapp: typeof sendWhatsApp }; + }); + expect(sendWhatsApp).toHaveBeenLastCalledWith( + "5511999999999@c.us", + "hello", + expect.any(Object), + ); + expect(result?.receipt.platformMessageIds).toEqual(["wa-1"]); + }, + replyTo: async () => { + const result = await whatsappMessageAdapter.send.text?.({ + cfg: {} as never, + to: "5511999999999@c.us", + text: "reply", + replyToId: "msg-1", + deps: { whatsapp: sendWhatsApp }, + } as Parameters>[0] & { + deps: { whatsapp: typeof sendWhatsApp }; + }); + expect(sendWhatsApp).toHaveBeenLastCalledWith( + "5511999999999@c.us", + "reply", + expect.objectContaining({ + quotedMessageKey: expect.objectContaining({ + id: "msg-1", + remoteJid: "5511999999999@c.us", + }), + }), + ); + expect(result?.receipt.platformMessageIds).toEqual(["wa-1"]); + }, + messageSendingHooks: () => { + expect(whatsappMessageAdapter.send.text).toBeTypeOf("function"); + }, + }, + }); + }); }); diff --git a/extensions/whatsapp/src/send.test.ts b/extensions/whatsapp/src/send.test.ts index 33d186312f5..a737cbc8208 100644 --- a/extensions/whatsapp/src/send.test.ts +++ b/extensions/whatsapp/src/send.test.ts @@ -28,7 +28,6 @@ function acceptedSendResult(kind: WhatsAppSendKind, id: string): WhatsAppSendRes return { kind, messageId: id, - messageIds: [id], keys: [{ id }], providerAccepted: true, }; diff --git a/extensions/whatsapp/src/test-helpers.ts b/extensions/whatsapp/src/test-helpers.ts index 3819d8c8f7b..edf25f48630 100644 --- a/extensions/whatsapp/src/test-helpers.ts +++ b/extensions/whatsapp/src/test-helpers.ts @@ -223,7 +223,7 @@ function formatInboundEnvelopeMock(params: TestInboundEnvelopeParams) { return `[${parts.join(" ")}] ${body}`; } -function createChannelReplyPipelineMock() { +function createChannelMessageReplyPipelineMock() { return { onModelSelected: undefined, responsePrefix: undefined, @@ -252,7 +252,7 @@ function resolveSendableOutboundReplyPartsMock(payload: Record) }; } -function resolveChannelSourceReplyDeliveryModeMock(params: { +function resolveChannelMessageSourceReplyDeliveryModeMock(params: { cfg: { messages?: { visibleReplies?: "automatic" | "message_tool"; @@ -464,13 +464,13 @@ vi.mock("./inbound/runtime-api.js", () => ({ })); vi.mock("./auto-reply/monitor/inbound-dispatch.runtime.js", () => ({ - createChannelReplyPipeline: createChannelReplyPipelineMock, + createChannelMessageReplyPipeline: createChannelMessageReplyPipelineMock, dispatchReplyWithBufferedBlockDispatcher: createBufferedDispatchReplyMock(), finalizeInboundContext: (ctx: T) => ctx, getAgentScopedMediaLocalRoots: () => [] as string[], jidToE164: normalizePhoneLikeToE164, logVerbose: (_msg: string) => undefined, - resolveChannelSourceReplyDeliveryMode: resolveChannelSourceReplyDeliveryModeMock, + resolveChannelMessageSourceReplyDeliveryMode: resolveChannelMessageSourceReplyDeliveryModeMock, resolveChunkMode: () => undefined, resolveIdentityNamePrefix: resolveIdentityNamePrefixMock, resolveInboundLastRouteSessionKey: (params: { sessionKey: string }) => params.sessionKey, @@ -494,7 +494,7 @@ vi.mock("./auto-reply/monitor/runtime-api.js", () => ({ ? `Chat messages since your last reply:\n${rendered}\n\n${params.currentMessage}` : params.currentMessage; }, - createChannelReplyPipeline: createChannelReplyPipelineMock, + createChannelMessageReplyPipeline: createChannelMessageReplyPipelineMock, dispatchReplyWithBufferedBlockDispatcher: createBufferedDispatchReplyMock(), finalizeInboundContext: (ctx: T) => ctx, formatInboundEnvelope: formatInboundEnvelopeMock, @@ -504,7 +504,7 @@ vi.mock("./auto-reply/monitor/runtime-api.js", () => ({ normalizeE164: normalizePhoneLikeToE164, readStoreAllowFromForDmPolicy: async () => [] as string[], recordSessionMetaFromInbound: async () => undefined, - resolveChannelSourceReplyDeliveryMode: resolveChannelSourceReplyDeliveryModeMock, + resolveChannelMessageSourceReplyDeliveryMode: resolveChannelMessageSourceReplyDeliveryModeMock, resolveChannelContextVisibilityMode: resolveChannelContextVisibilityModeMock, resolveChunkMode: () => undefined, resolveIdentityNamePrefix: resolveIdentityNamePrefixMock, diff --git a/extensions/zalo/runtime-api.ts b/extensions/zalo/runtime-api.ts index b49e978fd6c..0e574838b63 100644 --- a/extensions/zalo/runtime-api.ts +++ b/extensions/zalo/runtime-api.ts @@ -17,7 +17,7 @@ export { type ChannelStatusIssue, chunkTextForOutbound, createChannelPairingController, - createChannelReplyPipeline, + createChannelMessageReplyPipeline, createDedupeCache, createFixedWindowRateLimiter, createWebhookAnomalyTracker, diff --git a/extensions/zalo/src/channel.ts b/extensions/zalo/src/channel.ts index 0d4191f9542..084372dda16 100644 --- a/extensions/zalo/src/channel.ts +++ b/extensions/zalo/src/channel.ts @@ -13,6 +13,7 @@ import { createChatChannelPlugin, type ChannelPlugin, } from "openclaw/plugin-sdk/channel-core"; +import { defineChannelMessageAdapter } from "openclaw/plugin-sdk/channel-message"; import { buildOpenGroupPolicyRestrictSendersWarning, buildOpenGroupPolicyWarning, @@ -101,6 +102,38 @@ const zaloRawSendResultAdapter = createRawChannelSendResultAdapter({ }), }); +export const zaloMessageAdapter = defineChannelMessageAdapter({ + id: "zalo", + durableFinal: { + capabilities: { + text: true, + media: true, + messageSendingHooks: true, + }, + }, + send: { + text: async ({ to, text, accountId, cfg }) => + await ( + await loadZaloChannelRuntime() + ).sendZaloText({ + to, + text, + accountId: accountId ?? undefined, + cfg, + }), + media: async ({ to, text, mediaUrl, accountId, cfg }) => + await ( + await loadZaloChannelRuntime() + ).sendZaloText({ + to, + text, + accountId: accountId ?? undefined, + mediaUrl, + cfg, + }), + }, +}); + const zaloConfigAdapter = createScopedChannelConfigAdapter({ sectionKey: "zalo", listAccountIds: listZaloAccountIds, @@ -239,6 +272,7 @@ export const zaloPlugin: ChannelPlugin = startAccount: async (ctx) => await (await loadZaloChannelRuntime()).startZaloGatewayAccount(ctx), }, + message: zaloMessageAdapter, }, security: { resolveDmPolicy: resolveZaloDmPolicy, diff --git a/extensions/zalo/src/monitor-durable.test.ts b/extensions/zalo/src/monitor-durable.test.ts new file mode 100644 index 00000000000..eaa2d239275 --- /dev/null +++ b/extensions/zalo/src/monitor-durable.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it, vi } from "vitest"; +import { + prepareZaloDurableReplyPayload, + resolveZaloDurableReplyOptions, +} from "./monitor-durable.js"; + +describe("Zalo durable reply helpers", () => { + it("normalizes markdown tables before durable or legacy delivery", () => { + const convertMarkdownTables = vi.fn(() => "converted table"); + + expect( + prepareZaloDurableReplyPayload({ + payload: { text: "| a |\n| - |" }, + tableMode: "code", + convertMarkdownTables, + }), + ).toEqual({ text: "converted table" }); + expect(convertMarkdownTables).toHaveBeenCalledWith("| a |\n| - |", "code"); + }); + + it("uses durable final delivery for text-only final replies", () => { + expect( + resolveZaloDurableReplyOptions({ + payload: { text: "hello" }, + infoKind: "final", + chatId: "123456789", + }), + ).toEqual({ + to: "123456789", + }); + }); + + it("keeps media and non-final replies on the legacy path", () => { + expect( + resolveZaloDurableReplyOptions({ + payload: { text: "photo", mediaUrl: "https://example.com/photo.jpg" }, + infoKind: "final", + chatId: "123456789", + }), + ).toBe(false); + expect( + resolveZaloDurableReplyOptions({ + payload: { text: "hello" }, + infoKind: "block", + chatId: "123456789", + }), + ).toBe(false); + }); +}); diff --git a/extensions/zalo/src/monitor-durable.ts b/extensions/zalo/src/monitor-durable.ts new file mode 100644 index 00000000000..b347d5e6f03 --- /dev/null +++ b/extensions/zalo/src/monitor-durable.ts @@ -0,0 +1,38 @@ +import type { MarkdownTableMode } from "openclaw/plugin-sdk/config-types"; +import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; +import type { OutboundReplyPayload } from "openclaw/plugin-sdk/reply-payload"; + +export type ZaloDurableReplyOptions = { + to: string; +}; + +export function prepareZaloDurableReplyPayload(params: { + payload: OutboundReplyPayload; + tableMode: MarkdownTableMode; + convertMarkdownTables: (text: string, tableMode: MarkdownTableMode) => string; +}): OutboundReplyPayload { + if (!params.payload.text) { + return params.payload; + } + return { + ...params.payload, + text: params.convertMarkdownTables(params.payload.text, params.tableMode), + }; +} + +export function resolveZaloDurableReplyOptions(params: { + payload: OutboundReplyPayload; + infoKind: string; + chatId: string; +}): ZaloDurableReplyOptions | false { + if (params.infoKind !== "final") { + return false; + } + const reply = resolveSendableOutboundReplyParts(params.payload); + if (reply.hasMedia || !reply.hasText) { + return false; + } + return { + to: params.chatId, + }; +} diff --git a/extensions/zalo/src/monitor.ts b/extensions/zalo/src/monitor.ts index 8c4c112e926..4bd00ec4ae4 100644 --- a/extensions/zalo/src/monitor.ts +++ b/extensions/zalo/src/monitor.ts @@ -1,7 +1,6 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import { logTypingFailure } from "openclaw/plugin-sdk/channel-feedback"; import { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing"; -import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline"; import { resolveDirectDmAuthorizationOutcome, resolveSenderCommandAuthorizationWithRuntime, @@ -41,6 +40,10 @@ import { import { resolveZaloProxyFetch } from "./proxy.js"; import { getZaloRuntime } from "./runtime.js"; export type { ZaloRuntimeEnv } from "./monitor.types.js"; +import { + prepareZaloDurableReplyPayload, + resolveZaloDurableReplyOptions, +} from "./monitor-durable.js"; import type { ZaloRuntimeEnv } from "./monitor.types.js"; import { prepareHostedZaloMediaUrl, @@ -640,11 +643,7 @@ async function processMessageWithPipeline(params: ZaloMessagePipelineParams): Pr channel: "zalo", accountId: account.accountId, }); - const { onModelSelected, ...replyPipeline } = createChannelReplyPipeline({ - cfg: config, - agentId: route.agentId, - channel: "zalo", - accountId: account.accountId, + const replyPipeline = { typing: { start: async () => { await sendChatAction( @@ -657,7 +656,7 @@ async function processMessageWithPipeline(params: ZaloMessagePipelineParams): Pr ZALO_TYPING_TIMEOUT_MS, ); }, - onStartError: (err) => { + onStartError: (err: unknown) => { logTypingFailure({ log: (message) => logVerbose(core, runtime, message), channel: "zalo", @@ -667,7 +666,7 @@ async function processMessageWithPipeline(params: ZaloMessagePipelineParams): Pr }); }, }, - }); + }; await core.channel.turn.run({ channel: "zalo", @@ -694,6 +693,18 @@ async function processMessageWithPipeline(params: ZaloMessagePipelineParams): Pr dispatchReplyWithBufferedBlockDispatcher: core.channel.reply.dispatchReplyWithBufferedBlockDispatcher, delivery: { + preparePayload: (payload) => + prepareZaloDurableReplyPayload({ + payload, + tableMode, + convertMarkdownTables: core.channel.text.convertMarkdownTables, + }), + durable: (payload, info) => + resolveZaloDurableReplyOptions({ + payload, + infoKind: info.kind, + chatId, + }), deliver: async (payload) => { await deliverZaloReply({ payload, @@ -710,19 +721,19 @@ async function processMessageWithPipeline(params: ZaloMessagePipelineParams): Pr accountId: account.accountId, statusSink, fetcher, - tableMode, + tableMode: "off", }); }, + onDelivered: () => { + statusSink?.({ lastOutboundAt: Date.now() }); + }, onError: (err, info) => { runtime.error?.( `[${account.accountId}] Zalo ${info.kind} reply failed: ${String(err)}`, ); }, }, - dispatcherOptions: replyPipeline, - replyOptions: { - onModelSelected, - }, + replyPipeline, record: { onRecordError: (err) => { runtime.error?.(`zalo: failed updating session meta: ${String(err)}`); diff --git a/extensions/zalo/src/outbound-payload.contract.test.ts b/extensions/zalo/src/outbound-payload.contract.test.ts index 424c0d68a06..9c94ec59b22 100644 --- a/extensions/zalo/src/outbound-payload.contract.test.ts +++ b/extensions/zalo/src/outbound-payload.contract.test.ts @@ -3,8 +3,12 @@ import { primeChannelOutboundSendMock, type OutboundPayloadHarnessParams, } from "openclaw/plugin-sdk/channel-contract-testing"; -import { describe, vi } from "vitest"; -import { zaloPlugin } from "./channel.js"; +import { + createMessageReceiptFromOutboundResults, + verifyChannelMessageAdapterCapabilityProofs, +} from "openclaw/plugin-sdk/channel-message"; +import { describe, expect, it, vi } from "vitest"; +import { zaloMessageAdapter, zaloPlugin } from "./channel.js"; const { sendZaloTextMock } = vi.hoisted(() => ({ sendZaloTextMock: vi.fn(), @@ -42,4 +46,61 @@ describe("Zalo outbound payload contract", () => { chunking: { mode: "split", longTextLength: 3000, maxChunkLength: 2000 }, createHarness: createZaloHarness, }); + + it("declares message adapter durable text and media with receipt proofs", async () => { + sendZaloTextMock.mockReset().mockImplementation(async (ctx: { mediaUrl?: string }) => + ctx.mediaUrl + ? { + ok: true, + messageId: "zl-media-1", + receipt: createMessageReceiptFromOutboundResults({ + results: [{ channel: "zalo", messageId: "zl-media-1" }], + kind: "media", + }), + } + : { + ok: true, + messageId: "zl-text-1", + receipt: createMessageReceiptFromOutboundResults({ + results: [{ channel: "zalo", messageId: "zl-text-1" }], + kind: "text", + }), + }, + ); + + await expect( + verifyChannelMessageAdapterCapabilityProofs({ + adapterName: "zalo", + adapter: zaloMessageAdapter, + proofs: { + text: async () => { + const result = await zaloMessageAdapter.send?.text?.({ + cfg: {}, + to: "123456789", + text: "hello", + }); + expect(result?.receipt.platformMessageIds).toEqual(["zl-text-1"]); + }, + media: async () => { + const result = await zaloMessageAdapter.send?.media?.({ + cfg: {}, + to: "123456789", + text: "image", + mediaUrl: "https://example.com/image.png", + }); + expect(result?.receipt.platformMessageIds).toEqual(["zl-media-1"]); + }, + messageSendingHooks: () => { + expect(zaloMessageAdapter.send?.text).toBeTypeOf("function"); + }, + }, + }), + ).resolves.toEqual( + expect.arrayContaining([ + { capability: "text", status: "verified" }, + { capability: "media", status: "verified" }, + { capability: "messageSendingHooks", status: "verified" }, + ]), + ); + }); }); diff --git a/extensions/zalo/src/runtime-api.ts b/extensions/zalo/src/runtime-api.ts index b28d9ec2d93..8869e76640c 100644 --- a/extensions/zalo/src/runtime-api.ts +++ b/extensions/zalo/src/runtime-api.ts @@ -17,7 +17,7 @@ export { type ChannelStatusIssue, chunkTextForOutbound, createChannelPairingController, - createChannelReplyPipeline, + createChannelMessageReplyPipeline, createDedupeCache, createFixedWindowRateLimiter, createWebhookAnomalyTracker, diff --git a/extensions/zalo/src/runtime-support.ts b/extensions/zalo/src/runtime-support.ts index ab857a4e089..33bfb0cbb63 100644 --- a/extensions/zalo/src/runtime-support.ts +++ b/extensions/zalo/src/runtime-support.ts @@ -58,7 +58,7 @@ export { resolveDefaultGroupPolicy, } from "openclaw/plugin-sdk/runtime-group-policy"; export { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing"; -export { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline"; +export { createChannelMessageReplyPipeline } from "openclaw/plugin-sdk/channel-message"; export { logTypingFailure } from "openclaw/plugin-sdk/channel-feedback"; export { deliverTextOrMediaReply, diff --git a/extensions/zalo/src/send.test.ts b/extensions/zalo/src/send.test.ts index 7606d1b43f7..d0567fbf520 100644 --- a/extensions/zalo/src/send.test.ts +++ b/extensions/zalo/src/send.test.ts @@ -42,7 +42,22 @@ describe("zalo send", () => { undefined, ); expect(sendPhotoMock).not.toHaveBeenCalled(); - expect(result).toEqual({ ok: true, messageId: "z-msg-1" }); + expect(result).toMatchObject({ ok: true, messageId: "z-msg-1" }); + expect(result.receipt).toMatchObject({ + primaryPlatformMessageId: "z-msg-1", + platformMessageIds: ["z-msg-1"], + parts: [ + { + platformMessageId: "z-msg-1", + kind: "text", + raw: { + channel: "zalo", + chatId: "dm-chat-1", + messageId: "z-msg-1", + }, + }, + ], + }); }); it("routes media-bearing sends through the photo API and uses text as caption", async () => { @@ -67,23 +82,35 @@ describe("zalo send", () => { undefined, ); expect(sendMessageMock).not.toHaveBeenCalled(); - expect(result).toEqual({ ok: true, messageId: "z-photo-1" }); + expect(result).toMatchObject({ ok: true, messageId: "z-photo-1" }); + expect(result.receipt).toMatchObject({ + primaryPlatformMessageId: "z-photo-1", + platformMessageIds: ["z-photo-1"], + parts: [ + { + platformMessageId: "z-photo-1", + kind: "media", + }, + ], + }); }); it("fails fast for missing token or blank photo URLs", async () => { - await expect(sendMessageZalo("dm-chat-3", "hello", {})).resolves.toEqual({ + const missingToken = await sendMessageZalo("dm-chat-3", "hello", {}); + expect(missingToken).toMatchObject({ ok: false, error: "No Zalo bot token configured", }); + expect(missingToken.receipt.platformMessageIds).toEqual([]); - await expect( - sendPhotoZalo("dm-chat-4", " ", { - token: "zalo-token", - }), - ).resolves.toEqual({ + const blankPhoto = await sendPhotoZalo("dm-chat-4", " ", { + token: "zalo-token", + }); + expect(blankPhoto).toMatchObject({ ok: false, error: "No photo URL provided", }); + expect(blankPhoto.receipt.platformMessageIds).toEqual([]); expect(sendMessageMock).not.toHaveBeenCalled(); expect(sendPhotoMock).not.toHaveBeenCalled(); @@ -115,6 +142,7 @@ describe("zalo send", () => { }, undefined, ); - expect(result).toEqual({ ok: true, messageId: "z-photo-2" }); + expect(result).toMatchObject({ ok: true, messageId: "z-photo-2" }); + expect(result.receipt.platformMessageIds).toEqual(["z-photo-2"]); }); }); diff --git a/extensions/zalo/src/send.ts b/extensions/zalo/src/send.ts index bc0f1f658a5..907c765ffef 100644 --- a/extensions/zalo/src/send.ts +++ b/extensions/zalo/src/send.ts @@ -1,3 +1,8 @@ +import { + createMessageReceiptFromOutboundResults, + type MessageReceipt, + type MessageReceiptPartKind, +} from "openclaw/plugin-sdk/channel-message"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { resolveZaloAccount } from "./accounts.js"; @@ -19,28 +24,69 @@ type ZaloSendOptions = { type ZaloSendResult = { ok: boolean; messageId?: string; + receipt: MessageReceipt; error?: string; }; -function toZaloSendResult(response: { - ok?: boolean; - result?: { message_id?: string }; -}): ZaloSendResult { +function createZaloSendReceipt(params: { + messageId?: string; + chatId: string; + kind: MessageReceiptPartKind; +}): MessageReceipt { + const messageId = params.messageId?.trim(); + return createMessageReceiptFromOutboundResults({ + results: messageId + ? [ + { + channel: "zalo", + messageId, + chatId: params.chatId, + }, + ] + : [], + kind: params.kind, + }); +} + +function toZaloSendResult( + response: { + ok?: boolean; + result?: { message_id?: string }; + }, + params: { chatId: string; kind: MessageReceiptPartKind }, +): ZaloSendResult { if (response.ok && response.result) { - return { ok: true, messageId: response.result.message_id }; + return { + ok: true, + messageId: response.result.message_id, + receipt: createZaloSendReceipt({ + messageId: response.result.message_id, + chatId: params.chatId, + kind: params.kind, + }), + }; } - return { ok: false, error: "Failed to send message" }; + return { + ok: false, + error: "Failed to send message", + receipt: createZaloSendReceipt({ chatId: params.chatId, kind: params.kind }), + }; } async function runZaloSend( failureMessage: string, + params: { chatId: string; kind: MessageReceiptPartKind }, send: () => Promise<{ ok?: boolean; result?: { message_id?: string } }>, ): Promise { try { - const result = toZaloSendResult(await send()); - return result.ok ? result : { ok: false, error: failureMessage }; + const result = toZaloSendResult(await send(), params); + return result.ok ? result : { ok: false, error: failureMessage, receipt: result.receipt }; } catch (err) { - return { ok: false, error: formatErrorMessage(err) }; + return { + ok: false, + error: formatErrorMessage(err), + receipt: createZaloSendReceipt({ chatId: params.chatId, kind: params.kind }), + }; } } @@ -88,7 +134,11 @@ function resolveSendContextOrFailure( return context.ok ? { context } : { - failure: { ok: false, error: context.error }, + failure: { + ok: false, + error: context.error, + receipt: createZaloSendReceipt({ chatId, kind: "unknown" }), + }, }; } @@ -111,7 +161,7 @@ export async function sendMessageZalo( }); } - return await runZaloSend("Failed to send message", () => + return await runZaloSend("Failed to send message", { chatId: context.chatId, kind: "text" }, () => sendMessage( context.token, { @@ -135,10 +185,14 @@ export async function sendPhotoZalo( const { context } = resolved; if (!photoUrl?.trim()) { - return { ok: false, error: "No photo URL provided" }; + return { + ok: false, + error: "No photo URL provided", + receipt: createZaloSendReceipt({ chatId: context.chatId, kind: "media" }), + }; } - return await runZaloSend("Failed to send photo", () => + return await runZaloSend("Failed to send photo", { chatId: context.chatId, kind: "media" }, () => (async () => sendPhoto( context.token, diff --git a/extensions/zalouser/runtime-api.ts b/extensions/zalouser/runtime-api.ts index e1de9b6d4a4..fb776173e0a 100644 --- a/extensions/zalouser/runtime-api.ts +++ b/extensions/zalouser/runtime-api.ts @@ -49,7 +49,7 @@ export { } from "openclaw/plugin-sdk/allow-from"; export { resolveInboundMentionDecision } from "openclaw/plugin-sdk/channel-inbound"; export { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing"; -export { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline"; +export { createChannelMessageReplyPipeline } from "openclaw/plugin-sdk/channel-message"; export { buildBaseAccountStatusSnapshot } from "openclaw/plugin-sdk/status-helpers"; export { resolveSenderCommandAuthorization } from "openclaw/plugin-sdk/command-auth"; export { diff --git a/extensions/zalouser/src/channel.adapters.ts b/extensions/zalouser/src/channel.adapters.ts index afe99f562cc..293c251da01 100644 --- a/extensions/zalouser/src/channel.adapters.ts +++ b/extensions/zalouser/src/channel.adapters.ts @@ -1,4 +1,5 @@ import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers"; +import { defineChannelMessageAdapter } from "openclaw/plugin-sdk/channel-message"; import { createPairingPrefixStripper } from "openclaw/plugin-sdk/channel-pairing"; import { createEmptyChannelResult, @@ -43,6 +44,19 @@ const loadZalouserChannelRuntime = createLazyRuntimeModule(() => import("./chann const ZALOUSER_TEXT_CHUNK_LIMIT = 2000; +type ZalouserSendTextContext = { + to: string; + text: string; + accountId?: string | null; + cfg: OpenClawConfig; +}; + +type ZalouserSendMediaContext = ZalouserSendTextContext & { + mediaUrl?: string; + mediaLocalRoots?: readonly string[]; + mediaReadFile?: (filePath: string) => Promise; +}; + export function resolveZalouserQrProfile(accountId?: string | null): string { const normalized = normalizeAccountId(accountId); if (!normalized || normalized === DEFAULT_ACCOUNT_ID) { @@ -92,34 +106,61 @@ function resolveZalouserRequireMention(params: ChannelGroupContext): boolean { return true; } +async function sendZalouserTextFromContext({ to, text, accountId, cfg }: ZalouserSendTextContext) { + const { sendMessageZalouser } = await loadZalouserChannelRuntime(); + const account = resolveZalouserAccountSync({ cfg: cfg, accountId }); + const target = parseZalouserOutboundTarget(to); + return await sendMessageZalouser(target.threadId, text, { + profile: account.profile, + isGroup: target.isGroup, + textMode: "markdown", + textChunkMode: resolveZalouserOutboundChunkMode(cfg, account.accountId), + textChunkLimit: resolveZalouserOutboundTextChunkLimit(cfg, account.accountId), + }); +} + +async function sendZalouserMediaFromContext({ + to, + text, + mediaUrl, + accountId, + cfg, + mediaLocalRoots, + mediaReadFile, +}: ZalouserSendMediaContext) { + const { sendMessageZalouser } = await loadZalouserChannelRuntime(); + const account = resolveZalouserAccountSync({ cfg: cfg, accountId }); + const target = parseZalouserOutboundTarget(to); + return await sendMessageZalouser(target.threadId, text, { + profile: account.profile, + isGroup: target.isGroup, + mediaUrl, + mediaLocalRoots, + mediaReadFile, + textMode: "markdown", + textChunkMode: resolveZalouserOutboundChunkMode(cfg, account.accountId), + textChunkLimit: resolveZalouserOutboundTextChunkLimit(cfg, account.accountId), + }); +} + const zalouserRawSendResultAdapter = createRawChannelSendResultAdapter({ channel: "zalouser", - sendText: async ({ to, text, accountId, cfg }) => { - const { sendMessageZalouser } = await loadZalouserChannelRuntime(); - const account = resolveZalouserAccountSync({ cfg: cfg, accountId }); - const target = parseZalouserOutboundTarget(to); - return await sendMessageZalouser(target.threadId, text, { - profile: account.profile, - isGroup: target.isGroup, - textMode: "markdown", - textChunkMode: resolveZalouserOutboundChunkMode(cfg, account.accountId), - textChunkLimit: resolveZalouserOutboundTextChunkLimit(cfg, account.accountId), - }); + sendText: sendZalouserTextFromContext, + sendMedia: sendZalouserMediaFromContext, +}); + +export const zalouserMessageAdapter = defineChannelMessageAdapter({ + id: "zalouser", + durableFinal: { + capabilities: { + text: true, + media: true, + messageSendingHooks: true, + }, }, - sendMedia: async ({ to, text, mediaUrl, accountId, cfg, mediaLocalRoots, mediaReadFile }) => { - const { sendMessageZalouser } = await loadZalouserChannelRuntime(); - const account = resolveZalouserAccountSync({ cfg: cfg, accountId }); - const target = parseZalouserOutboundTarget(to); - return await sendMessageZalouser(target.threadId, text, { - profile: account.profile, - isGroup: target.isGroup, - mediaUrl, - mediaLocalRoots, - mediaReadFile, - textMode: "markdown", - textChunkMode: resolveZalouserOutboundChunkMode(cfg, account.accountId), - textChunkLimit: resolveZalouserOutboundTextChunkLimit(cfg, account.accountId), - }); + send: { + text: sendZalouserTextFromContext, + media: sendZalouserMediaFromContext, }, }); diff --git a/extensions/zalouser/src/channel.sendpayload.test.ts b/extensions/zalouser/src/channel.sendpayload.test.ts index efb179b4860..8709a58910a 100644 --- a/extensions/zalouser/src/channel.sendpayload.test.ts +++ b/extensions/zalouser/src/channel.sendpayload.test.ts @@ -3,6 +3,10 @@ import { primeChannelOutboundSendMock, type OutboundPayloadHarnessParams, } from "openclaw/plugin-sdk/channel-contract-testing"; +import { + createMessageReceiptFromOutboundResults, + verifyChannelMessageAdapterCapabilityProofs, +} from "openclaw/plugin-sdk/channel-message"; import { beforeEach, describe, expect, it, vi } from "vitest"; import "./accounts.test-mocks.js"; import "./zalo-js.test-mocks.js"; @@ -12,8 +16,8 @@ import { setZalouserRuntime } from "./runtime.js"; import * as sendModule from "./send.js"; vi.mock("./send.js", () => ({ - sendMessageZalouser: vi.fn().mockResolvedValue({ ok: true, messageId: "zlu-1" }), - sendReactionZalouser: vi.fn().mockResolvedValue({ ok: true }), + sendMessageZalouser: vi.fn().mockResolvedValue({ ok: true, messageId: "zlu-1" } as never), + sendReactionZalouser: vi.fn().mockResolvedValue({ ok: true } as never), })); function baseCtx(payload: ReplyPayload) { @@ -42,7 +46,7 @@ describe("zalouserPlugin outbound sendPayload", () => { }); it("group target delegates with isGroup=true and stripped threadId", async () => { - mockedSend.mockResolvedValue({ ok: true, messageId: "zlu-g1" }); + mockedSend.mockResolvedValue({ ok: true, messageId: "zlu-g1" } as never); const result = await zalouserPlugin.outbound!.sendPayload!({ ...baseCtx({ text: "hello group" }), @@ -58,7 +62,7 @@ describe("zalouserPlugin outbound sendPayload", () => { }); it("treats bare numeric targets as direct chats for backward compatibility", async () => { - mockedSend.mockResolvedValue({ ok: true, messageId: "zlu-d1" }); + mockedSend.mockResolvedValue({ ok: true, messageId: "zlu-d1" } as never); const result = await zalouserPlugin.outbound!.sendPayload!({ ...baseCtx({ text: "hello" }), @@ -74,7 +78,7 @@ describe("zalouserPlugin outbound sendPayload", () => { }); it("preserves provider-native group ids when sending to raw g- targets", async () => { - mockedSend.mockResolvedValue({ ok: true, messageId: "zlu-g-native" }); + mockedSend.mockResolvedValue({ ok: true, messageId: "zlu-g-native" } as never); const result = await zalouserPlugin.outbound!.sendPayload!({ ...baseCtx({ text: "hello native group" }), @@ -91,7 +95,7 @@ describe("zalouserPlugin outbound sendPayload", () => { it("passes long markdown through once so formatting happens before chunking", async () => { const text = `**${"a".repeat(2501)}**`; - mockedSend.mockResolvedValue({ ok: true, messageId: "zlu-code" }); + mockedSend.mockResolvedValue({ ok: true, messageId: "zlu-code" } as never); const result = await zalouserPlugin.outbound!.sendPayload!({ ...baseCtx({ text }), @@ -111,6 +115,65 @@ describe("zalouserPlugin outbound sendPayload", () => { ); expect(result).toMatchObject({ channel: "zalouser", messageId: "zlu-code" }); }); + + it("declares message adapter durable text and media with receipt proofs", async () => { + mockedSend.mockImplementation(async (_threadId, _text, opts: { mediaUrl?: string } = {}) => + opts.mediaUrl + ? { + ok: true, + messageId: "zlu-media-1", + receipt: createMessageReceiptFromOutboundResults({ + results: [{ channel: "zalouser", messageId: "zlu-media-1" }], + kind: "media", + }), + } + : { + ok: true, + messageId: "zlu-text-1", + receipt: createMessageReceiptFromOutboundResults({ + results: [{ channel: "zalouser", messageId: "zlu-text-1" }], + kind: "text", + }), + }, + ); + + await expect( + verifyChannelMessageAdapterCapabilityProofs({ + adapterName: "zalouser", + adapter: zalouserPlugin.message!, + proofs: { + text: async () => { + const result = await zalouserPlugin.message?.send?.text?.({ + cfg: {}, + to: "user:987654321", + text: "hello", + }); + expect(result?.receipt.platformMessageIds).toEqual(["zlu-text-1"]); + }, + media: async () => { + const result = await zalouserPlugin.message?.send?.media?.({ + cfg: {}, + to: "user:987654321", + text: "image", + mediaUrl: "https://example.com/image.png", + }); + expect(result?.receipt.platformMessageIds).toEqual(["zlu-media-1"]); + }, + messageSendingHooks: () => { + expect(zalouserPlugin.message?.durableFinal?.capabilities?.messageSendingHooks).toBe( + true, + ); + }, + }, + }), + ).resolves.toEqual( + expect.arrayContaining([ + { capability: "text", status: "verified" }, + { capability: "media", status: "verified" }, + { capability: "messageSendingHooks", status: "verified" }, + ]), + ); + }); }); describe("zalouserPlugin outbound payload contract", () => { diff --git a/extensions/zalouser/src/channel.test.ts b/extensions/zalouser/src/channel.test.ts index c94bbf31963..e61d012c600 100644 --- a/extensions/zalouser/src/channel.test.ts +++ b/extensions/zalouser/src/channel.test.ts @@ -142,7 +142,7 @@ describe("zalouser outbound chunking", () => { describe("zalouser channel policies", () => { beforeEach(() => { mockSendReaction.mockClear(); - mockSendReaction.mockResolvedValue({ ok: true }); + mockSendReaction.mockResolvedValue({ ok: true } as never); }); it("normalizes dm allowlist entries after trimming channel prefixes", () => { diff --git a/extensions/zalouser/src/channel.ts b/extensions/zalouser/src/channel.ts index f2d75caadd0..045c423c2d9 100644 --- a/extensions/zalouser/src/channel.ts +++ b/extensions/zalouser/src/channel.ts @@ -16,6 +16,7 @@ import { DEFAULT_ACCOUNT_ID } from "./channel-api.js"; import { zalouserAuthAdapter, zalouserGroupsAdapter, + zalouserMessageAdapter, zalouserMessageActions, zalouserMessagingAdapter, zalouserOutboundAdapter, @@ -131,6 +132,7 @@ export const zalouserPlugin: ChannelPlugin( { defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID), diff --git a/extensions/zalouser/src/monitor.group-gating.test.ts b/extensions/zalouser/src/monitor.group-gating.test.ts index a6a878b3df9..c0ec05578c4 100644 --- a/extensions/zalouser/src/monitor.group-gating.test.ts +++ b/extensions/zalouser/src/monitor.group-gating.test.ts @@ -1,3 +1,4 @@ +import { createChannelMessageReplyPipeline } from "openclaw/plugin-sdk/channel-message"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig, PluginRuntime } from "../runtime-api.js"; import "./monitor.send-mocks.js"; @@ -117,17 +118,28 @@ function installRuntime(params: { dispatchResult, }; } + const { onModelSelected, ...replyPipeline } = createChannelMessageReplyPipeline({ + cfg: turn.cfg, + agentId: turn.agentId, + channel: "zalouser", + accountId: turn.accountId, + ...turn.replyPipeline, + }); const dispatchResult = await turn.dispatchReplyWithBufferedBlockDispatcher({ ctx: turn.ctxPayload, cfg: turn.cfg, dispatcherOptions: { + ...replyPipeline, ...turn.dispatcherOptions, deliver: async (...args: Parameters) => { await turn.delivery.deliver(...args); }, onError: turn.delivery.onError, }, - replyOptions: turn.replyOptions, + replyOptions: { + onModelSelected, + ...turn.replyOptions, + }, replyResolver: turn.replyResolver, }); return { diff --git a/extensions/zalouser/src/monitor.ts b/extensions/zalouser/src/monitor.ts index cf42a67e9fb..6eb37c470bf 100644 --- a/extensions/zalouser/src/monitor.ts +++ b/extensions/zalouser/src/monitor.ts @@ -8,7 +8,6 @@ import { DM_GROUP_ACCESS_REASON, resolveDmGroupAccessWithLists, } from "openclaw/plugin-sdk/channel-policy"; -import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline"; import { resolveSenderCommandAuthorization } from "openclaw/plugin-sdk/command-auth"; import type { MarkdownTableMode, OpenClawConfig } from "openclaw/plugin-sdk/config-types"; import { KeyedAsyncQueue } from "openclaw/plugin-sdk/core"; @@ -652,11 +651,7 @@ async function processMessage( }, }); - const { onModelSelected, ...replyPipeline } = createChannelReplyPipeline({ - cfg: config, - agentId: route.agentId, - channel: "zalouser", - accountId: account.accountId, + const replyPipeline = { typing: { start: async () => { await sendTypingZalouser(chatId, { @@ -664,14 +659,14 @@ async function processMessage( isGroup, }); }, - onStartError: (err) => { + onStartError: (err: unknown) => { runtime.error?.( `[${account.accountId}] zalouser typing start failed for ${chatId}: ${String(err)}`, ); logVerbose(core, runtime, `zalouser typing failed for ${chatId}: ${String(err)}`); }, }, - }); + }; await core.channel.turn.run({ channel: "zalouser", @@ -698,8 +693,27 @@ async function processMessage( dispatchReplyWithBufferedBlockDispatcher: core.channel.reply.dispatchReplyWithBufferedBlockDispatcher, delivery: { + preparePayload: (payload) => { + if (payload.text === undefined) { + return payload; + } + return { + ...payload, + text: core.channel.text.convertMarkdownTables( + payload.text, + core.channel.text.resolveMarkdownTableMode({ + cfg: config, + channel: "zalouser", + accountId: account.accountId, + }), + ), + }; + }, + durable: () => ({ + to: normalizedTo, + }), deliver: async (payload) => { - await deliverZalouserReply({ + return await deliverZalouserReply({ payload: payload as { text?: string; mediaUrls?: string[]; mediaUrl?: string }, profile: account.profile, chatId, @@ -708,24 +722,21 @@ async function processMessage( core, config, accountId: account.accountId, - statusSink, - tableMode: core.channel.text.resolveMarkdownTableMode({ - cfg: config, - channel: "zalouser", - accountId: account.accountId, - }), + tableMode: "off", }); }, + onDelivered: (_payload, _info, result) => { + if (result?.visibleReplySent !== false) { + statusSink?.({ lastOutboundAt: Date.now() }); + } + }, onError: (err, info) => { runtime.error( `[${account.accountId}] Zalouser ${info.kind} reply failed: ${String(err)}`, ); }, }, - dispatcherOptions: replyPipeline, - replyOptions: { - onModelSelected, - }, + replyPipeline, record: { onRecordError: (err) => { runtime.error?.(`zalouser: failed updating session meta: ${String(err)}`); @@ -752,12 +763,11 @@ async function deliverZalouserReply(params: { core: ZalouserCoreRuntime; config: OpenClawConfig; accountId?: string; - statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void; tableMode?: MarkdownTableMode; -}): Promise { - const { payload, profile, chatId, isGroup, runtime, core, config, accountId, statusSink } = - params; +}): Promise<{ visibleReplySent: boolean }> { + const { payload, profile, chatId, isGroup, runtime, core, config, accountId } = params; const tableMode = params.tableMode ?? "code"; + let visibleReplySent = false; const reply = resolveSendableOutboundReplyParts(payload, { text: core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode), }); @@ -777,7 +787,7 @@ async function deliverZalouserReply(params: { textChunkMode: chunkMode, textChunkLimit, }); - statusSink?.({ lastOutboundAt: Date.now() }); + visibleReplySent = true; } catch (err) { runtime.error(`Zalouser message send failed: ${String(err)}`); } @@ -792,7 +802,7 @@ async function deliverZalouserReply(params: { textChunkMode: chunkMode, textChunkLimit, }); - statusSink?.({ lastOutboundAt: Date.now() }); + visibleReplySent = true; }, onMediaError: (error) => { runtime.error( @@ -802,6 +812,7 @@ async function deliverZalouserReply(params: { ); }, }); + return { visibleReplySent }; } export async function monitorZalouserProvider( diff --git a/extensions/zalouser/src/send-receipt.ts b/extensions/zalouser/src/send-receipt.ts new file mode 100644 index 00000000000..51d82ecf4f1 --- /dev/null +++ b/extensions/zalouser/src/send-receipt.ts @@ -0,0 +1,31 @@ +import { + createMessageReceiptFromOutboundResults, + type MessageReceipt, + type MessageReceiptPartKind, +} from "openclaw/plugin-sdk/channel-message"; + +export function createZalouserSendReceipt(params: { + messageId?: string; + platformMessageIds?: readonly (string | null | undefined)[]; + threadId?: string; + kind?: MessageReceiptPartKind; +}): MessageReceipt { + const platformMessageIds = (params.platformMessageIds ?? [params.messageId]) + .map((messageId) => messageId?.trim()) + .filter((messageId): messageId is string => Boolean(messageId)); + const threadId = params.threadId?.trim(); + return createMessageReceiptFromOutboundResults({ + results: platformMessageIds.map((messageId) => { + const result: { channel: string; messageId: string; conversationId?: string } = { + channel: "zalouser", + messageId, + }; + if (threadId) { + result.conversationId = threadId; + } + return result; + }), + ...(threadId ? { threadId } : {}), + kind: params.kind ?? "unknown", + }); +} diff --git a/extensions/zalouser/src/send.test.ts b/extensions/zalouser/src/send.test.ts index 432d37b3157..4614b785bdc 100644 --- a/extensions/zalouser/src/send.test.ts +++ b/extensions/zalouser/src/send.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createZalouserSendReceipt } from "./send-receipt.js"; import { sendDeliveredZalouser, sendImageZalouser, @@ -35,6 +36,29 @@ const mockSendReaction = vi.mocked(sendZaloReaction); const mockSendDelivered = vi.mocked(sendZaloDeliveredEvent); const mockSendSeen = vi.mocked(sendZaloSeenEvent); +function sendResult( + messageId: string, + threadId = "thread", +): { + ok: true; + messageId: string; + receipt: ReturnType; +} { + return { + ok: true, + messageId, + receipt: createZalouserSendReceipt({ messageId, threadId, kind: "text" }), + }; +} + +function sendFailure(error: string, threadId = "thread") { + return { + ok: false, + error, + receipt: createZalouserSendReceipt({ threadId, kind: "unknown" }), + }; +} + describe("zalouser send helpers", () => { beforeEach(() => { mockSendText.mockReset(); @@ -46,7 +70,7 @@ describe("zalouser send helpers", () => { }); it("keeps plain text literal by default", async () => { - mockSendText.mockResolvedValueOnce({ ok: true, messageId: "mid-1" }); + mockSendText.mockResolvedValueOnce(sendResult("mid-1", "thread-1")); const result = await sendMessageZalouser("thread-1", "**hello**", { profile: "default", @@ -61,11 +85,12 @@ describe("zalouser send helpers", () => { isGroup: true, }), ); - expect(result).toEqual({ ok: true, messageId: "mid-1" }); + expect(result).toMatchObject({ ok: true, messageId: "mid-1" }); + expect(result.receipt.primaryPlatformMessageId).toBe("mid-1"); }); it("formats markdown text when markdown mode is enabled", async () => { - mockSendText.mockResolvedValueOnce({ ok: true, messageId: "mid-1b" }); + mockSendText.mockResolvedValueOnce(sendResult("mid-1b", "thread-1")); await sendMessageZalouser("thread-1", "**hello**", { profile: "default", @@ -86,7 +111,7 @@ describe("zalouser send helpers", () => { }); it("formats image captions in markdown mode", async () => { - mockSendText.mockResolvedValueOnce({ ok: true, messageId: "mid-2" }); + mockSendText.mockResolvedValueOnce(sendResult("mid-2", "thread-2")); await sendImageZalouser("thread-2", "https://example.com/a.png", { profile: "p2", @@ -110,7 +135,7 @@ describe("zalouser send helpers", () => { }); it("does not keep the raw markdown caption as a media fallback after formatting", async () => { - mockSendText.mockResolvedValueOnce({ ok: true, messageId: "mid-2b" }); + mockSendText.mockResolvedValueOnce(sendResult("mid-2b", "thread-2")); await sendImageZalouser("thread-2", "https://example.com/a.png", { profile: "p2", @@ -137,8 +162,8 @@ describe("zalouser send helpers", () => { const text = "\t".repeat(500) + "a".repeat(1500); const formatted = parseZalouserTextStyles(text); mockSendText - .mockResolvedValueOnce({ ok: true, messageId: "mid-2c-1" }) - .mockResolvedValueOnce({ ok: true, messageId: "mid-2c-2" }); + .mockResolvedValueOnce(sendResult("mid-2c-1", "thread-2c")) + .mockResolvedValueOnce(sendResult("mid-2c-2", "thread-2c")); const result = await sendMessageZalouser("thread-2c", text, { profile: "p2c", @@ -150,14 +175,14 @@ describe("zalouser send helpers", () => { expect(mockSendText).toHaveBeenCalledTimes(2); expect(mockSendText.mock.calls.map((call) => call[1]).join("")).toBe(formatted.text); expect(mockSendText.mock.calls.every((call) => call[1].length <= 2000)).toBe(true); - expect(result).toEqual({ ok: true, messageId: "mid-2c-2" }); + expect(result).toMatchObject({ ok: true, messageId: "mid-2c-2" }); }); it("preserves text styles when splitting long formatted markdown", async () => { const text = `**${"a".repeat(2501)}**`; mockSendText - .mockResolvedValueOnce({ ok: true, messageId: "mid-2d-1" }) - .mockResolvedValueOnce({ ok: true, messageId: "mid-2d-2" }); + .mockResolvedValueOnce(sendResult("mid-2d-1", "thread-2d")) + .mockResolvedValueOnce(sendResult("mid-2d-2", "thread-2d")); const result = await sendMessageZalouser("thread-2d", text, { profile: "p2d", @@ -187,15 +212,15 @@ describe("zalouser send helpers", () => { textStyles: [{ start: 0, len: 501, st: TextStyle.Bold }], }), ); - expect(result).toEqual({ ok: true, messageId: "mid-2d-2" }); + expect(result).toMatchObject({ ok: true, messageId: "mid-2d-2" }); }); it("preserves formatted text and styles when newline chunk mode splits after parsing", async () => { const text = `**${"a".repeat(1995)}**\n\nsecond paragraph`; const formatted = parseZalouserTextStyles(text); mockSendText - .mockResolvedValueOnce({ ok: true, messageId: "mid-2d-3" }) - .mockResolvedValueOnce({ ok: true, messageId: "mid-2d-4" }); + .mockResolvedValueOnce(sendResult("mid-2d-3", "thread-2d-2")) + .mockResolvedValueOnce(sendResult("mid-2d-4", "thread-2d-2")); const result = await sendMessageZalouser("thread-2d-2", text, { profile: "p2d-2", @@ -230,14 +255,14 @@ describe("zalouser send helpers", () => { textStyles: undefined, }), ); - expect(result).toEqual({ ok: true, messageId: "mid-2d-4" }); + expect(result).toMatchObject({ ok: true, messageId: "mid-2d-4" }); }); it("respects an explicit text chunk limit when splitting formatted markdown", async () => { const text = `**${"a".repeat(1501)}**`; mockSendText - .mockResolvedValueOnce({ ok: true, messageId: "mid-2d-5" }) - .mockResolvedValueOnce({ ok: true, messageId: "mid-2d-6" }); + .mockResolvedValueOnce(sendResult("mid-2d-5", "thread-2d-3")) + .mockResolvedValueOnce(sendResult("mid-2d-6", "thread-2d-3")); const result = await sendMessageZalouser("thread-2d-3", text, { profile: "p2d-3", @@ -271,15 +296,15 @@ describe("zalouser send helpers", () => { textStyles: [{ start: 0, len: 301, st: TextStyle.Bold }], }), ); - expect(result).toEqual({ ok: true, messageId: "mid-2d-6" }); + expect(result).toMatchObject({ ok: true, messageId: "mid-2d-6" }); }); it("sends overflow markdown captions as follow-up text after the media message", async () => { const caption = "\t".repeat(500) + "a".repeat(1500); const formatted = parseZalouserTextStyles(caption); mockSendText - .mockResolvedValueOnce({ ok: true, messageId: "mid-2e-1" }) - .mockResolvedValueOnce({ ok: true, messageId: "mid-2e-2" }); + .mockResolvedValueOnce(sendResult("mid-2e-1", "thread-2e")) + .mockResolvedValueOnce(sendResult("mid-2e-2", "thread-2e")); const result = await sendImageZalouser("thread-2e", "https://example.com/long.png", { profile: "p2e", @@ -310,11 +335,11 @@ describe("zalouser send helpers", () => { mediaUrl: "https://example.com/long.png", }), ); - expect(result).toEqual({ ok: true, messageId: "mid-2e-2" }); + expect(result).toMatchObject({ ok: true, messageId: "mid-2e-2" }); }); it("delegates link helper to JS transport", async () => { - mockSendLink.mockResolvedValueOnce({ ok: false, error: "boom" }); + mockSendLink.mockResolvedValueOnce(sendFailure("boom", "thread-3")); const result = await sendLinkZalouser("thread-3", "https://openclaw.ai", { profile: "p3", @@ -325,7 +350,7 @@ describe("zalouser send helpers", () => { profile: "p3", isGroup: true, }); - expect(result).toEqual({ ok: false, error: "boom" }); + expect(result).toMatchObject({ ok: false, error: "boom" }); }); it("delegates typing helper to JS transport", async () => { @@ -358,7 +383,8 @@ describe("zalouser send helpers", () => { emoji: "👍", remove: undefined, }); - expect(result).toEqual({ ok: true, error: undefined }); + expect(result).toMatchObject({ ok: true, error: undefined }); + expect(result.receipt.platformMessageIds).toEqual([]); }); it("delegates delivered+seen helpers to JS transport", async () => { diff --git a/extensions/zalouser/src/send.ts b/extensions/zalouser/src/send.ts index 287b7be4006..c40d5de714a 100644 --- a/extensions/zalouser/src/send.ts +++ b/extensions/zalouser/src/send.ts @@ -1,3 +1,4 @@ +import { createZalouserSendReceipt } from "./send-receipt.js"; import { parseZalouserTextStyles } from "./text-styles.js"; import type { ZaloEventMessage, ZaloSendOptions, ZaloSendResult } from "./types.js"; import { @@ -59,7 +60,13 @@ export async function sendMessageZalouser( lastResult = result; } - return lastResult ?? { ok: false, error: "No message content provided" }; + return ( + lastResult ?? { + ok: false, + error: "No message content provided", + receipt: createZalouserSendReceipt({ threadId, kind: "text" }), + } + ); } export async function sendImageZalouser( @@ -110,6 +117,7 @@ export async function sendReactionZalouser(params: { return { ok: result.ok, error: result.error, + receipt: createZalouserSendReceipt({ threadId: params.threadId, kind: "unknown" }), }; } diff --git a/extensions/zalouser/src/tool.test.ts b/extensions/zalouser/src/tool.test.ts index d0b8c502a38..8ba0e606aa5 100644 --- a/extensions/zalouser/src/tool.test.ts +++ b/extensions/zalouser/src/tool.test.ts @@ -54,7 +54,7 @@ describe("executeZalouserTool", () => { }); it("sends text message for send action", async () => { - mockSendMessage.mockResolvedValueOnce({ ok: true, messageId: "m-1" }); + mockSendMessage.mockResolvedValueOnce({ ok: true, messageId: "m-1" } as never); const result = await executeZalouserTool("tool-1", { action: "send", threadId: "t-1", @@ -70,7 +70,7 @@ describe("executeZalouserTool", () => { }); it("defaults send routing from ambient deliveryContext target", async () => { - mockSendMessage.mockResolvedValueOnce({ ok: true, messageId: "m-ambient" }); + mockSendMessage.mockResolvedValueOnce({ ok: true, messageId: "m-ambient" } as never); const tool = createZalouserTool({ deliveryContext: { channel: "zalouser", @@ -91,7 +91,7 @@ describe("executeZalouserTool", () => { }); it("keeps explicit threadId over ambient delivery defaults", async () => { - mockSendMessage.mockResolvedValueOnce({ ok: true, messageId: "m-explicit" }); + mockSendMessage.mockResolvedValueOnce({ ok: true, messageId: "m-explicit" } as never); const tool = createZalouserTool({ deliveryContext: { channel: "zalouser", @@ -133,7 +133,7 @@ describe("executeZalouserTool", () => { }); it("returns tool error when send action fails", async () => { - mockSendMessage.mockResolvedValueOnce({ ok: false, error: "blocked" }); + mockSendMessage.mockResolvedValueOnce({ ok: false, error: "blocked" } as never); const result = await executeZalouserTool("tool-1", { action: "send", threadId: "t-1", @@ -143,7 +143,7 @@ describe("executeZalouserTool", () => { }); it("routes image and link actions to correct helpers", async () => { - mockSendImage.mockResolvedValueOnce({ ok: true, messageId: "img-1" }); + mockSendImage.mockResolvedValueOnce({ ok: true, messageId: "img-1" } as never); const imageResult = await executeZalouserTool("tool-1", { action: "image", threadId: "g-1", @@ -158,7 +158,7 @@ describe("executeZalouserTool", () => { }); expect(extractDetails(imageResult)).toEqual({ success: true, messageId: "img-1" }); - mockSendLink.mockResolvedValueOnce({ ok: true, messageId: "lnk-1" }); + mockSendLink.mockResolvedValueOnce({ ok: true, messageId: "lnk-1" } as never); const linkResult = await executeZalouserTool("tool-1", { action: "link", threadId: "t-2", diff --git a/extensions/zalouser/src/types.ts b/extensions/zalouser/src/types.ts index f007874776c..630956d6c5d 100644 --- a/extensions/zalouser/src/types.ts +++ b/extensions/zalouser/src/types.ts @@ -1,3 +1,4 @@ +import type { MessageReceipt } from "openclaw/plugin-sdk/channel-message"; import type { Style } from "./zca-constants.js"; export type ZcaFriend = { @@ -71,6 +72,7 @@ export type ZaloSendOptions = { export type ZaloSendResult = { ok: boolean; messageId?: string; + receipt: MessageReceipt; error?: string; }; diff --git a/extensions/zalouser/src/zalo-js.ts b/extensions/zalouser/src/zalo-js.ts index d56c9cd2084..90cdee92951 100644 --- a/extensions/zalouser/src/zalo-js.ts +++ b/extensions/zalouser/src/zalo-js.ts @@ -10,6 +10,7 @@ import { normalizeOptionalString, } from "openclaw/plugin-sdk/text-runtime"; import { normalizeZaloReactionIcon } from "./reaction.js"; +import { createZalouserSendReceipt } from "./send-receipt.js"; import type { ZaloAuthStatus, ZaloEventMessage, @@ -1243,7 +1244,11 @@ export async function sendZaloTextMessage( const profile = normalizeProfile(options.profile); const trimmedThreadId = threadId.trim(); if (!trimmedThreadId) { - return { ok: false, error: "No threadId provided" }; + return { + ok: false, + error: "No threadId provided", + receipt: createZalouserSendReceipt({ threadId, kind: "unknown" }), + }; } return await withZaloApi( @@ -1297,9 +1302,15 @@ export async function sendZaloTextMessage( } const voiceUrl = buildZaloVoicePlaybackUrl(voiceAsset); const response = await api.sendVoice({ voiceUrl }, trimmedThreadId, type); + const voiceMessageId = extractSendMessageId(response); return { ok: true, - messageId: extractSendMessageId(response) ?? textMessageId, + messageId: voiceMessageId ?? textMessageId, + receipt: createZalouserSendReceipt({ + platformMessageIds: [textMessageId, voiceMessageId], + threadId: trimmedThreadId, + kind: "voice", + }), }; } @@ -1320,7 +1331,16 @@ export async function sendZaloTextMessage( trimmedThreadId, type, ); - return { ok: true, messageId: extractSendMessageId(response) }; + const messageId = extractSendMessageId(response); + return { + ok: true, + messageId, + receipt: createZalouserSendReceipt({ + messageId, + threadId: trimmedThreadId, + kind: "media", + }), + }; } const payloadText = text.slice(0, 2000); @@ -1330,9 +1350,22 @@ export async function sendZaloTextMessage( trimmedThreadId, type, ); - return { ok: true, messageId: extractSendMessageId(response) }; + const messageId = extractSendMessageId(response); + return { + ok: true, + messageId, + receipt: createZalouserSendReceipt({ + messageId, + threadId: trimmedThreadId, + kind: "text", + }), + }; } catch (error) { - return { ok: false, error: toErrorMessage(error) }; + return { + ok: false, + error: toErrorMessage(error), + receipt: createZalouserSendReceipt({ threadId: trimmedThreadId, kind: "unknown" }), + }; } }, { shouldPersist: (result) => result.ok }, @@ -1453,10 +1486,18 @@ export async function sendZaloLink( const trimmedThreadId = threadId.trim(); const trimmedUrl = url.trim(); if (!trimmedThreadId) { - return { ok: false, error: "No threadId provided" }; + return { + ok: false, + error: "No threadId provided", + receipt: createZalouserSendReceipt({ threadId, kind: "unknown" }), + }; } if (!trimmedUrl) { - return { ok: false, error: "No URL provided" }; + return { + ok: false, + error: "No URL provided", + receipt: createZalouserSendReceipt({ threadId: trimmedThreadId, kind: "card" }), + }; } try { @@ -1469,12 +1510,25 @@ export async function sendZaloLink( trimmedThreadId, type, ); - return { ok: true, messageId: String(response.msgId) }; + const messageId = String(response.msgId); + return { + ok: true, + messageId, + receipt: createZalouserSendReceipt({ + messageId, + threadId: trimmedThreadId, + kind: "card", + }), + }; }, { shouldPersist: (result) => result.ok }, ); } catch (error) { - return { ok: false, error: toErrorMessage(error) }; + return { + ok: false, + error: toErrorMessage(error), + receipt: createZalouserSendReceipt({ threadId: trimmedThreadId, kind: "card" }), + }; } }