feat(feishu): extract action.option, action.options, and action.form_value from card callbacks (closes #42754)

Extend FeishuCardActionEvent type and handleFeishuCardAction() to extract
data from select_static dropdowns (action.option), multi-select checkboxes
(action.options), and form submissions (action.form_value).

Previously only action.value was read, silently discarding interaction data
from all non-button card components. The new fields are merged with the
existing value object to provide complete context to downstream handlers.

Existing button flows (value.text and value.command) are completely
unaffected — the new extraction only activates when the component-specific
fields are present.

Add tests for select_static, checkbox, and form submission scenarios.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Br1an67 2026-03-14 17:59:05 +08:00
parent db20141993
commit d9f1b69dbf
2 changed files with 147 additions and 16 deletions

View File

@ -60,4 +60,83 @@ describe("Feishu Card Action Handler", () => {
}), }),
); );
}); });
it("handles select_static dropdown with action.option", async () => {
const event: FeishuCardActionEvent = {
operator: { open_id: "u123", user_id: "uid1", union_id: "un1" },
token: "tok3",
action: { value: { field: "model_selection" }, tag: "select_static", option: "gpt-4o" },
context: { open_id: "u123", user_id: "uid1", chat_id: "chat1" },
};
await handleFeishuCardAction({ cfg, event, runtime });
const call = vi
.mocked(handleFeishuMessage)
.mock.calls.find(
(c: unknown[]) =>
(c[0] as { event: { message: { message_id: string } } }).event.message.message_id ===
"card-action-tok3",
);
expect(call).toBeDefined();
const inner = JSON.parse(
JSON.parse((call![0] as { event: { message: { content: string } } }).event.message.content)
.text,
);
expect(inner).toMatchObject({ option: "gpt-4o" });
});
it("handles multi-select checkbox with action.options", async () => {
const event: FeishuCardActionEvent = {
operator: { open_id: "u123", user_id: "uid1", union_id: "un1" },
token: "tok4",
action: { value: { field: "tags" }, tag: "checkbox", options: ["tag1", "tag2"] },
context: { open_id: "u123", user_id: "uid1", chat_id: "chat1" },
};
await handleFeishuCardAction({ cfg, event, runtime });
const call = vi
.mocked(handleFeishuMessage)
.mock.calls.find(
(c: unknown[]) =>
(c[0] as { event: { message: { message_id: string } } }).event.message.message_id ===
"card-action-tok4",
);
expect(call).toBeDefined();
const inner = JSON.parse(
JSON.parse((call![0] as { event: { message: { content: string } } }).event.message.content)
.text,
);
expect(inner).toMatchObject({ options: ["tag1", "tag2"] });
});
it("handles form submission with action.form_value", async () => {
const event: FeishuCardActionEvent = {
operator: { open_id: "u123", user_id: "uid1", union_id: "un1" },
token: "tok5",
action: {
value: { form_id: "approval" },
tag: "form",
form_value: { name: "Alice", approved: true },
},
context: { open_id: "u123", user_id: "uid1", chat_id: "chat1" },
};
await handleFeishuCardAction({ cfg, event, runtime });
const call = vi
.mocked(handleFeishuMessage)
.mock.calls.find(
(c: unknown[]) =>
(c[0] as { event: { message: { message_id: string } } }).event.message.message_id ===
"card-action-tok5",
);
expect(call).toBeDefined();
const inner = JSON.parse(
JSON.parse((call![0] as { event: { message: { content: string } } }).event.message.content)
.text,
);
expect(inner).toMatchObject({ form_value: { name: "Alice", approved: true } });
});
}); });

View File

@ -12,6 +12,9 @@ export type FeishuCardActionEvent = {
action: { action: {
value: Record<string, unknown>; value: Record<string, unknown>;
tag: string; tag: string;
option?: string;
options?: string[];
form_value?: Record<string, unknown>;
}; };
context: { context: {
open_id: string; open_id: string;
@ -19,21 +22,6 @@ export type FeishuCardActionEvent = {
chat_id: string; chat_id: string;
}; };
}; };
function buildCardActionTextFallback(event: FeishuCardActionEvent): string {
const actionValue = event.action.value;
if (typeof actionValue === "object" && actionValue !== null) {
if ("text" in actionValue && typeof actionValue.text === "string") {
return actionValue.text;
}
if ("command" in actionValue && typeof actionValue.command === "string") {
return actionValue.command;
}
return JSON.stringify(actionValue);
}
return String(actionValue);
}
export async function handleFeishuCardAction(params: { export async function handleFeishuCardAction(params: {
cfg: ClawdbotConfig; cfg: ClawdbotConfig;
event: FeishuCardActionEvent; event: FeishuCardActionEvent;
@ -44,7 +32,71 @@ export async function handleFeishuCardAction(params: {
const { cfg, event, runtime, accountId } = params; const { cfg, event, runtime, accountId } = params;
const account = resolveFeishuAccount({ cfg, accountId }); const account = resolveFeishuAccount({ cfg, accountId });
const log = runtime?.log ?? console.log; const log = runtime?.log ?? console.log;
const content = buildCardActionTextFallback(event);
// Extract action content — check component-specific fields before falling
// through to the generic value.text / value.command path.
const actionValue = event.action.value ?? {};
let content = "";
// Interactive components: when the static value includes a command/text
// routing key, preserve it as the content so downstream command detection
// still works. Attach the dynamic user input as structured metadata that
// the agent can inspect without breaking command probing.
const hasCommand =
typeof actionValue === "object" &&
actionValue !== null &&
"command" in actionValue &&
typeof actionValue.command === "string";
const hasText =
typeof actionValue === "object" &&
actionValue !== null &&
"text" in actionValue &&
typeof actionValue.text === "string";
if (event.action.form_value && typeof event.action.form_value === "object") {
if (hasCommand) {
content = `${actionValue.command} ${JSON.stringify({ form_value: event.action.form_value })}`;
} else if (hasText) {
content = `${actionValue.text} ${JSON.stringify({ form_value: event.action.form_value })}`;
} else {
const merged = { ...actionValue, form_value: event.action.form_value };
content = JSON.stringify(merged);
}
}
// multi-select (checkbox): merge options array into value
else if (Array.isArray(event.action.options)) {
if (hasCommand) {
content = `${actionValue.command} ${JSON.stringify({ options: event.action.options })}`;
} else if (hasText) {
content = `${actionValue.text} ${JSON.stringify({ options: event.action.options })}`;
} else {
const merged = { ...actionValue, options: event.action.options };
content = JSON.stringify(merged);
}
}
// single-select (select_static dropdown): merge option into value
else if (typeof event.action.option === "string") {
if (hasCommand) {
content = `${actionValue.command} ${JSON.stringify({ option: event.action.option })}`;
} else if (hasText) {
content = `${actionValue.text} ${JSON.stringify({ option: event.action.option })}`;
} else {
const merged = { ...actionValue, option: event.action.option };
content = JSON.stringify(merged);
}
}
// button: existing text / command / JSON fallback
else if (typeof actionValue === "object" && actionValue !== null) {
if ("text" in actionValue && typeof actionValue.text === "string") {
content = actionValue.text;
} else if ("command" in actionValue && typeof actionValue.command === "string") {
content = actionValue.command;
} else {
content = JSON.stringify(actionValue);
}
} else {
content = String(actionValue);
}
// Construct a synthetic message event // Construct a synthetic message event
const messageEvent: FeishuMessageEvent = { const messageEvent: FeishuMessageEvent = {