From 466e6c8831ed5c030e67595216e852866a039d59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=99=B4=E5=A4=A9?= Date: Tue, 26 May 2026 23:22:25 +0800 Subject: [PATCH] feat(hermes): add telegram runtime options --- scripts/dev-api.js | 25 +++++++ src-tauri/src/commands/hermes.rs | 97 +++++++++++++++++++++++++++- src/engines/hermes/pages/channels.js | 12 ++++ src/locales/modules/engine.js | 6 ++ tests/hermes-channel-config.test.js | 25 +++++++ 5 files changed, 164 insertions(+), 1 deletion(-) diff --git a/scripts/dev-api.js b/scripts/dev-api.js index 4ab93b6..2c47f03 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -3340,6 +3340,7 @@ const HERMES_PROVIDER_ROUTING_SORTS = new Set(['price', 'throughput', 'latency'] const HERMES_PROVIDER_ROUTING_DATA_COLLECTION = new Set(['allow', 'deny']) const HERMES_DISPLAY_TOOL_PROGRESS_VALUES = new Set(['off', 'new', 'all', 'verbose']) const HERMES_DISPLAY_STREAMING_VALUES = new Set(['inherit', 'true', 'false']) +const HERMES_TELEGRAM_REPLY_TO_MODE_VALUES = new Set(['off', 'first', 'all']) const HERMES_DISPLAY_RESUME_VALUES = new Set(['full', 'minimal']) const HERMES_DISPLAY_BUSY_INPUT_MODES = new Set(['interrupt', 'queue', 'steer']) const HERMES_DISPLAY_BACKGROUND_PROCESS_NOTIFICATIONS = new Set(['off', 'result', 'error', 'all']) @@ -5386,6 +5387,13 @@ function normalizeHermesGroupPolicy(raw) { return 'allowlist' } +function normalizeHermesTelegramReplyToMode(raw, strict = false) { + const value = String(raw || '').trim().toLowerCase() || 'first' + if (HERMES_TELEGRAM_REPLY_TO_MODE_VALUES.has(value)) return value + if (strict) throw new Error('platforms.telegram.extra.reply_to_mode 必须是 off、first 或 all') + return 'first' +} + function readHermesPlatform(config, platform) { const platforms = config?.platforms && typeof config.platforms === 'object' ? config.platforms : {} const entry = platforms?.[platform] && typeof platforms[platform] === 'object' ? platforms[platform] : {} @@ -5400,6 +5408,12 @@ export function buildHermesChannelConfigValues(config = {}, envValues = {}) { const form = { enabled: entry.enabled === true } if (platform === 'telegram') { form.botToken = hermesEnvValue(envValues, 'TELEGRAM_BOT_TOKEN') || (typeof entry.token === 'string' ? entry.token : '') + putHermesString(form, extra, 'reply_to_mode') + putHermesBool(form, extra, 'guest_mode') + putHermesBool(form, extra, 'disable_link_previews') + form.replyToMode = normalizeHermesTelegramReplyToMode(hermesEnvValue(envValues, 'TELEGRAM_REPLY_TO_MODE') || form.replyToMode, false) + putHermesEnvBool(form, envValues, 'TELEGRAM_GUEST_MODE', 'guestMode') + putHermesEnvBool(form, envValues, 'TELEGRAM_DISABLE_LINK_PREVIEWS', 'disableLinkPreviews') } else if (platform === 'discord') { form.token = hermesEnvValue(envValues, 'DISCORD_BOT_TOKEN') || (typeof entry.token === 'string' ? entry.token : '') for (const [yamlKey, formKey] of [ @@ -5611,6 +5625,11 @@ function normalizeHermesChannelForm(platform, form = {}) { normalized.historyBackfillLimit = String(normalized.historyBackfillLimit || '').trim() normalized.replyToMode = String(normalized.replyToMode || '').trim() } + if (platform === 'telegram') { + normalized.replyToMode = normalizeHermesTelegramReplyToMode(normalized.replyToMode, true) + if (Object.hasOwn(normalized, 'guestMode')) normalized.guestMode = normalized.guestMode === true || normalized.guestMode === 'true' || normalized.guestMode === 'on' + if (Object.hasOwn(normalized, 'disableLinkPreviews')) normalized.disableLinkPreviews = normalized.disableLinkPreviews === true || normalized.disableLinkPreviews === 'true' || normalized.disableLinkPreviews === 'on' + } if (platform === 'irc') { if (Object.hasOwn(normalized, 'useTls')) normalized.useTls = normalized.useTls === true || normalized.useTls === 'true' || normalized.useTls === 'on' } @@ -5697,6 +5716,9 @@ export function mergeHermesChannelConfig(config = {}, platform, form = {}) { entry.enabled = normalized.enabled if (normalizedPlatform === 'telegram') { deleteHermesEntryKey(entry, 'token') + setHermesExtra(entry, 'reply_to_mode', normalized.replyToMode) + if (Object.hasOwn(normalized, 'guestMode')) setHermesExtra(entry, 'guest_mode', !!normalized.guestMode) + if (Object.hasOwn(normalized, 'disableLinkPreviews')) setHermesExtra(entry, 'disable_link_previews', !!normalized.disableLinkPreviews) } else if (normalizedPlatform === 'discord') { deleteHermesEntryKey(entry, 'token') for (const [formKey, extraKey] of [ @@ -5860,6 +5882,9 @@ export function buildHermesChannelEnvUpdates(platform, form = {}) { 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) + updates.TELEGRAM_REPLY_TO_MODE = normalizeHermesTelegramReplyToMode(form.replyToMode, true) + if (Object.hasOwn(form, 'guestMode')) updates.TELEGRAM_GUEST_MODE = boolEnvValue(form.guestMode) + if (Object.hasOwn(form, 'disableLinkPreviews')) updates.TELEGRAM_DISABLE_LINK_PREVIEWS = boolEnvValue(form.disableLinkPreviews) } else if (platform === 'discord') { updates.DISCORD_BOT_TOKEN = String(form.token || '').trim() updates.DISCORD_ALLOWED_USERS = csvEnvValue(form.allowFrom) diff --git a/src-tauri/src/commands/hermes.rs b/src-tauri/src/commands/hermes.rs index e439e7d..4a1cf2d 100644 --- a/src-tauri/src/commands/hermes.rs +++ b/src-tauri/src/commands/hermes.rs @@ -2167,6 +2167,7 @@ const HERMES_CHANNEL_PLATFORMS: [&str; 10] = [ const HERMES_DISPLAY_TOOL_PROGRESS_VALUES: [&str; 4] = ["off", "new", "all", "verbose"]; const HERMES_DISPLAY_STREAMING_VALUES: [&str; 3] = ["inherit", "true", "false"]; +const HERMES_TELEGRAM_REPLY_TO_MODE_VALUES: [&str; 3] = ["off", "first", "all"]; const HERMES_PROMPT_CACHE_TTLS: [&str; 2] = ["5m", "1h"]; const HERMES_PROVIDER_ROUTING_SORTS: [&str; 3] = ["price", "throughput", "latency"]; const HERMES_PROVIDER_ROUTING_DATA_COLLECTION: [&str; 2] = ["allow", "deny"]; @@ -2228,6 +2229,25 @@ fn normalize_hermes_display_streaming_text( } } +fn normalize_hermes_telegram_reply_to_mode( + value: Option, + strict: bool, +) -> Result { + let mode = value.unwrap_or_default().trim().to_ascii_lowercase(); + let mode = if mode.is_empty() { + "first".to_string() + } else { + mode + }; + if HERMES_TELEGRAM_REPLY_TO_MODE_VALUES.contains(&mode.as_str()) { + Ok(mode) + } else if strict { + Err("platforms.telegram.extra.reply_to_mode 必须是 off、first 或 all".to_string()) + } else { + Ok("first".to_string()) + } +} + fn normalize_hermes_display_streaming_yaml( value: Option<&serde_yaml::Value>, strict: bool, @@ -2712,6 +2732,27 @@ fn build_hermes_channel_config_values( .or_else(|| yaml_string_field(&entry, "token")) .unwrap_or_default(); form.insert("botToken".to_string(), Value::String(token)); + let reply_to_mode = normalize_hermes_telegram_reply_to_mode( + hermes_env_value(env_values, "TELEGRAM_REPLY_TO_MODE") + .or_else(|| yaml_string_field(&extra, "reply_to_mode")), + false, + ) + .unwrap_or_else(|_| "first".to_string()); + form.insert("replyToMode".to_string(), Value::String(reply_to_mode)); + insert_json_bool_if_present(&mut form, &extra, "guest_mode", "guestMode"); + insert_json_bool_if_present( + &mut form, + &extra, + "disable_link_previews", + "disableLinkPreviews", + ); + put_json_bool_from_env(&mut form, env_values, "TELEGRAM_GUEST_MODE", "guestMode"); + put_json_bool_from_env( + &mut form, + env_values, + "TELEGRAM_DISABLE_LINK_PREVIEWS", + "disableLinkPreviews", + ); } "discord" => { let token = hermes_env_value(env_values, "DISCORD_BOT_TOKEN") @@ -8296,7 +8337,23 @@ fn merge_hermes_channel_config( ); match platform { - "telegram" => delete_yaml_key(entry, "token"), + "telegram" => { + delete_yaml_key(entry, "token"); + set_extra_string_if_present( + entry, + "reply_to_mode", + Some(normalize_hermes_telegram_reply_to_mode( + form_string(form, "replyToMode"), + true, + )?), + ); + if let Some(value) = form_bool(form, "guestMode") { + set_extra_bool(entry, "guest_mode", value); + } + if let Some(value) = form_bool(form, "disableLinkPreviews") { + set_extra_bool(entry, "disable_link_previews", value); + } + } "discord" => { delete_yaml_key(entry, "token"); for (form_key_name, extra_key_name) in [ @@ -8544,6 +8601,17 @@ fn build_hermes_channel_env_updates(platform: &str, form: &Value) -> Vec<(String if let Some(value) = form_bool(form, "requireMention") { push("TELEGRAM_REQUIRE_MENTION", bool_env_value(value)); } + push( + "TELEGRAM_REPLY_TO_MODE", + normalize_hermes_telegram_reply_to_mode(form_string(form, "replyToMode"), true) + .unwrap_or_else(|_| "first".to_string()), + ); + if let Some(value) = form_bool(form, "guestMode") { + push("TELEGRAM_GUEST_MODE", bool_env_value(value)); + } + if let Some(value) = form_bool(form, "disableLinkPreviews") { + push("TELEGRAM_DISABLE_LINK_PREVIEWS", bool_env_value(value)); + } } "discord" => { push( @@ -19280,6 +19348,9 @@ platforms: "groupPolicy": "allowlist", "allowFrom": "1001, 1002", "requireMention": true, + "replyToMode": "off", + "guestMode": true, + "disableLinkPreviews": true, }), ) .unwrap(); @@ -19296,15 +19367,39 @@ platforms: config["platforms"]["telegram"]["extra"]["unknown_option"].as_str(), Some("keep-me") ); + assert_eq!( + config["platforms"]["telegram"]["extra"]["reply_to_mode"].as_str(), + Some("off") + ); + assert_eq!( + config["platforms"]["telegram"]["extra"]["guest_mode"].as_bool(), + Some(true) + ); + assert_eq!( + config["platforms"]["telegram"]["extra"]["disable_link_previews"].as_bool(), + Some(true) + ); + assert_eq!(values["telegram"]["replyToMode"], "off"); + assert_eq!(values["telegram"]["guestMode"], true); + assert_eq!(values["telegram"]["disableLinkPreviews"], true); let env = build_hermes_channel_env_updates( "telegram", &json!({ "botToken": "123:token", "allowFrom": "1001, 1002", "requireMention": true, + "replyToMode": "off", + "guestMode": true, + "disableLinkPreviews": true, }), ); assert!(env.contains(&("TELEGRAM_BOT_TOKEN".to_string(), "123:token".to_string()))); + assert!(env.contains(&("TELEGRAM_REPLY_TO_MODE".to_string(), "off".to_string()))); + assert!(env.contains(&("TELEGRAM_GUEST_MODE".to_string(), "true".to_string()))); + assert!(env.contains(&( + "TELEGRAM_DISABLE_LINK_PREVIEWS".to_string(), + "true".to_string() + ))); } #[test] diff --git a/src/engines/hermes/pages/channels.js b/src/engines/hermes/pages/channels.js index f3fa8f7..d5012fa 100644 --- a/src/engines/hermes/pages/channels.js +++ b/src/engines/hermes/pages/channels.js @@ -17,6 +17,13 @@ const CHANNELS = [ fields: [ { key: 'botToken', labelKey: 'engine.hermesChannelBotToken', type: 'password', placeholder: '123456:ABC-DEF...' }, ], + advancedFields: [ + { key: 'replyToMode', labelKey: 'engine.hermesChannelTelegramReplyToMode', type: 'select', options: [['first', 'engine.hermesChannelTelegramReplyFirst'], ['all', 'engine.hermesChannelTelegramReplyAll'], ['off', 'engine.hermesChannelTelegramReplyOff']] }, + ], + advancedToggles: [ + { key: 'guestMode', labelKey: 'engine.hermesChannelTelegramGuestMode' }, + { key: 'disableLinkPreviews', labelKey: 'engine.hermesChannelTelegramDisableLinkPreviews' }, + ], }, { id: 'discord', @@ -271,6 +278,11 @@ function defaultForm(platform) { form.historyBackfill = false form.replyToMode = 'first' } + if (platform === 'telegram') { + form.replyToMode = 'first' + form.guestMode = false + form.disableLinkPreviews = false + } if (platform === 'slack') form.webhookPath = '/slack/events' return form } diff --git a/src/locales/modules/engine.js b/src/locales/modules/engine.js index ec9825e..13491c1 100644 --- a/src/locales/modules/engine.js +++ b/src/locales/modules/engine.js @@ -1522,6 +1522,12 @@ export default { hermesChannelDiscordReplyFirst: _('仅首条回复引用', 'Reply to first message', '僅首則回覆引用'), hermesChannelDiscordReplyAll: _('每条回复都引用', 'Reply to every message', '每則回覆都引用'), hermesChannelDiscordReplyOff: _('不引用回复', 'No reply reference', '不引用回覆'), + hermesChannelTelegramReplyToMode: _('Telegram 回复引用', 'Telegram Reply Reference', 'Telegram 回覆引用'), + hermesChannelTelegramReplyFirst: _('仅首条回复引用', 'Reply to first message', '僅首則回覆引用'), + hermesChannelTelegramReplyAll: _('每条回复都引用', 'Reply to every message', '每則回覆都引用'), + hermesChannelTelegramReplyOff: _('不引用回复', 'No reply reference', '不引用回覆'), + hermesChannelTelegramGuestMode: _('允许被 @ 时临时响应非白名单群组', 'Allow explicit @mentions from non-allowlisted groups', '允許被 @ 時臨時回應非白名單群組'), + hermesChannelTelegramDisableLinkPreviews: _('关闭链接预览', 'Disable link previews', '關閉連結預覽'), hermesChannelDiscordAutoThread: _('自动创建线程', 'Auto-create threads', '自動建立討論串'), hermesChannelDiscordReactions: _('启用表情反馈', 'Enable reactions', '啟用表情回饋'), hermesChannelDiscordThreadRequireMention: _('线程内也要求 @Bot', 'Require @mention in threads', '討論串內也要求 @Bot'), diff --git a/tests/hermes-channel-config.test.js b/tests/hermes-channel-config.test.js index 2550591..7ff5419 100644 --- a/tests/hermes-channel-config.test.js +++ b/tests/hermes-channel-config.test.js @@ -18,6 +18,9 @@ test('Hermes 渠道读取会从 platforms 平台配置生成稳定表单值', () group_policy: 'allowlist', allow_from: ['1001', '1002'], require_mention: true, + reply_to_mode: 'all', + guest_mode: true, + disable_link_previews: true, }, }, }, @@ -29,6 +32,9 @@ test('Hermes 渠道读取会从 platforms 平台配置生成稳定表单值', () assert.equal(values.telegram.groupPolicy, 'allowlist') assert.equal(values.telegram.allowFrom, '1001, 1002') assert.equal(values.telegram.requireMention, true) + assert.equal(values.telegram.replyToMode, 'all') + assert.equal(values.telegram.guestMode, true) + assert.equal(values.telegram.disableLinkPreviews, true) }) test('Hermes 渠道读取会按运行时优先级合并 .env 凭证', () => { @@ -101,6 +107,9 @@ test('Hermes 渠道保存会写入 Hermes 最新 platforms 配置并保留无关 groupPolicy: 'allowlist', allowFrom: '1001, 1002', requireMention: true, + replyToMode: 'off', + guestMode: true, + disableLinkPreviews: true, }) assert.deepEqual(next.model, { provider: 'anthropic', default: 'claude-sonnet-4-6' }) @@ -110,9 +119,19 @@ test('Hermes 渠道保存会写入 Hermes 最新 platforms 配置并保留无关 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.reply_to_mode, 'off') + assert.equal(next.platforms.telegram.extra.guest_mode, true) + assert.equal(next.platforms.telegram.extra.disable_link_previews, true) assert.equal(next.platforms.telegram.extra.unknown_option, 'keep-me') }) +test('Hermes Telegram 保存会校验回复模式选项', () => { + assert.throws(() => mergeHermesChannelConfig({}, 'telegram', { + enabled: true, + replyToMode: 'sometimes', + }), /platforms\.telegram\.extra\.reply_to_mode/) +}) + test('Hermes 飞书保存会补齐可运行默认项并使用 Hermes snake_case 字段', () => { const next = mergeHermesChannelConfig({}, 'feishu', { enabled: true, @@ -143,12 +162,18 @@ test('Hermes 渠道保存会生成运行时仍会读取的环境变量', () => { allowFrom: '1001, 1002', groupAllowFrom: 'group-a\ngroup-b', requireMention: true, + replyToMode: 'off', + guestMode: true, + disableLinkPreviews: 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') + assert.equal(telegramEnv.TELEGRAM_REPLY_TO_MODE, 'off') + assert.equal(telegramEnv.TELEGRAM_GUEST_MODE, 'true') + assert.equal(telegramEnv.TELEGRAM_DISABLE_LINK_PREVIEWS, 'true') const feishuEnv = buildHermesChannelEnvUpdates('feishu', { appId: 'cli_xxx',