mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-27 17:11:46 +00:00
feishu: add structured card actions and interactive approval flows (#47873)
* feishu: add structured card actions and interactive approval flows * feishu: address review fixes and test-gate regressions * feishu: hold inflight card dedup until completion * feishu: restore fire-and-forget bot menu handling * feishu: format card interaction helpers * Feishu: add changelog entry for card interactions * Feishu: add changelog entry for ACP session binding
This commit is contained in:
@@ -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<void>((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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
129
extensions/feishu/src/card-interaction.test.ts
Normal file
129
extensions/feishu/src/card-interaction.test.ts
Normal file
@@ -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" });
|
||||
});
|
||||
});
|
||||
168
extensions/feishu/src/card-interaction.ts
Normal file
168
extensions/feishu/src/card-interaction.ts
Normal file
@@ -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<string, unknown> {
|
||||
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, "oc">,
|
||||
): 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,
|
||||
};
|
||||
}
|
||||
65
extensions/feishu/src/card-ux-approval.ts
Normal file
65
extensions/feishu/src/card-ux-approval.ts
Normal file
@@ -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<string, unknown> {
|
||||
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,
|
||||
}),
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
98
extensions/feishu/src/card-ux-launcher.test.ts
Normal file
98
extensions/feishu/src/card-ux-launcher.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
120
extensions/feishu/src/card-ux-launcher.ts
Normal file
120
extensions/feishu/src/card-ux-launcher.ts
Normal file
@@ -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<string, unknown> {
|
||||
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<boolean> {
|
||||
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;
|
||||
}
|
||||
33
extensions/feishu/src/card-ux-shared.ts
Normal file
33
extensions/feishu/src/card-ux-shared.ts
Normal file
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
229
extensions/feishu/src/monitor.bot-menu.test.ts
Normal file
229
extensions/feishu/src/monitor.bot-menu.test.ts
Normal file
@@ -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<string, (data: unknown) => Promise<void>> = {};
|
||||
|
||||
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<typeof import("./bot.js")>("./bot.js");
|
||||
return {
|
||||
...actual,
|
||||
handleFeishuMessage: handleFeishuMessageMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./send.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./send.js")>("./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<string, (data: unknown) => Promise<void>>) => {
|
||||
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"}',
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user