kevin.yang 3 dni temu
rodzic
commit
a51025d606

+ 28 - 8
agent/tools/builtin/feishu/http_adapter_tools.py

@@ -278,6 +278,8 @@ async def feishu_adapter_tool_call(
 
     **params**:须为对象;若上游误传 JSON 字符串,会尝试解析,失败则报错(不再静默发空 params)。
 
+    **context_patch**:须为对象或合法 JSON 对象字符串;误传字符串时同样会解析。
+
     **tool**:与 ``GET /tools`` 的 ``name`` 完全一致;命名约定见注入的 feishu-bitable 等 SKILL,不确定时先 ``feishu_adapter_list_tools``。
     """
     name = (tool or "").strip()
@@ -288,10 +290,14 @@ async def feishu_adapter_tool_call(
     if err is not None:
         return err
 
+    patch_dict, patch_err = _coerce_tool_params(context_patch, tool_name=name, label="context_patch")
+    if patch_err is not None:
+        return patch_err
+
     adapter = await _load_feishu_adapter(context)
     uid = await _resolve_uid_for_adapter(context)
 
-    node_ctx = _merge_to_node_context(adapter, context_patch, uid)
+    node_ctx = _merge_to_node_context(adapter, patch_dict or None, uid)
     body: Dict[str, Any] = {
         "tool": name,
         "params": coerced,
@@ -351,7 +357,13 @@ async def feishu_adapter_tool_calls_batch(
     adapter = await _load_feishu_adapter(context)
     uid = await _resolve_uid_for_adapter(context)
 
-    base_ctx = _merge_to_node_context(adapter, context_patch, uid)
+    batch_patch, batch_patch_err = _coerce_tool_params(
+        context_patch, tool_name="feishu_adapter_tool_calls_batch", label="context_patch"
+    )
+    if batch_patch_err is not None:
+        return batch_patch_err
+
+    base_ctx = _merge_to_node_context(adapter, batch_patch or None, uid)
     norm_calls: list[dict[str, Any]] = []
     for i, raw in enumerate(calls):
         if not isinstance(raw, dict):
@@ -359,20 +371,28 @@ async def feishu_adapter_tool_calls_batch(
         tname = raw.get("tool")
         if not isinstance(tname, str) or not tname.strip():
             return ToolResult(title="参数错误", output=f"calls[{i}].tool 无效", error="missing_tool")
+        tname_stripped = tname.strip()
         p_raw = raw.get("params")
-        p, p_err = _coerce_tool_params(p_raw, tool_name=tname.strip(), label=f"calls[{i}].params")
+        p, p_err = _coerce_tool_params(p_raw, tool_name=tname_stripped, label=f"calls[{i}].params")
         if p_err is not None:
             return ToolResult(
                 title="参数错误",
                 output=f"calls[{i}]: {p_err.output}",
                 error=p_err.error or "invalid_params",
             )
-        c_extra = raw.get("context")
+        c_extra, c_err = _coerce_tool_params(
+            raw.get("context"), tool_name=tname_stripped, label=f"calls[{i}].context"
+        )
+        if c_err is not None:
+            return ToolResult(
+                title="参数错误",
+                output=f"calls[{i}]: {c_err.output}",
+                error=c_err.error or "invalid_context",
+            )
         merged = dict(base_ctx)
-        if isinstance(c_extra, dict):
-            for k, v in c_extra.items():
-                if v is not None and v != "":
-                    merged[k] = v
+        for k, v in c_extra.items():
+            if v is not None and v != "":
+                merged[k] = v
         norm_calls.append(
             {
                 "tool": tname.strip(),

+ 2 - 2
gateway/core/channels/feishu/manager.py

@@ -24,7 +24,7 @@ class FeishuChannelConfig:
     workspace_prefix: str = "feishu"
     default_agent_type: str = "personal_assistant"
     dispatch_reactions: bool = False
-    dispatch_card_actions: bool = False
+    dispatch_card_actions: bool = True  # 卡片授权等交互后需续跑 Agent;可用 CHANNELS_DISPATCH_CARD_ACTIONS=false 关闭
     agent_api_base_url: str = "http://127.0.0.1:8000"
     agent_run_model: str = "qwen3.5-flash"
     agent_run_max_iterations: int = 200
@@ -104,7 +104,7 @@ class FeishuChannelManager(ChannelRegistry):
                 feishu_http_base_url=os.getenv("FEISHU_HTTP_BASE_URL", "http://127.0.0.1:4380").strip(),
                 http_timeout=float(os.getenv("FEISHU_HTTP_TIMEOUT", "120")),
                 dispatch_reactions=os.getenv("CHANNELS_DISPATCH_REACTIONS", "false").lower() in ("1", "true", "yes"),
-                dispatch_card_actions=os.getenv("CHANNELS_DISPATCH_CARD_ACTIONS", "false").lower()
+                dispatch_card_actions=os.getenv("CHANNELS_DISPATCH_CARD_ACTIONS", "true").lower()
                 in ("1", "true", "yes"),
                 agent_api_base_url=os.getenv("GATEWAY_AGENT_API_BASE_URL", "http://127.0.0.1:8000").strip(),
                 agent_run_model=os.getenv("FEISHU_AGENT_RUN_MODEL", "qwen3.5-flash").strip(),

+ 5 - 7
gateway/core/channels/feishu/openclaw-lark-patch/src/http/openclaw-plugin-sdk.ts

@@ -72,13 +72,11 @@ export function recordPendingHistoryEntryIfEnabled(_params: {
   limit?: number;
 }): void {}
 
-export function resolveSenderCommandAuthorization(_params: {
-  cfg: unknown;
-  accountId: string;
-  chatId: string;
-  senderId: string;
-}): boolean {
-  return true;
+/** 与真实 plugin-sdk 一致:返回 Promise,供 inbound handler 解构 commandAuthorized。 */
+export async function resolveSenderCommandAuthorization(_params: Record<string, unknown>): Promise<{
+  commandAuthorized: boolean;
+}> {
+  return { commandAuthorized: true };
 }
 
 export function isNormalizedSenderAllowed(_params: {

+ 36 - 0
gateway/core/channels/feishu/openclaw-lark-patch/src/http/server.ts

@@ -808,6 +808,38 @@ function isEventAppIdValid(expectedAppId: string | undefined, data: unknown): bo
   return true
 }
 
+/** 从 card.action.trigger 载荷解析会话/消息 ID,供 Gateway 续跑 Agent(须带 chat_id 才能 reply) */
+function extractCardActionRoutingFields(data: unknown): {
+  chatId?: string
+  messageId?: string
+  chatType?: string
+} {
+  if (data == null || typeof data !== 'object') return {}
+  const root = data as Record<string, unknown>
+  const inner =
+    root.event != null && typeof root.event === 'object'
+      ? (root.event as Record<string, unknown>)
+      : root
+  const ctx =
+    inner.context != null && typeof inner.context === 'object'
+      ? (inner.context as Record<string, unknown>)
+      : {}
+  const str = (v: unknown): string | undefined =>
+    typeof v === 'string' && v.length > 0 ? v : undefined
+  const chatId =
+    str(inner.open_chat_id) ??
+    str(inner.chat_id) ??
+    str(ctx.open_chat_id) ??
+    str(ctx.chat_id)
+  const messageId =
+    str(inner.open_message_id) ??
+    str(inner.message_id) ??
+    str(ctx.open_message_id) ??
+    str(ctx.message_id)
+  const chatType = str(inner.chat_type) ?? str(ctx.chat_type)
+  return { chatId, messageId, chatType }
+}
+
 function createMessageDedupForAccount(account: {
   config?: { dedup?: { ttlMs?: number; maxEntries?: number } }
 }): MessageDedup {
@@ -947,11 +979,15 @@ async function startFeishuLongConnections() {
               const openId = event.operator?.open_id
               const action = event.action?.value?.action
               const operationId = event.action?.value?.operation_id
+              const { chatId, messageId, chatType } = extractCardActionRoutingFields(data)
               const normalized = {
                 event_type: 'card_action',
                 app_id: account.appId,
                 account_id: account.accountId,
                 open_id: openId,
+                chat_id: chatId,
+                message_id: messageId,
+                chat_type: chatType,
                 action,
                 operation_id: operationId,
                 raw: data,

+ 1146 - 0
gateway/core/channels/feishu/openclaw-lark-patch/src/tools/auto-auth.ts

@@ -0,0 +1,1146 @@
+/**
+ * Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ *
+ * auto-auth.ts — 工具层自动授权处理。
+ *
+ * 当 OAPI 工具遇到授权问题时,直接在工具层处理,不再让 AI 判断:
+ *
+ * - UserAuthRequiredError (appScopeVerified=true)
+ *   → 直接调用 executeAuthorize 发起 OAuth Device Flow 卡片
+ *
+ * - UserScopeInsufficientError
+ *   → 直接调用 executeAuthorize(使用 missingScopes)
+ *
+ * - AppScopeMissingError
+ *   → 发送应用权限引导卡片;用户点击"我已完成"后:
+ *     1. 更新卡片为处理中状态
+ *     2. invalidateAppScopeCache
+ *     3. 发送中间合成消息告知 AI("应用权限已确认,正在发起用户授权...")
+ *     4. 调用 executeAuthorize 发起 OAuth Device Flow
+ *
+ * - 其他情况(AppScopeCheckFailedError、appScopeVerified=false 等)
+ *   → 回退到原 handleInvokeError(不触发自动授权)
+ *
+ * 降级策略(保守):以下情况均回退到 handleInvokeError:
+ * - 无 LarkTicket(非消息场景)
+ * - 无 senderOpenId(无法确定授权对象)
+ * - 账号未配置(!acct.configured)
+ * - 任何步骤抛出异常
+ */
+
+import type { ClawdbotConfig } from 'openclaw/plugin-sdk';
+import type { ConfiguredLarkAccount, LarkBrand } from '../core/types';
+import type { LarkTicket } from '../core/lark-ticket';
+import { getTicket } from '../core/lark-ticket';
+import { larkLogger } from '../core/lark-logger';
+
+const log = larkLogger('tools/auto-auth');
+import { getLarkAccount } from '../core/accounts';
+import { UserAuthRequiredError, UserScopeInsufficientError, AppScopeMissingError } from '../core/tool-client';
+import { invalidateAppScopeCache, getAppGrantedScopes, isAppScopeSatisfied } from '../core/app-scope-checker';
+import { LarkClient } from '../core/lark-client';
+import { createCardEntity, sendCardByCardId, updateCardKitCardForAuth } from '../card/cardkit';
+import { executeAuthorize } from './oauth';
+import { formatLarkError, json } from './oapi/helpers';
+import { OwnerAccessDeniedError } from '../core/owner-policy';
+import { enqueueFeishuChatTask } from '../channel/chat-queue';
+import { handleFeishuMessage } from '../messaging/inbound/handler';
+import { withTicket } from '../core/lark-ticket';
+import {
+  feishuInboundNeedsGatewayWebhook,
+  forwardSyntheticTextMessageToGateway,
+} from './gateway-inbound-forward';
+
+// ---------------------------------------------------------------------------
+// Debounce + scope merge — 防抖缓冲区(两阶段)
+//
+// 工具调用可能是真正并发(50ms 内到达)或被框架序列化(间隔数秒到达)。
+// 为同时覆盖两种场景,采用两阶段设计:
+//
+//   collecting(收集阶段):50ms 防抖窗口,合并 scope
+//   executing(执行阶段):flushFn 正在运行,后续请求复用同一结果
+//
+// 从 collecting → executing 转换时不从 Map 中删除 entry,
+// 直到 flushFn 完成(resolve / reject)才移除。
+// ---------------------------------------------------------------------------
+
+type JsonResult = ReturnType<typeof json>;
+
+/** 缓冲中的授权请求 */
+interface AuthBatchEntry {
+  phase: 'collecting' | 'executing';
+  scopes: Set<string>;
+  waiters: Array<{ resolve: (v: JsonResult) => void; reject: (e: unknown) => void }>;
+  timer: ReturnType<typeof setTimeout> | null;
+  /** flushFn 执行中的 Promise(executing 阶段有值) */
+  resultPromise: Promise<JsonResult> | null;
+  /** executing 阶段:新 scope 到达时的延迟刷新定时器 */
+  updateTimer: ReturnType<typeof setTimeout> | null;
+  /** scope 更新的 executeAuthorize 是否正在执行(互斥锁) */
+  isUpdating: boolean;
+  /** isUpdating 期间又有新 scope 到达,需要再更新一轮 */
+  pendingReupdate: boolean;
+  /** flushFn 引用,executing 阶段用于 scope 更新时重新调用 */
+  flushFn: ((mergedScopes: string[]) => Promise<JsonResult>) | null;
+  /** 以下字段来自第一个入队的请求,后续请求复用 */
+  account: ConfiguredLarkAccount;
+  cfg: ClawdbotConfig;
+  ticket: LarkTicket;
+}
+
+/**
+ * 防抖缓冲区 Map。
+ *
+ * Key 规则:
+ *   用户授权:`user:${accountId}:${senderOpenId}:${messageId}`
+ *   应用授权:`app:${accountId}:${chatId}:${messageId}`
+ */
+const authBatches = new Map<string, AuthBatchEntry>();
+
+/** 防抖窗口(毫秒) */
+const AUTH_DEBOUNCE_MS = 50;
+
+/** 用户授权防抖窗口(毫秒)。比 app auth 的 50ms 更长,保证应用权限卡片先发出。 */
+const AUTH_USER_DEBOUNCE_MS = 150;
+
+/**
+ * Scope 更新防抖窗口(毫秒)。
+ * 比初始防抖更长,因为工具调用可能间隔数十到数百毫秒顺序到达。
+ * 需要等足够久以收集所有后续到达的 scope 后再一次性更新卡片。
+ */
+const AUTH_UPDATE_DEBOUNCE_MS = 500;
+
+/**
+ * 冷却期(毫秒)。
+ * flushFn 执行完毕后,entry 继续保留在 Map 中这么长时间,
+ * 防止后续顺序到达的工具调用创建重复卡片。
+ */
+const AUTH_COOLDOWN_MS = 30_000;
+
+/**
+ * 将授权请求入队到防抖缓冲区。
+ *
+ * 同一 bufferKey 的请求会被合并:
+ * - collecting 阶段:scope 集合取并集,共享同一个 flushFn 执行结果
+ * - executing 阶段:flushFn 已在运行,后续请求直接复用已有结果(不重复发卡片)
+ *
+ * @param bufferKey - 缓冲区 key(区分不同用户/会话)
+ * @param scopes - 本次请求需要的 scope 列表
+ * @param ctx - 上下文信息(仅第一个请求的被采用)
+ * @param flushFn - 定时器到期后执行的实际授权函数,接收合并后的 scope 数组
+ */
+function enqueueAuthRequest(
+  bufferKey: string,
+  scopes: string[],
+  ctx: { account: ConfiguredLarkAccount; cfg: ClawdbotConfig; ticket: LarkTicket },
+  flushFn: (mergedScopes: string[]) => Promise<JsonResult>,
+  debounceMs: number = AUTH_DEBOUNCE_MS,
+): Promise<JsonResult> {
+  const existing = authBatches.get(bufferKey);
+
+  if (existing) {
+    // 不论哪个阶段,都追加 scope
+    for (const s of scopes) existing.scopes.add(s);
+
+    if (existing.phase === 'executing') {
+      // flushFn 已在执行或已完成(卡片已发出),复用结果
+      // 同时触发延迟刷新:用合并后的 scope 重新调用 flushFn 更新卡片
+      log.info(`auth in-flight, piggyback → key=${bufferKey}, scopes=[${[...existing.scopes].join(', ')}]`);
+
+      // 防抖 + 互斥:多个快速到达的请求只触发一次卡片更新
+      if (existing.updateTimer) clearTimeout(existing.updateTimer);
+      existing.updateTimer = setTimeout(async () => {
+        existing.updateTimer = null;
+
+        // 互斥:如果上一轮更新还在执行,标记 pendingReupdate 等它结束后重跑
+        if (existing.isUpdating) {
+          existing.pendingReupdate = true;
+          log.info(`scope update deferred (previous update still running) → key=${bufferKey}`);
+          return;
+        }
+
+        existing.isUpdating = true;
+        try {
+          const mergedScopes = [...existing.scopes];
+          log.info(`scope update flush → key=${bufferKey}, scopes=[${mergedScopes.join(', ')}]`);
+          // 重新调用 flushFn(executeAuthorize 会检测到 pendingFlow,
+          // 原地更新旧卡片内容 + 重启 Device Flow)
+          await existing.flushFn!(mergedScopes);
+        } catch (err) {
+          log.warn(`scope update failed: ${err}`);
+        } finally {
+          existing.isUpdating = false;
+          // 如果锁定期间有新 scope 到达,再跑一轮
+          if (existing.pendingReupdate) {
+            existing.pendingReupdate = false;
+            const finalScopes = [...existing.scopes];
+            log.info(`scope reupdate → key=${bufferKey}, scopes=[${finalScopes.join(', ')}]`);
+            try {
+              await existing.flushFn!(finalScopes);
+            } catch (err) {
+              log.warn(`scope reupdate failed: ${err}`);
+            }
+          }
+        }
+      }, AUTH_UPDATE_DEBOUNCE_MS);
+
+      return existing.resultPromise!;
+    }
+
+    // collecting 阶段:正常合并
+    log.info(`debounce merge → key=${bufferKey}, scopes=[${[...existing.scopes].join(', ')}]`);
+    return new Promise<JsonResult>((resolve, reject) => {
+      existing.waiters.push({ resolve, reject });
+    });
+  }
+
+  // 创建新缓冲区(collecting 阶段)
+  const entry: AuthBatchEntry = {
+    phase: 'collecting',
+    scopes: new Set(scopes),
+    waiters: [],
+    timer: null,
+    resultPromise: null,
+    updateTimer: null,
+    isUpdating: false,
+    pendingReupdate: false,
+    flushFn: null,
+    account: ctx.account,
+    cfg: ctx.cfg,
+    ticket: ctx.ticket,
+  };
+
+  const promise = new Promise<JsonResult>((resolve, reject) => {
+    entry.waiters.push({ resolve, reject });
+  });
+
+  entry.timer = setTimeout(async () => {
+    // 转入 executing 阶段(不从 Map 中删除,阻止后续请求创建新卡片)
+    entry.phase = 'executing';
+    entry.timer = null;
+    entry.flushFn = flushFn; // 保存引用,供 executing 阶段 scope 更新时重新调用
+    const mergedScopes = [...entry.scopes];
+
+    log.info(
+      `debounce flush → key=${bufferKey}, ` + `waiters=${entry.waiters.length}, scopes=[${mergedScopes.join(', ')}]`,
+    );
+
+    // 将 flushFn 的 Promise 存入 entry,供 executing 阶段的后来者复用
+    entry.resultPromise = flushFn(mergedScopes);
+
+    try {
+      const result = await entry.resultPromise;
+      for (const w of entry.waiters) w.resolve(result);
+    } catch (err) {
+      for (const w of entry.waiters) w.reject(err);
+    } finally {
+      // 进入冷却期:entry 继续留在 Map 中,后续到达的工具调用
+      // 会命中 executing 分支并复用 resultPromise,不会创建新卡片。
+      // 冷却期结束后清理。
+      setTimeout(() => authBatches.delete(bufferKey), AUTH_COOLDOWN_MS);
+    }
+  }, debounceMs);
+
+  authBatches.set(bufferKey, entry);
+  return promise;
+}
+
+// ---------------------------------------------------------------------------
+// PendingAppAuthFlow — 等待用户确认的应用权限引导流程
+// ---------------------------------------------------------------------------
+
+interface PendingAppAuthFlow {
+  appId: string;
+  accountId: string;
+  cardId: string;
+  sequence: number;
+  requiredScopes: string[];
+  /** 与触发 AppScopeMissingError 时的 scopeNeedType 一致。 */
+  scopeNeedType?: 'one' | 'all';
+  /** 与触发 AppScopeMissingError 时的 tokenType 一致。 */
+  tokenType?: 'user' | 'tenant';
+  cfg: ClawdbotConfig;
+  ticket: LarkTicket;
+}
+
+/** TTL:15 分钟后自动清理,防止内存泄漏。 */
+const PENDING_FLOW_TTL_MS = 15 * 60 * 1000;
+
+/** 计算去重 key(chatId + messageId + 有序 scopes)。 */
+function makeDedupKey(chatId: string, messageId: string, scopes: string[]): string {
+  return chatId + '\0' + messageId + '\0' + [...scopes].sort().join(',');
+}
+
+/** 注册后的 flow,附加索引键信息 */
+type RegisteredFlow = PendingAppAuthFlow & {
+  dedupKey: string;
+  activeCardKey: string;
+};
+
+/**
+ * 应用权限授权流管理器 — 统一管理三个关联索引的一致性。
+ *
+ * 替代原来散布的 pendingAppAuthFlows / dedupIndex / activeAppCardIndex 三个 Map,
+ * 确保注册、删除、迁移操作的原子性。
+ */
+class AppAuthFlowManager {
+  private readonly flows = new Map<string, RegisteredFlow>();
+  private readonly dedupIndex = new Map<string, string>();
+  private readonly activeCardIndex = new Map<string, string>();
+
+  /** 原子注册新流程(同时写入 3 个索引 + 设置统一 TTL) */
+  register(operationId: string, flow: PendingAppAuthFlow, dedupKey: string, activeCardKey: string): void {
+    const registered: RegisteredFlow = { ...flow, dedupKey, activeCardKey };
+    this.flows.set(operationId, registered);
+    this.dedupIndex.set(dedupKey, operationId);
+    this.activeCardIndex.set(activeCardKey, operationId);
+
+    // 统一 TTL 清理
+    setTimeout(() => {
+      if (!this.flows.has(operationId)) return; // 已被手动清理,跳过
+      this.remove(operationId);
+    }, PENDING_FLOW_TTL_MS);
+  }
+
+  /** 只需 operationId 即可原子清理所有索引 */
+  remove(operationId: string): void {
+    const flow = this.flows.get(operationId);
+    if (!flow) return;
+
+    // 联动清理延迟用户授权队列(防止内存泄漏)
+    if (flow.ticket?.senderOpenId) {
+      const deferKey = `${flow.accountId}:${flow.ticket.senderOpenId}:${flow.ticket.messageId}`;
+      deferredUserAuth.delete(deferKey);
+    }
+
+    this.flows.delete(operationId);
+    // 条件删除:防止误删已被新 flow 覆盖的索引
+    if (this.dedupIndex.get(flow.dedupKey) === operationId) {
+      this.dedupIndex.delete(flow.dedupKey);
+    }
+    if (this.activeCardIndex.get(flow.activeCardKey) === operationId) {
+      this.activeCardIndex.delete(flow.activeCardKey);
+    }
+  }
+
+  /**
+   * 迁移到新 operationId(卡片复用场景:按钮回调需要匹配新 ID)。
+   * 原子操作:清理旧索引 → 更新 flow → 建立新索引 → 注册新 TTL。
+   *
+   * 修复原代码卡片复用路径缺少 TTL 注册导致的内存泄漏。
+   */
+  migrateToNewOperationId(
+    oldOperationId: string,
+    newOperationId: string,
+    updates?: { dedupKey?: string; requiredScopes?: string[]; scopeNeedType?: 'one' | 'all' },
+  ): RegisteredFlow | undefined {
+    const flow = this.flows.get(oldOperationId);
+    if (!flow) return undefined;
+
+    // 清理旧索引
+    this.flows.delete(oldOperationId);
+    if (updates?.dedupKey) {
+      if (this.dedupIndex.get(flow.dedupKey) === oldOperationId) {
+        this.dedupIndex.delete(flow.dedupKey);
+      }
+      flow.dedupKey = updates.dedupKey;
+    }
+    if (updates?.requiredScopes) flow.requiredScopes = updates.requiredScopes;
+    if (updates?.scopeNeedType) flow.scopeNeedType = updates.scopeNeedType;
+
+    // 建立新索引
+    this.flows.set(newOperationId, flow);
+    this.dedupIndex.set(flow.dedupKey, newOperationId);
+    this.activeCardIndex.set(flow.activeCardKey, newOperationId);
+
+    // 为新 operationId 注册 TTL(修复原代码的内存泄漏)
+    setTimeout(() => {
+      if (!this.flows.has(newOperationId)) return;
+      this.remove(newOperationId);
+    }, PENDING_FLOW_TTL_MS);
+
+    return flow;
+  }
+
+  /** 通过 operationId 查询(card action 回调用) */
+  getByOperationId(id: string): PendingAppAuthFlow | undefined {
+    return this.flows.get(id);
+  }
+
+  /** 通过去重键查询(避免发送重复卡片) */
+  getByDedupKey(key: string): { operationId: string; flow: PendingAppAuthFlow } | undefined {
+    const opId = this.dedupIndex.get(key);
+    if (!opId) return undefined;
+    const flow = this.flows.get(opId);
+    return flow ? { operationId: opId, flow } : undefined;
+  }
+
+  /** 通过活跃卡片键查询(同消息卡片复用) */
+  getByActiveCardKey(key: string): { operationId: string; flow: RegisteredFlow } | undefined {
+    const opId = this.activeCardIndex.get(key);
+    if (!opId) return undefined;
+    const flow = this.flows.get(opId);
+    return flow ? { operationId: opId, flow } : undefined;
+  }
+}
+
+const appAuthFlows = new AppAuthFlowManager();
+
+// ---------------------------------------------------------------------------
+// Deferred User Auth Queue — 用户授权延迟队列
+//
+// 当用户授权请求到达时,如果同一消息上下文存在未完成的应用权限流程,
+// 将 scope 收集到延迟队列,等应用授权完成后统一发起 OAuth。
+// ---------------------------------------------------------------------------
+
+interface DeferredUserAuthEntry {
+  scopes: Set<string>;
+  account: ConfiguredLarkAccount;
+  cfg: ClawdbotConfig;
+  ticket: LarkTicket;
+}
+
+/** 延迟用户授权队列。Key: `${accountId}:${senderOpenId}:${messageId}` */
+const deferredUserAuth = new Map<string, DeferredUserAuthEntry>();
+
+/**
+ * 检查指定消息上下文是否有未完成的应用权限授权流程。
+ * 检查两个来源:
+ *   1. authBatches 中的 app auth entry(collecting/executing 阶段)
+ *   2. appAuthFlows 中的活跃流(卡片已发送,等待用户点击"已完成")
+ */
+function hasActiveAppAuthForMessage(ticket: LarkTicket): boolean {
+  const appKey = `app:${ticket.accountId}:${ticket.chatId}:${ticket.messageId}`;
+  const appEntry = authBatches.get(appKey);
+  if (appEntry && (appEntry.phase === 'collecting' || appEntry.phase === 'executing')) {
+    return true;
+  }
+  const activeCardKey = `${ticket.chatId}:${ticket.messageId}`;
+  return !!appAuthFlows.getByActiveCardKey(activeCardKey);
+}
+
+/**
+ * 将用户授权 scope 添加到延迟队列。
+ * 多个工具调用的 scope 会被合并到同一个 entry。
+ */
+function addToDeferredUserAuth(
+  ticket: LarkTicket,
+  scopes: string[],
+  account: ConfiguredLarkAccount,
+  cfg: ClawdbotConfig,
+): void {
+  const key = `${ticket.accountId}:${ticket.senderOpenId}:${ticket.messageId}`;
+  const existing = deferredUserAuth.get(key);
+  if (existing) {
+    for (const s of scopes) existing.scopes.add(s);
+    log.info(`deferred user auth scope merge → key=${key}, scopes=[${[...existing.scopes].join(', ')}]`);
+  } else {
+    deferredUserAuth.set(key, { scopes: new Set(scopes), account, cfg, ticket });
+    log.info(`deferred user auth created → key=${key}, scopes=[${scopes.join(', ')}]`);
+  }
+}
+
+// ---------------------------------------------------------------------------
+// Card builders — CardKit v2 格式 + i18n_content 多语言
+// ---------------------------------------------------------------------------
+
+/** v2 卡片 i18n 配置 */
+const I18N_CONFIG = {
+  update_multi: true,
+  locales: ['zh_cn', 'en_us'],
+};
+
+/**
+ * 构建应用权限引导卡片。
+ *
+ * 橙色 header,列出缺失的 scope,提供权限管理链接和"已完成"按钮。
+ */
+function buildAppScopeMissingCard(params: {
+  missingScopes: string[];
+  appId?: string;
+  operationId: string;
+  brand?: LarkBrand;
+}): Record<string, unknown> {
+  const { missingScopes, appId, operationId, brand } = params;
+  const openDomain = brand === 'lark' ? 'https://open.larksuite.com' : 'https://open.feishu.cn';
+  const authUrl = appId
+    ? `${openDomain}/app/${appId}/auth?q=${encodeURIComponent(missingScopes.join(','))}&op_from=feishu-openclaw&token_type=user`
+    : `${openDomain}/`;
+  const multiUrl = { url: authUrl, pc_url: '', android_url: '', ios_url: '' };
+
+  const scopeList = missingScopes.map((s) => `• ${s}`).join('\n');
+
+  return {
+    schema: '2.0',
+    config: { wide_screen_mode: true, ...I18N_CONFIG },
+    header: {
+      title: {
+        tag: 'plain_text',
+        content: '🔐 Permissions required to continue',
+        i18n_content: {
+          zh_cn: '🔐 需要申请权限才能继续',
+          en_us: '🔐 Permissions required to continue',
+        },
+      },
+      template: 'orange',
+    },
+    body: {
+      elements: [
+        {
+          tag: 'markdown',
+          content: `Please request **all** the following permissions to proceed:\n\n${scopeList}`,
+          i18n_content: {
+            zh_cn: `调用前,请你先申请以下**所有**权限:\n\n${scopeList}`,
+            en_us: `Please request **all** the following permissions to proceed:\n\n${scopeList}`,
+          },
+          text_size: 'normal',
+        },
+        { tag: 'hr' },
+        {
+          tag: 'markdown',
+          content: '**Step 1: Request all permissions**',
+          i18n_content: {
+            zh_cn: '**第一步:申请所有权限**',
+            en_us: '**Step 1: Request all permissions**',
+          },
+          text_size: 'normal',
+        },
+        {
+          tag: 'button',
+          text: {
+            tag: 'plain_text',
+            content: 'Request Now',
+            i18n_content: { zh_cn: '去申请', en_us: 'Request Now' },
+          },
+          type: 'primary',
+          multi_url: multiUrl,
+        },
+        {
+          tag: 'markdown',
+          content: '**Step 2: Create version and get approval**',
+          i18n_content: {
+            zh_cn: '**第二步:创建版本并审核通过**',
+            en_us: '**Step 2: Create version and get approval**',
+          },
+          text_size: 'normal',
+        },
+        {
+          tag: 'button',
+          text: {
+            tag: 'plain_text',
+            content: 'Done',
+            i18n_content: { zh_cn: '已完成', en_us: 'Done' },
+          },
+          type: 'default',
+          value: { action: 'app_auth_done', operation_id: operationId },
+        },
+      ],
+    },
+  };
+}
+
+/**
+ * 构建应用权限引导卡片的"处理中"状态(用户点击按钮后更新)。
+ */
+function buildAppAuthProgressCard(): Record<string, unknown> {
+  return {
+    schema: '2.0',
+    config: { wide_screen_mode: false, ...I18N_CONFIG },
+    header: {
+      title: {
+        tag: 'plain_text',
+        content: 'Permissions enabled',
+        i18n_content: {
+          zh_cn: '应用权限已开通',
+          en_us: 'Permissions enabled',
+        },
+      },
+      subtitle: { tag: 'plain_text', content: '' },
+      template: 'green',
+      padding: '12px 12px 12px 12px',
+      icon: { tag: 'standard_icon', token: 'yes_filled' },
+    },
+    body: {
+      elements: [
+        {
+          tag: 'markdown',
+          content: 'App permissions ready. Starting user authorization...',
+          i18n_content: {
+            zh_cn: '你的应用权限已开通,正在为你发起用户授权',
+            en_us: 'App permissions ready. Starting user authorization...',
+          },
+          text_size: 'normal',
+        },
+      ],
+    },
+  };
+}
+
+// ---------------------------------------------------------------------------
+// Internal helpers
+// ---------------------------------------------------------------------------
+
+/**
+ * 发送应用权限引导卡片,并将 flow 存入 pendingAppAuthFlows。
+ * 返回工具结果(告知 AI 等待用户操作)。
+ */
+async function sendAppScopeCard(params: {
+  account: ConfiguredLarkAccount;
+  missingScopes: string[];
+  appId?: string;
+  scopeNeedType?: 'one' | 'all';
+  tokenType?: 'user' | 'tenant';
+  cfg: ClawdbotConfig;
+  ticket: LarkTicket;
+}): Promise<ReturnType<typeof json>> {
+  const { account, missingScopes, appId, scopeNeedType, tokenType, cfg, ticket } = params;
+  const { accountId, chatId, messageId } = ticket;
+  const activeCardKey = `${chatId}:${messageId}`;
+
+  // ---- 去重:避免并发工具调用时发出多张内容相同的卡片 ----
+  const dedup = makeDedupKey(chatId, messageId, missingScopes);
+  const existingEntry = appAuthFlows.getByDedupKey(dedup);
+  if (existingEntry) {
+    log.info(
+      `dedup – app-scope card already pending for chatId=${chatId}, ` +
+        `scopes=[${missingScopes.join(', ')}], skipping duplicate send`,
+    );
+    return json({
+      awaiting_app_authorization: true,
+      message:
+        '已向用户发送授权引导卡片,等待用户完成授权操作。' +
+        '请告知用户:按照卡片提示完成授权,完成后系统将自动重试之前的操作。' +
+        '请等待用户完成卡片操作,不要建议其他替代方案。',
+      missing_scopes: missingScopes,
+    });
+  }
+
+  // ---- 卡片复用:同一 chatId+messageId 已有活跃卡片时,原地更新而非创建新卡片 ----
+  const activeEntry = appAuthFlows.getByActiveCardKey(activeCardKey);
+
+  if (activeEntry) {
+    const { operationId: activeOpId, flow: activeFlow } = activeEntry;
+    // 更新已有卡片的内容(合并后的 scope)
+    const newOperationId = Date.now().toString(36) + Math.random().toString(36).slice(2);
+    const card = buildAppScopeMissingCard({ missingScopes, appId, operationId: newOperationId, brand: account.brand });
+    const newSeq = activeFlow.sequence + 1;
+
+    // TOCTOU 修复:先原子迁移(同步操作),再 await 更新卡片
+    const newDedup = makeDedupKey(chatId, messageId, missingScopes);
+    const migrated = appAuthFlows.migrateToNewOperationId(activeOpId, newOperationId, {
+      dedupKey: newDedup,
+      requiredScopes: missingScopes,
+      scopeNeedType,
+    });
+    if (!migrated) {
+      // 被其他并发请求抢先迁移了,降级到新建卡片
+      log.info(`migrate raced, falling through to new card creation`);
+    } else {
+      try {
+        await updateCardKitCardForAuth({
+          cfg,
+          cardId: activeFlow.cardId,
+          card,
+          sequence: newSeq,
+          accountId,
+        });
+        log.info(
+          `app-scope card updated in-place, cardId=${activeFlow.cardId}, ` +
+            `seq=${newSeq}, scopes=[${missingScopes.join(', ')}]`,
+        );
+
+        // 更新 sequence(migrate 不处理 sequence)
+        migrated.sequence = newSeq;
+
+        return json({
+          awaiting_app_authorization: true,
+          message:
+            '已向用户发送授权引导卡片,等待用户完成授权操作。' +
+            '请告知用户:按照卡片提示完成授权,完成后系统将自动重试之前的操作。' +
+            '请等待用户完成卡片操作,不要建议其他替代方案。',
+          missing_scopes: missingScopes,
+        });
+      } catch (err) {
+        // 回滚:删除已迁移的 flow
+        appAuthFlows.remove(newOperationId);
+        log.warn(`failed to update existing app-scope card, creating new one: ${err}`);
+        // 降级:走下面的新建卡片路径
+      }
+    }
+  }
+
+  const operationId = Date.now().toString(36) + Math.random().toString(36).slice(2);
+
+  const card = buildAppScopeMissingCard({ missingScopes, appId, operationId, brand: account.brand });
+
+  // 创建 CardKit 卡片实体
+  const cardId = await createCardEntity({ cfg, card, accountId });
+  if (!cardId) {
+    log.warn('createCardEntity failed for app-scope card, falling back');
+    return json({
+      error: 'app_scope_missing',
+      missing_scopes: missingScopes,
+      message:
+        `应用缺少以下权限:${missingScopes.join(', ')},` +
+        `请管理员在开放平台开通后重试。` +
+        (appId ? `\n权限管理:${account.brand === 'lark' ? 'https://open.larksuite.com' : 'https://open.feishu.cn'}/app/${appId}/permission` : ''),
+    });
+  }
+
+  // 发送到当前会话
+  const replyToMsgId = ticket.messageId?.startsWith('om_') ? ticket.messageId : undefined;
+
+  await sendCardByCardId({
+    cfg,
+    to: chatId,
+    cardId,
+    replyToMessageId: replyToMsgId,
+    replyInThread: Boolean(ticket?.threadId),
+    accountId,
+  });
+
+  // 原子注册到管理器(统一 TTL 清理)
+  const flow: PendingAppAuthFlow = {
+    appId: appId ?? account.appId,
+    accountId,
+    cardId,
+    sequence: 0,
+    requiredScopes: missingScopes,
+    scopeNeedType,
+    tokenType,
+    cfg,
+    ticket,
+  };
+  appAuthFlows.register(operationId, flow, dedup, activeCardKey);
+
+  log.info(`app-scope card sent, operationId=${operationId}, scopes=[${missingScopes.join(', ')}]`);
+
+  return json({
+    awaiting_app_authorization: true,
+    message:
+      '已向用户发送授权引导卡片,等待用户完成授权操作。' +
+      '请告知用户:按照卡片提示完成授权,完成后系统将自动重试之前的操作。' +
+      '请等待用户完成卡片操作,不要建议其他替代方案。',
+    missing_scopes: missingScopes,
+  });
+}
+
+// ---------------------------------------------------------------------------
+// Card action handler (exported for monitor.ts)
+// ---------------------------------------------------------------------------
+
+/**
+ * 处理 card.action.trigger 回调事件(由 monitor.ts 调用)。
+ *
+ * 当用户点击应用权限引导卡片的"我已完成,继续授权"按钮时:
+ * 1. 更新卡片为"处理中"状态
+ * 2. 清除应用 scope 缓存
+ * 3. 发送中间合成消息告知 AI
+ * 4. 发起 OAuth Device Flow
+ *
+ * 注意:函数体内的主要逻辑通过 setImmediate + fire-and-forget 异步执行,
+ * 确保 Feishu card.action.trigger 回调在 3 秒内返回。
+ */
+export async function handleCardAction(data: unknown, cfg: ClawdbotConfig, accountId: string): Promise<unknown> {
+  let action: string | undefined;
+  let operationId: string | undefined;
+  let senderOpenId: string | undefined;
+
+  try {
+    const event = data as {
+      operator?: { open_id?: string };
+      action?: { value?: { action?: string; operation_id?: string } };
+    };
+    action = event.action?.value?.action;
+    operationId = event.action?.value?.operation_id;
+    senderOpenId = event.operator?.open_id;
+  } catch {
+    return;
+  }
+
+  if (action !== 'app_auth_done' || !operationId) return;
+
+  const flow = appAuthFlows.getByOperationId(operationId);
+  if (!flow) {
+    log.warn(`card action ${operationId} not found (expired or already handled)`);
+    return;
+  }
+
+  log.info(`app_auth_done clicked by ${senderOpenId}, operationId=${operationId}`);
+
+  // scope 校验在同步路径完成(3 秒内返回 toast response)
+  invalidateAppScopeCache(flow.appId);
+
+  const acct = getLarkAccount(flow.cfg, flow.accountId);
+  if (!acct.configured) {
+    log.warn(`account ${flow.accountId} not configured, skipping OAuth`);
+    return;
+  }
+
+  const sdk = LarkClient.fromAccount(acct).sdk;
+  let grantedScopes: string[] = [];
+  try {
+    // 使用与原始 AppScopeMissingError 相同的 tokenType,保证校验逻辑完全一致
+    grantedScopes = await getAppGrantedScopes(sdk, flow.appId, flow.tokenType);
+  } catch (err) {
+    log.warn(`failed to re-check app scopes: ${err}, proceeding anyway`);
+  }
+
+  // 使用共享函数 isAppScopeSatisfied,与 tool-client invoke() 逻辑完全一致:
+  //   - scopeNeedType "all" → 全部必须有
+  //   - 默认"one" → 交集非空即可
+  //   - grantedScopes 为空 → 视为满足(API 失败退回服务端判断)
+  if (!isAppScopeSatisfied(grantedScopes, flow.requiredScopes, flow.scopeNeedType)) {
+    log.warn(`app scopes still missing after user confirmation: [${flow.requiredScopes.join(', ')}]`);
+    return {
+      toast: {
+        type: 'error',
+        content: '权限尚未开通,请确认已申请并审核通过后再试',
+      },
+    };
+  }
+
+  log.info(`app scopes verified, proceeding with OAuth`);
+
+  // ★ 在 remove() 之前先取出延迟队列数据,避免 remove() 的联动清理提前删掉它
+  const deferKey = flow.ticket.senderOpenId
+    ? `${flow.accountId}:${flow.ticket.senderOpenId}:${flow.ticket.messageId}`
+    : undefined;
+  const consumedDeferred = deferKey ? deferredUserAuth.get(deferKey) : undefined;
+  if (consumedDeferred && deferKey) {
+    deferredUserAuth.delete(deferKey);
+    log.info(`consumed deferred user auth scopes: [${[...consumedDeferred.scopes].join(', ')}]`);
+  }
+
+  // 校验通过才删除,防止用户在权限通过前多次点击无法重试
+  appAuthFlows.remove(operationId);
+
+  // 通过回调返回值直接更新卡片(方式一:3 秒内立即更新)。
+  // 飞书文档要求 card 字段必须包含 type + data 包装:
+  //   { card: { type: "raw", data: { schema: "2.0", ... } } }
+  // 注意:不能在回调返回前调用 card.update API,飞书文档明确说明
+  // "延时更新必须在响应回调请求之后执行,并行执行或提前执行会出现更新失败"。
+  const successCard = buildAppAuthProgressCard();
+
+  // 后台异步:回调响应之后再执行 API 更新 + OAuth
+  setImmediate(async () => {
+    try {
+      // 通过 API 再次更新卡片(确保所有查看者都看到更新,不只是点击者)
+      try {
+        await updateCardKitCardForAuth({
+          cfg,
+          cardId: flow.cardId,
+          card: successCard,
+          sequence: flow.sequence + 1,
+          accountId,
+        });
+      } catch (err) {
+        log.warn(`failed to update app-scope card to progress via API: ${err}`);
+      }
+
+      // 发起 OAuth Device Flow(完成后 executeAuthorize 会自动发合成消息触发 AI 重试)
+      if (!flow.ticket.senderOpenId) {
+        log.warn('no senderOpenId in ticket, skipping OAuth');
+        return;
+      }
+
+      // 收集所有来源的 scope(过滤 offline_access:仅 app 级需要,device-flow 自动追加)
+      const mergedScopes = new Set(flow.requiredScopes.filter((s) => s !== 'offline_access'));
+
+      // 来源 1: 延迟用户授权队列(已在同步路径中提前取出,见 consumedDeferred)
+      if (consumedDeferred) {
+        for (const s of consumedDeferred.scopes) mergedScopes.add(s);
+      }
+
+      // 来源 2: 现有 user auth batch(向后兼容,处理未被延迟拦截的 user auth)
+      const userBatchKey = `user:${flow.accountId}:${flow.ticket.senderOpenId}:${flow.ticket.messageId}`;
+      const userBatch = authBatches.get(userBatchKey);
+      if (userBatch) {
+        for (const s of userBatch.scopes) mergedScopes.add(s);
+        log.info(`merged user batch scopes into app auth completion: [${[...mergedScopes].join(', ')}]`);
+      }
+
+      if (mergedScopes.size === 0) {
+        // 无业务 scope 需要用户授权(例如 offline_access 是唯一缺失的应用权限,
+        // 且没有其他工具产生用户授权需求)。跳过 OAuth,直接发合成消息触发 AI 重试,
+        // 重试时工具会自然发现需要用户授权并发起正确的 OAuth 流程。
+        log.info('no business scopes to authorize after app auth, sending synthetic message for retry');
+        const syntheticMsgId = `${flow.ticket.messageId}:app-auth-complete`;
+        const syntheticLine = '应用权限已开通,请继续执行之前的操作。';
+        const syntheticEvent = {
+          sender: { sender_id: { open_id: flow.ticket.senderOpenId } },
+          message: {
+            message_id: syntheticMsgId,
+            chat_id: flow.ticket.chatId,
+            chat_type: flow.ticket.chatType ?? ('p2p' as const),
+            message_type: 'text',
+            content: JSON.stringify({ text: syntheticLine }),
+            thread_id: flow.ticket.threadId,
+          },
+        };
+        if (feishuInboundNeedsGatewayWebhook()) {
+          await forwardSyntheticTextMessageToGateway({
+            appId: acct.appId,
+            accountId: flow.accountId,
+            openId: flow.ticket.senderOpenId!,
+            chatId: flow.ticket.chatId,
+            chatType: flow.ticket.chatType,
+            messageId: syntheticMsgId,
+            content: syntheticLine,
+            rawEvent: syntheticEvent as Record<string, unknown>,
+          });
+          log.info('synthetic app-auth message forwarded to gateway webhook (standalone HTTP runtime)');
+        } else {
+          const syntheticRuntime = {
+            log: (msg: string) => log.info(msg),
+            error: (msg: string) => log.error(msg),
+          };
+          const { promise } = enqueueFeishuChatTask({
+            accountId: flow.accountId,
+            chatId: flow.ticket.chatId,
+            threadId: flow.ticket.threadId,
+            task: async () => {
+              await withTicket(
+                {
+                  messageId: syntheticMsgId,
+                  chatId: flow.ticket.chatId,
+                  accountId: flow.accountId,
+                  startTime: Date.now(),
+                  senderOpenId: flow.ticket.senderOpenId!,
+                  chatType: flow.ticket.chatType,
+                  threadId: flow.ticket.threadId,
+                },
+                () =>
+                  handleFeishuMessage({
+                    cfg: flow.cfg,
+                    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+                    event: syntheticEvent as any,
+                    accountId: flow.accountId,
+                    forceMention: true,
+                    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+                    runtime: syntheticRuntime as any,
+                    replyToMessageId: flow.ticket.messageId,
+                  }),
+              );
+            },
+          });
+          await promise;
+          log.info('synthetic message dispatched after app-auth-only completion');
+        }
+      } else {
+        await executeAuthorize({
+          account: acct,
+          senderOpenId: flow.ticket.senderOpenId,
+          scope: [...mergedScopes].join(' '),
+          showBatchAuthHint: true,
+          forceAuth: true, // 应用权限刚经历移除→补回,不信任本地 UAT 缓存
+          cfg: flow.cfg,
+          ticket: flow.ticket,
+        });
+      }
+    } catch (err) {
+      log.error(`handleCardAction background task failed: ${err}`);
+    }
+  });
+
+  // 回调返回值:通过 card 字段立即更新卡片 + toast 提示
+  return {
+    toast: {
+      type: 'success' as const,
+      content: '权限确认成功',
+    },
+    card: {
+      type: 'raw' as const,
+      data: successCard,
+    },
+  };
+}
+
+// ---------------------------------------------------------------------------
+// Main export
+// ---------------------------------------------------------------------------
+
+/**
+ * 统一处理 `client.invoke()` 抛出的错误,支持自动发起 OAuth 授权。
+ *
+ * 替代 `handleInvokeError`,在工具层直接处理授权问题:
+ * - 用户授权类错误 → 直接 executeAuthorize(发 Device Flow 卡片)
+ * - 应用权限缺失 → 发送引导卡片,用户确认后自动接力 OAuth
+ * - 其他错误 → 回退到 handleInvokeError 的标准处理
+ *
+ * @param err - invoke() 或其他逻辑抛出的错误
+ * @param cfg - OpenClaw 配置对象(从工具注册函数的闭包中获取)
+ */
+export async function handleInvokeErrorWithAutoAuth(err: unknown, cfg: ClawdbotConfig) {
+  const ticket = getTicket();
+
+  // --- Path 0:Owner 访问拒绝 → 直接返回友好提示 ---
+  if (err instanceof OwnerAccessDeniedError) {
+    return json({
+      error: 'permission_denied',
+      message: '当前应用仅限所有者(App Owner)使用。您没有权限使用相关功能。',
+      user_open_id: err.userOpenId,
+      // 注意:不序列化 err.appOwnerId,避免泄露 owner 的 open_id
+    });
+  }
+
+  if (ticket) {
+    const senderOpenId = ticket.senderOpenId;
+
+    // --- Path 1:用户授权类错误 → 防抖合并后发起 OAuth ---
+
+    if (senderOpenId) {
+      // 1a. 用户未授权或 token scope 不足(且 app scope 已验证)
+      if (err instanceof UserAuthRequiredError && err.appScopeVerified) {
+        const scopes = err.requiredScopes;
+        try {
+          const acct = getLarkAccount(cfg, ticket.accountId);
+          if (acct.configured) {
+            // ★ 延迟检查:如果同一消息有未完成的应用权限流程,
+            //   将用户授权 scope 收集到延迟队列,等应用授权完成后统一发起 OAuth
+            if (hasActiveAppAuthForMessage(ticket)) {
+              addToDeferredUserAuth(ticket, scopes, acct, cfg);
+              log.info(`UserAuthRequiredError deferred (app auth pending), scopes=[${scopes.join(', ')}]`);
+              return json({
+                awaiting_app_authorization: true,
+                user_auth_deferred: true,
+                message:
+                  '应用权限尚未开通,将在应用权限通过后自动为您发起用户授权。' +
+                  '请先按照应用权限卡片的提示完成操作。' +
+                  '请等待用户完成卡片操作,不要建议其他替代方案。',
+                deferred_scopes: scopes,
+              });
+            }
+
+            const bufferKey = `user:${ticket.accountId}:${senderOpenId}:${ticket.messageId}`;
+            log.info(`UserAuthRequiredError → enqueue, key=${bufferKey}, scopes=[${scopes.join(', ')}]`);
+            return await enqueueAuthRequest(
+              bufferKey,
+              scopes,
+              { account: acct, cfg, ticket },
+              async (mergedScopes) => {
+                // 等待同一消息的 app auth 卡片先发出
+                const appKey = `app:${ticket.accountId}:${ticket.chatId}:${ticket.messageId}`;
+                const appEntry = authBatches.get(appKey);
+                if (appEntry?.resultPromise) {
+                  await appEntry.resultPromise.catch(() => {});
+                }
+                return executeAuthorize({
+                  account: acct,
+                  senderOpenId,
+                  scope: mergedScopes.join(' '),
+                  showBatchAuthHint: true,
+                  cfg,
+                  ticket,
+                });
+              },
+              AUTH_USER_DEBOUNCE_MS,
+            );
+          }
+        } catch (autoAuthErr) {
+          log.warn(`executeAuthorize failed: ${autoAuthErr}, falling back`);
+        }
+      }
+
+      // 1b. 用户 token 存在但 scope 不足(服务端 LARK_ERROR.USER_SCOPE_INSUFFICIENT / 99991679)
+      if (err instanceof UserScopeInsufficientError) {
+        const scopes = err.missingScopes;
+        try {
+          const acct = getLarkAccount(cfg, ticket.accountId);
+          if (acct.configured) {
+            // ★ 延迟检查:同 Path 1a
+            if (hasActiveAppAuthForMessage(ticket)) {
+              addToDeferredUserAuth(ticket, scopes, acct, cfg);
+              log.info(`UserScopeInsufficientError deferred (app auth pending), scopes=[${scopes.join(', ')}]`);
+              return json({
+                awaiting_app_authorization: true,
+                user_auth_deferred: true,
+                message:
+                  '应用权限尚未开通,将在应用权限通过后自动为您发起用户授权。' +
+                  '请先按照应用权限卡片的提示完成操作。' +
+                  '请等待用户完成卡片操作,不要建议其他替代方案。',
+                deferred_scopes: scopes,
+              });
+            }
+
+            const bufferKey = `user:${ticket.accountId}:${senderOpenId}:${ticket.messageId}`;
+            log.info(`UserScopeInsufficientError → enqueue, key=${bufferKey}, scopes=[${scopes.join(', ')}]`);
+            return await enqueueAuthRequest(
+              bufferKey,
+              scopes,
+              { account: acct, cfg, ticket },
+              async (mergedScopes) => {
+                // 等待同一消息的 app auth 卡片先发出
+                const appKey = `app:${ticket.accountId}:${ticket.chatId}:${ticket.messageId}`;
+                const appEntry = authBatches.get(appKey);
+                if (appEntry?.resultPromise) {
+                  await appEntry.resultPromise.catch(() => {});
+                }
+                return executeAuthorize({
+                  account: acct,
+                  senderOpenId,
+                  scope: mergedScopes.join(' '),
+                  showBatchAuthHint: true,
+                  cfg,
+                  ticket,
+                });
+              },
+              AUTH_USER_DEBOUNCE_MS,
+            );
+          }
+        } catch (autoAuthErr) {
+          log.warn(`executeAuthorize failed: ${autoAuthErr}, falling back`);
+        }
+      }
+    } else {
+      log.error(`senderOpenId not found ${err}`);
+    }
+
+    // --- Path 2:应用权限缺失 → 防抖合并后发送引导卡片 ---
+
+    if (err instanceof AppScopeMissingError && ticket.chatId) {
+      // 捕获当前错误的附加信息,供 flushFn 使用
+      const appScopeErr = err;
+      try {
+        const acct = getLarkAccount(cfg, ticket.accountId);
+        if (acct.configured) {
+          // ★ 将工具的全部所需 scope 加入延迟用户授权队列。
+          // 应用权限完成后 handleCardAction 会消费这些 scope,
+          // 与 flow.requiredScopes(仅 app 缺失的)合并,一次性发起 OAuth。
+          if (senderOpenId && appScopeErr.allRequiredScopes?.length) {
+            addToDeferredUserAuth(ticket, appScopeErr.allRequiredScopes, acct, cfg);
+            log.info(`AppScopeMissingError → deferred allRequiredScopes=[${appScopeErr.allRequiredScopes.join(', ')}]`);
+          }
+
+          const bufferKey = `app:${ticket.accountId}:${ticket.chatId}:${ticket.messageId}`;
+          log.info(
+            `AppScopeMissingError → enqueue, key=${bufferKey}, ` + `scopes=[${appScopeErr.missingScopes.join(', ')}]`,
+          );
+          return await enqueueAuthRequest(
+            bufferKey,
+            appScopeErr.missingScopes,
+            { account: acct, cfg, ticket },
+            (mergedScopes) =>
+              sendAppScopeCard({
+                account: acct,
+                missingScopes: mergedScopes,
+                appId: appScopeErr.appId,
+                scopeNeedType: 'all', // 合并后所有 scope 都需要
+                tokenType: appScopeErr.tokenType,
+                cfg,
+                ticket,
+              }),
+          );
+        }
+      } catch (cardErr) {
+        log.warn(`sendAppScopeCard failed: ${cardErr}, falling back`);
+      }
+    }
+  } else {
+    log.error(`ticket not found ${err}`);
+  }
+  return json({
+    error: formatLarkError(err),
+  });
+}

+ 66 - 0
gateway/core/channels/feishu/openclaw-lark-patch/src/tools/gateway-inbound-forward.ts

@@ -0,0 +1,66 @@
+/**
+ * 独立 Feishu HTTP 服务(server.ts)注入的 PluginRuntime 不含 channel.reply / channel.commands 等
+ * OpenClaw 完整运行时;授权完成后的「合成用户消息」不能走 handleFeishuMessage,须与 WS 入站一致,
+ * POST 规范化体到 Gateway webhook。
+ */
+
+import { LarkClient } from '../core/lark-client';
+
+const DEFAULT_WEBHOOK =
+  process.env.GATEWAY_FEISHU_WEBHOOK_URL ?? 'http://localhost:8000/api/channels/feishu/inbound/webhook';
+
+/** 当前 runtime 是否具备 OpenClaw 插件入站管线(可安全调用 handleFeishuMessage / dispatchReplyFromConfig)。 */
+export function feishuInboundNeedsGatewayWebhook(): boolean {
+  const rt = LarkClient.runtime as Record<string, unknown>;
+  const ch = rt.channel as Record<string, unknown> | undefined;
+  const reply = ch?.reply as Record<string, unknown> | undefined;
+  const routing = ch?.routing as Record<string, unknown> | undefined;
+  const cmd = ch?.commands as Record<string, unknown> | undefined;
+  const sys = rt.system as Record<string, unknown> | undefined;
+  const ok =
+    Boolean(cmd?.shouldComputeCommandAuthorized) &&
+    Boolean(cmd?.resolveCommandAuthorizedFromAuthorizers) &&
+    Boolean(cmd?.isControlCommandMessage) &&
+    Boolean(reply?.resolveEnvelopeFormatOptions) &&
+    Boolean(reply?.finalizeInboundContext) &&
+    Boolean(reply?.dispatchReplyFromConfig) &&
+    Boolean(routing?.resolveAgentRoute) &&
+    Boolean(sys?.enqueueSystemEvent);
+  return !ok;
+}
+
+export async function postFeishuInboundToGateway(body: Record<string, unknown>): Promise<void> {
+  const urlStr = process.env.GATEWAY_FEISHU_WEBHOOK_URL ?? DEFAULT_WEBHOOK;
+  const res = await fetch(urlStr, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json; charset=utf-8' },
+    body: JSON.stringify(body),
+  });
+  if (!res.ok) {
+    const t = await res.text().catch(() => '');
+    throw new Error(`gateway webhook HTTP ${res.status}: ${t.slice(0, 500)}`);
+  }
+}
+
+export async function forwardSyntheticTextMessageToGateway(params: {
+  appId: string;
+  accountId: string;
+  openId: string;
+  chatId: string;
+  chatType: string | undefined;
+  messageId: string;
+  content: string;
+  rawEvent: Record<string, unknown>;
+}): Promise<void> {
+  await postFeishuInboundToGateway({
+    event_type: 'message',
+    app_id: params.appId,
+    account_id: params.accountId,
+    open_id: params.openId,
+    chat_type: params.chatType ?? 'p2p',
+    chat_id: params.chatId,
+    message_id: params.messageId,
+    content: params.content,
+    raw: params.rawEvent,
+  });
+}

+ 757 - 0
gateway/core/channels/feishu/openclaw-lark-patch/src/tools/oauth.ts

@@ -0,0 +1,757 @@
+/**
+ * Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ *
+ * feishu_oauth tool — User OAuth authorisation management.
+ *
+ * Actions:
+ *   - authorize : Initiate Device Flow, send auth card, poll for token.
+ *   - status    : Check whether the current user has a valid UAT.
+ *   - revoke    : Remove the current user's stored UAT.
+ *
+ * Security:
+ *   - **Does not** accept a `user_open_id` parameter.  The target user is
+ *     always the message sender, obtained from the LarkTicket.
+ *   - Token values are never included in the return payload (AI cannot see
+ *     them).
+ */
+
+import type { OpenClawPluginApi, ClawdbotConfig } from 'openclaw/plugin-sdk';
+import type { ConfiguredLarkAccount } from '../core/types';
+import { Type } from '@sinclair/typebox';
+import { getLarkAccount } from '../core/accounts';
+import { assertOwnerAccessStrict, OwnerAccessDeniedError } from '../core/owner-policy';
+import { LarkClient } from '../core/lark-client';
+import { getAppGrantedScopes } from '../core/app-scope-checker';
+import type { LarkTicket } from '../core/lark-ticket';
+import { getTicket, withTicket } from '../core/lark-ticket';
+import { larkLogger } from '../core/lark-logger';
+
+const log = larkLogger('tools/oauth');
+import { handleFeishuMessage } from '../messaging/inbound/handler';
+import { formatLarkError } from '../core/api-error';
+import { enqueueFeishuChatTask } from '../channel/chat-queue';
+import {
+  feishuInboundNeedsGatewayWebhook,
+  forwardSyntheticTextMessageToGateway,
+} from './gateway-inbound-forward';
+import { requestDeviceAuthorization, pollDeviceToken } from '../core/device-flow';
+import { getStoredToken, setStoredToken, tokenStatus, type StoredUAToken } from '../core/token-store';
+import { revokeUAT } from '../core/uat-client';
+import { createCardEntity, sendCardByCardId, updateCardKitCardForAuth } from '../card/cardkit';
+import { buildAuthCard, buildAuthSuccessCard, buildAuthFailedCard, buildAuthIdentityMismatchCard } from './oauth-cards';
+import { json, registerTool } from './oapi/helpers';
+
+// ---------------------------------------------------------------------------
+// Schema
+// ---------------------------------------------------------------------------
+
+const FeishuOAuthSchema = Type.Object(
+  {
+    action: Type.Union(
+      [
+        // Type.Literal("authorize"),  // 已由 auto-auth 自动处理,不再对外暴露
+        Type.Literal('revoke'),
+      ],
+      {
+        description: 'revoke: 撤销当前用户已保存的授权凭据',
+      },
+    ),
+  },
+  {
+    description:
+      '飞书用户撤销授权工具。' +
+      '仅在用户明确说"撤销授权"、"取消授权"、"退出登录"、"清除授权"时调用。' +
+      '【严禁调用场景】用户说"重新授权"、"发起授权"、"重新发起"、"授权失败"、"授权过期"时,绝对不要调用此工具,授权流程由系统自动处理。',
+  },
+);
+
+interface FeishuOAuthParams {
+  action: 'revoke';
+}
+
+// ---------------------------------------------------------------------------
+// In-flight authorize guard (prevent duplicate device-flows per user)
+// ---------------------------------------------------------------------------
+
+interface PendingFlow {
+  controller: AbortController;
+  cardId: string;
+  sequence: number;
+  messageId: string;
+  /** 被新流替换后标记为 true,旧轮询回调检测到后跳过卡片更新 */
+  superseded: boolean;
+  /** 当前 flow 请求的 scope(空格分隔),用于后续 scope 合并 */
+  scope?: string;
+}
+
+const pendingFlows = new Map<string, PendingFlow>();
+
+// ---------------------------------------------------------------------------
+// Identity verification after Device Flow
+// ---------------------------------------------------------------------------
+
+/**
+ * 使用刚获取的 UAT 调用 /authen/v1/user_info,
+ * 验证实际完成 OAuth 授权的用户 open_id 是否与预期的 senderOpenId 一致。
+ *
+ * 防止群聊中其他用户点击授权链接后,错误的 UAT 被绑定到 owner 的身份。
+ */
+async function verifyTokenIdentity(
+  brand: string,
+  accessToken: string,
+  expectedOpenId: string,
+): Promise<{ valid: boolean; actualOpenId?: string }> {
+  const domain = brand === 'lark' ? 'https://open.larksuite.com' : 'https://open.feishu.cn';
+  const url = `${domain}/open-apis/authen/v1/user_info`;
+
+  try {
+    const res = await fetch(url, {
+      headers: { Authorization: `Bearer ${accessToken}` },
+    });
+    const data = (await res.json()) as {
+      code?: number;
+      msg?: string;
+      data?: { open_id?: string };
+    };
+    if (data.code !== 0) {
+      log.warn(`user_info API error: code=${data.code}, msg=${data.msg}`);
+      return { valid: false };
+    }
+    const actualOpenId = data.data?.open_id;
+    if (!actualOpenId) {
+      log.warn('user_info API returned no open_id');
+      return { valid: false };
+    }
+    return {
+      valid: actualOpenId === expectedOpenId,
+      actualOpenId,
+    };
+  } catch (err) {
+    log.warn(`identity verification request failed: ${err}`);
+    return { valid: false };
+  }
+}
+
+// ---------------------------------------------------------------------------
+// Registration
+// ---------------------------------------------------------------------------
+
+export function registerFeishuOAuthTool(api: OpenClawPluginApi) {
+  if (!api.config) return;
+
+  const cfg = api.config;
+
+  registerTool(
+    api,
+    {
+      name: 'feishu_oauth',
+      label: 'Feishu OAuth',
+      description:
+        '飞书用户撤销授权工具。' +
+        '仅在用户明确说"撤销授权"、"取消授权"、"退出登录"、"清除授权"时调用 revoke。' +
+        '【严禁调用场景】用户说"重新授权"、"发起授权"、"重新发起"、"授权失败"、"授权过期"时,绝对不要调用此工具,授权流程由系统自动处理,无需人工干预。' +
+        '不需要传入 user_open_id,系统自动从消息上下文获取当前用户。',
+      parameters: FeishuOAuthSchema,
+
+      async execute(_toolCallId: string, params: unknown) {
+        const p = params as FeishuOAuthParams;
+
+        // Resolve identity from trace context (set in monitor.ts).
+        const ticket = getTicket();
+        const senderOpenId = ticket?.senderOpenId;
+        if (!senderOpenId) {
+          return json({
+            error: '无法获取当前用户身份(senderOpenId),请在飞书对话中使用此工具。',
+          });
+        }
+
+        // Use the accountId from LarkTicket to resolve the correct account
+        // (important for multi-account setups like prod + boe).
+        const acct = getLarkAccount(cfg, ticket.accountId);
+        if (!acct.configured) {
+          return json({
+            error: `账号 ${ticket.accountId} 缺少 appId 或 appSecret 配置`,
+          });
+        }
+        const account = acct; // Now we know it's ConfiguredLarkAccount
+
+        try {
+          switch (p.action) {
+            // ---------------------------------------------------------------
+            // AUTHORIZE — 已由 auto-auth 自动处理,此分支不再对外暴露
+            // ---------------------------------------------------------------
+            // case "authorize": {
+            //   return await executeAuthorize({
+            //     account,
+            //     senderOpenId,
+            //     scope: p.scope || "",
+            //     isBatchAuth: false,
+            //     cfg,
+            //     ticket,
+            //   });
+            // }
+
+            // ---------------------------------------------------------------
+            // STATUS
+            // ---------------------------------------------------------------
+            // case "status": {
+            //   const status = await getUATStatus(account.appId, senderOpenId);
+            //   return json({
+            //     authorized: status.authorized,
+            //     scope: status.scope,
+            //     token_status: status.tokenStatus,
+            //     granted_at: status.grantedAt
+            //       ? new Date(status.grantedAt).toISOString()
+            //       : undefined,
+            //     expires_at: status.expiresAt
+            //       ? new Date(status.expiresAt).toISOString()
+            //       : undefined,
+            //   });
+            // }
+
+            // ---------------------------------------------------------------
+            // REVOKE
+            // ---------------------------------------------------------------
+            case 'revoke': {
+              await revokeUAT(account.appId, senderOpenId);
+              return json({ success: true, message: '用户授权已撤销。' });
+            }
+
+            default:
+              // eslint-disable-next-line @typescript-eslint/no-explicit-any
+              return json({ error: `未知操作: ${(p as any).action}` });
+          }
+        } catch (err) {
+          log.error(`${p.action} failed: ${err}`);
+          return json({ error: formatLarkError(err) });
+        }
+      },
+    },
+    { name: 'feishu_oauth' },
+  );
+
+  api.logger.info?.('feishu_oauth: Registered feishu_oauth tool');
+}
+
+// ---------------------------------------------------------------------------
+// Shared authorize logic (used by both feishu_oauth and feishu_oauth_batch_auth)
+// ---------------------------------------------------------------------------
+
+export interface ExecuteAuthorizeParams {
+  account: ConfiguredLarkAccount;
+  senderOpenId: string;
+  scope: string;
+  isBatchAuth?: boolean;
+  totalAppScopes?: number;
+  alreadyGranted?: number;
+  batchInfo?: string; // 分批授权提示信息
+  skipSyntheticMessage?: boolean; // true 时跳过合成消息发送(onboarding 场景)
+  showBatchAuthHint?: boolean; // true 时在授权卡片底部展示"授予所有用户权限"提示(仅 auto-auth 流程)
+  forceAuth?: boolean; // true 时跳过本地 token 缓存检查,强制发起新 Device Flow(AppScopeMissing 场景专用)
+  onAuthComplete?: () => void | Promise<void>; // 授权完成回调(用于批量授权链式触发)
+  cfg: ClawdbotConfig;
+  ticket: LarkTicket | undefined;
+}
+
+/**
+ * 执行 OAuth 授权流程(Device Flow)
+ * 可被 feishu_oauth 和 feishu_oauth_batch_auth 共享调用
+ */
+export async function executeAuthorize(
+  params: ExecuteAuthorizeParams,
+): Promise<{ content: Array<{ type: 'text'; text: string }>; details: unknown }> {
+  const {
+    account,
+    senderOpenId,
+    scope,
+    isBatchAuth,
+    totalAppScopes,
+    alreadyGranted,
+    batchInfo,
+    skipSyntheticMessage,
+    showBatchAuthHint,
+    forceAuth,
+    onAuthComplete,
+    cfg,
+    ticket,
+  } = params;
+  const { appId, appSecret, brand, accountId } = account;
+
+  // 0. Check if the user is the app owner (fail-close: 安全优先).
+  const sdk = LarkClient.fromAccount(account).sdk;
+  try {
+    await assertOwnerAccessStrict(account, sdk, senderOpenId);
+  } catch (err) {
+    if (err instanceof OwnerAccessDeniedError) {
+      log.warn(`non-owner user ${senderOpenId} attempted to authorize`);
+      return json({
+        error: 'permission_denied',
+        message: '当前应用仅限所有者(App Owner)使用。您没有权限发起授权,无法使用相关功能。',
+      });
+    }
+    throw err;
+  }
+
+  // effectiveScope:可变 scope 变量,后续可能因 pendingFlow 合并而扩大
+  let effectiveScope = scope;
+
+  // 1. Check if user already authorised + scope coverage.
+  // forceAuth=true 时跳过缓存检查,直接发起新 Device Flow。
+  // 用于 AppScopeMissing 场景:应用权限刚被移除再补回,本地 UAT 缓存的 scope 状态不可信。
+  const existing = forceAuth ? null : await getStoredToken(appId, senderOpenId);
+  if (existing && tokenStatus(existing) !== 'expired') {
+    // 如果请求了特定 scope,检查是否已覆盖
+    if (effectiveScope) {
+      const requestedScopes = effectiveScope.split(/\s+/).filter(Boolean);
+      const grantedScopes = new Set((existing.scope ?? '').split(/\s+/).filter(Boolean));
+      const missingScopes = requestedScopes.filter((s) => !grantedScopes.has(s));
+
+      if (missingScopes.length > 0) {
+        // scope 不足 → 继续走 Device Flow(飞书 OAuth 是增量授权)
+        log.info(`existing token missing scopes [${missingScopes.join(', ')}], starting incremental auth`);
+        // 不 revoke 旧 token,直接用缺失的 scope 发起新 Device Flow
+        // 飞书会累积授权,新 token 包含旧 + 新 scope
+        // 继续执行下面的 Device Flow 逻辑
+      } else {
+        if (onAuthComplete) {
+          try {
+            await onAuthComplete();
+          } catch (e) {
+            log.warn(`onAuthComplete failed: ${e}`);
+          }
+        }
+        return json({
+          success: true,
+          message: '用户已授权,scope 已覆盖。',
+          authorized: true,
+          scope: existing.scope,
+        });
+      }
+    } else {
+      if (onAuthComplete) {
+        try {
+          await onAuthComplete();
+        } catch (e) {
+          log.warn(`onAuthComplete failed: ${e}`);
+        }
+      }
+      return json({
+        success: true,
+        message: '用户已授权,无需重复授权。',
+        authorized: true,
+        scope: existing!.scope,
+      });
+    }
+  }
+
+  // 2. Guard against duplicate in-flight flows for this user.
+  const flowKey = `${appId}:${senderOpenId}`;
+  let reuseCardId: string | undefined;
+  let reuseSeq = 0;
+
+  if (pendingFlows.has(flowKey)) {
+    const oldFlow = pendingFlows.get(flowKey)!;
+    const currentMessageId = ticket?.messageId ?? '';
+
+    if (oldFlow.messageId === currentMessageId) {
+      // 同一轮工具调用(messageId 相同)→ 复用旧卡片
+      oldFlow.superseded = true;
+      oldFlow.controller.abort();
+      reuseCardId = oldFlow.cardId;
+      reuseSeq = oldFlow.sequence;
+      pendingFlows.delete(flowKey);
+
+      // scope 合并:将旧 flow 的 scope 与新请求合并
+      if (oldFlow.scope) {
+        const oldScopes = oldFlow.scope.split(/\s+/).filter(Boolean);
+        const newScopes = effectiveScope?.split(/\s+/).filter(Boolean) ?? [];
+        const merged = new Set([...oldScopes, ...newScopes]);
+        effectiveScope = [...merged].join(' ');
+        log.info(`scope merge on reuse: [${[...merged].join(', ')}]`);
+      }
+
+      log.info(`same message, replacing flow for user=${senderOpenId}, app=${appId}, reusing cardId=${reuseCardId}`);
+    } else {
+      // 新对话(messageId 不同)→ 取消旧流 + 旧卡片标记"授权未完成" + 创建新卡片
+      oldFlow.superseded = true;
+      oldFlow.controller.abort();
+      pendingFlows.delete(flowKey);
+      log.info(`new message, cancelling old flow for user=${senderOpenId}, app=${appId}, old cardId=${oldFlow.cardId}`);
+      // 标记旧卡片为"授权未完成"
+      try {
+        await updateCardKitCardForAuth({
+          cfg,
+          cardId: oldFlow.cardId,
+          card: buildAuthFailedCard('新的授权请求已发起'),
+          sequence: oldFlow.sequence + 1,
+          accountId,
+        });
+      } catch (e) {
+        log.warn(`failed to update old card to expired: ${e}`);
+      }
+      // reuseCardId 保持 undefined,后续会创建新卡片
+    }
+  }
+
+  // 2.5 应用 scope 预检:过滤掉应用未开通的 scope
+  let filteredScope = effectiveScope;
+  let unavailableScopes: string[] = [];
+
+  if (effectiveScope) {
+    try {
+      const sdk = LarkClient.fromAccount(account).sdk;
+      const requestedScopes = effectiveScope.split(/\s+/).filter(Boolean);
+      const appScopes = await getAppGrantedScopes(sdk, appId, 'user');
+
+      const availableScopes = requestedScopes.filter((s) => appScopes.includes(s));
+      unavailableScopes = requestedScopes.filter((s) => !appScopes.includes(s));
+
+      if (unavailableScopes.length > 0) {
+        log.info(`app has not granted scopes [${unavailableScopes.join(', ')}], filtering them out`);
+
+        if (availableScopes.length === 0) {
+          // 所有 scope 都未开通,直接返回错误
+          const openDomain = brand === 'lark' ? 'https://open.larksuite.com' : 'https://open.feishu.cn';
+          const permissionUrl = `${openDomain}/app/${appId}/permission`;
+          return json({
+            error: 'app_scopes_not_granted',
+            message: `应用未开通任何请求的用户权限,无法发起授权。请先在开放平台开通以下权限:\n${unavailableScopes.map((s) => `- ${s}`).join('\n')}\n\n权限管理地址:${permissionUrl}`,
+            unavailable_scopes: unavailableScopes,
+            app_permission_url: permissionUrl,
+          });
+        }
+
+        // 部分 scope 未开通,只授权已开通的 scope
+        filteredScope = availableScopes.join(' ');
+        log.info(`proceeding with available scopes [${availableScopes.join(', ')}]`);
+      }
+    } catch (err) {
+      // 如果 scope 检查失败,记录日志但继续执行(降级处理)
+      log.warn(`failed to check app scopes, proceeding anyway: ${err}`);
+    }
+  }
+
+  // 3. Request device authorisation.
+  const deviceAuth = await requestDeviceAuthorization({
+    appId,
+    appSecret,
+    brand,
+    scope: filteredScope,
+  });
+
+  // 4. Build and send authorisation card.
+  const authCard = buildAuthCard({
+    verificationUriComplete: deviceAuth.verificationUriComplete,
+    expiresMin: Math.round(deviceAuth.expiresIn / 60),
+    scope: filteredScope, // 使用过滤后的 scope
+    isBatchAuth,
+    totalAppScopes,
+    alreadyGranted,
+    batchInfo,
+    filteredScopes: unavailableScopes.length > 0 ? unavailableScopes : undefined,
+    appId,
+    showBatchAuthHint,
+    brand,
+  });
+
+  let cardId: string;
+  let seq: number;
+  const chatId = ticket?.chatId;
+  if (!chatId || !ticket) {
+    return json({ error: '无法确定发送目标' });
+  }
+
+  if (reuseCardId) {
+    // 复用旧卡片:原地更新内容(scope + 授权链接),不创建新卡片
+    const newSeq = reuseSeq + 1;
+    try {
+      await updateCardKitCardForAuth({
+        cfg,
+        cardId: reuseCardId,
+        card: authCard,
+        sequence: newSeq,
+        accountId,
+      });
+      log.info(`updated existing card ${reuseCardId} with merged scopes, seq=${newSeq}`);
+    } catch (err) {
+      log.warn(`failed to update existing card, creating new one: ${err}`);
+      // 降级:创建新卡片
+      const newCardId = await createCardEntity({ cfg, card: authCard, accountId });
+      if (!newCardId) return json({ error: '创建授权卡片失败' });
+      if (chatId) {
+        await sendCardByCardId({
+          cfg,
+          to: chatId,
+          cardId: newCardId,
+          replyToMessageId: ticket?.messageId?.startsWith('om_') ? ticket.messageId : undefined,
+          replyInThread: Boolean(ticket?.threadId),
+          accountId,
+        });
+      }
+      cardId = newCardId;
+      seq = 1;
+      reuseCardId = undefined;
+    }
+    if (reuseCardId) {
+      cardId = reuseCardId;
+      seq = newSeq;
+    } else {
+      cardId = cardId!;
+      seq = seq!;
+    }
+  } else {
+    // 首次创建卡片
+    const newCardId = await createCardEntity({ cfg, card: authCard, accountId });
+    if (!newCardId) {
+      return json({ error: '创建授权卡片失败' });
+    }
+
+    await sendCardByCardId({
+      cfg,
+      to: chatId,
+      cardId: newCardId,
+      replyToMessageId: ticket?.messageId?.startsWith('om_') ? ticket.messageId : undefined,
+      replyInThread: Boolean(ticket?.threadId),
+      accountId,
+    });
+
+    cardId = newCardId;
+    seq = 1;
+  }
+
+  // 7. Start background polling.
+  const abortController = new AbortController();
+
+  const currentFlow: PendingFlow = {
+    controller: abortController,
+    cardId,
+    sequence: seq,
+    messageId: ticket?.messageId ?? '',
+    superseded: false,
+    scope: effectiveScope,
+  };
+  pendingFlows.set(flowKey, currentFlow);
+  let pendingFlowDelete = false;
+  // Fire-and-forget – polling happens asynchronously.
+  pollDeviceToken({
+    appId,
+    appSecret,
+    brand,
+    deviceCode: deviceAuth.deviceCode,
+    interval: deviceAuth.interval,
+    expiresIn: deviceAuth.expiresIn,
+    signal: abortController.signal,
+  })
+    .then(async (result) => {
+      // 被新流替换后,跳过所有卡片更新,避免覆盖新流的卡片内容
+      if (currentFlow.superseded) {
+        log.info(`flow superseded, skipping card update for cardId=${cardId}`);
+        return;
+      }
+      if (result.ok) {
+        // ===== 身份校验:验证实际授权用户与发起人一致 =====
+        const identity = await verifyTokenIdentity(brand, result.token.accessToken, senderOpenId);
+        if (!identity.valid) {
+          log.warn(
+            `identity mismatch! expected=${senderOpenId}, ` +
+              `actual=${identity.actualOpenId ?? 'unknown'}, cardId=${cardId}`,
+          );
+          try {
+            await updateCardKitCardForAuth({
+              cfg,
+              cardId,
+              card: buildAuthIdentityMismatchCard(brand),
+              sequence: ++seq,
+              accountId,
+            });
+          } catch (e) {
+            log.warn(`failed to update card for identity mismatch: ${e}`);
+          }
+          pendingFlows.delete(flowKey);
+          pendingFlowDelete = true;
+          return;
+        }
+        // ===== 身份校验通过,继续保存 token =====
+
+        // Save token to Keychain.
+        const now = Date.now();
+        const storedToken: StoredUAToken = {
+          userOpenId: senderOpenId,
+          appId,
+          accessToken: result.token.accessToken,
+          refreshToken: result.token.refreshToken,
+          expiresAt: now + result.token.expiresIn * 1000,
+          refreshExpiresAt: now + result.token.refreshExpiresIn * 1000,
+          scope: result.token.scope,
+          grantedAt: now,
+        };
+        await setStoredToken(storedToken);
+
+        // 1. Update card → success immediately so user sees
+        //    visual confirmation right away.
+        try {
+          await updateCardKitCardForAuth({
+            cfg,
+            cardId,
+            card: buildAuthSuccessCard(brand),
+            sequence: ++seq,
+            accountId,
+          });
+        } catch (e) {
+          log.warn(`failed to update card to success: ${e}`);
+        }
+        // 删除 pending flow
+        pendingFlows.delete(flowKey);
+        pendingFlowDelete = true;
+
+        // 2. Send synthetic message to notify AI that auth is
+        //    complete, so it can automatically retry the operation.
+        //    Skip when called from onboarding (no AI context to retry).
+        // 调用 onAuthComplete 回调(用于 onboarding 批量授权链式触发)
+        if (onAuthComplete) {
+          try {
+            await onAuthComplete();
+          } catch (e) {
+            log.warn(`onAuthComplete failed: ${e}`);
+          }
+        }
+
+        if (skipSyntheticMessage) {
+          log.info('skipSyntheticMessage=true, skipping synthetic message');
+        } else
+          try {
+            // Use a unique message_id for MessageSid (avoids SDK dedup),
+            // but pass the real message ID as replyToMessageId so that
+            // typing indicators, reply-to threading, and delivery work.
+            const syntheticMsgId = `${ticket.messageId}:auth-complete`;
+
+            const syntheticText = '我已完成飞书账号授权,请继续执行之前的操作。';
+            const syntheticEvent = {
+              sender: {
+                sender_id: { open_id: senderOpenId },
+              },
+              message: {
+                message_id: syntheticMsgId,
+                chat_id: chatId,
+                chat_type: ticket.chatType ?? ('p2p' as const),
+                message_type: 'text',
+                content: JSON.stringify({
+                  text: syntheticText,
+                }),
+                thread_id: ticket.threadId,
+              },
+            };
+
+            if (feishuInboundNeedsGatewayWebhook()) {
+              await forwardSyntheticTextMessageToGateway({
+                appId,
+                accountId,
+                openId: senderOpenId,
+                chatId,
+                chatType: ticket.chatType,
+                messageId: syntheticMsgId,
+                content: syntheticText,
+                rawEvent: syntheticEvent as Record<string, unknown>,
+              });
+              log.info('synthetic auth message forwarded to gateway webhook (standalone HTTP runtime)');
+            } else {
+              // Provide a minimal runtime so reply-dispatcher
+              // does not crash on `params.runtime.log?.()`.
+              const syntheticRuntime = {
+                log: (msg: string) => log.info(msg),
+                error: (msg: string) => log.error(msg),
+              };
+
+              const { status, promise } = enqueueFeishuChatTask({
+                accountId,
+                chatId,
+                threadId: ticket.threadId,
+                task: async () => {
+                  await withTicket(
+                    {
+                      messageId: syntheticMsgId,
+                      chatId,
+                      accountId,
+                      startTime: Date.now(),
+                      senderOpenId,
+                      chatType: ticket.chatType,
+                      threadId: ticket.threadId,
+                    },
+                    () =>
+                      handleFeishuMessage({
+                        cfg,
+                        // eslint-disable-next-line @typescript-eslint/no-explicit-any
+                        event: syntheticEvent as any,
+                        accountId,
+                        forceMention: true,
+                        // eslint-disable-next-line @typescript-eslint/no-explicit-any
+                        runtime: syntheticRuntime as any,
+                        replyToMessageId: ticket.messageId,
+                      }),
+                  );
+                },
+              });
+
+              log.info(`synthetic message queued (${status})`);
+              await promise;
+              log.info('synthetic message dispatched after successful auth');
+            }
+          } catch (e) {
+            log.warn(`failed to send synthetic message after auth: ${e}`);
+          }
+      } else {
+        // Update card → failure.
+        try {
+          await updateCardKitCardForAuth({
+            cfg,
+            cardId,
+            card: buildAuthFailedCard(result.message),
+            sequence: ++seq,
+            accountId,
+          });
+        } catch (e) {
+          log.warn(`failed to update card to failure: ${e}`);
+        }
+        // 删除 pending flow
+        pendingFlows.delete(flowKey);
+        pendingFlowDelete = true;
+      }
+    })
+    .catch((err) => {
+      log.error(`polling error: ${err}`);
+    })
+    .finally(() => {
+      if (!pendingFlowDelete) {
+        // 只在当前 flow 仍是注册的那个时才删除,避免旧流误删新流的 entry
+        if (pendingFlows.get(flowKey) === currentFlow) {
+          pendingFlows.delete(flowKey);
+        }
+      }
+    });
+
+  const scopeCount = filteredScope.split(/\s+/).filter(Boolean).length;
+  let message = isBatchAuth
+    ? `已发送批量授权请求卡片,共需授权 ${scopeCount} 个权限。请在卡片中完成授权。`
+    : '已发送授权请求卡片,请用户在卡片中点击链接完成授权。授权完成后请重新执行之前的操作。';
+
+  if (batchInfo) {
+    message += batchInfo;
+  }
+
+  // 如果有被过滤的 scope,添加提示信息
+  if (unavailableScopes.length > 0) {
+    const openDomain = brand === 'lark' ? 'https://open.larksuite.com' : 'https://open.feishu.cn';
+    const permissionUrl = `${openDomain}/app/${appId}/permission`;
+    message += `\n\n⚠️ **注意**:以下权限因应用未开通而被跳过,如需使用请先在开放平台开通:\n${unavailableScopes.map((s) => `- ${s}`).join('\n')}\n\n权限管理地址:${permissionUrl}`;
+  }
+
+  const openDomainForResult = brand === 'lark' ? 'https://open.larksuite.com' : 'https://open.feishu.cn';
+  return json({
+    success: true,
+    message,
+    awaiting_authorization: true,
+    filtered_scopes: unavailableScopes.length > 0 ? unavailableScopes : undefined,
+    app_permission_url: unavailableScopes.length > 0 ? `${openDomainForResult}/app/${appId}/permission` : undefined,
+  });
+}

+ 40 - 6
gateway/core/channels/feishu/router.py

@@ -18,6 +18,32 @@ from gateway.core.channels.types import CHANNEL_FEISHU, RouteResult
 logger = logging.getLogger(__name__)
 
 
+def _routing_from_card_action_raw(raw: dict[str, Any]) -> tuple[str | None, str | None, str | None]:
+    """
+    当规范化 JSON 未带 chat_id 时,从飞书 card.action.trigger 原始体兜底解析。
+    常见路径:event.context.open_chat_id / open_message_id(或顶层 open_chat_id)。
+    """
+    if not raw:
+        return None, None, None
+    ev: Any = raw.get("event") if isinstance(raw.get("event"), dict) else raw
+    if not isinstance(ev, dict):
+        return None, None, None
+    ctx = ev.get("context") if isinstance(ev.get("context"), dict) else {}
+
+    def pick(*keys: str) -> str | None:
+        for d in (ev, ctx):
+            for k in keys:
+                v = d.get(k)
+                if isinstance(v, str) and v.strip():
+                    return v.strip()
+        return None
+
+    chat_id = pick("open_chat_id", "chat_id")
+    message_id = pick("open_message_id", "message_id")
+    chat_type = pick("chat_type")
+    return chat_id, message_id, chat_type
+
+
 @runtime_checkable
 class FeishuExecutorBackend(ExecutorBackend, Protocol):
     """飞书执行器——窄化 ``ExecutorBackend`` 的参数类型为飞书专属结构。"""
@@ -47,7 +73,8 @@ class FeishuMessageRouter(ChannelTraceRouter):
     """
     飞书消息路由:用户 → trace_id → Executor;与 channels.md 中 MessageRouter 一致。
 
-    非 message 事件(reaction / card_action)默认跳过执行器,仅返回 200。
+    非 message 事件:reaction / card_action 由 ``dispatch_*`` 控制是否续跑 Agent。
+    card_action 常用于 OAuth / 权限卡片点击后触发继续流程(须开启 ``dispatch_card_actions``)。
     """
 
     def __init__(
@@ -76,14 +103,20 @@ class FeishuMessageRouter(ChannelTraceRouter):
         self._dispatch_card_actions = dispatch_card_actions
 
     def _reply_context_from_event(self, event: IncomingFeishuEvent) -> FeishuReplyContext | None:
-        if not event.chat_id:
+        chat_id = event.chat_id
+        message_id = event.message_id
+        if not chat_id and event.event_type == "card_action":
+            c, m, _ = _routing_from_card_action_raw(event.raw)
+            chat_id = chat_id or c
+            message_id = message_id or m
+        if not chat_id:
             logger.warning("missing chat_id, cannot reply: %s", feishu_event_to_mapping(event))
             return None
         return FeishuReplyContext(
             account_id=event.account_id,
             app_id=event.app_id,
-            chat_id=event.chat_id,
-            message_id=event.message_id,
+            chat_id=chat_id,
+            message_id=message_id,
             open_id=event.open_id,
         )
 
@@ -92,8 +125,9 @@ class FeishuMessageRouter(ChannelTraceRouter):
             return f"[系统-表情] {event.emoji} message_id={event.message_id or ''}"
         if event.event_type == "card_action":
             return (
-                f"[系统-卡片] action={event.action or ''} "
-                f"operation_id={event.operation_id or ''}"
+                "[系统-卡片交互] 用户已在飞书内完成卡片操作(如授权确认),"
+                "请结合当前上下文继续执行未完成任务,必要时重试刚才失败的工具。"
+                f" action={event.action or ''} operation_id={event.operation_id or ''}"
             )
         return None