fix(feishu): preserve button command values in fallback text and add Feishu comment guidance with callback privacy (#94385)

* 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>
This commit is contained in:
Peter Lee
2026-07-02 14:42:31 -05:00
committed by GitHub
parent a004e18b4b
commit 2d764dd8fe
6 changed files with 242 additions and 12 deletions
+33
View File
@@ -278,6 +278,39 @@ describe("interactive payload helpers", () => {
});
});
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 }],
+26 -1
View File
@@ -503,6 +503,21 @@ export function interactiveReplyToPresentation(
return blocks.length > 0 ? { blocks } : undefined;
}
/**
* Render presentation blocks as plain-text fallback for channels that do not
* support native interactive controls.
*
* Text and context blocks are rendered as-is. Buttons with a `command`-typed
* action render as `label: \`command\`` so the value is copyable. Buttons with
* a `callback` action, legacy `value`, or `select` options render as label-only
* to keep opaque callback values private. Disabled buttons render as label-only
* regardless of action type, since they are not actionable.
*
* Downstream consumers should not claim a manual command is available unless
* they verify one was actually rendered.
*
* Exported through the plugin SDK for channel adapters.
*/
export function renderMessagePresentationFallbackText(params: {
presentation?: MessagePresentation;
emptyFallback?: string | null;
@@ -529,7 +544,17 @@ export function renderMessagePresentationFallbackText(params: {
const labels = block.buttons
.map((button) => {
const targetUrl = button.url ?? button.webApp?.url ?? button.web_app?.url;
return targetUrl ? `${button.label}: ${targetUrl}` : button.label;
if (targetUrl) {
return `${button.label}: ${targetUrl}`;
}
const controlValue =
button.action?.type === "command"
? resolveMessagePresentationControlValue(button)
: undefined;
if (controlValue && !button.disabled) {
return `${button.label}: \`${controlValue}\``;
}
return button.label;
})
.filter(Boolean);
if (labels.length > 0) {