simplify config object

This commit is contained in:
jaydenfyi 2026-01-25 18:27:50 +08:00
parent bb7b1f1a12
commit 80fc5babd1
14 changed files with 422 additions and 205 deletions

View File

@ -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.

View File

@ -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

View File

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

View File

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

View File

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

View File

@ -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();

View File

@ -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,

View File

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

View File

@ -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!
},
},
}, },
}, },
}; };

View File

@ -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,

View File

@ -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: "",
}, },
}, },
}, },

View File

@ -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;

View File

@ -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) */

View File

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