feat(hermes): add channel configuration editor

This commit is contained in:
晴天
2026-05-23 01:51:08 +08:00
parent 27b35b6298
commit eccf91ed1e
11 changed files with 1775 additions and 12 deletions

18
package-lock.json generated
View File

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

View File

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

View File

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

View File

@@ -2147,6 +2147,639 @@ fn merge_env_file(existing: &str, managed_keys: &[&str], new_pairs: &[(String, S
content
}
// ---------------------------------------------------------------------------
// Hermes 渠道配置 — 读写 ~/.hermes/config.yaml 的 platforms.<platform>
// 并同步 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<String> {
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<bool> {
yaml_get(map, key).and_then(|v| v.as_bool())
}
fn yaml_csv_field(map: &serde_yaml::Mapping, key: &str) -> Option<String> {
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::<Vec<_>>()
.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<String, Value>,
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<String, Value>,
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<String, Value>,
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<String>) {
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<String>) {
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<String>) {
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::<Vec<_>>(),
),
);
}
}
fn form_string(form: &Value, key: &str) -> Option<String> {
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<bool> {
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<Vec<String>> {
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<String> {
value
.split([',', ';', '\n'])
.map(str::trim)
.filter(|item| !item.is_empty())
.map(ToString::to_string)
.collect()
}
fn normalize_hermes_dm_policy(value: Option<String>) -> 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>) -> 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<Value, String> {
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<Value, String> {
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()
)));
}
}

View File

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

View File

@@ -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 当前是 placeholder487 字节 stub— 暂不挂 nav
// 完整实现见 Batch 3待 Hermes 渠道完整支持时启用 sidebar 入口
{ path: '/h/channels', loader: () => import('./pages/channels.js') },
{ path: '/h/env', loader: () => import('./pages/env-editor.js') },
// 共用页面(引擎无关)

View File

