From b982d4a2d03ec03c3a99e968b8f1d6d01c0f992c Mon Sep 17 00:00:00 2001 From: matevip Date: Tue, 28 Apr 2026 11:09:17 +0800 Subject: [PATCH] feat(feishu): default connection to WebSocket and hide webhook UI when unused MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend (FeishuChannelAdapter): - Default connection_mode flips webhook -> websocket on doStart and doReconnect. - Stale event filter: drop events whose message.create_time is older than stale_event_threshold_seconds (default 30s) so SDK reconnect replays do not re-trigger the agent. - Silent disconnect watchdog runs every 60s; if no events arrive for silent_disconnect_threshold_seconds (default 1800s) after the first event, call onDisconnected to force a reconnect cycle. Setting the threshold to 0 disables the watchdog. The watchdog is scheduled before wsClient.start() on the bring-up path because that call blocks indefinitely. - Quoted message context: when a reply has parent_id set, fetch the parent via GET /open-apis/im/v1/messages/{id}, summarize per msg_type (text / post first paragraph / [Image]/[File]/[Audio]/[Video] placeholders, capped at 200 chars), and prepend [Quoted: ...] to both content text and the first content part. LRU-cached (200) per message_id. - AbstractChannelAdapter gains getConfigLong helper for numeric config keys. Frontend: - types/index.ts feishu fields: default connection_mode is websocket; the recommended option moves to the top; verification_token and encrypt_key get showIf so they only render in webhook mode; new enable_quoted_context switch (default on) exposes the quoted-message feature. - ChannelEditModal builds a feishu-specific WEBHOOK_GUIDES path that picks webhookStep vs websocketStep based on connection_mode, so users only see steps for the mode they're using. - i18n: split feishu.step3/step4 into webhookStep/websocketStep, rename step5 to permissionStep. Channel type labels in zh-CN drop bilingual prefix (e.g. 'Feishu / Lark (飞书)' -> '飞书'). Migrations: - V52 was a no-op the first time it ran (matched compact JSON only) and Flyway refused to re-run after the SQL was fixed. V52 is documented as a no-op; V53 carries the actual UPDATE with REPLACE covering both compact and pretty-printed JSON, and an idempotent WHERE for rows already on websocket. h2 and mysql variants stay in lockstep. --- .../mate/channel/AbstractChannelAdapter.java | 9 + .../channel/feishu/FeishuChannelAdapter.java | 248 +++++++++++++++++- .../V52__feishu_default_connection_mode.sql | 20 ++ .../V53__feishu_connection_mode_recover.sql | 34 +++ .../V52__feishu_default_connection_mode.sql | 4 + .../V53__feishu_connection_mode_recover.sql | 24 ++ .../components/channels/ChannelEditModal.vue | 22 +- mateclaw-ui/src/i18n/locales/en-US.ts | 6 +- mateclaw-ui/src/i18n/locales/zh-CN.ts | 16 +- mateclaw-ui/src/types/index.ts | 7 +- 10 files changed, 364 insertions(+), 26 deletions(-) create mode 100644 mateclaw-server/src/main/resources/db/migration/h2/V52__feishu_default_connection_mode.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/h2/V53__feishu_connection_mode_recover.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/mysql/V52__feishu_default_connection_mode.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/mysql/V53__feishu_connection_mode_recover.sql 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> items = (List>) data.get("items"); + if (items == null || items.isEmpty()) return null; + Map item = items.get(0); + String msgType = (String) item.get("msg_type"); + Map body = (Map) item.get("body"); + if (body == null) return null; + String contentJson = (String) body.get("content"); + String summary = summarizeQuotedContent(msgType, contentJson); + + if (summary != null && !summary.isBlank()) { + if (quotedMessageCache.size() >= QUOTED_CACHE_MAX) { + int toRemove = quotedMessageCache.size() / 2; + var iter = quotedMessageCache.keySet().iterator(); + while (iter.hasNext() && toRemove > 0) { + iter.next(); iter.remove(); toRemove--; + } + } + quotedMessageCache.put(parentMessageId, summary); + } + return summary; + } catch (Exception e) { + log.debug("[feishu] fetchQuotedMessageText failed: {}", e.getMessage()); + return null; + } + } + + /** + * 把引用消息的 body.content 折叠成一个简短文本表示,控制 prompt 注入大小。 + * 长文本截断到 200 字符以内,post 取段落首文本,图片/文件给类型占位。 + */ + @SuppressWarnings("unchecked") + private String summarizeQuotedContent(String msgType, String contentJson) { + if (contentJson == null) return null; + try { + Map obj = objectMapper.readValue(contentJson, Map.class); + String text = switch (msgType != null ? msgType : "") { + case "text" -> (String) obj.get("text"); + case "image" -> "[图片]"; + case "file" -> "[文件: " + obj.getOrDefault("file_name", "") + "]"; + case "audio" -> "[音频]"; + case "media" -> "[视频]"; + case "post" -> extractPostFirstText(obj); + default -> "[" + msgType + "]"; + }; + if (text == null) return null; + text = text.trim(); + if (text.length() > 200) text = text.substring(0, 200) + "…"; + return text; + } catch (Exception e) { + return null; + } + } + + @SuppressWarnings("unchecked") + private String extractPostFirstText(Map postObj) { + // post 结构:{"zh_cn": {"title": "...", "content": [[{tag:text, text:"..."}], ...]}} + // 我们只取第一个语种的 title + 第一段的首个 text 元素,作为引用摘要 + for (Object langValue : postObj.values()) { + if (!(langValue instanceof Map lang)) continue; + String title = (String) ((Map) lang).get("title"); + List>> content = (List>>) ((Map) lang).get("content"); + StringBuilder sb = new StringBuilder(); + if (title != null && !title.isBlank()) sb.append(title).append(":"); + if (content != null) { + outer: + for (var paragraph : content) { + for (var inline : paragraph) { + if ("text".equals(inline.get("tag"))) { + sb.append(inline.get("text")); + break outer; + } + } + } + } + return sb.toString(); + } + return null; + } + // ==================== 会话 ID 优化 ==================== /** diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V52__feishu_default_connection_mode.sql b/mateclaw-server/src/main/resources/db/migration/h2/V52__feishu_default_connection_mode.sql new file mode 100644 index 00000000..d42fbe48 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V52__feishu_default_connection_mode.sql @@ -0,0 +1,20 @@ +-- V52: Intentional no-op (deprecated). +-- +-- Original intent: flip every Feishu channel's connection_mode to "websocket". +-- The first cut of this migration matched the compact JSON form +-- '"connection_mode":"webhook"' but the form UI persists configJson via +-- JSON.stringify(cfg, null, 2), which produces '"connection_mode": "webhook"' +-- (with a space after the colon). V52 ran in 4 ms, updated zero rows, and +-- got marked "successfully applied" in flyway_schema_history. Flyway's +-- repair() refuses to re-run a successful migration even after the SQL is +-- corrected, so the original V52 is effectively dead on every install that +-- already ran it. +-- +-- The actual migration was moved to V53. V52 stays here as a no-op so +-- existing schema_history records remain valid; FlywayRepairConfig will +-- align checksums on the next startup. +-- +-- The SELECT below is a portable no-op that satisfies Flyway's "at least +-- one statement" requirement on both H2 and MySQL. + +SELECT 1; diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V53__feishu_connection_mode_recover.sql b/mateclaw-server/src/main/resources/db/migration/h2/V53__feishu_connection_mode_recover.sql new file mode 100644 index 00000000..aa4d387b --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V53__feishu_connection_mode_recover.sql @@ -0,0 +1,34 @@ +-- V53: Recover from V52's silent no-op. +-- +-- V52 was supposed to flip every Feishu channel's connection_mode to +-- "websocket". Its REPLACE pattern matched the compact JSON form +-- '"connection_mode":"webhook"' but the form UI persists configJson via +-- JSON.stringify(cfg, null, 2), which produces '"connection_mode": "webhook"' +-- (with a space after the colon). V52 ran in 4 ms, updated zero rows, and +-- got marked "successfully applied" — Flyway's repair() then refuses to +-- re-run it on later starts. +-- +-- This migration redoes the work with REPLACE patterns that cover both +-- formats, plus a permissive WHERE clause so any row that escaped V52 is +-- now caught. Idempotent on rows already at "websocket". + +UPDATE mate_channel +SET config_json = CASE + WHEN config_json IS NULL OR TRIM(config_json) = '' OR TRIM(config_json) = '{}' THEN + '{"connection_mode":"websocket"}' + WHEN POSITION('"connection_mode"' IN config_json) = 0 THEN + '{"connection_mode":"websocket",' || SUBSTRING(config_json FROM 2) + ELSE + REPLACE( + REPLACE(config_json, + '"connection_mode": "webhook"', '"connection_mode": "websocket"'), + '"connection_mode":"webhook"', '"connection_mode":"websocket"' + ) + END +WHERE channel_type = 'feishu' + AND deleted = 0 + AND ( + config_json IS NULL + OR (POSITION('"connection_mode": "websocket"' IN config_json) = 0 + AND POSITION('"connection_mode":"websocket"' IN config_json) = 0) + ); diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V52__feishu_default_connection_mode.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V52__feishu_default_connection_mode.sql new file mode 100644 index 00000000..ee8a2b3f --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V52__feishu_default_connection_mode.sql @@ -0,0 +1,4 @@ +-- V52: Intentional no-op (deprecated). See h2/V52 for the full rationale. +-- The actual migration was moved to V53. + +SELECT 1; diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V53__feishu_connection_mode_recover.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V53__feishu_connection_mode_recover.sql new file mode 100644 index 00000000..fe44b113 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V53__feishu_connection_mode_recover.sql @@ -0,0 +1,24 @@ +-- V53: Recover from V52's silent no-op. +-- See h2/V53 for the full rationale. Same surgery, MySQL-flavored. +-- Idempotent: rows already at "websocket" are skipped by the WHERE clause. + +UPDATE mate_channel +SET config_json = CASE + WHEN config_json IS NULL OR TRIM(config_json) = '' OR TRIM(config_json) = '{}' THEN + '{"connection_mode":"websocket"}' + WHEN LOCATE('"connection_mode"', config_json) = 0 THEN + CONCAT('{"connection_mode":"websocket",', SUBSTRING(config_json, 2)) + ELSE + REPLACE( + REPLACE(config_json, + '"connection_mode": "webhook"', '"connection_mode": "websocket"'), + '"connection_mode":"webhook"', '"connection_mode":"websocket"' + ) + END +WHERE channel_type = 'feishu' + AND deleted = 0 + AND ( + config_json IS NULL + OR (LOCATE('"connection_mode": "websocket"', config_json) = 0 + AND LOCATE('"connection_mode":"websocket"', config_json) = 0) + ); diff --git a/mateclaw-ui/src/components/channels/ChannelEditModal.vue b/mateclaw-ui/src/components/channels/ChannelEditModal.vue index bc334185..60d640e1 100644 --- a/mateclaw-ui/src/components/channels/ChannelEditModal.vue +++ b/mateclaw-ui/src/components/channels/ChannelEditModal.vue @@ -445,7 +445,6 @@ const currentFieldDefs = computed(() => { const WEBHOOK_GUIDES = computed>(() => ({ dingtalk: { steps: [t('channels.guide.dingtalk.step1'), t('channels.guide.dingtalk.step2'), t('channels.guide.dingtalk.step3')] }, - feishu: { steps: [t('channels.guide.feishu.step1'), t('channels.guide.feishu.step2'), t('channels.guide.feishu.step3'), t('channels.guide.feishu.step4'), t('channels.guide.feishu.step5')] }, telegram: { steps: [t('channels.guide.telegram.step1'), t('channels.guide.telegram.step2'), t('channels.guide.telegram.step3'), t('channels.guide.telegram.step4')] }, discord: { steps: [t('channels.guide.discord.step1'), t('channels.guide.discord.step2'), t('channels.guide.discord.step3'), t('channels.guide.discord.step4'), t('channels.guide.discord.step5')] }, wecom: { steps: [t('channels.guide.wecom.step1'), t('channels.guide.wecom.step2'), t('channels.guide.wecom.step3'), t('channels.guide.wecom.step4')] }, @@ -453,7 +452,26 @@ const WEBHOOK_GUIDES = computed>(() => ({ qq: { steps: [t('channels.guide.qq.step1'), t('channels.guide.qq.step2'), t('channels.guide.qq.step3'), t('channels.guide.qq.step4')] }, })) -const webhookGuide = computed(() => WEBHOOK_GUIDES.value[form.value.channelType || ''] || null) +// Feishu's guide is mode-aware: showing both webhook and websocket setup +// instructions side-by-side (the original behavior) was confusing — the user +// only ever uses one mode, so we filter the mode-specific step accordingly. +// Webhook + websocket steps live as separate i18n keys; we assemble at render time. +const webhookGuide = computed(() => { + const type = form.value.channelType + if (!type) return null + if (type === 'feishu') { + const mode = channelConfig.value.connection_mode === 'webhook' ? 'webhook' : 'websocket' + return { + steps: [ + t('channels.guide.feishu.step1'), + t('channels.guide.feishu.step2'), + t(`channels.guide.feishu.${mode}Step`), + t('channels.guide.feishu.permissionStep'), + ], + } + } + return WEBHOOK_GUIDES.value[type] || null +}) const needsWebhookUrl = computed(() => { const type = form.value.channelType diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index 86ed0582..fabc0260 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -1607,9 +1607,9 @@ export default { feishu: { step1: 'Go to Feishu Open Platform and create an enterprise app', step2: 'Fill in the app\'s App ID and App Secret below', - step3: 'Webhook mode: Set the request URL to the Webhook URL above in "Event Subscriptions", and subscribe to im.message.receive_v1 event', - step4: 'WebSocket mode: No public address needed, select "WebSocket (Long Connection)" in the connection mode below. Enable long connection in Feishu "Event Subscriptions"', - step5: 'For nickname display, request contact:user.base:readonly permission in "Permission Management"', + webhookStep: 'In Feishu "Event Subscriptions", set the request URL to the Webhook URL above and subscribe to im.message.receive_v1', + websocketStep: 'In Feishu "Event Subscriptions", choose the "Long Connection" subscription method and subscribe to im.message.receive_v1 (no public IP required)', + permissionStep: 'For nickname display, request contact:user.base:readonly permission in "Permission Management"', }, telegram: { step1: 'Search for {at}BotFather in Telegram, send /newbot to create a Bot', diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index 3faf0d5f..d5cc5f8c 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -1502,15 +1502,15 @@ export default { }, types: { web: 'Web API', - dingtalk: 'DingTalk (钉钉)', - feishu: 'Feishu / Lark (飞书)', + dingtalk: '钉钉', + feishu: '飞书', telegram: 'Telegram', discord: 'Discord', - wecom: 'WeChat Work (企业微信)', - weixin: 'WeChat (微信)', + wecom: '企业微信', + weixin: '微信', qq: 'QQ', slack: 'Slack', - webchat: 'WebChat 嵌入', + webchat: '网页嵌入', webhook: 'Webhook', }, tabs: { @@ -1617,9 +1617,9 @@ export default { feishu: { step1: '前往 飞书开放平台 创建企业自建应用', step2: '将应用的 App IDApp Secret 填入下方配置', - step3: 'Webhook 模式:在「事件订阅」配置中将请求地址设置为上方 Webhook URL,并订阅 im.message.receive_v1 事件', - step4: 'WebSocket 模式:无需公网地址,在下方接入模式中选择「WebSocket(长连接)」即可。需在飞书后台「事件订阅」中选择长连接方式', - step5: '如需昵称显示,请在「权限管理」中申请 contact:user.base:readonly 权限', + webhookStep: '在飞书后台「事件订阅」中将请求地址设置为上方 Webhook URL,订阅 im.message.receive_v1 事件', + websocketStep: '在飞书后台「事件订阅」中选择「长连接」订阅方式,订阅 im.message.receive_v1 事件(无需公网 IP)', + permissionStep: '如需昵称显示,请在「权限管理」中申请 contact:user.base:readonly 权限', }, telegram: { step1: '在 Telegram 中搜索 {at}BotFather,发送 /newbot 创建 Bot', diff --git a/mateclaw-ui/src/types/index.ts b/mateclaw-ui/src/types/index.ts index fd1a68dc..8f2907ba 100644 --- a/mateclaw-ui/src/types/index.ts +++ b/mateclaw-ui/src/types/index.ts @@ -373,12 +373,13 @@ export const CHANNEL_FIELD_DEFS: Record = { feishu: [ { key: 'app_id', label: 'App ID', placeholder: 'cli_xxxxxxxx', required: true, type: 'text', tooltip: '飞书开放平台应用的 App ID' }, { key: 'app_secret', label: 'App Secret', placeholder: 'xxxxxxxxxxxxxxxx', required: true, sensitive: true, type: 'password', tooltip: '飞书开放平台应用的 App Secret' }, - { key: 'connection_mode', label: '接入模式', placeholder: '', type: 'select', defaultValue: 'webhook', tooltip: 'Webhook 需要公网回调地址;WebSocket 长连接无需公网 IP,适合本地开发和内网部署', options: [{ label: 'Webhook(HTTP 回调)', value: 'webhook' }, { label: 'WebSocket(长连接)', value: 'websocket' }] }, + { key: 'connection_mode', label: '接入模式', placeholder: '', type: 'select', defaultValue: 'websocket', tooltip: 'WebSocket 长连接无需公网 IP(推荐,本地开发和内网部署都能直接用);Webhook 需要公网回调地址', options: [{ label: 'WebSocket(长连接,推荐)', value: 'websocket' }, { label: 'Webhook(HTTP 回调)', value: 'webhook' }] }, { key: 'domain', label: '服务区域', placeholder: '', type: 'select', defaultValue: 'feishu', tooltip: '国内版使用 feishu(open.feishu.cn),国际版使用 lark(open.larksuite.com)', options: [{ label: '飞书(国内版)', value: 'feishu' }, { label: 'Lark(国际版)', value: 'lark' }] }, - { key: 'verification_token', label: '验证 Token', placeholder: 'xxxxxxxx', type: 'text', tooltip: '事件订阅的 Verification Token(Webhook 模式需要)' }, - { key: 'encrypt_key', label: '加密密钥', placeholder: '可选,事件加密密钥', sensitive: true, type: 'password', tooltip: 'Encrypt Key,用于事件回调的消息解密(可选)' }, + { key: 'verification_token', label: '验证 Token', placeholder: 'xxxxxxxx', type: 'text', tooltip: '事件订阅的 Verification Token', showIf: { field: 'connection_mode', value: 'webhook' } }, + { key: 'encrypt_key', label: '加密密钥', placeholder: '事件加密密钥(webhook 模式必填)', sensitive: true, type: 'password', tooltip: 'Encrypt Key,用于 webhook 事件回调的消息解密(webhook 模式下必填,否则启动时会拒绝)', showIf: { field: 'connection_mode', value: 'webhook' } }, { key: 'enable_reaction', label: '消息反应', placeholder: '', type: 'switch', defaultValue: true, tooltip: '收到消息后自动添加 👍 表情反应,让用户知道消息已收到' }, { key: 'enable_nickname_cache', label: '昵称获取', placeholder: '', type: 'switch', defaultValue: true, tooltip: '通过联系人 API 获取用户真实昵称(需要 contact:user.base:readonly 权限)' }, + { key: 'enable_quoted_context', label: '引用消息上下文', placeholder: '', type: 'switch', defaultValue: true, tooltip: '用户引用某条消息回复时,自动拉取被引用消息内容注入到 prompt,agent 才能理解"解释一下"这种缺主语的引用' }, { key: 'media_download_enabled', label: '媒体下载', placeholder: '', type: 'switch', defaultValue: false, tooltip: '下载消息中的图片和文件到本地(保存至 ~/.mateclaw/media/feishu/)' }, ], telegram: [