mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-07 22:09:57 +00:00
Slack: fix interactive reply review findings
This commit is contained in:
@@ -135,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,
|
||||
|
||||
@@ -700,6 +700,70 @@ describe("parseSlackDirectives", () => {
|
||||
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: "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: "alpha",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "section",
|
||||
text: {
|
||||
type: "mrkdwn",
|
||||
text: "then",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "actions",
|
||||
block_id: "openclaw_reply_buttons_1",
|
||||
@@ -1626,6 +1690,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({
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { parseSlackBlocksInput } from "../../slack/blocks-input.js";
|
||||
import type { ReplyPayload } from "../types.js";
|
||||
|
||||
const SLACK_BUTTONS_DIRECTIVE_RE = /\[\[slack_buttons:\s*([^\]]+)\]\]/gi;
|
||||
const SLACK_SELECT_DIRECTIVE_RE = /\[\[slack_select:\s*([^\]]+)\]\]/gi;
|
||||
const SLACK_REPLY_BUTTON_ACTION_ID = "openclaw:reply_button";
|
||||
const SLACK_REPLY_SELECT_ACTION_ID = "openclaw:reply_select";
|
||||
const SLACK_BUTTON_MAX_ITEMS = 5;
|
||||
const SLACK_SELECT_MAX_ITEMS = 100;
|
||||
const SLACK_DIRECTIVE_RE = /\[\[(slack_buttons|slack_select):\s*([^\]]+)\]\]/gi;
|
||||
|
||||
type SlackBlock = Record<string, unknown>;
|
||||
type SlackChannelData = {
|
||||
@@ -127,9 +126,8 @@ function readExistingSlackBlocks(payload: ReplyPayload): SlackBlock[] {
|
||||
}
|
||||
|
||||
export function hasSlackDirectives(text: string): boolean {
|
||||
SLACK_BUTTONS_DIRECTIVE_RE.lastIndex = 0;
|
||||
SLACK_SELECT_DIRECTIVE_RE.lastIndex = 0;
|
||||
return SLACK_BUTTONS_DIRECTIVE_RE.test(text) || SLACK_SELECT_DIRECTIVE_RE.test(text);
|
||||
SLACK_DIRECTIVE_RE.lastIndex = 0;
|
||||
return SLACK_DIRECTIVE_RE.test(text);
|
||||
}
|
||||
|
||||
export function parseSlackDirectives(payload: ReplyPayload): ReplyPayload {
|
||||
@@ -139,40 +137,51 @@ export function parseSlackDirectives(payload: ReplyPayload): ReplyPayload {
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
let cleanedText = text.replace(SLACK_BUTTONS_DIRECTIVE_RE, (_match, body: string) => {
|
||||
buttonIndex += 1;
|
||||
const block = buildButtonsBlock(body, buttonIndex);
|
||||
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);
|
||||
}
|
||||
return "";
|
||||
});
|
||||
cursor = index + matchText.length;
|
||||
}
|
||||
|
||||
cleanedText = cleanedText.replace(SLACK_SELECT_DIRECTIVE_RE, (_match, body: string) => {
|
||||
selectIndex += 1;
|
||||
const block = buildSelectBlock(body, selectIndex);
|
||||
if (block) {
|
||||
generatedBlocks.push(block);
|
||||
}
|
||||
return "";
|
||||
});
|
||||
const trailingText = text.slice(cursor);
|
||||
visibleTextParts.push(trailingText);
|
||||
const trailingSection = buildSectionBlock(trailingText);
|
||||
if (trailingSection) {
|
||||
generatedBlocks.push(trailingSection);
|
||||
}
|
||||
const cleanedText = visibleTextParts.join("");
|
||||
|
||||
if (generatedBlocks.length === 0) {
|
||||
if (!matchedDirective || !generatedInteractiveBlock) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
const existingBlocks = readExistingSlackBlocks(payload);
|
||||
const nextBlocks = [...existingBlocks];
|
||||
if (existingBlocks.length === 0) {
|
||||
const section = buildSectionBlock(cleanedText);
|
||||
if (section) {
|
||||
nextBlocks.push(section);
|
||||
}
|
||||
}
|
||||
nextBlocks.push(...generatedBlocks);
|
||||
const nextBlocks = [...existingBlocks, ...generatedBlocks];
|
||||
|
||||
return {
|
||||
...payload,
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -28,8 +28,9 @@ export function isSlackInteractiveRepliesEnabled(params: {
|
||||
if (accountIds.length === 0) {
|
||||
return resolveInteractiveRepliesFromCapabilities(params.cfg.channels?.slack?.capabilities);
|
||||
}
|
||||
return accountIds.some((accountId) => {
|
||||
const account = resolveSlackAccount({ cfg: params.cfg, accountId });
|
||||
return resolveInteractiveRepliesFromCapabilities(account.config.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