fix: harden shared interactive payload landing (#47715) (thanks @vincentkoc)

This commit is contained in:
Peter Steinberger 2026-03-16 04:55:01 +00:00
parent 81c8e66f61
commit bec437faf8
11 changed files with 646 additions and 304 deletions

View File

@ -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.

View File

@ -220,6 +220,8 @@ function resolveDiscordInteractiveButtonStyle(
return style ?? "secondary";
}
const DISCORD_INTERACTIVE_BUTTON_ROW_SIZE = 5;
export function buildDiscordInteractiveComponents(
interactive?: InteractiveReply,
): DiscordComponentMessageSpec | undefined {
@ -227,18 +229,33 @@ export function buildDiscordInteractiveComponents(
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;
}
state.push({
type: "actions",
buttons: block.buttons.map((button) => ({
label: button.label,
style: resolveDiscordInteractiveButtonStyle(button.style),
callbackData: button.value,
})),
});
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) {

View File

@ -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",
});
});
});

View File

@ -7,7 +7,6 @@ import type { ChannelOutboundAdapter } from "../../../src/channels/plugins/types
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 { resolveInteractiveTextFallback } from "../../../src/interactive/payload.js";
import type { DiscordComponentMessageSpec } from "./components.js";
import { buildDiscordInteractiveComponents } from "./components.js";
import { getThreadBindingManager, type ThreadBindingRecord } from "./monitor/thread-bindings.js";
@ -93,11 +92,7 @@ export const discordOutbound: ChannelOutboundAdapter = {
sendPayload: async (ctx) => {
const payload = {
...ctx.payload,
text:
resolveInteractiveTextFallback({
text: ctx.payload.text,
interactive: ctx.payload.interactive,
}) ?? "",
text: ctx.payload.text ?? "",
};
const discordData = payload.channelData?.discord as
| { components?: DiscordComponentMessageSpec }

View File

@ -40,4 +40,65 @@ describe("buildDiscordInteractiveComponents", () => {
],
});
});
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" }],
},
],
});
});
});

View File

@ -106,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) => {
@ -173,6 +175,7 @@ function createContext(overrides?: {
isChannelAllowed,
resolveUserName,
resolveChannelName,
getActionMatcher: () => actionMatcher,
getHandler: () => handler,
getViewHandler: () => viewHandler,
getViewClosedHandler: () => viewClosedHandler,
@ -270,6 +273,16 @@ 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,
@ -320,11 +333,11 @@ describe("registerSlackInteractionEvents", () => {
expect.objectContaining({
channel: "slack",
data: "codex:approve:thread-1",
interactionId: "U123:C1:100.200: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:codex:approve:thread-1",
interactionId: "U123:C1:100.200:123.trigger:codex:approve:thread-1",
threadId: "100.100",
interaction: expect.objectContaining({
actionId: "codex",
@ -337,6 +350,91 @@ describe("registerSlackInteractionEvents", () => {
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",

View File

@ -446,6 +446,7 @@ function buildSlackPluginInteractionId(params: {
userId?: string;
channelId?: string;
messageTs?: string;
triggerId?: string;
actionId: string;
summary: Omit<InteractionSummary, "actionId" | "blockId">;
}): string {
@ -457,6 +458,7 @@ function buildSlackPluginInteractionId(params: {
params.userId?.trim() || "",
params.channelId?.trim() || "",
params.messageTs?.trim() || "",
params.triggerId?.trim() || "",
params.actionId.trim(),
primaryValue,
].join(":");
@ -492,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 {
@ -562,224 +604,178 @@ export function registerSlackInteractionEvents(params: { ctx: SlackMonitorContex
}
return;
}
const actionSummary = summarizeAction(typedAction);
const pluginInteractionData = buildSlackPluginInteractionData({
actionId,
summary: actionSummary,
});
if (pluginInteractionData) {
const pluginInteractionId = buildSlackPluginInteractionId({
userId,
channelId,
messageTs,
actionId,
summary: actionSummary,
});
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: buildPluginBindingResolvedText(resolved),
response_type: "ephemeral",
});
} catch {
// Best-effort feedback only.
}
}
return;
}
const pluginResult = await dispatchPluginInteractiveHandler({
channel: "slack",
data: pluginInteractionData,
const pluginResult = await dispatchPluginInteractiveHandler({
channel: "slack",
data: pluginInteractionData,
interactionId: pluginInteractionId,
ctx: {
accountId: ctx.accountId,
interactionId: pluginInteractionId,
ctx: {
channel: "slack",
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,
},
conversationId: channelId ?? "",
parentConversationId: undefined,
threadId: threadTs,
senderId: userId,
senderUsername: undefined,
auth: {
isAuthorizedSender: auth.allowed,
},
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)[] } : {}),
});
},
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,
},
});
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,
};
// 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,
},
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) {
// 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 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 (isBulkActionsBlock(typedBlock)) {
return false;
}
if (typedBlock.type !== "divider") {
return true;
}
const next = updatedBlocks[index + 1] as InteractionMessageBlock | undefined;
return !next || !isBulkActionsBlock(next);
});
}
try {
await ctx.app.client.chat.update({
channel: channelId,
ts: messageTs,
text: typedBody.message?.text ?? "",
blocks: updatedBlocks as (Block | KnownBlock)[],
});
} catch {
// If update fails, fallback to ephemeral confirmation for immediate UX feedback.
if (!respond) {
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 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 (isBulkActionsBlock(typedBlock)) {
return false;
}
if (typedBlock.type !== "divider") {
return true;
}
const next = updatedBlocks[index + 1] as InteractionMessageBlock | undefined;
return !next || !isBulkActionsBlock(next);
});
}
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;

View 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: [],
}),
);
});
});

View File

@ -32,14 +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 ??
buildTelegramInteractiveButtons(normalizeInteractiveReply(params.interactive));
const asVoice = readBooleanParam(params, "asVoice");
const silent = readBooleanParam(params, "silent");
const forceDocument = readBooleanParam(params, "forceDocument");

View File

@ -186,30 +186,44 @@ describe("runMessageAction context isolation", () => {
).rejects.toThrow(/message required/i);
});
it("requires message when send only includes shared interactive payloads", async () => {
await expect(
runDrySend({
cfg: {
channels: {
telegram: {
botToken: "telegram-test",
},
},
} as OpenClawConfig,
actionParams: {
channel: "telegram",
target: "123456",
interactive: {
blocks: [
{
type: "buttons",
buttons: [{ label: "Approve", value: "approve" }],
},
],
it("allows send when only shared interactive payloads are provided", async () => {
const result = await runDrySend({
cfg: {
channels: {
telegram: {
botToken: "telegram-test",
},
},
}),
).rejects.toThrow(/message required/i);
} 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([

View File

@ -404,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")) {
@ -475,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;