diff --git a/mateclaw-server/src/main/java/vip/mate/channel/AbstractChannelAdapter.java b/mateclaw-server/src/main/java/vip/mate/channel/AbstractChannelAdapter.java
index f577fce3..0f9ac2a6 100644
--- a/mateclaw-server/src/main/java/vip/mate/channel/AbstractChannelAdapter.java
+++ b/mateclaw-server/src/main/java/vip/mate/channel/AbstractChannelAdapter.java
@@ -458,6 +458,15 @@ public abstract class AbstractChannelAdapter implements ChannelAdapter {
return defaultValue;
}
+ protected long getConfigLong(String key, long defaultValue) {
+ Object value = config.get(key);
+ if (value instanceof Number n) return n.longValue();
+ if (value instanceof String s && !s.isBlank()) {
+ try { return Long.parseLong(s.trim()); } catch (NumberFormatException ignored) {}
+ }
+ return defaultValue;
+ }
+
/**
* 获取配置中的列表值
*/
diff --git a/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuChannelAdapter.java b/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuChannelAdapter.java
index 7ce7f803..abe5defb 100644
--- a/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuChannelAdapter.java
+++ b/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuChannelAdapter.java
@@ -34,20 +34,24 @@ import java.util.concurrent.TimeUnit;
* 飞书渠道适配器
*
* 飞书渠道实现:
- * - 接入模式:Event Subscription(HTTP 回调)或 WebSocket 长连接
+ * - 接入模式:WebSocket 长连接(默认,无需公网 IP)或 Event Subscription(HTTP 回调)
* - 发送方式:通过 Open API 发送消息
* - 消息去重:基于 message_id 防止重复处理
+ * - 引用消息:自动拉取 parent_id 对应的父消息内容并注入上下文
*
* 配置项(configJson):
* - app_id: 飞书应用 App ID
* - app_secret: 飞书应用 App Secret
- * - connection_mode: 接入模式 "webhook"(默认)或 "websocket"
+ * - connection_mode: 接入模式 "websocket"(默认)或 "webhook"
* - domain: "feishu"(默认)或 "lark"(国际版)
- * - encrypt_key: 事件加密密钥(可选)
- * - verification_token: 事件验证 Token(可选)
+ * - 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
*/
@@ -76,6 +80,21 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter {
/** 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) {
@@ -106,11 +125,11 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter {
// 定时刷新 Token:过期前 5 分钟自动刷新
scheduleTokenRefresh();
- String connectionMode = getConfigString("connection_mode", "webhook");
+ String connectionMode = getConfigString("connection_mode", "websocket");
if ("websocket".equals(connectionMode)) {
startWebSocket(appId, appSecret);
} else {
- // RFC-025 Change 3: webhook 模式下 encrypt_key 必须配置,否则 fail-fast 拒绝启动;
+ // webhook 模式下 encrypt_key 必须配置,否则 fail-fast 拒绝启动;
// 没有加密密钥 + 无签名校验 = 任何人可伪造 webhook 请求触发 agent 消息
String encryptKey = getConfigString("encrypt_key", null);
if (encryptKey == null || encryptKey.isBlank()) {
@@ -142,6 +161,7 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter {
this.tenantAccessToken = null;
this.processedMessageIds.clear();
this.nicknameCache.clear();
+ this.quotedMessageCache.clear();
log.info("[feishu] Feishu channel stopped");
}
@@ -149,7 +169,7 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter {
protected void doReconnect() {
String appId = getConfigString("app_id");
String appSecret = getConfigString("app_secret");
- String connectionMode = getConfigString("connection_mode", "webhook");
+ String connectionMode = getConfigString("connection_mode", "websocket");
// 重新建立 HTTP 客户端
this.httpClient = HttpClient.newBuilder()
@@ -236,6 +256,54 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter {
}, "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;
+ }
}
/**
@@ -247,6 +315,9 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter {
private void startWebSocketSync(String appId, String appSecret) {
wsClient = createWsClient(appId, appSecret);
log.info("[feishu] WebSocket connecting (long connection)...");
+ // 看门狗必须在 start() 之前启动,否则 start() 阻塞后这一行永远到不了,
+ // 一旦断连后重连这条路径,watchdog 就再也不会被恢复
+ startSilentDisconnectWatchdog();
wsClient.start(); // 阻塞:成功则永驻,失败则抛异常
}
@@ -255,6 +326,8 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter {
* SDK 的 start() 在线程中阻塞运行,通过中断线程来触发停止
*/
private void stopWebSocket() {
+ cancelSilentDisconnectWatchdog();
+ hasReceivedFirstEvent = false;
if (wsThread != null) {
wsThread.interrupt();
wsThread = null;
@@ -274,18 +347,41 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter {
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, event);
+ handleFeishuMessage(messageId, messageType, contentStr, chatId, chatType, senderOpenId, parentId, event);
}
// ==================== Token 管理 ====================
@@ -419,6 +515,7 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter {
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");
@@ -430,7 +527,7 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter {
}
}
- handleFeishuMessage(messageId, messageType, contentStr, chatId, chatType, senderOpenId, payload);
+ handleFeishuMessage(messageId, messageType, contentStr, chatId, chatType, senderOpenId, parentId, payload);
} catch (Exception e) {
log.error("[feishu] Failed to handle webhook: {}", e.getMessage(), e);
@@ -450,11 +547,12 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter {
* @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,
- Object rawPayload) {
+ String parentId, Object rawPayload) {
// 消息去重
if (messageId != null && !processedMessageIds.add(messageId)) {
log.debug("[feishu] Duplicate message_id: {}, skipping", messageId);
@@ -482,6 +580,18 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter {
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);
@@ -632,6 +742,124 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter {
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