diff --git a/package-lock.json b/package-lock.json index cd849e2..01c80ac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "ISC", "dependencies": { "@discordjs/collection": "^2.1.1", + "@twurple/api": "^7.4.0", "@twurple/auth": "^7.4.0", "@twurple/chat": "^7.4.0", "chalk": "^5.6.2", @@ -277,6 +278,31 @@ "dev": true, "license": "MIT" }, + "node_modules/@twurple/api": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@twurple/api/-/api-7.4.0.tgz", + "integrity": "sha512-RlXLs4ZvS8n0+iIk7YyVDwrjhlwpn+N+h7fX5Q61HoxlmzoCShmnnFo03abYw9i8Cc3deGpbQATOSVmigXM4qg==", + "license": "MIT", + "dependencies": { + "@d-fischer/cache-decorators": "^4.0.0", + "@d-fischer/cross-fetch": "^5.0.1", + "@d-fischer/detect-node": "^3.0.1", + "@d-fischer/logger": "^4.2.1", + "@d-fischer/rate-limiter": "^1.1.0", + "@d-fischer/shared-utils": "^3.6.1", + "@d-fischer/typed-event-emitter": "^3.3.1", + "@twurple/api-call": "7.4.0", + "@twurple/common": "7.4.0", + "retry": "^0.13.1", + "tslib": "^2.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "@twurple/auth": "7.4.0" + } + }, "node_modules/@twurple/api-call": { "version": "7.4.0", "resolved": "https://registry.npmjs.org/@twurple/api-call/-/api-call-7.4.0.tgz", @@ -1025,6 +1051,15 @@ "node": ">=8.10.0" } }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/rimraf": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.0.1.tgz", diff --git a/package.json b/package.json index a358843..ea6d703 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ }, "dependencies": { "@discordjs/collection": "^2.1.1", + "@twurple/api": "^7.4.0", "@twurple/auth": "^7.4.0", "@twurple/chat": "^7.4.0", "chalk": "^5.6.2", diff --git a/src/bot.ts b/src/bot.ts index 566e12b..eb1dea1 100644 --- a/src/bot.ts +++ b/src/bot.ts @@ -1,5 +1,5 @@ -import { client } from "./core/client.ts"; +import { chatClient } from "./core/chat-client.ts"; import { registerAllEvents } from "./events/registry.ts"; -registerAllEvents(client); -client.connect(); +registerAllEvents(); +chatClient.connect(); diff --git a/src/commands/base-command.ts b/src/commands/base-command.ts new file mode 100644 index 0000000..4a9963b --- /dev/null +++ b/src/commands/base-command.ts @@ -0,0 +1,14 @@ +import { chatClient } from "../core/chat-client.ts"; +import type { ICommand, ICommandRequirements } from "./interface"; + +export abstract class BaseCommand implements ICommand { + protected get chatClient() { + return chatClient; + } + + abstract name: string; + abstract cooldown: number; + abstract enabled: boolean; + abstract triggered(...args: unknown[]): Promise; + abstract requirements: ICommandRequirements; +} diff --git a/src/commands/command-song.ts b/src/commands/command-song.ts index 3e0eba8..e30dc94 100644 --- a/src/commands/command-song.ts +++ b/src/commands/command-song.ts @@ -1,8 +1,9 @@ import type { ChatMessage } from "@twurple/chat"; -import { logSuccess } from "../logger/logger.ts"; -import type { ICommand, ICommandRequirements } from "./interface.ts"; +import { logSuccess } from "../util/logger.ts"; +import { BaseCommand } from "./base-command.ts"; +import type { ICommandRequirements } from "./interface.ts"; -export class SongCommand implements ICommand { +export class SongCommand extends BaseCommand { name = "song"; cooldown = 0; enabled = true; diff --git a/src/config/config.ts b/src/config/config.ts index 4f504e6..0d71ac0 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -7,6 +7,6 @@ export const Config = { bot_user_id: process.env.BOT_USER_ID || "", client_id: process.env.CLIENT_ID || "", client_secret: process.env.CLIENT_SECRET || "", - channels: [process.env.CHANNELS || ""], - developers: [process.env.DEVELOPERS || ""], + channels: process.env.CHANNELS?.split(",") || [], + developers: process.env.DEVELOPERS?.split(",") || [], }; diff --git a/src/core/api-client.ts b/src/core/api-client.ts new file mode 100644 index 0000000..2de8221 --- /dev/null +++ b/src/core/api-client.ts @@ -0,0 +1,10 @@ +import { ApiClient } from "@twurple/api"; +import { AppTokenAuthProvider } from "@twurple/auth"; +import { Config } from "../config/config"; + +const authProvider = new AppTokenAuthProvider( + Config.client_id, + Config.client_secret, +); + +export const apiClient = new ApiClient({ authProvider }); diff --git a/src/core/chat-client.ts b/src/core/chat-client.ts new file mode 100644 index 0000000..d9ae383 --- /dev/null +++ b/src/core/chat-client.ts @@ -0,0 +1,29 @@ +import { type AccessToken, RefreshingAuthProvider } from "@twurple/auth"; +import { ChatClient } from "@twurple/chat"; +import { Config } from "../config/config.ts"; +import { logError } from "../util/logger.ts"; +import { TokenManager } from "./token-manager.ts"; + +const tokenManager = new TokenManager(Config.bot_user_id); +const tokenData = await tokenManager.getToken(); + +if (!tokenData) { + logError("no token found"); + throw new Error(); +} + +const authProviderPrivate = new RefreshingAuthProvider({ + clientId: Config.client_id, + clientSecret: Config.client_secret, +}); + +authProviderPrivate.onRefresh(async (_userId, newTokenData: AccessToken) => + tokenManager.updateToken(newTokenData), +); + +authProviderPrivate.addUser(Config.bot_user_id, tokenData, ["chat"]); + +export const chatClient = new ChatClient({ + authProvider: authProviderPrivate, + channels: Config.channels, +}); diff --git a/src/core/client.ts b/src/core/client.ts deleted file mode 100644 index 414d2c0..0000000 --- a/src/core/client.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { promises as fs } from "node:fs"; -import { RefreshingAuthProvider } from "@twurple/auth"; -import { ChatClient } from "@twurple/chat"; -import { Config } from "../config/config.ts"; - -// Auth Token Stuff -const tokenData = JSON.parse( - await fs.readFile(`./ tokens.${Config.bot_user_id}.json`, "utf-8"), -); - -const authProvider = new RefreshingAuthProvider({ - clientId: Config.client_id, - clientSecret: Config.client_secret, -}); - -authProvider.onRefresh( - async (_userId, newTokenData) => - await fs.writeFile( - `./tokens.${Config.bot_user_id}.json`, - JSON.stringify(newTokenData, null, 4), - "utf-8", - ), -); - -await authProvider.addUserForToken(tokenData, ["chat"]); - -export const client = new ChatClient({ - authProvider, - channels: Config.channels, -}); diff --git a/src/core/token-manager.ts b/src/core/token-manager.ts new file mode 100644 index 0000000..2625606 --- /dev/null +++ b/src/core/token-manager.ts @@ -0,0 +1,32 @@ +import { promises as fs } from "node:fs"; +import * as readline from "node:readline"; +import type { AccessToken } from "@twurple/auth"; + +export class TokenManager { + private readonly tokenFilePath: string; + + constructor(userId: string) { + this.tokenFilePath = `./tokens/${userId}.json`; + } + + async getToken(): Promise { + try { + const tokenFile = await fs.readFile(this.tokenFilePath, "utf-8"); + return JSON.parse(tokenFile); + } catch (_error) { + console.log( + `Token file ${this.tokenFilePath} not found. Please run the setup`, + ); + return null; + } + } + + async updateToken(token: AccessToken): Promise { + this.createTokenFile(token); + } + + private async createTokenFile(tokenData: AccessToken): Promise { + const formattedTokens = JSON.stringify(tokenData, null, 4); + await fs.writeFile(this.tokenFilePath, formattedTokens, "utf-8"); + } +} diff --git a/src/events/base-event.ts b/src/events/base-event.ts new file mode 100644 index 0000000..5b28168 --- /dev/null +++ b/src/events/base-event.ts @@ -0,0 +1,12 @@ +import { chatClient } from "../core/chat-client.ts"; +import type { IEvent } from "./interface.ts"; +import type { EventName } from "./registry.ts"; + +export abstract class BaseEvent implements IEvent { + protected get chatClient() { + return chatClient; + } + + abstract name: EventName; + abstract triggered(...args: unknown[]): Promise; +} diff --git a/src/events/event-connected.ts b/src/events/event-connected.ts index 88c0836..333d0e0 100644 --- a/src/events/event-connected.ts +++ b/src/events/event-connected.ts @@ -1,8 +1,8 @@ -import { logSuccess } from "../logger/logger.ts"; -import type { IEvent } from "./interface.ts"; +import { logSuccess } from "../util/logger.ts"; +import { BaseEvent } from "./base-event.ts"; import type { EventName } from "./registry.ts"; -export default class ConnectedEvent implements IEvent { +export default class ConnectedEvent extends BaseEvent { name: EventName = "connected"; triggered = async () => { diff --git a/src/events/event-message.ts b/src/events/event-message.ts index 730a1ad..4fad0a2 100644 --- a/src/events/event-message.ts +++ b/src/events/event-message.ts @@ -3,12 +3,12 @@ import type { ChatMessage } from "@twurple/chat"; import { commands } from "../commands/collection.ts"; import type { ICommand } from "../commands/interface.ts"; import { Config } from "../config/config.ts"; -import type { IEvent } from "./interface.ts"; +import { BaseEvent } from "./base-event.ts"; import type { EventName } from "./registry.ts"; const Cooldowns = new Collection(); -export default class MessageEvent implements IEvent { +export default class MessageEvent extends BaseEvent { name: EventName = "message"; triggered = async ( @@ -45,7 +45,7 @@ async function checkMessage( const timeLeft = checkCooldown(command); if (timeLeft > 0) { - // return client.say( + // return chatClient.say( // channel, // `@${user}, you must wait ${timeLeft} more seconds to use the command again`, // ); diff --git a/src/events/registry.ts b/src/events/registry.ts index 23ff657..d023727 100644 --- a/src/events/registry.ts +++ b/src/events/registry.ts @@ -1,23 +1,18 @@ -import type { ChatClient } from "@twurple/chat"; +import { chatClient } from "../core/chat-client.ts"; import ConnectedEvent from "./event-connected.ts"; import MessageEvent from "./event-message.ts"; import type { IEvent } from "./interface.ts"; export const eventRegistry = { - message: (client: ChatClient, handler: IEvent) => - client.onMessage(handler.triggered), - connected: (client: ChatClient, handler: IEvent) => - client.onConnect(handler.triggered), + message: (handler: IEvent) => chatClient.onMessage(handler.triggered), + connected: (handler: IEvent) => chatClient.onConnect(handler.triggered), }; -export function registerAllEvents(client: ChatClient) { +export function registerAllEvents() { const events = [new MessageEvent(), new ConnectedEvent()]; for (const event of events) { - const registerFn = eventRegistry[event.name]; - if (registerFn) { - registerFn(client, event); - } + eventRegistry[event.name](event); // Registers the event } } diff --git a/src/setup.ts b/src/setup.ts new file mode 100644 index 0000000..29149f4 --- /dev/null +++ b/src/setup.ts @@ -0,0 +1,47 @@ +import { exchangeCode } from "@twurple/auth"; +import { Config } from "./config/config"; +import { TokenManager } from "./core/token-manager"; +import { TwitchAuth } from "./util/auth"; +import { getUserId } from "./util/general"; +import { logError, logInfo, logWarning } from "./util/logger"; + +const botname = ""; +const developers = [""]; +const scopes = ["chat:read", "chat:edit", "channel:moderate"]; +const port = 3000; +const redirectUri = `http://localhost:${port}`; + +const botId = await getUserId(botname); +if (!botId) { + logError("bot not found. please check the configuration"); + throw new Error(); +} + +const developerIds = []; +for (const dev in developers) { + const devId = await getUserId(dev); + if (devId) { + developerIds.push(devId); + } else { + logWarning(`dev '${dev}' not found. please check the configuration`); + } +} + +logInfo(`Userid of bot '${botname}' => '${botId}'`); +logInfo( + `Userids of developers '${developers.join(",")}' => '${developerIds.join(",")}'`, +); + +logInfo("--------"); + +const auth = new TwitchAuth(redirectUri); +const authUrl = auth.getAuthorizationUrl(scopes); +logInfo("To authorize your Twitch bot, visit this URL:"); +logInfo(authUrl); +const code = await auth.startCallbackServer(port, 120); + +const tokenManager = new TokenManager(botId); + +tokenManager.updateToken( + await exchangeCode(Config.client_id, Config.client_secret, code, redirectUri), +); diff --git a/src/util/auth.ts b/src/util/auth.ts new file mode 100644 index 0000000..bceaeea --- /dev/null +++ b/src/util/auth.ts @@ -0,0 +1,97 @@ +import { + createServer, + type IncomingMessage, + type ServerResponse, +} from "node:http"; +import { URL } from "node:url"; +import { Config } from "../config/config.js"; + +export class TwitchAuth { + private readonly clientId: string; + private readonly redirectUri: string; + + constructor(redirectUri: string = "http://localhost:3000") { + this.clientId = Config.client_id; + this.redirectUri = redirectUri; + } + + getAuthorizationUrl(scopes: string[]): string { + const baseUrl = "https://id.twitch.tv/oauth2/authorize"; + const params = new URLSearchParams({ + response_type: "code", + client_id: this.clientId, + redirect_uri: this.redirectUri, + scope: scopes.join(" "), + }); + + return `${baseUrl}?${params.toString()}`; + } + + startCallbackServer(port: number, timeoutS: number = 120): Promise { + return new Promise((resolve, reject) => { + const server = createServer( + (req: IncomingMessage, res: ServerResponse) => { + if (!req.url) { + res.writeHead(400, { "Content-Type": "text/html" }); + res.end("

Bad Request

Invalid request URL

"); + return; + } + + const url = new URL(req.url, `http://localhost:${port}`); + const code = url.searchParams.get("code"); + const error = url.searchParams.get("error"); + const errorDescription = url.searchParams.get("error_description"); + + if (error) { + res.writeHead(400, { "Content-Type": "text/html" }); + res.end(` +

Authorization Failed

+

Error: ${error}

+

Description: ${errorDescription || "Unknown error"}

+

You can close this window.

+ `); + server.close(); + reject( + new Error(`Authorization failed: ${error} - ${errorDescription}`), + ); + return; + } + + if (code) { + res.writeHead(200, { "Content-Type": "text/html" }); + res.end(` +

Authorization Successful!

+

You have successfully authorized the Twitch bot.

+

You can close this window and return to your terminal.

+ `); + server.close(); + resolve(code); + return; + } + + res.writeHead(400, { "Content-Type": "text/html" }); + res.end("

Bad Request

No authorization code received

"); + }, + ); + + server.listen(port, "localhost", () => { + console.log( + `Waiting for authorization callback on http://localhost:${port}`, + ); + }); + + server.on("error", (err) => { + reject(new Error(`Server error: ${err.message}`)); + }); + + const timeout = setTimeout(() => { + server.close(); + reject(new Error("Authorization timeout. Please try again.")); + }, timeoutS * 1000); + + server.on("close", () => { + clearTimeout(timeout); + }); + }); + } +} diff --git a/src/util/general.ts b/src/util/general.ts new file mode 100644 index 0000000..0d8cbdd --- /dev/null +++ b/src/util/general.ts @@ -0,0 +1,12 @@ +import { apiClient } from "../core/api-client"; +import { logError, logInfo } from "./logger"; + +export async function getUserId(username: string): Promise { + const user = await apiClient.users.getUserByName(username); + if (user) { + logInfo(`${user.name} => ${user.id}`); + return user.id; + } + logError(`no user with name ${username} found`); + return null; +} diff --git a/src/logger/logger.ts b/src/util/logger.ts similarity index 76% rename from src/logger/logger.ts rename to src/util/logger.ts index 384c4dc..24ad46e 100644 --- a/src/logger/logger.ts +++ b/src/util/logger.ts @@ -4,6 +4,10 @@ export function logError(...args: unknown[]) { console.log(chalk.red(args)); } +export function logWarning(...args: unknown[]) { + console.log(chalk.yellow(args)); +} + export function logSuccess(...args: unknown[]) { console.log(chalk.green(args)); }