mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-29 20:30:00 +08:00
feat(channels): add Nextcloud Talk config compatibility
This commit is contained in:
@@ -2429,7 +2429,7 @@ function putWildcardAllowFromWhenOpen(entry, previousAllowFrom) {
|
||||
}
|
||||
|
||||
function platformSupportsTopLevelRequireMention(platform) {
|
||||
return ['feishu', 'slack', 'msteams', 'mattermost', 'googlechat'].includes(platformStorageKey(platform))
|
||||
return ['feishu', 'slack', 'msteams', 'mattermost', 'googlechat', 'nextcloud-talk'].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', 'line', 'mattermost', 'googlechat', 'imessage'].includes(storageKey)
|
||||
const needsAccessDefaults = ['telegram', 'discord', 'feishu', 'slack', 'signal', 'msteams', 'whatsapp', 'zalo', 'zalouser', 'line', 'mattermost', 'googlechat', 'nextcloud-talk', 'imessage'].includes(storageKey)
|
||||
const hasDmField = Object.hasOwn(normalized, 'dmPolicy') || needsAccessDefaults
|
||||
const hasGroupField = Object.hasOwn(normalized, 'groupPolicy') || needsAccessDefaults
|
||||
|
||||
@@ -2592,6 +2592,10 @@ const MESSAGING_CREDENTIAL_FIELDS = [
|
||||
'appPassword',
|
||||
'appSecret',
|
||||
'appToken',
|
||||
'apiPassword',
|
||||
'apiPasswordFile',
|
||||
'botSecret',
|
||||
'botSecretFile',
|
||||
'botToken',
|
||||
'channelAccessToken',
|
||||
'channelSecret',
|
||||
@@ -2671,6 +2675,11 @@ function channelAnyCredentialGroups(platform) {
|
||||
{ label: 'Channel Secret 或 Secret File', fields: [['channelSecret', 'Channel Secret'], ['secretFile', 'Secret File']] },
|
||||
]
|
||||
}
|
||||
if (storageKey === 'nextcloud-talk') {
|
||||
return [
|
||||
{ label: 'Bot Secret 或 Secret File', fields: [['botSecret', 'Bot Secret'], ['botSecretFile', 'Secret File']] },
|
||||
]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
@@ -2683,6 +2692,7 @@ const CHANNEL_DIAG_REQUIRED_FIELDS = {
|
||||
mattermost: [['botToken', 'Bot Token'], ['baseUrl', 'Base URL']],
|
||||
'synology-chat': [['token', 'Token'], ['incomingUrl', 'Incoming URL']],
|
||||
clickclack: [['baseUrl', 'Base URL'], ['token', 'Token'], ['workspace', 'Workspace']],
|
||||
'nextcloud-talk': [['baseUrl', 'Base URL']],
|
||||
signal: [['account', 'Signal 账号']],
|
||||
}
|
||||
|
||||
@@ -2709,10 +2719,11 @@ function channelDiagnosisCredentialsReady(platform, form = {}) {
|
||||
if (['zalouser', 'whatsapp'].includes(platformStorageKey(platform))) return true
|
||||
if (platformStorageKey(platform) === 'msteams') return msteamsCredentialMissingLabels(form).length === 0
|
||||
const requiredFields = requiredChannelCredentialFields(platform, form)
|
||||
const anyGroups = channelAnyCredentialGroups(platform)
|
||||
if (requiredFields.length) {
|
||||
return requiredFields.every(([key]) => hasConfiguredMessagingValue(form?.[key]))
|
||||
&& anyGroups.every(group => group.fields.some(([key]) => hasConfiguredMessagingValue(form?.[key])))
|
||||
}
|
||||
const anyGroups = channelAnyCredentialGroups(platform)
|
||||
if (anyGroups.length) {
|
||||
return anyGroups.every(group => group.fields.some(([key]) => hasConfiguredMessagingValue(form?.[key])))
|
||||
}
|
||||
@@ -2774,7 +2785,7 @@ export function buildOpenClawChannelDiagnosis({
|
||||
const credentialOk = ['zalouser', 'imessage', 'whatsapp'].includes(storageKey)
|
||||
? !!configExists
|
||||
: (requiredFields.length
|
||||
? missing.length === 0
|
||||
? missing.length === 0 && missingGroups.length === 0
|
||||
: (anyGroups.length
|
||||
? missingGroups.length === 0
|
||||
: (anyFields.length ? anyCredentialOk : hasAnyCredential)))
|
||||
@@ -2799,7 +2810,7 @@ export function buildOpenClawChannelDiagnosis({
|
||||
: '尚未保存 WhatsApp 渠道配置,请先填写并保存。')
|
||||
: (credentialOk
|
||||
? (requiredFields.length
|
||||
? `已填写 ${requiredFields.map(([, label]) => label).join(' / ')}。`
|
||||
? `已填写 ${requiredFields.map(([, label]) => label).join(' / ')}${anyGroups.length ? `;${anyGroups.map(group => group.label).join(';')}` : ''}。`
|
||||
: (anyGroups.length
|
||||
? `已填写 ${anyGroups.map(group => group.label).join(';')}。`
|
||||
: (anyFields.length ? `已填写 ${anyLabels} 其中一项。` : '已检测到可用凭证字段。')))
|
||||
@@ -3062,6 +3073,21 @@ export function buildMessagingPlatformFormValues(platform, saved = {}, options =
|
||||
return form
|
||||
}
|
||||
|
||||
if (storageKey === 'nextcloud-talk') {
|
||||
for (const key of ['name', 'baseUrl', 'botSecret', 'botSecretFile', 'apiUser', 'apiPassword', 'apiPasswordFile', 'webhookHost', 'webhookPath', 'webhookPublicUrl', 'chunkMode', 'responsePrefix']) {
|
||||
putSecretAwareFormValue(form, saved, key)
|
||||
}
|
||||
putBoolFormValue(form, saved, 'enabled')
|
||||
putAccessPolicyFormValues(form, saved, { mentionCompat: true })
|
||||
putCsvFormValue(form, saved, 'groupAllowFrom')
|
||||
putBoolFormValue(form, saved, 'blockStreaming')
|
||||
putBoolFormValue(form, saved?.network, 'dangerouslyAllowPrivateNetwork')
|
||||
for (const key of ['webhookPort', 'historyLimit', 'dmHistoryLimit', 'mediaMaxMb', 'textChunkLimit']) {
|
||||
if (typeof saved[key] === 'number') form[key] = String(saved[key])
|
||||
}
|
||||
return form
|
||||
}
|
||||
|
||||
if (storageKey === 'synology-chat') {
|
||||
for (const key of ['token', 'incomingUrl', 'nasHost', 'webhookPath', 'botName']) {
|
||||
putSecretAwareFormValue(form, saved, key)
|
||||
@@ -3826,6 +3852,23 @@ function buildOpenClawMessagingPlatformEntry(platform, form, currentSaved = {})
|
||||
for (const key of ['timeoutSeconds', 'reconnectMs']) {
|
||||
if (typeof form[key] === 'number') entry[key] = form[key]
|
||||
}
|
||||
} else if (storageKey === 'nextcloud-talk') {
|
||||
entry.enabled = typeof form.enabled === 'boolean' ? form.enabled : true
|
||||
for (const key of ['name', 'baseUrl', 'botSecret', 'botSecretFile', 'apiUser', 'apiPassword', 'apiPasswordFile', 'webhookHost', 'webhookPath', 'webhookPublicUrl', 'chunkMode', '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.blockStreaming === 'boolean') entry.blockStreaming = form.blockStreaming
|
||||
if (typeof form.dangerouslyAllowPrivateNetwork === 'boolean') {
|
||||
entry.network = { ...(currentSaved?.network || {}), dangerouslyAllowPrivateNetwork: form.dangerouslyAllowPrivateNetwork }
|
||||
}
|
||||
for (const key of ['webhookPort', 'historyLimit', 'dmHistoryLimit', 'mediaMaxMb', 'textChunkLimit']) {
|
||||
if (typeof form[key] === 'number') entry[key] = form[key]
|
||||
}
|
||||
} else if (storageKey === 'synology-chat') {
|
||||
for (const key of ['token', 'incomingUrl', 'nasHost', 'webhookPath', 'botName']) {
|
||||
if (form[key]) entry[key] = form[key]
|
||||
@@ -3867,7 +3910,7 @@ export function mergeOpenClawMessagingPlatformConfig(cfg, { platform, form, acco
|
||||
const currentSaved = resolvePlatformConfigEntry(cfg.channels?.[storageKey], platform, normalizedAccountId) || {}
|
||||
const entry = buildOpenClawMessagingPlatformEntry(platform, normalizedForm, currentSaved)
|
||||
applyMessagingPlatformEntry(cfg, storageKey, normalizedAccountId, entry)
|
||||
if (['zalo', 'zalouser', 'line', 'mattermost', 'clickclack', 'synology-chat', 'googlechat', 'msteams', 'imessage', 'whatsapp'].includes(storageKey)) {
|
||||
if (['zalo', 'zalouser', 'line', 'mattermost', 'clickclack', 'nextcloud-talk', 'synology-chat', 'googlechat', 'msteams', 'imessage', 'whatsapp'].includes(storageKey)) {
|
||||
ensureMessagingPluginAllowed(cfg, storageKey)
|
||||
}
|
||||
return { entry, accountId: normalizedAccountId, storageKey }
|
||||
@@ -5337,7 +5380,7 @@ const handlers = {
|
||||
} else {
|
||||
setRootChannelEntry(entry)
|
||||
}
|
||||
} else if (['line', 'mattermost', 'clickclack', 'synology-chat', 'googlechat', 'msteams', 'whatsapp'].includes(storageKey)) {
|
||||
} else if (['line', 'mattermost', 'clickclack', 'nextcloud-talk', 'synology-chat', 'googlechat', 'msteams', 'whatsapp'].includes(storageKey)) {
|
||||
const built = buildOpenClawMessagingPlatformEntry(platform, form, currentSaved)
|
||||
applyMessagingPlatformEntry(cfg, storageKey, normalizedAccountId, built)
|
||||
ensureMessagingPluginAllowed(cfg, storageKey)
|
||||
@@ -5346,7 +5389,7 @@ const handlers = {
|
||||
preserveMessagingCredentialRefs(entry, form, currentSaved)
|
||||
}
|
||||
|
||||
if (platform !== 'qqbot' && platform !== 'feishu' && platform !== 'dingtalk' && platform !== 'dingtalk-connector' && !['line', 'mattermost', 'clickclack', 'synology-chat', 'googlechat', 'msteams', 'whatsapp'].includes(storageKey)) {
|
||||
if (platform !== 'qqbot' && platform !== 'feishu' && platform !== 'dingtalk' && platform !== 'dingtalk-connector' && !['line', 'mattermost', 'clickclack', 'nextcloud-talk', 'synology-chat', 'googlechat', 'msteams', 'whatsapp'].includes(storageKey)) {
|
||||
preserveMessagingCredentialRefs(entry, form, currentSaved)
|
||||
// 合并模式:保留用户通过 CLI 或手动编辑的自定义字段
|
||||
applyMessagingPlatformEntry(cfg, storageKey, normalizedAccountId, entry)
|
||||
@@ -5474,6 +5517,9 @@ const handlers = {
|
||||
if (platform === 'clickclack') {
|
||||
return { valid: true, warnings: ['ClickClack 面板已完成基础字段校验;实际连通性请通过 Gateway 启动日志或 openclaw channels status --probe 验证。'] }
|
||||
}
|
||||
if (platform === 'nextcloud-talk') {
|
||||
return { valid: true, warnings: ['Nextcloud Talk 面板已完成基础字段校验;实际连通性请通过 Gateway 启动日志或 openclaw channels status --probe 验证。'] }
|
||||
}
|
||||
if (platform === 'discord') {
|
||||
try {
|
||||
const resp = await fetch('https://discord.com/api/v10/users/@me', {
|
||||
|
||||
@@ -139,6 +139,10 @@ fn preserve_messaging_credential_refs(
|
||||
"appPassword",
|
||||
"appSecret",
|
||||
"appToken",
|
||||
"apiPassword",
|
||||
"apiPasswordFile",
|
||||
"botSecret",
|
||||
"botSecretFile",
|
||||
"botToken",
|
||||
"channelAccessToken",
|
||||
"channelSecret",
|
||||
@@ -221,6 +225,10 @@ fn channel_root_has_messaging_credential(root: &Map<String, Value>) -> bool {
|
||||
"appPassword",
|
||||
"appSecret",
|
||||
"appToken",
|
||||
"apiPassword",
|
||||
"apiPasswordFile",
|
||||
"botSecret",
|
||||
"botSecretFile",
|
||||
"botToken",
|
||||
"channelAccessToken",
|
||||
"channelSecret",
|
||||
@@ -265,6 +273,7 @@ fn required_channel_credential_fields(
|
||||
("token", "Token"),
|
||||
("workspace", "Workspace"),
|
||||
],
|
||||
"nextcloud-talk" => vec![("baseUrl", "Base URL")],
|
||||
"signal" => vec![("account", "Signal 账号")],
|
||||
"slack" => {
|
||||
let mode = form_string(form, "mode");
|
||||
@@ -334,6 +343,13 @@ fn channel_any_credential_groups(
|
||||
],
|
||||
),
|
||||
],
|
||||
"nextcloud-talk" => vec![(
|
||||
"Bot Secret 或 Secret File",
|
||||
vec![
|
||||
("botSecret", "Bot Secret"),
|
||||
("botSecretFile", "Secret File"),
|
||||
],
|
||||
)],
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
@@ -349,12 +365,17 @@ fn channel_diagnosis_credentials_ready(platform: &str, form: &Map<String, Value>
|
||||
return msteams_credential_missing_labels(form).is_empty();
|
||||
}
|
||||
let required_fields = required_channel_credential_fields(platform, form);
|
||||
let any_groups = channel_any_credential_groups(platform);
|
||||
if !required_fields.is_empty() {
|
||||
return required_fields
|
||||
.iter()
|
||||
.all(|(key, _)| has_configured_messaging_value(form.get(*key)));
|
||||
.all(|(key, _)| has_configured_messaging_value(form.get(*key)))
|
||||
&& any_groups.iter().all(|(_, fields)| {
|
||||
fields
|
||||
.iter()
|
||||
.any(|(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
|
||||
@@ -476,7 +497,7 @@ fn build_openclaw_channel_diagnosis(
|
||||
let credential_ok = if matches!(storage_key, "zalouser" | "imessage" | "whatsapp") {
|
||||
config_exists
|
||||
} else if !required_fields.is_empty() {
|
||||
missing.is_empty()
|
||||
missing.is_empty() && missing_groups.is_empty()
|
||||
} else if !any_groups.is_empty() {
|
||||
missing_groups.is_empty()
|
||||
} else if !any_fields.is_empty() {
|
||||
@@ -514,7 +535,19 @@ fn build_openclaw_channel_diagnosis(
|
||||
}
|
||||
} else if credential_ok {
|
||||
if !required_fields.is_empty() {
|
||||
format!("已填写 {}。", required_labels)
|
||||
if !any_groups.is_empty() {
|
||||
format!(
|
||||
"已填写 {};{}。",
|
||||
required_labels,
|
||||
any_groups
|
||||
.iter()
|
||||
.map(|(label, _)| *label)
|
||||
.collect::<Vec<_>>()
|
||||
.join(";")
|
||||
)
|
||||
} else {
|
||||
format!("已填写 {}。", required_labels)
|
||||
}
|
||||
} else if !any_groups.is_empty() {
|
||||
format!(
|
||||
"已填写 {}。",
|
||||
@@ -808,7 +841,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" | "mattermost" | "googlechat"
|
||||
"feishu" | "slack" | "msteams" | "mattermost" | "googlechat" | "nextcloud-talk"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -839,6 +872,7 @@ fn normalize_messaging_platform_form(
|
||||
| "line"
|
||||
| "mattermost"
|
||||
| "googlechat"
|
||||
| "nextcloud-talk"
|
||||
| "imessage"
|
||||
);
|
||||
let has_dm_field = normalized.contains_key("dmPolicy") || needs_access_defaults;
|
||||
@@ -1787,6 +1821,40 @@ pub async fn read_platform_config(
|
||||
insert_number_as_string(&mut form, &saved, "timeoutSeconds");
|
||||
insert_number_as_string(&mut form, &saved, "reconnectMs");
|
||||
}
|
||||
"nextcloud-talk" => {
|
||||
for key in [
|
||||
"name",
|
||||
"baseUrl",
|
||||
"botSecret",
|
||||
"botSecretFile",
|
||||
"apiUser",
|
||||
"apiPassword",
|
||||
"apiPasswordFile",
|
||||
"webhookHost",
|
||||
"webhookPath",
|
||||
"webhookPublicUrl",
|
||||
"chunkMode",
|
||||
"responsePrefix",
|
||||
] {
|
||||
insert_secret_aware_form_value(&mut form, &saved, key);
|
||||
}
|
||||
insert_bool_as_string(&mut form, &saved, "enabled");
|
||||
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, "blockStreaming");
|
||||
if let Some(network) = saved.get("network") {
|
||||
insert_bool_as_string(&mut form, network, "dangerouslyAllowPrivateNetwork");
|
||||
}
|
||||
for key in [
|
||||
"webhookPort",
|
||||
"historyLimit",
|
||||
"dmHistoryLimit",
|
||||
"mediaMaxMb",
|
||||
"textChunkLimit",
|
||||
] {
|
||||
insert_number_as_string(&mut form, &saved, key);
|
||||
}
|
||||
}
|
||||
"synology-chat" => {
|
||||
for key in ["token", "incomingUrl", "nasHost", "webhookPath", "botName"] {
|
||||
insert_secret_aware_form_value(&mut form, &saved, key);
|
||||
@@ -2855,6 +2923,83 @@ pub async fn save_messaging_platform(
|
||||
)?;
|
||||
ensure_plugin_allowed(&mut cfg, "clickclack")?;
|
||||
}
|
||||
"nextcloud-talk" => {
|
||||
let base_url = form_string(form_obj, "baseUrl");
|
||||
let bot_secret = form_string(form_obj, "botSecret");
|
||||
let bot_secret_file = form_string(form_obj, "botSecretFile");
|
||||
if base_url.is_empty() {
|
||||
return Err("Nextcloud Talk Base URL 不能为空".into());
|
||||
}
|
||||
if bot_secret.is_empty()
|
||||
&& bot_secret_file.is_empty()
|
||||
&& !has_configured_messaging_value(form_obj.get("botSecret"))
|
||||
&& !has_configured_messaging_value(form_obj.get("botSecretFile"))
|
||||
{
|
||||
return Err("Nextcloud Talk Bot Secret 或 Secret File 至少填写一项".into());
|
||||
}
|
||||
|
||||
let mut entry = Map::new();
|
||||
entry.insert("enabled".into(), Value::Bool(true));
|
||||
put_bool_value_if_present(&mut entry, "enabled", form_obj.get("enabled"));
|
||||
for key in [
|
||||
"name",
|
||||
"baseUrl",
|
||||
"botSecret",
|
||||
"botSecretFile",
|
||||
"apiUser",
|
||||
"apiPassword",
|
||||
"apiPasswordFile",
|
||||
"webhookHost",
|
||||
"webhookPath",
|
||||
"webhookPublicUrl",
|
||||
"chunkMode",
|
||||
"responsePrefix",
|
||||
] {
|
||||
put_string(&mut entry, key, form_string(form_obj, key));
|
||||
}
|
||||
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, "blockStreaming", form_obj.get("blockStreaming"));
|
||||
for key in [
|
||||
"webhookPort",
|
||||
"historyLimit",
|
||||
"dmHistoryLimit",
|
||||
"mediaMaxMb",
|
||||
"textChunkLimit",
|
||||
] {
|
||||
put_number_value_if_present(&mut entry, key, form_obj.get(key));
|
||||
}
|
||||
if form_obj.contains_key("dangerouslyAllowPrivateNetwork") {
|
||||
let mut network = current_saved
|
||||
.get("network")
|
||||
.and_then(|v| v.as_object())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
put_bool_value_if_present(
|
||||
&mut network,
|
||||
"dangerouslyAllowPrivateNetwork",
|
||||
form_obj.get("dangerouslyAllowPrivateNetwork"),
|
||||
);
|
||||
if !network.is_empty() {
|
||||
entry.insert("network".into(), Value::Object(network));
|
||||
}
|
||||
}
|
||||
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, "nextcloud-talk")?;
|
||||
}
|
||||
"synology-chat" => {
|
||||
let token = form_string(form_obj, "token");
|
||||
let incoming_url = form_string(form_obj, "incomingUrl");
|
||||
@@ -3156,6 +3301,10 @@ pub async fn verify_bot_token(platform: String, form: Value) -> Result<Value, St
|
||||
"valid": true,
|
||||
"warnings": ["ClickClack 面板已完成基础字段校验;实际连通性请通过 Gateway 启动日志或 openclaw channels status --probe 验证"]
|
||||
})),
|
||||
"nextcloud-talk" => Ok(json!({
|
||||
"valid": true,
|
||||
"warnings": ["Nextcloud Talk 面板已完成基础字段校验;实际连通性请通过 Gateway 启动日志或 openclaw channels status --probe 验证"]
|
||||
})),
|
||||
_ => Ok(json!({
|
||||
"valid": true,
|
||||
"warnings": ["该平台暂不支持在线校验"]
|
||||
@@ -6304,6 +6453,114 @@ mod tests {
|
||||
.contains("Workspace"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_nextcloud_talk_form_preserves_self_hosted_runtime_fields() {
|
||||
let form = json!({
|
||||
"enabled": "true",
|
||||
"baseUrl": "https://cloud.example.com",
|
||||
"botSecret": "bot-secret",
|
||||
"apiUser": "openclaw-bot",
|
||||
"apiPassword": "app-password",
|
||||
"webhookPort": "8788",
|
||||
"webhookHost": "0.0.0.0",
|
||||
"webhookPath": "/nextcloud-talk-webhook",
|
||||
"webhookPublicUrl": "https://panel.example.com/nextcloud-talk-webhook",
|
||||
"dmPolicy": "allowlist",
|
||||
"allowFrom": "alice, bob",
|
||||
"groupPolicy": "mentioned",
|
||||
"groupAllowFrom": "room-token-1, room-token-2",
|
||||
"historyLimit": "80",
|
||||
"dmHistoryLimit": "20",
|
||||
"mediaMaxMb": "50",
|
||||
"textChunkLimit": "4000",
|
||||
"chunkMode": "newline",
|
||||
"blockStreaming": "true",
|
||||
"dangerouslyAllowPrivateNetwork": "true"
|
||||
});
|
||||
let normalized =
|
||||
normalize_messaging_platform_form("nextcloud-talk", form.as_object().expect("object"));
|
||||
|
||||
assert_eq!(
|
||||
normalized.get("enabled").and_then(|v| v.as_bool()),
|
||||
Some(true)
|
||||
);
|
||||
assert_eq!(
|
||||
normalized.get("webhookPort").and_then(|v| v.as_f64()),
|
||||
Some(8788.0)
|
||||
);
|
||||
assert_eq!(
|
||||
normalized.get("historyLimit").and_then(|v| v.as_f64()),
|
||||
Some(80.0)
|
||||
);
|
||||
assert_eq!(
|
||||
normalized.get("blockStreaming").and_then(|v| v.as_bool()),
|
||||
Some(true)
|
||||
);
|
||||
assert_eq!(
|
||||
normalized
|
||||
.get("dangerouslyAllowPrivateNetwork")
|
||||
.and_then(|v| v.as_bool()),
|
||||
Some(true)
|
||||
);
|
||||
assert_eq!(
|
||||
normalized.get("groupPolicy").and_then(|v| v.as_str()),
|
||||
Some("open")
|
||||
);
|
||||
assert_eq!(
|
||||
normalized.get("requireMention").and_then(|v| v.as_bool()),
|
||||
Some(true)
|
||||
);
|
||||
assert_eq!(
|
||||
normalized
|
||||
.get("allowFrom")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|items| items.len()),
|
||||
Some(2)
|
||||
);
|
||||
assert_eq!(
|
||||
normalized
|
||||
.get("groupAllowFrom")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|items| items.len()),
|
||||
Some(2)
|
||||
);
|
||||
assert!(channel_diagnosis_credentials_ready(
|
||||
"nextcloud-talk",
|
||||
&normalized
|
||||
));
|
||||
|
||||
let missing_secret = json!({
|
||||
"baseUrl": "https://cloud.example.com"
|
||||
});
|
||||
let missing = normalize_messaging_platform_form(
|
||||
"nextcloud-talk",
|
||||
missing_secret.as_object().expect("object"),
|
||||
);
|
||||
assert!(!channel_diagnosis_credentials_ready(
|
||||
"nextcloud-talk",
|
||||
&missing
|
||||
));
|
||||
let diagnosis = build_openclaw_channel_diagnosis(
|
||||
"nextcloud-talk",
|
||||
None,
|
||||
true,
|
||||
true,
|
||||
&missing,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert!(diagnosis
|
||||
.get("checks")
|
||||
.and_then(|v| v.as_array())
|
||||
.and_then(|items| items
|
||||
.iter()
|
||||
.find(|item| { item.get("id").and_then(|v| v.as_str()) == Some("credentials") }))
|
||||
.and_then(|item| item.get("detail"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.contains("Bot Secret 或 Secret File"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_form_readback_preserves_mention_policy_choice() {
|
||||
let saved = json!({
|
||||
|
||||
@@ -20,6 +20,7 @@ export const CHANNEL_LABELS = {
|
||||
nostr: 'Nostr',
|
||||
mattermost: 'Mattermost',
|
||||
clickclack: 'ClickClack',
|
||||
'nextcloud-talk': 'Nextcloud Talk',
|
||||
'openclaw-weixin': '微信',
|
||||
weixin: '微信',
|
||||
}
|
||||
|
||||
@@ -144,6 +144,23 @@ export default {
|
||||
clickclackSenderIsOwnerHint: _('开启后将 ClickClack 消息发送者视为 owner 身份,适合可信内部工作区。', 'Treat the ClickClack sender as owner when enabled; use only in trusted internal workspaces.'),
|
||||
clickclackDefaultToHint: _('默认发送目标,例如 channel:general、thread:123 或 dm:alice。', 'Default target, e.g. channel:general, thread:123, or dm:alice.'),
|
||||
clickclackAllowFromHint: _('可选,逗号分隔允许来源;留空按上游默认处理,* 表示允许全部。', 'Optional comma-separated allowed sources; leave empty for upstream defaults, * allows all.'),
|
||||
nextcloudTalkDesc: _('接入自托管 Nextcloud Talk,支持私聊、房间消息和 webhook 回调', 'Connect self-hosted Nextcloud Talk with DMs, rooms, and webhook callbacks'),
|
||||
nextcloudTalkGuide1: _('在 Nextcloud Talk 管理端创建 Bot,并复制 Bot Secret', 'Create a bot in Nextcloud Talk administration and copy the Bot Secret'),
|
||||
nextcloudTalkGuide2: _('填写 Nextcloud 站点 Base URL,例如 <code>https://cloud.example.com</code>', 'Fill the Nextcloud site Base URL, for example <code>https://cloud.example.com</code>'),
|
||||
nextcloudTalkGuide3: _('如需面板主动探测 Bot 能力,可填写 API User 与应用密码或密码文件', 'To let the panel probe bot capabilities, provide API User plus an app password or password file'),
|
||||
nextcloudTalkGuide4: _('保存后面板会启用 bundled Nextcloud Talk 插件并重载 Gateway;连通性以 Gateway 日志或 channels status 为准', 'After saving, the panel enables the bundled Nextcloud Talk plugin and reloads Gateway; verify connectivity through Gateway logs or channels status'),
|
||||
nextcloudTalkGuideFooter: _('<div style="margin-top:8px;font-size:var(--font-size-xs);color:var(--text-tertiary)">Nextcloud Talk 最小配置需要 Base URL,以及 Bot Secret 或 Secret File 其中一项。</div>', '<div style="margin-top:8px;font-size:var(--font-size-xs);color:var(--text-tertiary)">Nextcloud Talk minimally requires Base URL plus either Bot Secret or Secret File.</div>'),
|
||||
nextcloudTalkBaseUrlHint: _('填写 Nextcloud 站点根地址,不要包含 Talk API 路径;末尾斜杠会由上游归一化。', 'Use the Nextcloud site root URL without Talk API paths; upstream normalizes trailing slashes.'),
|
||||
nextcloudTalkBotSecretPh: _('Nextcloud Talk Bot Secret', 'Nextcloud Talk Bot Secret'),
|
||||
nextcloudTalkBotSecretHint: _('生产环境建议改用 Secret File 或 SecretRef,避免明文写入配置。', 'For production, prefer Secret File or SecretRef to avoid plain-text config secrets.'),
|
||||
nextcloudTalkBotSecretFileHint: _('Bot Secret 文件路径;与 Bot Secret 二选一。', 'Path to the Bot Secret file; use this or Bot Secret.'),
|
||||
nextcloudTalkApiPasswordPh: _('Nextcloud 应用密码', 'Nextcloud app password'),
|
||||
nextcloudTalkApiPasswordHint: _('可选;用于管理接口探测 Bot response feature。', 'Optional; used for admin API probes of the bot response feature.'),
|
||||
nextcloudTalkWebhookPublicUrlHint: _('Nextcloud 能访问的公网或反代地址,通常为面板/Gateway 的 webhook URL。', 'Public or reverse-proxy URL reachable by Nextcloud, usually the panel/Gateway webhook URL.'),
|
||||
nextcloudTalkAllowFromHint: _('可选,逗号分隔允许私聊的用户 ID;选择“允许所有私信”时会自动加入 *。', 'Optional comma-separated user IDs allowed for DMs; choosing Allow all DMs automatically adds *.'),
|
||||
nextcloudTalkGroupAllowFromHint: _('可选,逗号分隔允许的 room token。', 'Optional comma-separated room tokens.'),
|
||||
nextcloudTalkPrivateNetworkHint: _('仅在 Nextcloud 部署于可信内网且 Gateway 可以访问时开启。', 'Enable only when Nextcloud runs on a trusted private network reachable by Gateway.'),
|
||||
nextcloudTalkSecretOrFile: _('Bot Secret 或 Secret File', 'Bot Secret or Secret File'),
|
||||
synologyChatDesc: _('接入群晖 Synology Chat,适合 NAS 内网团队协作', 'Connect Synology Chat for NAS-hosted team messaging'),
|
||||
synologyChatGuide1: _('在 Synology Chat 管理后台创建 Bot,并复制 Token', 'Create a bot in Synology Chat administration and copy its Token'),
|
||||
synologyChatGuide2: _('配置 Incoming Webhook 或机器人发消息 URL,填入 Incoming URL', 'Configure an Incoming Webhook or bot post URL, then paste it as Incoming URL'),
|
||||
|
||||
@@ -310,6 +310,52 @@ const PLATFORM_REGISTRY = {
|
||||
pairingChannel: 'clickclack',
|
||||
pluginId: 'clickclack',
|
||||
},
|
||||
'nextcloud-talk': {
|
||||
label: 'Nextcloud Talk',
|
||||
iconName: 'message-square',
|
||||
desc: t('channels.nextcloudTalkDesc'),
|
||||
guide: [
|
||||
t('channels.nextcloudTalkGuide1'),
|
||||
t('channels.nextcloudTalkGuide2'),
|
||||
t('channels.nextcloudTalkGuide3'),
|
||||
t('channels.nextcloudTalkGuide4'),
|
||||
],
|
||||
guideFooter: t('channels.nextcloudTalkGuideFooter'),
|
||||
fields: [
|
||||
{ key: 'baseUrl', label: 'Base URL', placeholder: 'https://cloud.example.com', required: true, hint: t('channels.nextcloudTalkBaseUrlHint') },
|
||||
{ key: 'botSecret', label: 'Bot Secret', placeholder: t('channels.nextcloudTalkBotSecretPh'), secret: true, required: false, hint: t('channels.nextcloudTalkBotSecretHint') },
|
||||
{ key: 'botSecretFile', label: 'Secret File', placeholder: '/run/secrets/nextcloud-talk-bot-secret', required: false, hint: t('channels.nextcloudTalkBotSecretFileHint') },
|
||||
{ key: 'apiUser', label: 'API User', placeholder: t('channels.optionalEg', { example: 'openclaw-bot' }), required: false },
|
||||
{ key: 'apiPassword', label: 'API Password', placeholder: t('channels.nextcloudTalkApiPasswordPh'), secret: true, required: false, hint: t('channels.nextcloudTalkApiPasswordHint') },
|
||||
{ key: 'apiPasswordFile', label: 'API Password File', placeholder: '/run/secrets/nextcloud-talk-api-password', required: false },
|
||||
{ key: 'name', label: t('channels.accountName'), placeholder: t('channels.optionalEg', { example: 'work' }), required: false },
|
||||
{ key: 'webhookPort', label: 'Webhook Port', placeholder: '8788', required: false },
|
||||
{ key: 'webhookHost', label: 'Webhook Host', placeholder: '0.0.0.0', required: false },
|
||||
{ key: 'webhookPath', label: 'Webhook Path', placeholder: '/nextcloud-talk-webhook', required: false },
|
||||
{ key: 'webhookPublicUrl', label: 'Webhook Public URL', placeholder: 'https://panel.example.com/nextcloud-talk-webhook', required: false, hint: t('channels.nextcloudTalkWebhookPublicUrlHint') },
|
||||
{ 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.groupAllRooms'), { mention: true }), required: false },
|
||||
{ key: 'allowFrom', label: 'Allow From', placeholder: 'alice, bob', required: false, hint: t('channels.nextcloudTalkAllowFromHint') },
|
||||
{ key: 'groupAllowFrom', label: 'Group Allow From', placeholder: 'room-token-1, room-token-2', required: false, hint: t('channels.nextcloudTalkGroupAllowFromHint') },
|
||||
{ key: 'historyLimit', label: 'History Limit', placeholder: '80', required: false },
|
||||
{ key: 'dmHistoryLimit', label: 'DM History Limit', placeholder: '20', required: false },
|
||||
{ key: 'mediaMaxMb', label: 'Media Max MB', placeholder: '50', required: false },
|
||||
{ key: 'textChunkLimit', label: 'Text Chunk Limit', placeholder: '4000', required: false },
|
||||
{ key: 'chunkMode', label: 'Chunk Mode', type: 'select', options: [
|
||||
{ value: '', label: t('channels.policyDefault') },
|
||||
{ value: 'length', label: 'Length' },
|
||||
{ value: 'newline', label: 'Newline' },
|
||||
], required: false },
|
||||
{ key: 'blockStreaming', label: t('channels.signalBlockStreaming'), type: 'select', options: BOOLEAN_OPTIONS, required: false },
|
||||
{ key: 'dangerouslyAllowPrivateNetwork', label: t('channels.mattermostPrivateNetwork'), type: 'select', options: BOOLEAN_OPTIONS, required: false, hint: t('channels.nextcloudTalkPrivateNetworkHint') },
|
||||
{ key: 'responsePrefix', label: 'Response Prefix', placeholder: t('channels.optionalEg', { example: '[Talk]' }), required: false },
|
||||
],
|
||||
requiredAny: [{ keys: ['botSecret', 'botSecretFile'], label: t('channels.nextcloudTalkSecretOrFile') }],
|
||||
configKey: 'nextcloud-talk',
|
||||
pairingChannel: 'nextcloud-talk',
|
||||
pluginRequired: '@openclaw/nextcloud-talk@latest',
|
||||
pluginId: 'nextcloud-talk',
|
||||
},
|
||||
'synology-chat': {
|
||||
label: 'Synology Chat',
|
||||
iconName: 'message-square',
|
||||
@@ -832,7 +878,7 @@ function applyRouteIntent(page, state) {
|
||||
// ── 已配置平台渲染 ──
|
||||
|
||||
// ── 多账号支持的平台:与 OpenClaw 的 accounts/defaultAccount 配置模型保持一致 ──
|
||||
const MULTI_INSTANCE_PLATFORMS = ['telegram', 'discord', 'slack', 'feishu', 'dingtalk', 'dingtalk-connector', 'qqbot', 'zalo', 'zalouser', 'line', 'mattermost', 'clickclack', 'synology-chat', 'googlechat', 'signal']
|
||||
const MULTI_INSTANCE_PLATFORMS = ['telegram', 'discord', 'slack', 'feishu', 'dingtalk', 'dingtalk-connector', 'qqbot', 'zalo', 'zalouser', 'line', 'mattermost', 'clickclack', 'nextcloud-talk', 'synology-chat', 'googlechat', 'signal']
|
||||
|
||||
function supportsMessagingMultiAccount(pid) {
|
||||
return MULTI_INSTANCE_PLATFORMS.includes(pid)
|
||||
|
||||
@@ -249,6 +249,109 @@ test('ClickClack 读取会回显运行字段且诊断要求 Base URL、Token 和
|
||||
assert.equal(ready.checks.find(item => item.id === 'credentials')?.ok, true)
|
||||
})
|
||||
|
||||
test('Nextcloud Talk 渠道保存会写入自托管 Talk 字段并启用插件', () => {
|
||||
const cfg = { channels: {} }
|
||||
|
||||
mergeOpenClawMessagingPlatformConfig(cfg, {
|
||||
platform: 'nextcloud-talk',
|
||||
accountId: 'work',
|
||||
form: {
|
||||
enabled: 'true',
|
||||
name: 'Work Cloud',
|
||||
baseUrl: 'https://cloud.example.com',
|
||||
botSecret: 'bot-secret',
|
||||
apiUser: 'openclaw-bot',
|
||||
apiPassword: 'app-password',
|
||||
webhookPort: '8788',
|
||||
webhookHost: '0.0.0.0',
|
||||
webhookPath: '/nextcloud-talk-webhook',
|
||||
webhookPublicUrl: 'https://panel.example.com/nextcloud-talk-webhook',
|
||||
dmPolicy: 'allowlist',
|
||||
allowFrom: 'alice, bob',
|
||||
groupPolicy: 'mentioned',
|
||||
groupAllowFrom: 'room-token-1, room-token-2',
|
||||
historyLimit: '80',
|
||||
dmHistoryLimit: '20',
|
||||
mediaMaxMb: '50',
|
||||
textChunkLimit: '4000',
|
||||
chunkMode: 'newline',
|
||||
blockStreaming: 'true',
|
||||
responsePrefix: '[Talk]',
|
||||
dangerouslyAllowPrivateNetwork: 'true',
|
||||
},
|
||||
})
|
||||
|
||||
const root = cfg.channels['nextcloud-talk']
|
||||
const account = root.accounts.work
|
||||
assert.equal(root.defaultAccount, 'work')
|
||||
assert.equal(account.enabled, true)
|
||||
assert.equal(account.name, 'Work Cloud')
|
||||
assert.equal(account.baseUrl, 'https://cloud.example.com')
|
||||
assert.equal(account.botSecret, 'bot-secret')
|
||||
assert.equal(account.apiUser, 'openclaw-bot')
|
||||
assert.equal(account.apiPassword, 'app-password')
|
||||
assert.equal(account.webhookPort, 8788)
|
||||
assert.equal(account.webhookHost, '0.0.0.0')
|
||||
assert.equal(account.webhookPath, '/nextcloud-talk-webhook')
|
||||
assert.equal(account.webhookPublicUrl, 'https://panel.example.com/nextcloud-talk-webhook')
|
||||
assert.equal(account.dmPolicy, 'allowlist')
|
||||
assert.deepEqual(account.allowFrom, ['alice', 'bob'])
|
||||
assert.equal(account.groupPolicy, 'open')
|
||||
assert.equal(account.requireMention, true)
|
||||
assert.deepEqual(account.groupAllowFrom, ['room-token-1', 'room-token-2'])
|
||||
assert.equal(account.historyLimit, 80)
|
||||
assert.equal(account.dmHistoryLimit, 20)
|
||||
assert.equal(account.mediaMaxMb, 50)
|
||||
assert.equal(account.textChunkLimit, 4000)
|
||||
assert.equal(account.chunkMode, 'newline')
|
||||
assert.equal(account.blockStreaming, true)
|
||||
assert.equal(account.responsePrefix, '[Talk]')
|
||||
assert.deepEqual(account.network, { dangerouslyAllowPrivateNetwork: true })
|
||||
assert.equal(cfg.plugins.entries['nextcloud-talk'].enabled, true)
|
||||
})
|
||||
|
||||
test('Nextcloud Talk 读取和诊断支持 Bot Secret 或 Secret File 二选一', () => {
|
||||
const values = buildMessagingPlatformFormValues('nextcloud-talk', {
|
||||
enabled: true,
|
||||
baseUrl: 'https://cloud.example.com',
|
||||
botSecretFile: '/run/secrets/nextcloud-talk-secret',
|
||||
apiUser: 'openclaw-bot',
|
||||
allowFrom: ['alice'],
|
||||
groupPolicy: 'open',
|
||||
requireMention: true,
|
||||
groupAllowFrom: ['room-token-1'],
|
||||
webhookPort: 8788,
|
||||
historyLimit: 80,
|
||||
blockStreaming: true,
|
||||
network: { dangerouslyAllowPrivateNetwork: true },
|
||||
})
|
||||
const missingSecret = buildOpenClawChannelDiagnosis({
|
||||
platform: 'nextcloud-talk',
|
||||
configExists: true,
|
||||
channelEnabled: true,
|
||||
form: { baseUrl: 'https://cloud.example.com' },
|
||||
})
|
||||
const ready = buildOpenClawChannelDiagnosis({
|
||||
platform: 'nextcloud-talk',
|
||||
configExists: true,
|
||||
channelEnabled: true,
|
||||
form: values,
|
||||
})
|
||||
|
||||
assert.equal(values.enabled, 'true')
|
||||
assert.equal(values.baseUrl, 'https://cloud.example.com')
|
||||
assert.equal(values.botSecretFile, '/run/secrets/nextcloud-talk-secret')
|
||||
assert.equal(values.groupPolicy, 'mentioned')
|
||||
assert.equal(values.groupAllowFrom, 'room-token-1')
|
||||
assert.equal(values.webhookPort, '8788')
|
||||
assert.equal(values.historyLimit, '80')
|
||||
assert.equal(values.blockStreaming, 'true')
|
||||
assert.equal(values.dangerouslyAllowPrivateNetwork, 'true')
|
||||
assert.equal(missingSecret.checks.find(item => item.id === 'credentials')?.ok, false)
|
||||
assert.match(missingSecret.checks.find(item => item.id === 'credentials')?.detail || '', /Bot Secret.*Secret File/)
|
||||
assert.equal(ready.checks.find(item => item.id === 'credentials')?.ok, true)
|
||||
})
|
||||
|
||||
test('Signal 渠道保存会保留多账号和上游运行字段', () => {
|
||||
const cfg = { channels: {} }
|
||||
|
||||
|
||||
@@ -90,3 +90,29 @@ test('ClickClack 渠道 UI 会暴露自托管工作区配置字段', () => {
|
||||
}
|
||||
assert.match(clickclackBlock, /pluginId:\s*'clickclack'/)
|
||||
})
|
||||
|
||||
test('Nextcloud Talk 渠道 UI 会暴露自托管 Talk 配置字段', () => {
|
||||
const talkBlock = getRegistryBlock("'nextcloud-talk'")
|
||||
|
||||
for (const field of [
|
||||
'baseUrl',
|
||||
'botSecret',
|
||||
'botSecretFile',
|
||||
'apiUser',
|
||||
'apiPassword',
|
||||
'apiPasswordFile',
|
||||
'webhookPort',
|
||||
'webhookHost',
|
||||
'webhookPath',
|
||||
'webhookPublicUrl',
|
||||
'dmPolicy',
|
||||
'groupPolicy',
|
||||
'allowFrom',
|
||||
'groupAllowFrom',
|
||||
'dangerouslyAllowPrivateNetwork',
|
||||
'responsePrefix',
|
||||
]) {
|
||||
assert.match(talkBlock, new RegExp(`key:\\s*'${field}'`))
|
||||
}
|
||||
assert.match(talkBlock, /pluginId:\s*'nextcloud-talk'/)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user