Ctrl K
ComunidadGuías…

backend-ts/src/feishu-cards.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import * as Lark from '@larksuiteoapi/node-sdk';
import { createFeishuPatchedProgress, type FeishuProgressHandle } from './feishu/patched-progress.js';
import type { FeishuCardProgress } from './feishu/card-progress-listener.js';
import { buildFeishuProgressCard } from './feishu/progress-card.js';
import type { FeishuInboundContext, FeishuNormalizedMessage } from './feishu/types.js';
import type { FeishuMessageService } from './feishu/message.service.js';
import type { FeishuActiveProgressRegistry } from './feishu/active-progress.js';
import type { FeishuAskFormStore } from './feishu/ask-form-store.js';
import type { MysqlFeishuProgressCardRepository } from './feishu/progress-card.repository.js';
import type { MysqlFeishuCardSnapshotRepository } from './feishu/card-snapshot.repository.js';
import type { MysqlFeishuPendingBindingRepository } from './feishu/pending-binding.repository.js';
import type { BackgroundSubagentManager } from './harness/delegate/background-subagent-manager.js';
import type { FeedbackDbLookup } from './feedback/feedback.service.js';
import type { SessionService } from './session/session.service.js';
import type { EcpAuthService } from './auth/ecp-auth.service.js';
import type { FeishuAuthService } from './auth/feishu-auth.service.js';
import {
  buildFeishuAuthGuideCard,
  feishuUnauthorizedGuide,
  feishuUnauthorizedFallbackText,
  persistFeishuPendingAuth,
  startFeishuChannelAuthLink,
  type FeishuUnauthorizedGuide,
} from './feishu/ecp-inbound-gate.js';
import { parseSqlTimeMs } from './app-utils.js';

// ========== 排队卡片 ==========

export function buildFeishuQueueCard(context: FeishuInboundContext, queueId: number, position: number): Record<string, unknown> {
  const summary = context.text.length > 60 ? `${context.text.slice(0, 60)}…` : context.text;
  const showSender = context.chatType === 'group';
  const senderLabel = (context.senderLabel?.trim() || '用户');
  const body = showSender ? `${senderLabel}:${summary}` : summary;
  return {
    schema: '2.0',
    config: { update_multi: true },
    body: {
      direction: 'vertical', padding: '12px 12px 12px 12px',
      elements: [
        { tag: 'markdown', content: '**⏳ 任务排队中**', text_align: 'left', text_size: 'normal_v2' },
        { tag: 'markdown', content: `当前任务正在执行中,这条消息已进入队列(第 ${position} 位),将在当前任务完成后自动开始处理。`, text_align: 'left', text_size: 'normal_v2' },
        { tag: 'markdown', content: body, text_align: 'left', text_size: 'normal_v2' },
        {
          tag: 'column_set', flex_mode: 'flow', background_style: 'default',
          columns: [
            { tag: 'column', width: 'auto', vertical_align: 'top', elements: [{ tag: 'button', text: { tag: 'plain_text', content: '立即发送' }, type: 'primary', size: 'sm', value: { kind: 'feishu_queue', queueId, act: 'run' } }] },
            { tag: 'column', width: 'auto', vertical_align: 'top', elements: [{ tag: 'button', text: { tag: 'plain_text', content: '取消排队' }, type: 'default', size: 'sm', value: { kind: 'feishu_queue', queueId, act: 'cancel' } }] },
          ],
        },
      ],
    },
  };
}

export interface FeishuQueueCardDeps {
  getFeishuClient: (botId: number) => Promise<Lark.Client | null>;
  feishuMessageService: FeishuMessageService;
}

export function createFeishuQueueCardHelper(deps: FeishuQueueCardDeps) {
  const { getFeishuClient, feishuMessageService } = deps;

  return async (context: FeishuInboundContext, queueId: number, position: number, sessionId: number): Promise<string | null> => {
    const client = await getFeishuClient(Number(context.accountId));
    if (client == null) return null;
    const data = { msg_type: 'interactive', content: JSON.stringify(buildFeishuQueueCard(context, queueId, position)) };
    const response = await (context.chatType === 'group' && context.messageId != null
      ? client.im.v1.message.reply({ path: { message_id: context.messageId }, data })
      : client.im.v1.message.create({ params: { receive_id_type: 'open_id' }, data: { ...data, receive_id: context.senderId! } }));
    const cardMessageId = (response as { data?: { message_id?: string } }).data?.message_id ?? null;
    if (cardMessageId != null && context.chatType === 'p2p') {
      await feishuMessageService.recordP2pMessage(context.accountId, cardMessageId, sessionId, 'OUT');
    }
    return cardMessageId;
  };
}

