package vip.mate.channel.feishu;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.lark.oapi.event.EventDispatcher;
import com.lark.oapi.service.im.ImService;
import com.lark.oapi.service.im.v1.model.P2MessageReceiveV1;
import lombok.extern.slf4j.Slf4j;
import reactor.core.publisher.Flux;
import vip.mate.agent.AgentService.StreamDelta;
import vip.mate.channel.AbstractChannelAdapter;
import vip.mate.channel.ChannelMessage;
import vip.mate.channel.ChannelMessageRouter;
import vip.mate.channel.ExponentialBackoff;
import vip.mate.channel.StreamingChannelAdapter;
import vip.mate.channel.media.GeneratedFileScrubber;
import vip.mate.channel.media.MediaSource;
import vip.mate.channel.media.MediaUploadException;
import vip.mate.channel.media.MediaUploadRequest;
import vip.mate.channel.media.MediaUploadResult;
import vip.mate.channel.model.ChannelEntity;
import vip.mate.workspace.conversation.model.MessageContentPart;
import java.io.InputStream;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
/**
* 飞书渠道适配器
*
* 飞书渠道实现:
* - 接入模式:WebSocket 长连接(默认,无需公网 IP)或 Event Subscription(HTTP 回调)
* - 发送方式:通过 Open API 发送消息
* - 消息去重:基于 message_id 防止重复处理
* - 引用消息:自动拉取 parent_id 对应的父消息内容并注入上下文
*
* 配置项(configJson):
* - app_id: 飞书应用 App ID
* - app_secret: 飞书应用 App Secret
* - connection_mode: 接入模式 "websocket"(默认)或 "webhook"
* - domain: "feishu"(默认)或 "lark"(国际版)
* - encrypt_key: 事件加密密钥(webhook 模式必填)
* - verification_token: 事件验证 Token(webhook 模式可选)
* - enable_reaction: 是否在收到消息后添加表情反应(默认 true)
* - enable_nickname_cache: 是否通过 Contact API 获取用户昵称(默认 true)
* - media_download_enabled: 是否下载消息中的媒体文件(默认 false)
* - enable_quoted_context: 是否拉取被引用消息内容注入到 prompt(默认 true)
* - silent_disconnect_threshold_seconds: WebSocket 静默断连阈值(默认 1800,0 禁用)
* - stale_event_threshold_seconds: 过滤旧事件阈值(默认 30,0 禁用)
* - card_format: 卡片格式化模式 "auto"(默认)| "always" | "never"
* auto: 根据内容自动检测;always: 全部包卡片;never: 全部纯文本(降级/调试用)
* - card_header: Markdown 卡片 header 文案,默认 "AI 助手";设为空串可隐藏 header
* - require_mention: 群聊中是否需要 @机器人 才响应(默认 false)
* true: 仅当消息中 @了机器人才处理;通过飞书 mentions 字段精确判断,无需配置 botPrefix
*
* @author MateClaw Team
*/
@Slf4j
public class FeishuChannelAdapter extends AbstractChannelAdapter implements StreamingChannelAdapter {
public static final String CHANNEL_TYPE = "feishu";
private HttpClient httpClient;
private String tenantAccessToken;
private long tokenExpireTime;
/** 定时 Token 刷新任务 */
private ScheduledFuture> tokenRefreshFuture;
/** 消息去重:最近处理过的 message_id */
private final Set processedMessageIds = ConcurrentHashMap.newKeySet();
/** 昵称缓存:open_id → 显示名称 */
private final ConcurrentHashMap nicknameCache = new ConcurrentHashMap<>();
private static final int NICKNAME_CACHE_MAX = 500;
/** WebSocket 客户端(websocket 模式) */
private volatile com.lark.oapi.ws.Client wsClient;
/** WebSocket 连接线程 */
private volatile Thread wsThread;
/** WebSocket 静默断连看门狗:定期检查最近事件时间,超过阈值就强制重连 */
private ScheduledFuture> silentDisconnectWatchdog;
/** 是否已收到至少一个事件(用于避免新连接立即触发静默超时) */
private volatile boolean hasReceivedFirstEvent = false;
/** 看门狗检查间隔(秒) */
private static final long WATCHDOG_INTERVAL_SECONDS = 60L;
/** 静默断连默认阈值(秒):30 分钟无事件就视为可疑 */
private static final long DEFAULT_SILENT_THRESHOLD_SECONDS = 1800L;
/** 旧事件过滤默认阈值(秒):超过 30 秒的事件视为重连后回放 */
private static final long DEFAULT_STALE_THRESHOLD_SECONDS = 30L;
/** Bot's own open_id, fetched once from /open-apis/bot/v3/info and cached. */
private volatile String botOpenId;
/** Serializes lazy bot-open-id fetches so concurrent group messages share one API roundtrip. */
private final Object botOpenIdLock = new Object();
/**
* Last failure timestamp for {@code /open-apis/bot/v3/info}. While the call is in the
* {@link #BOT_OPENID_FAILURE_BACKOFF_MS} negative-cache window, {@link #getBotOpenId()}
* returns {@code null} fast so a Feishu outage doesn't trigger one synchronous retry
* per inbound group message.
*/
private volatile long botOpenIdLastFailureMs = 0L;
/** Negative-cache window for {@link #getBotOpenId()} failures (60 s). */
private static final long BOT_OPENID_FAILURE_BACKOFF_MS = 60_000L;
/** SDK-backed uploader for image / file / audio / video parts. Nullable for legacy callers. */
private final FeishuMediaUploader mediaUploader;
/** Scrubs {@code /api/v1/files/generated/{id}} URLs into native attachments. Nullable for legacy callers. */
private final GeneratedFileScrubber generatedFileScrubber;
/** CardKit streaming-card manager. Nullable for legacy callers / tests. */
private final FeishuStreamingCardManager streamingCardManager;
/** Interactive-card dispatcher (approval cards etc.). Nullable for legacy callers / tests. */
private final vip.mate.channel.feishu.cards.FeishuCardDispatcher cardDispatcher;
/**
* Per-channel SDK {@code Client} factory — drives inbound resource
* downloads via {@code client.im().v1().messageResource().get(...)}
* instead of the legacy hand-rolled {@code HttpClient} path. Nullable
* for legacy callers / tests, in which case the adapter falls back
* to the raw HTTP path (still works, just bypasses retries / token
* refresh / domain switching that the SDK handles).
*/
private final FeishuClientFactory clientFactory;
/**
* Process-local cache that exposes downloaded inbound media via
* {@code /api/v1/files/generated/{id}} so the conversation memory
* and admin UI can render the file without holding a tenant token.
* Nullable for legacy callers / tests.
*/
private final vip.mate.tool.document.GeneratedFileCache generatedFileCache;
/**
* STT service for transcribing inbound voice messages. Feishu, unlike
* WeCom / DingTalk, does NOT include ASR text in the webhook payload —
* its inbound audio carries only a {@code file_key}. So we download the
* bytes (via {@link #downloadResource}) and run them through
* {@link vip.mate.stt.SttService} here, prepending the transcript as a
* text {@link MessageContentPart} so the agent reasons over actual
* content instead of the bare {@code "[音频]"} placeholder.
*
* Nullable for legacy callers / tests — STT is skipped entirely when
* absent, and the message still goes through as audio-only (degraded
* but not broken).
*/
private final vip.mate.stt.SttService sttService;
public FeishuChannelAdapter(ChannelEntity channelEntity,
ChannelMessageRouter messageRouter,
ObjectMapper objectMapper) {
this(channelEntity, messageRouter, objectMapper, null, null, null, null, null, null, null);
}
public FeishuChannelAdapter(ChannelEntity channelEntity,
ChannelMessageRouter messageRouter,
ObjectMapper objectMapper,
FeishuMediaUploader mediaUploader,
GeneratedFileScrubber generatedFileScrubber) {
this(channelEntity, messageRouter, objectMapper, mediaUploader, generatedFileScrubber,
null, null, null, null, null);
}
public FeishuChannelAdapter(ChannelEntity channelEntity,
ChannelMessageRouter messageRouter,
ObjectMapper objectMapper,
FeishuMediaUploader mediaUploader,
GeneratedFileScrubber generatedFileScrubber,
FeishuStreamingCardManager streamingCardManager) {
this(channelEntity, messageRouter, objectMapper, mediaUploader,
generatedFileScrubber, streamingCardManager, null, null, null, null);
}
public FeishuChannelAdapter(ChannelEntity channelEntity,
ChannelMessageRouter messageRouter,
ObjectMapper objectMapper,
FeishuMediaUploader mediaUploader,
GeneratedFileScrubber generatedFileScrubber,
FeishuStreamingCardManager streamingCardManager,
vip.mate.channel.feishu.cards.FeishuCardDispatcher cardDispatcher) {
this(channelEntity, messageRouter, objectMapper, mediaUploader,
generatedFileScrubber, streamingCardManager, cardDispatcher, null, null, null);
}
public FeishuChannelAdapter(ChannelEntity channelEntity,
ChannelMessageRouter messageRouter,
ObjectMapper objectMapper,
FeishuMediaUploader mediaUploader,
GeneratedFileScrubber generatedFileScrubber,
FeishuStreamingCardManager streamingCardManager,
vip.mate.channel.feishu.cards.FeishuCardDispatcher cardDispatcher,
FeishuClientFactory clientFactory,
vip.mate.tool.document.GeneratedFileCache generatedFileCache) {
this(channelEntity, messageRouter, objectMapper, mediaUploader,
generatedFileScrubber, streamingCardManager, cardDispatcher,
clientFactory, generatedFileCache, null);
}
public FeishuChannelAdapter(ChannelEntity channelEntity,
ChannelMessageRouter messageRouter,
ObjectMapper objectMapper,
FeishuMediaUploader mediaUploader,
GeneratedFileScrubber generatedFileScrubber,
FeishuStreamingCardManager streamingCardManager,
vip.mate.channel.feishu.cards.FeishuCardDispatcher cardDispatcher,
FeishuClientFactory clientFactory,
vip.mate.tool.document.GeneratedFileCache generatedFileCache,
vip.mate.stt.SttService sttService) {
super(channelEntity, messageRouter, objectMapper);
this.mediaUploader = mediaUploader;
this.generatedFileScrubber = generatedFileScrubber;
this.streamingCardManager = streamingCardManager;
this.cardDispatcher = cardDispatcher;
this.clientFactory = clientFactory;
this.generatedFileCache = generatedFileCache;
this.sttService = sttService;
// Feishu WebSocket reconnect: 2s→4s→8s→16s→30s, infinite retry
this.backoff = new ExponentialBackoff(2000, 30000, 2.0, -1);
}
// ==================== 生命周期 ====================
@Override
protected void doStart() {
String appId = getConfigString("app_id");
String appSecret = getConfigString("app_secret");
if (appId == null || appSecret == null) {
throw new IllegalStateException("Feishu channel requires app_id and app_secret in configJson");
}
// HttpClient 两种模式都需要(发送消息、下载媒体、联系人 API)
this.httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
// 获取初始 tenant_access_token
refreshTenantAccessToken();
// 定时刷新 Token:过期前 5 分钟自动刷新
scheduleTokenRefresh();
// Prefetch the bot's own open_id once the token is valid so the first
// require_mention check on the WebSocket dispatch thread doesn't pay
// the 5 s API latency. Failure is non-fatal — getBotOpenId() handles
// it and the negative cache keeps subsequent retries cheap.
getBotOpenId();
String connectionMode = getConfigString("connection_mode", "websocket");
if ("websocket".equals(connectionMode)) {
startWebSocket(appId, appSecret);
} else {
// webhook 模式下 encrypt_key 必须配置,否则 fail-fast 拒绝启动;
// 没有加密密钥 + 无签名校验 = 任何人可伪造 webhook 请求触发 agent 消息
String encryptKey = getConfigString("encrypt_key", null);
if (encryptKey == null || encryptKey.isBlank()) {
throw new IllegalStateException(
"Feishu channel in webhook mode requires encrypt_key in configJson " +
"(fail-closed to prevent unauthenticated webhook abuse). " +
"Configure encrypt_key on the Feishu Event Subscriptions page and mirror " +
"it in this channel's configJson, or switch connection_mode to websocket.");
}
log.info("[feishu] Webhook mode (encrypt_key configured), waiting for callbacks at /api/v1/channels/webhook/feishu");
}
log.info("[feishu] Feishu channel initialized: appId={}, mode={}, domain={}",
appId, connectionMode, getConfigString("domain", "feishu"));
}
@Override
protected void doStop() {
// 取消定时 Token 刷新
if (tokenRefreshFuture != null) {
tokenRefreshFuture.cancel(false);
tokenRefreshFuture = null;
}
// 关闭 WebSocket
stopWebSocket();
this.httpClient = null;
this.tenantAccessToken = null;
// Reset bot-open-id state under the same lock getBotOpenId uses, so a
// dispatch thread mid-fetch sees a consistent (cleared) view rather than
// a torn write that could re-cache a stale id.
synchronized (botOpenIdLock) {
this.botOpenId = null;
this.botOpenIdLastFailureMs = 0L;
}
this.processedMessageIds.clear();
this.nicknameCache.clear();
this.quotedMessageCache.clear();
log.info("[feishu] Feishu channel stopped");
}
@Override
protected void doReconnect() {
String appId = getConfigString("app_id");
String appSecret = getConfigString("app_secret");
String connectionMode = getConfigString("connection_mode", "websocket");
// 重新建立 HTTP 客户端
this.httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
try {
refreshTenantAccessToken();
} catch (Exception e) {
log.warn("[feishu] Token refresh during reconnect failed: {}", e.getMessage());
}
// Re-prefetch bot open_id after reconnect: same dispatch-thread latency
// concern as doStart, plus picks up a rotated app identity if any.
getBotOpenId();
if ("websocket".equals(connectionMode)) {
log.info("[feishu] Reconnecting WebSocket...");
stopWebSocket();
// 同步连接:在当前重连线程中直接阻塞调用 start()
// 连接成功 start() 会一直阻塞(不会返回到这里)
// 连接失败 start() 抛异常,由 AbstractChannelAdapter.scheduleReconnect 捕获并触发 onReconnectFailed
startWebSocketSync(appId, appSecret);
}
log.info("[feishu] Reconnect completed for: {}", channelEntity.getName());
}
// ==================== WebSocket 长连接 ====================
/**
* 创建 WebSocket 客户端实例(不启动连接)
*/
private com.lark.oapi.ws.Client createWsClient(String appId, String appSecret) {
EventDispatcher eventDispatcher = EventDispatcher.newBuilder("", "")
.onP2MessageReceiveV1(new ImService.P2MessageReceiveV1Handler() {
@Override
public void handle(P2MessageReceiveV1 event) throws Exception {
if (!running.get()) return;
// 检查 app_id 匹配(防止多实例事件错路由)
if (event.getHeader() != null && event.getHeader().getAppId() != null
&& !event.getHeader().getAppId().equals(appId)) {
log.debug("[feishu] Dropping misrouted event, app_id={} (expected {})",
event.getHeader().getAppId(), appId);
return;
}
try {
handleWebSocketEvent(event);
} catch (Exception e) {
log.error("[feishu] Failed to handle WebSocket event: {}", e.getMessage(), e);
}
}
})
// Silently ignore all other IM events to avoid HandlerNotFoundException.
// When the SDK receives an event without a registered handler, it throws
// HandlerNotFoundException → SDK catches it and sends a 500 response →
// the Feishu server may close the WebSocket connection. The exception is
// swallowed inside the SDK so it never appears in application logs.
.onP2MessageReactionCreatedV1(new ImService.P2MessageReactionCreatedV1Handler() {
@Override
public void handle(com.lark.oapi.service.im.v1.model.P2MessageReactionCreatedV1 event) {}
})
.onP2MessageReactionDeletedV1(new ImService.P2MessageReactionDeletedV1Handler() {
@Override
public void handle(com.lark.oapi.service.im.v1.model.P2MessageReactionDeletedV1 event) {}
})
.onP2MessageReadV1(new ImService.P2MessageReadV1Handler() {
@Override
public void handle(com.lark.oapi.service.im.v1.model.P2MessageReadV1 event) {}
})
.onP2MessageRecalledV1(new ImService.P2MessageRecalledV1Handler() {
@Override
public void handle(com.lark.oapi.service.im.v1.model.P2MessageRecalledV1 event) {}
})
.onP2ChatMemberBotAddedV1(new ImService.P2ChatMemberBotAddedV1Handler() {
@Override
public void handle(com.lark.oapi.service.im.v1.model.P2ChatMemberBotAddedV1 event) {}
})
.onP2ChatMemberBotDeletedV1(new ImService.P2ChatMemberBotDeletedV1Handler() {
@Override
public void handle(com.lark.oapi.service.im.v1.model.P2ChatMemberBotDeletedV1 event) {}
})
.onP2ChatMemberUserAddedV1(new ImService.P2ChatMemberUserAddedV1Handler() {
@Override
public void handle(com.lark.oapi.service.im.v1.model.P2ChatMemberUserAddedV1 event) {}
})
.onP2ChatMemberUserDeletedV1(new ImService.P2ChatMemberUserDeletedV1Handler() {
@Override
public void handle(com.lark.oapi.service.im.v1.model.P2ChatMemberUserDeletedV1 event) {}
})
.onP2ChatMemberUserWithdrawnV1(new ImService.P2ChatMemberUserWithdrawnV1Handler() {
@Override
public void handle(com.lark.oapi.service.im.v1.model.P2ChatMemberUserWithdrawnV1 event) {}
})
.onP2ChatUpdatedV1(new ImService.P2ChatUpdatedV1Handler() {
@Override
public void handle(com.lark.oapi.service.im.v1.model.P2ChatUpdatedV1 event) {}
})
.onP2ChatDisbandedV1(new ImService.P2ChatDisbandedV1Handler() {
@Override
public void handle(com.lark.oapi.service.im.v1.model.P2ChatDisbandedV1 event) {}
})
.onP2ChatAccessEventBotP2pChatEnteredV1(new ImService.P2ChatAccessEventBotP2pChatEnteredV1Handler() {
@Override
public void handle(com.lark.oapi.service.im.v1.model.P2ChatAccessEventBotP2pChatEnteredV1 event) {}
})
// Interactive card button clicks (Schema 2.0) — routed through cardDispatcher.
// The returned P2CardActionTriggerResponse is how Schema-2.0
// cards update in-place; PATCH /im/v1/messages/{id} is a
// silent no-op for V2 cards and must not be used.
.onP2CardActionTrigger(new com.lark.oapi.event.cardcallback.P2CardActionTriggerHandler() {
@Override
public com.lark.oapi.event.cardcallback.model.P2CardActionTriggerResponse handle(
com.lark.oapi.event.cardcallback.model.P2CardActionTrigger event) {
if (!running.get()) return null;
try {
return handleCardActionTrigger(event);
} catch (Exception e) {
log.error("[feishu] Failed to handle card action: {}", e.getMessage(), e);
return null;
}
}
})
.build();
return new com.lark.oapi.ws.Client.Builder(appId, appSecret)
.eventHandler(eventDispatcher)
.autoReconnect(false) // 由我们的 ExponentialBackoff 控制重连,不用 SDK 内置重连
.domain("lark".equals(getConfigString("domain", "feishu"))
? "https://open.larksuite.com"
: "https://open.feishu.cn")
.build();
}
/**
* 启动 WebSocket 长连接(异步,用于 doStart 首次启动)
* 在守护线程中运行,避免阻塞主线程。连接失败时触发 onDisconnected → 退避重连
*/
private void startWebSocket(String appId, String appSecret) {
wsClient = createWsClient(appId, appSecret);
wsThread = new Thread(() -> {
try {
log.info("[feishu] WebSocket connecting (long connection)...");
wsClient.start();
// start() blocks until disconnect; if it returns normally, it means disconnected
if (running.get()) {
onDisconnected("WebSocket connection ended");
}
} catch (Exception e) {
log.error("[feishu] WebSocket error: {}", e.getMessage(), e);
if (running.get()) {
onDisconnected("WebSocket error: " + e.getMessage());
}
}
}, "feishu-ws-" + channelEntity.getId());
wsThread.setDaemon(true);
wsThread.start();
startSilentDisconnectWatchdog();
}
/**
* 启动静默断连看门狗。
*
* SDK 内部有 ping/pong 心跳,正常情况下连接断开会触发 start() 返回 → onDisconnected。
* 但少数场景 TCP 层认为连接还在但事件不再流入(NAT 超时、半开连接、对端 hang 死),
* SDK 也察觉不到。看门狗每 60s 检查一次「最近事件时间」,超过阈值就强制重连。
*
* 静默判定有两个前置条件:
* 1. 已经收到过至少一个事件(避免新连接立即触发误报)
* 2. silent_disconnect_threshold_seconds > 0(设为 0 可禁用看门狗)
*
* 默认阈值 30 分钟。安静的渠道(一天没几条消息)建议调大到 1-2 小时;
* 高频渠道可以调小到 5-10 分钟以更快发现问题。
*/
private void startSilentDisconnectWatchdog() {
long thresholdSec = getConfigLong("silent_disconnect_threshold_seconds",
DEFAULT_SILENT_THRESHOLD_SECONDS);
if (thresholdSec <= 0) {
log.debug("[feishu] Silent disconnect watchdog disabled (threshold=0)");
return;
}
long thresholdMs = thresholdSec * 1000L;
cancelSilentDisconnectWatchdog();
silentDisconnectWatchdog = ensureReconnectScheduler().scheduleAtFixedRate(() -> {
if (!running.get() || wsClient == null) return;
if (!hasReceivedFirstEvent) return; // 没收到首个事件前不算静默
long silentMs = System.currentTimeMillis() - lastEventTimeMs.get();
if (silentMs > thresholdMs) {
log.warn("[feishu] Silent WebSocket detected: no events for {}s (threshold {}s), forcing reconnect",
silentMs / 1000, thresholdSec);
onDisconnected("silent disconnect: no events for " + (silentMs / 1000) + "s");
}
}, WATCHDOG_INTERVAL_SECONDS, WATCHDOG_INTERVAL_SECONDS, TimeUnit.SECONDS);
log.info("[feishu] Silent disconnect watchdog started (threshold {}s, check every {}s)",
thresholdSec, WATCHDOG_INTERVAL_SECONDS);
}
private void cancelSilentDisconnectWatchdog() {
if (silentDisconnectWatchdog != null) {
silentDisconnectWatchdog.cancel(false);
silentDisconnectWatchdog = null;
}
}
/**
* 启动 WebSocket 长连接(同步,用于 doReconnect 重连线程)
* 在当前线程中阻塞调用 start():
* - 连接成功后 start() 会一直阻塞(收消息),不会返回
* - 连接失败 start() 抛异常,由 scheduleReconnect 的 catch 捕获 → onReconnectFailed → 退避递增
*/
private void startWebSocketSync(String appId, String appSecret) {
wsClient = createWsClient(appId, appSecret);
log.info("[feishu] WebSocket connecting (long connection)...");
// 看门狗必须在 start() 之前启动,否则 start() 阻塞后这一行永远到不了,
// 一旦断连后重连这条路径,watchdog 就再也不会被恢复
startSilentDisconnectWatchdog();
wsClient.start(); // 阻塞:成功则永驻,失败则抛异常
}
/**
* 关闭 WebSocket 连接
*
* 必须主动关闭底层 WebSocket 连接并触发 SDK 的内部清理(停止 pingLoop、
* 释放 ExecutorService)。仅置空引用会导致旧连接的 pingLoop 和线程池
* 持续运行,造成文件描述符和线程泄漏,最终使新连接无法建立。
*
* SDK 的 {@code disconnect()} 是 protected 的,无法直接调用。通过反射
* 获取内部 {@code conn} 字段并发送 close(1000) 触发 SDK 的
* {@code onClosed → disconnect()} 清理链路。
*/
private void stopWebSocket() {
cancelSilentDisconnectWatchdog();
hasReceivedFirstEvent = false;
if (wsThread != null) {
wsThread.interrupt();
wsThread = null;
}
if (wsClient != null) {
try {
// 反射获取 protected conn 字段,发送 close(1000) 触发 SDK 清理
var connField = wsClient.getClass().getDeclaredField("conn");
connField.setAccessible(true);
Object conn = connField.get(wsClient);
if (conn != null) {
// conn 是 OkHttp WebSocket,close(1000) 会触发 onClosed → disconnect()
var closeMethod = conn.getClass().getMethod("close", int.class, String.class);
closeMethod.invoke(conn, 1000, "client closed");
}
} catch (Exception e) {
log.debug("[feishu] Error during WebSocket disconnect: {}", e.getMessage());
}
}
wsClient = null;
}
/**
* 处理 WebSocket 事件:从 P2MessageReceiveV1 提取字段,调用统一入口
*/
private void handleWebSocketEvent(P2MessageReceiveV1 event) {
var eventBody = event.getEvent();
if (eventBody == null || eventBody.getMessage() == null) {
return;
}
var message = eventBody.getMessage();
var sender = eventBody.getSender();
// 任何事件到达都更新活跃时间戳,看门狗用它判断是否静默断连
touchActivity();
hasReceivedFirstEvent = true;
// 旧事件过滤:SDK 在重连后可能回放历史事件,按 message.create_time 过滤
// 远超 stale 阈值的消息(典型场景:连接断了 5 分钟后恢复,5 分钟前的消息再处理一次没意义)
long staleThresholdMs = getConfigLong("stale_event_threshold_seconds",
DEFAULT_STALE_THRESHOLD_SECONDS) * 1000L;
if (staleThresholdMs > 0 && message.getCreateTime() != null) {
try {
long msgCreateTimeMs = Long.parseLong(message.getCreateTime());
long ageMs = System.currentTimeMillis() - msgCreateTimeMs;
if (ageMs > staleThresholdMs) {
log.info("[feishu] Dropping stale event: messageId={}, age={}s (threshold {}s)",
message.getMessageId(), ageMs / 1000, staleThresholdMs / 1000);
return;
}
} catch (NumberFormatException ignored) {
// create_time 不是合法的毫秒数,跳过过滤而不是误丢
}
}
String messageId = message.getMessageId();
String messageType = message.getMessageType();
String contentStr = message.getContent();
String chatId = message.getChatId();
String chatType = message.getChatType();
String parentId = message.getParentId();
String senderOpenId = null;
if (sender != null && sender.getSenderId() != null) {
senderOpenId = sender.getSenderId().getOpenId();
}
boolean isBotMentioned = isBotMentionedInEvent(message.getMentions());
handleFeishuMessage(messageId, messageType, contentStr, chatId, chatType, senderOpenId, parentId, isBotMentioned, event);
}
// ==================== @提及检测 ====================
private boolean isBotMentionedInEvent(com.lark.oapi.service.im.v1.model.MentionEvent[] mentions) {
return eventMentionsContainBot(mentions, getBotOpenId());
}
private boolean isBotMentionedInWebhookMessage(Map message) {
Object mentionsObj = message.get("mentions");
if (!(mentionsObj instanceof List> list)) return false;
return webhookMentionsContainBot(list, getBotOpenId());
}
/** Package-private for testing: 判断 SDK mentions 数组中是否包含指定 open_id */
static boolean eventMentionsContainBot(com.lark.oapi.service.im.v1.model.MentionEvent[] mentions,
String botOpenId) {
if (mentions == null || mentions.length == 0 || botOpenId == null) return false;
for (var mention : mentions) {
if (mention.getId() != null && botOpenId.equals(mention.getId().getOpenId())) return true;
}
return false;
}
/** Package-private for testing: 判断 Webhook mentions 列表中是否包含指定 open_id */
static boolean webhookMentionsContainBot(List> mentions, String botOpenId) {
if (mentions == null || botOpenId == null) return false;
for (Object item : mentions) {
if (!(item instanceof Map, ?> mention)) continue;
Object idObj = mention.get("id");
if (!(idObj instanceof Map, ?> id)) continue;
if (botOpenId.equals(id.get("open_id"))) return true;
}
return false;
}
/**
* Package-private for testing: returns true when a group message should be dropped
* because {@code require_mention} is on, the bot was not mentioned, AND we know our
* own open_id (so we trust the negative answer).
*
* When {@code botOpenId} is {@code null} the bot identity is unavailable — either
* the {@code /open-apis/bot/v3/info} fetch hasn't succeeded yet, or it failed and is
* in the negative-cache window. In that case the gate falls open so a transient
* Feishu API outage doesn't silence the bot in every group it's in. The accompanying
* warn-level log makes the degraded mode visible.
*/
static boolean isGroupNonMentionDrop(boolean isGroup,
boolean requireMention,
boolean isBotMentioned,
String botOpenId) {
return isGroup && requireMention && !isBotMentioned && botOpenId != null;
}
/**
* Fetches the bot's own open_id once and caches it. Returns {@code null}
* when the identity is unavailable; callers (currently the
* {@code require_mention} gate) treat {@code null} as "identity unknown"
* and fall open so a transient API outage doesn't silence the bot.
*
*
Concurrent callers share a single API roundtrip via
* {@link #botOpenIdLock}. On failure, {@link #botOpenIdLastFailureMs} is
* stamped so callers within the next {@link #BOT_OPENID_FAILURE_BACKOFF_MS}
* ms return {@code null} immediately instead of triggering a fresh 5 s
* synchronous fetch per inbound message.
*/
private String getBotOpenId() {
String cached = botOpenId;
if (cached != null) return cached;
if (withinFailureBackoff()) return null;
synchronized (botOpenIdLock) {
// Re-check under the lock — another thread may have populated the
// cache or stamped a fresh failure while we were waiting.
if (botOpenId != null) return botOpenId;
if (withinFailureBackoff()) return null;
try {
ensureTokenValid();
String apiBase = getApiBaseUrl();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(apiBase + "/open-apis/bot/v3/info"))
.header("Authorization", "Bearer " + tenantAccessToken)
.GET()
.timeout(Duration.ofSeconds(5))
.build();
HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
Map, ?> body = objectMapper.readValue(response.body(), Map.class);
Map, ?> bot = (Map, ?>) body.get("bot");
if (bot != null && bot.get("open_id") instanceof String openId && !openId.isBlank()) {
botOpenId = openId;
log.info("[feishu] Bot open_id fetched and cached: {}", openId);
return openId;
}
// 2xx with no bot.open_id field → treat as transient failure.
botOpenIdLastFailureMs = System.currentTimeMillis();
log.warn("[feishu] /open-apis/bot/v3/info returned no bot.open_id; require_mention gate falls open for {}s",
BOT_OPENID_FAILURE_BACKOFF_MS / 1000);
} catch (Exception e) {
botOpenIdLastFailureMs = System.currentTimeMillis();
log.warn("[feishu] Failed to fetch bot open_id (require_mention gate falls open for {}s): {}",
BOT_OPENID_FAILURE_BACKOFF_MS / 1000, e.getMessage());
}
return null;
}
}
private boolean withinFailureBackoff() {
long last = botOpenIdLastFailureMs;
return last != 0L && System.currentTimeMillis() - last < BOT_OPENID_FAILURE_BACKOFF_MS;
}
// ==================== Token 管理 ====================
/**
* 定时刷新 Token:每隔 (expireSeconds - 300) 秒刷新一次
*/
private void scheduleTokenRefresh() {
// Token 默认有效期 7200s,提前 5 分钟刷新 => 周期 6900s
long refreshIntervalSeconds = Math.max(300, 7200 - 300);
tokenRefreshFuture = ensureReconnectScheduler().scheduleAtFixedRate(() -> {
if (!running.get()) return;
try {
refreshTenantAccessToken();
log.debug("[feishu] Scheduled token refresh succeeded");
} catch (Exception e) {
log.warn("[feishu] Scheduled token refresh failed: {}, will retry on next interval",
e.getMessage());
lastError = "Token refresh failed: " + e.getMessage();
}
}, refreshIntervalSeconds, refreshIntervalSeconds, TimeUnit.SECONDS);
log.info("[feishu] Token auto-refresh scheduled every {}s", refreshIntervalSeconds);
}
/**
* 获取/刷新 tenant_access_token
*/
private void refreshTenantAccessToken() {
String appId = getConfigString("app_id");
String appSecret = getConfigString("app_secret");
String apiBase = getApiBaseUrl();
try {
String jsonBody = objectMapper.writeValueAsString(Map.of(
"app_id", appId,
"app_secret", appSecret
));
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(apiBase + "/open-apis/auth/v3/tenant_access_token/internal"))
.header("Content-Type", "application/json; charset=utf-8")
.POST(HttpRequest.BodyPublishers.ofString(jsonBody))
.build();
HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
@SuppressWarnings("unchecked")
Map result = objectMapper.readValue(response.body(), Map.class);
Integer code = result.get("code") instanceof Number n ? n.intValue() : null;
if (code != null && code != 0) {
throw new RuntimeException("Feishu API error: code=" + code + ", msg=" + result.get("msg"));
}
this.tenantAccessToken = (String) result.get("tenant_access_token");
Object expire = result.get("expire");
int expireSeconds = expire instanceof Number n ? n.intValue() : 7200;
this.tokenExpireTime = System.currentTimeMillis() + (expireSeconds - 300) * 1000L;
log.info("[feishu] tenant_access_token refreshed, expires in {}s", expireSeconds);
} catch (Exception e) {
log.error("[feishu] Failed to refresh tenant_access_token: {}", e.getMessage(), e);
throw new RuntimeException("Token refresh failed: " + e.getMessage(), e);
}
}
private void ensureTokenValid() {
if (tenantAccessToken == null || System.currentTimeMillis() >= tokenExpireTime) {
try {
refreshTenantAccessToken();
} catch (Exception e) {
log.warn("[feishu] On-demand token refresh failed: {}", e.getMessage());
}
}
}
// ==================== Domain 国际化 ====================
/**
* 获取 API 基础 URL
* domain=feishu → https://open.feishu.cn
* domain=lark → https://open.larksuite.com
*/
private String getApiBaseUrl() {
String domain = getConfigString("domain", "feishu");
return "lark".equals(domain)
? "https://open.larksuite.com"
: "https://open.feishu.cn";
}
// ==================== Webhook 处理 ====================
/**
* 处理飞书 Event Subscription 回调
* 由 ChannelWebhookController 调用
*/
@SuppressWarnings("unchecked")
public Map handleWebhook(Map payload) {
// 处理 URL 验证请求
String type = (String) payload.get("type");
if ("url_verification".equals(type)) {
String challenge = (String) payload.get("challenge");
log.info("[feishu] URL verification challenge received");
return Map.of("challenge", challenge != null ? challenge : "");
}
try {
// 解析 v2 事件格式
Map header = (Map) payload.get("header");
Map event = (Map) payload.get("event");
if (header == null || event == null) {
log.warn("[feishu] Invalid event payload: missing header or event");
return Map.of("code", 0);
}
String eventType = (String) header.get("event_type");
if (!"im.message.receive_v1".equals(eventType)) {
log.debug("[feishu] Ignoring event type: {}", eventType);
return Map.of("code", 0);
}
// 解析消息
Map message = (Map) event.get("message");
if (message == null) {
return Map.of("code", 0);
}
String messageId = (String) message.get("message_id");
String messageType = (String) message.get("message_type");
String contentStr = (String) message.get("content");
String chatId = (String) message.get("chat_id");
String chatType = (String) message.get("chat_type");
String parentId = (String) message.get("parent_id");
// 提取发送者 open_id
Map sender = (Map) event.get("sender");
String senderOpenId = null;
if (sender != null) {
Map senderIdObj = (Map) sender.get("sender_id");
if (senderIdObj != null) {
senderOpenId = (String) senderIdObj.get("open_id");
}
}
boolean isBotMentioned = isBotMentionedInWebhookMessage(message);
handleFeishuMessage(messageId, messageType, contentStr, chatId, chatType, senderOpenId, parentId, isBotMentioned, payload);
} catch (Exception e) {
log.error("[feishu] Failed to handle webhook: {}", e.getMessage(), e);
}
return Map.of("code", 0);
}
// ==================== 统一消息处理入口 ====================
/**
* 统一消息处理入口(Webhook 和 WebSocket 共用)
*
* @param messageId 消息 ID
* @param messageType 消息类型(text/image/post/file/audio/media)
* @param contentStr 消息内容 JSON 字符串
* @param chatId 群组 ID(私聊为 null)
* @param chatType "p2p" 或 "group"
* @param senderOpenId 发送者 open_id
* @param parentId 被引用消息的 message_id(无引用时为 null)
* @param rawPayload 原始负载(用于调试)
*/
private void handleFeishuMessage(String messageId, String messageType, String contentStr,
String chatId, String chatType, String senderOpenId,
String parentId, boolean isBotMentioned, Object rawPayload) {
// require_mention 群聊过滤:群聊中必须 @机器人才响应。
// 当 botOpenId 为 null 时(API 抖动 / 尚未拉取成功),失败回退到放行 —
// 避免飞书 /open-apis/bot/v3/info 短暂不可用时整个群机器人变哑巴。
boolean isGroup = "group".equals(chatType);
boolean requireMention = getConfigBoolean("require_mention", false);
if (isGroupNonMentionDrop(isGroup, requireMention, isBotMentioned, botOpenId)) {
log.debug("[feishu] require_mention=true but bot not mentioned, dropping messageId={}", messageId);
return;
}
if (isGroup && requireMention && !isBotMentioned) {
// botOpenId is null here — identity unknown, gate falls open.
log.warn("[feishu] require_mention=true but bot open_id unavailable; allowing messageId={}", messageId);
}
// 消息去重
if (messageId != null && !processedMessageIds.add(messageId)) {
log.debug("[feishu] Duplicate message_id: {}, skipping", messageId);
return;
}
cleanupProcessedIds();
// 添加消息反应(非阻塞,表示"已收到")
if (messageId != null && getConfigBoolean("enable_reaction", true)) {
addReactionAsync(messageId, "THUMBSUP");
}
// 获取用户昵称
String senderName = senderOpenId;
if (senderOpenId != null && getConfigBoolean("enable_nickname_cache", true)) {
senderName = getUserName(senderOpenId);
}
// 解析消息内容
List contentParts = new ArrayList<>();
String textContent = extractContentParts(messageId, messageType, contentStr, contentParts);
if (contentParts.isEmpty() && (textContent == null || textContent.isBlank())) {
log.debug("[feishu] Empty message content, ignoring");
return;
}
// 引用消息(用户在飞书里"引用"了之前的某条消息回复):拉取被引用消息内容并注入上下文,
// 让 agent 能理解 "解释一下" 这种缺主语的引用回复 —— 不然就只看到"解释一下"三个字。
if (parentId != null && !parentId.isBlank() && getConfigBoolean("enable_quoted_context", true)) {
String quotedText = fetchQuotedMessageText(parentId);
if (quotedText != null && !quotedText.isBlank()) {
String prefix = "[引用消息: " + quotedText + "]\n";
textContent = prefix + (textContent != null ? textContent : "");
// 同步加一个 text part 到最前面,让多模态消息也能看到引用上下文
contentParts.add(0, MessageContentPart.text(prefix));
}
}
// 生成短会话后缀
String shortSuffix = generateShortSessionSuffix(chatId, senderOpenId, isGroup);
ChannelMessage channelMessage = ChannelMessage.builder()
.messageId(messageId)
.channelType(CHANNEL_TYPE)
.senderId(senderOpenId)
.senderName(senderName)
.chatId(isGroup ? shortSuffix : null)
.content(textContent != null ? textContent : "")
.contentType(messageType)
.contentParts(contentParts)
.inputMode("audio".equals(messageType) ? "voice" : "text")
.timestamp(LocalDateTime.now())
.rawPayload(rawPayload)
.build();
// replyToken ��留完整 chatId(��送消息需要完整 ID)
channelMessage.setReplyToken(chatId);
onMessage(channelMessage);
}
/**
* 清理旧的去重记录:超过 1000 条时保留最近添加的(移除最早的一半)
*/
private void cleanupProcessedIds() {
if (processedMessageIds.size() > 1000) {
int toRemove = processedMessageIds.size() / 2;
var iterator = processedMessageIds.iterator();
while (iterator.hasNext() && toRemove > 0) {
iterator.next();
iterator.remove();
toRemove--;
}
}
}
// ==================== 消息反应 ====================
/**
* Acknowledge a successful agent reply by reacting to the inbound
* message with a "DONE" (✅) emoji. Gated by {@code enable_done_reaction}
* (default true) — operators that prefer a quiet UI can disable it
* without losing the existing inbound "THUMBSUP" ack.
*/
@Override
public void onAgentCompleted(ChannelMessage inboundMessage) {
if (inboundMessage == null) return;
String messageId = inboundMessage.getMessageId();
if (messageId == null || messageId.isBlank()) return;
if (!getConfigBoolean("enable_done_reaction", true)) return;
addReactionAsync(messageId, "DONE");
}
// ==================== Approval card ====================
/**
* Card-button approval is the canonical path for Feishu when the
* dispatcher is wired. Tells {@code ChannelMessageRouter} not to
* auto-cancel pending approvals on subsequent user messages —
* users decide via clicking, not by typing {@code /approve}.
* Returns false only in legacy / test contexts where the
* dispatcher isn't injected (the inherited text-flow path applies).
*/
@Override
public boolean usesInteractiveApprovalCards() {
return cardDispatcher != null;
}
/**
* Render the approval notice as a Schema-2.0 interactive button card
* so the user can approve / deny in-channel without bouncing to the
* web UI. Falls back to the inherited markdown-text path when the
* dispatcher isn't wired (legacy constructors / test rigs), the
* card oversizes, or the platform refuses to deliver.
*/
@Override
public void sendApprovalNotice(String targetId,
vip.mate.channel.notification.ApprovalNotice notice) {
if (cardDispatcher == null || notice == null || targetId == null) {
super.sendApprovalNotice(targetId, notice);
return;
}
var kindOpt = cardDispatcher.lookupByName(
vip.mate.channel.feishu.cards.tool_guard.ToolGuardCardKindFactory.KIND_NAME);
if (kindOpt.isEmpty()) {
super.sendApprovalNotice(targetId, notice);
return;
}
try {
Map cardJson = kindOpt.get().renderer().render(notice);
boolean sent = sendCard(targetId, cardJson);
if (!sent) {
log.warn("[feishu-toolguard] sendCard returned false; falling back to text");
super.sendApprovalNotice(targetId, notice);
}
} catch (vip.mate.channel.cards.CardOversizedException e) {
log.warn("[feishu-toolguard] approval card oversized ({}); falling back to text", e.getMessage());
super.sendApprovalNotice(targetId, notice);
} catch (Exception e) {
log.error("[feishu-toolguard] render/send approval card failed; falling back to text: {}",
e.getMessage(), e);
super.sendApprovalNotice(targetId, notice);
}
}
/**
* Dispatch a {@code P2CardActionTrigger} (button click on an
* interactive card) to the matching {@code FeishuCardKind} via the
* dispatcher and propagate the kind's response back to Feishu so
* the card can update in-place. Returns null when no dispatcher is
* wired or no kind matches the action prefix — Feishu leaves the
* original card unchanged in that case.
*/
private com.lark.oapi.event.cardcallback.model.P2CardActionTriggerResponse handleCardActionTrigger(
com.lark.oapi.event.cardcallback.model.P2CardActionTrigger event) {
if (cardDispatcher == null || event == null || event.getEvent() == null) {
return null;
}
com.lark.oapi.event.cardcallback.model.P2CardActionTriggerData data = event.getEvent();
com.lark.oapi.event.cardcallback.model.CallBackAction action = data.getAction();
if (action == null || action.getValue() == null) {
return null;
}
Object actionField = action.getValue().get("action");
String actionStr = actionField != null ? actionField.toString() : null;
var kindOpt = cardDispatcher.lookupByAction(actionStr);
if (kindOpt.isEmpty()) {
log.debug("[feishu] No card kind registered for action={}", actionStr);
return null;
}
return kindOpt.get().handler().handle(this, data);
}
/**
* Re-enter the router as if the given message arrived from this
* channel. Used by the tool-guard card handler to re-emit the
* button click as a synthetic {@code /approve } or
* {@code /deny } so the router runs its canonical
* text-approve path ({@code resolveAndConsume + replay}).
*
* External callers must go through {@link #onMessage}; this is
* the in-package fast lane that skips the WS / webhook decode.
*/
public void injectSyntheticMessage(ChannelMessage message) {
messageRouter.enqueue(message, this, channelEntity);
}
/**
* 非阻塞地给消息添加表情反应
* 在新线程中执行,失败只 log.debug 不影响主流程
*/
private void addReactionAsync(String messageId, String emojiType) {
Thread reactionThread = new Thread(() -> {
try {
ensureTokenValid();
String apiBase = getApiBaseUrl();
String jsonBody = objectMapper.writeValueAsString(Map.of(
"reaction_type", Map.of("emoji_type", emojiType)
));
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(apiBase + "/open-apis/im/v1/messages/" + messageId + "/reactions"))
.header("Content-Type", "application/json; charset=utf-8")
.header("Authorization", "Bearer " + tenantAccessToken)
.POST(HttpRequest.BodyPublishers.ofString(jsonBody))
.timeout(Duration.ofSeconds(5))
.build();
HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
log.debug("[feishu] Add reaction failed: status={}, body={}", response.statusCode(), response.body());
} else {
log.debug("[feishu] Reaction {} added to message {}", emojiType, messageId);
}
} catch (Exception e) {
log.debug("[feishu] Add reaction error: {}", e.getMessage());
}
}, "feishu-reaction");
reactionThread.setDaemon(true);
reactionThread.start();
}
// ==================== 联系人昵称 ====================
/**
* 通过 open_id 获取用户昵称
* 优先查缓存 → 调用 Contact API → 降级返回 open_id 后缀
*/
@SuppressWarnings("unchecked")
private String getUserName(String openId) {
if (openId == null || openId.isBlank()) return openId;
// 1. 查缓存
String cached = nicknameCache.get(openId);
if (cached != null) return cached;
// 2. 调用 Contact API
try {
ensureTokenValid();
String apiBase = getApiBaseUrl();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(apiBase + "/open-apis/contact/v3/users/" + openId + "?user_id_type=open_id"))
.header("Authorization", "Bearer " + tenantAccessToken)
.timeout(Duration.ofSeconds(2))
.GET()
.build();
HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200) {
Map result = objectMapper.readValue(response.body(), Map.class);
Integer code = result.get("code") instanceof Number n ? n.intValue() : null;
if (code != null && code == 0) {
Map data = (Map) result.get("data");
if (data != null) {
Map user = (Map) data.get("user");
if (user != null) {
String name = firstNonBlank(
(String) user.get("name"),
(String) user.get("en_name")
);
if (name != null) {
// 缓存超限时清理最早的一半
if (nicknameCache.size() >= NICKNAME_CACHE_MAX) {
int toRemove = nicknameCache.size() / 2;
var iterator = nicknameCache.keySet().iterator();
while (iterator.hasNext() && toRemove > 0) {
iterator.next();
iterator.remove();
toRemove--;
}
}
nicknameCache.put(openId, name);
return name;
}
}
}
} else {
log.debug("[feishu] Contact API error for {}: code={}", openId, code);
}
}
} catch (Exception e) {
log.debug("[feishu] getUserName failed for {}: {}", openId, e.getMessage());
}
// 3. 降级:返回 open_id 后 6 位
String fallback = openId.length() > 6 ? openId.substring(openId.length() - 6) : openId;
return fallback;
}
private static String firstNonBlank(String... values) {
for (String v : values) {
if (v != null && !v.isBlank()) return v.trim();
}
return null;
}
// ==================== 引用消息上下文 ====================
/** 引用消息内容缓存:parent_message_id → 文本摘要(避免同一条引用反复拉 API) */
private final ConcurrentHashMap quotedMessageCache = new ConcurrentHashMap<>();
private static final int QUOTED_CACHE_MAX = 200;
/**
* 拉取被引用消息的文本摘要。
*
* GET /open-apis/im/v1/messages/{message_id} 返回 items[0],body.content 是一段 JSON 字符串,
* 形如 {"text": "..."} 或 post 富文本。我们只取一个简短文本表示,给 agent 当上下文用,
* 不还原完整富文本/媒体(成本高且 prompt 容易冗余)。
*/
@SuppressWarnings("unchecked")
private String fetchQuotedMessageText(String parentMessageId) {
String cached = quotedMessageCache.get(parentMessageId);
if (cached != null) return cached;
try {
ensureTokenValid();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(getApiBaseUrl() + "/open-apis/im/v1/messages/" + parentMessageId))
.header("Authorization", "Bearer " + tenantAccessToken)
.timeout(Duration.ofSeconds(3))
.GET()
.build();
HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
log.debug("[feishu] Fetch quoted message failed: status={}", response.statusCode());
return null;
}
Map result = objectMapper.readValue(response.body(), Map.class);
Integer code = result.get("code") instanceof Number n ? n.intValue() : null;
if (code == null || code != 0) {
log.debug("[feishu] Fetch quoted message API error: code={}, msg={}", code, result.get("msg"));
return null;
}
Map data = (Map) result.get("data");
if (data == null) return null;
List