Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
89
extensions/twitch/README.md
Normal file
89
extensions/twitch/README.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# @openclaw/twitch
|
||||
|
||||
Twitch channel plugin for OpenClaw.
|
||||
|
||||
## Install (local checkout)
|
||||
|
||||
```bash
|
||||
openclaw plugins install ./path/to/local/twitch-plugin
|
||||
```
|
||||
|
||||
## Install (npm)
|
||||
|
||||
```bash
|
||||
openclaw plugins install @openclaw/twitch
|
||||
```
|
||||
|
||||
Onboarding: select Twitch and confirm the install prompt to fetch the plugin automatically.
|
||||
|
||||
## Config
|
||||
|
||||
Minimal config (simplified single-account):
|
||||
|
||||
**⚠️ Important:** `requireMention` defaults to `true`. Add access control (`allowFrom` or `allowedRoles`) to prevent unauthorized users from triggering the bot.
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
twitch: {
|
||||
enabled: true,
|
||||
username: "openclaw",
|
||||
accessToken: "oauth:abc123...", // OAuth Access Token (add oauth: prefix)
|
||||
clientId: "xyz789...", // Client ID from Token Generator
|
||||
channel: "vevisk", // Channel to join (required)
|
||||
allowFrom: ["123456789"], // (recommended) Your Twitch user ID only (Convert your twitch username to ID at https://www.streamweasels.com/tools/convert-twitch-username-to-user-id/)
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
**Access control options:**
|
||||
|
||||
- `requireMention: false` - Disable the default mention requirement to respond to all messages
|
||||
- `allowFrom: ["your_user_id"]` - Restrict to your Twitch user ID only (find your ID at https://www.twitchangles.com/xqc or similar)
|
||||
- `allowedRoles: ["moderator", "vip", "subscriber"]` - Restrict to specific roles
|
||||
|
||||
Multi-account config (advanced):
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
twitch: {
|
||||
enabled: true,
|
||||
accounts: {
|
||||
default: {
|
||||
username: "openclaw",
|
||||
accessToken: "oauth:abc123...",
|
||||
clientId: "xyz789...",
|
||||
channel: "vevisk",
|
||||
},
|
||||
channel2: {
|
||||
username: "openclaw",
|
||||
accessToken: "oauth:def456...",
|
||||
clientId: "uvw012...",
|
||||
channel: "secondchannel",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Setup
|
||||
|
||||
1. Create a dedicated Twitch account for the bot, then generate credentials: [Twitch Token Generator](https://twitchtokengenerator.com/)
|
||||
- Select **Bot Token**
|
||||
- Verify scopes `chat:read` and `chat:write` are selected
|
||||
- Copy the **Access Token** to `token` property
|
||||
- Copy the **Client ID** to `clientId` property
|
||||
2. Start the gateway
|
||||
|
||||
## Full documentation
|
||||
|
||||
See https://docs.openclaw.ai/channels/twitch for:
|
||||
|
||||
- Token refresh setup
|
||||
- Access control patterns
|
||||
- Multi-account configuration
|
||||
- Troubleshooting
|
||||
- Capabilities & limits
|
||||
22
extensions/twitch/api.ts
Normal file
22
extensions/twitch/api.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
// Twitch API module exposes the plugin public contract.
|
||||
export {
|
||||
type ChannelAccountSnapshot,
|
||||
type ChannelCapabilities,
|
||||
type ChannelGatewayContext,
|
||||
type ChannelLogSink,
|
||||
type ChannelMessageActionAdapter,
|
||||
type ChannelMessageActionContext,
|
||||
type ChannelMeta,
|
||||
type ChannelOutboundAdapter,
|
||||
type ChannelOutboundContext,
|
||||
type ChannelPlugin,
|
||||
type ChannelResolveKind,
|
||||
type ChannelResolveResult,
|
||||
type ChannelStatusAdapter,
|
||||
type OpenClawConfig,
|
||||
type OutboundDeliveryResult,
|
||||
type RuntimeEnv,
|
||||
type WizardPrompter,
|
||||
} from "./runtime-api.js";
|
||||
export { twitchPlugin } from "./src/plugin.js";
|
||||
export { setTwitchRuntime } from "./src/runtime.js";
|
||||
2
extensions/twitch/channel-plugin-api.ts
Normal file
2
extensions/twitch/channel-plugin-api.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Twitch API module exposes the plugin public contract.
|
||||
export { twitchPlugin } from "./src/plugin.js";
|
||||
14
extensions/twitch/index.test.ts
Normal file
14
extensions/twitch/index.test.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
// Twitch tests cover index plugin behavior.
|
||||
import { assertBundledChannelEntries } from "openclaw/plugin-sdk/channel-test-helpers";
|
||||
import { describe } from "vitest";
|
||||
import entry from "./index.js";
|
||||
import setupEntry from "./setup-entry.js";
|
||||
|
||||
describe("twitch bundled entries", () => {
|
||||
assertBundledChannelEntries({
|
||||
entry,
|
||||
expectedId: "twitch",
|
||||
expectedName: "Twitch",
|
||||
setupEntry,
|
||||
});
|
||||
});
|
||||
17
extensions/twitch/index.ts
Normal file
17
extensions/twitch/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
// Twitch plugin entrypoint registers its OpenClaw integration.
|
||||
import { defineBundledChannelEntry } from "openclaw/plugin-sdk/channel-entry-contract";
|
||||
|
||||
export default defineBundledChannelEntry({
|
||||
id: "twitch",
|
||||
name: "Twitch",
|
||||
description: "Twitch IRC chat channel plugin",
|
||||
importMetaUrl: import.meta.url,
|
||||
plugin: {
|
||||
specifier: "./channel-plugin-api.js",
|
||||
exportName: "twitchPlugin",
|
||||
},
|
||||
runtime: {
|
||||
specifier: "./api.js",
|
||||
exportName: "setTwitchRuntime",
|
||||
},
|
||||
});
|
||||
332
extensions/twitch/npm-shrinkwrap.json
generated
Normal file
332
extensions/twitch/npm-shrinkwrap.json
generated
Normal file
@@ -0,0 +1,332 @@
|
||||
{
|
||||
"name": "@openclaw/twitch",
|
||||
"version": "2026.6.11",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/twitch",
|
||||
"version": "2026.6.11",
|
||||
"dependencies": {
|
||||
"@twurple/api": "8.1.4",
|
||||
"@twurple/auth": "8.1.4",
|
||||
"@twurple/chat": "8.1.4",
|
||||
"zod": "4.4.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@d-fischer/cache-decorators": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@d-fischer/cache-decorators/-/cache-decorators-4.0.1.tgz",
|
||||
"integrity": "sha512-HNYLBLWs/t28GFZZeqdIBqq8f37mqDIFO6xNPof94VjpKvuP6ROqCZGafx88dk5zZUlBfViV9jD8iNNlXfc4CA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@d-fischer/shared-utils": "^3.6.3",
|
||||
"tslib": "^2.6.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@d-fischer/connection": {
|
||||
"version": "10.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@d-fischer/connection/-/connection-10.0.1.tgz",
|
||||
"integrity": "sha512-CRP/azUPxwWpR4yT8wOQoM9XFliTVWVAJ8h1SlFnVRAgMlPNyg88/vbDEqZ+udtSB5m8uS10XafZxMUcegMBlQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@d-fischer/isomorphic-ws": "^7.0.0",
|
||||
"@d-fischer/logger": "^4.2.1",
|
||||
"@d-fischer/shared-utils": "^3.5.0",
|
||||
"@d-fischer/typed-event-emitter": "^3.3.0",
|
||||
"@types/node": "^20.19.37",
|
||||
"@types/ws": "^8.5.4",
|
||||
"tslib": "^2.4.1",
|
||||
"ws": "^8.11.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/d-fischer"
|
||||
}
|
||||
},
|
||||
"node_modules/@d-fischer/deprecate": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@d-fischer/deprecate/-/deprecate-2.0.2.tgz",
|
||||
"integrity": "sha512-wlw3HwEanJFJKctwLzhfOM6LKwR70FPfGZGoKOhWBKyOPXk+3a9Cc6S9zhm6tka7xKtpmfxVIReGUwPnMbIaZg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@d-fischer/detect-node": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@d-fischer/detect-node/-/detect-node-3.0.1.tgz",
|
||||
"integrity": "sha512-0Rf3XwTzuTh8+oPZW9SfxTIiL+26RRJ0BRPwj5oVjZFyFKmsj9RGfN2zuTRjOuA3FCK/jYm06HOhwNK+8Pfv8w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@d-fischer/escape-string-regexp": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@d-fischer/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
|
||||
"integrity": "sha512-7eoxnxcto5eVPW5h1T+ePnVFukmI9f/ZR9nlBLh1t3kyzJDUNor2C+YW9H/Terw3YnbZSDgDYrpCJCHtOtAQHw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@d-fischer/isomorphic-ws": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@d-fischer/isomorphic-ws/-/isomorphic-ws-7.0.2.tgz",
|
||||
"integrity": "sha512-xK+qIJUF0ne3dsjq5Y3BviQ4M+gx9dzkN+dPP7abBMje4YRfow+X9jBgeEoTe5e+Q6+8hI9R0b37Okkk8Vf0hQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"ws": "^8.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@d-fischer/logger": {
|
||||
"version": "4.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@d-fischer/logger/-/logger-4.2.4.tgz",
|
||||
"integrity": "sha512-TFMZ/SVW8xyQtyJw9Rcuci4betSKy0qbQn2B5+1+72vVXeO8Qb1pYvuwF5qr0vDGundmSWq7W8r19nVPnXXSvA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@d-fischer/detect-node": "^3.0.1",
|
||||
"@d-fischer/shared-utils": "^3.6.1",
|
||||
"tslib": "^2.5.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/d-fischer"
|
||||
}
|
||||
},
|
||||
"node_modules/@d-fischer/rate-limiter": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@d-fischer/rate-limiter/-/rate-limiter-1.1.0.tgz",
|
||||
"integrity": "sha512-O5HgACwApyCZhp4JTEBEtbv/W3eAwEkrARFvgWnEsDmXgCMWjIHwohWoHre5BW6IYXFSHBGsuZB/EvNL3942kQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@d-fischer/logger": "^4.2.3",
|
||||
"@d-fischer/shared-utils": "^3.6.3",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/d-fischer"
|
||||
}
|
||||
},
|
||||
"node_modules/@d-fischer/shared-utils": {
|
||||
"version": "3.6.4",
|
||||
"resolved": "https://registry.npmjs.org/@d-fischer/shared-utils/-/shared-utils-3.6.4.tgz",
|
||||
"integrity": "sha512-BPkVLHfn2Lbyo/ENDBwtEB8JVQ+9OzkjJhUunLaxkw4k59YFlQxUUwlDBejVSFcpQT0t+D3CQlX+ySZnQj0wxw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/d-fischer"
|
||||
}
|
||||
},
|
||||
"node_modules/@d-fischer/typed-event-emitter": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@d-fischer/typed-event-emitter/-/typed-event-emitter-3.3.3.tgz",
|
||||
"integrity": "sha512-OvSEOa8icfdWDqcRtjSEZtgJTFOFNgTjje7zaL0+nAtu2/kZtRCSK5wUMrI/aXtCH8o0Qz2vA8UqkhWUTARFQQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/d-fischer"
|
||||
}
|
||||
},
|
||||
"node_modules/@twurple/api": {
|
||||
"version": "8.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@twurple/api/-/api-8.1.4.tgz",
|
||||
"integrity": "sha512-UA2eg5lyZRB0w55NjGvdhSmWPyIBaOthFKGPjg3L/jCQVYnY/FcmUuPipe2Op9U4Ej99c29cdjsmTSQI7P7Vqg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@d-fischer/cache-decorators": "^4.0.0",
|
||||
"@d-fischer/detect-node": "^3.0.1",
|
||||
"@d-fischer/logger": "^4.2.1",
|
||||
"@d-fischer/rate-limiter": "^1.1.0",
|
||||
"@d-fischer/shared-utils": "^3.6.1",
|
||||
"@d-fischer/typed-event-emitter": "^3.3.3",
|
||||
"@twurple/api-call": "8.1.4",
|
||||
"@twurple/common": "8.1.4",
|
||||
"retry": "^0.13.1",
|
||||
"tslib": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/d-fischer"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@twurple/auth": "8.1.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@twurple/api-call": {
|
||||
"version": "8.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@twurple/api-call/-/api-call-8.1.4.tgz",
|
||||
"integrity": "sha512-qh2TpdxxyiSkwadcCSes6uBHQB6l4Fz8sVfmzk+Brb12asemHMXTEyQAdrMJT7LlgtZq01nr+RASzWM3jmGtkw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@d-fischer/shared-utils": "^3.6.1",
|
||||
"@twurple/common": "8.1.4",
|
||||
"tslib": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/d-fischer"
|
||||
}
|
||||
},
|
||||
"node_modules/@twurple/auth": {
|
||||
"version": "8.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@twurple/auth/-/auth-8.1.4.tgz",
|
||||
"integrity": "sha512-ylsJoPInCw9BwOqxKcx+1k2ce9QG3vJpKFzPdIyHh49HvM/ulQZ0CAGysydugDYXF0iO/TGryh7PluSwx5fIwA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@d-fischer/logger": "^4.2.1",
|
||||
"@d-fischer/shared-utils": "^3.6.1",
|
||||
"@d-fischer/typed-event-emitter": "^3.3.3",
|
||||
"@twurple/api-call": "8.1.4",
|
||||
"@twurple/common": "8.1.4",
|
||||
"tslib": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/d-fischer"
|
||||
}
|
||||
},
|
||||
"node_modules/@twurple/chat": {
|
||||
"version": "8.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@twurple/chat/-/chat-8.1.4.tgz",
|
||||
"integrity": "sha512-654LU7BwEpR7lLnWaVMgHtd7wMsaQo71GPTrFZ5ChS283d7B7GTe+9RJ5SxNliNxB5Xn0+R3R3M5HJlUOGYNeA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@d-fischer/cache-decorators": "^4.0.0",
|
||||
"@d-fischer/deprecate": "^2.0.2",
|
||||
"@d-fischer/logger": "^4.2.1",
|
||||
"@d-fischer/rate-limiter": "^1.1.0",
|
||||
"@d-fischer/shared-utils": "^3.6.1",
|
||||
"@d-fischer/typed-event-emitter": "^3.3.3",
|
||||
"@twurple/common": "8.1.4",
|
||||
"ircv3": "^0.33.0",
|
||||
"tslib": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/d-fischer"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@twurple/auth": "8.1.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@twurple/common": {
|
||||
"version": "8.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@twurple/common/-/common-8.1.4.tgz",
|
||||
"integrity": "sha512-1iN5DvOnW+g+Nl3OTI5zUJHgAfjmPCb50HpKsAFik6OYQEAHLsscQKgTOJ+KRuFBYepo/JkHsOWOmWhXxnK6lQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@d-fischer/shared-utils": "^3.6.1",
|
||||
"klona": "^2.0.4",
|
||||
"tslib": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/d-fischer"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "20.19.43",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
|
||||
"integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/ws/node_modules/@types/node": {
|
||||
"version": "26.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.0.tgz",
|
||||
"integrity": "sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~8.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/ws/node_modules/undici-types": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
|
||||
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ircv3": {
|
||||
"version": "0.33.1",
|
||||
"resolved": "https://registry.npmjs.org/ircv3/-/ircv3-0.33.1.tgz",
|
||||
"integrity": "sha512-FPUj/q6zsLgIX6QDdLMjPRBObw0xK+k6eiI62dcTRwdl5aezYV0nuMhpmafyHOD6ZDqfw8DW4ayrvDfmYO65JQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@d-fischer/connection": "^10.0.1",
|
||||
"@d-fischer/escape-string-regexp": "^5.0.0",
|
||||
"@d-fischer/logger": "^4.2.1",
|
||||
"@d-fischer/shared-utils": "^3.5.0",
|
||||
"@d-fischer/typed-event-emitter": "^3.3.0",
|
||||
"klona": "^2.0.5",
|
||||
"tslib": "^2.4.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/d-fischer"
|
||||
}
|
||||
},
|
||||
"node_modules/klona": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/klona/-/klona-2.0.6.tgz",
|
||||
"integrity": "sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/retry": {
|
||||
"version": "0.13.1",
|
||||
"resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz",
|
||||
"integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.21.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
|
||||
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
|
||||
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
18
extensions/twitch/openclaw.plugin.json
Normal file
18
extensions/twitch/openclaw.plugin.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"id": "twitch",
|
||||
"name": "Twitch",
|
||||
"description": "OpenClaw Twitch channel plugin for chat and moderation workflows.",
|
||||
"icon": "https://cdn.simpleicons.org/twitch",
|
||||
"activation": {
|
||||
"onStartup": false
|
||||
},
|
||||
"channels": ["twitch"],
|
||||
"channelEnvVars": {
|
||||
"twitch": ["OPENCLAW_TWITCH_ACCESS_TOKEN"]
|
||||
},
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
50
extensions/twitch/package.json
Normal file
50
extensions/twitch/package.json
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "@openclaw/twitch",
|
||||
"version": "2026.6.11",
|
||||
"description": "OpenClaw Twitch channel plugin for chat and moderation workflows.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/openclaw/openclaw"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@twurple/api": "8.1.4",
|
||||
"@twurple/auth": "8.1.4",
|
||||
"@twurple/chat": "8.1.4",
|
||||
"zod": "4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@openclaw/plugin-sdk": "workspace:*"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
],
|
||||
"setupEntry": "./setup-entry.ts",
|
||||
"install": {
|
||||
"npmSpec": "@openclaw/twitch",
|
||||
"defaultChoice": "npm",
|
||||
"minHostVersion": ">=2026.4.10"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.6.11"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.6.11"
|
||||
},
|
||||
"channel": {
|
||||
"id": "twitch",
|
||||
"label": "Twitch",
|
||||
"selectionLabel": "Twitch (Chat)",
|
||||
"docsPath": "/channels/twitch",
|
||||
"blurb": "Twitch chat integration",
|
||||
"aliases": [
|
||||
"twitch-chat"
|
||||
]
|
||||
},
|
||||
"release": {
|
||||
"publishToClawHub": true,
|
||||
"publishToNpm": true
|
||||
}
|
||||
}
|
||||
}
|
||||
22
extensions/twitch/runtime-api.ts
Normal file
22
extensions/twitch/runtime-api.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
// Private runtime barrel for the bundled Twitch extension.
|
||||
// Keep this barrel thin and aligned with the local extension surface.
|
||||
|
||||
export type {
|
||||
ChannelAccountSnapshot,
|
||||
ChannelCapabilities,
|
||||
ChannelGatewayContext,
|
||||
ChannelLogSink,
|
||||
ChannelMessageActionAdapter,
|
||||
ChannelMessageActionContext,
|
||||
ChannelMeta,
|
||||
ChannelOutboundAdapter,
|
||||
ChannelOutboundContext,
|
||||
ChannelResolveKind,
|
||||
ChannelResolveResult,
|
||||
ChannelStatusAdapter,
|
||||
} from "openclaw/plugin-sdk/channel-contract";
|
||||
export type { ChannelPlugin } from "openclaw/plugin-sdk/channel-core";
|
||||
export type { OutboundDeliveryResult } from "openclaw/plugin-sdk/channel-send-result";
|
||||
export type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
export type { RuntimeEnv } from "openclaw/plugin-sdk/runtime";
|
||||
export type { WizardPrompter } from "openclaw/plugin-sdk/setup";
|
||||
10
extensions/twitch/setup-entry.ts
Normal file
10
extensions/twitch/setup-entry.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
// Twitch plugin module implements setup entry behavior.
|
||||
import { defineBundledChannelSetupEntry } from "openclaw/plugin-sdk/channel-entry-contract";
|
||||
|
||||
export default defineBundledChannelSetupEntry({
|
||||
importMetaUrl: import.meta.url,
|
||||
plugin: {
|
||||
specifier: "./setup-plugin-api.js",
|
||||
exportName: "twitchSetupPlugin",
|
||||
},
|
||||
});
|
||||
3
extensions/twitch/setup-plugin-api.ts
Normal file
3
extensions/twitch/setup-plugin-api.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
// Keep bundled setup entry imports narrow so setup loads do not pull the
|
||||
// broader Twitch channel plugin surface.
|
||||
export { twitchSetupPlugin } from "./src/setup-surface.js";
|
||||
374
extensions/twitch/src/access-control.test.ts
Normal file
374
extensions/twitch/src/access-control.test.ts
Normal file
@@ -0,0 +1,374 @@
|
||||
// Twitch tests cover access control plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { checkTwitchAccessControl } from "./access-control.js";
|
||||
import type { TwitchAccountConfig, TwitchChatMessage } from "./types.js";
|
||||
|
||||
describe("checkTwitchAccessControl", () => {
|
||||
const mockAccount: TwitchAccountConfig = {
|
||||
username: "testbot",
|
||||
accessToken: "test",
|
||||
clientId: "test-client-id",
|
||||
channel: "testchannel",
|
||||
};
|
||||
|
||||
const mockMessage: TwitchChatMessage = {
|
||||
username: "testuser",
|
||||
userId: "123456",
|
||||
message: "hello bot",
|
||||
channel: "testchannel",
|
||||
};
|
||||
|
||||
function runAccessCheck(params: {
|
||||
account?: Partial<TwitchAccountConfig>;
|
||||
message?: Partial<TwitchChatMessage>;
|
||||
}) {
|
||||
return checkTwitchAccessControl({
|
||||
message: {
|
||||
...mockMessage,
|
||||
...params.message,
|
||||
},
|
||||
account: {
|
||||
...mockAccount,
|
||||
...params.account,
|
||||
},
|
||||
botUsername: "testbot",
|
||||
});
|
||||
}
|
||||
|
||||
async function expectSingleRoleAllowed(params: {
|
||||
role: NonNullable<TwitchAccountConfig["allowedRoles"]>[number];
|
||||
message: Partial<TwitchChatMessage>;
|
||||
}) {
|
||||
const result = await runAccessCheck({
|
||||
account: { allowedRoles: [params.role] },
|
||||
message: {
|
||||
message: "@testbot hello",
|
||||
...params.message,
|
||||
},
|
||||
});
|
||||
expect(result.allowed).toBe(true);
|
||||
return result;
|
||||
}
|
||||
|
||||
async function expectAllowedAccessCheck(params: {
|
||||
account?: Partial<TwitchAccountConfig>;
|
||||
message?: Partial<TwitchChatMessage>;
|
||||
}) {
|
||||
const result = await runAccessCheck({
|
||||
account: params.account,
|
||||
message: {
|
||||
message: "@testbot hello",
|
||||
...params.message,
|
||||
},
|
||||
});
|
||||
expect(result.allowed).toBe(true);
|
||||
return result;
|
||||
}
|
||||
|
||||
async function expectAllowFromBlocked(params: {
|
||||
allowFrom: string[];
|
||||
allowedRoles?: NonNullable<TwitchAccountConfig["allowedRoles"]>;
|
||||
message?: Partial<TwitchChatMessage>;
|
||||
reason: string;
|
||||
}) {
|
||||
const result = await runAccessCheck({
|
||||
account: {
|
||||
allowFrom: params.allowFrom,
|
||||
allowedRoles: params.allowedRoles,
|
||||
},
|
||||
message: {
|
||||
message: "@testbot hello",
|
||||
...params.message,
|
||||
},
|
||||
});
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.reason).toContain(params.reason);
|
||||
}
|
||||
|
||||
describe("when no restrictions are configured", () => {
|
||||
it("allows messages that mention the bot (default requireMention)", async () => {
|
||||
const result = await runAccessCheck({
|
||||
message: {
|
||||
message: "@testbot hello",
|
||||
},
|
||||
});
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("requireMention default", () => {
|
||||
it("defaults to true when undefined", async () => {
|
||||
const result = await runAccessCheck({
|
||||
message: {
|
||||
message: "hello bot",
|
||||
},
|
||||
});
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.reason).toContain("does not mention the bot");
|
||||
});
|
||||
|
||||
it("allows mention when requireMention is undefined", async () => {
|
||||
const result = await runAccessCheck({
|
||||
message: {
|
||||
message: "@testbot hello",
|
||||
},
|
||||
});
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("requireMention", () => {
|
||||
it("allows messages that mention the bot", async () => {
|
||||
const result = await runAccessCheck({
|
||||
account: { requireMention: true },
|
||||
message: { message: "@testbot hello" },
|
||||
});
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it("blocks messages that don't mention the bot", async () => {
|
||||
const result = await runAccessCheck({
|
||||
account: { requireMention: true },
|
||||
});
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.reason).toContain("does not mention the bot");
|
||||
});
|
||||
|
||||
it("is case-insensitive for bot username", async () => {
|
||||
const result = await runAccessCheck({
|
||||
account: { requireMention: true },
|
||||
message: { message: "@TestBot hello" },
|
||||
});
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("allowFrom allowlist", () => {
|
||||
it("allows users in the allowlist", async () => {
|
||||
const result = await expectAllowedAccessCheck({
|
||||
account: {
|
||||
allowFrom: ["123456", "789012"],
|
||||
},
|
||||
});
|
||||
expect(result.matchKey).toBe("123456");
|
||||
expect(result.matchSource).toBe("allowlist");
|
||||
});
|
||||
|
||||
it("blocks users not in allowlist when allowFrom is set", async () => {
|
||||
await expectAllowFromBlocked({
|
||||
allowFrom: ["789012"],
|
||||
reason: "allowFrom",
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks everyone when allowFrom is explicitly empty", async () => {
|
||||
await expectAllowFromBlocked({
|
||||
allowFrom: [],
|
||||
reason: "allowFrom",
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks messages without userId", async () => {
|
||||
await expectAllowFromBlocked({
|
||||
allowFrom: ["123456"],
|
||||
message: { userId: undefined },
|
||||
reason: "user ID not available",
|
||||
});
|
||||
});
|
||||
|
||||
it("bypasses role checks when user is in allowlist", async () => {
|
||||
const account: TwitchAccountConfig = {
|
||||
...mockAccount,
|
||||
allowFrom: ["123456"],
|
||||
allowedRoles: ["owner"],
|
||||
};
|
||||
const message: TwitchChatMessage = {
|
||||
...mockMessage,
|
||||
message: "@testbot hello",
|
||||
isOwner: false,
|
||||
};
|
||||
|
||||
const result = await checkTwitchAccessControl({
|
||||
message,
|
||||
account,
|
||||
botUsername: "testbot",
|
||||
});
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it("blocks user with role when not in allowlist", async () => {
|
||||
await expectAllowFromBlocked({
|
||||
allowFrom: ["789012"],
|
||||
allowedRoles: ["moderator"],
|
||||
message: { userId: "123456", isMod: true },
|
||||
reason: "allowFrom",
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks user not in allowlist even when roles configured", async () => {
|
||||
await expectAllowFromBlocked({
|
||||
allowFrom: ["789012"],
|
||||
allowedRoles: ["moderator"],
|
||||
message: { userId: "123456", isMod: false },
|
||||
reason: "allowFrom",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("allowedRoles", () => {
|
||||
it("allows users with matching role", async () => {
|
||||
const result = await expectSingleRoleAllowed({
|
||||
role: "moderator",
|
||||
message: { isMod: true },
|
||||
});
|
||||
expect(result.matchSource).toBe("role");
|
||||
});
|
||||
|
||||
it("allows users with any of multiple roles", async () => {
|
||||
const account: TwitchAccountConfig = {
|
||||
...mockAccount,
|
||||
allowedRoles: ["moderator", "vip", "subscriber"],
|
||||
};
|
||||
const message: TwitchChatMessage = {
|
||||
...mockMessage,
|
||||
message: "@testbot hello",
|
||||
isVip: true,
|
||||
isMod: false,
|
||||
isSub: false,
|
||||
};
|
||||
|
||||
const result = await checkTwitchAccessControl({
|
||||
message,
|
||||
account,
|
||||
botUsername: "testbot",
|
||||
});
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it("blocks users without matching role", async () => {
|
||||
const account: TwitchAccountConfig = {
|
||||
...mockAccount,
|
||||
allowedRoles: ["moderator"],
|
||||
};
|
||||
const message: TwitchChatMessage = {
|
||||
...mockMessage,
|
||||
message: "@testbot hello",
|
||||
isMod: false,
|
||||
};
|
||||
|
||||
const result = await checkTwitchAccessControl({
|
||||
message,
|
||||
account,
|
||||
botUsername: "testbot",
|
||||
});
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.reason).toContain("does not have any of the required roles");
|
||||
});
|
||||
|
||||
it("allows all users when role is 'all'", async () => {
|
||||
const result = await expectAllowedAccessCheck({
|
||||
account: {
|
||||
allowedRoles: ["all"],
|
||||
},
|
||||
});
|
||||
expect(result.matchKey).toBe("all");
|
||||
});
|
||||
|
||||
it("handles moderator role", async () => {
|
||||
await expectSingleRoleAllowed({
|
||||
role: "moderator",
|
||||
message: { isMod: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("handles subscriber role", async () => {
|
||||
await expectSingleRoleAllowed({
|
||||
role: "subscriber",
|
||||
message: { isSub: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("handles owner role", async () => {
|
||||
await expectSingleRoleAllowed({
|
||||
role: "owner",
|
||||
message: { isOwner: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("handles vip role", async () => {
|
||||
await expectSingleRoleAllowed({
|
||||
role: "vip",
|
||||
message: { isVip: true },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("combined restrictions", () => {
|
||||
it("checks requireMention before allowlist", async () => {
|
||||
const account: TwitchAccountConfig = {
|
||||
...mockAccount,
|
||||
requireMention: true,
|
||||
allowFrom: ["123456"],
|
||||
};
|
||||
const message: TwitchChatMessage = {
|
||||
...mockMessage,
|
||||
message: "hello", // No mention
|
||||
};
|
||||
|
||||
const result = await checkTwitchAccessControl({
|
||||
message,
|
||||
account,
|
||||
botUsername: "testbot",
|
||||
});
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.reason).toContain("does not mention the bot");
|
||||
});
|
||||
|
||||
it("checks requireMention before sender allowlists for unauthorized chat", async () => {
|
||||
const result = await runAccessCheck({
|
||||
account: {
|
||||
requireMention: true,
|
||||
allowFrom: ["789012"],
|
||||
},
|
||||
message: {
|
||||
message: "ordinary chat",
|
||||
userId: "123456",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.reason).toContain("does not mention the bot");
|
||||
});
|
||||
|
||||
it("checks requireMention before role gates for unauthorized chat", async () => {
|
||||
const result = await runAccessCheck({
|
||||
account: {
|
||||
requireMention: true,
|
||||
allowedRoles: ["moderator"],
|
||||
},
|
||||
message: {
|
||||
message: "ordinary chat",
|
||||
isMod: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.reason).toContain("does not mention the bot");
|
||||
});
|
||||
|
||||
it("checks allowlist before allowedRoles", async () => {
|
||||
const result = await runAccessCheck({
|
||||
account: {
|
||||
allowFrom: ["123456"],
|
||||
allowedRoles: ["owner"],
|
||||
},
|
||||
message: {
|
||||
message: "@testbot hello",
|
||||
isOwner: false,
|
||||
},
|
||||
});
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.matchSource).toBe("allowlist");
|
||||
});
|
||||
});
|
||||
});
|
||||
196
extensions/twitch/src/access-control.ts
Normal file
196
extensions/twitch/src/access-control.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
// Twitch plugin module implements access control behavior.
|
||||
import {
|
||||
createChannelIngressResolver,
|
||||
defineStableChannelIngressIdentity,
|
||||
type ChannelIngressIdentitySubjectInput,
|
||||
type IngressReasonCode,
|
||||
} from "openclaw/plugin-sdk/channel-ingress-runtime";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import type { TwitchAccountConfig, TwitchChatMessage } from "./types.js";
|
||||
|
||||
type TwitchAccessControlResult = {
|
||||
allowed: boolean;
|
||||
reason?: string;
|
||||
matchKey?: string;
|
||||
matchSource?: string;
|
||||
};
|
||||
|
||||
type TwitchPolicyKind = "open" | "allowFrom" | "role";
|
||||
|
||||
const twitchUserIdentity = defineStableChannelIngressIdentity({
|
||||
key: "sender-id",
|
||||
entryIdPrefix: "twitch-user-entry",
|
||||
});
|
||||
|
||||
const twitchRoleIdentity = defineStableChannelIngressIdentity({
|
||||
key: "role-moderator",
|
||||
kind: "role",
|
||||
normalizeEntry: normalizeTwitchRole,
|
||||
normalizeSubject: normalizeTwitchRole,
|
||||
aliases: ["owner", "vip", "subscriber"].map((role) => ({
|
||||
key: `role-${role}`,
|
||||
kind: "role",
|
||||
normalizeEntry: () => null,
|
||||
normalizeSubject: normalizeTwitchRole,
|
||||
})),
|
||||
isWildcardEntry: (entry) => normalizeTwitchRole(entry) === "all",
|
||||
resolveEntryId: ({ entryIndex }) => `twitch-role-entry-${entryIndex + 1}`,
|
||||
});
|
||||
|
||||
export async function checkTwitchAccessControl(params: {
|
||||
message: TwitchChatMessage;
|
||||
account: TwitchAccountConfig;
|
||||
botUsername: string;
|
||||
}): Promise<TwitchAccessControlResult> {
|
||||
const { message, account, botUsername } = params;
|
||||
const policyKind = resolveTwitchPolicyKind(account);
|
||||
const resolved = await createChannelIngressResolver({
|
||||
channelId: "twitch",
|
||||
accountId: "default",
|
||||
identity: policyKind === "role" ? twitchRoleIdentity : twitchUserIdentity,
|
||||
}).message({
|
||||
subject:
|
||||
policyKind === "role"
|
||||
? twitchRoleSubject(message)
|
||||
: ({ stableId: message.userId } satisfies ChannelIngressIdentitySubjectInput),
|
||||
conversation: {
|
||||
kind: "group",
|
||||
id: message.channel,
|
||||
},
|
||||
event: { mayPair: false },
|
||||
mentionFacts: {
|
||||
canDetectMention: true,
|
||||
wasMentioned: mentionsBot(message.message, botUsername),
|
||||
},
|
||||
dmPolicy: "open",
|
||||
groupPolicy: policyKind === "open" ? "open" : "allowlist",
|
||||
policy: {
|
||||
activation: {
|
||||
requireMention: account.requireMention ?? true,
|
||||
allowTextCommands: false,
|
||||
order: "before-sender",
|
||||
},
|
||||
},
|
||||
groupAllowFrom:
|
||||
policyKind === "allowFrom"
|
||||
? account.allowFrom
|
||||
: policyKind === "role"
|
||||
? account.allowedRoles
|
||||
: undefined,
|
||||
});
|
||||
const decision = resolved.ingress;
|
||||
|
||||
if (decision.decisiveGateId === "activation" && decision.admission !== "dispatch") {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: "message does not mention the bot (requireMention is enabled)",
|
||||
};
|
||||
}
|
||||
|
||||
if (decision.admission === "dispatch") {
|
||||
if (policyKind === "allowFrom") {
|
||||
return {
|
||||
allowed: true,
|
||||
matchKey: params.message.userId,
|
||||
matchSource: "allowlist",
|
||||
};
|
||||
}
|
||||
if (policyKind === "role") {
|
||||
return {
|
||||
allowed: true,
|
||||
matchKey: params.account.allowedRoles?.join(","),
|
||||
matchSource: "role",
|
||||
};
|
||||
}
|
||||
return {
|
||||
allowed: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (policyKind === "allowFrom") {
|
||||
if (!params.message.userId) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: "sender user ID not available for allowlist check",
|
||||
};
|
||||
}
|
||||
return {
|
||||
allowed: false,
|
||||
reason: "sender is not in allowFrom allowlist",
|
||||
};
|
||||
}
|
||||
|
||||
if (policyKind === "role") {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `sender does not have any of the required roles: ${params.account.allowedRoles?.join(", ") ?? ""}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
allowed: false,
|
||||
reason: reasonForTwitchIngressDecision(decision),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveTwitchPolicyKind(account: TwitchAccountConfig): TwitchPolicyKind {
|
||||
if (account.allowFrom !== undefined) {
|
||||
return "allowFrom";
|
||||
}
|
||||
if (account.allowedRoles && account.allowedRoles.length > 0) {
|
||||
return "role";
|
||||
}
|
||||
return "open";
|
||||
}
|
||||
|
||||
function twitchRoleSubject(message: TwitchChatMessage): ChannelIngressIdentitySubjectInput {
|
||||
return {
|
||||
stableId: message.isMod ? "moderator" : undefined,
|
||||
aliases: {
|
||||
"role-owner": message.isOwner ? "owner" : undefined,
|
||||
"role-vip": message.isVip ? "vip" : undefined,
|
||||
"role-subscriber": message.isSub ? "subscriber" : undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTwitchRole(value: string): string | null {
|
||||
const role = normalizeLowercaseStringOrEmpty(value);
|
||||
if (role === "*") {
|
||||
return "all";
|
||||
}
|
||||
return role === "moderator" ||
|
||||
role === "owner" ||
|
||||
role === "vip" ||
|
||||
role === "subscriber" ||
|
||||
role === "all"
|
||||
? role
|
||||
: null;
|
||||
}
|
||||
|
||||
function reasonForTwitchIngressDecision(decision: { reasonCode: IngressReasonCode }): string {
|
||||
switch (decision.reasonCode) {
|
||||
case "activation_skipped":
|
||||
return "message does not mention the bot (requireMention is enabled)";
|
||||
case "group_policy_empty_allowlist":
|
||||
case "group_policy_not_allowlisted":
|
||||
return "sender is not in allowFrom allowlist";
|
||||
default:
|
||||
return decision.reasonCode;
|
||||
}
|
||||
}
|
||||
|
||||
function mentionsBot(message: string, botUsername: string): boolean {
|
||||
const expected = normalizeLowercaseStringOrEmpty(botUsername);
|
||||
const mentionRegex = /@(\w+)/g;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = mentionRegex.exec(message)) !== null) {
|
||||
const username = match[1] ? normalizeLowercaseStringOrEmpty(match[1]) : "";
|
||||
if (username === expected) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
76
extensions/twitch/src/actions.test.ts
Normal file
76
extensions/twitch/src/actions.test.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
// Twitch tests cover actions plugin behavior.
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { twitchMessageActions } from "./actions.js";
|
||||
import type { ResolvedTwitchAccountContext } from "./config.js";
|
||||
import { resolveTwitchAccountContext } from "./config.js";
|
||||
import { twitchOutbound } from "./outbound.js";
|
||||
|
||||
vi.mock("./config.js", () => ({
|
||||
DEFAULT_ACCOUNT_ID: "default",
|
||||
resolveTwitchAccountContext: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./outbound.js", () => ({
|
||||
twitchOutbound: {
|
||||
sendText: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
function createSecondaryAccountContext(accountId = "secondary"): ResolvedTwitchAccountContext {
|
||||
return {
|
||||
accountId,
|
||||
account: {
|
||||
channel: "secondary-channel",
|
||||
username: "secondary",
|
||||
accessToken: "oauth:secondary-token",
|
||||
clientId: "secondary-client",
|
||||
enabled: true,
|
||||
},
|
||||
tokenResolution: { source: "config", token: "oauth:secondary-token" },
|
||||
configured: true,
|
||||
availableAccountIds: ["default", "secondary"],
|
||||
};
|
||||
}
|
||||
|
||||
describe("twitchMessageActions", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("uses configured defaultAccount when action accountId is omitted", async () => {
|
||||
vi.mocked(resolveTwitchAccountContext)
|
||||
.mockImplementationOnce(() => createSecondaryAccountContext())
|
||||
.mockImplementation((_cfg, accountId) =>
|
||||
createSecondaryAccountContext(accountId?.trim() || "secondary"),
|
||||
);
|
||||
const sendText = twitchOutbound.sendText;
|
||||
if (!sendText) {
|
||||
throw new Error("twitchOutbound.sendText is unavailable");
|
||||
}
|
||||
vi.mocked(sendText).mockResolvedValue({
|
||||
channel: "twitch",
|
||||
messageId: "msg-1",
|
||||
timestamp: 1,
|
||||
});
|
||||
const cfg = {
|
||||
channels: {
|
||||
twitch: {
|
||||
defaultAccount: "secondary",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await twitchMessageActions.handleAction!({
|
||||
action: "send",
|
||||
params: { message: "Hello!" },
|
||||
cfg,
|
||||
} as never);
|
||||
|
||||
expect(twitchOutbound.sendText).toHaveBeenCalledWith({
|
||||
cfg,
|
||||
to: "secondary-channel",
|
||||
text: "Hello!",
|
||||
accountId: "secondary",
|
||||
});
|
||||
});
|
||||
});
|
||||
175
extensions/twitch/src/actions.ts
Normal file
175
extensions/twitch/src/actions.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Twitch message actions adapter.
|
||||
*
|
||||
* Handles tool-based actions for Twitch, such as sending messages.
|
||||
*/
|
||||
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { resolveTwitchAccountContext } from "./config.js";
|
||||
import { twitchOutbound } from "./outbound.js";
|
||||
import type { ChannelMessageActionAdapter, ChannelMessageActionContext } from "./types.js";
|
||||
|
||||
/**
|
||||
* Create a tool result with error content.
|
||||
*/
|
||||
function errorResponse(error: string) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: JSON.stringify({ ok: false, error }),
|
||||
},
|
||||
],
|
||||
details: { ok: false },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a string parameter from action arguments.
|
||||
*
|
||||
* @param args - Action arguments
|
||||
* @param key - Parameter key
|
||||
* @param options - Options for reading the parameter
|
||||
* @returns The parameter value or undefined if not found
|
||||
*/
|
||||
function readStringParam(
|
||||
args: Record<string, unknown>,
|
||||
key: string,
|
||||
options: { required?: boolean; trim?: boolean } = {},
|
||||
): string | undefined {
|
||||
const value = args[key];
|
||||
if (value === undefined || value === null) {
|
||||
if (options.required) {
|
||||
throw new Error(`Missing required parameter: ${key}`);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Convert value to string safely
|
||||
if (typeof value === "string") {
|
||||
return options.trim !== false ? value.trim() : value;
|
||||
}
|
||||
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
const str = String(value);
|
||||
return options.trim !== false ? str.trim() : str;
|
||||
}
|
||||
|
||||
throw new Error(`Parameter ${key} must be a string, number, or boolean`);
|
||||
}
|
||||
|
||||
/** Supported Twitch actions */
|
||||
const TWITCH_ACTIONS = new Set(["send" as const]);
|
||||
type TwitchAction = typeof TWITCH_ACTIONS extends Set<infer U> ? U : never;
|
||||
|
||||
/**
|
||||
* Twitch message actions adapter.
|
||||
*/
|
||||
export const twitchMessageActions: ChannelMessageActionAdapter = {
|
||||
/**
|
||||
* List available actions for this channel.
|
||||
*/
|
||||
describeMessageTool: () => ({ actions: [...TWITCH_ACTIONS] }),
|
||||
|
||||
/**
|
||||
* Check if an action is supported.
|
||||
*/
|
||||
supportsAction: ({ action }) => TWITCH_ACTIONS.has(action as TwitchAction),
|
||||
|
||||
/**
|
||||
* Extract tool send parameters from action arguments.
|
||||
*
|
||||
* Parses and validates the "to" and "message" parameters for sending.
|
||||
*
|
||||
* @param params - Arguments from the tool call
|
||||
* @returns Parsed send parameters or null if invalid
|
||||
*
|
||||
* @example
|
||||
* const result = twitchMessageActions.extractToolSend!({
|
||||
* args: { to: "#mychannel", message: "Hello!" }
|
||||
* });
|
||||
* // Returns: { to: "#mychannel", message: "Hello!" }
|
||||
*/
|
||||
extractToolSend: ({ args }) => {
|
||||
try {
|
||||
const to = readStringParam(args, "to", { required: true });
|
||||
const message = readStringParam(args, "message", { required: true });
|
||||
|
||||
if (!to || !message) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { to, message };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle an action execution.
|
||||
*
|
||||
* Processes the "send" action to send messages to Twitch.
|
||||
*
|
||||
* @param ctx - Action context including action type, parameters, and config
|
||||
* @returns Tool result with content or null if action not supported
|
||||
*
|
||||
* @example
|
||||
* const result = await twitchMessageActions.handleAction!({
|
||||
* action: "send",
|
||||
* params: { message: "Hello Twitch!", to: "#mychannel" },
|
||||
* cfg: openclawConfig,
|
||||
* accountId: "default",
|
||||
* });
|
||||
*/
|
||||
handleAction: async (ctx: ChannelMessageActionContext) => {
|
||||
if (ctx.action !== "send") {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: "Unsupported action" }],
|
||||
details: { ok: false, error: "Unsupported action" },
|
||||
};
|
||||
}
|
||||
|
||||
const message = readStringParam(ctx.params, "message", { required: true });
|
||||
const to = readStringParam(ctx.params, "to", { required: false });
|
||||
const accountId = ctx.accountId ?? resolveTwitchAccountContext(ctx.cfg).accountId;
|
||||
|
||||
const { account, availableAccountIds } = resolveTwitchAccountContext(ctx.cfg, accountId);
|
||||
if (!account) {
|
||||
return errorResponse(
|
||||
`Account not found: ${accountId}. Available accounts: ${availableAccountIds.join(", ") || "none"}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Use the channel from account config (or override with `to` parameter)
|
||||
const targetChannel = to || account.channel;
|
||||
if (!targetChannel) {
|
||||
return errorResponse("No channel specified and no default channel in account config");
|
||||
}
|
||||
|
||||
if (!twitchOutbound.sendText) {
|
||||
return errorResponse("sendText not implemented");
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await twitchOutbound.sendText({
|
||||
cfg: ctx.cfg,
|
||||
to: targetChannel,
|
||||
text: message ?? "",
|
||||
accountId,
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: JSON.stringify(result),
|
||||
},
|
||||
],
|
||||
details: { ok: true },
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMsg = formatErrorMessage(error);
|
||||
return errorResponse(errorMsg);
|
||||
}
|
||||
},
|
||||
};
|
||||
52
extensions/twitch/src/client-manager-registry.test.ts
Normal file
52
extensions/twitch/src/client-manager-registry.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
// Twitch tests cover client manager registry plugin behavior.
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
clearRegistryForTest,
|
||||
getClientManager,
|
||||
getOrCreateClientManager,
|
||||
removeClientManager,
|
||||
} from "./client-manager-registry.js";
|
||||
import type { ChannelLogSink } from "./types.js";
|
||||
|
||||
function makeLogger(): ChannelLogSink {
|
||||
return {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("client manager registry", () => {
|
||||
afterEach(async () => {
|
||||
await clearRegistryForTest();
|
||||
});
|
||||
|
||||
it("clears cached managers for hot module test isolation", async () => {
|
||||
const firstManager = getOrCreateClientManager("default", makeLogger());
|
||||
const disconnectAll = vi.spyOn(firstManager, "disconnectAll");
|
||||
|
||||
expect(getClientManager("default")).toBe(firstManager);
|
||||
expect(getOrCreateClientManager("default", makeLogger())).toBe(firstManager);
|
||||
|
||||
await clearRegistryForTest();
|
||||
|
||||
expect(disconnectAll).toHaveBeenCalledOnce();
|
||||
expect(getClientManager("default")).toBeUndefined();
|
||||
expect(getOrCreateClientManager("default", makeLogger())).not.toBe(firstManager);
|
||||
});
|
||||
|
||||
it("removes cached managers even when disconnectAll rejects", async () => {
|
||||
const firstManager = getOrCreateClientManager("default", makeLogger());
|
||||
const disconnectError = new Error("disconnect failed");
|
||||
const disconnectAll = vi
|
||||
.spyOn(firstManager, "disconnectAll")
|
||||
.mockRejectedValueOnce(disconnectError);
|
||||
|
||||
await expect(removeClientManager("default")).rejects.toBe(disconnectError);
|
||||
|
||||
expect(disconnectAll).toHaveBeenCalledOnce();
|
||||
expect(getClientManager("default")).toBeUndefined();
|
||||
expect(getOrCreateClientManager("default", makeLogger())).not.toBe(firstManager);
|
||||
});
|
||||
});
|
||||
109
extensions/twitch/src/client-manager-registry.ts
Normal file
109
extensions/twitch/src/client-manager-registry.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Client manager registry for Twitch plugin.
|
||||
*
|
||||
* Manages the lifecycle of TwitchClientManager instances across the plugin,
|
||||
* ensuring proper cleanup when accounts are stopped or reconfigured.
|
||||
*/
|
||||
|
||||
import { TwitchClientManager } from "./twitch-client.js";
|
||||
import type { ChannelLogSink } from "./types.js";
|
||||
|
||||
/**
|
||||
* Registry entry tracking a client manager and its associated account.
|
||||
*/
|
||||
type RegistryEntry = {
|
||||
/** The client manager instance */
|
||||
manager: TwitchClientManager;
|
||||
/** The account ID this manager is for */
|
||||
accountId: string;
|
||||
/** Logger for this entry */
|
||||
logger: ChannelLogSink;
|
||||
/** When this entry was created */
|
||||
createdAt: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Global registry of client managers.
|
||||
* Keyed by account ID.
|
||||
*/
|
||||
const registry = new Map<string, RegistryEntry>();
|
||||
|
||||
/**
|
||||
* Get or create a client manager for an account.
|
||||
*
|
||||
* @param accountId - The account ID
|
||||
* @param logger - Logger instance
|
||||
* @returns The client manager
|
||||
*/
|
||||
export function getOrCreateClientManager(
|
||||
accountId: string,
|
||||
logger: ChannelLogSink,
|
||||
): TwitchClientManager {
|
||||
const existing = registry.get(accountId);
|
||||
if (existing) {
|
||||
return existing.manager;
|
||||
}
|
||||
|
||||
const manager = new TwitchClientManager(logger);
|
||||
registry.set(accountId, {
|
||||
manager,
|
||||
accountId,
|
||||
logger,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
|
||||
logger.info(`Registered client manager for account: ${accountId}`);
|
||||
return manager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an existing client manager for an account.
|
||||
*
|
||||
* @param accountId - The account ID
|
||||
* @returns The client manager, or undefined if not registered
|
||||
*/
|
||||
export function getClientManager(accountId: string): TwitchClientManager | undefined {
|
||||
return registry.get(accountId)?.manager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect and remove a client manager from the registry.
|
||||
*
|
||||
* @param accountId - The account ID
|
||||
* @returns Promise that resolves when cleanup is complete
|
||||
*/
|
||||
export async function removeClientManager(accountId: string): Promise<void> {
|
||||
const entry = registry.get(accountId);
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await entry.manager.disconnectAll();
|
||||
} finally {
|
||||
registry.delete(accountId);
|
||||
entry.logger.info(`Unregistered client manager for account: ${accountId}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test-only: clear the module-level registry of all client manager entries.
|
||||
*
|
||||
* Mirrors the `clearForTest` escape hatch on `TwitchClientManager`. Without
|
||||
* this, the module-level `registry` Map survives across tests when vitest
|
||||
* is run with `--isolate=false` (or any harness that does not tear the
|
||||
* module graph down between cases), and a stale entry from one test will
|
||||
* shadow `getOrCreateClientManager` calls in subsequent tests, silently
|
||||
* handing back another test's mocked logger/manager. See #83887.
|
||||
*
|
||||
* Production code MUST NOT call this. It disconnects cached managers before
|
||||
* clearing the registry so tests do not leave handlers or clients behind.
|
||||
*/
|
||||
export async function clearRegistryForTest(): Promise<void> {
|
||||
const entries = [...registry.values()];
|
||||
try {
|
||||
await Promise.all(entries.map((entry) => entry.manager.disconnectAll()));
|
||||
} finally {
|
||||
registry.clear();
|
||||
}
|
||||
}
|
||||
49
extensions/twitch/src/config-schema.test.ts
Normal file
49
extensions/twitch/src/config-schema.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
// Twitch tests cover config schema plugin behavior.
|
||||
import { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-schema";
|
||||
import { validateJsonSchemaValue } from "openclaw/plugin-sdk/json-schema-runtime";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { TwitchConfigSchema } from "./config-schema.js";
|
||||
|
||||
function validateTwitchConfig(value: unknown): boolean {
|
||||
const schema = buildChannelConfigSchema(TwitchConfigSchema).schema;
|
||||
const result = validateJsonSchemaValue({
|
||||
cacheKey: "twitch.config-schema.test",
|
||||
schema,
|
||||
value,
|
||||
});
|
||||
if (!result.ok) {
|
||||
throw new Error(`expected valid Twitch config: ${JSON.stringify(result.errors)}`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
describe("TwitchConfigSchema JSON schema", () => {
|
||||
it("accepts single-account channel config with base fields", () => {
|
||||
expect(
|
||||
validateTwitchConfig({
|
||||
enabled: false,
|
||||
username: "openclaw",
|
||||
accessToken: "oauth:test",
|
||||
clientId: "test-client-id",
|
||||
channel: "openclaw-test",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts multi-account channel config with defaultAccount", () => {
|
||||
expect(
|
||||
validateTwitchConfig({
|
||||
enabled: true,
|
||||
defaultAccount: "stream",
|
||||
accounts: {
|
||||
stream: {
|
||||
username: "openclaw",
|
||||
accessToken: "oauth:test",
|
||||
clientId: "test-client-id",
|
||||
channel: "openclaw-test",
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
89
extensions/twitch/src/config-schema.ts
Normal file
89
extensions/twitch/src/config-schema.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
// Twitch helper module supports config schema behavior.
|
||||
import { MarkdownConfigSchema } from "openclaw/plugin-sdk/channel-config-primitives";
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Twitch user roles that can be allowed to interact with the bot
|
||||
*/
|
||||
const TwitchRoleSchema = z.enum(["moderator", "owner", "vip", "subscriber", "all"]);
|
||||
|
||||
const TwitchAccountShape = {
|
||||
/** Twitch username */
|
||||
username: z.string(),
|
||||
/** Twitch OAuth access token (requires chat:read and chat:write scopes) */
|
||||
accessToken: z.string(),
|
||||
/** Twitch client ID (from Twitch Developer Portal or twitchtokengenerator.com) */
|
||||
clientId: z.string().optional(),
|
||||
/** Channel name to join */
|
||||
channel: z.string().min(1),
|
||||
/** Enable this account */
|
||||
enabled: z.boolean().optional(),
|
||||
/** Allowlist of Twitch user IDs who can interact with the bot (use IDs for safety, not usernames) */
|
||||
allowFrom: z.array(z.string()).optional(),
|
||||
/** Roles allowed to interact with the bot (e.g., ["moderator", "vip", "subscriber"]) */
|
||||
allowedRoles: z.array(TwitchRoleSchema).optional(),
|
||||
/** Require @mention to trigger bot responses */
|
||||
requireMention: z.boolean().optional(),
|
||||
/** Outbound response prefix override for this channel/account. */
|
||||
responsePrefix: z.string().optional(),
|
||||
/** Twitch client secret (required for token refresh via RefreshingAuthProvider) */
|
||||
clientSecret: z.string().optional(),
|
||||
/** Refresh token (required for automatic token refresh) */
|
||||
refreshToken: z.string().optional(),
|
||||
/** Token expiry time in seconds (optional, for token refresh tracking) */
|
||||
expiresIn: z.number().nullable().optional(),
|
||||
/** Timestamp when token was obtained (optional, for token refresh tracking) */
|
||||
obtainmentTimestamp: z.number().optional(),
|
||||
};
|
||||
|
||||
/**
|
||||
* Twitch account configuration schema
|
||||
*/
|
||||
const TwitchAccountSchema = z.object(TwitchAccountShape);
|
||||
|
||||
/**
|
||||
* Base configuration properties shared by both single and multi-account modes
|
||||
*/
|
||||
const TwitchConfigBaseShape = {
|
||||
name: z.string().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
markdown: MarkdownConfigSchema.optional(),
|
||||
defaultAccount: z.string().optional(),
|
||||
};
|
||||
|
||||
/**
|
||||
* Simplified single-account configuration schema
|
||||
*
|
||||
* Use this for single-account setups. Properties are at the top level,
|
||||
* creating an implicit "default" account.
|
||||
*/
|
||||
const SimplifiedSchema = z.object({
|
||||
...TwitchConfigBaseShape,
|
||||
...TwitchAccountShape,
|
||||
});
|
||||
|
||||
/**
|
||||
* Multi-account configuration schema
|
||||
*
|
||||
* Use this for multi-account setups. Each key is an account ID (e.g., "default", "secondary").
|
||||
*/
|
||||
const MultiAccountSchema = z
|
||||
.object({
|
||||
...TwitchConfigBaseShape,
|
||||
/** Per-account configuration (for multi-account setups) */
|
||||
accounts: z.record(z.string(), TwitchAccountSchema),
|
||||
})
|
||||
.refine((val) => Object.keys(val.accounts || {}).length > 0, {
|
||||
message: "accounts must contain at least one entry",
|
||||
});
|
||||
|
||||
/**
|
||||
* Twitch plugin configuration schema
|
||||
*
|
||||
* Supports two mutually exclusive patterns:
|
||||
* 1. Simplified single-account: username, accessToken, clientId, channel at top level
|
||||
* 2. Multi-account: accounts object with named account configs
|
||||
*
|
||||
* The union ensures clear discrimination between the two modes.
|
||||
*/
|
||||
export const TwitchConfigSchema = z.union([SimplifiedSchema, MultiAccountSchema]);
|
||||
234
extensions/twitch/src/config.test.ts
Normal file
234
extensions/twitch/src/config.test.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
// Twitch tests cover config plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getAccountConfig,
|
||||
listAccountIds,
|
||||
resolveDefaultTwitchAccountId,
|
||||
resolveTwitchAccountContext,
|
||||
} from "./config.js";
|
||||
|
||||
describe("getAccountConfig", () => {
|
||||
const mockMultiAccountConfig = {
|
||||
channels: {
|
||||
twitch: {
|
||||
accounts: {
|
||||
default: {
|
||||
username: "testbot",
|
||||
accessToken: "oauth:test123",
|
||||
},
|
||||
secondary: {
|
||||
username: "secondbot",
|
||||
accessToken: "oauth:secondary",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const mockSimplifiedConfig = {
|
||||
channels: {
|
||||
twitch: {
|
||||
username: "testbot",
|
||||
accessToken: "oauth:test123",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
it("returns account config for valid account ID (multi-account)", () => {
|
||||
const result = getAccountConfig(mockMultiAccountConfig, "default");
|
||||
|
||||
expect(result?.username).toBe("testbot");
|
||||
});
|
||||
|
||||
it("returns account config for default account (simplified config)", () => {
|
||||
const result = getAccountConfig(mockSimplifiedConfig, "default");
|
||||
|
||||
expect(result?.username).toBe("testbot");
|
||||
});
|
||||
|
||||
it("returns non-default account from multi-account config", () => {
|
||||
const result = getAccountConfig(mockMultiAccountConfig, "secondary");
|
||||
|
||||
expect(result?.username).toBe("secondbot");
|
||||
});
|
||||
|
||||
it("normalizes account ids without reading inherited account properties", () => {
|
||||
const accounts = Object.create({
|
||||
inherited: {
|
||||
username: "inherited-bot",
|
||||
accessToken: "oauth:inherited",
|
||||
},
|
||||
}) as Record<string, unknown>;
|
||||
accounts.Secondary = {
|
||||
username: "secondbot",
|
||||
accessToken: "oauth:secondary",
|
||||
};
|
||||
|
||||
const cfg = {
|
||||
channels: {
|
||||
twitch: {
|
||||
accounts,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(getAccountConfig(cfg, "SECONDARY\r\n")).toEqual({
|
||||
username: "secondbot",
|
||||
accessToken: "oauth:secondary",
|
||||
});
|
||||
expect(getAccountConfig(cfg, "inherited")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for non-existent account ID", () => {
|
||||
const result = getAccountConfig(mockMultiAccountConfig, "nonexistent");
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when core config is null", () => {
|
||||
const result = getAccountConfig(null, "default");
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when core config is undefined", () => {
|
||||
const result = getAccountConfig(undefined, "default");
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when channels are not defined", () => {
|
||||
const result = getAccountConfig({}, "default");
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when twitch is not defined", () => {
|
||||
const result = getAccountConfig({ channels: {} }, "default");
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when accounts are not defined", () => {
|
||||
const result = getAccountConfig({ channels: { twitch: {} } }, "default");
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("listAccountIds", () => {
|
||||
it("includes the implicit default account from simplified config", () => {
|
||||
expect(
|
||||
listAccountIds({
|
||||
channels: {
|
||||
twitch: {
|
||||
username: "testbot",
|
||||
accessToken: "oauth:test123",
|
||||
},
|
||||
},
|
||||
} as Parameters<typeof listAccountIds>[0]),
|
||||
).toEqual(["default"]);
|
||||
});
|
||||
|
||||
it("combines explicit accounts with the implicit default account once", () => {
|
||||
expect(
|
||||
listAccountIds({
|
||||
channels: {
|
||||
twitch: {
|
||||
username: "testbot",
|
||||
accounts: {
|
||||
default: { username: "testbot" },
|
||||
secondary: { username: "secondbot" },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as Parameters<typeof listAccountIds>[0]),
|
||||
).toEqual(["default", "secondary"]);
|
||||
});
|
||||
|
||||
it("normalizes configured account ids", () => {
|
||||
expect(
|
||||
listAccountIds({
|
||||
channels: {
|
||||
twitch: {
|
||||
accounts: {
|
||||
Secondary: { username: "secondbot" },
|
||||
"Alerts\r\n\u001b[31m": { username: "alerts" },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as Parameters<typeof listAccountIds>[0]),
|
||||
).toEqual(["alerts-31m", "secondary"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveDefaultTwitchAccountId", () => {
|
||||
it("prefers channels.twitch.defaultAccount when configured", () => {
|
||||
expect(
|
||||
resolveDefaultTwitchAccountId({
|
||||
channels: {
|
||||
twitch: {
|
||||
defaultAccount: "secondary",
|
||||
accounts: {
|
||||
default: { username: "default" },
|
||||
secondary: { username: "secondary" },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as Parameters<typeof resolveDefaultTwitchAccountId>[0]),
|
||||
).toBe("secondary");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveTwitchAccountContext", () => {
|
||||
it("uses configured defaultAccount when accountId is omitted", () => {
|
||||
const context = resolveTwitchAccountContext({
|
||||
channels: {
|
||||
twitch: {
|
||||
defaultAccount: "secondary",
|
||||
accounts: {
|
||||
default: {
|
||||
username: "default-bot",
|
||||
accessToken: "oauth:default-token",
|
||||
},
|
||||
secondary: {
|
||||
username: "second-bot",
|
||||
accessToken: "oauth:second-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as Parameters<typeof resolveTwitchAccountContext>[0]);
|
||||
|
||||
expect(context.accountId).toBe("secondary");
|
||||
expect(context.account?.username).toBe("second-bot");
|
||||
});
|
||||
|
||||
it("keeps account and token lookup aligned after account id normalization", () => {
|
||||
const context = resolveTwitchAccountContext(
|
||||
{
|
||||
channels: {
|
||||
twitch: {
|
||||
accounts: {
|
||||
Secondary: {
|
||||
username: "second-bot",
|
||||
accessToken: "oauth:second-token",
|
||||
clientId: "second-client",
|
||||
channel: "#second",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as Parameters<typeof resolveTwitchAccountContext>[0],
|
||||
"secondary",
|
||||
);
|
||||
|
||||
expect(context.accountId).toBe("secondary");
|
||||
expect(context.account?.username).toBe("second-bot");
|
||||
expect(context.tokenResolution).toEqual({
|
||||
token: "oauth:second-token",
|
||||
source: "config",
|
||||
});
|
||||
expect(context.configured).toBe(true);
|
||||
});
|
||||
});
|
||||
178
extensions/twitch/src/config.ts
Normal file
178
extensions/twitch/src/config.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
// Twitch helper module supports config behavior.
|
||||
import {
|
||||
listCombinedAccountIds,
|
||||
normalizeAccountId,
|
||||
resolveNormalizedAccountEntry,
|
||||
} from "openclaw/plugin-sdk/account-resolution";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { resolveTwitchToken, type TwitchTokenResolution } from "./token.js";
|
||||
import type { TwitchAccountConfig } from "./types.js";
|
||||
import { isAccountConfigured } from "./utils/twitch.js";
|
||||
|
||||
/**
|
||||
* Default account ID for Twitch
|
||||
*/
|
||||
export const DEFAULT_ACCOUNT_ID = "default";
|
||||
|
||||
export type ResolvedTwitchAccountContext = {
|
||||
accountId: string;
|
||||
account: TwitchAccountConfig | null;
|
||||
tokenResolution: TwitchTokenResolution;
|
||||
configured: boolean;
|
||||
availableAccountIds: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Get account config from core config
|
||||
*
|
||||
* Handles two patterns:
|
||||
* 1. Simplified single-account: base-level properties create implicit "default" account
|
||||
* 2. Multi-account: explicit accounts object
|
||||
*
|
||||
* For "default" account, base-level properties take precedence over accounts.default
|
||||
* For other accounts, only the accounts object is checked
|
||||
*/
|
||||
export function getAccountConfig(
|
||||
coreConfig: unknown,
|
||||
accountId: string,
|
||||
): TwitchAccountConfig | null {
|
||||
if (!coreConfig || typeof coreConfig !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cfg = coreConfig as OpenClawConfig;
|
||||
const normalizedAccountId = normalizeAccountId(accountId);
|
||||
const twitch = cfg.channels?.twitch;
|
||||
// Access accounts via unknown to handle union type (single-account vs multi-account)
|
||||
const twitchRaw = twitch as Record<string, unknown> | undefined;
|
||||
const accounts = twitchRaw?.accounts as Record<string, TwitchAccountConfig> | undefined;
|
||||
|
||||
// For default account, check base-level config first
|
||||
if (normalizedAccountId === DEFAULT_ACCOUNT_ID) {
|
||||
const accountFromAccounts = resolveNormalizedAccountEntry(
|
||||
accounts,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
normalizeAccountId,
|
||||
);
|
||||
|
||||
// Base-level properties that can form an implicit default account
|
||||
const baseLevel = {
|
||||
username: typeof twitchRaw?.username === "string" ? twitchRaw.username : undefined,
|
||||
accessToken: typeof twitchRaw?.accessToken === "string" ? twitchRaw.accessToken : undefined,
|
||||
clientId: typeof twitchRaw?.clientId === "string" ? twitchRaw.clientId : undefined,
|
||||
channel: typeof twitchRaw?.channel === "string" ? twitchRaw.channel : undefined,
|
||||
enabled: typeof twitchRaw?.enabled === "boolean" ? twitchRaw.enabled : undefined,
|
||||
allowFrom: Array.isArray(twitchRaw?.allowFrom) ? twitchRaw.allowFrom : undefined,
|
||||
allowedRoles: Array.isArray(twitchRaw?.allowedRoles) ? twitchRaw.allowedRoles : undefined,
|
||||
requireMention:
|
||||
typeof twitchRaw?.requireMention === "boolean" ? twitchRaw.requireMention : undefined,
|
||||
clientSecret:
|
||||
typeof twitchRaw?.clientSecret === "string" ? twitchRaw.clientSecret : undefined,
|
||||
refreshToken:
|
||||
typeof twitchRaw?.refreshToken === "string" ? twitchRaw.refreshToken : undefined,
|
||||
expiresIn: typeof twitchRaw?.expiresIn === "number" ? twitchRaw.expiresIn : undefined,
|
||||
obtainmentTimestamp:
|
||||
typeof twitchRaw?.obtainmentTimestamp === "number"
|
||||
? twitchRaw.obtainmentTimestamp
|
||||
: undefined,
|
||||
};
|
||||
|
||||
// Merge: base-level takes precedence over accounts.default
|
||||
const merged: Partial<TwitchAccountConfig> = {
|
||||
...accountFromAccounts,
|
||||
...baseLevel,
|
||||
} as Partial<TwitchAccountConfig>;
|
||||
|
||||
// Only return if we have at least username
|
||||
if (merged.username) {
|
||||
return merged as TwitchAccountConfig;
|
||||
}
|
||||
|
||||
// Fall through to accounts.default if no base-level username
|
||||
if (accountFromAccounts) {
|
||||
return accountFromAccounts;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// For non-default accounts, only check accounts object
|
||||
const account = resolveNormalizedAccountEntry(accounts, normalizedAccountId, normalizeAccountId);
|
||||
if (!account) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return account;
|
||||
}
|
||||
|
||||
/**
|
||||
* List all configured account IDs
|
||||
*
|
||||
* Includes both explicit accounts and implicit "default" from base-level config
|
||||
*/
|
||||
export function listAccountIds(cfg: OpenClawConfig): string[] {
|
||||
const twitch = cfg.channels?.twitch;
|
||||
// Access accounts via unknown to handle union type (single-account vs multi-account)
|
||||
const twitchRaw = twitch as Record<string, unknown> | undefined;
|
||||
const accountMap = twitchRaw?.accounts as Record<string, unknown> | undefined;
|
||||
|
||||
// Add implicit "default" if base-level config exists and "default" not already present
|
||||
const hasBaseLevelConfig =
|
||||
twitchRaw &&
|
||||
(typeof twitchRaw.username === "string" ||
|
||||
typeof twitchRaw.accessToken === "string" ||
|
||||
typeof twitchRaw.channel === "string");
|
||||
|
||||
return listCombinedAccountIds({
|
||||
configuredAccountIds: Object.keys(accountMap ?? {}).map((accountId) =>
|
||||
normalizeAccountId(accountId),
|
||||
),
|
||||
implicitAccountId: hasBaseLevelConfig ? DEFAULT_ACCOUNT_ID : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveDefaultTwitchAccountId(cfg: OpenClawConfig): string {
|
||||
const preferredRaw =
|
||||
typeof cfg.channels?.twitch?.defaultAccount === "string"
|
||||
? cfg.channels.twitch.defaultAccount.trim()
|
||||
: "";
|
||||
const preferred = preferredRaw ? normalizeAccountId(preferredRaw) : "";
|
||||
const ids = listAccountIds(cfg);
|
||||
if (preferred && ids.includes(preferred)) {
|
||||
return preferred;
|
||||
}
|
||||
if (ids.includes(DEFAULT_ACCOUNT_ID)) {
|
||||
return DEFAULT_ACCOUNT_ID;
|
||||
}
|
||||
return ids[0] ?? DEFAULT_ACCOUNT_ID;
|
||||
}
|
||||
|
||||
export function resolveTwitchAccountContext(
|
||||
cfg: OpenClawConfig,
|
||||
accountId?: string | null,
|
||||
): ResolvedTwitchAccountContext {
|
||||
const resolvedAccountId = accountId?.trim()
|
||||
? normalizeAccountId(accountId)
|
||||
: resolveDefaultTwitchAccountId(cfg);
|
||||
const account = getAccountConfig(cfg, resolvedAccountId);
|
||||
const tokenResolution = resolveTwitchToken(cfg, { accountId: resolvedAccountId });
|
||||
return {
|
||||
accountId: resolvedAccountId,
|
||||
account,
|
||||
tokenResolution,
|
||||
configured: account ? isAccountConfigured(account, tokenResolution.token) : false,
|
||||
availableAccountIds: listAccountIds(cfg),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveTwitchSnapshotAccountId(
|
||||
cfg: OpenClawConfig,
|
||||
account: TwitchAccountConfig,
|
||||
): string {
|
||||
const twitch = (cfg as Record<string, unknown>).channels as Record<string, unknown> | undefined;
|
||||
const twitchCfg = twitch?.twitch as Record<string, unknown> | undefined;
|
||||
const accountMap = (twitchCfg?.accounts as Record<string, unknown> | undefined) ?? {};
|
||||
return (
|
||||
Object.entries(accountMap).find(([, value]) => value === account)?.[0] ?? DEFAULT_ACCOUNT_ID
|
||||
);
|
||||
}
|
||||
305
extensions/twitch/src/monitor.ts
Normal file
305
extensions/twitch/src/monitor.ts
Normal file
@@ -0,0 +1,305 @@
|
||||
/**
|
||||
* Twitch message monitor - processes incoming messages and routes to agents.
|
||||
*
|
||||
* This monitor connects to the Twitch client manager, processes incoming messages,
|
||||
* resolves agent routes, and handles replies.
|
||||
*/
|
||||
|
||||
import type { MarkdownTableMode, OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { checkTwitchAccessControl } from "./access-control.js";
|
||||
import { getOrCreateClientManager } from "./client-manager-registry.js";
|
||||
import { getTwitchRuntime } from "./runtime.js";
|
||||
import type { TwitchAccountConfig, TwitchChatMessage } from "./types.js";
|
||||
import { stripMarkdownForTwitch } from "./utils/markdown.js";
|
||||
|
||||
export type TwitchRuntimeEnv = {
|
||||
log?: (message: string) => void;
|
||||
error?: (message: string) => void;
|
||||
};
|
||||
|
||||
export type TwitchMonitorOptions = {
|
||||
account: TwitchAccountConfig;
|
||||
accountId: string;
|
||||
config: unknown; // OpenClawConfig
|
||||
runtime: TwitchRuntimeEnv;
|
||||
abortSignal: AbortSignal;
|
||||
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
||||
};
|
||||
|
||||
export type TwitchMonitorResult = {
|
||||
stop: () => void;
|
||||
};
|
||||
|
||||
type TwitchCoreRuntime = ReturnType<typeof getTwitchRuntime>;
|
||||
|
||||
/**
|
||||
* Process an incoming Twitch message and dispatch to agent.
|
||||
*/
|
||||
async function processTwitchMessage(params: {
|
||||
message: TwitchChatMessage;
|
||||
account: TwitchAccountConfig;
|
||||
accountId: string;
|
||||
config: unknown;
|
||||
runtime: TwitchRuntimeEnv;
|
||||
core: TwitchCoreRuntime;
|
||||
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
||||
}): Promise<void> {
|
||||
const { message, account, accountId, config, runtime, core, statusSink } = params;
|
||||
const cfg = config as OpenClawConfig;
|
||||
|
||||
await core.channel.inbound.run({
|
||||
channel: "twitch",
|
||||
accountId,
|
||||
raw: message,
|
||||
adapter: {
|
||||
ingest: (incoming) => ({
|
||||
id: incoming.id ?? `${incoming.channel}:${incoming.timestamp?.getTime() ?? Date.now()}`,
|
||||
timestamp: incoming.timestamp?.getTime(),
|
||||
rawText: incoming.message,
|
||||
textForAgent: incoming.message,
|
||||
textForCommands: incoming.message,
|
||||
raw: incoming,
|
||||
}),
|
||||
resolveTurn: async (input) => {
|
||||
const route = core.channel.routing.resolveAgentRoute({
|
||||
cfg,
|
||||
channel: "twitch",
|
||||
accountId,
|
||||
peer: {
|
||||
kind: "group",
|
||||
id: message.channel,
|
||||
},
|
||||
});
|
||||
const senderId = message.userId ?? message.username;
|
||||
const fromLabel = message.displayName ?? message.username;
|
||||
const body = core.channel.reply.formatAgentEnvelope({
|
||||
channel: "Twitch",
|
||||
from: fromLabel,
|
||||
timestamp: input.timestamp,
|
||||
envelope: core.channel.reply.resolveEnvelopeFormatOptions(cfg),
|
||||
body: input.rawText,
|
||||
});
|
||||
const ctxPayload = core.channel.inbound.buildContext({
|
||||
channel: "twitch",
|
||||
accountId,
|
||||
messageId: input.id,
|
||||
timestamp: input.timestamp,
|
||||
from: `twitch:user:${senderId}`,
|
||||
sender: {
|
||||
id: senderId,
|
||||
name: fromLabel,
|
||||
username: message.username,
|
||||
},
|
||||
conversation: {
|
||||
kind: "group",
|
||||
id: message.channel,
|
||||
label: message.channel,
|
||||
},
|
||||
route: {
|
||||
agentId: route.agentId,
|
||||
accountId: route.accountId,
|
||||
routeSessionKey: route.sessionKey,
|
||||
},
|
||||
reply: {
|
||||
to: `twitch:channel:${message.channel}`,
|
||||
},
|
||||
message: {
|
||||
body,
|
||||
rawBody: input.rawText,
|
||||
bodyForAgent: input.textForAgent,
|
||||
commandBody: input.textForCommands,
|
||||
},
|
||||
});
|
||||
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
|
||||
agentId: route.agentId,
|
||||
});
|
||||
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
||||
cfg,
|
||||
channel: "twitch",
|
||||
accountId,
|
||||
});
|
||||
return {
|
||||
cfg,
|
||||
channel: "twitch",
|
||||
accountId,
|
||||
agentId: route.agentId,
|
||||
routeSessionKey: route.sessionKey,
|
||||
storePath,
|
||||
ctxPayload,
|
||||
recordInboundSession: core.channel.session.recordInboundSession,
|
||||
dispatchReplyWithBufferedBlockDispatcher:
|
||||
core.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
|
||||
delivery: {
|
||||
durable: () => ({
|
||||
to: `twitch:channel:${message.channel}`,
|
||||
}),
|
||||
deliver: async (payload) => {
|
||||
return await deliverTwitchReply({
|
||||
payload,
|
||||
channel: message.channel,
|
||||
account,
|
||||
accountId,
|
||||
config,
|
||||
tableMode,
|
||||
runtime,
|
||||
});
|
||||
},
|
||||
onDelivered: (_payload, _info, result) => {
|
||||
if (result?.visibleReplySent !== false) {
|
||||
statusSink?.({ lastOutboundAt: Date.now() });
|
||||
}
|
||||
},
|
||||
onError: (err, info) => {
|
||||
runtime.error?.(`Twitch ${info.kind} reply failed: ${String(err)}`);
|
||||
},
|
||||
},
|
||||
replyPipeline: {},
|
||||
record: {
|
||||
onRecordError: (err) => {
|
||||
runtime.error?.(`Failed updating session meta: ${String(err)}`);
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver a reply to Twitch chat.
|
||||
*/
|
||||
async function deliverTwitchReply(params: {
|
||||
payload: ReplyPayload;
|
||||
channel: string;
|
||||
account: TwitchAccountConfig;
|
||||
accountId: string;
|
||||
config: unknown;
|
||||
tableMode: MarkdownTableMode;
|
||||
runtime: TwitchRuntimeEnv;
|
||||
}): Promise<{ visibleReplySent: boolean }> {
|
||||
const { payload, channel, account, accountId, config, runtime } = params;
|
||||
|
||||
try {
|
||||
const clientManager = getOrCreateClientManager(accountId, {
|
||||
info: (msg) => runtime.log?.(msg),
|
||||
warn: (msg) => runtime.log?.(msg),
|
||||
error: (msg) => runtime.error?.(msg),
|
||||
debug: (msg) => runtime.log?.(msg),
|
||||
});
|
||||
|
||||
const client = await clientManager.getClient(
|
||||
account,
|
||||
config as Parameters<typeof clientManager.getClient>[1],
|
||||
accountId,
|
||||
);
|
||||
if (!client) {
|
||||
runtime.error?.(`No client available for sending reply`);
|
||||
return { visibleReplySent: false };
|
||||
}
|
||||
|
||||
// Send the reply
|
||||
if (!payload.text) {
|
||||
runtime.error?.(`No text to send in reply payload`);
|
||||
return { visibleReplySent: false };
|
||||
}
|
||||
|
||||
const textToSend = stripMarkdownForTwitch(payload.text);
|
||||
|
||||
await client.say(channel, textToSend);
|
||||
return { visibleReplySent: true };
|
||||
} catch (err) {
|
||||
runtime.error?.(`Failed to send reply: ${String(err)}`);
|
||||
return { visibleReplySent: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main monitor provider for Twitch.
|
||||
*
|
||||
* Sets up message handlers and processes incoming messages.
|
||||
*/
|
||||
export async function monitorTwitchProvider(
|
||||
options: TwitchMonitorOptions,
|
||||
): Promise<TwitchMonitorResult> {
|
||||
const { account, accountId, config, runtime, abortSignal, statusSink } = options;
|
||||
|
||||
const core = getTwitchRuntime();
|
||||
let stopped = false;
|
||||
|
||||
const coreLogger = core.logging.getChildLogger({ module: "twitch" });
|
||||
const logVerboseMessage = (message: string) => {
|
||||
if (!core.logging.shouldLogVerbose()) {
|
||||
return;
|
||||
}
|
||||
coreLogger.debug?.(message);
|
||||
};
|
||||
const logger = {
|
||||
info: (msg: string) => coreLogger.info(msg),
|
||||
warn: (msg: string) => coreLogger.warn(msg),
|
||||
error: (msg: string) => coreLogger.error(msg),
|
||||
debug: logVerboseMessage,
|
||||
};
|
||||
|
||||
const clientManager = getOrCreateClientManager(accountId, logger);
|
||||
|
||||
try {
|
||||
await clientManager.getClient(
|
||||
account,
|
||||
config as Parameters<typeof clientManager.getClient>[1],
|
||||
accountId,
|
||||
);
|
||||
} catch (error) {
|
||||
const errorMsg = formatErrorMessage(error);
|
||||
runtime.error?.(`Failed to connect: ${errorMsg}`);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const unregisterHandler = clientManager.onMessage(account, (message) => {
|
||||
if (stopped) {
|
||||
return;
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
const botUsername = normalizeLowercaseStringOrEmpty(account.username);
|
||||
if (normalizeLowercaseStringOrEmpty(message.username) === botUsername) {
|
||||
return;
|
||||
}
|
||||
|
||||
const access = await checkTwitchAccessControl({
|
||||
message,
|
||||
account,
|
||||
botUsername,
|
||||
});
|
||||
|
||||
if (stopped || !access.allowed) {
|
||||
return;
|
||||
}
|
||||
|
||||
statusSink?.({ lastInboundAt: Date.now() });
|
||||
|
||||
await processTwitchMessage({
|
||||
message,
|
||||
account,
|
||||
accountId,
|
||||
config,
|
||||
runtime,
|
||||
core,
|
||||
statusSink,
|
||||
});
|
||||
})().catch((err: unknown) => {
|
||||
runtime.error?.(`Message processing failed: ${String(err)}`);
|
||||
});
|
||||
});
|
||||
|
||||
const stop = () => {
|
||||
stopped = true;
|
||||
unregisterHandler();
|
||||
};
|
||||
|
||||
abortSignal.addEventListener("abort", stop, { once: true });
|
||||
|
||||
return { stop };
|
||||
}
|
||||
607
extensions/twitch/src/outbound.test.ts
Normal file
607
extensions/twitch/src/outbound.test.ts
Normal file
@@ -0,0 +1,607 @@
|
||||
/**
|
||||
* Tests for outbound.ts module
|
||||
*
|
||||
* Tests cover:
|
||||
* - resolveTarget with various modes (explicit, implicit, heartbeat)
|
||||
* - sendText with markdown stripping
|
||||
* - sendMedia delegation to sendText
|
||||
* - Error handling for missing accounts/channels
|
||||
* - Abort signal handling
|
||||
*/
|
||||
|
||||
import {
|
||||
createMessageReceiptFromOutboundResults,
|
||||
verifyChannelMessageAdapterCapabilityProofs,
|
||||
} from "openclaw/plugin-sdk/channel-outbound";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { resolveTwitchAccountContext } from "./config.js";
|
||||
import { twitchMessageAdapter, twitchOutbound } from "./outbound.js";
|
||||
import {
|
||||
BASE_TWITCH_TEST_ACCOUNT,
|
||||
installTwitchTestHooks,
|
||||
makeTwitchTestConfig,
|
||||
} from "./test-fixtures.js";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("./config.js", () => ({
|
||||
DEFAULT_ACCOUNT_ID: "default",
|
||||
resolveTwitchAccountContext: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./send.js", () => ({
|
||||
sendMessageTwitchInternal: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./utils/markdown.js", () => ({
|
||||
chunkTextForTwitch: vi.fn(chunkMockTextForTwitch),
|
||||
}));
|
||||
|
||||
vi.mock("./utils/twitch.js", () => ({
|
||||
normalizeTwitchChannel: (channel: string) => channel.toLowerCase().replace(/^#/, ""),
|
||||
missingTargetError: (channel: string, hint: string) =>
|
||||
new Error(`Missing target for ${channel}. Provide ${hint}`),
|
||||
}));
|
||||
|
||||
function chunkMockTextForTwitch(text: string): string[] {
|
||||
const chunks: string[] = [];
|
||||
for (const chunk of text.split(/(.{500})/)) {
|
||||
if (chunk.length > 0) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
function assertResolvedTarget(
|
||||
result: ReturnType<NonNullable<typeof twitchOutbound.resolveTarget>>,
|
||||
): string {
|
||||
if (!result.ok) {
|
||||
throw result.error;
|
||||
}
|
||||
return result.to;
|
||||
}
|
||||
|
||||
function expectTargetError(
|
||||
resolveTarget: NonNullable<typeof twitchOutbound.resolveTarget>,
|
||||
params: Parameters<NonNullable<typeof twitchOutbound.resolveTarget>>[0],
|
||||
expectedMessage: string,
|
||||
) {
|
||||
const result = resolveTarget(params);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) {
|
||||
throw new Error("expected resolveTarget to fail");
|
||||
}
|
||||
expect(result.error.message).toContain(expectedMessage);
|
||||
}
|
||||
|
||||
function twitchTestReceipt(messageId: string) {
|
||||
return createMessageReceiptFromOutboundResults({
|
||||
results: [
|
||||
{
|
||||
channel: "twitch",
|
||||
conversationId: "testchannel",
|
||||
messageId,
|
||||
},
|
||||
],
|
||||
kind: "text",
|
||||
});
|
||||
}
|
||||
|
||||
describe("outbound", () => {
|
||||
const mockAccount = {
|
||||
...BASE_TWITCH_TEST_ACCOUNT,
|
||||
accessToken: "oauth:test123",
|
||||
};
|
||||
const resolveTarget = twitchOutbound.resolveTarget!;
|
||||
|
||||
const mockConfig = makeTwitchTestConfig(mockAccount);
|
||||
installTwitchTestHooks();
|
||||
|
||||
function setupAccountContext(params?: {
|
||||
account?: typeof mockAccount | null;
|
||||
availableAccountIds?: string[];
|
||||
}) {
|
||||
const account = params?.account === undefined ? mockAccount : params.account;
|
||||
vi.mocked(resolveTwitchAccountContext).mockImplementation((_cfg, accountId) => ({
|
||||
accountId: accountId?.trim() || "default",
|
||||
account,
|
||||
tokenResolution: { source: "config", token: account?.accessToken ?? "" },
|
||||
configured: account !== null,
|
||||
availableAccountIds: params?.availableAccountIds ?? ["default"],
|
||||
}));
|
||||
}
|
||||
|
||||
const abortedSendCases = [
|
||||
{
|
||||
name: "sendText",
|
||||
invoke: (signal: AbortSignal) =>
|
||||
twitchOutbound.sendText!({
|
||||
cfg: mockConfig,
|
||||
to: "#testchannel",
|
||||
text: "Hello!",
|
||||
accountId: "default",
|
||||
signal,
|
||||
} as Parameters<NonNullable<typeof twitchOutbound.sendText>>[0]),
|
||||
},
|
||||
{
|
||||
name: "sendMedia",
|
||||
invoke: (signal: AbortSignal) =>
|
||||
twitchOutbound.sendMedia!({
|
||||
cfg: mockConfig,
|
||||
to: "#testchannel",
|
||||
text: "Check this:",
|
||||
mediaUrl: "https://example.com/image.png",
|
||||
accountId: "default",
|
||||
signal,
|
||||
} as Parameters<NonNullable<typeof twitchOutbound.sendMedia>>[0]),
|
||||
},
|
||||
];
|
||||
|
||||
describe("abort handling", () => {
|
||||
it.each(abortedSendCases)("$name should handle abort signal", async ({ invoke }) => {
|
||||
const abortController = new AbortController();
|
||||
abortController.abort();
|
||||
|
||||
await expect(invoke(abortController.signal)).rejects.toThrow("Outbound delivery aborted");
|
||||
});
|
||||
});
|
||||
|
||||
describe("metadata", () => {
|
||||
it("should have direct delivery mode", () => {
|
||||
expect(twitchOutbound.deliveryMode).toBe("direct");
|
||||
});
|
||||
|
||||
it("should have 500 character text chunk limit", () => {
|
||||
expect(twitchOutbound.textChunkLimit).toBe(500);
|
||||
});
|
||||
|
||||
it("should chunk long messages at 500 characters", () => {
|
||||
const chunker = twitchOutbound.chunker;
|
||||
if (!chunker) {
|
||||
throw new Error("twitch outbound.chunker unavailable");
|
||||
}
|
||||
|
||||
expect(chunker("a".repeat(600), 500)).toEqual(["a".repeat(500), "a".repeat(100)]);
|
||||
});
|
||||
|
||||
it("declares message adapter durable text and media with receipt proofs", async () => {
|
||||
const { sendMessageTwitchInternal } = await import("./send.js");
|
||||
|
||||
setupAccountContext();
|
||||
vi.mocked(sendMessageTwitchInternal).mockResolvedValue({
|
||||
ok: true,
|
||||
messageId: "twitch-msg-123",
|
||||
receipt: twitchTestReceipt("twitch-msg-123"),
|
||||
});
|
||||
|
||||
const proofResults = await verifyChannelMessageAdapterCapabilityProofs({
|
||||
adapterName: "twitch",
|
||||
adapter: twitchMessageAdapter,
|
||||
proofs: {
|
||||
text: async () => {
|
||||
const result = await twitchMessageAdapter.send?.text?.({
|
||||
cfg: mockConfig,
|
||||
to: "#testchannel",
|
||||
text: "Hello Twitch!",
|
||||
accountId: "default",
|
||||
});
|
||||
expect(result?.receipt?.platformMessageIds).toEqual(["twitch-msg-123"]);
|
||||
},
|
||||
media: async () => {
|
||||
const result = await twitchMessageAdapter.send?.media?.({
|
||||
cfg: mockConfig,
|
||||
to: "#testchannel",
|
||||
text: "image",
|
||||
mediaUrl: "https://example.com/image.png",
|
||||
accountId: "default",
|
||||
});
|
||||
expect(result?.receipt?.platformMessageIds).toEqual(["twitch-msg-123"]);
|
||||
expect(sendMessageTwitchInternal).toHaveBeenLastCalledWith(
|
||||
"testchannel",
|
||||
"image https://example.com/image.png",
|
||||
mockConfig,
|
||||
"default",
|
||||
true,
|
||||
console,
|
||||
);
|
||||
},
|
||||
messageSendingHooks: () => {
|
||||
expect(twitchMessageAdapter.durableFinal?.capabilities?.messageSendingHooks).toBe(true);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(proofResults).toEqual([
|
||||
{ capability: "text", status: "verified" },
|
||||
{ capability: "media", status: "verified" },
|
||||
{ capability: "poll", status: "not_declared" },
|
||||
{ capability: "payload", status: "not_declared" },
|
||||
{ capability: "silent", status: "not_declared" },
|
||||
{ capability: "replyTo", status: "not_declared" },
|
||||
{ capability: "thread", status: "not_declared" },
|
||||
{ capability: "nativeQuote", status: "not_declared" },
|
||||
{ capability: "messageSendingHooks", status: "verified" },
|
||||
{ capability: "batch", status: "not_declared" },
|
||||
{ capability: "reconcileUnknownSend", status: "not_declared" },
|
||||
{ capability: "afterSendSuccess", status: "not_declared" },
|
||||
{ capability: "afterCommit", status: "not_declared" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("adapts outbound progress into message receipts", async () => {
|
||||
const progress = {
|
||||
channel: "twitch",
|
||||
messageId: "twitch-progress-1",
|
||||
receipt: twitchTestReceipt("twitch-progress-1"),
|
||||
};
|
||||
const sendText = twitchOutbound.sendText;
|
||||
if (!sendText) {
|
||||
throw new Error("Twitch text sending is not available.");
|
||||
}
|
||||
const sendSpy = vi.spyOn(twitchOutbound, "sendText").mockImplementationOnce(async (ctx) => {
|
||||
await ctx.onDeliveryResult?.(progress);
|
||||
return progress;
|
||||
});
|
||||
const onDeliveryResult = vi.fn();
|
||||
|
||||
try {
|
||||
await twitchMessageAdapter.send?.text?.({
|
||||
cfg: mockConfig,
|
||||
to: "#testchannel",
|
||||
text: "Hello Twitch!",
|
||||
accountId: "default",
|
||||
onDeliveryResult,
|
||||
});
|
||||
} finally {
|
||||
sendSpy.mockRestore();
|
||||
}
|
||||
|
||||
expect(onDeliveryResult).toHaveBeenCalledOnce();
|
||||
expect(onDeliveryResult.mock.calls[0]?.[0]?.receipt.platformMessageIds).toEqual([
|
||||
"twitch-progress-1",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveTarget", () => {
|
||||
it("should normalize and return target in explicit mode", () => {
|
||||
const result = resolveTarget({
|
||||
to: "#MyChannel",
|
||||
mode: "explicit",
|
||||
allowFrom: [],
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(assertResolvedTarget(result)).toBe("mychannel");
|
||||
});
|
||||
|
||||
it("should return target in implicit mode with wildcard allowlist", () => {
|
||||
const result = resolveTarget({
|
||||
to: "#AnyChannel",
|
||||
mode: "implicit",
|
||||
allowFrom: ["*"],
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(assertResolvedTarget(result)).toBe("anychannel");
|
||||
});
|
||||
|
||||
it("should return target in implicit mode when in allowlist", () => {
|
||||
const result = resolveTarget({
|
||||
to: "#allowed",
|
||||
mode: "implicit",
|
||||
allowFrom: ["#allowed", "#other"],
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(assertResolvedTarget(result)).toBe("allowed");
|
||||
});
|
||||
|
||||
it("should error when target not in allowlist (implicit mode)", () => {
|
||||
expectTargetError(
|
||||
resolveTarget,
|
||||
{
|
||||
to: "#notallowed",
|
||||
mode: "implicit",
|
||||
allowFrom: ["#primary", "#secondary"],
|
||||
},
|
||||
"Twitch",
|
||||
);
|
||||
});
|
||||
|
||||
it("should accept any target when allowlist is empty", () => {
|
||||
const result = resolveTarget({
|
||||
to: "#anychannel",
|
||||
mode: "heartbeat",
|
||||
allowFrom: [],
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(assertResolvedTarget(result)).toBe("anychannel");
|
||||
});
|
||||
|
||||
it("should error when no target provided with allowlist", () => {
|
||||
expectTargetError(
|
||||
resolveTarget,
|
||||
{
|
||||
to: undefined,
|
||||
mode: "implicit",
|
||||
allowFrom: ["#fallback", "#other"],
|
||||
},
|
||||
"Twitch",
|
||||
);
|
||||
});
|
||||
|
||||
it("should return error when no target and no allowlist", () => {
|
||||
expectTargetError(
|
||||
resolveTarget,
|
||||
{
|
||||
to: undefined,
|
||||
mode: "explicit",
|
||||
allowFrom: [],
|
||||
},
|
||||
"Missing target",
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle whitespace-only target", () => {
|
||||
expectTargetError(
|
||||
resolveTarget,
|
||||
{
|
||||
to: " ",
|
||||
mode: "explicit",
|
||||
allowFrom: [],
|
||||
},
|
||||
"Missing target",
|
||||
);
|
||||
});
|
||||
|
||||
it("should error when target normalizes to empty string", () => {
|
||||
expectTargetError(
|
||||
resolveTarget,
|
||||
{
|
||||
to: "#",
|
||||
mode: "explicit",
|
||||
allowFrom: [],
|
||||
},
|
||||
"Twitch",
|
||||
);
|
||||
});
|
||||
|
||||
it("should filter wildcard from allowlist when checking membership", () => {
|
||||
const result = resolveTarget({
|
||||
to: "#mychannel",
|
||||
mode: "implicit",
|
||||
allowFrom: ["*", "#specific"],
|
||||
});
|
||||
|
||||
// With wildcard, any target is accepted
|
||||
expect(result.ok).toBe(true);
|
||||
expect(assertResolvedTarget(result)).toBe("mychannel");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendText", () => {
|
||||
it("should send message successfully", async () => {
|
||||
const { sendMessageTwitchInternal } = await import("./send.js");
|
||||
|
||||
setupAccountContext();
|
||||
vi.mocked(sendMessageTwitchInternal).mockResolvedValue({
|
||||
ok: true,
|
||||
messageId: "twitch-msg-123",
|
||||
receipt: twitchTestReceipt("twitch-msg-123"),
|
||||
});
|
||||
|
||||
const result = await twitchOutbound.sendText!({
|
||||
cfg: mockConfig,
|
||||
to: "#testchannel",
|
||||
text: "Hello Twitch!",
|
||||
accountId: "default",
|
||||
});
|
||||
|
||||
expect(result.channel).toBe("twitch");
|
||||
expect(result.messageId).toBe("twitch-msg-123");
|
||||
expect(result.receipt?.platformMessageIds).toEqual(["twitch-msg-123"]);
|
||||
expect(sendMessageTwitchInternal).toHaveBeenCalledWith(
|
||||
"testchannel",
|
||||
"Hello Twitch!",
|
||||
mockConfig,
|
||||
"default",
|
||||
true,
|
||||
console,
|
||||
);
|
||||
expect(result.timestamp).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should throw when account not found", async () => {
|
||||
setupAccountContext({ account: null });
|
||||
|
||||
await expect(
|
||||
twitchOutbound.sendText!({
|
||||
cfg: mockConfig,
|
||||
to: "#testchannel",
|
||||
text: "Hello!",
|
||||
accountId: "nonexistent",
|
||||
}),
|
||||
).rejects.toThrow("Twitch account not found: nonexistent");
|
||||
});
|
||||
|
||||
it("should throw when no channel specified", async () => {
|
||||
const accountWithoutChannel = { ...mockAccount, channel: undefined as unknown as string };
|
||||
setupAccountContext({ account: accountWithoutChannel });
|
||||
|
||||
await expect(
|
||||
twitchOutbound.sendText!({
|
||||
cfg: mockConfig,
|
||||
to: "",
|
||||
text: "Hello!",
|
||||
accountId: "default",
|
||||
}),
|
||||
).rejects.toThrow("No channel specified");
|
||||
});
|
||||
|
||||
it("should use account channel when target not provided", async () => {
|
||||
const { sendMessageTwitchInternal } = await import("./send.js");
|
||||
|
||||
setupAccountContext();
|
||||
vi.mocked(sendMessageTwitchInternal).mockResolvedValue({
|
||||
ok: true,
|
||||
messageId: "msg-456",
|
||||
receipt: twitchTestReceipt("msg-456"),
|
||||
});
|
||||
|
||||
await twitchOutbound.sendText!({
|
||||
cfg: mockConfig,
|
||||
to: "",
|
||||
text: "Hello!",
|
||||
accountId: "default",
|
||||
});
|
||||
|
||||
expect(sendMessageTwitchInternal).toHaveBeenCalledWith(
|
||||
"testchannel",
|
||||
"Hello!",
|
||||
mockConfig,
|
||||
"default",
|
||||
true,
|
||||
console,
|
||||
);
|
||||
});
|
||||
|
||||
it("uses configured defaultAccount when accountId is omitted", async () => {
|
||||
const { sendMessageTwitchInternal } = await import("./send.js");
|
||||
|
||||
vi.mocked(resolveTwitchAccountContext)
|
||||
.mockImplementationOnce(() => ({
|
||||
accountId: "secondary",
|
||||
account: {
|
||||
...mockAccount,
|
||||
channel: "secondary-channel",
|
||||
},
|
||||
tokenResolution: { source: "config", token: mockAccount.accessToken },
|
||||
configured: true,
|
||||
availableAccountIds: ["default", "secondary"],
|
||||
}))
|
||||
.mockImplementation((_cfg, accountId) => ({
|
||||
accountId: accountId?.trim() || "secondary",
|
||||
account: {
|
||||
...mockAccount,
|
||||
channel: "secondary-channel",
|
||||
},
|
||||
tokenResolution: { source: "config", token: mockAccount.accessToken },
|
||||
configured: true,
|
||||
availableAccountIds: ["default", "secondary"],
|
||||
}));
|
||||
vi.mocked(sendMessageTwitchInternal).mockResolvedValue({
|
||||
ok: true,
|
||||
messageId: "msg-secondary",
|
||||
receipt: twitchTestReceipt("msg-secondary"),
|
||||
});
|
||||
|
||||
const defaultAccountConfig = {
|
||||
channels: {
|
||||
twitch: {
|
||||
defaultAccount: "secondary",
|
||||
},
|
||||
},
|
||||
} as typeof mockConfig;
|
||||
|
||||
await twitchOutbound.sendText!({
|
||||
cfg: defaultAccountConfig,
|
||||
to: "#secondary-channel",
|
||||
text: "Hello!",
|
||||
});
|
||||
|
||||
expect(sendMessageTwitchInternal).toHaveBeenCalledWith(
|
||||
"secondary-channel",
|
||||
"Hello!",
|
||||
defaultAccountConfig,
|
||||
"secondary",
|
||||
true,
|
||||
console,
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw on send failure", async () => {
|
||||
const { sendMessageTwitchInternal } = await import("./send.js");
|
||||
|
||||
setupAccountContext();
|
||||
vi.mocked(sendMessageTwitchInternal).mockResolvedValue({
|
||||
ok: false,
|
||||
messageId: "failed-msg",
|
||||
receipt: createMessageReceiptFromOutboundResults({ results: [] }),
|
||||
error: "Connection lost",
|
||||
});
|
||||
|
||||
await expect(
|
||||
twitchOutbound.sendText!({
|
||||
cfg: mockConfig,
|
||||
to: "#testchannel",
|
||||
text: "Hello!",
|
||||
accountId: "default",
|
||||
}),
|
||||
).rejects.toThrow("Connection lost");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendMedia", () => {
|
||||
it("should combine text and media URL", async () => {
|
||||
const { sendMessageTwitchInternal } = await import("./send.js");
|
||||
|
||||
setupAccountContext();
|
||||
vi.mocked(sendMessageTwitchInternal).mockResolvedValue({
|
||||
ok: true,
|
||||
messageId: "media-msg-123",
|
||||
receipt: twitchTestReceipt("media-msg-123"),
|
||||
});
|
||||
|
||||
const result = await twitchOutbound.sendMedia!({
|
||||
cfg: mockConfig,
|
||||
to: "#testchannel",
|
||||
text: "Check this:",
|
||||
mediaUrl: "https://example.com/image.png",
|
||||
accountId: "default",
|
||||
});
|
||||
|
||||
expect(result.channel).toBe("twitch");
|
||||
expect(result.messageId).toBe("media-msg-123");
|
||||
expect(result.receipt?.platformMessageIds).toEqual(["media-msg-123"]);
|
||||
expect(sendMessageTwitchInternal).toHaveBeenCalledWith(
|
||||
"testchannel",
|
||||
"Check this: https://example.com/image.png",
|
||||
mockConfig,
|
||||
"default",
|
||||
true,
|
||||
console,
|
||||
);
|
||||
});
|
||||
|
||||
it("should send media URL only when no text", async () => {
|
||||
const { sendMessageTwitchInternal } = await import("./send.js");
|
||||
|
||||
setupAccountContext();
|
||||
vi.mocked(sendMessageTwitchInternal).mockResolvedValue({
|
||||
ok: true,
|
||||
messageId: "media-only-msg",
|
||||
receipt: twitchTestReceipt("media-only-msg"),
|
||||
});
|
||||
|
||||
await twitchOutbound.sendMedia!({
|
||||
cfg: mockConfig,
|
||||
to: "#testchannel",
|
||||
text: "",
|
||||
mediaUrl: "https://example.com/image.png",
|
||||
accountId: "default",
|
||||
});
|
||||
|
||||
expect(sendMessageTwitchInternal).toHaveBeenCalledWith(
|
||||
"testchannel",
|
||||
"https://example.com/image.png",
|
||||
mockConfig,
|
||||
"default",
|
||||
true,
|
||||
console,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
263
extensions/twitch/src/outbound.ts
Normal file
263
extensions/twitch/src/outbound.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* Twitch outbound adapter for sending messages.
|
||||
*
|
||||
* Implements the ChannelOutboundAdapter interface for Twitch chat.
|
||||
* Supports text and media (URL) sending with markdown stripping and chunking.
|
||||
*/
|
||||
|
||||
import {
|
||||
createMessageReceiptFromOutboundResults,
|
||||
defineChannelMessageAdapter,
|
||||
type ChannelMessageSendResult,
|
||||
type MessageReceiptPartKind,
|
||||
} from "openclaw/plugin-sdk/channel-outbound";
|
||||
import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { resolveTwitchAccountContext } from "./config.js";
|
||||
import { sendMessageTwitchInternal } from "./send.js";
|
||||
import type {
|
||||
ChannelOutboundAdapter,
|
||||
ChannelOutboundContext,
|
||||
OutboundDeliveryResult,
|
||||
} from "./types.js";
|
||||
import { chunkTextForTwitch } from "./utils/markdown.js";
|
||||
import { missingTargetError, normalizeTwitchChannel } from "./utils/twitch.js";
|
||||
|
||||
/**
|
||||
* Twitch outbound adapter.
|
||||
*
|
||||
* Handles sending text and media to Twitch channels with automatic
|
||||
* markdown stripping and message chunking.
|
||||
*/
|
||||
export const twitchOutbound: ChannelOutboundAdapter = {
|
||||
/** Direct delivery mode - messages are sent immediately */
|
||||
deliveryMode: "direct",
|
||||
|
||||
deliveryCapabilities: {
|
||||
durableFinal: {
|
||||
text: true,
|
||||
media: true,
|
||||
messageSendingHooks: true,
|
||||
},
|
||||
},
|
||||
|
||||
/** Twitch chat message limit is 500 characters */
|
||||
textChunkLimit: 500,
|
||||
|
||||
/** Word-boundary chunker with markdown stripping */
|
||||
chunker: chunkTextForTwitch,
|
||||
|
||||
/**
|
||||
* Resolve target from context.
|
||||
*
|
||||
* Handles target resolution with allowlist support for implicit/heartbeat modes.
|
||||
* For explicit mode, accepts any valid channel name.
|
||||
*
|
||||
* @param params - Resolution parameters
|
||||
* @returns Resolved target or error
|
||||
*/
|
||||
resolveTarget: ({ to, allowFrom, mode }) => {
|
||||
const trimmed = to?.trim() ?? "";
|
||||
const allowListRaw = normalizeStringEntries(allowFrom ?? []);
|
||||
const hasWildcard = allowListRaw.includes("*");
|
||||
const allowList = allowListRaw
|
||||
.filter((entry: string) => entry !== "*")
|
||||
.map((entry: string) => normalizeTwitchChannel(entry))
|
||||
.filter((entry): entry is string => entry.length > 0);
|
||||
|
||||
// If target is provided, normalize and validate it
|
||||
if (trimmed) {
|
||||
const normalizedTo = normalizeTwitchChannel(trimmed);
|
||||
if (!normalizedTo) {
|
||||
return {
|
||||
ok: false,
|
||||
error: missingTargetError("Twitch", "<channel-name>"),
|
||||
};
|
||||
}
|
||||
|
||||
// For implicit/heartbeat modes with allowList, check against allowlist
|
||||
if (mode === "implicit" || mode === "heartbeat") {
|
||||
if (hasWildcard || allowList.length === 0) {
|
||||
return { ok: true, to: normalizedTo };
|
||||
}
|
||||
if (allowList.includes(normalizedTo)) {
|
||||
return { ok: true, to: normalizedTo };
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: missingTargetError("Twitch", "<channel-name>"),
|
||||
};
|
||||
}
|
||||
|
||||
// For explicit mode, accept any valid channel name
|
||||
return { ok: true, to: normalizedTo };
|
||||
}
|
||||
|
||||
// No target provided - error
|
||||
|
||||
// No target and no allowFrom - error
|
||||
return {
|
||||
ok: false,
|
||||
error: missingTargetError("Twitch", "<channel-name>"),
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Send a text message to a Twitch channel.
|
||||
*
|
||||
* Strips markdown if enabled, validates account configuration,
|
||||
* and sends the message via the Twitch client.
|
||||
*
|
||||
* @param params - Send parameters including target, text, and config
|
||||
* @returns Delivery result with message ID and status
|
||||
*
|
||||
* @example
|
||||
* const result = await twitchOutbound.sendText({
|
||||
* cfg: openclawConfig,
|
||||
* to: "#mychannel",
|
||||
* text: "Hello Twitch!",
|
||||
* accountId: "default",
|
||||
* });
|
||||
*/
|
||||
sendText: async (params: ChannelOutboundContext): Promise<OutboundDeliveryResult> => {
|
||||
const { cfg, to, text, accountId } = params;
|
||||
const signal = (params as { signal?: AbortSignal }).signal;
|
||||
|
||||
if (signal?.aborted) {
|
||||
throw new Error("Outbound delivery aborted");
|
||||
}
|
||||
|
||||
const resolvedAccountId = accountId ?? resolveTwitchAccountContext(cfg).accountId;
|
||||
const { account, availableAccountIds } = resolveTwitchAccountContext(cfg, resolvedAccountId);
|
||||
if (!account) {
|
||||
throw new Error(
|
||||
`Twitch account not found: ${resolvedAccountId}. ` +
|
||||
`Available accounts: ${availableAccountIds.join(", ") || "none"}`,
|
||||
);
|
||||
}
|
||||
|
||||
const channel = to || account.channel;
|
||||
if (!channel) {
|
||||
throw new Error("No channel specified and no default channel in account config");
|
||||
}
|
||||
|
||||
const result = await sendMessageTwitchInternal(
|
||||
normalizeTwitchChannel(channel),
|
||||
text,
|
||||
cfg,
|
||||
resolvedAccountId,
|
||||
true, // stripMarkdown
|
||||
console,
|
||||
);
|
||||
|
||||
if (!result.ok) {
|
||||
throw new Error(result.error ?? "Send failed");
|
||||
}
|
||||
|
||||
return {
|
||||
channel: "twitch",
|
||||
messageId: result.messageId,
|
||||
receipt: result.receipt,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Send media to a Twitch channel.
|
||||
*
|
||||
* Note: Twitch chat doesn't support direct media uploads.
|
||||
* This sends the media URL as text instead.
|
||||
*
|
||||
* @param params - Send parameters including media URL
|
||||
* @returns Delivery result with message ID and status
|
||||
*
|
||||
* @example
|
||||
* const result = await twitchOutbound.sendMedia({
|
||||
* cfg: openclawConfig,
|
||||
* to: "#mychannel",
|
||||
* text: "Check this out!",
|
||||
* mediaUrl: "https://example.com/image.png",
|
||||
* accountId: "default",
|
||||
* });
|
||||
*/
|
||||
sendMedia: async (params: ChannelOutboundContext): Promise<OutboundDeliveryResult> => {
|
||||
const { text, mediaUrl } = params;
|
||||
const signal = (params as { signal?: AbortSignal }).signal;
|
||||
|
||||
if (signal?.aborted) {
|
||||
throw new Error("Outbound delivery aborted");
|
||||
}
|
||||
|
||||
const message = mediaUrl ? `${text || ""} ${mediaUrl}`.trim() : text;
|
||||
|
||||
if (!twitchOutbound.sendText) {
|
||||
throw new Error("sendText not implemented");
|
||||
}
|
||||
return twitchOutbound.sendText({
|
||||
...params,
|
||||
text: message,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
function toTwitchMessageSendResult(
|
||||
result: OutboundDeliveryResult,
|
||||
kind: MessageReceiptPartKind,
|
||||
): ChannelMessageSendResult {
|
||||
const receipt =
|
||||
result.receipt ??
|
||||
createMessageReceiptFromOutboundResults({
|
||||
results: result.messageId ? [{ channel: "twitch", messageId: result.messageId }] : [],
|
||||
kind,
|
||||
});
|
||||
return {
|
||||
messageId: result.messageId || receipt.primaryPlatformMessageId,
|
||||
receipt,
|
||||
};
|
||||
}
|
||||
|
||||
export const twitchMessageAdapter = defineChannelMessageAdapter({
|
||||
id: "twitch",
|
||||
durableFinal: {
|
||||
capabilities: {
|
||||
text: true,
|
||||
media: true,
|
||||
messageSendingHooks: true,
|
||||
},
|
||||
},
|
||||
send: {
|
||||
text: async (ctx) => {
|
||||
if (!twitchOutbound.sendText) {
|
||||
throw new Error("Twitch text sending is not available.");
|
||||
}
|
||||
const { onDeliveryResult, ...outboundCtx } = ctx;
|
||||
const result = await twitchOutbound.sendText({
|
||||
...outboundCtx,
|
||||
...(onDeliveryResult
|
||||
? {
|
||||
onDeliveryResult: async (progress) => {
|
||||
await onDeliveryResult(toTwitchMessageSendResult(progress, "text"));
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
return toTwitchMessageSendResult(result, "text");
|
||||
},
|
||||
media: async (ctx) => {
|
||||
if (!twitchOutbound.sendMedia) {
|
||||
throw new Error("Twitch media sending is not available.");
|
||||
}
|
||||
const { onDeliveryResult, ...outboundCtx } = ctx;
|
||||
const result = await twitchOutbound.sendMedia({
|
||||
...outboundCtx,
|
||||
...(onDeliveryResult
|
||||
? {
|
||||
onDeliveryResult: async (progress) => {
|
||||
await onDeliveryResult(toTwitchMessageSendResult(progress, "media"));
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
return toTwitchMessageSendResult(result, "media");
|
||||
},
|
||||
},
|
||||
});
|
||||
105
extensions/twitch/src/plugin.lifecycle.test.ts
Normal file
105
extensions/twitch/src/plugin.lifecycle.test.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
// Twitch tests cover plugin.lifecycle plugin behavior.
|
||||
import {
|
||||
createStartAccountContext,
|
||||
expectLifecyclePatch,
|
||||
expectStopPendingUntilAbort,
|
||||
startAccountAndTrackLifecycle,
|
||||
waitForStartedMocks,
|
||||
} from "openclaw/plugin-sdk/channel-test-helpers";
|
||||
import type { ChannelAccountSnapshot } from "openclaw/plugin-sdk/status-helpers";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { TwitchAccountConfig } from "./types.js";
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
monitorTwitchProvider: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./monitor.js", () => ({
|
||||
monitorTwitchProvider: hoisted.monitorTwitchProvider,
|
||||
}));
|
||||
|
||||
const { twitchPlugin } = await import("./plugin.js");
|
||||
|
||||
type TwitchStartAccount = NonNullable<NonNullable<typeof twitchPlugin.gateway>["startAccount"]>;
|
||||
|
||||
function requireStartAccount(): TwitchStartAccount {
|
||||
const startAccount = twitchPlugin.gateway?.startAccount;
|
||||
if (!startAccount) {
|
||||
throw new Error("Expected Twitch gateway startAccount");
|
||||
}
|
||||
return startAccount;
|
||||
}
|
||||
|
||||
function buildAccount(): TwitchAccountConfig & { accountId: string } {
|
||||
return {
|
||||
accountId: "default",
|
||||
username: "testbot",
|
||||
accessToken: "oauth:test-token",
|
||||
clientId: "test-client-id",
|
||||
channel: "#testchannel",
|
||||
enabled: true,
|
||||
};
|
||||
}
|
||||
|
||||
function mockStartedMonitor() {
|
||||
const stop = vi.fn();
|
||||
hoisted.monitorTwitchProvider.mockResolvedValue({ stop });
|
||||
return stop;
|
||||
}
|
||||
|
||||
function startTwitchAccount(abortSignal?: AbortSignal) {
|
||||
return requireStartAccount()(
|
||||
createStartAccountContext({
|
||||
account: buildAccount(),
|
||||
abortSignal,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
describe("twitch startAccount lifecycle", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("keeps startAccount pending until abort, then stops the monitor", async () => {
|
||||
const stop = mockStartedMonitor();
|
||||
const { abort, task, isSettled } = startAccountAndTrackLifecycle({
|
||||
startAccount: requireStartAccount(),
|
||||
account: buildAccount(),
|
||||
});
|
||||
await expectStopPendingUntilAbort({
|
||||
waitForStarted: waitForStartedMocks(hoisted.monitorTwitchProvider),
|
||||
isSettled,
|
||||
abort,
|
||||
task,
|
||||
stop,
|
||||
});
|
||||
});
|
||||
|
||||
it("stops immediately when startAccount receives an already-aborted signal", async () => {
|
||||
const stop = mockStartedMonitor();
|
||||
const abort = new AbortController();
|
||||
abort.abort();
|
||||
|
||||
await startTwitchAccount(abort.signal);
|
||||
|
||||
expect(hoisted.monitorTwitchProvider).toHaveBeenCalledOnce();
|
||||
expect(stop).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("clears running status when monitor startup fails", async () => {
|
||||
hoisted.monitorTwitchProvider.mockRejectedValue(new Error("irc join failed"));
|
||||
const patches: ChannelAccountSnapshot[] = [];
|
||||
|
||||
const task = requireStartAccount()(
|
||||
createStartAccountContext({
|
||||
account: buildAccount(),
|
||||
statusPatchSink: (next) => patches.push({ ...next }),
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(task).rejects.toThrow("irc join failed");
|
||||
expectLifecyclePatch(patches, { running: true });
|
||||
expectLifecyclePatch(patches, { running: false });
|
||||
});
|
||||
});
|
||||
122
extensions/twitch/src/plugin.live.test.ts
Normal file
122
extensions/twitch/src/plugin.live.test.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Live Twitch IRC verification for the runStoppablePassiveMonitor lifecycle
|
||||
* pattern used by the Twitch gateway.
|
||||
*
|
||||
* This test connects to irc.chat.twitch.tv using the same twurple stack the
|
||||
* Twitch plugin uses, then drives that connection through the helper this PR
|
||||
* wires into twitchPlugin.gateway.startAccount. It asserts the post-fix
|
||||
* invariant — startAccount-shaped task stays pending after a successful
|
||||
* connection and only resolves when the abort signal fires — using real
|
||||
* network rather than mocks.
|
||||
*
|
||||
* Skipped by default. Enable with:
|
||||
* TWITCH_LIVE_TEST=1
|
||||
* TWITCH_USERNAME=<bot username>
|
||||
* TWITCH_ACCESS_TOKEN=<oauth:token without the "oauth:" prefix>
|
||||
* TWITCH_CLIENT_ID=<client id>
|
||||
* TWITCH_CHANNEL=<channel name to join>
|
||||
*/
|
||||
|
||||
import { StaticAuthProvider } from "@twurple/auth";
|
||||
import { ChatClient } from "@twurple/chat";
|
||||
import { runStoppablePassiveMonitor } from "openclaw/plugin-sdk/extension-shared";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const LIVE = process.env.TWITCH_LIVE_TEST === "1";
|
||||
const HAS_CREDS = Boolean(
|
||||
process.env.TWITCH_USERNAME &&
|
||||
process.env.TWITCH_ACCESS_TOKEN &&
|
||||
process.env.TWITCH_CLIENT_ID &&
|
||||
process.env.TWITCH_CHANNEL,
|
||||
);
|
||||
|
||||
const maybeDescribe = LIVE && HAS_CREDS ? describe : describe.skip;
|
||||
|
||||
maybeDescribe("twitch live IRC lifecycle (skipped unless TWITCH_LIVE_TEST=1)", () => {
|
||||
it("real twurple connection + runStoppablePassiveMonitor stays pending until abort, then stops cleanly", async () => {
|
||||
const accessTokenRaw = process.env.TWITCH_ACCESS_TOKEN!.replace(/^oauth:/, "");
|
||||
const clientId = process.env.TWITCH_CLIENT_ID!;
|
||||
const channel = process.env.TWITCH_CHANNEL!;
|
||||
const username = process.env.TWITCH_USERNAME!;
|
||||
|
||||
const start = Date.now();
|
||||
const log = (msg: string) => {
|
||||
console.log(`[T+${Date.now() - start}ms] ${msg}`);
|
||||
};
|
||||
|
||||
log(`username=${username} channel=#${channel}`);
|
||||
|
||||
const authProvider = new StaticAuthProvider(clientId, accessTokenRaw, [
|
||||
"chat:read",
|
||||
"chat:edit",
|
||||
]);
|
||||
|
||||
const abort = new AbortController();
|
||||
let connectedAt: number | null = null;
|
||||
let settled = false;
|
||||
let stopCalled = false;
|
||||
|
||||
const task = runStoppablePassiveMonitor({
|
||||
abortSignal: abort.signal,
|
||||
start: async () => {
|
||||
const chat = new ChatClient({
|
||||
authProvider,
|
||||
channels: [channel],
|
||||
authIntents: ["chat"],
|
||||
});
|
||||
|
||||
chat.onConnect(() => {
|
||||
connectedAt = Date.now() - start;
|
||||
log(`Connected to Twitch as ${username}`);
|
||||
});
|
||||
chat.onJoin((joinedChannel: string, joinedUser: string) => {
|
||||
log(`Joined #${joinedChannel} as ${joinedUser}`);
|
||||
});
|
||||
chat.onDisconnect((manually: boolean, reason?: Error) => {
|
||||
log(`Disconnected (manual=${manually}, reason=${reason?.message ?? "n/a"})`);
|
||||
});
|
||||
|
||||
chat.connect();
|
||||
|
||||
return {
|
||||
stop: () => {
|
||||
stopCalled = true;
|
||||
log(`stop() invoked`);
|
||||
chat.quit();
|
||||
},
|
||||
};
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
settled = true;
|
||||
log(`task RESOLVED`);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
settled = true;
|
||||
log(`task REJECTED: ${err instanceof Error ? err.message : String(err)}`);
|
||||
throw err;
|
||||
});
|
||||
|
||||
// Wait long enough that the original bug would have manifested.
|
||||
// The reported time-to-restart in #60071 is ~2ms after connect.
|
||||
const WATCH_MS = 15_000;
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, WATCH_MS);
|
||||
});
|
||||
|
||||
expect(connectedAt, "expected onConnect within the watch window").not.toBeNull();
|
||||
expect(settled, "task must not have settled before abort").toBe(false);
|
||||
log(
|
||||
`--- t+${WATCH_MS}ms checkpoint: connected=${connectedAt}ms, settled=${settled}, stopCalled=${stopCalled}`,
|
||||
);
|
||||
|
||||
abort.abort();
|
||||
log(`abort() called`);
|
||||
|
||||
await task;
|
||||
|
||||
expect(settled).toBe(true);
|
||||
expect(stopCalled, "stop hook must run on abort").toBe(true);
|
||||
log(`PASS — promise pending for ${WATCH_MS}ms after connect, then stopped on abort`);
|
||||
}, 60_000);
|
||||
});
|
||||
78
extensions/twitch/src/plugin.test.ts
Normal file
78
extensions/twitch/src/plugin.test.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
// Twitch tests cover plugin plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../api.js";
|
||||
import { twitchPlugin } from "./plugin.js";
|
||||
|
||||
describe("twitchPlugin pairing", () => {
|
||||
it("normalizes trimmed twitch user prefixes in allow entries", () => {
|
||||
expect(twitchPlugin.pairing?.normalizeAllowEntry?.(" twitch:user:123456 ")).toBe("123456");
|
||||
expect(twitchPlugin.pairing?.normalizeAllowEntry?.(" user789012 ")).toBe("789012");
|
||||
});
|
||||
});
|
||||
|
||||
describe("twitchPlugin.status.buildAccountSnapshot", () => {
|
||||
it("uses the resolved account ID for multi-account configs", async () => {
|
||||
const secondary = {
|
||||
channel: "secondary-channel",
|
||||
username: "secondary",
|
||||
accessToken: "oauth:secondary-token",
|
||||
clientId: "secondary-client",
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
const cfg = {
|
||||
channels: {
|
||||
twitch: {
|
||||
accounts: {
|
||||
default: {
|
||||
channel: "default-channel",
|
||||
username: "default",
|
||||
accessToken: "oauth:default-token",
|
||||
clientId: "default-client",
|
||||
enabled: true,
|
||||
},
|
||||
secondary,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const snapshot = await twitchPlugin.status?.buildAccountSnapshot?.({
|
||||
account: secondary,
|
||||
cfg,
|
||||
});
|
||||
|
||||
expect(snapshot?.accountId).toBe("secondary");
|
||||
});
|
||||
});
|
||||
|
||||
describe("twitchPlugin.config", () => {
|
||||
it("uses configured defaultAccount for omitted-account plugin resolution", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
twitch: {
|
||||
defaultAccount: "secondary",
|
||||
accounts: {
|
||||
default: {
|
||||
channel: "default-channel",
|
||||
username: "default",
|
||||
accessToken: "oauth:default-token",
|
||||
clientId: "default-client",
|
||||
enabled: true,
|
||||
},
|
||||
secondary: {
|
||||
channel: "secondary-channel",
|
||||
username: "secondary",
|
||||
accessToken: "oauth:secondary-token",
|
||||
clientId: "secondary-client",
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
expect(twitchPlugin.config.defaultAccountId?.(cfg)).toBe("secondary");
|
||||
expect(twitchPlugin.config.resolveAccount(cfg).accountId).toBe("secondary");
|
||||
});
|
||||
});
|
||||
229
extensions/twitch/src/plugin.ts
Normal file
229
extensions/twitch/src/plugin.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* Twitch channel plugin for OpenClaw.
|
||||
*
|
||||
* Main plugin export combining all adapters (outbound, actions, status, gateway).
|
||||
* This is the primary entry point for the Twitch channel integration.
|
||||
*/
|
||||
|
||||
import { describeAccountSnapshot } from "openclaw/plugin-sdk/account-helpers";
|
||||
import { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-schema";
|
||||
import { createChatChannelPlugin } from "openclaw/plugin-sdk/channel-core";
|
||||
import {
|
||||
createLoggedPairingApprovalNotifier,
|
||||
createPairingPrefixStripper,
|
||||
} from "openclaw/plugin-sdk/channel-pairing";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import {
|
||||
buildPassiveProbedChannelStatusSummary,
|
||||
runStoppablePassiveMonitor,
|
||||
} from "openclaw/plugin-sdk/extension-shared";
|
||||
import {
|
||||
createComputedAccountStatusAdapter,
|
||||
createDefaultChannelRuntimeState,
|
||||
} from "openclaw/plugin-sdk/status-helpers";
|
||||
import { twitchMessageActions } from "./actions.js";
|
||||
import { removeClientManager } from "./client-manager-registry.js";
|
||||
import { TwitchConfigSchema } from "./config-schema.js";
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
getAccountConfig,
|
||||
listAccountIds,
|
||||
resolveDefaultTwitchAccountId,
|
||||
resolveTwitchAccountContext,
|
||||
resolveTwitchSnapshotAccountId,
|
||||
} from "./config.js";
|
||||
import { twitchMessageAdapter, twitchOutbound } from "./outbound.js";
|
||||
import { probeTwitch } from "./probe.js";
|
||||
import { resolveTwitchTargets } from "./resolver.js";
|
||||
import { twitchSetupAdapter, twitchSetupWizard } from "./setup-surface.js";
|
||||
import { collectTwitchStatusIssues } from "./status.js";
|
||||
import type {
|
||||
ChannelLogSink,
|
||||
ChannelPlugin,
|
||||
ChannelResolveKind,
|
||||
ChannelResolveResult,
|
||||
TwitchAccountConfig,
|
||||
} from "./types.js";
|
||||
import { isAccountConfigured } from "./utils/twitch.js";
|
||||
|
||||
type ResolvedTwitchAccount = TwitchAccountConfig & { accountId?: string | null };
|
||||
|
||||
/**
|
||||
* Twitch channel plugin.
|
||||
*
|
||||
* Implements the ChannelPlugin interface to provide Twitch chat integration
|
||||
* for OpenClaw. Supports message sending, receiving, access control, and
|
||||
* status monitoring.
|
||||
*/
|
||||
export const twitchPlugin: ChannelPlugin<ResolvedTwitchAccount> =
|
||||
createChatChannelPlugin<ResolvedTwitchAccount>({
|
||||
pairing: {
|
||||
idLabel: "twitchUserId",
|
||||
normalizeAllowEntry: createPairingPrefixStripper(/^(twitch:)?user:?/i),
|
||||
notifyApproval: createLoggedPairingApprovalNotifier(
|
||||
({ id }) => `Pairing approved for user ${id} (notification sent via chat if possible)`,
|
||||
console.warn,
|
||||
),
|
||||
},
|
||||
outbound: twitchOutbound,
|
||||
base: {
|
||||
id: "twitch",
|
||||
meta: {
|
||||
id: "twitch",
|
||||
label: "Twitch",
|
||||
selectionLabel: "Twitch (Chat)",
|
||||
docsPath: "/channels/twitch",
|
||||
blurb: "Twitch chat integration",
|
||||
aliases: ["twitch-chat"],
|
||||
},
|
||||
setup: twitchSetupAdapter,
|
||||
setupWizard: twitchSetupWizard,
|
||||
capabilities: {
|
||||
chatTypes: ["group"],
|
||||
},
|
||||
message: twitchMessageAdapter,
|
||||
configSchema: buildChannelConfigSchema(TwitchConfigSchema),
|
||||
config: {
|
||||
listAccountIds: (cfg: OpenClawConfig): string[] => listAccountIds(cfg),
|
||||
resolveAccount: (cfg: OpenClawConfig, accountId?: string | null): ResolvedTwitchAccount => {
|
||||
const resolvedAccountId = accountId ?? resolveDefaultTwitchAccountId(cfg);
|
||||
const account = getAccountConfig(cfg, resolvedAccountId);
|
||||
if (!account) {
|
||||
return {
|
||||
accountId: resolvedAccountId,
|
||||
channel: "",
|
||||
username: "",
|
||||
accessToken: "",
|
||||
clientId: "",
|
||||
enabled: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
accountId: resolvedAccountId,
|
||||
...account,
|
||||
};
|
||||
},
|
||||
defaultAccountId: (cfg: OpenClawConfig): string => resolveDefaultTwitchAccountId(cfg),
|
||||
isConfigured: (_account: unknown, cfg: OpenClawConfig): boolean =>
|
||||
resolveTwitchAccountContext(cfg).configured,
|
||||
isEnabled: (account: ResolvedTwitchAccount | undefined): boolean =>
|
||||
account?.enabled !== false,
|
||||
describeAccount: (account: TwitchAccountConfig | undefined) =>
|
||||
account
|
||||
? describeAccountSnapshot({
|
||||
account,
|
||||
configured: isAccountConfigured(account, account.accessToken),
|
||||
})
|
||||
: {
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
enabled: false,
|
||||
configured: false,
|
||||
},
|
||||
},
|
||||
actions: twitchMessageActions,
|
||||
resolver: {
|
||||
resolveTargets: async ({
|
||||
cfg,
|
||||
accountId,
|
||||
inputs,
|
||||
kind,
|
||||
runtime,
|
||||
}: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
inputs: string[];
|
||||
kind: ChannelResolveKind;
|
||||
runtime: import("openclaw/plugin-sdk/runtime-env").RuntimeEnv;
|
||||
}): Promise<ChannelResolveResult[]> => {
|
||||
const account = getAccountConfig(cfg, accountId ?? resolveDefaultTwitchAccountId(cfg));
|
||||
if (!account) {
|
||||
return inputs.map((input) => ({
|
||||
input,
|
||||
resolved: false,
|
||||
note: "account not configured",
|
||||
}));
|
||||
}
|
||||
|
||||
const log: ChannelLogSink = {
|
||||
info: (msg) => runtime.log(msg),
|
||||
warn: (msg) => runtime.log(msg),
|
||||
error: (msg) => runtime.error(msg),
|
||||
debug: (msg) => runtime.log(msg),
|
||||
};
|
||||
return await resolveTwitchTargets(inputs, account, kind, log);
|
||||
},
|
||||
},
|
||||
status: createComputedAccountStatusAdapter<ResolvedTwitchAccount>({
|
||||
defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID),
|
||||
buildChannelSummary: ({ snapshot }) => buildPassiveProbedChannelStatusSummary(snapshot),
|
||||
probeAccount: async ({ account, timeoutMs }) => await probeTwitch(account, timeoutMs),
|
||||
collectStatusIssues: collectTwitchStatusIssues,
|
||||
resolveAccountSnapshot: ({ account, cfg }) => {
|
||||
const resolvedAccountId =
|
||||
account.accountId || resolveTwitchSnapshotAccountId(cfg, account);
|
||||
const { configured } = resolveTwitchAccountContext(cfg, resolvedAccountId);
|
||||
return {
|
||||
accountId: resolvedAccountId,
|
||||
enabled: account.enabled !== false,
|
||||
configured,
|
||||
};
|
||||
},
|
||||
}),
|
||||
gateway: {
|
||||
startAccount: async (ctx): Promise<void> => {
|
||||
const account = ctx.account;
|
||||
const accountId = ctx.accountId;
|
||||
|
||||
ctx.setStatus?.({
|
||||
accountId,
|
||||
running: true,
|
||||
lastStartAt: Date.now(),
|
||||
lastError: null,
|
||||
});
|
||||
|
||||
ctx.log?.info(`Starting Twitch connection for ${account.username}`);
|
||||
|
||||
// Keep startAccount pending until abort fires; otherwise the channel
|
||||
// supervisor reads the settled task as `channel exited without an
|
||||
// error` and triggers a restart loop. See #60071.
|
||||
try {
|
||||
await runStoppablePassiveMonitor({
|
||||
abortSignal: ctx.abortSignal,
|
||||
start: async () => {
|
||||
// Lazy import: the monitor pulls the reply pipeline; avoid ESM init cycles.
|
||||
const { monitorTwitchProvider } = await import("./monitor.js");
|
||||
return monitorTwitchProvider({
|
||||
account,
|
||||
accountId,
|
||||
config: ctx.cfg,
|
||||
runtime: ctx.runtime,
|
||||
abortSignal: ctx.abortSignal,
|
||||
});
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
ctx.setStatus?.({
|
||||
accountId,
|
||||
running: false,
|
||||
lastStopAt: Date.now(),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
stopAccount: async (ctx): Promise<void> => {
|
||||
const account = ctx.account;
|
||||
const accountId = ctx.accountId;
|
||||
|
||||
await removeClientManager(accountId);
|
||||
|
||||
ctx.setStatus?.({
|
||||
accountId,
|
||||
running: false,
|
||||
lastStopAt: Date.now(),
|
||||
});
|
||||
|
||||
ctx.log?.info(`Stopped Twitch connection for ${account.username}`);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
197
extensions/twitch/src/probe.test.ts
Normal file
197
extensions/twitch/src/probe.test.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
// Twitch tests cover probe plugin behavior.
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { probeTwitch } from "./probe.js";
|
||||
import type { TwitchAccountConfig } from "./types.js";
|
||||
|
||||
// Mock Twurple modules - Vitest v4 compatible mocking
|
||||
const mockUnbind = vi.fn();
|
||||
|
||||
// Event handler storage
|
||||
let connectHandler: (() => void) | null = null;
|
||||
let disconnectHandler: ((manually: boolean, reason?: Error) => void) | null = null;
|
||||
|
||||
// Event listener mocks that store handlers and return unbind function
|
||||
const mockOnConnect = vi.fn((handler: () => void) => {
|
||||
connectHandler = handler;
|
||||
return { unbind: mockUnbind };
|
||||
});
|
||||
|
||||
const mockOnDisconnect = vi.fn((handler: (manually: boolean, reason?: Error) => void) => {
|
||||
disconnectHandler = handler;
|
||||
return { unbind: mockUnbind };
|
||||
});
|
||||
|
||||
const mockOnAuthenticationFailure = vi.fn((_handler: () => void) => {
|
||||
return { unbind: mockUnbind };
|
||||
});
|
||||
|
||||
// Connect mock that triggers the registered handler
|
||||
const defaultConnectImpl = async () => {
|
||||
// Simulate successful connection by calling the handler immediately.
|
||||
if (connectHandler) {
|
||||
connectHandler();
|
||||
}
|
||||
};
|
||||
|
||||
const mockConnect = vi.fn().mockImplementation(defaultConnectImpl);
|
||||
|
||||
const mockQuit = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
vi.mock("@twurple/chat", () => ({
|
||||
ChatClient: class {
|
||||
connect = mockConnect;
|
||||
quit = mockQuit;
|
||||
onConnect = mockOnConnect;
|
||||
onDisconnect = mockOnDisconnect;
|
||||
onAuthenticationFailure = mockOnAuthenticationFailure;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@twurple/auth", () => ({
|
||||
StaticAuthProvider: function StaticAuthProvider() {},
|
||||
}));
|
||||
|
||||
describe("probeTwitch", () => {
|
||||
const mockAccount: TwitchAccountConfig = {
|
||||
username: "testbot",
|
||||
accessToken: "oauth:test123456789",
|
||||
clientId: "test-client-id",
|
||||
channel: "testchannel",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Reset handlers
|
||||
connectHandler = null;
|
||||
disconnectHandler = null;
|
||||
});
|
||||
|
||||
it("returns error when username is missing", async () => {
|
||||
const account = { ...mockAccount, username: "" };
|
||||
const result = await probeTwitch(account, 5000);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toContain("missing credentials");
|
||||
});
|
||||
|
||||
it("returns error when token is missing", async () => {
|
||||
const account = { ...mockAccount, accessToken: "" };
|
||||
const result = await probeTwitch(account, 5000);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toContain("missing credentials");
|
||||
});
|
||||
|
||||
it("attempts connection regardless of token prefix", async () => {
|
||||
// Note: probeTwitch doesn't validate token format - it tries to connect with whatever token is provided
|
||||
// The actual connection would fail in production with an invalid token
|
||||
const account = { ...mockAccount, accessToken: "raw_token_no_prefix" };
|
||||
const result = await probeTwitch(account, 5000);
|
||||
|
||||
// With mock, connection succeeds even without oauth: prefix
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("successfully connects with valid credentials", async () => {
|
||||
const result = await probeTwitch(mockAccount, 5000);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.connected).toBe(true);
|
||||
expect(result.username).toBe("testbot");
|
||||
expect(result.channel).toBe("testchannel"); // uses account's configured channel
|
||||
});
|
||||
|
||||
it("uses custom channel when specified", async () => {
|
||||
const account: TwitchAccountConfig = {
|
||||
...mockAccount,
|
||||
channel: "customchannel",
|
||||
};
|
||||
|
||||
const result = await probeTwitch(account, 5000);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.channel).toBe("customchannel");
|
||||
});
|
||||
|
||||
it("times out when connection takes too long", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
mockConnect.mockImplementationOnce(() => new Promise(() => {})); // Never resolves
|
||||
const resultPromise = probeTwitch(mockAccount, 100);
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
const result = await resultPromise;
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toContain("timeout");
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
mockConnect.mockImplementation(defaultConnectImpl);
|
||||
}
|
||||
});
|
||||
|
||||
it("cleans up client even on failure", async () => {
|
||||
mockConnect.mockImplementationOnce(async () => {
|
||||
// Simulate connection failure by calling disconnect handler
|
||||
// onDisconnect signature: (manually: boolean, reason?: Error) => void
|
||||
if (disconnectHandler) {
|
||||
disconnectHandler(false, new Error("Connection failed"));
|
||||
}
|
||||
});
|
||||
|
||||
const result = await probeTwitch(mockAccount, 5000);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toContain("Connection failed");
|
||||
expect(mockQuit).toHaveBeenCalled();
|
||||
|
||||
// Reset mocks
|
||||
mockConnect.mockImplementation(defaultConnectImpl);
|
||||
});
|
||||
|
||||
it("handles connection errors gracefully", async () => {
|
||||
mockConnect.mockImplementationOnce(async () => {
|
||||
// Simulate connection failure by calling disconnect handler
|
||||
// onDisconnect signature: (manually: boolean, reason?: Error) => void
|
||||
if (disconnectHandler) {
|
||||
disconnectHandler(false, new Error("Network error"));
|
||||
}
|
||||
});
|
||||
|
||||
const result = await probeTwitch(mockAccount, 5000);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toContain("Network error");
|
||||
|
||||
// Reset mock
|
||||
mockConnect.mockImplementation(defaultConnectImpl);
|
||||
});
|
||||
|
||||
it("trims token before validation", async () => {
|
||||
const account: TwitchAccountConfig = {
|
||||
...mockAccount,
|
||||
accessToken: " oauth:test123456789 ",
|
||||
};
|
||||
|
||||
const result = await probeTwitch(account, 5000);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("handles non-Error objects in catch block", async () => {
|
||||
mockConnect.mockImplementationOnce(async () => {
|
||||
// Simulate connection failure by calling disconnect handler
|
||||
// onDisconnect signature: (manually: boolean, reason?: Error) => void
|
||||
if (disconnectHandler) {
|
||||
disconnectHandler(false, "String error" as unknown as Error);
|
||||
}
|
||||
});
|
||||
|
||||
const result = await probeTwitch(mockAccount, 5000);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toBe("String error");
|
||||
|
||||
// Reset mock
|
||||
mockConnect.mockImplementation(defaultConnectImpl);
|
||||
});
|
||||
});
|
||||
132
extensions/twitch/src/probe.ts
Normal file
132
extensions/twitch/src/probe.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
// Twitch plugin module implements probe behavior.
|
||||
import { StaticAuthProvider } from "@twurple/auth";
|
||||
import { ChatClient } from "@twurple/chat";
|
||||
import type { BaseProbeResult } from "openclaw/plugin-sdk/channel-contract";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import type { TwitchAccountConfig } from "./types.js";
|
||||
import { normalizeToken } from "./utils/twitch.js";
|
||||
|
||||
/**
|
||||
* Result of probing a Twitch account
|
||||
*/
|
||||
type ProbeTwitchResult = BaseProbeResult<string> & {
|
||||
username?: string;
|
||||
elapsedMs: number;
|
||||
connected?: boolean;
|
||||
channel?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Probe a Twitch account to verify the connection is working
|
||||
*
|
||||
* This tests the Twitch OAuth token by attempting to connect
|
||||
* to the chat server and verify the bot's username.
|
||||
*/
|
||||
export async function probeTwitch(
|
||||
account: TwitchAccountConfig,
|
||||
timeoutMs: number,
|
||||
): Promise<ProbeTwitchResult> {
|
||||
const started = Date.now();
|
||||
|
||||
if (!account.accessToken || !account.username) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "missing credentials (accessToken, username)",
|
||||
username: account.username,
|
||||
elapsedMs: Date.now() - started,
|
||||
};
|
||||
}
|
||||
|
||||
const rawToken = normalizeToken(account.accessToken.trim());
|
||||
|
||||
let client: ChatClient | undefined;
|
||||
|
||||
try {
|
||||
const authProvider = new StaticAuthProvider(account.clientId ?? "", rawToken);
|
||||
|
||||
client = new ChatClient({
|
||||
authProvider,
|
||||
});
|
||||
|
||||
// Create a promise that resolves when connected
|
||||
const connectionPromise = new Promise<void>((resolve, reject) => {
|
||||
let settled = false;
|
||||
|
||||
const cleanup = () => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
connectListener?.unbind();
|
||||
disconnectListener?.unbind();
|
||||
authFailListener?.unbind();
|
||||
};
|
||||
|
||||
// Success: connection established
|
||||
const connectListener: ReturnType<ChatClient["onConnect"]> | undefined = client?.onConnect(
|
||||
() => {
|
||||
cleanup();
|
||||
resolve();
|
||||
},
|
||||
);
|
||||
|
||||
// Failure: disconnected (e.g., auth failed)
|
||||
const disconnectListener: ReturnType<ChatClient["onDisconnect"]> | undefined =
|
||||
client?.onDisconnect((_manually, reason) => {
|
||||
cleanup();
|
||||
reject(reason || new Error("Disconnected"));
|
||||
});
|
||||
|
||||
// Failure: authentication failed
|
||||
const authFailListener: ReturnType<ChatClient["onAuthenticationFailure"]> | undefined =
|
||||
client?.onAuthenticationFailure(() => {
|
||||
cleanup();
|
||||
reject(new Error("Authentication failed"));
|
||||
});
|
||||
});
|
||||
|
||||
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
timeoutHandle = setTimeout(
|
||||
() => reject(new Error(`timeout after ${timeoutMs}ms`)),
|
||||
timeoutMs,
|
||||
);
|
||||
});
|
||||
|
||||
client.connect();
|
||||
try {
|
||||
await Promise.race([connectionPromise, timeout]);
|
||||
} finally {
|
||||
if (timeoutHandle) {
|
||||
clearTimeout(timeoutHandle);
|
||||
}
|
||||
}
|
||||
|
||||
client.quit();
|
||||
client = undefined;
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
connected: true,
|
||||
username: account.username,
|
||||
channel: account.channel,
|
||||
elapsedMs: Date.now() - started,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
error: formatErrorMessage(error),
|
||||
username: account.username,
|
||||
channel: account.channel,
|
||||
elapsedMs: Date.now() - started,
|
||||
};
|
||||
} finally {
|
||||
if (client) {
|
||||
try {
|
||||
client.quit();
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
139
extensions/twitch/src/resolver.ts
Normal file
139
extensions/twitch/src/resolver.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Twitch resolver adapter for channel/user name resolution.
|
||||
*
|
||||
* This module implements the ChannelResolverAdapter interface to resolve
|
||||
* Twitch usernames to user IDs via the Twitch Helix API.
|
||||
*/
|
||||
|
||||
import { ApiClient } from "@twurple/api";
|
||||
import { StaticAuthProvider } from "@twurple/auth";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import type { ChannelResolveKind, ChannelResolveResult } from "./types.js";
|
||||
import type { ChannelLogSink, TwitchAccountConfig } from "./types.js";
|
||||
import { normalizeToken } from "./utils/twitch.js";
|
||||
|
||||
/**
|
||||
* Normalize a Twitch username - strip @ prefix and convert to lowercase
|
||||
*/
|
||||
function normalizeUsername(input: string): string {
|
||||
const trimmed = input.trim();
|
||||
if (trimmed.startsWith("@")) {
|
||||
return normalizeLowercaseStringOrEmpty(trimmed.slice(1));
|
||||
}
|
||||
return normalizeLowercaseStringOrEmpty(trimmed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a logger that includes the Twitch prefix
|
||||
*/
|
||||
function createLogger(logger?: ChannelLogSink): ChannelLogSink {
|
||||
return {
|
||||
info: (msg: string) => logger?.info(msg),
|
||||
warn: (msg: string) => logger?.warn(msg),
|
||||
error: (msg: string) => logger?.error(msg),
|
||||
debug: (msg: string) => logger?.debug?.(msg) ?? (() => {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve Twitch usernames to user IDs via the Helix API
|
||||
*
|
||||
* @param inputs - Array of usernames or user IDs to resolve
|
||||
* @param account - Twitch account configuration with auth credentials
|
||||
* @param kind - Type of target to resolve ("user" or "group")
|
||||
* @param logger - Optional logger
|
||||
* @returns Promise resolving to array of ChannelResolveResult
|
||||
*/
|
||||
export async function resolveTwitchTargets(
|
||||
inputs: string[],
|
||||
account: TwitchAccountConfig,
|
||||
_kind: ChannelResolveKind,
|
||||
logger?: ChannelLogSink,
|
||||
): Promise<ChannelResolveResult[]> {
|
||||
const log = createLogger(logger);
|
||||
|
||||
if (!account.clientId || !account.accessToken) {
|
||||
log.error("Missing Twitch client ID or accessToken");
|
||||
return inputs.map((input) => ({
|
||||
input,
|
||||
resolved: false,
|
||||
note: "missing Twitch credentials",
|
||||
}));
|
||||
}
|
||||
|
||||
const normalizedToken = normalizeToken(account.accessToken);
|
||||
|
||||
const authProvider = new StaticAuthProvider(account.clientId, normalizedToken);
|
||||
const apiClient = new ApiClient({ authProvider });
|
||||
|
||||
const results: ChannelResolveResult[] = [];
|
||||
|
||||
for (const input of inputs) {
|
||||
const normalized = normalizeUsername(input);
|
||||
|
||||
if (!normalized) {
|
||||
results.push({
|
||||
input,
|
||||
resolved: false,
|
||||
note: "empty input",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const looksLikeUserId = /^\d+$/.test(normalized);
|
||||
|
||||
try {
|
||||
if (looksLikeUserId) {
|
||||
const user = await apiClient.users.getUserById(normalized);
|
||||
|
||||
if (user) {
|
||||
results.push({
|
||||
input,
|
||||
resolved: true,
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
});
|
||||
log.debug?.(`Resolved user ID ${normalized} -> ${user.name}`);
|
||||
} else {
|
||||
results.push({
|
||||
input,
|
||||
resolved: false,
|
||||
note: "user ID not found",
|
||||
});
|
||||
log.warn(`User ID ${normalized} not found`);
|
||||
}
|
||||
} else {
|
||||
const user = await apiClient.users.getUserByName(normalized);
|
||||
|
||||
if (user) {
|
||||
results.push({
|
||||
input,
|
||||
resolved: true,
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
note: user.displayName !== user.name ? `display: ${user.displayName}` : undefined,
|
||||
});
|
||||
log.debug?.(`Resolved username ${normalized} -> ${user.id} (${user.name})`);
|
||||
} else {
|
||||
results.push({
|
||||
input,
|
||||
resolved: false,
|
||||
note: "username not found",
|
||||
});
|
||||
log.warn(`Username ${normalized} not found`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = formatErrorMessage(error);
|
||||
results.push({
|
||||
input,
|
||||
resolved: false,
|
||||
note: `API error: ${errorMessage}`,
|
||||
});
|
||||
log.error(`Failed to resolve ${input}: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
10
extensions/twitch/src/runtime.ts
Normal file
10
extensions/twitch/src/runtime.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
// Twitch plugin module implements runtime behavior.
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/core";
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
|
||||
|
||||
const { setRuntime: setTwitchRuntime, getRuntime: getTwitchRuntime } =
|
||||
createPluginRuntimeStore<PluginRuntime>({
|
||||
pluginId: "twitch",
|
||||
errorMessage: "Twitch runtime not initialized",
|
||||
});
|
||||
export { getTwitchRuntime, setTwitchRuntime };
|
||||
342
extensions/twitch/src/send.test.ts
Normal file
342
extensions/twitch/src/send.test.ts
Normal file
@@ -0,0 +1,342 @@
|
||||
/**
|
||||
* Tests for send.ts module
|
||||
*
|
||||
* Tests cover:
|
||||
* - Message sending with valid configuration
|
||||
* - Account resolution and validation
|
||||
* - Channel normalization
|
||||
* - Markdown stripping
|
||||
* - Error handling for missing/invalid accounts
|
||||
* - Registry integration
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { getClientManager } from "./client-manager-registry.js";
|
||||
import { resolveTwitchAccountContext } from "./config.js";
|
||||
import { sendMessageTwitchInternal } from "./send.js";
|
||||
import {
|
||||
BASE_TWITCH_TEST_ACCOUNT,
|
||||
installTwitchTestHooks,
|
||||
makeTwitchTestConfig,
|
||||
} from "./test-fixtures.js";
|
||||
import { stripMarkdownForTwitch } from "./utils/markdown.js";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("./config.js", () => ({
|
||||
DEFAULT_ACCOUNT_ID: "default",
|
||||
resolveTwitchAccountContext: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./utils/twitch.js", () => ({
|
||||
generateMessageId: vi.fn(() => "test-msg-id"),
|
||||
normalizeTwitchChannel: (channel: string) => channel.toLowerCase().replace(/^#/, ""),
|
||||
}));
|
||||
|
||||
vi.mock("./utils/markdown.js", () => ({
|
||||
stripMarkdownForTwitch: vi.fn((text: string) => text.replace(/\*\*/g, "")),
|
||||
}));
|
||||
|
||||
vi.mock("./client-manager-registry.js", () => ({
|
||||
getClientManager: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("send", () => {
|
||||
const mockLogger = {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
};
|
||||
|
||||
const mockAccount = {
|
||||
...BASE_TWITCH_TEST_ACCOUNT,
|
||||
accessToken: "test123",
|
||||
};
|
||||
|
||||
const mockConfig = makeTwitchTestConfig(mockAccount);
|
||||
installTwitchTestHooks();
|
||||
|
||||
describe("sendMessageTwitchInternal", () => {
|
||||
function setupAccountContext(params?: {
|
||||
account?: typeof mockAccount | null;
|
||||
configured?: boolean;
|
||||
availableAccountIds?: string[];
|
||||
}) {
|
||||
const account = params?.account === undefined ? mockAccount : params.account;
|
||||
vi.mocked(resolveTwitchAccountContext).mockImplementation((_cfg, accountId) => ({
|
||||
accountId: accountId?.trim() || "default",
|
||||
account,
|
||||
tokenResolution: { source: "config", token: account?.accessToken ?? "" },
|
||||
configured: account ? (params?.configured ?? true) : false,
|
||||
availableAccountIds: params?.availableAccountIds ?? ["default"],
|
||||
}));
|
||||
}
|
||||
|
||||
async function mockSuccessfulSend(params: {
|
||||
messageId: string;
|
||||
stripMarkdown?: (text: string) => string;
|
||||
}) {
|
||||
setupAccountContext();
|
||||
vi.mocked(getClientManager).mockReturnValue({
|
||||
sendMessage: vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
messageId: params.messageId,
|
||||
}),
|
||||
} as unknown as ReturnType<typeof getClientManager>);
|
||||
vi.mocked(stripMarkdownForTwitch).mockImplementation(
|
||||
params.stripMarkdown ?? ((text) => text),
|
||||
);
|
||||
|
||||
return { stripMarkdownForTwitch };
|
||||
}
|
||||
|
||||
it("should send a message successfully", async () => {
|
||||
await mockSuccessfulSend({ messageId: "twitch-msg-123" });
|
||||
|
||||
const result = await sendMessageTwitchInternal(
|
||||
"#testchannel",
|
||||
"Hello Twitch!",
|
||||
mockConfig,
|
||||
"default",
|
||||
false,
|
||||
mockLogger as unknown as Console,
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.messageId).toBe("twitch-msg-123");
|
||||
expect(typeof result.receipt.sentAt).toBe("number");
|
||||
expect({ ...result.receipt, sentAt: 0 }).toEqual({
|
||||
primaryPlatformMessageId: "twitch-msg-123",
|
||||
platformMessageIds: ["twitch-msg-123"],
|
||||
parts: [
|
||||
{
|
||||
platformMessageId: "twitch-msg-123",
|
||||
kind: "text",
|
||||
index: 0,
|
||||
raw: {
|
||||
channel: "twitch",
|
||||
conversationId: "testchannel",
|
||||
messageId: "twitch-msg-123",
|
||||
},
|
||||
},
|
||||
],
|
||||
raw: [
|
||||
{
|
||||
channel: "twitch",
|
||||
conversationId: "testchannel",
|
||||
messageId: "twitch-msg-123",
|
||||
},
|
||||
],
|
||||
sentAt: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("should strip markdown when enabled", async () => {
|
||||
const { stripMarkdownForTwitch: stripMarkdownForTwitchLocal } = await mockSuccessfulSend({
|
||||
messageId: "twitch-msg-456",
|
||||
stripMarkdown: (text) => text.replace(/\*\*/g, ""),
|
||||
});
|
||||
|
||||
await sendMessageTwitchInternal(
|
||||
"#testchannel",
|
||||
"**Bold** text",
|
||||
mockConfig,
|
||||
"default",
|
||||
true,
|
||||
mockLogger as unknown as Console,
|
||||
);
|
||||
|
||||
expect(stripMarkdownForTwitchLocal).toHaveBeenCalledWith("**Bold** text");
|
||||
});
|
||||
|
||||
it("should return error when account not found", async () => {
|
||||
setupAccountContext({ account: null });
|
||||
|
||||
const result = await sendMessageTwitchInternal(
|
||||
"#testchannel",
|
||||
"Hello!",
|
||||
mockConfig,
|
||||
"nonexistent",
|
||||
false,
|
||||
mockLogger as unknown as Console,
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toContain("Account not found: nonexistent");
|
||||
});
|
||||
|
||||
it("should return error when account not configured", async () => {
|
||||
setupAccountContext({ configured: false });
|
||||
|
||||
const result = await sendMessageTwitchInternal(
|
||||
"#testchannel",
|
||||
"Hello!",
|
||||
mockConfig,
|
||||
"default",
|
||||
false,
|
||||
mockLogger as unknown as Console,
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toContain("not properly configured");
|
||||
});
|
||||
|
||||
it("should return error when no channel specified", async () => {
|
||||
// Set channel to undefined to trigger the error (bypassing type check)
|
||||
const accountWithoutChannel = {
|
||||
...mockAccount,
|
||||
channel: undefined as unknown as string,
|
||||
};
|
||||
setupAccountContext({ account: accountWithoutChannel });
|
||||
|
||||
const result = await sendMessageTwitchInternal(
|
||||
"",
|
||||
"Hello!",
|
||||
mockConfig,
|
||||
"default",
|
||||
false,
|
||||
mockLogger as unknown as Console,
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toContain("No channel specified");
|
||||
});
|
||||
|
||||
it("should skip sending empty message after markdown stripping", async () => {
|
||||
setupAccountContext();
|
||||
vi.mocked(stripMarkdownForTwitch).mockReturnValue("");
|
||||
|
||||
const result = await sendMessageTwitchInternal(
|
||||
"#testchannel",
|
||||
"**Only markdown**",
|
||||
mockConfig,
|
||||
"default",
|
||||
true,
|
||||
mockLogger as unknown as Console,
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.messageId).toBe("skipped");
|
||||
expect(result.receipt.platformMessageIds).toStrictEqual([]);
|
||||
expect(result.receipt.parts).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it("should return error when client manager not found", async () => {
|
||||
setupAccountContext();
|
||||
vi.mocked(getClientManager).mockReturnValue(undefined);
|
||||
|
||||
const result = await sendMessageTwitchInternal(
|
||||
"#testchannel",
|
||||
"Hello!",
|
||||
mockConfig,
|
||||
"default",
|
||||
false,
|
||||
mockLogger as unknown as Console,
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toContain("Client manager not found");
|
||||
});
|
||||
|
||||
it("should handle send errors gracefully", async () => {
|
||||
setupAccountContext();
|
||||
vi.mocked(getClientManager).mockReturnValue({
|
||||
sendMessage: vi.fn().mockRejectedValue(new Error("Connection lost")),
|
||||
} as unknown as ReturnType<typeof getClientManager>);
|
||||
|
||||
const result = await sendMessageTwitchInternal(
|
||||
"#testchannel",
|
||||
"Hello!",
|
||||
mockConfig,
|
||||
"default",
|
||||
false,
|
||||
mockLogger as unknown as Console,
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toBe("Connection lost");
|
||||
expect(mockLogger.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should use account channel when channel parameter is empty", async () => {
|
||||
setupAccountContext();
|
||||
const mockSend = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
messageId: "twitch-msg-789",
|
||||
});
|
||||
vi.mocked(getClientManager).mockReturnValue({
|
||||
sendMessage: mockSend,
|
||||
} as unknown as ReturnType<typeof getClientManager>);
|
||||
|
||||
await sendMessageTwitchInternal(
|
||||
"",
|
||||
"Hello!",
|
||||
mockConfig,
|
||||
"default",
|
||||
false,
|
||||
mockLogger as unknown as Console,
|
||||
);
|
||||
|
||||
expect(mockSend).toHaveBeenCalledWith(
|
||||
mockAccount,
|
||||
"testchannel", // normalized account channel
|
||||
"Hello!",
|
||||
mockConfig,
|
||||
"default",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the configured default account when accountId is omitted", async () => {
|
||||
const secondaryAccount = {
|
||||
...mockAccount,
|
||||
username: "secondary-user",
|
||||
channel: "secondary-channel",
|
||||
};
|
||||
vi.mocked(resolveTwitchAccountContext).mockImplementation((_cfg, accountId) => ({
|
||||
accountId: accountId?.trim() || "secondary",
|
||||
account: secondaryAccount,
|
||||
tokenResolution: { source: "config", token: secondaryAccount.accessToken ?? "" },
|
||||
configured: true,
|
||||
availableAccountIds: ["default", "secondary"],
|
||||
}));
|
||||
const mockSend = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
messageId: "twitch-msg-secondary",
|
||||
});
|
||||
vi.mocked(getClientManager).mockReturnValue({
|
||||
sendMessage: mockSend,
|
||||
} as unknown as ReturnType<typeof getClientManager>);
|
||||
|
||||
const result = await sendMessageTwitchInternal(
|
||||
"",
|
||||
"Hello!",
|
||||
{
|
||||
channels: {
|
||||
twitch: {
|
||||
defaultAccount: "secondary",
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
undefined,
|
||||
false,
|
||||
mockLogger as unknown as Console,
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(getClientManager).toHaveBeenCalledWith("secondary");
|
||||
expect(mockSend).toHaveBeenCalledWith(
|
||||
secondaryAccount,
|
||||
"secondary-channel",
|
||||
"Hello!",
|
||||
{
|
||||
channels: {
|
||||
twitch: {
|
||||
defaultAccount: "secondary",
|
||||
},
|
||||
},
|
||||
},
|
||||
"secondary",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
191
extensions/twitch/src/send.ts
Normal file
191
extensions/twitch/src/send.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* Twitch message sending functions with dependency injection support.
|
||||
*
|
||||
* These functions are the primary interface for sending messages to Twitch.
|
||||
* They support dependency injection via the `deps` parameter for testability.
|
||||
*/
|
||||
|
||||
import {
|
||||
createMessageReceiptFromOutboundResults,
|
||||
type MessageReceipt,
|
||||
} from "openclaw/plugin-sdk/channel-outbound";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { getClientManager as getRegistryClientManager } from "./client-manager-registry.js";
|
||||
import { resolveTwitchAccountContext } from "./config.js";
|
||||
import { stripMarkdownForTwitch } from "./utils/markdown.js";
|
||||
import { generateMessageId, normalizeTwitchChannel } from "./utils/twitch.js";
|
||||
|
||||
/**
|
||||
* Result from sending a message to Twitch.
|
||||
*/
|
||||
export interface SendMessageResult {
|
||||
/** Whether the send was successful */
|
||||
ok: boolean;
|
||||
/** The message ID (generated for tracking) */
|
||||
messageId: string;
|
||||
/** Receipt for visible sends; empty when no Twitch message was sent */
|
||||
receipt: MessageReceipt;
|
||||
/** Error message if the send failed */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function createTwitchSendReceipt(params: {
|
||||
messageId: string;
|
||||
channel?: string | null;
|
||||
visible?: boolean;
|
||||
}): MessageReceipt {
|
||||
const messageId = params.messageId.trim();
|
||||
const conversationId = params.channel?.trim();
|
||||
const hasVisibleMessage = params.visible === true && messageId && messageId !== "skipped";
|
||||
return createMessageReceiptFromOutboundResults({
|
||||
results: hasVisibleMessage
|
||||
? [
|
||||
{
|
||||
channel: "twitch",
|
||||
messageId,
|
||||
...(conversationId ? { conversationId } : {}),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
kind: "text",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal send function used by the outbound adapter.
|
||||
*
|
||||
* This function has access to the full OpenClaw config and handles
|
||||
* account resolution, markdown stripping, and actual message sending.
|
||||
*
|
||||
* @param channel - The channel name
|
||||
* @param text - The message text
|
||||
* @param cfg - Full OpenClaw configuration
|
||||
* @param accountId - Account ID to use
|
||||
* @param stripMarkdown - Whether to strip markdown (default: true)
|
||||
* @param logger - Logger instance
|
||||
* @returns Result with message ID and status
|
||||
*
|
||||
* @example
|
||||
* const result = await sendMessageTwitchInternal(
|
||||
* "#mychannel",
|
||||
* "Hello Twitch!",
|
||||
* openclawConfig,
|
||||
* "default",
|
||||
* true,
|
||||
* console,
|
||||
* );
|
||||
*/
|
||||
export async function sendMessageTwitchInternal(
|
||||
channel: string,
|
||||
text: string,
|
||||
cfg: OpenClawConfig,
|
||||
accountId?: string,
|
||||
stripMarkdown = true,
|
||||
logger: Console = console,
|
||||
): Promise<SendMessageResult> {
|
||||
const {
|
||||
account,
|
||||
configured,
|
||||
availableAccountIds,
|
||||
accountId: resolvedAccountId,
|
||||
} = resolveTwitchAccountContext(cfg, accountId);
|
||||
if (!account) {
|
||||
return {
|
||||
ok: false,
|
||||
messageId: generateMessageId(),
|
||||
receipt: createTwitchSendReceipt({ messageId: "", channel, visible: false }),
|
||||
error: `Account not found: ${accountId ?? "(default)"}. Available accounts: ${availableAccountIds.join(", ") || "none"}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!configured) {
|
||||
return {
|
||||
ok: false,
|
||||
messageId: generateMessageId(),
|
||||
receipt: createTwitchSendReceipt({ messageId: "", channel, visible: false }),
|
||||
error:
|
||||
`Account ${resolvedAccountId} is not properly configured. ` +
|
||||
"Required: username, clientId, and token (config or env for default account).",
|
||||
};
|
||||
}
|
||||
|
||||
const normalizedChannel = channel || account.channel;
|
||||
if (!normalizedChannel) {
|
||||
return {
|
||||
ok: false,
|
||||
messageId: generateMessageId(),
|
||||
receipt: createTwitchSendReceipt({
|
||||
messageId: "",
|
||||
channel: normalizedChannel,
|
||||
visible: false,
|
||||
}),
|
||||
error: "No channel specified and no default channel in account config",
|
||||
};
|
||||
}
|
||||
const deliveryChannel = normalizeTwitchChannel(normalizedChannel);
|
||||
|
||||
const cleanedText = stripMarkdown ? stripMarkdownForTwitch(text) : text;
|
||||
if (!cleanedText) {
|
||||
return {
|
||||
ok: true,
|
||||
messageId: "skipped",
|
||||
receipt: createTwitchSendReceipt({
|
||||
messageId: "skipped",
|
||||
channel: deliveryChannel,
|
||||
visible: false,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const clientManager = getRegistryClientManager(resolvedAccountId);
|
||||
if (!clientManager) {
|
||||
return {
|
||||
ok: false,
|
||||
messageId: generateMessageId(),
|
||||
receipt: createTwitchSendReceipt({
|
||||
messageId: "",
|
||||
channel: deliveryChannel,
|
||||
visible: false,
|
||||
}),
|
||||
error: `Client manager not found for account: ${resolvedAccountId}. Please start the Twitch gateway first.`,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await clientManager.sendMessage(
|
||||
account,
|
||||
deliveryChannel,
|
||||
cleanedText,
|
||||
cfg,
|
||||
resolvedAccountId,
|
||||
);
|
||||
|
||||
if (!result.ok) {
|
||||
const messageId = result.messageId ?? generateMessageId();
|
||||
return {
|
||||
ok: false,
|
||||
messageId,
|
||||
receipt: createTwitchSendReceipt({ messageId, channel: deliveryChannel, visible: false }),
|
||||
error: result.error ?? "Send failed",
|
||||
};
|
||||
}
|
||||
|
||||
const messageId = result.messageId ?? generateMessageId();
|
||||
return {
|
||||
ok: true,
|
||||
messageId,
|
||||
receipt: createTwitchSendReceipt({ messageId, channel: deliveryChannel, visible: true }),
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMsg = formatErrorMessage(error);
|
||||
const messageId = generateMessageId();
|
||||
logger.error(`Failed to send message: ${errorMsg}`);
|
||||
return {
|
||||
ok: false,
|
||||
messageId,
|
||||
receipt: createTwitchSendReceipt({ messageId, channel: deliveryChannel, visible: false }),
|
||||
error: errorMsg,
|
||||
};
|
||||
}
|
||||
}
|
||||
529
extensions/twitch/src/setup-surface.test.ts
Normal file
529
extensions/twitch/src/setup-surface.test.ts
Normal file
@@ -0,0 +1,529 @@
|
||||
/**
|
||||
* Tests for setup-surface.ts helpers.
|
||||
*
|
||||
* Tests cover:
|
||||
* - promptToken helper
|
||||
* - promptUsername helper
|
||||
* - promptClientId helper
|
||||
* - promptChannelName helper
|
||||
* - promptRefreshTokenSetup helper
|
||||
* - configureWithEnvToken helper
|
||||
* - setTwitchAccount config updates
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { WizardPrompter } from "../api.js";
|
||||
import {
|
||||
configureWithEnvToken,
|
||||
promptChannelName,
|
||||
promptClientId,
|
||||
promptRefreshTokenSetup,
|
||||
promptToken,
|
||||
promptUsername,
|
||||
setTwitchAccount,
|
||||
twitchSetupPlugin,
|
||||
twitchSetupWizard,
|
||||
} from "./setup-surface.js";
|
||||
import type { TwitchAccountConfig } from "./types.js";
|
||||
|
||||
// Mock the helpers we're testing
|
||||
const mockPromptText = vi.fn();
|
||||
const mockPromptConfirm = vi.fn();
|
||||
const mockPromptNote = vi.fn();
|
||||
const mockPrompter: WizardPrompter = {
|
||||
text: mockPromptText,
|
||||
confirm: mockPromptConfirm,
|
||||
note: mockPromptNote,
|
||||
} as unknown as WizardPrompter;
|
||||
const originalEnvToken = process.env.OPENCLAW_TWITCH_ACCESS_TOKEN;
|
||||
|
||||
const mockAccount: TwitchAccountConfig = {
|
||||
username: "testbot",
|
||||
accessToken: "oauth:test123",
|
||||
clientId: "test-client-id",
|
||||
channel: "#testchannel",
|
||||
};
|
||||
|
||||
function requireFirstTextPromptArgs(): {
|
||||
message?: string;
|
||||
initialValue?: string;
|
||||
validate?: (value: string) => string | undefined;
|
||||
} {
|
||||
const [call] = mockPromptText.mock.calls;
|
||||
if (!call || typeof call[0] !== "object" || call[0] === null || Array.isArray(call[0])) {
|
||||
throw new Error("expected Twitch text prompt args");
|
||||
}
|
||||
return call[0] as {
|
||||
message?: string;
|
||||
initialValue?: string;
|
||||
validate?: (value: string) => string | undefined;
|
||||
};
|
||||
}
|
||||
|
||||
describe("setup surface helpers", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalEnvToken === undefined) {
|
||||
delete process.env.OPENCLAW_TWITCH_ACCESS_TOKEN;
|
||||
} else {
|
||||
process.env.OPENCLAW_TWITCH_ACCESS_TOKEN = originalEnvToken;
|
||||
}
|
||||
// Don't restoreAllMocks as it breaks module-level mocks
|
||||
});
|
||||
|
||||
describe("promptToken", () => {
|
||||
it("should return existing token when user confirms to keep it", async () => {
|
||||
mockPromptConfirm.mockResolvedValue(true);
|
||||
|
||||
const result = await promptToken(mockPrompter, mockAccount, undefined);
|
||||
|
||||
expect(result).toBe("oauth:test123");
|
||||
expect(mockPromptConfirm).toHaveBeenCalledWith({
|
||||
message: "Access token already configured. Keep it?",
|
||||
initialValue: true,
|
||||
});
|
||||
expect(mockPromptText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should validate token format", async () => {
|
||||
// Set up mocks - user doesn't want to keep existing token
|
||||
mockPromptConfirm.mockResolvedValueOnce(false);
|
||||
|
||||
// Track how many times promptText is called
|
||||
let promptTextCallCount = 0;
|
||||
let capturedValidate: ((value: string) => string | undefined) | undefined;
|
||||
|
||||
mockPromptText.mockImplementationOnce((_args) => {
|
||||
promptTextCallCount++;
|
||||
// Capture the validate function from the first argument
|
||||
if (_args?.validate) {
|
||||
capturedValidate = _args.validate;
|
||||
}
|
||||
return Promise.resolve("oauth:test123");
|
||||
});
|
||||
|
||||
// Call promptToken
|
||||
const result = await promptToken(mockPrompter, mockAccount, undefined);
|
||||
|
||||
// Verify promptText was called
|
||||
expect(promptTextCallCount).toBe(1);
|
||||
expect(result).toBe("oauth:test123");
|
||||
|
||||
// Test the validate function
|
||||
if (!capturedValidate) {
|
||||
throw new Error("promptToken validate callback was not captured");
|
||||
}
|
||||
expect(capturedValidate("")).toBe("Required");
|
||||
expect(capturedValidate("notoauth")).toBe("Token should start with 'oauth:'");
|
||||
expect(capturedValidate("oauth:goodtoken")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("promptUsername", () => {
|
||||
it("should prompt for username with validation", async () => {
|
||||
mockPromptText.mockResolvedValue("mybot");
|
||||
|
||||
const result = await promptUsername(mockPrompter, null);
|
||||
|
||||
expect(result).toBe("mybot");
|
||||
const promptArgs = requireFirstTextPromptArgs();
|
||||
expect(promptArgs.message).toBe("Twitch bot username");
|
||||
expect(promptArgs.initialValue).toBe("");
|
||||
expect(promptArgs.validate?.("")).toBe("Required");
|
||||
expect(promptArgs.validate?.("mybot")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("promptClientId", () => {
|
||||
it("should prompt for client ID with validation", async () => {
|
||||
mockPromptText.mockResolvedValue("abc123xyz");
|
||||
|
||||
const result = await promptClientId(mockPrompter, null);
|
||||
|
||||
expect(result).toBe("abc123xyz");
|
||||
const promptArgs = requireFirstTextPromptArgs();
|
||||
expect(promptArgs.message).toBe("Twitch Client ID");
|
||||
expect(promptArgs.initialValue).toBe("");
|
||||
expect(promptArgs.validate?.("")).toBe("Required");
|
||||
expect(promptArgs.validate?.("abc123xyz")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("promptChannelName", () => {
|
||||
it("should require a non-empty channel name", async () => {
|
||||
mockPromptText.mockResolvedValue("");
|
||||
|
||||
await promptChannelName(mockPrompter, null);
|
||||
|
||||
const { validate } = requireFirstTextPromptArgs();
|
||||
expect(validate?.("")).toBe("Required");
|
||||
expect(validate?.(" ")).toBe("Required");
|
||||
expect(validate?.("#chan")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("promptRefreshTokenSetup", () => {
|
||||
it("should return empty object when user declines", async () => {
|
||||
mockPromptConfirm.mockResolvedValue(false);
|
||||
|
||||
const result = await promptRefreshTokenSetup(mockPrompter, mockAccount);
|
||||
|
||||
expect(result).toStrictEqual({});
|
||||
expect(mockPromptConfirm).toHaveBeenCalledWith({
|
||||
message: "Enable automatic token refresh (requires client secret and refresh token)?",
|
||||
initialValue: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("should prompt for credentials when user accepts", async () => {
|
||||
mockPromptConfirm
|
||||
.mockResolvedValueOnce(true) // First call: useRefresh
|
||||
.mockResolvedValueOnce("secret123") // clientSecret
|
||||
.mockResolvedValueOnce("refresh123"); // refreshToken
|
||||
|
||||
mockPromptText.mockResolvedValueOnce("secret123").mockResolvedValueOnce("refresh123");
|
||||
|
||||
const result = await promptRefreshTokenSetup(mockPrompter, null);
|
||||
|
||||
expect(result).toEqual({
|
||||
clientSecret: "secret123",
|
||||
refreshToken: "refresh123",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("configureWithEnvToken", () => {
|
||||
it("should prompt for username and clientId when using env token", async () => {
|
||||
// Reset and set up mocks - user accepts env token
|
||||
mockPromptConfirm.mockReset().mockResolvedValue(true as never);
|
||||
|
||||
// Set up mocks for username and clientId prompts
|
||||
mockPromptText
|
||||
.mockReset()
|
||||
.mockResolvedValueOnce("testbot" as never)
|
||||
.mockResolvedValueOnce("test-client-id" as never);
|
||||
|
||||
const result = await configureWithEnvToken(
|
||||
{} as Parameters<typeof configureWithEnvToken>[0],
|
||||
mockPrompter,
|
||||
null,
|
||||
"oauth:fromenv",
|
||||
false,
|
||||
{} as Parameters<typeof configureWithEnvToken>[5],
|
||||
);
|
||||
|
||||
// Should return config with username and clientId
|
||||
if (!result) {
|
||||
throw new Error("expected Twitch env-token setup result");
|
||||
}
|
||||
const defaultAccount = result.cfg.channels?.twitch?.accounts?.default as
|
||||
| { username?: string; clientId?: string }
|
||||
| undefined;
|
||||
expect(defaultAccount?.username).toBe("testbot");
|
||||
expect(defaultAccount?.clientId).toBe("test-client-id");
|
||||
});
|
||||
|
||||
it("skips env-token shortcut for non-default accounts", async () => {
|
||||
mockPromptConfirm.mockReset().mockResolvedValue(true as never);
|
||||
mockPromptText
|
||||
.mockReset()
|
||||
.mockResolvedValueOnce("secondary-bot" as never)
|
||||
.mockResolvedValueOnce("secondary-client" as never);
|
||||
|
||||
const result = await configureWithEnvToken(
|
||||
{
|
||||
channels: {
|
||||
twitch: {
|
||||
defaultAccount: "secondary",
|
||||
},
|
||||
},
|
||||
} as Parameters<typeof configureWithEnvToken>[0],
|
||||
mockPrompter,
|
||||
null,
|
||||
"oauth:fromenv",
|
||||
false,
|
||||
{} as Parameters<typeof configureWithEnvToken>[5],
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(mockPromptConfirm).not.toHaveBeenCalled();
|
||||
expect(mockPromptText).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("defaultAccount setup resolution", () => {
|
||||
it("reports status for the configured default account", () => {
|
||||
const lines = twitchSetupWizard.status?.resolveStatusLines?.({
|
||||
cfg: {
|
||||
channels: {
|
||||
twitch: {
|
||||
defaultAccount: "secondary",
|
||||
accounts: {
|
||||
secondary: {
|
||||
username: "secondary-bot",
|
||||
accessToken: "oauth:secondary",
|
||||
clientId: "secondary-client",
|
||||
channel: "#secondary",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never);
|
||||
|
||||
expect(lines).toEqual(["Twitch (secondary): configured"]);
|
||||
});
|
||||
|
||||
it("reports status for the requested account override", () => {
|
||||
const lines = twitchSetupWizard.status?.resolveStatusLines?.({
|
||||
cfg: {
|
||||
channels: {
|
||||
twitch: {
|
||||
accounts: {
|
||||
default: {
|
||||
username: "default-bot",
|
||||
accessToken: "oauth:default",
|
||||
clientId: "default-client",
|
||||
channel: "#default",
|
||||
},
|
||||
secondary: {
|
||||
username: "secondary-bot",
|
||||
accessToken: "oauth:secondary",
|
||||
clientId: "secondary-client",
|
||||
channel: "#secondary",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
accountId: "secondary",
|
||||
configured: true,
|
||||
} as never);
|
||||
|
||||
expect(lines).toEqual(["Twitch (secondary): configured"]);
|
||||
});
|
||||
|
||||
it("reports env-token default account setup as configured", async () => {
|
||||
process.env.OPENCLAW_TWITCH_ACCESS_TOKEN = "oauth:fromenv";
|
||||
|
||||
const cfg = {
|
||||
channels: {
|
||||
twitch: {
|
||||
accounts: {
|
||||
default: {
|
||||
username: "env-bot",
|
||||
accessToken: "",
|
||||
clientId: "env-client",
|
||||
channel: "#env",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as Parameters<NonNullable<typeof twitchSetupWizard.status>["resolveConfigured"]>[0]["cfg"];
|
||||
|
||||
expect(twitchSetupWizard.status?.resolveConfigured({ cfg })).toBe(true);
|
||||
const account = twitchSetupPlugin.config.resolveAccount(cfg, "default");
|
||||
expect(await twitchSetupPlugin.config.isConfigured?.(account, cfg)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setup wizard account routing", () => {
|
||||
type FinalizeArgs = Parameters<NonNullable<typeof twitchSetupWizard.finalize>>[0];
|
||||
|
||||
async function finalizeTwitchSetupForAccount(cfg: FinalizeArgs["cfg"]) {
|
||||
return await twitchSetupWizard.finalize?.({
|
||||
cfg,
|
||||
accountId: "secondary",
|
||||
credentialValues: {},
|
||||
runtime: {} as FinalizeArgs["runtime"],
|
||||
prompter: mockPrompter,
|
||||
options: {},
|
||||
forceAllowFrom: false,
|
||||
});
|
||||
}
|
||||
|
||||
it("rejects reserved account ids before using them as config keys", () => {
|
||||
expect(() =>
|
||||
setTwitchAccount(
|
||||
{} as Parameters<typeof setTwitchAccount>[0],
|
||||
{
|
||||
username: "reserved-bot",
|
||||
accessToken: "oauth:reserved",
|
||||
clientId: "reserved-client",
|
||||
channel: "#reserved",
|
||||
},
|
||||
"__proto__",
|
||||
),
|
||||
).toThrow("Invalid Twitch account id");
|
||||
|
||||
expect(Object.prototype).not.toHaveProperty("username");
|
||||
});
|
||||
|
||||
it("rejects reserved account ids before env-token writes", async () => {
|
||||
await expect(
|
||||
configureWithEnvToken(
|
||||
{} as Parameters<typeof configureWithEnvToken>[0],
|
||||
mockPrompter,
|
||||
null,
|
||||
"oauth:fromenv",
|
||||
false,
|
||||
{} as Parameters<typeof configureWithEnvToken>[5],
|
||||
"__proto__",
|
||||
),
|
||||
).rejects.toThrow("Invalid Twitch account id");
|
||||
|
||||
expect(mockPromptConfirm).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("normalizes account ids before rendering status lines", () => {
|
||||
expect(
|
||||
twitchSetupWizard.status?.resolveStatusLines?.({
|
||||
cfg: {},
|
||||
accountId: "Alerts\r\n\u001b[31m",
|
||||
configured: false,
|
||||
} as never),
|
||||
).toEqual(["Twitch (alerts-31m): needs username, token, and clientId"]);
|
||||
});
|
||||
|
||||
it("reports account-scoped DM policy config keys", () => {
|
||||
expect(
|
||||
twitchSetupWizard.dmPolicy?.resolveConfigKeys?.(
|
||||
{
|
||||
channels: {
|
||||
twitch: {
|
||||
defaultAccount: "secondary",
|
||||
},
|
||||
},
|
||||
} as Parameters<
|
||||
NonNullable<NonNullable<typeof twitchSetupWizard.dmPolicy>["resolveConfigKeys"]>
|
||||
>[0],
|
||||
undefined,
|
||||
),
|
||||
).toEqual({
|
||||
policyKey: "channels.twitch.accounts.secondary.allowedRoles",
|
||||
allowFromKey: "channels.twitch.accounts.secondary.allowFrom",
|
||||
});
|
||||
|
||||
expect(twitchSetupWizard.dmPolicy?.resolveConfigKeys?.({} as never, "alerts")).toEqual({
|
||||
policyKey: "channels.twitch.accounts.alerts.allowedRoles",
|
||||
allowFromKey: "channels.twitch.accounts.alerts.allowFrom",
|
||||
});
|
||||
});
|
||||
|
||||
it("writes to the requested account when defaultAccount is not created yet", async () => {
|
||||
mockPromptText
|
||||
.mockReset()
|
||||
.mockResolvedValueOnce("secondary-bot" as never)
|
||||
.mockResolvedValueOnce("oauth:secondary" as never)
|
||||
.mockResolvedValueOnce("secondary-client" as never)
|
||||
.mockResolvedValueOnce("#secondary" as never);
|
||||
mockPromptConfirm.mockReset().mockResolvedValue(false as never);
|
||||
|
||||
const result = await finalizeTwitchSetupForAccount({
|
||||
channels: {
|
||||
twitch: {
|
||||
defaultAccount: "secondary",
|
||||
accounts: {
|
||||
default: {
|
||||
username: "default-bot",
|
||||
accessToken: "oauth:default",
|
||||
clientId: "default-client",
|
||||
channel: "#default",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as FinalizeArgs["cfg"]);
|
||||
|
||||
const twitch = result?.cfg?.channels?.twitch;
|
||||
expect(twitch?.accounts?.secondary?.username).toBe("secondary-bot");
|
||||
expect(twitch?.accounts?.secondary?.accessToken).toBe("oauth:secondary");
|
||||
expect(twitch?.accounts?.default?.username).toBe("default-bot");
|
||||
});
|
||||
|
||||
it("persists a token instead of using env-token shortcut for non-default finalize", async () => {
|
||||
process.env.OPENCLAW_TWITCH_ACCESS_TOKEN = "oauth:fromenv";
|
||||
mockPromptText
|
||||
.mockReset()
|
||||
.mockResolvedValueOnce("secondary-bot" as never)
|
||||
.mockResolvedValueOnce("oauth:persisted" as never)
|
||||
.mockResolvedValueOnce("secondary-client" as never)
|
||||
.mockResolvedValueOnce("#secondary" as never);
|
||||
mockPromptConfirm.mockReset().mockResolvedValue(false as never);
|
||||
|
||||
const result = await finalizeTwitchSetupForAccount({
|
||||
channels: {
|
||||
twitch: {
|
||||
accounts: {},
|
||||
},
|
||||
},
|
||||
} as FinalizeArgs["cfg"]);
|
||||
|
||||
const twitch = result?.cfg?.channels?.twitch;
|
||||
expect(twitch?.accounts?.secondary?.accessToken).toBe("oauth:persisted");
|
||||
expect(mockPromptConfirm).toHaveBeenCalledTimes(1);
|
||||
expect(mockPromptConfirm).toHaveBeenCalledWith({
|
||||
message: "Enable automatic token refresh (requires client secret and refresh token)?",
|
||||
initialValue: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("setup-only plugin config", () => {
|
||||
it("lists all configured Twitch accounts", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
twitch: {
|
||||
defaultAccount: "secondary",
|
||||
accounts: {
|
||||
default: {
|
||||
username: "default-bot",
|
||||
accessToken: "oauth:default",
|
||||
clientId: "default-client",
|
||||
channel: "#default",
|
||||
},
|
||||
secondary: {
|
||||
username: "secondary-bot",
|
||||
accessToken: "oauth:secondary",
|
||||
clientId: "secondary-client",
|
||||
channel: "#secondary",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as Parameters<typeof twitchSetupPlugin.config.listAccountIds>[0];
|
||||
|
||||
expect(twitchSetupPlugin.config.listAccountIds(cfg)).toEqual(["default", "secondary"]);
|
||||
expect(twitchSetupPlugin.config.defaultAccountId?.(cfg)).toBe("secondary");
|
||||
});
|
||||
|
||||
it("normalizes exposed account ids", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
twitch: {
|
||||
accounts: {
|
||||
Secondary: {
|
||||
username: "secondary-bot",
|
||||
accessToken: "oauth:secondary",
|
||||
clientId: "secondary-client",
|
||||
channel: "#secondary",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as Parameters<typeof twitchSetupPlugin.config.listAccountIds>[0];
|
||||
|
||||
expect(twitchSetupPlugin.config.listAccountIds(cfg)).toEqual(["secondary"]);
|
||||
expect(twitchSetupPlugin.config.defaultAccountId?.(cfg)).toBe("secondary");
|
||||
expect(twitchSetupPlugin.config.resolveAccount(cfg, "SECONDARY\r\n").accountId).toBe(
|
||||
"secondary",
|
||||
);
|
||||
expect(twitchSetupPlugin.config.resolveAccount(cfg, "SECONDARY\r\n").username).toBe(
|
||||
"secondary-bot",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
524
extensions/twitch/src/setup-surface.ts
Normal file
524
extensions/twitch/src/setup-surface.ts
Normal file
@@ -0,0 +1,524 @@
|
||||
/**
|
||||
* Twitch setup wizard surface for CLI setup.
|
||||
*/
|
||||
|
||||
import { normalizeOptionalAccountId } from "openclaw/plugin-sdk/account-id";
|
||||
import { getChatChannelMeta, type ChannelPlugin } from "openclaw/plugin-sdk/core";
|
||||
import {
|
||||
formatDocsLink,
|
||||
type ChannelSetupAdapter,
|
||||
type ChannelSetupDmPolicy,
|
||||
type ChannelSetupWizard,
|
||||
type OpenClawConfig,
|
||||
type WizardPrompter,
|
||||
normalizeAccountId,
|
||||
createSetupTranslator,
|
||||
} from "openclaw/plugin-sdk/setup";
|
||||
import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
getAccountConfig,
|
||||
listAccountIds,
|
||||
resolveDefaultTwitchAccountId,
|
||||
resolveTwitchAccountContext,
|
||||
} from "./config.js";
|
||||
import type { TwitchAccountConfig, TwitchRole } from "./types.js";
|
||||
import { isAccountConfigured } from "./utils/twitch.js";
|
||||
|
||||
const channel = "twitch" as const;
|
||||
const t = createSetupTranslator();
|
||||
const INVALID_ACCOUNT_ID_MESSAGE = "Invalid Twitch account id";
|
||||
|
||||
function normalizeRequestedSetupAccountId(accountId: string): string {
|
||||
const normalized = normalizeOptionalAccountId(accountId);
|
||||
if (!normalized) {
|
||||
throw new Error(INVALID_ACCOUNT_ID_MESSAGE);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function resolveSetupAccountId(cfg: OpenClawConfig, requestedAccountId?: string): string {
|
||||
const requested = requestedAccountId?.trim();
|
||||
if (requested) {
|
||||
return normalizeRequestedSetupAccountId(requested);
|
||||
}
|
||||
|
||||
const preferred = cfg.channels?.twitch?.defaultAccount?.trim();
|
||||
return preferred ? normalizeAccountId(preferred) : resolveDefaultTwitchAccountId(cfg);
|
||||
}
|
||||
|
||||
export function setTwitchAccount(
|
||||
cfg: OpenClawConfig,
|
||||
account: Partial<TwitchAccountConfig>,
|
||||
accountId: string = resolveSetupAccountId(cfg),
|
||||
): OpenClawConfig {
|
||||
const resolvedAccountId = accountId.trim()
|
||||
? normalizeRequestedSetupAccountId(accountId)
|
||||
: resolveSetupAccountId(cfg);
|
||||
const existing = getAccountConfig(cfg, resolvedAccountId);
|
||||
const merged: TwitchAccountConfig = {
|
||||
username: account.username ?? existing?.username ?? "",
|
||||
accessToken: account.accessToken ?? existing?.accessToken ?? "",
|
||||
clientId: account.clientId ?? existing?.clientId ?? "",
|
||||
channel: account.channel ?? existing?.channel ?? "",
|
||||
enabled: account.enabled ?? existing?.enabled ?? true,
|
||||
allowFrom: account.allowFrom ?? existing?.allowFrom,
|
||||
allowedRoles: account.allowedRoles ?? existing?.allowedRoles,
|
||||
requireMention: account.requireMention ?? existing?.requireMention,
|
||||
clientSecret: account.clientSecret ?? existing?.clientSecret,
|
||||
refreshToken: account.refreshToken ?? existing?.refreshToken,
|
||||
expiresIn: account.expiresIn ?? existing?.expiresIn,
|
||||
obtainmentTimestamp: account.obtainmentTimestamp ?? existing?.obtainmentTimestamp,
|
||||
};
|
||||
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
twitch: {
|
||||
...((cfg.channels as Record<string, unknown>)?.twitch as
|
||||
| Record<string, unknown>
|
||||
| undefined),
|
||||
enabled: true,
|
||||
accounts: {
|
||||
...((
|
||||
(cfg.channels as Record<string, unknown>)?.twitch as Record<string, unknown> | undefined
|
||||
)?.accounts as Record<string, unknown> | undefined),
|
||||
[resolvedAccountId]: merged,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function noteTwitchSetupHelp(prompter: WizardPrompter): Promise<void> {
|
||||
await prompter.note(
|
||||
[
|
||||
t("wizard.twitch.helpRequiresBot"),
|
||||
t("wizard.twitch.helpCreateApp"),
|
||||
t("wizard.twitch.helpGenerateToken"),
|
||||
t("wizard.twitch.helpTokenTools"),
|
||||
t("wizard.twitch.helpCopyToken"),
|
||||
t("wizard.twitch.helpEnvVars"),
|
||||
`Docs: ${formatDocsLink("/channels/twitch", "channels/twitch")}`,
|
||||
].join("\n"),
|
||||
t("wizard.twitch.setupTitle"),
|
||||
);
|
||||
}
|
||||
|
||||
export async function promptToken(
|
||||
prompter: WizardPrompter,
|
||||
account: TwitchAccountConfig | null,
|
||||
envToken: string | undefined,
|
||||
): Promise<string> {
|
||||
const existingToken = account?.accessToken ?? "";
|
||||
|
||||
if (existingToken && !envToken) {
|
||||
const keepToken = await prompter.confirm({
|
||||
message: t("wizard.twitch.accessTokenKeep"),
|
||||
initialValue: true,
|
||||
});
|
||||
if (keepToken) {
|
||||
return existingToken;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
await prompter.text({
|
||||
message: t("wizard.twitch.oauthTokenPrompt"),
|
||||
initialValue: envToken ?? "",
|
||||
validate: (value) => {
|
||||
const raw = value?.trim() ?? "";
|
||||
if (!raw) {
|
||||
return "Required";
|
||||
}
|
||||
if (!raw.startsWith("oauth:")) {
|
||||
return "Token should start with 'oauth:'";
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
})
|
||||
).trim();
|
||||
}
|
||||
|
||||
export async function promptUsername(
|
||||
prompter: WizardPrompter,
|
||||
account: TwitchAccountConfig | null,
|
||||
): Promise<string> {
|
||||
return (
|
||||
await prompter.text({
|
||||
message: t("wizard.twitch.botUsernamePrompt"),
|
||||
initialValue: account?.username ?? "",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
})
|
||||
).trim();
|
||||
}
|
||||
|
||||
export async function promptClientId(
|
||||
prompter: WizardPrompter,
|
||||
account: TwitchAccountConfig | null,
|
||||
): Promise<string> {
|
||||
return (
|
||||
await prompter.text({
|
||||
message: t("wizard.twitch.clientIdPrompt"),
|
||||
initialValue: account?.clientId ?? "",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
})
|
||||
).trim();
|
||||
}
|
||||
|
||||
export async function promptChannelName(
|
||||
prompter: WizardPrompter,
|
||||
account: TwitchAccountConfig | null,
|
||||
): Promise<string> {
|
||||
return (
|
||||
await prompter.text({
|
||||
message: t("wizard.twitch.channelJoinPrompt"),
|
||||
initialValue: account?.channel ?? "",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
})
|
||||
).trim();
|
||||
}
|
||||
|
||||
export async function promptRefreshTokenSetup(
|
||||
prompter: WizardPrompter,
|
||||
account: TwitchAccountConfig | null,
|
||||
): Promise<{ clientSecret?: string; refreshToken?: string }> {
|
||||
const useRefresh = await prompter.confirm({
|
||||
message: t("wizard.twitch.refreshTokenPrompt"),
|
||||
initialValue: Boolean(account?.clientSecret && account?.refreshToken),
|
||||
});
|
||||
|
||||
if (!useRefresh) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const clientSecret =
|
||||
(
|
||||
await prompter.text({
|
||||
message: t("wizard.twitch.clientSecretPrompt"),
|
||||
initialValue: account?.clientSecret ?? "",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
})
|
||||
).trim() || undefined;
|
||||
|
||||
const refreshToken =
|
||||
(
|
||||
await prompter.text({
|
||||
message: t("wizard.twitch.refreshTokenInputPrompt"),
|
||||
initialValue: account?.refreshToken ?? "",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
})
|
||||
).trim() || undefined;
|
||||
|
||||
return { clientSecret, refreshToken };
|
||||
}
|
||||
|
||||
export async function configureWithEnvToken(
|
||||
cfg: OpenClawConfig,
|
||||
prompter: WizardPrompter,
|
||||
account: TwitchAccountConfig | null,
|
||||
envToken: string,
|
||||
forceAllowFrom: boolean,
|
||||
dmPolicy: ChannelSetupDmPolicy,
|
||||
accountId: string = resolveSetupAccountId(cfg),
|
||||
): Promise<{ cfg: OpenClawConfig } | null> {
|
||||
const resolvedAccountId = accountId.trim()
|
||||
? normalizeRequestedSetupAccountId(accountId)
|
||||
: resolveSetupAccountId(cfg);
|
||||
if (resolvedAccountId !== DEFAULT_ACCOUNT_ID) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const useEnv = await prompter.confirm({
|
||||
message: t("wizard.twitch.envPrompt"),
|
||||
initialValue: true,
|
||||
});
|
||||
if (!useEnv) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const username = await promptUsername(prompter, account);
|
||||
const clientId = await promptClientId(prompter, account);
|
||||
|
||||
const cfgWithAccount = setTwitchAccount(
|
||||
cfg,
|
||||
{
|
||||
username,
|
||||
clientId,
|
||||
accessToken: envToken,
|
||||
enabled: true,
|
||||
},
|
||||
resolvedAccountId,
|
||||
);
|
||||
|
||||
if (forceAllowFrom && dmPolicy.promptAllowFrom) {
|
||||
return {
|
||||
cfg: await dmPolicy.promptAllowFrom({
|
||||
cfg: cfgWithAccount,
|
||||
prompter,
|
||||
accountId: resolvedAccountId,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
return { cfg: cfgWithAccount };
|
||||
}
|
||||
|
||||
function setTwitchAccessControl(
|
||||
cfg: OpenClawConfig,
|
||||
allowedRoles: TwitchRole[],
|
||||
requireMention: boolean,
|
||||
accountId?: string,
|
||||
): OpenClawConfig {
|
||||
const resolvedAccountId = resolveSetupAccountId(cfg, accountId);
|
||||
const account = getAccountConfig(cfg, resolvedAccountId);
|
||||
if (!account) {
|
||||
return cfg;
|
||||
}
|
||||
|
||||
return setTwitchAccount(
|
||||
cfg,
|
||||
{
|
||||
...account,
|
||||
allowedRoles,
|
||||
requireMention,
|
||||
},
|
||||
resolvedAccountId,
|
||||
);
|
||||
}
|
||||
|
||||
function resolveTwitchGroupPolicy(
|
||||
cfg: OpenClawConfig,
|
||||
accountId?: string,
|
||||
): "open" | "allowlist" | "disabled" {
|
||||
const account = getAccountConfig(cfg, resolveSetupAccountId(cfg, accountId));
|
||||
if (account?.allowedRoles?.includes("all")) {
|
||||
return "open";
|
||||
}
|
||||
if (account?.allowedRoles?.includes("moderator")) {
|
||||
return "allowlist";
|
||||
}
|
||||
return "disabled";
|
||||
}
|
||||
|
||||
function setTwitchGroupPolicy(
|
||||
cfg: OpenClawConfig,
|
||||
policy: "open" | "allowlist" | "disabled",
|
||||
accountId?: string,
|
||||
): OpenClawConfig {
|
||||
const allowedRoles: TwitchRole[] =
|
||||
policy === "open" ? ["all"] : policy === "allowlist" ? ["moderator", "vip"] : [];
|
||||
return setTwitchAccessControl(cfg, allowedRoles, true, accountId);
|
||||
}
|
||||
|
||||
const twitchDmPolicy: ChannelSetupDmPolicy = {
|
||||
label: "Twitch",
|
||||
channel,
|
||||
policyKey: "channels.twitch.accounts.default.allowedRoles",
|
||||
allowFromKey: "channels.twitch.accounts.default.allowFrom",
|
||||
resolveConfigKeys: (cfg, accountId) => {
|
||||
const resolvedAccountId = resolveSetupAccountId(cfg, accountId);
|
||||
return {
|
||||
policyKey: `channels.twitch.accounts.${resolvedAccountId}.allowedRoles`,
|
||||
allowFromKey: `channels.twitch.accounts.${resolvedAccountId}.allowFrom`,
|
||||
};
|
||||
},
|
||||
getCurrent: (cfg, accountId) => {
|
||||
const account = getAccountConfig(cfg, resolveSetupAccountId(cfg, accountId));
|
||||
if (account?.allowedRoles?.includes("all")) {
|
||||
return "open";
|
||||
}
|
||||
if (account?.allowFrom && account.allowFrom.length > 0) {
|
||||
return "allowlist";
|
||||
}
|
||||
return "disabled";
|
||||
},
|
||||
setPolicy: (cfg, policy, accountId) => {
|
||||
const allowedRoles: TwitchRole[] =
|
||||
policy === "open" ? ["all"] : policy === "allowlist" ? [] : ["moderator"];
|
||||
return setTwitchAccessControl(cfg, allowedRoles, true, accountId);
|
||||
},
|
||||
promptAllowFrom: async ({ cfg, prompter, accountId }) => {
|
||||
const resolvedAccountId = resolveSetupAccountId(cfg, accountId);
|
||||
const account = getAccountConfig(cfg, resolvedAccountId);
|
||||
const existingAllowFrom = account?.allowFrom ?? [];
|
||||
|
||||
const entry = await prompter.text({
|
||||
message: t("wizard.twitch.allowFromPrompt"),
|
||||
placeholder: "123456789",
|
||||
initialValue: existingAllowFrom[0] || undefined,
|
||||
});
|
||||
|
||||
const allowFrom = normalizeStringEntries((entry ?? "").split(/[\n,;]+/g));
|
||||
|
||||
return setTwitchAccount(
|
||||
cfg,
|
||||
{
|
||||
...(account ?? undefined),
|
||||
allowFrom,
|
||||
},
|
||||
resolvedAccountId,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
const twitchGroupAccess: NonNullable<ChannelSetupWizard["groupAccess"]> = {
|
||||
label: "Twitch chat",
|
||||
placeholder: "",
|
||||
skipAllowlistEntries: true,
|
||||
currentPolicy: ({ cfg, accountId }) => resolveTwitchGroupPolicy(cfg, accountId),
|
||||
currentEntries: ({ cfg, accountId }) => {
|
||||
const account = getAccountConfig(cfg, resolveSetupAccountId(cfg, accountId));
|
||||
return account?.allowFrom ?? [];
|
||||
},
|
||||
updatePrompt: ({ cfg, accountId }) => {
|
||||
const account = getAccountConfig(cfg, resolveSetupAccountId(cfg, accountId));
|
||||
return Boolean(account?.allowedRoles?.length || account?.allowFrom?.length);
|
||||
},
|
||||
setPolicy: ({ cfg, accountId, policy }) => setTwitchGroupPolicy(cfg, policy, accountId),
|
||||
resolveAllowlist: async () => [],
|
||||
applyAllowlist: ({ cfg }) => cfg,
|
||||
};
|
||||
|
||||
export const twitchSetupAdapter: ChannelSetupAdapter = {
|
||||
resolveAccountId: ({ cfg }) => resolveSetupAccountId(cfg),
|
||||
applyAccountConfig: ({ cfg, accountId }) =>
|
||||
setTwitchAccount(
|
||||
cfg,
|
||||
{
|
||||
enabled: true,
|
||||
},
|
||||
accountId,
|
||||
),
|
||||
};
|
||||
|
||||
export const twitchSetupWizard: ChannelSetupWizard = {
|
||||
channel,
|
||||
resolveAccountIdForConfigure: ({ cfg, accountOverride }) =>
|
||||
resolveSetupAccountId(cfg, accountOverride),
|
||||
resolveShouldPromptAccountIds: () => false,
|
||||
status: {
|
||||
configuredLabel: t("wizard.channels.statusConfigured"),
|
||||
unconfiguredLabel: t("wizard.channels.statusNeedsUsernameTokenClientId"),
|
||||
configuredHint: t("wizard.channels.statusConfigured"),
|
||||
unconfiguredHint: t("wizard.channels.statusNeedsSetup"),
|
||||
resolveConfigured: ({ cfg, accountId }) => {
|
||||
return resolveTwitchAccountContext(cfg, resolveSetupAccountId(cfg, accountId)).configured;
|
||||
},
|
||||
resolveStatusLines: ({ cfg, accountId }) => {
|
||||
const resolvedAccountId = resolveSetupAccountId(cfg, accountId);
|
||||
const configured = resolveTwitchAccountContext(cfg, resolvedAccountId).configured;
|
||||
return [
|
||||
`Twitch${resolvedAccountId !== DEFAULT_ACCOUNT_ID ? ` (${resolvedAccountId})` : ""}: ${
|
||||
configured
|
||||
? t("wizard.channels.statusConfigured")
|
||||
: t("wizard.channels.statusNeedsUsernameTokenClientId")
|
||||
}`,
|
||||
];
|
||||
},
|
||||
},
|
||||
credentials: [],
|
||||
finalize: async ({ cfg, accountId: requestedAccountId, prompter, forceAllowFrom }) => {
|
||||
const accountId = resolveSetupAccountId(cfg, requestedAccountId);
|
||||
const account = getAccountConfig(cfg, accountId);
|
||||
|
||||
if (!account || !isAccountConfigured(account)) {
|
||||
await noteTwitchSetupHelp(prompter);
|
||||
}
|
||||
|
||||
const envToken = process.env.OPENCLAW_TWITCH_ACCESS_TOKEN?.trim();
|
||||
|
||||
if (accountId === DEFAULT_ACCOUNT_ID && envToken && !account?.accessToken) {
|
||||
const envResult = await configureWithEnvToken(
|
||||
cfg,
|
||||
prompter,
|
||||
account,
|
||||
envToken,
|
||||
forceAllowFrom,
|
||||
twitchDmPolicy,
|
||||
accountId,
|
||||
);
|
||||
if (envResult) {
|
||||
return envResult;
|
||||
}
|
||||
}
|
||||
|
||||
const username = await promptUsername(prompter, account);
|
||||
const token = await promptToken(prompter, account, envToken);
|
||||
const clientId = await promptClientId(prompter, account);
|
||||
const channelName = await promptChannelName(prompter, account);
|
||||
const { clientSecret, refreshToken } = await promptRefreshTokenSetup(prompter, account);
|
||||
|
||||
const cfgWithAccount = setTwitchAccount(
|
||||
cfg,
|
||||
{
|
||||
username,
|
||||
accessToken: token,
|
||||
clientId,
|
||||
channel: channelName,
|
||||
clientSecret,
|
||||
refreshToken,
|
||||
enabled: true,
|
||||
},
|
||||
accountId,
|
||||
);
|
||||
|
||||
const cfgWithAllowFrom =
|
||||
forceAllowFrom && twitchDmPolicy.promptAllowFrom
|
||||
? await twitchDmPolicy.promptAllowFrom({ cfg: cfgWithAccount, prompter, accountId })
|
||||
: cfgWithAccount;
|
||||
|
||||
return { cfg: cfgWithAllowFrom };
|
||||
},
|
||||
dmPolicy: twitchDmPolicy,
|
||||
groupAccess: twitchGroupAccess,
|
||||
disable: (cfg) => {
|
||||
const twitch = (cfg.channels as Record<string, unknown>)?.twitch as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
twitch: { ...twitch, enabled: false },
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
type ResolvedTwitchAccount = TwitchAccountConfig & { accountId?: string | null };
|
||||
|
||||
export const twitchSetupPlugin: ChannelPlugin<ResolvedTwitchAccount> = {
|
||||
id: channel,
|
||||
meta: getChatChannelMeta(channel),
|
||||
capabilities: {
|
||||
chatTypes: ["group"],
|
||||
},
|
||||
config: {
|
||||
listAccountIds: (cfg) => listAccountIds(cfg),
|
||||
resolveAccount: (cfg, accountId) => {
|
||||
const resolvedAccountId = normalizeAccountId(accountId ?? resolveDefaultTwitchAccountId(cfg));
|
||||
const account = getAccountConfig(cfg, resolvedAccountId);
|
||||
if (!account) {
|
||||
return {
|
||||
accountId: resolvedAccountId,
|
||||
username: "",
|
||||
accessToken: "",
|
||||
clientId: "",
|
||||
channel: "",
|
||||
enabled: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
accountId: resolvedAccountId,
|
||||
...account,
|
||||
};
|
||||
},
|
||||
defaultAccountId: (cfg) => resolveDefaultTwitchAccountId(cfg),
|
||||
isConfigured: (account, cfg) => resolveTwitchAccountContext(cfg, account?.accountId).configured,
|
||||
isEnabled: (account) => account.enabled !== false,
|
||||
},
|
||||
setup: twitchSetupAdapter,
|
||||
setupWizard: twitchSetupWizard,
|
||||
};
|
||||
298
extensions/twitch/src/status.test.ts
Normal file
298
extensions/twitch/src/status.test.ts
Normal file
@@ -0,0 +1,298 @@
|
||||
/**
|
||||
* Tests for status.ts module
|
||||
*
|
||||
* Tests cover:
|
||||
* - Detection of unconfigured accounts
|
||||
* - Detection of disabled accounts
|
||||
* - Detection of missing clientId
|
||||
* - Token format warnings
|
||||
* - Access control warnings
|
||||
* - Runtime error detection
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { collectTwitchStatusIssues } from "./status.js";
|
||||
import type { ChannelAccountSnapshot } from "./types.js";
|
||||
|
||||
function createSnapshot(overrides: Partial<ChannelAccountSnapshot> = {}): ChannelAccountSnapshot {
|
||||
return {
|
||||
accountId: "default",
|
||||
configured: true,
|
||||
enabled: true,
|
||||
running: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createSimpleTwitchConfig(overrides: Record<string, unknown>) {
|
||||
return {
|
||||
channels: {
|
||||
twitch: overrides,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function expectSingleIssue(
|
||||
issues: ReturnType<typeof collectTwitchStatusIssues>,
|
||||
expected: ReturnType<typeof collectTwitchStatusIssues>[number],
|
||||
): void {
|
||||
expect(issues).toEqual([expected]);
|
||||
}
|
||||
|
||||
function expectIssues(
|
||||
issues: ReturnType<typeof collectTwitchStatusIssues>,
|
||||
expected: ReturnType<typeof collectTwitchStatusIssues>,
|
||||
): void {
|
||||
expect(issues).toEqual(expected);
|
||||
}
|
||||
|
||||
function neverConnectedIssue(): ReturnType<typeof collectTwitchStatusIssues>[number] {
|
||||
return {
|
||||
channel: "twitch",
|
||||
accountId: "default",
|
||||
kind: "runtime",
|
||||
message: "Account has never connected successfully",
|
||||
fix: "Start the Twitch gateway to begin receiving messages. Check logs for connection errors.",
|
||||
};
|
||||
}
|
||||
|
||||
describe("status", () => {
|
||||
describe("collectTwitchStatusIssues", () => {
|
||||
it("should detect unconfigured accounts", () => {
|
||||
const snapshots: ChannelAccountSnapshot[] = [createSnapshot({ configured: false })];
|
||||
|
||||
const issues = collectTwitchStatusIssues(snapshots);
|
||||
|
||||
expectSingleIssue(issues, {
|
||||
channel: "twitch",
|
||||
accountId: "default",
|
||||
kind: "config",
|
||||
message: "Twitch account is not properly configured",
|
||||
fix: "Add required fields: username, accessToken, and clientId to your account configuration",
|
||||
});
|
||||
});
|
||||
|
||||
it("should detect disabled accounts", () => {
|
||||
const snapshots: ChannelAccountSnapshot[] = [createSnapshot({ enabled: false })];
|
||||
|
||||
const issues = collectTwitchStatusIssues(snapshots);
|
||||
|
||||
expectSingleIssue(issues, {
|
||||
channel: "twitch",
|
||||
accountId: "default",
|
||||
kind: "config",
|
||||
message: "Twitch account is disabled",
|
||||
fix: "Set enabled: true in your account configuration to enable this account",
|
||||
});
|
||||
});
|
||||
|
||||
it("should detect missing clientId when account configured (simplified config)", () => {
|
||||
const snapshots: ChannelAccountSnapshot[] = [createSnapshot()];
|
||||
const mockCfg = createSimpleTwitchConfig({
|
||||
username: "testbot",
|
||||
accessToken: "oauth:test123",
|
||||
// clientId missing
|
||||
});
|
||||
|
||||
const issues = collectTwitchStatusIssues(snapshots, () => mockCfg as never);
|
||||
|
||||
expectIssues(issues, [
|
||||
{
|
||||
channel: "twitch",
|
||||
accountId: "default",
|
||||
kind: "config",
|
||||
message: "Twitch client ID is required",
|
||||
fix: "Add clientId to your Twitch account configuration (from Twitch Developer Portal)",
|
||||
},
|
||||
neverConnectedIssue(),
|
||||
]);
|
||||
});
|
||||
|
||||
it("should warn about oauth: prefix in token (simplified config)", () => {
|
||||
const snapshots: ChannelAccountSnapshot[] = [createSnapshot()];
|
||||
const mockCfg = createSimpleTwitchConfig({
|
||||
username: "testbot",
|
||||
accessToken: "oauth:test123", // has prefix
|
||||
clientId: "test-id",
|
||||
});
|
||||
|
||||
const issues = collectTwitchStatusIssues(snapshots, () => mockCfg as never);
|
||||
|
||||
expectIssues(issues, [
|
||||
{
|
||||
channel: "twitch",
|
||||
accountId: "default",
|
||||
kind: "config",
|
||||
message: "Token contains 'oauth:' prefix (will be stripped)",
|
||||
fix: "The 'oauth:' prefix is optional. You can use just the token value, or keep it as-is (it will be normalized automatically).",
|
||||
},
|
||||
neverConnectedIssue(),
|
||||
]);
|
||||
});
|
||||
|
||||
it("should detect clientSecret without refreshToken (simplified config)", () => {
|
||||
const snapshots: ChannelAccountSnapshot[] = [createSnapshot()];
|
||||
const mockCfg = createSimpleTwitchConfig({
|
||||
username: "testbot",
|
||||
accessToken: "oauth:test123",
|
||||
clientId: "test-id",
|
||||
clientSecret: "secret123",
|
||||
// refreshToken missing
|
||||
});
|
||||
|
||||
const issues = collectTwitchStatusIssues(snapshots, () => mockCfg as never);
|
||||
|
||||
expectIssues(issues, [
|
||||
{
|
||||
channel: "twitch",
|
||||
accountId: "default",
|
||||
kind: "config",
|
||||
message: "Token contains 'oauth:' prefix (will be stripped)",
|
||||
fix: "The 'oauth:' prefix is optional. You can use just the token value, or keep it as-is (it will be normalized automatically).",
|
||||
},
|
||||
{
|
||||
channel: "twitch",
|
||||
accountId: "default",
|
||||
kind: "config",
|
||||
message: "clientSecret provided without refreshToken",
|
||||
fix: "For automatic token refresh, provide both clientSecret and refreshToken. Otherwise, clientSecret is not needed.",
|
||||
},
|
||||
neverConnectedIssue(),
|
||||
]);
|
||||
});
|
||||
|
||||
it("should detect empty allowFrom array (simplified config)", () => {
|
||||
const snapshots: ChannelAccountSnapshot[] = [createSnapshot()];
|
||||
const mockCfg = createSimpleTwitchConfig({
|
||||
username: "testbot",
|
||||
accessToken: "test123",
|
||||
clientId: "test-id",
|
||||
allowFrom: [], // empty array
|
||||
});
|
||||
|
||||
const issues = collectTwitchStatusIssues(snapshots, () => mockCfg as never);
|
||||
|
||||
expectIssues(issues, [
|
||||
{
|
||||
channel: "twitch",
|
||||
accountId: "default",
|
||||
kind: "config",
|
||||
message: "allowFrom is configured but empty",
|
||||
fix: "Either add user IDs to allowFrom, remove the allowFrom field, or use allowedRoles instead.",
|
||||
},
|
||||
neverConnectedIssue(),
|
||||
]);
|
||||
});
|
||||
|
||||
it("should detect allowedRoles 'all' with allowFrom conflict (simplified config)", () => {
|
||||
const snapshots: ChannelAccountSnapshot[] = [createSnapshot()];
|
||||
const mockCfg = createSimpleTwitchConfig({
|
||||
username: "testbot",
|
||||
accessToken: "test123",
|
||||
clientId: "test-id",
|
||||
allowedRoles: ["all"],
|
||||
allowFrom: ["123456"], // conflict!
|
||||
});
|
||||
|
||||
const issues = collectTwitchStatusIssues(snapshots, () => mockCfg as never);
|
||||
|
||||
expectIssues(issues, [
|
||||
{
|
||||
channel: "twitch",
|
||||
accountId: "default",
|
||||
kind: "intent",
|
||||
message: "allowedRoles is set to 'all' but allowFrom is also configured",
|
||||
fix: "When allowedRoles is 'all', the allowFrom list is not needed. Remove allowFrom or set allowedRoles to specific roles.",
|
||||
},
|
||||
neverConnectedIssue(),
|
||||
]);
|
||||
});
|
||||
|
||||
it("should detect runtime errors", () => {
|
||||
const snapshots: ChannelAccountSnapshot[] = [
|
||||
createSnapshot({ lastError: "Connection timeout" }),
|
||||
];
|
||||
|
||||
const issues = collectTwitchStatusIssues(snapshots);
|
||||
|
||||
expectIssues(issues, [
|
||||
{
|
||||
channel: "twitch",
|
||||
accountId: "default",
|
||||
kind: "runtime",
|
||||
message: "Last error: Connection timeout",
|
||||
fix: "Check your token validity and network connection. Ensure the bot has the required OAuth scopes.",
|
||||
},
|
||||
neverConnectedIssue(),
|
||||
]);
|
||||
});
|
||||
|
||||
it("should detect accounts that never connected", () => {
|
||||
const snapshots: ChannelAccountSnapshot[] = [
|
||||
createSnapshot({
|
||||
lastStartAt: undefined,
|
||||
lastInboundAt: undefined,
|
||||
lastOutboundAt: undefined,
|
||||
}),
|
||||
];
|
||||
|
||||
const issues = collectTwitchStatusIssues(snapshots);
|
||||
|
||||
expectSingleIssue(issues, {
|
||||
channel: "twitch",
|
||||
accountId: "default",
|
||||
kind: "runtime",
|
||||
message: "Account has never connected successfully",
|
||||
fix: "Start the Twitch gateway to begin receiving messages. Check logs for connection errors.",
|
||||
});
|
||||
});
|
||||
|
||||
it("should detect long-running connections", () => {
|
||||
const oldDate = Date.now() - 8 * 24 * 60 * 60 * 1000; // 8 days ago
|
||||
|
||||
const snapshots: ChannelAccountSnapshot[] = [
|
||||
createSnapshot({
|
||||
running: true,
|
||||
lastStartAt: oldDate,
|
||||
}),
|
||||
];
|
||||
|
||||
const issues = collectTwitchStatusIssues(snapshots);
|
||||
|
||||
expectSingleIssue(issues, {
|
||||
channel: "twitch",
|
||||
accountId: "default",
|
||||
kind: "runtime",
|
||||
message: "Connection has been running for 8 days",
|
||||
fix: "Consider restarting the connection periodically to refresh the connection. Twitch tokens may expire after long periods.",
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle empty snapshots array", () => {
|
||||
const issues = collectTwitchStatusIssues([]);
|
||||
|
||||
expect(issues).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it("should skip non-Twitch accounts gracefully", () => {
|
||||
const snapshots: ChannelAccountSnapshot[] = [
|
||||
{
|
||||
accountId: "unknown",
|
||||
configured: false,
|
||||
enabled: true,
|
||||
running: false,
|
||||
},
|
||||
];
|
||||
|
||||
const issues = collectTwitchStatusIssues(snapshots);
|
||||
|
||||
expectSingleIssue(issues, {
|
||||
channel: "twitch",
|
||||
accountId: "unknown",
|
||||
kind: "config",
|
||||
message: "Twitch account is not properly configured",
|
||||
fix: "Add required fields: username, accessToken, and clientId to your account configuration",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
179
extensions/twitch/src/status.ts
Normal file
179
extensions/twitch/src/status.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Twitch status issues collector.
|
||||
*
|
||||
* Detects and reports configuration issues for Twitch accounts.
|
||||
*/
|
||||
|
||||
import type { ChannelStatusIssue } from "openclaw/plugin-sdk/channel-contract";
|
||||
import { getAccountConfig } from "./config.js";
|
||||
import { resolveTwitchToken } from "./token.js";
|
||||
import type { ChannelAccountSnapshot } from "./types.js";
|
||||
import { isAccountConfigured } from "./utils/twitch.js";
|
||||
|
||||
/**
|
||||
* Collect status issues for Twitch accounts.
|
||||
*
|
||||
* Analyzes account snapshots and detects configuration problems,
|
||||
* authentication issues, and other potential problems.
|
||||
*
|
||||
* @param accounts - Array of account snapshots to analyze
|
||||
* @param getCfg - Optional function to get full config for additional checks
|
||||
* @returns Array of detected status issues
|
||||
*
|
||||
* @example
|
||||
* const issues = collectTwitchStatusIssues(accountSnapshots);
|
||||
* if (issues.length > 0) {
|
||||
* console.warn("Twitch configuration issues detected:");
|
||||
* issues.forEach(issue => console.warn(`- ${issue.message}`));
|
||||
* }
|
||||
*/
|
||||
export function collectTwitchStatusIssues(
|
||||
accounts: ChannelAccountSnapshot[],
|
||||
getCfg?: () => unknown,
|
||||
): ChannelStatusIssue[] {
|
||||
const issues: ChannelStatusIssue[] = [];
|
||||
|
||||
for (const entry of accounts) {
|
||||
const accountId = entry.accountId;
|
||||
|
||||
if (!accountId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let account: ReturnType<typeof getAccountConfig> | null = null;
|
||||
let cfg: Parameters<typeof resolveTwitchToken>[0] | undefined;
|
||||
if (getCfg) {
|
||||
try {
|
||||
cfg = getCfg() as {
|
||||
channels?: { twitch?: { accounts?: Record<string, unknown> } };
|
||||
};
|
||||
account = getAccountConfig(cfg, accountId);
|
||||
} catch {
|
||||
// Ignore config access errors
|
||||
}
|
||||
}
|
||||
|
||||
if (!entry.configured) {
|
||||
issues.push({
|
||||
channel: "twitch",
|
||||
accountId,
|
||||
kind: "config",
|
||||
message: "Twitch account is not properly configured",
|
||||
fix: "Add required fields: username, accessToken, and clientId to your account configuration",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.enabled === false) {
|
||||
issues.push({
|
||||
channel: "twitch",
|
||||
accountId,
|
||||
kind: "config",
|
||||
message: "Twitch account is disabled",
|
||||
fix: "Set enabled: true in your account configuration to enable this account",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (account && account.username && account.accessToken && !account.clientId) {
|
||||
issues.push({
|
||||
channel: "twitch",
|
||||
accountId,
|
||||
kind: "config",
|
||||
message: "Twitch client ID is required",
|
||||
fix: "Add clientId to your Twitch account configuration (from Twitch Developer Portal)",
|
||||
});
|
||||
}
|
||||
|
||||
const tokenResolution = cfg
|
||||
? resolveTwitchToken(cfg as Parameters<typeof resolveTwitchToken>[0], { accountId })
|
||||
: { token: "", source: "none" };
|
||||
if (account && isAccountConfigured(account, tokenResolution.token)) {
|
||||
if (account.accessToken?.startsWith("oauth:")) {
|
||||
issues.push({
|
||||
channel: "twitch",
|
||||
accountId,
|
||||
kind: "config",
|
||||
message: "Token contains 'oauth:' prefix (will be stripped)",
|
||||
fix: "The 'oauth:' prefix is optional. You can use just the token value, or keep it as-is (it will be normalized automatically).",
|
||||
});
|
||||
}
|
||||
|
||||
if (account.clientSecret && !account.refreshToken) {
|
||||
issues.push({
|
||||
channel: "twitch",
|
||||
accountId,
|
||||
kind: "config",
|
||||
message: "clientSecret provided without refreshToken",
|
||||
fix: "For automatic token refresh, provide both clientSecret and refreshToken. Otherwise, clientSecret is not needed.",
|
||||
});
|
||||
}
|
||||
|
||||
if (account.allowFrom && account.allowFrom.length === 0) {
|
||||
issues.push({
|
||||
channel: "twitch",
|
||||
accountId,
|
||||
kind: "config",
|
||||
message: "allowFrom is configured but empty",
|
||||
fix: "Either add user IDs to allowFrom, remove the allowFrom field, or use allowedRoles instead.",
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
account.allowedRoles?.includes("all") &&
|
||||
account.allowFrom &&
|
||||
account.allowFrom.length > 0
|
||||
) {
|
||||
issues.push({
|
||||
channel: "twitch",
|
||||
accountId,
|
||||
kind: "intent",
|
||||
message: "allowedRoles is set to 'all' but allowFrom is also configured",
|
||||
fix: "When allowedRoles is 'all', the allowFrom list is not needed. Remove allowFrom or set allowedRoles to specific roles.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (entry.lastError) {
|
||||
issues.push({
|
||||
channel: "twitch",
|
||||
accountId,
|
||||
kind: "runtime",
|
||||
message: `Last error: ${entry.lastError}`,
|
||||
fix: "Check your token validity and network connection. Ensure the bot has the required OAuth scopes.",
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
entry.configured &&
|
||||
!entry.running &&
|
||||
!entry.lastStartAt &&
|
||||
!entry.lastInboundAt &&
|
||||
!entry.lastOutboundAt
|
||||
) {
|
||||
issues.push({
|
||||
channel: "twitch",
|
||||
accountId,
|
||||
kind: "runtime",
|
||||
message: "Account has never connected successfully",
|
||||
fix: "Start the Twitch gateway to begin receiving messages. Check logs for connection errors.",
|
||||
});
|
||||
}
|
||||
|
||||
if (entry.running && entry.lastStartAt) {
|
||||
const uptime = Date.now() - entry.lastStartAt;
|
||||
const daysSinceStart = uptime / (1000 * 60 * 60 * 24);
|
||||
if (daysSinceStart > 7) {
|
||||
issues.push({
|
||||
channel: "twitch",
|
||||
accountId,
|
||||
kind: "runtime",
|
||||
message: `Connection has been running for ${Math.floor(daysSinceStart)} days`,
|
||||
fix: "Consider restarting the connection periodically to refresh the connection. Twitch tokens may expire after long periods.",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
31
extensions/twitch/src/test-fixtures.ts
Normal file
31
extensions/twitch/src/test-fixtures.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
// Twitch plugin module implements test fixtures behavior.
|
||||
import { afterEach, beforeEach, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../runtime-api.js";
|
||||
|
||||
export const BASE_TWITCH_TEST_ACCOUNT = {
|
||||
username: "testbot",
|
||||
clientId: "test-client-id",
|
||||
channel: "#testchannel",
|
||||
};
|
||||
|
||||
export function makeTwitchTestConfig(account: Record<string, unknown>): OpenClawConfig {
|
||||
return {
|
||||
channels: {
|
||||
twitch: {
|
||||
accounts: {
|
||||
default: account,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
}
|
||||
|
||||
export function installTwitchTestHooks() {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
}
|
||||
198
extensions/twitch/src/token.test.ts
Normal file
198
extensions/twitch/src/token.test.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* Tests for token.ts module
|
||||
*
|
||||
* Tests cover:
|
||||
* - Token resolution from config
|
||||
* - Token resolution from environment variable
|
||||
* - Fallback behavior when token not found
|
||||
* - Account ID normalization
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../api.js";
|
||||
import { resolveTwitchToken, type TwitchTokenSource } from "./token.js";
|
||||
|
||||
describe("token", () => {
|
||||
const originalAccessToken = process.env.OPENCLAW_TWITCH_ACCESS_TOKEN;
|
||||
|
||||
// Multi-account config for testing non-default accounts
|
||||
const mockMultiAccountConfig = {
|
||||
channels: {
|
||||
twitch: {
|
||||
accounts: {
|
||||
default: {
|
||||
username: "testbot",
|
||||
accessToken: "oauth:config-token",
|
||||
},
|
||||
other: {
|
||||
username: "otherbot",
|
||||
accessToken: "oauth:other-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
// Simplified single-account config
|
||||
const mockSimplifiedConfig = {
|
||||
channels: {
|
||||
twitch: {
|
||||
username: "testbot",
|
||||
accessToken: "oauth:config-token",
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
if (originalAccessToken === undefined) {
|
||||
delete process.env.OPENCLAW_TWITCH_ACCESS_TOKEN;
|
||||
} else {
|
||||
process.env.OPENCLAW_TWITCH_ACCESS_TOKEN = originalAccessToken;
|
||||
}
|
||||
});
|
||||
|
||||
describe("resolveTwitchToken", () => {
|
||||
it("should resolve token from simplified config for default account", () => {
|
||||
const result = resolveTwitchToken(mockSimplifiedConfig, { accountId: "default" });
|
||||
|
||||
expect(result.token).toBe("oauth:config-token");
|
||||
expect(result.source).toBe("config");
|
||||
});
|
||||
|
||||
it("should resolve token from config for non-default account (multi-account)", () => {
|
||||
const result = resolveTwitchToken(mockMultiAccountConfig, { accountId: "other" });
|
||||
|
||||
expect(result.token).toBe("oauth:other-token");
|
||||
expect(result.source).toBe("config");
|
||||
});
|
||||
|
||||
it("should resolve token from normalized account id", () => {
|
||||
const result = resolveTwitchToken(
|
||||
{
|
||||
channels: {
|
||||
twitch: {
|
||||
accounts: {
|
||||
Secondary: {
|
||||
username: "secondary",
|
||||
accessToken: "oauth:secondary-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig,
|
||||
{ accountId: "secondary" },
|
||||
);
|
||||
|
||||
expect(result.token).toBe("oauth:secondary-token");
|
||||
expect(result.source).toBe("config");
|
||||
});
|
||||
|
||||
it("should prioritize config token over env var (simplified config)", () => {
|
||||
process.env.OPENCLAW_TWITCH_ACCESS_TOKEN = "oauth:env-token";
|
||||
|
||||
const result = resolveTwitchToken(mockSimplifiedConfig, { accountId: "default" });
|
||||
|
||||
// Config token should be used even if env var exists
|
||||
expect(result.token).toBe("oauth:config-token");
|
||||
expect(result.source).toBe("config");
|
||||
});
|
||||
|
||||
it("should use env var when config token is empty (simplified config)", () => {
|
||||
process.env.OPENCLAW_TWITCH_ACCESS_TOKEN = "oauth:env-token";
|
||||
|
||||
const configWithEmptyToken = {
|
||||
channels: {
|
||||
twitch: {
|
||||
username: "testbot",
|
||||
accessToken: "",
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
const result = resolveTwitchToken(configWithEmptyToken, { accountId: "default" });
|
||||
|
||||
expect(result.token).toBe("oauth:env-token");
|
||||
expect(result.source).toBe("env");
|
||||
});
|
||||
|
||||
it("should return empty token when neither config nor env has token (simplified config)", () => {
|
||||
const configWithoutToken = {
|
||||
channels: {
|
||||
twitch: {
|
||||
username: "testbot",
|
||||
accessToken: "",
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
const result = resolveTwitchToken(configWithoutToken, { accountId: "default" });
|
||||
|
||||
expect(result.token).toBe("");
|
||||
expect(result.source).toBe("none");
|
||||
});
|
||||
|
||||
it("should not use env var for non-default accounts (multi-account)", () => {
|
||||
process.env.OPENCLAW_TWITCH_ACCESS_TOKEN = "oauth:env-token";
|
||||
|
||||
const configWithoutToken = {
|
||||
channels: {
|
||||
twitch: {
|
||||
accounts: {
|
||||
secondary: {
|
||||
username: "secondary",
|
||||
accessToken: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
const result = resolveTwitchToken(configWithoutToken, { accountId: "secondary" });
|
||||
|
||||
// Non-default accounts shouldn't use env var
|
||||
expect(result.token).toBe("");
|
||||
expect(result.source).toBe("none");
|
||||
});
|
||||
|
||||
it("should handle missing account gracefully", () => {
|
||||
const configWithoutAccount = {
|
||||
channels: {
|
||||
twitch: {
|
||||
accounts: {},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
const result = resolveTwitchToken(configWithoutAccount, { accountId: "nonexistent" });
|
||||
|
||||
expect(result.token).toBe("");
|
||||
expect(result.source).toBe("none");
|
||||
});
|
||||
|
||||
it("should handle missing Twitch config section", () => {
|
||||
const configWithoutSection = {
|
||||
channels: {},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
const result = resolveTwitchToken(configWithoutSection, { accountId: "default" });
|
||||
|
||||
expect(result.token).toBe("");
|
||||
expect(result.source).toBe("none");
|
||||
});
|
||||
});
|
||||
|
||||
describe("TwitchTokenSource type", () => {
|
||||
it("should have correct values", () => {
|
||||
const sources: TwitchTokenSource[] = ["env", "config", "none"];
|
||||
|
||||
expect(sources).toContain("env");
|
||||
expect(sources).toContain("config");
|
||||
expect(sources).toContain("none");
|
||||
});
|
||||
});
|
||||
});
|
||||
93
extensions/twitch/src/token.ts
Normal file
93
extensions/twitch/src/token.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Twitch access token resolution with environment variable support.
|
||||
*
|
||||
* Supports reading Twitch OAuth access tokens from config or environment variable.
|
||||
* The OPENCLAW_TWITCH_ACCESS_TOKEN env var is only used for the default account.
|
||||
*
|
||||
* Token resolution priority:
|
||||
* 1. Account access token from merged config (accounts.{id} or base-level for default)
|
||||
* 2. Environment variable: OPENCLAW_TWITCH_ACCESS_TOKEN (default account only)
|
||||
*/
|
||||
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
normalizeAccountId,
|
||||
resolveNormalizedAccountEntry,
|
||||
} from "openclaw/plugin-sdk/account-resolution";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
|
||||
export type TwitchTokenSource = "env" | "config" | "none";
|
||||
|
||||
export type TwitchTokenResolution = {
|
||||
token: string;
|
||||
source: TwitchTokenSource;
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalize a Twitch OAuth token - ensure it has the oauth: prefix
|
||||
*/
|
||||
function normalizeTwitchToken(raw?: string | null): string | undefined {
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
// Twitch tokens should have oauth: prefix
|
||||
return trimmed.startsWith("oauth:") ? trimmed : `oauth:${trimmed}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve Twitch access token from config or environment variable.
|
||||
*
|
||||
* Priority:
|
||||
* 1. Account access token (from merged config - base-level for default, or accounts.{accountId})
|
||||
* 2. Environment variable: OPENCLAW_TWITCH_ACCESS_TOKEN (default account only)
|
||||
*
|
||||
* The getAccountConfig function handles merging base-level config with accounts.default,
|
||||
* so this logic works for both simplified and multi-account patterns.
|
||||
*
|
||||
* @param cfg - OpenClaw config
|
||||
* @param opts - Options including accountId and optional envToken override
|
||||
* @returns Token resolution with source
|
||||
*/
|
||||
export function resolveTwitchToken(
|
||||
cfg?: OpenClawConfig,
|
||||
opts: { accountId?: string | null; envToken?: string | null } = {},
|
||||
): TwitchTokenResolution {
|
||||
const accountId = normalizeAccountId(opts.accountId);
|
||||
|
||||
// Get merged account config (handles both simplified and multi-account patterns)
|
||||
const twitchCfg = cfg?.channels?.twitch;
|
||||
const accounts = twitchCfg?.accounts as Record<string, Record<string, unknown>> | undefined;
|
||||
const accountCfg = resolveNormalizedAccountEntry(accounts, accountId, normalizeAccountId);
|
||||
|
||||
// For default account, also check base-level config
|
||||
let token: string | undefined;
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
// Base-level config takes precedence
|
||||
token = normalizeTwitchToken(
|
||||
(typeof twitchCfg?.accessToken === "string" ? twitchCfg.accessToken : undefined) ||
|
||||
(accountCfg?.accessToken as string | undefined),
|
||||
);
|
||||
} else {
|
||||
// Non-default accounts only use accounts object
|
||||
token = normalizeTwitchToken(accountCfg?.accessToken as string | undefined);
|
||||
}
|
||||
|
||||
if (token) {
|
||||
return { token, source: "config" };
|
||||
}
|
||||
|
||||
// Environment variable (default account only)
|
||||
const allowEnv = accountId === DEFAULT_ACCOUNT_ID;
|
||||
const envToken = allowEnv
|
||||
? normalizeTwitchToken(opts.envToken ?? process.env.OPENCLAW_TWITCH_ACCESS_TOKEN)
|
||||
: undefined;
|
||||
if (envToken) {
|
||||
return { token: envToken, source: "env" };
|
||||
}
|
||||
|
||||
return { token: "", source: "none" };
|
||||
}
|
||||
796
extensions/twitch/src/twitch-client.test.ts
Normal file
796
extensions/twitch/src/twitch-client.test.ts
Normal file
@@ -0,0 +1,796 @@
|
||||
/**
|
||||
* Tests for TwitchClientManager class
|
||||
*
|
||||
* Tests cover:
|
||||
* - Client connection and reconnection
|
||||
* - Message handling (chat)
|
||||
* - Message sending with rate limiting
|
||||
* - Disconnection scenarios
|
||||
* - Error handling and edge cases
|
||||
*/
|
||||
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { resolveTwitchToken } from "./token.js";
|
||||
import { TwitchClientManager } from "./twitch-client.js";
|
||||
import type { ChannelLogSink, TwitchAccountConfig, TwitchChatMessage } from "./types.js";
|
||||
|
||||
// Mock @twurple dependencies
|
||||
const mockConnect = vi.fn(() => {
|
||||
for (const handler of authSuccessHandlers) {
|
||||
handler();
|
||||
}
|
||||
});
|
||||
const mockJoin = vi.fn().mockResolvedValue(undefined);
|
||||
const mockSay = vi.fn().mockResolvedValue({ messageId: "test-msg-123" });
|
||||
const mockQuit = vi.fn();
|
||||
const mockUnbind = vi.fn();
|
||||
|
||||
// Event handler storage for testing
|
||||
const messageHandlers: Array<(channel: string, user: string, message: string, msg: any) => void> =
|
||||
[];
|
||||
const authSuccessHandlers: Array<() => void> = [];
|
||||
const authFailureHandlers: Array<(text: string, retryCount: number) => void> = [];
|
||||
const disconnectHandlers: Array<(manual: boolean, reason?: Error) => void> = [];
|
||||
|
||||
// Mock functions that track handlers and return unbind objects
|
||||
const mockOnMessage = vi.fn((handler: any) => {
|
||||
messageHandlers.push(handler);
|
||||
return { unbind: mockUnbind };
|
||||
});
|
||||
const mockOnAuthenticationSuccess = vi.fn((handler: () => void) => {
|
||||
authSuccessHandlers.push(handler);
|
||||
return { unbind: mockUnbind };
|
||||
});
|
||||
const mockOnAuthenticationFailure = vi.fn((handler: (text: string, retryCount: number) => void) => {
|
||||
authFailureHandlers.push(handler);
|
||||
return { unbind: mockUnbind };
|
||||
});
|
||||
const mockOnDisconnect = vi.fn((handler: (manual: boolean, reason?: Error) => void) => {
|
||||
disconnectHandlers.push(handler);
|
||||
return { unbind: mockUnbind };
|
||||
});
|
||||
|
||||
const mockAddUserForToken = vi.fn().mockResolvedValue("123456");
|
||||
const mockOnRefresh = vi.fn();
|
||||
const mockOnRefreshFailure = vi.fn();
|
||||
|
||||
vi.mock("@twurple/chat", () => ({
|
||||
ChatClient: class {
|
||||
onMessage = mockOnMessage;
|
||||
onAuthenticationSuccess = mockOnAuthenticationSuccess;
|
||||
onAuthenticationFailure = mockOnAuthenticationFailure;
|
||||
onDisconnect = mockOnDisconnect;
|
||||
connect = mockConnect;
|
||||
join = mockJoin;
|
||||
say = mockSay;
|
||||
quit = mockQuit;
|
||||
},
|
||||
LogLevel: {
|
||||
CRITICAL: "CRITICAL",
|
||||
ERROR: "ERROR",
|
||||
WARNING: "WARNING",
|
||||
INFO: "INFO",
|
||||
DEBUG: "DEBUG",
|
||||
TRACE: "TRACE",
|
||||
},
|
||||
}));
|
||||
|
||||
const mockAuthProvider = {
|
||||
constructor: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock("@twurple/auth", () => ({
|
||||
StaticAuthProvider: function StaticAuthProvider(...args: unknown[]) {
|
||||
mockAuthProvider.constructor(...args);
|
||||
},
|
||||
RefreshingAuthProvider: class {
|
||||
addUserForToken = mockAddUserForToken;
|
||||
onRefresh = mockOnRefresh;
|
||||
onRefreshFailure = mockOnRefreshFailure;
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock token resolution - must be after @twurple/auth mock
|
||||
vi.mock("./token.js", () => ({
|
||||
resolveTwitchToken: vi.fn(() => ({
|
||||
token: "oauth:mock-token-from-tests",
|
||||
source: "config" as const,
|
||||
})),
|
||||
DEFAULT_ACCOUNT_ID: "default",
|
||||
}));
|
||||
|
||||
describe("TwitchClientManager", () => {
|
||||
let manager: TwitchClientManager;
|
||||
let mockLogger: ChannelLogSink;
|
||||
let resolveTwitchTokenMock: ReturnType<typeof vi.mocked<typeof resolveTwitchToken>>;
|
||||
|
||||
const testAccount: TwitchAccountConfig = {
|
||||
username: "testbot",
|
||||
accessToken: "test123456",
|
||||
clientId: "test-client-id",
|
||||
channel: "testchannel",
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
const testAccount2: TwitchAccountConfig = {
|
||||
username: "testbot2",
|
||||
accessToken: "test789",
|
||||
clientId: "test-client-id-2",
|
||||
channel: "testchannel2",
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
beforeAll(() => {
|
||||
resolveTwitchTokenMock = vi.mocked(resolveTwitchToken);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// Clear all mocks first
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Clear handler arrays
|
||||
messageHandlers.length = 0;
|
||||
authSuccessHandlers.length = 0;
|
||||
authFailureHandlers.length = 0;
|
||||
disconnectHandlers.length = 0;
|
||||
|
||||
// Re-set up the default token mock implementation after clearing
|
||||
resolveTwitchTokenMock.mockReturnValue({
|
||||
token: "oauth:mock-token-from-tests",
|
||||
source: "config" as const,
|
||||
});
|
||||
|
||||
// Create mock logger
|
||||
mockLogger = {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
};
|
||||
|
||||
// Create manager instance
|
||||
manager = new TwitchClientManager(mockLogger);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up manager to avoid side effects
|
||||
manager.clearForTest();
|
||||
});
|
||||
|
||||
describe("getClient", () => {
|
||||
it("should create a new client connection", async () => {
|
||||
const ignoredClientForTest = await manager.getClient(testAccount);
|
||||
void ignoredClientForTest;
|
||||
|
||||
// New implementation: connect is called, channels are passed to constructor
|
||||
expect(mockConnect).toHaveBeenCalledTimes(1);
|
||||
expect(mockLogger.info).toHaveBeenCalledWith("Connected to Twitch as testbot");
|
||||
});
|
||||
|
||||
it("should use account username as default channel when channel not specified", async () => {
|
||||
const accountWithoutChannel: TwitchAccountConfig = {
|
||||
...testAccount,
|
||||
channel: "",
|
||||
} as unknown as TwitchAccountConfig;
|
||||
|
||||
await manager.getClient(accountWithoutChannel);
|
||||
|
||||
// New implementation: channel (testbot) is passed to constructor, not via join()
|
||||
expect(mockConnect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should reuse existing client for same account", async () => {
|
||||
const client1 = await manager.getClient(testAccount);
|
||||
const client2 = await manager.getClient(testAccount);
|
||||
|
||||
expect(client1).toBe(client2);
|
||||
expect(mockConnect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("deduplicates concurrent client creation for the same account", async () => {
|
||||
mockConnect.mockImplementationOnce(() => {});
|
||||
|
||||
const first = manager.getClient(testAccount);
|
||||
const second = manager.getClient(testAccount);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(mockConnect).toHaveBeenCalledTimes(1);
|
||||
expect(authSuccessHandlers).toHaveLength(1);
|
||||
authSuccessHandlers[0]?.();
|
||||
|
||||
const [client1, client2] = await Promise.all([first, second]);
|
||||
expect(client1).toBe(client2);
|
||||
});
|
||||
|
||||
it("waits through authentication failure retry disconnects", async () => {
|
||||
mockConnect.mockImplementationOnce(() => {});
|
||||
|
||||
const connection = manager.getClient(testAccount);
|
||||
await Promise.resolve();
|
||||
authFailureHandlers[0]?.("bad token", 1);
|
||||
|
||||
let settled = false;
|
||||
void connection.then(
|
||||
() => {
|
||||
settled = true;
|
||||
},
|
||||
() => {
|
||||
settled = true;
|
||||
},
|
||||
);
|
||||
await Promise.resolve();
|
||||
expect(settled).toBe(false);
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||
"Twitch authentication failed for testbot; waiting for retry, disconnect, or timeout: bad token",
|
||||
);
|
||||
|
||||
disconnectHandlers[0]?.(false, new Error("disconnected"));
|
||||
await Promise.resolve();
|
||||
expect(settled).toBe(false);
|
||||
|
||||
authSuccessHandlers[0]?.();
|
||||
await expect(connection).resolves.toBeTruthy();
|
||||
});
|
||||
|
||||
it("rejects pending auth retry connections on manual disconnect", async () => {
|
||||
mockConnect.mockImplementationOnce(() => {});
|
||||
|
||||
const connection = manager.getClient(testAccount);
|
||||
await Promise.resolve();
|
||||
authFailureHandlers[0]?.("bad token", 1);
|
||||
disconnectHandlers[0]?.(true);
|
||||
|
||||
await expect(connection).rejects.toThrow("Twitch connection cancelled");
|
||||
});
|
||||
|
||||
it("does not cache pending connections after disconnectAll", async () => {
|
||||
mockConnect.mockImplementationOnce(() => {});
|
||||
|
||||
const connection = manager.getClient(testAccount);
|
||||
await Promise.resolve();
|
||||
|
||||
await manager.disconnectAll();
|
||||
authSuccessHandlers[0]?.();
|
||||
|
||||
await expect(connection).rejects.toThrow("Twitch connection cancelled");
|
||||
expect(mockQuit).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("should create separate clients for different accounts", async () => {
|
||||
await manager.getClient(testAccount);
|
||||
await manager.getClient(testAccount2);
|
||||
|
||||
expect(mockConnect).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("should normalize token by removing oauth: prefix", async () => {
|
||||
const accountWithPrefix: TwitchAccountConfig = {
|
||||
...testAccount,
|
||||
accessToken: "oauth:actualtoken123",
|
||||
};
|
||||
|
||||
// Override the mock to return a specific token for this test
|
||||
resolveTwitchTokenMock.mockReturnValue({
|
||||
token: "oauth:actualtoken123",
|
||||
source: "config" as const,
|
||||
});
|
||||
|
||||
await manager.getClient(accountWithPrefix);
|
||||
|
||||
expect(mockAuthProvider.constructor).toHaveBeenCalledWith("test-client-id", "actualtoken123");
|
||||
});
|
||||
|
||||
it("should use token directly when no oauth: prefix", async () => {
|
||||
// Override the mock to return a token without oauth: prefix
|
||||
resolveTwitchTokenMock.mockReturnValue({
|
||||
token: "oauth:mock-token-from-tests",
|
||||
source: "config" as const,
|
||||
});
|
||||
|
||||
await manager.getClient(testAccount);
|
||||
|
||||
// Implementation strips oauth: prefix from all tokens
|
||||
expect(mockAuthProvider.constructor).toHaveBeenCalledWith(
|
||||
"test-client-id",
|
||||
"mock-token-from-tests",
|
||||
);
|
||||
});
|
||||
|
||||
it("should register refreshing tokens for Twurple chat intent", async () => {
|
||||
const refreshingAccount: TwitchAccountConfig = {
|
||||
...testAccount,
|
||||
clientSecret: "test-client-secret",
|
||||
refreshToken: "test-refresh-token",
|
||||
expiresIn: 3600,
|
||||
obtainmentTimestamp: 1_700_000_000_000,
|
||||
};
|
||||
|
||||
await manager.getClient(refreshingAccount);
|
||||
|
||||
expect(mockAddUserForToken).toHaveBeenCalledTimes(1);
|
||||
expect(mockAddUserForToken).toHaveBeenCalledWith(
|
||||
{
|
||||
accessToken: "mock-token-from-tests",
|
||||
refreshToken: "test-refresh-token",
|
||||
expiresIn: 3600,
|
||||
obtainmentTimestamp: 1_700_000_000_000,
|
||||
},
|
||||
["chat"],
|
||||
);
|
||||
expect(mockAuthProvider.constructor).not.toHaveBeenCalled();
|
||||
expect(mockLogger.info).toHaveBeenCalledWith(
|
||||
"Using RefreshingAuthProvider for testbot (automatic token refresh enabled)",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects and does not cache a client when addUserForToken fails (83853)", async () => {
|
||||
const refreshingAccount: TwitchAccountConfig = {
|
||||
...testAccount,
|
||||
clientSecret: "test-client-secret",
|
||||
refreshToken: "test-refresh-token",
|
||||
expiresIn: 3600,
|
||||
obtainmentTimestamp: 1_700_000_000_000,
|
||||
};
|
||||
mockAddUserForToken.mockRejectedValueOnce(new Error("token bind failed"));
|
||||
|
||||
await expect(manager.getClient(refreshingAccount)).rejects.toThrow("token bind failed");
|
||||
|
||||
// The broken auth provider must not be cached as a usable client;
|
||||
// otherwise later sends fail with an opaque error instead of failing fast.
|
||||
const key = manager.getAccountKey(refreshingAccount);
|
||||
expect((manager as any).clients.has(key)).toBe(false);
|
||||
});
|
||||
|
||||
it("retries client creation after an earlier addUserForToken failure (83853)", async () => {
|
||||
const refreshingAccount: TwitchAccountConfig = {
|
||||
...testAccount,
|
||||
clientSecret: "test-client-secret",
|
||||
refreshToken: "test-refresh-token",
|
||||
expiresIn: 3600,
|
||||
obtainmentTimestamp: 1_700_000_000_000,
|
||||
};
|
||||
mockAddUserForToken.mockRejectedValueOnce(new Error("token bind failed"));
|
||||
|
||||
await expect(manager.getClient(refreshingAccount)).rejects.toThrow("token bind failed");
|
||||
// No broken client was cached, so a second call re-attempts the bind.
|
||||
await manager.getClient(refreshingAccount);
|
||||
|
||||
expect(mockAddUserForToken).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("should throw error when clientId is missing", async () => {
|
||||
const accountWithoutClientId: TwitchAccountConfig = {
|
||||
...testAccount,
|
||||
clientId: "" as unknown as string,
|
||||
} as unknown as TwitchAccountConfig;
|
||||
|
||||
await expect(manager.getClient(accountWithoutClientId)).rejects.toThrow(
|
||||
"Missing Twitch client ID",
|
||||
);
|
||||
|
||||
expect(mockLogger.error).toHaveBeenCalledWith("Missing Twitch client ID for account testbot");
|
||||
});
|
||||
|
||||
it("should throw error when token is missing", async () => {
|
||||
// Override the mock to return empty token
|
||||
resolveTwitchTokenMock.mockReturnValue({
|
||||
token: "",
|
||||
source: "none" as const,
|
||||
});
|
||||
|
||||
await expect(manager.getClient(testAccount)).rejects.toThrow("Missing Twitch token");
|
||||
});
|
||||
|
||||
it("should set up message handlers on client connection", async () => {
|
||||
await manager.getClient(testAccount);
|
||||
|
||||
expect(mockOnMessage).toHaveBeenCalled();
|
||||
expect(mockLogger.info).toHaveBeenCalledWith("Set up handlers for testbot:testchannel");
|
||||
});
|
||||
|
||||
it("should create separate clients for same account with different channels", async () => {
|
||||
const account1: TwitchAccountConfig = {
|
||||
...testAccount,
|
||||
channel: "channel1",
|
||||
};
|
||||
const account2: TwitchAccountConfig = {
|
||||
...testAccount,
|
||||
channel: "channel2",
|
||||
};
|
||||
|
||||
await manager.getClient(account1);
|
||||
await manager.getClient(account2);
|
||||
|
||||
expect(mockConnect).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("onMessage", () => {
|
||||
it("should register message handler for account", () => {
|
||||
const handler = vi.fn();
|
||||
manager.onMessage(testAccount, handler);
|
||||
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should replace existing handler for same account", () => {
|
||||
const handler1 = vi.fn();
|
||||
const handler2 = vi.fn();
|
||||
|
||||
manager.onMessage(testAccount, handler1);
|
||||
manager.onMessage(testAccount, handler2);
|
||||
|
||||
// Check the stored handler is handler2
|
||||
const key = manager.getAccountKey(testAccount);
|
||||
expect((manager as any).messageHandlers.get(key)).toBe(handler2);
|
||||
});
|
||||
|
||||
it("cleanup of an earlier handler does not remove a newer registered handler (#83888)", () => {
|
||||
const handler1 = vi.fn();
|
||||
const handler2 = vi.fn();
|
||||
const key = manager.getAccountKey(testAccount);
|
||||
|
||||
const cleanup1 = manager.onMessage(testAccount, handler1);
|
||||
manager.onMessage(testAccount, handler2);
|
||||
|
||||
// Running the first handler's cleanup must not drop handler2.
|
||||
cleanup1();
|
||||
|
||||
expect((manager as any).messageHandlers.get(key)).toBe(handler2);
|
||||
});
|
||||
|
||||
it("cleanup of an earlier registration does not remove a newer registration using the same handler", () => {
|
||||
const handler = vi.fn();
|
||||
const key = manager.getAccountKey(testAccount);
|
||||
|
||||
const cleanup1 = manager.onMessage(testAccount, handler);
|
||||
manager.onMessage(testAccount, handler);
|
||||
cleanup1();
|
||||
|
||||
expect((manager as any).messageHandlers.get(key)).toBe(handler);
|
||||
});
|
||||
|
||||
it("cleanup of the current handler removes it", () => {
|
||||
const handler = vi.fn();
|
||||
const key = manager.getAccountKey(testAccount);
|
||||
|
||||
const cleanup = manager.onMessage(testAccount, handler);
|
||||
cleanup();
|
||||
|
||||
expect((manager as any).messageHandlers.has(key)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("disconnect", () => {
|
||||
it("should disconnect a connected client", async () => {
|
||||
await manager.getClient(testAccount);
|
||||
await manager.disconnect(testAccount);
|
||||
|
||||
expect(mockQuit).toHaveBeenCalledTimes(1);
|
||||
expect(mockLogger.info).toHaveBeenCalledWith("Disconnected testbot:testchannel");
|
||||
});
|
||||
|
||||
it("should clear client and message handler", async () => {
|
||||
const handler = vi.fn();
|
||||
await manager.getClient(testAccount);
|
||||
manager.onMessage(testAccount, handler);
|
||||
|
||||
await manager.disconnect(testAccount);
|
||||
|
||||
const key = manager.getAccountKey(testAccount);
|
||||
expect((manager as any).clients.has(key)).toBe(false);
|
||||
expect((manager as any).messageHandlers.has(key)).toBe(false);
|
||||
});
|
||||
|
||||
it("clears pending client message handlers when disconnect cancels connection", async () => {
|
||||
mockConnect.mockImplementationOnce(() => {});
|
||||
const handler = vi.fn();
|
||||
manager.onMessage(testAccount, handler);
|
||||
|
||||
const connection = manager.getClient(testAccount);
|
||||
await Promise.resolve();
|
||||
await manager.disconnect(testAccount);
|
||||
|
||||
const key = manager.getAccountKey(testAccount);
|
||||
expect((manager as any).messageHandlers.has(key)).toBe(false);
|
||||
authSuccessHandlers[0]?.();
|
||||
await expect(connection).rejects.toThrow("Twitch connection cancelled");
|
||||
|
||||
messageHandlers[0]?.("#testchannel", "testuser", "stale", {
|
||||
userInfo: {
|
||||
userName: "testuser",
|
||||
displayName: "TestUser",
|
||||
userId: "123",
|
||||
isMod: false,
|
||||
isBroadcaster: false,
|
||||
isVip: false,
|
||||
isSubscriber: false,
|
||||
},
|
||||
id: "msg-stale",
|
||||
});
|
||||
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle disconnecting non-existent client gracefully", async () => {
|
||||
// Missing clients are ignored.
|
||||
await manager.disconnect(testAccount);
|
||||
expect(mockQuit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should only disconnect specified account when multiple accounts exist", async () => {
|
||||
await manager.getClient(testAccount);
|
||||
await manager.getClient(testAccount2);
|
||||
|
||||
await manager.disconnect(testAccount);
|
||||
|
||||
expect(mockQuit).toHaveBeenCalledTimes(1);
|
||||
|
||||
const key2 = manager.getAccountKey(testAccount2);
|
||||
expect((manager as any).clients.has(key2)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("disconnectAll", () => {
|
||||
it("should disconnect all connected clients", async () => {
|
||||
await manager.getClient(testAccount);
|
||||
await manager.getClient(testAccount2);
|
||||
|
||||
await manager.disconnectAll();
|
||||
|
||||
expect(mockQuit).toHaveBeenCalledTimes(2);
|
||||
expect((manager as any).clients.size).toBe(0);
|
||||
expect((manager as any).messageHandlers.size).toBe(0);
|
||||
});
|
||||
|
||||
it("should handle empty client list gracefully", async () => {
|
||||
// Empty client sets are ignored.
|
||||
await manager.disconnectAll();
|
||||
expect(mockQuit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendMessage", () => {
|
||||
beforeEach(async () => {
|
||||
await manager.getClient(testAccount);
|
||||
});
|
||||
|
||||
it("should send message successfully", async () => {
|
||||
const result = await manager.sendMessage(testAccount, "testchannel", "Hello, world!");
|
||||
const { messageId, ...resultRest } = result;
|
||||
|
||||
expect(resultRest).toEqual({ ok: true });
|
||||
expect(messageId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
|
||||
expect(mockSay).toHaveBeenCalledWith("testchannel", "Hello, world!");
|
||||
});
|
||||
|
||||
it("should generate unique message ID for each message", async () => {
|
||||
const result1 = await manager.sendMessage(testAccount, "testchannel", "First message");
|
||||
const result2 = await manager.sendMessage(testAccount, "testchannel", "Second message");
|
||||
|
||||
expect(result1.messageId).not.toBe(result2.messageId);
|
||||
});
|
||||
|
||||
it("should handle sending to account's default channel", async () => {
|
||||
const result = await manager.sendMessage(
|
||||
testAccount,
|
||||
testAccount.channel || testAccount.username,
|
||||
"Test message",
|
||||
);
|
||||
|
||||
// Should use the account's channel or username
|
||||
expect(result.ok).toBe(true);
|
||||
expect(mockSay).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should return error on send failure", async () => {
|
||||
mockSay.mockRejectedValueOnce(new Error("Rate limited"));
|
||||
|
||||
const result = await manager.sendMessage(testAccount, "testchannel", "Test message");
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toBe("Rate limited");
|
||||
expect(mockLogger.error).toHaveBeenCalledWith("Failed to send message: Rate limited");
|
||||
});
|
||||
|
||||
it("should handle unknown error types", async () => {
|
||||
mockSay.mockRejectedValueOnce("String error");
|
||||
|
||||
const result = await manager.sendMessage(testAccount, "testchannel", "Test message");
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toBe("String error");
|
||||
});
|
||||
|
||||
it("should create client if not already connected", async () => {
|
||||
// Clear the existing client
|
||||
(manager as any).clients.clear();
|
||||
|
||||
// Reset connect call count for this specific test
|
||||
const connectCallCountBefore = mockConnect.mock.calls.length;
|
||||
|
||||
const result = await manager.sendMessage(testAccount, "testchannel", "Test message");
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(mockConnect.mock.calls.length).toBeGreaterThan(connectCallCountBefore);
|
||||
});
|
||||
});
|
||||
|
||||
describe("message handling integration", () => {
|
||||
let capturedMessage: TwitchChatMessage | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
capturedMessage = null;
|
||||
|
||||
// Set up message handler before connecting
|
||||
manager.onMessage(testAccount, (message) => {
|
||||
capturedMessage = message;
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle incoming chat messages", async () => {
|
||||
await manager.getClient(testAccount);
|
||||
|
||||
// Get the onMessage callback
|
||||
const onMessageCallback = messageHandlers[0];
|
||||
if (!onMessageCallback) {
|
||||
throw new Error("onMessageCallback not found");
|
||||
}
|
||||
|
||||
// Simulate Twitch message
|
||||
onMessageCallback("#testchannel", "testuser", "Hello bot!", {
|
||||
userInfo: {
|
||||
userName: "testuser",
|
||||
displayName: "TestUser",
|
||||
userId: "12345",
|
||||
isMod: false,
|
||||
isBroadcaster: false,
|
||||
isVip: false,
|
||||
isSubscriber: false,
|
||||
},
|
||||
id: "msg123",
|
||||
});
|
||||
|
||||
expect(capturedMessage?.username).toBe("testuser");
|
||||
expect(capturedMessage?.displayName).toBe("TestUser");
|
||||
expect(capturedMessage?.userId).toBe("12345");
|
||||
expect(capturedMessage?.message).toBe("Hello bot!");
|
||||
expect(capturedMessage?.channel).toBe("testchannel");
|
||||
expect(capturedMessage?.chatType).toBe("group");
|
||||
});
|
||||
|
||||
it("should normalize channel names without # prefix", async () => {
|
||||
await manager.getClient(testAccount);
|
||||
|
||||
const onMessageCallback = messageHandlers[0];
|
||||
|
||||
onMessageCallback("testchannel", "testuser", "Test", {
|
||||
userInfo: {
|
||||
userName: "testuser",
|
||||
displayName: "TestUser",
|
||||
userId: "123",
|
||||
isMod: false,
|
||||
isBroadcaster: false,
|
||||
isVip: false,
|
||||
isSubscriber: false,
|
||||
},
|
||||
id: "msg1",
|
||||
});
|
||||
|
||||
expect(capturedMessage?.channel).toBe("testchannel");
|
||||
});
|
||||
|
||||
it("should include user role flags in message", async () => {
|
||||
await manager.getClient(testAccount);
|
||||
|
||||
const onMessageCallback = messageHandlers[0];
|
||||
|
||||
onMessageCallback("#testchannel", "moduser", "Test", {
|
||||
userInfo: {
|
||||
userName: "moduser",
|
||||
displayName: "ModUser",
|
||||
userId: "456",
|
||||
isMod: true,
|
||||
isBroadcaster: false,
|
||||
isVip: true,
|
||||
isSubscriber: true,
|
||||
},
|
||||
id: "msg2",
|
||||
});
|
||||
|
||||
expect(capturedMessage?.isMod).toBe(true);
|
||||
expect(capturedMessage?.isVip).toBe(true);
|
||||
expect(capturedMessage?.isSub).toBe(true);
|
||||
expect(capturedMessage?.isOwner).toBe(false);
|
||||
});
|
||||
|
||||
it("should handle broadcaster messages", async () => {
|
||||
await manager.getClient(testAccount);
|
||||
|
||||
const onMessageCallback = messageHandlers[0];
|
||||
|
||||
onMessageCallback("#testchannel", "broadcaster", "Test", {
|
||||
userInfo: {
|
||||
userName: "broadcaster",
|
||||
displayName: "Broadcaster",
|
||||
userId: "789",
|
||||
isMod: false,
|
||||
isBroadcaster: true,
|
||||
isVip: false,
|
||||
isSubscriber: false,
|
||||
},
|
||||
id: "msg3",
|
||||
});
|
||||
|
||||
expect(capturedMessage?.isOwner).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("should handle multiple message handlers for different accounts", async () => {
|
||||
const messages1: TwitchChatMessage[] = [];
|
||||
const messages2: TwitchChatMessage[] = [];
|
||||
|
||||
manager.onMessage(testAccount, (msg) => messages1.push(msg));
|
||||
manager.onMessage(testAccount2, (msg) => messages2.push(msg));
|
||||
|
||||
await manager.getClient(testAccount);
|
||||
await manager.getClient(testAccount2);
|
||||
|
||||
// Simulate message for first account
|
||||
const onMessage1 = messageHandlers[0];
|
||||
if (!onMessage1) {
|
||||
throw new Error("onMessage1 not found");
|
||||
}
|
||||
onMessage1("#testchannel", "user1", "msg1", {
|
||||
userInfo: {
|
||||
userName: "user1",
|
||||
displayName: "User1",
|
||||
userId: "1",
|
||||
isMod: false,
|
||||
isBroadcaster: false,
|
||||
isVip: false,
|
||||
isSubscriber: false,
|
||||
},
|
||||
id: "1",
|
||||
});
|
||||
|
||||
// Simulate message for second account
|
||||
const onMessage2 = messageHandlers[1];
|
||||
if (!onMessage2) {
|
||||
throw new Error("onMessage2 not found");
|
||||
}
|
||||
onMessage2("#testchannel2", "user2", "msg2", {
|
||||
userInfo: {
|
||||
userName: "user2",
|
||||
displayName: "User2",
|
||||
userId: "2",
|
||||
isMod: false,
|
||||
isBroadcaster: false,
|
||||
isVip: false,
|
||||
isSubscriber: false,
|
||||
},
|
||||
id: "2",
|
||||
});
|
||||
|
||||
expect(messages1).toHaveLength(1);
|
||||
expect(messages2).toHaveLength(1);
|
||||
expect(messages1[0]?.message).toBe("msg1");
|
||||
expect(messages2[0]?.message).toBe("msg2");
|
||||
});
|
||||
|
||||
it("should handle rapid client creation requests", async () => {
|
||||
const promises = [
|
||||
manager.getClient(testAccount),
|
||||
manager.getClient(testAccount),
|
||||
manager.getClient(testAccount),
|
||||
];
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
// Note: The implementation doesn't handle concurrent getClient calls,
|
||||
// so multiple connections may be created. This is expected behavior.
|
||||
expect(mockConnect).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
411
extensions/twitch/src/twitch-client.ts
Normal file
411
extensions/twitch/src/twitch-client.ts
Normal file
@@ -0,0 +1,411 @@
|
||||
// Twitch plugin module implements twitch client behavior.
|
||||
import { RefreshingAuthProvider, StaticAuthProvider } from "@twurple/auth";
|
||||
import { ChatClient, LogLevel } from "@twurple/chat";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { sliceUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
|
||||
import { resolveTwitchToken } from "./token.js";
|
||||
import type { ChannelLogSink, TwitchAccountConfig, TwitchChatMessage } from "./types.js";
|
||||
import { normalizeToken } from "./utils/twitch.js";
|
||||
|
||||
const TWITCH_CHAT_AUTH_INTENTS = ["chat"];
|
||||
|
||||
/**
|
||||
* Manages Twitch chat client connections
|
||||
*/
|
||||
export class TwitchClientManager {
|
||||
private clients = new Map<string, ChatClient>();
|
||||
private pendingClients = new Map<string, ChatClient>();
|
||||
private connectionPromises = new Map<string, Promise<ChatClient>>();
|
||||
private messageHandlers = new Map<string, (message: TwitchChatMessage) => void>();
|
||||
private messageHandlerTokens = new Map<string, symbol>();
|
||||
|
||||
constructor(private logger: ChannelLogSink) {}
|
||||
|
||||
/**
|
||||
* Create an auth provider for the account.
|
||||
*/
|
||||
private async createAuthProvider(
|
||||
account: TwitchAccountConfig,
|
||||
normalizedToken: string,
|
||||
): Promise<StaticAuthProvider | RefreshingAuthProvider> {
|
||||
if (!account.clientId) {
|
||||
throw new Error("Missing Twitch client ID");
|
||||
}
|
||||
|
||||
if (account.clientSecret) {
|
||||
const authProvider = new RefreshingAuthProvider({
|
||||
clientId: account.clientId,
|
||||
clientSecret: account.clientSecret,
|
||||
});
|
||||
|
||||
try {
|
||||
const userId = await authProvider.addUserForToken(
|
||||
{
|
||||
accessToken: normalizedToken,
|
||||
refreshToken: account.refreshToken ?? null,
|
||||
expiresIn: account.expiresIn ?? null,
|
||||
obtainmentTimestamp: account.obtainmentTimestamp ?? Date.now(),
|
||||
},
|
||||
TWITCH_CHAT_AUTH_INTENTS,
|
||||
);
|
||||
this.logger.info(`Added user ${userId} to RefreshingAuthProvider for ${account.username}`);
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`Failed to add user to RefreshingAuthProvider: ${formatErrorMessage(err)}`,
|
||||
{
|
||||
cause: err,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
authProvider.onRefresh((userId, token) => {
|
||||
this.logger.info(
|
||||
`Access token refreshed for user ${userId} (expires in ${token.expiresIn ? `${token.expiresIn}s` : "unknown"})`,
|
||||
);
|
||||
});
|
||||
|
||||
authProvider.onRefreshFailure((userId, error) => {
|
||||
this.logger.error(`Failed to refresh access token for user ${userId}: ${error.message}`);
|
||||
});
|
||||
|
||||
const refreshStatus = account.refreshToken
|
||||
? "automatic token refresh enabled"
|
||||
: "token refresh disabled (no refresh token)";
|
||||
this.logger.info(`Using RefreshingAuthProvider for ${account.username} (${refreshStatus})`);
|
||||
|
||||
return authProvider;
|
||||
}
|
||||
|
||||
this.logger.info(`Using StaticAuthProvider for ${account.username} (no clientSecret provided)`);
|
||||
return new StaticAuthProvider(account.clientId, normalizedToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a chat client for an account
|
||||
*/
|
||||
async getClient(
|
||||
account: TwitchAccountConfig,
|
||||
cfg?: OpenClawConfig,
|
||||
accountId?: string,
|
||||
): Promise<ChatClient> {
|
||||
const key = this.getAccountKey(account);
|
||||
|
||||
const existing = this.clients.get(key);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const pending = this.connectionPromises.get(key);
|
||||
if (pending) {
|
||||
return pending;
|
||||
}
|
||||
|
||||
const connection = this.createConnectedClient(key, account, cfg, accountId);
|
||||
this.connectionPromises.set(key, connection);
|
||||
try {
|
||||
return await connection;
|
||||
} finally {
|
||||
if (this.connectionPromises.get(key) === connection) {
|
||||
this.connectionPromises.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async createConnectedClient(
|
||||
key: string,
|
||||
account: TwitchAccountConfig,
|
||||
cfg?: OpenClawConfig,
|
||||
accountId?: string,
|
||||
): Promise<ChatClient> {
|
||||
const tokenResolution = resolveTwitchToken(cfg, {
|
||||
accountId,
|
||||
});
|
||||
|
||||
if (!tokenResolution.token) {
|
||||
this.logger.error(
|
||||
`Missing Twitch token for account ${account.username} (set channels.twitch.accounts.${account.username}.token or OPENCLAW_TWITCH_ACCESS_TOKEN for default)`,
|
||||
);
|
||||
throw new Error("Missing Twitch token");
|
||||
}
|
||||
|
||||
this.logger.debug?.(`Using ${tokenResolution.source} token source for ${account.username}`);
|
||||
|
||||
if (!account.clientId) {
|
||||
this.logger.error(`Missing Twitch client ID for account ${account.username}`);
|
||||
throw new Error("Missing Twitch client ID");
|
||||
}
|
||||
|
||||
const normalizedToken = normalizeToken(tokenResolution.token);
|
||||
|
||||
const authProvider = await this.createAuthProvider(account, normalizedToken);
|
||||
|
||||
const client = new ChatClient({
|
||||
authProvider,
|
||||
channels: [account.channel],
|
||||
rejoinChannelsOnReconnect: true,
|
||||
requestMembershipEvents: true,
|
||||
logger: {
|
||||
minLevel: LogLevel.WARNING,
|
||||
custom: {
|
||||
log: (level, message) => {
|
||||
switch (level) {
|
||||
case LogLevel.CRITICAL:
|
||||
this.logger.error(message);
|
||||
break;
|
||||
case LogLevel.ERROR:
|
||||
this.logger.error(message);
|
||||
break;
|
||||
case LogLevel.WARNING:
|
||||
this.logger.warn(message);
|
||||
break;
|
||||
case LogLevel.INFO:
|
||||
this.logger.info(message);
|
||||
break;
|
||||
case LogLevel.DEBUG:
|
||||
this.logger.debug?.(message);
|
||||
break;
|
||||
case LogLevel.TRACE:
|
||||
this.logger.debug?.(message);
|
||||
break;
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
this.setupClientHandlers(client, account);
|
||||
|
||||
this.pendingClients.set(key, client);
|
||||
try {
|
||||
await this.connectClient(client, account);
|
||||
if (this.pendingClients.get(key) !== client) {
|
||||
client.quit();
|
||||
throw new Error(`Twitch connection cancelled for ${account.username}`);
|
||||
}
|
||||
this.pendingClients.delete(key);
|
||||
} catch (error) {
|
||||
if (this.pendingClients.get(key) === client) {
|
||||
this.pendingClients.delete(key);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
this.clients.set(key, client);
|
||||
this.logger.info(`Connected to Twitch as ${account.username}`);
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
private async connectClient(client: ChatClient, account: TwitchAccountConfig): Promise<void> {
|
||||
const connectTimeoutMs = 15000;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let settled = false;
|
||||
let authRetryPending = false;
|
||||
const listeners: Array<{ unbind: () => void }> = [];
|
||||
const finish = (error?: Error) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
for (const listener of listeners) {
|
||||
listener.unbind();
|
||||
}
|
||||
if (error) {
|
||||
try {
|
||||
client.quit();
|
||||
} catch {
|
||||
// Best effort: connection setup already failed.
|
||||
}
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
};
|
||||
listeners.push(
|
||||
client.onAuthenticationSuccess(() => finish()),
|
||||
client.onAuthenticationFailure((text) => {
|
||||
authRetryPending = true;
|
||||
this.logger.warn(
|
||||
`Twitch authentication failed for ${account.username}; waiting for retry, disconnect, or timeout: ${text}`,
|
||||
);
|
||||
}),
|
||||
client.onDisconnect((manual, reason) => {
|
||||
if (authRetryPending && !manual) {
|
||||
this.logger.debug?.(
|
||||
`Twitch disconnected during auth retry for ${account.username}: ${formatErrorMessage(reason)}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
finish(
|
||||
reason ??
|
||||
new Error(
|
||||
manual
|
||||
? `Twitch connection cancelled for ${account.username}`
|
||||
: `Twitch disconnected before ready for ${account.username}`,
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
const timeout: NodeJS.Timeout | undefined = setTimeout(
|
||||
() => finish(new Error(`Timed out connecting to Twitch as ${account.username}`)),
|
||||
connectTimeoutMs,
|
||||
);
|
||||
timeout.unref?.();
|
||||
try {
|
||||
client.connect();
|
||||
} catch (error) {
|
||||
finish(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up message and event handlers for a client
|
||||
*/
|
||||
private setupClientHandlers(client: ChatClient, account: TwitchAccountConfig): void {
|
||||
const key = this.getAccountKey(account);
|
||||
|
||||
// Handle incoming messages
|
||||
client.onMessage((channelName, _user, messageText, msg) => {
|
||||
const handler = this.messageHandlers.get(key);
|
||||
if (handler) {
|
||||
const normalizedChannel = channelName.startsWith("#") ? channelName.slice(1) : channelName;
|
||||
const from = `twitch:${msg.userInfo.userName}`;
|
||||
const preview = sliceUtf16Safe(messageText, 0, 100).replace(/\n/g, "\\n");
|
||||
this.logger.debug?.(
|
||||
`twitch inbound: channel=${normalizedChannel} from=${from} len=${messageText.length} preview="${preview}"`,
|
||||
);
|
||||
|
||||
handler({
|
||||
username: msg.userInfo.userName,
|
||||
displayName: msg.userInfo.displayName,
|
||||
userId: msg.userInfo.userId,
|
||||
message: messageText,
|
||||
channel: normalizedChannel,
|
||||
id: msg.id,
|
||||
timestamp: new Date(),
|
||||
isMod: msg.userInfo.isMod,
|
||||
isOwner: msg.userInfo.isBroadcaster,
|
||||
isVip: msg.userInfo.isVip,
|
||||
isSub: msg.userInfo.isSubscriber,
|
||||
chatType: "group",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
this.logger.info(`Set up handlers for ${key}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a message handler for an account
|
||||
* @returns A function that removes the handler when called
|
||||
*/
|
||||
onMessage(
|
||||
account: TwitchAccountConfig,
|
||||
handler: (message: TwitchChatMessage) => void,
|
||||
): () => void {
|
||||
const key = this.getAccountKey(account);
|
||||
const token = Symbol(key);
|
||||
this.messageHandlers.set(key, handler);
|
||||
this.messageHandlerTokens.set(key, token);
|
||||
return () => {
|
||||
// Only remove the exact registration this cleanup closure owns. A later
|
||||
// onMessage() may reuse the same callback function for the same account.
|
||||
if (this.messageHandlerTokens.get(key) === token) {
|
||||
this.messageHandlers.delete(key);
|
||||
this.messageHandlerTokens.delete(key);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private clearMessageHandler(key: string): void {
|
||||
this.messageHandlers.delete(key);
|
||||
this.messageHandlerTokens.delete(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect a client
|
||||
*/
|
||||
async disconnect(account: TwitchAccountConfig): Promise<void> {
|
||||
const key = this.getAccountKey(account);
|
||||
const client = this.clients.get(key);
|
||||
const pendingClient = this.pendingClients.get(key);
|
||||
|
||||
if (pendingClient) {
|
||||
pendingClient.quit();
|
||||
this.pendingClients.delete(key);
|
||||
this.connectionPromises.delete(key);
|
||||
this.clearMessageHandler(key);
|
||||
}
|
||||
|
||||
if (client) {
|
||||
client.quit();
|
||||
this.clients.delete(key);
|
||||
this.clearMessageHandler(key);
|
||||
this.logger.info(`Disconnected ${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect all clients
|
||||
*/
|
||||
async disconnectAll(): Promise<void> {
|
||||
this.pendingClients.forEach((client) => client.quit());
|
||||
this.clients.forEach((client) => client.quit());
|
||||
this.pendingClients.clear();
|
||||
this.connectionPromises.clear();
|
||||
this.clients.clear();
|
||||
this.messageHandlers.clear();
|
||||
this.messageHandlerTokens.clear();
|
||||
this.logger.info(" Disconnected all clients");
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to a channel
|
||||
*/
|
||||
async sendMessage(
|
||||
account: TwitchAccountConfig,
|
||||
channel: string,
|
||||
message: string,
|
||||
cfg?: OpenClawConfig,
|
||||
accountId?: string,
|
||||
): Promise<{ ok: boolean; error?: string; messageId?: string }> {
|
||||
try {
|
||||
const client = await this.getClient(account, cfg, accountId);
|
||||
|
||||
// Generate a message ID (Twurple's say() doesn't return the message ID, so we generate one)
|
||||
const messageId = crypto.randomUUID();
|
||||
|
||||
// Send message (Twurple handles rate limiting)
|
||||
await client.say(channel, message);
|
||||
|
||||
return { ok: true, messageId };
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to send message: ${formatErrorMessage(error)}`);
|
||||
return {
|
||||
ok: false,
|
||||
error: formatErrorMessage(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique key for an account
|
||||
*/
|
||||
public getAccountKey(account: TwitchAccountConfig): string {
|
||||
return `${account.username}:${account.channel}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all clients and handlers (for testing)
|
||||
*/
|
||||
clearForTest(): void {
|
||||
this.clients.clear();
|
||||
this.pendingClients.clear();
|
||||
this.connectionPromises.clear();
|
||||
this.messageHandlers.clear();
|
||||
}
|
||||
}
|
||||
104
extensions/twitch/src/types.ts
Normal file
104
extensions/twitch/src/types.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Twitch channel plugin types.
|
||||
*
|
||||
* This file defines Twitch-specific types. Generic channel types are imported
|
||||
* from OpenClaw core.
|
||||
*/
|
||||
|
||||
import type {
|
||||
ChannelAccountSnapshot,
|
||||
ChannelLogSink,
|
||||
ChannelMessageActionAdapter,
|
||||
ChannelMessageActionContext,
|
||||
ChannelOutboundAdapter,
|
||||
ChannelOutboundContext,
|
||||
ChannelPlugin,
|
||||
ChannelResolveKind,
|
||||
ChannelResolveResult,
|
||||
OutboundDeliveryResult,
|
||||
} from "../runtime-api.js";
|
||||
|
||||
// ============================================================================
|
||||
// Twitch-Specific Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Twitch user roles that can be allowed to interact with the bot
|
||||
*/
|
||||
export type TwitchRole = "moderator" | "owner" | "vip" | "subscriber" | "all";
|
||||
|
||||
/**
|
||||
* Account configuration for a Twitch channel
|
||||
*/
|
||||
export interface TwitchAccountConfig {
|
||||
/** Twitch username */
|
||||
username: string;
|
||||
/** Twitch OAuth access token (requires chat:read and chat:write scopes) */
|
||||
accessToken: string;
|
||||
/** Twitch client ID (from Twitch Developer Portal or twitchtokengenerator.com) */
|
||||
clientId: string;
|
||||
/** Channel name to join (required) */
|
||||
channel: string;
|
||||
/** Enable this account */
|
||||
enabled?: boolean;
|
||||
/** Allowlist of Twitch user IDs who can interact with the bot (use IDs for safety, not usernames) */
|
||||
allowFrom?: Array<string>;
|
||||
/** Roles allowed to interact with the bot (e.g., ["mod", "vip", "sub"]) */
|
||||
allowedRoles?: TwitchRole[];
|
||||
/** Require @mention to trigger bot responses */
|
||||
requireMention?: boolean;
|
||||
/** Outbound response prefix override for this channel/account. */
|
||||
responsePrefix?: string;
|
||||
/** Twitch client secret (required for token refresh via RefreshingAuthProvider) */
|
||||
clientSecret?: string;
|
||||
/** Refresh token (required for automatic token refresh) */
|
||||
refreshToken?: string;
|
||||
/** Token expiry time in seconds (optional, for token refresh tracking) */
|
||||
expiresIn?: number | null;
|
||||
/** Timestamp when token was obtained (optional, for token refresh tracking) */
|
||||
obtainmentTimestamp?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Twitch message from chat
|
||||
*/
|
||||
export interface TwitchChatMessage {
|
||||
/** Username of sender */
|
||||
username: string;
|
||||
/** Twitch user ID of sender (unique, persistent identifier) */
|
||||
userId?: string;
|
||||
/** Message text */
|
||||
message: string;
|
||||
/** Channel name */
|
||||
channel: string;
|
||||
/** Display name (may include special characters) */
|
||||
displayName?: string;
|
||||
/** Message ID */
|
||||
id?: string;
|
||||
/** Timestamp */
|
||||
timestamp?: Date;
|
||||
/** Whether the sender is a moderator */
|
||||
isMod?: boolean;
|
||||
/** Whether the sender is the channel owner/broadcaster */
|
||||
isOwner?: boolean;
|
||||
/** Whether the sender is a VIP */
|
||||
isVip?: boolean;
|
||||
/** Whether the sender is a subscriber */
|
||||
isSub?: boolean;
|
||||
/** Chat type */
|
||||
chatType?: "group";
|
||||
}
|
||||
|
||||
// Re-export core types for convenience
|
||||
export type {
|
||||
ChannelAccountSnapshot,
|
||||
ChannelLogSink,
|
||||
ChannelMessageActionAdapter,
|
||||
ChannelMessageActionContext,
|
||||
ChannelOutboundAdapter,
|
||||
ChannelResolveKind,
|
||||
ChannelResolveResult,
|
||||
ChannelPlugin,
|
||||
ChannelOutboundContext,
|
||||
OutboundDeliveryResult,
|
||||
};
|
||||
98
extensions/twitch/src/utils/markdown.ts
Normal file
98
extensions/twitch/src/utils/markdown.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Markdown utilities for Twitch chat
|
||||
*
|
||||
* Twitch chat doesn't support markdown formatting, so we strip it before sending.
|
||||
* Based on OpenClaw's markdownToText in src/agents/tools/web-fetch-utils.ts.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Strip markdown formatting from text for Twitch compatibility.
|
||||
*
|
||||
* Removes images, links, bold, italic, strikethrough, code blocks, inline code,
|
||||
* headers, and list formatting. Replaces newlines with spaces since Twitch
|
||||
* is a single-line chat medium.
|
||||
*
|
||||
* @param markdown - The markdown text to strip
|
||||
* @returns Plain text with markdown removed
|
||||
*/
|
||||
export function stripMarkdownForTwitch(markdown: string): string {
|
||||
return (
|
||||
markdown
|
||||
// Images
|
||||
.replace(/!\[[^\]]*]\([^)]+\)/g, "")
|
||||
// Links
|
||||
.replace(/\[([^\]]+)]\([^)]+\)/g, "$1")
|
||||
// Bold (**text**)
|
||||
.replace(/\*\*([^*]+)\*\*/g, "$1")
|
||||
// Bold (__text__)
|
||||
.replace(/__([^_]+)__/g, "$1")
|
||||
// Italic (*text*)
|
||||
.replace(/\*([^*]+)\*/g, "$1")
|
||||
// Italic (_text_)
|
||||
.replace(/_([^_]+)_/g, "$1")
|
||||
// Strikethrough (~~text~~)
|
||||
.replace(/~~([^~]+)~~/g, "$1")
|
||||
// Code blocks
|
||||
.replace(/```[\s\S]*?```/g, (block) => block.replace(/```[^\n]*\n?/g, "").replace(/```/g, ""))
|
||||
// Inline code
|
||||
.replace(/`([^`]+)`/g, "$1")
|
||||
// Headers
|
||||
.replace(/^#{1,6}\s+/gm, "")
|
||||
// Lists
|
||||
.replace(/^\s*[-*+]\s+/gm, "")
|
||||
.replace(/^\s*\d+\.\s+/gm, "")
|
||||
// Normalize whitespace
|
||||
.replace(/\r/g, "") // Remove carriage returns
|
||||
.replace(/[ \t]+\n/g, "\n") // Remove trailing spaces before newlines
|
||||
.replace(/\n/g, " ") // Replace newlines with spaces (for Twitch)
|
||||
.replace(/[ \t]{2,}/g, " ") // Reduce multiple spaces to single
|
||||
.trim()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple word-boundary chunker for Twitch (500 char limit).
|
||||
* Strips markdown before chunking to avoid breaking markdown patterns.
|
||||
*
|
||||
* @param text - The text to chunk
|
||||
* @param limit - Maximum characters per chunk (Twitch limit is 500)
|
||||
* @returns Array of text chunks
|
||||
*/
|
||||
export function chunkTextForTwitch(text: string, limit: number): string[] {
|
||||
// First, strip markdown
|
||||
const cleaned = stripMarkdownForTwitch(text);
|
||||
if (!cleaned) {
|
||||
return [];
|
||||
}
|
||||
if (limit <= 0) {
|
||||
return [cleaned];
|
||||
}
|
||||
if (cleaned.length <= limit) {
|
||||
return [cleaned];
|
||||
}
|
||||
|
||||
const chunks: string[] = [];
|
||||
let remaining = cleaned;
|
||||
|
||||
while (remaining.length > limit) {
|
||||
// Find the last space before the limit
|
||||
const window = remaining.slice(0, limit);
|
||||
const lastSpaceIndex = window.lastIndexOf(" ");
|
||||
|
||||
if (lastSpaceIndex === -1) {
|
||||
// No space found, hard split at limit
|
||||
chunks.push(window);
|
||||
remaining = remaining.slice(limit);
|
||||
} else {
|
||||
// Split at the last space
|
||||
chunks.push(window.slice(0, lastSpaceIndex));
|
||||
remaining = remaining.slice(lastSpaceIndex + 1);
|
||||
}
|
||||
}
|
||||
|
||||
if (remaining) {
|
||||
chunks.push(remaining);
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
82
extensions/twitch/src/utils/twitch.ts
Normal file
82
extensions/twitch/src/utils/twitch.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
// Twitch plugin module implements twitch behavior.
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
|
||||
/**
|
||||
* Twitch-specific utility functions
|
||||
*/
|
||||
|
||||
/**
|
||||
* Normalize Twitch channel names.
|
||||
*
|
||||
* Removes the '#' prefix if present, converts to lowercase, and trims whitespace.
|
||||
* Twitch channel names are case-insensitive and don't use the '#' prefix in the API.
|
||||
*
|
||||
* @param channel - The channel name to normalize
|
||||
* @returns Normalized channel name
|
||||
*
|
||||
* @example
|
||||
* normalizeTwitchChannel("#TwitchChannel") // "twitchchannel"
|
||||
* normalizeTwitchChannel("MyChannel") // "mychannel"
|
||||
*/
|
||||
export function normalizeTwitchChannel(channel: string): string {
|
||||
const trimmed = normalizeLowercaseStringOrEmpty(channel);
|
||||
return trimmed.startsWith("#") ? trimmed.slice(1) : trimmed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a standardized error message for missing target.
|
||||
*
|
||||
* @param provider - The provider name (e.g., "Twitch")
|
||||
* @param hint - Optional hint for how to fix the issue
|
||||
* @returns Error object with descriptive message
|
||||
*/
|
||||
export function missingTargetError(provider: string, hint?: string): Error {
|
||||
return new Error(`Delivering to ${provider} requires target${hint ? ` ${hint}` : ""}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique message ID for Twitch messages.
|
||||
*
|
||||
* Twurple's say() doesn't return the message ID, so we generate one
|
||||
* for tracking purposes.
|
||||
*
|
||||
* @returns A unique message ID
|
||||
*/
|
||||
export function generateMessageId(): string {
|
||||
return `${Date.now()}-${randomUUID()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize OAuth token by removing the "oauth:" prefix if present.
|
||||
*
|
||||
* Twurple doesn't require the "oauth:" prefix, so we strip it for consistency.
|
||||
*
|
||||
* @param token - The OAuth token to normalize
|
||||
* @returns Normalized token without "oauth:" prefix
|
||||
*
|
||||
* @example
|
||||
* normalizeToken("oauth:abc123") // "abc123"
|
||||
* normalizeToken("abc123") // "abc123"
|
||||
*/
|
||||
export function normalizeToken(token: string): string {
|
||||
return token.startsWith("oauth:") ? token.slice(6) : token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an account is properly configured with required credentials.
|
||||
*
|
||||
* @param account - The Twitch account config to check
|
||||
* @returns true if the account has required credentials
|
||||
*/
|
||||
export function isAccountConfigured(
|
||||
account: {
|
||||
username?: string;
|
||||
accessToken?: string;
|
||||
clientId?: string;
|
||||
},
|
||||
resolvedToken?: string | null,
|
||||
): boolean {
|
||||
const token = resolvedToken ?? account?.accessToken;
|
||||
return Boolean(account?.username && token && account?.clientId);
|
||||
}
|
||||
7
extensions/twitch/test/setup.ts
Normal file
7
extensions/twitch/test/setup.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Vitest setup file for Twitch plugin tests.
|
||||
*
|
||||
* Re-exports the root test setup to avoid duplication.
|
||||
*/
|
||||
|
||||
export * from "../../../test/setup.js";
|
||||
16
extensions/twitch/tsconfig.json
Normal file
16
extensions/twitch/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "../tsconfig.package-boundary.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["./*.ts", "./src/**/*.ts"],
|
||||
"exclude": [
|
||||
"./**/*.test.ts",
|
||||
"./dist/**",
|
||||
"./node_modules/**",
|
||||
"./src/test-support/**",
|
||||
"./src/**/*test-helpers.ts",
|
||||
"./src/**/*test-harness.ts",
|
||||
"./src/**/*test-support.ts"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user