add easy setup

This commit is contained in:
Darius
2025-09-26 19:05:49 +02:00
parent 95693c0201
commit a0a76a8b75
18 changed files with 313 additions and 54 deletions

35
package-lock.json generated
View File

@@ -10,6 +10,7 @@
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@discordjs/collection": "^2.1.1", "@discordjs/collection": "^2.1.1",
"@twurple/api": "^7.4.0",
"@twurple/auth": "^7.4.0", "@twurple/auth": "^7.4.0",
"@twurple/chat": "^7.4.0", "@twurple/chat": "^7.4.0",
"chalk": "^5.6.2", "chalk": "^5.6.2",
@@ -277,6 +278,31 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@twurple/api-call": {
"version": "7.4.0", "version": "7.4.0",
"resolved": "https://registry.npmjs.org/@twurple/api-call/-/api-call-7.4.0.tgz", "resolved": "https://registry.npmjs.org/@twurple/api-call/-/api-call-7.4.0.tgz",
@@ -1025,6 +1051,15 @@
"node": ">=8.10.0" "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": { "node_modules/rimraf": {
"version": "6.0.1", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.0.1.tgz", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.0.1.tgz",

View File

@@ -23,6 +23,7 @@
}, },
"dependencies": { "dependencies": {
"@discordjs/collection": "^2.1.1", "@discordjs/collection": "^2.1.1",
"@twurple/api": "^7.4.0",
"@twurple/auth": "^7.4.0", "@twurple/auth": "^7.4.0",
"@twurple/chat": "^7.4.0", "@twurple/chat": "^7.4.0",
"chalk": "^5.6.2", "chalk": "^5.6.2",

View File

@@ -1,5 +1,5 @@
import { client } from "./core/client.ts"; import { chatClient } from "./core/chat-client.ts";
import { registerAllEvents } from "./events/registry.ts"; import { registerAllEvents } from "./events/registry.ts";
registerAllEvents(client); registerAllEvents();
client.connect(); chatClient.connect();

View File

@@ -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<unknown>;
abstract requirements: ICommandRequirements;
}

View File

@@ -1,8 +1,9 @@
import type { ChatMessage } from "@twurple/chat"; import type { ChatMessage } from "@twurple/chat";
import { logSuccess } from "../logger/logger.ts"; import { logSuccess } from "../util/logger.ts";
import type { ICommand, ICommandRequirements } from "./interface.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"; name = "song";
cooldown = 0; cooldown = 0;
enabled = true; enabled = true;

View File

@@ -7,6 +7,6 @@ export const Config = {
bot_user_id: process.env.BOT_USER_ID || "", bot_user_id: process.env.BOT_USER_ID || "",
client_id: process.env.CLIENT_ID || "", client_id: process.env.CLIENT_ID || "",
client_secret: process.env.CLIENT_SECRET || "", client_secret: process.env.CLIENT_SECRET || "",
channels: [process.env.CHANNELS || ""], channels: process.env.CHANNELS?.split(",") || [],
developers: [process.env.DEVELOPERS || ""], developers: process.env.DEVELOPERS?.split(",") || [],
}; };

10
src/core/api-client.ts Normal file
View File

@@ -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 });

29
src/core/chat-client.ts Normal file
View File

@@ -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,
});

View File

@@ -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,
});

32
src/core/token-manager.ts Normal file
View File

@@ -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<AccessToken | null> {
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<void> {
this.createTokenFile(token);
}
private async createTokenFile(tokenData: AccessToken): Promise<void> {
const formattedTokens = JSON.stringify(tokenData, null, 4);
await fs.writeFile(this.tokenFilePath, formattedTokens, "utf-8");
}
}

12
src/events/base-event.ts Normal file
View File

@@ -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<unknown>;
}

View File

