openclaw/extensions/feishu/src/card-action.ts

311 lines
9.1 KiB
TypeScript

import type { ClawdbotConfig, RuntimeEnv } from "../runtime-api.js";
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: {
open_id: string;
user_id: string;
union_id: string;
};
token: string;
action: {
value: Record<string, unknown>;
tag: string;
};
context: {
open_id: string;
user_id: string;
chat_id: string;
};
};
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);
}
}
}
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: {
cfg: ClawdbotConfig;
event: FeishuCardActionEvent;
botOpenId?: string;
runtime?: RuntimeEnv;
accountId?: string;
}): Promise<void> {
const { cfg, event, runtime, accountId } = params;
const account = resolveFeishuAccount({ cfg, accountId });
const log = runtime?.log ?? console.log;
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;
}
}