tested & working basic bot

This commit is contained in:
Darius
2025-09-27 16:12:50 +02:00
parent 44f3f7af0b
commit 10eee0c0fd
16 changed files with 161 additions and 127 deletions

1
.gitignore vendored
View File

@@ -4,3 +4,4 @@
node_modules node_modules
dist dist
package-lock.jsom package-lock.jsom
db

View File

@@ -7,9 +7,9 @@
"clean": "rimraf dist", "clean": "rimraf dist",
"build": "npm run clean && tsc -p .", "build": "npm run clean && tsc -p .",
"start": "node dist/bot.js", "start": "node dist/bot.js",
"dev": "nodemon src/bot.ts --verbose", "dev": "nodemon src/bot.ts",
"setup:env": "nodemon src/setup-env.ts --verbose", "setup:env": "nodemon src/setup-env.ts",
"setup:auth": "nodemon src/setup-auth.ts --verbose" "setup:auth": "nodemon src/setup-auth.ts"
}, },
"keywords": [], "keywords": [],
"author": "", "author": "",

View File

@@ -1,3 +1,4 @@
import type { ChatUser } from "@twurple/chat";
import { chatClient } from "../core/chat-client.ts"; import { chatClient } from "../core/chat-client.ts";
import type { ICommand, ICommandRequirements } from "./interface.ts"; import type { ICommand, ICommandRequirements } from "./interface.ts";
@@ -9,6 +10,7 @@ export abstract class BaseCommand implements ICommand {
abstract name: string; abstract name: string;
abstract cooldown: number; abstract cooldown: number;
abstract enabled: boolean; abstract enabled: boolean;
abstract checkPerms(user: ChatUser): boolean;
abstract triggered(...args: unknown[]): Promise<unknown>; abstract triggered(...args: unknown[]): Promise<unknown>;
abstract requirements: ICommandRequirements; abstract requirements: ICommandRequirements;
} }

View File

@@ -1,7 +1,9 @@
import { Collection } from "@discordjs/collection"; import { Collection } from "@discordjs/collection";
import { SongCommand } from "./command-song.ts"; import { SongCommand } from "./impl/song.ts";
import type { ICommand } from "./interface.ts"; import type { ICommand } from "./interface.ts";
export const commands = new Collection<string, ICommand>(); export const commands = new Collection<string, ICommand>();
commands.set(SongCommand.name, new SongCommand()); const songCommand = new SongCommand();
commands.set(songCommand.name, songCommand);

View File

@@ -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<ISong>(`${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 "";
},
);
}

78
src/commands/impl/song.ts Normal file
View File

@@ -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<string> {
try {
const response = await axios.get<ISong>(
`${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 "";
}
}

View File

@@ -1,7 +1,10 @@
import type { ChatUser } from "@twurple/chat";
export interface ICommand { export interface ICommand {
name: string; name: string;
cooldown: number; cooldown: number;
enabled: boolean; enabled: boolean;
checkPerms(user: ChatUser): boolean;
triggered(...args: unknown[]): Promise<unknown>; triggered(...args: unknown[]): Promise<unknown>;
requirements: ICommandRequirements; requirements: ICommandRequirements;
} }

View File

@@ -1,27 +1,29 @@
import { promises as fs } from "node:fs"; import { promises as fs } from "node:fs";
import type { AccessToken } from "@twurple/auth"; import type { AccessToken } from "@twurple/auth";
import { logError, logInfo } from "../util/logger.ts";
export class TokenManager { export class TokenManager {
private readonly tokenFilePath: string; private readonly tokenFilePath: string;
constructor(userId: string) { constructor(userId: string) {
this.tokenFilePath = `../db/token.${userId}.json`; this.tokenFilePath = `./db/token.${userId}.json`;
} }
async getToken(): Promise<AccessToken | null> { async getToken(): Promise<AccessToken | null> {
try { try {
const tokenFile = await fs.readFile(this.tokenFilePath, "utf-8"); const tokenFile = await fs.readFile(this.tokenFilePath, "utf-8");
logInfo(`token file ${this.tokenFilePath} read`);
return JSON.parse(tokenFile); return JSON.parse(tokenFile);
} catch (_error) { } catch (_error) {
console.log( logError(
`token file ${this.tokenFilePath} not found. please run the setup`, `token file ${this.tokenFilePath} not found. please run the setup`,
); );
return null; throw new Error();
} }
} }
async createTokenFile(tokenData: AccessToken): Promise<void> { async createTokenFile(tokenData: AccessToken): Promise<void> {
const formattedTokens = JSON.stringify(tokenData, null, 4); const formattedToken = JSON.stringify(tokenData, null, 4);
await fs.writeFile(this.tokenFilePath, formattedTokens, "utf-8"); await fs.writeFile(this.tokenFilePath, formattedToken, "utf-8");
} }
} }

