mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-05 15:10:22 +00:00
refactor(plugins): move channel behavior into plugins
This commit is contained in:
@@ -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: {
|
||||
|
||||
304
extensions/slack/src/doctor.ts
Normal file
304
extensions/slack/src/doctor.ts
Normal 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),
|
||||
};
|
||||
109
extensions/slack/src/interactive-dispatch.ts
Normal file
109
extensions/slack/src/interactive-dispatch.ts
Normal 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,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user