mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-24 11:25:50 -06:00
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:
committed by
GitHub
parent
54cbce25bd
commit
f9b1ccc4fa
@@ -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" },
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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
@@ -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);
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1739,7 +1739,7 @@ describe("mattermostPlugin", () => {
|
||||
expect(onDeliveryResult).toHaveBeenCalledWith({
|
||||
channel: "mattermost",
|
||||
messageId: "post-final",
|
||||
channelId: "CHAN1",
|
||||
target: { kind: "channel", id: "CHAN1" },
|
||||
content: "provider-final",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
};
|
||||
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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]: [],
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }];
|
||||
|
||||
|
||||
@@ -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)",
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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" },
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user