27 Commits
Author SHA1 Message Date
deytenit 8259f76615 Merge branch 'development' of https://github.com/unknowableshade/Quetza into development 2024-03-02 02:05:59 +03:00
deytenit afc6fce555 Documentation of Music & Core (almost done) 2024-03-02 02:04:00 +03:00
deytenit be88d20f31 Update README.md 2024-01-30 14:36:57 +03:00
deytenit 811bc3a3c8 Create LICENSE 2024-01-30 14:36:12 +03:00
deytenit 824abc6533 Update README.md 2024-01-30 14:35:43 +03:00
deytenit 3144dd69b7 Refactoring, comments & overall improvements 2024-01-30 14:31:15 +03:00
deytenit d7421251ba TSC Build System 2024-01-16 19:20:05 +03:00
deytenit 29fcdcc06f Runtume version update 2024-01-16 13:33:58 +03:00
deytenit 6587bcd68e Removing ytdl-core from dependencies 2024-01-13 02:18:43 +03:00
deytenit ef61f7fa0a Modules simplification & music logging 2024-01-07 15:39:17 +03:00
deytenit 4ed9e1c6e0 Update README and branding 2023-12-23 02:51:30 +03:00
deytenit a59faab420 Fix Action Workflow 2023-12-17 19:41:19 +03:00
deytenit a725290293 Merge commit '3290962' into HEAD 2023-12-07 14:29:17 +03:00
deytenit 6a3425e2c3 Update Dependencies 2023-12-07 14:24:56 +03:00
deytenit 3290962c77 Update Dependencies 2023-12-07 14:20:15 +03:00
deytenit ace8170323 Comply to Docker Logging 2023-12-06 13:40:18 +03:00
Vladimir (unknowableshade) Eremin 1374bcc25d Merge branch 'master' of https://github.com/unknowableshade/quetza-bot 2023-07-12 02:57:29 +03:00
Vladimir (unknowableshade) Eremin 757d6e3501 Prettier & CI/CD Fixes 2023-07-12 02:57:24 +03:00
Vladimir (unknowableshade) Eremin b023f7134d Merge branch 'master' of https://github.com/unknowableshade/quetza-bot 2023-07-12 02:57:07 +03:00
Vladimir (unknowableshade) Eremin 9f191e6e80 Prettier & CI/CD Fixes 2023-07-12 02:56:53 +03:00
Vladimir (unknowableshade) Eremin 7ca5041725 Merge branch 'master' of https://github.com/unknowableshade/quetza-bot 2023-07-12 02:22:15 +03:00
Vladimir (unknowableshade) Eremin a045be3f2f Prettier & CI/CD Fixes 2023-07-12 02:16:29 +03:00
Vladimir (unknowableshade) Eremin c569661481 Prettier & CI/CD Fixes 2023-07-12 01:10:12 +03:00
Vladimir (unknowableshade) Eremin e8cd52d315 More Fixes 2023-05-09 18:05:41 +03:00
Vladimir (unknowableshade) Eremin d4d2930f58 Formatting 2023-05-09 17:01:40 +03:00
Vladimir (unknowableshade) Eremin 0f6ad29a3c Bug Fixes 2023-05-09 16:58:37 +03:00
Vladimir Eremin e4397c3f54 Merge pull request #5 from unknowableshade/laboratory
v4.2.0
2023-05-09 04:21:33 +03:00
80 changed files with 7782 additions and 6295 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
+21 -21
View File
@@ -1,21 +1,21 @@
{ {
"root": true, "root": true,
"parser": "@typescript-eslint/parser", "parser": "@typescript-eslint/parser",
"parserOptions": { "parserOptions": {
"warnOnUnsupportedTypeScriptVersion": false, "warnOnUnsupportedTypeScriptVersion": false,
"sourceType": "module" "sourceType": "module"
}, },
"env": { "env": {
"browser": false, "browser": false,
"node": true, "node": true,
"es6": true "es6": true
}, },
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier"], "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier"],
"plugins": ["@typescript-eslint", "import", "simple-import-sort"], "plugins": ["@typescript-eslint", "import", "simple-import-sort"],
"ignorePatterns": ["**/node_modules/**", "/dist/**", "/tests/**"], "ignorePatterns": ["**/node_modules/**", "/dist/**", "/tests/**"],
"rules": { "rules": {
"curly": ["error", "all"], "curly": ["error", "all"],
"simple-import-sort/imports": "error", "simple-import-sort/imports": "error",
"simple-import-sort/exports": "error" "simple-import-sort/exports": "error"
} }
} }
-68
View File
@@ -1,68 +0,0 @@
name: actions
on:
push:
tags:
- "*"
jobs:
mocha:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18.x]
pnpm-version: [8]
steps:
- name: Check out the repo
uses: actions/checkout@v3
- uses: pnpm/action-setup@v2
with:
version: ${{ matrix.pnpm-version }}
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
- name: Install dependencies
run: pnpm install
- name: ESLint & Prettier
run: pnpm run lint
- name: Build
run: pnpm run build
- name: Test
run: pnpm run --if-present test
docker:
runs-on: ubuntu-latest
needs: mocha
steps:
- name: Check out the repo
uses: actions/checkout@v3
- name: Log in to Docker Hub
uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38
with:
images: unknowableshade/quetza-bot
- name: Build and push Docker image
uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+86
View File
@@ -0,0 +1,86 @@
name: Continuous Integration
on:
push:
tags:
- "*"
branches:
- master
jobs:
mocha:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20.x]
pnpm-version: [8]
steps:
- name: Check out the repo
uses: actions/checkout@v3
- uses: pnpm/action-setup@v2
with:
version: ${{ matrix.pnpm-version }}
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
- name: Install dependencies
run: pnpm install
- name: ESLint & Prettier
run: pnpm run lint
- name: Prisma Migration
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
run: pnpm exec prisma migrate deploy
- name: Build
run: pnpm run build
- name: Test
run: pnpm run --if-present test
docker:
runs-on: ubuntu-latest
needs: mocha
steps:
- name: Check out the repo
uses: actions/checkout@v3
- name: Log in to Docker Hub
uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Docker Generate Meta
id: meta
uses: docker/metadata-action@v5
with:
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
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+23 -23
View File
@@ -1,23 +1,23 @@
{ {
"semi": true, "semi": true,
"trailingComma": "none", "trailingComma": "none",
"singleQuote": false, "singleQuote": false,
"printWidth": 100, "printWidth": 100,
"arrowParens": "always", "arrowParens": "always",
"bracketSpacing": true, "bracketSpacing": true,
"endOfLine": "crlf", "endOfLine": "lf",
"overrides": [ "overrides": [
{ {
"files": ["**/*.json, **/*.yml"], "files": ["**/*.json, **/*.yml"],
"options": { "options": {
"tabWidth": 2 "tabWidth": 2
} }
}, },
{ {
"files": ["**/*.ts"], "files": ["**/*.ts"],
"options": { "options": {
"tabWidth": 4 "tabWidth": 4
} }
} }
] ]
} }
+26 -25
View File
@@ -1,4 +1,10 @@
FROM node:18-alpine as builder FROM node:20-alpine as builder
RUN apk add --no-cache python3
RUN apk add --no-cache curl
RUN apk add --no-cache make
RUN apk add --no-cache gcc
RUN apk add --no-cache build-base
RUN npm install -g pnpm RUN npm install -g pnpm
@@ -12,32 +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 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" ]
-1
View File
@@ -1 +0,0 @@
worker: npm start
+148 -16
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.)_. At its core **Quetza** is simply a **[user-client](./src/lib/client.ts)** with a **[logger](./src/lib/logger.ts)**.
- Slash commands.
### Modules
## Links
**Modules** define the features of **Quetza**.
- **[Dependencies](https://github.com/unknowableshade/quetza-bot/blob/master/package.json)**
- **[Docker](https://hub.docker.com/repository/docker/unknowableshade/quetza-bot)** > 🗂 **The structure** of a **module**
- **[Licence](https://github.com/unknowableshade/5a0b-bot/blob/master/LICENSE)**
```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"]
}
+32
View File
@@ -0,0 +1,32 @@
/**
* /modules
*
* Replies module listing as well as description of each.
*
* Just for the information I guess...
*
* @packageDocumentation
*/
import { ChatInputCommandInteraction, 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()) {
logger.warn("Interaction rejected.", { interaction });
return;
}
await interaction.reply(replies.modules(client.modules));
}
const data = new SlashCommandBuilder()
.setName("modules")
.setDescription("List of availiable modules.");
export { data, execute };
@@ -1,23 +1,31 @@
import { Interaction, SlashCommandBuilder } from "discord.js"; /**
* /ping
import Client from "../../../lib/client.js"; *
import logger from "../../../lib/logger.js"; * Replies websocket ping (ping between bot and discord) and real ping (ping to fetch + process).
import replies from "../lib/replies.js"; *
* @remarks Probably should double their value as two ways, but nvm.
async function execute(client: Client, interaction: Interaction): Promise<void> { *
if (!interaction.isChatInputCommand()) { * @packageDocumentation
logger.warn("Interaction rejected.", { interaction }); */
return; import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
}
import Client from "$lib/client.js";
const reply = await interaction.reply({ ...replies.ping(client.ws.ping), fetchReply: true });
import replies from "../lib/replies.js";
await interaction.editReply(
replies.ping(client.ws.ping, reply.createdTimestamp - interaction.createdTimestamp) async function execute(client: Client, interaction: ChatInputCommandInteraction): Promise<void> {
); if (!interaction.isChatInputCommand()) {
} return;
}
const data = new SlashCommandBuilder().setName("ping").setDescription("Try to ping me.");
const reply = await interaction.reply({ ...replies.ping(client.ws.ping), fetchReply: true });
export { data, execute };
await interaction.editReply(
replies.ping(client.ws.ping, reply.createdTimestamp - interaction.createdTimestamp)
);
}
const data = new SlashCommandBuilder().setName("ping").setDescription("Try to ping me.");
export { data, execute };
+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,36 +1,34 @@
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] }; ping: (websocket: number, roundtrip?: number): BaseMessageOptions => {
}, const title = roundtrip ? `I've pinged ${roundtrip} ms. late` : "Pinging...";
ping: (websocket: number, roundtrip?: number): BaseMessageOptions => {
const title = roundtrip ? `I've pinged ${roundtrip} ms. late` : "Pinging..."; const comment = `Websocket ping is ${websocket} ms.`;
const comment = `Websocket ping is ${websocket} ms.`; const embed = new EmbedBuilder()
.setColor(config.colors.default)
const embed = new EmbedBuilder() .setTitle(title)
.setColor(config.colors.default) .setDescription(italic(comment));
.setTitle(title)
.setDescription(italic(comment)); return { embeds: [embed] };
}
return { embeds: [embed] }; };
}
}; export default replies;
export default replies;
+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,35 +1,36 @@
import { Interaction, 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 { controller } from "../module.js"; import replies from "../lib/replies.js";
import { controller } from "../module.js";
async function execute(client: Client, interaction: Interaction): Promise<void> {
if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) { async function execute(client: Client, interaction: ChatInputCommandInteraction): Promise<void> {
logger.warn("Interaction rejected.", { interaction }); if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) {
logger.warn("Interaction rejected.", { interaction });
return;
} return;
}
const player = controller.get(interaction.guild, interaction.channel);
const player = controller.get(interaction.guild, interaction.channel);
if (!player) {
await interaction.reply(replies.notExists()); if (!player) {
await interaction.reply(replies.notExists());
return;
} return;
}
const amount = player.queue.tracks.length;
const amount = player.queue.tracks.length;
player.clear();
player.clear();
await interaction.reply(replies.cleared(amount));
} await interaction.reply(replies.cleared(amount));
}
const data = new SlashCommandBuilder()
.setName("clear") const data = new SlashCommandBuilder()
.setDescription("Clear the player's queue.") .setName("clear")
.setDMPermission(false); .setDescription("Clear the player's queue.")
.setDMPermission(false);
export { data, execute };
export { data, execute };
@@ -1,43 +1,44 @@
import { Interaction, 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 { controller } from "../module.js"; import replies from "../lib/replies.js";
import { controller } from "../module.js";
async function execute(client: Client, interaction: Interaction): Promise<void> {
if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) { async function execute(client: Client, interaction: ChatInputCommandInteraction): Promise<void> {
logger.warn("Interaction rejected.", { interaction }); if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) {
logger.warn("Interaction rejected.", { interaction });
return;
} return;
}
await interaction.deferReply();
await interaction.deferReply();
const player =
controller.get(interaction.guild, interaction.channel) ?? const player =
controller.set(interaction.guild, interaction.channel); controller.get(interaction.guild, interaction.channel) ??
controller.set(interaction.guild, interaction.channel);
const channel = interaction.member.voice.channel;
const channel = interaction.member.voice.channel;
if (!channel) {
controller.delete(interaction.guildId); if (!channel) {
controller.delete(interaction.guildId);
await interaction.editReply(replies.notConnected());
await interaction.editReply(replies.notConnected());
return;
} return;
}
player.connect(channel);
player.connect(channel);
await interaction.editReply(replies.okConnected(channel.name));
await interaction.editReply(replies.okConnected(channel.name));
return;
} return;
}
const data = new SlashCommandBuilder()
.setName("connect") const data = new SlashCommandBuilder()
.setDescription("Connect me explicitly to the voice channel.") .setName("connect")
.setDMPermission(false); .setDescription("Connect me explicitly to the voice channel.")
.setDMPermission(false);
export { data, execute };
export { data, execute };
@@ -1,46 +1,43 @@
import { Interaction, 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 replies from "../lib/replies.js"; import Filter from "../lib/filter.js";
import { controller } from "../module.js"; import replies from "../lib/replies.js";
import { controller } from "../module.js";
async function execute(client: Client, interaction: Interaction) {
if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) { async function execute(client: Client, interaction: ChatInputCommandInteraction) {
logger.warn("Interaction rejected.", { interaction }); if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) {
logger.warn("Interaction rejected.", { interaction });
return;
} return;
}
const filter = interaction.options.getString("filter") ?? undefined;
const filter = interaction.options.getString("filter") ?? undefined;
const player = controller.get(interaction.guild, interaction.channel);
const player = controller.get(interaction.guild, interaction.channel);
if (!player) {
await interaction.reply(replies.notExists()); if (!player) {
await interaction.reply(replies.notExists());
return;
} return;
}
const status = player.setFilter(filter);
const status = player.setFilter(filter);
await interaction.reply(replies.filtered(filter, status));
} await interaction.reply(replies.filtered(filter, status));
}
const data = new SlashCommandBuilder()
.setName("filter") const data = new SlashCommandBuilder()
.setDescription("Apply unique filters to the playback.") .setName("filter")
.addStringOption((option) => .setDescription("Apply unique filters to the playback.")
option .addStringOption((option) =>
.setName("filter") option
.setDescription("Filter to apply.") .setName("filter")
.setChoices( .setDescription("Filter to apply.")
...Object.keys(Filter.filters).map((key) => { .setChoices(...Object.values(Filter.FILTERS))
return { name: key, value: key }; )
}) .setDMPermission(false);
)
) export { data, execute };
.setDMPermission(false);
export { data, execute };
@@ -1,31 +1,32 @@
import { Interaction, 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 { controller } from "../module.js"; import replies from "../lib/replies.js";
import { controller } from "../module.js";
async function execute(client: Client, interaction: Interaction) {
if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) { async function execute(client: Client, interaction: ChatInputCommandInteraction) {
logger.warn("Interaction rejected.", { interaction }); if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) {
logger.warn("Interaction rejected.", { interaction });
return;
} return;
}
const player = controller.get(interaction.guild, interaction.channel);
const player = controller.get(interaction.guild, interaction.channel);
if (!player) {
await interaction.reply(replies.notExists()); if (!player) {
await interaction.reply(replies.notExists());
return;
} return;
}
await interaction.reply(replies.info(player));
} await interaction.reply(replies.info(player));
}
const data = new SlashCommandBuilder()
.setName("info") const data = new SlashCommandBuilder()
.setDescription("Verbose information about player and stats.") .setName("info")
.setDMPermission(false); .setDescription("Verbose information about player and stats.")
.setDMPermission(false);
export { data, execute };
export { data, execute };
@@ -1,44 +1,45 @@
import { Interaction, 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 { controller } from "../module.js"; import replies from "../lib/replies.js";
import { controller } from "../module.js";
async function execute(client: Client, interaction: Interaction) {
if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) { async function execute(client: Client, interaction: ChatInputCommandInteraction) {
logger.warn("Interaction rejected.", { interaction }); if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) {
logger.warn("Interaction rejected.", { interaction });
return;
} return;
}
const pos = interaction.options.getNumber("position", true);
const query = interaction.options.getString("query", true); const query = interaction.options.getString("query", true);
const pos = interaction.options.getInteger("position", true);
await interaction.deferReply();
await interaction.deferReply();
const player = controller.get(interaction.guild, interaction.channel);
const player = controller.get(interaction.guild, interaction.channel);
if (!player) {
await interaction.editReply(replies.notExists()); if (!player) {
await interaction.editReply(replies.notExists());
return;
} return;
}
const track = await player.add(query, interaction.user, pos - 1);
const track = await player.add(query, interaction.user, pos - 1);
await interaction.editReply(replies.appended(track));
} await interaction.editReply(replies.appended(track));
}
const data = new SlashCommandBuilder()
.setName("insert") const data = new SlashCommandBuilder()
.setDescription("Insert a new track to specific position in the queue.") .setName("insert")
.addStringOption((option) => .setDescription("Insert a new track to specific position in the queue.")
option.setName("query").setDescription("Title or URL of the song.").setRequired(true) .addStringOption((option) =>
) option.setName("query").setDescription("Title or URL of the song.").setRequired(true)
.addIntegerOption((option) => )
option.setName("position").setDescription("Position to insert.").setRequired(true) .addIntegerOption((option) =>
) option.setName("position").setDescription("Position to insert.").setRequired(true)
.setDMPermission(false); )
.setDMPermission(false);
export { data, execute };
export { data, execute };
@@ -1,43 +1,44 @@
import { Interaction, 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 { controller } from "../module.js"; import replies from "../lib/replies.js";
import { controller } from "../module.js";
async function execute(client: Client, interaction: Interaction) {
if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) { async function execute(client: Client, interaction: ChatInputCommandInteraction) {
logger.warn("Interaction rejected.", { interaction }); if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) {
logger.warn("Interaction rejected.", { interaction });
return;
} return;
}
const query = interaction.options.getString("query", true);
const query = interaction.options.getString("query", true);
const player = controller.get(interaction.guild, interaction.channel);
const player = controller.get(interaction.guild, interaction.channel);
if (!player) {
await interaction.reply(replies.notExists()); if (!player) {
await interaction.reply(replies.notExists());
return;
} return;
}
const previous = player.resource?.metadata;
const previous = player.resource?.metadata;
const position =
!isNaN(+query) && isFinite(+query) && !/e/i.test(query) const position =
? player.jump(parseInt(query) - 1) !isNaN(+query) && isFinite(+query) && !/e/i.test(query)
: player.jump(query); ? player.jump(parseInt(query) - 1)
: player.jump(query);
await interaction.reply(replies.jumped(previous, position));
} await interaction.reply(replies.jumped(previous, position));
}
const data = new SlashCommandBuilder()
.setName("jump") const data = new SlashCommandBuilder()
.setDescription("Jump to specific track in the queue.") .setName("jump")
.addStringOption((option) => .setDescription("Jump to specific track in the queue.")
option.setName("query").setDescription("Position or title to jump to.").setRequired(true) .addStringOption((option) =>
) option.setName("query").setDescription("Position or title to jump to.").setRequired(true)
.setDMPermission(false); )
.setDMPermission(false);
export { data, execute };
export { data, execute };
@@ -1,48 +1,49 @@
import { Interaction, 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 { LoopOption } from "../lib/types.js"; import replies from "../lib/replies.js";
import { controller } from "../module.js"; import { LoopOption } from "../lib/types.js";
import { controller } from "../module.js";
async function execute(client: Client, interaction: Interaction) {
if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) { async function execute(client: Client, interaction: ChatInputCommandInteraction) {
logger.warn("Interaction rejected.", { interaction }); if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) {
logger.warn("Interaction rejected.", { interaction });
return;
} return;
}
const option = interaction.options.getString("option", true) as LoopOption;
const option = interaction.options.getString("option", true) as LoopOption;
const player = controller.get(interaction.guild, interaction.channel);
const player = controller.get(interaction.guild, interaction.channel);
if (!player) {
await interaction.reply(replies.notExists()); if (!player) {
await interaction.reply(replies.notExists());
return;
} return;
}
player.queue.loop = option;
player.queue.loop = option;
await interaction.reply(replies.looped(option));
} await interaction.reply(replies.looped(option));
}
const data = new SlashCommandBuilder()
.setName("loop") const data = new SlashCommandBuilder()
.setDescription("Select player's loop mode.") .setName("loop")
.addStringOption((option) => .setDescription("Select player's loop mode.")
option .addStringOption((option) =>
.setName("option") option
.setDescription("Loop option.") .setName("option")
.setChoices( .setDescription("Loop option.")
{ name: "Loop queue", value: "LOOP" }, .setChoices(
{ name: "Loop single track", value: "SONG" }, { name: "Loop queue", value: "LOOP" },
{ name: "Drop on final", value: "NONE" }, { name: "Loop single track", value: "SONG" },
{ name: "Random", value: "AUTO" } { name: "Drop on final", value: "NONE" },
) { name: "Random", value: "AUTO" }
.setRequired(true) )
) .setRequired(true)
.setDMPermission(false); )
.setDMPermission(false);
export { data, execute };
export { data, execute };
@@ -1,33 +1,34 @@
import { Interaction, 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 { controller } from "../module.js"; import replies from "../lib/replies.js";
import { controller } from "../module.js";
async function execute(client: Client, interaction: Interaction) {
if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) { async function execute(client: Client, interaction: ChatInputCommandInteraction) {
logger.warn("Interaction rejected.", { interaction }); if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) {
logger.warn("Interaction rejected.", { interaction });
return;
} return;
}
const player = controller.get(interaction.guild, interaction.channel);
const player = controller.get(interaction.guild, interaction.channel);
if (!player) {
await interaction.reply(replies.notExists()); if (!player) {
await interaction.reply(replies.notExists());
return;
} return;
}
player.skip();
player.skip();
await interaction.reply(replies.skipped());
} await interaction.reply(replies.skipped());
}
const data = new SlashCommandBuilder()
.setName("next") const data = new SlashCommandBuilder()
.setDescription("Skip current song.") .setName("next")
.setDMPermission(false); .setDescription("Skip current song.")
.setDMPermission(false);
export { data, execute };
export { data, execute };
@@ -1,33 +1,34 @@
import { Interaction, 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 { controller } from "../module.js"; import replies from "../lib/replies.js";
import { controller } from "../module.js";
async function execute(client: Client, interaction: Interaction) {
if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) { async function execute(client: Client, interaction: ChatInputCommandInteraction) {
logger.warn("Interaction rejected.", { interaction }); if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) {
logger.warn("Interaction rejected.", { interaction });
return;
} return;
}
const player = controller.get(interaction.guild, interaction.channel);
const player = controller.get(interaction.guild, interaction.channel);
if (!player) {
await interaction.reply(replies.notExists()); if (!player) {
await interaction.reply(replies.notExists());
return;
} return;
}
const state = player.pause();
const state = player.pause();
await interaction.reply(replies.paused(state));
} await interaction.reply(replies.paused(state));
}
const data = new SlashCommandBuilder()
.setName("pause") const data = new SlashCommandBuilder()
.setDescription("Toggle pause on playback.") .setName("pause")
.setDMPermission(false); .setDescription("Toggle pause on playback.")
.setDMPermission(false);
export { data, execute };
export { data, execute };
@@ -1,58 +1,59 @@
import { Interaction, 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 { controller } from "../module.js"; import replies from "../lib/replies.js";
import { controller } from "../module.js";
async function execute(client: Client, interaction: Interaction) {
if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) { async function execute(client: Client, interaction: ChatInputCommandInteraction) {
logger.warn("Interaction rejected.", { interaction }); if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) {
logger.warn("Interaction rejected.", { interaction });
return;
} return;
}
const query = interaction.options.getString("query");
const query = interaction.options.getString("query");
if (query) {
await interaction.reply(replies.searching()); if (query) {
} else { await interaction.reply(replies.searching());
await interaction.deferReply(); } else {
} await interaction.deferReply();
}
const player =
controller.get(interaction.guild, interaction.channel) ?? const player =
controller.set(interaction.guild, interaction.channel); controller.get(interaction.guild, interaction.channel) ??
controller.set(interaction.guild, interaction.channel);
const channel = interaction.member.voice.channel;
const channel = interaction.member.voice.channel;
if (!channel) {
controller.delete(interaction.guildId); if (!channel) {
controller.delete(interaction.guildId);
await interaction.editReply(replies.notConnected());
await interaction.editReply(replies.notConnected());
return;
} return;
}
player.connect(channel);
player.connect(channel);
if (query) {
const track = await player.add(query, interaction.user); if (query) {
const track = await player.add(query, interaction.user);
await interaction.editReply(replies.appended(track));
} await interaction.editReply(replies.appended(track));
}
if (!player.resource) {
player.play(); if (!player.resource) {
} await player.play();
} }
}
const data = new SlashCommandBuilder()
.setName("play") const data = new SlashCommandBuilder()
.setDescription("Launch player and add songs to the queue.") .setName("play")
.addStringOption((option) => .setDescription("Launch player and add songs to the queue.")
option.setName("query").setDescription("Title or URL of the song.") .addStringOption((option) =>
) option.setName("query").setDescription("Title or URL of the song.")
.setDMPermission(false); )
.setDMPermission(false);
export { data, execute };
export { data, execute };
@@ -1,86 +1,87 @@
import { import {
ActionRowBuilder, ActionRowBuilder,
ButtonBuilder, ButtonBuilder,
ButtonInteraction, ButtonInteraction,
ButtonStyle, ButtonStyle,
Interaction, ChatInputCommandInteraction,
MessageActionRowComponentBuilder, MessageActionRowComponentBuilder,
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 { controller } from "../module.js"; import replies from "../lib/replies.js";
import { controller } from "../module.js";
async function execute(client: Client, interaction: Interaction) {
if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) { async function execute(client: Client, interaction: ChatInputCommandInteraction) {
logger.warn("Interaction rejected.", { interaction }); if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) {
logger.warn("Interaction rejected.", { interaction });
return;
} return;
}
await interaction.deferReply();
await interaction.deferReply();
const player = controller.get(interaction.guild, interaction.channel);
const player = controller.get(interaction.guild, interaction.channel);
if (!player) {
await interaction.editReply(replies.notExists()); if (!player) {
await interaction.editReply(replies.notExists());
return;
} return;
}
if (player.queue.empty()) {
await interaction.editReply(replies.queue(player.queue)); if (player.queue.empty()) {
await interaction.editReply(replies.queue(player.queue));
return;
} return;
}
let position = 1;
let position = 1;
const row = new ActionRowBuilder<MessageActionRowComponentBuilder>().addComponents(
new ButtonBuilder().setCustomId("Top").setLabel("🔼").setStyle(ButtonStyle.Primary), const row = new ActionRowBuilder<MessageActionRowComponentBuilder>().addComponents(
new ButtonBuilder().setCustomId("Up").setLabel("🔺").setStyle(ButtonStyle.Secondary), new ButtonBuilder().setCustomId("Top").setLabel("🔼").setStyle(ButtonStyle.Primary),
new ButtonBuilder().setCustomId("Down").setLabel("🔻").setStyle(ButtonStyle.Secondary), new ButtonBuilder().setCustomId("Up").setLabel("🔺").setStyle(ButtonStyle.Secondary),
new ButtonBuilder().setCustomId("End").setLabel("🔽").setStyle(ButtonStyle.Primary) new ButtonBuilder().setCustomId("Down").setLabel("🔻").setStyle(ButtonStyle.Secondary),
); new ButtonBuilder().setCustomId("End").setLabel("🔽").setStyle(ButtonStyle.Primary)
);
const message = await interaction.editReply({
...replies.queue(player.queue, 0, player.resource), const message = await interaction.editReply({
components: [row] ...replies.queue(player.queue, 0, player.resource),
}); components: [row]
});
const collector = message.createMessageComponentCollector({ time: 30000 });
const collector = message.createMessageComponentCollector({ time: 30000 });
collector.on("collect", async (btn: ButtonInteraction) => {
switch (btn.customId) { collector.on("collect", async (btn: ButtonInteraction) => {
case "Top": { switch (btn.customId) {
position = 1; case "Top": {
break; position = 1;
} break;
case "Up": { }
position = Math.max(position - 10, 1); case "Up": {
break; position = Math.max(position - 10, 1);
} break;
case "Down": { }
position = Math.min( case "Down": {
position + 10, position = Math.min(
Math.floor(player.queue.tracks.length / 10) * 10 position + 10,
); Math.floor(player.queue.tracks.length / 10) * 10
break; );
} break;
case "End": { }
position = Math.floor(player.queue.tracks.length / 10) * 10; case "End": {
} position = Math.floor(player.queue.tracks.length / 10) * 10;
} }
}
await btn.update(replies.queue(player.queue, position - 1, player.resource));
}); await btn.update(replies.queue(player.queue, position - 1, player.resource));
} });
}
const data = new SlashCommandBuilder()
.setName("queue") const data = new SlashCommandBuilder()
.setDescription("Print interactive queue pointing to current track.") .setName("queue")
.setDMPermission(false); .setDescription("Print interactive queue pointing to current track.")
.setDMPermission(false);
export { data, execute };
export { data, execute };
@@ -1,41 +1,42 @@
import { Interaction, 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 { controller } from "../module.js"; import replies from "../lib/replies.js";
import { controller } from "../module.js";
async function execute(client: Client, interaction: Interaction) {
if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) { async function execute(client: Client, interaction: ChatInputCommandInteraction) {
logger.warn("Interaction rejected.", { interaction }); if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) {
logger.warn("Interaction rejected.", { interaction });
return;
} return;
}
const query = interaction.options.getString("query", true);
const query = interaction.options.getString("query", true);
const player = controller.get(interaction.guild, interaction.channel);
const player = controller.get(interaction.guild, interaction.channel);
if (!player) {
await interaction.reply(replies.notExists()); if (!player) {
await interaction.reply(replies.notExists());
return;
} return;
}
const track =
!isNaN(+query) && isFinite(+query) && !/e/i.test(query) const track =
? player.remove(parseInt(query) - 1) !isNaN(+query) && isFinite(+query) && !/e/i.test(query)
: player.remove(query); ? player.remove(parseInt(query) - 1)
: player.remove(query);
await interaction.reply(replies.removed(track));
} await interaction.reply(replies.removed(track));
}
const data = new SlashCommandBuilder()
.setName("remove") const data = new SlashCommandBuilder()
.setDescription("Remove specific track in the queue.") .setName("remove")
.addStringOption((option) => .setDescription("Remove specific track in the queue.")
option.setName("query").setDescription("Position or title to remove.").setRequired(true) .addStringOption((option) =>
) option.setName("query").setDescription("Position or title to remove.").setRequired(true)
.setDMPermission(false); )
.setDMPermission(false);
export { data, execute };
export { data, execute };
@@ -1,33 +1,34 @@
import { Interaction, 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 { controller } from "../module.js"; import replies from "../lib/replies.js";
import { controller } from "../module.js";
async function execute(client: Client, interaction: Interaction) {
if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) { async function execute(client: Client, interaction: ChatInputCommandInteraction) {
logger.warn("Interaction rejected.", { interaction }); if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) {
logger.warn("Interaction rejected.", { interaction });
return;
} return;
}
const player = controller.get(interaction.guild, interaction.channel);
const player = controller.get(interaction.guild, interaction.channel);
if (!player) {
await interaction.reply(replies.notExists()); if (!player) {
await interaction.reply(replies.notExists());
return;
} return;
}
player.shuffle();
player.shuffle();
await interaction.reply(replies.reshuffle());
} await interaction.reply(replies.reshuffle());
}
const data = new SlashCommandBuilder()
.setName("reshuffle") const data = new SlashCommandBuilder()
.setDescription("Reshuffle the queue. (Will change queue order)") .setName("reshuffle")
.setDMPermission(false); .setDescription("Reshuffle the queue. (Will change queue order)")
.setDMPermission(false);
export { data, execute };
export { data, execute };
@@ -1,50 +1,51 @@
import { Interaction, 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 { controller } from "../module.js"; import replies from "../lib/replies.js";
import { controller } from "../module.js";
async function execute(client: Client, interaction: Interaction) {
if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) { async function execute(client: Client, interaction: ChatInputCommandInteraction) {
logger.warn("Interaction rejected.", { interaction }); if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) {
logger.warn("Interaction rejected.", { interaction });
return;
} return;
}
const hours = interaction.options.getInteger("hrs") ?? 0;
const minutes = interaction.options.getInteger("mins") ?? 0; const hours = interaction.options.getInteger("hrs") ?? 0;
const seconds = interaction.options.getInteger("secs") ?? 0; const minutes = interaction.options.getInteger("mins") ?? 0;
const seconds = interaction.options.getInteger("secs") ?? 0;
const player = controller.get(interaction.guild, interaction.channel);
const player = controller.get(interaction.guild, interaction.channel);
if (!player) {
await interaction.reply(replies.notExists()); if (!player) {
await interaction.reply(replies.notExists());
return;
} return;
}
if (!player.resource) {
await interaction.reply(replies.notPlaying()); if (!player.resource) {
await interaction.reply(replies.notPlaying());
return;
} return;
}
const prevTime = player.resource.playbackDuration / 1000;
const prevTime = player.resource.playbackDuration / 1000;
const nextTime = hours * 3600 + minutes * 60 + seconds;
const nextTime = hours * 3600 + minutes * 60 + seconds;
player.seek(nextTime * 1000);
player.seek(nextTime * 1000);
await interaction.reply(replies.seeked(prevTime, nextTime));
} await interaction.reply(replies.seeked(prevTime, nextTime));
}
const data = new SlashCommandBuilder()
.setName("seek") const data = new SlashCommandBuilder()
.setDescription("Fast-forward/Fast-reverse by the given time.") .setName("seek")
.addIntegerOption((option) => option.setName("hrs").setDescription("Hours to seek.")) .setDescription("Fast-forward/Fast-reverse by the given time.")
.addIntegerOption((option) => option.setName("mins").setDescription("Minutes to seek.")) .addIntegerOption((option) => option.setName("hrs").setDescription("Hours to seek."))
.addIntegerOption((option) => option.setName("secs").setDescription("Seconds to seek.")) .addIntegerOption((option) => option.setName("mins").setDescription("Minutes to seek."))
.setDMPermission(false); .addIntegerOption((option) => option.setName("secs").setDescription("Seconds to seek."))
.setDMPermission(false);
export { data, execute };
export { data, execute };
@@ -1,33 +1,34 @@
import { Interaction, 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 { controller } from "../module.js"; import replies from "../lib/replies.js";
import { controller } from "../module.js";
async function execute(client: Client, interaction: Interaction) {
if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) { async function execute(client: Client, interaction: ChatInputCommandInteraction) {
logger.warn("Interaction rejected.", { interaction }); if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) {
logger.warn("Interaction rejected.", { interaction });
return;
} return;
}
const player = controller.get(interaction.guild, interaction.channel);
const player = controller.get(interaction.guild, interaction.channel);
if (!player) {
await interaction.reply(replies.notExists()); if (!player) {
await interaction.reply(replies.notExists());
return;
} return;
}
player.destroy();
player.destroy();
await interaction.reply(replies.destroyed());
} await interaction.reply(replies.destroyed());
}
const data = new SlashCommandBuilder()
.setName("stop") const data = new SlashCommandBuilder()
.setDescription("Destroy the player.") .setName("stop")
.setDMPermission(false); .setDescription("Destroy the player.")
.setDMPermission(false);
export { data, execute };
export { data, execute };
@@ -1,38 +1,39 @@
import { Interaction, 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 { controller } from "../module.js"; import replies from "../lib/replies.js";
import { controller } from "../module.js";
async function execute(client: Client, interaction: Interaction) {
if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) { async function execute(client: Client, interaction: ChatInputCommandInteraction) {
logger.warn("Interaction rejected.", { interaction }); if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) {
logger.warn("Interaction rejected.", { interaction });
return;
} return;
}
const volume = interaction.options.getInteger("volume") ?? 100;
const volume = interaction.options.getInteger("volume") ?? 100;
const player = controller.get(interaction.guild, interaction.channel);
const player = controller.get(interaction.guild, interaction.channel);
if (!player) {
await interaction.reply(replies.notExists()); if (!player) {
await interaction.reply(replies.notExists());
return;
} return;
}
player.volume = volume;
player.volume = volume;
await interaction.reply(replies.volumeSet(player.volume));
} await interaction.reply(replies.volumeSet(player.volume));
}
const data = new SlashCommandBuilder()
.setName("volume") const data = new SlashCommandBuilder()
.setDescription("Change player's volume amount.") .setName("volume")
.addIntegerOption((option) => .setDescription("Change player's volume amount.")
option.setName("volume").setDescription("Volume in percents.").setRequired(true) .addIntegerOption((option) =>
) option.setName("volume").setDescription("Volume in percents.").setRequired(true)
.setDMPermission(false); )
.setDMPermission(false);
export { data, execute };
export { data, execute };
@@ -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(",");
}
}
@@ -1,59 +1,132 @@
import { AudioResource } from "@discordjs/voice"; import { AudioResource } from "@discordjs/voice";
import { LoopOption, Track } from "./types.js"; import { LoopOption, Track } from "./types.js";
const STATUSBAR_LENGTH = 15; /**
* Length of playback status bar in emoji symbols.
export function statusBarGenerator(resource?: AudioResource<Track>): string { *
const duration = resource?.metadata.duration || 1; * @intenal
const playback = Math.min(resource?.playbackDuration || 0 / 1000, duration); */
const STATUSBAR_LENGTH = 15;
const filled = Array<string>(Math.floor((STATUSBAR_LENGTH * playback) / duration)).fill("🔹");
/**
const empty = Array<string>(STATUSBAR_LENGTH - filled.length).fill("▫️"); * Generates pretty playback status bar out of {@link AudioResource}.
*
return `[${filled.join("")}${empty.join("")}] ${toISOTime(playback)}/${toISOTime(duration)}`; * @remarks Takes duration and current playback duration to 'draw' bar.
} *
* @returns String representing generated status bar.
export function toISOTime(amount: number): string { *
const h = Math.floor(amount / 3600); * @param resource - AudioResource with {@link Track} metadata, so duration and playback duration is available.
const m = Math.floor((amount % 3600) / 60); *
const s = Math.floor((amount % 3600) % 60); * @privateRemarks I don't know why resource can be undefined - I forgor.
*
const hDisplay = h > 0 ? `${h}:` : ""; * @public
const mDisplay = `${Math.floor(m / 10)}${Math.floor(m % 10)}:`; */
const sDisplay = `${Math.floor(s / 10)}${Math.floor(s % 10)}`; export function statusBarGenerator(resource?: AudioResource<Track>): string {
const duration = resource?.metadata.duration || 1;
return hDisplay + mDisplay + sDisplay; const playback = Math.min((resource?.playbackDuration || 0) / 1000, duration);
}
const filled = Array<string>(Math.floor((STATUSBAR_LENGTH * playback) / duration)).fill("🔹");
export async function asleep(amount: number): Promise<unknown> {
return new Promise((r) => setTimeout(r, amount)); const empty = Array<string>(STATUSBAR_LENGTH - filled.length).fill("▫️");
}
return `[${filled.join("")}${empty.join("")}] ${toISOTime(playback)}/${toISOTime(duration)}`;
export function volumeMoji(amount: number): string { }
return amount > 100 ? "🔊" : amount > 50 ? "🔉" : amount > 0 ? "🔈" : "🔇";
} /**
* Converts seconds to ISO time string.
export function loopMoji(loop: LoopOption): string { *
return loop === "AUTO" ? "🔀" : loop === "LOOP" ? "🔁" : loop === "NONE" ? "⤵️" : "🔂"; * @returns String representing given seconds in ISO format.
} *
* @param amount - Seconds to convert.
export function largestCommonSequence(s1: string, s2: string) { *
s1 = s1.toLowerCase().slice(0, 100); * @example
s2 = s2.toLowerCase().slice(0, 100); * ```
* toISOTime(1337228); // "371:27:08"
const buffer = Array.from(Array(s1.length + 1), () => new Array<number>(s2.length + 1).fill(0)); * ```
*
for (let i = 1; i <= s1.length; i++) { * @public
for (let j = 1; j <= s2.length; j++) { */
if (s1[i - 1] === s2[j - 1]) { export function toISOTime(amount: number): string {
buffer[i][j] = buffer[i - 1][j - 1] + 1; const h = Math.floor(amount / 3600);
} else { const m = Math.floor((amount % 3600) / 60);
buffer[i][j] = Math.max(buffer[i - 1][j], buffer[i][j - 1]); const s = Math.floor((amount % 3600) % 60);
}
} const hDisplay = h > 0 ? `${h}:` : "";
} const mDisplay = `${Math.floor(m / 10)}${Math.floor(m % 10)}:`;
const sDisplay = `${Math.floor(s / 10)}${Math.floor(s % 10)}`;
return buffer[s1.length][s2.length];
} 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> {
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 {
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 {
return loop === "AUTO" ? "🔀" : loop === "LOOP" ? "🔁" : loop === "NONE" ? "⤵️" : "🔂";
}
/**
* 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);
s2 = s2.toLowerCase().slice(0, 100);
const buffer = Array.from(Array(s1.length + 1), () => new Array<number>(s2.length + 1).fill(0));
for (let i = 1; i <= s1.length; i++) {
for (let j = 1; j <= s2.length; j++) {
if (s1[i - 1] === s2[j - 1]) {
buffer[i][j] = buffer[i - 1][j - 1] + 1;
} else {
buffer[i][j] = Math.max(buffer[i - 1][j], buffer[i][j - 1]);
}
}
}
return buffer[s1.length][s2.length];
}
+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);
}
}
@@ -1,288 +1,446 @@
import { import {
AudioPlayer, AudioPlayer,
AudioPlayerStatus, AudioPlayerStatus,
AudioResource, AudioResource,
createAudioPlayer, createAudioPlayer,
createAudioResource, createAudioResource,
DiscordGatewayAdapterCreator, DiscordGatewayAdapterCreator,
joinVoiceChannel, joinVoiceChannel,
VoiceConnection VoiceConnection
} 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 ytdl from "discord-ytdl-core";
import { clamp } from "$lib/misc.js";
import { fetchInfo } from "./fetch.js";
import Filter from "./filter.js"; import { dlpInfo, dlpStream } from "./fetch/fetch.js";
import { largestCommonSequence } from "./misc.js"; import Filter from "./filter.js";
import Music from "./music.js"; import { largestCommonSequence } from "./misc.js";
import Queue from "./queue.js"; import Music from "./music.js";
import replies from "./replies.js"; import Queue from "./queue.js";
import { Track } from "./types.js"; import replies from "./replies.js";
import { Track } from "./types.js";
export default class Player {
private music: Music; /**
private guild: Guild; * Defines guild music player connected to a voice channel by wrapping AudioPlayer, Queue, VoiceConnection, etc.
*
private channel_: GuildTextBasedChannel; * @public
public get channel(): GuildTextBasedChannel { */
return this.channel_; export default class Player {
} /**
public set channel(x: GuildTextBasedChannel) { * Volume limiter clamp.
this.channel_ = x; */
} public static readonly VOLUME_CLAMP = clamp(0, 150);
private message_?: Message; /**
* Module controller orchistrate guild-player mapping.
private connection_?: VoiceConnection; */
public get connection(): VoiceConnection | undefined { private music_: Music;
return this.connection_;
} /**
* {@link Player} owner guild.
private player_: AudioPlayer; *
public get player(): AudioPlayer { * @remarks No different {@link Player} instances can have same {@link Guild}.
return this.player_; */
} private guild_: Guild;
private resource_: AudioResource<Track> | undefined; /**
public get resource(): AudioResource<Track> | undefined { * {@link Guild | Guild's} text based channel for player notifications,
return this.resource_; * such as Current Playing Track.
} */
private channel_: GuildTextBasedChannel;
private volume_ = 100; public get channel(): GuildTextBasedChannel {
public get volume(): number { return this.channel_;
return this.volume_; }
} public set channel(x: GuildTextBasedChannel) {
public set volume(x: number) { this.channel_ = x;
this.volume_ = Math.max(0, Math.min(150, x)); }
if (this.resource_ && this.resource_.volume) { /**
this.resource_.volume.setVolumeLogarithmic(this.volume_ / 100); * Previous notification message.
} */
} private message_?: Message;
public readonly queue = new Queue(); /**
* A connection to the voice channel of a {@link Guild}.
public readonly filter = new Filter(); *
* @readonly
public readonly createdAt: Date = new Date(); */
private connection_?: VoiceConnection;
public constructor(guild: Guild, music: Music, channel: GuildTextBasedChannel) { public get connection(): VoiceConnection | undefined {
this.music = music; return this.connection_;
this.guild = guild; }
this.channel_ = channel;
/**
this.player_ = createAudioPlayer(); * Player for {@link AudioResource} that is subscribed to the most recent {@link VoiceConnection}.
*
this.player_.on(AudioPlayerStatus.Idle, async () => this.resourceEndResolvable()); * @readonly
} */
private player_: AudioPlayer;
private resourceGenerator(track: Track, seek?: number) { public get player(): AudioPlayer {
const stream = ytdl(track.url, { return this.player_;
opusEncoded: true, }
filter: "audioonly",
highWaterMark: 1 << 25, /**
seek: seek ? seek / 1000 : undefined, * Represents an Audio Resource that can be played by {@link AudioPlayer}.
encoderArgs: !this.filter.empty() ? ["-af", this.filter.toString()] : undefined *
}); * @readonly
*/
this.resource_ = createAudioResource(stream, { private resource_: AudioResource<Track> | undefined;
inlineVolume: true, public get resource(): AudioResource<Track> | undefined {
metadata: track return this.resource_;
}); }
if (seek) { /**
this.resource_.playbackDuration = seek; * Inline volume for an {@link AudioResource}.
} * Must be clamped by {@link VOLUME_CLAMP}.
*/
if (this.resource_.volume) { private volume_ = 100;
this.resource_.volume.setVolumeLogarithmic(this.volume_ / 100); public get volume(): number {
} return this.volume_;
}
return this.resource_; public set volume(x: number) {
} this.volume_ = Player.VOLUME_CLAMP(x);
private resourceEndResolvable(): void { if (this.resource_?.volume) {
if (this.resource_) { this.resource_.volume.setVolumeLogarithmic(this.volume_ / 100);
this.resource_.encoder?.destroy(); }
} }
if (this.queue.next()) { /**
this.play(); * Queue manager of the {@link Player}.
} else { */
this.player_.stop(true); public readonly queue = new Queue();
this.resource_ = undefined;
} /**
} * Filter manager of the {@link Player}.
*/
private async messageResolvable(track: Track) { public readonly filter = new Filter();
if (this.message_?.deletable) {
await this.message_.delete().catch(); /**
} * Timestamp when {@link Player} was created.
*/
this.message_ = await this.channel_.send(replies.nowPlaying(track)); public readonly createdAt: Date = new Date();
}
/**
private async search(query: string, requester: User): Promise<Track[] | undefined> { * Player constructor.
const info = await fetchInfo(query); *
* @param guild - {@link Guild} for which player will be created for
if (info.length === 0) { * @param music - Module controller for managing player own existence
return undefined; * @param channel - Initial text based channed for player notifications
} */
public constructor(guild: Guild, music: Music, channel: GuildTextBasedChannel) {
return info.map((value) => { this.music_ = music;
return { ...value, requester: requester }; this.guild_ = guild;
}); this.channel_ = channel;
}
this.player_ = createAudioPlayer();
public play(seek?: number): void {
const track = this.queue.current(); this.player_.on(AudioPlayerStatus.Idle, async () => this.resourceEndResolvable());
}
if (!this.connection_ || !track) {
return; /**
} * Creates {@link AudioResource | an Audio Resource} for {@link an AudioPlayer}.
*
this.player_.play(this.resourceGenerator(track, seek)); * @returns Configured audio resource ready to be played
*
this.connection_.subscribe(this.player_); * @param track - Metadata to generate resource from and embed into it
* @param seek - Amount of seconds to start playback from
this.messageResolvable(track); */
} private async resourceGenerator(track: Track, seek?: number): Promise<AudioResource<Track>> {
const probe = await dlpStream(track.url, {
public connect(channel: VoiceBasedChannel): void { seek: seek ? seek / 1000 : undefined,
if ( ffmpeg: !this.filter.empty() ? ["-af", this.filter.toString()] : undefined
this.connection_?.joinConfig.channelId === channel.id && });
this.connection_.joinConfig.guildId === channel.guildId
) { this.resource_ = createAudioResource(probe, {
return; inlineVolume: true,
} metadata: track
});
if (this.connection_) {
this.connection_.destroy(); if (seek) {
} this.resource_.playbackDuration = seek;
}
this.connection_ = joinVoiceChannel({
channelId: channel.id, if (this.resource_.volume) {
guildId: channel.guildId, this.resource_.volume.setVolumeLogarithmic(this.volume_ / 100);
adapterCreator: channel.guild.voiceAdapterCreator as DiscordGatewayAdapterCreator }
});
return this.resource_;
this.connection_.subscribe(this.player_); }
}
/**
public async add(query: string, requester: User, index?: number): Promise<Track | undefined> { * Resolves player behavior after {@link AudioResource} ends.
const tracks = await this.search(query, requester); *
* @remarks Force destroys ended resource,
if (!tracks) { * and either plays next one or stops,
return; * depending on {@link Queue#next | Queue.next() method}
} */
private resourceEndResolvable(): void {
this.queue.push(tracks, index); if (this.resource_) {
this.resource_.encoder?.destroy();
return tracks[0]; }
}
if (this.queue.next()) {
public jump(query: number | string): number | undefined { this.play();
let position = typeof query === "number" ? query : -1; } else {
let diff = 0; this.player_.stop(true);
this.resource_ = undefined;
if (typeof query === "string") { }
this.queue.tracks.forEach((track, index) => { }
const tempDiff = largestCommonSequence(track.title, query);
if (diff < tempDiff) { /**
diff = tempDiff; * Resolves notification emittion.
position = index; *
} * @remarks Tries to delete previous notification message,
}); * and send next one
} */
private async messageResolvable(track: Track) {
if (position !== -1 && this.queue.jump(position)) { if (this.message_?.deletable) {
this.play(); await this.message_.delete().catch();
return position; }
}
this.message_ = await this.channel_.send(replies.nowPlaying(track));
return undefined; }
}
/**
public remove(query?: number | string): Track | undefined { * Fetches {@link DlpInfo} and converts it to {@link Track | Track metadata}.
const oldPosition = this.queue.position; *
* @returns Promise to Track list or undefined, if found nothing.
let newPosition = typeof query === "number" ? query : this.queue.position; *
let diff = 0; * @param query - String that Dlp will try to resolve
* @param requester - {@link User} that requested Tracks returned to complete convertion
if (typeof query === "string") { */
this.queue.tracks.forEach((track, index) => { private async search(query: string, requester: User): Promise<Track[] | undefined> {
const tempDiff = largestCommonSequence(track.title, query); const info = await dlpInfo(query);
if (diff < tempDiff) {
diff = tempDiff; return info.map((value) => {
newPosition = index; return { ...value, requester: requester };
} });
}); }
}
/**
const track = this.queue.pop(newPosition); * Plays current {@link Queue} track from position specified.
*
if (track) { * @param seek - Amount of seconds to start playback from
if (oldPosition === newPosition) { */
this.play(); public async play(seek?: number): Promise<void> {
} const track = this.queue.current();
return track;
} if (!this.connection_ || !track) {
return;
return undefined; }
}
this.player_.play(await this.resourceGenerator(track, seek));
public seek(time: number): void {
this.play(time); this.connection_.subscribe(this.player_);
}
await this.messageResolvable(track);
public clear(): void { }
this.queue.clear();
this.resource_ = undefined; /**
* Creates connection to guild's voice based channel,
if (this.player_) { * previously destroying existing connection.
this.player_.stop(); *
} * @param channel - {@link VoiceBasedChannel} to connect to
} */
public connect(channel: VoiceBasedChannel): void {
public destroy(): void { if (
if (this.connection_) { this.connection_?.joinConfig.channelId === channel.id &&
this.connection_.destroy(); this.connection_.joinConfig.guildId === channel.guildId
} ) {
return;
this.player_.stop(); }
this.music.delete(this.guild.id);
} if (this.connection_) {
this.connection_.destroy();
public pause(): boolean { }
if (this.player_.state.status === AudioPlayerStatus.Paused) {
this.player_.unpause(); this.connection_ = joinVoiceChannel({
return false; channelId: channel.id,
} else { guildId: channel.guildId,
this.player_.pause(); adapterCreator: channel.guild.voiceAdapterCreator as DiscordGatewayAdapterCreator
return true; });
}
} this.connection_.subscribe(this.player_);
}
public shuffle(): void {
this.queue.shuffle(); /**
* Adds {@link Track | Track(s)} found by {@link search | the search method} to the {@link queue}.
if (this.resource_) { *
this.player_.stop(); * @returns Track metadata if found, undefined otherwise
this.play(); *
} * @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
public skip(): void { * Value < 0 resolves to 0
this.player_.stop(); * Value >= (queue size) resolves to (queue end)
} */
public async add(query: string, requester: User, index?: number): Promise<Track | undefined> {
public setFilter(filter?: string): boolean { const tracks = await this.search(query, requester);
const status = this.filter.toggle(filter);
if (!tracks) {
if (this.resource_) { return;
this.play(this.resource_.playbackDuration); }
}
this.queue.push(tracks, index);
return status;
} return tracks[0];
} }
/**
* Jumps across {@link queue} to the position determined by the query.
*
* @remarks If string passed, then position will be determined by
* the largest common sequence across all the {@link queue} titles.
*
* @return Position to which jump was perfomed
*
* @param query - String of {@link Track} title or position to jump to
*/
public jump(query: number | string): number | undefined {
let position = typeof query === "number" ? query : -1;
let diff = 0;
if (typeof query === "string") {
this.queue.tracks.forEach((track, index) => {
const tempDiff = largestCommonSequence(track.title, query);
if (diff < tempDiff) {
diff = tempDiff;
position = index;
}
});
}
if (position !== -1 && this.queue.jump(position)) {
this.play();
return position;
}
return undefined;
}
/**
* Removes {@link Track} from {@link queue | the queue} by the position determined by the query.
*
* @remarks If string passed, then position will be determined by
* the largest common sequence across all the {@link queue} titles.
*
* @return Metadata of removed track, or undefined if nothing happened
*
* @param query - String of {@link Track} title or position to perfome removal at
*/
public remove(query?: number | string): Track | undefined {
const oldPosition = this.queue.position;
let newPosition = typeof query === "number" ? query : this.queue.position;
let diff = 0;
if (typeof query === "string") {
this.queue.tracks.forEach((track, index) => {
const tempDiff = largestCommonSequence(track.title, query);
if (diff < tempDiff) {
diff = tempDiff;
newPosition = index;
}
});
}
const track = this.queue.pop(newPosition);
if (track) {
if (oldPosition === newPosition) {
this.play();
}
return track;
}
return undefined;
}
/**
* Restarts playback from timecode specified.
*
* @param time - Amount of seconds to start playback from
*/
public seek(time: number): void {
this.play(time);
}
/**
* Clears {@link queue} completely - not destroying {@link voiceConnection_}.
*/
public clear(): void {
this.queue.clear();
this.resource_ = undefined;
if (this.player_) {
this.player_.stop();
}
}
/**
* Destroys this instance of {@link Player} and deletes it from controller mapping.
*/
public destroy(): void {
if (this.connection_) {
this.connection_.destroy();
}
this.player_.stop();
this.music_.delete(this.guild_.id);
}
/**
* Pauses/unpauses {@link AudioPlayer} playback.
*
* @returns True if paused, or False if unpaused
*/
public pause(): boolean {
if (this.player_.state.status === AudioPlayerStatus.Paused) {
this.player_.unpause();
return false;
} else {
this.player_.pause();
return true;
}
}
/**
* Reshuffles {@link queue} order - will start new playback from the same position in the queue.
*/
public shuffle(): void {
this.queue.shuffle();
if (this.resource_) {
this.player_.stop();
this.play();
}
}
/**
* Stops {@link player_} that will emit {@link resourceEndResolvable}.
*
* @see {@link resourceEndResolvable} for understanding full behavior
*/
public skip(): void {
this.player_.stop();
}
/**
* Applies {@link Filter} preset to the current playback.
*
* @returns True if applied, false if revoked
*
* @param filter - Name of a filter to apply
* Must exist in Filter class
*/
public setFilter(filter?: string): boolean {
const status = this.filter.toggle(filter);
if (this.resource_) {
this.play(this.resource_.playbackDuration);
}
return status;
}
}
@@ -1,117 +1,181 @@
import { LoopOption, Track } from "./types.js"; import { LoopOption, Track } from "./types.js";
export default class Queue { /**
private tracks_: Track[] = []; * Class of {@link ./player#Player | Player's} queue.
public get tracks(): Track[] { *
return this.tracks_; * @public
} */
export default class Queue {
private position_ = 0; /**
public get position(): number { * Array of {@link Track | Tracks} - content of queue.
return this.position_; */
} private tracks_: Track[] = [];
public get tracks(): Track[] {
private loop_: LoopOption = "NONE"; return this.tracks_;
public get loop(): LoopOption { }
return this.loop_;
} /**
public set loop(x: LoopOption) { * Position of current playing track.
this.loop_ = x; */
} private position_ = 0;
public get position(): number {
private duration_ = 0; return this.position_;
public get duration(): number { }
return this.duration_;
} /**
* Defines behavior of queue.
public next(): Track | undefined { *
if (this.loop_ === "SONG") { * @see ./types.ts for more info about values.
return this.current(); */
} private loop_: LoopOption = "NONE";
public get loop(): LoopOption {
if (this.loop_ === "AUTO") { return this.loop_;
this.position_ = Math.floor(Math.random() * this.tracks_.length); }
return this.current(); public set loop(x: LoopOption) {
} this.loop_ = x;
}
if (this.position_ + 1 < this.tracks_.length) {
this.position_++; /**
return this.current(); * Queue overall duration in seconds.
} else if (this.loop_ === "LOOP") { */
this.position_ = 0; private duration_ = 0;
return this.current(); public get duration(): number {
} else if (this.loop_ === "NONE") { return this.duration_;
this.position_ = 0; }
return undefined;
} /**
} * Skips current track.
*
public shuffle(): void { * @remarks Behavior depends on loop option.
let currentIndex = this.tracks_.length; *
* @returns next {@link Track} or undefined if end was reached.
while (currentIndex != 0) { */
const randomIndex = Math.floor(Math.random() * currentIndex); public next(): Track | undefined {
currentIndex--; if (this.loop_ === "SONG") {
return this.current();
[this.tracks_[currentIndex], this.tracks_[randomIndex]] = [ }
this.tracks_[randomIndex],
this.tracks_[currentIndex] if (this.loop_ === "AUTO") {
]; this.position_ = Math.floor(Math.random() * this.tracks_.length);
} return this.current();
}
this.position_ = 0;
} if (this.position_ + 1 < this.tracks_.length) {
this.position_++;
public push(tracks: Track[], index?: number): Track { return this.current();
if (!index || index >= this.tracks_.length) { } else if (this.loop_ === "LOOP") {
this.tracks_ = this.tracks_.concat(tracks); this.position_ = 0;
} else { return this.current();
this.tracks_.splice(Math.max(0, index), 0, ...tracks); } else if (this.loop_ === "NONE") {
if (this.position_ >= index) { this.position_ = 0;
this.position_ += tracks.length; return undefined;
} }
} }
for (const track of tracks) { /**
this.duration_ += track.duration; * Shuffles queue by using simple Math.random.
} */
public shuffle(): void {
return this.tracks_[index || this.tracks_.length - 1]; let currentIndex = this.tracks_.length;
}
while (currentIndex != 0) {
public pop(query: number): Track | undefined { const randomIndex = Math.floor(Math.random() * currentIndex);
if (query >= 0 && query < this.tracks_.length) { currentIndex--;
const track = this.tracks_[query];
[this.tracks_[currentIndex], this.tracks_[randomIndex]] = [
this.tracks_.splice(query, 1); this.tracks_[randomIndex],
this.position_ = Math.max(0, this.position_ - 1); this.tracks_[currentIndex]
this.duration_ -= track.duration; ];
return track; }
}
this.position_ = 0;
return undefined; }
}
/**
public jump(query: number): Track | undefined { * Inserts tracks from provided array at specific index.
if (query >= 0 && query < this.tracks_.length) { *
const track = this.tracks_[query]; * @returns first {@link Track} of inserted Tracks.
this.position_ = query; *
return track; * @param tracks - List of Tracks to insert to the queue.
} * @param index - Position at which insertion will be occurred.
*/
return undefined; public push(tracks: Track[], index?: number): Track {
} for (const track of tracks) {
this.duration_ += track.duration;
public current(): Track { }
return this.tracks_[this.position_];
} if (!index || index >= this.tracks_.length) {
this.tracks_ = this.tracks_.concat(tracks);
public empty(): boolean {
return this.tracks_.length === 0; return this.tracks_[this.tracks_.length - tracks.length];
} }
public clear(): void { this.tracks_.splice(Math.max(0, index), 0, ...tracks);
this.tracks_ = []; if (this.position_ >= index) {
this.position_ = 0; this.position_ += tracks.length;
} }
}
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 {
if (query >= 0 && query < this.tracks_.length) {
const track = this.tracks_[query];
this.tracks_.splice(query, 1);
this.position_ = Math.max(0, this.position_ - 1);
this.duration_ -= track.duration;
return track;
}
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 {
if (query >= 0 && query < this.tracks_.length) {
const track = this.tracks_[query];
this.position_ = query;
return track;
}
return undefined;
}
/**
* Returns current {@link Track} (at position).
*
* @returns Track at internal position.
*/
public current(): Track {
return this.tracks_[this.position_];
}
/**
* Checks if queue is empty.
*
* @returns True is queue is empty, False otherwise.
*/
public empty(): boolean {
return this.tracks_.length === 0;
}
/**
* Clears entire queue and sets position to 0.
*/
public clear(): void {
this.tracks_ = [];
this.position_ = 0;
}
}
@@ -1,376 +1,377 @@
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 Player from "./player.js"; import { loopMoji, statusBarGenerator, toISOTime, volumeMoji } from "./misc.js";
import Queue from "./queue.js"; import Player from "./player.js";
import { LoopOption, Track } from "./types.js"; import Queue from "./queue.js";
import { LoopOption, Track } from "./types.js";
const replies = {
notExists: (): BaseMessageOptions => { const replies = {
const embed = new EmbedBuilder() notExists: (): BaseMessageOptions => {
.setColor(config.colors.warning) const embed = new EmbedBuilder()
.setTitle("⚠️ Command ignored - player does not exist") .setColor(config.colors.warning)
.setDescription("Use '/play' to create a new one."); .setTitle("⚠️ Command ignored - player does not exist")
.setDescription("Use '/play' to create a new one.");
return { embeds: [embed] };
}, return { embeds: [embed] };
searching: (): BaseMessageOptions => { },
const embed = new EmbedBuilder() searching: (): BaseMessageOptions => {
.setColor(config.colors.default) const embed = new EmbedBuilder()
.setTitle("🔍 Searching...") .setColor(config.colors.default)
.setDescription("It will take longer if playlist or non-youtube URL was queried."); .setTitle("🔍 Searching...")
.setDescription("It will take longer if playlist or non-youtube URL was queried.");
return { embeds: [embed] };
}, return { embeds: [embed] };
notPlaying: (): BaseMessageOptions => { },
const embed = new EmbedBuilder() notPlaying: (): BaseMessageOptions => {
.setColor(config.colors.default) const embed = new EmbedBuilder()
.setTitle("💿 Insert the disk...") .setColor(config.colors.default)
.setURL("https://www.youtube.com/watch?v=dQw4w9WgXcQ") .setTitle("💿 Insert the disk...")
.setDescription("Use '/play' to start the playback."); .setURL("https://www.youtube.com/watch?v=dQw4w9WgXcQ")
.setDescription("Use '/play' to start the playback.");
return { embeds: [embed] };
}, return { embeds: [embed] };
nowPlaying: (track: Track): BaseMessageOptions => { },
const embed = new EmbedBuilder() nowPlaying: (track: Track): BaseMessageOptions => {
.setColor(config.colors.default) const embed = new EmbedBuilder()
.setAuthor({ .setColor(config.colors.default)
iconURL: track.requester.avatarURL() ?? undefined, .setAuthor({
name: track.requester.tag iconURL: track.requester.avatarURL() ?? undefined,
}) name: track.requester.username
.setTitle(track.title) })
.setURL(track.url) .setTitle(track.title)
.setDescription("playing now.") .setURL(track.url)
.setThumbnail(track.thumbnail); .setDescription("playing now.")
.setThumbnail(track.thumbnail);
return { embeds: [embed] };
}, return { embeds: [embed] };
okConnected: (channel: string): BaseMessageOptions => { },
const embed = new EmbedBuilder() okConnected: (channel: string): BaseMessageOptions => {
.setColor(config.colors.success) const embed = new EmbedBuilder()
.setTitle(`✅ Successfuly connected to: ${italic(channel)}`) .setColor(config.colors.success)
.setDescription("Will leave after some time, if left alone."); .setTitle(`✅ Successfuly connected to: ${italic(channel)}`)
.setDescription("Will leave after some time, if left alone.");
return { embeds: [embed] };
}, return { embeds: [embed] };
notConnected: (): BaseMessageOptions => { },
const embed = new EmbedBuilder() notConnected: (): BaseMessageOptions => {
.setColor(config.colors.error) const embed = new EmbedBuilder()
.setTitle("❗ Cannot create new player.") .setColor(config.colors.error)
.setDescription("Make yourself connected to a voice channel beforehand."); .setTitle("❗ Cannot create new player.")
.setDescription("Make yourself connected to a voice channel beforehand.");
return { embeds: [embed] };
}, return { embeds: [embed] };
destroyed: (): BaseMessageOptions => { },
const embed = new EmbedBuilder() destroyed: (): BaseMessageOptions => {
.setColor(config.colors.error) const embed = new EmbedBuilder()
.setTitle("💀 Destroying the player") .setColor(config.colors.error)
.setDescription("Everything is lost now. Use '/play' to create a new one."); .setTitle("💀 Destroying the player")
.setDescription("Everything is lost now. Use '/play' to create a new one.");
return { embeds: [embed] };
}, return { embeds: [embed] };
alone: (): BaseMessageOptions => { },
const embed = new EmbedBuilder() alone: (): BaseMessageOptions => {
.setColor(config.colors.default) const embed = new EmbedBuilder()
.setTitle("🥬 I have decided to leave to conserve my energy") .setColor(config.colors.default)
.setDescription("Since there was nobody in the voice channel."); .setTitle("🥬 I have decided to leave to conserve my energy")
.setDescription("Since there was nobody in the voice channel.");
return { embeds: [embed] };
}, return { embeds: [embed] };
paused: (state: boolean): BaseMessageOptions => { },
let embed: EmbedBuilder; paused: (state: boolean): BaseMessageOptions => {
let embed: EmbedBuilder;
if (state) {
embed = new EmbedBuilder() if (state) {
.setColor(config.colors.success) embed = new EmbedBuilder()
.setTitle("▶️ The playback was resumed"); .setColor(config.colors.success)
} else { .setTitle("▶️ The playback was resumed");
embed = new EmbedBuilder() } else {
.setColor(config.colors.warning) embed = new EmbedBuilder()
.setTitle("⏸️ The playback was paused") .setColor(config.colors.warning)
.setDescription("Invoke this command again to resume."); .setTitle("⏸️ The playback was paused")
} .setDescription("Invoke this command again to resume.");
}
return { embeds: [embed] };
}, return { embeds: [embed] };
seeked: (from: number, to: number): BaseMessageOptions => { },
let embed: EmbedBuilder; seeked: (from: number, to: number): BaseMessageOptions => {
let embed: EmbedBuilder;
if (from < to) {
embed = new EmbedBuilder() if (from < to) {
.setColor(config.colors.info) embed = new EmbedBuilder()
.setTitle(`⏩ Fast-forwarding by ${toISOTime(to - from)}`); .setColor(config.colors.info)
} else { .setTitle(`⏩ Fast-forwarding by ${toISOTime(to - from)}`);
embed = new EmbedBuilder() } else {
.setColor(config.colors.info) embed = new EmbedBuilder()
.setTitle(`⏪ Fast-reversing by ${toISOTime(from - to)}`); .setColor(config.colors.info)
} .setTitle(`⏪ Fast-reversing by ${toISOTime(from - to)}`);
}
embed.setDescription(
`Moving playback from ${italic(toISOTime(from))} to ${italic(toISOTime(to))}.` embed.setDescription(
); `Moving playback from ${italic(toISOTime(from))} to ${italic(toISOTime(to))}.`
);
return { embeds: [embed] };
}, return { embeds: [embed] };
looped: (option: LoopOption) => { },
let embed: EmbedBuilder; looped: (option: LoopOption) => {
let embed: EmbedBuilder;
switch (option) {
case "AUTO": { switch (option) {
embed = new EmbedBuilder() case "AUTO": {
.setColor(config.colors.default) embed = new EmbedBuilder()
.setTitle(`${loopMoji(option)} Shuffle mode.`) .setColor(config.colors.default)
.setDescription("Does not affect the queue order. Endless playback."); .setTitle(`${loopMoji(option)} Shuffle mode.`)
.setDescription("Does not affect the queue order. Endless playback.");
break;
} break;
case "LOOP": { }
embed = new EmbedBuilder() case "LOOP": {
.setColor(config.colors.info) embed = new EmbedBuilder()
.setTitle(`${loopMoji(option)} Looping over the queue.`) .setColor(config.colors.info)
.setDescription("Will start over after final track. Endless playback."); .setTitle(`${loopMoji(option)} Looping over the queue.`)
.setDescription("Will start over after final track. Endless playback.");
break;
} break;
case "SONG": { }
embed = new EmbedBuilder() case "SONG": {
.setColor(config.colors.warning) embed = new EmbedBuilder()
.setTitle(`${loopMoji(option)} Looping a single track.`) .setColor(config.colors.warning)
.setDescription("Endless playback of a single track. Over and over again..."); .setTitle(`${loopMoji(option)} Looping a single track.`)
.setDescription("Endless playback of a single track. Over and over again...");
break;
} break;
case "NONE": { }
embed = new EmbedBuilder() case "NONE": {
.setColor(config.colors.error) embed = new EmbedBuilder()
.setTitle(`${loopMoji(option)} Drop on final.`) .setColor(config.colors.error)
.setDescription( .setTitle(`${loopMoji(option)} Drop on final.`)
"Will end playback after final track. Action will be needed to continue." .setDescription(
); "Will end playback after final track. Action will be needed to continue."
);
break;
} break;
} }
}
return { embeds: [embed] };
}, return { embeds: [embed] };
reshuffle: (): BaseMessageOptions => { },
const embed = new EmbedBuilder() reshuffle: (): BaseMessageOptions => {
.setColor(config.colors.success) const embed = new EmbedBuilder()
.setTitle("✅ Successfuly reshuffled queue") .setColor(config.colors.success)
.setDescription("Use 'random' mode, if you want shuffle without reshuffle."); .setTitle("✅ Successfuly reshuffled queue")
.setDescription("Use 'random' mode, if you want shuffle without reshuffle.");
return { embeds: [embed] };
}, return { embeds: [embed] };
skipped: (): BaseMessageOptions => { },
const embed = new EmbedBuilder() skipped: (): BaseMessageOptions => {
.setColor(config.colors.info) const embed = new EmbedBuilder()
.setTitle("⏭️ Skipping current track") .setColor(config.colors.info)
.setDescription("Behavior depends on the loop option."); .setTitle("⏭️ Skipping current track")
.setDescription("Behavior depends on the loop option.");
return { embeds: [embed] };
}, return { embeds: [embed] };
jumped: (previous?: Track, to?: number): BaseMessageOptions => { },
if (!to) { jumped: (previous?: Track, to?: number): BaseMessageOptions => {
const embed = new EmbedBuilder() if (!to) {
.setColor(config.colors.error) const embed = new EmbedBuilder()
.setTitle("❗ Cannot perfome jump over the queue") .setColor(config.colors.error)
.setDescription("Either not found by query, or index is not in range."); .setTitle("❗ Cannot perfome jump over the queue")
.setDescription("Either not found by query, or index is not in range.");
return { embeds: [embed] };
} return { embeds: [embed] };
}
const embed = new EmbedBuilder()
.setColor(config.colors.success) const embed = new EmbedBuilder()
.setTitle(`✅ Jumping over the queue`); .setColor(config.colors.success)
.setTitle(`✅ Jumping over the queue`);
if (previous) {
embed.setDescription(`Switching from ${italic(previous.title)}.`); if (previous) {
} else { embed.setDescription(`Switching from ${italic(previous.title)}.`);
embed.setDescription("Playback will start again."); } else {
} embed.setDescription("Playback will start again.");
}
return { embeds: [embed] };
}, return { embeds: [embed] };
appended: (track?: Track): BaseMessageOptions => { },
let embed: EmbedBuilder; appended: (track?: Track): BaseMessageOptions => {
let embed: EmbedBuilder;
if (track) {
embed = new EmbedBuilder() if (track) {
.setColor(config.colors.success) embed = new EmbedBuilder()
.setAuthor({ .setColor(config.colors.success)
iconURL: track.requester.avatarURL() ?? undefined, .setAuthor({
name: track.requester.tag iconURL: track.requester.avatarURL() ?? undefined,
}) name: track.requester.username
.setTitle(track.title) })
.setURL(track.url) .setTitle(track.title)
.setDescription("has been added to the queue.") .setURL(track.url)
.setThumbnail(track.thumbnail) .setDescription("has been added to the queue.")
.setTimestamp(); .setThumbnail(track.thumbnail)
} else { .setTimestamp();
embed = new EmbedBuilder() } else {
.setColor(config.colors.error) embed = new EmbedBuilder()
.setTitle("❗ Cannot add track to the queue") .setColor(config.colors.error)
.setDescription("Either not found by query, or URL is broken."); .setTitle("❗ Cannot add track to the queue")
} .setDescription("Either not found by query, or URL is broken.");
}
return { embeds: [embed] };
}, return { embeds: [embed] };
removed: (track?: Track): BaseMessageOptions => { },
let embed: EmbedBuilder; removed: (track?: Track): BaseMessageOptions => {
let embed: EmbedBuilder;
if (track) {
embed = new EmbedBuilder() if (track) {
.setColor(config.colors.success) embed = new EmbedBuilder()
.setTitle(track.title) .setColor(config.colors.success)
.setURL(track.url) .setTitle(track.title)
.setDescription("was removed from the queue.") .setURL(track.url)
.setThumbnail(track.thumbnail) .setDescription("was removed from the queue.")
.setTimestamp(); .setThumbnail(track.thumbnail)
} else { .setTimestamp();
embed = new EmbedBuilder() } else {
.setColor(config.colors.error) embed = new EmbedBuilder()
.setTitle("❗ Cannot delete track from queue") .setColor(config.colors.error)
.setDescription("Either not found by query, or index is not in range."); .setTitle("❗ Cannot delete track from queue")
} .setDescription("Either not found by query, or index is not in range.");
}
return { embeds: [embed] };
}, return { embeds: [embed] };
cleared: (amount: number): BaseMessageOptions => { },
const embed = new EmbedBuilder() cleared: (amount: number): BaseMessageOptions => {
.setColor(config.colors.success) const embed = new EmbedBuilder()
.setTitle(`♻️ Queue has been cleared`) .setColor(config.colors.success)
.setDescription( .setTitle(`♻️ Queue has been cleared`)
`${italic(amount.toString())} track${amount > 1 ? "s were" : " was"} deleted.` .setDescription(
); `${italic(amount.toString())} track${amount > 1 ? "s were" : " was"} deleted.`
);
return { embeds: [embed] };
}, return { embeds: [embed] };
filtered: (filter?: string, status = true): BaseMessageOptions => { },
let embed: EmbedBuilder; filtered: (filter?: string, status = true): BaseMessageOptions => {
let embed: EmbedBuilder;
if (filter) {
embed = new EmbedBuilder().setTitle( if (filter) {
`✅ ${italic(filter)} was ${status ? "applied to the playback" : "declined"}.` embed = new EmbedBuilder().setTitle(
); `✅ ${italic(filter)} was ${status ? "applied to the playback" : "declined"}.`
} else { );
embed = new EmbedBuilder().setTitle("♻️ All filters were declined"); } else {
} embed = new EmbedBuilder().setTitle("♻️ All filters were declined");
}
embed
.setColor(config.colors.success) embed
.setDescription("Playback can experience some lags during intial transformation."); .setColor(config.colors.success)
.setDescription("Playback can experience some lags during initial transformation.");
return { embeds: [embed] };
}, return { embeds: [embed] };
volumeSet: (amount: number): BaseMessageOptions => { },
const embed = new EmbedBuilder() volumeSet: (amount: number): BaseMessageOptions => {
.setColor(config.colors.success) const embed = new EmbedBuilder()
.setTitle(`${volumeMoji(amount)} Volume has been set to ${amount}%`) .setColor(config.colors.success)
.setDescription("This will affect everyone listening."); .setTitle(`${volumeMoji(amount)} Volume has been set to ${amount}%`)
.setDescription("This will affect everyone listening.");
return { embeds: [embed] };
}, return { embeds: [embed] };
queue: (queue: Queue, position = 0, resource?: AudioResource<Track>): BaseMessageOptions => { },
if (queue.empty()) { queue: (queue: Queue, position = 0, resource?: AudioResource<Track>): BaseMessageOptions => {
const embed = new EmbedBuilder() if (queue.empty()) {
.setColor(config.colors.warning) const embed = new EmbedBuilder()
.setTitle("⚠️ Queue is empty") .setColor(config.colors.warning)
.setDescription("Use '/play' to add some tracks."); .setTitle("⚠️ Queue is empty")
.setDescription("Use '/play' to add some tracks.");
return { embeds: [embed] };
} return { embeds: [embed] };
}
let embed: EmbedBuilder;
let embed: EmbedBuilder;
if (resource) {
embed = new EmbedBuilder() if (resource) {
.setTitle(`${queue.position + 1}. ${resource.metadata.title}`) embed = new EmbedBuilder()
.setAuthor({ .setTitle(`${queue.position + 1}. ${resource.metadata.title}`)
iconURL: resource.metadata.requester.avatarURL() ?? undefined, .setAuthor({
name: resource.metadata.requester.tag iconURL: resource.metadata.requester.avatarURL() ?? undefined,
}) name: resource.metadata.requester.username
.setURL(resource.metadata.url) })
.setDescription(statusBarGenerator(resource)) .setURL(resource.metadata.url)
.setThumbnail(resource.metadata.thumbnail); .setDescription(statusBarGenerator(resource))
} else { .setThumbnail(resource.metadata.thumbnail);
embed = new EmbedBuilder() } else {
.setTitle("💿 Insert the disk...") embed = new EmbedBuilder()
.setURL("https://www.youtube.com/watch?v=5mGuCdlCcNM&ab_channel=Ra%C3%BAlBlanco") .setTitle("💿 Insert the disk...")
.setDescription(statusBarGenerator()) .setURL("https://www.youtube.com/watch?v=5mGuCdlCcNM&ab_channel=Ra%C3%BAlBlanco")
.setThumbnail("https://img.youtube.com/vi/5mGuCdlCcNM/default.jpg"); .setDescription(statusBarGenerator())
} .setThumbnail("https://img.youtube.com/vi/5mGuCdlCcNM/default.jpg");
}
embed.setColor(config.colors.info).setFields(
queue.tracks.slice(position, position + 10).map((value, index) => ({ embed.setColor(config.colors.info).setFields(
name: `${position + index + 1}. ${value.title.padEnd(35)}`, queue.tracks.slice(position, position + 10).map((value, index) => ({
value: `[${toISOTime(value.duration)}] | ${value.requester.tag}` name: `${position + index + 1}. ${value.title.padEnd(35)}`,
})) value: `[${toISOTime(value.duration)}] | ${value.requester.username}`
); }))
);
return { embeds: [embed] };
}, return { embeds: [embed] };
info: (player: Player): BaseMessageOptions => { },
let embed: EmbedBuilder; info: (player: Player): BaseMessageOptions => {
let embed: EmbedBuilder;
if (player.resource) {
embed = new EmbedBuilder() if (player.resource) {
.setTitle(`${player.queue.position + 1}. ${player.resource.metadata.title}`) embed = new EmbedBuilder()
.setAuthor({ .setTitle(`${player.queue.position + 1}. ${player.resource.metadata.title}`)
iconURL: player.resource.metadata.requester.avatarURL() ?? undefined, .setAuthor({
name: player.resource.metadata.requester.tag iconURL: player.resource.metadata.requester.avatarURL() ?? undefined,
}) name: player.resource.metadata.requester.username
.setURL(player.resource.metadata.url) })
.setDescription(statusBarGenerator(player.resource)) .setURL(player.resource.metadata.url)
.setThumbnail(player.resource.metadata.thumbnail); .setDescription(statusBarGenerator(player.resource))
} else { .setThumbnail(player.resource.metadata.thumbnail);
embed = new EmbedBuilder() } else {
.setTitle("💿 Insert the disk...") embed = new EmbedBuilder()
.setURL("https://www.youtube.com/watch?v=dQw4w9WgXcQ") .setTitle("💿 Insert the disk...")
.setDescription(statusBarGenerator()); .setURL("https://www.youtube.com/watch?v=dQw4w9WgXcQ")
} .setDescription(statusBarGenerator());
}
embed.setColor(config.colors.info).addFields([
{ embed.setColor(config.colors.info).addFields([
name: "Applied Filters", {
value: name: "Applied Filters",
player.filter value:
.toggled() player.filter
.map((value) => value[0]) .toggled()
.join("\n") + "\ndefault", .map((value) => value.name)
inline: true .join("\n") + "\ndefault",
}, inline: true
{ },
name: "Queue Length", {
value: `🔢 ${player.queue.tracks.length}`, name: "Queue Length",
inline: true value: `🔢 ${player.queue.tracks.length}`,
}, inline: true
{ },
name: "Queue Duration", {
value: `💿 ${toISOTime(player.queue.duration)}`, name: "Queue Duration",
inline: true value: `💿 ${toISOTime(player.queue.duration)}`,
}, inline: true
{ },
name: "Queue Looping", {
value: `${loopMoji(player.queue.loop)} ${player.queue.loop}`, name: "Queue Looping",
inline: true value: `${loopMoji(player.queue.loop)} ${player.queue.loop}`,
}, inline: true
{ },
name: "Volume Amount", {
value: `${volumeMoji(player.volume)} ${player.volume}/150`, name: "Volume Amount",
inline: true value: `${volumeMoji(player.volume)} ${player.volume}/150`,
}, inline: true
{ },
name: "Player Lifetime", {
value: `🕓 ${toISOTime((Date.now() - player.createdAt.getTime()) / 1000)}`, name: "Player Lifetime",
inline: true value: `🕓 ${toISOTime((Date.now() - player.createdAt.getTime()) / 1000)}`,
} inline: true
]); }
]);
return { embeds: [embed] };
} return { embeds: [embed] };
}; }
};
export default replies;
export default replies;
+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/" }]
}
+54 -54
View File
@@ -1,54 +1,54 @@
{ {
"name": "quetza-bot", "name": "quetza",
"engines": { "version": "2024.3.0",
"node": "18.x.x", "description": "Awesome Discord bot that performs various duties",
"npm": "*" "type": "module",
}, "main": "./dist/src/index.js",
"type": "module", "scripts": {
"version": "4.0.0", "start": "node ./dist/src/index.js",
"description": "Discord multifunctional bot", "build": "./build.sh",
"author": { "clean": "rm -rf ./dist",
"name": "unknowableshade", "lint": "pnpm exec eslint . && pnpm exec prettier --check .",
"email": "60275123+unknowableshade@users.noreply.github.com", "format": "pnpm exec eslint --fix . && pnpm exec prettier --write .",
"url": "https://unknowableshade.ru" "preinstall": "pnpm dlx only-allow pnpm"
}, },
"repository": { "license": "MIT",
"type": "git", "repository": {
"url": "git+https://github.com/unknowableshade/quetza-bot.git" "type": "git",
}, "url": "git+https://github.com/unknowableshade/Quetza.git"
"bugs": { },
"url": "https://github.com/unknowableshade/quetza-bot/issues" "author": {
}, "name": "Vladimir Eremin",
"readme": "https://github.com/unknowableshade/quetza-bot#readme", "email": "personal@ermnvldmr.com",
"license": "MIT", "url": "https://ermnvldmr.com"
"main": "dist/index.js", },
"scripts": { "bugs": {
"start": "node dist/index.js", "url": "https://github.com/unknowableshade/Quetza/issues"
"build": "pnpm exec tsc --build", },
"clean": "pnpm exec tsc --build --clean", "readme": "https://github.com/unknowableshade/Quetza#readme",
"lint": "pnpm exec eslint --fix . && pnpm exec prettier --write .", "engines": {
"preinstall": "pnpx only-allow pnpm" "node": "20.x.x",
}, "npm": "*"
"dependencies": { },
"@discordjs/opus": "^0.9.0", "dependencies": {
"@discordjs/voice": "^0.16.0", "@discordjs/opus": "^0.9.0",
"@prisma/client": "4.13.0", "@discordjs/voice": "^0.16.1",
"discord-ytdl-core": "^5.0.4", "discord.js": "^14.14.1",
"discord.js": "^14.11.0", "prism-media": "^1.3.5",
"tweetnacl": "^1.0.3", "tweetnacl": "^1.0.3",
"winston": "^3.8.2", "winston": "^3.11.0"
"ytdl-core": "^4.11.4" },
}, "devDependencies": {
"devDependencies": { "@types/node": "^20.11.24",
"@types/node": "^18.16.6", "@typescript-eslint/eslint-plugin": "^7.1.0",
"@typescript-eslint/eslint-plugin": "^5.59.5", "@typescript-eslint/parser": "^7.1.0",
"@typescript-eslint/parser": "^5.59.5", "eslint": "^8.57.0",
"eslint": "^8.40.0", "eslint-config-prettier": "^9.1.0",
"eslint-config-prettier": "^8.8.0", "eslint-plugin-import": "^2.29.1",
"eslint-plugin-import": "^2.27.5", "eslint-plugin-simple-import-sort": "^12.0.0",
"eslint-plugin-simple-import-sort": "^10.0.0", "mocha": "^10.3.0",
"prettier": "^2.8.8", "prettier": "^3.2.5",
"prisma": "^4.13.0", "tsc-alias": "^1.8.8",
"typescript": "^4.9.5" "typescript": "^5.3.3"
} }
} }
+3749 -3218
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;
+19 -14
View File
@@ -1,14 +1,19 @@
import { GatewayIntentBits } from "discord.js"; import { GatewayIntentBits } from "discord.js";
import config from "./config.js"; import config from "$config.js";
import Client from "./lib/client.js"; import Client from "$lib/client.js";
const client = new Client({ /**
intents: [ * Ready to go bot instance.
GatewayIntentBits.Guilds, *
GatewayIntentBits.GuildVoiceStates, * @remarks Quetza itself.
GatewayIntentBits.GuildMembers */
] const client = new Client({
}); intents: [
GatewayIntentBits.Guilds,
client.login(config.discordToken); GatewayIntentBits.GuildVoiceStates,
GatewayIntentBits.GuildMembers
]
});
client.login(config.application.token);
+132 -88
View File
@@ -1,88 +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 { readdirSync } from "fs";
import { existsSync, lstatSync, readdirSync } from "fs";
import path from "path"; import config from "$config.js";
import { pathToFileURL } from "url"; import { importDir, pathThrough } from "$lib/misc.js";
import {
import config from "../config.js"; ApplicationStatus,
import { Command, Event, Module, ModuleMetadata } from "./types.js"; Command,
CommandBase,
const MODULE_ENTRY = "module.js"; Event,
const MODULE_COMMANDS = "commands"; EventBase,
const MODULE_EVENTS = "events"; Module,
ModuleBase
export default class Client extends DiscordClient { } from "$lib/types.js";
public readonly commands = new Collection<string, Command>();
/**
public readonly modules = new Collection<string, ModuleMetadata>(); * Creating path to {@link Module | Module's} declaration file.
*
public readonly db = new PrismaClient(); * @internal
*/
public constructor(options: ClientOptions) { const toModuleDeclaration = pathThrough([config.path.modules], ["module.js"]);
super(options);
/**
for (const module of readdirSync(config.modulesDir)) { * Creating path to {@link Module | Module's} commands directory.
const modulePath = path.join(config.modulesDir, module); *
* @internal
if (!lstatSync(modulePath).isDirectory()) { */
continue; const toModuleCommands = pathThrough([config.path.modules], ["commands"]);
}
/**
import(pathToFileURL(path.join(modulePath, MODULE_ENTRY)).toString()).then( * Creating path to {@link Module | Module's} events directory.
(data: Module) => this.loadModule(data) *
); * @internal
} */
} const toModuleEvents = pathThrough([config.path.modules], ["events"]);
private loadModule(data: Module): void { /**
const commands: string[] = []; * Bot client enchanced with module handling and database access.
const events: string[] = []; *
* @typeparam T - True if client ready, false otherwise.
function importModule(dir: string, importThen: (data: Command & Event) => void) { *
if (existsSync(dir)) { * @public
for (const source of readdirSync(dir)) { */
const file = path.join(dir, source); export default class Client<T extends boolean = boolean> extends DiscordClient<T> {
/**
if (!lstatSync(file).isFile() || !file.endsWith(".js")) { * Mapping command name to its {@link Command | instance}.
continue; */
} public readonly commands = new Collection<string, Command>();
import(pathToFileURL(file).toString()).then(importThen); /**
} * Mapping event name to its {@link Event | instance}.
} */
} public readonly events = new Collection<string, Event>();
importModule(path.join(data.rootDir, MODULE_COMMANDS), (command: Command) => { /**
this.commands.set(command.data.name, command); * Mapping module name to its {@link Module | instance}.
commands.push(command.data.name); */
}); public readonly modules = new Collection<string, Module>();
importModule(path.join(data.rootDir, MODULE_EVENTS), (event: Event) => { /**
this.on(event.name, (...eventee: unknown[]) => event.execute(this, eventee)); * Constructs Quetza Client.
events.push(event.name); *
}); * @param options - Discord.js client options
*/
this.modules.set(data.name, { public constructor(options: ClientOptions) {
...data, super(options);
commands,
events readdirSync(config.path.modules).forEach((module) =>
}); this.importModule(
} toModuleDeclaration(module),
toModuleCommands(module),
public generateApplicationStatus(): string[] { toModuleEvents(module)
const modulesStatus = this.modules.map((module) => { )
const tag = `✓ ${module.name}@${module.tag} module is loaded.`; );
const commands = module.commands.map((name) => " - " + name).join("\n"); }
const events = module.events.map((name) => " - " + name).join("\n");
/**
return [`${tag}\n`, "commands:", `${commands}`, "events:", `${events}`].join("\n"); * Generates client's status by providing Bot User and Loaded Modules info.
}); *
* @returns Status of application.
const clientStatus = * Additional info such as Application ID provided if {@link Client} is ready.
this.user && this.application */
? `✓ ${this.application.id} successfully logged in as ${this.user.tag}.` public generateApplicationStatus(): ApplicationStatus {
: "✖ Application is not availiable."; const modules = Array.from(this.modules.values());
return modulesStatus.concat(clientStatus); if (!this.isReady()) {
} return {
} applicationId: null,
tag: null,
modules
};
}
return {
applicationId: this.application.id,
tag: this.user.tag,
modules
};
}
/**
* Imports module metadata, commands and events to the client.
*
* @param declaration - File declaring module information.
* @param commands - Directory containing module's commands.
* @param events - Directory containing module's events.
*/
private async importModule(
declaration: string,
commands: string,
events: string
): Promise<void> {
const module = await import(declaration).then((module: ModuleBase) =>
this.modules.ensure(module.name, () => ({ ...module, commands: [], events: [] }))
);
importDir<CommandBase>(commands, (command) => {
const saved = this.commands.ensure(command.data.name, () => ({ ...command, module }));
module.commands.push(saved);
});
importDir<EventBase>(events, (event) => {
const saved = this.events.ensure(event.name, () => ({ ...event, module }));
module.events.push(saved);
this.on(event.name, (...eventee: unknown[]) =>
saved.execute(this, eventee, module.controller)
);
});
}
}
+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();
}
}
+33 -20
View File
@@ -1,20 +1,33 @@
import winston from "winston"; import path from "path";
import winston from "winston";
const logger = winston.createLogger({
level: "info", import config from "$config.js";
format: winston.format.combine(winston.format.timestamp(), winston.format.json()),
transports: [ /** Error logging file path.
new winston.transports.File({ filename: "logs/error.log", level: "error" }), *
new winston.transports.File({ filename: "logs/combined.log" }) * @internal
] */
}); const errorLog = path.join(config.path.log, "error.log");
if (process.env.NODE_ENV !== "production") { /** Combined logging file path.
logger.add( *
new winston.transports.Console({ * @internal
format: winston.format.simple() */
}) const combinedLog = path.join(config.path.log, "combined.log");
);
} /**
* Logger instance. Use it for logging.
export default logger; *
* @public
*/
const logger = winston.createLogger({
levels: winston.config.syslog.levels,
format: winston.format.combine(winston.format.timestamp(), winston.format.json()),
transports: [
new winston.transports.Console({ format: winston.format.simple() }),
new winston.transports.File({ filename: errorLog, level: "error" }),
new winston.transports.File({ filename: combinedLog })
]
});
export default logger;
+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);
}
+103 -27
View File
@@ -1,27 +1,103 @@
import { ApplicationCommandData, CommandInteraction } from "discord.js"; /**
* Quetza's base type declarations.
import Client from "./client.js"; *
* @see Architecture documentation for more understanding of its purpose.
export interface Module { */
name: string; import { ApplicationCommandData, CommandInteraction, If } from "discord.js";
description?: string;
author: string; import Client from "$lib/client.js";
tag: string;
controller?: unknown; /**
rootDir: string; * Quetza's module declaration.
} *
* @public
export interface ModuleMetadata extends Module { */
commands: string[]; export interface ModuleBase {
events: string[]; /** Name of a module. */
} name: string;
export interface Command { /** Description of a module. */
data: ApplicationCommandData; description: string;
execute: (client: Client, interaction: CommandInteraction) => Promise<void>;
} /** Module controller. */
controller?: unknown;
export interface Event { }
name: string;
execute: (client: Client, eventee: unknown[]) => Promise<void>; /**
} * Complete {@link ModuleBase} declaration with commands and events.
*
* @public
*/
export interface Module extends ModuleBase {
/** Module's commands. */
commands: CommandBase[];
/** Module's events. */
events: EventBase[];
}
/**
* Quetza's command interaction declaration.
*
* @public
*/
export interface CommandBase {
/** Command metadata that will be passed to the Discord API. */
data: ApplicationCommandData;
/** Function that will be executed upon invoking this interaction. */
execute: (
client: Client,
interaction: CommandInteraction,
controller?: unknown
) => Promise<void>;
}
/**
* {@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;
/** 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[];
}
-21
View File
@@ -1,21 +0,0 @@
import { Interaction, SlashCommandBuilder } from "discord.js";
import Client from "../../../lib/client.js";
import logger from "../../../lib/logger.js";
import replies from "../lib/replies.js";
async function execute(client: Client, interaction: Interaction): Promise<void> {
if (!interaction.isChatInputCommand()) {
logger.warn("Interaction rejected.", { interaction });
return;
}
await interaction.reply(replies.modules(client.modules));
}
const data = new SlashCommandBuilder()
.setName("modules")
.setDescription("List of availiable modules.");
export { data, execute };
@@ -1,22 +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;
logger.info("Interaction created.", interaction);
if (interaction.isCommand()) {
const command = client.commands.get(interaction.commandName);
if (command) {
command.execute(client, 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.0";
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 };
-102
View File
@@ -1,102 +0,0 @@
import { execFile } from "child_process";
import path from "path";
import { promisify } from "util";
import config from "../../../config.js";
import logger from "../../../lib/logger.js";
const execFileAsync = promisify(execFile);
const SEARCH_DEPTH = 5;
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
);
}
interface YtdlDump {
id: string;
original_url: string;
title?: string;
uploader?: string;
duration?: number;
extractor_key: string;
thumbnail?: string;
}
interface YtdlResult {
url: string;
title: string;
thumbnail: string;
duration: number;
}
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 [];
}
}
-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);
}
}
-261
View File
@@ -1,261 +0,0 @@
import { User } from "discord.js";
export interface Track {
url: string;
title: string;
thumbnail: string;
duration: number;
requester: User;
}
export type LoopOption = "LOOP" | "SONG" | "NONE" | "AUTO";
export interface YtdlArgs {
help?: boolean;
version?: boolean;
update?: boolean;
ignoreErrors?: boolean;
abortOnError?: boolean;
dumpUserAgent?: boolean;
listExtractors?: boolean;
extractorDescriptions?: boolean;
forceGenericExtractor?: boolean;
defaultSearch?: string;
igonreConfig?: boolean;
configLocation?: string;
flatPlaylist?: boolean;
markWatched?: boolean;
noColor?: boolean;
proxy?: string;
socketTimeout?: number;
sourceAddress?: string;
forceIpv4?: boolean;
forceIpv6?: boolean;
geoVerificationProxy?: string;
geoBypass?: boolean;
geoBypassCountry?: string;
geoBypassIpBlock?: string;
playlistStart?: number;
playlistEnd?: number | "last";
playlistItems?: string;
matchTitle?: string;
rejectTitle?: string;
maxDownloads?: number;
minFilesize?: string;
maxFilesize?: string;
date?: string;
datebefore?: string;
dateafter?: string;
minViews?: number;
maxViews?: number;
matchFilter?: string;
noPlaylist?: boolean;
yesPlaylist?: boolean;
ageLimit?: number;
downloadArchive?: string;
includeAds?: boolean;
limitRate?: string;
retries?: number | "infinite";
skipUnavailableFragments?: boolean;
abortOnUnavailableFragment?: boolean;
keepFragments?: boolean;
bufferSize?: string;
noResizeBuffer?: boolean;
httpChunkSize?: string;
playlistReverse?: boolean;
playlistRandom?: boolean;
xattrSetFilesize?: boolean;
hlsPreferNative?: boolean;
hlsPreferFfmpeg?: boolean;
hlsUseMpegts?: boolean;
externalDownloader?: string;
externalDownloaderArgs?: string;
batchFile?: string;
id?: boolean;
output?: string;
outputNaPlaceholder?: string;
autonumberStart?: number;
restrictFilenames?: boolean;
noOverwrites?: boolean;
continue?: boolean;
noPart?: boolean;
noMtime?: boolean;
writeDescription?: boolean;
writeInfoJson?: boolean;
writeAnnotations?: boolean;
loadInfoJson?: string;
cookies?: string;
cacheDir?: string;
noCacheDir?: boolean;
rmCacheDir?: boolean;
writeThumbnail?: boolean;
writeAllThumbnails?: boolean;
listThumbnails?: boolean;
quiet?: boolean;
noWarnings?: boolean;
simulate?: boolean;
skipDownload?: boolean;
getUrl?: boolean;
getTitle?: boolean;
getId?: boolean;
getThumbnail?: boolean;
getDuration?: boolean;
getFilename?: boolean;
getFormat?: boolean;
dumpJson?: boolean;
dumpSingleJson?: boolean;
printJson?: boolean;
newline?: boolean;
noProgress?: boolean;
consoleTitle?: boolean;
verbose?: boolean;
dumpPages?: boolean;
writePages?: boolean;
printTraffic?: boolean;
callHome?: boolean;
encoding?: string;
noCheckCertificate?: boolean;
preferInsecure?: boolean;
userAgent?: string;
referer?: string;
addHeader?: string;
bidiWorkaround?: boolean;
sleepInterval?: number;
maxSleepInterval?: number;
format?: string;
allFormats?: boolean;
preferFreeFormats?: boolean;
listFormats?: boolean;
youtubeSkipDashManifest?: boolean;
mergeOutputFormat?: string;
writeSub?: boolean;
writeAutoSub?: boolean;
allSubs?: boolean;
listSubs?: boolean;
subFormat?: string;
subLang?: string;
username?: string;
password?: string;
twofactor?: string;
netrc?: boolean;
videoPassword?: string;
apMso?: string;
apUsername?: string;
apPassword?: string;
apListMso?: boolean;
extractAudio?: boolean;
audioFormat?: string;
audioQuality?: number;
recodeVideo?: string;
postprocessorArgs?: string;
keepVideo?: boolean;
noPostOverwrites?: boolean;
embedSubs?: boolean;
embedThumbnail?: boolean;
addMetadata?: boolean;
metadataFromFile?: string;
xattrs?: boolean;
fixup?: string;
preferAvconv?: boolean;
preferFfmpeg?: boolean;
ffmpegLocation?: string;
exec?: string;
convertSubs?: string;
}
interface YtdlFormat {
asr: number;
filesize: number;
format_id: string;
format_note: string;
fps: number;
height: number;
quality: number;
tbr: number;
url: string;
width: number;
ext: string;
vcodec: string;
acodec: string;
abr: number;
downloader_options: unknown;
container: string;
format: string;
protocol: string;
http_headers: unknown;
}
interface YtdlThumbnail {
height: number;
url: string;
width: number;
resolution: string;
id: string;
}
interface YtdlEntry {
uploader: string;
duration: number;
id: string;
title: string;
url: string;
}
export interface YtdlResponse {
id: string;
title: string;
formats: YtdlFormat[];
thumbnails: YtdlThumbnail[];
description: string;
upload_date: string;
uploader: string;
uploader_id: string;
uploader_url: string;
channel_id: string;
channel_url: string;
duration: number;
view_count: number;
average_rating: number;
age_limit: number;
webpage_url: string;
categories: string[];
tags: string[];
is_live: boolean;
like_count: number;
dislike_count: number;
channel: string;
track: string;
artist: string;
creator: string;
alt_title: string;
extractor: string;
webpage_url_basename: string;
extractor_key: string;
playlist: string;
playlist_index: number;
thumbnail: string;
display_id: string;
requested_subtitles: unknown;
asr: number;
filesize: number;
format_id: string;
format_note: string;
fps: number;
height: number;
quality: number;
tbr: number;
url: string;
width: number;
ext: string;
vcodec: string;
acodec: string;
abr: number;
downloader_options: { http_chunk_size: number };
container: string;
format: string;
protocol: string;
http_headers: unknown;
fulltitle: string;
_filename: string;
entries: YtdlEntry[];
}
-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 };
-90
View File
@@ -1,90 +0,0 @@
import { Events, Interaction, PermissionFlagsBits, SlashCommandBuilder } from "discord.js";
import Client from "../../../lib/client.js";
import logger from "../../../lib/logger.js";
import replies from "../lib/replies.js";
async function execute(client: Client, interaction: Interaction): Promise<void> {
if (!interaction.isChatInputCommand() || !interaction.inCachedGuild()) {
logger.warn("Interaction rejected.", { interaction });
return;
}
const subcommand = interaction.options.getSubcommand();
const event = interaction.options.getString("event", true);
switch (subcommand) {
case "role": {
const role = interaction.options.getRole("role", true);
await interaction.deferReply();
try {
await client.db.event.upsert({
where: {
eventIdentifier: {
title: event,
action: subcommand,
guildId: interaction.guildId
}
},
update: { metadata: [role.id] },
create: {
Guild: {
connectOrCreate: {
where: {
id: interaction.guildId
},
create: {
id: interaction.guildId
}
}
},
title: event,
action: subcommand,
metadata: [role.id]
}
});
await interaction.editReply(replies.auto("add role to a member", event));
} catch (error) {
logger.error("Error occured while quering db.", { interaction });
} finally {
await client.db.$disconnect();
}
return;
}
}
}
const AUTO_EVENT_CHOICES = [
{
name: "Member Joins",
value: Events.GuildMemberAdd
}
];
const data = new SlashCommandBuilder()
.setName("auto")
.setDescription("Quetza's automatization engine.")
.addSubcommand((subcommand) =>
subcommand
.setName("role")
.setDescription("Auto: give role.")
.addStringOption((option) =>
option
.setName("event")
.setDescription("Event when perfome an action.")
.setChoices(...AUTO_EVENT_CHOICES)
.setRequired(true)
)
.addRoleOption((option) =>
option.setName("role").setDescription("Role to grant on event.").setRequired(true)
)
)
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator)
.setDMPermission(false);
export { data, execute };
-98
View File
@@ -1,98 +0,0 @@
import { Interaction, PermissionFlagsBits, SlashCommandBuilder } from "discord.js";
import Client from "../../../lib/client.js";
import logger from "../../../lib/logger.js";
import replies from "../lib/replies.js";
async function execute(client: Client, interaction: Interaction): Promise<void> {
if (!interaction.isChatInputCommand() || !interaction.inCachedGuild()) {
logger.warn("Interaction rejected.", { interaction });
return;
}
const subcommand = interaction.options.getSubcommand();
switch (subcommand) {
case "check": {
const user = interaction.options.getUser("member", true);
const member = await interaction.guild.members.fetch(user).catch(() => undefined);
if (member) {
await interaction.reply(replies.roleList(user.tag, member.roles.cache));
} else {
await interaction.reply(replies.notMember("edited", user));
}
return;
}
case "edit": {
const user = interaction.options.getUser("member", true);
const role = interaction.options.getRole("role", true);
const reason = interaction.options.getString("reason") ?? undefined;
const member = await interaction.guild.members.fetch(user).catch(() => undefined);
if (member) {
if (member.roles.cache.some((memberRole) => role.id === memberRole.id)) {
await member.roles.remove(role.id, reason);
await interaction.reply(
replies.roleAction("revoked from", interaction.user, role.name, reason)
);
} else {
await member.roles.add(role.id, reason);
await interaction.reply(
replies.roleAction("granted to", interaction.user, role.name, reason)
);
}
} else {
await interaction.reply(replies.notMember("edited", user));
}
return;
}
case "list": {
const roles = await interaction.guild.roles.fetch();
await interaction.reply(replies.roleList(interaction.guild.name, roles));
return;
}
}
}
const data = new SlashCommandBuilder()
.setName("role")
.setDescription("Quetza's role manager.")
.addSubcommand((subcommand) =>
subcommand.setName("list").setDescription("Role: list guild's roles.")
)
.addSubcommand((subcommand) =>
subcommand
.setName("check")
.setDescription("Role: check user roles.")
.addUserOption((option) =>
option.setName("member").setDescription("Member to check.").setRequired(true)
)
)
.addSubcommand((subcommand) =>
subcommand
.setName("edit")
.setDescription("Role: edit role for a specific user.")
.addUserOption((option) =>
option.setName("member").setDescription("Member to edit.").setRequired(true)
)
.addRoleOption((option) =>
option.setName("role").setDescription("Member's role to edit.").setRequired(true)
)
.addStringOption((option) =>
option.setName("reason").setDescription("Reason for an action.")
)
)
.setDefaultMemberPermissions(PermissionFlagsBits.ManageRoles)
.setDMPermission(false);
export { data, execute };
@@ -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.tag} cannot be ${action}`)
.setDescription("They're not a guild member.")
.setTimestamp();
return { embeds: [embed], ephemeral: true };
},
roleAction: (
action: string,
user: User,
roleName: string,
reason?: string
): InteractionReplyOptions => {
const embed = new EmbedBuilder()
.setColor(config.colors.success)
.setTitle(`Role ${roleName} was ${action} user ${user.tag}`)
.setDescription(reason ?? "No reason provided.")
.setTimestamp();
return { embeds: [embed], ephemeral: true };
},
roleList: (observed: string, roles: Collection<string, Role>): InteractionReplyOptions => {
const embed = new EmbedBuilder()
.setColor(config.colors.success)
.setTitle(`${observed} roles:`)
.addFields(
roles.map((role) => {
return {
name: role.name,
value: italic(role.id)
};
})
)
.setTimestamp();
return { embeds: [embed], ephemeral: true };
},
auto: (action: string, event: string): BaseMessageOptions => {
const embed = new EmbedBuilder()
.setColor(config.colors.success)
.setTitle(
bold(
`From now I will ${underscore(italic(action))} when ${underscore(
italic(event)
)} event happens`
)
)
.setTimestamp();
return { embeds: [embed] };
}
};
export default replies;
-11
View File
@@ -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" }]
}
+21 -19
View File
@@ -1,19 +1,21 @@
{ {
"compilerOptions": { "compilerOptions": {
"strict": true, "strict": true,
"module": "ESNext", "module": "NodeNext",
"moduleResolution": "node", "moduleResolution": "NodeNext",
"outDir": "dist", "resolveJsonModule": true,
"removeComments": false, "types": ["node"],
"sourceMap": true,
"esModuleInterop": true, "outDir": "./dist",
"sourceMap": true,
"target": "ESNext",
"lib": ["ESNext"], "esModuleInterop": true,
"types": ["node"], "forceConsistentCasingInFileNames": true,
"skipLibCheck": true
}, "target": "ES2022",
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "src/commands", "src/events"] "skipLibCheck": true
} },
"references": [{ "path": "./modules/core" }, { "path": "./modules/music" }]
}