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:
matevip 2026-04-28 11:09:17 +08:00
parent 22894ac4b1
commit b982d4a2d0
10 changed files with 364 additions and 26 deletions

View File

@ -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;
}
/**
* 获取配置中的列表值
*/

View File

@ -34,20 +34,24 @@ import java.util.concurrent.TimeUnit;
* 飞书渠道适配器
* <p>
* 飞书渠道实现
* - 接入模式Event SubscriptionHTTP 回调 WebSocket 长连接
* - 接入模式WebSocket 长连接默认无需公网 IP Event SubscriptionHTTP 回调
* - 发送方式通过 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: 事件验证 Tokenwebhook 模式可选
* - enable_reaction: 是否在收到消息后添加表情反应默认 true
* - enable_nickname_cache: 是否通过 Contact API 获取用户昵称默认 true
* - media_download_enabled: 是否下载消息中的媒体文件默认 false
* - enable_quoted_context: 是否拉取被引用消息内容注入到 prompt默认 true
* - silent_disconnect_threshold_seconds: WebSocket 静默断连阈值默认 18000 禁用
* - stale_event_threshold_seconds: 过滤旧事件阈值默认 300 禁用
*
* @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 &gt; 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 优化 ====================
/**

View File

@ -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;

View File

@ -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)
);

View File

@ -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;

View File

@ -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)
);

View File

@ -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

View File

@ -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',

View File

@ -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',

View File

@ -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: 'WebhookHTTP 回调)', 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: 'WebhookHTTP 回调)', value: 'webhook' }] },
{ key: 'domain', label: '服务区域', placeholder: '', type: 'select', defaultValue: 'feishu', tooltip: '国内版使用 feishuopen.feishu.cn国际版使用 larkopen.larksuite.com', options: [{ label: '飞书(国内版)', value: 'feishu' }, { label: 'Lark国际版', value: 'lark' }] },
{ key: 'verification_token', label: '验证 Token', placeholder: 'xxxxxxxx', type: 'text', tooltip: '事件订阅的 Verification TokenWebhook 模式需要)' },
{ 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: '用户引用某条消息回复时,自动拉取被引用消息内容注入到 promptagent 才能理解"解释一下"这种缺主语的引用' },
{ key: 'media_download_enabled', label: '媒体下载', placeholder: '', type: 'switch', defaultValue: false, tooltip: '下载消息中的图片和文件到本地(保存至 ~/.mateclaw/media/feishu/' },
],
telegram: [