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

View File

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

View File

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

View File

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

View File

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

View File

@ -3,385 +3,383 @@ import { checkTwitchAccessControl, extractMentions } from "./access-control.js";
import type { TwitchAccountConfig, TwitchChatMessage } from "./types.js"; import type { TwitchAccountConfig, TwitchChatMessage } from "./types.js";
describe("checkTwitchAccessControl", () => { describe("checkTwitchAccessControl", () => {
const mockAccount: TwitchAccountConfig = { const mockAccount: TwitchAccountConfig = {
username: "testbot", username: "testbot",
token: "oauth:test", token: "oauth:test",
}; };
const mockMessage: TwitchChatMessage = { const mockMessage: TwitchChatMessage = {
username: "testuser", username: "testuser",
userId: "123456", userId: "123456",
message: "hello bot", message: "hello bot",
channel: "testchannel", channel: "testchannel",
}; };
describe("when no restrictions are configured", () => { describe("when no restrictions are configured", () => {
it("allows all messages", () => { it("allows all messages", () => {
const result = checkTwitchAccessControl({ const result = checkTwitchAccessControl({
message: mockMessage, message: mockMessage,
account: mockAccount, account: mockAccount,
botUsername: "testbot", botUsername: "testbot",
}); });
expect(result.allowed).toBe(true); expect(result.allowed).toBe(true);
}); });
}); });
describe("requireMention", () => { describe("requireMention", () => {
it("allows messages that mention the bot", () => { it("allows messages that mention the bot", () => {
const account: TwitchAccountConfig = { const account: TwitchAccountConfig = {
...mockAccount, ...mockAccount,
requireMention: true, requireMention: true,
}; };
const message: TwitchChatMessage = { const message: TwitchChatMessage = {
...mockMessage, ...mockMessage,
message: "@testbot hello", message: "@testbot hello",
}; };
const result = checkTwitchAccessControl({ const result = checkTwitchAccessControl({
message, message,
account, account,
botUsername: "testbot", botUsername: "testbot",
}); });
expect(result.allowed).toBe(true); expect(result.allowed).toBe(true);
}); });
it("blocks messages that don't mention the bot", () => { it("blocks messages that don't mention the bot", () => {
const account: TwitchAccountConfig = { const account: TwitchAccountConfig = {
...mockAccount, ...mockAccount,
requireMention: true, requireMention: true,
}; };
const result = checkTwitchAccessControl({ const result = checkTwitchAccessControl({
message: mockMessage, message: mockMessage,
account, account,
botUsername: "testbot", botUsername: "testbot",
}); });
expect(result.allowed).toBe(false); expect(result.allowed).toBe(false);
expect(result.reason).toContain("does not mention the bot"); expect(result.reason).toContain("does not mention the bot");
}); });
it("is case-insensitive for bot username", () => { it("is case-insensitive for bot username", () => {
const account: TwitchAccountConfig = { const account: TwitchAccountConfig = {
...mockAccount, ...mockAccount,
requireMention: true, requireMention: true,
}; };
const message: TwitchChatMessage = { const message: TwitchChatMessage = {
...mockMessage, ...mockMessage,
message: "@TestBot hello", message: "@TestBot hello",
}; };
const result = checkTwitchAccessControl({ const result = checkTwitchAccessControl({
message, message,
account, account,
botUsername: "testbot", botUsername: "testbot",
}); });
expect(result.allowed).toBe(true); expect(result.allowed).toBe(true);
}); });
}); });
describe("allowFrom allowlist", () => { describe("allowFrom allowlist", () => {
it("allows users in the allowlist", () => { it("allows users in the allowlist", () => {
const account: TwitchAccountConfig = { const account: TwitchAccountConfig = {
...mockAccount, ...mockAccount,
allowFrom: ["123456", "789012"], allowFrom: ["123456", "789012"],
}; };
const result = checkTwitchAccessControl({ const result = checkTwitchAccessControl({
message: mockMessage, message: mockMessage,
account, account,
botUsername: "testbot", botUsername: "testbot",
}); });
expect(result.allowed).toBe(true); expect(result.allowed).toBe(true);
expect(result.matchKey).toBe("123456"); expect(result.matchKey).toBe("123456");
expect(result.matchSource).toBe("allowlist"); expect(result.matchSource).toBe("allowlist");
}); });
it("blocks users not in the allowlist", () => { it("blocks users not in the allowlist", () => {
const account: TwitchAccountConfig = { const account: TwitchAccountConfig = {
...mockAccount, ...mockAccount,
allowFrom: ["789012"], allowFrom: ["789012"],
}; };
const result = checkTwitchAccessControl({ const result = checkTwitchAccessControl({
message: mockMessage, message: mockMessage,
account, account,
botUsername: "testbot", botUsername: "testbot",
}); });
expect(result.allowed).toBe(false); expect(result.allowed).toBe(false);
expect(result.reason).toContain("not in allowlist"); expect(result.reason).toContain("not in allowlist");
}); });
it("blocks messages without userId", () => { it("blocks messages without userId", () => {
const account: TwitchAccountConfig = { const account: TwitchAccountConfig = {
...mockAccount, ...mockAccount,
allowFrom: ["123456"], allowFrom: ["123456"],
}; };
const message = { ...mockMessage, userId: undefined }; const message = { ...mockMessage, userId: undefined };
const result = checkTwitchAccessControl({ const result = checkTwitchAccessControl({
message, message,
account, account,
botUsername: "testbot", botUsername: "testbot",
}); });
expect(result.allowed).toBe(false); expect(result.allowed).toBe(false);
expect(result.reason).toContain("user ID not available"); expect(result.reason).toContain("user ID not available");
}); });
it("bypasses role checks when user is in allowlist", () => { it("bypasses role checks when user is in allowlist", () => {
const account: TwitchAccountConfig = { const account: TwitchAccountConfig = {
...mockAccount, ...mockAccount,
allowFrom: ["123456"], allowFrom: ["123456"],
allowedRoles: ["owner"], allowedRoles: ["owner"],
}; };
const message: TwitchChatMessage = { const message: TwitchChatMessage = {
...mockMessage, ...mockMessage,
isOwner: false, isOwner: false,
}; };
const result = checkTwitchAccessControl({ const result = checkTwitchAccessControl({
message, message,
account, account,
botUsername: "testbot", botUsername: "testbot",
}); });
expect(result.allowed).toBe(true); expect(result.allowed).toBe(true);
}); });
}); });
describe("allowedRoles", () => { describe("allowedRoles", () => {
it("allows users with matching role", () => { it("allows users with matching role", () => {
const account: TwitchAccountConfig = { const account: TwitchAccountConfig = {
...mockAccount, ...mockAccount,
allowedRoles: ["moderator"], allowedRoles: ["moderator"],
}; };
const message: TwitchChatMessage = { const message: TwitchChatMessage = {
...mockMessage, ...mockMessage,
isMod: true, isMod: true,
}; };
const result = checkTwitchAccessControl({ const result = checkTwitchAccessControl({
message, message,
account, account,
botUsername: "testbot", botUsername: "testbot",
}); });
expect(result.allowed).toBe(true); expect(result.allowed).toBe(true);
expect(result.matchSource).toBe("role"); expect(result.matchSource).toBe("role");
}); });
it("allows users with any of multiple roles", () => { it("allows users with any of multiple roles", () => {
const account: TwitchAccountConfig = { const account: TwitchAccountConfig = {
...mockAccount, ...mockAccount,
allowedRoles: ["moderator", "vip", "subscriber"], allowedRoles: ["moderator", "vip", "subscriber"],
}; };
const message: TwitchChatMessage = { const message: TwitchChatMessage = {
...mockMessage, ...mockMessage,
isVip: true, isVip: true,
isMod: false, isMod: false,
isSub: false, isSub: false,
}; };
const result = checkTwitchAccessControl({ const result = checkTwitchAccessControl({
message, message,
account, account,
botUsername: "testbot", botUsername: "testbot",
}); });
expect(result.allowed).toBe(true); expect(result.allowed).toBe(true);
}); });
it("blocks users without matching role", () => { it("blocks users without matching role", () => {
const account: TwitchAccountConfig = { const account: TwitchAccountConfig = {
...mockAccount, ...mockAccount,
allowedRoles: ["moderator"], allowedRoles: ["moderator"],
}; };
const message: TwitchChatMessage = { const message: TwitchChatMessage = {
...mockMessage, ...mockMessage,
isMod: false, isMod: false,
}; };
const result = checkTwitchAccessControl({ const result = checkTwitchAccessControl({
message, message,
account, account,
botUsername: "testbot", botUsername: "testbot",
}); });
expect(result.allowed).toBe(false); expect(result.allowed).toBe(false);
expect(result.reason).toContain( expect(result.reason).toContain("does not have any of the required roles");
"does not have any of the required roles", });
);
});
it("allows all users when role is 'all'", () => { it("allows all users when role is 'all'", () => {
const account: TwitchAccountConfig = { const account: TwitchAccountConfig = {
...mockAccount, ...mockAccount,
allowedRoles: ["all"], allowedRoles: ["all"],
}; };
const result = checkTwitchAccessControl({ const result = checkTwitchAccessControl({
message: mockMessage, message: mockMessage,
account, account,
botUsername: "testbot", botUsername: "testbot",
}); });
expect(result.allowed).toBe(true); expect(result.allowed).toBe(true);
expect(result.matchKey).toBe("all"); expect(result.matchKey).toBe("all");
}); });
it("handles moderator role", () => { it("handles moderator role", () => {
const account: TwitchAccountConfig = { const account: TwitchAccountConfig = {
...mockAccount, ...mockAccount,
allowedRoles: ["moderator"], allowedRoles: ["moderator"],
}; };
const message: TwitchChatMessage = { const message: TwitchChatMessage = {
...mockMessage, ...mockMessage,
isMod: true, isMod: true,
}; };
const result = checkTwitchAccessControl({ const result = checkTwitchAccessControl({
message, message,
account, account,
botUsername: "testbot", botUsername: "testbot",
}); });
expect(result.allowed).toBe(true); expect(result.allowed).toBe(true);
}); });
it("handles subscriber role", () => { it("handles subscriber role", () => {
const account: TwitchAccountConfig = { const account: TwitchAccountConfig = {
...mockAccount, ...mockAccount,
allowedRoles: ["subscriber"], allowedRoles: ["subscriber"],
}; };
const message: TwitchChatMessage = { const message: TwitchChatMessage = {
...mockMessage, ...mockMessage,
isSub: true, isSub: true,
}; };
const result = checkTwitchAccessControl({ const result = checkTwitchAccessControl({
message, message,
account, account,
botUsername: "testbot", botUsername: "testbot",
}); });
expect(result.allowed).toBe(true); expect(result.allowed).toBe(true);
}); });
it("handles owner role", () => { it("handles owner role", () => {
const account: TwitchAccountConfig = { const account: TwitchAccountConfig = {
...mockAccount, ...mockAccount,
allowedRoles: ["owner"], allowedRoles: ["owner"],
}; };
const message: TwitchChatMessage = { const message: TwitchChatMessage = {
...mockMessage, ...mockMessage,
isOwner: true, isOwner: true,
}; };
const result = checkTwitchAccessControl({ const result = checkTwitchAccessControl({
message, message,
account, account,
botUsername: "testbot", botUsername: "testbot",
}); });
expect(result.allowed).toBe(true); expect(result.allowed).toBe(true);
}); });
it("handles vip role", () => { it("handles vip role", () => {
const account: TwitchAccountConfig = { const account: TwitchAccountConfig = {
...mockAccount, ...mockAccount,
allowedRoles: ["vip"], allowedRoles: ["vip"],
}; };
const message: TwitchChatMessage = { const message: TwitchChatMessage = {
...mockMessage, ...mockMessage,
isVip: true, isVip: true,
}; };
const result = checkTwitchAccessControl({ const result = checkTwitchAccessControl({
message, message,
account, account,
botUsername: "testbot", botUsername: "testbot",
}); });
expect(result.allowed).toBe(true); expect(result.allowed).toBe(true);
}); });
}); });
describe("combined restrictions", () => { describe("combined restrictions", () => {
it("checks requireMention before allowlist", () => { it("checks requireMention before allowlist", () => {
const account: TwitchAccountConfig = { const account: TwitchAccountConfig = {
...mockAccount, ...mockAccount,
requireMention: true, requireMention: true,
allowFrom: ["123456"], allowFrom: ["123456"],
}; };
const message: TwitchChatMessage = { const message: TwitchChatMessage = {
...mockMessage, ...mockMessage,
message: "hello", // No mention message: "hello", // No mention
}; };
const result = checkTwitchAccessControl({ const result = checkTwitchAccessControl({
message, message,
account, account,
botUsername: "testbot", botUsername: "testbot",
}); });
expect(result.allowed).toBe(false); expect(result.allowed).toBe(false);
expect(result.reason).toContain("does not mention the bot"); expect(result.reason).toContain("does not mention the bot");
}); });
it("checks allowlist before allowedRoles", () => { it("checks allowlist before allowedRoles", () => {
const account: TwitchAccountConfig = { const account: TwitchAccountConfig = {
...mockAccount, ...mockAccount,
allowFrom: ["123456"], allowFrom: ["123456"],
allowedRoles: ["owner"], allowedRoles: ["owner"],
}; };
const message: TwitchChatMessage = { const message: TwitchChatMessage = {
...mockMessage, ...mockMessage,
isOwner: false, // Not owner, but in allowlist isOwner: false, // Not owner, but in allowlist
}; };
const result = checkTwitchAccessControl({ const result = checkTwitchAccessControl({
message, message,
account, account,
botUsername: "testbot", botUsername: "testbot",
}); });
expect(result.allowed).toBe(true); expect(result.allowed).toBe(true);
expect(result.matchSource).toBe("allowlist"); expect(result.matchSource).toBe("allowlist");
}); });
}); });
}); });
describe("extractMentions", () => { describe("extractMentions", () => {
it("extracts single mention", () => { it("extracts single mention", () => {
const mentions = extractMentions("hello @testbot"); const mentions = extractMentions("hello @testbot");
expect(mentions).toEqual(["testbot"]); expect(mentions).toEqual(["testbot"]);
}); });
it("extracts multiple mentions", () => { it("extracts multiple mentions", () => {
const mentions = extractMentions("hello @testbot and @otheruser"); const mentions = extractMentions("hello @testbot and @otheruser");
expect(mentions).toEqual(["testbot", "otheruser"]); expect(mentions).toEqual(["testbot", "otheruser"]);
}); });
it("returns empty array when no mentions", () => { it("returns empty array when no mentions", () => {
const mentions = extractMentions("hello everyone"); const mentions = extractMentions("hello everyone");
expect(mentions).toEqual([]); expect(mentions).toEqual([]);
}); });
it("handles mentions at start of message", () => { it("handles mentions at start of message", () => {
const mentions = extractMentions("@testbot hello"); const mentions = extractMentions("@testbot hello");
expect(mentions).toEqual(["testbot"]); expect(mentions).toEqual(["testbot"]);
}); });
it("handles mentions at end of message", () => { it("handles mentions at end of message", () => {
const mentions = extractMentions("hello @testbot"); const mentions = extractMentions("hello @testbot");
expect(mentions).toEqual(["testbot"]); expect(mentions).toEqual(["testbot"]);
}); });
it("converts mentions to lowercase", () => { it("converts mentions to lowercase", () => {
const mentions = extractMentions("hello @TestBot"); const mentions = extractMentions("hello @TestBot");
expect(mentions).toEqual(["testbot"]); expect(mentions).toEqual(["testbot"]);
}); });
it("extracts alphanumeric usernames", () => { it("extracts alphanumeric usernames", () => {
const mentions = extractMentions("hello @user123"); const mentions = extractMentions("hello @user123");
expect(mentions).toEqual(["user123"]); expect(mentions).toEqual(["user123"]);
}); });
it("handles underscores in usernames", () => { it("handles underscores in usernames", () => {
const mentions = extractMentions("hello @test_user"); const mentions = extractMentions("hello @test_user");
expect(mentions).toEqual(["test_user"]); expect(mentions).toEqual(["test_user"]);
}); });
it("handles empty string", () => { it("handles empty string", () => {
const mentions = extractMentions(""); const mentions = extractMentions("");
expect(mentions).toEqual([]); expect(mentions).toEqual([]);
}); });
}); });

View File

@ -4,10 +4,10 @@ import type { TwitchAccountConfig, TwitchChatMessage } from "./types.js";
* Result of checking access control for a Twitch message * Result of checking access control for a Twitch message
*/ */
export type TwitchAccessControlResult = { export type TwitchAccessControlResult = {
allowed: boolean; allowed: boolean;
reason?: string; reason?: string;
matchKey?: string; matchKey?: string;
matchSource?: string; matchSource?: string;
}; };
/** /**
@ -32,118 +32,115 @@ export type TwitchAccessControlResult = {
* - "all": Anyone in the chat * - "all": Anyone in the chat
*/ */
export function checkTwitchAccessControl(params: { export function checkTwitchAccessControl(params: {
message: TwitchChatMessage; message: TwitchChatMessage;
account: TwitchAccountConfig; account: TwitchAccountConfig;
botUsername: string; botUsername: string;
}): TwitchAccessControlResult { }): TwitchAccessControlResult {
const { message, account, botUsername } = params; const { message, account, botUsername } = params;
// Check mention requirement first // Check mention requirement first
if (account.requireMention) { if (account.requireMention) {
const mentions = extractMentions(message.message); const mentions = extractMentions(message.message);
if (!mentions.includes(botUsername.toLowerCase())) { if (!mentions.includes(botUsername.toLowerCase())) {
return { return {
allowed: false, allowed: false,
reason: "message does not mention the bot (requireMention is enabled)", reason: "message does not mention the bot (requireMention is enabled)",
}; };
} }
} }
// Check allowlist (by user ID) // Check allowlist (by user ID)
if (account.allowFrom && account.allowFrom.length > 0) { if (account.allowFrom && account.allowFrom.length > 0) {
const allowFrom = account.allowFrom; const allowFrom = account.allowFrom;
const senderId = message.userId; const senderId = message.userId;
if (!senderId) { if (!senderId) {
return { return {
allowed: false, allowed: false,
reason: "sender user ID not available for allowlist check", reason: "sender user ID not available for allowlist check",
}; };
} }
// Check if sender is in allowlist // Check if sender is in allowlist
if (!allowFrom.includes(senderId)) { if (!allowFrom.includes(senderId)) {
return { return {
allowed: false, allowed: false,
reason: "sender not in allowlist", reason: "sender not in allowlist",
}; };
} }
// Sender is in allowlist, no need to check role restrictions // Sender is in allowlist, no need to check role restrictions
return { return {
allowed: true, allowed: true,
matchKey: senderId, matchKey: senderId,
matchSource: "allowlist", matchSource: "allowlist",
}; };
} }
// Check role-based restrictions // Check role-based restrictions
if (account.allowedRoles && account.allowedRoles.length > 0) { if (account.allowedRoles && account.allowedRoles.length > 0) {
const allowedRoles = account.allowedRoles; const allowedRoles = account.allowedRoles;
// "all" grants access to everyone // "all" grants access to everyone
if (allowedRoles.includes("all")) { if (allowedRoles.includes("all")) {
return { return {
allowed: true, allowed: true,
matchKey: "all", matchKey: "all",
matchSource: "role", matchSource: "role",
}; };
} }
// Check if sender has any of the allowed roles // Check if sender has any of the allowed roles
const hasAllowedRole = checkSenderRoles({ const hasAllowedRole = checkSenderRoles({
message, message,
allowedRoles, allowedRoles,
}); });
if (!hasAllowedRole) { if (!hasAllowedRole) {
return { return {
allowed: false, allowed: false,
reason: `sender does not have any of the required roles: ${allowedRoles.join(", ")}`, reason: `sender does not have any of the required roles: ${allowedRoles.join(", ")}`,
}; };
} }
return { return {
allowed: true, allowed: true,
matchKey: allowedRoles.join(","), matchKey: allowedRoles.join(","),
matchSource: "role", matchSource: "role",
}; };
} }
// No restrictions configured - allow everyone // No restrictions configured - allow everyone
return { return {
allowed: true, allowed: true,
}; };
} }
/** /**
* Check if the sender has any of the allowed roles * Check if the sender has any of the allowed roles
*/ */
function checkSenderRoles(params: { function checkSenderRoles(params: { message: TwitchChatMessage; allowedRoles: string[] }): boolean {
message: TwitchChatMessage; const { message, allowedRoles } = params;
allowedRoles: string[]; const { isMod, isOwner, isVip, isSub } = message;
}): boolean {
const { message, allowedRoles } = params;
const { isMod, isOwner, isVip, isSub } = message;
for (const role of allowedRoles) { for (const role of allowedRoles) {
switch (role) { switch (role) {
case "moderator": case "moderator":
if (isMod) return true; if (isMod) return true;
break; break;
case "owner": case "owner":
if (isOwner) return true; if (isOwner) return true;
break; break;
case "vip": case "vip":
if (isVip) return true; if (isVip) return true;
break; break;
case "subscriber": case "subscriber":
if (isSub) return true; if (isSub) return true;
break; break;
} }
} }
return false; return false;
} }
/** /**
@ -153,17 +150,17 @@ function checkSenderRoles(params: {
* Twitch mentions are in the format @username. * Twitch mentions are in the format @username.
*/ */
export function extractMentions(message: string): string[] { export function extractMentions(message: string): string[] {
const mentionRegex = /@(\w+)/g; const mentionRegex = /@(\w+)/g;
const mentions: string[] = []; const mentions: string[] = [];
let match: RegExpExecArray | null; let match: RegExpExecArray | null;
// biome-ignore lint/suspicious/noAssignInExpressions: Standard regex iteration pattern // biome-ignore lint/suspicious/noAssignInExpressions: Standard regex iteration pattern
while ((match = mentionRegex.exec(message)) !== null) { while ((match = mentionRegex.exec(message)) !== null) {
const username = match[1]; const username = match[1];
if (username) { if (username) {
mentions.push(username.toLowerCase()); mentions.push(username.toLowerCase());
} }
} }
return mentions; return mentions;
} }

