17 Commits
Author SHA1 Message Date
Vladimir (unknowableshade) Eremin ae41f11bef Docker & Versions 2023-05-09 04:11:25 +03:00
Vladimir (unknowableshade) Eremin af57e4a25f Minor Changes 2023-05-09 02:49:11 +03:00
Vladimir (unknowableshade) Eremin 483bc4895d Pretty 2023-05-09 02:48:58 +03:00
Vladimir (unknowableshade) Eremin 615921a2d9 Dependencies 2023-05-09 02:48:17 +03:00
Vladimir (unknowableshade) Eremin 93f3bd1047 Server Module Changes 2023-05-08 23:52:21 +03:00
Vladimir (unknowableshade) Eremin c2f666c408 Music Improvements 2023-05-08 23:51:55 +03:00
Vladimir (unknowableshade) Eremin 3f55646bce Core Improvements 2023-05-08 23:51:37 +03:00
Vladimir (unknowableshade) Eremin 0fdc80aeab Configuration changes 2023-05-08 23:51:13 +03:00
Vladimir (unknowableshade) Eremin 0b451c2c4e Music v2.0.0 (early) 2023-05-07 18:35:13 +03:00
Vladimir (unknowableshade) Eremin bb9448c9c1 Core improvements 2023-05-07 18:34:20 +03:00
Vladimir (unknowableshade) Eremin d726ab6571 Logging, Migrations & Updates 2023-05-07 18:33:14 +03:00
Vladimir (unknowableshade) Eremin 85976917b7 Server Module + DB (very early) 2023-05-04 03:54:34 +03:00
Vladimir (unknowableshade) Eremin 1b218fa7c3 Dependencies & CI/CD 2023-04-01 16:07:55 +03:00
Vladimir (unknowableshade) Eremin 5fe2d9f99d Submit to node-semver 2023-03-30 16:33:06 +03:00
Vladimir (unknowableshade) Eremin 411c2ce262 Pnpm Migration 2023-03-30 16:16:11 +03:00
Vladimir (unknowableshade) Eremin 8e9ce884a6 CI/CD Building & Testing 2023-03-29 12:47:06 +03:00
Vladimir Eremin 151437e7a8 Merge pull request #4 from unknowableshade/laboratory
Laboratory collapse
2023-03-28 23:43:07 +03:00
58 changed files with 6300 additions and 5683 deletions

No files matched your search

