diff --git a/extensions/twitch/src/access-control.test.ts b/extensions/twitch/src/access-control.test.ts new file mode 100644 index 000000000..77105d522 --- /dev/null +++ b/extensions/twitch/src/access-control.test.ts @@ -0,0 +1,387 @@ +import { describe, expect, it } from "vitest"; +import { checkTwitchAccessControl, extractMentions } from "./access-control.js"; +import type { TwitchAccountConfig, TwitchChatMessage } from "./types.js"; + +describe("checkTwitchAccessControl", () => { + const mockAccount: TwitchAccountConfig = { + username: "testbot", + token: "oauth:test", + }; + + const mockMessage: TwitchChatMessage = { + username: "testuser", + userId: "123456", + message: "hello bot", + channel: "testchannel", + }; + + describe("when no restrictions are configured", () => { + it("allows all messages", () => { + const result = checkTwitchAccessControl({ + message: mockMessage, + account: mockAccount, + botUsername: "testbot", + }); + expect(result.allowed).toBe(true); + }); + }); + + describe("requireMention", () => { + it("allows messages that mention the bot", () => { + const account: TwitchAccountConfig = { + ...mockAccount, + requireMention: true, + }; + const message: TwitchChatMessage = { + ...mockMessage, + message: "@testbot hello", + }; + + const result = checkTwitchAccessControl({ + message, + account, + botUsername: "testbot", + }); + expect(result.allowed).toBe(true); + }); + + it("blocks messages that don't mention the bot", () => { + const account: TwitchAccountConfig = { + ...mockAccount, + requireMention: true, + }; + + const result = checkTwitchAccessControl({ + message: mockMessage, + account, + botUsername: "testbot", + }); + expect(result.allowed).toBe(false); + expect(result.reason).toContain("does not mention the bot"); + }); + + it("is case-insensitive for bot username", () => { + const account: TwitchAccountConfig = { + ...mockAccount, + requireMention: true, + }; + const message: TwitchChatMessage = { + ...mockMessage, + message: "@TestBot hello", + }; + + const result = checkTwitchAccessControl({ + message, + account, + botUsername: "testbot", + }); + expect(result.allowed).toBe(true); + }); + }); + + describe("allowFrom allowlist", () => { + it("allows users in the allowlist", () => { + const account: TwitchAccountConfig = { + ...mockAccount, + allowFrom: ["123456", "789012"], + }; + + const result = checkTwitchAccessControl({ + message: mockMessage, + account, + botUsername: "testbot", + }); + expect(result.allowed).toBe(true); + expect(result.matchKey).toBe("123456"); + expect(result.matchSource).toBe("allowlist"); + }); + + it("blocks users not in the allowlist", () => { + const account: TwitchAccountConfig = { + ...mockAccount, + allowFrom: ["789012"], + }; + + const result = checkTwitchAccessControl({ + message: mockMessage, + account, + botUsername: "testbot", + }); + expect(result.allowed).toBe(false); + expect(result.reason).toContain("not in allowlist"); + }); + + it("blocks messages without userId", () => { + const account: TwitchAccountConfig = { + ...mockAccount, + allowFrom: ["123456"], + }; + const message = { ...mockMessage, userId: undefined }; + + const result = checkTwitchAccessControl({ + message, + account, + botUsername: "testbot", + }); + expect(result.allowed).toBe(false); + expect(result.reason).toContain("user ID not available"); + }); + + it("bypasses role checks when user is in allowlist", () => { + const account: TwitchAccountConfig = { + ...mockAccount, + allowFrom: ["123456"], + allowedRoles: ["owner"], + }; + const message: TwitchChatMessage = { + ...mockMessage, + isOwner: false, + }; + + const result = checkTwitchAccessControl({ + message, + account, + botUsername: "testbot", + }); + expect(result.allowed).toBe(true); + }); + }); + + describe("allowedRoles", () => { + it("allows users with matching role", () => { + const account: TwitchAccountConfig = { + ...mockAccount, + allowedRoles: ["moderator"], + }; + const message: TwitchChatMessage = { + ...mockMessage, + isMod: true, + }; + + const result = checkTwitchAccessControl({ + message, + account, + botUsername: "testbot", + }); + expect(result.allowed).toBe(true); + expect(result.matchSource).toBe("role"); + }); + + it("allows users with any of multiple roles", () => { + const account: TwitchAccountConfig = { + ...mockAccount, + allowedRoles: ["moderator", "vip", "subscriber"], + }; + const message: TwitchChatMessage = { + ...mockMessage, + isVip: true, + isMod: false, + isSub: false, + }; + + const result = checkTwitchAccessControl({ + message, + account, + botUsername: "testbot", + }); + expect(result.allowed).toBe(true); + }); + + it("blocks users without matching role", () => { + const account: TwitchAccountConfig = { + ...mockAccount, + allowedRoles: ["moderator"], + }; + const message: TwitchChatMessage = { + ...mockMessage, + isMod: false, + }; + + const result = checkTwitchAccessControl({ + message, + account, + botUsername: "testbot", + }); + expect(result.allowed).toBe(false); + expect(result.reason).toContain( + "does not have any of the required roles", + ); + }); + + it("allows all users when role is 'all'", () => { + const account: TwitchAccountConfig = { + ...mockAccount, + allowedRoles: ["all"], + }; + + const result = checkTwitchAccessControl({ + message: mockMessage, + account, + botUsername: "testbot", + }); + expect(result.allowed).toBe(true); + expect(result.matchKey).toBe("all"); + }); + + it("handles moderator role", () => { + const account: TwitchAccountConfig = { + ...mockAccount, + allowedRoles: ["moderator"], + }; + const message: TwitchChatMessage = { + ...mockMessage, + isMod: true, + }; + + const result = checkTwitchAccessControl({ + message, + account, + botUsername: "testbot", + }); + expect(result.allowed).toBe(true); + }); + + it("handles subscriber role", () => { + const account: TwitchAccountConfig = { + ...mockAccount, + allowedRoles: ["subscriber"], + }; + const message: TwitchChatMessage = { + ...mockMessage, + isSub: true, + }; + + const result = checkTwitchAccessControl({ + message, + account, + botUsername: "testbot", + }); + expect(result.allowed).toBe(true); + }); + + it("handles owner role", () => { + const account: TwitchAccountConfig = { + ...mockAccount, + allowedRoles: ["owner"], + }; + const message: TwitchChatMessage = { + ...mockMessage, + isOwner: true, + }; + + const result = checkTwitchAccessControl({ + message, + account, + botUsername: "testbot", + }); + expect(result.allowed).toBe(true); + }); + + it("handles vip role", () => { + const account: TwitchAccountConfig = { + ...mockAccount, + allowedRoles: ["vip"], + }; + const message: TwitchChatMessage = { + ...mockMessage, + isVip: true, + }; + + const result = checkTwitchAccessControl({ + message, + account, + botUsername: "testbot", + }); + expect(result.allowed).toBe(true); + }); + }); + + describe("combined restrictions", () => { + it("checks requireMention before allowlist", () => { + const account: TwitchAccountConfig = { + ...mockAccount, + requireMention: true, + allowFrom: ["123456"], + }; + const message: TwitchChatMessage = { + ...mockMessage, + message: "hello", // No mention + }; + + const result = checkTwitchAccessControl({ + message, + account, + botUsername: "testbot", + }); + expect(result.allowed).toBe(false); + expect(result.reason).toContain("does not mention the bot"); + }); + + it("checks allowlist before allowedRoles", () => { + const account: TwitchAccountConfig = { + ...mockAccount, + allowFrom: ["123456"], + allowedRoles: ["owner"], + }; + const message: TwitchChatMessage = { + ...mockMessage, + isOwner: false, // Not owner, but in allowlist + }; + + const result = checkTwitchAccessControl({ + message, + account, + botUsername: "testbot", + }); + expect(result.allowed).toBe(true); + expect(result.matchSource).toBe("allowlist"); + }); + }); +}); + +describe("extractMentions", () => { + it("extracts single mention", () => { + const mentions = extractMentions("hello @testbot"); + expect(mentions).toEqual(["testbot"]); + }); + + it("extracts multiple mentions", () => { + const mentions = extractMentions("hello @testbot and @otheruser"); + expect(mentions).toEqual(["testbot", "otheruser"]); + }); + + it("returns empty array when no mentions", () => { + const mentions = extractMentions("hello everyone"); + expect(mentions).toEqual([]); + }); + + it("handles mentions at start of message", () => { + const mentions = extractMentions("@testbot hello"); + expect(mentions).toEqual(["testbot"]); + }); + + it("handles mentions at end of message", () => { + const mentions = extractMentions("hello @testbot"); + expect(mentions).toEqual(["testbot"]); + }); + + it("converts mentions to lowercase", () => { + const mentions = extractMentions("hello @TestBot"); + expect(mentions).toEqual(["testbot"]); + }); + + it("extracts alphanumeric usernames", () => { + const mentions = extractMentions("hello @user123"); + expect(mentions).toEqual(["user123"]); + }); + + it("handles underscores in usernames", () => { + const mentions = extractMentions("hello @test_user"); + expect(mentions).toEqual(["test_user"]); + }); + + it("handles empty string", () => { + const mentions = extractMentions(""); + expect(mentions).toEqual([]); + }); +}); diff --git a/extensions/twitch/src/access-control.ts b/extensions/twitch/src/access-control.ts new file mode 100644 index 000000000..cb0bed15b --- /dev/null +++ b/extensions/twitch/src/access-control.ts @@ -0,0 +1,169 @@ +import type { TwitchAccountConfig, TwitchChatMessage } from "./types.js"; + +/** + * Result of checking access control for a Twitch message + */ +export type TwitchAccessControlResult = { + allowed: boolean; + reason?: string; + matchKey?: string; + matchSource?: string; +}; + +/** + * Check if a Twitch message should be allowed based on account configuration + * + * This function implements the access control logic for incoming Twitch messages, + * checking allowlists, role-based restrictions, and mention requirements. + * + * Priority order: + * 1. If `requireMention` is true, message must mention the bot + * 2. If `allowFrom` is set, sender must be in the allowlist (by user ID) + * 3. If `allowedRoles` is set, sender must have at least one of the specified roles + * + * Note: You can combine `allowFrom` with `allowedRoles`. If a user is in `allowFrom`, + * they bypass role checks. This is useful for allowing specific users regardless of role. + * + * Available roles: + * - "moderator": Moderators + * - "owner": Channel owner/broadcaster + * - "vip": VIPs + * - "subscriber": Subscribers + * - "all": Anyone in the chat + */ +export function checkTwitchAccessControl(params: { + message: TwitchChatMessage; + account: TwitchAccountConfig; + botUsername: string; +}): TwitchAccessControlResult { + const { message, account, botUsername } = params; + + // Check mention requirement first + if (account.requireMention) { + const mentions = extractMentions(message.message); + if (!mentions.includes(botUsername.toLowerCase())) { + return { + allowed: false, + reason: "message does not mention the bot (requireMention is enabled)", + }; + } + } + + // Check allowlist (by user ID) + if (account.allowFrom && account.allowFrom.length > 0) { + const allowFrom = account.allowFrom; + const senderId = message.userId; + + if (!senderId) { + return { + allowed: false, + reason: "sender user ID not available for allowlist check", + }; + } + + // Check if sender is in allowlist + if (!allowFrom.includes(senderId)) { + return { + allowed: false, + reason: "sender not in allowlist", + }; + } + + // Sender is in allowlist, no need to check role restrictions + return { + allowed: true, + matchKey: senderId, + matchSource: "allowlist", + }; + } + + // Check role-based restrictions + if (account.allowedRoles && account.allowedRoles.length > 0) { + const allowedRoles = account.allowedRoles; + + // "all" grants access to everyone + if (allowedRoles.includes("all")) { + return { + allowed: true, + matchKey: "all", + matchSource: "role", + }; + } + + // Check if sender has any of the allowed roles + const hasAllowedRole = checkSenderRoles({ + message, + allowedRoles, + }); + + if (!hasAllowedRole) { + return { + allowed: false, + reason: `sender does not have any of the required roles: ${allowedRoles.join(", ")}`, + }; + } + + return { + allowed: true, + matchKey: allowedRoles.join(","), + matchSource: "role", + }; + } + + // No restrictions configured - allow everyone + return { + allowed: true, + }; +} + +/** + * Check if the sender has any of the allowed roles + */ +function checkSenderRoles(params: { + message: TwitchChatMessage; + allowedRoles: string[]; +}): boolean { + const { message, allowedRoles } = params; + const { isMod, isOwner, isVip, isSub } = message; + + for (const role of allowedRoles) { + switch (role) { + case "moderator": + if (isMod) return true; + break; + case "owner": + if (isOwner) return true; + break; + case "vip": + if (isVip) return true; + break; + case "subscriber": + if (isSub) return true; + break; + } + } + + return false; +} + +/** + * Extract @mentions from a Twitch chat message + * + * Returns a list of lowercase usernames that were mentioned in the message. + * Twitch mentions are in the format @username. + */ +export function extractMentions(message: string): string[] { + const mentionRegex = /@(\w+)/g; + const mentions: string[] = []; + let match: RegExpExecArray | null; + + // biome-ignore lint/suspicious/noAssignInExpressions: Standard regex iteration pattern + while ((match = mentionRegex.exec(message)) !== null) { + const username = match[1]; + if (username) { + mentions.push(username.toLowerCase()); + } + } + + return mentions; +} diff --git a/extensions/twitch/src/actions.ts b/extensions/twitch/src/actions.ts new file mode 100644 index 000000000..20d1d5d3d --- /dev/null +++ b/extensions/twitch/src/actions.ts @@ -0,0 +1,207 @@ +/** + * Twitch message actions adapter. + * + * Handles tool-based actions for Twitch, such as sending messages. + */ + +import { DEFAULT_ACCOUNT_ID, getAccountConfig } from "./config.js"; +import { twitchOutbound } from "./outbound.js"; +import type { + ChannelMessageActionAdapter, + ChannelMessageActionContext, +} from "./types.js"; + +/** + * Read a string parameter from action arguments. + * + * @param args - Action arguments + * @param key - Parameter key + * @param options - Options for reading the parameter + * @returns The parameter value or undefined if not found + */ +function readStringParam( + args: Record, + key: string, + options: { required?: boolean; trim?: boolean } = {}, +): string | undefined { + const value = args[key]; + if (value === undefined || value === null) { + if (options.required) { + throw new Error(`Missing required parameter: ${key}`); + } + return undefined; + } + + const str = String(value); + return options.trim !== false ? str.trim() : str; +} + +/** + * Twitch message actions adapter. + * + * Supports the "send" action for sending messages to Twitch channels. + */ +export const twitchMessageActions: ChannelMessageActionAdapter = { + /** + * List available actions for this channel. + * + * Currently supports: + * - "send" - Send a message to a Twitch channel + * + * @param params - Parameters including config + * @returns Array of available action names + */ + listActions: () => { + // TODO: Add action gate pattern for configurable actions + const actions = new Set(["send"]); + return Array.from(actions); + }, + + /** + * Check if an action is supported. + * + * @param params - Action to check + * @returns true if the action is supported + */ + supportsAction: ({ action }) => { + return action === "send"; + }, + + /** + * Extract tool send parameters from action arguments. + * + * Parses and validates the "to" and "message" parameters for sending. + * + * @param params - Arguments from the tool call + * @returns Parsed send parameters or null if invalid + * + * @example + * const result = twitchMessageActions.extractToolSend!({ + * args: { to: "#mychannel", message: "Hello!" } + * }); + * // Returns: { to: "#mychannel", message: "Hello!" } + */ + extractToolSend: ({ args }) => { + try { + const to = readStringParam(args, "to", { required: true }); + const message = readStringParam(args, "message", { required: true }); + + if (!to || !message) { + return null; + } + + return { to, message }; + } catch { + return null; + } + }, + + /** + * Handle an action execution. + * + * Processes the "send" action to send messages to Twitch. + * + * @param ctx - Action context including action type, parameters, and config + * @returns Tool result with content or null if action not supported + * + * @example + * const result = await twitchMessageActions.handleAction!({ + * action: "send", + * params: { message: "Hello Twitch!", to: "#mychannel" }, + * cfg: clawdbotConfig, + * accountId: "default", + * }); + */ + handleAction: async ( + ctx: ChannelMessageActionContext, + ): Promise<{ content: Array<{ type: string; text: string }> } | null> => { + if (ctx.action === "send") { + const message = readStringParam(ctx.params, "message", { + required: true, + }); + const to = readStringParam(ctx.params, "to", { required: false }); + const accountId = ctx.accountId ?? DEFAULT_ACCOUNT_ID; + + const account = getAccountConfig(ctx.cfg, accountId); + if (!account) { + return { + content: [ + { + type: "text", + text: JSON.stringify({ + ok: false, + error: `Account not found: ${accountId}. Available accounts: ${Object.keys(ctx.cfg.channels?.twitch?.accounts ?? {}).join(", ") || "none"}`, + }), + }, + ], + }; + } + + // Use the channel from account config if not specified + const targetChannel = to || account.channel || account.username; + + if (!targetChannel) { + return { + content: [ + { + type: "text", + text: JSON.stringify({ + ok: false, + error: + "No channel specified and no default channel in account config", + }), + }, + ], + }; + } + + try { + if (!twitchOutbound.sendText) { + return { + content: [ + { + type: "text", + text: JSON.stringify({ + ok: false, + error: "sendText not implemented", + }), + }, + ], + }; + } + + const result = await twitchOutbound.sendText({ + cfg: ctx.cfg, + to: targetChannel, + text: message ?? "", + accountId, + }); + + return { + content: [ + { + type: "text", + text: JSON.stringify(result), + }, + ], + }; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + return { + content: [ + { + type: "text", + text: JSON.stringify({ + ok: false, + error: errorMsg, + }), + }, + ], + }; + } + } + + // Action not supported + return null; + }, +}; diff --git a/extensions/twitch/src/cli/test-connect.ts b/extensions/twitch/src/cli/test-connect.ts new file mode 100644 index 000000000..2a3259a5f --- /dev/null +++ b/extensions/twitch/src/cli/test-connect.ts @@ -0,0 +1,98 @@ +import { StaticAuthProvider } from "@twurple/auth"; +import { ChatClient } from "@twurple/chat"; + +type Args = { + token?: string; + clientId?: string; + username?: string; + channel?: string; + message?: string; + timeoutMs: number; +}; + +function parseArgs(argv: string[]): Args { + const args: Args = { timeoutMs: 10000 }; + for (let i = 0; i < argv.length; i += 1) { + const key = argv[i]; + const value = argv[i + 1]; + if (!key?.startsWith("--")) continue; + switch (key) { + case "--token": + args.token = value; + i += 1; + break; + case "--client-id": + args.clientId = value; + i += 1; + break; + case "--username": + args.username = value; + i += 1; + break; + case "--channel": + args.channel = value; + i += 1; + break; + case "--message": + args.message = value; + i += 1; + break; + case "--timeout-ms": + args.timeoutMs = Number(value ?? 10000); + i += 1; + break; + default: + break; + } + } + return args; +} + +async function run(): Promise { + const args = parseArgs(process.argv.slice(2)); + const token = args.token; + const clientId = args.clientId; + const username = args.username; + const channel = args.channel ?? username; + + if (!token || !clientId || !username || !channel) { + console.error( + "Usage: npx tsx src/cli/test-connect.ts --token --client-id --username --channel [--timeout-ms 10000]", + ); + process.exit(2); + } + + const normalizedToken = token.startsWith("oauth:") ? token.slice(6) : token; + + const authProvider = new StaticAuthProvider(clientId, normalizedToken); + const client = new ChatClient({ + authProvider, + requestMembershipEvents: true, + }); + + const timeout = setTimeout( + () => { + console.error("Timed out waiting for connection."); + process.exit(1); + }, + Number.isFinite(args.timeoutMs) ? args.timeoutMs : 10000, + ); + + try { + await client.connect(); + await client.join(channel); + console.log(`Connected as ${username} and joined #${channel}`); + if (args.message) { + await client.say(channel, args.message); + console.log("Message sent."); + } + } finally { + clearTimeout(timeout); + client.quit(); + } +} + +run().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +}); diff --git a/extensions/twitch/src/config.test.ts b/extensions/twitch/src/config.test.ts new file mode 100644 index 000000000..e86a65769 --- /dev/null +++ b/extensions/twitch/src/config.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vitest"; + +import { getAccountConfig, parsePluginConfig } from "./config.js"; + +describe("parsePluginConfig", () => { + it("parses valid config with stripMarkdown true", () => { + const result = parsePluginConfig({ stripMarkdown: true }); + expect(result.stripMarkdown).toBe(true); + }); + + it("parses valid config with stripMarkdown false", () => { + const result = parsePluginConfig({ stripMarkdown: false }); + expect(result.stripMarkdown).toBe(false); + }); + + it("defaults to stripMarkdown true when not specified", () => { + const result = parsePluginConfig({}); + expect(result.stripMarkdown).toBe(true); + }); + + it("handles undefined config", () => { + const result = parsePluginConfig( + undefined as unknown as Record, + ); + expect(result).toEqual({ stripMarkdown: true }); // Returns defaults when value is falsy + }); + + it("handles null config", () => { + const result = parsePluginConfig( + null as unknown as Record, + ); + expect(result).toEqual({ stripMarkdown: true }); // Returns defaults when value is null + }); +}); + +describe("getAccountConfig", () => { + const mockCoreConfig = { + channels: { + twitch: { + accounts: { + default: { + username: "testbot", + token: "oauth:test123", + }, + }, + }, + }, + }; + + it("returns account config for valid account ID", () => { + const result = getAccountConfig(mockCoreConfig, "default"); + + expect(result).not.toBeNull(); + expect(result?.username).toBe("testbot"); + }); + + it("returns null for non-existent account ID", () => { + const result = getAccountConfig(mockCoreConfig, "nonexistent"); + + expect(result).toBeNull(); + }); + + it("returns null when core config is null", () => { + const result = getAccountConfig(null, "default"); + + expect(result).toBeNull(); + }); + + it("returns null when core config is undefined", () => { + const result = getAccountConfig(undefined, "default"); + + expect(result).toBeNull(); + }); + + it("returns null when channels are not defined", () => { + const result = getAccountConfig({}, "default"); + + expect(result).toBeNull(); + }); + + it("returns null when twitch is not defined", () => { + const result = getAccountConfig({ channels: {} }, "default"); + + expect(result).toBeNull(); + }); + + it("returns null when accounts are not defined", () => { + const result = getAccountConfig({ channels: { twitch: {} } }, "default"); + + expect(result).toBeNull(); + }); +}); diff --git a/extensions/twitch/src/config.ts b/extensions/twitch/src/config.ts new file mode 100644 index 000000000..465c6db03 --- /dev/null +++ b/extensions/twitch/src/config.ts @@ -0,0 +1,65 @@ +import type { + CoreConfig, + TwitchAccountConfig, + TwitchPluginConfig, +} from "./types.js"; + +/** + * Default account ID for Twitch + */ +export const DEFAULT_ACCOUNT_ID = "default"; + +/** + * Get account config from core config + */ +export function getAccountConfig( + coreConfig: unknown, + accountId: string, +): TwitchAccountConfig | null { + if (!coreConfig || typeof coreConfig !== "object") { + return null; + } + + const cfg = coreConfig as Record; + const channels = cfg.channels as Record | undefined; + const twitch = channels?.twitch as Record | undefined; + const accounts = twitch?.accounts as Record | undefined; + + if (!accounts || !accounts[accountId]) { + return null; + } + + return accounts[accountId] as TwitchAccountConfig | null; +} + +/** + * Parse plugin config + */ +export function parsePluginConfig(value: unknown): TwitchPluginConfig { + if (!value || typeof value !== "object") { + return { stripMarkdown: true }; + } + + const raw = value as Record; + return { + stripMarkdown: + typeof raw.stripMarkdown === "boolean" ? raw.stripMarkdown : true, + }; +} + +/** + * List all configured account IDs + */ +export function listAccountIds(cfg: CoreConfig): string[] { + const accounts = (cfg as Record).channels as + | Record + | undefined; + const twitch = accounts?.twitch as Record | undefined; + const accountMap = twitch?.accounts as Record | undefined; + + if (!accountMap) { + return []; + } + + return Object.keys(accountMap); +} diff --git a/extensions/twitch/src/index.test.ts b/extensions/twitch/src/index.test.ts new file mode 100644 index 000000000..57bb9a970 --- /dev/null +++ b/extensions/twitch/src/index.test.ts @@ -0,0 +1,252 @@ +/** + * Tests for the Twitch plugin + * + * Tests cover: + * - Plugin metadata and capabilities + * - Configuration parsing + * - Account resolution + * - Adapter functionality + */ + +import { describe, expect, it, vi } from "vitest"; + +import { + collectTwitchStatusIssues, + twitchMessageActions, + twitchOutbound, + twitchPlugin, +} from "./index.js"; + +// Mock Clawdbot's internal modules +const _mockRegisterChannel = vi.fn(); + +vi.mock("clawdbot", () => ({ + default: { + helpers: {}, + }, +})); + +describe("Twitch Plugin", () => { + describe("Plugin Metadata", () => { + it("should have correct plugin ID", () => { + expect(twitchPlugin.id).toBe("twitch"); + }); + + it("should have correct metadata", () => { + expect(twitchPlugin.meta.id).toBe("twitch"); + expect(twitchPlugin.meta.label).toBe("Twitch"); + expect(twitchPlugin.meta.selectionLabel).toBe("Twitch (Chat)"); + expect(twitchPlugin.meta.docsPath).toBe("/channels/twitch"); + expect(twitchPlugin.meta.blurb).toBe("Twitch chat integration"); + expect(twitchPlugin.meta.aliases).toContain("twitch-chat"); + }); + + it("should have correct capabilities", () => { + expect(twitchPlugin.capabilities.chatTypes).toEqual(["group", "direct"]); + }); + }); + + describe("Plugin Config", () => { + const mockCoreConfig = { + channels: { + twitch: { + accounts: { + default: { + username: "testbot", + token: "oauth:test123", + clientId: "test-client-id", + }, + }, + }, + }, + }; + + it("should list account IDs", () => { + const ids = twitchPlugin.config.listAccountIds(mockCoreConfig); + expect(ids).toContain("default"); + }); + + it("should resolve account config", () => { + const account = twitchPlugin.config.resolveAccount( + mockCoreConfig, + "default", + ); + expect(account).not.toBeNull(); + expect(account?.username).toBe("testbot"); + }); + + it("should return default account ID", () => { + const defaultId = twitchPlugin.config.defaultAccountId?.(); + expect(defaultId).toBe("default"); + }); + + it("should check if account is configured", () => { + const isConfigured = twitchPlugin.config.isConfigured?.( + null, + mockCoreConfig, + ); + expect(isConfigured).toBe(true); + }); + + it("should check if account is enabled", () => { + const account = twitchPlugin.config.resolveAccount( + mockCoreConfig, + "default", + ); + const isEnabled = twitchPlugin.config.isEnabled?.(account ?? undefined); + expect(isEnabled).toBe(true); + }); + + it("should describe account", () => { + const account = twitchPlugin.config.resolveAccount( + mockCoreConfig, + "default", + ); + const description = twitchPlugin.config.describeAccount?.( + account ?? undefined, + ); + expect(description?.accountId).toBe("default"); + expect(description?.configured).toBe(true); + expect(description?.enabled).toBe(true); + }); + }); + + describe("Outbound Adapter", () => { + it("should have correct delivery mode", () => { + expect(twitchOutbound.deliveryMode).toBe("direct"); + }); + + it("should have text chunk limit", () => { + expect(twitchOutbound.textChunkLimit).toBe(500); + }); + + it("should have a chunker function", () => { + expect(twitchOutbound.chunker).toBeDefined(); + expect(typeof twitchOutbound.chunker).toBe("function"); + }); + + it("should have resolveTarget function", () => { + expect(twitchOutbound.resolveTarget).toBeDefined(); + expect(typeof twitchOutbound.resolveTarget).toBe("function"); + }); + + it("should have sendText function", () => { + expect(twitchOutbound.sendText).toBeDefined(); + expect(typeof twitchOutbound.sendText).toBe("function"); + }); + + it("should have sendMedia function", () => { + expect(twitchOutbound.sendMedia).toBeDefined(); + expect(typeof twitchOutbound.sendMedia).toBe("function"); + }); + }); + + describe("Actions Adapter", () => { + it("should have listActions function", () => { + expect(twitchMessageActions.listActions).toBeDefined(); + expect(typeof twitchMessageActions.listActions).toBe("function"); + }); + + it("should list available actions", () => { + const actions = twitchMessageActions.listActions?.({ cfg: {} }); + expect(actions).toContain("send"); + }); + + it("should support send action", () => { + const supported = twitchMessageActions.supportsAction?.({ + action: "send", + }); + expect(supported).toBe(true); + }); + + it("should not support unknown actions", () => { + const supported = twitchMessageActions.supportsAction?.({ + action: "unknown", + }); + expect(supported).toBe(false); + }); + }); + + describe("Status Adapter", () => { + it("should have status adapter", () => { + expect(twitchPlugin.status).toBeDefined(); + }); + + it("should have default runtime", () => { + expect(twitchPlugin.status?.defaultRuntime).toBeDefined(); + expect(twitchPlugin.status?.defaultRuntime?.accountId).toBe("default"); + }); + + it("should have buildAccountSnapshot function", () => { + expect(twitchPlugin.status?.buildAccountSnapshot).toBeDefined(); + expect(typeof twitchPlugin.status?.buildAccountSnapshot).toBe("function"); + }); + + it("should have collectStatusIssues function", () => { + expect(twitchPlugin.status?.collectStatusIssues).toBeDefined(); + expect(typeof twitchPlugin.status?.collectStatusIssues).toBe("function"); + }); + }); + + describe("Status Issues Collection", () => { + it("should detect unconfigured accounts", () => { + const snapshots = [ + { + accountId: "default", + configured: false, + enabled: true, + running: false, + }, + ]; + const issues = collectTwitchStatusIssues(snapshots); + expect(issues.length).toBeGreaterThan(0); + expect(issues[0]?.kind).toBe("config"); + }); + }); + + describe("Gateway Adapter", () => { + it("should have gateway adapter", () => { + expect(twitchPlugin.gateway).toBeDefined(); + }); + + it("should have startAccount function", () => { + expect(twitchPlugin.gateway?.startAccount).toBeDefined(); + expect(typeof twitchPlugin.gateway?.startAccount).toBe("function"); + }); + + it("should have stopAccount function", () => { + expect(twitchPlugin.gateway?.stopAccount).toBeDefined(); + expect(typeof twitchPlugin.gateway?.stopAccount).toBe("function"); + }); + }); +}); + +describe("Twitch Utilities", () => { + describe("Markdown Stripping", () => { + it("should be exported", async () => { + const { stripMarkdownForTwitch } = await import("./utils/markdown.js"); + expect(stripMarkdownForTwitch).toBeDefined(); + expect(typeof stripMarkdownForTwitch).toBe("function"); + }); + + it("should strip bold markdown", async () => { + const { stripMarkdownForTwitch } = await import("./utils/markdown.js"); + const result = stripMarkdownForTwitch("**bold** text"); + expect(result).toBe("bold text"); + }); + + it("should strip links", async () => { + const { stripMarkdownForTwitch } = await import("./utils/markdown.js"); + const result = stripMarkdownForTwitch("[link](https://example.com)"); + expect(result).toBe("link"); + }); + }); + + describe("Twitch Utilities", () => { + it("should normalize channel names", async () => { + const { normalizeTwitchChannel } = await import("./utils/twitch.js"); + expect(normalizeTwitchChannel("#TwitchChannel")).toBe("twitchchannel"); + expect(normalizeTwitchChannel("MyChannel")).toBe("mychannel"); + }); + }); +}); diff --git a/extensions/twitch/src/outbound.ts b/extensions/twitch/src/outbound.ts new file mode 100644 index 000000000..dea76f8e3 --- /dev/null +++ b/extensions/twitch/src/outbound.ts @@ -0,0 +1,209 @@ +/** + * Twitch outbound adapter for sending messages. + * + * Implements the ChannelOutboundAdapter interface for Twitch chat. + * Supports text and media (URL) sending with markdown stripping and chunking. + */ + +import { + DEFAULT_ACCOUNT_ID, + getAccountConfig, + parsePluginConfig, +} from "./config.js"; +import { sendMessageTwitchInternal } from "./send.js"; +import type { + ChannelOutboundAdapter, + ChannelOutboundContext, + OutboundDeliveryResult, +} from "./types.js"; +import { chunkTextForTwitch } from "./utils/markdown.js"; +import { missingTargetError, normalizeTwitchChannel } from "./utils/twitch.js"; + +/** + * Twitch outbound adapter. + * + * Handles sending text and media to Twitch channels with automatic + * markdown stripping and message chunking. + */ +export const twitchOutbound: ChannelOutboundAdapter = { + /** Direct delivery mode - messages are sent immediately */ + deliveryMode: "direct", + + /** Twitch chat message limit is 500 characters */ + textChunkLimit: 500, + + /** Word-boundary chunker with markdown stripping */ + chunker: chunkTextForTwitch, + + /** + * Resolve target from context. + * + * Handles target resolution with allowlist support for implicit/heartbeat modes. + * For explicit mode, accepts any valid channel name. + * + * @param params - Resolution parameters + * @returns Resolved target or error + */ + resolveTarget: ({ to, allowFrom, mode }) => { + const trimmed = to?.trim() ?? ""; + const allowListRaw = (allowFrom ?? []) + .map((entry: unknown) => String(entry).trim()) + .filter(Boolean); + const hasWildcard = allowListRaw.includes("*"); + const allowList = allowListRaw + .filter((entry: string) => entry !== "*") + .map((entry: string) => normalizeTwitchChannel(entry)) + .filter((entry): entry is string => entry.length > 0); + + // If target is provided, normalize and validate it + if (trimmed) { + const normalizedTo = normalizeTwitchChannel(trimmed); + + // For implicit/heartbeat modes with allowList, check against allowlist + if (mode === "implicit" || mode === "heartbeat") { + if (hasWildcard || allowList.length === 0) { + return { ok: true, to: normalizedTo }; + } + if (allowList.includes(normalizedTo)) { + return { ok: true, to: normalizedTo }; + } + // Fallback to first allowFrom entry + // biome-ignore lint/style/noNonNullAssertion: length > 0 check ensures element exists + return { ok: true, to: allowList[0]! }; + } + + // For explicit mode, accept any valid channel name + return { ok: true, to: normalizedTo }; + } + + // No target provided, use allowFrom fallback + if (allowList.length > 0) { + // biome-ignore lint/style/noNonNullAssertion: length > 0 check ensures element exists + return { ok: true, to: allowList[0]! }; + } + + // No target and no allowFrom - error + return { + ok: false, + error: missingTargetError( + "Twitch", + " or channels.twitch.accounts..allowFrom[0]", + ), + }; + }, + + /** + * Send a text message to a Twitch channel. + * + * Strips markdown if enabled, validates account configuration, + * and sends the message via the Twitch client. + * + * @param params - Send parameters including target, text, and config + * @returns Delivery result with message ID and status + * + * @example + * const result = await twitchOutbound.sendText({ + * cfg: clawdbotConfig, + * to: "#mychannel", + * text: "Hello Twitch!", + * accountId: "default", + * }); + */ + sendText: async ( + params: ChannelOutboundContext, + ): Promise => { + const { cfg, to, text, accountId, signal } = params; + + // Check for abort signal + if (signal?.aborted) { + throw new Error("Outbound delivery aborted"); + } + + // Resolve account + const resolvedAccountId = accountId ?? DEFAULT_ACCOUNT_ID; + const account = getAccountConfig(cfg, resolvedAccountId); + if (!account) { + const availableIds = Object.keys(cfg.channels?.twitch?.accounts ?? {}); + throw new Error( + `Twitch account not found: ${resolvedAccountId}. ` + + `Available accounts: ${availableIds.join(", ") || "none"}`, + ); + } + + // 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", + ); + } + + // Get plugin config for markdown stripping + const pluginCfg = parsePluginConfig( + // biome-ignore lint/suspicious/noExplicitAny: pluginConfig is not part of CoreConfig + (cfg as any).pluginConfig ?? {}, + ); + + // Send message + const result = await sendMessageTwitchInternal( + normalizeTwitchChannel(channel), + text, + cfg, + resolvedAccountId, + pluginCfg.stripMarkdown ?? true, + console, + ); + + if (!result.ok) { + throw new Error(result.error ?? "Send failed"); + } + + return { + channel: "twitch", + messageId: result.messageId, + timestamp: Date.now(), + to: normalizeTwitchChannel(channel), + }; + }, + + /** + * Send media to a Twitch channel. + * + * Note: Twitch chat doesn't support direct media uploads. + * This sends the media URL as text instead. + * + * @param params - Send parameters including media URL + * @returns Delivery result with message ID and status + * + * @example + * const result = await twitchOutbound.sendMedia({ + * cfg: clawdbotConfig, + * to: "#mychannel", + * text: "Check this out!", + * mediaUrl: "https://example.com/image.png", + * accountId: "default", + * }); + */ + sendMedia: async ( + params: ChannelOutboundContext, + ): Promise => { + const { text, mediaUrl, signal } = params; + + // Check for abort signal + if (signal?.aborted) { + throw new Error("Outbound delivery aborted"); + } + + // Combine text and media URL + const message = mediaUrl ? `${text || ""} ${mediaUrl}`.trim() : text; + + // Delegate to sendText + if (!twitchOutbound.sendText) { + throw new Error("sendText not implemented"); + } + return twitchOutbound.sendText({ + ...params, + text: message, + }); + }, +}; diff --git a/extensions/twitch/src/plugin.ts b/extensions/twitch/src/plugin.ts new file mode 100644 index 000000000..f24f3c841 --- /dev/null +++ b/extensions/twitch/src/plugin.ts @@ -0,0 +1,304 @@ +/** + * Twitch channel plugin for Clawdbot. + * + * Main plugin export combining all adapters (outbound, actions, status, gateway). + * This is the primary entry point for the Twitch channel integration. + */ + +import { checkTwitchAccessControl } from "./access-control.js"; +import { twitchMessageActions } from "./actions.js"; +import { + DEFAULT_ACCOUNT_ID, + getAccountConfig, + listAccountIds, +} from "./config.js"; +import { twitchOutbound } from "./outbound.js"; +import { probeTwitch } from "./probe.js"; +import { resolveTwitchTargets } from "./resolver.js"; +import { collectTwitchStatusIssues } from "./status.js"; +import { TwitchClientManager } from "./twitch-client.js"; +import type { + ChannelAccountSnapshot, + ChannelMeta, + ChannelPlugin, + ChannelResolveKind, + ChannelResolveResult, + ChatCapabilities, + CoreConfig, + PluginAPI, + ProviderLogger, + TwitchAccountConfig, +} from "./types.js"; + +/** + * Check if an account is properly configured. + */ +function isConfigured( + account: TwitchAccountConfig | null | undefined, +): boolean { + return Boolean(account?.token && account?.username && account?.clientId); +} + +/** + * Twitch channel plugin. + * + * Implements the ChannelPlugin interface to provide Twitch chat integration + * for Clawdbot. Supports message sending, receiving, access control, and + * status monitoring. + */ +export const twitchPlugin: ChannelPlugin = { + /** Plugin identifier */ + id: "twitch", + + /** Plugin metadata */ + meta: { + id: "twitch", + label: "Twitch", + selectionLabel: "Twitch (Chat)", + docsPath: "/channels/twitch", + blurb: "Twitch chat integration", + aliases: ["twitch-chat"], + } satisfies ChannelMeta, + + /** Supported chat capabilities */ + capabilities: { + chatTypes: ["group", "direct"], + } satisfies ChatCapabilities, + + /** Account configuration management */ + config: { + /** List all configured account IDs */ + listAccountIds: (cfg: CoreConfig): string[] => listAccountIds(cfg), + + /** Resolve an account config by ID */ + resolveAccount: ( + cfg: CoreConfig, + accountId?: string | null, + ): TwitchAccountConfig | null => + getAccountConfig(cfg, accountId ?? DEFAULT_ACCOUNT_ID), + + /** Get the default account ID */ + defaultAccountId: (): string => DEFAULT_ACCOUNT_ID, + + /** Check if an account is configured */ + isConfigured: (_account: unknown, cfg: CoreConfig): boolean => { + const account = getAccountConfig(cfg, DEFAULT_ACCOUNT_ID); + return isConfigured(account); + }, + + /** Check if an account is enabled */ + isEnabled: (account: TwitchAccountConfig | undefined): boolean => + account?.enabled !== false, + + /** Describe account status */ + describeAccount: (account: TwitchAccountConfig | undefined) => ({ + accountId: DEFAULT_ACCOUNT_ID, + enabled: account?.enabled !== false, + configured: account ? isConfigured(account) : false, + }), + }, + + /** Outbound message adapter */ + outbound: twitchOutbound, + + /** Message actions adapter */ + actions: twitchMessageActions, + + /** Resolver adapter for username -> user ID resolution */ + resolver: { + resolveTargets: async ({ + cfg, + accountId, + inputs, + kind, + runtime, + }: { + cfg: CoreConfig; + accountId?: string | null; + inputs: string[]; + kind: ChannelResolveKind; + runtime: { log?: ProviderLogger }; + }): Promise => { + const account = getAccountConfig(cfg, accountId ?? DEFAULT_ACCOUNT_ID); + + if (!account) { + return inputs.map((input) => ({ + input, + resolved: false, + note: "account not configured", + })); + } + + return await resolveTwitchTargets(inputs, account, kind, runtime?.log); + }, + }, + + /** Status monitoring adapter */ + status: { + /** Default runtime state */ + defaultRuntime: { + accountId: DEFAULT_ACCOUNT_ID, + running: false, + lastStartAt: null, + lastStopAt: null, + lastError: null, + }, + + /** Build channel summary from snapshot */ + buildChannelSummary: ({ + snapshot, + }: { + snapshot: ChannelAccountSnapshot; + }) => ({ + configured: snapshot.configured ?? false, + running: snapshot.running ?? false, + lastStartAt: snapshot.lastStartAt ?? null, + lastStopAt: snapshot.lastStopAt ?? null, + lastError: snapshot.lastError ?? null, + probe: snapshot.probe, + lastProbeAt: snapshot.lastProbeAt ?? null, + }), + + /** Probe account connection */ + probeAccount: async ({ + account, + timeoutMs, + }: { + account: TwitchAccountConfig; + timeoutMs: number; + }): Promise => { + return await probeTwitch(account, timeoutMs); + }, + + /** Build account snapshot with current status */ + buildAccountSnapshot: ({ + account, + runtime, + probe, + }: { + account: TwitchAccountConfig; + cfg: CoreConfig; + runtime?: ChannelAccountSnapshot; + probe?: unknown; + }): ChannelAccountSnapshot => { + return { + accountId: DEFAULT_ACCOUNT_ID, + enabled: account?.enabled !== false, + configured: isConfigured(account), + running: runtime?.running ?? false, + lastStartAt: runtime?.lastStartAt ?? null, + lastStopAt: runtime?.lastStopAt ?? null, + lastError: runtime?.lastError ?? null, + probe, + }; + }, + + /** Collect status issues for all accounts */ + collectStatusIssues: collectTwitchStatusIssues, + }, + + /** Gateway adapter for connection lifecycle */ + gateway: { + /** Start an account connection */ + startAccount: async (ctx): Promise => { + const account = ctx.account as TwitchAccountConfig; + const accountId = ctx.accountId; + + // Create client manager + const clientManager = new TwitchClientManager({ + info: (msg) => ctx.log?.info(`[twitch] ${msg}`), + warn: (msg) => ctx.log?.warn(`[twitch] ${msg}`), + error: (msg) => ctx.log?.error(`[twitch] ${msg}`), + debug: (msg) => ctx.log?.debug?.(`[twitch] ${msg}`), + }); + + // Set up message handler + clientManager.onMessage(account, (message) => { + // Access control check + const botUsername = account.username.toLowerCase(); + if (message.username.toLowerCase() === botUsername) { + return; // Ignore own messages + } + + const access = checkTwitchAccessControl({ + message, + account, + botUsername, + }); + + if (!access.allowed) { + console.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}`, + ); + }); + + // Update status + ctx.setStatus?.({ + accountId, + running: true, + lastStartAt: Date.now(), + lastError: null, + }); + + ctx.log?.info( + `[twitch] Starting Twitch connection for ${account.username}`, + ); + + try { + await clientManager.getClient(account); + ctx.log?.info(`[twitch] Connected to Twitch as ${account.username}`); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + ctx.log?.error(`[twitch] Failed to connect: ${errorMsg}`); + ctx.setStatus?.({ + accountId, + running: false, + lastError: errorMsg, + }); + throw error; + } + }, + + /** Stop an account connection */ + stopAccount: async (ctx): Promise => { + const account = ctx.account as TwitchAccountConfig; + const accountId = ctx.accountId; + + // TODO: Implement client manager cleanup + // This requires tracking client managers per account + + ctx.setStatus?.({ + accountId, + running: false, + lastStopAt: Date.now(), + }); + + ctx.log?.info( + `[twitch] Stopped Twitch connection for ${account.username}`, + ); + }, + }, +}; + +/** + * Extension entry point for registering the Twitch plugin with Clawdbot. + * + * @param api - Plugin API provided by Clawdbot + */ +export function registerTwitchPlugin(api: PluginAPI): void { + api.registerChannel({ plugin: twitchPlugin }); + api.logger?.info("[twitch] Plugin registered"); +} + +/** + * Default export for CommonJS compatibility. + */ +export default twitchPlugin; diff --git a/extensions/twitch/src/probe.test.ts b/extensions/twitch/src/probe.test.ts new file mode 100644 index 000000000..592114e92 --- /dev/null +++ b/extensions/twitch/src/probe.test.ts @@ -0,0 +1,145 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { probeTwitch } from "./probe.js"; +import type { TwitchAccountConfig } from "./types.js"; + +// Mock Twurple modules - Vitest v4 compatible mocking +const mockConnect = vi.fn().mockResolvedValue(undefined); +const mockQuit = vi.fn().mockResolvedValue(undefined); + +vi.mock("@twurple/chat", () => ({ + ChatClient: class { + connect = mockConnect; + quit = mockQuit; + }, +})); + +vi.mock("@twurple/auth", () => ({ + StaticAuthProvider: class {}, +})); + +describe("probeTwitch", () => { + const mockAccount: TwitchAccountConfig = { + username: "testbot", + token: "oauth:test123456789", + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns error when username is missing", async () => { + const account = { ...mockAccount, username: "" as unknown as string }; + const result = await probeTwitch(account, 5000); + + expect(result.ok).toBe(false); + expect(result.error).toContain("missing credentials"); + expect(result.elapsedMs).toBeGreaterThanOrEqual(0); + }); + + it("returns error when token is missing", async () => { + const account = { ...mockAccount, token: "" as unknown as string }; + const result = await probeTwitch(account, 5000); + + expect(result.ok).toBe(false); + expect(result.error).toContain("missing credentials"); + }); + + it("attempts connection regardless of token prefix", async () => { + // Note: probeTwitch doesn't validate token format - it tries to connect with whatever token is provided + // The actual connection would fail in production with an invalid token + const account = { ...mockAccount, token: "raw_token_no_prefix" }; + const result = await probeTwitch(account, 5000); + + // With mock, connection succeeds even without oauth: prefix + expect(result.ok).toBe(true); + }); + + it("successfully connects with valid credentials", async () => { + const result = await probeTwitch(mockAccount, 5000); + + expect(result.ok).toBe(true); + expect(result.connected).toBe(true); + expect(result.username).toBe("testbot"); + expect(result.channel).toBe("testbot"); // defaults to username + expect(result.elapsedMs).toBeGreaterThan(0); + }); + + it("uses custom channel when specified", async () => { + const account: TwitchAccountConfig = { + ...mockAccount, + channel: "customchannel", + }; + + const result = await probeTwitch(account, 5000); + + expect(result.ok).toBe(true); + expect(result.channel).toBe("customchannel"); + }); + + it("times out when connection takes too long", async () => { + mockConnect.mockImplementationOnce(() => new Promise(() => {})); // Never resolves + + const result = await probeTwitch(mockAccount, 100); + + expect(result.ok).toBe(false); + expect(result.error).toContain("timeout"); + + // Reset mock + mockConnect.mockResolvedValue(undefined); + }); + + it("cleans up client even on failure", async () => { + mockConnect.mockRejectedValueOnce(new Error("Connection failed")); + + const result = await probeTwitch(mockAccount, 5000); + + expect(result.ok).toBe(false); + expect(result.error).toContain("Connection failed"); + expect(mockQuit).toHaveBeenCalled(); + + // Reset mocks + mockConnect.mockResolvedValue(undefined); + }); + + it("measures elapsed time", async () => { + const result = await probeTwitch(mockAccount, 5000); + + expect(result.ok).toBe(true); + expect(result.elapsedMs).toBeGreaterThan(0); + }); + + it("handles connection errors gracefully", async () => { + mockConnect.mockRejectedValueOnce(new Error("Network error")); + + const result = await probeTwitch(mockAccount, 5000); + + expect(result.ok).toBe(false); + expect(result.error).toContain("Network error"); + + // Reset mock + mockConnect.mockResolvedValue(undefined); + }); + + it("trims token before validation", async () => { + const account: TwitchAccountConfig = { + ...mockAccount, + token: " oauth:test123456789 ", + }; + + const result = await probeTwitch(account, 5000); + + expect(result.ok).toBe(true); + }); + + it("handles non-Error objects in catch block", async () => { + mockConnect.mockRejectedValueOnce("String error"); + + const result = await probeTwitch(mockAccount, 5000); + + expect(result.ok).toBe(false); + expect(result.error).toBe("String error"); + + // Reset mock + mockConnect.mockResolvedValue(undefined); + }); +}); diff --git a/extensions/twitch/src/probe.ts b/extensions/twitch/src/probe.ts new file mode 100644 index 000000000..a2c1edcf0 --- /dev/null +++ b/extensions/twitch/src/probe.ts @@ -0,0 +1,94 @@ +import { StaticAuthProvider } from "@twurple/auth"; +import { ChatClient } from "@twurple/chat"; +import type { TwitchAccountConfig } from "./types.js"; + +/** + * Result of probing a Twitch account + */ +export type ProbeTwitchResult = { + ok: boolean; + error?: string; + username?: string; + elapsedMs: number; + connected?: boolean; + channel?: string; +}; + +/** + * Probe a Twitch account to verify the connection is working + * + * This tests the Twitch OAuth token by attempting to connect + * to the chat server and verify the bot's username. + */ +export async function probeTwitch( + account: TwitchAccountConfig, + timeoutMs: number, +): Promise { + const started = Date.now(); + + if (!account.token || !account.username) { + return { + ok: false, + error: "missing credentials (token, username)", + username: account.username, + elapsedMs: Date.now() - started, + }; + } + + const rawToken = account.token.trim(); + + let client: ChatClient | undefined; + + try { + // Create a timeout promise + const timeout = new Promise((_, reject) => { + setTimeout( + () => reject(new Error(`connection timeout after ${timeoutMs}ms`)), + timeoutMs, + ); + }); + + // Create auth provider with the token + const authProvider = new StaticAuthProvider( + account.clientId ?? "", + rawToken, + ); + + // Create chat client + client = new ChatClient({ + authProvider, + }); + + // Race between connection and timeout + await Promise.race([client.connect(), timeout]); + + // Wait a moment for the connection to fully establish + await new Promise((resolve) => setTimeout(resolve, 500)); + + // If we got here, connection was successful + return { + ok: true, + connected: true, + username: account.username, + channel: account.channel ?? account.username, + elapsedMs: Date.now() - started, + }; + } catch (error) { + return { + ok: false, + error: error instanceof Error ? error.message : String(error), + username: account.username, + channel: account.channel ?? account.username, + elapsedMs: Date.now() - started, + }; + } finally { + // Always clean up the client + if (client) { + try { + client.quit(); + } catch { + // Ignore cleanup errors + } + } + } +} diff --git a/extensions/twitch/src/resolver.ts b/extensions/twitch/src/resolver.ts new file mode 100644 index 000000000..5c05ab511 --- /dev/null +++ b/extensions/twitch/src/resolver.ts @@ -0,0 +1,155 @@ +/** + * Twitch resolver adapter for channel/user name resolution. + * + * This module implements the ChannelResolverAdapter interface to resolve + * Twitch usernames to user IDs via the Twitch Helix API. + */ + +import { ApiClient } from "@twurple/api"; +import { StaticAuthProvider } from "@twurple/auth"; +import type { ChannelResolveKind, ChannelResolveResult } from "./types.js"; +import type { ProviderLogger, TwitchAccountConfig } from "./types.js"; + +/** + * Normalize a Twitch username - strip @ prefix and convert to lowercase + */ +function normalizeUsername(input: string): string { + const trimmed = input.trim(); + if (trimmed.startsWith("@")) { + return trimmed.slice(1).toLowerCase(); + } + return trimmed.toLowerCase(); +} + +/** + * Create a logger that includes the Twitch prefix + */ +function createLogger(logger?: ProviderLogger): ProviderLogger { + return { + info: (msg: string) => logger?.info(`[twitch] ${msg}`), + warn: (msg: string) => logger?.warn(`[twitch] ${msg}`), + error: (msg: string) => logger?.error(`[twitch] ${msg}`), + debug: (msg: string) => logger?.debug?.(`[twitch] ${msg}`) ?? (() => {}), + }; +} + +/** + * Resolve Twitch usernames to user IDs via the Helix API + * + * @param inputs - Array of usernames or user IDs to resolve + * @param account - Twitch account configuration with auth credentials + * @param kind - Type of target to resolve ("user" or "group") + * @param logger - Optional logger + * @returns Promise resolving to array of ChannelResolveResult + */ +export async function resolveTwitchTargets( + inputs: string[], + account: TwitchAccountConfig, + kind: ChannelResolveKind, + logger?: ProviderLogger, +): Promise { + const log = createLogger(logger); + + // Validate credentials + if (!account.clientId || !account.token) { + log.error("Missing Twitch client ID or token"); + return inputs.map((input) => ({ + input, + resolved: false, + note: "missing Twitch credentials", + })); + } + + // Normalize token - strip oauth: prefix if present + const normalizedToken = account.token.startsWith("oauth:") + ? account.token.slice(6) + : account.token; + + // Create auth provider and API client + const authProvider = new StaticAuthProvider( + account.clientId, + normalizedToken, + ); + const apiClient = new ApiClient({ authProvider }); + + const results: ChannelResolveResult[] = []; + + // Process each input + for (const input of inputs) { + const normalized = normalizeUsername(input); + + // Skip empty inputs + if (!normalized) { + results.push({ + input, + resolved: false, + note: "empty input", + }); + continue; + } + + // If it looks like a user ID (numeric), validate it exists + const looksLikeUserId = /^\d+$/.test(normalized); + + try { + if (looksLikeUserId) { + // Validate user ID by fetching the user + const user = await apiClient.users.getUserById(normalized); + + if (user) { + results.push({ + input, + resolved: true, + id: user.id, + name: user.name, + }); + log.debug(`Resolved user ID ${normalized} -> ${user.name}`); + } else { + results.push({ + input, + resolved: false, + note: "user ID not found", + }); + log.warn(`User ID ${normalized} not found`); + } + } else { + // Resolve username to user ID + const user = await apiClient.users.getUserByName(normalized); + + if (user) { + results.push({ + input, + resolved: true, + id: user.id, + name: user.name, + note: + user.displayName !== user.name + ? `display: ${user.displayName}` + : undefined, + }); + log.debug( + `Resolved username ${normalized} -> ${user.id} (${user.name})`, + ); + } else { + results.push({ + input, + resolved: false, + note: "username not found", + }); + log.warn(`Username ${normalized} not found`); + } + } + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + results.push({ + input, + resolved: false, + note: `API error: ${errorMessage}`, + }); + log.error(`Failed to resolve ${input}: ${errorMessage}`); + } + } + + return results; +} diff --git a/extensions/twitch/src/send.ts b/extensions/twitch/src/send.ts new file mode 100644 index 000000000..bd9271957 --- /dev/null +++ b/extensions/twitch/src/send.ts @@ -0,0 +1,236 @@ +/** + * Twitch message sending functions with dependency injection support. + * + * These functions are the primary interface for sending messages to Twitch. + * They support dependency injection via the `deps` parameter for testability. + */ + +import { DEFAULT_ACCOUNT_ID, getAccountConfig } from "./config.js"; +import { TwitchClientManager } from "./twitch-client.js"; +import type { CoreConfig } from "./types.js"; +import { stripMarkdownForTwitch } from "./utils/markdown.js"; +import { + generateMessageId, + isAccountConfigured, + normalizeTwitchChannel, +} from "./utils/twitch.js"; + +/** + * Result from sending a message to Twitch. + */ +export interface SendMessageResult { + /** Whether the send was successful */ + ok: boolean; + /** The message ID (generated for tracking) */ + messageId: string; + /** Error message if the send failed */ + error?: string; +} + +/** + * Options for sending Twitch messages. + */ +export interface SendTwitchOptions { + /** Account ID to use for sending */ + accountId?: string; + /** Whether to enable verbose logging */ + verbose?: boolean; + /** Abort signal for cancellation */ + signal?: AbortSignal; +} + +/** + * Client manager cache for reuse across calls. + */ +const clientManagerCache = new Map(); + +/** + * Get or create a client manager for an account. + * + * @param accountId - The account ID + * @param logger - Logger instance + * @returns The client manager + */ +function getClientManager( + accountId: string, + logger: Console, +): TwitchClientManager { + if (!clientManagerCache.has(accountId)) { + clientManagerCache.set( + accountId, + new TwitchClientManager({ + info: (msg) => logger.info(`[twitch] ${msg}`), + warn: (msg) => logger.warn(`[twitch] ${msg}`), + error: (msg) => logger.error(`[twitch] ${msg}`), + debug: (msg) => logger.debug(`[twitch] ${msg}`), + }), + ); + } + const client = clientManagerCache.get(accountId); + if (!client) { + throw new Error(`Client manager not found for account: ${accountId}`); + } + return client; +} + +/** + * Send a text message to a Twitch channel. + * + * @param channel - The channel name (with or without # prefix) + * @param text - The message text to send + * @param options - Send options + * @returns Result with message ID and status + * + * @example + * const result = await sendMessageTwitch("#mychannel", "Hello Twitch!", { + * accountId: "default", + * }); + */ +export async function sendMessageTwitch( + _channel: string, + _text: string, + options: SendTwitchOptions = {}, +): Promise { + const { verbose = false, signal } = options; + + // Check for abort signal + if (signal?.aborted) { + return { + ok: false, + messageId: generateMessageId(), + error: "Send operation aborted", + }; + } + + try { + // This would normally come from the Clawdbot config + // For now, we'll require the config to be passed via a different mechanism + // In the actual adapter, this comes from ChannelOutboundContext.cfg + throw new Error( + "sendMessageTwitch requires CoreConfig. " + + "Use the outbound adapter via Clawdbot's channel system instead.", + ); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + if (verbose) console.error(`[twitch] Send failed: ${errorMsg}`); + return { + ok: false, + messageId: generateMessageId(), + error: errorMsg, + }; + } +} + +/** + * Internal send function used by the outbound adapter. + * + * This function has access to the full Clawdbot config and handles + * account resolution, markdown stripping, and actual message sending. + * + * @param channel - The channel name + * @param text - The message text + * @param cfg - Full Clawdbot configuration + * @param accountId - Account ID to use + * @param stripMarkdown - Whether to strip markdown (default: true) + * @param logger - Logger instance + * @returns Result with message ID and status + */ +export async function sendMessageTwitchInternal( + channel: string, + text: string, + cfg: CoreConfig, + accountId: string = DEFAULT_ACCOUNT_ID, + stripMarkdown: boolean = true, + logger: Console = console, +): Promise { + // Resolve account + const account = getAccountConfig(cfg, accountId); + if (!account) { + const availableIds = Object.keys(cfg.channels?.twitch?.accounts ?? {}); + return { + ok: false, + messageId: generateMessageId(), + error: `Account not found: ${accountId}. Available accounts: ${availableIds.join(", ") || "none"}`, + }; + } + + if (!isAccountConfigured(account)) { + return { + ok: false, + messageId: generateMessageId(), + error: `Account ${accountId} is not properly configured. Required: username, token, clientId`, + }; + } + + // Normalize channel + const normalizedChannel = channel || account.channel || account.username; + if (!normalizedChannel) { + return { + ok: false, + messageId: generateMessageId(), + error: "No channel specified and no default channel in account config", + }; + } + + // Strip markdown if enabled + const cleanedText = stripMarkdown ? stripMarkdownForTwitch(text) : text; + if (!cleanedText) { + return { + ok: true, + messageId: "skipped", + }; + } + + try { + // Get client manager and send message + const clientManager = getClientManager(accountId, logger); + const result = await clientManager.sendMessage( + account, + normalizeTwitchChannel(normalizedChannel), + cleanedText, + ); + + if (!result.ok) { + return { + ok: false, + messageId: result.messageId ?? generateMessageId(), + error: result.error ?? "Send failed", + }; + } + + return { + ok: true, + messageId: result.messageId ?? generateMessageId(), + }; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + logger.error(`[twitch] Failed to send message: ${errorMsg}`); + return { + ok: false, + messageId: generateMessageId(), + error: errorMsg, + }; + } +} + +/** + * Send media to a Twitch channel. + * + * Note: Twitch chat doesn't support direct media uploads. This function + * sends the media URL as text instead. + * + * @param channel - The channel name + * @param text - Optional message text to accompany the media + * @param mediaUrl - The media URL to send + * @param options - Send options + * @returns Result with message ID and status + */ +export async function sendMediaTwitch( + channel: string, + mediaUrl: string, + text: string = "", + options: SendTwitchOptions = {}, +): Promise { + const combinedMessage = text ? `${text} ${mediaUrl}`.trim() : mediaUrl; + return sendMessageTwitch(channel, combinedMessage, options); +} diff --git a/extensions/twitch/src/status.ts b/extensions/twitch/src/status.ts new file mode 100644 index 000000000..6fcffa3c2 --- /dev/null +++ b/extensions/twitch/src/status.ts @@ -0,0 +1,185 @@ +/** + * Twitch status issues collector. + * + * Detects and reports configuration issues for Twitch accounts. + */ + +import { getAccountConfig } from "./config.js"; +import type { ChannelAccountSnapshot, ChannelStatusIssue } from "./types.js"; +import { isAccountConfigured } from "./utils/twitch.js"; + +/** + * Collect status issues for Twitch accounts. + * + * Analyzes account snapshots and detects configuration problems, + * authentication issues, and other potential problems. + * + * @param accounts - Array of account snapshots to analyze + * @param getCfg - Optional function to get full config for additional checks + * @returns Array of detected status issues + * + * @example + * const issues = collectTwitchStatusIssues(accountSnapshots); + * if (issues.length > 0) { + * console.warn("Twitch configuration issues detected:"); + * issues.forEach(issue => console.warn(`- ${issue.message}`)); + * } + */ +export function collectTwitchStatusIssues( + accounts: ChannelAccountSnapshot[], + getCfg?: () => unknown, +): ChannelStatusIssue[] { + const issues: ChannelStatusIssue[] = []; + + for (const entry of accounts) { + const accountId = entry.accountId; + + // Skip if not a Twitch account + if (!accountId) continue; + + // Get full account config if available + let account: ReturnType | null = null; + if (getCfg) { + try { + const cfg = getCfg() as { + channels?: { twitch?: { accounts?: Record } }; + }; + account = getAccountConfig(cfg, accountId); + } catch { + // Ignore config access errors + } + } + + // Check 1: Account not configured + if (!entry.configured) { + issues.push({ + channel: "twitch", + accountId, + kind: "config", + message: "Twitch account is not properly configured", + fix: "Add required fields: username, token, and clientId to your account configuration", + }); + continue; // Skip further checks if not configured + } + + // Check 2: Account disabled + if (entry.enabled === false) { + issues.push({ + channel: "twitch", + accountId, + kind: "config", + message: "Twitch account is disabled", + fix: "Set enabled: true in your account configuration to enable this account", + }); + continue; // Skip further checks if disabled + } + + // Checks that require account config + if (account && isAccountConfigured(account)) { + // Check 3: Missing clientId + if (!account.clientId) { + issues.push({ + channel: "twitch", + accountId, + kind: "config", + message: "Twitch client ID is required", + fix: "Add clientId to your Twitch account configuration (from Twitch Developer Portal)", + }); + } + + // Check 4: Token format warning (normalized, but may indicate config issue) + if (account.token?.startsWith("oauth:")) { + issues.push({ + channel: "twitch", + accountId, + kind: "config", + message: "Token contains 'oauth:' prefix (will be stripped)", + fix: "The 'oauth:' prefix is optional. You can use just the token value, or keep it as-is (it will be normalized automatically).", + }); + } + + // Check 5: clientSecret provided without refreshToken + if (account.clientSecret && !account.refreshToken) { + issues.push({ + channel: "twitch", + accountId, + kind: "config", + message: "clientSecret provided without refreshToken", + fix: "For automatic token refresh, provide both clientSecret and refreshToken. Otherwise, clientSecret is not needed.", + }); + } + + // Check 6: Access control warnings + if (account.allowFrom && account.allowFrom.length === 0) { + issues.push({ + channel: "twitch", + accountId, + kind: "config", + message: "allowFrom is configured but empty", + fix: "Either add user IDs to allowFrom, remove the allowFrom field, or use allowedRoles instead.", + }); + } + + // Check 7: Invalid role combinations + if ( + account.allowedRoles?.includes("all") && + account.allowFrom && + account.allowFrom.length > 0 + ) { + issues.push({ + channel: "twitch", + accountId, + kind: "intent", + 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.", + }); + } + } + + // Check 8: Runtime errors + if (entry.lastError) { + issues.push({ + channel: "twitch", + accountId, + kind: "runtime", + message: `Last error: ${entry.lastError}`, + fix: "Check your token validity and network connection. Ensure the bot has the required OAuth scopes.", + }); + } + + // Check 9: Account never connected successfully + if ( + entry.configured && + !entry.running && + !entry.lastStartAt && + !entry.lastInboundAt && + !entry.lastOutboundAt + ) { + issues.push({ + channel: "twitch", + accountId, + kind: "runtime", + message: "Account has never connected successfully", + fix: "Start the Twitch gateway to begin receiving messages. Check logs for connection errors.", + }); + } + + // Check 10: Long-running connection may need reconnection + if (entry.running && entry.lastStartAt) { + const uptime = Date.now() - entry.lastStartAt; + const daysSinceStart = uptime / (1000 * 60 * 60 * 24); + if (daysSinceStart > 7) { + issues.push({ + channel: "twitch", + accountId, + kind: "runtime", + message: `Connection has been running for ${Math.floor(daysSinceStart)} days`, + fix: "Consider restarting the connection periodically to refresh the connection. Twitch tokens may expire after long periods.", + }); + } + } + } + + return issues; +} diff --git a/extensions/twitch/src/twitch-client.test.ts b/extensions/twitch/src/twitch-client.test.ts new file mode 100644 index 000000000..83476eae1 --- /dev/null +++ b/extensions/twitch/src/twitch-client.test.ts @@ -0,0 +1,589 @@ +/** + * Tests for TwitchClientManager class + * + * Tests cover: + * - Client connection and reconnection + * - Message handling (chat and whispers) + * - Message sending with rate limiting + * - Disconnection scenarios + * - Error handling and edge cases + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { TwitchClientManager } from "./twitch-client.js"; +import type { + ProviderLogger, + TwitchAccountConfig, + TwitchChatMessage, +} from "./types.js"; + +// Mock @twurple dependencies +const mockConnect = vi.fn().mockResolvedValue(undefined); +const mockJoin = vi.fn().mockResolvedValue(undefined); +const mockSay = vi.fn().mockResolvedValue({ messageId: "test-msg-123" }); +const mockQuit = vi.fn(); +const mockOnMessage = vi.fn(); +const mockOnWhisper = vi.fn(); +const mockAddUserForToken = vi.fn().mockResolvedValue("123456"); +const mockOnRefresh = vi.fn(); +const mockOnRefreshFailure = vi.fn(); + +vi.mock("@twurple/chat", () => ({ + ChatClient: class { + onMessage = mockOnMessage; + onWhisper = mockOnWhisper; + connect = mockConnect; + join = mockJoin; + say = mockSay; + quit = mockQuit; + }, +})); + +const mockAuthProvider = { + constructor: vi.fn(), +}; + +vi.mock("@twurple/auth", () => ({ + StaticAuthProvider: class { + constructor(...args: unknown[]) { + mockAuthProvider.constructor(...args); + } + }, + RefreshingAuthProvider: class { + addUserForToken = mockAddUserForToken; + onRefresh = mockOnRefresh; + onRefreshFailure = mockOnRefreshFailure; + }, +})); + +describe("TwitchClientManager", () => { + let manager: TwitchClientManager; + let mockLogger: ProviderLogger; + + const testAccount: TwitchAccountConfig = { + username: "testbot", + token: "oauth:test123456", + clientId: "test-client-id", + channel: "testchannel", + enabled: true, + }; + + const testAccount2: TwitchAccountConfig = { + username: "testbot2", + token: "oauth:test789", + clientId: "test-client-id-2", + channel: "testchannel2", + enabled: true, + }; + + beforeEach(() => { + // Clear all mocks + vi.clearAllMocks(); + + // Create mock logger + mockLogger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }; + + // Create manager instance + manager = new TwitchClientManager(mockLogger); + }); + + afterEach(() => { + // Clean up manager to avoid side effects + manager._clearForTest(); + }); + + describe("getClient", () => { + it("should create a new client connection", async () => { + const _client = await manager.getClient(testAccount); + + // New implementation: connect is called, channels are passed to constructor + expect(mockConnect).toHaveBeenCalledTimes(1); + expect(mockLogger.info).toHaveBeenCalledWith( + expect.stringContaining("Connected to Twitch as testbot"), + ); + }); + + it("should use account username as default channel when channel not specified", async () => { + const accountWithoutChannel: TwitchAccountConfig = { + ...testAccount, + channel: undefined, + }; + + await manager.getClient(accountWithoutChannel); + + // New implementation: channel (testbot) is passed to constructor, not via join() + expect(mockConnect).toHaveBeenCalledTimes(1); + }); + + it("should reuse existing client for same account", async () => { + const client1 = await manager.getClient(testAccount); + const client2 = await manager.getClient(testAccount); + + expect(client1).toBe(client2); + expect(mockConnect).toHaveBeenCalledTimes(1); + }); + + it("should create separate clients for different accounts", async () => { + await manager.getClient(testAccount); + await manager.getClient(testAccount2); + + expect(mockConnect).toHaveBeenCalledTimes(2); + }); + + it("should normalize token by removing oauth: prefix", async () => { + const accountWithPrefix: TwitchAccountConfig = { + ...testAccount, + token: "oauth:actualtoken123", + }; + + await manager.getClient(accountWithPrefix); + + expect(mockAuthProvider.constructor).toHaveBeenCalledWith( + "test-client-id", + "actualtoken123", + ); + }); + + it("should use token directly when no oauth: prefix", async () => { + await manager.getClient(testAccount); + + // Implementation strips oauth: prefix from all tokens + expect(mockAuthProvider.constructor).toHaveBeenCalledWith( + "test-client-id", + "test123456", + ); + }); + + it("should throw error when clientId is missing", async () => { + const accountWithoutClientId: TwitchAccountConfig = { + ...testAccount, + clientId: undefined, + }; + + await expect(manager.getClient(accountWithoutClientId)).rejects.toThrow( + "Missing Twitch client ID or token", + ); + + expect(mockLogger.error).toHaveBeenCalledWith( + expect.stringContaining("Missing Twitch client ID or token"), + ); + }); + + it("should throw error when token is missing", async () => { + const accountWithoutToken: TwitchAccountConfig = { + ...testAccount, + token: "", + }; + + await expect(manager.getClient(accountWithoutToken)).rejects.toThrow( + "Missing Twitch client ID or token", + ); + }); + + it("should set up message handlers on client connection", async () => { + await manager.getClient(testAccount); + + expect(mockOnMessage).toHaveBeenCalled(); + expect(mockOnWhisper).toHaveBeenCalled(); + expect(mockLogger.info).toHaveBeenCalledWith( + expect.stringContaining("Set up handlers for"), + ); + }); + + it("should create separate clients for same account with different channels", async () => { + const account1: TwitchAccountConfig = { + ...testAccount, + channel: "channel1", + }; + const account2: TwitchAccountConfig = { + ...testAccount, + channel: "channel2", + }; + + await manager.getClient(account1); + await manager.getClient(account2); + + expect(mockConnect).toHaveBeenCalledTimes(2); + }); + }); + + describe("onMessage", () => { + it("should register message handler for account", () => { + const handler = vi.fn(); + manager.onMessage(testAccount, handler); + + expect(handler).not.toHaveBeenCalled(); + }); + + it("should replace existing handler for same account", () => { + const handler1 = vi.fn(); + const handler2 = vi.fn(); + + manager.onMessage(testAccount, handler1); + manager.onMessage(testAccount, handler2); + + // Check the stored handler is handler2 + const key = manager.getAccountKey(testAccount); + expect((manager as any).messageHandlers.get(key)).toBe(handler2); + }); + }); + + describe("disconnect", () => { + it("should disconnect a connected client", async () => { + await manager.getClient(testAccount); + await manager.disconnect(testAccount); + + expect(mockQuit).toHaveBeenCalledTimes(1); + expect(mockLogger.info).toHaveBeenCalledWith( + expect.stringContaining("Disconnected"), + ); + }); + + it("should clear client and message handler", async () => { + const handler = vi.fn(); + await manager.getClient(testAccount); + manager.onMessage(testAccount, handler); + + await manager.disconnect(testAccount); + + const key = manager.getAccountKey(testAccount); + expect((manager as any).clients.has(key)).toBe(false); + expect((manager as any).messageHandlers.has(key)).toBe(false); + }); + + it("should handle disconnecting non-existent client gracefully", async () => { + // disconnect doesn't throw, just does nothing + await manager.disconnect(testAccount); + expect(mockQuit).not.toHaveBeenCalled(); + }); + + it("should only disconnect specified account when multiple accounts exist", async () => { + await manager.getClient(testAccount); + await manager.getClient(testAccount2); + + await manager.disconnect(testAccount); + + expect(mockQuit).toHaveBeenCalledTimes(1); + + const key2 = manager.getAccountKey(testAccount2); + expect((manager as any).clients.has(key2)).toBe(true); + }); + }); + + describe("disconnectAll", () => { + it("should disconnect all connected clients", async () => { + await manager.getClient(testAccount); + await manager.getClient(testAccount2); + + await manager.disconnectAll(); + + expect(mockQuit).toHaveBeenCalledTimes(2); + expect((manager as any).clients.size).toBe(0); + expect((manager as any).messageHandlers.size).toBe(0); + }); + + it("should handle empty client list gracefully", async () => { + // disconnectAll doesn't throw, just does nothing + await manager.disconnectAll(); + expect(mockQuit).not.toHaveBeenCalled(); + }); + }); + + describe("sendMessage", () => { + beforeEach(async () => { + await manager.getClient(testAccount); + }); + + it("should send message successfully", async () => { + const result = await manager.sendMessage( + testAccount, + "testchannel", + "Hello, world!", + ); + + expect(result.ok).toBe(true); + expect(result.messageId).toBeDefined(); + expect(mockSay).toHaveBeenCalledWith("testchannel", "Hello, world!"); + }); + + 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", + ); + + expect(result1.messageId).not.toBe(result2.messageId); + }); + + it("should handle sending to account's default channel", async () => { + const result = await manager.sendMessage( + testAccount, + testAccount.channel || testAccount.username, + "Test message", + ); + + // Should use the account's channel or username + expect(result.ok).toBe(true); + expect(mockSay).toHaveBeenCalled(); + }); + + it("should return error on send failure", async () => { + mockSay.mockRejectedValueOnce(new Error("Rate limited")); + + const result = await manager.sendMessage( + testAccount, + "testchannel", + "Test message", + ); + + expect(result.ok).toBe(false); + expect(result.error).toBe("Rate limited"); + expect(mockLogger.error).toHaveBeenCalledWith( + expect.stringContaining("Failed to send message"), + ); + }); + + it("should handle unknown error types", async () => { + mockSay.mockRejectedValueOnce("String error"); + + const result = await manager.sendMessage( + testAccount, + "testchannel", + "Test message", + ); + + expect(result.ok).toBe(false); + expect(result.error).toBe("String error"); + }); + + it("should create client if not already connected", async () => { + // Clear the existing client + (manager as any).clients.clear(); + + // Reset connect call count for this specific test + const connectCallCountBefore = mockConnect.mock.calls.length; + + const result = await manager.sendMessage( + testAccount, + "testchannel", + "Test message", + ); + + expect(result.ok).toBe(true); + expect(mockConnect.mock.calls.length).toBeGreaterThan( + connectCallCountBefore, + ); + }); + }); + + describe("message handling integration", () => { + let capturedMessage: TwitchChatMessage | null = null; + + beforeEach(() => { + capturedMessage = null; + + // Set up message handler before connecting + manager.onMessage(testAccount, (message) => { + capturedMessage = message; + }); + }); + + it("should handle incoming chat messages", async () => { + await manager.getClient(testAccount); + + // Get the onMessage callback + const onMessageCallback = mockOnMessage.mock.calls[0]?.[0]; + if (!onMessageCallback) throw new Error("onMessageCallback not found"); + + // Simulate Twitch message + onMessageCallback("#testchannel", "testuser", "Hello bot!", { + userInfo: { + userName: "testuser", + displayName: "TestUser", + userId: "12345", + isMod: false, + isBroadcaster: false, + isVip: false, + isSubscriber: false, + }, + id: "msg123", + }); + + expect(capturedMessage).not.toBeNull(); + expect(capturedMessage?.username).toBe("testuser"); + expect(capturedMessage?.displayName).toBe("TestUser"); + expect(capturedMessage?.userId).toBe("12345"); + expect(capturedMessage?.message).toBe("Hello bot!"); + expect(capturedMessage?.channel).toBe("testchannel"); + expect(capturedMessage?.chatType).toBe("group"); + }); + + it("should handle whispers (DMs)", async () => { + await manager.getClient(testAccount); + + // Get the onWhisper callback + const onWhisperCallback = mockOnWhisper.mock.calls[0]?.[0]; + if (!onWhisperCallback) throw new Error("onWhisperCallback not found"); + + // Simulate Twitch whisper + onWhisperCallback("whisperuser", "Secret message", { + userInfo: { + userName: "whisperuser", + displayName: "WhisperUser", + userId: "67890", + isMod: true, + isBroadcaster: false, + isVip: false, + isSubscriber: true, + }, + }); + + expect(capturedMessage).not.toBeNull(); + expect(capturedMessage?.username).toBe("whisperuser"); + expect(capturedMessage?.message).toBe("Secret message"); + expect(capturedMessage?.chatType).toBe("direct"); + expect(capturedMessage?.id).toBeUndefined(); + }); + + it("should normalize channel names without # prefix", async () => { + await manager.getClient(testAccount); + + const onMessageCallback = mockOnMessage.mock.calls[0]?.[0]; + + onMessageCallback("testchannel", "testuser", "Test", { + userInfo: { + userName: "testuser", + displayName: "TestUser", + userId: "123", + isMod: false, + isBroadcaster: false, + isVip: false, + isSubscriber: false, + }, + id: "msg1", + }); + + expect(capturedMessage?.channel).toBe("testchannel"); + }); + + it("should include user role flags in message", async () => { + await manager.getClient(testAccount); + + const onMessageCallback = mockOnMessage.mock.calls[0]?.[0]; + + onMessageCallback("#testchannel", "moduser", "Test", { + userInfo: { + userName: "moduser", + displayName: "ModUser", + userId: "456", + isMod: true, + isBroadcaster: false, + isVip: true, + isSubscriber: true, + }, + id: "msg2", + }); + + expect(capturedMessage?.isMod).toBe(true); + expect(capturedMessage?.isVip).toBe(true); + expect(capturedMessage?.isSub).toBe(true); + expect(capturedMessage?.isOwner).toBe(false); + }); + + it("should handle broadcaster messages", async () => { + await manager.getClient(testAccount); + + const onMessageCallback = mockOnMessage.mock.calls[0]?.[0]; + + onMessageCallback("#testchannel", "broadcaster", "Test", { + userInfo: { + userName: "broadcaster", + displayName: "Broadcaster", + userId: "789", + isMod: false, + isBroadcaster: true, + isVip: false, + isSubscriber: false, + }, + id: "msg3", + }); + + expect(capturedMessage?.isOwner).toBe(true); + }); + }); + + describe("edge cases", () => { + it("should handle multiple message handlers for different accounts", async () => { + const messages1: TwitchChatMessage[] = []; + const messages2: TwitchChatMessage[] = []; + + manager.onMessage(testAccount, (msg) => messages1.push(msg)); + manager.onMessage(testAccount2, (msg) => messages2.push(msg)); + + await manager.getClient(testAccount); + await manager.getClient(testAccount2); + + // Simulate message for first account + const onMessage1 = mockOnMessage.mock.calls[0]?.[0]; + if (!onMessage1) throw new Error("onMessage1 not found"); + onMessage1("#testchannel", "user1", "msg1", { + userInfo: { + userName: "user1", + displayName: "User1", + userId: "1", + isMod: false, + isBroadcaster: false, + isVip: false, + isSubscriber: false, + }, + id: "1", + }); + + // Simulate message for second account + const onMessage2 = mockOnMessage.mock.calls[1]?.[0]; + if (!onMessage2) throw new Error("onMessage2 not found"); + onMessage2("#testchannel2", "user2", "msg2", { + userInfo: { + userName: "user2", + displayName: "User2", + userId: "2", + isMod: false, + isBroadcaster: false, + isVip: false, + isSubscriber: false, + }, + id: "2", + }); + + expect(messages1).toHaveLength(1); + expect(messages2).toHaveLength(1); + expect(messages1[0]?.message).toBe("msg1"); + expect(messages2[0]?.message).toBe("msg2"); + }); + + it("should handle rapid client creation requests", async () => { + const promises = [ + manager.getClient(testAccount), + manager.getClient(testAccount), + manager.getClient(testAccount), + ]; + + await Promise.all(promises); + + // Note: The implementation doesn't handle concurrent getClient calls, + // so multiple connections may be created. This is expected behavior. + expect(mockConnect).toHaveBeenCalled(); + }); + }); +}); diff --git a/extensions/twitch/src/twitch-client.ts b/extensions/twitch/src/twitch-client.ts new file mode 100644 index 000000000..7776a32f6 --- /dev/null +++ b/extensions/twitch/src/twitch-client.ts @@ -0,0 +1,290 @@ +import { RefreshingAuthProvider, StaticAuthProvider } from "@twurple/auth"; +import { ChatClient, LogLevel } from "@twurple/chat"; +import type { + ProviderLogger, + TwitchAccountConfig, + TwitchChatMessage, +} from "./types.js"; + +/** + * Manages Twitch chat client connections + */ +export class TwitchClientManager { + private clients = new Map(); + private messageHandlers = new Map< + string, + (message: TwitchChatMessage) => void + >(); + + constructor(private logger: ProviderLogger) {} + + /** + * Get or create a chat client for an account + */ + async getClient(account: TwitchAccountConfig): Promise { + const key = this.getAccountKey(account); + + const existing = this.clients.get(key); + if (existing) { + return existing; + } + + if (!account.clientId || !account.token) { + this.logger.error( + `[twitch] Missing Twitch client ID or token for account ${account.username}`, + ); + throw new Error("Missing Twitch client ID or token"); + } + + // Normalize token - strip oauth: prefix if present (Twurple doesn't need it) + const normalizedToken = account.token.startsWith("oauth:") + ? account.token.slice(6) + : account.token; + + // Use RefreshingAuthProvider if clientSecret is provided (supports optional refresh tokens) + let authProvider: StaticAuthProvider | RefreshingAuthProvider; + if (account.clientSecret) { + // Use RefreshingAuthProvider - can handle tokens with or without refresh tokens + authProvider = new RefreshingAuthProvider({ + clientId: account.clientId, + clientSecret: account.clientSecret, + }); + + // Use addUserForToken to figure out the user ID from the token + // This works whether we have a refresh token or not + (authProvider as RefreshingAuthProvider) + .addUserForToken({ + accessToken: normalizedToken, + refreshToken: account.refreshToken ?? null, + expiresIn: account.expiresIn ?? null, + obtainmentTimestamp: account.obtainmentTimestamp ?? Date.now(), + }) + .then((userId) => { + this.logger.info( + `[twitch] Added user ${userId} to RefreshingAuthProvider for ${account.username}`, + ); + }) + .catch((err) => { + this.logger.error( + `[twitch] Failed to add user to RefreshingAuthProvider: ${err instanceof Error ? err.message : String(err)}`, + ); + }); + + // Set up token refresh event listener (only fires if refreshToken is provided) + (authProvider as RefreshingAuthProvider).onRefresh((userId, token) => { + this.logger.info( + `[twitch] Access token refreshed for user ${userId} (expires in ${token.expiresIn ? `${token.expiresIn}s` : "unknown"})`, + ); + }); + + // Set up token refresh failure listener + (authProvider as RefreshingAuthProvider).onRefreshFailure( + (userId, error) => { + this.logger.error( + `[twitch] Failed to refresh access token for user ${userId}: ${error.message}`, + ); + }, + ); + + const refreshStatus = account.refreshToken + ? "automatic token refresh enabled" + : "token refresh disabled (no refresh token)"; + this.logger.info( + `[twitch] Using RefreshingAuthProvider for ${account.username} (${refreshStatus})`, + ); + } else { + // Fall back to StaticAuthProvider for backward compatibility (no clientSecret) + authProvider = new StaticAuthProvider(account.clientId, normalizedToken); + this.logger.info( + `[twitch] Using StaticAuthProvider for ${account.username} (no clientSecret provided)`, + ); + } + + const channel = account.channel ?? account.username; + + // Create chat client + const client = new ChatClient({ + authProvider, + channels: [channel], + rejoinChannelsOnReconnect: true, + requestMembershipEvents: true, + logger: { + // minLevel: LogLevel.ERROR, + custom: { + log: (level, message) => { + switch (level) { + case LogLevel.CRITICAL: + this.logger.error(`[twitch] ${message}`); + break; + case LogLevel.ERROR: + this.logger.error(`[twitch] ${message}`); + break; + case LogLevel.WARNING: + this.logger.warn(`[twitch] ${message}`); + break; + case LogLevel.INFO: + this.logger.info(`[twitch] ${message}`); + break; + case LogLevel.DEBUG: + this.logger.debug(`[twitch] ${message}`); + break; + case LogLevel.TRACE: + this.logger.debug(`[twitch] ${message}`); + break; + } + }, + }, + }, + }); + + // Set up event handlers + this.setupClientHandlers(client, account); + + // Connect + client.connect(); + + this.clients.set(key, client); + this.logger.info(`[twitch] Connected to Twitch as ${account.username}`); + + return client; + } + + /** + * Set up message and event handlers for a client + */ + 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; + handler({ + username: msg.userInfo.userName, + displayName: msg.userInfo.displayName, + userId: msg.userInfo.userId, + message: messageText, + channel: normalizedChannel, + id: msg.id, + timestamp: new Date(), + isMod: msg.userInfo.isMod, + isOwner: msg.userInfo.isBroadcaster, + isVip: msg.userInfo.isVip, + isSub: msg.userInfo.isSubscriber, + chatType: "group", + }); + } + }); + + // Handle whispers (DMs) + client.onWhisper((_user, messageText, msg) => { + const handler = this.messageHandlers.get(key); + if (handler) { + handler({ + username: msg.userInfo.userName, + displayName: msg.userInfo.displayName, + userId: msg.userInfo.userId, + message: messageText, + channel: msg.userInfo.userName, + id: undefined, // Whisper doesn't have id property + timestamp: new Date(), + isMod: msg.userInfo.isMod, + isOwner: msg.userInfo.isBroadcaster, + isVip: msg.userInfo.isVip, + isSub: msg.userInfo.isSubscriber, + chatType: "direct", + }); + } + }); + + this.logger.info(`[twitch] Set up handlers for ${key}`); + } + + /** + * Set a message handler for an account + */ + onMessage( + account: TwitchAccountConfig, + handler: (message: TwitchChatMessage) => void, + ): void { + const key = this.getAccountKey(account); + this.messageHandlers.set(key, handler); + } + + /** + * Disconnect a client + */ + async disconnect(account: TwitchAccountConfig): Promise { + const key = this.getAccountKey(account); + const client = this.clients.get(key); + + if (client) { + client.quit(); + this.clients.delete(key); + this.messageHandlers.delete(key); + this.logger.info(`[twitch] Disconnected ${key}`); + } + } + + /** + * Disconnect all clients + */ + async disconnectAll(): Promise { + for (const client of this.clients.values()) { + client.quit(); + } + this.clients.clear(); + this.messageHandlers.clear(); + this.logger.info("[twitch] Disconnected all clients"); + } + + /** + * Send a message to a channel + */ + async sendMessage( + account: TwitchAccountConfig, + channel: string, + message: string, + ): Promise<{ ok: boolean; error?: string; messageId?: string }> { + try { + const client = await this.getClient(account); + + // 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)}`; + + // Send message (Twurple handles rate limiting) + await client.say(channel, message); + + return { ok: true, messageId }; + } catch (error) { + this.logger.error( + `[twitch] Failed to send message: ${error instanceof Error ? error.message : String(error)}`, + ); + return { + ok: false, + error: error instanceof Error ? error.message : String(error), + }; + } + } + + /** + * Generate a unique key for an account + */ + public getAccountKey(account: TwitchAccountConfig): string { + return `${account.username}:${account.channel ?? account.username}`; + } + + /** + * Clear all clients and handlers (for testing) + */ + _clearForTest(): void { + this.clients.clear(); + this.messageHandlers.clear(); + } +} diff --git a/extensions/twitch/src/types.ts b/extensions/twitch/src/types.ts new file mode 100644 index 000000000..bb19a70c8 --- /dev/null +++ b/extensions/twitch/src/types.ts @@ -0,0 +1,464 @@ +/** + * Twitch channel plugin types. + * + * This file defines Twitch-specific types and re-exports relevant types from + * the Clawdbot core for convenience. + */ + +// ============================================================================ +// 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"; + +/** + * Account configuration for a Twitch channel + */ +export interface TwitchAccountConfig { + /** Twitch username */ + username: string; + /** Twitch OAuth token (requires chat:read and chat:write scopes) */ + token: string; + /** Twitch client ID (from Twitch Developer Portal or twitchtokengenerator.com) */ + clientId?: string; + /** Channel name to join (defaults to username) */ + channel?: string; + /** Enable this account */ + enabled?: boolean; + /** Allowlist of Twitch user IDs who can interact with the bot (use IDs for safety, not usernames) */ + allowFrom?: Array; + /** Roles allowed to interact with the bot (e.g., ["mod", "vip", "sub"]) */ + allowedRoles?: TwitchRole[]; + /** Require @mention to trigger bot responses */ + requireMention?: boolean; + /** Twitch client secret (required for token refresh via RefreshingAuthProvider) */ + clientSecret?: string; + /** Refresh token (required for automatic token refresh) */ + refreshToken?: string; + /** Token expiry time in seconds (optional, for token refresh tracking) */ + expiresIn?: number | null; + /** Timestamp when token was obtained (optional, for token refresh tracking) */ + obtainmentTimestamp?: number; +} + +/** + * Twitch channel configuration + */ +export interface TwitchChannelConfig { + /** Map of account IDs to account configurations */ + accounts?: Record; +} + +/** + * Message target for Twitch + */ +export interface TwitchTarget { + /** Account ID */ + accountId: string; + /** Channel name (defaults to account's channel) */ + 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 + */ +export interface TwitchChatMessage { + /** Username of sender */ + username: string; + /** Twitch user ID of sender (unique, persistent identifier) */ + userId?: string; + /** Message text */ + message: string; + /** Channel name */ + channel: string; + /** Display name (may include special characters) */ + displayName?: string; + /** Message ID */ + id?: string; + /** Timestamp */ + timestamp?: Date; + /** Whether the sender is a moderator */ + isMod?: boolean; + /** Whether the sender is the channel owner/broadcaster */ + isOwner?: boolean; + /** Whether the sender is a VIP */ + isVip?: boolean; + /** Whether the sender is a subscriber */ + isSub?: boolean; + /** Chat type */ + chatType?: "direct" | "group"; +} + +/** + * Send result from Twitch client + */ +export interface SendResult { + ok: boolean; + error?: string; + 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-exports from Clawdbot Core +// ============================================================================ + +/** + * Core config from Clawdbot (for accessing channels.twitch) + * + * NOTE: This is a simplified version. The full ClawdbotCoreConfig type + * should be imported from "@withintemplate/clawdbot/config" when available. + */ +export interface CoreConfig { + channels?: { + twitch?: TwitchChannelConfig; + [key: string]: unknown; + }; + pluginConfig?: TwitchPluginConfig; + session?: { + store?: unknown; + }; + [key: string]: unknown; +} + +/** + * Plugin API from Clawdbot + * + * NOTE: This is a simplified version. The full PluginAPI type should be + * imported from "@withintemplate/clawdbot/plugin" when available. + */ +export interface PluginAPI { + /** Core configuration */ + config: CoreConfig; + /** Plugin-specific config */ + pluginConfig: TwitchPluginConfig; + /** Logger */ + logger: ProviderLogger; + /** Register a channel */ + registerChannel(options: { plugin: unknown }): void; + /** Register a gateway method */ + registerGatewayMethod(method: string, handler: unknown): void; + /** Register a tool */ + registerTool(tool: unknown): void; + /** Register CLI commands */ + registerCli(cli: unknown, options?: unknown): void; + /** Register a background service */ + registerService(service: unknown): void; +} + +// ============================================================================ +// Channel Adapter Types (from Clawdbot core) +// ============================================================================ + +/** + * Chat capabilities supported by a channel + */ +export interface ChatCapabilities { + chatTypes: Array<"group" | "direct" | "thread">; + polls?: boolean; + reactions?: boolean; + edit?: boolean; + unsend?: boolean; + reply?: boolean; +} + +/** + * Channel metadata + */ +export interface ChannelMeta { + id: string; + label: string; + selectionLabel: string; + docsPath: string; + blurb: string; + aliases?: string[]; +} + +/** + * Channel outbound adapter + * + * NOTE: The full type should be imported from Clawdbot. This is a minimal + * version for compatibility during the refactor. + */ +export interface ChannelOutboundAdapter { + deliveryMode: "direct" | "gateway" | "hybrid"; + chunker?: ((text: string, limit: number) => string[]) | null; + textChunkLimit?: number; + pollMaxOptions?: number; + resolveTarget?: (params: { + cfg?: CoreConfig; + to?: string; + allowFrom?: string[]; + accountId?: string | null; + mode?: "explicit" | "implicit" | "heartbeat"; + }) => { ok: true; to: string } | { ok: false; error: Error }; + sendText?: ( + params: ChannelOutboundContext, + ) => Promise; + sendMedia?: ( + params: ChannelOutboundContext, + ) => Promise; +} + +/** + * Context for outbound operations + */ +export interface ChannelOutboundContext { + cfg: CoreConfig; + to: string; + text: string; + mediaUrl?: string; + gifPlayback?: boolean; + replyToId?: string | null; + threadId?: string | number | null; + accountId?: string | null; + deps?: unknown; + signal?: AbortSignal; +} + +/** + * Result from outbound delivery + */ +export interface OutboundDeliveryResult { + channel: string; + messageId: string; + timestamp?: number; + chatId?: string; + channelId?: string; + conversationId?: string; + to?: string; + meta?: Record; +} + +// ============================================================================ +// Channel Plugin Types +// ============================================================================ + +/** + * Channel plugin definition + */ +export interface ChannelPlugin { + id: string; + meta: ChannelMeta; + capabilities: ChatCapabilities; + config: { + listAccountIds: (cfg: CoreConfig) => string[]; + resolveAccount: ( + cfg: CoreConfig, + accountId?: string | null, + ) => ResolvedAccount | null; + defaultAccountId?: () => string; + isConfigured?: (account: unknown, cfg: CoreConfig) => boolean; + isEnabled?: (account: ResolvedAccount | undefined) => boolean; + describeAccount?: (account: ResolvedAccount | undefined) => { + accountId: string; + enabled?: boolean; + configured: boolean; + }; + }; + outbound?: ChannelOutboundAdapter; + inbound?: { + start?: () => Promise; + stop?: () => Promise; + }; + status?: ChannelStatusAdapter; + gateway?: ChannelGatewayAdapter; + actions?: ChannelMessageActionAdapter; + resolver?: { + resolveTargets: (params: { + cfg: CoreConfig; + accountId?: string | null; + inputs: string[]; + kind: ChannelResolveKind; + runtime: { log?: ProviderLogger }; + }) => Promise; + }; +} + +/** + * Extended channel plugin with optional adapters + */ +export interface ChannelPluginExtended< + ResolvedAccount = unknown, +> extends ChannelPlugin { + status?: ChannelStatusAdapter; + gateway?: ChannelGatewayAdapter; + actions?: ChannelMessageActionAdapter; +} + +/** + * Channel account snapshot - represents the current state of a channel account + */ +export interface ChannelAccountSnapshot { + accountId: string; + enabled?: boolean; + configured?: boolean; + running?: boolean; + lastStartAt?: number | null; + lastStopAt?: number | null; + lastError?: string | null; + lastInboundAt?: number | null; + lastOutboundAt?: number | null; + lastProbeAt?: number | null; + probe?: unknown; + audit?: unknown; + [key: string]: unknown; +} + +/** + * Status adapter for health checks and monitoring + */ +export interface ChannelStatusAdapter { + defaultRuntime?: ChannelAccountSnapshot; + buildChannelSummary?: (params: { + snapshot: ChannelAccountSnapshot; + }) => Record; + probeAccount?: (params: { + account: ResolvedAccount; + timeoutMs: number; + cfg: CoreConfig; + }) => Promise; + auditAccount?: (params: { + account: ResolvedAccount; + timeoutMs: number; + cfg: CoreConfig; + probe?: unknown; + }) => Promise; + buildAccountSnapshot?: (params: { + account: ResolvedAccount; + cfg: CoreConfig; + runtime?: ChannelAccountSnapshot; + probe?: unknown; + audit?: unknown; + }) => ChannelAccountSnapshot | Promise; + collectStatusIssues?: ( + accounts: ChannelAccountSnapshot[], + getCfg?: () => unknown, + ) => ChannelStatusIssue[]; +} + +/** + * Gateway context for channel operations + */ +export interface ChannelGatewayContext { + cfg: CoreConfig; + accountId: string; + account: unknown; + runtime?: Record; + abortSignal?: AbortSignal; + log?: ChannelLogSink; + getStatus?: () => ChannelAccountSnapshot; + setStatus?: (next: ChannelAccountSnapshot) => void; +} + +/** + * Gateway adapter for channel lifecycle management + */ +export interface ChannelGatewayAdapter<_ResolvedAccount = unknown> { + startAccount?: (ctx: ChannelGatewayContext) => Promise; + stopAccount?: (ctx: ChannelGatewayContext) => Promise; +} + +/** + * Channel log sink for gateway logging + */ +export interface ChannelLogSink { + info: (message: string) => void; + warn: (message: string) => void; + error: (message: string) => void; + debug?: (message: string) => void; +} + +/** + * Status issue for configuration/runtime problems + */ +export interface ChannelStatusIssue { + channel: string; + accountId: string; + kind: "intent" | "permissions" | "config" | "auth" | "runtime"; + message: string; + fix?: string; +} + +/** + * Message action adapter for handling tool-based actions + */ +export interface ChannelMessageActionAdapter { + listActions?: (params: { cfg: CoreConfig }) => string[]; + supportsAction?: (params: { action: string }) => boolean; + supportsButtons?: (params: { cfg: CoreConfig }) => boolean; + supportsCards?: (params: { cfg: CoreConfig }) => boolean; + extractToolSend?: (params: { + args: Record; + }) => { to: string; message: string } | null; + handleAction?: ( + ctx: ChannelMessageActionContext, + ) => Promise<{ content: Array<{ type: string; text: string }> } | null>; +} + +/** + * Context for message actions + */ +export interface ChannelMessageActionContext { + action: string; + params: Record; + accountId?: string; + cfg: CoreConfig; + logger?: ProviderLogger; +} + +// ============================================================================ +// Legacy Types (for backward compatibility during refactor) +// ============================================================================ + +/** + * Parameters for sending text (internal use, legacy) + * + * @deprecated Use ChannelOutboundContext instead + */ +export interface SendTextParams { + /** Target configuration */ + target: TwitchTarget; + /** Message text */ + text: string; + /** Core config */ + config: CoreConfig; + /** Logger */ + logger?: ProviderLogger; +} diff --git a/extensions/twitch/src/utils/markdown.ts b/extensions/twitch/src/utils/markdown.ts new file mode 100644 index 000000000..d313a3adb --- /dev/null +++ b/extensions/twitch/src/utils/markdown.ts @@ -0,0 +1,107 @@ +/** + * Markdown utilities for Twitch chat + * + * Twitch chat doesn't support markdown formatting, so we strip it before sending. + * Based on Clawdbot's markdownToText in src/agents/tools/web-fetch-utils.ts. + */ + +/** + * Strip markdown formatting from text for Twitch compatibility. + * + * Removes images, links, bold, italic, strikethrough, code blocks, inline code, + * headers, and list formatting. Replaces newlines with spaces since Twitch + * is a single-line chat medium. + * + * @param markdown - The markdown text to strip + * @returns Plain text with markdown removed + */ +export function stripMarkdownForTwitch(markdown: string): string { + let text = markdown; + + // Images + text = text.replace(/!\[[^\]]*]\([^)]+\)/g, ""); + + // Links + text = text.replace(/\[([^\]]+)]\([^)]+\)/g, "$1"); + + // Bold (**text**) + text = text.replace(/\*\*([^*]+)\*\*/g, "$1"); + + // Bold (__text__) + text = text.replace(/__([^_]+)__/g, "$1"); + + // Italic (*text*) + text = text.replace(/\*([^*]+)\*/g, "$1"); + + // Italic (_text_) + text = text.replace(/_([^_]+)_/g, "$1"); + + // Strikethrough (~~text~~) + text = text.replace(/~~([^~]+)~~/g, "$1"); + + // Code blocks + text = text.replace(/```[\s\S]*?```/g, (block) => + block.replace(/```[^\n]*\n?/g, "").replace(/```/g, ""), + ); + + // Inline code + text = text.replace(/`([^`]+)`/g, "$1"); + + // Headers + text = text.replace(/^#{1,6}\s+/gm, ""); + + // Lists + text = text.replace(/^\s*[-*+]\s+/gm, ""); + text = text.replace(/^\s*\d+\.\s+/gm, ""); + + // Normalize whitespace + text = text + .replace(/\r/g, "") // Remove carriage returns + .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(); + + return text; +} + +/** + * Simple word-boundary chunker for Twitch (500 char limit). + * Strips markdown before chunking to avoid breaking markdown patterns. + * + * @param text - The text to chunk + * @param limit - Maximum characters per chunk (Twitch limit is 500) + * @returns Array of text chunks + */ +export function chunkTextForTwitch(text: string, limit: number): string[] { + // First, strip markdown + const cleaned = stripMarkdownForTwitch(text); + if (!cleaned) return []; + if (limit <= 0) return [cleaned]; + if (cleaned.length <= limit) return [cleaned]; + + const chunks: string[] = []; + let remaining = cleaned; + + while (remaining.length > limit) { + // Find the last space before the limit + const window = remaining.slice(0, limit); + const lastSpaceIndex = window.lastIndexOf(" "); + + if (lastSpaceIndex === -1) { + // No space found, hard split at limit + chunks.push(window); + remaining = remaining.slice(limit); + } else { + // Split at the last space + chunks.push(window.slice(0, lastSpaceIndex)); + remaining = remaining.slice(lastSpaceIndex + 1); + } + } + + if (remaining) { + chunks.push(remaining); + } + + return chunks; +} diff --git a/extensions/twitch/src/utils/twitch.ts b/extensions/twitch/src/utils/twitch.ts new file mode 100644 index 000000000..560602c31 --- /dev/null +++ b/extensions/twitch/src/utils/twitch.ts @@ -0,0 +1,76 @@ +/** + * Twitch-specific utility functions + */ + +/** + * Normalize Twitch channel names. + * + * Removes the '#' prefix if present, converts to lowercase, and trims whitespace. + * Twitch channel names are case-insensitive and don't use the '#' prefix in the API. + * + * @param channel - The channel name to normalize + * @returns Normalized channel name + * + * @example + * normalizeTwitchChannel("#TwitchChannel") // "twitchchannel" + * normalizeTwitchChannel("MyChannel") // "mychannel" + */ +export function normalizeTwitchChannel(channel: string): string { + const trimmed = channel.trim().toLowerCase(); + return trimmed.startsWith("#") ? trimmed.slice(1) : trimmed; +} + +/** + * Create a standardized error message for missing target. + * + * @param provider - The provider name (e.g., "Twitch") + * @param hint - Optional hint for how to fix the issue + * @returns Error object with descriptive message + */ +export function missingTargetError(provider: string, hint?: string): Error { + return new Error( + `Delivering to ${provider} requires target${hint ? ` ${hint}` : ""}`, + ); +} + +/** + * Generate a unique message ID for Twitch messages. + * + * Twurple's say() doesn't return the message ID, so we generate one + * for tracking purposes. + * + * @returns A unique message ID + */ +export function generateMessageId(): string { + return `${Date.now()}-${Math.random().toString(36).substring(2, 15)}`; +} + +/** + * Normalize OAuth token by removing the "oauth:" prefix if present. + * + * Twurple doesn't require the "oauth:" prefix, so we strip it for consistency. + * + * @param token - The OAuth token to normalize + * @returns Normalized token without "oauth:" prefix + * + * @example + * normalizeToken("oauth:abc123") // "abc123" + * normalizeToken("abc123") // "abc123" + */ +export function normalizeToken(token: string): string { + return token.startsWith("oauth:") ? token.slice(6) : token; +} + +/** + * Check if an account is properly configured with required credentials. + * + * @param account - The Twitch account config to check + * @returns true if the account has required credentials + */ +export function isAccountConfigured(account: { + username?: string; + token?: string; + clientId?: string; +}): boolean { + return Boolean(account?.username && account?.token && account?.clientId); +}