@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
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 `
<label class="hm-field">
<span class="hm-field-label">${label}</span>
<select class="hm-input hm-channel-input" data-key="${esc(field.key)}" ${disabled ? 'disabled' : ''}>
${(field.options || []).map(([optionValue, optionLabel]) => `
<option value="${esc(optionValue)}" ${value === optionValue ? 'selected' : ''}>${esc(optionLabel.startsWith('engine.') ? t(optionLabel) : optionLabel)}</option>
`).join('')}
</select>
</label>
`
}
if (field.type === 'textarea') {
return `
<label class="hm-field">
<span class="hm-field-label">${label}</span>
<textarea class="hm-input hm-channel-input hm-channel-textarea" data-key="${esc(field.key)}" ${disabled ? 'disabled' : ''} placeholder="${esc(t(field.placeholderKey))}">${esc(value)}</textarea>
</label>
`
}
return `
<label class="hm-field">
<span class="hm-field-label">${label}</span>
<input class="hm-input hm-channel-input" data-key="${esc(field.key)}" type="${esc(field.type || 'text')}" value="${esc(value)}" ${disabled ? 'disabled' : ''} placeholder="${esc(field.placeholder || '')}" autocomplete="off">
</label>
`
}
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 = `
<div class="page-header"><h1>${t('engine.hermesChannelsTitle')}</h1></div>
<div class="card"><div class="card-body" style="padding:32px;text-align:center;color:var(--text-tertiary)">
${t('engine.comingSoonPhase2')}
</div></div>
`
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 = `
<div class="hm-hero">
<div class="hm-hero-title">
<div class="hm-hero-eyebrow">${esc(t('engine.hermesChannelsEyebrow'))}</div>
<h1 class="hm-hero-h1">${esc(t('engine.hermesChannelsTitle'))}</h1>
<div class="hm-hero-sub">${esc(configPath || '~/.hermes/config.yaml')}</div>
</div>
<div class="hm-hero-actions">
<button class="hm-btn hm-btn--ghost hm-btn--sm" id="hm-channels-reload" ${disabled ? 'disabled' : ''}>${icon('refresh-cw', 14)}${esc(t('engine.hermesConfigReload'))}</button>
<button class="hm-btn hm-btn--cta hm-btn--sm" id="hm-channels-save" ${disabled ? 'disabled' : ''}>${saving ? esc(t('engine.hermesChannelSaving')) : esc(t('engine.hermesChannelSave'))}</button>
</div>
</div>
<section class="hm-channel-summary" aria-label="${esc(t('engine.hermesChannelSummary'))}">
<div class="hm-channel-stat"><span>${esc(t('engine.hermesChannelEnabledCount'))}</span><strong>${enabledCount}</strong></div>
<div class="hm-channel-stat"><span>${esc(t('engine.hermesChannelConfiguredCount'))}</span><strong>${configuredCount}</strong></div>
<div class="hm-channel-stat"><span>${esc(t('engine.hermesChannelRuntimeWrite'))}</span><strong>YAML + .env</strong></div>
</section>
${(error || success) ? `
<div class="hm-channel-alert ${error ? 'is-error' : 'is-success'}">
${icon(error ? 'alert-triangle' : 'check-circle', 15)}
<span>${esc(error || success)}</span>
</div>
` : ''}
<div class="hm-channel-layout">
<section class="hm-panel hm-channel-list-panel">
<div class="hm-panel-header">
<div class="hm-panel-title">${esc(t('engine.hermesChannelPlatforms'))}</div>
</div>
<div class="hm-panel-body hm-panel-body--tight">
<div class="hm-channel-list" role="tablist" aria-label="${esc(t('engine.hermesChannelPlatforms'))}">
${CHANNELS.map(item => {
const itemForm = normalizeForm(item.id, values[item.id])
return `
<button class="hm-channel-tab ${item.id === active ? 'is-active' : ''}" data-channel="${esc(item.id)}" role="tab" aria-selected="${item.id === active ? 'true' : 'false'}" ${disabled ? 'disabled' : ''}>
<span class="hm-channel-tab-icon">${icon(item.icon, 16)}</span>
<span class="hm-channel-tab-main">
<strong>${esc(t(item.titleKey))}</strong>
<small>${esc(itemForm.enabled ? t('engine.hermesChannelEnabled') : t('engine.hermesChannelDisabled'))}</small>
</span>
<span class="hm-channel-dot ${itemForm.enabled ? 'is-on' : ''}" aria-hidden="true"></span>
</button>
`
}).join('')}
</div>
</div>
</section>
<section class="hm-panel hm-channel-form-panel">
<div class="hm-panel-header">
<div>
<div class="hm-panel-title">${icon(channel.icon, 15)}${esc(t(channel.titleKey))}</div>
<div class="hm-channel-panel-desc">${esc(t(channel.descKey))}</div>
</div>
<label class="hm-channel-switch">
<input class="hm-channel-input" data-key="enabled" type="checkbox" ${form.enabled ? 'checked' : ''} ${disabled ? 'disabled' : ''}>
<span>${esc(form.enabled ? t('engine.hermesChannelEnabled') : t('engine.hermesChannelDisabled'))}</span>
</label>
</div>
<div class="hm-panel-body">
${loading ? `
<div class="hm-channel-loading">${esc(t('common.loading'))}...</div>
` : `
<div class="hm-channel-section">
<div class="hm-channel-section-title">${esc(t('engine.hermesChannelCredentials'))}</div>
<div class="hm-field-row">
${channel.fields.map(field => renderField(field, form, disabled)).join('')}
</div>
${(channel.toggles || []).length ? `
<div class="hm-channel-toggle-grid">
${channel.toggles.map(toggle => `
<label class="hm-channel-check">
<input class="hm-channel-input" data-key="${esc(toggle.key)}" type="checkbox" ${form[toggle.key] ? 'checked' : ''} ${disabled ? 'disabled' : ''}>
<span>${esc(t(toggle.labelKey))}</span>
</label>
`).join('')}
</div>
` : ''}
</div>
<div class="hm-channel-section">
<div class="hm-channel-section-title">${esc(t('engine.hermesChannelAccessPolicy'))}</div>
<div class="hm-field-row">
${COMMON_FIELDS.slice(0, 2).map(field => renderField(field, form, disabled)).join('')}
</div>
<label class="hm-channel-check hm-channel-check--wide">
<input class="hm-channel-input" data-key="requireMention" type="checkbox" ${form.requireMention ? 'checked' : ''} ${disabled ? 'disabled' : ''}>
<span>${esc(t('engine.hermesChannelRequireMention'))}</span>
</label>
<div class="hm-field-row">
${COMMON_FIELDS.slice(2).map(field => renderField(field, form, disabled)).join('')}
</div>
</div>
<div class="hm-channel-footnote">
${icon('info', 14)}
<span>${esc(t('engine.hermesChannelRestartHint'))}</span>
</div>
`}
</div>
</section>
</div>
`
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
}

View File

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

View File

@@ -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 }),

View File

@@ -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 擴展清單、視覺主題和使用洞察。'),

View File

@@ -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')
})