mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(feishu): default connection to WebSocket and hide webhook UI when unused
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.
This commit is contained in:
parent
22894ac4b1
commit
b982d4a2d0
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取配置中的列表值
|
||||
*/
|
||||
|
||||
@ -34,20 +34,24 @@ import java.util.concurrent.TimeUnit;
|
||||
* 飞书渠道适配器
|
||||
* <p>
|
||||
* 飞书渠道实现:
|
||||
* - 接入模式:Event Subscription(HTTP 回调)或 WebSocket 长连接
|
||||
* - 接入模式:WebSocket 长连接(默认,无需公网 IP)或 Event Subscription(HTTP 回调)
|
||||
* - 发送方式:通过 Open API 发送消息
|
||||
* - 消息去重:基于 message_id 防止重复处理
|
||||
* - 引用消息:自动拉取 parent_id 对应的父消息内容并注入上下文
|
||||
* <p>
|
||||
* 配置项(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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动静默断连看门狗。
|
||||
* <p>
|
||||
* SDK 内部有 ping/pong 心跳,正常情况下连接断开会触发 start() 返回 → onDisconnected。
|
||||
* 但少数场景 TCP 层认为连接还在但事件不再流入(NAT 超时、半开连接、对端 hang 死),
|
||||
* SDK 也察觉不到。看门狗每 60s 检查一次「最近事件时间」,超过阈值就强制重连。
|
||||
* <p>
|
||||
* 静默判定有两个前置条件:
|
||||
* 1. 已经收到过至少一个事件(避免新连接立即触发误报)
|
||||
* 2. silent_disconnect_threshold_seconds > 0(设为 0 可禁用看门狗)
|
||||
* <p>
|
||||
* 默认阈值 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<String, Object> sender = (Map<String, Object>) 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<String, String> quotedMessageCache = new ConcurrentHashMap<>();
|
||||
private static final int QUOTED_CACHE_MAX = 200;
|
||||
|
||||
/**
|
||||
* 拉取被引用消息的文本摘要。
|
||||
* <p>
|
||||
* 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<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
if (response.statusCode() != 200) {
|
||||
log.debug("[feishu] Fetch quoted message failed: status={}", response.statusCode());
|
||||
return null;
|
||||
}
|
||||
Map<String, Object> 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<String, Object> data = (Map<String, Object>) result.get("data");
|
||||
if (data == null) return null;
|
||||
List<Map<String, Object>> items = (List<Map<String, Object>>) data.get("items");
|
||||
if (items == null || items.isEmpty()) return null;
|
||||
Map<String, Object> item = items.get(0);
|
||||
String msgType = (String) item.get("msg_type");
|
||||
Map<String, Object> body = (Map<String, Object>) 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<String, Object> 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<String, Object> 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<String, Object>) lang).get("title");
|
||||
List<List<Map<String, Object>>> content = (List<List<Map<String, Object>>>) ((Map<String, Object>) 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 优化 ====================
|
||||
|
||||
/**
|
||||
|
||||
@ -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;
|
||||
@ -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)
|
||||
);
|
||||
@ -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;
|
||||
@ -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)
|
||||
);
|
||||
@ -445,7 +445,6 @@ const currentFieldDefs = computed<ChannelFieldDef[]>(() => {
|
||||
|
||||
const WEBHOOK_GUIDES = computed<Record<string, { steps: string[] }>>(() => ({
|
||||
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<Record<string, { steps: string[] }>>(() => ({
|
||||
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
|
||||
|
||||
@ -1607,9 +1607,9 @@ export default {
|
||||
feishu: {
|
||||
step1: 'Go to <a href="https://open.feishu.cn/" target="_blank" rel="noopener">Feishu Open Platform</a> and create an enterprise app',
|
||||
step2: 'Fill in the app\'s <b>App ID</b> and <b>App Secret</b> below',
|
||||
step3: '<b>Webhook mode</b>: Set the request URL to the Webhook URL above in "Event Subscriptions", and subscribe to <code>im.message.receive_v1</code> event',
|
||||
step4: '<b>WebSocket mode</b>: 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 <code>contact:user.base:readonly</code> permission in "Permission Management"',
|
||||
webhookStep: 'In Feishu "Event Subscriptions", set the request URL to the Webhook URL above and subscribe to <code>im.message.receive_v1</code>',
|
||||
websocketStep: 'In Feishu "Event Subscriptions", choose the "Long Connection" subscription method and subscribe to <code>im.message.receive_v1</code> (no public IP required)',
|
||||
permissionStep: 'For nickname display, request <code>contact:user.base:readonly</code> permission in "Permission Management"',
|
||||
},
|
||||
telegram: {
|
||||
step1: 'Search for <a href="https://t.me/BotFather" target="_blank" rel="noopener">{at}BotFather</a> in Telegram, send <code>/newbot</code> to create a Bot',
|
||||
|
||||
@ -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: '前往 <a href="https://open.feishu.cn/" target="_blank" rel="noopener">飞书开放平台</a> 创建企业自建应用',
|
||||
step2: '将应用的 <b>App ID</b> 和 <b>App Secret</b> 填入下方配置',
|
||||
step3: '<b>Webhook 模式</b>:在「事件订阅」配置中将请求地址设置为上方 Webhook URL,并订阅 <code>im.message.receive_v1</code> 事件',
|
||||
step4: '<b>WebSocket 模式</b>:无需公网地址,在下方接入模式中选择「WebSocket(长连接)」即可。需在飞书后台「事件订阅」中选择长连接方式',
|
||||
step5: '如需昵称显示,请在「权限管理」中申请 <code>contact:user.base:readonly</code> 权限',
|
||||
webhookStep: '在飞书后台「事件订阅」中将请求地址设置为上方 Webhook URL,订阅 <code>im.message.receive_v1</code> 事件',
|
||||
websocketStep: '在飞书后台「事件订阅」中选择「长连接」订阅方式,订阅 <code>im.message.receive_v1</code> 事件(无需公网 IP)',
|
||||
permissionStep: '如需昵称显示,请在「权限管理」中申请 <code>contact:user.base:readonly</code> 权限',
|
||||
},
|
||||
telegram: {
|
||||
step1: '在 Telegram 中搜索 <a href="https://t.me/BotFather" target="_blank" rel="noopener">{at}BotFather</a>,发送 <code>/newbot</code> 创建 Bot',
|
||||
|
||||
@ -373,12 +373,13 @@ export const CHANNEL_FIELD_DEFS: Record<string, ChannelFieldDef[]> = {
|
||||
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: [
|
||||
|
||||
Loading…
Reference in New Issue
Block a user