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