diff --git a/docs/channels/index.md b/docs/channels/index.md index 52e963b87..35f65de0a 100644 --- a/docs/channels/index.md +++ b/docs/channels/index.md @@ -25,6 +25,7 @@ Text is supported everywhere; media and reactions vary by channel. - [Matrix](/channels/matrix) — Matrix protocol (plugin, installed separately). - [Nostr](/channels/nostr) — Decentralized DMs via NIP-04 (plugin, installed separately). - [Tlon](/channels/tlon) — Urbit-based messenger (plugin, installed separately). +- [Twitch](/channels/twitch) — Twitch chat via IRC connection (plugin, installed separately). - [Zalo](/channels/zalo) — Zalo Bot API; Vietnam's popular messenger (plugin, installed separately). - [Zalo Personal](/channels/zalouser) — Zalo personal account via QR login (plugin, installed separately). - [WebChat](/web/webchat) — Gateway WebChat UI over WebSocket. diff --git a/docs/channels/twitch.md b/docs/channels/twitch.md new file mode 100644 index 000000000..927491764 --- /dev/null +++ b/docs/channels/twitch.md @@ -0,0 +1,459 @@ +--- +summary: "Twitch chat bot support status, capabilities, and configuration" +read_when: + - Setting up Twitch chat integration for Clawdbot + - Configuring Twitch bot permissions and access control +--- +# Twitch (plugin) + +Twitch chat support via IRC connection. Clawdbot connects as a Twitch user (bot account) to receive and send messages in channels. Requires a Twitch application with OAuth token. + +Status: ready for Twitch chat via IRC connection with @twurple. + +## Plugin required + +Twitch ships as a plugin and is not bundled with the core install. + +Install via CLI (npm registry): + +```bash +clawdbot plugins install @clawdbot/twitch +``` + +Local checkout (when running from a git repo): + +```bash +clawdbot plugins install ./extensions/twitch +``` + +Details: [Plugins](/plugin) + +## Setup + +1) Install the Twitch plugin: + - From npm: `clawdbot plugins install @clawdbot/twitch` + - From a local checkout: `clawdbot plugins install ./extensions/twitch` + +2) Create a Twitch application: + - Go to [Twitch Developer Console](https://dev.twitch.tv/console) + - Click "Register Your Application" + - Set Application Type to "Chat Bot" + - Copy the Client ID + +3) Generate your OAuth token: + - Use [Twitch Token Generator](https://twitchtokengenerator.com/) or [TwitchApps TMI](https://twitchapps.com/tmi/) + - Select scopes: `chat:read` and `chat:write` + - Copy the token (starts with `oauth:`) + +4) Configure credentials: + - Env: `CLAWDBOT_TWITCH_ACCESS_TOKEN=...` (default account only) + - Or config: `channels.twitch.accounts.default.token` + - If both are set, config takes precedence (env fallback is default-account only) + +5) Start the gateway. Twitch starts when a token is resolved (config first, env fallback). + +6) Invite the bot to your channel (it will auto-join the channel specified in config). + +Minimal config (default account): + +```json5 +{ + channels: { + twitch: { + enabled: true, + accounts: { + default: { + username: "mybot", + token: "oauth:your_token_here", + clientId: "your_client_id_here" + } + } + } + } +} +``` + +Env-only setup: + +```bash +export CLAWDBOT_TWITCH_ACCESS_TOKEN=oauth:your_token_here +``` + +With env, you still need `clientId` in config (or use the minimal config above without `token`). + +## Token setup + +### Option 1: Static token (simple) + +Use [Twitch Token Generator](https://twitchtokengenerator.com/): + +1. Select scopes: `chat:read` and `chat:write` +2. Copy the token (starts with `oauth:`) +3. Add to config: + +```json5 +{ + channels: { + twitch: { + accounts: { + default: { + username: "mybot", + token: "oauth:abc123...", + clientId: "your_client_id" + } + } + } + } +} +``` + +### Option 2: Automatic token refresh (recommended) + +For long-running bots, configure RefreshingAuthProvider: + +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) +3. Add to config: + +```json5 +{ + channels: { + twitch: { + accounts: { + default: { + username: "mybot", + token: "oauth:abc123...", + clientId: "your_client_id", + clientSecret: "your_client_secret", + refreshToken: "your_refresh_token", + expiresIn: 14400, + obtainmentTimestamp: 1706092800000 + } + } + } + } +} +``` + +The bot will automatically refresh tokens before they expire and log refresh events. + +## Access control + +### Allowlist by User ID (recommended) + +Most secure: only allow specific Twitch user IDs: + +```json5 +{ + channels: { + twitch: { + accounts: { + default: { + username: "mybot", + token: "oauth:...", + clientId: "...", + allowFrom: ["123456789", "987654321"] + } + } + } + } +} +``` + +**Why user IDs instead of usernames?** Twitch usernames can change, which could allow someone to hijack another user's access. User IDs are permanent. + +Find your Twitch user ID at: https://www.streamweasels.com/tools/convert-your-twitch-username-to-user-id/ + +### Role-based restrictions + +Restrict access to specific roles: + +```json5 +{ + channels: { + twitch: { + accounts: { + default: { + username: "mybot", + token: "oauth:...", + clientId: "...", + allowedRoles: ["moderator", "vip"] + } + } + } + } +} +``` + +**Available roles:** +- `"moderator"` - Channel moderators +- `"owner"` - Channel owner/broadcaster +- `"vip"` - VIPs +- `"subscriber"` - Subscribers +- `"all"` - Anyone in chat + +### Combined allowlist + roles + +Users in `allowFrom` bypass role checks: + +```json5 +{ + channels: { + twitch: { + accounts: { + default: { + username: "mybot", + token: "oauth:...", + clientId: "...", + allowFrom: ["123456789"], + allowedRoles: ["moderator"] + } + } + } + } +} +``` + +In this example: +- User `123456789` can always message (bypasses role check) +- All moderators can message +- Everyone else is blocked + +### Require @mention + +Only respond when the bot is mentioned: + +```json5 +{ + channels: { + twitch: { + accounts: { + default: { + username: "mybot", + token: "oauth:...", + clientId: "...", + requireMention: true + } + } + } + } +} +``` + +## Multiple accounts + +Configure multiple Twitch channels: + +```json5 +{ + channels: { + twitch: { + accounts: { + main: { + username: "mybot", + token: "oauth:...", + clientId: "...", + channel: "streamer1" + }, + secondary: { + username: "mybot", + token: "oauth:...", + clientId: "...", + channel: "streamer2", + allowedRoles: ["moderator"] + } + } + } + } +} +``` + +## Environment variables + +For the default account, you can use environment variables instead of config: + +- `CLAWDBOT_TWITCH_ACCESS_TOKEN` - OAuth token (with `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... +``` + +Config: + +```json5 +{ + channels: { + twitch: { + enabled: true, + accounts: { + default: { + username: "mybot", + clientId: "your_client_id" + // token will be read from CLAWDBOT_TWITCH_ACCESS_TOKEN + } + } + } + } +} +``` + +Priority: account config > base config > env var (for default account only). + +## Plugin options + +Control markdown stripping behavior: + +```json5 +{ + plugins: { + entries: { + twitch: { + stripMarkdown: true + } + } + } +} +``` + +- `stripMarkdown` (default: `true`) - Remove markdown formatting before sending to Twitch + +Twitch doesn't support markdown, so this is enabled by default. Disable if you want to send markdown as-is (it will appear as plain text with markdown symbols). + +## Troubleshooting + +First, run diagnostic commands: + +```bash +clawdbot doctor +clawdbot channels status --probe +``` + +### Bot doesn't respond to messages + +**Check access control:** +```json5 +{ + channels: { + twitch: { + accounts: { + default: { + username: "mybot", + token: "oauth:...", + clientId: "...", + // Temporary: allow everyone + allowedRoles: ["all"] + } + } + } + } +} +``` + +**Check the bot is in the channel:** The bot must join the channel (either `channel: "target_channel"` or defaults to `username`). + +### Token issues + +**"Failed to connect" or authentication errors:** +- Verify token starts with `oauth:` +- Check token has `chat:read` and `chat:write` scopes +- If using RefreshingAuthProvider, verify `clientSecret` and `refreshToken` are set + +### Token refresh not working + +**Check logs for refresh events:** +``` +[twitch] Using env token source for mybot +[twitch] Access token refreshed for user 123456 (expires in 14400s) +``` + +If you see "token refresh disabled (no refresh token)": +- Ensure `clientSecret` is provided +- Ensure `refreshToken` is provided (from Twitch Token Generator with "Include Refresh Token" checked) + +## Capabilities & limits + +**Supported:** +- ✅ Channel messages (group chat) +- ✅ Whispers/DMs (received but replies not supported - Twitch doesn't allow bots to send whispers) +- ✅ Markdown stripping (automatically applied) +- ✅ Message chunking (500 char limit) +- ✅ Access control (user ID allowlist, role-based) +- ✅ @mention requirement +- ✅ Automatic token refresh (with RefreshingAuthProvider) +- ✅ Multi-account support + +**Not supported:** +- ❌ Native reactions +- ❌ Threaded replies +- ❌ Message editing +- ❌ Message deletion +- ❌ Rich embeds/media uploads (sends media URLs as text) + +## Config reference + +### Account config + +```typescript +{ + username: string, // Bot username (required) + token: string, // OAuth token with chat:read and chat:write (required) + clientId: string, // Twitch Client ID (required) + channel?: string, // Channel to join (default: username) + 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 + allowFrom?: string[], // User ID allowlist + allowedRoles?: TwitchRole[], // Role-based access control + requireMention?: boolean // Require @mention (default: false) +} +``` + +**TwitchRole:** `"moderator"` | `"owner"` | `"vip"` | `"subscriber"` | `"all"` + +### Plugin config + +```typescript +{ + stripMarkdown?: boolean // Strip markdown from outbound (default: true) +} +``` + +## Tool actions + +The agent can call `twitch` with action: + +- `send` - Send a message to a channel + +Example: + +```json5 +{ + "action": "twitch", + "params": { + "message": "Hello Twitch!", + "to": "#mychannel" + } +} +``` + +## Safety & ops + +- **Treat tokens like passwords** - Never commit tokens to git +- **Use RefreshingAuthProvider** for long-running bots +- **Use user ID allowlists** instead of usernames for access control +- **Monitor logs** for token refresh events and connection status +- **Scope tokens minimally** - Only request `chat:read` and `chat:write` +- **If stuck**: Restart the gateway after confirming no other process owns the session + +## Message limits + +- **500 characters** per message (Twitch limit) +- Messages are automatically chunked at word boundaries +- Markdown is stripped before chunking to avoid breaking patterns +- No rate limiting (uses Twitch's built-in rate limits) diff --git a/extensions/twitch/index.ts b/extensions/twitch/index.ts new file mode 100644 index 000000000..7f2aa6960 --- /dev/null +++ b/extensions/twitch/index.ts @@ -0,0 +1,18 @@ +import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk"; +import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk"; + +import { twitchPlugin } from "./src/plugin.js"; +import { setTwitchRuntime } from "./src/runtime.js"; + +const plugin = { + id: "twitch", + name: "Twitch", + description: "Twitch channel plugin", + configSchema: emptyPluginConfigSchema(), + register(api: ClawdbotPluginApi) { + setTwitchRuntime(api.runtime); + api.registerChannel({ plugin: twitchPlugin }); + }, +}; + +export default plugin; diff --git a/extensions/twitch/package.json b/extensions/twitch/package.json index 455376779..f215da0de 100644 --- a/extensions/twitch/package.json +++ b/extensions/twitch/package.json @@ -9,9 +9,11 @@ ] }, "dependencies": { - "@sinclair/typebox": "0.34.47", "@twurple/api": "^8.0.3", "@twurple/auth": "^8.0.3", "@twurple/chat": "^8.0.3" + }, + "devDependencies": { + "clawdbot": "workspace:*" } } diff --git a/extensions/twitch/src/config.ts b/extensions/twitch/src/config.ts index 465c6db03..c5f0edf8e 100644 --- a/extensions/twitch/src/config.ts +++ b/extensions/twitch/src/config.ts @@ -1,5 +1,5 @@ +import type { ClawdbotConfig } from "clawdbot/plugin-sdk"; import type { - CoreConfig, TwitchAccountConfig, TwitchPluginConfig, } from "./types.js"; @@ -50,7 +50,7 @@ export function parsePluginConfig(value: unknown): TwitchPluginConfig { /** * List all configured account IDs */ -export function listAccountIds(cfg: CoreConfig): string[] { +export function listAccountIds(cfg: ClawdbotConfig): string[] { const accounts = (cfg as Record).channels as | Record | undefined; diff --git a/extensions/twitch/src/plugin.ts b/extensions/twitch/src/plugin.ts index f24f3c841..6010218cb 100644 --- a/extensions/twitch/src/plugin.ts +++ b/extensions/twitch/src/plugin.ts @@ -6,6 +6,8 @@ */ import { checkTwitchAccessControl } from "./access-control.js"; +import { resolveAgentRoute } from "../../../src/routing/resolve-route.js"; +import type { ClawdbotConfig } from "clawdbot/plugin-sdk"; import { twitchMessageActions } from "./actions.js"; import { DEFAULT_ACCOUNT_ID, @@ -19,14 +21,12 @@ import { collectTwitchStatusIssues } from "./status.js"; import { TwitchClientManager } from "./twitch-client.js"; import type { ChannelAccountSnapshot, + ChannelCapabilities, + ChannelLogSink, ChannelMeta, ChannelPlugin, ChannelResolveKind, ChannelResolveResult, - ChatCapabilities, - CoreConfig, - PluginAPI, - ProviderLogger, TwitchAccountConfig, } from "./types.js"; @@ -63,16 +63,16 @@ export const twitchPlugin: ChannelPlugin = { /** Supported chat capabilities */ capabilities: { chatTypes: ["group", "direct"], - } satisfies ChatCapabilities, + } satisfies ChannelCapabilities, /** Account configuration management */ config: { /** List all configured account IDs */ - listAccountIds: (cfg: CoreConfig): string[] => listAccountIds(cfg), + listAccountIds: (cfg: ClawdbotConfig): string[] => listAccountIds(cfg), /** Resolve an account config by ID */ resolveAccount: ( - cfg: CoreConfig, + cfg: ClawdbotConfig, accountId?: string | null, ): TwitchAccountConfig | null => getAccountConfig(cfg, accountId ?? DEFAULT_ACCOUNT_ID), @@ -81,7 +81,7 @@ export const twitchPlugin: ChannelPlugin = { defaultAccountId: (): string => DEFAULT_ACCOUNT_ID, /** Check if an account is configured */ - isConfigured: (_account: unknown, cfg: CoreConfig): boolean => { + isConfigured: (_account: unknown, cfg: ClawdbotConfig): boolean => { const account = getAccountConfig(cfg, DEFAULT_ACCOUNT_ID); return isConfigured(account); }, @@ -113,11 +113,11 @@ export const twitchPlugin: ChannelPlugin = { kind, runtime, }: { - cfg: CoreConfig; + cfg: ClawdbotConfig; accountId?: string | null; inputs: string[]; kind: ChannelResolveKind; - runtime: { log?: ProviderLogger }; + runtime: { log?: ChannelLogSink }; }): Promise => { const account = getAccountConfig(cfg, accountId ?? DEFAULT_ACCOUNT_ID); @@ -177,7 +177,7 @@ export const twitchPlugin: ChannelPlugin = { probe, }: { account: TwitchAccountConfig; - cfg: CoreConfig; + cfg: ClawdbotConfig; runtime?: ChannelAccountSnapshot; probe?: unknown; }): ChannelAccountSnapshot => { @@ -227,16 +227,33 @@ export const twitchPlugin: ChannelPlugin = { }); if (!access.allowed) { - console.info( + ctx.log?.info( `[twitch] Ignored message from ${message.username}: ${access.reason ?? "blocked"}`, ); return; } - // TODO: Implement inbound message routing to Clawdbot - // This requires integration with Clawdbot's message handling system - console.info( - `[twitch] Received message from ${message.username}: ${message.message}`, + // Resolve route for this message + const route = resolveAgentRoute({ + cfg: ctx.cfg, + channel: "twitch", + accountId, + peer: { + kind: "group", // Twitch chat is always group-like + id: message.channel, + }, + }); + + // Build message preview + const preview = message.message.replace(/\s+/g, " ").slice(0, 160); + + // Dispatch to agent system + ctx.runtime.system.enqueueSystemEvent( + `Twitch message from ${message.displayName ?? message.username}: ${preview}`, + { + sessionKey: route.sessionKey, + contextKey: `twitch:message:${message.channel}:${message.id ?? "unknown"}`, + }, ); }); @@ -253,7 +270,7 @@ export const twitchPlugin: ChannelPlugin = { ); try { - await clientManager.getClient(account); + await clientManager.getClient(account, ctx.cfg); ctx.log?.info(`[twitch] Connected to Twitch as ${account.username}`); } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); @@ -287,18 +304,3 @@ export const twitchPlugin: ChannelPlugin = { }, }, }; - -/** - * Extension entry point for registering the Twitch plugin with Clawdbot. - * - * @param api - Plugin API provided by Clawdbot - */ -export function registerTwitchPlugin(api: PluginAPI): void { - api.registerChannel({ plugin: twitchPlugin }); - api.logger?.info("[twitch] Plugin registered"); -} - -/** - * Default export for CommonJS compatibility. - */ -export default twitchPlugin; diff --git a/extensions/twitch/src/resolver.ts b/extensions/twitch/src/resolver.ts index 5c05ab511..8ec7307e2 100644 --- a/extensions/twitch/src/resolver.ts +++ b/extensions/twitch/src/resolver.ts @@ -8,7 +8,7 @@ import { ApiClient } from "@twurple/api"; import { StaticAuthProvider } from "@twurple/auth"; import type { ChannelResolveKind, ChannelResolveResult } from "./types.js"; -import type { ProviderLogger, TwitchAccountConfig } from "./types.js"; +import type { ChannelLogSink, TwitchAccountConfig } from "./types.js"; /** * Normalize a Twitch username - strip @ prefix and convert to lowercase @@ -24,7 +24,7 @@ function normalizeUsername(input: string): string { /** * Create a logger that includes the Twitch prefix */ -function createLogger(logger?: ProviderLogger): ProviderLogger { +function createLogger(logger?: ChannelLogSink): ChannelLogSink { return { info: (msg: string) => logger?.info(`[twitch] ${msg}`), warn: (msg: string) => logger?.warn(`[twitch] ${msg}`), @@ -46,7 +46,7 @@ export async function resolveTwitchTargets( inputs: string[], account: TwitchAccountConfig, kind: ChannelResolveKind, - logger?: ProviderLogger, + logger?: ChannelLogSink, ): Promise { const log = createLogger(logger); diff --git a/extensions/twitch/src/runtime.ts b/extensions/twitch/src/runtime.ts new file mode 100644 index 000000000..5c2f1c672 --- /dev/null +++ b/extensions/twitch/src/runtime.ts @@ -0,0 +1,14 @@ +import type { PluginRuntime } from "clawdbot/plugin-sdk"; + +let runtime: PluginRuntime | null = null; + +export function setTwitchRuntime(next: PluginRuntime) { + runtime = next; +} + +export function getTwitchRuntime(): PluginRuntime { + if (!runtime) { + throw new Error("Twitch runtime not initialized"); + } + return runtime; +} diff --git a/extensions/twitch/src/send.ts b/extensions/twitch/src/send.ts index bd9271957..3818a1f58 100644 --- a/extensions/twitch/src/send.ts +++ b/extensions/twitch/src/send.ts @@ -7,7 +7,7 @@ import { DEFAULT_ACCOUNT_ID, getAccountConfig } from "./config.js"; import { TwitchClientManager } from "./twitch-client.js"; -import type { CoreConfig } from "./types.js"; +import type { ClawdbotConfig } from "clawdbot/plugin-sdk"; import { stripMarkdownForTwitch } from "./utils/markdown.js"; import { generateMessageId, @@ -138,7 +138,7 @@ export async function sendMessageTwitch( export async function sendMessageTwitchInternal( channel: string, text: string, - cfg: CoreConfig, + cfg: ClawdbotConfig, accountId: string = DEFAULT_ACCOUNT_ID, stripMarkdown: boolean = true, logger: Console = console, @@ -188,6 +188,7 @@ export async function sendMessageTwitchInternal( account, normalizeTwitchChannel(normalizedChannel), cleanedText, + cfg, ); if (!result.ok) { diff --git a/extensions/twitch/src/token.ts b/extensions/twitch/src/token.ts new file mode 100644 index 000000000..2a10f5a55 --- /dev/null +++ b/extensions/twitch/src/token.ts @@ -0,0 +1,77 @@ +/** + * Twitch token resolution with environment variable support. + * + * Supports reading Twitch OAuth tokens from config or environment variable. + * The CLAWDBOT_TWITCH_ACCESS_TOKEN env var is only used for the default account. + */ + +import type { ClawdbotConfig } from "../../../src/config/config.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; +import type { TwitchAccountConfig } from "./types.js"; + +export type TwitchTokenSource = "env" | "config" | "none"; + +export type TwitchTokenResolution = { + token: string; + source: TwitchTokenSource; +}; + +/** + * Normalize a Twitch OAuth token - ensure it has the oauth: prefix + */ +function normalizeTwitchToken(raw?: string | null): string | undefined { + if (!raw) return undefined; + const trimmed = raw.trim(); + if (!trimmed) return undefined; + // Twitch tokens should have oauth: prefix + return trimmed.startsWith("oauth:") ? trimmed : `oauth:${trimmed}`; +} + +/** + * Resolve Twitch 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) + * + * @param cfg - Clawdbot config + * @param opts - Options including accountId and optional envToken override + * @returns Token resolution with source + */ +export function resolveTwitchToken( + cfg?: ClawdbotConfig, + opts: { accountId?: string | null; envToken?: string | null } = {}, +): TwitchTokenResolution { + const accountId = normalizeAccountId(opts.accountId); + const twitchCfg = cfg?.channels?.twitch; + const accountCfg = + accountId !== DEFAULT_ACCOUNT_ID + ? twitchCfg?.accounts?.[accountId] + : twitchCfg?.accounts?.[DEFAULT_ACCOUNT_ID]; + + // 1. Account token (highest priority) + const accountToken = normalizeTwitchToken(accountCfg?.token ?? undefined); + if (accountToken) { + return { token: accountToken, source: "config" }; + } + + // 2. Base config token (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; + if (envToken) { + return { token: envToken, source: "env" }; + } + + return { token: "", source: "none" }; +} diff --git a/extensions/twitch/src/twitch-client.test.ts b/extensions/twitch/src/twitch-client.test.ts index 83476eae1..66e6192df 100644 --- a/extensions/twitch/src/twitch-client.test.ts +++ b/extensions/twitch/src/twitch-client.test.ts @@ -12,7 +12,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { TwitchClientManager } from "./twitch-client.js"; import type { - ProviderLogger, + ChannelLogSink, TwitchAccountConfig, TwitchChatMessage, } from "./types.js"; @@ -58,7 +58,7 @@ vi.mock("@twurple/auth", () => ({ describe("TwitchClientManager", () => { let manager: TwitchClientManager; - let mockLogger: ProviderLogger; + let mockLogger: ChannelLogSink; const testAccount: TwitchAccountConfig = { username: "testbot", diff --git a/extensions/twitch/src/twitch-client.ts b/extensions/twitch/src/twitch-client.ts index 7776a32f6..3cab0721e 100644 --- a/extensions/twitch/src/twitch-client.ts +++ b/extensions/twitch/src/twitch-client.ts @@ -1,10 +1,12 @@ import { RefreshingAuthProvider, StaticAuthProvider } from "@twurple/auth"; import { ChatClient, LogLevel } from "@twurple/chat"; +import type { ClawdbotConfig } from "clawdbot/plugin-sdk"; import type { - ProviderLogger, + ChannelLogSink, TwitchAccountConfig, TwitchChatMessage, } from "./types.js"; +import { resolveTwitchToken } from "./token.js"; /** * Manages Twitch chat client connections @@ -16,12 +18,15 @@ export class TwitchClientManager { (message: TwitchChatMessage) => void >(); - constructor(private logger: ProviderLogger) {} + constructor(private logger: ChannelLogSink) {} /** * Get or create a chat client for an account */ - async getClient(account: TwitchAccountConfig): Promise { + async getClient( + account: TwitchAccountConfig, + cfg?: ClawdbotConfig, + ): Promise { const key = this.getAccountKey(account); const existing = this.clients.get(key); @@ -29,17 +34,33 @@ export class TwitchClientManager { return existing; } - if (!account.clientId || !account.token) { + // Resolve token from config or environment + const tokenResolution = resolveTwitchToken(cfg, { + accountId: account.username, + }); + + if (!tokenResolution.token) { this.logger.error( - `[twitch] Missing Twitch client ID or token for account ${account.username}`, + `[twitch] Missing Twitch token for account ${account.username} (set channels.twitch.accounts.${account.username}.token or CLAWDBOT_TWITCH_ACCESS_TOKEN for default)`, ); - throw new Error("Missing Twitch client ID or token"); + throw new Error("Missing Twitch token"); + } + + this.logger.debug( + `[twitch] Using ${tokenResolution.source} token source for ${account.username}`, + ); + + if (!account.clientId) { + this.logger.error( + `[twitch] Missing Twitch client ID for account ${account.username}`, + ); + throw new Error("Missing Twitch client ID"); } // Normalize token - strip oauth: prefix if present (Twurple doesn't need it) - const normalizedToken = account.token.startsWith("oauth:") - ? account.token.slice(6) - : account.token; + const normalizedToken = tokenResolution.token.startsWith("oauth:") + ? tokenResolution.token.slice(6) + : tokenResolution.token; // Use RefreshingAuthProvider if clientSecret is provided (supports optional refresh tokens) let authProvider: StaticAuthProvider | RefreshingAuthProvider; @@ -251,9 +272,10 @@ export class TwitchClientManager { account: TwitchAccountConfig, channel: string, message: string, + cfg?: ClawdbotConfig, ): Promise<{ ok: boolean; error?: string; messageId?: string }> { try { - const client = await this.getClient(account); + const client = await this.getClient(account, cfg); // Generate a message ID (Twurple's say() doesn't return the message ID, so we generate one) const messageId = `${Date.now()}-${Math.random().toString(36).substring(2, 15)}`; diff --git a/extensions/twitch/src/types.ts b/extensions/twitch/src/types.ts index bb19a70c8..d2e1318f6 100644 --- a/extensions/twitch/src/types.ts +++ b/extensions/twitch/src/types.ts @@ -1,35 +1,48 @@ /** * Twitch channel plugin types. * - * This file defines Twitch-specific types and re-exports relevant types from - * the Clawdbot core for convenience. + * This file defines Twitch-specific types. Generic channel types are imported + * from Clawdbot core. */ +import type { + ChannelAccountSnapshot, + ChannelCapabilities, + ChannelLogSink, + ChannelMessageActionAdapter, + ChannelMessageActionContext, + ChannelMeta, +} from "../../../src/channels/plugins/types.core.js"; +import type { ChannelPlugin } from "../../../src/channels/plugins/types.plugin.js"; +import type { + ChannelGatewayContext, + ChannelOutboundAdapter, + ChannelOutboundContext, + ChannelResolveKind, + ChannelResolveResult, + ChannelStatusAdapter, +} from "../../../src/channels/plugins/types.adapters.js"; +import type { ClawdbotConfig } from "../../../src/config/config.js"; +import type { OutboundDeliveryResult } from "../../../src/infra/outbound/deliver.js"; +import type { RuntimeEnv } from "../../../src/runtime.js"; + // ============================================================================ // Twitch-Specific Types // ============================================================================ -/** - * Resolver target kind (user or group/channel) - */ -export type ChannelResolveKind = "user" | "group"; - -/** - * Result from resolving a target (username -> user ID) - */ -export type ChannelResolveResult = { - input: string; - resolved: boolean; - id?: string; - name?: string; - note?: string; -}; - /** * Twitch user roles that can be allowed to interact with the bot */ export type TwitchRole = "moderator" | "owner" | "vip" | "subscriber" | "all"; +/** + * Plugin configuration passed from Clawdbot + */ +export interface TwitchPluginConfig { + /** Strip markdown from outbound messages before sending to Twitch (default true). */ + stripMarkdown?: boolean; +} + /** * Account configuration for a Twitch channel */ @@ -60,14 +73,6 @@ export interface TwitchAccountConfig { obtainmentTimestamp?: number; } -/** - * Twitch channel configuration - */ -export interface TwitchChannelConfig { - /** Map of account IDs to account configurations */ - accounts?: Record; -} - /** * Message target for Twitch */ @@ -78,14 +83,6 @@ export interface TwitchTarget { channel?: string; } -/** - * Plugin configuration passed from Clawdbot - */ -export interface TwitchPluginConfig { - /** Strip markdown from outbound messages before sending to Twitch (default true). */ - stripMarkdown?: boolean; -} - /** * Twitch message from chat */ @@ -125,340 +122,23 @@ export interface SendResult { messageId?: string; } -/** - * Provider logger interface - */ -export interface ProviderLogger { - info(msg: string): void; - warn(msg: string): void; - error(msg: string): void; - debug(msg: string): void; -} +// Re-export core types for convenience +export type { + ChannelAccountSnapshot, + ChannelGatewayContext, + ChannelLogSink, + ChannelMessageActionAdapter, + ChannelMessageActionContext, + ChannelMeta, + ChannelOutboundAdapter, + ChannelStatusAdapter, + ChannelCapabilities, + ChannelResolveKind, + ChannelResolveResult, + ChannelPlugin, + ChannelOutboundContext, + OutboundDeliveryResult, +}; -// ============================================================================ -// Re-exports from Clawdbot Core -// ============================================================================ - -/** - * Core config from Clawdbot (for accessing channels.twitch) - * - * NOTE: This is a simplified version. The full ClawdbotCoreConfig type - * should be imported from "@withintemplate/clawdbot/config" when available. - */ -export interface CoreConfig { - channels?: { - twitch?: TwitchChannelConfig; - [key: string]: unknown; - }; - pluginConfig?: TwitchPluginConfig; - session?: { - store?: unknown; - }; - [key: string]: unknown; -} - -/** - * Plugin API from Clawdbot - * - * NOTE: This is a simplified version. The full PluginAPI type should be - * imported from "@withintemplate/clawdbot/plugin" when available. - */ -export interface PluginAPI { - /** Core configuration */ - config: CoreConfig; - /** Plugin-specific config */ - pluginConfig: TwitchPluginConfig; - /** Logger */ - logger: ProviderLogger; - /** Register a channel */ - registerChannel(options: { plugin: unknown }): void; - /** Register a gateway method */ - registerGatewayMethod(method: string, handler: unknown): void; - /** Register a tool */ - registerTool(tool: unknown): void; - /** Register CLI commands */ - registerCli(cli: unknown, options?: unknown): void; - /** Register a background service */ - registerService(service: unknown): void; -} - -// ============================================================================ -// Channel Adapter Types (from Clawdbot core) -// ============================================================================ - -/** - * Chat capabilities supported by a channel - */ -export interface ChatCapabilities { - chatTypes: Array<"group" | "direct" | "thread">; - polls?: boolean; - reactions?: boolean; - edit?: boolean; - unsend?: boolean; - reply?: boolean; -} - -/** - * Channel metadata - */ -export interface ChannelMeta { - id: string; - label: string; - selectionLabel: string; - docsPath: string; - blurb: string; - aliases?: string[]; -} - -/** - * Channel outbound adapter - * - * NOTE: The full type should be imported from Clawdbot. This is a minimal - * version for compatibility during the refactor. - */ -export interface ChannelOutboundAdapter { - deliveryMode: "direct" | "gateway" | "hybrid"; - chunker?: ((text: string, limit: number) => string[]) | null; - textChunkLimit?: number; - pollMaxOptions?: number; - resolveTarget?: (params: { - cfg?: CoreConfig; - to?: string; - allowFrom?: string[]; - accountId?: string | null; - mode?: "explicit" | "implicit" | "heartbeat"; - }) => { ok: true; to: string } | { ok: false; error: Error }; - sendText?: ( - params: ChannelOutboundContext, - ) => Promise; - sendMedia?: ( - params: ChannelOutboundContext, - ) => Promise; -} - -/** - * Context for outbound operations - */ -export interface ChannelOutboundContext { - cfg: CoreConfig; - to: string; - text: string; - mediaUrl?: string; - gifPlayback?: boolean; - replyToId?: string | null; - threadId?: string | number | null; - accountId?: string | null; - deps?: unknown; - signal?: AbortSignal; -} - -/** - * Result from outbound delivery - */ -export interface OutboundDeliveryResult { - channel: string; - messageId: string; - timestamp?: number; - chatId?: string; - channelId?: string; - conversationId?: string; - to?: string; - meta?: Record; -} - -// ============================================================================ -// Channel Plugin Types -// ============================================================================ - -/** - * Channel plugin definition - */ -export interface ChannelPlugin { - id: string; - meta: ChannelMeta; - capabilities: ChatCapabilities; - config: { - listAccountIds: (cfg: CoreConfig) => string[]; - resolveAccount: ( - cfg: CoreConfig, - accountId?: string | null, - ) => ResolvedAccount | null; - defaultAccountId?: () => string; - isConfigured?: (account: unknown, cfg: CoreConfig) => boolean; - isEnabled?: (account: ResolvedAccount | undefined) => boolean; - describeAccount?: (account: ResolvedAccount | undefined) => { - accountId: string; - enabled?: boolean; - configured: boolean; - }; - }; - outbound?: ChannelOutboundAdapter; - inbound?: { - start?: () => Promise; - stop?: () => Promise; - }; - status?: ChannelStatusAdapter; - gateway?: ChannelGatewayAdapter; - actions?: ChannelMessageActionAdapter; - resolver?: { - resolveTargets: (params: { - cfg: CoreConfig; - accountId?: string | null; - inputs: string[]; - kind: ChannelResolveKind; - runtime: { log?: ProviderLogger }; - }) => Promise; - }; -} - -/** - * Extended channel plugin with optional adapters - */ -export interface ChannelPluginExtended< - ResolvedAccount = unknown, -> extends ChannelPlugin { - status?: ChannelStatusAdapter; - gateway?: ChannelGatewayAdapter; - actions?: ChannelMessageActionAdapter; -} - -/** - * Channel account snapshot - represents the current state of a channel account - */ -export interface ChannelAccountSnapshot { - accountId: string; - enabled?: boolean; - configured?: boolean; - running?: boolean; - lastStartAt?: number | null; - lastStopAt?: number | null; - lastError?: string | null; - lastInboundAt?: number | null; - lastOutboundAt?: number | null; - lastProbeAt?: number | null; - probe?: unknown; - audit?: unknown; - [key: string]: unknown; -} - -/** - * Status adapter for health checks and monitoring - */ -export interface ChannelStatusAdapter { - defaultRuntime?: ChannelAccountSnapshot; - buildChannelSummary?: (params: { - snapshot: ChannelAccountSnapshot; - }) => Record; - probeAccount?: (params: { - account: ResolvedAccount; - timeoutMs: number; - cfg: CoreConfig; - }) => Promise; - auditAccount?: (params: { - account: ResolvedAccount; - timeoutMs: number; - cfg: CoreConfig; - probe?: unknown; - }) => Promise; - buildAccountSnapshot?: (params: { - account: ResolvedAccount; - cfg: CoreConfig; - runtime?: ChannelAccountSnapshot; - probe?: unknown; - audit?: unknown; - }) => ChannelAccountSnapshot | Promise; - collectStatusIssues?: ( - accounts: ChannelAccountSnapshot[], - getCfg?: () => unknown, - ) => ChannelStatusIssue[]; -} - -/** - * Gateway context for channel operations - */ -export interface ChannelGatewayContext { - cfg: CoreConfig; - accountId: string; - account: unknown; - runtime?: Record; - abortSignal?: AbortSignal; - log?: ChannelLogSink; - getStatus?: () => ChannelAccountSnapshot; - setStatus?: (next: ChannelAccountSnapshot) => void; -} - -/** - * Gateway adapter for channel lifecycle management - */ -export interface ChannelGatewayAdapter<_ResolvedAccount = unknown> { - startAccount?: (ctx: ChannelGatewayContext) => Promise; - stopAccount?: (ctx: ChannelGatewayContext) => Promise; -} - -/** - * Channel log sink for gateway logging - */ -export interface ChannelLogSink { - info: (message: string) => void; - warn: (message: string) => void; - error: (message: string) => void; - debug?: (message: string) => void; -} - -/** - * Status issue for configuration/runtime problems - */ -export interface ChannelStatusIssue { - channel: string; - accountId: string; - kind: "intent" | "permissions" | "config" | "auth" | "runtime"; - message: string; - fix?: string; -} - -/** - * Message action adapter for handling tool-based actions - */ -export interface ChannelMessageActionAdapter { - listActions?: (params: { cfg: CoreConfig }) => string[]; - supportsAction?: (params: { action: string }) => boolean; - supportsButtons?: (params: { cfg: CoreConfig }) => boolean; - supportsCards?: (params: { cfg: CoreConfig }) => boolean; - extractToolSend?: (params: { - args: Record; - }) => { to: string; message: string } | null; - handleAction?: ( - ctx: ChannelMessageActionContext, - ) => Promise<{ content: Array<{ type: string; text: string }> } | null>; -} - -/** - * Context for message actions - */ -export interface ChannelMessageActionContext { - action: string; - params: Record; - accountId?: string; - cfg: CoreConfig; - logger?: ProviderLogger; -} - -// ============================================================================ -// Legacy Types (for backward compatibility during refactor) -// ============================================================================ - -/** - * Parameters for sending text (internal use, legacy) - * - * @deprecated Use ChannelOutboundContext instead - */ -export interface SendTextParams { - /** Target configuration */ - target: TwitchTarget; - /** Message text */ - text: string; - /** Core config */ - config: CoreConfig; - /** Logger */ - logger?: ProviderLogger; -} +export type { ClawdbotConfig }; +export type { RuntimeEnv }; diff --git a/tsconfig.declarations.json b/tsconfig.declarations.json new file mode 100644 index 000000000..1aef89a0f --- /dev/null +++ b/tsconfig.declarations.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmitOnError": false + } +} diff --git a/tsconfig.json b/tsconfig.json index 8f82c611d..879b14415 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,6 +5,7 @@ "moduleResolution": "NodeNext", "outDir": "dist", "rootDir": "src", + "declaration": true, "strict": true, "esModuleInterop": true, "forceConsistentCasingInFileNames": true,