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

View File

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

View File

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

View File

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

View File

@ -203,9 +203,7 @@ describe("checkTwitchAccessControl", () => {
botUsername: "testbot",
});
expect(result.allowed).toBe(false);
expect(result.reason).toContain(
"does not have any of the required roles",
);
expect(result.reason).toContain("does not have any of the required roles");
});
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
*/
function checkSenderRoles(params: {
message: TwitchChatMessage;
allowedRoles: string[];
}): boolean {
function checkSenderRoles(params: { message: TwitchChatMessage; allowedRoles: string[] }): boolean {
const { message, allowedRoles } = params;
const { isMod, isOwner, isVip, isSub } = message;

View File

@ -6,10 +6,7 @@
import { DEFAULT_ACCOUNT_ID, getAccountConfig } from "./config.js";
import { twitchOutbound } from "./outbound.js";
import type {
ChannelMessageActionAdapter,
ChannelMessageActionContext,
} from "./types.js";
import type { ChannelMessageActionAdapter, ChannelMessageActionContext } from "./types.js";
/**
* 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
* @returns Promise that resolves when cleanup is complete
*/
export async function removeClientManager(
accountId: string,
): Promise<void> {
export async function removeClientManager(accountId: string): Promise<void> {
const entry = registry.get(accountId);
if (!entry) {
return;
@ -97,9 +95,7 @@ export async function removeClientManager(
* @returns Promise that resolves when all cleanup is complete
*/
export async function removeAllClientManagers(): Promise<void> {
const promises = Array.from(registry.keys()).map((accountId) =>
removeClientManager(accountId),
);
const promises = Array.from(registry.keys()).map((accountId) => removeClientManager(accountId));
await Promise.all(promises);
}

View File

@ -19,16 +19,12 @@ describe("parsePluginConfig", () => {
});
it("handles undefined config", () => {
const result = parsePluginConfig(
undefined as unknown as Record<string, unknown>,
);
const result = parsePluginConfig(undefined as unknown as Record<string, unknown>);
expect(result).toEqual({ stripMarkdown: true }); // Returns defaults when value is falsy
});
it("handles null config", () => {
const result = parsePluginConfig(
null as unknown as Record<string, unknown>,
);
const result = parsePluginConfig(null as unknown as Record<string, unknown>);
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 {
TwitchAccountConfig,
TwitchPluginConfig,
} from "./types.js";
import type { TwitchAccountConfig, TwitchPluginConfig } from "./types.js";
/**
* Default account ID for Twitch
@ -42,8 +39,7 @@ export function parsePluginConfig(value: unknown): TwitchPluginConfig {
const raw = value as Record<string, unknown>;
return {
stripMarkdown:
typeof raw.stripMarkdown === "boolean" ? raw.stripMarkdown : true,
stripMarkdown: typeof raw.stripMarkdown === "boolean" ? raw.stripMarkdown : true,
};
}
@ -51,9 +47,7 @@ export function parsePluginConfig(value: unknown): TwitchPluginConfig {
* List all configured account IDs
*/
export function listAccountIds(cfg: ClawdbotConfig): string[] {
const accounts = (cfg as Record<string, unknown>).channels as
| Record<string, unknown>
| undefined;
const accounts = (cfg as Record<string, unknown>).channels as Record<string, unknown> | undefined;
const twitch = accounts?.twitch as Record<string, unknown> | undefined;
const accountMap = twitch?.accounts as Record<string, unknown> | undefined;

View File

@ -62,7 +62,9 @@ async function processTwitchMessage(params: {
channel: "Twitch",
from: message.displayName ?? message.username,
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,
});
@ -111,7 +113,9 @@ async function processTwitchMessage(params: {
// Dispatch reply
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
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: {
deliver: async (payload) => {
await deliverTwitchReply({
@ -188,7 +192,11 @@ export async function monitorTwitchProvider(
// Establish connection
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}`);
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);

View File

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

View File

@ -9,10 +9,7 @@ import {
type ChannelOnboardingDmPolicy,
type WizardPrompter,
} from "clawdbot/plugin-sdk";
import {
DEFAULT_ACCOUNT_ID,
getAccountConfig,
} from "./config.js";
import { DEFAULT_ACCOUNT_ID, getAccountConfig } from "./config.js";
import type { TwitchAccountConfig, TwitchRole } from "./types.js";
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
@ -60,10 +57,14 @@ function setTwitchAccount(
channels: {
...cfg.channels,
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,
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,
},
},
@ -191,7 +192,8 @@ async function promptRefreshTokenSetup(
return {};
}
const clientSecret = String(
const clientSecret =
String(
await prompter.text({
message: "Twitch Client Secret (for token refresh)",
initialValue: account?.clientSecret ?? "",
@ -199,7 +201,8 @@ async function promptRefreshTokenSetup(
}),
).trim() || undefined;
const refreshToken = String(
const refreshToken =
String(
await prompter.text({
message: "Twitch Refresh Token",
initialValue: account?.refreshToken ?? "",
@ -315,9 +318,7 @@ export const twitchOnboardingAdapter: ChannelOnboardingAdapter = {
return {
channel,
configured,
statusLines: [
`Twitch: ${configured ? "configured" : "needs username, token, and clientId"}`,
],
statusLines: [`Twitch: ${configured ? "configured" : "needs username, token, and clientId"}`],
selectionHint: configured ? "configured" : "needs setup",
};
},
@ -399,7 +400,9 @@ export const twitchOnboardingAdapter: ChannelOnboardingAdapter = {
},
dmPolicy,
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 {
...cfg,
channels: {
@ -411,4 +414,11 @@ export const twitchOnboardingAdapter: ChannelOnboardingAdapter = {
};
// 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.
*/
import {
DEFAULT_ACCOUNT_ID,
getAccountConfig,
parsePluginConfig,
} from "./config.js";
import { DEFAULT_ACCOUNT_ID, getAccountConfig, parsePluginConfig } from "./config.js";
import { sendMessageTwitchInternal } from "./send.js";
import type {
ChannelOutboundAdapter,
@ -109,9 +105,7 @@ export const twitchOutbound: ChannelOutboundAdapter = {
* accountId: "default",
* });
*/
sendText: async (
params: ChannelOutboundContext,
): Promise<OutboundDeliveryResult> => {
sendText: async (params: ChannelOutboundContext): Promise<OutboundDeliveryResult> => {
const { cfg, to, text, accountId, signal } = params;
// Check for abort signal
@ -133,9 +127,7 @@ export const twitchOutbound: ChannelOutboundAdapter = {
// Get channel (support target parameter)
const channel = to || account.channel || account.username;
if (!channel) {
throw new Error(
"No channel specified and no default channel in account config",
);
throw new Error("No channel specified and no default channel in account config");
}
// Get plugin config for markdown stripping
@ -184,9 +176,7 @@ export const twitchOutbound: ChannelOutboundAdapter = {
* accountId: "default",
* });
*/
sendMedia: async (
params: ChannelOutboundContext,
): Promise<OutboundDeliveryResult> => {
sendMedia: async (params: ChannelOutboundContext): Promise<OutboundDeliveryResult> => {
const { text, mediaUrl, signal } = params;
// Check for abort signal

View File

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

View File

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

View File

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

View File

@ -107,9 +107,7 @@ describe("send", () => {
messageId: "twitch-msg-456",
}),
} as ReturnType<typeof getClientManager>);
vi.mocked(stripMarkdownForTwitch).mockImplementation((text) =>
text.replace(/\*\*/g, ""),
);
vi.mocked(stripMarkdownForTwitch).mockImplementation((text) => text.replace(/\*\*/g, ""));
await sendMessageTwitchInternal(
"#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 type { ClawdbotConfig } from "clawdbot/plugin-sdk";
import { stripMarkdownForTwitch } from "./utils/markdown.js";
import {
generateMessageId,
isAccountConfigured,
normalizeTwitchChannel,
} from "./utils/twitch.js";
import { generateMessageId, isAccountConfigured, normalizeTwitchChannel } from "./utils/twitch.js";
/**
* Result from sending a message to Twitch.

View File

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

View File

@ -87,7 +87,6 @@ export function collectTwitchStatusIssues(
// Checks that require account config
if (account && isAccountConfigured(account)) {
// Check 4: Token format warning (normalized, but may indicate config issue)
if (account.token?.startsWith("oauth:")) {
issues.push({
@ -131,8 +130,7 @@ export function collectTwitchStatusIssues(
channel: "twitch",
accountId,
kind: "intent",
message:
"allowedRoles is set to 'all' but allowFrom is also configured",
message: "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.",
});
}

View File

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

View File

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

View File

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

View File

@ -16,7 +16,8 @@
* @returns Plain text with markdown removed
*/
export function stripMarkdownForTwitch(markdown: string): string {
return markdown
return (
markdown
// Images
.replace(/!\[[^\]]*]\([^)]+\)/g, "")
// Links
@ -32,9 +33,7 @@ export function stripMarkdownForTwitch(markdown: string): string {
// Strikethrough (~~text~~)
.replace(/~~([^~]+)~~/g, "$1")
// Code blocks
.replace(/```[\s\S]*?```/g, (block) =>
block.replace(/```[^\n]*\n?/g, "").replace(/```/g, ""),
)
.replace(/```[\s\S]*?```/g, (block) => block.replace(/```[^\n]*\n?/g, "").replace(/```/g, ""))
// Inline code
.replace(/`([^`]+)`/g, "$1")
// Headers
@ -47,7 +46,8 @@ export function stripMarkdownForTwitch(markdown: string): string {
.replace(/[ \t]+\n/g, "\n") // Remove trailing spaces before newlines
.replace(/\n/g, " ") // Replace newlines with spaces (for Twitch)
.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
*/
export function missingTargetError(provider: string, hint?: string): Error {
return new Error(
`Delivering to ${provider} requires target${hint ? ` ${hint}` : ""}`,
);
return new Error(`Delivering to ${provider} requires target${hint ? ` ${hint}` : ""}`);
}
/**