mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-05 02:40:24 +00:00
Slack: enrich Block Kit interaction events
This commit is contained in:
144
src/slack/monitor/events/interactions.test.ts
Normal file
144
src/slack/monitor/events/interactions.test.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { registerSlackInteractionEvents } from "./interactions.js";
|
||||
|
||||
const enqueueSystemEventMock = vi.fn();
|
||||
|
||||
vi.mock("../../../infra/system-events.js", () => ({
|
||||
enqueueSystemEvent: (...args: unknown[]) => enqueueSystemEventMock(...args),
|
||||
}));
|
||||
|
||||
type RegisteredHandler = (args: {
|
||||
ack: () => Promise<void>;
|
||||
body: {
|
||||
user: { id: string };
|
||||
channel?: { id?: string };
|
||||
message?: { ts?: string; text?: string; blocks?: unknown[] };
|
||||
};
|
||||
action: Record<string, unknown>;
|
||||
respond?: (payload: { text: string; response_type: string }) => Promise<void>;
|
||||
}) => Promise<void>;
|
||||
|
||||
function createContext() {
|
||||
let handler: RegisteredHandler | null = null;
|
||||
const app = {
|
||||
action: vi.fn((_matcher: RegExp, next: RegisteredHandler) => {
|
||||
handler = next;
|
||||
}),
|
||||
client: {
|
||||
chat: {
|
||||
update: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
},
|
||||
};
|
||||
const runtimeLog = vi.fn();
|
||||
const resolveSessionKey = vi.fn().mockReturnValue("agent:ops:slack:channel:C1");
|
||||
const ctx = {
|
||||
app,
|
||||
runtime: { log: runtimeLog },
|
||||
resolveSlackSystemEventSessionKey: resolveSessionKey,
|
||||
};
|
||||
return { ctx, app, runtimeLog, resolveSessionKey, getHandler: () => handler };
|
||||
}
|
||||
|
||||
describe("registerSlackInteractionEvents", () => {
|
||||
it("enqueues structured events and updates button rows", async () => {
|
||||
enqueueSystemEventMock.mockReset();
|
||||
const { ctx, app, getHandler } = createContext();
|
||||
registerSlackInteractionEvents({ ctx: ctx as never });
|
||||
|
||||
const handler = getHandler();
|
||||
expect(handler).toBeTruthy();
|
||||
|
||||
const ack = vi.fn().mockResolvedValue(undefined);
|
||||
const respond = vi.fn().mockResolvedValue(undefined);
|
||||
await handler!({
|
||||
ack,
|
||||
respond,
|
||||
body: {
|
||||
user: { id: "U123" },
|
||||
channel: { id: "C1" },
|
||||
message: {
|
||||
ts: "100.200",
|
||||
text: "fallback",
|
||||
blocks: [
|
||||
{
|
||||
type: "actions",
|
||||
block_id: "verify_block",
|
||||
elements: [{ type: "button", action_id: "openclaw:verify" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
action: {
|
||||
type: "button",
|
||||
action_id: "openclaw:verify",
|
||||
block_id: "verify_block",
|
||||
value: "approved",
|
||||
text: { type: "plain_text", text: "Approve" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(ack).toHaveBeenCalled();
|
||||
expect(enqueueSystemEventMock).toHaveBeenCalledTimes(1);
|
||||
const [eventText] = enqueueSystemEventMock.mock.calls[0] as [string];
|
||||
expect(eventText.startsWith("Slack interaction: ")).toBe(true);
|
||||
const payload = JSON.parse(eventText.replace("Slack interaction: ", "")) as {
|
||||
actionId: string;
|
||||
actionType: string;
|
||||
value: string;
|
||||
userId: string;
|
||||
channelId: string;
|
||||
messageTs: string;
|
||||
};
|
||||
expect(payload).toMatchObject({
|
||||
actionId: "openclaw:verify",
|
||||
actionType: "button",
|
||||
value: "approved",
|
||||
userId: "U123",
|
||||
channelId: "C1",
|
||||
messageTs: "100.200",
|
||||
});
|
||||
expect(app.client.chat.update).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("captures select values and skips chat.update for non-button actions", async () => {
|
||||
enqueueSystemEventMock.mockReset();
|
||||
const { ctx, app, 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: "U555" },
|
||||
channel: { id: "C1" },
|
||||
message: {
|
||||
ts: "111.222",
|
||||
blocks: [{ type: "actions", block_id: "select_block", elements: [] }],
|
||||
},
|
||||
},
|
||||
action: {
|
||||
type: "static_select",
|
||||
action_id: "openclaw:pick",
|
||||
block_id: "select_block",
|
||||
selected_option: {
|
||||
text: { type: "plain_text", text: "Canary" },
|
||||
value: "canary",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(ack).toHaveBeenCalled();
|
||||
expect(enqueueSystemEventMock).toHaveBeenCalledTimes(1);
|
||||
const [eventText] = enqueueSystemEventMock.mock.calls[0] as [string];
|
||||
const payload = JSON.parse(eventText.replace("Slack interaction: ", "")) as {
|
||||
actionType: string;
|
||||
selectedValues?: string[];
|
||||
};
|
||||
expect(payload.actionType).toBe("static_select");
|
||||
expect(payload.selectedValues).toEqual(["canary"]);
|
||||
expect(app.client.chat.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
225
src/slack/monitor/events/interactions.ts
Normal file
225
src/slack/monitor/events/interactions.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
import type { BlockAction, SlackActionMiddlewareArgs } from "@slack/bolt";
|
||||
import type { Block, KnownBlock } from "@slack/web-api";
|
||||
import type { SlackMonitorContext } from "../context.js";
|
||||
import { enqueueSystemEvent } from "../../../infra/system-events.js";
|
||||
|
||||
// Prefix for OpenClaw-generated action IDs to scope our handler
|
||||
const OPENCLAW_ACTION_PREFIX = "openclaw:";
|
||||
|
||||
type InteractionMessageBlock = {
|
||||
type?: string;
|
||||
block_id?: string;
|
||||
elements?: Array<{ action_id?: string }>;
|
||||
};
|
||||
|
||||
type SelectOption = {
|
||||
value?: string;
|
||||
text?: { text?: string };
|
||||
};
|
||||
|
||||
type InteractionSummary = {
|
||||
actionId: string;
|
||||
blockId?: string;
|
||||
actionType?: string;
|
||||
value?: string;
|
||||
selectedValues?: string[];
|
||||
selectedLabels?: string[];
|
||||
selectedDate?: string;
|
||||
selectedTime?: string;
|
||||
selectedDateTime?: number;
|
||||
inputValue?: string;
|
||||
userId?: string;
|
||||
channelId?: string;
|
||||
messageTs?: string;
|
||||
};
|
||||
|
||||
function readOptionValues(options: unknown): string[] | undefined {
|
||||
if (!Array.isArray(options)) {
|
||||
return undefined;
|
||||
}
|
||||
const values = options
|
||||
.map((option) => (option && typeof option === "object" ? (option as SelectOption).value : null))
|
||||
.filter((value): value is string => typeof value === "string" && value.trim().length > 0);
|
||||
return values.length > 0 ? values : undefined;
|
||||
}
|
||||
|
||||
function readOptionLabels(options: unknown): string[] | undefined {
|
||||
if (!Array.isArray(options)) {
|
||||
return undefined;
|
||||
}
|
||||
const labels = options
|
||||
.map((option) =>
|
||||
option && typeof option === "object" ? ((option as SelectOption).text?.text ?? null) : null,
|
||||
)
|
||||
.filter((label): label is string => typeof label === "string" && label.trim().length > 0);
|
||||
return labels.length > 0 ? labels : undefined;
|
||||
}
|
||||
|
||||
function summarizeAction(action: BlockAction): Omit<InteractionSummary, "actionId" | "blockId"> {
|
||||
const typed = action as BlockAction & {
|
||||
selected_option?: SelectOption;
|
||||
selected_options?: SelectOption[];
|
||||
selected_user?: string;
|
||||
selected_users?: string[];
|
||||
selected_channel?: string;
|
||||
selected_channels?: string[];
|
||||
selected_conversation?: string;
|
||||
selected_conversations?: string[];
|
||||
selected_date?: string;
|
||||
selected_time?: string;
|
||||
selected_date_time?: number;
|
||||
value?: string;
|
||||
};
|
||||
const actionType = typed.type;
|
||||
const selectedValues = [
|
||||
...(typed.selected_option?.value ? [typed.selected_option.value] : []),
|
||||
...(readOptionValues(typed.selected_options) ?? []),
|
||||
...(typed.selected_user ? [typed.selected_user] : []),
|
||||
...(Array.isArray(typed.selected_users) ? typed.selected_users : []),
|
||||
...(typed.selected_channel ? [typed.selected_channel] : []),
|
||||
...(Array.isArray(typed.selected_channels) ? typed.selected_channels : []),
|
||||
...(typed.selected_conversation ? [typed.selected_conversation] : []),
|
||||
...(Array.isArray(typed.selected_conversations) ? typed.selected_conversations : []),
|
||||
].filter((entry) => typeof entry === "string" && entry.trim().length > 0);
|
||||
const selectedLabels = readOptionLabels(typed.selected_options);
|
||||
|
||||
return {
|
||||
actionType,
|
||||
value: typed.value,
|
||||
selectedValues: selectedValues.length > 0 ? selectedValues : undefined,
|
||||
selectedLabels,
|
||||
selectedDate: typed.selected_date,
|
||||
selectedTime: typed.selected_time,
|
||||
selectedDateTime:
|
||||
typeof typed.selected_date_time === "number" ? typed.selected_date_time : undefined,
|
||||
inputValue: actionType === "plain_text_input" ? typed.value : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function isBulkActionsBlock(block: InteractionMessageBlock): boolean {
|
||||
return (
|
||||
block.type === "actions" &&
|
||||
Array.isArray(block.elements) &&
|
||||
block.elements.length > 0 &&
|
||||
block.elements.every((el) => typeof el.action_id === "string" && el.action_id.includes("_all_"))
|
||||
);
|
||||
}
|
||||
|
||||
export function registerSlackInteractionEvents(params: { ctx: SlackMonitorContext }) {
|
||||
const { ctx } = params;
|
||||
if (typeof ctx.app.action !== "function") {
|
||||
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<BlockAction>) => {
|
||||
const { ack, body, action, respond } = args;
|
||||
|
||||
// Acknowledge the action immediately to prevent the warning icon
|
||||
await ack();
|
||||
|
||||
// Extract action details using proper Bolt types
|
||||
const actionId = action.action_id;
|
||||
const blockId = action.block_id;
|
||||
const userId = body.user.id;
|
||||
const channelId = body.channel?.id;
|
||||
const messageTs = body.message?.ts;
|
||||
const actionSummary = summarizeAction(action);
|
||||
const eventPayload: InteractionSummary = {
|
||||
actionId,
|
||||
blockId,
|
||||
...actionSummary,
|
||||
userId,
|
||||
channelId,
|
||||
messageTs,
|
||||
};
|
||||
|
||||
// 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: "channel",
|
||||
});
|
||||
|
||||
// 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(`Slack interaction: ${JSON.stringify(eventPayload)}`, {
|
||||
sessionKey,
|
||||
contextKey,
|
||||
});
|
||||
|
||||
const originalBlocks = (body.message as { blocks?: unknown[] } | undefined)?.blocks;
|
||||
if (!Array.isArray(originalBlocks) || !channelId || !messageTs) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (action.type !== "button") {
|
||||
return;
|
||||
}
|
||||
|
||||
const buttonText = action.text?.text ?? actionId;
|
||||
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: `:white_check_mark: *${buttonText}* selected` }],
|
||||
};
|
||||
}
|
||||
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: (body.message as { text?: string } | undefined)?.text ?? "",
|
||||
blocks: updatedBlocks as (Block | KnownBlock)[],
|
||||
});
|
||||
} 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.
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user