This commit is contained in:
jaydenfyi 2026-01-25 10:12:30 +08:00
parent 3dbca21334
commit 960bb108d0
30 changed files with 3423 additions and 3546 deletions

View File

@ -3,6 +3,7 @@
## 2026.1.23 ## 2026.1.23
### Features ### Features
- Initial Twitch plugin release - Initial Twitch plugin release
- Twitch chat integration via @twurple (IRC connection) - Twitch chat integration via @twurple (IRC connection)
- Multi-account support with per-channel configuration - Multi-account support with per-channel configuration
@ -14,6 +15,7 @@
- Outbound message delivery with markdown stripping - Outbound message delivery with markdown stripping
### Improvements ### Improvements
- Added proper configuration schema with Zod validation - Added proper configuration schema with Zod validation
- Added plugin descriptor (clawdbot.plugin.json) - Added plugin descriptor (clawdbot.plugin.json)
- Added comprehensive README and documentation - Added comprehensive README and documentation

View File

@ -29,11 +29,11 @@ Minimal config (default account):
default: { default: {
username: "mybot", username: "mybot",
token: "oauth:your_token_here", token: "oauth:your_token_here",
clientId: "your_client_id_here" clientId: "your_client_id_here",
} },
} },
} },
} },
} }
``` ```
@ -70,11 +70,11 @@ For long-running bots, configure automatic token refresh:
token: "oauth:abc123...", token: "oauth:abc123...",
clientId: "your_client_id", clientId: "your_client_id",
clientSecret: "your_client_secret", clientSecret: "your_client_secret",
refreshToken: "your_refresh_token" refreshToken: "your_refresh_token",
} },
} },
} },
} },
} }
``` ```
@ -91,11 +91,11 @@ Allowlist by user ID (recommended):
username: "mybot", username: "mybot",
token: "oauth:...", token: "oauth:...",
clientId: "...", clientId: "...",
allowFrom: ["123456789", "987654321"] allowFrom: ["123456789", "987654321"],
} },
} },
} },
} },
} }
``` ```
@ -110,11 +110,11 @@ Role-based restrictions:
username: "mybot", username: "mybot",
token: "oauth:...", token: "oauth:...",
clientId: "...", clientId: "...",
allowedRoles: ["moderator", "vip"] allowedRoles: ["moderator", "vip"],
} },
} },
} },
} },
} }
``` ```
@ -131,23 +131,24 @@ Available roles: `"moderator"`, `"owner"`, `"vip"`, `"subscriber"`, `"all"`
username: "mybot", username: "mybot",
token: "oauth:...", token: "oauth:...",
clientId: "...", clientId: "...",
channel: "streamer1" channel: "streamer1",
}, },
secondary: { secondary: {
username: "mybot", username: "mybot",
token: "oauth:...", token: "oauth:...",
clientId: "...", clientId: "...",
channel: "streamer2" channel: "streamer2",
} },
} },
} },
} },
} }
``` ```
## Environment variables ## Environment variables
For the default account: For the default account:
- `CLAWDBOT_TWITCH_ACCESS_TOKEN` - OAuth token (with `oauth:` prefix) - `CLAWDBOT_TWITCH_ACCESS_TOKEN` - OAuth token (with `oauth:` prefix)
Restart the gateway after config changes. Restart the gateway after config changes.
@ -155,6 +156,7 @@ Restart the gateway after config changes.
## Full documentation ## Full documentation
See [https://docs.clawd.bot/channels/twitch](https://docs.clawd.bot/channels/twitch) for complete documentation including: See [https://docs.clawd.bot/channels/twitch](https://docs.clawd.bot/channels/twitch) for complete documentation including:
- Token setup options - Token setup options
- Access control patterns - Access control patterns
- Troubleshooting - Troubleshooting

View File

@ -1,8 +1,6 @@
{ {
"id": "twitch", "id": "twitch",
"channels": [ "channels": ["twitch"],
"twitch"
],
"configSchema": { "configSchema": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,

View File

@ -1,13 +1,8 @@
{ {
"name": "@clawdbot/twitch", "name": "@clawdbot/twitch",
"version": "2026.1.23", "version": "2026.1.23",
"type": "module",
"description": "Clawdbot Twitch channel plugin", "description": "Clawdbot Twitch channel plugin",
"clawdbot": { "type": "module",
"extensions": [
"./index.ts"
]
},
"dependencies": { "dependencies": {
"@twurple/api": "^8.0.3", "@twurple/api": "^8.0.3",
"@twurple/auth": "^8.0.3", "@twurple/auth": "^8.0.3",
@ -16,5 +11,10 @@
}, },
"devDependencies": { "devDependencies": {
"clawdbot": "workspace:*" "clawdbot": "workspace:*"
},
"clawdbot": {
"extensions": [
"./index.ts"
]
} }
} }

View File

@ -203,9 +203,7 @@ describe("checkTwitchAccessControl", () => {
botUsername: "testbot", botUsername: "testbot",
}); });
expect(result.allowed).toBe(false); expect(result.allowed).toBe(false);
expect(result.reason).toContain( expect(result.reason).toContain("does not have any of the required roles");
"does not have any of the required roles",
);
}); });
it("allows all users when role is 'all'", () => { it("allows all users when role is 'all'", () => {

View File

@ -119,10 +119,7 @@ export function checkTwitchAccessControl(params: {
/** /**
* Check if the sender has any of the allowed roles * Check if the sender has any of the allowed roles
*/ */
function checkSenderRoles(params: { function checkSenderRoles(params: { message: TwitchChatMessage; allowedRoles: string[] }): boolean {
message: TwitchChatMessage;
allowedRoles: string[];
}): boolean {
const { message, allowedRoles } = params; const { message, allowedRoles } = params;
const { isMod, isOwner, isVip, isSub } = message; const { isMod, isOwner, isVip, isSub } = message;

View File

@ -6,10 +6,7 @@
import { DEFAULT_ACCOUNT_ID, getAccountConfig } from "./config.js"; import { DEFAULT_ACCOUNT_ID, getAccountConfig } from "./config.js";
import { twitchOutbound } from "./outbound.js"; import { twitchOutbound } from "./outbound.js";
import type { import type { ChannelMessageActionAdapter, ChannelMessageActionContext } from "./types.js";
ChannelMessageActionAdapter,
ChannelMessageActionContext,
} from "./types.js";
/** /**
* Create a tool result with error content. * Create a tool result with error content.

View File

@ -72,9 +72,7 @@ export function getClientManager(accountId: string): TwitchClientManager | undef
* @param accountId - The account ID * @param accountId - The account ID
* @returns Promise that resolves when cleanup is complete * @returns Promise that resolves when cleanup is complete
*/ */
export async function removeClientManager( export async function removeClientManager(accountId: string): Promise<void> {
accountId: string,
): Promise<void> {
const entry = registry.get(accountId); const entry = registry.get(accountId);
if (!entry) { if (!entry) {
return; return;
@ -97,9 +95,7 @@ export async function removeClientManager(
* @returns Promise that resolves when all cleanup is complete * @returns Promise that resolves when all cleanup is complete
*/ */
export async function removeAllClientManagers(): Promise<void> { export async function removeAllClientManagers(): Promise<void> {
const promises = Array.from(registry.keys()).map((accountId) => const promises = Array.from(registry.keys()).map((accountId) => removeClientManager(accountId));
removeClientManager(accountId),
);
await Promise.all(promises); await Promise.all(promises);
} }

View File

@ -19,16 +19,12 @@ describe("parsePluginConfig", () => {
}); });
it("handles undefined config", () => { it("handles undefined config", () => {
const result = parsePluginConfig( const result = parsePluginConfig(undefined as unknown as Record<string, unknown>);
undefined as unknown as Record<string, unknown>,
);
expect(result).toEqual({ stripMarkdown: true }); // Returns defaults when value is falsy expect(result).toEqual({ stripMarkdown: true }); // Returns defaults when value is falsy
}); });
it("handles null config", () => { it("handles null config", () => {
const result = parsePluginConfig( const result = parsePluginConfig(null as unknown as Record<string, unknown>);
null as unknown as Record<string, unknown>,
);
expect(result).toEqual({ stripMarkdown: true }); // Returns defaults when value is null expect(result).toEqual({ stripMarkdown: true }); // Returns defaults when value is null
}); });
}); });

View File

@ -1,8 +1,5 @@
import type { ClawdbotConfig } from "clawdbot/plugin-sdk"; import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
import type { import type { TwitchAccountConfig, TwitchPluginConfig } from "./types.js";
TwitchAccountConfig,
TwitchPluginConfig,
} from "./types.js";
/** /**
* Default account ID for Twitch * Default account ID for Twitch
@ -42,8 +39,7 @@ export function parsePluginConfig(value: unknown): TwitchPluginConfig {
const raw = value as Record<string, unknown>; const raw = value as Record<string, unknown>;
return { return {
stripMarkdown: stripMarkdown: typeof raw.stripMarkdown === "boolean" ? raw.stripMarkdown : true,
typeof raw.stripMarkdown === "boolean" ? raw.stripMarkdown : true,
}; };
} }
@ -51,9 +47,7 @@ export function parsePluginConfig(value: unknown): TwitchPluginConfig {
* List all configured account IDs * List all configured account IDs
*/ */
export function listAccountIds(cfg: ClawdbotConfig): string[] { export function listAccountIds(cfg: ClawdbotConfig): string[] {
const accounts = (cfg as Record<string, unknown>).channels as const accounts = (cfg as Record<string, unknown>).channels as Record<string, unknown> | undefined;
| Record<string, unknown>
| undefined;
const twitch = accounts?.twitch as Record<string, unknown> | undefined; const twitch = accounts?.twitch as Record<string, unknown> | undefined;
const accountMap = twitch?.accounts as Record<string, unknown> | undefined; const accountMap = twitch?.accounts as Record<string, unknown> | undefined;

View File

@ -62,7 +62,9 @@ async function processTwitchMessage(params: {
channel: "Twitch", channel: "Twitch",
from: message.displayName ?? message.username, from: message.displayName ?? message.username,
timestamp: message.timestamp?.getTime(), timestamp: message.timestamp?.getTime(),
envelope: core.channel.reply.resolveEnvelopeFormatOptions(config as Parameters<typeof core.channel.reply.resolveEnvelopeFormatOptions>[0]), envelope: core.channel.reply.resolveEnvelopeFormatOptions(
config as Parameters<typeof core.channel.reply.resolveEnvelopeFormatOptions>[0],
),
body: rawBody, body: rawBody,
}); });
@ -111,7 +113,9 @@ async function processTwitchMessage(params: {
// Dispatch reply // Dispatch reply
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({ await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload, ctx: ctxPayload,
cfg: config as Parameters<typeof core.channel.reply.dispatchReplyWithBufferedBlockDispatcher>[0]["cfg"], cfg: config as Parameters<
typeof core.channel.reply.dispatchReplyWithBufferedBlockDispatcher
>[0]["cfg"],
dispatcherOptions: { dispatcherOptions: {
deliver: async (payload) => { deliver: async (payload) => {
await deliverTwitchReply({ await deliverTwitchReply({
@ -188,7 +192,11 @@ export async function monitorTwitchProvider(
// Establish connection // Establish connection
try { try {
await clientManager.getClient(account, config as Parameters<typeof clientManager.getClient>[1], accountId); await clientManager.getClient(
account,
config as Parameters<typeof clientManager.getClient>[1],
accountId,
);
runtime.log?.(`[twitch] Connected to Twitch as ${account.username}`); runtime.log?.(`[twitch] Connected to Twitch as ${account.username}`);
} catch (error) { } catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error); const errorMsg = error instanceof Error ? error.message : String(error);

View File

@ -220,8 +220,7 @@ describe("onboarding helpers", () => {
expect(result).toEqual({}); expect(result).toEqual({});
expect(mockPromptConfirm).toHaveBeenCalledWith({ expect(mockPromptConfirm).toHaveBeenCalledWith({
message: message: "Enable automatic token refresh (requires client secret and refresh token)?",
"Enable automatic token refresh (requires client secret and refresh token)?",
initialValue: false, initialValue: false,
}); });
}); });
@ -234,9 +233,7 @@ describe("onboarding helpers", () => {
.mockResolvedValueOnce("secret123") // clientSecret .mockResolvedValueOnce("secret123") // clientSecret
.mockResolvedValueOnce("refresh123"); // refreshToken .mockResolvedValueOnce("refresh123"); // refreshToken
mockPromptText mockPromptText.mockResolvedValueOnce("secret123").mockResolvedValueOnce("refresh123");
.mockResolvedValueOnce("secret123")
.mockResolvedValueOnce("refresh123");
const result = await promptRefreshTokenSetup(mockPrompter, null); const result = await promptRefreshTokenSetup(mockPrompter, null);
@ -298,7 +295,10 @@ describe("onboarding helpers", () => {
mockPromptConfirm.mockReset().mockResolvedValue(true as never); mockPromptConfirm.mockReset().mockResolvedValue(true as never);
// Set up mocks for username and clientId prompts // Set up mocks for username and clientId prompts
mockPromptText.mockReset().mockResolvedValueOnce("testbot" as never).mockResolvedValueOnce("test-client-id" as never); mockPromptText
.mockReset()
.mockResolvedValueOnce("testbot" as never)
.mockResolvedValueOnce("test-client-id" as never);
const result = await configureWithEnvToken( const result = await configureWithEnvToken(
{} as Parameters<typeof configureWithEnvToken>[0], {} as Parameters<typeof configureWithEnvToken>[0],

View File

@ -9,10 +9,7 @@ import {
type ChannelOnboardingDmPolicy, type ChannelOnboardingDmPolicy,
type WizardPrompter, type WizardPrompter,
} from "clawdbot/plugin-sdk"; } from "clawdbot/plugin-sdk";
import { import { DEFAULT_ACCOUNT_ID, getAccountConfig } from "./config.js";
DEFAULT_ACCOUNT_ID,
getAccountConfig,
} from "./config.js";
import type { TwitchAccountConfig, TwitchRole } from "./types.js"; import type { TwitchAccountConfig, TwitchRole } from "./types.js";
import type { ClawdbotConfig } from "clawdbot/plugin-sdk"; import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
@ -60,10 +57,14 @@ function setTwitchAccount(
channels: { channels: {
...cfg.channels, ...cfg.channels,
twitch: { twitch: {
...((cfg.channels as Record<string, unknown>)?.twitch as Record<string, unknown> | undefined), ...((cfg.channels as Record<string, unknown>)?.twitch as
| Record<string, unknown>
| undefined),
enabled: true, enabled: true,
accounts: { accounts: {
...(((cfg.channels as Record<string, unknown>)?.twitch as Record<string, unknown> | undefined)?.accounts as Record<string, unknown> | undefined), ...((
(cfg.channels as Record<string, unknown>)?.twitch as Record<string, unknown> | undefined
)?.accounts as Record<string, unknown> | undefined),
[DEFAULT_ACCOUNT_ID]: merged, [DEFAULT_ACCOUNT_ID]: merged,
}, },
}, },
@ -191,7 +192,8 @@ async function promptRefreshTokenSetup(
return {}; return {};
} }
const clientSecret = String( const clientSecret =
String(
await prompter.text({ await prompter.text({
message: "Twitch Client Secret (for token refresh)", message: "Twitch Client Secret (for token refresh)",
initialValue: account?.clientSecret ?? "", initialValue: account?.clientSecret ?? "",
@ -199,7 +201,8 @@ async function promptRefreshTokenSetup(
}), }),
).trim() || undefined; ).trim() || undefined;
const refreshToken = String( const refreshToken =
String(
await prompter.text({ await prompter.text({
message: "Twitch Refresh Token", message: "Twitch Refresh Token",
initialValue: account?.refreshToken ?? "", initialValue: account?.refreshToken ?? "",
@ -315,9 +318,7 @@ export const twitchOnboardingAdapter: ChannelOnboardingAdapter = {
return { return {
channel, channel,
configured, configured,
statusLines: [ statusLines: [`Twitch: ${configured ? "configured" : "needs username, token, and clientId"}`],
`Twitch: ${configured ? "configured" : "needs username, token, and clientId"}`,
],
selectionHint: configured ? "configured" : "needs setup", selectionHint: configured ? "configured" : "needs setup",
}; };
}, },
@ -399,7 +400,9 @@ export const twitchOnboardingAdapter: ChannelOnboardingAdapter = {
}, },
dmPolicy, dmPolicy,
disable: (cfg) => { disable: (cfg) => {
const twitch = (cfg.channels as Record<string, unknown>)?.twitch as Record<string, unknown> | undefined; const twitch = (cfg.channels as Record<string, unknown>)?.twitch as
| Record<string, unknown>
| undefined;
return { return {
...cfg, ...cfg,
channels: { channels: {
@ -411,4 +414,11 @@ export const twitchOnboardingAdapter: ChannelOnboardingAdapter = {
}; };
// Export helper functions for testing // Export helper functions for testing
export { promptToken, promptUsername, promptClientId, promptChannelName, promptRefreshTokenSetup, configureWithEnvToken }; export {
promptToken,
promptUsername,
promptClientId,
promptChannelName,
promptRefreshTokenSetup,
configureWithEnvToken,
};

View File

@ -5,11 +5,7 @@
* Supports text and media (URL) sending with markdown stripping and chunking. * Supports text and media (URL) sending with markdown stripping and chunking.
*/ */
import { import { DEFAULT_ACCOUNT_ID, getAccountConfig, parsePluginConfig } from "./config.js";
DEFAULT_ACCOUNT_ID,
getAccountConfig,
parsePluginConfig,
} from "./config.js";
import { sendMessageTwitchInternal } from "./send.js"; import { sendMessageTwitchInternal } from "./send.js";
import type { import type {
ChannelOutboundAdapter, ChannelOutboundAdapter,
@ -109,9 +105,7 @@ export const twitchOutbound: ChannelOutboundAdapter = {
* accountId: "default", * accountId: "default",
* }); * });
*/ */
sendText: async ( sendText: async (params: ChannelOutboundContext): Promise<OutboundDeliveryResult> => {
params: ChannelOutboundContext,
): Promise<OutboundDeliveryResult> => {
const { cfg, to, text, accountId, signal } = params; const { cfg, to, text, accountId, signal } = params;
// Check for abort signal // Check for abort signal
@ -133,9 +127,7 @@ export const twitchOutbound: ChannelOutboundAdapter = {
// Get channel (support target parameter) // Get channel (support target parameter)
const channel = to || account.channel || account.username; const channel = to || account.channel || account.username;
if (!channel) { if (!channel) {
throw new Error( throw new Error("No channel specified and no default channel in account config");
"No channel specified and no default channel in account config",
);
} }
// Get plugin config for markdown stripping // Get plugin config for markdown stripping
@ -184,9 +176,7 @@ export const twitchOutbound: ChannelOutboundAdapter = {
* accountId: "default", * accountId: "default",
* }); * });
*/ */
sendMedia: async ( sendMedia: async (params: ChannelOutboundContext): Promise<OutboundDeliveryResult> => {
params: ChannelOutboundContext,
): Promise<OutboundDeliveryResult> => {
const { text, mediaUrl, signal } = params; const { text, mediaUrl, signal } = params;
// Check for abort signal // Check for abort signal

View File

@ -9,11 +9,7 @@ import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
import { buildChannelConfigSchema } from "clawdbot/plugin-sdk"; import { buildChannelConfigSchema } from "clawdbot/plugin-sdk";
import { twitchMessageActions } from "./actions.js"; import { twitchMessageActions } from "./actions.js";
import { TwitchConfigSchema } from "./config-schema.js"; import { TwitchConfigSchema } from "./config-schema.js";
import { import { DEFAULT_ACCOUNT_ID, getAccountConfig, listAccountIds } from "./config.js";
DEFAULT_ACCOUNT_ID,
getAccountConfig,
listAccountIds,
} from "./config.js";
import { twitchOnboardingAdapter } from "./onboarding.js"; import { twitchOnboardingAdapter } from "./onboarding.js";
import { twitchOutbound } from "./outbound.js"; import { twitchOutbound } from "./outbound.js";
import { probeTwitch } from "./probe.js"; import { probeTwitch } from "./probe.js";
@ -34,9 +30,7 @@ import type {
/** /**
* Check if an account is properly configured. * Check if an account is properly configured.
*/ */
function isConfigured( function isConfigured(account: TwitchAccountConfig | null | undefined): boolean {
account: TwitchAccountConfig | null | undefined,
): boolean {
return Boolean(account?.token && account?.username && account?.clientId); return Boolean(account?.token && account?.username && account?.clientId);
} }
@ -71,7 +65,9 @@ export const twitchPlugin: ChannelPlugin<TwitchAccountConfig> = {
notifyApproval: async ({ id }) => { notifyApproval: async ({ id }) => {
// Note: Twitch doesn't support DMs from bots, so pairing approval is limited // Note: Twitch doesn't support DMs from bots, so pairing approval is limited
// We'll log the approval instead // We'll log the approval instead
console.warn(`[twitch] Pairing approved for user ${id} (notification sent via chat if possible)`); console.warn(
`[twitch] Pairing approved for user ${id} (notification sent via chat if possible)`,
);
}, },
}, },
@ -89,10 +85,7 @@ export const twitchPlugin: ChannelPlugin<TwitchAccountConfig> = {
listAccountIds: (cfg: ClawdbotConfig): string[] => listAccountIds(cfg), listAccountIds: (cfg: ClawdbotConfig): string[] => listAccountIds(cfg),
/** Resolve an account config by ID */ /** Resolve an account config by ID */
resolveAccount: ( resolveAccount: (cfg: ClawdbotConfig, accountId?: string | null): TwitchAccountConfig => {
cfg: ClawdbotConfig,
accountId?: string | null,
): TwitchAccountConfig => {
const account = getAccountConfig(cfg, accountId ?? DEFAULT_ACCOUNT_ID); const account = getAccountConfig(cfg, accountId ?? DEFAULT_ACCOUNT_ID);
if (!account) { if (!account) {
// Return a default/empty account if not configured // Return a default/empty account if not configured
@ -116,8 +109,7 @@ export const twitchPlugin: ChannelPlugin<TwitchAccountConfig> = {
}, },
/** Check if an account is enabled */ /** Check if an account is enabled */
isEnabled: (account: TwitchAccountConfig | undefined): boolean => isEnabled: (account: TwitchAccountConfig | undefined): boolean => account?.enabled !== false,
account?.enabled !== false,
/** Describe account status */ /** Describe account status */
describeAccount: (account: TwitchAccountConfig | undefined) => ({ describeAccount: (account: TwitchAccountConfig | undefined) => ({
@ -181,11 +173,7 @@ export const twitchPlugin: ChannelPlugin<TwitchAccountConfig> = {
}, },
/** Build channel summary from snapshot */ /** Build channel summary from snapshot */
buildChannelSummary: ({ buildChannelSummary: ({ snapshot }: { snapshot: ChannelAccountSnapshot }) => ({
snapshot,
}: {
snapshot: ChannelAccountSnapshot;
}) => ({
configured: snapshot.configured ?? false, configured: snapshot.configured ?? false,
running: snapshot.running ?? false, running: snapshot.running ?? false,
lastStartAt: snapshot.lastStartAt ?? null, lastStartAt: snapshot.lastStartAt ?? null,
@ -247,9 +235,7 @@ export const twitchPlugin: ChannelPlugin<TwitchAccountConfig> = {
lastError: null, lastError: null,
}); });
ctx.log?.info( ctx.log?.info(`[twitch] Starting Twitch connection for ${account.username}`);
`[twitch] Starting Twitch connection for ${account.username}`,
);
// Lazy import: the monitor pulls the reply pipeline; avoid ESM init cycles. // Lazy import: the monitor pulls the reply pipeline; avoid ESM init cycles.
const { monitorTwitchProvider } = await import("./monitor.js"); const { monitorTwitchProvider } = await import("./monitor.js");
@ -276,9 +262,7 @@ export const twitchPlugin: ChannelPlugin<TwitchAccountConfig> = {
lastStopAt: Date.now(), lastStopAt: Date.now(),
}); });
ctx.log?.info( ctx.log?.info(`[twitch] Stopped Twitch connection for ${account.username}`);
`[twitch] Stopped Twitch connection for ${account.username}`,
);
}, },
}, },
}; };

View File

@ -41,10 +41,7 @@ export async function probeTwitch(
try { try {
// Create auth provider with the token // Create auth provider with the token
const authProvider = new StaticAuthProvider( const authProvider = new StaticAuthProvider(account.clientId ?? "", rawToken);
account.clientId ?? "",
rawToken,
);
// Create chat client // Create chat client
client = new ChatClient({ client = new ChatClient({

View File

@ -65,10 +65,7 @@ export async function resolveTwitchTargets(
const normalizedToken = normalizeToken(account.token); const normalizedToken = normalizeToken(account.token);
// Create auth provider and API client // Create auth provider and API client
const authProvider = new StaticAuthProvider( const authProvider = new StaticAuthProvider(account.clientId, normalizedToken);
account.clientId,
normalizedToken,
);
const apiClient = new ApiClient({ authProvider }); const apiClient = new ApiClient({ authProvider });
const results: ChannelResolveResult[] = []; const results: ChannelResolveResult[] = [];
@ -121,14 +118,9 @@ export async function resolveTwitchTargets(
resolved: true, resolved: true,
id: user.id, id: user.id,
name: user.name, name: user.name,
note: note: user.displayName !== user.name ? `display: ${user.displayName}` : undefined,
user.displayName !== user.name
? `display: ${user.displayName}`
: undefined,
}); });
log.debug( log.debug(`Resolved username ${normalized} -> ${user.id} (${user.name})`);
`Resolved username ${normalized} -> ${user.id} (${user.name})`,
);
} else { } else {
results.push({ results.push({
input, input,
@ -139,8 +131,7 @@ export async function resolveTwitchTargets(
} }
} }
} catch (error) { } catch (error) {
const errorMessage = const errorMessage = error instanceof Error ? error.message : String(error);
error instanceof Error ? error.message : String(error);
results.push({ results.push({
input, input,
resolved: false, resolved: false,

View File

@ -107,9 +107,7 @@ describe("send", () => {
messageId: "twitch-msg-456", messageId: "twitch-msg-456",
}), }),
} as ReturnType<typeof getClientManager>); } as ReturnType<typeof getClientManager>);
vi.mocked(stripMarkdownForTwitch).mockImplementation((text) => vi.mocked(stripMarkdownForTwitch).mockImplementation((text) => text.replace(/\*\*/g, ""));
text.replace(/\*\*/g, ""),
);
await sendMessageTwitchInternal( await sendMessageTwitchInternal(
"#testchannel", "#testchannel",

View File

@ -9,11 +9,7 @@ import { DEFAULT_ACCOUNT_ID, getAccountConfig } from "./config.js";
import { getClientManager as getRegistryClientManager } from "./client-manager-registry.js"; import { getClientManager as getRegistryClientManager } from "./client-manager-registry.js";
import type { ClawdbotConfig } from "clawdbot/plugin-sdk"; import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
import { stripMarkdownForTwitch } from "./utils/markdown.js"; import { stripMarkdownForTwitch } from "./utils/markdown.js";
import { import { generateMessageId, isAccountConfigured, normalizeTwitchChannel } from "./utils/twitch.js";
generateMessageId,
isAccountConfigured,
normalizeTwitchChannel,
} from "./utils/twitch.js";
/** /**
* Result from sending a message to Twitch. * Result from sending a message to Twitch.

View File

@ -74,10 +74,7 @@ describe("status", () => {
}, },
}; };
const issues = collectTwitchStatusIssues( const issues = collectTwitchStatusIssues(snapshots, () => mockCfg as never);
snapshots,
() => mockCfg as never,
);
const clientIdIssue = issues.find((i) => i.message.includes("client ID")); const clientIdIssue = issues.find((i) => i.message.includes("client ID"));
expect(clientIdIssue).toBeDefined(); expect(clientIdIssue).toBeDefined();
@ -107,10 +104,7 @@ describe("status", () => {
}, },
}; };
const issues = collectTwitchStatusIssues( const issues = collectTwitchStatusIssues(snapshots, () => mockCfg as never);
snapshots,
() => mockCfg as never,
);
const prefixIssue = issues.find((i) => i.message.includes("oauth:")); const prefixIssue = issues.find((i) => i.message.includes("oauth:"));
expect(prefixIssue).toBeDefined(); expect(prefixIssue).toBeDefined();
@ -143,10 +137,7 @@ describe("status", () => {
}, },
}; };
const issues = collectTwitchStatusIssues( const issues = collectTwitchStatusIssues(snapshots, () => mockCfg as never);
snapshots,
() => mockCfg as never,
);
const secretIssue = issues.find((i) => i.message.includes("clientSecret")); const secretIssue = issues.find((i) => i.message.includes("clientSecret"));
expect(secretIssue).toBeDefined(); expect(secretIssue).toBeDefined();
@ -177,10 +168,7 @@ describe("status", () => {
}, },
}; };
const issues = collectTwitchStatusIssues( const issues = collectTwitchStatusIssues(snapshots, () => mockCfg as never);
snapshots,
() => mockCfg as never,
);
const allowFromIssue = issues.find((i) => i.message.includes("allowFrom")); const allowFromIssue = issues.find((i) => i.message.includes("allowFrom"));
expect(allowFromIssue).toBeDefined(); expect(allowFromIssue).toBeDefined();
@ -212,10 +200,7 @@ describe("status", () => {
}, },
}; };
const issues = collectTwitchStatusIssues( const issues = collectTwitchStatusIssues(snapshots, () => mockCfg as never);
snapshots,
() => mockCfg as never,
);
const conflictIssue = issues.find((i) => i.kind === "intent"); const conflictIssue = issues.find((i) => i.kind === "intent");
expect(conflictIssue).toBeDefined(); expect(conflictIssue).toBeDefined();

View File

@ -87,7 +87,6 @@ export function collectTwitchStatusIssues(
// Checks that require account config // Checks that require account config
if (account && isAccountConfigured(account)) { if (account && isAccountConfigured(account)) {
// Check 4: Token format warning (normalized, but may indicate config issue) // Check 4: Token format warning (normalized, but may indicate config issue)
if (account.token?.startsWith("oauth:")) { if (account.token?.startsWith("oauth:")) {
issues.push({ issues.push({
@ -131,8 +130,7 @@ export function collectTwitchStatusIssues(
channel: "twitch", channel: "twitch",
accountId, accountId,
kind: "intent", kind: "intent",
message: message: "allowedRoles is set to 'all' but allowFrom is also configured",
"allowedRoles is set to 'all' but allowFrom is also configured",
fix: "When allowedRoles is 'all', the allowFrom list is not needed. Remove allowFrom or set allowedRoles to specific roles.", fix: "When allowedRoles is 'all', the allowFrom list is not needed. Remove allowFrom or set allowedRoles to specific roles.",
}); });
} }

View File

@ -57,9 +57,7 @@ export function resolveTwitchToken(
// 2. Base config token (default account only) // 2. Base config token (default account only)
const allowEnv = accountId === DEFAULT_ACCOUNT_ID; const allowEnv = accountId === DEFAULT_ACCOUNT_ID;
const configToken = allowEnv const configToken = allowEnv ? normalizeTwitchToken(twitchCfg?.token ?? undefined) : undefined;
? normalizeTwitchToken(twitchCfg?.token ?? undefined)
: undefined;
if (configToken) { if (configToken) {
return { token: configToken, source: "config" }; return { token: configToken, source: "config" };
} }

View File

@ -11,11 +11,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { TwitchClientManager } from "./twitch-client.js"; import { TwitchClientManager } from "./twitch-client.js";
import type { import type { ChannelLogSink, TwitchAccountConfig, TwitchChatMessage } from "./types.js";
ChannelLogSink,
TwitchAccountConfig,
TwitchChatMessage,
} from "./types.js";
// Mock @twurple dependencies // Mock @twurple dependencies
const mockConnect = vi.fn().mockResolvedValue(undefined); const mockConnect = vi.fn().mockResolvedValue(undefined);
@ -166,10 +162,7 @@ describe("TwitchClientManager", () => {
await manager.getClient(accountWithPrefix); await manager.getClient(accountWithPrefix);
expect(mockAuthProvider.constructor).toHaveBeenCalledWith( expect(mockAuthProvider.constructor).toHaveBeenCalledWith("test-client-id", "actualtoken123");
"test-client-id",
"actualtoken123",
);
}); });
it("should use token directly when no oauth: prefix", async () => { it("should use token directly when no oauth: prefix", async () => {
@ -212,9 +205,7 @@ describe("TwitchClientManager", () => {
source: "none" as const, source: "none" as const,
}); });
await expect(manager.getClient(testAccount)).rejects.toThrow( await expect(manager.getClient(testAccount)).rejects.toThrow("Missing Twitch token");
"Missing Twitch token",
);
}); });
it("should set up message handlers on client connection", async () => { it("should set up message handlers on client connection", async () => {
@ -222,9 +213,7 @@ describe("TwitchClientManager", () => {
expect(mockOnMessage).toHaveBeenCalled(); expect(mockOnMessage).toHaveBeenCalled();
expect(mockOnWhisper).toHaveBeenCalled(); expect(mockOnWhisper).toHaveBeenCalled();
expect(mockLogger.info).toHaveBeenCalledWith( expect(mockLogger.info).toHaveBeenCalledWith(expect.stringContaining("Set up handlers for"));
expect.stringContaining("Set up handlers for"),
);
}); });
it("should create separate clients for same account with different channels", async () => { it("should create separate clients for same account with different channels", async () => {
@ -271,9 +260,7 @@ describe("TwitchClientManager", () => {
await manager.disconnect(testAccount); await manager.disconnect(testAccount);
expect(mockQuit).toHaveBeenCalledTimes(1); expect(mockQuit).toHaveBeenCalledTimes(1);
expect(mockLogger.info).toHaveBeenCalledWith( expect(mockLogger.info).toHaveBeenCalledWith(expect.stringContaining("Disconnected"));
expect.stringContaining("Disconnected"),
);
}); });
it("should clear client and message handler", async () => { it("should clear client and message handler", async () => {
@ -332,11 +319,7 @@ describe("TwitchClientManager", () => {
}); });
it("should send message successfully", async () => { it("should send message successfully", async () => {
const result = await manager.sendMessage( const result = await manager.sendMessage(testAccount, "testchannel", "Hello, world!");
testAccount,
"testchannel",
"Hello, world!",
);
expect(result.ok).toBe(true); expect(result.ok).toBe(true);
expect(result.messageId).toBeDefined(); expect(result.messageId).toBeDefined();
@ -344,16 +327,8 @@ describe("TwitchClientManager", () => {
}); });
it("should generate unique message ID for each message", async () => { it("should generate unique message ID for each message", async () => {
const result1 = await manager.sendMessage( const result1 = await manager.sendMessage(testAccount, "testchannel", "First message");
testAccount, const result2 = await manager.sendMessage(testAccount, "testchannel", "Second message");
"testchannel",
"First message",
);
const result2 = await manager.sendMessage(
testAccount,
"testchannel",
"Second message",
);
expect(result1.messageId).not.toBe(result2.messageId); expect(result1.messageId).not.toBe(result2.messageId);
}); });
@ -373,11 +348,7 @@ describe("TwitchClientManager", () => {
it("should return error on send failure", async () => { it("should return error on send failure", async () => {
mockSay.mockRejectedValueOnce(new Error("Rate limited")); mockSay.mockRejectedValueOnce(new Error("Rate limited"));
const result = await manager.sendMessage( const result = await manager.sendMessage(testAccount, "testchannel", "Test message");
testAccount,
"testchannel",
"Test message",
);
expect(result.ok).toBe(false); expect(result.ok).toBe(false);
expect(result.error).toBe("Rate limited"); expect(result.error).toBe("Rate limited");
@ -389,11 +360,7 @@ describe("TwitchClientManager", () => {
it("should handle unknown error types", async () => { it("should handle unknown error types", async () => {
mockSay.mockRejectedValueOnce("String error"); mockSay.mockRejectedValueOnce("String error");
const result = await manager.sendMessage( const result = await manager.sendMessage(testAccount, "testchannel", "Test message");
testAccount,
"testchannel",
"Test message",
);
expect(result.ok).toBe(false); expect(result.ok).toBe(false);
expect(result.error).toBe("String error"); expect(result.error).toBe("String error");
@ -406,16 +373,10 @@ describe("TwitchClientManager", () => {
// Reset connect call count for this specific test // Reset connect call count for this specific test
const connectCallCountBefore = mockConnect.mock.calls.length; const connectCallCountBefore = mockConnect.mock.calls.length;
const result = await manager.sendMessage( const result = await manager.sendMessage(testAccount, "testchannel", "Test message");
testAccount,
"testchannel",
"Test message",
);
expect(result.ok).toBe(true); expect(result.ok).toBe(true);
expect(mockConnect.mock.calls.length).toBeGreaterThan( expect(mockConnect.mock.calls.length).toBeGreaterThan(connectCallCountBefore);
connectCallCountBefore,
);
}); });
}); });

View File

@ -1,11 +1,7 @@
import { RefreshingAuthProvider, StaticAuthProvider } from "@twurple/auth"; import { RefreshingAuthProvider, StaticAuthProvider } from "@twurple/auth";
import { ChatClient, LogLevel } from "@twurple/chat"; import { ChatClient, LogLevel } from "@twurple/chat";
import type { ClawdbotConfig } from "clawdbot/plugin-sdk"; import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
import type { import type { ChannelLogSink, TwitchAccountConfig, TwitchChatMessage } from "./types.js";
ChannelLogSink,
TwitchAccountConfig,
TwitchChatMessage,
} from "./types.js";
import { resolveTwitchToken } from "./token.js"; import { resolveTwitchToken } from "./token.js";
import { normalizeToken } from "./utils/twitch.js"; import { normalizeToken } from "./utils/twitch.js";
@ -14,10 +10,7 @@ import { normalizeToken } from "./utils/twitch.js";
*/ */
export class TwitchClientManager { export class TwitchClientManager {
private clients = new Map<string, ChatClient>(); private clients = new Map<string, ChatClient>();
private messageHandlers = new Map< private messageHandlers = new Map<string, (message: TwitchChatMessage) => void>();
string,
(message: TwitchChatMessage) => void
>();
constructor(private logger: ChannelLogSink) {} constructor(private logger: ChannelLogSink) {}
@ -122,9 +115,7 @@ export class TwitchClientManager {
); );
if (!account.clientId) { if (!account.clientId) {
this.logger.error( this.logger.error(`[twitch] Missing Twitch client ID for account ${account.username}`);
`[twitch] Missing Twitch client ID for account ${account.username}`,
);
throw new Error("Missing Twitch client ID"); throw new Error("Missing Twitch client ID");
} }
@ -186,19 +177,14 @@ export class TwitchClientManager {
/** /**
* Set up message and event handlers for a client * Set up message and event handlers for a client
*/ */
private setupClientHandlers( private setupClientHandlers(client: ChatClient, account: TwitchAccountConfig): void {
client: ChatClient,
account: TwitchAccountConfig,
): void {
const key = this.getAccountKey(account); const key = this.getAccountKey(account);
// Handle incoming messages // Handle incoming messages
client.onMessage((channelName, _user, messageText, msg) => { client.onMessage((channelName, _user, messageText, msg) => {
const handler = this.messageHandlers.get(key); const handler = this.messageHandlers.get(key);
if (handler) { if (handler) {
const normalizedChannel = channelName.startsWith("#") const normalizedChannel = channelName.startsWith("#") ? channelName.slice(1) : channelName;
? channelName.slice(1)
: channelName;
handler({ handler({
username: msg.userInfo.userName, username: msg.userInfo.userName,
displayName: msg.userInfo.displayName, displayName: msg.userInfo.displayName,
@ -243,10 +229,7 @@ export class TwitchClientManager {
/** /**
* Set a message handler for an account * Set a message handler for an account
*/ */
onMessage( onMessage(account: TwitchAccountConfig, handler: (message: TwitchChatMessage) => void): void {
account: TwitchAccountConfig,
handler: (message: TwitchChatMessage) => void,
): void {
const key = this.getAccountKey(account); const key = this.getAccountKey(account);
this.messageHandlers.set(key, handler); this.messageHandlers.set(key, handler);
} }

View File

@ -16,7 +16,8 @@
* @returns Plain text with markdown removed * @returns Plain text with markdown removed
*/ */
export function stripMarkdownForTwitch(markdown: string): string { export function stripMarkdownForTwitch(markdown: string): string {
return markdown return (
markdown
// Images // Images
.replace(/!\[[^\]]*]\([^)]+\)/g, "") .replace(/!\[[^\]]*]\([^)]+\)/g, "")
// Links // Links
@ -32,9 +33,7 @@ export function stripMarkdownForTwitch(markdown: string): string {
// Strikethrough (~~text~~) // Strikethrough (~~text~~)
.replace(/~~([^~]+)~~/g, "$1") .replace(/~~([^~]+)~~/g, "$1")
// Code blocks // Code blocks
.replace(/```[\s\S]*?```/g, (block) => .replace(/```[\s\S]*?```/g, (block) => block.replace(/```[^\n]*\n?/g, "").replace(/```/g, ""))
block.replace(/```[^\n]*\n?/g, "").replace(/```/g, ""),
)
// Inline code // Inline code
.replace(/`([^`]+)`/g, "$1") .replace(/`([^`]+)`/g, "$1")
// Headers // Headers
@ -47,7 +46,8 @@ export function stripMarkdownForTwitch(markdown: string): string {
.replace(/[ \t]+\n/g, "\n") // Remove trailing spaces before newlines .replace(/[ \t]+\n/g, "\n") // Remove trailing spaces before newlines
.replace(/\n/g, " ") // Replace newlines with spaces (for Twitch) .replace(/\n/g, " ") // Replace newlines with spaces (for Twitch)
.replace(/[ \t]{2,}/g, " ") // Reduce multiple spaces to single .replace(/[ \t]{2,}/g, " ") // Reduce multiple spaces to single
.trim(); .trim()
);
} }
/** /**

View File

@ -28,9 +28,7 @@ export function normalizeTwitchChannel(channel: string): string {
* @returns Error object with descriptive message * @returns Error object with descriptive message
*/ */
export function missingTargetError(provider: string, hint?: string): Error { export function missingTargetError(provider: string, hint?: string): Error {
return new Error( return new Error(`Delivering to ${provider} requires target${hint ? ` ${hint}` : ""}`);
`Delivering to ${provider} requires target${hint ? ` ${hint}` : ""}`,
);
} }
/** /**