14 Commits
75 changed files with 2824 additions and 1757 deletions

No files matched your search

+5 -2
View File
@@ -5,12 +5,15 @@
!Dockerfile !Dockerfile
# Source # Source
!config.ts
!src/ !src/
!prisma/ !modules/
# Dependencies # Dependencies
!package.json !package.json
!pnpm-lock.yaml !pnpm-lock.yaml
# Typescript compiller config # Build
!build.sh
!config.tsconfig.json
!tsconfig.json !tsconfig.json
@@ -1,9 +1,11 @@
name: actions name: Continuous Integration
on: on:
push: push:
tags: tags:
- "*" - "*"
branches:
- master
jobs: jobs:
mocha: mocha:
@@ -11,7 +13,7 @@ jobs:
strategy: strategy:
matrix: matrix:
node-version: [18.x] node-version: [20.x]
pnpm-version: [8] pnpm-version: [8]
steps: steps:
@@ -33,6 +35,11 @@ jobs:
- name: ESLint & Prettier - name: ESLint & Prettier
run: pnpm run lint run: pnpm run lint
- name: Prisma Migration
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
run: pnpm exec prisma migrate deploy
- name: Build - name: Build
run: pnpm run build run: pnpm run build
@@ -53,14 +60,25 @@ jobs:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}
- name: Extract metadata (tags, labels) for Docker - name: Docker Generate Meta
id: meta id: meta
uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7 uses: docker/metadata-action@v5
with: with:
images: unknowableshade/quetza-bot images: |
unknowableshade/quetza
# generate Docker tags based on the following events/attributes
# on tag event: tag using git tag, and as latest if the tag doesn't contain hyphens (pre-releases)
# on push event: tag using git sha, branch name and as latest-dev
tags: |
type=raw,value=latest,enable=${{ github.ref_type == 'tag' && !contains(github.ref_name, '-') }}
type=raw,value=latest-dev,enable=${{ github.ref_type == 'branch' && github.ref_name == 'master' }}
type=raw,value=${{ github.ref_name }}
type=raw,value=${{ github.sha }},enable=${{ github.ref_type == 'branch' }}
flavor: |
latest=false
- name: Build and push Docker image - name: Build and Push Docker Image
uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4 uses: docker/build-push-action@v5
with: with:
context: . context: .
push: true push: true
+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 python3
RUN apk add --no-cache curl
RUN apk add --no-cache make RUN apk add --no-cache make
RUN apk add --no-cache gcc RUN apk add --no-cache gcc
RUN apk add --no-cache build-base RUN apk add --no-cache build-base
@@ -17,35 +18,27 @@ COPY . .
RUN pnpm run build RUN pnpm run build
FROM node:18-alpine 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 apk update RUN chmod a+rx /usr/src/app/dist/bin/yt-dlp
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 pnpm install --prod 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" ]
+143 -11
View File
@@ -1,16 +1,148 @@
# Quetza 4 ![quetza-logo](./assets/quetza-logo-readme.png)
_Neat typescript discord bot._ # Quetza │ [![Build Status](https://github.com/unknowableshade/quetza/actions/workflows/ci.yml/badge.svg?branch=master&event=push)](https://github.com/unknowableshade/quetza/actions/workflows/ci.yml)
## About Awesome Discord bot that performs various duties on my server.
- Base player features. ## Architecture
- Play music from YouTube and many other sites.
- Lots of different filters _(nightcore, earrape etc.)_.
- Slash commands.
## Links At its core **Quetza** is simply a **[user-client](./src/lib/client.ts)** with a **[logger](./src/lib/logger.ts)**.
- **[Dependencies](https://github.com/unknowableshade/quetza-bot/blob/master/package.json)** ### Modules
- **[Docker](https://hub.docker.com/repository/docker/unknowableshade/quetza-bot)**
- **[Licence](https://github.com/unknowableshade/5a0b-bot/blob/master/LICENSE)** **Modules** define the features of **Quetza**.
> 🗂 **The structure** of a **module**
```sh
modules/example/
├ module.ts
├ commands/
│ ├ [command-name].ts
├ events/
│ ├ [event-name].ts
├ lib/
│ ├ [...libraries] // Including controller if exists.
```
#### **module.ts**
**The module** must have a definition file that **adheres** to the following **interface** as _export_.
> 📐 **Module** export **interface** [defined in _[src/lib/types.ts](./src/lib/types.ts)_]
```ts
interface Module {
name: string; // Name of the module.
description?: string; // Description of the module.
controller?: unknown;
// The service orchestrating the module. (passed as third argument to command or event executor)
}
```
#### commands/[command-name].ts
Each **command** has to be defined in its own file, preferably named after **the command** itself.
**The client** indexes **the commands** of each **module** and sends them to **the Discord API**.
> 📐 **Command** **interface** to export [defined in _[src/lib/types.ts](./src/lib/types.ts)_]
```ts
interface Command {
data: ApplicationCommandData; // Definition of command specifically for the Discord API.
execute: (client: Client, interaction: CommandInteraction, controller?: unknown) => Promise<void>;
// Function that will be called upon interaction invocation.
}
```
> ⚠️ Each **command** must comply with the _command interaction_ requirements of **the Discord API**.
#### events/[event-name].ts
**Events** are pre-defined by **the Discord API** and can only have new functions
bound to **them** by **modules**, despite being declared similarly to **commands**.
> 📐 **Event** **interface** to export [defined in _[src/lib/types.ts](./src/lib/types.ts)_]
```ts
export interface Event {
name: string;
execute: (client: Client, eventee: unknown[], controller?: unknown) => Promise<void>;
}
```
> ✅ **These** can be re-defined so that they can operate independently in their own **module**.
#### lib/[...libraries] & controller
If you expand the provided **module** **structure**, it will still be a valid **module**.
**_lib/_** is simply a suggestion to add consistency to the codebase.
**The controller** is responsible for orchestrating the behaviour of your **module**.
_Imagine it like a web application service._
> 📘 **For example**, creating players for listening to music on voice channels
## Build System
**Quetza** relies on various dependencies to function correctly. The **[package.json](./package.json)** file lists the _engines_, _toolchain_, _dependencies_, and other _metadata_.
> ⚠️ **pnpm** is being used as package manager.
### External Dependencies
**Modules** (or maybe **Quetza** itself) can leverage external _binaries_
that shall be located at _bin/_ directory or be accessible from _the environment_.
> 🔗 **Listing** of extenal _dependencies_
| **Base/Module** | **Dependency** |
| --------------- | ------------------------------------------ |
| Music | [yt-dlp](https://github.com/yt-dlp/yt-dlp) |
### Shipping
#### Docker
[![Build Status](https://github.com/unknowableshade/quetza/actions/workflows/ci.yml/badge.svg?branch=master&event=push)](https://github.com/unknowableshade/quetza/actions/workflows/ci.yml)
The primary method of deploying **Quetza** is through
a **[Docker Image](https://hub.docker.com/repository/docker/unknowableshade/quetza)**
provided by the respective
**[action](./.github/workflows/ci.yml)**.
```bash
docker run \
-e DISCORD_TOKEN=*aquired via Discord Developer Portal* \
--name quetza -d \
unknowableshade/quetza-bot:latest
```
#### From Source
Although it is possible to run it directly from the source, some additional steps are required.
- Compile _TypeScript_
```bash
pnpm run build
```
- Run **Quetza**
```bash
pnpm start
```
#### Environment
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)**.
## Licence
**Quetza's** source code is licensed under **[the MIT Licence](,/LICENSE)**.
**Notice!** This does not cover '**_Quetza_**' branding such as **[logo](./assets/quetza-logo.png)** or other **[assets listed](./assets/)**, as these are protected by **[the separate licence](./assets/LICENSE)**.
+407
View File
@@ -0,0 +1,407 @@
Attribution-NonCommercial 4.0 International
=======================================================================
Creative Commons Corporation ("Creative Commons") is not a law firm and
does not provide legal services or legal advice. Distribution of
Creative Commons public licenses does not create a lawyer-client or
other relationship. Creative Commons makes its licenses and related
information available on an "as-is" basis. Creative Commons gives no
warranties regarding its licenses, any material licensed under their
terms and conditions, or any related information. Creative Commons
disclaims all liability for damages resulting from their use to the
fullest extent possible.
Using Creative Commons Public Licenses
Creative Commons public licenses provide a standard set of terms and
conditions that creators and other rights holders may use to share
original works of authorship and other material subject to copyright
and certain other rights specified in the public license below. The
following considerations are for informational purposes only, are not
exhaustive, and do not form part of our licenses.
Considerations for licensors: Our public licenses are
intended for use by those authorized to give the public
permission to use material in ways otherwise restricted by
copyright and certain other rights. Our licenses are
irrevocable. Licensors should read and understand the terms
and conditions of the license they choose before applying it.
Licensors should also secure all rights necessary before
applying our licenses so that the public can reuse the
material as expected. Licensors should clearly mark any
material not subject to the license. This includes other CC-
licensed material, or material used under an exception or
limitation to copyright. More considerations for licensors:
wiki.creativecommons.org/Considerations_for_licensors
Considerations for the public: By using one of our public
licenses, a licensor grants the public permission to use the
licensed material under specified terms and conditions. If
the licensor's permission is not necessary for any reason--for
example, because of any applicable exception or limitation to
copyright--then that use is not regulated by the license. Our
licenses grant only permissions under copyright and certain
other rights that a licensor has authority to grant. Use of
the licensed material may still be restricted for other
reasons, including because others have copyright or other
rights in the material. A licensor may make special requests,
such as asking that all changes be marked or described.
Although not required by our licenses, you are encouraged to
respect those requests where reasonable. More considerations
for the public:
wiki.creativecommons.org/Considerations_for_licensees
=======================================================================
Creative Commons Attribution-NonCommercial 4.0 International Public
License
By exercising the Licensed Rights (defined below), You accept and agree
to be bound by the terms and conditions of this Creative Commons
Attribution-NonCommercial 4.0 International Public License ("Public
License"). To the extent this Public License may be interpreted as a
contract, You are granted the Licensed Rights in consideration of Your
acceptance of these terms and conditions, and the Licensor grants You
such rights in consideration of benefits the Licensor receives from
making the Licensed Material available under these terms and
conditions.
Section 1 -- Definitions.
a. Adapted Material means material subject to Copyright and Similar
Rights that is derived from or based upon the Licensed Material
and in which the Licensed Material is translated, altered,
arranged, transformed, or otherwise modified in a manner requiring
permission under the Copyright and Similar Rights held by the
Licensor. For purposes of this Public License, where the Licensed
Material is a musical work, performance, or sound recording,
Adapted Material is always produced where the Licensed Material is
synched in timed relation with a moving image.
b. Adapter's License means the license You apply to Your Copyright
and Similar Rights in Your contributions to Adapted Material in
accordance with the terms and conditions of this Public License.
c. Copyright and Similar Rights means copyright and/or similar rights
closely related to copyright including, without limitation,
performance, broadcast, sound recording, and Sui Generis Database
Rights, without regard to how the rights are labeled or
categorized. For purposes of this Public License, the rights
specified in Section 2(b)(1)-(2) are not Copyright and Similar
Rights.
d. Effective Technological Measures means those measures that, in the
absence of proper authority, may not be circumvented under laws
fulfilling obligations under Article 11 of the WIPO Copyright
Treaty adopted on December 20, 1996, and/or similar international
agreements.
e. Exceptions and Limitations means fair use, fair dealing, and/or
any other exception or limitation to Copyright and Similar Rights
that applies to Your use of the Licensed Material.
f. Licensed Material means the artistic or literary work, database,
or other material to which the Licensor applied this Public
License.
g. Licensed Rights means the rights granted to You subject to the
terms and conditions of this Public License, which are limited to
all Copyright and Similar Rights that apply to Your use of the
Licensed Material and that the Licensor has authority to license.
h. Licensor means the individual(s) or entity(ies) granting rights
under this Public License.
i. NonCommercial means not primarily intended for or directed towards
commercial advantage or monetary compensation. For purposes of
this Public License, the exchange of the Licensed Material for
other material subject to Copyright and Similar Rights by digital
file-sharing or similar means is NonCommercial provided there is
no payment of monetary compensation in connection with the
exchange.
j. Share means to provide material to the public by any means or
process that requires permission under the Licensed Rights, such
as reproduction, public display, public performance, distribution,
dissemination, communication, or importation, and to make material
available to the public including in ways that members of the
public may access the material from a place and at a time
individually chosen by them.
k. Sui Generis Database Rights means rights other than copyright
resulting from Directive 96/9/EC of the European Parliament and of
the Council of 11 March 1996 on the legal protection of databases,
as amended and/or succeeded, as well as other essentially
equivalent rights anywhere in the world.
l. You means the individual or entity exercising the Licensed Rights
under this Public License. Your has a corresponding meaning.
Section 2 -- Scope.
a. License grant.
1. Subject to the terms and conditions of this Public License,
the Licensor hereby grants You a worldwide, royalty-free,
non-sublicensable, non-exclusive, irrevocable license to
exercise the Licensed Rights in the Licensed Material to:
a. reproduce and Share the Licensed Material, in whole or
in part, for NonCommercial purposes only; and
b. produce, reproduce, and Share Adapted Material for
NonCommercial purposes only.
2. Exceptions and Limitations. For the avoidance of doubt, where
Exceptions and Limitations apply to Your use, this Public
License does not apply, and You do not need to comply with
its terms and conditions.
3. Term. The term of this Public License is specified in Section
6(a).
4. Media and formats; technical modifications allowed. The
Licensor authorizes You to exercise the Licensed Rights in
all media and formats whether now known or hereafter created,
and to make technical modifications necessary to do so. The
Licensor waives and/or agrees not to assert any right or
authority to forbid You from making technical modifications
necessary to exercise the Licensed Rights, including
technical modifications necessary to circumvent Effective
Technological Measures. For purposes of this Public License,
simply making modifications authorized by this Section 2(a)
(4) never produces Adapted Material.
5. Downstream recipients.
a. Offer from the Licensor -- Licensed Material. Every
recipient of the Licensed Material automatically
receives an offer from the Licensor to exercise the
Licensed Rights under the terms and conditions of this
Public License.
b. No downstream restrictions. You may not offer or impose
any additional or different terms or conditions on, or
apply any Effective Technological Measures to, the
Licensed Material if doing so restricts exercise of the
Licensed Rights by any recipient of the Licensed
Material.
6. No endorsement. Nothing in this Public License constitutes or
may be construed as permission to assert or imply that You
are, or that Your use of the Licensed Material is, connected
with, or sponsored, endorsed, or granted official status by,
the Licensor or others designated to receive attribution as
provided in Section 3(a)(1)(A)(i).
b. Other rights.
1. Moral rights, such as the right of integrity, are not
licensed under this Public License, nor are publicity,
privacy, and/or other similar personality rights; however, to
the extent possible, the Licensor waives and/or agrees not to
assert any such rights held by the Licensor to the limited
extent necessary to allow You to exercise the Licensed
Rights, but not otherwise.
2. Patent and trademark rights are not licensed under this
Public License.
3. To the extent possible, the Licensor waives any right to
collect royalties from You for the exercise of the Licensed
Rights, whether directly or through a collecting society
under any voluntary or waivable statutory or compulsory
licensing scheme. In all other cases the Licensor expressly
reserves any right to collect such royalties, including when
the Licensed Material is used other than for NonCommercial
purposes.
Section 3 -- License Conditions.
Your exercise of the Licensed Rights is expressly made subject to the
following conditions.
a. Attribution.
1. If You Share the Licensed Material (including in modified
form), You must:
a. retain the following if it is supplied by the Licensor
with the Licensed Material:
i. identification of the creator(s) of the Licensed
Material and any others designated to receive
attribution, in any reasonable manner requested by
the Licensor (including by pseudonym if
designated);
ii. a copyright notice;
iii. a notice that refers to this Public License;
iv. a notice that refers to the disclaimer of
warranties;
v. a URI or hyperlink to the Licensed Material to the
extent reasonably practicable;
b. indicate if You modified the Licensed Material and
retain an indication of any previous modifications; and
c. indicate the Licensed Material is licensed under this
Public License, and include the text of, or the URI or
hyperlink to, this Public License.
2. You may satisfy the conditions in Section 3(a)(1) in any
reasonable manner based on the medium, means, and context in
which You Share the Licensed Material. For example, it may be
reasonable to satisfy the conditions by providing a URI or
hyperlink to a resource that includes the required
information.
3. If requested by the Licensor, You must remove any of the
information required by Section 3(a)(1)(A) to the extent
reasonably practicable.
4. If You Share Adapted Material You produce, the Adapter's
License You apply must not prevent recipients of the Adapted
Material from complying with this Public License.
Section 4 -- Sui Generis Database Rights.
Where the Licensed Rights include Sui Generis Database Rights that
apply to Your use of the Licensed Material:
a. for the avoidance of doubt, Section 2(a)(1) grants You the right
to extract, reuse, reproduce, and Share all or a substantial
portion of the contents of the database for NonCommercial purposes
only;
b. if You include all or a substantial portion of the database
contents in a database in which You have Sui Generis Database
Rights, then the database in which You have Sui Generis Database
Rights (but not its individual contents) is Adapted Material; and
c. You must comply with the conditions in Section 3(a) if You Share
all or a substantial portion of the contents of the database.
For the avoidance of doubt, this Section 4 supplements and does not
replace Your obligations under this Public License where the Licensed
Rights include other Copyright and Similar Rights.
Section 5 -- Disclaimer of Warranties and Limitation of Liability.
a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE
EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS
AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF
ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS,
IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION,
WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR
PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS,
ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT
KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT
ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU.
b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE
TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION,
NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT,
INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES,
COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR
USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN
ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR
DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR
IN PART, THIS LIMITATION MAY NOT APPLY TO YOU.
c. The disclaimer of warranties and limitation of liability provided
above shall be interpreted in a manner that, to the extent
possible, most closely approximates an absolute disclaimer and
waiver of all liability.
Section 6 -- Term and Termination.
a. This Public License applies for the term of the Copyright and
Similar Rights licensed here. However, if You fail to comply with
this Public License, then Your rights under this Public License
terminate automatically.
b. Where Your right to use the Licensed Material has terminated under
Section 6(a), it reinstates:
1. automatically as of the date the violation is cured, provided
it is cured within 30 days of Your discovery of the
violation; or
2. upon express reinstatement by the Licensor.
For the avoidance of doubt, this Section 6(b) does not affect any
right the Licensor may have to seek remedies for Your violations
of this Public License.
c. For the avoidance of doubt, the Licensor may also offer the
Licensed Material under separate terms or conditions or stop
distributing the Licensed Material at any time; however, doing so
will not terminate this Public License.
d. Sections 1, 5, 6, 7, and 8 survive termination of this Public
License.
Section 7 -- Other Terms and Conditions.
a. The Licensor shall not be bound by any additional or different
terms or conditions communicated by You unless expressly agreed.
b. Any arrangements, understandings, or agreements regarding the
Licensed Material not stated herein are separate from and
independent of the terms and conditions of this Public License.
Section 8 -- Interpretation.
a. For the avoidance of doubt, this Public License does not, and
shall not be interpreted to, reduce, limit, restrict, or impose
conditions on any use of the Licensed Material that could lawfully
be made without permission under this Public License.
b. To the extent possible, if any provision of this Public License is
deemed unenforceable, it shall be automatically reformed to the
minimum extent necessary to make it enforceable. If the provision
cannot be reformed, it shall be severed from this Public License
without affecting the enforceability of the remaining terms and
conditions.
c. No term or condition of this Public License will be waived and no
failure to comply consented to unless expressly agreed to by the
Licensor.
d. Nothing in this Public License constitutes or may be interpreted
as a limitation upon, or waiver of, any privileges and immunities
that apply to the Licensor or You, including from the legal
processes of any jurisdiction or authority.
=======================================================================
Creative Commons is not a party to its public
licenses. Notwithstanding, Creative Commons may elect to apply one of
its public licenses to material it publishes and in those instances
will be considered the “Licensor.” The text of the Creative Commons
public licenses is dedicated to the public domain under the CC0 Public
Domain Dedication. Except for the limited purpose of indicating that
material is shared under a Creative Commons public license or as
otherwise permitted by the Creative Commons policies published at
creativecommons.org/policies, Creative Commons does not authorize the
use of the trademark "Creative Commons" or any other trademark or logo
of Creative Commons without its prior written consent including,
without limitation, in connection with any unauthorized modifications
to any of its public licenses or any other arrangements,
understandings, or agreements concerning use of licensed material. For
the avoidance of doubt, this paragraph does not form part of the
public licenses.
Creative Commons may be contacted at creativecommons.org.
Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

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;
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "./dist",
"composite": true
},
"include": ["config.ts"]
}
@@ -1,7 +1,18 @@
/**
* /modules
*
* Replies module listing as well as description of each.
*
* Just for the information I guess...
*
* @packageDocumentation
*/
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js"; import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
import Client from "../../../lib/client.js"; import Client from "$lib/client.js";
import logger from "../../../lib/logger.js"; import logger from "$lib/logger.js";
import replies from "../lib/replies.js"; import replies from "../lib/replies.js";
async function execute(client: Client, interaction: ChatInputCommandInteraction): Promise<void> { async function execute(client: Client, interaction: ChatInputCommandInteraction): Promise<void> {
@@ -1,13 +1,21 @@
/**
* /ping
*
* Replies websocket ping (ping between bot and discord) and real ping (ping to fetch + process).
*
* @remarks Probably should double their value as two ways, but nvm.
*
* @packageDocumentation
*/
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js"; import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
import Client from "../../../lib/client.js"; import Client from "$lib/client.js";
import logger from "../../../lib/logger.js";
import replies from "../lib/replies.js"; import replies from "../lib/replies.js";
async function execute(client: Client, interaction: ChatInputCommandInteraction): Promise<void> { async function execute(client: Client, interaction: ChatInputCommandInteraction): Promise<void> {
if (!interaction.isChatInputCommand()) { if (!interaction.isChatInputCommand()) {
logger.warn("Interaction rejected.", { interaction });
return; return;
} }
+42
View File
@@ -0,0 +1,42 @@
/**
* InteractionCreate
*
* Resolves creation of an interaction by users.
*
* @remark Only {@link ChatInputCommand} currently being resolved.
*
* @packageDocumentation
*/
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()) {
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);
}
}
const name = Events.InteractionCreate;
export { execute, name };
+50
View File
@@ -0,0 +1,50 @@
/**
* Ready
*
* Will execute upon emitting Client's Ready event.
*
* @remarks Will push slash commands, set activity status,
* and log bot's initials.
*
* @packageDocumentation
*/
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<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);
}
const name = Events.ClientReady;
export { execute, name };
@@ -1,20 +1,18 @@
import { BaseMessageOptions, Collection, EmbedBuilder, italic } from "discord.js"; import { BaseMessageOptions, Collection, EmbedBuilder, italic } from "discord.js";
import config from "../../../config.js"; import config from "$config.js";
import { ModuleMetadata } from "../../../lib/types.js"; import { Module } from "$lib/types.js";
const replies = { const replies = {
modules: (modules: Collection<string, ModuleMetadata>): BaseMessageOptions => { modules: (modules: Collection<string, Module>): BaseMessageOptions => {
const embed = new EmbedBuilder() const embed = new EmbedBuilder()
.setColor(config.colors.default) .setColor(config.colors.default)
.setTitle("Applied modules") .setTitle("Avaliable modules")
.addFields( .addFields(
modules.map((value) => { modules.map((value) => ({
return { name: value.name,
name: value.name + "@" + value.tag, value: italic(value.description ?? "")
value: italic(value.description ?? "") }))
};
})
); );
return { embeds: [embed] }; return { embeds: [embed] };
+20
View File
@@ -0,0 +1,20 @@
/**
* Core Quetza Module definition.
*
* This module provides default capabilities.
* - Comands are being resolved here at InteractionCreated event.
* - Aplication status being generated as well as commands pushed to Discord at Ready event.
*
* Also this module provides basic commands:
* - ping
* - modules
*
* There a lot of speaking because it's as important as $lib.
*
* @packageDocumentation
*/
const name = "core";
const description = "Quetza's core functionality";
export { description, name };
+16
View File
@@ -0,0 +1,16 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"paths": {
"$config.js": ["../../config.js"],
"$lib/*": ["../../src/lib/*"],
"$mlib/*": ["./lib/*"]
},
"rootDir": ".",
"outDir": "../../dist/modules/core",
"composite": true
},
"references": [{ "path": "../../src/" }]
}
@@ -1,7 +1,8 @@
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js"; import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
import Client from "../../../lib/client.js"; import Client from "$lib/client.js";
import logger from "../../../lib/logger.js"; import logger from "$lib/logger.js";
import replies from "../lib/replies.js"; import replies from "../lib/replies.js";
import { controller } from "../module.js"; import { controller } from "../module.js";
@@ -1,7 +1,8 @@
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js"; import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
import Client from "../../../lib/client.js"; import Client from "$lib/client.js";
import logger from "../../../lib/logger.js"; import logger from "$lib/logger.js";
import replies from "../lib/replies.js"; import replies from "../lib/replies.js";
import { controller } from "../module.js"; import { controller } from "../module.js";
@@ -1,7 +1,8 @@
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js"; import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
import Client from "../../../lib/client.js"; import Client from "$lib/client.js";
import logger from "../../../lib/logger.js"; import logger from "$lib/logger.js";
import Filter from "../lib/filter.js"; import Filter from "../lib/filter.js";
import replies from "../lib/replies.js"; import replies from "../lib/replies.js";
import { controller } from "../module.js"; import { controller } from "../module.js";
@@ -35,11 +36,7 @@ const data = new SlashCommandBuilder()
option option
.setName("filter") .setName("filter")
.setDescription("Filter to apply.") .setDescription("Filter to apply.")
.setChoices( .setChoices(...Object.values(Filter.FILTERS))
...Object.keys(Filter.filters).map((key) => {
return { name: key, value: key };
})
)
) )
.setDMPermission(false); .setDMPermission(false);
@@ -1,7 +1,8 @@
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js"; import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
import Client from "../../../lib/client.js"; import Client from "$lib/client.js";
import logger from "../../../lib/logger.js"; import logger from "$lib/logger.js";
import replies from "../lib/replies.js"; import replies from "../lib/replies.js";
import { controller } from "../module.js"; import { controller } from "../module.js";
@@ -1,7 +1,8 @@
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js"; import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
import Client from "../../../lib/client.js"; import Client from "$lib/client.js";
import logger from "../../../lib/logger.js"; import logger from "$lib/logger.js";
import replies from "../lib/replies.js"; import replies from "../lib/replies.js";
import { controller } from "../module.js"; import { controller } from "../module.js";
@@ -1,7 +1,8 @@
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js"; import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
import Client from "../../../lib/client.js"; import Client from "$lib/client.js";
import logger from "../../../lib/logger.js"; import logger from "$lib/logger.js";
import replies from "../lib/replies.js"; import replies from "../lib/replies.js";
import { controller } from "../module.js"; import { controller } from "../module.js";
@@ -1,7 +1,8 @@
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js"; import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
import Client from "../../../lib/client.js"; import Client from "$lib/client.js";
import logger from "../../../lib/logger.js"; import logger from "$lib/logger.js";
import replies from "../lib/replies.js"; import replies from "../lib/replies.js";
import { LoopOption } from "../lib/types.js"; import { LoopOption } from "../lib/types.js";
import { controller } from "../module.js"; import { controller } from "../module.js";
@@ -1,7 +1,8 @@
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js"; import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
import Client from "../../../lib/client.js"; import Client from "$lib/client.js";
import logger from "../../../lib/logger.js"; import logger from "$lib/logger.js";
import replies from "../lib/replies.js"; import replies from "../lib/replies.js";
import { controller } from "../module.js"; import { controller } from "../module.js";
@@ -1,7 +1,8 @@
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js"; import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
import Client from "../../../lib/client.js"; import Client from "$lib/client.js";
import logger from "../../../lib/logger.js"; import logger from "$lib/logger.js";
import replies from "../lib/replies.js"; import replies from "../lib/replies.js";
import { controller } from "../module.js"; import { controller } from "../module.js";
@@ -1,7 +1,8 @@
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js"; import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
import Client from "../../../lib/client.js"; import Client from "$lib/client.js";
import logger from "../../../lib/logger.js"; import logger from "$lib/logger.js";
import replies from "../lib/replies.js"; import replies from "../lib/replies.js";
import { controller } from "../module.js"; import { controller } from "../module.js";
@@ -8,8 +8,9 @@ import {
SlashCommandBuilder SlashCommandBuilder
} from "discord.js"; } from "discord.js";
import Client from "../../../lib/client.js"; import Client from "$lib/client.js";
import logger from "../../../lib/logger.js"; import logger from "$lib/logger.js";
import replies from "../lib/replies.js"; import replies from "../lib/replies.js";
import { controller } from "../module.js"; import { controller } from "../module.js";
@@ -1,7 +1,8 @@
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js"; import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
import Client from "../../../lib/client.js"; import Client from "$lib/client.js";
import logger from "../../../lib/logger.js"; import logger from "$lib/logger.js";
import replies from "../lib/replies.js"; import replies from "../lib/replies.js";
import { controller } from "../module.js"; import { controller } from "../module.js";
@@ -1,7 +1,8 @@
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js"; import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
import Client from "../../../lib/client.js"; import Client from "$lib/client.js";
import logger from "../../../lib/logger.js"; import logger from "$lib/logger.js";
import replies from "../lib/replies.js"; import replies from "../lib/replies.js";
import { controller } from "../module.js"; import { controller } from "../module.js";
@@ -1,7 +1,8 @@
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js"; import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
import Client from "../../../lib/client.js"; import Client from "$lib/client.js";
import logger from "../../../lib/logger.js"; import logger from "$lib/logger.js";
import replies from "../lib/replies.js"; import replies from "../lib/replies.js";
import { controller } from "../module.js"; import { controller } from "../module.js";
@@ -1,7 +1,8 @@
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js"; import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
import Client from "../../../lib/client.js"; import Client from "$lib/client.js";
import logger from "../../../lib/logger.js"; import logger from "$lib/logger.js";
import replies from "../lib/replies.js"; import replies from "../lib/replies.js";
import { controller } from "../module.js"; import { controller } from "../module.js";
@@ -1,7 +1,8 @@
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js"; import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
import Client from "../../../lib/client.js"; import Client from "$lib/client.js";
import logger from "../../../lib/logger.js"; import logger from "$lib/logger.js";
import replies from "../lib/replies.js"; import replies from "../lib/replies.js";
import { controller } from "../module.js"; import { controller } from "../module.js";
@@ -0,0 +1,66 @@
/**
* voiceStateUpdate
*
* Monitors voice states of attended guilds.
* If Quetza happens to be alone in the Voice Channel - destroys guilds player and disconnects.
*
* @packageDocumentation
*/
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.
*
* @internal
*/
function resolveVoiceChannel(
user: User,
...channels: (BaseGuildVoiceChannel | null)[]
): BaseGuildVoiceChannel | null {
return (
channels.find((channel) => channel?.members.some((member) => member.user === user)) ?? null
);
}
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();
}
}
}
const name = "voiceStateUpdate";
export { execute, name };
+15
View File
@@ -0,0 +1,15 @@
/**
* @see ../../../src/lib/error.ts
*
* @packageDocumentation
*/
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);
}
}
+75
View File
@@ -0,0 +1,75 @@
import { DlpDump, dlpExtractorKey, DlpInfo } from "$mlib/types.js";
/**
* Avaliable extractors.
*
* @internal
*/
export const dlpExtractorsValues = ["Youtube", "Soundcloud"] as const;
/**
* Defines structure for an extractor that will be called by dlpInfo.
*
* For example:
*
* @see the {@link youtubeExtractor | youtubeExtractor(...) } function
*
* @internal
*/
type dlpExtractor = (arg0: DlpDump, arg1: (arg0: string) => Promise<DlpInfo>) => Promise<DlpInfo>;
/**
* Resolves data that was extracted by dlp (depending on extractor_key).
*
* @returns DlpInfo generated from dump provided
*
* @param dump - Parsed dlp output
* @param dlpInfo - Function to call if any other information needed
* (will recurcively call next extractor after its run)
*
* @public
*/
const youtubeExtractor: dlpExtractor = async (dump, dlpInfo) => {
if (dump.id && dump.title && dump.duration) {
const result = {
url: `https://www.youtube.com/watch?v=${dump.id}`,
title: dump.title,
thumbnail: `https://img.youtube.com/vi/${dump.id}/default.jpg`,
duration: dump.duration
};
return [result];
}
return await dlpInfo(dump.original_url);
};
/**
* {@inheritdoc youtubeExtractor}
*/
const soundcloudExtractor: dlpExtractor = async (dump, dlpInfo) => {
if (dump.title && dump.duration && dump.thumbnail) {
const result = {
url: dump.webpage_url ?? dump.original_url,
title: dump.title,
thumbnail: dump.thumbnail,
duration: dump.duration
};
return [result];
}
return await dlpInfo(dump.original_url);
};
/**
* DlpDump extractor_key values mapped to corresponding extractor.
*
* @public
*/
const extractors: Record<dlpExtractorKey, dlpExtractor> = {
Youtube: youtubeExtractor,
Soundcloud: soundcloudExtractor
};
export default extractors;
+199
View File
@@ -0,0 +1,199 @@
import { execFile } from "child_process";
import path from "path";
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 MusicError from "$mlib/MusicError.js";
import { DlpDump, DlpInfo, dlpStreamOptions } from "../types.js";
import extractors from "./extractors.js";
const { FFmpeg } = prismMedia;
const execFileAsync = promisify(execFile);
/**
* Maximum length of dlp search stack.
*
* @internal
*/
const DLPINFO_DEPTH = 3;
/**
* Path to the Dlp binary.
*
* @internal
*/
const DLP_BINARY = path.join(config.path.binaries, "/yt-dlp");
/**
* Base arguments for Ffmpeg transcoder.
*
* @internal
*/
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.
*
* @returns true if given string is a valid URL, false otherwise
*
* @param str - Any string
*
* @internal
*/
function isURL(str: string): boolean {
return /^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)$/.test(
str
);
}
/**
* Sub-function of dlpInfo.
*
* @tutorial Shall be used only inside original function
*
* @returns Info of the media that related to query, and ones that cannot be found
*
* @param query - Dlp search request or compatible URL
* @param depth - Recursion depth (force stops at 0)
*
* @internal
*/
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 });
throw new MusicError(message, reason);
};
if (!depth) {
return error("Dlp: recursion depth reached", 1010);
}
const subprocess = await execFileAsync(DLP_BINARY, [
query,
"-O",
"%(.{id,original_url,title,uploader,duration,extractor_key,thumbnail})j",
"-s",
"--flat-playlist"
]);
if (!subprocess.stdout) {
return error("Dlp: no stdout", 1011);
}
if (subprocess.stderr) {
return error("Dlp: stderr", 1012, {
stderr: subprocess.stderr
});
}
const dumps: DlpDump[] = subprocess.stdout
.trim()
.split("\n")
.map((out) => JSON.parse(out));
const resolveDump = async (dump: DlpDump): Promise<DlpInfo> => {
if (!Object.hasOwn(extractors, dump.extractor_key)) {
error("Dlp: illegal extractor", 1013);
}
return extractors[dump.extractor_key](dump, (subquery: string) =>
dlpInfoRecursive(subquery, depth - 1)
);
};
const result = await Promise.all(dumps.map(resolveDump));
return result.flat();
}
/**
* Leverages dlp to fetch metadata of media accessible by the extractors.
*
* @returns Metadata to a youtube video that related to query by title or URL of findings
*
* @param query - Dlp search request or compatible URL
*
* @public
*/
export async function dlpInfo(query: string): Promise<DlpInfo> {
return await dlpInfoRecursive(isURL(query) ? query : "ytsearch1:" + query);
}
/**
* Creates Opus stream by downloading media via dlp.
*
* @returns Readable stream of requested media
*
* @param url - Dlp compatible URL to the requested resource
* @param options - Transcoders and dlp options
*
* @public
*/
export function dlpStream(url: string, options?: dlpStreamOptions): Promise<Readable> {
let ffmpegArgs = FFMPEG_BASE_ARGS;
if (options?.seek) {
ffmpegArgs.unshift("-ss", options.seek.toString());
}
if (options?.ffmpeg) {
ffmpegArgs = ffmpegArgs.concat(options.ffmpeg);
}
return new Promise<Readable>((resolve, reject) => {
const subprocess = execFile(
DLP_BINARY,
[
url,
"-o",
"-",
"--verbose",
"-f",
"bestaudio[ext=webm][acodec=opus][asr=48000]/bestaudio",
"--rm-cache-dir",
"-r",
"1M"
],
{ encoding: null, maxBuffer: 25000000 }
);
if (!subprocess.stdout || !subprocess.stderr) {
throw new Error("No stdout");
}
const ffmpegTranscoder = new FFmpeg({ args: ffmpegArgs });
const stream = subprocess.stdout.pipe(ffmpegTranscoder);
const onError = (err: Error) => {
if (!subprocess.killed) {
subprocess.kill();
}
reject(err);
stream.resume();
};
subprocess
.once("spawn", () => {
resolve(stream);
})
.on("error", onError);
});
}
+113
View File
@@ -0,0 +1,113 @@
/**
* Filter entry.
*
* @internal
*/
type filterValue = {
/** Public well formatted name of the filter. */
name: string;
/** FFmpeg argument of this filter. */
value: string;
};
/**
* Record mapping id of filter with its {@link filterValue}.
*
* @internal
*/
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 build of {@link FILTERS} keys.
*
* @internal
*/
type filterPreset = keyof typeof FILTERS;
/**
* {@link ./player#Player | Player's } filters options.
*
* Resolves FFMpeg transcoder arguments that tweak playback stream.
*
* @public
*/
export default class Filter {
/**
* Filter list
*/
public static readonly FILTERS = FILTERS;
/**
* Active filters. Mock of {@link FILTERS} object.
*/
private switches_ = new Map<filterPreset, filterValue>();
/**
* Converts active {@link filterValue | filterValues} to array.
*
* @returns Array of active filter values.
*/
public toggled(): filterValue[] {
return [...this.switches_.values()];
}
/**
* Toggles filter specified. Clears them all if no arguments provided.
*
* @returns True if filter was toggled, False otherwise.
*
* @param query - filter id to toggle. Undefined to clear all.
*/
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;
}
/**
* Calculates of active filter map.
*
* @returns Size of active filter map.
*/
public size(): number {
return this.switches_.size;
}
/**
* Checks if there are any active filter.
*
* @returns size === 0 ? True : False.
*/
public empty(): boolean {
return this.size() === 0;
}
/**
* Converts active filters to FFmpeg compatable argument.
*
* @returns FFmpeg compatable argument representing active filters.
*/
public toString(): string {
return this.toggled()
.map((value) => value.value)
.join(",");
}
}
@@ -2,8 +2,26 @@ import { AudioResource } from "@discordjs/voice";
import { LoopOption, Track } from "./types.js"; import { LoopOption, Track } from "./types.js";
/**
* Length of playback status bar in emoji symbols.
*
* @intenal
*/
const STATUSBAR_LENGTH = 15; const STATUSBAR_LENGTH = 15;
/**
* Generates pretty playback status bar out of {@link AudioResource}.
*
* @remarks Takes duration and current playback duration to 'draw' bar.
*
* @returns String representing generated status bar.
*
* @param resource - AudioResource with {@link Track} metadata, so duration and playback duration is available.
*
* @privateRemarks I don't know why resource can be undefined - I forgor.
*
* @public
*/
export function statusBarGenerator(resource?: AudioResource<Track>): string { export function statusBarGenerator(resource?: AudioResource<Track>): string {
const duration = resource?.metadata.duration || 1; const duration = resource?.metadata.duration || 1;
const playback = Math.min((resource?.playbackDuration || 0) / 1000, duration); const playback = Math.min((resource?.playbackDuration || 0) / 1000, duration);
@@ -15,6 +33,20 @@ export function statusBarGenerator(resource?: AudioResource<Track>): string {
return `[${filled.join("")}${empty.join("")}] ${toISOTime(playback)}/${toISOTime(duration)}`; return `[${filled.join("")}${empty.join("")}] ${toISOTime(playback)}/${toISOTime(duration)}`;
} }
/**
* Converts seconds to ISO time string.
*
* @returns String representing given seconds in ISO format.
*
* @param amount - Seconds to convert.
*
* @example
* ```
* toISOTime(1337228); // "371:27:08"
* ```
*
* @public
*/
export function toISOTime(amount: number): string { export function toISOTime(amount: number): string {
const h = Math.floor(amount / 3600); const h = Math.floor(amount / 3600);
const m = Math.floor((amount % 3600) / 60); const m = Math.floor((amount % 3600) / 60);
@@ -27,19 +59,60 @@ export function toISOTime(amount: number): string {
return hDisplay + mDisplay + sDisplay; return hDisplay + mDisplay + sDisplay;
} }
/**
* Asynchronous timeout.
*
* @returns Promise that will be settled after timeout pass.
*
* @param amount - Milliseconds to wait before settle.
*
* @public
*/
export async function asleep(amount: number): Promise<unknown> { export async function asleep(amount: number): Promise<unknown> {
return new Promise((r) => setTimeout(r, amount)); return new Promise((r) => setTimeout(r, amount));
} }
/**
* Converts volume value to emoji representation.
*
* @remarks In span of 0 ... 100 - emoji from muted to loud.
*
* @returns String with single emoji representing specified volume.
*
* @param amount - volume to convert.
*
* @public
*/
export function volumeMoji(amount: number): string { export function volumeMoji(amount: number): string {
return amount > 100 ? "🔊" : amount > 50 ? "🔉" : amount > 0 ? "🔈" : "🔇"; return amount > 100 ? "🔊" : amount > 50 ? "🔉" : amount > 0 ? "🔈" : "🔇";
} }
/**
* Converts {@link LoopOption} to emoji representation..
*
* @returns String with single emoji representing specified loop option.
*
* @param loop - Loop option that will be converted.
*
* @public
*/
export function loopMoji(loop: LoopOption): string { export function loopMoji(loop: LoopOption): string {
return loop === "AUTO" ? "🔀" : loop === "LOOP" ? "🔁" : loop === "NONE" ? "⤵️" : "🔂"; return loop === "AUTO" ? "🔀" : loop === "LOOP" ? "🔁" : loop === "NONE" ? "⤵️" : "🔂";
} }
export function largestCommonSequence(s1: string, s2: string) { /**
* Finds larges common sequence (LCS) of characters withing provided strings.
*
* @remarks Used to fuzzy search query title of Track.
*
* @returns Length of the LCS found.
*
* @param s1 - First string.
* @param s2 - Second string.
*
* @public
*/
export function largestCommonSequence(s1: string, s2: string): number {
s1 = s1.toLowerCase().slice(0, 100); s1 = s1.toLowerCase().slice(0, 100);
s2 = s2.toLowerCase().slice(0, 100); s2 = s2.toLowerCase().slice(0, 100);
+67
View File
@@ -0,0 +1,67 @@
import { Collection, Guild, GuildTextBasedChannel } from "discord.js";
import logger from "$lib/logger.js";
import Player from "./player.js";
/**
* Module's controller.
*
* Binds guilds to player.
* Creates and destroys them.
*/
export default class Music {
/**
* GuildId to {@link Player} mapping.
*/
private players_ = new Collection<string, Player>();
/**
* Retrieves {@link Player} by {@link Guild}.
*
* @returns Player if such mapping exists or undefined otherwise.
*
* @param guild - Discord guild that might has a Player binded.
* @param channel - Text channel that should replace Player's one.
*/
public get(guild: Guild, channel?: GuildTextBasedChannel): Player | undefined {
const player = this.players_.get(guild.id);
if (player) {
if (channel) {
player.channel = channel;
}
return player;
}
}
/**
* Creates {@link Player} and maps it to GuildId.
*
* @returns Newly created Player.
*
* @param guild - {@link Guild} that created Player will be binded to.
* @param channel - Text channel that used by Player constructor.
*/
public set(guild: Guild, channel: GuildTextBasedChannel): Player {
const player = new Player(guild, this, channel);
this.players_.set(guild.id, player);
logger.info("Player was created.", { player });
return player;
}
/**
* Deleted {@link Player} and its mapping by GuildId.
*
* @param guildId - Id of {@link Guild} that must be removed from mapping.
*/
public delete(guildId: string): void {
logger.info("Player was deleted.", { guildId });
this.players_.delete(guildId);
}
}
@@ -10,7 +10,9 @@ import {
} from "@discordjs/voice"; } from "@discordjs/voice";
import { Guild, GuildTextBasedChannel, Message, User, VoiceBasedChannel } from "discord.js"; import { Guild, GuildTextBasedChannel, Message, User, VoiceBasedChannel } from "discord.js";
import { fetchInfo, ytdlStream } from "./fetch.js"; import { clamp } from "$lib/misc.js";
import { dlpInfo, dlpStream } from "./fetch/fetch.js";
import Filter from "./filter.js"; import Filter from "./filter.js";
import { largestCommonSequence } from "./misc.js"; import { largestCommonSequence } from "./misc.js";
import Music from "./music.js"; import Music from "./music.js";
@@ -18,10 +20,33 @@ import Queue from "./queue.js";
import replies from "./replies.js"; import replies from "./replies.js";
import { Track } from "./types.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 { 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; private channel_: GuildTextBasedChannel;
public get channel(): GuildTextBasedChannel { public get channel(): GuildTextBasedChannel {
return this.channel_; return this.channel_;
@@ -30,44 +55,82 @@ export default class Player {
this.channel_ = x; this.channel_ = x;
} }
/**
* Previous notification message.
*/
private message_?: Message; private message_?: Message;
/**
* A connection to the voice channel of a {@link Guild}.
*
* @readonly
*/
private connection_?: VoiceConnection; private connection_?: VoiceConnection;
public get connection(): VoiceConnection | undefined { public get connection(): VoiceConnection | undefined {
return this.connection_; return this.connection_;
} }
/**
* Player for {@link AudioResource} that is subscribed to the most recent {@link VoiceConnection}.
*
* @readonly
*/
private player_: AudioPlayer; private player_: AudioPlayer;
public get player(): AudioPlayer { public get player(): AudioPlayer {
return this.player_; return this.player_;
} }
/**
* Represents an Audio Resource that can be played by {@link AudioPlayer}.
*
* @readonly
*/
private resource_: AudioResource<Track> | undefined; private resource_: AudioResource<Track> | undefined;
public get resource(): AudioResource<Track> | undefined { public get resource(): AudioResource<Track> | undefined {
return this.resource_; return this.resource_;
} }
/**
* Inline volume for an {@link AudioResource}.
* Must be clamped by {@link VOLUME_CLAMP}.
*/
private volume_ = 100; private volume_ = 100;
public get volume(): number { public get volume(): number {
return this.volume_; return this.volume_;
} }
public set volume(x: number) { 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); this.resource_.volume.setVolumeLogarithmic(this.volume_ / 100);
} }
} }
/**
* Queue manager of the {@link Player}.
*/
public readonly queue = new Queue(); public readonly queue = new Queue();
/**
* Filter manager of the {@link Player}.
*/
public readonly filter = new Filter(); public readonly filter = new Filter();
/**
* Timestamp when {@link Player} was created.
*/
public readonly createdAt: Date = new Date(); 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) { public constructor(guild: Guild, music: Music, channel: GuildTextBasedChannel) {
this.music = music; this.music_ = music;
this.guild = guild; this.guild_ = guild;
this.channel_ = channel; this.channel_ = channel;
this.player_ = createAudioPlayer(); this.player_ = createAudioPlayer();
@@ -75,16 +138,21 @@ export default class Player {
this.player_.on(AudioPlayerStatus.Idle, async () => this.resourceEndResolvable()); this.player_.on(AudioPlayerStatus.Idle, async () => this.resourceEndResolvable());
} }
private resourceGenerator(track: Track, seek?: number) { /**
const stream = ytdlStream(track.url, { * Creates {@link AudioResource | an Audio Resource} for {@link an AudioPlayer}.
opusEncoded: true, *
filter: "audioonly", * @returns Configured audio resource ready to be played
highWaterMark: 1 << 25, *
* @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, seek: seek ? seek / 1000 : undefined,
encoderArgs: !this.filter.empty() ? ["-af", this.filter.toString()] : undefined ffmpeg: !this.filter.empty() ? ["-af", this.filter.toString()] : undefined
}); });
this.resource_ = createAudioResource(stream, { this.resource_ = createAudioResource(probe, {
inlineVolume: true, inlineVolume: true,
metadata: track metadata: track
}); });
@@ -100,6 +168,13 @@ export default class Player {
return this.resource_; 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 { private resourceEndResolvable(): void {
if (this.resource_) { if (this.resource_) {
this.resource_.encoder?.destroy(); this.resource_.encoder?.destroy();
@@ -113,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) { private async messageResolvable(track: Track) {
if (this.message_?.deletable) { if (this.message_?.deletable) {
await this.message_.delete().catch(); await this.message_.delete().catch();
@@ -121,18 +202,27 @@ export default class Player {
this.message_ = await this.channel_.send(replies.nowPlaying(track)); 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> { private async search(query: string, requester: User): Promise<Track[] | undefined> {
const info = await fetchInfo(query); const info = await dlpInfo(query);
if (info.length === 0) {
return undefined;
}
return info.map((value) => { return info.map((value) => {
return { ...value, requester: requester }; 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> { public async play(seek?: number): Promise<void> {
const track = this.queue.current(); const track = this.queue.current();
@@ -140,13 +230,19 @@ export default class Player {
return; return;
} }
this.player_.play(this.resourceGenerator(track, seek)); this.player_.play(await this.resourceGenerator(track, seek));
this.connection_.subscribe(this.player_); this.connection_.subscribe(this.player_);
await this.messageResolvable(track); 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 { public connect(channel: VoiceBasedChannel): void {
if ( if (
this.connection_?.joinConfig.channelId === channel.id && this.connection_?.joinConfig.channelId === channel.id &&
@@ -168,6 +264,17 @@ export default class Player {
this.connection_.subscribe(this.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> { public async add(query: string, requester: User, index?: number): Promise<Track | undefined> {
const tracks = await this.search(query, requester); const tracks = await this.search(query, requester);
@@ -180,6 +287,16 @@ export default class Player {
return tracks[0]; 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 { public jump(query: number | string): number | undefined {
let position = typeof query === "number" ? query : -1; let position = typeof query === "number" ? query : -1;
let diff = 0; let diff = 0;
@@ -202,6 +319,16 @@ export default class Player {
return undefined; 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 { public remove(query?: number | string): Track | undefined {
const oldPosition = this.queue.position; const oldPosition = this.queue.position;
@@ -230,10 +357,18 @@ export default class Player {
return undefined; return undefined;
} }
/**
* Restarts playback from timecode specified.
*
* @param time - Amount of seconds to start playback from
*/
public seek(time: number): void { public seek(time: number): void {
this.play(time); this.play(time);
} }
/**
* Clears {@link queue} completely - not destroying {@link voiceConnection_}.
*/
public clear(): void { public clear(): void {
this.queue.clear(); this.queue.clear();
this.resource_ = undefined; this.resource_ = undefined;
@@ -243,15 +378,23 @@ export default class Player {
} }
} }
/**
* Destroys this instance of {@link Player} and deletes it from controller mapping.
*/
public destroy(): void { public destroy(): void {
if (this.connection_) { if (this.connection_) {
this.connection_.destroy(); this.connection_.destroy();
} }
this.player_.stop(); 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 { public pause(): boolean {
if (this.player_.state.status === AudioPlayerStatus.Paused) { if (this.player_.state.status === AudioPlayerStatus.Paused) {
this.player_.unpause(); this.player_.unpause();
@@ -262,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 { public shuffle(): void {
this.queue.shuffle(); this.queue.shuffle();
@@ -271,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 { public skip(): void {
this.player_.stop(); 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 { public setFilter(filter?: string): boolean {
const status = this.filter.toggle(filter); const status = this.filter.toggle(filter);
@@ -1,16 +1,32 @@
import { LoopOption, Track } from "./types.js"; import { LoopOption, Track } from "./types.js";
/**
* Class of {@link ./player#Player | Player's} queue.
*
* @public
*/
export default class Queue { export default class Queue {
/**
* Array of {@link Track | Tracks} - content of queue.
*/
private tracks_: Track[] = []; private tracks_: Track[] = [];
public get tracks(): Track[] { public get tracks(): Track[] {
return this.tracks_; return this.tracks_;
} }
/**
* Position of current playing track.
*/
private position_ = 0; private position_ = 0;
public get position(): number { public get position(): number {
return this.position_; return this.position_;
} }
/**
* Defines behavior of queue.
*
* @see ./types.ts for more info about values.
*/
private loop_: LoopOption = "NONE"; private loop_: LoopOption = "NONE";
public get loop(): LoopOption { public get loop(): LoopOption {
return this.loop_; return this.loop_;
@@ -19,11 +35,21 @@ export default class Queue {
this.loop_ = x; this.loop_ = x;
} }
/**
* Queue overall duration in seconds.
*/
private duration_ = 0; private duration_ = 0;
public get duration(): number { public get duration(): number {
return this.duration_; return this.duration_;
} }
/**
* Skips current track.
*
* @remarks Behavior depends on loop option.
*
* @returns next {@link Track} or undefined if end was reached.
*/
public next(): Track | undefined { public next(): Track | undefined {
if (this.loop_ === "SONG") { if (this.loop_ === "SONG") {
return this.current(); return this.current();
@@ -46,6 +72,9 @@ export default class Queue {
} }
} }
/**
* Shuffles queue by using simple Math.random.
*/
public shuffle(): void { public shuffle(): void {
let currentIndex = this.tracks_.length; let currentIndex = this.tracks_.length;
@@ -62,15 +91,23 @@ export default class Queue {
this.position_ = 0; this.position_ = 0;
} }
/**
* Inserts tracks from provided array at specific index.
*
* @returns first {@link Track} of inserted Tracks.
*
* @param tracks - List of Tracks to insert to the queue.
* @param index - Position at which insertion will be occurred.
*/
public push(tracks: Track[], index?: number): Track { public push(tracks: Track[], index?: number): Track {
for (const track of tracks) { for (const track of tracks) {
this.duration_ += track.duration; this.duration_ += track.duration;
} }
if (index === undefined || index >= this.tracks_.length) { if (!index || index >= this.tracks_.length) {
this.tracks_ = this.tracks_.concat(tracks); this.tracks_ = this.tracks_.concat(tracks);
return this.tracks_[this.tracks_.length - 1]; return this.tracks_[this.tracks_.length - tracks.length];
} }
this.tracks_.splice(Math.max(0, index), 0, ...tracks); this.tracks_.splice(Math.max(0, index), 0, ...tracks);
@@ -81,6 +118,13 @@ export default class Queue {
return this.tracks_[Math.max(0, index)]; return this.tracks_[Math.max(0, index)];
} }
/**
* Removes {@link Track} from the queue at position specified.
*
* @returns Track that was deleted or undefined if not.
*
* @param query - Index of element that must be deleted.
*/
public pop(query: number): Track | undefined { public pop(query: number): Track | undefined {
if (query >= 0 && query < this.tracks_.length) { if (query >= 0 && query < this.tracks_.length) {
const track = this.tracks_[query]; const track = this.tracks_[query];
@@ -94,6 +138,11 @@ export default class Queue {
return undefined; return undefined;
} }
/**
* Assigns position new value.
*
* @returns {@link Track} at position specified or undefined if index out of bounds.
*/
public jump(query: number): Track | undefined { public jump(query: number): Track | undefined {
if (query >= 0 && query < this.tracks_.length) { if (query >= 0 && query < this.tracks_.length) {
const track = this.tracks_[query]; const track = this.tracks_[query];
@@ -104,14 +153,27 @@ export default class Queue {
return undefined; return undefined;
} }
/**
* Returns current {@link Track} (at position).
*
* @returns Track at internal position.
*/
public current(): Track { public current(): Track {
return this.tracks_[this.position_]; return this.tracks_[this.position_];
} }
/**
* Checks if queue is empty.
*
* @returns True is queue is empty, False otherwise.
*/
public empty(): boolean { public empty(): boolean {
return this.tracks_.length === 0; return this.tracks_.length === 0;
} }
/**
* Clears entire queue and sets position to 0.
*/
public clear(): void { public clear(): void {
this.tracks_ = []; this.tracks_ = [];
this.position_ = 0; this.position_ = 0;
@@ -1,7 +1,8 @@
import { AudioResource } from "@discordjs/voice"; import { AudioResource } from "@discordjs/voice";
import { BaseMessageOptions, EmbedBuilder, italic } from "discord.js"; 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 { loopMoji, statusBarGenerator, toISOTime, volumeMoji } from "./misc.js";
import Player from "./player.js"; import Player from "./player.js";
import Queue from "./queue.js"; import Queue from "./queue.js";
@@ -338,7 +339,7 @@ const replies = {
value: value:
player.filter player.filter
.toggled() .toggled()
.map((value) => value[0]) .map((value) => value.name)
.join("\n") + "\ndefault", .join("\n") + "\ndefault",
inline: true inline: true
}, },
+99
View File
@@ -0,0 +1,99 @@
import { User } from "discord.js";
import { dlpExtractorsValues } from "./fetch/extractors.js";
/**
* Track information fetched by Dlp.
*
* @public
*/
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.
*
* @public
*/
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;
*
* @public
*/
export type LoopOption = "LOOP" | "SONG" | "NONE" | "AUTO";
/**
* Dlp fetched track list.
*
* @public
*/
export type DlpInfo = TrackBase[];
/**
* Dlp supported extractors as types.
*
* @public
*/
export type dlpExtractorKey = (typeof dlpExtractorsValues)[number];
/**
* Actual Dlp dump that later translates to {@link DlpInfo}.
*
* @public
*/
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.
*
* @public
*/
export interface dlpStreamOptions {
/** Amount of seconds to start playback from. */
seek?: number;
/** Args for FFMPEG transcoder. */
ffmpeg?: string[];
}
+16
View File
@@ -0,0 +1,16 @@
/**
* Music module definition.
*
* Module to play media content from various sites.
* @see ./lib/fetch/extractors.ts for site extractors.
*
* @packageDocumentation
*/
import Music from "./lib/music.js";
const name = "music";
const description = "Music module";
const controller = new Music();
export { controller, description, name };
+16
View File
@@ -0,0 +1,16 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"paths": {
"$config.js": ["../../config.js"],
"$lib/*": ["../../src/lib/*"],
"$mlib/*": ["./lib/*"]
},
"rootDir": ".",
"outDir": "../../dist/modules/music",
"composite": true
},
"references": [{ "path": "../../src/" }]
}
+24 -31
View File
@@ -1,61 +1,54 @@
{ {
"name": "quetza-bot", "name": "quetza",
"version": "4.2.3", "version": "2024.3.0",
"description": "Discord multipurpose bot", "description": "Awesome Discord bot that performs various duties",
"type": "module", "type": "module",
"main": "./dist/index.js", "main": "./dist/src/index.js",
"scripts": { "scripts": {
"start": "node ./dist/index.js", "start": "node ./dist/src/index.js",
"build": "pnpm exec tsc --build", "build": "./build.sh",
"clean": "rm -rf ./dist && pnpm run build", "clean": "rm -rf ./dist",
"lint": "pnpm exec eslint . && pnpm exec prettier --check .", "lint": "pnpm exec eslint . && pnpm exec prettier --check .",
"format": "pnpm exec eslint --fix . && pnpm exec prettier --write .", "format": "pnpm exec eslint --fix . && pnpm exec prettier --write .",
"preinstall": "pnpm dlx only-allow pnpm" "preinstall": "pnpm dlx only-allow pnpm"
}, },
"license": "MIT", "license": "MIT",
"keywords": [
"discord",
"bot",
"music"
],
"repository": { "repository": {
"type": "git", "type": "git",
"url": "git+https://github.com/unknowableshade/quetza-bot.git" "url": "git+https://github.com/unknowableshade/Quetza.git"
}, },
"author": { "author": {
"name": "unknowableshade", "name": "Vladimir Eremin",
"email": "60275123+unknowableshade@users.noreply.github.com", "email": "personal@ermnvldmr.com",
"url": "https://unknowableshade.ru" "url": "https://ermnvldmr.com"
}, },
"bugs": { "bugs": {
"url": "https://github.com/unknowableshade/quetza-bot/issues" "url": "https://github.com/unknowableshade/Quetza/issues"
}, },
"readme": "https://github.com/unknowableshade/quetza-bot#readme", "readme": "https://github.com/unknowableshade/Quetza#readme",
"engines": { "engines": {
"node": "18.x.x", "node": "20.x.x",
"npm": "*" "npm": "*"
}, },
"dependencies": { "dependencies": {
"@discordjs/opus": "^0.9.0", "@discordjs/opus": "^0.9.0",
"@discordjs/voice": "^0.16.1", "@discordjs/voice": "^0.16.1",
"@prisma/client": "5.7.0",
"discord.js": "^14.14.1", "discord.js": "^14.14.1",
"prism-media": "^1.3.5", "prism-media": "^1.3.5",
"tweetnacl": "^1.0.3", "tweetnacl": "^1.0.3",
"winston": "^3.11.0", "winston": "^3.11.0"
"ytdl-core": "^4.11.5"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20.10.4", "@types/node": "^20.11.24",
"@typescript-eslint/eslint-plugin": "^6.13.2", "@typescript-eslint/eslint-plugin": "^7.1.0",
"@typescript-eslint/parser": "^6.13.2", "@typescript-eslint/parser": "^7.1.0",
"eslint": "^8.55.0", "eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^9.1.0",
"eslint-plugin-import": "^2.29.0", "eslint-plugin-import": "^2.29.1",
"eslint-plugin-simple-import-sort": "^10.0.0", "eslint-plugin-simple-import-sort": "^12.0.0",
"mocha": "^10.2.0", "mocha": "^10.3.0",
"prettier": "^3.1.0", "prettier": "^3.2.5",
"prisma": "^5.7.0", "tsc-alias": "^1.8.8",
"typescript": "^5.3.3" "typescript": "^5.3.3"
} }
} }
+539 -477
View File
File diff suppressed because it is too large. Load diff
@@ -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 { 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";
/**
* Ready to go bot instance.
*
* @remarks Quetza itself.
*/
const client = new Client({ const client = new Client({
intents: [ intents: [
GatewayIntentBits.Guilds, GatewayIntentBits.Guilds,
@@ -11,4 +16,4 @@ const client = new Client({
] ]
}); });
client.login(config.discordToken); client.login(config.application.token);
+108 -67
View File
@@ -1,91 +1,132 @@
import { PrismaClient } from "@prisma/client";
import { Client as DiscordClient, ClientOptions, Collection } from "discord.js"; import { Client as DiscordClient, ClientOptions, Collection } from "discord.js";
import { existsSync, lstatSync, readdirSync } from "fs"; import { readdirSync } from "fs";
import path from "path";
import { pathToFileURL } from "url";
import config from "../config.js"; import config from "$config.js";
import { Command, Event, Module, ModuleMetadata } from "./types.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"; * Creating path to {@link Module | Module's} declaration file.
const MODULE_EVENTS = "events"; *
* @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>(); public readonly commands = new Collection<string, Command>();
public readonly modules = new Collection<string, ModuleMetadata>(); /**
* Mapping event name to its {@link Event | instance}.
*/
public readonly events = new Collection<string, Event>();
public readonly db = new PrismaClient(); /**
* Mapping module name to its {@link Module | instance}.
*/
public readonly modules = new Collection<string, Module>();
/**
* Constructs Quetza Client.
*
* @param options - Discord.js client options
*/
public constructor(options: ClientOptions) { public constructor(options: ClientOptions) {
super(options); super(options);
for (const module of readdirSync(config.modulesDir)) { readdirSync(config.path.modules).forEach((module) =>
const modulePath = path.join(config.modulesDir, module); this.importModule(
toModuleDeclaration(module),
if (!lstatSync(modulePath).isDirectory()) { toModuleCommands(module),
continue; toModuleEvents(module)
} )
);
import(pathToFileURL(path.join(modulePath, MODULE_ENTRY)).toString()).then(
(data: Module) => this.loadModule(data)
);
}
} }
private loadModule(data: Module): void { /**
const commands: string[] = []; * Generates client's status by providing Bot User and Loaded Modules info.
const events: string[] = []; *
* @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());
function importModule(dir: string, importThen: (data: Command & Event) => void) { if (!this.isReady()) {
if (existsSync(dir)) { return {
for (const source of readdirSync(dir)) { applicationId: null,
const file = path.join(dir, source); tag: null,
modules
if (!lstatSync(file).isFile() || !file.endsWith(".js")) { };
continue;
}
import(pathToFileURL(file).toString()).then(importThen);
}
}
} }
importModule(path.join(data.rootDir, MODULE_COMMANDS), (command: Command) => { return {
this.commands.set(command.data.name, command); applicationId: this.application.id,
commands.push(command.data.name); tag: this.user.tag,
}); modules
};
importModule(path.join(data.rootDir, MODULE_EVENTS), (event: Event) => {
this.on(
event.name,
async (...eventee: unknown[]) => await event.execute(this, eventee)
);
events.push(event.name);
});
this.modules.set(data.name, {
...data,
commands,
events
});
} }
public generateApplicationStatus(): string[] { /**
const modulesStatus = this.modules.map((module) => { * Imports module metadata, commands and events to the client.
const tag = `✓ ${module.name}@${module.tag} module is loaded.`; *
const commands = module.commands.map((name) => " - " + name).join("\n"); * @param declaration - File declaring module information.
const events = module.events.map((name) => " - " + name).join("\n"); * @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: [] }))
);
return [`${tag}\n`, "commands:", `${commands}`, "events:", `${events}`].join("\n"); importDir<CommandBase>(commands, (command) => {
const saved = this.commands.ensure(command.data.name, () => ({ ...command, module }));
module.commands.push(saved);
}); });
const clientStatus = importDir<EventBase>(events, (event) => {
this.user && this.application const saved = this.events.ensure(event.name, () => ({ ...event, module }));
? `✓ ${this.application.id} successfully logged in as ${this.user.tag}.`
: "✖ Application is not availiable.";
return modulesStatus.concat(clientStatus); module.events.push(saved);
this.on(event.name, (...eventee: unknown[]) =>
saved.execute(this, eventee, module.controller)
);
});
} }
} }
+52
View File
@@ -0,0 +1,52 @@
/**
* 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 - Identifier of module where error has occurred
*/
public constructor(msg: string, code: number, prefix = QuetzaError.DEFAULT_PREFIX) {
super(msg);
this.code = code;
this.prefix = prefix;
}
/**
* Must create a unique error ID if no collision bugs were written.
*
* @returns error ID as concatenation 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();
}
}
+23 -3
View File
@@ -1,12 +1,32 @@
import path from "path";
import winston from "winston"; import winston from "winston";
import config from "$config.js";
/** Error logging file path.
*
* @internal
*/
const errorLog = path.join(config.path.log, "error.log");
/** Combined logging file path.
*
* @internal
*/
const combinedLog = path.join(config.path.log, "combined.log");
/**
* Logger instance. Use it for logging.
*
* @public
*/
const logger = winston.createLogger({ const logger = winston.createLogger({
level: "info", levels: winston.config.syslog.levels,
format: winston.format.combine(winston.format.timestamp(), winston.format.json()), format: winston.format.combine(winston.format.timestamp(), winston.format.json()),
transports: [ transports: [
new winston.transports.Console({ format: winston.format.simple() }), new winston.transports.Console({ format: winston.format.simple() }),
new winston.transports.File({ filename: "logs/error.log", level: "error" }), new winston.transports.File({ filename: errorLog, level: "error" }),
new winston.transports.File({ filename: "logs/combined.log" }) 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);
}
+90 -14
View File
@@ -1,27 +1,103 @@
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.
*
* @public
*/
export interface ModuleBase {
/** Name of a module. */
name: string; name: string;
description?: string;
author: string; /** Description of a module. */
tag: string; description: string;
/** Module controller. */
controller?: unknown; controller?: unknown;
rootDir: string;
} }
export interface ModuleMetadata extends Module { /**
commands: string[]; * Complete {@link ModuleBase} declaration with commands and events.
events: string[]; *
* @public
*/
export interface Module extends ModuleBase {
/** Module's commands. */
commands: CommandBase[];
/** Module's events. */
events: EventBase[];
} }
export interface Command { /**
* Quetza's command interaction declaration.
*
* @public
*/
export interface CommandBase {
/** Command metadata that will be passed to the Discord API. */
data: ApplicationCommandData; 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 { /**
* {@link ModuleBase} with {@link Module} included.
*
* @public
*/
export interface Command extends CommandBase {
/** Module containing this Command. */
module: Module;
}
/**
* Quetza's event declaration.
*
* @public
*/
export interface EventBase {
/** Name of an event to listen. */
name: string; 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>;
}
/**
* {@link EventBase} with {@link Module} included.
*
* @public
*/
export interface Event extends EventBase {
/** Module containing this Event. */
module: ModuleBase;
}
/**
* Application status provides information that may be useful at initialization.
*
* @public
*/
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);
}
client.generateApplicationStatus().forEach((report) => logger.info(report));
client.user.setActivity(config.activityStatus);
}
const name = Events.ClientReady;
export { execute, name };
-11
View File
@@ -1,11 +0,0 @@
import path from "path";
import { fileURLToPath } from "url";
const name = "core";
const description = "Quetza core functionality";
const author = "unknowableshade";
const tag = "4.2.2";
const rootDir = path.dirname(fileURLToPath(import.meta.url));
export { author, description, name, rootDir, tag };
@@ -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 };
-245
View File
@@ -1,245 +0,0 @@
import { execFile } from "child_process";
import path from "path";
import prismMedia from "prism-media";
import { Duplex, Readable } from "stream";
import { promisify } from "util";
import ytdl from "ytdl-core";
import config from "../../../config.js";
import logger from "../../../lib/logger.js";
import { StreamOptions, YtdlDump, YtdlResult, YtdlStreamOptions } from "./types.js";
const { opus: Opus, FFmpeg } = prismMedia;
const execFileAsync = promisify(execFile);
const SEARCH_DEPTH = 5;
const YTDL_EVENTS = [
"info",
"progress",
"abort",
"request",
"response",
"error",
"redirect",
"retry",
"reconnect"
];
function isURL(url: string): boolean {
return /^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)$/.test(
url
);
}
async function fetchInfoRecursive(query: string, depth = 0): Promise<YtdlResult[]> {
if (depth === SEARCH_DEPTH) {
logger.warn("yt-dlp failed to find anything.", { query });
return [];
}
const executable = await execFileAsync(path.join(config.binariesDir, "/yt-dlp"), [
query,
"-O %(.{id,original_url,title,uploader,duration,extractor_key,thumbnail})j",
"-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 [];
}
}
export function ytdlStream(url: string, options?: YtdlStreamOptions) {
logger.info("Created ytdl stream.", { url, options });
let ffmpegArgs: string[] = [
"-analyzeduration",
"0",
"-loglevel",
"0",
"-f",
`${options?.fmt ? options.fmt : "s16le"}`,
"-ar",
"48000",
"-ac",
"2"
];
if (options?.seek) {
ffmpegArgs.unshift("-ss", options.seek.toString());
}
if (options?.encoderArgs) {
ffmpegArgs = ffmpegArgs.concat(options.encoderArgs);
}
const transcoder = new FFmpeg({
args: ffmpegArgs
});
const inputStream = ytdl(url, options);
const transcoderStream = inputStream.pipe(transcoder);
if (options && !options.opusEncoded) {
YTDL_EVENTS.forEach((event) =>
inputStream.on(event, (...args) => transcoderStream.emit(event, ...args))
);
inputStream.on("error", () => transcoder.destroy());
transcoderStream.on("close", () => transcoder.destroy());
return transcoderStream;
}
const opus = new Opus.Encoder({
rate: 48000,
channels: 2,
frameSize: 960
});
const outputStream = transcoderStream.pipe(opus);
transcoderStream.on("error", (e) => outputStream.emit("error", e));
YTDL_EVENTS.forEach((event) =>
inputStream.on(event, (...args) => outputStream.emit(event, ...args))
);
outputStream.on("close", () => {
transcoder.destroy();
opus.destroy();
});
return outputStream;
}
export function arbitraryStream(stream: string | Readable | Duplex, options?: StreamOptions) {
logger.info("Created arbitrary stream.", { stream, options });
let ffmpegArgs: string[];
if (typeof stream === "string") {
ffmpegArgs = [
"-reconnect",
"1",
"-reconnect_streamed",
"1",
"-reconnect_delay_max",
"5",
"-i",
stream,
"-analyzeduration",
"0",
"-loglevel",
"0",
"-f",
`${options?.fmt ? options.fmt : "s16le"}`,
"-ar",
"48000",
"-ac",
"2"
];
} else {
ffmpegArgs = [
"-analyzeduration",
"0",
"-loglevel",
"0",
"-f",
`${options?.fmt ? options.fmt : "s16le"}`,
"-ar",
"48000",
"-ac",
"2"
];
}
if (options?.seek) {
ffmpegArgs.unshift("-ss", options.seek.toString());
}
if (options?.encoderArgs) {
ffmpegArgs = ffmpegArgs.concat(options.encoderArgs);
}
let transcoder = new FFmpeg({
args: ffmpegArgs
});
if (typeof stream !== "string") {
transcoder = stream.pipe(transcoder);
stream.on("error", () => transcoder.destroy());
}
if (options && !options.opusEncoded) {
transcoder.on("close", () => transcoder.destroy());
return transcoder;
}
const opus = new Opus.Encoder({
rate: 48000,
channels: 2,
frameSize: 960
});
const outputStream = transcoder.pipe(opus);
outputStream.on("close", () => {
transcoder.destroy();
opus.destroy();
});
return outputStream;
}
-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(",");
}
}
-36
View File
@@ -1,36 +0,0 @@
import { Collection, Guild, GuildTextBasedChannel } from "discord.js";
import logger from "../../../lib/logger.js";
import Player from "./player.js";
export default class Music {
private players_ = new Collection<string, Player>();
public get(guild: Guild, channel?: GuildTextBasedChannel): Player | undefined {
const player = this.players_.get(guild.id);
if (player) {
if (channel) {
player.channel = channel;
}
return player;
}
}
public set(guild: Guild, channel: GuildTextBasedChannel): Player {
const player = new Player(guild, this, channel);
this.players_.set(guild.id, player);
logger.info("Player was created.", { player });
return player;
}
public delete(guildId: string): void {
logger.info("Player was deleted.", { guildId });
this.players_.delete(guildId);
}
}
-43
View File
@@ -1,43 +0,0 @@
import { User } from "discord.js";
import { downloadOptions } from "ytdl-core";
export interface Track {
url: string;
title: string;
thumbnail: string;
duration: number;
requester: User;
}
export type LoopOption = "LOOP" | "SONG" | "NONE" | "AUTO";
export interface YtdlDump {
id: string;
original_url: string;
title?: string;
uploader?: string;
duration?: number;
extractor_key: string;
thumbnail?: string;
}
export interface YtdlResult {
url: string;
title: string;
thumbnail: string;
duration: number;
}
export interface YtdlStreamOptions extends downloadOptions {
seek?: number;
encoderArgs?: string[];
fmt?: string;
opusEncoded?: boolean;
}
export interface StreamOptions {
seek?: number;
encoderArgs?: string[];
fmt?: string;
opusEncoded?: boolean;
}
-15
View File
@@ -1,15 +0,0 @@
import path from "path";
import { fileURLToPath } from "url";
import Music from "./lib/music.js";
const name = "music";
const description = "Music module";
const author = "unknowableshade";
const tag = "2.0.0";
const controller = new Music();
const rootDir = path.dirname(fileURLToPath(import.meta.url));
export { author, controller, description, name, rootDir, tag };
-95
View File
@@ -1,95 +0,0 @@
import {
ChatInputCommandInteraction,
Events,
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: ChatInputCommandInteraction): Promise<void> {
if (!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.", { error });
} 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
@@ -1,98 +0,0 @@
import { ChatInputCommandInteraction, 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: ChatInputCommandInteraction): 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.username, 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 };
@@ -1,23 +0,0 @@
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
@@ -1,80 +0,0 @@
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.username} 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.username}`)
.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
@@ -1,11 +0,0 @@
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 };
+15
View File
@@ -0,0 +1,15 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"paths": {
"$config.js": ["../config.js"],
"$lib/*": ["./lib/*"]
},
"rootDir": ".",
"outDir": "../dist/src",
"composite": true
},
"references": [{ "path": "../config.tsconfig.json" }]
}
+13 -11
View File
@@ -2,18 +2,20 @@
"compilerOptions": { "compilerOptions": {
"strict": true, "strict": true,
"module": "ESNext", "module": "NodeNext",
"moduleResolution": "node", "moduleResolution": "NodeNext",
"outDir": "dist", "resolveJsonModule": true,
"removeComments": false,
"sourceMap": true,
"esModuleInterop": true,
"target": "ESNext",
"lib": ["ESNext"],
"types": ["node"], "types": ["node"],
"outDir": "./dist",
"sourceMap": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"target": "ES2022",
"skipLibCheck": true "skipLibCheck": true
}, },
"include": ["src/**/*"], "references": [{ "path": "./modules/core" }, { "path": "./modules/music" }]
"exclude": ["node_modules", "dist", "src/commands", "src/events"]
} }