From 6c947a1fec6d6d762baf5df0277e0152a25415bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=99=B4=E5=A4=A9?= Date: Sat, 23 May 2026 06:41:49 +0800 Subject: [PATCH] feat(hermes): support Discord plugin runtime config --- scripts/dev-api.js | 80 +++++++ src-tauri/src/commands/hermes.rs | 318 ++++++++++++++++++++++++++- src/engines/hermes/pages/channels.js | 42 ++++ src/locales/modules/engine.js | 18 ++ src/pages/engine-select.js | 13 ++ tests/hermes-channel-config.test.js | 123 +++++++++++ 6 files changed, 593 insertions(+), 1 deletion(-) diff --git a/scripts/dev-api.js b/scripts/dev-api.js index b4e8169..fbb92d8 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -3091,6 +3091,22 @@ function hermesEnvValue(envValues, key) { return typeof value === 'string' && value.trim() ? value.trim() : '' } +function hermesEnvBoolValue(envValues, key) { + const value = hermesEnvValue(envValues, key) + if (!value) return undefined + return ['true', '1', 'yes', 'on'].includes(value.toLowerCase()) +} + +function putHermesEnvString(form, envValues, envKey, formKey) { + const value = hermesEnvValue(envValues, envKey) + if (value) form[formKey] = value +} + +function putHermesEnvBool(form, envValues, envKey, formKey) { + const value = hermesEnvBoolValue(envValues, envKey) + if (value !== undefined) form[formKey] = value +} + function readHermesEnvValues() { const envPath = path.join(hermesHome(), '.env') const values = {} @@ -3136,6 +3152,30 @@ export function buildHermesChannelConfigValues(config = {}, envValues = {}) { form.botToken = hermesEnvValue(envValues, 'TELEGRAM_BOT_TOKEN') || (typeof entry.token === 'string' ? entry.token : '') } else if (platform === 'discord') { form.token = hermesEnvValue(envValues, 'DISCORD_BOT_TOKEN') || (typeof entry.token === 'string' ? entry.token : '') + for (const [yamlKey, formKey] of [ + ['free_response_channels', 'freeResponseChannels'], + ['allowed_channels', 'allowedChannels'], + ['ignored_channels', 'ignoredChannels'], + ['no_thread_channels', 'noThreadChannels'], + ]) { + putHermesCsv(form, extra, yamlKey) + putHermesEnvString(form, envValues, `DISCORD_${yamlKey.toUpperCase()}`, formKey) + } + for (const [yamlKey, formKey] of [ + ['auto_thread', 'autoThread'], + ['reactions', 'reactions'], + ['thread_require_mention', 'threadRequireMention'], + ['history_backfill', 'historyBackfill'], + ]) { + putHermesBool(form, extra, yamlKey) + putHermesEnvBool(form, envValues, `DISCORD_${yamlKey.toUpperCase()}`, formKey) + } + putHermesString(form, extra, 'history_backfill_limit') + putHermesEnvString(form, envValues, 'DISCORD_HISTORY_BACKFILL_LIMIT', 'historyBackfillLimit') + putHermesString(form, extra, 'reply_to_mode') + putHermesEnvString(form, envValues, 'DISCORD_REPLY_TO_MODE', 'replyToMode') + putHermesEnvString(form, envValues, 'DISCORD_HOME_CHANNEL', 'homeChannel') + putHermesEnvString(form, envValues, 'DISCORD_HOME_CHANNEL_NAME', 'homeChannelName') } else if (platform === 'slack') { form.botToken = hermesEnvValue(envValues, 'SLACK_BOT_TOKEN') || (typeof entry.token === 'string' ? entry.token : '') putHermesString(form, extra, 'app_token') @@ -3214,6 +3254,16 @@ function normalizeHermesChannelForm(platform, form = {}) { if (platform === 'slack') { normalized.webhookPath = String(normalized.webhookPath || '').trim() || '/slack/events' } + if (platform === 'discord') { + for (const key of ['freeResponseChannels', 'allowedChannels', 'ignoredChannels', 'noThreadChannels']) { + if (Object.hasOwn(normalized, key)) normalized[key] = csvToStringArray(normalized[key]) + } + for (const key of ['autoThread', 'reactions', 'threadRequireMention', 'historyBackfill']) { + if (Object.hasOwn(normalized, key)) normalized[key] = normalized[key] === true || normalized[key] === 'true' || normalized[key] === 'on' + } + normalized.historyBackfillLimit = String(normalized.historyBackfillLimit || '').trim() + normalized.replyToMode = String(normalized.replyToMode || '').trim() + } return normalized } @@ -3232,6 +3282,24 @@ export function mergeHermesChannelConfig(config = {}, platform, form = {}) { deleteHermesEntryKey(entry, 'token') } else if (normalizedPlatform === 'discord') { deleteHermesEntryKey(entry, 'token') + for (const [formKey, extraKey] of [ + ['freeResponseChannels', 'free_response_channels'], + ['allowedChannels', 'allowed_channels'], + ['ignoredChannels', 'ignored_channels'], + ['noThreadChannels', 'no_thread_channels'], + ]) { + if (Array.isArray(normalized[formKey])) setHermesExtra(entry, extraKey, normalized[formKey]) + } + for (const [formKey, extraKey] of [ + ['autoThread', 'auto_thread'], + ['reactions', 'reactions'], + ['threadRequireMention', 'thread_require_mention'], + ['historyBackfill', 'history_backfill'], + ]) { + if (Object.hasOwn(normalized, formKey)) setHermesExtra(entry, extraKey, !!normalized[formKey]) + } + setHermesExtra(entry, 'history_backfill_limit', normalized.historyBackfillLimit) + setHermesExtra(entry, 'reply_to_mode', normalized.replyToMode) } else if (normalizedPlatform === 'slack') { deleteHermesEntryKey(entry, 'token') deleteHermesExtraKey(entry, 'app_token') @@ -3340,6 +3408,18 @@ export function buildHermesChannelEnvUpdates(platform, form = {}) { 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) + updates.DISCORD_FREE_RESPONSE_CHANNELS = csvEnvValue(form.freeResponseChannels) + updates.DISCORD_ALLOWED_CHANNELS = csvEnvValue(form.allowedChannels) + updates.DISCORD_IGNORED_CHANNELS = csvEnvValue(form.ignoredChannels) + updates.DISCORD_NO_THREAD_CHANNELS = csvEnvValue(form.noThreadChannels) + if (Object.hasOwn(form, 'autoThread')) updates.DISCORD_AUTO_THREAD = boolEnvValue(form.autoThread) + if (Object.hasOwn(form, 'reactions')) updates.DISCORD_REACTIONS = boolEnvValue(form.reactions) + if (Object.hasOwn(form, 'threadRequireMention')) updates.DISCORD_THREAD_REQUIRE_MENTION = boolEnvValue(form.threadRequireMention) + if (Object.hasOwn(form, 'historyBackfill')) updates.DISCORD_HISTORY_BACKFILL = boolEnvValue(form.historyBackfill) + updates.DISCORD_HISTORY_BACKFILL_LIMIT = String(form.historyBackfillLimit || '').trim() + updates.DISCORD_REPLY_TO_MODE = String(form.replyToMode || '').trim() + updates.DISCORD_HOME_CHANNEL = String(form.homeChannel || '').trim() + updates.DISCORD_HOME_CHANNEL_NAME = String(form.homeChannelName || '').trim() } else if (platform === 'slack') { updates.SLACK_BOT_TOKEN = String(form.botToken || '').trim() updates.SLACK_APP_TOKEN = String(form.appToken || '').trim() diff --git a/src-tauri/src/commands/hermes.rs b/src-tauri/src/commands/hermes.rs index f59d2f3..776ac41 100644 --- a/src-tauri/src/commands/hermes.rs +++ b/src-tauri/src/commands/hermes.rs @@ -2279,6 +2279,21 @@ fn put_json_string_from_env( } } +fn put_json_bool_from_env( + form: &mut serde_json::Map, + env_values: &std::collections::HashMap, + env_key: &str, + json_key: &str, +) { + if let Some(value) = hermes_env_value(env_values, env_key) { + let enabled = matches!( + value.trim().to_ascii_lowercase().as_str(), + "true" | "1" | "yes" | "on" + ); + form.insert(json_key.to_string(), Value::Bool(enabled)); + } +} + fn build_hermes_channel_config_values( config: &serde_yaml::Value, env_values: &std::collections::HashMap, @@ -2313,6 +2328,79 @@ fn build_hermes_channel_config_values( .or_else(|| yaml_string_field(&entry, "token")) .unwrap_or_default(); form.insert("token".to_string(), Value::String(token)); + for (yaml_key_name, json_key_name, env_key_name) in [ + ( + "free_response_channels", + "freeResponseChannels", + "DISCORD_FREE_RESPONSE_CHANNELS", + ), + ( + "allowed_channels", + "allowedChannels", + "DISCORD_ALLOWED_CHANNELS", + ), + ( + "ignored_channels", + "ignoredChannels", + "DISCORD_IGNORED_CHANNELS", + ), + ( + "no_thread_channels", + "noThreadChannels", + "DISCORD_NO_THREAD_CHANNELS", + ), + ] { + insert_json_csv_if_present(&mut form, &extra, yaml_key_name, json_key_name); + put_json_string_from_env(&mut form, env_values, env_key_name, json_key_name); + } + for (yaml_key_name, json_key_name, env_key_name) in [ + ("auto_thread", "autoThread", "DISCORD_AUTO_THREAD"), + ("reactions", "reactions", "DISCORD_REACTIONS"), + ( + "thread_require_mention", + "threadRequireMention", + "DISCORD_THREAD_REQUIRE_MENTION", + ), + ( + "history_backfill", + "historyBackfill", + "DISCORD_HISTORY_BACKFILL", + ), + ] { + insert_json_bool_if_present(&mut form, &extra, yaml_key_name, json_key_name); + put_json_bool_from_env(&mut form, env_values, env_key_name, json_key_name); + } + insert_json_string_if_present( + &mut form, + &extra, + "history_backfill_limit", + "historyBackfillLimit", + ); + put_json_string_from_env( + &mut form, + env_values, + "DISCORD_HISTORY_BACKFILL_LIMIT", + "historyBackfillLimit", + ); + insert_json_string_if_present(&mut form, &extra, "reply_to_mode", "replyToMode"); + put_json_string_from_env( + &mut form, + env_values, + "DISCORD_REPLY_TO_MODE", + "replyToMode", + ); + put_json_string_from_env( + &mut form, + env_values, + "DISCORD_HOME_CHANNEL", + "homeChannel", + ); + put_json_string_from_env( + &mut form, + env_values, + "DISCORD_HOME_CHANNEL_NAME", + "homeChannelName", + ); } "slack" => { let bot_token = hermes_env_value(env_values, "SLACK_BOT_TOKEN") @@ -2568,7 +2656,35 @@ fn merge_hermes_channel_config( match platform { "telegram" => delete_yaml_key(entry, "token"), - "discord" => delete_yaml_key(entry, "token"), + "discord" => { + delete_yaml_key(entry, "token"); + for (form_key_name, extra_key_name) in [ + ("freeResponseChannels", "free_response_channels"), + ("allowedChannels", "allowed_channels"), + ("ignoredChannels", "ignored_channels"), + ("noThreadChannels", "no_thread_channels"), + ] { + if let Some(values) = form_string_array(form, form_key_name) { + set_extra_string_array(entry, extra_key_name, values); + } + } + for (form_key_name, extra_key_name) in [ + ("autoThread", "auto_thread"), + ("reactions", "reactions"), + ("threadRequireMention", "thread_require_mention"), + ("historyBackfill", "history_backfill"), + ] { + if let Some(value) = form_bool(form, form_key_name) { + set_extra_bool(entry, extra_key_name, value); + } + } + set_extra_string_if_present( + entry, + "history_backfill_limit", + form_string(form, "historyBackfillLimit"), + ); + set_extra_string_if_present(entry, "reply_to_mode", form_string(form, "replyToMode")); + } "slack" => { delete_yaml_key(entry, "token"); delete_extra_key(entry, "app_token"); @@ -2740,6 +2856,50 @@ fn build_hermes_channel_env_updates(platform: &str, form: &Value) -> Vec<(String if let Some(value) = form_bool(form, "requireMention") { push("DISCORD_REQUIRE_MENTION", bool_env_value(value)); } + push( + "DISCORD_FREE_RESPONSE_CHANNELS", + csv_env_value(form, "freeResponseChannels"), + ); + push( + "DISCORD_ALLOWED_CHANNELS", + csv_env_value(form, "allowedChannels"), + ); + push( + "DISCORD_IGNORED_CHANNELS", + csv_env_value(form, "ignoredChannels"), + ); + push( + "DISCORD_NO_THREAD_CHANNELS", + csv_env_value(form, "noThreadChannels"), + ); + if let Some(value) = form_bool(form, "autoThread") { + push("DISCORD_AUTO_THREAD", bool_env_value(value)); + } + if let Some(value) = form_bool(form, "reactions") { + push("DISCORD_REACTIONS", bool_env_value(value)); + } + if let Some(value) = form_bool(form, "threadRequireMention") { + push("DISCORD_THREAD_REQUIRE_MENTION", bool_env_value(value)); + } + if let Some(value) = form_bool(form, "historyBackfill") { + push("DISCORD_HISTORY_BACKFILL", bool_env_value(value)); + } + push( + "DISCORD_HISTORY_BACKFILL_LIMIT", + form_string(form, "historyBackfillLimit").unwrap_or_default(), + ); + push( + "DISCORD_REPLY_TO_MODE", + form_string(form, "replyToMode").unwrap_or_default(), + ); + push( + "DISCORD_HOME_CHANNEL", + form_string(form, "homeChannel").unwrap_or_default(), + ); + push( + "DISCORD_HOME_CHANNEL_NAME", + form_string(form, "homeChannelName").unwrap_or_default(), + ); } "slack" => { push( @@ -2837,6 +2997,18 @@ fn write_hermes_channel_env(platform: &str, form: &Value) -> Result<(), String> "DISCORD_BOT_TOKEN", "DISCORD_ALLOWED_USERS", "DISCORD_REQUIRE_MENTION", + "DISCORD_FREE_RESPONSE_CHANNELS", + "DISCORD_ALLOWED_CHANNELS", + "DISCORD_IGNORED_CHANNELS", + "DISCORD_NO_THREAD_CHANNELS", + "DISCORD_AUTO_THREAD", + "DISCORD_REACTIONS", + "DISCORD_THREAD_REQUIRE_MENTION", + "DISCORD_HISTORY_BACKFILL", + "DISCORD_HISTORY_BACKFILL_LIMIT", + "DISCORD_REPLY_TO_MODE", + "DISCORD_HOME_CHANNEL", + "DISCORD_HOME_CHANNEL_NAME", ], "slack" => vec![ "SLACK_BOT_TOKEN", @@ -7948,6 +8120,150 @@ platforms: ))); } + #[test] + fn discord_channel_supports_plugin_runtime_fields() { + let mut config: serde_yaml::Value = serde_yaml::from_str( + r#" +platforms: + discord: + enabled: true + token: old-token + extra: + unknown_option: keep-me + free_response_channels: ["yaml-free"] + auto_thread: true +"#, + ) + .unwrap(); + let mut env = HashMap::new(); + env.insert( + "DISCORD_BOT_TOKEN".to_string(), + "env-discord-token".to_string(), + ); + env.insert( + "DISCORD_FREE_RESPONSE_CHANNELS".to_string(), + "env-free".to_string(), + ); + env.insert("DISCORD_AUTO_THREAD".to_string(), "false".to_string()); + env.insert("DISCORD_HOME_CHANNEL".to_string(), "home-1".to_string()); + + let values = build_hermes_channel_config_values(&config, &env); + assert_eq!(values["discord"]["token"], "env-discord-token"); + assert_eq!(values["discord"]["freeResponseChannels"], "env-free"); + assert_eq!(values["discord"]["autoThread"], false); + assert_eq!(values["discord"]["homeChannel"], "home-1"); + + merge_hermes_channel_config( + &mut config, + "discord", + &json!({ + "enabled": true, + "token": "discord-token", + "allowFrom": "1001, 1002", + "requireMention": true, + "freeResponseChannels": "free-a\nfree-b", + "allowedChannels": "allow-a", + "ignoredChannels": "ignore-a", + "noThreadChannels": "plain-a", + "autoThread": false, + "reactions": true, + "threadRequireMention": true, + "historyBackfill": true, + "historyBackfillLimit": "12", + "replyToMode": "off", + "homeChannel": "home-1", + "homeChannelName": "ops-home", + }), + ) + .unwrap(); + + assert_eq!( + config["platforms"]["discord"]["token"], + serde_yaml::Value::Null + ); + assert_eq!( + config["platforms"]["discord"]["extra"]["free_response_channels"] + .as_sequence() + .unwrap() + .iter() + .filter_map(|item| item.as_str()) + .collect::>(), + vec!["free-a", "free-b"] + ); + assert_eq!( + config["platforms"]["discord"]["extra"]["allowed_channels"] + .as_sequence() + .unwrap() + .iter() + .filter_map(|item| item.as_str()) + .collect::>(), + vec!["allow-a"] + ); + assert_eq!( + config["platforms"]["discord"]["extra"]["auto_thread"].as_bool(), + Some(false) + ); + assert_eq!( + config["platforms"]["discord"]["extra"]["reactions"].as_bool(), + Some(true) + ); + assert_eq!( + config["platforms"]["discord"]["extra"]["thread_require_mention"].as_bool(), + Some(true) + ); + assert_eq!( + config["platforms"]["discord"]["extra"]["history_backfill"].as_bool(), + Some(true) + ); + assert_eq!( + config["platforms"]["discord"]["extra"]["history_backfill_limit"].as_str(), + Some("12") + ); + assert_eq!( + config["platforms"]["discord"]["extra"]["reply_to_mode"].as_str(), + Some("off") + ); + assert_eq!( + config["platforms"]["discord"]["extra"]["unknown_option"].as_str(), + Some("keep-me") + ); + + let env_updates = build_hermes_channel_env_updates( + "discord", + &json!({ + "token": "discord-token", + "allowFrom": "1001, 1002", + "requireMention": true, + "freeResponseChannels": "free-a\nfree-b", + "allowedChannels": "allow-a", + "ignoredChannels": "ignore-a", + "noThreadChannels": "plain-a", + "autoThread": false, + "reactions": true, + "threadRequireMention": true, + "historyBackfill": true, + "historyBackfillLimit": "12", + "replyToMode": "off", + "homeChannel": "home-1", + "homeChannelName": "ops-home", + }), + ); + + assert!( + env_updates.contains(&("DISCORD_BOT_TOKEN".to_string(), "discord-token".to_string())) + ); + assert!(env_updates.contains(&( + "DISCORD_FREE_RESPONSE_CHANNELS".to_string(), + "free-a,free-b".to_string() + ))); + assert!(env_updates.contains(&("DISCORD_AUTO_THREAD".to_string(), "false".to_string()))); + assert!(env_updates.contains(&( + "DISCORD_THREAD_REQUIRE_MENTION".to_string(), + "true".to_string() + ))); + assert!(env_updates.contains(&("DISCORD_HOME_CHANNEL".to_string(), "home-1".to_string()))); + } + #[test] fn merge_dingtalk_channel_uses_runtime_fields() { let mut config: serde_yaml::Value = serde_yaml::from_str( diff --git a/src/engines/hermes/pages/channels.js b/src/engines/hermes/pages/channels.js index 67f7fdf..8ecae6b 100644 --- a/src/engines/hermes/pages/channels.js +++ b/src/engines/hermes/pages/channels.js @@ -26,6 +26,22 @@ const CHANNELS = [ secretFields: ['token'], fields: [ { key: 'token', labelKey: 'engine.hermesChannelBotToken', type: 'password', placeholder: 'MTA...' }, + { key: 'homeChannel', labelKey: 'engine.hermesChannelDiscordHomeChannel', type: 'text', placeholder: '123456789012345678' }, + { key: 'homeChannelName', labelKey: 'engine.hermesChannelDiscordHomeChannelName', type: 'text', placeholder: 'ops' }, + ], + advancedFields: [ + { key: 'freeResponseChannels', labelKey: 'engine.hermesChannelDiscordFreeResponseChannels', type: 'textarea', placeholderKey: 'engine.hermesChannelDiscordFreeResponseChannelsPh' }, + { key: 'allowedChannels', labelKey: 'engine.hermesChannelDiscordAllowedChannels', type: 'textarea', placeholderKey: 'engine.hermesChannelDiscordChannelListPh' }, + { key: 'ignoredChannels', labelKey: 'engine.hermesChannelDiscordIgnoredChannels', type: 'textarea', placeholderKey: 'engine.hermesChannelDiscordChannelListPh' }, + { key: 'noThreadChannels', labelKey: 'engine.hermesChannelDiscordNoThreadChannels', type: 'textarea', placeholderKey: 'engine.hermesChannelDiscordChannelListPh' }, + { key: 'historyBackfillLimit', labelKey: 'engine.hermesChannelDiscordHistoryBackfillLimit', type: 'text', placeholder: '12' }, + { key: 'replyToMode', labelKey: 'engine.hermesChannelDiscordReplyToMode', type: 'select', options: [['first', 'engine.hermesChannelDiscordReplyFirst'], ['all', 'engine.hermesChannelDiscordReplyAll'], ['off', 'engine.hermesChannelDiscordReplyOff']] }, + ], + advancedToggles: [ + { key: 'autoThread', labelKey: 'engine.hermesChannelDiscordAutoThread' }, + { key: 'reactions', labelKey: 'engine.hermesChannelDiscordReactions' }, + { key: 'threadRequireMention', labelKey: 'engine.hermesChannelDiscordThreadRequireMention' }, + { key: 'historyBackfill', labelKey: 'engine.hermesChannelDiscordHistoryBackfill' }, ], }, { @@ -109,6 +125,13 @@ function defaultForm(platform) { form.typingIndicator = true form.resolveSenderNames = true } + if (platform === 'discord') { + form.autoThread = true + form.reactions = true + form.threadRequireMention = false + form.historyBackfill = false + form.replyToMode = 'first' + } if (platform === 'slack') form.webhookPath = '/slack/events' return form } @@ -284,6 +307,25 @@ export function render() { + ${(channel.advancedFields || []).length ? ` +
+
${esc(t('engine.hermesChannelRuntimeBehavior'))}
+ ${(channel.advancedToggles || []).length ? ` +
+ ${channel.advancedToggles.map(toggle => ` + + `).join('')} +
+ ` : ''} +
+ ${channel.advancedFields.map(field => renderField(field, form, disabled)).join('')} +
+
+ ` : ''} +
${icon('info', 14)} ${esc(t('engine.hermesChannelRestartHint'))} diff --git a/src/locales/modules/engine.js b/src/locales/modules/engine.js index c3fbfcc..260ba08 100644 --- a/src/locales/modules/engine.js +++ b/src/locales/modules/engine.js @@ -937,7 +937,25 @@ export default { hermesChannelSaveFailed: _('保存渠道配置失败', 'Failed to save channel configuration', '儲存頻道設定失敗'), hermesChannelCredentials: _('凭证', 'Credentials', '憑證'), hermesChannelAccessPolicy: _('访问策略', 'Access Policy', '存取策略'), + hermesChannelRuntimeBehavior: _('运行行为', 'Runtime Behavior', '執行行為'), hermesChannelBotToken: _('Bot Token', 'Bot Token', 'Bot Token'), + hermesChannelDiscordHomeChannel: _('默认频道 ID', 'Home Channel ID', '預設頻道 ID'), + hermesChannelDiscordHomeChannelName: _('默认频道名称', 'Home Channel Name', '預設頻道名稱'), + hermesChannelDiscordFreeResponseChannels: _('免 @ 响应频道', 'Free Response Channels', '免 @ 回應頻道'), + hermesChannelDiscordAllowedChannels: _('允许频道', 'Allowed Channels', '允許頻道'), + hermesChannelDiscordIgnoredChannels: _('忽略频道', 'Ignored Channels', '忽略頻道'), + hermesChannelDiscordNoThreadChannels: _('不自动建线程频道', 'No-thread Channels', '不自動建立討論串頻道'), + hermesChannelDiscordChannelListPh: _('每行或逗号分隔一个 Discord 频道 ID。', 'One Discord channel ID per line or comma-separated.', '每行或逗號分隔一個 Discord 頻道 ID。'), + hermesChannelDiscordFreeResponseChannelsPh: _('这些频道内无需 @Bot 即可响应;支持频道或父频道 ID。', 'Messages in these channels do not require @mention; channel or parent channel IDs are supported.', '這些頻道內無需 @Bot 即可回應;支援頻道或父頻道 ID。'), + hermesChannelDiscordHistoryBackfillLimit: _('上下文回填条数', 'History Backfill Limit', '上下文回填筆數'), + hermesChannelDiscordReplyToMode: _('回复模式', 'Reply Mode', '回覆模式'), + hermesChannelDiscordReplyFirst: _('仅首条回复引用', 'Reply to first message', '僅首則回覆引用'), + hermesChannelDiscordReplyAll: _('每条回复都引用', 'Reply to every message', '每則回覆都引用'), + hermesChannelDiscordReplyOff: _('不引用回复', 'No reply reference', '不引用回覆'), + hermesChannelDiscordAutoThread: _('自动创建线程', 'Auto-create threads', '自動建立討論串'), + hermesChannelDiscordReactions: _('启用表情反馈', 'Enable reactions', '啟用表情回饋'), + hermesChannelDiscordThreadRequireMention: _('线程内也要求 @Bot', 'Require @mention in threads', '討論串內也要求 @Bot'), + hermesChannelDiscordHistoryBackfill: _('触发时回填频道上下文', 'Backfill channel context on trigger', '觸發時回填頻道上下文'), 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'), diff --git a/src/pages/engine-select.js b/src/pages/engine-select.js index a2ec05e..9eee303 100644 --- a/src/pages/engine-select.js +++ b/src/pages/engine-select.js @@ -146,6 +146,19 @@ function bindClick(page) { if (option) chooseWithAnimation(page, panel, option, engine) }) + // CTA 按钮本身位于内容层,不一定会命中背景三角形面板; + // 单独绑定可以保证移动端和键盘操作都能真正完成引擎选择。 + page.querySelectorAll('[data-engine-cta]').forEach(btn => { + btn.addEventListener('click', (event) => { + event.stopPropagation() + if (_busy) return + const engine = btn.dataset.engineCta + const option = PRIMARY_OPTIONS.find(o => o.id === engine) + const panel = page.querySelector(`.es-panel[data-engine="${engine}"]`) + if (option && panel) chooseWithAnimation(page, panel, option, engine) + }) + }) + // 次级链接:两个都要 / 稍后再说(无对角线动画,直接走选择) page.querySelectorAll('[data-secondary]').forEach(btn => { btn.addEventListener('click', async (event) => { diff --git a/tests/hermes-channel-config.test.js b/tests/hermes-channel-config.test.js index f91de21..536f62e 100644 --- a/tests/hermes-channel-config.test.js +++ b/tests/hermes-channel-config.test.js @@ -183,6 +183,129 @@ test('Hermes 渠道保存会生成运行时仍会读取的环境变量', () => { assert.equal(dingTalkEnv.DINGTALK_REQUIRE_MENTION, 'true') }) +test('Hermes Discord 读取会回显新版插件运行字段并优先使用环境变量', () => { + const values = buildHermesChannelConfigValues({ + platforms: { + discord: { + enabled: true, + extra: { + require_mention: true, + thread_require_mention: true, + free_response_channels: ['free-a', 'free-b'], + allowed_channels: ['allow-a'], + ignored_channels: ['ignore-a'], + no_thread_channels: ['plain-a'], + auto_thread: true, + reactions: false, + history_backfill: true, + history_backfill_limit: '12', + reply_to_mode: 'all', + }, + }, + }, + }, { + DISCORD_BOT_TOKEN: 'env-discord-token', + DISCORD_HOME_CHANNEL: 'home-1', + DISCORD_HOME_CHANNEL_NAME: 'ops-home', + DISCORD_FREE_RESPONSE_CHANNELS: 'env-free', + DISCORD_AUTO_THREAD: 'false', + }) + + assert.equal(values.discord.enabled, true) + assert.equal(values.discord.token, 'env-discord-token') + assert.equal(values.discord.freeResponseChannels, 'env-free') + assert.equal(values.discord.allowedChannels, 'allow-a') + assert.equal(values.discord.ignoredChannels, 'ignore-a') + assert.equal(values.discord.noThreadChannels, 'plain-a') + assert.equal(values.discord.autoThread, false) + assert.equal(values.discord.reactions, false) + assert.equal(values.discord.threadRequireMention, true) + assert.equal(values.discord.historyBackfill, true) + assert.equal(values.discord.historyBackfillLimit, '12') + assert.equal(values.discord.replyToMode, 'all') + assert.equal(values.discord.homeChannel, 'home-1') + assert.equal(values.discord.homeChannelName, 'ops-home') +}) + +test('Hermes Discord 保存会写入新版插件 YAML 字段和运行时环境变量', () => { + const next = mergeHermesChannelConfig({ + platforms: { + discord: { + enabled: true, + token: 'old-token', + extra: { + unknown_option: 'keep-me', + }, + }, + }, + }, 'discord', { + enabled: true, + token: 'discord-token', + allowFrom: '1001, 1002', + requireMention: true, + freeResponseChannels: 'free-a\nfree-b', + allowedChannels: 'allow-a', + ignoredChannels: 'ignore-a', + noThreadChannels: 'plain-a', + autoThread: false, + reactions: true, + threadRequireMention: true, + historyBackfill: true, + historyBackfillLimit: '12', + replyToMode: 'off', + homeChannel: 'home-1', + homeChannelName: 'ops-home', + }) + + assert.equal(next.platforms.discord.enabled, true) + assert.equal(next.platforms.discord.token, undefined) + assert.deepEqual(next.platforms.discord.extra.allow_from, ['1001', '1002']) + assert.deepEqual(next.platforms.discord.extra.free_response_channels, ['free-a', 'free-b']) + assert.deepEqual(next.platforms.discord.extra.allowed_channels, ['allow-a']) + assert.deepEqual(next.platforms.discord.extra.ignored_channels, ['ignore-a']) + assert.deepEqual(next.platforms.discord.extra.no_thread_channels, ['plain-a']) + assert.equal(next.platforms.discord.extra.auto_thread, false) + assert.equal(next.platforms.discord.extra.reactions, true) + assert.equal(next.platforms.discord.extra.thread_require_mention, true) + assert.equal(next.platforms.discord.extra.history_backfill, true) + assert.equal(next.platforms.discord.extra.history_backfill_limit, '12') + assert.equal(next.platforms.discord.extra.reply_to_mode, 'off') + assert.equal(next.platforms.discord.extra.unknown_option, 'keep-me') + + const env = buildHermesChannelEnvUpdates('discord', { + token: 'discord-token', + allowFrom: '1001, 1002', + requireMention: true, + freeResponseChannels: 'free-a\nfree-b', + allowedChannels: 'allow-a', + ignoredChannels: 'ignore-a', + noThreadChannels: 'plain-a', + autoThread: false, + reactions: true, + threadRequireMention: true, + historyBackfill: true, + historyBackfillLimit: '12', + replyToMode: 'off', + homeChannel: 'home-1', + homeChannelName: 'ops-home', + }) + + assert.equal(env.DISCORD_BOT_TOKEN, 'discord-token') + assert.equal(env.DISCORD_ALLOWED_USERS, '1001,1002') + assert.equal(env.DISCORD_FREE_RESPONSE_CHANNELS, 'free-a,free-b') + assert.equal(env.DISCORD_ALLOWED_CHANNELS, 'allow-a') + assert.equal(env.DISCORD_IGNORED_CHANNELS, 'ignore-a') + assert.equal(env.DISCORD_NO_THREAD_CHANNELS, 'plain-a') + assert.equal(env.DISCORD_AUTO_THREAD, 'false') + assert.equal(env.DISCORD_REACTIONS, 'true') + assert.equal(env.DISCORD_THREAD_REQUIRE_MENTION, 'true') + assert.equal(env.DISCORD_HISTORY_BACKFILL, 'true') + assert.equal(env.DISCORD_HISTORY_BACKFILL_LIMIT, '12') + assert.equal(env.DISCORD_REPLY_TO_MODE, 'off') + assert.equal(env.DISCORD_HOME_CHANNEL, 'home-1') + assert.equal(env.DISCORD_HOME_CHANNEL_NAME, 'ops-home') +}) + test('Hermes 渠道保存会从 YAML 清理旧凭证,避免覆盖 .env 运行时值', () => { const next = mergeHermesChannelConfig({ platforms: {