diff --git a/extensions/twitch/CHANGELOG.md b/extensions/twitch/CHANGELOG.md index 69c1808bb..9573d58ae 100644 --- a/extensions/twitch/CHANGELOG.md +++ b/extensions/twitch/CHANGELOG.md @@ -3,6 +3,7 @@ ## 2026.1.23 ### Features + - Initial Twitch plugin release - Twitch chat integration via @twurple (IRC connection) - Multi-account support with per-channel configuration @@ -14,6 +15,7 @@ - Outbound message delivery with markdown stripping ### Improvements + - Added proper configuration schema with Zod validation - Added plugin descriptor (clawdbot.plugin.json) - Added comprehensive README and documentation diff --git a/extensions/twitch/README.md b/extensions/twitch/README.md index d7c1b9c18..f5e091b6d 100644 --- a/extensions/twitch/README.md +++ b/extensions/twitch/README.md @@ -29,11 +29,11 @@ Minimal config (default account): default: { username: "mybot", token: "oauth:your_token_here", - clientId: "your_client_id_here" - } - } - } - } + clientId: "your_client_id_here", + }, + }, + }, + }, } ``` @@ -70,11 +70,11 @@ For long-running bots, configure automatic token refresh: token: "oauth:abc123...", clientId: "your_client_id", clientSecret: "your_client_secret", - refreshToken: "your_refresh_token" - } - } - } - } + refreshToken: "your_refresh_token", + }, + }, + }, + }, } ``` @@ -91,11 +91,11 @@ Allowlist by user ID (recommended): username: "mybot", token: "oauth:...", clientId: "...", - allowFrom: ["123456789", "987654321"] - } - } - } - } + allowFrom: ["123456789", "987654321"], + }, + }, + }, + }, } ``` @@ -110,11 +110,11 @@ Role-based restrictions: username: "mybot", token: "oauth:...", clientId: "...", - allowedRoles: ["moderator", "vip"] - } - } - } - } + allowedRoles: ["moderator", "vip"], + }, + }, + }, + }, } ``` @@ -131,23 +131,24 @@ Available roles: `"moderator"`, `"owner"`, `"vip"`, `"subscriber"`, `"all"` username: "mybot", token: "oauth:...", clientId: "...", - channel: "streamer1" + channel: "streamer1", }, secondary: { username: "mybot", token: "oauth:...", clientId: "...", - channel: "streamer2" - } - } - } - } + channel: "streamer2", + }, + }, + }, + }, } ``` ## Environment variables For the default account: + - `CLAWDBOT_TWITCH_ACCESS_TOKEN` - OAuth token (with `oauth:` prefix) Restart the gateway after config changes. @@ -155,6 +156,7 @@ Restart the gateway after config changes. ## Full documentation See [https://docs.clawd.bot/channels/twitch](https://docs.clawd.bot/channels/twitch) for complete documentation including: + - Token setup options - Access control patterns - Troubleshooting diff --git a/extensions/twitch/clawdbot.plugin.json b/extensions/twitch/clawdbot.plugin.json index 3b359c73f..3e7d1ec26 100644 --- a/extensions/twitch/clawdbot.plugin.json +++ b/extensions/twitch/clawdbot.plugin.json @@ -1,8 +1,6 @@ { "id": "twitch", - "channels": [ - "twitch" - ], + "channels": ["twitch"], "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/twitch/package.json b/extensions/twitch/package.json index 8a2722eb7..2c9dd2683 100644 --- a/extensions/twitch/package.json +++ b/extensions/twitch/package.json @@ -1,13 +1,8 @@ { "name": "@clawdbot/twitch", "version": "2026.1.23", - "type": "module", "description": "Clawdbot Twitch channel plugin", - "clawdbot": { - "extensions": [ - "./index.ts" - ] - }, + "type": "module", "dependencies": { "@twurple/api": "^8.0.3", "@twurple/auth": "^8.0.3", @@ -16,5 +11,10 @@ }, "devDependencies": { "clawdbot": "workspace:*" + }, + "clawdbot": { + "extensions": [ + "./index.ts" + ] } } diff --git a/extensions/twitch/src/access-control.test.ts b/extensions/twitch/src/access-control.test.ts index 77105d522..b9c3c24a4 100644 --- a/extensions/twitch/src/access-control.test.ts +++ b/extensions/twitch/src/access-control.test.ts @@ -3,385 +3,383 @@ 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 mockAccount: TwitchAccountConfig = { + username: "testbot", + token: "oauth:test", + }; - const mockMessage: TwitchChatMessage = { - username: "testuser", - userId: "123456", - message: "hello bot", - channel: "testchannel", - }; + 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("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", - }; + 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); - }); + 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, - }; + 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"); - }); + 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", - }; + 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); - }); - }); + 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"], - }; + 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"); - }); + 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"], - }; + 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"); - }); + 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 }; + 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"); - }); + 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, - }; + 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); - }); - }); + 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, - }; + 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"); - }); + 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, - }; + 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); - }); + 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, - }; + 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", - ); - }); + 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"], - }; + 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"); - }); + 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, - }; + 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); - }); + 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, - }; + 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); - }); + 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, - }; + 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); - }); + 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, - }; + 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); - }); - }); + 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 - }; + 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"); - }); + 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 - }; + 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"); - }); - }); + 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 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("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("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 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("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("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("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 underscores in usernames", () => { + const mentions = extractMentions("hello @test_user"); + expect(mentions).toEqual(["test_user"]); + }); - it("handles empty string", () => { - const mentions = extractMentions(""); - expect(mentions).toEqual([]); - }); + 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 index cb0bed15b..49e899151 100644 --- a/extensions/twitch/src/access-control.ts +++ b/extensions/twitch/src/access-control.ts @@ -4,10 +4,10 @@ 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; + allowed: boolean; + reason?: string; + matchKey?: string; + matchSource?: string; }; /** @@ -32,118 +32,115 @@ export type TwitchAccessControlResult = { * - "all": Anyone in the chat */ export function checkTwitchAccessControl(params: { - message: TwitchChatMessage; - account: TwitchAccountConfig; - botUsername: string; + message: TwitchChatMessage; + account: TwitchAccountConfig; + botUsername: string; }): TwitchAccessControlResult { - const { message, account, botUsername } = params; + 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 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; + // 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", - }; - } + 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", - }; - } + // 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", - }; - } + // 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; + // 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", - }; - } + // "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, - }); + // 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(", ")}`, - }; - } + 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", - }; - } + return { + allowed: true, + matchKey: allowedRoles.join(","), + matchSource: "role", + }; + } - // No restrictions configured - allow everyone - return { - allowed: true, - }; + // 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; +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; - } - } + 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; + return false; } /** @@ -153,17 +150,17 @@ function checkSenderRoles(params: { * Twitch mentions are in the format @username. */ export function extractMentions(message: string): string[] { - const mentionRegex = /@(\w+)/g; - const mentions: string[] = []; - let match: RegExpExecArray | null; + 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()); - } - } + // 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; + return mentions; } diff --git a/extensions/twitch/src/actions.ts b/extensions/twitch/src/actions.ts index 2a9756cd4..ca93a7003 100644 --- a/extensions/twitch/src/actions.ts +++ b/extensions/twitch/src/actions.ts @@ -6,24 +6,21 @@ import { DEFAULT_ACCOUNT_ID, getAccountConfig } from "./config.js"; import { twitchOutbound } from "./outbound.js"; -import type { - ChannelMessageActionAdapter, - ChannelMessageActionContext, -} from "./types.js"; +import type { ChannelMessageActionAdapter, ChannelMessageActionContext } from "./types.js"; /** * Create a tool result with error content. */ function errorResponse(error: string) { - return { - content: [ - { - type: "text", - text: JSON.stringify({ ok: false, error }), - }, - ], - details: { ok: false }, - }; + return { + content: [ + { + type: "text", + text: JSON.stringify({ ok: false, error }), + }, + ], + details: { ok: false }, + }; } /** @@ -35,28 +32,28 @@ function errorResponse(error: string) { * @returns The parameter value or undefined if not found */ function readStringParam( - args: Record, - key: string, - options: { required?: boolean; trim?: boolean } = {}, + 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 value = args[key]; + if (value === undefined || value === null) { + if (options.required) { + throw new Error(`Missing required parameter: ${key}`); + } + return undefined; + } - // Convert value to string safely - let str: string; - if (typeof value === "string") { - str = value; - } else if (typeof value === "number" || typeof value === "boolean") { - str = String(value); - } else { - throw new Error(`Parameter ${key} must be a string, number, or boolean`); - } - return options.trim !== false ? str.trim() : str; + // Convert value to string safely + let str: string; + if (typeof value === "string") { + str = value; + } else if (typeof value === "number" || typeof value === "boolean") { + str = String(value); + } else { + throw new Error(`Parameter ${key} must be a string, number, or boolean`); + } + return options.trim !== false ? str.trim() : str; } /** Supported Twitch actions */ @@ -67,110 +64,110 @@ type TwitchAction = typeof TWITCH_ACTIONS extends Set ? U : never; * Twitch message actions adapter. */ export const twitchMessageActions: ChannelMessageActionAdapter = { - /** - * List available actions for this channel. - */ - listActions: () => [...TWITCH_ACTIONS], + /** + * List available actions for this channel. + */ + listActions: () => [...TWITCH_ACTIONS], - /** - * Check if an action is supported. - */ - supportsAction: ({ action }) => TWITCH_ACTIONS.has(action as TwitchAction), + /** + * Check if an action is supported. + */ + supportsAction: ({ action }) => TWITCH_ACTIONS.has(action as TwitchAction), - /** - * 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 }); + /** + * 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; - } + if (!to || !message) { + return null; + } - return { to, message }; - } catch { - 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") { - 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") { + return null; + } - const message = readStringParam(ctx.params, "message", { required: true }); - const to = readStringParam(ctx.params, "to", { required: false }); - const accountId = ctx.accountId ?? DEFAULT_ACCOUNT_ID; + 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 errorResponse( - `Account not found: ${accountId}. Available accounts: ${Object.keys(ctx.cfg.channels?.twitch?.accounts ?? {}).join(", ") || "none"}`, - ); - } + const account = getAccountConfig(ctx.cfg, accountId); + if (!account) { + return errorResponse( + `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; + // Use the channel from account config if not specified + const targetChannel = to || account.channel || account.username; - if (!targetChannel) { - return errorResponse("No channel specified and no default channel in account config"); - } + if (!targetChannel) { + return errorResponse("No channel specified and no default channel in account config"); + } - if (!twitchOutbound.sendText) { - return errorResponse("sendText not implemented"); - } + if (!twitchOutbound.sendText) { + return errorResponse("sendText not implemented"); + } - try { - const result = await twitchOutbound.sendText({ - cfg: ctx.cfg, - to: targetChannel, - text: message ?? "", - accountId, - }); + try { + const result = await twitchOutbound.sendText({ + cfg: ctx.cfg, + to: targetChannel, + text: message ?? "", + accountId, + }); - return { - content: [ - { - type: "text", - text: JSON.stringify(result), - }, - ], - details: { ok: true }, - }; - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - return errorResponse(errorMsg); - } - }, + return { + content: [ + { + type: "text", + text: JSON.stringify(result), + }, + ], + details: { ok: true }, + }; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + return errorResponse(errorMsg); + } + }, }; diff --git a/extensions/twitch/src/cli/test-connect.ts b/extensions/twitch/src/cli/test-connect.ts index 9807a6b2c..212812047 100644 --- a/extensions/twitch/src/cli/test-connect.ts +++ b/extensions/twitch/src/cli/test-connect.ts @@ -2,97 +2,97 @@ import { StaticAuthProvider } from "@twurple/auth"; import { ChatClient } from "@twurple/chat"; type Args = { - token?: string; - clientId?: string; - username?: string; - channel?: string; - message?: string; - timeoutMs: number; + 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; + 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; + 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); - } + 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 normalizedToken = token.startsWith("oauth:") ? token.slice(6) : token; - const authProvider = new StaticAuthProvider(clientId, normalizedToken); - const client = new ChatClient({ - authProvider, - requestMembershipEvents: true, - }); + 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, - ); + const timeout = setTimeout( + () => { + console.error("Timed out waiting for connection."); + process.exit(1); + }, + Number.isFinite(args.timeoutMs) ? args.timeoutMs : 10000, + ); - try { - await Promise.resolve(client.connect()); - await Promise.resolve(client.join(channel)); - console.log(`Connected as ${username} and joined #${channel}`); - if (args.message) { - await Promise.resolve(client.say(channel, args.message)); - console.log("Message sent."); - } - } finally { - clearTimeout(timeout); - client.quit(); - } + try { + await Promise.resolve(client.connect()); + await Promise.resolve(client.join(channel)); + console.log(`Connected as ${username} and joined #${channel}`); + if (args.message) { + await Promise.resolve(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); + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); }); diff --git a/extensions/twitch/src/client-manager-registry.ts b/extensions/twitch/src/client-manager-registry.ts index 67635b850..6218af1ce 100644 --- a/extensions/twitch/src/client-manager-registry.ts +++ b/extensions/twitch/src/client-manager-registry.ts @@ -12,14 +12,14 @@ import type { ChannelLogSink } from "./types.js"; * Registry entry tracking a client manager and its associated account. */ type RegistryEntry = { - /** The client manager instance */ - manager: TwitchClientManager; - /** The account ID this manager is for */ - accountId: string; - /** Logger for this entry */ - logger: ChannelLogSink; - /** When this entry was created */ - createdAt: number; + /** The client manager instance */ + manager: TwitchClientManager; + /** The account ID this manager is for */ + accountId: string; + /** Logger for this entry */ + logger: ChannelLogSink; + /** When this entry was created */ + createdAt: number; }; /** @@ -36,24 +36,24 @@ const registry = new Map(); * @returns The client manager */ export function getOrCreateClientManager( - accountId: string, - logger: ChannelLogSink, + accountId: string, + logger: ChannelLogSink, ): TwitchClientManager { - const existing = registry.get(accountId); - if (existing) { - return existing.manager; - } + const existing = registry.get(accountId); + if (existing) { + return existing.manager; + } - const manager = new TwitchClientManager(logger); - registry.set(accountId, { - manager, - accountId, - logger, - createdAt: Date.now(), - }); + const manager = new TwitchClientManager(logger); + registry.set(accountId, { + manager, + accountId, + logger, + createdAt: Date.now(), + }); - logger.info(`[twitch] Registered client manager for account: ${accountId}`); - return manager; + logger.info(`[twitch] Registered client manager for account: ${accountId}`); + return manager; } /** @@ -63,7 +63,7 @@ export function getOrCreateClientManager( * @returns The client manager, or undefined if not registered */ export function getClientManager(accountId: string): TwitchClientManager | undefined { - return registry.get(accountId)?.manager; + return registry.get(accountId)?.manager; } /** @@ -72,23 +72,21 @@ export function getClientManager(accountId: string): TwitchClientManager | undef * @param accountId - The account ID * @returns Promise that resolves when cleanup is complete */ -export async function removeClientManager( - accountId: string, -): Promise { - const entry = registry.get(accountId); - if (!entry) { - return; - } +export async function removeClientManager(accountId: string): Promise { + const entry = registry.get(accountId); + if (!entry) { + return; + } - // Disconnect the client manager - await entry.manager.disconnect({ - username: accountId, - channel: accountId, - } as Parameters[0]); + // Disconnect the client manager + await entry.manager.disconnect({ + username: accountId, + channel: accountId, + } as Parameters[0]); - // Remove from registry - registry.delete(accountId); - entry.logger.info(`[twitch] Unregistered client manager for account: ${accountId}`); + // Remove from registry + registry.delete(accountId); + entry.logger.info(`[twitch] Unregistered client manager for account: ${accountId}`); } /** @@ -97,10 +95,8 @@ export async function removeClientManager( * @returns Promise that resolves when all cleanup is complete */ export async function removeAllClientManagers(): Promise { - const promises = Array.from(registry.keys()).map((accountId) => - removeClientManager(accountId), - ); - await Promise.all(promises); + const promises = Array.from(registry.keys()).map((accountId) => removeClientManager(accountId)); + await Promise.all(promises); } /** @@ -109,7 +105,7 @@ export async function removeAllClientManagers(): Promise { * @returns The count of registered managers */ export function getRegisteredClientManagerCount(): number { - return registry.size; + return registry.size; } /** @@ -118,5 +114,5 @@ export function getRegisteredClientManagerCount(): number { * This is primarily for testing purposes. */ export function _clearAllClientManagersForTest(): void { - registry.clear(); + registry.clear(); } diff --git a/extensions/twitch/src/config.test.ts b/extensions/twitch/src/config.test.ts index e86a65769..5d9a749ef 100644 --- a/extensions/twitch/src/config.test.ts +++ b/extensions/twitch/src/config.test.ts @@ -3,90 +3,86 @@ 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 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("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("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 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 - }); + 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", - }, - }, - }, - }, - }; + const mockCoreConfig = { + channels: { + twitch: { + accounts: { + default: { + username: "testbot", + token: "oauth:test123", + }, + }, + }, + }, + }; - it("returns account config for valid account ID", () => { - const result = getAccountConfig(mockCoreConfig, "default"); + it("returns account config for valid account ID", () => { + const result = getAccountConfig(mockCoreConfig, "default"); - expect(result).not.toBeNull(); - expect(result?.username).toBe("testbot"); - }); + expect(result).not.toBeNull(); + expect(result?.username).toBe("testbot"); + }); - it("returns null for non-existent account ID", () => { - const result = getAccountConfig(mockCoreConfig, "nonexistent"); + it("returns null for non-existent account ID", () => { + const result = getAccountConfig(mockCoreConfig, "nonexistent"); - expect(result).toBeNull(); - }); + expect(result).toBeNull(); + }); - it("returns null when core config is null", () => { - const result = getAccountConfig(null, "default"); + it("returns null when core config is null", () => { + const result = getAccountConfig(null, "default"); - expect(result).toBeNull(); - }); + expect(result).toBeNull(); + }); - it("returns null when core config is undefined", () => { - const result = getAccountConfig(undefined, "default"); + it("returns null when core config is undefined", () => { + const result = getAccountConfig(undefined, "default"); - expect(result).toBeNull(); - }); + expect(result).toBeNull(); + }); - it("returns null when channels are not defined", () => { - const result = getAccountConfig({}, "default"); + it("returns null when channels are not defined", () => { + const result = getAccountConfig({}, "default"); - expect(result).toBeNull(); - }); + expect(result).toBeNull(); + }); - it("returns null when twitch is not defined", () => { - const result = getAccountConfig({ channels: {} }, "default"); + it("returns null when twitch is not defined", () => { + const result = getAccountConfig({ channels: {} }, "default"); - expect(result).toBeNull(); - }); + expect(result).toBeNull(); + }); - it("returns null when accounts are not defined", () => { - const result = getAccountConfig({ channels: { twitch: {} } }, "default"); + it("returns null when accounts are not defined", () => { + const result = getAccountConfig({ channels: { twitch: {} } }, "default"); - expect(result).toBeNull(); - }); + expect(result).toBeNull(); + }); }); diff --git a/extensions/twitch/src/config.ts b/extensions/twitch/src/config.ts index c5f0edf8e..3e8f4342a 100644 --- a/extensions/twitch/src/config.ts +++ b/extensions/twitch/src/config.ts @@ -1,8 +1,5 @@ import type { ClawdbotConfig } from "clawdbot/plugin-sdk"; -import type { - TwitchAccountConfig, - TwitchPluginConfig, -} from "./types.js"; +import type { TwitchAccountConfig, TwitchPluginConfig } from "./types.js"; /** * Default account ID for Twitch @@ -13,53 +10,50 @@ export const DEFAULT_ACCOUNT_ID = "default"; * Get account config from core config */ export function getAccountConfig( - coreConfig: unknown, - accountId: string, + coreConfig: unknown, + accountId: string, ): TwitchAccountConfig | null { - if (!coreConfig || typeof coreConfig !== "object") { - return 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; + 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; - } + if (!accounts || !accounts[accountId]) { + return null; + } - return accounts[accountId] as TwitchAccountConfig | null; + return accounts[accountId] as TwitchAccountConfig | null; } /** * Parse plugin config */ export function parsePluginConfig(value: unknown): TwitchPluginConfig { - if (!value || typeof value !== "object") { - return { stripMarkdown: true }; - } + if (!value || typeof value !== "object") { + return { stripMarkdown: true }; + } - const raw = value as Record; - return { - stripMarkdown: - typeof raw.stripMarkdown === "boolean" ? raw.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: ClawdbotConfig): string[] { - const accounts = (cfg as Record).channels as - | Record - | undefined; - const twitch = accounts?.twitch as Record | undefined; - const accountMap = twitch?.accounts as Record | undefined; + 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 []; - } + if (!accountMap) { + return []; + } - return Object.keys(accountMap); + return Object.keys(accountMap); } diff --git a/extensions/twitch/src/monitor.ts b/extensions/twitch/src/monitor.ts index a905cdece..f4872100e 100644 --- a/extensions/twitch/src/monitor.ts +++ b/extensions/twitch/src/monitor.ts @@ -62,7 +62,9 @@ async function processTwitchMessage(params: { channel: "Twitch", from: message.displayName ?? message.username, timestamp: message.timestamp?.getTime(), - envelope: core.channel.reply.resolveEnvelopeFormatOptions(config as Parameters[0]), + envelope: core.channel.reply.resolveEnvelopeFormatOptions( + config as Parameters[0], + ), body: rawBody, }); @@ -111,7 +113,9 @@ async function processTwitchMessage(params: { // Dispatch reply await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({ ctx: ctxPayload, - cfg: config as Parameters[0]["cfg"], + cfg: config as Parameters< + typeof core.channel.reply.dispatchReplyWithBufferedBlockDispatcher + >[0]["cfg"], dispatcherOptions: { deliver: async (payload) => { await deliverTwitchReply({ @@ -188,7 +192,11 @@ export async function monitorTwitchProvider( // Establish connection try { - await clientManager.getClient(account, config as Parameters[1], accountId); + await clientManager.getClient( + account, + config as Parameters[1], + accountId, + ); runtime.log?.(`[twitch] Connected to Twitch as ${account.username}`); } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); diff --git a/extensions/twitch/src/onboarding.test.ts b/extensions/twitch/src/onboarding.test.ts index 57aa35201..b3aff4f4b 100644 --- a/extensions/twitch/src/onboarding.test.ts +++ b/extensions/twitch/src/onboarding.test.ts @@ -19,300 +19,300 @@ import type { TwitchAccountConfig } from "./types.js"; const mockPromptText = vi.fn(); const mockPromptConfirm = vi.fn(); const mockPrompter: WizardPrompter = { - text: mockPromptText, - confirm: mockPromptConfirm, + text: mockPromptText, + confirm: mockPromptConfirm, } as unknown as WizardPrompter; const mockAccount: TwitchAccountConfig = { - username: "testbot", - token: "oauth:test123", - clientId: "test-client-id", - channel: "#testchannel", + username: "testbot", + token: "oauth:test123", + clientId: "test-client-id", + channel: "#testchannel", }; describe("onboarding helpers", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); + beforeEach(() => { + vi.clearAllMocks(); + }); - afterEach(() => { - // Don't restoreAllMocks as it breaks module-level mocks - }); + afterEach(() => { + // Don't restoreAllMocks as it breaks module-level mocks + }); - describe("promptToken", () => { - it("should return existing token when user confirms to keep it", async () => { - const { promptToken } = await import("./onboarding.js"); + describe("promptToken", () => { + it("should return existing token when user confirms to keep it", async () => { + const { promptToken } = await import("./onboarding.js"); - mockPromptConfirm.mockResolvedValue(true); + mockPromptConfirm.mockResolvedValue(true); - const result = await promptToken(mockPrompter, mockAccount, undefined); + const result = await promptToken(mockPrompter, mockAccount, undefined); - expect(result).toBe("oauth:test123"); - expect(mockPromptConfirm).toHaveBeenCalledWith({ - message: "Token already configured. Keep it?", - initialValue: true, - }); - expect(mockPromptText).not.toHaveBeenCalled(); - }); + expect(result).toBe("oauth:test123"); + expect(mockPromptConfirm).toHaveBeenCalledWith({ + message: "Token already configured. Keep it?", + initialValue: true, + }); + expect(mockPromptText).not.toHaveBeenCalled(); + }); - it("should prompt for new token when user doesn't keep existing", async () => { - const { promptToken } = await import("./onboarding.js"); + it("should prompt for new token when user doesn't keep existing", async () => { + const { promptToken } = await import("./onboarding.js"); - mockPromptConfirm.mockResolvedValue(false); - mockPromptText.mockResolvedValue("oauth:newtoken123"); + mockPromptConfirm.mockResolvedValue(false); + mockPromptText.mockResolvedValue("oauth:newtoken123"); - const result = await promptToken(mockPrompter, mockAccount, undefined); + const result = await promptToken(mockPrompter, mockAccount, undefined); - expect(result).toBe("oauth:newtoken123"); - expect(mockPromptText).toHaveBeenCalledWith({ - message: "Twitch OAuth token (oauth:...)", - initialValue: "", - validate: expect.any(Function), - }); - }); + expect(result).toBe("oauth:newtoken123"); + expect(mockPromptText).toHaveBeenCalledWith({ + message: "Twitch OAuth token (oauth:...)", + initialValue: "", + validate: expect.any(Function), + }); + }); - it("should use env token as initial value when provided", async () => { - const { promptToken } = await import("./onboarding.js"); + it("should use env token as initial value when provided", async () => { + const { promptToken } = await import("./onboarding.js"); - mockPromptConfirm.mockResolvedValue(false); - mockPromptText.mockResolvedValue("oauth:fromenv"); + mockPromptConfirm.mockResolvedValue(false); + mockPromptText.mockResolvedValue("oauth:fromenv"); - await promptToken(mockPrompter, null, "oauth:fromenv"); + await promptToken(mockPrompter, null, "oauth:fromenv"); - expect(mockPromptText).toHaveBeenCalledWith( - expect.objectContaining({ - initialValue: "oauth:fromenv", - }), - ); - }); + expect(mockPromptText).toHaveBeenCalledWith( + expect.objectContaining({ + initialValue: "oauth:fromenv", + }), + ); + }); - it("should validate token format", async () => { - const { promptToken } = await import("./onboarding.js"); + it("should validate token format", async () => { + const { promptToken } = await import("./onboarding.js"); - // Set up mocks - user doesn't want to keep existing token - mockPromptConfirm.mockResolvedValueOnce(false); + // Set up mocks - user doesn't want to keep existing token + mockPromptConfirm.mockResolvedValueOnce(false); - // Track how many times promptText is called - let promptTextCallCount = 0; - let capturedValidate: ((value: string) => string | undefined) | undefined; + // Track how many times promptText is called + let promptTextCallCount = 0; + let capturedValidate: ((value: string) => string | undefined) | undefined; - mockPromptText.mockImplementationOnce((_args) => { - promptTextCallCount++; - // Capture the validate function from the first argument - if (_args?.validate) { - capturedValidate = _args.validate; - } - return Promise.resolve("oauth:test123"); - }); + mockPromptText.mockImplementationOnce((_args) => { + promptTextCallCount++; + // Capture the validate function from the first argument + if (_args?.validate) { + capturedValidate = _args.validate; + } + return Promise.resolve("oauth:test123"); + }); - // Call promptToken - const result = await promptToken(mockPrompter, mockAccount, undefined); + // Call promptToken + const result = await promptToken(mockPrompter, mockAccount, undefined); - // Verify promptText was called - expect(promptTextCallCount).toBe(1); - expect(result).toBe("oauth:test123"); + // Verify promptText was called + expect(promptTextCallCount).toBe(1); + expect(result).toBe("oauth:test123"); - // Test the validate function - expect(capturedValidate).toBeDefined(); - expect(capturedValidate!("")).toBe("Required"); - expect(capturedValidate!("notoauth")).toBe("Token should start with 'oauth:'"); - }); + // Test the validate function + expect(capturedValidate).toBeDefined(); + expect(capturedValidate!("")).toBe("Required"); + expect(capturedValidate!("notoauth")).toBe("Token should start with 'oauth:'"); + }); - it("should return early when no existing token and no env token", async () => { - const { promptToken } = await import("./onboarding.js"); + it("should return early when no existing token and no env token", async () => { + const { promptToken } = await import("./onboarding.js"); - mockPromptText.mockResolvedValue("oauth:newtoken"); + mockPromptText.mockResolvedValue("oauth:newtoken"); - const result = await promptToken(mockPrompter, null, undefined); + const result = await promptToken(mockPrompter, null, undefined); - expect(result).toBe("oauth:newtoken"); - expect(mockPromptConfirm).not.toHaveBeenCalled(); - }); - }); + expect(result).toBe("oauth:newtoken"); + expect(mockPromptConfirm).not.toHaveBeenCalled(); + }); + }); - describe("promptUsername", () => { - it("should prompt for username with validation", async () => { - const { promptUsername } = await import("./onboarding.js"); + describe("promptUsername", () => { + it("should prompt for username with validation", async () => { + const { promptUsername } = await import("./onboarding.js"); - mockPromptText.mockResolvedValue("mybot"); + mockPromptText.mockResolvedValue("mybot"); - const result = await promptUsername(mockPrompter, null); + const result = await promptUsername(mockPrompter, null); - expect(result).toBe("mybot"); - expect(mockPromptText).toHaveBeenCalledWith({ - message: "Twitch bot username", - initialValue: "", - validate: expect.any(Function), - }); - }); + expect(result).toBe("mybot"); + expect(mockPromptText).toHaveBeenCalledWith({ + message: "Twitch bot username", + initialValue: "", + validate: expect.any(Function), + }); + }); - it("should use existing username as initial value", async () => { - const { promptUsername } = await import("./onboarding.js"); + it("should use existing username as initial value", async () => { + const { promptUsername } = await import("./onboarding.js"); - mockPromptText.mockResolvedValue("testbot"); + mockPromptText.mockResolvedValue("testbot"); - await promptUsername(mockPrompter, mockAccount); + await promptUsername(mockPrompter, mockAccount); - expect(mockPromptText).toHaveBeenCalledWith( - expect.objectContaining({ - initialValue: "testbot", - }), - ); - }); - }); + expect(mockPromptText).toHaveBeenCalledWith( + expect.objectContaining({ + initialValue: "testbot", + }), + ); + }); + }); - describe("promptClientId", () => { - it("should prompt for client ID with validation", async () => { - const { promptClientId } = await import("./onboarding.js"); + describe("promptClientId", () => { + it("should prompt for client ID with validation", async () => { + const { promptClientId } = await import("./onboarding.js"); - mockPromptText.mockResolvedValue("abc123xyz"); + mockPromptText.mockResolvedValue("abc123xyz"); - const result = await promptClientId(mockPrompter, null); + const result = await promptClientId(mockPrompter, null); - expect(result).toBe("abc123xyz"); - expect(mockPromptText).toHaveBeenCalledWith({ - message: "Twitch Client ID", - initialValue: "", - validate: expect.any(Function), - }); - }); - }); + expect(result).toBe("abc123xyz"); + expect(mockPromptText).toHaveBeenCalledWith({ + message: "Twitch Client ID", + initialValue: "", + validate: expect.any(Function), + }); + }); + }); - describe("promptChannelName", () => { - it("should return channel name when provided", async () => { - const { promptChannelName } = await import("./onboarding.js"); + describe("promptChannelName", () => { + it("should return channel name when provided", async () => { + const { promptChannelName } = await import("./onboarding.js"); - mockPromptText.mockResolvedValue("#mychannel"); + mockPromptText.mockResolvedValue("#mychannel"); - const result = await promptChannelName(mockPrompter, null); + const result = await promptChannelName(mockPrompter, null); - expect(result).toBe("#mychannel"); - }); + expect(result).toBe("#mychannel"); + }); - it("should return undefined when empty string provided", async () => { - const { promptChannelName } = await import("./onboarding.js"); + it("should return undefined when empty string provided", async () => { + const { promptChannelName } = await import("./onboarding.js"); - mockPromptText.mockResolvedValue(""); + mockPromptText.mockResolvedValue(""); - const result = await promptChannelName(mockPrompter, null); + const result = await promptChannelName(mockPrompter, null); - expect(result).toBeUndefined(); - }); + expect(result).toBeUndefined(); + }); - it("should return undefined when whitespace only", async () => { - const { promptChannelName } = await import("./onboarding.js"); + it("should return undefined when whitespace only", async () => { + const { promptChannelName } = await import("./onboarding.js"); - mockPromptText.mockResolvedValue(" "); + mockPromptText.mockResolvedValue(" "); - const result = await promptChannelName(mockPrompter, null); + const result = await promptChannelName(mockPrompter, null); - expect(result).toBeUndefined(); - }); - }); + expect(result).toBeUndefined(); + }); + }); - describe("promptRefreshTokenSetup", () => { - it("should return empty object when user declines", async () => { - const { promptRefreshTokenSetup } = await import("./onboarding.js"); + describe("promptRefreshTokenSetup", () => { + it("should return empty object when user declines", async () => { + const { promptRefreshTokenSetup } = await import("./onboarding.js"); - mockPromptConfirm.mockResolvedValue(false); + mockPromptConfirm.mockResolvedValue(false); - const result = await promptRefreshTokenSetup(mockPrompter, mockAccount); + const result = await promptRefreshTokenSetup(mockPrompter, mockAccount); - expect(result).toEqual({}); - expect(mockPromptConfirm).toHaveBeenCalledWith({ - message: - "Enable automatic token refresh (requires client secret and refresh token)?", - initialValue: false, - }); - }); + expect(result).toEqual({}); + expect(mockPromptConfirm).toHaveBeenCalledWith({ + message: "Enable automatic token refresh (requires client secret and refresh token)?", + initialValue: false, + }); + }); - it("should prompt for credentials when user accepts", async () => { - const { promptRefreshTokenSetup } = await import("./onboarding.js"); + it("should prompt for credentials when user accepts", async () => { + const { promptRefreshTokenSetup } = await import("./onboarding.js"); - mockPromptConfirm - .mockResolvedValueOnce(true) // First call: useRefresh - .mockResolvedValueOnce("secret123") // clientSecret - .mockResolvedValueOnce("refresh123"); // refreshToken + mockPromptConfirm + .mockResolvedValueOnce(true) // First call: useRefresh + .mockResolvedValueOnce("secret123") // clientSecret + .mockResolvedValueOnce("refresh123"); // refreshToken - mockPromptText - .mockResolvedValueOnce("secret123") - .mockResolvedValueOnce("refresh123"); + mockPromptText.mockResolvedValueOnce("secret123").mockResolvedValueOnce("refresh123"); - const result = await promptRefreshTokenSetup(mockPrompter, null); - - expect(result).toEqual({ - clientSecret: "secret123", - refreshToken: "refresh123", - }); - }); + const result = await promptRefreshTokenSetup(mockPrompter, null); - it("should use existing values as initial prompts", async () => { - const { promptRefreshTokenSetup } = await import("./onboarding.js"); + expect(result).toEqual({ + clientSecret: "secret123", + refreshToken: "refresh123", + }); + }); - const accountWithRefresh = { - ...mockAccount, - clientSecret: "existing-secret", - refreshToken: "existing-refresh", - }; - - mockPromptConfirm.mockResolvedValue(true); - mockPromptText - .mockResolvedValueOnce("existing-secret") - .mockResolvedValueOnce("existing-refresh"); + it("should use existing values as initial prompts", async () => { + const { promptRefreshTokenSetup } = await import("./onboarding.js"); - await promptRefreshTokenSetup(mockPrompter, accountWithRefresh); + const accountWithRefresh = { + ...mockAccount, + clientSecret: "existing-secret", + refreshToken: "existing-refresh", + }; + + mockPromptConfirm.mockResolvedValue(true); + mockPromptText + .mockResolvedValueOnce("existing-secret") + .mockResolvedValueOnce("existing-refresh"); - expect(mockPromptConfirm).toHaveBeenCalledWith( - expect.objectContaining({ - initialValue: true, // Both clientSecret and refreshToken exist - }), - ); - }); - }); - - describe("configureWithEnvToken", () => { - it("should return null when user declines env token", async () => { - const { configureWithEnvToken } = await import("./onboarding.js"); + await promptRefreshTokenSetup(mockPrompter, accountWithRefresh); - // Reset and set up mock - user declines env token - mockPromptConfirm.mockReset().mockResolvedValue(false as never); + expect(mockPromptConfirm).toHaveBeenCalledWith( + expect.objectContaining({ + initialValue: true, // Both clientSecret and refreshToken exist + }), + ); + }); + }); - const result = await configureWithEnvToken( - {} as Parameters[0], - mockPrompter, - null, - "oauth:fromenv", - false, - {} as Parameters[5], - ); + describe("configureWithEnvToken", () => { + it("should return null when user declines env token", async () => { + const { configureWithEnvToken } = await import("./onboarding.js"); + + // Reset and set up mock - user declines env token + mockPromptConfirm.mockReset().mockResolvedValue(false as never); - // Since user declined, should return null without prompting for username/clientId - expect(result).toBeNull(); - expect(mockPromptText).not.toHaveBeenCalled(); - }); - - it("should prompt for username and clientId when using env token", async () => { - const { configureWithEnvToken } = await import("./onboarding.js"); - - // Reset and set up mocks - user accepts env token - mockPromptConfirm.mockReset().mockResolvedValue(true as never); - - // Set up mocks for username and clientId prompts - mockPromptText.mockReset().mockResolvedValueOnce("testbot" as never).mockResolvedValueOnce("test-client-id" as never); - - const result = await configureWithEnvToken( - {} as Parameters[0], - mockPrompter, - null, - "oauth:fromenv", - false, - {} as Parameters[5], - ); - - // Should return config with username and clientId - expect(result).not.toBeNull(); - expect(result?.cfg.channels?.twitch?.accounts?.default?.username).toBe("testbot"); - expect(result?.cfg.channels?.twitch?.accounts?.default?.clientId).toBe("test-client-id"); - }); - }); + const result = await configureWithEnvToken( + {} as Parameters[0], + mockPrompter, + null, + "oauth:fromenv", + false, + {} as Parameters[5], + ); + + // Since user declined, should return null without prompting for username/clientId + expect(result).toBeNull(); + expect(mockPromptText).not.toHaveBeenCalled(); + }); + + it("should prompt for username and clientId when using env token", async () => { + const { configureWithEnvToken } = await import("./onboarding.js"); + + // Reset and set up mocks - user accepts env token + mockPromptConfirm.mockReset().mockResolvedValue(true as never); + + // Set up mocks for username and clientId prompts + mockPromptText + .mockReset() + .mockResolvedValueOnce("testbot" as never) + .mockResolvedValueOnce("test-client-id" as never); + + const result = await configureWithEnvToken( + {} as Parameters[0], + mockPrompter, + null, + "oauth:fromenv", + false, + {} as Parameters[5], + ); + + // Should return config with username and clientId + expect(result).not.toBeNull(); + expect(result?.cfg.channels?.twitch?.accounts?.default?.username).toBe("testbot"); + expect(result?.cfg.channels?.twitch?.accounts?.default?.clientId).toBe("test-client-id"); + }); + }); }); diff --git a/extensions/twitch/src/onboarding.ts b/extensions/twitch/src/onboarding.ts index 6f9dd6c9e..1f6762aab 100644 --- a/extensions/twitch/src/onboarding.ts +++ b/extensions/twitch/src/onboarding.ts @@ -9,10 +9,7 @@ import { type ChannelOnboardingDmPolicy, type WizardPrompter, } from "clawdbot/plugin-sdk"; -import { - DEFAULT_ACCOUNT_ID, - getAccountConfig, -} from "./config.js"; +import { DEFAULT_ACCOUNT_ID, getAccountConfig } from "./config.js"; import type { TwitchAccountConfig, TwitchRole } from "./types.js"; import type { ClawdbotConfig } from "clawdbot/plugin-sdk"; @@ -60,10 +57,14 @@ function setTwitchAccount( channels: { ...cfg.channels, twitch: { - ...((cfg.channels as Record)?.twitch as Record | undefined), + ...((cfg.channels as Record)?.twitch as + | Record + | undefined), enabled: true, accounts: { - ...(((cfg.channels as Record)?.twitch as Record | undefined)?.accounts as Record | undefined), + ...(( + (cfg.channels as Record)?.twitch as Record | undefined + )?.accounts as Record | undefined), [DEFAULT_ACCOUNT_ID]: merged, }, }, @@ -191,21 +192,23 @@ async function promptRefreshTokenSetup( return {}; } - const clientSecret = String( - await prompter.text({ - message: "Twitch Client Secret (for token refresh)", - initialValue: account?.clientSecret ?? "", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - ).trim() || undefined; + const clientSecret = + String( + await prompter.text({ + message: "Twitch Client Secret (for token refresh)", + initialValue: account?.clientSecret ?? "", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim() || undefined; - const refreshToken = String( - await prompter.text({ - message: "Twitch Refresh Token", - initialValue: account?.refreshToken ?? "", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - ).trim() || undefined; + const refreshToken = + String( + await prompter.text({ + message: "Twitch Refresh Token", + initialValue: account?.refreshToken ?? "", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim() || undefined; return { clientSecret, refreshToken }; } @@ -315,9 +318,7 @@ export const twitchOnboardingAdapter: ChannelOnboardingAdapter = { return { channel, configured, - statusLines: [ - `Twitch: ${configured ? "configured" : "needs username, token, and clientId"}`, - ], + statusLines: [`Twitch: ${configured ? "configured" : "needs username, token, and clientId"}`], selectionHint: configured ? "configured" : "needs setup", }; }, @@ -399,7 +400,9 @@ export const twitchOnboardingAdapter: ChannelOnboardingAdapter = { }, dmPolicy, disable: (cfg) => { - const twitch = (cfg.channels as Record)?.twitch as Record | undefined; + const twitch = (cfg.channels as Record)?.twitch as + | Record + | undefined; return { ...cfg, channels: { @@ -411,4 +414,11 @@ export const twitchOnboardingAdapter: ChannelOnboardingAdapter = { }; // Export helper functions for testing -export { promptToken, promptUsername, promptClientId, promptChannelName, promptRefreshTokenSetup, configureWithEnvToken }; +export { + promptToken, + promptUsername, + promptClientId, + promptChannelName, + promptRefreshTokenSetup, + configureWithEnvToken, +}; diff --git a/extensions/twitch/src/outbound.test.ts b/extensions/twitch/src/outbound.test.ts index 4160aec2c..dc663be78 100644 --- a/extensions/twitch/src/outbound.test.ts +++ b/extensions/twitch/src/outbound.test.ts @@ -15,389 +15,389 @@ import type { ClawdbotConfig } from "clawdbot/plugin-sdk"; // Mock dependencies vi.mock("./config.js", () => ({ - DEFAULT_ACCOUNT_ID: "default", - getAccountConfig: vi.fn(), - parsePluginConfig: vi.fn(() => ({ stripMarkdown: true })), + DEFAULT_ACCOUNT_ID: "default", + getAccountConfig: vi.fn(), + parsePluginConfig: vi.fn(() => ({ stripMarkdown: true })), })); vi.mock("./send.js", () => ({ - sendMessageTwitchInternal: vi.fn(), + sendMessageTwitchInternal: vi.fn(), })); vi.mock("./utils/markdown.js", () => ({ - chunkTextForTwitch: vi.fn((text) => text.split(/(.{500})/).filter(Boolean)), + chunkTextForTwitch: vi.fn((text) => text.split(/(.{500})/).filter(Boolean)), })); vi.mock("./utils/twitch.js", () => ({ - normalizeTwitchChannel: (channel: string) => channel.toLowerCase().replace(/^#/, ""), - missingTargetError: (channel: string, hint: string) => - `Missing target for ${channel}. Provide ${hint}`, + normalizeTwitchChannel: (channel: string) => channel.toLowerCase().replace(/^#/, ""), + missingTargetError: (channel: string, hint: string) => + `Missing target for ${channel}. Provide ${hint}`, })); describe("outbound", () => { - const mockAccount = { - username: "testbot", - token: "oauth:test123", - clientId: "test-client-id", - channel: "#testchannel", - }; + const mockAccount = { + username: "testbot", + token: "oauth:test123", + clientId: "test-client-id", + channel: "#testchannel", + }; - const mockConfig = { - channels: { - twitch: { - accounts: { - default: mockAccount, - }, - }, - }, - } as unknown as ClawdbotConfig; + const mockConfig = { + channels: { + twitch: { + accounts: { + default: mockAccount, + }, + }, + }, + } as unknown as ClawdbotConfig; - beforeEach(() => { - vi.clearAllMocks(); - }); + beforeEach(() => { + vi.clearAllMocks(); + }); - afterEach(() => { - vi.restoreAllMocks(); - }); + afterEach(() => { + vi.restoreAllMocks(); + }); - describe("metadata", () => { - it("should have direct delivery mode", () => { - expect(twitchOutbound.deliveryMode).toBe("direct"); - }); + describe("metadata", () => { + it("should have direct delivery mode", () => { + expect(twitchOutbound.deliveryMode).toBe("direct"); + }); - it("should have 500 character text chunk limit", () => { - expect(twitchOutbound.textChunkLimit).toBe(500); - }); + it("should have 500 character text chunk limit", () => { + expect(twitchOutbound.textChunkLimit).toBe(500); + }); - it("should have chunker function", () => { - expect(twitchOutbound.chunker).toBeDefined(); - expect(typeof twitchOutbound.chunker).toBe("function"); - }); - }); + it("should have chunker function", () => { + expect(twitchOutbound.chunker).toBeDefined(); + expect(typeof twitchOutbound.chunker).toBe("function"); + }); + }); - describe("resolveTarget", () => { - it("should normalize and return target in explicit mode", () => { - const result = twitchOutbound.resolveTarget({ - to: "#MyChannel", - mode: "explicit", - allowFrom: [], - }); + describe("resolveTarget", () => { + it("should normalize and return target in explicit mode", () => { + const result = twitchOutbound.resolveTarget({ + to: "#MyChannel", + mode: "explicit", + allowFrom: [], + }); - expect(result.ok).toBe(true); - expect(result.to).toBe("mychannel"); - }); + expect(result.ok).toBe(true); + expect(result.to).toBe("mychannel"); + }); - it("should return target in implicit mode with wildcard allowlist", () => { - const result = twitchOutbound.resolveTarget({ - to: "#AnyChannel", - mode: "implicit", - allowFrom: ["*"], - }); + it("should return target in implicit mode with wildcard allowlist", () => { + const result = twitchOutbound.resolveTarget({ + to: "#AnyChannel", + mode: "implicit", + allowFrom: ["*"], + }); - expect(result.ok).toBe(true); - expect(result.to).toBe("anychannel"); - }); + expect(result.ok).toBe(true); + expect(result.to).toBe("anychannel"); + }); - it("should return target in implicit mode when in allowlist", () => { - const result = twitchOutbound.resolveTarget({ - to: "#allowed", - mode: "implicit", - allowFrom: ["#allowed", "#other"], - }); + it("should return target in implicit mode when in allowlist", () => { + const result = twitchOutbound.resolveTarget({ + to: "#allowed", + mode: "implicit", + allowFrom: ["#allowed", "#other"], + }); - expect(result.ok).toBe(true); - expect(result.to).toBe("allowed"); - }); + expect(result.ok).toBe(true); + expect(result.to).toBe("allowed"); + }); - it("should fallback to first allowlist entry when target not in list", () => { - const result = twitchOutbound.resolveTarget({ - to: "#notallowed", - mode: "implicit", - allowFrom: ["#primary", "#secondary"], - }); + it("should fallback to first allowlist entry when target not in list", () => { + const result = twitchOutbound.resolveTarget({ + to: "#notallowed", + mode: "implicit", + allowFrom: ["#primary", "#secondary"], + }); - expect(result.ok).toBe(true); - expect(result.to).toBe("primary"); - }); + expect(result.ok).toBe(true); + expect(result.to).toBe("primary"); + }); - it("should accept any target when allowlist is empty", () => { - const result = twitchOutbound.resolveTarget({ - to: "#anychannel", - mode: "heartbeat", - allowFrom: [], - }); + it("should accept any target when allowlist is empty", () => { + const result = twitchOutbound.resolveTarget({ + to: "#anychannel", + mode: "heartbeat", + allowFrom: [], + }); - expect(result.ok).toBe(true); - expect(result.to).toBe("anychannel"); - }); + expect(result.ok).toBe(true); + expect(result.to).toBe("anychannel"); + }); - it("should use first allowlist entry when no target provided", () => { - const result = twitchOutbound.resolveTarget({ - to: undefined, - mode: "implicit", - allowFrom: ["#fallback", "#other"], - }); + it("should use first allowlist entry when no target provided", () => { + const result = twitchOutbound.resolveTarget({ + to: undefined, + mode: "implicit", + allowFrom: ["#fallback", "#other"], + }); - expect(result.ok).toBe(true); - expect(result.to).toBe("fallback"); - }); + expect(result.ok).toBe(true); + expect(result.to).toBe("fallback"); + }); - it("should return error when no target and no allowlist", () => { - const result = twitchOutbound.resolveTarget({ - to: undefined, - mode: "explicit", - allowFrom: [], - }); + it("should return error when no target and no allowlist", () => { + const result = twitchOutbound.resolveTarget({ + to: undefined, + mode: "explicit", + allowFrom: [], + }); - expect(result.ok).toBe(false); - expect(result.error).toContain("Missing target"); - }); + expect(result.ok).toBe(false); + expect(result.error).toContain("Missing target"); + }); - it("should handle whitespace-only target", () => { - const result = twitchOutbound.resolveTarget({ - to: " ", - mode: "explicit", - allowFrom: [], - }); + it("should handle whitespace-only target", () => { + const result = twitchOutbound.resolveTarget({ + to: " ", + mode: "explicit", + allowFrom: [], + }); - expect(result.ok).toBe(false); - expect(result.error).toContain("Missing target"); - }); + expect(result.ok).toBe(false); + expect(result.error).toContain("Missing target"); + }); - it("should filter wildcard from allowlist when checking membership", () => { - const result = twitchOutbound.resolveTarget({ - to: "#mychannel", - mode: "implicit", - allowFrom: ["*", "#specific"], - }); + it("should filter wildcard from allowlist when checking membership", () => { + const result = twitchOutbound.resolveTarget({ + to: "#mychannel", + mode: "implicit", + allowFrom: ["*", "#specific"], + }); - // With wildcard, any target is accepted - expect(result.ok).toBe(true); - expect(result.to).toBe("mychannel"); - }); - }); + // With wildcard, any target is accepted + expect(result.ok).toBe(true); + expect(result.to).toBe("mychannel"); + }); + }); - describe("sendText", () => { - it("should send message successfully", async () => { - const { getAccountConfig } = await import("./config.js"); - const { sendMessageTwitchInternal } = await import("./send.js"); + describe("sendText", () => { + it("should send message successfully", async () => { + const { getAccountConfig } = await import("./config.js"); + const { sendMessageTwitchInternal } = await import("./send.js"); - vi.mocked(getAccountConfig).mockReturnValue(mockAccount); - vi.mocked(sendMessageTwitchInternal).mockResolvedValue({ - ok: true, - messageId: "twitch-msg-123", - }); + vi.mocked(getAccountConfig).mockReturnValue(mockAccount); + vi.mocked(sendMessageTwitchInternal).mockResolvedValue({ + ok: true, + messageId: "twitch-msg-123", + }); - const result = await twitchOutbound.sendText({ - cfg: mockConfig, - to: "#testchannel", - text: "Hello Twitch!", - accountId: "default", - }); + const result = await twitchOutbound.sendText({ + cfg: mockConfig, + to: "#testchannel", + text: "Hello Twitch!", + accountId: "default", + }); - expect(result.channel).toBe("twitch"); - expect(result.messageId).toBe("twitch-msg-123"); - expect(result.to).toBe("testchannel"); - expect(result.timestamp).toBeGreaterThan(0); - }); + expect(result.channel).toBe("twitch"); + expect(result.messageId).toBe("twitch-msg-123"); + expect(result.to).toBe("testchannel"); + expect(result.timestamp).toBeGreaterThan(0); + }); - it("should throw when account not found", async () => { - const { getAccountConfig } = await import("./config.js"); + it("should throw when account not found", async () => { + const { getAccountConfig } = await import("./config.js"); - vi.mocked(getAccountConfig).mockReturnValue(null); + vi.mocked(getAccountConfig).mockReturnValue(null); - await expect( - twitchOutbound.sendText({ - cfg: mockConfig, - to: "#testchannel", - text: "Hello!", - accountId: "nonexistent", - }), - ).rejects.toThrow("Twitch account not found: nonexistent"); - }); + await expect( + twitchOutbound.sendText({ + cfg: mockConfig, + to: "#testchannel", + text: "Hello!", + accountId: "nonexistent", + }), + ).rejects.toThrow("Twitch account not found: nonexistent"); + }); - it("should throw when no channel specified", async () => { - const { getAccountConfig } = await import("./config.js"); + it("should throw when no channel specified", async () => { + const { getAccountConfig } = await import("./config.js"); - const accountWithoutChannel = { ...mockAccount, channel: undefined, username: undefined }; - vi.mocked(getAccountConfig).mockReturnValue(accountWithoutChannel); + const accountWithoutChannel = { ...mockAccount, channel: undefined, username: undefined }; + vi.mocked(getAccountConfig).mockReturnValue(accountWithoutChannel); - await expect( - twitchOutbound.sendText({ - cfg: mockConfig, - to: undefined, - text: "Hello!", - accountId: "default", - }), - ).rejects.toThrow("No channel specified"); - }); + await expect( + twitchOutbound.sendText({ + cfg: mockConfig, + to: undefined, + text: "Hello!", + accountId: "default", + }), + ).rejects.toThrow("No channel specified"); + }); - it("should use account channel when target not provided", async () => { - const { getAccountConfig } = await import("./config.js"); - const { sendMessageTwitchInternal } = await import("./send.js"); + it("should use account channel when target not provided", async () => { + const { getAccountConfig } = await import("./config.js"); + const { sendMessageTwitchInternal } = await import("./send.js"); - vi.mocked(getAccountConfig).mockReturnValue(mockAccount); - vi.mocked(sendMessageTwitchInternal).mockResolvedValue({ - ok: true, - messageId: "msg-456", - }); + vi.mocked(getAccountConfig).mockReturnValue(mockAccount); + vi.mocked(sendMessageTwitchInternal).mockResolvedValue({ + ok: true, + messageId: "msg-456", + }); - await twitchOutbound.sendText({ - cfg: mockConfig, - to: undefined, - text: "Hello!", - accountId: "default", - }); + await twitchOutbound.sendText({ + cfg: mockConfig, + to: undefined, + text: "Hello!", + accountId: "default", + }); - expect(sendMessageTwitchInternal).toHaveBeenCalledWith( - "testchannel", - "Hello!", - mockConfig, - "default", - true, - console, - ); - }); + expect(sendMessageTwitchInternal).toHaveBeenCalledWith( + "testchannel", + "Hello!", + mockConfig, + "default", + true, + console, + ); + }); - it("should handle abort signal", async () => { - const abortController = new AbortController(); - abortController.abort(); + it("should handle abort signal", async () => { + const abortController = new AbortController(); + abortController.abort(); - await expect( - twitchOutbound.sendText({ - cfg: mockConfig, - to: "#testchannel", - text: "Hello!", - accountId: "default", - signal: abortController.signal, - }), - ).rejects.toThrow("Outbound delivery aborted"); - }); + await expect( + twitchOutbound.sendText({ + cfg: mockConfig, + to: "#testchannel", + text: "Hello!", + accountId: "default", + signal: abortController.signal, + }), + ).rejects.toThrow("Outbound delivery aborted"); + }); - it("should throw on send failure", async () => { - const { getAccountConfig } = await import("./config.js"); - const { sendMessageTwitchInternal } = await import("./send.js"); + it("should throw on send failure", async () => { + const { getAccountConfig } = await import("./config.js"); + const { sendMessageTwitchInternal } = await import("./send.js"); - vi.mocked(getAccountConfig).mockReturnValue(mockAccount); - vi.mocked(sendMessageTwitchInternal).mockResolvedValue({ - ok: false, - messageId: "failed-msg", - error: "Connection lost", - }); + vi.mocked(getAccountConfig).mockReturnValue(mockAccount); + vi.mocked(sendMessageTwitchInternal).mockResolvedValue({ + ok: false, + messageId: "failed-msg", + error: "Connection lost", + }); - await expect( - twitchOutbound.sendText({ - cfg: mockConfig, - to: "#testchannel", - text: "Hello!", - accountId: "default", - }), - ).rejects.toThrow("Connection lost"); - }); + await expect( + twitchOutbound.sendText({ + cfg: mockConfig, + to: "#testchannel", + text: "Hello!", + accountId: "default", + }), + ).rejects.toThrow("Connection lost"); + }); - it("should respect stripMarkdown config", async () => { - const { getAccountConfig } = await import("./config.js"); - const { parsePluginConfig } = await import("./config.js"); - const { sendMessageTwitchInternal } = await import("./send.js"); + it("should respect stripMarkdown config", async () => { + const { getAccountConfig } = await import("./config.js"); + const { parsePluginConfig } = await import("./config.js"); + const { sendMessageTwitchInternal } = await import("./send.js"); - vi.mocked(getAccountConfig).mockReturnValue(mockAccount); - vi.mocked(parsePluginConfig).mockReturnValue({ stripMarkdown: false }); - vi.mocked(sendMessageTwitchInternal).mockResolvedValue({ - ok: true, - messageId: "msg-789", - }); + vi.mocked(getAccountConfig).mockReturnValue(mockAccount); + vi.mocked(parsePluginConfig).mockReturnValue({ stripMarkdown: false }); + vi.mocked(sendMessageTwitchInternal).mockResolvedValue({ + ok: true, + messageId: "msg-789", + }); - await twitchOutbound.sendText({ - cfg: mockConfig, - to: "#testchannel", - text: "**Bold** text", - accountId: "default", - }); + await twitchOutbound.sendText({ + cfg: mockConfig, + to: "#testchannel", + text: "**Bold** text", + accountId: "default", + }); - expect(sendMessageTwitchInternal).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - expect.anything(), - expect.anything(), - false, // stripMarkdown disabled - expect.anything(), - ); - }); - }); + expect(sendMessageTwitchInternal).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.anything(), + expect.anything(), + false, // stripMarkdown disabled + expect.anything(), + ); + }); + }); - describe("sendMedia", () => { - it("should combine text and media URL", async () => { - const { sendMessageTwitchInternal } = await import("./send.js"); - const { getAccountConfig } = await import("./config.js"); + describe("sendMedia", () => { + it("should combine text and media URL", async () => { + const { sendMessageTwitchInternal } = await import("./send.js"); + const { getAccountConfig } = await import("./config.js"); - vi.mocked(getAccountConfig).mockReturnValue(mockAccount); - vi.mocked(sendMessageTwitchInternal).mockResolvedValue({ - ok: true, - messageId: "media-msg-123", - }); + vi.mocked(getAccountConfig).mockReturnValue(mockAccount); + vi.mocked(sendMessageTwitchInternal).mockResolvedValue({ + ok: true, + messageId: "media-msg-123", + }); - const result = await twitchOutbound.sendMedia({ - cfg: mockConfig, - to: "#testchannel", - text: "Check this:", - mediaUrl: "https://example.com/image.png", - accountId: "default", - }); + const result = await twitchOutbound.sendMedia({ + cfg: mockConfig, + to: "#testchannel", + text: "Check this:", + mediaUrl: "https://example.com/image.png", + accountId: "default", + }); - expect(result.channel).toBe("twitch"); - expect(result.messageId).toBe("media-msg-123"); - expect(sendMessageTwitchInternal).toHaveBeenCalledWith( - expect.anything(), - "Check this: https://example.com/image.png", - expect.anything(), - expect.anything(), - expect.anything(), - expect.anything(), - ); - }); + expect(result.channel).toBe("twitch"); + expect(result.messageId).toBe("media-msg-123"); + expect(sendMessageTwitchInternal).toHaveBeenCalledWith( + expect.anything(), + "Check this: https://example.com/image.png", + expect.anything(), + expect.anything(), + expect.anything(), + expect.anything(), + ); + }); - it("should send media URL only when no text", async () => { - const { sendMessageTwitchInternal } = await import("./send.js"); - const { getAccountConfig } = await import("./config.js"); + it("should send media URL only when no text", async () => { + const { sendMessageTwitchInternal } = await import("./send.js"); + const { getAccountConfig } = await import("./config.js"); - vi.mocked(getAccountConfig).mockReturnValue(mockAccount); - vi.mocked(sendMessageTwitchInternal).mockResolvedValue({ - ok: true, - messageId: "media-only-msg", - }); + vi.mocked(getAccountConfig).mockReturnValue(mockAccount); + vi.mocked(sendMessageTwitchInternal).mockResolvedValue({ + ok: true, + messageId: "media-only-msg", + }); - await twitchOutbound.sendMedia({ - cfg: mockConfig, - to: "#testchannel", - text: undefined, - mediaUrl: "https://example.com/image.png", - accountId: "default", - }); + await twitchOutbound.sendMedia({ + cfg: mockConfig, + to: "#testchannel", + text: undefined, + mediaUrl: "https://example.com/image.png", + accountId: "default", + }); - expect(sendMessageTwitchInternal).toHaveBeenCalledWith( - expect.anything(), - "https://example.com/image.png", - expect.anything(), - expect.anything(), - expect.anything(), - expect.anything(), - ); - }); + expect(sendMessageTwitchInternal).toHaveBeenCalledWith( + expect.anything(), + "https://example.com/image.png", + expect.anything(), + expect.anything(), + expect.anything(), + expect.anything(), + ); + }); - it("should handle abort signal", async () => { - const abortController = new AbortController(); - abortController.abort(); + it("should handle abort signal", async () => { + const abortController = new AbortController(); + abortController.abort(); - await expect( - twitchOutbound.sendMedia({ - cfg: mockConfig, - to: "#testchannel", - text: "Check this:", - mediaUrl: "https://example.com/image.png", - accountId: "default", - signal: abortController.signal, - }), - ).rejects.toThrow("Outbound delivery aborted"); - }); - }); + await expect( + twitchOutbound.sendMedia({ + cfg: mockConfig, + to: "#testchannel", + text: "Check this:", + mediaUrl: "https://example.com/image.png", + accountId: "default", + signal: abortController.signal, + }), + ).rejects.toThrow("Outbound delivery aborted"); + }); + }); }); diff --git a/extensions/twitch/src/outbound.ts b/extensions/twitch/src/outbound.ts index dea76f8e3..a3df3e9d6 100644 --- a/extensions/twitch/src/outbound.ts +++ b/extensions/twitch/src/outbound.ts @@ -5,16 +5,12 @@ * Supports text and media (URL) sending with markdown stripping and chunking. */ -import { - DEFAULT_ACCOUNT_ID, - getAccountConfig, - parsePluginConfig, -} from "./config.js"; +import { DEFAULT_ACCOUNT_ID, getAccountConfig, parsePluginConfig } from "./config.js"; import { sendMessageTwitchInternal } from "./send.js"; import type { - ChannelOutboundAdapter, - ChannelOutboundContext, - OutboundDeliveryResult, + ChannelOutboundAdapter, + ChannelOutboundContext, + OutboundDeliveryResult, } from "./types.js"; import { chunkTextForTwitch } from "./utils/markdown.js"; import { missingTargetError, normalizeTwitchChannel } from "./utils/twitch.js"; @@ -26,184 +22,178 @@ import { missingTargetError, normalizeTwitchChannel } from "./utils/twitch.js"; * markdown stripping and message chunking. */ export const twitchOutbound: ChannelOutboundAdapter = { - /** Direct delivery mode - messages are sent immediately */ - deliveryMode: "direct", + /** Direct delivery mode - messages are sent immediately */ + deliveryMode: "direct", - /** Twitch chat message limit is 500 characters */ - textChunkLimit: 500, + /** Twitch chat message limit is 500 characters */ + textChunkLimit: 500, - /** Word-boundary chunker with markdown stripping */ - chunker: chunkTextForTwitch, + /** 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); + /** + * 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); + // 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 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 }; - } + // 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 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]", - ), - }; - }, + // 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; + /** + * 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"); - } + // 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"}`, - ); - } + // 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 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 ?? {}, - ); + // 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, - ); + // 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"); - } + if (!result.ok) { + throw new Error(result.error ?? "Send failed"); + } - return { - channel: "twitch", - messageId: result.messageId, - timestamp: Date.now(), - to: normalizeTwitchChannel(channel), - }; - }, + 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; + /** + * 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"); - } + // 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; + // 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, - }); - }, + // 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 index eca3e62b9..8f4b5c948 100644 --- a/extensions/twitch/src/plugin.ts +++ b/extensions/twitch/src/plugin.ts @@ -9,11 +9,7 @@ import type { ClawdbotConfig } from "clawdbot/plugin-sdk"; import { buildChannelConfigSchema } from "clawdbot/plugin-sdk"; import { twitchMessageActions } from "./actions.js"; import { TwitchConfigSchema } from "./config-schema.js"; -import { - DEFAULT_ACCOUNT_ID, - getAccountConfig, - listAccountIds, -} from "./config.js"; +import { DEFAULT_ACCOUNT_ID, getAccountConfig, listAccountIds } from "./config.js"; import { twitchOnboardingAdapter } from "./onboarding.js"; import { twitchOutbound } from "./outbound.js"; import { probeTwitch } from "./probe.js"; @@ -34,9 +30,7 @@ import type { /** * Check if an account is properly configured. */ -function isConfigured( - account: TwitchAccountConfig | null | undefined, -): boolean { +function isConfigured(account: TwitchAccountConfig | null | undefined): boolean { return Boolean(account?.token && account?.username && account?.clientId); } @@ -71,7 +65,9 @@ export const twitchPlugin: ChannelPlugin = { notifyApproval: async ({ id }) => { // Note: Twitch doesn't support DMs from bots, so pairing approval is limited // We'll log the approval instead - console.warn(`[twitch] Pairing approved for user ${id} (notification sent via chat if possible)`); + console.warn( + `[twitch] Pairing approved for user ${id} (notification sent via chat if possible)`, + ); }, }, @@ -89,10 +85,7 @@ export const twitchPlugin: ChannelPlugin = { listAccountIds: (cfg: ClawdbotConfig): string[] => listAccountIds(cfg), /** Resolve an account config by ID */ - resolveAccount: ( - cfg: ClawdbotConfig, - accountId?: string | null, - ): TwitchAccountConfig => { + resolveAccount: (cfg: ClawdbotConfig, accountId?: string | null): TwitchAccountConfig => { const account = getAccountConfig(cfg, accountId ?? DEFAULT_ACCOUNT_ID); if (!account) { // Return a default/empty account if not configured @@ -116,8 +109,7 @@ export const twitchPlugin: ChannelPlugin = { }, /** Check if an account is enabled */ - isEnabled: (account: TwitchAccountConfig | undefined): boolean => - account?.enabled !== false, + isEnabled: (account: TwitchAccountConfig | undefined): boolean => account?.enabled !== false, /** Describe account status */ describeAccount: (account: TwitchAccountConfig | undefined) => ({ @@ -181,11 +173,7 @@ export const twitchPlugin: ChannelPlugin = { }, /** Build channel summary from snapshot */ - buildChannelSummary: ({ - snapshot, - }: { - snapshot: ChannelAccountSnapshot; - }) => ({ + buildChannelSummary: ({ snapshot }: { snapshot: ChannelAccountSnapshot }) => ({ configured: snapshot.configured ?? false, running: snapshot.running ?? false, lastStartAt: snapshot.lastStartAt ?? null, @@ -247,9 +235,7 @@ export const twitchPlugin: ChannelPlugin = { lastError: null, }); - ctx.log?.info( - `[twitch] Starting Twitch connection for ${account.username}`, - ); + ctx.log?.info(`[twitch] Starting Twitch connection for ${account.username}`); // Lazy import: the monitor pulls the reply pipeline; avoid ESM init cycles. const { monitorTwitchProvider } = await import("./monitor.js"); @@ -276,9 +262,7 @@ export const twitchPlugin: ChannelPlugin = { lastStopAt: Date.now(), }); - ctx.log?.info( - `[twitch] Stopped Twitch connection for ${account.username}`, - ); + ctx.log?.info(`[twitch] Stopped Twitch connection for ${account.username}`); }, }, }; diff --git a/extensions/twitch/src/probe.test.ts b/extensions/twitch/src/probe.test.ts index 592114e92..dd1dbc90f 100644 --- a/extensions/twitch/src/probe.test.ts +++ b/extensions/twitch/src/probe.test.ts @@ -7,139 +7,139 @@ const mockConnect = vi.fn().mockResolvedValue(undefined); const mockQuit = vi.fn().mockResolvedValue(undefined); vi.mock("@twurple/chat", () => ({ - ChatClient: class { - connect = mockConnect; - quit = mockQuit; - }, + ChatClient: class { + connect = mockConnect; + quit = mockQuit; + }, })); vi.mock("@twurple/auth", () => ({ - StaticAuthProvider: class {}, + StaticAuthProvider: class {}, })); describe("probeTwitch", () => { - const mockAccount: TwitchAccountConfig = { - username: "testbot", - token: "oauth:test123456789", - }; + const mockAccount: TwitchAccountConfig = { + username: "testbot", + token: "oauth:test123456789", + }; - beforeEach(() => { - vi.clearAllMocks(); - }); + 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); + 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); - }); + 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); + 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"); - }); + 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); + 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); - }); + // 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); + 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); - }); + 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", - }; + it("uses custom channel when specified", async () => { + const account: TwitchAccountConfig = { + ...mockAccount, + channel: "customchannel", + }; - const result = await probeTwitch(account, 5000); + const result = await probeTwitch(account, 5000); - expect(result.ok).toBe(true); - expect(result.channel).toBe("customchannel"); - }); + 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 + it("times out when connection takes too long", async () => { + mockConnect.mockImplementationOnce(() => new Promise(() => {})); // Never resolves - const result = await probeTwitch(mockAccount, 100); + const result = await probeTwitch(mockAccount, 100); - expect(result.ok).toBe(false); - expect(result.error).toContain("timeout"); + expect(result.ok).toBe(false); + expect(result.error).toContain("timeout"); - // Reset mock - mockConnect.mockResolvedValue(undefined); - }); + // Reset mock + mockConnect.mockResolvedValue(undefined); + }); - it("cleans up client even on failure", async () => { - mockConnect.mockRejectedValueOnce(new Error("Connection failed")); + it("cleans up client even on failure", async () => { + mockConnect.mockRejectedValueOnce(new Error("Connection failed")); - const result = await probeTwitch(mockAccount, 5000); + const result = await probeTwitch(mockAccount, 5000); - expect(result.ok).toBe(false); - expect(result.error).toContain("Connection failed"); - expect(mockQuit).toHaveBeenCalled(); + expect(result.ok).toBe(false); + expect(result.error).toContain("Connection failed"); + expect(mockQuit).toHaveBeenCalled(); - // Reset mocks - mockConnect.mockResolvedValue(undefined); - }); + // Reset mocks + mockConnect.mockResolvedValue(undefined); + }); - it("measures elapsed time", async () => { - const result = await probeTwitch(mockAccount, 5000); + it("measures elapsed time", async () => { + const result = await probeTwitch(mockAccount, 5000); - expect(result.ok).toBe(true); - expect(result.elapsedMs).toBeGreaterThan(0); - }); + expect(result.ok).toBe(true); + expect(result.elapsedMs).toBeGreaterThan(0); + }); - it("handles connection errors gracefully", async () => { - mockConnect.mockRejectedValueOnce(new Error("Network error")); + it("handles connection errors gracefully", async () => { + mockConnect.mockRejectedValueOnce(new Error("Network error")); - const result = await probeTwitch(mockAccount, 5000); + const result = await probeTwitch(mockAccount, 5000); - expect(result.ok).toBe(false); - expect(result.error).toContain("Network error"); + expect(result.ok).toBe(false); + expect(result.error).toContain("Network error"); - // Reset mock - mockConnect.mockResolvedValue(undefined); - }); + // Reset mock + mockConnect.mockResolvedValue(undefined); + }); - it("trims token before validation", async () => { - const account: TwitchAccountConfig = { - ...mockAccount, - token: " oauth:test123456789 ", - }; + it("trims token before validation", async () => { + const account: TwitchAccountConfig = { + ...mockAccount, + token: " oauth:test123456789 ", + }; - const result = await probeTwitch(account, 5000); + const result = await probeTwitch(account, 5000); - expect(result.ok).toBe(true); - }); + expect(result.ok).toBe(true); + }); - it("handles non-Error objects in catch block", async () => { - mockConnect.mockRejectedValueOnce("String error"); + it("handles non-Error objects in catch block", async () => { + mockConnect.mockRejectedValueOnce("String error"); - const result = await probeTwitch(mockAccount, 5000); + const result = await probeTwitch(mockAccount, 5000); - expect(result.ok).toBe(false); - expect(result.error).toBe("String error"); + expect(result.ok).toBe(false); + expect(result.error).toBe("String error"); - // Reset mock - mockConnect.mockResolvedValue(undefined); - }); + // Reset mock + mockConnect.mockResolvedValue(undefined); + }); }); diff --git a/extensions/twitch/src/probe.ts b/extensions/twitch/src/probe.ts index cb7e3153e..9b908a9b0 100644 --- a/extensions/twitch/src/probe.ts +++ b/extensions/twitch/src/probe.ts @@ -6,12 +6,12 @@ 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; + ok: boolean; + error?: string; + username?: string; + elapsedMs: number; + connected?: boolean; + channel?: string; }; /** @@ -21,108 +21,105 @@ export type ProbeTwitchResult = { * to the chat server and verify the bot's username. */ export async function probeTwitch( - account: TwitchAccountConfig, - timeoutMs: number, + account: TwitchAccountConfig, + timeoutMs: number, ): Promise { - const started = Date.now(); + const started = Date.now(); - if (!account.token || !account.username) { - return { - ok: false, - error: "missing credentials (token, username)", - username: account.username, - elapsedMs: Date.now() - started, - }; - } + 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(); + const rawToken = account.token.trim(); - let client: ChatClient | undefined; + let client: ChatClient | undefined; - try { - // Create auth provider with the token - const authProvider = new StaticAuthProvider( - account.clientId ?? "", - rawToken, - ); + try { + // Create auth provider with the token + const authProvider = new StaticAuthProvider(account.clientId ?? "", rawToken); - // Create chat client - client = new ChatClient({ - authProvider, - }); + // Create chat client + client = new ChatClient({ + authProvider, + }); - // Create a promise that resolves when connected - const connectionPromise = new Promise((resolve, reject) => { - let settled = false; - let connectListener: ReturnType | undefined; - let disconnectListener: ReturnType | undefined; - let authFailListener: ReturnType | undefined; + // Create a promise that resolves when connected + const connectionPromise = new Promise((resolve, reject) => { + let settled = false; + let connectListener: ReturnType | undefined; + let disconnectListener: ReturnType | undefined; + let authFailListener: ReturnType | undefined; - const cleanup = () => { - if (settled) return; - settled = true; - // Remove all listeners - connectListener?.unbind(); - disconnectListener?.unbind(); - authFailListener?.unbind(); - }; + const cleanup = () => { + if (settled) return; + settled = true; + // Remove all listeners + connectListener?.unbind(); + disconnectListener?.unbind(); + authFailListener?.unbind(); + }; - // Success: connection established - connectListener = client?.onConnect(() => { - cleanup(); - resolve(); - }); + // Success: connection established + connectListener = client?.onConnect(() => { + cleanup(); + resolve(); + }); - // Failure: disconnected (e.g., auth failed) - disconnectListener = client?.onDisconnect((_manually, reason) => { - cleanup(); - reject(reason || new Error("Disconnected")); - }); + // Failure: disconnected (e.g., auth failed) + disconnectListener = client?.onDisconnect((_manually, reason) => { + cleanup(); + reject(reason || new Error("Disconnected")); + }); - // Failure: authentication failed - authFailListener = client?.onAuthenticationFailure(() => { - cleanup(); - reject(new Error("Authentication failed")); - }); - }); + // Failure: authentication failed + authFailListener = client?.onAuthenticationFailure(() => { + cleanup(); + reject(new Error("Authentication failed")); + }); + }); - // Create timeout promise - const timeout = new Promise((_, reject) => { - setTimeout(() => reject(new Error(`timeout after ${timeoutMs}ms`)), timeoutMs); - }); + // Create timeout promise + const timeout = new Promise((_, reject) => { + setTimeout(() => reject(new Error(`timeout after ${timeoutMs}ms`)), timeoutMs); + }); - // Set up listeners BEFORE connecting, then race against timeout - client.connect(); - await Promise.race([connectionPromise, timeout]); + // Set up listeners BEFORE connecting, then race against timeout + client.connect(); + await Promise.race([connectionPromise, timeout]); - // Clean up connection before returning - client.quit(); - client = undefined; + // Clean up connection before returning + client.quit(); + client = undefined; - // 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 - } - } - } + // 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 index 96021c457..268688795 100644 --- a/extensions/twitch/src/resolver.ts +++ b/extensions/twitch/src/resolver.ts @@ -65,10 +65,7 @@ export async function resolveTwitchTargets( const normalizedToken = normalizeToken(account.token); // Create auth provider and API client - const authProvider = new StaticAuthProvider( - account.clientId, - normalizedToken, - ); + const authProvider = new StaticAuthProvider(account.clientId, normalizedToken); const apiClient = new ApiClient({ authProvider }); const results: ChannelResolveResult[] = []; @@ -121,14 +118,9 @@ export async function resolveTwitchTargets( resolved: true, id: user.id, name: user.name, - note: - user.displayName !== user.name - ? `display: ${user.displayName}` - : undefined, + note: user.displayName !== user.name ? `display: ${user.displayName}` : undefined, }); - log.debug( - `Resolved username ${normalized} -> ${user.id} (${user.name})`, - ); + log.debug(`Resolved username ${normalized} -> ${user.id} (${user.name})`); } else { results.push({ input, @@ -139,8 +131,7 @@ export async function resolveTwitchTargets( } } } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); + const errorMessage = error instanceof Error ? error.message : String(error); results.push({ input, resolved: false, diff --git a/extensions/twitch/src/send.test.ts b/extensions/twitch/src/send.test.ts index 20050da70..d1a76cfb9 100644 --- a/extensions/twitch/src/send.test.ts +++ b/extensions/twitch/src/send.test.ts @@ -16,277 +16,275 @@ import type { ClawdbotConfig } from "clawdbot/plugin-sdk"; // Mock dependencies vi.mock("./config.js", () => ({ - DEFAULT_ACCOUNT_ID: "default", - getAccountConfig: vi.fn(), + DEFAULT_ACCOUNT_ID: "default", + getAccountConfig: vi.fn(), })); vi.mock("./utils/twitch.js", () => ({ - generateMessageId: vi.fn(() => "test-msg-id"), - isAccountConfigured: vi.fn(() => true), - normalizeTwitchChannel: (channel: string) => channel.toLowerCase().replace(/^#/, ""), + generateMessageId: vi.fn(() => "test-msg-id"), + isAccountConfigured: vi.fn(() => true), + normalizeTwitchChannel: (channel: string) => channel.toLowerCase().replace(/^#/, ""), })); vi.mock("./utils/markdown.js", () => ({ - stripMarkdownForTwitch: vi.fn((text: string) => text.replace(/\*\*/g, "")), + stripMarkdownForTwitch: vi.fn((text: string) => text.replace(/\*\*/g, "")), })); vi.mock("./client-manager-registry.js", () => ({ - getClientManager: vi.fn(), + getClientManager: vi.fn(), })); describe("send", () => { - const mockLogger = { - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - debug: vi.fn(), - }; + const mockLogger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }; - const mockAccount = { - username: "testbot", - token: "oauth:test123", - clientId: "test-client-id", - channel: "#testchannel", - }; + const mockAccount = { + username: "testbot", + token: "oauth:test123", + clientId: "test-client-id", + channel: "#testchannel", + }; - const mockConfig = { - channels: { - twitch: { - accounts: { - default: mockAccount, - }, - }, - }, - } as unknown as ClawdbotConfig; + const mockConfig = { + channels: { + twitch: { + accounts: { + default: mockAccount, + }, + }, + }, + } as unknown as ClawdbotConfig; - beforeEach(() => { - vi.clearAllMocks(); - }); + beforeEach(() => { + vi.clearAllMocks(); + }); - afterEach(() => { - vi.restoreAllMocks(); - }); + afterEach(() => { + vi.restoreAllMocks(); + }); - describe("sendMessageTwitchInternal", () => { - it("should send a message successfully", async () => { - const { getAccountConfig } = await import("./config.js"); - const { getClientManager } = await import("./client-manager-registry.js"); - const { stripMarkdownForTwitch } = await import("./utils/markdown.js"); + describe("sendMessageTwitchInternal", () => { + it("should send a message successfully", async () => { + const { getAccountConfig } = await import("./config.js"); + const { getClientManager } = await import("./client-manager-registry.js"); + const { stripMarkdownForTwitch } = await import("./utils/markdown.js"); - vi.mocked(getAccountConfig).mockReturnValue(mockAccount); - vi.mocked(getClientManager).mockReturnValue({ - sendMessage: vi.fn().mockResolvedValue({ - ok: true, - messageId: "twitch-msg-123", - }), - } as ReturnType); - vi.mocked(stripMarkdownForTwitch).mockImplementation((text) => text); + vi.mocked(getAccountConfig).mockReturnValue(mockAccount); + vi.mocked(getClientManager).mockReturnValue({ + sendMessage: vi.fn().mockResolvedValue({ + ok: true, + messageId: "twitch-msg-123", + }), + } as ReturnType); + vi.mocked(stripMarkdownForTwitch).mockImplementation((text) => text); - const result = await sendMessageTwitchInternal( - "#testchannel", - "Hello Twitch!", - mockConfig, - "default", - false, - mockLogger as unknown as Console, - ); + const result = await sendMessageTwitchInternal( + "#testchannel", + "Hello Twitch!", + mockConfig, + "default", + false, + mockLogger as unknown as Console, + ); - expect(result.ok).toBe(true); - expect(result.messageId).toBe("twitch-msg-123"); - }); + expect(result.ok).toBe(true); + expect(result.messageId).toBe("twitch-msg-123"); + }); - it("should strip markdown when enabled", async () => { - const { getAccountConfig } = await import("./config.js"); - const { getClientManager } = await import("./client-manager-registry.js"); - const { stripMarkdownForTwitch } = await import("./utils/markdown.js"); + it("should strip markdown when enabled", async () => { + const { getAccountConfig } = await import("./config.js"); + const { getClientManager } = await import("./client-manager-registry.js"); + const { stripMarkdownForTwitch } = await import("./utils/markdown.js"); - vi.mocked(getAccountConfig).mockReturnValue(mockAccount); - vi.mocked(getClientManager).mockReturnValue({ - sendMessage: vi.fn().mockResolvedValue({ - ok: true, - messageId: "twitch-msg-456", - }), - } as ReturnType); - vi.mocked(stripMarkdownForTwitch).mockImplementation((text) => - text.replace(/\*\*/g, ""), - ); + vi.mocked(getAccountConfig).mockReturnValue(mockAccount); + vi.mocked(getClientManager).mockReturnValue({ + sendMessage: vi.fn().mockResolvedValue({ + ok: true, + messageId: "twitch-msg-456", + }), + } as ReturnType); + vi.mocked(stripMarkdownForTwitch).mockImplementation((text) => text.replace(/\*\*/g, "")); - await sendMessageTwitchInternal( - "#testchannel", - "**Bold** text", - mockConfig, - "default", - true, - mockLogger as unknown as Console, - ); + await sendMessageTwitchInternal( + "#testchannel", + "**Bold** text", + mockConfig, + "default", + true, + mockLogger as unknown as Console, + ); - expect(stripMarkdownForTwitch).toHaveBeenCalledWith("**Bold** text"); - }); + expect(stripMarkdownForTwitch).toHaveBeenCalledWith("**Bold** text"); + }); - it("should return error when account not found", async () => { - const { getAccountConfig } = await import("./config.js"); + it("should return error when account not found", async () => { + const { getAccountConfig } = await import("./config.js"); - vi.mocked(getAccountConfig).mockReturnValue(null); + vi.mocked(getAccountConfig).mockReturnValue(null); - const result = await sendMessageTwitchInternal( - "#testchannel", - "Hello!", - mockConfig, - "nonexistent", - false, - mockLogger as unknown as Console, - ); + const result = await sendMessageTwitchInternal( + "#testchannel", + "Hello!", + mockConfig, + "nonexistent", + false, + mockLogger as unknown as Console, + ); - expect(result.ok).toBe(false); - expect(result.error).toContain("Account not found: nonexistent"); - }); + expect(result.ok).toBe(false); + expect(result.error).toContain("Account not found: nonexistent"); + }); - it("should return error when account not configured", async () => { - const { getAccountConfig } = await import("./config.js"); - const { isAccountConfigured } = await import("./utils/twitch.js"); + it("should return error when account not configured", async () => { + const { getAccountConfig } = await import("./config.js"); + const { isAccountConfigured } = await import("./utils/twitch.js"); - vi.mocked(getAccountConfig).mockReturnValue(mockAccount); - vi.mocked(isAccountConfigured).mockReturnValue(false); + vi.mocked(getAccountConfig).mockReturnValue(mockAccount); + vi.mocked(isAccountConfigured).mockReturnValue(false); - const result = await sendMessageTwitchInternal( - "#testchannel", - "Hello!", - mockConfig, - "default", - false, - mockLogger as unknown as Console, - ); + const result = await sendMessageTwitchInternal( + "#testchannel", + "Hello!", + mockConfig, + "default", + false, + mockLogger as unknown as Console, + ); - expect(result.ok).toBe(false); - expect(result.error).toContain("not properly configured"); - }); + expect(result.ok).toBe(false); + expect(result.error).toContain("not properly configured"); + }); - it("should return error when no channel specified", async () => { - const { getAccountConfig } = await import("./config.js"); - const { isAccountConfigured } = await import("./utils/twitch.js"); + it("should return error when no channel specified", async () => { + const { getAccountConfig } = await import("./config.js"); + const { isAccountConfigured } = await import("./utils/twitch.js"); - // Set both channel and username to undefined to trigger the error - const accountWithoutChannelOrUsername = { - ...mockAccount, - channel: undefined, - username: undefined, - }; - vi.mocked(getAccountConfig).mockReturnValue(accountWithoutChannelOrUsername); - vi.mocked(isAccountConfigured).mockReturnValue(true); + // Set both channel and username to undefined to trigger the error + const accountWithoutChannelOrUsername = { + ...mockAccount, + channel: undefined, + username: undefined, + }; + vi.mocked(getAccountConfig).mockReturnValue(accountWithoutChannelOrUsername); + vi.mocked(isAccountConfigured).mockReturnValue(true); - const result = await sendMessageTwitchInternal( - "", - "Hello!", - mockConfig, - "default", - false, - mockLogger as unknown as Console, - ); + const result = await sendMessageTwitchInternal( + "", + "Hello!", + mockConfig, + "default", + false, + mockLogger as unknown as Console, + ); - expect(result.ok).toBe(false); - expect(result.error).toContain("No channel specified"); - }); + expect(result.ok).toBe(false); + expect(result.error).toContain("No channel specified"); + }); - it("should skip sending empty message after markdown stripping", async () => { - const { getAccountConfig } = await import("./config.js"); - const { isAccountConfigured } = await import("./utils/twitch.js"); - const { stripMarkdownForTwitch } = await import("./utils/markdown.js"); + it("should skip sending empty message after markdown stripping", async () => { + const { getAccountConfig } = await import("./config.js"); + const { isAccountConfigured } = await import("./utils/twitch.js"); + const { stripMarkdownForTwitch } = await import("./utils/markdown.js"); - vi.mocked(getAccountConfig).mockReturnValue(mockAccount); - vi.mocked(isAccountConfigured).mockReturnValue(true); - vi.mocked(stripMarkdownForTwitch).mockReturnValue(""); + vi.mocked(getAccountConfig).mockReturnValue(mockAccount); + vi.mocked(isAccountConfigured).mockReturnValue(true); + vi.mocked(stripMarkdownForTwitch).mockReturnValue(""); - const result = await sendMessageTwitchInternal( - "#testchannel", - "**Only markdown**", - mockConfig, - "default", - true, - mockLogger as unknown as Console, - ); + const result = await sendMessageTwitchInternal( + "#testchannel", + "**Only markdown**", + mockConfig, + "default", + true, + mockLogger as unknown as Console, + ); - expect(result.ok).toBe(true); - expect(result.messageId).toBe("skipped"); - }); + expect(result.ok).toBe(true); + expect(result.messageId).toBe("skipped"); + }); - it("should return error when client manager not found", async () => { - const { getAccountConfig } = await import("./config.js"); - const { isAccountConfigured } = await import("./utils/twitch.js"); - const { getClientManager } = await import("./client-manager-registry.js"); + it("should return error when client manager not found", async () => { + const { getAccountConfig } = await import("./config.js"); + const { isAccountConfigured } = await import("./utils/twitch.js"); + const { getClientManager } = await import("./client-manager-registry.js"); - vi.mocked(getAccountConfig).mockReturnValue(mockAccount); - vi.mocked(isAccountConfigured).mockReturnValue(true); - vi.mocked(getClientManager).mockReturnValue(undefined); + vi.mocked(getAccountConfig).mockReturnValue(mockAccount); + vi.mocked(isAccountConfigured).mockReturnValue(true); + vi.mocked(getClientManager).mockReturnValue(undefined); - const result = await sendMessageTwitchInternal( - "#testchannel", - "Hello!", - mockConfig, - "default", - false, - mockLogger as unknown as Console, - ); + const result = await sendMessageTwitchInternal( + "#testchannel", + "Hello!", + mockConfig, + "default", + false, + mockLogger as unknown as Console, + ); - expect(result.ok).toBe(false); - expect(result.error).toContain("Client manager not found"); - }); + expect(result.ok).toBe(false); + expect(result.error).toContain("Client manager not found"); + }); - it("should handle send errors gracefully", async () => { - const { getAccountConfig } = await import("./config.js"); - const { isAccountConfigured } = await import("./utils/twitch.js"); - const { getClientManager } = await import("./client-manager-registry.js"); + it("should handle send errors gracefully", async () => { + const { getAccountConfig } = await import("./config.js"); + const { isAccountConfigured } = await import("./utils/twitch.js"); + const { getClientManager } = await import("./client-manager-registry.js"); - vi.mocked(getAccountConfig).mockReturnValue(mockAccount); - vi.mocked(isAccountConfigured).mockReturnValue(true); - vi.mocked(getClientManager).mockReturnValue({ - sendMessage: vi.fn().mockRejectedValue(new Error("Connection lost")), - } as ReturnType); + vi.mocked(getAccountConfig).mockReturnValue(mockAccount); + vi.mocked(isAccountConfigured).mockReturnValue(true); + vi.mocked(getClientManager).mockReturnValue({ + sendMessage: vi.fn().mockRejectedValue(new Error("Connection lost")), + } as ReturnType); - const result = await sendMessageTwitchInternal( - "#testchannel", - "Hello!", - mockConfig, - "default", - false, - mockLogger as unknown as Console, - ); + const result = await sendMessageTwitchInternal( + "#testchannel", + "Hello!", + mockConfig, + "default", + false, + mockLogger as unknown as Console, + ); - expect(result.ok).toBe(false); - expect(result.error).toBe("Connection lost"); - expect(mockLogger.error).toHaveBeenCalled(); - }); + expect(result.ok).toBe(false); + expect(result.error).toBe("Connection lost"); + expect(mockLogger.error).toHaveBeenCalled(); + }); - it("should use account channel when channel parameter is empty", async () => { - const { getAccountConfig } = await import("./config.js"); - const { isAccountConfigured } = await import("./utils/twitch.js"); - const { getClientManager } = await import("./client-manager-registry.js"); + it("should use account channel when channel parameter is empty", async () => { + const { getAccountConfig } = await import("./config.js"); + const { isAccountConfigured } = await import("./utils/twitch.js"); + const { getClientManager } = await import("./client-manager-registry.js"); - vi.mocked(getAccountConfig).mockReturnValue(mockAccount); - vi.mocked(isAccountConfigured).mockReturnValue(true); - const mockSend = vi.fn().mockResolvedValue({ - ok: true, - messageId: "twitch-msg-789", - }); - vi.mocked(getClientManager).mockReturnValue({ - sendMessage: mockSend, - } as ReturnType); + vi.mocked(getAccountConfig).mockReturnValue(mockAccount); + vi.mocked(isAccountConfigured).mockReturnValue(true); + const mockSend = vi.fn().mockResolvedValue({ + ok: true, + messageId: "twitch-msg-789", + }); + vi.mocked(getClientManager).mockReturnValue({ + sendMessage: mockSend, + } as ReturnType); - await sendMessageTwitchInternal( - "", - "Hello!", - mockConfig, - "default", - false, - mockLogger as unknown as Console, - ); + await sendMessageTwitchInternal( + "", + "Hello!", + mockConfig, + "default", + false, + mockLogger as unknown as Console, + ); - expect(mockSend).toHaveBeenCalledWith( - mockAccount, - "testchannel", // normalized account channel - "Hello!", - mockConfig, - "default", - ); - }); - }); + expect(mockSend).toHaveBeenCalledWith( + mockAccount, + "testchannel", // normalized account channel + "Hello!", + mockConfig, + "default", + ); + }); + }); }); diff --git a/extensions/twitch/src/send.ts b/extensions/twitch/src/send.ts index 3922a86b0..10128b206 100644 --- a/extensions/twitch/src/send.ts +++ b/extensions/twitch/src/send.ts @@ -9,22 +9,18 @@ import { DEFAULT_ACCOUNT_ID, getAccountConfig } from "./config.js"; import { getClientManager as getRegistryClientManager } from "./client-manager-registry.js"; import type { ClawdbotConfig } from "clawdbot/plugin-sdk"; import { stripMarkdownForTwitch } from "./utils/markdown.js"; -import { - generateMessageId, - isAccountConfigured, - normalizeTwitchChannel, -} from "./utils/twitch.js"; +import { generateMessageId, isAccountConfigured, normalizeTwitchChannel } from "./utils/twitch.js"; /** * Result from sending a message to Twitch. */ 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; + /** Whether the send was successful */ + ok: boolean; + /** The message ID (generated for tracking) */ + messageId: string; + /** Error message if the send failed */ + error?: string; } /** @@ -52,89 +48,89 @@ export interface SendMessageResult { * ); */ export async function sendMessageTwitchInternal( - channel: string, - text: string, - cfg: ClawdbotConfig, - accountId: string = DEFAULT_ACCOUNT_ID, - stripMarkdown: boolean = true, - logger: Console = console, + channel: string, + text: string, + cfg: ClawdbotConfig, + 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"}`, - }; - } + // 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`, - }; - } + 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", - }; - } + // 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", - }; - } + // Strip markdown if enabled + const cleanedText = stripMarkdown ? stripMarkdownForTwitch(text) : text; + if (!cleanedText) { + return { + ok: true, + messageId: "skipped", + }; + } - // Get client manager from registry - const clientManager = getRegistryClientManager(accountId); - if (!clientManager) { - return { - ok: false, - messageId: generateMessageId(), - error: `Client manager not found for account: ${accountId}. Please start the Twitch gateway first.`, - }; - } + // Get client manager from registry + const clientManager = getRegistryClientManager(accountId); + if (!clientManager) { + return { + ok: false, + messageId: generateMessageId(), + error: `Client manager not found for account: ${accountId}. Please start the Twitch gateway first.`, + }; + } - try { - const result = await clientManager.sendMessage( - account, - normalizeTwitchChannel(normalizedChannel), - cleanedText, - cfg, - accountId, - ); + try { + const result = await clientManager.sendMessage( + account, + normalizeTwitchChannel(normalizedChannel), + cleanedText, + cfg, + accountId, + ); - if (!result.ok) { - return { - ok: false, - messageId: result.messageId ?? generateMessageId(), - error: result.error ?? "Send failed", - }; - } + 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, - }; - } + 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, + }; + } } diff --git a/extensions/twitch/src/status.test.ts b/extensions/twitch/src/status.test.ts index 966b50bf3..66090ef23 100644 --- a/extensions/twitch/src/status.test.ts +++ b/extensions/twitch/src/status.test.ts @@ -15,291 +15,276 @@ import { collectTwitchStatusIssues } from "./status.js"; import type { ChannelAccountSnapshot } from "./types.js"; describe("status", () => { - describe("collectTwitchStatusIssues", () => { - it("should detect unconfigured accounts", () => { - const snapshots: ChannelAccountSnapshot[] = [ - { - accountId: "default", - configured: false, - enabled: true, - running: false, - }, - ]; + describe("collectTwitchStatusIssues", () => { + it("should detect unconfigured accounts", () => { + const snapshots: ChannelAccountSnapshot[] = [ + { + accountId: "default", + configured: false, + enabled: true, + running: false, + }, + ]; - const issues = collectTwitchStatusIssues(snapshots); + const issues = collectTwitchStatusIssues(snapshots); - expect(issues.length).toBeGreaterThan(0); - expect(issues[0]?.kind).toBe("config"); - expect(issues[0]?.message).toContain("not properly configured"); - }); + expect(issues.length).toBeGreaterThan(0); + expect(issues[0]?.kind).toBe("config"); + expect(issues[0]?.message).toContain("not properly configured"); + }); - it("should detect disabled accounts", () => { - const snapshots: ChannelAccountSnapshot[] = [ - { - accountId: "default", - configured: true, - enabled: false, - running: false, - }, - ]; + it("should detect disabled accounts", () => { + const snapshots: ChannelAccountSnapshot[] = [ + { + accountId: "default", + configured: true, + enabled: false, + running: false, + }, + ]; - const issues = collectTwitchStatusIssues(snapshots); + const issues = collectTwitchStatusIssues(snapshots); - expect(issues.length).toBeGreaterThan(0); - const disabledIssue = issues.find((i) => i.message.includes("disabled")); - expect(disabledIssue).toBeDefined(); - }); + expect(issues.length).toBeGreaterThan(0); + const disabledIssue = issues.find((i) => i.message.includes("disabled")); + expect(disabledIssue).toBeDefined(); + }); - it("should detect missing clientId when account configured", () => { - const snapshots: ChannelAccountSnapshot[] = [ - { - accountId: "default", - configured: true, - enabled: true, - running: false, - }, - ]; + it("should detect missing clientId when account configured", () => { + const snapshots: ChannelAccountSnapshot[] = [ + { + accountId: "default", + configured: true, + enabled: true, + running: false, + }, + ]; - const mockCfg = { - channels: { - twitch: { - accounts: { - default: { - username: "testbot", - token: "oauth:test123", - // clientId missing - }, - }, - }, - }, - }; + const mockCfg = { + channels: { + twitch: { + accounts: { + default: { + username: "testbot", + token: "oauth:test123", + // clientId missing + }, + }, + }, + }, + }; - const issues = collectTwitchStatusIssues( - snapshots, - () => mockCfg as never, - ); + const issues = collectTwitchStatusIssues(snapshots, () => mockCfg as never); - const clientIdIssue = issues.find((i) => i.message.includes("client ID")); - expect(clientIdIssue).toBeDefined(); - }); + const clientIdIssue = issues.find((i) => i.message.includes("client ID")); + expect(clientIdIssue).toBeDefined(); + }); - it("should warn about oauth: prefix in token", () => { - const snapshots: ChannelAccountSnapshot[] = [ - { - accountId: "default", - configured: true, - enabled: true, - running: false, - }, - ]; + it("should warn about oauth: prefix in token", () => { + const snapshots: ChannelAccountSnapshot[] = [ + { + accountId: "default", + configured: true, + enabled: true, + running: false, + }, + ]; - const mockCfg = { - channels: { - twitch: { - accounts: { - default: { - username: "testbot", - token: "oauth:test123", // has prefix - clientId: "test-id", - }, - }, - }, - }, - }; + const mockCfg = { + channels: { + twitch: { + accounts: { + default: { + username: "testbot", + token: "oauth:test123", // has prefix + clientId: "test-id", + }, + }, + }, + }, + }; - const issues = collectTwitchStatusIssues( - snapshots, - () => mockCfg as never, - ); + const issues = collectTwitchStatusIssues(snapshots, () => mockCfg as never); - const prefixIssue = issues.find((i) => i.message.includes("oauth:")); - expect(prefixIssue).toBeDefined(); - expect(prefixIssue?.kind).toBe("config"); - }); + const prefixIssue = issues.find((i) => i.message.includes("oauth:")); + expect(prefixIssue).toBeDefined(); + expect(prefixIssue?.kind).toBe("config"); + }); - it("should detect clientSecret without refreshToken", () => { - const snapshots: ChannelAccountSnapshot[] = [ - { - accountId: "default", - configured: true, - enabled: true, - running: false, - }, - ]; + it("should detect clientSecret without refreshToken", () => { + const snapshots: ChannelAccountSnapshot[] = [ + { + accountId: "default", + configured: true, + enabled: true, + running: false, + }, + ]; - const mockCfg = { - channels: { - twitch: { - accounts: { - default: { - username: "testbot", - token: "oauth:test123", - clientId: "test-id", - clientSecret: "secret123", - // refreshToken missing - }, - }, - }, - }, - }; + const mockCfg = { + channels: { + twitch: { + accounts: { + default: { + username: "testbot", + token: "oauth:test123", + clientId: "test-id", + clientSecret: "secret123", + // refreshToken missing + }, + }, + }, + }, + }; - const issues = collectTwitchStatusIssues( - snapshots, - () => mockCfg as never, - ); + const issues = collectTwitchStatusIssues(snapshots, () => mockCfg as never); - const secretIssue = issues.find((i) => i.message.includes("clientSecret")); - expect(secretIssue).toBeDefined(); - }); + const secretIssue = issues.find((i) => i.message.includes("clientSecret")); + expect(secretIssue).toBeDefined(); + }); - it("should detect empty allowFrom array", () => { - const snapshots: ChannelAccountSnapshot[] = [ - { - accountId: "default", - configured: true, - enabled: true, - running: false, - }, - ]; + it("should detect empty allowFrom array", () => { + const snapshots: ChannelAccountSnapshot[] = [ + { + accountId: "default", + configured: true, + enabled: true, + running: false, + }, + ]; - const mockCfg = { - channels: { - twitch: { - accounts: { - default: { - username: "testbot", - token: "test123", - clientId: "test-id", - allowFrom: [], // empty array - }, - }, - }, - }, - }; + const mockCfg = { + channels: { + twitch: { + accounts: { + default: { + username: "testbot", + token: "test123", + clientId: "test-id", + allowFrom: [], // empty array + }, + }, + }, + }, + }; - const issues = collectTwitchStatusIssues( - snapshots, - () => mockCfg as never, - ); + const issues = collectTwitchStatusIssues(snapshots, () => mockCfg as never); - const allowFromIssue = issues.find((i) => i.message.includes("allowFrom")); - expect(allowFromIssue).toBeDefined(); - }); + const allowFromIssue = issues.find((i) => i.message.includes("allowFrom")); + expect(allowFromIssue).toBeDefined(); + }); - it("should detect allowedRoles 'all' with allowFrom conflict", () => { - const snapshots: ChannelAccountSnapshot[] = [ - { - accountId: "default", - configured: true, - enabled: true, - running: false, - }, - ]; + it("should detect allowedRoles 'all' with allowFrom conflict", () => { + const snapshots: ChannelAccountSnapshot[] = [ + { + accountId: "default", + configured: true, + enabled: true, + running: false, + }, + ]; - const mockCfg = { - channels: { - twitch: { - accounts: { - default: { - username: "testbot", - token: "test123", - clientId: "test-id", - allowedRoles: ["all"], - allowFrom: ["123456"], // conflict! - }, - }, - }, - }, - }; + const mockCfg = { + channels: { + twitch: { + accounts: { + default: { + username: "testbot", + token: "test123", + clientId: "test-id", + allowedRoles: ["all"], + allowFrom: ["123456"], // conflict! + }, + }, + }, + }, + }; - const issues = collectTwitchStatusIssues( - snapshots, - () => mockCfg as never, - ); + const issues = collectTwitchStatusIssues(snapshots, () => mockCfg as never); - const conflictIssue = issues.find((i) => i.kind === "intent"); - expect(conflictIssue).toBeDefined(); - expect(conflictIssue?.message).toContain("allowedRoles is set to 'all'"); - }); + const conflictIssue = issues.find((i) => i.kind === "intent"); + expect(conflictIssue).toBeDefined(); + expect(conflictIssue?.message).toContain("allowedRoles is set to 'all'"); + }); - it("should detect runtime errors", () => { - const snapshots: ChannelAccountSnapshot[] = [ - { - accountId: "default", - configured: true, - enabled: true, - running: false, - lastError: "Connection timeout", - }, - ]; + it("should detect runtime errors", () => { + const snapshots: ChannelAccountSnapshot[] = [ + { + accountId: "default", + configured: true, + enabled: true, + running: false, + lastError: "Connection timeout", + }, + ]; - const issues = collectTwitchStatusIssues(snapshots); + const issues = collectTwitchStatusIssues(snapshots); - const runtimeIssue = issues.find((i) => i.kind === "runtime"); - expect(runtimeIssue).toBeDefined(); - expect(runtimeIssue?.message).toContain("Connection timeout"); - }); + const runtimeIssue = issues.find((i) => i.kind === "runtime"); + expect(runtimeIssue).toBeDefined(); + expect(runtimeIssue?.message).toContain("Connection timeout"); + }); - it("should detect accounts that never connected", () => { - const snapshots: ChannelAccountSnapshot[] = [ - { - accountId: "default", - configured: true, - enabled: true, - running: false, - lastStartAt: undefined, - lastInboundAt: undefined, - lastOutboundAt: undefined, - }, - ]; + it("should detect accounts that never connected", () => { + const snapshots: ChannelAccountSnapshot[] = [ + { + accountId: "default", + configured: true, + enabled: true, + running: false, + lastStartAt: undefined, + lastInboundAt: undefined, + lastOutboundAt: undefined, + }, + ]; - const issues = collectTwitchStatusIssues(snapshots); + const issues = collectTwitchStatusIssues(snapshots); - const neverConnectedIssue = issues.find((i) => - i.message.includes("never connected successfully"), - ); - expect(neverConnectedIssue).toBeDefined(); - }); + const neverConnectedIssue = issues.find((i) => + i.message.includes("never connected successfully"), + ); + expect(neverConnectedIssue).toBeDefined(); + }); - it("should detect long-running connections", () => { - const oldDate = Date.now() - 8 * 24 * 60 * 60 * 1000; // 8 days ago + it("should detect long-running connections", () => { + const oldDate = Date.now() - 8 * 24 * 60 * 60 * 1000; // 8 days ago - const snapshots: ChannelAccountSnapshot[] = [ - { - accountId: "default", - configured: true, - enabled: true, - running: true, - lastStartAt: oldDate, - }, - ]; + const snapshots: ChannelAccountSnapshot[] = [ + { + accountId: "default", + configured: true, + enabled: true, + running: true, + lastStartAt: oldDate, + }, + ]; - const issues = collectTwitchStatusIssues(snapshots); + const issues = collectTwitchStatusIssues(snapshots); - const uptimeIssue = issues.find((i) => i.message.includes("running for")); - expect(uptimeIssue).toBeDefined(); - }); + const uptimeIssue = issues.find((i) => i.message.includes("running for")); + expect(uptimeIssue).toBeDefined(); + }); - it("should handle empty snapshots array", () => { - const issues = collectTwitchStatusIssues([]); + it("should handle empty snapshots array", () => { + const issues = collectTwitchStatusIssues([]); - expect(issues).toEqual([]); - }); + expect(issues).toEqual([]); + }); - it("should skip non-Twitch accounts gracefully", () => { - const snapshots: ChannelAccountSnapshot[] = [ - { - accountId: undefined, - configured: false, - enabled: true, - running: false, - }, - ]; + it("should skip non-Twitch accounts gracefully", () => { + const snapshots: ChannelAccountSnapshot[] = [ + { + accountId: undefined, + configured: false, + enabled: true, + running: false, + }, + ]; - const issues = collectTwitchStatusIssues(snapshots); + const issues = collectTwitchStatusIssues(snapshots); - // Should not crash, may return empty or minimal issues - expect(Array.isArray(issues)).toBe(true); - }); - }); + // Should not crash, may return empty or minimal issues + expect(Array.isArray(issues)).toBe(true); + }); + }); }); diff --git a/extensions/twitch/src/status.ts b/extensions/twitch/src/status.ts index f77f99e67..fc1ae0d89 100644 --- a/extensions/twitch/src/status.ts +++ b/extensions/twitch/src/status.ts @@ -26,161 +26,159 @@ import { isAccountConfigured } from "./utils/twitch.js"; * } */ export function collectTwitchStatusIssues( - accounts: ChannelAccountSnapshot[], - getCfg?: () => unknown, + accounts: ChannelAccountSnapshot[], + getCfg?: () => unknown, ): ChannelStatusIssue[] { - const issues: ChannelStatusIssue[] = []; + const issues: ChannelStatusIssue[] = []; - for (const entry of accounts) { - const accountId = entry.accountId; + for (const entry of accounts) { + const accountId = entry.accountId; - // Skip if not a Twitch account - if (!accountId) continue; + // 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 - } - } + // 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 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 - } + // 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 + } - // Check 3: Missing clientId (check if username/token present but no clientId) - if (account && account.username && account.token && !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 3: Missing clientId (check if username/token present but no clientId) + if (account && account.username && account.token && !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)", + }); + } - // Checks that require account config - if (account && isAccountConfigured(account)) { + // Checks that require account config + if (account && isAccountConfigured(account)) { + // Check 4: Token format warning (normalized, but may indicate config issue) + if (account.token?.startsWith("oauth:")) { + issues.push({ + 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 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 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 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 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 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 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.", + }); + } + } + } - // 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; + return issues; } diff --git a/extensions/twitch/src/token.test.ts b/extensions/twitch/src/token.test.ts index ff6fe3b07..d15a05490 100644 --- a/extensions/twitch/src/token.test.ts +++ b/extensions/twitch/src/token.test.ts @@ -13,156 +13,156 @@ import { resolveTwitchToken, type TwitchTokenSource } from "./token.js"; import type { ClawdbotConfig } from "clawdbot/plugin-sdk"; describe("token", () => { - const mockConfig = { - channels: { - twitch: { - accounts: { - default: { - username: "testbot", - token: "oauth:config-token", - }, - other: { - username: "otherbot", - token: "oauth:other-token", - }, - }, - }, - }, - } as unknown as ClawdbotConfig; + const mockConfig = { + channels: { + twitch: { + accounts: { + default: { + username: "testbot", + token: "oauth:config-token", + }, + other: { + username: "otherbot", + token: "oauth:other-token", + }, + }, + }, + }, + } as unknown as ClawdbotConfig; - beforeEach(() => { - vi.clearAllMocks(); - }); + beforeEach(() => { + vi.clearAllMocks(); + }); - afterEach(() => { - vi.restoreAllMocks(); - delete process.env.CLAWDBOT_TWITCH_ACCESS_TOKEN; - }); + afterEach(() => { + vi.restoreAllMocks(); + delete process.env.CLAWDBOT_TWITCH_ACCESS_TOKEN; + }); - describe("resolveTwitchToken", () => { - it("should resolve token from config for default account", () => { - const result = resolveTwitchToken(mockConfig, { accountId: "default" }); + describe("resolveTwitchToken", () => { + it("should resolve token from config for default account", () => { + const result = resolveTwitchToken(mockConfig, { accountId: "default" }); - expect(result.token).toBe("oauth:config-token"); - expect(result.source).toBe("config"); - }); + expect(result.token).toBe("oauth:config-token"); + expect(result.source).toBe("config"); + }); - it("should resolve token from config for non-default account", () => { - const result = resolveTwitchToken(mockConfig, { accountId: "other" }); + it("should resolve token from config for non-default account", () => { + const result = resolveTwitchToken(mockConfig, { accountId: "other" }); - expect(result.token).toBe("oauth:other-token"); - expect(result.source).toBe("config"); - }); + expect(result.token).toBe("oauth:other-token"); + expect(result.source).toBe("config"); + }); - it("should prioritize config token over env var", () => { - process.env.CLAWDBOT_TWITCH_ACCESS_TOKEN = "oauth:env-token"; + it("should prioritize config token over env var", () => { + process.env.CLAWDBOT_TWITCH_ACCESS_TOKEN = "oauth:env-token"; - const result = resolveTwitchToken(mockConfig, { accountId: "default" }); + const result = resolveTwitchToken(mockConfig, { accountId: "default" }); - // Config token should be used even if env var exists - expect(result.token).toBe("oauth:config-token"); - expect(result.source).toBe("config"); - }); + // Config token should be used even if env var exists + expect(result.token).toBe("oauth:config-token"); + expect(result.source).toBe("config"); + }); - it("should use env var when config token is empty", () => { - process.env.CLAWDBOT_TWITCH_ACCESS_TOKEN = "oauth:env-token"; + it("should use env var when config token is empty", () => { + process.env.CLAWDBOT_TWITCH_ACCESS_TOKEN = "oauth:env-token"; - const configWithEmptyToken = { - channels: { - twitch: { - accounts: { - default: { - username: "testbot", - token: "", - }, - }, - }, - }, - } as unknown as ClawdbotConfig; + const configWithEmptyToken = { + channels: { + twitch: { + accounts: { + default: { + username: "testbot", + token: "", + }, + }, + }, + }, + } as unknown as ClawdbotConfig; - const result = resolveTwitchToken(configWithEmptyToken, { accountId: "default" }); + const result = resolveTwitchToken(configWithEmptyToken, { accountId: "default" }); - expect(result.token).toBe("oauth:env-token"); - expect(result.source).toBe("env"); - }); + expect(result.token).toBe("oauth:env-token"); + expect(result.source).toBe("env"); + }); - it("should return empty token when neither config nor env has token", () => { - const configWithoutToken = { - channels: { - twitch: { - accounts: { - default: { - username: "testbot", - token: "", - }, - }, - }, - }, - } as unknown as ClawdbotConfig; + it("should return empty token when neither config nor env has token", () => { + const configWithoutToken = { + channels: { + twitch: { + accounts: { + default: { + username: "testbot", + token: "", + }, + }, + }, + }, + } as unknown as ClawdbotConfig; - const result = resolveTwitchToken(configWithoutToken, { accountId: "default" }); + const result = resolveTwitchToken(configWithoutToken, { accountId: "default" }); - expect(result.token).toBe(""); - expect(result.source).toBe("none"); - }); + expect(result.token).toBe(""); + expect(result.source).toBe("none"); + }); - it("should not use env var for non-default accounts", () => { - process.env.CLAWDBOT_TWITCH_ACCESS_TOKEN = "oauth:env-token"; + it("should not use env var for non-default accounts", () => { + process.env.CLAWDBOT_TWITCH_ACCESS_TOKEN = "oauth:env-token"; - const configWithoutToken = { - channels: { - twitch: { - accounts: { - secondary: { - username: "secondary", - token: "", - }, - }, - }, - }, - } as unknown as ClawdbotConfig; + const configWithoutToken = { + channels: { + twitch: { + accounts: { + secondary: { + username: "secondary", + token: "", + }, + }, + }, + }, + } as unknown as ClawdbotConfig; - const result = resolveTwitchToken(configWithoutToken, { accountId: "secondary" }); + const result = resolveTwitchToken(configWithoutToken, { accountId: "secondary" }); - // Non-default accounts shouldn't use env var - expect(result.token).toBe(""); - expect(result.source).toBe("none"); - }); + // Non-default accounts shouldn't use env var + expect(result.token).toBe(""); + expect(result.source).toBe("none"); + }); - it("should handle missing account gracefully", () => { - const configWithoutAccount = { - channels: { - twitch: { - accounts: {}, - }, - }, - } as unknown as ClawdbotConfig; + it("should handle missing account gracefully", () => { + const configWithoutAccount = { + channels: { + twitch: { + accounts: {}, + }, + }, + } as unknown as ClawdbotConfig; - const result = resolveTwitchToken(configWithoutAccount, { accountId: "nonexistent" }); + const result = resolveTwitchToken(configWithoutAccount, { accountId: "nonexistent" }); - expect(result.token).toBe(""); - expect(result.source).toBe("none"); - }); + expect(result.token).toBe(""); + expect(result.source).toBe("none"); + }); - it("should handle missing Twitch config section", () => { - const configWithoutSection = { - channels: {}, - } as unknown as ClawdbotConfig; + it("should handle missing Twitch config section", () => { + const configWithoutSection = { + channels: {}, + } as unknown as ClawdbotConfig; - const result = resolveTwitchToken(configWithoutSection, { accountId: "default" }); + const result = resolveTwitchToken(configWithoutSection, { accountId: "default" }); - expect(result.token).toBe(""); - expect(result.source).toBe("none"); - }); - }); + expect(result.token).toBe(""); + expect(result.source).toBe("none"); + }); + }); - describe("TwitchTokenSource type", () => { - it("should have correct values", () => { - const sources: TwitchTokenSource[] = ["env", "config", "none"]; + describe("TwitchTokenSource type", () => { + it("should have correct values", () => { + const sources: TwitchTokenSource[] = ["env", "config", "none"]; - expect(sources).toContain("env"); - expect(sources).toContain("config"); - expect(sources).toContain("none"); - }); - }); + expect(sources).toContain("env"); + expect(sources).toContain("config"); + expect(sources).toContain("none"); + }); + }); }); diff --git a/extensions/twitch/src/token.ts b/extensions/twitch/src/token.ts index 5f22b52a1..c005ea375 100644 --- a/extensions/twitch/src/token.ts +++ b/extensions/twitch/src/token.ts @@ -57,9 +57,7 @@ export function resolveTwitchToken( // 2. Base config token (default account only) const allowEnv = accountId === DEFAULT_ACCOUNT_ID; - const configToken = allowEnv - ? normalizeTwitchToken(twitchCfg?.token ?? undefined) - : undefined; + const configToken = allowEnv ? normalizeTwitchToken(twitchCfg?.token ?? undefined) : undefined; if (configToken) { return { token: configToken, source: "config" }; } diff --git a/extensions/twitch/src/twitch-client.test.ts b/extensions/twitch/src/twitch-client.test.ts index 86deb56e2..998880e6b 100644 --- a/extensions/twitch/src/twitch-client.test.ts +++ b/extensions/twitch/src/twitch-client.test.ts @@ -11,11 +11,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { TwitchClientManager } from "./twitch-client.js"; -import type { - ChannelLogSink, - TwitchAccountConfig, - TwitchChatMessage, -} from "./types.js"; +import type { ChannelLogSink, TwitchAccountConfig, TwitchChatMessage } from "./types.js"; // Mock @twurple dependencies const mockConnect = vi.fn().mockResolvedValue(undefined); @@ -29,593 +25,558 @@ 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; - }, + ChatClient: class { + onMessage = mockOnMessage; + onWhisper = mockOnWhisper; + connect = mockConnect; + join = mockJoin; + say = mockSay; + quit = mockQuit; + }, })); const mockAuthProvider = { - constructor: vi.fn(), + constructor: vi.fn(), }; vi.mock("@twurple/auth", () => ({ - StaticAuthProvider: class { - constructor(...args: unknown[]) { - mockAuthProvider.constructor(...args); - } - }, - RefreshingAuthProvider: class { - addUserForToken = mockAddUserForToken; - onRefresh = mockOnRefresh; - onRefreshFailure = mockOnRefreshFailure; - }, + StaticAuthProvider: class { + constructor(...args: unknown[]) { + mockAuthProvider.constructor(...args); + } + }, + RefreshingAuthProvider: class { + addUserForToken = mockAddUserForToken; + onRefresh = mockOnRefresh; + onRefreshFailure = mockOnRefreshFailure; + }, })); // Mock token resolution - must be after @twurple/auth mock vi.mock("./token.js", () => ({ - resolveTwitchToken: vi.fn(() => ({ - token: "oauth:mock-token-from-tests", - source: "config" as const, - })), - DEFAULT_ACCOUNT_ID: "default", + resolveTwitchToken: vi.fn(() => ({ + token: "oauth:mock-token-from-tests", + source: "config" as const, + })), + DEFAULT_ACCOUNT_ID: "default", })); describe("TwitchClientManager", () => { - let manager: TwitchClientManager; - let mockLogger: ChannelLogSink; - - 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(async () => { - // Clear all mocks first - vi.clearAllMocks(); - - // Re-set up the default token mock implementation after clearing - const { resolveTwitchToken } = await import("./token.js"); - vi.mocked(resolveTwitchToken).mockReturnValue({ - token: "oauth:mock-token-from-tests", - source: "config" as const, - }); - - // 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", - }; - - // Override the mock to return a specific token for this test - const { resolveTwitchToken } = await import("./token.js"); - vi.mocked(resolveTwitchToken).mockReturnValue({ - token: "oauth:actualtoken123", - source: "config" as const, - }); - - await manager.getClient(accountWithPrefix); - - expect(mockAuthProvider.constructor).toHaveBeenCalledWith( - "test-client-id", - "actualtoken123", - ); - }); - - it("should use token directly when no oauth: prefix", async () => { - // Override the mock to return a token without oauth: prefix - const { resolveTwitchToken } = await import("./token.js"); - vi.mocked(resolveTwitchToken).mockReturnValue({ - token: "oauth:mock-token-from-tests", - source: "config" as const, - }); - - await manager.getClient(testAccount); - - // Implementation strips oauth: prefix from all tokens - expect(mockAuthProvider.constructor).toHaveBeenCalledWith( - "test-client-id", - "mock-token-from-tests", - ); - }); - - 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", - ); - - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining("Missing Twitch client ID"), - ); - }); - - it("should throw error when token is missing", async () => { - // Override the mock to return empty token - const { resolveTwitchToken } = await import("./token.js"); - vi.mocked(resolveTwitchToken).mockReturnValue({ - token: "", - source: "none" as const, - }); - - await expect(manager.getClient(testAccount)).rejects.toThrow( - "Missing Twitch 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(); - }); - }); + let manager: TwitchClientManager; + let mockLogger: ChannelLogSink; + + 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(async () => { + // Clear all mocks first + vi.clearAllMocks(); + + // Re-set up the default token mock implementation after clearing + const { resolveTwitchToken } = await import("./token.js"); + vi.mocked(resolveTwitchToken).mockReturnValue({ + token: "oauth:mock-token-from-tests", + source: "config" as const, + }); + + // 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", + }; + + // Override the mock to return a specific token for this test + const { resolveTwitchToken } = await import("./token.js"); + vi.mocked(resolveTwitchToken).mockReturnValue({ + token: "oauth:actualtoken123", + source: "config" as const, + }); + + await manager.getClient(accountWithPrefix); + + expect(mockAuthProvider.constructor).toHaveBeenCalledWith("test-client-id", "actualtoken123"); + }); + + it("should use token directly when no oauth: prefix", async () => { + // Override the mock to return a token without oauth: prefix + const { resolveTwitchToken } = await import("./token.js"); + vi.mocked(resolveTwitchToken).mockReturnValue({ + token: "oauth:mock-token-from-tests", + source: "config" as const, + }); + + await manager.getClient(testAccount); + + // Implementation strips oauth: prefix from all tokens + expect(mockAuthProvider.constructor).toHaveBeenCalledWith( + "test-client-id", + "mock-token-from-tests", + ); + }); + + 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", + ); + + expect(mockLogger.error).toHaveBeenCalledWith( + expect.stringContaining("Missing Twitch client ID"), + ); + }); + + it("should throw error when token is missing", async () => { + // Override the mock to return empty token + const { resolveTwitchToken } = await import("./token.js"); + vi.mocked(resolveTwitchToken).mockReturnValue({ + token: "", + source: "none" as const, + }); + + await expect(manager.getClient(testAccount)).rejects.toThrow("Missing Twitch 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 index 1d8acf1fc..91f67c11f 100644 --- a/extensions/twitch/src/twitch-client.ts +++ b/extensions/twitch/src/twitch-client.ts @@ -1,11 +1,7 @@ import { RefreshingAuthProvider, StaticAuthProvider } from "@twurple/auth"; import { ChatClient, LogLevel } from "@twurple/chat"; import type { ClawdbotConfig } from "clawdbot/plugin-sdk"; -import type { - ChannelLogSink, - TwitchAccountConfig, - TwitchChatMessage, -} from "./types.js"; +import type { ChannelLogSink, TwitchAccountConfig, TwitchChatMessage } from "./types.js"; import { resolveTwitchToken } from "./token.js"; import { normalizeToken } from "./utils/twitch.js"; @@ -13,312 +9,299 @@ import { normalizeToken } from "./utils/twitch.js"; * Manages Twitch chat client connections */ export class TwitchClientManager { - private clients = new Map(); - private messageHandlers = new Map< - string, - (message: TwitchChatMessage) => void - >(); + private clients = new Map(); + private messageHandlers = new Map void>(); - constructor(private logger: ChannelLogSink) {} + constructor(private logger: ChannelLogSink) {} - /** - * Create an auth provider for the account. - */ - private createAuthProvider( - account: TwitchAccountConfig, - normalizedToken: string, - ): StaticAuthProvider | RefreshingAuthProvider { - if (!account.clientId) { - throw new Error("Missing Twitch client ID"); - } + /** + * Create an auth provider for the account. + */ + private createAuthProvider( + account: TwitchAccountConfig, + normalizedToken: string, + ): StaticAuthProvider | RefreshingAuthProvider { + if (!account.clientId) { + throw new Error("Missing Twitch client ID"); + } - if (account.clientSecret) { - // Use RefreshingAuthProvider - can handle tokens with or without refresh tokens - const authProvider = new RefreshingAuthProvider({ - clientId: account.clientId, - clientSecret: account.clientSecret, - }); + if (account.clientSecret) { + // Use RefreshingAuthProvider - can handle tokens with or without refresh tokens + const 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 - .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)}`, - ); - }); + // Use addUserForToken to figure out the user ID from the token + // This works whether we have a refresh token or not + authProvider + .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.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 event listener (only fires if refreshToken is provided) + authProvider.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.onRefreshFailure((userId, error) => { - this.logger.error( - `[twitch] Failed to refresh access token for user ${userId}: ${error.message}`, - ); - }); + // Set up token refresh failure listener + authProvider.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})`, - ); + const refreshStatus = account.refreshToken + ? "automatic token refresh enabled" + : "token refresh disabled (no refresh token)"; + this.logger.info( + `[twitch] Using RefreshingAuthProvider for ${account.username} (${refreshStatus})`, + ); - return authProvider; - } + return authProvider; + } - // Fall back to StaticAuthProvider for backward compatibility (no clientSecret) - this.logger.info( - `[twitch] Using StaticAuthProvider for ${account.username} (no clientSecret provided)`, - ); - return new StaticAuthProvider(account.clientId, normalizedToken); - } + // Fall back to StaticAuthProvider for backward compatibility (no clientSecret) + this.logger.info( + `[twitch] Using StaticAuthProvider for ${account.username} (no clientSecret provided)`, + ); + return new StaticAuthProvider(account.clientId, normalizedToken); + } - /** - * Get or create a chat client for an account - */ - async getClient( - account: TwitchAccountConfig, - cfg?: ClawdbotConfig, - accountId?: string, - ): Promise { - const key = this.getAccountKey(account); + /** + * Get or create a chat client for an account + */ + async getClient( + account: TwitchAccountConfig, + cfg?: ClawdbotConfig, + accountId?: string, + ): Promise { + const key = this.getAccountKey(account); - const existing = this.clients.get(key); - if (existing) { - return existing; - } + const existing = this.clients.get(key); + if (existing) { + return existing; + } - // Resolve token from config or environment - const tokenResolution = resolveTwitchToken(cfg, { - accountId, - }); + // Resolve token from config or environment + const tokenResolution = resolveTwitchToken(cfg, { + accountId, + }); - if (!tokenResolution.token) { - this.logger.error( - `[twitch] Missing Twitch token for account ${account.username} (set channels.twitch.accounts.${account.username}.token or CLAWDBOT_TWITCH_ACCESS_TOKEN for default)`, - ); - throw new Error("Missing Twitch token"); - } + if (!tokenResolution.token) { + this.logger.error( + `[twitch] Missing Twitch token for account ${account.username} (set channels.twitch.accounts.${account.username}.token or CLAWDBOT_TWITCH_ACCESS_TOKEN for default)`, + ); + throw new Error("Missing Twitch token"); + } - this.logger.debug?.( - `[twitch] Using ${tokenResolution.source} token source for ${account.username}`, - ); + this.logger.debug?.( + `[twitch] Using ${tokenResolution.source} token source for ${account.username}`, + ); - if (!account.clientId) { - this.logger.error( - `[twitch] Missing Twitch client ID for account ${account.username}`, - ); - throw new Error("Missing Twitch client ID"); - } + if (!account.clientId) { + this.logger.error(`[twitch] Missing Twitch client ID for account ${account.username}`); + throw new Error("Missing Twitch client ID"); + } - // Normalize token - strip oauth: prefix if present (Twurple doesn't need it) - const normalizedToken = normalizeToken(tokenResolution.token); + // Normalize token - strip oauth: prefix if present (Twurple doesn't need it) + const normalizedToken = normalizeToken(tokenResolution.token); - // Create auth provider - const authProvider = this.createAuthProvider(account, normalizedToken); + // Create auth provider + const authProvider = this.createAuthProvider(account, normalizedToken); - const channel = account.channel ?? account.username; + const channel = account.channel ?? account.username; - // Create chat client - const client = new ChatClient({ - authProvider, - channels: [channel], - rejoinChannelsOnReconnect: true, - requestMembershipEvents: true, - logger: { - minLevel: LogLevel.WARNING, - 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; - } - }, - }, - }, - }); + // Create chat client + const client = new ChatClient({ + authProvider, + channels: [channel], + rejoinChannelsOnReconnect: true, + requestMembershipEvents: true, + logger: { + minLevel: LogLevel.WARNING, + 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); + // Set up event handlers + this.setupClientHandlers(client, account); - // Connect - client.connect(); + // Connect + client.connect(); - this.clients.set(key, client); - this.logger.info(`[twitch] Connected to Twitch as ${account.username}`); + this.clients.set(key, client); + this.logger.info(`[twitch] Connected to Twitch as ${account.username}`); - return client; - } + return client; + } - /** - * Set up message and event handlers for a client - */ - private setupClientHandlers( - client: ChatClient, - account: TwitchAccountConfig, - ): void { - const key = this.getAccountKey(account); + /** + * 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 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", - }); - } - }); + // 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}`); - } + 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); - } + /** + * 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); + /** + * 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}`); - } - } + if (client) { + client.quit(); + this.clients.delete(key); + this.messageHandlers.delete(key); + this.logger.info(`[twitch] Disconnected ${key}`); + } + } - /** - * Disconnect all clients - */ - async disconnectAll(): Promise { - this.clients.forEach((client) => client.quit()); - this.clients.clear(); - this.messageHandlers.clear(); - this.logger.info("[twitch] Disconnected all clients"); - } + /** + * Disconnect all clients + */ + async disconnectAll(): Promise { + this.clients.forEach((client) => 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, - cfg?: ClawdbotConfig, - accountId?: string, - ): Promise<{ ok: boolean; error?: string; messageId?: string }> { - try { - const client = await this.getClient(account, cfg, accountId); + /** + * Send a message to a channel + */ + async sendMessage( + account: TwitchAccountConfig, + channel: string, + message: string, + cfg?: ClawdbotConfig, + accountId?: string, + ): Promise<{ ok: boolean; error?: string; messageId?: string }> { + try { + const client = await this.getClient(account, cfg, accountId); - // 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)}`; + // 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); + // 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), - }; - } - } + 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}`; - } + /** + * 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(); - } + /** + * Clear all clients and handlers (for testing) + */ + _clearForTest(): void { + this.clients.clear(); + this.messageHandlers.clear(); + } } diff --git a/extensions/twitch/src/utils/markdown.ts b/extensions/twitch/src/utils/markdown.ts index 89a8e8329..0fa4a5fdf 100644 --- a/extensions/twitch/src/utils/markdown.ts +++ b/extensions/twitch/src/utils/markdown.ts @@ -16,38 +16,38 @@ * @returns Plain text with markdown removed */ export function stripMarkdownForTwitch(markdown: string): string { - return markdown - // Images - .replace(/!\[[^\]]*]\([^)]+\)/g, "") - // Links - .replace(/\[([^\]]+)]\([^)]+\)/g, "$1") - // Bold (**text**) - .replace(/\*\*([^*]+)\*\*/g, "$1") - // Bold (__text__) - .replace(/__([^_]+)__/g, "$1") - // Italic (*text*) - .replace(/\*([^*]+)\*/g, "$1") - // Italic (_text_) - .replace(/_([^_]+)_/g, "$1") - // Strikethrough (~~text~~) - .replace(/~~([^~]+)~~/g, "$1") - // Code blocks - .replace(/```[\s\S]*?```/g, (block) => - block.replace(/```[^\n]*\n?/g, "").replace(/```/g, ""), - ) - // Inline code - .replace(/`([^`]+)`/g, "$1") - // Headers - .replace(/^#{1,6}\s+/gm, "") - // Lists - .replace(/^\s*[-*+]\s+/gm, "") - .replace(/^\s*\d+\.\s+/gm, "") - // Normalize whitespace - .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 ( + markdown + // Images + .replace(/!\[[^\]]*]\([^)]+\)/g, "") + // Links + .replace(/\[([^\]]+)]\([^)]+\)/g, "$1") + // Bold (**text**) + .replace(/\*\*([^*]+)\*\*/g, "$1") + // Bold (__text__) + .replace(/__([^_]+)__/g, "$1") + // Italic (*text*) + .replace(/\*([^*]+)\*/g, "$1") + // Italic (_text_) + .replace(/_([^_]+)_/g, "$1") + // Strikethrough (~~text~~) + .replace(/~~([^~]+)~~/g, "$1") + // Code blocks + .replace(/```[\s\S]*?```/g, (block) => block.replace(/```[^\n]*\n?/g, "").replace(/```/g, "")) + // Inline code + .replace(/`([^`]+)`/g, "$1") + // Headers + .replace(/^#{1,6}\s+/gm, "") + // Lists + .replace(/^\s*[-*+]\s+/gm, "") + .replace(/^\s*\d+\.\s+/gm, "") + // Normalize whitespace + .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() + ); } /** @@ -59,34 +59,34 @@ export function stripMarkdownForTwitch(markdown: string): string { * @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]; + // 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; + 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(" "); + 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 (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); - } + if (remaining) { + chunks.push(remaining); + } - return chunks; + return chunks; } diff --git a/extensions/twitch/src/utils/twitch.ts b/extensions/twitch/src/utils/twitch.ts index 560602c31..d9d17633e 100644 --- a/extensions/twitch/src/utils/twitch.ts +++ b/extensions/twitch/src/utils/twitch.ts @@ -16,8 +16,8 @@ * normalizeTwitchChannel("MyChannel") // "mychannel" */ export function normalizeTwitchChannel(channel: string): string { - const trimmed = channel.trim().toLowerCase(); - return trimmed.startsWith("#") ? trimmed.slice(1) : trimmed; + const trimmed = channel.trim().toLowerCase(); + return trimmed.startsWith("#") ? trimmed.slice(1) : trimmed; } /** @@ -28,9 +28,7 @@ export function normalizeTwitchChannel(channel: string): string { * @returns Error object with descriptive message */ export function missingTargetError(provider: string, hint?: string): Error { - return new Error( - `Delivering to ${provider} requires target${hint ? ` ${hint}` : ""}`, - ); + return new Error(`Delivering to ${provider} requires target${hint ? ` ${hint}` : ""}`); } /** @@ -42,7 +40,7 @@ export function missingTargetError(provider: string, hint?: string): Error { * @returns A unique message ID */ export function generateMessageId(): string { - return `${Date.now()}-${Math.random().toString(36).substring(2, 15)}`; + return `${Date.now()}-${Math.random().toString(36).substring(2, 15)}`; } /** @@ -58,7 +56,7 @@ export function generateMessageId(): string { * normalizeToken("abc123") // "abc123" */ export function normalizeToken(token: string): string { - return token.startsWith("oauth:") ? token.slice(6) : token; + return token.startsWith("oauth:") ? token.slice(6) : token; } /** @@ -68,9 +66,9 @@ export function normalizeToken(token: string): string { * @returns true if the account has required credentials */ export function isAccountConfigured(account: { - username?: string; - token?: string; - clientId?: string; + username?: string; + token?: string; + clientId?: string; }): boolean { - return Boolean(account?.username && account?.token && account?.clientId); + return Boolean(account?.username && account?.token && account?.clientId); }