simplify config object
This commit is contained in:
parent
bb7b1f1a12
commit
80fc5babd1
@ -11,9 +11,12 @@ Twitch chat support via IRC connection. Clawdbot connects as a Twitch user (bot
|
|||||||
Status: ready for Twitch chat via IRC connection with @twurple.
|
Status: ready for Twitch chat via IRC connection with @twurple.
|
||||||
|
|
||||||
## Quick setup (beginner)
|
## Quick setup (beginner)
|
||||||
1) Install the Twitch plugin and create a Twitch application.
|
1) Install the Twitch plugin.
|
||||||
2) Generate your OAuth token (recommended: use [Twitch Token Generator](https://twitchtokengenerator.com/)).
|
2) Generate your credentials (recommended: use [Twitch Token Generator](https://twitchtokengenerator.com/)):
|
||||||
3) Set the token for Clawdbot:
|
- Select **Bot Token**
|
||||||
|
- Verify scopes `chat:read` and `chat:write` are selected
|
||||||
|
- Copy the **Client ID** and **Access Token** (and optionally **Refresh Token**)
|
||||||
|
3) Set the credentials for Clawdbot:
|
||||||
- Env: `CLAWDBOT_TWITCH_ACCESS_TOKEN=...` (default account only)
|
- Env: `CLAWDBOT_TWITCH_ACCESS_TOKEN=...` (default account only)
|
||||||
- Or config: `channels.twitch.accounts.default.token`
|
- Or config: `channels.twitch.accounts.default.token`
|
||||||
- If both are set, config takes precedence (env fallback is default-account only).
|
- If both are set, config takes precedence (env fallback is default-account only).
|
||||||
@ -26,27 +29,28 @@ Minimal config:
|
|||||||
channels: {
|
channels: {
|
||||||
twitch: {
|
twitch: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
accounts: {
|
username: "clawdbot", // Bot's Twitch account
|
||||||
default: {
|
accessToken: "oauth:abc123...", // OAuth Access Token (or use CLAWDBOT_TWITCH_ACCESS_TOKEN env var)
|
||||||
username: "clawdbot", // Bot's Twitch account
|
clientId: "your_client_id", // Client ID from Token Generator
|
||||||
token: "oauth:abc123...", // Or omit to use CLAWDBOT_TWITCH_ACCESS_TOKEN env var
|
channel: "vevisk" // Which Twitch channel's chat to join
|
||||||
clientId: "your_client_id_here",
|
|
||||||
channel: "vevisk" // Which Twitch channel's chat to join
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Note:** [Twitch Token Generator](https://twitchtokengenerator.com/) provides the Client ID and Access Token (select **Bot Token** and verify `chat:read` + `chat:write` scopes) - no manual app registration needed.
|
||||||
|
|
||||||
**Note:** `username` is the bot's account, `channel` is which chat to join.
|
**Note:** `username` is the bot's account, `channel` is which chat to join.
|
||||||
|
|
||||||
|
**Multi-account setup:** Use `channels.twitch.accounts` for advanced multi-account configurations.
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
1. Create a Twitch application and bot account (or use an existing account).
|
1. Create a bot account (or use an existing Twitch account).
|
||||||
2. Configure Clawdbot with `channels.twitch.accounts.default.token` (or `CLAWDBOT_TWITCH_ACCESS_TOKEN` as a fallback).
|
2. Generate credentials using [Twitch Token Generator](https://twitchtokengenerator.com/) (provides Client ID, Access Token, and Refresh Token).
|
||||||
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`.
|
3. Configure Clawdbot with the credentials.
|
||||||
4. The bot joins the specified `channel` to send/receive messages.
|
4. 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`.
|
||||||
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>`.
|
5. The bot joins the specified `channel` to send/receive messages.
|
||||||
|
6. 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.
|
**Key distinction:** `username` is who the bot authenticates as (the bot's account), `channel` is which chat room it joins.
|
||||||
|
|
||||||
@ -70,22 +74,16 @@ Details: [Plugins](/plugin)
|
|||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
### 1) Create a Twitch application
|
### 1) Generate your credentials (recommended: Twitch Token Generator)
|
||||||
- Go to [Twitch Developer Console](https://dev.twitch.tv/console)
|
- Go to [Twitch Token Generator](https://twitchtokengenerator.com/)
|
||||||
- Click "Register Your Application"
|
- Select **Bot Token**
|
||||||
- Set Application Type to "Chat Bot"
|
- Verify scopes `chat:read` and `chat:write` are selected
|
||||||
- Copy the **Client ID**
|
- Copy the **Access Token** and **Client ID**
|
||||||
|
|
||||||
### 2) Generate your OAuth token (recommended: Twitch Token Generator)
|
### 2) Configure credentials
|
||||||
- Use [Twitch Token Generator](https://twitchtokengenerator.com/)
|
|
||||||
- Select scopes: `chat:read` and `chat:write`
|
|
||||||
- Copy the token (starts with `oauth:`)
|
|
||||||
|
|
||||||
### 3) Configure credentials
|
|
||||||
Env (default account only):
|
Env (default account only):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export CLAWDBOT_TWITCH_ACCESS_TOKEN=oauth:your_token_here
|
export CLAWDBOT_TWITCH_ACCESS_TOKEN=your_access_token_here
|
||||||
```
|
```
|
||||||
|
|
||||||
Or config:
|
Or config:
|
||||||
@ -98,8 +96,8 @@ Or config:
|
|||||||
accounts: {
|
accounts: {
|
||||||
default: {
|
default: {
|
||||||
username: "clawdbot", // Bot's Twitch account
|
username: "clawdbot", // Bot's Twitch account
|
||||||
token: "oauth:abc123...", // Or omit to use CLAWDBOT_TWITCH_ACCESS_TOKEN env var
|
accessToken: "oauth:abc123...", // Access Token from Token Generator (or omit to use env var)
|
||||||
clientId: "your_client_id_here",
|
clientId: "xyz789...", // Client ID from Token Generator
|
||||||
channel: "vevisk" // Which Twitch channel's chat to join
|
channel: "vevisk" // Which Twitch channel's chat to join
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -108,22 +106,25 @@ Or config:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Note:** `username` is the bot's account, `channel` is which chat to join.
|
**Note:** Copy the **Access Token** value to the `accessToken` property (add `oauth:` prefix if needed), and the **Client ID** value to the `clientId` property.
|
||||||
|
|
||||||
With env, you still need `clientId` and `channel` in config (or use the minimal config above without `token`).
|
With env, you still need `clientId` and `channel` in config (or use the minimal config above without `accessToken`).
|
||||||
|
|
||||||
### 4) Start the gateway
|
### 3) Start the gateway
|
||||||
Twitch starts when a token is resolved (config first, env fallback).
|
Twitch starts when a token is resolved (config first, env fallback).
|
||||||
|
|
||||||
### 5) Join a channel
|
### 4) Join a channel
|
||||||
The bot joins the channel specified in `channel`.
|
The bot joins the channel specified in `channel`.
|
||||||
|
|
||||||
## Token refresh (optional, recommended for long-running bots)
|
## Token refresh (optional, for long-running bots)
|
||||||
|
|
||||||
For long-running bots, configure automatic token refresh to avoid expired tokens:
|
**Important:** Tokens from [Twitch Token Generator](https://twitchtokengenerator.com/) cannot be automatically refreshed - you'll need to generate a new token when it expires (typically after several hours).
|
||||||
|
|
||||||
1. Use [Twitch Token Generator](https://twitchtokengenerator.com/) with **"Include Refresh Token"** checked
|
For automatic token refresh, you must create your own Twitch application:
|
||||||
2. Get your **Client Secret** from [Twitch Developer Console](https://dev.twitch.tv/console)
|
|
||||||
|
1. Create a Twitch application at [Twitch Developer Console](https://dev.twitch.tv/console)
|
||||||
|
- Copy the **Client ID** and **Client Secret**
|
||||||
|
2. Generate a refresh token using your own app (you'll need to implement the OAuth flow or use a tool that lets you specify your Client ID)
|
||||||
3. Add to config:
|
3. Add to config:
|
||||||
|
|
||||||
```json5
|
```json5
|
||||||
@ -133,10 +134,10 @@ For long-running bots, configure automatic token refresh to avoid expired tokens
|
|||||||
accounts: {
|
accounts: {
|
||||||
default: {
|
default: {
|
||||||
username: "clawdbot",
|
username: "clawdbot",
|
||||||
token: "oauth:abc123...",
|
accessToken: "oauth:abc123...", // Access Token from your app
|
||||||
clientId: "your_client_id",
|
clientId: "xyz789...", // Client ID from your app
|
||||||
clientSecret: "your_client_secret",
|
clientSecret: "secret123...", // Client Secret from your app
|
||||||
refreshToken: "your_refresh_token",
|
refreshToken: "refresh456...", // Refresh Token from your app
|
||||||
expiresIn: 14400,
|
expiresIn: 14400,
|
||||||
obtainmentTimestamp: 1706092800000
|
obtainmentTimestamp: 1706092800000
|
||||||
}
|
}
|
||||||
@ -146,6 +147,8 @@ For long-running bots, configure automatic token refresh to avoid expired tokens
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Note:** All three values (`accessToken`, `clientId`, `refreshToken`) must come from the same Twitch application you created.
|
||||||
|
|
||||||
The bot automatically refreshes tokens before they expire and logs refresh events.
|
The bot automatically refreshes tokens before they expire and logs refresh events.
|
||||||
|
|
||||||
## Routing model
|
## Routing model
|
||||||
@ -165,14 +168,14 @@ Example (one bot account in two different channels):
|
|||||||
accounts: {
|
accounts: {
|
||||||
ninjaChannel: {
|
ninjaChannel: {
|
||||||
username: "clawdbot",
|
username: "clawdbot",
|
||||||
token: "oauth:...",
|
accessToken: "oauth:abc123...",
|
||||||
clientId: "...",
|
clientId: "xyz789...",
|
||||||
channel: "vevisk"
|
channel: "vevisk"
|
||||||
},
|
},
|
||||||
shroudChannel: {
|
shroudChannel: {
|
||||||
username: "clawdbot",
|
username: "clawdbot",
|
||||||
token: "oauth:...",
|
accessToken: "oauth:def456...",
|
||||||
clientId: "...",
|
clientId: "uvw012...",
|
||||||
channel: "secondchannel"
|
channel: "secondchannel"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -181,6 +184,66 @@ Example (one bot account in two different channels):
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Migration notes
|
||||||
|
|
||||||
|
### Breaking changes (2026.1.23+)
|
||||||
|
|
||||||
|
**`token` renamed to `accessToken`:** If you have existing Twitch config using `token`, update to `accessToken`:
|
||||||
|
|
||||||
|
**Before:**
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
channels: {
|
||||||
|
twitch: {
|
||||||
|
accounts: {
|
||||||
|
default: {
|
||||||
|
username: "clawdbot",
|
||||||
|
token: "oauth:abc123...",
|
||||||
|
clientId: "xyz789...",
|
||||||
|
channel: "vevisk"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**After:**
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
channels: {
|
||||||
|
twitch: {
|
||||||
|
accounts: {
|
||||||
|
default: {
|
||||||
|
username: "clawdbot",
|
||||||
|
accessToken: "oauth:abc123...",
|
||||||
|
clientId: "xyz789...",
|
||||||
|
channel: "vevisk"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Simplified config (recommended):** For single-account setups, you can now use base-level properties:
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
channels: {
|
||||||
|
twitch: {
|
||||||
|
enabled: true,
|
||||||
|
username: "clawdbot",
|
||||||
|
accessToken: "oauth:abc123...",
|
||||||
|
clientId: "xyz789...",
|
||||||
|
channel: "vevisk"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The env var `CLAWDBOT_TWITCH_ACCESS_TOKEN` continues to work for the default account.
|
||||||
|
|
||||||
## Access control
|
## Access control
|
||||||
|
|
||||||
### Role-based restrictions (recommended)
|
### Role-based restrictions (recommended)
|
||||||
@ -194,8 +257,8 @@ Restrict access to specific roles:
|
|||||||
accounts: {
|
accounts: {
|
||||||
default: {
|
default: {
|
||||||
username: "mybot",
|
username: "mybot",
|
||||||
token: "oauth:...",
|
accessToken: "oauth:abc123...",
|
||||||
clientId: "...",
|
clientId: "xyz789...",
|
||||||
channel: "your_channel",
|
channel: "your_channel",
|
||||||
allowedRoles: ["moderator", "vip"]
|
allowedRoles: ["moderator", "vip"]
|
||||||
}
|
}
|
||||||
@ -223,8 +286,8 @@ Only allow specific Twitch user IDs (most secure):
|
|||||||
accounts: {
|
accounts: {
|
||||||
default: {
|
default: {
|
||||||
username: "mybot",
|
username: "mybot",
|
||||||
token: "oauth:...",
|
accessToken: "oauth:abc123...",
|
||||||
clientId: "...",
|
clientId: "xyz789...",
|
||||||
channel: "your_channel",
|
channel: "your_channel",
|
||||||
allowFrom: ["123456789", "987654321"]
|
allowFrom: ["123456789", "987654321"]
|
||||||
}
|
}
|
||||||
@ -252,8 +315,8 @@ Users in `allowFrom` bypass role checks. In this example:
|
|||||||
accounts: {
|
accounts: {
|
||||||
default: {
|
default: {
|
||||||
username: "mybot",
|
username: "mybot",
|
||||||
token: "oauth:...",
|
accessToken: "oauth:abc123...",
|
||||||
clientId: "...",
|
clientId: "xyz789...",
|
||||||
channel: "your_channel",
|
channel: "your_channel",
|
||||||
allowFrom: ["123456789"],
|
allowFrom: ["123456789"],
|
||||||
allowedRoles: ["moderator"]
|
allowedRoles: ["moderator"]
|
||||||
@ -275,8 +338,8 @@ Only respond when the bot is mentioned:
|
|||||||
accounts: {
|
accounts: {
|
||||||
default: {
|
default: {
|
||||||
username: "mybot",
|
username: "mybot",
|
||||||
token: "oauth:...",
|
accessToken: "oauth:abc123...",
|
||||||
clientId: "...",
|
clientId: "xyz789...",
|
||||||
channel: "your_channel",
|
channel: "your_channel",
|
||||||
requireMention: true
|
requireMention: true
|
||||||
}
|
}
|
||||||
@ -290,14 +353,14 @@ Only respond when the bot is mentioned:
|
|||||||
|
|
||||||
For the default account, you can use environment variables instead of config:
|
For the default account, you can use environment variables instead of config:
|
||||||
|
|
||||||
- `CLAWDBOT_TWITCH_ACCESS_TOKEN` - OAuth token (with `oauth:` prefix)
|
- `CLAWDBOT_TWITCH_ACCESS_TOKEN` - Access Token (without `oauth:` prefix)
|
||||||
|
|
||||||
Env fallback only works for the default account. For multi-account setups, use config.
|
Env fallback only works for the default account. For multi-account setups, use config.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export CLAWDBOT_TWITCH_ACCESS_TOKEN=oauth:abc123def456...
|
export CLAWDBOT_TWITCH_ACCESS_TOKEN=abc123def456...
|
||||||
```
|
```
|
||||||
|
|
||||||
Config with env fallback:
|
Config with env fallback:
|
||||||
@ -310,7 +373,7 @@ Config with env fallback:
|
|||||||
accounts: {
|
accounts: {
|
||||||
default: {
|
default: {
|
||||||
username: "mybot",
|
username: "mybot",
|
||||||
clientId: "your_client_id",
|
clientId: "xyz789...",
|
||||||
channel: "your_channel"
|
channel: "your_channel"
|
||||||
// token will be read from CLAWDBOT_TWITCH_ACCESS_TOKEN
|
// token will be read from CLAWDBOT_TWITCH_ACCESS_TOKEN
|
||||||
}
|
}
|
||||||
@ -380,8 +443,8 @@ clawdbot channels status --probe
|
|||||||
accounts: {
|
accounts: {
|
||||||
default: {
|
default: {
|
||||||
username: "mybot",
|
username: "mybot",
|
||||||
token: "oauth:...",
|
accessToken: "oauth:abc123...",
|
||||||
clientId: "...",
|
clientId: "xyz789...",
|
||||||
channel: "your_channel",
|
channel: "your_channel",
|
||||||
// Temporary: allow everyone
|
// Temporary: allow everyone
|
||||||
allowedRoles: ["all"]
|
allowedRoles: ["all"]
|
||||||
@ -397,7 +460,7 @@ clawdbot channels status --probe
|
|||||||
### Token issues
|
### Token issues
|
||||||
|
|
||||||
**"Failed to connect" or authentication errors:**
|
**"Failed to connect" or authentication errors:**
|
||||||
- Verify token starts with `oauth:`
|
- Verify `accessToken` is the OAuth access token value (typically starts with `oauth:` prefix)
|
||||||
- Check token has `chat:read` and `chat:write` scopes
|
- Check token has `chat:read` and `chat:write` scopes
|
||||||
- If using RefreshingAuthProvider, verify `clientSecret` and `refreshToken` are set
|
- If using RefreshingAuthProvider, verify `clientSecret` and `refreshToken` are set
|
||||||
|
|
||||||
@ -422,14 +485,14 @@ Full configuration: [Configuration](/gateway/configuration)
|
|||||||
```typescript
|
```typescript
|
||||||
{
|
{
|
||||||
username: string, // Bot username
|
username: string, // Bot username
|
||||||
token: string, // OAuth token with chat:read and chat:write
|
accessToken: string, // OAuth access token with chat:read and chat:write
|
||||||
clientId: string, // Twitch Client ID
|
clientId: string, // Twitch Client ID (from Token Generator site or your app)
|
||||||
channel: string, // Channel to join
|
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, // Optional: For automatic token refresh (from YOUR Twitch app)
|
||||||
refreshToken?: string, // For RefreshingAuthProvider
|
refreshToken?: string, // Optional: For automatic token refresh (from YOUR Twitch app)
|
||||||
expiresIn?: number, // Token expiry in seconds
|
expiresIn?: number, // Token expiry in seconds (for refresh)
|
||||||
obtainmentTimestamp?: number, // Token obtained timestamp
|
obtainmentTimestamp?: number, // Token obtained timestamp (for refresh)
|
||||||
allowFrom?: string[], // User ID allowlist
|
allowFrom?: string[], // User ID allowlist
|
||||||
allowedRoles?: TwitchRole[], // Role-based access control
|
allowedRoles?: TwitchRole[], // Role-based access control
|
||||||
requireMention?: boolean // Require @mention (default: false)
|
requireMention?: boolean // Require @mention (default: false)
|
||||||
@ -448,13 +511,17 @@ Full configuration: [Configuration](/gateway/configuration)
|
|||||||
|
|
||||||
Provider options:
|
Provider options:
|
||||||
- `channels.twitch.enabled`: enable/disable channel startup.
|
- `channels.twitch.enabled`: enable/disable channel startup.
|
||||||
- `channels.twitch.accounts.<accountName>.username`: bot username.
|
- `channels.twitch.username`: bot username (simplified single-account config).
|
||||||
- `channels.twitch.accounts.<accountName>.token`: OAuth token.
|
- `channels.twitch.accessToken`: OAuth access token (simplified single-account config).
|
||||||
- `channels.twitch.accounts.<accountName>.clientId`: Twitch Client ID.
|
- `channels.twitch.clientId`: Twitch Client ID (simplified single-account config).
|
||||||
|
- `channels.twitch.channel`: channel to join (simplified single-account config).
|
||||||
|
- `channels.twitch.accounts.<accountName>.username`: bot username (multi-account config).
|
||||||
|
- `channels.twitch.accounts.<accountName>.accessToken`: OAuth access token (multi-account config).
|
||||||
|
- `channels.twitch.accounts.<accountName>.clientId`: Twitch Client ID (from Token Generator or your app).
|
||||||
- `channels.twitch.accounts.<accountName>.channel`: channel to join.
|
- `channels.twitch.accounts.<accountName>.channel`: channel to join.
|
||||||
- `channels.twitch.accounts.<accountName>.enabled`: enable/disable account (default: true).
|
- `channels.twitch.accounts.<accountName>.enabled`: enable/disable account (default: true).
|
||||||
- `channels.twitch.accounts.<accountName>.clientSecret`: for RefreshingAuthProvider.
|
- `channels.twitch.accounts.<accountName>.clientSecret`: optional, for automatic token refresh (must be from YOUR Twitch app).
|
||||||
- `channels.twitch.accounts.<accountName>.refreshToken`: for RefreshingAuthProvider.
|
- `channels.twitch.accounts.<accountName>.refreshToken`: optional, for automatic token refresh (must be from YOUR Twitch app).
|
||||||
- `channels.twitch.accounts.<accountName>.expiresIn`: token expiry in seconds.
|
- `channels.twitch.accounts.<accountName>.expiresIn`: token expiry in seconds.
|
||||||
- `channels.twitch.accounts.<accountName>.obtainmentTimestamp`: token obtained timestamp.
|
- `channels.twitch.accounts.<accountName>.obtainmentTimestamp`: token obtained timestamp.
|
||||||
- `channels.twitch.accounts.<accountName>.allowFrom`: user ID allowlist.
|
- `channels.twitch.accounts.<accountName>.allowFrom`: user ID allowlist.
|
||||||
|
|||||||
@ -18,7 +18,23 @@ Onboarding: select Twitch and confirm the install prompt to fetch the plugin aut
|
|||||||
|
|
||||||
## Config
|
## Config
|
||||||
|
|
||||||
Minimal config:
|
Minimal config (simplified single-account):
|
||||||
|
|
||||||
|
```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
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Multi-account config (advanced):
|
||||||
|
|
||||||
```json5
|
```json5
|
||||||
{
|
{
|
||||||
@ -28,10 +44,16 @@ Minimal config:
|
|||||||
accounts: {
|
accounts: {
|
||||||
default: {
|
default: {
|
||||||
username: "clawdbot",
|
username: "clawdbot",
|
||||||
token: "oauth:abc123...",
|
accessToken: "oauth:abc123...",
|
||||||
clientId: "your_client_id_here",
|
clientId: "xyz789...",
|
||||||
channel: "vevisk",
|
channel: "vevisk",
|
||||||
},
|
},
|
||||||
|
channel2: {
|
||||||
|
username: "clawdbot",
|
||||||
|
accessToken: "oauth:def456...",
|
||||||
|
clientId: "uvw012...",
|
||||||
|
channel: "secondchannel",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -40,10 +62,12 @@ Minimal config:
|
|||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
1. Create a Twitch application: [Twitch Developer Console](https://dev.twitch.tv/console)
|
1. Generate credentials: [Twitch Token Generator](https://twitchtokengenerator.com/)
|
||||||
2. Generate OAuth token: [Twitch Token Generator](https://twitchtokengenerator.com/)
|
- Select **Bot Token**
|
||||||
- Select scopes: `chat:read` and `chat:write`
|
- Verify scopes `chat:read` and `chat:write` are selected
|
||||||
3. Start the gateway
|
- Copy the **Access Token** to `token` property
|
||||||
|
- Copy the **Client ID** to `clientId` property
|
||||||
|
2. Start the gateway
|
||||||
|
|
||||||
## Full documentation
|
## Full documentation
|
||||||
|
|
||||||
|
|||||||
@ -12,8 +12,8 @@ const TwitchRoleSchema = z.enum(["moderator", "owner", "vip", "subscriber", "all
|
|||||||
const TwitchAccountSchema = z.object({
|
const TwitchAccountSchema = z.object({
|
||||||
/** Twitch username */
|
/** Twitch username */
|
||||||
username: z.string(),
|
username: z.string(),
|
||||||
/** Twitch OAuth token (requires chat:read and chat:write scopes) */
|
/** Twitch OAuth access token (requires chat:read and chat:write scopes) */
|
||||||
token: z.string(),
|
accessToken: z.string(),
|
||||||
/** Twitch client ID (from Twitch Developer Portal or twitchtokengenerator.com) */
|
/** Twitch client ID (from Twitch Developer Portal or twitchtokengenerator.com) */
|
||||||
clientId: z.string().optional(),
|
clientId: z.string().optional(),
|
||||||
/** Channel name to join (defaults to username) */
|
/** Channel name to join (defaults to username) */
|
||||||
@ -37,12 +37,48 @@ const TwitchAccountSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Twitch plugin configuration schema
|
* Simplified single-account configuration schema
|
||||||
|
*
|
||||||
|
* Use this for single-account setups. Properties are at the top level,
|
||||||
|
* creating an implicit "default" account.
|
||||||
*/
|
*/
|
||||||
export const TwitchConfigSchema = z.object({
|
const simplifiedSchema = z.intersection(
|
||||||
name: z.string().optional(),
|
z.object({
|
||||||
enabled: z.boolean().optional(),
|
name: z.string().optional(),
|
||||||
markdown: MarkdownConfigSchema.optional(),
|
enabled: z.boolean().optional(),
|
||||||
/** Per-account configuration */
|
markdown: MarkdownConfigSchema.optional(),
|
||||||
accounts: z.record(z.string(), TwitchAccountSchema).optional(),
|
}),
|
||||||
});
|
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(
|
||||||
|
z.object({
|
||||||
|
name: z.string().optional(),
|
||||||
|
enabled: z.boolean().optional(),
|
||||||
|
markdown: MarkdownConfigSchema.optional(),
|
||||||
|
}),
|
||||||
|
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]);
|
||||||
|
|||||||
@ -30,28 +30,55 @@ describe("parsePluginConfig", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("getAccountConfig", () => {
|
describe("getAccountConfig", () => {
|
||||||
const mockCoreConfig = {
|
const mockMultiAccountConfig = {
|
||||||
channels: {
|
channels: {
|
||||||
twitch: {
|
twitch: {
|
||||||
accounts: {
|
accounts: {
|
||||||
default: {
|
default: {
|
||||||
username: "testbot",
|
username: "testbot",
|
||||||
token: "oauth:test123",
|
accessToken: "oauth:test123",
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
username: "secondbot",
|
||||||
|
accessToken: "oauth:secondary",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
it("returns account config for valid account ID", () => {
|
const mockSimplifiedConfig = {
|
||||||
const result = getAccountConfig(mockCoreConfig, "default");
|
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).not.toBeNull();
|
||||||
expect(result?.username).toBe("testbot");
|
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", () => {
|
it("returns null for non-existent account ID", () => {
|
||||||
const result = getAccountConfig(mockCoreConfig, "nonexistent");
|
const result = getAccountConfig(mockMultiAccountConfig, "nonexistent");
|
||||||
|
|
||||||
expect(result).toBeNull();
|
expect(result).toBeNull();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -8,6 +8,13 @@ export const DEFAULT_ACCOUNT_ID = "default";
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get account config from core config
|
* 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(
|
export function getAccountConfig(
|
||||||
coreConfig: unknown,
|
coreConfig: unknown,
|
||||||
@ -22,6 +29,50 @@ export function getAccountConfig(
|
|||||||
const twitch = channels?.twitch as Record<string, unknown> | undefined;
|
const twitch = channels?.twitch as Record<string, unknown> | undefined;
|
||||||
const accounts = twitch?.accounts as Record<string, unknown> | undefined;
|
const accounts = twitch?.accounts as Record<string, unknown> | undefined;
|
||||||
|
|
||||||
|
// For default account, check base-level config first
|
||||||
|
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||||
|
const accountFromAccounts = accounts?.[DEFAULT_ACCOUNT_ID] as
|
||||||
|
| Record<string, unknown>
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
// Base-level properties that can form an implicit default account
|
||||||
|
const baseLevel = {
|
||||||
|
username: typeof twitch?.username === "string" ? twitch.username : undefined,
|
||||||
|
accessToken: typeof twitch?.accessToken === "string" ? twitch.accessToken : undefined,
|
||||||
|
clientId: typeof twitch?.clientId === "string" ? twitch.clientId : undefined,
|
||||||
|
channel: typeof twitch?.channel === "string" ? twitch.channel : undefined,
|
||||||
|
enabled: typeof twitch?.enabled === "boolean" ? twitch.enabled : undefined,
|
||||||
|
allowFrom: Array.isArray(twitch?.allowFrom) ? twitch.allowFrom : undefined,
|
||||||
|
allowedRoles: Array.isArray(twitch?.allowedRoles) ? twitch.allowedRoles : undefined,
|
||||||
|
requireMention:
|
||||||
|
typeof twitch?.requireMention === "boolean" ? twitch.requireMention : undefined,
|
||||||
|
clientSecret: typeof twitch?.clientSecret === "string" ? twitch.clientSecret : undefined,
|
||||||
|
refreshToken: typeof twitch?.refreshToken === "string" ? twitch.refreshToken : undefined,
|
||||||
|
expiresIn: typeof twitch?.expiresIn === "number" ? twitch.expiresIn : undefined,
|
||||||
|
obtainmentTimestamp:
|
||||||
|
typeof twitch?.obtainmentTimestamp === "number" ? twitch.obtainmentTimestamp : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Merge: base-level takes precedence over accounts.default
|
||||||
|
const merged = {
|
||||||
|
...accountFromAccounts,
|
||||||
|
...baseLevel,
|
||||||
|
} as Record<string, unknown>;
|
||||||
|
|
||||||
|
// Only return if we have at least username
|
||||||
|
if (merged.username) {
|
||||||
|
return merged as unknown as TwitchAccountConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall through to accounts.default if no base-level username
|
||||||
|
if (accountFromAccounts) {
|
||||||
|
return accountFromAccounts as unknown as TwitchAccountConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For non-default accounts, only check accounts object
|
||||||
if (!accounts || !accounts[accountId]) {
|
if (!accounts || !accounts[accountId]) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@ -45,15 +96,31 @@ export function parsePluginConfig(value: unknown): TwitchPluginConfig {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* List all configured account IDs
|
* List all configured account IDs
|
||||||
|
*
|
||||||
|
* Includes both explicit accounts and implicit "default" from base-level config
|
||||||
*/
|
*/
|
||||||
export function listAccountIds(cfg: ClawdbotConfig): string[] {
|
export function listAccountIds(cfg: ClawdbotConfig): string[] {
|
||||||
const accounts = (cfg as Record<string, unknown>).channels as Record<string, unknown> | undefined;
|
const accounts = (cfg as Record<string, unknown>).channels as Record<string, unknown> | undefined;
|
||||||
const twitch = accounts?.twitch as Record<string, unknown> | undefined;
|
const twitch = accounts?.twitch as Record<string, unknown> | undefined;
|
||||||
const accountMap = twitch?.accounts as Record<string, unknown> | undefined;
|
const accountMap = twitch?.accounts as Record<string, unknown> | undefined;
|
||||||
|
|
||||||
if (!accountMap) {
|
const ids: string[] = [];
|
||||||
return [];
|
|
||||||
|
// Add explicit accounts
|
||||||
|
if (accountMap) {
|
||||||
|
ids.push(...Object.keys(accountMap));
|
||||||
}
|
}
|
||||||
|
|
||||||
return Object.keys(accountMap);
|
// Add implicit "default" if base-level config exists and "default" not already present
|
||||||
|
const hasBaseLevelConfig =
|
||||||
|
twitch &&
|
||||||
|
(typeof twitch.username === "string" ||
|
||||||
|
typeof twitch.accessToken === "string" ||
|
||||||
|
typeof twitch.channel === "string");
|
||||||
|
|
||||||
|
if (hasBaseLevelConfig && !ids.includes(DEFAULT_ACCOUNT_ID)) {
|
||||||
|
ids.push(DEFAULT_ACCOUNT_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ids;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -25,7 +25,7 @@ const mockPrompter: WizardPrompter = {
|
|||||||
|
|
||||||
const mockAccount: TwitchAccountConfig = {
|
const mockAccount: TwitchAccountConfig = {
|
||||||
username: "testbot",
|
username: "testbot",
|
||||||
token: "oauth:test123",
|
accessToken: "oauth:test123",
|
||||||
clientId: "test-client-id",
|
clientId: "test-client-id",
|
||||||
channel: "#testchannel",
|
channel: "#testchannel",
|
||||||
};
|
};
|
||||||
@ -49,7 +49,7 @@ describe("onboarding helpers", () => {
|
|||||||
|
|
||||||
expect(result).toBe("oauth:test123");
|
expect(result).toBe("oauth:test123");
|
||||||
expect(mockPromptConfirm).toHaveBeenCalledWith({
|
expect(mockPromptConfirm).toHaveBeenCalledWith({
|
||||||
message: "Token already configured. Keep it?",
|
message: "Access token already configured. Keep it?",
|
||||||
initialValue: true,
|
initialValue: true,
|
||||||
});
|
});
|
||||||
expect(mockPromptText).not.toHaveBeenCalled();
|
expect(mockPromptText).not.toHaveBeenCalled();
|
||||||
|
|||||||
@ -26,7 +26,7 @@ function setTwitchAccount(
|
|||||||
const existing = getAccountConfig(cfg, DEFAULT_ACCOUNT_ID);
|
const existing = getAccountConfig(cfg, DEFAULT_ACCOUNT_ID);
|
||||||
const merged: TwitchAccountConfig = {
|
const merged: TwitchAccountConfig = {
|
||||||
username: account.username ?? existing?.username ?? "",
|
username: account.username ?? existing?.username ?? "",
|
||||||
token: account.token ?? existing?.token ?? "",
|
accessToken: account.accessToken ?? existing?.accessToken ?? "",
|
||||||
clientId: account.clientId ?? existing?.clientId ?? "",
|
clientId: account.clientId ?? existing?.clientId ?? "",
|
||||||
channel: account.channel ?? existing?.channel ?? "",
|
channel: account.channel ?? existing?.channel ?? "",
|
||||||
enabled: account.enabled ?? existing?.enabled ?? true,
|
enabled: account.enabled ?? existing?.enabled ?? true,
|
||||||
@ -85,12 +85,12 @@ async function promptToken(
|
|||||||
account: TwitchAccountConfig | null,
|
account: TwitchAccountConfig | null,
|
||||||
envToken: string | undefined,
|
envToken: string | undefined,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const existingToken = account?.token ?? "";
|
const existingToken = account?.accessToken ?? "";
|
||||||
|
|
||||||
// If we have an existing token and no env var, ask if we should keep it
|
// If we have an existing token and no env var, ask if we should keep it
|
||||||
if (existingToken && !envToken) {
|
if (existingToken && !envToken) {
|
||||||
const keepToken = await prompter.confirm({
|
const keepToken = await prompter.confirm({
|
||||||
message: "Token already configured. Keep it?",
|
message: "Access token already configured. Keep it?",
|
||||||
initialValue: true,
|
initialValue: true,
|
||||||
});
|
});
|
||||||
if (keepToken) {
|
if (keepToken) {
|
||||||
@ -225,7 +225,7 @@ async function configureWithEnvToken(
|
|||||||
const cfgWithAccount = setTwitchAccount(cfg, {
|
const cfgWithAccount = setTwitchAccount(cfg, {
|
||||||
username,
|
username,
|
||||||
clientId,
|
clientId,
|
||||||
token: "", // Will use env var
|
accessToken: "", // Will use env var
|
||||||
enabled: true,
|
enabled: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -275,7 +275,7 @@ const dmPolicy: ChannelOnboardingDmPolicy = {
|
|||||||
return setTwitchAccessControl(cfg as ClawdbotConfig, allowedRoles, false);
|
return setTwitchAccessControl(cfg as ClawdbotConfig, allowedRoles, false);
|
||||||
},
|
},
|
||||||
promptAllowFrom: async ({ cfg, prompter }) => {
|
promptAllowFrom: async ({ cfg, prompter }) => {
|
||||||
const account = getTwitchAccount(cfg as ClawdbotConfig);
|
const account = getAccountConfig(cfg, DEFAULT_ACCOUNT_ID);
|
||||||
const existingAllowFrom = account?.allowFrom ?? [];
|
const existingAllowFrom = account?.allowFrom ?? [];
|
||||||
|
|
||||||
const entry = await prompter.text({
|
const entry = await prompter.text({
|
||||||
@ -319,7 +319,7 @@ export const twitchOnboardingAdapter: ChannelOnboardingAdapter = {
|
|||||||
const envToken = process.env.CLAWDBOT_TWITCH_ACCESS_TOKEN?.trim();
|
const envToken = process.env.CLAWDBOT_TWITCH_ACCESS_TOKEN?.trim();
|
||||||
|
|
||||||
// Check if env var is set and config is empty
|
// Check if env var is set and config is empty
|
||||||
if (envToken && !account?.token) {
|
if (envToken && !account?.accessToken) {
|
||||||
const envResult = await configureWithEnvToken(
|
const envResult = await configureWithEnvToken(
|
||||||
cfg,
|
cfg,
|
||||||
prompter,
|
prompter,
|
||||||
@ -342,7 +342,7 @@ export const twitchOnboardingAdapter: ChannelOnboardingAdapter = {
|
|||||||
|
|
||||||
const cfgWithAccount = setTwitchAccount(cfg, {
|
const cfgWithAccount = setTwitchAccount(cfg, {
|
||||||
username,
|
username,
|
||||||
token,
|
accessToken: token,
|
||||||
clientId,
|
clientId,
|
||||||
channel: channelName,
|
channel: channelName,
|
||||||
clientSecret,
|
clientSecret,
|
||||||
|
|||||||
@ -86,7 +86,7 @@ export const twitchPlugin: ChannelPlugin<TwitchAccountConfig> = {
|
|||||||
// Return a default/empty account if not configured
|
// Return a default/empty account if not configured
|
||||||
return {
|
return {
|
||||||
username: "",
|
username: "",
|
||||||
token: "",
|
accessToken: "",
|
||||||
clientId: "",
|
clientId: "",
|
||||||
enabled: false,
|
enabled: false,
|
||||||
} as TwitchAccountConfig;
|
} as TwitchAccountConfig;
|
||||||
@ -112,7 +112,7 @@ export const twitchPlugin: ChannelPlugin<TwitchAccountConfig> = {
|
|||||||
return {
|
return {
|
||||||
accountId: DEFAULT_ACCOUNT_ID,
|
accountId: DEFAULT_ACCOUNT_ID,
|
||||||
enabled: account?.enabled !== false,
|
enabled: account?.enabled !== false,
|
||||||
configured: account ? isAccountConfigured(account, account?.token) : false,
|
configured: account ? isAccountConfigured(account, account?.accessToken) : false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@ -50,7 +50,7 @@ describe("status", () => {
|
|||||||
expect(disabledIssue).toBeDefined();
|
expect(disabledIssue).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should detect missing clientId when account configured", () => {
|
it("should detect missing clientId when account configured (simplified config)", () => {
|
||||||
const snapshots: ChannelAccountSnapshot[] = [
|
const snapshots: ChannelAccountSnapshot[] = [
|
||||||
{
|
{
|
||||||
accountId: "default",
|
accountId: "default",
|
||||||
@ -63,13 +63,9 @@ describe("status", () => {
|
|||||||
const mockCfg = {
|
const mockCfg = {
|
||||||
channels: {
|
channels: {
|
||||||
twitch: {
|
twitch: {
|
||||||
accounts: {
|
username: "testbot",
|
||||||
default: {
|
accessToken: "oauth:test123",
|
||||||
username: "testbot",
|
// clientId missing
|
||||||
token: "oauth:test123",
|
|
||||||
// clientId missing
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@ -80,7 +76,7 @@ describe("status", () => {
|
|||||||
expect(clientIdIssue).toBeDefined();
|
expect(clientIdIssue).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should warn about oauth: prefix in token", () => {
|
it("should warn about oauth: prefix in token (simplified config)", () => {
|
||||||
const snapshots: ChannelAccountSnapshot[] = [
|
const snapshots: ChannelAccountSnapshot[] = [
|
||||||
{
|
{
|
||||||
accountId: "default",
|
accountId: "default",
|
||||||
@ -93,13 +89,9 @@ describe("status", () => {
|
|||||||
const mockCfg = {
|
const mockCfg = {
|
||||||
channels: {
|
channels: {
|
||||||
twitch: {
|
twitch: {
|
||||||
accounts: {
|
username: "testbot",
|
||||||
default: {
|
accessToken: "oauth:test123", // has prefix
|
||||||
username: "testbot",
|
clientId: "test-id",
|
||||||
token: "oauth:test123", // has prefix
|
|
||||||
clientId: "test-id",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@ -111,7 +103,7 @@ describe("status", () => {
|
|||||||
expect(prefixIssue?.kind).toBe("config");
|
expect(prefixIssue?.kind).toBe("config");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should detect clientSecret without refreshToken", () => {
|
it("should detect clientSecret without refreshToken (simplified config)", () => {
|
||||||
const snapshots: ChannelAccountSnapshot[] = [
|
const snapshots: ChannelAccountSnapshot[] = [
|
||||||
{
|
{
|
||||||
accountId: "default",
|
accountId: "default",
|
||||||
@ -124,15 +116,11 @@ describe("status", () => {
|
|||||||
const mockCfg = {
|
const mockCfg = {
|
||||||
channels: {
|
channels: {
|
||||||
twitch: {
|
twitch: {
|
||||||
accounts: {
|
username: "testbot",
|
||||||
default: {
|
accessToken: "oauth:test123",
|
||||||
username: "testbot",
|
clientId: "test-id",
|
||||||
token: "oauth:test123",
|
clientSecret: "secret123",
|
||||||
clientId: "test-id",
|
// refreshToken missing
|
||||||
clientSecret: "secret123",
|
|
||||||
// refreshToken missing
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@ -143,7 +131,7 @@ describe("status", () => {
|
|||||||
expect(secretIssue).toBeDefined();
|
expect(secretIssue).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should detect empty allowFrom array", () => {
|
it("should detect empty allowFrom array (simplified config)", () => {
|
||||||
const snapshots: ChannelAccountSnapshot[] = [
|
const snapshots: ChannelAccountSnapshot[] = [
|
||||||
{
|
{
|
||||||
accountId: "default",
|
accountId: "default",
|
||||||
@ -156,14 +144,10 @@ describe("status", () => {
|
|||||||
const mockCfg = {
|
const mockCfg = {
|
||||||
channels: {
|
channels: {
|
||||||
twitch: {
|
twitch: {
|
||||||
accounts: {
|
username: "testbot",
|
||||||
default: {
|
accessToken: "test123",
|
||||||
username: "testbot",
|
clientId: "test-id",
|
||||||
token: "test123",
|
allowFrom: [], // empty array
|
||||||
clientId: "test-id",
|
|
||||||
allowFrom: [], // empty array
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@ -174,7 +158,7 @@ describe("status", () => {
|
|||||||
expect(allowFromIssue).toBeDefined();
|
expect(allowFromIssue).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should detect allowedRoles 'all' with allowFrom conflict", () => {
|
it("should detect allowedRoles 'all' with allowFrom conflict (simplified config)", () => {
|
||||||
const snapshots: ChannelAccountSnapshot[] = [
|
const snapshots: ChannelAccountSnapshot[] = [
|
||||||
{
|
{
|
||||||
accountId: "default",
|
accountId: "default",
|
||||||
@ -187,15 +171,11 @@ describe("status", () => {
|
|||||||
const mockCfg = {
|
const mockCfg = {
|
||||||
channels: {
|
channels: {
|
||||||
twitch: {
|
twitch: {
|
||||||
accounts: {
|
username: "testbot",
|
||||||
default: {
|
accessToken: "test123",
|
||||||
username: "testbot",
|
clientId: "test-id",
|
||||||
token: "test123",
|
allowedRoles: ["all"],
|
||||||
clientId: "test-id",
|
allowFrom: ["123456"], // conflict!
|
||||||
allowedRoles: ["all"],
|
|
||||||
allowFrom: ["123456"], // conflict!
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -56,7 +56,7 @@ export function collectTwitchStatusIssues(
|
|||||||
accountId,
|
accountId,
|
||||||
kind: "config",
|
kind: "config",
|
||||||
message: "Twitch account is not properly configured",
|
message: "Twitch account is not properly configured",
|
||||||
fix: "Add required fields: username, token, and clientId to your account configuration",
|
fix: "Add required fields: username, accessToken, and clientId to your account configuration",
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -72,7 +72,7 @@ export function collectTwitchStatusIssues(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (account && account.username && account.token && !account.clientId) {
|
if (account && account.username && account.accessToken && !account.clientId) {
|
||||||
issues.push({
|
issues.push({
|
||||||
channel: "twitch",
|
channel: "twitch",
|
||||||
accountId,
|
accountId,
|
||||||
@ -86,7 +86,7 @@ export function collectTwitchStatusIssues(
|
|||||||
? resolveTwitchToken(cfg as Parameters<typeof resolveTwitchToken>[0], { accountId })
|
? resolveTwitchToken(cfg as Parameters<typeof resolveTwitchToken>[0], { accountId })
|
||||||
: { token: "", source: "none" };
|
: { token: "", source: "none" };
|
||||||
if (account && isAccountConfigured(account, tokenResolution.token)) {
|
if (account && isAccountConfigured(account, tokenResolution.token)) {
|
||||||
if (account.token?.startsWith("oauth:")) {
|
if (account.accessToken?.startsWith("oauth:")) {
|
||||||
issues.push({
|
issues.push({
|
||||||
channel: "twitch",
|
channel: "twitch",
|
||||||
accountId,
|
accountId,
|
||||||
|
|||||||
@ -13,23 +13,34 @@ import { resolveTwitchToken, type TwitchTokenSource } from "./token.js";
|
|||||||
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
describe("token", () => {
|
describe("token", () => {
|
||||||
const mockConfig = {
|
// Multi-account config for testing non-default accounts
|
||||||
|
const mockMultiAccountConfig = {
|
||||||
channels: {
|
channels: {
|
||||||
twitch: {
|
twitch: {
|
||||||
accounts: {
|
accounts: {
|
||||||
default: {
|
default: {
|
||||||
username: "testbot",
|
username: "testbot",
|
||||||
token: "oauth:config-token",
|
accessToken: "oauth:config-token",
|
||||||
},
|
},
|
||||||
other: {
|
other: {
|
||||||
username: "otherbot",
|
username: "otherbot",
|
||||||
token: "oauth:other-token",
|
accessToken: "oauth:other-token",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as unknown as ClawdbotConfig;
|
} as unknown as ClawdbotConfig;
|
||||||
|
|
||||||
|
// Simplified single-account config
|
||||||
|
const mockSimplifiedConfig = {
|
||||||
|
channels: {
|
||||||
|
twitch: {
|
||||||
|
username: "testbot",
|
||||||
|
accessToken: "oauth:config-token",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as ClawdbotConfig;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
@ -40,42 +51,38 @@ describe("token", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("resolveTwitchToken", () => {
|
describe("resolveTwitchToken", () => {
|
||||||
it("should resolve token from config for default account", () => {
|
it("should resolve token from simplified config for default account", () => {
|
||||||
const result = resolveTwitchToken(mockConfig, { accountId: "default" });
|
const result = resolveTwitchToken(mockSimplifiedConfig, { accountId: "default" });
|
||||||
|
|
||||||
expect(result.token).toBe("oauth:config-token");
|
expect(result.token).toBe("oauth:config-token");
|
||||||
expect(result.source).toBe("config");
|
expect(result.source).toBe("config");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should resolve token from config for non-default account", () => {
|
it("should resolve token from config for non-default account (multi-account)", () => {
|
||||||
const result = resolveTwitchToken(mockConfig, { accountId: "other" });
|
const result = resolveTwitchToken(mockMultiAccountConfig, { accountId: "other" });
|
||||||
|
|
||||||
expect(result.token).toBe("oauth:other-token");
|
expect(result.token).toBe("oauth:other-token");
|
||||||
expect(result.source).toBe("config");
|
expect(result.source).toBe("config");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should prioritize config token over env var", () => {
|
it("should prioritize config token over env var (simplified config)", () => {
|
||||||
process.env.CLAWDBOT_TWITCH_ACCESS_TOKEN = "oauth:env-token";
|
process.env.CLAWDBOT_TWITCH_ACCESS_TOKEN = "oauth:env-token";
|
||||||
|
|
||||||
const result = resolveTwitchToken(mockConfig, { accountId: "default" });
|
const result = resolveTwitchToken(mockSimplifiedConfig, { accountId: "default" });
|
||||||
|
|
||||||
// Config token should be used even if env var exists
|
// Config token should be used even if env var exists
|
||||||
expect(result.token).toBe("oauth:config-token");
|
expect(result.token).toBe("oauth:config-token");
|
||||||
expect(result.source).toBe("config");
|
expect(result.source).toBe("config");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should use env var when config token is empty", () => {
|
it("should use env var when config token is empty (simplified config)", () => {
|
||||||
process.env.CLAWDBOT_TWITCH_ACCESS_TOKEN = "oauth:env-token";
|
process.env.CLAWDBOT_TWITCH_ACCESS_TOKEN = "oauth:env-token";
|
||||||
|
|
||||||
const configWithEmptyToken = {
|
const configWithEmptyToken = {
|
||||||
channels: {
|
channels: {
|
||||||
twitch: {
|
twitch: {
|
||||||
accounts: {
|
username: "testbot",
|
||||||
default: {
|
accessToken: "",
|
||||||
username: "testbot",
|
|
||||||
token: "",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as unknown as ClawdbotConfig;
|
} as unknown as ClawdbotConfig;
|
||||||
@ -86,16 +93,12 @@ describe("token", () => {
|
|||||||
expect(result.source).toBe("env");
|
expect(result.source).toBe("env");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return empty token when neither config nor env has token", () => {
|
it("should return empty token when neither config nor env has token (simplified config)", () => {
|
||||||
const configWithoutToken = {
|
const configWithoutToken = {
|
||||||
channels: {
|
channels: {
|
||||||
twitch: {
|
twitch: {
|
||||||
accounts: {
|
username: "testbot",
|
||||||
default: {
|
accessToken: "",
|
||||||
username: "testbot",
|
|
||||||
token: "",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as unknown as ClawdbotConfig;
|
} as unknown as ClawdbotConfig;
|
||||||
@ -106,7 +109,7 @@ describe("token", () => {
|
|||||||
expect(result.source).toBe("none");
|
expect(result.source).toBe("none");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should not use env var for non-default accounts", () => {
|
it("should not use env var for non-default accounts (multi-account)", () => {
|
||||||
process.env.CLAWDBOT_TWITCH_ACCESS_TOKEN = "oauth:env-token";
|
process.env.CLAWDBOT_TWITCH_ACCESS_TOKEN = "oauth:env-token";
|
||||||
|
|
||||||
const configWithoutToken = {
|
const configWithoutToken = {
|
||||||
@ -115,7 +118,7 @@ describe("token", () => {
|
|||||||
accounts: {
|
accounts: {
|
||||||
secondary: {
|
secondary: {
|
||||||
username: "secondary",
|
username: "secondary",
|
||||||
token: "",
|
accessToken: "",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,8 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* Twitch token resolution with environment variable support.
|
* Twitch access token resolution with environment variable support.
|
||||||
*
|
*
|
||||||
* Supports reading Twitch OAuth tokens from config or environment variable.
|
* 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.
|
* 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 type { ClawdbotConfig } from "../../../src/config/config.js";
|
||||||
@ -27,12 +31,14 @@ function normalizeTwitchToken(raw?: string | null): string | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve Twitch token from config or environment variable.
|
* Resolve Twitch access token from config or environment variable.
|
||||||
*
|
*
|
||||||
* Priority:
|
* Priority:
|
||||||
* 1. Account token: channels.twitch.accounts.{accountId}.token
|
* 1. Account access token (from merged config - base-level for default, or accounts.{accountId})
|
||||||
* 2. Base config token: channels.twitch.token (default account only)
|
* 2. Environment variable: CLAWDBOT_TWITCH_ACCESS_TOKEN (default account only)
|
||||||
* 3. 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 cfg - Clawdbot config
|
||||||
* @param opts - Options including accountId and optional envToken override
|
* @param opts - Options including accountId and optional envToken override
|
||||||
@ -43,26 +49,33 @@ export function resolveTwitchToken(
|
|||||||
opts: { accountId?: string | null; envToken?: string | null } = {},
|
opts: { accountId?: string | null; envToken?: string | null } = {},
|
||||||
): TwitchTokenResolution {
|
): TwitchTokenResolution {
|
||||||
const accountId = normalizeAccountId(opts.accountId);
|
const accountId = normalizeAccountId(opts.accountId);
|
||||||
|
|
||||||
|
// Get merged account config (handles both simplified and multi-account patterns)
|
||||||
const twitchCfg = cfg?.channels?.twitch;
|
const twitchCfg = cfg?.channels?.twitch;
|
||||||
const accountCfg =
|
const accountCfg =
|
||||||
accountId !== DEFAULT_ACCOUNT_ID
|
accountId === DEFAULT_ACCOUNT_ID
|
||||||
? twitchCfg?.accounts?.[accountId]
|
? (twitchCfg?.accounts?.[DEFAULT_ACCOUNT_ID] as Record<string, unknown> | undefined)
|
||||||
: twitchCfg?.accounts?.[DEFAULT_ACCOUNT_ID];
|
: (twitchCfg?.accounts?.[accountId as string] as Record<string, unknown> | undefined);
|
||||||
|
|
||||||
// 1. Account token (highest priority)
|
// For default account, also check base-level config
|
||||||
const accountToken = normalizeTwitchToken(accountCfg?.token ?? undefined);
|
let token: string | undefined;
|
||||||
if (accountToken) {
|
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||||
return { token: accountToken, source: "config" };
|
// 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Base config token (default account only)
|
if (token) {
|
||||||
|
return { token, source: "config" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Environment variable (default account only)
|
||||||
const allowEnv = accountId === DEFAULT_ACCOUNT_ID;
|
const allowEnv = accountId === DEFAULT_ACCOUNT_ID;
|
||||||
const configToken = allowEnv ? normalizeTwitchToken(twitchCfg?.token ?? undefined) : undefined;
|
|
||||||
if (configToken) {
|
|
||||||
return { token: configToken, source: "config" };
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Environment variable (default account only)
|
|
||||||
const envToken = allowEnv
|
const envToken = allowEnv
|
||||||
? normalizeTwitchToken(opts.envToken ?? process.env.CLAWDBOT_TWITCH_ACCESS_TOKEN)
|
? normalizeTwitchToken(opts.envToken ?? process.env.CLAWDBOT_TWITCH_ACCESS_TOKEN)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|||||||
@ -49,8 +49,8 @@ export interface TwitchPluginConfig {
|
|||||||
export interface TwitchAccountConfig {
|
export interface TwitchAccountConfig {
|
||||||
/** Twitch username */
|
/** Twitch username */
|
||||||
username: string;
|
username: string;
|
||||||
/** Twitch OAuth token (requires chat:read and chat:write scopes) */
|
/** Twitch OAuth access token (requires chat:read and chat:write scopes) */
|
||||||
token: string;
|
accessToken: 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 (required) */
|
/** Channel name to join (required) */
|
||||||
|
|||||||
@ -68,11 +68,11 @@ export function normalizeToken(token: string): string {
|
|||||||
export function isAccountConfigured(
|
export function isAccountConfigured(
|
||||||
account: {
|
account: {
|
||||||
username?: string;
|
username?: string;
|
||||||
token?: string;
|
accessToken?: string;
|
||||||
clientId?: string;
|
clientId?: string;
|
||||||
},
|
},
|
||||||
resolvedToken?: string | null,
|
resolvedToken?: string | null,
|
||||||
): boolean {
|
): boolean {
|
||||||
const token = resolvedToken ?? account?.token;
|
const token = resolvedToken ?? account?.accessToken;
|
||||||
return Boolean(account?.username && token && account?.clientId);
|
return Boolean(account?.username && token && account?.clientId);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user