mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-05 21:20:23 +00:00
fix(slack): improve interactive reply parity (#53389)
* fix(slack): improve interactive reply parity * fix(slack): isolate reply interactions from plugins * docs(changelog): note slack interactive parity fixes * fix(slack): preserve preview text for local agent replies * fix(agent): preserve directive text in local previews
This commit is contained in:
@@ -13,13 +13,14 @@ import {
|
||||
resolveResponsePrefixTemplate,
|
||||
type ResponsePrefixContext,
|
||||
} from "./response-prefix-template.js";
|
||||
import { hasSlackDirectives, parseSlackDirectives } from "./slack-directives.js";
|
||||
import { compileSlackInteractiveReplies } from "./slack-directives.js";
|
||||
|
||||
export type NormalizeReplySkipReason = "empty" | "silent" | "heartbeat";
|
||||
|
||||
export type NormalizeReplyOptions = {
|
||||
responsePrefix?: string;
|
||||
enableSlackInteractiveReplies?: boolean;
|
||||
applyChannelTransforms?: boolean;
|
||||
/** Context for template variable interpolation in responsePrefix */
|
||||
responsePrefixContext?: ResponsePrefixContext;
|
||||
onHeartbeatStrip?: () => void;
|
||||
@@ -32,6 +33,7 @@ export function normalizeReplyPayload(
|
||||
payload: ReplyPayload,
|
||||
opts: NormalizeReplyOptions = {},
|
||||
): ReplyPayload | null {
|
||||
const applyChannelTransforms = opts.applyChannelTransforms ?? true;
|
||||
const hasContent = (text: string | undefined) =>
|
||||
hasReplyPayloadContent(
|
||||
{
|
||||
@@ -95,7 +97,7 @@ export function normalizeReplyPayload(
|
||||
|
||||
// Parse LINE-specific directives from text (quick_replies, location, confirm, buttons)
|
||||
let enrichedPayload: ReplyPayload = { ...payload, text };
|
||||
if (text && hasLineDirectives(text)) {
|
||||
if (applyChannelTransforms && text && hasLineDirectives(text)) {
|
||||
enrichedPayload = parseLineDirectives(enrichedPayload);
|
||||
text = enrichedPayload.text;
|
||||
}
|
||||
@@ -115,8 +117,8 @@ export function normalizeReplyPayload(
|
||||
}
|
||||
|
||||
enrichedPayload = { ...enrichedPayload, text };
|
||||
if (opts.enableSlackInteractiveReplies && text && hasSlackDirectives(text)) {
|
||||
enrichedPayload = parseSlackDirectives(enrichedPayload);
|
||||
if (applyChannelTransforms && opts.enableSlackInteractiveReplies && text) {
|
||||
enrichedPayload = compileSlackInteractiveReplies(enrichedPayload);
|
||||
}
|
||||
|
||||
return enrichedPayload;
|
||||
|
||||
@@ -193,6 +193,81 @@ describe("normalizeReplyPayload", () => {
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("compiles simple trailing Options lines into Slack buttons when interactive replies are enabled", () => {
|
||||
const result = normalizeReplyPayload(
|
||||
{
|
||||
text: "Current verbose level: off.\nOptions: on, full, off.",
|
||||
},
|
||||
{ enableSlackInteractiveReplies: true },
|
||||
);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.text).toBe("Current verbose level: off.");
|
||||
expect(result!.interactive).toEqual({
|
||||
blocks: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Current verbose level: off.",
|
||||
},
|
||||
{
|
||||
type: "buttons",
|
||||
buttons: [
|
||||
{ label: "on", value: "on" },
|
||||
{ label: "full", value: "full" },
|
||||
{ label: "off", value: "off" },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("uses a Slack select when simple Options lines exceed the button row size", () => {
|
||||
const result = normalizeReplyPayload(
|
||||
{
|
||||
text: "Choose a reasoning level.\nOptions: off, minimal, low, medium, high, adaptive.",
|
||||
},
|
||||
{ enableSlackInteractiveReplies: true },
|
||||
);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.text).toBe("Choose a reasoning level.");
|
||||
expect(result!.interactive).toEqual({
|
||||
blocks: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Choose a reasoning level.",
|
||||
},
|
||||
{
|
||||
type: "select",
|
||||
placeholder: "Choose an option",
|
||||
options: [
|
||||
{ label: "off", value: "off" },
|
||||
{ label: "minimal", value: "minimal" },
|
||||
{ label: "low", value: "low" },
|
||||
{ label: "medium", value: "medium" },
|
||||
{ label: "high", value: "high" },
|
||||
{ label: "adaptive", value: "adaptive" },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves complex Options lines as plain text", () => {
|
||||
const result = normalizeReplyPayload(
|
||||
{
|
||||
text: "ACP runtime choices.\nOptions: host=sandbox|gateway|node, security=deny|allowlist|full.",
|
||||
},
|
||||
{ enableSlackInteractiveReplies: true },
|
||||
);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.text).toBe(
|
||||
"ACP runtime choices.\nOptions: host=sandbox|gateway|node, security=deny|allowlist|full.",
|
||||
);
|
||||
expect(result!.interactive).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("typing controller", () => {
|
||||
|
||||
@@ -3,6 +3,9 @@ import type { ReplyPayload } from "../types.js";
|
||||
const SLACK_BUTTON_MAX_ITEMS = 5;
|
||||
const SLACK_SELECT_MAX_ITEMS = 100;
|
||||
const SLACK_DIRECTIVE_RE = /\[\[(slack_buttons|slack_select):\s*([^\]]+)\]\]/gi;
|
||||
const SLACK_OPTIONS_LINE_RE = /^\s*Options:\s*(.+?)\s*\.?\s*$/i;
|
||||
const SLACK_AUTO_SELECT_MAX_ITEMS = 12;
|
||||
const SLACK_SIMPLE_OPTION_RE = /^[a-z0-9][a-z0-9 _+/-]{0,31}$/i;
|
||||
|
||||
type SlackChoice = {
|
||||
label: string;
|
||||
@@ -147,3 +150,97 @@ export function parseSlackDirectives(payload: ReplyPayload): ReplyPayload {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function hasSlackBlocks(payload: ReplyPayload): boolean {
|
||||
const blocks = (payload.channelData?.slack as { blocks?: unknown } | undefined)?.blocks;
|
||||
if (typeof blocks === "string") {
|
||||
return blocks.trim().length > 0;
|
||||
}
|
||||
return Array.isArray(blocks) && blocks.length > 0;
|
||||
}
|
||||
|
||||
function parseSimpleSlackOptions(raw: string): SlackChoice[] | null {
|
||||
const entries = raw
|
||||
.split(",")
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
if (entries.length < 2 || entries.length > SLACK_AUTO_SELECT_MAX_ITEMS) {
|
||||
return null;
|
||||
}
|
||||
if (!entries.every((entry) => SLACK_SIMPLE_OPTION_RE.test(entry))) {
|
||||
return null;
|
||||
}
|
||||
const deduped = new Set(entries.map((entry) => entry.toLowerCase()));
|
||||
if (deduped.size !== entries.length) {
|
||||
return null;
|
||||
}
|
||||
return entries.map((entry) => ({
|
||||
label: entry,
|
||||
value: entry,
|
||||
}));
|
||||
}
|
||||
|
||||
export function parseSlackOptionsLine(payload: ReplyPayload): ReplyPayload {
|
||||
const text = payload.text;
|
||||
if (!text || payload.interactive?.blocks?.length || hasSlackBlocks(payload)) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
const lines = text.split("\n");
|
||||
const lastNonEmptyIndex = [...lines.keys()].toReversed().find((index) => lines[index]?.trim());
|
||||
if (lastNonEmptyIndex == null) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
const optionsLine = lines[lastNonEmptyIndex] ?? "";
|
||||
const match = optionsLine.match(SLACK_OPTIONS_LINE_RE);
|
||||
if (!match) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
const choices = parseSimpleSlackOptions(match[1] ?? "");
|
||||
if (!choices) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
const bodyText = lines
|
||||
.filter((_, index) => index !== lastNonEmptyIndex)
|
||||
.join("\n")
|
||||
.trim();
|
||||
const generatedBlocks: NonNullable<ReplyPayload["interactive"]>["blocks"] = [];
|
||||
const bodyBlock = buildTextBlock(bodyText);
|
||||
if (bodyBlock) {
|
||||
generatedBlocks.push(bodyBlock);
|
||||
}
|
||||
generatedBlocks.push(
|
||||
choices.length <= SLACK_BUTTON_MAX_ITEMS
|
||||
? {
|
||||
type: "buttons",
|
||||
buttons: choices,
|
||||
}
|
||||
: {
|
||||
type: "select",
|
||||
placeholder: "Choose an option",
|
||||
options: choices,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
...payload,
|
||||
text: bodyText || undefined,
|
||||
interactive: {
|
||||
blocks: [...(payload.interactive?.blocks ?? []), ...generatedBlocks],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function compileSlackInteractiveReplies(payload: ReplyPayload): ReplyPayload {
|
||||
const text = payload.text;
|
||||
if (!text) {
|
||||
return payload;
|
||||
}
|
||||
if (hasSlackDirectives(text)) {
|
||||
return parseSlackDirectives(payload);
|
||||
}
|
||||
return parseSlackOptionsLine(payload);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user