refactor(plugins): move channel behavior into plugins

This commit is contained in:
Peter Steinberger
2026-04-03 17:54:01 +01:00
parent c52df32878
commit ab96520bba
158 changed files with 5967 additions and 5054 deletions

View File

@@ -68,6 +68,7 @@ import {
} from "./runtime-api.js";
import { getOptionalSlackRuntime, getSlackRuntime } from "./runtime.js";
import { fetchSlackScopes } from "./scopes.js";
import { collectSlackSecurityAuditFindings } from "./security-audit.js";
import { sendMessageSlack } from "./send.js";
import { slackSetupAdapter } from "./setup-core.js";
import { slackSetupWizard } from "./setup-surface.js";
@@ -460,6 +461,9 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount, SlackProbe> = crea
});
},
},
mentions: {
stripPatterns: () => ["<@[^>\\s]+>"],
},
},
pairing: {
text: {
@@ -488,6 +492,7 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount, SlackProbe> = crea
security: {
resolveDmPolicy: resolveSlackDmPolicy,
collectWarnings: collectSlackSecurityWarnings,
collectAuditFindings: collectSlackSecurityAuditFindings,
},
threading: {
scopedAccountReplyToMode: {

View File

@@ -0,0 +1,304 @@
import {
type ChannelDoctorAdapter,
type ChannelDoctorConfigMutation,
} from "openclaw/plugin-sdk/channel-contract";
import {
formatSlackStreamingBooleanMigrationMessage,
formatSlackStreamModeMigrationMessage,
resolveSlackNativeStreaming,
resolveSlackStreamingMode,
type OpenClawConfig,
} from "openclaw/plugin-sdk/config-runtime";
import {
collectProviderDangerousNameMatchingScopes,
isSlackMutableAllowEntry,
} from "openclaw/plugin-sdk/runtime";
function asObjectRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function sanitizeForLog(value: string): string {
return value.replace(/[\u0000-\u001f\u007f]+/g, " ").trim();
}
function normalizeSlackDmAliases(params: {
entry: Record<string, unknown>;
pathPrefix: string;
changes: string[];
}): { entry: Record<string, unknown>; changed: boolean } {
let changed = false;
let updated: Record<string, unknown> = params.entry;
const rawDm = updated.dm;
const dm = asObjectRecord(rawDm) ? (structuredClone(rawDm) as Record<string, unknown>) : null;
let dmChanged = false;
const allowFromEqual = (a: unknown, b: unknown): boolean => {
if (!Array.isArray(a) || !Array.isArray(b)) {
return false;
}
const na = a.map((v) => String(v).trim()).filter(Boolean);
const nb = b.map((v) => String(v).trim()).filter(Boolean);
if (na.length !== nb.length) {
return false;
}
return na.every((v, i) => v === nb[i]);
};
const topDmPolicy = updated.dmPolicy;
const legacyDmPolicy = dm?.policy;
if (topDmPolicy === undefined && legacyDmPolicy !== undefined) {
updated = { ...updated, dmPolicy: legacyDmPolicy };
changed = true;
if (dm) {
delete dm.policy;
dmChanged = true;
}
params.changes.push(`Moved ${params.pathPrefix}.dm.policy → ${params.pathPrefix}.dmPolicy.`);
} else if (
topDmPolicy !== undefined &&
legacyDmPolicy !== undefined &&
topDmPolicy === legacyDmPolicy
) {
if (dm) {
delete dm.policy;
dmChanged = true;
params.changes.push(`Removed ${params.pathPrefix}.dm.policy (dmPolicy already set).`);
}
}
const topAllowFrom = updated.allowFrom;
const legacyAllowFrom = dm?.allowFrom;
if (topAllowFrom === undefined && legacyAllowFrom !== undefined) {
updated = { ...updated, allowFrom: legacyAllowFrom };
changed = true;
if (dm) {
delete dm.allowFrom;
dmChanged = true;
}
params.changes.push(
`Moved ${params.pathPrefix}.dm.allowFrom → ${params.pathPrefix}.allowFrom.`,
);
} else if (
topAllowFrom !== undefined &&
legacyAllowFrom !== undefined &&
allowFromEqual(topAllowFrom, legacyAllowFrom)
) {
if (dm) {
delete dm.allowFrom;
dmChanged = true;
params.changes.push(`Removed ${params.pathPrefix}.dm.allowFrom (allowFrom already set).`);
}
}
if (dm && asObjectRecord(rawDm) && dmChanged) {
const keys = Object.keys(dm);
if (keys.length === 0) {
if (updated.dm !== undefined) {
const { dm: _ignored, ...rest } = updated;
updated = rest;
changed = true;
params.changes.push(`Removed empty ${params.pathPrefix}.dm after migration.`);
}
} else {
updated = { ...updated, dm };
changed = true;
}
}
return { entry: updated, changed };
}
function normalizeSlackStreamingAliases(params: {
entry: Record<string, unknown>;
pathPrefix: string;
changes: string[];
}): { entry: Record<string, unknown>; changed: boolean } {
let updated = params.entry;
const hadLegacyStreamMode = updated.streamMode !== undefined;
const legacyStreaming = updated.streaming;
const beforeStreaming = updated.streaming;
const beforeNativeStreaming = updated.nativeStreaming;
const resolvedStreaming = resolveSlackStreamingMode(updated);
const resolvedNativeStreaming = resolveSlackNativeStreaming(updated);
const shouldNormalize =
hadLegacyStreamMode ||
typeof legacyStreaming === "boolean" ||
(typeof legacyStreaming === "string" && legacyStreaming !== resolvedStreaming);
if (!shouldNormalize) {
return { entry: updated, changed: false };
}
let changed = false;
if (beforeStreaming !== resolvedStreaming) {
updated = { ...updated, streaming: resolvedStreaming };
changed = true;
}
if (
typeof beforeNativeStreaming !== "boolean" ||
beforeNativeStreaming !== resolvedNativeStreaming
) {
updated = { ...updated, nativeStreaming: resolvedNativeStreaming };
changed = true;
}
if (hadLegacyStreamMode) {
const { streamMode: _ignored, ...rest } = updated;
updated = rest;
changed = true;
params.changes.push(
formatSlackStreamModeMigrationMessage(params.pathPrefix, resolvedStreaming),
);
}
if (typeof legacyStreaming === "boolean") {
params.changes.push(
formatSlackStreamingBooleanMigrationMessage(params.pathPrefix, resolvedNativeStreaming),
);
} else if (typeof legacyStreaming === "string" && legacyStreaming !== resolvedStreaming) {
params.changes.push(
`Normalized ${params.pathPrefix}.streaming (${legacyStreaming}) → (${resolvedStreaming}).`,
);
}
return { entry: updated, changed };
}
function normalizeSlackCompatibilityConfig(cfg: OpenClawConfig): ChannelDoctorConfigMutation {
const rawEntry = asObjectRecord((cfg.channels as Record<string, unknown> | undefined)?.slack);
if (!rawEntry) {
return { config: cfg, changes: [] };
}
const changes: string[] = [];
let updated = rawEntry;
let changed = false;
const base = normalizeSlackDmAliases({
entry: rawEntry,
pathPrefix: "channels.slack",
changes,
});
updated = base.entry;
changed = base.changed;
const baseStreaming = normalizeSlackStreamingAliases({
entry: updated,
pathPrefix: "channels.slack",
changes,
});
updated = baseStreaming.entry;
changed = changed || baseStreaming.changed;
const rawAccounts = asObjectRecord(updated.accounts);
if (rawAccounts) {
let accountsChanged = false;
const accounts = { ...rawAccounts };
for (const [accountId, rawAccount] of Object.entries(rawAccounts)) {
const account = asObjectRecord(rawAccount);
if (!account) {
continue;
}
let accountEntry = account;
let accountChanged = false;
const dm = normalizeSlackDmAliases({
entry: account,
pathPrefix: `channels.slack.accounts.${accountId}`,
changes,
});
accountEntry = dm.entry;
accountChanged = dm.changed;
const streaming = normalizeSlackStreamingAliases({
entry: accountEntry,
pathPrefix: `channels.slack.accounts.${accountId}`,
changes,
});
accountEntry = streaming.entry;
accountChanged = accountChanged || streaming.changed;
if (accountChanged) {
accounts[accountId] = accountEntry;
accountsChanged = true;
}
}
if (accountsChanged) {
updated = { ...updated, accounts };
changed = true;
}
}
if (!changed) {
return { config: cfg, changes: [] };
}
return {
config: {
...cfg,
channels: {
...cfg.channels,
slack: updated as unknown as NonNullable<OpenClawConfig["channels"]>["slack"],
} as OpenClawConfig["channels"],
},
changes,
};
}
export function collectSlackMutableAllowlistWarnings(cfg: OpenClawConfig): string[] {
const hits: Array<{ path: string; entry: string }> = [];
const addHits = (pathLabel: string, list: unknown) => {
if (!Array.isArray(list)) {
return;
}
for (const entry of list) {
const text = String(entry).trim();
if (!text || text === "*" || !isSlackMutableAllowEntry(text)) {
continue;
}
hits.push({ path: pathLabel, entry: text });
}
};
for (const scope of collectProviderDangerousNameMatchingScopes(cfg, "slack")) {
if (scope.dangerousNameMatchingEnabled) {
continue;
}
addHits(`${scope.prefix}.allowFrom`, scope.account.allowFrom);
const dm = asObjectRecord(scope.account.dm);
if (dm) {
addHits(`${scope.prefix}.dm.allowFrom`, dm.allowFrom);
}
const channels = asObjectRecord(scope.account.channels);
if (!channels) {
continue;
}
for (const [channelKey, channelRaw] of Object.entries(channels)) {
const channel = asObjectRecord(channelRaw);
if (channel) {
addHits(`${scope.prefix}.channels.${channelKey}.users`, channel.users);
}
}
}
if (hits.length === 0) {
return [];
}
const exampleLines = hits
.slice(0, 8)
.map((hit) => `- ${sanitizeForLog(hit.path)}: ${sanitizeForLog(hit.entry)}`);
const remaining =
hits.length > 8 ? `- +${hits.length - 8} more mutable allowlist entries.` : null;
return [
`- Found ${hits.length} mutable allowlist ${hits.length === 1 ? "entry" : "entries"} across slack while name matching is disabled by default.`,
...exampleLines,
...(remaining ? [remaining] : []),
"- Option A (break-glass): enable channels.slack.dangerousNameMatching=true for the affected scope.",
"- Option B (recommended): resolve names to stable Slack IDs and rewrite the allowlist entries.",
];
}
export const slackDoctor: ChannelDoctorAdapter = {
dmAllowFromMode: "topOrNested",
groupModel: "route",
groupAllowFromFallbackToAllowFrom: false,
warnOnEmptyGroupSenderAllowlist: false,
normalizeCompatibilityConfig: ({ cfg }) => normalizeSlackCompatibilityConfig(cfg),
collectMutableAllowlistWarnings: ({ cfg }) => collectSlackMutableAllowlistWarnings(cfg),
};

View File

@@ -0,0 +1,109 @@
import {
createInteractiveConversationBindingHelpers,
dispatchPluginInteractiveHandler,
type PluginConversationBinding,
type PluginConversationBindingRequestParams,
type PluginConversationBindingRequestResult,
type PluginInteractiveRegistration,
} from "openclaw/plugin-sdk/plugin-runtime";
export type SlackInteractiveHandlerContext = {
channel: "slack";
accountId: string;
interactionId: string;
conversationId: string;
parentConversationId?: string;
senderId?: string;
senderUsername?: string;
threadId?: string;
auth: {
isAuthorizedSender: boolean;
};
interaction: {
kind: "button" | "select";
data: string;
namespace: string;
payload: string;
actionId: string;
blockId?: string;
messageTs?: string;
threadTs?: string;
value?: string;
selectedValues?: string[];
selectedLabels?: string[];
triggerId?: string;
responseUrl?: string;
};
respond: {
acknowledge: () => Promise<void>;
reply: (params: { text: string; responseType?: "ephemeral" | "in_channel" }) => Promise<void>;
followUp: (params: {
text: string;
responseType?: "ephemeral" | "in_channel";
}) => Promise<void>;
editMessage: (params: { text?: string; blocks?: unknown[] }) => Promise<void>;
};
requestConversationBinding: (
params?: PluginConversationBindingRequestParams,
) => Promise<PluginConversationBindingRequestResult>;
detachConversationBinding: () => Promise<{ removed: boolean }>;
getCurrentConversationBinding: () => Promise<PluginConversationBinding | null>;
};
export type SlackInteractiveHandlerRegistration = PluginInteractiveRegistration<
SlackInteractiveHandlerContext,
"slack"
>;
export type SlackInteractiveDispatchContext = Omit<
SlackInteractiveHandlerContext,
| "interaction"
| "respond"
| "channel"
| "requestConversationBinding"
| "detachConversationBinding"
| "getCurrentConversationBinding"
> & {
interaction: Omit<
SlackInteractiveHandlerContext["interaction"],
"data" | "namespace" | "payload"
>;
};
export async function dispatchSlackPluginInteractiveHandler(params: {
data: string;
interactionId: string;
ctx: SlackInteractiveDispatchContext;
respond: SlackInteractiveHandlerContext["respond"];
onMatched?: () => Promise<void> | void;
}) {
return await dispatchPluginInteractiveHandler<SlackInteractiveHandlerRegistration>({
channel: "slack",
data: params.data,
dedupeId: params.interactionId,
onMatched: params.onMatched,
invoke: ({ registration, namespace, payload }) =>
registration.handler({
...params.ctx,
channel: "slack",
interaction: {
...params.ctx.interaction,
data: params.data,
namespace,
payload,
},
respond: params.respond,
...createInteractiveConversationBindingHelpers({
registration,
senderId: params.ctx.senderId,
conversation: {
channel: "slack",
accountId: params.ctx.accountId,
conversationId: params.ctx.conversationId,
parentConversationId: params.ctx.parentConversationId,
threadId: params.ctx.threadId,
},
}),
}),
});
}

View File

@@ -1,39 +1,96 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { describe, expect, it } from "vitest";
import { isSlackInteractiveRepliesEnabled } from "./interactive-replies.js";
import { compileSlackInteractiveReplies } from "./interactive-replies.js";
describe("isSlackInteractiveRepliesEnabled", () => {
it("uses the configured default account when accountId is unknown and multiple accounts exist", () => {
const cfg = {
channels: {
slack: {
defaultAccount: "one",
accounts: {
one: {
capabilities: { interactiveReplies: true },
},
two: {},
},
describe("compileSlackInteractiveReplies", () => {
it("compiles inline Slack button directives into shared interactive blocks", () => {
const result = compileSlackInteractiveReplies({
text: "[bot] hello [[slack_buttons: Retry:retry, Ignore:ignore]]",
});
expect(result.text).toBe("[bot] hello");
expect(result.interactive).toEqual({
blocks: [
{
type: "text",
text: "[bot] hello",
},
},
} as OpenClawConfig;
expect(isSlackInteractiveRepliesEnabled({ cfg, accountId: undefined })).toBe(true);
{
type: "buttons",
buttons: [
{
label: "Retry",
value: "retry",
},
{
label: "Ignore",
value: "ignore",
},
],
},
],
});
});
it("uses the only configured account when accountId is unknown", () => {
const cfg = {
channels: {
slack: {
accounts: {
only: {
capabilities: { interactiveReplies: true },
},
},
},
},
} as OpenClawConfig;
it("compiles simple trailing Options lines into Slack buttons", () => {
const result = compileSlackInteractiveReplies({
text: "Current verbose level: off.\nOptions: on, full, off.",
});
expect(isSlackInteractiveRepliesEnabled({ cfg, accountId: undefined })).toBe(true);
expect(result.text).toBe("Current verbose level: off.");
expect(result.interactive).toEqual({
blocks: [
{
type: "text",
text: "Current verbose level: off.",
},
{
type: "buttons",
buttons: [
{ label: "on", value: "on" },
{ label: "full", value: "full" },
{ label: "off", value: "off" },
],
},
],
});
});
it("uses a Slack select when Options lines exceed button capacity", () => {
const result = compileSlackInteractiveReplies({
text: "Choose a reasoning level.\nOptions: off, minimal, low, medium, high, adaptive.",
});
expect(result.text).toBe("Choose a reasoning level.");
expect(result.interactive).toEqual({
blocks: [
{
type: "text",
text: "Choose a reasoning level.",
},
{
type: "select",
placeholder: "Choose an option",
options: [
{ label: "off", value: "off" },
{ label: "minimal", value: "minimal" },
{ label: "low", value: "low" },
{ label: "medium", value: "medium" },
{ label: "high", value: "high" },
{ label: "adaptive", value: "adaptive" },
],
},
],
});
});
it("leaves complex Options lines as plain text", () => {
const result = compileSlackInteractiveReplies({
text: "ACP runtime choices.\nOptions: host=auto|sandbox|gateway|node, security=deny|allowlist|full.",
});
expect(result.text).toBe(
"ACP runtime choices.\nOptions: host=auto|sandbox|gateway|node, security=deny|allowlist|full.",
);
expect(result.interactive).toBeUndefined();
});
});

View File

@@ -1,6 +1,153 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import { resolveDefaultSlackAccountId, resolveSlackAccount } from "./accounts.js";
const SLACK_BUTTON_MAX_ITEMS = 5;
const SLACK_SELECT_MAX_ITEMS = 100;
const SLACK_DIRECTIVE_RE = /\[\[(slack_buttons|slack_select):\s*([^\]]+)\]\]/gi;
const SLACK_OPTIONS_LINE_RE = /^\s*Options:\s*(.+?)\s*\.?\s*$/i;
const SLACK_AUTO_SELECT_MAX_ITEMS = 12;
const SLACK_SIMPLE_OPTION_RE = /^[a-z0-9][a-z0-9 _+/-]{0,31}$/i;
type SlackChoice = {
label: string;
value: string;
style?: "primary" | "secondary" | "success" | "danger";
};
function parseChoice(raw: string, options?: { allowStyle?: boolean }): SlackChoice | null {
const trimmed = raw.trim();
if (!trimmed) {
return null;
}
const delimiter = trimmed.indexOf(":");
if (delimiter === -1) {
return {
label: trimmed,
value: trimmed,
};
}
const label = trimmed.slice(0, delimiter).trim();
let value = trimmed.slice(delimiter + 1).trim();
if (!label || !value) {
return null;
}
let style: SlackChoice["style"];
if (options?.allowStyle) {
const styleDelimiter = value.lastIndexOf(":");
if (styleDelimiter !== -1) {
const maybeStyle = value
.slice(styleDelimiter + 1)
.trim()
.toLowerCase();
if (
maybeStyle === "primary" ||
maybeStyle === "secondary" ||
maybeStyle === "success" ||
maybeStyle === "danger"
) {
const unstyledValue = value.slice(0, styleDelimiter).trim();
if (unstyledValue) {
value = unstyledValue;
style = maybeStyle;
}
}
}
}
return style ? { label, value, style } : { label, value };
}
function parseChoices(
raw: string,
maxItems: number,
options?: { allowStyle?: boolean },
): SlackChoice[] {
return raw
.split(",")
.map((entry) => parseChoice(entry, options))
.filter((entry): entry is SlackChoice => Boolean(entry))
.slice(0, maxItems);
}
function buildTextBlock(
text: string,
): NonNullable<ReplyPayload["interactive"]>["blocks"][number] | null {
const trimmed = text.trim();
if (!trimmed) {
return null;
}
return { type: "text", text: trimmed };
}
function buildButtonsBlock(
raw: string,
): NonNullable<ReplyPayload["interactive"]>["blocks"][number] | null {
const choices = parseChoices(raw, SLACK_BUTTON_MAX_ITEMS, { allowStyle: true });
if (choices.length === 0) {
return null;
}
return {
type: "buttons",
buttons: choices.map((choice) => ({
label: choice.label,
value: choice.value,
...(choice.style ? { style: choice.style } : {}),
})),
};
}
function buildSelectBlock(
raw: string,
): NonNullable<ReplyPayload["interactive"]>["blocks"][number] | null {
const parts = raw
.split("|")
.map((entry) => entry.trim())
.filter(Boolean);
if (parts.length === 0) {
return null;
}
const [first, second] = parts;
const placeholder = parts.length >= 2 ? first : "Choose an option";
const choices = parseChoices(parts.length >= 2 ? second : first, SLACK_SELECT_MAX_ITEMS);
if (choices.length === 0) {
return null;
}
return {
type: "select",
placeholder,
options: choices,
};
}
function hasSlackBlocks(payload: ReplyPayload): boolean {
const blocks = (payload.channelData?.slack as { blocks?: unknown } | undefined)?.blocks;
if (typeof blocks === "string") {
return blocks.trim().length > 0;
}
return Array.isArray(blocks) && blocks.length > 0;
}
function parseSimpleSlackOptions(raw: string): SlackChoice[] | null {
const entries = raw
.split(",")
.map((entry) => entry.trim())
.filter(Boolean);
if (entries.length < 2 || entries.length > SLACK_AUTO_SELECT_MAX_ITEMS) {
return null;
}
if (!entries.every((entry) => SLACK_SIMPLE_OPTION_RE.test(entry))) {
return null;
}
const deduped = new Set(entries.map((entry) => entry.toLowerCase()));
if (deduped.size !== entries.length) {
return null;
}
return entries.map((entry) => ({
label: entry,
value: entry,
}));
}
function resolveInteractiveRepliesFromCapabilities(capabilities: unknown): boolean {
if (!capabilities) {
return false;
@@ -26,3 +173,114 @@ export function isSlackInteractiveRepliesEnabled(params: {
});
return resolveInteractiveRepliesFromCapabilities(account.config.capabilities);
}
export function compileSlackInteractiveReplies(payload: ReplyPayload): ReplyPayload {
const text = payload.text;
if (!text) {
return payload;
}
const generatedBlocks: NonNullable<ReplyPayload["interactive"]>["blocks"] = [];
const visibleTextParts: string[] = [];
let cursor = 0;
let matchedDirective = false;
let generatedInteractiveBlock = false;
SLACK_DIRECTIVE_RE.lastIndex = 0;
for (const match of text.matchAll(SLACK_DIRECTIVE_RE)) {
matchedDirective = true;
const matchText = match[0];
const directiveType = match[1];
const body = match[2];
const index = match.index ?? 0;
const precedingText = text.slice(cursor, index);
visibleTextParts.push(precedingText);
const section = buildTextBlock(precedingText);
if (section) {
generatedBlocks.push(section);
}
const block =
directiveType.toLowerCase() === "slack_buttons"
? buildButtonsBlock(body)
: buildSelectBlock(body);
if (block) {
generatedInteractiveBlock = true;
generatedBlocks.push(block);
}
cursor = index + matchText.length;
}
const trailingText = text.slice(cursor);
visibleTextParts.push(trailingText);
const trailingSection = buildTextBlock(trailingText);
if (trailingSection) {
generatedBlocks.push(trailingSection);
}
const cleanedText = visibleTextParts.join("");
if (!matchedDirective || !generatedInteractiveBlock) {
return parseSlackOptionsLine(payload);
}
return {
...payload,
text: cleanedText.trim() || undefined,
interactive: {
blocks: [...(payload.interactive?.blocks ?? []), ...generatedBlocks],
},
};
}
export function parseSlackOptionsLine(payload: ReplyPayload): ReplyPayload {
const text = payload.text;
if (!text || payload.interactive?.blocks?.length || hasSlackBlocks(payload)) {
return payload;
}
const lines = text.split("\n");
const lastNonEmptyIndex = [...lines.keys()].toReversed().find((index) => lines[index]?.trim());
if (lastNonEmptyIndex == null) {
return payload;
}
const optionsLine = lines[lastNonEmptyIndex] ?? "";
const match = optionsLine.match(SLACK_OPTIONS_LINE_RE);
if (!match) {
return payload;
}
const choices = parseSimpleSlackOptions(match[1] ?? "");
if (!choices) {
return payload;
}
const bodyText = lines
.filter((_, index) => index !== lastNonEmptyIndex)
.join("\n")
.trim();
const generatedBlocks: NonNullable<ReplyPayload["interactive"]>["blocks"] = [];
const bodyBlock = buildTextBlock(bodyText);
if (bodyBlock) {
generatedBlocks.push(bodyBlock);
}
generatedBlocks.push(
choices.length <= SLACK_BUTTON_MAX_ITEMS
? {
type: "buttons",
buttons: choices,
}
: {
type: "select",
placeholder: "Choose an option",
options: choices,
},
);
return {
...payload,
text: bodyText || undefined,
interactive: {
blocks: [...(payload.interactive?.blocks ?? []), ...generatedBlocks],
},
};
}

View File

@@ -1,8 +1,8 @@
import type { SlackActionMiddlewareArgs } from "@slack/bolt";
import type { Block, KnownBlock } from "@slack/web-api";
import { enqueueSystemEvent } from "openclaw/plugin-sdk/infra-runtime";
import { dispatchPluginInteractiveHandler } from "openclaw/plugin-sdk/plugin-runtime";
import { SLACK_REPLY_BUTTON_ACTION_ID, SLACK_REPLY_SELECT_ACTION_ID } from "../../blocks-render.js";
import { dispatchSlackPluginInteractiveHandler } from "../../interactive-dispatch.js";
import { authorizeSlackSystemEventSender } from "../auth.js";
import type { SlackMonitorContext } from "../context.js";
import {
@@ -536,8 +536,7 @@ async function dispatchSlackPluginInteraction(params: {
) {
return true;
}
const pluginResult = await dispatchPluginInteractiveHandler({
channel: "slack",
const pluginResult = await dispatchSlackPluginInteractiveHandler({
data: params.pluginInteractionData,
interactionId: pluginInteractionId,
ctx: {

View File

@@ -19,6 +19,7 @@ import {
} from "openclaw/plugin-sdk/reply-payload";
import { parseSlackBlocksInput } from "./blocks-input.js";
import { buildSlackInteractiveBlocks, type SlackBlock } from "./blocks-render.js";
import { compileSlackInteractiveReplies } from "./interactive-replies.js";
import { SLACK_TEXT_LIMIT } from "./limits.js";
import { sendMessageSlack, type SlackSendIdentity } from "./send.js";
@@ -161,6 +162,7 @@ export const slackOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct",
chunker: null,
textChunkLimit: SLACK_TEXT_LIMIT,
normalizePayload: ({ payload }) => compileSlackInteractiveReplies(payload),
sendPayload: async (ctx) => {
const payload = {
...ctx.payload,

View File

@@ -18,6 +18,7 @@ import {
type ResolvedSlackAccount,
} from "./accounts.js";
import { SlackChannelConfigSchema } from "./config-schema.js";
import { slackDoctor } from "./doctor.js";
import { isSlackInteractiveRepliesEnabled } from "./interactive-replies.js";
import { getChatChannelMeta, type ChannelPlugin, type OpenClawConfig } from "./runtime-api.js";
@@ -168,6 +169,8 @@ export function createSlackPluginBase(params: {
| "meta"
| "setupWizard"
| "capabilities"
| "commands"
| "doctor"
| "agentPrompt"
| "streaming"
| "reload"
@@ -189,7 +192,24 @@ export function createSlackPluginBase(params: {
media: true,
nativeCommands: true,
},
commands: {
nativeCommandsAutoEnabled: false,
nativeSkillsAutoEnabled: false,
resolveNativeCommandName: ({ commandKey, defaultName }) =>
commandKey === "status" ? "agentstatus" : defaultName,
},
doctor: slackDoctor,
agentPrompt: {
inboundFormattingHints: () => ({
text_markup: "slack_mrkdwn",
rules: [
"Use Slack mrkdwn, not standard Markdown.",
"Bold uses *single asterisks*.",
"Links use <url|label>.",
"Code blocks use triple backticks without a language identifier.",
"Do not use markdown headings or pipe tables.",
],
}),
messageToolHints: ({ cfg, accountId }) =>
isSlackInteractiveRepliesEnabled({ cfg, accountId })
? [
@@ -226,6 +246,8 @@ export function createSlackPluginBase(params: {
| "meta"
| "setupWizard"
| "capabilities"
| "commands"
| "doctor"
| "agentPrompt"
| "streaming"
| "reload"