diff --git a/.gitignore b/.gitignore index 3cde11e..9dcebc1 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ node_modules dist package-lock.jsom +db diff --git a/package.json b/package.json index ebf7146..24f4389 100644 --- a/package.json +++ b/package.json @@ -7,9 +7,9 @@ "clean": "rimraf dist", "build": "npm run clean && tsc -p .", "start": "node dist/bot.js", - "dev": "nodemon src/bot.ts --verbose", - "setup:env": "nodemon src/setup-env.ts --verbose", - "setup:auth": "nodemon src/setup-auth.ts --verbose" + "dev": "nodemon src/bot.ts", + "setup:env": "nodemon src/setup-env.ts", + "setup:auth": "nodemon src/setup-auth.ts" }, "keywords": [], "author": "", diff --git a/src/commands/base-command.ts b/src/commands/base-command.ts index 5fd3214..fd30911 100644 --- a/src/commands/base-command.ts +++ b/src/commands/base-command.ts @@ -1,3 +1,4 @@ +import type { ChatUser } from "@twurple/chat"; import { chatClient } from "../core/chat-client.ts"; import type { ICommand, ICommandRequirements } from "./interface.ts"; @@ -9,6 +10,7 @@ export abstract class BaseCommand implements ICommand { abstract name: string; abstract cooldown: number; abstract enabled: boolean; + abstract checkPerms(user: ChatUser): boolean; abstract triggered(...args: unknown[]): Promise; abstract requirements: ICommandRequirements; } diff --git a/src/commands/collection.ts b/src/commands/collection.ts index 1a6147a..9303ef3 100644 --- a/src/commands/collection.ts +++ b/src/commands/collection.ts @@ -1,7 +1,9 @@ import { Collection } from "@discordjs/collection"; -import { SongCommand } from "./command-song.ts"; +import { SongCommand } from "./impl/song.ts"; import type { ICommand } from "./interface.ts"; export const commands = new Collection(); -commands.set(SongCommand.name, new SongCommand()); +const songCommand = new SongCommand(); + +commands.set(songCommand.name, songCommand); diff --git a/src/commands/command-song.ts b/src/commands/command-song.ts deleted file mode 100644 index 7883d55..0000000 --- a/src/commands/command-song.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { ChatMessage } from "@twurple/chat"; -import axios from "axios"; -import { Config } from "../config/config.ts"; -import { logError, logSuccess } from "../util/logger.ts"; -import { BaseCommand } from "./base-command.ts"; -import type { ICommandRequirements } from "./interface.ts"; - -interface ISong { - title: string; - artists: string; - album: string; - playingFrom: string; - status: "playing" | "paused"; - url: string; - current: string; - duration: string; -} - -export class SongCommand extends BaseCommand { - name = "song"; - cooldown = 0; - enabled = true; - - requirements: ICommandRequirements = { - developer: true, - mod: false, - }; - - triggered = async ( - channel: string, - user: string, - _text: string, - _msg: ChatMessage, - ) => { - logSuccess(`${channel} ${user} song command triggered`); - logSuccess(await getSongFromTidal()); - // client.say(channel, "testing"); - }; -} - -async function getSongFromTidal() { - axios.get(`${Config.tidal.host}:${Config.tidal.port}/current`).then( - (response) => { - const currentSong = response.data; - - const status = currentSong.status === "playing" ? "▶️" : "⏸️"; - - return `listening to ${currentSong.title} by ${currentSong.artists}. ${status} ${currentSong.current}/${currentSong.duration}. link: ${currentSong.url}`; - }, - () => { - logError("error getting song from tidal"); - return ""; - }, - ); -} diff --git a/src/commands/impl/song.ts b/src/commands/impl/song.ts new file mode 100644 index 0000000..c42c35d --- /dev/null +++ b/src/commands/impl/song.ts @@ -0,0 +1,78 @@ +import type { ChatMessage, ChatUser } from "@twurple/chat"; +import axios from "axios"; +import { Config } from "../../config/config.ts"; +import { chatClient } from "../../core/chat-client.ts"; +import { logSuccess, logWarning } from "../../util/logger.ts"; +import { BaseCommand } from "../base-command.ts"; +import type { ICommandRequirements } from "../interface.ts"; + +interface ISong { + title: string; + artists: string; + album: string; + playingFrom: string; + status: "playing" | "paused"; + url: string; + current: string; + duration: string; +} + +export class SongCommand extends BaseCommand { + name = "song"; + cooldown = 0; + enabled = true; + + requirements: ICommandRequirements = { + developer: true, + mod: false, + }; + + triggered = async ( + channel: string, + user: string, + _text: string, + msg: ChatMessage, + ) => { + logSuccess(`${channel} ${user} song command triggered`); + const song = await getSongFromTidal(); + if (song) { + logSuccess(song); + chatClient.say(channel, song, { replyTo: msg }); + } else { + chatClient.say(channel, "tidal not running..", { replyTo: msg }); + } + }; + + checkPerms = (user: ChatUser): boolean => { + if (!this.enabled) { + return false; + } + + if ( + (this.requirements.developer && + !Config.developers.includes(user.userId)) || + (this.requirements.mod && !user.isMod) + ) { + return false; + } + + return true; + }; +} + +async function getSongFromTidal(): Promise { + try { + const response = await axios.get( + `${Config.tidal.host}:${Config.tidal.port}/current`, + ); + + const currentSong = response.data; + + const status = currentSong.status === "playing" ? "▶️" : "⏸️"; + + return `listening to ${currentSong.title} by ${currentSong.artists}. ${status} ${currentSong.current}/${currentSong.duration}. link: ${currentSong.url}`; + } catch { + logWarning("error getting song from tidal"); + return ""; + } +} diff --git a/src/commands/interface.ts b/src/commands/interface.ts index 3d97922..65db687 100644 --- a/src/commands/interface.ts +++ b/src/commands/interface.ts @@ -1,7 +1,10 @@ +import type { ChatUser } from "@twurple/chat"; + export interface ICommand { name: string; cooldown: number; enabled: boolean; + checkPerms(user: ChatUser): boolean; triggered(...args: unknown[]): Promise; requirements: ICommandRequirements; } diff --git a/src/core/token-manager.ts b/src/core/token-manager.ts index cc36eac..f49dc12 100644 --- a/src/core/token-manager.ts +++ b/src/core/token-manager.ts @@ -1,27 +1,29 @@ import { promises as fs } from "node:fs"; import type { AccessToken } from "@twurple/auth"; +import { logError, logInfo } from "../util/logger.ts"; export class TokenManager { private readonly tokenFilePath: string; constructor(userId: string) { - this.tokenFilePath = `../db/token.${userId}.json`; + this.tokenFilePath = `./db/token.${userId}.json`; } async getToken(): Promise { try { const tokenFile = await fs.readFile(this.tokenFilePath, "utf-8"); + logInfo(`token file ${this.tokenFilePath} read`); return JSON.parse(tokenFile); } catch (_error) { - console.log( + logError( `token file ${this.tokenFilePath} not found. please run the setup`, ); - return null; + throw new Error(); } } async createTokenFile(tokenData: AccessToken): Promise { - const formattedTokens = JSON.stringify(tokenData, null, 4); - await fs.writeFile(this.tokenFilePath, formattedTokens, "utf-8"); + const formattedToken = JSON.stringify(tokenData, null, 4); + await fs.writeFile(this.tokenFilePath, formattedToken, "utf-8"); } } diff --git a/src/events/event-connected.ts b/src/events/impl/connected.ts similarity index 51% rename from src/events/event-connected.ts rename to src/events/impl/connected.ts index 333d0e0..18c2744 100644 --- a/src/events/event-connected.ts +++ b/src/events/impl/connected.ts @@ -1,6 +1,6 @@ -import { logSuccess } from "../util/logger.ts"; -import { BaseEvent } from "./base-event.ts"; -import type { EventName } from "./registry.ts"; +import { logSuccess } from "../../util/logger.ts"; +import { BaseEvent } from "../base-event.ts"; +import type { EventName } from "../registry.ts"; export default class ConnectedEvent extends BaseEvent { name: EventName = "connected"; diff --git a/src/events/event-message.ts b/src/events/impl/message.ts similarity index 67% rename from src/events/event-message.ts rename to src/events/impl/message.ts index 41dd4e7..e9c3f02 100644 --- a/src/events/event-message.ts +++ b/src/events/impl/message.ts @@ -1,11 +1,10 @@ import { Collection } from "@discordjs/collection"; 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 { logInfo } from "../util/logger.ts"; -import { BaseEvent } from "./base-event.ts"; -import type { EventName } from "./registry.ts"; +import { commands } from "../../commands/collection.ts"; +import type { ICommand } from "../../commands/interface.ts"; +import { Config } from "../../config/config.ts"; +import { BaseEvent } from "../base-event.ts"; +import type { EventName } from "../registry.ts"; const Cooldowns = new Collection(); @@ -28,7 +27,7 @@ async function checkMessage( text: string, msg: ChatMessage, ) { - logInfo(`message seen: ${channel} - ${user} - ${text}`); + // logInfo(`message seen: ${channel} - ${user} - ${text}`); const prefix = Config.prefix; if (!text.startsWith(prefix)) return; @@ -37,13 +36,11 @@ async function checkMessage( .trim() .split(/ +/g)[0] .toLowerCase(); + // logInfo(`available commands: ${commands.toJSON()}`); + // logInfo(`searching for command: ${commandName}`); const command = commands.get(commandName); if (!command) return; - if (!command.enabled) return; - - if (command.requirements.developer && !isDeveloper(msg.userInfo.userId)) - return; - if (command.requirements.mod && !msg.userInfo.isMod) return; + if (!command.checkPerms) return; const timeLeft = checkCooldown(command); if (timeLeft > 0) { @@ -56,17 +53,13 @@ async function checkMessage( await command.triggered(channel, user, text, msg); } -function isDeveloper(userId: string): boolean { - return Config.developers.includes(userId); -} - function checkCooldown(command: ICommand): number { const now = Date.now(); if (command.cooldown > 0) { const cooldownTime = Cooldowns.get(command.name); if (cooldownTime) { if (cooldownTime < now) { - const timeLeft = 0; // TODO + const timeLeft = 0; // TODO!!! return timeLeft; } else { Cooldowns.set(command.name, now + command.cooldown * 1000); diff --git a/src/events/registry.ts b/src/events/registry.ts index d023727..8118591 100644 --- a/src/events/registry.ts +++ b/src/events/registry.ts @@ -1,6 +1,7 @@ import { chatClient } from "../core/chat-client.ts"; -import ConnectedEvent from "./event-connected.ts"; -import MessageEvent from "./event-message.ts"; +import { logInfo } from "../util/logger.ts"; +import ConnectedEvent from "./impl/connected.ts"; +import MessageEvent from "./impl/message.ts"; import type { IEvent } from "./interface.ts"; export const eventRegistry = { @@ -13,6 +14,7 @@ export function registerAllEvents() { for (const event of events) { eventRegistry[event.name](event); // Registers the event + logInfo(`event ${event.name} registered`); } } diff --git a/src/setup-auth.ts b/src/setup-auth.ts index 0976663..92b9fed 100644 --- a/src/setup-auth.ts +++ b/src/setup-auth.ts @@ -1,29 +1,21 @@ import { exchangeCode } from "@twurple/auth"; -import { Config } from "./config/config"; -import { TokenManager } from "./core/token-manager"; -import { TwitchAuth } from "./util/auth"; -import { getUserId, promptForInput } from "./util/general"; -import { logError, logInfo } from "./util/logger"; +import { Config } from "./config/config.ts"; +import { TokenManager } from "./core/token-manager.ts"; +import { TwitchAuth } from "./util/auth.ts"; +import { logInfo } from "./util/logger.ts"; const port = 3000; const redirectUri = `http://localhost:${port}`; const scopes = ["chat:read", "chat:edit", "channel:moderate"]; -const userName = await promptForInput("enter userName to authorize on: "); - -const userId = await getUserId(userName); -if (!userId) { - logError("user not found. please check the configuration"); - throw new Error(); -} - const auth = new TwitchAuth(redirectUri); -const authUrl = auth.getAuthorizationUrl(scopes); +const state = auth.generateState(); +const authUrl = auth.getAuthorizationUrl(scopes, state); logInfo("To authorize your Twitch bot, visit this URL:"); logInfo(authUrl); -const code = await auth.startCallbackServer(port, 120); +const code = await auth.startCallbackServer(port, 120, state); -const tokenManager = new TokenManager(userId); +const tokenManager = new TokenManager(Config.bot_user_id); tokenManager.createTokenFile( await exchangeCode(Config.client_id, Config.client_secret, code, redirectUri), diff --git a/src/setup-env.ts b/src/setup-env.ts index 28604c1..c3574ab 100644 --- a/src/setup-env.ts +++ b/src/setup-env.ts @@ -1,5 +1,5 @@ import { getUserId, promptForInput } from "./util/general.ts"; -import { logError, logInfo, logWarning } from "./util/logger.ts"; +import { logError, logSuccess, logWarning } from "./util/logger.ts"; const botname = await promptForInput("enter bot username: "); const developers = ( @@ -13,7 +13,7 @@ if (!botId) { } const developerIds = []; -for (const dev in developers) { +for (const dev of developers) { const devId = await getUserId(dev); if (devId) { developerIds.push(devId); @@ -22,7 +22,7 @@ for (const dev in developers) { } } -logInfo(`Userid of bot '${botname}' => '${botId}'`); -logInfo( +logSuccess(`Userid of bot '${botname}' => '${botId}'`); +logSuccess( `Userids of developers '${developers.join(",")}' => '${developerIds.join(",")}'`, ); diff --git a/src/util/auth.ts b/src/util/auth.ts index b3f0844..d6b4643 100644 --- a/src/util/auth.ts +++ b/src/util/auth.ts @@ -1,3 +1,4 @@ +import * as crypto from "node:crypto"; import { createServer, type IncomingMessage, @@ -5,6 +6,7 @@ import { } from "node:http"; import { URL } from "node:url"; import { Config } from "../config/config.ts"; +import { logError, logInfo } from "./logger.ts"; export class TwitchAuth { private readonly redirectUri: string; @@ -13,20 +15,29 @@ export class TwitchAuth { this.redirectUri = redirectUri; } - getAuthorizationUrl(scopes: string[]): string { + getAuthorizationUrl(scopes: string[], state: string): string { const baseUrl = "https://id.twitch.tv/oauth2/authorize"; const params = new URLSearchParams({ response_type: "code", client_id: Config.client_id, redirect_uri: this.redirectUri, scope: scopes.join(" "), + state: state, }); return `${baseUrl}?${params.toString()}`; } - startCallbackServer(port: number, timeoutS: number = 120): Promise { - return new Promise((resolve, reject) => { + generateState(): string { + return crypto.randomBytes(20).toString("hex"); + } + + startCallbackServer( + port: number, + timeoutS: number = 120, + state: string, + ): Promise { + return new Promise((resolve) => { const server = createServer( (req: IncomingMessage, res: ServerResponse) => { if (!req.url) { @@ -38,8 +49,15 @@ export class TwitchAuth { const url = new URL(req.url, `http://localhost:${port}`); const code = url.searchParams.get("code"); const error = url.searchParams.get("error"); + const responseState = url.searchParams.get("state"); const errorDescription = url.searchParams.get("error_description"); + if (state !== responseState) { + res.writeHead(400, { "Content-Type": "text/html" }); + res.end("

Wrong state

Invalid state param

"); + return; + } + if (error) { res.writeHead(400, { "Content-Type": "text/html" }); res.end(` @@ -49,10 +67,8 @@ export class TwitchAuth {

You can close this window.

`); server.close(); - reject( - new Error(`Authorization failed: ${error} - ${errorDescription}`), - ); - return; + logError(`authorization failed: ${error} - ${errorDescription}`); + throw new Error(); } if (code) { @@ -73,18 +89,20 @@ export class TwitchAuth { ); server.listen(port, "localhost", () => { - console.log( - `Waiting for authorization callback on http://localhost:${port}`, + logInfo( + `waiting for authorization callback on http://localhost:${port}`, ); }); server.on("error", (err) => { - reject(new Error(`Server error: ${err.message}`)); + logError(`server error: ${err.message}`); + throw new Error(); }); const timeout = setTimeout(() => { server.close(); - reject(new Error("Authorization timeout. Please try again.")); + logError("authorization timeout"); + throw new Error(); }, timeoutS * 1000); server.on("close", () => { diff --git a/src/util/general.ts b/src/util/general.ts index 83ff736..f1a15c6 100644 --- a/src/util/general.ts +++ b/src/util/general.ts @@ -1,14 +1,14 @@ import * as readline from "node:readline"; import { apiClient } from "../core/api-client.ts"; -import { logError, logInfo } from "./logger.ts"; +import { logSuccess, logWarning } from "./logger.ts"; export async function getUserId(username: string): Promise { const user = await apiClient.users.getUserByName(username); - if (user) { - logInfo(`${user.name} => ${user.id}`); + if (user?.id) { + logSuccess(`${user.name} => ${user.id}`); return user.id; } - logError(`no user with name ${username} found`); + logWarning(`no user with name ${username} found`); return null; } diff --git a/src/util/logger.ts b/src/util/logger.ts index 68db067..24ad46e 100644 --- a/src/util/logger.ts +++ b/src/util/logger.ts @@ -1,7 +1,5 @@ import chalk from "chalk"; -const verbose = process.argv.includes("--verbose"); - export function logError(...args: unknown[]) { console.log(chalk.red(args)); } @@ -15,7 +13,5 @@ export function logSuccess(...args: unknown[]) { } export function logInfo(...args: unknown[]) { - if (verbose) { - console.log(chalk.cyan(args)); - } + console.log(chalk.cyan(args)); }