diff --git a/hub/src/feishu/projectOnboardingCard.ts b/hub/src/feishu/projectOnboardingCard.ts index 33ab1e3..75f0657 100644 --- a/hub/src/feishu/projectOnboardingCard.ts +++ b/hub/src/feishu/projectOnboardingCard.ts @@ -4,31 +4,42 @@ export interface OnboardingProjectOption { readonly folderName?: string | undefined; } +export interface OnboardingFolderOption { + readonly folderId: string; + readonly name: string; +} + export interface ProjectOnboardingActionValue { readonly action: "create_project_from_chat" | "bind_project"; readonly organization_id: string; readonly project_id?: string | undefined; + readonly folder_id?: string | undefined; } export function buildUnboundChatOnboardingCard(params: { readonly organizationId: string; readonly organizationName: string; + readonly folders: readonly OnboardingFolderOption[]; 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, + const folders = params.folders.length === 0 ? [{ folderId: undefined, name: "默认位置" }] : params.folders.slice(0, 3); + for (const folder of folders) { + actions.push({ + tag: "button", + text: { tag: "plain_text", content: `新建到 ${buttonLabel(folder.name, 14)}` }, + type: "primary", + value: { + project_onboarding: { + action: "create_project_from_chat", + organization_id: params.organizationId, + ...(folder.folderId !== undefined ? { folder_id: folder.folderId } : {}), + }, }, - }, - }); + }); + } } for (const project of params.projects.slice(0, 5)) { @@ -53,7 +64,7 @@ export function buildUnboundChatOnboardingCard(params: { `这个飞书群还没有绑定项目。`, ``, `组织: **${escapeMarkdown(params.organizationName)}**`, - `可以新建一个项目并绑定到本群,或绑定你已经有管理权限的未绑定项目。`, + `可以选择 folder 新建项目并绑定到本群,或绑定你已经有管理权限的未绑定项目。`, ].join("\n"), }, ]; @@ -101,16 +112,21 @@ export function projectOnboardingActionFromValue(value: unknown): ProjectOnboard 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; + const folderId = (action as { folder_id?: unknown }).folder_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; } + if (folderId !== undefined && (typeof folderId !== "string" || folderId === "")) { + return null; + } return { action: rawAction, organization_id: organizationId, ...(typeof projectId === "string" && projectId !== "" ? { project_id: projectId } : {}), + ...(typeof folderId === "string" && folderId !== "" ? { folder_id: folderId } : {}), }; } @@ -126,7 +142,11 @@ function unwrapValue(value: unknown): unknown { 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)}...`; + return buttonLabel(label, 24); +} + +function buttonLabel(label: string, maxLength: number): string { + return label.length <= maxLength ? label : `${label.slice(0, maxLength - 3)}...`; } function escapeMarkdown(value: string): string { diff --git a/hub/src/feishu/trigger.ts b/hub/src/feishu/trigger.ts index faa7b60..8c9b91f 100644 --- a/hub/src/feishu/trigger.ts +++ b/hub/src/feishu/trigger.ts @@ -53,6 +53,7 @@ import { buildProjectOnboardingResolvedCard, buildUnboundChatOnboardingCard, projectOnboardingActionFromValue, + type OnboardingFolderOption, type OnboardingProjectOption, type ProjectOnboardingActionValue, } from "./projectOnboardingCard.js"; @@ -575,6 +576,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler { chatId, name, workspaceRoot: deps.projectWorkspaceRoot, + folderId: action.folder_id, }); await resolveProjectOnboardingCard(rt, messageId, { title: "已创建并绑定项目", @@ -860,6 +862,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler { actorFeishuOpenId: senderOpenId, isOrgAdmin: isOrgAdminRole(organization.role), }); + const folders = await listCreatableRootFolders(organization.organizationId); await sendCard( rt, @@ -867,6 +870,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler { buildUnboundChatOnboardingCard({ organizationId: organization.organizationId, organizationName: organization.organizationName, + folders, projects, canCreateProject, }), @@ -956,6 +960,16 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler { return allowed; } + async function listCreatableRootFolders(organizationId: string): Promise { + const folders = await deps.prisma.folder.findMany({ + where: { organizationId, parentId: null, archivedAt: null }, + select: { id: true, name: true }, + orderBy: [{ sortKey: "asc" }, { name: "asc" }], + take: 3, + }); + return folders.map((folder) => ({ folderId: folder.id, name: folder.name })); + } + return Object.assign(onMessage, { onCardAction, approvalManager }); } diff --git a/hub/test/integration/trigger.test.ts b/hub/test/integration/trigger.test.ts index 3532dab..6afbc90 100644 --- a/hub/test/integration/trigger.test.ts +++ b/hub/test/integration/trigger.test.ts @@ -168,12 +168,15 @@ describe("trigger full lifecycle (integration)", () => { expect(rt.sentCards).toHaveLength(1); expect(rt.sentTexts.at(-1)).toContain("这个飞书群还没有绑定项目"); const values = cardActionValues(rt.sentCards[0]); - expect(values).toContainEqual({ - project_onboarding: { + expect(values).toEqual(expect.arrayContaining([ + expect.objectContaining({ + project_onboarding: expect.objectContaining({ action: "create_project_from_chat", organization_id: DEFAULT_ORG_ID, - }, - }); + folder_id: expect.any(String), + }), + }), + ])); expect(runAgentCalls).toHaveLength(0); });