mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-29 04:10:00 +08:00
feat(channels): add Twitch config compatibility
This commit is contained in:
@@ -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', {
|
||||
|
||||
@@ -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, ¤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<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!({
|
||||
|
||||
@@ -21,6 +21,7 @@ export const CHANNEL_LABELS = {
|
||||
mattermost: 'Mattermost',
|
||||
clickclack: 'ClickClack',
|
||||
'nextcloud-talk': 'Nextcloud Talk',
|
||||
twitch: 'Twitch',
|
||||
'openclaw-weixin': '微信',
|
||||
weixin: '微信',
|
||||
}
|
||||
|
||||
@@ -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 和要监听的 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: _('<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'),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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: {} }
|
||||
|
||||
|
||||
@@ -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'/)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user