diff --git a/CHANGELOG.md b/CHANGELOG.md index 07937512400..b63a53d9355 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/extensions/discord/src/components.ts b/extensions/discord/src/components.ts index 01f83d49d8d..6725ad49a4d 100644 --- a/extensions/discord/src/components.ts +++ b/extensions/discord/src/components.ts @@ -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, (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) { diff --git a/extensions/discord/src/outbound-adapter.interactive-order.test.ts b/extensions/discord/src/outbound-adapter.interactive-order.test.ts new file mode 100644 index 00000000000..e2c8b749d24 --- /dev/null +++ b/extensions/discord/src/outbound-adapter.interactive-order.test.ts @@ -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(); + 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(); + 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", + }); + }); +}); diff --git a/extensions/discord/src/outbound-adapter.ts b/extensions/discord/src/outbound-adapter.ts index f81128ec4c9..09796a7b0b3 100644 --- a/extensions/discord/src/outbound-adapter.ts +++ b/extensions/discord/src/outbound-adapter.ts @@ -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 } diff --git a/extensions/discord/src/shared-interactive.test.ts b/extensions/discord/src/shared-interactive.test.ts index 35d3f78d131..827ad1126a8 100644 --- a/extensions/discord/src/shared-interactive.test.ts +++ b/extensions/discord/src/shared-interactive.test.ts @@ -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" }], + }, + ], + }); + }); }); diff --git a/extensions/slack/src/monitor/events/interactions.test.ts b/extensions/slack/src/monitor/events/interactions.test.ts index 373206d351e..5b71ebd11fd 100644 --- a/extensions/slack/src/monitor/events/interactions.test.ts +++ b/extensions/slack/src/monitor/events/interactions.test.ts @@ -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", diff --git a/extensions/slack/src/monitor/events/interactions.ts b/extensions/slack/src/monitor/events/interactions.ts index 3cda494ed42..1ebb55d090e 100644 --- a/extensions/slack/src/monitor/events/interactions.ts +++ b/extensions/slack/src/monitor/events/interactions.ts @@ -446,6 +446,7 @@ function buildSlackPluginInteractionId(params: { userId?: string; channelId?: string; messageTs?: string; + triggerId?: string; actionId: string; summary: Omit; }): 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; diff --git a/extensions/telegram/src/channel-actions.test.ts b/extensions/telegram/src/channel-actions.test.ts new file mode 100644 index 00000000000..0e5170431b1 --- /dev/null +++ b/extensions/telegram/src/channel-actions.test.ts @@ -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: [], + }), + ); + }); +}); diff --git a/extensions/telegram/src/channel-actions.ts b/extensions/telegram/src/channel-actions.ts index 0055308cb45..246ed45c0e3 100644 --- a/extensions/telegram/src/channel-actions.ts +++ b/extensions/telegram/src/channel-actions.ts @@ -32,14 +32,18 @@ const providerId = "telegram"; function readTelegramSendParams(params: Record) { 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"); diff --git a/src/infra/outbound/message-action-runner.context.test.ts b/src/infra/outbound/message-action-runner.context.test.ts index c6a8388af57..ed470984e45 100644 --- a/src/infra/outbound/message-action-runner.context.test.ts +++ b/src/infra/outbound/message-action-runner.context.test.ts @@ -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([ diff --git a/src/infra/outbound/message-action-runner.ts b/src/infra/outbound/message-action-runner.ts index 909cc7ce9ef..a867d912aca 100644 --- a/src/infra/outbound/message-action-runner.ts +++ b/src/infra/outbound/message-action-runner.ts @@ -404,12 +404,18 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise 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