feat: Twitch Plugin (#1612)
* wip * copy polugin files * wip type changes * refactor: improve Twitch plugin code quality and fix all tests - Extract client manager registry for centralized lifecycle management - Refactor to use early returns and reduce mutations - Fix status check logic for clientId detection - Add comprehensive test coverage for new modules - Remove tests for unimplemented features (index.test.ts, resolver.test.ts) - Fix mock setup issues in test suite (149 tests now passing) - Improve error handling with errorResponse helper in actions.ts - Normalize token handling to eliminate duplication Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * use accountId * delete md file * delte tsconfig * adjust log level * fix probe logic * format * fix monitor * code review fixes * format * no mutation * less mutation * chain debug log * await authProvider setup * use uuid * use spread * fix tests * update docs and remove bot channel fallback * more readme fixes * remove comments + fromat * fix tests * adjust access control logic * format * install * simplify config object * remove duplicate log tags + log received messages * update docs * update tests * format * strip markdown in monitor * remove strip markdown config, enabled by default * default requireMention to true * fix store path arg * fix multi account id + add unit test * fix multi account id + add unit test * make channel required and update docs * remove whisper functionality * remove duplicate connect log * update docs with convert twitch link * make twitch message processing non blocking * schema consistent casing * remove noisy ignore log * use coreLogger --------- Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
c5ffc11df5
commit
f5c90f0e5c
38 changed files with 6558 additions and 8 deletions
|
|
@ -26,6 +26,7 @@ Text is supported everywhere; media and reactions vary by channel.
|
||||||
- [Matrix](/channels/matrix) — Matrix protocol (plugin, installed separately).
|
- [Matrix](/channels/matrix) — Matrix protocol (plugin, installed separately).
|
||||||
- [Nostr](/channels/nostr) — Decentralized DMs via NIP-04 (plugin, installed separately).
|
- [Nostr](/channels/nostr) — Decentralized DMs via NIP-04 (plugin, installed separately).
|
||||||
- [Tlon](/channels/tlon) — Urbit-based messenger (plugin, installed separately).
|
- [Tlon](/channels/tlon) — Urbit-based messenger (plugin, installed separately).
|
||||||
|
- [Twitch](/channels/twitch) — Twitch chat via IRC connection (plugin, installed separately).
|
||||||
- [Zalo](/channels/zalo) — Zalo Bot API; Vietnam's popular messenger (plugin, installed separately).
|
- [Zalo](/channels/zalo) — Zalo Bot API; Vietnam's popular messenger (plugin, installed separately).
|
||||||
- [Zalo Personal](/channels/zalouser) — Zalo personal account via QR login (plugin, installed separately).
|
- [Zalo Personal](/channels/zalouser) — Zalo personal account via QR login (plugin, installed separately).
|
||||||
- [WebChat](/web/webchat) — Gateway WebChat UI over WebSocket.
|
- [WebChat](/web/webchat) — Gateway WebChat UI over WebSocket.
|
||||||
|
|
|
||||||
366
docs/channels/twitch.md
Normal file
366
docs/channels/twitch.md
Normal file
|
|
@ -0,0 +1,366 @@
|
||||||
|
---
|
||||||
|
summary: "Twitch chat bot configuration and setup"
|
||||||
|
read_when:
|
||||||
|
- Setting up Twitch chat integration for Clawdbot
|
||||||
|
---
|
||||||
|
# Twitch (plugin)
|
||||||
|
|
||||||
|
Twitch chat support via IRC connection. Clawdbot connects as a Twitch user (bot account) to receive and send messages in channels.
|
||||||
|
|
||||||
|
## Plugin required
|
||||||
|
|
||||||
|
Twitch ships as a plugin and is not bundled with the core install.
|
||||||
|
|
||||||
|
Install via CLI (npm registry):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot plugins install @clawdbot/twitch
|
||||||
|
```
|
||||||
|
|
||||||
|
Local checkout (when running from a git repo):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot plugins install ./extensions/twitch
|
||||||
|
```
|
||||||
|
|
||||||
|
Details: [Plugins](/plugin)
|
||||||
|
|
||||||
|
## Quick setup (beginner)
|
||||||
|
|
||||||
|
1) Create a dedicated Twitch account for the bot (or use an existing account).
|
||||||
|
2) Generate credentials: [Twitch Token Generator](https://twitchtokengenerator.com/)
|
||||||
|
- Select **Bot Token**
|
||||||
|
- Verify scopes `chat:read` and `chat:write` are selected
|
||||||
|
- Copy the **Client ID** and **Access Token**
|
||||||
|
3) Find your Twitch user ID: https://www.streamweasels.com/tools/convert-twitch-username-to-user-id/
|
||||||
|
4) Configure the token:
|
||||||
|
- Env: `CLAWDBOT_TWITCH_ACCESS_TOKEN=...` (default account only)
|
||||||
|
- Or config: `channels.twitch.accessToken`
|
||||||
|
- If both are set, config takes precedence (env fallback is default-account only).
|
||||||
|
5) Start the gateway.
|
||||||
|
|
||||||
|
**⚠️ Important:** Add access control (`allowFrom` or `allowedRoles`) to prevent unauthorized users from triggering the bot. `requireMention` defaults to `true`.
|
||||||
|
|
||||||
|
Minimal config:
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
channels: {
|
||||||
|
twitch: {
|
||||||
|
enabled: true,
|
||||||
|
username: "clawdbot", // Bot's Twitch account
|
||||||
|
accessToken: "oauth:abc123...", // OAuth Access Token (or use CLAWDBOT_TWITCH_ACCESS_TOKEN env var)
|
||||||
|
clientId: "xyz789...", // Client ID from Token Generator
|
||||||
|
channel: "vevisk", // Which Twitch channel's chat to join (required)
|
||||||
|
allowFrom: ["123456789"] // (recommended) Your Twitch user ID only - get it from https://www.streamweasels.com/tools/convert-twitch-username-to-user-id/
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## What it is
|
||||||
|
|
||||||
|
- A Twitch channel owned by the Gateway.
|
||||||
|
- Deterministic routing: replies always go back to Twitch.
|
||||||
|
- Each account maps to an isolated session key `agent:<agentId>:twitch:<accountName>`.
|
||||||
|
- `username` is the bot's account (who authenticates), `channel` is which chat room to join.
|
||||||
|
|
||||||
|
## Setup (detailed)
|
||||||
|
|
||||||
|
### Generate credentials
|
||||||
|
|
||||||
|
Use [Twitch Token Generator](https://twitchtokengenerator.com/):
|
||||||
|
- Select **Bot Token**
|
||||||
|
- Verify scopes `chat:read` and `chat:write` are selected
|
||||||
|
- Copy the **Client ID** and **Access Token**
|
||||||
|
|
||||||
|
No manual app registration needed. Tokens expire after several hours.
|
||||||
|
|
||||||
|
### Configure the bot
|
||||||
|
|
||||||
|
**Env var (default account only):**
|
||||||
|
```bash
|
||||||
|
CLAWDBOT_TWITCH_ACCESS_TOKEN=oauth:abc123...
|
||||||
|
```
|
||||||
|
|
||||||
|
**Or config:**
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
channels: {
|
||||||
|
twitch: {
|
||||||
|
enabled: true,
|
||||||
|
username: "clawdbot",
|
||||||
|
accessToken: "oauth:abc123...",
|
||||||
|
clientId: "xyz789...",
|
||||||
|
channel: "vevisk"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If both env and config are set, config takes precedence.
|
||||||
|
|
||||||
|
### Access control (recommended)
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
channels: {
|
||||||
|
twitch: {
|
||||||
|
allowFrom: ["123456789"], // (recommended) Your Twitch user ID only
|
||||||
|
allowedRoles: ["moderator"] // Or restrict to roles
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Available roles:** `"moderator"`, `"owner"`, `"vip"`, `"subscriber"`, `"all"`.
|
||||||
|
|
||||||
|
**Why user IDs?** Usernames can change, allowing impersonation. User IDs are permanent.
|
||||||
|
|
||||||
|
Find your Twitch user ID: https://www.streamweasels.com/tools/convert-twitch-username-%20to-user-id/ (Convert your Twitch username to ID)
|
||||||
|
|
||||||
|
## Token refresh (optional)
|
||||||
|
|
||||||
|
Tokens from [Twitch Token Generator](https://twitchtokengenerator.com/) cannot be automatically refreshed - regenerate when expired.
|
||||||
|
|
||||||
|
For automatic token refresh, create your own Twitch application at [Twitch Developer Console](https://dev.twitch.tv/console) and add to config:
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
channels: {
|
||||||
|
twitch: {
|
||||||
|
clientSecret: "your_client_secret",
|
||||||
|
refreshToken: "your_refresh_token"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The bot automatically refreshes tokens before expiration and logs refresh events.
|
||||||
|
|
||||||
|
## Multi-account support
|
||||||
|
|
||||||
|
Use `channels.twitch.accounts` with per-account tokens. See [`gateway/configuration`](/gateway/configuration) for the shared pattern.
|
||||||
|
|
||||||
|
Example (one bot account in two channels):
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
channels: {
|
||||||
|
twitch: {
|
||||||
|
accounts: {
|
||||||
|
channel1: {
|
||||||
|
username: "clawdbot",
|
||||||
|
accessToken: "oauth:abc123...",
|
||||||
|
clientId: "xyz789...",
|
||||||
|
channel: "vevisk"
|
||||||
|
},
|
||||||
|
channel2: {
|
||||||
|
username: "clawdbot",
|
||||||
|
accessToken: "oauth:def456...",
|
||||||
|
clientId: "uvw012...",
|
||||||
|
channel: "secondchannel"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** Each account needs its own token (one token per channel).
|
||||||
|
|
||||||
|
## Access control
|
||||||
|
|
||||||
|
### Role-based restrictions
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
channels: {
|
||||||
|
twitch: {
|
||||||
|
accounts: {
|
||||||
|
default: {
|
||||||
|
allowedRoles: ["moderator", "vip"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Allowlist by User ID (most secure)
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
channels: {
|
||||||
|
twitch: {
|
||||||
|
accounts: {
|
||||||
|
default: {
|
||||||
|
allowFrom: ["123456789", "987654321"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Combined allowlist + roles
|
||||||
|
|
||||||
|
Users in `allowFrom` bypass role checks:
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
channels: {
|
||||||
|
twitch: {
|
||||||
|
accounts: {
|
||||||
|
default: {
|
||||||
|
allowFrom: ["123456789"],
|
||||||
|
allowedRoles: ["moderator"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Disable @mention requirement
|
||||||
|
|
||||||
|
By default, `requireMention` is `true`. To disable and respond to all messages:
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
channels: {
|
||||||
|
twitch: {
|
||||||
|
accounts: {
|
||||||
|
default: {
|
||||||
|
requireMention: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
First, run diagnostic commands:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot doctor
|
||||||
|
clawdbot channels status --probe
|
||||||
|
```
|
||||||
|
|
||||||
|
### Bot doesn't respond to messages
|
||||||
|
|
||||||
|
**Check access control:** Temporarily set `allowedRoles: ["all"]` to test.
|
||||||
|
|
||||||
|
**Check the bot is in the channel:** The bot must join the channel specified in `channel`.
|
||||||
|
|
||||||
|
### Token issues
|
||||||
|
|
||||||
|
**"Failed to connect" or authentication errors:**
|
||||||
|
- Verify `accessToken` is the OAuth access token value (typically starts with `oauth:` prefix)
|
||||||
|
- Check token has `chat:read` and `chat:write` scopes
|
||||||
|
- If using token refresh, verify `clientSecret` and `refreshToken` are set
|
||||||
|
|
||||||
|
### Token refresh not working
|
||||||
|
|
||||||
|
**Check logs for refresh events:**
|
||||||
|
```
|
||||||
|
Using env token source for mybot
|
||||||
|
Access token refreshed for user 123456 (expires in 14400s)
|
||||||
|
```
|
||||||
|
|
||||||
|
If you see "token refresh disabled (no refresh token)":
|
||||||
|
- Ensure `clientSecret` is provided
|
||||||
|
- Ensure `refreshToken` is provided
|
||||||
|
|
||||||
|
## Config
|
||||||
|
|
||||||
|
**Account config:**
|
||||||
|
- `username` - Bot username
|
||||||
|
- `accessToken` - OAuth access token with `chat:read` and `chat:write`
|
||||||
|
- `clientId` - Twitch Client ID (from Token Generator or your app)
|
||||||
|
- `channel` - Channel to join (required)
|
||||||
|
- `enabled` - Enable this account (default: `true`)
|
||||||
|
- `clientSecret` - Optional: For automatic token refresh
|
||||||
|
- `refreshToken` - Optional: For automatic token refresh
|
||||||
|
- `expiresIn` - Token expiry in seconds
|
||||||
|
- `obtainmentTimestamp` - Token obtained timestamp
|
||||||
|
- `allowFrom` - User ID allowlist
|
||||||
|
- `allowedRoles` - Role-based access control (`"moderator" | "owner" | "vip" | "subscriber" | "all"`)
|
||||||
|
- `requireMention` - Require @mention (default: `true`)
|
||||||
|
|
||||||
|
**Provider options:**
|
||||||
|
- `channels.twitch.enabled` - Enable/disable channel startup
|
||||||
|
- `channels.twitch.username` - Bot username (simplified single-account config)
|
||||||
|
- `channels.twitch.accessToken` - OAuth access token (simplified single-account config)
|
||||||
|
- `channels.twitch.clientId` - Twitch Client ID (simplified single-account config)
|
||||||
|
- `channels.twitch.channel` - Channel to join (simplified single-account config)
|
||||||
|
- `channels.twitch.accounts.<accountName>` - Multi-account config (all account fields above)
|
||||||
|
|
||||||
|
Full example:
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
channels: {
|
||||||
|
twitch: {
|
||||||
|
enabled: true,
|
||||||
|
username: "clawdbot",
|
||||||
|
accessToken: "oauth:abc123...",
|
||||||
|
clientId: "xyz789...",
|
||||||
|
channel: "vevisk",
|
||||||
|
clientSecret: "secret123...",
|
||||||
|
refreshToken: "refresh456...",
|
||||||
|
allowFrom: ["123456789"],
|
||||||
|
allowedRoles: ["moderator", "vip"],
|
||||||
|
accounts: {
|
||||||
|
default: {
|
||||||
|
username: "mybot",
|
||||||
|
accessToken: "oauth:abc123...",
|
||||||
|
clientId: "xyz789...",
|
||||||
|
channel: "your_channel",
|
||||||
|
enabled: true,
|
||||||
|
clientSecret: "secret123...",
|
||||||
|
refreshToken: "refresh456...",
|
||||||
|
expiresIn: 14400,
|
||||||
|
obtainmentTimestamp: 1706092800000,
|
||||||
|
allowFrom: ["123456789", "987654321"],
|
||||||
|
allowedRoles: ["moderator"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tool actions
|
||||||
|
|
||||||
|
The agent can call `twitch` with action:
|
||||||
|
- `send` - Send a message to a channel
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
"action": "twitch",
|
||||||
|
"params": {
|
||||||
|
"message": "Hello Twitch!",
|
||||||
|
"to": "#mychannel"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Safety & ops
|
||||||
|
|
||||||
|
- **Treat tokens like passwords** - Never commit tokens to git
|
||||||
|
- **Use automatic token refresh** for long-running bots
|
||||||
|
- **Use user ID allowlists** instead of usernames for access control
|
||||||
|
- **Monitor logs** for token refresh events and connection status
|
||||||
|
- **Scope tokens minimally** - Only request `chat:read` and `chat:write`
|
||||||
|
- **If stuck**: Restart the gateway after confirming no other process owns the session
|
||||||
|
|
||||||
|
## Limits
|
||||||
|
|
||||||
|
- **500 characters** per message (auto-chunked at word boundaries)
|
||||||
|
- Markdown is stripped before chunking
|
||||||
|
- No rate limiting (uses Twitch's built-in rate limits)
|
||||||
21
extensions/twitch/CHANGELOG.md
Normal file
21
extensions/twitch/CHANGELOG.md
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
# Changelog
|
||||||
|
|
||||||
|
## 2026.1.23
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- Initial Twitch plugin release
|
||||||
|
- Twitch chat integration via @twurple (IRC connection)
|
||||||
|
- Multi-account support with per-channel configuration
|
||||||
|
- Access control via user ID allowlists and role-based restrictions
|
||||||
|
- Automatic token refresh with RefreshingAuthProvider
|
||||||
|
- Environment variable fallback for default account token
|
||||||
|
- Message actions support
|
||||||
|
- Status monitoring and probing
|
||||||
|
- Outbound message delivery with markdown stripping
|
||||||
|
|
||||||
|
### Improvements
|
||||||
|
|
||||||
|
- Added proper configuration schema with Zod validation
|
||||||
|
- Added plugin descriptor (clawdbot.plugin.json)
|
||||||
|
- Added comprehensive README and documentation
|
||||||
89
extensions/twitch/README.md
Normal file
89
extensions/twitch/README.md
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
# @clawdbot/twitch
|
||||||
|
|
||||||
|
Twitch channel plugin for Clawdbot.
|
||||||
|
|
||||||
|
## Install (local checkout)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot plugins install ./extensions/twitch
|
||||||
|
```
|
||||||
|
|
||||||
|
## Install (npm)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot plugins install @clawdbot/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: "clawdbot",
|
||||||
|
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-%20to-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: "clawdbot",
|
||||||
|
accessToken: "oauth:abc123...",
|
||||||
|
clientId: "xyz789...",
|
||||||
|
channel: "vevisk",
|
||||||
|
},
|
||||||
|
channel2: {
|
||||||
|
username: "clawdbot",
|
||||||
|
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.clawd.bot/channels/twitch for:
|
||||||
|
|
||||||
|
- Token refresh setup
|
||||||
|
- Access control patterns
|
||||||
|
- Multi-account configuration
|
||||||
|
- Troubleshooting
|
||||||
|
- Capabilities & limits
|
||||||
9
extensions/twitch/clawdbot.plugin.json
Normal file
9
extensions/twitch/clawdbot.plugin.json
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"id": "twitch",
|
||||||
|
"channels": ["twitch"],
|
||||||
|
"configSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
20
extensions/twitch/index.ts
Normal file
20
extensions/twitch/index.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
|
||||||
|
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
|
import { twitchPlugin } from "./src/plugin.js";
|
||||||
|
import { setTwitchRuntime } from "./src/runtime.js";
|
||||||
|
|
||||||
|
export { monitorTwitchProvider } from "./src/monitor.js";
|
||||||
|
|
||||||
|
const plugin = {
|
||||||
|
id: "twitch",
|
||||||
|
name: "Twitch",
|
||||||
|
description: "Twitch channel plugin",
|
||||||
|
configSchema: emptyPluginConfigSchema(),
|
||||||
|
register(api: ClawdbotPluginApi) {
|
||||||
|
setTwitchRuntime(api.runtime);
|
||||||
|
api.registerChannel({ plugin: twitchPlugin as any });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default plugin;
|
||||||
20
extensions/twitch/package.json
Normal file
20
extensions/twitch/package.json
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
{
|
||||||
|
"name": "@clawdbot/twitch",
|
||||||
|
"version": "2026.1.23",
|
||||||
|
"description": "Clawdbot Twitch channel plugin",
|
||||||
|
"type": "module",
|
||||||
|
"dependencies": {
|
||||||
|
"@twurple/api": "^8.0.3",
|
||||||
|
"@twurple/auth": "^8.0.3",
|
||||||
|
"@twurple/chat": "^8.0.3",
|
||||||
|
"zod": "^4.3.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"clawdbot": "workspace:*"
|
||||||
|
},
|
||||||
|
"clawdbot": {
|
||||||
|
"extensions": [
|
||||||
|
"./index.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
489
extensions/twitch/src/access-control.test.ts
Normal file
489
extensions/twitch/src/access-control.test.ts
Normal file
|
|
@ -0,0 +1,489 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { checkTwitchAccessControl, extractMentions } from "./access-control.js";
|
||||||
|
import type { TwitchAccountConfig, TwitchChatMessage } from "./types.js";
|
||||||
|
|
||||||
|
describe("checkTwitchAccessControl", () => {
|
||||||
|
const mockAccount: TwitchAccountConfig = {
|
||||||
|
username: "testbot",
|
||||||
|
token: "oauth:test",
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockMessage: TwitchChatMessage = {
|
||||||
|
username: "testuser",
|
||||||
|
userId: "123456",
|
||||||
|
message: "hello bot",
|
||||||
|
channel: "testchannel",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("when no restrictions are configured", () => {
|
||||||
|
it("allows messages that mention the bot (default requireMention)", () => {
|
||||||
|
const message: TwitchChatMessage = {
|
||||||
|
...mockMessage,
|
||||||
|
message: "@testbot hello",
|
||||||
|
};
|
||||||
|
const result = checkTwitchAccessControl({
|
||||||
|
message,
|
||||||
|
account: mockAccount,
|
||||||
|
botUsername: "testbot",
|
||||||
|
});
|
||||||
|
expect(result.allowed).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("requireMention default", () => {
|
||||||
|
it("defaults to true when undefined", () => {
|
||||||
|
const message: TwitchChatMessage = {
|
||||||
|
...mockMessage,
|
||||||
|
message: "hello bot",
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = checkTwitchAccessControl({
|
||||||
|
message,
|
||||||
|
account: mockAccount,
|
||||||
|
botUsername: "testbot",
|
||||||
|
});
|
||||||
|
expect(result.allowed).toBe(false);
|
||||||
|
expect(result.reason).toContain("does not mention the bot");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows mention when requireMention is undefined", () => {
|
||||||
|
const message: TwitchChatMessage = {
|
||||||
|
...mockMessage,
|
||||||
|
message: "@testbot hello",
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = checkTwitchAccessControl({
|
||||||
|
message,
|
||||||
|
account: mockAccount,
|
||||||
|
botUsername: "testbot",
|
||||||
|
});
|
||||||
|
expect(result.allowed).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("requireMention", () => {
|
||||||
|
it("allows messages that mention the bot", () => {
|
||||||
|
const account: TwitchAccountConfig = {
|
||||||
|
...mockAccount,
|
||||||
|
requireMention: true,
|
||||||
|
};
|
||||||
|
const message: TwitchChatMessage = {
|
||||||
|
...mockMessage,
|
||||||
|
message: "@testbot hello",
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = checkTwitchAccessControl({
|
||||||
|
message,
|
||||||
|
account,
|
||||||
|
botUsername: "testbot",
|
||||||
|
});
|
||||||
|
expect(result.allowed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks messages that don't mention the bot", () => {
|
||||||
|
const account: TwitchAccountConfig = {
|
||||||
|
...mockAccount,
|
||||||
|
requireMention: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = checkTwitchAccessControl({
|
||||||
|
message: mockMessage,
|
||||||
|
account,
|
||||||
|
botUsername: "testbot",
|
||||||
|
});
|
||||||
|
expect(result.allowed).toBe(false);
|
||||||
|
expect(result.reason).toContain("does not mention the bot");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is case-insensitive for bot username", () => {
|
||||||
|
const account: TwitchAccountConfig = {
|
||||||
|
...mockAccount,
|
||||||
|
requireMention: true,
|
||||||
|
};
|
||||||
|
const message: TwitchChatMessage = {
|
||||||
|
...mockMessage,
|
||||||
|
message: "@TestBot hello",
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = checkTwitchAccessControl({
|
||||||
|
message,
|
||||||
|
account,
|
||||||
|
botUsername: "testbot",
|
||||||
|
});
|
||||||
|
expect(result.allowed).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("allowFrom allowlist", () => {
|
||||||
|
it("allows users in the allowlist", () => {
|
||||||
|
const account: TwitchAccountConfig = {
|
||||||
|
...mockAccount,
|
||||||
|
allowFrom: ["123456", "789012"],
|
||||||
|
};
|
||||||
|
const message: TwitchChatMessage = {
|
||||||
|
...mockMessage,
|
||||||
|
message: "@testbot hello",
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = checkTwitchAccessControl({
|
||||||
|
message,
|
||||||
|
account,
|
||||||
|
botUsername: "testbot",
|
||||||
|
});
|
||||||
|
expect(result.allowed).toBe(true);
|
||||||
|
expect(result.matchKey).toBe("123456");
|
||||||
|
expect(result.matchSource).toBe("allowlist");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows users not in allowlist via fallback (open access)", () => {
|
||||||
|
const account: TwitchAccountConfig = {
|
||||||
|
...mockAccount,
|
||||||
|
allowFrom: ["789012"],
|
||||||
|
};
|
||||||
|
const message: TwitchChatMessage = {
|
||||||
|
...mockMessage,
|
||||||
|
message: "@testbot hello",
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = checkTwitchAccessControl({
|
||||||
|
message,
|
||||||
|
account,
|
||||||
|
botUsername: "testbot",
|
||||||
|
});
|
||||||
|
// Falls through to final fallback since allowedRoles is not set
|
||||||
|
expect(result.allowed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks messages without userId", () => {
|
||||||
|
const account: TwitchAccountConfig = {
|
||||||
|
...mockAccount,
|
||||||
|
allowFrom: ["123456"],
|
||||||
|
};
|
||||||
|
const message: TwitchChatMessage = {
|
||||||
|
...mockMessage,
|
||||||
|
message: "@testbot hello",
|
||||||
|
userId: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = checkTwitchAccessControl({
|
||||||
|
message,
|
||||||
|
account,
|
||||||
|
botUsername: "testbot",
|
||||||
|
});
|
||||||
|
expect(result.allowed).toBe(false);
|
||||||
|
expect(result.reason).toContain("user ID not available");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bypasses role checks when user is in allowlist", () => {
|
||||||
|
const account: TwitchAccountConfig = {
|
||||||
|
...mockAccount,
|
||||||
|
allowFrom: ["123456"],
|
||||||
|
allowedRoles: ["owner"],
|
||||||
|
};
|
||||||
|
const message: TwitchChatMessage = {
|
||||||
|
...mockMessage,
|
||||||
|
message: "@testbot hello",
|
||||||
|
isOwner: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = checkTwitchAccessControl({
|
||||||
|
message,
|
||||||
|
account,
|
||||||
|
botUsername: "testbot",
|
||||||
|
});
|
||||||
|
expect(result.allowed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows user with role even if not in allowlist", () => {
|
||||||
|
const account: TwitchAccountConfig = {
|
||||||
|
...mockAccount,
|
||||||
|
allowFrom: ["789012"],
|
||||||
|
allowedRoles: ["moderator"],
|
||||||
|
};
|
||||||
|
const message: TwitchChatMessage = {
|
||||||
|
...mockMessage,
|
||||||
|
message: "@testbot hello",
|
||||||
|
userId: "123456",
|
||||||
|
isMod: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = checkTwitchAccessControl({
|
||||||
|
message,
|
||||||
|
account,
|
||||||
|
botUsername: "testbot",
|
||||||
|
});
|
||||||
|
expect(result.allowed).toBe(true);
|
||||||
|
expect(result.matchSource).toBe("role");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks user with neither allowlist nor role", () => {
|
||||||
|
const account: TwitchAccountConfig = {
|
||||||
|
...mockAccount,
|
||||||
|
allowFrom: ["789012"],
|
||||||
|
allowedRoles: ["moderator"],
|
||||||
|
};
|
||||||
|
const message: TwitchChatMessage = {
|
||||||
|
...mockMessage,
|
||||||
|
message: "@testbot hello",
|
||||||
|
userId: "123456",
|
||||||
|
isMod: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = checkTwitchAccessControl({
|
||||||
|
message,
|
||||||
|
account,
|
||||||
|
botUsername: "testbot",
|
||||||
|
});
|
||||||
|
expect(result.allowed).toBe(false);
|
||||||
|
expect(result.reason).toContain("does not have any of the required roles");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("allowedRoles", () => {
|
||||||
|
it("allows users with matching role", () => {
|
||||||
|
const account: TwitchAccountConfig = {
|
||||||
|
...mockAccount,
|
||||||
|
allowedRoles: ["moderator"],
|
||||||
|
};
|
||||||
|
const message: TwitchChatMessage = {
|
||||||
|
...mockMessage,
|
||||||
|
message: "@testbot hello",
|
||||||
|
isMod: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = checkTwitchAccessControl({
|
||||||
|
message,
|
||||||
|
account,
|
||||||
|
botUsername: "testbot",
|
||||||
|
});
|
||||||
|
expect(result.allowed).toBe(true);
|
||||||
|
expect(result.matchSource).toBe("role");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows users with any of multiple roles", () => {
|
||||||
|
const account: TwitchAccountConfig = {
|
||||||
|
...mockAccount,
|
||||||
|
allowedRoles: ["moderator", "vip", "subscriber"],
|
||||||
|
};
|
||||||
|
const message: TwitchChatMessage = {
|
||||||
|
...mockMessage,
|
||||||
|
message: "@testbot hello",
|
||||||
|
isVip: true,
|
||||||
|
isMod: false,
|
||||||
|
isSub: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = checkTwitchAccessControl({
|
||||||
|
message,
|
||||||
|
account,
|
||||||
|
botUsername: "testbot",
|
||||||
|
});
|
||||||
|
expect(result.allowed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks users without matching role", () => {
|
||||||
|
const account: TwitchAccountConfig = {
|
||||||
|
...mockAccount,
|
||||||
|
allowedRoles: ["moderator"],
|
||||||
|
};
|
||||||
|
const message: TwitchChatMessage = {
|
||||||
|
...mockMessage,
|
||||||
|
message: "@testbot hello",
|
||||||
|
isMod: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = 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'", () => {
|
||||||
|
const account: TwitchAccountConfig = {
|
||||||
|
...mockAccount,
|
||||||
|
allowedRoles: ["all"],
|
||||||
|
};
|
||||||
|
const message: TwitchChatMessage = {
|
||||||
|
...mockMessage,
|
||||||
|
message: "@testbot hello",
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = checkTwitchAccessControl({
|
||||||
|
message,
|
||||||
|
account,
|
||||||
|
botUsername: "testbot",
|
||||||
|
});
|
||||||
|
expect(result.allowed).toBe(true);
|
||||||
|
expect(result.matchKey).toBe("all");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles moderator role", () => {
|
||||||
|
const account: TwitchAccountConfig = {
|
||||||
|
...mockAccount,
|
||||||
|
allowedRoles: ["moderator"],
|
||||||
|
};
|
||||||
|
const message: TwitchChatMessage = {
|
||||||
|
...mockMessage,
|
||||||
|
message: "@testbot hello",
|
||||||
|
isMod: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = checkTwitchAccessControl({
|
||||||
|
message,
|
||||||
|
account,
|
||||||
|
botUsername: "testbot",
|
||||||
|
});
|
||||||
|
expect(result.allowed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles subscriber role", () => {
|
||||||
|
const account: TwitchAccountConfig = {
|
||||||
|
...mockAccount,
|
||||||
|
allowedRoles: ["subscriber"],
|
||||||
|
};
|
||||||
|
const message: TwitchChatMessage = {
|
||||||
|
...mockMessage,
|
||||||
|
message: "@testbot hello",
|
||||||
|
isSub: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = checkTwitchAccessControl({
|
||||||
|
message,
|
||||||
|
account,
|
||||||
|
botUsername: "testbot",
|
||||||
|
});
|
||||||
|
expect(result.allowed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles owner role", () => {
|
||||||
|
const account: TwitchAccountConfig = {
|
||||||
|
...mockAccount,
|
||||||
|
allowedRoles: ["owner"],
|
||||||
|
};
|
||||||
|
const message: TwitchChatMessage = {
|
||||||
|
...mockMessage,
|
||||||
|
message: "@testbot hello",
|
||||||
|
isOwner: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = checkTwitchAccessControl({
|
||||||
|
message,
|
||||||
|
account,
|
||||||
|
botUsername: "testbot",
|
||||||
|
});
|
||||||
|
expect(result.allowed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles vip role", () => {
|
||||||
|
const account: TwitchAccountConfig = {
|
||||||
|
...mockAccount,
|
||||||
|
allowedRoles: ["vip"],
|
||||||
|
};
|
||||||
|
const message: TwitchChatMessage = {
|
||||||
|
...mockMessage,
|
||||||
|
message: "@testbot hello",
|
||||||
|
isVip: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = checkTwitchAccessControl({
|
||||||
|
message,
|
||||||
|
account,
|
||||||
|
botUsername: "testbot",
|
||||||
|
});
|
||||||
|
expect(result.allowed).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("combined restrictions", () => {
|
||||||
|
it("checks requireMention before allowlist", () => {
|
||||||
|
const account: TwitchAccountConfig = {
|
||||||
|
...mockAccount,
|
||||||
|
requireMention: true,
|
||||||
|
allowFrom: ["123456"],
|
||||||
|
};
|
||||||
|
const message: TwitchChatMessage = {
|
||||||
|
...mockMessage,
|
||||||
|
message: "hello", // No mention
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = checkTwitchAccessControl({
|
||||||
|
message,
|
||||||
|
account,
|
||||||
|
botUsername: "testbot",
|
||||||
|
});
|
||||||
|
expect(result.allowed).toBe(false);
|
||||||
|
expect(result.reason).toContain("does not mention the bot");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("checks allowlist before allowedRoles", () => {
|
||||||
|
const account: TwitchAccountConfig = {
|
||||||
|
...mockAccount,
|
||||||
|
allowFrom: ["123456"],
|
||||||
|
allowedRoles: ["owner"],
|
||||||
|
};
|
||||||
|
const message: TwitchChatMessage = {
|
||||||
|
...mockMessage,
|
||||||
|
message: "@testbot hello",
|
||||||
|
isOwner: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = checkTwitchAccessControl({
|
||||||
|
message,
|
||||||
|
account,
|
||||||
|
botUsername: "testbot",
|
||||||
|
});
|
||||||
|
expect(result.allowed).toBe(true);
|
||||||
|
expect(result.matchSource).toBe("allowlist");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("extractMentions", () => {
|
||||||
|
it("extracts single mention", () => {
|
||||||
|
const mentions = extractMentions("hello @testbot");
|
||||||
|
expect(mentions).toEqual(["testbot"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extracts multiple mentions", () => {
|
||||||
|
const mentions = extractMentions("hello @testbot and @otheruser");
|
||||||
|
expect(mentions).toEqual(["testbot", "otheruser"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty array when no mentions", () => {
|
||||||
|
const mentions = extractMentions("hello everyone");
|
||||||
|
expect(mentions).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles mentions at start of message", () => {
|
||||||
|
const mentions = extractMentions("@testbot hello");
|
||||||
|
expect(mentions).toEqual(["testbot"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles mentions at end of message", () => {
|
||||||
|
const mentions = extractMentions("hello @testbot");
|
||||||
|
expect(mentions).toEqual(["testbot"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("converts mentions to lowercase", () => {
|
||||||
|
const mentions = extractMentions("hello @TestBot");
|
||||||
|
expect(mentions).toEqual(["testbot"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extracts alphanumeric usernames", () => {
|
||||||
|
const mentions = extractMentions("hello @user123");
|
||||||
|
expect(mentions).toEqual(["user123"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles underscores in usernames", () => {
|
||||||
|
const mentions = extractMentions("hello @test_user");
|
||||||
|
expect(mentions).toEqual(["test_user"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles empty string", () => {
|
||||||
|
const mentions = extractMentions("");
|
||||||
|
expect(mentions).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
154
extensions/twitch/src/access-control.ts
Normal file
154
extensions/twitch/src/access-control.ts
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
import type { TwitchAccountConfig, TwitchChatMessage } from "./types.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of checking access control for a Twitch message
|
||||||
|
*/
|
||||||
|
export type TwitchAccessControlResult = {
|
||||||
|
allowed: boolean;
|
||||||
|
reason?: string;
|
||||||
|
matchKey?: string;
|
||||||
|
matchSource?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a Twitch message should be allowed based on account configuration
|
||||||
|
*
|
||||||
|
* This function implements the access control logic for incoming Twitch messages,
|
||||||
|
* checking allowlists, role-based restrictions, and mention requirements.
|
||||||
|
*
|
||||||
|
* Priority order:
|
||||||
|
* 1. If `requireMention` is true, message must mention the bot
|
||||||
|
* 2. If `allowFrom` is set, sender must be in the allowlist (by user ID)
|
||||||
|
* 3. If `allowedRoles` is set, sender must have at least one of the specified roles
|
||||||
|
*
|
||||||
|
* Note: You can combine `allowFrom` with `allowedRoles`. If a user is in `allowFrom`,
|
||||||
|
* they bypass role checks. This is useful for allowing specific users regardless of role.
|
||||||
|
*
|
||||||
|
* Available roles:
|
||||||
|
* - "moderator": Moderators
|
||||||
|
* - "owner": Channel owner/broadcaster
|
||||||
|
* - "vip": VIPs
|
||||||
|
* - "subscriber": Subscribers
|
||||||
|
* - "all": Anyone in the chat
|
||||||
|
*/
|
||||||
|
export function checkTwitchAccessControl(params: {
|
||||||
|
message: TwitchChatMessage;
|
||||||
|
account: TwitchAccountConfig;
|
||||||
|
botUsername: string;
|
||||||
|
}): TwitchAccessControlResult {
|
||||||
|
const { message, account, botUsername } = params;
|
||||||
|
|
||||||
|
if (account.requireMention ?? true) {
|
||||||
|
const mentions = extractMentions(message.message);
|
||||||
|
if (!mentions.includes(botUsername.toLowerCase())) {
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
reason: "message does not mention the bot (requireMention is enabled)",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (account.allowFrom && account.allowFrom.length > 0) {
|
||||||
|
const allowFrom = account.allowFrom;
|
||||||
|
const senderId = message.userId;
|
||||||
|
|
||||||
|
if (!senderId) {
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
reason: "sender user ID not available for allowlist check",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allowFrom.includes(senderId)) {
|
||||||
|
return {
|
||||||
|
allowed: true,
|
||||||
|
matchKey: senderId,
|
||||||
|
matchSource: "allowlist",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (account.allowedRoles && account.allowedRoles.length > 0) {
|
||||||
|
const allowedRoles = account.allowedRoles;
|
||||||
|
|
||||||
|
// "all" grants access to everyone
|
||||||
|
if (allowedRoles.includes("all")) {
|
||||||
|
return {
|
||||||
|
allowed: true,
|
||||||
|
matchKey: "all",
|
||||||
|
matchSource: "role",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasAllowedRole = checkSenderRoles({
|
||||||
|
message,
|
||||||
|
allowedRoles,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!hasAllowedRole) {
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
reason: `sender does not have any of the required roles: ${allowedRoles.join(", ")}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
allowed: true,
|
||||||
|
matchKey: allowedRoles.join(","),
|
||||||
|
matchSource: "role",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
allowed: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the sender has any of the allowed roles
|
||||||
|
*/
|
||||||
|
function checkSenderRoles(params: { message: TwitchChatMessage; allowedRoles: string[] }): boolean {
|
||||||
|
const { message, allowedRoles } = params;
|
||||||
|
const { isMod, isOwner, isVip, isSub } = message;
|
||||||
|
|
||||||
|
for (const role of allowedRoles) {
|
||||||
|
switch (role) {
|
||||||
|
case "moderator":
|
||||||
|
if (isMod) return true;
|
||||||
|
break;
|
||||||
|
case "owner":
|
||||||
|
if (isOwner) return true;
|
||||||
|
break;
|
||||||
|
case "vip":
|
||||||
|
if (isVip) return true;
|
||||||
|
break;
|
||||||
|
case "subscriber":
|
||||||
|
if (isSub) return true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract @mentions from a Twitch chat message
|
||||||
|
*
|
||||||
|
* Returns a list of lowercase usernames that were mentioned in the message.
|
||||||
|
* Twitch mentions are in the format @username.
|
||||||
|
*/
|
||||||
|
export function extractMentions(message: string): string[] {
|
||||||
|
const mentionRegex = /@(\w+)/g;
|
||||||
|
const mentions: string[] = [];
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
|
||||||
|
// biome-ignore lint/suspicious/noAssignInExpressions: Standard regex iteration pattern
|
||||||
|
while ((match = mentionRegex.exec(message)) !== null) {
|
||||||
|
const username = match[1];
|
||||||
|
if (username) {
|
||||||
|
mentions.push(username.toLowerCase());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return mentions;
|
||||||
|
}
|
||||||
173
extensions/twitch/src/actions.ts
Normal file
173
extensions/twitch/src/actions.ts
Normal file
|
|
@ -0,0 +1,173 @@
|
||||||
|
/**
|
||||||
|
* Twitch message actions adapter.
|
||||||
|
*
|
||||||
|
* Handles tool-based actions for Twitch, such as sending messages.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { DEFAULT_ACCOUNT_ID, getAccountConfig } 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",
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
listActions: () => [...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: clawdbotConfig,
|
||||||
|
* accountId: "default",
|
||||||
|
* });
|
||||||
|
*/
|
||||||
|
handleAction: async (
|
||||||
|
ctx: ChannelMessageActionContext,
|
||||||
|
): Promise<{ content: Array<{ type: string; text: string }> } | null> => {
|
||||||
|
if (ctx.action !== "send") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = readStringParam(ctx.params, "message", { required: true });
|
||||||
|
const to = readStringParam(ctx.params, "to", { required: false });
|
||||||
|
const accountId = ctx.accountId ?? DEFAULT_ACCOUNT_ID;
|
||||||
|
|
||||||
|
const account = getAccountConfig(ctx.cfg, accountId);
|
||||||
|
if (!account) {
|
||||||
|
return errorResponse(
|
||||||
|
`Account not found: ${accountId}. Available accounts: ${Object.keys(ctx.cfg.channels?.twitch?.accounts ?? {}).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",
|
||||||
|
text: JSON.stringify(result),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
details: { ok: true },
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||||
|
return errorResponse(errorMsg);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
115
extensions/twitch/src/client-manager-registry.ts
Normal file
115
extensions/twitch/src/client-manager-registry.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disconnect the client manager
|
||||||
|
await entry.manager.disconnectAll();
|
||||||
|
|
||||||
|
// Remove from registry
|
||||||
|
registry.delete(accountId);
|
||||||
|
entry.logger.info(`Unregistered client manager for account: ${accountId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disconnect and remove all client managers from the registry.
|
||||||
|
*
|
||||||
|
* @returns Promise that resolves when all cleanup is complete
|
||||||
|
*/
|
||||||
|
export async function removeAllClientManagers(): Promise<void> {
|
||||||
|
const promises = [...registry.keys()].map((accountId) => removeClientManager(accountId));
|
||||||
|
await Promise.all(promises);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the number of registered client managers.
|
||||||
|
*
|
||||||
|
* @returns The count of registered managers
|
||||||
|
*/
|
||||||
|
export function getRegisteredClientManagerCount(): number {
|
||||||
|
return registry.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all client managers without disconnecting.
|
||||||
|
*
|
||||||
|
* This is primarily for testing purposes.
|
||||||
|
*/
|
||||||
|
export function _clearAllClientManagersForTest(): void {
|
||||||
|
registry.clear();
|
||||||
|
}
|
||||||
82
extensions/twitch/src/config-schema.ts
Normal file
82
extensions/twitch/src/config-schema.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
import { MarkdownConfigSchema } from "clawdbot/plugin-sdk";
|
||||||
|
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"]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Twitch account configuration schema
|
||||||
|
*/
|
||||||
|
const TwitchAccountSchema = z.object({
|
||||||
|
/** 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(),
|
||||||
|
/** 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(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base configuration properties shared by both single and multi-account modes
|
||||||
|
*/
|
||||||
|
const TwitchConfigBaseSchema = z.object({
|
||||||
|
name: z.string().optional(),
|
||||||
|
enabled: z.boolean().optional(),
|
||||||
|
markdown: MarkdownConfigSchema.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.intersection(TwitchConfigBaseSchema, TwitchAccountSchema);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Multi-account configuration schema
|
||||||
|
*
|
||||||
|
* Use this for multi-account setups. Each key is an account ID (e.g., "default", "secondary").
|
||||||
|
*/
|
||||||
|
const MultiAccountSchema = z.intersection(
|
||||||
|
TwitchConfigBaseSchema,
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
/** 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]);
|
||||||
88
extensions/twitch/src/config.test.ts
Normal file
88
extensions/twitch/src/config.test.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { getAccountConfig } 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).not.toBeNull();
|
||||||
|
expect(result?.username).toBe("testbot");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns account config for default account (simplified config)", () => {
|
||||||
|
const result = getAccountConfig(mockSimplifiedConfig, "default");
|
||||||
|
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result?.username).toBe("testbot");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns non-default account from multi-account config", () => {
|
||||||
|
const result = getAccountConfig(mockMultiAccountConfig, "secondary");
|
||||||
|
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result?.username).toBe("secondbot");
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
116
extensions/twitch/src/config.ts
Normal file
116
extensions/twitch/src/config.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||||
|
import type { TwitchAccountConfig } from "./types.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default account ID for Twitch
|
||||||
|
*/
|
||||||
|
export const DEFAULT_ACCOUNT_ID = "default";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 ClawdbotConfig;
|
||||||
|
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 (accountId === DEFAULT_ACCOUNT_ID) {
|
||||||
|
const accountFromAccounts = accounts?.[DEFAULT_ACCOUNT_ID];
|
||||||
|
|
||||||
|
// 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
|
||||||
|
if (!accounts || !accounts[accountId]) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return accounts[accountId] as TwitchAccountConfig | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all configured account IDs
|
||||||
|
*
|
||||||
|
* Includes both explicit accounts and implicit "default" from base-level config
|
||||||
|
*/
|
||||||
|
export function listAccountIds(cfg: ClawdbotConfig): 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;
|
||||||
|
|
||||||
|
const ids: string[] = [];
|
||||||
|
|
||||||
|
// Add explicit accounts
|
||||||
|
if (accountMap) {
|
||||||
|
ids.push(...Object.keys(accountMap));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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");
|
||||||
|
|
||||||
|
if (hasBaseLevelConfig && !ids.includes(DEFAULT_ACCOUNT_ID)) {
|
||||||
|
ids.push(DEFAULT_ACCOUNT_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
257
extensions/twitch/src/monitor.ts
Normal file
257
extensions/twitch/src/monitor.ts
Normal file
|
|
@ -0,0 +1,257 @@
|
||||||
|
/**
|
||||||
|
* 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 { ReplyPayload, ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||||
|
import type { TwitchAccountConfig, TwitchChatMessage } from "./types.js";
|
||||||
|
import { checkTwitchAccessControl } from "./access-control.js";
|
||||||
|
import { getTwitchRuntime } from "./runtime.js";
|
||||||
|
import { getOrCreateClientManager } from "./client-manager-registry.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; // ClawdbotConfig
|
||||||
|
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 ClawdbotConfig;
|
||||||
|
|
||||||
|
const route = core.channel.routing.resolveAgentRoute({
|
||||||
|
cfg,
|
||||||
|
channel: "twitch",
|
||||||
|
accountId,
|
||||||
|
peer: {
|
||||||
|
kind: "group", // Twitch chat is always group-like
|
||||||
|
id: message.channel,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const rawBody = message.message;
|
||||||
|
const body = core.channel.reply.formatAgentEnvelope({
|
||||||
|
channel: "Twitch",
|
||||||
|
from: message.displayName ?? message.username,
|
||||||
|
timestamp: message.timestamp?.getTime(),
|
||||||
|
envelope: core.channel.reply.resolveEnvelopeFormatOptions(cfg),
|
||||||
|
body: rawBody,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
||||||
|
Body: body,
|
||||||
|
RawBody: rawBody,
|
||||||
|
CommandBody: rawBody,
|
||||||
|
From: `twitch:user:${message.userId}`,
|
||||||
|
To: `twitch:channel:${message.channel}`,
|
||||||
|
SessionKey: route.sessionKey,
|
||||||
|
AccountId: route.accountId,
|
||||||
|
ChatType: "group",
|
||||||
|
ConversationLabel: message.channel,
|
||||||
|
SenderName: message.displayName ?? message.username,
|
||||||
|
SenderId: message.userId,
|
||||||
|
SenderUsername: message.username,
|
||||||
|
Provider: "twitch",
|
||||||
|
Surface: "twitch",
|
||||||
|
MessageSid: message.id,
|
||||||
|
OriginatingChannel: "twitch",
|
||||||
|
OriginatingTo: `twitch:channel:${message.channel}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
|
||||||
|
agentId: route.agentId,
|
||||||
|
});
|
||||||
|
await core.channel.session.recordInboundSession({
|
||||||
|
storePath,
|
||||||
|
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
||||||
|
ctx: ctxPayload,
|
||||||
|
onRecordError: (err) => {
|
||||||
|
runtime.error?.(`Failed updating session meta: ${String(err)}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
||||||
|
cfg,
|
||||||
|
channel: "twitch",
|
||||||
|
accountId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
||||||
|
ctx: ctxPayload,
|
||||||
|
cfg,
|
||||||
|
dispatcherOptions: {
|
||||||
|
deliver: async (payload) => {
|
||||||
|
await deliverTwitchReply({
|
||||||
|
payload,
|
||||||
|
channel: message.channel,
|
||||||
|
account,
|
||||||
|
accountId,
|
||||||
|
config,
|
||||||
|
tableMode,
|
||||||
|
runtime,
|
||||||
|
statusSink,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deliver a reply to Twitch chat.
|
||||||
|
*/
|
||||||
|
async function deliverTwitchReply(params: {
|
||||||
|
payload: ReplyPayload;
|
||||||
|
channel: string;
|
||||||
|
account: TwitchAccountConfig;
|
||||||
|
accountId: string;
|
||||||
|
config: unknown;
|
||||||
|
tableMode: "off" | "plain" | "markdown" | "bullets" | "code";
|
||||||
|
runtime: TwitchRuntimeEnv;
|
||||||
|
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
||||||
|
}): Promise<void> {
|
||||||
|
const { payload, channel, account, accountId, config, tableMode, runtime, statusSink } = 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send the reply
|
||||||
|
if (!payload.text) {
|
||||||
|
runtime.error?.(`No text to send in reply payload`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const textToSend = stripMarkdownForTwitch(payload.text);
|
||||||
|
|
||||||
|
await client.say(channel, textToSend);
|
||||||
|
statusSink?.({ lastOutboundAt: Date.now() });
|
||||||
|
} catch (err) {
|
||||||
|
runtime.error?.(`Failed to send reply: ${String(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 = error instanceof Error ? error.message : String(error);
|
||||||
|
runtime.error?.(`Failed to connect: ${errorMsg}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const unregisterHandler = clientManager.onMessage(account, (message) => {
|
||||||
|
if (stopped) return;
|
||||||
|
|
||||||
|
// Access control check
|
||||||
|
const botUsername = account.username.toLowerCase();
|
||||||
|
if (message.username.toLowerCase() === botUsername) {
|
||||||
|
return; // Ignore own messages
|
||||||
|
}
|
||||||
|
|
||||||
|
const access = checkTwitchAccessControl({
|
||||||
|
message,
|
||||||
|
account,
|
||||||
|
botUsername,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!access.allowed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
statusSink?.({ lastInboundAt: Date.now() });
|
||||||
|
|
||||||
|
// Fire-and-forget: process message without blocking
|
||||||
|
void processTwitchMessage({
|
||||||
|
message,
|
||||||
|
account,
|
||||||
|
accountId,
|
||||||
|
config,
|
||||||
|
runtime,
|
||||||
|
core,
|
||||||
|
statusSink,
|
||||||
|
}).catch((err) => {
|
||||||
|
runtime.error?.(`Message processing failed: ${String(err)}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const stop = () => {
|
||||||
|
stopped = true;
|
||||||
|
unregisterHandler();
|
||||||
|
};
|
||||||
|
|
||||||
|
abortSignal.addEventListener("abort", stop, { once: true });
|
||||||
|
|
||||||
|
return { stop };
|
||||||
|
}
|
||||||
311
extensions/twitch/src/onboarding.test.ts
Normal file
311
extensions/twitch/src/onboarding.test.ts
Normal file
|
|
@ -0,0 +1,311 @@
|
||||||
|
/**
|
||||||
|
* Tests for onboarding.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 "clawdbot/plugin-sdk";
|
||||||
|
import type { TwitchAccountConfig } from "./types.js";
|
||||||
|
|
||||||
|
// Mock the helpers we're testing
|
||||||
|
const mockPromptText = vi.fn();
|
||||||
|
const mockPromptConfirm = vi.fn();
|
||||||
|
const mockPrompter: WizardPrompter = {
|
||||||
|
text: mockPromptText,
|
||||||
|
confirm: mockPromptConfirm,
|
||||||
|
} as unknown as WizardPrompter;
|
||||||
|
|
||||||
|
const mockAccount: TwitchAccountConfig = {
|
||||||
|
username: "testbot",
|
||||||
|
accessToken: "oauth:test123",
|
||||||
|
clientId: "test-client-id",
|
||||||
|
channel: "#testchannel",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("onboarding helpers", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// Don't restoreAllMocks as it breaks module-level mocks
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("promptToken", () => {
|
||||||
|
it("should return existing token when user confirms to keep it", async () => {
|
||||||
|
const { promptToken } = await import("./onboarding.js");
|
||||||
|
|
||||||
|
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 prompt for new token when user doesn't keep existing", async () => {
|
||||||
|
const { promptToken } = await import("./onboarding.js");
|
||||||
|
|
||||||
|
mockPromptConfirm.mockResolvedValue(false);
|
||||||
|
mockPromptText.mockResolvedValue("oauth:newtoken123");
|
||||||
|
|
||||||
|
const result = await promptToken(mockPrompter, mockAccount, undefined);
|
||||||
|
|
||||||
|
expect(result).toBe("oauth:newtoken123");
|
||||||
|
expect(mockPromptText).toHaveBeenCalledWith({
|
||||||
|
message: "Twitch OAuth token (oauth:...)",
|
||||||
|
initialValue: "",
|
||||||
|
validate: expect.any(Function),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should use env token as initial value when provided", async () => {
|
||||||
|
const { promptToken } = await import("./onboarding.js");
|
||||||
|
|
||||||
|
mockPromptConfirm.mockResolvedValue(false);
|
||||||
|
mockPromptText.mockResolvedValue("oauth:fromenv");
|
||||||
|
|
||||||
|
await promptToken(mockPrompter, null, "oauth:fromenv");
|
||||||
|
|
||||||
|
expect(mockPromptText).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
initialValue: "oauth:fromenv",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should validate token format", async () => {
|
||||||
|
const { promptToken } = await import("./onboarding.js");
|
||||||
|
|
||||||
|
// 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
|
||||||
|
expect(capturedValidate).toBeDefined();
|
||||||
|
expect(capturedValidate!("")).toBe("Required");
|
||||||
|
expect(capturedValidate!("notoauth")).toBe("Token should start with 'oauth:'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return early when no existing token and no env token", async () => {
|
||||||
|
const { promptToken } = await import("./onboarding.js");
|
||||||
|
|
||||||
|
mockPromptText.mockResolvedValue("oauth:newtoken");
|
||||||
|
|
||||||
|
const result = await promptToken(mockPrompter, null, undefined);
|
||||||
|
|
||||||
|
expect(result).toBe("oauth:newtoken");
|
||||||
|
expect(mockPromptConfirm).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("promptUsername", () => {
|
||||||
|
it("should prompt for username with validation", async () => {
|
||||||
|
const { promptUsername } = await import("./onboarding.js");
|
||||||
|
|
||||||
|
mockPromptText.mockResolvedValue("mybot");
|
||||||
|
|
||||||
|
const result = await promptUsername(mockPrompter, null);
|
||||||
|
|
||||||
|
expect(result).toBe("mybot");
|
||||||
|
expect(mockPromptText).toHaveBeenCalledWith({
|
||||||
|
message: "Twitch bot username",
|
||||||
|
initialValue: "",
|
||||||
|
validate: expect.any(Function),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should use existing username as initial value", async () => {
|
||||||
|
const { promptUsername } = await import("./onboarding.js");
|
||||||
|
|
||||||
|
mockPromptText.mockResolvedValue("testbot");
|
||||||
|
|
||||||
|
await promptUsername(mockPrompter, mockAccount);
|
||||||
|
|
||||||
|
expect(mockPromptText).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
initialValue: "testbot",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("promptClientId", () => {
|
||||||
|
it("should prompt for client ID with validation", async () => {
|
||||||
|
const { promptClientId } = await import("./onboarding.js");
|
||||||
|
|
||||||
|
mockPromptText.mockResolvedValue("abc123xyz");
|
||||||
|
|
||||||
|
const result = await promptClientId(mockPrompter, null);
|
||||||
|
|
||||||
|
expect(result).toBe("abc123xyz");
|
||||||
|
expect(mockPromptText).toHaveBeenCalledWith({
|
||||||
|
message: "Twitch Client ID",
|
||||||
|
initialValue: "",
|
||||||
|
validate: expect.any(Function),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("promptChannelName", () => {
|
||||||
|
it("should return channel name when provided", async () => {
|
||||||
|
const { promptChannelName } = await import("./onboarding.js");
|
||||||
|
|
||||||
|
mockPromptText.mockResolvedValue("#mychannel");
|
||||||
|
|
||||||
|
const result = await promptChannelName(mockPrompter, null);
|
||||||
|
|
||||||
|
expect(result).toBe("#mychannel");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should require a non-empty channel name", async () => {
|
||||||
|
const { promptChannelName } = await import("./onboarding.js");
|
||||||
|
|
||||||
|
mockPromptText.mockResolvedValue("");
|
||||||
|
|
||||||
|
await promptChannelName(mockPrompter, null);
|
||||||
|
|
||||||
|
const { validate } = mockPromptText.mock.calls[0]?.[0] ?? {};
|
||||||
|
expect(validate?.("")).toBe("Required");
|
||||||
|
expect(validate?.(" ")).toBe("Required");
|
||||||
|
expect(validate?.("#chan")).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("promptRefreshTokenSetup", () => {
|
||||||
|
it("should return empty object when user declines", async () => {
|
||||||
|
const { promptRefreshTokenSetup } = await import("./onboarding.js");
|
||||||
|
|
||||||
|
mockPromptConfirm.mockResolvedValue(false);
|
||||||
|
|
||||||
|
const result = await promptRefreshTokenSetup(mockPrompter, mockAccount);
|
||||||
|
|
||||||
|
expect(result).toEqual({});
|
||||||
|
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 () => {
|
||||||
|
const { promptRefreshTokenSetup } = await import("./onboarding.js");
|
||||||
|
|
||||||
|
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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should use existing values as initial prompts", async () => {
|
||||||
|
const { promptRefreshTokenSetup } = await import("./onboarding.js");
|
||||||
|
|
||||||
|
const accountWithRefresh = {
|
||||||
|
...mockAccount,
|
||||||
|
clientSecret: "existing-secret",
|
||||||
|
refreshToken: "existing-refresh",
|
||||||
|
};
|
||||||
|
|
||||||
|
mockPromptConfirm.mockResolvedValue(true);
|
||||||
|
mockPromptText
|
||||||
|
.mockResolvedValueOnce("existing-secret")
|
||||||
|
.mockResolvedValueOnce("existing-refresh");
|
||||||
|
|
||||||
|
await promptRefreshTokenSetup(mockPrompter, accountWithRefresh);
|
||||||
|
|
||||||
|
expect(mockPromptConfirm).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
initialValue: true, // Both clientSecret and refreshToken exist
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("configureWithEnvToken", () => {
|
||||||
|
it("should return null when user declines env token", async () => {
|
||||||
|
const { configureWithEnvToken } = await import("./onboarding.js");
|
||||||
|
|
||||||
|
// Reset and set up mock - user declines env token
|
||||||
|
mockPromptConfirm.mockReset().mockResolvedValue(false as never);
|
||||||
|
|
||||||
|
const result = await configureWithEnvToken(
|
||||||
|
{} as Parameters<typeof configureWithEnvToken>[0],
|
||||||
|
mockPrompter,
|
||||||
|
null,
|
||||||
|
"oauth:fromenv",
|
||||||
|
false,
|
||||||
|
{} as Parameters<typeof configureWithEnvToken>[5],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Since user declined, should return null without prompting for username/clientId
|
||||||
|
expect(result).toBeNull();
|
||||||
|
expect(mockPromptText).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should prompt for username and clientId when using env token", async () => {
|
||||||
|
const { configureWithEnvToken } = await import("./onboarding.js");
|
||||||
|
|
||||||
|
// 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
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result?.cfg.channels?.twitch?.accounts?.default?.username).toBe("testbot");
|
||||||
|
expect(result?.cfg.channels?.twitch?.accounts?.default?.clientId).toBe("test-client-id");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
411
extensions/twitch/src/onboarding.ts
Normal file
411
extensions/twitch/src/onboarding.ts
Normal file
|
|
@ -0,0 +1,411 @@
|
||||||
|
/**
|
||||||
|
* Twitch onboarding adapter for CLI setup wizard.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
formatDocsLink,
|
||||||
|
promptChannelAccessConfig,
|
||||||
|
type ChannelOnboardingAdapter,
|
||||||
|
type ChannelOnboardingDmPolicy,
|
||||||
|
type WizardPrompter,
|
||||||
|
} from "clawdbot/plugin-sdk";
|
||||||
|
import { DEFAULT_ACCOUNT_ID, getAccountConfig } from "./config.js";
|
||||||
|
import { isAccountConfigured } from "./utils/twitch.js";
|
||||||
|
import type { TwitchAccountConfig, TwitchRole } from "./types.js";
|
||||||
|
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
|
const channel = "twitch" as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set Twitch account configuration
|
||||||
|
*/
|
||||||
|
function setTwitchAccount(
|
||||||
|
cfg: ClawdbotConfig,
|
||||||
|
account: Partial<TwitchAccountConfig>,
|
||||||
|
): ClawdbotConfig {
|
||||||
|
const existing = getAccountConfig(cfg, DEFAULT_ACCOUNT_ID);
|
||||||
|
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),
|
||||||
|
[DEFAULT_ACCOUNT_ID]: merged,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Note about Twitch setup
|
||||||
|
*/
|
||||||
|
async function noteTwitchSetupHelp(prompter: WizardPrompter): Promise<void> {
|
||||||
|
await prompter.note(
|
||||||
|
[
|
||||||
|
"Twitch requires a bot account with OAuth token.",
|
||||||
|
"1. Create a Twitch application at https://dev.twitch.tv/console",
|
||||||
|
"2. Generate a token with scopes: chat:read and chat:write",
|
||||||
|
" Use https://twitchtokengenerator.com/ or https://twitchapps.com/tmi/",
|
||||||
|
"3. Copy the token (starts with 'oauth:') and Client ID",
|
||||||
|
"Env vars supported: CLAWDBOT_TWITCH_ACCESS_TOKEN",
|
||||||
|
`Docs: ${formatDocsLink("/channels/twitch", "channels/twitch")}`,
|
||||||
|
].join("\n"),
|
||||||
|
"Twitch setup",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prompt for Twitch OAuth token with early returns.
|
||||||
|
*/
|
||||||
|
async function promptToken(
|
||||||
|
prompter: WizardPrompter,
|
||||||
|
account: TwitchAccountConfig | null,
|
||||||
|
envToken: string | undefined,
|
||||||
|
): Promise<string> {
|
||||||
|
const existingToken = account?.accessToken ?? "";
|
||||||
|
|
||||||
|
// If we have an existing token and no env var, ask if we should keep it
|
||||||
|
if (existingToken && !envToken) {
|
||||||
|
const keepToken = await prompter.confirm({
|
||||||
|
message: "Access token already configured. Keep it?",
|
||||||
|
initialValue: true,
|
||||||
|
});
|
||||||
|
if (keepToken) {
|
||||||
|
return existingToken;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prompt for new token
|
||||||
|
return String(
|
||||||
|
await prompter.text({
|
||||||
|
message: "Twitch OAuth token (oauth:...)",
|
||||||
|
initialValue: envToken ?? "",
|
||||||
|
validate: (value) => {
|
||||||
|
const raw = String(value ?? "").trim();
|
||||||
|
if (!raw) return "Required";
|
||||||
|
if (!raw.startsWith("oauth:")) {
|
||||||
|
return "Token should start with 'oauth:'";
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prompt for Twitch username.
|
||||||
|
*/
|
||||||
|
async function promptUsername(
|
||||||
|
prompter: WizardPrompter,
|
||||||
|
account: TwitchAccountConfig | null,
|
||||||
|
): Promise<string> {
|
||||||
|
return String(
|
||||||
|
await prompter.text({
|
||||||
|
message: "Twitch bot username",
|
||||||
|
initialValue: account?.username ?? "",
|
||||||
|
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||||
|
}),
|
||||||
|
).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prompt for Twitch Client ID.
|
||||||
|
*/
|
||||||
|
async function promptClientId(
|
||||||
|
prompter: WizardPrompter,
|
||||||
|
account: TwitchAccountConfig | null,
|
||||||
|
): Promise<string> {
|
||||||
|
return String(
|
||||||
|
await prompter.text({
|
||||||
|
message: "Twitch Client ID",
|
||||||
|
initialValue: account?.clientId ?? "",
|
||||||
|
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||||
|
}),
|
||||||
|
).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prompt for optional channel name.
|
||||||
|
*/
|
||||||
|
async function promptChannelName(
|
||||||
|
prompter: WizardPrompter,
|
||||||
|
account: TwitchAccountConfig | null,
|
||||||
|
): Promise<string> {
|
||||||
|
const channelName = String(
|
||||||
|
await prompter.text({
|
||||||
|
message: "Channel to join",
|
||||||
|
initialValue: account?.channel ?? "",
|
||||||
|
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||||
|
}),
|
||||||
|
).trim();
|
||||||
|
return channelName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prompt for token refresh credentials (client secret and refresh token).
|
||||||
|
*/
|
||||||
|
async function promptRefreshTokenSetup(
|
||||||
|
prompter: WizardPrompter,
|
||||||
|
account: TwitchAccountConfig | null,
|
||||||
|
): Promise<{ clientSecret?: string; refreshToken?: string }> {
|
||||||
|
const useRefresh = await prompter.confirm({
|
||||||
|
message: "Enable automatic token refresh (requires client secret and refresh token)?",
|
||||||
|
initialValue: Boolean(account?.clientSecret && account?.refreshToken),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!useRefresh) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientSecret =
|
||||||
|
String(
|
||||||
|
await prompter.text({
|
||||||
|
message: "Twitch Client Secret (for token refresh)",
|
||||||
|
initialValue: account?.clientSecret ?? "",
|
||||||
|
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||||
|
}),
|
||||||
|
).trim() || undefined;
|
||||||
|
|
||||||
|
const refreshToken =
|
||||||
|
String(
|
||||||
|
await prompter.text({
|
||||||
|
message: "Twitch Refresh Token",
|
||||||
|
initialValue: account?.refreshToken ?? "",
|
||||||
|
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||||
|
}),
|
||||||
|
).trim() || undefined;
|
||||||
|
|
||||||
|
return { clientSecret, refreshToken };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure with env token path (returns early if user chooses env token).
|
||||||
|
*/
|
||||||
|
async function configureWithEnvToken(
|
||||||
|
cfg: ClawdbotConfig,
|
||||||
|
prompter: WizardPrompter,
|
||||||
|
account: TwitchAccountConfig | null,
|
||||||
|
envToken: string,
|
||||||
|
forceAllowFrom: boolean,
|
||||||
|
dmPolicy: ChannelOnboardingDmPolicy,
|
||||||
|
): Promise<{ cfg: ClawdbotConfig } | null> {
|
||||||
|
const useEnv = await prompter.confirm({
|
||||||
|
message: "Twitch env var CLAWDBOT_TWITCH_ACCESS_TOKEN detected. Use env token?",
|
||||||
|
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: "", // Will use env var
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (forceAllowFrom && dmPolicy.promptAllowFrom) {
|
||||||
|
return { cfg: await dmPolicy.promptAllowFrom({ cfg: cfgWithAccount, prompter }) };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { cfg: cfgWithAccount };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set Twitch access control (role-based)
|
||||||
|
*/
|
||||||
|
function setTwitchAccessControl(
|
||||||
|
cfg: ClawdbotConfig,
|
||||||
|
allowedRoles: TwitchRole[],
|
||||||
|
requireMention: boolean,
|
||||||
|
): ClawdbotConfig {
|
||||||
|
const account = getAccountConfig(cfg, DEFAULT_ACCOUNT_ID);
|
||||||
|
if (!account) {
|
||||||
|
return cfg;
|
||||||
|
}
|
||||||
|
|
||||||
|
return setTwitchAccount(cfg, {
|
||||||
|
...account,
|
||||||
|
allowedRoles,
|
||||||
|
requireMention,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const dmPolicy: ChannelOnboardingDmPolicy = {
|
||||||
|
label: "Twitch",
|
||||||
|
channel,
|
||||||
|
policyKey: "channels.twitch.allowedRoles", // Twitch uses roles instead of DM policy
|
||||||
|
allowFromKey: "channels.twitch.accounts.default.allowFrom",
|
||||||
|
getCurrent: (cfg) => {
|
||||||
|
const account = getAccountConfig(cfg, DEFAULT_ACCOUNT_ID);
|
||||||
|
// Map allowedRoles to policy equivalent
|
||||||
|
if (account?.allowedRoles?.includes("all")) return "open";
|
||||||
|
if (account?.allowFrom && account.allowFrom.length > 0) return "allowlist";
|
||||||
|
return "disabled";
|
||||||
|
},
|
||||||
|
setPolicy: (cfg, policy) => {
|
||||||
|
const allowedRoles: TwitchRole[] =
|
||||||
|
policy === "open" ? ["all"] : policy === "allowlist" ? [] : ["moderator"];
|
||||||
|
return setTwitchAccessControl(cfg as ClawdbotConfig, allowedRoles, true);
|
||||||
|
},
|
||||||
|
promptAllowFrom: async ({ cfg, prompter }) => {
|
||||||
|
const account = getAccountConfig(cfg, DEFAULT_ACCOUNT_ID);
|
||||||
|
const existingAllowFrom = account?.allowFrom ?? [];
|
||||||
|
|
||||||
|
const entry = await prompter.text({
|
||||||
|
message: "Twitch allowFrom (user IDs, one per line, recommended for security)",
|
||||||
|
placeholder: "123456789",
|
||||||
|
initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const allowFrom = String(entry ?? "")
|
||||||
|
.split(/[\n,;]+/g)
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
return setTwitchAccount(cfg as ClawdbotConfig, {
|
||||||
|
...(account ?? undefined),
|
||||||
|
allowFrom,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const twitchOnboardingAdapter: ChannelOnboardingAdapter = {
|
||||||
|
channel,
|
||||||
|
getStatus: async ({ cfg }) => {
|
||||||
|
const account = getAccountConfig(cfg, DEFAULT_ACCOUNT_ID);
|
||||||
|
const configured = account ? isAccountConfigured(account) : false;
|
||||||
|
|
||||||
|
return {
|
||||||
|
channel,
|
||||||
|
configured,
|
||||||
|
statusLines: [`Twitch: ${configured ? "configured" : "needs username, token, and clientId"}`],
|
||||||
|
selectionHint: configured ? "configured" : "needs setup",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
configure: async ({ cfg, prompter, forceAllowFrom }) => {
|
||||||
|
const account = getAccountConfig(cfg, DEFAULT_ACCOUNT_ID);
|
||||||
|
|
||||||
|
if (!account || !isAccountConfigured(account)) {
|
||||||
|
await noteTwitchSetupHelp(prompter);
|
||||||
|
}
|
||||||
|
|
||||||
|
const envToken = process.env.CLAWDBOT_TWITCH_ACCESS_TOKEN?.trim();
|
||||||
|
|
||||||
|
// Check if env var is set and config is empty
|
||||||
|
if (envToken && !account?.accessToken) {
|
||||||
|
const envResult = await configureWithEnvToken(
|
||||||
|
cfg,
|
||||||
|
prompter,
|
||||||
|
account,
|
||||||
|
envToken,
|
||||||
|
forceAllowFrom,
|
||||||
|
dmPolicy,
|
||||||
|
);
|
||||||
|
if (envResult) {
|
||||||
|
return envResult;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prompt for credentials
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
|
||||||
|
const cfgWithAllowFrom =
|
||||||
|
forceAllowFrom && dmPolicy.promptAllowFrom
|
||||||
|
? await dmPolicy.promptAllowFrom({ cfg: cfgWithAccount, prompter })
|
||||||
|
: cfgWithAccount;
|
||||||
|
|
||||||
|
// Prompt for access control if allowFrom not set
|
||||||
|
if (!account?.allowFrom || account.allowFrom.length === 0) {
|
||||||
|
const accessConfig = await promptChannelAccessConfig({
|
||||||
|
prompter,
|
||||||
|
label: "Twitch chat",
|
||||||
|
currentPolicy: account?.allowedRoles?.includes("all")
|
||||||
|
? "open"
|
||||||
|
: account?.allowedRoles?.includes("moderator")
|
||||||
|
? "allowlist"
|
||||||
|
: "disabled",
|
||||||
|
currentEntries: [],
|
||||||
|
placeholder: "",
|
||||||
|
updatePrompt: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (accessConfig) {
|
||||||
|
const allowedRoles: TwitchRole[] =
|
||||||
|
accessConfig.policy === "open"
|
||||||
|
? ["all"]
|
||||||
|
: accessConfig.policy === "allowlist"
|
||||||
|
? ["moderator", "vip"]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const cfgWithAccessControl = setTwitchAccessControl(cfgWithAllowFrom, allowedRoles, true);
|
||||||
|
return { cfg: cfgWithAccessControl };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { cfg: cfgWithAllowFrom };
|
||||||
|
},
|
||||||
|
dmPolicy,
|
||||||
|
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 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Export helper functions for testing
|
||||||
|
export {
|
||||||
|
promptToken,
|
||||||
|
promptUsername,
|
||||||
|
promptClientId,
|
||||||
|
promptChannelName,
|
||||||
|
promptRefreshTokenSetup,
|
||||||
|
configureWithEnvToken,
|
||||||
|
};
|
||||||
373
extensions/twitch/src/outbound.test.ts
Normal file
373
extensions/twitch/src/outbound.test.ts
Normal file
|
|
@ -0,0 +1,373 @@
|
||||||
|
/**
|
||||||
|
* 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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { twitchOutbound } from "./outbound.js";
|
||||||
|
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
|
// Mock dependencies
|
||||||
|
vi.mock("./config.js", () => ({
|
||||||
|
DEFAULT_ACCOUNT_ID: "default",
|
||||||
|
getAccountConfig: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./send.js", () => ({
|
||||||
|
sendMessageTwitchInternal: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./utils/markdown.js", () => ({
|
||||||
|
chunkTextForTwitch: vi.fn((text) => text.split(/(.{500})/).filter(Boolean)),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./utils/twitch.js", () => ({
|
||||||
|
normalizeTwitchChannel: (channel: string) => channel.toLowerCase().replace(/^#/, ""),
|
||||||
|
missingTargetError: (channel: string, hint: string) =>
|
||||||
|
`Missing target for ${channel}. Provide ${hint}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("outbound", () => {
|
||||||
|
const mockAccount = {
|
||||||
|
username: "testbot",
|
||||||
|
token: "oauth:test123",
|
||||||
|
clientId: "test-client-id",
|
||||||
|
channel: "#testchannel",
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockConfig = {
|
||||||
|
channels: {
|
||||||
|
twitch: {
|
||||||
|
accounts: {
|
||||||
|
default: mockAccount,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as ClawdbotConfig;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
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 have chunker function", () => {
|
||||||
|
expect(twitchOutbound.chunker).toBeDefined();
|
||||||
|
expect(typeof twitchOutbound.chunker).toBe("function");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveTarget", () => {
|
||||||
|
it("should normalize and return target in explicit mode", () => {
|
||||||
|
const result = twitchOutbound.resolveTarget({
|
||||||
|
to: "#MyChannel",
|
||||||
|
mode: "explicit",
|
||||||
|
allowFrom: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
expect(result.to).toBe("mychannel");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return target in implicit mode with wildcard allowlist", () => {
|
||||||
|
const result = twitchOutbound.resolveTarget({
|
||||||
|
to: "#AnyChannel",
|
||||||
|
mode: "implicit",
|
||||||
|
allowFrom: ["*"],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
expect(result.to).toBe("anychannel");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return target in implicit mode when in allowlist", () => {
|
||||||
|
const result = twitchOutbound.resolveTarget({
|
||||||
|
to: "#allowed",
|
||||||
|
mode: "implicit",
|
||||||
|
allowFrom: ["#allowed", "#other"],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
expect(result.to).toBe("allowed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fallback to first allowlist entry when target not in list", () => {
|
||||||
|
const result = twitchOutbound.resolveTarget({
|
||||||
|
to: "#notallowed",
|
||||||
|
mode: "implicit",
|
||||||
|
allowFrom: ["#primary", "#secondary"],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
expect(result.to).toBe("primary");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should accept any target when allowlist is empty", () => {
|
||||||
|
const result = twitchOutbound.resolveTarget({
|
||||||
|
to: "#anychannel",
|
||||||
|
mode: "heartbeat",
|
||||||
|
allowFrom: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
expect(result.to).toBe("anychannel");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should use first allowlist entry when no target provided", () => {
|
||||||
|
const result = twitchOutbound.resolveTarget({
|
||||||
|
to: undefined,
|
||||||
|
mode: "implicit",
|
||||||
|
allowFrom: ["#fallback", "#other"],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
expect(result.to).toBe("fallback");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return error when no target and no allowlist", () => {
|
||||||
|
const result = twitchOutbound.resolveTarget({
|
||||||
|
to: undefined,
|
||||||
|
mode: "explicit",
|
||||||
|
allowFrom: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result.error).toContain("Missing target");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle whitespace-only target", () => {
|
||||||
|
const result = twitchOutbound.resolveTarget({
|
||||||
|
to: " ",
|
||||||
|
mode: "explicit",
|
||||||
|
allowFrom: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result.error).toContain("Missing target");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should filter wildcard from allowlist when checking membership", () => {
|
||||||
|
const result = twitchOutbound.resolveTarget({
|
||||||
|
to: "#mychannel",
|
||||||
|
mode: "implicit",
|
||||||
|
allowFrom: ["*", "#specific"],
|
||||||
|
});
|
||||||
|
|
||||||
|
// With wildcard, any target is accepted
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
expect(result.to).toBe("mychannel");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sendText", () => {
|
||||||
|
it("should send message successfully", async () => {
|
||||||
|
const { getAccountConfig } = await import("./config.js");
|
||||||
|
const { sendMessageTwitchInternal } = await import("./send.js");
|
||||||
|
|
||||||
|
vi.mocked(getAccountConfig).mockReturnValue(mockAccount);
|
||||||
|
vi.mocked(sendMessageTwitchInternal).mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
messageId: "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.to).toBe("testchannel");
|
||||||
|
expect(result.timestamp).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw when account not found", async () => {
|
||||||
|
const { getAccountConfig } = await import("./config.js");
|
||||||
|
|
||||||
|
vi.mocked(getAccountConfig).mockReturnValue(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 { getAccountConfig } = await import("./config.js");
|
||||||
|
|
||||||
|
const accountWithoutChannel = { ...mockAccount, channel: undefined as unknown as string };
|
||||||
|
vi.mocked(getAccountConfig).mockReturnValue(accountWithoutChannel);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
twitchOutbound.sendText({
|
||||||
|
cfg: mockConfig,
|
||||||
|
to: undefined,
|
||||||
|
text: "Hello!",
|
||||||
|
accountId: "default",
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("No channel specified");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should use account channel when target not provided", async () => {
|
||||||
|
const { getAccountConfig } = await import("./config.js");
|
||||||
|
const { sendMessageTwitchInternal } = await import("./send.js");
|
||||||
|
|
||||||
|
vi.mocked(getAccountConfig).mockReturnValue(mockAccount);
|
||||||
|
vi.mocked(sendMessageTwitchInternal).mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
messageId: "msg-456",
|
||||||
|
});
|
||||||
|
|
||||||
|
await twitchOutbound.sendText({
|
||||||
|
cfg: mockConfig,
|
||||||
|
to: undefined,
|
||||||
|
text: "Hello!",
|
||||||
|
accountId: "default",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sendMessageTwitchInternal).toHaveBeenCalledWith(
|
||||||
|
"testchannel",
|
||||||
|
"Hello!",
|
||||||
|
mockConfig,
|
||||||
|
"default",
|
||||||
|
true,
|
||||||
|
console,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle abort signal", async () => {
|
||||||
|
const abortController = new AbortController();
|
||||||
|
abortController.abort();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
twitchOutbound.sendText({
|
||||||
|
cfg: mockConfig,
|
||||||
|
to: "#testchannel",
|
||||||
|
text: "Hello!",
|
||||||
|
accountId: "default",
|
||||||
|
signal: abortController.signal,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("Outbound delivery aborted");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw on send failure", async () => {
|
||||||
|
const { getAccountConfig } = await import("./config.js");
|
||||||
|
const { sendMessageTwitchInternal } = await import("./send.js");
|
||||||
|
|
||||||
|
vi.mocked(getAccountConfig).mockReturnValue(mockAccount);
|
||||||
|
vi.mocked(sendMessageTwitchInternal).mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
messageId: "failed-msg",
|
||||||
|
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");
|
||||||
|
const { getAccountConfig } = await import("./config.js");
|
||||||
|
|
||||||
|
vi.mocked(getAccountConfig).mockReturnValue(mockAccount);
|
||||||
|
vi.mocked(sendMessageTwitchInternal).mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
messageId: "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(sendMessageTwitchInternal).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
"Check this: https://example.com/image.png",
|
||||||
|
expect.anything(),
|
||||||
|
expect.anything(),
|
||||||
|
expect.anything(),
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should send media URL only when no text", async () => {
|
||||||
|
const { sendMessageTwitchInternal } = await import("./send.js");
|
||||||
|
const { getAccountConfig } = await import("./config.js");
|
||||||
|
|
||||||
|
vi.mocked(getAccountConfig).mockReturnValue(mockAccount);
|
||||||
|
vi.mocked(sendMessageTwitchInternal).mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
messageId: "media-only-msg",
|
||||||
|
});
|
||||||
|
|
||||||
|
await twitchOutbound.sendMedia({
|
||||||
|
cfg: mockConfig,
|
||||||
|
to: "#testchannel",
|
||||||
|
text: undefined,
|
||||||
|
mediaUrl: "https://example.com/image.png",
|
||||||
|
accountId: "default",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sendMessageTwitchInternal).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
"https://example.com/image.png",
|
||||||
|
expect.anything(),
|
||||||
|
expect.anything(),
|
||||||
|
expect.anything(),
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle abort signal", async () => {
|
||||||
|
const abortController = new AbortController();
|
||||||
|
abortController.abort();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
twitchOutbound.sendMedia({
|
||||||
|
cfg: mockConfig,
|
||||||
|
to: "#testchannel",
|
||||||
|
text: "Check this:",
|
||||||
|
mediaUrl: "https://example.com/image.png",
|
||||||
|
accountId: "default",
|
||||||
|
signal: abortController.signal,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("Outbound delivery aborted");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
186
extensions/twitch/src/outbound.ts
Normal file
186
extensions/twitch/src/outbound.ts
Normal file
|
|
@ -0,0 +1,186 @@
|
||||||
|
/**
|
||||||
|
* 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 { DEFAULT_ACCOUNT_ID, getAccountConfig } 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",
|
||||||
|
|
||||||
|
/** 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 = (allowFrom ?? [])
|
||||||
|
.map((entry: unknown) => String(entry).trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
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);
|
||||||
|
|
||||||
|
// 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 };
|
||||||
|
}
|
||||||
|
// Fallback to first allowFrom entry
|
||||||
|
// biome-ignore lint/style/noNonNullAssertion: length > 0 check ensures element exists
|
||||||
|
return { ok: true, to: allowList[0]! };
|
||||||
|
}
|
||||||
|
|
||||||
|
// For explicit mode, accept any valid channel name
|
||||||
|
return { ok: true, to: normalizedTo };
|
||||||
|
}
|
||||||
|
|
||||||
|
// No target provided, use allowFrom fallback
|
||||||
|
if (allowList.length > 0) {
|
||||||
|
// biome-ignore lint/style/noNonNullAssertion: length > 0 check ensures element exists
|
||||||
|
return { ok: true, to: allowList[0]! };
|
||||||
|
}
|
||||||
|
|
||||||
|
// No target and no allowFrom - error
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: missingTargetError(
|
||||||
|
"Twitch",
|
||||||
|
"<channel-name> or channels.twitch.accounts.<account>.allowFrom[0]",
|
||||||
|
),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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: clawdbotConfig,
|
||||||
|
* to: "#mychannel",
|
||||||
|
* text: "Hello Twitch!",
|
||||||
|
* accountId: "default",
|
||||||
|
* });
|
||||||
|
*/
|
||||||
|
sendText: async (params: ChannelOutboundContext): Promise<OutboundDeliveryResult> => {
|
||||||
|
const { cfg, to, text, accountId, signal } = params;
|
||||||
|
|
||||||
|
if (signal?.aborted) {
|
||||||
|
throw new Error("Outbound delivery aborted");
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedAccountId = accountId ?? DEFAULT_ACCOUNT_ID;
|
||||||
|
const account = getAccountConfig(cfg, resolvedAccountId);
|
||||||
|
if (!account) {
|
||||||
|
const availableIds = Object.keys(cfg.channels?.twitch?.accounts ?? {});
|
||||||
|
throw new Error(
|
||||||
|
`Twitch account not found: ${resolvedAccountId}. ` +
|
||||||
|
`Available accounts: ${availableIds.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,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
to: normalizeTwitchChannel(channel),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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: clawdbotConfig,
|
||||||
|
* to: "#mychannel",
|
||||||
|
* text: "Check this out!",
|
||||||
|
* mediaUrl: "https://example.com/image.png",
|
||||||
|
* accountId: "default",
|
||||||
|
* });
|
||||||
|
*/
|
||||||
|
sendMedia: async (params: ChannelOutboundContext): Promise<OutboundDeliveryResult> => {
|
||||||
|
const { text, mediaUrl, signal } = params;
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
39
extensions/twitch/src/plugin.test.ts
Normal file
39
extensions/twitch/src/plugin.test.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||||
|
import { twitchPlugin } from "./plugin.js";
|
||||||
|
|
||||||
|
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 ClawdbotConfig;
|
||||||
|
|
||||||
|
const snapshot = await twitchPlugin.status?.buildAccountSnapshot?.({
|
||||||
|
account: secondary,
|
||||||
|
cfg,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(snapshot?.accountId).toBe("secondary");
|
||||||
|
});
|
||||||
|
});
|
||||||
274
extensions/twitch/src/plugin.ts
Normal file
274
extensions/twitch/src/plugin.ts
Normal file
|
|
@ -0,0 +1,274 @@
|
||||||
|
/**
|
||||||
|
* Twitch channel plugin for Clawdbot.
|
||||||
|
*
|
||||||
|
* Main plugin export combining all adapters (outbound, actions, status, gateway).
|
||||||
|
* This is the primary entry point for the Twitch channel integration.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||||
|
import { buildChannelConfigSchema } from "clawdbot/plugin-sdk";
|
||||||
|
import { twitchMessageActions } from "./actions.js";
|
||||||
|
import { TwitchConfigSchema } from "./config-schema.js";
|
||||||
|
import { DEFAULT_ACCOUNT_ID, getAccountConfig, listAccountIds } from "./config.js";
|
||||||
|
import { twitchOnboardingAdapter } from "./onboarding.js";
|
||||||
|
import { twitchOutbound } from "./outbound.js";
|
||||||
|
import { probeTwitch } from "./probe.js";
|
||||||
|
import { resolveTwitchTargets } from "./resolver.js";
|
||||||
|
import { collectTwitchStatusIssues } from "./status.js";
|
||||||
|
import { removeClientManager } from "./client-manager-registry.js";
|
||||||
|
import { resolveTwitchToken } from "./token.js";
|
||||||
|
import { isAccountConfigured } from "./utils/twitch.js";
|
||||||
|
import type {
|
||||||
|
ChannelAccountSnapshot,
|
||||||
|
ChannelCapabilities,
|
||||||
|
ChannelLogSink,
|
||||||
|
ChannelMeta,
|
||||||
|
ChannelPlugin,
|
||||||
|
ChannelResolveKind,
|
||||||
|
ChannelResolveResult,
|
||||||
|
TwitchAccountConfig,
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Twitch channel plugin.
|
||||||
|
*
|
||||||
|
* Implements the ChannelPlugin interface to provide Twitch chat integration
|
||||||
|
* for Clawdbot. Supports message sending, receiving, access control, and
|
||||||
|
* status monitoring.
|
||||||
|
*/
|
||||||
|
export const twitchPlugin: ChannelPlugin<TwitchAccountConfig> = {
|
||||||
|
/** Plugin identifier */
|
||||||
|
id: "twitch",
|
||||||
|
|
||||||
|
/** Plugin metadata */
|
||||||
|
meta: {
|
||||||
|
id: "twitch",
|
||||||
|
label: "Twitch",
|
||||||
|
selectionLabel: "Twitch (Chat)",
|
||||||
|
docsPath: "/channels/twitch",
|
||||||
|
blurb: "Twitch chat integration",
|
||||||
|
aliases: ["twitch-chat"],
|
||||||
|
} satisfies ChannelMeta,
|
||||||
|
|
||||||
|
/** Onboarding adapter */
|
||||||
|
onboarding: twitchOnboardingAdapter,
|
||||||
|
|
||||||
|
/** Pairing configuration */
|
||||||
|
pairing: {
|
||||||
|
idLabel: "twitchUserId",
|
||||||
|
normalizeAllowEntry: (entry) => entry.replace(/^(twitch:)?user:?/i, ""),
|
||||||
|
notifyApproval: async ({ id }) => {
|
||||||
|
// Note: Twitch doesn't support DMs from bots, so pairing approval is limited
|
||||||
|
// We'll log the approval instead
|
||||||
|
console.warn(`Pairing approved for user ${id} (notification sent via chat if possible)`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Supported chat capabilities */
|
||||||
|
capabilities: {
|
||||||
|
chatTypes: ["group"],
|
||||||
|
} satisfies ChannelCapabilities,
|
||||||
|
|
||||||
|
/** Configuration schema for Twitch channel */
|
||||||
|
configSchema: buildChannelConfigSchema(TwitchConfigSchema),
|
||||||
|
|
||||||
|
/** Account configuration management */
|
||||||
|
config: {
|
||||||
|
/** List all configured account IDs */
|
||||||
|
listAccountIds: (cfg: ClawdbotConfig): string[] => listAccountIds(cfg),
|
||||||
|
|
||||||
|
/** Resolve an account config by ID */
|
||||||
|
resolveAccount: (cfg: ClawdbotConfig, accountId?: string | null): TwitchAccountConfig => {
|
||||||
|
const account = getAccountConfig(cfg, accountId ?? DEFAULT_ACCOUNT_ID);
|
||||||
|
if (!account) {
|
||||||
|
// Return a default/empty account if not configured
|
||||||
|
return {
|
||||||
|
username: "",
|
||||||
|
accessToken: "",
|
||||||
|
clientId: "",
|
||||||
|
enabled: false,
|
||||||
|
} as TwitchAccountConfig;
|
||||||
|
}
|
||||||
|
return account;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Get the default account ID */
|
||||||
|
defaultAccountId: (): string => DEFAULT_ACCOUNT_ID,
|
||||||
|
|
||||||
|
/** Check if an account is configured */
|
||||||
|
isConfigured: (_account: unknown, cfg: ClawdbotConfig): boolean => {
|
||||||
|
const account = getAccountConfig(cfg, DEFAULT_ACCOUNT_ID);
|
||||||
|
const tokenResolution = resolveTwitchToken(cfg, { accountId: DEFAULT_ACCOUNT_ID });
|
||||||
|
return account ? isAccountConfigured(account, tokenResolution.token) : false;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Check if an account is enabled */
|
||||||
|
isEnabled: (account: TwitchAccountConfig | undefined): boolean => account?.enabled !== false,
|
||||||
|
|
||||||
|
/** Describe account status */
|
||||||
|
describeAccount: (account: TwitchAccountConfig | undefined) => {
|
||||||
|
return {
|
||||||
|
accountId: DEFAULT_ACCOUNT_ID,
|
||||||
|
enabled: account?.enabled !== false,
|
||||||
|
configured: account ? isAccountConfigured(account, account?.accessToken) : false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Outbound message adapter */
|
||||||
|
outbound: twitchOutbound,
|
||||||
|
|
||||||
|
/** Message actions adapter */
|
||||||
|
actions: twitchMessageActions,
|
||||||
|
|
||||||
|
/** Resolver adapter for username -> user ID resolution */
|
||||||
|
resolver: {
|
||||||
|
resolveTargets: async ({
|
||||||
|
cfg,
|
||||||
|
accountId,
|
||||||
|
inputs,
|
||||||
|
kind,
|
||||||
|
runtime,
|
||||||
|
}: {
|
||||||
|
cfg: ClawdbotConfig;
|
||||||
|
accountId?: string | null;
|
||||||
|
inputs: string[];
|
||||||
|
kind: ChannelResolveKind;
|
||||||
|
runtime: import("../../../src/runtime.js").RuntimeEnv;
|
||||||
|
}): Promise<ChannelResolveResult[]> => {
|
||||||
|
const account = getAccountConfig(cfg, accountId ?? DEFAULT_ACCOUNT_ID);
|
||||||
|
|
||||||
|
if (!account) {
|
||||||
|
return inputs.map((input) => ({
|
||||||
|
input,
|
||||||
|
resolved: false,
|
||||||
|
note: "account not configured",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adapt RuntimeEnv.log to ChannelLogSink
|
||||||
|
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 monitoring adapter */
|
||||||
|
status: {
|
||||||
|
/** Default runtime state */
|
||||||
|
defaultRuntime: {
|
||||||
|
accountId: DEFAULT_ACCOUNT_ID,
|
||||||
|
running: false,
|
||||||
|
lastStartAt: null,
|
||||||
|
lastStopAt: null,
|
||||||
|
lastError: null,
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Build channel summary from snapshot */
|
||||||
|
buildChannelSummary: ({ snapshot }: { snapshot: ChannelAccountSnapshot }) => ({
|
||||||
|
configured: snapshot.configured ?? false,
|
||||||
|
running: snapshot.running ?? false,
|
||||||
|
lastStartAt: snapshot.lastStartAt ?? null,
|
||||||
|
lastStopAt: snapshot.lastStopAt ?? null,
|
||||||
|
lastError: snapshot.lastError ?? null,
|
||||||
|
probe: snapshot.probe,
|
||||||
|
lastProbeAt: snapshot.lastProbeAt ?? null,
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** Probe account connection */
|
||||||
|
probeAccount: async ({
|
||||||
|
account,
|
||||||
|
timeoutMs,
|
||||||
|
}: {
|
||||||
|
account: TwitchAccountConfig;
|
||||||
|
timeoutMs: number;
|
||||||
|
}): Promise<unknown> => {
|
||||||
|
return await probeTwitch(account, timeoutMs);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Build account snapshot with current status */
|
||||||
|
buildAccountSnapshot: ({
|
||||||
|
account,
|
||||||
|
cfg,
|
||||||
|
runtime,
|
||||||
|
probe,
|
||||||
|
}: {
|
||||||
|
account: TwitchAccountConfig;
|
||||||
|
cfg: ClawdbotConfig;
|
||||||
|
runtime?: ChannelAccountSnapshot;
|
||||||
|
probe?: unknown;
|
||||||
|
}): ChannelAccountSnapshot => {
|
||||||
|
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) ?? {};
|
||||||
|
const resolvedAccountId =
|
||||||
|
Object.entries(accountMap).find(([, value]) => value === account)?.[0] ??
|
||||||
|
DEFAULT_ACCOUNT_ID;
|
||||||
|
const tokenResolution = resolveTwitchToken(cfg, { accountId: resolvedAccountId });
|
||||||
|
return {
|
||||||
|
accountId: resolvedAccountId,
|
||||||
|
enabled: account?.enabled !== false,
|
||||||
|
configured: isAccountConfigured(account, tokenResolution.token),
|
||||||
|
running: runtime?.running ?? false,
|
||||||
|
lastStartAt: runtime?.lastStartAt ?? null,
|
||||||
|
lastStopAt: runtime?.lastStopAt ?? null,
|
||||||
|
lastError: runtime?.lastError ?? null,
|
||||||
|
probe,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Collect status issues for all accounts */
|
||||||
|
collectStatusIssues: collectTwitchStatusIssues,
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Gateway adapter for connection lifecycle */
|
||||||
|
gateway: {
|
||||||
|
/** Start an account connection */
|
||||||
|
startAccount: async (ctx): Promise<void> => {
|
||||||
|
const account = ctx.account as TwitchAccountConfig;
|
||||||
|
const accountId = ctx.accountId;
|
||||||
|
|
||||||
|
ctx.setStatus?.({
|
||||||
|
accountId,
|
||||||
|
running: true,
|
||||||
|
lastStartAt: Date.now(),
|
||||||
|
lastError: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.log?.info(`Starting Twitch connection for ${account.username}`);
|
||||||
|
|
||||||
|
// Lazy import: the monitor pulls the reply pipeline; avoid ESM init cycles.
|
||||||
|
const { monitorTwitchProvider } = await import("./monitor.js");
|
||||||
|
await monitorTwitchProvider({
|
||||||
|
account,
|
||||||
|
accountId,
|
||||||
|
config: ctx.cfg,
|
||||||
|
runtime: ctx.runtime,
|
||||||
|
abortSignal: ctx.abortSignal,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Stop an account connection */
|
||||||
|
stopAccount: async (ctx): Promise<void> => {
|
||||||
|
const account = ctx.account as TwitchAccountConfig;
|
||||||
|
const accountId = ctx.accountId;
|
||||||
|
|
||||||
|
// Disconnect and remove client manager from registry
|
||||||
|
await removeClientManager(accountId);
|
||||||
|
|
||||||
|
ctx.setStatus?.({
|
||||||
|
accountId,
|
||||||
|
running: false,
|
||||||
|
lastStopAt: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.log?.info(`Stopped Twitch connection for ${account.username}`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
198
extensions/twitch/src/probe.test.ts
Normal file
198
extensions/twitch/src/probe.test.ts
Normal file
|
|
@ -0,0 +1,198 @@
|
||||||
|
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;
|
||||||
|
let authFailHandler: (() => 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) => {
|
||||||
|
authFailHandler = handler;
|
||||||
|
return { unbind: mockUnbind };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Connect mock that triggers the registered handler
|
||||||
|
const defaultConnectImpl = async () => {
|
||||||
|
// Simulate successful connection by calling the handler after a delay
|
||||||
|
if (connectHandler) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||||
|
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: class {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("probeTwitch", () => {
|
||||||
|
const mockAccount: TwitchAccountConfig = {
|
||||||
|
username: "testbot",
|
||||||
|
token: "oauth:test123456789",
|
||||||
|
channel: "testchannel",
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
// Reset handlers
|
||||||
|
connectHandler = null;
|
||||||
|
disconnectHandler = null;
|
||||||
|
authFailHandler = 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, token: "" };
|
||||||
|
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, token: "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 () => {
|
||||||
|
mockConnect.mockImplementationOnce(() => new Promise(() => {})); // Never resolves
|
||||||
|
|
||||||
|
const result = await probeTwitch(mockAccount, 100);
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result.error).toContain("timeout");
|
||||||
|
|
||||||
|
// Reset mock
|
||||||
|
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) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||||
|
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) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||||
|
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,
|
||||||
|
token: " 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) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
118
extensions/twitch/src/probe.ts
Normal file
118
extensions/twitch/src/probe.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
import { StaticAuthProvider } from "@twurple/auth";
|
||||||
|
import { ChatClient } from "@twurple/chat";
|
||||||
|
import type { TwitchAccountConfig } from "./types.js";
|
||||||
|
import { normalizeToken } from "./utils/twitch.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of probing a Twitch account
|
||||||
|
*/
|
||||||
|
export type ProbeTwitchResult = {
|
||||||
|
ok: boolean;
|
||||||
|
error?: 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.token || !account.username) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: "missing credentials (token, username)",
|
||||||
|
username: account.username,
|
||||||
|
elapsedMs: Date.now() - started,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawToken = normalizeToken(account.token.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;
|
||||||
|
let connectListener: ReturnType<ChatClient["onConnect"]> | undefined;
|
||||||
|
let disconnectListener: ReturnType<ChatClient["onDisconnect"]> | undefined;
|
||||||
|
let authFailListener: ReturnType<ChatClient["onAuthenticationFailure"]> | undefined;
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
connectListener?.unbind();
|
||||||
|
disconnectListener?.unbind();
|
||||||
|
authFailListener?.unbind();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Success: connection established
|
||||||
|
connectListener = client?.onConnect(() => {
|
||||||
|
cleanup();
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Failure: disconnected (e.g., auth failed)
|
||||||
|
disconnectListener = client?.onDisconnect((_manually, reason) => {
|
||||||
|
cleanup();
|
||||||
|
reject(reason || new Error("Disconnected"));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Failure: authentication failed
|
||||||
|
authFailListener = client?.onAuthenticationFailure(() => {
|
||||||
|
cleanup();
|
||||||
|
reject(new Error("Authentication failed"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const timeout = new Promise<never>((_, reject) => {
|
||||||
|
setTimeout(() => reject(new Error(`timeout after ${timeoutMs}ms`)), timeoutMs);
|
||||||
|
});
|
||||||
|
|
||||||
|
client.connect();
|
||||||
|
await Promise.race([connectionPromise, timeout]);
|
||||||
|
|
||||||
|
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: error instanceof Error ? error.message : String(error),
|
||||||
|
username: account.username,
|
||||||
|
channel: account.channel,
|
||||||
|
elapsedMs: Date.now() - started,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
if (client) {
|
||||||
|
try {
|
||||||
|
client.quit();
|
||||||
|
} catch {
|
||||||
|
// Ignore cleanup errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
137
extensions/twitch/src/resolver.ts
Normal file
137
extensions/twitch/src/resolver.ts
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
/**
|
||||||
|
* 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 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 trimmed.slice(1).toLowerCase();
|
||||||
|
}
|
||||||
|
return trimmed.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.token) {
|
||||||
|
log.error("Missing Twitch client ID or token");
|
||||||
|
return inputs.map((input) => ({
|
||||||
|
input,
|
||||||
|
resolved: false,
|
||||||
|
note: "missing Twitch credentials",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedToken = normalizeToken(account.token);
|
||||||
|
|
||||||
|
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 = error instanceof Error ? error.message : String(error);
|
||||||
|
results.push({
|
||||||
|
input,
|
||||||
|
resolved: false,
|
||||||
|
note: `API error: ${errorMessage}`,
|
||||||
|
});
|
||||||
|
log.error(`Failed to resolve ${input}: ${errorMessage}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
14
extensions/twitch/src/runtime.ts
Normal file
14
extensions/twitch/src/runtime.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import type { PluginRuntime } from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
|
let runtime: PluginRuntime | null = null;
|
||||||
|
|
||||||
|
export function setTwitchRuntime(next: PluginRuntime) {
|
||||||
|
runtime = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTwitchRuntime(): PluginRuntime {
|
||||||
|
if (!runtime) {
|
||||||
|
throw new Error("Twitch runtime not initialized");
|
||||||
|
}
|
||||||
|
return runtime;
|
||||||
|
}
|
||||||
289
extensions/twitch/src/send.test.ts
Normal file
289
extensions/twitch/src/send.test.ts
Normal file
|
|
@ -0,0 +1,289 @@
|
||||||
|
/**
|
||||||
|
* 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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { sendMessageTwitchInternal } from "./send.js";
|
||||||
|
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
|
// Mock dependencies
|
||||||
|
vi.mock("./config.js", () => ({
|
||||||
|
DEFAULT_ACCOUNT_ID: "default",
|
||||||
|
getAccountConfig: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./utils/twitch.js", () => ({
|
||||||
|
generateMessageId: vi.fn(() => "test-msg-id"),
|
||||||
|
isAccountConfigured: vi.fn(() => true),
|
||||||
|
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 = {
|
||||||
|
username: "testbot",
|
||||||
|
token: "oauth:test123",
|
||||||
|
clientId: "test-client-id",
|
||||||
|
channel: "#testchannel",
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockConfig = {
|
||||||
|
channels: {
|
||||||
|
twitch: {
|
||||||
|
accounts: {
|
||||||
|
default: mockAccount,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as ClawdbotConfig;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sendMessageTwitchInternal", () => {
|
||||||
|
it("should send a message successfully", async () => {
|
||||||
|
const { getAccountConfig } = await import("./config.js");
|
||||||
|
const { getClientManager } = await import("./client-manager-registry.js");
|
||||||
|
const { stripMarkdownForTwitch } = await import("./utils/markdown.js");
|
||||||
|
|
||||||
|
vi.mocked(getAccountConfig).mockReturnValue(mockAccount);
|
||||||
|
vi.mocked(getClientManager).mockReturnValue({
|
||||||
|
sendMessage: vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
messageId: "twitch-msg-123",
|
||||||
|
}),
|
||||||
|
} as ReturnType<typeof getClientManager>);
|
||||||
|
vi.mocked(stripMarkdownForTwitch).mockImplementation((text) => text);
|
||||||
|
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should strip markdown when enabled", async () => {
|
||||||
|
const { getAccountConfig } = await import("./config.js");
|
||||||
|
const { getClientManager } = await import("./client-manager-registry.js");
|
||||||
|
const { stripMarkdownForTwitch } = await import("./utils/markdown.js");
|
||||||
|
|
||||||
|
vi.mocked(getAccountConfig).mockReturnValue(mockAccount);
|
||||||
|
vi.mocked(getClientManager).mockReturnValue({
|
||||||
|
sendMessage: vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
messageId: "twitch-msg-456",
|
||||||
|
}),
|
||||||
|
} as ReturnType<typeof getClientManager>);
|
||||||
|
vi.mocked(stripMarkdownForTwitch).mockImplementation((text) => text.replace(/\*\*/g, ""));
|
||||||
|
|
||||||
|
await sendMessageTwitchInternal(
|
||||||
|
"#testchannel",
|
||||||
|
"**Bold** text",
|
||||||
|
mockConfig,
|
||||||
|
"default",
|
||||||
|
true,
|
||||||
|
mockLogger as unknown as Console,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(stripMarkdownForTwitch).toHaveBeenCalledWith("**Bold** text");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return error when account not found", async () => {
|
||||||
|
const { getAccountConfig } = await import("./config.js");
|
||||||
|
|
||||||
|
vi.mocked(getAccountConfig).mockReturnValue(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 () => {
|
||||||
|
const { getAccountConfig } = await import("./config.js");
|
||||||
|
const { isAccountConfigured } = await import("./utils/twitch.js");
|
||||||
|
|
||||||
|
vi.mocked(getAccountConfig).mockReturnValue(mockAccount);
|
||||||
|
vi.mocked(isAccountConfigured).mockReturnValue(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 () => {
|
||||||
|
const { getAccountConfig } = await import("./config.js");
|
||||||
|
const { isAccountConfigured } = await import("./utils/twitch.js");
|
||||||
|
|
||||||
|
// Set channel to undefined to trigger the error (bypassing type check)
|
||||||
|
const accountWithoutChannel = {
|
||||||
|
...mockAccount,
|
||||||
|
channel: undefined as unknown as string,
|
||||||
|
};
|
||||||
|
vi.mocked(getAccountConfig).mockReturnValue(accountWithoutChannel);
|
||||||
|
vi.mocked(isAccountConfigured).mockReturnValue(true);
|
||||||
|
|
||||||
|
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 () => {
|
||||||
|
const { getAccountConfig } = await import("./config.js");
|
||||||
|
const { isAccountConfigured } = await import("./utils/twitch.js");
|
||||||
|
const { stripMarkdownForTwitch } = await import("./utils/markdown.js");
|
||||||
|
|
||||||
|
vi.mocked(getAccountConfig).mockReturnValue(mockAccount);
|
||||||
|
vi.mocked(isAccountConfigured).mockReturnValue(true);
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return error when client manager not found", async () => {
|
||||||
|
const { getAccountConfig } = await import("./config.js");
|
||||||
|
const { isAccountConfigured } = await import("./utils/twitch.js");
|
||||||
|
const { getClientManager } = await import("./client-manager-registry.js");
|
||||||
|
|
||||||
|
vi.mocked(getAccountConfig).mockReturnValue(mockAccount);
|
||||||
|
vi.mocked(isAccountConfigured).mockReturnValue(true);
|
||||||
|
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 () => {
|
||||||
|
const { getAccountConfig } = await import("./config.js");
|
||||||
|
const { isAccountConfigured } = await import("./utils/twitch.js");
|
||||||
|
const { getClientManager } = await import("./client-manager-registry.js");
|
||||||
|
|
||||||
|
vi.mocked(getAccountConfig).mockReturnValue(mockAccount);
|
||||||
|
vi.mocked(isAccountConfigured).mockReturnValue(true);
|
||||||
|
vi.mocked(getClientManager).mockReturnValue({
|
||||||
|
sendMessage: vi.fn().mockRejectedValue(new Error("Connection lost")),
|
||||||
|
} 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 () => {
|
||||||
|
const { getAccountConfig } = await import("./config.js");
|
||||||
|
const { isAccountConfigured } = await import("./utils/twitch.js");
|
||||||
|
const { getClientManager } = await import("./client-manager-registry.js");
|
||||||
|
|
||||||
|
vi.mocked(getAccountConfig).mockReturnValue(mockAccount);
|
||||||
|
vi.mocked(isAccountConfigured).mockReturnValue(true);
|
||||||
|
const mockSend = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
messageId: "twitch-msg-789",
|
||||||
|
});
|
||||||
|
vi.mocked(getClientManager).mockReturnValue({
|
||||||
|
sendMessage: mockSend,
|
||||||
|
} 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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
136
extensions/twitch/src/send.ts
Normal file
136
extensions/twitch/src/send.ts
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
/**
|
||||||
|
* 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 { DEFAULT_ACCOUNT_ID, getAccountConfig } from "./config.js";
|
||||||
|
import { getClientManager as getRegistryClientManager } from "./client-manager-registry.js";
|
||||||
|
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||||
|
import { resolveTwitchToken } from "./token.js";
|
||||||
|
import { stripMarkdownForTwitch } from "./utils/markdown.js";
|
||||||
|
import { generateMessageId, isAccountConfigured, 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;
|
||||||
|
/** Error message if the send failed */
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal send function used by the outbound adapter.
|
||||||
|
*
|
||||||
|
* This function has access to the full Clawdbot config and handles
|
||||||
|
* account resolution, markdown stripping, and actual message sending.
|
||||||
|
*
|
||||||
|
* @param channel - The channel name
|
||||||
|
* @param text - The message text
|
||||||
|
* @param cfg - Full Clawdbot 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!",
|
||||||
|
* clawdbotConfig,
|
||||||
|
* "default",
|
||||||
|
* true,
|
||||||
|
* console,
|
||||||
|
* );
|
||||||
|
*/
|
||||||
|
export async function sendMessageTwitchInternal(
|
||||||
|
channel: string,
|
||||||
|
text: string,
|
||||||
|
cfg: ClawdbotConfig,
|
||||||
|
accountId: string = DEFAULT_ACCOUNT_ID,
|
||||||
|
stripMarkdown: boolean = true,
|
||||||
|
logger: Console = console,
|
||||||
|
): Promise<SendMessageResult> {
|
||||||
|
const account = getAccountConfig(cfg, accountId);
|
||||||
|
if (!account) {
|
||||||
|
const availableIds = Object.keys(cfg.channels?.twitch?.accounts ?? {});
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
messageId: generateMessageId(),
|
||||||
|
error: `Account not found: ${accountId}. Available accounts: ${availableIds.join(", ") || "none"}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenResolution = resolveTwitchToken(cfg, { accountId });
|
||||||
|
if (!isAccountConfigured(account, tokenResolution.token)) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
messageId: generateMessageId(),
|
||||||
|
error:
|
||||||
|
`Account ${accountId} 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(),
|
||||||
|
error: "No channel specified and no default channel in account config",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanedText = stripMarkdown ? stripMarkdownForTwitch(text) : text;
|
||||||
|
if (!cleanedText) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
messageId: "skipped",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientManager = getRegistryClientManager(accountId);
|
||||||
|
if (!clientManager) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
messageId: generateMessageId(),
|
||||||
|
error: `Client manager not found for account: ${accountId}. Please start the Twitch gateway first.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await clientManager.sendMessage(
|
||||||
|
account,
|
||||||
|
normalizeTwitchChannel(normalizedChannel),
|
||||||
|
cleanedText,
|
||||||
|
cfg,
|
||||||
|
accountId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
messageId: result.messageId ?? generateMessageId(),
|
||||||
|
error: result.error ?? "Send failed",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
messageId: result.messageId ?? generateMessageId(),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||||
|
logger.error(`Failed to send message: ${errorMsg}`);
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
messageId: generateMessageId(),
|
||||||
|
error: errorMsg,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
270
extensions/twitch/src/status.test.ts
Normal file
270
extensions/twitch/src/status.test.ts
Normal file
|
|
@ -0,0 +1,270 @@
|
||||||
|
/**
|
||||||
|
* 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";
|
||||||
|
|
||||||
|
describe("status", () => {
|
||||||
|
describe("collectTwitchStatusIssues", () => {
|
||||||
|
it("should detect unconfigured accounts", () => {
|
||||||
|
const snapshots: ChannelAccountSnapshot[] = [
|
||||||
|
{
|
||||||
|
accountId: "default",
|
||||||
|
configured: false,
|
||||||
|
enabled: true,
|
||||||
|
running: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const issues = collectTwitchStatusIssues(snapshots);
|
||||||
|
|
||||||
|
expect(issues.length).toBeGreaterThan(0);
|
||||||
|
expect(issues[0]?.kind).toBe("config");
|
||||||
|
expect(issues[0]?.message).toContain("not properly configured");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should detect disabled accounts", () => {
|
||||||
|
const snapshots: ChannelAccountSnapshot[] = [
|
||||||
|
{
|
||||||
|
accountId: "default",
|
||||||
|
configured: true,
|
||||||
|
enabled: false,
|
||||||
|
running: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const issues = collectTwitchStatusIssues(snapshots);
|
||||||
|
|
||||||
|
expect(issues.length).toBeGreaterThan(0);
|
||||||
|
const disabledIssue = issues.find((i) => i.message.includes("disabled"));
|
||||||
|
expect(disabledIssue).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should detect missing clientId when account configured (simplified config)", () => {
|
||||||
|
const snapshots: ChannelAccountSnapshot[] = [
|
||||||
|
{
|
||||||
|
accountId: "default",
|
||||||
|
configured: true,
|
||||||
|
enabled: true,
|
||||||
|
running: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const mockCfg = {
|
||||||
|
channels: {
|
||||||
|
twitch: {
|
||||||
|
username: "testbot",
|
||||||
|
accessToken: "oauth:test123",
|
||||||
|
// clientId missing
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const issues = collectTwitchStatusIssues(snapshots, () => mockCfg as never);
|
||||||
|
|
||||||
|
const clientIdIssue = issues.find((i) => i.message.includes("client ID"));
|
||||||
|
expect(clientIdIssue).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should warn about oauth: prefix in token (simplified config)", () => {
|
||||||
|
const snapshots: ChannelAccountSnapshot[] = [
|
||||||
|
{
|
||||||
|
accountId: "default",
|
||||||
|
configured: true,
|
||||||
|
enabled: true,
|
||||||
|
running: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const mockCfg = {
|
||||||
|
channels: {
|
||||||
|
twitch: {
|
||||||
|
username: "testbot",
|
||||||
|
accessToken: "oauth:test123", // has prefix
|
||||||
|
clientId: "test-id",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const issues = collectTwitchStatusIssues(snapshots, () => mockCfg as never);
|
||||||
|
|
||||||
|
const prefixIssue = issues.find((i) => i.message.includes("oauth:"));
|
||||||
|
expect(prefixIssue).toBeDefined();
|
||||||
|
expect(prefixIssue?.kind).toBe("config");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should detect clientSecret without refreshToken (simplified config)", () => {
|
||||||
|
const snapshots: ChannelAccountSnapshot[] = [
|
||||||
|
{
|
||||||
|
accountId: "default",
|
||||||
|
configured: true,
|
||||||
|
enabled: true,
|
||||||
|
running: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const mockCfg = {
|
||||||
|
channels: {
|
||||||
|
twitch: {
|
||||||
|
username: "testbot",
|
||||||
|
accessToken: "oauth:test123",
|
||||||
|
clientId: "test-id",
|
||||||
|
clientSecret: "secret123",
|
||||||
|
// refreshToken missing
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const issues = collectTwitchStatusIssues(snapshots, () => mockCfg as never);
|
||||||
|
|
||||||
|
const secretIssue = issues.find((i) => i.message.includes("clientSecret"));
|
||||||
|
expect(secretIssue).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should detect empty allowFrom array (simplified config)", () => {
|
||||||
|
const snapshots: ChannelAccountSnapshot[] = [
|
||||||
|
{
|
||||||
|
accountId: "default",
|
||||||
|
configured: true,
|
||||||
|
enabled: true,
|
||||||
|
running: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const mockCfg = {
|
||||||
|
channels: {
|
||||||
|
twitch: {
|
||||||
|
username: "testbot",
|
||||||
|
accessToken: "test123",
|
||||||
|
clientId: "test-id",
|
||||||
|
allowFrom: [], // empty array
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const issues = collectTwitchStatusIssues(snapshots, () => mockCfg as never);
|
||||||
|
|
||||||
|
const allowFromIssue = issues.find((i) => i.message.includes("allowFrom"));
|
||||||
|
expect(allowFromIssue).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should detect allowedRoles 'all' with allowFrom conflict (simplified config)", () => {
|
||||||
|
const snapshots: ChannelAccountSnapshot[] = [
|
||||||
|
{
|
||||||
|
accountId: "default",
|
||||||
|
configured: true,
|
||||||
|
enabled: true,
|
||||||
|
running: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const mockCfg = {
|
||||||
|
channels: {
|
||||||
|
twitch: {
|
||||||
|
username: "testbot",
|
||||||
|
accessToken: "test123",
|
||||||
|
clientId: "test-id",
|
||||||
|
allowedRoles: ["all"],
|
||||||
|
allowFrom: ["123456"], // conflict!
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const issues = collectTwitchStatusIssues(snapshots, () => mockCfg as never);
|
||||||
|
|
||||||
|
const conflictIssue = issues.find((i) => i.kind === "intent");
|
||||||
|
expect(conflictIssue).toBeDefined();
|
||||||
|
expect(conflictIssue?.message).toContain("allowedRoles is set to 'all'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should detect runtime errors", () => {
|
||||||
|
const snapshots: ChannelAccountSnapshot[] = [
|
||||||
|
{
|
||||||
|
accountId: "default",
|
||||||
|
configured: true,
|
||||||
|
enabled: true,
|
||||||
|
running: false,
|
||||||
|
lastError: "Connection timeout",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const issues = collectTwitchStatusIssues(snapshots);
|
||||||
|
|
||||||
|
const runtimeIssue = issues.find((i) => i.kind === "runtime");
|
||||||
|
expect(runtimeIssue).toBeDefined();
|
||||||
|
expect(runtimeIssue?.message).toContain("Connection timeout");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should detect accounts that never connected", () => {
|
||||||
|
const snapshots: ChannelAccountSnapshot[] = [
|
||||||
|
{
|
||||||
|
accountId: "default",
|
||||||
|
configured: true,
|
||||||
|
enabled: true,
|
||||||
|
running: false,
|
||||||
|
lastStartAt: undefined,
|
||||||
|
lastInboundAt: undefined,
|
||||||
|
lastOutboundAt: undefined,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const issues = collectTwitchStatusIssues(snapshots);
|
||||||
|
|
||||||
|
const neverConnectedIssue = issues.find((i) =>
|
||||||
|
i.message.includes("never connected successfully"),
|
||||||
|
);
|
||||||
|
expect(neverConnectedIssue).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should detect long-running connections", () => {
|
||||||
|
const oldDate = Date.now() - 8 * 24 * 60 * 60 * 1000; // 8 days ago
|
||||||
|
|
||||||
|
const snapshots: ChannelAccountSnapshot[] = [
|
||||||
|
{
|
||||||
|
accountId: "default",
|
||||||
|
configured: true,
|
||||||
|
enabled: true,
|
||||||
|
running: true,
|
||||||
|
lastStartAt: oldDate,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const issues = collectTwitchStatusIssues(snapshots);
|
||||||
|
|
||||||
|
const uptimeIssue = issues.find((i) => i.message.includes("running for"));
|
||||||
|
expect(uptimeIssue).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle empty snapshots array", () => {
|
||||||
|
const issues = collectTwitchStatusIssues([]);
|
||||||
|
|
||||||
|
expect(issues).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should skip non-Twitch accounts gracefully", () => {
|
||||||
|
const snapshots: ChannelAccountSnapshot[] = [
|
||||||
|
{
|
||||||
|
accountId: undefined,
|
||||||
|
configured: false,
|
||||||
|
enabled: true,
|
||||||
|
running: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const issues = collectTwitchStatusIssues(snapshots);
|
||||||
|
|
||||||
|
// Should not crash, may return empty or minimal issues
|
||||||
|
expect(Array.isArray(issues)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
176
extensions/twitch/src/status.ts
Normal file
176
extensions/twitch/src/status.ts
Normal file
|
|
@ -0,0 +1,176 @@
|
||||||
|
/**
|
||||||
|
* Twitch status issues collector.
|
||||||
|
*
|
||||||
|
* Detects and reports configuration issues for Twitch accounts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getAccountConfig } from "./config.js";
|
||||||
|
import type { ChannelAccountSnapshot, ChannelStatusIssue } from "./types.js";
|
||||||
|
import { resolveTwitchToken } from "./token.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;
|
||||||
|
}
|
||||||
171
extensions/twitch/src/token.test.ts
Normal file
171
extensions/twitch/src/token.test.ts
Normal file
|
|
@ -0,0 +1,171 @@
|
||||||
|
/**
|
||||||
|
* 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 { resolveTwitchToken, type TwitchTokenSource } from "./token.js";
|
||||||
|
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
|
describe("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 ClawdbotConfig;
|
||||||
|
|
||||||
|
// Simplified single-account config
|
||||||
|
const mockSimplifiedConfig = {
|
||||||
|
channels: {
|
||||||
|
twitch: {
|
||||||
|
username: "testbot",
|
||||||
|
accessToken: "oauth:config-token",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as ClawdbotConfig;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
delete process.env.CLAWDBOT_TWITCH_ACCESS_TOKEN;
|
||||||
|
});
|
||||||
|
|
||||||
|
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 prioritize config token over env var (simplified config)", () => {
|
||||||
|
process.env.CLAWDBOT_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.CLAWDBOT_TWITCH_ACCESS_TOKEN = "oauth:env-token";
|
||||||
|
|
||||||
|
const configWithEmptyToken = {
|
||||||
|
channels: {
|
||||||
|
twitch: {
|
||||||
|
username: "testbot",
|
||||||
|
accessToken: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as ClawdbotConfig;
|
||||||
|
|
||||||
|
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 ClawdbotConfig;
|
||||||
|
|
||||||
|
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.CLAWDBOT_TWITCH_ACCESS_TOKEN = "oauth:env-token";
|
||||||
|
|
||||||
|
const configWithoutToken = {
|
||||||
|
channels: {
|
||||||
|
twitch: {
|
||||||
|
accounts: {
|
||||||
|
secondary: {
|
||||||
|
username: "secondary",
|
||||||
|
accessToken: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as ClawdbotConfig;
|
||||||
|
|
||||||
|
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 ClawdbotConfig;
|
||||||
|
|
||||||
|
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 ClawdbotConfig;
|
||||||
|
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
87
extensions/twitch/src/token.ts
Normal file
87
extensions/twitch/src/token.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
/**
|
||||||
|
* Twitch access token resolution with environment variable support.
|
||||||
|
*
|
||||||
|
* Supports reading Twitch OAuth access tokens from config or environment variable.
|
||||||
|
* The CLAWDBOT_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: CLAWDBOT_TWITCH_ACCESS_TOKEN (default account only)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ClawdbotConfig } from "../../../src/config/config.js";
|
||||||
|
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js";
|
||||||
|
|
||||||
|
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: CLAWDBOT_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 - Clawdbot config
|
||||||
|
* @param opts - Options including accountId and optional envToken override
|
||||||
|
* @returns Token resolution with source
|
||||||
|
*/
|
||||||
|
export function resolveTwitchToken(
|
||||||
|
cfg?: ClawdbotConfig,
|
||||||
|
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 accountCfg =
|
||||||
|
accountId === DEFAULT_ACCOUNT_ID
|
||||||
|
? (twitchCfg?.accounts?.[DEFAULT_ACCOUNT_ID] as Record<string, unknown> | undefined)
|
||||||
|
: (twitchCfg?.accounts?.[accountId as string] as Record<string, unknown> | undefined);
|
||||||
|
|
||||||
|
// 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.CLAWDBOT_TWITCH_ACCESS_TOKEN)
|
||||||
|
: undefined;
|
||||||
|
if (envToken) {
|
||||||
|
return { token: envToken, source: "env" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { token: "", source: "none" };
|
||||||
|
}
|
||||||
574
extensions/twitch/src/twitch-client.test.ts
Normal file
574
extensions/twitch/src/twitch-client.test.ts
Normal file
|
|
@ -0,0 +1,574 @@
|
||||||
|
/**
|
||||||
|
* 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, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { TwitchClientManager } from "./twitch-client.js";
|
||||||
|
import type { ChannelLogSink, TwitchAccountConfig, TwitchChatMessage } from "./types.js";
|
||||||
|
|
||||||
|
// Mock @twurple dependencies
|
||||||
|
const mockConnect = vi.fn().mockResolvedValue(undefined);
|
||||||
|
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> =
|
||||||
|
[];
|
||||||
|
|
||||||
|
// Mock functions that track handlers and return unbind objects
|
||||||
|
const mockOnMessage = vi.fn((handler: any) => {
|
||||||
|
messageHandlers.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;
|
||||||
|
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: class {
|
||||||
|
constructor(...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;
|
||||||
|
|
||||||
|
const testAccount: TwitchAccountConfig = {
|
||||||
|
username: "testbot",
|
||||||
|
token: "oauth:test123456",
|
||||||
|
clientId: "test-client-id",
|
||||||
|
channel: "testchannel",
|
||||||
|
enabled: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const testAccount2: TwitchAccountConfig = {
|
||||||
|
username: "testbot2",
|
||||||
|
token: "oauth:test789",
|
||||||
|
clientId: "test-client-id-2",
|
||||||
|
channel: "testchannel2",
|
||||||
|
enabled: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Clear all mocks first
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
// Clear handler arrays
|
||||||
|
messageHandlers.length = 0;
|
||||||
|
|
||||||
|
// Re-set up the default token mock implementation after clearing
|
||||||
|
const { resolveTwitchToken } = await import("./token.js");
|
||||||
|
vi.mocked(resolveTwitchToken).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 _client = await manager.getClient(testAccount);
|
||||||
|
|
||||||
|
// New implementation: connect is called, channels are passed to constructor
|
||||||
|
expect(mockConnect).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockLogger.info).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("Connected to Twitch as testbot"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should use account username as default channel when channel not specified", async () => {
|
||||||
|
const accountWithoutChannel: TwitchAccountConfig = {
|
||||||
|
...testAccount,
|
||||||
|
channel: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
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("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,
|
||||||
|
token: "oauth:actualtoken123",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Override the mock to return a specific token for this test
|
||||||
|
const { resolveTwitchToken } = await import("./token.js");
|
||||||
|
vi.mocked(resolveTwitchToken).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
|
||||||
|
const { resolveTwitchToken } = await import("./token.js");
|
||||||
|
vi.mocked(resolveTwitchToken).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 throw error when clientId is missing", async () => {
|
||||||
|
const accountWithoutClientId: TwitchAccountConfig = {
|
||||||
|
...testAccount,
|
||||||
|
clientId: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(manager.getClient(accountWithoutClientId)).rejects.toThrow(
|
||||||
|
"Missing Twitch client ID",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("Missing Twitch client ID"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw error when token is missing", async () => {
|
||||||
|
// Override the mock to return empty token
|
||||||
|
const { resolveTwitchToken } = await import("./token.js");
|
||||||
|
vi.mocked(resolveTwitchToken).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(expect.stringContaining("Set up handlers for"));
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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(expect.stringContaining("Disconnected"));
|
||||||
|
});
|
||||||
|
|
||||||
|
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("should handle disconnecting non-existent client gracefully", async () => {
|
||||||
|
// disconnect doesn't throw, just does nothing
|
||||||
|
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 () => {
|
||||||
|
// disconnectAll doesn't throw, just does nothing
|
||||||
|
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!");
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
expect(result.messageId).toBeDefined();
|
||||||
|
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(
|
||||||
|
expect.stringContaining("Failed to send message"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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).not.toBeNull();
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
277
extensions/twitch/src/twitch-client.ts
Normal file
277
extensions/twitch/src/twitch-client.ts
Normal file
|
|
@ -0,0 +1,277 @@
|
||||||
|
import { RefreshingAuthProvider, StaticAuthProvider } from "@twurple/auth";
|
||||||
|
import { ChatClient, LogLevel } from "@twurple/chat";
|
||||||
|
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||||
|
import type { ChannelLogSink, TwitchAccountConfig, TwitchChatMessage } from "./types.js";
|
||||||
|
import { resolveTwitchToken } from "./token.js";
|
||||||
|
import { normalizeToken } from "./utils/twitch.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages Twitch chat client connections
|
||||||
|
*/
|
||||||
|
export class TwitchClientManager {
|
||||||
|
private clients = new Map<string, ChatClient>();
|
||||||
|
private messageHandlers = new Map<string, (message: TwitchChatMessage) => void>();
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
|
||||||
|
await authProvider
|
||||||
|
.addUserForToken({
|
||||||
|
accessToken: normalizedToken,
|
||||||
|
refreshToken: account.refreshToken ?? null,
|
||||||
|
expiresIn: account.expiresIn ?? null,
|
||||||
|
obtainmentTimestamp: account.obtainmentTimestamp ?? Date.now(),
|
||||||
|
})
|
||||||
|
.then((userId) => {
|
||||||
|
this.logger.info(
|
||||||
|
`Added user ${userId} to RefreshingAuthProvider for ${account.username}`,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to add user to RefreshingAuthProvider: ${err instanceof Error ? err.message : String(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?: ClawdbotConfig,
|
||||||
|
accountId?: string,
|
||||||
|
): Promise<ChatClient> {
|
||||||
|
const key = this.getAccountKey(account);
|
||||||
|
|
||||||
|
const existing = this.clients.get(key);
|
||||||
|
if (existing) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 CLAWDBOT_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);
|
||||||
|
|
||||||
|
client.connect();
|
||||||
|
|
||||||
|
this.clients.set(key, client);
|
||||||
|
this.logger.info(`Connected to Twitch as ${account.username}`);
|
||||||
|
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 = messageText.slice(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);
|
||||||
|
this.messageHandlers.set(key, handler);
|
||||||
|
return () => {
|
||||||
|
this.messageHandlers.delete(key);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disconnect a client
|
||||||
|
*/
|
||||||
|
async disconnect(account: TwitchAccountConfig): Promise<void> {
|
||||||
|
const key = this.getAccountKey(account);
|
||||||
|
const client = this.clients.get(key);
|
||||||
|
|
||||||
|
if (client) {
|
||||||
|
client.quit();
|
||||||
|
this.clients.delete(key);
|
||||||
|
this.messageHandlers.delete(key);
|
||||||
|
this.logger.info(`Disconnected ${key}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disconnect all clients
|
||||||
|
*/
|
||||||
|
async disconnectAll(): Promise<void> {
|
||||||
|
this.clients.forEach((client) => client.quit());
|
||||||
|
this.clients.clear();
|
||||||
|
this.messageHandlers.clear();
|
||||||
|
this.logger.info(" Disconnected all clients");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a message to a channel
|
||||||
|
*/
|
||||||
|
async sendMessage(
|
||||||
|
account: TwitchAccountConfig,
|
||||||
|
channel: string,
|
||||||
|
message: string,
|
||||||
|
cfg?: ClawdbotConfig,
|
||||||
|
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: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: error instanceof Error ? error.message : String(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.messageHandlers.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
141
extensions/twitch/src/types.ts
Normal file
141
extensions/twitch/src/types.ts
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
/**
|
||||||
|
* Twitch channel plugin types.
|
||||||
|
*
|
||||||
|
* This file defines Twitch-specific types. Generic channel types are imported
|
||||||
|
* from Clawdbot core.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ChannelAccountSnapshot,
|
||||||
|
ChannelCapabilities,
|
||||||
|
ChannelLogSink,
|
||||||
|
ChannelMessageActionAdapter,
|
||||||
|
ChannelMessageActionContext,
|
||||||
|
ChannelMeta,
|
||||||
|
} from "../../../src/channels/plugins/types.core.js";
|
||||||
|
import type { ChannelPlugin } from "../../../src/channels/plugins/types.plugin.js";
|
||||||
|
import type {
|
||||||
|
ChannelGatewayContext,
|
||||||
|
ChannelOutboundAdapter,
|
||||||
|
ChannelOutboundContext,
|
||||||
|
ChannelResolveKind,
|
||||||
|
ChannelResolveResult,
|
||||||
|
ChannelStatusAdapter,
|
||||||
|
} from "../../../src/channels/plugins/types.adapters.js";
|
||||||
|
import type { ClawdbotConfig } from "../../../src/config/config.js";
|
||||||
|
import type { OutboundDeliveryResult } from "../../../src/infra/outbound/deliver.js";
|
||||||
|
import type { RuntimeEnv } from "../../../src/runtime.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;
|
||||||
|
/** 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Message target for Twitch
|
||||||
|
*/
|
||||||
|
export interface TwitchTarget {
|
||||||
|
/** Account ID */
|
||||||
|
accountId: string;
|
||||||
|
/** Channel name (defaults to account's channel) */
|
||||||
|
channel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send result from Twitch client
|
||||||
|
*/
|
||||||
|
export interface SendResult {
|
||||||
|
ok: boolean;
|
||||||
|
error?: string;
|
||||||
|
messageId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-export core types for convenience
|
||||||
|
export type {
|
||||||
|
ChannelAccountSnapshot,
|
||||||
|
ChannelGatewayContext,
|
||||||
|
ChannelLogSink,
|
||||||
|
ChannelMessageActionAdapter,
|
||||||
|
ChannelMessageActionContext,
|
||||||
|
ChannelMeta,
|
||||||
|
ChannelOutboundAdapter,
|
||||||
|
ChannelStatusAdapter,
|
||||||
|
ChannelCapabilities,
|
||||||
|
ChannelResolveKind,
|
||||||
|
ChannelResolveResult,
|
||||||
|
ChannelPlugin,
|
||||||
|
ChannelOutboundContext,
|
||||||
|
OutboundDeliveryResult,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Import and re-export the schema type
|
||||||
|
import type { TwitchConfigSchema } from "./config-schema.js";
|
||||||
|
import type { z } from "zod";
|
||||||
|
export type TwitchConfig = z.infer<typeof TwitchConfigSchema>;
|
||||||
|
|
||||||
|
export type { ClawdbotConfig };
|
||||||
|
export type { RuntimeEnv };
|
||||||
92
extensions/twitch/src/utils/markdown.ts
Normal file
92
extensions/twitch/src/utils/markdown.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
/**
|
||||||
|
* Markdown utilities for Twitch chat
|
||||||
|
*
|
||||||
|
* Twitch chat doesn't support markdown formatting, so we strip it before sending.
|
||||||
|
* Based on Clawdbot'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;
|
||||||
|
}
|
||||||
78
extensions/twitch/src/utils/twitch.ts
Normal file
78
extensions/twitch/src/utils/twitch.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
/**
|
||||||
|
* 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 = channel.trim().toLowerCase();
|
||||||
|
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()}-${Math.random().toString(36).substring(2, 15)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize OAuth token by removing the "oauth:" prefix if present.
|
||||||
|
*
|
||||||
|
* Twurple doesn't require the "oauth:" prefix, so we strip it for consistency.
|
||||||
|
*
|
||||||
|
* @param token - The OAuth token to normalize
|
||||||
|
* @returns Normalized token without "oauth:" prefix
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* normalizeToken("oauth:abc123") // "abc123"
|
||||||
|
* normalizeToken("abc123") // "abc123"
|
||||||
|
*/
|
||||||
|
export function normalizeToken(token: string): string {
|
||||||
|
return token.startsWith("oauth:") ? token.slice(6) : token;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an account is properly configured with required credentials.
|
||||||
|
*
|
||||||
|
* @param account - The Twitch account config to check
|
||||||
|
* @returns true if the account has required credentials
|
||||||
|
*/
|
||||||
|
export function isAccountConfigured(
|
||||||
|
account: {
|
||||||
|
username?: string;
|
||||||
|
accessToken?: string;
|
||||||
|
clientId?: string;
|
||||||
|
},
|
||||||
|
resolvedToken?: string | null,
|
||||||
|
): boolean {
|
||||||
|
const token = resolvedToken ?? account?.accessToken;
|
||||||
|
return Boolean(account?.username && token && account?.clientId);
|
||||||
|
}
|
||||||
7
extensions/twitch/test/setup.ts
Normal file
7
extensions/twitch/test/setup.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
/**
|
||||||
|
* Vitest setup file for Twitch plugin tests.
|
||||||
|
*
|
||||||
|
* Re-exports the root test setup to avoid duplication.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from "../../../test/setup.js";
|
||||||
207
pnpm-lock.yaml
207
pnpm-lock.yaml
|
|
@ -172,6 +172,13 @@ importers:
|
||||||
zod:
|
zod:
|
||||||
specifier: ^4.3.6
|
specifier: ^4.3.6
|
||||||
version: 4.3.6
|
version: 4.3.6
|
||||||
|
optionalDependencies:
|
||||||
|
'@napi-rs/canvas':
|
||||||
|
specifier: ^0.1.88
|
||||||
|
version: 0.1.88
|
||||||
|
node-llama-cpp:
|
||||||
|
specifier: 3.15.0
|
||||||
|
version: 3.15.0(typescript@5.9.3)
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@grammyjs/types':
|
'@grammyjs/types':
|
||||||
specifier: ^3.23.0
|
specifier: ^3.23.0
|
||||||
|
|
@ -254,13 +261,6 @@ importers:
|
||||||
wireit:
|
wireit:
|
||||||
specifier: ^0.14.12
|
specifier: ^0.14.12
|
||||||
version: 0.14.12
|
version: 0.14.12
|
||||||
optionalDependencies:
|
|
||||||
'@napi-rs/canvas':
|
|
||||||
specifier: ^0.1.88
|
|
||||||
version: 0.1.88
|
|
||||||
node-llama-cpp:
|
|
||||||
specifier: 3.15.0
|
|
||||||
version: 3.15.0(typescript@5.9.3)
|
|
||||||
|
|
||||||
extensions/bluebubbles: {}
|
extensions/bluebubbles: {}
|
||||||
|
|
||||||
|
|
@ -424,6 +424,25 @@ importers:
|
||||||
specifier: ^3.0.0
|
specifier: ^3.0.0
|
||||||
version: 3.0.0
|
version: 3.0.0
|
||||||
|
|
||||||
|
extensions/twitch:
|
||||||
|
dependencies:
|
||||||
|
'@twurple/api':
|
||||||
|
specifier: ^8.0.3
|
||||||
|
version: 8.0.3(@twurple/auth@8.0.3)
|
||||||
|
'@twurple/auth':
|
||||||
|
specifier: ^8.0.3
|
||||||
|
version: 8.0.3
|
||||||
|
'@twurple/chat':
|
||||||
|
specifier: ^8.0.3
|
||||||
|
version: 8.0.3(@twurple/auth@8.0.3)
|
||||||
|
zod:
|
||||||
|
specifier: ^4.3.5
|
||||||
|
version: 4.3.6
|
||||||
|
devDependencies:
|
||||||
|
clawdbot:
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../..
|
||||||
|
|
||||||
extensions/voice-call:
|
extensions/voice-call:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@sinclair/typebox':
|
'@sinclair/typebox':
|
||||||
|
|
@ -810,6 +829,39 @@ packages:
|
||||||
'@cloudflare/workers-types@4.20260120.0':
|
'@cloudflare/workers-types@4.20260120.0':
|
||||||
resolution: {integrity: sha512-B8pueG+a5S+mdK3z8oKu1ShcxloZ7qWb68IEyLLaepvdryIbNC7JVPcY0bWsjS56UQVKc5fnyRge3yZIwc9bxw==}
|
resolution: {integrity: sha512-B8pueG+a5S+mdK3z8oKu1ShcxloZ7qWb68IEyLLaepvdryIbNC7JVPcY0bWsjS56UQVKc5fnyRge3yZIwc9bxw==}
|
||||||
|
|
||||||
|
'@d-fischer/cache-decorators@4.0.1':
|
||||||
|
resolution: {integrity: sha512-HNYLBLWs/t28GFZZeqdIBqq8f37mqDIFO6xNPof94VjpKvuP6ROqCZGafx88dk5zZUlBfViV9jD8iNNlXfc4CA==}
|
||||||
|
|
||||||
|
'@d-fischer/connection@9.0.0':
|
||||||
|
resolution: {integrity: sha512-Mljp/EbaE+eYWfsFXUOk+RfpbHgrWGL/60JkAvjYixw6KREfi5r17XdUiXe54ByAQox6jwgdN2vebdmW1BT+nQ==}
|
||||||
|
|
||||||
|
'@d-fischer/deprecate@2.0.2':
|
||||||
|
resolution: {integrity: sha512-wlw3HwEanJFJKctwLzhfOM6LKwR70FPfGZGoKOhWBKyOPXk+3a9Cc6S9zhm6tka7xKtpmfxVIReGUwPnMbIaZg==}
|
||||||
|
|
||||||
|
'@d-fischer/detect-node@3.0.1':
|
||||||
|
resolution: {integrity: sha512-0Rf3XwTzuTh8+oPZW9SfxTIiL+26RRJ0BRPwj5oVjZFyFKmsj9RGfN2zuTRjOuA3FCK/jYm06HOhwNK+8Pfv8w==}
|
||||||
|
|
||||||
|
'@d-fischer/escape-string-regexp@5.0.0':
|
||||||
|
resolution: {integrity: sha512-7eoxnxcto5eVPW5h1T+ePnVFukmI9f/ZR9nlBLh1t3kyzJDUNor2C+YW9H/Terw3YnbZSDgDYrpCJCHtOtAQHw==}
|
||||||
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
'@d-fischer/isomorphic-ws@7.0.2':
|
||||||
|
resolution: {integrity: sha512-xK+qIJUF0ne3dsjq5Y3BviQ4M+gx9dzkN+dPP7abBMje4YRfow+X9jBgeEoTe5e+Q6+8hI9R0b37Okkk8Vf0hQ==}
|
||||||
|
peerDependencies:
|
||||||
|
ws: ^8.2.0
|
||||||
|
|
||||||
|
'@d-fischer/logger@4.2.4':
|
||||||
|
resolution: {integrity: sha512-TFMZ/SVW8xyQtyJw9Rcuci4betSKy0qbQn2B5+1+72vVXeO8Qb1pYvuwF5qr0vDGundmSWq7W8r19nVPnXXSvA==}
|
||||||
|
|
||||||
|
'@d-fischer/rate-limiter@1.1.0':
|
||||||
|
resolution: {integrity: sha512-O5HgACwApyCZhp4JTEBEtbv/W3eAwEkrARFvgWnEsDmXgCMWjIHwohWoHre5BW6IYXFSHBGsuZB/EvNL3942kQ==}
|
||||||
|
|
||||||
|
'@d-fischer/shared-utils@3.6.4':
|
||||||
|
resolution: {integrity: sha512-BPkVLHfn2Lbyo/ENDBwtEB8JVQ+9OzkjJhUunLaxkw4k59YFlQxUUwlDBejVSFcpQT0t+D3CQlX+ySZnQj0wxw==}
|
||||||
|
|
||||||
|
'@d-fischer/typed-event-emitter@3.3.3':
|
||||||
|
resolution: {integrity: sha512-OvSEOa8icfdWDqcRtjSEZtgJTFOFNgTjje7zaL0+nAtu2/kZtRCSK5wUMrI/aXtCH8o0Qz2vA8UqkhWUTARFQQ==}
|
||||||
|
|
||||||
'@discordjs/voice@0.19.0':
|
'@discordjs/voice@0.19.0':
|
||||||
resolution: {integrity: sha512-UyX6rGEXzVyPzb1yvjHtPfTlnLvB5jX/stAMdiytHhfoydX+98hfympdOwsnTktzr+IRvphxTbdErgYDJkEsvw==}
|
resolution: {integrity: sha512-UyX6rGEXzVyPzb1yvjHtPfTlnLvB5jX/stAMdiytHhfoydX+98hfympdOwsnTktzr+IRvphxTbdErgYDJkEsvw==}
|
||||||
engines: {node: '>=22.12.0'}
|
engines: {node: '>=22.12.0'}
|
||||||
|
|
@ -1264,7 +1316,6 @@ packages:
|
||||||
'@lancedb/lancedb@0.23.0':
|
'@lancedb/lancedb@0.23.0':
|
||||||
resolution: {integrity: sha512-aYrIoEG24AC+wILCL57Ius/Y4yU+xFHDPKLvmjzzN4byAjzeIGF0TC86S5RBt4Ji+dxS7yIWV5Q/gE5/fybIFQ==}
|
resolution: {integrity: sha512-aYrIoEG24AC+wILCL57Ius/Y4yU+xFHDPKLvmjzzN4byAjzeIGF0TC86S5RBt4Ji+dxS7yIWV5Q/gE5/fybIFQ==}
|
||||||
engines: {node: '>= 18'}
|
engines: {node: '>= 18'}
|
||||||
cpu: [x64, arm64]
|
|
||||||
os: [darwin, linux, win32]
|
os: [darwin, linux, win32]
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
apache-arrow: '>=15.0.0 <=18.1.0'
|
apache-arrow: '>=15.0.0 <=18.1.0'
|
||||||
|
|
@ -2585,6 +2636,25 @@ packages:
|
||||||
'@tokenizer/token@0.3.0':
|
'@tokenizer/token@0.3.0':
|
||||||
resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==}
|
resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==}
|
||||||
|
|
||||||
|
'@twurple/api-call@8.0.3':
|
||||||
|
resolution: {integrity: sha512-/5DBTqFjpYB+qqOkkFzoTWE79a7+I8uLXmBIIIYjGoq/CIPxKcHnlemXlU8cQhTr87PVa3th8zJXGYiNkpRx8w==}
|
||||||
|
|
||||||
|
'@twurple/api@8.0.3':
|
||||||
|
resolution: {integrity: sha512-vnqVi9YlNDbCqgpUUvTIq4sDitKCY0dkTw9zPluZvRNqUB1eCsuoaRNW96HQDhKtA9P4pRzwZ8xU7v/1KU2ytg==}
|
||||||
|
peerDependencies:
|
||||||
|
'@twurple/auth': 8.0.3
|
||||||
|
|
||||||
|
'@twurple/auth@8.0.3':
|
||||||
|
resolution: {integrity: sha512-Xlv+WNXmGQir4aBXYeRCqdno5XurA6jzYTIovSEHa7FZf3AMHMFqtzW7yqTCUn4iOahfUSA2TIIxmxFM0wis0g==}
|
||||||
|
|
||||||
|
'@twurple/chat@8.0.3':
|
||||||
|
resolution: {integrity: sha512-rhm6xhWKp+4zYFimaEj5fPm6lw/yjrAOsGXXSvPDsEqFR+fc0cVXzmHmglTavkmEELRajFiqNBKZjg73JZWhTQ==}
|
||||||
|
peerDependencies:
|
||||||
|
'@twurple/auth': 8.0.3
|
||||||
|
|
||||||
|
'@twurple/common@8.0.3':
|
||||||
|
resolution: {integrity: sha512-JQ2lb5qSFT21Y9qMfIouAILb94ppedLHASq49Fe/AP8oq0k3IC9Q7tX2n6tiMzGWqn+n8MnONUpMSZ6FhulMXA==}
|
||||||
|
|
||||||
'@tybys/wasm-util@0.10.1':
|
'@tybys/wasm-util@0.10.1':
|
||||||
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
|
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
|
||||||
|
|
||||||
|
|
@ -3775,6 +3845,9 @@ packages:
|
||||||
engines: {node: '>=18.0.0'}
|
engines: {node: '>=18.0.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
ircv3@0.33.0:
|
||||||
|
resolution: {integrity: sha512-7rK1Aial3LBiFycE8w3MHiBBFb41/2GG2Ll/fR2IJj1vx0pLpn1s+78K+z/I4PZTqCCSp/Sb4QgKMh3NMhx0Kg==}
|
||||||
|
|
||||||
is-binary-path@2.1.0:
|
is-binary-path@2.1.0:
|
||||||
resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
|
resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
@ -3944,6 +4017,10 @@ packages:
|
||||||
keyv@5.6.0:
|
keyv@5.6.0:
|
||||||
resolution: {integrity: sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==}
|
resolution: {integrity: sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==}
|
||||||
|
|
||||||
|
klona@2.0.6:
|
||||||
|
resolution: {integrity: sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==}
|
||||||
|
engines: {node: '>= 8'}
|
||||||
|
|
||||||
leac@0.6.0:
|
leac@0.6.0:
|
||||||
resolution: {integrity: sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==}
|
resolution: {integrity: sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==}
|
||||||
|
|
||||||
|
|
@ -6383,6 +6460,54 @@ snapshots:
|
||||||
'@cloudflare/workers-types@4.20260120.0':
|
'@cloudflare/workers-types@4.20260120.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@d-fischer/cache-decorators@4.0.1':
|
||||||
|
dependencies:
|
||||||
|
'@d-fischer/shared-utils': 3.6.4
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@d-fischer/connection@9.0.0':
|
||||||
|
dependencies:
|
||||||
|
'@d-fischer/isomorphic-ws': 7.0.2(ws@8.19.0)
|
||||||
|
'@d-fischer/logger': 4.2.4
|
||||||
|
'@d-fischer/shared-utils': 3.6.4
|
||||||
|
'@d-fischer/typed-event-emitter': 3.3.3
|
||||||
|
'@types/ws': 8.18.1
|
||||||
|
tslib: 2.8.1
|
||||||
|
ws: 8.19.0
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- bufferutil
|
||||||
|
- utf-8-validate
|
||||||
|
|
||||||
|
'@d-fischer/deprecate@2.0.2': {}
|
||||||
|
|
||||||
|
'@d-fischer/detect-node@3.0.1': {}
|
||||||
|
|
||||||
|
'@d-fischer/escape-string-regexp@5.0.0': {}
|
||||||
|
|
||||||
|
'@d-fischer/isomorphic-ws@7.0.2(ws@8.19.0)':
|
||||||
|
dependencies:
|
||||||
|
ws: 8.19.0
|
||||||
|
|
||||||
|
'@d-fischer/logger@4.2.4':
|
||||||
|
dependencies:
|
||||||
|
'@d-fischer/detect-node': 3.0.1
|
||||||
|
'@d-fischer/shared-utils': 3.6.4
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@d-fischer/rate-limiter@1.1.0':
|
||||||
|
dependencies:
|
||||||
|
'@d-fischer/logger': 4.2.4
|
||||||
|
'@d-fischer/shared-utils': 3.6.4
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@d-fischer/shared-utils@3.6.4':
|
||||||
|
dependencies:
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@d-fischer/typed-event-emitter@3.3.3':
|
||||||
|
dependencies:
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@discordjs/voice@0.19.0':
|
'@discordjs/voice@0.19.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/ws': 8.18.1
|
'@types/ws': 8.18.1
|
||||||
|
|
@ -8225,6 +8350,57 @@ snapshots:
|
||||||
|
|
||||||
'@tokenizer/token@0.3.0': {}
|
'@tokenizer/token@0.3.0': {}
|
||||||
|
|
||||||
|
'@twurple/api-call@8.0.3':
|
||||||
|
dependencies:
|
||||||
|
'@d-fischer/shared-utils': 3.6.4
|
||||||
|
'@twurple/common': 8.0.3
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@twurple/api@8.0.3(@twurple/auth@8.0.3)':
|
||||||
|
dependencies:
|
||||||
|
'@d-fischer/cache-decorators': 4.0.1
|
||||||
|
'@d-fischer/detect-node': 3.0.1
|
||||||
|
'@d-fischer/logger': 4.2.4
|
||||||
|
'@d-fischer/rate-limiter': 1.1.0
|
||||||
|
'@d-fischer/shared-utils': 3.6.4
|
||||||
|
'@d-fischer/typed-event-emitter': 3.3.3
|
||||||
|
'@twurple/api-call': 8.0.3
|
||||||
|
'@twurple/auth': 8.0.3
|
||||||
|
'@twurple/common': 8.0.3
|
||||||
|
retry: 0.13.1
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@twurple/auth@8.0.3':
|
||||||
|
dependencies:
|
||||||
|
'@d-fischer/logger': 4.2.4
|
||||||
|
'@d-fischer/shared-utils': 3.6.4
|
||||||
|
'@d-fischer/typed-event-emitter': 3.3.3
|
||||||
|
'@twurple/api-call': 8.0.3
|
||||||
|
'@twurple/common': 8.0.3
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@twurple/chat@8.0.3(@twurple/auth@8.0.3)':
|
||||||
|
dependencies:
|
||||||
|
'@d-fischer/cache-decorators': 4.0.1
|
||||||
|
'@d-fischer/deprecate': 2.0.2
|
||||||
|
'@d-fischer/logger': 4.2.4
|
||||||
|
'@d-fischer/rate-limiter': 1.1.0
|
||||||
|
'@d-fischer/shared-utils': 3.6.4
|
||||||
|
'@d-fischer/typed-event-emitter': 3.3.3
|
||||||
|
'@twurple/auth': 8.0.3
|
||||||
|
'@twurple/common': 8.0.3
|
||||||
|
ircv3: 0.33.0
|
||||||
|
tslib: 2.8.1
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- bufferutil
|
||||||
|
- utf-8-validate
|
||||||
|
|
||||||
|
'@twurple/common@8.0.3':
|
||||||
|
dependencies:
|
||||||
|
'@d-fischer/shared-utils': 3.6.4
|
||||||
|
klona: 2.0.6
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@tybys/wasm-util@0.10.1':
|
'@tybys/wasm-util@0.10.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
@ -9644,6 +9820,19 @@ snapshots:
|
||||||
'@reflink/reflink': 0.1.19
|
'@reflink/reflink': 0.1.19
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
ircv3@0.33.0:
|
||||||
|
dependencies:
|
||||||
|
'@d-fischer/connection': 9.0.0
|
||||||
|
'@d-fischer/escape-string-regexp': 5.0.0
|
||||||
|
'@d-fischer/logger': 4.2.4
|
||||||
|
'@d-fischer/shared-utils': 3.6.4
|
||||||
|
'@d-fischer/typed-event-emitter': 3.3.3
|
||||||
|
klona: 2.0.6
|
||||||
|
tslib: 2.8.1
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- bufferutil
|
||||||
|
- utf-8-validate
|
||||||
|
|
||||||
is-binary-path@2.1.0:
|
is-binary-path@2.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
binary-extensions: 2.3.0
|
binary-extensions: 2.3.0
|
||||||
|
|
@ -9814,6 +10003,8 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@keyv/serialize': 1.1.1
|
'@keyv/serialize': 1.1.1
|
||||||
|
|
||||||
|
klona@2.0.6: {}
|
||||||
|
|
||||||
leac@0.6.0: {}
|
leac@0.6.0: {}
|
||||||
|
|
||||||
lie@3.3.0:
|
lie@3.3.0:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue