forked from EduCraft/curriculum-project-hub
fix: guide Feishu users through onboarding
This commit is contained in:
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@paradigm/hub",
|
||||
"version": "0.0.10",
|
||||
"version": "0.0.11",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@paradigm/hub",
|
||||
"version": "0.0.10",
|
||||
"version": "0.0.11",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.202",
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@paradigm/hub",
|
||||
"version": "0.0.10",
|
||||
"version": "0.0.11",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
|
||||
@@ -85,6 +85,10 @@ interface TriggerDeps {
|
||||
readonly messageBatcherOptions?: MessageBatcherOptions | undefined;
|
||||
readonly triggerQueue?: TriggerQueue | undefined;
|
||||
readonly projectWorkspaceRoot: string;
|
||||
/** Public origin used to build the Silo Organization's Feishu OAuth entrypoint. */
|
||||
readonly publicBaseUrl: string;
|
||||
/** The single Organization this Alpha Silo is fail-closed to serve. */
|
||||
readonly siloOrganizationId: string;
|
||||
/** Alpha Silo policy: never acknowledge work into the process-local queue. */
|
||||
readonly rejectWhenBusy?: boolean | undefined;
|
||||
readonly resourceLimits?: {
|
||||
@@ -133,6 +137,9 @@ export interface TriggerHandler {
|
||||
export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
const projectWorkspaceRoot = deps.projectWorkspaceRoot.trim();
|
||||
if (projectWorkspaceRoot === "") throw new Error("projectWorkspaceRoot is required");
|
||||
const publicBaseUrl = parsePublicBaseUrl(deps.publicBaseUrl);
|
||||
const siloOrganizationId = deps.siloOrganizationId.trim();
|
||||
if (siloOrganizationId === "") throw new Error("siloOrganizationId is required");
|
||||
const authorizer = deps.authorizer ?? createPermissionAuthorizer(deps.prisma);
|
||||
const runAgent = deps.runAgent ?? defaultRunAgent;
|
||||
const approvalManager = new ApprovalManager();
|
||||
@@ -936,6 +943,15 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
await sendText(rt, chatId, "无法识别发送者,拒绝触发。", sendOptionsForTriggerMessage(msg));
|
||||
return;
|
||||
}
|
||||
const organizationResolution = await resolveSingleActiveOrganizationForFeishuUser(senderOpenId);
|
||||
if (organizationResolution.status === "error" && organizationResolution.reason !== "organization_unavailable") {
|
||||
deps.logger.info(
|
||||
{ projectId, senderOpenId, reason: organizationResolution.reason },
|
||||
"feishu trigger: actor onboarding required before project authorization",
|
||||
);
|
||||
await sendText(rt, chatId, organizationResolution.message, sendOptionsForTriggerMessage(msg));
|
||||
return;
|
||||
}
|
||||
const actor = { feishuOpenId: senderOpenId, chatId };
|
||||
const triggerDecision = await authorizer.can({
|
||||
actor,
|
||||
@@ -1083,17 +1099,33 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
readonly organizationName: string;
|
||||
readonly role: "OWNER" | "ADMIN" | "MEMBER";
|
||||
}
|
||||
| { readonly status: "error"; readonly message: string }
|
||||
| {
|
||||
readonly status: "error";
|
||||
readonly reason: "identity_missing" | "membership_missing" | "organization_unavailable";
|
||||
readonly message: string;
|
||||
}
|
||||
> {
|
||||
const siloOrganization = await deps.prisma.organization.findFirst({
|
||||
where: { id: siloOrganizationId, status: "ACTIVE" },
|
||||
select: { id: true, slug: true },
|
||||
});
|
||||
if (siloOrganization === null) {
|
||||
return {
|
||||
status: "error",
|
||||
reason: "organization_unavailable",
|
||||
message: "组织当前不可用,请联系 Educraft 运维人员。",
|
||||
};
|
||||
}
|
||||
const identity = await deps.prisma.feishuUserIdentity.findFirst({
|
||||
where: {
|
||||
openId: feishuOpenId,
|
||||
connection: { status: "ACTIVE", organization: { status: "ACTIVE" } },
|
||||
connection: { status: "ACTIVE", organizationId: siloOrganization.id },
|
||||
},
|
||||
select: {
|
||||
user: { select: { organizationMemberships: {
|
||||
where: {
|
||||
revokedAt: null,
|
||||
organizationId: siloOrganization.id,
|
||||
organization: { status: "ACTIVE" },
|
||||
},
|
||||
select: {
|
||||
@@ -1109,19 +1141,34 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
where: { feishuOpenId },
|
||||
select: {
|
||||
organizationMemberships: {
|
||||
where: { revokedAt: null, organization: { status: "ACTIVE" } },
|
||||
where: {
|
||||
revokedAt: null,
|
||||
organizationId: siloOrganization.id,
|
||||
organization: { status: "ACTIVE" },
|
||||
},
|
||||
select: { role: true, organization: { select: { id: true, name: true } } },
|
||||
orderBy: { createdAt: "asc" },
|
||||
},
|
||||
},
|
||||
})
|
||||
: null);
|
||||
if (user === null) return { status: "error", message: "请先登录并加入组织后,再绑定项目。" };
|
||||
if (user.organizationMemberships.length === 0) {
|
||||
return { status: "error", message: "你还不属于任何可用组织,请联系组织管理员。" };
|
||||
if (user === null) {
|
||||
const loginUrl = new URL(
|
||||
`/auth/feishu/${encodeURIComponent(siloOrganization.slug)}`,
|
||||
publicBaseUrl,
|
||||
).toString();
|
||||
return {
|
||||
status: "error",
|
||||
reason: "identity_missing",
|
||||
message: `请先通过飞书登录建立身份:${loginUrl}\n登录后仍需由组织管理员将你加入组织。`,
|
||||
};
|
||||
}
|
||||
if (user.organizationMemberships.length > 1) {
|
||||
return { status: "error", message: "你属于多个组织。请先从组织后台选择项目绑定,或等待多组织选择入口。" };
|
||||
if (user.organizationMemberships.length === 0) {
|
||||
return {
|
||||
status: "error",
|
||||
reason: "membership_missing",
|
||||
message: "你已完成飞书登录,但尚未加入该组织。请联系组织管理员为你开通成员权限。",
|
||||
};
|
||||
}
|
||||
const membership = user.organizationMemberships[0]!;
|
||||
return {
|
||||
@@ -1184,6 +1231,19 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
return Object.assign(onMessage, { onCardAction, approvalManager });
|
||||
}
|
||||
|
||||
function parsePublicBaseUrl(raw: string): URL {
|
||||
const value = raw.trim();
|
||||
if (value === "") throw new Error("publicBaseUrl is required");
|
||||
const url = new URL(value);
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
||||
throw new Error("publicBaseUrl must use http or https");
|
||||
}
|
||||
if (url.username !== "" || url.password !== "" || url.search !== "" || url.hash !== "" || url.pathname !== "/") {
|
||||
throw new Error("publicBaseUrl must be an origin without credentials, path, query, or fragment");
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
interface SessionMetadata {
|
||||
readonly claudeSessionId?: string | undefined;
|
||||
}
|
||||
|
||||
@@ -160,6 +160,8 @@ export async function startHub(): Promise<void> {
|
||||
settings: runtimeSettings,
|
||||
logger: app.log,
|
||||
projectWorkspaceRoot,
|
||||
publicBaseUrl,
|
||||
siloOrganizationId: siloOrganization.id,
|
||||
rejectWhenBusy: true,
|
||||
resourceLimits: { maxFilesPerMessage, maxBytesPerFile },
|
||||
allowLegacyFeishuIdentity: false,
|
||||
|
||||
@@ -3,7 +3,15 @@ import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { Readable } from "node:stream";
|
||||
import { describe, it, expect, beforeEach, afterEach, afterAll, vi } from "vitest";
|
||||
import { DEFAULT_ORG_ID, prisma, resetDb, mockFeishuRuntime, seedProject, silentLogger } from "./helpers.js";
|
||||
import {
|
||||
DEFAULT_ORG_ID,
|
||||
prisma,
|
||||
resetDb,
|
||||
mockFeishuRuntime,
|
||||
seedProject,
|
||||
seedTestOrganization,
|
||||
silentLogger,
|
||||
} from "./helpers.js";
|
||||
import { InMemoryModelRegistry } from "../../src/agent/models.js";
|
||||
import { createSlashCommandRegistry } from "../../src/feishu/slashCommands.js";
|
||||
import { makeTriggerHandler as makeProductionTriggerHandler, extractPrompt } from "../../src/feishu/trigger.js";
|
||||
@@ -24,6 +32,8 @@ type TestTriggerDeps = Omit<Parameters<typeof makeProductionTriggerHandler>[0],
|
||||
function makeTriggerHandler(deps: TestTriggerDeps): ReturnType<typeof makeProductionTriggerHandler> {
|
||||
return makeProductionTriggerHandler({
|
||||
projectWorkspaceRoot: "/tmp",
|
||||
publicBaseUrl: "https://educraft.example.test",
|
||||
siloOrganizationId: DEFAULT_ORG_ID,
|
||||
allowLegacyFeishuIdentity: true,
|
||||
...deps,
|
||||
});
|
||||
@@ -386,6 +396,18 @@ describe("trigger full lifecycle (integration)", () => {
|
||||
role: "EDIT",
|
||||
},
|
||||
});
|
||||
await prisma.user.createMany({
|
||||
data: [
|
||||
{ id: "u-speaker-alice", feishuOpenId: "ou_alice", displayName: "Alice" },
|
||||
{ id: "u-speaker-bob", feishuOpenId: "ou_bob", displayName: "Bob" },
|
||||
],
|
||||
});
|
||||
await prisma.organizationMembership.createMany({
|
||||
data: [
|
||||
{ organizationId: DEFAULT_ORG_ID, userId: "u-speaker-alice", role: "MEMBER" },
|
||||
{ organizationId: DEFAULT_ORG_ID, userId: "u-speaker-bob", role: "MEMBER" },
|
||||
],
|
||||
});
|
||||
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
|
||||
|
||||
await trigger(makeEvent("chat-speaker", "@_user_1 Alice 的需求", "ou_alice"), rt);
|
||||
@@ -626,6 +648,66 @@ describe("trigger full lifecycle (integration)", () => {
|
||||
expect(runs).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("gives an unknown user the scoped login URL in an already-bound chat", async () => {
|
||||
await seedProject("proj-bound-unknown", "chat-bound-unknown");
|
||||
const trigger = makeTriggerHandler({
|
||||
prisma,
|
||||
settings,
|
||||
logger: silentLogger,
|
||||
runAgent,
|
||||
allowLegacyFeishuIdentity: false,
|
||||
messageBatcherOptions: { maxMessages: 1 },
|
||||
});
|
||||
|
||||
await trigger(makeEvent("chat-bound-unknown", "@_user_1 写教案", "ou_bound_unknown"), rt);
|
||||
|
||||
expect(rt.sentTexts).toContain(
|
||||
"请先通过飞书登录建立身份:https://educraft.example.test/auth/feishu/test-default\n" +
|
||||
"登录后仍需由组织管理员将你加入组织。",
|
||||
);
|
||||
expect(rt.sentTexts).not.toContain("无权限触发。");
|
||||
expect(runAgentCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("tells a logged-in non-member to contact the administrator in an already-bound chat", async () => {
|
||||
await seedProject("proj-bound-non-member", "chat-bound-non-member");
|
||||
await seedScopedIdentityWithoutMembership("bound-non-member", "ou_bound_non_member");
|
||||
const trigger = makeTriggerHandler({
|
||||
prisma,
|
||||
settings,
|
||||
logger: silentLogger,
|
||||
runAgent,
|
||||
allowLegacyFeishuIdentity: false,
|
||||
messageBatcherOptions: { maxMessages: 1 },
|
||||
});
|
||||
|
||||
await trigger(makeEvent("chat-bound-non-member", "@_user_1 写教案", "ou_bound_non_member"), rt);
|
||||
|
||||
expect(rt.sentTexts).toContain(
|
||||
"你已完成飞书登录,但尚未加入该组织。请联系组织管理员为你开通成员权限。",
|
||||
);
|
||||
expect(rt.sentTexts).not.toContain("无权限触发。");
|
||||
expect(runAgentCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("keeps generic project denial for an Organization member without project permission", async () => {
|
||||
await seedProject("proj-bound-read-only", "chat-bound-read-only", { role: "READ" });
|
||||
const trigger = makeTriggerHandler({
|
||||
prisma,
|
||||
settings,
|
||||
logger: silentLogger,
|
||||
runAgent,
|
||||
messageBatcherOptions: { maxMessages: 1 },
|
||||
});
|
||||
|
||||
await trigger(makeEvent("chat-bound-read-only", "@_user_1 写教案"), rt);
|
||||
|
||||
expect(rt.sentTexts).toContain("无权限触发。");
|
||||
expect(rt.sentTexts.join("\n")).not.toContain("/auth/feishu/");
|
||||
expect(rt.sentTexts.join("\n")).not.toContain("尚未加入该组织");
|
||||
expect(runAgentCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it.each(["SUSPENDED", "ARCHIVED"] as const)(
|
||||
"rejects triggers and resume commands when the organization is %s",
|
||||
async (status) => {
|
||||
@@ -922,18 +1004,62 @@ describe("trigger full lifecycle (integration)", () => {
|
||||
await expect(readdir(join(workspaceRoot, ".cph-staging"))).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("does not create a run for unbound chats and asks unknown users to log in", async () => {
|
||||
it("does not create a run for unbound chats and gives unknown users the scoped login URL", 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 写教案", "ou_unknown_user"), rt);
|
||||
|
||||
expect(rt.sentTexts).toContain("请先登录并加入组织后,再绑定项目。");
|
||||
expect(rt.sentTexts).toContain(
|
||||
"请先通过飞书登录建立身份:https://educraft.example.test/auth/feishu/test-default\n" +
|
||||
"登录后仍需由组织管理员将你加入组织。",
|
||||
);
|
||||
expect(rt.sentCards).toHaveLength(0);
|
||||
const runs = await prisma.agentRun.findMany();
|
||||
expect(runs).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("distinguishes a scoped Feishu identity that has not joined the Silo Organization", async () => {
|
||||
await seedScopedIdentityWithoutMembership("unbound-non-member", "ou_logged_in_not_member");
|
||||
const trigger = makeTriggerHandler({
|
||||
prisma,
|
||||
settings,
|
||||
logger: silentLogger,
|
||||
runAgent,
|
||||
allowLegacyFeishuIdentity: false,
|
||||
messageBatcherOptions: { maxMessages: 1 },
|
||||
});
|
||||
|
||||
await trigger(makeEvent("chat-unbound", "@_user_1 写教案", "ou_logged_in_not_member"), rt);
|
||||
|
||||
expect(rt.sentTexts).toContain(
|
||||
"你已完成飞书登录,但尚未加入该组织。请联系组织管理员为你开通成员权限。",
|
||||
);
|
||||
expect(rt.sentTexts.join("\n")).not.toContain("/auth/feishu/");
|
||||
await expect(prisma.agentRun.count()).resolves.toBe(0);
|
||||
});
|
||||
|
||||
it("encodes the configured Organization slug in the OAuth login URL", async () => {
|
||||
const encodedOrgId = "org_url_encoding";
|
||||
await seedTestOrganization(encodedOrgId, "school east/数学?");
|
||||
const trigger = makeTriggerHandler({
|
||||
prisma,
|
||||
settings,
|
||||
logger: silentLogger,
|
||||
runAgent,
|
||||
siloOrganizationId: encodedOrgId,
|
||||
publicBaseUrl: "https://school.example.test/",
|
||||
messageBatcherOptions: { maxMessages: 1 },
|
||||
});
|
||||
|
||||
await trigger(makeEvent("chat-unbound", "@_user_1 写教案", "ou_unknown_encoded"), rt);
|
||||
|
||||
expect(rt.sentTexts.join("\n")).toContain(
|
||||
"https://school.example.test/auth/feishu/school%20east%2F%E6%95%B0%E5%AD%A6%3F",
|
||||
);
|
||||
expect(rt.sentTexts.join("\n")).not.toContain("school east/数学?");
|
||||
});
|
||||
|
||||
it("ignores messages without @bot mention", async () => {
|
||||
await seedProject("proj-5", "chat-5");
|
||||
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
|
||||
@@ -1430,6 +1556,28 @@ async function seedOnboardingUser(id: string, feishuOpenId: string, role: "OWNER
|
||||
});
|
||||
}
|
||||
|
||||
async function seedScopedIdentityWithoutMembership(id: string, openId: string): Promise<void> {
|
||||
const connectionId = `feishu-connection-${id}`;
|
||||
await prisma.organizationFeishuApplicationConnection.create({
|
||||
data: {
|
||||
id: connectionId,
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
appIdentityFingerprint: `fingerprint-${id}`,
|
||||
status: "ACTIVE",
|
||||
},
|
||||
});
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
id: `user-${id}`,
|
||||
displayName: "Logged in user",
|
||||
feishuOpenId: `legacy-${openId}`,
|
||||
feishuIdentities: {
|
||||
create: { connectionId, openId },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function tempWorkspaceRoot(): Promise<string> {
|
||||
const root = await mkdtemp(join(tmpdir(), "cph-trigger-onboarding-"));
|
||||
workspaceRoots.push(root);
|
||||
|
||||
@@ -110,6 +110,8 @@ describe("Feishu approval cards", () => {
|
||||
logger: silentLogger(),
|
||||
authorizer: allowAllAuthorizer(),
|
||||
projectWorkspaceRoot: "/tmp",
|
||||
publicBaseUrl: "https://educraft.example.test",
|
||||
siloOrganizationId: "org_test_default",
|
||||
runAgent: async () => ({
|
||||
status: "completed",
|
||||
text: "",
|
||||
|
||||
@@ -103,6 +103,8 @@ async function triggerWithRunAgent(
|
||||
logger: rt.logger,
|
||||
authorizer: allowAllAuthorizer(),
|
||||
projectWorkspaceRoot: "/tmp",
|
||||
publicBaseUrl: "https://educraft.example.test",
|
||||
siloOrganizationId: "org_test_default",
|
||||
runAgent,
|
||||
messageBatcherOptions: { maxMessages: 1 },
|
||||
});
|
||||
@@ -254,6 +256,19 @@ function mockPrisma(): PrismaClient {
|
||||
const session = { id: "session-1", metadata: {} };
|
||||
|
||||
const client = {
|
||||
organization: {
|
||||
findFirst: vi.fn(async () => ({ id: "org_test_default", slug: "test-default" })),
|
||||
},
|
||||
feishuUserIdentity: {
|
||||
findFirst: vi.fn(async () => ({
|
||||
user: {
|
||||
organizationMemberships: [{
|
||||
role: "OWNER",
|
||||
organization: { id: "org_test_default", name: "Test Organization" },
|
||||
}],
|
||||
},
|
||||
})),
|
||||
},
|
||||
feishuEventReceipt: {
|
||||
findUnique: vi.fn(async () => null),
|
||||
create: vi.fn(async () => ({ id: "receipt-1" })),
|
||||
|
||||
Reference in New Issue
Block a user