diff --git a/scripts/dev-api.js b/scripts/dev-api.js index bc3f2d0..8592f29 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -2429,7 +2429,7 @@ function putWildcardAllowFromWhenOpen(entry, previousAllowFrom) { } function platformSupportsTopLevelRequireMention(platform) { - return ['feishu', 'slack', 'msteams', 'mattermost', 'googlechat', 'nextcloud-talk'].includes(platformStorageKey(platform)) + return ['feishu', 'slack', 'msteams', 'mattermost', 'googlechat', 'nextcloud-talk', 'twitch'].includes(platformStorageKey(platform)) } export function normalizeMessagingPlatformForm(platform, form = {}) { @@ -2472,11 +2472,11 @@ export function normalizeMessagingPlatformForm(platform, form = {}) { normalized.allowedUserIds = csvToStringArray(normalized.allowedUserIds) } - for (const key of ['promptStarters', 'delegatedAuthScopes', 'attachmentRoots', 'remoteAttachmentRoots', 'toolsAllow']) { + for (const key of ['promptStarters', 'delegatedAuthScopes', 'attachmentRoots', 'remoteAttachmentRoots', 'toolsAllow', 'allowedRoles']) { if (Object.hasOwn(normalized, key)) normalized[key] = csvToStringArray(normalized[key]) } - for (const key of ['mediaMaxMb', 'historyLimit', 'dmHistoryLimit', 'textChunkLimit', 'probeTimeoutMs', 'debounceMs', 'rateLimitPerMinute', 'httpPort', 'webhookPort', 'feedbackReflectionCooldownMs', 'timeoutSeconds', 'reconnectMs']) { + for (const key of ['mediaMaxMb', 'historyLimit', 'dmHistoryLimit', 'textChunkLimit', 'probeTimeoutMs', 'debounceMs', 'rateLimitPerMinute', 'httpPort', 'webhookPort', 'feedbackReflectionCooldownMs', 'timeoutSeconds', 'reconnectMs', 'expiresIn', 'obtainmentTimestamp']) { if (!Object.hasOwn(normalized, key)) continue const value = String(normalized[key] || '').trim() if (!value) { @@ -2489,9 +2489,11 @@ 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']) { + 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']) { if (Object.hasOwn(normalized, key)) { - const value = String(normalized[key] || '').trim() + const value = typeof normalized[key] === 'boolean' + ? String(normalized[key]) + : String(normalized[key] || '').trim() if (!value) { delete normalized[key] } else { @@ -2601,6 +2603,7 @@ const MESSAGING_CREDENTIAL_FIELDS = [ 'channelSecret', 'clientId', 'clientSecret', + 'refreshToken', 'gatewayPassword', 'gatewayToken', 'password', @@ -2693,6 +2696,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']], + twitch: [['username', 'Username'], ['accessToken', 'Access Token'], ['clientId', 'Client ID'], ['channel', 'Channel']], signal: [['account', 'Signal 账号']], } @@ -3088,6 +3092,20 @@ export function buildMessagingPlatformFormValues(platform, saved = {}, options = return form } + if (storageKey === 'twitch') { + for (const key of ['username', 'accessToken', 'clientId', 'channel', 'responsePrefix', 'clientSecret', 'refreshToken']) { + putSecretAwareFormValue(form, saved, key) + } + putBoolFormValue(form, saved, 'enabled') + putCsvFormValue(form, saved, 'allowFrom') + putCsvFormValue(form, saved, 'allowedRoles') + putBoolFormValue(form, saved, 'requireMention') + for (const key of ['expiresIn', 'obtainmentTimestamp']) { + 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) @@ -3869,6 +3887,17 @@ function buildOpenClawMessagingPlatformEntry(platform, form, currentSaved = {}) for (const key of ['webhookPort', 'historyLimit', 'dmHistoryLimit', 'mediaMaxMb', 'textChunkLimit']) { if (typeof form[key] === 'number') entry[key] = form[key] } + } else if (storageKey === 'twitch') { + entry.enabled = typeof form.enabled === 'boolean' ? form.enabled : true + for (const key of ['username', 'accessToken', 'clientId', 'channel', 'responsePrefix', 'clientSecret', 'refreshToken']) { + if (form[key]) entry[key] = form[key] + } + if (Array.isArray(form.allowFrom) && form.allowFrom.length) entry.allowFrom = form.allowFrom + if (Array.isArray(form.allowedRoles) && form.allowedRoles.length) entry.allowedRoles = form.allowedRoles + if (typeof form.requireMention === 'boolean') entry.requireMention = form.requireMention + for (const key of ['expiresIn', 'obtainmentTimestamp']) { + 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] @@ -3910,7 +3939,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', 'nextcloud-talk', 'synology-chat', 'googlechat', 'msteams', 'imessage', 'whatsapp'].includes(storageKey)) { + if (['zalo', 'zalouser', 'line', 'mattermost', 'clickclack', 'nextcloud-talk', 'twitch', 'synology-chat', 'googlechat', 'msteams', 'imessage', 'whatsapp'].includes(storageKey)) { ensureMessagingPluginAllowed(cfg, storageKey) } return { entry, accountId: normalizedAccountId, storageKey } @@ -5380,7 +5409,7 @@ const handlers = { } else { setRootChannelEntry(entry) } - } else if (['line', 'mattermost', 'clickclack', 'nextcloud-talk', 'synology-chat', 'googlechat', 'msteams', 'whatsapp'].includes(storageKey)) { + } else if (['line', 'mattermost', 'clickclack', 'nextcloud-talk', 'twitch', 'synology-chat', 'googlechat', 'msteams', 'whatsapp'].includes(storageKey)) { const built = buildOpenClawMessagingPlatformEntry(platform, form, currentSaved) applyMessagingPlatformEntry(cfg, storageKey, normalizedAccountId, built) ensureMessagingPluginAllowed(cfg, storageKey) @@ -5389,7 +5418,7 @@ const handlers = { preserveMessagingCredentialRefs(entry, form, currentSaved) } - if (platform !== 'qqbot' && platform !== 'feishu' && platform !== 'dingtalk' && platform !== 'dingtalk-connector' && !['line', 'mattermost', 'clickclack', 'nextcloud-talk', 'synology-chat', 'googlechat', 'msteams', 'whatsapp'].includes(storageKey)) { + if (platform !== 'qqbot' && platform !== 'feishu' && platform !== 'dingtalk' && platform !== 'dingtalk-connector' && !['line', 'mattermost', 'clickclack', 'nextcloud-talk', 'twitch', 'synology-chat', 'googlechat', 'msteams', 'whatsapp'].includes(storageKey)) { preserveMessagingCredentialRefs(entry, form, currentSaved) // 合并模式:保留用户通过 CLI 或手动编辑的自定义字段 applyMessagingPlatformEntry(cfg, storageKey, normalizedAccountId, entry) @@ -5520,6 +5549,9 @@ const handlers = { if (platform === 'nextcloud-talk') { return { valid: true, warnings: ['Nextcloud Talk 面板已完成基础字段校验;实际连通性请通过 Gateway 启动日志或 openclaw channels status --probe 验证。'] } } + if (platform === 'twitch') { + return { valid: true, warnings: ['Twitch 面板已完成基础字段校验;实际连通性请通过 Gateway 启动日志或 openclaw channels status --probe 验证。'] } + } if (platform === 'discord') { try { const resp = await fetch('https://discord.com/api/v10/users/@me', { diff --git a/src-tauri/src/commands/messaging.rs b/src-tauri/src/commands/messaging.rs index 51e4511..8bdccca 100644 --- a/src-tauri/src/commands/messaging.rs +++ b/src-tauri/src/commands/messaging.rs @@ -148,6 +148,7 @@ fn preserve_messaging_credential_refs( "channelSecret", "clientId", "clientSecret", + "refreshToken", "gatewayPassword", "gatewayToken", "password", @@ -234,6 +235,7 @@ fn channel_root_has_messaging_credential(root: &Map) -> bool { "channelSecret", "clientId", "clientSecret", + "refreshToken", "gatewayPassword", "gatewayToken", "password", @@ -274,6 +276,12 @@ fn required_channel_credential_fields( ("workspace", "Workspace"), ], "nextcloud-talk" => vec![("baseUrl", "Base URL")], + "twitch" => vec![ + ("username", "Username"), + ("accessToken", "Access Token"), + ("clientId", "Client ID"), + ("channel", "Channel"), + ], "signal" => vec![("account", "Signal 账号")], "slack" => { let mode = form_string(form, "mode"); @@ -841,7 +849,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" | "nextcloud-talk" + "feishu" | "slack" | "msteams" | "mattermost" | "googlechat" | "nextcloud-talk" | "twitch" ) } @@ -946,6 +954,8 @@ fn normalize_messaging_platform_form( normalize_numeric_form_value(&mut normalized, "feedbackReflectionCooldownMs"); normalize_numeric_form_value(&mut normalized, "timeoutSeconds"); normalize_numeric_form_value(&mut normalized, "reconnectMs"); + normalize_numeric_form_value(&mut normalized, "expiresIn"); + normalize_numeric_form_value(&mut normalized, "obtainmentTimestamp"); for key in [ "promptStarters", @@ -953,6 +963,7 @@ fn normalize_messaging_platform_form( "attachmentRoots", "remoteAttachmentRoots", "toolsAllow", + "allowedRoles", ] { if normalized.contains_key(key) { let items = json_array_from_csv_value(normalized.get(key)); @@ -983,6 +994,7 @@ fn normalize_messaging_platform_form( "selfChatMode", "ackDirect", "senderIsOwner", + "requireMention", ] { if normalized.contains_key(key) { let value = match normalized.get(key) { @@ -1855,6 +1867,25 @@ pub async fn read_platform_config( insert_number_as_string(&mut form, &saved, key); } } + "twitch" => { + for key in [ + "username", + "accessToken", + "clientId", + "channel", + "responsePrefix", + "clientSecret", + "refreshToken", + ] { + insert_secret_aware_form_value(&mut form, &saved, key); + } + insert_bool_as_string(&mut form, &saved, "enabled"); + insert_array_as_csv(&mut form, &saved, "allowFrom"); + insert_array_as_csv(&mut form, &saved, "allowedRoles"); + insert_bool_as_string(&mut form, &saved, "requireMention"); + insert_number_as_string(&mut form, &saved, "expiresIn"); + insert_number_as_string(&mut form, &saved, "obtainmentTimestamp"); + } "synology-chat" => { for key in ["token", "incomingUrl", "nasHost", "webhookPath", "botName"] { insert_secret_aware_form_value(&mut form, &saved, key); @@ -3000,6 +3031,58 @@ pub async fn save_messaging_platform( )?; ensure_plugin_allowed(&mut cfg, "nextcloud-talk")?; } + "twitch" => { + let username = form_string(form_obj, "username"); + let access_token = form_string(form_obj, "accessToken"); + let client_id = form_string(form_obj, "clientId"); + let channel = form_string(form_obj, "channel"); + if username.is_empty() { + return Err("Twitch Username 不能为空".into()); + } + if access_token.is_empty() + && !has_configured_messaging_value(form_obj.get("accessToken")) + { + return Err("Twitch Access Token 不能为空".into()); + } + if client_id.is_empty() { + return Err("Twitch Client ID 不能为空".into()); + } + if channel.is_empty() { + return Err("Twitch Channel 不能为空".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 [ + "username", + "accessToken", + "clientId", + "channel", + "responsePrefix", + "clientSecret", + "refreshToken", + ] { + put_string(&mut entry, key, form_string(form_obj, key)); + } + put_array_from_form_value(&mut entry, "allowFrom", form_obj.get("allowFrom")); + put_array_from_form_value(&mut entry, "allowedRoles", form_obj.get("allowedRoles")); + put_bool_value_if_present(&mut entry, "requireMention", form_obj.get("requireMention")); + put_number_value_if_present(&mut entry, "expiresIn", form_obj.get("expiresIn")); + put_number_value_if_present( + &mut entry, + "obtainmentTimestamp", + form_obj.get("obtainmentTimestamp"), + ); + 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, "twitch")?; + } "synology-chat" => { let token = form_string(form_obj, "token"); let incoming_url = form_string(form_obj, "incomingUrl"); @@ -3305,6 +3388,10 @@ pub async fn verify_bot_token(platform: String, form: Value) -> Result Ok(json!({ + "valid": true, + "warnings": ["Twitch 面板已完成基础字段校验;实际连通性请通过 Gateway 启动日志或 openclaw channels status --probe 验证"] + })), _ => Ok(json!({ "valid": true, "warnings": ["该平台暂不支持在线校验"] @@ -6561,6 +6648,85 @@ mod tests { .contains("Bot Secret 或 Secret File")); } + #[test] + fn normalize_twitch_form_preserves_chat_runtime_fields() { + let form = json!({ + "enabled": "true", + "username": "openclaw", + "accessToken": "oauth:abc123", + "clientId": "client-123", + "channel": "openclaw", + "allowFrom": "123456, 789012", + "allowedRoles": "moderator, vip", + "requireMention": "true", + "responsePrefix": "[AI]", + "clientSecret": "client-secret", + "refreshToken": "refresh-token", + "expiresIn": "3600", + "obtainmentTimestamp": "1779490000" + }); + let normalized = + normalize_messaging_platform_form("twitch", form.as_object().expect("object")); + + assert_eq!( + normalized.get("enabled").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("allowedRoles") + .and_then(|v| v.as_array()) + .map(|items| items.len()), + Some(2) + ); + assert_eq!( + normalized.get("requireMention").and_then(|v| v.as_bool()), + Some(true) + ); + assert_eq!( + normalized.get("expiresIn").and_then(|v| v.as_f64()), + Some(3600.0) + ); + assert_eq!( + normalized + .get("obtainmentTimestamp") + .and_then(|v| v.as_f64()), + Some(1779490000.0) + ); + assert!(channel_diagnosis_credentials_ready("twitch", &normalized)); + + let missing = normalize_messaging_platform_form( + "twitch", + json!({ + "username": "openclaw", + "clientId": "client-123", + "channel": "openclaw" + }) + .as_object() + .expect("object"), + ); + assert!(!channel_diagnosis_credentials_ready("twitch", &missing)); + let diagnosis = + build_openclaw_channel_diagnosis("twitch", 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("Access Token")); + } + #[test] fn channel_form_readback_preserves_mention_policy_choice() { let saved = json!({ diff --git a/src/lib/channel-labels.js b/src/lib/channel-labels.js index 9c1aba1..dab0981 100644 --- a/src/lib/channel-labels.js +++ b/src/lib/channel-labels.js @@ -21,6 +21,7 @@ export const CHANNEL_LABELS = { mattermost: 'Mattermost', clickclack: 'ClickClack', 'nextcloud-talk': 'Nextcloud Talk', + twitch: 'Twitch', 'openclaw-weixin': '微信', weixin: '微信', } diff --git a/src/locales/modules/channels.js b/src/locales/modules/channels.js index 0308661..a32ed5b 100644 --- a/src/locales/modules/channels.js +++ b/src/locales/modules/channels.js @@ -161,6 +161,23 @@ export default { 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'), + twitchDesc: _('接入 Twitch 聊天频道,支持直播间消息、角色过滤和 OAuth Token 配置', 'Connect Twitch chat with channel messages, role filters, and OAuth token settings'), + twitchGuide1: _('在 Twitch 开发者控制台创建应用,获取 Client ID', 'Create an app in the Twitch developer console and copy the Client ID'), + twitchGuide2: _('为机器人账号准备 OAuth Access Token,至少包含 chat:readchat:write 权限', 'Prepare an OAuth access token for the bot account with at least chat:read and chat:write scopes'), + twitchGuide3: _('填写机器人 Username 和要监听的 Channel;Channel 可不带 #', 'Fill the bot username and target channel; the channel can be entered without #'), + twitchGuide4: _('保存后面板会安装 Twitch 插件并重载 Gateway;实际连通性以 Gateway 日志或 channels status 为准', 'After saving, the panel installs the Twitch plugin and reloads Gateway; verify connectivity through Gateway logs or channels status'), + twitchGuideFooter: _('
Twitch 最小配置需要 Username、Access Token、Client ID 与 Channel。
', '
Twitch minimally requires Username, Access Token, Client ID, and Channel.
'), + twitchUsernameHint: _('机器人登录名,通常是不带 @ 的 Twitch 用户名。', 'Bot login name, usually the Twitch username without @.'), + twitchAccessTokenHint: _('OAuth Access Token,可带 oauth: 前缀;生产环境建议使用 SecretRef。', 'OAuth access token; oauth: prefix is allowed. Prefer SecretRef in production.'), + twitchClientIdHint: _('Twitch 开发者控制台中的应用 Client ID。', 'Application Client ID from the Twitch developer console.'), + twitchChannelHint: _('目标直播间频道名,可填写 openclaw 或 #openclaw。', 'Target chat channel, e.g. openclaw or #openclaw.'), + twitchAllowFromHint: _('可选,逗号分隔允许发起对话的 Twitch 用户 ID。', 'Optional comma-separated Twitch user IDs allowed to start conversations.'), + twitchAllowedRolesHint: _('可选,逗号分隔 moderator、owner、vip、subscriber、all。', 'Optional comma-separated roles: moderator, owner, vip, subscriber, all.'), + twitchRequireMention: _('要求提及机器人', 'Require mention'), + twitchClientSecretHint: _('可选;仅在需要刷新 Token 的 OAuth 流程中填写。', 'Optional; only needed for OAuth flows that refresh tokens.'), + 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.'), 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'), diff --git a/src/pages/channels.js b/src/pages/channels.js index 31e62ec..1a33814 100644 --- a/src/pages/channels.js +++ b/src/pages/channels.js @@ -356,6 +356,36 @@ const PLATFORM_REGISTRY = { pluginRequired: '@openclaw/nextcloud-talk@latest', pluginId: 'nextcloud-talk', }, + twitch: { + label: 'Twitch', + iconName: 'message-square', + desc: t('channels.twitchDesc'), + guide: [ + t('channels.twitchGuide1'), + t('channels.twitchGuide2'), + t('channels.twitchGuide3'), + t('channels.twitchGuide4'), + ], + guideFooter: t('channels.twitchGuideFooter'), + fields: [ + { key: 'username', label: 'Username', placeholder: t('channels.optionalEg', { example: 'openclaw' }), required: true, hint: t('channels.twitchUsernameHint') }, + { key: 'accessToken', label: 'Access Token', placeholder: 'oauth:abc123...', secret: true, required: true, hint: t('channels.twitchAccessTokenHint') }, + { key: 'clientId', label: 'Client ID', placeholder: 'abc123clientid', required: true, hint: t('channels.twitchClientIdHint') }, + { key: 'channel', label: 'Channel', placeholder: 'openclaw', required: true, hint: t('channels.twitchChannelHint') }, + { key: 'allowFrom', label: 'Allow From', placeholder: '123456789, 987654321', required: false, hint: t('channels.twitchAllowFromHint') }, + { key: 'allowedRoles', label: 'Allowed Roles', placeholder: 'moderator, vip, subscriber', required: false, hint: t('channels.twitchAllowedRolesHint') }, + { key: 'requireMention', label: t('channels.twitchRequireMention'), type: 'select', options: BOOLEAN_OPTIONS, required: false }, + { key: 'responsePrefix', label: 'Response Prefix', placeholder: t('channels.optionalEg', { example: '[AI]' }), required: false }, + { key: 'clientSecret', label: 'Client Secret', placeholder: t('channels.optionalEg', { example: 'client-secret' }), secret: true, required: false, hint: t('channels.twitchClientSecretHint') }, + { key: 'refreshToken', label: 'Refresh Token', placeholder: t('channels.optionalEg', { example: 'refresh-token' }), secret: true, required: false, hint: t('channels.twitchRefreshTokenHint') }, + { key: 'expiresIn', label: 'Expires In', placeholder: '3600', required: false, hint: t('channels.twitchExpiresInHint') }, + { key: 'obtainmentTimestamp', label: 'Obtainment Timestamp', placeholder: '1779490000', required: false, hint: t('channels.twitchObtainmentTimestampHint') }, + ], + configKey: 'twitch', + pairingChannel: 'twitch', + pluginRequired: '@openclaw/twitch@latest', + pluginId: 'twitch', + }, 'synology-chat': { label: 'Synology Chat', iconName: 'message-square', @@ -878,7 +908,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', 'synology-chat', 'googlechat', 'signal'] +const MULTI_INSTANCE_PLATFORMS = ['telegram', 'discord', 'slack', 'feishu', 'dingtalk', 'dingtalk-connector', 'qqbot', 'zalo', 'zalouser', 'line', 'mattermost', 'clickclack', 'nextcloud-talk', 'twitch', 'synology-chat', 'googlechat', 'signal'] function supportsMessagingMultiAccount(pid) { return MULTI_INSTANCE_PLATFORMS.includes(pid) diff --git a/tests/channel-config-normalization.test.js b/tests/channel-config-normalization.test.js index aa7f213..0745d20 100644 --- a/tests/channel-config-normalization.test.js +++ b/tests/channel-config-normalization.test.js @@ -352,6 +352,93 @@ test('Nextcloud Talk 读取和诊断支持 Bot Secret 或 Secret File 二选一' assert.equal(ready.checks.find(item => item.id === 'credentials')?.ok, true) }) +test('Twitch 渠道保存会写入聊天账号字段并启用插件', () => { + const cfg = { channels: {} } + + mergeOpenClawMessagingPlatformConfig(cfg, { + platform: 'twitch', + accountId: 'stream', + form: { + enabled: 'true', + username: 'openclawbot', + accessToken: 'oauth:access-token', + clientId: 'client-id', + channel: '#openclaw', + allowFrom: '123456789, 987654321', + allowedRoles: 'moderator, vip', + requireMention: 'true', + responsePrefix: '[Twitch]', + clientSecret: 'client-secret', + refreshToken: 'refresh-token', + expiresIn: '3600', + obtainmentTimestamp: '1779490000', + }, + }) + + const root = cfg.channels.twitch + const account = root.accounts.stream + assert.equal(root.defaultAccount, 'stream') + assert.equal(account.enabled, true) + assert.equal(account.username, 'openclawbot') + assert.equal(account.accessToken, 'oauth:access-token') + assert.equal(account.clientId, 'client-id') + assert.equal(account.channel, '#openclaw') + assert.deepEqual(account.allowFrom, ['123456789', '987654321']) + assert.deepEqual(account.allowedRoles, ['moderator', 'vip']) + assert.equal(account.requireMention, true) + assert.equal(account.responsePrefix, '[Twitch]') + assert.equal(account.clientSecret, 'client-secret') + assert.equal(account.refreshToken, 'refresh-token') + assert.equal(account.expiresIn, 3600) + assert.equal(account.obtainmentTimestamp, 1779490000) + assert.equal(cfg.plugins.entries.twitch.enabled, true) +}) + +test('Twitch 读取和诊断会回显访问控制与刷新 Token 字段', () => { + const values = buildMessagingPlatformFormValues('twitch', { + enabled: true, + username: 'openclawbot', + accessToken: 'oauth:access-token', + clientId: 'client-id', + channel: '#openclaw', + allowFrom: ['123456789'], + allowedRoles: ['moderator', 'subscriber'], + requireMention: true, + responsePrefix: '[Twitch]', + clientSecret: 'client-secret', + refreshToken: 'refresh-token', + expiresIn: 3600, + obtainmentTimestamp: 1779490000, + }) + const missingToken = buildOpenClawChannelDiagnosis({ + platform: 'twitch', + configExists: true, + channelEnabled: true, + form: { username: 'openclawbot', clientId: 'client-id', channel: '#openclaw' }, + }) + const ready = buildOpenClawChannelDiagnosis({ + platform: 'twitch', + configExists: true, + channelEnabled: true, + form: values, + }) + + assert.equal(values.enabled, 'true') + assert.equal(values.username, 'openclawbot') + assert.equal(values.accessToken, 'oauth:access-token') + assert.equal(values.clientId, 'client-id') + assert.equal(values.channel, '#openclaw') + assert.equal(values.allowFrom, '123456789') + assert.equal(values.allowedRoles, 'moderator, subscriber') + assert.equal(values.requireMention, 'true') + assert.equal(values.refreshToken, 'refresh-token') + assert.equal(values.expiresIn, '3600') + assert.equal(values.obtainmentTimestamp, '1779490000') + assert.equal(missingToken.checks.find(item => item.id === 'credentials')?.ok, false) + assert.match(missingToken.checks.find(item => item.id === 'credentials')?.detail || '', /Access Token/) + assert.equal(ready.checks.find(item => item.id === 'credentials')?.ok, true) +}) + test('Signal 渠道保存会保留多账号和上游运行字段', () => { const cfg = { channels: {} } diff --git a/tests/channel-ui-registry.test.js b/tests/channel-ui-registry.test.js index 4985b44..b991bde 100644 --- a/tests/channel-ui-registry.test.js +++ b/tests/channel-ui-registry.test.js @@ -116,3 +116,26 @@ test('Nextcloud Talk 渠道 UI 会暴露自托管 Talk 配置字段', () => { } assert.match(talkBlock, /pluginId:\s*'nextcloud-talk'/) }) + +test('Twitch 渠道 UI 会暴露聊天账号和访问控制配置字段', () => { + const twitchBlock = getRegistryBlock('twitch') + + for (const field of [ + 'username', + 'accessToken', + 'clientId', + 'channel', + 'allowFrom', + 'allowedRoles', + 'requireMention', + 'responsePrefix', + 'clientSecret', + 'refreshToken', + 'expiresIn', + 'obtainmentTimestamp', + ]) { + assert.match(twitchBlock, new RegExp(`key:\\s*'${field}'`)) + } + assert.match(twitchBlock, /pluginRequired:\s*'@openclaw\/twitch@latest'/) + assert.match(twitchBlock, /pluginId:\s*'twitch'/) +})