diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 000000000..54188b2fd --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(pnpm vitest:*)" + ] + } +} diff --git a/docs/channels/twitch.md b/docs/channels/twitch.md index 927491764..38f6a3eb8 100644 --- a/docs/channels/twitch.md +++ b/docs/channels/twitch.md @@ -6,10 +6,50 @@ read_when: --- # 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. +## 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::twitch:`. + +**Key distinction:** `username` is who the bot authenticates as (the bot's account), `channel` is which chat room it joins. + ## Plugin required Twitch ships as a plugin and is not bundled with the core install. @@ -30,31 +70,25 @@ Details: [Plugins](/plugin) ## Setup -1) Install the Twitch plugin: - - From npm: `clawdbot plugins install @clawdbot/twitch` - - From a local checkout: `clawdbot plugins install ./extensions/twitch` +### 1) Create a Twitch application +- Go to [Twitch Developer Console](https://dev.twitch.tv/console) +- Click "Register Your Application" +- Set Application Type to "Chat Bot" +- Copy the **Client ID** -2) Create a Twitch application: - - Go to [Twitch Developer Console](https://dev.twitch.tv/console) - - Click "Register Your Application" - - Set Application Type to "Chat Bot" - - Copy the Client ID +### 2) Generate your OAuth token (recommended: Twitch Token Generator) +- Use [Twitch Token Generator](https://twitchtokengenerator.com/) +- Select scopes: `chat:read` and `chat:write` +- Copy the token (starts with `oauth:`) -3) Generate your OAuth token: - - Use [Twitch Token Generator](https://twitchtokengenerator.com/) or [TwitchApps TMI](https://twitchapps.com/tmi/) - - Select scopes: `chat:read` and `chat:write` - - Copy the token (starts with `oauth:`) +### 3) Configure credentials +Env (default account only): -4) Configure credentials: - - 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) +```bash +export CLAWDBOT_TWITCH_ACCESS_TOKEN=oauth:your_token_here +``` -5) Start the gateway. Twitch starts when a token is resolved (config first, env fallback). - -6) Invite the bot to your channel (it will auto-join the channel specified in config). - -Minimal config (default account): +Or config: ```json5 { @@ -63,9 +97,10 @@ Minimal config (default account): enabled: true, accounts: { default: { - username: "mybot", - token: "oauth:your_token_here", - clientId: "your_client_id_here" + 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 } } } @@ -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 -export CLAWDBOT_TWITCH_ACCESS_TOKEN=oauth:your_token_here -``` +With env, you still need `clientId` and `channel` in config (or use the minimal config above without `token`). -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/): - -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: +For long-running bots, configure automatic token refresh to avoid expired tokens: 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: ```json5 @@ -121,7 +132,7 @@ For long-running bots, configure RefreshingAuthProvider: twitch: { accounts: { default: { - username: "mybot", + username: "clawdbot", token: "oauth:abc123...", clientId: "your_client_id", 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::twitch:`. -### 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 { channels: { twitch: { accounts: { - default: { - username: "mybot", + ninjaChannel: { + username: "clawdbot", token: "oauth:...", 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 +### Role-based restrictions (recommended) Restrict access to specific roles: @@ -192,9 +211,37 @@ Restrict access to specific roles: - `"subscriber"` - Subscribers - `"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 -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 { @@ -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 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 For the default account, you can use environment variables instead of config: @@ -282,7 +296,7 @@ Example: export CLAWDBOT_TWITCH_ACCESS_TOKEN=oauth:abc123def456... ``` -Config: +Config with env fallback: ```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). +## 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 First, run diagnostic commands: @@ -374,35 +407,18 @@ If you see "token refresh disabled (no refresh token)": - Ensure `clientSecret` is provided - Ensure `refreshToken` is provided (from Twitch Token Generator with "Include Refresh Token" checked) -## Capabilities & limits +## Configuration reference (Twitch) -**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) - -## Config reference +Full configuration: [Configuration](/gateway/configuration) ### Account config ```typescript { - username: string, // Bot username (required) - token: string, // OAuth token with chat:read and chat:write (required) - clientId: string, // Twitch Client ID (required) - channel?: string, // Channel to join (default: username) + username: string, // Bot username + token: string, // OAuth token with chat:read and chat:write + clientId: string, // Twitch Client ID + channel: string, // Channel to join enabled?: boolean, // Enable this account (default: true) clientSecret?: 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..username`: bot username. +- `channels.twitch.accounts..token`: OAuth token. +- `channels.twitch.accounts..clientId`: Twitch Client ID. +- `channels.twitch.accounts..channel`: channel to join. +- `channels.twitch.accounts..enabled`: enable/disable account (default: true). +- `channels.twitch.accounts..clientSecret`: for RefreshingAuthProvider. +- `channels.twitch.accounts..refreshToken`: for RefreshingAuthProvider. +- `channels.twitch.accounts..expiresIn`: token expiry in seconds. +- `channels.twitch.accounts..obtainmentTimestamp`: token obtained timestamp. +- `channels.twitch.accounts..allowFrom`: user ID allowlist. +- `channels.twitch.accounts..allowedRoles`: role-based access control. +- `channels.twitch.accounts..requireMention`: require @mention (default: false). + ## Tool actions The agent can call `twitch` with action: diff --git a/extensions/twitch/src/actions.ts b/extensions/twitch/src/actions.ts index 7f4ad63d4..9e7ade194 100644 --- a/extensions/twitch/src/actions.ts +++ b/extensions/twitch/src/actions.ts @@ -138,9 +138,8 @@ export const twitchMessageActions: ChannelMessageActionAdapter = { ); } - // Use the channel from account config if not specified - const targetChannel = to || account.channel || account.username; - + // 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"); } diff --git a/extensions/twitch/src/outbound.test.ts b/extensions/twitch/src/outbound.test.ts index dc663be78..b7b1d031c 100644 --- a/extensions/twitch/src/outbound.test.ts +++ b/extensions/twitch/src/outbound.test.ts @@ -219,7 +219,7 @@ describe("outbound", () => { it("should throw when no channel specified", async () => { 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); await expect( diff --git a/extensions/twitch/src/outbound.ts b/extensions/twitch/src/outbound.ts index a3df3e9d6..892cee621 100644 --- a/extensions/twitch/src/outbound.ts +++ b/extensions/twitch/src/outbound.ts @@ -124,8 +124,8 @@ export const twitchOutbound: ChannelOutboundAdapter = { ); } - // Get channel (support target parameter) - const channel = to || account.channel || account.username; + // Get channel (support target parameter override) + const channel = to || account.channel; if (!channel) { throw new Error("No channel specified and no default channel in account config"); } diff --git a/extensions/twitch/src/probe.test.ts b/extensions/twitch/src/probe.test.ts index ad985f460..7bfd07b5a 100644 --- a/extensions/twitch/src/probe.test.ts +++ b/extensions/twitch/src/probe.test.ts @@ -58,6 +58,7 @@ describe("probeTwitch", () => { const mockAccount: TwitchAccountConfig = { username: "testbot", token: "oauth:test123456789", + channel: "testchannel", }; beforeEach(() => { @@ -101,7 +102,7 @@ describe("probeTwitch", () => { expect(result.ok).toBe(true); expect(result.connected).toBe(true); 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); }); diff --git a/extensions/twitch/src/probe.ts b/extensions/twitch/src/probe.ts index 8f3cbfcb2..2a6ab9394 100644 --- a/extensions/twitch/src/probe.ts +++ b/extensions/twitch/src/probe.ts @@ -102,7 +102,7 @@ export async function probeTwitch( ok: true, connected: true, username: account.username, - channel: account.channel ?? account.username, + channel: account.channel, elapsedMs: Date.now() - started, }; } catch (error) { @@ -110,7 +110,7 @@ export async function probeTwitch( ok: false, error: error instanceof Error ? error.message : String(error), username: account.username, - channel: account.channel ?? account.username, + channel: account.channel, elapsedMs: Date.now() - started, }; } finally { diff --git a/extensions/twitch/src/send.test.ts b/extensions/twitch/src/send.test.ts index d1a76cfb9..541d4964d 100644 --- a/extensions/twitch/src/send.test.ts +++ b/extensions/twitch/src/send.test.ts @@ -163,13 +163,12 @@ describe("send", () => { const { getAccountConfig } = await import("./config.js"); const { isAccountConfigured } = await import("./utils/twitch.js"); - // Set both channel and username to undefined to trigger the error - const accountWithoutChannelOrUsername = { + // Set channel to undefined to trigger the error (bypassing type check) + const accountWithoutChannel = { ...mockAccount, - channel: undefined, - username: undefined, + channel: undefined as unknown as string, }; - vi.mocked(getAccountConfig).mockReturnValue(accountWithoutChannelOrUsername); + vi.mocked(getAccountConfig).mockReturnValue(accountWithoutChannel); vi.mocked(isAccountConfigured).mockReturnValue(true); const result = await sendMessageTwitchInternal( diff --git a/extensions/twitch/src/send.ts b/extensions/twitch/src/send.ts index 910a64ed8..77c3f5808 100644 --- a/extensions/twitch/src/send.ts +++ b/extensions/twitch/src/send.ts @@ -78,8 +78,8 @@ export async function sendMessageTwitchInternal( }; } - // Normalize channel - const normalizedChannel = channel || account.channel || account.username; + // Normalize channel (parameter override, then account channel) + const normalizedChannel = channel || account.channel; if (!normalizedChannel) { return { ok: false, diff --git a/extensions/twitch/src/twitch-client.ts b/extensions/twitch/src/twitch-client.ts index 2a0fc7bca..5407f86ca 100644 --- a/extensions/twitch/src/twitch-client.ts +++ b/extensions/twitch/src/twitch-client.ts @@ -125,12 +125,10 @@ export class TwitchClientManager { // Create auth provider const authProvider = await this.createAuthProvider(account, normalizedToken); - const channel = account.channel ?? account.username; - // Create chat client const client = new ChatClient({ authProvider, - channels: [channel], + channels: [account.channel], rejoinChannelsOnReconnect: true, requestMembershipEvents: true, logger: { @@ -301,7 +299,7 @@ export class TwitchClientManager { * Generate a unique key for an account */ public getAccountKey(account: TwitchAccountConfig): string { - return `${account.username}:${account.channel ?? account.username}`; + return `${account.username}:${account.channel}`; } /** diff --git a/extensions/twitch/src/types.ts b/extensions/twitch/src/types.ts index d2e1318f6..11106ee84 100644 --- a/extensions/twitch/src/types.ts +++ b/extensions/twitch/src/types.ts @@ -53,8 +53,8 @@ export interface TwitchAccountConfig { token: string; /** Twitch client ID (from Twitch Developer Portal or twitchtokengenerator.com) */ clientId?: string; - /** Channel name to join (defaults to username) */ - channel?: 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) */