feat(hermes): add telegram runtime options

This commit is contained in:
晴天
2026-05-26 23:22:25 +08:00
parent 45aadbdc63
commit 466e6c8831
5 changed files with 164 additions and 1 deletions

View File

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

View File

@@ -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<String>,
strict: bool,
) -> Result<String, String> {
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]

View File

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

View File

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

View File

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