// ========== 进度卡片 PATCH ==========

export interface PatchedProgressDeps {
  feishuActiveProgress: FeishuActiveProgressRegistry;
  feishuAskFormStore: FeishuAskFormStore;
  feishuCardSnapshotRepo: MysqlFeishuCardSnapshotRepository;
  backgroundSubagentManager: BackgroundSubagentManager;
  feedbackMessageLookup: FeedbackDbLookup;
}

export function createPatchedProgressHelper(deps: PatchedProgressDeps) {
  const { feishuActiveProgress, feishuAskFormStore, feishuCardSnapshotRepo, backgroundSubagentManager, feedbackMessageLookup } = deps;

  return (
    client: Lark.Client,
    cardMessageId: string,
    sessionId: number,
    cancelAction: { sessionId: number; sender: string; botId?: number } | null,
    startedAtMs: number,
    sessionDetailUrl?: string,
    seed?: { round?: number; content?: string },
    botId?: number,
  ): FeishuCardProgress => {
    const gate: { progress: FeishuProgressHandle | null } = { progress: null };
    const progress = createFeishuPatchedProgress({
      listAsks: () => (
        gate.progress != null && feishuActiveProgress.current(sessionId) === gate.progress
          ? feishuAskFormStore.list(sessionId)
          : []
      ),
      clearAsks: () => {
        if (gate.progress != null && feishuActiveProgress.current(sessionId) === gate.progress) {
          feishuAskFormStore.clearSession(sessionId);
        }
      },
      patch: async (card) => {
        await client.im.v1.message.patch({ path: { message_id: cardMessageId }, data: { content: JSON.stringify(card) } });
      },
      buildCard: ({ status, round, content, tools, subagents, pendingAsks, elapsedMs, disliked }) => buildFeishuProgressCard(
        status, round, content, tools, cancelAction ?? undefined, elapsedMs, sessionDetailUrl, pendingAsks, disliked, subagents,
      ),
      loadSubagents: async () => {
        const running = await backgroundSubagentManager.listRunning(sessionId);
        return running.map((subagent) => ({
          taskId: subagent.taskId,
          agentType: subagent.agentType,
          title: subagent.title,
          status: subagent.status,
          round: subagent.round,
        }));
      },
      persistTerminal: botId == null ? undefined : async ({ round, content, elapsedMs, messageId }) => {
        const terminalMessageId = messageId ?? await feedbackMessageLookup.findLatestAssistantMessageId(sessionId);
        if (terminalMessageId == null) {
          console.warn(`飞书完成卡快照跳过: 终态时未找到ASSISTANT消息, sessionId=${sessionId}, cardMessageId=${cardMessageId}`);
          return;
        }
        await feishuCardSnapshotRepo.upsert({
          cardMessageId, sessionId, botId, messageId: terminalMessageId,
          status: 'COMPLETED', round, elapsedMs: elapsedMs ?? 0, content,
        });
      },
      startedAtMs,
      seed: { status: 'RUNNING', round: seed?.round ?? 0, content: seed?.content ?? '', tools: [] },
    });
    gate.progress = progress;
    feishuActiveProgress.bind(sessionId, cancelAction?.sender ?? '', progress, cardMessageId);
    return progress;
  };
}

// ========== 进度卡片创建 ==========

export interface FeishuProgressCardDeps {
  getFeishuClient: (botId: number) => Promise<Lark.Client | null>;
  feishuMessageService: FeishuMessageService;
  feishuProgressCardRepo: MysqlFeishuProgressCardRepository;
  resolveFeishuSessionDetailUrl: (sessionId: number) => Promise<string | undefined>;
  createPatchedProgress: (
    client: Lark.Client,
    cardMessageId: string,
    sessionId: number,
    cancelAction: { sessionId: number; sender: string; botId?: number } | null,
    startedAtMs: number,
    sessionDetailUrl?: string,
    seed?: { round?: number; content?: string },
    botId?: number,
  ) => FeishuCardProgress;
}

