feat(channels): add Nostr config compatibility

This commit is contained in:
晴天
2026-05-23 09:25:07 +08:00
parent dcc3751ded
commit 326c5597df
6 changed files with 357 additions and 6 deletions

View File

@@ -2472,7 +2472,7 @@ export function normalizeMessagingPlatformForm(platform, form = {}) {
normalized.allowedUserIds = csvToStringArray(normalized.allowedUserIds)
}
for (const key of ['promptStarters', 'delegatedAuthScopes', 'attachmentRoots', 'remoteAttachmentRoots', 'toolsAllow', 'allowedRoles']) {
for (const key of ['promptStarters', 'delegatedAuthScopes', 'attachmentRoots', 'remoteAttachmentRoots', 'toolsAllow', 'allowedRoles', 'relays']) {
if (Object.hasOwn(normalized, key)) normalized[key] = csvToStringArray(normalized[key])
}
@@ -2607,6 +2607,7 @@ const MESSAGING_CREDENTIAL_FIELDS = [
'gatewayPassword',
'gatewayToken',
'password',
'privateKey',
'secretFile',
'serviceAccount',
'serviceAccountFile',
@@ -2696,6 +2697,7 @@ const CHANNEL_DIAG_REQUIRED_FIELDS = {
'synology-chat': [['token', 'Token'], ['incomingUrl', 'Incoming URL']],
clickclack: [['baseUrl', 'Base URL'], ['token', 'Token'], ['workspace', 'Workspace']],
'nextcloud-talk': [['baseUrl', 'Base URL']],
nostr: [['privateKey', 'Private Key']],
twitch: [['username', 'Username'], ['accessToken', 'Access Token'], ['clientId', 'Client ID'], ['channel', 'Channel']],
signal: [['account', 'Signal 账号']],
}
@@ -3106,6 +3108,31 @@ export function buildMessagingPlatformFormValues(platform, saved = {}, options =
return form
}
if (storageKey === 'nostr') {
putSecretAwareFormValue(form, saved, 'privateKey')
for (const key of ['name', 'defaultAccount', 'dmPolicy']) {
putStringFormValue(form, saved, key)
}
putBoolFormValue(form, saved, 'enabled')
putCsvFormValue(form, saved, 'relays')
putCsvFormValue(form, saved, 'allowFrom')
const profile = saved.profile && typeof saved.profile === 'object' ? saved.profile : {}
const profileMap = {
name: 'profileName',
displayName: 'profileDisplayName',
about: 'profileAbout',
picture: 'profilePicture',
banner: 'profileBanner',
website: 'profileWebsite',
nip05: 'profileNip05',
lud16: 'profileLud16',
}
for (const [sourceKey, formKey] of Object.entries(profileMap)) {
if (typeof profile[sourceKey] === 'string') form[formKey] = profile[sourceKey]
}
return form
}
if (storageKey === 'synology-chat') {
for (const key of ['token', 'incomingUrl', 'nasHost', 'webhookPath', 'botName']) {
putSecretAwareFormValue(form, saved, key)
@@ -3898,6 +3925,28 @@ function buildOpenClawMessagingPlatformEntry(platform, form, currentSaved = {})
for (const key of ['expiresIn', 'obtainmentTimestamp']) {
if (typeof form[key] === 'number') entry[key] = form[key]
}
} else if (storageKey === 'nostr') {
entry.enabled = typeof form.enabled === 'boolean' ? form.enabled : true
for (const key of ['name', 'defaultAccount', 'privateKey', 'dmPolicy']) {
if (form[key]) entry[key] = form[key]
}
if (Array.isArray(form.relays) && form.relays.length) entry.relays = form.relays
if (Array.isArray(form.allowFrom) && form.allowFrom.length) entry.allowFrom = form.allowFrom
const profileMap = {
profileName: 'name',
profileDisplayName: 'displayName',
profileAbout: 'about',
profilePicture: 'picture',
profileBanner: 'banner',
profileWebsite: 'website',
profileNip05: 'nip05',
profileLud16: 'lud16',
}
const profile = {}
for (const [formKey, targetKey] of Object.entries(profileMap)) {
if (form[formKey]) profile[targetKey] = form[formKey]
}
if (Object.keys(profile).length) entry.profile = profile
} else if (storageKey === 'synology-chat') {
for (const key of ['token', 'incomingUrl', 'nasHost', 'webhookPath', 'botName']) {
if (form[key]) entry[key] = form[key]
@@ -3938,8 +3987,8 @@ 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, normalizedAccountId, entry)
if (['zalo', 'zalouser', 'line', 'mattermost', 'clickclack', 'nextcloud-talk', 'twitch', 'synology-chat', 'googlechat', 'msteams', 'imessage', 'whatsapp'].includes(storageKey)) {
applyMessagingPlatformEntry(cfg, storageKey, storageKey === 'nostr' ? '' : normalizedAccountId, entry)
if (['zalo', 'zalouser', 'line', 'mattermost', 'clickclack', 'nextcloud-talk', 'twitch', 'nostr', 'synology-chat', 'googlechat', 'msteams', 'imessage', 'whatsapp'].includes(storageKey)) {
ensureMessagingPluginAllowed(cfg, storageKey)
}
return { entry, accountId: normalizedAccountId, storageKey }
@@ -5409,16 +5458,16 @@ const handlers = {
} else {
setRootChannelEntry(entry)
}
} else if (['line', 'mattermost', 'clickclack', 'nextcloud-talk', 'twitch', 'synology-chat', 'googlechat', 'msteams', 'whatsapp'].includes(storageKey)) {
} else if (['line', 'mattermost', 'clickclack', 'nextcloud-talk', 'twitch', 'nostr', 'synology-chat', 'googlechat', 'msteams', 'whatsapp'].includes(storageKey)) {
const built = buildOpenClawMessagingPlatformEntry(platform, form, currentSaved)
applyMessagingPlatformEntry(cfg, storageKey, normalizedAccountId, built)
applyMessagingPlatformEntry(cfg, storageKey, storageKey === 'nostr' ? '' : normalizedAccountId, 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', 'synology-chat', 'googlechat', 'msteams', 'whatsapp'].includes(storageKey)) {
if (platform !== 'qqbot' && platform !== 'feishu' && platform !== 'dingtalk' && platform !== 'dingtalk-connector' && !['line', 'mattermost', 'clickclack', 'nextcloud-talk', 'twitch', 'nostr', 'synology-chat', 'googlechat', 'msteams', 'whatsapp'].includes(storageKey)) {
preserveMessagingCredentialRefs(entry, form, currentSaved)
// 合并模式:保留用户通过 CLI 或手动编辑的自定义字段
applyMessagingPlatformEntry(cfg, storageKey, normalizedAccountId, entry)
@@ -5552,6 +5601,9 @@ const handlers = {
if (platform === 'twitch') {
return { valid: true, warnings: ['Twitch 面板已完成基础字段校验;实际连通性请通过 Gateway 启动日志或 openclaw channels status --probe 验证。'] }
}
if (platform === 'nostr') {
return { valid: true, warnings: ['Nostr 面板已完成基础字段校验;实际连通性请通过 Gateway 启动日志或 openclaw channels status --probe 验证。'] }
}
if (platform === 'discord') {
try {
const resp = await fetch('https://discord.com/api/v10/users/@me', {

View File

@@ -152,6 +152,7 @@ fn preserve_messaging_credential_refs(
"gatewayPassword",
"gatewayToken",
"password",
"privateKey",
"secretFile",
"serviceAccount",
"serviceAccountFile",
@@ -239,6 +240,7 @@ fn channel_root_has_messaging_credential(root: &Map<String, Value>) -> bool {
"gatewayPassword",
"gatewayToken",
"password",
"privateKey",
"secretFile",
"serviceAccount",
"serviceAccountFile",
@@ -276,6 +278,7 @@ fn required_channel_credential_fields(
("workspace", "Workspace"),
],
"nextcloud-talk" => vec![("baseUrl", "Base URL")],
"nostr" => vec![("privateKey", "Private Key")],
"twitch" => vec![
("username", "Username"),
("accessToken", "Access Token"),
@@ -964,6 +967,7 @@ fn normalize_messaging_platform_form(
"remoteAttachmentRoots",
"toolsAllow",
"allowedRoles",
"relays",
] {
if normalized.contains_key(key) {
let items = json_array_from_csv_value(normalized.get(key));
@@ -1886,6 +1890,31 @@ pub async fn read_platform_config(
insert_number_as_string(&mut form, &saved, "expiresIn");
insert_number_as_string(&mut form, &saved, "obtainmentTimestamp");
}
"nostr" => {
insert_secret_aware_form_value(&mut form, &saved, "privateKey");
for key in ["name", "defaultAccount", "dmPolicy"] {
insert_string_if_present(&mut form, &saved, key);
}
insert_bool_as_string(&mut form, &saved, "enabled");
insert_array_as_csv(&mut form, &saved, "relays");
insert_array_as_csv(&mut form, &saved, "allowFrom");
if let Some(profile) = saved.get("profile") {
for (source_key, form_key) in [
("name", "profileName"),
("displayName", "profileDisplayName"),
("about", "profileAbout"),
("picture", "profilePicture"),
("banner", "profileBanner"),
("website", "profileWebsite"),
("nip05", "profileNip05"),
("lud16", "profileLud16"),
] {
if let Some(v) = profile.get(source_key).and_then(|v| v.as_str()) {
form.insert(form_key.into(), Value::String(v.into()));
}
}
}
}
"synology-chat" => {
for key in ["token", "incomingUrl", "nasHost", "webhookPath", "botName"] {
insert_secret_aware_form_value(&mut form, &saved, key);
@@ -3083,6 +3112,47 @@ pub async fn save_messaging_platform(
)?;
ensure_plugin_allowed(&mut cfg, "twitch")?;
}
"nostr" => {
let private_key = form_string(form_obj, "privateKey");
if private_key.is_empty() && !has_configured_messaging_value(form_obj.get("privateKey"))
{
return Err("Nostr Private Key 不能为空".into());
}
let root_saved = channels_map
.get(storage_key.as_str())
.cloned()
.unwrap_or(Value::Null);
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", "defaultAccount", "privateKey", "dmPolicy"] {
put_string(&mut entry, key, form_string(form_obj, key));
}
put_array_from_form_value(&mut entry, "relays", form_obj.get("relays"));
put_array_from_form_value(&mut entry, "allowFrom", form_obj.get("allowFrom"));
let mut profile = Map::new();
for (form_key, target_key) in [
("profileName", "name"),
("profileDisplayName", "displayName"),
("profileAbout", "about"),
("profilePicture", "picture"),
("profileBanner", "banner"),
("profileWebsite", "website"),
("profileNip05", "nip05"),
("profileLud16", "lud16"),
] {
put_string(&mut profile, target_key, form_string(form_obj, form_key));
}
if !profile.is_empty() {
entry.insert("profile".into(), Value::Object(profile));
}
preserve_messaging_credential_refs(&mut entry, form_obj, &root_saved);
merge_channel_entry_for_account(channels_map, &storage_key, None, entry)?;
ensure_plugin_allowed(&mut cfg, "nostr")?;
}
"synology-chat" => {
let token = form_string(form_obj, "token");
let incoming_url = form_string(form_obj, "incomingUrl");
@@ -3392,6 +3462,10 @@ pub async fn verify_bot_token(platform: String, form: Value) -> Result<Value, St
"valid": true,
"warnings": ["Twitch 面板已完成基础字段校验;实际连通性请通过 Gateway 启动日志或 openclaw channels status --probe 验证"]
})),
"nostr" => Ok(json!({
"valid": true,
"warnings": ["Nostr 面板已完成基础字段校验;实际连通性请通过 Gateway 启动日志或 openclaw channels status --probe 验证"]
})),
_ => Ok(json!({
"valid": true,
"warnings": ["该平台暂不支持在线校验"]
@@ -6727,6 +6801,74 @@ mod tests {
.contains("Access Token"));
}
#[test]
fn normalize_nostr_form_preserves_relay_access_and_profile_fields() {
let form = json!({
"enabled": "true",
"name": "nostr-bot",
"defaultAccount": "default",
"privateKey": "nsec1example",
"relays": "wss://relay.damus.io, wss://nos.lol",
"dmPolicy": "allowlist",
"allowFrom": "npub1sender, 0123456789abcdef",
"profileName": "openclaw",
"profileDisplayName": "OpenClaw Bot",
"profileAbout": "Nostr DM assistant",
"profilePicture": "https://example.com/avatar.png",
"profileWebsite": "https://example.com",
"profileNip05": "openclaw@example.com",
"profileLud16": "openclaw@example.com"
});
let normalized =
normalize_messaging_platform_form("nostr", form.as_object().expect("object"));
assert_eq!(
normalized.get("enabled").and_then(|v| v.as_bool()),
Some(true)
);
assert_eq!(
normalized.get("dmPolicy").and_then(|v| v.as_str()),
Some("allowlist")
);
assert_eq!(
normalized
.get("relays")
.and_then(|v| v.as_array())
.map(|items| items.len()),
Some(2)
);
assert_eq!(
normalized
.get("allowFrom")
.and_then(|v| v.as_array())
.map(|items| items.len()),
Some(2)
);
assert!(channel_diagnosis_credentials_ready("nostr", &normalized));
let missing = normalize_messaging_platform_form(
"nostr",
json!({
"relays": "wss://relay.damus.io"
})
.as_object()
.expect("object"),
);
assert!(!channel_diagnosis_credentials_ready("nostr", &missing));
let diagnosis =
build_openclaw_channel_diagnosis("nostr", 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("Private Key"));
}
#[test]
fn channel_form_readback_preserves_mention_policy_choice() {
let saved = json!({

View File

@@ -178,6 +178,18 @@ export default {
twitchRefreshTokenHint: _('可选;与 Client Secret 配合用于刷新 Access Token。', 'Optional; used with Client Secret to refresh the access token.'),
twitchExpiresInHint: _('Access Token 有效期,单位秒。', 'Access token lifetime in seconds.'),
twitchObtainmentTimestampHint: _('Token 获取时间戳;上游用于判断刷新时机。', 'Token obtainment timestamp; upstream uses it to decide refresh timing.'),
nostrDesc: _('接入 Nostr 私信网络,支持 Relay 列表、私信访问策略和公开 Profile 配置', 'Connect Nostr direct messages with relay lists, DM access policy, and public profile settings'),
nostrGuide1: _('准备机器人账号的 Nostr 私钥,支持 nsec 或 64 位 hex 格式', 'Prepare the bot account Nostr private key, using nsec or 64-character hex format'),
nostrGuide2: _('填写 Gateway 可访问的 Relay URL多个地址用逗号分隔', 'Fill relay URLs reachable by Gateway; separate multiple URLs with commas'),
nostrGuide3: _('按需设置 DM Policy 和 Allow From限制允许发起私信的 npub 或公钥', 'Set DM Policy and Allow From as needed to limit which npub or public keys can start DMs'),
nostrGuide4: _('保存后面板会启用 Nostr 插件并重载 Gateway连通性以 Gateway 日志或 channels status 为准', 'After saving, the panel enables the Nostr plugin and reloads Gateway; verify connectivity through Gateway logs or channels status'),
nostrGuideFooter: _('<div style="margin-top:8px;font-size:var(--font-size-xs);color:var(--text-tertiary)">Nostr 最小配置需要 Private KeyRelay 留空时上游会使用默认 Relay。</div>', '<div style="margin-top:8px;font-size:var(--font-size-xs);color:var(--text-tertiary)">Nostr minimally requires Private Key; upstream uses default relays when Relay URLs are empty.</div>'),
nostrPrivateKeyHint: _('生产环境建议使用 SecretRef明文私钥会写入 openclaw.json。', 'Prefer SecretRef in production; plain-text private keys are written to openclaw.json.'),
nostrRelaysHint: _('可填写 ws:// 或 wss:// Relay URL多个地址用逗号、分号或换行分隔。', 'Use ws:// or wss:// relay URLs; separate multiple values with commas, semicolons, or new lines.'),
nostrAllowFromHint: _('可选,逗号分隔允许发起私信的 npub 或 64 位公钥dmPolicy=allowlist 时生效。', 'Optional comma-separated npub or 64-character public keys allowed to start DMs; used when dmPolicy=allowlist.'),
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.'),
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'),

View File

@@ -386,6 +386,38 @@ const PLATFORM_REGISTRY = {
pluginRequired: '@openclaw/twitch@latest',
pluginId: 'twitch',
},
nostr: {
label: 'Nostr',
iconName: 'message-square',
desc: t('channels.nostrDesc'),
guide: [
t('channels.nostrGuide1'),
t('channels.nostrGuide2'),
t('channels.nostrGuide3'),
t('channels.nostrGuide4'),
],
guideFooter: t('channels.nostrGuideFooter'),
fields: [
{ key: 'privateKey', label: 'Private Key', placeholder: 'nsec1...', secret: true, required: true, hint: t('channels.nostrPrivateKeyHint') },
{ key: 'relays', label: 'Relay URLs', placeholder: 'wss://relay.damus.io, wss://nos.lol', required: false, hint: t('channels.nostrRelaysHint') },
{ key: 'dmPolicy', label: t('channels.dmPolicy'), type: 'select', options: DM_POLICY_OPTIONS, required: false },
{ key: 'allowFrom', label: 'Allow From', placeholder: 'npub1..., 0123456789abcdef', required: false, hint: t('channels.nostrAllowFromHint') },
{ key: 'name', label: t('channels.accountName'), placeholder: t('channels.optionalEg', { example: 'nostr-bot' }), required: false },
{ key: 'defaultAccount', label: 'Default Account', placeholder: 'default', required: false, hint: t('channels.nostrDefaultAccountHint') },
{ key: 'profileName', label: 'Profile Name', placeholder: 'openclaw', required: false },
{ key: 'profileDisplayName', label: 'Profile Display Name', placeholder: 'OpenClaw Bot', required: false },
{ key: 'profileAbout', label: 'Profile About', placeholder: t('channels.nostrProfileAboutPh'), multiline: true, required: false },
{ key: 'profilePicture', label: 'Profile Picture URL', placeholder: 'https://example.com/avatar.png', required: false, hint: t('channels.nostrProfileUrlHint') },
{ key: 'profileBanner', label: 'Profile Banner URL', placeholder: 'https://example.com/banner.png', required: false, hint: t('channels.nostrProfileUrlHint') },
{ key: 'profileWebsite', label: 'Profile Website', placeholder: 'https://example.com', required: false, hint: t('channels.nostrProfileUrlHint') },
{ key: 'profileNip05', label: 'NIP-05', placeholder: 'openclaw@example.com', required: false },
{ key: 'profileLud16', label: 'LUD-16', placeholder: 'openclaw@example.com', required: false },
],
configKey: 'nostr',
pairingChannel: 'nostr',
pluginRequired: '@openclaw/nostr@latest',
pluginId: 'nostr',
},
'synology-chat': {
label: 'Synology Chat',
iconName: 'message-square',

View File

@@ -439,6 +439,97 @@ test('Twitch 读取和诊断会回显访问控制与刷新 Token 字段', () =>
assert.equal(ready.checks.find(item => item.id === 'credentials')?.ok, true)
})
test('Nostr 渠道保存会写入上游根节点配置并启用插件', () => {
const cfg = { channels: {} }
mergeOpenClawMessagingPlatformConfig(cfg, {
platform: 'nostr',
form: {
enabled: 'true',
name: 'nostr-bot',
defaultAccount: 'default',
privateKey: 'nsec1example',
relays: 'wss://relay.damus.io, wss://nos.lol',
dmPolicy: 'allowlist',
allowFrom: 'npub1sender, 0123456789abcdef',
profileName: 'openclaw',
profileDisplayName: 'OpenClaw Bot',
profileAbout: 'Nostr DM assistant',
profilePicture: 'https://example.com/avatar.png',
profileWebsite: 'https://example.com',
profileNip05: 'openclaw@example.com',
profileLud16: 'openclaw@example.com',
},
})
const root = cfg.channels.nostr
assert.equal(root.enabled, true)
assert.equal(root.name, 'nostr-bot')
assert.equal(root.defaultAccount, 'default')
assert.equal(root.privateKey, 'nsec1example')
assert.deepEqual(root.relays, ['wss://relay.damus.io', 'wss://nos.lol'])
assert.equal(root.dmPolicy, 'allowlist')
assert.deepEqual(root.allowFrom, ['npub1sender', '0123456789abcdef'])
assert.equal(root.profile.name, 'openclaw')
assert.equal(root.profile.displayName, 'OpenClaw Bot')
assert.equal(root.profile.about, 'Nostr DM assistant')
assert.equal(root.profile.picture, 'https://example.com/avatar.png')
assert.equal(root.profile.website, 'https://example.com')
assert.equal(root.profile.nip05, 'openclaw@example.com')
assert.equal(root.profile.lud16, 'openclaw@example.com')
assert.equal(Object.hasOwn(root, 'accounts'), false)
assert.equal(cfg.plugins.entries.nostr.enabled, true)
})
test('Nostr 读取和诊断会回显 relay、访问控制和 profile 字段', () => {
const values = buildMessagingPlatformFormValues('nostr', {
enabled: true,
name: 'nostr-bot',
privateKey: 'nsec1example',
relays: ['wss://relay.damus.io', 'wss://nos.lol'],
dmPolicy: 'allowlist',
allowFrom: ['npub1sender'],
profile: {
name: 'openclaw',
displayName: 'OpenClaw Bot',
about: 'Nostr DM assistant',
picture: 'https://example.com/avatar.png',
website: 'https://example.com',
nip05: 'openclaw@example.com',
lud16: 'openclaw@example.com',
},
})
const missingKey = buildOpenClawChannelDiagnosis({
platform: 'nostr',
configExists: true,
channelEnabled: true,
form: { relays: 'wss://relay.damus.io' },
})
const ready = buildOpenClawChannelDiagnosis({
platform: 'nostr',
configExists: true,
channelEnabled: true,
form: values,
})
assert.equal(values.enabled, 'true')
assert.equal(values.name, 'nostr-bot')
assert.equal(values.privateKey, 'nsec1example')
assert.equal(values.relays, 'wss://relay.damus.io, wss://nos.lol')
assert.equal(values.dmPolicy, 'allowlist')
assert.equal(values.allowFrom, 'npub1sender')
assert.equal(values.profileName, 'openclaw')
assert.equal(values.profileDisplayName, 'OpenClaw Bot')
assert.equal(values.profileAbout, 'Nostr DM assistant')
assert.equal(values.profilePicture, 'https://example.com/avatar.png')
assert.equal(values.profileWebsite, 'https://example.com')
assert.equal(values.profileNip05, 'openclaw@example.com')
assert.equal(values.profileLud16, 'openclaw@example.com')
assert.equal(missingKey.checks.find(item => item.id === 'credentials')?.ok, false)
assert.match(missingKey.checks.find(item => item.id === 'credentials')?.detail || '', /Private Key/)
assert.equal(ready.checks.find(item => item.id === 'credentials')?.ok, true)
})
test('Signal 渠道保存会保留多账号和上游运行字段', () => {
const cfg = { channels: {} }

View File

@@ -139,3 +139,25 @@ test('Twitch 渠道 UI 会暴露聊天账号和访问控制配置字段', () =>
assert.match(twitchBlock, /pluginRequired:\s*'@openclaw\/twitch@latest'/)
assert.match(twitchBlock, /pluginId:\s*'twitch'/)
})
test('Nostr 渠道 UI 会暴露私钥、Relay、访问控制和 Profile 配置字段', () => {
const nostrBlock = getRegistryBlock('nostr')
for (const field of [
'privateKey',
'relays',
'dmPolicy',
'allowFrom',
'profileName',
'profileDisplayName',
'profileAbout',
'profilePicture',
'profileWebsite',
'profileNip05',
'profileLud16',
]) {
assert.match(nostrBlock, new RegExp(`key:\\s*'${field}'`))
}
assert.match(nostrBlock, /pluginRequired:\s*'@openclaw\/nostr@latest'/)
assert.match(nostrBlock, /pluginId:\s*'nostr'/)
})