+3 -1
View File
@@ -6,9 +6,11 @@
# Source # Source
!src/ !src/
!prisma/
# Dependencies # Dependencies
!package*.json !package.json
!pnpm-lock.yaml
# Typescript compiller config # Typescript compiller config
!tsconfig.json !tsconfig.json
+21 -21
View File
@@ -1,21 +1,21 @@
{ {
"root": true, "root": true,
"parser": "@typescript-eslint/parser", "parser": "@typescript-eslint/parser",
"parserOptions": { "parserOptions": {
"warnOnUnsupportedTypeScriptVersion": false, "warnOnUnsupportedTypeScriptVersion": false,
"sourceType": "module" "sourceType": "module"
}, },
"env": { "env": {
"browser": false, "browser": false,
"node": true, "node": true,
"es6": true "es6": true
}, },
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier"], "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier"],
"plugins": ["@typescript-eslint", "import", "simple-import-sort"], "plugins": ["@typescript-eslint", "import", "simple-import-sort"],
"ignorePatterns": ["**/node_modules/**", "/dist/**", "/tests/**"], "ignorePatterns": ["**/node_modules/**", "/dist/**", "/tests/**"],
"rules": { "rules": {
"curly": ["error", "all"], "curly": ["error", "all"],
"simple-import-sort/imports": "error", "simple-import-sort/imports": "error",
"simple-import-sort/exports": "error" "simple-import-sort/exports": "error"
} }
} }
@@ -1,51 +1,68 @@
# This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node name: actions
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs
on:
name: CI/CD push:
tags:
on: - "*"
push:
tags: jobs:
- "*" mocha:
runs-on: ubuntu-latest
jobs:
build: strategy:
runs-on: ubuntu-latest matrix:
node-version: [18.x]
strategy: pnpm-version: [8]
matrix:
node-version: [18.x] steps:
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/ - name: Check out the repo
uses: actions/checkout@v3
steps:
- uses: actions/checkout@v3 - uses: pnpm/action-setup@v2
- name: Use Node.js ${{ matrix.node-version }} with:
uses: actions/setup-node@v3 version: ${{ matrix.pnpm-version }}
with:
node-version: ${{ matrix.node-version }} - name: Use Node.js ${{ matrix.node-version }}
cache: "npm" uses: actions/setup-node@v3
- run: npm ci with:
- run: npm run build --if-present node-version: ${{ matrix.node-version }}
- name: Check out the repo - name: Install dependencies
uses: actions/checkout@v3 run: pnpm install
- name: Log in to Docker Hub - name: ESLint & Prettier
uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9 run: pnpm run lint
with:
username: ${{ secrets.DOCKER_USERNAME }} - name: Build
password: ${{ secrets.DOCKER_PASSWORD }} run: pnpm run build
- name: Extract metadata (tags, labels) for Docker - name: Test
id: meta run: pnpm run --if-present test
uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38
with: docker:
images: unknowableshade/quetza-bot runs-on: ubuntu-latest
needs: mocha
- name: Build and push Docker image
uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc steps:
with: - name: Check out the repo
context: . uses: actions/checkout@v3
push: true
tags: ${{ steps.meta.outputs.tags }} - name: Log in to Docker Hub
labels: ${{ steps.meta.outputs.labels }} uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38
with:
images: unknowableshade/quetza-bot
- name: Build and push Docker image
uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+23 -23
View File
@@ -1,23 +1,23 @@
{ {
"semi": true, "semi": true,
"trailingComma": "none", "trailingComma": "none",
"singleQuote": false, "singleQuote": false,
"printWidth": 100, "printWidth": 100,
"arrowParens": "always", "arrowParens": "always",
"bracketSpacing": true, "bracketSpacing": true,
"endOfLine": "crlf", "endOfLine": "crlf",
"overrides": [ "overrides": [
{ {
"files": ["**/*.json, **/*.yml"], "files": ["**/*.json, **/*.yml"],
"options": { "options": {
"tabWidth": 2 "tabWidth": 2
} }
}, },
{ {
"files": ["**/*.ts"], "files": ["**/*.ts"],
"options": { "options": {
"tabWidth": 4 "tabWidth": 4
} }
} }
] ]
} }
+30 -5
View File
@@ -1,3 +1,17 @@
FROM node:18-alpine as builder
RUN npm install -g pnpm
WORKDIR /usr/src/app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install
COPY . .
RUN pnpm run build
FROM node:18-alpine FROM node:18-alpine
RUN apk update RUN apk update
@@ -6,13 +20,24 @@ RUN apk add --no-cache curl
RUN apk add --no-cache ffmpeg RUN apk add --no-cache ffmpeg
RUN apk add --no-cache python3 RUN apk add --no-cache python3
RUN npm install -g pnpm
ENV NODE_ENV production
USER node
WORKDIR /usr/src/app WORKDIR /usr/src/app
RUN mkdir bin RUN mkdir bin
RUN curl -L https://yt-dl.org/downloads/latest/youtube-dl -o /usr/src/app/bin/youtube-dl RUN curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /usr/src/app/bin/yt-dlp
RUN chmod a+rx /usr/src/app/bin/youtube-dl RUN chmod a+rx /usr/src/app/bin/yt-dlp
COPY . . COPY prisma/ .
RUN npm ci COPY package.json pnpm-lock.yaml ./
CMD [ "npm", "start" ] RUN pnpm install --prod
RUN pnpx prisma generate
COPY --from=builder /usr/src/app/dist ./dist
CMD [ "pnpm", "start" ]
+16 -16
View File
@@ -1,16 +1,16 @@
# Quetza - 2302.1 # Quetza 4
_Neat typescript discord bot._ _Neat typescript discord bot._
## About ## About
- Base player features. - Base player features.
- Play music from YouTube and many other sites. - Play music from YouTube and many other sites.
- Lots of different filters _(nightcore, earrape etc.)_. - Lots of different filters _(nightcore, earrape etc.)_.
- Slash commands. - Slash commands.
## Links ## Links
- **[Dependencies](https://github.com/unknowableshade/quetza-bot/blob/master/package.json)** - **[Dependencies](https://github.com/unknowableshade/quetza-bot/blob/master/package.json)**
- **[Docker](https://hub.docker.com/repository/docker/unknowableshade/quetza-bot)** - **[Docker](https://hub.docker.com/repository/docker/unknowableshade/quetza-bot)**
- **[Licence](https://github.com/unknowableshade/5a0b-bot/blob/master/LICENSE)** - **[Licence](https://github.com/unknowableshade/5a0b-bot/blob/master/LICENSE)**
-3369
View File
File diff suppressed because it is too large. Load diff
+54 -50
View File
@@ -1,50 +1,54 @@
{ {
"name": "quetza-bot", "name": "quetza-bot",
"engines": { "engines": {
"node": "18.x.x", "node": "18.x.x",
"npm": "*" "npm": "*"
}, },
"type": "module", "type": "module",
"version": "2303.1", "version": "4.0.0",
"description": "Discord multifunctional bot", "description": "Discord multifunctional bot",
"author": { "author": {
"name": "unknowableshade", "name": "unknowableshade",
"email": "60275123+unknowableshade@users.noreply.github.com", "email": "60275123+unknowableshade@users.noreply.github.com",
"url": "https://unknowableshade.ru" "url": "https://unknowableshade.ru"
}, },
"repository": { "repository": {
"type": "git", "type": "git",
"url": "git+https://github.com/unknowableshade/quetza-bot.git" "url": "git+https://github.com/unknowableshade/quetza-bot.git"
}, },
"bugs": { "bugs": {
"url": "https://github.com/unknowableshade/quetza-bot/issues" "url": "https://github.com/unknowableshade/quetza-bot/issues"
}, },
"readme": "https://github.com/unknowableshade/quetza-bot#readme", "readme": "https://github.com/unknowableshade/quetza-bot#readme",
"license": "MIT", "license": "MIT",
"main": "dist/index.js", "main": "dist/index.js",
"scripts": { "scripts": {
"start": "node dist/index.js", "start": "node dist/index.js",
"build": "npx tsc --build", "build": "pnpm exec tsc --build",
"clean": "npx tsc --build --clean", "clean": "pnpm exec tsc --build --clean",
"lint": "npx eslint --fix . && npx prettier --write .", "lint": "pnpm exec eslint --fix . && pnpm exec prettier --write .",
"postinstall": "npm run build" "preinstall": "pnpx only-allow pnpm"
}, },
"dependencies": { "dependencies": {
"@discordjs/opus": "^0.8.0", "@discordjs/opus": "^0.9.0",
"@discordjs/voice": "^0.11.0", "@discordjs/voice": "^0.16.0",
"discord-ytdl-core": "^5.0.4", "@prisma/client": "4.13.0",
"discord.js": "^14.3.0", "discord-ytdl-core": "^5.0.4",
"tweetnacl": "^1.0.3", "discord.js": "^14.11.0",
"ytdl-core": "^4.11.1" "tweetnacl": "^1.0.3",
}, "winston": "^3.8.2",
"devDependencies": { "ytdl-core": "^4.11.4"
"@types/node": "^18.7.13", },
"@typescript-eslint/eslint-plugin": "^5.35.1", "devDependencies": {
"@typescript-eslint/parser": "^5.35.1", "@types/node": "^18.16.6",
"eslint": "^8.22.0", "@typescript-eslint/eslint-plugin": "^5.59.5",
"eslint-config-prettier": "^8.6.0", "@typescript-eslint/parser": "^5.59.5",
"eslint-plugin-import": "^2.27.5", "eslint": "^8.40.0",
"eslint-plugin-simple-import-sort": "^10.0.0", "eslint-config-prettier": "^8.8.0",
"typescript": "^4.8.2" "eslint-plugin-import": "^2.27.5",
} "eslint-plugin-simple-import-sort": "^10.0.0",
} "prettier": "^2.8.8",
"prisma": "^4.13.0",
"typescript": "^4.9.5"
}
}
+3218
View File
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,22 @@
-- CreateTable
CREATE TABLE "Guild" (
"id" TEXT NOT NULL,
CONSTRAINT "Guild_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Event" (
"objectId" SERIAL NOT NULL,
"title" TEXT NOT NULL,
"Roles" TEXT[],
"guildId" TEXT NOT NULL,
CONSTRAINT "Event_pkey" PRIMARY KEY ("objectId")
);
-- CreateIndex
CREATE UNIQUE INDEX "Event_guildId_title_key" ON "Event"("guildId", "title");
-- AddForeignKey
ALTER TABLE "Event" ADD CONSTRAINT "Event_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,77 @@
/*
Warnings:
- The primary key for the `Event` table will be changed. If it partially fails, the table could be left without primary key constraint.
- You are about to drop the column `Roles` on the `Event` table. All the data in the column will be lost.
- You are about to drop the column `objectId` on the `Event` table. All the data in the column will be lost.
- A unique constraint covering the columns `[title,action,guildId]` on the table `Event` will be added. If there are existing duplicate values, this will fail.
- Added the required column `action` to the `Event` table without a default value. This is not possible if the table is not empty.
- Added the required column `metadata` to the `Event` table without a default value. This is not possible if the table is not empty.
*/
-- CreateEnum
CREATE TYPE "PenaltyType" AS ENUM ('BAN', 'TIMEOUT', 'WARN');
-- DropIndex
DROP INDEX "Event_guildId_title_key";
-- AlterTable
ALTER TABLE "Event" DROP CONSTRAINT "Event_pkey",
DROP COLUMN "Roles",
DROP COLUMN "objectId",
ADD COLUMN "action" TEXT NOT NULL,
ADD COLUMN "metadata" JSONB NOT NULL;
-- CreateTable
CREATE TABLE "Queue" (
"title" TEXT NOT NULL,
"description" TEXT,
"updatedAt" TIMESTAMP(3) NOT NULL,
"Tracks" JSONB NOT NULL,
"userId" TEXT NOT NULL,
"authorId" TEXT NOT NULL,
"guildId" TEXT NOT NULL
);
-- CreateTable
CREATE TABLE "Member" (
"userId" TEXT NOT NULL,
"level" INTEGER NOT NULL DEFAULT 0,
"guildId" TEXT NOT NULL
);
-- CreateTable
CREATE TABLE "Penalty" (
"userId" TEXT NOT NULL,
"type" "PenaltyType" NOT NULL,
"reason" TEXT,
"expires" TIMESTAMP(3),
"guildId" TEXT NOT NULL
);
-- CreateIndex
CREATE UNIQUE INDEX "Queue_title_guildId_key" ON "Queue"("title", "guildId");
-- CreateIndex
CREATE UNIQUE INDEX "Member_userId_guildId_key" ON "Member"("userId", "guildId");
-- CreateIndex
CREATE UNIQUE INDEX "Penalty_type_userId_guildId_key" ON "Penalty"("type", "userId", "guildId");
-- CreateIndex
CREATE UNIQUE INDEX "Event_title_action_guildId_key" ON "Event"("title", "action", "guildId");
-- AddForeignKey
ALTER TABLE "Queue" ADD CONSTRAINT "Queue_userId_guildId_fkey" FOREIGN KEY ("userId", "guildId") REFERENCES "Member"("userId", "guildId") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Queue" ADD CONSTRAINT "Queue_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Member" ADD CONSTRAINT "Member_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Penalty" ADD CONSTRAINT "Penalty_userId_guildId_fkey" FOREIGN KEY ("userId", "guildId") REFERENCES "Member"("userId", "guildId") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Penalty" ADD CONSTRAINT "Penalty_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+3
View File
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"
+68
View File
@@ -0,0 +1,68 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Guild {
id String @id
events Event[]
members Member[]
penalties Penalty[]
queues Queue[]
}
model Queue {
title String
description String?
updatedAt DateTime @updatedAt
Tracks Json
Member Member @relation(fields: [userId, guildId], references: [userId, guildId])
userId String
authorId String
Guild Guild @relation(fields: [guildId], references: [id])
guildId String
@@unique([title, guildId], name: "queueIdentifier")
}
model Member {
userId String
level Int @default(0)
queues Queue[]
Guild Guild @relation(fields: [guildId], references: [id])
guildId String
penalties Penalty[]
@@unique([userId, guildId], name: "memberIdentifier")
}
enum PenaltyType {
BAN
TIMEOUT
WARN
}
model Penalty {
Member Member @relation(fields: [userId, guildId], references: [userId, guildId])
userId String
type PenaltyType
reason String?
expires DateTime?
Guild Guild @relation(fields: [guildId], references: [id])
guildId String
@@unique([type, userId, guildId], name: "penaltyIdentifier")
}
model Event {
title String
action String
metadata Json
Guild Guild @relation(fields: [guildId], references: [id])
guildId String
@@unique([title, action, guildId], name: "eventIdentifier")
}
+26 -12
View File
@@ -1,12 +1,26 @@
import path from "path"; import { ActivityOptions, ActivityType, ColorResolvable } from "discord.js";
import { fileURLToPath } from "url"; import path from "path";
import { fileURLToPath } from "url";
const ROOT = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.dirname(fileURLToPath(import.meta.url));
const config = { const discordToken = process.env.DISCORD_TOKEN;
discordToken: process.env.DISCORD_TOKEN, const modulesDir = path.join(ROOT, "/modules/");
modulesDir: path.join(ROOT, "/modules/"), const binariesDir = path.join(ROOT, "/../bin/");
binariesDir: path.join(ROOT, "/../bin/") const activityStatus: ActivityOptions = { type: ActivityType.Watching, name: "over you" };
}; const colors = {
default: "#ff6e9b" as ColorResolvable,
export default config; error: "#b94e59" as ColorResolvable,
warning: "#e5925f" as ColorResolvable,
info: "#367df6" as ColorResolvable,
success: "#83d18e" as ColorResolvable
};
const config = {
discordToken,
modulesDir,
binariesDir,
activityStatus,
colors
};
export default config;
+14 -10
View File
@@ -1,10 +1,14 @@
import { GatewayIntentBits } from "discord.js"; import { GatewayIntentBits } from "discord.js";
import config from "./config.js"; import config from "./config.js";
import Client from "./lib/client.js"; import Client from "./lib/client.js";
const client = new Client({ const client = new Client({
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildVoiceStates] intents: [
}); GatewayIntentBits.Guilds,
GatewayIntentBits.GuildVoiceStates,
client.login(config.discordToken); GatewayIntentBits.GuildMembers
]
});
client.login(config.discordToken);
+88 -85
View File
@@ -1,85 +1,88 @@
import { Client as DiscordClient, ClientOptions, Collection } from "discord.js"; import { PrismaClient } from "@prisma/client";
import { existsSync, lstatSync, readdirSync } from "fs"; import { Client as DiscordClient, ClientOptions, Collection } from "discord.js";
import path from "path"; import { existsSync, lstatSync, readdirSync } from "fs";
import { pathToFileURL } from "url"; import path from "path";
import { pathToFileURL } from "url";
import config from "../config.js";
import { Command, Event, Module, ModuleMetadata } from "./types.js"; import config from "../config.js";
import { Command, Event, Module, ModuleMetadata } from "./types.js";
const MODULE_ENTRY = "module.js";
const MODULE_COMMANDS = "commands"; const MODULE_ENTRY = "module.js";
const MODULE_EVENTS = "events"; const MODULE_COMMANDS = "commands";
const MODULE_EVENTS = "events";
export default class Client extends DiscordClient {
public readonly commands = new Collection<string, Command>(); export default class Client extends DiscordClient {
public readonly commands = new Collection<string, Command>();
public readonly modules = new Collection<string, ModuleMetadata>();
public readonly modules = new Collection<string, ModuleMetadata>();
public constructor(options: ClientOptions) {
super(options); public readonly db = new PrismaClient();
for (const module of readdirSync(config.modulesDir)) { public constructor(options: ClientOptions) {
const modulePath = path.join(config.modulesDir, module); super(options);
if (!lstatSync(modulePath).isDirectory()) { for (const module of readdirSync(config.modulesDir)) {
continue; const modulePath = path.join(config.modulesDir, module);
}
if (!lstatSync(modulePath).isDirectory()) {
import(pathToFileURL(path.join(modulePath, MODULE_ENTRY)).toString()).then( continue;
(data: Module) => this.loadModule(data) }
);
} import(pathToFileURL(path.join(modulePath, MODULE_ENTRY)).toString()).then(
} (data: Module) => this.loadModule(data)
);
private loadModule(data: Module): void { }
const commands: string[] = []; }
const events: string[] = [];
private loadModule(data: Module): void {
function importModule(dir: string, importThen: (data: Command & Event) => void) { const commands: string[] = [];
if (existsSync(dir)) { const events: string[] = [];
for (const source of readdirSync(dir)) {
const file = path.join(dir, source); function importModule(dir: string, importThen: (data: Command & Event) => void) {
if (existsSync(dir)) {
if (!lstatSync(file).isFile() || !file.endsWith(".js")) { for (const source of readdirSync(dir)) {
continue; const file = path.join(dir, source);
}
if (!lstatSync(file).isFile() || !file.endsWith(".js")) {
import(pathToFileURL(file).toString()).then(importThen); continue;
} }
}
} import(pathToFileURL(file).toString()).then(importThen);
}
importModule(path.join(data.rootDir, MODULE_COMMANDS), (command: Command) => { }
this.commands.set(command.data.name, command) }
commands.push(command.data.name);
}); importModule(path.join(data.rootDir, MODULE_COMMANDS), (command: Command) => {
this.commands.set(command.data.name, command);
importModule(path.join(data.rootDir, MODULE_EVENTS), (event: Event) => { commands.push(command.data.name);
this.on(event.name, (...eventee: unknown[]) => event.execute(this, eventee)); });
events.push(event.name);
}); importModule(path.join(data.rootDir, MODULE_EVENTS), (event: Event) => {
this.on(event.name, (...eventee: unknown[]) => event.execute(this, eventee));
this.modules.set(data.name, { events.push(event.name);
...data, });
commands,
events this.modules.set(data.name, {
}) ...data,
} commands,
events
public generateApplicationStatus(): string { });
const modulesStatus = this.modules.map((module) => { }
const tag = `✓ ${module.name}@${module.tag} module is loaded.`
const commands = module.commands.map((name) => " - " + name).join("\n"); public generateApplicationStatus(): string[] {
const events = module.events.map((name) => " - " + name).join("\n"); const modulesStatus = this.modules.map((module) => {
const tag = `✓ ${module.name}@${module.tag} module is loaded.`;
return [`${tag}\n`, "commands:", `${commands}`, "events:", `${events}`].join("\n"); const commands = module.commands.map((name) => " - " + name).join("\n");
}).join("\n" + "-".repeat(50) + "\n"); const events = module.events.map((name) => " - " + name).join("\n");
const clientStatus = return [`${tag}\n`, "commands:", `${commands}`, "events:", `${events}`].join("\n");
this.user && this.application });
? `✓ ${this.application.id} successfully logged in as ${this.user.tag}.`
: "✖ Application is not availiable."; const clientStatus =
this.user && this.application
return ["-".repeat(50), modulesStatus, "-".repeat(50), clientStatus].join("\n"); ? `✓ ${this.application.id} successfully logged in as ${this.user.tag}.`
} : "✖ Application is not availiable.";
}
return modulesStatus.concat(clientStatus);
}
}
+20
View File
@@ -0,0 +1,20 @@
import winston from "winston";
const logger = winston.createLogger({
level: "info",
format: winston.format.combine(winston.format.timestamp(), winston.format.json()),
transports: [
new winston.transports.File({ filename: "logs/error.log", level: "error" }),
new winston.transports.File({ filename: "logs/combined.log" })
]
});
if (process.env.NODE_ENV !== "production") {
logger.add(
new winston.transports.Console({
format: winston.format.simple()
})
);
}
export default logger;
+27 -27
View File
@@ -1,27 +1,27 @@
import { ApplicationCommandData, CommandInteraction } from "discord.js"; import { ApplicationCommandData, CommandInteraction } from "discord.js";
import Client from "./client.js"; import Client from "./client.js";
export interface Module { export interface Module {
name: string; name: string;
description?: string; description?: string;
author: string; author: string;
tag: string; tag: string;
controller?: unknown; controller?: unknown;
rootDir: string; rootDir: string;
} }
export interface ModuleMetadata extends Module { export interface ModuleMetadata extends Module {
commands: string[]; commands: string[];
events: string[]; events: string[];
} }
export interface Command { export interface Command {
data: ApplicationCommandData; data: ApplicationCommandData;
execute: (client: Client, interaction: CommandInteraction) => Promise<void>; execute: (client: Client, interaction: CommandInteraction) => Promise<void>;
} }
export interface Event { export interface Event {
name: string; name: string;
execute: (client: Client, eventee: unknown[]) => Promise<void>; execute: (client: Client, eventee: unknown[]) => Promise<void>;
} }
+21 -14
View File
@@ -1,14 +1,21 @@
import { CommandInteraction, SlashCommandBuilder } from "discord.js"; import { Interaction, SlashCommandBuilder } from "discord.js";
import Client from "../../../lib/client.js"; import Client from "../../../lib/client.js";
import I18n from "../lib/i18n.js"; import logger from "../../../lib/logger.js";
import replies from "../lib/replies.js";
async function execute(client: Client, interaction: CommandInteraction): Promise<void> {
await interaction.reply(I18n.replies.modules(client.modules)); async function execute(client: Client, interaction: Interaction): Promise<void> {
} if (!interaction.isChatInputCommand()) {
logger.warn("Interaction rejected.", { interaction });
const data = new SlashCommandBuilder()
.setName("modules") return;
.setDescription("List of availiable modules."); }
export { data, execute }; await interaction.reply(replies.modules(client.modules));
}
const data = new SlashCommandBuilder()
.setName("modules")
.setDescription("List of availiable modules.");
export { data, execute };
+23 -21
View File
@@ -1,21 +1,23 @@
import { CommandInteraction, SlashCommandBuilder } from "discord.js"; import { Interaction, SlashCommandBuilder } from "discord.js";
import Client from "../../../lib/client.js"; import Client from "../../../lib/client.js";
import I18n from "../lib/i18n.js"; import logger from "../../../lib/logger.js";
import replies from "../lib/replies.js";
async function execute(client: Client, interaction: CommandInteraction): Promise<void> {
const start = Date.now(); async function execute(client: Client, interaction: Interaction): Promise<void> {
if (!interaction.isChatInputCommand()) {
if (!client.application) { logger.warn("Interaction rejected.", { interaction });
await interaction.reply(I18n.replies.ping());
return; return;
} }
await client.application.fetch(); const reply = await interaction.reply({ ...replies.ping(client.ws.ping), fetchReply: true });
await interaction.reply(I18n.replies.ping(Date.now() - start)); await interaction.editReply(
} replies.ping(client.ws.ping, reply.createdTimestamp - interaction.createdTimestamp)
);
const data = new SlashCommandBuilder().setName("ping").setDescription("Try to ping me."); }
export { data, execute }; const data = new SlashCommandBuilder().setName("ping").setDescription("Try to ping me.");
export { data, execute };
+22 -19
View File
@@ -1,19 +1,22 @@
import { Interaction } from "discord.js"; import { Events, Interaction } from "discord.js";
import Client from "../../../lib/client.js"; import Client from "../../../lib/client.js";
import logger from "../../../lib/logger.js";
async function execute(client: Client, eventee: Interaction[]): Promise<void> {
const [interaction] = eventee; async function execute(client: Client, eventee: [Interaction]): Promise<void> {
const [interaction] = eventee;
if (interaction.isCommand()) {
const command = client.commands.get(interaction.commandName); logger.info("Interaction created.", interaction);
if (command) { if (interaction.isCommand()) {
command.execute(client, interaction); const command = client.commands.get(interaction.commandName);
}
} if (command) {
} command.execute(client, interaction);
}
const name = "interactionCreate"; }
}
export { execute, name };
const name = Events.InteractionCreate;
export { execute, name };
+37 -23
View File
@@ -1,23 +1,37 @@
import { generateDependencyReport } from "@discordjs/voice"; import { generateDependencyReport } from "@discordjs/voice";
import { ActivityType } from "discord.js"; import { Events } from "discord.js";
import Client from "../../../lib/client.js"; import config from "../../../config.js";
import Client from "../../../lib/client.js";
async function execute(client: Client): Promise<void> { import logger from "../../../lib/logger.js";
if (!client.user || !client.application) {
throw new Error("No application or user for it is provided."); async function execute(client: Client): Promise<void> {
} if (!client.user || !client.application) {
throw new Error("No application or user for it is provided.");
console.log(generateDependencyReport()); }
console.log(client.generateApplicationStatus());
const commandData = Array.from(client.commands.values()).map((value) => value.data);
client.user.setActivity("over you", { type: ActivityType.Watching });
if (process.env.NODE_ENV === "development" && process.env.TEST_GUILD) {
const restCommands = Array.from(client.commands.values()).map((value) => value.data); logger.info("Development mode: Commands will be pushed to the test guild!");
client.application.commands.set(restCommands); logger.info(generateDependencyReport());
}
const guild = await client.guilds.fetch(process.env.TEST_GUILD);
const name = "ready"; guild.commands.set(commandData);
}
export { execute, name };
if (process.env.NODE_ENV === "production") {
logger.info("Production mode: Commands will be pushed to the application!");
await client.application.commands.set(commandData);
}
client.generateApplicationStatus().forEach((report) => logger.info(report));
client.user.setActivity(config.activityStatus);
}
const name = Events.ClientReady;
export { execute, name };
-41
View File
@@ -1,41 +0,0 @@
import { bold, Collection, EmbedBuilder, InteractionReplyOptions, italic } from "discord.js";
import { ModuleMetadata } from "../../../lib/types.js";
export default class I18n {
public static readonly replies = {
modules: (modules: Collection<string, ModuleMetadata>): InteractionReplyOptions => {
const embed = new EmbedBuilder()
.setColor("Random")
.setTitle("Modules")
.addFields(
modules.map((value) => {
return {
name: value.name + "@" + value.tag,
value: (value.description ?? "") + " | " + value.author,
inline: true
};
})
);
return { embeds: [embed] };
},
ping: (ping?: number): InteractionReplyOptions => {
const title = ping ? `I have pinged ${ping} ms late.` : "There is no application.";
const comment = !ping
? "How am I speaking then?"
: ping > 250
? "I'm kinda slowpoke ngl."
: ping > 200
? "I can do better than this."
: "Dream's speed!";
const embed = new EmbedBuilder()
.setColor("Random")
.setTitle(bold(title))
.setDescription(italic(bold(comment)));
return { embeds: [embed] };
}
};
}
+36
View File
@@ -0,0 +1,36 @@
import { BaseMessageOptions, Collection, EmbedBuilder, italic } from "discord.js";
import config from "../../../config.js";
import { ModuleMetadata } from "../../../lib/types.js";
const replies = {
modules: (modules: Collection<string, ModuleMetadata>): BaseMessageOptions => {
const embed = new EmbedBuilder()
.setColor(config.colors.default)
.setTitle("Applied modules")
.addFields(
modules.map((value) => {
return {
name: value.name + "@" + value.tag,
value: italic(value.description ?? "")
};
})
);
return { embeds: [embed] };
},
ping: (websocket: number, roundtrip?: number): BaseMessageOptions => {
const title = roundtrip ? `I've pinged ${roundtrip} ms. late` : "Pinging...";
const comment = `Websocket ping is ${websocket} ms.`;
const embed = new EmbedBuilder()
.setColor(config.colors.default)
.setTitle(title)
.setDescription(italic(comment));
return { embeds: [embed] };
}
};
export default replies;
+11 -11
View File
@@ -1,11 +1,11 @@
import path from "path"; import path from "path";
import { fileURLToPath } from "url"; import { fileURLToPath } from "url";
const name = "core"; const name = "core";
const description = "Quetza core functionality"; const description = "Quetza core functionality";
const author = "unknowableshade"; const author = "unknowableshade";
const tag = "2302.1"; const tag = "4.2.0";
const rootDir = path.dirname(fileURLToPath(import.meta.url)); const rootDir = path.dirname(fileURLToPath(import.meta.url));
export { author, description, name, rootDir, tag }; export { author, description, name, rootDir, tag };
+35 -27
View File
@@ -1,27 +1,35 @@
import { CommandInteraction, SlashCommandBuilder, TextChannel } from "discord.js"; import { Interaction, SlashCommandBuilder } from "discord.js";
import Client from "../../../lib/client.js"; import Client from "../../../lib/client.js";
import I18n from "../lib/i18n.js"; import logger from "../../../lib/logger.js";
import { controller } from "../module.js"; import replies from "../lib/replies.js";
import { controller } from "../module.js";
async function execute(client: Client, interaction: CommandInteraction): Promise<void> {
if (!interaction.guild || !interaction.channel) { async function execute(client: Client, interaction: Interaction): Promise<void> {
return; if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) {
} logger.warn("Interaction rejected.", { interaction });
const player = controller.get(interaction.guild.id, interaction.channel as TextChannel); return;
}
if (!player) {
return; const player = controller.get(interaction.guild, interaction.channel);
}
if (!player) {
const amount = player.queue.tracks.length; await interaction.reply(replies.notExists());
player.clear(); return;
}
await interaction.reply({ embeds: [I18n.embeds.cleared(amount)] });
} const amount = player.queue.tracks.length;
const data = new SlashCommandBuilder().setName("clear").setDescription("Clear the player's queue."); player.clear();
export { data, execute }; await interaction.reply(replies.cleared(amount));
}
const data = new SlashCommandBuilder()
.setName("clear")
.setDescription("Clear the player's queue.")
.setDMPermission(false);
export { data, execute };
+43 -41
View File
@@ -1,41 +1,43 @@
import { CommandInteraction, GuildMember, SlashCommandBuilder, TextChannel } from "discord.js"; import { Interaction, SlashCommandBuilder } from "discord.js";
import Client from "../../../lib/client.js"; import Client from "../../../lib/client.js";
import I18n from "../lib/i18n.js"; import logger from "../../../lib/logger.js";
import { controller } from "../module.js"; import replies from "../lib/replies.js";
import { controller } from "../module.js";
async function execute(client: Client, interaction: CommandInteraction) {
if (!interaction.guild || !interaction.channel || !interaction.member) { async function execute(client: Client, interaction: Interaction): Promise<void> {
return; if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) {
} logger.warn("Interaction rejected.", { interaction });
await interaction.deferReply(); return;
}
const player =
controller.get(interaction.guild.id, interaction.channel as TextChannel) || await interaction.deferReply();
controller.set(interaction.guild, interaction.channel as TextChannel);
const player =
if (!player.connection) { controller.get(interaction.guild, interaction.channel) ??
const channel = (interaction.member as GuildMember).voice.channel; controller.set(interaction.guild, interaction.channel);
if (channel) {
player.connect(channel); const channel = interaction.member.voice.channel;
await interaction.editReply({ embeds: [I18n.embeds.okConnected(channel.name)] }); if (!channel) {
} else { controller.delete(interaction.guildId);
controller.delete(interaction.guild.id);
await interaction.editReply(replies.notConnected());
await interaction.editReply({ embeds: [I18n.embeds.notConnected()] });
return undefined; return;
} }
} else {
await interaction.editReply({ embeds: [I18n.embeds.wasConnected()] }); player.connect(channel);
}
await interaction.editReply(replies.okConnected(channel.name));
return player;
} return;
}
const data = new SlashCommandBuilder()
.setName("connect") const data = new SlashCommandBuilder()
.setDescription("Connect me to a voice channel."); .setName("connect")
.setDescription("Connect me explicitly to the voice channel.")
export { data, execute }; .setDMPermission(false);
export { data, execute };
+46 -129
View File
@@ -1,129 +1,46 @@
import { CommandInteraction, SlashCommandBuilder, TextChannel } from "discord.js"; import { Interaction, SlashCommandBuilder } from "discord.js";
import Client from "../../../lib/client.js"; import Client from "../../../lib/client.js";
import I18n from "../lib/i18n.js"; import logger from "../../../lib/logger.js";
import { controller } from "../module.js"; import Filter from "../lib/filter.js";
import replies from "../lib/replies.js";
async function execute(client: Client, interaction: CommandInteraction) { import { controller } from "../module.js";
const filter = interaction.options.get("filter")?.value as string;
async function execute(client: Client, interaction: Interaction) {
if (!interaction.guild || !interaction.channel) { if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) {
return; logger.warn("Interaction rejected.", { interaction });
}
return;
const player = controller.get(interaction.guild.id, interaction.channel as TextChannel); }
if (!player) { const filter = interaction.options.getString("filter") ?? undefined;
return;
} const player = controller.get(interaction.guild, interaction.channel);
player.setFilter(filter || undefined); if (!player) {
await interaction.reply(replies.notExists());
await interaction.reply({ embeds: [I18n.embeds.filtered(filter || undefined)] });
} return;
}
const data = new SlashCommandBuilder()
.setName("filter") const status = player.setFilter(filter);
.setDescription("Apply unique filters to the tracks.")
.addStringOption((option) => await interaction.reply(replies.filtered(filter, status));
option }
.setName("filter")
.setDescription("Filter to apply.") const data = new SlashCommandBuilder()
.setChoices( .setName("filter")
{ .setDescription("Apply unique filters to the playback.")
name: "Bassboost low", .addStringOption((option) =>
value: "bassboost_low" option
}, .setName("filter")
{ .setDescription("Filter to apply.")
name: "Bassboost", .setChoices(
value: "bassboost" ...Object.keys(Filter.filters).map((key) => {
}, return { name: key, value: key };
{ })
name: "Bassboost high", )
value: "bassboost_high" )
}, .setDMPermission(false);
{
name: "8D", export { data, execute };
value: "8D"
},
{
name: "Vaporwave",
value: "vaporwave"
},
{
name: "Nightcore",
value: "nightcore"
},
{
name: "Phaser",
value: "phaser"
},
{
name: "Tremolo",
value: "tremolo"
},
{
name: "Vibrato",
value: "vibrato"
},
{
name: "Reverse",
value: "reverse"
},
{
name: "Treble",
value: "treble"
},
{
name: "Normalizer",
value: "normalizer"
},
{
name: "Surround Sound",
value: "surrounding"
},
{
name: "Pulsator",
value: "pulsator"
},
{
name: "Subboost",
value: "subboost"
},
{
name: "Mono Audio",
value: "mono"
},
{
name: "Compressor",
value: "compressor"
},
{
name: "Expander",
value: "expander"
},
{
name: "Softlimiter",
value: "softlimiter"
},
{
name: "Chorus",
value: "chorus"
},
{
name: "Fadein",
value: "fadein"
},
{
name: "Dimming",
value: "dim"
},
{
name: "Earrape",
value: "earrape"
}
)
.setRequired(true)
);
export { data, execute };
+31 -23
View File
@@ -1,23 +1,31 @@
import { CommandInteraction, SlashCommandBuilder, TextChannel } from "discord.js"; import { Interaction, SlashCommandBuilder } from "discord.js";
import Client from "../../../lib/client.js"; import Client from "../../../lib/client.js";
import I18n from "../lib/i18n.js"; import logger from "../../../lib/logger.js";
import { controller } from "../module.js"; import replies from "../lib/replies.js";
import { controller } from "../module.js";
async function execute(client: Client, interaction: CommandInteraction) {
if (!interaction.guild || !interaction.channel || !interaction.member) { async function execute(client: Client, interaction: Interaction) {
return; if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) {
} logger.warn("Interaction rejected.", { interaction });
await interaction.deferReply(); return;
}
const player = controller.get(interaction.guild.id, interaction.channel as TextChannel);
const player = controller.get(interaction.guild, interaction.channel);
await interaction.editReply({ embeds: [I18n.embeds.playerInfo(player)] });
} if (!player) {
await interaction.reply(replies.notExists());
const data = new SlashCommandBuilder()
.setName("info") return;
.setDescription("Information about the playback."); }
export { data, execute }; await interaction.reply(replies.info(player));
}
const data = new SlashCommandBuilder()
.setName("info")
.setDescription("Verbose information about player and stats.")
.setDMPermission(false);
export { data, execute };
+44 -38
View File
@@ -1,38 +1,44 @@
import { CommandInteraction, SlashCommandBuilder, TextChannel } from "discord.js"; import { Interaction, SlashCommandBuilder } from "discord.js";
import Client from "../../../lib/client.js"; import Client from "../../../lib/client.js";
import I18n from "../lib/i18n.js"; import logger from "../../../lib/logger.js";
import { controller } from "../module.js"; import replies from "../lib/replies.js";
import { controller } from "../module.js";
async function execute(client: Client, interaction: CommandInteraction) {
const pos = interaction.options.get("position")?.value as number; async function execute(client: Client, interaction: Interaction) {
const query = interaction.options.get("query")?.value as string; if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) {
logger.warn("Interaction rejected.", { interaction });
if (!interaction.guild || !query || !pos || !interaction.channel) {
return; return;
} }
const player = controller.get(interaction.guild.id, interaction.channel as TextChannel); const pos = interaction.options.getNumber("position", true);
const query = interaction.options.getString("query", true);
if (!player) {
return; await interaction.deferReply();
}
const player = controller.get(interaction.guild, interaction.channel);
await interaction.deferReply();
if (!player) {
const track = await player.add(query, interaction.user, pos - 1); await interaction.editReply(replies.notExists());
await interaction.editReply({ embeds: [I18n.embeds.appended(track)] }); return;
} }
const data = new SlashCommandBuilder() const track = await player.add(query, interaction.user, pos - 1);
.setName("insert")
.setDescription("Insert a new track to specific position in the queue.") await interaction.editReply(replies.appended(track));
.addStringOption((option) => }
option.setName("query").setDescription("Title or URL of the song.").setRequired(true)
) const data = new SlashCommandBuilder()
.addIntegerOption((option) => .setName("insert")
option.setName("position").setDescription("Position to insert.").setRequired(true) .setDescription("Insert a new track to specific position in the queue.")
); .addStringOption((option) =>
option.setName("query").setDescription("Title or URL of the song.").setRequired(true)
export { data, execute }; )
.addIntegerOption((option) =>
option.setName("position").setDescription("Position to insert.").setRequired(true)
)
.setDMPermission(false);
export { data, execute };
+43 -35
View File
@@ -1,35 +1,43 @@
import { CommandInteraction, SlashCommandBuilder, TextChannel } from "discord.js"; import { Interaction, SlashCommandBuilder } from "discord.js";
import Client from "../../../lib/client.js"; import Client from "../../../lib/client.js";
import I18n from "../lib/i18n.js"; import logger from "../../../lib/logger.js";
import { controller } from "../module.js"; import replies from "../lib/replies.js";
import { controller } from "../module.js";
async function execute(client: Client, interaction: CommandInteraction) {
const query = interaction.options.get("query")?.value as string; async function execute(client: Client, interaction: Interaction) {
if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) {
if (!interaction.guild || !query || !interaction.channel) { logger.warn("Interaction rejected.", { interaction });
return;
} return;
}
const player = controller.get(interaction.guild.id, interaction.channel as TextChannel);
const query = interaction.options.getString("query", true);
if (!player) {
return; const player = controller.get(interaction.guild, interaction.channel);
}
if (!player) {
const state = await interaction.reply(replies.notExists());
!isNaN(+query) && isFinite(+query) && !/e/i.test(query)
? player.jump(parseInt(query) - 1) return;
: player.jump(query); }
await interaction.reply({ embeds: [I18n.embeds.jumped(state)] }); const previous = player.resource?.metadata;
}
const position =
const data = new SlashCommandBuilder() !isNaN(+query) && isFinite(+query) && !/e/i.test(query)
.setName("jump") ? player.jump(parseInt(query) - 1)
.setDescription("Jump to specific track in the queue.") : player.jump(query);
.addStringOption((option) =>
option.setName("query").setDescription("Position or title to jump to.").setRequired(true) await interaction.reply(replies.jumped(previous, position));
); }
export { data, execute }; const data = new SlashCommandBuilder()
.setName("jump")
.setDescription("Jump to specific track in the queue.")
.addStringOption((option) =>
option.setName("query").setDescription("Position or title to jump to.").setRequired(true)
)
.setDMPermission(false);
export { data, execute };
+48 -42
View File
@@ -1,42 +1,48 @@
import { CommandInteraction, SlashCommandBuilder, TextChannel } from "discord.js"; import { Interaction, SlashCommandBuilder } from "discord.js";
import Client from "../../../lib/client.js"; import Client from "../../../lib/client.js";
import I18n from "../lib/i18n.js"; import logger from "../../../lib/logger.js";
import { LoopOption } from "../lib/types.js"; import replies from "../lib/replies.js";
import { controller } from "../module.js"; import { LoopOption } from "../lib/types.js";
import { controller } from "../module.js";
async function execute(client: Client, interaction: CommandInteraction) {
const query = interaction.options.get("option")?.value as string; async function execute(client: Client, interaction: Interaction) {
if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) {
if (!interaction.guild || !query || !interaction.channel) { logger.warn("Interaction rejected.", { interaction });
return;
} return;
}
const player = controller.get(interaction.guild.id, interaction.channel as TextChannel);
const option = interaction.options.getString("option", true) as LoopOption;
if (!player) {
return; const player = controller.get(interaction.guild, interaction.channel);
}
if (!player) {
player.queue.loop = query as LoopOption; await interaction.reply(replies.notExists());
await interaction.reply({ embeds: [I18n.embeds.looped(query as LoopOption)] }); return;
} }
const data = new SlashCommandBuilder() player.queue.loop = option;
.setName("loop")
.setDescription("Select player's loop mode.") await interaction.reply(replies.looped(option));
.addStringOption((option) => }
option
.setName("option") const data = new SlashCommandBuilder()
.setDescription("Select loop option.") .setName("loop")
.setChoices( .setDescription("Select player's loop mode.")
{ name: "loop queue", value: "LOOP" }, .addStringOption((option) =>
{ name: "loop single track", value: "SONG" }, option
{ name: "Till end", value: "NONE" }, .setName("option")
{ name: "Random order", value: "AUTO" } .setDescription("Loop option.")
) .setChoices(
.setRequired(true) { name: "Loop queue", value: "LOOP" },
); { name: "Loop single track", value: "SONG" },
{ name: "Drop on final", value: "NONE" },
export { data, execute }; { name: "Random", value: "AUTO" }
)
.setRequired(true)
)
.setDMPermission(false);
export { data, execute };
+33 -25
View File
@@ -1,25 +1,33 @@
import { CommandInteraction, SlashCommandBuilder, TextChannel } from "discord.js"; import { Interaction, SlashCommandBuilder } from "discord.js";
import Client from "../../../lib/client.js"; import Client from "../../../lib/client.js";
import I18n from "../lib/i18n.js"; import logger from "../../../lib/logger.js";
import { controller } from "../module.js"; import replies from "../lib/replies.js";
import { controller } from "../module.js";
async function execute(client: Client, interaction: CommandInteraction) {
if (!interaction.guild || !interaction.channel) { async function execute(client: Client, interaction: Interaction) {
return; if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) {
} logger.warn("Interaction rejected.", { interaction });
const player = controller.get(interaction.guild.id, interaction.channel as TextChannel); return;
}
if (!player) {
return; const player = controller.get(interaction.guild, interaction.channel);
}
if (!player) {
player.skip(); await interaction.reply(replies.notExists());
await interaction.reply({ embeds: [I18n.embeds.skipped()] }); return;
} }
const data = new SlashCommandBuilder().setName("next").setDescription("Skip current song."); player.skip();
export { data, execute }; await interaction.reply(replies.skipped());
}
const data = new SlashCommandBuilder()
.setName("next")
.setDescription("Skip current song.")
.setDMPermission(false);
export { data, execute };
+33 -25
View File
@@ -1,25 +1,33 @@
import { CommandInteraction, SlashCommandBuilder, TextChannel } from "discord.js"; import { Interaction, SlashCommandBuilder } from "discord.js";
import Client from "../../../lib/client.js"; import Client from "../../../lib/client.js";
import I18n from "../lib/i18n.js"; import logger from "../../../lib/logger.js";
import { controller } from "../module.js"; import replies from "../lib/replies.js";
import { controller } from "../module.js";
async function execute(client: Client, interaction: CommandInteraction) {
if (!interaction.guild || !interaction.channel) { async function execute(client: Client, interaction: Interaction) {
return; if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) {
} logger.warn("Interaction rejected.", { interaction });
const player = controller.get(interaction.guild.id, interaction.channel as TextChannel); return;
}
if (!player) {
return; const player = controller.get(interaction.guild, interaction.channel);
}
if (!player) {
const state = player.pause(); await interaction.reply(replies.notExists());
await interaction.reply({ embeds: [I18n.embeds.paused(state)] }); return;
} }
const data = new SlashCommandBuilder().setName("pause").setDescription("Toggle pause on player."); const state = player.pause();
export { data, execute }; await interaction.reply(replies.paused(state));
}
const data = new SlashCommandBuilder()
.setName("pause")
.setDescription("Toggle pause on playback.")
.setDMPermission(false);
export { data, execute };
+58 -34
View File
@@ -1,34 +1,58 @@
import { CommandInteraction, SlashCommandBuilder } from "discord.js"; import { Interaction, SlashCommandBuilder } from "discord.js";
import Client from "../../../lib/client.js"; import Client from "../../../lib/client.js";
import I18n from "../lib/i18n.js"; import logger from "../../../lib/logger.js";
import { execute as connect } from "./connect.js"; import replies from "../lib/replies.js";
import { controller } from "../module.js";
async function execute(client: Client, interaction: CommandInteraction) {
const query = interaction.options.get("query")?.value as string; async function execute(client: Client, interaction: Interaction) {
if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) {
const player = await connect(client, interaction); logger.warn("Interaction rejected.", { interaction });
if (!player) { return;
return; }
}
const query = interaction.options.getString("query");
if (query) {
const track = await player.add(query, interaction.user); if (query) {
await interaction.reply(replies.searching());
await interaction.editReply({ embeds: [I18n.embeds.appended(track)] }); } else {
} await interaction.deferReply();
}
if (!player.resource) {
player.play(); const player =
} controller.get(interaction.guild, interaction.channel) ??
} controller.set(interaction.guild, interaction.channel);
const data = new SlashCommandBuilder() const channel = interaction.member.voice.channel;
.setName("play")
.setDescription("Launch player and add songs to the queue.") if (!channel) {
.addStringOption((option) => controller.delete(interaction.guildId);
option.setName("query").setDescription("Title or URL of the song.")
); await interaction.editReply(replies.notConnected());
export { data, execute }; return;
}
player.connect(channel);
if (query) {
const track = await player.add(query, interaction.user);
await interaction.editReply(replies.appended(track));
}
if (!player.resource) {
player.play();
}
}
const data = new SlashCommandBuilder()
.setName("play")
.setDescription("Launch player and add songs to the queue.")
.addStringOption((option) =>
option.setName("query").setDescription("Title or URL of the song.")
)
.setDMPermission(false);
export { data, execute };
+86 -77
View File
@@ -1,77 +1,86 @@
import { import {
ActionRowBuilder, ActionRowBuilder,
ButtonBuilder, ButtonBuilder,
ButtonInteraction, ButtonInteraction,
ButtonStyle, ButtonStyle,
CommandInteraction, Interaction,
Message, MessageActionRowComponentBuilder,
MessageActionRowComponentBuilder, SlashCommandBuilder
SlashCommandBuilder, } from "discord.js";
TextChannel
} from "discord.js"; import Client from "../../../lib/client.js";
import logger from "../../../lib/logger.js";
import Client from "../../../lib/client.js"; import replies from "../lib/replies.js";
import I18n from "../lib/i18n.js"; import { controller } from "../module.js";
import { controller } from "../module.js";
async function execute(client: Client, interaction: Interaction) {
async function execute(client: Client, interaction: CommandInteraction) { if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) {
if (!interaction.guild || !interaction.channel) { logger.warn("Interaction rejected.", { interaction });
return;
} return;
}
const player = controller.get(interaction.guild.id, interaction.channel as TextChannel);
await interaction.deferReply();
if (!player) {
return; const player = controller.get(interaction.guild, interaction.channel);
}
if (!player) {
await interaction.deferReply(); await interaction.editReply(replies.notExists());
if (player.queue.empty()) { return;
await interaction.editReply({ embeds: [I18n.embeds.queueEmpty()] }); }
return;
} if (player.queue.empty()) {
await interaction.editReply(replies.queue(player.queue));
let page = 0;
return;
const row = new ActionRowBuilder<MessageActionRowComponentBuilder>().addComponents( }
new ButtonBuilder().setCustomId("QueueTop").setLabel("🔼").setStyle(ButtonStyle.Primary),
new ButtonBuilder().setCustomId("QueueUp").setLabel("🔺").setStyle(ButtonStyle.Secondary), let position = 1;
new ButtonBuilder().setCustomId("QueueDown").setLabel("🔻").setStyle(ButtonStyle.Secondary),
new ButtonBuilder().setCustomId("QueueEnd").setLabel("🔽").setStyle(ButtonStyle.Primary) const row = new ActionRowBuilder<MessageActionRowComponentBuilder>().addComponents(
); new ButtonBuilder().setCustomId("Top").setLabel("🔼").setStyle(ButtonStyle.Primary),
new ButtonBuilder().setCustomId("Up").setLabel("🔺").setStyle(ButtonStyle.Secondary),
const message = (await interaction.editReply({ new ButtonBuilder().setCustomId("Down").setLabel("🔻").setStyle(ButtonStyle.Secondary),
embeds: [I18n.embeds.queueDesigner(player.queue, 0, player.resource)], new ButtonBuilder().setCustomId("End").setLabel("🔽").setStyle(ButtonStyle.Primary)
components: [row] );
})) as Message;
const message = await interaction.editReply({
const collector = message.createMessageComponentCollector({ time: 30000 }); ...replies.queue(player.queue, 0, player.resource),
components: [row]
collector.on("collect", async (btn: ButtonInteraction) => { });
switch (btn.customId) {
case "QueueTop": { const collector = message.createMessageComponentCollector({ time: 30000 });
page = 0;
break; collector.on("collect", async (btn: ButtonInteraction) => {
} switch (btn.customId) {
case "QueueUp": { case "Top": {
page = Math.max(page - 1, 0); position = 1;
break; break;
} }
case "QueueDown": { case "Up": {
page = Math.min(page + 1, Math.floor(player.queue.tracks.length / 11)); position = Math.max(position - 10, 1);
break; break;
} }
case "QueueEnd": case "Down": {
page = Math.floor(player.queue.tracks.length / 11); position = Math.min(
} position + 10,
Math.floor(player.queue.tracks.length / 10) * 10
await btn.update({ );
embeds: [I18n.embeds.queueDesigner(player.queue, page, player.resource)] break;
}); }
}); case "End": {
} position = Math.floor(player.queue.tracks.length / 10) * 10;
}
const data = new SlashCommandBuilder().setName("queue").setDescription("Print out the queue."); }
export { data, execute }; await btn.update(replies.queue(player.queue, position - 1, player.resource));
});
}
const data = new SlashCommandBuilder()
.setName("queue")
.setDescription("Print interactive queue pointing to current track.")
.setDMPermission(false);
export { data, execute };
+41 -35
View File
@@ -1,35 +1,41 @@
import { CommandInteraction, SlashCommandBuilder, TextChannel } from "discord.js"; import { Interaction, SlashCommandBuilder } from "discord.js";
import Client from "../../../lib/client.js"; import Client from "../../../lib/client.js";
import I18n from "../lib/i18n.js"; import logger from "../../../lib/logger.js";
import { controller } from "../module.js"; import replies from "../lib/replies.js";
import { controller } from "../module.js";
async function execute(client: Client, interaction: CommandInteraction) {
const query = interaction.options.get("query")?.value as string; async function execute(client: Client, interaction: Interaction) {
if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) {
if (!interaction.guild || !query || !interaction.channel) { logger.warn("Interaction rejected.", { interaction });
return;
} return;
}
const player = controller.get(interaction.guild.id, interaction.channel as TextChannel);
const query = interaction.options.getString("query", true);
if (!player) {
return; const player = controller.get(interaction.guild, interaction.channel);
}
if (!player) {
const track = await interaction.reply(replies.notExists());
!isNaN(+query) && isFinite(+query) && !/e/i.test(query)
? player.remove(parseInt(query) - 1) return;
: player.remove(query); }
await interaction.reply({ embeds: [I18n.embeds.removed(track)] }); const track =
} !isNaN(+query) && isFinite(+query) && !/e/i.test(query)
? player.remove(parseInt(query) - 1)
const data = new SlashCommandBuilder() : player.remove(query);
.setName("remove")
.setDescription("Remove specific track in the queue.") await interaction.reply(replies.removed(track));
.addStringOption((option) => }
option.setName("query").setDescription("Position or title to remove.").setRequired(true)
); const data = new SlashCommandBuilder()
.setName("remove")
export { data, execute }; .setDescription("Remove specific track in the queue.")
.addStringOption((option) =>
option.setName("query").setDescription("Position or title to remove.").setRequired(true)
)
.setDMPermission(false);
export { data, execute };
+33 -25
View File
@@ -1,25 +1,33 @@
import { CommandInteraction, SlashCommandBuilder, TextChannel } from "discord.js"; import { Interaction, SlashCommandBuilder } from "discord.js";
import Client from "../../../lib/client.js"; import Client from "../../../lib/client.js";
import I18n from "../lib/i18n.js"; import logger from "../../../lib/logger.js";
import { controller } from "../module.js"; import replies from "../lib/replies.js";
import { controller } from "../module.js";
async function execute(client: Client, interaction: CommandInteraction) {
if (!interaction.guild || !interaction.channel) { async function execute(client: Client, interaction: Interaction) {
return; if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) {
} logger.warn("Interaction rejected.", { interaction });
const player = controller.get(interaction.guild.id, interaction.channel as TextChannel); return;
}
if (!player) {
return; const player = controller.get(interaction.guild, interaction.channel);
}
if (!player) {
player.shuffle(); await interaction.reply(replies.notExists());
await interaction.reply({ embeds: [I18n.embeds.reshuffle()] }); return;
} }
const data = new SlashCommandBuilder().setName("reshuffle").setDescription("Shuffle the queue."); player.shuffle();
export { data, execute }; await interaction.reply(replies.reshuffle());
}
const data = new SlashCommandBuilder()
.setName("reshuffle")
.setDescription("Reshuffle the queue. (Will change queue order)")
.setDMPermission(false);
export { data, execute };
+50 -38
View File
@@ -1,38 +1,50 @@
import { CommandInteraction, SlashCommandBuilder, TextChannel } from "discord.js"; import { Interaction, SlashCommandBuilder } from "discord.js";
import Client from "../../../lib/client.js"; import Client from "../../../lib/client.js";
import I18n from "../lib/i18n.js"; import logger from "../../../lib/logger.js";
import { controller } from "../module.js"; import replies from "../lib/replies.js";
import { controller } from "../module.js";
async function execute(client: Client, interaction: CommandInteraction) {
const hours = (interaction.options.get("hrs")?.value as number) || 0; async function execute(client: Client, interaction: Interaction) {
const minutes = (interaction.options.get("mins")?.value as number) || 0; if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) {
const seconds = (interaction.options.get("secs")?.value as number) || 0; logger.warn("Interaction rejected.", { interaction });
if (!interaction.guild || !interaction.channel) { return;
return; }
}
const hours = interaction.options.getInteger("hrs") ?? 0;
const player = controller.get(interaction.guild.id, interaction.channel as TextChannel); const minutes = interaction.options.getInteger("mins") ?? 0;
const seconds = interaction.options.getInteger("secs") ?? 0;
if (!player) {
return; const player = controller.get(interaction.guild, interaction.channel);
}
if (!player) {
const time = hours * 3600 + minutes * 60 + seconds; await interaction.reply(replies.notExists());
player.seek(time * 1000); return;
}
await interaction.reply({
embeds: [I18n.embeds.fastForwarded(time)] if (!player.resource) {
}); await interaction.reply(replies.notPlaying());
}
return;
const data = new SlashCommandBuilder() }
.setName("seek")
.setDescription("Fast-forward by the time.") const prevTime = player.resource.playbackDuration / 1000;
.addIntegerOption((option) => option.setName("hrs").setDescription("Hours to seek."))
.addIntegerOption((option) => option.setName("mins").setDescription("Minutes to seek.")) const nextTime = hours * 3600 + minutes * 60 + seconds;
.addIntegerOption((option) => option.setName("secs").setDescription("Seconds to seek."));
player.seek(nextTime * 1000);
export { data, execute };
await interaction.reply(replies.seeked(prevTime, nextTime));
}
const data = new SlashCommandBuilder()
.setName("seek")
.setDescription("Fast-forward/Fast-reverse by the given time.")
.addIntegerOption((option) => option.setName("hrs").setDescription("Hours to seek."))
.addIntegerOption((option) => option.setName("mins").setDescription("Minutes to seek."))
.addIntegerOption((option) => option.setName("secs").setDescription("Seconds to seek."))
.setDMPermission(false);
export { data, execute };
+33 -25
View File
@@ -1,25 +1,33 @@
import { CommandInteraction, SlashCommandBuilder, TextChannel } from "discord.js"; import { Interaction, SlashCommandBuilder } from "discord.js";
import Client from "../../../lib/client.js"; import Client from "../../../lib/client.js";
import I18n from "../lib/i18n.js"; import logger from "../../../lib/logger.js";
import { controller } from "../module.js"; import replies from "../lib/replies.js";
import { controller } from "../module.js";
async function execute(client: Client, interaction: CommandInteraction) {
if (!interaction.guild || !interaction.channel) { async function execute(client: Client, interaction: Interaction) {
return; if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) {
} logger.warn("Interaction rejected.", { interaction });
const player = controller.get(interaction.guild.id, interaction.channel as TextChannel); return;
}
if (!player) {
return; const player = controller.get(interaction.guild, interaction.channel);
}
if (!player) {
player.destroy(); await interaction.reply(replies.notExists());
await interaction.reply({ embeds: [I18n.embeds.destroyed()] }); return;
} }
const data = new SlashCommandBuilder().setName("stop").setDescription("Destroy the player."); player.destroy();
export { data, execute }; await interaction.reply(replies.destroyed());
}
const data = new SlashCommandBuilder()
.setName("stop")
.setDescription("Destroy the player.")
.setDMPermission(false);
export { data, execute };
+38 -32
View File
@@ -1,32 +1,38 @@
import { CommandInteraction, SlashCommandBuilder, TextChannel } from "discord.js"; import { Interaction, SlashCommandBuilder } from "discord.js";
import Client from "../../../lib/client.js"; import Client from "../../../lib/client.js";
import I18n from "../lib/i18n.js"; import logger from "../../../lib/logger.js";
import { controller } from "../module.js"; import replies from "../lib/replies.js";
import { controller } from "../module.js";
async function execute(client: Client, interaction: CommandInteraction) {
const volume = (interaction.options.get("volume")?.value as number) || 100; async function execute(client: Client, interaction: Interaction) {
if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) {
if (!interaction.guild || !interaction.channel) { logger.warn("Interaction rejected.", { interaction });
return;
} return;
}
const player = controller.get(interaction.guild.id, interaction.channel as TextChannel);
const volume = interaction.options.getInteger("volume") ?? 100;
if (!player) {
return; const player = controller.get(interaction.guild, interaction.channel);
}
if (!player) {
player.volume = volume; await interaction.reply(replies.notExists());
await interaction.reply({ embeds: [I18n.embeds.volumeSet(player.volume)] }); return;
} }
const data = new SlashCommandBuilder() player.volume = volume;
.setName("volume")
.setDescription("Change player's volume amount.") await interaction.reply(replies.volumeSet(player.volume));
.addIntegerOption((option) => }
option.setName("volume").setDescription("Volume in percents.").setRequired(true)
); const data = new SlashCommandBuilder()
.setName("volume")
export { data, execute }; .setDescription("Change player's volume amount.")
.addIntegerOption((option) =>
option.setName("volume").setDescription("Volume in percents.").setRequired(true)
)
.setDMPermission(false);
export { data, execute };
+36 -28
View File
@@ -1,28 +1,36 @@
import { VoiceState } from "discord.js"; import { VoiceState } from "discord.js";
import Client from "../../../lib/client.js"; import Client from "../../../lib/client.js";
import I18n from "../lib/i18n.js"; import { asleep } from "../lib/misc.js";
import { asleep } from "../lib/misc.js"; import replies from "../lib/replies.js";
import { controller } from "../module.js"; import { controller } from "../module.js";
async function execute(client: Client, eventee: VoiceState[]): Promise<void> { const WAIT_TIME = 30_000;
const [oldState] = eventee;
async function execute(client: Client, eventee: [VoiceState]): Promise<void> {
const player = controller.get(oldState.guild.id); const [oldState] = eventee;
if (!player || !oldState.channel || !client.user) { const player = controller.get(oldState.guild);
return;
} if (!player || !oldState.channel || !client.user) {
return;
if (oldState.channel.members.filter((value) => value.user !== client.user).size === 0) { }
await asleep(30000);
if (oldState.channel.members.filter((value) => value.user !== client.user).size === 0) { if (
await player.channel.send({ embeds: [I18n.embeds.fullyAlone()] }); oldState.channel.members.size === 1 &&
player.destroy(); oldState.channel.members.first()?.user === client.user
} ) {
} await asleep(WAIT_TIME);
} if (
oldState.channel.members.size === 1 &&
const name = "voiceStateUpdate"; oldState.channel.members.first()?.user === client.user
) {
export { execute, name }; await player.channel.send(replies.alone());
player.destroy();
}
}
}
const name = "voiceStateUpdate";
export { execute, name };
+102 -46
View File
@@ -1,46 +1,102 @@
import { execFile } from "child_process"; import { execFile } from "child_process";
import path from "path"; import path from "path";
import { promisify } from "util"; import { promisify } from "util";
import config from "../../../config.js"; import config from "../../../config.js";
import { YtdlArgs, YtdlResponse } from "./types.js"; import logger from "../../../lib/logger.js";
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
function argumentsResolver(args: YtdlArgs): string[] { const SEARCH_DEPTH = 5;
const options = Object.entries(args);
function isURL(url: string): boolean {
return options.map(([key, value]) => { return /^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)$/.test(
if (typeof value === "boolean" && value) { url
return `--${key.split(/(?=[A-Z])/).join("-")}`.toLowerCase(); );
} else { }
return `--${key.split(/(?=[A-Z])/).join("-")}=${value}`.toLowerCase();
} interface YtdlDump {
}); id: string;
} original_url: string;
title?: string;
async function ytdlExec(query: string, args: YtdlArgs): Promise<YtdlResponse | undefined> { uploader?: string;
try { duration?: number;
const executable = await execFileAsync( extractor_key: string;
path.join(config.binariesDir, "/youtube-dl"), thumbnail?: string;
[query].concat(argumentsResolver(args)) }
);
return JSON.parse(executable.stdout); interface YtdlResult {
} catch (error) { url: string;
console.log(error); title: string;
return; thumbnail: string;
} duration: number;
} }
export async function fetchStream( async function fetchInfoRecursive(query: string, depth = 0): Promise<YtdlResult[]> {
query: string, if (depth === SEARCH_DEPTH) {
args: YtdlArgs logger.warn("yt-dlp failed to find anything.", { query });
): Promise<YtdlResponse | undefined> {
let response = await ytdlExec(query, args); return [];
}
if (response && !response.extractor.includes("youtube")) {
response = await ytdlExec(`${response.title} - ${response.creator}`, args); const executable = await execFileAsync(path.join(config.binariesDir, "/yt-dlp"), [
} query,
"-O %(.{id,original_url,title,uploader,duration,extractor_key,thumbnail})j",
return response; "-s",
} "--flat-playlist"
]);
const dump: YtdlDump[] = JSON.parse("[" + executable.stdout.replace(/\n(?=.+)/g, ",") + "]");
const result: YtdlResult[] = [];
for (const entry of dump) {
if (entry.title && entry.thumbnail && entry.duration && entry.extractor_key === "Youtube") {
result.push({
url: entry.original_url,
title: entry.title,
thumbnail: entry.thumbnail,
duration: entry.duration
});
logger.info("yt-dlp has found corresponding youtube video.", {
result: result[result.length - 1]
});
continue;
}
let unwind: YtdlResult[];
if (entry.extractor_key !== "Youtube" && entry.title) {
unwind = await fetchInfoRecursive(
`ytsearch1:${entry.uploader ? entry.uploader + " - " : ""} ${entry.title}`,
depth + 1
);
} else {
unwind = await fetchInfoRecursive(entry.original_url, depth + 1);
}
if (unwind.length !== 0) {
result.push(unwind[0]);
}
}
return result;
}
export async function fetchInfo(query: string): Promise<YtdlResult[]> {
logger.info("yt-dlp was invoked.", { query });
try {
if (!isURL(query)) {
return await fetchInfoRecursive("ytsearch1:" + query);
}
return await fetchInfoRecursive(query);
} catch (error) {
logger.error("yt-dlp execution invoked error.", { error });
return [];
}
}
+66 -71
View File
@@ -1,71 +1,66 @@
export default class Filter { export default class Filter {
public static readonly filters: Record<string, string> = { public static readonly filters = {
bassboost_low: "bass=g=15:f=110:w=0.3", "Bassboost 1.5": "bass=g=15:f=110:w=0.3",
bassboost: "bass=g=20:f=110:w=0.3", "bassboost 2.0": "bass=g=20:f=110:w=0.3",
bassboost_high: "bass=g=30:f=110:w=0.3", "bassboost 3.0": "bass=g=30:f=110:w=0.3",
"8D": "apulsator=hz=0.09", "8D": "apulsator=hz=0.09",
vaporwave: "aresample=48000,asetrate=48000*0.75", Daycore: "aresample=48000,asetrate=48000*0.75",
nightcore: "aresample=48000,asetrate=48000*1.25", Nightcore: "aresample=48000,asetrate=48000*1.25",
phaser: "aphaser=in_gain=0.4", Phaser: "aphaser=in_gain=0.4",
tremolo: "tremolo", Tremolo: "tremolo",
vibrato: "vibrato=f=6.5", Vibrato: "vibrato=f=6.5",
reverse: "areverse", Reverse: "areverse",
treble: "treble=g=5", Treble: "treble=g=5",
normalizer: "dynaudnorm=g=101", "Normalizer 1": "dynaudnorm=g=101",
normalizer2: "acompressor", "Normalizer 2": "acompressor",
surrounding: "surround", "Surround Sound": "surround",
pulsator: "apulsator=hz=1", Pulsator: "apulsator=hz=1",
subboost: "asubboost", Subboost: "asubboost",
karaoke: "stereotools=mlev=0.03", Karaoke: "stereotools=mlev=0.03",
flanger: "flanger", Compressor: "compand=points=-80/-105|-62/-80|-15.4/-15.4|0/-12|20/-7.6",
gate: "agate", Expander:
haas: "haas", "compand=attacks=0:points=-80/-169|-54/-80|-49.5/-64.6|-41.1/-41.1|-25.8/-15|-10.8/-4.5|0/0|20/8.3",
mcompand: "mcompand", "Soft Limiter": "compand=attacks=0:points=-80/-80|-12.4/-12.4|-6/-8|0/-6.8|20/-2.8",
mono: "pan=mono|c0=.5*c0+.5*c1", Chorus: "chorus=0.7:0.9:55:0.4:0.25:2",
mstlr: "stereotools=mode=ms>lr", "Fade In": "afade=t=in:ss=0:d=10",
mstrr: "stereotools=mode=ms>rr", Dimming:
compressor: "compand=points=-80/-105|-62/-80|-15.4/-15.4|0/-12|20/-7.6", "afftfilt=\"'real=re * (1-clip((b/nb)*b,0,1))':imag='im * (1-clip((b/nb)*b,0,1))'\"",
expander: Earrape: "channelsplit,sidechaingate=level_in=64"
"compand=attacks=0:points=-80/-169|-54/-80|-49.5/-64.6|-41.1/-41.1|-25.8/-15|-10.8/-4.5|0/0|20/8.3", };
softlimiter: "compand=attacks=0:points=-80/-80|-12.4/-12.4|-6/-8|0/-6.8|20/-2.8",
chorus: "chorus=0.7:0.9:55:0.4:0.25:2", private switches_ = new Set<string>();
chorus2d: "chorus=0.6:0.9:50|60:0.4|0.32:0.25|0.4:2|1.3",
chorus3d: "chorus=0.5:0.9:50|60|40:0.4|0.32|0.3:0.25|0.4|0.3:2|2.3|1.3", public toggled(): [string, string][] {
fadein: "afade=t=in:ss=0:d=10", return Object.entries(Filter.filters)
dim: "afftfilt=\"'real=re * (1-clip((b/nb)*b,0,1))':imag='im * (1-clip((b/nb)*b,0,1))'\"", .filter((value) => this.switches_.has(value[0]))
earrape: "channelsplit,sidechaingate=level_in=64" .map((value) => [value[0], value[1]]);
}; }
private switches = new Set<string>(); public toggle(query?: string): boolean {
if (!query) {
public toggled(): [string, string][] { this.switches_.clear();
return Object.entries(Filter.filters) return false;
.filter((value) => this.switches.has(value[0])) }
.map((value) => [value[0], value[1]]);
} if (!this.switches_.delete(query) && Object.hasOwn(Filter.filters, query)) {
this.switches_.add(query);
public toggle(query?: string): void { return true;
if (!query) { }
this.switches.clear();
return; return false;
} }
if (!this.switches.delete(query) && Object.hasOwn(Filter.filters, query)) { public size(): number {
this.switches.add(query); return this.switches_.size;
} }
}
public empty(): boolean {
public size(): number { return this.size() === 0;
return this.switches.size; }
}
public toString(): string {
public empty(): boolean { return this.toggled()
return this.size() === 0; .map((value) => value[1])
} .join(",");
}
public toString(): string { }
return this.toggled()
.map((value) => value[1])
.join(",");
}
}
-259
View File
@@ -1,259 +0,0 @@
import { AudioResource } from "@discordjs/voice";
import { EmbedBuilder } from "discord.js";
import { loopMoji, secToISO, statusBarGenerator, volumeMoji } from "./misc.js";
import Player from "./player.js";
import Queue from "./queue.js";
import { LoopOption, Track } from "./types.js";
export default class I18n {
public static readonly embeds = {
nowPlaying: (track: Track) =>
new EmbedBuilder()
.setColor("LuminousVividPink")
.setAuthor({
iconURL: track.requester.avatarURL() || undefined,
name: track.requester.tag
})
.setTitle(track.title)
.setURL(track.url)
.setDescription("**playing now.**")
.setThumbnail(track.thumbnail),
cleared: (amount: number) =>
new EmbedBuilder()
.setColor("Green")
.setTitle(`**♻️ ${amount} tracks have been cleared from the queue.**`)
.setTimestamp(new Date()),
okConnected: (channel: string) =>
new EmbedBuilder()
.setColor("Green")
.setTitle(`**✅ Successfuly connected to:** _${channel}_.`),
notConnected: () =>
new EmbedBuilder()
.setColor("Red")
.setTitle("**🚫 Make yourself connected to a voice channel beforehand.**"),
wasConnected: () =>
new EmbedBuilder()
.setColor("Yellow")
.setTitle("**⚠️ Already connected to a voice channel.**"),
filtered: (filter?: string) =>
new EmbedBuilder()
.setColor("Green")
.setTitle(
filter
? `**✅ ${filter} was applied to the player.**`
: "**✅ All filters were declined.**"
),
appended: (track?: Track) =>
!track
? new EmbedBuilder()
.setColor("Red")
.setTitle("**🚫 Could not add track to the queue.**")
: new EmbedBuilder()
.setColor("Green")
.setTitle(track.title)
.setURL(track.url)
.setThumbnail(track.thumbnail)
.setAuthor({
iconURL: track.requester.avatarURL() || undefined,
name: track.requester.tag
})
.setDescription("**has been added to the queue.**")
.setTimestamp(new Date()),
jumped: (state: boolean) =>
state
? new EmbedBuilder().setColor("Green").setTitle("_**Perfoming jump.**_")
: new EmbedBuilder().setColor("Red").setTitle("**❗ Bad _jump_ request.**"),
looped: (query: LoopOption) => {
switch (query) {
case "AUTO": {
return new EmbedBuilder()
.setColor("LuminousVividPink")
.setTitle("**🔀 Shuffle mode** _(does not affect the queue order)_.");
}
case "LOOP": {
return new EmbedBuilder()
.setColor("Blue")
.setTitle("**🔁 Looping over the queue.**");
}
case "NONE": {
return new EmbedBuilder()
.setColor("Red")
.setTitle("**⤵️ Player will stop on the queue end.**");
}
case "SONG": {
return new EmbedBuilder()
.setColor("Yellow")
.setTitle("**🔂 Looping a single track.**");
}
}
},
skipped: () => new EmbedBuilder().setColor("Green").setTitle("**⏭️ Skipping.**"),
paused: (state: boolean) =>
state
? new EmbedBuilder().setColor("Green").setTitle("_**▶️ Resumed.**_")
: new EmbedBuilder().setColor("Yellow").setTitle("_**⏸️ Paused.**_"),
removed: (track?: Track) =>
track
? new EmbedBuilder()
.setColor("Green")
.setTitle(track.title)
.setDescription("**was removed from the queue.**")
.setTimestamp(new Date())
.setURL(track.url)
.setThumbnail(track.thumbnail)
: new EmbedBuilder().setColor("Red").setTitle("**❗ Bad _remove_ request.**"),
fastForwarded: (amount: number) =>
new EmbedBuilder()
.setColor("Blue")
.setTitle(`**⏩ Fast-forwarding to ${secToISO(amount)}**`),
destroyed: () =>
new EmbedBuilder().setColor("Red").setTitle("**💀 Destroying the player.**"),
volumeSet: (amount: number) =>
new EmbedBuilder()
.setColor("Green")
.setTitle(`**${volumeMoji(amount)} Volume has been set to ${amount}%**`),
queueEmpty: () => new EmbedBuilder().setColor("Yellow").setTitle("**❎ Queue is empty.**"),
playerInfo: (player?: Player) => {
if (!player) {
return new EmbedBuilder()
.setColor("Red")
.setTitle("**🚫 Player does not exist.**");
}
if (player.resource) {
return new EmbedBuilder()
.setColor("LuminousVividPink")
.setTitle(`${player.queue.position + 1}. ${player.resource.metadata.title}`)
.setDescription(
statusBarGenerator(
player.resource.playbackDuration / 1000,
player.resource.metadata.duration
)
)
.setThumbnail(player.resource.metadata.thumbnail)
.setURL(player.resource.metadata.url)
.addFields([
{
name: "Applied Filters",
value:
player.filter
.toggled()
.map((value) => value[0])
.join("\n") + "\ndefault",
inline: true
},
{
name: "Queue Length",
value: `🔢 ${player.queue.tracks.length}`,
inline: true
},
{
name: "Queue Duration",
value: `💿 ${secToISO(player.queue.duration)}`,
inline: true
},
{
name: "Queue Looping",
value: `${loopMoji(player.queue.loop)} ${player.queue.loop}`,
inline: true
},
{
name: "Volume amount",
value: `${volumeMoji(player.volume)} ${player.volume}/150`,
inline: true
},
{
name: "Player lifetime",
value: `🕓 ${secToISO((Date.now() - player.created.getTime()) / 1000)}`,
inline: true
}
]);
} else {
return new EmbedBuilder()
.setColor("Blue")
.setTitle("💿 Insert the disk...")
.setDescription(statusBarGenerator(0, 0))
.setURL("https://www.youtube.com/watch?v=dQw4w9WgXcQ&ab_channel=RickAstley")
.addFields([
{
name: "Applied Filters",
value:
player.filter
.toggled()
.map((value) => value[0])
.join("\n") + "\ndefault",
inline: true
},
{
name: "Queue Length",
value: `🔢 ${player.queue.tracks.length}`,
inline: true
},
{
name: "Queue Duration",
value: `💿 ${secToISO(player.queue.duration)}`,
inline: true
},
{
name: "Queue Looping",
value: `${loopMoji(player.queue.loop)} ${player.queue.loop}`,
inline: true
},
{
name: "Volume amount",
value: `${volumeMoji(player.volume)} ${player.volume}/150`,
inline: true
},
{
name: "Player lifetime",
value: `🕓 ${secToISO((Date.now() - player.created.getTime()) / 1000)}`,
inline: true
}
]);
}
},
queueDesigner: (queue: Queue, page: number, resource: AudioResource<Track> | undefined) => {
page = Math.max(0, Math.min(Math.floor(queue.tracks.length / 11), page));
return new EmbedBuilder()
.setColor("LuminousVividPink")
.setTitle(
resource
? `${queue.position + 1}. ${resource.metadata.title}`
: "💿 Insert the disk..."
)
.setURL(
resource
? resource.metadata.url
: "https://www.youtube.com/watch?v=5mGuCdlCcNM&ab_channel=Ra%C3%BAlBlanco"
)
.setThumbnail(
resource
? resource.metadata.thumbnail
: "https/img.youtube.com/vi/5mGuCdlCcNM/default.jpg"
)
.setDescription(
resource
? statusBarGenerator(
resource.playbackDuration / 1000,
resource.metadata.duration
)
: statusBarGenerator(0, 0)
)
.setFields(
queue.tracks.slice(page * 10, page * 10 + 10).map((value, index) => ({
name: `${page * 10 + index + 1}. ${value.title.padEnd(35)}`,
value: `**[${secToISO(value.duration)}]**`
}))
);
},
fullyAlone: () =>
new EmbedBuilder()
.setColor("LuminousVividPink")
.setTitle("🥬 I have decided to leave to conserve my energy.")
.setDescription("_**Since there was nobody in the voice channel.**_"),
reshuffle: () =>
new EmbedBuilder().setColor("Green").setTitle("**✅ Successfuly reshuffled queue.**")
};
}
+59 -58
View File
@@ -1,58 +1,59 @@
import { LoopOption } from "./types.js"; import { AudioResource } from "@discordjs/voice";
const PLAYBACK_LENGTH = 15; import { LoopOption, Track } from "./types.js";
export function statusBarGenerator(playback: number, duration: number): string { const STATUSBAR_LENGTH = 15;
const filled = Array<string>(
Math.floor((PLAYBACK_LENGTH * playback) / (duration === 0 ? 1 : duration)) export function statusBarGenerator(resource?: AudioResource<Track>): string {
).fill("🔹"); const duration = resource?.metadata.duration || 1;
const playback = Math.min(resource?.playbackDuration || 0 / 1000, duration);
const empty = Array<string>(
PLAYBACK_LENGTH - Math.floor((PLAYBACK_LENGTH * playback) / (duration === 0 ? 1 : duration)) const filled = Array<string>(Math.floor((STATUSBAR_LENGTH * playback) / duration)).fill("🔹");
).fill("▫️");
const empty = Array<string>(STATUSBAR_LENGTH - filled.length).fill("▫️");
return `**[${filled.join("")}${empty.join("")}] ${secToISO(playback)}/${secToISO(duration)}**`;
} return `[${filled.join("")}${empty.join("")}] ${toISOTime(playback)}/${toISOTime(duration)}`;
}
export function secToISO(amount: number): string {
const h = Math.floor(amount / 3600); export function toISOTime(amount: number): string {
const m = Math.floor((amount % 3600) / 60); const h = Math.floor(amount / 3600);
const s = Math.floor((amount % 3600) % 60); const m = Math.floor((amount % 3600) / 60);
const s = Math.floor((amount % 3600) % 60);
const hDisplay = h > 0 ? `${h}:` : "";
const mDisplay = `${Math.floor(m / 10)}${Math.floor(m % 10)}:`; const hDisplay = h > 0 ? `${h}:` : "";
const sDisplay = `${Math.floor(s / 10)}${Math.floor(s % 10)}`; const mDisplay = `${Math.floor(m / 10)}${Math.floor(m % 10)}:`;
const sDisplay = `${Math.floor(s / 10)}${Math.floor(s % 10)}`;
return hDisplay + mDisplay + sDisplay;
} return hDisplay + mDisplay + sDisplay;
}
export async function asleep(amount: number): Promise<unknown> {
return new Promise((r) => setTimeout(r, amount)); export async function asleep(amount: number): Promise<unknown> {
} return new Promise((r) => setTimeout(r, amount));
}
export function volumeMoji(amount: number): string {
return amount > 100 ? "🔊" : amount > 50 ? "🔉" : amount > 0 ? "🔈" : "🔇"; export function volumeMoji(amount: number): string {
} return amount > 100 ? "🔊" : amount > 50 ? "🔉" : amount > 0 ? "🔈" : "🔇";
}
export function loopMoji(loop: LoopOption): string {
return loop === "AUTO" ? "🔀" : loop === "LOOP" ? "🔁" : loop === "NONE" ? "⤵️" : "🔂"; export function loopMoji(loop: LoopOption): string {
} return loop === "AUTO" ? "🔀" : loop === "LOOP" ? "🔁" : loop === "NONE" ? "⤵️" : "🔂";
}
export function largestCommonSequence(s1: string, s2: string) {
s1 = s1.toLowerCase().slice(0, 100); export function largestCommonSequence(s1: string, s2: string) {
s2 = s2.toLowerCase().slice(0, 100); s1 = s1.toLowerCase().slice(0, 100);
s2 = s2.toLowerCase().slice(0, 100);
const buffer = Array.from(Array(s1.length + 1), () => new Array<number>(s2.length + 1).fill(0));
const buffer = Array.from(Array(s1.length + 1), () => new Array<number>(s2.length + 1).fill(0));
for (let i = 1; i <= s1.length; i++) {
for (let j = 1; j <= s2.length; j++) { for (let i = 1; i <= s1.length; i++) {
if (s1[i - 1] === s2[j - 1]) { for (let j = 1; j <= s2.length; j++) {
buffer[i][j] = buffer[i - 1][j - 1] + 1; if (s1[i - 1] === s2[j - 1]) {
} else { buffer[i][j] = buffer[i - 1][j - 1] + 1;
buffer[i][j] = Math.max(buffer[i - 1][j], buffer[i][j - 1]); } else {
} buffer[i][j] = Math.max(buffer[i - 1][j], buffer[i][j - 1]);
} }
} }
}
return buffer[s1.length][s2.length];
} return buffer[s1.length][s2.length];
}
+36 -31
View File
@@ -1,31 +1,36 @@
import { Collection, Guild, TextChannel } from "discord.js"; import { Collection, Guild, GuildTextBasedChannel } from "discord.js";
import Player from "./player.js"; import logger from "../../../lib/logger.js";
import Player from "./player.js";
export default class Music {
private players = new Collection<string, Player>(); export default class Music {
private players_ = new Collection<string, Player>();
public get(guildId: string, channel?: TextChannel): Player | undefined {
const player = this.players.get(guildId); public get(guild: Guild, channel?: GuildTextBasedChannel): Player | undefined {
const player = this.players_.get(guild.id);
if (player) {
if (channel) { if (player) {
player.channel = channel; if (channel) {
} player.channel = channel;
}
return player;
} return player;
} }
}
public set(guild: Guild, channel: TextChannel): Player {
const player = new Player(guild, this, channel); public set(guild: Guild, channel: GuildTextBasedChannel): Player {
const player = new Player(guild, this, channel);
this.players.set(guild.id, player);
this.players_.set(guild.id, player);
return player;
} logger.info("Player was created.", { player });
public delete(guildId: string): void { return player;
this.players.delete(guildId); }
}
} public delete(guildId: string): void {
logger.info("Player was deleted.", { guildId });
this.players_.delete(guildId);
}
}
+288 -328
View File
@@ -1,328 +1,288 @@
import { import {
AudioPlayer, AudioPlayer,
AudioPlayerStatus, AudioPlayerStatus,
AudioResource, AudioResource,
createAudioPlayer, createAudioPlayer,
createAudioResource, createAudioResource,
DiscordGatewayAdapterCreator, DiscordGatewayAdapterCreator,
entersState, joinVoiceChannel,
joinVoiceChannel, VoiceConnection
VoiceConnection, } from "@discordjs/voice";
VoiceConnectionStatus import { Guild, GuildTextBasedChannel, Message, User, VoiceBasedChannel } from "discord.js";
} from "@discordjs/voice"; import ytdl from "discord-ytdl-core";
import { Guild, Message, TextChannel, User, VoiceBasedChannel } from "discord.js";
import ytdl from "discord-ytdl-core"; import { fetchInfo } from "./fetch.js";
import Filter from "./filter.js";
import { fetchStream } from "./fetch.js"; import { largestCommonSequence } from "./misc.js";
import Filter from "./filter.js"; import Music from "./music.js";
import I18n from "./i18n.js"; import Queue from "./queue.js";
import { largestCommonSequence } from "./misc.js"; import replies from "./replies.js";
import Music from "./music.js"; import { Track } from "./types.js";
import Queue from "./queue.js";
import { Track } from "./types.js"; export default class Player {
private music: Music;
export default class Player { private guild: Guild;
private music: Music;
private guild: Guild; private channel_: GuildTextBasedChannel;
public get channel(): GuildTextBasedChannel {
private msgChannel: TextChannel; return this.channel_;
public get channel(): TextChannel { }
return this.msgChannel; public set channel(x: GuildTextBasedChannel) {
} this.channel_ = x;
public set channel(x: TextChannel) { }
this.msgChannel = x;
} private message_?: Message;
private message: Message | undefined; private connection_?: VoiceConnection;
public get connection(): VoiceConnection | undefined {
public internalConnection: VoiceConnection | undefined; return this.connection_;
public get connection(): VoiceConnection | undefined { }
return this.internalConnection;
} private player_: AudioPlayer;
public get player(): AudioPlayer {
private internalPlayer: AudioPlayer; return this.player_;
public get player(): AudioPlayer { }
return this.internalPlayer;
} private resource_: AudioResource<Track> | undefined;
public get resource(): AudioResource<Track> | undefined {
private playerResource: AudioResource<Track> | undefined; return this.resource_;
public get resource(): AudioResource<Track> | undefined { }
return this.playerResource;
} private volume_ = 100;
public get volume(): number {
private resourceVolume = 100; return this.volume_;
public get volume(): number { }
return this.resourceVolume; public set volume(x: number) {
} this.volume_ = Math.max(0, Math.min(150, x));
public set volume(x: number) {
this.resourceVolume = Math.max(0, Math.min(150, x)); if (this.resource_ && this.resource_.volume) {
this.resource_.volume.setVolumeLogarithmic(this.volume_ / 100);
if (this.playerResource && this.playerResource.volume) { }
this.playerResource.volume.setVolumeLogarithmic(this.resourceVolume / 100); }
}
} public readonly queue = new Queue();
public readonly queue = new Queue(); public readonly filter = new Filter();
public readonly filter = new Filter(); public readonly createdAt: Date = new Date();
public readonly created: Date = new Date(); public constructor(guild: Guild, music: Music, channel: GuildTextBasedChannel) {
this.music = music;
public constructor(guild: Guild, music: Music, textChannel: TextChannel) { this.guild = guild;
this.music = music; this.channel_ = channel;
this.guild = guild;
this.msgChannel = textChannel; this.player_ = createAudioPlayer();
this.internalPlayer = createAudioPlayer(); this.player_.on(AudioPlayerStatus.Idle, async () => this.resourceEndResolvable());
}
this.internalPlayer.on(AudioPlayerStatus.Idle, async () => this.resourceEndResolvable());
} private resourceGenerator(track: Track, seek?: number) {
const stream = ytdl(track.url, {
private resourceGenerator(track: Track, seek?: number) { opusEncoded: true,
const stream = ytdl(track.url, { filter: "audioonly",
opusEncoded: false, highWaterMark: 1 << 25,
fmt: "mp3", seek: seek ? seek / 1000 : undefined,
filter: "audioonly", encoderArgs: !this.filter.empty() ? ["-af", this.filter.toString()] : undefined
highWaterMark: 1 << 25, });
seek: seek ? seek / 1000 : undefined,
encoderArgs: !this.filter.empty() ? ["-af", this.filter.toString()] : undefined this.resource_ = createAudioResource(stream, {
}); inlineVolume: true,
metadata: track
this.playerResource = createAudioResource(stream, { });
inlineVolume: true,
metadata: track if (seek) {
}); this.resource_.playbackDuration = seek;
}
if (seek) {
this.playerResource.playbackDuration = seek; if (this.resource_.volume) {
} this.resource_.volume.setVolumeLogarithmic(this.volume_ / 100);
}
if (this.playerResource.volume) {
this.playerResource.volume.setVolumeLogarithmic(this.resourceVolume / 100); return this.resource_;
} }
return this.playerResource; private resourceEndResolvable(): void {
} if (this.resource_) {
this.resource_.encoder?.destroy();
private resourceEndResolvable(): void { }
if (this.playerResource) {
this.playerResource.encoder?.destroy(); if (this.queue.next()) {
} this.play();
} else {
if (this.queue.next()) { this.player_.stop(true);
this.play(); this.resource_ = undefined;
} else { }
this.playerResource = undefined; }
this.internalPlayer.stop();
} private async messageResolvable(track: Track) {
} if (this.message_?.deletable) {
await this.message_.delete().catch();
private async messageResolvable(track: Track) { }
if (this.message) {
await this.message.delete(); this.message_ = await this.channel_.send(replies.nowPlaying(track));
} }
this.message = await this.msgChannel.send({ private async search(query: string, requester: User): Promise<Track[] | undefined> {
embeds: [I18n.embeds.nowPlaying(track)] const info = await fetchInfo(query);
});
} if (info.length === 0) {
return undefined;
private async search(query: string, requester: User): Promise<Track[] | undefined> { }
const response = await fetchStream(query, {
dumpSingleJson: true, return info.map((value) => {
noWarnings: true, return { ...value, requester: requester };
noCheckCertificate: true, });
flatPlaylist: true, }
skipDownload: true,
defaultSearch: "ytsearch" public play(seek?: number): void {
}); const track = this.queue.current();
if (!response) { if (!this.connection_ || !track) {
return undefined; return;
} }
const tracks: Track[] = []; this.player_.play(this.resourceGenerator(track, seek));
if (response.entries) { this.connection_.subscribe(this.player_);
for (const entry of response.entries) {
tracks.push({ this.messageResolvable(track);
url: `https://youtube.com/watch?v=${entry.id}`, }
title: entry.title,
thumbnail: `https://img.youtube.com/vi/${entry.id}/default.jpg`, public connect(channel: VoiceBasedChannel): void {
duration: entry.duration, if (
requester: requester this.connection_?.joinConfig.channelId === channel.id &&
}); this.connection_.joinConfig.guildId === channel.guildId
} ) {
} else { return;
tracks.push({ }
url: `https://youtube.com/watch?v=${response.id}`,
title: response.title, if (this.connection_) {
thumbnail: `https://img.youtube.com/vi/${response.id}/default.jpg`, this.connection_.destroy();
duration: response.duration, }
requester: requester
}); this.connection_ = joinVoiceChannel({
} channelId: channel.id,
guildId: channel.guildId,
return tracks; adapterCreator: channel.guild.voiceAdapterCreator as DiscordGatewayAdapterCreator
} });
public play(seek?: number): void { this.connection_.subscribe(this.player_);
const track = this.queue.current(); }
if (!this.internalConnection || !track) { public async add(query: string, requester: User, index?: number): Promise<Track | undefined> {
return; const tracks = await this.search(query, requester);
}
if (!tracks) {
this.internalPlayer.play(this.resourceGenerator(track, seek)); return;
}
this.internalConnection.subscribe(this.internalPlayer);
this.queue.push(tracks, index);
this.messageResolvable(track);
} return tracks[0];
}
public connect(channel: VoiceBasedChannel): void {
if (this.internalConnection) { public jump(query: number | string): number | undefined {
this.internalConnection.destroy(); let position = typeof query === "number" ? query : -1;
} let diff = 0;
this.internalConnection = joinVoiceChannel({ if (typeof query === "string") {
channelId: channel.id, this.queue.tracks.forEach((track, index) => {
guildId: channel.guildId, const tempDiff = largestCommonSequence(track.title, query);
adapterCreator: channel.guild.voiceAdapterCreator as DiscordGatewayAdapterCreator if (diff < tempDiff) {
}); diff = tempDiff;
position = index;
this.internalConnection.on(VoiceConnectionStatus.Disconnected, async () => { }
try { });
await Promise.race([ }
entersState(
this.internalConnection as VoiceConnection, if (position !== -1 && this.queue.jump(position)) {
VoiceConnectionStatus.Signalling, this.play();
10_000 return position;
), }
entersState(
this.internalConnection as VoiceConnection, return undefined;
VoiceConnectionStatus.Connecting, }
10_000
) public remove(query?: number | string): Track | undefined {
]); const oldPosition = this.queue.position;
} catch (error) {
this.destroy(); let newPosition = typeof query === "number" ? query : this.queue.position;
} let diff = 0;
});
} if (typeof query === "string") {
this.queue.tracks.forEach((track, index) => {
public async add(query: string, requester: User, index?: number): Promise<Track | undefined> { const tempDiff = largestCommonSequence(track.title, query);
const tracks = await this.search(query, requester); if (diff < tempDiff) {
diff = tempDiff;
if (!tracks) { newPosition = index;
return; }
} });
}
this.queue.push(tracks, index);
const track = this.queue.pop(newPosition);
return tracks[0];
} if (track) {
if (oldPosition === newPosition) {
public jump(query: number | string): boolean { this.play();
let position = typeof query === "number" ? query : -1; }
let diff = 0; return track;
}
if (typeof query === "string") {
this.queue.tracks.forEach((track, index) => { return undefined;
const tempDiff = largestCommonSequence(track.title, query); }
if (diff < tempDiff) {
diff = tempDiff; public seek(time: number): void {
position = index; this.play(time);
} }
});
} public clear(): void {
this.queue.clear();
if (position !== -1 && this.queue.jump(position)) { this.resource_ = undefined;
this.play();
return true; if (this.player_) {
} this.player_.stop();
}
return false; }
}
public destroy(): void {
public remove(query?: number | string): Track | undefined { if (this.connection_) {
const oldPosition = this.queue.position; this.connection_.destroy();
}
let newPosition = typeof query === "number" ? query : this.queue.position;
let diff = 0; this.player_.stop();
this.music.delete(this.guild.id);
if (typeof query === "string") { }
this.queue.tracks.forEach((track, index) => {
const tempDiff = largestCommonSequence(track.title, query); public pause(): boolean {
if (diff < tempDiff) { if (this.player_.state.status === AudioPlayerStatus.Paused) {
diff = tempDiff; this.player_.unpause();
newPosition = index; return false;
} } else {
}); this.player_.pause();
} return true;
}
const track = this.queue.pop(newPosition); }
if (track) { public shuffle(): void {
if (oldPosition === newPosition) { this.queue.shuffle();
this.play();
} if (this.resource_) {
return track; this.player_.stop();
} this.play();
}
return undefined; }
}
public skip(): void {
public seek(time: number): void { this.player_.stop();
this.play(time); }
}
public setFilter(filter?: string): boolean {
public clear(): void { const status = this.filter.toggle(filter);
this.queue.clear();
this.playerResource = undefined; if (this.resource_) {
this.play(this.resource_.playbackDuration);
if (this.internalPlayer) { }
this.internalPlayer.stop();
} return status;
} }
}
public destroy(): void {
if (this.internalConnection) {
this.internalConnection.destroy();
}
this.internalPlayer.stop();
this.music.delete(this.guild.id);
}
public pause(): boolean {
if (this.internalPlayer.state.status === AudioPlayerStatus.Paused) {
this.internalPlayer.unpause();
return false;
} else {
this.internalPlayer.pause();
return true;
}
}
public shuffle(): void {
this.queue.shuffle();
if (this.playerResource) {
this.internalPlayer.stop();
this.play();
}
}
public skip(): void {
this.internalPlayer.stop();
}
public setFilter(filter?: string): void {
this.filter.toggle(filter);
if (this.playerResource) {
this.play(this.playerResource.playbackDuration);
}
}
}
+117 -117
View File
@@ -1,117 +1,117 @@
import { LoopOption, Track } from "./types.js"; import { LoopOption, Track } from "./types.js";
export default class Queue { export default class Queue {
private entries: Track[] = []; private tracks_: Track[] = [];
public get tracks(): Track[] { public get tracks(): Track[] {
return this.entries; return this.tracks_;
} }
private currentIndex = 0; private position_ = 0;
public get position(): number { public get position(): number {
return this.currentIndex; return this.position_;
} }
private loopStatus: LoopOption = "NONE"; private loop_: LoopOption = "NONE";
public get loop(): LoopOption { public get loop(): LoopOption {
return this.loopStatus; return this.loop_;
} }
public set loop(x: LoopOption) { public set loop(x: LoopOption) {
this.loopStatus = x; this.loop_ = x;
} }
private overallDuration = 0; private duration_ = 0;
public get duration(): number { public get duration(): number {
return this.overallDuration; return this.duration_;
} }
public next(): Track | undefined { public next(): Track | undefined {
if (this.loopStatus === "SONG") { if (this.loop_ === "SONG") {
return this.current(); return this.current();
} }
if (this.loopStatus === "AUTO") { if (this.loop_ === "AUTO") {
this.currentIndex = Math.floor(Math.random() * this.entries.length); this.position_ = Math.floor(Math.random() * this.tracks_.length);
return this.current(); return this.current();
} }
if (this.currentIndex + 1 < this.entries.length) { if (this.position_ + 1 < this.tracks_.length) {
this.currentIndex++; this.position_++;
return this.current(); return this.current();
} else if (this.loopStatus === "LOOP") { } else if (this.loop_ === "LOOP") {
this.currentIndex = 0; this.position_ = 0;
return this.current(); return this.current();
} else if (this.loopStatus === "NONE") { } else if (this.loop_ === "NONE") {
this.currentIndex = 0; this.position_ = 0;
return undefined; return undefined;
} }
} }
public shuffle(): void { public shuffle(): void {
let currentIndex = this.entries.length; let currentIndex = this.tracks_.length;
while (currentIndex != 0) { while (currentIndex != 0) {
const randomIndex = Math.floor(Math.random() * currentIndex); const randomIndex = Math.floor(Math.random() * currentIndex);
currentIndex--; currentIndex--;
[this.entries[currentIndex], this.entries[randomIndex]] = [ [this.tracks_[currentIndex], this.tracks_[randomIndex]] = [
this.entries[randomIndex], this.tracks_[randomIndex],
this.entries[currentIndex] this.tracks_[currentIndex]
]; ];
} }
this.currentIndex = 0; this.position_ = 0;
} }
public push(tracks: Track[], index?: number): Track { public push(tracks: Track[], index?: number): Track {
if (!index || index >= this.entries.length) { if (!index || index >= this.tracks_.length) {
this.entries = this.entries.concat(tracks); this.tracks_ = this.tracks_.concat(tracks);
} else { } else {
this.entries.splice(Math.max(0, index), 0, ...tracks); this.tracks_.splice(Math.max(0, index), 0, ...tracks);
if (this.currentIndex >= index) { if (this.position_ >= index) {
this.currentIndex += tracks.length; this.position_ += tracks.length;
} }
} }
for (const track of tracks) { for (const track of tracks) {
this.overallDuration += track.duration; this.duration_ += track.duration;
} }
return this.entries[index || this.entries.length - 1]; return this.tracks_[index || this.tracks_.length - 1];
} }
public pop(query: number): Track | undefined { public pop(query: number): Track | undefined {
if (query >= 0 && query < this.entries.length) { if (query >= 0 && query < this.tracks_.length) {
const track = this.entries[query]; const track = this.tracks_[query];
this.entries.splice(query, 1); this.tracks_.splice(query, 1);
this.currentIndex = Math.max(0, this.currentIndex - 1); this.position_ = Math.max(0, this.position_ - 1);
this.overallDuration -= track.duration; this.duration_ -= track.duration;
return track; return track;
} }
return undefined; return undefined;
} }
public jump(query: number): Track | undefined { public jump(query: number): Track | undefined {
if (query >= 0 && query < this.entries.length) { if (query >= 0 && query < this.tracks_.length) {
const track = this.entries[query]; const track = this.tracks_[query];
this.currentIndex = query; this.position_ = query;
return track; return track;
} }
return undefined; return undefined;
} }
public current(): Track { public current(): Track {
return this.entries[this.currentIndex]; return this.tracks_[this.position_];
} }
public empty(): boolean { public empty(): boolean {
return this.entries.length === 0; return this.tracks_.length === 0;
} }
public clear(): void { public clear(): void {
this.entries = []; this.tracks_ = [];
this.currentIndex = 0; this.position_ = 0;
} }
} }
+376
View File
@@ -0,0 +1,376 @@
import { AudioResource } from "@discordjs/voice";
import { BaseMessageOptions, EmbedBuilder, italic } from "discord.js";
import config from "../../../config.js";
import { loopMoji, statusBarGenerator, toISOTime, volumeMoji } from "./misc.js";
import Player from "./player.js";
import Queue from "./queue.js";
import { LoopOption, Track } from "./types.js";
const replies = {
notExists: (): BaseMessageOptions => {
const embed = new EmbedBuilder()
.setColor(config.colors.warning)
.setTitle("⚠️ Command ignored - player does not exist")
.setDescription("Use '/play' to create a new one.");
return { embeds: [embed] };
},
searching: (): BaseMessageOptions => {
const embed = new EmbedBuilder()
.setColor(config.colors.default)
.setTitle("🔍 Searching...")
.setDescription("It will take longer if playlist or non-youtube URL was queried.");
return { embeds: [embed] };
},
notPlaying: (): BaseMessageOptions => {
const embed = new EmbedBuilder()
.setColor(config.colors.default)
.setTitle("💿 Insert the disk...")
.setURL("https://www.youtube.com/watch?v=dQw4w9WgXcQ")
.setDescription("Use '/play' to start the playback.");
return { embeds: [embed] };
},
nowPlaying: (track: Track): BaseMessageOptions => {
const embed = new EmbedBuilder()
.setColor(config.colors.default)
.setAuthor({
iconURL: track.requester.avatarURL() ?? undefined,
name: track.requester.tag
})
.setTitle(track.title)
.setURL(track.url)
.setDescription("playing now.")
.setThumbnail(track.thumbnail);
return { embeds: [embed] };
},
okConnected: (channel: string): BaseMessageOptions => {
const embed = new EmbedBuilder()
.setColor(config.colors.success)
.setTitle(`✅ Successfuly connected to: ${italic(channel)}`)
.setDescription("Will leave after some time, if left alone.");
return { embeds: [embed] };
},
notConnected: (): BaseMessageOptions => {
const embed = new EmbedBuilder()
.setColor(config.colors.error)
.setTitle("❗ Cannot create new player.")
.setDescription("Make yourself connected to a voice channel beforehand.");
return { embeds: [embed] };
},
destroyed: (): BaseMessageOptions => {
const embed = new EmbedBuilder()
.setColor(config.colors.error)
.setTitle("💀 Destroying the player")
.setDescription("Everything is lost now. Use '/play' to create a new one.");
return { embeds: [embed] };
},
alone: (): BaseMessageOptions => {
const embed = new EmbedBuilder()
.setColor(config.colors.default)
.setTitle("🥬 I have decided to leave to conserve my energy")
.setDescription("Since there was nobody in the voice channel.");
return { embeds: [embed] };
},
paused: (state: boolean): BaseMessageOptions => {
let embed: EmbedBuilder;
if (state) {
embed = new EmbedBuilder()
.setColor(config.colors.success)
.setTitle("▶️ The playback was resumed");
} else {
embed = new EmbedBuilder()
.setColor(config.colors.warning)
.setTitle("⏸️ The playback was paused")
.setDescription("Invoke this command again to resume.");
}
return { embeds: [embed] };
},
seeked: (from: number, to: number): BaseMessageOptions => {
let embed: EmbedBuilder;
if (from < to) {
embed = new EmbedBuilder()
.setColor(config.colors.info)
.setTitle(`⏩ Fast-forwarding by ${toISOTime(to - from)}`);
} else {
embed = new EmbedBuilder()
.setColor(config.colors.info)
.setTitle(`⏪ Fast-reversing by ${toISOTime(from - to)}`);
}
embed.setDescription(
`Moving playback from ${italic(toISOTime(from))} to ${italic(toISOTime(to))}.`
);
return { embeds: [embed] };
},
looped: (option: LoopOption) => {
let embed: EmbedBuilder;
switch (option) {
case "AUTO": {
embed = new EmbedBuilder()
.setColor(config.colors.default)
.setTitle(`${loopMoji(option)} Shuffle mode.`)
.setDescription("Does not affect the queue order. Endless playback.");
break;
}
case "LOOP": {
embed = new EmbedBuilder()
.setColor(config.colors.info)
.setTitle(`${loopMoji(option)} Looping over the queue.`)
.setDescription("Will start over after final track. Endless playback.");
break;
}
case "SONG": {
embed = new EmbedBuilder()
.setColor(config.colors.warning)
.setTitle(`${loopMoji(option)} Looping a single track.`)
.setDescription("Endless playback of a single track. Over and over again...");
break;
}
case "NONE": {
embed = new EmbedBuilder()
.setColor(config.colors.error)
.setTitle(`${loopMoji(option)} Drop on final.`)
.setDescription(
"Will end playback after final track. Action will be needed to continue."
);
break;
}
}
return { embeds: [embed] };
},
reshuffle: (): BaseMessageOptions => {
const embed = new EmbedBuilder()
.setColor(config.colors.success)
.setTitle("✅ Successfuly reshuffled queue")
.setDescription("Use 'random' mode, if you want shuffle without reshuffle.");
return { embeds: [embed] };
},
skipped: (): BaseMessageOptions => {
const embed = new EmbedBuilder()
.setColor(config.colors.info)
.setTitle("⏭️ Skipping current track")
.setDescription("Behavior depends on the loop option.");
return { embeds: [embed] };
},
jumped: (previous?: Track, to?: number): BaseMessageOptions => {
if (!to) {
const embed = new EmbedBuilder()
.setColor(config.colors.error)
.setTitle("❗ Cannot perfome jump over the queue")
.setDescription("Either not found by query, or index is not in range.");
return { embeds: [embed] };
}
const embed = new EmbedBuilder()
.setColor(config.colors.success)
.setTitle(`✅ Jumping over the queue`);
if (previous) {
embed.setDescription(`Switching from ${italic(previous.title)}.`);
} else {
embed.setDescription("Playback will start again.");
}
return { embeds: [embed] };
},
appended: (track?: Track): BaseMessageOptions => {
let embed: EmbedBuilder;
if (track) {
embed = new EmbedBuilder()
.setColor(config.colors.success)
.setAuthor({
iconURL: track.requester.avatarURL() ?? undefined,
name: track.requester.tag
})
.setTitle(track.title)
.setURL(track.url)
.setDescription("has been added to the queue.")
.setThumbnail(track.thumbnail)
.setTimestamp();
} else {
embed = new EmbedBuilder()
.setColor(config.colors.error)
.setTitle("❗ Cannot add track to the queue")
.setDescription("Either not found by query, or URL is broken.");
}
return { embeds: [embed] };
},
removed: (track?: Track): BaseMessageOptions => {
let embed: EmbedBuilder;
if (track) {
embed = new EmbedBuilder()
.setColor(config.colors.success)
.setTitle(track.title)
.setURL(track.url)
.setDescription("was removed from the queue.")
.setThumbnail(track.thumbnail)
.setTimestamp();
} else {
embed = new EmbedBuilder()
.setColor(config.colors.error)
.setTitle("❗ Cannot delete track from queue")
.setDescription("Either not found by query, or index is not in range.");
}
return { embeds: [embed] };
},
cleared: (amount: number): BaseMessageOptions => {
const embed = new EmbedBuilder()
.setColor(config.colors.success)
.setTitle(`♻️ Queue has been cleared`)
.setDescription(
`${italic(amount.toString())} track${amount > 1 ? "s were" : " was"} deleted.`
);
return { embeds: [embed] };
},
filtered: (filter?: string, status = true): BaseMessageOptions => {
let embed: EmbedBuilder;
if (filter) {
embed = new EmbedBuilder().setTitle(
`✅ ${italic(filter)} was ${status ? "applied to the playback" : "declined"}.`
);
} else {
embed = new EmbedBuilder().setTitle("♻️ All filters were declined");
}
embed
.setColor(config.colors.success)
.setDescription("Playback can experience some lags during intial transformation.");
return { embeds: [embed] };
},
volumeSet: (amount: number): BaseMessageOptions => {
const embed = new EmbedBuilder()
.setColor(config.colors.success)
.setTitle(`${volumeMoji(amount)} Volume has been set to ${amount}%`)
.setDescription("This will affect everyone listening.");
return { embeds: [embed] };
},
queue: (queue: Queue, position = 0, resource?: AudioResource<Track>): BaseMessageOptions => {
if (queue.empty()) {
const embed = new EmbedBuilder()
.setColor(config.colors.warning)
.setTitle("⚠️ Queue is empty")
.setDescription("Use '/play' to add some tracks.");
return { embeds: [embed] };
}
let embed: EmbedBuilder;
if (resource) {
embed = new EmbedBuilder()
.setTitle(`${queue.position + 1}. ${resource.metadata.title}`)
.setAuthor({
iconURL: resource.metadata.requester.avatarURL() ?? undefined,
name: resource.metadata.requester.tag
})
.setURL(resource.metadata.url)
.setDescription(statusBarGenerator(resource))
.setThumbnail(resource.metadata.thumbnail);
} else {
embed = new EmbedBuilder()
.setTitle("💿 Insert the disk...")
.setURL("https://www.youtube.com/watch?v=5mGuCdlCcNM&ab_channel=Ra%C3%BAlBlanco")
.setDescription(statusBarGenerator())
.setThumbnail("https://img.youtube.com/vi/5mGuCdlCcNM/default.jpg");
}
embed.setColor(config.colors.info).setFields(
queue.tracks.slice(position, position + 10).map((value, index) => ({
name: `${position + index + 1}. ${value.title.padEnd(35)}`,
value: `[${toISOTime(value.duration)}] | ${value.requester.tag}`
}))
);
return { embeds: [embed] };
},
info: (player: Player): BaseMessageOptions => {
let embed: EmbedBuilder;
if (player.resource) {
embed = new EmbedBuilder()
.setTitle(`${player.queue.position + 1}. ${player.resource.metadata.title}`)
.setAuthor({
iconURL: player.resource.metadata.requester.avatarURL() ?? undefined,
name: player.resource.metadata.requester.tag
})
.setURL(player.resource.metadata.url)
.setDescription(statusBarGenerator(player.resource))
.setThumbnail(player.resource.metadata.thumbnail);
} else {
embed = new EmbedBuilder()
.setTitle("💿 Insert the disk...")
.setURL("https://www.youtube.com/watch?v=dQw4w9WgXcQ")
.setDescription(statusBarGenerator());
}
embed.setColor(config.colors.info).addFields([
{
name: "Applied Filters",
value:
player.filter
.toggled()
.map((value) => value[0])
.join("\n") + "\ndefault",
inline: true
},
{
name: "Queue Length",
value: `🔢 ${player.queue.tracks.length}`,
inline: true
},
{
name: "Queue Duration",
value: `💿 ${toISOTime(player.queue.duration)}`,
inline: true
},
{
name: "Queue Looping",
value: `${loopMoji(player.queue.loop)} ${player.queue.loop}`,
inline: true
},
{
name: "Volume Amount",
value: `${volumeMoji(player.volume)} ${player.volume}/150`,
inline: true
},
{
name: "Player Lifetime",
value: `🕓 ${toISOTime((Date.now() - player.createdAt.getTime()) / 1000)}`,
inline: true
}
]);
return { embeds: [embed] };
}
};
export default replies;
+261 -261
View File
@@ -1,261 +1,261 @@
import { User } from "discord.js"; import { User } from "discord.js";
export interface Track { export interface Track {
url: string; url: string;
title: string; title: string;
thumbnail: string; thumbnail: string;
duration: number; duration: number;
requester: User; requester: User;
} }
export type LoopOption = "LOOP" | "SONG" | "NONE" | "AUTO"; export type LoopOption = "LOOP" | "SONG" | "NONE" | "AUTO";
export interface YtdlArgs { export interface YtdlArgs {
help?: boolean; help?: boolean;
version?: boolean; version?: boolean;
update?: boolean; update?: boolean;
ignoreErrors?: boolean; ignoreErrors?: boolean;
abortOnError?: boolean; abortOnError?: boolean;
dumpUserAgent?: boolean; dumpUserAgent?: boolean;
listExtractors?: boolean; listExtractors?: boolean;
extractorDescriptions?: boolean; extractorDescriptions?: boolean;
forceGenericExtractor?: boolean; forceGenericExtractor?: boolean;
defaultSearch?: string; defaultSearch?: string;
igonreConfig?: boolean; igonreConfig?: boolean;
configLocation?: string; configLocation?: string;
flatPlaylist?: boolean; flatPlaylist?: boolean;
markWatched?: boolean; markWatched?: boolean;
noColor?: boolean; noColor?: boolean;
proxy?: string; proxy?: string;
socketTimeout?: number; socketTimeout?: number;
sourceAddress?: string; sourceAddress?: string;
forceIpv4?: boolean; forceIpv4?: boolean;
forceIpv6?: boolean; forceIpv6?: boolean;
geoVerificationProxy?: string; geoVerificationProxy?: string;
geoBypass?: boolean; geoBypass?: boolean;
geoBypassCountry?: string; geoBypassCountry?: string;
geoBypassIpBlock?: string; geoBypassIpBlock?: string;
playlistStart?: number; playlistStart?: number;
playlistEnd?: number | "last"; playlistEnd?: number | "last";
playlistItems?: string; playlistItems?: string;
matchTitle?: string; matchTitle?: string;
rejectTitle?: string; rejectTitle?: string;
maxDownloads?: number; maxDownloads?: number;
minFilesize?: string; minFilesize?: string;
maxFilesize?: string; maxFilesize?: string;
date?: string; date?: string;
datebefore?: string; datebefore?: string;
dateafter?: string; dateafter?: string;
minViews?: number; minViews?: number;
maxViews?: number; maxViews?: number;
matchFilter?: string; matchFilter?: string;
noPlaylist?: boolean; noPlaylist?: boolean;
yesPlaylist?: boolean; yesPlaylist?: boolean;
ageLimit?: number; ageLimit?: number;
downloadArchive?: string; downloadArchive?: string;
includeAds?: boolean; includeAds?: boolean;
limitRate?: string; limitRate?: string;
retries?: number | "infinite"; retries?: number | "infinite";
skipUnavailableFragments?: boolean; skipUnavailableFragments?: boolean;
abortOnUnavailableFragment?: boolean; abortOnUnavailableFragment?: boolean;
keepFragments?: boolean; keepFragments?: boolean;
bufferSize?: string; bufferSize?: string;
noResizeBuffer?: boolean; noResizeBuffer?: boolean;
httpChunkSize?: string; httpChunkSize?: string;
playlistReverse?: boolean; playlistReverse?: boolean;
playlistRandom?: boolean; playlistRandom?: boolean;
xattrSetFilesize?: boolean; xattrSetFilesize?: boolean;
hlsPreferNative?: boolean; hlsPreferNative?: boolean;
hlsPreferFfmpeg?: boolean; hlsPreferFfmpeg?: boolean;
hlsUseMpegts?: boolean; hlsUseMpegts?: boolean;
externalDownloader?: string; externalDownloader?: string;
externalDownloaderArgs?: string; externalDownloaderArgs?: string;
batchFile?: string; batchFile?: string;
id?: boolean; id?: boolean;
output?: string; output?: string;
outputNaPlaceholder?: string; outputNaPlaceholder?: string;
autonumberStart?: number; autonumberStart?: number;
restrictFilenames?: boolean; restrictFilenames?: boolean;
noOverwrites?: boolean; noOverwrites?: boolean;
continue?: boolean; continue?: boolean;
noPart?: boolean; noPart?: boolean;
noMtime?: boolean; noMtime?: boolean;
writeDescription?: boolean; writeDescription?: boolean;
writeInfoJson?: boolean; writeInfoJson?: boolean;
writeAnnotations?: boolean; writeAnnotations?: boolean;
loadInfoJson?: string; loadInfoJson?: string;
cookies?: string; cookies?: string;
cacheDir?: string; cacheDir?: string;
noCacheDir?: boolean; noCacheDir?: boolean;
rmCacheDir?: boolean; rmCacheDir?: boolean;
writeThumbnail?: boolean; writeThumbnail?: boolean;
writeAllThumbnails?: boolean; writeAllThumbnails?: boolean;
listThumbnails?: boolean; listThumbnails?: boolean;
quiet?: boolean; quiet?: boolean;
noWarnings?: boolean; noWarnings?: boolean;
simulate?: boolean; simulate?: boolean;
skipDownload?: boolean; skipDownload?: boolean;
getUrl?: boolean; getUrl?: boolean;
getTitle?: boolean; getTitle?: boolean;
getId?: boolean; getId?: boolean;
getThumbnail?: boolean; getThumbnail?: boolean;
getDuration?: boolean; getDuration?: boolean;
getFilename?: boolean; getFilename?: boolean;
getFormat?: boolean; getFormat?: boolean;
dumpJson?: boolean; dumpJson?: boolean;
dumpSingleJson?: boolean; dumpSingleJson?: boolean;
printJson?: boolean; printJson?: boolean;
newline?: boolean; newline?: boolean;
noProgress?: boolean; noProgress?: boolean;
consoleTitle?: boolean; consoleTitle?: boolean;
verbose?: boolean; verbose?: boolean;
dumpPages?: boolean; dumpPages?: boolean;
writePages?: boolean; writePages?: boolean;
printTraffic?: boolean; printTraffic?: boolean;
callHome?: boolean; callHome?: boolean;
encoding?: string; encoding?: string;
noCheckCertificate?: boolean; noCheckCertificate?: boolean;
preferInsecure?: boolean; preferInsecure?: boolean;
userAgent?: string; userAgent?: string;
referer?: string; referer?: string;
addHeader?: string; addHeader?: string;
bidiWorkaround?: boolean; bidiWorkaround?: boolean;
sleepInterval?: number; sleepInterval?: number;
maxSleepInterval?: number; maxSleepInterval?: number;
format?: string; format?: string;
allFormats?: boolean; allFormats?: boolean;
preferFreeFormats?: boolean; preferFreeFormats?: boolean;
listFormats?: boolean; listFormats?: boolean;
youtubeSkipDashManifest?: boolean; youtubeSkipDashManifest?: boolean;
mergeOutputFormat?: string; mergeOutputFormat?: string;
writeSub?: boolean; writeSub?: boolean;
writeAutoSub?: boolean; writeAutoSub?: boolean;
allSubs?: boolean; allSubs?: boolean;
listSubs?: boolean; listSubs?: boolean;
subFormat?: string; subFormat?: string;
subLang?: string; subLang?: string;
username?: string; username?: string;
password?: string; password?: string;
twofactor?: string; twofactor?: string;
netrc?: boolean; netrc?: boolean;
videoPassword?: string; videoPassword?: string;
apMso?: string; apMso?: string;
apUsername?: string; apUsername?: string;
apPassword?: string; apPassword?: string;
apListMso?: boolean; apListMso?: boolean;
extractAudio?: boolean; extractAudio?: boolean;
audioFormat?: string; audioFormat?: string;
audioQuality?: number; audioQuality?: number;
recodeVideo?: string; recodeVideo?: string;
postprocessorArgs?: string; postprocessorArgs?: string;
keepVideo?: boolean; keepVideo?: boolean;
noPostOverwrites?: boolean; noPostOverwrites?: boolean;
embedSubs?: boolean; embedSubs?: boolean;
embedThumbnail?: boolean; embedThumbnail?: boolean;
addMetadata?: boolean; addMetadata?: boolean;
metadataFromFile?: string; metadataFromFile?: string;
xattrs?: boolean; xattrs?: boolean;
fixup?: string; fixup?: string;
preferAvconv?: boolean; preferAvconv?: boolean;
preferFfmpeg?: boolean; preferFfmpeg?: boolean;
ffmpegLocation?: string; ffmpegLocation?: string;
exec?: string; exec?: string;
convertSubs?: string; convertSubs?: string;
} }
interface YtdlFormat { interface YtdlFormat {
asr: number; asr: number;
filesize: number; filesize: number;
format_id: string; format_id: string;
format_note: string; format_note: string;
fps: number; fps: number;
height: number; height: number;
quality: number; quality: number;
tbr: number; tbr: number;
url: string; url: string;
width: number; width: number;
ext: string; ext: string;
vcodec: string; vcodec: string;
acodec: string; acodec: string;
abr: number; abr: number;
downloader_options: unknown; downloader_options: unknown;
container: string; container: string;
format: string; format: string;
protocol: string; protocol: string;
http_headers: unknown; http_headers: unknown;
} }
interface YtdlThumbnail { interface YtdlThumbnail {
height: number; height: number;
url: string; url: string;
width: number; width: number;
resolution: string; resolution: string;
id: string; id: string;
} }
interface YtdlEntry { interface YtdlEntry {
uploader: string; uploader: string;
duration: number; duration: number;
id: string; id: string;
title: string; title: string;
url: string; url: string;
} }
export interface YtdlResponse { export interface YtdlResponse {
id: string; id: string;
title: string; title: string;
formats: YtdlFormat[]; formats: YtdlFormat[];
thumbnails: YtdlThumbnail[]; thumbnails: YtdlThumbnail[];
description: string; description: string;
upload_date: string; upload_date: string;
uploader: string; uploader: string;
uploader_id: string; uploader_id: string;
uploader_url: string; uploader_url: string;
channel_id: string; channel_id: string;
channel_url: string; channel_url: string;
duration: number; duration: number;
view_count: number; view_count: number;
average_rating: number; average_rating: number;
age_limit: number; age_limit: number;
webpage_url: string; webpage_url: string;
categories: string[]; categories: string[];
tags: string[]; tags: string[];
is_live: boolean; is_live: boolean;
like_count: number; like_count: number;
dislike_count: number; dislike_count: number;
channel: string; channel: string;
track: string; track: string;
artist: string; artist: string;
creator: string; creator: string;
alt_title: string; alt_title: string;
extractor: string; extractor: string;
webpage_url_basename: string; webpage_url_basename: string;
extractor_key: string; extractor_key: string;
playlist: string; playlist: string;
playlist_index: number; playlist_index: number;
thumbnail: string; thumbnail: string;
display_id: string; display_id: string;
requested_subtitles: unknown; requested_subtitles: unknown;
asr: number; asr: number;
filesize: number; filesize: number;
format_id: string; format_id: string;
format_note: string; format_note: string;
fps: number; fps: number;
height: number; height: number;
quality: number; quality: number;
tbr: number; tbr: number;
url: string; url: string;
width: number; width: number;
ext: string; ext: string;
vcodec: string; vcodec: string;
acodec: string; acodec: string;
abr: number; abr: number;
downloader_options: { http_chunk_size: number }; downloader_options: { http_chunk_size: number };
container: string; container: string;
format: string; format: string;
protocol: string; protocol: string;
http_headers: unknown; http_headers: unknown;
fulltitle: string; fulltitle: string;
_filename: string; _filename: string;
entries: YtdlEntry[]; entries: YtdlEntry[];
} }
+15 -15
View File
@@ -1,15 +1,15 @@
import path from "path"; import path from "path";
import { fileURLToPath } from "url"; import { fileURLToPath } from "url";
import Music from "./lib/music.js"; import Music from "./lib/music.js";
const name = "music"; const name = "music";
const description = "Music module"; const description = "Music module";
const author = "unknowableshade"; const author = "unknowableshade";
const tag = "2302.1"; const tag = "2.0.0";
const controller = new Music(); const controller = new Music();
const rootDir = path.dirname(fileURLToPath(import.meta.url)); const rootDir = path.dirname(fileURLToPath(import.meta.url));
export { author, controller, description, name, rootDir, tag }; export { author, controller, description, name, rootDir, tag };
+90
View File
@@ -0,0 +1,90 @@
import { Events, Interaction, PermissionFlagsBits, SlashCommandBuilder } from "discord.js";
import Client from "../../../lib/client.js";
import logger from "../../../lib/logger.js";
import replies from "../lib/replies.js";
async function execute(client: Client, interaction: Interaction): Promise<void> {
if (!interaction.isChatInputCommand() || !interaction.inCachedGuild()) {
logger.warn("Interaction rejected.", { interaction });
return;
}
const subcommand = interaction.options.getSubcommand();
const event = interaction.options.getString("event", true);
switch (subcommand) {
case "role": {
const role = interaction.options.getRole("role", true);
await interaction.deferReply();
try {
await client.db.event.upsert({
where: {
eventIdentifier: {
title: event,
action: subcommand,
guildId: interaction.guildId
}
},
update: { metadata: [role.id] },
create: {
Guild: {
connectOrCreate: {
where: {
id: interaction.guildId
},
create: {
id: interaction.guildId
}
}
},
title: event,
action: subcommand,
metadata: [role.id]
}
});
await interaction.editReply(replies.auto("add role to a member", event));
} catch (error) {
logger.error("Error occured while quering db.", { interaction });
} finally {
await client.db.$disconnect();
}
return;
}
}
}
const AUTO_EVENT_CHOICES = [
{
name: "Member Joins",
value: Events.GuildMemberAdd
}
];
const data = new SlashCommandBuilder()
.setName("auto")
.setDescription("Quetza's automatization engine.")
.addSubcommand((subcommand) =>
subcommand
.setName("role")
.setDescription("Auto: give role.")
.addStringOption((option) =>
option
.setName("event")
.setDescription("Event when perfome an action.")
.setChoices(...AUTO_EVENT_CHOICES)
.setRequired(true)
)
.addRoleOption((option) =>
option.setName("role").setDescription("Role to grant on event.").setRequired(true)
)
)
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator)
.setDMPermission(false);
export { data, execute };
+98
View File
@@ -0,0 +1,98 @@
import { Interaction, PermissionFlagsBits, SlashCommandBuilder } from "discord.js";
import Client from "../../../lib/client.js";
import logger from "../../../lib/logger.js";
import replies from "../lib/replies.js";
async function execute(client: Client, interaction: Interaction): Promise<void> {
if (!interaction.isChatInputCommand() || !interaction.inCachedGuild()) {
logger.warn("Interaction rejected.", { interaction });
return;
}
const subcommand = interaction.options.getSubcommand();
switch (subcommand) {
case "check": {
const user = interaction.options.getUser("member", true);
const member = await interaction.guild.members.fetch(user).catch(() => undefined);
if (member) {
await interaction.reply(replies.roleList(user.tag, member.roles.cache));
} else {
await interaction.reply(replies.notMember("edited", user));
}
return;
}
case "edit": {
const user = interaction.options.getUser("member", true);
const role = interaction.options.getRole("role", true);
const reason = interaction.options.getString("reason") ?? undefined;
const member = await interaction.guild.members.fetch(user).catch(() => undefined);
if (member) {
if (member.roles.cache.some((memberRole) => role.id === memberRole.id)) {
await member.roles.remove(role.id, reason);
await interaction.reply(
replies.roleAction("revoked from", interaction.user, role.name, reason)
);
} else {
await member.roles.add(role.id, reason);
await interaction.reply(
replies.roleAction("granted to", interaction.user, role.name, reason)
);
}
} else {
await interaction.reply(replies.notMember("edited", user));
}
return;
}
case "list": {
const roles = await interaction.guild.roles.fetch();
await interaction.reply(replies.roleList(interaction.guild.name, roles));
return;
}
}
}
const data = new SlashCommandBuilder()
.setName("role")
.setDescription("Quetza's role manager.")
.addSubcommand((subcommand) =>
subcommand.setName("list").setDescription("Role: list guild's roles.")
)
.addSubcommand((subcommand) =>
subcommand
.setName("check")
.setDescription("Role: check user roles.")
.addUserOption((option) =>
option.setName("member").setDescription("Member to check.").setRequired(true)
)
)
.addSubcommand((subcommand) =>
subcommand
.setName("edit")
.setDescription("Role: edit role for a specific user.")
.addUserOption((option) =>
option.setName("member").setDescription("Member to edit.").setRequired(true)
)
.addRoleOption((option) =>
option.setName("role").setDescription("Member's role to edit.").setRequired(true)
)
.addStringOption((option) =>
option.setName("reason").setDescription("Reason for an action.")
)
)
.setDefaultMemberPermissions(PermissionFlagsBits.ManageRoles)
.setDMPermission(false);
export { data, execute };
@@ -0,0 +1,23 @@
import { Events, GuildMember } from "discord.js";
import Client from "../../../lib/client.js";
async function execute(client: Client, eventee: [GuildMember]): Promise<void> {
const [member] = eventee;
try {
const event = await client.db.event.findFirst({
where: { guildId: member.guild.id, title: name }
});
if (event) {
await member.roles.add(event.metadata as string[]);
}
} finally {
await client.db.$disconnect();
}
}
const name = Events.GuildMemberAdd;
export { execute, name };
+80
View File
@@ -0,0 +1,80 @@
import {
BaseMessageOptions,
bold,
Collection,
EmbedBuilder,
InteractionReplyOptions,
italic,
Role,
underscore,
User
} from "discord.js";
import config from "../../../config.js";
const replies = {
error: (): InteractionReplyOptions => {
const embed = new EmbedBuilder()
.setColor(config.colors.error)
.setTitle("Internal error occured while resolving interaction.")
.setDescription("Submit an issue (bio). Logs were saved.")
.setTimestamp();
return { embeds: [embed], ephemeral: true };
},
notMember: (action: string, user: User): InteractionReplyOptions => {
const embed = new EmbedBuilder()
.setColor("Blue")
.setTitle(`${user.tag} cannot be ${action}`)
.setDescription("They're not a guild member.")
.setTimestamp();
return { embeds: [embed], ephemeral: true };
},
roleAction: (
action: string,
user: User,
roleName: string,
reason?: string
): InteractionReplyOptions => {
const embed = new EmbedBuilder()
.setColor(config.colors.success)
.setTitle(`Role ${roleName} was ${action} user ${user.tag}`)
.setDescription(reason ?? "No reason provided.")
.setTimestamp();
return { embeds: [embed], ephemeral: true };
},
roleList: (observed: string, roles: Collection<string, Role>): InteractionReplyOptions => {
const embed = new EmbedBuilder()
.setColor(config.colors.success)
.setTitle(`${observed} roles:`)
.addFields(
roles.map((role) => {
return {
name: role.name,
value: italic(role.id)
};
})
)
.setTimestamp();
return { embeds: [embed], ephemeral: true };
},
auto: (action: string, event: string): BaseMessageOptions => {
const embed = new EmbedBuilder()
.setColor(config.colors.success)
.setTitle(
bold(
`From now I will ${underscore(italic(action))} when ${underscore(
italic(event)
)} event happens`
)
)
.setTimestamp();
return { embeds: [embed] };
}
};
export default replies;
+11
View File
@@ -0,0 +1,11 @@
import path from "path";
import { fileURLToPath } from "url";
const name = "server";
const description = "Server utilities module";
const author = "unknowableshade";
const tag = "0.0.1";
const rootDir = path.dirname(fileURLToPath(import.meta.url));
export { author, description, name, rootDir, tag };
+19 -19
View File
@@ -1,19 +1,19 @@
{ {
"compilerOptions": { "compilerOptions": {
"strict": true, "strict": true,
"module": "ESNext", "module": "ESNext",
"moduleResolution": "node", "moduleResolution": "node",
"outDir": "dist", "outDir": "dist",
"removeComments": false, "removeComments": false,
"sourceMap": true, "sourceMap": true,
"esModuleInterop": true, "esModuleInterop": true,
"target": "ESNext", "target": "ESNext",
"lib": ["ESNext"], "lib": ["ESNext"],
"types": ["node"], "types": ["node"],
"skipLibCheck": true "skipLibCheck": true
}, },
"include": ["src/**/*"], "include": ["src/**/*"],
"exclude": ["node_modules", "dist", "src/commands", "src/events"] "exclude": ["node_modules", "dist", "src/commands", "src/events"]
} }