feat: add paginated project discovery

This commit is contained in:
2026-07-12 01:45:15 +08:00
parent 53d372e29b
commit 816af1abdb
14 changed files with 1505 additions and 237 deletions
+1
View File
@@ -100,6 +100,7 @@ export interface CardActionEvent {
readonly value?: unknown;
readonly tag?: string;
readonly option?: string;
readonly form_value?: Readonly<Record<string, unknown>>;
};
readonly context?: { readonly open_message_id?: string; readonly open_chat_id?: string };
readonly token?: string;
+238 -106
View File
@@ -1,97 +1,46 @@
export interface OnboardingProjectOption {
readonly projectId: string;
readonly name: string;
readonly folderName?: string | undefined;
}
import type { ProjectDiscoveryPage, ProjectFolderPage } from "../projectDiscovery.js";
export interface OnboardingFolderOption {
readonly folderId: string;
readonly name: string;
}
export type ProjectOnboardingView =
| { readonly mode: "search"; readonly result: ProjectDiscoveryPage }
| { readonly mode: "browse"; readonly result: ProjectFolderPage };
export interface ProjectOnboardingActionValue {
readonly action: "create_project_from_chat" | "bind_project";
readonly action: "create_project_from_chat" | "bind_project" | "browse_folder" | "search_page" | "rename_project";
readonly organization_id: string;
readonly project_id?: string | undefined;
readonly folder_id?: string | undefined;
readonly search_query?: string | undefined;
readonly page?: number | undefined;
}
export function buildUnboundChatOnboardingCard(params: {
readonly organizationId: string;
readonly organizationName: string;
readonly folders: readonly OnboardingFolderOption[];
readonly projects: readonly OnboardingProjectOption[];
readonly canCreateProject: boolean;
readonly searchQuery?: string | undefined;
readonly view: ProjectOnboardingView;
}): Record<string, unknown> {
const actions: unknown[] = [];
if (params.canCreateProject) {
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 } : {}),
},
},
});
}
const elements: unknown[] = [{
tag: "markdown",
content: onboardingSummary(params.organizationName, params.view),
}];
if (params.view.mode === "browse") {
appendFolderNavigation(elements, params.organizationId, params.view.result);
}
appendProjectResults(
elements,
params.organizationId,
params.view.mode === "search" ? params.view.result.items : params.view.result.projects,
);
appendPagination(elements, params.organizationId, params.view);
appendCreation(elements, params.organizationId, params.canCreateProject, params.view);
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,
},
},
});
if (elements.length === 1) {
elements.push({ tag: "markdown", content: "当前目录没有可浏览内容。" });
}
const elements: unknown[] = [
{
tag: "markdown",
content: [
`这个飞书群还没有绑定项目。`,
``,
`组织: **${escapeMarkdown(params.organizationName)}**`,
...(params.searchQuery === undefined || params.searchQuery === ""
? [`可以选择 folder 新建项目并绑定到本群,或绑定你已经有管理权限的未绑定项目。`]
: [
`项目搜索: **${escapeMarkdown(params.searchQuery)}**`,
params.projects.length === 0
? `没有匹配的未绑定项目。请 @bot 后换一个项目关键词。`
: `请选择匹配项目绑定到本群;如未找到,请 @bot 后换一个更具体的关键词。`,
]),
].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",
},
header: { title: { tag: "plain_text", content: "绑定项目" }, template: "blue" },
elements,
};
}
@@ -103,54 +52,237 @@ export function buildProjectOnboardingResolvedCard(params: {
}): Record<string, unknown> {
return {
config: { wide_screen_mode: true },
header: {
title: { tag: "plain_text", content: params.title },
template: params.template,
},
header: { title: { tag: "plain_text", content: params.title }, template: params.template },
elements: [{ tag: "markdown", content: params.body }],
};
}
export function buildProjectManagementCard(params: {
readonly organizationId: string;
readonly projectId: string;
readonly projectName: string;
readonly title?: string | undefined;
}): Record<string, unknown> {
return {
config: { wide_screen_mode: true },
header: {
title: { tag: "plain_text", content: params.title ?? "项目管理" },
template: "green",
},
elements: [
{ tag: "markdown", content: `当前项目: **${escapeMarkdown(params.projectName)}**` },
{
tag: "form",
name: "project_rename_form",
fallback: {
tag: "fallback_text",
text: { tag: "plain_text", content: "请升级飞书客户端后修改项目名称。" },
},
elements: [
{
tag: "input",
name: "project_name",
required: true,
max_length: 100,
default_value: params.projectName,
placeholder: { tag: "plain_text", content: "请输入项目名称" },
},
{
tag: "button",
name: "project_rename_submit",
text: { tag: "plain_text", content: "保存项目名称" },
type: "primary",
complex_interaction: true,
action_type: "form_submit",
value: {
project_onboarding: {
action: "rename_project",
organization_id: params.organizationId,
project_id: params.projectId,
},
},
},
],
},
],
};
}
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;
if (typeof raw !== "object" || raw === null || Array.isArray(raw) || !("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;
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;
}
const fields = action as Record<string, unknown>;
const rawAction = fields.action;
const organizationId = fields.organization_id;
const projectId = fields.project_id;
const folderId = fields.folder_id;
const searchQuery = fields.search_query;
const page = fields.page;
if (!isAction(rawAction) || typeof organizationId !== "string" || organizationId === "") return null;
if ((rawAction === "bind_project" || rawAction === "rename_project") && (typeof projectId !== "string" || projectId === "")) return null;
if (rawAction === "search_page" && (typeof searchQuery !== "string" || !validPage(page))) return null;
if (folderId !== undefined && (typeof folderId !== "string" || folderId === "")) return null;
if (page !== undefined && !validPage(page)) return null;
return {
action: rawAction,
organization_id: organizationId,
...(typeof projectId === "string" && projectId !== "" ? { project_id: projectId } : {}),
...(typeof folderId === "string" && folderId !== "" ? { folder_id: folderId } : {}),
...(typeof searchQuery === "string" ? { search_query: searchQuery.slice(0, 100) } : {}),
...(typeof page === "number" ? { page } : {}),
};
}
function onboardingSummary(organizationName: string, view: ProjectOnboardingView): string {
if (view.mode === "search") {
const result = view.result;
return [
"这个飞书群还没有绑定项目。",
"",
`组织: **${escapeMarkdown(organizationName)}**`,
`搜索: **${escapeMarkdown(result.query)}**`,
result.totalItems === 0
? "没有匹配的可绑定项目,请换一个关键词。"
: `共 **${result.totalItems}** 个匹配结果 · 第 **${result.page}/${result.totalPages}** 页`,
].join("\n");
}
const location = view.result.breadcrumb === "" ? "根目录" : view.result.breadcrumb;
return [
"这个飞书群还没有绑定项目。",
"",
`组织: **${escapeMarkdown(organizationName)}**`,
`当前位置: **${escapeMarkdown(location)}**`,
"可以进入 folder 浏览,或选择有管理权限的未绑定项目。",
].join("\n");
}
function appendFolderNavigation(elements: unknown[], organizationId: string, result: ProjectFolderPage): void {
const navigation: unknown[] = [];
if (result.folderId !== null) {
navigation.push(actionButton("⬆ 上一级", {
action: "browse_folder",
organization_id: organizationId,
...(result.parentFolderId !== null ? { folder_id: result.parentFolderId } : {}),
page: 1,
}));
}
for (const folder of result.childFolders) {
navigation.push(actionButton(`📁 ${buttonLabel(folder.name, 22)}`, {
action: "browse_folder",
organization_id: organizationId,
folder_id: folder.folderId,
page: 1,
}));
}
for (let index = 0; index < navigation.length; index += 5) {
elements.push({ tag: "action", actions: navigation.slice(index, index + 5) });
}
}
function appendProjectResults(
elements: unknown[],
organizationId: string,
projects: readonly ProjectDiscoveryPage["items"][number][],
): void {
for (const project of projects) {
const path = project.breadcrumb === "" ? "根目录" : project.breadcrumb;
elements.push({
tag: "markdown",
content: `**${escapeMarkdown(project.name)}**\n${escapeMarkdown(path)}`,
});
elements.push({
tag: "action",
actions: [actionButton("绑定这个项目", {
action: "bind_project",
organization_id: organizationId,
project_id: project.projectId,
})],
});
}
}
function appendPagination(elements: unknown[], organizationId: string, view: ProjectOnboardingView): void {
const result = projectPage(view);
if (result.totalPages <= 1) return;
const actions: unknown[] = [];
if (result.page > 1) actions.push(pageButton("上一页", organizationId, view, result.page - 1));
if (result.page < result.totalPages) actions.push(pageButton("下一页", organizationId, view, result.page + 1));
elements.push({ tag: "action", actions });
}
function appendCreation(
elements: unknown[],
organizationId: string,
canCreateProject: boolean,
view: ProjectOnboardingView,
): void {
if (!canCreateProject || view.mode !== "browse") return;
elements.push({
tag: "action",
actions: [actionButton(view.result.folderId === null ? "新建项目" : "在此 folder 新建项目", {
action: "create_project_from_chat",
organization_id: organizationId,
...(view.result.folderId !== null ? { folder_id: view.result.folderId } : {}),
}, "primary")],
});
}
function pageButton(label: string, organizationId: string, view: ProjectOnboardingView, page: number): unknown {
if (view.mode === "search") {
return actionButton(label, {
action: "search_page",
organization_id: organizationId,
search_query: view.result.query,
page,
});
}
return actionButton(label, {
action: "browse_folder",
organization_id: organizationId,
...(view.result.folderId !== null ? { folder_id: view.result.folderId } : {}),
page,
});
}
function projectPage(view: ProjectOnboardingView): ProjectDiscoveryPage {
if (view.mode === "search") return view.result;
return {
query: "",
page: view.result.page,
pageSize: view.result.pageSize,
totalItems: view.result.totalFolders + view.result.totalProjects,
totalPages: view.result.totalPages,
items: view.result.projects,
};
}
function actionButton(
label: string,
value: ProjectOnboardingActionValue,
type: "default" | "primary" = "default",
): unknown {
return {
tag: "button",
text: { tag: "plain_text", content: label },
type,
value: { project_onboarding: value },
};
}
function isAction(value: unknown): value is ProjectOnboardingActionValue["action"] {
return value === "create_project_from_chat" || value === "bind_project"
|| value === "browse_folder" || value === "search_page" || value === "rename_project";
}
function validPage(value: unknown): value is number {
return typeof value === "number" && Number.isSafeInteger(value) && value >= 1 && value <= 10_000;
}
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 buttonLabel(label, 24);
try { return JSON.parse(value); } catch { return value; }
}
function buttonLabel(label: string, maxLength: number): string {
+13
View File
@@ -99,6 +99,19 @@ export function createSlashCommandRegistry(deps: SlashCommandRegistryDeps): Read
},
});
add({
name: "project",
usage: "/project",
summary: "打开当前项目管理卡片,可修改项目名称。",
details: [
"要求操作者拥有当前项目的 MANAGE 权限。",
"实际卡片由飞书 trigger adapter 渲染,不创建 agent run。",
],
run: async ({ chatId, rt, sendOptions }) => {
await sendText(rt, chatId, "请在飞书项目群中使用 @bot /project 打开项目管理卡片。", sendOptions);
},
});
add({
name: "resume",
usage: "/resume",
+181 -82
View File
@@ -58,16 +58,18 @@ import {
bindFeishuChatToProject,
createProjectFromFeishuChat,
ensureOrganizationProjectSettings,
renameProjectForActor,
} from "../projectOnboarding.js";
import {
buildProjectManagementCard,
buildProjectOnboardingResolvedCard,
buildUnboundChatOnboardingCard,
projectOnboardingActionFromValue,
type OnboardingFolderOption,
type OnboardingProjectOption,
type ProjectOnboardingActionValue,
type ProjectOnboardingView,
} from "./projectOnboardingCard.js";
import { SiloFixedWindowRateLimiter } from "../deployment/siloRateLimit.js";
import { browseBindableFolder, discoverBindableProjects } from "../projectDiscovery.js";
export { ApprovalManager } from "./approval.js";
export type { ApprovalResult, PendingApproval } from "./approval.js";
@@ -754,21 +756,91 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
}
try {
const organization = await resolveSingleActiveOrganizationForFeishuUser(operatorOpenId);
if (organization.status !== "ok") throw new Error(organization.message);
if (organization.organizationId !== action.organization_id) {
throw new Error("project onboarding organization mismatch");
}
if (action.action === "rename_project") {
const projectId = action.project_id;
if (projectId === undefined) throw new Error("rename_project action requires project_id");
const binding = await deps.prisma.projectGroupBinding.findFirst({
where: { chatId, projectId, archivedAt: null },
select: { id: true },
});
if (binding === null) throw new Error("project rename requires the project to be bound to this chat");
const submittedName = event.action.form_value?.["project_name"];
if (typeof submittedName !== "string") throw new Error("project rename form is missing project_name");
const renamed = await renameProjectForActor(deps.prisma, {
organizationId: organization.organizationId,
projectId,
actorFeishuOpenId: operatorOpenId,
name: submittedName.slice(0, 100),
});
if (messageId === undefined) throw new Error("project rename requires message id");
await patchCard(rt, messageId, buildProjectManagementCard({
organizationId: organization.organizationId,
projectId: renamed.projectId,
projectName: renamed.name,
title: "项目名称已更新",
}));
deps.logger.info({ chatId, projectId, operatorOpenId }, "project onboarding: project renamed");
return;
}
if (action.action === "browse_folder" || action.action === "search_page") {
const activeBinding = await deps.prisma.projectGroupBinding.findFirst({
where: { chatId, archivedAt: null },
select: { projectId: true },
});
if (activeBinding !== null) throw new Error(`chat is already bound to project ${activeBinding.projectId}`);
const settings = await ensureOrganizationProjectSettings(deps.prisma, organization.organizationId);
const view = action.action === "search_page"
? await searchOnboardingView({
organizationId: organization.organizationId,
actorFeishuOpenId: operatorOpenId,
isOrgAdmin: isOrgAdminRole(organization.role),
query: action.search_query ?? "",
page: action.page ?? 1,
})
: await browseOnboardingView({
organizationId: organization.organizationId,
actorFeishuOpenId: operatorOpenId,
isOrgAdmin: isOrgAdminRole(organization.role),
folderId: action.folder_id ?? null,
page: action.page ?? 1,
});
if (messageId === undefined) throw new Error("project onboarding navigation requires message id");
await patchCard(rt, messageId, buildUnboundChatOnboardingCard({
organizationId: organization.organizationId,
organizationName: organization.organizationName,
canCreateProject: settings.membersCanCreateProjects || isOrgAdminRole(organization.role),
view,
}));
deps.logger.info(
{ chatId, operatorOpenId, action: action.action, folderId: action.folder_id, page: action.page },
"project onboarding: navigated discovery card",
);
return;
}
if (action.action === "create_project_from_chat") {
const name = defaultProjectNameForChat(chatId);
const project = await createProjectFromFeishuChat(deps.prisma, {
organizationId: action.organization_id,
organizationId: organization.organizationId,
actorFeishuOpenId: operatorOpenId,
chatId,
name,
workspaceRoot: projectWorkspaceRoot,
folderId: action.folder_id,
});
await resolveProjectOnboardingCard(rt, messageId, {
title: "已创建并绑定项目",
body: `项目 **${escapeCardMarkdown(name)}** 已绑定到本群。后续 @bot 会进入这个项目的 session。`,
template: "green",
});
if (messageId !== undefined) {
await patchCard(rt, messageId, buildProjectManagementCard({
organizationId: organization.organizationId,
projectId: project.projectId,
projectName: name,
title: "已创建并绑定项目",
}));
}
deps.logger.info({ chatId, projectId: project.projectId, operatorOpenId }, "project onboarding: created project from chat");
return;
}
@@ -777,16 +849,31 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
if (projectId === undefined) {
throw new Error("bind_project action requires project_id");
}
const target = await deps.prisma.project.findUnique({
where: { id: projectId },
select: { organizationId: true },
});
if (target === null) throw new Error(`project not found: ${projectId}`);
if (target.organizationId !== organization.organizationId) {
throw new Error("project onboarding project organization mismatch");
}
const project = await bindFeishuChatToProject(deps.prisma, {
projectId,
actorFeishuOpenId: operatorOpenId,
chatId,
});
await resolveProjectOnboardingCard(rt, messageId, {
title: "已绑定项目",
body: `本群已绑定到项目 \`${project.projectId}\`。后续 @bot 会进入这个项目的 session。`,
template: "green",
});
if (messageId !== undefined) {
const boundProject = await deps.prisma.project.findUniqueOrThrow({
where: { id: project.projectId },
select: { name: true },
});
await patchCard(rt, messageId, buildProjectManagementCard({
organizationId: organization.organizationId,
projectId: project.projectId,
projectName: boundProject.name,
title: "已绑定项目",
}));
}
deps.logger.info({ chatId, projectId: project.projectId, operatorOpenId }, "project onboarding: bound existing project");
} catch (e) {
const reason = e instanceof Error ? e.message : String(e);
@@ -1011,6 +1098,39 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
}
if (invocation !== null) {
if (invocation.name === "project") {
if (invocation.args.length > 0) {
await sendText(rt, chatId, "用法: /project(打开当前项目管理卡片)", sendOptionsForTriggerMessage(msg));
return;
}
const project = await deps.prisma.project.findUniqueOrThrow({
where: { id: projectId },
select: { id: true, name: true, organizationId: true },
});
const organization = await resolveSingleActiveOrganizationForFeishuUser(senderOpenId);
if (organization.status !== "ok" || organization.organizationId !== project.organizationId) {
await sendText(rt, chatId, "当前身份不属于该项目组织。", sendOptionsForTriggerMessage(msg));
return;
}
if (!isOrgAdminRole(organization.role)) {
const decision = await authorizer.can({
actor,
action: "collaborator.manage",
resource: { type: "PROJECT", id: projectId },
});
if (!decision.allowed) {
await sendText(rt, chatId, "你没有管理当前项目的权限。", sendOptionsForTriggerMessage(msg));
return;
}
}
await sendCard(rt, chatId, buildProjectManagementCard({
organizationId: project.organizationId,
projectId: project.id,
projectName: project.name,
}), sendOptionsForTriggerMessage(msg));
deps.logger.info({ chatId, projectId, senderOpenId }, "feishu project management card opened");
return;
}
const slashCommand = slashCommands.get(invocation.name);
if (slashCommand !== undefined) {
try {
@@ -1071,13 +1191,21 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
const settings = await ensureOrganizationProjectSettings(deps.prisma, organization.organizationId);
const canCreateProject = settings.membersCanCreateProjects || isOrgAdminRole(organization.role);
const searchQuery = (extractPrompt(msg) ?? "").trim().slice(0, 100);
const projects = await listBindableProjectsForActor({
organizationId: organization.organizationId,
actorFeishuOpenId: senderOpenId,
isOrgAdmin: isOrgAdminRole(organization.role),
searchQuery,
});
const folders = await listCreatableRootFolders(organization.organizationId);
const view = searchQuery === ""
? await browseOnboardingView({
organizationId: organization.organizationId,
actorFeishuOpenId: senderOpenId,
isOrgAdmin: isOrgAdminRole(organization.role),
folderId: null,
page: 1,
})
: await searchOnboardingView({
organizationId: organization.organizationId,
actorFeishuOpenId: senderOpenId,
isOrgAdmin: isOrgAdminRole(organization.role),
query: searchQuery,
page: 1,
});
await sendCard(
rt,
@@ -1085,10 +1213,8 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
buildUnboundChatOnboardingCard({
organizationId: organization.organizationId,
organizationName: organization.organizationName,
folders,
projects,
canCreateProject,
searchQuery,
view,
}),
sendOptionsForTriggerMessage(msg),
);
@@ -1181,67 +1307,44 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
};
}
async function listBindableProjectsForActor(input: {
async function searchOnboardingView(input: {
readonly organizationId: string;
readonly actorFeishuOpenId: string;
readonly isOrgAdmin: boolean;
readonly searchQuery: string;
}): Promise<readonly OnboardingProjectOption[]> {
const allowed: OnboardingProjectOption[] = [];
let cursor: string | undefined;
while (allowed.length < 5) {
const candidates = await deps.prisma.project.findMany({
where: {
readonly query: string;
readonly page: number;
}): Promise<ProjectOnboardingView> {
return {
mode: "search",
result: await discoverBindableProjects({
prisma: deps.prisma,
organizationId: input.organizationId,
archivedAt: null,
groupBindings: { none: { archivedAt: null } },
...(input.searchQuery === "" ? {} : {
OR: [
{ name: { contains: input.searchQuery, mode: "insensitive" } },
{ folder: { name: { contains: input.searchQuery, mode: "insensitive" } } },
],
}),
},
select: {
id: true,
name: true,
folder: { select: { name: true } },
},
orderBy: [{ updatedAt: "desc" }, { id: "desc" }],
take: 20,
...(cursor === undefined ? {} : { cursor: { id: cursor }, skip: 1 }),
});
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;
}
if (candidates.length < 20) break;
cursor = candidates.at(-1)?.id;
if (cursor === undefined) break;
}
return allowed;
actorFeishuOpenId: input.actorFeishuOpenId,
isOrgAdmin: input.isOrgAdmin,
query: input.query,
page: input.page,
}),
};
}
async function listCreatableRootFolders(organizationId: string): Promise<readonly OnboardingFolderOption[]> {
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 }));
async function browseOnboardingView(input: {
readonly organizationId: string;
readonly actorFeishuOpenId: string;
readonly isOrgAdmin: boolean;
readonly folderId: string | null;
readonly page: number;
}): Promise<ProjectOnboardingView> {
return {
mode: "browse",
result: await browseBindableFolder({
prisma: deps.prisma,
organizationId: input.organizationId,
actorFeishuOpenId: input.actorFeishuOpenId,
isOrgAdmin: input.isOrgAdmin,
folderId: input.folderId,
page: input.page,
}),
};
}
return Object.assign(onMessage, { onCardAction, approvalManager });
@@ -1505,10 +1608,6 @@ 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() });