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

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:
2026-07-05 09:36:54 +00:00
parent 3216769225
commit bedb527145
21108 changed files with 6010766 additions and 0 deletions

View 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
View 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";

View File

@@ -0,0 +1,2 @@
// Twitch API module exposes the plugin public contract.
export { twitchPlugin } from "./src/plugin.js";

View 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,
});
});

View 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
View 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"
}
}
}
}

View 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": {}
}
}

View 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
}
}
}

View 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";

View 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",
},
});

View 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";

View 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");
});
});
});

View 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;
}

View 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",
});
});
});

View 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);
}
},
};

View 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);
});
});

View 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();
}
}

View 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);
});
});

View 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]);

View 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);
});
});

View 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
);
}

View 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 };
}

View 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,
);
});
});
});

View 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");
},
},
});

View 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 });
});
});

View 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);
});

View 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");
});
});

View 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}`);
},
},
},
});

View 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);
});
});

View 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
}
}
}
}

View 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;
}

View 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 };

View 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",
);
});
});
});

View 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,
};
}
}

View 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",
);
});
});
});

View 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,
};

View 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",
});
});
});
});

View 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;
}

View 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();
});
}

View 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");
});
});
});

View 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" };
}

View 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();
});
});
});

View 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();
}
}

View 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,
};

View 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;
}

View 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);
}

View 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";

View 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"
]
}