/**
 * Sends a "processing" reaction immediately, then streams a single
 * interactive card through the full agent run lifecycle: thinking → tool
 * calls (with trace panel) → streaming answer text → final card. The card
 * shows a collapsible tool-use panel, a collapsible reasoning panel, and
 * the markdown answer text. Throttled to ~2.5 patches/sec to avoid
 * spamming the Feishu API.
 *
 * ADR-0001..0003, 0017: chat→project binding, permission gate, lock,
 * provider-bound session, SDK resume continuity, ADR-0003-authorized reads.
 */
import { randomUUID } from "node:crypto";
import { join } from "node:path";
import type { Prisma, PrismaClient } from "@prisma/client";
import { z } from "zod";
import type { FastifyBaseLogger } from "fastify";
import type { LocalSecretEnvelope } from "../security/secretEnvelope.js";
import {
  sendText,
  sendTextMessage,
  sendCard,
  patchCard,
  reactToMessage,
  addReaction,
  removeReaction,
  parsePostMessage,
  resolveCard,
  resolveSenderName,
  type CardActionEvent,
  type FeishuRuntime,
  type MessageReceiveEvent,
  type SendMessageOptions,
} from "./client.js";
import { runAgent as defaultRunAgent, type ProjectContext, type RunRequest, type RunResult } from "../agent/runner.js";
import type { RuntimeSettings } from "../settings/runtime.js";
import { currentLockRunId, releaseLock } from "../lock.js";
import { createPermissionAuthorizer, type AuthorizationDecision, type PermissionAuthorizer } from "../permission.js";
import { writeAudit } from "../audit.js";
import { createAgentSdkStderrSink } from "../agent/diagnostics.js";
import { InactiveOrganizationError, lockActiveOrganization } from "../org/status.js";
import { StreamingAgentCard } from "./card/streaming-card.js";
import { createFileDeliveryMcpServer } from "./fileDeliveryTool.js";
import { readFeishuContext } from "./read.js";
import { MessageBatcher, messageBatchKey, type MessageBatcherOptions } from "./messageBatcher.js";
import { ApprovalManager } from "./approval.js";
import { SenderNameCache } from "./senderCache.js";
import {
  compensatePublishedMessageResources,
  publishStagedMessageResources,
  removeStagedMessageResources,
  stageMessageResources,
  type MessageResourceStageRequest,
  type StagedMessageResourceBatch,
} from "./resourceStaging.js";
import { TriggerQueue, triggerQueue as defaultTriggerQueue, type QueuedTrigger } from "./triggerQueue.js";
import { createSlashCommandRegistry, parseSlashInvocation } from "./slashCommands.js";
import { cphHubMcpToolsForRole, roleToolsAllow } from "../agent/roleTools.js";
import {
  bindFeishuChatToProject,
  createProjectFromFeishuChat,
  ensureOrganizationProjectSettings,
  moveProjectToFolder,
  renameProjectForActor,
} from "../projectOnboarding.js";
import {
  buildProjectManagementCard,
  buildMoveProjectCard,
  buildSessionHistoryCard,
  buildProjectOnboardingResolvedCard,
  buildUnboundChatOnboardingCard,
  projectOnboardingActionFromValue,
  type ProjectOnboardingActionValue,
  type ProjectOnboardingView,
} from "./projectOnboardingCard.js";
import {
  archiveCurrentRoleSession,
  browseFolderDestinations,
  createFolderAndMoveProject,
  listRoleSessionHistory,
  loadProjectConsole,
  resumeRoleSession,
  selectProjectGroupRole,
} from "./projectConsole.js";
import { SiloFixedWindowRateLimiter } from "../deployment/siloRateLimit.js";
import { browseBindableFolder, discoverBindableProjects } from "../projectDiscovery.js";

export { ApprovalManager } from "./approval.js";
export type { ApprovalResult, PendingApproval } from "./approval.js";
export { TriggerQueue, triggerQueue } from "./triggerQueue.js";
export type { QueuedTrigger, TriggerQueueOptions } from "./triggerQueue.js";
export const senderNameCache = new SenderNameCache();

interface TriggerDeps {
  readonly prisma: PrismaClient;
  readonly settings: RuntimeSettings;
  readonly logger: FastifyBaseLogger;
  readonly secretEnvelope: LocalSecretEnvelope;
  readonly runAgent?: (req: RunRequest) => Promise<RunResult>;
  readonly authorizer?: PermissionAuthorizer | undefined;
  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?: {
    readonly maxFilesPerMessage: number;
    readonly maxBytesPerFile: number;
  } | undefined;
  readonly allowLegacyFeishuIdentity?: boolean | undefined;
  /** Alpha Silo aggregate ingress ceiling across message and card events. */
  readonly maxFeishuEventsPerMinute?: number | undefined;
}

interface TriggerActor {
  readonly feishuOpenId: string;
  readonly chatId: string;
}

interface TriggerRunContext {
  readonly msg: MessageReceiveEvent["message"];
  readonly rt: FeishuRuntime;
  readonly chatId: string;
  readonly projectId: string;
  readonly senderOpenId: string;
  readonly actor: TriggerActor;
  readonly roleId: string;
}

type StartAgentRunOutcome = "started" | "queued" | "rejected" | "locked" | "skipped";

class InstanceCapacityExhaustedError extends Error {
  constructor(readonly limit: number) {
    super(`Silo Agent capacity exhausted (${limit} active run(s))`);
    this.name = "InstanceCapacityExhaustedError";
  }
}

export interface TriggerHandler {
  (event: MessageReceiveEvent, rt: FeishuRuntime): Promise<void>;
  readonly onCardAction: (event: CardActionEvent, rt: FeishuRuntime) => Promise<void>;
  readonly approvalManager: ApprovalManager;
}

