forked from bai/curriculum-project-hub
feat: archive bindings on Feishu lifecycle events
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
/** 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<void>,
|
||||
onCardAction?: (event: CardActionEvent, rt: FeishuRuntime) => Promise<void>,
|
||||
onTerminalError?: (error: Error) => void,
|
||||
onBindingLifecycle?: (event: FeishuBindingLifecycleEvent) => Promise<void>,
|
||||
): Promise<FeishuRuntime> {
|
||||
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<typeof setTimeout> | undefined;
|
||||
const startupTimeout = new Promise<never>((_resolve, reject) => {
|
||||
@@ -977,6 +993,36 @@ export async function startFeishuListenerWithClient(
|
||||
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(
|
||||
config: FeishuConfig,
|
||||
logger: FastifyBaseLogger,
|
||||
|
||||
@@ -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 }> {
|
||||
|
||||
Reference in New Issue
Block a user