export function createFeishuProgressCardHelper(deps: FeishuProgressCardDeps) {
  const { getFeishuClient, feishuMessageService, feishuProgressCardRepo, resolveFeishuSessionDetailUrl, createPatchedProgress } = deps;

  return async (context: FeishuInboundContext, sessionId: number): Promise<FeishuCardProgress | null> => {
    const botId = Number(context.accountId);
    const client = await getFeishuClient(botId);
    if (client == null) return null;
    const cancelAction = { sessionId, sender: context.senderId ?? '', botId };
    const sessionDetailUrl = await resolveFeishuSessionDetailUrl(sessionId);
    const persistCard = async (cardMessageId: string): Promise<void> => {
      try {
        await feishuProgressCardRepo.upsert({
          sessionId, botId, cardMessageId,
          chatType: context.chatType,
          chatId: context.chatId,
          senderOpenId: context.senderId,
        });
      } catch (error) {
        console.warn(`飞书进度卡片映射持久化失败, sessionId=${sessionId}: ${error instanceof Error ? error.message : String(error)}`);
      }
    };
    const existingMessageId = context.progressCardMessageId;
    const startedAtMs = Date.now();
    if (existingMessageId != null) {
      const card = buildFeishuProgressCard('RUNNING', 0, '任务已接收,正在准备执行。', [], cancelAction, undefined, sessionDetailUrl);
      await client.im.v1.message.patch({ path: { message_id: existingMessageId }, data: { content: JSON.stringify(card) } });
      await persistCard(existingMessageId);
      return createPatchedProgress(client, existingMessageId, sessionId, cancelAction, startedAtMs, sessionDetailUrl, {
        content: '任务已接收,正在准备执行。',
      }, botId);
    }
    const card = buildFeishuProgressCard('RUNNING', 0, '任务已接收,正在准备执行。', [], cancelAction, undefined, sessionDetailUrl);
    const data = { msg_type: 'interactive', content: JSON.stringify(card) };
    const response = await (context.chatType === 'group' && context.messageId != null
      ? client.im.v1.message.reply({ path: { message_id: context.messageId }, data })
      : client.im.v1.message.create({
        params: { receive_id_type: context.chatType === 'group' ? 'chat_id' : 'open_id' },
        data: { ...data, receive_id: context.chatType === 'group' ? context.chatId! : context.senderId! },
      }));
    const messageId = (response as { data?: { message_id?: string } }).data?.message_id;
    if (messageId == null || messageId === '') throw new Error('飞书处理中卡片发送失败:未返回 message_id');
    await persistCard(messageId);
    if (context.chatType === 'p2p') await feishuMessageService.recordP2pMessage(String(botId), messageId, sessionId, 'OUT');
    return createPatchedProgress(client, messageId, sessionId, cancelAction, startedAtMs, sessionDetailUrl, {
      content: '任务已接收,正在准备执行。',
    }, botId);
  };
}

// ========== 恢复进度卡片 ==========

export interface FeishuRecoveryProgressDeps {
  getFeishuClient: (botId: number) => Promise<Lark.Client | null>;
  feishuProgressCardRepo: MysqlFeishuProgressCardRepository;
  sessionService: SessionService;
  resolveFeishuSessionDetailUrl: (sessionId: number) => Promise<string | undefined>;
  resolveFeishuRoundOffset: (sessionId: number) => Promise<number>;
  createPatchedProgress: (
    client: Lark.Client,
    cardMessageId: string,
    sessionId: number,
    cancelAction: { sessionId: number; sender: string; botId?: number } | null,
    startedAtMs: number,
    sessionDetailUrl?: string,
    seed?: { round?: number; content?: string },
    botId?: number,
  ) => FeishuCardProgress;
}

