forked from EduCraft/curriculum-project-hub
feat: add Feishu project onboarding card
This commit is contained in:
@@ -21,6 +21,9 @@ ANTHROPIC_API_KEY=""
|
|||||||
# Agent run policy (optional). Defaults to 25.
|
# Agent run policy (optional). Defaults to 25.
|
||||||
# HUB_AGENT_MAX_TURNS=25
|
# HUB_AGENT_MAX_TURNS=25
|
||||||
|
|
||||||
|
# System-managed root for project workspaces created from org admin / Feishu onboarding.
|
||||||
|
HUB_PROJECT_WORKSPACE_ROOT="./data/project-workspaces"
|
||||||
|
|
||||||
# Feishu (lark) bot credentials.
|
# Feishu (lark) bot credentials.
|
||||||
FEISHU_APP_ID=""
|
FEISHU_APP_ID=""
|
||||||
FEISHU_APP_SECRET=""
|
FEISHU_APP_SECRET=""
|
||||||
|
|||||||
@@ -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 {
|
import {
|
||||||
sendText,
|
sendText,
|
||||||
sendTextMessage,
|
sendTextMessage,
|
||||||
|
sendCard,
|
||||||
|
patchCard,
|
||||||
reactToMessage,
|
reactToMessage,
|
||||||
addReaction,
|
addReaction,
|
||||||
removeReaction,
|
removeReaction,
|
||||||
@@ -42,6 +44,18 @@ import { SenderNameCache } from "./senderCache.js";
|
|||||||
import { TriggerQueue, triggerQueue as defaultTriggerQueue, type QueuedTrigger } from "./triggerQueue.js";
|
import { TriggerQueue, triggerQueue as defaultTriggerQueue, type QueuedTrigger } from "./triggerQueue.js";
|
||||||
import { createSlashCommandRegistry, formatSlashHelpTarget, parseSlashHelpSubcommand, parseSlashInvocation } from "./slashCommands.js";
|
import { createSlashCommandRegistry, formatSlashHelpTarget, parseSlashHelpSubcommand, parseSlashInvocation } from "./slashCommands.js";
|
||||||
import { cphHubMcpToolsForRole, roleToolsAllow } from "../agent/roleTools.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 { ApprovalManager } from "./approval.js";
|
||||||
export type { ApprovalResult, PendingApproval } from "./approval.js";
|
export type { ApprovalResult, PendingApproval } from "./approval.js";
|
||||||
@@ -57,6 +71,7 @@ interface TriggerDeps {
|
|||||||
readonly authorizer?: PermissionAuthorizer | undefined;
|
readonly authorizer?: PermissionAuthorizer | undefined;
|
||||||
readonly messageBatcherOptions?: MessageBatcherOptions | undefined;
|
readonly messageBatcherOptions?: MessageBatcherOptions | undefined;
|
||||||
readonly triggerQueue?: TriggerQueue | undefined;
|
readonly triggerQueue?: TriggerQueue | undefined;
|
||||||
|
readonly projectWorkspaceRoot?: string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TriggerActor {
|
interface TriggerActor {
|
||||||
@@ -521,6 +536,13 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
|||||||
return;
|
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.
|
// Interrupt button on a live run card.
|
||||||
const runId = interruptRunFromValue(event.action.value);
|
const runId = interruptRunFromValue(event.action.value);
|
||||||
if (runId !== null) {
|
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. */
|
/** Abort a live run after authorizing agent.cancel on its project. */
|
||||||
async function handleInterruptAction(event: CardActionEvent, rt: FeishuRuntime, runId: string): Promise<void> {
|
async function handleInterruptAction(event: CardActionEvent, rt: FeishuRuntime, runId: string): Promise<void> {
|
||||||
const chatId = nonEmpty(event.context?.open_chat_id);
|
const chatId = nonEmpty(event.context?.open_chat_id);
|
||||||
@@ -624,6 +717,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
|||||||
});
|
});
|
||||||
if (binding === null) {
|
if (binding === null) {
|
||||||
deps.logger.debug({ chatId }, "feishu trigger: chat not bound to any project");
|
deps.logger.debug({ chatId }, "feishu trigger: chat not bound to any project");
|
||||||
|
await sendUnboundChatOnboardingCard(event, rt);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const projectId = binding.projectId;
|
const projectId = binding.projectId;
|
||||||
@@ -743,6 +837,125 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
|||||||
await startAgentRun(runContext, cleanPrompt);
|
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 });
|
return Object.assign(onMessage, { onCardAction, approvalManager });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -793,6 +1006,10 @@ function nonEmpty(value: string | undefined): string | undefined {
|
|||||||
return value === undefined || value === "" ? undefined : value;
|
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 {
|
function sendOptionsForTriggerMessage(msg: MessageReceiveEvent["message"]): SendMessageOptions {
|
||||||
return { replyToMessageId: msg.message_id };
|
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.";
|
"Do not say a file is attached or sent unless that tool returns success.";
|
||||||
return systemPrompt === undefined ? fileDeliveryPrompt : `${systemPrompt}\n\n${fileDeliveryPrompt}`;
|
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"}`. */
|
/** Lark text-message content: `{"text":"@_user_1 do something"}`. */
|
||||||
const TextMessageContentSchema = z.object({ text: z.string() });
|
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 feishuAppId = requireEnv("FEISHU_APP_ID");
|
||||||
const feishuAppSecret = requireEnv("FEISHU_APP_SECRET");
|
const feishuAppSecret = requireEnv("FEISHU_APP_SECRET");
|
||||||
const feishuBotOpenId = requireEnv("FEISHU_BOT_OPEN_ID");
|
const feishuBotOpenId = requireEnv("FEISHU_BOT_OPEN_ID");
|
||||||
|
const projectWorkspaceRoot = requireEnv("HUB_PROJECT_WORKSPACE_ROOT");
|
||||||
const port = Number(process.env["PORT"] ?? "8788");
|
const port = Number(process.env["PORT"] ?? "8788");
|
||||||
|
|
||||||
const app = Fastify({ logger: true });
|
const app = Fastify({ logger: true });
|
||||||
@@ -69,7 +70,7 @@ async function main(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}, 60_000);
|
}, 60_000);
|
||||||
triggerQueuePurgeTimer.unref();
|
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);
|
startFeishuListenerWithClient(feishuConfig, larkClient, app.log, trigger, trigger.onCardAction);
|
||||||
} else {
|
} else {
|
||||||
app.log.info("feishu listener disabled by HUB_FEISHU_LISTENER_ENABLED");
|
app.log.info("feishu listener disabled by HUB_FEISHU_LISTENER_ENABLED");
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { describe, it, expect, beforeEach, afterAll, vi } from "vitest";
|
import { mkdtemp, rm } from "node:fs/promises";
|
||||||
import { prisma, resetDb, mockFeishuRuntime, seedProject, silentLogger } from "./helpers.js";
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, afterAll, vi } from "vitest";
|
||||||
|
import { DEFAULT_ORG_ID, prisma, resetDb, mockFeishuRuntime, seedProject, silentLogger } from "./helpers.js";
|
||||||
import { InMemoryModelRegistry } from "../../src/agent/models.js";
|
import { InMemoryModelRegistry } from "../../src/agent/models.js";
|
||||||
import { makeTriggerHandler, extractPrompt } from "../../src/feishu/trigger.js";
|
import { makeTriggerHandler, extractPrompt } from "../../src/feishu/trigger.js";
|
||||||
import { TriggerQueue } from "../../src/feishu/triggerQueue.js";
|
import { TriggerQueue } from "../../src/feishu/triggerQueue.js";
|
||||||
@@ -9,6 +12,7 @@ import type { RuntimeSettings } from "../../src/settings/runtime.js";
|
|||||||
|
|
||||||
const bot = { key: "@_user_1", id: { open_id: "ou_bot" }, name: "Bot" };
|
const bot = { key: "@_user_1", id: { open_id: "ou_bot" }, name: "Bot" };
|
||||||
type TestRunner = (req: RunRequest) => Promise<RunResult>;
|
type TestRunner = (req: RunRequest) => Promise<RunResult>;
|
||||||
|
const workspaceRoots: string[] = [];
|
||||||
|
|
||||||
function makeTestSettings(models: InMemoryModelRegistry): RuntimeSettings {
|
function makeTestSettings(models: InMemoryModelRegistry): RuntimeSettings {
|
||||||
return {
|
return {
|
||||||
@@ -102,6 +106,13 @@ describe("trigger full lifecycle (integration)", () => {
|
|||||||
rt = mockFeishuRuntime();
|
rt = mockFeishuRuntime();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
while (workspaceRoots.length > 0) {
|
||||||
|
const root = workspaceRoots.pop();
|
||||||
|
if (root !== undefined) await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("creates a run, acquires + releases the lock, sends status card", async () => {
|
it("creates a run, acquires + releases the lock, sends status card", async () => {
|
||||||
await seedProject("proj-1", "chat-1");
|
await seedProject("proj-1", "chat-1");
|
||||||
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
|
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
|
||||||
@@ -142,6 +153,125 @@ describe("trigger full lifecycle (integration)", () => {
|
|||||||
expect(run.costSource).toBe("provider_reported");
|
expect(run.costSource).toBe("provider_reported");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("sends an onboarding card when an unbound chat mentions the bot", async () => {
|
||||||
|
await seedOnboardingUser("u-onboard-card", "ou_onboard_card", "MEMBER");
|
||||||
|
const trigger = makeTriggerHandler({
|
||||||
|
prisma,
|
||||||
|
settings,
|
||||||
|
logger: silentLogger,
|
||||||
|
runAgent,
|
||||||
|
projectWorkspaceRoot: await tempWorkspaceRoot(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await trigger(makeEvent("chat-unbound-card", "@_user_1 开始项目", "ou_onboard_card"), rt);
|
||||||
|
|
||||||
|
expect(rt.sentCards).toHaveLength(1);
|
||||||
|
expect(rt.sentTexts.at(-1)).toContain("这个飞书群还没有绑定项目");
|
||||||
|
const values = cardActionValues(rt.sentCards[0]);
|
||||||
|
expect(values).toContainEqual({
|
||||||
|
project_onboarding: {
|
||||||
|
action: "create_project_from_chat",
|
||||||
|
organization_id: DEFAULT_ORG_ID,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(runAgentCalls).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates and binds a project from the unbound-chat onboarding card", async () => {
|
||||||
|
await seedOnboardingUser("u-onboard-create", "ou_onboard_create", "MEMBER");
|
||||||
|
const trigger = makeTriggerHandler({
|
||||||
|
prisma,
|
||||||
|
settings,
|
||||||
|
logger: silentLogger,
|
||||||
|
runAgent,
|
||||||
|
projectWorkspaceRoot: await tempWorkspaceRoot(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await trigger.onCardAction(makeOnboardingEvent("chat-onboard-create", {
|
||||||
|
project_onboarding: {
|
||||||
|
action: "create_project_from_chat",
|
||||||
|
organization_id: DEFAULT_ORG_ID,
|
||||||
|
},
|
||||||
|
}, "ou_onboard_create"), rt);
|
||||||
|
|
||||||
|
const binding = await prisma.projectGroupBinding.findFirst({
|
||||||
|
where: { chatId: "chat-onboard-create", archivedAt: null },
|
||||||
|
select: { projectId: true },
|
||||||
|
});
|
||||||
|
expect(binding).not.toBeNull();
|
||||||
|
const grants = await prisma.permissionGrant.findMany({
|
||||||
|
where: { resourceType: "PROJECT", resourceId: binding!.projectId, revokedAt: null },
|
||||||
|
select: { principalType: true, principalId: true, role: true },
|
||||||
|
});
|
||||||
|
expect(grants).toEqual(expect.arrayContaining([
|
||||||
|
{ principalType: "USER", principalId: "ou_onboard_create", role: "MANAGE" },
|
||||||
|
{ principalType: "FEISHU_CHAT", principalId: "chat-onboard-create", role: "EDIT" },
|
||||||
|
]));
|
||||||
|
expect(cardHeaderTitle(rt.sentPatches.at(-1))).toBe("已创建并绑定项目");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("binds an existing manageable project from the unbound-chat onboarding card", async () => {
|
||||||
|
await seedOnboardingUser("u-onboard-bind", "ou_onboard_bind", "MEMBER");
|
||||||
|
await prisma.project.create({
|
||||||
|
data: {
|
||||||
|
id: "p-onboard-bind",
|
||||||
|
organizationId: DEFAULT_ORG_ID,
|
||||||
|
name: "可绑定项目",
|
||||||
|
workspaceDir: join(await tempWorkspaceRoot(), "p-onboard-bind"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.permissionGrant.create({
|
||||||
|
data: {
|
||||||
|
resourceType: "PROJECT",
|
||||||
|
resourceId: "p-onboard-bind",
|
||||||
|
principalType: "USER",
|
||||||
|
principalId: "ou_onboard_bind",
|
||||||
|
role: "MANAGE",
|
||||||
|
createdByUserId: "u-onboard-bind",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const trigger = makeTriggerHandler({
|
||||||
|
prisma,
|
||||||
|
settings,
|
||||||
|
logger: silentLogger,
|
||||||
|
runAgent,
|
||||||
|
projectWorkspaceRoot: await tempWorkspaceRoot(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await trigger(makeEvent("chat-onboard-list", "@_user_1 绑定项目", "ou_onboard_bind"), rt);
|
||||||
|
expect(cardActionValues(rt.sentCards[0])).toContainEqual({
|
||||||
|
project_onboarding: {
|
||||||
|
action: "bind_project",
|
||||||
|
organization_id: DEFAULT_ORG_ID,
|
||||||
|
project_id: "p-onboard-bind",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await trigger.onCardAction(makeOnboardingEvent("chat-onboard-list", {
|
||||||
|
project_onboarding: {
|
||||||
|
action: "bind_project",
|
||||||
|
organization_id: DEFAULT_ORG_ID,
|
||||||
|
project_id: "p-onboard-bind",
|
||||||
|
},
|
||||||
|
}, "ou_onboard_bind"), rt);
|
||||||
|
|
||||||
|
await expect(prisma.projectGroupBinding.findFirst({
|
||||||
|
where: { projectId: "p-onboard-bind", chatId: "chat-onboard-list", archivedAt: null },
|
||||||
|
})).resolves.not.toBeNull();
|
||||||
|
const chatGrant = await prisma.permissionGrant.findFirst({
|
||||||
|
where: {
|
||||||
|
resourceType: "PROJECT",
|
||||||
|
resourceId: "p-onboard-bind",
|
||||||
|
principalType: "FEISHU_CHAT",
|
||||||
|
principalId: "chat-onboard-list",
|
||||||
|
revokedAt: null,
|
||||||
|
},
|
||||||
|
select: { role: true },
|
||||||
|
});
|
||||||
|
expect(chatGrant?.role).toBe("EDIT");
|
||||||
|
expect(cardHeaderTitle(rt.sentPatches.at(-1))).toBe("已绑定项目");
|
||||||
|
});
|
||||||
|
|
||||||
it("batches quick text messages from the same chat and sender into one run", async () => {
|
it("batches quick text messages from the same chat and sender into one run", async () => {
|
||||||
await seedProject("proj-1b", "chat-1b");
|
await seedProject("proj-1b", "chat-1b");
|
||||||
const trigger = makeTriggerHandler({
|
const trigger = makeTriggerHandler({
|
||||||
@@ -484,13 +614,13 @@ describe("trigger full lifecycle (integration)", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ignores messages from unbound chats (ADR-0001)", async () => {
|
it("does not create a run for unbound chats and asks unknown users to log in", async () => {
|
||||||
await seedProject("proj-4", "chat-4");
|
await seedProject("proj-4", "chat-4");
|
||||||
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
|
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
|
||||||
|
|
||||||
await trigger(makeEvent("chat-UNKNOWN", "@_user_1 写教案"), rt);
|
await trigger(makeEvent("chat-UNKNOWN", "@_user_1 写教案", "ou_unknown_user"), rt);
|
||||||
|
|
||||||
expect(rt.sentTexts).toHaveLength(0);
|
expect(rt.sentTexts).toContain("请先登录并加入组织后,再绑定项目。");
|
||||||
expect(rt.sentCards).toHaveLength(0);
|
expect(rt.sentCards).toHaveLength(0);
|
||||||
const runs = await prisma.agentRun.findMany();
|
const runs = await prisma.agentRun.findMany();
|
||||||
expect(runs).toHaveLength(0);
|
expect(runs).toHaveLength(0);
|
||||||
@@ -973,6 +1103,56 @@ function makeInterruptEvent(chatId: string, runId: string, openId: string): Card
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function makeOnboardingEvent(chatId: string, value: unknown, openId: string): CardActionEvent {
|
||||||
|
return {
|
||||||
|
operator: { open_id: openId },
|
||||||
|
action: { value, tag: "button" },
|
||||||
|
context: { open_chat_id: chatId, open_message_id: "card-message-1" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedOnboardingUser(id: string, feishuOpenId: string, role: "OWNER" | "ADMIN" | "MEMBER"): Promise<void> {
|
||||||
|
await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
id,
|
||||||
|
feishuOpenId,
|
||||||
|
displayName: feishuOpenId,
|
||||||
|
organizationMemberships: { create: { organizationId: DEFAULT_ORG_ID, role } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tempWorkspaceRoot(): Promise<string> {
|
||||||
|
const root = await mkdtemp(join(tmpdir(), "cph-trigger-onboarding-"));
|
||||||
|
workspaceRoots.push(root);
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cardActionValues(card: unknown): unknown[] {
|
||||||
|
if (typeof card !== "object" || card === null || !("elements" in card)) return [];
|
||||||
|
const elements = (card as { elements?: unknown }).elements;
|
||||||
|
if (!Array.isArray(elements)) return [];
|
||||||
|
return elements.flatMap((element) => {
|
||||||
|
if (typeof element !== "object" || element === null || !("actions" in element)) return [];
|
||||||
|
const actions = (element as { actions?: unknown }).actions;
|
||||||
|
if (!Array.isArray(actions)) return [];
|
||||||
|
return actions.map((action) => {
|
||||||
|
if (typeof action !== "object" || action === null || !("value" in action)) return undefined;
|
||||||
|
return (action as { value?: unknown }).value;
|
||||||
|
}).filter((value): value is unknown => value !== undefined);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function cardHeaderTitle(card: unknown): string | undefined {
|
||||||
|
if (typeof card !== "object" || card === null || !("header" in card)) return undefined;
|
||||||
|
const header = (card as { header?: unknown }).header;
|
||||||
|
if (typeof header !== "object" || header === null || !("title" in header)) return undefined;
|
||||||
|
const title = (header as { title?: unknown }).title;
|
||||||
|
if (typeof title !== "object" || title === null || !("content" in title)) return undefined;
|
||||||
|
const content = (title as { content?: unknown }).content;
|
||||||
|
return typeof content === "string" ? content : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function cardHasInterruptedFooter(card: unknown): boolean {
|
function cardHasInterruptedFooter(card: unknown): boolean {
|
||||||
if (typeof card !== "object" || card === null) return false;
|
if (typeof card !== "object" || card === null) return false;
|
||||||
if (!("elements" in card)) return false;
|
if (!("elements" in card)) return false;
|
||||||
|
|||||||
Reference in New Issue
Block a user