diff --git a/CHANGELOG.md b/CHANGELOG.md index 4aaf84db974..ddfb252fc71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,8 @@ Docs: https://docs.openclaw.ai - secrets: harden read-only SecretRef command paths and diagnostics. (#47794) Thanks @joshavant. - Sandbox/runtime: add pluggable sandbox backends, ship an OpenShell backend with `mirror` and `remote` workspace modes, and make sandbox list/recreate/prune backend-aware instead of Docker-only. - Sandbox/SSH: add a core SSH sandbox backend with secret-backed key, certificate, and known_hosts inputs, move shared remote exec/filesystem tooling into core, and keep OpenShell focused on sandbox lifecycle plus optional `mirror` mode. +- Feishu/cards: add structured interactive approval and quick-action launcher cards, preserve callback user and conversation context through routing, and keep legacy card-action fallback behavior so common actions can run without typing raw commands. (#47873) +- Feishu/ACP: add current-conversation ACP and subagent session binding for supported DMs and topic conversations, including completion delivery back to the originating Feishu conversation. (#46819) ### Fixes diff --git a/extensions/feishu/src/bot.card-action.test.ts b/extensions/feishu/src/bot.card-action.test.ts index 90967b593bd..2df1ce361a1 100644 --- a/extensions/feishu/src/bot.card-action.test.ts +++ b/extensions/feishu/src/bot.card-action.test.ts @@ -1,5 +1,15 @@ -import { describe, it, expect, vi } from "vitest"; -import { handleFeishuCardAction, type FeishuCardActionEvent } from "./card-action.js"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + handleFeishuCardAction, + resetProcessedFeishuCardActionTokensForTests, + type FeishuCardActionEvent, +} from "./card-action.js"; +import { createFeishuCardInteractionEnvelope } from "./card-interaction.js"; +import { + FEISHU_APPROVAL_CANCEL_ACTION, + FEISHU_APPROVAL_CONFIRM_ACTION, + FEISHU_APPROVAL_REQUEST_ACTION, +} from "./card-ux-approval.js"; // Mock resolveFeishuAccount vi.mock("./accounts.js", () => ({ @@ -11,12 +21,25 @@ vi.mock("./bot.js", () => ({ handleFeishuMessage: vi.fn(), })); +const sendCardFeishuMock = vi.hoisted(() => vi.fn()); +const sendMessageFeishuMock = vi.hoisted(() => vi.fn()); + +vi.mock("./send.js", () => ({ + sendCardFeishu: sendCardFeishuMock, + sendMessageFeishu: sendMessageFeishuMock, +})); + import { handleFeishuMessage } from "./bot.js"; describe("Feishu Card Action Handler", () => { const cfg = {} as any; // Minimal mock const runtime = { log: vi.fn(), error: vi.fn() } as any; + beforeEach(() => { + vi.clearAllMocks(); + resetProcessedFeishuCardActionTokensForTests(); + }); + it("handles card action with text payload", async () => { const event: FeishuCardActionEvent = { operator: { open_id: "u123", user_id: "uid1", union_id: "un1" }, @@ -60,4 +83,321 @@ describe("Feishu Card Action Handler", () => { }), ); }); + + it("routes quick command actions with operator and conversation context", async () => { + const event: FeishuCardActionEvent = { + operator: { open_id: "u123", user_id: "uid1", union_id: "un1" }, + token: "tok3", + action: { + value: createFeishuCardInteractionEnvelope({ + k: "quick", + a: "feishu.quick_actions.help", + q: "/help", + c: { u: "u123", h: "chat1", t: "group", e: Date.now() + 60_000 }, + }), + tag: "button", + }, + context: { open_id: "u123", user_id: "uid1", chat_id: "chat1" }, + }; + + await handleFeishuCardAction({ cfg, event, runtime }); + + expect(handleFeishuMessage).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + sender: expect.objectContaining({ + sender_id: expect.objectContaining({ + open_id: "u123", + user_id: "uid1", + union_id: "un1", + }), + }), + message: expect.objectContaining({ + chat_id: "chat1", + content: '{"text":"/help"}', + }), + }), + }), + ); + }); + + it("opens an approval card for metadata actions", async () => { + const event: FeishuCardActionEvent = { + operator: { open_id: "u123", user_id: "uid1", union_id: "un1" }, + token: "tok4", + action: { + value: createFeishuCardInteractionEnvelope({ + k: "meta", + a: FEISHU_APPROVAL_REQUEST_ACTION, + m: { + command: "/new", + prompt: "Start a fresh session?", + }, + c: { + u: "u123", + h: "chat1", + t: "group", + s: "agent:codex:feishu:chat:chat1", + e: Date.now() + 60_000, + }, + }), + tag: "button", + }, + context: { open_id: "u123", user_id: "uid1", chat_id: "chat1" }, + }; + + await handleFeishuCardAction({ cfg, event, runtime, accountId: "main" }); + + expect(sendCardFeishuMock).toHaveBeenCalledWith( + expect.objectContaining({ + to: "chat:chat1", + accountId: "main", + card: expect.objectContaining({ + header: expect.objectContaining({ + title: expect.objectContaining({ content: "Confirm action" }), + }), + body: expect.objectContaining({ + elements: expect.arrayContaining([ + expect.objectContaining({ + tag: "action", + actions: expect.arrayContaining([ + expect.objectContaining({ + value: expect.objectContaining({ + c: expect.objectContaining({ + u: "u123", + h: "chat1", + t: "group", + s: "agent:codex:feishu:chat:chat1", + }), + }), + }), + ]), + }), + ]), + }), + }), + }), + ); + expect(handleFeishuMessage).not.toHaveBeenCalled(); + }); + + it("runs approval confirmation through the normal message path", async () => { + const event: FeishuCardActionEvent = { + operator: { open_id: "u123", user_id: "uid1", union_id: "un1" }, + token: "tok5", + action: { + value: createFeishuCardInteractionEnvelope({ + k: "quick", + a: FEISHU_APPROVAL_CONFIRM_ACTION, + q: "/new", + c: { u: "u123", h: "chat1", t: "group", e: Date.now() + 60_000 }, + }), + tag: "button", + }, + context: { open_id: "u123", user_id: "uid1", chat_id: "chat1" }, + }; + + await handleFeishuCardAction({ cfg, event, runtime }); + + expect(handleFeishuMessage).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + message: expect.objectContaining({ + content: '{"text":"/new"}', + }), + }), + }), + ); + }); + + it("safely rejects stale structured actions", async () => { + const event: FeishuCardActionEvent = { + operator: { open_id: "u123", user_id: "uid1", union_id: "un1" }, + token: "tok6", + action: { + value: createFeishuCardInteractionEnvelope({ + k: "quick", + a: "feishu.quick_actions.help", + q: "/help", + c: { u: "u123", h: "chat1", t: "group", e: Date.now() - 1 }, + }), + tag: "button", + }, + context: { open_id: "u123", user_id: "uid1", chat_id: "chat1" }, + }; + + await handleFeishuCardAction({ cfg, event, runtime }); + + expect(sendMessageFeishuMock).toHaveBeenCalledWith( + expect.objectContaining({ + to: "chat:chat1", + text: expect.stringContaining("expired"), + }), + ); + expect(handleFeishuMessage).not.toHaveBeenCalled(); + }); + + it("safely rejects wrong-user structured actions", async () => { + const event: FeishuCardActionEvent = { + operator: { open_id: "u999", user_id: "uid1", union_id: "un1" }, + token: "tok7", + action: { + value: createFeishuCardInteractionEnvelope({ + k: "quick", + a: "feishu.quick_actions.help", + q: "/help", + c: { u: "u123", h: "chat1", t: "group", e: Date.now() + 60_000 }, + }), + tag: "button", + }, + context: { open_id: "u999", user_id: "uid1", chat_id: "chat1" }, + }; + + await handleFeishuCardAction({ cfg, event, runtime }); + + expect(sendMessageFeishuMock).toHaveBeenCalledWith( + expect.objectContaining({ + text: expect.stringContaining("different user"), + }), + ); + expect(handleFeishuMessage).not.toHaveBeenCalled(); + }); + + it("sends a lightweight cancellation notice", async () => { + const event: FeishuCardActionEvent = { + operator: { open_id: "u123", user_id: "uid1", union_id: "un1" }, + token: "tok8", + action: { + value: createFeishuCardInteractionEnvelope({ + k: "button", + a: FEISHU_APPROVAL_CANCEL_ACTION, + c: { u: "u123", h: "chat1", t: "group", e: Date.now() + 60_000 }, + }), + tag: "button", + }, + context: { open_id: "u123", user_id: "uid1", chat_id: "chat1" }, + }; + + await handleFeishuCardAction({ cfg, event, runtime }); + + expect(sendMessageFeishuMock).toHaveBeenCalledWith( + expect.objectContaining({ + to: "chat:chat1", + text: "Cancelled.", + }), + ); + }); + + it("preserves p2p callbacks for DM quick actions", async () => { + const event: FeishuCardActionEvent = { + operator: { open_id: "u123", user_id: "uid1", union_id: "un1" }, + token: "tok9", + action: { + value: createFeishuCardInteractionEnvelope({ + k: "quick", + a: "feishu.quick_actions.help", + q: "/help", + c: { u: "u123", h: "p2p-chat-1", t: "p2p", e: Date.now() + 60_000 }, + }), + tag: "button", + }, + context: { open_id: "u123", user_id: "uid1", chat_id: "p2p-chat-1" }, + }; + + await handleFeishuCardAction({ cfg, event, runtime }); + + expect(handleFeishuMessage).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + message: expect.objectContaining({ + chat_id: "p2p-chat-1", + chat_type: "p2p", + }), + }), + }), + ); + }); + + it("drops duplicate structured callback tokens", async () => { + const event: FeishuCardActionEvent = { + operator: { open_id: "u123", user_id: "uid1", union_id: "un1" }, + token: "tok10", + action: { + value: createFeishuCardInteractionEnvelope({ + k: "quick", + a: "feishu.quick_actions.help", + q: "/help", + c: { u: "u123", h: "chat1", t: "group", e: Date.now() + 60_000 }, + }), + tag: "button", + }, + context: { open_id: "u123", user_id: "uid1", chat_id: "chat1" }, + }; + + await handleFeishuCardAction({ cfg, event, runtime }); + await handleFeishuCardAction({ cfg, event, runtime }); + + expect(handleFeishuMessage).toHaveBeenCalledTimes(1); + }); + + it("releases a claimed token when dispatch fails so retries can succeed", async () => { + const event: FeishuCardActionEvent = { + operator: { open_id: "u123", user_id: "uid1", union_id: "un1" }, + token: "tok11", + action: { + value: createFeishuCardInteractionEnvelope({ + k: "quick", + a: "feishu.quick_actions.help", + q: "/help", + c: { u: "u123", h: "chat1", t: "group", e: Date.now() + 60_000 }, + }), + tag: "button", + }, + context: { open_id: "u123", user_id: "uid1", chat_id: "chat1" }, + }; + vi.mocked(handleFeishuMessage) + .mockRejectedValueOnce(new Error("transient")) + .mockResolvedValueOnce(undefined as never); + + await expect(handleFeishuCardAction({ cfg, event, runtime })).rejects.toThrow("transient"); + await handleFeishuCardAction({ cfg, event, runtime }); + + expect(handleFeishuMessage).toHaveBeenCalledTimes(2); + }); + + it("keeps an in-flight token claimed while a slow dispatch is still running", async () => { + vi.useFakeTimers(); + const event: FeishuCardActionEvent = { + operator: { open_id: "u123", user_id: "uid1", union_id: "un1" }, + token: "tok12", + action: { + value: createFeishuCardInteractionEnvelope({ + k: "quick", + a: "feishu.quick_actions.help", + q: "/help", + c: { u: "u123", h: "chat1", t: "group", e: Date.now() + 60_000 }, + }), + tag: "button", + }, + context: { open_id: "u123", user_id: "uid1", chat_id: "chat1" }, + }; + + let resolveDispatch: (() => void) | undefined; + vi.mocked(handleFeishuMessage).mockImplementation( + () => + new Promise((resolve) => { + resolveDispatch = resolve; + }) as never, + ); + + const first = handleFeishuCardAction({ cfg, event, runtime }); + await vi.advanceTimersByTimeAsync(61_000); + await handleFeishuCardAction({ cfg, event, runtime }); + + expect(handleFeishuMessage).toHaveBeenCalledTimes(1); + + resolveDispatch?.(); + await first; + vi.useRealTimers(); + }); }); diff --git a/extensions/feishu/src/card-action.ts b/extensions/feishu/src/card-action.ts index e4f76846316..d664b8d6af2 100644 --- a/extensions/feishu/src/card-action.ts +++ b/extensions/feishu/src/card-action.ts @@ -1,6 +1,14 @@ import type { ClawdbotConfig, RuntimeEnv } from "openclaw/plugin-sdk/feishu"; import { resolveFeishuAccount } from "./accounts.js"; import { handleFeishuMessage, type FeishuMessageEvent } from "./bot.js"; +import { decodeFeishuCardAction, buildFeishuCardActionTextFallback } from "./card-interaction.js"; +import { + createApprovalCard, + FEISHU_APPROVAL_CANCEL_ACTION, + FEISHU_APPROVAL_CONFIRM_ACTION, + FEISHU_APPROVAL_REQUEST_ACTION, +} from "./card-ux-approval.js"; +import { sendCardFeishu, sendMessageFeishu } from "./send.js"; export type FeishuCardActionEvent = { operator: { @@ -20,18 +28,142 @@ export type FeishuCardActionEvent = { }; }; -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; +const FEISHU_APPROVAL_CARD_TTL_MS = 5 * 60_000; +const FEISHU_CARD_ACTION_TOKEN_TTL_MS = 15 * 60_000; +const processedCardActionTokens = new Map< + string, + { status: "inflight" | "completed"; expiresAt: number } +>(); + +export function resetProcessedFeishuCardActionTokensForTests(): void { + processedCardActionTokens.clear(); +} + +function pruneProcessedCardActionTokens(now: number): void { + for (const [key, entry] of processedCardActionTokens.entries()) { + if (entry.expiresAt <= now) { + processedCardActionTokens.delete(key); } - if ("command" in actionValue && typeof actionValue.command === "string") { - return actionValue.command; - } - return JSON.stringify(actionValue); } - return String(actionValue); +} + +function beginFeishuCardActionToken(params: { + token: string; + accountId: string; + now?: number; +}): boolean { + const now = params.now ?? Date.now(); + pruneProcessedCardActionTokens(now); + const normalizedToken = params.token.trim(); + if (!normalizedToken) { + return true; + } + const key = `${params.accountId}:${normalizedToken}`; + const existing = processedCardActionTokens.get(key); + if (existing && existing.expiresAt > now) { + return false; + } + processedCardActionTokens.set(key, { + status: "inflight", + expiresAt: now + FEISHU_CARD_ACTION_TOKEN_TTL_MS, + }); + return true; +} + +function completeFeishuCardActionToken(params: { + token: string; + accountId: string; + now?: number; +}): void { + const now = params.now ?? Date.now(); + const normalizedToken = params.token.trim(); + if (!normalizedToken) { + return; + } + processedCardActionTokens.set(`${params.accountId}:${normalizedToken}`, { + status: "completed", + expiresAt: now + FEISHU_CARD_ACTION_TOKEN_TTL_MS, + }); +} + +function releaseFeishuCardActionToken(params: { token: string; accountId: string }): void { + const normalizedToken = params.token.trim(); + if (!normalizedToken) { + return; + } + processedCardActionTokens.delete(`${params.accountId}:${normalizedToken}`); +} + +function buildSyntheticMessageEvent( + event: FeishuCardActionEvent, + content: string, + chatType?: "p2p" | "group", +): FeishuMessageEvent { + return { + sender: { + sender_id: { + open_id: event.operator.open_id, + user_id: event.operator.user_id, + union_id: event.operator.union_id, + }, + }, + message: { + message_id: `card-action-${event.token}`, + chat_id: event.context.chat_id || event.operator.open_id, + chat_type: chatType ?? (event.context.chat_id ? "group" : "p2p"), + message_type: "text", + content: JSON.stringify({ text: content }), + }, + }; +} + +function resolveCallbackTarget(event: FeishuCardActionEvent): string { + const chatId = event.context.chat_id?.trim(); + if (chatId) { + return `chat:${chatId}`; + } + return `user:${event.operator.open_id}`; +} + +async function dispatchSyntheticCommand(params: { + cfg: ClawdbotConfig; + event: FeishuCardActionEvent; + command: string; + botOpenId?: string; + runtime?: RuntimeEnv; + accountId?: string; + chatType?: "p2p" | "group"; +}): Promise { + await handleFeishuMessage({ + cfg: params.cfg, + event: buildSyntheticMessageEvent(params.event, params.command, params.chatType), + botOpenId: params.botOpenId, + runtime: params.runtime, + accountId: params.accountId, + }); +} + +async function sendInvalidInteractionNotice(params: { + cfg: ClawdbotConfig; + event: FeishuCardActionEvent; + reason: "malformed" | "stale" | "wrong_user" | "wrong_conversation"; + accountId?: string; +}): Promise { + const reasonText = + params.reason === "stale" + ? "This card action has expired. Open a fresh launcher card and try again." + : params.reason === "wrong_user" + ? "This card action belongs to a different user." + : params.reason === "wrong_conversation" + ? "This card action belongs to a different conversation." + : "This card action payload is invalid."; + + await sendMessageFeishu({ + cfg: params.cfg, + to: resolveCallbackTarget(params.event), + text: `⚠️ ${reasonText}`, + accountId: params.accountId, + }); } export async function handleFeishuCardAction(params: { @@ -44,36 +176,135 @@ export async function handleFeishuCardAction(params: { const { cfg, event, runtime, accountId } = params; const account = resolveFeishuAccount({ cfg, accountId }); const log = runtime?.log ?? console.log; - const content = buildCardActionTextFallback(event); - - // Construct a synthetic message event - const messageEvent: FeishuMessageEvent = { - sender: { - sender_id: { - open_id: event.operator.open_id, - user_id: event.operator.user_id, - union_id: event.operator.union_id, - }, - }, - message: { - message_id: `card-action-${event.token}`, - chat_id: event.context.chat_id || event.operator.open_id, - chat_type: event.context.chat_id ? "group" : "p2p", - message_type: "text", - content: JSON.stringify({ text: content }), - }, - }; - - log( - `feishu[${account.accountId}]: handling card action from ${event.operator.open_id}: ${content}`, - ); - - // Dispatch as normal message - await handleFeishuMessage({ - cfg, - event: messageEvent, - botOpenId: params.botOpenId, - runtime, - accountId, + const decoded = decodeFeishuCardAction({ event }); + const claimedToken = beginFeishuCardActionToken({ + token: event.token, + accountId: account.accountId, }); + if (!claimedToken) { + log(`feishu[${account.accountId}]: skipping duplicate card action token ${event.token}`); + return; + } + + try { + if (decoded.kind === "invalid") { + log( + `feishu[${account.accountId}]: rejected card action from ${event.operator.open_id}: ${decoded.reason}`, + ); + await sendInvalidInteractionNotice({ + cfg, + event, + reason: decoded.reason, + accountId, + }); + completeFeishuCardActionToken({ token: event.token, accountId: account.accountId }); + return; + } + + if (decoded.kind === "structured") { + const { envelope } = decoded; + log( + `feishu[${account.accountId}]: handling structured card action ${envelope.a} from ${event.operator.open_id}`, + ); + + if (envelope.a === FEISHU_APPROVAL_REQUEST_ACTION) { + const command = typeof envelope.m?.command === "string" ? envelope.m.command.trim() : ""; + if (!command) { + await sendInvalidInteractionNotice({ + cfg, + event, + reason: "malformed", + accountId, + }); + completeFeishuCardActionToken({ token: event.token, accountId: account.accountId }); + return; + } + const prompt = + typeof envelope.m?.prompt === "string" && envelope.m.prompt.trim() + ? envelope.m.prompt + : `Run \`${command}\` in this Feishu conversation?`; + await sendCardFeishu({ + cfg, + to: resolveCallbackTarget(event), + card: createApprovalCard({ + operatorOpenId: event.operator.open_id, + chatId: event.context.chat_id || undefined, + command, + prompt, + sessionKey: envelope.c?.s, + expiresAt: Date.now() + FEISHU_APPROVAL_CARD_TTL_MS, + chatType: envelope.c?.t ?? (event.context.chat_id ? "group" : "p2p"), + confirmLabel: command === "/reset" ? "Reset" : "Confirm", + }), + accountId, + }); + completeFeishuCardActionToken({ token: event.token, accountId: account.accountId }); + return; + } + + if (envelope.a === FEISHU_APPROVAL_CANCEL_ACTION) { + await sendMessageFeishu({ + cfg, + to: resolveCallbackTarget(event), + text: "Cancelled.", + accountId, + }); + completeFeishuCardActionToken({ token: event.token, accountId: account.accountId }); + return; + } + + if (envelope.a === FEISHU_APPROVAL_CONFIRM_ACTION || envelope.k === "quick") { + const command = envelope.q?.trim(); + if (!command) { + await sendInvalidInteractionNotice({ + cfg, + event, + reason: "malformed", + accountId, + }); + completeFeishuCardActionToken({ token: event.token, accountId: account.accountId }); + return; + } + await dispatchSyntheticCommand({ + cfg, + event, + command, + botOpenId: params.botOpenId, + runtime, + accountId, + chatType: envelope.c?.t ?? (event.context.chat_id ? "group" : "p2p"), + }); + completeFeishuCardActionToken({ token: event.token, accountId: account.accountId }); + return; + } + + await sendInvalidInteractionNotice({ + cfg, + event, + reason: "malformed", + accountId, + }); + completeFeishuCardActionToken({ token: event.token, accountId: account.accountId }); + return; + } + + const content = buildFeishuCardActionTextFallback(event); + + log( + `feishu[${account.accountId}]: handling card action from ${event.operator.open_id}: ${content}`, + ); + + await dispatchSyntheticCommand({ + cfg, + event, + command: content, + botOpenId: params.botOpenId, + runtime, + accountId, + }); + completeFeishuCardActionToken({ token: event.token, accountId: account.accountId }); + } catch (err) { + releaseFeishuCardActionToken({ token: event.token, accountId: account.accountId }); + throw err; + } } diff --git a/extensions/feishu/src/card-interaction.test.ts b/extensions/feishu/src/card-interaction.test.ts new file mode 100644 index 00000000000..58aee261162 --- /dev/null +++ b/extensions/feishu/src/card-interaction.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from "vitest"; +import { + buildFeishuCardActionTextFallback, + createFeishuCardInteractionEnvelope, + decodeFeishuCardAction, +} from "./card-interaction.js"; + +describe("feishu card interaction decoder", () => { + it("decodes valid structured payloads", () => { + const result = decodeFeishuCardAction({ + now: 1_700_000_000_000, + event: { + operator: { open_id: "u123" }, + context: { chat_id: "chat1" }, + action: { + value: createFeishuCardInteractionEnvelope({ + k: "quick", + a: "feishu.quick_actions.help", + q: "/help", + c: { u: "u123", h: "chat1", t: "group", e: 1_700_000_060_000 }, + }), + }, + }, + }); + + expect(result).toEqual( + expect.objectContaining({ + kind: "structured", + envelope: expect.objectContaining({ + q: "/help", + }), + }), + ); + }); + + it("falls back for legacy text-like payloads", () => { + const result = decodeFeishuCardAction({ + event: { + operator: { open_id: "u123" }, + context: { chat_id: "chat1" }, + action: { value: { text: "/ping" } }, + }, + }); + + expect(result).toEqual({ kind: "legacy", text: "/ping" }); + expect( + buildFeishuCardActionTextFallback({ + operator: { open_id: "u123" }, + context: { chat_id: "chat1" }, + action: { value: { command: "/new" } }, + }), + ).toBe("/new"); + }); + + it("rejects malformed structured payloads", () => { + const result = decodeFeishuCardAction({ + event: { + operator: { open_id: "u123" }, + context: { chat_id: "chat1" }, + action: { + value: { + oc: "ocf1", + k: "quick", + a: "broken", + m: { bad: { nested: true } }, + }, + }, + }, + }); + + expect(result).toEqual({ kind: "invalid", reason: "malformed" }); + }); + + it("rejects stale payloads", () => { + const result = decodeFeishuCardAction({ + now: 100, + event: { + operator: { open_id: "u123" }, + context: { chat_id: "chat1" }, + action: { + value: createFeishuCardInteractionEnvelope({ + k: "button", + a: "stale", + c: { e: 99, t: "group" }, + }), + }, + }, + }); + + expect(result).toEqual({ kind: "invalid", reason: "stale" }); + }); + + it("rejects wrong-conversation payloads when chat context is enforced", () => { + const result = decodeFeishuCardAction({ + event: { + operator: { open_id: "u123" }, + context: { chat_id: "chat2" }, + action: { + value: createFeishuCardInteractionEnvelope({ + k: "button", + a: "scoped", + c: { u: "u123", h: "chat1", t: "group", e: Date.now() + 60_000 }, + }), + }, + }, + }); + + expect(result).toEqual({ kind: "invalid", reason: "wrong_conversation" }); + }); + + it("rejects malformed chat-type context", () => { + const result = decodeFeishuCardAction({ + event: { + operator: { open_id: "u123" }, + context: { chat_id: "chat1" }, + action: { + value: { + oc: "ocf1", + k: "button", + a: "bad", + c: { t: "private" }, + }, + }, + }, + }); + + expect(result).toEqual({ kind: "invalid", reason: "malformed" }); + }); +}); diff --git a/extensions/feishu/src/card-interaction.ts b/extensions/feishu/src/card-interaction.ts new file mode 100644 index 00000000000..1da2df05baf --- /dev/null +++ b/extensions/feishu/src/card-interaction.ts @@ -0,0 +1,168 @@ +export const FEISHU_CARD_INTERACTION_VERSION = "ocf1"; + +export type FeishuCardInteractionKind = "button" | "quick" | "meta"; +export type FeishuCardInteractionReason = + | "malformed" + | "stale" + | "wrong_user" + | "wrong_conversation"; + +export type FeishuCardInteractionMetadata = Record< + string, + string | number | boolean | null | undefined +>; + +export type FeishuCardInteractionEnvelope = { + oc: typeof FEISHU_CARD_INTERACTION_VERSION; + k: FeishuCardInteractionKind; + a: string; + q?: string; + m?: FeishuCardInteractionMetadata; + c?: { + u?: string; + h?: string; + s?: string; + e?: number; + t?: "p2p" | "group"; + }; +}; + +export type FeishuCardActionEventLike = { + operator: { + open_id?: string; + }; + action: { + value: unknown; + }; + context: { + chat_id?: string; + }; +}; + +export type DecodedFeishuCardAction = + | { + kind: "structured"; + envelope: FeishuCardInteractionEnvelope; + } + | { + kind: "legacy"; + text: string; + } + | { + kind: "invalid"; + reason: FeishuCardInteractionReason; + }; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function isInteractionKind(value: unknown): value is FeishuCardInteractionKind { + return value === "button" || value === "quick" || value === "meta"; +} + +function isMetadataValue(value: unknown): value is string | number | boolean | null | undefined { + return ( + value === null || + value === undefined || + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" + ); +} + +export function createFeishuCardInteractionEnvelope( + envelope: Omit, +): FeishuCardInteractionEnvelope { + return { + oc: FEISHU_CARD_INTERACTION_VERSION, + ...envelope, + }; +} + +export function buildFeishuCardActionTextFallback(event: FeishuCardActionEventLike): string { + const actionValue = event.action.value; + if (isRecord(actionValue)) { + if (typeof actionValue.text === "string") { + return actionValue.text; + } + if (typeof actionValue.command === "string") { + return actionValue.command; + } + return JSON.stringify(actionValue); + } + return String(actionValue); +} + +export function decodeFeishuCardAction(params: { + event: FeishuCardActionEventLike; + now?: number; +}): DecodedFeishuCardAction { + const { event, now = Date.now() } = params; + const actionValue = event.action.value; + if (!isRecord(actionValue) || actionValue.oc !== FEISHU_CARD_INTERACTION_VERSION) { + return { + kind: "legacy", + text: buildFeishuCardActionTextFallback(event), + }; + } + + if (!isInteractionKind(actionValue.k) || typeof actionValue.a !== "string" || !actionValue.a) { + return { kind: "invalid", reason: "malformed" }; + } + + if (actionValue.q !== undefined && typeof actionValue.q !== "string") { + return { kind: "invalid", reason: "malformed" }; + } + + if (actionValue.m !== undefined) { + if (!isRecord(actionValue.m)) { + return { kind: "invalid", reason: "malformed" }; + } + for (const value of Object.values(actionValue.m)) { + if (!isMetadataValue(value)) { + return { kind: "invalid", reason: "malformed" }; + } + } + } + + if (actionValue.c !== undefined) { + if (!isRecord(actionValue.c)) { + return { kind: "invalid", reason: "malformed" }; + } + if (actionValue.c.u !== undefined && typeof actionValue.c.u !== "string") { + return { kind: "invalid", reason: "malformed" }; + } + if (actionValue.c.h !== undefined && typeof actionValue.c.h !== "string") { + return { kind: "invalid", reason: "malformed" }; + } + if (actionValue.c.s !== undefined && typeof actionValue.c.s !== "string") { + return { kind: "invalid", reason: "malformed" }; + } + if (actionValue.c.e !== undefined && !Number.isFinite(actionValue.c.e)) { + return { kind: "invalid", reason: "malformed" }; + } + if (actionValue.c.t !== undefined && actionValue.c.t !== "p2p" && actionValue.c.t !== "group") { + return { kind: "invalid", reason: "malformed" }; + } + + if (typeof actionValue.c.e === "number" && actionValue.c.e < now) { + return { kind: "invalid", reason: "stale" }; + } + + const expectedUser = actionValue.c.u?.trim(); + if (expectedUser && expectedUser !== (event.operator.open_id ?? "").trim()) { + return { kind: "invalid", reason: "wrong_user" }; + } + + const expectedChat = actionValue.c.h?.trim(); + if (expectedChat && expectedChat !== (event.context.chat_id ?? "").trim()) { + return { kind: "invalid", reason: "wrong_conversation" }; + } + } + + return { + kind: "structured", + envelope: actionValue as FeishuCardInteractionEnvelope, + }; +} diff --git a/extensions/feishu/src/card-ux-approval.ts b/extensions/feishu/src/card-ux-approval.ts new file mode 100644 index 00000000000..944ace931ea --- /dev/null +++ b/extensions/feishu/src/card-ux-approval.ts @@ -0,0 +1,65 @@ +import { createFeishuCardInteractionEnvelope } from "./card-interaction.js"; +import { buildFeishuCardButton, buildFeishuCardInteractionContext } from "./card-ux-shared.js"; + +export const FEISHU_APPROVAL_REQUEST_ACTION = "feishu.quick_actions.request_approval"; +export const FEISHU_APPROVAL_CONFIRM_ACTION = "feishu.approval.confirm"; +export const FEISHU_APPROVAL_CANCEL_ACTION = "feishu.approval.cancel"; + +export function createApprovalCard(params: { + operatorOpenId: string; + chatId?: string; + command: string; + prompt: string; + expiresAt: number; + chatType?: "p2p" | "group"; + sessionKey?: string; + confirmLabel?: string; + cancelLabel?: string; +}): Record { + const context = buildFeishuCardInteractionContext(params); + + return { + schema: "2.0", + config: { + wide_screen_mode: true, + }, + header: { + title: { + tag: "plain_text", + content: "Confirm action", + }, + template: "orange", + }, + body: { + elements: [ + { + tag: "markdown", + content: params.prompt, + }, + { + tag: "action", + actions: [ + buildFeishuCardButton({ + label: params.confirmLabel ?? "Confirm", + type: "primary", + value: createFeishuCardInteractionEnvelope({ + k: "quick", + a: FEISHU_APPROVAL_CONFIRM_ACTION, + q: params.command, + c: context, + }), + }), + buildFeishuCardButton({ + label: params.cancelLabel ?? "Cancel", + value: createFeishuCardInteractionEnvelope({ + k: "button", + a: FEISHU_APPROVAL_CANCEL_ACTION, + c: context, + }), + }), + ], + }, + ], + }, + }; +} diff --git a/extensions/feishu/src/card-ux-launcher.test.ts b/extensions/feishu/src/card-ux-launcher.test.ts new file mode 100644 index 00000000000..6f9f7917daf --- /dev/null +++ b/extensions/feishu/src/card-ux-launcher.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { + createQuickActionLauncherCard, + isFeishuQuickActionMenuEventKey, + maybeHandleFeishuQuickActionMenu, +} from "./card-ux-launcher.js"; + +const sendCardFeishuMock = vi.hoisted(() => vi.fn()); + +vi.mock("./send.js", () => ({ + sendCardFeishu: sendCardFeishuMock, +})); + +describe("feishu quick-action launcher", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("recognizes the quick-actions bot menu key", () => { + expect(isFeishuQuickActionMenuEventKey("quick-actions")).toBe(true); + expect(isFeishuQuickActionMenuEventKey("other")).toBe(false); + }); + + it("builds a launcher card with interactive actions", () => { + const card = createQuickActionLauncherCard({ + operatorOpenId: "u123", + chatId: "chat1", + expiresAt: 123, + sessionKey: "agent:codex:feishu:chat:chat1", + }) as { + body: { + elements: Array<{ + tag: string; + actions?: Array<{ value?: { oc?: string; c?: { s?: string; t?: string } } }>; + }>; + }; + }; + + const actionBlock = card.body.elements.find((entry) => entry.tag === "action"); + expect(actionBlock?.actions).toHaveLength(3); + expect(actionBlock?.actions?.[0]?.value?.oc).toBe("ocf1"); + expect(actionBlock?.actions?.[0]?.value?.c?.s).toBe("agent:codex:feishu:chat:chat1"); + expect(actionBlock?.actions?.[0]?.value?.c?.t).toBeUndefined(); + }); + + it("opens the launcher from a supported bot menu event", async () => { + sendCardFeishuMock.mockResolvedValue({ messageId: "m1", chatId: "c1" }); + + const handled = await maybeHandleFeishuQuickActionMenu({ + cfg: {} as any, + eventKey: "quick-actions", + operatorOpenId: "u123", + accountId: "main", + now: 100, + }); + + expect(handled).toBe(true); + expect(sendCardFeishuMock).toHaveBeenCalledWith( + expect.objectContaining({ + to: "user:u123", + accountId: "main", + card: expect.objectContaining({ + body: expect.objectContaining({ + elements: expect.arrayContaining([ + expect.objectContaining({ + tag: "action", + actions: expect.arrayContaining([ + expect.objectContaining({ + value: expect.objectContaining({ + c: expect.objectContaining({ + t: "p2p", + }), + }), + }), + ]), + }), + ]), + }), + }), + }), + ); + }); + + it("falls back to legacy menu handling when launcher send fails", async () => { + sendCardFeishuMock.mockRejectedValueOnce(new Error("network")); + + const handled = await maybeHandleFeishuQuickActionMenu({ + cfg: {} as any, + eventKey: "quick-actions", + operatorOpenId: "u123", + accountId: "main", + runtime: { log: vi.fn() } as any, + now: 100, + }); + + expect(handled).toBe(false); + }); +}); diff --git a/extensions/feishu/src/card-ux-launcher.ts b/extensions/feishu/src/card-ux-launcher.ts new file mode 100644 index 00000000000..3303bc2ed77 --- /dev/null +++ b/extensions/feishu/src/card-ux-launcher.ts @@ -0,0 +1,120 @@ +import type { ClawdbotConfig, RuntimeEnv } from "openclaw/plugin-sdk/feishu"; +import { createFeishuCardInteractionEnvelope } from "./card-interaction.js"; +import { FEISHU_APPROVAL_REQUEST_ACTION } from "./card-ux-approval.js"; +import { buildFeishuCardButton, buildFeishuCardInteractionContext } from "./card-ux-shared.js"; +import { sendCardFeishu } from "./send.js"; + +export const FEISHU_QUICK_ACTION_CARD_TTL_MS = 10 * 60_000; + +const QUICK_ACTION_MENU_KEYS = new Set(["quick-actions", "quick_actions", "launcher"]); + +export function isFeishuQuickActionMenuEventKey(eventKey: string): boolean { + return QUICK_ACTION_MENU_KEYS.has(eventKey.trim().toLowerCase()); +} + +export function createQuickActionLauncherCard(params: { + operatorOpenId: string; + chatId?: string; + expiresAt: number; + chatType?: "p2p" | "group"; + sessionKey?: string; +}): Record { + const context = buildFeishuCardInteractionContext(params); + return { + schema: "2.0", + config: { + wide_screen_mode: true, + }, + header: { + title: { + tag: "plain_text", + content: "Quick actions", + }, + template: "indigo", + }, + body: { + elements: [ + { + tag: "markdown", + content: "Run common actions without typing raw commands.", + }, + { + tag: "action", + actions: [ + buildFeishuCardButton({ + label: "Help", + value: createFeishuCardInteractionEnvelope({ + k: "quick", + a: "feishu.quick_actions.help", + q: "/help", + c: context, + }), + }), + buildFeishuCardButton({ + label: "New session", + type: "primary", + value: createFeishuCardInteractionEnvelope({ + k: "meta", + a: FEISHU_APPROVAL_REQUEST_ACTION, + m: { + command: "/new", + prompt: "Start a fresh session? This will reset the current chat context.", + }, + c: context, + }), + }), + buildFeishuCardButton({ + label: "Reset", + type: "danger", + value: createFeishuCardInteractionEnvelope({ + k: "meta", + a: FEISHU_APPROVAL_REQUEST_ACTION, + m: { + command: "/reset", + prompt: "Reset this session now? Any active conversation state will be cleared.", + }, + c: context, + }), + }), + ], + }, + ], + }, + }; +} + +export async function maybeHandleFeishuQuickActionMenu(params: { + cfg: ClawdbotConfig; + eventKey: string; + operatorOpenId: string; + runtime?: RuntimeEnv; + accountId?: string; + now?: number; +}): Promise { + if (!isFeishuQuickActionMenuEventKey(params.eventKey)) { + return false; + } + + const expiresAt = (params.now ?? Date.now()) + FEISHU_QUICK_ACTION_CARD_TTL_MS; + try { + await sendCardFeishu({ + cfg: params.cfg, + to: `user:${params.operatorOpenId}`, + card: createQuickActionLauncherCard({ + operatorOpenId: params.operatorOpenId, + expiresAt, + chatType: "p2p", + }), + accountId: params.accountId, + }); + } catch (err) { + params.runtime?.log?.( + `feishu[${params.accountId ?? "default"}]: failed to open quick-action launcher for ${params.operatorOpenId}: ${String(err)}`, + ); + return false; + } + params.runtime?.log?.( + `feishu[${params.accountId ?? "default"}]: opened quick-action launcher for ${params.operatorOpenId}`, + ); + return true; +} diff --git a/extensions/feishu/src/card-ux-shared.ts b/extensions/feishu/src/card-ux-shared.ts new file mode 100644 index 00000000000..02133c39a5c --- /dev/null +++ b/extensions/feishu/src/card-ux-shared.ts @@ -0,0 +1,33 @@ +import type { FeishuCardInteractionEnvelope } from "./card-interaction.js"; + +export function buildFeishuCardButton(params: { + label: string; + value: FeishuCardInteractionEnvelope; + type?: "default" | "primary" | "danger"; +}) { + return { + tag: "button", + text: { + tag: "plain_text", + content: params.label, + }, + type: params.type ?? "default", + value: params.value, + }; +} + +export function buildFeishuCardInteractionContext(params: { + operatorOpenId: string; + chatId?: string; + expiresAt: number; + chatType?: "p2p" | "group"; + sessionKey?: string; +}) { + return { + u: params.operatorOpenId, + ...(params.chatId ? { h: params.chatId } : {}), + ...(params.sessionKey ? { s: params.sessionKey } : {}), + e: params.expiresAt, + ...(params.chatType ? { t: params.chatType } : {}), + }; +} diff --git a/extensions/feishu/src/monitor.account.ts b/extensions/feishu/src/monitor.account.ts index 3d761631399..241376ac0ba 100644 --- a/extensions/feishu/src/monitor.account.ts +++ b/extensions/feishu/src/monitor.account.ts @@ -10,6 +10,7 @@ import { type FeishuBotAddedEvent, } from "./bot.js"; import { handleFeishuCardAction, type FeishuCardActionEvent } from "./card-action.js"; +import { maybeHandleFeishuQuickActionMenu } from "./card-ux-launcher.js"; import { createEventDispatcher } from "./client.js"; import { hasProcessedFeishuMessage, @@ -513,7 +514,7 @@ function registerEventHandlers( try { const event = data as { event_key?: string; - timestamp?: number; + timestamp?: string | number; operator?: { operator_name?: string; operator_id?: { open_id?: string; user_id?: string; union_id?: string }; @@ -543,14 +544,28 @@ function registerEventHandlers( }), }, }; - const promise = handleFeishuMessage({ + const handleLegacyMenu = () => + handleFeishuMessage({ + cfg, + event: syntheticEvent, + botOpenId: botOpenIds.get(accountId), + botName: botNames.get(accountId), + runtime, + chatHistories, + accountId, + }); + + const promise = maybeHandleFeishuQuickActionMenu({ cfg, - event: syntheticEvent, - botOpenId: botOpenIds.get(accountId), - botName: botNames.get(accountId), + eventKey, + operatorOpenId, runtime, - chatHistories, accountId, + }).then((handledMenu) => { + if (handledMenu) { + return; + } + return handleLegacyMenu(); }); if (fireAndForget) { promise.catch((err) => { diff --git a/extensions/feishu/src/monitor.bot-menu.test.ts b/extensions/feishu/src/monitor.bot-menu.test.ts new file mode 100644 index 00000000000..cecb0b0512c --- /dev/null +++ b/extensions/feishu/src/monitor.bot-menu.test.ts @@ -0,0 +1,229 @@ +import type { ClawdbotConfig, RuntimeEnv } from "openclaw/plugin-sdk/feishu"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { hasControlCommand } from "../../../src/auto-reply/command-detection.js"; +import { + createInboundDebouncer, + resolveInboundDebounceMs, +} from "../../../src/auto-reply/inbound-debounce.js"; +import { createPluginRuntimeMock } from "../../test-utils/plugin-runtime-mock.js"; +import { monitorSingleAccount } from "./monitor.account.js"; +import { setFeishuRuntime } from "./runtime.js"; +import type { ResolvedFeishuAccount } from "./types.js"; + +const createEventDispatcherMock = vi.hoisted(() => vi.fn()); +const monitorWebSocketMock = vi.hoisted(() => vi.fn(async () => {})); +const monitorWebhookMock = vi.hoisted(() => vi.fn(async () => {})); +const handleFeishuMessageMock = vi.hoisted(() => vi.fn(async () => {})); +const sendCardFeishuMock = vi.hoisted(() => vi.fn(async () => ({ messageId: "m1", chatId: "c1" }))); +const createFeishuThreadBindingManagerMock = vi.hoisted(() => vi.fn(() => ({ stop: vi.fn() }))); + +let handlers: Record Promise> = {}; + +vi.mock("./client.js", () => ({ + createEventDispatcher: createEventDispatcherMock, +})); + +vi.mock("./monitor.transport.js", () => ({ + monitorWebSocket: monitorWebSocketMock, + monitorWebhook: monitorWebhookMock, +})); + +vi.mock("./bot.js", async () => { + const actual = await vi.importActual("./bot.js"); + return { + ...actual, + handleFeishuMessage: handleFeishuMessageMock, + }; +}); + +vi.mock("./send.js", async () => { + const actual = await vi.importActual("./send.js"); + return { + ...actual, + sendCardFeishu: sendCardFeishuMock, + }; +}); + +vi.mock("./thread-bindings.js", () => ({ + createFeishuThreadBindingManager: createFeishuThreadBindingManagerMock, +})); + +function buildAccount(): ResolvedFeishuAccount { + return { + accountId: "default", + enabled: true, + configured: true, + appId: "cli_test", + appSecret: "secret_test", // pragma: allowlist secret + domain: "feishu", + config: { + enabled: true, + connectionMode: "websocket", + }, + } as ResolvedFeishuAccount; +} + +async function registerHandlers() { + setFeishuRuntime( + createPluginRuntimeMock({ + channel: { + debounce: { + createInboundDebouncer, + resolveInboundDebounceMs, + }, + text: { + hasControlCommand, + }, + }, + }), + ); + const register = vi.fn((registered: Record Promise>) => { + handlers = registered; + }); + createEventDispatcherMock.mockReturnValue({ register }); + + await monitorSingleAccount({ + cfg: {} as ClawdbotConfig, + account: buildAccount(), + runtime: { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + } as RuntimeEnv, + botOpenIdSource: { + kind: "prefetched", + botOpenId: "ou_bot", + botName: "Bot", + }, + }); + + const onBotMenu = handlers["application.bot.menu_v6"]; + if (!onBotMenu) { + throw new Error("missing application.bot.menu_v6 handler"); + } + return onBotMenu; +} + +describe("Feishu bot menu handler", () => { + beforeEach(() => { + handlers = {}; + vi.clearAllMocks(); + }); + + it("opens the quick-action launcher card at the webhook/event layer", async () => { + const onBotMenu = await registerHandlers(); + + await onBotMenu({ + event_key: "quick-actions", + timestamp: "1700000000000", + operator: { + operator_id: { + open_id: "ou_user1", + user_id: "user_1", + union_id: "union_1", + }, + }, + }); + + expect(sendCardFeishuMock).toHaveBeenCalledWith( + expect.objectContaining({ + to: "user:ou_user1", + card: expect.objectContaining({ + header: expect.objectContaining({ + title: expect.objectContaining({ content: "Quick actions" }), + }), + }), + }), + ); + expect(handleFeishuMessageMock).not.toHaveBeenCalled(); + }); + + it("does not block bot-menu handling on quick-action launcher send", async () => { + const onBotMenu = await registerHandlers(); + let resolveSend: (() => void) | undefined; + sendCardFeishuMock.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveSend = () => resolve({ messageId: "m1", chatId: "c1" }); + }), + ); + + const pending = onBotMenu({ + event_key: "quick-actions", + timestamp: "1700000000000", + operator: { + operator_id: { + open_id: "ou_user1", + user_id: "user_1", + union_id: "union_1", + }, + }, + }); + let settled = false; + pending.finally(() => { + settled = true; + }); + + await Promise.resolve(); + expect(settled).toBe(true); + + resolveSend?.(); + await pending; + }); + + it("falls back to the legacy /menu synthetic message path for unrelated bot menu keys", async () => { + const onBotMenu = await registerHandlers(); + + await onBotMenu({ + event_key: "custom-key", + timestamp: "1700000000000", + operator: { + operator_id: { + open_id: "ou_user1", + user_id: "user_1", + union_id: "union_1", + }, + }, + }); + + expect(handleFeishuMessageMock).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + message: expect.objectContaining({ + content: '{"text":"/menu custom-key"}', + }), + }), + }), + ); + expect(sendCardFeishuMock).not.toHaveBeenCalled(); + }); + + it("falls back to the legacy /menu path when launcher rendering fails", async () => { + const onBotMenu = await registerHandlers(); + sendCardFeishuMock.mockRejectedValueOnce(new Error("boom")); + + await onBotMenu({ + event_key: "quick-actions", + timestamp: "1700000000000", + operator: { + operator_id: { + open_id: "ou_user1", + user_id: "user_1", + union_id: "union_1", + }, + }, + }); + + await vi.waitFor(() => { + expect(handleFeishuMessageMock).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + message: expect.objectContaining({ + content: '{"text":"/menu quick-actions"}', + }), + }), + }), + ); + }); + }); +}); diff --git a/src/security/audit.ts b/src/security/audit.ts index b304f658d68..0b13ecc5531 100644 --- a/src/security/audit.ts +++ b/src/security/audit.ts @@ -1250,13 +1250,16 @@ export async function runSecurityAudit(opts: SecurityAuditOptions): Promise