feat(channels): add LINE and Mattermost config

This commit is contained in:
晴天
2026-05-23 05:08:51 +08:00
parent 780b1bdde5
commit 53fe25a277
5 changed files with 601 additions and 30 deletions

View File

@@ -2429,7 +2429,7 @@ function putWildcardAllowFromWhenOpen(entry, previousAllowFrom) {
}
function platformSupportsTopLevelRequireMention(platform) {
return ['feishu', 'slack', 'msteams'].includes(platformStorageKey(platform))
return ['feishu', 'slack', 'msteams', 'mattermost'].includes(platformStorageKey(platform))
}
export function normalizeMessagingPlatformForm(platform, form = {}) {
@@ -2438,7 +2438,7 @@ export function normalizeMessagingPlatformForm(platform, form = {}) {
if (!Object.hasOwn(normalized, 'allowFrom') && Object.hasOwn(normalized, 'allowedUsers')) {
normalized.allowFrom = normalized.allowedUsers
}
const needsAccessDefaults = ['telegram', 'discord', 'feishu', 'slack', 'signal', 'msteams', 'whatsapp', 'zalo', 'zalouser'].includes(storageKey)
const needsAccessDefaults = ['telegram', 'discord', 'feishu', 'slack', 'signal', 'msteams', 'whatsapp', 'zalo', 'zalouser', 'line', 'mattermost'].includes(storageKey)
const hasDmField = Object.hasOwn(normalized, 'dmPolicy') || needsAccessDefaults
const hasGroupField = Object.hasOwn(normalized, 'groupPolicy') || needsAccessDefaults
@@ -2481,12 +2481,14 @@ export function normalizeMessagingPlatformForm(platform, form = {}) {
}
}
if (storageKey === 'zalouser' && Object.hasOwn(normalized, 'dangerouslyAllowNameMatching')) {
const value = String(normalized.dangerouslyAllowNameMatching || '').trim()
if (!value) {
delete normalized.dangerouslyAllowNameMatching
} else {
normalized.dangerouslyAllowNameMatching = value === 'true'
for (const key of ['dangerouslyAllowNameMatching', 'dangerouslyAllowPrivateNetwork']) {
if (Object.hasOwn(normalized, key)) {
const value = String(normalized[key] || '').trim()
if (!value) {
delete normalized[key]
} else {
normalized[key] = value === 'true'
}
}
}
@@ -2583,11 +2585,14 @@ const MESSAGING_CREDENTIAL_FIELDS = [
'appSecret',
'appToken',
'botToken',
'channelAccessToken',
'channelSecret',
'clientId',
'clientSecret',
'gatewayPassword',
'gatewayToken',
'password',
'secretFile',
'signingSecret',
'token',
'tokenFile',
@@ -2613,6 +2618,17 @@ function channelAnyCredentialFields(platform) {
return []
}
function channelAnyCredentialGroups(platform) {
const storageKey = platformStorageKey(platform)
if (storageKey === 'line') {
return [
{ label: 'Channel Access Token 或 Token File', fields: [['channelAccessToken', 'Channel Access Token'], ['tokenFile', 'Token File']] },
{ label: 'Channel Secret 或 Secret File', fields: [['channelSecret', 'Channel Secret'], ['secretFile', 'Secret File']] },
]
}
return []
}
const CHANNEL_DIAG_REQUIRED_FIELDS = {
telegram: [['botToken', 'Bot Token']],
discord: [['token', 'Bot Token']],
@@ -2620,6 +2636,7 @@ const CHANNEL_DIAG_REQUIRED_FIELDS = {
dingtalk: [['clientId', 'Client ID'], ['clientSecret', 'Client Secret']],
'dingtalk-connector': [['clientId', 'Client ID'], ['clientSecret', 'Client Secret']],
msteams: [['appId', 'App ID'], ['appPassword', 'App Password']],
mattermost: [['botToken', 'Bot Token'], ['baseUrl', 'Base URL']],
signal: [['account', 'Signal 账号']],
}
@@ -2645,6 +2662,10 @@ function channelDiagnosisCredentialsReady(platform, form = {}) {
if (requiredFields.length) {
return requiredFields.every(([key]) => hasConfiguredMessagingValue(form?.[key]))
}
const anyGroups = channelAnyCredentialGroups(platform)
if (anyGroups.length) {
return anyGroups.every(group => group.fields.some(([key]) => hasConfiguredMessagingValue(form?.[key])))
}
const anyFields = channelAnyCredentialFields(platform)
if (anyFields.length) {
return anyFields.some(([key]) => hasConfiguredMessagingValue(form?.[key]))
@@ -2689,14 +2710,22 @@ export function buildOpenClawChannelDiagnosis({
const requiredFields = requiredChannelCredentialFields(storageKey, form)
const anyFields = channelAnyCredentialFields(storageKey)
const anyGroups = channelAnyCredentialGroups(storageKey)
const missing = requiredFields
.filter(([key]) => !hasConfiguredMessagingValue(form?.[key]))
.map(([, label]) => label)
const missingGroups = anyGroups
.filter(group => !group.fields.some(([key]) => hasConfiguredMessagingValue(form?.[key])))
.map(group => group.label)
const hasAnyCredential = channelRootHasMessagingCredential(form)
const anyCredentialOk = anyFields.length ? anyFields.some(([key]) => hasConfiguredMessagingValue(form?.[key])) : false
const credentialOk = storageKey === 'zalouser'
? !!configExists
: (requiredFields.length ? missing.length === 0 : (anyFields.length ? anyCredentialOk : hasAnyCredential))
: (requiredFields.length
? missing.length === 0
: (anyGroups.length
? missingGroups.length === 0
: (anyFields.length ? anyCredentialOk : hasAnyCredential)))
const anyLabels = anyFields.map(([, label]) => label).join(' / ')
checks.push({
id: 'credentials',
@@ -2707,10 +2736,14 @@ export function buildOpenClawChannelDiagnosis({
: (credentialOk
? (requiredFields.length
? `已填写 ${requiredFields.map(([, label]) => label).join(' / ')}`
: (anyFields.length ? `已填写 ${anyLabels} 其中一项。` : '已检测到可用凭证字段。'))
: (anyGroups.length
? `已填写 ${anyGroups.map(group => group.label).join('')}`
: (anyFields.length ? `已填写 ${anyLabels} 其中一项。` : '已检测到可用凭证字段。')))
: (missing.length
? `缺少 ${missing.join(' / ')},请补齐后保存。`
: (anyFields.length ? `缺少 ${anyLabels},至少填写一项后保存。` : '未检测到可用凭证字段,请检查渠道配置。'))),
: (missingGroups.length
? `缺少 ${missingGroups.join('')},请补齐后保存。`
: (anyFields.length ? `缺少 ${anyLabels},至少填写一项后保存。` : '未检测到可用凭证字段,请检查渠道配置。')))),
})
if (verifyError) {
@@ -2862,6 +2895,29 @@ export function buildMessagingPlatformFormValues(platform, saved = {}, options =
return form
}
if (storageKey === 'line') {
for (const key of ['channelAccessToken', 'tokenFile', 'channelSecret', 'secretFile', 'webhookPath', 'responsePrefix']) {
putSecretAwareFormValue(form, saved, key)
}
putAccessPolicyFormValues(form, saved)
putCsvFormValue(form, saved, 'groupAllowFrom')
if (typeof saved.mediaMaxMb === 'number') form.mediaMaxMb = String(saved.mediaMaxMb)
return form
}
if (storageKey === 'mattermost') {
for (const key of ['botToken', 'baseUrl', 'name', 'replyToMode', 'responsePrefix']) {
putSecretAwareFormValue(form, saved, key)
}
putAccessPolicyFormValues(form, saved, { mentionCompat: true })
putCsvFormValue(form, saved, 'groupAllowFrom')
putBoolFormValue(form, saved, 'dangerouslyAllowNameMatching')
putBoolFormValue(form, saved?.network, 'dangerouslyAllowPrivateNetwork')
putStringFormValue(form, saved?.commands, 'callbackPath')
putStringFormValue(form, saved?.commands, 'callbackUrl')
return form
}
for (const [key, value] of Object.entries(saved)) {
if (key === 'enabled' || key === 'accounts') continue
if (typeof value === 'string') form[key] = value
@@ -3334,6 +3390,19 @@ function applyMessagingPlatformEntry(cfg, storageKey, accountId, entry) {
}
}
function ensureMessagingPluginAllowed(cfg, pluginId) {
if (!pluginId || !pluginId.trim()) return
const pid = pluginId.trim()
if (!cfg.plugins || typeof cfg.plugins !== 'object' || Array.isArray(cfg.plugins)) cfg.plugins = {}
if (!cfg.plugins.entries || typeof cfg.plugins.entries !== 'object' || Array.isArray(cfg.plugins.entries)) cfg.plugins.entries = {}
if (!Array.isArray(cfg.plugins.allow)) cfg.plugins.allow = []
if (!cfg.plugins.allow.includes(pid)) cfg.plugins.allow.push(pid)
if (!cfg.plugins.entries[pid] || typeof cfg.plugins.entries[pid] !== 'object' || Array.isArray(cfg.plugins.entries[pid])) {
cfg.plugins.entries[pid] = {}
}
cfg.plugins.entries[pid].enabled = true
}
function buildOpenClawMessagingPlatformEntry(platform, form, currentSaved = {}) {
const entry = { enabled: true }
const storageKey = platformStorageKey(platform)
@@ -3384,6 +3453,32 @@ function buildOpenClawMessagingPlatformEntry(platform, form, currentSaved = {})
if (Array.isArray(form.groupAllowFrom) && form.groupAllowFrom.length) entry.groupAllowFrom = form.groupAllowFrom
if (typeof form.historyLimit === 'number') entry.historyLimit = form.historyLimit
if (typeof form.dangerouslyAllowNameMatching === 'boolean') entry.dangerouslyAllowNameMatching = form.dangerouslyAllowNameMatching
} else if (storageKey === 'line') {
for (const key of ['channelAccessToken', 'tokenFile', 'channelSecret', 'secretFile', 'webhookPath', 'responsePrefix']) {
if (form[key]) entry[key] = form[key]
}
entry.dmPolicy = form.dmPolicy
entry.groupPolicy = form.groupPolicy
if (Array.isArray(form.allowFrom) && form.allowFrom.length) entry.allowFrom = form.allowFrom
if (Array.isArray(form.groupAllowFrom) && form.groupAllowFrom.length) entry.groupAllowFrom = form.groupAllowFrom
if (typeof form.mediaMaxMb === 'number') entry.mediaMaxMb = form.mediaMaxMb
} else if (storageKey === 'mattermost') {
for (const key of ['botToken', 'baseUrl', 'name', 'replyToMode', 'responsePrefix']) {
if (form[key]) entry[key] = form[key]
}
entry.dmPolicy = form.dmPolicy
entry.groupPolicy = form.groupPolicy
if (Object.hasOwn(form, 'requireMention')) entry.requireMention = !!form.requireMention
if (Array.isArray(form.allowFrom) && form.allowFrom.length) entry.allowFrom = form.allowFrom
if (Array.isArray(form.groupAllowFrom) && form.groupAllowFrom.length) entry.groupAllowFrom = form.groupAllowFrom
if (typeof form.dangerouslyAllowNameMatching === 'boolean') entry.dangerouslyAllowNameMatching = form.dangerouslyAllowNameMatching
if (typeof form.dangerouslyAllowPrivateNetwork === 'boolean') {
entry.network = { ...(currentSaved?.network || {}), dangerouslyAllowPrivateNetwork: form.dangerouslyAllowPrivateNetwork }
}
const commands = {}
if (form.callbackPath) commands.callbackPath = form.callbackPath
if (form.callbackUrl) commands.callbackUrl = form.callbackUrl
if (Object.keys(commands).length) entry.commands = { ...(currentSaved?.commands || {}), ...commands }
} else {
Object.assign(entry, form)
}
@@ -4866,12 +4961,16 @@ const handlers = {
} else {
setRootChannelEntry(entry)
}
} else if (platform === 'line' || platform === 'mattermost') {
const built = buildOpenClawMessagingPlatformEntry(platform, form, currentSaved)
applyMessagingPlatformEntry(cfg, storageKey, normalizedAccountId, built)
ensureMessagingPluginAllowed(cfg, platform)
} else {
Object.assign(entry, form)
preserveMessagingCredentialRefs(entry, form, currentSaved)
}
if (platform !== 'qqbot' && platform !== 'feishu' && platform !== 'dingtalk' && platform !== 'dingtalk-connector') {
if (platform !== 'qqbot' && platform !== 'feishu' && platform !== 'dingtalk' && platform !== 'dingtalk-connector' && platform !== 'line' && platform !== 'mattermost') {
preserveMessagingCredentialRefs(entry, form, currentSaved)
// 合并模式:保留用户通过 CLI 或手动编辑的自定义字段
applyMessagingPlatformEntry(cfg, storageKey, normalizedAccountId, entry)

View File

@@ -140,11 +140,14 @@ fn preserve_messaging_credential_refs(
"appSecret",
"appToken",
"botToken",
"channelAccessToken",
"channelSecret",
"clientId",
"clientSecret",
"gatewayPassword",
"gatewayToken",
"password",
"secretFile",
"signingSecret",
"token",
"tokenFile",
@@ -181,11 +184,14 @@ fn channel_root_has_messaging_credential(root: &Map<String, Value>) -> bool {
"appSecret",
"appToken",
"botToken",
"channelAccessToken",
"channelSecret",
"clientId",
"clientSecret",
"gatewayPassword",
"gatewayToken",
"password",
"secretFile",
"signingSecret",
"token",
"tokenFile",
@@ -212,6 +218,7 @@ fn required_channel_credential_fields(
"feishu" => vec![("appId", "App ID"), ("appSecret", "App Secret")],
"dingtalk-connector" => vec![("clientId", "Client ID"), ("clientSecret", "Client Secret")],
"msteams" => vec![("appId", "App ID"), ("appPassword", "App Password")],
"mattermost" => vec![("botToken", "Bot Token"), ("baseUrl", "Base URL")],
"signal" => vec![("account", "Signal 账号")],
"slack" => {
let mode = form_string(form, "mode");
@@ -246,6 +253,30 @@ fn channel_any_credential_fields(platform: &str) -> Vec<(&'static str, &'static
}
}
fn channel_any_credential_groups(
platform: &str,
) -> Vec<(&'static str, Vec<(&'static str, &'static str)>)> {
match platform_storage_key(platform) {
"line" => vec![
(
"Channel Access Token 或 Token File",
vec![
("channelAccessToken", "Channel Access Token"),
("tokenFile", "Token File"),
],
),
(
"Channel Secret 或 Secret File",
vec![
("channelSecret", "Channel Secret"),
("secretFile", "Secret File"),
],
),
],
_ => vec![],
}
}
fn channel_diagnosis_credentials_ready(platform: &str, form: &Map<String, Value>) -> bool {
if platform_storage_key(platform) == "zalouser" {
return true;
@@ -256,6 +287,14 @@ fn channel_diagnosis_credentials_ready(platform: &str, form: &Map<String, Value>
.iter()
.all(|(key, _)| has_configured_messaging_value(form.get(*key)));
}
let any_groups = channel_any_credential_groups(platform);
if !any_groups.is_empty() {
return any_groups.iter().all(|(_, fields)| {
fields
.iter()
.any(|(key, _)| has_configured_messaging_value(form.get(*key)))
});
}
let any_fields = channel_any_credential_fields(platform);
if !any_fields.is_empty() {
return any_fields
@@ -341,11 +380,21 @@ fn build_openclaw_channel_diagnosis(
let required_fields = required_channel_credential_fields(storage_key, form);
let any_fields = channel_any_credential_fields(storage_key);
let any_groups = channel_any_credential_groups(storage_key);
let missing: Vec<&str> = required_fields
.iter()
.filter(|(key, _)| !has_configured_messaging_value(form.get(*key)))
.map(|(_, label)| *label)
.collect();
let missing_groups: Vec<&str> = any_groups
.iter()
.filter(|(_, fields)| {
!fields
.iter()
.any(|(key, _)| has_configured_messaging_value(form.get(*key)))
})
.map(|(label, _)| *label)
.collect();
let any_credential_ok = if any_fields.is_empty() {
false
} else {
@@ -357,6 +406,8 @@ fn build_openclaw_channel_diagnosis(
config_exists
} else if !required_fields.is_empty() {
missing.is_empty()
} else if !any_groups.is_empty() {
missing_groups.is_empty()
} else if !any_fields.is_empty() {
any_credential_ok
} else {
@@ -373,6 +424,15 @@ fn build_openclaw_channel_diagnosis(
} else if credential_ok {
if !required_fields.is_empty() {
format!("已填写 {}", required_labels)
} else if !any_groups.is_empty() {
format!(
"已填写 {}",
any_groups
.iter()
.map(|(label, _)| *label)
.collect::<Vec<_>>()
.join("")
)
} else if !any_fields.is_empty() {
format!("已填写 {} 其中一项。", any_labels)
} else {
@@ -380,6 +440,8 @@ fn build_openclaw_channel_diagnosis(
}
} else if !missing.is_empty() {
format!("缺少 {},请补齐后保存。", missing.join(" / "))
} else if !missing_groups.is_empty() {
format!("缺少 {},请补齐后保存。", missing_groups.join(""))
} else if !any_fields.is_empty() {
format!("缺少 {},至少填写一项后保存。", any_labels)
} else {
@@ -639,7 +701,7 @@ fn normalize_group_policy_value(raw: Option<&Value>, fallback: &str) -> String {
fn platform_supports_top_level_require_mention(platform: &str) -> bool {
matches!(
platform_storage_key(platform),
"feishu" | "slack" | "msteams"
"feishu" | "slack" | "msteams" | "mattermost"
)
}
@@ -667,6 +729,8 @@ fn normalize_messaging_platform_form(
| "whatsapp"
| "zalo"
| "zalouser"
| "line"
| "mattermost"
);
let has_dm_field = normalized.contains_key("dmPolicy") || needs_access_defaults;
let has_group_field = normalized.contains_key("groupPolicy") || needs_access_defaults;
@@ -725,23 +789,28 @@ fn normalize_messaging_platform_form(
normalize_numeric_form_value(&mut normalized, "mediaMaxMb");
normalize_numeric_form_value(&mut normalized, "historyLimit");
if storage_key == "zalouser" && normalized.contains_key("dangerouslyAllowNameMatching") {
let value = match normalized.get("dangerouslyAllowNameMatching") {
Some(Value::Bool(v)) => Some(*v),
Some(Value::String(raw)) => {
let trimmed = raw.trim();
if trimmed.is_empty() {
None
} else {
Some(bool_from_form_value(trimmed).unwrap_or(false))
for key in [
"dangerouslyAllowNameMatching",
"dangerouslyAllowPrivateNetwork",
] {
if normalized.contains_key(key) {
let value = match normalized.get(key) {
Some(Value::Bool(v)) => Some(*v),
Some(Value::String(raw)) => {
let trimmed = raw.trim();
if trimmed.is_empty() {
None
} else {
Some(bool_from_form_value(trimmed).unwrap_or(false))
}
}
_ => None,
};
if let Some(v) = value {
normalized.insert(key.into(), Value::Bool(v));
} else {
normalized.remove(key);
}
_ => None,
};
if let Some(v) = value {
normalized.insert("dangerouslyAllowNameMatching".into(), Value::Bool(v));
} else {
normalized.remove("dangerouslyAllowNameMatching");
}
}
@@ -1345,6 +1414,44 @@ pub async fn read_platform_config(
insert_access_policy_form_values(&mut form, &saved, false, true);
insert_bool_as_string(&mut form, &saved, "requireMention");
}
"line" => {
for key in [
"channelAccessToken",
"tokenFile",
"channelSecret",
"secretFile",
"webhookPath",
"responsePrefix",
] {
insert_secret_aware_form_value(&mut form, &saved, key);
}
insert_access_policy_form_values(&mut form, &saved, false, false);
insert_array_as_csv(&mut form, &saved, "groupAllowFrom");
if let Some(v) = saved.get("mediaMaxMb").and_then(|v| v.as_i64()) {
form.insert("mediaMaxMb".into(), Value::String(v.to_string()));
}
}
"mattermost" => {
for key in [
"botToken",
"baseUrl",
"name",
"replyToMode",
"responsePrefix",
] {
insert_secret_aware_form_value(&mut form, &saved, key);
}
insert_access_policy_form_values(&mut form, &saved, false, true);
insert_array_as_csv(&mut form, &saved, "groupAllowFrom");
insert_bool_as_string(&mut form, &saved, "dangerouslyAllowNameMatching");
if let Some(network) = saved.get("network") {
insert_bool_as_string(&mut form, network, "dangerouslyAllowPrivateNetwork");
}
if let Some(commands) = saved.get("commands") {
insert_string_if_present(&mut form, commands, "callbackPath");
insert_string_if_present(&mut form, commands, "callbackUrl");
}
}
_ => {
if saved.is_null() {
return Ok(json!({ "exists": false }));
@@ -1973,6 +2080,152 @@ pub async fn save_messaging_platform(
)?;
ensure_plugin_allowed(&mut cfg, "msteams")?;
}
"line" => {
let channel_access_token = form_string(form_obj, "channelAccessToken");
let token_file = form_string(form_obj, "tokenFile");
let channel_secret = form_string(form_obj, "channelSecret");
let secret_file = form_string(form_obj, "secretFile");
if channel_access_token.is_empty() && token_file.is_empty() {
return Err("Channel Access Token 或 Token File 至少填写一项".into());
}
if channel_secret.is_empty() && secret_file.is_empty() {
return Err("Channel Secret 或 Secret File 至少填写一项".into());
}
let mut entry = Map::new();
entry.insert("enabled".into(), Value::Bool(true));
put_string(&mut entry, "channelAccessToken", channel_access_token);
put_string(&mut entry, "tokenFile", token_file);
put_string(&mut entry, "channelSecret", channel_secret);
put_string(&mut entry, "secretFile", secret_file);
put_string(
&mut entry,
"webhookPath",
form_string(form_obj, "webhookPath"),
);
put_string(
&mut entry,
"responsePrefix",
form_string(form_obj, "responsePrefix"),
);
put_string(&mut entry, "dmPolicy", form_string(form_obj, "dmPolicy"));
put_string(
&mut entry,
"groupPolicy",
form_string(form_obj, "groupPolicy"),
);
put_array_from_form_value(&mut entry, "allowFrom", form_obj.get("allowFrom"));
put_array_from_form_value(&mut entry, "groupAllowFrom", form_obj.get("groupAllowFrom"));
if let Some(value) = form_obj.get("mediaMaxMb").and_then(|v| v.as_f64()) {
if let Some(number) = serde_json::Number::from_f64(value) {
entry.insert("mediaMaxMb".into(), Value::Number(number));
}
} else {
put_number_from_form(
&mut entry,
"mediaMaxMb",
&form_string(form_obj, "mediaMaxMb"),
);
}
preserve_messaging_credential_refs(&mut entry, form_obj, &current_saved);
merge_channel_entry_for_account(
channels_map,
&storage_key,
account_id.as_deref(),
entry,
)?;
ensure_plugin_allowed(&mut cfg, "line")?;
}
"mattermost" => {
let bot_token = form_string(form_obj, "botToken");
let base_url = form_string(form_obj, "baseUrl");
if bot_token.is_empty() {
return Err("Mattermost Bot Token 不能为空".into());
}
if base_url.is_empty() {
return Err("Mattermost Base URL 不能为空".into());
}
let mut entry = Map::new();
entry.insert("enabled".into(), Value::Bool(true));
put_string(&mut entry, "botToken", bot_token);
put_string(&mut entry, "baseUrl", base_url);
put_string(&mut entry, "name", form_string(form_obj, "name"));
put_string(
&mut entry,
"replyToMode",
form_string(form_obj, "replyToMode"),
);
put_string(
&mut entry,
"responsePrefix",
form_string(form_obj, "responsePrefix"),
);
put_string(&mut entry, "dmPolicy", form_string(form_obj, "dmPolicy"));
put_string(
&mut entry,
"groupPolicy",
form_string(form_obj, "groupPolicy"),
);
put_bool_value_if_present(&mut entry, "requireMention", form_obj.get("requireMention"));
put_array_from_form_value(&mut entry, "allowFrom", form_obj.get("allowFrom"));
put_array_from_form_value(&mut entry, "groupAllowFrom", form_obj.get("groupAllowFrom"));
put_bool_value_if_present(
&mut entry,
"dangerouslyAllowNameMatching",
form_obj.get("dangerouslyAllowNameMatching"),
);
if form_obj.contains_key("dangerouslyAllowPrivateNetwork") {
let mut network = current_saved
.get("network")
.and_then(|v| v.as_object())
.cloned()
.unwrap_or_default();
match form_obj.get("dangerouslyAllowPrivateNetwork") {
Some(Value::Bool(v)) => {
network.insert("dangerouslyAllowPrivateNetwork".into(), Value::Bool(*v));
}
Some(Value::String(raw)) => {
if let Some(v) = bool_from_form_value(raw) {
network.insert("dangerouslyAllowPrivateNetwork".into(), Value::Bool(v));
}
}
_ => {}
}
if !network.is_empty() {
entry.insert("network".into(), Value::Object(network));
}
}
let mut commands = current_saved
.get("commands")
.and_then(|v| v.as_object())
.cloned()
.unwrap_or_default();
put_string(
&mut commands,
"callbackPath",
form_string(form_obj, "callbackPath"),
);
put_string(
&mut commands,
"callbackUrl",
form_string(form_obj, "callbackUrl"),
);
if !commands.is_empty() {
entry.insert("commands".into(), Value::Object(commands));
}
preserve_messaging_credential_refs(&mut entry, form_obj, &current_saved);
merge_channel_entry_for_account(
channels_map,
&storage_key,
account_id.as_deref(),
entry,
)?;
ensure_plugin_allowed(&mut cfg, "mattermost")?;
}
_ => {
// 通用平台:直接保存表单字段
let mut entry = Map::new();

View File

@@ -91,6 +91,43 @@ export default {
zalouserAllowFromHint: _('推荐使用 Zalo 用户 ID只有确认安全时才开启名称匹配。', 'Zalo user IDs are recommended; enable name matching only when you understand the risk.'),
zalouserGroupAllowFromPh: _('可选,逗号分隔群组 ID 或精确群名', 'Optional, comma-separated group IDs or exact group names'),
zalouserManualLoginHint: _('插件安装并保存配置后,在终端运行此命令完成二维码登录。多账号请追加 --account 账号标识。', 'After installing the plugin and saving config, run this command in your terminal to complete QR login. For multi-account setup, append --account account-id.'),
lineDesc: _('接入 LINE Messaging API支持私聊、群组和 webhook 回调', 'Connect LINE Messaging API with DM, group, and webhook support'),
lineGuide1: _('前往 <a href="https://developers.line.biz/console/" target="_blank" rel="noopener">LINE Developers Console</a> 创建或打开 Messaging API Channel', 'Open <a href="https://developers.line.biz/console/" target="_blank" rel="noopener">LINE Developers Console</a> and create or open a Messaging API Channel'),
lineGuide2: _('在「Messaging API」页获取 <strong>Channel Access Token</strong>在「Basic settings」页获取 <strong>Channel Secret</strong>', 'Get <strong>Channel Access Token</strong> from Messaging API and <strong>Channel Secret</strong> from Basic settings'),
lineGuide3: _('如果凭证由外部文件或 SecretRef 管理,可分别填写 Token File 与 Secret File', 'If credentials are managed by files or SecretRef, use Token File and Secret File instead'),
lineGuide4: _('配置 Webhook URL 指向 Gateway 暴露的 LINE webhook path并在 LINE 控制台启用 Webhook', 'Set the Webhook URL to the Gateway LINE webhook path and enable Webhook in LINE Console'),
lineGuide5: _('设置私信/群组策略与允许列表,保存后面板会安装插件并重载 Gateway', 'Set DM/group policy and allowlists; after saving, the panel installs the plugin and reloads Gateway'),
lineGuideFooter: _('<div style="margin-top:8px;font-size:var(--font-size-xs);color:var(--text-tertiary)">LINE 至少需要 Channel Access Token 或 Token File 其中一项,并且需要 Channel Secret 或 Secret File 其中一项。</div>', '<div style="margin-top:8px;font-size:var(--font-size-xs);color:var(--text-tertiary)">LINE requires either Channel Access Token or Token File, and either Channel Secret or Secret File.</div>'),
lineAccessTokenPh: _('LINE Channel Access Token', 'LINE Channel Access Token'),
lineAccessTokenHint: _('与 Token File 二选一;如果当前值来自 SecretRef保持占位不变即可保留引用。', 'Use either this or Token File; keep the SecretRef placeholder unchanged to preserve the reference.'),
lineTokenFilePh: _('可选,例如 /etc/openclaw/line-token.txt', 'Optional, e.g. /etc/openclaw/line-token.txt'),
lineTokenFileHint: _('当 Channel Access Token 由文件管理时填写;在线校验只会直接校验表单 Token。', 'Use this when Channel Access Token is file-managed; online verification only checks the form token directly.'),
lineChannelSecretPh: _('LINE Channel Secret', 'LINE Channel Secret'),
lineChannelSecretHint: _('与 Secret File 二选一,用于校验 LINE webhook 签名。', 'Use either this or Secret File; used to verify LINE webhook signatures.'),
lineSecretFilePh: _('可选,例如 /etc/openclaw/line-secret.txt', 'Optional, e.g. /etc/openclaw/line-secret.txt'),
lineSecretFileHint: _('当 Channel Secret 由文件管理时填写。', 'Use this when Channel Secret is file-managed.'),
lineAllowFromPh: _('可选,逗号分隔 LINE 用户 ID', 'Optional, comma-separated LINE user IDs'),
lineAllowFromHint: _('LINE 用户 ID 通常以 U 开头;留空表示按策略默认处理。', 'LINE user IDs usually start with U; leave empty to use policy defaults.'),
lineGroupAllowFromPh: _('可选,逗号分隔群组或聊天室 ID', 'Optional, comma-separated group or room IDs'),
lineTokenOrFile: _('Channel Access Token 或 Token File', 'Channel Access Token or Token File'),
lineSecretOrFile: _('Channel Secret 或 Secret File', 'Channel Secret or Secret File'),
mattermostDesc: _('接入自托管 Mattermost支持私信、频道、slash command 和交互按钮', 'Connect self-hosted Mattermost with DMs, channels, slash commands, and interactions'),
mattermostGuide1: _('在 Mattermost 中创建 Bot Account并复制 Personal Access Token 作为 Bot Token', 'Create a Bot Account in Mattermost and copy its Personal Access Token as Bot Token'),
mattermostGuide2: _('填写 Mattermost 站点 Base URL例如 <code>https://mattermost.example.com</code>', 'Fill the Mattermost site Base URL, for example <code>https://mattermost.example.com</code>'),
mattermostGuide3: _('如启用 slash command配置 Callback Path / URL并确保 Mattermost 服务端能访问 Gateway', 'If slash commands are enabled, configure Callback Path / URL and ensure the Mattermost server can reach Gateway'),
mattermostGuide4: _('自托管内网地址需要显式开启 Private Network 开关,避免误连不受信任地址', 'For self-hosted private-network URLs, explicitly enable Private Network to avoid unsafe internal access'),
mattermostGuide5: _('设置私信/频道策略与允许列表,保存后面板会安装插件并重载 Gateway', 'Set DM/channel policy and allowlists; after saving, the panel installs the plugin and reloads Gateway'),
mattermostGuideFooter: _('<div style="margin-top:8px;font-size:var(--font-size-xs);color:var(--text-tertiary)">Mattermost 最小配置需要 Bot Token 与 Base URL。</div>', '<div style="margin-top:8px;font-size:var(--font-size-xs);color:var(--text-tertiary)">Mattermost minimally requires Bot Token and Base URL.</div>'),
mattermostBotTokenPh: _('Mattermost Bot Personal Access Token', 'Mattermost Bot Personal Access Token'),
mattermostBaseUrlHint: _('填写站点根地址,不要包含 /api/v4末尾斜杠会在运行时自动归一化。', 'Use the site root URL without /api/v4; trailing slash is normalized at runtime.'),
mattermostAllowFromPh: _('可选,逗号分隔用户名或用户 ID', 'Optional, comma-separated usernames or user IDs'),
mattermostAllowFromHint: _('用户名可带 @;选择“允许所有私信”时会自动加入 *。', 'Usernames may include @; choosing Allow all DMs automatically adds *.'),
mattermostGroupAllowFromPh: _('可选,逗号分隔频道 ID 或频道名称', 'Optional, comma-separated channel IDs or channel names'),
mattermostCallbackPathHint: _('用于 slash command/交互回调的 Gateway HTTP path。', 'Gateway HTTP path for slash command and interaction callbacks.'),
mattermostNameMatching: _('允许名称匹配', 'Allow Name Matching'),
mattermostNameMatchingHint: _('关闭时优先使用稳定 ID避免同名用户或频道误匹配。', 'When disabled, prefer stable IDs to avoid wrong matches with duplicate users or channels.'),
mattermostPrivateNetwork: _('允许内网地址', 'Allow Private Network'),
mattermostPrivateNetworkHint: _('仅在 Mattermost 部署于可信内网时开启。', 'Enable only when Mattermost is deployed on a trusted private network.'),
discordDesc: _('接入 Discord Bot支持服务器频道和私信', 'Connect a Discord Bot, supports server channels and DMs', '接入 Discord Bot支援伺服器頻道和私信', 'Discord Bot に接続'),
discordGuide1: _('前往 <a href="https://discord.com/developers/applications" target="_blank" rel="noopener">Discord Developer Portal</a> 创建 Application', '前往 <a href="https://discord.com/developers/applications" target="_blank" rel="noopener">Discord Developer Portal</a> 创建 Application', '前往 <a href="https://discord.com/developers/applications" target="_blank" rel="noopener">Discord Developer Portal</a> 建立 Application'),
discordGuide2: _('在 Bot 页面点击「Reset Token」获取 <strong>Bot Token</strong>', 'Click "Reset Token" on the Bot page to get the <strong>Bot Token</strong>', '在 Bot 頁面点擊「Reset Token」取得 <strong>Bot Token</strong>'),
@@ -127,6 +164,7 @@ export default {
groupDisabled: _('禁用群组', 'Disable groups', '停用群組'),
allowFromPh: _('可选,逗号分隔用户/频道 ID', 'Optional, comma-separated user/channel IDs', '可選,逗號分隔使用者/頻道 ID'),
allowFromHint: _('限制允许的用户或频道 ID留空不限制', 'Restrict to specific user or channel IDs; leave empty for no restriction', '限制允許的使用者或頻道 ID留空不限制'),
accountName: _('账号名称', 'Account Name'),
weixinLabel: _('微信', 'WeChat'),
weixinDesc: _('通过 openclaw-weixin 插件接入个人微信', 'Connect personal WeChat via the openclaw-weixin plugin', '通過 openclaw-weixin 外掛接入個人微信'),
weixinGuide1: _('本功能基于 <strong>openclaw-weixin</strong> 插件', 'This feature is powered by the <strong>openclaw-weixin</strong> plugin', '本功能基於 <strong>openclaw-weixin</strong> 外掛'),
@@ -171,7 +209,7 @@ export default {
groupAllRooms: _('所有房间', 'All rooms', '所有房間'),
groupAllTeams: _('所有团队', 'All teams', '所有團队'),
groupMentionBot: _('仅 @机器人时', 'Only when @bot', '僅 @機器人時'),
optionalEg: _('可选,如', 'Optional, e.g.', '可選,如'),
optionalEg: _('可选,如 {example}', 'Optional, e.g. {example}', '可選,如 {example}'),
editAccountLabel: _('编辑 {id}', 'Edit {id}', '編輯 {id}'),
bound: _('已绑定', 'Bound', '已綁定'),
notBoundAgent: _('未绑定 Agent', 'No Agent bound', '未綁定 Agent'),

View File

@@ -195,6 +195,78 @@ const PLATFORM_REGISTRY = {
pluginRequired: '@openclaw/zalouser@latest',
pluginId: 'zalouser',
},
line: {
label: 'LINE',
iconName: 'message-circle',
desc: t('channels.lineDesc'),
guide: [
t('channels.lineGuide1'),
t('channels.lineGuide2'),
t('channels.lineGuide3'),
t('channels.lineGuide4'),
t('channels.lineGuide5'),
],
guideFooter: t('channels.lineGuideFooter'),
fields: [
{ key: 'channelAccessToken', label: 'Channel Access Token', placeholder: t('channels.lineAccessTokenPh'), secret: true, required: false, hint: t('channels.lineAccessTokenHint') },
{ key: 'tokenFile', label: 'Token File', placeholder: t('channels.lineTokenFilePh'), required: false, hint: t('channels.lineTokenFileHint') },
{ key: 'channelSecret', label: 'Channel Secret', placeholder: t('channels.lineChannelSecretPh'), secret: true, required: false, hint: t('channels.lineChannelSecretHint') },
{ key: 'secretFile', label: 'Secret File', placeholder: t('channels.lineSecretFilePh'), required: false, hint: t('channels.lineSecretFileHint') },
{ key: 'webhookPath', label: 'Webhook Path', placeholder: '/line/webhook', required: false },
{ key: 'dmPolicy', label: t('channels.dmPolicy'), type: 'select', options: DM_POLICY_OPTIONS, required: false },
{ key: 'groupPolicy', label: t('channels.groupPolicy'), type: 'select', options: GROUP_POLICY_OPTIONS(t('channels.groupAllGroups')), required: false },
{ key: 'allowFrom', label: 'Allow From', placeholder: t('channels.lineAllowFromPh'), required: false, hint: t('channels.lineAllowFromHint') },
{ key: 'groupAllowFrom', label: 'Group Allow From', placeholder: t('channels.lineGroupAllowFromPh'), required: false, hint: t('channels.groupAllowFromHint') },
{ key: 'mediaMaxMb', label: 'Media Max MB', placeholder: '50', required: false },
{ key: 'responsePrefix', label: 'Response Prefix', placeholder: t('channels.optionalEg', { example: '[AI]' }), required: false },
],
requiredAny: [
{ keys: ['channelAccessToken', 'tokenFile'], label: t('channels.lineTokenOrFile') },
{ keys: ['channelSecret', 'secretFile'], label: t('channels.lineSecretOrFile') },
],
configKey: 'line',
pairingChannel: 'line',
pluginRequired: '@openclaw/line@latest',
pluginId: 'line',
},
mattermost: {
label: 'Mattermost',
iconName: 'message-square',
desc: t('channels.mattermostDesc'),
guide: [
t('channels.mattermostGuide1'),
t('channels.mattermostGuide2'),
t('channels.mattermostGuide3'),
t('channels.mattermostGuide4'),
t('channels.mattermostGuide5'),
],
guideFooter: t('channels.mattermostGuideFooter'),
fields: [
{ key: 'botToken', label: 'Bot Token', placeholder: t('channels.mattermostBotTokenPh'), secret: true, required: true },
{ key: 'baseUrl', label: 'Base URL', placeholder: 'https://mattermost.example.com', required: true, hint: t('channels.mattermostBaseUrlHint') },
{ key: 'name', label: t('channels.accountName'), placeholder: t('channels.optionalEg', { example: 'ops' }), required: false },
{ key: 'dmPolicy', label: t('channels.dmPolicy'), type: 'select', options: DM_POLICY_OPTIONS, required: false },
{ key: 'groupPolicy', label: t('channels.groupPolicy'), type: 'select', options: GROUP_POLICY_OPTIONS(t('channels.groupAllChannels'), { mention: true }), required: false },
{ key: 'allowFrom', label: 'Allow From', placeholder: t('channels.mattermostAllowFromPh'), required: false, hint: t('channels.mattermostAllowFromHint') },
{ key: 'groupAllowFrom', label: 'Group Allow From', placeholder: t('channels.mattermostGroupAllowFromPh'), required: false, hint: t('channels.groupAllowFromHint') },
{ key: 'replyToMode', label: 'Reply To Mode', type: 'select', required: false, options: [
{ value: '', label: t('channels.policyDefault') },
{ value: 'off', label: t('channels.disable') },
{ value: 'first', label: 'First' },
{ value: 'all', label: 'All' },
{ value: 'batched', label: 'Batched' },
] },
{ key: 'callbackPath', label: 'Slash Callback Path', placeholder: '/api/channels/mattermost/command', required: false, hint: t('channels.mattermostCallbackPathHint') },
{ key: 'callbackUrl', label: 'Slash Callback URL', placeholder: 'https://panel.example.com/api/channels/mattermost/command', required: false },
{ key: 'dangerouslyAllowNameMatching', label: t('channels.mattermostNameMatching'), type: 'select', options: BOOLEAN_OPTIONS, required: false, hint: t('channels.mattermostNameMatchingHint') },
{ key: 'dangerouslyAllowPrivateNetwork', label: t('channels.mattermostPrivateNetwork'), type: 'select', options: BOOLEAN_OPTIONS, required: false, hint: t('channels.mattermostPrivateNetworkHint') },
{ key: 'responsePrefix', label: 'Response Prefix', placeholder: t('channels.optionalEg', { example: '[AI]' }), required: false },
],
configKey: 'mattermost',
pairingChannel: 'mattermost',
pluginRequired: '@openclaw/mattermost@latest',
pluginId: 'mattermost',
},
discord: {
label: 'Discord',
iconName: 'hash',
@@ -505,7 +577,7 @@ function applyRouteIntent(page, state) {
// ── 已配置平台渲染 ──
// ── 多账号支持的平台:与 OpenClaw 的 accounts/defaultAccount 配置模型保持一致 ──
const MULTI_INSTANCE_PLATFORMS = ['telegram', 'discord', 'slack', 'feishu', 'dingtalk', 'dingtalk-connector', 'qqbot', 'zalo', 'zalouser']
const MULTI_INSTANCE_PLATFORMS = ['telegram', 'discord', 'slack', 'feishu', 'dingtalk', 'dingtalk-connector', 'qqbot', 'zalo', 'zalouser', 'line', 'mattermost']
function supportsMessagingMultiAccount(pid) {
return MULTI_INSTANCE_PLATFORMS.includes(pid)

View File

@@ -460,6 +460,115 @@ test('Zalo Personal 保存和诊断按二维码会话型渠道处理', () => {
assert.equal(result.checks.find(item => item.id === 'credentials')?.title, '登录/会话配置')
})
test('LINE 渠道保存会写入双凭证组合并支持多账号', () => {
const cfg = { channels: {} }
mergeOpenClawMessagingPlatformConfig(cfg, {
platform: 'line',
accountId: 'jp',
form: {
tokenFile: '/run/secrets/line-token',
secretFile: '/run/secrets/line-secret',
allowFrom: 'U123, U456',
groupAllowFrom: 'C123',
groupPolicy: 'open',
mediaMaxMb: '25',
webhookPath: '/line/webhook',
},
})
const account = cfg.channels.line.accounts.jp
assert.equal(cfg.channels.line.defaultAccount, 'jp')
assert.equal(account.tokenFile, '/run/secrets/line-token')
assert.equal(account.secretFile, '/run/secrets/line-secret')
assert.deepEqual(account.allowFrom, ['U123', 'U456'])
assert.deepEqual(account.groupAllowFrom, ['C123'])
assert.equal(account.groupPolicy, 'open')
assert.equal(account.mediaMaxMb, 25)
assert.equal(account.webhookPath, '/line/webhook')
})
test('LINE 诊断要求 token 与 secret 两组凭证各满足一项', () => {
const ready = buildOpenClawChannelDiagnosis({
platform: 'line',
configExists: true,
channelEnabled: true,
form: {
channelAccessToken: 'line-token',
secretFile: '/run/secrets/line-secret',
},
})
const missingSecret = buildOpenClawChannelDiagnosis({
platform: 'line',
configExists: true,
channelEnabled: true,
form: {
channelAccessToken: 'line-token',
},
})
assert.equal(ready.checks.find(item => item.id === 'credentials')?.ok, true)
assert.equal(missingSecret.checks.find(item => item.id === 'credentials')?.ok, false)
assert.match(missingSecret.checks.find(item => item.id === 'credentials')?.detail || '', /Channel Secret.*Secret File/)
})
test('Mattermost 渠道保存会写入嵌套命令和网络配置', () => {
const cfg = { channels: {} }
mergeOpenClawMessagingPlatformConfig(cfg, {
platform: 'mattermost',
accountId: 'ops',
form: {
botToken: 'mattermost-token',
baseUrl: 'https://mattermost.example.com/',
groupPolicy: 'mentioned',
allowFrom: '@alice, bob',
groupAllowFrom: 'town-square',
callbackPath: '/api/channels/mattermost/ops',
callbackUrl: 'https://panel.example.com/api/channels/mattermost/ops',
dangerouslyAllowNameMatching: 'true',
dangerouslyAllowPrivateNetwork: 'true',
replyToMode: 'all',
},
})
const account = cfg.channels.mattermost.accounts.ops
assert.equal(cfg.channels.mattermost.defaultAccount, 'ops')
assert.equal(account.botToken, 'mattermost-token')
assert.equal(account.baseUrl, 'https://mattermost.example.com/')
assert.equal(account.groupPolicy, 'open')
assert.equal(account.requireMention, true)
assert.deepEqual(account.allowFrom, ['@alice', 'bob'])
assert.deepEqual(account.groupAllowFrom, ['town-square'])
assert.equal(account.commands.callbackPath, '/api/channels/mattermost/ops')
assert.equal(account.commands.callbackUrl, 'https://panel.example.com/api/channels/mattermost/ops')
assert.equal(account.network.dangerouslyAllowPrivateNetwork, true)
assert.equal(account.dangerouslyAllowNameMatching, true)
assert.equal(account.replyToMode, 'all')
})
test('Mattermost 诊断要求 Bot Token 和 Base URL', () => {
const missingBaseUrl = buildOpenClawChannelDiagnosis({
platform: 'mattermost',
configExists: true,
channelEnabled: true,
form: { botToken: 'mattermost-token' },
})
const ready = buildOpenClawChannelDiagnosis({
platform: 'mattermost',
configExists: true,
channelEnabled: true,
form: {
botToken: 'mattermost-token',
baseUrl: 'https://mattermost.example.com',
},
})
assert.equal(missingBaseUrl.checks.find(item => item.id === 'credentials')?.ok, false)
assert.match(missingBaseUrl.checks.find(item => item.id === 'credentials')?.detail || '', /Base URL/)
assert.equal(ready.checks.find(item => item.id === 'credentials')?.ok, true)
})
test('Discord 渠道保存会保留运行时需要的 applicationId', () => {
const cfg = { channels: {} }