/**
 * Build the message handler. Returns a function suitable for the lark
 * `im.message.receive_v1` event. The handler is async and long-running (the
 * agent run is the long leg); the lock guarantees one run per project at a time.
 */
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();
  const triggerQueue = deps.triggerQueue ?? defaultTriggerQueue;
  const feishuEventLimiter = deps.maxFeishuEventsPerMinute === undefined
    ? undefined
    : new SiloFixedWindowRateLimiter(deps.maxFeishuEventsPerMinute, 60_000);
  const slashCommands = createSlashCommandRegistry({
    prisma: deps.prisma,
  });
  const batchContexts = new Map<string, TriggerRunContext>();
  // runId → AbortController for live runs. Registered when a run starts,
  // used by the interrupt card action to abort the SDK query, cleared in the
  // run's finally. Absence means the run already terminated (or never started).
  const activeRuns = new Map<string, AbortController>();
  const messageBatcher = new MessageBatcher(async (mergedText, key) => {
    if (key === undefined) {
      deps.logger.warn("feishu trigger: message batch flushed without a key");
      return;
    }
    const context = batchContexts.get(key);
    if (context === undefined) {
      deps.logger.warn({ key }, "feishu trigger: message batch flushed without trigger context");
      return;
    }
    batchContexts.delete(key);
    try {
      await startAgentRun(context, mergedText);
    } catch (e) {
      deps.logger.error({ key, err: e instanceof Error ? e.message : String(e) }, "feishu trigger: batched run setup failed");
      throw e;
    }
  }, deps.messageBatcherOptions);

  async function projectConsoleCard(
    projectId: string,
    chatId: string,
    actorOpenId: string,
    title?: string,
  ): Promise<Record<string, unknown>> {
    const state = await loadProjectConsole(deps.prisma, { projectId, chatId });
    const actor = { feishuOpenId: actorOpenId, chatId };
    const [manageDecision, organization, roleDecisions] = await Promise.all([
      authorizer.can({ actor, action: "collaborator.manage", resource: { type: "PROJECT", id: projectId } }),
      resolveSingleActiveOrganizationForFeishuUser(actorOpenId),
      Promise.all(state.roles.map(async (role) => ({
        role,
        decision: await authorizer.can({
          actor,
          action: "role.trigger",
          resource: { type: "PROJECT", id: projectId },
          roleId: role.roleId,
        }),
      }))),
    ]);
    const visibleRoles = roleDecisions.filter(({ decision }) => decision.allowed).map(({ role }) => role);
    const isOrgAdmin = organization.status === "ok" &&
      organization.organizationId === state.organizationId && isOrgAdminRole(organization.role);
    return buildProjectManagementCard({
      organizationId: state.organizationId,
      projectId: state.projectId,
      projectName: state.projectName,
      breadcrumb: state.breadcrumb,
      selectedRole: state.selectedRole,
      roles: visibleRoles,
      currentSession: state.currentSession,
      canManageProject: manageDecision.allowed || isOrgAdmin,
      ...(title !== undefined ? { title } : {}),
    });
  }

  async function startAgentRun(
    context: TriggerRunContext,
    cleanPrompt: string,
    options: { readonly queueIfLocked?: boolean; readonly nativeSlash?: boolean } = {},
  ): Promise<StartAgentRunOutcome> {
    const { msg, rt, chatId, projectId, senderOpenId, actor } = context;
    const sendOptions = sendOptionsForTriggerMessage(msg);
    const queueIfLocked = options.queueIfLocked ?? true;

    // ADR-0002: if locked by another run, enqueue the trigger for this project.
    const existing = await currentLockRunId(deps.prisma, projectId);
    if (existing !== null) {
      if (deps.rejectWhenBusy === true) {
        await sendText(rt, chatId, "当前项目正在处理中，请稍后重试。", sendOptions);
        return "rejected";
      }
      if (!queueIfLocked) return "locked";
      return enqueueLockedTrigger(context, cleanPrompt, options.nativeSlash === true ? "native_compact" : "conversation");
    }

    const project = await deps.prisma.project.findUnique({
      where: { id: projectId },
      select: { workspaceDir: true },
    });
    if (project === null) {
      deps.logger.error({ projectId }, "feishu trigger: project missing");
      return "skipped";
    }

    const models = await deps.settings.modelRegistry({ projectId });
    const roleId = context.roleId;
    // ADR-0019: role trigger is the second gate after project agent.trigger.
    // Configured roles require a matching active RoleTriggerGrant for any
    // resolved principal; otherwise the role remains open within the project.
    const roleDecision = await authorizer.can({
      actor,
      action: "role.trigger",
      resource: { type: "PROJECT", id: projectId },
      roleId,
    });
    if (!roleDecision.allowed) {
      deps.logger.info({ projectId, roleId, senderOpenId, reason: roleDecision.reason }, "feishu trigger: role permission denied");
      await writeAudit(deps.prisma, {
        projectId,
        ...(roleDecision.actorUserId !== undefined ? { actorUserId: roleDecision.actorUserId } : {}),
        action: "trigger.role_denied",
        metadata: {
          roleId,
          decision: auditDecision(roleDecision),
          sender: await senderAuditMetadata(rt, senderOpenId),
        },
      });
      await sendText(rt, chatId, `无权限使用角色 ${roleId}。`, sendOptions);
      return "skipped";
    }
    const role = models.role(roleId);
    const model = models.resolve(undefined, roleId);
    const provider = await deps.settings.provider("openrouter", { projectId });
    const runPolicy = await deps.settings.runPolicy({ projectId, roleId });
    const providerId = provider.id;
    // Per-role bundle (ADR-0017): systemPrompt seeds persona; tools whitelist
    // is enforced inside runAgent when it builds the SDK tools record. `tools:
    // undefined` means the full registered set (unrestricted role).
    const roleTools = role?.tools;
    const systemPrompt = withFileDeliveryInstructions(role?.systemPrompt, roleTools);
    const feishuTriggerContext = await buildFeishuTriggerContext({
      msg,
      chatId,
      projectId,
      workspaceDir: project.workspaceDir,
      rt,
      senderOpenId,
    });
    const senderMetadata = await senderAuditMetadata(rt, senderOpenId);
    const stagedResources = await stageTriggerMessageResources(
      rt,
      msg,
      projectWorkspaceRoot,
      deps.resourceLimits,
    );
    const agentPrompt = appendStagedResourcePaths(cleanPrompt, stagedResources, project.workspaceDir);
    const promptForAgent = options.nativeSlash === true
      ? cleanPrompt
      : withFeishuTriggerContext(agentPrompt, feishuTriggerContext);

    // Linearization point for org lifecycle + session/run/lock admission.
    // FOR SHARE on the Organization row conflicts with a concurrent status
    // UPDATE, so either the run is admitted while ACTIVE or suspension wins
    // and no session/run/lock/audit row is committed.
    let admission: {
      readonly session: { readonly id: string; readonly metadata: Prisma.JsonValue };
      readonly run: { readonly id: string };
    };
    try {
      admission = await deps.prisma.$transaction(async (tx) => {
        await lockActiveOrganization(tx, roleDecision.organizationId);
        await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext('cph-alpha-silo-agent-capacity'))`;
        const activeRuns = await tx.agentRun.count({ where: { status: "ACTIVE" } });
        if (activeRuns >= runPolicy.maxConcurrentRuns) {
          throw new InstanceCapacityExhaustedError(runPolicy.maxConcurrentRuns);
        }

        // ADR-0017: provider+role+model-bound session. Reuse if one exists for
        // this tuple; otherwise create. Role remains part of the key.
        const existingSession = await tx.agentSession.findFirst({
          where: { projectId, provider: providerId, roleId, model, archivedAt: null },
          select: { id: true, metadata: true },
        });
        const session = existingSession !== null
          ? await tx.agentSession.update({
              where: { id: existingSession.id },
              data: { provider: providerId, roleId, model, updatedAt: new Date() },
              select: { id: true, metadata: true },
            })
          : await tx.agentSession.create({
              data: {
                projectId,
                provider: providerId,
                roleId,
                model,
                title: agentPrompt.slice(0, 40) || null,
                metadata: {},
              },
              select: { id: true, metadata: true },
            });
        const run = await tx.agentRun.create({
          data: {
            projectId,
            sessionId: session.id,
            requestedByUserId: roleDecision.actorUserId ?? null,
            entrypoint: "FEISHU",
            status: "ACTIVE",
            prompt: promptForAgent,
            model,
            provider: providerId,
            metadata: agentRunMetadata(roleId, agentPrompt, feishuTriggerContext),
          },
          select: { id: true },
        });
        await tx.projectAgentLock.create({
          data: { projectId, runId: run.id, holderUserId: null },
        });
        return { session, run };
      });
    } catch (error) {
      if (stagedResources !== null) {
        await compensateFailedResourceAdmission(
          error,
          stagedResources,
          projectWorkspaceRoot,
          project.workspaceDir,
        );
      }
      if (error instanceof InactiveOrganizationError) {
        deps.logger.info(
          { projectId, organizationId: error.organizationId, status: error.status },
          "feishu trigger: organization became inactive before run admission",
        );
        await sendText(rt, chatId, "组织当前不可用，拒绝触发。", sendOptions);
        return "skipped";
      }
      if (error instanceof InstanceCapacityExhaustedError) {
        deps.logger.info(
          { projectId, maxConcurrentRuns: error.limit },
          "feishu trigger: Silo Agent capacity exhausted",
        );
        await sendText(rt, chatId, "当前组织处理任务已满，请稍后重试。", sendOptions);
        return "rejected";
      }
      if (!isPrismaUniqueConstraintError(error)) throw error;
      const current = await currentLockRunId(deps.prisma, projectId);
      if (current === null) throw error;
      if (deps.rejectWhenBusy === true) {
        await sendText(rt, chatId, "当前项目正在处理中，请稍后重试。", sendOptions);
        return "rejected";
      }
      if (!queueIfLocked) return "locked";
      return enqueueLockedTrigger(context, cleanPrompt);
    }
    const { session, run } = admission;
    if (stagedResources !== null) {
      try {
        await publishStagedMessageResources(
          stagedResources,
          projectWorkspaceRoot,
          project.workspaceDir,
        );
        await removeStagedMessageResources(stagedResources);
      } catch (error) {
        let publicationFailure: unknown = error;
        try {
          await compensateFailedResourceAdmission(
            error,
            stagedResources,
            projectWorkspaceRoot,
            project.workspaceDir,
          );
        } catch (cleanupError) {
          publicationFailure = cleanupError;
        }
        try {
          await deps.prisma.$transaction(async (tx) => {
            await tx.agentRun.update({
              where: { id: run.id },
              data: { status: "FAILED", error: "attachment publication failed", finishedAt: new Date() },
            });
            await tx.projectAgentLock.deleteMany({ where: { runId: run.id } });
          });
        } catch (finalizationError) {
          publicationFailure = new AggregateError(
            [publicationFailure, finalizationError],
            `attachment publication and run finalization both failed: ${run.id}`,
            { cause: publicationFailure },
          );
        }
        await writeAudit(deps.prisma, {
          runId: run.id,
          projectId,
          action: "run.attachment_publish_failed",
          metadata: { error: String(publicationFailure) },
        });
        await sendText(rt, chatId, "附件处理失败，未启动运行。", sendOptions);
        throw publicationFailure;
      }
    }
    await writeAudit(deps.prisma, {
      runId: run.id,
      projectId,
      ...(roleDecision.actorUserId !== undefined ? { actorUserId: roleDecision.actorUserId } : {}),
      action: "run.created",
      metadata: {
        roleId,
        model,
        requestedSkills: (role?.skills ?? []).map((skill) => ({
          name: skill.name,
          version: skill.version,
          contentDigest: skill.contentDigest,
        })),
        prompt: agentPrompt.slice(0, 200),
        sender: senderMetadata,
        feishuTriggerContext,
      },
    });
    const sessionMetadata = readSessionMetadata(session.metadata);

    const projectCtx: ProjectContext = {
      projectId,
      boundChatId: chatId, // ADR-0003: Feishu reads stay in this chat
      workspaceRoot: projectWorkspaceRoot,
      workspaceDir: project.workspaceDir,
    };

    const processingReactionId = await addReaction(rt, msg.message_id, "Typing");
    const removeProcessingReaction = async (): Promise<boolean> => {
      if (processingReactionId === null) return false;
      return removeReaction(rt, msg.message_id, processingReactionId);
    };

    // Streaming agent card: single interactive card through the full run
    // lifecycle (thinking → tool calls → streaming text → complete).
    // Shows tool-use trace panel + reasoning panel + answer text.
    const deliveredFiles: string[] = [];
    const card = new StreamingAgentCard({
      runId: run.id,
      rt,
      chatId,
      sendOptions,
      patchIntervalMs: undefined,
      maxMessageLength: undefined,
      workspaceRoot: projectWorkspaceRoot,
      workspaceDir: project.workspaceDir,
      maxImageBytes: deps.resourceLimits?.maxBytesPerFile,
    });
    const fileDeliveryMcpServer = createFileDeliveryMcpServer({
      rt,
      chatId,
      projectId,
      organizationId: siloOrganizationId,
      runId: run.id,
      workspaceRoot: projectWorkspaceRoot,
      workspaceDir: project.workspaceDir,
      maxFileBytes: deps.resourceLimits?.maxBytesPerFile,
      sendOptions,
      approvalManager,
      tools: cphHubMcpToolsForRole(roleTools),
      prisma: deps.prisma,
      secretEnvelope: deps.secretEnvelope,
      onDelivered: (path) => {
        deliveredFiles.push(path);
      },
    });

    // Abort handle for this run; the interrupt card action calls abort() to
    // stop the SDK query. Cleared in the finally below.
    const abortController = new AbortController();
    let wallTimeExceeded = false;
    const wallTimeTimer = setTimeout(() => {
      wallTimeExceeded = true;
      abortController.abort("run_wall_time_exceeded");
    }, runPolicy.maxRunSeconds * 1000);
    wallTimeTimer.unref();
    activeRuns.set(run.id, abortController);
    const agentExecution = (async () => {
      const providerLease = await provider.openAgentLease({ runId: run.id });
      const sdkStderr = createAgentSdkStderrSink({
        logger: deps.logger,
        runId: run.id,
        projectId,
        sensitiveValues: providerLease.sensitiveValues,
      });
      try {
        return await runAgent({
          prompt: promptForAgent,
          model,
          project: projectCtx,
          systemPrompt,
          providerProxyEnv: { ...providerLease.sdkEnv },
          resumeSessionId: sessionMetadata.claudeSessionId,
          tools: roleTools,
          skills: role?.skills,
          mcpServers: { cph_hub: fileDeliveryMcpServer },
          maxTurns: runPolicy.maxTurns,
          runId: run.id,
          sessionId: session.id,
          prisma: deps.prisma,
          abortController,
          onSdkStderr: sdkStderr.write,
          onStream: (event) => {
            switch (event.type) {
              case "text-delta":
                card.appendText(event.text);
                break;
              case "thinking-delta":
                card.appendReasoning(event.text);
                break;
              case "tool-start":
                card.onToolStart(event.toolName, event.toolUseId);
                break;
              case "tool-end":
                card.onToolEnd({
                  toolName: event.toolName,
                  toolUseId: event.toolUseId,
                  input: event.input,
                  result: undefined,
                  error: undefined,
                  durationMs: event.durationMs,
                });
                break;
              case "tool-result":
                card.onToolEnd({
                  toolName: event.toolName,
                  toolUseId: event.toolUseId,
                  input: undefined,
                  result: event.result,
                  error: event.isError ? event.result : undefined,
                  durationMs: event.durationMs,
                });
                break;
              case "finish":
                break;
            }
          },
        });
      } finally {
        sdkStderr.flush();
        await providerLease.close();
      }
    })();
    agentExecution
      .then(async (result) => {
        const interrupted = result.status === "interrupted" && !wallTimeExceeded;
        const finalText =
          result.text !== ""
            ? result.text
            : result.status === "failed" && result.error !== undefined
              ? `\u5904\u7406\u5931\u8D25: ${result.error}`
              : result.text;
        await card.finish(finalText, { interrupted });
        const metadataPatch = sessionMetadataPatch(result.sdkSessionId);
        if (metadataPatch !== null) {
          await deps.prisma.agentSession.update({
            where: { id: session.id },
            data: { metadata: mergeSessionMetadata(session.metadata, metadataPatch) },
          });
        }
        // ADR-0026: the UsageFact ledger is the truth; AgentRun.costUsd /
        // inputTokens / outputTokens are a derived rollup cache for existing
        // readers. We write the fact first, then mirror it onto the run.
        // They are separate statements (not one transaction) so the AgentRun
        // row lock is held for the shortest possible window and concurrent
        // workspace teardown cannot deadlock on the FK ShareLock. A crash
        // between the two leaves the cache stale, but the cache is derived
        // (the usage service re-reads facts), so staleness is recoverable;
        // the reverse order would lose the truth and is unrecoverable.
        const finishedAt = new Date();
        const reportedCost = result.costUsd;
        const costSource = reportedCost !== undefined ? "provider_reported" : "unknown";
        await deps.prisma.usageFact.create({
          data: {
            runId: run.id,
            occurredAt: finishedAt,
            kind: "model_completion",
            provider: providerId,
            model,
            inputTokens: result.usage.inputTokens,
            outputTokens: result.usage.outputTokens,
            costUsd: reportedCost ?? null,
            costSource,
            metadata: {},
          },
        });
        await deps.prisma.agentRun.update({
          where: { id: run.id },
          data: {
            status: wallTimeExceeded
              ? "TIMED_OUT"
              : interrupted
                ? "CANCELED"
                : result.status === "completed"
                  ? "COMPLETED"
                  : result.status === "length"
                    ? "TIMED_OUT"
                    : "FAILED",
            inputTokens: result.usage.inputTokens,
            outputTokens: result.usage.outputTokens,
            costUsd: reportedCost ?? null,
            costSource,
            error: result.error ?? null,
            finishedAt,
          },
        });
        await writeAudit(deps.prisma, {
          runId: run.id,
          projectId,
          action: "run.finished",
          metadata: {
            status: result.status,
            deliveredFiles,
            initializedSkills: [...(result.initializedSkillIds ?? [])],
          },
        });
        await removeProcessingReaction();
      })
      .catch(async (e) => {
        const removedProcessingReaction = await removeProcessingReaction();
        if (removedProcessingReaction) {
          await addReaction(rt, msg.message_id, "CrossMark");
        }
        await card.fail(e instanceof Error ? e.message : String(e));
        try {
          await deps.prisma.agentRun.update({
            where: { id: run.id },
            data: { status: "FAILED", error: String(e), finishedAt: new Date() },
          });
        } catch (updateErr) {
          deps.logger.warn({ runId: run.id, err: String(updateErr) }, "trigger: could not mark run FAILED");
        }
        await writeAudit(deps.prisma, { runId: run.id, projectId, action: "run.failed", metadata: { error: String(e) } });
      })
      .finally(async () => {
        clearTimeout(wallTimeTimer);
        try {
          await releaseLock(deps.prisma, run.id);
        } catch (e) {
          deps.logger.warn({ runId: run.id, err: e instanceof Error ? e.message : String(e) }, "trigger: could not release lock");
        }
        try {
          await drainTriggerQueue(projectId, rt);
        } catch (e) {
          deps.logger.warn({ projectId, err: e instanceof Error ? e.message : String(e) }, "trigger: could not drain trigger queue");
        }
        activeRuns.delete(run.id);
      });
    return "started";
  }

  async function enqueueLockedTrigger(
    context: TriggerRunContext,
    cleanPrompt: string,
    executionKind: QueuedTrigger["executionKind"] = "conversation",
  ): Promise<StartAgentRunOutcome> {
    const position = triggerQueue.enqueue(context.projectId, {
      chatId: context.chatId,
      prompt: cleanPrompt,
      roleId: context.roleId,
      executionKind,
      msg: context.msg,
      senderOpenId: context.senderOpenId,
      actor: context.actor,
    });
    if (position === 0) {
      await reactToMessage(context.rt, context.msg.message_id, "OnIt");
      await sendText(context.rt, context.chatId, "队列已满，请稍后再试", sendOptionsForTriggerMessage(context.msg));
      return "rejected";
    }

    await sendText(
      context.rt,
      context.chatId,
      `已加入队列（第${position}位），当前处理完成后将自动开始`,
      sendOptionsForTriggerMessage(context.msg),
    );
    return "queued";
  }

  async function drainTriggerQueue(projectId: string, rt: FeishuRuntime): Promise<void> {
    while (triggerQueue.hasPending(projectId)) {
      const existing = await currentLockRunId(deps.prisma, projectId);
      if (existing !== null) return;

      const queued = triggerQueue.peek(projectId);
      if (queued === null) return;
      if (triggerQueue.isExpired(queued)) {
        triggerQueue.dequeue(projectId);
        deps.logger.info(
          { projectId, messageId: queued.msg.message_id, enqueuedAt: queued.enqueuedAt },
          "feishu trigger: dropped expired queued trigger",
        );
        continue;
      }

      const next = triggerQueue.dequeue(projectId);
      if (next === null) return;
      const outcome = await startAgentRun(contextFromQueuedTrigger(next, rt), next.prompt, {
        queueIfLocked: false,
        nativeSlash: next.executionKind === "native_compact",
      });
      if (outcome === "started") return;
      if (outcome === "locked") {
        requeueTrigger(next);
        return;
      }
    }
  }

  function contextFromQueuedTrigger(trigger: QueuedTrigger, rt: FeishuRuntime): TriggerRunContext {
    return {
      msg: trigger.msg,
      rt,
      chatId: trigger.chatId,
      projectId: trigger.projectId,
      senderOpenId: trigger.senderOpenId,
      actor: trigger.actor,
      roleId: trigger.roleId,
    };
  }

  function requeueTrigger(trigger: QueuedTrigger): void {
    const position = triggerQueue.enqueue(trigger.projectId, {
      chatId: trigger.chatId,
      prompt: trigger.prompt,
      roleId: trigger.roleId,
      executionKind: trigger.executionKind,
      msg: trigger.msg,
      senderOpenId: trigger.senderOpenId,
      actor: trigger.actor,
    });
    if (position === 0) {
      deps.logger.warn(
        { projectId: trigger.projectId, messageId: trigger.msg.message_id },
        "feishu trigger: could not requeue locked queued trigger",
      );
    }
  }

  const onCardAction = async (event: CardActionEvent, rt: FeishuRuntime): Promise<void> => {
    const rateDecision = feishuEventLimiter?.consume();
    if (rateDecision !== undefined && !rateDecision.allowed) {
      deps.logger.warn(
        {
          retryAfterSeconds: rateDecision.retryAfterSeconds,
          chatId: nonEmpty(event.context?.open_chat_id),
          operatorOpenId: nonEmpty(event.operator.open_id),
        },
        "feishu trigger: Silo event rate exceeded; card action rejected",
      );
      return;
    }
    // Approval buttons (file-delivery / explicit approval cards).
    const approvalAction = approvalActionFromValue(event.action.value);
    if (approvalAction !== null) {
      const messageId = nonEmpty(event.context?.open_message_id);
      if (messageId === undefined || !approvalManager.hasPending(messageId)) return;
      const operatorOpenId = nonEmpty(event.operator.open_id);
      const resolvedBy = operatorOpenId ?? "unknown";
      const resolvedByName =
        operatorOpenId === undefined ? "unknown" : await senderDisplayNameOrOpenId(rt, operatorOpenId);
      const resolution = approvalResolutionFor(approvalAction);
      approvalManager.resolve(messageId, approvalAction, resolvedBy);
      await resolveCard(rt, messageId, resolution.icon, resolution.label, resolution.template, resolvedByName);
      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.
    const runId = interruptRunFromValue(event.action.value);
    if (runId !== null) {
      await handleInterruptAction(event, rt, runId);
    }
  };

  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 {
      const organization = await resolveSingleActiveOrganizationForFeishuUser(operatorOpenId);
      if (organization.status !== "ok") throw new Error(organization.message);
      if (organization.organizationId !== action.organization_id) {
        throw new Error("project onboarding organization mismatch");
      }
      if (
        action.action === "select_agent_role" ||
        action.action === "new_agent_session" ||
        action.action === "show_session_history" ||
        action.action === "resume_agent_session" ||
        action.action === "compact_agent_session"
      ) {
        const projectId = action.project_id;
        if (projectId === undefined) throw new Error(`${action.action} requires project_id`);
        const binding = await deps.prisma.projectGroupBinding.findFirst({
          where: { projectId, chatId, archivedAt: null },
          select: { id: true },
        });
        if (binding === null) throw new Error("project console action requires the project to be bound to this chat");
        const state = await loadProjectConsole(deps.prisma, { projectId, chatId });
        const targetRole = action.action === "select_agent_role"
          ? state.roles.find((role) => role.id === action.agent_role_id)
          : state.selectedRole;
        if (targetRole === undefined) throw new Error("selected Agent role is unavailable");
        const actor = { feishuOpenId: operatorOpenId, chatId };
        const [triggerDecision, roleDecision] = await Promise.all([
          authorizer.can({ actor, action: "agent.trigger", resource: { type: "PROJECT", id: projectId } }),
          authorizer.can({
            actor,
            action: "role.trigger",
            resource: { type: "PROJECT", id: projectId },
            roleId: targetRole.roleId,
          }),
        ]);
        if (!triggerDecision.allowed || !roleDecision.allowed || roleDecision.actorUserId === undefined) {
          throw new Error(`not authorized for Agent role ${targetRole.roleId}`);
        }

        if (action.action === "select_agent_role") {
          await selectProjectGroupRole(deps.prisma, {
            projectId,
            chatId,
            agentRoleId: targetRole.id,
            actorUserId: roleDecision.actorUserId,
          });
          if (messageId === undefined) throw new Error("role selection requires message id");
          await patchCard(rt, messageId, await projectConsoleCard(
            projectId,
            chatId,
            operatorOpenId,
            `已切换至 ${targetRole.label}`,
          ));
          return;
        }
        if (action.action === "new_agent_session") {
          await archiveCurrentRoleSession(deps.prisma, {
            projectId,
            chatId,
            actorUserId: roleDecision.actorUserId,
          });
          if (messageId === undefined) throw new Error("new session requires message id");
          await patchCard(rt, messageId, await projectConsoleCard(projectId, chatId, operatorOpenId, "已新开会话"));
          return;
        }
        if (action.action === "show_session_history") {
          const sessions = await listRoleSessionHistory(deps.prisma, { projectId, chatId });
          if (messageId === undefined) throw new Error("session history requires message id");
          await patchCard(rt, messageId, buildSessionHistoryCard({
            organizationId: state.organizationId,
            projectId,
            roleLabel: state.selectedRole.label,
            sessions,
          }));
          return;
        }
        if (action.action === "resume_agent_session") {
          if (action.session_id === undefined) throw new Error("resume_agent_session requires session_id");
          await resumeRoleSession(deps.prisma, {
            projectId,
            chatId,
            sessionId: action.session_id,
            actorUserId: roleDecision.actorUserId,
          });
          if (messageId === undefined) throw new Error("session resume requires message id");
          await patchCard(rt, messageId, await projectConsoleCard(projectId, chatId, operatorOpenId, "已恢复历史会话"));
          return;
        }
        if (state.currentSession === null || !state.currentSession.sdkSessionReady) {
          throw new Error("当前角色还没有可压缩的 Claude 会话");
        }
        const commandMessage: MessageReceiveEvent["message"] = {
          message_id: messageId ?? randomUUID(),
          chat_id: chatId,
          chat_type: "group",
          message_type: "text",
          content: JSON.stringify({ text: "/compact" }),
          mentions: [],
        };
        await startAgentRun({
          msg: commandMessage,
          rt,
          chatId,
          projectId,
          senderOpenId: operatorOpenId,
          actor,
          roleId: state.selectedRole.roleId,
        }, "/compact", { nativeSlash: true });
        return;
      }
      if (
        action.action === "browse_move_destination" ||
        action.action === "move_project" ||
        action.action === "create_folder"
      ) {
        const projectId = action.project_id;
        if (projectId === undefined) throw new Error(`${action.action} requires project_id`);
        const state = await loadProjectConsole(deps.prisma, { projectId, chatId });
        const isOrgAdmin = isOrgAdminRole(organization.role);
        const manageDecision = await authorizer.can({
          actor: { feishuOpenId: operatorOpenId, chatId },
          action: "collaborator.manage",
          resource: { type: "PROJECT", id: projectId },
        });
        if (!isOrgAdmin && !manageDecision.allowed) throw new Error("project MANAGE permission is required");

        if (action.action === "browse_move_destination") {
          const page = await browseFolderDestinations(deps.prisma, {
            organizationId: state.organizationId,
            folderId: action.folder_id ?? null,
          });
          if (messageId === undefined) throw new Error("folder navigation requires message id");
          await patchCard(rt, messageId, buildMoveProjectCard({
            organizationId: state.organizationId,
            projectId,
            projectName: state.projectName,
            canCreateFolder: isOrgAdmin,
            ...page,
          }));
          return;
        }
        if (action.action === "move_project") {
          await moveProjectToFolder(deps.prisma, { projectId, folderId: action.folder_id ?? null });
          await writeAudit(deps.prisma, {
            projectId,
            ...(manageDecision.actorUserId !== undefined ? { actorUserId: manageDecision.actorUserId } : {}),
            action: "project.moved_from_feishu",
            metadata: { folderId: action.folder_id ?? null },
          });
          if (messageId === undefined) throw new Error("project move requires message id");
          await patchCard(rt, messageId, await projectConsoleCard(projectId, chatId, operatorOpenId, "项目已移动"));
          return;
        }
        if (!isOrgAdmin) throw new Error("creating an Organization folder requires OWNER or ADMIN");
        const folderName = event.action.form_value?.["folder_name"];
        if (typeof folderName !== "string") throw new Error("create folder form is missing folder_name");
        const folder = await createFolderAndMoveProject(deps.prisma, {
          organizationId: state.organizationId,
          projectId,
          parentFolderId: action.folder_id ?? null,
          name: folderName.slice(0, 100),
          ...(manageDecision.actorUserId !== undefined ? { actorUserId: manageDecision.actorUserId } : {}),
        });
        if (messageId === undefined) throw new Error("folder creation requires message id");
        await patchCard(rt, messageId, await projectConsoleCard(
          projectId,
          chatId,
          operatorOpenId,
          `已新建目录“${folder.folderName}”并移动项目`,
        ));
        return;
      }
      if (action.action === "rename_project") {
        const projectId = action.project_id;
        if (projectId === undefined) throw new Error("rename_project action requires project_id");
        const binding = await deps.prisma.projectGroupBinding.findFirst({
          where: { chatId, projectId, archivedAt: null },
          select: { id: true },
        });
        if (binding === null) throw new Error("project rename requires the project to be bound to this chat");
        const submittedName = event.action.form_value?.["project_name"];
        if (typeof submittedName !== "string") throw new Error("project rename form is missing project_name");
        const renamed = await renameProjectForActor(deps.prisma, {
          organizationId: organization.organizationId,
          projectId,
          actorFeishuOpenId: operatorOpenId,
          name: submittedName.slice(0, 100),
        });
        if (messageId === undefined) throw new Error("project rename requires message id");
        await patchCard(rt, messageId, await projectConsoleCard(
          renamed.projectId,
          chatId,
          operatorOpenId,
          "项目名称已更新",
        ));
        deps.logger.info({ chatId, projectId, operatorOpenId }, "project onboarding: project renamed");
        return;
      }
      if (action.action === "browse_folder" || action.action === "search_page") {
        const activeBinding = await deps.prisma.projectGroupBinding.findFirst({
          where: { chatId, archivedAt: null },
          select: { projectId: true },
        });
        if (activeBinding !== null) throw new Error(`chat is already bound to project ${activeBinding.projectId}`);
        const settings = await ensureOrganizationProjectSettings(deps.prisma, organization.organizationId);
        const view = action.action === "search_page"
          ? await searchOnboardingView({
              organizationId: organization.organizationId,
              actorFeishuOpenId: operatorOpenId,
              isOrgAdmin: isOrgAdminRole(organization.role),
              query: action.search_query ?? "",
              page: action.page ?? 1,
            })
          : await browseOnboardingView({
              organizationId: organization.organizationId,
              actorFeishuOpenId: operatorOpenId,
              isOrgAdmin: isOrgAdminRole(organization.role),
              folderId: action.folder_id ?? null,
              page: action.page ?? 1,
            });
        if (messageId === undefined) throw new Error("project onboarding navigation requires message id");
        await patchCard(rt, messageId, buildUnboundChatOnboardingCard({
          organizationId: organization.organizationId,
          organizationName: organization.organizationName,
          canCreateProject: settings.membersCanCreateProjects || isOrgAdminRole(organization.role),
          view,
        }));
        deps.logger.info(
          { chatId, operatorOpenId, action: action.action, folderId: action.folder_id, page: action.page },
          "project onboarding: navigated discovery card",
        );
        return;
      }

      if (action.action === "create_project_from_chat") {
        const name = defaultProjectNameForChat(chatId);
        const project = await createProjectFromFeishuChat(deps.prisma, {
          organizationId: organization.organizationId,
          actorFeishuOpenId: operatorOpenId,
          chatId,
          name,
          workspaceRoot: projectWorkspaceRoot,
          folderId: action.folder_id,
        });
        if (messageId !== undefined) {
          await patchCard(rt, messageId, await projectConsoleCard(
            project.projectId,
            chatId,
            operatorOpenId,
            "已创建并绑定项目",
          ));
        }
        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 target = await deps.prisma.project.findUnique({
        where: { id: projectId },
        select: { organizationId: true },
      });
      if (target === null) throw new Error(`project not found: ${projectId}`);
      if (target.organizationId !== organization.organizationId) {
        throw new Error("project onboarding project organization mismatch");
      }
      const project = await bindFeishuChatToProject(deps.prisma, {
        projectId,
        actorFeishuOpenId: operatorOpenId,
        chatId,
      });
      if (messageId !== undefined) {
        await patchCard(rt, messageId, await projectConsoleCard(
          project.projectId,
          chatId,
          operatorOpenId,
          "已绑定项目",
        ));
      }
      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. */
  async function handleInterruptAction(event: CardActionEvent, rt: FeishuRuntime, runId: string): Promise<void> {
    const chatId = nonEmpty(event.context?.open_chat_id);
    if (chatId === undefined) {
      deps.logger.warn({ runId }, "feishu interrupt: card action missing chat id");
      return;
    }
    const binding = await deps.prisma.projectGroupBinding.findFirst({
      where: { chatId, archivedAt: null },
      select: { projectId: true, selectedRole: { select: { roleId: true } } },
    });
    if (binding === null) {
      deps.logger.debug({ chatId }, "feishu interrupt: chat not bound to any project");
      return;
    }
    const projectId = binding.projectId;
    const senderOpenId = nonEmpty(event.operator.open_id) ?? "";
    if (senderOpenId === "") {
      deps.logger.warn({ projectId, chatId, runId }, "feishu interrupt: operator has no open_id, denying");
      await sendText(rt, chatId, "无法识别操作者,拒绝中断。");
      return;
    }
    const actor = { feishuOpenId: senderOpenId, chatId };
    const decision = await authorizer.can({
      actor,
      action: "agent.cancel",
      resource: { type: "PROJECT", id: projectId },
    });
    if (!decision.allowed) {
      deps.logger.info({ projectId, runId, senderOpenId, reason: decision.reason }, "feishu interrupt: permission denied");
      await writeAudit(deps.prisma, {
        projectId,
        ...(decision.actorUserId !== undefined ? { actorUserId: decision.actorUserId } : {}),
        runId,
        action: "run.interrupt_denied",
        metadata: { decision: auditDecision(decision), sender: await senderAuditMetadata(rt, senderOpenId) },
      });
      await sendText(rt, chatId, "无权限中断该运行。");
      return;
    }
    const controller = activeRuns.get(runId);
    if (controller === undefined) {
      // Run already terminated (or unknown): nothing to abort. The card's
      // complete-state patch already removed the button, so a stray click is
      // best-effort ignored rather than spammed with a message.
      deps.logger.debug({ runId, projectId }, "feishu interrupt: no active run for runId");
      return;
    }
    controller.abort();
    await writeAudit(deps.prisma, {
      projectId,
      ...(decision.actorUserId !== undefined ? { actorUserId: decision.actorUserId } : {}),
      runId,
      action: "run.interrupted",
      metadata: { sender: await senderAuditMetadata(rt, senderOpenId) },
    });
  }

  const onMessage = async (event: MessageReceiveEvent, rt: FeishuRuntime): Promise<void> => {
    const msg = event.message;

    // Dedup: the lark ws client redelivers at-least-once. A duplicate event
    // would spawn a second AgentRun — the lock would refuse it, but a FAILED
    // run row + a busy reply would leak. Record the event_id up front; if it
    // already exists, this is a redelivery → stop.
    const eventId = feishuEventId(event);
    if (eventId !== undefined && eventId !== "") {
      const existing = await deps.prisma.feishuEventReceipt.findUnique({
        where: { eventId },
        select: { id: true },
      });
      if (existing !== null) {
        deps.logger.debug({ eventId }, "feishu trigger: duplicate event, skipping");
        return;
      }
      await deps.prisma.feishuEventReceipt.create({
        data: {
          eventId,
          eventType: feishuEventType(event),
          messageId: msg.message_id,
        },
      });
    }

    const rateDecision = feishuEventLimiter?.consume();
    if (rateDecision !== undefined && !rateDecision.allowed) {
      deps.logger.warn(
        {
          retryAfterSeconds: rateDecision.retryAfterSeconds,
          chatId: msg.chat_id,
          messageId: msg.message_id,
          senderOpenId: event.sender.sender_id.open_id,
        },
        "feishu trigger: Silo event rate exceeded; message rejected",
      );
      if (msg.chat_id !== "") {
        await sendText(
          rt,
          msg.chat_id,
          `当前服务请求过多，请约 ${rateDecision.retryAfterSeconds} 秒后重试。`,
          sendOptionsForTriggerMessage(msg),
        );
      }
      return;
    }

    // Only react to supported inbound message types in group chats.
    if (msg.message_type !== "text" && msg.message_type !== "file" && msg.message_type !== "image" && msg.message_type !== "post") return;
    const chatId = msg.chat_id;
    if (chatId === "") return;

    // ADR-0001: resolve chat → project. Unknown chat ⇒ not a project group.
    const binding = await deps.prisma.projectGroupBinding.findFirst({
      where: { chatId, archivedAt: null },
      select: { projectId: true, selectedRole: { select: { roleId: true } } },
    });
    if (binding === null) {
      deps.logger.debug({ chatId }, "feishu trigger: chat not bound to any project");
      await sendUnboundChatOnboardingCard(event, rt);
      return;
    }
    const projectId = binding.projectId;
    // ADR-0019: triggerAgent is authorized through actor → principal-set
    // resolution, then grant/settings composition.
    // A missing open_id is treated as an untrusted event: deny explicitly
    // rather than skipping the check (fail closed).
    const senderOpenId = event.sender.sender_id.open_id ?? "";
    if (senderOpenId === "") {
      deps.logger.warn({ projectId, chatId }, "feishu trigger: sender has no open_id, denying");
      await writeAudit(deps.prisma, { projectId, action: "trigger.denied", metadata: { reason: "missing open_id" } });
      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,
      action: "agent.trigger",
      resource: { type: "PROJECT", id: projectId },
    });
    if (!triggerDecision.allowed) {
      deps.logger.info({ projectId, senderOpenId, reason: triggerDecision.reason }, "feishu trigger: permission denied");
      await writeAudit(deps.prisma, {
        projectId,
        ...(triggerDecision.actorUserId !== undefined ? { actorUserId: triggerDecision.actorUserId } : {}),
        action: "trigger.denied",
        metadata: {
          decision: auditDecision(triggerDecision),
          sender: await senderAuditMetadata(rt, senderOpenId),
        },
      });
      await sendText(rt, chatId, "无权限触发。", sendOptionsForTriggerMessage(msg));
      return;
    }

    let cleanPrompt: string | null = null;
    if (msg.message_type === "text") {
      cleanPrompt = extractPrompt(msg);
    } else if (msg.message_type === "post") {
      const parsed = parsePostMessage(msg.content);
      cleanPrompt = extractPostPrompt(msg, parsed.text);
    } else if (msg.message_type === "file" || msg.message_type === "image") {
      let content: z.infer<typeof FileMessageContentSchema>;
      try {
        content = FileMessageContentSchema.parse(JSON.parse(msg.content));
      } catch (error) {
        rt.logger.warn(
          { err: error, messageId: msg.message_id, messageType: msg.message_type },
          "feishu trigger: invalid file message content",
        );
        return;
      }
      const key = content.file_key ?? content.image_key ?? "";
      if (key !== "") {
        cleanPrompt = "用户发来一个文件";
      }
    }
    if (cleanPrompt === null) return;
    const runContext: TriggerRunContext = {
      msg,
      rt,
      chatId,
      projectId,
      senderOpenId,
      actor,
      roleId: binding.selectedRole.roleId,
    };

    // Hub owns a closed slash-command protocol. Unknown commands fail visibly;
    // they are never downgraded to Agent text or forwarded to the SDK.
    if (cleanPrompt.startsWith("/")) {
      const invocation = parseSlashInvocation(cleanPrompt);
      if (invocation !== null) {
        if (invocation.name === "project") {
          if (invocation.args.length > 0) {
            await sendText(rt, chatId, "用法: /project", sendOptionsForTriggerMessage(msg));
            return;
          }
          await sendCard(
            rt,
            chatId,
            await projectConsoleCard(projectId, chatId, senderOpenId),
            sendOptionsForTriggerMessage(msg),
          );
          deps.logger.info({ chatId, projectId, senderOpenId }, "feishu project console opened");
          return;
        }
        const slashCommand = slashCommands.get(invocation.name);
        if (slashCommand !== undefined) {
          try {
            await slashCommand.run({ invocation, projectId, chatId, rt, sendOptions: sendOptionsForTriggerMessage(msg) });
          } catch (error) {
            if (!(error instanceof InactiveOrganizationError)) throw error;
            deps.logger.info(
              { projectId, organizationId: error.organizationId, status: error.status, command: invocation.name },
              "feishu slash command: organization became inactive before mutation",
            );
            await sendText(rt, chatId, "组织当前不可用，拒绝操作。", sendOptionsForTriggerMessage(msg));
          }
          return;
        }
      }
      const name = invocation?.name ?? cleanPrompt.slice(1).trim();
      await sendText(rt, chatId, `未知 slash 命令 /${name}。使用 /help 查看可用命令。`, sendOptionsForTriggerMessage(msg));
      return;
    }

    if (msg.message_type === "text") {
      const existing = await currentLockRunId(deps.prisma, projectId);
      if (existing !== null) {
        if (deps.rejectWhenBusy === true) {
          await sendText(rt, chatId, "当前项目正在处理中，请稍后重试。", sendOptionsForTriggerMessage(msg));
          return;
        }
        await enqueueLockedTrigger(runContext, cleanPrompt);
        return;
      }
      const key = messageBatchKey(chatId, senderOpenId, runContext.roleId);
      for (const [pendingKey, pendingContext] of batchContexts) {
        if (
          pendingKey !== key &&
          pendingContext.chatId === chatId &&
          pendingContext.senderOpenId === senderOpenId
        ) {
          await messageBatcher.flushNow(pendingKey);
        }
      }
      if (!batchContexts.has(key)) {
        batchContexts.set(key, runContext);
      }
      await messageBatcher.enqueue(chatId, senderOpenId, cleanPrompt, runContext.roleId);
      return;
    }

    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 searchQuery = (extractPrompt(msg) ?? "").trim().slice(0, 100);
    const view = searchQuery === ""
      ? await browseOnboardingView({
          organizationId: organization.organizationId,
          actorFeishuOpenId: senderOpenId,
          isOrgAdmin: isOrgAdminRole(organization.role),
          folderId: null,
          page: 1,
        })
      : await searchOnboardingView({
          organizationId: organization.organizationId,
          actorFeishuOpenId: senderOpenId,
          isOrgAdmin: isOrgAdminRole(organization.role),
          query: searchQuery,
          page: 1,
        });

    await sendCard(
      rt,
      chatId,
      buildUnboundChatOnboardingCard({
        organizationId: organization.organizationId,
        organizationName: organization.organizationName,
        canCreateProject,
        view,
      }),
      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 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", organizationId: siloOrganization.id },
      },
      select: {
        user: { select: { organizationMemberships: {
          where: {
            revokedAt: null,
            organizationId: siloOrganization.id,
            organization: { status: "ACTIVE" },
          },
          select: {
            role: true,
            organization: { select: { id: true, name: true } },
          },
          orderBy: { createdAt: "asc" },
        } } },
      },
    });
    const user = identity?.user ?? (deps.allowLegacyFeishuIdentity === true
      ? await deps.prisma.user.findUnique({
      where: { feishuOpenId },
      select: {
        organizationMemberships: {
          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) {
      const loginUrl = new URL(
        `/auth/feishu/${encodeURIComponent(siloOrganization.slug)}`,
        publicBaseUrl,
      ).toString();
      return {
        status: "error",
        reason: "identity_missing",
        message: `请先通过飞书登录并加入组织：${loginUrl}\n完成后返回群聊重试。`,
      };
    }
    if (user.organizationMemberships.length === 0) {
      return {
        status: "error",
        reason: "membership_missing",
        message: "你尚未加入该组织，或成员资格已被移除。请联系组织管理员。",
      };
    }
    const membership = user.organizationMemberships[0]!;
    return {
      status: "ok",
      organizationId: membership.organization.id,
      organizationName: membership.organization.name,
      role: membership.role,
    };
  }

  async function searchOnboardingView(input: {
    readonly organizationId: string;
    readonly actorFeishuOpenId: string;
    readonly isOrgAdmin: boolean;
    readonly query: string;
    readonly page: number;
  }): Promise<ProjectOnboardingView> {
    return {
      mode: "search",
      result: await discoverBindableProjects({
        prisma: deps.prisma,
        organizationId: input.organizationId,
        actorFeishuOpenId: input.actorFeishuOpenId,
        isOrgAdmin: input.isOrgAdmin,
        query: input.query,
        page: input.page,
      }),
    };
  }

  async function browseOnboardingView(input: {
    readonly organizationId: string;
    readonly actorFeishuOpenId: string;
    readonly isOrgAdmin: boolean;
    readonly folderId: string | null;
    readonly page: number;
  }): Promise<ProjectOnboardingView> {
    return {
      mode: "browse",
      result: await browseBindableFolder({
        prisma: deps.prisma,
        organizationId: input.organizationId,
        actorFeishuOpenId: input.actorFeishuOpenId,
        isOrgAdmin: input.isOrgAdmin,
        folderId: input.folderId,
        page: input.page,
      }),
    };
  }

  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;
}

function readSessionMetadata(metadata: unknown): SessionMetadata {
  if (typeof metadata !== "object" || metadata === null || Array.isArray(metadata)) {
    return {};
  }
  const raw = metadata as Record<string, unknown>;
  const claudeSessionId =
    typeof raw["claudeSessionId"] === "string" && raw["claudeSessionId"] !== ""
      ? raw["claudeSessionId"]
      : undefined;

  return {
    ...(claudeSessionId !== undefined ? { claudeSessionId } : {}),
  };
}

function sessionMetadataPatch(claudeSessionId: string | undefined): SessionMetadata | null {
  const patch: SessionMetadata = {
    ...(claudeSessionId !== undefined ? { claudeSessionId } : {}),
  };
  return Object.keys(patch).length > 0 ? patch : null;
}

function mergeSessionMetadata(metadata: unknown, patch: SessionMetadata): Prisma.InputJsonObject {
  const base =
    typeof metadata === "object" && metadata !== null && !Array.isArray(metadata)
      ? { ...(metadata as Prisma.InputJsonObject) }
      : {};
  if (patch.claudeSessionId !== undefined) base["claudeSessionId"] = patch.claudeSessionId;
  return base;
}

function feishuEventId(event: MessageReceiveEvent): string | undefined {
  return nonEmpty(event.event_id) ?? nonEmpty(event.header?.event_id);
}

function feishuEventType(event: MessageReceiveEvent): string {
  return nonEmpty(event.event_type) ?? nonEmpty(event.header?.event_type) ?? "im.message.receive_v1";
}

function nonEmpty(value: string | undefined): string | undefined {
  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 {
  return { replyToMessageId: msg.message_id };
}

function approvalActionFromValue(value: unknown): string | null {
  if (typeof value === "string") {
    try {
      return approvalActionFromValue(JSON.parse(value));
    } catch {
      return null;
    }
  }
  if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
  if (!("approval_action" in value)) return null;
  const action = value.approval_action;
  return typeof action === "string" && action !== "" ? action : null;
}
function interruptRunFromValue(value: unknown): string | null {
  if (typeof value === "string") {
    try {
      return interruptRunFromValue(JSON.parse(value));
    } catch {
      return null;
    }
  }
  if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
  if (!("interrupt_run" in value)) return null;
  const runId = value.interrupt_run;
  return typeof runId === "string" && runId !== "" ? runId : null;
}

function approvalResolutionFor(action: string): { readonly icon: string; readonly label: string; readonly template: "green" | "red" } {
  const normalized = action.trim().toLowerCase();
  switch (normalized) {
    case "approve":
    case "approved":
      return { icon: "✅", label: "Approved", template: "green" };
    case "accept":
    case "accepted":
      return { icon: "✅", label: "Accepted", template: "green" };
    case "confirm":
    case "confirmed":
    case "yes":
    case "ok":
      return { icon: "✅", label: "Confirmed", template: "green" };
    case "reject":
    case "rejected":
      return { icon: "❌", label: "Rejected", template: "red" };
    case "deny":
    case "denied":
    case "decline":
    case "declined":
      return { icon: "❌", label: "Declined", template: "red" };
    case "cancel":
    case "canceled":
    case "cancelled":
    case "no":
      return { icon: "❌", label: "Cancelled", template: "red" };
    default:
      return { icon: "✅", label: action, template: "green" };
  }
}

interface BuildFeishuTriggerContextArgs {
  readonly msg: MessageReceiveEvent["message"];
  readonly chatId: string;
  readonly projectId: string;
  readonly workspaceDir: string;
  readonly rt: FeishuRuntime;
  readonly senderOpenId: string;
}

async function buildFeishuTriggerContext(args: BuildFeishuTriggerContextArgs): Promise<Prisma.InputJsonObject> {
  const replyToMessageId = nonEmpty(args.msg.parent_id);
  const rootId = nonEmpty(args.msg.root_id);
  const threadId = nonEmpty(args.msg.thread_id);

  const context: Record<string, Prisma.InputJsonValue> = {
    trigger_message_id: args.msg.message_id,
    chat_id: args.chatId,
    sender: await senderAuditMetadata(args.rt, args.senderOpenId),
  };
  if (replyToMessageId !== undefined) context["reply_to_message_id"] = replyToMessageId;
  if (rootId !== undefined) context["root_id"] = rootId;
  if (threadId !== undefined) context["thread_id"] = threadId;

  if (replyToMessageId !== undefined) {
    const rawReplyContext = await readFeishuContext(
      { chat_id: args.chatId, anchor: "reply", id: replyToMessageId },
      {
        runId: "pending",
        projectId: args.projectId,
        boundChatId: args.chatId,
        workspaceDir: args.workspaceDir,
      },
      args.rt,
    );
    context["replied_to_message"] = parseJsonForMetadata(rawReplyContext);
  }

  return context as Prisma.InputJsonObject;
}

function parseJsonForMetadata(text: string): Prisma.InputJsonValue {
  try {
    return JSON.parse(text) as Prisma.InputJsonValue;
  } catch {
    return { error: "invalid json", raw: text };
  }
}

function withFeishuTriggerContext(prompt: string, context: Prisma.InputJsonObject): string {
  return `Feishu trigger context:\n${JSON.stringify(context, null, 2)}\n\nUser request from ${senderLabelFromContext(context)}:\n${prompt}`;
}

function agentRunMetadata(
  roleId: string,
  rawPrompt: string,
  feishuTriggerContext: Prisma.InputJsonObject,
): Prisma.InputJsonObject {
  const metadata: Record<string, Prisma.InputJsonValue> = { roleId, rawPrompt, feishuTriggerContext };
  return metadata as Prisma.InputJsonObject;
}

function senderLabelFromContext(context: Prisma.InputJsonObject): string {
  const sender = context["sender"];
  if (typeof sender !== "object" || sender === null || Array.isArray(sender)) return "unknown sender";
  const senderObject = sender as Record<string, unknown>;
  const name = senderObject["name"];
  if (typeof name === "string" && name.trim() !== "") return name.trim();
  const openId = senderObject["open_id"];
  return typeof openId === "string" && openId.trim() !== "" ? openId.trim() : "unknown sender";
}

function auditDecision(decision: AuthorizationDecision): Record<string, unknown> {
  return {
    allowed: decision.allowed,
    reason: decision.reason,
    action: decision.action,
    requiredRole: decision.requiredRole,
    effectiveRole: decision.effectiveRole ?? null,
    principals: decision.principals.map((principal) => ({
      type: principal.type,
      id: principal.id,
    })),
    matchedGrant: decision.matchedGrant === undefined
      ? null
      : {
          id: decision.matchedGrant.id,
          principal: decision.matchedGrant.principal,
          role: decision.matchedGrant.role,
        },
    matchedRoleGrant: decision.matchedRoleGrant === undefined
      ? null
      : {
          id: decision.matchedRoleGrant.id,
          principal: decision.matchedRoleGrant.principal,
        },
  };
}

async function senderDisplayNameOrOpenId(rt: FeishuRuntime, openId: string): Promise<string> {
  return (await resolveSenderName(rt, openId, senderNameCache)) ?? openId;
}

async function senderAuditMetadata(rt: FeishuRuntime, openId: string): Promise<Prisma.InputJsonObject> {
  const sender: Record<string, Prisma.InputJsonValue> = { open_id: openId };
  const name = await resolveSenderName(rt, openId, senderNameCache);
  if (name !== null) {
    sender["name"] = name;
  }
  return sender as Prisma.InputJsonObject;
}

function withFileDeliveryInstructions(systemPrompt: string | undefined, roleTools: readonly string[] | undefined): string | undefined {
  if (!roleToolsAllow(roleTools, "send_file")) return systemPrompt;
  const fileDeliveryPrompt =
    "When the user asks you to send, resend, attach, or provide a file, call the cph_hub send_file tool with the actual existing file path. " +
    "Do not say a file is attached or sent unless that tool returns success.";
  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);
}

/** Lark text-message content: `{"text":"@_user_1 do something"}`. */
const TextMessageContentSchema = z.object({ text: z.string() });

/** Lark file/image-message content: carries a file_key or image_key. */
const FileMessageContentSchema = z.object({
  file_key: z.string().optional(),
  image_key: z.string().optional(),
});

/**
 * Extract the prompt text from a text message, stripping @mentions.
 * Returns null if the message is empty after stripping or doesn't mention the bot.
 * Lark text content is JSON: `{"text":"@_user_1 do something"}`.
 */
export function extractPrompt(msg: MessageReceiveEvent["message"]): string | null {
  // Check for bot mention — mentions[].id.open_id should include the bot.
  // The dispatcher already routes only message events; we further require
  // that the bot is among the mentions (if no mentions, it's not @bot).
  const mentions = msg.mentions;
  if (mentions === undefined || mentions.length === 0) return null;

  try {
    const text = TextMessageContentSchema.parse(JSON.parse(msg.content)).text;
    // Strip @mentions (@_user_1 etc.) and trim; remainder is the prompt.
    const stripped = text.replace(/@_\w+\s*/g, "").trim();
    return stripped === "" ? null : stripped;
  } catch {
    return null;
  }
}

function extractPostPrompt(msg: MessageReceiveEvent["message"], parsedText: string): string | null {
  const mentions = msg.mentions;
  if (mentions === undefined || mentions.length === 0) return null;

  const stripped = stripParsedPostMentions(parsedText, mentions).trim();
  return stripped === "" ? null : stripped;
}

function stripParsedPostMentions(
  text: string,
  mentions: NonNullable<MessageReceiveEvent["message"]["mentions"]>,
): string {
  let stripped = text;
  for (const mention of mentions) {
    stripped = removeMentionMarker(stripped, mention.key);
    stripped = removeMentionMarker(stripped, `@${mention.name}`);
  }
  return stripped;
}

function removeMentionMarker(text: string, marker: string): string {
  if (marker === "") return text;
  return text.replace(new RegExp(`${escapeRegExp(marker)}\\s*`, "g"), "");
}

function escapeRegExp(value: string): string {
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

function isPrismaUniqueConstraintError(error: unknown): boolean {
  return typeof error === "object" && error !== null && "code" in error && error.code === "P2002";
}

async function stageTriggerMessageResources(
  rt: FeishuRuntime,
  msg: MessageReceiveEvent["message"],
  workspaceRoot: string,
  limits?: TriggerDeps["resourceLimits"],
): Promise<StagedMessageResourceBatch | null> {
  const requests: MessageResourceStageRequest[] = [];
  if (msg.message_type === "post") {
    const parsed = parsePostMessage(msg.content);
    for (const imageKey of parsed.imageKeys) {
      requests.push({
        fileKey: imageKey,
        resourceType: "image",
        workspaceRelativePath: `.cph/inbox/post-image-${randomUUID()}.png`,
      });
    }
    for (const fileKey of parsed.fileKeys) {
      requests.push({
        fileKey,
        resourceType: "file",
        workspaceRelativePath: `.cph/inbox/post-file-${randomUUID()}.bin`,
      });
    }
  } else if (msg.message_type === "file" || msg.message_type === "image") {
    const content = FileMessageContentSchema.parse(JSON.parse(msg.content));
    const fileKey = content.file_key ?? content.image_key;
    if (fileKey !== undefined && fileKey !== "") {
      requests.push({
        fileKey,
        resourceType: msg.message_type,
        workspaceRelativePath: `.cph/inbox/${msg.message_type}-${randomUUID()}.${msg.message_type === "image" ? "png" : "bin"}`,
      });
    }
  }
  return stageMessageResources(
    rt,
    msg.message_id,
    requests,
    workspaceRoot,
    limits === undefined
      ? undefined
      : { maxFiles: limits.maxFilesPerMessage, maxBytesPerFile: limits.maxBytesPerFile },
  );
}

function appendStagedResourcePaths(
  prompt: string,
  batch: StagedMessageResourceBatch | null,
  workspaceDir: string,
): string {
  const resources = batch?.resources ?? [];
  if (resources.length === 0) return prompt;
  const paths = resources.map((resource) => {
    const label = resource.resourceType === "image" ? "图片" : "文件";
    return `- ${label}: ${join(workspaceDir, resource.workspaceRelativePath)}`;
  });
  return `${prompt}\n\n消息附件已下载到项目工作区，可直接读取以下本地文件：\n${paths.join("\n")}`;
}

async function compensateFailedResourceAdmission(
  primaryError: unknown,
  batch: StagedMessageResourceBatch,
  workspaceRoot: string,
  workspaceDir: string,
): Promise<void> {
  const cleanup = await Promise.allSettled([
    removeStagedMessageResources(batch),
    compensatePublishedMessageResources(batch.publishedResources, workspaceRoot, workspaceDir),
  ]);
  const failures = cleanup.flatMap((result) => result.status === "rejected" ? [result.reason] : []);
  if (failures.length > 0) {
    throw new AggregateError(
      [primaryError, ...failures],
      "Agent admission failed and Feishu resource compensation was incomplete",
      { cause: primaryError },
    );
  }
}