export function createFeishuRecoveryProgressHelper(deps: FeishuRecoveryProgressDeps) {
  const { getFeishuClient, feishuProgressCardRepo, sessionService, resolveFeishuSessionDetailUrl, resolveFeishuRoundOffset, createPatchedProgress } = deps;

  return async (sessionId: number): Promise<{ progress: FeishuCardProgress; roundOffset: number } | null> => {
    try {
      const row = await feishuProgressCardRepo.findBySessionId(sessionId);
      if (row == null) {
        console.warn(`飞书恢复进度卡片无映射, sessionId=${sessionId}`);
        return null;
      }
      const client = await getFeishuClient(row.botId);
      if (client == null) {
        console.warn(`飞书恢复进度卡片无法创建客户端, sessionId=${sessionId} botId=${row.botId}`);
        return null;
      }
      const cancelAction = row.senderOpenId != null && row.senderOpenId !== ''
        ? { sessionId, sender: row.senderOpenId, botId: row.botId }
        : null;
      const sessionDetailUrl = await resolveFeishuSessionDetailUrl(sessionId);
      const session = await sessionService.getSession(sessionId).catch(() => null);
      const startedAtMs = parseSqlTimeMs(session?.startedAt) ?? Date.now();
      const progress = createPatchedProgress(client, row.cardMessageId, sessionId, cancelAction, startedAtMs, sessionDetailUrl, {
        content: '任务正在恢复执行。',
      }, row.botId);
      const roundOffset = await resolveFeishuRoundOffset(sessionId);
      try {
        await progress.update('RUNNING', roundOffset, '任务正在恢复执行。', []);
      } catch (error) {
        console.warn(`飞书恢复进度卡片首次 PATCH 失败, sessionId=${sessionId}: ${error instanceof Error ? error.message : String(error)}`);
      }
      return { progress, roundOffset };
    } catch (error) {
      console.warn(`飞书恢复进度卡片加载失败, sessionId=${sessionId}: ${error instanceof Error ? error.message : String(error)}`);
      return null;
    }
  };
}

// ========== 未授权卡片 ==========

export interface SendUnauthorizedCardDeps {
  getFeishuClient: (botId: number) => Promise<Lark.Client | null>;
  ecpAuth: EcpAuthService;
  feishu: FeishuAuthService;
  pendingBindingMessages: MysqlFeishuPendingBindingRepository;
}

export function createSendUnauthorizedCardHelper(deps: SendUnauthorizedCardDeps) {
  const { getFeishuClient, ecpAuth, feishu, pendingBindingMessages } = deps;

  return async (accountId: string, event: FeishuNormalizedMessage): Promise<boolean> => {
    const client = await getFeishuClient(Number(accountId));
    if (client == null || event.messageId == null) return false;
    if (event.chatType === 'group' && event.chatId == null) return false;
    if (event.chatType === 'p2p' && event.senderId == null) return false;
    let auth: { authUrl: string; state: string } | null = null;
    try {
      auth = await startFeishuChannelAuthLink({
        ecpEnabled: await ecpAuth.isEnabled(),
        feishuEnabled: await feishu.isEnabled(),
        // 飞书通道用 channel 回调:外网用户点卡片也能落到公网可访问的服务端渲染页;
        // 未配置 channelCallbackUrl 时自动回落到 desktopCallbackUrl,行为与旧版一致。
        startEcp: () => ecpAuth.startFeishuLogin('channel'),
        startFeishu: () => feishu.getQrCodeUrl(),
      });
    } catch (error) {
      console.error('飞书未授权引导获取登录链接失败', error);
      return false;
    }
    if (auth == null || auth.authUrl.trim() === '') return false;
    const inboundMessageId = event.messageId;
    const persisted = await persistFeishuPendingAuth(
      () => pendingBindingMessages.insert({ state: auth.state, appId: Number(accountId), messageId: inboundMessageId, event }),
      auth.state,
    );
    const ecpEnabled = await ecpAuth.isEnabled();
    const guide = feishuUnauthorizedGuide(ecpEnabled, event.chatType);
    const card = buildFeishuAuthGuideCard(guide, auth.authUrl, event.senderId != null ? { sender: event.senderId } : undefined);
    const data = { msg_type: 'interactive' as const, content: JSON.stringify(card) };
    let response;
    try {
      response = await (event.chatType === 'group'
        ? client.im.v1.message.reply({ path: { message_id: inboundMessageId }, data })
        : client.im.v1.message.create({
            params: { receive_id_type: 'open_id' },
            data: { ...data, receive_id: event.senderId! },
          }));
    } catch (error) {
      if (persisted) await pendingBindingMessages.fail(auth.state);
      throw error;
    }
    const cardMessageId = (response as { data?: { message_id?: string } }).data?.message_id;
    if (cardMessageId == null || cardMessageId === '') {
      if (persisted) await pendingBindingMessages.fail(auth.state);
      return false;
    }
    if (persisted) {
      try {
        await pendingBindingMessages.setCardMessageId(auth.state, cardMessageId);
      } catch (error) {
        console.error(`飞书登录引导卡片已发送但回写 card_message_id 失败, state=${auth.state}`, error);
      }
    }
    return true;
  };
}

