From eccf91ed1e9a3550d4aabd60785abbeab72e6eda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=99=B4=E5=A4=A9?= Date: Sat, 23 May 2026 01:51:08 +0800 Subject: [PATCH] feat(hermes): add channel configuration editor --- package-lock.json | 18 +- package.json | 3 +- scripts/dev-api.js | 270 ++++++++++ src-tauri/src/commands/hermes.rs | 743 +++++++++++++++++++++++++++ src-tauri/src/lib.rs | 2 + src/engines/hermes/index.js | 3 +- src/engines/hermes/pages/channels.js | 337 +++++++++++- src/engines/hermes/style/hermes.css | 239 ++++++++- src/lib/tauri-api.js | 2 + src/locales/modules/engine.js | 51 ++ tests/hermes-channel-config.test.js | 119 +++++ 11 files changed, 1775 insertions(+), 12 deletions(-) create mode 100644 tests/hermes-channel-config.test.js diff --git a/package-lock.json b/package-lock.json index 69ce7d2..70a4fb8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,8 @@ "dependencies": { "@tauri-apps/api": "^2.5.0", "@tauri-apps/plugin-autostart": "^2.5.1", - "@tauri-apps/plugin-shell": "^2.2.1" + "@tauri-apps/plugin-shell": "^2.2.1", + "yaml": "^2.9.0" }, "devDependencies": { "@tauri-apps/cli": "^2.5.0", @@ -1351,6 +1352,21 @@ "optional": true } } + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } } } } diff --git a/package.json b/package.json index 590f3b3..980a9cf 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,8 @@ "dependencies": { "@tauri-apps/api": "^2.5.0", "@tauri-apps/plugin-autostart": "^2.5.1", - "@tauri-apps/plugin-shell": "^2.2.1" + "@tauri-apps/plugin-shell": "^2.2.1", + "yaml": "^2.9.0" }, "devDependencies": { "@tauri-apps/cli": "^2.5.0", diff --git a/scripts/dev-api.js b/scripts/dev-api.js index 4fdce27..7100434 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -12,6 +12,7 @@ import { fileURLToPath } from 'url' import net from 'net' import http from 'http' import crypto from 'crypto' +import * as YAML from 'yaml' import * as skillhubSdk from './lib/skillhub-sdk.js' const DOCKER_TASK_TIMEOUT_MS = 10 * 60 * 1000 @@ -2599,6 +2600,252 @@ export function buildMessagingPlatformFormValues(platform, saved = {}, options = return form } +const HERMES_CHANNEL_PLATFORMS = ['telegram', 'discord', 'slack', 'feishu'] + +function normalizeHermesPlatform(platform) { + const p = String(platform || '').trim().toLowerCase() + return HERMES_CHANNEL_PLATFORMS.includes(p) ? p : '' +} + +function toCamelCaseKey(key) { + return String(key || '').replace(/_([a-z0-9])/g, (_, c) => c.toUpperCase()) +} + +function putHermesString(form, source, key) { + const value = source?.[key] + if (typeof value === 'string') form[toCamelCaseKey(key)] = value +} + +function putHermesBool(form, source, key) { + const value = source?.[key] + if (typeof value === 'boolean') form[toCamelCaseKey(key)] = value +} + +function putHermesCsv(form, source, key) { + const value = csvForForm(source?.[key]) + if (value) form[toCamelCaseKey(key)] = value +} + +function normalizeHermesDmPolicy(raw) { + const value = String(raw || '').trim().toLowerCase() + if (value === 'pairing') return 'pair' + if (value === 'allow') return 'open' + if (value === 'deny') return 'disabled' + if (['pair', 'open', 'allowlist', 'disabled'].includes(value)) return value + return 'pair' +} + +function normalizeHermesGroupPolicy(raw) { + const value = String(raw || '').trim().toLowerCase() + if (value === 'all') return 'open' + if (value === 'mentioned') return 'open' + if (value === 'deny') return 'disabled' + if (['open', 'allowlist', 'disabled'].includes(value)) return value + return 'allowlist' +} + +function readHermesPlatform(config, platform) { + const platforms = config?.platforms && typeof config.platforms === 'object' ? config.platforms : {} + const entry = platforms?.[platform] && typeof platforms[platform] === 'object' ? platforms[platform] : {} + const extra = entry?.extra && typeof entry.extra === 'object' ? entry.extra : {} + return { entry, extra } +} + +export function buildHermesChannelConfigValues(config = {}) { + const values = {} + for (const platform of HERMES_CHANNEL_PLATFORMS) { + const { entry, extra } = readHermesPlatform(config, platform) + const form = { enabled: entry.enabled === true } + if (platform === 'telegram') { + form.botToken = typeof entry.token === 'string' ? entry.token : '' + } else if (platform === 'discord') { + form.token = typeof entry.token === 'string' ? entry.token : '' + } else if (platform === 'slack') { + form.botToken = typeof entry.token === 'string' ? entry.token : '' + putHermesString(form, extra, 'app_token') + putHermesString(form, extra, 'signing_secret') + putHermesString(form, extra, 'webhook_path') + } else if (platform === 'feishu') { + for (const key of ['app_id', 'app_secret', 'domain', 'connection_mode', 'webhook_path', 'reaction_notifications']) { + putHermesString(form, extra, key) + } + for (const key of ['typing_indicator', 'resolve_sender_names']) { + putHermesBool(form, extra, key) + } + } + putHermesString(form, extra, 'dm_policy') + putHermesString(form, extra, 'group_policy') + putHermesBool(form, extra, 'require_mention') + putHermesCsv(form, extra, 'allow_from') + putHermesCsv(form, extra, 'group_allow_from') + values[platform] = form + } + return values +} + +function setHermesExtra(entry, key, value) { + if (!entry.extra || typeof entry.extra !== 'object' || Array.isArray(entry.extra)) entry.extra = {} + if (value === undefined || value === null || value === '') return + entry.extra[key] = value +} + +function normalizeHermesChannelForm(platform, form = {}) { + const normalized = { ...(form || {}) } + normalized.enabled = normalized.enabled === true || normalized.enabled === 'true' || normalized.enabled === 'on' + if (Object.hasOwn(normalized, 'dmPolicy')) normalized.dmPolicy = normalizeHermesDmPolicy(normalized.dmPolicy) + if (Object.hasOwn(normalized, 'groupPolicy')) normalized.groupPolicy = normalizeHermesGroupPolicy(normalized.groupPolicy) + if (Object.hasOwn(normalized, 'allowFrom')) normalized.allowFrom = csvToStringArray(normalized.allowFrom) + if (Object.hasOwn(normalized, 'groupAllowFrom')) normalized.groupAllowFrom = csvToStringArray(normalized.groupAllowFrom) + if (Object.hasOwn(normalized, 'requireMention')) { + normalized.requireMention = normalized.requireMention === true || normalized.requireMention === 'true' || normalized.requireMention === 'on' + } + if (platform === 'feishu') { + normalized.domain = String(normalized.domain || '').trim() || 'feishu' + normalized.connectionMode = String(normalized.connectionMode || '').trim() || 'websocket' + normalized.webhookPath = String(normalized.webhookPath || '').trim() || '/feishu/webhook' + normalized.reactionNotifications = String(normalized.reactionNotifications || '').trim() || 'off' + if (!Object.hasOwn(normalized, 'typingIndicator')) normalized.typingIndicator = true + if (!Object.hasOwn(normalized, 'resolveSenderNames')) normalized.resolveSenderNames = true + } + if (platform === 'slack') { + normalized.webhookPath = String(normalized.webhookPath || '').trim() || '/slack/events' + } + return normalized +} + +export function mergeHermesChannelConfig(config = {}, platform, form = {}) { + const normalizedPlatform = normalizeHermesPlatform(platform) + if (!normalizedPlatform) throw new Error(`不支持的 Hermes 渠道: ${platform}`) + const next = mergeConfigsPreservingFields({}, config && typeof config === 'object' ? config : {}) + if (!next.platforms || typeof next.platforms !== 'object' || Array.isArray(next.platforms)) next.platforms = {} + const current = next.platforms[normalizedPlatform] && typeof next.platforms[normalizedPlatform] === 'object' + ? next.platforms[normalizedPlatform] + : {} + const entry = mergeConfigsPreservingFields(current, {}) + const normalized = normalizeHermesChannelForm(normalizedPlatform, form) + entry.enabled = normalized.enabled + if (normalizedPlatform === 'telegram') { + if (typeof normalized.botToken === 'string') entry.token = normalized.botToken.trim() + } else if (normalizedPlatform === 'discord') { + if (typeof normalized.token === 'string') entry.token = normalized.token.trim() + } else if (normalizedPlatform === 'slack') { + if (typeof normalized.botToken === 'string') entry.token = normalized.botToken.trim() + setHermesExtra(entry, 'app_token', String(normalized.appToken || '').trim()) + setHermesExtra(entry, 'signing_secret', String(normalized.signingSecret || '').trim()) + setHermesExtra(entry, 'webhook_path', String(normalized.webhookPath || '').trim()) + } else if (normalizedPlatform === 'feishu') { + setHermesExtra(entry, 'app_id', String(normalized.appId || '').trim()) + setHermesExtra(entry, 'app_secret', String(normalized.appSecret || '').trim()) + setHermesExtra(entry, 'domain', normalized.domain) + setHermesExtra(entry, 'connection_mode', normalized.connectionMode) + setHermesExtra(entry, 'webhook_path', normalized.webhookPath) + setHermesExtra(entry, 'reaction_notifications', normalized.reactionNotifications) + setHermesExtra(entry, 'typing_indicator', !!normalized.typingIndicator) + setHermesExtra(entry, 'resolve_sender_names', !!normalized.resolveSenderNames) + } + if (Object.hasOwn(normalized, 'dmPolicy')) setHermesExtra(entry, 'dm_policy', normalized.dmPolicy) + if (Object.hasOwn(normalized, 'groupPolicy')) { + setHermesExtra(entry, 'group_policy', normalized.groupPolicy) + if (normalizedPlatform === 'feishu') setHermesExtra(entry, 'default_group_policy', normalized.groupPolicy) + } + if (Object.hasOwn(normalized, 'requireMention')) setHermesExtra(entry, 'require_mention', !!normalized.requireMention) + if (Array.isArray(normalized.allowFrom)) setHermesExtra(entry, 'allow_from', normalized.allowFrom) + if (Array.isArray(normalized.groupAllowFrom)) setHermesExtra(entry, 'group_allow_from', normalized.groupAllowFrom) + next.platforms[normalizedPlatform] = entry + return next +} + +function readHermesConfigYamlObject() { + const configPath = path.join(hermesHome(), 'config.yaml') + if (!fs.existsSync(configPath)) return { configPath, exists: false, config: {} } + const raw = fs.readFileSync(configPath, 'utf8') + const parsed = raw.trim() ? YAML.parse(raw) : {} + if (parsed && (typeof parsed !== 'object' || Array.isArray(parsed))) { + throw new Error('config.yaml 顶层必须是对象') + } + return { configPath, exists: true, config: parsed || {} } +} + +function writeHermesConfigYamlObject(configPath, config) { + fs.mkdirSync(path.dirname(configPath), { recursive: true }) + if (fs.existsSync(configPath)) { + fs.copyFileSync(configPath, `${configPath}.bak-${Math.floor(Date.now() / 1000)}`) + } + fs.writeFileSync(configPath, YAML.stringify(config || {}, { lineWidth: 0 }), 'utf8') +} + +function writeHermesEnvValues(updates = {}) { + const envPath = path.join(hermesHome(), '.env') + fs.mkdirSync(path.dirname(envPath), { recursive: true }) + const raw = fs.existsSync(envPath) ? fs.readFileSync(envPath, 'utf8') : '' + const lines = raw.split('\n') + const remaining = new Set(Object.keys(updates)) + const out = [] + for (const line of lines) { + const trimmed = line.trim() + if (!trimmed || trimmed.startsWith('#')) { + out.push(line) + continue + } + const eq = trimmed.indexOf('=') + const key = eq > 0 ? trimmed.slice(0, eq).trim() : '' + if (key && Object.hasOwn(updates, key)) { + const value = updates[key] + if (value !== undefined && value !== null && String(value).trim() !== '') { + out.push(`${key}=${value}`) + } + remaining.delete(key) + continue + } + out.push(line) + } + for (const key of remaining) { + const value = updates[key] + if (value !== undefined && value !== null && String(value).trim() !== '') out.push(`${key}=${value}`) + } + let content = out.join('\n').replace(/\n+$/, '') + if (content) content += '\n' + fs.writeFileSync(envPath, content, 'utf8') +} + +function csvEnvValue(value) { + return csvToStringArray(value).join(',') +} + +function boolEnvValue(value) { + return value === true || value === 'true' || value === 'on' ? 'true' : 'false' +} + +export function buildHermesChannelEnvUpdates(platform, form = {}) { + const updates = {} + if (platform === 'telegram') { + updates.TELEGRAM_BOT_TOKEN = String(form.botToken || '').trim() + updates.TELEGRAM_ALLOWED_USERS = csvEnvValue(form.allowFrom) + updates.TELEGRAM_GROUP_ALLOWED_USERS = csvEnvValue(form.groupAllowFrom) + if (Object.hasOwn(form, 'requireMention')) updates.TELEGRAM_REQUIRE_MENTION = boolEnvValue(form.requireMention) + } else if (platform === 'discord') { + updates.DISCORD_BOT_TOKEN = String(form.token || '').trim() + updates.DISCORD_ALLOWED_USERS = csvEnvValue(form.allowFrom) + if (Object.hasOwn(form, 'requireMention')) updates.DISCORD_REQUIRE_MENTION = boolEnvValue(form.requireMention) + } else if (platform === 'slack') { + updates.SLACK_BOT_TOKEN = String(form.botToken || '').trim() + updates.SLACK_APP_TOKEN = String(form.appToken || '').trim() + updates.SLACK_ALLOWED_USERS = csvEnvValue(form.allowFrom) + if (Object.hasOwn(form, 'requireMention')) updates.SLACK_REQUIRE_MENTION = boolEnvValue(form.requireMention) + } else if (platform === 'feishu') { + updates.FEISHU_APP_ID = String(form.appId || '').trim() + updates.FEISHU_APP_SECRET = String(form.appSecret || '').trim() + updates.FEISHU_DOMAIN = String(form.domain || 'feishu').trim() + updates.FEISHU_CONNECTION_MODE = String(form.connectionMode || 'websocket').trim() + updates.FEISHU_WEBHOOK_PATH = String(form.webhookPath || '/feishu/webhook').trim() + updates.FEISHU_ALLOWED_USERS = csvEnvValue(form.allowFrom) + updates.FEISHU_GROUP_POLICY = String(form.groupPolicy || 'allowlist').trim() + updates.FEISHU_REQUIRE_MENTION = Object.hasOwn(form, 'requireMention') ? boolEnvValue(form.requireMention) : 'true' + updates.FEISHU_REACTIONS = String(form.reactionNotifications || '').trim() === 'off' ? 'false' : 'true' + } + return updates +} + function channelHasQqbotCredentials(entry) { return !!(entry && typeof entry === 'object' && (entry.appId || entry.clientSecret || entry.appSecret || entry.token)) } @@ -7711,6 +7958,29 @@ const handlers = { return { model: displayModel, model_raw: modelName, base_url: baseUrl, provider, api_key: apiKey, config_exists: fs.existsSync(configPath) } }, + hermes_channel_config_read() { + const { configPath, exists, config } = readHermesConfigYamlObject() + return { + exists, + configPath, + values: buildHermesChannelConfigValues(config), + } + }, + + hermes_channel_config_save({ platform, form } = {}) { + const normalizedPlatform = normalizeHermesPlatform(platform) + if (!normalizedPlatform) throw new Error(`不支持的 Hermes 渠道: ${platform || ''}`) + const { configPath, config } = readHermesConfigYamlObject() + const next = mergeHermesChannelConfig(config, normalizedPlatform, form || {}) + writeHermesConfigYamlObject(configPath, next) + writeHermesEnvValues(buildHermesChannelEnvUpdates(normalizedPlatform, form || {})) + return { + ok: true, + configPath, + values: buildHermesChannelConfigValues(next)[normalizedPlatform], + } + }, + // P1-3 lazy_deps: Web 模式下不能调 venv python,但仍提供 feature 列表 + 提示用户走桌面端装 hermes_lazy_deps_features() { const features = [ diff --git a/src-tauri/src/commands/hermes.rs b/src-tauri/src/commands/hermes.rs index ae0d81e..bf382e3 100644 --- a/src-tauri/src/commands/hermes.rs +++ b/src-tauri/src/commands/hermes.rs @@ -2147,6 +2147,639 @@ fn merge_env_file(existing: &str, managed_keys: &[&str], new_pairs: &[(String, S content } +// --------------------------------------------------------------------------- +// Hermes 渠道配置 — 读写 ~/.hermes/config.yaml 的 platforms., +// 并同步 Hermes 运行时仍会读取的 .env 变量。 +// --------------------------------------------------------------------------- + +const HERMES_CHANNEL_PLATFORMS: [&str; 4] = ["telegram", "discord", "slack", "feishu"]; + +fn normalize_hermes_channel_platform(platform: &str) -> Option<&'static str> { + let platform = platform.trim().to_ascii_lowercase(); + HERMES_CHANNEL_PLATFORMS + .iter() + .copied() + .find(|item| *item == platform) +} + +fn yaml_key(key: &str) -> serde_yaml::Value { + serde_yaml::Value::String(key.to_string()) +} + +fn yaml_get<'a>(map: &'a serde_yaml::Mapping, key: &str) -> Option<&'a serde_yaml::Value> { + map.get(yaml_key(key)) +} + +fn yaml_get_mapping<'a>( + map: &'a serde_yaml::Mapping, + key: &str, +) -> Option<&'a serde_yaml::Mapping> { + yaml_get(map, key).and_then(|v| v.as_mapping()) +} + +fn yaml_string_field(map: &serde_yaml::Mapping, key: &str) -> Option { + yaml_get(map, key) + .and_then(|v| v.as_str()) + .map(|v| v.to_string()) +} + +fn yaml_bool_field(map: &serde_yaml::Mapping, key: &str) -> Option { + yaml_get(map, key).and_then(|v| v.as_bool()) +} + +fn yaml_csv_field(map: &serde_yaml::Mapping, key: &str) -> Option { + let value = yaml_get(map, key)?; + if let Some(items) = value.as_sequence() { + let joined = items + .iter() + .filter_map(|item| item.as_str().map(str::trim)) + .filter(|item| !item.is_empty()) + .collect::>() + .join(", "); + if joined.is_empty() { + None + } else { + Some(joined) + } + } else { + value + .as_str() + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) + } +} + +fn insert_json_string_if_present( + form: &mut serde_json::Map, + source: &serde_yaml::Mapping, + yaml_key: &str, + json_key: &str, +) { + if let Some(value) = yaml_string_field(source, yaml_key) { + form.insert(json_key.to_string(), Value::String(value)); + } +} + +fn insert_json_bool_if_present( + form: &mut serde_json::Map, + source: &serde_yaml::Mapping, + yaml_key: &str, + json_key: &str, +) { + if let Some(value) = yaml_bool_field(source, yaml_key) { + form.insert(json_key.to_string(), Value::Bool(value)); + } +} + +fn insert_json_csv_if_present( + form: &mut serde_json::Map, + source: &serde_yaml::Mapping, + yaml_key: &str, + json_key: &str, +) { + if let Some(value) = yaml_csv_field(source, yaml_key) { + form.insert(json_key.to_string(), Value::String(value)); + } +} + +fn build_hermes_channel_config_values(config: &serde_yaml::Value) -> Value { + let mut values = serde_json::Map::new(); + let root = config.as_mapping(); + let platforms = root.and_then(|map| yaml_get_mapping(map, "platforms")); + + for platform in HERMES_CHANNEL_PLATFORMS { + let entry = platforms + .and_then(|map| yaml_get_mapping(map, platform)) + .cloned() + .unwrap_or_default(); + let extra = yaml_get_mapping(&entry, "extra") + .cloned() + .unwrap_or_default(); + let mut form = serde_json::Map::new(); + form.insert( + "enabled".to_string(), + Value::Bool(yaml_bool_field(&entry, "enabled").unwrap_or(false)), + ); + + match platform { + "telegram" => { + form.insert( + "botToken".to_string(), + Value::String(yaml_string_field(&entry, "token").unwrap_or_default()), + ); + } + "discord" => { + form.insert( + "token".to_string(), + Value::String(yaml_string_field(&entry, "token").unwrap_or_default()), + ); + } + "slack" => { + form.insert( + "botToken".to_string(), + Value::String(yaml_string_field(&entry, "token").unwrap_or_default()), + ); + insert_json_string_if_present(&mut form, &extra, "app_token", "appToken"); + insert_json_string_if_present(&mut form, &extra, "signing_secret", "signingSecret"); + insert_json_string_if_present(&mut form, &extra, "webhook_path", "webhookPath"); + } + "feishu" => { + insert_json_string_if_present(&mut form, &extra, "app_id", "appId"); + insert_json_string_if_present(&mut form, &extra, "app_secret", "appSecret"); + insert_json_string_if_present(&mut form, &extra, "domain", "domain"); + insert_json_string_if_present( + &mut form, + &extra, + "connection_mode", + "connectionMode", + ); + insert_json_string_if_present(&mut form, &extra, "webhook_path", "webhookPath"); + insert_json_string_if_present( + &mut form, + &extra, + "reaction_notifications", + "reactionNotifications", + ); + insert_json_bool_if_present( + &mut form, + &extra, + "typing_indicator", + "typingIndicator", + ); + insert_json_bool_if_present( + &mut form, + &extra, + "resolve_sender_names", + "resolveSenderNames", + ); + } + _ => {} + } + + insert_json_string_if_present(&mut form, &extra, "dm_policy", "dmPolicy"); + insert_json_string_if_present(&mut form, &extra, "group_policy", "groupPolicy"); + insert_json_bool_if_present(&mut form, &extra, "require_mention", "requireMention"); + insert_json_csv_if_present(&mut form, &extra, "allow_from", "allowFrom"); + insert_json_csv_if_present(&mut form, &extra, "group_allow_from", "groupAllowFrom"); + values.insert(platform.to_string(), Value::Object(form)); + } + + Value::Object(values) +} + +fn ensure_yaml_object(value: &mut serde_yaml::Value) -> Result<&mut serde_yaml::Mapping, String> { + if value.is_null() { + *value = serde_yaml::Value::Mapping(serde_yaml::Mapping::new()); + } + value + .as_mapping_mut() + .ok_or_else(|| "config.yaml 顶层必须是对象".to_string()) +} + +fn yaml_child_object<'a>( + parent: &'a mut serde_yaml::Mapping, + key: &str, +) -> Result<&'a mut serde_yaml::Mapping, String> { + let key_value = yaml_key(key); + if !parent + .get(&key_value) + .map(|value| value.is_mapping()) + .unwrap_or(false) + { + parent.insert( + key_value.clone(), + serde_yaml::Value::Mapping(serde_yaml::Mapping::new()), + ); + } + parent + .get_mut(&key_value) + .and_then(|value| value.as_mapping_mut()) + .ok_or_else(|| format!("{key} 必须是对象")) +} + +fn set_yaml_string_if_present(entry: &mut serde_yaml::Mapping, key: &str, value: Option) { + if let Some(value) = value { + entry.insert( + yaml_key(key), + serde_yaml::Value::String(value.trim().to_string()), + ); + } +} + +fn set_extra_string_if_present(entry: &mut serde_yaml::Mapping, key: &str, value: Option) { + if let Some(value) = value + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) + { + if let Ok(extra) = yaml_child_object(entry, "extra") { + extra.insert(yaml_key(key), serde_yaml::Value::String(value)); + } + } +} + +fn set_extra_bool(entry: &mut serde_yaml::Mapping, key: &str, value: bool) { + if let Ok(extra) = yaml_child_object(entry, "extra") { + extra.insert(yaml_key(key), serde_yaml::Value::Bool(value)); + } +} + +fn set_extra_string_array(entry: &mut serde_yaml::Mapping, key: &str, values: Vec) { + if let Ok(extra) = yaml_child_object(entry, "extra") { + extra.insert( + yaml_key(key), + serde_yaml::Value::Sequence( + values + .into_iter() + .map(serde_yaml::Value::String) + .collect::>(), + ), + ); + } +} + +fn form_string(form: &Value, key: &str) -> Option { + form.get(key) + .and_then(|v| v.as_str()) + .map(|v| v.to_string()) +} + +fn form_string_or_default(form: &Value, key: &str, default_value: &str) -> String { + form_string(form, key) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| default_value.to_string()) +} + +fn form_bool(form: &Value, key: &str) -> Option { + form.get(key).and_then(|value| { + if let Some(b) = value.as_bool() { + Some(b) + } else { + value.as_str().map(|s| { + matches!( + s.trim().to_ascii_lowercase().as_str(), + "true" | "on" | "1" | "yes" + ) + }) + } + }) +} + +fn form_string_array(form: &Value, key: &str) -> Option> { + let value = form.get(key)?; + let items = if let Some(values) = value.as_array() { + values + .iter() + .filter_map(|item| item.as_str()) + .flat_map(split_csv_items) + .collect() + } else if let Some(value) = value.as_str() { + split_csv_items(value) + } else { + Vec::new() + }; + Some(items) +} + +fn split_csv_items(value: &str) -> Vec { + value + .split([',', ';', '\n']) + .map(str::trim) + .filter(|item| !item.is_empty()) + .map(ToString::to_string) + .collect() +} + +fn normalize_hermes_dm_policy(value: Option) -> String { + let value = value.unwrap_or_default().trim().to_ascii_lowercase(); + match value.as_str() { + "pairing" => "pair".to_string(), + "allow" => "open".to_string(), + "deny" => "disabled".to_string(), + "pair" | "open" | "allowlist" | "disabled" => value, + _ => "pair".to_string(), + } +} + +fn normalize_hermes_group_policy(value: Option) -> String { + let value = value.unwrap_or_default().trim().to_ascii_lowercase(); + match value.as_str() { + "all" | "mentioned" => "open".to_string(), + "deny" => "disabled".to_string(), + "open" | "allowlist" | "disabled" => value, + _ => "allowlist".to_string(), + } +} + +fn merge_hermes_channel_config( + config: &mut serde_yaml::Value, + platform: &str, + form: &Value, +) -> Result<(), String> { + let platform = normalize_hermes_channel_platform(platform) + .ok_or_else(|| format!("不支持的 Hermes 渠道: {platform}"))?; + let root = ensure_yaml_object(config)?; + let platforms = yaml_child_object(root, "platforms")?; + let entry = yaml_child_object(platforms, platform)?; + + entry.insert( + yaml_key("enabled"), + serde_yaml::Value::Bool(form_bool(form, "enabled").unwrap_or(false)), + ); + + match platform { + "telegram" => set_yaml_string_if_present(entry, "token", form_string(form, "botToken")), + "discord" => set_yaml_string_if_present(entry, "token", form_string(form, "token")), + "slack" => { + set_yaml_string_if_present(entry, "token", form_string(form, "botToken")); + set_extra_string_if_present(entry, "app_token", form_string(form, "appToken")); + set_extra_string_if_present( + entry, + "signing_secret", + form_string(form, "signingSecret"), + ); + set_extra_string_if_present( + entry, + "webhook_path", + Some(form_string_or_default(form, "webhookPath", "/slack/events")), + ); + } + "feishu" => { + set_extra_string_if_present(entry, "app_id", form_string(form, "appId")); + set_extra_string_if_present(entry, "app_secret", form_string(form, "appSecret")); + set_extra_string_if_present( + entry, + "domain", + Some(form_string_or_default(form, "domain", "feishu")), + ); + set_extra_string_if_present( + entry, + "connection_mode", + Some(form_string_or_default(form, "connectionMode", "websocket")), + ); + set_extra_string_if_present( + entry, + "webhook_path", + Some(form_string_or_default( + form, + "webhookPath", + "/feishu/webhook", + )), + ); + set_extra_string_if_present( + entry, + "reaction_notifications", + Some(form_string_or_default(form, "reactionNotifications", "off")), + ); + set_extra_bool( + entry, + "typing_indicator", + form_bool(form, "typingIndicator").unwrap_or(true), + ); + set_extra_bool( + entry, + "resolve_sender_names", + form_bool(form, "resolveSenderNames").unwrap_or(true), + ); + } + _ => {} + } + + if form.get("dmPolicy").is_some() { + set_extra_string_if_present( + entry, + "dm_policy", + Some(normalize_hermes_dm_policy(form_string(form, "dmPolicy"))), + ); + } + if form.get("groupPolicy").is_some() { + let group_policy = normalize_hermes_group_policy(form_string(form, "groupPolicy")); + set_extra_string_if_present(entry, "group_policy", Some(group_policy.clone())); + if platform == "feishu" { + set_extra_string_if_present(entry, "default_group_policy", Some(group_policy)); + } + } + if let Some(value) = form_bool(form, "requireMention") { + set_extra_bool(entry, "require_mention", value); + } + if let Some(values) = form_string_array(form, "allowFrom") { + set_extra_string_array(entry, "allow_from", values); + } + if let Some(values) = form_string_array(form, "groupAllowFrom") { + set_extra_string_array(entry, "group_allow_from", values); + } + + Ok(()) +} + +fn read_hermes_channel_yaml_config() -> Result<(PathBuf, bool, serde_yaml::Value), String> { + let config_path = hermes_home().join("config.yaml"); + if !config_path.exists() { + return Ok(( + config_path, + false, + serde_yaml::Value::Mapping(serde_yaml::Mapping::new()), + )); + } + let raw = + std::fs::read_to_string(&config_path).map_err(|e| format!("读取 config.yaml 失败: {e}"))?; + let config = if raw.trim().is_empty() { + serde_yaml::Value::Mapping(serde_yaml::Mapping::new()) + } else { + serde_yaml::from_str(&raw).map_err(|e| format!("解析 config.yaml 失败: {e}"))? + }; + Ok((config_path, true, config)) +} + +fn write_hermes_yaml_config(path: &PathBuf, config: &serde_yaml::Value) -> Result<(), String> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| format!("创建 Hermes 配置目录失败: {e}"))?; + } + if path.exists() { + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let backup = path.with_extension(format!("yaml.bak-{ts}")); + let _ = std::fs::copy(path, backup); + } + let yaml = + serde_yaml::to_string(config).map_err(|e| format!("序列化 config.yaml 失败: {e}"))?; + std::fs::write(path, yaml).map_err(|e| format!("写入 config.yaml 失败: {e}")) +} + +fn csv_env_value(form: &Value, key: &str) -> String { + form_string_array(form, key).unwrap_or_default().join(",") +} + +fn bool_env_value(value: bool) -> String { + if value { "true" } else { "false" }.to_string() +} + +fn build_hermes_channel_env_updates(platform: &str, form: &Value) -> Vec<(String, String)> { + let mut pairs = Vec::new(); + let mut push = |key: &str, value: String| { + let value = value.trim().to_string(); + if !value.is_empty() { + pairs.push((key.to_string(), value)); + } + }; + + match platform { + "telegram" => { + push( + "TELEGRAM_BOT_TOKEN", + form_string(form, "botToken").unwrap_or_default(), + ); + push("TELEGRAM_ALLOWED_USERS", csv_env_value(form, "allowFrom")); + push( + "TELEGRAM_GROUP_ALLOWED_USERS", + csv_env_value(form, "groupAllowFrom"), + ); + if let Some(value) = form_bool(form, "requireMention") { + push("TELEGRAM_REQUIRE_MENTION", bool_env_value(value)); + } + } + "discord" => { + push( + "DISCORD_BOT_TOKEN", + form_string(form, "token").unwrap_or_default(), + ); + push("DISCORD_ALLOWED_USERS", csv_env_value(form, "allowFrom")); + if let Some(value) = form_bool(form, "requireMention") { + push("DISCORD_REQUIRE_MENTION", bool_env_value(value)); + } + } + "slack" => { + push( + "SLACK_BOT_TOKEN", + form_string(form, "botToken").unwrap_or_default(), + ); + push( + "SLACK_APP_TOKEN", + form_string(form, "appToken").unwrap_or_default(), + ); + push("SLACK_ALLOWED_USERS", csv_env_value(form, "allowFrom")); + if let Some(value) = form_bool(form, "requireMention") { + push("SLACK_REQUIRE_MENTION", bool_env_value(value)); + } + } + "feishu" => { + push( + "FEISHU_APP_ID", + form_string(form, "appId").unwrap_or_default(), + ); + push( + "FEISHU_APP_SECRET", + form_string(form, "appSecret").unwrap_or_default(), + ); + push( + "FEISHU_DOMAIN", + form_string_or_default(form, "domain", "feishu"), + ); + push( + "FEISHU_CONNECTION_MODE", + form_string_or_default(form, "connectionMode", "websocket"), + ); + push( + "FEISHU_WEBHOOK_PATH", + form_string_or_default(form, "webhookPath", "/feishu/webhook"), + ); + push("FEISHU_ALLOWED_USERS", csv_env_value(form, "allowFrom")); + push( + "FEISHU_GROUP_POLICY", + normalize_hermes_group_policy(form_string(form, "groupPolicy")), + ); + push( + "FEISHU_REQUIRE_MENTION", + bool_env_value(form_bool(form, "requireMention").unwrap_or(true)), + ); + let reactions = form_string(form, "reactionNotifications").unwrap_or_default(); + push( + "FEISHU_REACTIONS", + if reactions.trim() == "off" { + "false" + } else { + "true" + } + .to_string(), + ); + } + _ => {} + } + + pairs +} + +fn write_hermes_channel_env(platform: &str, form: &Value) -> Result<(), String> { + let env_path = hermes_home().join(".env"); + if let Some(parent) = env_path.parent() { + std::fs::create_dir_all(parent).map_err(|e| format!("创建 Hermes 配置目录失败: {e}"))?; + } + let raw = std::fs::read_to_string(&env_path).unwrap_or_default(); + let managed_keys: Vec<&str> = match platform { + "telegram" => vec![ + "TELEGRAM_BOT_TOKEN", + "TELEGRAM_ALLOWED_USERS", + "TELEGRAM_GROUP_ALLOWED_USERS", + "TELEGRAM_REQUIRE_MENTION", + ], + "discord" => vec![ + "DISCORD_BOT_TOKEN", + "DISCORD_ALLOWED_USERS", + "DISCORD_REQUIRE_MENTION", + ], + "slack" => vec![ + "SLACK_BOT_TOKEN", + "SLACK_APP_TOKEN", + "SLACK_ALLOWED_USERS", + "SLACK_REQUIRE_MENTION", + ], + "feishu" => vec![ + "FEISHU_APP_ID", + "FEISHU_APP_SECRET", + "FEISHU_DOMAIN", + "FEISHU_CONNECTION_MODE", + "FEISHU_WEBHOOK_PATH", + "FEISHU_ALLOWED_USERS", + "FEISHU_GROUP_POLICY", + "FEISHU_REQUIRE_MENTION", + "FEISHU_REACTIONS", + ], + _ => Vec::new(), + }; + let pairs = build_hermes_channel_env_updates(platform, form); + let content = merge_env_file(&raw, &managed_keys, &pairs); + std::fs::write(&env_path, content).map_err(|e| format!("写入 .env 失败: {e}")) +} + +#[tauri::command] +pub fn hermes_channel_config_read() -> Result { + let (config_path, exists, config) = read_hermes_channel_yaml_config()?; + ensure_yaml_object(&mut config.clone())?; + Ok(serde_json::json!({ + "exists": exists, + "configPath": config_path.to_string_lossy(), + "values": build_hermes_channel_config_values(&config), + })) +} + +#[tauri::command] +pub fn hermes_channel_config_save(platform: String, form: Value) -> Result { + let platform = normalize_hermes_channel_platform(&platform) + .ok_or_else(|| format!("不支持的 Hermes 渠道: {}", platform.trim()))?; + let (config_path, _exists, mut config) = read_hermes_channel_yaml_config()?; + merge_hermes_channel_config(&mut config, platform, &form)?; + write_hermes_yaml_config(&config_path, &config)?; + write_hermes_channel_env(platform, &form)?; + let values = build_hermes_channel_config_values(&config); + Ok(serde_json::json!({ + "ok": true, + "configPath": config_path.to_string_lossy(), + "values": values.get(platform).cloned().unwrap_or(Value::Null), + })) +} + // --------------------------------------------------------------------------- // hermes_read_config — 读取 Hermes config.yaml + .env // --------------------------------------------------------------------------- @@ -7003,3 +7636,113 @@ platforms: ); } } + +#[cfg(test)] +mod hermes_channel_tests { + use super::{ + build_hermes_channel_config_values, build_hermes_channel_env_updates, + merge_hermes_channel_config, + }; + use serde_json::json; + + #[test] + fn merge_telegram_channel_keeps_unknown_extra_fields() { + let mut config: serde_yaml::Value = serde_yaml::from_str( + r#" +model: + provider: anthropic + default: claude-sonnet-4-6 +platforms: + telegram: + enabled: false + token: old + extra: + unknown_option: keep-me +"#, + ) + .unwrap(); + + merge_hermes_channel_config( + &mut config, + "telegram", + &json!({ + "enabled": true, + "botToken": "123:token", + "dmPolicy": "pair", + "groupPolicy": "allowlist", + "allowFrom": "1001, 1002", + "requireMention": true, + }), + ) + .unwrap(); + + let values = build_hermes_channel_config_values(&config); + assert_eq!(values["telegram"]["enabled"], true); + assert_eq!(values["telegram"]["botToken"], "123:token"); + assert_eq!(values["telegram"]["allowFrom"], "1001, 1002"); + assert_eq!( + config["platforms"]["telegram"]["extra"]["unknown_option"].as_str(), + Some("keep-me") + ); + } + + #[test] + fn merge_feishu_channel_fills_runtime_defaults() { + let mut config = serde_yaml::Value::Mapping(serde_yaml::Mapping::new()); + + merge_hermes_channel_config( + &mut config, + "feishu", + &json!({ + "enabled": true, + "appId": "cli_xxx", + "appSecret": "secret", + "domain": "", + "connectionMode": "", + "webhookPath": "", + "reactionNotifications": "", + "typingIndicator": true, + "resolveSenderNames": true, + }), + ) + .unwrap(); + + assert_eq!( + config["platforms"]["feishu"]["extra"]["domain"].as_str(), + Some("feishu") + ); + assert_eq!( + config["platforms"]["feishu"]["extra"]["connection_mode"].as_str(), + Some("websocket") + ); + assert_eq!( + config["platforms"]["feishu"]["extra"]["webhook_path"].as_str(), + Some("/feishu/webhook") + ); + assert_eq!( + config["platforms"]["feishu"]["extra"]["reaction_notifications"].as_str(), + Some("off") + ); + + let env = build_hermes_channel_env_updates( + "feishu", + &json!({ + "appId": "cli_xxx", + "appSecret": "secret", + "domain": "", + "connectionMode": "", + "webhookPath": "", + "groupPolicy": "allowlist", + }), + ); + assert!(env.contains(&("FEISHU_DOMAIN".to_string(), "feishu".to_string()))); + assert!(env.contains(&( + "FEISHU_CONNECTION_MODE".to_string(), + "websocket".to_string() + ))); + assert!(env.contains(&( + "FEISHU_WEBHOOK_PATH".to_string(), + "/feishu/webhook".to_string() + ))); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a298ad1..0a9f64e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -255,6 +255,8 @@ pub fn run() { hermes::hermes_fs_write, hermes::hermes_read_config, hermes::hermes_read_config_full, + hermes::hermes_channel_config_read, + hermes::hermes_channel_config_save, hermes::hermes_lazy_deps_features, hermes::hermes_lazy_deps_status, hermes::hermes_lazy_deps_ensure, diff --git a/src/engines/hermes/index.js b/src/engines/hermes/index.js index 3d17c6e..7e8e25b 100644 --- a/src/engines/hermes/index.js +++ b/src/engines/hermes/index.js @@ -89,6 +89,7 @@ export default { { route: '/h/cron', label: t('sidebar.cron'), icon: 'clock' }, { route: '/h/profiles', label: t('engine.hermesProfilesTitle'), icon: 'agents' }, { route: '/h/gateways', label: t('engine.hermesGatewaysTitle'), icon: 'gateway' }, + { route: '/h/channels', label: t('engine.hermesChannelsTitle'), icon: 'channels' }, { route: '/h/kanban', label: t('engine.hermesKanbanTitle'), icon: 'inbox' }, { route: '/h/oauth', label: t('engine.hermesOAuthTitle'), icon: 'memory' }, { route: '/h/files', label: t('engine.hermesFilesTitle'), icon: 'folder' }, @@ -128,8 +129,6 @@ export default { { path: '/h/lazy-deps', loader: () => import('./pages/lazy-deps.js') }, { path: '/h/services', loader: () => import('./pages/services.js') }, { path: '/h/config', loader: () => import('./pages/config.js') }, - // Batch 1 §A: /h/channels 当前是 placeholder(487 字节 stub)— 暂不挂 nav - // 完整实现见 Batch 3,待 Hermes 渠道完整支持时启用 sidebar 入口 { path: '/h/channels', loader: () => import('./pages/channels.js') }, { path: '/h/env', loader: () => import('./pages/env-editor.js') }, // 共用页面(引擎无关) diff --git a/src/engines/hermes/pages/channels.js b/src/engines/hermes/pages/channels.js index 9921594..cb7bce0 100644 --- a/src/engines/hermes/pages/channels.js +++ b/src/engines/hermes/pages/channels.js @@ -2,16 +2,339 @@ * Hermes Agent 渠道配置 */ import { t } from '../../../lib/i18n.js' +import { api } from '../../../lib/tauri-api.js' +import { toast } from '../../../components/toast.js' +import { humanizeErrorText } from '../../../lib/humanize-error.js' +import { icon } from '../../../lib/icons.js' + +const CHANNELS = [ + { + id: 'telegram', + icon: 'message-circle', + titleKey: 'engine.hermesChannelTelegram', + descKey: 'engine.hermesChannelTelegramDesc', + secretFields: ['botToken'], + fields: [ + { key: 'botToken', labelKey: 'engine.hermesChannelBotToken', type: 'password', placeholder: '123456:ABC-DEF...' }, + ], + }, + { + id: 'discord', + icon: 'message-square', + titleKey: 'engine.hermesChannelDiscord', + descKey: 'engine.hermesChannelDiscordDesc', + secretFields: ['token'], + fields: [ + { key: 'token', labelKey: 'engine.hermesChannelBotToken', type: 'password', placeholder: 'MTA...' }, + ], + }, + { + id: 'slack', + icon: 'hash', + titleKey: 'engine.hermesChannelSlack', + descKey: 'engine.hermesChannelSlackDesc', + secretFields: ['botToken', 'appToken', 'signingSecret'], + fields: [ + { key: 'botToken', labelKey: 'engine.hermesChannelSlackBotToken', type: 'password', placeholder: 'xoxb-...' }, + { key: 'appToken', labelKey: 'engine.hermesChannelSlackAppToken', type: 'password', placeholder: 'xapp-...' }, + { key: 'signingSecret', labelKey: 'engine.hermesChannelSigningSecret', type: 'password', placeholder: 'optional' }, + { key: 'webhookPath', labelKey: 'engine.hermesChannelWebhookPath', type: 'text', placeholder: '/slack/events' }, + ], + }, + { + id: 'feishu', + icon: 'send', + titleKey: 'engine.hermesChannelFeishu', + descKey: 'engine.hermesChannelFeishuDesc', + secretFields: ['appSecret'], + fields: [ + { key: 'appId', labelKey: 'engine.hermesChannelFeishuAppId', type: 'text', placeholder: 'cli_xxx' }, + { key: 'appSecret', labelKey: 'engine.hermesChannelFeishuAppSecret', type: 'password', placeholder: 'app secret' }, + { key: 'domain', labelKey: 'engine.hermesChannelFeishuDomain', type: 'select', options: [['feishu', 'engine.hermesChannelFeishuDomainCn'], ['lark', 'engine.hermesChannelFeishuDomainIntl']] }, + { key: 'connectionMode', labelKey: 'engine.hermesChannelConnectionMode', type: 'select', options: [['websocket', 'WebSocket'], ['webhook', 'Webhook']] }, + { key: 'webhookPath', labelKey: 'engine.hermesChannelWebhookPath', type: 'text', placeholder: '/feishu/webhook' }, + { key: 'reactionNotifications', labelKey: 'engine.hermesChannelReactions', type: 'select', options: [['off', 'engine.hermesChannelReactionsOff'], ['basic', 'engine.hermesChannelReactionsBasic']] }, + ], + toggles: [ + { key: 'typingIndicator', labelKey: 'engine.hermesChannelTypingIndicator' }, + { key: 'resolveSenderNames', labelKey: 'engine.hermesChannelResolveSenderNames' }, + ], + }, +] + +const COMMON_FIELDS = [ + { key: 'dmPolicy', labelKey: 'engine.hermesChannelDmPolicy', type: 'select', options: [['pair', 'engine.hermesChannelPolicyPair'], ['open', 'engine.hermesChannelPolicyOpen'], ['allowlist', 'engine.hermesChannelPolicyAllowlist'], ['disabled', 'engine.hermesChannelPolicyDisabled']] }, + { key: 'groupPolicy', labelKey: 'engine.hermesChannelGroupPolicy', type: 'select', options: [['allowlist', 'engine.hermesChannelPolicyAllowlist'], ['open', 'engine.hermesChannelPolicyOpen'], ['disabled', 'engine.hermesChannelPolicyDisabled']] }, + { key: 'allowFrom', labelKey: 'engine.hermesChannelAllowFrom', type: 'textarea', placeholderKey: 'engine.hermesChannelAllowFromPlaceholder' }, + { key: 'groupAllowFrom', labelKey: 'engine.hermesChannelGroupAllowFrom', type: 'textarea', placeholderKey: 'engine.hermesChannelGroupAllowFromPlaceholder' }, +] + +function esc(value) { + return String(value ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') +} + +function channelMeta(id) { + return CHANNELS.find(channel => channel.id === id) || CHANNELS[0] +} + +function defaultForm(platform) { + const form = { + enabled: false, + dmPolicy: 'pair', + groupPolicy: 'allowlist', + allowFrom: '', + groupAllowFrom: '', + requireMention: true, + } + if (platform === 'feishu') { + form.domain = 'feishu' + form.connectionMode = 'websocket' + form.webhookPath = '/feishu/webhook' + form.reactionNotifications = 'off' + form.typingIndicator = true + form.resolveSenderNames = true + } + if (platform === 'slack') form.webhookPath = '/slack/events' + return form +} + +function normalizeForm(platform, form = {}) { + return { ...defaultForm(platform), ...(form || {}) } +} + +function valueOf(form, key) { + const value = form?.[key] + return value == null ? '' : String(value) +} + +function isConfigured(channel, form) { + return channel.secretFields.some(key => valueOf(form, key).trim()) +} + +function renderField(field, form, disabled) { + const value = valueOf(form, field.key) + const label = esc(t(field.labelKey)) + if (field.type === 'select') { + return ` + + ` + } + if (field.type === 'textarea') { + return ` + + ` + } + return ` + + ` +} + +function collectForm(el, platform) { + const form = normalizeForm(platform, {}) + el.querySelectorAll('.hm-channel-input').forEach(input => { + const key = input.dataset.key + if (!key) return + if (input.type === 'checkbox') form[key] = input.checked + else form[key] = input.value + }) + return form +} export function render() { const el = document.createElement('div') - el.className = 'page' + el.className = 'page hm-channels-page' el.dataset.engine = 'hermes' - el.innerHTML = ` - -
- ${t('engine.comingSoonPhase2')} -
- ` + + let active = 'telegram' + let values = {} + let configPath = '' + let loading = true + let saving = false + let error = '' + let success = '' + + function draw() { + const channel = channelMeta(active) + const form = normalizeForm(active, values[active]) + const disabled = loading || saving + const enabledCount = CHANNELS.filter(item => normalizeForm(item.id, values[item.id]).enabled).length + const configuredCount = CHANNELS.filter(item => isConfigured(item, normalizeForm(item.id, values[item.id]))).length + + el.innerHTML = ` +
+
+
${esc(t('engine.hermesChannelsEyebrow'))}
+

${esc(t('engine.hermesChannelsTitle'))}

+
${esc(configPath || '~/.hermes/config.yaml')}
+
+
+ + +
+
+ +
+
${esc(t('engine.hermesChannelEnabledCount'))}${enabledCount}
+
${esc(t('engine.hermesChannelConfiguredCount'))}${configuredCount}
+
${esc(t('engine.hermesChannelRuntimeWrite'))}YAML + .env
+
+ + ${(error || success) ? ` +
+ ${icon(error ? 'alert-triangle' : 'check-circle', 15)} + ${esc(error || success)} +
+ ` : ''} + +
+
+
+
${esc(t('engine.hermesChannelPlatforms'))}
+
+
+
+ ${CHANNELS.map(item => { + const itemForm = normalizeForm(item.id, values[item.id]) + return ` + + ` + }).join('')} +
+
+
+ +
+
+
+
${icon(channel.icon, 15)}${esc(t(channel.titleKey))}
+
${esc(t(channel.descKey))}
+
+ +
+
+ ${loading ? ` +
${esc(t('common.loading'))}...
+ ` : ` +
+
${esc(t('engine.hermesChannelCredentials'))}
+
+ ${channel.fields.map(field => renderField(field, form, disabled)).join('')} +
+ ${(channel.toggles || []).length ? ` +
+ ${channel.toggles.map(toggle => ` + + `).join('')} +
+ ` : ''} +
+ +
+
${esc(t('engine.hermesChannelAccessPolicy'))}
+
+ ${COMMON_FIELDS.slice(0, 2).map(field => renderField(field, form, disabled)).join('')} +
+ +
+ ${COMMON_FIELDS.slice(2).map(field => renderField(field, form, disabled)).join('')} +
+
+ +
+ ${icon('info', 14)} + ${esc(t('engine.hermesChannelRestartHint'))} +
+ `} +
+
+
+ ` + + el.querySelector('#hm-channels-reload')?.addEventListener('click', load) + el.querySelector('#hm-channels-save')?.addEventListener('click', save) + el.querySelectorAll('.hm-channel-tab').forEach(button => { + button.addEventListener('click', () => { + if (!loading && !saving) values = { ...values, [active]: collectForm(el, active) } + active = button.dataset.channel || active + error = '' + success = '' + draw() + }) + }) + } + + async function load() { + loading = true + error = '' + success = '' + draw() + try { + const data = await api.hermesChannelConfigRead() + values = data?.values || {} + configPath = data?.configPath || '' + } catch (err) { + error = humanizeErrorText(err, t('engine.hermesChannelLoadFailed')) + } finally { + loading = false + draw() + } + } + + async function save() { + const form = collectForm(el, active) + values = { ...values, [active]: form } + saving = true + error = '' + success = '' + draw() + try { + const result = await api.hermesChannelConfigSave(active, form) + values = { ...values, [active]: result?.values || form } + success = t('engine.hermesChannelSaved') + toast(success, 'success') + } catch (err) { + error = humanizeErrorText(err, t('engine.hermesChannelSaveFailed')) + toast(error, 'error') + } finally { + saving = false + draw() + } + } + + draw() + load() return el } diff --git a/src/engines/hermes/style/hermes.css b/src/engines/hermes/style/hermes.css index 5f1107d..95e7b0c 100644 --- a/src/engines/hermes/style/hermes.css +++ b/src/engines/hermes/style/hermes.css @@ -6593,6 +6593,210 @@ body[data-active-engine="hermes"][data-theme="dark"] { appearance: none; } +[data-engine="hermes"].hm-channels-page { + max-width: 1280px; +} +[data-engine="hermes"] .hm-channel-summary { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; + margin: -12px 0 18px; +} +[data-engine="hermes"] .hm-channel-stat { + min-height: 72px; + border: 1px solid var(--hm-border); + border-radius: var(--hm-radius-md); + background: var(--hm-surface-1); + padding: 14px 16px; + display: flex; + flex-direction: column; + justify-content: space-between; + gap: 8px; +} +[data-engine="hermes"] .hm-channel-stat span { + font-family: var(--hm-font-serif); + font-size: 12px; + font-style: italic; + color: var(--hm-text-tertiary); +} +[data-engine="hermes"] .hm-channel-stat strong { + font-family: var(--hm-font-mono); + font-size: 18px; + font-weight: 600; + color: var(--hm-text-primary); +} +[data-engine="hermes"] .hm-channel-alert { + display: flex; + align-items: flex-start; + gap: 10px; + margin: 0 0 18px; + padding: 12px 14px; + border-radius: var(--hm-radius-sm); + border: 1px solid var(--hm-border); + font-size: 13px; + line-height: 1.55; +} +[data-engine="hermes"] .hm-channel-alert.is-error { + color: var(--hm-error); + background: var(--hm-error-soft); + border-color: color-mix(in srgb, var(--hm-error) 24%, transparent); +} +[data-engine="hermes"] .hm-channel-alert.is-success { + color: var(--hm-success); + background: var(--hm-success-soft); + border-color: color-mix(in srgb, var(--hm-success) 24%, transparent); +} +[data-engine="hermes"] .hm-channel-layout { + display: grid; + grid-template-columns: minmax(220px, 280px) minmax(0, 1fr); + gap: 18px; + align-items: start; +} +[data-engine="hermes"] .hm-channel-list-panel { + position: sticky; + top: 18px; +} +[data-engine="hermes"] .hm-channel-list { + display: grid; + gap: 8px; +} +[data-engine="hermes"] .hm-channel-tab { + width: 100%; + min-height: 58px; + border: 1px solid var(--hm-border); + border-radius: var(--hm-radius-sm); + background: var(--hm-surface-0); + color: var(--hm-text-primary); + display: grid; + grid-template-columns: 34px minmax(0, 1fr) 10px; + align-items: center; + gap: 10px; + padding: 10px 12px; + text-align: left; + cursor: pointer; + transition: border-color var(--hm-dur-fast) var(--hm-ease), + background var(--hm-dur-fast) var(--hm-ease); +} +[data-engine="hermes"] .hm-channel-tab:hover:not(:disabled) { + border-color: var(--hm-border-strong); + background: var(--hm-surface-2); +} +[data-engine="hermes"] .hm-channel-tab.is-active { + border-color: var(--hm-accent); + background: var(--hm-accent-soft); +} +[data-engine="hermes"] .hm-channel-tab:disabled { + cursor: not-allowed; + opacity: 0.58; +} +[data-engine="hermes"] .hm-channel-tab:focus-visible { + outline: none; + box-shadow: 0 0 0 3px var(--hm-accent-ring); +} +[data-engine="hermes"] .hm-channel-tab-icon { + width: 34px; + height: 34px; + border: 1px solid var(--hm-border); + border-radius: var(--hm-radius-sm); + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--hm-accent); + background: var(--hm-surface-1); +} +[data-engine="hermes"] .hm-channel-tab-main { + display: grid; + gap: 2px; + min-width: 0; +} +[data-engine="hermes"] .hm-channel-tab-main strong { + font-size: 13px; + line-height: 1.25; + overflow-wrap: anywhere; +} +[data-engine="hermes"] .hm-channel-tab-main small { + color: var(--hm-text-tertiary); + font-size: 11px; + line-height: 1.25; +} +[data-engine="hermes"] .hm-channel-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--hm-text-muted); + box-shadow: 0 0 0 3px var(--hm-border-subtle); +} +[data-engine="hermes"] .hm-channel-dot.is-on { + background: var(--hm-success); + box-shadow: 0 0 0 3px var(--hm-success-soft); +} +[data-engine="hermes"] .hm-channel-form-panel .hm-panel-header { + align-items: flex-start; +} +[data-engine="hermes"] .hm-channel-panel-desc { + margin-top: 6px; + color: var(--hm-text-tertiary); + font-size: 13px; + line-height: 1.6; + max-width: 720px; +} +[data-engine="hermes"] .hm-channel-switch, +[data-engine="hermes"] .hm-channel-check { + min-height: 44px; + display: inline-flex; + align-items: center; + gap: 10px; + color: var(--hm-text-secondary); + font-size: 13px; + line-height: 1.45; + cursor: pointer; +} +[data-engine="hermes"] .hm-channel-switch input, +[data-engine="hermes"] .hm-channel-check input { + width: 18px; + height: 18px; + accent-color: var(--hm-accent); + flex: 0 0 auto; +} +[data-engine="hermes"] .hm-channel-section { + display: grid; + gap: 16px; +} +[data-engine="hermes"] .hm-channel-section + .hm-channel-section { + margin-top: 26px; + padding-top: 24px; + border-top: 1px solid var(--hm-border-subtle); +} +[data-engine="hermes"] .hm-channel-section-title { + font-family: var(--hm-font-serif); + font-size: 16px; + font-weight: 500; + color: var(--hm-text-primary); +} +[data-engine="hermes"] .hm-channel-textarea { + min-height: 104px; + height: auto; + resize: vertical; +} +[data-engine="hermes"] .hm-channel-toggle-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px 16px; +} +[data-engine="hermes"] .hm-channel-check--wide { + width: fit-content; +} +[data-engine="hermes"] .hm-channel-footnote, +[data-engine="hermes"] .hm-channel-loading { + margin-top: 20px; + display: flex; + align-items: flex-start; + gap: 9px; + color: var(--hm-text-tertiary); + font-size: 12.5px; + line-height: 1.6; +} + @media (max-width: 960px) { [data-engine="hermes"].hm-usage-page { padding: 24px 22px 34px; @@ -6609,6 +6813,15 @@ body[data-active-engine="hermes"][data-theme="dark"] { [data-engine="hermes"] .hm-services-action-grid { grid-template-columns: 1fr; } + [data-engine="hermes"] .hm-channel-layout { + grid-template-columns: 1fr; + } + [data-engine="hermes"] .hm-channel-list-panel { + position: static; + } + [data-engine="hermes"] .hm-channel-list { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } } @media (max-width: 720px) { @@ -6642,6 +6855,31 @@ body[data-active-engine="hermes"][data-theme="dark"] { [data-engine="hermes"] .hm-services-health-grid { grid-template-columns: 1fr; } + [data-engine="hermes"].hm-channels-page { + padding: 24px 18px 34px; + } + [data-engine="hermes"].hm-channels-page .hm-hero { + align-items: stretch; + } + [data-engine="hermes"].hm-channels-page .hm-hero-actions { + width: 100%; + flex-wrap: wrap; + } + [data-engine="hermes"].hm-channels-page .hm-hero-actions .hm-btn { + flex: 1 1 150px; + min-height: 44px; + } + [data-engine="hermes"] .hm-channel-summary, + [data-engine="hermes"] .hm-channel-list, + [data-engine="hermes"] .hm-channel-toggle-grid { + grid-template-columns: 1fr; + } + [data-engine="hermes"] .hm-channel-form-panel .hm-panel-header { + flex-direction: column; + } + [data-engine="hermes"] .hm-channel-switch { + width: 100%; + } } /* ---- Responsive breakpoints ---- */ @@ -6720,4 +6958,3 @@ body[data-active-engine="hermes"][data-theme="dark"] { grid-template-columns: 1fr !important; } } - diff --git a/src/lib/tauri-api.js b/src/lib/tauri-api.js index d48d10a..8e4caf4 100644 --- a/src/lib/tauri-api.js +++ b/src/lib/tauri-api.js @@ -507,6 +507,8 @@ export const api = { hermesFsWrite: (path, content) => invoke('hermes_fs_write', { path, content }), hermesReadConfig: () => invoke('hermes_read_config'), hermesReadConfigFull: () => invoke('hermes_read_config_full'), + hermesChannelConfigRead: () => invoke('hermes_channel_config_read'), + hermesChannelConfigSave: (platform, form) => invoke('hermes_channel_config_save', { platform, form }), hermesLazyDepsFeatures: () => cachedInvoke('hermes_lazy_deps_features', {}, 600000), hermesLazyDepsStatus: (features) => invoke('hermes_lazy_deps_status', { features }), hermesLazyDepsEnsure: (feature) => invoke('hermes_lazy_deps_ensure', { feature }), diff --git a/src/locales/modules/engine.js b/src/locales/modules/engine.js index 40d4e50..061db9c 100644 --- a/src/locales/modules/engine.js +++ b/src/locales/modules/engine.js @@ -911,6 +911,57 @@ export default { servicesMaintenanceGatewayRestartWarn: _('Gateway 重启失败,请稍后手动启动:{error}', 'Gateway restart failed; start it manually later: {error}', 'Gateway 重新啟動失敗,請稍後手動啟動:{error}'), hermesConfigTitle: _('Hermes 配置', 'Hermes Config', 'Hermes 配置'), hermesChannelsTitle: _('Hermes 渠道', 'Hermes Channels', 'Hermes 頻道'), + hermesChannelsEyebrow: _('HERMES AGENT · 消息渠道', 'HERMES AGENT · MESSAGING', 'HERMES AGENT · 訊息頻道'), + hermesChannelSummary: _('渠道配置摘要', 'Channel configuration summary', '頻道設定摘要'), + hermesChannelEnabledCount: _('已启用', 'Enabled', '已啟用'), + hermesChannelConfiguredCount: _('已填写凭证', 'Credentials set', '已填寫憑證'), + hermesChannelRuntimeWrite: _('写入位置', 'Writes to', '寫入位置'), + hermesChannelPlatforms: _('渠道', 'Platforms', '頻道'), + hermesChannelTelegram: _('Telegram', 'Telegram', 'Telegram'), + hermesChannelDiscord: _('Discord', 'Discord', 'Discord'), + hermesChannelSlack: _('Slack', 'Slack', 'Slack'), + hermesChannelFeishu: _('飞书 / Lark', 'Feishu / Lark', '飛書 / Lark'), + hermesChannelTelegramDesc: _('通过 Telegram Bot 与 Hermes 对话,适合个人私聊和小群组。', 'Talk to Hermes through a Telegram bot for direct chats and small groups.', '透過 Telegram Bot 與 Hermes 對話,適合個人私聊和小群組。'), + hermesChannelDiscordDesc: _('连接 Discord Bot,支持服务器频道和线程里的 Agent 会话。', 'Connect a Discord bot for server channels and threaded agent sessions.', '連接 Discord Bot,支援伺服器頻道和討論串裡的 Agent 會話。'), + hermesChannelSlackDesc: _('连接 Slack Bot,可用于团队频道、私信和工作流通知。', 'Connect a Slack bot for team channels, direct messages, and workflow notifications.', '連接 Slack Bot,可用於團隊頻道、私訊和工作流通知。'), + hermesChannelFeishuDesc: _('连接飞书或 Lark 应用,支持长连接和 Webhook 两种模式。', 'Connect a Feishu or Lark app with WebSocket or webhook mode.', '連接飛書或 Lark 應用,支援長連線和 Webhook 兩種模式。'), + hermesChannelEnabled: _('已启用', 'Enabled', '已啟用'), + hermesChannelDisabled: _('未启用', 'Disabled', '未啟用'), + hermesChannelSave: _('保存渠道', 'Save Channel', '儲存頻道'), + hermesChannelSaving: _('保存中...', 'Saving...', '儲存中...'), + hermesChannelSaved: _('渠道配置已保存,重启 Hermes Gateway 后生效。', 'Channel configuration saved. Restart Hermes Gateway to apply it.', '頻道設定已儲存,重啟 Hermes Gateway 後生效。'), + hermesChannelLoadFailed: _('加载渠道配置失败', 'Failed to load channel configuration', '載入頻道設定失敗'), + hermesChannelSaveFailed: _('保存渠道配置失败', 'Failed to save channel configuration', '儲存頻道設定失敗'), + hermesChannelCredentials: _('凭证', 'Credentials', '憑證'), + hermesChannelAccessPolicy: _('访问策略', 'Access Policy', '存取策略'), + hermesChannelBotToken: _('Bot Token', 'Bot Token', 'Bot Token'), + hermesChannelSlackBotToken: _('Bot Token (xoxb)', 'Bot Token (xoxb)', 'Bot Token (xoxb)'), + hermesChannelSlackAppToken: _('App Token (xapp)', 'App Token (xapp)', 'App Token (xapp)'), + hermesChannelSigningSecret: _('Signing Secret', 'Signing Secret', 'Signing Secret'), + hermesChannelWebhookPath: _('Webhook 路径', 'Webhook Path', 'Webhook 路徑'), + hermesChannelFeishuAppId: _('App ID', 'App ID', 'App ID'), + hermesChannelFeishuAppSecret: _('App Secret', 'App Secret', 'App Secret'), + hermesChannelFeishuDomain: _('区域', 'Region', '區域'), + hermesChannelFeishuDomainCn: _('中国大陆(feishu)', 'Mainland China (feishu)', '中國大陸(feishu)'), + hermesChannelFeishuDomainIntl: _('国际版(lark)', 'International (lark)', '國際版(lark)'), + hermesChannelConnectionMode: _('连接模式', 'Connection Mode', '連線模式'), + hermesChannelReactions: _('表情通知', 'Reaction Notifications', '表情通知'), + hermesChannelReactionsOff: _('关闭', 'Off', '關閉'), + hermesChannelReactionsBasic: _('基础通知', 'Basic Notifications', '基礎通知'), + hermesChannelTypingIndicator: _('显示正在输入状态', 'Show typing indicator', '顯示正在輸入狀態'), + hermesChannelResolveSenderNames: _('解析发送者姓名', 'Resolve sender names', '解析傳送者姓名'), + hermesChannelDmPolicy: _('私聊策略', 'Direct Message Policy', '私聊策略'), + hermesChannelGroupPolicy: _('群组策略', 'Group Policy', '群組策略'), + hermesChannelPolicyPair: _('配对后允许', 'Pair before access', '配對後允許'), + hermesChannelPolicyOpen: _('开放', 'Open', '開放'), + hermesChannelPolicyAllowlist: _('白名单', 'Allowlist', '白名單'), + hermesChannelPolicyDisabled: _('禁用', 'Disabled', '停用'), + hermesChannelAllowFrom: _('私聊白名单', 'Direct Allowlist', '私聊白名單'), + hermesChannelGroupAllowFrom: _('群组白名单', 'Group Allowlist', '群組白名單'), + hermesChannelAllowFromPlaceholder: _('每行或逗号分隔一个用户 ID,开放策略可留空。', 'One user ID per line or comma-separated. Leave empty for open policy.', '每行或逗號分隔一個使用者 ID,開放策略可留空。'), + hermesChannelGroupAllowFromPlaceholder: _('每行或逗号分隔一个群组 / 频道 ID。', 'One group or channel ID per line or comma-separated.', '每行或逗號分隔一個群組 / 頻道 ID。'), + hermesChannelRequireMention: _('群组消息需要 @Bot 才响应', 'Require @mention in groups', '群組訊息需要 @Bot 才回應'), + hermesChannelRestartHint: _('保存会同时写入 config.yaml 和 .env。Hermes Gateway 读取启动时配置,修改后请重启 Gateway。', 'Saving writes both config.yaml and .env. Hermes Gateway reads them on startup, so restart the gateway after changes.', '儲存會同時寫入 config.yaml 和 .env。Hermes Gateway 於啟動時讀取設定,修改後請重啟 Gateway。'), extensionsEyebrow: _('HERMES AGENT · 扩展', 'HERMES AGENT · EXTENSIONS', 'HERMES AGENT · 擴展'), extensionsTitle: _('文档 / 插件 / 主题', 'Docs / Plugins / Themes', '文件 / 插件 / 主題'), extensionsDesc: _('集中管理 Dashboard 扩展清单、视觉主题和使用洞察。', 'Manage dashboard extension manifests, visual themes and usage intelligence.', '集中管理 Dashboard 擴展清單、視覺主題和使用洞察。'), diff --git a/tests/hermes-channel-config.test.js b/tests/hermes-channel-config.test.js new file mode 100644 index 0000000..0926ce9 --- /dev/null +++ b/tests/hermes-channel-config.test.js @@ -0,0 +1,119 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { + buildHermesChannelEnvUpdates, + buildHermesChannelConfigValues, + mergeHermesChannelConfig, +} from '../scripts/dev-api.js' + +test('Hermes 渠道读取会从 platforms 平台配置生成稳定表单值', () => { + const values = buildHermesChannelConfigValues({ + platforms: { + telegram: { + enabled: true, + token: '123:token', + extra: { + dm_policy: 'pair', + group_policy: 'allowlist', + allow_from: ['1001', '1002'], + require_mention: true, + }, + }, + }, + }) + + assert.equal(values.telegram.enabled, true) + assert.equal(values.telegram.botToken, '123:token') + assert.equal(values.telegram.dmPolicy, 'pair') + assert.equal(values.telegram.groupPolicy, 'allowlist') + assert.equal(values.telegram.allowFrom, '1001, 1002') + assert.equal(values.telegram.requireMention, true) +}) + +test('Hermes 渠道保存会写入 Hermes 最新 platforms 配置并保留无关配置', () => { + const next = mergeHermesChannelConfig({ + model: { provider: 'anthropic', default: 'claude-sonnet-4-6' }, + platforms: { + telegram: { + enabled: false, + token: 'old', + extra: { + unknown_option: 'keep-me', + }, + }, + }, + }, 'telegram', { + enabled: true, + botToken: '123:token', + dmPolicy: 'pair', + groupPolicy: 'allowlist', + allowFrom: '1001, 1002', + requireMention: true, + }) + + assert.deepEqual(next.model, { provider: 'anthropic', default: 'claude-sonnet-4-6' }) + assert.equal(next.platforms.telegram.enabled, true) + assert.equal(next.platforms.telegram.token, '123:token') + assert.equal(next.platforms.telegram.extra.dm_policy, 'pair') + assert.equal(next.platforms.telegram.extra.group_policy, 'allowlist') + assert.deepEqual(next.platforms.telegram.extra.allow_from, ['1001', '1002']) + assert.equal(next.platforms.telegram.extra.require_mention, true) + assert.equal(next.platforms.telegram.extra.unknown_option, 'keep-me') +}) + +test('Hermes 飞书保存会补齐可运行默认项并使用 Hermes snake_case 字段', () => { + const next = mergeHermesChannelConfig({}, 'feishu', { + enabled: true, + appId: 'cli_xxx', + appSecret: 'secret', + domain: '', + connectionMode: '', + webhookPath: '', + reactionNotifications: '', + typingIndicator: true, + resolveSenderNames: true, + }) + + assert.equal(next.platforms.feishu.enabled, true) + assert.equal(next.platforms.feishu.extra.app_id, 'cli_xxx') + assert.equal(next.platforms.feishu.extra.app_secret, 'secret') + assert.equal(next.platforms.feishu.extra.domain, 'feishu') + assert.equal(next.platforms.feishu.extra.connection_mode, 'websocket') + assert.equal(next.platforms.feishu.extra.webhook_path, '/feishu/webhook') + assert.equal(next.platforms.feishu.extra.reaction_notifications, 'off') + assert.equal(next.platforms.feishu.extra.typing_indicator, true) + assert.equal(next.platforms.feishu.extra.resolve_sender_names, true) +}) + +test('Hermes 渠道保存会生成运行时仍会读取的环境变量', () => { + const telegramEnv = buildHermesChannelEnvUpdates('telegram', { + botToken: '123:token', + allowFrom: '1001, 1002', + groupAllowFrom: 'group-a\ngroup-b', + requireMention: true, + }) + + assert.equal(telegramEnv.TELEGRAM_BOT_TOKEN, '123:token') + assert.equal(telegramEnv.TELEGRAM_ALLOWED_USERS, '1001,1002') + assert.equal(telegramEnv.TELEGRAM_GROUP_ALLOWED_USERS, 'group-a,group-b') + assert.equal(telegramEnv.TELEGRAM_REQUIRE_MENTION, 'true') + + const feishuEnv = buildHermesChannelEnvUpdates('feishu', { + appId: 'cli_xxx', + appSecret: 'secret', + domain: '', + connectionMode: '', + webhookPath: '', + groupPolicy: 'allowlist', + reactionNotifications: 'off', + }) + + assert.equal(feishuEnv.FEISHU_APP_ID, 'cli_xxx') + assert.equal(feishuEnv.FEISHU_APP_SECRET, 'secret') + assert.equal(feishuEnv.FEISHU_DOMAIN, 'feishu') + assert.equal(feishuEnv.FEISHU_CONNECTION_MODE, 'websocket') + assert.equal(feishuEnv.FEISHU_WEBHOOK_PATH, '/feishu/webhook') + assert.equal(feishuEnv.FEISHU_GROUP_POLICY, 'allowlist') + assert.equal(feishuEnv.FEISHU_REACTIONS, 'false') +})