Compare commits
18 Commits
main
...
vincentkoc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
458c813873 | ||
|
|
ed2caefac8 | ||
|
|
aa6574d6c8 | ||
|
|
d477373656 | ||
|
|
b7271de633 | ||
|
|
357fa94893 | ||
|
|
df353a98f5 | ||
|
|
7c86d58f71 | ||
|
|
177165559e | ||
|
|
6585f6f608 | ||
|
|
f16c14536c | ||
|
|
af414e2e73 | ||
|
|
b88b875ac0 | ||
|
|
5b52b2ebfe | ||
|
|
c23529c349 | ||
|
|
2654733df7 | ||
|
|
f8a8e8830b | ||
|
|
f8623b55c4 |
@ -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
|
- 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
|
- 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/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
|
### Fixes
|
||||||
|
|
||||||
|
|||||||
@ -218,6 +218,55 @@ For actions/directory reads, user token can be preferred when configured. For wr
|
|||||||
- if encoded option values exceed Slack limits, the flow falls back to buttons
|
- if encoded option values exceed Slack limits, the flow falls back to buttons
|
||||||
- For long option payloads, Slash command argument menus use a confirm dialog before dispatching a selected value.
|
- For long option payloads, Slash command argument menus use a confirm dialog before dispatching a selected value.
|
||||||
|
|
||||||
|
## Interactive replies
|
||||||
|
|
||||||
|
Slack can render agent-authored interactive reply controls, but this feature is disabled by default.
|
||||||
|
|
||||||
|
Enable it globally:
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
channels: {
|
||||||
|
slack: {
|
||||||
|
capabilities: {
|
||||||
|
interactiveReplies: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Or enable it for one Slack account only:
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
channels: {
|
||||||
|
slack: {
|
||||||
|
accounts: {
|
||||||
|
ops: {
|
||||||
|
capabilities: {
|
||||||
|
interactiveReplies: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
When enabled, agents can emit Slack-only reply directives:
|
||||||
|
|
||||||
|
- `[[slack_buttons: Approve:approve, Reject:reject]]`
|
||||||
|
- `[[slack_select: Choose a target | Canary:canary, Production:production]]`
|
||||||
|
|
||||||
|
These directives compile into Slack Block Kit and route clicks or selections back through the existing Slack interaction event path.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- This is Slack-specific UI. Other channels do not translate Slack Block Kit directives into their own button systems.
|
||||||
|
- The interactive callback values are OpenClaw-generated opaque tokens, not raw agent-authored values.
|
||||||
|
- If generated interactive blocks would exceed Slack Block Kit limits, OpenClaw falls back to the original text reply instead of sending an invalid blocks payload.
|
||||||
|
|
||||||
Default slash command settings:
|
Default slash command settings:
|
||||||
|
|
||||||
- `enabled: false`
|
- `enabled: false`
|
||||||
|
|||||||
@ -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", () => {
|
describe("slackPlugin config", () => {
|
||||||
it("treats HTTP mode accounts with bot token + signing secret as configured", async () => {
|
it("treats HTTP mode accounts with bot token + signing secret as configured", async () => {
|
||||||
const cfg: OpenClawConfig = {
|
const cfg: OpenClawConfig = {
|
||||||
|
|||||||
@ -29,6 +29,7 @@ import {
|
|||||||
resolveDefaultSlackAccountId,
|
resolveDefaultSlackAccountId,
|
||||||
resolveSlackAccount,
|
resolveSlackAccount,
|
||||||
resolveSlackReplyToMode,
|
resolveSlackReplyToMode,
|
||||||
|
isSlackInteractiveRepliesEnabled,
|
||||||
resolveSlackGroupRequireMention,
|
resolveSlackGroupRequireMention,
|
||||||
resolveSlackGroupToolPolicy,
|
resolveSlackGroupToolPolicy,
|
||||||
buildSlackThreadingToolContext,
|
buildSlackThreadingToolContext,
|
||||||
@ -146,6 +147,17 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
|
|||||||
media: true,
|
media: true,
|
||||||
nativeCommands: 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: {
|
streaming: {
|
||||||
blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
|
blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
|
||||||
},
|
},
|
||||||
|
|||||||
@ -12,11 +12,13 @@ import {
|
|||||||
resolveResponsePrefixTemplate,
|
resolveResponsePrefixTemplate,
|
||||||
type ResponsePrefixContext,
|
type ResponsePrefixContext,
|
||||||
} from "./response-prefix-template.js";
|
} from "./response-prefix-template.js";
|
||||||
|
import { hasSlackDirectives, parseSlackDirectives } from "./slack-directives.js";
|
||||||
|
|
||||||
export type NormalizeReplySkipReason = "empty" | "silent" | "heartbeat";
|
export type NormalizeReplySkipReason = "empty" | "silent" | "heartbeat";
|
||||||
|
|
||||||
export type NormalizeReplyOptions = {
|
export type NormalizeReplyOptions = {
|
||||||
responsePrefix?: string;
|
responsePrefix?: string;
|
||||||
|
enableSlackInteractiveReplies?: boolean;
|
||||||
/** Context for template variable interpolation in responsePrefix */
|
/** Context for template variable interpolation in responsePrefix */
|
||||||
responsePrefixContext?: ResponsePrefixContext;
|
responsePrefixContext?: ResponsePrefixContext;
|
||||||
onHeartbeatStrip?: () => void;
|
onHeartbeatStrip?: () => void;
|
||||||
@ -105,5 +107,10 @@ export function normalizeReplyPayload(
|
|||||||
text = `${effectivePrefix} ${text}`;
|
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 = {
|
export type ReplyDispatcherOptions = {
|
||||||
deliver: ReplyDispatchDeliverer;
|
deliver: ReplyDispatchDeliverer;
|
||||||
responsePrefix?: string;
|
responsePrefix?: string;
|
||||||
|
enableSlackInteractiveReplies?: boolean;
|
||||||
/** Static context for response prefix template interpolation. */
|
/** Static context for response prefix template interpolation. */
|
||||||
responsePrefixContext?: ResponsePrefixContext;
|
responsePrefixContext?: ResponsePrefixContext;
|
||||||
/** Dynamic context provider for response prefix template interpolation.
|
/** Dynamic context provider for response prefix template interpolation.
|
||||||
@ -84,7 +85,11 @@ export type ReplyDispatcher = {
|
|||||||
|
|
||||||
type NormalizeReplyPayloadInternalOptions = Pick<
|
type NormalizeReplyPayloadInternalOptions = Pick<
|
||||||
ReplyDispatcherOptions,
|
ReplyDispatcherOptions,
|
||||||
"responsePrefix" | "responsePrefixContext" | "responsePrefixContextProvider" | "onHeartbeatStrip"
|
| "responsePrefix"
|
||||||
|
| "enableSlackInteractiveReplies"
|
||||||
|
| "responsePrefixContext"
|
||||||
|
| "responsePrefixContextProvider"
|
||||||
|
| "onHeartbeatStrip"
|
||||||
> & {
|
> & {
|
||||||
onSkip?: (reason: NormalizeReplySkipReason) => void;
|
onSkip?: (reason: NormalizeReplySkipReason) => void;
|
||||||
};
|
};
|
||||||
@ -98,6 +103,7 @@ function normalizeReplyPayloadInternal(
|
|||||||
|
|
||||||
return normalizeReplyPayload(payload, {
|
return normalizeReplyPayload(payload, {
|
||||||
responsePrefix: opts.responsePrefix,
|
responsePrefix: opts.responsePrefix,
|
||||||
|
enableSlackInteractiveReplies: opts.enableSlackInteractiveReplies,
|
||||||
responsePrefixContext: prefixContext,
|
responsePrefixContext: prefixContext,
|
||||||
onHeartbeatStrip: opts.onHeartbeatStrip,
|
onHeartbeatStrip: opts.onHeartbeatStrip,
|
||||||
onSkip: opts.onSkip,
|
onSkip: opts.onSkip,
|
||||||
@ -129,6 +135,7 @@ export function createReplyDispatcher(options: ReplyDispatcherOptions): ReplyDis
|
|||||||
const enqueue = (kind: ReplyDispatchKind, payload: ReplyPayload) => {
|
const enqueue = (kind: ReplyDispatchKind, payload: ReplyPayload) => {
|
||||||
const normalized = normalizeReplyPayloadInternal(payload, {
|
const normalized = normalizeReplyPayloadInternal(payload, {
|
||||||
responsePrefix: options.responsePrefix,
|
responsePrefix: options.responsePrefix,
|
||||||
|
enableSlackInteractiveReplies: options.enableSlackInteractiveReplies,
|
||||||
responsePrefixContext: options.responsePrefixContext,
|
responsePrefixContext: options.responsePrefixContext,
|
||||||
responsePrefixContextProvider: options.responsePrefixContextProvider,
|
responsePrefixContextProvider: options.responsePrefixContextProvider,
|
||||||
onHeartbeatStrip: options.onHeartbeatStrip,
|
onHeartbeatStrip: options.onHeartbeatStrip,
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import {
|
|||||||
} from "./queue.js";
|
} from "./queue.js";
|
||||||
import { createReplyDispatcher } from "./reply-dispatcher.js";
|
import { createReplyDispatcher } from "./reply-dispatcher.js";
|
||||||
import { createReplyToModeFilter, resolveReplyToMode } from "./reply-threading.js";
|
import { createReplyToModeFilter, resolveReplyToMode } from "./reply-threading.js";
|
||||||
|
import { parseSlackDirectives, hasSlackDirectives } from "./slack-directives.js";
|
||||||
|
|
||||||
describe("normalizeInboundTextNewlines", () => {
|
describe("normalizeInboundTextNewlines", () => {
|
||||||
it("normalizes real newlines and preserves literal backslash-n sequences", () => {
|
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>) =>
|
const getLineData = (result: ReturnType<typeof parseLineDirectives>) =>
|
||||||
(result.channelData?.line as Record<string, unknown> | undefined) ?? {};
|
(result.channelData?.line as Record<string, unknown> | undefined) ?? {};
|
||||||
|
const getSlackData = (result: ReturnType<typeof parseSlackDirectives>) =>
|
||||||
|
(result.channelData?.slack as Record<string, unknown> | undefined) ?? {};
|
||||||
|
|
||||||
describe("hasLineDirectives", () => {
|
describe("hasLineDirectives", () => {
|
||||||
it("matches expected detection across directive patterns", () => {
|
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("parseLineDirectives", () => {
|
||||||
describe("quick_replies", () => {
|
describe("quick_replies", () => {
|
||||||
it("parses quick replies variants", () => {
|
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>() {
|
function createDeferred<T>() {
|
||||||
let resolve!: (value: T) => void;
|
let resolve!: (value: T) => void;
|
||||||
let reject!: (reason?: unknown) => void;
|
let reject!: (reason?: unknown) => void;
|
||||||
@ -1485,6 +1779,43 @@ describe("createReplyDispatcher", () => {
|
|||||||
expect(onHeartbeatStrip).toHaveBeenCalledTimes(2);
|
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 () => {
|
it("avoids double-prefixing and keeps media when heartbeat is the only text", async () => {
|
||||||
const deliver = vi.fn().mockResolvedValue(undefined);
|
const deliver = vi.fn().mockResolvedValue(undefined);
|
||||||
const dispatcher = createReplyDispatcher({
|
const dispatcher = createReplyDispatcher({
|
||||||
|
|||||||
@ -150,6 +150,67 @@ describe("normalizeReplyPayload", () => {
|
|||||||
expect(result!.text).toBe("");
|
expect(result!.text).toBe("");
|
||||||
expect(result!.mediaUrl).toBe("https://example.com/img.png");
|
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", () => {
|
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 () => {
|
it("does not derive responsePrefix from agent identity when routing", async () => {
|
||||||
mocks.sendMessageSlack.mockClear();
|
mocks.sendMessageSlack.mockClear();
|
||||||
const cfg = {
|
const cfg = {
|
||||||
|
|||||||
@ -12,6 +12,8 @@ import { resolveEffectiveMessagesConfig } from "../../agents/identity.js";
|
|||||||
import { normalizeChannelId } from "../../channels/plugins/index.js";
|
import { normalizeChannelId } from "../../channels/plugins/index.js";
|
||||||
import type { OpenClawConfig } from "../../config/config.js";
|
import type { OpenClawConfig } from "../../config/config.js";
|
||||||
import { buildOutboundSessionContext } from "../../infra/outbound/session-context.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 { INTERNAL_MESSAGE_CHANNEL, normalizeMessageChannel } from "../../utils/message-channel.js";
|
||||||
import type { OriginatingChannelType } from "../templating.js";
|
import type { OriginatingChannelType } from "../templating.js";
|
||||||
import type { ReplyPayload } from "../types.js";
|
import type { ReplyPayload } from "../types.js";
|
||||||
@ -94,6 +96,8 @@ export async function routeReply(params: RouteReplyParams): Promise<RouteReplyRe
|
|||||||
: cfg.messages?.responsePrefix;
|
: cfg.messages?.responsePrefix;
|
||||||
const normalized = normalizeReplyPayload(payload, {
|
const normalized = normalizeReplyPayload(payload, {
|
||||||
responsePrefix,
|
responsePrefix,
|
||||||
|
enableSlackInteractiveReplies:
|
||||||
|
channel === "slack" ? isSlackInteractiveRepliesEnabled({ cfg, accountId }) : false,
|
||||||
});
|
});
|
||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
@ -106,9 +110,25 @@ export async function routeReply(params: RouteReplyParams): Promise<RouteReplyRe
|
|||||||
? [normalized.mediaUrl]
|
? [normalized.mediaUrl]
|
||||||
: [];
|
: [];
|
||||||
const replyToId = normalized.replyToId;
|
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.
|
// Skip empty replies.
|
||||||
if (!text.trim() && mediaUrls.length === 0) {
|
if (!text.trim() && mediaUrls.length === 0 && !hasSlackBlocks) {
|
||||||
return { ok: true };
|
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";
|
} from "../auto-reply/reply/response-prefix-template.js";
|
||||||
import type { GetReplyOptions } from "../auto-reply/types.js";
|
import type { GetReplyOptions } from "../auto-reply/types.js";
|
||||||
import type { OpenClawConfig } from "../config/config.js";
|
import type { OpenClawConfig } from "../config/config.js";
|
||||||
|
import { isSlackInteractiveRepliesEnabled } from "../slack/interactive-replies.js";
|
||||||
|
|
||||||
type ModelSelectionContext = Parameters<NonNullable<GetReplyOptions["onModelSelected"]>>[0];
|
type ModelSelectionContext = Parameters<NonNullable<GetReplyOptions["onModelSelected"]>>[0];
|
||||||
|
|
||||||
export type ReplyPrefixContextBundle = {
|
export type ReplyPrefixContextBundle = {
|
||||||
prefixContext: ResponsePrefixContext;
|
prefixContext: ResponsePrefixContext;
|
||||||
responsePrefix?: string;
|
responsePrefix?: string;
|
||||||
|
enableSlackInteractiveReplies?: boolean;
|
||||||
responsePrefixContextProvider: () => ResponsePrefixContext;
|
responsePrefixContextProvider: () => ResponsePrefixContext;
|
||||||
onModelSelected: (ctx: ModelSelectionContext) => void;
|
onModelSelected: (ctx: ModelSelectionContext) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ReplyPrefixOptions = Pick<
|
export type ReplyPrefixOptions = Pick<
|
||||||
ReplyPrefixContextBundle,
|
ReplyPrefixContextBundle,
|
||||||
"responsePrefix" | "responsePrefixContextProvider" | "onModelSelected"
|
| "responsePrefix"
|
||||||
|
| "enableSlackInteractiveReplies"
|
||||||
|
| "responsePrefixContextProvider"
|
||||||
|
| "onModelSelected"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export function createReplyPrefixContext(params: {
|
export function createReplyPrefixContext(params: {
|
||||||
@ -45,6 +50,10 @@ export function createReplyPrefixContext(params: {
|
|||||||
channel: params.channel,
|
channel: params.channel,
|
||||||
accountId: params.accountId,
|
accountId: params.accountId,
|
||||||
}).responsePrefix,
|
}).responsePrefix,
|
||||||
|
enableSlackInteractiveReplies:
|
||||||
|
params.channel === "slack"
|
||||||
|
? isSlackInteractiveRepliesEnabled({ cfg, accountId: params.accountId })
|
||||||
|
: undefined,
|
||||||
responsePrefixContextProvider: () => prefixContext,
|
responsePrefixContextProvider: () => prefixContext,
|
||||||
onModelSelected,
|
onModelSelected,
|
||||||
};
|
};
|
||||||
@ -56,7 +65,16 @@ export function createReplyPrefixOptions(params: {
|
|||||||
channel?: string;
|
channel?: string;
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
}): ReplyPrefixOptions {
|
}): ReplyPrefixOptions {
|
||||||
const { responsePrefix, responsePrefixContextProvider, onModelSelected } =
|
const {
|
||||||
createReplyPrefixContext(params);
|
responsePrefix,
|
||||||
return { responsePrefix, responsePrefixContextProvider, onModelSelected };
|
enableSlackInteractiveReplies,
|
||||||
|
responsePrefixContextProvider,
|
||||||
|
onModelSelected,
|
||||||
|
} = createReplyPrefixContext(params);
|
||||||
|
return {
|
||||||
|
responsePrefix,
|
||||||
|
enableSlackInteractiveReplies,
|
||||||
|
responsePrefixContextProvider,
|
||||||
|
onModelSelected,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -125,6 +125,23 @@ describe("resolveChannelCapabilities", () => {
|
|||||||
}),
|
}),
|
||||||
).toBeUndefined();
|
).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 => ({
|
const createStubPlugin = (id: string): ChannelPlugin => ({
|
||||||
|
|||||||
@ -2,9 +2,10 @@ import { normalizeChannelId } from "../channels/plugins/index.js";
|
|||||||
import { resolveAccountEntry } from "../routing/account-lookup.js";
|
import { resolveAccountEntry } from "../routing/account-lookup.js";
|
||||||
import { normalizeAccountId } from "../routing/session-key.js";
|
import { normalizeAccountId } from "../routing/session-key.js";
|
||||||
import type { OpenClawConfig } from "./config.js";
|
import type { OpenClawConfig } from "./config.js";
|
||||||
|
import type { SlackCapabilitiesConfig } from "./types.slack.js";
|
||||||
import type { TelegramCapabilitiesConfig } from "./types.telegram.js";
|
import type { TelegramCapabilitiesConfig } from "./types.telegram.js";
|
||||||
|
|
||||||
type CapabilitiesConfig = TelegramCapabilitiesConfig;
|
type CapabilitiesConfig = TelegramCapabilitiesConfig | SlackCapabilitiesConfig;
|
||||||
|
|
||||||
const isStringArray = (value: unknown): value is string[] =>
|
const isStringArray = (value: unknown): value is string[] =>
|
||||||
Array.isArray(value) && value.every((entry) => typeof entry === "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.",
|
"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":
|
"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.",
|
"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":
|
"channels.mattermost.configWrites":
|
||||||
"Allow Mattermost to write config in response to channel events/commands (default: true).",
|
"Allow Mattermost to write config in response to channel events/commands (default: true).",
|
||||||
"channels.discord.configWrites":
|
"channels.discord.configWrites":
|
||||||
|
|||||||
@ -813,6 +813,7 @@ export const FIELD_LABELS: Record<string, string> = {
|
|||||||
"channels.slack.appToken": "Slack App Token",
|
"channels.slack.appToken": "Slack App Token",
|
||||||
"channels.slack.userToken": "Slack User Token",
|
"channels.slack.userToken": "Slack User Token",
|
||||||
"channels.slack.userTokenReadOnly": "Slack User Token Read Only",
|
"channels.slack.userTokenReadOnly": "Slack User Token Read Only",
|
||||||
|
"channels.slack.capabilities.interactiveReplies": "Slack Interactive Replies",
|
||||||
"channels.slack.streaming": "Slack Streaming Mode",
|
"channels.slack.streaming": "Slack Streaming Mode",
|
||||||
"channels.slack.nativeStreaming": "Slack Native Streaming",
|
"channels.slack.nativeStreaming": "Slack Native Streaming",
|
||||||
"channels.slack.streamMode": "Slack Stream Mode (Legacy)",
|
"channels.slack.streamMode": "Slack Stream Mode (Legacy)",
|
||||||
|
|||||||
@ -47,6 +47,11 @@ export type SlackChannelConfig = {
|
|||||||
export type SlackReactionNotificationMode = "off" | "own" | "all" | "allowlist";
|
export type SlackReactionNotificationMode = "off" | "own" | "all" | "allowlist";
|
||||||
export type SlackStreamingMode = "off" | "partial" | "block" | "progress";
|
export type SlackStreamingMode = "off" | "partial" | "block" | "progress";
|
||||||
export type SlackLegacyStreamMode = "replace" | "status_final" | "append";
|
export type SlackLegacyStreamMode = "replace" | "status_final" | "append";
|
||||||
|
export type SlackCapabilitiesConfig =
|
||||||
|
| string[]
|
||||||
|
| {
|
||||||
|
interactiveReplies?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type SlackActionConfig = {
|
export type SlackActionConfig = {
|
||||||
reactions?: boolean;
|
reactions?: boolean;
|
||||||
@ -89,7 +94,7 @@ export type SlackAccountConfig = {
|
|||||||
/** Slack Events API webhook path (default: /slack/events). */
|
/** Slack Events API webhook path (default: /slack/events). */
|
||||||
webhookPath?: string;
|
webhookPath?: string;
|
||||||
/** Optional provider capability tags used for agent/runtime guidance. */
|
/** Optional provider capability tags used for agent/runtime guidance. */
|
||||||
capabilities?: string[];
|
capabilities?: SlackCapabilitiesConfig;
|
||||||
/** Markdown formatting overrides (tables). */
|
/** Markdown formatting overrides (tables). */
|
||||||
markdown?: MarkdownConfig;
|
markdown?: MarkdownConfig;
|
||||||
/** Override native command registration for Slack (bool or "auto"). */
|
/** Override native command registration for Slack (bool or "auto"). */
|
||||||
|
|||||||
@ -59,6 +59,14 @@ const TelegramCapabilitiesSchema = z.union([
|
|||||||
})
|
})
|
||||||
.strict(),
|
.strict(),
|
||||||
]);
|
]);
|
||||||
|
const SlackCapabilitiesSchema = z.union([
|
||||||
|
z.array(z.string()),
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
interactiveReplies: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
.strict(),
|
||||||
|
]);
|
||||||
|
|
||||||
export const TelegramTopicSchema = z
|
export const TelegramTopicSchema = z
|
||||||
.object({
|
.object({
|
||||||
@ -831,7 +839,7 @@ export const SlackAccountSchema = z
|
|||||||
mode: z.enum(["socket", "http"]).optional(),
|
mode: z.enum(["socket", "http"]).optional(),
|
||||||
signingSecret: SecretInputSchema.optional().register(sensitive),
|
signingSecret: SecretInputSchema.optional().register(sensitive),
|
||||||
webhookPath: z.string().optional(),
|
webhookPath: z.string().optional(),
|
||||||
capabilities: z.array(z.string()).optional(),
|
capabilities: SlackCapabilitiesSchema.optional(),
|
||||||
markdown: MarkdownConfigSchema,
|
markdown: MarkdownConfigSchema,
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
commands: ProviderCommandsSchema,
|
commands: ProviderCommandsSchema,
|
||||||
|
|||||||
@ -8,6 +8,7 @@ export {
|
|||||||
resolveSlackAccount,
|
resolveSlackAccount,
|
||||||
resolveSlackReplyToMode,
|
resolveSlackReplyToMode,
|
||||||
} from "../slack/accounts.js";
|
} from "../slack/accounts.js";
|
||||||
|
export { isSlackInteractiveRepliesEnabled } from "../slack/interactive-replies.js";
|
||||||
export { inspectSlackAccount } from "../slack/account-inspect.js";
|
export { inspectSlackAccount } from "../slack/account-inspect.js";
|
||||||
export {
|
export {
|
||||||
projectCredentialSnapshotFields,
|
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);
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user