// ========== 重新获取授权链接 ==========

export interface RenewAuthLinkDeps {
  ecpAuth: EcpAuthService;
  feishu: FeishuAuthService;
  pendingBindingMessages: MysqlFeishuPendingBindingRepository;
}

export interface RenewAuthLinkResult {
  ok: boolean;
  authUrl?: string;
  guide?: FeishuUnauthorizedGuide;
  message?: string;
}

/**
 * 绑定卡「重新获取链接」:原链接 3 分钟内有效,过期后由用户主动换一张。
 * 沿用原卡片记录里的入站消息(state → appId/messageId/event),换链接后原消息仍会被重放。
 */
export function createRenewAuthLinkHelper(deps: RenewAuthLinkDeps) {
  const { ecpAuth, feishu, pendingBindingMessages } = deps;
  return async (params: { botId: number; cardMessageId: string; sender: string }): Promise<RenewAuthLinkResult> => {
    const pending = await pendingBindingMessages.findByCardMessageId(params.cardMessageId);
    if (pending == null) {
      return { ok: false, message: '这张卡片已失效,请在飞书里重新发送一条消息' };
    }
    const pendingSender = pending.event.senderId ?? '';
    if (pendingSender !== '' && pendingSender !== params.sender) {
      return { ok: false, message: '仅消息发送者可操作' };
    }
    let link: { authUrl: string; state: string } | null = null;
    try {
      link = await startFeishuChannelAuthLink({
        ecpEnabled: await ecpAuth.isEnabled(),
        feishuEnabled: await feishu.isEnabled(),
        startEcp: () => ecpAuth.startFeishuLogin('channel'),
        startFeishu: () => feishu.getQrCodeUrl(),
      });
    } catch (error) {
      console.error('飞书绑定卡重新获取授权链接失败', error);
      return { ok: false, message: '获取新的授权链接失败,请稍后重试' };
    }
    if (link == null || link.authUrl.trim() === '') {
      return { ok: false, message: '当前未开启飞书登录,请联系管理员' };
    }
    // 旧链接作废:同一张卡片上其它待完成记录全部标记失败,避免重启恢复时把同一条消息再重放一次。
    await pendingBindingMessages.insert({
      state: link.state,
      appId: pending.appId,
      messageId: pending.messageId,
      event: pending.event,
      cardMessageId: params.cardMessageId,
    });
    await pendingBindingMessages.setCardMessageId(link.state, params.cardMessageId);
    await pendingBindingMessages.failOthersForCard(params.cardMessageId, link.state);
    const guide = feishuUnauthorizedGuide(await ecpAuth.isEnabled(), pending.event.chatType);
    return { ok: true, authUrl: link.authUrl, guide };
  };
}

// ========== 未授权文本 ==========

export interface UnauthorizedTextDeps {
  ecpAuth: EcpAuthService;
}

export function createUnauthorizedTextHelper(deps: UnauthorizedTextDeps) {
  const { ecpAuth } = deps;

  return async (_accountId: string, event: FeishuNormalizedMessage): Promise<string> => {
    const ecpEnabled = await ecpAuth.isEnabled();
    return feishuUnauthorizedFallbackText(feishuUnauthorizedGuide(ecpEnabled, event.chatType));
  };
}