forked from bai/curriculum-project-hub
feat: archive bindings on Feishu lifecycle events
This commit is contained in:
@@ -72,8 +72,10 @@ Educraft 机器人以应用身份调用上述 API,因此这些 scope 全部放
|
|||||||
|
|
||||||
1. 在“事件配置”中将订阅方式设为“使用长连接接收事件”。
|
1. 在“事件配置”中将订阅方式设为“使用长连接接收事件”。
|
||||||
2. 添加事件“接收消息” `im.message.receive_v1`。
|
2. 添加事件“接收消息” `im.message.receive_v1`。
|
||||||
3. 在“回调配置”中同样选择长连接。
|
3. 添加事件“解散群” `im.chat.disbanded_v1`,用于立即归档该群的项目绑定。
|
||||||
4. 添加回调“卡片回传交互” `card.action.trigger`,用于审批、运行中断和项目创建/绑定按钮。
|
4. 添加事件“机器人被移出群” `im.chat.member.bot.deleted_v1`,用于立即归档该群的项目绑定。
|
||||||
|
5. 在“回调配置”中同样选择长连接。
|
||||||
|
6. 添加回调“卡片回传交互” `card.action.trigger`,用于审批、运行中断和项目创建/绑定按钮。
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -174,7 +176,7 @@ App ID:cli_...
|
|||||||
App Secret:(通过安全渠道单独发送)
|
App Secret:(通过安全渠道单独发送)
|
||||||
应用已发布:是 / 否
|
应用已发布:是 / 否
|
||||||
机器人能力已启用:是 / 否
|
机器人能力已启用:是 / 否
|
||||||
消息事件和卡片回调已配置:是 / 否
|
消息事件(含解散群、机器人被移出群)和卡片回调已配置:是 / 否
|
||||||
OAuth 重定向 URL 已配置:是 / 否
|
OAuth 重定向 URL 已配置:是 / 否
|
||||||
|
|
||||||
【首位 OWNER】
|
【首位 OWNER】
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -106,6 +106,13 @@ export interface CardActionEvent {
|
|||||||
readonly token?: string;
|
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 {
|
interface ContactBasicUser {
|
||||||
readonly name?: string | undefined;
|
readonly name?: string | undefined;
|
||||||
readonly i18n_name?: {
|
readonly i18n_name?: {
|
||||||
@@ -922,6 +929,7 @@ export async function startFeishuListenerWithClient(
|
|||||||
onMessage: (event: MessageReceiveEvent, rt: FeishuRuntime) => Promise<void>,
|
onMessage: (event: MessageReceiveEvent, rt: FeishuRuntime) => Promise<void>,
|
||||||
onCardAction?: (event: CardActionEvent, rt: FeishuRuntime) => Promise<void>,
|
onCardAction?: (event: CardActionEvent, rt: FeishuRuntime) => Promise<void>,
|
||||||
onTerminalError?: (error: Error) => void,
|
onTerminalError?: (error: Error) => void,
|
||||||
|
onBindingLifecycle?: (event: FeishuBindingLifecycleEvent) => Promise<void>,
|
||||||
): Promise<FeishuRuntime> {
|
): Promise<FeishuRuntime> {
|
||||||
let state: "STARTING" | "READY" | "FAILED" = "STARTING";
|
let state: "STARTING" | "READY" | "FAILED" = "STARTING";
|
||||||
let resolveReady!: () => void;
|
let resolveReady!: () => void;
|
||||||
@@ -963,6 +971,14 @@ export async function startFeishuListenerWithClient(
|
|||||||
.catch((e) => { logger.error({ err: e }, "feishu card action handler threw"); });
|
.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) });
|
await wsClient.start({ eventDispatcher: new lark.EventDispatcher({}).register(handlers) });
|
||||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||||
const startupTimeout = new Promise<never>((_resolve, reject) => {
|
const startupTimeout = new Promise<never>((_resolve, reject) => {
|
||||||
@@ -977,6 +993,36 @@ export async function startFeishuListenerWithClient(
|
|||||||
return rt;
|
return rt;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function dispatchBindingLifecycleEvent(
|
||||||
|
data: unknown,
|
||||||
|
reason: FeishuBindingLifecycleEvent["reason"],
|
||||||
|
onBindingLifecycle: (event: FeishuBindingLifecycleEvent) => Promise<void>,
|
||||||
|
logger: FastifyBaseLogger,
|
||||||
|
): Promise<void> {
|
||||||
|
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(
|
export async function startFeishuListener(
|
||||||
config: FeishuConfig,
|
config: FeishuConfig,
|
||||||
logger: FastifyBaseLogger,
|
logger: FastifyBaseLogger,
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ export async function upsertScopedFeishuIdentityInTransaction(
|
|||||||
const principalId = scopedFeishuPrincipalId("USER", connectionId, openId);
|
const principalId = scopedFeishuPrincipalId("USER", connectionId, openId);
|
||||||
const userId = deterministicFeishuUserId(connectionId, openId);
|
const userId = deterministicFeishuUserId(connectionId, openId);
|
||||||
|
|
||||||
const connection = await lockActiveConnection(prisma, connectionId);
|
const connection = await requireActiveFeishuApplicationConnectionInTransaction(prisma, connectionId);
|
||||||
if (input.expectedOrganizationId !== undefined &&
|
if (input.expectedOrganizationId !== undefined &&
|
||||||
input.expectedOrganizationId !== connection.organizationId) {
|
input.expectedOrganizationId !== connection.organizationId) {
|
||||||
throw new Error("Feishu identity Organization scope mismatch");
|
throw new Error("Feishu identity Organization scope mismatch");
|
||||||
@@ -114,7 +114,7 @@ export async function resolveScopedFeishuIdentity(
|
|||||||
const connectionId = nonEmpty(input.connectionId, "Feishu connectionId");
|
const connectionId = nonEmpty(input.connectionId, "Feishu connectionId");
|
||||||
const openId = nonEmpty(input.openId, "Feishu openId");
|
const openId = nonEmpty(input.openId, "Feishu openId");
|
||||||
return prisma.$transaction(async (tx) => {
|
return prisma.$transaction(async (tx) => {
|
||||||
const connection = await lockActiveConnection(tx, connectionId);
|
const connection = await requireActiveFeishuApplicationConnectionInTransaction(tx, connectionId);
|
||||||
if (input.expectedOrganizationId !== undefined &&
|
if (input.expectedOrganizationId !== undefined &&
|
||||||
input.expectedOrganizationId !== connection.organizationId) {
|
input.expectedOrganizationId !== connection.organizationId) {
|
||||||
throw new Error("Feishu identity Organization scope mismatch");
|
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,
|
prisma: Prisma.TransactionClient,
|
||||||
connectionId: string,
|
connectionId: string,
|
||||||
): Promise<{ readonly organizationId: string }> {
|
): Promise<{ readonly organizationId: string }> {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import Fastify from "fastify";
|
|||||||
import { registerAdminPlugin } from "./admin/plugin.js";
|
import { registerAdminPlugin } from "./admin/plugin.js";
|
||||||
import { prisma } from "./db.js";
|
import { prisma } from "./db.js";
|
||||||
import { createLarkClient, startFeishuListenerWithClient } from "./feishu/client.js";
|
import { createLarkClient, startFeishuListenerWithClient } from "./feishu/client.js";
|
||||||
|
import { archiveFeishuBindingForLifecycleEvent } from "./feishu/bindingLifecycle.js";
|
||||||
import { makeTriggerHandler } from "./feishu/trigger.js";
|
import { makeTriggerHandler } from "./feishu/trigger.js";
|
||||||
import { removeAbandonedMessageResourceStages } from "./feishu/resourceStaging.js";
|
import { removeAbandonedMessageResourceStages } from "./feishu/resourceStaging.js";
|
||||||
import { triggerQueue } from "./feishu/triggerQueue.js";
|
import { triggerQueue } from "./feishu/triggerQueue.js";
|
||||||
@@ -177,6 +178,10 @@ export async function startHub(): Promise<void> {
|
|||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
void app.close().catch((error) => app.log.error({ err: error }, "Hub close after Feishu failure failed"));
|
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 {
|
} else {
|
||||||
app.log.info("feishu listener disabled by HUB_FEISHU_LISTENER_ENABLED");
|
app.log.info("feishu listener disabled by HUB_FEISHU_LISTENER_ENABLED");
|
||||||
|
|||||||
@@ -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<void> {
|
||||||
|
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(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||||
import type { FastifyBaseLogger } from "fastify";
|
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";
|
import { startFeishuListenerWithClient } from "../../src/feishu/client.js";
|
||||||
|
|
||||||
const larkMock = vi.hoisted(() => {
|
const larkMock = vi.hoisted(() => {
|
||||||
@@ -80,6 +80,57 @@ describe("Feishu card action callbacks", () => {
|
|||||||
expect(logger.error).toHaveBeenCalledWith({ err }, "feishu card action handler threw");
|
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<void> {
|
function cardActionHandler(): (data: unknown) => Promise<void> {
|
||||||
@@ -91,6 +142,15 @@ function cardActionHandler(): (data: unknown) => Promise<void> {
|
|||||||
return handler;
|
return handler;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function lifecycleHandler(eventType: string): (data: unknown) => Promise<void> {
|
||||||
|
const start = larkMock.starts[0];
|
||||||
|
if (start === undefined) throw new Error("WSClient.start was not called");
|
||||||
|
const dispatcher = start.eventDispatcher as { readonly handlers?: Record<string, (data: unknown) => Promise<void>> };
|
||||||
|
const handler = dispatcher.handlers?.[eventType];
|
||||||
|
if (handler === undefined) throw new Error(`${eventType} handler was not registered`);
|
||||||
|
return handler;
|
||||||
|
}
|
||||||
|
|
||||||
function makeCardActionEvent(): CardActionEvent {
|
function makeCardActionEvent(): CardActionEvent {
|
||||||
return {
|
return {
|
||||||
operator: { open_id: "ou_user" },
|
operator: { open_id: "ou_user" },
|
||||||
|
|||||||
Reference in New Issue
Block a user