feat(channels): add Tlon config compatibility

This commit is contained in:
晴天
2026-05-24 02:03:55 +08:00
parent 7e3bb71fca
commit 7b32b533fb
8 changed files with 561 additions and 9 deletions

View File

@@ -2498,7 +2498,7 @@ export function normalizeMessagingPlatformForm(platform, form = {}) {
normalized.allowedUserIds = csvToStringArray(normalized.allowedUserIds)
}
for (const key of ['promptStarters', 'delegatedAuthScopes', 'attachmentRoots', 'remoteAttachmentRoots', 'toolsAllow', 'allowedRoles', 'relays', 'channels', 'groups', 'mentionPatterns']) {
for (const key of ['promptStarters', 'delegatedAuthScopes', 'attachmentRoots', 'remoteAttachmentRoots', 'toolsAllow', 'allowedRoles', 'relays', 'channels', 'groups', 'mentionPatterns', 'groupChannels', 'dmAllowlist', 'groupInviteAllowlist', 'defaultAuthorizedShips']) {
if (Object.hasOwn(normalized, key)) normalized[key] = csvToStringArray(normalized[key])
}
@@ -2515,7 +2515,7 @@ export function normalizeMessagingPlatformForm(platform, form = {}) {
}
}
for (const key of ['dangerouslyAllowNameMatching', 'dangerouslyAllowPrivateNetwork', 'dangerouslyAllowInheritedWebhookPath', 'allowInsecureSsl', 'enabled', 'allowBots', 'blockStreaming', 'useManagedIdentity', 'typingIndicator', 'welcomeCard', 'groupWelcomeCard', 'feedbackEnabled', 'feedbackReflection', 'delegatedAuthEnabled', 'ssoEnabled', 'configWrites', 'includeAttachments', 'sendReadReceipts', 'coalesceSameSenderDms', 'selfChatMode', 'ackDirect', 'senderIsOwner', 'requireMention', 'tls', 'nickservEnabled', 'nickservRegister']) {
for (const key of ['dangerouslyAllowNameMatching', 'dangerouslyAllowPrivateNetwork', 'dangerouslyAllowInheritedWebhookPath', 'allowInsecureSsl', 'enabled', 'allowBots', 'blockStreaming', 'useManagedIdentity', 'typingIndicator', 'welcomeCard', 'groupWelcomeCard', 'feedbackEnabled', 'feedbackReflection', 'delegatedAuthEnabled', 'ssoEnabled', 'configWrites', 'includeAttachments', 'sendReadReceipts', 'coalesceSameSenderDms', 'selfChatMode', 'ackDirect', 'senderIsOwner', 'requireMention', 'tls', 'nickservEnabled', 'nickservRegister', 'autoDiscoverChannels', 'showModelSignature', 'autoAcceptDmInvites', 'autoAcceptGroupInvites']) {
if (Object.hasOwn(normalized, key)) {
const value = typeof normalized[key] === 'boolean'
? String(normalized[key])
@@ -2645,6 +2645,7 @@ const MESSAGING_CREDENTIAL_FIELDS = [
'botToken',
'channelAccessToken',
'channelSecret',
'code',
'clientId',
'clientSecret',
'refreshToken',
@@ -2744,6 +2745,7 @@ const CHANNEL_DIAG_REQUIRED_FIELDS = {
'nextcloud-talk': [['baseUrl', 'Base URL']],
nostr: [['privateKey', 'Private Key']],
irc: [['host', 'Host'], ['nick', 'Nick']],
tlon: [['ship', 'Ship'], ['url', 'URL'], ['code', 'Code']],
twitch: [['username', 'Username'], ['accessToken', 'Access Token'], ['clientId', 'Client ID'], ['channel', 'Channel']],
signal: [['account', 'Signal 账号']],
}
@@ -3211,6 +3213,26 @@ export function buildMessagingPlatformFormValues(platform, saved = {}, options =
return form
}
if (storageKey === 'tlon') {
const shared = options.channelRoot && typeof options.channelRoot === 'object'
? { ...options.channelRoot, ...saved }
: saved
if (options.channelRoot?.network && !saved.network) shared.network = options.channelRoot.network
for (const key of ['name', 'ship', 'url', 'code', 'responsePrefix', 'ownerShip']) {
putSecretAwareFormValue(form, shared, key)
}
putBoolFormValue(form, shared, 'enabled')
putBoolFormValue(form, shared?.network, 'dangerouslyAllowPrivateNetwork')
putCsvFormValue(form, shared, 'groupChannels')
putCsvFormValue(form, shared, 'dmAllowlist')
putCsvFormValue(form, shared, 'groupInviteAllowlist')
putCsvFormValue(form, shared, 'defaultAuthorizedShips')
for (const key of ['autoDiscoverChannels', 'showModelSignature', 'autoAcceptDmInvites', 'autoAcceptGroupInvites']) {
putBoolFormValue(form, shared, key)
}
return form
}
if (storageKey === 'synology-chat') {
for (const key of ['token', 'incomingUrl', 'nasHost', 'webhookPath', 'botName']) {
putSecretAwareFormValue(form, saved, key)
@@ -3660,6 +3682,7 @@ function secretAwareAccountDisplayValue(value) {
function resolvePlatformConfigEntry(channelRoot, platform, accountId) {
if (!channelRoot || typeof channelRoot !== 'object') return null
const accountKey = typeof accountId === 'string' ? accountId.trim() : ''
if (platformStorageKey(platform) === 'tlon' && accountKey === QQBOT_DEFAULT_ACCOUNT_ID) return channelRoot
if (accountKey) return channelRoot.accounts?.[accountKey] || channelRoot
if (platformStorageKey(platform) === 'qqbot' && !channelHasQqbotCredentials(channelRoot)) {
return channelRoot.accounts?.[QQBOT_DEFAULT_ACCOUNT_ID] || channelRoot
@@ -3674,7 +3697,7 @@ export function listPlatformAccounts(channelRoot) {
return Object.entries(channelRoot.accounts)
.map(([accountId, value]) => {
const entry = { accountId }
const displayId = ['appId', 'clientId', 'account', 'nick']
const displayId = ['appId', 'clientId', 'account', 'nick', 'ship']
.map(key => secretAwareAccountDisplayValue(value?.[key]))
.find(Boolean)
if (displayId) entry.appId = displayId
@@ -4056,6 +4079,24 @@ function buildOpenClawMessagingPlatformEntry(platform, form, currentSaved = {})
if (typeof form.nickservRegister === 'boolean') nickserv.register = form.nickservRegister
if (form.nickservRegisterEmail) nickserv.registerEmail = form.nickservRegisterEmail
if (Object.keys(nickserv).length) entry.nickserv = nickserv
} else if (storageKey === 'tlon') {
entry.enabled = typeof form.enabled === 'boolean' ? form.enabled : true
for (const key of ['name', 'ship', 'url', 'responsePrefix', 'ownerShip']) {
if (form[key]) entry[key] = form[key]
}
const code = resolveMessagingCredentialFormValueForSave({ form, current: currentSaved, formKey: 'code' })
if (code === undefined) delete entry.code
else entry.code = code
if (Array.isArray(form.groupChannels) && form.groupChannels.length) entry.groupChannels = form.groupChannels
if (Array.isArray(form.dmAllowlist) && form.dmAllowlist.length) entry.dmAllowlist = form.dmAllowlist
if (Array.isArray(form.groupInviteAllowlist) && form.groupInviteAllowlist.length) entry.groupInviteAllowlist = form.groupInviteAllowlist
if (Array.isArray(form.defaultAuthorizedShips) && form.defaultAuthorizedShips.length) entry.defaultAuthorizedShips = form.defaultAuthorizedShips
for (const key of ['autoDiscoverChannels', 'showModelSignature', 'autoAcceptDmInvites', 'autoAcceptGroupInvites']) {
if (typeof form[key] === 'boolean') entry[key] = form[key]
}
if (typeof form.dangerouslyAllowPrivateNetwork === 'boolean') {
entry.network = { ...(currentSaved?.network || {}), dangerouslyAllowPrivateNetwork: form.dangerouslyAllowPrivateNetwork }
}
} else if (storageKey === 'synology-chat') {
for (const key of ['token', 'incomingUrl', 'nasHost', 'webhookPath', 'botName']) {
if (form[key]) entry[key] = form[key]
@@ -4096,8 +4137,11 @@ export function mergeOpenClawMessagingPlatformConfig(cfg, { platform, form, acco
const normalizedAccountId = typeof accountId === 'string' ? accountId.trim() : ''
const currentSaved = resolvePlatformConfigEntry(cfg.channels?.[storageKey], platform, normalizedAccountId) || {}
const entry = buildOpenClawMessagingPlatformEntry(platform, normalizedForm, currentSaved)
applyMessagingPlatformEntry(cfg, storageKey, storageKey === 'nostr' ? '' : normalizedAccountId, entry)
if (['zalo', 'zalouser', 'line', 'mattermost', 'clickclack', 'nextcloud-talk', 'twitch', 'nostr', 'irc', 'synology-chat', 'googlechat', 'msteams', 'imessage', 'whatsapp'].includes(storageKey)) {
const targetAccountId = storageKey === 'nostr' || (storageKey === 'tlon' && normalizedAccountId === QQBOT_DEFAULT_ACCOUNT_ID)
? ''
: normalizedAccountId
applyMessagingPlatformEntry(cfg, storageKey, targetAccountId, entry)
if (['zalo', 'zalouser', 'line', 'mattermost', 'clickclack', 'nextcloud-talk', 'twitch', 'nostr', 'irc', 'tlon', 'synology-chat', 'googlechat', 'msteams', 'imessage', 'whatsapp'].includes(storageKey)) {
ensureMessagingPluginAllowed(cfg, storageKey)
}
return { entry, accountId: normalizedAccountId, storageKey }
@@ -5567,16 +5611,19 @@ const handlers = {
} else {
setRootChannelEntry(entry)
}
} else if (['line', 'mattermost', 'clickclack', 'nextcloud-talk', 'twitch', 'nostr', 'irc', 'synology-chat', 'googlechat', 'msteams', 'whatsapp'].includes(storageKey)) {
} else if (['line', 'mattermost', 'clickclack', 'nextcloud-talk', 'twitch', 'nostr', 'irc', 'tlon', 'synology-chat', 'googlechat', 'msteams', 'whatsapp'].includes(storageKey)) {
const built = buildOpenClawMessagingPlatformEntry(platform, form, currentSaved)
applyMessagingPlatformEntry(cfg, storageKey, storageKey === 'nostr' ? '' : normalizedAccountId, built)
const targetAccountId = storageKey === 'nostr' || (storageKey === 'tlon' && normalizedAccountId === QQBOT_DEFAULT_ACCOUNT_ID)
? ''
: normalizedAccountId
applyMessagingPlatformEntry(cfg, storageKey, targetAccountId, built)
ensureMessagingPluginAllowed(cfg, storageKey)
} else {
Object.assign(entry, form)
preserveMessagingCredentialRefs(entry, form, currentSaved)
}
if (platform !== 'qqbot' && platform !== 'feishu' && platform !== 'dingtalk' && platform !== 'dingtalk-connector' && !['line', 'mattermost', 'clickclack', 'nextcloud-talk', 'twitch', 'nostr', 'irc', 'synology-chat', 'googlechat', 'msteams', 'whatsapp'].includes(storageKey)) {
if (platform !== 'qqbot' && platform !== 'feishu' && platform !== 'dingtalk' && platform !== 'dingtalk-connector' && !['line', 'mattermost', 'clickclack', 'nextcloud-talk', 'twitch', 'nostr', 'irc', 'tlon', 'synology-chat', 'googlechat', 'msteams', 'whatsapp'].includes(storageKey)) {
preserveMessagingCredentialRefs(entry, form, currentSaved)
// 合并模式:保留用户通过 CLI 或手动编辑的自定义字段
applyMessagingPlatformEntry(cfg, storageKey, normalizedAccountId, entry)
@@ -5716,6 +5763,9 @@ const handlers = {
if (platform === 'irc') {
return { valid: true, warnings: ['IRC 面板已完成基础字段校验;实际连通性请通过 Gateway 启动日志或 openclaw channels status --probe 验证。'] }
}
if (platform === 'tlon') {
return { valid: true, warnings: ['Tlon 面板已完成基础字段校验;实际连通性请通过 Gateway 启动日志或 openclaw channels status --probe 验证。'] }
}
if (platform === 'discord') {
try {
const resp = await fetch('https://discord.com/api/v10/users/@me', {

View File

@@ -197,6 +197,7 @@ fn preserve_messaging_credential_refs(
"botToken",
"channelAccessToken",
"channelSecret",
"code",
"clientId",
"clientSecret",
"refreshToken",
@@ -286,6 +287,7 @@ fn channel_root_has_messaging_credential(root: &Map<String, Value>) -> bool {
"botToken",
"channelAccessToken",
"channelSecret",
"code",
"clientId",
"clientSecret",
"refreshToken",
@@ -332,6 +334,7 @@ fn required_channel_credential_fields(
"nextcloud-talk" => vec![("baseUrl", "Base URL")],
"nostr" => vec![("privateKey", "Private Key")],
"irc" => vec![("host", "Host"), ("nick", "Nick")],
"tlon" => vec![("ship", "Ship"), ("url", "URL"), ("code", "Code")],
"twitch" => vec![
("username", "Username"),
("accessToken", "Access Token"),
@@ -1081,6 +1084,10 @@ fn normalize_messaging_platform_form(
"channels",
"groups",
"mentionPatterns",
"groupChannels",
"dmAllowlist",
"groupInviteAllowlist",
"defaultAuthorizedShips",
] {
if normalized.contains_key(key) {
let items = json_array_from_csv_value(normalized.get(key));
@@ -1115,6 +1122,10 @@ fn normalize_messaging_platform_form(
"tls",
"nickservEnabled",
"nickservRegister",
"autoDiscoverChannels",
"showModelSignature",
"autoAcceptDmInvites",
"autoAcceptGroupInvites",
] {
if normalized.contains_key(key) {
let value = match normalized.get(key) {
@@ -1409,6 +1420,9 @@ fn resolve_platform_config_entry(
let root = channel_root?;
let account = account_id.map(str::trim).filter(|s| !s.is_empty());
if let Some(acct) = account {
if platform_storage_key(platform) == "tlon" && acct == QQBOT_DEFAULT_ACCOUNT_ID {
return Some(root.clone());
}
if let Some(value) = root.get("accounts").and_then(|a| a.get(acct)) {
return Some(value.clone());
}
@@ -2094,6 +2108,41 @@ pub async fn read_platform_config(
}
}
}
"tlon" => {
let mut shared = channel_root
.and_then(|root| root.as_object())
.cloned()
.unwrap_or_default();
if let Some(saved_obj) = saved.as_object() {
for (key, value) in saved_obj {
shared.insert(key.clone(), value.clone());
}
}
let shared = Value::Object(shared);
for key in ["name", "ship", "url", "code", "responsePrefix", "ownerShip"] {
insert_secret_aware_form_value(&mut form, &shared, key);
}
insert_bool_as_string(&mut form, &shared, "enabled");
if let Some(network) = shared.get("network") {
insert_bool_as_string(&mut form, network, "dangerouslyAllowPrivateNetwork");
}
for key in [
"groupChannels",
"dmAllowlist",
"groupInviteAllowlist",
"defaultAuthorizedShips",
] {
insert_array_as_csv(&mut form, &shared, key);
}
for key in [
"autoDiscoverChannels",
"showModelSignature",
"autoAcceptDmInvites",
"autoAcceptGroupInvites",
] {
insert_bool_as_string(&mut form, &shared, key);
}
}
"synology-chat" => {
for key in ["token", "incomingUrl", "nasHost", "webhookPath", "botName"] {
insert_secret_aware_form_value(&mut form, &saved, key);
@@ -3445,6 +3494,75 @@ pub async fn save_messaging_platform(
)?;
ensure_plugin_allowed(&mut cfg, "irc")?;
}
"tlon" => {
let ship = form_string(form_obj, "ship");
let url = form_string(form_obj, "url");
let code = form_string(form_obj, "code");
if ship.is_empty() {
return Err("Tlon Ship 不能为空".into());
}
if url.is_empty() {
return Err("Tlon URL 不能为空".into());
}
if code.is_empty() && !has_configured_messaging_value(form_obj.get("code")) {
return Err("Tlon Code 不能为空".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", "ship", "url", "responsePrefix", "ownerShip"] {
put_string(&mut entry, key, form_string(form_obj, key));
}
match resolve_messaging_credential_value_for_save(form_obj, &current_saved, "code") {
Some(value) => {
entry.insert("code".into(), value);
}
None => {
entry.remove("code");
}
}
for key in [
"groupChannels",
"dmAllowlist",
"groupInviteAllowlist",
"defaultAuthorizedShips",
] {
put_array_from_form_value(&mut entry, key, form_obj.get(key));
}
for key in [
"autoDiscoverChannels",
"showModelSignature",
"autoAcceptDmInvites",
"autoAcceptGroupInvites",
] {
put_bool_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, &current_saved);
let target_account_id =
if account_id.as_deref().map(str::trim) == Some(QQBOT_DEFAULT_ACCOUNT_ID) {
None
} else {
account_id.as_deref()
};
merge_channel_entry_for_account(channels_map, &storage_key, target_account_id, entry)?;
ensure_plugin_allowed(&mut cfg, "tlon")?;
}
"synology-chat" => {
let token = form_string(form_obj, "token");
let incoming_url = form_string(form_obj, "incomingUrl");
@@ -3762,6 +3880,10 @@ pub async fn verify_bot_token(platform: String, form: Value) -> Result<Value, St
"valid": true,
"warnings": ["IRC 面板已完成基础字段校验;实际连通性请通过 Gateway 启动日志或 openclaw channels status --probe 验证"]
})),
"tlon" => Ok(json!({
"valid": true,
"warnings": ["Tlon 面板已完成基础字段校验;实际连通性请通过 Gateway 启动日志或 openclaw channels status --probe 验证"]
})),
_ => Ok(json!({
"valid": true,
"warnings": ["该平台暂不支持在线校验"]
@@ -4686,6 +4808,7 @@ pub async fn list_configured_platforms() -> Result<Value, String> {
.or_else(|| account_display_value(acct_val, "clientId"))
.or_else(|| account_display_value(acct_val, "account"))
.or_else(|| account_display_value(acct_val, "nick"))
.or_else(|| account_display_value(acct_val, "ship"))
{
entry["appId"] = Value::String(display_id);
}
@@ -7296,6 +7419,139 @@ mod tests {
.contains("IRC 面板已完成基础字段校验"));
}
#[test]
fn normalize_tlon_form_preserves_ship_login_and_invite_fields() {
let form = json!({
"enabled": "true",
"name": "Main Ship",
"ship": "~sampel-palnet",
"url": "https://urbit.example.com",
"code": "lidlut-tabwed-pillex-ridrup",
"dangerouslyAllowPrivateNetwork": "true",
"groupChannels": "chat/~host-ship/general, chat/~host-ship/support",
"dmAllowlist": "zod, ~nec",
"groupInviteAllowlist": "~bus",
"autoDiscoverChannels": "true",
"showModelSignature": "false",
"responsePrefix": "[Tlon]",
"autoAcceptDmInvites": "true",
"autoAcceptGroupInvites": "false",
"ownerShip": "~sampel-palnet",
"defaultAuthorizedShips": "~zod, ~nec"
});
let normalized =
normalize_messaging_platform_form("tlon", form.as_object().expect("object"));
assert_eq!(
normalized.get("enabled").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("groupChannels")
.and_then(|v| v.as_array())
.map(|items| items.len()),
Some(2)
);
assert_eq!(
normalized
.get("dmAllowlist")
.and_then(|v| v.as_array())
.map(|items| items.len()),
Some(2)
);
assert_eq!(
normalized
.get("groupInviteAllowlist")
.and_then(|v| v.as_array())
.map(|items| items.len()),
Some(1)
);
assert_eq!(
normalized
.get("defaultAuthorizedShips")
.and_then(|v| v.as_array())
.map(|items| items.len()),
Some(2)
);
assert_eq!(
normalized
.get("autoDiscoverChannels")
.and_then(|v| v.as_bool()),
Some(true)
);
assert_eq!(
normalized
.get("showModelSignature")
.and_then(|v| v.as_bool()),
Some(false)
);
assert_eq!(
normalized
.get("autoAcceptDmInvites")
.and_then(|v| v.as_bool()),
Some(true)
);
assert_eq!(
normalized
.get("autoAcceptGroupInvites")
.and_then(|v| v.as_bool()),
Some(false)
);
assert!(channel_diagnosis_credentials_ready("tlon", &normalized));
let missing = normalize_messaging_platform_form(
"tlon",
json!({
"ship": "~sampel-palnet",
"url": "https://urbit.example.com"
})
.as_object()
.expect("object"),
);
assert!(!channel_diagnosis_credentials_ready("tlon", &missing));
let diagnosis =
build_openclaw_channel_diagnosis("tlon", 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("Code"));
}
#[test]
fn verify_tlon_token_returns_probe_guidance_warning() {
let result = tauri::async_runtime::block_on(verify_bot_token(
"tlon".to_string(),
json!({
"ship": "~sampel-palnet",
"url": "https://urbit.example.com",
"code": "lidlut-tabwed-pillex-ridrup"
}),
))
.expect("verify result");
assert_eq!(result.get("valid").and_then(|v| v.as_bool()), Some(true));
assert!(result
.get("warnings")
.and_then(|v| v.as_array())
.and_then(|items| items.first())
.and_then(|v| v.as_str())
.unwrap_or("")
.contains("Tlon 面板已完成基础字段校验"));
}
#[test]
fn channel_form_readback_preserves_mention_policy_choice() {
let saved = json!({

View File

@@ -18,6 +18,7 @@ export const CHANNEL_LABELS = {
imessage: 'iMessage',
line: 'LINE',
nostr: 'Nostr',
tlon: 'Tlon',
mattermost: 'Mattermost',
clickclack: 'ClickClack',
'nextcloud-talk': 'Nextcloud Talk',

View File

@@ -190,6 +190,28 @@ export default {
nostrDefaultAccountHint: _('上游当前通过根节点配置生成隐式账号,通常保持 default 即可。', 'Upstream currently derives an implicit account from the root config; default is usually enough.'),
nostrProfileAboutPh: _('可选,展示在 Nostr Profile 中的机器人介绍', 'Optional bot introduction shown in the Nostr profile'),
nostrProfileUrlHint: _('上游 Profile URL 字段要求使用 https:// 地址。', 'Upstream profile URL fields require https:// URLs.'),
tlonDesc: _('接入 Tlon / Urbit 消息网络,支持 Ship 登录、群组频道、私信白名单和邀请控制', 'Connect Tlon / Urbit messaging with ship login, group channels, DM allowlists, and invite controls'),
tlonGuide1: _('准备可访问的 Tlon Ship例如 <code>~sampel-palnet</code>,并确认 Gateway 网络可以访问该 Ship 的 URL', 'Prepare a reachable Tlon ship, for example <code>~sampel-palnet</code>, and make sure Gateway can access its URL'),
tlonGuide2: _('填写 Ship、URL 和登录 Code这是上游 Tlon 插件的最小可运行配置', 'Fill Ship, URL, and login Code; these are the minimum fields required by the upstream Tlon plugin'),
tlonGuide3: _('按需填写 Group Channels、DM Allowlist 与 Group Invite Allowlist限制机器人可响应和可接受邀请的范围', 'Set Group Channels, DM Allowlist, and Group Invite Allowlist as needed to limit where the bot can respond and accept invites'),
tlonGuide4: _('保存后面板会启用 Tlon 插件并重载 Gateway真实连通性以 Gateway 日志或 channels status --probe 为准', 'After saving, the panel enables the Tlon plugin and reloads Gateway; verify connectivity through Gateway logs or channels status --probe'),
tlonGuideFooter: _('<div style="margin-top:8px;font-size:var(--font-size-xs);color:var(--text-tertiary)">Tlon 最小配置需要 Ship、URL 与 Code命名账号会写入 <code>channels.tlon.accounts</code>,默认账号写入 <code>channels.tlon</code> 根节点。</div>', '<div style="margin-top:8px;font-size:var(--font-size-xs);color:var(--text-tertiary)">Tlon minimally requires Ship, URL, and Code; named accounts are written to <code>channels.tlon.accounts</code>, while the default account is written to the <code>channels.tlon</code> root.</div>'),
tlonShipHint: _('Ship 名通常以 ~ 开头,例如 ~sampel-palnet。', 'Ship names usually start with ~, for example ~sampel-palnet.'),
tlonUrlHint: _('Tlon 实例 URL建议使用 https://。如为内网地址,需确认 Gateway 可访问。', 'Tlon instance URL; https:// is recommended. For private addresses, make sure Gateway can reach it.'),
tlonCodeHint: _('Tlon 登录码;生产环境建议使用 SecretRef保持 SecretRef 占位不变即可保留引用。', 'Tlon login code; prefer SecretRef in production. Keep the SecretRef placeholder unchanged to preserve the reference.'),
tlonPrivateNetworkHint: _('仅在 Tlon URL 是可信内网地址时开启;该开关会写入 network.dangerouslyAllowPrivateNetwork。', 'Enable only for trusted private Tlon URLs; this writes network.dangerouslyAllowPrivateNetwork.'),
tlonGroupChannelsHint: _('群组频道 Nest多个值用逗号分隔例如 chat/~host-ship/general。', 'Group channel nests separated by commas, for example chat/~host-ship/general.'),
tlonDmAllowlistHint: _('允许发起私信的 Ship 列表,多个值用逗号分隔。', 'Ships allowed to start DMs, separated by commas.'),
tlonGroupInviteAllowlistHint: _('允许邀请机器人加入群组的 Ship 列表,留空表示按上游默认处理。', 'Ships allowed to invite the bot to groups; leave empty to use upstream defaults.'),
tlonAutoDiscoverChannels: _('自动发现频道', 'Auto-discover channels'),
tlonAutoDiscoverChannelsHint: _('开启后上游会尝试发现 Ship 可用频道;大型 Ship 上建议先显式填写 Group Channels。', 'When enabled, upstream tries to discover available channels; for large ships, explicitly setting Group Channels first is recommended.'),
tlonShowModelSignature: _('显示模型签名', 'Show model signature'),
tlonAutoAcceptDmInvites: _('自动接受私信邀请', 'Auto-accept DM invites'),
tlonAutoAcceptDmInvitesHint: _('通常仅配合 DM Allowlist 使用,避免接受未知 Ship。', 'Usually use this with DM Allowlist to avoid accepting unknown ships.'),
tlonAutoAcceptGroupInvites: _('自动接受群组邀请', 'Auto-accept group invites'),
tlonAutoAcceptGroupInvitesHint: _('建议同时配置 Group Invite Allowlist避免恶意群组邀请。', 'Configure Group Invite Allowlist as well to avoid malicious group invites.'),
tlonOwnerShipHint: _('用于审批请求的 Owner Ship可填写当前 Ship 或专门的管理 Ship。', 'Owner ship that receives approval requests; use the current ship or a dedicated management ship.'),
tlonDefaultAuthorizedShipsHint: _('默认授权 Ship 列表,多个值用逗号分隔。', 'Default authorized ships separated by commas.'),
ircDesc: _('接入 IRC 网络支持服务器账号、NickServ 登录、频道白名单和提及策略', 'Connect IRC networks with server accounts, NickServ login, channel allowlists, and mention policy'),
ircGuide1: _('准备 IRC 服务器地址,例如 <code>irc.libera.chat</code>,并确认 Gateway 网络可以连接', 'Prepare the IRC server host, for example <code>irc.libera.chat</code>, and make sure Gateway can connect'),
ircGuide2: _('填写机器人 Nick如服务器需要 SASL 或密码,可填写 Server Password 或文件路径', 'Fill the bot Nick; if the server requires SASL or a password, provide Server Password or a file path'),

View File

@@ -418,6 +418,39 @@ const PLATFORM_REGISTRY = {
pluginRequired: '@openclaw/nostr@latest',
pluginId: 'nostr',
},
tlon: {
label: 'Tlon',
iconName: 'globe',
desc: t('channels.tlonDesc'),
guide: [
t('channels.tlonGuide1'),
t('channels.tlonGuide2'),
t('channels.tlonGuide3'),
t('channels.tlonGuide4'),
],
guideFooter: t('channels.tlonGuideFooter'),
fields: [
{ key: 'name', label: t('channels.accountName'), placeholder: t('channels.optionalEg', { example: 'main-ship' }), required: false },
{ key: 'ship', label: 'Ship', placeholder: '~sampel-palnet', required: true, hint: t('channels.tlonShipHint') },
{ key: 'url', label: 'URL', placeholder: 'https://urbit.example.com', required: true, hint: t('channels.tlonUrlHint') },
{ key: 'code', label: 'Code', placeholder: 'lidlut-tabwed-pillex-ridrup', secret: true, required: true, hint: t('channels.tlonCodeHint') },
{ key: 'dangerouslyAllowPrivateNetwork', label: t('channels.mattermostPrivateNetwork'), type: 'select', options: BOOLEAN_OPTIONS, required: false, hint: t('channels.tlonPrivateNetworkHint') },
{ key: 'groupChannels', label: 'Group Channels', placeholder: 'chat/~host-ship/general, chat/~host-ship/support', required: false, hint: t('channels.tlonGroupChannelsHint') },
{ key: 'dmAllowlist', label: 'DM Allowlist', placeholder: '~zod, ~nec', required: false, hint: t('channels.tlonDmAllowlistHint') },
{ key: 'groupInviteAllowlist', label: 'Group Invite Allowlist', placeholder: '~zod, ~nec', required: false, hint: t('channels.tlonGroupInviteAllowlistHint') },
{ key: 'autoDiscoverChannels', label: t('channels.tlonAutoDiscoverChannels'), type: 'select', options: BOOLEAN_OPTIONS, required: false, hint: t('channels.tlonAutoDiscoverChannelsHint') },
{ key: 'showModelSignature', label: t('channels.tlonShowModelSignature'), type: 'select', options: BOOLEAN_OPTIONS, required: false },
{ key: 'responsePrefix', label: 'Response Prefix', placeholder: t('channels.optionalEg', { example: '[Tlon]' }), required: false },
{ key: 'autoAcceptDmInvites', label: t('channels.tlonAutoAcceptDmInvites'), type: 'select', options: BOOLEAN_OPTIONS, required: false, hint: t('channels.tlonAutoAcceptDmInvitesHint') },
{ key: 'autoAcceptGroupInvites', label: t('channels.tlonAutoAcceptGroupInvites'), type: 'select', options: BOOLEAN_OPTIONS, required: false, hint: t('channels.tlonAutoAcceptGroupInvitesHint') },
{ key: 'ownerShip', label: 'Owner Ship', placeholder: '~sampel-palnet', required: false, hint: t('channels.tlonOwnerShipHint') },
{ key: 'defaultAuthorizedShips', label: 'Default Authorized Ships', placeholder: '~zod, ~nec', required: false, hint: t('channels.tlonDefaultAuthorizedShipsHint') },
],
configKey: 'tlon',
pairingChannel: 'tlon',
pluginRequired: '@openclaw/tlon@latest',
pluginId: 'tlon',
},
irc: {
label: 'IRC',
iconName: 'hash',
@@ -993,7 +1026,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', 'nextcloud-talk', 'twitch', 'irc', 'synology-chat', 'googlechat', 'signal']
const MULTI_INSTANCE_PLATFORMS = ['telegram', 'discord', 'slack', 'feishu', 'dingtalk', 'dingtalk-connector', 'qqbot', 'zalo', 'zalouser', 'line', 'mattermost', 'clickclack', 'nextcloud-talk', 'twitch', 'tlon', 'irc', 'synology-chat', 'googlechat', 'signal']
function supportsMessagingMultiAccount(pid) {
return MULTI_INSTANCE_PLATFORMS.includes(pid)

View File

@@ -1149,6 +1149,18 @@ mark {
/* === 移动端响应式 === */
@media (max-width: 768px) {
.modal .btn {
min-height: 44px;
justify-content: center;
}
.modal .btn-sm,
.modal .btn-xs {
min-height: 40px;
padding: var(--space-sm) var(--space-md);
}
.modal-actions {
flex-wrap: wrap;
}
.stat-cards {
grid-template-columns: repeat(2, 1fr);
gap: var(--space-sm);

View File

@@ -689,6 +689,145 @@ test('IRC 读取和诊断会回显服务器、NickServ 与频道字段', () => {
assert.equal(ready.checks.find(item => item.id === 'credentials')?.ok, true)
})
test('Tlon 默认账号保存会写入上游根节点配置并启用插件', () => {
const cfg = { channels: {} }
mergeOpenClawMessagingPlatformConfig(cfg, {
platform: 'tlon',
accountId: 'default',
form: {
enabled: 'true',
name: 'Main Ship',
ship: '~sampel-palnet',
url: 'https://urbit.example.com',
code: 'lidlut-tabwed-pillex-ridrup',
dangerouslyAllowPrivateNetwork: 'true',
groupChannels: 'chat/~host-ship/general, chat/~host-ship/support',
dmAllowlist: 'zod, ~nec',
groupInviteAllowlist: '~bus',
autoDiscoverChannels: 'true',
showModelSignature: 'false',
responsePrefix: '[Tlon]',
autoAcceptDmInvites: 'true',
autoAcceptGroupInvites: 'false',
ownerShip: '~sampel-palnet',
defaultAuthorizedShips: '~zod, ~nec',
},
})
const root = cfg.channels.tlon
assert.equal(root.enabled, true)
assert.equal(root.name, 'Main Ship')
assert.equal(root.ship, '~sampel-palnet')
assert.equal(root.url, 'https://urbit.example.com')
assert.equal(root.code, 'lidlut-tabwed-pillex-ridrup')
assert.deepEqual(root.network, { dangerouslyAllowPrivateNetwork: true })
assert.deepEqual(root.groupChannels, ['chat/~host-ship/general', 'chat/~host-ship/support'])
assert.deepEqual(root.dmAllowlist, ['zod', '~nec'])
assert.deepEqual(root.groupInviteAllowlist, ['~bus'])
assert.equal(root.autoDiscoverChannels, true)
assert.equal(root.showModelSignature, false)
assert.equal(root.responsePrefix, '[Tlon]')
assert.equal(root.autoAcceptDmInvites, true)
assert.equal(root.autoAcceptGroupInvites, false)
assert.equal(root.ownerShip, '~sampel-palnet')
assert.deepEqual(root.defaultAuthorizedShips, ['~zod', '~nec'])
assert.equal(Object.hasOwn(root, 'accounts'), false)
assert.equal(cfg.plugins.entries.tlon.enabled, true)
})
test('Tlon 命名账号保存会写入 accounts 并保留根节点共享字段', () => {
const cfg = {
channels: {
tlon: {
enabled: true,
defaultAuthorizedShips: ['~zod'],
},
},
}
mergeOpenClawMessagingPlatformConfig(cfg, {
platform: 'tlon',
accountId: 'support',
form: {
enabled: 'true',
ship: '~support-palnet',
url: 'https://support.example.com',
code: 'fodwyt-ragful-sivnys-nivlup',
groupChannels: 'chat/~host-ship/support',
dmAllowlist: '~zod',
autoDiscoverChannels: 'false',
ownerShip: '~support-palnet',
},
})
const root = cfg.channels.tlon
const account = root.accounts.support
assert.deepEqual(root.defaultAuthorizedShips, ['~zod'])
assert.equal(account.enabled, true)
assert.equal(account.ship, '~support-palnet')
assert.equal(account.url, 'https://support.example.com')
assert.equal(account.code, 'fodwyt-ragful-sivnys-nivlup')
assert.deepEqual(account.groupChannels, ['chat/~host-ship/support'])
assert.deepEqual(account.dmAllowlist, ['~zod'])
assert.equal(account.autoDiscoverChannels, false)
assert.equal(account.ownerShip, '~support-palnet')
assert.equal(cfg.plugins.entries.tlon.enabled, true)
})
test('Tlon 读取和诊断会回显 Ship、URL、登录码和安全配置', () => {
const values = buildMessagingPlatformFormValues('tlon', {
enabled: true,
name: 'Main Ship',
ship: '~sampel-palnet',
url: 'https://urbit.example.com',
code: 'lidlut-tabwed-pillex-ridrup',
network: { dangerouslyAllowPrivateNetwork: true },
groupChannels: ['chat/~host-ship/general', 'chat/~host-ship/support'],
dmAllowlist: ['~zod', '~nec'],
groupInviteAllowlist: ['~bus'],
autoDiscoverChannels: true,
showModelSignature: false,
responsePrefix: '[Tlon]',
autoAcceptDmInvites: true,
autoAcceptGroupInvites: false,
ownerShip: '~sampel-palnet',
defaultAuthorizedShips: ['~zod', '~nec'],
})
const missingCode = buildOpenClawChannelDiagnosis({
platform: 'tlon',
configExists: true,
channelEnabled: true,
form: { ship: '~sampel-palnet', url: 'https://urbit.example.com' },
})
const ready = buildOpenClawChannelDiagnosis({
platform: 'tlon',
configExists: true,
channelEnabled: true,
form: values,
})
assert.equal(values.enabled, 'true')
assert.equal(values.name, 'Main Ship')
assert.equal(values.ship, '~sampel-palnet')
assert.equal(values.url, 'https://urbit.example.com')
assert.equal(values.code, 'lidlut-tabwed-pillex-ridrup')
assert.equal(values.dangerouslyAllowPrivateNetwork, 'true')
assert.equal(values.groupChannels, 'chat/~host-ship/general, chat/~host-ship/support')
assert.equal(values.dmAllowlist, '~zod, ~nec')
assert.equal(values.groupInviteAllowlist, '~bus')
assert.equal(values.autoDiscoverChannels, 'true')
assert.equal(values.showModelSignature, 'false')
assert.equal(values.responsePrefix, '[Tlon]')
assert.equal(values.autoAcceptDmInvites, 'true')
assert.equal(values.autoAcceptGroupInvites, 'false')
assert.equal(values.ownerShip, '~sampel-palnet')
assert.equal(values.defaultAuthorizedShips, '~zod, ~nec')
assert.equal(missingCode.checks.find(item => item.id === 'credentials')?.ok, false)
assert.match(missingCode.checks.find(item => item.id === 'credentials')?.detail || '', /Code/)
assert.equal(ready.checks.find(item => item.id === 'credentials')?.ok, true)
})
test('Signal 渠道保存会保留多账号和上游运行字段', () => {
const cfg = { channels: {} }
@@ -943,6 +1082,20 @@ test('渠道账号列表会使用 IRC Nick 作为安全展示标识', () => {
])
})
test('渠道账号列表会使用 Tlon Ship 作为安全展示标识', () => {
const accounts = listPlatformAccounts({
accounts: {
support: {
ship: '~support-palnet',
},
},
})
assert.deepEqual(accounts, [
{ accountId: 'support', appId: '~support-palnet' },
])
})
test('渠道保存时 clientId 未改动 SecretRef 占位会保留原始引用', () => {
const secretRef = { source: 'env', provider: 'default', id: 'DINGTALK_CLIENT_ID' }
const value = resolveMessagingCredentialValueForSave({

View File

@@ -197,3 +197,28 @@ test('IRC 渠道 UI 会暴露服务器、NickServ 与频道访问配置字段',
assert.match(ircBlock, /pluginRequired:\s*'@openclaw\/irc@latest'/)
assert.match(ircBlock, /pluginId:\s*'irc'/)
})
test('Tlon 渠道 UI 会暴露 Urbit 登录、频道和邀请安全配置字段', () => {
const tlonBlock = getRegistryBlock('tlon')
for (const field of [
'ship',
'url',
'code',
'dangerouslyAllowPrivateNetwork',
'groupChannels',
'dmAllowlist',
'groupInviteAllowlist',
'autoDiscoverChannels',
'showModelSignature',
'responsePrefix',
'autoAcceptDmInvites',
'autoAcceptGroupInvites',
'ownerShip',
'defaultAuthorizedShips',
]) {
assert.match(tlonBlock, new RegExp(`key:\\s*'${field}'`))
}
assert.match(tlonBlock, /pluginRequired:\s*'@openclaw\/tlon@latest'/)
assert.match(tlonBlock, /pluginId:\s*'tlon'/)
})