mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-10 22:36:10 +00:00
* fix(interactive): preserve button command values in fallback text for degraded approval UX * fix(interactive): keep callback values private in fallback text and narrow Feishu interactive detection - P1: Skip rendering action.type === "callback" values in renderMessagePresentationFallbackText to avoid leaking opaque channel/plugin data into user-visible text. Command and legacy values are still rendered. - P2: Replace hasMessagePresentationBlocks/hasInteractiveReplyBlocks with isMessagePresentationInteractiveBlock so Feishu comment guidance only appears when the presentation actually contains buttons or selects, not for text-only blocks. - Update tests: callback button now shows label-only; all 137 tests pass. * fix(interactive): only render typed command values in fallback text, keep legacy value private * fix(feishu): gate document-comment command guidance on actual command action * docs(message-presentation): document command/callback value fallback visibility * fix(feishu): omit command guidance when URL overrides fallback command text * docs: regenerate docs_map.md * fix(interactive): exclude disabled buttons from fallback command rendering and guidance * fix(interactive): extract hasRenderedCommandAction, exclude disabled buttons from command fallback * fix(feishu): preserve command guidance marker through core presentation rendering * fix(feishu): type-narrow channelData.feishu with isRecord before reading rendered-command marker * fix(feishu): move hasRenderedCommandAction from public SDK into Feishu plugin as local helper Keep the helper local to the only caller (Feishu outbound) instead of adding a new public plugin SDK API contract. The shared fallback renderer in renderMessagePresentationFallbackText already inlines the same command-visibility logic; a local helper is sufficient for the Feishu comment-thread guidance gate. * refactor(feishu): tighten fallback command marker --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
328 lines
8.9 KiB
TypeScript
328 lines
8.9 KiB
TypeScript
// Interactive payload tests cover validation of interactive response payloads.
|
|
import { describe, expect, it } from "vitest";
|
|
import {
|
|
hasReplyChannelData,
|
|
hasReplyContent,
|
|
hasReplyPayloadContent,
|
|
normalizeInteractiveReply,
|
|
normalizeMessagePresentation,
|
|
presentationToInteractiveControlsReply,
|
|
presentationToInteractiveReply,
|
|
renderMessagePresentationFallbackText,
|
|
resolveInteractiveTextFallback,
|
|
} from "./payload.js";
|
|
|
|
describe("hasReplyChannelData", () => {
|
|
it.each([
|
|
{ value: undefined, expected: false },
|
|
{ value: {}, expected: false },
|
|
{ value: [], expected: false },
|
|
{ value: { slack: { blocks: [] } }, expected: true },
|
|
] as const)("accepts non-empty objects only: %j", ({ value, expected }) => {
|
|
expect(hasReplyChannelData(value)).toBe(expected);
|
|
});
|
|
});
|
|
|
|
describe("hasReplyContent", () => {
|
|
it("treats whitespace-only text and empty structured payloads as empty", () => {
|
|
expect(
|
|
hasReplyContent({
|
|
text: " ",
|
|
mediaUrls: ["", " "],
|
|
interactive: { blocks: [] },
|
|
hasChannelData: false,
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: "shared interactive blocks",
|
|
input: {
|
|
interactive: {
|
|
blocks: [{ type: "buttons", buttons: [{ label: "Retry", value: "retry" }] }],
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "explicit extra content",
|
|
input: {
|
|
text: " ",
|
|
extraContent: true,
|
|
},
|
|
},
|
|
] as const)("accepts $name", ({ input }) => {
|
|
expect(hasReplyContent(input)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("hasReplyPayloadContent", () => {
|
|
it("trims text and falls back to channel data by default", () => {
|
|
expect(
|
|
hasReplyPayloadContent({
|
|
text: " ",
|
|
channelData: { slack: { blocks: [] } },
|
|
}),
|
|
).toBe(true);
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: "explicit channel-data overrides",
|
|
payload: {
|
|
text: " ",
|
|
channelData: {},
|
|
},
|
|
options: {
|
|
hasChannelData: true,
|
|
},
|
|
},
|
|
{
|
|
name: "extra content",
|
|
payload: {
|
|
text: " ",
|
|
},
|
|
options: {
|
|
extraContent: true,
|
|
},
|
|
},
|
|
] as const)("accepts $name", ({ payload, options }) => {
|
|
expect(hasReplyPayloadContent(payload, options)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("interactive payload helpers", () => {
|
|
it("normalizes interactive replies and resolves text fallbacks", () => {
|
|
const interactive = normalizeInteractiveReply({
|
|
blocks: [
|
|
{ type: "text", text: "First" },
|
|
{ type: "buttons", buttons: [{ label: "Retry", value: "retry" }] },
|
|
{ type: "text", text: "Second" },
|
|
],
|
|
});
|
|
|
|
expect(interactive).toEqual({
|
|
blocks: [
|
|
{ type: "text", text: "First" },
|
|
{ type: "buttons", buttons: [{ label: "Retry", value: "retry" }] },
|
|
{ type: "text", text: "Second" },
|
|
],
|
|
});
|
|
expect(resolveInteractiveTextFallback({ interactive })).toBe("First\n\nSecond");
|
|
});
|
|
|
|
it("preserves URL-only presentation buttons for native link renderers and fallback text", () => {
|
|
const presentation = {
|
|
blocks: [
|
|
{
|
|
type: "buttons" as const,
|
|
buttons: [{ label: "Docs", url: "https://example.com/docs" }],
|
|
},
|
|
],
|
|
};
|
|
|
|
expect(presentationToInteractiveReply(presentation)).toEqual({
|
|
blocks: [
|
|
{
|
|
type: "buttons",
|
|
buttons: [{ label: "Docs", url: "https://example.com/docs" }],
|
|
},
|
|
],
|
|
});
|
|
expect(renderMessagePresentationFallbackText({ presentation })).toBe(
|
|
"- Docs: https://example.com/docs",
|
|
);
|
|
});
|
|
|
|
it("preserves web app presentation buttons for channel-native renderers", () => {
|
|
const presentation = {
|
|
blocks: [
|
|
{
|
|
type: "buttons" as const,
|
|
buttons: [{ label: "Launch", web_app: { url: "https://example.com/app" } }],
|
|
},
|
|
],
|
|
};
|
|
const normalized = normalizeMessagePresentation(presentation);
|
|
|
|
expect(normalized).toEqual({
|
|
blocks: [
|
|
{
|
|
type: "buttons",
|
|
buttons: [{ label: "Launch", webApp: { url: "https://example.com/app" } }],
|
|
},
|
|
],
|
|
});
|
|
expect(presentationToInteractiveReply(normalized!)).toEqual({
|
|
blocks: [
|
|
{
|
|
type: "buttons",
|
|
buttons: [{ label: "Launch", webApp: { url: "https://example.com/app" } }],
|
|
},
|
|
],
|
|
});
|
|
expect(renderMessagePresentationFallbackText({ presentation: normalized })).toBe(
|
|
"- Launch: https://example.com/app",
|
|
);
|
|
});
|
|
|
|
it("normalizes typed presentation actions and bridges them to legacy values", () => {
|
|
const normalized = normalizeMessagePresentation({
|
|
blocks: [
|
|
{
|
|
type: "buttons",
|
|
buttons: [
|
|
{
|
|
label: "Plugins",
|
|
action: { type: "command", command: "/codex plugins menu" },
|
|
},
|
|
{
|
|
label: "Approve",
|
|
action: { type: "callback", value: "/approve req allow-once" },
|
|
},
|
|
],
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(normalized).toEqual({
|
|
blocks: [
|
|
{
|
|
type: "buttons",
|
|
buttons: [
|
|
{
|
|
label: "Plugins",
|
|
action: { type: "command", command: "/codex plugins menu" },
|
|
},
|
|
{
|
|
label: "Approve",
|
|
action: { type: "callback", value: "/approve req allow-once" },
|
|
},
|
|
],
|
|
},
|
|
],
|
|
});
|
|
expect(presentationToInteractiveReply(normalized!)).toEqual({
|
|
blocks: [
|
|
{
|
|
type: "buttons",
|
|
buttons: [
|
|
{
|
|
label: "Plugins",
|
|
action: { type: "command", command: "/codex plugins menu" },
|
|
value: "/codex plugins menu",
|
|
},
|
|
{
|
|
label: "Approve",
|
|
action: { type: "callback", value: "/approve req allow-once" },
|
|
value: "/approve req allow-once",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
it("converts only presentation controls for native component renderers", () => {
|
|
const presentation = {
|
|
title: "Deploy approval",
|
|
blocks: [
|
|
{ type: "text" as const, text: "Canary is ready." },
|
|
{ type: "divider" as const },
|
|
{
|
|
type: "buttons" as const,
|
|
buttons: [
|
|
{
|
|
label: "Approve",
|
|
value: "approve",
|
|
style: "success" as const,
|
|
reusable: true,
|
|
},
|
|
],
|
|
},
|
|
{
|
|
type: "select" as const,
|
|
placeholder: "Rollback target",
|
|
options: [{ label: "Previous", value: "previous" }],
|
|
},
|
|
],
|
|
};
|
|
|
|
expect(presentationToInteractiveReply(presentation)).toEqual({
|
|
blocks: [
|
|
{ type: "text", text: "Deploy approval" },
|
|
{ type: "text", text: "Canary is ready." },
|
|
{
|
|
type: "buttons",
|
|
buttons: [{ label: "Approve", value: "approve", style: "success", reusable: true }],
|
|
},
|
|
{
|
|
type: "select",
|
|
placeholder: "Rollback target",
|
|
options: [{ label: "Previous", value: "previous" }],
|
|
},
|
|
],
|
|
});
|
|
expect(presentationToInteractiveControlsReply(presentation)).toEqual({
|
|
blocks: [
|
|
{
|
|
type: "buttons",
|
|
buttons: [{ label: "Approve", value: "approve", style: "success", reusable: true }],
|
|
},
|
|
{
|
|
type: "select",
|
|
placeholder: "Rollback target",
|
|
options: [{ label: "Previous", value: "previous" }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
it("preserves command values in button fallback text while keeping callback values private", () => {
|
|
const presentation = {
|
|
blocks: [
|
|
{
|
|
type: "buttons" as const,
|
|
buttons: [
|
|
{ label: "Approve", value: "/approve req_1 allow-once" },
|
|
{ label: "Deny", action: { type: "command" as const, command: "/approve req_1 deny" } },
|
|
{ label: "Ignore", action: { type: "callback" as const, value: "ignore_123" } },
|
|
{ label: "Docs", url: "https://example.com/docs" },
|
|
{ label: "Disabled", disabled: true },
|
|
{
|
|
label: "DisabledCmd",
|
|
disabled: true,
|
|
action: { type: "command" as const, command: "/test" },
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
|
|
expect(renderMessagePresentationFallbackText({ presentation })).toBe(
|
|
[
|
|
"- Approve",
|
|
"- Deny: `/approve req_1 deny`",
|
|
"- Ignore",
|
|
"- Docs: https://example.com/docs",
|
|
"- Disabled",
|
|
"- DisabledCmd",
|
|
].join("\n"),
|
|
);
|
|
});
|
|
|
|
it("keeps divider-only fallback empty unless a send transport fallback is requested", () => {
|
|
const presentation = {
|
|
blocks: [{ type: "divider" as const }],
|
|
};
|
|
|
|
expect(renderMessagePresentationFallbackText({ presentation })).toBe("");
|
|
expect(
|
|
renderMessagePresentationFallbackText({
|
|
presentation,
|
|
emptyFallback: "---",
|
|
}),
|
|
).toBe("---");
|
|
});
|
|
});
|