From f9b1ccc4fa2eea2b8083fe29ce5092b15db268a4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 16 Aug 2026 22:55:41 -0700 Subject: [PATCH] refactor(plugin-sdk): discriminate delivery and exec results (#124979) * refactor(plugin-sdk): discriminate delivery and exec results * test(plugin-sdk): update delivery target expectations * fix(outbound): preserve canonical delivery targets * fix(irc): normalize message delivery target --- docs/plugins/sdk-migration.md | 7 +- ...outbound-adapter.interactive-order.test.ts | 2 +- .../src/outbound-adapter.test-harness.ts | 2 +- .../discord/src/outbound-adapter.test.ts | 14 +- extensions/discord/src/outbound-adapter.ts | 46 +-- extensions/discord/src/outbound-payload.ts | 24 +- .../discord/src/question-finalization.test.ts | 8 +- .../discord/src/send.creates-thread.test.ts | 8 +- extensions/discord/src/send.receipt.ts | 5 + extensions/feishu/src/outbound.ts | 271 ++++++++++-------- extensions/irc/src/message-adapter.ts | 12 +- extensions/irc/src/send.test.ts | 1 + extensions/matrix/src/outbound.test.ts | 4 +- extensions/matrix/src/outbound.ts | 28 +- extensions/mattermost/src/channel.test.ts | 2 +- extensions/mattermost/src/channel.ts | 57 ++-- extensions/msteams/src/outbound.test.ts | 8 +- extensions/msteams/src/outbound.ts | 33 ++- extensions/reef/src/outbound.test.ts | 2 +- extensions/reef/src/outbound.ts | 7 +- extensions/slack/src/channel.test.ts | 18 +- extensions/slack/src/outbound-adapter.ts | 81 +++--- .../slack/src/question-finalization.test.ts | 30 +- extensions/synology-chat/src/channel.test.ts | 4 +- extensions/synology-chat/src/channel.ts | 4 +- .../synology-chat/src/client.loopback.test.ts | 2 +- .../telegram/src/action-runtime.test.ts | 8 +- extensions/telegram/src/action-runtime.ts | 2 +- .../src/outbound-adapter.presentation.test.ts | 2 +- .../telegram/src/outbound-adapter.test.ts | 42 ++- extensions/telegram/src/outbound-adapter.ts | 44 ++- .../src/question-finalization.test.ts | 4 +- src/channels/message/outbound-bridge.ts | 2 + src/channels/message/receipt.ts | 1 + src/channels/message/types.ts | 5 + src/commands/message-format.test.ts | 2 +- src/infra/command-analysis/risks.test.ts | 3 + src/infra/exec-allowlist-matching.test.ts | 12 + src/infra/exec-approvals-allowlist.ts | 3 + src/infra/exec-approvals-config.test.ts | 5 + src/infra/exec-approvals-safe-bins.test.ts | 8 + src/infra/exec-approvals-test-helpers.ts | 26 +- src/infra/exec-command-resolution.test.ts | 8 + src/infra/exec-command-resolution.ts | 41 +-- src/infra/outbound/deliver-payload.ts | 10 +- src/infra/outbound/deliver-results.ts | 5 +- src/infra/outbound/deliver-types.ts | 8 +- src/infra/outbound/deliver.test.ts | 2 +- src/infra/outbound/envelope.test.ts | 2 +- src/infra/outbound/format.test.ts | 8 +- src/infra/outbound/format.ts | 18 +- src/infra/outbound/message.channels.test.ts | 13 +- src/infra/outbound/message.ts | 36 +-- src/infra/outbound/outbound-audit.ts | 9 +- .../invoke-system-run-allowlist.test.ts | 9 + src/plugin-sdk/channel-send-result.test.ts | 11 +- 56 files changed, 611 insertions(+), 418 deletions(-) diff --git a/docs/plugins/sdk-migration.md b/docs/plugins/sdk-migration.md index 164b4f06a6aa..e78e3b001729 100644 --- a/docs/plugins/sdk-migration.md +++ b/docs/plugins/sdk-migration.md @@ -800,8 +800,11 @@ timeline for current status. **New**: return `OutboundDeliveryResult` fields and attach the channel with `createAttachedChannelResultAdapter(...)`. Failed sends should throw instead - of returning an error string. The raw result type remains available until - the next plugin-SDK major release. + of returning an error string. Put the platform destination in + `target: { kind: "chat" | "channel" | "room" | "conversation", id }`; + the old parallel `chatId`, `channelId`, `roomId`, and `conversationId` + result fields are no longer accepted. The raw result type remains available + until the next plugin-SDK major release. diff --git a/extensions/discord/src/outbound-adapter.interactive-order.test.ts b/extensions/discord/src/outbound-adapter.interactive-order.test.ts index 0982be940f02..b5a6360125ad 100644 --- a/extensions/discord/src/outbound-adapter.interactive-order.test.ts +++ b/extensions/discord/src/outbound-adapter.interactive-order.test.ts @@ -77,7 +77,7 @@ describe("discordOutbound shared interactive ordering", () => { expect(result).toEqual({ channel: "discord", messageId: "msg-1", - channelId: "123456", + target: { kind: "channel", id: "123456" }, }); }); }); diff --git a/extensions/discord/src/outbound-adapter.test-harness.ts b/extensions/discord/src/outbound-adapter.test-harness.ts index 3683fb677660..46d54f46496e 100644 --- a/extensions/discord/src/outbound-adapter.test-harness.ts +++ b/extensions/discord/src/outbound-adapter.test-harness.ts @@ -45,7 +45,7 @@ export function createDiscordOutboundHoisted(): DiscordOutboundHoisted { const DEFAULT_DISCORD_SEND_RESULT = { channel: "discord", messageId: "msg-1", - channelId: "ch-1", + target: { kind: "channel", id: "ch-1" }, } as const; async function createDiscordSendModuleMock( diff --git a/extensions/discord/src/outbound-adapter.test.ts b/extensions/discord/src/outbound-adapter.test.ts index a99fc7a855fb..2d6a41c6526b 100644 --- a/extensions/discord/src/outbound-adapter.test.ts +++ b/extensions/discord/src/outbound-adapter.test.ts @@ -232,7 +232,7 @@ describe("discordOutbound", () => { expect(result).toEqual({ channel: "discord", messageId: "msg-webhook-1", - channelId: "thread-1", + target: { kind: "channel", id: "thread-1" }, }); }); @@ -408,7 +408,7 @@ describe("discordOutbound", () => { expect(result).toEqual({ channel: "discord", messageId: "msg-1", - channelId: "ch-1", + target: { kind: "channel", id: "ch-1" }, }); expect(onDeliveryResult.mock.calls.map((call) => call[0]?.messageId)).toEqual([ "voice-1", @@ -518,7 +518,7 @@ describe("discordOutbound", () => { expect(result).toEqual({ channel: "discord", messageId: "msg-1", - channelId: "ch-1", + target: { kind: "channel", id: "ch-1" }, }); }); @@ -547,7 +547,7 @@ describe("discordOutbound", () => { expect(result).toMatchObject({ channel: "discord", messageId: "", - channelId: "channel:123456", + target: { kind: "channel", id: "channel:123456" }, receipt: { platformMessageIds: [], parts: [], @@ -739,7 +739,7 @@ describe("discordOutbound", () => { expect(result).toEqual({ channel: "discord", messageId: "video-1", - channelId: "channel-1", + target: { kind: "channel", id: "channel-1" }, receipt: mediaReceipt, }); }); @@ -799,7 +799,7 @@ describe("discordOutbound", () => { expect(result).toMatchObject({ channel: "discord", messageId: "starter-1", - channelId: "thread-1", + target: { kind: "channel", id: "thread-1" }, receipt: { primaryPlatformMessageId: "starter-1", threadId: "thread-1", @@ -980,7 +980,7 @@ describe("discordOutbound", () => { expect(result).toEqual({ channel: "discord", messageId: "msg-2", - channelId: "ch-1", + target: { kind: "channel", id: "ch-1" }, }); }); diff --git a/extensions/discord/src/outbound-adapter.ts b/extensions/discord/src/outbound-adapter.ts index 7785ee69ed4a..e38f4feb02cb 100644 --- a/extensions/discord/src/outbound-adapter.ts +++ b/extensions/discord/src/outbound-adapter.ts @@ -38,7 +38,10 @@ import { type DiscordVoiceSendFn, } from "./outbound-send-context.js"; import { resolveDiscordReplyReference } from "./reply-reference.js"; -import { createDiscordSendReceiptFromResults } from "./send.receipt.js"; +import { + createDiscordSendReceiptFromResults, + toDiscordOutboundDeliveryResult, +} from "./send.receipt.js"; export const DISCORD_TEXT_CHUNK_LIMIT = 2000; const log = createSubsystemLogger("discord/outbound"); @@ -131,7 +134,9 @@ async function resolveDiscordOutboundMessageSend(params: DiscordOutboundMessageC NonNullable[2]>["onDeliveryResult"]> >[0], ) => { - await params.onDeliveryResult?.(attachChannelToResult("discord", result)); + await params.onDeliveryResult?.( + attachChannelToResult("discord", toDiscordOutboundDeliveryResult(result)), + ); } : undefined, onPlatformSendDispatch: params.onPlatformSendDispatch, @@ -195,7 +200,7 @@ export const discordOutbound: ChannelOutboundAdapter = { : undefined, }); if (webhookResult) { - return webhookResult; + return toDiscordOutboundDeliveryResult(webhookResult); } } catch (error) { if (webhookSelected) { @@ -208,7 +213,7 @@ export const discordOutbound: ChannelOutboundAdapter = { } } const { send, target, options } = await resolveDiscordOutboundMessageSend(ctx); - return await send(target, ctx.text, options); + return toDiscordOutboundDeliveryResult(await send(target, ctx.text, options)); }, sendMedia: async (ctx) => { const { send, target, options } = await resolveDiscordOutboundMessageSend(ctx); @@ -216,16 +221,18 @@ export const discordOutbound: ChannelOutboundAdapter = { const sendVoice = resolveOutboundSendDep(ctx.deps, "discordVoice") ?? (await loadDiscordSendRuntime()).sendVoiceMessageDiscord; - return await sendVoice(target, ctx.mediaUrl, { - cfg: ctx.cfg, - reply: options.reply, - accountId: ctx.accountId ?? undefined, - silent: ctx.silent ?? undefined, - mediaAccess: ctx.mediaAccess, - mediaLocalRoots: ctx.mediaLocalRoots, - mediaReadFile: ctx.mediaReadFile, - onPlatformSendDispatch: ctx.onPlatformSendDispatch, - }); + return toDiscordOutboundDeliveryResult( + await sendVoice(target, ctx.mediaUrl, { + cfg: ctx.cfg, + reply: options.reply, + accountId: ctx.accountId ?? undefined, + silent: ctx.silent ?? undefined, + mediaAccess: ctx.mediaAccess, + mediaLocalRoots: ctx.mediaLocalRoots, + mediaReadFile: ctx.mediaReadFile, + onPlatformSendDispatch: ctx.onPlatformSendDispatch, + }), + ); } const mediaOptions = { ...options, @@ -246,17 +253,17 @@ export const discordOutbound: ChannelOutboundAdapter = { }); const threadId = captionResult.receipt?.threadId; if (!threadId) { - return mediaResult; + return toDiscordOutboundDeliveryResult(mediaResult); } - return { + return toDiscordOutboundDeliveryResult({ ...captionResult, receipt: createDiscordSendReceiptFromResults({ results: [captionResult, mediaResult], threadId, }), - }; + }); } - return await send(target, ctx.text, mediaOptions); + return toDiscordOutboundDeliveryResult(await send(target, ctx.text, mediaOptions)); }, sendPoll: async ({ cfg, to, poll, accountId, threadId, silent, onPlatformSendDispatch }) => await ( @@ -285,9 +292,10 @@ export const discordOutbound: ChannelOutboundAdapter = { const componentSpec = questionId ? await resolveDiscordComponentSpec(payload) : undefined; if (questionId && result && componentSpec) { const to = resolveDiscordOutboundTarget({ to: target.to, threadId: target.threadId }); + const channelId = result.target?.kind === "channel" ? result.target.id : to; questionGatewayRuntime.registerChannelDelivery({ questionId, - deliveryId: `discord:${target.accountId ?? "default"}:${result.channelId ?? to}:${result.messageId}`, + deliveryId: `discord:${target.accountId ?? "default"}:${channelId}:${result.messageId}`, finalize: async (statusLine) => { const { editDiscordComponentMessage } = await loadDiscordComponentSendRuntime(); await editDiscordComponentMessage( diff --git a/extensions/discord/src/outbound-payload.ts b/extensions/discord/src/outbound-payload.ts index 68803bd03687..484064685e46 100644 --- a/extensions/discord/src/outbound-payload.ts +++ b/extensions/discord/src/outbound-payload.ts @@ -18,7 +18,11 @@ import { } from "./outbound-components.js"; import { createDiscordPayloadSendContext } from "./outbound-send-context.js"; import { hasDiscordMessageCreateAmbiguity } from "./retry.js"; -import { createDiscordSendReceipt, createDiscordSendReceiptFromResults } from "./send.receipt.js"; +import { + createDiscordSendReceipt, + createDiscordSendReceiptFromResults, + toDiscordOutboundDeliveryResult, +} from "./send.receipt.js"; import type { DiscordSendComponents, DiscordSendEmbeds } from "./send.shared.js"; import type { DiscordSendResult } from "./send.types.js"; @@ -30,7 +34,9 @@ type DiscordPayloadSendContext = Awaited>) => { - await ctx.onDeliveryResult?.(attachChannelToResult("discord", result)); + await ctx.onDeliveryResult?.( + attachChannelToResult("discord", toDiscordOutboundDeliveryResult(result)), + ); } : undefined; } @@ -139,7 +145,9 @@ export async function sendDiscordOutboundPayload(params: { } } if (deliveredVoice) { - await ctx.onDeliveryResult?.(attachChannelToResult("discord", lastResult)); + await ctx.onDeliveryResult?.( + attachChannelToResult("discord", toDiscordOutboundDeliveryResult(lastResult)), + ); } if (deliveredVoice && payload.text?.trim()) { lastResult = await sendContext.send(sendContext.target, payload.text, { @@ -155,7 +163,7 @@ export async function sendDiscordOutboundPayload(params: { onDeliveryResult: resolveDiscordDeliveryProgress(ctx), }); } - return attachChannelToResult("discord", lastResult); + return attachChannelToResult("discord", toDiscordOutboundDeliveryResult(lastResult)); } const componentSpec = await resolveDiscordComponentSpec(payload); @@ -197,7 +205,7 @@ export async function sendDiscordOutboundPayload(params: { onDeliveryResult: resolveDiscordDeliveryProgress(ctx), }), }); - return attachChannelToResult("discord", result); + return attachChannelToResult("discord", toDiscordOutboundDeliveryResult(result)); } const payloadContext = { ...ctx, payload }; const deliveredResults: DiscordSendResult[] = []; @@ -210,10 +218,10 @@ export async function sendDiscordOutboundPayload(params: { payloadContext.threadId = threadId; createdThreadId = threadId; } - if (createdThreadId && result.channelId && result.receipt) { + if (createdThreadId && result.target?.kind === "channel" && result.receipt) { deliveredResults.push({ messageId: result.messageId, - channelId: result.channelId, + channelId: result.target.id, receipt: result.receipt, }); } @@ -258,5 +266,5 @@ export async function sendDiscordOutboundPayload(params: { }); }, }); - return attachChannelToResult("discord", result); + return attachChannelToResult("discord", toDiscordOutboundDeliveryResult(result)); } diff --git a/extensions/discord/src/question-finalization.test.ts b/extensions/discord/src/question-finalization.test.ts index 9bebf3203421..b7dbbe71bf81 100644 --- a/extensions/discord/src/question-finalization.test.ts +++ b/extensions/discord/src/question-finalization.test.ts @@ -42,7 +42,13 @@ describe("Discord question finalization", () => { }, }, }, - results: [{ channel: "discord", messageId: "55", channelId: "123" }], + results: [ + { + channel: "discord", + messageId: "55", + target: { kind: "channel", id: "123" }, + }, + ], }); await hoisted.registration?.finalize("Expired"); diff --git a/extensions/discord/src/send.creates-thread.test.ts b/extensions/discord/src/send.creates-thread.test.ts index f90f9d9d7e7c..ca36895be0b5 100644 --- a/extensions/discord/src/send.creates-thread.test.ts +++ b/extensions/discord/src/send.creates-thread.test.ts @@ -171,9 +171,11 @@ describe("sendMessageDiscord", () => { Routes.channelMessages("701"), ), ]); - expect(onDeliveryResult.mock.calls.map(([delivery]) => delivery.channelId)).toEqual( - Array.from({ length: testCase.expectedThreadMessages + 1 }, () => "701"), - ); + expect( + onDeliveryResult.mock.calls.map(([delivery]) => + delivery.target?.kind === "channel" ? delivery.target.id : undefined, + ), + ).toEqual(Array.from({ length: testCase.expectedThreadMessages + 1 }, () => "701")); expect(result?.receipt).toMatchObject({ threadId: "701", platformMessageIds: [ diff --git a/extensions/discord/src/send.receipt.ts b/extensions/discord/src/send.receipt.ts index b3781980d33d..5de55c899136 100644 --- a/extensions/discord/src/send.receipt.ts +++ b/extensions/discord/src/send.receipt.ts @@ -15,6 +15,11 @@ export type DiscordReceiptResultSource = { platformMessageIds?: readonly string[]; }; +export function toDiscordOutboundDeliveryResult(result: T) { + const { channelId, ...delivery } = result; + return { ...delivery, target: { kind: "channel" as const, id: channelId } }; +} + export function createDiscordSendReceiptFromResults(params: { results: readonly DiscordSendResult[]; threadId?: string; diff --git a/extensions/feishu/src/outbound.ts b/extensions/feishu/src/outbound.ts index a54973461494..4eed15c5ddb8 100644 --- a/extensions/feishu/src/outbound.ts +++ b/extensions/feishu/src/outbound.ts @@ -152,11 +152,16 @@ type FeishuOutboundPayload = Parameters< type FeishuSendPayloadContext = Parameters>[0]; type FeishuSendTextContext = Parameters>[0]; -async function reportFeishuOutboundDelivery( +function toFeishuOutboundResult(result: T) { + const { chatId, ...delivery } = result; + return { ...delivery, target: { kind: "chat" as const, id: chatId } }; +} + +async function reportFeishuOutboundDelivery( result: T, onDeliveryResult: FeishuSendTextContext["onDeliveryResult"], ): Promise { - await onDeliveryResult?.(attachChannelToResult("feishu", result)); + await onDeliveryResult?.(attachChannelToResult("feishu", toFeishuOutboundResult(result))); return result; } @@ -701,14 +706,16 @@ export const feishuOutbound: ChannelOutboundAdapter = { }); return attachChannelToResult( "feishu", - await sendCardFeishu({ - cfg: ctx.cfg, - to: ctx.to, - card, - replyToMessageId, - replyInThread, - accountId: ctx.accountId ?? undefined, - }), + toFeishuOutboundResult( + await sendCardFeishu({ + cfg: ctx.cfg, + to: ctx.to, + card, + replyToMessageId, + replyInThread, + accountId: ctx.accountId ?? undefined, + }), + ), ); }, }); @@ -733,44 +740,48 @@ export const feishuOutbound: ChannelOutboundAdapter = { const mediaUrls = normalizeStringEntries(resolvePayloadMediaUrls(payload)); return attachChannelToResult( "feishu", - await sendPayloadMediaSequenceAndFinalize< - SendMediaResult, - Awaited> - >({ - text: payload.text ?? "", - mediaUrls, - onResult: async (deliveryResult) => { - await ctx.onDeliveryResult?.(attachChannelToResult("feishu", deliveryResult)); - }, - send: async ({ mediaUrl }) => { - const { replyToMessageId, replyInThread } = nextReplyMode(); - return await sendMediaFeishu({ - cfg: ctx.cfg, - to: ctx.to, - mediaUrl, - accountId: ctx.accountId ?? undefined, - mediaAccess: ctx.mediaAccess, - mediaLocalRoots: ctx.mediaLocalRoots, - mediaReadFile: ctx.mediaReadFile, - replyToMessageId, - replyInThread, - ...(payload.audioAsVoice === true || ctx.audioAsVoice === true - ? { audioAsVoice: true } - : {}), - }); - }, - finalize: async () => { - const { replyToMessageId, replyInThread } = nextReplyMode(); - return await sendCardFeishu({ - cfg: ctx.cfg, - to: ctx.to, - card, - replyToMessageId, - replyInThread, - accountId: ctx.accountId ?? undefined, - }); - }, - }), + toFeishuOutboundResult( + await sendPayloadMediaSequenceAndFinalize< + SendMediaResult, + Awaited> + >({ + text: payload.text ?? "", + mediaUrls, + onResult: async (deliveryResult) => { + await ctx.onDeliveryResult?.( + attachChannelToResult("feishu", toFeishuOutboundResult(deliveryResult)), + ); + }, + send: async ({ mediaUrl }) => { + const { replyToMessageId, replyInThread } = nextReplyMode(); + return await sendMediaFeishu({ + cfg: ctx.cfg, + to: ctx.to, + mediaUrl, + accountId: ctx.accountId ?? undefined, + mediaAccess: ctx.mediaAccess, + mediaLocalRoots: ctx.mediaLocalRoots, + mediaReadFile: ctx.mediaReadFile, + replyToMessageId, + replyInThread, + ...(payload.audioAsVoice === true || ctx.audioAsVoice === true + ? { audioAsVoice: true } + : {}), + }); + }, + finalize: async () => { + const { replyToMessageId, replyInThread } = nextReplyMode(); + return await sendCardFeishu({ + cfg: ctx.cfg, + to: ctx.to, + card, + replyToMessageId, + replyInThread, + accountId: ctx.accountId ?? undefined, + }); + }, + }), + ), ); }, ...createAttachedChannelResultAdapter({ @@ -819,44 +830,52 @@ export const feishuOutbound: ChannelOutboundAdapter = { throw err; } console.error(`[feishu] local image path auto-send failed:`, err); - return await sendOutboundText({ + return toFeishuOutboundResult( + await sendOutboundText({ + cfg, + to, + text: await buildFeishuMediaFallbackText({}), + accountId: accountId ?? undefined, + replyToMessageId, + replyInThread, + ...deliveryOptions, + }), + ); + } + return toFeishuOutboundResult( + await reportFeishuOutboundDelivery(mediaResult, onDeliveryResult), + ); + } + + if (parseFeishuCommentTarget(to)) { + return toFeishuOutboundResult( + await sendOutboundText({ cfg, to, - text: await buildFeishuMediaFallbackText({}), + text, accountId: accountId ?? undefined, replyToMessageId, replyInThread, ...deliveryOptions, - }); - } - return await reportFeishuOutboundDelivery(mediaResult, onDeliveryResult); - } - - if (parseFeishuCommentTarget(to)) { - return await sendOutboundText({ - cfg, - to, - text, - accountId: accountId ?? undefined, - replyToMessageId, - replyInThread, - ...deliveryOptions, - }); + }), + ); } const card = readNativeFeishuCardJson(text); if (card) { assertFeishuCardWithinEnvelope(card, "Feishu native card"); - return await reportFeishuOutboundDelivery( - await sendCardFeishu({ - cfg, - to, - card: markRenderedFeishuCard(card), - accountId: accountId ?? undefined, - replyToMessageId, - replyInThread, - }), - onDeliveryResult, + return toFeishuOutboundResult( + await reportFeishuOutboundDelivery( + await sendCardFeishu({ + cfg, + to, + card: markRenderedFeishuCard(card), + accountId: accountId ?? undefined, + replyToMessageId, + replyInThread, + }), + onDeliveryResult, + ), ); } @@ -870,28 +889,32 @@ export const feishuOutbound: ChannelOutboundAdapter = { template: "blue" as const, } : undefined; - return await reportFeishuOutboundDelivery( - await sendStructuredCardFeishu({ - cfg, - to, - text, - replyToMessageId, - replyInThread, - accountId: accountId ?? undefined, - header: header?.title ? header : undefined, - }), - onDeliveryResult, + return toFeishuOutboundResult( + await reportFeishuOutboundDelivery( + await sendStructuredCardFeishu({ + cfg, + to, + text, + replyToMessageId, + replyInThread, + accountId: accountId ?? undefined, + header: header?.title ? header : undefined, + }), + onDeliveryResult, + ), ); } - return await sendOutboundText({ - cfg, - to, - text, - accountId: accountId ?? undefined, - replyToMessageId, - replyInThread, - ...deliveryOptions, - }); + return toFeishuOutboundResult( + await sendOutboundText({ + cfg, + to, + text, + accountId: accountId ?? undefined, + replyToMessageId, + replyInThread, + ...deliveryOptions, + }), + ); }, sendMedia: async ({ cfg, @@ -934,25 +957,29 @@ export const feishuOutbound: ChannelOutboundAdapter = { mediaLinkStyle: "plain", }) : (text?.trim() ?? ""); - return await sendOutboundText({ - cfg, - to, - text: commentText, - accountId: accountId ?? undefined, - ...nextReplyMode(), - ...deliveryOptions, - }); + return toFeishuOutboundResult( + await sendOutboundText({ + cfg, + to, + text: commentText, + accountId: accountId ?? undefined, + ...nextReplyMode(), + ...deliveryOptions, + }), + ); } if (!mediaUrl) { - return await sendOutboundText({ - cfg, - to, - text: text ?? "", - accountId: accountId ?? undefined, - ...nextReplyMode(), - ...deliveryOptions, - }); + return toFeishuOutboundResult( + await sendOutboundText({ + cfg, + to, + text: text ?? "", + accountId: accountId ?? undefined, + ...nextReplyMode(), + ...deliveryOptions, + }), + ); } const suppressTextForVoiceMedia = shouldSuppressFeishuTextForVoiceMedia({ @@ -998,15 +1025,17 @@ export const feishuOutbound: ChannelOutboundAdapter = { text: textSent ? undefined : text, mediaUrl, }); - return await sendOutboundText({ - cfg, - to, - text: fallbackText, - accountId: accountId ?? undefined, - // A rejected upload never delivered its attempted reply target. - ...(textSent ? nextReplyMode() : mediaReplyMode), - ...deliveryOptions, - }); + return toFeishuOutboundResult( + await sendOutboundText({ + cfg, + to, + text: fallbackText, + accountId: accountId ?? undefined, + // A rejected upload never delivered its attempted reply target. + ...(textSent ? nextReplyMode() : mediaReplyMode), + ...deliveryOptions, + }), + ); } // Persist the accepted attachment before any later fallible text action. @@ -1021,7 +1050,7 @@ export const feishuOutbound: ChannelOutboundAdapter = { ...deliveryOptions, }); } - return mediaResult; + return toFeishuOutboundResult(mediaResult); }, }), }; diff --git a/extensions/irc/src/message-adapter.ts b/extensions/irc/src/message-adapter.ts index 9f35185fb25a..accfac490404 100644 --- a/extensions/irc/src/message-adapter.ts +++ b/extensions/irc/src/message-adapter.ts @@ -3,6 +3,14 @@ import { defineChannelMessageAdapter } from "openclaw/plugin-sdk/channel-outboun import { sendMessageIrc } from "./send.js"; import type { CoreConfig } from "./types.js"; +async function sendIrcMessage(...args: Parameters) { + const { target, ...result } = await sendMessageIrc(...args); + return { + ...result, + target: { kind: "conversation" as const, id: target }, + }; +} + export const ircMessageAdapter = defineChannelMessageAdapter({ id: "irc", durableFinal: { @@ -14,13 +22,13 @@ export const ircMessageAdapter = defineChannelMessageAdapter({ }, send: { text: async ({ cfg, to, text, accountId, replyToId }) => - await sendMessageIrc(to, text, { + await sendIrcMessage(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, { + await sendIrcMessage(to, mediaUrl ? `${text}\n\nAttachment: ${mediaUrl}` : text, { cfg: cfg as CoreConfig, accountId: accountId ?? undefined, replyTo: replyToId ?? undefined, diff --git a/extensions/irc/src/send.test.ts b/extensions/irc/src/send.test.ts index e18cf5522dd7..1ed1665e72ae 100644 --- a/extensions/irc/src/send.test.ts +++ b/extensions/irc/src/send.test.ts @@ -358,6 +358,7 @@ describe("sendMessageIrc cfg threading", () => { text: "hello", }); expect(result?.receipt.platformMessageIds).toEqual(["irc-msg-1"]); + expect(result?.target).toEqual({ kind: "conversation", id: "#room" }); expect(client.join).toHaveBeenCalledWith("#room"); expect(client.sendPrivmsg).toHaveBeenCalledWith("#room", "hello"); }, diff --git a/extensions/matrix/src/outbound.test.ts b/extensions/matrix/src/outbound.test.ts index 81160e8ba113..cde3a0085a0b 100644 --- a/extensions/matrix/src/outbound.test.ts +++ b/extensions/matrix/src/outbound.test.ts @@ -157,7 +157,7 @@ describe("matrixOutbound cfg threading", () => { expect(result).toMatchObject({ channel: "matrix", messageId: "$last", - roomId: "!room:example", + target: { kind: "room", id: "!room:example" }, primaryMessageId: "$first", content: "first\nlast", }); @@ -739,7 +739,7 @@ describe("matrixOutbound cfg threading", () => { expect(result).toEqual({ channel: "matrix", messageId: "evt-1", - roomId: "!room:example", + target: { kind: "room", id: "!room:example" }, }); }); diff --git a/extensions/matrix/src/outbound.ts b/extensions/matrix/src/outbound.ts index 7724b3cc3f36..74ba74670c08 100644 --- a/extensions/matrix/src/outbound.ts +++ b/extensions/matrix/src/outbound.ts @@ -30,6 +30,11 @@ type MatrixChannelData = { extraContent?: MatrixExtraContentFields; }; +function toMatrixOutboundResult(result: T) { + const { roomId, ...delivery } = result; + return { ...delivery, target: { kind: "room" as const, id: roomId } }; +} + function resolveMatrixChannelData(payload: ReplyPayload): MatrixChannelData { const raw = asOptionalRecord(payload.channelData)?.matrix; return (asOptionalRecord(raw) as MatrixChannelData | undefined) ?? {}; @@ -103,7 +108,7 @@ function resolveMatrixDeliveryProgress( ) { return onDeliveryResult ? async (result: Awaited>) => { - await onDeliveryResult(attachChannelToResult("matrix", result)); + await onDeliveryResult(attachChannelToResult("matrix", toMatrixOutboundResult(result))); } : undefined; } @@ -188,12 +193,15 @@ export const matrixOutbound: ChannelOutboundAdapter = { // One payload owns one receipt; keep every attachment and its original reply metadata. const receipt = createMessageReceiptFromOutboundResults({ results: sentResults }); receipt.parts = receipt.parts.map((part, index) => ({ ...part, index })); - return attachChannelToResult("matrix", { - ...lastResult, - primaryMessageId: receipt.primaryPlatformMessageId, - receipt, - content: sentResults.map((result) => result.content).join("\n"), - }); + return attachChannelToResult( + "matrix", + toMatrixOutboundResult({ + ...lastResult, + primaryMessageId: receipt.primaryPlatformMessageId, + receipt, + content: sentResults.map((result) => result.content).join("\n"), + }), + ); } } const result = await send(to, payloadText, { @@ -212,7 +220,7 @@ export const matrixOutbound: ChannelOutboundAdapter = { extraContent: resolveMatrixExtraContent(payload), onDeliveryResult: resolveMatrixDeliveryProgress(onDeliveryResult), }); - return attachChannelToResult("matrix", result); + return attachChannelToResult("matrix", toMatrixOutboundResult(result)); }, sendText: async ({ cfg, @@ -245,7 +253,7 @@ export const matrixOutbound: ChannelOutboundAdapter = { onPlatformSendDispatch, onDeliveryResult: resolveMatrixDeliveryProgress(onDeliveryResult), }); - return attachChannelToResult("matrix", result); + return attachChannelToResult("matrix", toMatrixOutboundResult(result)); }, sendMedia: async ({ cfg, @@ -286,7 +294,7 @@ export const matrixOutbound: ChannelOutboundAdapter = { onPlatformSendDispatch, onDeliveryResult: resolveMatrixDeliveryProgress(onDeliveryResult), }); - return attachChannelToResult("matrix", result); + return attachChannelToResult("matrix", toMatrixOutboundResult(result)); }, sendPoll: async ({ cfg, to, poll, threadId, accountId }) => { const resolvedThreadId = threadId !== undefined && threadId !== null ? threadId : undefined; diff --git a/extensions/mattermost/src/channel.test.ts b/extensions/mattermost/src/channel.test.ts index a30fe1f01b2a..4fba91e200ef 100644 --- a/extensions/mattermost/src/channel.test.ts +++ b/extensions/mattermost/src/channel.test.ts @@ -1739,7 +1739,7 @@ describe("mattermostPlugin", () => { expect(onDeliveryResult).toHaveBeenCalledWith({ channel: "mattermost", messageId: "post-final", - channelId: "CHAN1", + target: { kind: "channel", id: "CHAN1" }, content: "provider-final", }); }); diff --git a/extensions/mattermost/src/channel.ts b/extensions/mattermost/src/channel.ts index 8550d51f0f43..27ec7bb09c69 100644 --- a/extensions/mattermost/src/channel.ts +++ b/extensions/mattermost/src/channel.ts @@ -764,12 +764,19 @@ function resolveMattermostSendAttachmentMedia(params: Record): type MattermostOutboundContext = Parameters>[0]; +function toMattermostOutboundResult(result: MattermostSendResult) { + const { channelId, ...delivery } = result; + return { ...delivery, target: { kind: "channel" as const, id: channelId } }; +} + function createMattermostDeliveryProgressReporter( onDeliveryResult: MattermostOutboundContext["onDeliveryResult"], ) { return onDeliveryResult ? async (result: MattermostSendResult) => { - await onDeliveryResult(attachChannelToResult("mattermost", result)); + await onDeliveryResult( + attachChannelToResult("mattermost", toMattermostOutboundResult(result)), + ); } : undefined; } @@ -842,7 +849,7 @@ const mattermostOutbound: ChannelOutboundAdapter = { attachmentText, onDeliveryResult: createMattermostDeliveryProgressReporter(ctx.onDeliveryResult), }); - return attachChannelToResult("mattermost", result); + return attachChannelToResult("mattermost", toMattermostOutboundResult(result)); } return await sendTextMediaPayload({ channel: "mattermost", ctx, adapter: mattermostOutbound }); }, @@ -861,14 +868,16 @@ const mattermostOutbound: ChannelOutboundAdapter = { ...createAttachedChannelResultAdapter({ channel: "mattermost", sendText: async ({ cfg, to, text, accountId, replyToId, threadId, onDeliveryResult }) => - await ( - await loadMattermostChannelRuntime() - ).sendMessageMattermost(to, text, { - cfg, - accountId: accountId ?? undefined, - replyToId: replyToId ?? (threadId != null ? String(threadId) : undefined), - onDeliveryResult: createMattermostDeliveryProgressReporter(onDeliveryResult), - }), + toMattermostOutboundResult( + await ( + await loadMattermostChannelRuntime() + ).sendMessageMattermost(to, text, { + cfg, + accountId: accountId ?? undefined, + replyToId: replyToId ?? (threadId != null ? String(threadId) : undefined), + onDeliveryResult: createMattermostDeliveryProgressReporter(onDeliveryResult), + }), + ), sendMedia: async ({ cfg, to, @@ -882,19 +891,21 @@ const mattermostOutbound: ChannelOutboundAdapter = { threadId, onDeliveryResult, }) => - await ( - await loadMattermostChannelRuntime() - ).sendMessageMattermost(to, text, { - cfg, - accountId: accountId ?? undefined, - mediaUrl, - mediaLocalRoots: mediaLocalRoots ?? mediaAccess?.localRoots, - mediaReadFile: mediaReadFile ?? mediaAccess?.readFile, - ...(mediaAccess?.workspaceDir ? { workspaceDir: mediaAccess.workspaceDir } : {}), - requireMediaUpload: requiresMattermostMediaUpload(mediaUrl) ? true : undefined, - replyToId: replyToId ?? (threadId != null ? String(threadId) : undefined), - onDeliveryResult: createMattermostDeliveryProgressReporter(onDeliveryResult), - }), + toMattermostOutboundResult( + await ( + await loadMattermostChannelRuntime() + ).sendMessageMattermost(to, text, { + cfg, + accountId: accountId ?? undefined, + mediaUrl, + mediaLocalRoots: mediaLocalRoots ?? mediaAccess?.localRoots, + mediaReadFile: mediaReadFile ?? mediaAccess?.readFile, + ...(mediaAccess?.workspaceDir ? { workspaceDir: mediaAccess.workspaceDir } : {}), + requireMediaUpload: requiresMattermostMediaUpload(mediaUrl) ? true : undefined, + replyToId: replyToId ?? (threadId != null ? String(threadId) : undefined), + onDeliveryResult: createMattermostDeliveryProgressReporter(onDeliveryResult), + }), + ), }), }; diff --git a/extensions/msteams/src/outbound.test.ts b/extensions/msteams/src/outbound.test.ts index 8c801594051e..68c46a226cad 100644 --- a/extensions/msteams/src/outbound.test.ts +++ b/extensions/msteams/src/outbound.test.ts @@ -330,7 +330,7 @@ describe("msteamsOutbound cfg threading", () => { expect(result).toEqual({ channel: "msteams", messageId: "msg-card-1", - conversationId: "conv-card-1", + target: { kind: "conversation", id: "conv-card-1" }, }); }); @@ -418,7 +418,7 @@ describe("msteamsOutbound cfg threading", () => { expect(result).toEqual({ channel: "msteams", messageId: "msg-1", - conversationId: "conv-1", + target: { kind: "conversation", id: "conv-1" }, }); }); @@ -451,7 +451,7 @@ describe("msteamsOutbound cfg threading", () => { expect(result).toEqual({ channel: "msteams", messageId: "msg-text-2", - conversationId: "conv-text", + target: { kind: "conversation", id: "conv-text" }, }); }); @@ -528,7 +528,7 @@ describe("msteamsOutbound cfg threading", () => { expect(result).toEqual({ channel: "msteams", messageId: "msg-media-2", - conversationId: "conv-media", + target: { kind: "conversation", id: "conv-media" }, }); }); diff --git a/extensions/msteams/src/outbound.ts b/extensions/msteams/src/outbound.ts index c6462697a364..1a683936e09d 100644 --- a/extensions/msteams/src/outbound.ts +++ b/extensions/msteams/src/outbound.ts @@ -43,6 +43,11 @@ type MSTeamsMediaSendFn = ( opts?: MSTeamsMediaSendOptions, ) => Promise; +function toMSTeamsOutboundResult(result: MSTeamsSendResult) { + const { conversationId, ...delivery } = result; + return { ...delivery, target: { kind: "conversation" as const, id: conversationId } }; +} + function resolveMSTeamsThreadTarget(to: string, threadId?: string | number | null) { const normalizedThreadId = threadId == null ? "" : String(threadId).trim(); const graphChannelId = to.includes("/") ? to.slice(to.indexOf("/") + 1) : ""; @@ -142,7 +147,7 @@ export const msteamsOutbound: ChannelOutboundAdapter = { to: deliveryTarget, card: presentationCard as Record, }); - return attachChannelToResult("msteams", result); + return attachChannelToResult("msteams", toMSTeamsOutboundResult(result)); } const mediaUrls = normalizeStringEntries( resolvePayloadMediaUrls({ @@ -156,7 +161,9 @@ export const msteamsOutbound: ChannelOutboundAdapter = { text, mediaUrls, onResult: async (deliveryResult) => { - await onDeliveryResult?.(attachChannelToResult("msteams", deliveryResult)); + await onDeliveryResult?.( + attachChannelToResult("msteams", toMSTeamsOutboundResult(deliveryResult)), + ); }, send: async ({ text: textLocal, mediaUrl: mediaUrlLocal }) => await send(deliveryTarget, textLocal, { @@ -167,7 +174,7 @@ export const msteamsOutbound: ChannelOutboundAdapter = { }), }); if (result) { - return attachChannelToResult("msteams", result); + return attachChannelToResult("msteams", toMSTeamsOutboundResult(result)); } } if (text.trim()) { @@ -182,9 +189,9 @@ export const msteamsOutbound: ChannelOutboundAdapter = { let result: Awaited>; for (const chunk of chunks) { result = await send(deliveryTarget, chunk); - await onDeliveryResult?.(attachChannelToResult("msteams", result)); + await onDeliveryResult?.(attachChannelToResult("msteams", toMSTeamsOutboundResult(result))); } - return attachChannelToResult("msteams", result!); + return attachChannelToResult("msteams", toMSTeamsOutboundResult(result!)); } throw new Error("MS Teams payload send requires text, media, or a presentation card."); }, @@ -192,7 +199,7 @@ export const msteamsOutbound: ChannelOutboundAdapter = { channel: "msteams", sendText: async ({ cfg, to, text, deps, threadId }) => { const send = resolveMSTeamsTextSend({ cfg, deps }); - return await send(resolveMSTeamsThreadTarget(to, threadId), text); + return toMSTeamsOutboundResult(await send(resolveMSTeamsThreadTarget(to, threadId), text)); }, sendMedia: async ({ cfg, @@ -206,12 +213,14 @@ export const msteamsOutbound: ChannelOutboundAdapter = { threadId, }) => { const send = resolveMSTeamsMediaSend({ cfg, deps }); - return await send(resolveMSTeamsThreadTarget(to, threadId), text, { - mediaUrl, - mediaAccess, - mediaLocalRoots, - mediaReadFile, - }); + return toMSTeamsOutboundResult( + await send(resolveMSTeamsThreadTarget(to, threadId), text, { + mediaUrl, + mediaAccess, + mediaLocalRoots, + mediaReadFile, + }), + ); }, sendPoll: async ({ cfg, to, poll, threadId }) => { const maxSelections = poll.maxSelections ?? 1; diff --git a/extensions/reef/src/outbound.test.ts b/extensions/reef/src/outbound.test.ts index 71d72fc7d225..35b810a11087 100644 --- a/extensions/reef/src/outbound.test.ts +++ b/extensions/reef/src/outbound.test.ts @@ -67,7 +67,7 @@ describe("reefOutboundAdapter", () => { ).resolves.toEqual({ channel: "reef", messageId: "01JZ0000000000000000000200", - chatId: "alice", + target: { kind: "chat", id: "alice" }, toJid: "reef:alice", }); expect(order).toEqual(["dispatch", "send"]); diff --git a/extensions/reef/src/outbound.ts b/extensions/reef/src/outbound.ts index 0299095ef886..014621ac6cef 100644 --- a/extensions/reef/src/outbound.ts +++ b/extensions/reef/src/outbound.ts @@ -134,7 +134,12 @@ async function send( } throw cause; } - return { channel: "reef", messageId: id, chatId: peer, toJid: `reef:${peer}` }; + return { + channel: "reef", + messageId: id, + target: { kind: "chat", id: peer }, + toJid: `reef:${peer}`, + }; } export const reefOutboundAdapter: ChannelOutboundAdapter = { diff --git a/extensions/slack/src/channel.test.ts b/extensions/slack/src/channel.test.ts index 176464a96bc2..1a1abf06a547 100644 --- a/extensions/slack/src/channel.test.ts +++ b/extensions/slack/src/channel.test.ts @@ -1750,7 +1750,11 @@ describe("slackPlugin outbound new targets", () => { expect(requireMockCallArgValue(sendSlack, 0, 0)).toBe("user:U99NEW"); expect(requireMockCallArgValue(sendSlack, 0, 1)).toBe("hello new user"); expect(requireMockCallArg(sendSlack, 0, 2).cfg).toBe(cfg); - expect(result).toEqual({ channel: "slack", messageId: "m-new-user", channelId: "D999" }); + expect(result).toEqual({ + channel: "slack", + messageId: "m-new-user", + target: { kind: "channel", id: "D999" }, + }); }); it("sends to a new channel target without erroring", async () => { @@ -1768,7 +1772,11 @@ describe("slackPlugin outbound new targets", () => { expect(requireMockCallArgValue(sendSlack, 0, 0)).toBe("channel:C555NEW"); expect(requireMockCallArgValue(sendSlack, 0, 1)).toBe("hello channel"); expect(requireMockCallArg(sendSlack, 0, 2).cfg).toBe(cfg); - expect(result).toEqual({ channel: "slack", messageId: "m-new-chan", channelId: "C555" }); + expect(result).toEqual({ + channel: "slack", + messageId: "m-new-chan", + target: { kind: "channel", id: "C555" }, + }); }); it("sends media to a new user target without erroring", async () => { @@ -1790,7 +1798,11 @@ describe("slackPlugin outbound new targets", () => { cfg, mediaUrl: "https://example.com/file.png", }); - expect(result).toEqual({ channel: "slack", messageId: "m-new-media", channelId: "D888" }); + expect(result).toEqual({ + channel: "slack", + messageId: "m-new-media", + target: { kind: "channel", id: "D888" }, + }); }); }); diff --git a/extensions/slack/src/outbound-adapter.ts b/extensions/slack/src/outbound-adapter.ts index b798aab62e34..810dacee4802 100644 --- a/extensions/slack/src/outbound-adapter.ts +++ b/extensions/slack/src/outbound-adapter.ts @@ -44,6 +44,13 @@ import { resolveSlackThreadTsValue } from "./thread-ts.js"; type SlackSendFn = typeof import("./send.runtime.js").sendMessageSlack; +function toSlackOutboundResult(result: T) { + const { channelId, ...delivery } = result; + return channelId === undefined + ? delivery + : { ...delivery, target: { kind: "channel" as const, id: channelId } }; +} + type SlackOutboundChannelData = Record & { authoredTextPlacement?: SlackAuthoredTextPlacement; blocks?: unknown; @@ -247,7 +254,9 @@ async function sendSlackOutboundMessage(params: { ...(params.onDeliveryResult ? { onDeliveryResult: async (progress) => { - await params.onDeliveryResult?.(attachChannelToResult("slack", progress)); + await params.onDeliveryResult?.( + attachChannelToResult("slack", toSlackOutboundResult(progress)), + ); }, } : {}), @@ -259,8 +268,8 @@ async function sendSlackOutboundMessage(params: { function createSlackAttachedSendAdapter() { return createAttachedChannelResultAdapter({ channel: "slack", - sendText: sendSlackOutboundMessage, - sendMedia: sendSlackOutboundMessage, + sendText: async (ctx) => toSlackOutboundResult(await sendSlackOutboundMessage(ctx)), + sendMedia: async (ctx) => toSlackOutboundResult(await sendSlackOutboundMessage(ctx)), }); } @@ -309,39 +318,41 @@ export const slackOutbound: ChannelOutboundAdapter = { const useSingleDeliveryMarker = mediaUrls.length === 0 && deliveryMessages.length === 1; return attachChannelToResult( "slack", - await sendPayloadMediaSequenceAndFinalize({ - text: "", - mediaUrls, - send: async ({ text, mediaUrl }) => - await sendSlackOutboundMessage({ - ...ctx, - text, - mediaUrl, - deliveryQueueId: useSingleDeliveryMarker ? ctx.deliveryQueueId : undefined, - }), - finalize: async () => { - let lastResult: Awaited> | undefined; - for (const message of deliveryMessages) { - lastResult = await sendSlackOutboundMessage({ + toSlackOutboundResult( + await sendPayloadMediaSequenceAndFinalize({ + text: "", + mediaUrls, + send: async ({ text, mediaUrl }) => + await sendSlackOutboundMessage({ ...ctx, - text: message.text, - ...(message.blocks ? { blocks: message.blocks } : {}), - ...(message.authoredTextPlacement - ? { authoredTextPlacement: message.authoredTextPlacement } - : {}), - ...(message.nativeDataFallbackBaseText - ? { nativeDataFallbackBaseText: message.nativeDataFallbackBaseText } - : {}), - ...(message.textIsSlackPlainText ? { textIsSlackPlainText: true } : {}), + text, + mediaUrl, deliveryQueueId: useSingleDeliveryMarker ? ctx.deliveryQueueId : undefined, - }); - } - if (!lastResult) { - throw new Error("Slack rendered presentation produced no deliverable segment"); - } - return lastResult; - }, - }), + }), + finalize: async () => { + let lastResult: Awaited> | undefined; + for (const message of deliveryMessages) { + lastResult = await sendSlackOutboundMessage({ + ...ctx, + text: message.text, + ...(message.blocks ? { blocks: message.blocks } : {}), + ...(message.authoredTextPlacement + ? { authoredTextPlacement: message.authoredTextPlacement } + : {}), + ...(message.nativeDataFallbackBaseText + ? { nativeDataFallbackBaseText: message.nativeDataFallbackBaseText } + : {}), + ...(message.textIsSlackPlainText ? { textIsSlackPlainText: true } : {}), + deliveryQueueId: useSingleDeliveryMarker ? ctx.deliveryQueueId : undefined, + }); + } + if (!lastResult) { + throw new Error("Slack rendered presentation produced no deliverable segment"); + } + return lastResult; + }, + }), + ), ); }, afterDeliverPayload: async ({ cfg, target, payload, results }) => { @@ -377,7 +388,7 @@ export const slackOutbound: ChannelOutboundAdapter = { if (!deliveryMessage || !deliveredDisplayBlocks || !result?.messageId) { return; } - const channelId = result.channelId; + const channelId = result.target?.kind === "channel" ? result.target.id : undefined; if (!channelId) { return; } diff --git a/extensions/slack/src/question-finalization.test.ts b/extensions/slack/src/question-finalization.test.ts index 49c98e46fe9a..81fb62fc2ada 100644 --- a/extensions/slack/src/question-finalization.test.ts +++ b/extensions/slack/src/question-finalization.test.ts @@ -104,11 +104,15 @@ describe("Slack question finalization", () => { }, payload: renderedAfterTransport!, results: [ - { channel: "slack", messageId: "44", channelId: "C123" }, + { + channel: "slack", + messageId: "44", + target: { kind: "channel", id: "C123" }, + }, { channel: "slack", messageId: "55", - channelId: "C123", + target: { kind: "channel", id: "C123" }, meta: { slackQuestionActionIds: ["openclaw:question_button:1:1"], [SLACK_QUESTION_FINALIZATION_BLOCKS]: [], @@ -169,19 +173,31 @@ describe("Slack question finalization", () => { target: { channel: "slack", to: "C123", accountId: "default" }, payload: jsonRoundTrip(rendered)!, results: [ - { channel: "slack", messageId: "upload", channelId: "C123" }, - { channel: "slack", messageId: "preface-1", channelId: "C123" }, - { channel: "slack", messageId: "preface-2", channelId: "C123" }, + { + channel: "slack", + messageId: "upload", + target: { kind: "channel", id: "C123" }, + }, + { + channel: "slack", + messageId: "preface-1", + target: { kind: "channel", id: "C123" }, + }, + { + channel: "slack", + messageId: "preface-2", + target: { kind: "channel", id: "C123" }, + }, { channel: "slack", messageId: "another-question", - channelId: "C123", + target: { kind: "channel", id: "C123" }, meta: { slackQuestionActionIds: ["openclaw:question_button:9:1"] }, }, { channel: "slack", messageId: "actual-question", - channelId: "C123", + target: { kind: "channel", id: "C123" }, meta: { slackQuestionActionIds: [questionActionId], [SLACK_QUESTION_FINALIZATION_BLOCKS]: [], diff --git a/extensions/synology-chat/src/channel.test.ts b/extensions/synology-chat/src/channel.test.ts index c7f5e089025e..0fe93200cd4d 100644 --- a/extensions/synology-chat/src/channel.test.ts +++ b/extensions/synology-chat/src/channel.test.ts @@ -650,7 +650,7 @@ describe("createSynologyChatPlugin", () => { to: "user1", }); expect(result.channel).toBe("synology-chat"); - expect(result.chatId).toBe("user1"); + expect(result.target).toEqual({ kind: "chat", id: "user1" }); expect(result.messageId).toBe(""); expect(result.receipt.primaryPlatformMessageId).toBeUndefined(); expect(result.receipt.platformMessageIds).toHaveLength(0); @@ -683,7 +683,7 @@ describe("createSynologyChatPlugin", () => { }); expect(result.channel).toBe("synology-chat"); - expect(result.chatId).toBe("user1"); + expect(result.target).toEqual({ kind: "chat", id: "user1" }); expect(result.messageId).toBe(""); expect(result.receipt.primaryPlatformMessageId).toBeUndefined(); expect(result.receipt.platformMessageIds).toHaveLength(0); diff --git a/extensions/synology-chat/src/channel.ts b/extensions/synology-chat/src/channel.ts index 7ffa14a3dbc6..ff24776c90fd 100644 --- a/extensions/synology-chat/src/channel.ts +++ b/extensions/synology-chat/src/channel.ts @@ -178,7 +178,7 @@ const collectSynologyChatCriticalFindings = createConditionalWarningCollector.fi type SynologyChatOutboundResult = { channel: typeof CHANNEL_ID; messageId: string; - chatId: string; + target: { kind: "chat"; id: string }; receipt: MessageReceipt; }; @@ -273,7 +273,7 @@ function createSynologyChatSendResult(params: { // The webhook acknowledges delivery without returning a platform message id. // Keep the empty receipt so a chat id cannot become a fabricated message id. messageId: "", - chatId: params.chatId, + target: { kind: "chat", id: params.chatId }, receipt: createMessageReceiptFromOutboundResults({ results: [], threadId: params.chatId, diff --git a/extensions/synology-chat/src/client.loopback.test.ts b/extensions/synology-chat/src/client.loopback.test.ts index 969a02a7af80..f7da4f12b7f0 100644 --- a/extensions/synology-chat/src/client.loopback.test.ts +++ b/extensions/synology-chat/src/client.loopback.test.ts @@ -297,7 +297,7 @@ describe("Synology Chat client loopback", () => { expect(result).toMatchObject({ channel: "synology-chat", messageId: "", - chatId: "42", + target: { kind: "chat", id: "42" }, receipt: { platformMessageIds: [], parts: [], diff --git a/extensions/telegram/src/action-runtime.test.ts b/extensions/telegram/src/action-runtime.test.ts index e039bd06802f..5197a1762ed3 100644 --- a/extensions/telegram/src/action-runtime.test.ts +++ b/extensions/telegram/src/action-runtime.test.ts @@ -149,7 +149,13 @@ const sendDurableMessageBatch = vi.fn( } return { status: "sent", - results: [{ channel: "telegram", messageId: last.messageId, chatId: last.chatId }], + results: [ + { + channel: "telegram", + messageId: last.messageId, + target: { kind: "chat", id: last.chatId }, + }, + ], receipt: { primaryPlatformMessageId: last.messageId, platformMessageIds: [last.messageId], diff --git a/extensions/telegram/src/action-runtime.ts b/extensions/telegram/src/action-runtime.ts index 45905ffa755d..565d11576e11 100644 --- a/extensions/telegram/src/action-runtime.ts +++ b/extensions/telegram/src/action-runtime.ts @@ -402,7 +402,7 @@ function getLastDurableTelegramActionResult( lastResult?.messageId ?? receipt.primaryPlatformMessageId ?? receipt.platformMessageIds.at(-1), - chatId: lastResult?.chatId, + chatId: lastResult?.target?.kind === "chat" ? lastResult.target.id : undefined, }; } diff --git a/extensions/telegram/src/outbound-adapter.presentation.test.ts b/extensions/telegram/src/outbound-adapter.presentation.test.ts index c6b6de2b9e1f..7d84872a7af5 100644 --- a/extensions/telegram/src/outbound-adapter.presentation.test.ts +++ b/extensions/telegram/src/outbound-adapter.presentation.test.ts @@ -64,7 +64,7 @@ describe("telegramOutbound presentation", () => { expect(result).toEqual({ channel: "telegram", messageId: "tg-presentation-buttons", - chatId: "12345", + target: { kind: "chat", id: "12345" }, }); }); diff --git a/extensions/telegram/src/outbound-adapter.test.ts b/extensions/telegram/src/outbound-adapter.test.ts index 5c5c9a04611b..bd9335da73db 100644 --- a/extensions/telegram/src/outbound-adapter.test.ts +++ b/extensions/telegram/src/outbound-adapter.test.ts @@ -182,7 +182,11 @@ describe("telegramOutbound", () => { expect((firstOptions.promptContextProjectionPlan as { cursor: unknown }).cursor).toBe( (secondOptions.promptContextProjectionPlan as { cursor: unknown }).cursor, ); - expect(result).toEqual({ channel: "telegram", messageId: "tg-2", chatId: "12345" }); + expect(result).toEqual({ + channel: "telegram", + messageId: "tg-2", + target: { kind: "chat", id: "12345" }, + }); }); it.each([ @@ -240,7 +244,11 @@ describe("telegramOutbound", () => { const options = callOptionsAt(sendMessageTelegramMock, 0, "12345", "- Retry"); expect(options.buttons).toEqual([[{ text: "Retry", callback_data: "cmd:retry" }]]); - expect(result).toEqual({ channel: "telegram", messageId: "tg-buttons", chatId: "12345" }); + expect(result).toEqual({ + channel: "telegram", + messageId: "tg-buttons", + target: { kind: "chat", id: "12345" }, + }); }); it("forwards prompt-context sources on durable payload sends", async () => { @@ -277,7 +285,11 @@ describe("telegramOutbound", () => { }, finalPart: true, }); - expect(result).toEqual({ channel: "telegram", messageId: "tg-final", chatId: "12345" }); + expect(result).toEqual({ + channel: "telegram", + messageId: "tg-final", + target: { kind: "chat", id: "12345" }, + }); }); it("detaches stale prompt-context provenance after a durable hook rewrite", async () => { @@ -332,7 +344,11 @@ describe("telegramOutbound", () => { gatewayClientScopes: undefined, }); expect(sendMessageTelegramMock).not.toHaveBeenCalled(); - expect(result).toEqual({ channel: "telegram", messageId: "777", chatId: "12345" }); + expect(result).toEqual({ + channel: "telegram", + messageId: "777", + target: { kind: "chat", id: "12345" }, + }); }); it("applies reaction payloads before sending visible text", async () => { @@ -474,7 +490,11 @@ describe("telegramOutbound", () => { const options = callOptionsAt(sendMessageTelegramMock, 0, "12345", "quiet"); expect(options.silent).toBe(true); - expect(result).toEqual({ channel: "telegram", messageId: "tg-silent", chatId: "12345" }); + expect(result).toEqual({ + channel: "telegram", + messageId: "tg-silent", + target: { kind: "chat", id: "12345" }, + }); }); it("does not plain-text sanitize Telegram HTML before durable delivery", async () => { @@ -606,7 +626,11 @@ describe("telegramOutbound", () => { const options = callOptionsAt(sendMessageTelegramMock, 0, "12345", "voice caption"); expect(options.mediaUrl).toBe("file:///tmp/note.ogg"); expect(options.asVoice).toBe(true); - expect(result).toEqual({ channel: "telegram", messageId: "tg-voice", chatId: "12345" }); + expect(result).toEqual({ + channel: "telegram", + messageId: "tg-voice", + target: { kind: "chat", id: "12345" }, + }); }); it("forwards videoAsNote payload media to Telegram video-note sends", async () => { @@ -629,7 +653,7 @@ describe("telegramOutbound", () => { expect(result).toEqual({ channel: "telegram", messageId: "tg-video-note", - chatId: "12345", + target: { kind: "chat", id: "12345" }, }); }); @@ -703,7 +727,7 @@ describe("telegramOutbound", () => { expect(result).toEqual({ channel: "telegram", messageId: "tg-location", - chatId: "12345", + target: { kind: "chat", id: "12345" }, }); }); @@ -750,7 +774,7 @@ describe("telegramOutbound", () => { expect(result).toEqual({ channel: "telegram", messageId: "tg-location", - chatId: "12345", + target: { kind: "chat", id: "12345" }, }); }); diff --git a/extensions/telegram/src/outbound-adapter.ts b/extensions/telegram/src/outbound-adapter.ts index 97f3ae6be499..c6be7ecdcbab 100644 --- a/extensions/telegram/src/outbound-adapter.ts +++ b/extensions/telegram/src/outbound-adapter.ts @@ -45,6 +45,13 @@ type TelegramLocationFn = typeof import("./send.js").sendLocationTelegram; type ResolveTelegramSendFn = (deps?: OutboundSendDeps) => Promise; type LoadTelegramSendModuleFn = () => Promise; +function toTelegramOutboundResult(result: T) { + const { chatId, ...delivery } = result; + return chatId === undefined + ? delivery + : { ...delivery, target: { kind: "chat" as const, id: chatId } }; +} + async function resolveDefaultTelegramSend(deps?: OutboundSendDeps): Promise { return ( resolveOutboundSendDep(deps, "telegram") ?? @@ -111,7 +118,9 @@ async function resolveTelegramSendContext(params: { gatewayClientScopes: params.gatewayClientScopes, onDeliveryResult: params.onDeliveryResult ? async (result) => { - await params.onDeliveryResult?.(attachChannelToResult("telegram", result)); + await params.onDeliveryResult?.( + attachChannelToResult("telegram", toTelegramOutboundResult(result)), + ); } : undefined, onPlatformSendDispatch: params.onPlatformSendDispatch, @@ -511,7 +520,10 @@ export function createTelegramOutboundAdapter( if (!questionId || !result || !text) { return; } - const chatId = result.chatId ?? normalizeTelegramOutboundTarget(target.to); + const chatId = + result.target?.kind === "chat" + ? result.target.id + : normalizeTelegramOutboundTarget(target.to); questionGatewayRuntime.registerChannelDelivery({ questionId, deliveryId: `telegram:${target.accountId ?? "default"}:${chatId}:${result.messageId}`, @@ -550,23 +562,27 @@ export function createTelegramOutboundAdapter( ...params, resolveSend, }); - return await send(outboundTo, params.text, { - ...baseOpts, - }); + return toTelegramOutboundResult( + await send(outboundTo, params.text, { + ...baseOpts, + }), + ); }, sendMedia: async (params) => { const { outboundTo, send, baseOpts } = await resolveTelegramOutboundSendContext({ ...params, resolveSend, }); - return await send(outboundTo, params.text, { - ...baseOpts, - mediaUrl: params.mediaUrl, - ...(params.mediaAccess !== undefined ? { mediaAccess: params.mediaAccess } : {}), - mediaLocalRoots: params.mediaLocalRoots, - mediaReadFile: params.mediaReadFile, - forceDocument: params.forceDocument ?? false, - }); + return toTelegramOutboundResult( + await send(outboundTo, params.text, { + ...baseOpts, + mediaUrl: params.mediaUrl, + ...(params.mediaAccess !== undefined ? { mediaAccess: params.mediaAccess } : {}), + mediaLocalRoots: params.mediaLocalRoots, + mediaReadFile: params.mediaReadFile, + forceDocument: params.forceDocument ?? false, + }), + ); }, }), sendPayload: async (params) => { @@ -589,7 +605,7 @@ export function createTelegramOutboundAdapter( forceDocument: params.forceDocument ?? false, }, }); - return attachChannelToResult("telegram", result); + return attachChannelToResult("telegram", toTelegramOutboundResult(result)); }, sendPoll: async ({ cfg, diff --git a/extensions/telegram/src/question-finalization.test.ts b/extensions/telegram/src/question-finalization.test.ts index 0df29d7b675a..696621f1d46b 100644 --- a/extensions/telegram/src/question-finalization.test.ts +++ b/extensions/telegram/src/question-finalization.test.ts @@ -40,13 +40,13 @@ describe("Telegram question finalization", () => { { channel: "telegram", messageId: "54", - chatId: "123", + target: { kind: "chat", id: "123" }, meta: { telegramDeliveredText: "Long preface", telegramHasInlineKeyboard: false }, }, { channel: "telegram", messageId: "55", - chatId: "123", + target: { kind: "chat", id: "123" }, meta: { telegramDeliveredText: "Pick one", telegramHasInlineKeyboard: true }, }, ], diff --git a/src/channels/message/outbound-bridge.ts b/src/channels/message/outbound-bridge.ts index bb17a4e7cc38..9c4c3016d130 100644 --- a/src/channels/message/outbound-bridge.ts +++ b/src/channels/message/outbound-bridge.ts @@ -67,6 +67,7 @@ function resolveResultMessageId(result: ChannelMessageOutboundBridgeResult): str result.messageId ?? result.receipt?.primaryPlatformMessageId ?? result.receipt?.platformMessageIds[0] ?? + result.target?.id ?? result.chatId ?? result.channelId ?? result.roomId ?? @@ -103,6 +104,7 @@ function toMessageSendResult( return { // Preserve sanctioned owner facts for delivery hooks without exposing private // provider fields or trusting a provider-authored channel identity. + ...(result.target !== undefined ? { target: result.target } : {}), ...(result.chatId !== undefined ? { chatId: result.chatId } : {}), ...(result.channelId !== undefined ? { channelId: result.channelId } : {}), ...(result.roomId !== undefined ? { roomId: result.roomId } : {}), diff --git a/src/channels/message/receipt.ts b/src/channels/message/receipt.ts index eea116258955..b40e4a7df15d 100644 --- a/src/channels/message/receipt.ts +++ b/src/channels/message/receipt.ts @@ -17,6 +17,7 @@ type MessageReceiptInputResult = MessageReceiptSourceResult & { function resolveReceiptMessageId(result: MessageReceiptInputResult): string | undefined { return ( result.messageId || + result.target?.id || result.chatId || result.channelId || result.roomId || diff --git a/src/channels/message/types.ts b/src/channels/message/types.ts index 969be50bc114..df0dac22745b 100644 --- a/src/channels/message/types.ts +++ b/src/channels/message/types.ts @@ -50,6 +50,10 @@ type DurableFinalDeliveryPayloadShape = { export type MessageReceiptSourceResult = { channel?: string; messageId?: string; + target?: { + kind: "chat" | "channel" | "room" | "conversation"; + id: string; + }; chatId?: string; channelId?: string; roomId?: string; @@ -232,6 +236,7 @@ export type ChannelMessageSendPollContext = Omit< export type ChannelMessageSendResult = { receipt: MessageReceipt; messageId?: string; + target?: MessageReceiptSourceResult["target"]; }; /** Discriminator for lifecycle hooks around a concrete adapter send attempt. */ diff --git a/src/commands/message-format.test.ts b/src/commands/message-format.test.ts index a518700307ef..b82402b1c9b4 100644 --- a/src/commands/message-format.test.ts +++ b/src/commands/message-format.test.ts @@ -236,7 +236,7 @@ describe("formatMessageCliText poll results", () => { via: "direct", result: { messageId: "p1", - conversationId: "conv-1", + target: { kind: "conversation", id: "conv-1" }, pollId: "poll-1", }, }, diff --git a/src/infra/command-analysis/risks.test.ts b/src/infra/command-analysis/risks.test.ts index 9ae72e1d396e..d768f16607ab 100644 --- a/src/infra/command-analysis/risks.test.ts +++ b/src/infra/command-analysis/risks.test.ts @@ -274,11 +274,14 @@ describe("command-analysis risks", () => { raw: "sudo python3 -c 'print(1)'", argv: ["sudo", "python3", "-c", "print(1)"], resolution: { + kind: "command", execution: { + kind: "executable", rawExecutable: "sudo", executableName: "sudo", }, policy: { + kind: "executable", rawExecutable: "sudo", executableName: "sudo", }, diff --git a/src/infra/exec-allowlist-matching.test.ts b/src/infra/exec-allowlist-matching.test.ts index 83e63e21fcea..6b2b6b9c5b38 100644 --- a/src/infra/exec-allowlist-matching.test.ts +++ b/src/infra/exec-allowlist-matching.test.ts @@ -4,6 +4,7 @@ import { matchAllowlist, type ExecAllowlistEntry } from "./exec-approvals.js"; describe("exec allowlist matching", () => { const baseResolution = { + kind: "executable" as const, rawExecutable: "rg", resolvedPath: "/opt/homebrew/bin/rg", executableName: "rg", @@ -30,11 +31,13 @@ describe("exec allowlist matching", () => { it("does not let bare command-name patterns match path-selected executables", () => { const relativeResolution = { + kind: "executable" as const, rawExecutable: "./rg", resolvedPath: "/tmp/openclaw-workspace/rg", executableName: "rg", }; const absoluteResolution = { + kind: "executable" as const, rawExecutable: "/tmp/openclaw-workspace/rg", resolvedPath: "/tmp/openclaw-workspace/rg", executableName: "rg", @@ -58,6 +61,7 @@ describe("exec allowlist matching", () => { describe("argPattern path matches", () => { const resolution = { + kind: "executable" as const, rawExecutable: "python3", resolvedPath: "/usr/bin/python3", resolvedRealPath: "/usr/bin/python3", @@ -175,6 +179,7 @@ describe("exec allowlist matching", () => { const cases = [ baseResolution, { + kind: "executable" as const, rawExecutable: "python3", resolvedPath: "/usr/bin/python3", executableName: "python3", @@ -190,6 +195,7 @@ describe("exec allowlist matching", () => { () => { expect( matchAllowlist([{ pattern: "/usr/bin/**" }], { + kind: "executable", rawExecutable: "/usr/bin/../../bin/sh", resolvedPath: "/usr/bin/../../bin/sh", executableName: "sh", @@ -197,6 +203,7 @@ describe("exec allowlist matching", () => { ).toBeNull(); expect( matchAllowlist([{ pattern: "/usr/bin/**" }], { + kind: "executable", rawExecutable: "/usr/bin/sub/../env", resolvedPath: "/usr/bin/sub/../env", executableName: "env", @@ -209,6 +216,7 @@ describe("exec allowlist matching", () => { const plusPathCases = ["/usr/bin/g++", "/usr/bin/clang++"] as const; for (const candidatePath of plusPathCases) { const match = matchAllowlist([{ pattern: candidatePath }], { + kind: "executable", rawExecutable: candidatePath, resolvedPath: candidatePath, executableName: candidatePath.split("/").at(-1) ?? candidatePath, @@ -220,6 +228,7 @@ describe("exec allowlist matching", () => { { pattern: "/usr/bin/*++", resolution: { + kind: "executable", rawExecutable: "/usr/bin/g++", resolvedPath: "/usr/bin/g++", executableName: "g++", @@ -228,6 +237,7 @@ describe("exec allowlist matching", () => { { pattern: "/opt/builds/tool[1](stable)", resolution: { + kind: "executable", rawExecutable: "/opt/builds/tool[1](stable)", resolvedPath: "/opt/builds/tool[1](stable)", executableName: "tool[1](stable)", @@ -241,6 +251,7 @@ describe("exec allowlist matching", () => { it("matches path-shaped allowlist entries against the executable trust realpath", () => { const resolution = { + kind: "executable" as const, rawExecutable: "rg", resolvedPath: "/opt/homebrew/bin/rg", resolvedRealPath: "/opt/homebrew/Cellar/ripgrep/14.1.1/bin/rg", @@ -256,6 +267,7 @@ describe("exec allowlist matching", () => { it("keeps basename allowlist entries on the PATH-resolved executable name", () => { const resolution = { + kind: "executable" as const, rawExecutable: "rg", resolvedPath: "/opt/homebrew/bin/rg", resolvedRealPath: "/opt/homebrew/Cellar/ripgrep/14.1.1/bin/rg", diff --git a/src/infra/exec-approvals-allowlist.ts b/src/infra/exec-approvals-allowlist.ts index d0554d89bf58..b17606b2a9e2 100644 --- a/src/infra/exec-approvals-allowlist.ts +++ b/src/infra/exec-approvals-allowlist.ts @@ -561,6 +561,7 @@ function resolveSegmentAllowlistMatch(params: { ? params.context.allowlist : params.context.allowlist.filter((entry) => entry.argPattern === undefined), { + kind: "executable", rawExecutable: shellPositionalArgvCandidate.path, resolvedPath: shellPositionalArgvCandidate.path, resolvedRealPath: resolveCandidateTrustPath(shellPositionalArgvCandidate.path), @@ -591,6 +592,7 @@ function resolveSegmentAllowlistMatch(params: { ? matchAllowlist( params.context.allowlist, { + kind: "executable", rawExecutable: shellScriptCandidatePath, resolvedPath: shellScriptCandidatePath, resolvedRealPath: resolveCandidateTrustPath(shellScriptCandidatePath), @@ -1267,6 +1269,7 @@ function resolveCandidateTrustPath(candidatePath: string | undefined): string | return undefined; } return resolveExecutableTrustPath({ + kind: "executable", rawExecutable: candidatePath, resolvedPath: candidatePath, executableName: path.basename(candidatePath), diff --git a/src/infra/exec-approvals-config.test.ts b/src/infra/exec-approvals-config.test.ts index d3fa5e045a0c..d95a278970fe 100644 --- a/src/infra/exec-approvals-config.test.ts +++ b/src/infra/exec-approvals-config.test.ts @@ -69,6 +69,7 @@ describe("exec approvals node host allowlist check", () => { it.each([ { resolution: { + kind: "executable" as const, rawExecutable: "python3", resolvedPath: "/usr/bin/python3", resolvedRealPath: "/usr/bin/python3", @@ -81,6 +82,7 @@ describe("exec approvals node host allowlist check", () => { // Simulates symlink resolution: // /opt/homebrew/bin/python3 -> /opt/homebrew/opt/python@3.14/bin/python3.14 resolution: { + kind: "executable" as const, rawExecutable: "python3", resolvedPath: "/opt/homebrew/opt/python@3.14/bin/python3.14", executableName: "python3.14", @@ -90,6 +92,7 @@ describe("exec approvals node host allowlist check", () => { }, { resolution: { + kind: "executable" as const, rawExecutable: "unknown-tool", resolvedPath: "/usr/local/bin/unknown-tool", executableName: "unknown-tool", @@ -107,6 +110,7 @@ describe("exec approvals node host allowlist check", () => { it("does not treat unknown tools as safe bins", () => { const resolution = { + kind: "executable" as const, rawExecutable: "unknown-tool", resolvedPath: "/usr/local/bin/unknown-tool", executableName: "unknown-tool", @@ -121,6 +125,7 @@ describe("exec approvals node host allowlist check", () => { it("satisfies via safeBins even when not in allowlist", () => { const resolution = { + kind: "executable" as const, rawExecutable: "head", resolvedPath: "/usr/bin/head", executableName: "head", diff --git a/src/infra/exec-approvals-safe-bins.test.ts b/src/infra/exec-approvals-safe-bins.test.ts index 2da03142f26d..a7a74bf246da 100644 --- a/src/infra/exec-approvals-safe-bins.test.ts +++ b/src/infra/exec-approvals-safe-bins.test.ts @@ -393,6 +393,7 @@ describe("exec approvals safe bins", () => { const ok = isSafeBinUsage({ argv: testCase.argv, resolution: { + kind: "executable", rawExecutable, resolvedPath: testCase.resolvedPath, executableName, @@ -412,6 +413,7 @@ describe("exec approvals safe bins", () => { const ok = isSafeBinUsage({ argv: ["head", "-n", "1"], resolution: { + kind: "executable", rawExecutable: "head", resolvedPath: "/custom/bin/head", executableName: "head", @@ -427,6 +429,7 @@ describe("exec approvals safe bins", () => { return; } const resolution = { + kind: "executable" as const, rawExecutable: "head", resolvedPath: "/opt/homebrew/bin/head", resolvedRealPath: "/opt/homebrew/Cellar/coreutils/9.5/bin/head", @@ -465,6 +468,7 @@ describe("exec approvals safe bins", () => { const ok = isSafeBinUsage({ argv: ["head", "-n", "1"], resolution: { + kind: "executable", rawExecutable: "head", resolvedPath: "/usr/bin/head", executableName: "head", @@ -482,6 +486,7 @@ describe("exec approvals safe bins", () => { const baseParams = { argv: ["head", "-n", "1"], resolution: { + kind: "executable" as const, rawExecutable: "head", resolvedPath: "/tmp/custom/head", executableName: "head", @@ -535,6 +540,7 @@ describe("exec approvals safe bins", () => { const allow = isSafeBinUsage({ argv: ["echo", "hello"], resolution: { + kind: "executable", rawExecutable: "echo", resolvedPath: "/opt/openclaw-test/bin/echo", executableName: "echo", @@ -546,6 +552,7 @@ describe("exec approvals safe bins", () => { const deny = isSafeBinUsage({ argv: ["echo", "hello", "world"], resolution: { + kind: "executable", rawExecutable: "echo", resolvedPath: "/opt/openclaw-test/bin/echo", executableName: "echo", @@ -565,6 +572,7 @@ describe("exec approvals safe bins", () => { const cwd = makeExecApprovalsTempDir(); fs.writeFileSync(path.join(cwd, "existing.txt"), "x"); const resolution = { + kind: "executable" as const, rawExecutable: "sort", resolvedPath: "/usr/bin/sort", executableName: "sort", diff --git a/src/infra/exec-approvals-test-helpers.ts b/src/infra/exec-approvals-test-helpers.ts index 23da165fdaf4..38972743b65d 100644 --- a/src/infra/exec-approvals-test-helpers.ts +++ b/src/infra/exec-approvals-test-helpers.ts @@ -47,6 +47,7 @@ export function makeMockExecutableResolution(params: { resolvedRealPath?: string; }): ExecutableResolution { return { + kind: "executable", rawExecutable: params.rawExecutable, resolvedPath: params.resolvedPath, resolvedRealPath: params.resolvedRealPath, @@ -54,7 +55,7 @@ export function makeMockExecutableResolution(params: { }; } -/** Build a command resolution while preserving legacy getter accessors. */ +/** Build a command resolution for command-policy tests. */ export function makeMockCommandResolution(params: { execution: ExecutableResolution; policy?: ExecutableResolution; @@ -63,32 +64,15 @@ export function makeMockCommandResolution(params: { policyBlocked?: boolean; blockedWrapper?: string; }): CommandResolution { - const policy = params.policy ?? params.execution; - const resolution: CommandResolution = { + return { + kind: "command", execution: params.execution, - policy, + policy: params.policy ?? params.execution, effectiveArgv: params.effectiveArgv, wrapperChain: params.wrapperChain, policyBlocked: params.policyBlocked, blockedWrapper: params.blockedWrapper, }; - return Object.defineProperties(resolution, { - rawExecutable: { - get: () => params.execution.rawExecutable, - }, - resolvedPath: { - get: () => params.execution.resolvedPath, - }, - resolvedRealPath: { - get: () => params.execution.resolvedRealPath, - }, - executableName: { - get: () => params.execution.executableName, - }, - policyResolution: { - get: () => (policy === params.execution ? undefined : policy), - }, - }); } type ShellParserParityFixtureCase = { diff --git a/src/infra/exec-command-resolution.test.ts b/src/infra/exec-command-resolution.test.ts index 287d30df8a90..6fb21afca52b 100644 --- a/src/infra/exec-command-resolution.test.ts +++ b/src/infra/exec-command-resolution.test.ts @@ -242,13 +242,16 @@ describe("exec-command-resolution", () => { it("exposes canonical trust paths separately from display candidate paths", () => { const resolution = { + kind: "command" as const, execution: { + kind: "executable" as const, rawExecutable: "rg", resolvedPath: "/opt/homebrew/bin/rg", resolvedRealPath: "/opt/homebrew/Cellar/ripgrep/14.1.1/bin/rg", executableName: "rg", }, policy: { + kind: "executable" as const, rawExecutable: "rg", resolvedPath: "/opt/homebrew/bin/rg", resolvedRealPath: "/opt/homebrew/Cellar/ripgrep/14.1.1/bin/rg", @@ -401,6 +404,7 @@ describe("exec-command-resolution", () => { expect( resolveExecutionTargetCandidatePath( { + kind: "executable", rawExecutable: "~/bin/tool", executableName: "tool", }, @@ -411,6 +415,7 @@ describe("exec-command-resolution", () => { expect( resolveExecutionTargetCandidatePath( { + kind: "executable", rawExecutable: "./scripts/run.sh", executableName: "run.sh", }, @@ -421,6 +426,7 @@ describe("exec-command-resolution", () => { expect( resolveExecutionTargetCandidatePath( { + kind: "executable", rawExecutable: "rg", executableName: "rg", }, @@ -571,6 +577,7 @@ describe("exec-command-resolution", () => { expect( resolveAllowlistCandidatePath( { + kind: "executable", rawExecutable: String.raw`:\Users\demo\AI\system\openclaw`, executableName: "openclaw", }, @@ -580,6 +587,7 @@ describe("exec-command-resolution", () => { expect( resolveAllowlistCandidatePath( { + kind: "executable", rawExecutable: String.raw`:/Users/demo/AI/system/openclaw`, executableName: "openclaw", }, diff --git a/src/infra/exec-command-resolution.ts b/src/infra/exec-command-resolution.ts index b0c802cc410a..4c50a7b554f3 100644 --- a/src/infra/exec-command-resolution.ts +++ b/src/infra/exec-command-resolution.ts @@ -12,6 +12,7 @@ import { } from "./executable-path.js"; export type ExecutableResolution = { + kind: "executable"; rawExecutable: string; resolvedPath?: string; resolvedRealPath?: string; @@ -19,6 +20,7 @@ export type ExecutableResolution = { }; export type CommandResolution = { + kind: "command"; execution: ExecutableResolution; policy: ExecutableResolution; effectiveArgv?: string[]; @@ -27,12 +29,6 @@ export type CommandResolution = { blockedWrapper?: string; }; -function isCommandResolution( - resolution: CommandResolution | ExecutableResolution | null, -): resolution is CommandResolution { - return Boolean(resolution && "execution" in resolution && "policy" in resolution); -} - function parseFirstToken(command: string): string | null { const trimmed = command.trim(); if (!trimmed) { @@ -68,6 +64,7 @@ function buildExecutableResolution( const resolvedRealPath = tryResolveRealpath(resolvedPath); const executableName = resolvedPath ? path.basename(resolvedPath) : rawExecutable; return { + kind: "executable", rawExecutable, resolvedPath, resolvedRealPath, @@ -90,6 +87,7 @@ function buildCommandResolution(params: { ? buildExecutableResolution(params.policyRawExecutable, params) : execution; const resolution: CommandResolution = { + kind: "command", execution, policy, effectiveArgv: params.effectiveArgv, @@ -97,24 +95,7 @@ function buildCommandResolution(params: { policyBlocked: params.policyBlocked, blockedWrapper: params.blockedWrapper, }; - // Compatibility getters for JS/tests while TS callers migrate to explicit targets. - return Object.defineProperties(resolution, { - rawExecutable: { - get: () => execution.rawExecutable, - }, - resolvedPath: { - get: () => execution.resolvedPath, - }, - resolvedRealPath: { - get: () => execution.resolvedRealPath, - }, - executableName: { - get: () => execution.executableName, - }, - policyResolution: { - get: () => (policy === execution ? undefined : policy), - }, - }); + return resolution; } export function resolveCommandResolution( @@ -198,7 +179,7 @@ export function resolveExecutionTargetResolution( if (!resolution) { return null; } - return isCommandResolution(resolution) ? resolution.execution : resolution; + return resolution.kind === "command" ? resolution.execution : resolution; } export function resolvePolicyTargetResolution( @@ -207,7 +188,7 @@ export function resolvePolicyTargetResolution( if (!resolution) { return null; } - return isCommandResolution(resolution) ? resolution.policy : resolution; + return resolution.kind === "command" ? resolution.policy : resolution; } export function resolveExecutionTargetCandidatePath( @@ -215,7 +196,7 @@ export function resolveExecutionTargetCandidatePath( cwd?: string, ): string | undefined { return resolveExecutableCandidatePathFromResolution( - isCommandResolution(resolution) ? resolution.execution : resolution, + resolution?.kind === "command" ? resolution.execution : resolution, cwd, ); } @@ -225,7 +206,7 @@ export function resolveExecutionTargetTrustPath( cwd?: string, ): string | undefined { return resolveExecutableTrustPath( - isCommandResolution(resolution) ? resolution.execution : resolution, + resolution?.kind === "command" ? resolution.execution : resolution, cwd, ); } @@ -235,7 +216,7 @@ export function resolvePolicyTargetCandidatePath( cwd?: string, ): string | undefined { return resolveExecutableCandidatePathFromResolution( - isCommandResolution(resolution) ? resolution.policy : resolution, + resolution?.kind === "command" ? resolution.policy : resolution, cwd, ); } @@ -245,7 +226,7 @@ export function resolvePolicyTargetTrustPath( cwd?: string, ): string | undefined { return resolveExecutableTrustPath( - isCommandResolution(resolution) ? resolution.policy : resolution, + resolution?.kind === "command" ? resolution.policy : resolution, cwd, ); } diff --git a/src/infra/outbound/deliver-payload.ts b/src/infra/outbound/deliver-payload.ts index 2d41c775753a..1dee430da1b4 100644 --- a/src/infra/outbound/deliver-payload.ts +++ b/src/infra/outbound/deliver-payload.ts @@ -175,15 +175,7 @@ export function buildPayloadSummary(payload: ReplyPayload): NormalizedOutboundPa } export function hasDeliveryResultIdentity(result: OutboundDeliveryResult): boolean { - return Boolean( - result.messageId || - result.chatId || - result.channelId || - result.roomId || - result.conversationId || - result.toJid || - result.pollId, - ); + return Boolean(result.messageId || result.target?.id || result.toJid || result.pollId); } function normalizeDeliveryPin(payload: ReplyPayload): ReplyPayloadDeliveryPin | undefined { diff --git a/src/infra/outbound/deliver-results.ts b/src/infra/outbound/deliver-results.ts index 80ce3995132f..3c857beacc50 100644 --- a/src/infra/outbound/deliver-results.ts +++ b/src/infra/outbound/deliver-results.ts @@ -13,10 +13,7 @@ export function createDeliveryResultRecorder(params: { JSON.stringify([ delivery.channel, delivery.messageId, - delivery.chatId, - delivery.channelId, - delivery.roomId, - delivery.conversationId, + delivery.target, delivery.timestamp, delivery.toJid, delivery.pollId, diff --git a/src/infra/outbound/deliver-types.ts b/src/infra/outbound/deliver-types.ts index 5be06b53cd2e..3fbf9d998265 100644 --- a/src/infra/outbound/deliver-types.ts +++ b/src/infra/outbound/deliver-types.ts @@ -7,10 +7,10 @@ import type { ChannelId } from "../../channels/plugins/channel-id.types.js"; export type OutboundDeliveryResult = { channel: ChannelId; messageId: string; - chatId?: string; - channelId?: string; - roomId?: string; - conversationId?: string; + target?: { + kind: "chat" | "channel" | "room" | "conversation"; + id: string; + }; timestamp?: number; toJid?: string; pollId?: string; diff --git a/src/infra/outbound/deliver.test.ts b/src/infra/outbound/deliver.test.ts index 371f19f9b694..a83676279ac7 100644 --- a/src/infra/outbound/deliver.test.ts +++ b/src/infra/outbound/deliver.test.ts @@ -2464,7 +2464,7 @@ describe("deliverOutboundPayloads", () => { const unsubscribe = onTrustedMessageAuditEvent((event) => events.push(event)); const sendMatrix = vi.fn().mockResolvedValue({ messageId: "platform-message-1", - roomId: "!room:example", + target: { kind: "room", id: "!room:example" }, }); try { diff --git a/src/infra/outbound/envelope.test.ts b/src/infra/outbound/envelope.test.ts index 71fe1acbeafa..eedeb0f33666 100644 --- a/src/infra/outbound/envelope.test.ts +++ b/src/infra/outbound/envelope.test.ts @@ -11,7 +11,7 @@ describe("buildOutboundResultEnvelope", () => { to: "123", messageId: "m1", mediaUrl: null, - chatId: "c1", + target: { kind: "chat", id: "c1" }, }; const payloads = [{ text: "hi", mediaUrl: null, mediaUrls: undefined }]; diff --git a/src/infra/outbound/format.test.ts b/src/infra/outbound/format.test.ts index 136c1a5758a5..9a5d8c8154da 100644 --- a/src/infra/outbound/format.test.ts +++ b/src/infra/outbound/format.test.ts @@ -37,7 +37,7 @@ describe("formatOutboundDeliverySummary", () => { result: { channel: "alpha" as const, messageId: "m1", - chatId: "c1", + target: { kind: "chat" as const, id: "c1" }, }, expected: "✅ Sent via Alpha. Message ID: m1 (chat c1)", }, @@ -46,7 +46,7 @@ describe("formatOutboundDeliverySummary", () => { result: { channel: "richchat" as const, messageId: "d1", - channelId: "chan", + target: { kind: "channel" as const, id: "chan" }, }, expected: "✅ Sent via Rich Chat. Message ID: d1 (channel chan)", }, @@ -55,7 +55,7 @@ describe("formatOutboundDeliverySummary", () => { result: { channel: "workspace" as const, messageId: "s1", - roomId: "room-1", + target: { kind: "room" as const, id: "room-1" }, }, expected: "✅ Sent via Workspace. Message ID: s1 (room room-1)", }, @@ -64,7 +64,7 @@ describe("formatOutboundDeliverySummary", () => { result: { channel: "teamchat" as const, messageId: "t1", - conversationId: "conv-1", + target: { kind: "conversation" as const, id: "conv-1" }, }, expected: "✅ Sent via Team Chat. Message ID: t1 (conversation conv-1)", }, diff --git a/src/infra/outbound/format.ts b/src/infra/outbound/format.ts index 681f82a74236..02cec66428f2 100644 --- a/src/infra/outbound/format.ts +++ b/src/infra/outbound/format.ts @@ -15,10 +15,7 @@ export type OutboundDeliveryJson = { to: string; messageId: string; mediaUrl: string | null; - chatId?: string; - channelId?: string; - roomId?: string; - conversationId?: string; + target?: OutboundDeliveryResult["target"]; timestamp?: number; toJid?: string; meta?: Record; @@ -53,17 +50,8 @@ export function formatOutboundDeliverySummary( const label = resolveChannelLabel(result.channel); const base = `✅ ${action} via ${label}. Message ID: ${result.messageId}`; - if ("chatId" in result) { - return `${base} (chat ${result.chatId})`; - } - if ("channelId" in result) { - return `${base} (channel ${result.channelId})`; - } - if ("roomId" in result) { - return `${base} (room ${result.roomId})`; - } - if ("conversationId" in result) { - return `${base} (conversation ${result.conversationId})`; + if (result.target) { + return `${base} (${result.target.kind} ${result.target.id})`; } return base; } diff --git a/src/infra/outbound/message.channels.test.ts b/src/infra/outbound/message.channels.test.ts index 2ad3e99e37a2..ca9809d8343f 100644 --- a/src/infra/outbound/message.channels.test.ts +++ b/src/infra/outbound/message.channels.test.ts @@ -251,7 +251,7 @@ function setDemoPollRegistry(outboundOptions: Parameters { it("normalizes plugin aliases for gateway polls", async () => { - callGatewayMock.mockResolvedValueOnce({ messageId: "p1" }); + callGatewayMock.mockResolvedValueOnce({ messageId: "p1", channelId: "channel-1" }); setDemoPollRegistry({ deliveryMode: "gateway" }); const result = await sendPoll({ @@ -267,11 +267,15 @@ describe("sendPoll channel normalization", () => { expect(gatewayCall()?.params?.idempotencyKey).toBe("stable-poll-key"); expect(result.channel).toBe("demo-alias-channel"); expect(result.via).toBe("gateway"); + expect(result.result).toEqual({ + messageId: "p1", + target: { kind: "channel", id: "channel-1" }, + }); }); it("uses direct poll fallback for direct channel plugins", async () => { const cfg = { channels: {} }; - const sendPollMock = vi.fn(async () => ({ messageId: "p1" })); + const sendPollMock = vi.fn(async () => ({ messageId: "p1", conversationId: "conv-1" })); setDemoPollRegistry({ supportsAnonymousPolls: true, sendPoll: sendPollMock }); const result = await sendPoll({ @@ -291,7 +295,10 @@ describe("sendPoll channel normalization", () => { channel: "demo-alias-channel", to: "conversation:demo-target", via: "direct", - result: { messageId: "p1" }, + result: { + messageId: "p1", + target: { kind: "conversation", id: "conv-1" }, + }, }); expect(sendPollMock).toHaveBeenCalledWith({ cfg, diff --git a/src/infra/outbound/message.ts b/src/infra/outbound/message.ts index 828f151fd069..477d1aae6799 100644 --- a/src/infra/outbound/message.ts +++ b/src/infra/outbound/message.ts @@ -11,7 +11,7 @@ import { type SerializedDurableMessagePayloadOutcome, } from "../../channels/message/runtime.js"; import type { DurableMessageSendIntent } from "../../channels/message/types.js"; -import type { ChannelPlugin } from "../../channels/plugins/types.public.js"; +import type { ChannelPlugin, ChannelPollResult } from "../../channels/plugins/types.public.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { OutboundMediaAccess } from "../../media/load-options.js"; import type { PollInput } from "../../polls.js"; @@ -172,16 +172,24 @@ export type MessagePollResult = { durationSeconds: number | null; durationHours: number | null; via: "direct" | "gateway"; - result?: { - messageId: string; - toJid?: string; - channelId?: string; - conversationId?: string; - pollId?: string; - }; + result?: Pick; dryRun?: boolean; }; +function normalizeMessagePollDeliveryResult( + result: ChannelPollResult, +): NonNullable { + const { channelId, conversationId, ...delivery } = result; + return { + ...delivery, + ...(channelId + ? { target: { kind: "channel" as const, id: channelId } } + : conversationId + ? { target: { kind: "conversation" as const, id: conversationId } } + : {}), + }; +} + function buildMessagePollResult(params: { channel: string; to: string; @@ -566,17 +574,11 @@ export async function sendPoll(params: MessagePollParams): Promise({ + const result = await callMessageGateway({ gateway: params.gateway, method: "poll", params: { @@ -600,6 +602,6 @@ export async function sendPoll(params: MessagePollParams): Promise { raw: "safe --version", argv: ["safe", "--version"], resolution: { + kind: "command", execution: { + kind: "executable", rawExecutable: "safe", resolvedPath: trustedExecutable, executableName: "safe.exe", }, policy: { + kind: "executable", rawExecutable: "safe", resolvedPath: trustedExecutable, executableName: "safe.exe", @@ -135,12 +138,15 @@ describe("resolveSystemRunExecArgv", () => { raw: "safe-tool arg", argv: ["safe-tool", "arg"], resolution: { + kind: "command", execution: { + kind: "executable", rawExecutable: "safe-tool", resolvedPath: trustedExecutable, executableName: "safe-tool.exe", }, policy: { + kind: "executable", rawExecutable: "safe-tool", resolvedPath: trustedExecutable, executableName: "safe-tool.exe", @@ -162,12 +168,15 @@ describe("resolveSystemRunExecArgv", () => { raw: "safe --version", argv: ["safe", "--version"], resolution: { + kind: "command", policyBlocked: true, execution: { + kind: "executable", rawExecutable: "safe", executableName: "safe", }, policy: { + kind: "executable", rawExecutable: "safe", executableName: "safe", }, diff --git a/src/plugin-sdk/channel-send-result.test.ts b/src/plugin-sdk/channel-send-result.test.ts index 2cf3ba8770a9..22ba9047b40a 100644 --- a/src/plugin-sdk/channel-send-result.test.ts +++ b/src/plugin-sdk/channel-send-result.test.ts @@ -73,10 +73,10 @@ describe("buildChannelSendResult", () => { describe("createEmptyChannelResult", () => { it("builds an empty outbound result with channel metadata", () => { - expect(createEmptyChannelResult("line", { chatId: "u1" })).toEqual({ + expect(createEmptyChannelResult("line", { target: { kind: "chat", id: "u1" } })).toEqual({ channel: "line", messageId: "", - chatId: "u1", + target: { kind: "chat", id: "u1" }, }); }); }); @@ -85,7 +85,10 @@ describe("createAttachedChannelResultAdapter", () => { it("wraps outbound delivery and poll results", async () => { const adapter = createAttachedChannelResultAdapter({ channel: "discord", - sendText: async () => ({ messageId: "m1", channelId: "c1" }), + sendText: async () => ({ + messageId: "m1", + target: { kind: "channel", id: "c1" }, + }), sendMedia: async () => ({ messageId: "m2" }), sendPoll: async () => ({ messageId: "m3", pollId: "p1" }), }); @@ -97,7 +100,7 @@ describe("createAttachedChannelResultAdapter", () => { expected: { channel: "discord", messageId: "m1", - channelId: "c1", + target: { kind: "channel", id: "c1" }, }, }, {