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
This commit is contained in:
Peter Steinberger
2026-08-16 22:55:41 -07:00
committed by GitHub
parent 54cbce25bd
commit f9b1ccc4fa
56 changed files with 611 additions and 418 deletions
+5 -2
View File
@@ -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.
</Accordion>
@@ -77,7 +77,7 @@ describe("discordOutbound shared interactive ordering", () => {
expect(result).toEqual({
channel: "discord",
messageId: "msg-1",
channelId: "123456",
target: { kind: "channel", id: "123456" },
});
});
});
@@ -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(
@@ -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" },
});
});
+27 -19
View File
@@ -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<NonNullable<Parameters<DiscordSendFn>[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<DiscordVoiceSendFn>(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(
+16 -8
View File
@@ -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<ReturnType<typeof createDiscordPayloadS
function resolveDiscordDeliveryProgress(ctx: DiscordOutboundPayloadContext) {
return ctx.onDeliveryResult
? async (result: Awaited<ReturnType<DiscordPayloadSendContext["send"]>>) => {
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));
}
@@ -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");
@@ -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: [
+5
View File
@@ -15,6 +15,11 @@ export type DiscordReceiptResultSource = {
platformMessageIds?: readonly string[];
};
export function toDiscordOutboundDeliveryResult<T extends { channelId: string }>(result: T) {
const { channelId, ...delivery } = result;
return { ...delivery, target: { kind: "channel" as const, id: channelId } };
}
export function createDiscordSendReceiptFromResults(params: {
results: readonly DiscordSendResult[];
threadId?: string;
+150 -121
View File
@@ -152,11 +152,16 @@ type FeishuOutboundPayload = Parameters<
type FeishuSendPayloadContext = Parameters<NonNullable<ChannelOutboundAdapter["sendPayload"]>>[0];
type FeishuSendTextContext = Parameters<NonNullable<ChannelOutboundAdapter["sendText"]>>[0];
async function reportFeishuOutboundDelivery<T extends { messageId: string }>(
function toFeishuOutboundResult<T extends { chatId: string }>(result: T) {
const { chatId, ...delivery } = result;
return { ...delivery, target: { kind: "chat" as const, id: chatId } };
}
async function reportFeishuOutboundDelivery<T extends { messageId: string; chatId: string }>(
result: T,
onDeliveryResult: FeishuSendTextContext["onDeliveryResult"],
): Promise<T> {
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<ReturnType<typeof sendCardFeishu>>
>({
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<ReturnType<typeof sendCardFeishu>>
>({
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);
},
}),
};
+10 -2
View File
@@ -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<typeof sendMessageIrc>) {
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,
+1
View File
@@ -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");
},
+2 -2
View File
@@ -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" },
});
});
+18 -10
View File
@@ -30,6 +30,11 @@ type MatrixChannelData = {
extraContent?: MatrixExtraContentFields;
};
function toMatrixOutboundResult<T extends { roomId: string }>(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<ReturnType<typeof sendMessageMatrix>>) => {
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;
+1 -1
View File
@@ -1739,7 +1739,7 @@ describe("mattermostPlugin", () => {
expect(onDeliveryResult).toHaveBeenCalledWith({
channel: "mattermost",
messageId: "post-final",
channelId: "CHAN1",
target: { kind: "channel", id: "CHAN1" },
content: "provider-final",
});
});
+34 -23
View File
@@ -764,12 +764,19 @@ function resolveMattermostSendAttachmentMedia(params: Record<string, unknown>):
type MattermostOutboundContext = Parameters<NonNullable<ChannelOutboundAdapter["sendText"]>>[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),
}),
),
}),
};
+4 -4
View File
@@ -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" },
});
});
+21 -12
View File
@@ -43,6 +43,11 @@ type MSTeamsMediaSendFn = (
opts?: MSTeamsMediaSendOptions,
) => Promise<MSTeamsSendResult>;
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<string, unknown>,
});
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<ReturnType<MSTeamsTextSendFn>>;
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;
+1 -1
View File
@@ -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"]);
+6 -1
View File
@@ -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 = {
+15 -3
View File
@@ -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" },
});
});
});
+46 -35
View File
@@ -44,6 +44,13 @@ import { resolveSlackThreadTsValue } from "./thread-ts.js";
type SlackSendFn = typeof import("./send.runtime.js").sendMessageSlack;
function toSlackOutboundResult<T extends { channelId?: string }>(result: T) {
const { channelId, ...delivery } = result;
return channelId === undefined
? delivery
: { ...delivery, target: { kind: "channel" as const, id: channelId } };
}
type SlackOutboundChannelData = Record<string, unknown> & {
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<ReturnType<SlackSendFn>> | 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<ReturnType<SlackSendFn>> | 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;
}
@@ -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]: [],
+2 -2
View File
@@ -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);
+2 -2
View File
@@ -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,
@@ -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: [],
@@ -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],
+1 -1
View File
@@ -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,
};
}
@@ -64,7 +64,7 @@ describe("telegramOutbound presentation", () => {
expect(result).toEqual({
channel: "telegram",
messageId: "tg-presentation-buttons",
chatId: "12345",
target: { kind: "chat", id: "12345" },
});
});
@@ -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" },
});
});
+30 -14
View File
@@ -45,6 +45,13 @@ type TelegramLocationFn = typeof import("./send.js").sendLocationTelegram;
type ResolveTelegramSendFn = (deps?: OutboundSendDeps) => Promise<TelegramSendFn>;
type LoadTelegramSendModuleFn = () => Promise<TelegramSendModule>;
function toTelegramOutboundResult<T extends { chatId?: string }>(result: T) {
const { chatId, ...delivery } = result;
return chatId === undefined
? delivery
: { ...delivery, target: { kind: "chat" as const, id: chatId } };
}
async function resolveDefaultTelegramSend(deps?: OutboundSendDeps): Promise<TelegramSendFn> {
return (
resolveOutboundSendDep<TelegramSendFn>(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,
@@ -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 },
},
],
+2
View File
@@ -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 } : {}),
+1
View File
@@ -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 ||
+5
View File
@@ -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<TConfig = OpenClawConfig> = Omit<
export type ChannelMessageSendResult = {
receipt: MessageReceipt;
messageId?: string;
target?: MessageReceiptSourceResult["target"];
};
/** Discriminator for lifecycle hooks around a concrete adapter send attempt. */
+1 -1
View File
@@ -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",
},
},
+3
View File
@@ -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",
},
+12
View File
@@ -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",
+3
View File
@@ -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),
+5
View File
@@ -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",
@@ -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",
+5 -21
View File
@@ -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 = {
@@ -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",
},
+11 -30
View File
@@ -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,
);
}
+1 -9
View File
@@ -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 {
+1 -4
View File
@@ -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,
+4 -4
View File
@@ -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;
+1 -1
View File
@@ -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 {
+1 -1
View File
@@ -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 }];
+4 -4
View File
@@ -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)",
},
+3 -15
View File
@@ -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<string, unknown>;
@@ -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;
}
+10 -3
View File
@@ -251,7 +251,7 @@ function setDemoPollRegistry(outboundOptions: Parameters<typeof createDemoAliasO
describe("sendPoll channel normalization", () => {
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,
+19 -17
View File
@@ -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<OutboundDeliveryResult, "messageId" | "target" | "toJid" | "pollId">;
dryRun?: boolean;
};
function normalizeMessagePollDeliveryResult(
result: ChannelPollResult,
): NonNullable<MessagePollResult["result"]> {
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<MessagePollRe
to: params.to,
normalized,
via: "direct",
result,
result: normalizeMessagePollDeliveryResult(result),
});
}
const result = await callMessageGateway<{
messageId: string;
toJid?: string;
channelId?: string;
conversationId?: string;
pollId?: string;
}>({
const result = await callMessageGateway<ChannelPollResult>({
gateway: params.gateway,
method: "poll",
params: {
@@ -600,6 +602,6 @@ export async function sendPoll(params: MessagePollParams): Promise<MessagePollRe
to: params.to,
normalized,
via: "gateway",
result,
result: normalizeMessagePollDeliveryResult(result),
});
}
+2 -7
View File
@@ -317,13 +317,8 @@ function resolveResultIdentifiers(
} {
const last = results.at(-1);
const conversationId =
firstIdentifier(
last?.conversationId,
last?.chatId,
last?.channelId,
last?.roomId,
last?.toJid,
) ?? resolveOutboundTargetFacts(context).conversationId;
firstIdentifier(last?.target?.id, last?.toJid) ??
resolveOutboundTargetFacts(context).conversationId;
const messageId = firstIdentifier(
last?.messageId,
last?.receipt?.primaryPlatformMessageId,
@@ -85,12 +85,15 @@ describe("resolveSystemRunExecArgv", () => {
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",
},
+7 -4
View File
@@ -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" },
},
},
{