mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 10:01:24 +00:00
454 lines
14 KiB
TypeScript
454 lines
14 KiB
TypeScript
import type { 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 = {
|
|
interactionType?: "block_action" | "view_submission" | "view_closed";
|
|
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;
|
|
};
|
|
|
|
type ModalInputSummary = {
|
|
blockId: string;
|
|
actionId: string;
|
|
actionType?: string;
|
|
value?: string;
|
|
selectedValues?: string[];
|
|
selectedLabels?: string[];
|
|
selectedDate?: string;
|
|
selectedTime?: string;
|
|
selectedDateTime?: number;
|
|
inputValue?: string;
|
|
};
|
|
|
|
type ModalPrivateMetadata = {
|
|
sessionKey?: string;
|
|
channelId?: string;
|
|
channelType?: 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: Record<string, unknown>,
|
|
): Omit<InteractionSummary, "actionId" | "blockId"> {
|
|
const typed = action as {
|
|
type?: string;
|
|
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: typed.value,
|
|
};
|
|
}
|
|
|
|
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_"))
|
|
);
|
|
}
|
|
|
|
function summarizeViewState(values: unknown): ModalInputSummary[] {
|
|
if (!values || typeof values !== "object") {
|
|
return [];
|
|
}
|
|
const entries: ModalInputSummary[] = [];
|
|
for (const [blockId, blockValue] of Object.entries(values as Record<string, unknown>)) {
|
|
if (!blockValue || typeof blockValue !== "object") {
|
|
continue;
|
|
}
|
|
for (const [actionId, rawAction] of Object.entries(blockValue as Record<string, unknown>)) {
|
|
if (!rawAction || typeof rawAction !== "object") {
|
|
continue;
|
|
}
|
|
const actionSummary = summarizeAction(rawAction as Record<string, unknown>);
|
|
entries.push({
|
|
blockId,
|
|
actionId,
|
|
...actionSummary,
|
|
});
|
|
}
|
|
}
|
|
return entries;
|
|
}
|
|
|
|
function parseModalPrivateMetadata(raw: unknown): ModalPrivateMetadata {
|
|
if (typeof raw !== "string" || raw.trim().length === 0) {
|
|
return {};
|
|
}
|
|
try {
|
|
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
|
const sessionKey =
|
|
typeof parsed.sessionKey === "string" && parsed.sessionKey.trim().length > 0
|
|
? parsed.sessionKey
|
|
: undefined;
|
|
const channelId =
|
|
typeof parsed.channelId === "string" && parsed.channelId.trim().length > 0
|
|
? parsed.channelId
|
|
: undefined;
|
|
const channelType =
|
|
typeof parsed.channelType === "string" && parsed.channelType.trim().length > 0
|
|
? parsed.channelType
|
|
: undefined;
|
|
return { sessionKey, channelId, channelType };
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
function resolveModalSessionRouting(params: {
|
|
ctx: SlackMonitorContext;
|
|
privateMetadata: unknown;
|
|
}): { sessionKey: string; channelId?: string; channelType?: string } {
|
|
const metadata = parseModalPrivateMetadata(params.privateMetadata);
|
|
if (metadata.sessionKey) {
|
|
return { sessionKey: metadata.sessionKey };
|
|
}
|
|
if (metadata.channelId) {
|
|
return {
|
|
sessionKey: params.ctx.resolveSlackSystemEventSessionKey({
|
|
channelId: metadata.channelId,
|
|
channelType: metadata.channelType,
|
|
}),
|
|
channelId: metadata.channelId,
|
|
channelType: metadata.channelType,
|
|
};
|
|
}
|
|
return {
|
|
sessionKey: params.ctx.resolveSlackSystemEventSessionKey({}),
|
|
};
|
|
}
|
|
|
|
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) => {
|
|
const { ack, body, action, respond } = args;
|
|
const typedBody = body as unknown as {
|
|
user?: { id?: string };
|
|
channel?: { id?: string };
|
|
message?: { ts?: string; text?: string; blocks?: unknown[] };
|
|
};
|
|
|
|
// Acknowledge the action immediately to prevent the warning icon
|
|
await ack();
|
|
|
|
// Extract action details using proper Bolt types
|
|
const typedAction = action as unknown as Record<string, unknown> & {
|
|
action_id?: string;
|
|
block_id?: string;
|
|
type?: string;
|
|
text?: { text?: string };
|
|
};
|
|
const actionId = typedAction.action_id ?? "unknown";
|
|
const blockId = typedAction.block_id;
|
|
const userId = typedBody.user?.id ?? "unknown";
|
|
const channelId = typedBody.channel?.id;
|
|
const messageTs = typedBody.message?.ts;
|
|
const actionSummary = summarizeAction(typedAction);
|
|
const eventPayload: InteractionSummary = {
|
|
interactionType: "block_action",
|
|
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 = typedBody.message?.blocks;
|
|
if (!Array.isArray(originalBlocks) || !channelId || !messageTs) {
|
|
return;
|
|
}
|
|
|
|
if (typedAction.type !== "button") {
|
|
return;
|
|
}
|
|
|
|
const buttonText = typedAction.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: typedBody.message?.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.
|
|
}
|
|
}
|
|
},
|
|
);
|
|
|
|
if (typeof ctx.app.view !== "function") {
|
|
return;
|
|
}
|
|
|
|
// Handle OpenClaw modal submissions with callback_ids scoped by our prefix.
|
|
ctx.app.view(
|
|
new RegExp(`^${OPENCLAW_ACTION_PREFIX}`),
|
|
async ({ ack, body }: { ack: () => Promise<void>; body: unknown }) => {
|
|
await ack();
|
|
|
|
const typedBody = body as {
|
|
user?: { id?: string };
|
|
team?: { id?: string };
|
|
view?: {
|
|
id?: string;
|
|
callback_id?: string;
|
|
private_metadata?: string;
|
|
state?: { values?: unknown };
|
|
};
|
|
};
|
|
|
|
const callbackId = typedBody.view?.callback_id ?? "unknown";
|
|
const userId = typedBody.user?.id ?? "unknown";
|
|
const viewId = typedBody.view?.id;
|
|
const inputs = summarizeViewState(typedBody.view?.state?.values);
|
|
const sessionRouting = resolveModalSessionRouting({
|
|
ctx,
|
|
privateMetadata: typedBody.view?.private_metadata,
|
|
});
|
|
const eventPayload = {
|
|
interactionType: "view_submission",
|
|
actionId: `view:${callbackId}`,
|
|
callbackId,
|
|
viewId,
|
|
userId,
|
|
teamId: typedBody.team?.id,
|
|
privateMetadata: typedBody.view?.private_metadata,
|
|
routedChannelId: sessionRouting.channelId,
|
|
routedChannelType: sessionRouting.channelType,
|
|
inputs,
|
|
};
|
|
|
|
ctx.runtime.log?.(
|
|
`slack:interaction view_submission callback=${callbackId} user=${userId} inputs=${inputs.length}`,
|
|
);
|
|
|
|
enqueueSystemEvent(`Slack interaction: ${JSON.stringify(eventPayload)}`, {
|
|
sessionKey: sessionRouting.sessionKey,
|
|
contextKey: ["slack:interaction:view", callbackId, viewId, userId]
|
|
.filter(Boolean)
|
|
.join(":"),
|
|
});
|
|
},
|
|
);
|
|
|
|
const viewClosed = (
|
|
ctx.app as unknown as {
|
|
viewClosed?: (
|
|
matcher: RegExp,
|
|
handler: (args: { ack: () => Promise<void>; body: unknown }) => Promise<void>,
|
|
) => void;
|
|
}
|
|
).viewClosed;
|
|
if (typeof viewClosed !== "function") {
|
|
return;
|
|
}
|
|
|
|
// Handle modal close events so agent workflows can react to cancelled forms.
|
|
viewClosed(
|
|
new RegExp(`^${OPENCLAW_ACTION_PREFIX}`),
|
|
async ({ ack, body }: { ack: () => Promise<void>; body: unknown }) => {
|
|
await ack();
|
|
|
|
const typedBody = body as {
|
|
user?: { id?: string };
|
|
team?: { id?: string };
|
|
view?: {
|
|
id?: string;
|
|
callback_id?: string;
|
|
private_metadata?: string;
|
|
state?: { values?: unknown };
|
|
};
|
|
is_cleared?: boolean;
|
|
};
|
|
|
|
const callbackId = typedBody.view?.callback_id ?? "unknown";
|
|
const userId = typedBody.user?.id ?? "unknown";
|
|
const viewId = typedBody.view?.id;
|
|
const inputs = summarizeViewState(typedBody.view?.state?.values);
|
|
const sessionRouting = resolveModalSessionRouting({
|
|
ctx,
|
|
privateMetadata: typedBody.view?.private_metadata,
|
|
});
|
|
const eventPayload = {
|
|
interactionType: "view_closed",
|
|
actionId: `view:${callbackId}`,
|
|
callbackId,
|
|
viewId,
|
|
userId,
|
|
teamId: typedBody.team?.id,
|
|
isCleared: typedBody.is_cleared === true,
|
|
privateMetadata: typedBody.view?.private_metadata,
|
|
routedChannelId: sessionRouting.channelId,
|
|
routedChannelType: sessionRouting.channelType,
|
|
inputs,
|
|
};
|
|
|
|
ctx.runtime.log?.(
|
|
`slack:interaction view_closed callback=${callbackId} user=${userId} cleared=${
|
|
typedBody.is_cleared === true
|
|
}`,
|
|
);
|
|
|
|
enqueueSystemEvent(`Slack interaction: ${JSON.stringify(eventPayload)}`, {
|
|
sessionKey: sessionRouting.sessionKey,
|
|
contextKey: ["slack:interaction:view-closed", callbackId, viewId, userId]
|
|
.filter(Boolean)
|
|
.join(":"),
|
|
});
|
|
},
|
|
);
|
|
}
|