wip type changes

This commit is contained in:
jaydenfyi 2026-01-24 19:00:25 +08:00
parent ebf7dcc054
commit 9693933b9e
15 changed files with 705 additions and 422 deletions

View File

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

459
docs/channels/twitch.md Normal file
View File

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

View File

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

View File

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

View File

@ -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<string, unknown>).channels as
| Record<string, unknown>
| undefined;

View File

@ -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<TwitchAccountConfig> = {
/** 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<TwitchAccountConfig> = {
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<TwitchAccountConfig> = {
kind,
runtime,
}: {
cfg: CoreConfig;
cfg: ClawdbotConfig;
accountId?: string | null;
inputs: string[];
kind: ChannelResolveKind;
runtime: { log?: ProviderLogger };
runtime: { log?: ChannelLogSink };
}): Promise<ChannelResolveResult[]> => {
const account = getAccountConfig(cfg, accountId ?? DEFAULT_ACCOUNT_ID);
@ -177,7 +177,7 @@ export const twitchPlugin: ChannelPlugin<TwitchAccountConfig> = {
probe,
}: {
account: TwitchAccountConfig;
cfg: CoreConfig;
cfg: ClawdbotConfig;
runtime?: ChannelAccountSnapshot;
probe?: unknown;
}): ChannelAccountSnapshot => {
@ -227,16 +227,33 @@ export const twitchPlugin: ChannelPlugin<TwitchAccountConfig> = {
});
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<TwitchAccountConfig> = {
);
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<TwitchAccountConfig> = {
},
},
};
/**
* 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;

View File

@ -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<ChannelResolveResult[]> {
const log = createLogger(logger);

View File

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

View File

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

View File

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

View File

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

View File

@ -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<ChatClient> {
async getClient(
account: TwitchAccountConfig,
cfg?: ClawdbotConfig,
): Promise<ChatClient> {
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)}`;

View File

@ -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<string, TwitchAccountConfig>;
}
/**
* 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<OutboundDeliveryResult>;
sendMedia?: (
params: ChannelOutboundContext,
) => Promise<OutboundDeliveryResult>;
}
/**
* 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<string, unknown>;
}
// ============================================================================
// Channel Plugin Types
// ============================================================================
/**
* Channel plugin definition
*/
export interface ChannelPlugin<ResolvedAccount = unknown> {
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<void>;
stop?: () => Promise<void>;
};
status?: ChannelStatusAdapter<ResolvedAccount>;
gateway?: ChannelGatewayAdapter<ResolvedAccount>;
actions?: ChannelMessageActionAdapter;
resolver?: {
resolveTargets: (params: {
cfg: CoreConfig;
accountId?: string | null;
inputs: string[];
kind: ChannelResolveKind;
runtime: { log?: ProviderLogger };
}) => Promise<ChannelResolveResult[]>;
};
}
/**
* Extended channel plugin with optional adapters
*/
export interface ChannelPluginExtended<
ResolvedAccount = unknown,
> extends ChannelPlugin<ResolvedAccount> {
status?: ChannelStatusAdapter<ResolvedAccount>;
gateway?: ChannelGatewayAdapter<ResolvedAccount>;
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<ResolvedAccount = unknown> {
defaultRuntime?: ChannelAccountSnapshot;
buildChannelSummary?: (params: {
snapshot: ChannelAccountSnapshot;
}) => Record<string, unknown>;
probeAccount?: (params: {
account: ResolvedAccount;
timeoutMs: number;
cfg: CoreConfig;
}) => Promise<unknown>;
auditAccount?: (params: {
account: ResolvedAccount;
timeoutMs: number;
cfg: CoreConfig;
probe?: unknown;
}) => Promise<unknown>;
buildAccountSnapshot?: (params: {
account: ResolvedAccount;
cfg: CoreConfig;
runtime?: ChannelAccountSnapshot;
probe?: unknown;
audit?: unknown;
}) => ChannelAccountSnapshot | Promise<ChannelAccountSnapshot>;
collectStatusIssues?: (
accounts: ChannelAccountSnapshot[],
getCfg?: () => unknown,
) => ChannelStatusIssue[];
}
/**
* Gateway context for channel operations
*/
export interface ChannelGatewayContext {
cfg: CoreConfig;
accountId: string;
account: unknown;
runtime?: Record<string, unknown>;
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<undefined | unknown>;
stopAccount?: (ctx: ChannelGatewayContext) => Promise<void>;
}
/**
* 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<string, unknown>;
}) => { 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<string, unknown>;
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 };

View File

@ -0,0 +1,6 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmitOnError": false
}
}

View File

@ -5,6 +5,7 @@
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"declaration": true,
"strict": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,