diff --git a/docs/para-26071100-feishu-setup.md b/docs/para-26071100-feishu-setup.md index a6ab17f..da2ceee 100644 --- a/docs/para-26071100-feishu-setup.md +++ b/docs/para-26071100-feishu-setup.md @@ -72,8 +72,10 @@ Educraft 机器人以应用身份调用上述 API,因此这些 scope 全部放 1. 在“事件配置”中将订阅方式设为“使用长连接接收事件”。 2. 添加事件“接收消息” `im.message.receive_v1`。 -3. 在“回调配置”中同样选择长连接。 -4. 添加回调“卡片回传交互” `card.action.trigger`,用于审批、运行中断和项目创建/绑定按钮。 +3. 添加事件“解散群” `im.chat.disbanded_v1`,用于立即归档该群的项目绑定。 +4. 添加事件“机器人被移出群” `im.chat.member.bot.deleted_v1`,用于立即归档该群的项目绑定。 +5. 在“回调配置”中同样选择长连接。 +6. 添加回调“卡片回传交互” `card.action.trigger`,用于审批、运行中断和项目创建/绑定按钮。 ![长连接与消息事件配置](assets/feishu-setup/03-events.png) @@ -174,7 +176,7 @@ App ID:cli_... App Secret:(通过安全渠道单独发送) 应用已发布:是 / 否 机器人能力已启用:是 / 否 -消息事件和卡片回调已配置:是 / 否 +消息事件(含解散群、机器人被移出群)和卡片回调已配置:是 / 否 OAuth 重定向 URL 已配置:是 / 否 【首位 OWNER】 diff --git a/hub/src/feishu/bindingLifecycle.ts b/hub/src/feishu/bindingLifecycle.ts new file mode 100644 index 0000000..8192eec --- /dev/null +++ b/hub/src/feishu/bindingLifecycle.ts @@ -0,0 +1,101 @@ +import type { PrismaClient } from "@prisma/client"; +import { + requireActiveFeishuApplicationConnectionInTransaction, + scopedFeishuPrincipalId, +} from "./identityNamespace.js"; +import { lockActiveOrganization } from "../org/status.js"; + +export type FeishuBindingLifecycleReason = "chat_dissolved" | "bot_removed"; + +/** + * Archive a project's active Feishu chat binding after Feishu has notified this + * bot that the chat dissolved or that the bot was removed. The event can be + * redelivered; only the first delivery changes state and writes an audit row. + */ +export async function archiveFeishuBindingForLifecycleEvent( + prisma: PrismaClient, + input: { + readonly chatId: string; + readonly eventId: string; + readonly reason: FeishuBindingLifecycleReason; + }, +): Promise<{ readonly archived: boolean; readonly projectId?: string | undefined }> { + const chatId = requireNonEmpty(input.chatId, "Feishu chat id"); + const eventId = requireNonEmpty(input.eventId, "Feishu event id"); + return prisma.$transaction(async (tx) => { + // Feishu WS delivery is at-least-once. Persist the receipt in the same + // transaction as the archive so a failed archive remains eligible for a + // retry, while an old redelivery cannot archive a later re-binding. + const receipt = await tx.feishuEventReceipt.createMany({ + data: { + eventId, + eventType: lifecycleEventType(input.reason), + }, + skipDuplicates: true, + }); + if (receipt.count === 0) return { archived: false }; + + const binding = await tx.projectGroupBinding.findFirst({ + where: { chatId, archivedAt: null }, + select: { id: true, projectId: true, project: { select: { organizationId: true } } }, + }); + if (binding === null) return { archived: false }; + + await lockActiveOrganization(tx, binding.project.organizationId); + const connection = await tx.organizationFeishuApplicationConnection.findUnique({ + where: { organizationId: binding.project.organizationId }, + select: { id: true }, + }); + if (connection === null) { + throw new Error(`Feishu application connection not found for organization ${binding.project.organizationId}`); + } + const activeConnection = await requireActiveFeishuApplicationConnectionInTransaction(tx, connection.id); + if (activeConnection.organizationId !== binding.project.organizationId) { + throw new Error("Feishu application connection Organization scope mismatch"); + } + const now = new Date(); + const archived = await tx.projectGroupBinding.updateMany({ + where: { id: binding.id, archivedAt: null }, + data: { archivedAt: now }, + }); + if (archived.count === 0) return { archived: false }; + + await tx.permissionGrant.updateMany({ + where: { + resourceType: "PROJECT", + resourceId: binding.projectId, + principalType: "FEISHU_CHAT", + // Bindings created before ADR-0024 used the raw chat id. Retire only + // that exact legacy principal alongside the current scoped principal. + principalId: { + in: [scopedFeishuPrincipalId("CHAT", connection.id, chatId), chatId], + }, + revokedAt: null, + }, + data: { revokedAt: now }, + }); + await tx.auditEntry.create({ + data: { + organizationId: binding.project.organizationId, + projectId: binding.projectId, + action: "project.chat_binding_archived", + metadata: { + chatId, + reason: input.reason, + eventId, + }, + }, + }); + return { archived: true, projectId: binding.projectId }; + }); +} + +function lifecycleEventType(reason: FeishuBindingLifecycleReason): string { + return reason === "chat_dissolved" ? "im.chat.disbanded_v1" : "im.chat.member.bot.deleted_v1"; +} + +function requireNonEmpty(value: string, label: string): string { + const trimmed = value.trim(); + if (trimmed === "") throw new Error(`${label} is required`); + return trimmed; +} diff --git a/hub/src/feishu/client.ts b/hub/src/feishu/client.ts index 8a1b51f..d9d7b64 100644 --- a/hub/src/feishu/client.ts +++ b/hub/src/feishu/client.ts @@ -106,6 +106,13 @@ export interface CardActionEvent { readonly token?: string; } +/** A binding-ending Feishu event delivered to this application's bot. */ +export interface FeishuBindingLifecycleEvent { + readonly eventId: string; + readonly chatId: string; + readonly reason: "chat_dissolved" | "bot_removed"; +} + interface ContactBasicUser { readonly name?: string | undefined; readonly i18n_name?: { @@ -922,6 +929,7 @@ export async function startFeishuListenerWithClient( onMessage: (event: MessageReceiveEvent, rt: FeishuRuntime) => Promise, onCardAction?: (event: CardActionEvent, rt: FeishuRuntime) => Promise, onTerminalError?: (error: Error) => void, + onBindingLifecycle?: (event: FeishuBindingLifecycleEvent) => Promise, ): Promise { let state: "STARTING" | "READY" | "FAILED" = "STARTING"; let resolveReady!: () => void; @@ -963,6 +971,14 @@ export async function startFeishuListenerWithClient( .catch((e) => { logger.error({ err: e }, "feishu card action handler threw"); }); }; } + if (onBindingLifecycle !== undefined) { + handlers["im.chat.disbanded_v1"] = async (data) => { + await dispatchBindingLifecycleEvent(data, "chat_dissolved", onBindingLifecycle, logger); + }; + handlers["im.chat.member.bot.deleted_v1"] = async (data) => { + await dispatchBindingLifecycleEvent(data, "bot_removed", onBindingLifecycle, logger); + }; + } await wsClient.start({ eventDispatcher: new lark.EventDispatcher({}).register(handlers) }); let timeout: ReturnType | undefined; const startupTimeout = new Promise((_resolve, reject) => { @@ -977,6 +993,36 @@ export async function startFeishuListenerWithClient( return rt; } +async function dispatchBindingLifecycleEvent( + data: unknown, + reason: FeishuBindingLifecycleEvent["reason"], + onBindingLifecycle: (event: FeishuBindingLifecycleEvent) => Promise, + logger: FastifyBaseLogger, +): Promise { + const event = bindingLifecycleEventFrom(data, reason); + if (event === null) { + logger.warn({ reason }, "feishu binding lifecycle event missing chat id"); + return; + } + await onBindingLifecycle(event); +} + +function bindingLifecycleEventFrom( + data: unknown, + reason: FeishuBindingLifecycleEvent["reason"], +): FeishuBindingLifecycleEvent | null { + if (typeof data !== "object" || data === null) return null; + const event = data as { readonly chat_id?: unknown; readonly event_id?: unknown; readonly header?: { readonly event_id?: unknown } }; + if (typeof event.chat_id !== "string" || event.chat_id.trim() === "") return null; + const eventId = typeof event.event_id === "string" && event.event_id !== "" + ? event.event_id + : typeof event.header?.event_id === "string" && event.header.event_id !== "" + ? event.header.event_id + : undefined; + if (eventId === undefined) return null; + return { chatId: event.chat_id, reason, eventId }; +} + export async function startFeishuListener( config: FeishuConfig, logger: FastifyBaseLogger, diff --git a/hub/src/feishu/identityNamespace.ts b/hub/src/feishu/identityNamespace.ts index 17156d5..6e4da27 100644 --- a/hub/src/feishu/identityNamespace.ts +++ b/hub/src/feishu/identityNamespace.ts @@ -66,7 +66,7 @@ export async function upsertScopedFeishuIdentityInTransaction( const principalId = scopedFeishuPrincipalId("USER", connectionId, openId); const userId = deterministicFeishuUserId(connectionId, openId); - const connection = await lockActiveConnection(prisma, connectionId); + const connection = await requireActiveFeishuApplicationConnectionInTransaction(prisma, connectionId); if (input.expectedOrganizationId !== undefined && input.expectedOrganizationId !== connection.organizationId) { throw new Error("Feishu identity Organization scope mismatch"); @@ -114,7 +114,7 @@ export async function resolveScopedFeishuIdentity( const connectionId = nonEmpty(input.connectionId, "Feishu connectionId"); const openId = nonEmpty(input.openId, "Feishu openId"); return prisma.$transaction(async (tx) => { - const connection = await lockActiveConnection(tx, connectionId); + const connection = await requireActiveFeishuApplicationConnectionInTransaction(tx, connectionId); if (input.expectedOrganizationId !== undefined && input.expectedOrganizationId !== connection.organizationId) { throw new Error("Feishu identity Organization scope mismatch"); @@ -127,7 +127,12 @@ export async function resolveScopedFeishuIdentity( }); } -async function lockActiveConnection( +/** + * Lock and prove that a Feishu application connection is currently usable. + * Callers handling provider-originated data use this before trusting a + * connection-scoped identifier. + */ +export async function requireActiveFeishuApplicationConnectionInTransaction( prisma: Prisma.TransactionClient, connectionId: string, ): Promise<{ readonly organizationId: string }> { diff --git a/hub/src/hub.ts b/hub/src/hub.ts index bc24897..a165496 100644 --- a/hub/src/hub.ts +++ b/hub/src/hub.ts @@ -2,6 +2,7 @@ import Fastify from "fastify"; import { registerAdminPlugin } from "./admin/plugin.js"; import { prisma } from "./db.js"; import { createLarkClient, startFeishuListenerWithClient } from "./feishu/client.js"; +import { archiveFeishuBindingForLifecycleEvent } from "./feishu/bindingLifecycle.js"; import { makeTriggerHandler } from "./feishu/trigger.js"; import { removeAbandonedMessageResourceStages } from "./feishu/resourceStaging.js"; import { triggerQueue } from "./feishu/triggerQueue.js"; @@ -177,6 +178,10 @@ export async function startHub(): Promise { process.exitCode = 1; void app.close().catch((error) => app.log.error({ err: error }, "Hub close after Feishu failure failed")); }, + async (event) => { + const result = await archiveFeishuBindingForLifecycleEvent(prisma, event); + app.log.info({ ...event, archived: result.archived, projectId: result.projectId }, "feishu binding lifecycle event handled"); + }, ); } else { app.log.info("feishu listener disabled by HUB_FEISHU_LISTENER_ENABLED"); diff --git a/hub/test/integration/feishu-binding-lifecycle.test.ts b/hub/test/integration/feishu-binding-lifecycle.test.ts new file mode 100644 index 0000000..d3deec6 --- /dev/null +++ b/hub/test/integration/feishu-binding-lifecycle.test.ts @@ -0,0 +1,176 @@ +import { afterAll, beforeEach, describe, expect, it } from "vitest"; +import { archiveFeishuBindingForLifecycleEvent } from "../../src/feishu/bindingLifecycle.js"; +import { scopedFeishuPrincipalId } from "../../src/feishu/identityNamespace.js"; +import { DEFAULT_ORG_ID, prisma, resetDb, seedProject } from "./helpers.js"; + +const CONNECTION_ID = "feishu-connection-lifecycle"; + +describe("Feishu binding lifecycle events", () => { + beforeEach(async () => { + await resetDb(); + }); + + afterAll(async () => prisma.$disconnect()); + + it("archives a bound project and revokes its chat grant when the chat is dissolved", async () => { + await seedProject("project-dissolved", "chat-dissolved"); + await seedFeishuConnection(); + await prisma.permissionGrant.create({ + data: { + resourceType: "PROJECT", + resourceId: "project-dissolved", + principalType: "FEISHU_CHAT", + principalId: scopedFeishuPrincipalId("CHAT", CONNECTION_ID, "chat-dissolved"), + role: "EDIT", + }, + }); + await prisma.permissionGrant.create({ + data: { + resourceType: "PROJECT", + resourceId: "project-dissolved", + principalType: "FEISHU_CHAT", + principalId: "chat-dissolved", + role: "EDIT", + }, + }); + await prisma.permissionGrant.create({ + data: { + resourceType: "PROJECT", + resourceId: "project-dissolved", + principalType: "FEISHU_CHAT", + principalId: scopedFeishuPrincipalId("CHAT", CONNECTION_ID, "chat-unrelated"), + role: "EDIT", + }, + }); + + await expect(archiveFeishuBindingForLifecycleEvent(prisma, { + chatId: "chat-dissolved", + eventId: "event-dissolved", + reason: "chat_dissolved", + })).resolves.toEqual({ archived: true, projectId: "project-dissolved" }); + + await expect(prisma.projectGroupBinding.findFirst({ + where: { chatId: "chat-dissolved" }, + select: { archivedAt: true }, + })).resolves.toMatchObject({ archivedAt: expect.any(Date) }); + await expect(prisma.permissionGrant.findFirst({ + where: { + resourceId: "project-dissolved", + principalId: scopedFeishuPrincipalId("CHAT", CONNECTION_ID, "chat-dissolved"), + }, + select: { revokedAt: true }, + })).resolves.toMatchObject({ revokedAt: expect.any(Date) }); + await expect(prisma.permissionGrant.findFirst({ + where: { resourceId: "project-dissolved", principalId: "chat-dissolved" }, + select: { revokedAt: true }, + })).resolves.toMatchObject({ revokedAt: expect.any(Date) }); + await expect(prisma.permissionGrant.findFirst({ + where: { + resourceId: "project-dissolved", + principalId: scopedFeishuPrincipalId("CHAT", CONNECTION_ID, "chat-unrelated"), + }, + select: { revokedAt: true }, + })).resolves.toEqual({ revokedAt: null }); + await expect(prisma.auditEntry.findFirst({ + where: { projectId: "project-dissolved", action: "project.chat_binding_archived" }, + select: { organizationId: true, metadata: true }, + })).resolves.toEqual({ + organizationId: DEFAULT_ORG_ID, + metadata: { chatId: "chat-dissolved", eventId: "event-dissolved", reason: "chat_dissolved" }, + }); + }); + + it("is idempotent when Feishu redelivers a lifecycle event", async () => { + await seedProject("project-redelivery", "chat-redelivery"); + await seedFeishuConnection(); + + await archiveFeishuBindingForLifecycleEvent(prisma, { + chatId: "chat-redelivery", + eventId: "event-redelivery", + reason: "bot_removed", + }); + await expect(archiveFeishuBindingForLifecycleEvent(prisma, { + chatId: "chat-redelivery", + eventId: "event-redelivery", + reason: "bot_removed", + })).resolves.toEqual({ archived: false }); + + await expect(prisma.auditEntry.count({ + where: { projectId: "project-redelivery", action: "project.chat_binding_archived" }, + })).resolves.toBe(1); + }); + + it("does not archive a later binding when an old lifecycle event is redelivered", async () => { + await seedProject("project-original", "chat-rebound"); + await prisma.project.create({ + data: { + id: "project-rebound", + organizationId: DEFAULT_ORG_ID, + name: "Rebound project", + workspaceDir: "/tmp/test-project-rebound", + }, + }); + await seedFeishuConnection(); + + await archiveFeishuBindingForLifecycleEvent(prisma, { + chatId: "chat-rebound", + eventId: "event-before-rebind", + reason: "bot_removed", + }); + await prisma.projectGroupBinding.create({ + data: { projectId: "project-rebound", chatId: "chat-rebound" }, + }); + + await expect(archiveFeishuBindingForLifecycleEvent(prisma, { + chatId: "chat-rebound", + eventId: "event-before-rebind", + reason: "bot_removed", + })).resolves.toEqual({ archived: false }); + await expect(prisma.projectGroupBinding.findFirst({ + where: { projectId: "project-rebound", archivedAt: null }, + select: { id: true }, + })).resolves.not.toBeNull(); + }); + + it("leaves unrelated active bindings untouched", async () => { + await seedProject("project-kept", "chat-kept"); + + await expect(archiveFeishuBindingForLifecycleEvent(prisma, { + chatId: "chat-unknown", + eventId: "event-unknown", + reason: "chat_dissolved", + })).resolves.toEqual({ archived: false }); + + await expect(prisma.projectGroupBinding.findFirst({ + where: { chatId: "chat-kept", archivedAt: null }, + select: { id: true }, + })).resolves.not.toBeNull(); + }); +}); + +async function seedFeishuConnection(): Promise { + await prisma.organizationFeishuApplicationConnection.create({ + data: { + id: CONNECTION_ID, + organizationId: DEFAULT_ORG_ID, + appIdentityFingerprint: "fingerprint-lifecycle", + }, + }); + await prisma.feishuApplicationCredentialVersion.create({ + data: { + id: "feishu-secret-version-lifecycle", + connectionId: CONNECTION_ID, + version: 1, + keyId: "test-active", + envelope: { fixture: true }, + }, + }); + await prisma.organizationFeishuApplicationConnection.update({ + where: { id: CONNECTION_ID }, + data: { + status: "ACTIVE", + activeSecretVersionId: "feishu-secret-version-lifecycle", + activatedAt: new Date(), + }, + }); +} diff --git a/hub/test/unit/feishu-card-action-ack.test.ts b/hub/test/unit/feishu-card-action-ack.test.ts index 9d9b6f6..7025a8d 100644 --- a/hub/test/unit/feishu-card-action-ack.test.ts +++ b/hub/test/unit/feishu-card-action-ack.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; import type { FastifyBaseLogger } from "fastify"; -import type { CardActionEvent, FeishuRuntime } from "../../src/feishu/client.js"; +import type { CardActionEvent, FeishuBindingLifecycleEvent, FeishuRuntime } from "../../src/feishu/client.js"; import { startFeishuListenerWithClient } from "../../src/feishu/client.js"; const larkMock = vi.hoisted(() => { @@ -80,6 +80,57 @@ describe("Feishu card action callbacks", () => { expect(logger.error).toHaveBeenCalledWith({ err }, "feishu card action handler threw"); }); }); + + it("dispatches chat dissolution and bot-removal lifecycle events", async () => { + const onLifecycle = vi.fn(async (_event: FeishuBindingLifecycleEvent) => {}); + await startFeishuListenerWithClient( + { appId: "app-id", appSecret: "app-secret", botOpenId: "bot-open-id" }, + fakeClient(), + silentLogger(), + async () => {}, + undefined, + undefined, + onLifecycle, + ); + + await lifecycleHandler("im.chat.disbanded_v1")({ + event_id: "event-dissolved", + chat_id: "chat-dissolved", + }); + await lifecycleHandler("im.chat.member.bot.deleted_v1")({ + header: { event_id: "event-bot-removed" }, + chat_id: "chat-bot-removed", + }); + + expect(onLifecycle).toHaveBeenNthCalledWith(1, { + eventId: "event-dissolved", + chatId: "chat-dissolved", + reason: "chat_dissolved", + }); + expect(onLifecycle).toHaveBeenNthCalledWith(2, { + eventId: "event-bot-removed", + chatId: "chat-bot-removed", + reason: "bot_removed", + }); + }); + + it("propagates lifecycle archival failures to the WS dispatcher", async () => { + const failure = new Error("archive failed"); + await startFeishuListenerWithClient( + { appId: "app-id", appSecret: "app-secret", botOpenId: "bot-open-id" }, + fakeClient(), + silentLogger(), + async () => {}, + undefined, + undefined, + async () => { throw failure; }, + ); + + await expect(lifecycleHandler("im.chat.disbanded_v1")({ + event_id: "event-failure", + chat_id: "chat-failure", + })).rejects.toThrow(failure); + }); }); function cardActionHandler(): (data: unknown) => Promise { @@ -91,6 +142,15 @@ function cardActionHandler(): (data: unknown) => Promise { return handler; } +function lifecycleHandler(eventType: string): (data: unknown) => Promise { + const start = larkMock.starts[0]; + if (start === undefined) throw new Error("WSClient.start was not called"); + const dispatcher = start.eventDispatcher as { readonly handlers?: Record Promise> }; + const handler = dispatcher.handlers?.[eventType]; + if (handler === undefined) throw new Error(`${eventType} handler was not registered`); + return handler; +} + function makeCardActionEvent(): CardActionEvent { return { operator: { open_id: "ou_user" },