From 80fc5babd1c3e69494f9e7978e0562c4a2a8de53 Mon Sep 17 00:00:00 2001 From: jaydenfyi <213395523+jaydenfyi@users.noreply.github.com> Date: Sun, 25 Jan 2026 18:27:50 +0800 Subject: [PATCH] simplify config object --- docs/channels/twitch.md | 211 +++++++++++++++-------- extensions/twitch/README.md | 38 +++- extensions/twitch/src/config-schema.ts | 56 ++++-- extensions/twitch/src/config.test.ts | 37 +++- extensions/twitch/src/config.ts | 73 +++++++- extensions/twitch/src/onboarding.test.ts | 4 +- extensions/twitch/src/onboarding.ts | 14 +- extensions/twitch/src/plugin.ts | 4 +- extensions/twitch/src/status.test.ts | 70 +++----- extensions/twitch/src/status.ts | 6 +- extensions/twitch/src/token.test.ts | 53 +++--- extensions/twitch/src/token.ts | 53 +++--- extensions/twitch/src/types.ts | 4 +- extensions/twitch/src/utils/twitch.ts | 4 +- 14 files changed, 422 insertions(+), 205 deletions(-) diff --git a/docs/channels/twitch.md b/docs/channels/twitch.md index b2ab3d0ac..266078da5 100644 --- a/docs/channels/twitch.md +++ b/docs/channels/twitch.md @@ -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::twitch:`. +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::twitch:`. **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..username`: bot username. -- `channels.twitch.accounts..token`: OAuth token. -- `channels.twitch.accounts..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..username`: bot username (multi-account config). +- `channels.twitch.accounts..accessToken`: OAuth access token (multi-account config). +- `channels.twitch.accounts..clientId`: Twitch Client ID (from Token Generator or your app). - `channels.twitch.accounts..channel`: channel to join. - `channels.twitch.accounts..enabled`: enable/disable account (default: true). -- `channels.twitch.accounts..clientSecret`: for RefreshingAuthProvider. -- `channels.twitch.accounts..refreshToken`: for RefreshingAuthProvider. +- `channels.twitch.accounts..clientSecret`: optional, for automatic token refresh (must be from YOUR Twitch app). +- `channels.twitch.accounts..refreshToken`: optional, for automatic token refresh (must be from YOUR Twitch app). - `channels.twitch.accounts..expiresIn`: token expiry in seconds. - `channels.twitch.accounts..obtainmentTimestamp`: token obtained timestamp. - `channels.twitch.accounts..allowFrom`: user ID allowlist. diff --git a/extensions/twitch/README.md b/extensions/twitch/README.md index 377da572a..bc51ddca0 100644 --- a/extensions/twitch/README.md +++ b/extensions/twitch/README.md @@ -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 diff --git a/extensions/twitch/src/config-schema.ts b/extensions/twitch/src/config-schema.ts index 26c55b095..7b9b6a2df 100644 --- a/extensions/twitch/src/config-schema.ts +++ b/extensions/twitch/src/config-schema.ts @@ -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]); diff --git a/extensions/twitch/src/config.test.ts b/extensions/twitch/src/config.test.ts index 5d9a749ef..413c79bf5 100644 --- a/extensions/twitch/src/config.test.ts +++ b/extensions/twitch/src/config.test.ts @@ -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(); }); diff --git a/extensions/twitch/src/config.ts b/extensions/twitch/src/config.ts index 3e8f4342a..a5b8afa82 100644 --- a/extensions/twitch/src/config.ts +++ b/extensions/twitch/src/config.ts @@ -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 | undefined; const accounts = twitch?.accounts as Record | undefined; + // For default account, check base-level config first + if (accountId === DEFAULT_ACCOUNT_ID) { + const accountFromAccounts = accounts?.[DEFAULT_ACCOUNT_ID] as + | Record + | 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; + + // 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).channels as Record | undefined; const twitch = accounts?.twitch as Record | undefined; const accountMap = twitch?.accounts as Record | 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; } diff --git a/extensions/twitch/src/onboarding.test.ts b/extensions/twitch/src/onboarding.test.ts index b3aff4f4b..abc7c527a 100644 --- a/extensions/twitch/src/onboarding.test.ts +++ b/extensions/twitch/src/onboarding.test.ts @@ -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(); diff --git a/extensions/twitch/src/onboarding.ts b/extensions/twitch/src/onboarding.ts index 9095a0dbb..14501693c 100644 --- a/extensions/twitch/src/onboarding.ts +++ b/extensions/twitch/src/onboarding.ts @@ -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 { - 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, diff --git a/extensions/twitch/src/plugin.ts b/extensions/twitch/src/plugin.ts index 27f64a777..6a43ac3a2 100644 --- a/extensions/twitch/src/plugin.ts +++ b/extensions/twitch/src/plugin.ts @@ -86,7 +86,7 @@ export const twitchPlugin: ChannelPlugin = { // 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 = { return { accountId: DEFAULT_ACCOUNT_ID, enabled: account?.enabled !== false, - configured: account ? isAccountConfigured(account, account?.token) : false, + configured: account ? isAccountConfigured(account, account?.accessToken) : false, }; }, }, diff --git a/extensions/twitch/src/status.test.ts b/extensions/twitch/src/status.test.ts index 66090ef23..8f7cd55ab 100644 --- a/extensions/twitch/src/status.test.ts +++ b/extensions/twitch/src/status.test.ts @@ -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! }, }, }; diff --git a/extensions/twitch/src/status.ts b/extensions/twitch/src/status.ts index 118b48975..b2a488e66 100644 --- a/extensions/twitch/src/status.ts +++ b/extensions/twitch/src/status.ts @@ -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[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, diff --git a/extensions/twitch/src/token.test.ts b/extensions/twitch/src/token.test.ts index d15a05490..3894532bc 100644 --- a/extensions/twitch/src/token.test.ts +++ b/extensions/twitch/src/token.test.ts @@ -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: "", }, }, }, diff --git a/extensions/twitch/src/token.ts b/extensions/twitch/src/token.ts index c005ea375..bad0f2b57 100644 --- a/extensions/twitch/src/token.ts +++ b/extensions/twitch/src/token.ts @@ -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 | undefined) + : (twitchCfg?.accounts?.[accountId as string] as Record | 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; diff --git a/extensions/twitch/src/types.ts b/extensions/twitch/src/types.ts index 1938194f5..2e1134eec 100644 --- a/extensions/twitch/src/types.ts +++ b/extensions/twitch/src/types.ts @@ -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) */ diff --git a/extensions/twitch/src/utils/twitch.ts b/extensions/twitch/src/utils/twitch.ts index bbbbe9ada..cb2667cb1 100644 --- a/extensions/twitch/src/utils/twitch.ts +++ b/extensions/twitch/src/utils/twitch.ts @@ -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); }