mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-15 12:00:43 +00:00
Slack: add opt-in interactive reply directives (#44607)
* Reply: add Slack interactive directive parser * Reply: wire Slack directives into normalization * Reply: cover Slack directive parsing * Reply: test Slack directive normalization * Slack: hint interactive reply directives * Config: add Slack interactive reply capability type * Config: validate Slack interactive reply capability * Reply: gate Slack directives behind capability * Slack: gate interactive reply hints by capability * Tests: cover Slack interactive reply capability gating * Changelog: note opt-in Slack interactive replies * Slack: fix interactive reply review findings * Slack: harden interactive reply routing and limits * Slack: harden interactive reply trust and validation
This commit is contained in:
@@ -56,6 +56,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Docs/Kubernetes: Add a starter K8s install path with raw manifests, Kind setup, and deployment docs. Thanks @sallyom @dzianisv @egkristi
|
||||
- Agents/subagents: add `sessions_yield` so orchestrators can end the current turn immediately, skip queued tool work, and carry a hidden follow-up payload into the next session turn. (#36537) thanks @jriff
|
||||
- Slack/agent replies: support `channelData.slack.blocks` in the shared reply delivery path so agents can send Block Kit messages through standard Slack outbound delivery. (#44592) Thanks @vincentkoc.
|
||||
- Slack/interactive replies: add opt-in Slack button and select reply directives behind `channels.slack.capabilities.interactiveReplies`, disabled by default unless explicitly enabled. (#44607) Thanks @vincentkoc.
|
||||
|
||||
### Fixes
|
||||
|
||||
|
||||
@@ -137,6 +137,46 @@ describe("slackPlugin outbound", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("slackPlugin agentPrompt", () => {
|
||||
it("tells agents interactive replies are disabled by default", () => {
|
||||
const hints = slackPlugin.agentPrompt?.messageToolHints?.({
|
||||
cfg: {
|
||||
channels: {
|
||||
slack: {
|
||||
botToken: "xoxb-test",
|
||||
appToken: "xapp-test",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(hints).toEqual([
|
||||
"- Slack interactive replies are disabled. If needed, ask to set `channels.slack.capabilities.interactiveReplies=true` (or the same under `channels.slack.accounts.<account>.capabilities`).",
|
||||
]);
|
||||
});
|
||||
|
||||
it("shows Slack interactive reply directives when enabled", () => {
|
||||
const hints = slackPlugin.agentPrompt?.messageToolHints?.({
|
||||
cfg: {
|
||||
channels: {
|
||||
slack: {
|
||||
botToken: "xoxb-test",
|
||||
appToken: "xapp-test",
|
||||
capabilities: { interactiveReplies: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(hints).toContain(
|
||||
"- Slack interactive replies: use `[[slack_buttons: Label:value, Other:other]]` to add action buttons that route clicks back as Slack interaction system events.",
|
||||
);
|
||||
expect(hints).toContain(
|
||||
"- Slack selects: use `[[slack_select: Placeholder | Label:value, Other:other]]` to add a static select menu that routes the chosen value back as a Slack interaction system event.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("slackPlugin config", () => {
|
||||
it("treats HTTP mode accounts with bot token + signing secret as configured", async () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
resolveDefaultSlackAccountId,
|
||||
resolveSlackAccount,
|
||||
resolveSlackReplyToMode,
|
||||
isSlackInteractiveRepliesEnabled,
|
||||
resolveSlackGroupRequireMention,
|
||||
resolveSlackGroupToolPolicy,
|
||||
buildSlackThreadingToolContext,
|
||||
@@ -146,6 +147,17 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
|
||||
media: true,
|
||||
nativeCommands: true,
|
||||
},
|
||||
agentPrompt: {
|
||||
messageToolHints: ({ cfg, accountId }) =>
|
||||
isSlackInteractiveRepliesEnabled({ cfg, accountId })
|
||||
? [
|
||||
"- Slack interactive replies: use `[[slack_buttons: Label:value, Other:other]]` to add action buttons that route clicks back as Slack interaction system events.",
|
||||
"- Slack selects: use `[[slack_select: Placeholder | Label:value, Other:other]]` to add a static select menu that routes the chosen value back as a Slack interaction system event.",
|
||||
]
|
||||
: [
|
||||
"- Slack interactive replies are disabled. If needed, ask to set `channels.slack.capabilities.interactiveReplies=true` (or the same under `channels.slack.accounts.<account>.capabilities`).",
|
||||
],
|
||||
},
|
||||
streaming: {
|
||||
blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
|
||||
},
|
||||
|
||||
@@ -12,11 +12,13 @@ import {
|
||||
resolveResponsePrefixTemplate,
|
||||
type ResponsePrefixContext,
|
||||
} from "./response-prefix-template.js";
|
||||
import { hasSlackDirectives, parseSlackDirectives } from "./slack-directives.js";
|
||||
|
||||
export type NormalizeReplySkipReason = "empty" | "silent" | "heartbeat";
|
||||
|
||||
export type NormalizeReplyOptions = {
|
||||
responsePrefix?: string;
|
||||
enableSlackInteractiveReplies?: boolean;
|
||||
/** Context for template variable interpolation in responsePrefix */
|
||||
responsePrefixContext?: ResponsePrefixContext;
|
||||
onHeartbeatStrip?: () => void;
|
||||
@@ -105,5 +107,10 @@ export function normalizeReplyPayload(
|
||||
text = `${effectivePrefix} ${text}`;
|
||||
}
|
||||
|
||||
return { ...enrichedPayload, text };
|
||||
enrichedPayload = { ...enrichedPayload, text };
|
||||
if (opts.enableSlackInteractiveReplies && text && hasSlackDirectives(text)) {
|
||||
enrichedPayload = parseSlackDirectives(enrichedPayload);
|
||||
}
|
||||
|
||||
return enrichedPayload;
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ function getHumanDelay(config: HumanDelayConfig | undefined): number {
|
||||
export type ReplyDispatcherOptions = {
|
||||
deliver: ReplyDispatchDeliverer;
|
||||
responsePrefix?: string;
|
||||
enableSlackInteractiveReplies?: boolean;
|
||||
/** Static context for response prefix template interpolation. */
|
||||
responsePrefixContext?: ResponsePrefixContext;
|
||||
/** Dynamic context provider for response prefix template interpolation.
|
||||
@@ -84,7 +85,11 @@ export type ReplyDispatcher = {
|
||||
|
||||
type NormalizeReplyPayloadInternalOptions = Pick<
|
||||
ReplyDispatcherOptions,
|
||||
"responsePrefix" | "responsePrefixContext" | "responsePrefixContextProvider" | "onHeartbeatStrip"
|
||||
| "responsePrefix"
|
||||
| "enableSlackInteractiveReplies"
|
||||
| "responsePrefixContext"
|
||||
| "responsePrefixContextProvider"
|
||||
| "onHeartbeatStrip"
|
||||
> & {
|
||||
onSkip?: (reason: NormalizeReplySkipReason) => void;
|
||||
};
|
||||
@@ -98,6 +103,7 @@ function normalizeReplyPayloadInternal(
|
||||
|
||||
return normalizeReplyPayload(payload, {
|
||||
responsePrefix: opts.responsePrefix,
|
||||
enableSlackInteractiveReplies: opts.enableSlackInteractiveReplies,
|
||||
responsePrefixContext: prefixContext,
|
||||
onHeartbeatStrip: opts.onHeartbeatStrip,
|
||||
onSkip: opts.onSkip,
|
||||
@@ -129,6 +135,7 @@ export function createReplyDispatcher(options: ReplyDispatcherOptions): ReplyDis
|
||||
const enqueue = (kind: ReplyDispatchKind, payload: ReplyPayload) => {
|
||||
const normalized = normalizeReplyPayloadInternal(payload, {
|
||||
responsePrefix: options.responsePrefix,
|
||||
enableSlackInteractiveReplies: options.enableSlackInteractiveReplies,
|
||||
responsePrefixContext: options.responsePrefixContext,
|
||||
responsePrefixContextProvider: options.responsePrefixContextProvider,
|
||||
onHeartbeatStrip: options.onHeartbeatStrip,
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from "./queue.js";
|
||||
import { createReplyDispatcher } from "./reply-dispatcher.js";
|
||||
import { createReplyToModeFilter, resolveReplyToMode } from "./reply-threading.js";
|
||||
import { parseSlackDirectives, hasSlackDirectives } from "./slack-directives.js";
|
||||
|
||||
describe("normalizeInboundTextNewlines", () => {
|
||||
it("normalizes real newlines and preserves literal backslash-n sequences", () => {
|
||||
@@ -196,6 +197,8 @@ describe("inbound context contract (providers + extensions)", () => {
|
||||
|
||||
const getLineData = (result: ReturnType<typeof parseLineDirectives>) =>
|
||||
(result.channelData?.line as Record<string, unknown> | undefined) ?? {};
|
||||
const getSlackData = (result: ReturnType<typeof parseSlackDirectives>) =>
|
||||
(result.channelData?.slack as Record<string, unknown> | undefined) ?? {};
|
||||
|
||||
describe("hasLineDirectives", () => {
|
||||
it("matches expected detection across directive patterns", () => {
|
||||
@@ -219,6 +222,24 @@ describe("hasLineDirectives", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasSlackDirectives", () => {
|
||||
it("matches expected detection across Slack directive patterns", () => {
|
||||
const cases: Array<{ text: string; expected: boolean }> = [
|
||||
{ text: "Pick one [[slack_buttons: Approve:approve, Reject:reject]]", expected: true },
|
||||
{
|
||||
text: "[[slack_select: Choose a project | Alpha:alpha, Beta:beta]]",
|
||||
expected: true,
|
||||
},
|
||||
{ text: "Just regular text", expected: false },
|
||||
{ text: "[[buttons: Menu | Choose | A:a]]", expected: false },
|
||||
];
|
||||
|
||||
for (const testCase of cases) {
|
||||
expect(hasSlackDirectives(testCase.text)).toBe(testCase.expected);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseLineDirectives", () => {
|
||||
describe("quick_replies", () => {
|
||||
it("parses quick replies variants", () => {
|
||||
@@ -579,6 +600,279 @@ describe("parseLineDirectives", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseSlackDirectives", () => {
|
||||
it("builds section and button blocks from slack_buttons directives", () => {
|
||||
const result = parseSlackDirectives({
|
||||
text: "Choose an action [[slack_buttons: Approve:approve, Reject:reject]]",
|
||||
});
|
||||
|
||||
expect(result.text).toBe("Choose an action");
|
||||
expect(getSlackData(result).blocks).toEqual([
|
||||
{
|
||||
type: "section",
|
||||
text: {
|
||||
type: "mrkdwn",
|
||||
text: "Choose an action",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "actions",
|
||||
block_id: "openclaw_reply_buttons_1",
|
||||
elements: [
|
||||
{
|
||||
type: "button",
|
||||
action_id: "openclaw:reply_button",
|
||||
text: {
|
||||
type: "plain_text",
|
||||
text: "Approve",
|
||||
emoji: true,
|
||||
},
|
||||
value: "reply_1_approve",
|
||||
},
|
||||
{
|
||||
type: "button",
|
||||
action_id: "openclaw:reply_button",
|
||||
text: {
|
||||
type: "plain_text",
|
||||
text: "Reject",
|
||||
emoji: true,
|
||||
},
|
||||
value: "reply_2_reject",
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("builds static select blocks from slack_select directives", () => {
|
||||
const result = parseSlackDirectives({
|
||||
text: "[[slack_select: Choose a project | Alpha:alpha, Beta:beta]]",
|
||||
});
|
||||
|
||||
expect(result.text).toBeUndefined();
|
||||
expect(getSlackData(result).blocks).toEqual([
|
||||
{
|
||||
type: "actions",
|
||||
block_id: "openclaw_reply_select_1",
|
||||
elements: [
|
||||
{
|
||||
type: "static_select",
|
||||
action_id: "openclaw:reply_select",
|
||||
placeholder: {
|
||||
type: "plain_text",
|
||||
text: "Choose a project",
|
||||
emoji: true,
|
||||
},
|
||||
options: [
|
||||
{
|
||||
text: {
|
||||
type: "plain_text",
|
||||
text: "Alpha",
|
||||
emoji: true,
|
||||
},
|
||||
value: "reply_1_alpha",
|
||||
},
|
||||
{
|
||||
text: {
|
||||
type: "plain_text",
|
||||
text: "Beta",
|
||||
emoji: true,
|
||||
},
|
||||
value: "reply_2_beta",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("appends Slack interactive blocks to existing slack blocks", () => {
|
||||
const result = parseSlackDirectives({
|
||||
text: "Act now [[slack_buttons: Retry:retry]]",
|
||||
channelData: {
|
||||
slack: {
|
||||
blocks: [{ type: "divider" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.text).toBe("Act now");
|
||||
expect(getSlackData(result).blocks).toEqual([
|
||||
{ type: "divider" },
|
||||
{
|
||||
type: "section",
|
||||
text: {
|
||||
type: "mrkdwn",
|
||||
text: "Act now",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "actions",
|
||||
block_id: "openclaw_reply_buttons_1",
|
||||
elements: [
|
||||
{
|
||||
type: "button",
|
||||
action_id: "openclaw:reply_button",
|
||||
text: {
|
||||
type: "plain_text",
|
||||
text: "Retry",
|
||||
emoji: true,
|
||||
},
|
||||
value: "reply_1_retry",
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves authored order for mixed Slack directives", () => {
|
||||
const result = parseSlackDirectives({
|
||||
text: "[[slack_select: Pick one | Alpha:alpha]] then [[slack_buttons: Retry:retry]]",
|
||||
});
|
||||
|
||||
expect(getSlackData(result).blocks).toEqual([
|
||||
{
|
||||
type: "actions",
|
||||
block_id: "openclaw_reply_select_1",
|
||||
elements: [
|
||||
{
|
||||
type: "static_select",
|
||||
action_id: "openclaw:reply_select",
|
||||
placeholder: {
|
||||
type: "plain_text",
|
||||
text: "Pick one",
|
||||
emoji: true,
|
||||
},
|
||||
options: [
|
||||
{
|
||||
text: {
|
||||
type: "plain_text",
|
||||
text: "Alpha",
|
||||
emoji: true,
|
||||
},
|
||||
value: "reply_1_alpha",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "section",
|
||||
text: {
|
||||
type: "mrkdwn",
|
||||
text: "then",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "actions",
|
||||
block_id: "openclaw_reply_buttons_1",
|
||||
elements: [
|
||||
{
|
||||
type: "button",
|
||||
action_id: "openclaw:reply_button",
|
||||
text: {
|
||||
type: "plain_text",
|
||||
text: "Retry",
|
||||
emoji: true,
|
||||
},
|
||||
value: "reply_1_retry",
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("truncates Slack interactive reply strings to safe Block Kit limits", () => {
|
||||
const long = "x".repeat(120);
|
||||
const result = parseSlackDirectives({
|
||||
text: `${"y".repeat(3100)} [[slack_select: ${long} | ${long}:${long}]] [[slack_buttons: ${long}:${long}]]`,
|
||||
});
|
||||
|
||||
const blocks = getSlackData(result).blocks as Array<Record<string, unknown>>;
|
||||
expect(blocks).toHaveLength(3);
|
||||
expect(((blocks[0]?.text as { text?: string })?.text ?? "").length).toBeLessThanOrEqual(3000);
|
||||
expect(
|
||||
(
|
||||
(
|
||||
(blocks[1]?.elements as Array<Record<string, unknown>>)?.[0]?.placeholder as {
|
||||
text?: string;
|
||||
}
|
||||
)?.text ?? ""
|
||||
).length,
|
||||
).toBeLessThanOrEqual(75);
|
||||
expect(
|
||||
(
|
||||
(
|
||||
(
|
||||
(blocks[1]?.elements as Array<Record<string, unknown>>)?.[0]?.options as Array<
|
||||
Record<string, unknown>
|
||||
>
|
||||
)?.[0]?.text as { text?: string }
|
||||
)?.text ?? ""
|
||||
).length,
|
||||
).toBeLessThanOrEqual(75);
|
||||
expect(
|
||||
(
|
||||
((
|
||||
(blocks[1]?.elements as Array<Record<string, unknown>>)?.[0]?.options as Array<
|
||||
Record<string, unknown>
|
||||
>
|
||||
)?.[0]?.value as string | undefined) ?? ""
|
||||
).length,
|
||||
).toBeLessThanOrEqual(75);
|
||||
expect(
|
||||
(
|
||||
(
|
||||
(blocks[2]?.elements as Array<Record<string, unknown>>)?.[0]?.text as {
|
||||
text?: string;
|
||||
}
|
||||
)?.text ?? ""
|
||||
).length,
|
||||
).toBeLessThanOrEqual(75);
|
||||
expect(
|
||||
(
|
||||
((blocks[2]?.elements as Array<Record<string, unknown>>)?.[0]?.value as
|
||||
| string
|
||||
| undefined) ?? ""
|
||||
).length,
|
||||
).toBeLessThanOrEqual(75);
|
||||
});
|
||||
|
||||
it("falls back to the original payload when generated blocks would exceed Slack limits", () => {
|
||||
const result = parseSlackDirectives({
|
||||
text: "Choose [[slack_buttons: Retry:retry]]",
|
||||
channelData: {
|
||||
slack: {
|
||||
blocks: Array.from({ length: 49 }, () => ({ type: "divider" })),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
text: "Choose [[slack_buttons: Retry:retry]]",
|
||||
channelData: {
|
||||
slack: {
|
||||
blocks: Array.from({ length: 49 }, () => ({ type: "divider" })),
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores malformed existing Slack blocks during directive compilation", () => {
|
||||
expect(() =>
|
||||
parseSlackDirectives({
|
||||
text: "Choose [[slack_buttons: Retry:retry]]",
|
||||
channelData: {
|
||||
slack: {
|
||||
blocks: "{not json}",
|
||||
},
|
||||
},
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
@@ -1485,6 +1779,43 @@ describe("createReplyDispatcher", () => {
|
||||
expect(onHeartbeatStrip).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("compiles Slack directives in dispatcher flows when enabled", async () => {
|
||||
const deliver = vi.fn().mockResolvedValue(undefined);
|
||||
const dispatcher = createReplyDispatcher({
|
||||
deliver,
|
||||
enableSlackInteractiveReplies: true,
|
||||
});
|
||||
|
||||
expect(
|
||||
dispatcher.sendFinalReply({
|
||||
text: "Choose [[slack_buttons: Retry:retry]]",
|
||||
}),
|
||||
).toBe(true);
|
||||
await dispatcher.waitForIdle();
|
||||
|
||||
expect(deliver).toHaveBeenCalledTimes(1);
|
||||
expect(deliver.mock.calls[0]?.[0]).toMatchObject({
|
||||
text: "Choose",
|
||||
channelData: {
|
||||
slack: {
|
||||
blocks: [
|
||||
{
|
||||
type: "section",
|
||||
text: {
|
||||
type: "mrkdwn",
|
||||
text: "Choose",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "actions",
|
||||
block_id: "openclaw_reply_buttons_1",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("avoids double-prefixing and keeps media when heartbeat is the only text", async () => {
|
||||
const deliver = vi.fn().mockResolvedValue(undefined);
|
||||
const dispatcher = createReplyDispatcher({
|
||||
|
||||
@@ -150,6 +150,67 @@ describe("normalizeReplyPayload", () => {
|
||||
expect(result!.text).toBe("");
|
||||
expect(result!.mediaUrl).toBe("https://example.com/img.png");
|
||||
});
|
||||
|
||||
it("does not compile Slack directives unless interactive replies are enabled", () => {
|
||||
const result = normalizeReplyPayload({
|
||||
text: "hello [[slack_buttons: Retry:retry, Ignore:ignore]]",
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.text).toBe("hello [[slack_buttons: Retry:retry, Ignore:ignore]]");
|
||||
expect(result!.channelData).toBeUndefined();
|
||||
});
|
||||
|
||||
it("applies responsePrefix before compiling Slack directives into blocks", () => {
|
||||
const result = normalizeReplyPayload(
|
||||
{
|
||||
text: "hello [[slack_buttons: Retry:retry, Ignore:ignore]]",
|
||||
},
|
||||
{ responsePrefix: "[bot]", enableSlackInteractiveReplies: true },
|
||||
);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.text).toBe("[bot] hello");
|
||||
expect(result!.channelData).toEqual({
|
||||
slack: {
|
||||
blocks: [
|
||||
{
|
||||
type: "section",
|
||||
text: {
|
||||
type: "mrkdwn",
|
||||
text: "[bot] hello",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "actions",
|
||||
block_id: "openclaw_reply_buttons_1",
|
||||
elements: [
|
||||
{
|
||||
type: "button",
|
||||
action_id: "openclaw:reply_button",
|
||||
text: {
|
||||
type: "plain_text",
|
||||
text: "Retry",
|
||||
emoji: true,
|
||||
},
|
||||
value: "reply_1_retry",
|
||||
},
|
||||
{
|
||||
type: "button",
|
||||
action_id: "openclaw:reply_button",
|
||||
text: {
|
||||
type: "plain_text",
|
||||
text: "Ignore",
|
||||
emoji: true,
|
||||
},
|
||||
value: "reply_2_ignore",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("typing controller", () => {
|
||||
|
||||
@@ -201,6 +201,55 @@ describe("routeReply", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("routes directive-only Slack replies when interactive replies are enabled", async () => {
|
||||
mocks.sendMessageSlack.mockClear();
|
||||
const cfg = {
|
||||
channels: {
|
||||
slack: {
|
||||
capabilities: { interactiveReplies: true },
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
await routeReply({
|
||||
payload: { text: "[[slack_select: Choose one | Alpha:alpha]]" },
|
||||
channel: "slack",
|
||||
to: "channel:C123",
|
||||
cfg,
|
||||
});
|
||||
expect(mocks.sendMessageSlack).toHaveBeenCalledWith(
|
||||
"channel:C123",
|
||||
"",
|
||||
expect.objectContaining({
|
||||
blocks: [
|
||||
expect.objectContaining({
|
||||
type: "actions",
|
||||
block_id: "openclaw_reply_select_1",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not bypass the empty-reply guard for invalid Slack blocks", async () => {
|
||||
mocks.sendMessageSlack.mockClear();
|
||||
const res = await routeReply({
|
||||
payload: {
|
||||
text: " ",
|
||||
channelData: {
|
||||
slack: {
|
||||
blocks: " ",
|
||||
},
|
||||
},
|
||||
},
|
||||
channel: "slack",
|
||||
to: "channel:C123",
|
||||
cfg: {} as never,
|
||||
});
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
expect(mocks.sendMessageSlack).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not derive responsePrefix from agent identity when routing", async () => {
|
||||
mocks.sendMessageSlack.mockClear();
|
||||
const cfg = {
|
||||
|
||||
@@ -12,6 +12,8 @@ import { resolveEffectiveMessagesConfig } from "../../agents/identity.js";
|
||||
import { normalizeChannelId } from "../../channels/plugins/index.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { buildOutboundSessionContext } from "../../infra/outbound/session-context.js";
|
||||
import { parseSlackBlocksInput } from "../../slack/blocks-input.js";
|
||||
import { isSlackInteractiveRepliesEnabled } from "../../slack/interactive-replies.js";
|
||||
import { INTERNAL_MESSAGE_CHANNEL, normalizeMessageChannel } from "../../utils/message-channel.js";
|
||||
import type { OriginatingChannelType } from "../templating.js";
|
||||
import type { ReplyPayload } from "../types.js";
|
||||
@@ -94,6 +96,8 @@ export async function routeReply(params: RouteReplyParams): Promise<RouteReplyRe
|
||||
: cfg.messages?.responsePrefix;
|
||||
const normalized = normalizeReplyPayload(payload, {
|
||||
responsePrefix,
|
||||
enableSlackInteractiveReplies:
|
||||
channel === "slack" ? isSlackInteractiveRepliesEnabled({ cfg, accountId }) : false,
|
||||
});
|
||||
if (!normalized) {
|
||||
return { ok: true };
|
||||
@@ -106,9 +110,25 @@ export async function routeReply(params: RouteReplyParams): Promise<RouteReplyRe
|
||||
? [normalized.mediaUrl]
|
||||
: [];
|
||||
const replyToId = normalized.replyToId;
|
||||
let hasSlackBlocks = false;
|
||||
if (
|
||||
channel === "slack" &&
|
||||
normalized.channelData?.slack &&
|
||||
typeof normalized.channelData.slack === "object" &&
|
||||
!Array.isArray(normalized.channelData.slack)
|
||||
) {
|
||||
try {
|
||||
hasSlackBlocks = Boolean(
|
||||
parseSlackBlocksInput((normalized.channelData.slack as { blocks?: unknown }).blocks)
|
||||
?.length,
|
||||
);
|
||||
} catch {
|
||||
hasSlackBlocks = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Skip empty replies.
|
||||
if (!text.trim() && mediaUrls.length === 0) {
|
||||
if (!text.trim() && mediaUrls.length === 0 && !hasSlackBlocks) {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
|
||||
228
src/auto-reply/reply/slack-directives.ts
Normal file
228
src/auto-reply/reply/slack-directives.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
import { parseSlackBlocksInput } from "../../slack/blocks-input.js";
|
||||
import type { ReplyPayload } from "../types.js";
|
||||
|
||||
const SLACK_REPLY_BUTTON_ACTION_ID = "openclaw:reply_button";
|
||||
const SLACK_REPLY_SELECT_ACTION_ID = "openclaw:reply_select";
|
||||
const SLACK_MAX_BLOCKS = 50;
|
||||
const SLACK_BUTTON_MAX_ITEMS = 5;
|
||||
const SLACK_SELECT_MAX_ITEMS = 100;
|
||||
const SLACK_SECTION_TEXT_MAX = 3000;
|
||||
const SLACK_PLAIN_TEXT_MAX = 75;
|
||||
const SLACK_OPTION_VALUE_MAX = 75;
|
||||
const SLACK_DIRECTIVE_RE = /\[\[(slack_buttons|slack_select):\s*([^\]]+)\]\]/gi;
|
||||
|
||||
type SlackBlock = Record<string, unknown>;
|
||||
type SlackChannelData = {
|
||||
blocks?: unknown;
|
||||
};
|
||||
|
||||
type SlackChoice = {
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
function truncateSlackText(value: string, max: number): string {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length <= max) {
|
||||
return trimmed;
|
||||
}
|
||||
if (max <= 1) {
|
||||
return trimmed.slice(0, max);
|
||||
}
|
||||
return `${trimmed.slice(0, max - 1)}…`;
|
||||
}
|
||||
|
||||
function parseChoice(raw: string): 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();
|
||||
const value = trimmed.slice(delimiter + 1).trim();
|
||||
if (!label || !value) {
|
||||
return null;
|
||||
}
|
||||
return { label, value };
|
||||
}
|
||||
|
||||
function parseChoices(raw: string, maxItems: number): SlackChoice[] {
|
||||
return raw
|
||||
.split(",")
|
||||
.map((entry) => parseChoice(entry))
|
||||
.filter((entry): entry is SlackChoice => Boolean(entry))
|
||||
.slice(0, maxItems);
|
||||
}
|
||||
|
||||
function buildSlackReplyChoiceToken(value: string, index: number): string {
|
||||
const slug = value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "_")
|
||||
.replace(/^_+|_+$/g, "");
|
||||
return truncateSlackText(`reply_${index}_${slug || "choice"}`, SLACK_OPTION_VALUE_MAX);
|
||||
}
|
||||
|
||||
function buildSectionBlock(text: string): SlackBlock | null {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
type: "section",
|
||||
text: {
|
||||
type: "mrkdwn",
|
||||
text: truncateSlackText(trimmed, SLACK_SECTION_TEXT_MAX),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildButtonsBlock(raw: string, index: number): SlackBlock | null {
|
||||
const choices = parseChoices(raw, SLACK_BUTTON_MAX_ITEMS);
|
||||
if (choices.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
type: "actions",
|
||||
block_id: `openclaw_reply_buttons_${index}`,
|
||||
elements: choices.map((choice, choiceIndex) => ({
|
||||
type: "button",
|
||||
action_id: SLACK_REPLY_BUTTON_ACTION_ID,
|
||||
text: {
|
||||
type: "plain_text",
|
||||
text: truncateSlackText(choice.label, SLACK_PLAIN_TEXT_MAX),
|
||||
emoji: true,
|
||||
},
|
||||
value: buildSlackReplyChoiceToken(choice.value, choiceIndex + 1),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function buildSelectBlock(raw: string, index: number): SlackBlock | 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: "actions",
|
||||
block_id: `openclaw_reply_select_${index}`,
|
||||
elements: [
|
||||
{
|
||||
type: "static_select",
|
||||
action_id: SLACK_REPLY_SELECT_ACTION_ID,
|
||||
placeholder: {
|
||||
type: "plain_text",
|
||||
text: truncateSlackText(placeholder, SLACK_PLAIN_TEXT_MAX),
|
||||
emoji: true,
|
||||
},
|
||||
options: choices.map((choice, choiceIndex) => ({
|
||||
text: {
|
||||
type: "plain_text",
|
||||
text: truncateSlackText(choice.label, SLACK_PLAIN_TEXT_MAX),
|
||||
emoji: true,
|
||||
},
|
||||
value: buildSlackReplyChoiceToken(choice.value, choiceIndex + 1),
|
||||
})),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function readExistingSlackBlocks(payload: ReplyPayload): SlackBlock[] {
|
||||
const slackData = payload.channelData?.slack as SlackChannelData | undefined;
|
||||
try {
|
||||
const blocks = parseSlackBlocksInput(slackData?.blocks) as SlackBlock[] | undefined;
|
||||
return blocks ?? [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function hasSlackDirectives(text: string): boolean {
|
||||
SLACK_DIRECTIVE_RE.lastIndex = 0;
|
||||
return SLACK_DIRECTIVE_RE.test(text);
|
||||
}
|
||||
|
||||
export function parseSlackDirectives(payload: ReplyPayload): ReplyPayload {
|
||||
const text = payload.text;
|
||||
if (!text) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
const generatedBlocks: SlackBlock[] = [];
|
||||
const visibleTextParts: string[] = [];
|
||||
let buttonIndex = 0;
|
||||
let selectIndex = 0;
|
||||
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 = buildSectionBlock(precedingText);
|
||||
if (section) {
|
||||
generatedBlocks.push(section);
|
||||
}
|
||||
const block =
|
||||
directiveType.toLowerCase() === "slack_buttons"
|
||||
? buildButtonsBlock(body, ++buttonIndex)
|
||||
: buildSelectBlock(body, ++selectIndex);
|
||||
if (block) {
|
||||
generatedInteractiveBlock = true;
|
||||
generatedBlocks.push(block);
|
||||
}
|
||||
cursor = index + matchText.length;
|
||||
}
|
||||
|
||||
const trailingText = text.slice(cursor);
|
||||
visibleTextParts.push(trailingText);
|
||||
const trailingSection = buildSectionBlock(trailingText);
|
||||
if (trailingSection) {
|
||||
generatedBlocks.push(trailingSection);
|
||||
}
|
||||
const cleanedText = visibleTextParts.join("");
|
||||
|
||||
if (!matchedDirective || !generatedInteractiveBlock) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
const existingBlocks = readExistingSlackBlocks(payload);
|
||||
if (existingBlocks.length + generatedBlocks.length > SLACK_MAX_BLOCKS) {
|
||||
return payload;
|
||||
}
|
||||
const nextBlocks = [...existingBlocks, ...generatedBlocks];
|
||||
|
||||
return {
|
||||
...payload,
|
||||
text: cleanedText.trim() || undefined,
|
||||
channelData: {
|
||||
...payload.channelData,
|
||||
slack: {
|
||||
...(payload.channelData?.slack as Record<string, unknown> | undefined),
|
||||
blocks: nextBlocks,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -5,19 +5,24 @@ import {
|
||||
} from "../auto-reply/reply/response-prefix-template.js";
|
||||
import type { GetReplyOptions } from "../auto-reply/types.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { isSlackInteractiveRepliesEnabled } from "../slack/interactive-replies.js";
|
||||
|
||||
type ModelSelectionContext = Parameters<NonNullable<GetReplyOptions["onModelSelected"]>>[0];
|
||||
|
||||
export type ReplyPrefixContextBundle = {
|
||||
prefixContext: ResponsePrefixContext;
|
||||
responsePrefix?: string;
|
||||
enableSlackInteractiveReplies?: boolean;
|
||||
responsePrefixContextProvider: () => ResponsePrefixContext;
|
||||
onModelSelected: (ctx: ModelSelectionContext) => void;
|
||||
};
|
||||
|
||||
export type ReplyPrefixOptions = Pick<
|
||||
ReplyPrefixContextBundle,
|
||||
"responsePrefix" | "responsePrefixContextProvider" | "onModelSelected"
|
||||
| "responsePrefix"
|
||||
| "enableSlackInteractiveReplies"
|
||||
| "responsePrefixContextProvider"
|
||||
| "onModelSelected"
|
||||
>;
|
||||
|
||||
export function createReplyPrefixContext(params: {
|
||||
@@ -45,6 +50,10 @@ export function createReplyPrefixContext(params: {
|
||||
channel: params.channel,
|
||||
accountId: params.accountId,
|
||||
}).responsePrefix,
|
||||
enableSlackInteractiveReplies:
|
||||
params.channel === "slack"
|
||||
? isSlackInteractiveRepliesEnabled({ cfg, accountId: params.accountId })
|
||||
: undefined,
|
||||
responsePrefixContextProvider: () => prefixContext,
|
||||
onModelSelected,
|
||||
};
|
||||
@@ -56,7 +65,16 @@ export function createReplyPrefixOptions(params: {
|
||||
channel?: string;
|
||||
accountId?: string;
|
||||
}): ReplyPrefixOptions {
|
||||
const { responsePrefix, responsePrefixContextProvider, onModelSelected } =
|
||||
createReplyPrefixContext(params);
|
||||
return { responsePrefix, responsePrefixContextProvider, onModelSelected };
|
||||
const {
|
||||
responsePrefix,
|
||||
enableSlackInteractiveReplies,
|
||||
responsePrefixContextProvider,
|
||||
onModelSelected,
|
||||
} = createReplyPrefixContext(params);
|
||||
return {
|
||||
responsePrefix,
|
||||
enableSlackInteractiveReplies,
|
||||
responsePrefixContextProvider,
|
||||
onModelSelected,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -125,6 +125,23 @@ describe("resolveChannelCapabilities", () => {
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("handles Slack object-format capabilities gracefully", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
slack: {
|
||||
capabilities: { interactiveReplies: true },
|
||||
},
|
||||
},
|
||||
} as unknown as Partial<OpenClawConfig>;
|
||||
|
||||
expect(
|
||||
resolveChannelCapabilities({
|
||||
cfg,
|
||||
channel: "slack",
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
const createStubPlugin = (id: string): ChannelPlugin => ({
|
||||
|
||||
@@ -2,9 +2,10 @@ import { normalizeChannelId } from "../channels/plugins/index.js";
|
||||
import { resolveAccountEntry } from "../routing/account-lookup.js";
|
||||
import { normalizeAccountId } from "../routing/session-key.js";
|
||||
import type { OpenClawConfig } from "./config.js";
|
||||
import type { SlackCapabilitiesConfig } from "./types.slack.js";
|
||||
import type { TelegramCapabilitiesConfig } from "./types.telegram.js";
|
||||
|
||||
type CapabilitiesConfig = TelegramCapabilitiesConfig;
|
||||
type CapabilitiesConfig = TelegramCapabilitiesConfig | SlackCapabilitiesConfig;
|
||||
|
||||
const isStringArray = (value: unknown): value is string[] =>
|
||||
Array.isArray(value) && value.every((entry) => typeof entry === "string");
|
||||
|
||||
@@ -1431,6 +1431,8 @@ export const FIELD_HELP: Record<string, string> = {
|
||||
"Optional Slack user token for workflows requiring user-context API access beyond bot permissions. Use sparingly and audit scopes because this token can carry broader authority.",
|
||||
"channels.slack.userTokenReadOnly":
|
||||
"When true, treat configured Slack user token usage as read-only helper behavior where possible. Keep enabled if you only need supplemental reads without user-context writes.",
|
||||
"channels.slack.capabilities.interactiveReplies":
|
||||
"Enable agent-authored Slack interactive reply directives (`[[slack_buttons: ...]]`, `[[slack_select: ...]]`). Default: false.",
|
||||
"channels.mattermost.configWrites":
|
||||
"Allow Mattermost to write config in response to channel events/commands (default: true).",
|
||||
"channels.discord.configWrites":
|
||||
|
||||
@@ -813,6 +813,7 @@ export const FIELD_LABELS: Record<string, string> = {
|
||||
"channels.slack.appToken": "Slack App Token",
|
||||
"channels.slack.userToken": "Slack User Token",
|
||||
"channels.slack.userTokenReadOnly": "Slack User Token Read Only",
|
||||
"channels.slack.capabilities.interactiveReplies": "Slack Interactive Replies",
|
||||
"channels.slack.streaming": "Slack Streaming Mode",
|
||||
"channels.slack.nativeStreaming": "Slack Native Streaming",
|
||||
"channels.slack.streamMode": "Slack Stream Mode (Legacy)",
|
||||
|
||||
@@ -47,6 +47,11 @@ export type SlackChannelConfig = {
|
||||
export type SlackReactionNotificationMode = "off" | "own" | "all" | "allowlist";
|
||||
export type SlackStreamingMode = "off" | "partial" | "block" | "progress";
|
||||
export type SlackLegacyStreamMode = "replace" | "status_final" | "append";
|
||||
export type SlackCapabilitiesConfig =
|
||||
| string[]
|
||||
| {
|
||||
interactiveReplies?: boolean;
|
||||
};
|
||||
|
||||
export type SlackActionConfig = {
|
||||
reactions?: boolean;
|
||||
@@ -89,7 +94,7 @@ export type SlackAccountConfig = {
|
||||
/** Slack Events API webhook path (default: /slack/events). */
|
||||
webhookPath?: string;
|
||||
/** Optional provider capability tags used for agent/runtime guidance. */
|
||||
capabilities?: string[];
|
||||
capabilities?: SlackCapabilitiesConfig;
|
||||
/** Markdown formatting overrides (tables). */
|
||||
markdown?: MarkdownConfig;
|
||||
/** Override native command registration for Slack (bool or "auto"). */
|
||||
|
||||
@@ -59,6 +59,14 @@ const TelegramCapabilitiesSchema = z.union([
|
||||
})
|
||||
.strict(),
|
||||
]);
|
||||
const SlackCapabilitiesSchema = z.union([
|
||||
z.array(z.string()),
|
||||
z
|
||||
.object({
|
||||
interactiveReplies: z.boolean().optional(),
|
||||
})
|
||||
.strict(),
|
||||
]);
|
||||
|
||||
export const TelegramTopicSchema = z
|
||||
.object({
|
||||
@@ -831,7 +839,7 @@ export const SlackAccountSchema = z
|
||||
mode: z.enum(["socket", "http"]).optional(),
|
||||
signingSecret: SecretInputSchema.optional().register(sensitive),
|
||||
webhookPath: z.string().optional(),
|
||||
capabilities: z.array(z.string()).optional(),
|
||||
capabilities: SlackCapabilitiesSchema.optional(),
|
||||
markdown: MarkdownConfigSchema,
|
||||
enabled: z.boolean().optional(),
|
||||
commands: ProviderCommandsSchema,
|
||||
|
||||
@@ -8,6 +8,7 @@ export {
|
||||
resolveSlackAccount,
|
||||
resolveSlackReplyToMode,
|
||||
} from "../slack/accounts.js";
|
||||
export { isSlackInteractiveRepliesEnabled } from "../slack/interactive-replies.js";
|
||||
export { inspectSlackAccount } from "../slack/account-inspect.js";
|
||||
export {
|
||||
projectCredentialSnapshotFields,
|
||||
|
||||
38
src/slack/interactive-replies.test.ts
Normal file
38
src/slack/interactive-replies.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { isSlackInteractiveRepliesEnabled } from "./interactive-replies.js";
|
||||
|
||||
describe("isSlackInteractiveRepliesEnabled", () => {
|
||||
it("fails closed when accountId is unknown and multiple accounts exist", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
slack: {
|
||||
accounts: {
|
||||
one: {
|
||||
capabilities: { interactiveReplies: true },
|
||||
},
|
||||
two: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
expect(isSlackInteractiveRepliesEnabled({ cfg, accountId: undefined })).toBe(false);
|
||||
});
|
||||
|
||||
it("uses the only configured account when accountId is unknown", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
slack: {
|
||||
accounts: {
|
||||
only: {
|
||||
capabilities: { interactiveReplies: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
expect(isSlackInteractiveRepliesEnabled({ cfg, accountId: undefined })).toBe(true);
|
||||
});
|
||||
});
|
||||
36
src/slack/interactive-replies.ts
Normal file
36
src/slack/interactive-replies.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { listSlackAccountIds, resolveSlackAccount } from "./accounts.js";
|
||||
|
||||
function resolveInteractiveRepliesFromCapabilities(capabilities: unknown): boolean {
|
||||
if (!capabilities) {
|
||||
return false;
|
||||
}
|
||||
if (Array.isArray(capabilities)) {
|
||||
return capabilities.some(
|
||||
(entry) => String(entry).trim().toLowerCase() === "interactivereplies",
|
||||
);
|
||||
}
|
||||
if (typeof capabilities === "object") {
|
||||
return (capabilities as { interactiveReplies?: unknown }).interactiveReplies === true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isSlackInteractiveRepliesEnabled(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
}): boolean {
|
||||
if (params.accountId) {
|
||||
const account = resolveSlackAccount({ cfg: params.cfg, accountId: params.accountId });
|
||||
return resolveInteractiveRepliesFromCapabilities(account.config.capabilities);
|
||||
}
|
||||
const accountIds = listSlackAccountIds(params.cfg);
|
||||
if (accountIds.length === 0) {
|
||||
return resolveInteractiveRepliesFromCapabilities(params.cfg.channels?.slack?.capabilities);
|
||||
}
|
||||
if (accountIds.length > 1) {
|
||||
return false;
|
||||
}
|
||||
const account = resolveSlackAccount({ cfg: params.cfg, accountId: accountIds[0] });
|
||||
return resolveInteractiveRepliesFromCapabilities(account.config.capabilities);
|
||||
}
|
||||
Reference in New Issue
Block a user