feat: add Feishu project onboarding card

This commit is contained in:
2026-07-10 00:32:09 +08:00
parent 34e07e229f
commit 519ea8144f
5 changed files with 558 additions and 6 deletions
+134
View File
@@ -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<string, unknown> {
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<string, unknown> {
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");
}
+234
View File
@@ -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<void> {
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<void> {
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<void> {
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<void> {
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<readonly OnboardingProjectOption[]> {
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() });
+2 -1
View File
@@ -43,6 +43,7 @@ async function main(): Promise<void> {
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<void> {
}
}, 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");