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.
## Quick setup (beginner)
1) Install the Twitch plugin and create a Twitch application.
2) Generate your OAuth token (recommended: use [Twitch Token Generator](https://twitchtokengenerator.com/)).
3) Set the token for Clawdbot:
1) Install the Twitch plugin.
2) Generate your credentials (recommended: use [Twitch Token Generator](https://twitchtokengenerator.com/)):
- Select **Bot Token**
- Verify scopes `chat:read` and `chat:write` are selected
- Copy the **Client ID** and **Access Token** (and optionally **Refresh Token**)
3) Set the credentials for Clawdbot:
- Env: `CLAWDBOT_TWITCH_ACCESS_TOKEN=...` (default account only)
- Or config: `channels.twitch.accounts.default.token`
- If both are set, config takes precedence (env fallback is default-account only).
@ -26,27 +29,28 @@ Minimal config:
channels: {
twitch: {
enabled: true,
accounts: {
default: {
username: "clawdbot", // Bot's Twitch account
token: "oauth:abc123...", // Or omit to use CLAWDBOT_TWITCH_ACCESS_TOKEN env var
clientId: "your_client_id_here",
channel: "vevisk" // Which Twitch channel's chat to join
}
}
username: "clawdbot", // Bot's Twitch account
accessToken: "oauth:abc123...", // OAuth Access Token (or use CLAWDBOT_TWITCH_ACCESS_TOKEN env var)
clientId: "your_client_id", // Client ID from Token Generator
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.
**Multi-account setup:** Use `channels.twitch.accounts` for advanced multi-account configurations.
## How it works
1. Create a Twitch application and bot account (or use an existing account).
2. Configure Clawdbot with `channels.twitch.accounts.default.token` (or `CLAWDBOT_TWITCH_ACCESS_TOKEN` as a fallback).
3. Run the gateway; it auto-starts the Twitch channel when a token is available (config first, env fallback) and `channels.twitch.enabled` is not `false`.
4. The bot joins the specified `channel` to send/receive messages.
5. Direct chats collapse into the agent's main session (default `agent:main:main`); each account maps to an isolated session key `agent:<agentId>:twitch:<accountName>`.
1. Create a bot account (or use an existing Twitch account).
2. Generate credentials using [Twitch Token Generator](https://twitchtokengenerator.com/) (provides Client ID, Access Token, and Refresh Token).
3. Configure Clawdbot with the credentials.
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. 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.
@ -70,22 +74,16 @@ Details: [Plugins](/plugin)
## Setup
### 1) Create a Twitch application
- Go to [Twitch Developer Console](https://dev.twitch.tv/console)
- Click "Register Your Application"
- Set Application Type to "Chat Bot"
- Copy the **Client ID**
### 1) Generate your credentials (recommended: Twitch Token Generator)
- Go to [Twitch Token Generator](https://twitchtokengenerator.com/)
- Select **Bot Token**
- Verify scopes `chat:read` and `chat:write` are selected
- Copy the **Access Token** and **Client ID**
### 2) Generate your OAuth token (recommended: Twitch Token Generator)
- Use [Twitch Token Generator](https://twitchtokengenerator.com/)
- Select scopes: `chat:read` and `chat:write`
- Copy the token (starts with `oauth:`)
### 3) Configure credentials
### 2) Configure credentials
Env (default account only):
```bash
export CLAWDBOT_TWITCH_ACCESS_TOKEN=oauth:your_token_here
export CLAWDBOT_TWITCH_ACCESS_TOKEN=your_access_token_here
```
Or config:
@ -98,8 +96,8 @@ Or config:
accounts: {
default: {
username: "clawdbot", // Bot's Twitch account
token: "oauth:abc123...", // Or omit to use CLAWDBOT_TWITCH_ACCESS_TOKEN env var
clientId: "your_client_id_here",
accessToken: "oauth:abc123...", // Access Token from Token Generator (or omit to use env var)
clientId: "xyz789...", // Client ID from Token Generator
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).
### 5) Join a channel
### 4) Join a 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
2. Get your **Client Secret** from [Twitch Developer Console](https://dev.twitch.tv/console)
For automatic token refresh, you must create your own Twitch application:
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:
```json5
@ -133,10 +134,10 @@ For long-running bots, configure automatic token refresh to avoid expired tokens
accounts: {
default: {
username: "clawdbot",
token: "oauth:abc123...",
clientId: "your_client_id",
clientSecret: "your_client_secret",
refreshToken: "your_refresh_token",
accessToken: "oauth:abc123...", // Access Token from your app
clientId: "xyz789...", // Client ID from your app
clientSecret: "secret123...", // Client Secret from your app
refreshToken: "refresh456...", // Refresh Token from your app
expiresIn: 14400,
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.
## Routing model
@ -165,14 +168,14 @@ Example (one bot account in two different channels):
accounts: {
ninjaChannel: {
username: "clawdbot",
token: "oauth:...",
clientId: "...",
accessToken: "oauth:abc123...",
clientId: "xyz789...",
channel: "vevisk"
},
shroudChannel: {
username: "clawdbot",
token: "oauth:...",
clientId: "...",
accessToken: "oauth:def456...",
clientId: "uvw012...",
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
### Role-based restrictions (recommended)
@ -194,8 +257,8 @@ Restrict access to specific roles:
accounts: {
default: {
username: "mybot",
token: "oauth:...",
clientId: "...",
accessToken: "oauth:abc123...",
clientId: "xyz789...",
channel: "your_channel",
allowedRoles: ["moderator", "vip"]
}
@ -223,8 +286,8 @@ Only allow specific Twitch user IDs (most secure):
accounts: {
default: {
username: "mybot",
token: "oauth:...",
clientId: "...",
accessToken: "oauth:abc123...",
clientId: "xyz789...",
channel: "your_channel",
allowFrom: ["123456789", "987654321"]
}
@ -252,8 +315,8 @@ Users in `allowFrom` bypass role checks. In this example:
accounts: {
default: {
username: "mybot",
token: "oauth:...",
clientId: "...",
accessToken: "oauth:abc123...",
clientId: "xyz789...",
channel: "your_channel",
allowFrom: ["123456789"],
allowedRoles: ["moderator"]
@ -275,8 +338,8 @@ Only respond when the bot is mentioned:
accounts: {
default: {
username: "mybot",
token: "oauth:...",
clientId: "...",
accessToken: "oauth:abc123...",
clientId: "xyz789...",
channel: "your_channel",
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:
- `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.
Example:
```bash
export CLAWDBOT_TWITCH_ACCESS_TOKEN=oauth:abc123def456...
export CLAWDBOT_TWITCH_ACCESS_TOKEN=abc123def456...
```
Config with env fallback:
@ -310,7 +373,7 @@ Config with env fallback:
accounts: {
default: {
username: "mybot",
clientId: "your_client_id",
clientId: "xyz789...",
channel: "your_channel"
// token will be read from CLAWDBOT_TWITCH_ACCESS_TOKEN
}
@ -380,8 +443,8 @@ clawdbot channels status --probe
accounts: {
default: {
username: "mybot",
token: "oauth:...",
clientId: "...",
accessToken: "oauth:abc123...",
clientId: "xyz789...",
channel: "your_channel",
// Temporary: allow everyone
allowedRoles: ["all"]
@ -397,7 +460,7 @@ clawdbot channels status --probe
### Token issues
**"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
- If using RefreshingAuthProvider, verify `clientSecret` and `refreshToken` are set
@ -422,14 +485,14 @@ Full configuration: [Configuration](/gateway/configuration)
```typescript
{
username: string, // Bot username
token: string, // OAuth token with chat:read and chat:write
clientId: string, // Twitch Client ID
accessToken: string, // OAuth access token with chat:read and chat:write
clientId: string, // Twitch Client ID (from Token Generator site or your app)
channel: string, // Channel to join
enabled?: boolean, // Enable this account (default: true)
clientSecret?: string, // For RefreshingAuthProvider
refreshToken?: string, // For RefreshingAuthProvider
expiresIn?: number, // Token expiry in seconds
obtainmentTimestamp?: number, // Token obtained timestamp
clientSecret?: string, // Optional: For automatic token refresh (from YOUR Twitch app)
refreshToken?: string, // Optional: For automatic token refresh (from YOUR Twitch app)
expiresIn?: number, // Token expiry in seconds (for refresh)
obtainmentTimestamp?: number, // Token obtained timestamp (for refresh)
allowFrom?: string[], // User ID allowlist
allowedRoles?: TwitchRole[], // Role-based access control
requireMention?: boolean // Require @mention (default: false)
@ -448,13 +511,17 @@ Full configuration: [Configuration](/gateway/configuration)
Provider options:
- `channels.twitch.enabled`: enable/disable channel startup.
- `channels.twitch.accounts.<accountName>.username`: bot username.
- `channels.twitch.accounts.<accountName>.token`: OAuth token.
- `channels.twitch.accounts.<accountName>.clientId`: Twitch Client ID.
- `channels.twitch.username`: bot username (simplified single-account config).
- `channels.twitch.accessToken`: OAuth access token (simplified single-account config).
- `channels.twitch.clientId`: Twitch Client ID (simplified single-account config).
- `channels.twitch.channel`: channel to join (simplified single-account config).
- `channels.twitch.accounts.<accountName>.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>.enabled`: enable/disable account (default: true).
- `channels.twitch.accounts.<accountName>.clientSecret`: for RefreshingAuthProvider.
- `channels.twitch.accounts.<accountName>.refreshToken`: for RefreshingAuthProvider.
- `channels.twitch.accounts.<accountName>.clientSecret`: optional, for automatic token refresh (must be from YOUR Twitch app).
- `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>.obtainmentTimestamp`: token obtained timestamp.
- `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
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
{
@ -28,10 +44,16 @@ Minimal config:
accounts: {
default: {
username: "clawdbot",
token: "oauth:abc123...",
clientId: "your_client_id_here",
accessToken: "oauth:abc123...",
clientId: "xyz789...",
channel: "vevisk",
},
channel2: {
username: "clawdbot",
accessToken: "oauth:def456...",
clientId: "uvw012...",
channel: "secondchannel",
},
},
},
},
@ -40,10 +62,12 @@ Minimal config:
## Setup
1. Create a Twitch application: [Twitch Developer Console](https://dev.twitch.tv/console)
2. Generate OAuth token: [Twitch Token Generator](https://twitchtokengenerator.com/)
- Select scopes: `chat:read` and `chat:write`
3. Start the gateway
1. Generate credentials: [Twitch Token Generator](https://twitchtokengenerator.com/)
- Select **Bot Token**
- Verify scopes `chat:read` and `chat:write` are selected
- Copy the **Access Token** to `token` property
- Copy the **Client ID** to `clientId` property
2. Start the gateway
## Full documentation

View File

@ -12,8 +12,8 @@ const TwitchRoleSchema = z.enum(["moderator", "owner", "vip", "subscriber", "all
const TwitchAccountSchema = z.object({
/** Twitch username */
username: z.string(),
/** Twitch OAuth token (requires chat:read and chat:write scopes) */
token: z.string(),
/** Twitch OAuth access token (requires chat:read and chat:write scopes) */
accessToken: z.string(),
/** Twitch client ID (from Twitch Developer Portal or twitchtokengenerator.com) */
clientId: z.string().optional(),
/** Channel name to join (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({
name: z.string().optional(),
enabled: z.boolean().optional(),
markdown: MarkdownConfigSchema.optional(),
/** Per-account configuration */
accounts: z.record(z.string(), TwitchAccountSchema).optional(),
});
const simplifiedSchema = z.intersection(
z.object({
name: z.string().optional(),
enabled: z.boolean().optional(),
markdown: MarkdownConfigSchema.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", () => {
const mockCoreConfig = {
const mockMultiAccountConfig = {
channels: {
twitch: {
accounts: {
default: {
username: "testbot",
token: "oauth:test123",
accessToken: "oauth:test123",
},
secondary: {
username: "secondbot",
accessToken: "oauth:secondary",
},
},
},
},
};
it("returns account config for valid account ID", () => {
const result = getAccountConfig(mockCoreConfig, "default");
const mockSimplifiedConfig = {
channels: {
twitch: {
username: "testbot",
accessToken: "oauth:test123",
},
},
};
it("returns account config for valid account ID (multi-account)", () => {
const result = getAccountConfig(mockMultiAccountConfig, "default");
expect(result).not.toBeNull();
expect(result?.username).toBe("testbot");
});
it("returns account config for default account (simplified config)", () => {
const result = getAccountConfig(mockSimplifiedConfig, "default");
expect(result).not.toBeNull();
expect(result?.username).toBe("testbot");
});
it("returns non-default account from multi-account config", () => {
const result = getAccountConfig(mockMultiAccountConfig, "secondary");
expect(result).not.toBeNull();
expect(result?.username).toBe("secondbot");
});
it("returns null for non-existent account ID", () => {
const result = getAccountConfig(mockCoreConfig, "nonexistent");
const result = getAccountConfig(mockMultiAccountConfig, "nonexistent");
expect(result).toBeNull();
});

View File

@ -8,6 +8,13 @@ export const DEFAULT_ACCOUNT_ID = "default";
/**
* Get account config from core config
*
* Handles two patterns:
* 1. Simplified single-account: base-level properties create implicit "default" account
* 2. Multi-account: explicit accounts object
*
* For "default" account, base-level properties take precedence over accounts.default
* For other accounts, only the accounts object is checked
*/
export function getAccountConfig(
coreConfig: unknown,
@ -22,6 +29,50 @@ export function getAccountConfig(
const twitch = channels?.twitch 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]) {
return null;
}
@ -45,15 +96,31 @@ export function parsePluginConfig(value: unknown): TwitchPluginConfig {
/**
* List all configured account IDs
*
* Includes both explicit accounts and implicit "default" from base-level config
*/
export function listAccountIds(cfg: ClawdbotConfig): string[] {
const accounts = (cfg as Record<string, unknown>).channels as Record<string, unknown> | undefined;
const twitch = accounts?.twitch as Record<string, unknown> | undefined;
const accountMap = twitch?.accounts as Record<string, unknown> | undefined;
if (!accountMap) {
return [];
const ids: string[] = [];
// 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 = {
username: "testbot",
token: "oauth:test123",
accessToken: "oauth:test123",
clientId: "test-client-id",
channel: "#testchannel",
};
@ -49,7 +49,7 @@ describe("onboarding helpers", () => {
expect(result).toBe("oauth:test123");
expect(mockPromptConfirm).toHaveBeenCalledWith({
message: "Token already configured. Keep it?",
message: "Access token already configured. Keep it?",
initialValue: true,
});
expect(mockPromptText).not.toHaveBeenCalled();

View File

@ -26,7 +26,7 @@ function setTwitchAccount(
const existing = getAccountConfig(cfg, DEFAULT_ACCOUNT_ID);
const merged: TwitchAccountConfig = {
username: account.username ?? existing?.username ?? "",
token: account.token ?? existing?.token ?? "",
accessToken: account.accessToken ?? existing?.accessToken ?? "",
clientId: account.clientId ?? existing?.clientId ?? "",
channel: account.channel ?? existing?.channel ?? "",
enabled: account.enabled ?? existing?.enabled ?? true,
@ -85,12 +85,12 @@ async function promptToken(
account: TwitchAccountConfig | null,
envToken: string | undefined,
): 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 (existingToken && !envToken) {
const keepToken = await prompter.confirm({
message: "Token already configured. Keep it?",
message: "Access token already configured. Keep it?",
initialValue: true,
});
if (keepToken) {
@ -225,7 +225,7 @@ async function configureWithEnvToken(
const cfgWithAccount = setTwitchAccount(cfg, {
username,
clientId,
token: "", // Will use env var
accessToken: "", // Will use env var
enabled: true,
});
@ -275,7 +275,7 @@ const dmPolicy: ChannelOnboardingDmPolicy = {
return setTwitchAccessControl(cfg as ClawdbotConfig, allowedRoles, false);
},
promptAllowFrom: async ({ cfg, prompter }) => {
const account = getTwitchAccount(cfg as ClawdbotConfig);
const account = getAccountConfig(cfg, DEFAULT_ACCOUNT_ID);
const existingAllowFrom = account?.allowFrom ?? [];
const entry = await prompter.text({
@ -319,7 +319,7 @@ export const twitchOnboardingAdapter: ChannelOnboardingAdapter = {
const envToken = process.env.CLAWDBOT_TWITCH_ACCESS_TOKEN?.trim();
// Check if env var is set and config is empty
if (envToken && !account?.token) {
if (envToken && !account?.accessToken) {
const envResult = await configureWithEnvToken(
cfg,
prompter,
@ -342,7 +342,7 @@ export const twitchOnboardingAdapter: ChannelOnboardingAdapter = {
const cfgWithAccount = setTwitchAccount(cfg, {
username,
token,
accessToken: token,
clientId,
channel: channelName,
clientSecret,

View File

@ -86,7 +86,7 @@ export const twitchPlugin: ChannelPlugin<TwitchAccountConfig> = {
// Return a default/empty account if not configured
return {
username: "",
token: "",
accessToken: "",
clientId: "",
enabled: false,
} as TwitchAccountConfig;
@ -112,7 +112,7 @@ export const twitchPlugin: ChannelPlugin<TwitchAccountConfig> = {
return {
accountId: DEFAULT_ACCOUNT_ID,
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();
});
it("should detect missing clientId when account configured", () => {
it("should detect missing clientId when account configured (simplified config)", () => {
const snapshots: ChannelAccountSnapshot[] = [
{
accountId: "default",
@ -63,13 +63,9 @@ describe("status", () => {
const mockCfg = {
channels: {
twitch: {
accounts: {
default: {
username: "testbot",
token: "oauth:test123",
// clientId missing
},
},
username: "testbot",
accessToken: "oauth:test123",
// clientId missing
},
},
};
@ -80,7 +76,7 @@ describe("status", () => {
expect(clientIdIssue).toBeDefined();
});
it("should warn about oauth: prefix in token", () => {
it("should warn about oauth: prefix in token (simplified config)", () => {
const snapshots: ChannelAccountSnapshot[] = [
{
accountId: "default",
@ -93,13 +89,9 @@ describe("status", () => {
const mockCfg = {
channels: {
twitch: {
accounts: {
default: {
username: "testbot",
token: "oauth:test123", // has prefix
clientId: "test-id",
},
},
username: "testbot",
accessToken: "oauth:test123", // has prefix
clientId: "test-id",
},
},
};
@ -111,7 +103,7 @@ describe("status", () => {
expect(prefixIssue?.kind).toBe("config");
});
it("should detect clientSecret without refreshToken", () => {
it("should detect clientSecret without refreshToken (simplified config)", () => {
const snapshots: ChannelAccountSnapshot[] = [
{
accountId: "default",
@ -124,15 +116,11 @@ describe("status", () => {
const mockCfg = {
channels: {
twitch: {
accounts: {
default: {
username: "testbot",
token: "oauth:test123",
clientId: "test-id",
clientSecret: "secret123",
// refreshToken missing
},
},
username: "testbot",
accessToken: "oauth:test123",
clientId: "test-id",
clientSecret: "secret123",
// refreshToken missing
},
},
};
@ -143,7 +131,7 @@ describe("status", () => {
expect(secretIssue).toBeDefined();
});
it("should detect empty allowFrom array", () => {
it("should detect empty allowFrom array (simplified config)", () => {
const snapshots: ChannelAccountSnapshot[] = [
{
accountId: "default",
@ -156,14 +144,10 @@ describe("status", () => {
const mockCfg = {
channels: {
twitch: {
accounts: {
default: {
username: "testbot",
token: "test123",
clientId: "test-id",
allowFrom: [], // empty array
},
},
username: "testbot",
accessToken: "test123",
clientId: "test-id",
allowFrom: [], // empty array
},
},
};
@ -174,7 +158,7 @@ describe("status", () => {
expect(allowFromIssue).toBeDefined();
});
it("should detect allowedRoles 'all' with allowFrom conflict", () => {
it("should detect allowedRoles 'all' with allowFrom conflict (simplified config)", () => {
const snapshots: ChannelAccountSnapshot[] = [
{
accountId: "default",
@ -187,15 +171,11 @@ describe("status", () => {
const mockCfg = {
channels: {
twitch: {
accounts: {
default: {
username: "testbot",
token: "test123",
clientId: "test-id",
allowedRoles: ["all"],
allowFrom: ["123456"], // conflict!
},
},
username: "testbot",
accessToken: "test123",
clientId: "test-id",
allowedRoles: ["all"],
allowFrom: ["123456"], // conflict!
},
},
};

View File

@ -56,7 +56,7 @@ export function collectTwitchStatusIssues(
accountId,
kind: "config",
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;
}
@ -72,7 +72,7 @@ export function collectTwitchStatusIssues(
continue;
}
if (account && account.username && account.token && !account.clientId) {
if (account && account.username && account.accessToken && !account.clientId) {
issues.push({
channel: "twitch",
accountId,
@ -86,7 +86,7 @@ export function collectTwitchStatusIssues(
? resolveTwitchToken(cfg as Parameters<typeof resolveTwitchToken>[0], { accountId })
: { token: "", source: "none" };
if (account && isAccountConfigured(account, tokenResolution.token)) {
if (account.token?.startsWith("oauth:")) {
if (account.accessToken?.startsWith("oauth:")) {
issues.push({
channel: "twitch",
accountId,

View File

@ -13,23 +13,34 @@ import { resolveTwitchToken, type TwitchTokenSource } from "./token.js";
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
describe("token", () => {
const mockConfig = {
// Multi-account config for testing non-default accounts
const mockMultiAccountConfig = {
channels: {
twitch: {
accounts: {
default: {
username: "testbot",
token: "oauth:config-token",
accessToken: "oauth:config-token",
},
other: {
username: "otherbot",
token: "oauth:other-token",
accessToken: "oauth:other-token",
},
},
},
},
} as unknown as ClawdbotConfig;
// Simplified single-account config
const mockSimplifiedConfig = {
channels: {
twitch: {
username: "testbot",
accessToken: "oauth:config-token",
},
},
} as unknown as ClawdbotConfig;
beforeEach(() => {
vi.clearAllMocks();
});
@ -40,42 +51,38 @@ describe("token", () => {
});
describe("resolveTwitchToken", () => {
it("should resolve token from config for default account", () => {
const result = resolveTwitchToken(mockConfig, { accountId: "default" });
it("should resolve token from simplified config for default account", () => {
const result = resolveTwitchToken(mockSimplifiedConfig, { accountId: "default" });
expect(result.token).toBe("oauth:config-token");
expect(result.source).toBe("config");
});
it("should resolve token from config for non-default account", () => {
const result = resolveTwitchToken(mockConfig, { accountId: "other" });
it("should resolve token from config for non-default account (multi-account)", () => {
const result = resolveTwitchToken(mockMultiAccountConfig, { accountId: "other" });
expect(result.token).toBe("oauth:other-token");
expect(result.source).toBe("config");
});
it("should prioritize config token over env var", () => {
it("should prioritize config token over env var (simplified config)", () => {
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
expect(result.token).toBe("oauth:config-token");
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";
const configWithEmptyToken = {
channels: {
twitch: {
accounts: {
default: {
username: "testbot",
token: "",
},
},
username: "testbot",
accessToken: "",
},
},
} as unknown as ClawdbotConfig;
@ -86,16 +93,12 @@ describe("token", () => {
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 = {
channels: {
twitch: {
accounts: {
default: {
username: "testbot",
token: "",
},
},
username: "testbot",
accessToken: "",
},
},
} as unknown as ClawdbotConfig;
@ -106,7 +109,7 @@ describe("token", () => {
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";
const configWithoutToken = {
@ -115,7 +118,7 @@ describe("token", () => {
accounts: {
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.
*
* 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";
@ -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:
* 1. Account token: channels.twitch.accounts.{accountId}.token
* 2. Base config token: channels.twitch.token (default account only)
* 3. Environment variable: CLAWDBOT_TWITCH_ACCESS_TOKEN (default account only)
* 1. Account access token (from merged config - base-level for default, or accounts.{accountId})
* 2. Environment variable: CLAWDBOT_TWITCH_ACCESS_TOKEN (default account only)
*
* The getAccountConfig function handles merging base-level config with accounts.default,
* so this logic works for both simplified and multi-account patterns.
*
* @param cfg - Clawdbot config
* @param opts - Options including accountId and optional envToken override
@ -43,26 +49,33 @@ export function resolveTwitchToken(
opts: { accountId?: string | null; envToken?: string | null } = {},
): TwitchTokenResolution {
const accountId = normalizeAccountId(opts.accountId);
// Get merged account config (handles both simplified and multi-account patterns)
const twitchCfg = cfg?.channels?.twitch;
const accountCfg =
accountId !== DEFAULT_ACCOUNT_ID
? twitchCfg?.accounts?.[accountId]
: twitchCfg?.accounts?.[DEFAULT_ACCOUNT_ID];
accountId === DEFAULT_ACCOUNT_ID
? (twitchCfg?.accounts?.[DEFAULT_ACCOUNT_ID] as Record<string, unknown> | undefined)
: (twitchCfg?.accounts?.[accountId as string] as Record<string, unknown> | undefined);
// 1. Account token (highest priority)
const accountToken = normalizeTwitchToken(accountCfg?.token ?? undefined);
if (accountToken) {
return { token: accountToken, source: "config" };
// For default account, also check base-level config
let token: string | undefined;
if (accountId === DEFAULT_ACCOUNT_ID) {
// Base-level config takes precedence
token = normalizeTwitchToken(
(typeof twitchCfg?.accessToken === "string" ? twitchCfg.accessToken : undefined) ||
(accountCfg?.accessToken as string | undefined),
);
} else {
// Non-default accounts only use accounts object
token = normalizeTwitchToken(accountCfg?.accessToken as string | undefined);
}
// 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 configToken = allowEnv ? normalizeTwitchToken(twitchCfg?.token ?? undefined) : undefined;
if (configToken) {
return { token: configToken, source: "config" };
}
// 3. Environment variable (default account only)
const envToken = allowEnv
? normalizeTwitchToken(opts.envToken ?? process.env.CLAWDBOT_TWITCH_ACCESS_TOKEN)
: undefined;

View File

@ -49,8 +49,8 @@ export interface TwitchPluginConfig {
export interface TwitchAccountConfig {
/** Twitch username */
username: string;
/** Twitch OAuth token (requires chat:read and chat:write scopes) */
token: string;
/** Twitch OAuth access token (requires chat:read and chat:write scopes) */
accessToken: string;
/** Twitch client ID (from Twitch Developer Portal or twitchtokengenerator.com) */
clientId: string;
/** Channel name to join (required) */

View File

@ -68,11 +68,11 @@ export function normalizeToken(token: string): string {
export function isAccountConfigured(
account: {
username?: string;
token?: string;
accessToken?: string;
clientId?: string;
},
resolvedToken?: string | null,
): boolean {
const token = resolvedToken ?? account?.token;
const token = resolvedToken ?? account?.accessToken;
return Boolean(account?.username && token && account?.clientId);
}