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
+185 -5
View File
@@ -1,5 +1,8 @@
import { describe, it, expect, beforeEach, afterAll, vi } from "vitest";
import { prisma, resetDb, mockFeishuRuntime, seedProject, silentLogger } from "./helpers.js";
import { mkdtemp, rm } from "node:fs/promises";
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 { makeTriggerHandler, extractPrompt } from "../../src/feishu/trigger.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" };
type TestRunner = (req: RunRequest) => Promise<RunResult>;
const workspaceRoots: string[] = [];
function makeTestSettings(models: InMemoryModelRegistry): RuntimeSettings {
return {
@@ -102,6 +106,13 @@ describe("trigger full lifecycle (integration)", () => {
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 () => {
await seedProject("proj-1", "chat-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");
});
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 () => {
await seedProject("proj-1b", "chat-1b");
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");
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);
const runs = await prisma.agentRun.findMany();
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 {
if (typeof card !== "object" || card === null) return false;
if (!("elements" in card)) return false;