View File

@@ -1,6 +1,6 @@
import { logSuccess } from "../util/logger.ts"; import { logSuccess } from "../../util/logger.ts";
import { BaseEvent } from "./base-event.ts"; import { BaseEvent } from "../base-event.ts";
import type { EventName } from "./registry.ts"; import type { EventName } from "../registry.ts";
export default class ConnectedEvent extends BaseEvent { export default class ConnectedEvent extends BaseEvent {
name: EventName = "connected"; name: EventName = "connected";

View File

@@ -1,11 +1,10 @@
import { Collection } from "@discordjs/collection"; import { Collection } from "@discordjs/collection";
import type { ChatMessage } from "@twurple/chat"; import type { ChatMessage } from "@twurple/chat";
import { commands } from "../commands/collection.ts"; import { commands } from "../../commands/collection.ts";
import type { ICommand } from "../commands/interface.ts"; import type { ICommand } from "../../commands/interface.ts";
import { Config } from "../config/config.ts"; import { Config } from "../../config/config.ts";
import { logInfo } from "../util/logger.ts"; import { BaseEvent } from "../base-event.ts";
import { BaseEvent } from "./base-event.ts"; import type { EventName } from "../registry.ts";
import type { EventName } from "./registry.ts";
const Cooldowns = new Collection<string, number>(); const Cooldowns = new Collection<string, number>();
@@ -28,7 +27,7 @@ async function checkMessage(
text: string, text: string,
msg: ChatMessage, msg: ChatMessage,
) { ) {
logInfo(`message seen: ${channel} - ${user} - ${text}`); // logInfo(`message seen: ${channel} - ${user} - ${text}`);
const prefix = Config.prefix; const prefix = Config.prefix;
if (!text.startsWith(prefix)) return; if (!text.startsWith(prefix)) return;
@@ -37,13 +36,11 @@ async function checkMessage(
.trim() .trim()
.split(/ +/g)[0] .split(/ +/g)[0]
.toLowerCase(); .toLowerCase();
// logInfo(`available commands: ${commands.toJSON()}`);
// logInfo(`searching for command: ${commandName}`);
const command = commands.get(commandName); const command = commands.get(commandName);
if (!command) return; if (!command) return;
if (!command.enabled) return; if (!command.checkPerms) return;
if (command.requirements.developer && !isDeveloper(msg.userInfo.userId))
return;
if (command.requirements.mod && !msg.userInfo.isMod) return;
const timeLeft = checkCooldown(command); const timeLeft = checkCooldown(command);
if (timeLeft > 0) { if (timeLeft > 0) {
@@ -56,17 +53,13 @@ async function checkMessage(
await command.triggered(channel, user, text, msg); await command.triggered(channel, user, text, msg);
} }
function isDeveloper(userId: string): boolean {
return Config.developers.includes(userId);
}
function checkCooldown(command: ICommand): number { function checkCooldown(command: ICommand): number {
const now = Date.now(); const now = Date.now();
if (command.cooldown > 0) { if (command.cooldown > 0) {
const cooldownTime = Cooldowns.get(command.name); const cooldownTime = Cooldowns.get(command.name);
if (cooldownTime) { if (cooldownTime) {
if (cooldownTime < now) { if (cooldownTime < now) {
const timeLeft = 0; // TODO const timeLeft = 0; // TODO!!!
return timeLeft; return timeLeft;
} else { } else {
Cooldowns.set(command.name, now + command.cooldown * 1000); Cooldowns.set(command.name, now + command.cooldown * 1000);

View File

@@ -1,6 +1,7 @@
import { chatClient } from "../core/chat-client.ts"; import { chatClient } from "../core/chat-client.ts";
import ConnectedEvent from "./event-connected.ts"; import { logInfo } from "../util/logger.ts";
import MessageEvent from "./event-message.ts"; import ConnectedEvent from "./impl/connected.ts";
import MessageEvent from "./impl/message.ts";
import type { IEvent } from "./interface.ts"; import type { IEvent } from "./interface.ts";
export const eventRegistry = { export const eventRegistry = {
@@ -13,6 +14,7 @@ export function registerAllEvents() {
for (const event of events) { for (const event of events) {
eventRegistry[event.name](event); // Registers the event eventRegistry[event.name](event); // Registers the event
logInfo(`event ${event.name} registered`);
} }
} }

View File

@@ -1,29 +1,21 @@
import { exchangeCode } from "@twurple/auth"; import { exchangeCode } from "@twurple/auth";
import { Config } from "./config/config"; import { Config } from "./config/config.ts";
import { TokenManager } from "./core/token-manager"; import { TokenManager } from "./core/token-manager.ts";
import { TwitchAuth } from "./util/auth"; import { TwitchAuth } from "./util/auth.ts";
import { getUserId, promptForInput } from "./util/general"; import { logInfo } from "./util/logger.ts";
import { logError, logInfo } from "./util/logger";
const port = 3000; const port = 3000;
const redirectUri = `http://localhost:${port}`; const redirectUri = `http://localhost:${port}`;
const scopes = ["chat:read", "chat:edit", "channel:moderate"]; 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 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("To authorize your Twitch bot, visit this URL:");
logInfo(authUrl); 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( tokenManager.createTokenFile(
await exchangeCode(Config.client_id, Config.client_secret, code, redirectUri), await exchangeCode(Config.client_id, Config.client_secret, code, redirectUri),

View File

@@ -1,5 +1,5 @@
import { getUserId, promptForInput } from "./util/general.ts"; 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 botname = await promptForInput("enter bot username: ");
const developers = ( const developers = (
@@ -13,7 +13,7 @@ if (!botId) {
} }
const developerIds = []; const developerIds = [];
for (const dev in developers) { for (const dev of developers) {
const devId = await getUserId(dev); const devId = await getUserId(dev);
if (devId) { if (devId) {
developerIds.push(devId); developerIds.push(devId);
@@ -22,7 +22,7 @@ for (const dev in developers) {
} }
} }
logInfo(`Userid of bot '${botname}' => '${botId}'`); logSuccess(`Userid of bot '${botname}' => '${botId}'`);
logInfo( logSuccess(
`Userids of developers '${developers.join(",")}' => '${developerIds.join(",")}'`, `Userids of developers '${developers.join(",")}' => '${developerIds.join(",")}'`,
); );

View File

@@ -1,3 +1,4 @@
import * as crypto from "node:crypto";
import { import {
createServer, createServer,
type IncomingMessage, type IncomingMessage,
@@ -5,6 +6,7 @@ import {
} from "node:http"; } from "node:http";
import { URL } from "node:url"; import { URL } from "node:url";
import { Config } from "../config/config.ts"; import { Config } from "../config/config.ts";
import { logError, logInfo } from "./logger.ts";
export class TwitchAuth { export class TwitchAuth {
private readonly redirectUri: string; private readonly redirectUri: string;
@@ -13,20 +15,29 @@ export class TwitchAuth {
this.redirectUri = redirectUri; this.redirectUri = redirectUri;
} }
getAuthorizationUrl(scopes: string[]): string { getAuthorizationUrl(scopes: string[], state: string): string {
const baseUrl = "https://id.twitch.tv/oauth2/authorize"; const baseUrl = "https://id.twitch.tv/oauth2/authorize";
const params = new URLSearchParams({ const params = new URLSearchParams({
response_type: "code", response_type: "code",
client_id: Config.client_id, client_id: Config.client_id,
redirect_uri: this.redirectUri, redirect_uri: this.redirectUri,
scope: scopes.join(" "), scope: scopes.join(" "),
state: state,
}); });
return `${baseUrl}?${params.toString()}`; return `${baseUrl}?${params.toString()}`;
} }
startCallbackServer(port: number, timeoutS: number = 120): Promise<string> { generateState(): string {
return new Promise((resolve, reject) => { return crypto.randomBytes(20).toString("hex");
}
startCallbackServer(
port: number,
timeoutS: number = 120,
state: string,
): Promise<string> {
return new Promise((resolve) => {
const server = createServer( const server = createServer(
(req: IncomingMessage, res: ServerResponse) => { (req: IncomingMessage, res: ServerResponse) => {
if (!req.url) { if (!req.url) {
@@ -38,8 +49,15 @@ export class TwitchAuth {
const url = new URL(req.url, `http://localhost:${port}`); const url = new URL(req.url, `http://localhost:${port}`);
const code = url.searchParams.get("code"); const code = url.searchParams.get("code");
const error = url.searchParams.get("error"); const error = url.searchParams.get("error");
const responseState = url.searchParams.get("state");
const errorDescription = url.searchParams.get("error_description"); const errorDescription = url.searchParams.get("error_description");
if (state !== responseState) {
res.writeHead(400, { "Content-Type": "text/html" });
res.end("<h1>Wrong state</h1><p>Invalid state param</p>");
return;
}
if (error) { if (error) {
res.writeHead(400, { "Content-Type": "text/html" }); res.writeHead(400, { "Content-Type": "text/html" });
res.end(` res.end(`
@@ -49,10 +67,8 @@ export class TwitchAuth {
<p>You can close this window.</p> <p>You can close this window.</p>
`); `);
server.close(); server.close();
reject( logError(`authorization failed: ${error} - ${errorDescription}`);
new Error(`Authorization failed: ${error} - ${errorDescription}`), throw new Error();
);
return;
} }
if (code) { if (code) {
@@ -73,18 +89,20 @@ export class TwitchAuth {
); );
server.listen(port, "localhost", () => { server.listen(port, "localhost", () => {
console.log( logInfo(
`Waiting for authorization callback on http://localhost:${port}`, `waiting for authorization callback on http://localhost:${port}`,
); );
}); });
server.on("error", (err) => { server.on("error", (err) => {
reject(new Error(`Server error: ${err.message}`)); logError(`server error: ${err.message}`);
throw new Error();
}); });
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
server.close(); server.close();
reject(new Error("Authorization timeout. Please try again.")); logError("authorization timeout");
throw new Error();
}, timeoutS * 1000); }, timeoutS * 1000);
server.on("close", () => { server.on("close", () => {

View File

@@ -1,14 +1,14 @@
import * as readline from "node:readline"; import * as readline from "node:readline";
import { apiClient } from "../core/api-client.ts"; 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<string | null> { export async function getUserId(username: string): Promise<string | null> {
const user = await apiClient.users.getUserByName(username); const user = await apiClient.users.getUserByName(username);
if (user) { if (user?.id) {
logInfo(`${user.name} => ${user.id}`); logSuccess(`${user.name} => ${user.id}`);
return user.id; return user.id;
} }
logError(`no user with name ${username} found`); logWarning(`no user with name ${username} found`);
return null; return null;
} }

View File

@@ -1,7 +1,5 @@
import chalk from "chalk"; import chalk from "chalk";
const verbose = process.argv.includes("--verbose");
export function logError(...args: unknown[]) { export function logError(...args: unknown[]) {
console.log(chalk.red(args)); console.log(chalk.red(args));
} }
@@ -15,7 +13,5 @@ export function logSuccess(...args: unknown[]) {
} }
export function logInfo(...args: unknown[]) { export function logInfo(...args: unknown[]) {
if (verbose) { console.log(chalk.cyan(args));
console.log(chalk.cyan(args));
}
} }