View File

@ -6,24 +6,21 @@
import { DEFAULT_ACCOUNT_ID, getAccountConfig } from "./config.js"; import { DEFAULT_ACCOUNT_ID, getAccountConfig } from "./config.js";
import { twitchOutbound } from "./outbound.js"; import { twitchOutbound } from "./outbound.js";
import type { import type { ChannelMessageActionAdapter, ChannelMessageActionContext } from "./types.js";
ChannelMessageActionAdapter,
ChannelMessageActionContext,
} from "./types.js";
/** /**
* Create a tool result with error content. * Create a tool result with error content.
*/ */
function errorResponse(error: string) { function errorResponse(error: string) {
return { return {
content: [ content: [
{ {
type: "text", type: "text",
text: JSON.stringify({ ok: false, error }), text: JSON.stringify({ ok: false, error }),
}, },
], ],
details: { ok: false }, details: { ok: false },
}; };
} }
/** /**
@ -35,28 +32,28 @@ function errorResponse(error: string) {
* @returns The parameter value or undefined if not found * @returns The parameter value or undefined if not found
*/ */
function readStringParam( function readStringParam(
args: Record<string, unknown>, args: Record<string, unknown>,
key: string, key: string,
options: { required?: boolean; trim?: boolean } = {}, options: { required?: boolean; trim?: boolean } = {},
): string | undefined { ): string | undefined {
const value = args[key]; const value = args[key];
if (value === undefined || value === null) { if (value === undefined || value === null) {
if (options.required) { if (options.required) {
throw new Error(`Missing required parameter: ${key}`); throw new Error(`Missing required parameter: ${key}`);
} }
return undefined; return undefined;
} }
// Convert value to string safely // Convert value to string safely
let str: string; let str: string;
if (typeof value === "string") { if (typeof value === "string") {
str = value; str = value;
} else if (typeof value === "number" || typeof value === "boolean") { } else if (typeof value === "number" || typeof value === "boolean") {
str = String(value); str = String(value);
} else { } else {
throw new Error(`Parameter ${key} must be a string, number, or boolean`); throw new Error(`Parameter ${key} must be a string, number, or boolean`);
} }
return options.trim !== false ? str.trim() : str; return options.trim !== false ? str.trim() : str;
} }
/** Supported Twitch actions */ /** Supported Twitch actions */
@ -67,110 +64,110 @@ type TwitchAction = typeof TWITCH_ACTIONS extends Set<infer U> ? U : never;
* Twitch message actions adapter. * Twitch message actions adapter.
*/ */
export const twitchMessageActions: ChannelMessageActionAdapter = { export const twitchMessageActions: ChannelMessageActionAdapter = {
/** /**
* List available actions for this channel. * List available actions for this channel.
*/ */
listActions: () => [...TWITCH_ACTIONS], listActions: () => [...TWITCH_ACTIONS],
/** /**
* Check if an action is supported. * Check if an action is supported.
*/ */
supportsAction: ({ action }) => TWITCH_ACTIONS.has(action as TwitchAction), supportsAction: ({ action }) => TWITCH_ACTIONS.has(action as TwitchAction),
/** /**
* Extract tool send parameters from action arguments. * Extract tool send parameters from action arguments.
* *
* Parses and validates the "to" and "message" parameters for sending. * Parses and validates the "to" and "message" parameters for sending.
* *
* @param params - Arguments from the tool call * @param params - Arguments from the tool call
* @returns Parsed send parameters or null if invalid * @returns Parsed send parameters or null if invalid
* *
* @example * @example
* const result = twitchMessageActions.extractToolSend!({ * const result = twitchMessageActions.extractToolSend!({
* args: { to: "#mychannel", message: "Hello!" } * args: { to: "#mychannel", message: "Hello!" }
* }); * });
* // Returns: { to: "#mychannel", message: "Hello!" } * // Returns: { to: "#mychannel", message: "Hello!" }
*/ */
extractToolSend: ({ args }) => { extractToolSend: ({ args }) => {
try { try {
const to = readStringParam(args, "to", { required: true }); const to = readStringParam(args, "to", { required: true });
const message = readStringParam(args, "message", { required: true }); const message = readStringParam(args, "message", { required: true });
if (!to || !message) { if (!to || !message) {
return null; return null;
} }
return { to, message }; return { to, message };
} catch { } catch {
return null; return null;
} }
}, },
/** /**
* Handle an action execution. * Handle an action execution.
* *
* Processes the "send" action to send messages to Twitch. * Processes the "send" action to send messages to Twitch.
* *
* @param ctx - Action context including action type, parameters, and config * @param ctx - Action context including action type, parameters, and config
* @returns Tool result with content or null if action not supported * @returns Tool result with content or null if action not supported
* *
* @example * @example
* const result = await twitchMessageActions.handleAction!({ * const result = await twitchMessageActions.handleAction!({
* action: "send", * action: "send",
* params: { message: "Hello Twitch!", to: "#mychannel" }, * params: { message: "Hello Twitch!", to: "#mychannel" },
* cfg: clawdbotConfig, * cfg: clawdbotConfig,
* accountId: "default", * accountId: "default",
* }); * });
*/ */
handleAction: async ( handleAction: async (
ctx: ChannelMessageActionContext, ctx: ChannelMessageActionContext,
): Promise<{ content: Array<{ type: string; text: string }> } | null> => { ): Promise<{ content: Array<{ type: string; text: string }> } | null> => {
if (ctx.action !== "send") { if (ctx.action !== "send") {
return null; return null;
} }
const message = readStringParam(ctx.params, "message", { required: true }); const message = readStringParam(ctx.params, "message", { required: true });
const to = readStringParam(ctx.params, "to", { required: false }); const to = readStringParam(ctx.params, "to", { required: false });
const accountId = ctx.accountId ?? DEFAULT_ACCOUNT_ID; const accountId = ctx.accountId ?? DEFAULT_ACCOUNT_ID;
const account = getAccountConfig(ctx.cfg, accountId); const account = getAccountConfig(ctx.cfg, accountId);
if (!account) { if (!account) {
return errorResponse( return errorResponse(
`Account not found: ${accountId}. Available accounts: ${Object.keys(ctx.cfg.channels?.twitch?.accounts ?? {}).join(", ") || "none"}`, `Account not found: ${accountId}. Available accounts: ${Object.keys(ctx.cfg.channels?.twitch?.accounts ?? {}).join(", ") || "none"}`,
); );
} }
// Use the channel from account config if not specified // Use the channel from account config if not specified
const targetChannel = to || account.channel || account.username; const targetChannel = to || account.channel || account.username;
if (!targetChannel) { if (!targetChannel) {
return errorResponse("No channel specified and no default channel in account config"); return errorResponse("No channel specified and no default channel in account config");
} }
if (!twitchOutbound.sendText) { if (!twitchOutbound.sendText) {
return errorResponse("sendText not implemented"); return errorResponse("sendText not implemented");
} }
try { try {
const result = await twitchOutbound.sendText({ const result = await twitchOutbound.sendText({
cfg: ctx.cfg, cfg: ctx.cfg,
to: targetChannel, to: targetChannel,
text: message ?? "", text: message ?? "",
accountId, accountId,
}); });
return { return {
content: [ content: [
{ {
type: "text", type: "text",
text: JSON.stringify(result), text: JSON.stringify(result),
}, },
], ],
details: { ok: true }, details: { ok: true },
}; };
} catch (error) { } catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error); const errorMsg = error instanceof Error ? error.message : String(error);
return errorResponse(errorMsg); return errorResponse(errorMsg);
} }
}, },
}; };

View File

