Refactoring, comments & overall improvements

This commit is contained in:
deytenit committed 2024-01-30 14:31:15 +03:00
1 parent d7421251ba
commit 3144dd69b7
60 files changed
+1106 -708

No files matched your search

+5 -2
View File
@@ -5,12 +5,15 @@
!Dockerfile
# Source
!config.ts
!src/
!prisma/
!modules/
# Dependencies
!package.json
!pnpm-lock.yaml
# Typescript compiller config
# Build
!build.sh
!config.tsconfig.json
!tsconfig.json
+21 -28
View File
@@ -1,6 +1,7 @@
FROM node:18-alpine as builder
FROM node:20-alpine as builder
RUN apk add --no-cache python3
RUN apk add --no-cache curl
RUN apk add --no-cache make
RUN apk add --no-cache gcc
RUN apk add --no-cache build-base
@@ -17,35 +18,27 @@ COPY . .
RUN pnpm run build
FROM node:20-alpine
RUN apk update
RUN apk add --no-cache bash
RUN apk add --no-cache curl
RUN apk add --no-cache ffmpeg
RUN apk add --no-cache python3
RUN apk add --no-cache make
RUN apk add --no-cache gcc
RUN apk add --no-cache build-base
RUN npm install -g pnpm
ENV NODE_ENV production
USER node
WORKDIR /usr/src/app
RUN mkdir bin
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/yt-dlp
COPY prisma/ .
COPY package.json pnpm-lock.yaml ./
RUN mkdir ./dist/bin/
RUN curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /usr/src/app/dist/bin/yt-dlp
RUN chmod a+rx /usr/src/app/dist/bin/yt-dlp
RUN pnpm install --prod
RUN pnpx prisma generate
COPY --from=builder /usr/src/app/dist ./dist
FROM node:20-alpine
CMD [ "pnpm", "start" ]
RUN apk update
RUN apk add --no-cache ffmpeg
RUN apk add --no-cache python3
USER node
ENV NODE_ENV production
WORKDIR /usr/src/app
COPY package.json ./
COPY --from=builder /usr/src/app/node_modules ./node_modules
COPY --from=builder /usr/src/app/dist ./
CMD [ "node", "./src/index.js" ]
+4 -14
View File
@@ -35,7 +35,8 @@ modules/example/
interface Module {
name: string; // Name of the module.
description?: string; // Description of the module.
controller?: unknown; // The service orchestrating the module. (use it in commands or events)
controller?: unknown;
// The service orchestrating the module. (passed as third argument to command or event executor)
}
```
@@ -49,7 +50,7 @@ Each **command** has to be defined in its own file, preferably named after **the
```ts
interface Command {
data: ApplicationCommandData; // Definition of command specifically for the Discord API.
execute: (client: Client, interaction: CommandInteraction) => Promise<void>;
execute: (client: Client, interaction: CommandInteraction, controller?: unknown) => Promise<void>;
// Function that will be called upon interaction invocation.
}
```
@@ -66,7 +67,7 @@ bound to **them** by **modules**, despite being declared similarly to **commands
```ts
export interface Event {
name: string;
execute: (client: Client, eventee: unknown[]) => Promise<void>;
execute: (client: Client, eventee: unknown[], controller?: unknown) => Promise<void>;
}
```
@@ -97,7 +98,6 @@ that shall be located at _bin/_ directory or be accessible from _the environment
| **Base/Module** | **Dependency** |
| --------------- | ------------------------------------------ |
| Base | [postgres](https://www.postgresql.org/) |
| Music | [yt-dlp](https://github.com/yt-dlp/yt-dlp) |
### Shipping
@@ -113,7 +113,6 @@ provided by the respective
```bash
docker run \
-e DATABASE_URL=*as required by prisma - postgres connection url* \
-e DISCORD_TOKEN=*aquired via Discord Developer Portal* \
--name quetza -d \
unknowableshade/quetza-bot:latest
@@ -123,12 +122,6 @@ unknowableshade/quetza-bot:latest
Although it is possible to run it directly from the source, some additional steps are required.
- Push _SQL migrations_ using _prisma_ CLI (that will also generate _client_)
```bash
pnpx prisma migrate deploy
```
- Compile _TypeScript_
```bash
@@ -148,9 +141,6 @@ In any case, you have to provide **environmental variables** for **bot** to run.
- **DISCORD_TOKEN**: generated token issued by **Discord** itself at
**[Developer Portal](https://discord.com/developers/applications)**.
- **DATABASE_URL**: _postgres_ connection URL,
if _prisma_ database provider has not been changed.
## Licence
**Quetza's** source code is licensed under **[the MIT Licence](,/LICENSE)**.
Executable
+9
View File
@@ -0,0 +1,9 @@
#!/bin/sh
# Build typescript
pnpm exec tsc --build
# Map path aliases from tsconfigs
for TS_CONFIG in ./config.tsconfig.json ./src/tsconfig.json ./modules/*/tsconfig.json; do
pnpm exec tsc-alias -p "$TS_CONFIG"
done
+58
View File
@@ -0,0 +1,58 @@
import { ActivityOptions, ActivityType, ColorResolvable } from "discord.js";
import { dirname, join } from "path";
import { fileURLToPath } from "url";
/** Quetza project root directory. */
const rootDir = dirname(fileURLToPath(import.meta.url));
/** Quetza's modules directory. */
const modulesDir = join(rootDir, "/modules/");
/** External binaries directory. */
const binariesDir = join(rootDir, "/bin/");
/** Logger files directory. */
const loggerDir = join(rootDir, "/log/");
/** Locations that might be useful in application. */
const path = {
root: rootDir,
modules: modulesDir,
binaries: binariesDir,
log: loggerDir
};
/** Discord bot token. */
const appToken = process.env.DISCORD_TOKEN;
/** Discord bot user activity status */
const appActivity = { type: ActivityType.Watching, name: "over you" };
/** Bot application configuration variables. */
const application = {
token: appToken,
activity: appActivity as ActivityOptions
};
/** Colors Quetza uses in responses. */
const colors = {
default: "#ff6e9b" as ColorResolvable,
error: "#b94e59" as ColorResolvable,
warning: "#e5925f" as ColorResolvable,
info: "#367df6" as ColorResolvable,
success: "#83d18e" as ColorResolvable
};
/** Guild where testing occures. */
const testGuild = "912401672939139142";
/** Developing options. */
const dev = {
guild: testGuild
};
/** Exported bundled configuration object. */
const config = {
application,
colors,
path,
dev
};
export default config;
@@ -1,7 +1,8 @@
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
import Client from "../../../lib/client.js";
import logger from "../../../lib/logger.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: ChatInputCommandInteraction): Promise<void> {
@@ -1,13 +1,11 @@
import { ChatInputCommandInteraction, SlashCommandBuilder } 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";
async function execute(client: Client, interaction: ChatInputCommandInteraction): Promise<void> {
if (!interaction.isChatInputCommand()) {
logger.warn("Interaction rejected.", { interaction });
return;
}
+12
View File
@@ -0,0 +1,12 @@
{
"CR1001": {
"id": 652627557,
"title": "Imported Modules",
"color": 6412972,
"fields": [],
"footer": {
"text": "If there is a bug - submit it"
},
"timestamp": "2024-01-18T21:00:00.000Z"
}
}
+42
View File
@@ -0,0 +1,42 @@
import { Events, Interaction } from "discord.js";
import Client from "$lib/client.js";
import logger from "$lib/logger.js";
/**
* Resolves creation of an interaction by users.
*
* @remark Only {@link ChatInputCommand} currently being resolved.
*
* @param client - Quetza client.
* @param eventee - List containing invoked interaction.
*/
async function execute(client: Client, eventee: [Interaction]): Promise<void> {
const [interaction] = eventee;
if (!interaction.isChatInputCommand()) {
logger.notice("Interaction is not a ChatInputCommand.");
return;
}
const command = client.commands.get(interaction.commandName);
if (!command) {
logger.notice("Interaction does not exist.", interaction);
return;
}
try {
logger.info("Interaction was created.", interaction);
await command.execute(client, interaction, command.module.controller);
} catch (error) {
logger.error("Interaction has and error occured.", error, interaction);
}
}
/** Event name. */
const name = Events.InteractionCreate;
export { execute, name };
+48
View File
@@ -0,0 +1,48 @@
import { generateDependencyReport } from "@discordjs/voice";
import { Events } from "discord.js";
import config from "$config.js";
import Client from "$lib/client.js";
import logger from "$lib/logger.js";
/**
* Function that will execute upon emitting Client's Ready event.
*
* @remarks Will push slash commands, set activity status,
* and log bot's initials.
*
* @param client - Quetza client.
*/
async function execute(client: Client<true>): Promise<void> {
const commandData = client.commands.map((value) => value.data);
// To not reload bot's global slash commands at every dev build.
if (process.env.NODE_ENV === "development") {
logger.info(
"Development mode: Commands will be pushed to the _test guild_!",
client.generateApplicationStatus()
);
logger.info("Dependency report by '@discordjs/voice'.", {
report: generateDependencyReport()
});
const guild = await client.guilds.fetch(config.dev.guild);
guild.commands.set(commandData);
}
if (process.env.NODE_ENV === "production") {
logger.info(
"Production mode: Commands will be pushed to the _application_!",
client.generateApplicationStatus()
);
await client.application.commands.set(commandData);
}
client.user.setActivity(config.application.activity);
}
/** Event name. */
const name = Events.ClientReady;
export { execute, name };
@@ -1,7 +1,7 @@
import { BaseMessageOptions, Collection, EmbedBuilder, italic } from "discord.js";
import config from "../../../config.js";
import { Module } from "../../../lib/types.js";
import config from "$config.js";
import { Module } from "$lib/types.js";
const replies = {
modules: (modules: Collection<string, Module>): BaseMessageOptions => {
@@ -1,4 +1,6 @@
/** Module's name. */
const name = "core";
/** Module's description */
const description = "Quetza's core functionality";
export { description, name };
@@ -1,7 +1,8 @@
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
import Client from "../../../lib/client.js";
import logger from "../../../lib/logger.js";
import Client from "$lib/client.js";
import logger from "$lib/logger.js";
import replies from "../lib/replies.js";
import { controller } from "../module.js";
@@ -1,7 +1,8 @@
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
import Client from "../../../lib/client.js";
import logger from "../../../lib/logger.js";
import Client from "$lib/client.js";
import logger from "$lib/logger.js";
import replies from "../lib/replies.js";
import { controller } from "../module.js";
@@ -1,7 +1,8 @@
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
import Client from "../../../lib/client.js";
import logger from "../../../lib/logger.js";
import Client from "$lib/client.js";
import logger from "$lib/logger.js";
import Filter from "../lib/filter.js";
import replies from "../lib/replies.js";
import { controller } from "../module.js";
@@ -35,11 +36,7 @@ const data = new SlashCommandBuilder()
option
.setName("filter")
.setDescription("Filter to apply.")
.setChoices(
...Object.keys(Filter.filters).map((key) => {
return { name: key, value: key };
})
)
.setChoices(...Object.values(Filter.FILTERS))
)
.setDMPermission(false);
@@ -1,7 +1,8 @@
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
import Client from "../../../lib/client.js";
import logger from "../../../lib/logger.js";
import Client from "$lib/client.js";
import logger from "$lib/logger.js";
import replies from "../lib/replies.js";
import { controller } from "../module.js";
@@ -1,7 +1,8 @@
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
import Client from "../../../lib/client.js";
import logger from "../../../lib/logger.js";
import Client from "$lib/client.js";
import logger from "$lib/logger.js";
import replies from "../lib/replies.js";
import { controller } from "../module.js";
@@ -1,7 +1,8 @@
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
import Client from "../../../lib/client.js";
import logger from "../../../lib/logger.js";
import Client from "$lib/client.js";
import logger from "$lib/logger.js";
import replies from "../lib/replies.js";
import { controller } from "../module.js";
@@ -1,7 +1,8 @@
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
import Client from "../../../lib/client.js";
import logger from "../../../lib/logger.js";
import Client from "$lib/client.js";
import logger from "$lib/logger.js";
import replies from "../lib/replies.js";
import { LoopOption } from "../lib/types.js";
import { controller } from "../module.js";
@@ -1,7 +1,8 @@
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
import Client from "../../../lib/client.js";
import logger from "../../../lib/logger.js";
import Client from "$lib/client.js";
import logger from "$lib/logger.js";
import replies from "../lib/replies.js";
import { controller } from "../module.js";
@@ -1,7 +1,8 @@
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
import Client from "../../../lib/client.js";
import logger from "../../../lib/logger.js";
import Client from "$lib/client.js";
import logger from "$lib/logger.js";
import replies from "../lib/replies.js";
import { controller } from "../module.js";
@@ -1,7 +1,8 @@
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
import Client from "../../../lib/client.js";
import logger from "../../../lib/logger.js";
import Client from "$lib/client.js";
import logger from "$lib/logger.js";
import replies from "../lib/replies.js";
import { controller } from "../module.js";
@@ -8,8 +8,9 @@ import {
SlashCommandBuilder
} from "discord.js";
import Client from "../../../lib/client.js";
import logger from "../../../lib/logger.js";
import Client from "$lib/client.js";
import logger from "$lib/logger.js";
import replies from "../lib/replies.js";
import { controller } from "../module.js";
@@ -1,7 +1,8 @@
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
import Client from "../../../lib/client.js";
import logger from "../../../lib/logger.js";
import Client from "$lib/client.js";
import logger from "$lib/logger.js";
import replies from "../lib/replies.js";
import { controller } from "../module.js";
@@ -1,7 +1,8 @@
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
import Client from "../../../lib/client.js";
import logger from "../../../lib/logger.js";
import Client from "$lib/client.js";
import logger from "$lib/logger.js";
import replies from "../lib/replies.js";
import { controller } from "../module.js";
@@ -1,7 +1,8 @@
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
import Client from "../../../lib/client.js";
import logger from "../../../lib/logger.js";
import Client from "$lib/client.js";
import logger from "$lib/logger.js";
import replies from "../lib/replies.js";
import { controller } from "../module.js";
@@ -1,7 +1,8 @@
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
import Client from "../../../lib/client.js";
import logger from "../../../lib/logger.js";
import Client from "$lib/client.js";
import logger from "$lib/logger.js";
import replies from "../lib/replies.js";
import { controller } from "../module.js";
@@ -1,7 +1,8 @@
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
import Client from "../../../lib/client.js";
import logger from "../../../lib/logger.js";
import Client from "$lib/client.js";
import logger from "$lib/logger.js";
import replies from "../lib/replies.js";
import { controller } from "../module.js";
@@ -0,0 +1,65 @@
import { BaseGuildVoiceChannel, User, VoiceState } from "discord.js";
import Client from "$lib/client.js";
import Music from "$mlib/music.js";
import { asleep } from "../lib/misc.js";
import replies from "../lib/replies.js";
/** Amount of time Quetza can be alone in voice channel. */
const TIME_TILL_DISCONNECT = 30_000;
/**
* Deduct {@link BaseGuildVoiceChannel} user is a member of.
*
* @returns BaseGuildVoiceChannel user is a member of, null if not a member of any.
*
* @param user - User that might be in one of a channels.
* @param channels - List of channels that might have user as it member.
*/
function resolveVoiceChannel(
user: User,
...channels: (BaseGuildVoiceChannel | null)[]
): BaseGuildVoiceChannel | null {
return (
channels.find((channel) => channel?.members.some((member) => member.user === user)) ?? null
);
}
/**
* Tracks voice channel Quetza is connected to.
*
* @remark Destroys player if Quetza appears to be alone.
*
* @param client - Quetza client.
* @param eventee - Old and new {@link VoiceState}.
* @param controller - Module's controller.
*/
async function execute(
client: Client<true>,
eventee: [VoiceState, VoiceState],
controller: Music
): Promise<void> {
const [oldState, newState] = eventee;
const player = controller.get(oldState.guild);
const channel = resolveVoiceChannel(client.user, oldState.channel, newState.channel);
if (!player || !channel) {
return;
}
if (channel.members.size === 1) {
await asleep(TIME_TILL_DISCONNECT);
if (channel.members.size === 1 && channel.members.first()?.user === client.user) {
await player.channel.send(replies.alone());
player.destroy();
}
}
}
/** Event name. */
const name = "voiceStateUpdate";
export { execute, name };
+9
View File
@@ -0,0 +1,9 @@
import QuetzaError from "$lib/error.js";
export default class MusicError extends QuetzaError {
public static readonly PREFIX = "MZ";
constructor(msg: string, code: number) {
super(msg, code, MusicError.PREFIX);
}
}
@@ -1,4 +1,4 @@
import { DlpDump, dlpExtractorKey, DlpInfo } from "../types";
import { DlpDump, dlpExtractorKey, DlpInfo } from "$mlib/types.js";
export const dlpExtractorsValues = ["Youtube", "Soundcloud"] as const;
@@ -7,14 +7,10 @@ export const dlpExtractorsValues = ["Youtube", "Soundcloud"] as const;
*
* For example:
*
* @see {@link youtubeExtractor | the youtubeExtractor(...) function }
* @see the {@link youtubeExtractor | youtubeExtractor(...) } function
*/
type dlpExtractor = (arg0: DlpDump, arg1: (arg0: string) => Promise<DlpInfo>) => Promise<DlpInfo>;
export function isDlpExtractorKey(that: string): that is dlpExtractorKey {
return dlpExtractorsValues.includes(that as dlpExtractorKey);
}
/**
* Resolves data that was extracted by dlp (depending on extractor_key).
*
@@ -33,7 +29,7 @@ const youtubeExtractor: dlpExtractor = async (dump, dlpInfo) => {
duration: dump.duration
};
return [[result], []];
return [result];
}
return await dlpInfo(dump.original_url);
@@ -51,12 +47,15 @@ const soundcloudExtractor: dlpExtractor = async (dump, dlpInfo) => {
duration: dump.duration
};
return [[result], []];
return [result];
}
return await dlpInfo(dump.original_url);
};
/**
* DlpDump extractor_key values mapped to corresponding extractor.
*/
const extractors: Record<dlpExtractorKey, dlpExtractor> = {
Youtube: youtubeExtractor,
Soundcloud: soundcloudExtractor
@@ -4,10 +4,12 @@ import prismMedia from "prism-media";
import { Readable } from "stream";
import { promisify } from "util";
import config from "../../../../config.js";
import logger from "../../../../lib/logger.js";
import config from "$config.js";
import logger from "$lib/logger.js";
import MusicError from "$mlib/MusicError.js";
import { DlpDump, DlpInfo, dlpStreamOptions } from "../types.js";
import extractors, { isDlpExtractorKey } from "./extractors.js";
import extractors from "./extractors.js";
const { FFmpeg } = prismMedia;
const execFileAsync = promisify(execFile);
@@ -15,7 +17,30 @@ const execFileAsync = promisify(execFile);
/**
* Maximum length of dlp search stack.
*/
const DLPINFO_DEPTH = 5;
const DLPINFO_DEPTH = 3;
/**
* Path to the Dlp binary.
*/
const DLP_BINARY = path.join(config.path.binaries, "/yt-dlp");
/**
* Base arguments for Ffmpeg transcoder.
*/
const FFMPEG_BASE_ARGS = [
"-analyzeduration",
"0",
"-loglevel",
"0",
"-acodec",
"libopus",
"-f",
"opus",
"-ar",
"48000",
"-ac",
"2"
];
/**
* Predicate to check whether is string a valid URL.
@@ -43,14 +68,14 @@ function isURL(str: string): boolean {
async function dlpInfoRecursive(query: string, depth = DLPINFO_DEPTH): Promise<DlpInfo> {
const error = (message: string, reason: number, ...meta: unknown[]): DlpInfo => {
logger.warn(message, { reason, query, ...meta });
return [[], [{ query, reason }]];
throw new MusicError(message, reason);
};
if (!depth) {
return error("Dlp: recursion depth reached", 408);
return error("Dlp: recursion depth reached", 1010);
}
const subprocess = await execFileAsync(path.join(config.binariesDir, "/yt-dlp"), [
const subprocess = await execFileAsync(DLP_BINARY, [
query,
"-O",
"%(.{id,original_url,title,uploader,duration,extractor_key,thumbnail})j",
@@ -59,20 +84,23 @@ async function dlpInfoRecursive(query: string, depth = DLPINFO_DEPTH): Promise<D
]);
if (!subprocess.stdout) {
return error("Dlp: no stdout", 400);
return error("Dlp: no stdout", 1011);
}
if (subprocess.stderr) {
return error("Dlp: stderr", 500, {
return error("Dlp: stderr", 1012, {
stderr: subprocess.stderr
});
}
const dumps: DlpDump[] = JSON.parse("[" + subprocess.stdout.replace(/\n(?=.+)/g, ",") + "]");
const dumps: DlpDump[] = subprocess.stdout
.trim()
.split("\n")
.map((out) => JSON.parse(out));
const resolveDump = async (dump: DlpDump): Promise<DlpInfo> => {
if (!isDlpExtractorKey(dump.extractor_key)) {
throw new Error("Dlp: illegal extractor");
if (!Object.hasOwn(extractors, dump.extractor_key)) {
error("Dlp: illegal extractor", 1013);
}
return extractors[dump.extractor_key](dump, (subquery: string) =>
@@ -80,14 +108,9 @@ async function dlpInfoRecursive(query: string, depth = DLPINFO_DEPTH): Promise<D
);
};
const result = await Promise.all(dumps.map(resolveDump)).catch((err: Error): DlpInfo[] => {
return [error(err.message, 417, { query })];
});
const result = await Promise.all(dumps.map(resolveDump));
return result.reduce(
(prev, curr) => [prev[0].concat(curr[0]), prev[1].concat(curr[1])],
[[], []]
);
return result.flat();
}
/**
@@ -109,21 +132,8 @@ export async function dlpInfo(query: string): Promise<DlpInfo> {
* @param url - Dlp compatible URL to the requested resource
* @param options - Transcoders and dlp options
*/
export function dlpStream(url: string, options?: dlpStreamOptions) {
let ffmpegArgs: string[] = [
"-analyzeduration",
"0",
"-loglevel",
"0",
"-acodec",
"libopus",
"-f",
"opus",
"-ar",
"48000",
"-ac",
"2"
];
export function dlpStream(url: string, options?: dlpStreamOptions): Promise<Readable> {
let ffmpegArgs = FFMPEG_BASE_ARGS;
if (options?.seek) {
ffmpegArgs.unshift("-ss", options.seek.toString());
@@ -135,7 +145,7 @@ export function dlpStream(url: string, options?: dlpStreamOptions) {
return new Promise<Readable>((resolve, reject) => {
const subprocess = execFile(
path.join(config.binariesDir, "/yt-dlp"),
DLP_BINARY,
[
url,
"-o",
+55
View File
@@ -0,0 +1,55 @@
type filterValue = {
name: string;
value: string;
};
const FILTERS: Record<string, filterValue> = {
bassboost1x: { name: "Bassboost 1x", value: "bass=g=15:f=110:w=0.3" },
bassboost2x: { name: "Bassboost 2x", value: "bass=g=30:f=110:w=0.3" },
daycore: { name: "Daycore", value: "aresample=48000,asetrate=48000*0.75" },
nightcore: { name: "Nightcore", value: "aresample=48000,asetrate=48000*1.25" },
treble: {
name: "Treble Boost",
value: "firequalizer=gain_entry='entry(2500,3);entry(6300,6);entry(16000,10)'"
}
} as const;
type filterPreset = keyof typeof FILTERS;
export default class Filter {
public static readonly FILTERS = FILTERS;
private switches_ = new Map<filterPreset, filterValue>();
public toggled(): filterValue[] {
return [...this.switches_.values()];
}
public toggle(query?: filterPreset): boolean {
if (!query) {
this.switches_.clear();
return false;
}
if (!this.switches_.delete(query)) {
this.switches_.set(query, FILTERS[query]);
return true;
}
return false;
}
public size(): number {
return this.switches_.size;
}
public empty(): boolean {
return this.size() === 0;
}
public toString(): string {
return this.toggled()
.map((value) => value.value)
.join(",");
}
}
File renamed without changes.
@@ -1,6 +1,7 @@
import { Collection, Guild, GuildTextBasedChannel } from "discord.js";
import logger from "../../../lib/logger.js";
import logger from "$lib/logger.js";
import Player from "./player.js";
export default class Music {
@@ -10,6 +10,8 @@ import {
} from "@discordjs/voice";
import { Guild, GuildTextBasedChannel, Message, User, VoiceBasedChannel } from "discord.js";
import { clamp } from "$lib/misc.js";
import { dlpInfo, dlpStream } from "./fetch/fetch.js";
import Filter from "./filter.js";
import { largestCommonSequence } from "./misc.js";
@@ -18,10 +20,33 @@ import Queue from "./queue.js";
import replies from "./replies.js";
import { Track } from "./types.js";
/**
* Defines guild music player connected to a voice channel by wrapping AudioPlayer, Queue, VoiceConnection, etc.
*
* @public
*/
export default class Player {
private music: Music;
private guild: Guild;
/**
* Volume limiter clamp.
*/
public static readonly VOLUME_CLAMP = clamp(0, 150);
/**
* Module controller orchistrate guild-player mapping.
*/
private music_: Music;
/**
* {@link Player} owner guild.
*
* @remarks No different {@link Player} instances can have same {@link Guild}.
*/
private guild_: Guild;
/**
* {@link Guild | Guild's} text based channel for player notifications,
* such as Current Playing Track.
*/
private channel_: GuildTextBasedChannel;
public get channel(): GuildTextBasedChannel {
return this.channel_;
@@ -30,44 +55,82 @@ export default class Player {
this.channel_ = x;
}
/**
* Previous notification message.
*/
private message_?: Message;
/**
* A connection to the voice channel of a {@link Guild}.
*
* @readonly
*/
private connection_?: VoiceConnection;
public get connection(): VoiceConnection | undefined {
return this.connection_;
}
/**
* Player for {@link AudioResource} that is subscribed to the most recent {@link VoiceConnection}.
*
* @readonly
*/
private player_: AudioPlayer;
public get player(): AudioPlayer {
return this.player_;
}
/**
* Represents an Audio Resource that can be played by {@link AudioPlayer}.
*
* @readonly
*/
private resource_: AudioResource<Track> | undefined;
public get resource(): AudioResource<Track> | undefined {
return this.resource_;
}
/**
* Inline volume for an {@link AudioResource}.
* Must be clamped by {@link VOLUME_CLAMP}.
*/
private volume_ = 100;
public get volume(): number {
return this.volume_;
}
public set volume(x: number) {
this.volume_ = Math.max(0, Math.min(150, x));
this.volume_ = Player.VOLUME_CLAMP(x);
if (this.resource_ && this.resource_.volume) {
if (this.resource_?.volume) {
this.resource_.volume.setVolumeLogarithmic(this.volume_ / 100);
}
}
/**
* Queue manager of the {@link Player}.
*/
public readonly queue = new Queue();
/**
* Filter manager of the {@link Player}.
*/
public readonly filter = new Filter();
/**
* Timestamp when {@link Player} was created.
*/
public readonly createdAt: Date = new Date();
/**
* Player constructor.
*
* @param guild - {@link Guild} for which player will be created for
* @param music - Module controller for managing player own existence
* @param channel - Initial text based channed for player notifications
*/
public constructor(guild: Guild, music: Music, channel: GuildTextBasedChannel) {
this.music = music;
this.guild = guild;
this.music_ = music;
this.guild_ = guild;
this.channel_ = channel;
this.player_ = createAudioPlayer();
@@ -75,7 +138,15 @@ export default class Player {
this.player_.on(AudioPlayerStatus.Idle, async () => this.resourceEndResolvable());
}
private async resourceGenerator(track: Track, seek?: number) {
/**
* Creates {@link AudioResource | an Audio Resource} for {@link an AudioPlayer}.
*
* @returns Configured audio resource ready to be played
*
* @param track - Metadata to generate resource from and embed into it
* @param seek - Amount of seconds to start playback from
*/
private async resourceGenerator(track: Track, seek?: number): Promise<AudioResource<Track>> {
const probe = await dlpStream(track.url, {
seek: seek ? seek / 1000 : undefined,
ffmpeg: !this.filter.empty() ? ["-af", this.filter.toString()] : undefined
@@ -97,6 +168,13 @@ export default class Player {
return this.resource_;
}
/**
* Resolves player behavior after {@link AudioResource} ends.
*
* @remarks Force destroys ended resource,
* and either plays next one or stops,
* depending on {@link Queue#next | Queue.next() method}
*/
private resourceEndResolvable(): void {
if (this.resource_) {
this.resource_.encoder?.destroy();
@@ -110,6 +188,12 @@ export default class Player {
}
}
/**
* Resolves notification emittion.
*
* @remarks Tries to delete previous notification message,
* and send next one
*/
private async messageResolvable(track: Track) {
if (this.message_?.deletable) {
await this.message_.delete().catch();
@@ -118,18 +202,27 @@ export default class Player {
this.message_ = await this.channel_.send(replies.nowPlaying(track));
}
/**
* Fetches {@link DlpInfo} and converts it to {@link Track | Track metadata}.
*
* @returns Promise to Track list or undefined, if found nothing.
*
* @param query - String that Dlp will try to resolve
* @param requester - {@link User} that requested Tracks returned to complete convertion
*/
private async search(query: string, requester: User): Promise<Track[] | undefined> {
const [info] = await dlpInfo(query);
if (info.length === 0) {
return undefined;
}
const info = await dlpInfo(query);
return info.map((value) => {
return { ...value, requester: requester };
});
}
/**
* Plays current {@link queue} track from position specified.
*
* @param seek - Amount of seconds to start playback from
*/
public async play(seek?: number): Promise<void> {
const track = this.queue.current();
@@ -144,6 +237,12 @@ export default class Player {
await this.messageResolvable(track);
}
/**
* Creates connection to guild's voice based channel,
* previously destroying existing connection.
*
* @param channel - {@link VoiceBasedChannel} to connect to
*/
public connect(channel: VoiceBasedChannel): void {
if (
this.connection_?.joinConfig.channelId === channel.id &&
@@ -165,6 +264,17 @@ export default class Player {
this.connection_.subscribe(this.player_);
}
/**
* Adds {@link Track | Track(s)} found by {@link search | the search method} to the {@link queue}.
*
* @returns Track metadata if found, undefined otherwise
*
* @param query - String that will be passed to the search method
* @param requester - {@link User} that requested the query
* @param index - Position to insert track to
* Value < 0 resolves to 0
* Value >= (queue size) resolves to (queue end)
*/
public async add(query: string, requester: User, index?: number): Promise<Track | undefined> {
const tracks = await this.search(query, requester);
@@ -177,6 +287,16 @@ export default class Player {
return tracks[0];
}
/**
* Jumps across {@link queue} to the position determined by the query.
*
* @remarks If string passed, then position will be determined by
* the largest common sequence across all the {@link queue} titles.
*
* @return Position to which jump was perfomed
*
* @param query - String of {@link Track} title or position to jump to
*/
public jump(query: number | string): number | undefined {
let position = typeof query === "number" ? query : -1;
let diff = 0;
@@ -199,6 +319,16 @@ export default class Player {
return undefined;
}
/**
* Removes {@link Track} from {@link queue | the queue} by the position determined by the query.
*
* @remarks If string passed, then position will be determined by
* the largest common sequence across all the {@link queue} titles.
*
* @return Metadata of removed track, or undefined if nothing happened
*
* @param query - String of {@link Track} title or position to perfome removal at
*/
public remove(query?: number | string): Track | undefined {
const oldPosition = this.queue.position;
@@ -227,10 +357,18 @@ export default class Player {
return undefined;
}
/**
* Restarts playback from timecode specified.
*
* @param time - Amount of seconds to start playback from
*/
public seek(time: number): void {
this.play(time);
}
/**
* Clears {@link queue} completely - not destroying {@link voiceConnection_}.
*/
public clear(): void {
this.queue.clear();
this.resource_ = undefined;
@@ -240,15 +378,23 @@ export default class Player {
}
}
/**
* Destroys this instance of {@link Player} and deletes it from controller mapping.
*/
public destroy(): void {
if (this.connection_) {
this.connection_.destroy();
}
this.player_.stop();
this.music.delete(this.guild.id);
this.music_.delete(this.guild_.id);
}
/**
* Pauses/unpauses {@link AudioPlayer} playback.
*
* @returns True if paused, or False if unpaused
*/
public pause(): boolean {
if (this.player_.state.status === AudioPlayerStatus.Paused) {
this.player_.unpause();
@@ -259,6 +405,9 @@ export default class Player {
}
}
/**
* Reshuffles {@link queue} order - will start new playback from the same position in the queue.
*/
public shuffle(): void {
this.queue.shuffle();
@@ -268,10 +417,23 @@ export default class Player {
}
}
/**
* Stops {@link player_} that will emit {@link resourceEndResolvable}.
*
* @see {@link resourceEndResolvable} for understanding full behavior
*/
public skip(): void {
this.player_.stop();
}
/**
* Applies {@link Filter} preset to the current playback.
*
* @returns True if applied, false if revoked
*
* @param filter - Name of a filter to apply
* Must exist in Filter class
*/
public setFilter(filter?: string): boolean {
const status = this.filter.toggle(filter);
File renamed without changes.
@@ -1,7 +1,8 @@
import { AudioResource } from "@discordjs/voice";
import { BaseMessageOptions, EmbedBuilder, italic } from "discord.js";
import config from "../../../config.js";
import config from "$config.js";
import { loopMoji, statusBarGenerator, toISOTime, volumeMoji } from "./misc.js";
import Player from "./player.js";
import Queue from "./queue.js";
@@ -338,7 +339,7 @@ const replies = {
value:
player.filter
.toggled()
.map((value) => value[0])
.map((value) => value.name)
.join("\n") + "\ndefault",
inline: true
},
+81
View File
@@ -0,0 +1,81 @@
import { User } from "discord.js";
import { dlpExtractorsValues } from "./fetch/extractors.js";
/**
* Track information fetched by Dlp.
*/
export interface TrackBase {
/** URL of track. */
url: string;
/** Track title. */
title: string;
/** Link to track thumbnail. */
thumbnail: string;
/** Track duration in seconds. */
duration: number;
}
/**
* Track information with {@link User} that requested it.
*/
export interface Track extends TrackBase {
/** User requested this track. */
requester: User;
}
/**
* Player loop options.
*
* @remarks LOOP - Looping queue;
* SONG - Looping single song;
* NONE - Stops after queue end; (default)
* AUTO - Random order;
*/
export type LoopOption = "LOOP" | "SONG" | "NONE" | "AUTO";
/** Dlp fetched track list. */
export type DlpInfo = TrackBase[];
/** Dlp supported extractors as types. */
export type dlpExtractorKey = (typeof dlpExtractorsValues)[number];
/**
* Actual Dlp dump that later translates to {@link DlpInfo}.
* */
export interface DlpDump {
/** Id of resource. */
id: string;
/** URL passed to Dlp or link to resource found. */
original_url: string;
/** Link to webpage of resource requested. */
webpage_url?: string;
/** Resource title. */
title?: string;
/** Resource duration in seconds. */
duration?: number;
/** Link to resource thumbnail. */
thumbnail?: string;
/** Resource's extractor identifier. */
extractor_key: dlpExtractorKey;
}
/**
* Options that shall be passed to DlpStream function.
*/
export interface dlpStreamOptions {
/** Amount of seconds to start playback from. */
seek?: number;
/** Args for FFMPEG transcoder. */
ffmpeg?: string[];
}
@@ -1,8 +1,11 @@
import Music from "./lib/music.js";
/** Module's name. */
const name = "music";
/** Module's description. */
const description = "Music module";
/** Module's controller. */
const controller = new Music();
export { controller, description, name };
+5 -5
View File
@@ -1,12 +1,12 @@
{
"name": "quetza",
"version": "2023.1.0-pre",
"version": "2023.1.1-pre",
"description": "Awesome Discord bot that performs various duties",
"type": "module",
"main": "./dist/index.js",
"main": "./dist/src/index.js",
"scripts": {
"start": "node ./dist/index.js",
"build": "pnpm exec tsc --build",
"start": "node ./dist/src/index.js",
"build": "./build.sh",
"clean": "rm -rf ./dist",
"lint": "pnpm exec eslint . && pnpm exec prettier --check .",
"format": "pnpm exec eslint --fix . && pnpm exec prettier --write .",
@@ -49,7 +49,7 @@
"eslint-plugin-simple-import-sort": "^10.0.0",
"mocha": "^10.2.0",
"prettier": "^3.1.0",
"prisma": "^5.7.0",
"tsc-alias": "^1.8.8",
"typescript": "^5.3.3"
}
}
+54 -60
View File
@@ -13,7 +13,7 @@ dependencies:
version: 0.16.1(@discordjs/opus@0.9.0)
"@prisma/client":
specifier: 5.7.0
version: 5.7.0(prisma@5.7.0)
version: 5.7.0
discord.js:
specifier: ^14.14.1
version: 14.14.1
@@ -55,9 +55,9 @@ devDependencies:
prettier:
specifier: ^3.1.0
version: 3.1.0
prisma:
specifier: ^5.7.0
version: 5.7.0
tsc-alias:
specifier: ^1.8.8
version: 1.8.8
typescript:
specifier: ^5.3.3
version: 5.3.3
@@ -352,7 +352,7 @@ packages:
fastq: 1.15.0
dev: true
/@prisma/client@5.7.0(prisma@5.7.0):
/@prisma/client@5.7.0:
resolution:
{
integrity: sha512-cZmglCrfNbYpzUtz7HscVHl38e9CrUs31nrVoGUK1nIPXGgt8hT4jj2s657UXcNdQ/jBUxDgGmHyu2Nyrq1txg==
@@ -364,52 +364,8 @@ packages:
peerDependenciesMeta:
prisma:
optional: true
dependencies:
prisma: 5.7.0
dev: false
/@prisma/debug@5.7.0:
resolution:
{
integrity: sha512-tZ+MOjWlVvz1kOEhNYMa4QUGURY+kgOUBqLHYIV8jmCsMuvA1tWcn7qtIMLzYWCbDcQT4ZS8xDgK0R2gl6/0wA==
}
/@prisma/engines-version@5.7.0-41.79fb5193cf0a8fdbef536e4b4a159cad677ab1b9:
resolution:
{
integrity: sha512-V6tgRVi62jRwTm0Hglky3Scwjr/AKFBFtS+MdbsBr7UOuiu1TKLPc6xfPiyEN1+bYqjEtjxwGsHgahcJsd1rNg==
}
/@prisma/engines@5.7.0:
resolution:
{
integrity: sha512-TkOMgMm60n5YgEKPn9erIvFX2/QuWnl3GBo6yTRyZKk5O5KQertXiNnrYgSLy0SpsKmhovEPQb+D4l0SzyE7XA==
}
requiresBuild: true
dependencies:
"@prisma/debug": 5.7.0
"@prisma/engines-version": 5.7.0-41.79fb5193cf0a8fdbef536e4b4a159cad677ab1b9
"@prisma/fetch-engine": 5.7.0
"@prisma/get-platform": 5.7.0
/@prisma/fetch-engine@5.7.0:
resolution:
{
integrity: sha512-zIn/qmO+N/3FYe7/L9o+yZseIU8ivh4NdPKSkQRIHfg2QVTVMnbhGoTcecbxfVubeTp+DjcbjS0H9fCuM4W04w==
}
dependencies:
"@prisma/debug": 5.7.0
"@prisma/engines-version": 5.7.0-41.79fb5193cf0a8fdbef536e4b4a159cad677ab1b9
"@prisma/get-platform": 5.7.0
/@prisma/get-platform@5.7.0:
resolution:
{
integrity: sha512-ZeV/Op4bZsWXuw5Tg05WwRI8BlKiRFhsixPcAM+5BKYSiUZiMKIi713tfT3drBq8+T0E1arNZgYSA9QYcglWNA==
}
dependencies:
"@prisma/debug": 5.7.0
/@sapphire/async-queue@1.5.1:
resolution:
{
@@ -1070,6 +1026,14 @@ packages:
text-hex: 1.0.0
dev: false
/commander@9.5.0:
resolution:
{
integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==
}
engines: { node: ^12.20.0 || >=14 }
dev: true
/concat-map@0.0.1:
resolution:
{
@@ -2555,6 +2519,14 @@ packages:
integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==
}
/mylas@2.1.13:
resolution:
{
integrity: sha512-+MrqnJRtxdF+xngFfUUkIMQrUUL0KsxbADUkn23Z/4ibGg192Q+z+CQyiYwvWTsYjJygmMR8+w3ZDa98Zh6ESg==
}
engines: { node: ">=12.0.0" }
dev: true
/nanoid@3.3.3:
resolution:
{
@@ -2804,6 +2776,16 @@ packages:
engines: { node: ">=8.6" }
dev: true
/plimit-lit@1.6.1:
resolution:
{
integrity: sha512-B7+VDyb8Tl6oMJT9oSO2CW8XC/T4UcJGrwOVoNGwOQsQYhlpfajmrMj5xeejqaASq3V/EqThyOeATEOMuSEXiA==
}
engines: { node: ">=12" }
dependencies:
queue-lit: 1.5.2
dev: true
/prelude-ls@1.2.1:
resolution:
{
@@ -2844,17 +2826,6 @@ packages:
"@discordjs/opus": 0.9.0
dev: false
/prisma@5.7.0:
resolution:
{
integrity: sha512-0rcfXO2ErmGAtxnuTNHQT9ztL0zZheQjOI/VNJzdq87C3TlGPQtMqtM+KCwU6XtmkoEr7vbCQqA7HF9IY0ST+Q==
}
engines: { node: ">=16.13" }
hasBin: true
requiresBuild: true
dependencies:
"@prisma/engines": 5.7.0
/punycode@2.3.1:
resolution:
{
@@ -2863,6 +2834,14 @@ packages:
engines: { node: ">=6" }
dev: true
/queue-lit@1.5.2:
resolution:
{
integrity: sha512-tLc36IOPeMAubu8BkW8YDBV+WyIgKlYU7zUNs0J5Vk9skSZ4JfGlPOqplP0aHdfv7HL0B2Pg6nwiq60Qc6M2Hw==
}
engines: { node: ">=12" }
dev: true
/queue-microtask@1.2.3:
resolution:
{
@@ -3302,6 +3281,21 @@ packages:
}
dev: false
/tsc-alias@1.8.8:
resolution:
{
integrity: sha512-OYUOd2wl0H858NvABWr/BoSKNERw3N9GTi3rHPK8Iv4O1UyUXIrTTOAZNHsjlVpXFOhpJBVARI1s+rzwLivN3Q==
}
hasBin: true
dependencies:
chokidar: 3.5.3
commander: 9.5.0
globby: 11.1.0
mylas: 2.1.13
normalize-path: 3.0.0
plimit-lit: 1.6.1
dev: true
/tsconfig-paths@3.14.2:
resolution:
{
@@ -1,22 +0,0 @@
-- 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;
@@ -1,77 +0,0 @@
/*
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
@@ -1,3 +0,0 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"
-68
View File
@@ -1,68 +0,0 @@
// 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
View File
@@ -1,26 +0,0 @@
import { ActivityOptions, ActivityType, ColorResolvable } from "discord.js";
import path from "path";
import { fileURLToPath } from "url";
const ROOT = path.dirname(fileURLToPath(import.meta.url));
const discordToken = process.env.DISCORD_TOKEN;
const modulesDir = path.join(ROOT, "/modules/");
const binariesDir = path.join(ROOT, "/../bin/");
const activityStatus: ActivityOptions = { type: ActivityType.Watching, name: "over you" };
const colors = {
default: "#ff6e9b" as ColorResolvable,
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;
+8 -3
View File
@@ -1,8 +1,13 @@
import { GatewayIntentBits } from "discord.js";
import config from "./config.js";
import Client from "./lib/client.js";
import config from "$config.js";
import Client from "$lib/client.js";
/**
* Ready to go bot instance.
*
* @remarks Quetza itself.
*/
const client = new Client({
intents: [
GatewayIntentBits.Guilds,
@@ -11,4 +16,4 @@ const client = new Client({
]
});
client.login(config.discordToken);
client.login(config.application.token);
+110 -62
View File
@@ -1,84 +1,132 @@
import { PrismaClient } from "@prisma/client";
import { Client as DiscordClient, ClientOptions, Collection } from "discord.js";
import { existsSync, lstatSync, readdirSync } from "fs";
import path from "path";
import { pathToFileURL } from "url";
import { readdirSync } from "fs";
import config from "../config.js";
import { ApplicationStatus, Command, Event, Module } from "./types.js";
import config from "$config.js";
import { importDir, pathThrough } from "$lib/misc.js";
import {
ApplicationStatus,
Command,
CommandBase,
Event,
EventBase,
Module,
ModuleBase
} from "$lib/types.js";
const MODULE_ENTRY = "module.js";
const MODULE_COMMANDS = "commands";
const MODULE_EVENTS = "events";
/**
* Creating path to {@link Module | Module's} declaration file.
*
* @internal
*/
const toModuleDeclaration = pathThrough([config.path.modules], ["module.js"]);
export default class Client extends DiscordClient {
/**
* Creating path to {@link Module | Module's} commands directory.
*
* @internal
*/
const toModuleCommands = pathThrough([config.path.modules], ["commands"]);
/**
* Creating path to {@link Module | Module's} events directory.
*
* @internal
*/
const toModuleEvents = pathThrough([config.path.modules], ["events"]);
/**
* Bot client enchanced with module handling and database access.
*
* @typeparam T - True if client ready, false otherwise.
*
* @public
*/
export default class Client<T extends boolean = boolean> extends DiscordClient<T> {
/**
* Mapping command name to its {@link Command | instance}.
*/
public readonly commands = new Collection<string, Command>();
/**
* Mapping event name to its {@link Event | instance}.
*/
public readonly events = new Collection<string, Event>();
/**
* Mapping module name to its {@link Module | instance}.
*/
public readonly modules = new Collection<string, Module>();
public readonly db = new PrismaClient();
/**
* Constructs Quetza Client.
*
* @param options - Discord.js client options
*/
public constructor(options: ClientOptions) {
super(options);
for (const module of readdirSync(config.modulesDir)) {
const modulePath = path.join(config.modulesDir, module);
if (!lstatSync(modulePath).isDirectory()) {
continue;
}
import(pathToFileURL(path.join(modulePath, MODULE_ENTRY)).toString()).then(
(data: Module) => this.loadModule(data, modulePath)
);
}
readdirSync(config.path.modules).forEach((module) =>
this.importModule(
toModuleDeclaration(module),
toModuleCommands(module),
toModuleEvents(module)
)
);
}
private loadModule(data: Module, rootDir: string): void {
function importModule(dir: string, importThen: (data: Command & Event) => void) {
if (existsSync(dir)) {
for (const source of readdirSync(dir)) {
const file = path.join(dir, source);
/**
* Generates client's status by providing Bot User and Loaded Modules info.
*
* @returns Status of application.
* Additional info such as Application ID provided if {@link Client} is ready.
*/
public generateApplicationStatus(): ApplicationStatus {
const modules = Array.from(this.modules.values());
if (!lstatSync(file).isFile() || !file.endsWith(".js")) {
continue;
}
import(pathToFileURL(file).toString()).then(importThen);
}
}
if (!this.isReady()) {
return {
applicationId: null,
tag: null,
modules
};
}
importModule(path.join(rootDir, MODULE_COMMANDS), (command: Command) => {
this.commands.set(command.data.name, command);
});
importModule(path.join(rootDir, MODULE_EVENTS), (event: Event) => {
this.on(
event.name,
async (...eventee: unknown[]) => await event.execute(this, eventee)
);
});
this.modules.set(data.name, data);
}
public generateApplicationStatus(): ApplicationStatus<"OK" | "UNAUTHORIZED"> {
const modules = Array.from(this.modules.keys());
const commands = Array.from(this.commands.keys());
const events = Array.from(this.events.keys());
const status: "OK" | "UNAUTHORIZED" = this.user && this.application ? "OK" : "UNAUTHORIZED";
return {
status,
applicationId: this.application?.id,
userTag: this.user?.tag,
modules,
commands,
events
applicationId: this.application.id,
tag: this.user.tag,
modules
};
}
/**
* Imports module metadata, commands and events to the client.
*
* @param declaration - File declaring module information.
* @param commands - Directory containing module's commands.
* @param events - Directory containing module's events.
*/
private async importModule(
declaration: string,
commands: string,
events: string
): Promise<void> {
const module = await import(declaration).then((module: ModuleBase) =>
this.modules.ensure(module.name, () => ({ ...module, commands: [], events: [] }))
);
importDir<CommandBase>(commands, (command) => {
const saved = this.commands.ensure(command.data.name, () => ({ ...command, module }));
module.commands.push(saved);
});
importDir<EventBase>(events, (event) => {
const saved = this.events.ensure(event.name, () => ({ ...event, module }));
module.events.push(saved);
this.on(event.name, (...eventee: unknown[]) =>
saved.execute(this, eventee, module.controller)
);
});
}
}
+55
View File
@@ -0,0 +1,55 @@
/**
* Base class for all quetza's errors.
*
* @public
*/
export default class QuetzaError extends Error {
/**
* Default prefix value stands for quetza's base features.
*/
public static readonly DEFAULT_PREFIX = "QZ";
/**
* Error code is an identifier for specific Error in the same module.
*/
public readonly code: number;
/**
* Prefix defines module where Error has occured.
*
* @defaultValue `QuetzaError.DEFAULT_PREFIX`
*/
public readonly prefix: string;
/**
* Constructs QuetzaError class.
*
* @param msg - Error message (that shall not be displayed to end user)
* @param code - {@link code | Code} unique to error in module
* @param prefix - Indentifier of module where error has occured
*/
public constructor(msg: string, code: number, prefix = QuetzaError.DEFAULT_PREFIX) {
super(msg);
Object.setPrototypeOf(this, QuetzaError.prototype);
this.code = code;
this.prefix = prefix;
}
/**
* Must create a unique error ID if no collision bugs were written.
*
* @returns error ID as concatination of {@link prefix} and {@link code}
*
* @example
* ```ts
* const error = new QuetzaError("Some error.", 404, "NF");
*
* error.getErrorId(); // "NF404"
* ```
*/
public getErrorId(): string {
return this.prefix + this.code.toString();
}
}
+17 -3
View File
@@ -1,12 +1,26 @@
import path from "path";
import winston from "winston";
import config from "$config.js";
/** Error logging file path. */
const errorLog = path.join(config.path.log, "error.log");
/** Combined logging file path. */
const combinedLog = path.join(config.path.log, "combined.log");
/**
* Logger instance. Use it for logging.
*
* @public
*/
const logger = winston.createLogger({
level: "info",
levels: winston.config.syslog.levels,
format: winston.format.combine(winston.format.timestamp(), winston.format.json()),
transports: [
new winston.transports.Console({ format: winston.format.simple() }),
new winston.transports.File({ filename: "logs/error.log", level: "error" }),
new winston.transports.File({ filename: "logs/combined.log" })
new winston.transports.File({ filename: errorLog, level: "error" }),
new winston.transports.File({ filename: combinedLog })
]
});
+77
View File
@@ -0,0 +1,77 @@
/**
* Definitions for miscellaneous functions used in this project.
*/
import assert from "assert";
import { existsSync, readdirSync } from "fs";
import path from "path";
import { pathToFileURL } from "url";
/**
* Clamps value between min and max params.
*
* @returns Function with defined min and max, expecting a value to clamp.
*
* @param min - value less than min will resolve to min.
* @param max - value greater than max will resolve to max.
*
* @public
*/
export function clamp(min: number, max: number) {
assert(min <= max);
return (value: number) => Math.max(min, Math.max(max, value));
}
/**
* Converts Path to File URI Scheme String.
*
* @returns URI.toString() made from joined paths.
*
* @param paths - Strings that should be joined and converted to URI.
*
* @public
*/
export function pathToURI(...paths: string[]): string {
return pathToFileURL(path.join(...paths)).toString();
}
/**
* Imports every module withing provided folder with callback.
*
* @remarks Will import nothing if directory not exists.
*
* @typeparam T - Type of whatever you're importing.
* If not 'any', expecting every import to have the same semantics.
*
* @param dir - Directory where importing modules resides.
* @param callback - Function to run on every import.
*
* @public
*/
export function importDir<T>(dir: string, callback: (module: T) => void) {
if (!existsSync(dir)) {
return;
}
readdirSync(dir)
.filter((source) => source.endsWith(".js"))
.forEach((source) => import(pathToURI(dir, source)).then(callback));
}
/**
* Creates function that joins paths with predefined origin and target.
*
* @returns String with following structure: '...origins/...through/...target'.
*
* @param origins - Paths that will be joined and prepended to 'through'.
* @param targets - Paths that will be joined and appended to 'through'.
*
* @public
*/
export function pathThrough(
origins: string[],
targets: string[]
): (...through: string[]) => string {
return (...through: string[]) => path.join(...origins, ...through, ...targets);
}
+76 -15
View File
@@ -1,28 +1,89 @@
import { ApplicationCommandData, CommandInteraction } from "discord.js";
/**
* Quetza's base type declarations.
*
* @see Architecture documentation for more understanding of its purpose.
*/
import { ApplicationCommandData, CommandInteraction, If } from "discord.js";
import Client from "./client.js";
import Client from "$lib/client.js";
export interface Module {
/**
* Quetza's module declaration.
*/
export interface ModuleBase {
/** Name of a module. */
name: string;
description?: string;
/** Description of a module. */
description: string;
/** Module controller. */
controller?: unknown;
}
export interface Command {
/**
* Complete {@link ModuleBase | Module} declaration with commands and events.
*/
export interface Module extends ModuleBase {
/** Module's commands. */
commands: CommandBase[];
/** Module's events. */
events: EventBase[];
}
/**
* Quetza's command interaction declaration.
*/
export interface CommandBase {
/** Command metadata that will be passed to the Discord API. */
data: ApplicationCommandData;
execute: (client: Client, interaction: CommandInteraction) => Promise<void>;
/** Function that will be executed upon invoking this interaction. */
execute: (
client: Client,
interaction: CommandInteraction,
controller?: unknown
) => Promise<void>;
}
export interface Event {
/**
* Command with module included.
*/
export interface Command extends CommandBase {
/** Module containing this Command. */
module: Module;
}
/**
* Quetza's event declaration.
*/
export interface EventBase {
/** Name of an event to listen. */
name: string;
execute: (client: Client, eventee: unknown[]) => Promise<void>;
/** Function that will be executed upon event emitting. */
execute: (client: Client, eventee: unknown[], controller?: unknown) => Promise<void>;
}
export interface ApplicationStatus<T extends "OK" | "UNAUTHORIZED"> {
status: T;
applicationId: T extends "OK" ? string : undefined;
userTag: T extends "OK" ? string : undefined;
modules: string[];
commands: string[];
events: string[];
/**
* {@link EventBase | Event} with {@link Module} included.
*/
export interface Event extends EventBase {
/** Module containing this Event. */
module: ModuleBase;
}
/**
* Application status provides information that may be useful at initialization.
*/
export interface ApplicationStatus<Ready extends boolean = boolean> {
/** Discord's ID of an application. */
applicationId: If<Ready, string>;
/** Tag of a bot user. */
tag: If<Ready, string>;
/** List of each module imported. */
modules: Module[];
}
@@ -1,32 +0,0 @@
import { Events, Interaction } from "discord.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;
if (interaction.isChatInputCommand()) {
const command = client.commands.get(interaction.commandName);
if (!command) {
logger.error(`No command matching ${interaction.commandName} was found.`, {
interaction
});
return;
}
try {
logger.info(`Interaction ${interaction.commandName} was created.`, interaction);
await command.execute(client, interaction);
} catch (error) {
logger.error("Interaction error occured. ", { error, interaction });
}
}
}
const name = Events.InteractionCreate;
export { execute, name };
-37
View File
@@ -1,37 +0,0 @@
import { generateDependencyReport } from "@discordjs/voice";
import { Events } from "discord.js";
import config from "../../../config.js";
import Client from "../../../lib/client.js";
import logger from "../../../lib/logger.js";
async function execute(client: Client): Promise<void> {
if (!client.user || !client.application) {
throw new Error("No application or user for it is provided.");
}
const commandData = Array.from(client.commands.values()).map((value) => value.data);
if (process.env.NODE_ENV === "development" && process.env.TEST_GUILD) {
logger.info("Development mode: Commands will be pushed to the test guild!");
logger.info(generateDependencyReport());
const guild = await client.guilds.fetch(process.env.TEST_GUILD);
guild.commands.set(commandData);
}
if (process.env.NODE_ENV === "production") {
logger.info("Production mode: Commands will be pushed to the application!");
await client.application.commands.set(commandData);
}
logger.info("Ready.", client.generateApplicationStatus());
client.user.setActivity(config.activityStatus);
}
const name = Events.ClientReady;
export { execute, name };
@@ -1,36 +0,0 @@
import { VoiceState } from "discord.js";
import Client from "../../../lib/client.js";
import { asleep } from "../lib/misc.js";
import replies from "../lib/replies.js";
import { controller } from "../module.js";
const WAIT_TIME = 30_000;
async function execute(client: Client, eventee: [VoiceState]): Promise<void> {
const [oldState] = eventee;
const player = controller.get(oldState.guild);
if (!player || !oldState.channel || !client.user) {
return;
}
if (
oldState.channel.members.size === 1 &&
oldState.channel.members.first()?.user === client.user
) {
await asleep(WAIT_TIME);
if (
oldState.channel.members.size === 1 &&
oldState.channel.members.first()?.user === client.user
) {
await player.channel.send(replies.alone());
player.destroy();
}
}
}
const name = "voiceStateUpdate";
export { execute, name };
-66
View File
@@ -1,66 +0,0 @@
export default class Filter {
public static readonly filters = {
"Bassboost 1.5": "bass=g=15:f=110:w=0.3",
"bassboost 2.0": "bass=g=20:f=110:w=0.3",
"bassboost 3.0": "bass=g=30:f=110:w=0.3",
"8D": "apulsator=hz=0.09",
Daycore: "aresample=48000,asetrate=48000*0.75",
Nightcore: "aresample=48000,asetrate=48000*1.25",
Phaser: "aphaser=in_gain=0.4",
Tremolo: "tremolo",
Vibrato: "vibrato=f=6.5",
Reverse: "areverse",
Treble: "treble=g=5",
"Normalizer 1": "dynaudnorm=g=101",
"Normalizer 2": "acompressor",
"Surround Sound": "surround",
Pulsator: "apulsator=hz=1",
Subboost: "asubboost",
Karaoke: "stereotools=mlev=0.03",
Compressor: "compand=points=-80/-105|-62/-80|-15.4/-15.4|0/-12|20/-7.6",
Expander:
"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",
"Soft Limiter": "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",
"Fade In": "afade=t=in:ss=0:d=10",
Dimming:
"afftfilt=\"'real=re * (1-clip((b/nb)*b,0,1))':imag='im * (1-clip((b/nb)*b,0,1))'\"",
Earrape: "channelsplit,sidechaingate=level_in=64"
};
private switches_ = new Set<string>();
public toggled(): [string, string][] {
return Object.entries(Filter.filters)
.filter((value) => this.switches_.has(value[0]))
.map((value) => [value[0], value[1]]);
}
public toggle(query?: string): boolean {
if (!query) {
this.switches_.clear();
return false;
}
if (!this.switches_.delete(query) && Object.hasOwn(Filter.filters, query)) {
this.switches_.add(query);
return true;
}
return false;
}
public size(): number {
return this.switches_.size;
}
public empty(): boolean {
return this.size() === 0;
}
public toString(): string {
return this.toggled()
.map((value) => value[1])
.join(",");
}
}
-44
View File
@@ -1,44 +0,0 @@
import { User } from "discord.js";
import { dlpExtractorsValues } from "./fetch/extractors";
export interface LostTrack {
query: string;
reason: number;
}
export interface TrackBase {
url: string;
title: string;
thumbnail: string;
duration: number;
}
export interface Track extends TrackBase {
url: string;
title: string;
thumbnail: string;
duration: number;
requester: User;
}
export type DlpInfo = [TrackBase[], LostTrack[]];
export type LoopOption = "LOOP" | "SONG" | "NONE" | "AUTO";
export type dlpExtractorKey = (typeof dlpExtractorsValues)[number];
export interface DlpDump {
id: string;
original_url: string;
webpage_url?: string;
title?: string;
duration?: number;
thumbnail?: string;
extractor_key: dlpExtractorKey;
}
export interface dlpStreamOptions {
seek?: number;
ffmpeg?: string[];
}
+1 -1
View File
@@ -2,7 +2,7 @@
"extends": "../tsconfig.json",
"compilerOptions": {
"paths": {
"$config": ["../config.js"],
"$config.js": ["../config.js"],
"$lib/*": ["./lib/*"]
},
+1
View File
@@ -7,6 +7,7 @@
"resolveJsonModule": true,
"types": ["node"],
"outDir": "./dist",
"sourceMap": true,
"esModuleInterop": true,