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