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

No files matched your search

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

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Executable
+9
View File
@@ -0,0 +1,9 @@
#!/bin/sh
# Build typescript
pnpm exec tsc --build
# Map path aliases from tsconfigs
for TS_CONFIG in ./config.tsconfig.json ./src/tsconfig.json ./modules/*/tsconfig.json; do
pnpm exec tsc-alias -p "$TS_CONFIG"
done
+58
View File
@@ -0,0 +1,58 @@
import { ActivityOptions, ActivityType, ColorResolvable } from "discord.js";
import { dirname, join } from "path";
import { fileURLToPath } from "url";
/** Quetza project root directory. */
const rootDir = dirname(fileURLToPath(import.meta.url));
/** Quetza's modules directory. */
const modulesDir = join(rootDir, "/modules/");
/** External binaries directory. */
const binariesDir = join(rootDir, "/bin/");
/** Logger files directory. */
const loggerDir = join(rootDir, "/log/");
/** Locations that might be useful in application. */
const path = {
root: rootDir,
modules: modulesDir,
binaries: binariesDir,
log: loggerDir
};
/** Discord bot token. */
const appToken = process.env.DISCORD_TOKEN;
/** Discord bot user activity status */
const appActivity = { type: ActivityType.Watching, name: "over you" };
/** Bot application configuration variables. */
const application = {
token: appToken,
activity: appActivity as ActivityOptions
};
/** Colors Quetza uses in responses. */
const colors = {
default: "#ff6e9b" as ColorResolvable,
error: "#b94e59" as ColorResolvable,
warning: "#e5925f" as ColorResolvable,
info: "#367df6" as ColorResolvable,
success: "#83d18e" as ColorResolvable
};
/** Guild where testing occures. */
const testGuild = "912401672939139142";
/** Developing options. */
const dev = {
guild: testGuild
};
/** Exported bundled configuration object. */
const config = {
application,
colors,
path,
dev
};
export default config;
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "./dist",
"composite": true
},
"include": ["config.ts"]
}
+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 };
+31
View File
@@ -0,0 +1,31 @@
/**
* /ping
*
* Replies websocket ping (ping between bot and discord) and real ping (ping to fetch + process).
*
* @remarks Probably should double their value as two ways, but nvm.
*
* @packageDocumentation
*/
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
import Client from "$lib/client.js";
import replies from "../lib/replies.js";
async function execute(client: Client, interaction: ChatInputCommandInteraction): Promise<void> {
if (!interaction.isChatInputCommand()) {
return;
}
const reply = await interaction.reply({ ...replies.ping(client.ws.ping), fetchReply: true });
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 };
+34
View File
@@ -0,0 +1,34 @@
import { BaseMessageOptions, Collection, EmbedBuilder, italic } from "discord.js";
import config from "$config.js";
import { Module } from "$lib/types.js";
const replies = {
modules: (modules: Collection<string, Module>): BaseMessageOptions => {
const embed = new EmbedBuilder()
.setColor(config.colors.default)
.setTitle("Avaliable modules")
.addFields(
modules.map((value) => ({
name: value.name,
value: italic(value.description ?? "")
}))
);
return { embeds: [embed] };
},
ping: (websocket: number, roundtrip?: number): BaseMessageOptions => {
const title = roundtrip ? `I've pinged ${roundtrip} ms. late` : "Pinging...";
const comment = `Websocket ping is ${websocket} ms.`;
const embed = new EmbedBuilder()
.setColor(config.colors.default)
.setTitle(title)
.setDescription(italic(comment));
return { embeds: [embed] };
}
};
export default replies;
+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/" }]
}
+36
View File
@@ -0,0 +1,36 @@
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
import Client from "$lib/client.js";
import logger from "$lib/logger.js";
import replies from "../lib/replies.js";
import { controller } from "../module.js";
async function execute(client: Client, interaction: ChatInputCommandInteraction): Promise<void> {
if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) {
logger.warn("Interaction rejected.", { interaction });
return;
}
const player = controller.get(interaction.guild, interaction.channel);
if (!player) {
await interaction.reply(replies.notExists());
return;
}
const amount = player.queue.tracks.length;
player.clear();
await interaction.reply(replies.cleared(amount));
}
const data = new SlashCommandBuilder()
.setName("clear")
.setDescription("Clear the player's queue.")
.setDMPermission(false);
export { data, execute };
+44
View File
@@ -0,0 +1,44 @@
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
import Client from "$lib/client.js";
import logger from "$lib/logger.js";
import replies from "../lib/replies.js";
import { controller } from "../module.js";
async function execute(client: Client, interaction: ChatInputCommandInteraction): Promise<void> {
if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) {
logger.warn("Interaction rejected.", { interaction });
return;
}
await interaction.deferReply();
const player =
controller.get(interaction.guild, interaction.channel) ??
controller.set(interaction.guild, interaction.channel);
const channel = interaction.member.voice.channel;
if (!channel) {
controller.delete(interaction.guildId);
await interaction.editReply(replies.notConnected());
return;
}
player.connect(channel);
await interaction.editReply(replies.okConnected(channel.name));
return;
}
const data = new SlashCommandBuilder()
.setName("connect")
.setDescription("Connect me explicitly to the voice channel.")
.setDMPermission(false);
export { data, execute };
+43
View File
@@ -0,0 +1,43 @@
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
import Client from "$lib/client.js";
import logger from "$lib/logger.js";
import Filter from "../lib/filter.js";
import replies from "../lib/replies.js";
import { controller } from "../module.js";
async function execute(client: Client, interaction: ChatInputCommandInteraction) {
if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) {
logger.warn("Interaction rejected.", { interaction });
return;
}
const filter = interaction.options.getString("filter") ?? undefined;
const player = controller.get(interaction.guild, interaction.channel);
if (!player) {
await interaction.reply(replies.notExists());
return;
}
const status = player.setFilter(filter);
await interaction.reply(replies.filtered(filter, status));
}
const data = new SlashCommandBuilder()
.setName("filter")
.setDescription("Apply unique filters to the playback.")
.addStringOption((option) =>
option
.setName("filter")
.setDescription("Filter to apply.")
.setChoices(...Object.values(Filter.FILTERS))
)
.setDMPermission(false);
export { data, execute };
+32
View File
@@ -0,0 +1,32 @@
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
import Client from "$lib/client.js";
import logger from "$lib/logger.js";
import replies from "../lib/replies.js";
import { controller } from "../module.js";
async function execute(client: Client, interaction: ChatInputCommandInteraction) {
if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) {
logger.warn("Interaction rejected.", { interaction });
return;
}
const player = controller.get(interaction.guild, interaction.channel);
if (!player) {
await interaction.reply(replies.notExists());
return;
}
await interaction.reply(replies.info(player));
}
const data = new SlashCommandBuilder()
.setName("info")
.setDescription("Verbose information about player and stats.")
.setDMPermission(false);
export { data, execute };
+45
View File
@@ -0,0 +1,45 @@
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
import Client from "$lib/client.js";
import logger from "$lib/logger.js";
import replies from "../lib/replies.js";
import { controller } from "../module.js";
async function execute(client: Client, interaction: ChatInputCommandInteraction) {
if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) {
logger.warn("Interaction rejected.", { interaction });
return;
}
const query = interaction.options.getString("query", true);
const pos = interaction.options.getInteger("position", true);
await interaction.deferReply();
const player = controller.get(interaction.guild, interaction.channel);
if (!player) {
await interaction.editReply(replies.notExists());
return;
}
const track = await player.add(query, interaction.user, pos - 1);
await interaction.editReply(replies.appended(track));
}
const data = new SlashCommandBuilder()
.setName("insert")
.setDescription("Insert a new track to specific position in the queue.")
.addStringOption((option) =>
option.setName("query").setDescription("Title or URL of the song.").setRequired(true)
)
.addIntegerOption((option) =>
option.setName("position").setDescription("Position to insert.").setRequired(true)
)
.setDMPermission(false);
export { data, execute };
+44
View File
@@ -0,0 +1,44 @@
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
import Client from "$lib/client.js";
import logger from "$lib/logger.js";
import replies from "../lib/replies.js";
import { controller } from "../module.js";
async function execute(client: Client, interaction: ChatInputCommandInteraction) {
if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) {
logger.warn("Interaction rejected.", { interaction });
return;
}
const query = interaction.options.getString("query", true);
const player = controller.get(interaction.guild, interaction.channel);
if (!player) {
await interaction.reply(replies.notExists());
return;
}
const previous = player.resource?.metadata;
const position =
!isNaN(+query) && isFinite(+query) && !/e/i.test(query)
? player.jump(parseInt(query) - 1)
: player.jump(query);
await interaction.reply(replies.jumped(previous, position));
}
const data = new SlashCommandBuilder()
.setName("jump")
.setDescription("Jump to specific track in the queue.")
.addStringOption((option) =>
option.setName("query").setDescription("Position or title to jump to.").setRequired(true)
)
.setDMPermission(false);
export { data, execute };
+49
View File
@@ -0,0 +1,49 @@
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
import Client from "$lib/client.js";
import logger from "$lib/logger.js";
import replies from "../lib/replies.js";
import { LoopOption } from "../lib/types.js";
import { controller } from "../module.js";
async function execute(client: Client, interaction: ChatInputCommandInteraction) {
if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) {
logger.warn("Interaction rejected.", { interaction });
return;
}
const option = interaction.options.getString("option", true) as LoopOption;
const player = controller.get(interaction.guild, interaction.channel);
if (!player) {
await interaction.reply(replies.notExists());
return;
}
player.queue.loop = option;
await interaction.reply(replies.looped(option));
}
const data = new SlashCommandBuilder()
.setName("loop")
.setDescription("Select player's loop mode.")
.addStringOption((option) =>
option
.setName("option")
.setDescription("Loop option.")
.setChoices(
{ name: "Loop queue", value: "LOOP" },
{ name: "Loop single track", value: "SONG" },
{ name: "Drop on final", value: "NONE" },
{ name: "Random", value: "AUTO" }
)
.setRequired(true)
)
.setDMPermission(false);
export { data, execute };
+34
View File
@@ -0,0 +1,34 @@
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
import Client from "$lib/client.js";
import logger from "$lib/logger.js";
import replies from "../lib/replies.js";
import { controller } from "../module.js";
async function execute(client: Client, interaction: ChatInputCommandInteraction) {
if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) {
logger.warn("Interaction rejected.", { interaction });
return;
}
const player = controller.get(interaction.guild, interaction.channel);
if (!player) {
await interaction.reply(replies.notExists());
return;
}
player.skip();
await interaction.reply(replies.skipped());
}
const data = new SlashCommandBuilder()
.setName("next")
.setDescription("Skip current song.")
.setDMPermission(false);
export { data, execute };
+34
View File
@@ -0,0 +1,34 @@
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
import Client from "$lib/client.js";
import logger from "$lib/logger.js";
import replies from "../lib/replies.js";
import { controller } from "../module.js";
async function execute(client: Client, interaction: ChatInputCommandInteraction) {
if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) {
logger.warn("Interaction rejected.", { interaction });
return;
}
const player = controller.get(interaction.guild, interaction.channel);
if (!player) {
await interaction.reply(replies.notExists());
return;
}
const state = player.pause();
await interaction.reply(replies.paused(state));
}
const data = new SlashCommandBuilder()
.setName("pause")
.setDescription("Toggle pause on playback.")
.setDMPermission(false);
export { data, execute };
+59
View File
@@ -0,0 +1,59 @@
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
import Client from "$lib/client.js";
import logger from "$lib/logger.js";
import replies from "../lib/replies.js";
import { controller } from "../module.js";
async function execute(client: Client, interaction: ChatInputCommandInteraction) {
if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) {
logger.warn("Interaction rejected.", { interaction });
return;
}
const query = interaction.options.getString("query");
if (query) {
await interaction.reply(replies.searching());
} else {
await interaction.deferReply();
}
const player =
controller.get(interaction.guild, interaction.channel) ??
controller.set(interaction.guild, interaction.channel);
const channel = interaction.member.voice.channel;
if (!channel) {
controller.delete(interaction.guildId);
await interaction.editReply(replies.notConnected());
return;
}
player.connect(channel);
if (query) {
const track = await player.add(query, interaction.user);
await interaction.editReply(replies.appended(track));
}
if (!player.resource) {
await player.play();
}
}
const data = new SlashCommandBuilder()
.setName("play")
.setDescription("Launch player and add songs to the queue.")
.addStringOption((option) =>
option.setName("query").setDescription("Title or URL of the song.")
)
.setDMPermission(false);
export { data, execute };
+87
View File
@@ -0,0 +1,87 @@
import {
ActionRowBuilder,
ButtonBuilder,
ButtonInteraction,
ButtonStyle,
ChatInputCommandInteraction,
MessageActionRowComponentBuilder,
SlashCommandBuilder
} from "discord.js";
import Client from "$lib/client.js";
import logger from "$lib/logger.js";
import replies from "../lib/replies.js";
import { controller } from "../module.js";
async function execute(client: Client, interaction: ChatInputCommandInteraction) {
if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) {
logger.warn("Interaction rejected.", { interaction });
return;
}
await interaction.deferReply();
const player = controller.get(interaction.guild, interaction.channel);
if (!player) {
await interaction.editReply(replies.notExists());
return;
}
if (player.queue.empty()) {
await interaction.editReply(replies.queue(player.queue));
return;
}
let position = 1;
const row = new ActionRowBuilder<MessageActionRowComponentBuilder>().addComponents(
new ButtonBuilder().setCustomId("Top").setLabel("🔼").setStyle(ButtonStyle.Primary),
new ButtonBuilder().setCustomId("Up").setLabel("🔺").setStyle(ButtonStyle.Secondary),
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),
components: [row]
});
const collector = message.createMessageComponentCollector({ time: 30000 });
collector.on("collect", async (btn: ButtonInteraction) => {
switch (btn.customId) {
case "Top": {
position = 1;
break;
}
case "Up": {
position = Math.max(position - 10, 1);
break;
}
case "Down": {
position = Math.min(
position + 10,
Math.floor(player.queue.tracks.length / 10) * 10
);
break;
}
case "End": {
position = Math.floor(player.queue.tracks.length / 10) * 10;
}
}
await btn.update(replies.queue(player.queue, position - 1, player.resource));
});
}
const data = new SlashCommandBuilder()
.setName("queue")
.setDescription("Print interactive queue pointing to current track.")
.setDMPermission(false);
export { data, execute };
+42
View File
@@ -0,0 +1,42 @@
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
import Client from "$lib/client.js";
import logger from "$lib/logger.js";
import replies from "../lib/replies.js";
import { controller } from "../module.js";
async function execute(client: Client, interaction: ChatInputCommandInteraction) {
if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) {
logger.warn("Interaction rejected.", { interaction });
return;
}
const query = interaction.options.getString("query", true);
const player = controller.get(interaction.guild, interaction.channel);
if (!player) {
await interaction.reply(replies.notExists());
return;
}
const track =
!isNaN(+query) && isFinite(+query) && !/e/i.test(query)
? player.remove(parseInt(query) - 1)
: player.remove(query);
await interaction.reply(replies.removed(track));
}
const data = new SlashCommandBuilder()
.setName("remove")
.setDescription("Remove specific track in the queue.")
.addStringOption((option) =>
option.setName("query").setDescription("Position or title to remove.").setRequired(true)
)
.setDMPermission(false);
export { data, execute };
+34
View File
@@ -0,0 +1,34 @@
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
import Client from "$lib/client.js";
import logger from "$lib/logger.js";
import replies from "../lib/replies.js";
import { controller } from "../module.js";
async function execute(client: Client, interaction: ChatInputCommandInteraction) {
if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) {
logger.warn("Interaction rejected.", { interaction });
return;
}
const player = controller.get(interaction.guild, interaction.channel);
if (!player) {
await interaction.reply(replies.notExists());
return;
}
player.shuffle();
await interaction.reply(replies.reshuffle());
}
const data = new SlashCommandBuilder()
.setName("reshuffle")
.setDescription("Reshuffle the queue. (Will change queue order)")
.setDMPermission(false);
export { data, execute };
+51
View File
@@ -0,0 +1,51 @@
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
import Client from "$lib/client.js";
import logger from "$lib/logger.js";
import replies from "../lib/replies.js";
import { controller } from "../module.js";
async function execute(client: Client, interaction: ChatInputCommandInteraction) {
if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) {
logger.warn("Interaction rejected.", { interaction });
return;
}
const hours = interaction.options.getInteger("hrs") ?? 0;
const minutes = interaction.options.getInteger("mins") ?? 0;
const seconds = interaction.options.getInteger("secs") ?? 0;
const player = controller.get(interaction.guild, interaction.channel);
if (!player) {
await interaction.reply(replies.notExists());
return;
}
if (!player.resource) {
await interaction.reply(replies.notPlaying());
return;
}
const prevTime = player.resource.playbackDuration / 1000;
const nextTime = hours * 3600 + minutes * 60 + seconds;
player.seek(nextTime * 1000);
await interaction.reply(replies.seeked(prevTime, nextTime));
}
const data = new SlashCommandBuilder()
.setName("seek")
.setDescription("Fast-forward/Fast-reverse by the given time.")
.addIntegerOption((option) => option.setName("hrs").setDescription("Hours to seek."))
.addIntegerOption((option) => option.setName("mins").setDescription("Minutes to seek."))
.addIntegerOption((option) => option.setName("secs").setDescription("Seconds to seek."))
.setDMPermission(false);
export { data, execute };
+34
View File
@@ -0,0 +1,34 @@
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
import Client from "$lib/client.js";
import logger from "$lib/logger.js";
import replies from "../lib/replies.js";
import { controller } from "../module.js";
async function execute(client: Client, interaction: ChatInputCommandInteraction) {
if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) {
logger.warn("Interaction rejected.", { interaction });
return;
}
const player = controller.get(interaction.guild, interaction.channel);
if (!player) {
await interaction.reply(replies.notExists());
return;
}
player.destroy();
await interaction.reply(replies.destroyed());
}
const data = new SlashCommandBuilder()
.setName("stop")
.setDescription("Destroy the player.")
.setDMPermission(false);
export { data, execute };
+39
View File
@@ -0,0 +1,39 @@
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
import Client from "$lib/client.js";
import logger from "$lib/logger.js";
import replies from "../lib/replies.js";
import { controller } from "../module.js";
async function execute(client: Client, interaction: ChatInputCommandInteraction) {
if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) {
logger.warn("Interaction rejected.", { interaction });
return;
}
const volume = interaction.options.getInteger("volume") ?? 100;
const player = controller.get(interaction.guild, interaction.channel);
if (!player) {
await interaction.reply(replies.notExists());
return;
}
player.volume = volume;
await interaction.reply(replies.volumeSet(player.volume));
}
const data = new SlashCommandBuilder()
.setName("volume")
.setDescription("Change player's volume amount.")
.addIntegerOption((option) =>
option.setName("volume").setDescription("Volume in percents.").setRequired(true)
)
.setDMPermission(false);
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(",");
}
}
+132
View File
@@ -0,0 +1,132 @@
import { AudioResource } from "@discordjs/voice";
import { LoopOption, Track } from "./types.js";
/**
* Length of playback status bar in emoji symbols.
*
* @intenal
*/
const STATUSBAR_LENGTH = 15;
/**
* Generates pretty playback status bar out of {@link AudioResource}.
*
* @remarks Takes duration and current playback duration to 'draw' bar.
*
* @returns String representing generated status bar.
*
* @param resource - AudioResource with {@link Track} metadata, so duration and playback duration is available.
*
* @privateRemarks I don't know why resource can be undefined - I forgor.
*
* @public
*/
export function statusBarGenerator(resource?: AudioResource<Track>): string {
const duration = resource?.metadata.duration || 1;
const playback = Math.min((resource?.playbackDuration || 0) / 1000, duration);
const filled = Array<string>(Math.floor((STATUSBAR_LENGTH * playback) / duration)).fill("🔹");
const empty = Array<string>(STATUSBAR_LENGTH - filled.length).fill("▫️");
return `[${filled.join("")}${empty.join("")}] ${toISOTime(playback)}/${toISOTime(duration)}`;
}
/**
* Converts seconds to ISO time string.
*
* @returns String representing given seconds in ISO format.
*
* @param amount - Seconds to convert.
*
* @example
* ```
* toISOTime(1337228); // "371:27:08"
* ```
*
* @public
*/
export function toISOTime(amount: number): string {
const h = Math.floor(amount / 3600);
const m = Math.floor((amount % 3600) / 60);
const s = Math.floor((amount % 3600) % 60);
const hDisplay = h > 0 ? `${h}:` : "";
const mDisplay = `${Math.floor(m / 10)}${Math.floor(m % 10)}:`;
const sDisplay = `${Math.floor(s / 10)}${Math.floor(s % 10)}`;
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);
}
}
+446
View File
@@ -0,0 +1,446 @@
import {
AudioPlayer,
AudioPlayerStatus,
AudioResource,
createAudioPlayer,
createAudioResource,
DiscordGatewayAdapterCreator,
joinVoiceChannel,
VoiceConnection
} from "@discordjs/voice";
import { Guild, GuildTextBasedChannel, Message, User, VoiceBasedChannel } from "discord.js";
import { clamp } from "$lib/misc.js";
import { dlpInfo, dlpStream } from "./fetch/fetch.js";
import Filter from "./filter.js";
import { largestCommonSequence } from "./misc.js";
import Music from "./music.js";
import Queue from "./queue.js";
import replies from "./replies.js";
import { Track } from "./types.js";
/**
* Defines guild music player connected to a voice channel by wrapping AudioPlayer, Queue, VoiceConnection, etc.
*
* @public
*/
export default class Player {
/**
* Volume limiter clamp.
*/
public static readonly VOLUME_CLAMP = clamp(0, 150);
/**
* Module controller orchistrate guild-player mapping.
*/
private music_: Music;
/**
* {@link Player} owner guild.
*
* @remarks No different {@link Player} instances can have same {@link Guild}.
*/
private guild_: Guild;
/**
* {@link Guild | Guild's} text based channel for player notifications,
* such as Current Playing Track.
*/
private channel_: GuildTextBasedChannel;
public get channel(): GuildTextBasedChannel {
return this.channel_;
}
public set channel(x: GuildTextBasedChannel) {
this.channel_ = x;
}
/**
* Previous notification message.
*/
private message_?: Message;
/**
* A connection to the voice channel of a {@link Guild}.
*
* @readonly
*/
private connection_?: VoiceConnection;
public get connection(): VoiceConnection | undefined {
return this.connection_;
}
/**
* Player for {@link AudioResource} that is subscribed to the most recent {@link VoiceConnection}.
*
* @readonly
*/
private player_: AudioPlayer;
public get player(): AudioPlayer {
return this.player_;
}
/**
* Represents an Audio Resource that can be played by {@link AudioPlayer}.
*
* @readonly
*/
private resource_: AudioResource<Track> | undefined;
public get resource(): AudioResource<Track> | undefined {
return this.resource_;
}
/**
* Inline volume for an {@link AudioResource}.
* Must be clamped by {@link VOLUME_CLAMP}.
*/
private volume_ = 100;
public get volume(): number {
return this.volume_;
}
public set volume(x: number) {
this.volume_ = Player.VOLUME_CLAMP(x);
if (this.resource_?.volume) {
this.resource_.volume.setVolumeLogarithmic(this.volume_ / 100);
}
}
/**
* Queue manager of the {@link Player}.
*/
public readonly queue = new Queue();
/**
* Filter manager of the {@link Player}.
*/
public readonly filter = new Filter();
/**
* Timestamp when {@link Player} was created.
*/
public readonly createdAt: Date = new Date();
/**
* Player constructor.
*
* @param guild - {@link Guild} for which player will be created for
* @param music - Module controller for managing player own existence
* @param channel - Initial text based channed for player notifications
*/
public constructor(guild: Guild, music: Music, channel: GuildTextBasedChannel) {
this.music_ = music;
this.guild_ = guild;
this.channel_ = channel;
this.player_ = createAudioPlayer();
this.player_.on(AudioPlayerStatus.Idle, async () => this.resourceEndResolvable());
}
/**
* Creates {@link AudioResource | an Audio Resource} for {@link an AudioPlayer}.
*
* @returns Configured audio resource ready to be played
*
* @param track - Metadata to generate resource from and embed into it
* @param seek - Amount of seconds to start playback from
*/
private async resourceGenerator(track: Track, seek?: number): Promise<AudioResource<Track>> {
const probe = await dlpStream(track.url, {
seek: seek ? seek / 1000 : undefined,
ffmpeg: !this.filter.empty() ? ["-af", this.filter.toString()] : undefined
});
this.resource_ = createAudioResource(probe, {
inlineVolume: true,
metadata: track
});
if (seek) {
this.resource_.playbackDuration = seek;
}
if (this.resource_.volume) {
this.resource_.volume.setVolumeLogarithmic(this.volume_ / 100);
}
return this.resource_;
}
/**
* Resolves player behavior after {@link AudioResource} ends.
*
* @remarks Force destroys ended resource,
* and either plays next one or stops,
* depending on {@link Queue#next | Queue.next() method}
*/
private resourceEndResolvable(): void {
if (this.resource_) {
this.resource_.encoder?.destroy();
}
if (this.queue.next()) {
this.play();
} else {
this.player_.stop(true);
this.resource_ = undefined;
}
}
/**
* Resolves notification emittion.
*
* @remarks Tries to delete previous notification message,
* and send next one
*/
private async messageResolvable(track: Track) {
if (this.message_?.deletable) {
await this.message_.delete().catch();
}
this.message_ = await this.channel_.send(replies.nowPlaying(track));
}
/**
* Fetches {@link DlpInfo} and converts it to {@link Track | Track metadata}.
*
* @returns Promise to Track list or undefined, if found nothing.
*
* @param query - String that Dlp will try to resolve
* @param requester - {@link User} that requested Tracks returned to complete convertion
*/
private async search(query: string, requester: User): Promise<Track[] | undefined> {
const info = await dlpInfo(query);
return info.map((value) => {
return { ...value, requester: requester };
});
}
/**
* Plays current {@link Queue} track from position specified.
*
* @param seek - Amount of seconds to start playback from
*/
public async play(seek?: number): Promise<void> {
const track = this.queue.current();
if (!this.connection_ || !track) {
return;
}
this.player_.play(await this.resourceGenerator(track, seek));
this.connection_.subscribe(this.player_);
await this.messageResolvable(track);
}
/**
* Creates connection to guild's voice based channel,
* previously destroying existing connection.
*
* @param channel - {@link VoiceBasedChannel} to connect to
*/
public connect(channel: VoiceBasedChannel): void {
if (
this.connection_?.joinConfig.channelId === channel.id &&
this.connection_.joinConfig.guildId === channel.guildId
) {
return;
}
if (this.connection_) {
this.connection_.destroy();
}
this.connection_ = joinVoiceChannel({
channelId: channel.id,
guildId: channel.guildId,
adapterCreator: channel.guild.voiceAdapterCreator as DiscordGatewayAdapterCreator
});
this.connection_.subscribe(this.player_);
}
/**
* Adds {@link Track | Track(s)} found by {@link search | the search method} to the {@link queue}.
*
* @returns Track metadata if found, undefined otherwise
*
* @param query - String that will be passed to the search method
* @param requester - {@link User} that requested the query
* @param index - Position to insert track to
* Value < 0 resolves to 0
* Value >= (queue size) resolves to (queue end)
*/
public async add(query: string, requester: User, index?: number): Promise<Track | undefined> {
const tracks = await this.search(query, requester);
if (!tracks) {
return;
}
this.queue.push(tracks, index);
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;
}
}
+181
View File
@@ -0,0 +1,181 @@
import { LoopOption, Track } from "./types.js";
/**
* Class of {@link ./player#Player | Player's} queue.
*
* @public
*/
export default class Queue {
/**
* Array of {@link Track | Tracks} - content of queue.
*/
private tracks_: Track[] = [];
public get tracks(): Track[] {
return this.tracks_;
}
/**
* Position of current playing track.
*/
private position_ = 0;
public get position(): number {
return this.position_;
}
/**
* Defines behavior of queue.
*
* @see ./types.ts for more info about values.
*/
private loop_: LoopOption = "NONE";
public get loop(): LoopOption {
return this.loop_;
}
public set loop(x: LoopOption) {
this.loop_ = x;
}
/**
* Queue overall duration in seconds.
*/
private duration_ = 0;
public get duration(): number {
return this.duration_;
}
/**
* Skips current track.
*
* @remarks Behavior depends on loop option.
*
* @returns next {@link Track} or undefined if end was reached.
*/
public next(): Track | undefined {
if (this.loop_ === "SONG") {
return this.current();
}
if (this.loop_ === "AUTO") {
this.position_ = Math.floor(Math.random() * this.tracks_.length);
return this.current();
}
if (this.position_ + 1 < this.tracks_.length) {
this.position_++;
return this.current();
} else if (this.loop_ === "LOOP") {
this.position_ = 0;
return this.current();
} else if (this.loop_ === "NONE") {
this.position_ = 0;
return undefined;
}
}
/**
* Shuffles queue by using simple Math.random.
*/
public shuffle(): void {
let currentIndex = this.tracks_.length;
while (currentIndex != 0) {
const randomIndex = Math.floor(Math.random() * currentIndex);
currentIndex--;
[this.tracks_[currentIndex], this.tracks_[randomIndex]] = [
this.tracks_[randomIndex],
this.tracks_[currentIndex]
];
}
this.position_ = 0;
}
/**
* Inserts tracks from provided array at specific index.
*
* @returns first {@link Track} of inserted Tracks.
*
* @param tracks - List of Tracks to insert to the queue.
* @param index - Position at which insertion will be occurred.
*/
public push(tracks: Track[], index?: number): Track {
for (const track of tracks) {
this.duration_ += track.duration;
}
if (!index || index >= this.tracks_.length) {
this.tracks_ = this.tracks_.concat(tracks);
return this.tracks_[this.tracks_.length - tracks.length];
}
this.tracks_.splice(Math.max(0, index), 0, ...tracks);
if (this.position_ >= index) {
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;
}
}
+377
View File
@@ -0,0 +1,377 @@
import { AudioResource } from "@discordjs/voice";
import { BaseMessageOptions, EmbedBuilder, italic } from "discord.js";
import config from "$config.js";
import { loopMoji, statusBarGenerator, toISOTime, volumeMoji } from "./misc.js";
import Player from "./player.js";
import Queue from "./queue.js";
import { LoopOption, Track } from "./types.js";
const replies = {
notExists: (): BaseMessageOptions => {
const embed = new EmbedBuilder()
.setColor(config.colors.warning)
.setTitle("⚠️ Command ignored - player does not exist")
.setDescription("Use '/play' to create a new one.");
return { embeds: [embed] };
},
searching: (): BaseMessageOptions => {
const embed = new EmbedBuilder()
.setColor(config.colors.default)
.setTitle("🔍 Searching...")
.setDescription("It will take longer if playlist or non-youtube URL was queried.");
return { embeds: [embed] };
},
notPlaying: (): BaseMessageOptions => {
const embed = new EmbedBuilder()
.setColor(config.colors.default)
.setTitle("💿 Insert the disk...")
.setURL("https://www.youtube.com/watch?v=dQw4w9WgXcQ")
.setDescription("Use '/play' to start the playback.");
return { embeds: [embed] };
},
nowPlaying: (track: Track): BaseMessageOptions => {
const embed = new EmbedBuilder()
.setColor(config.colors.default)
.setAuthor({
iconURL: track.requester.avatarURL() ?? undefined,
name: track.requester.username
})
.setTitle(track.title)
.setURL(track.url)
.setDescription("playing now.")
.setThumbnail(track.thumbnail);
return { embeds: [embed] };
},
okConnected: (channel: string): BaseMessageOptions => {
const embed = new EmbedBuilder()
.setColor(config.colors.success)
.setTitle(`✅ Successfuly connected to: ${italic(channel)}`)
.setDescription("Will leave after some time, if left alone.");
return { embeds: [embed] };
},
notConnected: (): BaseMessageOptions => {
const embed = new EmbedBuilder()
.setColor(config.colors.error)
.setTitle("❗ Cannot create new player.")
.setDescription("Make yourself connected to a voice channel beforehand.");
return { embeds: [embed] };
},
destroyed: (): BaseMessageOptions => {
const embed = new EmbedBuilder()
.setColor(config.colors.error)
.setTitle("💀 Destroying the player")
.setDescription("Everything is lost now. Use '/play' to create a new one.");
return { embeds: [embed] };
},
alone: (): BaseMessageOptions => {
const embed = new EmbedBuilder()
.setColor(config.colors.default)
.setTitle("🥬 I have decided to leave to conserve my energy")
.setDescription("Since there was nobody in the voice channel.");
return { embeds: [embed] };
},
paused: (state: boolean): BaseMessageOptions => {
let embed: EmbedBuilder;
if (state) {
embed = new EmbedBuilder()
.setColor(config.colors.success)
.setTitle("▶️ The playback was resumed");
} else {
embed = new EmbedBuilder()
.setColor(config.colors.warning)
.setTitle("⏸️ The playback was paused")
.setDescription("Invoke this command again to resume.");
}
return { embeds: [embed] };
},
seeked: (from: number, to: number): BaseMessageOptions => {
let embed: EmbedBuilder;
if (from < to) {
embed = new EmbedBuilder()
.setColor(config.colors.info)
.setTitle(`⏩ Fast-forwarding by ${toISOTime(to - from)}`);
} else {
embed = new EmbedBuilder()
.setColor(config.colors.info)
.setTitle(`⏪ Fast-reversing by ${toISOTime(from - to)}`);
}
embed.setDescription(
`Moving playback from ${italic(toISOTime(from))} to ${italic(toISOTime(to))}.`
);
return { embeds: [embed] };
},
looped: (option: LoopOption) => {
let embed: EmbedBuilder;
switch (option) {
case "AUTO": {
embed = new EmbedBuilder()
.setColor(config.colors.default)
.setTitle(`${loopMoji(option)} Shuffle mode.`)
.setDescription("Does not affect the queue order. Endless playback.");
break;
}
case "LOOP": {
embed = new EmbedBuilder()
.setColor(config.colors.info)
.setTitle(`${loopMoji(option)} Looping over the queue.`)
.setDescription("Will start over after final track. Endless playback.");
break;
}
case "SONG": {
embed = new EmbedBuilder()
.setColor(config.colors.warning)
.setTitle(`${loopMoji(option)} Looping a single track.`)
.setDescription("Endless playback of a single track. Over and over again...");
break;
}
case "NONE": {
embed = new EmbedBuilder()
.setColor(config.colors.error)
.setTitle(`${loopMoji(option)} Drop on final.`)
.setDescription(
"Will end playback after final track. Action will be needed to continue."
);
break;
}
}
return { embeds: [embed] };
},
reshuffle: (): BaseMessageOptions => {
const embed = new EmbedBuilder()
.setColor(config.colors.success)
.setTitle("✅ Successfuly reshuffled queue")
.setDescription("Use 'random' mode, if you want shuffle without reshuffle.");
return { embeds: [embed] };
},
skipped: (): BaseMessageOptions => {
const embed = new EmbedBuilder()
.setColor(config.colors.info)
.setTitle("⏭️ Skipping current track")
.setDescription("Behavior depends on the loop option.");
return { embeds: [embed] };
},
jumped: (previous?: Track, to?: number): BaseMessageOptions => {
if (!to) {
const embed = new EmbedBuilder()
.setColor(config.colors.error)
.setTitle("❗ Cannot perfome jump over the queue")
.setDescription("Either not found by query, or index is not in range.");
return { embeds: [embed] };
}
const embed = new EmbedBuilder()
.setColor(config.colors.success)
.setTitle(`✅ Jumping over the queue`);
if (previous) {
embed.setDescription(`Switching from ${italic(previous.title)}.`);
} else {
embed.setDescription("Playback will start again.");
}
return { embeds: [embed] };
},
appended: (track?: Track): BaseMessageOptions => {
let embed: EmbedBuilder;
if (track) {
embed = new EmbedBuilder()
.setColor(config.colors.success)
.setAuthor({
iconURL: track.requester.avatarURL() ?? undefined,
name: track.requester.username
})
.setTitle(track.title)
.setURL(track.url)
.setDescription("has been added to the queue.")
.setThumbnail(track.thumbnail)
.setTimestamp();
} else {
embed = new EmbedBuilder()
.setColor(config.colors.error)
.setTitle("❗ Cannot add track to the queue")
.setDescription("Either not found by query, or URL is broken.");
}
return { embeds: [embed] };
},
removed: (track?: Track): BaseMessageOptions => {
let embed: EmbedBuilder;
if (track) {
embed = new EmbedBuilder()
.setColor(config.colors.success)
.setTitle(track.title)
.setURL(track.url)
.setDescription("was removed from the queue.")
.setThumbnail(track.thumbnail)
.setTimestamp();
} else {
embed = new EmbedBuilder()
.setColor(config.colors.error)
.setTitle("❗ Cannot delete track from queue")
.setDescription("Either not found by query, or index is not in range.");
}
return { embeds: [embed] };
},
cleared: (amount: number): BaseMessageOptions => {
const embed = new EmbedBuilder()
.setColor(config.colors.success)
.setTitle(`♻️ Queue has been cleared`)
.setDescription(
`${italic(amount.toString())} track${amount > 1 ? "s were" : " was"} deleted.`
);
return { embeds: [embed] };
},
filtered: (filter?: string, status = true): BaseMessageOptions => {
let embed: EmbedBuilder;
if (filter) {
embed = new EmbedBuilder().setTitle(
`✅ ${italic(filter)} was ${status ? "applied to the playback" : "declined"}.`
);
} else {
embed = new EmbedBuilder().setTitle("♻️ All filters were declined");
}
embed
.setColor(config.colors.success)
.setDescription("Playback can experience some lags during initial transformation.");
return { embeds: [embed] };
},
volumeSet: (amount: number): BaseMessageOptions => {
const embed = new EmbedBuilder()
.setColor(config.colors.success)
.setTitle(`${volumeMoji(amount)} Volume has been set to ${amount}%`)
.setDescription("This will affect everyone listening.");
return { embeds: [embed] };
},
queue: (queue: Queue, position = 0, resource?: AudioResource<Track>): BaseMessageOptions => {
if (queue.empty()) {
const embed = new EmbedBuilder()
.setColor(config.colors.warning)
.setTitle("⚠️ Queue is empty")
.setDescription("Use '/play' to add some tracks.");
return { embeds: [embed] };
}
let embed: EmbedBuilder;
if (resource) {
embed = new EmbedBuilder()
.setTitle(`${queue.position + 1}. ${resource.metadata.title}`)
.setAuthor({
iconURL: resource.metadata.requester.avatarURL() ?? undefined,
name: resource.metadata.requester.username
})
.setURL(resource.metadata.url)
.setDescription(statusBarGenerator(resource))
.setThumbnail(resource.metadata.thumbnail);
} else {
embed = new EmbedBuilder()
.setTitle("💿 Insert the disk...")
.setURL("https://www.youtube.com/watch?v=5mGuCdlCcNM&ab_channel=Ra%C3%BAlBlanco")
.setDescription(statusBarGenerator())
.setThumbnail("https://img.youtube.com/vi/5mGuCdlCcNM/default.jpg");
}
embed.setColor(config.colors.info).setFields(
queue.tracks.slice(position, position + 10).map((value, index) => ({
name: `${position + index + 1}. ${value.title.padEnd(35)}`,
value: `[${toISOTime(value.duration)}] | ${value.requester.username}`
}))
);
return { embeds: [embed] };
},
info: (player: Player): BaseMessageOptions => {
let embed: EmbedBuilder;
if (player.resource) {
embed = new EmbedBuilder()
.setTitle(`${player.queue.position + 1}. ${player.resource.metadata.title}`)
.setAuthor({
iconURL: player.resource.metadata.requester.avatarURL() ?? undefined,
name: player.resource.metadata.requester.username
})
.setURL(player.resource.metadata.url)
.setDescription(statusBarGenerator(player.resource))
.setThumbnail(player.resource.metadata.thumbnail);
} else {
embed = new EmbedBuilder()
.setTitle("💿 Insert the disk...")
.setURL("https://www.youtube.com/watch?v=dQw4w9WgXcQ")
.setDescription(statusBarGenerator());
}
embed.setColor(config.colors.info).addFields([
{
name: "Applied Filters",
value:
player.filter
.toggled()
.map((value) => value.name)
.join("\n") + "\ndefault",
inline: true
},
{
name: "Queue Length",
value: `🔢 ${player.queue.tracks.length}`,
inline: true
},
{
name: "Queue Duration",
value: `💿 ${toISOTime(player.queue.duration)}`,
inline: true
},
{
name: "Queue Looping",
value: `${loopMoji(player.queue.loop)} ${player.queue.loop}`,
inline: true
},
{
name: "Volume Amount",
value: `${volumeMoji(player.volume)} ${player.volume}/150`,
inline: true
},
{
name: "Player Lifetime",
value: `🕓 ${toISOTime((Date.now() - player.createdAt.getTime()) / 1000)}`,
inline: true
}
]);
return { embeds: [embed] };
}
};
export default replies;
+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/" }]
}
-3369
View File
File diff suppressed because it is too large. Load diff
+39 -35
View File
@@ -1,50 +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", "type": "module",
"version": "2303.1", "main": "./dist/src/index.js",
"description": "Discord multifunctional bot", "scripts": {
"author": { "start": "node ./dist/src/index.js",
"name": "unknowableshade", "build": "./build.sh",
"email": "60275123+unknowableshade@users.noreply.github.com", "clean": "rm -rf ./dist",
"url": "https://unknowableshade.ru" "lint": "pnpm exec eslint . && pnpm exec prettier --check .",
"format": "pnpm exec eslint --fix . && pnpm exec prettier --write .",
"preinstall": "pnpm dlx only-allow pnpm"
}, },
"license": "MIT",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "git+https://github.com/unknowableshade/quetza-bot.git" "url": "git+https://github.com/unknowableshade/Quetza.git"
},
"author": {
"name": "Vladimir Eremin",
"email": "personal@ermnvldmr.com",
"url": "https://ermnvldmr.com"
}, },
"bugs": { "bugs": {
"url": "https://github.com/unknowableshade/quetza-bot/issues" "url": "https://github.com/unknowableshade/Quetza/issues"
}, },
"readme": "https://github.com/unknowableshade/quetza-bot#readme", "readme": "https://github.com/unknowableshade/Quetza#readme",
"license": "MIT", "engines": {
"main": "dist/index.js", "node": "20.x.x",
"scripts": { "npm": "*"
"start": "node dist/index.js",
"build": "npx tsc --build",
"clean": "npx tsc --build --clean",
"lint": "npx eslint --fix . && npx prettier --write .",
"postinstall": "npm run build"
}, },
"dependencies": { "dependencies": {
"@discordjs/opus": "^0.8.0", "@discordjs/opus": "^0.9.0",
"@discordjs/voice": "^0.11.0", "@discordjs/voice": "^0.16.1",
"discord-ytdl-core": "^5.0.4", "discord.js": "^14.14.1",
"discord.js": "^14.3.0", "prism-media": "^1.3.5",
"tweetnacl": "^1.0.3", "tweetnacl": "^1.0.3",
"ytdl-core": "^4.11.1" "winston": "^3.11.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^18.7.13", "@types/node": "^20.11.24",
"@typescript-eslint/eslint-plugin": "^5.35.1", "@typescript-eslint/eslint-plugin": "^7.1.0",
"@typescript-eslint/parser": "^5.35.1", "@typescript-eslint/parser": "^7.1.0",
"eslint": "^8.22.0", "eslint": "^8.57.0",
"eslint-config-prettier": "^8.6.0", "eslint-config-prettier": "^9.1.0",
"eslint-plugin-import": "^2.27.5", "eslint-plugin-import": "^2.29.1",
"eslint-plugin-simple-import-sort": "^10.0.0", "eslint-plugin-simple-import-sort": "^12.0.0",
"typescript": "^4.8.2" "mocha": "^10.3.0",
"prettier": "^3.2.5",
"tsc-alias": "^1.8.8",
"typescript": "^5.3.3"
} }
} }
+3749
View File
File diff suppressed because it is too large. Load diff
-12
View File
@@ -1,12 +0,0 @@
import path from "path";
import { fileURLToPath } from "url";
const ROOT = path.dirname(fileURLToPath(import.meta.url));
const config = {
discordToken: process.env.DISCORD_TOKEN,
modulesDir: path.join(ROOT, "/modules/"),
binariesDir: path.join(ROOT, "/../bin/")
};
export default config;
+13 -4
View File
@@ -1,10 +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";
/**
* Ready to go bot instance.
*
* @remarks Quetza itself.
*/
const client = new Client({ const client = new Client({
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildVoiceStates] intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildVoiceStates,
GatewayIntentBits.GuildMembers
]
}); });
client.login(config.discordToken); client.login(config.application.token);
+110 -63
View File
@@ -1,85 +1,132 @@
import { Client as DiscordClient, ClientOptions, Collection } from "discord.js"; import { Client as DiscordClient, ClientOptions, Collection } from "discord.js";
import { existsSync, lstatSync, readdirSync } from "fs"; import { readdirSync } from "fs";
import path from "path";
import { pathToFileURL } from "url";
import config from "../config.js"; import config from "$config.js";
import { Command, Event, Module, ModuleMetadata } from "./types.js"; import { importDir, pathThrough } from "$lib/misc.js";
import {
ApplicationStatus,
Command,
CommandBase,
Event,
EventBase,
Module,
ModuleBase
} from "$lib/types.js";
const MODULE_ENTRY = "module.js"; /**
const MODULE_COMMANDS = "commands"; * Creating path to {@link Module | Module's} declaration file.
const MODULE_EVENTS = "events"; *
* @internal
*/
const toModuleDeclaration = pathThrough([config.path.modules], ["module.js"]);
export default class Client extends DiscordClient { /**
* Creating path to {@link Module | Module's} commands directory.
*
* @internal
*/
const toModuleCommands = pathThrough([config.path.modules], ["commands"]);
/**
* Creating path to {@link Module | Module's} events directory.
*
* @internal
*/
const toModuleEvents = pathThrough([config.path.modules], ["events"]);
/**
* Bot client enchanced with module handling and database access.
*
* @typeparam T - True if client ready, false otherwise.
*
* @public
*/
export default class Client<T extends boolean = boolean> extends DiscordClient<T> {
/**
* Mapping command name to its {@link Command | instance}.
*/
public readonly commands = new Collection<string, Command>(); public readonly commands = new Collection<string, Command>();
public readonly modules = new Collection<string, ModuleMetadata>(); /**
* Mapping event name to its {@link Event | instance}.
*/
public readonly events = new Collection<string, Event>();
/**
* Mapping module name to its {@link Module | instance}.
*/
public readonly modules = new Collection<string, Module>();
/**
* Constructs Quetza Client.
*
* @param options - Discord.js client options
*/
public constructor(options: ClientOptions) { public constructor(options: ClientOptions) {
super(options); super(options);
for (const module of readdirSync(config.modulesDir)) { readdirSync(config.path.modules).forEach((module) =>
const modulePath = path.join(config.modulesDir, module); this.importModule(
toModuleDeclaration(module),
if (!lstatSync(modulePath).isDirectory()) { toModuleCommands(module),
continue; toModuleEvents(module)
} )
import(pathToFileURL(path.join(modulePath, MODULE_ENTRY)).toString()).then(
(data: Module) => this.loadModule(data)
); );
} }
/**
* Generates client's status by providing Bot User and Loaded Modules info.
*
* @returns Status of application.
* Additional info such as Application ID provided if {@link Client} is ready.
*/
public generateApplicationStatus(): ApplicationStatus {
const modules = Array.from(this.modules.values());
if (!this.isReady()) {
return {
applicationId: null,
tag: null,
modules
};
} }
private loadModule(data: Module): void { return {
const commands: string[] = []; applicationId: this.application.id,
const events: string[] = []; tag: this.user.tag,
modules
function importModule(dir: string, importThen: (data: Command & Event) => void) { };
if (existsSync(dir)) {
for (const source of readdirSync(dir)) {
const file = path.join(dir, source);
if (!lstatSync(file).isFile() || !file.endsWith(".js")) {
continue;
} }
import(pathToFileURL(file).toString()).then(importThen); /**
} * 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: [] }))
);
importModule(path.join(data.rootDir, MODULE_COMMANDS), (command: Command) => { importDir<CommandBase>(commands, (command) => {
this.commands.set(command.data.name, command) const saved = this.commands.ensure(command.data.name, () => ({ ...command, module }));
commands.push(command.data.name);
module.commands.push(saved);
}); });
importModule(path.join(data.rootDir, MODULE_EVENTS), (event: Event) => { importDir<EventBase>(events, (event) => {
this.on(event.name, (...eventee: unknown[]) => event.execute(this, eventee)); const saved = this.events.ensure(event.name, () => ({ ...event, module }));
events.push(event.name);
module.events.push(saved);
this.on(event.name, (...eventee: unknown[]) =>
saved.execute(this, eventee, module.controller)
);
}); });
this.modules.set(data.name, {
...data,
commands,
events
})
}
public generateApplicationStatus(): string {
const modulesStatus = this.modules.map((module) => {
const tag = `✓ ${module.name}@${module.tag} module is loaded.`
const commands = module.commands.map((name) => " - " + name).join("\n");
const events = module.events.map((name) => " - " + name).join("\n");
return [`${tag}\n`, "commands:", `${commands}`, "events:", `${events}`].join("\n");
}).join("\n" + "-".repeat(50) + "\n");
const clientStatus =
this.user && this.application
? `✓ ${this.application.id} successfully logged in as ${this.user.tag}.`
: "✖ Application is not availiable.";
return ["-".repeat(50), modulesStatus, "-".repeat(50), clientStatus].join("\n");
} }
} }
+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
View File
@@ -0,0 +1,33 @@
import path from "path";
import winston from "winston";
import config from "$config.js";
/** Error logging file path.
*
* @internal
*/
const errorLog = path.join(config.path.log, "error.log");
/** Combined logging file path.
*
* @internal
*/
const combinedLog = path.join(config.path.log, "combined.log");
/**
* Logger instance. Use it for logging.
*
* @public
*/
const logger = winston.createLogger({
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);
}
+90 -14
View File
@@ -1,27 +1,103 @@
import { ApplicationCommandData, CommandInteraction } from "discord.js"; /**
* Quetza's base type declarations.
*
* @see Architecture documentation for more understanding of its purpose.
*/
import { ApplicationCommandData, CommandInteraction, If } from "discord.js";
import Client from "./client.js"; import Client from "$lib/client.js";
export interface Module { /**
* Quetza's module declaration.
*
* @public
*/
export interface ModuleBase {
/** Name of a module. */
name: string; name: string;
description?: string;
author: string; /** Description of a module. */
tag: string; description: string;
/** Module controller. */
controller?: unknown; controller?: unknown;
rootDir: string;
} }
export interface ModuleMetadata extends Module { /**
commands: string[]; * Complete {@link ModuleBase} declaration with commands and events.
events: string[]; *
* @public
*/
export interface Module extends ModuleBase {
/** Module's commands. */
commands: CommandBase[];
/** Module's events. */
events: EventBase[];
} }
export interface Command { /**
* Quetza's command interaction declaration.
*
* @public
*/
export interface CommandBase {
/** Command metadata that will be passed to the Discord API. */
data: ApplicationCommandData; data: ApplicationCommandData;
execute: (client: Client, interaction: CommandInteraction) => Promise<void>;
/** Function that will be executed upon invoking this interaction. */
execute: (
client: Client,
interaction: CommandInteraction,
controller?: unknown
) => Promise<void>;
} }
export interface Event { /**
* {@link ModuleBase} with {@link Module} included.
*
* @public
*/
export interface Command extends CommandBase {
/** Module containing this Command. */
module: Module;
}
/**
* Quetza's event declaration.
*
* @public
*/
export interface EventBase {
/** Name of an event to listen. */
name: string; name: string;
execute: (client: Client, eventee: unknown[]) => Promise<void>;
/** Function that will be executed upon event emitting. */
execute: (client: Client, eventee: unknown[], controller?: unknown) => Promise<void>;
}
/**
* {@link EventBase} with {@link Module} included.
*
* @public
*/
export interface Event extends EventBase {
/** Module containing this Event. */
module: ModuleBase;
}
/**
* Application status provides information that may be useful at initialization.
*
* @public
*/
export interface ApplicationStatus<Ready extends boolean = boolean> {
/** Discord's ID of an application. */
applicationId: If<Ready, string>;
/** Tag of a bot user. */
tag: If<Ready, string>;
/** List of each module imported. */
modules: Module[];
} }
-14
View File
@@ -1,14 +0,0 @@
import { CommandInteraction, SlashCommandBuilder } from "discord.js";
import Client from "../../../lib/client.js";
import I18n from "../lib/i18n.js";
async function execute(client: Client, interaction: CommandInteraction): Promise<void> {
await interaction.reply(I18n.replies.modules(client.modules));
}
const data = new SlashCommandBuilder()
.setName("modules")
.setDescription("List of availiable modules.");
export { data, execute };
-21
View File
@@ -1,21 +0,0 @@
import { CommandInteraction, SlashCommandBuilder } from "discord.js";
import Client from "../../../lib/client.js";
import I18n from "../lib/i18n.js";
async function execute(client: Client, interaction: CommandInteraction): Promise<void> {
const start = Date.now();
if (!client.application) {
await interaction.reply(I18n.replies.ping());
return;
}
await client.application.fetch();
await interaction.reply(I18n.replies.ping(Date.now() - start));
}
const data = new SlashCommandBuilder().setName("ping").setDescription("Try to ping me.");
export { data, execute };
@@ -1,19 +0,0 @@
import { Interaction } from "discord.js";
import Client from "../../../lib/client.js";
async function execute(client: Client, eventee: Interaction[]): Promise<void> {
const [interaction] = eventee;
if (interaction.isCommand()) {
const command = client.commands.get(interaction.commandName);
if (command) {
command.execute(client, interaction);
}
}
}
const name = "interactionCreate";
export { execute, name };
-23
View File
@@ -1,23 +0,0 @@
import { generateDependencyReport } from "@discordjs/voice";
import { ActivityType } from "discord.js";
import Client from "../../../lib/client.js";
async function execute(client: Client): Promise<void> {
if (!client.user || !client.application) {
throw new Error("No application or user for it is provided.");
}
console.log(generateDependencyReport());
console.log(client.generateApplicationStatus());
client.user.setActivity("over you", { type: ActivityType.Watching });
const restCommands = Array.from(client.commands.values()).map((value) => value.data);
client.application.commands.set(restCommands);
}
const name = "ready";
export { execute, name };
-41
View File
@@ -1,41 +0,0 @@
import { bold, Collection, EmbedBuilder, InteractionReplyOptions, italic } from "discord.js";
import { ModuleMetadata } from "../../../lib/types.js";
export default class I18n {
public static readonly replies = {
modules: (modules: Collection<string, ModuleMetadata>): InteractionReplyOptions => {
const embed = new EmbedBuilder()
.setColor("Random")
.setTitle("Modules")
.addFields(
modules.map((value) => {
return {
name: value.name + "@" + value.tag,
value: (value.description ?? "") + " | " + value.author,
inline: true
};
})
);
return { embeds: [embed] };
},
ping: (ping?: number): InteractionReplyOptions => {
const title = ping ? `I have pinged ${ping} ms late.` : "There is no application.";
const comment = !ping
? "How am I speaking then?"
: ping > 250
? "I'm kinda slowpoke ngl."
: ping > 200
? "I can do better than this."
: "Dream's speed!";
const embed = new EmbedBuilder()
.setColor("Random")
.setTitle(bold(title))
.setDescription(italic(bold(comment)));
return { embeds: [embed] };
}
};
}
-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 = "2302.1";
const rootDir = path.dirname(fileURLToPath(import.meta.url));
export { author, description, name, rootDir, tag };
-27
View File
@@ -1,27 +0,0 @@
import { CommandInteraction, SlashCommandBuilder, TextChannel } from "discord.js";
import Client from "../../../lib/client.js";
import I18n from "../lib/i18n.js";
import { controller } from "../module.js";
async function execute(client: Client, interaction: CommandInteraction): Promise<void> {
if (!interaction.guild || !interaction.channel) {
return;
}
const player = controller.get(interaction.guild.id, interaction.channel as TextChannel);
if (!player) {
return;
}
const amount = player.queue.tracks.length;
player.clear();
await interaction.reply({ embeds: [I18n.embeds.cleared(amount)] });
}
const data = new SlashCommandBuilder().setName("clear").setDescription("Clear the player's queue.");
export { data, execute };
-41
View File
@@ -1,41 +0,0 @@
import { CommandInteraction, GuildMember, SlashCommandBuilder, TextChannel } from "discord.js";
import Client from "../../../lib/client.js";
import I18n from "../lib/i18n.js";
import { controller } from "../module.js";
async function execute(client: Client, interaction: CommandInteraction) {
if (!interaction.guild || !interaction.channel || !interaction.member) {
return;
}
await interaction.deferReply();
const player =
controller.get(interaction.guild.id, interaction.channel as TextChannel) ||
controller.set(interaction.guild, interaction.channel as TextChannel);
if (!player.connection) {
const channel = (interaction.member as GuildMember).voice.channel;
if (channel) {
player.connect(channel);
await interaction.editReply({ embeds: [I18n.embeds.okConnected(channel.name)] });
} else {
controller.delete(interaction.guild.id);
await interaction.editReply({ embeds: [I18n.embeds.notConnected()] });
return undefined;
}
} else {
await interaction.editReply({ embeds: [I18n.embeds.wasConnected()] });
}
return player;
}
const data = new SlashCommandBuilder()
.setName("connect")
.setDescription("Connect me to a voice channel.");
export { data, execute };
-129
View File
@@ -1,129 +0,0 @@
import { CommandInteraction, SlashCommandBuilder, TextChannel } from "discord.js";
import Client from "../../../lib/client.js";
import I18n from "../lib/i18n.js";
import { controller } from "../module.js";
async function execute(client: Client, interaction: CommandInteraction) {
const filter = interaction.options.get("filter")?.value as string;
if (!interaction.guild || !interaction.channel) {
return;
}
const player = controller.get(interaction.guild.id, interaction.channel as TextChannel);
if (!player) {
return;
}
player.setFilter(filter || undefined);
await interaction.reply({ embeds: [I18n.embeds.filtered(filter || undefined)] });
}
const data = new SlashCommandBuilder()
.setName("filter")
.setDescription("Apply unique filters to the tracks.")
.addStringOption((option) =>
option
.setName("filter")
.setDescription("Filter to apply.")
.setChoices(
{
name: "Bassboost low",
value: "bassboost_low"
},
{
name: "Bassboost",
value: "bassboost"
},
{
name: "Bassboost high",
value: "bassboost_high"
},
{
name: "8D",
value: "8D"
},
{
name: "Vaporwave",
value: "vaporwave"
},
{
name: "Nightcore",
value: "nightcore"
},
{
name: "Phaser",
value: "phaser"
},
{
name: "Tremolo",
value: "tremolo"
},
{
name: "Vibrato",
value: "vibrato"
},
{
name: "Reverse",
value: "reverse"
},
{
name: "Treble",
value: "treble"
},
{
name: "Normalizer",
value: "normalizer"
},
{
name: "Surround Sound",
value: "surrounding"
},
{
name: "Pulsator",
value: "pulsator"
},
{
name: "Subboost",
value: "subboost"
},
{
name: "Mono Audio",
value: "mono"
},
{
name: "Compressor",
value: "compressor"
},
{
name: "Expander",
value: "expander"
},
{
name: "Softlimiter",
value: "softlimiter"
},
{
name: "Chorus",
value: "chorus"
},
{
name: "Fadein",
value: "fadein"
},
{
name: "Dimming",
value: "dim"
},
{
name: "Earrape",
value: "earrape"
}
)
.setRequired(true)
);
export { data, execute };
-23
View File
@@ -1,23 +0,0 @@
import { CommandInteraction, SlashCommandBuilder, TextChannel } from "discord.js";
import Client from "../../../lib/client.js";
import I18n from "../lib/i18n.js";
import { controller } from "../module.js";
async function execute(client: Client, interaction: CommandInteraction) {
if (!interaction.guild || !interaction.channel || !interaction.member) {
return;
}
await interaction.deferReply();
const player = controller.get(interaction.guild.id, interaction.channel as TextChannel);
await interaction.editReply({ embeds: [I18n.embeds.playerInfo(player)] });
}
const data = new SlashCommandBuilder()
.setName("info")
.setDescription("Information about the playback.");
export { data, execute };
-38
View File
@@ -1,38 +0,0 @@
import { CommandInteraction, SlashCommandBuilder, TextChannel } from "discord.js";
import Client from "../../../lib/client.js";
import I18n from "../lib/i18n.js";
import { controller } from "../module.js";
async function execute(client: Client, interaction: CommandInteraction) {
const pos = interaction.options.get("position")?.value as number;
const query = interaction.options.get("query")?.value as string;
if (!interaction.guild || !query || !pos || !interaction.channel) {
return;
}
const player = controller.get(interaction.guild.id, interaction.channel as TextChannel);
if (!player) {
return;
}
await interaction.deferReply();
const track = await player.add(query, interaction.user, pos - 1);
await interaction.editReply({ embeds: [I18n.embeds.appended(track)] });
}
const data = new SlashCommandBuilder()
.setName("insert")
.setDescription("Insert a new track to specific position in the queue.")
.addStringOption((option) =>
option.setName("query").setDescription("Title or URL of the song.").setRequired(true)
)
.addIntegerOption((option) =>
option.setName("position").setDescription("Position to insert.").setRequired(true)
);
export { data, execute };
-35
View File
@@ -1,35 +0,0 @@
import { CommandInteraction, SlashCommandBuilder, TextChannel } from "discord.js";
import Client from "../../../lib/client.js";
import I18n from "../lib/i18n.js";
import { controller } from "../module.js";
async function execute(client: Client, interaction: CommandInteraction) {
const query = interaction.options.get("query")?.value as string;
if (!interaction.guild || !query || !interaction.channel) {
return;
}
const player = controller.get(interaction.guild.id, interaction.channel as TextChannel);
if (!player) {
return;
}
const state =
!isNaN(+query) && isFinite(+query) && !/e/i.test(query)
? player.jump(parseInt(query) - 1)
: player.jump(query);
await interaction.reply({ embeds: [I18n.embeds.jumped(state)] });
}
const data = new SlashCommandBuilder()
.setName("jump")
.setDescription("Jump to specific track in the queue.")
.addStringOption((option) =>
option.setName("query").setDescription("Position or title to jump to.").setRequired(true)
);
export { data, execute };
-42
View File
@@ -1,42 +0,0 @@
import { CommandInteraction, SlashCommandBuilder, TextChannel } from "discord.js";
import Client from "../../../lib/client.js";
import I18n from "../lib/i18n.js";
import { LoopOption } from "../lib/types.js";
import { controller } from "../module.js";
async function execute(client: Client, interaction: CommandInteraction) {
const query = interaction.options.get("option")?.value as string;
if (!interaction.guild || !query || !interaction.channel) {
return;
}
const player = controller.get(interaction.guild.id, interaction.channel as TextChannel);
if (!player) {
return;
}
player.queue.loop = query as LoopOption;
await interaction.reply({ embeds: [I18n.embeds.looped(query as LoopOption)] });
}
const data = new SlashCommandBuilder()
.setName("loop")
.setDescription("Select player's loop mode.")
.addStringOption((option) =>
option
.setName("option")
.setDescription("Select loop option.")
.setChoices(
{ name: "loop queue", value: "LOOP" },
{ name: "loop single track", value: "SONG" },
{ name: "Till end", value: "NONE" },
{ name: "Random order", value: "AUTO" }
)
.setRequired(true)
);
export { data, execute };
-25
View File
@@ -1,25 +0,0 @@
import { CommandInteraction, SlashCommandBuilder, TextChannel } from "discord.js";
import Client from "../../../lib/client.js";
import I18n from "../lib/i18n.js";
import { controller } from "../module.js";
async function execute(client: Client, interaction: CommandInteraction) {
if (!interaction.guild || !interaction.channel) {
return;
}
const player = controller.get(interaction.guild.id, interaction.channel as TextChannel);
if (!player) {
return;
}
player.skip();
await interaction.reply({ embeds: [I18n.embeds.skipped()] });
}
const data = new SlashCommandBuilder().setName("next").setDescription("Skip current song.");
export { data, execute };
-25
View File
@@ -1,25 +0,0 @@
import { CommandInteraction, SlashCommandBuilder, TextChannel } from "discord.js";
import Client from "../../../lib/client.js";
import I18n from "../lib/i18n.js";
import { controller } from "../module.js";
async function execute(client: Client, interaction: CommandInteraction) {
if (!interaction.guild || !interaction.channel) {
return;
}
const player = controller.get(interaction.guild.id, interaction.channel as TextChannel);
if (!player) {
return;
}
const state = player.pause();
await interaction.reply({ embeds: [I18n.embeds.paused(state)] });
}
const data = new SlashCommandBuilder().setName("pause").setDescription("Toggle pause on player.");
export { data, execute };
-34
View File
@@ -1,34 +0,0 @@
import { CommandInteraction, SlashCommandBuilder } from "discord.js";
import Client from "../../../lib/client.js";
import I18n from "../lib/i18n.js";
import { execute as connect } from "./connect.js";
async function execute(client: Client, interaction: CommandInteraction) {
const query = interaction.options.get("query")?.value as string;
const player = await connect(client, interaction);
if (!player) {
return;
}
if (query) {
const track = await player.add(query, interaction.user);
await interaction.editReply({ embeds: [I18n.embeds.appended(track)] });
}
if (!player.resource) {
player.play();
}
}
const data = new SlashCommandBuilder()
.setName("play")
.setDescription("Launch player and add songs to the queue.")
.addStringOption((option) =>
option.setName("query").setDescription("Title or URL of the song.")
);
export { data, execute };
-77
View File
@@ -1,77 +0,0 @@
import {
ActionRowBuilder,
ButtonBuilder,
ButtonInteraction,
ButtonStyle,
CommandInteraction,
Message,
MessageActionRowComponentBuilder,
SlashCommandBuilder,
TextChannel
} from "discord.js";
import Client from "../../../lib/client.js";
import I18n from "../lib/i18n.js";
import { controller } from "../module.js";
async function execute(client: Client, interaction: CommandInteraction) {
if (!interaction.guild || !interaction.channel) {
return;
}
const player = controller.get(interaction.guild.id, interaction.channel as TextChannel);
if (!player) {
return;
}
await interaction.deferReply();
if (player.queue.empty()) {
await interaction.editReply({ embeds: [I18n.embeds.queueEmpty()] });
return;
}
let page = 0;
const row = new ActionRowBuilder<MessageActionRowComponentBuilder>().addComponents(
new ButtonBuilder().setCustomId("QueueTop").setLabel("🔼").setStyle(ButtonStyle.Primary),
new ButtonBuilder().setCustomId("QueueUp").setLabel("🔺").setStyle(ButtonStyle.Secondary),
new ButtonBuilder().setCustomId("QueueDown").setLabel("🔻").setStyle(ButtonStyle.Secondary),
new ButtonBuilder().setCustomId("QueueEnd").setLabel("🔽").setStyle(ButtonStyle.Primary)
);
const message = (await interaction.editReply({
embeds: [I18n.embeds.queueDesigner(player.queue, 0, player.resource)],
components: [row]
})) as Message;
const collector = message.createMessageComponentCollector({ time: 30000 });
collector.on("collect", async (btn: ButtonInteraction) => {
switch (btn.customId) {
case "QueueTop": {
page = 0;
break;
}
case "QueueUp": {
page = Math.max(page - 1, 0);
break;
}
case "QueueDown": {
page = Math.min(page + 1, Math.floor(player.queue.tracks.length / 11));
break;
}
case "QueueEnd":
page = Math.floor(player.queue.tracks.length / 11);
}
await btn.update({
embeds: [I18n.embeds.queueDesigner(player.queue, page, player.resource)]
});
});
}
const data = new SlashCommandBuilder().setName("queue").setDescription("Print out the queue.");
export { data, execute };
-35
View File
@@ -1,35 +0,0 @@
import { CommandInteraction, SlashCommandBuilder, TextChannel } from "discord.js";
import Client from "../../../lib/client.js";
import I18n from "../lib/i18n.js";
import { controller } from "../module.js";
async function execute(client: Client, interaction: CommandInteraction) {
const query = interaction.options.get("query")?.value as string;
if (!interaction.guild || !query || !interaction.channel) {
return;
}
const player = controller.get(interaction.guild.id, interaction.channel as TextChannel);
if (!player) {
return;
}
const track =
!isNaN(+query) && isFinite(+query) && !/e/i.test(query)
? player.remove(parseInt(query) - 1)
: player.remove(query);
await interaction.reply({ embeds: [I18n.embeds.removed(track)] });
}
const data = new SlashCommandBuilder()
.setName("remove")
.setDescription("Remove specific track in the queue.")
.addStringOption((option) =>
option.setName("query").setDescription("Position or title to remove.").setRequired(true)
);
export { data, execute };
-25
View File
@@ -1,25 +0,0 @@
import { CommandInteraction, SlashCommandBuilder, TextChannel } from "discord.js";
import Client from "../../../lib/client.js";
import I18n from "../lib/i18n.js";
import { controller } from "../module.js";
async function execute(client: Client, interaction: CommandInteraction) {
if (!interaction.guild || !interaction.channel) {
return;
}
const player = controller.get(interaction.guild.id, interaction.channel as TextChannel);
if (!player) {
return;
}
player.shuffle();
await interaction.reply({ embeds: [I18n.embeds.reshuffle()] });
}
const data = new SlashCommandBuilder().setName("reshuffle").setDescription("Shuffle the queue.");
export { data, execute };
-38
View File
@@ -1,38 +0,0 @@
import { CommandInteraction, SlashCommandBuilder, TextChannel } from "discord.js";
import Client from "../../../lib/client.js";
import I18n from "../lib/i18n.js";
import { controller } from "../module.js";
async function execute(client: Client, interaction: CommandInteraction) {
const hours = (interaction.options.get("hrs")?.value as number) || 0;
const minutes = (interaction.options.get("mins")?.value as number) || 0;
const seconds = (interaction.options.get("secs")?.value as number) || 0;
if (!interaction.guild || !interaction.channel) {
return;
}
const player = controller.get(interaction.guild.id, interaction.channel as TextChannel);
if (!player) {
return;
}
const time = hours * 3600 + minutes * 60 + seconds;
player.seek(time * 1000);
await interaction.reply({
embeds: [I18n.embeds.fastForwarded(time)]
});
}
const data = new SlashCommandBuilder()
.setName("seek")
.setDescription("Fast-forward by the time.")
.addIntegerOption((option) => option.setName("hrs").setDescription("Hours to seek."))
.addIntegerOption((option) => option.setName("mins").setDescription("Minutes to seek."))
.addIntegerOption((option) => option.setName("secs").setDescription("Seconds to seek."));
export { data, execute };
-25
View File
@@ -1,25 +0,0 @@
import { CommandInteraction, SlashCommandBuilder, TextChannel } from "discord.js";
import Client from "../../../lib/client.js";
import I18n from "../lib/i18n.js";
import { controller } from "../module.js";
async function execute(client: Client, interaction: CommandInteraction) {
if (!interaction.guild || !interaction.channel) {
return;
}
const player = controller.get(interaction.guild.id, interaction.channel as TextChannel);
if (!player) {
return;
}
player.destroy();
await interaction.reply({ embeds: [I18n.embeds.destroyed()] });
}
const data = new SlashCommandBuilder().setName("stop").setDescription("Destroy the player.");
export { data, execute };
-32
View File
@@ -1,32 +0,0 @@
import { CommandInteraction, SlashCommandBuilder, TextChannel } from "discord.js";
import Client from "../../../lib/client.js";
import I18n from "../lib/i18n.js";
import { controller } from "../module.js";
async function execute(client: Client, interaction: CommandInteraction) {
const volume = (interaction.options.get("volume")?.value as number) || 100;
if (!interaction.guild || !interaction.channel) {
return;
}
const player = controller.get(interaction.guild.id, interaction.channel as TextChannel);
if (!player) {
return;
}
player.volume = volume;
await interaction.reply({ embeds: [I18n.embeds.volumeSet(player.volume)] });
}
const data = new SlashCommandBuilder()
.setName("volume")
.setDescription("Change player's volume amount.")
.addIntegerOption((option) =>
option.setName("volume").setDescription("Volume in percents.").setRequired(true)
);
export { data, execute };
@@ -1,28 +0,0 @@
import { VoiceState } from "discord.js";
import Client from "../../../lib/client.js";
import I18n from "../lib/i18n.js";
import { asleep } from "../lib/misc.js";
import { controller } from "../module.js";
async function execute(client: Client, eventee: VoiceState[]): Promise<void> {
const [oldState] = eventee;
const player = controller.get(oldState.guild.id);
if (!player || !oldState.channel || !client.user) {
return;
}
if (oldState.channel.members.filter((value) => value.user !== client.user).size === 0) {
await asleep(30000);
if (oldState.channel.members.filter((value) => value.user !== client.user).size === 0) {
await player.channel.send({ embeds: [I18n.embeds.fullyAlone()] });
player.destroy();
}
}
}
const name = "voiceStateUpdate";
export { execute, name };
-46
View File
@@ -1,46 +0,0 @@
import { execFile } from "child_process";
import path from "path";
import { promisify } from "util";
import config from "../../../config.js";
import { YtdlArgs, YtdlResponse } from "./types.js";
const execFileAsync = promisify(execFile);
function argumentsResolver(args: YtdlArgs): string[] {
const options = Object.entries(args);
return options.map(([key, value]) => {
if (typeof value === "boolean" && value) {
return `--${key.split(/(?=[A-Z])/).join("-")}`.toLowerCase();
} else {
return `--${key.split(/(?=[A-Z])/).join("-")}=${value}`.toLowerCase();
}
});
}
async function ytdlExec(query: string, args: YtdlArgs): Promise<YtdlResponse | undefined> {
try {
const executable = await execFileAsync(
path.join(config.binariesDir, "/youtube-dl"),
[query].concat(argumentsResolver(args))
);
return JSON.parse(executable.stdout);
} catch (error) {
console.log(error);
return;
}
}
export async function fetchStream(
query: string,
args: YtdlArgs
): Promise<YtdlResponse | undefined> {
let response = await ytdlExec(query, args);
if (response && !response.extractor.includes("youtube")) {
response = await ytdlExec(`${response.title} - ${response.creator}`, args);
}
return response;
}
-71
View File
@@ -1,71 +0,0 @@
export default class Filter {
public static readonly filters: Record<string, string> = {
bassboost_low: "bass=g=15:f=110:w=0.3",
bassboost: "bass=g=20:f=110:w=0.3",
bassboost_high: "bass=g=30:f=110:w=0.3",
"8D": "apulsator=hz=0.09",
vaporwave: "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: "dynaudnorm=g=101",
normalizer2: "acompressor",
surrounding: "surround",
pulsator: "apulsator=hz=1",
subboost: "asubboost",
karaoke: "stereotools=mlev=0.03",
flanger: "flanger",
gate: "agate",
haas: "haas",
mcompand: "mcompand",
mono: "pan=mono|c0=.5*c0+.5*c1",
mstlr: "stereotools=mode=ms>lr",
mstrr: "stereotools=mode=ms>rr",
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",
softlimiter: "compand=attacks=0:points=-80/-80|-12.4/-12.4|-6/-8|0/-6.8|20/-2.8",
chorus: "chorus=0.7:0.9:55:0.4:0.25:2",
chorus2d: "chorus=0.6:0.9:50|60:0.4|0.32:0.25|0.4:2|1.3",
chorus3d: "chorus=0.5:0.9:50|60|40:0.4|0.32|0.3:0.25|0.4|0.3:2|2.3|1.3",
fadein: "afade=t=in:ss=0:d=10",
dim: "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): void {
if (!query) {
this.switches.clear();
return;
}
if (!this.switches.delete(query) && Object.hasOwn(Filter.filters, query)) {
this.switches.add(query);
}
}
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(",");
}
}
-259
View File
@@ -1,259 +0,0 @@
import { AudioResource } from "@discordjs/voice";
import { EmbedBuilder } from "discord.js";
import { loopMoji, secToISO, statusBarGenerator, volumeMoji } from "./misc.js";
import Player from "./player.js";
import Queue from "./queue.js";
import { LoopOption, Track } from "./types.js";
export default class I18n {
public static readonly embeds = {
nowPlaying: (track: Track) =>
new EmbedBuilder()
.setColor("LuminousVividPink")
.setAuthor({
iconURL: track.requester.avatarURL() || undefined,
name: track.requester.tag
})
.setTitle(track.title)
.setURL(track.url)
.setDescription("**playing now.**")
.setThumbnail(track.thumbnail),
cleared: (amount: number) =>
new EmbedBuilder()
.setColor("Green")
.setTitle(`**♻️ ${amount} tracks have been cleared from the queue.**`)
.setTimestamp(new Date()),
okConnected: (channel: string) =>
new EmbedBuilder()
.setColor("Green")
.setTitle(`**✅ Successfuly connected to:** _${channel}_.`),
notConnected: () =>
new EmbedBuilder()
.setColor("Red")
.setTitle("**🚫 Make yourself connected to a voice channel beforehand.**"),
wasConnected: () =>
new EmbedBuilder()
.setColor("Yellow")
.setTitle("**⚠️ Already connected to a voice channel.**"),
filtered: (filter?: string) =>
new EmbedBuilder()
.setColor("Green")
.setTitle(
filter
? `**✅ ${filter} was applied to the player.**`
: "**✅ All filters were declined.**"
),
appended: (track?: Track) =>
!track
? new EmbedBuilder()
.setColor("Red")
.setTitle("**🚫 Could not add track to the queue.**")
: new EmbedBuilder()
.setColor("Green")
.setTitle(track.title)
.setURL(track.url)
.setThumbnail(track.thumbnail)
.setAuthor({
iconURL: track.requester.avatarURL() || undefined,
name: track.requester.tag
})
.setDescription("**has been added to the queue.**")
.setTimestamp(new Date()),
jumped: (state: boolean) =>
state
? new EmbedBuilder().setColor("Green").setTitle("_**Perfoming jump.**_")
: new EmbedBuilder().setColor("Red").setTitle("**❗ Bad _jump_ request.**"),
looped: (query: LoopOption) => {
switch (query) {
case "AUTO": {
return new EmbedBuilder()
.setColor("LuminousVividPink")
.setTitle("**🔀 Shuffle mode** _(does not affect the queue order)_.");
}
case "LOOP": {
return new EmbedBuilder()
.setColor("Blue")
.setTitle("**🔁 Looping over the queue.**");
}
case "NONE": {
return new EmbedBuilder()
.setColor("Red")
.setTitle("**⤵️ Player will stop on the queue end.**");
}
case "SONG": {
return new EmbedBuilder()
.setColor("Yellow")
.setTitle("**🔂 Looping a single track.**");
}
}
},
skipped: () => new EmbedBuilder().setColor("Green").setTitle("**⏭️ Skipping.**"),
paused: (state: boolean) =>
state
? new EmbedBuilder().setColor("Green").setTitle("_**▶️ Resumed.**_")
: new EmbedBuilder().setColor("Yellow").setTitle("_**⏸️ Paused.**_"),
removed: (track?: Track) =>
track
? new EmbedBuilder()
.setColor("Green")
.setTitle(track.title)
.setDescription("**was removed from the queue.**")
.setTimestamp(new Date())
.setURL(track.url)
.setThumbnail(track.thumbnail)
: new EmbedBuilder().setColor("Red").setTitle("**❗ Bad _remove_ request.**"),
fastForwarded: (amount: number) =>
new EmbedBuilder()
.setColor("Blue")
.setTitle(`**⏩ Fast-forwarding to ${secToISO(amount)}**`),
destroyed: () =>
new EmbedBuilder().setColor("Red").setTitle("**💀 Destroying the player.**"),
volumeSet: (amount: number) =>
new EmbedBuilder()
.setColor("Green")
.setTitle(`**${volumeMoji(amount)} Volume has been set to ${amount}%**`),
queueEmpty: () => new EmbedBuilder().setColor("Yellow").setTitle("**❎ Queue is empty.**"),
playerInfo: (player?: Player) => {
if (!player) {
return new EmbedBuilder()
.setColor("Red")
.setTitle("**🚫 Player does not exist.**");
}
if (player.resource) {
return new EmbedBuilder()
.setColor("LuminousVividPink")
.setTitle(`${player.queue.position + 1}. ${player.resource.metadata.title}`)
.setDescription(
statusBarGenerator(
player.resource.playbackDuration / 1000,
player.resource.metadata.duration
)
)
.setThumbnail(player.resource.metadata.thumbnail)
.setURL(player.resource.metadata.url)
.addFields([
{
name: "Applied Filters",
value:
player.filter
.toggled()
.map((value) => value[0])
.join("\n") + "\ndefault",
inline: true
},
{
name: "Queue Length",
value: `🔢 ${player.queue.tracks.length}`,
inline: true
},
{
name: "Queue Duration",
value: `💿 ${secToISO(player.queue.duration)}`,
inline: true
},
{
name: "Queue Looping",
value: `${loopMoji(player.queue.loop)} ${player.queue.loop}`,
inline: true
},
{
name: "Volume amount",
value: `${volumeMoji(player.volume)} ${player.volume}/150`,
inline: true
},
{
name: "Player lifetime",
value: `🕓 ${secToISO((Date.now() - player.created.getTime()) / 1000)}`,
inline: true
}
]);
} else {
return new EmbedBuilder()
.setColor("Blue")
.setTitle("💿 Insert the disk...")
.setDescription(statusBarGenerator(0, 0))
.setURL("https://www.youtube.com/watch?v=dQw4w9WgXcQ&ab_channel=RickAstley")
.addFields([
{
name: "Applied Filters",
value:
player.filter
.toggled()
.map((value) => value[0])
.join("\n") + "\ndefault",
inline: true
},
{
name: "Queue Length",
value: `🔢 ${player.queue.tracks.length}`,
inline: true
},
{
name: "Queue Duration",
value: `💿 ${secToISO(player.queue.duration)}`,
inline: true
},
{
name: "Queue Looping",
value: `${loopMoji(player.queue.loop)} ${player.queue.loop}`,
inline: true
},
{
name: "Volume amount",
value: `${volumeMoji(player.volume)} ${player.volume}/150`,
inline: true
},
{
name: "Player lifetime",
value: `🕓 ${secToISO((Date.now() - player.created.getTime()) / 1000)}`,
inline: true
}
]);
}
},
queueDesigner: (queue: Queue, page: number, resource: AudioResource<Track> | undefined) => {
page = Math.max(0, Math.min(Math.floor(queue.tracks.length / 11), page));
return new EmbedBuilder()
.setColor("LuminousVividPink")
.setTitle(
resource
? `${queue.position + 1}. ${resource.metadata.title}`
: "💿 Insert the disk..."
)
.setURL(
resource
? resource.metadata.url
: "https://www.youtube.com/watch?v=5mGuCdlCcNM&ab_channel=Ra%C3%BAlBlanco"
)
.setThumbnail(
resource
? resource.metadata.thumbnail
: "https/img.youtube.com/vi/5mGuCdlCcNM/default.jpg"
)
.setDescription(
resource
? statusBarGenerator(
resource.playbackDuration / 1000,
resource.metadata.duration
)
: statusBarGenerator(0, 0)
)
.setFields(
queue.tracks.slice(page * 10, page * 10 + 10).map((value, index) => ({
name: `${page * 10 + index + 1}. ${value.title.padEnd(35)}`,
value: `**[${secToISO(value.duration)}]**`
}))
);
},
fullyAlone: () =>
new EmbedBuilder()
.setColor("LuminousVividPink")
.setTitle("🥬 I have decided to leave to conserve my energy.")
.setDescription("_**Since there was nobody in the voice channel.**_"),
reshuffle: () =>
new EmbedBuilder().setColor("Green").setTitle("**✅ Successfuly reshuffled queue.**")
};
}
-58
View File
@@ -1,58 +0,0 @@
import { LoopOption } from "./types.js";
const PLAYBACK_LENGTH = 15;
export function statusBarGenerator(playback: number, duration: number): string {
const filled = Array<string>(
Math.floor((PLAYBACK_LENGTH * playback) / (duration === 0 ? 1 : duration))
).fill("🔹");
const empty = Array<string>(
PLAYBACK_LENGTH - Math.floor((PLAYBACK_LENGTH * playback) / (duration === 0 ? 1 : duration))
).fill("▫️");
return `**[${filled.join("")}${empty.join("")}] ${secToISO(playback)}/${secToISO(duration)}**`;
}
export function secToISO(amount: number): string {
const h = Math.floor(amount / 3600);
const m = Math.floor((amount % 3600) / 60);
const s = Math.floor((amount % 3600) % 60);
const hDisplay = h > 0 ? `${h}:` : "";
const mDisplay = `${Math.floor(m / 10)}${Math.floor(m % 10)}:`;
const sDisplay = `${Math.floor(s / 10)}${Math.floor(s % 10)}`;
return hDisplay + mDisplay + sDisplay;
}
export async function asleep(amount: number): Promise<unknown> {
return new Promise((r) => setTimeout(r, amount));
}
export function volumeMoji(amount: number): string {
return amount > 100 ? "🔊" : amount > 50 ? "🔉" : amount > 0 ? "🔈" : "🔇";
}
export function loopMoji(loop: LoopOption): string {
return loop === "AUTO" ? "🔀" : loop === "LOOP" ? "🔁" : loop === "NONE" ? "⤵️" : "🔂";
}
export function largestCommonSequence(s1: string, s2: string) {
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];
}
-31
View File
@@ -1,31 +0,0 @@
import { Collection, Guild, TextChannel } from "discord.js";
import Player from "./player.js";
export default class Music {
private players = new Collection<string, Player>();
public get(guildId: string, channel?: TextChannel): Player | undefined {
const player = this.players.get(guildId);
if (player) {
if (channel) {
player.channel = channel;
}
return player;
}
}
public set(guild: Guild, channel: TextChannel): Player {
const player = new Player(guild, this, channel);
this.players.set(guild.id, player);
return player;
}
public delete(guildId: string): void {
this.players.delete(guildId);
}
}
-328
View File
@@ -1,328 +0,0 @@
import {
AudioPlayer,
AudioPlayerStatus,
AudioResource,
createAudioPlayer,
createAudioResource,
DiscordGatewayAdapterCreator,
entersState,
joinVoiceChannel,
VoiceConnection,
VoiceConnectionStatus
} from "@discordjs/voice";
import { Guild, Message, TextChannel, User, VoiceBasedChannel } from "discord.js";
import ytdl from "discord-ytdl-core";
import { fetchStream } from "./fetch.js";
import Filter from "./filter.js";
import I18n from "./i18n.js";
import { largestCommonSequence } from "./misc.js";
import Music from "./music.js";
import Queue from "./queue.js";
import { Track } from "./types.js";
export default class Player {
private music: Music;
private guild: Guild;
private msgChannel: TextChannel;
public get channel(): TextChannel {
return this.msgChannel;
}
public set channel(x: TextChannel) {
this.msgChannel = x;
}
private message: Message | undefined;
public internalConnection: VoiceConnection | undefined;
public get connection(): VoiceConnection | undefined {
return this.internalConnection;
}
private internalPlayer: AudioPlayer;
public get player(): AudioPlayer {
return this.internalPlayer;
}
private playerResource: AudioResource<Track> | undefined;
public get resource(): AudioResource<Track> | undefined {
return this.playerResource;
}
private resourceVolume = 100;
public get volume(): number {
return this.resourceVolume;
}
public set volume(x: number) {
this.resourceVolume = Math.max(0, Math.min(150, x));
if (this.playerResource && this.playerResource.volume) {
this.playerResource.volume.setVolumeLogarithmic(this.resourceVolume / 100);
}
}
public readonly queue = new Queue();
public readonly filter = new Filter();
public readonly created: Date = new Date();
public constructor(guild: Guild, music: Music, textChannel: TextChannel) {
this.music = music;
this.guild = guild;
this.msgChannel = textChannel;
this.internalPlayer = createAudioPlayer();
this.internalPlayer.on(AudioPlayerStatus.Idle, async () => this.resourceEndResolvable());
}
private resourceGenerator(track: Track, seek?: number) {
const stream = ytdl(track.url, {
opusEncoded: false,
fmt: "mp3",
filter: "audioonly",
highWaterMark: 1 << 25,
seek: seek ? seek / 1000 : undefined,
encoderArgs: !this.filter.empty() ? ["-af", this.filter.toString()] : undefined
});
this.playerResource = createAudioResource(stream, {
inlineVolume: true,
metadata: track
});
if (seek) {
this.playerResource.playbackDuration = seek;
}
if (this.playerResource.volume) {
this.playerResource.volume.setVolumeLogarithmic(this.resourceVolume / 100);
}
return this.playerResource;
}
private resourceEndResolvable(): void {
if (this.playerResource) {
this.playerResource.encoder?.destroy();
}
if (this.queue.next()) {
this.play();
} else {
this.playerResource = undefined;
this.internalPlayer.stop();
}
}
private async messageResolvable(track: Track) {
if (this.message) {
await this.message.delete();
}
this.message = await this.msgChannel.send({
embeds: [I18n.embeds.nowPlaying(track)]
});
}
private async search(query: string, requester: User): Promise<Track[] | undefined> {
const response = await fetchStream(query, {
dumpSingleJson: true,
noWarnings: true,
noCheckCertificate: true,
flatPlaylist: true,
skipDownload: true,
defaultSearch: "ytsearch"
});
if (!response) {
return undefined;
}
const tracks: Track[] = [];
if (response.entries) {
for (const entry of response.entries) {
tracks.push({
url: `https://youtube.com/watch?v=${entry.id}`,
title: entry.title,
thumbnail: `https://img.youtube.com/vi/${entry.id}/default.jpg`,
duration: entry.duration,
requester: requester
});
}
} else {
tracks.push({
url: `https://youtube.com/watch?v=${response.id}`,
title: response.title,
thumbnail: `https://img.youtube.com/vi/${response.id}/default.jpg`,
duration: response.duration,
requester: requester
});
}
return tracks;
}
public play(seek?: number): void {
const track = this.queue.current();
if (!this.internalConnection || !track) {
return;
}
this.internalPlayer.play(this.resourceGenerator(track, seek));
this.internalConnection.subscribe(this.internalPlayer);
this.messageResolvable(track);
}
public connect(channel: VoiceBasedChannel): void {
if (this.internalConnection) {
this.internalConnection.destroy();
}
this.internalConnection = joinVoiceChannel({
channelId: channel.id,
guildId: channel.guildId,
adapterCreator: channel.guild.voiceAdapterCreator as DiscordGatewayAdapterCreator
});
this.internalConnection.on(VoiceConnectionStatus.Disconnected, async () => {
try {
await Promise.race([
entersState(
this.internalConnection as VoiceConnection,
VoiceConnectionStatus.Signalling,
10_000
),
entersState(
this.internalConnection as VoiceConnection,
VoiceConnectionStatus.Connecting,
10_000
)
]);
} catch (error) {
this.destroy();
}
});
}
public async add(query: string, requester: User, index?: number): Promise<Track | undefined> {
const tracks = await this.search(query, requester);
if (!tracks) {
return;
}
this.queue.push(tracks, index);
return tracks[0];
}
public jump(query: number | string): boolean {
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 true;
}
return false;
}
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;
}
public seek(time: number): void {
this.play(time);
}
public clear(): void {
this.queue.clear();
this.playerResource = undefined;
if (this.internalPlayer) {
this.internalPlayer.stop();
}
}
public destroy(): void {
if (this.internalConnection) {
this.internalConnection.destroy();
}
this.internalPlayer.stop();
this.music.delete(this.guild.id);
}
public pause(): boolean {
if (this.internalPlayer.state.status === AudioPlayerStatus.Paused) {
this.internalPlayer.unpause();
return false;
} else {
this.internalPlayer.pause();
return true;
}
}
public shuffle(): void {
this.queue.shuffle();
if (this.playerResource) {
this.internalPlayer.stop();
this.play();
}
}
public skip(): void {
this.internalPlayer.stop();
}
public setFilter(filter?: string): void {
this.filter.toggle(filter);
if (this.playerResource) {
this.play(this.playerResource.playbackDuration);
}
}
}
-117
View File
@@ -1,117 +0,0 @@
import { LoopOption, Track } from "./types.js";
export default class Queue {
private entries: Track[] = [];
public get tracks(): Track[] {
return this.entries;
}
private currentIndex = 0;
public get position(): number {
return this.currentIndex;
}
private loopStatus: LoopOption = "NONE";
public get loop(): LoopOption {
return this.loopStatus;
}
public set loop(x: LoopOption) {
this.loopStatus = x;
}
private overallDuration = 0;
public get duration(): number {
return this.overallDuration;
}
public next(): Track | undefined {
if (this.loopStatus === "SONG") {
return this.current();
}
if (this.loopStatus === "AUTO") {
this.currentIndex = Math.floor(Math.random() * this.entries.length);
return this.current();
}
if (this.currentIndex + 1 < this.entries.length) {
this.currentIndex++;
return this.current();
} else if (this.loopStatus === "LOOP") {
this.currentIndex = 0;
return this.current();
} else if (this.loopStatus === "NONE") {
this.currentIndex = 0;
return undefined;
}
}
public shuffle(): void {
let currentIndex = this.entries.length;
while (currentIndex != 0) {
const randomIndex = Math.floor(Math.random() * currentIndex);
currentIndex--;
[this.entries[currentIndex], this.entries[randomIndex]] = [
this.entries[randomIndex],
this.entries[currentIndex]
];
}
this.currentIndex = 0;
}
public push(tracks: Track[], index?: number): Track {
if (!index || index >= this.entries.length) {
this.entries = this.entries.concat(tracks);
} else {
this.entries.splice(Math.max(0, index), 0, ...tracks);
if (this.currentIndex >= index) {
this.currentIndex += tracks.length;
}
}
for (const track of tracks) {
this.overallDuration += track.duration;
}
return this.entries[index || this.entries.length - 1];
}
public pop(query: number): Track | undefined {
if (query >= 0 && query < this.entries.length) {
const track = this.entries[query];
this.entries.splice(query, 1);
this.currentIndex = Math.max(0, this.currentIndex - 1);
this.overallDuration -= track.duration;
return track;
}
return undefined;
}
public jump(query: number): Track | undefined {
if (query >= 0 && query < this.entries.length) {
const track = this.entries[query];
this.currentIndex = query;
return track;
}
return undefined;
}
public current(): Track {
return this.entries[this.currentIndex];
}
public empty(): boolean {
return this.entries.length === 0;
}
public clear(): void {
this.entries = [];
this.currentIndex = 0;
}
}
-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 = "2302.1";
const controller = new Music();
const rootDir = path.dirname(fileURLToPath(import.meta.url));
export { author, controller, description, name, rootDir, tag };
+15
View File
@@ -0,0 +1,15 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"paths": {
"$config.js": ["../config.js"],
"$lib/*": ["./lib/*"]
},
"rootDir": ".",
"outDir": "../dist/src",
"composite": true
},
"references": [{ "path": "../config.tsconfig.json" }]
}
+13 -11
View File
@@ -2,18 +2,20 @@
"compilerOptions": { "compilerOptions": {
"strict": true, "strict": true,
"module": "ESNext", "module": "NodeNext",
"moduleResolution": "node", "moduleResolution": "NodeNext",
"outDir": "dist", "resolveJsonModule": true,
"removeComments": false,
"sourceMap": true,
"esModuleInterop": true,
"target": "ESNext",
"lib": ["ESNext"],
"types": ["node"], "types": ["node"],
"outDir": "./dist",
"sourceMap": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"target": "ES2022",
"skipLibCheck": true "skipLibCheck": true
}, },
"include": ["src/**/*"], "references": [{ "path": "./modules/core" }, { "path": "./modules/music" }]
"exclude": ["node_modules", "dist", "src/commands", "src/events"]
} }