feat(hermes): support Discord plugin runtime config

This commit is contained in:
晴天
2026-05-23 06:41:49 +08:00
parent f188bb85f7
commit 6c947a1fec
6 changed files with 593 additions and 1 deletions

View File

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

View File

@@ -2279,6 +2279,21 @@ fn put_json_string_from_env(
}
}
fn put_json_bool_from_env(
form: &mut serde_json::Map<String, Value>,
env_values: &std::collections::HashMap<String, String>,
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<String, String>,
@@ -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<_>>(),
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<_>>(),
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(

View File

@@ -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() {
</div>
</div>
${(channel.advancedFields || []).length ? `
<div class="hm-channel-section">
<div class="hm-channel-section-title">${esc(t('engine.hermesChannelRuntimeBehavior'))}</div>
${(channel.advancedToggles || []).length ? `
<div class="hm-channel-toggle-grid">
${channel.advancedToggles.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 class="hm-field-row">
${channel.advancedFields.map(field => renderField(field, form, disabled)).join('')}
</div>
</div>
` : ''}
<div class="hm-channel-footnote">
${icon('info', 14)}
<span>${esc(t('engine.hermesChannelRestartHint'))}</span>

View File

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

View File

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

View File

@@ -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: {