From 519ea8144fd6480a8a62c42151633a63f08182da Mon Sep 17 00:00:00 2001 From: Hong Jiarong Date: Fri, 10 Jul 2026 00:32:09 +0800 Subject: [PATCH] feat: add Feishu project onboarding card --- hub/.env.example | 3 + hub/src/feishu/projectOnboardingCard.ts | 134 ++++++++++++++ hub/src/feishu/trigger.ts | 234 ++++++++++++++++++++++++ hub/src/server.ts | 3 +- hub/test/integration/trigger.test.ts | 190 ++++++++++++++++++- 5 files changed, 558 insertions(+), 6 deletions(-) create mode 100644 hub/src/feishu/projectOnboardingCard.ts diff --git a/hub/.env.example b/hub/.env.example index 1675953..5d4517c 100644 --- a/hub/.env.example +++ b/hub/.env.example @@ -21,6 +21,9 @@ ANTHROPIC_API_KEY="" # Agent run policy (optional). Defaults to 25. # HUB_AGENT_MAX_TURNS=25 +# System-managed root for project workspaces created from org admin / Feishu onboarding. +HUB_PROJECT_WORKSPACE_ROOT="./data/project-workspaces" + # Feishu (lark) bot credentials. FEISHU_APP_ID="" FEISHU_APP_SECRET="" diff --git a/hub/src/feishu/projectOnboardingCard.ts b/hub/src/feishu/projectOnboardingCard.ts new file mode 100644 index 0000000..33ab1e3 --- /dev/null +++ b/hub/src/feishu/projectOnboardingCard.ts @@ -0,0 +1,134 @@ +export interface OnboardingProjectOption { + readonly projectId: string; + readonly name: string; + readonly folderName?: string | undefined; +} + +export interface ProjectOnboardingActionValue { + readonly action: "create_project_from_chat" | "bind_project"; + readonly organization_id: string; + readonly project_id?: string | undefined; +} + +export function buildUnboundChatOnboardingCard(params: { + readonly organizationId: string; + readonly organizationName: string; + readonly projects: readonly OnboardingProjectOption[]; + readonly canCreateProject: boolean; +}): Record { + const actions: unknown[] = []; + if (params.canCreateProject) { + actions.push({ + tag: "button", + text: { tag: "plain_text", content: "新建并绑定项目" }, + type: "primary", + value: { + project_onboarding: { + action: "create_project_from_chat", + organization_id: params.organizationId, + }, + }, + }); + } + + for (const project of params.projects.slice(0, 5)) { + actions.push({ + tag: "button", + text: { tag: "plain_text", content: buttonProjectLabel(project) }, + type: "default", + value: { + project_onboarding: { + action: "bind_project", + organization_id: params.organizationId, + project_id: project.projectId, + }, + }, + }); + } + + const elements: unknown[] = [ + { + tag: "markdown", + content: [ + `这个飞书群还没有绑定项目。`, + ``, + `组织: **${escapeMarkdown(params.organizationName)}**`, + `可以新建一个项目并绑定到本群,或绑定你已经有管理权限的未绑定项目。`, + ].join("\n"), + }, + ]; + + if (actions.length > 0) { + elements.push({ tag: "action", actions }); + } else { + elements.push({ + tag: "markdown", + content: "你当前没有可绑定项目,也没有新建项目权限。请联系组织管理员。", + }); + } + + return { + config: { wide_screen_mode: true }, + header: { + title: { tag: "plain_text", content: "绑定项目" }, + template: "blue", + }, + elements, + }; +} + +export function buildProjectOnboardingResolvedCard(params: { + readonly title: string; + readonly body: string; + readonly template: "green" | "red"; +}): Record { + return { + config: { wide_screen_mode: true }, + header: { + title: { tag: "plain_text", content: params.title }, + template: params.template, + }, + elements: [{ tag: "markdown", content: params.body }], + }; +} + +export function projectOnboardingActionFromValue(value: unknown): ProjectOnboardingActionValue | null { + const raw = unwrapValue(value); + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null; + if (!("project_onboarding" in raw)) return null; + const action = raw.project_onboarding; + if (typeof action !== "object" || action === null || Array.isArray(action)) return null; + const rawAction = (action as { action?: unknown }).action; + const organizationId = (action as { organization_id?: unknown }).organization_id; + const projectId = (action as { project_id?: unknown }).project_id; + if ((rawAction !== "create_project_from_chat" && rawAction !== "bind_project") || typeof organizationId !== "string" || organizationId === "") { + return null; + } + if (rawAction === "bind_project" && (typeof projectId !== "string" || projectId === "")) { + return null; + } + return { + action: rawAction, + organization_id: organizationId, + ...(typeof projectId === "string" && projectId !== "" ? { project_id: projectId } : {}), + }; +} + +function unwrapValue(value: unknown): unknown { + if (typeof value !== "string") return value; + try { + return JSON.parse(value); + } catch { + return value; + } +} + +function buttonProjectLabel(project: OnboardingProjectOption): string { + const folderPrefix = project.folderName === undefined ? "" : `${project.folderName} / `; + const label = `${folderPrefix}${project.name}`; + return label.length <= 24 ? label : `${label.slice(0, 21)}...`; +} + +function escapeMarkdown(value: string): string { + return value.replace(/([`*_{}[\]<>])/g, "\\$1"); +} diff --git a/hub/src/feishu/trigger.ts b/hub/src/feishu/trigger.ts index 0059e9f..faa7b60 100644 --- a/hub/src/feishu/trigger.ts +++ b/hub/src/feishu/trigger.ts @@ -15,6 +15,8 @@ import type { FastifyBaseLogger } from "fastify"; import { sendText, sendTextMessage, + sendCard, + patchCard, reactToMessage, addReaction, removeReaction, @@ -42,6 +44,18 @@ import { SenderNameCache } from "./senderCache.js"; import { TriggerQueue, triggerQueue as defaultTriggerQueue, type QueuedTrigger } from "./triggerQueue.js"; import { createSlashCommandRegistry, formatSlashHelpTarget, parseSlashHelpSubcommand, parseSlashInvocation } from "./slashCommands.js"; import { cphHubMcpToolsForRole, roleToolsAllow } from "../agent/roleTools.js"; +import { + bindFeishuChatToProject, + createProjectFromFeishuChat, + ensureOrganizationProjectSettings, +} from "../projectOnboarding.js"; +import { + buildProjectOnboardingResolvedCard, + buildUnboundChatOnboardingCard, + projectOnboardingActionFromValue, + type OnboardingProjectOption, + type ProjectOnboardingActionValue, +} from "./projectOnboardingCard.js"; export { ApprovalManager } from "./approval.js"; export type { ApprovalResult, PendingApproval } from "./approval.js"; @@ -57,6 +71,7 @@ interface TriggerDeps { readonly authorizer?: PermissionAuthorizer | undefined; readonly messageBatcherOptions?: MessageBatcherOptions | undefined; readonly triggerQueue?: TriggerQueue | undefined; + readonly projectWorkspaceRoot?: string | undefined; } interface TriggerActor { @@ -521,6 +536,13 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler { return; } + // Project onboarding buttons for unbound Feishu chats. + const onboardingAction = projectOnboardingActionFromValue(event.action.value); + if (onboardingAction !== null) { + await handleProjectOnboardingAction(event, rt, onboardingAction); + return; + } + // Interrupt button on a live run card. const runId = interruptRunFromValue(event.action.value); if (runId !== null) { @@ -528,6 +550,77 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler { } }; + async function handleProjectOnboardingAction( + event: CardActionEvent, + rt: FeishuRuntime, + action: ProjectOnboardingActionValue, + ): Promise { + const chatId = nonEmpty(event.context?.open_chat_id); + const messageId = nonEmpty(event.context?.open_message_id); + const operatorOpenId = nonEmpty(event.operator.open_id); + if (chatId === undefined || operatorOpenId === undefined) { + deps.logger.warn({ action }, "project onboarding: card action missing chat id or operator open_id"); + return; + } + + try { + if (action.action === "create_project_from_chat") { + if (deps.projectWorkspaceRoot === undefined || deps.projectWorkspaceRoot.trim() === "") { + throw new Error("HUB_PROJECT_WORKSPACE_ROOT is required for Feishu project creation"); + } + const name = defaultProjectNameForChat(chatId); + const project = await createProjectFromFeishuChat(deps.prisma, { + organizationId: action.organization_id, + actorFeishuOpenId: operatorOpenId, + chatId, + name, + workspaceRoot: deps.projectWorkspaceRoot, + }); + await resolveProjectOnboardingCard(rt, messageId, { + title: "已创建并绑定项目", + body: `项目 **${escapeCardMarkdown(name)}** 已绑定到本群。后续 @bot 会进入这个项目的 session。`, + template: "green", + }); + deps.logger.info({ chatId, projectId: project.projectId, operatorOpenId }, "project onboarding: created project from chat"); + return; + } + + const projectId = action.project_id; + if (projectId === undefined) { + throw new Error("bind_project action requires project_id"); + } + const project = await bindFeishuChatToProject(deps.prisma, { + projectId, + actorFeishuOpenId: operatorOpenId, + chatId, + }); + await resolveProjectOnboardingCard(rt, messageId, { + title: "已绑定项目", + body: `本群已绑定到项目 \`${project.projectId}\`。后续 @bot 会进入这个项目的 session。`, + template: "green", + }); + deps.logger.info({ chatId, projectId: project.projectId, operatorOpenId }, "project onboarding: bound existing project"); + } catch (e) { + const reason = e instanceof Error ? e.message : String(e); + deps.logger.warn({ chatId, operatorOpenId, action, err: reason }, "project onboarding: action failed"); + await resolveProjectOnboardingCard(rt, messageId, { + title: "绑定失败", + body: reason, + template: "red", + }); + await sendText(rt, chatId, `项目绑定失败: ${reason}`); + } + } + + async function resolveProjectOnboardingCard( + rt: FeishuRuntime, + messageId: string | undefined, + params: { readonly title: string; readonly body: string; readonly template: "green" | "red" }, + ): Promise { + if (messageId === undefined) return; + await patchCard(rt, messageId, buildProjectOnboardingResolvedCard(params)); + } + /** Abort a live run after authorizing agent.cancel on its project. */ async function handleInterruptAction(event: CardActionEvent, rt: FeishuRuntime, runId: string): Promise { const chatId = nonEmpty(event.context?.open_chat_id); @@ -624,6 +717,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler { }); if (binding === null) { deps.logger.debug({ chatId }, "feishu trigger: chat not bound to any project"); + await sendUnboundChatOnboardingCard(event, rt); return; } const projectId = binding.projectId; @@ -743,6 +837,125 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler { await startAgentRun(runContext, cleanPrompt); }; + async function sendUnboundChatOnboardingCard(event: MessageReceiveEvent, rt: FeishuRuntime): Promise { + const msg = event.message; + if (!messageMentionsBot(msg)) return; + const chatId = msg.chat_id; + const senderOpenId = event.sender.sender_id.open_id ?? ""; + if (senderOpenId === "") { + await sendText(rt, chatId, "无法识别发送者,请先用可识别的飞书用户身份操作。", sendOptionsForTriggerMessage(msg)); + return; + } + + const organization = await resolveSingleActiveOrganizationForFeishuUser(senderOpenId); + if (organization.status !== "ok") { + await sendText(rt, chatId, organization.message, sendOptionsForTriggerMessage(msg)); + return; + } + + const settings = await ensureOrganizationProjectSettings(deps.prisma, organization.organizationId); + const canCreateProject = settings.membersCanCreateProjects || isOrgAdminRole(organization.role); + const projects = await listBindableProjectsForActor({ + organizationId: organization.organizationId, + actorFeishuOpenId: senderOpenId, + isOrgAdmin: isOrgAdminRole(organization.role), + }); + + await sendCard( + rt, + chatId, + buildUnboundChatOnboardingCard({ + organizationId: organization.organizationId, + organizationName: organization.organizationName, + projects, + canCreateProject, + }), + sendOptionsForTriggerMessage(msg), + ); + } + + async function resolveSingleActiveOrganizationForFeishuUser(feishuOpenId: string): Promise< + | { + readonly status: "ok"; + readonly organizationId: string; + readonly organizationName: string; + readonly role: "OWNER" | "ADMIN" | "MEMBER"; + } + | { readonly status: "error"; readonly message: string } + > { + const user = await deps.prisma.user.findUnique({ + where: { feishuOpenId }, + select: { + organizationMemberships: { + where: { + revokedAt: null, + organization: { status: "ACTIVE" }, + }, + select: { + role: true, + organization: { select: { id: true, name: true } }, + }, + orderBy: { createdAt: "asc" }, + }, + }, + }); + if (user === null) { + return { status: "error", message: "请先登录并加入组织后,再绑定项目。" }; + } + if (user.organizationMemberships.length === 0) { + return { status: "error", message: "你还不属于任何可用组织,请联系组织管理员。" }; + } + if (user.organizationMemberships.length > 1) { + return { status: "error", message: "你属于多个组织。请先从组织后台选择项目绑定,或等待多组织选择入口。" }; + } + const membership = user.organizationMemberships[0]!; + return { + status: "ok", + organizationId: membership.organization.id, + organizationName: membership.organization.name, + role: membership.role, + }; + } + + async function listBindableProjectsForActor(input: { + readonly organizationId: string; + readonly actorFeishuOpenId: string; + readonly isOrgAdmin: boolean; + }): Promise { + const candidates = await deps.prisma.project.findMany({ + where: { + organizationId: input.organizationId, + archivedAt: null, + groupBindings: { none: { archivedAt: null } }, + }, + select: { + id: true, + name: true, + folder: { select: { name: true } }, + }, + orderBy: { updatedAt: "desc" }, + take: 20, + }); + const allowed: OnboardingProjectOption[] = []; + for (const project of candidates) { + if (!input.isOrgAdmin) { + const decision = await authorizer.can({ + actor: { feishuOpenId: input.actorFeishuOpenId }, + action: "collaborator.manage", + resource: { type: "PROJECT", id: project.id }, + }); + if (!decision.allowed) continue; + } + allowed.push({ + projectId: project.id, + name: project.name, + ...(project.folder?.name !== undefined ? { folderName: project.folder.name } : {}), + }); + if (allowed.length >= 5) break; + } + return allowed; + } + return Object.assign(onMessage, { onCardAction, approvalManager }); } @@ -793,6 +1006,10 @@ function nonEmpty(value: string | undefined): string | undefined { return value === undefined || value === "" ? undefined : value; } +function messageMentionsBot(msg: MessageReceiveEvent["message"]): boolean { + return msg.mentions !== undefined && msg.mentions.length > 0; +} + function sendOptionsForTriggerMessage(msg: MessageReceiveEvent["message"]): SendMessageOptions { return { replyToMessageId: msg.message_id }; } @@ -974,6 +1191,23 @@ function withFileDeliveryInstructions(systemPrompt: string | undefined, roleTool "Do not say a file is attached or sent unless that tool returns success."; return systemPrompt === undefined ? fileDeliveryPrompt : `${systemPrompt}\n\n${fileDeliveryPrompt}`; } + +function isOrgAdminRole(role: "OWNER" | "ADMIN" | "MEMBER"): boolean { + return role === "OWNER" || role === "ADMIN"; +} + +function defaultProjectNameForChat(chatId: string): string { + return `飞书群项目 ${shortId(chatId)}`; +} + +function shortId(value: string): string { + return value.length <= 8 ? value : value.slice(-8); +} + +function escapeCardMarkdown(value: string): string { + return value.replace(/([`*_{}[\]<>])/g, "\\$1"); +} + /** Lark text-message content: `{"text":"@_user_1 do something"}`. */ const TextMessageContentSchema = z.object({ text: z.string() }); diff --git a/hub/src/server.ts b/hub/src/server.ts index 4612b37..e3da3af 100644 --- a/hub/src/server.ts +++ b/hub/src/server.ts @@ -43,6 +43,7 @@ async function main(): Promise { const feishuAppId = requireEnv("FEISHU_APP_ID"); const feishuAppSecret = requireEnv("FEISHU_APP_SECRET"); const feishuBotOpenId = requireEnv("FEISHU_BOT_OPEN_ID"); + const projectWorkspaceRoot = requireEnv("HUB_PROJECT_WORKSPACE_ROOT"); const port = Number(process.env["PORT"] ?? "8788"); const app = Fastify({ logger: true }); @@ -69,7 +70,7 @@ async function main(): Promise { } }, 60_000); triggerQueuePurgeTimer.unref(); - const trigger = makeTriggerHandler({ prisma, settings: runtimeSettings, logger: app.log }); + const trigger = makeTriggerHandler({ prisma, settings: runtimeSettings, logger: app.log, projectWorkspaceRoot }); startFeishuListenerWithClient(feishuConfig, larkClient, app.log, trigger, trigger.onCardAction); } else { app.log.info("feishu listener disabled by HUB_FEISHU_LISTENER_ENABLED"); diff --git a/hub/test/integration/trigger.test.ts b/hub/test/integration/trigger.test.ts index a9591dc..3532dab 100644 --- a/hub/test/integration/trigger.test.ts +++ b/hub/test/integration/trigger.test.ts @@ -1,5 +1,8 @@ -import { describe, it, expect, beforeEach, afterAll, vi } from "vitest"; -import { prisma, resetDb, mockFeishuRuntime, seedProject, silentLogger } from "./helpers.js"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, it, expect, beforeEach, afterEach, afterAll, vi } from "vitest"; +import { DEFAULT_ORG_ID, prisma, resetDb, mockFeishuRuntime, seedProject, silentLogger } from "./helpers.js"; import { InMemoryModelRegistry } from "../../src/agent/models.js"; import { makeTriggerHandler, extractPrompt } from "../../src/feishu/trigger.js"; import { TriggerQueue } from "../../src/feishu/triggerQueue.js"; @@ -9,6 +12,7 @@ import type { RuntimeSettings } from "../../src/settings/runtime.js"; const bot = { key: "@_user_1", id: { open_id: "ou_bot" }, name: "Bot" }; type TestRunner = (req: RunRequest) => Promise; +const workspaceRoots: string[] = []; function makeTestSettings(models: InMemoryModelRegistry): RuntimeSettings { return { @@ -102,6 +106,13 @@ describe("trigger full lifecycle (integration)", () => { rt = mockFeishuRuntime(); }); + afterEach(async () => { + while (workspaceRoots.length > 0) { + const root = workspaceRoots.pop(); + if (root !== undefined) await rm(root, { recursive: true, force: true }); + } + }); + it("creates a run, acquires + releases the lock, sends status card", async () => { await seedProject("proj-1", "chat-1"); const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } }); @@ -142,6 +153,125 @@ describe("trigger full lifecycle (integration)", () => { expect(run.costSource).toBe("provider_reported"); }); + it("sends an onboarding card when an unbound chat mentions the bot", async () => { + await seedOnboardingUser("u-onboard-card", "ou_onboard_card", "MEMBER"); + const trigger = makeTriggerHandler({ + prisma, + settings, + logger: silentLogger, + runAgent, + projectWorkspaceRoot: await tempWorkspaceRoot(), + }); + + await trigger(makeEvent("chat-unbound-card", "@_user_1 开始项目", "ou_onboard_card"), rt); + + expect(rt.sentCards).toHaveLength(1); + expect(rt.sentTexts.at(-1)).toContain("这个飞书群还没有绑定项目"); + const values = cardActionValues(rt.sentCards[0]); + expect(values).toContainEqual({ + project_onboarding: { + action: "create_project_from_chat", + organization_id: DEFAULT_ORG_ID, + }, + }); + expect(runAgentCalls).toHaveLength(0); + }); + + it("creates and binds a project from the unbound-chat onboarding card", async () => { + await seedOnboardingUser("u-onboard-create", "ou_onboard_create", "MEMBER"); + const trigger = makeTriggerHandler({ + prisma, + settings, + logger: silentLogger, + runAgent, + projectWorkspaceRoot: await tempWorkspaceRoot(), + }); + + await trigger.onCardAction(makeOnboardingEvent("chat-onboard-create", { + project_onboarding: { + action: "create_project_from_chat", + organization_id: DEFAULT_ORG_ID, + }, + }, "ou_onboard_create"), rt); + + const binding = await prisma.projectGroupBinding.findFirst({ + where: { chatId: "chat-onboard-create", archivedAt: null }, + select: { projectId: true }, + }); + expect(binding).not.toBeNull(); + const grants = await prisma.permissionGrant.findMany({ + where: { resourceType: "PROJECT", resourceId: binding!.projectId, revokedAt: null }, + select: { principalType: true, principalId: true, role: true }, + }); + expect(grants).toEqual(expect.arrayContaining([ + { principalType: "USER", principalId: "ou_onboard_create", role: "MANAGE" }, + { principalType: "FEISHU_CHAT", principalId: "chat-onboard-create", role: "EDIT" }, + ])); + expect(cardHeaderTitle(rt.sentPatches.at(-1))).toBe("已创建并绑定项目"); + }); + + it("binds an existing manageable project from the unbound-chat onboarding card", async () => { + await seedOnboardingUser("u-onboard-bind", "ou_onboard_bind", "MEMBER"); + await prisma.project.create({ + data: { + id: "p-onboard-bind", + organizationId: DEFAULT_ORG_ID, + name: "可绑定项目", + workspaceDir: join(await tempWorkspaceRoot(), "p-onboard-bind"), + }, + }); + await prisma.permissionGrant.create({ + data: { + resourceType: "PROJECT", + resourceId: "p-onboard-bind", + principalType: "USER", + principalId: "ou_onboard_bind", + role: "MANAGE", + createdByUserId: "u-onboard-bind", + }, + }); + const trigger = makeTriggerHandler({ + prisma, + settings, + logger: silentLogger, + runAgent, + projectWorkspaceRoot: await tempWorkspaceRoot(), + }); + + await trigger(makeEvent("chat-onboard-list", "@_user_1 绑定项目", "ou_onboard_bind"), rt); + expect(cardActionValues(rt.sentCards[0])).toContainEqual({ + project_onboarding: { + action: "bind_project", + organization_id: DEFAULT_ORG_ID, + project_id: "p-onboard-bind", + }, + }); + + await trigger.onCardAction(makeOnboardingEvent("chat-onboard-list", { + project_onboarding: { + action: "bind_project", + organization_id: DEFAULT_ORG_ID, + project_id: "p-onboard-bind", + }, + }, "ou_onboard_bind"), rt); + + await expect(prisma.projectGroupBinding.findFirst({ + where: { projectId: "p-onboard-bind", chatId: "chat-onboard-list", archivedAt: null }, + })).resolves.not.toBeNull(); + const chatGrant = await prisma.permissionGrant.findFirst({ + where: { + resourceType: "PROJECT", + resourceId: "p-onboard-bind", + principalType: "FEISHU_CHAT", + principalId: "chat-onboard-list", + revokedAt: null, + }, + select: { role: true }, + }); + expect(chatGrant?.role).toBe("EDIT"); + expect(cardHeaderTitle(rt.sentPatches.at(-1))).toBe("已绑定项目"); + }); + it("batches quick text messages from the same chat and sender into one run", async () => { await seedProject("proj-1b", "chat-1b"); const trigger = makeTriggerHandler({ @@ -484,13 +614,13 @@ describe("trigger full lifecycle (integration)", () => { }); }); - it("ignores messages from unbound chats (ADR-0001)", async () => { + it("does not create a run for unbound chats and asks unknown users to log in", async () => { await seedProject("proj-4", "chat-4"); const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } }); - await trigger(makeEvent("chat-UNKNOWN", "@_user_1 写教案"), rt); + await trigger(makeEvent("chat-UNKNOWN", "@_user_1 写教案", "ou_unknown_user"), rt); - expect(rt.sentTexts).toHaveLength(0); + expect(rt.sentTexts).toContain("请先登录并加入组织后,再绑定项目。"); expect(rt.sentCards).toHaveLength(0); const runs = await prisma.agentRun.findMany(); expect(runs).toHaveLength(0); @@ -973,6 +1103,56 @@ function makeInterruptEvent(chatId: string, runId: string, openId: string): Card }; } +function makeOnboardingEvent(chatId: string, value: unknown, openId: string): CardActionEvent { + return { + operator: { open_id: openId }, + action: { value, tag: "button" }, + context: { open_chat_id: chatId, open_message_id: "card-message-1" }, + }; +} + +async function seedOnboardingUser(id: string, feishuOpenId: string, role: "OWNER" | "ADMIN" | "MEMBER"): Promise { + await prisma.user.create({ + data: { + id, + feishuOpenId, + displayName: feishuOpenId, + organizationMemberships: { create: { organizationId: DEFAULT_ORG_ID, role } }, + }, + }); +} + +async function tempWorkspaceRoot(): Promise { + const root = await mkdtemp(join(tmpdir(), "cph-trigger-onboarding-")); + workspaceRoots.push(root); + return root; +} + +function cardActionValues(card: unknown): unknown[] { + if (typeof card !== "object" || card === null || !("elements" in card)) return []; + const elements = (card as { elements?: unknown }).elements; + if (!Array.isArray(elements)) return []; + return elements.flatMap((element) => { + if (typeof element !== "object" || element === null || !("actions" in element)) return []; + const actions = (element as { actions?: unknown }).actions; + if (!Array.isArray(actions)) return []; + return actions.map((action) => { + if (typeof action !== "object" || action === null || !("value" in action)) return undefined; + return (action as { value?: unknown }).value; + }).filter((value): value is unknown => value !== undefined); + }); +} + +function cardHeaderTitle(card: unknown): string | undefined { + if (typeof card !== "object" || card === null || !("header" in card)) return undefined; + const header = (card as { header?: unknown }).header; + if (typeof header !== "object" || header === null || !("title" in header)) return undefined; + const title = (header as { title?: unknown }).title; + if (typeof title !== "object" || title === null || !("content" in title)) return undefined; + const content = (title as { content?: unknown }).content; + return typeof content === "string" ? content : undefined; +} + function cardHasInterruptedFooter(card: unknown): boolean { if (typeof card !== "object" || card === null) return false; if (!("elements" in card)) return false;