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
+3
View File
@@ -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=""
+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 { 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
View File
@@ -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");
+185 -5
View File
@@ -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;