update docs and remove bot channel fallback

This commit is contained in:
jaydenfyi 2026-01-25 15:25:32 +08:00
parent d9093572ac
commit 0412ee0aba
11 changed files with 185 additions and 150 deletions

View File

@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(pnpm vitest:*)"
]
}
}

View File

@ -6,10 +6,50 @@ read_when:
--- ---
# Twitch (plugin) # Twitch (plugin)
Twitch chat support via IRC connection. Clawdbot connects as a Twitch user (bot account) to receive and send messages in channels. Requires a Twitch application with OAuth token. Twitch chat support via IRC connection. Clawdbot connects as a Twitch user (bot account) to receive and send messages in channels.
Status: ready for Twitch chat via IRC connection with @twurple. Status: ready for Twitch chat via IRC connection with @twurple.
## Quick setup (beginner)
1) Install the Twitch plugin and create a Twitch application.
2) Generate your OAuth token (recommended: use [Twitch Token Generator](https://twitchtokengenerator.com/)).
3) Set the token for Clawdbot:
- Env: `CLAWDBOT_TWITCH_ACCESS_TOKEN=...` (default account only)
- Or config: `channels.twitch.accounts.default.token`
- If both are set, config takes precedence (env fallback is default-account only).
4) Start the gateway.
5) The bot joins your channel and responds to messages.
Minimal config:
```json5
{
channels: {
twitch: {
enabled: true,
accounts: {
default: {
username: "clawdbot", // Bot's Twitch account
token: "oauth:abc123...", // Or omit to use CLAWDBOT_TWITCH_ACCESS_TOKEN env var
clientId: "your_client_id_here",
channel: "vevisk" // Which Twitch channel's chat to join
}
}
}
}
}
```
**Note:** `username` is the bot's account, `channel` is which chat to join.
## How it works
1. Create a Twitch application and bot account (or use an existing account).
2. Configure Clawdbot with `channels.twitch.accounts.default.token` (or `CLAWDBOT_TWITCH_ACCESS_TOKEN` as a fallback).
3. Run the gateway; it auto-starts the Twitch channel when a token is available (config first, env fallback) and `channels.twitch.enabled` is not `false`.
4. The bot joins the specified `channel` to send/receive messages.
5. Direct chats collapse into the agent's main session (default `agent:main:main`); each account maps to an isolated session key `agent:<agentId>:twitch:<accountName>`.
**Key distinction:** `username` is who the bot authenticates as (the bot's account), `channel` is which chat room it joins.
## Plugin required ## Plugin required
Twitch ships as a plugin and is not bundled with the core install. Twitch ships as a plugin and is not bundled with the core install.
@ -30,31 +70,25 @@ Details: [Plugins](/plugin)
## Setup ## Setup
1) Install the Twitch plugin: ### 1) Create a Twitch application
- From npm: `clawdbot plugins install @clawdbot/twitch` - Go to [Twitch Developer Console](https://dev.twitch.tv/console)
- From a local checkout: `clawdbot plugins install ./extensions/twitch` - Click "Register Your Application"
- Set Application Type to "Chat Bot"
- Copy the **Client ID**
2) Create a Twitch application: ### 2) Generate your OAuth token (recommended: Twitch Token Generator)
- Go to [Twitch Developer Console](https://dev.twitch.tv/console) - Use [Twitch Token Generator](https://twitchtokengenerator.com/)
- Click "Register Your Application" - Select scopes: `chat:read` and `chat:write`
- Set Application Type to "Chat Bot" - Copy the token (starts with `oauth:`)
- Copy the Client ID
3) Generate your OAuth token: ### 3) Configure credentials
- Use [Twitch Token Generator](https://twitchtokengenerator.com/) or [TwitchApps TMI](https://twitchapps.com/tmi/) Env (default account only):
- Select scopes: `chat:read` and `chat:write`
- Copy the token (starts with `oauth:`)
4) Configure credentials: ```bash
- Env: `CLAWDBOT_TWITCH_ACCESS_TOKEN=...` (default account only) export CLAWDBOT_TWITCH_ACCESS_TOKEN=oauth:your_token_here
- Or config: `channels.twitch.accounts.default.token` ```
- If both are set, config takes precedence (env fallback is default-account only)
5) Start the gateway. Twitch starts when a token is resolved (config first, env fallback). Or config:
6) Invite the bot to your channel (it will auto-join the channel specified in config).
Minimal config (default account):
```json5 ```json5
{ {
@ -63,9 +97,10 @@ Minimal config (default account):
enabled: true, enabled: true,
accounts: { accounts: {
default: { default: {
username: "mybot", username: "clawdbot", // Bot's Twitch account
token: "oauth:your_token_here", token: "oauth:abc123...", // Or omit to use CLAWDBOT_TWITCH_ACCESS_TOKEN env var
clientId: "your_client_id_here" clientId: "your_client_id_here",
channel: "vevisk" // Which Twitch channel's chat to join
} }
} }
} }
@ -73,46 +108,22 @@ Minimal config (default account):
} }
``` ```
Env-only setup: **Note:** `username` is the bot's account, `channel` is which chat to join.
```bash With env, you still need `clientId` and `channel` in config (or use the minimal config above without `token`).
export CLAWDBOT_TWITCH_ACCESS_TOKEN=oauth:your_token_here
```
With env, you still need `clientId` in config (or use the minimal config above without `token`). ### 4) Start the gateway
Twitch starts when a token is resolved (config first, env fallback).
## Token setup ### 5) Join a channel
The bot joins the channel specified in `channel`.
### Option 1: Static token (simple) ## Token refresh (optional, recommended for long-running bots)
Use [Twitch Token Generator](https://twitchtokengenerator.com/): For long-running bots, configure automatic token refresh to avoid expired tokens:
1. Select scopes: `chat:read` and `chat:write`
2. Copy the token (starts with `oauth:`)
3. Add to config:
```json5
{
channels: {
twitch: {
accounts: {
default: {
username: "mybot",
token: "oauth:abc123...",
clientId: "your_client_id"
}
}
}
}
}
```
### Option 2: Automatic token refresh (recommended)
For long-running bots, configure RefreshingAuthProvider:
1. Use [Twitch Token Generator](https://twitchtokengenerator.com/) with **"Include Refresh Token"** checked 1. Use [Twitch Token Generator](https://twitchtokengenerator.com/) with **"Include Refresh Token"** checked
2. Get your Client Secret from [Twitch Developer Console](https://dev.twitch.tv/console) 2. Get your **Client Secret** from [Twitch Developer Console](https://dev.twitch.tv/console)
3. Add to config: 3. Add to config:
```json5 ```json5
@ -121,7 +132,7 @@ For long-running bots, configure RefreshingAuthProvider:
twitch: { twitch: {
accounts: { accounts: {
default: { default: {
username: "mybot", username: "clawdbot",
token: "oauth:abc123...", token: "oauth:abc123...",
clientId: "your_client_id", clientId: "your_client_id",
clientSecret: "your_client_secret", clientSecret: "your_client_secret",
@ -135,24 +146,34 @@ For long-running bots, configure RefreshingAuthProvider:
} }
``` ```
The bot will automatically refresh tokens before they expire and log refresh events. The bot automatically refreshes tokens before they expire and logs refresh events.
## Access control ## Routing model
- Replies always go back to Twitch.
- Each account maps to `agent:<agentId>:twitch:<accountName>`.
### Allowlist by User ID (recommended) ## Multi-account support
Most secure: only allow specific Twitch user IDs: Use `channels.twitch.accounts` with per-account tokens and optional `name`. See [`gateway/configuration`](/gateway/configuration#telegramaccounts--discordaccounts--slackaccounts--signalaccounts--imessageaccounts) for the shared pattern.
Example (one bot account in two different channels):
```json5 ```json5
{ {
channels: { channels: {
twitch: { twitch: {
accounts: { accounts: {
default: { ninjaChannel: {
username: "mybot", username: "clawdbot",
token: "oauth:...", token: "oauth:...",
clientId: "...", clientId: "...",
allowFrom: ["123456789", "987654321"] channel: "vevisk"
},
shroudChannel: {
username: "clawdbot",
token: "oauth:...",
clientId: "...",
channel: "secondchannel"
} }
} }
} }
@ -160,11 +181,9 @@ Most secure: only allow specific Twitch user IDs:
} }
``` ```
**Why user IDs instead of usernames?** Twitch usernames can change, which could allow someone to hijack another user's access. User IDs are permanent. ## Access control
Find your Twitch user ID at: https://www.streamweasels.com/tools/convert-your-twitch-username-to-user-id/ ### Role-based restrictions (recommended)
### Role-based restrictions
Restrict access to specific roles: Restrict access to specific roles:
@ -192,9 +211,37 @@ Restrict access to specific roles:
- `"subscriber"` - Subscribers - `"subscriber"` - Subscribers
- `"all"` - Anyone in chat - `"all"` - Anyone in chat
### Allowlist by User ID
Only allow specific Twitch user IDs (most secure):
```json5
{
channels: {
twitch: {
accounts: {
default: {
username: "mybot",
token: "oauth:...",
clientId: "...",
allowFrom: ["123456789", "987654321"]
}
}
}
}
}
```
**Why user IDs instead of usernames?** Twitch usernames can change, which could allow someone to hijack another user's access. User IDs are permanent.
Find your Twitch user ID at: https://www.streamweasels.com/tools/convert-your-twitch-username-to-user-id/
### Combined allowlist + roles ### Combined allowlist + roles
Users in `allowFrom` bypass role checks: Users in `allowFrom` bypass role checks. In this example:
- User `123456789` can always message (bypasses role check)
- All moderators can message
- Everyone else is blocked
```json5 ```json5
{ {
@ -214,11 +261,6 @@ Users in `allowFrom` bypass role checks:
} }
``` ```
In this example:
- User `123456789` can always message (bypasses role check)
- All moderators can message
- Everyone else is blocked
### Require @mention ### Require @mention
Only respond when the bot is mentioned: Only respond when the bot is mentioned:
@ -240,34 +282,6 @@ Only respond when the bot is mentioned:
} }
``` ```
## Multiple accounts
Configure multiple Twitch channels:
```json5
{
channels: {
twitch: {
accounts: {
main: {
username: "mybot",
token: "oauth:...",
clientId: "...",
channel: "streamer1"
},
secondary: {
username: "mybot",
token: "oauth:...",
clientId: "...",
channel: "streamer2",
allowedRoles: ["moderator"]
}
}
}
}
}
```
## Environment variables ## Environment variables
For the default account, you can use environment variables instead of config: For the default account, you can use environment variables instead of config:
@ -282,7 +296,7 @@ Example:
export CLAWDBOT_TWITCH_ACCESS_TOKEN=oauth:abc123def456... export CLAWDBOT_TWITCH_ACCESS_TOKEN=oauth:abc123def456...
``` ```
Config: Config with env fallback:
```json5 ```json5
{ {
@ -323,6 +337,25 @@ Control markdown stripping behavior:
Twitch doesn't support markdown, so this is enabled by default. Disable if you want to send markdown as-is (it will appear as plain text with markdown symbols). Twitch doesn't support markdown, so this is enabled by default. Disable if you want to send markdown as-is (it will appear as plain text with markdown symbols).
## Capabilities & limits
**Supported:**
- ✅ Channel messages (group chat)
- ✅ Whispers/DMs (received but replies not supported - Twitch doesn't allow bots to send whispers)
- ✅ Markdown stripping (automatically applied)
- ✅ Message chunking (500 char limit)
- ✅ Access control (user ID allowlist, role-based)
- ✅ @mention requirement
- ✅ Automatic token refresh (with RefreshingAuthProvider)
- ✅ Multi-account support
**Not supported:**
- ❌ Native reactions
- ❌ Threaded replies
- ❌ Message editing
- ❌ Message deletion
- ❌ Rich embeds/media uploads (sends media URLs as text)
## Troubleshooting ## Troubleshooting
First, run diagnostic commands: First, run diagnostic commands:
@ -374,35 +407,18 @@ If you see "token refresh disabled (no refresh token)":
- Ensure `clientSecret` is provided - Ensure `clientSecret` is provided
- Ensure `refreshToken` is provided (from Twitch Token Generator with "Include Refresh Token" checked) - Ensure `refreshToken` is provided (from Twitch Token Generator with "Include Refresh Token" checked)
## Capabilities & limits ## Configuration reference (Twitch)
**Supported:** Full configuration: [Configuration](/gateway/configuration)
- ✅ Channel messages (group chat)
- ✅ Whispers/DMs (received but replies not supported - Twitch doesn't allow bots to send whispers)
- ✅ Markdown stripping (automatically applied)
- ✅ Message chunking (500 char limit)
- ✅ Access control (user ID allowlist, role-based)
- ✅ @mention requirement
- ✅ Automatic token refresh (with RefreshingAuthProvider)
- ✅ Multi-account support
**Not supported:**
- ❌ Native reactions
- ❌ Threaded replies
- ❌ Message editing
- ❌ Message deletion
- ❌ Rich embeds/media uploads (sends media URLs as text)
## Config reference
### Account config ### Account config
```typescript ```typescript
{ {
username: string, // Bot username (required) username: string, // Bot username
token: string, // OAuth token with chat:read and chat:write (required) token: string, // OAuth token with chat:read and chat:write
clientId: string, // Twitch Client ID (required) clientId: string, // Twitch Client ID
channel?: string, // Channel to join (default: username) channel: string, // Channel to join
enabled?: boolean, // Enable this account (default: true) enabled?: boolean, // Enable this account (default: true)
clientSecret?: string, // For RefreshingAuthProvider clientSecret?: string, // For RefreshingAuthProvider
refreshToken?: string, // For RefreshingAuthProvider refreshToken?: string, // For RefreshingAuthProvider
@ -424,6 +440,21 @@ If you see "token refresh disabled (no refresh token)":
} }
``` ```
Provider options:
- `channels.twitch.enabled`: enable/disable channel startup.
- `channels.twitch.accounts.<accountName>.username`: bot username.
- `channels.twitch.accounts.<accountName>.token`: OAuth token.
- `channels.twitch.accounts.<accountName>.clientId`: Twitch Client ID.
- `channels.twitch.accounts.<accountName>.channel`: channel to join.
- `channels.twitch.accounts.<accountName>.enabled`: enable/disable account (default: true).
- `channels.twitch.accounts.<accountName>.clientSecret`: for RefreshingAuthProvider.
- `channels.twitch.accounts.<accountName>.refreshToken`: for RefreshingAuthProvider.
- `channels.twitch.accounts.<accountName>.expiresIn`: token expiry in seconds.
- `channels.twitch.accounts.<accountName>.obtainmentTimestamp`: token obtained timestamp.
- `channels.twitch.accounts.<accountName>.allowFrom`: user ID allowlist.
- `channels.twitch.accounts.<accountName>.allowedRoles`: role-based access control.
- `channels.twitch.accounts.<accountName>.requireMention`: require @mention (default: false).
## Tool actions ## Tool actions
The agent can call `twitch` with action: The agent can call `twitch` with action:

View File

@ -138,9 +138,8 @@ export const twitchMessageActions: ChannelMessageActionAdapter = {
); );
} }
// Use the channel from account config if not specified // Use the channel from account config (or override with `to` parameter)
const targetChannel = to || account.channel || account.username; const targetChannel = to || account.channel;
if (!targetChannel) { if (!targetChannel) {
return errorResponse("No channel specified and no default channel in account config"); return errorResponse("No channel specified and no default channel in account config");
} }

View File

@ -219,7 +219,7 @@ describe("outbound", () => {
it("should throw when no channel specified", async () => { it("should throw when no channel specified", async () => {
const { getAccountConfig } = await import("./config.js"); const { getAccountConfig } = await import("./config.js");
const accountWithoutChannel = { ...mockAccount, channel: undefined, username: undefined }; const accountWithoutChannel = { ...mockAccount, channel: undefined as unknown as string };
vi.mocked(getAccountConfig).mockReturnValue(accountWithoutChannel); vi.mocked(getAccountConfig).mockReturnValue(accountWithoutChannel);
await expect( await expect(

View File

@ -124,8 +124,8 @@ export const twitchOutbound: ChannelOutboundAdapter = {
); );
} }
// Get channel (support target parameter) // Get channel (support target parameter override)
const channel = to || account.channel || account.username; const channel = to || account.channel;
if (!channel) { if (!channel) {
throw new Error("No channel specified and no default channel in account config"); throw new Error("No channel specified and no default channel in account config");
} }

View File

@ -58,6 +58,7 @@ describe("probeTwitch", () => {
const mockAccount: TwitchAccountConfig = { const mockAccount: TwitchAccountConfig = {
username: "testbot", username: "testbot",
token: "oauth:test123456789", token: "oauth:test123456789",
channel: "testchannel",
}; };
beforeEach(() => { beforeEach(() => {
@ -101,7 +102,7 @@ describe("probeTwitch", () => {
expect(result.ok).toBe(true); expect(result.ok).toBe(true);
expect(result.connected).toBe(true); expect(result.connected).toBe(true);
expect(result.username).toBe("testbot"); expect(result.username).toBe("testbot");
expect(result.channel).toBe("testbot"); // defaults to username expect(result.channel).toBe("testchannel"); // uses account's configured channel
expect(result.elapsedMs).toBeGreaterThan(0); expect(result.elapsedMs).toBeGreaterThan(0);
}); });

View File

@ -102,7 +102,7 @@ export async function probeTwitch(
ok: true, ok: true,
connected: true, connected: true,
username: account.username, username: account.username,
channel: account.channel ?? account.username, channel: account.channel,
elapsedMs: Date.now() - started, elapsedMs: Date.now() - started,
}; };
} catch (error) { } catch (error) {
@ -110,7 +110,7 @@ export async function probeTwitch(
ok: false, ok: false,
error: error instanceof Error ? error.message : String(error), error: error instanceof Error ? error.message : String(error),
username: account.username, username: account.username,
channel: account.channel ?? account.username, channel: account.channel,
elapsedMs: Date.now() - started, elapsedMs: Date.now() - started,
}; };
} finally { } finally {

View File

@ -163,13 +163,12 @@ describe("send", () => {
const { getAccountConfig } = await import("./config.js"); const { getAccountConfig } = await import("./config.js");
const { isAccountConfigured } = await import("./utils/twitch.js"); const { isAccountConfigured } = await import("./utils/twitch.js");
// Set both channel and username to undefined to trigger the error // Set channel to undefined to trigger the error (bypassing type check)
const accountWithoutChannelOrUsername = { const accountWithoutChannel = {
...mockAccount, ...mockAccount,
channel: undefined, channel: undefined as unknown as string,
username: undefined,
}; };
vi.mocked(getAccountConfig).mockReturnValue(accountWithoutChannelOrUsername); vi.mocked(getAccountConfig).mockReturnValue(accountWithoutChannel);
vi.mocked(isAccountConfigured).mockReturnValue(true); vi.mocked(isAccountConfigured).mockReturnValue(true);
const result = await sendMessageTwitchInternal( const result = await sendMessageTwitchInternal(

View File

@ -78,8 +78,8 @@ export async function sendMessageTwitchInternal(
}; };
} }
// Normalize channel // Normalize channel (parameter override, then account channel)
const normalizedChannel = channel || account.channel || account.username; const normalizedChannel = channel || account.channel;
if (!normalizedChannel) { if (!normalizedChannel) {
return { return {
ok: false, ok: false,

View File

@ -125,12 +125,10 @@ export class TwitchClientManager {
// Create auth provider // Create auth provider
const authProvider = await this.createAuthProvider(account, normalizedToken); const authProvider = await this.createAuthProvider(account, normalizedToken);
const channel = account.channel ?? account.username;
// Create chat client // Create chat client
const client = new ChatClient({ const client = new ChatClient({
authProvider, authProvider,
channels: [channel], channels: [account.channel],
rejoinChannelsOnReconnect: true, rejoinChannelsOnReconnect: true,
requestMembershipEvents: true, requestMembershipEvents: true,
logger: { logger: {
@ -301,7 +299,7 @@ export class TwitchClientManager {
* Generate a unique key for an account * Generate a unique key for an account
*/ */
public getAccountKey(account: TwitchAccountConfig): string { public getAccountKey(account: TwitchAccountConfig): string {
return `${account.username}:${account.channel ?? account.username}`; return `${account.username}:${account.channel}`;
} }
/** /**

View File

@ -53,8 +53,8 @@ export interface TwitchAccountConfig {
token: string; token: string;
/** Twitch client ID (from Twitch Developer Portal or twitchtokengenerator.com) */ /** Twitch client ID (from Twitch Developer Portal or twitchtokengenerator.com) */
clientId?: string; clientId?: string;
/** Channel name to join (defaults to username) */ /** Channel name to join (required) */
channel?: string; channel: string;
/** Enable this account */ /** Enable this account */
enabled?: boolean; enabled?: boolean;
/** Allowlist of Twitch user IDs who can interact with the bot (use IDs for safety, not usernames) */ /** Allowlist of Twitch user IDs who can interact with the bot (use IDs for safety, not usernames) */