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 vip.mate.channel.AbstractChannelAdapter;
import vip.mate.channel.ChannelMessage;
import vip.mate.channel.ChannelMessageRouter;
import vip.mate.channel.ExponentialBackoff;
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 禁用)
*
* @author MateClaw Team
*/
@Slf4j
public class FeishuChannelAdapter extends AbstractChannelAdapter {
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;
public FeishuChannelAdapter(ChannelEntity channelEntity,
ChannelMessageRouter messageRouter,
ObjectMapper objectMapper) {
super(channelEntity, messageRouter, objectMapper);
// 飞书 WebSocket 重连:2s→4s→8s→16s→30s,无限重试
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();
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;
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());
}
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);
}
}
})
.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 连接
* SDK 的 start() 在线程中阻塞运行,通过中断线程来触发停止
*/
private void stopWebSocket() {
cancelSilentDisconnectWatchdog();
hasReceivedFirstEvent = false;
if (wsThread != null) {
wsThread.interrupt();
wsThread = null;
}
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();
}
handleFeishuMessage(messageId, messageType, contentStr, chatId, chatType, senderOpenId, parentId, event);
}
// ==================== 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");
}
}
handleFeishuMessage(messageId, messageType, contentStr, chatId, chatType, senderOpenId, parentId, 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, Object rawPayload) {
// 消息去重
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));
}
}
// 生成短会话后缀
boolean isGroup = "group".equals(chatType);
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--;
}
}
}
// ==================== 消息反应 ====================
/**
* 非阻塞地给消息添加表情反应
* 在新线程中执行,失败只 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