@ -2,97 +2,97 @@ import { StaticAuthProvider } from "@twurple/auth";
import { ChatClient } from "@twurple/chat"; import { ChatClient } from "@twurple/chat";
type Args = { type Args = {
token?: string; token?: string;
clientId?: string; clientId?: string;
username?: string; username?: string;
channel?: string; channel?: string;
message?: string; message?: string;
timeoutMs: number; timeoutMs: number;
}; };
function parseArgs(argv: string[]): Args { function parseArgs(argv: string[]): Args {
const args: Args = { timeoutMs: 10000 }; const args: Args = { timeoutMs: 10000 };
for (let i = 0; i < argv.length; i += 1) { for (let i = 0; i < argv.length; i += 1) {
const key = argv[i]; const key = argv[i];
const value = argv[i + 1]; const value = argv[i + 1];
if (!key?.startsWith("--")) continue; if (!key?.startsWith("--")) continue;
switch (key) { switch (key) {
case "--token": case "--token":
args.token = value; args.token = value;
i += 1; i += 1;
break; break;
case "--client-id": case "--client-id":
args.clientId = value; args.clientId = value;
i += 1; i += 1;
break; break;
case "--username": case "--username":
args.username = value; args.username = value;
i += 1; i += 1;
break; break;
case "--channel": case "--channel":
args.channel = value; args.channel = value;
i += 1; i += 1;
break; break;
case "--message": case "--message":
args.message = value; args.message = value;
i += 1; i += 1;
break; break;
case "--timeout-ms": case "--timeout-ms":
args.timeoutMs = Number(value ?? 10000); args.timeoutMs = Number(value ?? 10000);
i += 1; i += 1;
break; break;
default: default:
break; break;
} }
} }
return args; return args;
} }
async function run(): Promise<void> { async function run(): Promise<void> {
const args = parseArgs(process.argv.slice(2)); const args = parseArgs(process.argv.slice(2));
const token = args.token; const token = args.token;
const clientId = args.clientId; const clientId = args.clientId;
const username = args.username; const username = args.username;
const channel = args.channel ?? username; const channel = args.channel ?? username;
if (!token || !clientId || !username || !channel) { if (!token || !clientId || !username || !channel) {
console.error( console.error(
"Usage: npx tsx src/cli/test-connect.ts --token <token> --client-id <id> --username <bot> --channel <channel> [--timeout-ms 10000]", "Usage: npx tsx src/cli/test-connect.ts --token <token> --client-id <id> --username <bot> --channel <channel> [--timeout-ms 10000]",
); );
process.exit(2); 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 authProvider = new StaticAuthProvider(clientId, normalizedToken);
const client = new ChatClient({ const client = new ChatClient({
authProvider, authProvider,
requestMembershipEvents: true, requestMembershipEvents: true,
}); });
const timeout = setTimeout( const timeout = setTimeout(
() => { () => {
console.error("Timed out waiting for connection."); console.error("Timed out waiting for connection.");
process.exit(1); process.exit(1);
}, },
Number.isFinite(args.timeoutMs) ? args.timeoutMs : 10000, Number.isFinite(args.timeoutMs) ? args.timeoutMs : 10000,
); );
try { try {
await Promise.resolve(client.connect()); await Promise.resolve(client.connect());
await Promise.resolve(client.join(channel)); await Promise.resolve(client.join(channel));
console.log(`Connected as ${username} and joined #${channel}`); console.log(`Connected as ${username} and joined #${channel}`);
if (args.message) { if (args.message) {
await Promise.resolve(client.say(channel, args.message)); await Promise.resolve(client.say(channel, args.message));
console.log("Message sent."); console.log("Message sent.");
} }
} finally { } finally {
clearTimeout(timeout); clearTimeout(timeout);
client.quit(); client.quit();
} }
} }
run().catch((error) => { run().catch((error) => {
console.error(error instanceof Error ? error.message : String(error)); console.error(error instanceof Error ? error.message : String(error));
process.exit(1); process.exit(1);
}); });

View File

@ -12,14 +12,14 @@ import type { ChannelLogSink } from "./types.js";
* Registry entry tracking a client manager and its associated account. * Registry entry tracking a client manager and its associated account.
*/ */
type RegistryEntry = { type RegistryEntry = {
/** The client manager instance */ /** The client manager instance */
manager: TwitchClientManager; manager: TwitchClientManager;
/** The account ID this manager is for */ /** The account ID this manager is for */
accountId: string; accountId: string;
/** Logger for this entry */ /** Logger for this entry */
logger: ChannelLogSink; logger: ChannelLogSink;
/** When this entry was created */ /** When this entry was created */
createdAt: number; createdAt: number;
}; };
/** /**
@ -36,24 +36,24 @@ const registry = new Map<string, RegistryEntry>();
* @returns The client manager * @returns The client manager
*/ */
export function getOrCreateClientManager( export function getOrCreateClientManager(
accountId: string, accountId: string,
logger: ChannelLogSink, logger: ChannelLogSink,
): TwitchClientManager { ): TwitchClientManager {
const existing = registry.get(accountId); const existing = registry.get(accountId);
if (existing) { if (existing) {
return existing.manager; return existing.manager;
} }
const manager = new TwitchClientManager(logger); const manager = new TwitchClientManager(logger);
registry.set(accountId, { registry.set(accountId, {
manager, manager,
accountId, accountId,
logger, logger,
createdAt: Date.now(), createdAt: Date.now(),
}); });
logger.info(`[twitch] Registered client manager for account: ${accountId}`); logger.info(`[twitch] Registered client manager for account: ${accountId}`);
return manager; return manager;
} }
/** /**
@ -63,7 +63,7 @@ export function getOrCreateClientManager(
* @returns The client manager, or undefined if not registered * @returns The client manager, or undefined if not registered
*/ */
export function getClientManager(accountId: string): TwitchClientManager | undefined { 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 * @param accountId - The account ID
* @returns Promise that resolves when cleanup is complete * @returns Promise that resolves when cleanup is complete
*/ */
export async function removeClientManager( export async function removeClientManager(accountId: string): Promise<void> {
accountId: string, const entry = registry.get(accountId);
): Promise<void> { if (!entry) {
const entry = registry.get(accountId); return;
if (!entry) { }
return;
}
// Disconnect the client manager // Disconnect the client manager
await entry.manager.disconnect({ await entry.manager.disconnect({
username: accountId, username: accountId,
channel: accountId, channel: accountId,
} as Parameters<typeof entry.manager.disconnect>[0]); } as Parameters<typeof entry.manager.disconnect>[0]);
// Remove from registry // Remove from registry
registry.delete(accountId); registry.delete(accountId);
entry.logger.info(`[twitch] Unregistered client manager for account: ${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 * @returns Promise that resolves when all cleanup is complete
*/ */
export async function removeAllClientManagers(): Promise<void> { export async function removeAllClientManagers(): Promise<void> {
const promises = Array.from(registry.keys()).map((accountId) => const promises = Array.from(registry.keys()).map((accountId) => removeClientManager(accountId));
removeClientManager(accountId), await Promise.all(promises);
);
await Promise.all(promises);
} }
/** /**
@ -109,7 +105,7 @@ export async function removeAllClientManagers(): Promise<void> {
* @returns The count of registered managers * @returns The count of registered managers
*/ */
export function getRegisteredClientManagerCount(): number { export function getRegisteredClientManagerCount(): number {
return registry.size; return registry.size;
} }
/** /**
@ -118,5 +114,5 @@ export function getRegisteredClientManagerCount(): number {
* This is primarily for testing purposes. * This is primarily for testing purposes.
*/ */
export function _clearAllClientManagersForTest(): void { export function _clearAllClientManagersForTest(): void {
registry.clear(); registry.clear();
} }

View File

@ -3,90 +3,86 @@ import { describe, expect, it } from "vitest";
import { getAccountConfig, parsePluginConfig } from "./config.js"; import { getAccountConfig, parsePluginConfig } from "./config.js";
describe("parsePluginConfig", () => { describe("parsePluginConfig", () => {
it("parses valid config with stripMarkdown true", () => { it("parses valid config with stripMarkdown true", () => {
const result = parsePluginConfig({ stripMarkdown: true }); const result = parsePluginConfig({ stripMarkdown: true });
expect(result.stripMarkdown).toBe(true); expect(result.stripMarkdown).toBe(true);
}); });
it("parses valid config with stripMarkdown false", () => { it("parses valid config with stripMarkdown false", () => {
const result = parsePluginConfig({ stripMarkdown: false }); const result = parsePluginConfig({ stripMarkdown: false });
expect(result.stripMarkdown).toBe(false); expect(result.stripMarkdown).toBe(false);
}); });
it("defaults to stripMarkdown true when not specified", () => { it("defaults to stripMarkdown true when not specified", () => {
const result = parsePluginConfig({}); const result = parsePluginConfig({});
expect(result.stripMarkdown).toBe(true); expect(result.stripMarkdown).toBe(true);
}); });
it("handles undefined config", () => { it("handles undefined config", () => {
const result = parsePluginConfig( const result = parsePluginConfig(undefined as unknown as Record<string, unknown>);
undefined as unknown as Record<string, unknown>, expect(result).toEqual({ stripMarkdown: true }); // Returns defaults when value is falsy
); });
expect(result).toEqual({ stripMarkdown: true }); // Returns defaults when value is falsy
});
it("handles null config", () => { it("handles null config", () => {
const result = parsePluginConfig( const result = parsePluginConfig(null as unknown as Record<string, unknown>);
null as unknown as Record<string, unknown>, expect(result).toEqual({ stripMarkdown: true }); // Returns defaults when value is null
); });
expect(result).toEqual({ stripMarkdown: true }); // Returns defaults when value is null
});
}); });
describe("getAccountConfig", () => { describe("getAccountConfig", () => {
const mockCoreConfig = { const mockCoreConfig = {
channels: { channels: {
twitch: { twitch: {
accounts: { accounts: {
default: { default: {
username: "testbot", username: "testbot",
token: "oauth:test123", token: "oauth:test123",
}, },
}, },
}, },
}, },
}; };
it("returns account config for valid account ID", () => { it("returns account config for valid account ID", () => {
const result = getAccountConfig(mockCoreConfig, "default"); const result = getAccountConfig(mockCoreConfig, "default");
expect(result).not.toBeNull(); expect(result).not.toBeNull();
expect(result?.username).toBe("testbot"); expect(result?.username).toBe("testbot");
}); });
it("returns null for non-existent account ID", () => { it("returns null for non-existent account ID", () => {
const result = getAccountConfig(mockCoreConfig, "nonexistent"); const result = getAccountConfig(mockCoreConfig, "nonexistent");
expect(result).toBeNull(); expect(result).toBeNull();
}); });
it("returns null when core config is null", () => { it("returns null when core config is null", () => {
const result = getAccountConfig(null, "default"); const result = getAccountConfig(null, "default");
expect(result).toBeNull(); expect(result).toBeNull();
}); });
it("returns null when core config is undefined", () => { it("returns null when core config is undefined", () => {
const result = getAccountConfig(undefined, "default"); const result = getAccountConfig(undefined, "default");
expect(result).toBeNull(); expect(result).toBeNull();
}); });
it("returns null when channels are not defined", () => { it("returns null when channels are not defined", () => {
const result = getAccountConfig({}, "default"); const result = getAccountConfig({}, "default");
expect(result).toBeNull(); expect(result).toBeNull();
}); });
it("returns null when twitch is not defined", () => { it("returns null when twitch is not defined", () => {
const result = getAccountConfig({ channels: {} }, "default"); const result = getAccountConfig({ channels: {} }, "default");
expect(result).toBeNull(); expect(result).toBeNull();
}); });
it("returns null when accounts are not defined", () => { it("returns null when accounts are not defined", () => {
const result = getAccountConfig({ channels: { twitch: {} } }, "default"); const result = getAccountConfig({ channels: { twitch: {} } }, "default");
expect(result).toBeNull(); expect(result).toBeNull();
}); });
}); });

View File

@ -1,8 +1,5 @@
import type { ClawdbotConfig } from "clawdbot/plugin-sdk"; import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
import type { import type { TwitchAccountConfig, TwitchPluginConfig } from "./types.js";
TwitchAccountConfig,
TwitchPluginConfig,
} from "./types.js";
/** /**
* Default account ID for Twitch * Default account ID for Twitch
@ -13,53 +10,50 @@ export const DEFAULT_ACCOUNT_ID = "default";
* Get account config from core config * Get account config from core config
*/ */
export function getAccountConfig( export function getAccountConfig(
coreConfig: unknown, coreConfig: unknown,
accountId: string, accountId: string,
): TwitchAccountConfig | null { ): TwitchAccountConfig | null {
if (!coreConfig || typeof coreConfig !== "object") { if (!coreConfig || typeof coreConfig !== "object") {
return null; return null;
} }
const cfg = coreConfig as Record<string, unknown>; const cfg = coreConfig as Record<string, unknown>;
const channels = cfg.channels as Record<string, unknown> | undefined; const channels = cfg.channels as Record<string, unknown> | undefined;
const twitch = channels?.twitch as Record<string, unknown> | undefined; const twitch = channels?.twitch as Record<string, unknown> | undefined;
const accounts = twitch?.accounts as Record<string, unknown> | undefined; const accounts = twitch?.accounts as Record<string, unknown> | undefined;
if (!accounts || !accounts[accountId]) { if (!accounts || !accounts[accountId]) {
return null; return null;
} }
return accounts[accountId] as TwitchAccountConfig | null; return accounts[accountId] as TwitchAccountConfig | null;
} }
/** /**
* Parse plugin config * Parse plugin config
*/ */
export function parsePluginConfig(value: unknown): TwitchPluginConfig { export function parsePluginConfig(value: unknown): TwitchPluginConfig {
if (!value || typeof value !== "object") { if (!value || typeof value !== "object") {
return { stripMarkdown: true }; return { stripMarkdown: true };
} }
const raw = value as Record<string, unknown>; const raw = value as Record<string, unknown>;
return { return {
stripMarkdown: stripMarkdown: typeof raw.stripMarkdown === "boolean" ? raw.stripMarkdown : true,
typeof raw.stripMarkdown === "boolean" ? raw.stripMarkdown : true, };
};
} }
/** /**
* List all configured account IDs * List all configured account IDs
*/ */
export function listAccountIds(cfg: ClawdbotConfig): string[] { export function listAccountIds(cfg: ClawdbotConfig): string[] {
const accounts = (cfg as Record<string, unknown>).channels as const accounts = (cfg as Record<string, unknown>).channels as Record<string, unknown> | undefined;
| Record<string, unknown> const twitch = accounts?.twitch as Record<string, unknown> | undefined;
| undefined; const accountMap = twitch?.accounts as Record<string, unknown> | undefined;
const twitch = accounts?.twitch as Record<string, unknown> | undefined;
const accountMap = twitch?.accounts as Record<string, unknown> | undefined;
if (!accountMap) { if (!accountMap) {
return []; return [];
} }
return Object.keys(accountMap); return Object.keys(accountMap);
} }

View File

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

View File

@ -19,300 +19,300 @@ import type { TwitchAccountConfig } from "./types.js";
const mockPromptText = vi.fn(); const mockPromptText = vi.fn();
const mockPromptConfirm = vi.fn(); const mockPromptConfirm = vi.fn();
const mockPrompter: WizardPrompter = { const mockPrompter: WizardPrompter = {
text: mockPromptText, text: mockPromptText,
confirm: mockPromptConfirm, confirm: mockPromptConfirm,
} as unknown as WizardPrompter; } as unknown as WizardPrompter;
const mockAccount: TwitchAccountConfig = { const mockAccount: TwitchAccountConfig = {
username: "testbot", username: "testbot",
token: "oauth:test123", token: "oauth:test123",
clientId: "test-client-id", clientId: "test-client-id",
channel: "#testchannel", channel: "#testchannel",
}; };
describe("onboarding helpers", () => { describe("onboarding helpers", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
afterEach(() => { afterEach(() => {
// Don't restoreAllMocks as it breaks module-level mocks // Don't restoreAllMocks as it breaks module-level mocks
}); });
describe("promptToken", () => { describe("promptToken", () => {
it("should return existing token when user confirms to keep it", async () => { it("should return existing token when user confirms to keep it", async () => {
const { promptToken } = await import("./onboarding.js"); 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(result).toBe("oauth:test123");
expect(mockPromptConfirm).toHaveBeenCalledWith({ expect(mockPromptConfirm).toHaveBeenCalledWith({
message: "Token already configured. Keep it?", message: "Token already configured. Keep it?",
initialValue: true, initialValue: true,
}); });
expect(mockPromptText).not.toHaveBeenCalled(); expect(mockPromptText).not.toHaveBeenCalled();
}); });
it("should prompt for new token when user doesn't keep existing", async () => { it("should prompt for new token when user doesn't keep existing", async () => {
const { promptToken } = await import("./onboarding.js"); const { promptToken } = await import("./onboarding.js");
mockPromptConfirm.mockResolvedValue(false); mockPromptConfirm.mockResolvedValue(false);
mockPromptText.mockResolvedValue("oauth:newtoken123"); mockPromptText.mockResolvedValue("oauth:newtoken123");
const result = await promptToken(mockPrompter, mockAccount, undefined); const result = await promptToken(mockPrompter, mockAccount, undefined);
expect(result).toBe("oauth:newtoken123"); expect(result).toBe("oauth:newtoken123");
expect(mockPromptText).toHaveBeenCalledWith({ expect(mockPromptText).toHaveBeenCalledWith({
message: "Twitch OAuth token (oauth:...)", message: "Twitch OAuth token (oauth:...)",
initialValue: "", initialValue: "",
validate: expect.any(Function), validate: expect.any(Function),
}); });
}); });
it("should use env token as initial value when provided", async () => { it("should use env token as initial value when provided", async () => {
const { promptToken } = await import("./onboarding.js"); const { promptToken } = await import("./onboarding.js");
mockPromptConfirm.mockResolvedValue(false); mockPromptConfirm.mockResolvedValue(false);
mockPromptText.mockResolvedValue("oauth:fromenv"); mockPromptText.mockResolvedValue("oauth:fromenv");
await promptToken(mockPrompter, null, "oauth:fromenv"); await promptToken(mockPrompter, null, "oauth:fromenv");
expect(mockPromptText).toHaveBeenCalledWith( expect(mockPromptText).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
initialValue: "oauth:fromenv", initialValue: "oauth:fromenv",
}), }),
); );
}); });
it("should validate token format", async () => { it("should validate token format", async () => {
const { promptToken } = await import("./onboarding.js"); const { promptToken } = await import("./onboarding.js");
// Set up mocks - user doesn't want to keep existing token // Set up mocks - user doesn't want to keep existing token
mockPromptConfirm.mockResolvedValueOnce(false); mockPromptConfirm.mockResolvedValueOnce(false);
// Track how many times promptText is called // Track how many times promptText is called
let promptTextCallCount = 0; let promptTextCallCount = 0;
let capturedValidate: ((value: string) => string | undefined) | undefined; let capturedValidate: ((value: string) => string | undefined) | undefined;
mockPromptText.mockImplementationOnce((_args) => { mockPromptText.mockImplementationOnce((_args) => {
promptTextCallCount++; promptTextCallCount++;
// Capture the validate function from the first argument // Capture the validate function from the first argument
if (_args?.validate) { if (_args?.validate) {
capturedValidate = _args.validate; capturedValidate = _args.validate;
} }
return Promise.resolve("oauth:test123"); return Promise.resolve("oauth:test123");
}); });
// Call promptToken // Call promptToken
const result = await promptToken(mockPrompter, mockAccount, undefined); const result = await promptToken(mockPrompter, mockAccount, undefined);
// Verify promptText was called // Verify promptText was called
expect(promptTextCallCount).toBe(1); expect(promptTextCallCount).toBe(1);
expect(result).toBe("oauth:test123"); expect(result).toBe("oauth:test123");
// Test the validate function // Test the validate function
expect(capturedValidate).toBeDefined(); expect(capturedValidate).toBeDefined();
expect(capturedValidate!("")).toBe("Required"); expect(capturedValidate!("")).toBe("Required");
expect(capturedValidate!("notoauth")).toBe("Token should start with 'oauth:'"); expect(capturedValidate!("notoauth")).toBe("Token should start with 'oauth:'");
}); });
it("should return early when no existing token and no env token", async () => { it("should return early when no existing token and no env token", async () => {
const { promptToken } = await import("./onboarding.js"); 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(result).toBe("oauth:newtoken");
expect(mockPromptConfirm).not.toHaveBeenCalled(); expect(mockPromptConfirm).not.toHaveBeenCalled();
}); });
}); });
describe("promptUsername", () => { describe("promptUsername", () => {
it("should prompt for username with validation", async () => { it("should prompt for username with validation", async () => {
const { promptUsername } = await import("./onboarding.js"); 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(result).toBe("mybot");
expect(mockPromptText).toHaveBeenCalledWith({ expect(mockPromptText).toHaveBeenCalledWith({
message: "Twitch bot username", message: "Twitch bot username",
initialValue: "", initialValue: "",
validate: expect.any(Function), validate: expect.any(Function),
}); });
}); });
it("should use existing username as initial value", async () => { it("should use existing username as initial value", async () => {
const { promptUsername } = await import("./onboarding.js"); const { promptUsername } = await import("./onboarding.js");
mockPromptText.mockResolvedValue("testbot"); mockPromptText.mockResolvedValue("testbot");
await promptUsername(mockPrompter, mockAccount); await promptUsername(mockPrompter, mockAccount);
expect(mockPromptText).toHaveBeenCalledWith( expect(mockPromptText).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
initialValue: "testbot", initialValue: "testbot",
}), }),
); );
}); });
}); });
describe("promptClientId", () => { describe("promptClientId", () => {
it("should prompt for client ID with validation", async () => { it("should prompt for client ID with validation", async () => {
const { promptClientId } = await import("./onboarding.js"); 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(result).toBe("abc123xyz");
expect(mockPromptText).toHaveBeenCalledWith({ expect(mockPromptText).toHaveBeenCalledWith({
message: "Twitch Client ID", message: "Twitch Client ID",
initialValue: "", initialValue: "",
validate: expect.any(Function), validate: expect.any(Function),
}); });
}); });
}); });
describe("promptChannelName", () => { describe("promptChannelName", () => {
it("should return channel name when provided", async () => { it("should return channel name when provided", async () => {
const { promptChannelName } = await import("./onboarding.js"); 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 () => { it("should return undefined when empty string provided", async () => {
const { promptChannelName } = await import("./onboarding.js"); 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 () => { it("should return undefined when whitespace only", async () => {
const { promptChannelName } = await import("./onboarding.js"); 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", () => { describe("promptRefreshTokenSetup", () => {
it("should return empty object when user declines", async () => { it("should return empty object when user declines", async () => {
const { promptRefreshTokenSetup } = await import("./onboarding.js"); 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(result).toEqual({});
expect(mockPromptConfirm).toHaveBeenCalledWith({ expect(mockPromptConfirm).toHaveBeenCalledWith({
message: message: "Enable automatic token refresh (requires client secret and refresh token)?",
"Enable automatic token refresh (requires client secret and refresh token)?", initialValue: false,
initialValue: false, });
}); });
});
it("should prompt for credentials when user accepts", async () => { it("should prompt for credentials when user accepts", async () => {
const { promptRefreshTokenSetup } = await import("./onboarding.js"); const { promptRefreshTokenSetup } = await import("./onboarding.js");
mockPromptConfirm mockPromptConfirm
.mockResolvedValueOnce(true) // First call: useRefresh .mockResolvedValueOnce(true) // First call: useRefresh
.mockResolvedValueOnce("secret123") // clientSecret .mockResolvedValueOnce("secret123") // clientSecret
.mockResolvedValueOnce("refresh123"); // refreshToken .mockResolvedValueOnce("refresh123"); // refreshToken
mockPromptText mockPromptText.mockResolvedValueOnce("secret123").mockResolvedValueOnce("refresh123");
.mockResolvedValueOnce("secret123")
.mockResolvedValueOnce("refresh123");
const result = await promptRefreshTokenSetup(mockPrompter, null); const result = await promptRefreshTokenSetup(mockPrompter, null);
expect(result).toEqual({
clientSecret: "secret123",
refreshToken: "refresh123",
});
});
it("should use existing values as initial prompts", async () => { expect(result).toEqual({
const { promptRefreshTokenSetup } = await import("./onboarding.js"); clientSecret: "secret123",
refreshToken: "refresh123",
});
});
const accountWithRefresh = { it("should use existing values as initial prompts", async () => {
...mockAccount, const { promptRefreshTokenSetup } = await import("./onboarding.js");
clientSecret: "existing-secret",
refreshToken: "existing-refresh",
};
mockPromptConfirm.mockResolvedValue(true);
mockPromptText
.mockResolvedValueOnce("existing-secret")
.mockResolvedValueOnce("existing-refresh");
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( await promptRefreshTokenSetup(mockPrompter, accountWithRefresh);
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");
// Reset and set up mock - user declines env token expect(mockPromptConfirm).toHaveBeenCalledWith(
mockPromptConfirm.mockReset().mockResolvedValue(false as never); expect.objectContaining({
initialValue: true, // Both clientSecret and refreshToken exist
}),
);
});
});
const result = await configureWithEnvToken( describe("configureWithEnvToken", () => {
{} as Parameters<typeof configureWithEnvToken>[0], it("should return null when user declines env token", async () => {
mockPrompter, const { configureWithEnvToken } = await import("./onboarding.js");
null,
"oauth:fromenv", // Reset and set up mock - user declines env token
false, mockPromptConfirm.mockReset().mockResolvedValue(false as never);
{} as Parameters<typeof configureWithEnvToken>[5],
);
// Since user declined, should return null without prompting for username/clientId const result = await configureWithEnvToken(
expect(result).toBeNull(); {} as Parameters<typeof configureWithEnvToken>[0],
expect(mockPromptText).not.toHaveBeenCalled(); mockPrompter,
}); null,
"oauth:fromenv",
it("should prompt for username and clientId when using env token", async () => { false,
const { configureWithEnvToken } = await import("./onboarding.js"); {} as Parameters<typeof configureWithEnvToken>[5],
);
// Reset and set up mocks - user accepts env token
mockPromptConfirm.mockReset().mockResolvedValue(true as never); // Since user declined, should return null without prompting for username/clientId
expect(result).toBeNull();
// Set up mocks for username and clientId prompts expect(mockPromptText).not.toHaveBeenCalled();
mockPromptText.mockReset().mockResolvedValueOnce("testbot" as never).mockResolvedValueOnce("test-client-id" as never); });
const result = await configureWithEnvToken( it("should prompt for username and clientId when using env token", async () => {
{} as Parameters<typeof configureWithEnvToken>[0], const { configureWithEnvToken } = await import("./onboarding.js");
mockPrompter,
null, // Reset and set up mocks - user accepts env token
"oauth:fromenv", mockPromptConfirm.mockReset().mockResolvedValue(true as never);
false,
{} as Parameters<typeof configureWithEnvToken>[5], // Set up mocks for username and clientId prompts
); mockPromptText
.mockReset()
// Should return config with username and clientId .mockResolvedValueOnce("testbot" as never)
expect(result).not.toBeNull(); .mockResolvedValueOnce("test-client-id" as never);
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<typeof configureWithEnvToken>[0],
}); mockPrompter,
null,
"oauth:fromenv",
false,
{} as Parameters<typeof configureWithEnvToken>[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");
});
});
}); });

View File

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

View File

@ -15,389 +15,389 @@ import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
// Mock dependencies // Mock dependencies
vi.mock("./config.js", () => ({ vi.mock("./config.js", () => ({
DEFAULT_ACCOUNT_ID: "default", DEFAULT_ACCOUNT_ID: "default",
getAccountConfig: vi.fn(), getAccountConfig: vi.fn(),
parsePluginConfig: vi.fn(() => ({ stripMarkdown: true })), parsePluginConfig: vi.fn(() => ({ stripMarkdown: true })),
})); }));
vi.mock("./send.js", () => ({ vi.mock("./send.js", () => ({
sendMessageTwitchInternal: vi.fn(), sendMessageTwitchInternal: vi.fn(),
})); }));
vi.mock("./utils/markdown.js", () => ({ 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", () => ({ vi.mock("./utils/twitch.js", () => ({
normalizeTwitchChannel: (channel: string) => channel.toLowerCase().replace(/^#/, ""), normalizeTwitchChannel: (channel: string) => channel.toLowerCase().replace(/^#/, ""),
missingTargetError: (channel: string, hint: string) => missingTargetError: (channel: string, hint: string) =>
`Missing target for ${channel}. Provide ${hint}`, `Missing target for ${channel}. Provide ${hint}`,
})); }));
describe("outbound", () => { describe("outbound", () => {
const mockAccount = { const mockAccount = {
username: "testbot", username: "testbot",
token: "oauth:test123", token: "oauth:test123",
clientId: "test-client-id", clientId: "test-client-id",
channel: "#testchannel", channel: "#testchannel",
}; };
const mockConfig = { const mockConfig = {
channels: { channels: {
twitch: { twitch: {
accounts: { accounts: {
default: mockAccount, default: mockAccount,
}, },
}, },
}, },
} as unknown as ClawdbotConfig; } as unknown as ClawdbotConfig;
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
afterEach(() => { afterEach(() => {
vi.restoreAllMocks(); vi.restoreAllMocks();
}); });
describe("metadata", () => { describe("metadata", () => {
it("should have direct delivery mode", () => { it("should have direct delivery mode", () => {
expect(twitchOutbound.deliveryMode).toBe("direct"); expect(twitchOutbound.deliveryMode).toBe("direct");
}); });
it("should have 500 character text chunk limit", () => { it("should have 500 character text chunk limit", () => {
expect(twitchOutbound.textChunkLimit).toBe(500); expect(twitchOutbound.textChunkLimit).toBe(500);
}); });
it("should have chunker function", () => { it("should have chunker function", () => {
expect(twitchOutbound.chunker).toBeDefined(); expect(twitchOutbound.chunker).toBeDefined();
expect(typeof twitchOutbound.chunker).toBe("function"); expect(typeof twitchOutbound.chunker).toBe("function");
}); });
}); });
describe("resolveTarget", () => { describe("resolveTarget", () => {
it("should normalize and return target in explicit mode", () => { it("should normalize and return target in explicit mode", () => {
const result = twitchOutbound.resolveTarget({ const result = twitchOutbound.resolveTarget({
to: "#MyChannel", to: "#MyChannel",
mode: "explicit", mode: "explicit",
allowFrom: [], allowFrom: [],
}); });
expect(result.ok).toBe(true); expect(result.ok).toBe(true);
expect(result.to).toBe("mychannel"); expect(result.to).toBe("mychannel");
}); });
it("should return target in implicit mode with wildcard allowlist", () => { it("should return target in implicit mode with wildcard allowlist", () => {
const result = twitchOutbound.resolveTarget({ const result = twitchOutbound.resolveTarget({
to: "#AnyChannel", to: "#AnyChannel",
mode: "implicit", mode: "implicit",
allowFrom: ["*"], allowFrom: ["*"],
}); });
expect(result.ok).toBe(true); expect(result.ok).toBe(true);
expect(result.to).toBe("anychannel"); expect(result.to).toBe("anychannel");
}); });
it("should return target in implicit mode when in allowlist", () => { it("should return target in implicit mode when in allowlist", () => {
const result = twitchOutbound.resolveTarget({ const result = twitchOutbound.resolveTarget({
to: "#allowed", to: "#allowed",
mode: "implicit", mode: "implicit",
allowFrom: ["#allowed", "#other"], allowFrom: ["#allowed", "#other"],
}); });
expect(result.ok).toBe(true); expect(result.ok).toBe(true);
expect(result.to).toBe("allowed"); expect(result.to).toBe("allowed");
}); });
it("should fallback to first allowlist entry when target not in list", () => { it("should fallback to first allowlist entry when target not in list", () => {
const result = twitchOutbound.resolveTarget({ const result = twitchOutbound.resolveTarget({
to: "#notallowed", to: "#notallowed",
mode: "implicit", mode: "implicit",
allowFrom: ["#primary", "#secondary"], allowFrom: ["#primary", "#secondary"],
}); });
expect(result.ok).toBe(true); expect(result.ok).toBe(true);
expect(result.to).toBe("primary"); expect(result.to).toBe("primary");
}); });
it("should accept any target when allowlist is empty", () => { it("should accept any target when allowlist is empty", () => {
const result = twitchOutbound.resolveTarget({ const result = twitchOutbound.resolveTarget({
to: "#anychannel", to: "#anychannel",
mode: "heartbeat", mode: "heartbeat",
allowFrom: [], allowFrom: [],
}); });
expect(result.ok).toBe(true); expect(result.ok).toBe(true);
expect(result.to).toBe("anychannel"); expect(result.to).toBe("anychannel");
}); });
it("should use first allowlist entry when no target provided", () => { it("should use first allowlist entry when no target provided", () => {
const result = twitchOutbound.resolveTarget({ const result = twitchOutbound.resolveTarget({
to: undefined, to: undefined,
mode: "implicit", mode: "implicit",
allowFrom: ["#fallback", "#other"], allowFrom: ["#fallback", "#other"],
}); });
expect(result.ok).toBe(true); expect(result.ok).toBe(true);
expect(result.to).toBe("fallback"); expect(result.to).toBe("fallback");
}); });
it("should return error when no target and no allowlist", () => { it("should return error when no target and no allowlist", () => {
const result = twitchOutbound.resolveTarget({ const result = twitchOutbound.resolveTarget({
to: undefined, to: undefined,
mode: "explicit", mode: "explicit",
allowFrom: [], allowFrom: [],
}); });
expect(result.ok).toBe(false); expect(result.ok).toBe(false);
expect(result.error).toContain("Missing target"); expect(result.error).toContain("Missing target");
}); });
it("should handle whitespace-only target", () => { it("should handle whitespace-only target", () => {
const result = twitchOutbound.resolveTarget({ const result = twitchOutbound.resolveTarget({
to: " ", to: " ",
mode: "explicit", mode: "explicit",
allowFrom: [], allowFrom: [],
}); });
expect(result.ok).toBe(false); expect(result.ok).toBe(false);
expect(result.error).toContain("Missing target"); expect(result.error).toContain("Missing target");
}); });
it("should filter wildcard from allowlist when checking membership", () => { it("should filter wildcard from allowlist when checking membership", () => {
const result = twitchOutbound.resolveTarget({ const result = twitchOutbound.resolveTarget({
to: "#mychannel", to: "#mychannel",
mode: "implicit", mode: "implicit",
allowFrom: ["*", "#specific"], allowFrom: ["*", "#specific"],
}); });
// With wildcard, any target is accepted // With wildcard, any target is accepted
expect(result.ok).toBe(true); expect(result.ok).toBe(true);
expect(result.to).toBe("mychannel"); expect(result.to).toBe("mychannel");
}); });
}); });
describe("sendText", () => { describe("sendText", () => {
it("should send message successfully", async () => { it("should send message successfully", async () => {
const { getAccountConfig } = await import("./config.js"); const { getAccountConfig } = await import("./config.js");
const { sendMessageTwitchInternal } = await import("./send.js"); const { sendMessageTwitchInternal } = await import("./send.js");
vi.mocked(getAccountConfig).mockReturnValue(mockAccount); vi.mocked(getAccountConfig).mockReturnValue(mockAccount);
vi.mocked(sendMessageTwitchInternal).mockResolvedValue({ vi.mocked(sendMessageTwitchInternal).mockResolvedValue({
ok: true, ok: true,
messageId: "twitch-msg-123", messageId: "twitch-msg-123",
}); });
const result = await twitchOutbound.sendText({ const result = await twitchOutbound.sendText({
cfg: mockConfig, cfg: mockConfig,
to: "#testchannel", to: "#testchannel",
text: "Hello Twitch!", text: "Hello Twitch!",
accountId: "default", accountId: "default",
}); });
expect(result.channel).toBe("twitch"); expect(result.channel).toBe("twitch");
expect(result.messageId).toBe("twitch-msg-123"); expect(result.messageId).toBe("twitch-msg-123");
expect(result.to).toBe("testchannel"); expect(result.to).toBe("testchannel");
expect(result.timestamp).toBeGreaterThan(0); expect(result.timestamp).toBeGreaterThan(0);
}); });
it("should throw when account not found", async () => { it("should throw when account not found", async () => {
const { getAccountConfig } = await import("./config.js"); const { getAccountConfig } = await import("./config.js");
vi.mocked(getAccountConfig).mockReturnValue(null); vi.mocked(getAccountConfig).mockReturnValue(null);
await expect( await expect(
twitchOutbound.sendText({ twitchOutbound.sendText({
cfg: mockConfig, cfg: mockConfig,
to: "#testchannel", to: "#testchannel",
text: "Hello!", text: "Hello!",
accountId: "nonexistent", accountId: "nonexistent",
}), }),
).rejects.toThrow("Twitch account not found: nonexistent"); ).rejects.toThrow("Twitch account not found: nonexistent");
}); });
it("should throw when no channel specified", async () => { it("should throw when no channel specified", async () => {
const { getAccountConfig } = await import("./config.js"); const { getAccountConfig } = await import("./config.js");
const accountWithoutChannel = { ...mockAccount, channel: undefined, username: undefined }; const accountWithoutChannel = { ...mockAccount, channel: undefined, username: undefined };
vi.mocked(getAccountConfig).mockReturnValue(accountWithoutChannel); vi.mocked(getAccountConfig).mockReturnValue(accountWithoutChannel);
await expect( await expect(
twitchOutbound.sendText({ twitchOutbound.sendText({
cfg: mockConfig, cfg: mockConfig,
to: undefined, to: undefined,
text: "Hello!", text: "Hello!",
accountId: "default", accountId: "default",
}), }),
).rejects.toThrow("No channel specified"); ).rejects.toThrow("No channel specified");
}); });
it("should use account channel when target not provided", async () => { it("should use account channel when target not provided", async () => {
const { getAccountConfig } = await import("./config.js"); const { getAccountConfig } = await import("./config.js");
const { sendMessageTwitchInternal } = await import("./send.js"); const { sendMessageTwitchInternal } = await import("./send.js");
vi.mocked(getAccountConfig).mockReturnValue(mockAccount); vi.mocked(getAccountConfig).mockReturnValue(mockAccount);
vi.mocked(sendMessageTwitchInternal).mockResolvedValue({ vi.mocked(sendMessageTwitchInternal).mockResolvedValue({
ok: true, ok: true,
messageId: "msg-456", messageId: "msg-456",
}); });
await twitchOutbound.sendText({ await twitchOutbound.sendText({
cfg: mockConfig, cfg: mockConfig,
to: undefined, to: undefined,
text: "Hello!", text: "Hello!",
accountId: "default", accountId: "default",
}); });
expect(sendMessageTwitchInternal).toHaveBeenCalledWith( expect(sendMessageTwitchInternal).toHaveBeenCalledWith(
"testchannel", "testchannel",
"Hello!", "Hello!",
mockConfig, mockConfig,
"default", "default",
true, true,
console, console,
); );
}); });
it("should handle abort signal", async () => { it("should handle abort signal", async () => {
const abortController = new AbortController(); const abortController = new AbortController();
abortController.abort(); abortController.abort();
await expect( await expect(
twitchOutbound.sendText({ twitchOutbound.sendText({
cfg: mockConfig, cfg: mockConfig,
to: "#testchannel", to: "#testchannel",
text: "Hello!", text: "Hello!",
accountId: "default", accountId: "default",
signal: abortController.signal, signal: abortController.signal,
}), }),
).rejects.toThrow("Outbound delivery aborted"); ).rejects.toThrow("Outbound delivery aborted");
}); });
it("should throw on send failure", async () => { it("should throw on send failure", async () => {
const { getAccountConfig } = await import("./config.js"); const { getAccountConfig } = await import("./config.js");
const { sendMessageTwitchInternal } = await import("./send.js"); const { sendMessageTwitchInternal } = await import("./send.js");
vi.mocked(getAccountConfig).mockReturnValue(mockAccount); vi.mocked(getAccountConfig).mockReturnValue(mockAccount);
vi.mocked(sendMessageTwitchInternal).mockResolvedValue({ vi.mocked(sendMessageTwitchInternal).mockResolvedValue({
ok: false, ok: false,
messageId: "failed-msg", messageId: "failed-msg",
error: "Connection lost", error: "Connection lost",
}); });
await expect( await expect(
twitchOutbound.sendText({ twitchOutbound.sendText({
cfg: mockConfig, cfg: mockConfig,
to: "#testchannel", to: "#testchannel",
text: "Hello!", text: "Hello!",
accountId: "default", accountId: "default",
}), }),
).rejects.toThrow("Connection lost"); ).rejects.toThrow("Connection lost");
}); });
it("should respect stripMarkdown config", async () => { it("should respect stripMarkdown config", async () => {
const { getAccountConfig } = await import("./config.js"); const { getAccountConfig } = await import("./config.js");
const { parsePluginConfig } = await import("./config.js"); const { parsePluginConfig } = await import("./config.js");
const { sendMessageTwitchInternal } = await import("./send.js"); const { sendMessageTwitchInternal } = await import("./send.js");
vi.mocked(getAccountConfig).mockReturnValue(mockAccount); vi.mocked(getAccountConfig).mockReturnValue(mockAccount);
vi.mocked(parsePluginConfig).mockReturnValue({ stripMarkdown: false }); vi.mocked(parsePluginConfig).mockReturnValue({ stripMarkdown: false });
vi.mocked(sendMessageTwitchInternal).mockResolvedValue({ vi.mocked(sendMessageTwitchInternal).mockResolvedValue({
ok: true, ok: true,
messageId: "msg-789", messageId: "msg-789",
}); });
await twitchOutbound.sendText({ await twitchOutbound.sendText({
cfg: mockConfig, cfg: mockConfig,
to: "#testchannel", to: "#testchannel",
text: "**Bold** text", text: "**Bold** text",
accountId: "default", accountId: "default",
}); });
expect(sendMessageTwitchInternal).toHaveBeenCalledWith( expect(sendMessageTwitchInternal).toHaveBeenCalledWith(
expect.anything(), expect.anything(),
expect.anything(), expect.anything(),
expect.anything(), expect.anything(),
expect.anything(), expect.anything(),
false, // stripMarkdown disabled false, // stripMarkdown disabled
expect.anything(), expect.anything(),
); );
}); });
}); });
describe("sendMedia", () => { describe("sendMedia", () => {
it("should combine text and media URL", async () => { it("should combine text and media URL", async () => {
const { sendMessageTwitchInternal } = await import("./send.js"); const { sendMessageTwitchInternal } = await import("./send.js");
const { getAccountConfig } = await import("./config.js"); const { getAccountConfig } = await import("./config.js");
vi.mocked(getAccountConfig).mockReturnValue(mockAccount); vi.mocked(getAccountConfig).mockReturnValue(mockAccount);
vi.mocked(sendMessageTwitchInternal).mockResolvedValue({ vi.mocked(sendMessageTwitchInternal).mockResolvedValue({
ok: true, ok: true,
messageId: "media-msg-123", messageId: "media-msg-123",
}); });
const result = await twitchOutbound.sendMedia({ const result = await twitchOutbound.sendMedia({
cfg: mockConfig, cfg: mockConfig,
to: "#testchannel", to: "#testchannel",
text: "Check this:", text: "Check this:",
mediaUrl: "https://example.com/image.png", mediaUrl: "https://example.com/image.png",
accountId: "default", accountId: "default",
}); });
expect(result.channel).toBe("twitch"); expect(result.channel).toBe("twitch");
expect(result.messageId).toBe("media-msg-123"); expect(result.messageId).toBe("media-msg-123");
expect(sendMessageTwitchInternal).toHaveBeenCalledWith( expect(sendMessageTwitchInternal).toHaveBeenCalledWith(
expect.anything(), expect.anything(),
"Check this: https://example.com/image.png", "Check this: https://example.com/image.png",
expect.anything(), expect.anything(),
expect.anything(), expect.anything(),
expect.anything(), expect.anything(),
expect.anything(), expect.anything(),
); );
}); });
it("should send media URL only when no text", async () => { it("should send media URL only when no text", async () => {
const { sendMessageTwitchInternal } = await import("./send.js"); const { sendMessageTwitchInternal } = await import("./send.js");
const { getAccountConfig } = await import("./config.js"); const { getAccountConfig } = await import("./config.js");
vi.mocked(getAccountConfig).mockReturnValue(mockAccount); vi.mocked(getAccountConfig).mockReturnValue(mockAccount);
vi.mocked(sendMessageTwitchInternal).mockResolvedValue({ vi.mocked(sendMessageTwitchInternal).mockResolvedValue({
ok: true, ok: true,
messageId: "media-only-msg", messageId: "media-only-msg",
}); });
await twitchOutbound.sendMedia({ await twitchOutbound.sendMedia({
cfg: mockConfig, cfg: mockConfig,
to: "#testchannel", to: "#testchannel",
text: undefined, text: undefined,
mediaUrl: "https://example.com/image.png", mediaUrl: "https://example.com/image.png",
accountId: "default", accountId: "default",
}); });
expect(sendMessageTwitchInternal).toHaveBeenCalledWith( expect(sendMessageTwitchInternal).toHaveBeenCalledWith(
expect.anything(), expect.anything(),
"https://example.com/image.png", "https://example.com/image.png",
expect.anything(), expect.anything(),
expect.anything(), expect.anything(),
expect.anything(), expect.anything(),
expect.anything(), expect.anything(),
); );
}); });
it("should handle abort signal", async () => { it("should handle abort signal", async () => {
const abortController = new AbortController(); const abortController = new AbortController();
abortController.abort(); abortController.abort();
await expect( await expect(
twitchOutbound.sendMedia({ twitchOutbound.sendMedia({
cfg: mockConfig, cfg: mockConfig,
to: "#testchannel", to: "#testchannel",
text: "Check this:", text: "Check this:",
mediaUrl: "https://example.com/image.png", mediaUrl: "https://example.com/image.png",
accountId: "default", accountId: "default",
signal: abortController.signal, signal: abortController.signal,
}), }),
).rejects.toThrow("Outbound delivery aborted"); ).rejects.toThrow("Outbound delivery aborted");
}); });
}); });
}); });

View File

@ -5,16 +5,12 @@
* Supports text and media (URL) sending with markdown stripping and chunking. * Supports text and media (URL) sending with markdown stripping and chunking.
*/ */
import { import { DEFAULT_ACCOUNT_ID, getAccountConfig, parsePluginConfig } from "./config.js";
DEFAULT_ACCOUNT_ID,
getAccountConfig,
parsePluginConfig,
} from "./config.js";
import { sendMessageTwitchInternal } from "./send.js"; import { sendMessageTwitchInternal } from "./send.js";
import type { import type {
ChannelOutboundAdapter, ChannelOutboundAdapter,
ChannelOutboundContext, ChannelOutboundContext,
OutboundDeliveryResult, OutboundDeliveryResult,
} from "./types.js"; } from "./types.js";
import { chunkTextForTwitch } from "./utils/markdown.js"; import { chunkTextForTwitch } from "./utils/markdown.js";
import { missingTargetError, normalizeTwitchChannel } from "./utils/twitch.js"; import { missingTargetError, normalizeTwitchChannel } from "./utils/twitch.js";
@ -26,184 +22,178 @@ import { missingTargetError, normalizeTwitchChannel } from "./utils/twitch.js";
* markdown stripping and message chunking. * markdown stripping and message chunking.
*/ */
export const twitchOutbound: ChannelOutboundAdapter = { export const twitchOutbound: ChannelOutboundAdapter = {
/** Direct delivery mode - messages are sent immediately */ /** Direct delivery mode - messages are sent immediately */
deliveryMode: "direct", deliveryMode: "direct",
/** Twitch chat message limit is 500 characters */ /** Twitch chat message limit is 500 characters */
textChunkLimit: 500, textChunkLimit: 500,
/** Word-boundary chunker with markdown stripping */ /** Word-boundary chunker with markdown stripping */
chunker: chunkTextForTwitch, chunker: chunkTextForTwitch,
/** /**
* Resolve target from context. * Resolve target from context.
* *
* Handles target resolution with allowlist support for implicit/heartbeat modes. * Handles target resolution with allowlist support for implicit/heartbeat modes.
* For explicit mode, accepts any valid channel name. * For explicit mode, accepts any valid channel name.
* *
* @param params - Resolution parameters * @param params - Resolution parameters
* @returns Resolved target or error * @returns Resolved target or error
*/ */
resolveTarget: ({ to, allowFrom, mode }) => { resolveTarget: ({ to, allowFrom, mode }) => {
const trimmed = to?.trim() ?? ""; const trimmed = to?.trim() ?? "";
const allowListRaw = (allowFrom ?? []) const allowListRaw = (allowFrom ?? [])
.map((entry: unknown) => String(entry).trim()) .map((entry: unknown) => String(entry).trim())
.filter(Boolean); .filter(Boolean);
const hasWildcard = allowListRaw.includes("*"); const hasWildcard = allowListRaw.includes("*");
const allowList = allowListRaw const allowList = allowListRaw
.filter((entry: string) => entry !== "*") .filter((entry: string) => entry !== "*")
.map((entry: string) => normalizeTwitchChannel(entry)) .map((entry: string) => normalizeTwitchChannel(entry))
.filter((entry): entry is string => entry.length > 0); .filter((entry): entry is string => entry.length > 0);
// If target is provided, normalize and validate it // If target is provided, normalize and validate it
if (trimmed) { if (trimmed) {
const normalizedTo = normalizeTwitchChannel(trimmed); const normalizedTo = normalizeTwitchChannel(trimmed);
// For implicit/heartbeat modes with allowList, check against allowlist // For implicit/heartbeat modes with allowList, check against allowlist
if (mode === "implicit" || mode === "heartbeat") { if (mode === "implicit" || mode === "heartbeat") {
if (hasWildcard || allowList.length === 0) { if (hasWildcard || allowList.length === 0) {
return { ok: true, to: normalizedTo }; return { ok: true, to: normalizedTo };
} }
if (allowList.includes(normalizedTo)) { if (allowList.includes(normalizedTo)) {
return { ok: true, to: normalizedTo }; return { ok: true, to: normalizedTo };
} }
// Fallback to first allowFrom entry // Fallback to first allowFrom entry
// biome-ignore lint/style/noNonNullAssertion: length > 0 check ensures element exists // biome-ignore lint/style/noNonNullAssertion: length > 0 check ensures element exists
return { ok: true, to: allowList[0]! }; return { ok: true, to: allowList[0]! };
} }
// For explicit mode, accept any valid channel name // For explicit mode, accept any valid channel name
return { ok: true, to: normalizedTo }; return { ok: true, to: normalizedTo };
} }
// No target provided, use allowFrom fallback // No target provided, use allowFrom fallback
if (allowList.length > 0) { if (allowList.length > 0) {
// biome-ignore lint/style/noNonNullAssertion: length > 0 check ensures element exists // biome-ignore lint/style/noNonNullAssertion: length > 0 check ensures element exists
return { ok: true, to: allowList[0]! }; return { ok: true, to: allowList[0]! };
} }
// No target and no allowFrom - error // No target and no allowFrom - error
return { return {
ok: false, ok: false,
error: missingTargetError( error: missingTargetError(
"Twitch", "Twitch",
"<channel-name> or channels.twitch.accounts.<account>.allowFrom[0]", "<channel-name> or channels.twitch.accounts.<account>.allowFrom[0]",
), ),
}; };
}, },
/** /**
* Send a text message to a Twitch channel. * Send a text message to a Twitch channel.
* *
* Strips markdown if enabled, validates account configuration, * Strips markdown if enabled, validates account configuration,
* and sends the message via the Twitch client. * and sends the message via the Twitch client.
* *
* @param params - Send parameters including target, text, and config * @param params - Send parameters including target, text, and config
* @returns Delivery result with message ID and status * @returns Delivery result with message ID and status
* *
* @example * @example
* const result = await twitchOutbound.sendText({ * const result = await twitchOutbound.sendText({
* cfg: clawdbotConfig, * cfg: clawdbotConfig,
* to: "#mychannel", * to: "#mychannel",
* text: "Hello Twitch!", * text: "Hello Twitch!",
* accountId: "default", * accountId: "default",
* }); * });
*/ */
sendText: async ( sendText: async (params: ChannelOutboundContext): Promise<OutboundDeliveryResult> => {
params: ChannelOutboundContext, const { cfg, to, text, accountId, signal } = params;
): Promise<OutboundDeliveryResult> => {
const { cfg, to, text, accountId, signal } = params;
// Check for abort signal // Check for abort signal
if (signal?.aborted) { if (signal?.aborted) {
throw new Error("Outbound delivery aborted"); throw new Error("Outbound delivery aborted");
} }
// Resolve account // Resolve account
const resolvedAccountId = accountId ?? DEFAULT_ACCOUNT_ID; const resolvedAccountId = accountId ?? DEFAULT_ACCOUNT_ID;
const account = getAccountConfig(cfg, resolvedAccountId); const account = getAccountConfig(cfg, resolvedAccountId);
if (!account) { if (!account) {
const availableIds = Object.keys(cfg.channels?.twitch?.accounts ?? {}); const availableIds = Object.keys(cfg.channels?.twitch?.accounts ?? {});
throw new Error( throw new Error(
`Twitch account not found: ${resolvedAccountId}. ` + `Twitch account not found: ${resolvedAccountId}. ` +
`Available accounts: ${availableIds.join(", ") || "none"}`, `Available accounts: ${availableIds.join(", ") || "none"}`,
); );
} }
// Get channel (support target parameter) // Get channel (support target parameter)
const channel = to || account.channel || account.username; const channel = to || account.channel || account.username;
if (!channel) { if (!channel) {
throw new Error( throw new Error("No channel specified and no default channel in account config");
"No channel specified and no default channel in account config", }
);
}
// Get plugin config for markdown stripping // Get plugin config for markdown stripping
const pluginCfg = parsePluginConfig( const pluginCfg = parsePluginConfig(
// biome-ignore lint/suspicious/noExplicitAny: pluginConfig is not part of CoreConfig // biome-ignore lint/suspicious/noExplicitAny: pluginConfig is not part of CoreConfig
(cfg as any).pluginConfig ?? {}, (cfg as any).pluginConfig ?? {},
); );
// Send message // Send message
const result = await sendMessageTwitchInternal( const result = await sendMessageTwitchInternal(
normalizeTwitchChannel(channel), normalizeTwitchChannel(channel),
text, text,
cfg, cfg,
resolvedAccountId, resolvedAccountId,
pluginCfg.stripMarkdown ?? true, pluginCfg.stripMarkdown ?? true,
console, console,
); );
if (!result.ok) { if (!result.ok) {
throw new Error(result.error ?? "Send failed"); throw new Error(result.error ?? "Send failed");
} }
return { return {
channel: "twitch", channel: "twitch",
messageId: result.messageId, messageId: result.messageId,
timestamp: Date.now(), timestamp: Date.now(),
to: normalizeTwitchChannel(channel), to: normalizeTwitchChannel(channel),
}; };
}, },
/** /**
* Send media to a Twitch channel. * Send media to a Twitch channel.
* *
* Note: Twitch chat doesn't support direct media uploads. * Note: Twitch chat doesn't support direct media uploads.
* This sends the media URL as text instead. * This sends the media URL as text instead.
* *
* @param params - Send parameters including media URL * @param params - Send parameters including media URL
* @returns Delivery result with message ID and status * @returns Delivery result with message ID and status
* *
* @example * @example
* const result = await twitchOutbound.sendMedia({ * const result = await twitchOutbound.sendMedia({
* cfg: clawdbotConfig, * cfg: clawdbotConfig,
* to: "#mychannel", * to: "#mychannel",
* text: "Check this out!", * text: "Check this out!",
* mediaUrl: "https://example.com/image.png", * mediaUrl: "https://example.com/image.png",
* accountId: "default", * accountId: "default",
* }); * });
*/ */
sendMedia: async ( sendMedia: async (params: ChannelOutboundContext): Promise<OutboundDeliveryResult> => {
params: ChannelOutboundContext, const { text, mediaUrl, signal } = params;
): Promise<OutboundDeliveryResult> => {
const { text, mediaUrl, signal } = params;
// Check for abort signal // Check for abort signal
if (signal?.aborted) { if (signal?.aborted) {
throw new Error("Outbound delivery aborted"); throw new Error("Outbound delivery aborted");
} }
// Combine text and media URL // Combine text and media URL
const message = mediaUrl ? `${text || ""} ${mediaUrl}`.trim() : text; const message = mediaUrl ? `${text || ""} ${mediaUrl}`.trim() : text;
// Delegate to sendText // Delegate to sendText
if (!twitchOutbound.sendText) { if (!twitchOutbound.sendText) {
throw new Error("sendText not implemented"); throw new Error("sendText not implemented");
} }
return twitchOutbound.sendText({ return twitchOutbound.sendText({
...params, ...params,
text: message, text: message,
}); });
}, },
}; };

View File

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

View File

@ -7,139 +7,139 @@ const mockConnect = vi.fn().mockResolvedValue(undefined);
const mockQuit = vi.fn().mockResolvedValue(undefined); const mockQuit = vi.fn().mockResolvedValue(undefined);
vi.mock("@twurple/chat", () => ({ vi.mock("@twurple/chat", () => ({
ChatClient: class { ChatClient: class {
connect = mockConnect; connect = mockConnect;
quit = mockQuit; quit = mockQuit;
}, },
})); }));
vi.mock("@twurple/auth", () => ({ vi.mock("@twurple/auth", () => ({
StaticAuthProvider: class {}, StaticAuthProvider: class {},
})); }));
describe("probeTwitch", () => { describe("probeTwitch", () => {
const mockAccount: TwitchAccountConfig = { const mockAccount: TwitchAccountConfig = {
username: "testbot", username: "testbot",
token: "oauth:test123456789", token: "oauth:test123456789",
}; };
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
it("returns error when username is missing", async () => { it("returns error when username is missing", async () => {
const account = { ...mockAccount, username: "" as unknown as string }; const account = { ...mockAccount, username: "" as unknown as string };
const result = await probeTwitch(account, 5000); const result = await probeTwitch(account, 5000);
expect(result.ok).toBe(false); expect(result.ok).toBe(false);
expect(result.error).toContain("missing credentials"); expect(result.error).toContain("missing credentials");
expect(result.elapsedMs).toBeGreaterThanOrEqual(0); expect(result.elapsedMs).toBeGreaterThanOrEqual(0);
}); });
it("returns error when token is missing", async () => { it("returns error when token is missing", async () => {
const account = { ...mockAccount, token: "" as unknown as string }; const account = { ...mockAccount, token: "" as unknown as string };
const result = await probeTwitch(account, 5000); const result = await probeTwitch(account, 5000);
expect(result.ok).toBe(false); expect(result.ok).toBe(false);
expect(result.error).toContain("missing credentials"); expect(result.error).toContain("missing credentials");
}); });
it("attempts connection regardless of token prefix", async () => { it("attempts connection regardless of token prefix", async () => {
// Note: probeTwitch doesn't validate token format - it tries to connect with whatever token is provided // 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 // The actual connection would fail in production with an invalid token
const account = { ...mockAccount, token: "raw_token_no_prefix" }; const account = { ...mockAccount, token: "raw_token_no_prefix" };
const result = await probeTwitch(account, 5000); const result = await probeTwitch(account, 5000);
// With mock, connection succeeds even without oauth: prefix // With mock, connection succeeds even without oauth: prefix
expect(result.ok).toBe(true); expect(result.ok).toBe(true);
}); });
it("successfully connects with valid credentials", async () => { it("successfully connects with valid credentials", async () => {
const result = await probeTwitch(mockAccount, 5000); const result = await probeTwitch(mockAccount, 5000);
expect(result.ok).toBe(true); expect(result.ok).toBe(true);
expect(result.connected).toBe(true); expect(result.connected).toBe(true);
expect(result.username).toBe("testbot"); expect(result.username).toBe("testbot");
expect(result.channel).toBe("testbot"); // defaults to username expect(result.channel).toBe("testbot"); // defaults to username
expect(result.elapsedMs).toBeGreaterThan(0); expect(result.elapsedMs).toBeGreaterThan(0);
}); });
it("uses custom channel when specified", async () => { it("uses custom channel when specified", async () => {
const account: TwitchAccountConfig = { const account: TwitchAccountConfig = {
...mockAccount, ...mockAccount,
channel: "customchannel", channel: "customchannel",
}; };
const result = await probeTwitch(account, 5000); const result = await probeTwitch(account, 5000);
expect(result.ok).toBe(true); expect(result.ok).toBe(true);
expect(result.channel).toBe("customchannel"); expect(result.channel).toBe("customchannel");
}); });
it("times out when connection takes too long", async () => { it("times out when connection takes too long", async () => {
mockConnect.mockImplementationOnce(() => new Promise(() => {})); // Never resolves 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.ok).toBe(false);
expect(result.error).toContain("timeout"); expect(result.error).toContain("timeout");
// Reset mock // Reset mock
mockConnect.mockResolvedValue(undefined); mockConnect.mockResolvedValue(undefined);
}); });
it("cleans up client even on failure", async () => { it("cleans up client even on failure", async () => {
mockConnect.mockRejectedValueOnce(new Error("Connection failed")); 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.ok).toBe(false);
expect(result.error).toContain("Connection failed"); expect(result.error).toContain("Connection failed");
expect(mockQuit).toHaveBeenCalled(); expect(mockQuit).toHaveBeenCalled();
// Reset mocks // Reset mocks
mockConnect.mockResolvedValue(undefined); mockConnect.mockResolvedValue(undefined);
}); });
it("measures elapsed time", async () => { it("measures elapsed time", async () => {
const result = await probeTwitch(mockAccount, 5000); const result = await probeTwitch(mockAccount, 5000);
expect(result.ok).toBe(true); expect(result.ok).toBe(true);
expect(result.elapsedMs).toBeGreaterThan(0); expect(result.elapsedMs).toBeGreaterThan(0);
}); });
it("handles connection errors gracefully", async () => { it("handles connection errors gracefully", async () => {
mockConnect.mockRejectedValueOnce(new Error("Network error")); 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.ok).toBe(false);
expect(result.error).toContain("Network error"); expect(result.error).toContain("Network error");
// Reset mock // Reset mock
mockConnect.mockResolvedValue(undefined); mockConnect.mockResolvedValue(undefined);
}); });
it("trims token before validation", async () => { it("trims token before validation", async () => {
const account: TwitchAccountConfig = { const account: TwitchAccountConfig = {
...mockAccount, ...mockAccount,
token: " oauth:test123456789 ", 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 () => { it("handles non-Error objects in catch block", async () => {
mockConnect.mockRejectedValueOnce("String error"); mockConnect.mockRejectedValueOnce("String error");
const result = await probeTwitch(mockAccount, 5000); const result = await probeTwitch(mockAccount, 5000);
expect(result.ok).toBe(false); expect(result.ok).toBe(false);
expect(result.error).toBe("String error"); expect(result.error).toBe("String error");
// Reset mock // Reset mock
mockConnect.mockResolvedValue(undefined); mockConnect.mockResolvedValue(undefined);
}); });
}); });

View File

@ -6,12 +6,12 @@ import type { TwitchAccountConfig } from "./types.js";
* Result of probing a Twitch account * Result of probing a Twitch account
*/ */
export type ProbeTwitchResult = { export type ProbeTwitchResult = {
ok: boolean; ok: boolean;
error?: string; error?: string;
username?: string; username?: string;
elapsedMs: number; elapsedMs: number;
connected?: boolean; connected?: boolean;
channel?: string; channel?: string;
}; };
/** /**
@ -21,108 +21,105 @@ export type ProbeTwitchResult = {
* to the chat server and verify the bot's username. * to the chat server and verify the bot's username.
*/ */
export async function probeTwitch( export async function probeTwitch(
account: TwitchAccountConfig, account: TwitchAccountConfig,
timeoutMs: number, timeoutMs: number,
): Promise<ProbeTwitchResult> { ): Promise<ProbeTwitchResult> {
const started = Date.now(); const started = Date.now();
if (!account.token || !account.username) { if (!account.token || !account.username) {
return { return {
ok: false, ok: false,
error: "missing credentials (token, username)", error: "missing credentials (token, username)",
username: account.username, username: account.username,
elapsedMs: Date.now() - started, elapsedMs: Date.now() - started,
}; };
} }
const rawToken = account.token.trim(); const rawToken = account.token.trim();
let client: ChatClient | undefined; let client: ChatClient | undefined;
try { try {
// Create auth provider with the token // Create auth provider with the token
const authProvider = new StaticAuthProvider( const authProvider = new StaticAuthProvider(account.clientId ?? "", rawToken);
account.clientId ?? "",
rawToken,
);
// Create chat client // Create chat client
client = new ChatClient({ client = new ChatClient({
authProvider, authProvider,
}); });
// Create a promise that resolves when connected // Create a promise that resolves when connected
const connectionPromise = new Promise<void>((resolve, reject) => { const connectionPromise = new Promise<void>((resolve, reject) => {
let settled = false; let settled = false;
let connectListener: ReturnType<ChatClient["onConnect"]> | undefined; let connectListener: ReturnType<ChatClient["onConnect"]> | undefined;
let disconnectListener: ReturnType<ChatClient["onDisconnect"]> | undefined; let disconnectListener: ReturnType<ChatClient["onDisconnect"]> | undefined;
let authFailListener: ReturnType<ChatClient["onAuthenticationFailure"]> | undefined; let authFailListener: ReturnType<ChatClient["onAuthenticationFailure"]> | undefined;
const cleanup = () => { const cleanup = () => {
if (settled) return; if (settled) return;
settled = true; settled = true;
// Remove all listeners // Remove all listeners
connectListener?.unbind(); connectListener?.unbind();
disconnectListener?.unbind(); disconnectListener?.unbind();
authFailListener?.unbind(); authFailListener?.unbind();
}; };
// Success: connection established // Success: connection established
connectListener = client?.onConnect(() => { connectListener = client?.onConnect(() => {
cleanup(); cleanup();
resolve(); resolve();
}); });
// Failure: disconnected (e.g., auth failed) // Failure: disconnected (e.g., auth failed)
disconnectListener = client?.onDisconnect((_manually, reason) => { disconnectListener = client?.onDisconnect((_manually, reason) => {
cleanup(); cleanup();
reject(reason || new Error("Disconnected")); reject(reason || new Error("Disconnected"));
}); });
// Failure: authentication failed // Failure: authentication failed
authFailListener = client?.onAuthenticationFailure(() => { authFailListener = client?.onAuthenticationFailure(() => {
cleanup(); cleanup();
reject(new Error("Authentication failed")); reject(new Error("Authentication failed"));
}); });
}); });
// Create timeout promise // Create timeout promise
const timeout = new Promise<never>((_, reject) => { const timeout = new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error(`timeout after ${timeoutMs}ms`)), timeoutMs); setTimeout(() => reject(new Error(`timeout after ${timeoutMs}ms`)), timeoutMs);
}); });
// Set up listeners BEFORE connecting, then race against timeout // Set up listeners BEFORE connecting, then race against timeout
client.connect(); client.connect();
await Promise.race([connectionPromise, timeout]); await Promise.race([connectionPromise, timeout]);
// Clean up connection before returning // Clean up connection before returning
client.quit(); client.quit();
client = undefined; client = undefined;
// If we got here, connection was successful // If we got here, connection was successful
return { return {
ok: true, ok: true,
connected: true, connected: true,
username: account.username, username: account.username,
channel: account.channel ?? account.username, channel: account.channel ?? account.username,
elapsedMs: Date.now() - started, elapsedMs: Date.now() - started,
}; };
} catch (error) { } catch (error) {
return { return {
ok: false, ok: false,
error: error instanceof Error ? error.message : String(error), error: error instanceof Error ? error.message : String(error),
username: account.username, username: account.username,
channel: account.channel ?? account.username, channel: account.channel ?? account.username,
elapsedMs: Date.now() - started, elapsedMs: Date.now() - started,
}; };
} finally { } finally {
// Always clean up the client // Always clean up the client
if (client) { if (client) {
try { try {
client.quit(); client.quit();
} catch { } catch {
// Ignore cleanup errors // Ignore cleanup errors
} }
} }
} }
} }

View File

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

View File

@ -16,277 +16,275 @@ import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
// Mock dependencies // Mock dependencies
vi.mock("./config.js", () => ({ vi.mock("./config.js", () => ({
DEFAULT_ACCOUNT_ID: "default", DEFAULT_ACCOUNT_ID: "default",
getAccountConfig: vi.fn(), getAccountConfig: vi.fn(),
})); }));
vi.mock("./utils/twitch.js", () => ({ vi.mock("./utils/twitch.js", () => ({
generateMessageId: vi.fn(() => "test-msg-id"), generateMessageId: vi.fn(() => "test-msg-id"),
isAccountConfigured: vi.fn(() => true), isAccountConfigured: vi.fn(() => true),
normalizeTwitchChannel: (channel: string) => channel.toLowerCase().replace(/^#/, ""), normalizeTwitchChannel: (channel: string) => channel.toLowerCase().replace(/^#/, ""),
})); }));
vi.mock("./utils/markdown.js", () => ({ 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", () => ({ vi.mock("./client-manager-registry.js", () => ({
getClientManager: vi.fn(), getClientManager: vi.fn(),
})); }));
describe("send", () => { describe("send", () => {
const mockLogger = { const mockLogger = {
info: vi.fn(), info: vi.fn(),
warn: vi.fn(), warn: vi.fn(),
error: vi.fn(), error: vi.fn(),
debug: vi.fn(), debug: vi.fn(),
}; };
const mockAccount = { const mockAccount = {
username: "testbot", username: "testbot",
token: "oauth:test123", token: "oauth:test123",
clientId: "test-client-id", clientId: "test-client-id",
channel: "#testchannel", channel: "#testchannel",
}; };
const mockConfig = { const mockConfig = {
channels: { channels: {
twitch: { twitch: {
accounts: { accounts: {
default: mockAccount, default: mockAccount,
}, },
}, },
}, },
} as unknown as ClawdbotConfig; } as unknown as ClawdbotConfig;
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
afterEach(() => { afterEach(() => {
vi.restoreAllMocks(); vi.restoreAllMocks();
}); });
describe("sendMessageTwitchInternal", () => { describe("sendMessageTwitchInternal", () => {
it("should send a message successfully", async () => { it("should send a message successfully", async () => {
const { getAccountConfig } = await import("./config.js"); const { getAccountConfig } = await import("./config.js");
const { getClientManager } = await import("./client-manager-registry.js"); const { getClientManager } = await import("./client-manager-registry.js");
const { stripMarkdownForTwitch } = await import("./utils/markdown.js"); const { stripMarkdownForTwitch } = await import("./utils/markdown.js");
vi.mocked(getAccountConfig).mockReturnValue(mockAccount); vi.mocked(getAccountConfig).mockReturnValue(mockAccount);
vi.mocked(getClientManager).mockReturnValue({ vi.mocked(getClientManager).mockReturnValue({
sendMessage: vi.fn().mockResolvedValue({ sendMessage: vi.fn().mockResolvedValue({
ok: true, ok: true,
messageId: "twitch-msg-123", messageId: "twitch-msg-123",
}), }),
} as ReturnType<typeof getClientManager>); } as ReturnType<typeof getClientManager>);
vi.mocked(stripMarkdownForTwitch).mockImplementation((text) => text); vi.mocked(stripMarkdownForTwitch).mockImplementation((text) => text);
const result = await sendMessageTwitchInternal( const result = await sendMessageTwitchInternal(
"#testchannel", "#testchannel",
"Hello Twitch!", "Hello Twitch!",
mockConfig, mockConfig,
"default", "default",
false, false,
mockLogger as unknown as Console, mockLogger as unknown as Console,
); );
expect(result.ok).toBe(true); expect(result.ok).toBe(true);
expect(result.messageId).toBe("twitch-msg-123"); expect(result.messageId).toBe("twitch-msg-123");
}); });
it("should strip markdown when enabled", async () => { it("should strip markdown when enabled", async () => {
const { getAccountConfig } = await import("./config.js"); const { getAccountConfig } = await import("./config.js");
const { getClientManager } = await import("./client-manager-registry.js"); const { getClientManager } = await import("./client-manager-registry.js");
const { stripMarkdownForTwitch } = await import("./utils/markdown.js"); const { stripMarkdownForTwitch } = await import("./utils/markdown.js");
vi.mocked(getAccountConfig).mockReturnValue(mockAccount); vi.mocked(getAccountConfig).mockReturnValue(mockAccount);
vi.mocked(getClientManager).mockReturnValue({ vi.mocked(getClientManager).mockReturnValue({
sendMessage: vi.fn().mockResolvedValue({ sendMessage: vi.fn().mockResolvedValue({
ok: true, ok: true,
messageId: "twitch-msg-456", messageId: "twitch-msg-456",
}), }),
} as ReturnType<typeof getClientManager>); } as ReturnType<typeof getClientManager>);
vi.mocked(stripMarkdownForTwitch).mockImplementation((text) => vi.mocked(stripMarkdownForTwitch).mockImplementation((text) => text.replace(/\*\*/g, ""));
text.replace(/\*\*/g, ""),
);
await sendMessageTwitchInternal( await sendMessageTwitchInternal(
"#testchannel", "#testchannel",
"**Bold** text", "**Bold** text",
mockConfig, mockConfig,
"default", "default",
true, true,
mockLogger as unknown as Console, mockLogger as unknown as Console,
); );
expect(stripMarkdownForTwitch).toHaveBeenCalledWith("**Bold** text"); expect(stripMarkdownForTwitch).toHaveBeenCalledWith("**Bold** text");
}); });
it("should return error when account not found", async () => { it("should return error when account not found", async () => {
const { getAccountConfig } = await import("./config.js"); const { getAccountConfig } = await import("./config.js");
vi.mocked(getAccountConfig).mockReturnValue(null); vi.mocked(getAccountConfig).mockReturnValue(null);
const result = await sendMessageTwitchInternal( const result = await sendMessageTwitchInternal(
"#testchannel", "#testchannel",
"Hello!", "Hello!",
mockConfig, mockConfig,
"nonexistent", "nonexistent",
false, false,
mockLogger as unknown as Console, mockLogger as unknown as Console,
); );
expect(result.ok).toBe(false); expect(result.ok).toBe(false);
expect(result.error).toContain("Account not found: nonexistent"); expect(result.error).toContain("Account not found: nonexistent");
}); });
it("should return error when account not configured", async () => { it("should return error when account not configured", async () => {
const { getAccountConfig } = await import("./config.js"); const { getAccountConfig } = await import("./config.js");
const { isAccountConfigured } = await import("./utils/twitch.js"); const { isAccountConfigured } = await import("./utils/twitch.js");
vi.mocked(getAccountConfig).mockReturnValue(mockAccount); vi.mocked(getAccountConfig).mockReturnValue(mockAccount);
vi.mocked(isAccountConfigured).mockReturnValue(false); vi.mocked(isAccountConfigured).mockReturnValue(false);
const result = await sendMessageTwitchInternal( const result = await sendMessageTwitchInternal(
"#testchannel", "#testchannel",
"Hello!", "Hello!",
mockConfig, mockConfig,
"default", "default",
false, false,
mockLogger as unknown as Console, mockLogger as unknown as Console,
); );
expect(result.ok).toBe(false); expect(result.ok).toBe(false);
expect(result.error).toContain("not properly configured"); expect(result.error).toContain("not properly configured");
}); });
it("should return error when no channel specified", async () => { it("should return error when no channel specified", async () => {
const { getAccountConfig } = await import("./config.js"); const { getAccountConfig } = await import("./config.js");
const { isAccountConfigured } = await import("./utils/twitch.js"); const { isAccountConfigured } = await import("./utils/twitch.js");
// Set both channel and username to undefined to trigger the error // Set both channel and username to undefined to trigger the error
const accountWithoutChannelOrUsername = { const accountWithoutChannelOrUsername = {
...mockAccount, ...mockAccount,
channel: undefined, channel: undefined,
username: undefined, username: undefined,
}; };
vi.mocked(getAccountConfig).mockReturnValue(accountWithoutChannelOrUsername); vi.mocked(getAccountConfig).mockReturnValue(accountWithoutChannelOrUsername);
vi.mocked(isAccountConfigured).mockReturnValue(true); vi.mocked(isAccountConfigured).mockReturnValue(true);
const result = await sendMessageTwitchInternal( const result = await sendMessageTwitchInternal(
"", "",
"Hello!", "Hello!",
mockConfig, mockConfig,
"default", "default",
false, false,
mockLogger as unknown as Console, mockLogger as unknown as Console,
); );
expect(result.ok).toBe(false); expect(result.ok).toBe(false);
expect(result.error).toContain("No channel specified"); expect(result.error).toContain("No channel specified");
}); });
it("should skip sending empty message after markdown stripping", async () => { it("should skip sending empty message after markdown stripping", async () => {
const { getAccountConfig } = await import("./config.js"); const { getAccountConfig } = await import("./config.js");
const { isAccountConfigured } = await import("./utils/twitch.js"); const { isAccountConfigured } = await import("./utils/twitch.js");
const { stripMarkdownForTwitch } = await import("./utils/markdown.js"); const { stripMarkdownForTwitch } = await import("./utils/markdown.js");
vi.mocked(getAccountConfig).mockReturnValue(mockAccount); vi.mocked(getAccountConfig).mockReturnValue(mockAccount);
vi.mocked(isAccountConfigured).mockReturnValue(true); vi.mocked(isAccountConfigured).mockReturnValue(true);
vi.mocked(stripMarkdownForTwitch).mockReturnValue(""); vi.mocked(stripMarkdownForTwitch).mockReturnValue("");
const result = await sendMessageTwitchInternal( const result = await sendMessageTwitchInternal(
"#testchannel", "#testchannel",
"**Only markdown**", "**Only markdown**",
mockConfig, mockConfig,
"default", "default",
true, true,
mockLogger as unknown as Console, mockLogger as unknown as Console,
); );
expect(result.ok).toBe(true); expect(result.ok).toBe(true);
expect(result.messageId).toBe("skipped"); expect(result.messageId).toBe("skipped");
}); });
it("should return error when client manager not found", async () => { it("should return error when client manager not found", async () => {
const { getAccountConfig } = await import("./config.js"); const { getAccountConfig } = await import("./config.js");
const { isAccountConfigured } = await import("./utils/twitch.js"); const { isAccountConfigured } = await import("./utils/twitch.js");
const { getClientManager } = await import("./client-manager-registry.js"); const { getClientManager } = await import("./client-manager-registry.js");
vi.mocked(getAccountConfig).mockReturnValue(mockAccount); vi.mocked(getAccountConfig).mockReturnValue(mockAccount);
vi.mocked(isAccountConfigured).mockReturnValue(true); vi.mocked(isAccountConfigured).mockReturnValue(true);
vi.mocked(getClientManager).mockReturnValue(undefined); vi.mocked(getClientManager).mockReturnValue(undefined);
const result = await sendMessageTwitchInternal( const result = await sendMessageTwitchInternal(
"#testchannel", "#testchannel",
"Hello!", "Hello!",
mockConfig, mockConfig,
"default", "default",
false, false,
mockLogger as unknown as Console, mockLogger as unknown as Console,
); );
expect(result.ok).toBe(false); expect(result.ok).toBe(false);
expect(result.error).toContain("Client manager not found"); expect(result.error).toContain("Client manager not found");
}); });
it("should handle send errors gracefully", async () => { it("should handle send errors gracefully", async () => {
const { getAccountConfig } = await import("./config.js"); const { getAccountConfig } = await import("./config.js");
const { isAccountConfigured } = await import("./utils/twitch.js"); const { isAccountConfigured } = await import("./utils/twitch.js");
const { getClientManager } = await import("./client-manager-registry.js"); const { getClientManager } = await import("./client-manager-registry.js");
vi.mocked(getAccountConfig).mockReturnValue(mockAccount); vi.mocked(getAccountConfig).mockReturnValue(mockAccount);
vi.mocked(isAccountConfigured).mockReturnValue(true); vi.mocked(isAccountConfigured).mockReturnValue(true);
vi.mocked(getClientManager).mockReturnValue({ vi.mocked(getClientManager).mockReturnValue({
sendMessage: vi.fn().mockRejectedValue(new Error("Connection lost")), sendMessage: vi.fn().mockRejectedValue(new Error("Connection lost")),
} as ReturnType<typeof getClientManager>); } as ReturnType<typeof getClientManager>);
const result = await sendMessageTwitchInternal( const result = await sendMessageTwitchInternal(
"#testchannel", "#testchannel",
"Hello!", "Hello!",
mockConfig, mockConfig,
"default", "default",
false, false,
mockLogger as unknown as Console, mockLogger as unknown as Console,
); );
expect(result.ok).toBe(false); expect(result.ok).toBe(false);
expect(result.error).toBe("Connection lost"); expect(result.error).toBe("Connection lost");
expect(mockLogger.error).toHaveBeenCalled(); expect(mockLogger.error).toHaveBeenCalled();
}); });
it("should use account channel when channel parameter is empty", async () => { it("should use account channel when channel parameter is empty", async () => {
const { getAccountConfig } = await import("./config.js"); const { getAccountConfig } = await import("./config.js");
const { isAccountConfigured } = await import("./utils/twitch.js"); const { isAccountConfigured } = await import("./utils/twitch.js");
const { getClientManager } = await import("./client-manager-registry.js"); const { getClientManager } = await import("./client-manager-registry.js");
vi.mocked(getAccountConfig).mockReturnValue(mockAccount); vi.mocked(getAccountConfig).mockReturnValue(mockAccount);
vi.mocked(isAccountConfigured).mockReturnValue(true); vi.mocked(isAccountConfigured).mockReturnValue(true);
const mockSend = vi.fn().mockResolvedValue({ const mockSend = vi.fn().mockResolvedValue({
ok: true, ok: true,
messageId: "twitch-msg-789", messageId: "twitch-msg-789",
}); });
vi.mocked(getClientManager).mockReturnValue({ vi.mocked(getClientManager).mockReturnValue({
sendMessage: mockSend, sendMessage: mockSend,
} as ReturnType<typeof getClientManager>); } as ReturnType<typeof getClientManager>);
await sendMessageTwitchInternal( await sendMessageTwitchInternal(
"", "",
"Hello!", "Hello!",
mockConfig, mockConfig,
"default", "default",
false, false,
mockLogger as unknown as Console, mockLogger as unknown as Console,
); );
expect(mockSend).toHaveBeenCalledWith( expect(mockSend).toHaveBeenCalledWith(
mockAccount, mockAccount,
"testchannel", // normalized account channel "testchannel", // normalized account channel
"Hello!", "Hello!",
mockConfig, mockConfig,
"default", "default",
); );
}); });
}); });
}); });

View File

@ -9,22 +9,18 @@ import { DEFAULT_ACCOUNT_ID, getAccountConfig } from "./config.js";
import { getClientManager as getRegistryClientManager } from "./client-manager-registry.js"; import { getClientManager as getRegistryClientManager } from "./client-manager-registry.js";
import type { ClawdbotConfig } from "clawdbot/plugin-sdk"; import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
import { stripMarkdownForTwitch } from "./utils/markdown.js"; import { stripMarkdownForTwitch } from "./utils/markdown.js";
import { import { generateMessageId, isAccountConfigured, normalizeTwitchChannel } from "./utils/twitch.js";
generateMessageId,
isAccountConfigured,
normalizeTwitchChannel,
} from "./utils/twitch.js";
/** /**
* Result from sending a message to Twitch. * Result from sending a message to Twitch.
*/ */
export interface SendMessageResult { export interface SendMessageResult {
/** Whether the send was successful */ /** Whether the send was successful */
ok: boolean; ok: boolean;
/** The message ID (generated for tracking) */ /** The message ID (generated for tracking) */
messageId: string; messageId: string;
/** Error message if the send failed */ /** Error message if the send failed */
error?: string; error?: string;
} }
/** /**
@ -52,89 +48,89 @@ export interface SendMessageResult {
* ); * );
*/ */
export async function sendMessageTwitchInternal( export async function sendMessageTwitchInternal(
channel: string, channel: string,
text: string, text: string,
cfg: ClawdbotConfig, cfg: ClawdbotConfig,
accountId: string = DEFAULT_ACCOUNT_ID, accountId: string = DEFAULT_ACCOUNT_ID,
stripMarkdown: boolean = true, stripMarkdown: boolean = true,
logger: Console = console, logger: Console = console,
): Promise<SendMessageResult> { ): Promise<SendMessageResult> {
// Resolve account // Resolve account
const account = getAccountConfig(cfg, accountId); const account = getAccountConfig(cfg, accountId);
if (!account) { if (!account) {
const availableIds = Object.keys(cfg.channels?.twitch?.accounts ?? {}); const availableIds = Object.keys(cfg.channels?.twitch?.accounts ?? {});
return { return {
ok: false, ok: false,
messageId: generateMessageId(), messageId: generateMessageId(),
error: `Account not found: ${accountId}. Available accounts: ${availableIds.join(", ") || "none"}`, error: `Account not found: ${accountId}. Available accounts: ${availableIds.join(", ") || "none"}`,
}; };
} }
if (!isAccountConfigured(account)) { if (!isAccountConfigured(account)) {
return { return {
ok: false, ok: false,
messageId: generateMessageId(), messageId: generateMessageId(),
error: `Account ${accountId} is not properly configured. Required: username, token, clientId`, error: `Account ${accountId} is not properly configured. Required: username, token, clientId`,
}; };
} }
// Normalize channel // Normalize channel
const normalizedChannel = channel || account.channel || account.username; const normalizedChannel = channel || account.channel || account.username;
if (!normalizedChannel) { if (!normalizedChannel) {
return { return {
ok: false, ok: false,
messageId: generateMessageId(), messageId: generateMessageId(),
error: "No channel specified and no default channel in account config", error: "No channel specified and no default channel in account config",
}; };
} }
// Strip markdown if enabled // Strip markdown if enabled
const cleanedText = stripMarkdown ? stripMarkdownForTwitch(text) : text; const cleanedText = stripMarkdown ? stripMarkdownForTwitch(text) : text;
if (!cleanedText) { if (!cleanedText) {
return { return {
ok: true, ok: true,
messageId: "skipped", messageId: "skipped",
}; };
} }
// Get client manager from registry // Get client manager from registry
const clientManager = getRegistryClientManager(accountId); const clientManager = getRegistryClientManager(accountId);
if (!clientManager) { if (!clientManager) {
return { return {
ok: false, ok: false,
messageId: generateMessageId(), messageId: generateMessageId(),
error: `Client manager not found for account: ${accountId}. Please start the Twitch gateway first.`, error: `Client manager not found for account: ${accountId}. Please start the Twitch gateway first.`,
}; };
} }
try { try {
const result = await clientManager.sendMessage( const result = await clientManager.sendMessage(
account, account,
normalizeTwitchChannel(normalizedChannel), normalizeTwitchChannel(normalizedChannel),
cleanedText, cleanedText,
cfg, cfg,
accountId, accountId,
); );
if (!result.ok) { if (!result.ok) {
return { return {
ok: false, ok: false,
messageId: result.messageId ?? generateMessageId(), messageId: result.messageId ?? generateMessageId(),
error: result.error ?? "Send failed", error: result.error ?? "Send failed",
}; };
} }
return { return {
ok: true, ok: true,
messageId: result.messageId ?? generateMessageId(), messageId: result.messageId ?? generateMessageId(),
}; };
} catch (error) { } catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error); const errorMsg = error instanceof Error ? error.message : String(error);
logger.error(`[twitch] Failed to send message: ${errorMsg}`); logger.error(`[twitch] Failed to send message: ${errorMsg}`);
return { return {
ok: false, ok: false,
messageId: generateMessageId(), messageId: generateMessageId(),
error: errorMsg, error: errorMsg,
}; };
} }
} }

View File

@ -15,291 +15,276 @@ import { collectTwitchStatusIssues } from "./status.js";
import type { ChannelAccountSnapshot } from "./types.js"; import type { ChannelAccountSnapshot } from "./types.js";
describe("status", () => { describe("status", () => {
describe("collectTwitchStatusIssues", () => { describe("collectTwitchStatusIssues", () => {
it("should detect unconfigured accounts", () => { it("should detect unconfigured accounts", () => {
const snapshots: ChannelAccountSnapshot[] = [ const snapshots: ChannelAccountSnapshot[] = [
{ {
accountId: "default", accountId: "default",
configured: false, configured: false,
enabled: true, enabled: true,
running: false, running: false,
}, },
]; ];
const issues = collectTwitchStatusIssues(snapshots); const issues = collectTwitchStatusIssues(snapshots);
expect(issues.length).toBeGreaterThan(0); expect(issues.length).toBeGreaterThan(0);
expect(issues[0]?.kind).toBe("config"); expect(issues[0]?.kind).toBe("config");
expect(issues[0]?.message).toContain("not properly configured"); expect(issues[0]?.message).toContain("not properly configured");
}); });
it("should detect disabled accounts", () => { it("should detect disabled accounts", () => {
const snapshots: ChannelAccountSnapshot[] = [ const snapshots: ChannelAccountSnapshot[] = [
{ {
accountId: "default", accountId: "default",
configured: true, configured: true,
enabled: false, enabled: false,
running: false, running: false,
}, },
]; ];
const issues = collectTwitchStatusIssues(snapshots); const issues = collectTwitchStatusIssues(snapshots);
expect(issues.length).toBeGreaterThan(0); expect(issues.length).toBeGreaterThan(0);
const disabledIssue = issues.find((i) => i.message.includes("disabled")); const disabledIssue = issues.find((i) => i.message.includes("disabled"));
expect(disabledIssue).toBeDefined(); expect(disabledIssue).toBeDefined();
}); });
it("should detect missing clientId when account configured", () => { it("should detect missing clientId when account configured", () => {
const snapshots: ChannelAccountSnapshot[] = [ const snapshots: ChannelAccountSnapshot[] = [
{ {
accountId: "default", accountId: "default",
configured: true, configured: true,
enabled: true, enabled: true,
running: false, running: false,
}, },
]; ];
const mockCfg = { const mockCfg = {
channels: { channels: {
twitch: { twitch: {
accounts: { accounts: {
default: { default: {
username: "testbot", username: "testbot",
token: "oauth:test123", token: "oauth:test123",
// clientId missing // clientId missing
}, },
}, },
}, },
}, },
}; };
const issues = collectTwitchStatusIssues( const issues = collectTwitchStatusIssues(snapshots, () => mockCfg as never);
snapshots,
() => mockCfg as never,
);
const clientIdIssue = issues.find((i) => i.message.includes("client ID")); const clientIdIssue = issues.find((i) => i.message.includes("client ID"));
expect(clientIdIssue).toBeDefined(); expect(clientIdIssue).toBeDefined();
}); });
it("should warn about oauth: prefix in token", () => { it("should warn about oauth: prefix in token", () => {
const snapshots: ChannelAccountSnapshot[] = [ const snapshots: ChannelAccountSnapshot[] = [
{ {
accountId: "default", accountId: "default",
configured: true, configured: true,
enabled: true, enabled: true,
running: false, running: false,
}, },
]; ];
const mockCfg = { const mockCfg = {
channels: { channels: {
twitch: { twitch: {
accounts: { accounts: {
default: { default: {
username: "testbot", username: "testbot",
token: "oauth:test123", // has prefix token: "oauth:test123", // has prefix
clientId: "test-id", clientId: "test-id",
}, },
}, },
}, },
}, },
}; };
const issues = collectTwitchStatusIssues( const issues = collectTwitchStatusIssues(snapshots, () => mockCfg as never);
snapshots,
() => mockCfg as never,
);
const prefixIssue = issues.find((i) => i.message.includes("oauth:")); const prefixIssue = issues.find((i) => i.message.includes("oauth:"));
expect(prefixIssue).toBeDefined(); expect(prefixIssue).toBeDefined();
expect(prefixIssue?.kind).toBe("config"); expect(prefixIssue?.kind).toBe("config");
}); });
it("should detect clientSecret without refreshToken", () => { it("should detect clientSecret without refreshToken", () => {
const snapshots: ChannelAccountSnapshot[] = [ const snapshots: ChannelAccountSnapshot[] = [
{ {
accountId: "default", accountId: "default",
configured: true, configured: true,
enabled: true, enabled: true,
running: false, running: false,
}, },
]; ];
const mockCfg = { const mockCfg = {
channels: { channels: {
twitch: { twitch: {
accounts: { accounts: {
default: { default: {
username: "testbot", username: "testbot",
token: "oauth:test123", token: "oauth:test123",
clientId: "test-id", clientId: "test-id",
clientSecret: "secret123", clientSecret: "secret123",
// refreshToken missing // refreshToken missing
}, },
}, },
}, },
}, },
}; };
const issues = collectTwitchStatusIssues( const issues = collectTwitchStatusIssues(snapshots, () => mockCfg as never);
snapshots,
() => mockCfg as never,
);
const secretIssue = issues.find((i) => i.message.includes("clientSecret")); const secretIssue = issues.find((i) => i.message.includes("clientSecret"));
expect(secretIssue).toBeDefined(); expect(secretIssue).toBeDefined();
}); });
it("should detect empty allowFrom array", () => { it("should detect empty allowFrom array", () => {
const snapshots: ChannelAccountSnapshot[] = [ const snapshots: ChannelAccountSnapshot[] = [
{ {
accountId: "default", accountId: "default",
configured: true, configured: true,
enabled: true, enabled: true,
running: false, running: false,
}, },
]; ];
const mockCfg = { const mockCfg = {
channels: { channels: {
twitch: { twitch: {
accounts: { accounts: {
default: { default: {
username: "testbot", username: "testbot",
token: "test123", token: "test123",
clientId: "test-id", clientId: "test-id",
allowFrom: [], // empty array allowFrom: [], // empty array
}, },
}, },
}, },
}, },
}; };
const issues = collectTwitchStatusIssues( const issues = collectTwitchStatusIssues(snapshots, () => mockCfg as never);
snapshots,
() => mockCfg as never,
);
const allowFromIssue = issues.find((i) => i.message.includes("allowFrom")); const allowFromIssue = issues.find((i) => i.message.includes("allowFrom"));
expect(allowFromIssue).toBeDefined(); expect(allowFromIssue).toBeDefined();
}); });
it("should detect allowedRoles 'all' with allowFrom conflict", () => { it("should detect allowedRoles 'all' with allowFrom conflict", () => {
const snapshots: ChannelAccountSnapshot[] = [ const snapshots: ChannelAccountSnapshot[] = [
{ {
accountId: "default", accountId: "default",
configured: true, configured: true,
enabled: true, enabled: true,
running: false, running: false,
}, },
]; ];
const mockCfg = { const mockCfg = {
channels: { channels: {
twitch: { twitch: {
accounts: { accounts: {
default: { default: {
username: "testbot", username: "testbot",
token: "test123", token: "test123",
clientId: "test-id", clientId: "test-id",
allowedRoles: ["all"], allowedRoles: ["all"],
allowFrom: ["123456"], // conflict! allowFrom: ["123456"], // conflict!
}, },
}, },
}, },
}, },
}; };
const issues = collectTwitchStatusIssues( const issues = collectTwitchStatusIssues(snapshots, () => mockCfg as never);
snapshots,
() => mockCfg as never,
);
const conflictIssue = issues.find((i) => i.kind === "intent"); const conflictIssue = issues.find((i) => i.kind === "intent");
expect(conflictIssue).toBeDefined(); expect(conflictIssue).toBeDefined();
expect(conflictIssue?.message).toContain("allowedRoles is set to 'all'"); expect(conflictIssue?.message).toContain("allowedRoles is set to 'all'");
}); });
it("should detect runtime errors", () => { it("should detect runtime errors", () => {
const snapshots: ChannelAccountSnapshot[] = [ const snapshots: ChannelAccountSnapshot[] = [
{ {
accountId: "default", accountId: "default",
configured: true, configured: true,
enabled: true, enabled: true,
running: false, running: false,
lastError: "Connection timeout", lastError: "Connection timeout",
}, },
]; ];
const issues = collectTwitchStatusIssues(snapshots); const issues = collectTwitchStatusIssues(snapshots);
const runtimeIssue = issues.find((i) => i.kind === "runtime"); const runtimeIssue = issues.find((i) => i.kind === "runtime");
expect(runtimeIssue).toBeDefined(); expect(runtimeIssue).toBeDefined();
expect(runtimeIssue?.message).toContain("Connection timeout"); expect(runtimeIssue?.message).toContain("Connection timeout");
}); });
it("should detect accounts that never connected", () => { it("should detect accounts that never connected", () => {
const snapshots: ChannelAccountSnapshot[] = [ const snapshots: ChannelAccountSnapshot[] = [
{ {
accountId: "default", accountId: "default",
configured: true, configured: true,
enabled: true, enabled: true,
running: false, running: false,
lastStartAt: undefined, lastStartAt: undefined,
lastInboundAt: undefined, lastInboundAt: undefined,
lastOutboundAt: undefined, lastOutboundAt: undefined,
}, },
]; ];
const issues = collectTwitchStatusIssues(snapshots); const issues = collectTwitchStatusIssues(snapshots);
const neverConnectedIssue = issues.find((i) => const neverConnectedIssue = issues.find((i) =>
i.message.includes("never connected successfully"), i.message.includes("never connected successfully"),
); );
expect(neverConnectedIssue).toBeDefined(); expect(neverConnectedIssue).toBeDefined();
}); });
it("should detect long-running connections", () => { it("should detect long-running connections", () => {
const oldDate = Date.now() - 8 * 24 * 60 * 60 * 1000; // 8 days ago const oldDate = Date.now() - 8 * 24 * 60 * 60 * 1000; // 8 days ago
const snapshots: ChannelAccountSnapshot[] = [ const snapshots: ChannelAccountSnapshot[] = [
{ {
accountId: "default", accountId: "default",
configured: true, configured: true,
enabled: true, enabled: true,
running: true, running: true,
lastStartAt: oldDate, lastStartAt: oldDate,
}, },
]; ];
const issues = collectTwitchStatusIssues(snapshots); const issues = collectTwitchStatusIssues(snapshots);
const uptimeIssue = issues.find((i) => i.message.includes("running for")); const uptimeIssue = issues.find((i) => i.message.includes("running for"));
expect(uptimeIssue).toBeDefined(); expect(uptimeIssue).toBeDefined();
}); });
it("should handle empty snapshots array", () => { it("should handle empty snapshots array", () => {
const issues = collectTwitchStatusIssues([]); const issues = collectTwitchStatusIssues([]);
expect(issues).toEqual([]); expect(issues).toEqual([]);
}); });
it("should skip non-Twitch accounts gracefully", () => { it("should skip non-Twitch accounts gracefully", () => {
const snapshots: ChannelAccountSnapshot[] = [ const snapshots: ChannelAccountSnapshot[] = [
{ {
accountId: undefined, accountId: undefined,
configured: false, configured: false,
enabled: true, enabled: true,
running: false, running: false,
}, },
]; ];
const issues = collectTwitchStatusIssues(snapshots); const issues = collectTwitchStatusIssues(snapshots);
// Should not crash, may return empty or minimal issues // Should not crash, may return empty or minimal issues
expect(Array.isArray(issues)).toBe(true); expect(Array.isArray(issues)).toBe(true);
}); });
}); });
}); });

View File

@ -26,161 +26,159 @@ import { isAccountConfigured } from "./utils/twitch.js";
* } * }
*/ */
export function collectTwitchStatusIssues( export function collectTwitchStatusIssues(
accounts: ChannelAccountSnapshot[], accounts: ChannelAccountSnapshot[],
getCfg?: () => unknown, getCfg?: () => unknown,
): ChannelStatusIssue[] { ): ChannelStatusIssue[] {
const issues: ChannelStatusIssue[] = []; const issues: ChannelStatusIssue[] = [];
for (const entry of accounts) { for (const entry of accounts) {
const accountId = entry.accountId; const accountId = entry.accountId;
// Skip if not a Twitch account // Skip if not a Twitch account
if (!accountId) continue; if (!accountId) continue;
// Get full account config if available // Get full account config if available
let account: ReturnType<typeof getAccountConfig> | null = null; let account: ReturnType<typeof getAccountConfig> | null = null;
if (getCfg) { if (getCfg) {
try { try {
const cfg = getCfg() as { const cfg = getCfg() as {
channels?: { twitch?: { accounts?: Record<string, unknown> } }; channels?: { twitch?: { accounts?: Record<string, unknown> } };
}; };
account = getAccountConfig(cfg, accountId); account = getAccountConfig(cfg, accountId);
} catch { } catch {
// Ignore config access errors // Ignore config access errors
} }
} }
// Check 1: Account not configured // Check 1: Account not configured
if (!entry.configured) { if (!entry.configured) {
issues.push({ issues.push({
channel: "twitch", channel: "twitch",
accountId, accountId,
kind: "config", kind: "config",
message: "Twitch account is not properly configured", message: "Twitch account is not properly configured",
fix: "Add required fields: username, token, and clientId to your account configuration", fix: "Add required fields: username, token, and clientId to your account configuration",
}); });
continue; // Skip further checks if not configured continue; // Skip further checks if not configured
} }
// Check 2: Account disabled // Check 2: Account disabled
if (entry.enabled === false) { if (entry.enabled === false) {
issues.push({ issues.push({
channel: "twitch", channel: "twitch",
accountId, accountId,
kind: "config", kind: "config",
message: "Twitch account is disabled", message: "Twitch account is disabled",
fix: "Set enabled: true in your account configuration to enable this account", fix: "Set enabled: true in your account configuration to enable this account",
}); });
continue; // Skip further checks if disabled continue; // Skip further checks if disabled
} }
// Check 3: Missing clientId (check if username/token present but no clientId) // Check 3: Missing clientId (check if username/token present but no clientId)
if (account && account.username && account.token && !account.clientId) { if (account && account.username && account.token && !account.clientId) {
issues.push({ issues.push({
channel: "twitch", channel: "twitch",
accountId, accountId,
kind: "config", kind: "config",
message: "Twitch client ID is required", message: "Twitch client ID is required",
fix: "Add clientId to your Twitch account configuration (from Twitch Developer Portal)", fix: "Add clientId to your Twitch account configuration (from Twitch Developer Portal)",
}); });
} }
// Checks that require account config // Checks that require account config
if (account && isAccountConfigured(account)) { if (account && isAccountConfigured(account)) {
// Check 4: Token format warning (normalized, but may indicate config issue)
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) // Check 5: clientSecret provided without refreshToken
if (account.token?.startsWith("oauth:")) { if (account.clientSecret && !account.refreshToken) {
issues.push({ issues.push({
channel: "twitch", channel: "twitch",
accountId, accountId,
kind: "config", kind: "config",
message: "Token contains 'oauth:' prefix (will be stripped)", message: "clientSecret provided without refreshToken",
fix: "The 'oauth:' prefix is optional. You can use just the token value, or keep it as-is (it will be normalized automatically).", fix: "For automatic token refresh, provide both clientSecret and refreshToken. Otherwise, clientSecret is not needed.",
}); });
} }
// Check 5: clientSecret provided without refreshToken // Check 6: Access control warnings
if (account.clientSecret && !account.refreshToken) { if (account.allowFrom && account.allowFrom.length === 0) {
issues.push({ issues.push({
channel: "twitch", channel: "twitch",
accountId, accountId,
kind: "config", kind: "config",
message: "clientSecret provided without refreshToken", message: "allowFrom is configured but empty",
fix: "For automatic token refresh, provide both clientSecret and refreshToken. Otherwise, clientSecret is not needed.", fix: "Either add user IDs to allowFrom, remove the allowFrom field, or use allowedRoles instead.",
}); });
} }
// Check 6: Access control warnings // Check 7: Invalid role combinations
if (account.allowFrom && account.allowFrom.length === 0) { if (
issues.push({ account.allowedRoles?.includes("all") &&
channel: "twitch", account.allowFrom &&
accountId, account.allowFrom.length > 0
kind: "config", ) {
message: "allowFrom is configured but empty", issues.push({
fix: "Either add user IDs to allowFrom, remove the allowFrom field, or use allowedRoles instead.", 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 // Check 8: Runtime errors
if ( if (entry.lastError) {
account.allowedRoles?.includes("all") && issues.push({
account.allowFrom && channel: "twitch",
account.allowFrom.length > 0 accountId,
) { kind: "runtime",
issues.push({ message: `Last error: ${entry.lastError}`,
channel: "twitch", fix: "Check your token validity and network connection. Ensure the bot has the required OAuth scopes.",
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 // Check 9: Account never connected successfully
if (entry.lastError) { if (
issues.push({ entry.configured &&
channel: "twitch", !entry.running &&
accountId, !entry.lastStartAt &&
kind: "runtime", !entry.lastInboundAt &&
message: `Last error: ${entry.lastError}`, !entry.lastOutboundAt
fix: "Check your token validity and network connection. Ensure the bot has the required OAuth scopes.", ) {
}); 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 // Check 10: Long-running connection may need reconnection
if ( if (entry.running && entry.lastStartAt) {
entry.configured && const uptime = Date.now() - entry.lastStartAt;
!entry.running && const daysSinceStart = uptime / (1000 * 60 * 60 * 24);
!entry.lastStartAt && if (daysSinceStart > 7) {
!entry.lastInboundAt && issues.push({
!entry.lastOutboundAt channel: "twitch",
) { accountId,
issues.push({ kind: "runtime",
channel: "twitch", message: `Connection has been running for ${Math.floor(daysSinceStart)} days`,
accountId, fix: "Consider restarting the connection periodically to refresh the connection. Twitch tokens may expire after long periods.",
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 return issues;
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;
} }

View File

@ -13,156 +13,156 @@ import { resolveTwitchToken, type TwitchTokenSource } from "./token.js";
import type { ClawdbotConfig } from "clawdbot/plugin-sdk"; import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
describe("token", () => { describe("token", () => {
const mockConfig = { const mockConfig = {
channels: { channels: {
twitch: { twitch: {
accounts: { accounts: {
default: { default: {
username: "testbot", username: "testbot",
token: "oauth:config-token", token: "oauth:config-token",
}, },
other: { other: {
username: "otherbot", username: "otherbot",
token: "oauth:other-token", token: "oauth:other-token",
}, },
}, },
}, },
}, },
} as unknown as ClawdbotConfig; } as unknown as ClawdbotConfig;
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
afterEach(() => { afterEach(() => {
vi.restoreAllMocks(); vi.restoreAllMocks();
delete process.env.CLAWDBOT_TWITCH_ACCESS_TOKEN; delete process.env.CLAWDBOT_TWITCH_ACCESS_TOKEN;
}); });
describe("resolveTwitchToken", () => { describe("resolveTwitchToken", () => {
it("should resolve token from config for default account", () => { it("should resolve token from config for default account", () => {
const result = resolveTwitchToken(mockConfig, { accountId: "default" }); const result = resolveTwitchToken(mockConfig, { accountId: "default" });
expect(result.token).toBe("oauth:config-token"); expect(result.token).toBe("oauth:config-token");
expect(result.source).toBe("config"); expect(result.source).toBe("config");
}); });
it("should resolve token from config for non-default account", () => { it("should resolve token from config for non-default account", () => {
const result = resolveTwitchToken(mockConfig, { accountId: "other" }); const result = resolveTwitchToken(mockConfig, { accountId: "other" });
expect(result.token).toBe("oauth:other-token"); expect(result.token).toBe("oauth:other-token");
expect(result.source).toBe("config"); expect(result.source).toBe("config");
}); });
it("should prioritize config token over env var", () => { it("should prioritize config token over env var", () => {
process.env.CLAWDBOT_TWITCH_ACCESS_TOKEN = "oauth:env-token"; 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 // Config token should be used even if env var exists
expect(result.token).toBe("oauth:config-token"); expect(result.token).toBe("oauth:config-token");
expect(result.source).toBe("config"); expect(result.source).toBe("config");
}); });
it("should use env var when config token is empty", () => { it("should use env var when config token is empty", () => {
process.env.CLAWDBOT_TWITCH_ACCESS_TOKEN = "oauth:env-token"; process.env.CLAWDBOT_TWITCH_ACCESS_TOKEN = "oauth:env-token";
const configWithEmptyToken = { const configWithEmptyToken = {
channels: { channels: {
twitch: { twitch: {
accounts: { accounts: {
default: { default: {
username: "testbot", username: "testbot",
token: "", token: "",
}, },
}, },
}, },
}, },
} as unknown as ClawdbotConfig; } 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.token).toBe("oauth:env-token");
expect(result.source).toBe("env"); expect(result.source).toBe("env");
}); });
it("should return empty token when neither config nor env has token", () => { it("should return empty token when neither config nor env has token", () => {
const configWithoutToken = { const configWithoutToken = {
channels: { channels: {
twitch: { twitch: {
accounts: { accounts: {
default: { default: {
username: "testbot", username: "testbot",
token: "", token: "",
}, },
}, },
}, },
}, },
} as unknown as ClawdbotConfig; } as unknown as ClawdbotConfig;
const result = resolveTwitchToken(configWithoutToken, { accountId: "default" }); const result = resolveTwitchToken(configWithoutToken, { accountId: "default" });
expect(result.token).toBe(""); expect(result.token).toBe("");
expect(result.source).toBe("none"); expect(result.source).toBe("none");
}); });
it("should not use env var for non-default accounts", () => { it("should not use env var for non-default accounts", () => {
process.env.CLAWDBOT_TWITCH_ACCESS_TOKEN = "oauth:env-token"; process.env.CLAWDBOT_TWITCH_ACCESS_TOKEN = "oauth:env-token";
const configWithoutToken = { const configWithoutToken = {
channels: { channels: {
twitch: { twitch: {
accounts: { accounts: {
secondary: { secondary: {
username: "secondary", username: "secondary",
token: "", token: "",
}, },
}, },
}, },
}, },
} as unknown as ClawdbotConfig; } as unknown as ClawdbotConfig;
const result = resolveTwitchToken(configWithoutToken, { accountId: "secondary" }); const result = resolveTwitchToken(configWithoutToken, { accountId: "secondary" });
// Non-default accounts shouldn't use env var // Non-default accounts shouldn't use env var
expect(result.token).toBe(""); expect(result.token).toBe("");
expect(result.source).toBe("none"); expect(result.source).toBe("none");
}); });
it("should handle missing account gracefully", () => { it("should handle missing account gracefully", () => {
const configWithoutAccount = { const configWithoutAccount = {
channels: { channels: {
twitch: { twitch: {
accounts: {}, accounts: {},
}, },
}, },
} as unknown as ClawdbotConfig; } as unknown as ClawdbotConfig;
const result = resolveTwitchToken(configWithoutAccount, { accountId: "nonexistent" }); const result = resolveTwitchToken(configWithoutAccount, { accountId: "nonexistent" });
expect(result.token).toBe(""); expect(result.token).toBe("");
expect(result.source).toBe("none"); expect(result.source).toBe("none");
}); });
it("should handle missing Twitch config section", () => { it("should handle missing Twitch config section", () => {
const configWithoutSection = { const configWithoutSection = {
channels: {}, channels: {},
} as unknown as ClawdbotConfig; } as unknown as ClawdbotConfig;
const result = resolveTwitchToken(configWithoutSection, { accountId: "default" }); const result = resolveTwitchToken(configWithoutSection, { accountId: "default" });
expect(result.token).toBe(""); expect(result.token).toBe("");
expect(result.source).toBe("none"); expect(result.source).toBe("none");
}); });
}); });
describe("TwitchTokenSource type", () => { describe("TwitchTokenSource type", () => {
it("should have correct values", () => { it("should have correct values", () => {
const sources: TwitchTokenSource[] = ["env", "config", "none"]; const sources: TwitchTokenSource[] = ["env", "config", "none"];
expect(sources).toContain("env"); expect(sources).toContain("env");
expect(sources).toContain("config"); expect(sources).toContain("config");
expect(sources).toContain("none"); expect(sources).toContain("none");
}); });
}); });
}); });

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -1,11 +1,7 @@
import { RefreshingAuthProvider, StaticAuthProvider } from "@twurple/auth"; import { RefreshingAuthProvider, StaticAuthProvider } from "@twurple/auth";
import { ChatClient, LogLevel } from "@twurple/chat"; import { ChatClient, LogLevel } from "@twurple/chat";
import type { ClawdbotConfig } from "clawdbot/plugin-sdk"; import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
import type { import type { ChannelLogSink, TwitchAccountConfig, TwitchChatMessage } from "./types.js";
ChannelLogSink,
TwitchAccountConfig,
TwitchChatMessage,
} from "./types.js";
import { resolveTwitchToken } from "./token.js"; import { resolveTwitchToken } from "./token.js";
import { normalizeToken } from "./utils/twitch.js"; import { normalizeToken } from "./utils/twitch.js";
@ -13,312 +9,299 @@ import { normalizeToken } from "./utils/twitch.js";
* Manages Twitch chat client connections * Manages Twitch chat client connections
*/ */
export class TwitchClientManager { export class TwitchClientManager {
private clients = new Map<string, ChatClient>(); private clients = new Map<string, ChatClient>();
private messageHandlers = new Map< private messageHandlers = new Map<string, (message: TwitchChatMessage) => void>();
string,
(message: TwitchChatMessage) => void
>();
constructor(private logger: ChannelLogSink) {} constructor(private logger: ChannelLogSink) {}
/** /**
* Create an auth provider for the account. * Create an auth provider for the account.
*/ */
private createAuthProvider( private createAuthProvider(
account: TwitchAccountConfig, account: TwitchAccountConfig,
normalizedToken: string, normalizedToken: string,
): StaticAuthProvider | RefreshingAuthProvider { ): StaticAuthProvider | RefreshingAuthProvider {
if (!account.clientId) { if (!account.clientId) {
throw new Error("Missing Twitch client ID"); throw new Error("Missing Twitch client ID");
} }
if (account.clientSecret) { if (account.clientSecret) {
// Use RefreshingAuthProvider - can handle tokens with or without refresh tokens // Use RefreshingAuthProvider - can handle tokens with or without refresh tokens
const authProvider = new RefreshingAuthProvider({ const authProvider = new RefreshingAuthProvider({
clientId: account.clientId, clientId: account.clientId,
clientSecret: account.clientSecret, clientSecret: account.clientSecret,
}); });
// Use addUserForToken to figure out the user ID from the token // Use addUserForToken to figure out the user ID from the token
// This works whether we have a refresh token or not // This works whether we have a refresh token or not
authProvider authProvider
.addUserForToken({ .addUserForToken({
accessToken: normalizedToken, accessToken: normalizedToken,
refreshToken: account.refreshToken ?? null, refreshToken: account.refreshToken ?? null,
expiresIn: account.expiresIn ?? null, expiresIn: account.expiresIn ?? null,
obtainmentTimestamp: account.obtainmentTimestamp ?? Date.now(), obtainmentTimestamp: account.obtainmentTimestamp ?? Date.now(),
}) })
.then((userId) => { .then((userId) => {
this.logger.info( this.logger.info(
`[twitch] Added user ${userId} to RefreshingAuthProvider for ${account.username}`, `[twitch] Added user ${userId} to RefreshingAuthProvider for ${account.username}`,
); );
}) })
.catch((err) => { .catch((err) => {
this.logger.error( this.logger.error(
`[twitch] Failed to add user to RefreshingAuthProvider: ${err instanceof Error ? err.message : String(err)}`, `[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) // Set up token refresh event listener (only fires if refreshToken is provided)
authProvider.onRefresh((userId, token) => { authProvider.onRefresh((userId, token) => {
this.logger.info( this.logger.info(
`[twitch] Access token refreshed for user ${userId} (expires in ${token.expiresIn ? `${token.expiresIn}s` : "unknown"})`, `[twitch] Access token refreshed for user ${userId} (expires in ${token.expiresIn ? `${token.expiresIn}s` : "unknown"})`,
); );
}); });
// Set up token refresh failure listener // Set up token refresh failure listener
authProvider.onRefreshFailure((userId, error) => { authProvider.onRefreshFailure((userId, error) => {
this.logger.error( this.logger.error(
`[twitch] Failed to refresh access token for user ${userId}: ${error.message}`, `[twitch] Failed to refresh access token for user ${userId}: ${error.message}`,
); );
}); });
const refreshStatus = account.refreshToken const refreshStatus = account.refreshToken
? "automatic token refresh enabled" ? "automatic token refresh enabled"
: "token refresh disabled (no refresh token)"; : "token refresh disabled (no refresh token)";
this.logger.info( this.logger.info(
`[twitch] Using RefreshingAuthProvider for ${account.username} (${refreshStatus})`, `[twitch] Using RefreshingAuthProvider for ${account.username} (${refreshStatus})`,
); );
return authProvider; return authProvider;
} }
// Fall back to StaticAuthProvider for backward compatibility (no clientSecret) // Fall back to StaticAuthProvider for backward compatibility (no clientSecret)
this.logger.info( this.logger.info(
`[twitch] Using StaticAuthProvider for ${account.username} (no clientSecret provided)`, `[twitch] Using StaticAuthProvider for ${account.username} (no clientSecret provided)`,
); );
return new StaticAuthProvider(account.clientId, normalizedToken); return new StaticAuthProvider(account.clientId, normalizedToken);
} }
/** /**
* Get or create a chat client for an account * Get or create a chat client for an account
*/ */
async getClient( async getClient(
account: TwitchAccountConfig, account: TwitchAccountConfig,
cfg?: ClawdbotConfig, cfg?: ClawdbotConfig,
accountId?: string, accountId?: string,
): Promise<ChatClient> { ): Promise<ChatClient> {
const key = this.getAccountKey(account); const key = this.getAccountKey(account);
const existing = this.clients.get(key); const existing = this.clients.get(key);
if (existing) { if (existing) {
return existing; return existing;
} }
// Resolve token from config or environment // Resolve token from config or environment
const tokenResolution = resolveTwitchToken(cfg, { const tokenResolution = resolveTwitchToken(cfg, {
accountId, accountId,
}); });
if (!tokenResolution.token) { if (!tokenResolution.token) {
this.logger.error( 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)`, `[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"); throw new Error("Missing Twitch token");
} }
this.logger.debug?.( this.logger.debug?.(
`[twitch] Using ${tokenResolution.source} token source for ${account.username}`, `[twitch] Using ${tokenResolution.source} token source for ${account.username}`,
); );
if (!account.clientId) { if (!account.clientId) {
this.logger.error( this.logger.error(`[twitch] Missing Twitch client ID for account ${account.username}`);
`[twitch] Missing Twitch client ID for account ${account.username}`, throw new Error("Missing Twitch client ID");
); }
throw new Error("Missing Twitch client ID");
}
// Normalize token - strip oauth: prefix if present (Twurple doesn't need it) // Normalize token - strip oauth: prefix if present (Twurple doesn't need it)
const normalizedToken = normalizeToken(tokenResolution.token); const normalizedToken = normalizeToken(tokenResolution.token);
// Create auth provider // Create auth provider
const authProvider = this.createAuthProvider(account, normalizedToken); const authProvider = this.createAuthProvider(account, normalizedToken);
const channel = account.channel ?? account.username; const channel = account.channel ?? account.username;
// Create chat client // Create chat client
const client = new ChatClient({ const client = new ChatClient({
authProvider, authProvider,
channels: [channel], channels: [channel],
rejoinChannelsOnReconnect: true, rejoinChannelsOnReconnect: true,
requestMembershipEvents: true, requestMembershipEvents: true,
logger: { logger: {
minLevel: LogLevel.WARNING, minLevel: LogLevel.WARNING,
custom: { custom: {
log: (level, message) => { log: (level, message) => {
switch (level) { switch (level) {
case LogLevel.CRITICAL: case LogLevel.CRITICAL:
this.logger.error(`[twitch] ${message}`); this.logger.error(`[twitch] ${message}`);
break; break;
case LogLevel.ERROR: case LogLevel.ERROR:
this.logger.error(`[twitch] ${message}`); this.logger.error(`[twitch] ${message}`);
break; break;
case LogLevel.WARNING: case LogLevel.WARNING:
this.logger.warn(`[twitch] ${message}`); this.logger.warn(`[twitch] ${message}`);
break; break;
case LogLevel.INFO: case LogLevel.INFO:
this.logger.info(`[twitch] ${message}`); this.logger.info(`[twitch] ${message}`);
break; break;
case LogLevel.DEBUG: case LogLevel.DEBUG:
this.logger.debug?.(`[twitch] ${message}`); this.logger.debug?.(`[twitch] ${message}`);
break; break;
case LogLevel.TRACE: case LogLevel.TRACE:
this.logger.debug?.(`[twitch] ${message}`); this.logger.debug?.(`[twitch] ${message}`);
break; break;
} }
}, },
}, },
}, },
}); });
// Set up event handlers // Set up event handlers
this.setupClientHandlers(client, account); this.setupClientHandlers(client, account);
// Connect // Connect
client.connect(); client.connect();
this.clients.set(key, client); this.clients.set(key, client);
this.logger.info(`[twitch] Connected to Twitch as ${account.username}`); this.logger.info(`[twitch] Connected to Twitch as ${account.username}`);
return client; return client;
} }
/** /**
* Set up message and event handlers for a client * Set up message and event handlers for a client
*/ */
private setupClientHandlers( private setupClientHandlers(client: ChatClient, account: TwitchAccountConfig): void {
client: ChatClient, const key = this.getAccountKey(account);
account: TwitchAccountConfig,
): void {
const key = this.getAccountKey(account);
// Handle incoming messages // Handle incoming messages
client.onMessage((channelName, _user, messageText, msg) => { client.onMessage((channelName, _user, messageText, msg) => {
const handler = this.messageHandlers.get(key); const handler = this.messageHandlers.get(key);
if (handler) { if (handler) {
const normalizedChannel = channelName.startsWith("#") const normalizedChannel = channelName.startsWith("#") ? channelName.slice(1) : channelName;
? channelName.slice(1) handler({
: channelName; username: msg.userInfo.userName,
handler({ displayName: msg.userInfo.displayName,
username: msg.userInfo.userName, userId: msg.userInfo.userId,
displayName: msg.userInfo.displayName, message: messageText,
userId: msg.userInfo.userId, channel: normalizedChannel,
message: messageText, id: msg.id,
channel: normalizedChannel, timestamp: new Date(),
id: msg.id, isMod: msg.userInfo.isMod,
timestamp: new Date(), isOwner: msg.userInfo.isBroadcaster,
isMod: msg.userInfo.isMod, isVip: msg.userInfo.isVip,
isOwner: msg.userInfo.isBroadcaster, isSub: msg.userInfo.isSubscriber,
isVip: msg.userInfo.isVip, chatType: "group",
isSub: msg.userInfo.isSubscriber, });
chatType: "group", }
}); });
}
});
// Handle whispers (DMs) // Handle whispers (DMs)
client.onWhisper((_user, messageText, msg) => { client.onWhisper((_user, messageText, msg) => {
const handler = this.messageHandlers.get(key); const handler = this.messageHandlers.get(key);
if (handler) { if (handler) {
handler({ handler({
username: msg.userInfo.userName, username: msg.userInfo.userName,
displayName: msg.userInfo.displayName, displayName: msg.userInfo.displayName,
userId: msg.userInfo.userId, userId: msg.userInfo.userId,
message: messageText, message: messageText,
channel: msg.userInfo.userName, channel: msg.userInfo.userName,
id: undefined, // Whisper doesn't have id property id: undefined, // Whisper doesn't have id property
timestamp: new Date(), timestamp: new Date(),
isMod: msg.userInfo.isMod, isMod: msg.userInfo.isMod,
isOwner: msg.userInfo.isBroadcaster, isOwner: msg.userInfo.isBroadcaster,
isVip: msg.userInfo.isVip, isVip: msg.userInfo.isVip,
isSub: msg.userInfo.isSubscriber, isSub: msg.userInfo.isSubscriber,
chatType: "direct", 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 * Set a message handler for an account
*/ */
onMessage( onMessage(account: TwitchAccountConfig, handler: (message: TwitchChatMessage) => void): void {
account: TwitchAccountConfig, const key = this.getAccountKey(account);
handler: (message: TwitchChatMessage) => void, this.messageHandlers.set(key, handler);
): void { }
const key = this.getAccountKey(account);
this.messageHandlers.set(key, handler);
}
/** /**
* Disconnect a client * Disconnect a client
*/ */
async disconnect(account: TwitchAccountConfig): Promise<void> { async disconnect(account: TwitchAccountConfig): Promise<void> {
const key = this.getAccountKey(account); const key = this.getAccountKey(account);
const client = this.clients.get(key); const client = this.clients.get(key);
if (client) { if (client) {
client.quit(); client.quit();
this.clients.delete(key); this.clients.delete(key);
this.messageHandlers.delete(key); this.messageHandlers.delete(key);
this.logger.info(`[twitch] Disconnected ${key}`); this.logger.info(`[twitch] Disconnected ${key}`);
} }
} }
/** /**
* Disconnect all clients * Disconnect all clients
*/ */
async disconnectAll(): Promise<void> { async disconnectAll(): Promise<void> {
this.clients.forEach((client) => client.quit()); this.clients.forEach((client) => client.quit());
this.clients.clear(); this.clients.clear();
this.messageHandlers.clear(); this.messageHandlers.clear();
this.logger.info("[twitch] Disconnected all clients"); this.logger.info("[twitch] Disconnected all clients");
} }
/** /**
* Send a message to a channel * Send a message to a channel
*/ */
async sendMessage( async sendMessage(
account: TwitchAccountConfig, account: TwitchAccountConfig,
channel: string, channel: string,
message: string, message: string,
cfg?: ClawdbotConfig, cfg?: ClawdbotConfig,
accountId?: string, accountId?: string,
): Promise<{ ok: boolean; error?: string; messageId?: string }> { ): Promise<{ ok: boolean; error?: string; messageId?: string }> {
try { try {
const client = await this.getClient(account, cfg, accountId); 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) // 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)}`; const messageId = `${Date.now()}-${Math.random().toString(36).substring(2, 15)}`;
// Send message (Twurple handles rate limiting) // Send message (Twurple handles rate limiting)
await client.say(channel, message); await client.say(channel, message);
return { ok: true, messageId }; return { ok: true, messageId };
} catch (error) { } catch (error) {
this.logger.error( this.logger.error(
`[twitch] Failed to send message: ${error instanceof Error ? error.message : String(error)}`, `[twitch] Failed to send message: ${error instanceof Error ? error.message : String(error)}`,
); );
return { return {
ok: false, ok: false,
error: error instanceof Error ? error.message : String(error), error: error instanceof Error ? error.message : String(error),
}; };
} }
} }
/** /**
* Generate a unique key for an account * Generate a unique key for an account
*/ */
public getAccountKey(account: TwitchAccountConfig): string { public getAccountKey(account: TwitchAccountConfig): string {
return `${account.username}:${account.channel ?? account.username}`; return `${account.username}:${account.channel ?? account.username}`;
} }
/** /**
* Clear all clients and handlers (for testing) * Clear all clients and handlers (for testing)
*/ */
_clearForTest(): void { _clearForTest(): void {
this.clients.clear(); this.clients.clear();
this.messageHandlers.clear(); this.messageHandlers.clear();
} }
} }

View File

@ -16,38 +16,38 @@
* @returns Plain text with markdown removed * @returns Plain text with markdown removed
*/ */
export function stripMarkdownForTwitch(markdown: string): string { export function stripMarkdownForTwitch(markdown: string): string {
return markdown return (
// Images markdown
.replace(/!\[[^\]]*]\([^)]+\)/g, "") // Images
// Links .replace(/!\[[^\]]*]\([^)]+\)/g, "")
.replace(/\[([^\]]+)]\([^)]+\)/g, "$1") // Links
// Bold (**text**) .replace(/\[([^\]]+)]\([^)]+\)/g, "$1")
.replace(/\*\*([^*]+)\*\*/g, "$1") // Bold (**text**)
// Bold (__text__) .replace(/\*\*([^*]+)\*\*/g, "$1")
.replace(/__([^_]+)__/g, "$1") // Bold (__text__)
// Italic (*text*) .replace(/__([^_]+)__/g, "$1")
.replace(/\*([^*]+)\*/g, "$1") // Italic (*text*)
// Italic (_text_) .replace(/\*([^*]+)\*/g, "$1")
.replace(/_([^_]+)_/g, "$1") // Italic (_text_)
// Strikethrough (~~text~~) .replace(/_([^_]+)_/g, "$1")
.replace(/~~([^~]+)~~/g, "$1") // Strikethrough (~~text~~)
// Code blocks .replace(/~~([^~]+)~~/g, "$1")
.replace(/```[\s\S]*?```/g, (block) => // Code blocks
block.replace(/```[^\n]*\n?/g, "").replace(/```/g, ""), .replace(/```[\s\S]*?```/g, (block) => block.replace(/```[^\n]*\n?/g, "").replace(/```/g, ""))
) // Inline code
// Inline code .replace(/`([^`]+)`/g, "$1")
.replace(/`([^`]+)`/g, "$1") // Headers
// Headers .replace(/^#{1,6}\s+/gm, "")
.replace(/^#{1,6}\s+/gm, "") // Lists
// Lists .replace(/^\s*[-*+]\s+/gm, "")
.replace(/^\s*[-*+]\s+/gm, "") .replace(/^\s*\d+\.\s+/gm, "")
.replace(/^\s*\d+\.\s+/gm, "") // Normalize whitespace
// Normalize whitespace .replace(/\r/g, "") // Remove carriage returns
.replace(/\r/g, "") // Remove carriage returns .replace(/[ \t]+\n/g, "\n") // Remove trailing spaces before newlines
.replace(/[ \t]+\n/g, "\n") // Remove trailing spaces before newlines .replace(/\n/g, " ") // Replace newlines with spaces (for Twitch)
.replace(/\n/g, " ") // Replace newlines with spaces (for Twitch) .replace(/[ \t]{2,}/g, " ") // Reduce multiple spaces to single
.replace(/[ \t]{2,}/g, " ") // Reduce multiple spaces to single .trim()
.trim(); );
} }
/** /**
@ -59,34 +59,34 @@ export function stripMarkdownForTwitch(markdown: string): string {
* @returns Array of text chunks * @returns Array of text chunks
*/ */
export function chunkTextForTwitch(text: string, limit: number): string[] { export function chunkTextForTwitch(text: string, limit: number): string[] {
// First, strip markdown // First, strip markdown
const cleaned = stripMarkdownForTwitch(text); const cleaned = stripMarkdownForTwitch(text);
if (!cleaned) return []; if (!cleaned) return [];
if (limit <= 0) return [cleaned]; if (limit <= 0) return [cleaned];
if (cleaned.length <= limit) return [cleaned]; if (cleaned.length <= limit) return [cleaned];
const chunks: string[] = []; const chunks: string[] = [];
let remaining = cleaned; let remaining = cleaned;
while (remaining.length > limit) { while (remaining.length > limit) {
// Find the last space before the limit // Find the last space before the limit
const window = remaining.slice(0, limit); const window = remaining.slice(0, limit);
const lastSpaceIndex = window.lastIndexOf(" "); const lastSpaceIndex = window.lastIndexOf(" ");
if (lastSpaceIndex === -1) { if (lastSpaceIndex === -1) {
// No space found, hard split at limit // No space found, hard split at limit
chunks.push(window); chunks.push(window);
remaining = remaining.slice(limit); remaining = remaining.slice(limit);
} else { } else {
// Split at the last space // Split at the last space
chunks.push(window.slice(0, lastSpaceIndex)); chunks.push(window.slice(0, lastSpaceIndex));
remaining = remaining.slice(lastSpaceIndex + 1); remaining = remaining.slice(lastSpaceIndex + 1);
} }
} }
if (remaining) { if (remaining) {
chunks.push(remaining); chunks.push(remaining);
} }
return chunks; return chunks;
} }

View File

@ -16,8 +16,8 @@
* normalizeTwitchChannel("MyChannel") // "mychannel" * normalizeTwitchChannel("MyChannel") // "mychannel"
*/ */
export function normalizeTwitchChannel(channel: string): string { export function normalizeTwitchChannel(channel: string): string {
const trimmed = channel.trim().toLowerCase(); const trimmed = channel.trim().toLowerCase();
return trimmed.startsWith("#") ? trimmed.slice(1) : trimmed; return trimmed.startsWith("#") ? trimmed.slice(1) : trimmed;
} }
/** /**
@ -28,9 +28,7 @@ export function normalizeTwitchChannel(channel: string): string {
* @returns Error object with descriptive message * @returns Error object with descriptive message
*/ */
export function missingTargetError(provider: string, hint?: string): Error { export function missingTargetError(provider: string, hint?: string): Error {
return new Error( return new Error(`Delivering to ${provider} requires target${hint ? ` ${hint}` : ""}`);
`Delivering to ${provider} requires target${hint ? ` ${hint}` : ""}`,
);
} }
/** /**
@ -42,7 +40,7 @@ export function missingTargetError(provider: string, hint?: string): Error {
* @returns A unique message ID * @returns A unique message ID
*/ */
export function generateMessageId(): string { 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" * normalizeToken("abc123") // "abc123"
*/ */
export function normalizeToken(token: string): string { 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 * @returns true if the account has required credentials
*/ */
export function isAccountConfigured(account: { export function isAccountConfigured(account: {
username?: string; username?: string;
token?: string; token?: string;
clientId?: string; clientId?: string;
}): boolean { }): boolean {
return Boolean(account?.username && account?.token && account?.clientId); return Boolean(account?.username && account?.token && account?.clientId);
} }