@@ -1,8 +1,8 @@
import { logSuccess } from "../logger/logger.ts"; import { logSuccess } from "../util/logger.ts";
import type { IEvent } from "./interface.ts"; import { BaseEvent } from "./base-event.ts";
import type { EventName } from "./registry.ts"; import type { EventName } from "./registry.ts";
export default class ConnectedEvent implements IEvent { export default class ConnectedEvent extends BaseEvent {
name: EventName = "connected"; name: EventName = "connected";
triggered = async () => { triggered = async () => {

View File

@@ -3,12 +3,12 @@ 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 type { IEvent } from "./interface.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>();
export default class MessageEvent implements IEvent { export default class MessageEvent extends BaseEvent {
name: EventName = "message"; name: EventName = "message";
triggered = async ( triggered = async (
@@ -45,7 +45,7 @@ async function checkMessage(
const timeLeft = checkCooldown(command); const timeLeft = checkCooldown(command);
if (timeLeft > 0) { if (timeLeft > 0) {
// return client.say( // return chatClient.say(
// channel, // channel,
// `@${user}, you must wait ${timeLeft} more seconds to use the command again`, // `@${user}, you must wait ${timeLeft} more seconds to use the command again`,
// ); // );

View File

@@ -1,23 +1,18 @@
import type { ChatClient } from "@twurple/chat"; import { chatClient } from "../core/chat-client.ts";
import ConnectedEvent from "./event-connected.ts"; import ConnectedEvent from "./event-connected.ts";
import MessageEvent from "./event-message.ts"; import MessageEvent from "./event-message.ts";
import type { IEvent } from "./interface.ts"; import type { IEvent } from "./interface.ts";
export const eventRegistry = { export const eventRegistry = {
message: (client: ChatClient, handler: IEvent) => message: (handler: IEvent) => chatClient.onMessage(handler.triggered),
client.onMessage(handler.triggered), connected: (handler: IEvent) => chatClient.onConnect(handler.triggered),
connected: (client: ChatClient, handler: IEvent) =>
client.onConnect(handler.triggered),
}; };
export function registerAllEvents(client: ChatClient) { export function registerAllEvents() {
const events = [new MessageEvent(), new ConnectedEvent()]; const events = [new MessageEvent(), new ConnectedEvent()];
for (const event of events) { for (const event of events) {
const registerFn = eventRegistry[event.name]; eventRegistry[event.name](event); // Registers the event
if (registerFn) {
registerFn(client, event);
}
} }
} }

47
src/setup.ts Normal file
View File

@@ -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),
);

97
src/util/auth.ts Normal file
View File

@@ -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<string> {
return new Promise((resolve, reject) => {
const server = createServer(
(req: IncomingMessage, res: ServerResponse) => {
if (!req.url) {
res.writeHead(400, { "Content-Type": "text/html" });
res.end("<h1>Bad Request</h1><p>Invalid request URL</p>");
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(`
<h1>Authorization Failed</h1>
<p>Error: ${error}</p>
<p>Description: ${errorDescription || "Unknown error"}</p>
<p>You can close this window.</p>
`);
server.close();
reject(
new Error(`Authorization failed: ${error} - ${errorDescription}`),
);
return;
}
if (code) {
res.writeHead(200, { "Content-Type": "text/html" });
res.end(`
<h1>Authorization Successful!</h1>
<p>You have successfully authorized the Twitch bot.</p>
<p>You can close this window and return to your terminal.</p>
`);
server.close();
resolve(code);
return;
}
res.writeHead(400, { "Content-Type": "text/html" });
res.end("<h1>Bad Request</h1><p>No authorization code received</p>");
},
);
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);
});
});
}
}

12
src/util/general.ts Normal file
View File

@@ -0,0 +1,12 @@
import { apiClient } from "../core/api-client";
import { logError, logInfo } from "./logger";
export async function getUserId(username: string): Promise<string | null> {
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;
}

View File

@@ -4,6 +4,10 @@ export function logError(...args: unknown[]) {
console.log(chalk.red(args)); console.log(chalk.red(args));
} }
export function logWarning(...args: unknown[]) {
console.log(chalk.yellow(args));
}
export function logSuccess(...args: unknown[]) { export function logSuccess(...args: unknown[]) {
console.log(chalk.green(args)); console.log(chalk.green(args));
} }