forked from EduCraft/curriculum-project-hub
feat: add Feishu project onboarding card
This commit is contained in:
@@ -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");
|
||||
}
|
||||
@@ -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
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user