forked from bai/curriculum-project-hub
feat: add paginated project discovery
This commit is contained in:
+181
-82
@@ -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() });
|
||||
|
||||
|
||||
Reference in New Issue
Block a user