fix: harden shared interactive payload landing (#47715) (thanks @vincentkoc)
This commit is contained in:
parent
81c8e66f61
commit
bec437faf8
@ -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.
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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 }
|
||||
|
||||
@ -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" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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;
|
||||
|
||||
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: [],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -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");
|
||||
|
||||
@ -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([
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user