feat(channels): add Twitch config compatibility

This commit is contained in:
晴天
2026-05-23 08:42:54 +08:00
parent 8623efd49c
commit dcc3751ded
7 changed files with 366 additions and 10 deletions

View File

@@ -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', {

View File

@@ -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<String, Value>) -> 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, &current_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<Value, St
"valid": true,
"warnings": ["Nextcloud Talk 面板已完成基础字段校验;实际连通性请通过 Gateway 启动日志或 openclaw channels status --probe 验证"]
})),
"twitch" => 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!({

View File

@@ -21,6 +21,7 @@ export const CHANNEL_LABELS = {
mattermost: 'Mattermost',
clickclack: 'ClickClack',
'nextcloud-talk': 'Nextcloud Talk',
twitch: 'Twitch',
'openclaw-weixin': '微信',
weixin: '微信',
}

View File

@@ -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 开发者控制台创建应用,获取 <strong>Client ID</strong>', 'Create an app in the Twitch developer console and copy the <strong>Client ID</strong>'),
twitchGuide2: _('为机器人账号准备 OAuth Access Token至少包含 <code>chat:read</code> 和 <code>chat:write</code> 权限', 'Prepare an OAuth access token for the bot account with at least <code>chat:read</code> and <code>chat:write</code> scopes'),
twitchGuide3: _('填写机器人 Username 和要监听的 ChannelChannel 可不带 #', '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: _('<div style="margin-top:8px;font-size:var(--font-size-xs);color:var(--text-tertiary)">Twitch 最小配置需要 Username、Access Token、Client ID 与 Channel。</div>', '<div style="margin-top:8px;font-size:var(--font-size-xs);color:var(--text-tertiary)">Twitch minimally requires Username, Access Token, Client ID, and Channel.</div>'),
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'),

View File

@@ -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)

View File

@@ -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: {} }

View File

@@ -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'/)
})