mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-29 04:10:00 +08:00
feat(channels): add LINE and Mattermost config
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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, ¤t_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, ¤t_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();
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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: {} }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user