Compare commits
50 Commits
main
...
vincentkoc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bec437faf8 | ||
|
|
81c8e66f61 | ||
|
|
3bfd093cdb | ||
|
|
47e0bf522f | ||
|
|
749f3e7baa | ||
|
|
4572ddfe2c | ||
|
|
66df5f2bcb | ||
|
|
9b7a943eda | ||
|
|
fbe80bdd46 | ||
|
|
53e4359249 | ||
|
|
daecbeeaba | ||
|
|
bd74ce74aa | ||
|
|
e9a8d840a6 | ||
|
|
78f0e35529 | ||
|
|
a1e78fd52f | ||
|
|
86c26bd171 | ||
|
|
fd60b0fc85 | ||
|
|
2ff309db4d | ||
|
|
6f6edbe770 | ||
|
|
99a251e7ca | ||
|
|
3b6652dcd8 | ||
|
|
ee89ffd264 | ||
|
|
8880bc32fa | ||
|
|
7648fe6f8a | ||
|
|
488f1b0ed3 | ||
|
|
4db39a74f1 | ||
|
|
fbc4217443 | ||
|
|
1a0313bb1f | ||
|
|
68a809298d | ||
|
|
8fd4511df5 | ||
|
|
5d4dbf1c7d | ||
|
|
a079c190f9 | ||
|
|
23b68d0349 | ||
|
|
f56aa4dee7 | ||
|
|
3218efcfd8 | ||
|
|
c9a07282e4 | ||
|
|
1c562bf211 | ||
|
|
86befdd2b3 | ||
|
|
2c50e199b7 | ||
|
|
103f92c3ed | ||
|
|
b1ac4e1d8e | ||
|
|
bb34721175 | ||
|
|
96e6ba3046 | ||
|
|
60648a51b3 | ||
|
|
07eae3da90 | ||
|
|
7158406298 | ||
|
|
c2f8549bce | ||
|
|
93e25774da | ||
|
|
8795a5fee6 | ||
|
|
70e4931739 |
@ -38,6 +38,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Models/OpenRouter runtime capabilities: fetch uncatalogued OpenRouter model metadata on first use so newly added vision models keep image input instead of silently degrading to text-only, with top-level capability field fallbacks for `/api/v1/models`. (#45824) Thanks @DJjjjhao.
|
||||
- Z.AI/onboarding: add `glm-5-turbo` to the default Z.AI provider catalog so onboarding-generated configs expose the new model alongside the existing GLM defaults. (#46670) Thanks @tomsun28.
|
||||
- Zalo Personal/group gating: stop reapplying `dmPolicy.allowFrom` as a sender gate for already-allowlisted groups when `groupAllowFrom` is unset, so any member of an allowed group can trigger replies while DMs stay restricted. (#40146)
|
||||
- Channels/plugins: keep shared interactive payloads merge-ready by fixing Slack custom callback routing and repeat-click dedupe, allowing interactive-only sends, and preserving ordered Discord shared text blocks. (#47715) Thanks @vincentkoc.
|
||||
- Browser/remote CDP: honor strict browser SSRF policy during remote CDP reachability and `/json/version` discovery checks, redact sensitive `cdpUrl` tokens from status output, and warn when remote CDP targets private/internal hosts.
|
||||
- Plugins/install precedence: keep bundled plugins ahead of auto-discovered globals by default, but let an explicitly installed plugin record win its own duplicate-id tie so installed channel plugins load from `~/.openclaw/extensions` after `openclaw plugins install`.
|
||||
- ACP/acpx: resolve the bundled plugin root from the actual plugin directory so plugin-local installs stay under `dist/extensions/acpx` instead of escaping to `dist/extensions` and failing runtime setup.
|
||||
|
||||
@ -8,7 +8,9 @@ import { readDiscordParentIdParam } from "../../../../src/agents/tools/discord-a
|
||||
import { handleDiscordAction } from "../../../../src/agents/tools/discord-actions.js";
|
||||
import { resolveReactionMessageId } from "../../../../src/channels/plugins/actions/reaction-message-id.js";
|
||||
import type { ChannelMessageActionContext } from "../../../../src/channels/plugins/types.js";
|
||||
import { normalizeInteractiveReply } from "../../../../src/interactive/payload.js";
|
||||
import { readBooleanParam } from "../../../../src/plugin-sdk/boolean-param.js";
|
||||
import { buildDiscordInteractiveComponents } from "../components.js";
|
||||
import { resolveDiscordChannelId } from "../targets.js";
|
||||
import { tryHandleDiscordMessageActionGuildAdmin } from "./handle-action.guild-admin.js";
|
||||
|
||||
@ -40,7 +42,9 @@ export async function handleDiscordMessageAction(
|
||||
if (action === "send") {
|
||||
const to = readStringParam(params, "to", { required: true });
|
||||
const asVoice = readBooleanParam(params, "asVoice") === true;
|
||||
const rawComponents = params.components;
|
||||
const rawComponents =
|
||||
params.components ??
|
||||
buildDiscordInteractiveComponents(normalizeInteractiveReply(params.interactive));
|
||||
const hasComponents =
|
||||
Boolean(rawComponents) &&
|
||||
(typeof rawComponents === "function" || typeof rawComponents === "object");
|
||||
|
||||
@ -106,6 +106,10 @@ export const discordMessageActions: ChannelMessageActionAdapter = {
|
||||
}
|
||||
return Array.from(actions);
|
||||
},
|
||||
getCapabilities: ({ cfg }) =>
|
||||
listTokenSourcedAccounts(listEnabledDiscordAccounts(cfg)).length > 0
|
||||
? (["interactive", "components"] as const)
|
||||
: [],
|
||||
extractToolSend: ({ args }) => {
|
||||
const action = typeof args.action === "string" ? args.action.trim() : "";
|
||||
if (action === "sendMessage") {
|
||||
|
||||
@ -25,6 +25,8 @@ import {
|
||||
type TopLevelComponents,
|
||||
} from "@buape/carbon";
|
||||
import { ButtonStyle, MessageFlags, TextInputStyle } from "discord-api-types/v10";
|
||||
import { reduceInteractiveReply } from "../../../src/channels/plugins/outbound/interactive.js";
|
||||
import type { InteractiveButtonStyle, InteractiveReply } from "../../../src/interactive/payload.js";
|
||||
|
||||
export const DISCORD_COMPONENT_CUSTOM_ID_KEY = "occomp";
|
||||
export const DISCORD_MODAL_CUSTOM_ID_KEY = "ocmodal";
|
||||
@ -212,6 +214,69 @@ export type DiscordComponentBuildResult = {
|
||||
modals: DiscordModalEntry[];
|
||||
};
|
||||
|
||||
function resolveDiscordInteractiveButtonStyle(
|
||||
style?: InteractiveButtonStyle,
|
||||
): DiscordComponentButtonStyle | undefined {
|
||||
return style ?? "secondary";
|
||||
}
|
||||
|
||||
const DISCORD_INTERACTIVE_BUTTON_ROW_SIZE = 5;
|
||||
|
||||
export function buildDiscordInteractiveComponents(
|
||||
interactive?: InteractiveReply,
|
||||
): DiscordComponentMessageSpec | undefined {
|
||||
const blocks = reduceInteractiveReply(
|
||||
interactive,
|
||||
[] as NonNullable<DiscordComponentMessageSpec["blocks"]>,
|
||||
(state, block) => {
|
||||
if (block.type === "text") {
|
||||
const text = block.text.trim();
|
||||
if (text) {
|
||||
state.push({ type: "text", text });
|
||||
}
|
||||
return state;
|
||||
}
|
||||
if (block.type === "buttons") {
|
||||
if (block.buttons.length === 0) {
|
||||
return state;
|
||||
}
|
||||
for (
|
||||
let index = 0;
|
||||
index < block.buttons.length;
|
||||
index += DISCORD_INTERACTIVE_BUTTON_ROW_SIZE
|
||||
) {
|
||||
state.push({
|
||||
type: "actions",
|
||||
buttons: block.buttons
|
||||
.slice(index, index + DISCORD_INTERACTIVE_BUTTON_ROW_SIZE)
|
||||
.map((button) => ({
|
||||
label: button.label,
|
||||
style: resolveDiscordInteractiveButtonStyle(button.style),
|
||||
callbackData: button.value,
|
||||
})),
|
||||
});
|
||||
}
|
||||
return state;
|
||||
}
|
||||
if (block.type === "select" && block.options.length > 0) {
|
||||
state.push({
|
||||
type: "actions",
|
||||
select: {
|
||||
type: "string",
|
||||
placeholder: block.placeholder,
|
||||
options: block.options.map((option) => ({
|
||||
label: option.label,
|
||||
value: option.value,
|
||||
})),
|
||||
},
|
||||
});
|
||||
}
|
||||
return state;
|
||||
},
|
||||
);
|
||||
return blocks.length > 0 ? { blocks } : undefined;
|
||||
}
|
||||
|
||||
const BLOCK_ALIASES = new Map<string, DiscordComponentBlock["type"]>([
|
||||
["row", "actions"],
|
||||
["action-row", "actions"],
|
||||
|
||||
@ -0,0 +1,88 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
sendDiscordComponentMessageMock: vi.fn(),
|
||||
sendMessageDiscordMock: vi.fn(),
|
||||
sendPollDiscordMock: vi.fn(),
|
||||
sendWebhookMessageDiscordMock: vi.fn(),
|
||||
getThreadBindingManagerMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./send.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./send.js")>();
|
||||
return {
|
||||
...actual,
|
||||
sendDiscordComponentMessage: (...args: unknown[]) =>
|
||||
hoisted.sendDiscordComponentMessageMock(...args),
|
||||
sendMessageDiscord: (...args: unknown[]) => hoisted.sendMessageDiscordMock(...args),
|
||||
sendPollDiscord: (...args: unknown[]) => hoisted.sendPollDiscordMock(...args),
|
||||
sendWebhookMessageDiscord: (...args: unknown[]) =>
|
||||
hoisted.sendWebhookMessageDiscordMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./monitor/thread-bindings.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./monitor/thread-bindings.js")>();
|
||||
return {
|
||||
...actual,
|
||||
getThreadBindingManager: (...args: unknown[]) => hoisted.getThreadBindingManagerMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
const { discordOutbound } = await import("./outbound-adapter.js");
|
||||
|
||||
describe("discordOutbound shared interactive ordering", () => {
|
||||
beforeEach(() => {
|
||||
hoisted.sendDiscordComponentMessageMock.mockReset().mockResolvedValue({
|
||||
messageId: "msg-1",
|
||||
channelId: "123456",
|
||||
});
|
||||
hoisted.sendMessageDiscordMock.mockReset();
|
||||
hoisted.sendPollDiscordMock.mockReset();
|
||||
hoisted.sendWebhookMessageDiscordMock.mockReset();
|
||||
hoisted.getThreadBindingManagerMock.mockReset().mockReturnValue(null);
|
||||
});
|
||||
|
||||
it("keeps shared text blocks in authored order without hoisting fallback text", async () => {
|
||||
const result = await discordOutbound.sendPayload!({
|
||||
cfg: {},
|
||||
to: "channel:123456",
|
||||
text: "",
|
||||
payload: {
|
||||
interactive: {
|
||||
blocks: [
|
||||
{ type: "text", text: "First" },
|
||||
{
|
||||
type: "buttons",
|
||||
buttons: [{ label: "Approve", value: "approve" }],
|
||||
},
|
||||
{ type: "text", text: "Last" },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(hoisted.sendDiscordComponentMessageMock).toHaveBeenCalledWith(
|
||||
"channel:123456",
|
||||
{
|
||||
blocks: [
|
||||
{ type: "text", text: "First" },
|
||||
{
|
||||
type: "actions",
|
||||
buttons: [{ label: "Approve", style: "secondary", callbackData: "approve" }],
|
||||
},
|
||||
{ type: "text", text: "Last" },
|
||||
],
|
||||
},
|
||||
expect.objectContaining({
|
||||
cfg: {},
|
||||
}),
|
||||
);
|
||||
expect(hoisted.sendMessageDiscordMock).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
channel: "discord",
|
||||
messageId: "msg-1",
|
||||
channelId: "123456",
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,11 +1,22 @@
|
||||
import { sendTextMediaPayload } from "../../../src/channels/plugins/outbound/direct-text-media.js";
|
||||
import {
|
||||
resolvePayloadMediaUrls,
|
||||
sendPayloadMediaSequence,
|
||||
sendTextMediaPayload,
|
||||
} from "../../../src/channels/plugins/outbound/direct-text-media.js";
|
||||
import type { ChannelOutboundAdapter } from "../../../src/channels/plugins/types.js";
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
import type { OutboundIdentity } from "../../../src/infra/outbound/identity.js";
|
||||
import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js";
|
||||
import type { DiscordComponentMessageSpec } from "./components.js";
|
||||
import { buildDiscordInteractiveComponents } from "./components.js";
|
||||
import { getThreadBindingManager, type ThreadBindingRecord } from "./monitor/thread-bindings.js";
|
||||
import { normalizeDiscordOutboundTarget } from "./normalize.js";
|
||||
import { sendMessageDiscord, sendPollDiscord, sendWebhookMessageDiscord } from "./send.js";
|
||||
import {
|
||||
sendDiscordComponentMessage,
|
||||
sendMessageDiscord,
|
||||
sendPollDiscord,
|
||||
sendWebhookMessageDiscord,
|
||||
} from "./send.js";
|
||||
|
||||
function resolveDiscordOutboundTarget(params: {
|
||||
to: string;
|
||||
@ -78,8 +89,76 @@ export const discordOutbound: ChannelOutboundAdapter = {
|
||||
textChunkLimit: 2000,
|
||||
pollMaxOptions: 10,
|
||||
resolveTarget: ({ to }) => normalizeDiscordOutboundTarget(to),
|
||||
sendPayload: async (ctx) =>
|
||||
await sendTextMediaPayload({ channel: "discord", ctx, adapter: discordOutbound }),
|
||||
sendPayload: async (ctx) => {
|
||||
const payload = {
|
||||
...ctx.payload,
|
||||
text: ctx.payload.text ?? "",
|
||||
};
|
||||
const discordData = payload.channelData?.discord as
|
||||
| { components?: DiscordComponentMessageSpec }
|
||||
| undefined;
|
||||
const rawComponentSpec =
|
||||
discordData?.components ?? buildDiscordInteractiveComponents(payload.interactive);
|
||||
const componentSpec = rawComponentSpec
|
||||
? rawComponentSpec.text
|
||||
? rawComponentSpec
|
||||
: {
|
||||
...rawComponentSpec,
|
||||
text: payload.text?.trim() ? payload.text : undefined,
|
||||
}
|
||||
: undefined;
|
||||
if (!componentSpec) {
|
||||
return await sendTextMediaPayload({
|
||||
channel: "discord",
|
||||
ctx: {
|
||||
...ctx,
|
||||
payload,
|
||||
},
|
||||
adapter: discordOutbound,
|
||||
});
|
||||
}
|
||||
const send =
|
||||
resolveOutboundSendDep<typeof sendMessageDiscord>(ctx.deps, "discord") ?? sendMessageDiscord;
|
||||
const target = resolveDiscordOutboundTarget({ to: ctx.to, threadId: ctx.threadId });
|
||||
const mediaUrls = resolvePayloadMediaUrls(payload);
|
||||
if (mediaUrls.length === 0) {
|
||||
const result = await sendDiscordComponentMessage(target, componentSpec, {
|
||||
replyTo: ctx.replyToId ?? undefined,
|
||||
accountId: ctx.accountId ?? undefined,
|
||||
silent: ctx.silent ?? undefined,
|
||||
cfg: ctx.cfg,
|
||||
});
|
||||
return { channel: "discord", ...result };
|
||||
}
|
||||
const lastResult = await sendPayloadMediaSequence({
|
||||
text: payload.text ?? "",
|
||||
mediaUrls,
|
||||
send: async ({ text, mediaUrl, isFirst }) => {
|
||||
if (isFirst) {
|
||||
return await sendDiscordComponentMessage(target, componentSpec, {
|
||||
mediaUrl,
|
||||
mediaLocalRoots: ctx.mediaLocalRoots,
|
||||
replyTo: ctx.replyToId ?? undefined,
|
||||
accountId: ctx.accountId ?? undefined,
|
||||
silent: ctx.silent ?? undefined,
|
||||
cfg: ctx.cfg,
|
||||
});
|
||||
}
|
||||
return await send(target, text, {
|
||||
verbose: false,
|
||||
mediaUrl,
|
||||
mediaLocalRoots: ctx.mediaLocalRoots,
|
||||
replyTo: ctx.replyToId ?? undefined,
|
||||
accountId: ctx.accountId ?? undefined,
|
||||
silent: ctx.silent ?? undefined,
|
||||
cfg: ctx.cfg,
|
||||
});
|
||||
},
|
||||
});
|
||||
return lastResult
|
||||
? { channel: "discord", ...lastResult }
|
||||
: { channel: "discord", messageId: "" };
|
||||
},
|
||||
sendText: async ({ cfg, to, text, accountId, deps, replyToId, threadId, identity, silent }) => {
|
||||
if (!silent) {
|
||||
const webhookResult = await maybeSendDiscordWebhookText({
|
||||
|
||||
104
extensions/discord/src/shared-interactive.test.ts
Normal file
104
extensions/discord/src/shared-interactive.test.ts
Normal file
@ -0,0 +1,104 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildDiscordInteractiveComponents } from "./components.js";
|
||||
|
||||
describe("buildDiscordInteractiveComponents", () => {
|
||||
it("maps shared buttons and selects into Discord component blocks", () => {
|
||||
expect(
|
||||
buildDiscordInteractiveComponents({
|
||||
blocks: [
|
||||
{
|
||||
type: "buttons",
|
||||
buttons: [
|
||||
{ label: "Approve", value: "approve", style: "success" },
|
||||
{ label: "Reject", value: "reject", style: "danger" },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "select",
|
||||
placeholder: "Pick one",
|
||||
options: [{ label: "Alpha", value: "alpha" }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toEqual({
|
||||
blocks: [
|
||||
{
|
||||
type: "actions",
|
||||
buttons: [
|
||||
{ label: "Approve", style: "success", callbackData: "approve" },
|
||||
{ label: "Reject", style: "danger", callbackData: "reject" },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "actions",
|
||||
select: {
|
||||
type: "string",
|
||||
placeholder: "Pick one",
|
||||
options: [{ label: "Alpha", value: "alpha" }],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves authored shared text blocks around controls", () => {
|
||||
expect(
|
||||
buildDiscordInteractiveComponents({
|
||||
blocks: [
|
||||
{ type: "text", text: "First" },
|
||||
{
|
||||
type: "buttons",
|
||||
buttons: [{ label: "Approve", value: "approve", style: "success" }],
|
||||
},
|
||||
{ type: "text", text: "Last" },
|
||||
],
|
||||
}),
|
||||
).toEqual({
|
||||
blocks: [
|
||||
{ type: "text", text: "First" },
|
||||
{
|
||||
type: "actions",
|
||||
buttons: [{ label: "Approve", style: "success", callbackData: "approve" }],
|
||||
},
|
||||
{ type: "text", text: "Last" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("splits long shared button rows to stay within Discord action limits", () => {
|
||||
expect(
|
||||
buildDiscordInteractiveComponents({
|
||||
blocks: [
|
||||
{
|
||||
type: "buttons",
|
||||
buttons: [
|
||||
{ label: "One", value: "1" },
|
||||
{ label: "Two", value: "2" },
|
||||
{ label: "Three", value: "3" },
|
||||
{ label: "Four", value: "4" },
|
||||
{ label: "Five", value: "5" },
|
||||
{ label: "Six", value: "6" },
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toEqual({
|
||||
blocks: [
|
||||
{
|
||||
type: "actions",
|
||||
buttons: [
|
||||
{ label: "One", style: "secondary", callbackData: "1" },
|
||||
{ label: "Two", style: "secondary", callbackData: "2" },
|
||||
{ label: "Three", style: "secondary", callbackData: "3" },
|
||||
{ label: "Four", style: "secondary", callbackData: "4" },
|
||||
{ label: "Five", style: "secondary", callbackData: "5" },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "actions",
|
||||
buttons: [{ label: "Six", style: "secondary", callbackData: "6" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -219,11 +219,11 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
|
||||
}
|
||||
return Array.from(actions);
|
||||
},
|
||||
supportsCards: ({ cfg }) => {
|
||||
return (
|
||||
cfg.channels?.feishu?.enabled !== false &&
|
||||
getCapabilities: ({ cfg }) => {
|
||||
return cfg.channels?.feishu?.enabled !== false &&
|
||||
Boolean(resolveFeishuCredentials(cfg.channels?.feishu as FeishuConfig | undefined))
|
||||
);
|
||||
? (["cards"] as const)
|
||||
: [];
|
||||
},
|
||||
handleAction: async (ctx) => {
|
||||
const account = resolveFeishuAccount({ cfg: ctx.cfg, accountId: ctx.accountId ?? undefined });
|
||||
|
||||
@ -288,10 +288,27 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
|
||||
chunker: (text, limit) => getMatrixRuntime().channel.text.chunkMarkdownText!(text, limit),
|
||||
chunkerMode: "markdown",
|
||||
textChunkLimit: 4000,
|
||||
sendText: async (params) => (await loadMatrixChannelRuntime()).matrixOutbound.sendText!(params),
|
||||
sendMedia: async (params) =>
|
||||
(await loadMatrixChannelRuntime()).matrixOutbound.sendMedia!(params),
|
||||
sendPoll: async (params) => (await loadMatrixChannelRuntime()).matrixOutbound.sendPoll!(params),
|
||||
sendText: async (params) => {
|
||||
const outbound = (await loadMatrixChannelRuntime()).matrixOutbound;
|
||||
if (!outbound.sendText) {
|
||||
throw new Error("Matrix outbound text delivery is unavailable");
|
||||
}
|
||||
return await outbound.sendText(params);
|
||||
},
|
||||
sendMedia: async (params) => {
|
||||
const outbound = (await loadMatrixChannelRuntime()).matrixOutbound;
|
||||
if (!outbound.sendMedia) {
|
||||
throw new Error("Matrix outbound media delivery is unavailable");
|
||||
}
|
||||
return await outbound.sendMedia(params);
|
||||
},
|
||||
sendPoll: async (params) => {
|
||||
const outbound = (await loadMatrixChannelRuntime()).matrixOutbound;
|
||||
if (!outbound.sendPoll) {
|
||||
throw new Error("Matrix outbound poll delivery is unavailable");
|
||||
}
|
||||
return await outbound.sendPoll(params);
|
||||
},
|
||||
},
|
||||
status: {
|
||||
defaultRuntime: {
|
||||
|
||||
@ -71,11 +71,11 @@ const mattermostMessageActions: ChannelMessageActionAdapter = {
|
||||
supportsAction: ({ action }) => {
|
||||
return action === "send" || action === "react";
|
||||
},
|
||||
supportsButtons: ({ cfg }) => {
|
||||
getCapabilities: ({ cfg }) => {
|
||||
const accounts = listMattermostAccountIds(cfg)
|
||||
.map((id) => resolveMattermostAccount({ cfg, accountId: id }))
|
||||
.filter((a) => a.enabled && a.botToken?.trim() && a.baseUrl?.trim());
|
||||
return accounts.length > 0;
|
||||
return accounts.length > 0 ? (["buttons"] as const) : [];
|
||||
},
|
||||
handleAction: async ({ action, params, cfg, accountId }) => {
|
||||
if (action === "react") {
|
||||
|
||||
@ -366,11 +366,11 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
|
||||
}
|
||||
return ["poll"] satisfies ChannelMessageActionName[];
|
||||
},
|
||||
supportsCards: ({ cfg }) => {
|
||||
return (
|
||||
cfg.channels?.msteams?.enabled !== false &&
|
||||
getCapabilities: ({ cfg }) => {
|
||||
return cfg.channels?.msteams?.enabled !== false &&
|
||||
Boolean(resolveMSTeamsCredentials(cfg.channels?.msteams))
|
||||
);
|
||||
? (["cards"] as const)
|
||||
: [];
|
||||
},
|
||||
handleAction: async (ctx) => {
|
||||
// Handle send action with card parameter
|
||||
|
||||
85
extensions/slack/src/blocks-render.ts
Normal file
85
extensions/slack/src/blocks-render.ts
Normal file
@ -0,0 +1,85 @@
|
||||
import type { Block, KnownBlock } from "@slack/web-api";
|
||||
import { reduceInteractiveReply } from "../../../src/channels/plugins/outbound/interactive.js";
|
||||
import type { InteractiveReply } from "../../../src/interactive/payload.js";
|
||||
import { truncateSlackText } from "./truncate.js";
|
||||
|
||||
export const SLACK_REPLY_BUTTON_ACTION_ID = "openclaw:reply_button";
|
||||
export const SLACK_REPLY_SELECT_ACTION_ID = "openclaw:reply_select";
|
||||
const SLACK_SECTION_TEXT_MAX = 3000;
|
||||
const SLACK_PLAIN_TEXT_MAX = 75;
|
||||
|
||||
export type SlackBlock = Block | KnownBlock;
|
||||
|
||||
export function buildSlackInteractiveBlocks(interactive?: InteractiveReply): SlackBlock[] {
|
||||
const initialState = {
|
||||
blocks: [] as SlackBlock[],
|
||||
buttonIndex: 0,
|
||||
selectIndex: 0,
|
||||
};
|
||||
return reduceInteractiveReply(interactive, initialState, (state, block) => {
|
||||
if (block.type === "text") {
|
||||
const trimmed = block.text.trim();
|
||||
if (!trimmed) {
|
||||
return state;
|
||||
}
|
||||
state.blocks.push({
|
||||
type: "section",
|
||||
text: {
|
||||
type: "mrkdwn",
|
||||
text: truncateSlackText(trimmed, SLACK_SECTION_TEXT_MAX),
|
||||
},
|
||||
});
|
||||
return state;
|
||||
}
|
||||
if (block.type === "buttons") {
|
||||
if (block.buttons.length === 0) {
|
||||
return state;
|
||||
}
|
||||
state.blocks.push({
|
||||
type: "actions",
|
||||
block_id: `openclaw_reply_buttons_${++state.buttonIndex}`,
|
||||
elements: block.buttons.map((button, choiceIndex) => ({
|
||||
type: "button",
|
||||
action_id: SLACK_REPLY_BUTTON_ACTION_ID,
|
||||
text: {
|
||||
type: "plain_text",
|
||||
text: truncateSlackText(button.label, SLACK_PLAIN_TEXT_MAX),
|
||||
emoji: true,
|
||||
},
|
||||
value: button.value,
|
||||
})),
|
||||
});
|
||||
return state;
|
||||
}
|
||||
if (block.options.length === 0) {
|
||||
return state;
|
||||
}
|
||||
state.blocks.push({
|
||||
type: "actions",
|
||||
block_id: `openclaw_reply_select_${++state.selectIndex}`,
|
||||
elements: [
|
||||
{
|
||||
type: "static_select",
|
||||
action_id: SLACK_REPLY_SELECT_ACTION_ID,
|
||||
placeholder: {
|
||||
type: "plain_text",
|
||||
text: truncateSlackText(
|
||||
block.placeholder?.trim() || "Choose an option",
|
||||
SLACK_PLAIN_TEXT_MAX,
|
||||
),
|
||||
emoji: true,
|
||||
},
|
||||
options: block.options.map((option, choiceIndex) => ({
|
||||
text: {
|
||||
type: "plain_text",
|
||||
text: truncateSlackText(option.label, SLACK_PLAIN_TEXT_MAX),
|
||||
emoji: true,
|
||||
},
|
||||
value: option.value,
|
||||
})),
|
||||
},
|
||||
],
|
||||
});
|
||||
return state;
|
||||
}).blocks;
|
||||
}
|
||||
@ -292,6 +292,16 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
|
||||
},
|
||||
actions: {
|
||||
listActions: ({ cfg }) => listSlackMessageActions(cfg),
|
||||
getCapabilities: ({ cfg }) => {
|
||||
const capabilities = new Set<"interactive" | "blocks">();
|
||||
if (listSlackMessageActions(cfg).includes("send")) {
|
||||
capabilities.add("blocks");
|
||||
}
|
||||
if (isSlackInteractiveRepliesEnabled({ cfg })) {
|
||||
capabilities.add("interactive");
|
||||
}
|
||||
return Array.from(capabilities);
|
||||
},
|
||||
extractToolSend: ({ args }) => extractSlackToolSend(args),
|
||||
handleAction: async (ctx) =>
|
||||
await handleSlackMessageAction({
|
||||
|
||||
@ -1,12 +1,40 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { registerSlackInteractionEvents } from "./interactions.js";
|
||||
|
||||
const enqueueSystemEventMock = vi.fn();
|
||||
const dispatchPluginInteractiveHandlerMock = vi.fn(async () => ({
|
||||
matched: false,
|
||||
handled: false,
|
||||
duplicate: false,
|
||||
}));
|
||||
const resolvePluginConversationBindingApprovalMock = vi.fn();
|
||||
const buildPluginBindingResolvedTextMock = vi.fn(() => "Binding updated.");
|
||||
|
||||
vi.mock("../../../../../src/infra/system-events.js", () => ({
|
||||
enqueueSystemEvent: (...args: unknown[]) => enqueueSystemEventMock(...args),
|
||||
enqueueSystemEvent: (...args: unknown[]) =>
|
||||
(enqueueSystemEventMock as (...innerArgs: unknown[]) => unknown)(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../../../../src/plugins/interactive.js", () => ({
|
||||
dispatchPluginInteractiveHandler: (...args: unknown[]) =>
|
||||
(dispatchPluginInteractiveHandlerMock as (...innerArgs: unknown[]) => unknown)(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../../../../src/plugins/conversation-binding.js", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("../../../../../src/plugins/conversation-binding.js")
|
||||
>("../../../../../src/plugins/conversation-binding.js");
|
||||
return {
|
||||
...actual,
|
||||
resolvePluginConversationBindingApproval: (...args: unknown[]) =>
|
||||
(resolvePluginConversationBindingApprovalMock as (...innerArgs: unknown[]) => unknown)(
|
||||
...args,
|
||||
),
|
||||
buildPluginBindingResolvedText: (...args: unknown[]) =>
|
||||
(buildPluginBindingResolvedTextMock as (...innerArgs: unknown[]) => unknown)(...args),
|
||||
};
|
||||
});
|
||||
|
||||
type RegisteredHandler = (args: {
|
||||
ack: () => Promise<void>;
|
||||
body: {
|
||||
@ -78,10 +106,12 @@ function createContext(overrides?: {
|
||||
}>;
|
||||
}) {
|
||||
let handler: RegisteredHandler | null = null;
|
||||
let actionMatcher: RegExp | null = null;
|
||||
let viewHandler: RegisteredViewHandler | null = null;
|
||||
let viewClosedHandler: RegisteredViewClosedHandler | null = null;
|
||||
const app = {
|
||||
action: vi.fn((_matcher: RegExp, next: RegisteredHandler) => {
|
||||
action: vi.fn((matcher: RegExp, next: RegisteredHandler) => {
|
||||
actionMatcher = matcher;
|
||||
handler = next;
|
||||
}),
|
||||
view: vi.fn((_matcher: RegExp, next: RegisteredViewHandler) => {
|
||||
@ -122,6 +152,7 @@ function createContext(overrides?: {
|
||||
);
|
||||
const ctx = {
|
||||
app,
|
||||
accountId: "default",
|
||||
runtime: { log: runtimeLog },
|
||||
dmEnabled: overrides?.dmEnabled ?? true,
|
||||
dmPolicy: overrides?.dmPolicy ?? ("open" as const),
|
||||
@ -144,6 +175,7 @@ function createContext(overrides?: {
|
||||
isChannelAllowed,
|
||||
resolveUserName,
|
||||
resolveChannelName,
|
||||
getActionMatcher: () => actionMatcher,
|
||||
getHandler: () => handler,
|
||||
getViewHandler: () => viewHandler,
|
||||
getViewClosedHandler: () => viewClosedHandler,
|
||||
@ -151,8 +183,21 @@ function createContext(overrides?: {
|
||||
}
|
||||
|
||||
describe("registerSlackInteractionEvents", () => {
|
||||
it("enqueues structured events and updates button rows", async () => {
|
||||
beforeEach(() => {
|
||||
enqueueSystemEventMock.mockClear();
|
||||
dispatchPluginInteractiveHandlerMock.mockClear();
|
||||
resolvePluginConversationBindingApprovalMock.mockClear();
|
||||
resolvePluginConversationBindingApprovalMock.mockResolvedValue({ status: "expired" });
|
||||
buildPluginBindingResolvedTextMock.mockClear();
|
||||
buildPluginBindingResolvedTextMock.mockReturnValue("Binding updated.");
|
||||
dispatchPluginInteractiveHandlerMock.mockResolvedValue({
|
||||
matched: false,
|
||||
handled: false,
|
||||
duplicate: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("enqueues structured events and updates button rows", async () => {
|
||||
const { ctx, app, getHandler, resolveSessionKey } = createContext();
|
||||
registerSlackInteractionEvents({ ctx: ctx as never });
|
||||
|
||||
@ -228,6 +273,236 @@ describe("registerSlackInteractionEvents", () => {
|
||||
expect(app.client.chat.update).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("registers a matcher that accepts plugin action ids beyond the OpenClaw prefix", () => {
|
||||
const { ctx, getActionMatcher } = createContext();
|
||||
registerSlackInteractionEvents({ ctx: ctx as never });
|
||||
|
||||
const matcher = getActionMatcher();
|
||||
expect(matcher).toBeTruthy();
|
||||
expect(matcher?.test("openclaw:verify")).toBe(true);
|
||||
expect(matcher?.test("codex")).toBe(true);
|
||||
});
|
||||
|
||||
it("routes matching Slack actions through the shared plugin interactive dispatcher", async () => {
|
||||
dispatchPluginInteractiveHandlerMock.mockResolvedValueOnce({
|
||||
matched: true,
|
||||
handled: true,
|
||||
duplicate: false,
|
||||
});
|
||||
const { ctx, app, getHandler } = createContext();
|
||||
registerSlackInteractionEvents({ ctx: ctx as never });
|
||||
|
||||
const handler = getHandler();
|
||||
expect(handler).toBeTruthy();
|
||||
|
||||
const ack = vi.fn().mockResolvedValue(undefined);
|
||||
const respond = vi.fn().mockResolvedValue(undefined);
|
||||
await handler!({
|
||||
ack,
|
||||
respond,
|
||||
body: {
|
||||
user: { id: "U123" },
|
||||
team: { id: "T9" },
|
||||
trigger_id: "123.trigger",
|
||||
response_url: "https://hooks.slack.test/response",
|
||||
channel: { id: "C1" },
|
||||
container: { channel_id: "C1", message_ts: "100.200", thread_ts: "100.100" },
|
||||
message: {
|
||||
ts: "100.200",
|
||||
text: "fallback",
|
||||
blocks: [
|
||||
{
|
||||
type: "actions",
|
||||
block_id: "codex_actions",
|
||||
elements: [{ type: "button", action_id: "codex" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
action: {
|
||||
type: "button",
|
||||
action_id: "codex",
|
||||
block_id: "codex_actions",
|
||||
value: "approve:thread-1",
|
||||
text: { type: "plain_text", text: "Approve" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(ack).toHaveBeenCalled();
|
||||
expect(dispatchPluginInteractiveHandlerMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channel: "slack",
|
||||
data: "codex:approve:thread-1",
|
||||
interactionId: "U123:C1:100.200:123.trigger:codex:approve:thread-1",
|
||||
ctx: expect.objectContaining({
|
||||
accountId: ctx.accountId,
|
||||
conversationId: "C1",
|
||||
interactionId: "U123:C1:100.200:123.trigger:codex:approve:thread-1",
|
||||
threadId: "100.100",
|
||||
interaction: expect.objectContaining({
|
||||
actionId: "codex",
|
||||
value: "approve:thread-1",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(enqueueSystemEventMock).not.toHaveBeenCalled();
|
||||
expect(app.client.chat.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses unique interaction ids for repeated Slack actions on the same message", async () => {
|
||||
dispatchPluginInteractiveHandlerMock.mockResolvedValue({
|
||||
matched: true,
|
||||
handled: false,
|
||||
duplicate: false,
|
||||
});
|
||||
const { ctx, getHandler } = createContext();
|
||||
registerSlackInteractionEvents({ ctx: ctx as never });
|
||||
|
||||
const handler = getHandler();
|
||||
expect(handler).toBeTruthy();
|
||||
|
||||
const ack = vi.fn().mockResolvedValue(undefined);
|
||||
await handler!({
|
||||
ack,
|
||||
body: {
|
||||
user: { id: "U123" },
|
||||
channel: { id: "C1" },
|
||||
container: { channel_id: "C1", message_ts: "100.200", thread_ts: "100.100" },
|
||||
trigger_id: "trigger-1",
|
||||
message: {
|
||||
ts: "100.200",
|
||||
text: "fallback",
|
||||
blocks: [
|
||||
{
|
||||
type: "actions",
|
||||
block_id: "codex_actions",
|
||||
elements: [{ type: "button", action_id: "codex" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
action: {
|
||||
type: "button",
|
||||
action_id: "codex",
|
||||
block_id: "codex_actions",
|
||||
value: "approve:thread-1",
|
||||
text: { type: "plain_text", text: "Approve" },
|
||||
},
|
||||
});
|
||||
await handler!({
|
||||
ack,
|
||||
body: {
|
||||
user: { id: "U123" },
|
||||
channel: { id: "C1" },
|
||||
container: { channel_id: "C1", message_ts: "100.200", thread_ts: "100.100" },
|
||||
trigger_id: "trigger-2",
|
||||
message: {
|
||||
ts: "100.200",
|
||||
text: "fallback",
|
||||
blocks: [
|
||||
{
|
||||
type: "actions",
|
||||
block_id: "codex_actions",
|
||||
elements: [{ type: "button", action_id: "codex" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
action: {
|
||||
type: "button",
|
||||
action_id: "codex",
|
||||
block_id: "codex_actions",
|
||||
value: "approve:thread-1",
|
||||
text: { type: "plain_text", text: "Approve" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(dispatchPluginInteractiveHandlerMock).toHaveBeenCalledTimes(2);
|
||||
const calls = dispatchPluginInteractiveHandlerMock.mock.calls as unknown[][];
|
||||
const firstCall = calls[0]?.[0] as
|
||||
| {
|
||||
interactionId?: string;
|
||||
}
|
||||
| undefined;
|
||||
const secondCall = calls[1]?.[0] as
|
||||
| {
|
||||
interactionId?: string;
|
||||
}
|
||||
| undefined;
|
||||
expect(firstCall?.interactionId).toContain(":trigger-1:");
|
||||
expect(secondCall?.interactionId).toContain(":trigger-2:");
|
||||
expect(firstCall?.interactionId).not.toBe(secondCall?.interactionId);
|
||||
});
|
||||
|
||||
it("resolves plugin binding approvals from shared interactive Slack actions", async () => {
|
||||
resolvePluginConversationBindingApprovalMock.mockResolvedValueOnce({
|
||||
status: "approved",
|
||||
decision: "allow-once",
|
||||
request: {
|
||||
pluginId: "codex",
|
||||
pluginName: "Codex",
|
||||
summary: "for this thread",
|
||||
},
|
||||
});
|
||||
const { ctx, app, getHandler } = createContext();
|
||||
registerSlackInteractionEvents({ ctx: ctx as never });
|
||||
|
||||
const handler = getHandler();
|
||||
expect(handler).toBeTruthy();
|
||||
|
||||
const ack = vi.fn().mockResolvedValue(undefined);
|
||||
const respond = vi.fn().mockResolvedValue(undefined);
|
||||
await handler!({
|
||||
ack,
|
||||
respond,
|
||||
body: {
|
||||
user: { id: "U123" },
|
||||
channel: { id: "C1" },
|
||||
container: { channel_id: "C1", message_ts: "100.200", thread_ts: "100.100" },
|
||||
message: {
|
||||
ts: "100.200",
|
||||
text: "Approve this bind?",
|
||||
blocks: [
|
||||
{
|
||||
type: "actions",
|
||||
block_id: "bind_actions",
|
||||
elements: [{ type: "button", action_id: "openclaw:reply_button" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
action: {
|
||||
type: "button",
|
||||
action_id: "openclaw:reply_button",
|
||||
block_id: "bind_actions",
|
||||
value: "pluginbind:approval-123:o",
|
||||
text: { type: "plain_text", text: "Allow once" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(ack).toHaveBeenCalled();
|
||||
expect(resolvePluginConversationBindingApprovalMock).toHaveBeenCalledWith({
|
||||
approvalId: "approval-123",
|
||||
decision: "allow-once",
|
||||
senderId: "U123",
|
||||
});
|
||||
expect(dispatchPluginInteractiveHandlerMock).not.toHaveBeenCalled();
|
||||
expect(app.client.chat.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channel: "C1",
|
||||
ts: "100.200",
|
||||
text: "Approve this bind?",
|
||||
blocks: [],
|
||||
}),
|
||||
);
|
||||
expect(respond).toHaveBeenCalledWith({
|
||||
text: "Binding updated.",
|
||||
response_type: "ephemeral",
|
||||
});
|
||||
expect(enqueueSystemEventMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("drops block actions when mismatch guard triggers", async () => {
|
||||
enqueueSystemEventMock.mockClear();
|
||||
const { ctx, app, getHandler } = createContext({
|
||||
|
||||
@ -1,6 +1,13 @@
|
||||
import type { SlackActionMiddlewareArgs } from "@slack/bolt";
|
||||
import type { Block, KnownBlock } from "@slack/web-api";
|
||||
import { enqueueSystemEvent } from "../../../../../src/infra/system-events.js";
|
||||
import {
|
||||
buildPluginBindingResolvedText,
|
||||
parsePluginBindingApprovalCustomId,
|
||||
resolvePluginConversationBindingApproval,
|
||||
} from "../../../../../src/plugins/conversation-binding.js";
|
||||
import { dispatchPluginInteractiveHandler } from "../../../../../src/plugins/interactive.js";
|
||||
import { SLACK_REPLY_BUTTON_ACTION_ID, SLACK_REPLY_SELECT_ACTION_ID } from "../../blocks-render.js";
|
||||
import { truncateSlackText } from "../../truncate.js";
|
||||
import { authorizeSlackSystemEventSender } from "../auth.js";
|
||||
import type { SlackMonitorContext } from "../context.js";
|
||||
@ -417,6 +424,46 @@ function formatInteractionConfirmationText(params: {
|
||||
return `:white_check_mark: *${escapeSlackMrkdwn(params.selectedLabel)}* selected${actor}`;
|
||||
}
|
||||
|
||||
function buildSlackPluginInteractionData(params: {
|
||||
actionId: string;
|
||||
summary: Omit<InteractionSummary, "actionId" | "blockId">;
|
||||
}): string | null {
|
||||
const actionId = params.actionId.trim();
|
||||
if (!actionId) {
|
||||
return null;
|
||||
}
|
||||
const payload =
|
||||
params.summary.value?.trim() ||
|
||||
params.summary.selectedValues?.map((value) => value.trim()).find(Boolean) ||
|
||||
"";
|
||||
if (actionId === SLACK_REPLY_BUTTON_ACTION_ID || actionId === SLACK_REPLY_SELECT_ACTION_ID) {
|
||||
return payload || null;
|
||||
}
|
||||
return payload ? `${actionId}:${payload}` : actionId;
|
||||
}
|
||||
|
||||
function buildSlackPluginInteractionId(params: {
|
||||
userId?: string;
|
||||
channelId?: string;
|
||||
messageTs?: string;
|
||||
triggerId?: string;
|
||||
actionId: string;
|
||||
summary: Omit<InteractionSummary, "actionId" | "blockId">;
|
||||
}): string {
|
||||
const primaryValue =
|
||||
params.summary.value?.trim() ||
|
||||
params.summary.selectedValues?.map((value) => value.trim()).find(Boolean) ||
|
||||
"";
|
||||
return [
|
||||
params.userId?.trim() || "",
|
||||
params.channelId?.trim() || "",
|
||||
params.messageTs?.trim() || "",
|
||||
params.triggerId?.trim() || "",
|
||||
params.actionId.trim(),
|
||||
primaryValue,
|
||||
].join(":");
|
||||
}
|
||||
|
||||
function summarizeViewState(values: unknown): ModalInputSummary[] {
|
||||
if (!values || typeof values !== "object") {
|
||||
return [];
|
||||
@ -447,68 +494,108 @@ export function registerSlackInteractionEvents(params: { ctx: SlackMonitorContex
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle Block Kit button clicks from OpenClaw-generated messages
|
||||
// Only matches action_ids that start with our prefix to avoid interfering
|
||||
// with other Slack integrations or future features
|
||||
ctx.app.action(
|
||||
new RegExp(`^${OPENCLAW_ACTION_PREFIX}`),
|
||||
async (args: SlackActionMiddlewareArgs) => {
|
||||
const { ack, body, action, respond } = args;
|
||||
const typedBody = body as unknown as {
|
||||
user?: { id?: string };
|
||||
team?: { id?: string };
|
||||
trigger_id?: string;
|
||||
response_url?: string;
|
||||
channel?: { id?: string };
|
||||
container?: { channel_id?: string; message_ts?: string; thread_ts?: string };
|
||||
message?: { ts?: string; text?: string; blocks?: unknown[] };
|
||||
};
|
||||
// Handle Block Kit actions for this Slack app, including legacy/custom
|
||||
// action_ids that plugin handlers map into shared interactive namespaces.
|
||||
ctx.app.action(/.+/, async (args: SlackActionMiddlewareArgs) => {
|
||||
const { ack, body, action, respond } = args;
|
||||
const typedBody = body as unknown as {
|
||||
user?: { id?: string };
|
||||
team?: { id?: string };
|
||||
trigger_id?: string;
|
||||
response_url?: string;
|
||||
channel?: { id?: string };
|
||||
container?: { channel_id?: string; message_ts?: string; thread_ts?: string };
|
||||
message?: { ts?: string; text?: string; blocks?: unknown[] };
|
||||
};
|
||||
|
||||
// Acknowledge the action immediately to prevent the warning icon
|
||||
await ack();
|
||||
if (ctx.shouldDropMismatchedSlackEvent?.(body)) {
|
||||
ctx.runtime.log?.("slack:interaction drop block action payload (mismatched app/team)");
|
||||
return;
|
||||
}
|
||||
// Acknowledge the action immediately to prevent the warning icon
|
||||
await ack();
|
||||
if (ctx.shouldDropMismatchedSlackEvent?.(body)) {
|
||||
ctx.runtime.log?.("slack:interaction drop block action payload (mismatched app/team)");
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract action details using proper Bolt types
|
||||
const typedAction = readInteractionAction(action);
|
||||
if (!typedAction) {
|
||||
ctx.runtime.log?.(
|
||||
`slack:interaction malformed action payload channel=${typedBody.channel?.id ?? typedBody.container?.channel_id ?? "unknown"} user=${
|
||||
typedBody.user?.id ?? "unknown"
|
||||
}`,
|
||||
);
|
||||
return;
|
||||
// Extract action details using proper Bolt types
|
||||
const typedAction = readInteractionAction(action);
|
||||
if (!typedAction) {
|
||||
ctx.runtime.log?.(
|
||||
`slack:interaction malformed action payload channel=${typedBody.channel?.id ?? typedBody.container?.channel_id ?? "unknown"} user=${
|
||||
typedBody.user?.id ?? "unknown"
|
||||
}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const typedActionWithText = typedAction as {
|
||||
action_id?: string;
|
||||
block_id?: string;
|
||||
type?: string;
|
||||
text?: { text?: string };
|
||||
};
|
||||
const actionId =
|
||||
typeof typedActionWithText.action_id === "string" ? typedActionWithText.action_id : "unknown";
|
||||
const blockId = typedActionWithText.block_id;
|
||||
const userId = typedBody.user?.id ?? "unknown";
|
||||
const channelId = typedBody.channel?.id ?? typedBody.container?.channel_id;
|
||||
const messageTs = typedBody.message?.ts ?? typedBody.container?.message_ts;
|
||||
const threadTs = typedBody.container?.thread_ts;
|
||||
const auth = await authorizeSlackSystemEventSender({
|
||||
ctx,
|
||||
senderId: userId,
|
||||
channelId,
|
||||
});
|
||||
if (!auth.allowed) {
|
||||
ctx.runtime.log?.(
|
||||
`slack:interaction drop action=${actionId} user=${userId} channel=${channelId ?? "unknown"} reason=${auth.reason ?? "unauthorized"}`,
|
||||
);
|
||||
if (respond) {
|
||||
try {
|
||||
await respond({
|
||||
text: "You are not authorized to use this control.",
|
||||
response_type: "ephemeral",
|
||||
});
|
||||
} catch {
|
||||
// Best-effort feedback only.
|
||||
}
|
||||
}
|
||||
const typedActionWithText = typedAction as {
|
||||
action_id?: string;
|
||||
block_id?: string;
|
||||
type?: string;
|
||||
text?: { text?: string };
|
||||
};
|
||||
const actionId =
|
||||
typeof typedActionWithText.action_id === "string"
|
||||
? typedActionWithText.action_id
|
||||
: "unknown";
|
||||
const blockId = typedActionWithText.block_id;
|
||||
const userId = typedBody.user?.id ?? "unknown";
|
||||
const channelId = typedBody.channel?.id ?? typedBody.container?.channel_id;
|
||||
const messageTs = typedBody.message?.ts ?? typedBody.container?.message_ts;
|
||||
const threadTs = typedBody.container?.thread_ts;
|
||||
const auth = await authorizeSlackSystemEventSender({
|
||||
ctx,
|
||||
senderId: userId,
|
||||
return;
|
||||
}
|
||||
const actionSummary = summarizeAction(typedAction);
|
||||
const pluginInteractionData = buildSlackPluginInteractionData({
|
||||
actionId,
|
||||
summary: actionSummary,
|
||||
});
|
||||
if (pluginInteractionData) {
|
||||
const pluginInteractionId = buildSlackPluginInteractionId({
|
||||
userId,
|
||||
channelId,
|
||||
messageTs,
|
||||
triggerId: typedBody.trigger_id,
|
||||
actionId,
|
||||
summary: actionSummary,
|
||||
});
|
||||
if (!auth.allowed) {
|
||||
ctx.runtime.log?.(
|
||||
`slack:interaction drop action=${actionId} user=${userId} channel=${channelId ?? "unknown"} reason=${auth.reason ?? "unauthorized"}`,
|
||||
);
|
||||
const pluginBindingApproval = parsePluginBindingApprovalCustomId(pluginInteractionData);
|
||||
if (pluginBindingApproval) {
|
||||
const resolved = await resolvePluginConversationBindingApproval({
|
||||
approvalId: pluginBindingApproval.approvalId,
|
||||
decision: pluginBindingApproval.decision,
|
||||
senderId: userId,
|
||||
});
|
||||
if (channelId && messageTs) {
|
||||
try {
|
||||
await ctx.app.client.chat.update({
|
||||
channel: channelId,
|
||||
ts: messageTs,
|
||||
text: typedBody.message?.text ?? "",
|
||||
blocks: [],
|
||||
});
|
||||
} catch {
|
||||
// Best-effort cleanup only; continue with follow-up feedback.
|
||||
}
|
||||
}
|
||||
if (respond) {
|
||||
try {
|
||||
await respond({
|
||||
text: "You are not authorized to use this control.",
|
||||
text: buildPluginBindingResolvedText(resolved),
|
||||
response_type: "ephemeral",
|
||||
});
|
||||
} catch {
|
||||
@ -517,115 +604,178 @@ export function registerSlackInteractionEvents(params: { ctx: SlackMonitorContex
|
||||
}
|
||||
return;
|
||||
}
|
||||
const actionSummary = summarizeAction(typedAction);
|
||||
const eventPayload: InteractionSummary = {
|
||||
interactionType: "block_action",
|
||||
actionId,
|
||||
blockId,
|
||||
...actionSummary,
|
||||
userId,
|
||||
teamId: typedBody.team?.id,
|
||||
triggerId: typedBody.trigger_id,
|
||||
responseUrl: typedBody.response_url,
|
||||
channelId,
|
||||
messageTs,
|
||||
threadTs,
|
||||
};
|
||||
|
||||
// Log the interaction for debugging
|
||||
ctx.runtime.log?.(
|
||||
`slack:interaction action=${actionId} type=${actionSummary.actionType ?? "unknown"} user=${userId} channel=${channelId}`,
|
||||
);
|
||||
|
||||
// Send a system event to notify the agent about the button click
|
||||
// Pass undefined (not "unknown") to allow proper main session fallback
|
||||
const sessionKey = ctx.resolveSlackSystemEventSessionKey({
|
||||
channelId: channelId,
|
||||
channelType: auth.channelType,
|
||||
senderId: userId,
|
||||
const pluginResult = await dispatchPluginInteractiveHandler({
|
||||
channel: "slack",
|
||||
data: pluginInteractionData,
|
||||
interactionId: pluginInteractionId,
|
||||
ctx: {
|
||||
accountId: ctx.accountId,
|
||||
interactionId: pluginInteractionId,
|
||||
conversationId: channelId ?? "",
|
||||
parentConversationId: undefined,
|
||||
threadId: threadTs,
|
||||
senderId: userId,
|
||||
senderUsername: undefined,
|
||||
auth: {
|
||||
isAuthorizedSender: auth.allowed,
|
||||
},
|
||||
interaction: {
|
||||
kind: actionSummary.actionType === "button" ? "button" : "select",
|
||||
actionId,
|
||||
blockId,
|
||||
messageTs,
|
||||
threadTs,
|
||||
value: actionSummary.value,
|
||||
selectedValues: actionSummary.selectedValues,
|
||||
selectedLabels: actionSummary.selectedLabels,
|
||||
triggerId: typedBody.trigger_id,
|
||||
responseUrl: typedBody.response_url,
|
||||
},
|
||||
},
|
||||
respond: {
|
||||
acknowledge: async () => {},
|
||||
reply: async ({ text, responseType }) => {
|
||||
if (!respond) {
|
||||
return;
|
||||
}
|
||||
await respond({
|
||||
text,
|
||||
response_type: responseType ?? "ephemeral",
|
||||
});
|
||||
},
|
||||
followUp: async ({ text, responseType }) => {
|
||||
if (!respond) {
|
||||
return;
|
||||
}
|
||||
await respond({
|
||||
text,
|
||||
response_type: responseType ?? "ephemeral",
|
||||
});
|
||||
},
|
||||
editMessage: async ({ text, blocks }) => {
|
||||
if (!channelId || !messageTs) {
|
||||
return;
|
||||
}
|
||||
await ctx.app.client.chat.update({
|
||||
channel: channelId,
|
||||
ts: messageTs,
|
||||
text: text ?? typedBody.message?.text ?? "",
|
||||
...(Array.isArray(blocks) ? { blocks: blocks as (Block | KnownBlock)[] } : {}),
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Build context key - only include defined values to avoid "unknown" noise
|
||||
const contextParts = ["slack:interaction", channelId, messageTs, actionId].filter(Boolean);
|
||||
const contextKey = contextParts.join(":");
|
||||
|
||||
enqueueSystemEvent(formatSlackInteractionSystemEvent(eventPayload), {
|
||||
sessionKey,
|
||||
contextKey,
|
||||
});
|
||||
|
||||
const originalBlocks = typedBody.message?.blocks;
|
||||
if (!Array.isArray(originalBlocks) || !channelId || !messageTs) {
|
||||
if (pluginResult.matched && pluginResult.handled) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
const eventPayload: InteractionSummary = {
|
||||
interactionType: "block_action",
|
||||
actionId,
|
||||
blockId,
|
||||
...actionSummary,
|
||||
userId,
|
||||
teamId: typedBody.team?.id,
|
||||
triggerId: typedBody.trigger_id,
|
||||
responseUrl: typedBody.response_url,
|
||||
channelId,
|
||||
messageTs,
|
||||
threadTs,
|
||||
};
|
||||
|
||||
if (!blockId) {
|
||||
return;
|
||||
// Log the interaction for debugging
|
||||
ctx.runtime.log?.(
|
||||
`slack:interaction action=${actionId} type=${actionSummary.actionType ?? "unknown"} user=${userId} channel=${channelId}`,
|
||||
);
|
||||
|
||||
// Send a system event to notify the agent about the button click
|
||||
// Pass undefined (not "unknown") to allow proper main session fallback
|
||||
const sessionKey = ctx.resolveSlackSystemEventSessionKey({
|
||||
channelId: channelId,
|
||||
channelType: auth.channelType,
|
||||
senderId: userId,
|
||||
});
|
||||
|
||||
// Build context key - only include defined values to avoid "unknown" noise
|
||||
const contextParts = ["slack:interaction", channelId, messageTs, actionId].filter(Boolean);
|
||||
const contextKey = contextParts.join(":");
|
||||
|
||||
enqueueSystemEvent(formatSlackInteractionSystemEvent(eventPayload), {
|
||||
sessionKey,
|
||||
contextKey,
|
||||
});
|
||||
|
||||
const originalBlocks = typedBody.message?.blocks;
|
||||
if (!Array.isArray(originalBlocks) || !channelId || !messageTs) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!blockId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedLabel = formatInteractionSelectionLabel({
|
||||
actionId,
|
||||
summary: actionSummary,
|
||||
buttonText: typedActionWithText.text?.text,
|
||||
});
|
||||
let updatedBlocks = originalBlocks.map((block) => {
|
||||
const typedBlock = block as InteractionMessageBlock;
|
||||
if (typedBlock.type === "actions" && typedBlock.block_id === blockId) {
|
||||
return {
|
||||
type: "context",
|
||||
elements: [
|
||||
{
|
||||
type: "mrkdwn",
|
||||
text: formatInteractionConfirmationText({ selectedLabel, userId }),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return block;
|
||||
});
|
||||
|
||||
const selectedLabel = formatInteractionSelectionLabel({
|
||||
actionId,
|
||||
summary: actionSummary,
|
||||
buttonText: typedActionWithText.text?.text,
|
||||
});
|
||||
let updatedBlocks = originalBlocks.map((block) => {
|
||||
const hasRemainingIndividualActionRows = updatedBlocks.some((block) => {
|
||||
const typedBlock = block as InteractionMessageBlock;
|
||||
return typedBlock.type === "actions" && !isBulkActionsBlock(typedBlock);
|
||||
});
|
||||
|
||||
if (!hasRemainingIndividualActionRows) {
|
||||
updatedBlocks = updatedBlocks.filter((block, index) => {
|
||||
const typedBlock = block as InteractionMessageBlock;
|
||||
if (typedBlock.type === "actions" && typedBlock.block_id === blockId) {
|
||||
return {
|
||||
type: "context",
|
||||
elements: [
|
||||
{
|
||||
type: "mrkdwn",
|
||||
text: formatInteractionConfirmationText({ selectedLabel, userId }),
|
||||
},
|
||||
],
|
||||
};
|
||||
if (isBulkActionsBlock(typedBlock)) {
|
||||
return false;
|
||||
}
|
||||
return block;
|
||||
if (typedBlock.type !== "divider") {
|
||||
return true;
|
||||
}
|
||||
const next = updatedBlocks[index + 1] as InteractionMessageBlock | undefined;
|
||||
return !next || !isBulkActionsBlock(next);
|
||||
});
|
||||
}
|
||||
|
||||
const hasRemainingIndividualActionRows = updatedBlocks.some((block) => {
|
||||
const typedBlock = block as InteractionMessageBlock;
|
||||
return typedBlock.type === "actions" && !isBulkActionsBlock(typedBlock);
|
||||
try {
|
||||
await ctx.app.client.chat.update({
|
||||
channel: channelId,
|
||||
ts: messageTs,
|
||||
text: typedBody.message?.text ?? "",
|
||||
blocks: updatedBlocks as (Block | KnownBlock)[],
|
||||
});
|
||||
|
||||
if (!hasRemainingIndividualActionRows) {
|
||||
updatedBlocks = updatedBlocks.filter((block, index) => {
|
||||
const typedBlock = block as InteractionMessageBlock;
|
||||
if (isBulkActionsBlock(typedBlock)) {
|
||||
return false;
|
||||
}
|
||||
if (typedBlock.type !== "divider") {
|
||||
return true;
|
||||
}
|
||||
const next = updatedBlocks[index + 1] as InteractionMessageBlock | undefined;
|
||||
return !next || !isBulkActionsBlock(next);
|
||||
});
|
||||
} catch {
|
||||
// If update fails, fallback to ephemeral confirmation for immediate UX feedback.
|
||||
if (!respond) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await ctx.app.client.chat.update({
|
||||
channel: channelId,
|
||||
ts: messageTs,
|
||||
text: typedBody.message?.text ?? "",
|
||||
blocks: updatedBlocks as (Block | KnownBlock)[],
|
||||
await respond({
|
||||
text: `Button "${actionId}" clicked!`,
|
||||
response_type: "ephemeral",
|
||||
});
|
||||
} catch {
|
||||
// If update fails, fallback to ephemeral confirmation for immediate UX feedback.
|
||||
if (!respond) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await respond({
|
||||
text: `Button "${actionId}" clicked!`,
|
||||
response_type: "ephemeral",
|
||||
});
|
||||
} catch {
|
||||
// Action was acknowledged and system event enqueued even when response updates fail.
|
||||
}
|
||||
// Action was acknowledged and system event enqueued even when response updates fail.
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
if (typeof ctx.app.view !== "function") {
|
||||
return;
|
||||
|
||||
85
extensions/slack/src/shared-interactive.test.ts
Normal file
85
extensions/slack/src/shared-interactive.test.ts
Normal file
@ -0,0 +1,85 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildSlackInteractiveBlocks } from "./blocks-render.js";
|
||||
|
||||
describe("buildSlackInteractiveBlocks", () => {
|
||||
it("renders shared interactive blocks in authored order", () => {
|
||||
expect(
|
||||
buildSlackInteractiveBlocks({
|
||||
blocks: [
|
||||
{
|
||||
type: "select",
|
||||
placeholder: "Pick one",
|
||||
options: [{ label: "Alpha", value: "alpha" }],
|
||||
},
|
||||
{ type: "text", text: "then" },
|
||||
{ type: "buttons", buttons: [{ label: "Retry", value: "retry" }] },
|
||||
],
|
||||
}),
|
||||
).toEqual([
|
||||
expect.objectContaining({
|
||||
type: "actions",
|
||||
block_id: "openclaw_reply_select_1",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: "section",
|
||||
text: expect.objectContaining({ text: "then" }),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: "actions",
|
||||
block_id: "openclaw_reply_buttons_1",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("truncates Slack render strings to Block Kit limits", () => {
|
||||
const long = "x".repeat(120);
|
||||
const blocks = buildSlackInteractiveBlocks({
|
||||
blocks: [
|
||||
{ type: "text", text: "y".repeat(3100) },
|
||||
{ type: "select", placeholder: long, options: [{ label: long, value: long }] },
|
||||
{ type: "buttons", buttons: [{ label: long, value: long }] },
|
||||
],
|
||||
});
|
||||
const section = blocks[0] as { text?: { text?: string } };
|
||||
const selectBlock = blocks[1] as {
|
||||
elements?: Array<{ placeholder?: { text?: string } }>;
|
||||
};
|
||||
const buttonBlock = blocks[2] as {
|
||||
elements?: Array<{ value?: string }>;
|
||||
};
|
||||
|
||||
expect((section.text?.text ?? "").length).toBeLessThanOrEqual(3000);
|
||||
expect((selectBlock.elements?.[0]?.placeholder?.text ?? "").length).toBeLessThanOrEqual(75);
|
||||
expect(buttonBlock.elements?.[0]?.value).toBe(long);
|
||||
});
|
||||
|
||||
it("preserves original callback payloads for round-tripping", () => {
|
||||
const blocks = buildSlackInteractiveBlocks({
|
||||
blocks: [
|
||||
{
|
||||
type: "buttons",
|
||||
buttons: [{ label: "Allow", value: "pluginbind:approval-123:o" }],
|
||||
},
|
||||
{
|
||||
type: "select",
|
||||
options: [{ label: "Approve", value: "codex:approve:thread-1" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const buttonBlock = blocks[0] as {
|
||||
elements?: Array<{ action_id?: string; value?: string }>;
|
||||
};
|
||||
const selectBlock = blocks[1] as {
|
||||
elements?: Array<{
|
||||
action_id?: string;
|
||||
options?: Array<{ value?: string }>;
|
||||
}>;
|
||||
};
|
||||
|
||||
expect(buttonBlock.elements?.[0]?.action_id).toBe("openclaw:reply_button");
|
||||
expect(buttonBlock.elements?.[0]?.value).toBe("pluginbind:approval-123:o");
|
||||
expect(selectBlock.elements?.[0]?.action_id).toBe("openclaw:reply_select");
|
||||
expect(selectBlock.elements?.[0]?.options?.[0]?.value).toBe("codex:approve:thread-1");
|
||||
});
|
||||
});
|
||||
@ -1,3 +1,6 @@
|
||||
import { reduceInteractiveReply } from "../../../src/channels/plugins/outbound/interactive.js";
|
||||
import type { InteractiveReply, InteractiveReplyButton } from "../../../src/interactive/payload.js";
|
||||
|
||||
export type TelegramButtonStyle = "danger" | "success" | "primary";
|
||||
|
||||
export type TelegramInlineButton = {
|
||||
@ -7,3 +10,53 @@ export type TelegramInlineButton = {
|
||||
};
|
||||
|
||||
export type TelegramInlineButtons = ReadonlyArray<ReadonlyArray<TelegramInlineButton>>;
|
||||
|
||||
const TELEGRAM_INTERACTIVE_ROW_SIZE = 3;
|
||||
|
||||
function toTelegramButtonStyle(
|
||||
style?: InteractiveReplyButton["style"],
|
||||
): TelegramInlineButton["style"] {
|
||||
return style === "danger" || style === "success" || style === "primary" ? style : undefined;
|
||||
}
|
||||
|
||||
function chunkInteractiveButtons(
|
||||
buttons: readonly InteractiveReplyButton[],
|
||||
rows: TelegramInlineButton[][],
|
||||
) {
|
||||
for (let i = 0; i < buttons.length; i += TELEGRAM_INTERACTIVE_ROW_SIZE) {
|
||||
const row = buttons.slice(i, i + TELEGRAM_INTERACTIVE_ROW_SIZE).map((button) => ({
|
||||
text: button.label,
|
||||
callback_data: button.value,
|
||||
style: toTelegramButtonStyle(button.style),
|
||||
}));
|
||||
if (row.length > 0) {
|
||||
rows.push(row);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function buildTelegramInteractiveButtons(
|
||||
interactive?: InteractiveReply,
|
||||
): TelegramInlineButtons | undefined {
|
||||
const rows = reduceInteractiveReply(
|
||||
interactive,
|
||||
[] as TelegramInlineButton[][],
|
||||
(state, block) => {
|
||||
if (block.type === "buttons") {
|
||||
chunkInteractiveButtons(block.buttons, state);
|
||||
return state;
|
||||
}
|
||||
if (block.type === "select") {
|
||||
chunkInteractiveButtons(
|
||||
block.options.map((option) => ({
|
||||
label: option.label,
|
||||
value: option.value,
|
||||
})),
|
||||
state,
|
||||
);
|
||||
}
|
||||
return state;
|
||||
},
|
||||
);
|
||||
return rows.length > 0 ? rows : undefined;
|
||||
}
|
||||
|
||||
53
extensions/telegram/src/channel-actions.test.ts
Normal file
53
extensions/telegram/src/channel-actions.test.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const handleTelegramActionMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../../../src/agents/tools/telegram-actions.js", () => ({
|
||||
handleTelegramAction: (...args: unknown[]) => handleTelegramActionMock(...args),
|
||||
}));
|
||||
|
||||
import { telegramMessageActions } from "./channel-actions.js";
|
||||
|
||||
describe("telegramMessageActions", () => {
|
||||
beforeEach(() => {
|
||||
handleTelegramActionMock.mockReset().mockResolvedValue({
|
||||
ok: true,
|
||||
content: [],
|
||||
details: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("allows interactive-only sends", async () => {
|
||||
await telegramMessageActions.handleAction!({
|
||||
action: "send",
|
||||
params: {
|
||||
to: "123456",
|
||||
interactive: {
|
||||
blocks: [
|
||||
{
|
||||
type: "buttons",
|
||||
buttons: [{ label: "Approve", value: "approve", style: "success" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
cfg: {} as never,
|
||||
accountId: "default",
|
||||
mediaLocalRoots: [],
|
||||
} as never);
|
||||
|
||||
expect(handleTelegramActionMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: "sendMessage",
|
||||
to: "123456",
|
||||
content: "",
|
||||
buttons: [[{ text: "Approve", callback_data: "approve", style: "success" }]],
|
||||
accountId: "default",
|
||||
}),
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
mediaLocalRoots: [],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -15,6 +15,7 @@ import type {
|
||||
ChannelMessageActionName,
|
||||
} from "../../../src/channels/plugins/types.js";
|
||||
import type { TelegramActionConfig } from "../../../src/config/types.telegram.js";
|
||||
import { normalizeInteractiveReply } from "../../../src/interactive/payload.js";
|
||||
import { readBooleanParam } from "../../../src/plugin-sdk/boolean-param.js";
|
||||
import { extractToolSend } from "../../../src/plugin-sdk/tool-send.js";
|
||||
import { resolveTelegramPollVisibility } from "../../../src/poll-params.js";
|
||||
@ -23,6 +24,7 @@ import {
|
||||
listEnabledTelegramAccounts,
|
||||
resolveTelegramPollActionGateState,
|
||||
} from "./accounts.js";
|
||||
import { buildTelegramInteractiveButtons } from "./button-types.js";
|
||||
import { isTelegramInlineButtonsEnabled } from "./inline-buttons.js";
|
||||
|
||||
const providerId = "telegram";
|
||||
@ -30,12 +32,18 @@ const providerId = "telegram";
|
||||
function readTelegramSendParams(params: Record<string, unknown>) {
|
||||
const to = readStringParam(params, "to", { required: true });
|
||||
const mediaUrl = readStringParam(params, "media", { trim: false });
|
||||
const message = readStringParam(params, "message", { required: !mediaUrl, allowEmpty: true });
|
||||
const buttons =
|
||||
params.buttons ??
|
||||
buildTelegramInteractiveButtons(normalizeInteractiveReply(params.interactive));
|
||||
const hasButtons = Array.isArray(buttons) && buttons.length > 0;
|
||||
const message = readStringParam(params, "message", {
|
||||
required: !mediaUrl && !hasButtons,
|
||||
allowEmpty: true,
|
||||
});
|
||||
const caption = readStringParam(params, "caption", { allowEmpty: true });
|
||||
const content = message || caption || "";
|
||||
const replyTo = readStringParam(params, "replyTo");
|
||||
const threadId = readStringParam(params, "threadId");
|
||||
const buttons = params.buttons;
|
||||
const asVoice = readBooleanParam(params, "asVoice");
|
||||
const silent = readBooleanParam(params, "silent");
|
||||
const forceDocument = readBooleanParam(params, "forceDocument");
|
||||
@ -120,14 +128,15 @@ export const telegramMessageActions: ChannelMessageActionAdapter = {
|
||||
}
|
||||
return Array.from(actions);
|
||||
},
|
||||
supportsButtons: ({ cfg }) => {
|
||||
getCapabilities: ({ cfg }) => {
|
||||
const accounts = listTokenSourcedAccounts(listEnabledTelegramAccounts(cfg));
|
||||
if (accounts.length === 0) {
|
||||
return false;
|
||||
return [];
|
||||
}
|
||||
return accounts.some((account) =>
|
||||
const buttonsEnabled = accounts.some((account) =>
|
||||
isTelegramInlineButtonsEnabled({ cfg, accountId: account.accountId }),
|
||||
);
|
||||
return buttonsEnabled ? (["interactive", "buttons"] as const) : [];
|
||||
},
|
||||
extractToolSend: ({ args }) => {
|
||||
return extractToolSend(args, "sendMessage");
|
||||
|
||||
@ -8,7 +8,9 @@ import {
|
||||
resolveOutboundSendDep,
|
||||
type OutboundSendDeps,
|
||||
} from "../../../src/infra/outbound/send-deps.js";
|
||||
import { resolveInteractiveTextFallback } from "../../../src/interactive/payload.js";
|
||||
import type { TelegramInlineButtons } from "./button-types.js";
|
||||
import { buildTelegramInteractiveButtons } from "./button-types.js";
|
||||
import { markdownToTelegramHtmlChunks } from "./format.js";
|
||||
import { parseTelegramReplyToMessageId, parseTelegramThreadId } from "./outbound-params.js";
|
||||
import { sendMessageTelegram } from "./send.js";
|
||||
@ -59,8 +61,14 @@ export async function sendTelegramPayloadMessages(params: {
|
||||
| undefined;
|
||||
const quoteText =
|
||||
typeof telegramData?.quoteText === "string" ? telegramData.quoteText : undefined;
|
||||
const text = params.payload.text ?? "";
|
||||
const text =
|
||||
resolveInteractiveTextFallback({
|
||||
text: params.payload.text,
|
||||
interactive: params.payload.interactive,
|
||||
}) ?? "";
|
||||
const mediaUrls = resolvePayloadMediaUrls(params.payload);
|
||||
const interactiveButtons = buildTelegramInteractiveButtons(params.payload.interactive);
|
||||
const buttons = telegramData?.buttons ?? interactiveButtons;
|
||||
const payloadOpts = {
|
||||
...params.baseOpts,
|
||||
quoteText,
|
||||
@ -69,7 +77,7 @@ export async function sendTelegramPayloadMessages(params: {
|
||||
if (mediaUrls.length === 0) {
|
||||
return await params.send(params.to, text, {
|
||||
...payloadOpts,
|
||||
buttons: telegramData?.buttons,
|
||||
buttons,
|
||||
});
|
||||
}
|
||||
|
||||
@ -81,7 +89,7 @@ export async function sendTelegramPayloadMessages(params: {
|
||||
await params.send(params.to, text, {
|
||||
...payloadOpts,
|
||||
mediaUrl,
|
||||
...(isFirst ? { buttons: telegramData?.buttons } : {}),
|
||||
...(isFirst ? { buttons } : {}),
|
||||
}),
|
||||
});
|
||||
return finalResult ?? { messageId: "unknown", chatId: params.to };
|
||||
|
||||
@ -24,7 +24,7 @@ export const zaloMessageActions: ChannelMessageActionAdapter = {
|
||||
const actions = new Set<ChannelMessageActionName>(["send"]);
|
||||
return Array.from(actions);
|
||||
},
|
||||
supportsButtons: () => false,
|
||||
getCapabilities: () => [],
|
||||
extractToolSend: ({ args }) => extractToolSend(args, "sendMessage"),
|
||||
handleAction: async ({ action, params, cfg, accountId }) => {
|
||||
if (action === "send") {
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ChannelMessageCapability } from "../../channels/plugins/message-capabilities.js";
|
||||
import type { ChannelMessageActionName, ChannelPlugin } from "../../channels/plugins/types.js";
|
||||
import type { MessageActionRunResult } from "../../infra/outbound/message-action-runner.js";
|
||||
import { setActivePluginRegistry } from "../../plugins/runtime.js";
|
||||
@ -47,9 +48,10 @@ function createChannelPlugin(params: {
|
||||
blurb: string;
|
||||
actions?: ChannelMessageActionName[];
|
||||
listActions?: NonNullable<NonNullable<ChannelPlugin["actions"]>["listActions"]>;
|
||||
supportsButtons?: boolean;
|
||||
capabilities?: readonly ChannelMessageCapability[];
|
||||
messaging?: ChannelPlugin["messaging"];
|
||||
}): ChannelPlugin {
|
||||
const actionCapabilities = params.capabilities;
|
||||
return {
|
||||
id: params.id as ChannelPlugin["id"],
|
||||
meta: {
|
||||
@ -71,7 +73,9 @@ function createChannelPlugin(params: {
|
||||
(() => {
|
||||
return (params.actions ?? []) as never;
|
||||
}),
|
||||
...(params.supportsButtons ? { supportsButtons: () => true } : {}),
|
||||
...(actionCapabilities
|
||||
? { getCapabilities: (_params: { cfg: unknown }) => actionCapabilities }
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -145,7 +149,7 @@ describe("message tool schema scoping", () => {
|
||||
docsPath: "/channels/telegram",
|
||||
blurb: "Telegram test plugin.",
|
||||
actions: ["send", "react", "poll"],
|
||||
supportsButtons: true,
|
||||
capabilities: ["interactive", "buttons"],
|
||||
});
|
||||
|
||||
const discordPlugin = createChannelPlugin({
|
||||
@ -154,6 +158,16 @@ describe("message tool schema scoping", () => {
|
||||
docsPath: "/channels/discord",
|
||||
blurb: "Discord test plugin.",
|
||||
actions: ["send", "poll", "poll-vote"],
|
||||
capabilities: ["interactive", "components"],
|
||||
});
|
||||
|
||||
const slackPlugin = createChannelPlugin({
|
||||
id: "slack",
|
||||
label: "Slack",
|
||||
docsPath: "/channels/slack",
|
||||
blurb: "Slack test plugin.",
|
||||
actions: ["send", "react"],
|
||||
capabilities: ["interactive", "blocks"],
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@ -164,6 +178,7 @@ describe("message tool schema scoping", () => {
|
||||
{
|
||||
provider: "telegram",
|
||||
expectComponents: false,
|
||||
expectBlocks: false,
|
||||
expectButtons: true,
|
||||
expectButtonStyle: true,
|
||||
expectTelegramPollExtras: true,
|
||||
@ -172,16 +187,27 @@ describe("message tool schema scoping", () => {
|
||||
{
|
||||
provider: "discord",
|
||||
expectComponents: true,
|
||||
expectBlocks: false,
|
||||
expectButtons: false,
|
||||
expectButtonStyle: false,
|
||||
expectTelegramPollExtras: true,
|
||||
expectedActions: ["send", "poll", "poll-vote", "react"],
|
||||
},
|
||||
{
|
||||
provider: "slack",
|
||||
expectComponents: false,
|
||||
expectBlocks: true,
|
||||
expectButtons: false,
|
||||
expectButtonStyle: false,
|
||||
expectTelegramPollExtras: true,
|
||||
expectedActions: ["send", "react", "poll", "poll-vote"],
|
||||
},
|
||||
])(
|
||||
"scopes schema fields for $provider",
|
||||
({
|
||||
provider,
|
||||
expectComponents,
|
||||
expectBlocks,
|
||||
expectButtons,
|
||||
expectButtonStyle,
|
||||
expectTelegramPollExtras,
|
||||
@ -191,6 +217,7 @@ describe("message tool schema scoping", () => {
|
||||
createTestRegistry([
|
||||
{ pluginId: "telegram", source: "test", plugin: telegramPlugin },
|
||||
{ pluginId: "discord", source: "test", plugin: discordPlugin },
|
||||
{ pluginId: "slack", source: "test", plugin: slackPlugin },
|
||||
]),
|
||||
);
|
||||
|
||||
@ -206,6 +233,11 @@ describe("message tool schema scoping", () => {
|
||||
} else {
|
||||
expect(properties.components).toBeUndefined();
|
||||
}
|
||||
if (expectBlocks) {
|
||||
expect(properties.blocks).toBeDefined();
|
||||
} else {
|
||||
expect(properties.blocks).toBeUndefined();
|
||||
}
|
||||
if (expectButtons) {
|
||||
expect(properties.buttons).toBeDefined();
|
||||
} else {
|
||||
@ -263,7 +295,7 @@ describe("message tool schema scoping", () => {
|
||||
.channels?.telegram;
|
||||
return telegramCfg?.actions?.poll === false ? ["send", "react"] : ["send", "react", "poll"];
|
||||
},
|
||||
supportsButtons: true,
|
||||
capabilities: ["interactive", "buttons"],
|
||||
});
|
||||
|
||||
setActivePluginRegistry(
|
||||
|
||||
@ -2,12 +2,11 @@ import { Type } from "@sinclair/typebox";
|
||||
import { BLUEBUBBLES_GROUP_ACTIONS } from "../../channels/plugins/bluebubbles-actions.js";
|
||||
import { listChannelPlugins } from "../../channels/plugins/index.js";
|
||||
import {
|
||||
channelSupportsMessageCapability,
|
||||
channelSupportsMessageCapabilityForChannel,
|
||||
listChannelMessageActions,
|
||||
supportsChannelMessageButtons,
|
||||
supportsChannelMessageButtonsForChannel,
|
||||
supportsChannelMessageCards,
|
||||
supportsChannelMessageCardsForChannel,
|
||||
} from "../../channels/plugins/message-actions.js";
|
||||
import type { ChannelMessageCapability } from "../../channels/plugins/message-capabilities.js";
|
||||
import {
|
||||
CHANNEL_MESSAGE_ACTION_NAMES,
|
||||
type ChannelMessageActionName,
|
||||
@ -163,10 +162,41 @@ const discordComponentMessageSchema = Type.Object(
|
||||
},
|
||||
);
|
||||
|
||||
const interactiveOptionSchema = Type.Object({
|
||||
label: Type.String(),
|
||||
value: Type.String(),
|
||||
});
|
||||
|
||||
const interactiveButtonSchema = Type.Object({
|
||||
label: Type.String(),
|
||||
value: Type.String(),
|
||||
style: Type.Optional(stringEnum(["primary", "secondary", "success", "danger"])),
|
||||
});
|
||||
|
||||
const interactiveBlockSchema = Type.Object({
|
||||
type: stringEnum(["text", "buttons", "select"]),
|
||||
text: Type.Optional(Type.String()),
|
||||
buttons: Type.Optional(Type.Array(interactiveButtonSchema)),
|
||||
placeholder: Type.Optional(Type.String()),
|
||||
options: Type.Optional(Type.Array(interactiveOptionSchema)),
|
||||
});
|
||||
|
||||
const interactiveMessageSchema = Type.Object(
|
||||
{
|
||||
blocks: Type.Array(interactiveBlockSchema),
|
||||
},
|
||||
{
|
||||
description:
|
||||
"Shared interactive message payload for buttons and selects. Channels render this into their native components when supported.",
|
||||
},
|
||||
);
|
||||
|
||||
function buildSendSchema(options: {
|
||||
includeInteractive: boolean;
|
||||
includeButtons: boolean;
|
||||
includeCards: boolean;
|
||||
includeComponents: boolean;
|
||||
includeBlocks: boolean;
|
||||
}) {
|
||||
const props: Record<string, unknown> = {
|
||||
message: Type.Optional(Type.String()),
|
||||
@ -208,6 +238,7 @@ function buildSendSchema(options: {
|
||||
description: "Send image/GIF as document to avoid Telegram compression (Telegram only).",
|
||||
}),
|
||||
),
|
||||
interactive: Type.Optional(interactiveMessageSchema),
|
||||
buttons: Type.Optional(
|
||||
Type.Array(
|
||||
Type.Array(
|
||||
@ -232,16 +263,33 @@ function buildSendSchema(options: {
|
||||
),
|
||||
),
|
||||
components: Type.Optional(discordComponentMessageSchema),
|
||||
blocks: Type.Optional(
|
||||
Type.Array(
|
||||
Type.Object(
|
||||
{},
|
||||
{
|
||||
additionalProperties: true,
|
||||
description: "Slack Block Kit payload blocks (Slack only).",
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
};
|
||||
if (!options.includeButtons) {
|
||||
delete props.buttons;
|
||||
}
|
||||
if (!options.includeInteractive) {
|
||||
delete props.interactive;
|
||||
}
|
||||
if (!options.includeCards) {
|
||||
delete props.card;
|
||||
}
|
||||
if (!options.includeComponents) {
|
||||
delete props.components;
|
||||
}
|
||||
if (!options.includeBlocks) {
|
||||
delete props.blocks;
|
||||
}
|
||||
return props;
|
||||
}
|
||||
|
||||
@ -447,9 +495,11 @@ function buildChannelManagementSchema() {
|
||||
}
|
||||
|
||||
function buildMessageToolSchemaProps(options: {
|
||||
includeInteractive: boolean;
|
||||
includeButtons: boolean;
|
||||
includeCards: boolean;
|
||||
includeComponents: boolean;
|
||||
includeBlocks: boolean;
|
||||
includeTelegramPollExtras: boolean;
|
||||
}) {
|
||||
return {
|
||||
@ -472,9 +522,11 @@ function buildMessageToolSchemaProps(options: {
|
||||
function buildMessageToolSchemaFromActions(
|
||||
actions: readonly string[],
|
||||
options: {
|
||||
includeInteractive: boolean;
|
||||
includeButtons: boolean;
|
||||
includeCards: boolean;
|
||||
includeComponents: boolean;
|
||||
includeBlocks: boolean;
|
||||
includeTelegramPollExtras: boolean;
|
||||
},
|
||||
) {
|
||||
@ -486,9 +538,11 @@ function buildMessageToolSchemaFromActions(
|
||||
}
|
||||
|
||||
const MessageToolSchema = buildMessageToolSchemaFromActions(AllMessageActions, {
|
||||
includeInteractive: true,
|
||||
includeButtons: true,
|
||||
includeCards: true,
|
||||
includeComponents: true,
|
||||
includeBlocks: true,
|
||||
includeTelegramPollExtras: true,
|
||||
});
|
||||
|
||||
@ -539,16 +593,59 @@ function resolveMessageToolSchemaActions(params: {
|
||||
return actions.length > 0 ? actions : ["send"];
|
||||
}
|
||||
|
||||
function resolveIncludeCapability(
|
||||
params: {
|
||||
cfg: OpenClawConfig;
|
||||
currentChannelProvider?: string;
|
||||
},
|
||||
capability: ChannelMessageCapability,
|
||||
): boolean {
|
||||
const currentChannel = normalizeMessageChannel(params.currentChannelProvider);
|
||||
if (currentChannel) {
|
||||
return channelSupportsMessageCapabilityForChannel(
|
||||
{
|
||||
cfg: params.cfg,
|
||||
channel: currentChannel,
|
||||
},
|
||||
capability,
|
||||
);
|
||||
}
|
||||
return channelSupportsMessageCapability(params.cfg, capability);
|
||||
}
|
||||
|
||||
function resolveIncludeComponents(params: {
|
||||
cfg: OpenClawConfig;
|
||||
currentChannelProvider?: string;
|
||||
}): boolean {
|
||||
const currentChannel = normalizeMessageChannel(params.currentChannelProvider);
|
||||
if (currentChannel) {
|
||||
return currentChannel === "discord";
|
||||
}
|
||||
// Components are currently Discord-specific.
|
||||
return listChannelSupportedActions({ cfg: params.cfg, channel: "discord" }).length > 0;
|
||||
return resolveIncludeCapability(params, "components");
|
||||
}
|
||||
|
||||
function resolveIncludeInteractive(params: {
|
||||
cfg: OpenClawConfig;
|
||||
currentChannelProvider?: string;
|
||||
}): boolean {
|
||||
return resolveIncludeCapability(params, "interactive");
|
||||
}
|
||||
|
||||
function resolveIncludeButtons(params: {
|
||||
cfg: OpenClawConfig;
|
||||
currentChannelProvider?: string;
|
||||
}): boolean {
|
||||
return resolveIncludeCapability(params, "buttons");
|
||||
}
|
||||
|
||||
function resolveIncludeCards(params: {
|
||||
cfg: OpenClawConfig;
|
||||
currentChannelProvider?: string;
|
||||
}): boolean {
|
||||
return resolveIncludeCapability(params, "cards");
|
||||
}
|
||||
|
||||
function resolveIncludeBlocks(params: {
|
||||
cfg: OpenClawConfig;
|
||||
currentChannelProvider?: string;
|
||||
}): boolean {
|
||||
return resolveIncludeCapability(params, "blocks");
|
||||
}
|
||||
|
||||
function resolveIncludeTelegramPollExtras(params: {
|
||||
@ -566,20 +663,19 @@ function buildMessageToolSchema(params: {
|
||||
currentChannelProvider?: string;
|
||||
currentChannelId?: string;
|
||||
}) {
|
||||
const currentChannel = normalizeMessageChannel(params.currentChannelProvider);
|
||||
const actions = resolveMessageToolSchemaActions(params);
|
||||
const includeButtons = currentChannel
|
||||
? supportsChannelMessageButtonsForChannel({ cfg: params.cfg, channel: currentChannel })
|
||||
: supportsChannelMessageButtons(params.cfg);
|
||||
const includeCards = currentChannel
|
||||
? supportsChannelMessageCardsForChannel({ cfg: params.cfg, channel: currentChannel })
|
||||
: supportsChannelMessageCards(params.cfg);
|
||||
const includeInteractive = resolveIncludeInteractive(params);
|
||||
const includeButtons = resolveIncludeButtons(params);
|
||||
const includeCards = resolveIncludeCards(params);
|
||||
const includeComponents = resolveIncludeComponents(params);
|
||||
const includeBlocks = resolveIncludeBlocks(params);
|
||||
const includeTelegramPollExtras = resolveIncludeTelegramPollExtras(params);
|
||||
return buildMessageToolSchemaFromActions(actions.length > 0 ? actions : ["send"], {
|
||||
includeInteractive,
|
||||
includeButtons,
|
||||
includeCards,
|
||||
includeComponents,
|
||||
includeBlocks,
|
||||
includeTelegramPollExtras,
|
||||
});
|
||||
}
|
||||
|
||||
@ -32,11 +32,12 @@ export function normalizeReplyPayload(
|
||||
opts: NormalizeReplyOptions = {},
|
||||
): ReplyPayload | null {
|
||||
const hasMedia = Boolean(payload.mediaUrl || (payload.mediaUrls?.length ?? 0) > 0);
|
||||
const hasInteractive = (payload.interactive?.blocks.length ?? 0) > 0;
|
||||
const hasChannelData = Boolean(
|
||||
payload.channelData && Object.keys(payload.channelData).length > 0,
|
||||
);
|
||||
const trimmed = payload.text?.trim() ?? "";
|
||||
if (!trimmed && !hasMedia && !hasChannelData) {
|
||||
if (!trimmed && !hasMedia && !hasInteractive && !hasChannelData) {
|
||||
opts.onSkip?.("empty");
|
||||
return null;
|
||||
}
|
||||
@ -44,7 +45,7 @@ export function normalizeReplyPayload(
|
||||
const silentToken = opts.silentToken ?? SILENT_REPLY_TOKEN;
|
||||
let text = payload.text ?? undefined;
|
||||
if (text && isSilentReplyText(text, silentToken)) {
|
||||
if (!hasMedia && !hasChannelData) {
|
||||
if (!hasMedia && !hasInteractive && !hasChannelData) {
|
||||
opts.onSkip?.("silent");
|
||||
return null;
|
||||
}
|
||||
@ -55,7 +56,7 @@ export function normalizeReplyPayload(
|
||||
// silent just like the exact-match path above. (#30916, #30955)
|
||||
if (text && text.includes(silentToken) && !isSilentReplyText(text, silentToken)) {
|
||||
text = stripSilentToken(text, silentToken);
|
||||
if (!text && !hasMedia && !hasChannelData) {
|
||||
if (!text && !hasMedia && !hasInteractive && !hasChannelData) {
|
||||
opts.onSkip?.("silent");
|
||||
return null;
|
||||
}
|
||||
@ -71,7 +72,7 @@ export function normalizeReplyPayload(
|
||||
if (stripped.didStrip) {
|
||||
opts.onHeartbeatStrip?.();
|
||||
}
|
||||
if (stripped.shouldSkip && !hasMedia && !hasChannelData) {
|
||||
if (stripped.shouldSkip && !hasMedia && !hasInteractive && !hasChannelData) {
|
||||
opts.onSkip?.("heartbeat");
|
||||
return null;
|
||||
}
|
||||
@ -81,7 +82,7 @@ export function normalizeReplyPayload(
|
||||
if (text) {
|
||||
text = sanitizeUserFacingText(text, { errorContext: Boolean(payload.isError) });
|
||||
}
|
||||
if (!text?.trim() && !hasMedia && !hasChannelData) {
|
||||
if (!text?.trim() && !hasMedia && !hasInteractive && !hasChannelData) {
|
||||
opts.onSkip?.("empty");
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -197,8 +197,8 @@ describe("inbound context contract (providers + extensions)", () => {
|
||||
|
||||
const getLineData = (result: ReturnType<typeof parseLineDirectives>) =>
|
||||
(result.channelData?.line as Record<string, unknown> | undefined) ?? {};
|
||||
const getSlackData = (result: ReturnType<typeof parseSlackDirectives>) =>
|
||||
(result.channelData?.slack as Record<string, unknown> | undefined) ?? {};
|
||||
const getSlackInteractive = (result: ReturnType<typeof parseSlackDirectives>) =>
|
||||
result.interactive?.blocks ?? [];
|
||||
|
||||
describe("hasLineDirectives", () => {
|
||||
it("matches expected detection across directive patterns", () => {
|
||||
@ -601,93 +601,52 @@ describe("parseLineDirectives", () => {
|
||||
});
|
||||
|
||||
describe("parseSlackDirectives", () => {
|
||||
it("builds section and button blocks from slack_buttons directives", () => {
|
||||
it("builds shared text and button blocks from slack_buttons directives", () => {
|
||||
const result = parseSlackDirectives({
|
||||
text: "Choose an action [[slack_buttons: Approve:approve, Reject:reject]]",
|
||||
});
|
||||
|
||||
expect(result.text).toBe("Choose an action");
|
||||
expect(getSlackData(result).blocks).toEqual([
|
||||
expect(getSlackInteractive(result)).toEqual([
|
||||
{
|
||||
type: "section",
|
||||
text: {
|
||||
type: "mrkdwn",
|
||||
text: "Choose an action",
|
||||
},
|
||||
type: "text",
|
||||
text: "Choose an action",
|
||||
},
|
||||
{
|
||||
type: "actions",
|
||||
block_id: "openclaw_reply_buttons_1",
|
||||
elements: [
|
||||
type: "buttons",
|
||||
buttons: [
|
||||
{
|
||||
type: "button",
|
||||
action_id: "openclaw:reply_button",
|
||||
text: {
|
||||
type: "plain_text",
|
||||
text: "Approve",
|
||||
emoji: true,
|
||||
},
|
||||
value: "reply_1_approve",
|
||||
label: "Approve",
|
||||
value: "approve",
|
||||
},
|
||||
{
|
||||
type: "button",
|
||||
action_id: "openclaw:reply_button",
|
||||
text: {
|
||||
type: "plain_text",
|
||||
text: "Reject",
|
||||
emoji: true,
|
||||
},
|
||||
value: "reply_2_reject",
|
||||
label: "Reject",
|
||||
value: "reject",
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("builds static select blocks from slack_select directives", () => {
|
||||
it("builds shared select blocks from slack_select directives", () => {
|
||||
const result = parseSlackDirectives({
|
||||
text: "[[slack_select: Choose a project | Alpha:alpha, Beta:beta]]",
|
||||
});
|
||||
|
||||
expect(result.text).toBeUndefined();
|
||||
expect(getSlackData(result).blocks).toEqual([
|
||||
expect(getSlackInteractive(result)).toEqual([
|
||||
{
|
||||
type: "actions",
|
||||
block_id: "openclaw_reply_select_1",
|
||||
elements: [
|
||||
{
|
||||
type: "static_select",
|
||||
action_id: "openclaw:reply_select",
|
||||
placeholder: {
|
||||
type: "plain_text",
|
||||
text: "Choose a project",
|
||||
emoji: true,
|
||||
},
|
||||
options: [
|
||||
{
|
||||
text: {
|
||||
type: "plain_text",
|
||||
text: "Alpha",
|
||||
emoji: true,
|
||||
},
|
||||
value: "reply_1_alpha",
|
||||
},
|
||||
{
|
||||
text: {
|
||||
type: "plain_text",
|
||||
text: "Beta",
|
||||
emoji: true,
|
||||
},
|
||||
value: "reply_2_beta",
|
||||
},
|
||||
],
|
||||
},
|
||||
type: "select",
|
||||
placeholder: "Choose a project",
|
||||
options: [
|
||||
{ label: "Alpha", value: "alpha" },
|
||||
{ label: "Beta", value: "beta" },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("appends Slack interactive blocks to existing slack blocks", () => {
|
||||
it("leaves existing slack blocks in channelData and appends shared interactive blocks", () => {
|
||||
const result = parseSlackDirectives({
|
||||
text: "Act now [[slack_buttons: Retry:retry]]",
|
||||
channelData: {
|
||||
@ -698,30 +657,19 @@ describe("parseSlackDirectives", () => {
|
||||
});
|
||||
|
||||
expect(result.text).toBe("Act now");
|
||||
expect(getSlackData(result).blocks).toEqual([
|
||||
{ type: "divider" },
|
||||
expect(result.channelData).toEqual({
|
||||
slack: {
|
||||
blocks: [{ type: "divider" }],
|
||||
},
|
||||
});
|
||||
expect(getSlackInteractive(result)).toEqual([
|
||||
{
|
||||
type: "section",
|
||||
text: {
|
||||
type: "mrkdwn",
|
||||
text: "Act now",
|
||||
},
|
||||
type: "text",
|
||||
text: "Act now",
|
||||
},
|
||||
{
|
||||
type: "actions",
|
||||
block_id: "openclaw_reply_buttons_1",
|
||||
elements: [
|
||||
{
|
||||
type: "button",
|
||||
action_id: "openclaw:reply_button",
|
||||
text: {
|
||||
type: "plain_text",
|
||||
text: "Retry",
|
||||
emoji: true,
|
||||
},
|
||||
value: "reply_1_retry",
|
||||
},
|
||||
],
|
||||
type: "buttons",
|
||||
buttons: [{ label: "Retry", value: "retry" }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
@ -731,146 +679,70 @@ describe("parseSlackDirectives", () => {
|
||||
text: "[[slack_select: Pick one | Alpha:alpha]] then [[slack_buttons: Retry:retry]]",
|
||||
});
|
||||
|
||||
expect(getSlackData(result).blocks).toEqual([
|
||||
expect(getSlackInteractive(result)).toEqual([
|
||||
{
|
||||
type: "actions",
|
||||
block_id: "openclaw_reply_select_1",
|
||||
elements: [
|
||||
{
|
||||
type: "static_select",
|
||||
action_id: "openclaw:reply_select",
|
||||
placeholder: {
|
||||
type: "plain_text",
|
||||
text: "Pick one",
|
||||
emoji: true,
|
||||
},
|
||||
options: [
|
||||
{
|
||||
text: {
|
||||
type: "plain_text",
|
||||
text: "Alpha",
|
||||
emoji: true,
|
||||
},
|
||||
value: "reply_1_alpha",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
type: "select",
|
||||
placeholder: "Pick one",
|
||||
options: [{ label: "Alpha", value: "alpha" }],
|
||||
},
|
||||
{
|
||||
type: "section",
|
||||
text: {
|
||||
type: "mrkdwn",
|
||||
text: "then",
|
||||
},
|
||||
type: "text",
|
||||
text: "then",
|
||||
},
|
||||
{
|
||||
type: "actions",
|
||||
block_id: "openclaw_reply_buttons_1",
|
||||
elements: [
|
||||
{
|
||||
type: "button",
|
||||
action_id: "openclaw:reply_button",
|
||||
text: {
|
||||
type: "plain_text",
|
||||
text: "Retry",
|
||||
emoji: true,
|
||||
},
|
||||
value: "reply_1_retry",
|
||||
},
|
||||
],
|
||||
type: "buttons",
|
||||
buttons: [{ label: "Retry", value: "retry" }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("truncates Slack interactive reply strings to safe Block Kit limits", () => {
|
||||
it("preserves long Slack directive values in the shared interactive model", () => {
|
||||
const long = "x".repeat(120);
|
||||
const result = parseSlackDirectives({
|
||||
text: `${"y".repeat(3100)} [[slack_select: ${long} | ${long}:${long}]] [[slack_buttons: ${long}:${long}]]`,
|
||||
});
|
||||
|
||||
const blocks = getSlackData(result).blocks as Array<Record<string, unknown>>;
|
||||
expect(blocks).toHaveLength(3);
|
||||
expect(((blocks[0]?.text as { text?: string })?.text ?? "").length).toBeLessThanOrEqual(3000);
|
||||
expect(
|
||||
(
|
||||
(
|
||||
(blocks[1]?.elements as Array<Record<string, unknown>>)?.[0]?.placeholder as {
|
||||
text?: string;
|
||||
}
|
||||
)?.text ?? ""
|
||||
).length,
|
||||
).toBeLessThanOrEqual(75);
|
||||
expect(
|
||||
(
|
||||
(
|
||||
(
|
||||
(blocks[1]?.elements as Array<Record<string, unknown>>)?.[0]?.options as Array<
|
||||
Record<string, unknown>
|
||||
>
|
||||
)?.[0]?.text as { text?: string }
|
||||
)?.text ?? ""
|
||||
).length,
|
||||
).toBeLessThanOrEqual(75);
|
||||
expect(
|
||||
(
|
||||
((
|
||||
(blocks[1]?.elements as Array<Record<string, unknown>>)?.[0]?.options as Array<
|
||||
Record<string, unknown>
|
||||
>
|
||||
)?.[0]?.value as string | undefined) ?? ""
|
||||
).length,
|
||||
).toBeLessThanOrEqual(75);
|
||||
expect(
|
||||
(
|
||||
(
|
||||
(blocks[2]?.elements as Array<Record<string, unknown>>)?.[0]?.text as {
|
||||
text?: string;
|
||||
}
|
||||
)?.text ?? ""
|
||||
).length,
|
||||
).toBeLessThanOrEqual(75);
|
||||
expect(
|
||||
(
|
||||
((blocks[2]?.elements as Array<Record<string, unknown>>)?.[0]?.value as
|
||||
| string
|
||||
| undefined) ?? ""
|
||||
).length,
|
||||
).toBeLessThanOrEqual(75);
|
||||
expect(getSlackInteractive(result)).toEqual([
|
||||
{
|
||||
type: "text",
|
||||
text: "y".repeat(3100),
|
||||
},
|
||||
{
|
||||
type: "select",
|
||||
placeholder: long,
|
||||
options: [{ label: long, value: long }],
|
||||
},
|
||||
{
|
||||
type: "buttons",
|
||||
buttons: [{ label: long, value: long }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("falls back to the original payload when generated blocks would exceed Slack limits", () => {
|
||||
it("keeps existing interactive blocks when compiling additional Slack directives", () => {
|
||||
const result = parseSlackDirectives({
|
||||
text: "Choose [[slack_buttons: Retry:retry]]",
|
||||
channelData: {
|
||||
slack: {
|
||||
blocks: Array.from({ length: 49 }, () => ({ type: "divider" })),
|
||||
},
|
||||
interactive: {
|
||||
blocks: [{ type: "text", text: "Existing" }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(getSlackInteractive(result)).toEqual([
|
||||
{ type: "text", text: "Existing" },
|
||||
{ type: "text", text: "Choose" },
|
||||
{ type: "buttons", buttons: [{ label: "Retry", value: "retry" }] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignores malformed directive choices when none remain", () => {
|
||||
const result = parseSlackDirectives({
|
||||
text: "Choose [[slack_buttons: : , : ]]",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
text: "Choose [[slack_buttons: Retry:retry]]",
|
||||
channelData: {
|
||||
slack: {
|
||||
blocks: Array.from({ length: 49 }, () => ({ type: "divider" })),
|
||||
},
|
||||
},
|
||||
text: "Choose [[slack_buttons: : , : ]]",
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores malformed existing Slack blocks during directive compilation", () => {
|
||||
expect(() =>
|
||||
parseSlackDirectives({
|
||||
text: "Choose [[slack_buttons: Retry:retry]]",
|
||||
channelData: {
|
||||
slack: {
|
||||
blocks: "{not json}",
|
||||
},
|
||||
},
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
function createDeferred<T>() {
|
||||
@ -1796,22 +1668,17 @@ describe("createReplyDispatcher", () => {
|
||||
expect(deliver).toHaveBeenCalledTimes(1);
|
||||
expect(deliver.mock.calls[0]?.[0]).toMatchObject({
|
||||
text: "Choose",
|
||||
channelData: {
|
||||
slack: {
|
||||
blocks: [
|
||||
{
|
||||
type: "section",
|
||||
text: {
|
||||
type: "mrkdwn",
|
||||
text: "Choose",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "actions",
|
||||
block_id: "openclaw_reply_buttons_1",
|
||||
},
|
||||
],
|
||||
},
|
||||
interactive: {
|
||||
blocks: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Choose",
|
||||
},
|
||||
{
|
||||
type: "buttons",
|
||||
buttons: [{ label: "Retry", value: "retry" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@ -79,6 +79,7 @@ export function isRenderablePayload(payload: ReplyPayload): boolean {
|
||||
payload.mediaUrl ||
|
||||
(payload.mediaUrls && payload.mediaUrls.length > 0) ||
|
||||
payload.audioAsVoice ||
|
||||
payload.interactive ||
|
||||
payload.channelData,
|
||||
);
|
||||
}
|
||||
|
||||
@ -158,10 +158,10 @@ describe("normalizeReplyPayload", () => {
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.text).toBe("hello [[slack_buttons: Retry:retry, Ignore:ignore]]");
|
||||
expect(result!.channelData).toBeUndefined();
|
||||
expect(result!.interactive).toBeUndefined();
|
||||
});
|
||||
|
||||
it("applies responsePrefix before compiling Slack directives into blocks", () => {
|
||||
it("applies responsePrefix before compiling Slack directives into shared interactive blocks", () => {
|
||||
const result = normalizeReplyPayload(
|
||||
{
|
||||
text: "hello [[slack_buttons: Retry:retry, Ignore:ignore]]",
|
||||
@ -171,44 +171,26 @@ describe("normalizeReplyPayload", () => {
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.text).toBe("[bot] hello");
|
||||
expect(result!.channelData).toEqual({
|
||||
slack: {
|
||||
blocks: [
|
||||
{
|
||||
type: "section",
|
||||
text: {
|
||||
type: "mrkdwn",
|
||||
text: "[bot] hello",
|
||||
expect(result!.interactive).toEqual({
|
||||
blocks: [
|
||||
{
|
||||
type: "text",
|
||||
text: "[bot] hello",
|
||||
},
|
||||
{
|
||||
type: "buttons",
|
||||
buttons: [
|
||||
{
|
||||
label: "Retry",
|
||||
value: "retry",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "actions",
|
||||
block_id: "openclaw_reply_buttons_1",
|
||||
elements: [
|
||||
{
|
||||
type: "button",
|
||||
action_id: "openclaw:reply_button",
|
||||
text: {
|
||||
type: "plain_text",
|
||||
text: "Retry",
|
||||
emoji: true,
|
||||
},
|
||||
value: "reply_1_retry",
|
||||
},
|
||||
{
|
||||
type: "button",
|
||||
action_id: "openclaw:reply_button",
|
||||
text: {
|
||||
type: "plain_text",
|
||||
text: "Ignore",
|
||||
emoji: true,
|
||||
},
|
||||
value: "reply_2_ignore",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Ignore",
|
||||
value: "ignore",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -117,6 +117,7 @@ export async function routeReply(params: RouteReplyParams): Promise<RouteReplyRe
|
||||
? [externalPayload.mediaUrl]
|
||||
: [];
|
||||
const replyToId = externalPayload.replyToId;
|
||||
const hasInteractive = (externalPayload.interactive?.blocks.length ?? 0) > 0;
|
||||
let hasSlackBlocks = false;
|
||||
if (
|
||||
channel === "slack" &&
|
||||
@ -135,7 +136,7 @@ export async function routeReply(params: RouteReplyParams): Promise<RouteReplyRe
|
||||
}
|
||||
|
||||
// Skip empty replies.
|
||||
if (!text.trim() && mediaUrls.length === 0 && !hasSlackBlocks) {
|
||||
if (!text.trim() && mediaUrls.length === 0 && !hasInteractive && !hasSlackBlocks) {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
|
||||
@ -1,22 +1,9 @@
|
||||
import { parseSlackBlocksInput } from "../../../extensions/slack/src/blocks-input.js";
|
||||
import { truncateSlackText } from "../../../extensions/slack/src/truncate.js";
|
||||
import type { ReplyPayload } from "../types.js";
|
||||
|
||||
const SLACK_REPLY_BUTTON_ACTION_ID = "openclaw:reply_button";
|
||||
const SLACK_REPLY_SELECT_ACTION_ID = "openclaw:reply_select";
|
||||
const SLACK_MAX_BLOCKS = 50;
|
||||
const SLACK_BUTTON_MAX_ITEMS = 5;
|
||||
const SLACK_SELECT_MAX_ITEMS = 100;
|
||||
const SLACK_SECTION_TEXT_MAX = 3000;
|
||||
const SLACK_PLAIN_TEXT_MAX = 75;
|
||||
const SLACK_OPTION_VALUE_MAX = 75;
|
||||
const SLACK_DIRECTIVE_RE = /\[\[(slack_buttons|slack_select):\s*([^\]]+)\]\]/gi;
|
||||
|
||||
type SlackBlock = Record<string, unknown>;
|
||||
type SlackChannelData = {
|
||||
blocks?: unknown;
|
||||
};
|
||||
|
||||
type SlackChoice = {
|
||||
label: string;
|
||||
value: string;
|
||||
@ -50,51 +37,35 @@ function parseChoices(raw: string, maxItems: number): SlackChoice[] {
|
||||
.slice(0, maxItems);
|
||||
}
|
||||
|
||||
function buildSlackReplyChoiceToken(value: string, index: number): string {
|
||||
const slug = value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "_")
|
||||
.replace(/^_+|_+$/g, "");
|
||||
return truncateSlackText(`reply_${index}_${slug || "choice"}`, SLACK_OPTION_VALUE_MAX);
|
||||
}
|
||||
|
||||
function buildSectionBlock(text: string): SlackBlock | null {
|
||||
function buildTextBlock(
|
||||
text: string,
|
||||
): NonNullable<ReplyPayload["interactive"]>["blocks"][number] | null {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
type: "section",
|
||||
text: {
|
||||
type: "mrkdwn",
|
||||
text: truncateSlackText(trimmed, SLACK_SECTION_TEXT_MAX),
|
||||
},
|
||||
};
|
||||
return { type: "text", text: trimmed };
|
||||
}
|
||||
|
||||
function buildButtonsBlock(raw: string, index: number): SlackBlock | null {
|
||||
function buildButtonsBlock(
|
||||
raw: string,
|
||||
): NonNullable<ReplyPayload["interactive"]>["blocks"][number] | null {
|
||||
const choices = parseChoices(raw, SLACK_BUTTON_MAX_ITEMS);
|
||||
if (choices.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
type: "actions",
|
||||
block_id: `openclaw_reply_buttons_${index}`,
|
||||
elements: choices.map((choice, choiceIndex) => ({
|
||||
type: "button",
|
||||
action_id: SLACK_REPLY_BUTTON_ACTION_ID,
|
||||
text: {
|
||||
type: "plain_text",
|
||||
text: truncateSlackText(choice.label, SLACK_PLAIN_TEXT_MAX),
|
||||
emoji: true,
|
||||
},
|
||||
value: buildSlackReplyChoiceToken(choice.value, choiceIndex + 1),
|
||||
type: "buttons",
|
||||
buttons: choices.map((choice) => ({
|
||||
label: choice.label,
|
||||
value: choice.value,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function buildSelectBlock(raw: string, index: number): SlackBlock | null {
|
||||
function buildSelectBlock(
|
||||
raw: string,
|
||||
): NonNullable<ReplyPayload["interactive"]>["blocks"][number] | null {
|
||||
const parts = raw
|
||||
.split("|")
|
||||
.map((entry) => entry.trim())
|
||||
@ -109,40 +80,12 @@ function buildSelectBlock(raw: string, index: number): SlackBlock | null {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
type: "actions",
|
||||
block_id: `openclaw_reply_select_${index}`,
|
||||
elements: [
|
||||
{
|
||||
type: "static_select",
|
||||
action_id: SLACK_REPLY_SELECT_ACTION_ID,
|
||||
placeholder: {
|
||||
type: "plain_text",
|
||||
text: truncateSlackText(placeholder, SLACK_PLAIN_TEXT_MAX),
|
||||
emoji: true,
|
||||
},
|
||||
options: choices.map((choice, choiceIndex) => ({
|
||||
text: {
|
||||
type: "plain_text",
|
||||
text: truncateSlackText(choice.label, SLACK_PLAIN_TEXT_MAX),
|
||||
emoji: true,
|
||||
},
|
||||
value: buildSlackReplyChoiceToken(choice.value, choiceIndex + 1),
|
||||
})),
|
||||
},
|
||||
],
|
||||
type: "select",
|
||||
placeholder,
|
||||
options: choices,
|
||||
};
|
||||
}
|
||||
|
||||
function readExistingSlackBlocks(payload: ReplyPayload): SlackBlock[] {
|
||||
const slackData = payload.channelData?.slack as SlackChannelData | undefined;
|
||||
try {
|
||||
const blocks = parseSlackBlocksInput(slackData?.blocks) as SlackBlock[] | undefined;
|
||||
return blocks ?? [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function hasSlackDirectives(text: string): boolean {
|
||||
SLACK_DIRECTIVE_RE.lastIndex = 0;
|
||||
return SLACK_DIRECTIVE_RE.test(text);
|
||||
@ -154,10 +97,8 @@ export function parseSlackDirectives(payload: ReplyPayload): ReplyPayload {
|
||||
return payload;
|
||||
}
|
||||
|
||||
const generatedBlocks: SlackBlock[] = [];
|
||||
const generatedBlocks: NonNullable<ReplyPayload["interactive"]>["blocks"] = [];
|
||||
const visibleTextParts: string[] = [];
|
||||
let buttonIndex = 0;
|
||||
let selectIndex = 0;
|
||||
let cursor = 0;
|
||||
let matchedDirective = false;
|
||||
let generatedInteractiveBlock = false;
|
||||
@ -171,14 +112,14 @@ export function parseSlackDirectives(payload: ReplyPayload): ReplyPayload {
|
||||
const index = match.index ?? 0;
|
||||
const precedingText = text.slice(cursor, index);
|
||||
visibleTextParts.push(precedingText);
|
||||
const section = buildSectionBlock(precedingText);
|
||||
const section = buildTextBlock(precedingText);
|
||||
if (section) {
|
||||
generatedBlocks.push(section);
|
||||
}
|
||||
const block =
|
||||
directiveType.toLowerCase() === "slack_buttons"
|
||||
? buildButtonsBlock(body, ++buttonIndex)
|
||||
: buildSelectBlock(body, ++selectIndex);
|
||||
? buildButtonsBlock(body)
|
||||
: buildSelectBlock(body);
|
||||
if (block) {
|
||||
generatedInteractiveBlock = true;
|
||||
generatedBlocks.push(block);
|
||||
@ -188,7 +129,7 @@ export function parseSlackDirectives(payload: ReplyPayload): ReplyPayload {
|
||||
|
||||
const trailingText = text.slice(cursor);
|
||||
visibleTextParts.push(trailingText);
|
||||
const trailingSection = buildSectionBlock(trailingText);
|
||||
const trailingSection = buildTextBlock(trailingText);
|
||||
if (trailingSection) {
|
||||
generatedBlocks.push(trailingSection);
|
||||
}
|
||||
@ -198,21 +139,11 @@ export function parseSlackDirectives(payload: ReplyPayload): ReplyPayload {
|
||||
return payload;
|
||||
}
|
||||
|
||||
const existingBlocks = readExistingSlackBlocks(payload);
|
||||
if (existingBlocks.length + generatedBlocks.length > SLACK_MAX_BLOCKS) {
|
||||
return payload;
|
||||
}
|
||||
const nextBlocks = [...existingBlocks, ...generatedBlocks];
|
||||
|
||||
return {
|
||||
...payload,
|
||||
text: cleanedText.trim() || undefined,
|
||||
channelData: {
|
||||
...payload.channelData,
|
||||
slack: {
|
||||
...(payload.channelData?.slack as Record<string, unknown> | undefined),
|
||||
blocks: nextBlocks,
|
||||
},
|
||||
interactive: {
|
||||
blocks: [...(payload.interactive?.blocks ?? []), ...generatedBlocks],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import type { ImageContent } from "@mariozechner/pi-ai";
|
||||
import type { InteractiveReply } from "../interactive/payload.js";
|
||||
import type { TypingController } from "./reply/typing.js";
|
||||
|
||||
export type BlockReplyContext = {
|
||||
@ -76,6 +77,7 @@ export type ReplyPayload = {
|
||||
text?: string;
|
||||
mediaUrl?: string;
|
||||
mediaUrls?: string[];
|
||||
interactive?: InteractiveReply;
|
||||
btw?: {
|
||||
question: string;
|
||||
};
|
||||
|
||||
@ -1292,6 +1292,25 @@ describe("slack actions adapter", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("does not attach empty blocks to plain media sends", async () => {
|
||||
handleSlackAction.mockClear();
|
||||
|
||||
await runSlackAction("send", {
|
||||
to: "channel:C1",
|
||||
message: "",
|
||||
media: "https://example.com/image.png",
|
||||
});
|
||||
|
||||
const [params] = handleSlackAction.mock.calls[0] ?? [];
|
||||
expect(params).toMatchObject({
|
||||
action: "sendMessage",
|
||||
to: "channel:C1",
|
||||
content: "",
|
||||
mediaUrl: "https://example.com/image.png",
|
||||
});
|
||||
expect(params).not.toHaveProperty("blocks");
|
||||
});
|
||||
|
||||
it("rejects edit when both message and blocks are missing", async () => {
|
||||
const { cfg, actions } = slackHarness();
|
||||
|
||||
|
||||
@ -6,19 +6,19 @@ import {
|
||||
createTestRegistry,
|
||||
} from "../../test-utils/channel-plugins.js";
|
||||
import {
|
||||
supportsChannelMessageButtons,
|
||||
supportsChannelMessageButtonsForChannel,
|
||||
supportsChannelMessageCards,
|
||||
supportsChannelMessageCardsForChannel,
|
||||
channelSupportsMessageCapability,
|
||||
channelSupportsMessageCapabilityForChannel,
|
||||
listChannelMessageCapabilities,
|
||||
listChannelMessageCapabilitiesForChannel,
|
||||
} from "./message-actions.js";
|
||||
import type { ChannelMessageCapability } from "./message-capabilities.js";
|
||||
import type { ChannelPlugin } from "./types.js";
|
||||
|
||||
const emptyRegistry = createTestRegistry([]);
|
||||
|
||||
function createMessageActionsPlugin(params: {
|
||||
id: "discord" | "telegram";
|
||||
supportsButtons: boolean;
|
||||
supportsCards: boolean;
|
||||
capabilities: readonly ChannelMessageCapability[];
|
||||
}): ChannelPlugin {
|
||||
return {
|
||||
...createChannelTestPluginBase({
|
||||
@ -31,22 +31,19 @@ function createMessageActionsPlugin(params: {
|
||||
}),
|
||||
actions: {
|
||||
listActions: () => ["send"],
|
||||
supportsButtons: () => params.supportsButtons,
|
||||
supportsCards: () => params.supportsCards,
|
||||
getCapabilities: () => params.capabilities,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const buttonsPlugin = createMessageActionsPlugin({
|
||||
id: "discord",
|
||||
supportsButtons: true,
|
||||
supportsCards: false,
|
||||
capabilities: ["interactive", "buttons"],
|
||||
});
|
||||
|
||||
const cardsPlugin = createMessageActionsPlugin({
|
||||
id: "telegram",
|
||||
supportsButtons: false,
|
||||
supportsCards: true,
|
||||
capabilities: ["cards"],
|
||||
});
|
||||
|
||||
function activateMessageActionTestRegistry() {
|
||||
@ -63,25 +60,66 @@ describe("message action capability checks", () => {
|
||||
setActivePluginRegistry(emptyRegistry);
|
||||
});
|
||||
|
||||
it("aggregates buttons/card support across plugins", () => {
|
||||
it("aggregates capabilities across plugins", () => {
|
||||
activateMessageActionTestRegistry();
|
||||
|
||||
expect(supportsChannelMessageButtons({} as OpenClawConfig)).toBe(true);
|
||||
expect(supportsChannelMessageCards({} as OpenClawConfig)).toBe(true);
|
||||
expect(listChannelMessageCapabilities({} as OpenClawConfig).toSorted()).toEqual([
|
||||
"buttons",
|
||||
"cards",
|
||||
"interactive",
|
||||
]);
|
||||
expect(channelSupportsMessageCapability({} as OpenClawConfig, "interactive")).toBe(true);
|
||||
expect(channelSupportsMessageCapability({} as OpenClawConfig, "buttons")).toBe(true);
|
||||
expect(channelSupportsMessageCapability({} as OpenClawConfig, "cards")).toBe(true);
|
||||
});
|
||||
|
||||
it("checks per-channel capabilities", () => {
|
||||
activateMessageActionTestRegistry();
|
||||
|
||||
expect(
|
||||
supportsChannelMessageButtonsForChannel({ cfg: {} as OpenClawConfig, channel: "discord" }),
|
||||
listChannelMessageCapabilitiesForChannel({
|
||||
cfg: {} as OpenClawConfig,
|
||||
channel: "discord",
|
||||
}),
|
||||
).toEqual(["interactive", "buttons"]);
|
||||
expect(
|
||||
listChannelMessageCapabilitiesForChannel({
|
||||
cfg: {} as OpenClawConfig,
|
||||
channel: "telegram",
|
||||
}),
|
||||
).toEqual(["cards"]);
|
||||
expect(
|
||||
channelSupportsMessageCapabilityForChannel(
|
||||
{ cfg: {} as OpenClawConfig, channel: "discord" },
|
||||
"interactive",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
supportsChannelMessageButtonsForChannel({ cfg: {} as OpenClawConfig, channel: "telegram" }),
|
||||
channelSupportsMessageCapabilityForChannel(
|
||||
{ cfg: {} as OpenClawConfig, channel: "telegram" },
|
||||
"interactive",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
supportsChannelMessageCardsForChannel({ cfg: {} as OpenClawConfig, channel: "telegram" }),
|
||||
channelSupportsMessageCapabilityForChannel(
|
||||
{ cfg: {} as OpenClawConfig, channel: "discord" },
|
||||
"buttons",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(supportsChannelMessageCardsForChannel({ cfg: {} as OpenClawConfig })).toBe(false);
|
||||
expect(
|
||||
channelSupportsMessageCapabilityForChannel(
|
||||
{ cfg: {} as OpenClawConfig, channel: "telegram" },
|
||||
"buttons",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
channelSupportsMessageCapabilityForChannel(
|
||||
{ cfg: {} as OpenClawConfig, channel: "telegram" },
|
||||
"cards",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(channelSupportsMessageCapabilityForChannel({ cfg: {} as OpenClawConfig }, "cards")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { getChannelPlugin, listChannelPlugins } from "./index.js";
|
||||
import type { ChannelMessageCapability } from "./message-capabilities.js";
|
||||
import type { ChannelMessageActionContext, ChannelMessageActionName } from "./types.js";
|
||||
|
||||
const trustedRequesterRequiredByChannel: Readonly<
|
||||
@ -30,58 +31,52 @@ export function listChannelMessageActions(cfg: OpenClawConfig): ChannelMessageAc
|
||||
return Array.from(actions);
|
||||
}
|
||||
|
||||
export function supportsChannelMessageButtons(cfg: OpenClawConfig): boolean {
|
||||
return supportsMessageFeature(cfg, (actions) => actions?.supportsButtons?.({ cfg }) === true);
|
||||
}
|
||||
|
||||
export function supportsChannelMessageButtonsForChannel(params: {
|
||||
cfg: OpenClawConfig;
|
||||
channel?: string;
|
||||
}): boolean {
|
||||
return supportsMessageFeatureForChannel(
|
||||
params,
|
||||
(actions) => actions.supportsButtons?.(params) === true,
|
||||
);
|
||||
}
|
||||
|
||||
export function supportsChannelMessageCards(cfg: OpenClawConfig): boolean {
|
||||
return supportsMessageFeature(cfg, (actions) => actions?.supportsCards?.({ cfg }) === true);
|
||||
}
|
||||
|
||||
export function supportsChannelMessageCardsForChannel(params: {
|
||||
cfg: OpenClawConfig;
|
||||
channel?: string;
|
||||
}): boolean {
|
||||
return supportsMessageFeatureForChannel(
|
||||
params,
|
||||
(actions) => actions.supportsCards?.(params) === true,
|
||||
);
|
||||
}
|
||||
|
||||
function supportsMessageFeature(
|
||||
function listCapabilities(
|
||||
actions: ChannelActions,
|
||||
cfg: OpenClawConfig,
|
||||
check: (actions: ChannelActions) => boolean,
|
||||
): boolean {
|
||||
): readonly ChannelMessageCapability[] {
|
||||
return actions.getCapabilities?.({ cfg }) ?? [];
|
||||
}
|
||||
|
||||
export function listChannelMessageCapabilities(cfg: OpenClawConfig): ChannelMessageCapability[] {
|
||||
const capabilities = new Set<ChannelMessageCapability>();
|
||||
for (const plugin of listChannelPlugins()) {
|
||||
if (plugin.actions && check(plugin.actions)) {
|
||||
return true;
|
||||
if (!plugin.actions) {
|
||||
continue;
|
||||
}
|
||||
for (const capability of listCapabilities(plugin.actions, cfg)) {
|
||||
capabilities.add(capability);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
return Array.from(capabilities);
|
||||
}
|
||||
|
||||
function supportsMessageFeatureForChannel(
|
||||
export function listChannelMessageCapabilitiesForChannel(params: {
|
||||
cfg: OpenClawConfig;
|
||||
channel?: string;
|
||||
}): ChannelMessageCapability[] {
|
||||
if (!params.channel) {
|
||||
return [];
|
||||
}
|
||||
const plugin = getChannelPlugin(params.channel as Parameters<typeof getChannelPlugin>[0]);
|
||||
return plugin?.actions ? Array.from(listCapabilities(plugin.actions, params.cfg)) : [];
|
||||
}
|
||||
|
||||
export function channelSupportsMessageCapability(
|
||||
cfg: OpenClawConfig,
|
||||
capability: ChannelMessageCapability,
|
||||
): boolean {
|
||||
return listChannelMessageCapabilities(cfg).includes(capability);
|
||||
}
|
||||
|
||||
export function channelSupportsMessageCapabilityForChannel(
|
||||
params: {
|
||||
cfg: OpenClawConfig;
|
||||
channel?: string;
|
||||
},
|
||||
check: (actions: ChannelActions) => boolean,
|
||||
capability: ChannelMessageCapability,
|
||||
): boolean {
|
||||
if (!params.channel) {
|
||||
return false;
|
||||
}
|
||||
const plugin = getChannelPlugin(params.channel as Parameters<typeof getChannelPlugin>[0]);
|
||||
return plugin?.actions ? check(plugin.actions) : false;
|
||||
return listChannelMessageCapabilitiesForChannel(params).includes(capability);
|
||||
}
|
||||
|
||||
export async function dispatchChannelMessageAction(
|
||||
|
||||
9
src/channels/plugins/message-capabilities.ts
Normal file
9
src/channels/plugins/message-capabilities.ts
Normal file
@ -0,0 +1,9 @@
|
||||
export const CHANNEL_MESSAGE_CAPABILITIES = [
|
||||
"interactive",
|
||||
"buttons",
|
||||
"cards",
|
||||
"components",
|
||||
"blocks",
|
||||
] as const;
|
||||
|
||||
export type ChannelMessageCapability = (typeof CHANNEL_MESSAGE_CAPABILITIES)[number];
|
||||
27
src/channels/plugins/outbound/interactive.test.ts
Normal file
27
src/channels/plugins/outbound/interactive.test.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { reduceInteractiveReply } from "./interactive.js";
|
||||
|
||||
describe("reduceInteractiveReply", () => {
|
||||
it("walks authored blocks in order", () => {
|
||||
const order = reduceInteractiveReply(
|
||||
{
|
||||
blocks: [
|
||||
{ type: "text", text: "first" },
|
||||
{ type: "buttons", buttons: [{ label: "Retry", value: "retry" }] },
|
||||
{ type: "select", options: [{ label: "Alpha", value: "alpha" }] },
|
||||
],
|
||||
},
|
||||
[] as string[],
|
||||
(state, block) => {
|
||||
state.push(block.type);
|
||||
return state;
|
||||
},
|
||||
);
|
||||
|
||||
expect(order).toEqual(["text", "buttons", "select"]);
|
||||
});
|
||||
|
||||
it("returns the initial state when interactive payload is missing", () => {
|
||||
expect(reduceInteractiveReply(undefined, 3, (value) => value + 1)).toBe(3);
|
||||
});
|
||||
});
|
||||
13
src/channels/plugins/outbound/interactive.ts
Normal file
13
src/channels/plugins/outbound/interactive.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import type { InteractiveReply, InteractiveReplyBlock } from "../../../interactive/payload.js";
|
||||
|
||||
export function reduceInteractiveReply<TState>(
|
||||
interactive: InteractiveReply | undefined,
|
||||
initialState: TState,
|
||||
reduce: (state: TState, block: InteractiveReplyBlock, index: number) => TState,
|
||||
): TState {
|
||||
let state = initialState;
|
||||
for (const [index, block] of (interactive?.blocks ?? []).entries()) {
|
||||
state = reduce(state, block, index);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
@ -100,4 +100,71 @@ describe("slackOutbound sendPayload", () => {
|
||||
await expect(run()).rejects.toThrow(/blocks must be an array/i);
|
||||
expect(sendMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends media before a separate interactive blocks message", async () => {
|
||||
const { run, sendMock, to } = createHarness({
|
||||
payload: {
|
||||
text: "Approval required",
|
||||
mediaUrl: "https://example.com/image.png",
|
||||
interactive: {
|
||||
blocks: [
|
||||
{
|
||||
type: "buttons",
|
||||
buttons: [{ label: "Allow", value: "pluginbind:approval-123:o" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
sendResults: [{ messageId: "sl-media" }, { messageId: "sl-controls" }],
|
||||
});
|
||||
|
||||
const result = await run();
|
||||
|
||||
expect(sendMock).toHaveBeenCalledTimes(2);
|
||||
expect(sendMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
to,
|
||||
"",
|
||||
expect.objectContaining({
|
||||
mediaUrl: "https://example.com/image.png",
|
||||
}),
|
||||
);
|
||||
expect(sendMock.mock.calls[0]?.[2]).not.toHaveProperty("blocks");
|
||||
expect(sendMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
to,
|
||||
"Approval required",
|
||||
expect.objectContaining({
|
||||
blocks: [
|
||||
expect.objectContaining({
|
||||
type: "actions",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(result).toMatchObject({ channel: "slack", messageId: "sl-controls" });
|
||||
});
|
||||
|
||||
it("fails when merged Slack blocks exceed the platform limit", async () => {
|
||||
const { run, sendMock } = createHarness({
|
||||
payload: {
|
||||
channelData: {
|
||||
slack: {
|
||||
blocks: Array.from({ length: 50 }, () => ({ type: "divider" })),
|
||||
},
|
||||
},
|
||||
interactive: {
|
||||
blocks: [
|
||||
{
|
||||
type: "buttons",
|
||||
buttons: [{ label: "Allow", value: "pluginbind:approval-123:o" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(run()).rejects.toThrow(/Slack blocks cannot exceed 50 items/i);
|
||||
expect(sendMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,10 +1,34 @@
|
||||
import { parseSlackBlocksInput } from "../../../../extensions/slack/src/blocks-input.js";
|
||||
import {
|
||||
buildSlackInteractiveBlocks,
|
||||
type SlackBlock,
|
||||
} from "../../../../extensions/slack/src/blocks-render.js";
|
||||
import { sendMessageSlack, type SlackSendIdentity } from "../../../../extensions/slack/src/send.js";
|
||||
import type { OutboundIdentity } from "../../../infra/outbound/identity.js";
|
||||
import { resolveOutboundSendDep } from "../../../infra/outbound/send-deps.js";
|
||||
import {
|
||||
resolveInteractiveTextFallback,
|
||||
type InteractiveReply,
|
||||
} from "../../../interactive/payload.js";
|
||||
import { getGlobalHookRunner } from "../../../plugins/hook-runner-global.js";
|
||||
import type { ChannelOutboundAdapter } from "../types.js";
|
||||
import { sendTextMediaPayload } from "./direct-text-media.js";
|
||||
import {
|
||||
resolvePayloadMediaUrls,
|
||||
sendPayloadMediaSequence,
|
||||
sendTextMediaPayload,
|
||||
} from "./direct-text-media.js";
|
||||
|
||||
const SLACK_MAX_BLOCKS = 50;
|
||||
|
||||
function resolveRenderedInteractiveBlocks(
|
||||
interactive?: InteractiveReply,
|
||||
): SlackBlock[] | undefined {
|
||||
if (!interactive) {
|
||||
return undefined;
|
||||
}
|
||||
const blocks = buildSlackInteractiveBlocks(interactive);
|
||||
return blocks.length > 0 ? blocks : undefined;
|
||||
}
|
||||
|
||||
function resolveSlackSendIdentity(identity?: OutboundIdentity): SlackSendIdentity | undefined {
|
||||
if (!identity) {
|
||||
@ -97,12 +121,29 @@ async function sendSlackOutboundMessage(params: {
|
||||
return { channel: "slack" as const, ...result };
|
||||
}
|
||||
|
||||
function resolveSlackBlocks(channelData: Record<string, unknown> | undefined) {
|
||||
const slackData = channelData?.slack;
|
||||
function resolveSlackBlocks(payload: {
|
||||
channelData?: Record<string, unknown>;
|
||||
interactive?: InteractiveReply;
|
||||
}) {
|
||||
const slackData = payload.channelData?.slack;
|
||||
const renderedInteractive = resolveRenderedInteractiveBlocks(payload.interactive);
|
||||
if (!slackData || typeof slackData !== "object" || Array.isArray(slackData)) {
|
||||
return renderedInteractive;
|
||||
}
|
||||
let existingBlocks: SlackBlock[] | undefined;
|
||||
existingBlocks = parseSlackBlocksInput((slackData as { blocks?: unknown }).blocks) as
|
||||
| SlackBlock[]
|
||||
| undefined;
|
||||
const mergedBlocks = [...(existingBlocks ?? []), ...(renderedInteractive ?? [])];
|
||||
if (mergedBlocks.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return parseSlackBlocksInput((slackData as { blocks?: unknown }).blocks);
|
||||
if (mergedBlocks.length > SLACK_MAX_BLOCKS) {
|
||||
throw new Error(
|
||||
`Slack blocks cannot exceed ${SLACK_MAX_BLOCKS} items after interactive render`,
|
||||
);
|
||||
}
|
||||
return mergedBlocks;
|
||||
}
|
||||
|
||||
export const slackOutbound: ChannelOutboundAdapter = {
|
||||
@ -110,15 +151,61 @@ export const slackOutbound: ChannelOutboundAdapter = {
|
||||
chunker: null,
|
||||
textChunkLimit: 4000,
|
||||
sendPayload: async (ctx) => {
|
||||
const blocks = resolveSlackBlocks(ctx.payload.channelData);
|
||||
const payload = {
|
||||
...ctx.payload,
|
||||
text:
|
||||
resolveInteractiveTextFallback({
|
||||
text: ctx.payload.text,
|
||||
interactive: ctx.payload.interactive,
|
||||
}) ?? "",
|
||||
};
|
||||
const blocks = resolveSlackBlocks(payload);
|
||||
if (!blocks) {
|
||||
return await sendTextMediaPayload({ channel: "slack", ctx, adapter: slackOutbound });
|
||||
return await sendTextMediaPayload({
|
||||
channel: "slack",
|
||||
ctx: {
|
||||
...ctx,
|
||||
payload,
|
||||
},
|
||||
adapter: slackOutbound,
|
||||
});
|
||||
}
|
||||
const mediaUrls = resolvePayloadMediaUrls(payload);
|
||||
if (mediaUrls.length === 0) {
|
||||
return await sendSlackOutboundMessage({
|
||||
cfg: ctx.cfg,
|
||||
to: ctx.to,
|
||||
text: payload.text ?? "",
|
||||
mediaLocalRoots: ctx.mediaLocalRoots,
|
||||
blocks,
|
||||
accountId: ctx.accountId,
|
||||
deps: ctx.deps,
|
||||
replyToId: ctx.replyToId,
|
||||
threadId: ctx.threadId,
|
||||
identity: ctx.identity,
|
||||
});
|
||||
}
|
||||
await sendPayloadMediaSequence({
|
||||
text: "",
|
||||
mediaUrls,
|
||||
send: async ({ text, mediaUrl }) =>
|
||||
await sendSlackOutboundMessage({
|
||||
cfg: ctx.cfg,
|
||||
to: ctx.to,
|
||||
text,
|
||||
mediaUrl,
|
||||
mediaLocalRoots: ctx.mediaLocalRoots,
|
||||
accountId: ctx.accountId,
|
||||
deps: ctx.deps,
|
||||
replyToId: ctx.replyToId,
|
||||
threadId: ctx.threadId,
|
||||
identity: ctx.identity,
|
||||
}),
|
||||
});
|
||||
return await sendSlackOutboundMessage({
|
||||
cfg: ctx.cfg,
|
||||
to: ctx.to,
|
||||
text: ctx.payload.text ?? "",
|
||||
mediaUrl: ctx.payload.mediaUrl,
|
||||
text: payload.text ?? "",
|
||||
mediaLocalRoots: ctx.mediaLocalRoots,
|
||||
blocks,
|
||||
accountId: ctx.accountId,
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { isSlackInteractiveRepliesEnabled } from "../../../extensions/slack/src/interactive-replies.js";
|
||||
import {
|
||||
extractSlackToolSend,
|
||||
listSlackMessageActions,
|
||||
@ -10,6 +11,16 @@ import type { ChannelMessageActionAdapter } from "./types.js";
|
||||
export function createSlackActions(providerId: string): ChannelMessageActionAdapter {
|
||||
return {
|
||||
listActions: ({ cfg }) => listSlackMessageActions(cfg),
|
||||
getCapabilities: ({ cfg }) => {
|
||||
const capabilities = new Set<"interactive" | "blocks">();
|
||||
if (listSlackMessageActions(cfg).includes("send")) {
|
||||
capabilities.add("blocks");
|
||||
}
|
||||
if (isSlackInteractiveRepliesEnabled({ cfg })) {
|
||||
capabilities.add("interactive");
|
||||
}
|
||||
return Array.from(capabilities);
|
||||
},
|
||||
extractToolSend: ({ args }) => extractSlackToolSend(args),
|
||||
handleAction: async (ctx) => {
|
||||
return await handleSlackMessageAction({
|
||||
|
||||
@ -7,6 +7,7 @@ import type { GatewayClientMode, GatewayClientName } from "../../utils/message-c
|
||||
import type { ChatType } from "../chat-type.js";
|
||||
import type { ChatChannelId } from "../registry.js";
|
||||
import type { ChannelMessageActionName as ChannelMessageActionNameFromList } from "./message-action-names.js";
|
||||
import type { ChannelMessageCapability } from "./message-capabilities.js";
|
||||
|
||||
export type ChannelId = ChatChannelId | (string & {});
|
||||
|
||||
@ -372,8 +373,7 @@ export type ChannelMessageActionAdapter = {
|
||||
*/
|
||||
listActions?: (params: { cfg: OpenClawConfig }) => ChannelMessageActionName[];
|
||||
supportsAction?: (params: { action: ChannelMessageActionName }) => boolean;
|
||||
supportsButtons?: (params: { cfg: OpenClawConfig }) => boolean;
|
||||
supportsCards?: (params: { cfg: OpenClawConfig }) => boolean;
|
||||
getCapabilities?: (params: { cfg: OpenClawConfig }) => readonly ChannelMessageCapability[];
|
||||
extractToolSend?: (params: { args: Record<string, unknown> }) => ChannelToolSend | null;
|
||||
handleAction?: (ctx: ChannelMessageActionContext) => Promise<AgentToolResult<unknown>>;
|
||||
};
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
import type { ChannelMessageActionName as ChannelMessageActionNameFromList } from "./message-action-names.js";
|
||||
|
||||
export { CHANNEL_MESSAGE_ACTION_NAMES } from "./message-action-names.js";
|
||||
export { CHANNEL_MESSAGE_CAPABILITIES } from "./message-capabilities.js";
|
||||
|
||||
export type ChannelMessageActionName = ChannelMessageActionNameFromList;
|
||||
export type { ChannelMessageCapability } from "./message-capabilities.js";
|
||||
|
||||
export type {
|
||||
ChannelAuthAdapter,
|
||||
|
||||
@ -15,6 +15,10 @@ export function registerMessageSendCommand(message: Command, helpers: MessageCli
|
||||
"--media <path-or-url>",
|
||||
"Attach media (image/audio/video/document). Accepts local paths or URLs.",
|
||||
)
|
||||
.option(
|
||||
"--interactive <json>",
|
||||
"Shared interactive payload as JSON (buttons/selects rendered natively by supported channels)",
|
||||
)
|
||||
.option(
|
||||
"--buttons <json>",
|
||||
"Telegram inline keyboard buttons as JSON (array of button rows)",
|
||||
|
||||
@ -1,14 +1,12 @@
|
||||
import { DEFAULT_HEARTBEAT_ACK_MAX_CHARS } from "../../auto-reply/heartbeat.js";
|
||||
import type { ReplyPayload } from "../../auto-reply/types.js";
|
||||
import { truncateUtf16Safe } from "../../utils.js";
|
||||
import { shouldSkipHeartbeatOnlyDelivery } from "../heartbeat-policy.js";
|
||||
|
||||
type DeliveryPayload = {
|
||||
text?: string;
|
||||
mediaUrl?: string;
|
||||
mediaUrls?: string[];
|
||||
channelData?: Record<string, unknown>;
|
||||
isError?: boolean;
|
||||
};
|
||||
type DeliveryPayload = Pick<
|
||||
ReplyPayload,
|
||||
"text" | "mediaUrl" | "mediaUrls" | "interactive" | "channelData" | "isError"
|
||||
>;
|
||||
|
||||
export function pickSummaryFromOutput(text: string | undefined) {
|
||||
const clean = (text ?? "").trim();
|
||||
@ -65,8 +63,9 @@ export function pickLastDeliverablePayload(payloads: DeliveryPayload[]) {
|
||||
const isDeliverable = (p: DeliveryPayload) => {
|
||||
const text = (p?.text ?? "").trim();
|
||||
const hasMedia = Boolean(p?.mediaUrl) || (p?.mediaUrls?.length ?? 0) > 0;
|
||||
const hasInteractive = (p?.interactive?.blocks?.length ?? 0) > 0;
|
||||
const hasChannelData = Object.keys(p?.channelData ?? {}).length > 0;
|
||||
return text || hasMedia || hasChannelData;
|
||||
return text || hasMedia || hasInteractive || hasChannelData;
|
||||
};
|
||||
for (let i = payloads.length - 1; i >= 0; i--) {
|
||||
if (payloads[i]?.isError) {
|
||||
|
||||
@ -404,6 +404,38 @@ describe("deliverOutboundPayloads", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("renders shared interactive payloads into telegram buttons", async () => {
|
||||
const sendTelegram = vi.fn().mockResolvedValue({ messageId: "m1", chatId: "c1" });
|
||||
|
||||
await deliverTelegramPayload({
|
||||
sendTelegram,
|
||||
payload: {
|
||||
text: "Approval required",
|
||||
interactive: {
|
||||
blocks: [
|
||||
{
|
||||
type: "buttons",
|
||||
buttons: [
|
||||
{ label: "Allow once", value: "allow-once", style: "success" },
|
||||
{ label: "Always allow", value: "allow-always", style: "primary" },
|
||||
{ label: "Deny", value: "deny", style: "danger" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const sendOpts = sendTelegram.mock.calls[0]?.[2] as { buttons?: unknown } | undefined;
|
||||
expect(sendOpts?.buttons).toEqual([
|
||||
[
|
||||
{ text: "Allow once", callback_data: "allow-once", style: "success" },
|
||||
{ text: "Always allow", callback_data: "allow-always", style: "primary" },
|
||||
{ text: "Deny", callback_data: "deny", style: "danger" },
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it("scopes media local roots to the active agent workspace when agentId is provided", async () => {
|
||||
const sendTelegram = vi.fn().mockResolvedValue({ messageId: "m1", chatId: "c1" });
|
||||
|
||||
|
||||
@ -238,17 +238,22 @@ function hasChannelDataPayload(payload: ReplyPayload): boolean {
|
||||
return Boolean(payload.channelData && Object.keys(payload.channelData).length > 0);
|
||||
}
|
||||
|
||||
function hasInteractivePayload(payload: ReplyPayload): boolean {
|
||||
return (payload.interactive?.blocks.length ?? 0) > 0;
|
||||
}
|
||||
|
||||
function normalizePayloadForChannelDelivery(
|
||||
payload: ReplyPayload,
|
||||
channelId: string,
|
||||
): ReplyPayload | null {
|
||||
const hasMedia = hasMediaPayload(payload);
|
||||
const hasChannelData = hasChannelDataPayload(payload);
|
||||
const hasInteractive = hasInteractivePayload(payload);
|
||||
const rawText = typeof payload.text === "string" ? payload.text : "";
|
||||
const normalizedText =
|
||||
channelId === "whatsapp" ? rawText.replace(/^(?:[ \t]*\r?\n)+/, "") : rawText;
|
||||
if (!normalizedText.trim()) {
|
||||
if (!hasMedia && !hasChannelData) {
|
||||
if (!hasMedia && !hasInteractive && !hasChannelData) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
@ -299,6 +304,7 @@ function buildPayloadSummary(payload: ReplyPayload): NormalizedOutboundPayload {
|
||||
return {
|
||||
text: payload.text ?? "",
|
||||
mediaUrls: payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []),
|
||||
interactive: payload.interactive,
|
||||
channelData: payload.channelData,
|
||||
};
|
||||
}
|
||||
@ -697,7 +703,10 @@ async function deliverOutboundPayloadsCore(
|
||||
threadId: params.threadId ?? undefined,
|
||||
forceDocument: params.forceDocument,
|
||||
};
|
||||
if (handler.sendPayload && effectivePayload.channelData) {
|
||||
if (
|
||||
handler.sendPayload &&
|
||||
(effectivePayload.channelData || hasInteractivePayload(effectivePayload))
|
||||
) {
|
||||
const delivery = await handler.sendPayload(effectivePayload, sendOverrides);
|
||||
results.push(delivery);
|
||||
emitMessageSent({
|
||||
|
||||
@ -422,3 +422,20 @@ export function parseComponentsParam(params: Record<string, unknown>): void {
|
||||
throw new Error("--components must be valid JSON");
|
||||
}
|
||||
}
|
||||
|
||||
export function parseInteractiveParam(params: Record<string, unknown>): void {
|
||||
const raw = params.interactive;
|
||||
if (typeof raw !== "string") {
|
||||
return;
|
||||
}
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
delete params.interactive;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
params.interactive = JSON.parse(trimmed) as unknown;
|
||||
} catch {
|
||||
throw new Error("--interactive must be valid JSON");
|
||||
}
|
||||
}
|
||||
|
||||
@ -186,6 +186,46 @@ describe("runMessageAction context isolation", () => {
|
||||
).rejects.toThrow(/message required/i);
|
||||
});
|
||||
|
||||
it("allows send when only shared interactive payloads are provided", async () => {
|
||||
const result = await runDrySend({
|
||||
cfg: {
|
||||
channels: {
|
||||
telegram: {
|
||||
botToken: "telegram-test",
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
actionParams: {
|
||||
channel: "telegram",
|
||||
target: "123456",
|
||||
interactive: {
|
||||
blocks: [
|
||||
{
|
||||
type: "buttons",
|
||||
buttons: [{ label: "Approve", value: "approve" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.kind).toBe("send");
|
||||
});
|
||||
|
||||
it("allows send when only Slack blocks are provided", async () => {
|
||||
const result = await runDrySend({
|
||||
cfg: slackConfig,
|
||||
actionParams: {
|
||||
channel: "slack",
|
||||
target: "#C12345678",
|
||||
blocks: [{ type: "divider" }],
|
||||
},
|
||||
toolContext: { currentChannelId: "C12345678" },
|
||||
});
|
||||
|
||||
expect(result.kind).toBe("send");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "structured poll params",
|
||||
|
||||
@ -34,6 +34,7 @@ import {
|
||||
parseButtonsParam,
|
||||
parseCardParam,
|
||||
parseComponentsParam,
|
||||
parseInteractiveParam,
|
||||
readBooleanParam,
|
||||
resolveAttachmentMediaPolicy,
|
||||
resolveSlackAutoThreadId,
|
||||
@ -403,12 +404,18 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise<MessageActi
|
||||
readStringParam(params, "media", { trim: false }) ??
|
||||
readStringParam(params, "path", { trim: false }) ??
|
||||
readStringParam(params, "filePath", { trim: false });
|
||||
const hasButtons = Array.isArray(params.buttons) && params.buttons.length > 0;
|
||||
const hasCard = params.card != null && typeof params.card === "object";
|
||||
const hasComponents = params.components != null && typeof params.components === "object";
|
||||
const hasInteractive = params.interactive != null && typeof params.interactive === "object";
|
||||
const hasBlocks =
|
||||
(Array.isArray(params.blocks) && params.blocks.length > 0) ||
|
||||
(typeof params.blocks === "string" && params.blocks.trim().length > 0);
|
||||
const caption = readStringParam(params, "caption", { allowEmpty: true }) ?? "";
|
||||
let message =
|
||||
readStringParam(params, "message", {
|
||||
required: !mediaHint && !hasCard && !hasComponents,
|
||||
required:
|
||||
!mediaHint && !hasButtons && !hasCard && !hasComponents && !hasInteractive && !hasBlocks,
|
||||
allowEmpty: true,
|
||||
}) ?? "";
|
||||
if (message.includes("\\n")) {
|
||||
@ -474,7 +481,16 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise<MessageActi
|
||||
message = "";
|
||||
}
|
||||
}
|
||||
if (!message.trim() && !mediaUrl && mergedMediaUrls.length === 0 && !hasCard && !hasComponents) {
|
||||
if (
|
||||
!message.trim() &&
|
||||
!mediaUrl &&
|
||||
mergedMediaUrls.length === 0 &&
|
||||
!hasButtons &&
|
||||
!hasCard &&
|
||||
!hasComponents &&
|
||||
!hasInteractive &&
|
||||
!hasBlocks
|
||||
) {
|
||||
throw new Error("send requires text or media");
|
||||
}
|
||||
params.message = message;
|
||||
@ -714,6 +730,7 @@ export async function runMessageAction(
|
||||
parseButtonsParam(params);
|
||||
parseCardParam(params);
|
||||
parseComponentsParam(params);
|
||||
parseInteractiveParam(params);
|
||||
|
||||
const action = input.action;
|
||||
if (action === "broadcast") {
|
||||
|
||||
@ -5,10 +5,12 @@ import {
|
||||
shouldSuppressReasoningPayload,
|
||||
} from "../../auto-reply/reply/reply-payloads.js";
|
||||
import type { ReplyPayload } from "../../auto-reply/types.js";
|
||||
import type { InteractiveReply } from "../../interactive/payload.js";
|
||||
|
||||
export type NormalizedOutboundPayload = {
|
||||
text: string;
|
||||
mediaUrls: string[];
|
||||
interactive?: InteractiveReply;
|
||||
channelData?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
@ -16,6 +18,7 @@ export type OutboundPayloadJson = {
|
||||
text: string;
|
||||
mediaUrl: string | null;
|
||||
mediaUrls?: string[];
|
||||
interactive?: InteractiveReply;
|
||||
channelData?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
@ -89,15 +92,18 @@ export function normalizeOutboundPayloads(
|
||||
const normalizedPayloads: NormalizedOutboundPayload[] = [];
|
||||
for (const payload of normalizeReplyPayloadsForDelivery(payloads)) {
|
||||
const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
||||
const interactive = payload.interactive;
|
||||
const channelData = payload.channelData;
|
||||
const hasChannelData = Boolean(channelData && Object.keys(channelData).length > 0);
|
||||
const hasInteractive = Boolean(interactive?.blocks.length);
|
||||
const text = payload.text ?? "";
|
||||
if (!text && mediaUrls.length === 0 && !hasChannelData) {
|
||||
if (!text && mediaUrls.length === 0 && !hasInteractive && !hasChannelData) {
|
||||
continue;
|
||||
}
|
||||
normalizedPayloads.push({
|
||||
text,
|
||||
mediaUrls,
|
||||
...(hasInteractive ? { interactive } : {}),
|
||||
...(hasChannelData ? { channelData } : {}),
|
||||
});
|
||||
}
|
||||
@ -113,6 +119,7 @@ export function normalizeOutboundPayloadsForJson(
|
||||
text: payload.text ?? "",
|
||||
mediaUrl: payload.mediaUrl ?? null,
|
||||
mediaUrls: payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : undefined),
|
||||
interactive: payload.interactive,
|
||||
channelData: payload.channelData,
|
||||
});
|
||||
}
|
||||
|
||||
153
src/interactive/payload.ts
Normal file
153
src/interactive/payload.ts
Normal file
@ -0,0 +1,153 @@
|
||||
export type InteractiveButtonStyle = "primary" | "secondary" | "success" | "danger";
|
||||
|
||||
export type InteractiveReplyButton = {
|
||||
label: string;
|
||||
value: string;
|
||||
style?: InteractiveButtonStyle;
|
||||
};
|
||||
|
||||
export type InteractiveReplyOption = {
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type InteractiveReplyTextBlock = {
|
||||
type: "text";
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type InteractiveReplyButtonsBlock = {
|
||||
type: "buttons";
|
||||
buttons: InteractiveReplyButton[];
|
||||
};
|
||||
|
||||
export type InteractiveReplySelectBlock = {
|
||||
type: "select";
|
||||
placeholder?: string;
|
||||
options: InteractiveReplyOption[];
|
||||
};
|
||||
|
||||
export type InteractiveReplyBlock =
|
||||
| InteractiveReplyTextBlock
|
||||
| InteractiveReplyButtonsBlock
|
||||
| InteractiveReplySelectBlock;
|
||||
|
||||
export type InteractiveReply = {
|
||||
blocks: InteractiveReplyBlock[];
|
||||
};
|
||||
|
||||
function readTrimmedString(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed || undefined;
|
||||
}
|
||||
|
||||
function normalizeButtonStyle(value: unknown): InteractiveButtonStyle | undefined {
|
||||
const style = readTrimmedString(value)?.toLowerCase();
|
||||
return style === "primary" || style === "secondary" || style === "success" || style === "danger"
|
||||
? style
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function normalizeInteractiveButton(raw: unknown): InteractiveReplyButton | undefined {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
||||
return undefined;
|
||||
}
|
||||
const record = raw as Record<string, unknown>;
|
||||
const label = readTrimmedString(record.label) ?? readTrimmedString(record.text);
|
||||
const value =
|
||||
readTrimmedString(record.value) ??
|
||||
readTrimmedString(record.callbackData) ??
|
||||
readTrimmedString(record.callback_data);
|
||||
if (!label || !value) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
label,
|
||||
value,
|
||||
style: normalizeButtonStyle(record.style),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeInteractiveOption(raw: unknown): InteractiveReplyOption | undefined {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
||||
return undefined;
|
||||
}
|
||||
const record = raw as Record<string, unknown>;
|
||||
const label = readTrimmedString(record.label) ?? readTrimmedString(record.text);
|
||||
const value = readTrimmedString(record.value);
|
||||
if (!label || !value) {
|
||||
return undefined;
|
||||
}
|
||||
return { label, value };
|
||||
}
|
||||
|
||||
function normalizeInteractiveBlock(raw: unknown): InteractiveReplyBlock | undefined {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
||||
return undefined;
|
||||
}
|
||||
const record = raw as Record<string, unknown>;
|
||||
const type = readTrimmedString(record.type)?.toLowerCase();
|
||||
if (type === "text") {
|
||||
const text = readTrimmedString(record.text);
|
||||
return text ? { type: "text", text } : undefined;
|
||||
}
|
||||
if (type === "buttons") {
|
||||
const buttons = Array.isArray(record.buttons)
|
||||
? record.buttons
|
||||
.map((entry) => normalizeInteractiveButton(entry))
|
||||
.filter((entry): entry is InteractiveReplyButton => Boolean(entry))
|
||||
: [];
|
||||
return buttons.length > 0 ? { type: "buttons", buttons } : undefined;
|
||||
}
|
||||
if (type === "select") {
|
||||
const options = Array.isArray(record.options)
|
||||
? record.options
|
||||
.map((entry) => normalizeInteractiveOption(entry))
|
||||
.filter((entry): entry is InteractiveReplyOption => Boolean(entry))
|
||||
: [];
|
||||
return options.length > 0
|
||||
? {
|
||||
type: "select",
|
||||
placeholder: readTrimmedString(record.placeholder),
|
||||
options,
|
||||
}
|
||||
: undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function normalizeInteractiveReply(raw: unknown): InteractiveReply | undefined {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
||||
return undefined;
|
||||
}
|
||||
const record = raw as Record<string, unknown>;
|
||||
const blocks = Array.isArray(record.blocks)
|
||||
? record.blocks
|
||||
.map((entry) => normalizeInteractiveBlock(entry))
|
||||
.filter((entry): entry is InteractiveReplyBlock => Boolean(entry))
|
||||
: [];
|
||||
return blocks.length > 0 ? { blocks } : undefined;
|
||||
}
|
||||
|
||||
export function hasInteractiveReplyBlocks(value: unknown): value is InteractiveReply {
|
||||
return Boolean(normalizeInteractiveReply(value));
|
||||
}
|
||||
|
||||
export function resolveInteractiveTextFallback(params: {
|
||||
text?: string;
|
||||
interactive?: InteractiveReply;
|
||||
}): string | undefined {
|
||||
const text = readTrimmedString(params.text);
|
||||
if (text) {
|
||||
return params.text;
|
||||
}
|
||||
const interactiveText = (params.interactive?.blocks ?? [])
|
||||
.filter((block): block is InteractiveReplyTextBlock => block.type === "text")
|
||||
.map((block) => block.text.trim())
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
return interactiveText || params.text;
|
||||
}
|
||||
@ -105,6 +105,7 @@ export type {
|
||||
PluginHookInboundClaimResult,
|
||||
PluginInteractiveDiscordHandlerContext,
|
||||
PluginInteractiveHandlerRegistration,
|
||||
PluginInteractiveSlackHandlerContext,
|
||||
PluginInteractiveTelegramHandlerContext,
|
||||
PluginLogger,
|
||||
ProviderAuthContext,
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||
import { parseSlackBlocksInput } from "../../extensions/slack/src/blocks-input.js";
|
||||
import { buildSlackInteractiveBlocks } from "../../extensions/slack/src/blocks-render.js";
|
||||
import { readNumberParam, readStringParam } from "../agents/tools/common.js";
|
||||
import type { ChannelMessageActionContext } from "../channels/plugins/types.js";
|
||||
import { normalizeInteractiveReply } from "../interactive/payload.js";
|
||||
|
||||
type SlackActionInvoke = (
|
||||
action: Record<string, unknown>,
|
||||
@ -37,7 +39,9 @@ export async function handleSlackMessageAction(params: {
|
||||
allowEmpty: true,
|
||||
});
|
||||
const mediaUrl = readStringParam(actionParams, "media", { trim: false });
|
||||
const blocks = readSlackBlocksParam(actionParams);
|
||||
const interactive = normalizeInteractiveReply(actionParams.interactive);
|
||||
const interactiveBlocks = interactive ? buildSlackInteractiveBlocks(interactive) : undefined;
|
||||
const blocks = readSlackBlocksParam(actionParams) ?? interactiveBlocks;
|
||||
if (!content && !mediaUrl && !blocks) {
|
||||
throw new Error("Slack send requires message, blocks, or media.");
|
||||
}
|
||||
@ -52,9 +56,9 @@ export async function handleSlackMessageAction(params: {
|
||||
to,
|
||||
content: content ?? "",
|
||||
mediaUrl: mediaUrl ?? undefined,
|
||||
blocks,
|
||||
accountId,
|
||||
threadTs: threadId ?? replyTo ?? undefined,
|
||||
...(blocks ? { blocks } : {}),
|
||||
},
|
||||
cfg,
|
||||
ctx.toolContext,
|
||||
|
||||
@ -1,8 +1,6 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { Button, Row, type TopLevelComponents } from "@buape/carbon";
|
||||
import { ButtonStyle } from "discord-api-types/v10";
|
||||
import type { ReplyPayload } from "../auto-reply/types.js";
|
||||
import { expandHomePrefix } from "../infra/home-dir.js";
|
||||
import { writeJsonAtomic } from "../infra/json-files.js";
|
||||
@ -119,24 +117,6 @@ function getPluginBindingGlobalState(): PluginBindingGlobalState {
|
||||
});
|
||||
}
|
||||
|
||||
class PluginBindingApprovalButton extends Button {
|
||||
customId: string;
|
||||
label: string;
|
||||
style: ButtonStyle;
|
||||
|
||||
constructor(params: {
|
||||
approvalId: string;
|
||||
decision: PluginBindingApprovalDecision;
|
||||
label: string;
|
||||
style: ButtonStyle;
|
||||
}) {
|
||||
super();
|
||||
this.customId = buildPluginBindingApprovalCustomId(params.approvalId, params.decision);
|
||||
this.label = params.label;
|
||||
this.style = params.style;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveApprovalsPath(): string {
|
||||
return expandHomePrefix(APPROVALS_PATH);
|
||||
}
|
||||
@ -236,54 +216,33 @@ function isLegacyPluginBindingRecord(params: {
|
||||
);
|
||||
}
|
||||
|
||||
function buildDiscordButtonRow(
|
||||
function buildApprovalInteractiveReply(
|
||||
approvalId: string,
|
||||
labels?: { once?: string; always?: string; deny?: string },
|
||||
): TopLevelComponents[] {
|
||||
return [
|
||||
new Row([
|
||||
new PluginBindingApprovalButton({
|
||||
approvalId,
|
||||
decision: "allow-once",
|
||||
label: labels?.once ?? "Allow once",
|
||||
style: ButtonStyle.Success,
|
||||
}),
|
||||
new PluginBindingApprovalButton({
|
||||
approvalId,
|
||||
decision: "allow-always",
|
||||
label: labels?.always ?? "Always allow",
|
||||
style: ButtonStyle.Primary,
|
||||
}),
|
||||
new PluginBindingApprovalButton({
|
||||
approvalId,
|
||||
decision: "deny",
|
||||
label: labels?.deny ?? "Deny",
|
||||
style: ButtonStyle.Danger,
|
||||
}),
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
function buildTelegramButtons(approvalId: string) {
|
||||
return [
|
||||
[
|
||||
): NonNullable<ReplyPayload["interactive"]> {
|
||||
return {
|
||||
blocks: [
|
||||
{
|
||||
text: "Allow once",
|
||||
callback_data: buildPluginBindingApprovalCustomId(approvalId, "allow-once"),
|
||||
style: "success" as const,
|
||||
},
|
||||
{
|
||||
text: "Always allow",
|
||||
callback_data: buildPluginBindingApprovalCustomId(approvalId, "allow-always"),
|
||||
style: "primary" as const,
|
||||
},
|
||||
{
|
||||
text: "Deny",
|
||||
callback_data: buildPluginBindingApprovalCustomId(approvalId, "deny"),
|
||||
style: "danger" as const,
|
||||
type: "buttons",
|
||||
buttons: [
|
||||
{
|
||||
label: "Allow once",
|
||||
value: buildPluginBindingApprovalCustomId(approvalId, "allow-once"),
|
||||
style: "success",
|
||||
},
|
||||
{
|
||||
label: "Always allow",
|
||||
value: buildPluginBindingApprovalCustomId(approvalId, "allow-always"),
|
||||
style: "primary",
|
||||
},
|
||||
{
|
||||
label: "Deny",
|
||||
value: buildPluginBindingApprovalCustomId(approvalId, "deny"),
|
||||
style: "danger",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
];
|
||||
};
|
||||
}
|
||||
|
||||
function createApprovalRequestId(): string {
|
||||
@ -547,14 +506,7 @@ export function markPluginBindingFallbackNoticeShown(bindingId: string): void {
|
||||
function buildPendingReply(request: PendingPluginBindingRequest): ReplyPayload {
|
||||
return {
|
||||
text: buildApprovalMessage(request),
|
||||
channelData: {
|
||||
telegram: {
|
||||
buttons: buildTelegramButtons(request.id),
|
||||
},
|
||||
discord: {
|
||||
components: buildDiscordButtonRow(request.id),
|
||||
},
|
||||
},
|
||||
interactive: buildApprovalInteractiveReply(request.id),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -147,6 +147,72 @@ describe("plugin interactive handlers", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("routes Slack interactions by namespace and dedupes interaction ids", async () => {
|
||||
const handler = vi.fn(async () => ({ handled: true }));
|
||||
expect(
|
||||
registerPluginInteractiveHandler("codex-plugin", {
|
||||
channel: "slack",
|
||||
namespace: "codex",
|
||||
handler,
|
||||
}),
|
||||
).toEqual({ ok: true });
|
||||
|
||||
const baseParams = {
|
||||
channel: "slack" as const,
|
||||
data: "codex:approve:thread-1",
|
||||
interactionId: "slack-ix-1",
|
||||
ctx: {
|
||||
channel: "slack" as const,
|
||||
accountId: "default",
|
||||
interactionId: "slack-ix-1",
|
||||
conversationId: "C123",
|
||||
parentConversationId: "C123",
|
||||
threadId: "1710000000.000100",
|
||||
senderId: "U123",
|
||||
senderUsername: "ada",
|
||||
auth: { isAuthorizedSender: true },
|
||||
interaction: {
|
||||
kind: "button" as const,
|
||||
actionId: "codex",
|
||||
blockId: "codex_actions",
|
||||
messageTs: "1710000000.000200",
|
||||
threadTs: "1710000000.000100",
|
||||
value: "approve:thread-1",
|
||||
selectedValues: ["approve:thread-1"],
|
||||
selectedLabels: ["Approve"],
|
||||
triggerId: "trigger-1",
|
||||
responseUrl: "https://hooks.slack.test/response",
|
||||
},
|
||||
},
|
||||
respond: {
|
||||
acknowledge: vi.fn(async () => {}),
|
||||
reply: vi.fn(async () => {}),
|
||||
followUp: vi.fn(async () => {}),
|
||||
editMessage: vi.fn(async () => {}),
|
||||
},
|
||||
};
|
||||
|
||||
const first = await dispatchPluginInteractiveHandler(baseParams);
|
||||
const duplicate = await dispatchPluginInteractiveHandler(baseParams);
|
||||
|
||||
expect(first).toEqual({ matched: true, handled: true, duplicate: false });
|
||||
expect(duplicate).toEqual({ matched: true, handled: true, duplicate: true });
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
expect(handler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channel: "slack",
|
||||
conversationId: "C123",
|
||||
threadId: "1710000000.000100",
|
||||
interaction: expect.objectContaining({
|
||||
namespace: "codex",
|
||||
payload: "approve:thread-1",
|
||||
actionId: "codex",
|
||||
messageTs: "1710000000.000200",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not consume dedupe keys when a handler throws", async () => {
|
||||
const handler = vi
|
||||
.fn(async () => ({ handled: true }))
|
||||
|
||||
@ -9,6 +9,8 @@ import type {
|
||||
PluginInteractiveButtons,
|
||||
PluginInteractiveDiscordHandlerRegistration,
|
||||
PluginInteractiveHandlerRegistration,
|
||||
PluginInteractiveSlackHandlerContext,
|
||||
PluginInteractiveSlackHandlerRegistration,
|
||||
PluginInteractiveTelegramHandlerRegistration,
|
||||
PluginInteractiveTelegramHandlerContext,
|
||||
} from "./types.js";
|
||||
@ -59,6 +61,21 @@ type DiscordInteractiveDispatchContext = Omit<
|
||||
>;
|
||||
};
|
||||
|
||||
type SlackInteractiveDispatchContext = Omit<
|
||||
PluginInteractiveSlackHandlerContext,
|
||||
| "interaction"
|
||||
| "respond"
|
||||
| "channel"
|
||||
| "requestConversationBinding"
|
||||
| "detachConversationBinding"
|
||||
| "getCurrentConversationBinding"
|
||||
> & {
|
||||
interaction: Omit<
|
||||
PluginInteractiveSlackHandlerContext["interaction"],
|
||||
"data" | "namespace" | "payload"
|
||||
>;
|
||||
};
|
||||
|
||||
const interactiveHandlers = new Map<string, RegisteredInteractiveHandler>();
|
||||
const callbackDedupe = createDedupeCache({
|
||||
ttlMs: 5 * 60_000,
|
||||
@ -134,6 +151,15 @@ export function registerPluginInteractiveHandler(
|
||||
pluginName: opts?.pluginName,
|
||||
pluginRoot: opts?.pluginRoot,
|
||||
});
|
||||
} else if (registration.channel === "slack") {
|
||||
interactiveHandlers.set(key, {
|
||||
...registration,
|
||||
namespace,
|
||||
channel: "slack",
|
||||
pluginId,
|
||||
pluginName: opts?.pluginName,
|
||||
pluginRoot: opts?.pluginRoot,
|
||||
});
|
||||
} else {
|
||||
interactiveHandlers.set(key, {
|
||||
...registration,
|
||||
@ -181,11 +207,21 @@ export async function dispatchPluginInteractiveHandler(params: {
|
||||
respond: PluginInteractiveDiscordHandlerContext["respond"];
|
||||
}): Promise<InteractiveDispatchResult>;
|
||||
export async function dispatchPluginInteractiveHandler(params: {
|
||||
channel: "telegram" | "discord";
|
||||
channel: "slack";
|
||||
data: string;
|
||||
interactionId: string;
|
||||
ctx: SlackInteractiveDispatchContext;
|
||||
respond: PluginInteractiveSlackHandlerContext["respond"];
|
||||
}): Promise<InteractiveDispatchResult>;
|
||||
export async function dispatchPluginInteractiveHandler(params: {
|
||||
channel: "telegram" | "discord" | "slack";
|
||||
data: string;
|
||||
callbackId?: string;
|
||||
interactionId?: string;
|
||||
ctx: TelegramInteractiveDispatchContext | DiscordInteractiveDispatchContext;
|
||||
ctx:
|
||||
| TelegramInteractiveDispatchContext
|
||||
| DiscordInteractiveDispatchContext
|
||||
| SlackInteractiveDispatchContext;
|
||||
respond:
|
||||
| {
|
||||
reply: (params: { text: string; buttons?: PluginInteractiveButtons }) => Promise<void>;
|
||||
@ -197,7 +233,8 @@ export async function dispatchPluginInteractiveHandler(params: {
|
||||
clearButtons: () => Promise<void>;
|
||||
deleteMessage: () => Promise<void>;
|
||||
}
|
||||
| PluginInteractiveDiscordHandlerContext["respond"];
|
||||
| PluginInteractiveDiscordHandlerContext["respond"]
|
||||
| PluginInteractiveSlackHandlerContext["respond"];
|
||||
}): Promise<InteractiveDispatchResult> {
|
||||
const match = resolveNamespaceMatch(params.channel, params.data);
|
||||
if (!match) {
|
||||
@ -212,7 +249,8 @@ export async function dispatchPluginInteractiveHandler(params: {
|
||||
|
||||
let result:
|
||||
| ReturnType<PluginInteractiveTelegramHandlerRegistration["handler"]>
|
||||
| ReturnType<PluginInteractiveDiscordHandlerRegistration["handler"]>;
|
||||
| ReturnType<PluginInteractiveDiscordHandlerRegistration["handler"]>
|
||||
| ReturnType<PluginInteractiveSlackHandlerRegistration["handler"]>;
|
||||
if (params.channel === "telegram") {
|
||||
const pluginRoot = match.registration.pluginRoot;
|
||||
const { callbackMessage, ...handlerContext } = params.ctx as TelegramInteractiveDispatchContext;
|
||||
@ -284,7 +322,7 @@ export async function dispatchPluginInteractiveHandler(params: {
|
||||
});
|
||||
},
|
||||
});
|
||||
} else {
|
||||
} else if (params.channel === "discord") {
|
||||
const pluginRoot = match.registration.pluginRoot;
|
||||
result = (
|
||||
match.registration as RegisteredInteractiveHandler &
|
||||
@ -352,6 +390,74 @@ export async function dispatchPluginInteractiveHandler(params: {
|
||||
});
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const pluginRoot = match.registration.pluginRoot;
|
||||
const handlerContext = params.ctx as SlackInteractiveDispatchContext;
|
||||
result = (
|
||||
match.registration as RegisteredInteractiveHandler & PluginInteractiveSlackHandlerRegistration
|
||||
).handler({
|
||||
...handlerContext,
|
||||
channel: "slack",
|
||||
interaction: {
|
||||
...handlerContext.interaction,
|
||||
data: params.data,
|
||||
namespace: match.namespace,
|
||||
payload: match.payload,
|
||||
},
|
||||
respond: params.respond as PluginInteractiveSlackHandlerContext["respond"],
|
||||
requestConversationBinding: async (bindingParams) => {
|
||||
if (!pluginRoot) {
|
||||
return {
|
||||
status: "error",
|
||||
message: "This interaction cannot bind the current conversation.",
|
||||
};
|
||||
}
|
||||
return requestPluginConversationBinding({
|
||||
pluginId: match.registration.pluginId,
|
||||
pluginName: match.registration.pluginName,
|
||||
pluginRoot,
|
||||
requestedBySenderId: handlerContext.senderId,
|
||||
conversation: {
|
||||
channel: "slack",
|
||||
accountId: handlerContext.accountId,
|
||||
conversationId: handlerContext.conversationId,
|
||||
parentConversationId: handlerContext.parentConversationId,
|
||||
threadId: handlerContext.threadId,
|
||||
},
|
||||
binding: bindingParams,
|
||||
});
|
||||
},
|
||||
detachConversationBinding: async () => {
|
||||
if (!pluginRoot) {
|
||||
return { removed: false };
|
||||
}
|
||||
return detachPluginConversationBinding({
|
||||
pluginRoot,
|
||||
conversation: {
|
||||
channel: "slack",
|
||||
accountId: handlerContext.accountId,
|
||||
conversationId: handlerContext.conversationId,
|
||||
parentConversationId: handlerContext.parentConversationId,
|
||||
threadId: handlerContext.threadId,
|
||||
},
|
||||
});
|
||||
},
|
||||
getCurrentConversationBinding: async () => {
|
||||
if (!pluginRoot) {
|
||||
return null;
|
||||
}
|
||||
return getCurrentPluginConversationBinding({
|
||||
pluginRoot,
|
||||
conversation: {
|
||||
channel: "slack",
|
||||
accountId: handlerContext.accountId,
|
||||
conversationId: handlerContext.conversationId,
|
||||
parentConversationId: handlerContext.parentConversationId,
|
||||
threadId: handlerContext.threadId,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
const resolved = await result;
|
||||
if (dedupeKey) {
|
||||
|
||||
@ -796,7 +796,7 @@ export type OpenClawPluginCommandDefinition = {
|
||||
handler: PluginCommandHandler;
|
||||
};
|
||||
|
||||
export type PluginInteractiveChannel = "telegram" | "discord";
|
||||
export type PluginInteractiveChannel = "telegram" | "discord" | "slack";
|
||||
|
||||
export type PluginInteractiveButtons = Array<
|
||||
Array<{ text: string; callback_data: string; style?: "danger" | "success" | "primary" }>
|
||||
@ -881,6 +881,53 @@ export type PluginInteractiveDiscordHandlerContext = {
|
||||
getCurrentConversationBinding: () => Promise<PluginConversationBinding | null>;
|
||||
};
|
||||
|
||||
export type PluginInteractiveSlackHandlerResult = {
|
||||
handled?: boolean;
|
||||
} | void;
|
||||
|
||||
export type PluginInteractiveSlackHandlerContext = {
|
||||
channel: "slack";
|
||||
accountId: string;
|
||||
interactionId: string;
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
senderId?: string;
|
||||
senderUsername?: string;
|
||||
threadId?: string;
|
||||
auth: {
|
||||
isAuthorizedSender: boolean;
|
||||
};
|
||||
interaction: {
|
||||
kind: "button" | "select";
|
||||
data: string;
|
||||
namespace: string;
|
||||
payload: string;
|
||||
actionId: string;
|
||||
blockId?: string;
|
||||
messageTs?: string;
|
||||
threadTs?: string;
|
||||
value?: string;
|
||||
selectedValues?: string[];
|
||||
selectedLabels?: string[];
|
||||
triggerId?: string;
|
||||
responseUrl?: string;
|
||||
};
|
||||
respond: {
|
||||
acknowledge: () => Promise<void>;
|
||||
reply: (params: { text: string; responseType?: "ephemeral" | "in_channel" }) => Promise<void>;
|
||||
followUp: (params: {
|
||||
text: string;
|
||||
responseType?: "ephemeral" | "in_channel";
|
||||
}) => Promise<void>;
|
||||
editMessage: (params: { text?: string; blocks?: unknown[] }) => Promise<void>;
|
||||
};
|
||||
requestConversationBinding: (
|
||||
params?: PluginConversationBindingRequestParams,
|
||||
) => Promise<PluginConversationBindingRequestResult>;
|
||||
detachConversationBinding: () => Promise<{ removed: boolean }>;
|
||||
getCurrentConversationBinding: () => Promise<PluginConversationBinding | null>;
|
||||
};
|
||||
|
||||
export type PluginInteractiveTelegramHandlerRegistration = {
|
||||
channel: "telegram";
|
||||
namespace: string;
|
||||
@ -897,9 +944,18 @@ export type PluginInteractiveDiscordHandlerRegistration = {
|
||||
) => Promise<PluginInteractiveDiscordHandlerResult> | PluginInteractiveDiscordHandlerResult;
|
||||
};
|
||||
|
||||
export type PluginInteractiveSlackHandlerRegistration = {
|
||||
channel: "slack";
|
||||
namespace: string;
|
||||
handler: (
|
||||
ctx: PluginInteractiveSlackHandlerContext,
|
||||
) => Promise<PluginInteractiveSlackHandlerResult> | PluginInteractiveSlackHandlerResult;
|
||||
};
|
||||
|
||||
export type PluginInteractiveHandlerRegistration =
|
||||
| PluginInteractiveTelegramHandlerRegistration
|
||||
| PluginInteractiveDiscordHandlerRegistration;
|
||||
| PluginInteractiveDiscordHandlerRegistration
|
||||
| PluginInteractiveSlackHandlerRegistration;
|
||||
|
||||
export type OpenClawPluginHttpRouteAuth = "gateway" | "plugin";
|
||||
export type OpenClawPluginHttpRouteMatch = "exact" | "prefix";
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user