Compare commits
16
Commits
4.2.1
...
development
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8259f76615 | ||
|
|
afc6fce555 | ||
|
|
be88d20f31 | ||
|
|
811bc3a3c8 | ||
|
|
824abc6533 | ||
|
|
3144dd69b7 | ||
|
|
d7421251ba | ||
|
|
29fcdcc06f | ||
|
|
6587bcd68e | ||
|
|
ef61f7fa0a | ||
|
|
4ed9e1c6e0 | ||
|
|
a59faab420 | ||
|
|
a725290293 | ||
|
|
6a3425e2c3 | ||
|
|
3290962c77 | ||
|
|
ace8170323 |
No files matched your search
+5
-2
@@ -5,12 +5,15 @@
|
||||
!Dockerfile
|
||||
|
||||
# Source
|
||||
!config.ts
|
||||
!src/
|
||||
!prisma/
|
||||
!modules/
|
||||
|
||||
# Dependencies
|
||||
!package.json
|
||||
!pnpm-lock.yaml
|
||||
|
||||
# Typescript compiller config
|
||||
# Build
|
||||
!build.sh
|
||||
!config.tsconfig.json
|
||||
!tsconfig.json
|
||||
@@ -1,9 +1,11 @@
|
||||
name: actions
|
||||
name: Continuous Integration
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "*"
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
mocha:
|
||||
@@ -11,7 +13,7 @@ jobs:
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [18.x]
|
||||
node-version: [20.x]
|
||||
pnpm-version: [8]
|
||||
|
||||
steps:
|
||||
@@ -33,6 +35,11 @@ jobs:
|
||||
- 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
|
||||
|
||||
@@ -53,14 +60,25 @@ jobs:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
- name: Docker Generate Meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: unknowableshade/quetza-bot
|
||||
images: |
|
||||
unknowableshade/quetza
|
||||
# generate Docker tags based on the following events/attributes
|
||||
# on tag event: tag using git tag, and as latest if the tag doesn't contain hyphens (pre-releases)
|
||||
# on push event: tag using git sha, branch name and as latest-dev
|
||||
tags: |
|
||||
type=raw,value=latest,enable=${{ github.ref_type == 'tag' && !contains(github.ref_name, '-') }}
|
||||
type=raw,value=latest-dev,enable=${{ github.ref_type == 'branch' && github.ref_name == 'master' }}
|
||||
type=raw,value=${{ github.ref_name }}
|
||||
type=raw,value=${{ github.sha }},enable=${{ github.ref_type == 'branch' }}
|
||||
flavor: |
|
||||
latest=false
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4
|
||||
- name: Build and Push Docker Image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
+21
-28
@@ -1,6 +1,7 @@
|
||||
FROM node:18-alpine as builder
|
||||
FROM node:20-alpine as builder
|
||||
|
||||
RUN apk add --no-cache python3
|
||||
RUN apk add --no-cache curl
|
||||
RUN apk add --no-cache make
|
||||
RUN apk add --no-cache gcc
|
||||
RUN apk add --no-cache build-base
|
||||
@@ -17,35 +18,27 @@ COPY . .
|
||||
|
||||
RUN pnpm run build
|
||||
|
||||
FROM node:18-alpine
|
||||
|
||||
RUN apk update
|
||||
RUN apk add --no-cache bash
|
||||
RUN apk add --no-cache curl
|
||||
RUN apk add --no-cache ffmpeg
|
||||
RUN apk add --no-cache python3
|
||||
RUN apk add --no-cache make
|
||||
RUN apk add --no-cache gcc
|
||||
RUN apk add --no-cache build-base
|
||||
|
||||
RUN npm install -g pnpm
|
||||
|
||||
ENV NODE_ENV production
|
||||
USER node
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
RUN mkdir bin
|
||||
RUN curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /usr/src/app/bin/yt-dlp
|
||||
RUN chmod a+rx /usr/src/app/bin/yt-dlp
|
||||
|
||||
COPY prisma/ .
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN mkdir ./dist/bin/
|
||||
RUN curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /usr/src/app/dist/bin/yt-dlp
|
||||
RUN chmod a+rx /usr/src/app/dist/bin/yt-dlp
|
||||
|
||||
RUN pnpm install --prod
|
||||
|
||||
RUN pnpx prisma generate
|
||||
|
||||
COPY --from=builder /usr/src/app/dist ./dist
|
||||
FROM node:20-alpine
|
||||
|
||||
CMD [ "pnpm", "start" ]
|
||||
RUN apk update
|
||||
RUN apk add --no-cache ffmpeg
|
||||
RUN apk add --no-cache python3
|
||||
|
||||
USER node
|
||||
ENV NODE_ENV production
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
COPY package.json ./
|
||||
|
||||
COPY --from=builder /usr/src/app/node_modules ./node_modules
|
||||
COPY --from=builder /usr/src/app/dist ./
|
||||
|
||||
CMD [ "node", "./src/index.js" ]
|
||||
@@ -1,16 +1,148 @@
|
||||
# Quetza 4
|
||||

|
||||
|
||||
_Neat typescript discord bot._
|
||||
# Quetza │ [](https://github.com/unknowableshade/quetza/actions/workflows/ci.yml)
|
||||
|
||||
## About
|
||||
Awesome Discord bot that performs various duties on my server.
|
||||
|
||||
- Base player features.
|
||||
- Play music from YouTube and many other sites.
|
||||
- Lots of different filters _(nightcore, earrape etc.)_.
|
||||
- Slash commands.
|
||||
## Architecture
|
||||
|
||||
## 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)**
|
||||
- **[Docker](https://hub.docker.com/repository/docker/unknowableshade/quetza-bot)**
|
||||
- **[Licence](https://github.com/unknowableshade/5a0b-bot/blob/master/LICENSE)**
|
||||
### Modules
|
||||
|
||||
**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
|
||||
|
||||
[](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
@@ -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 |
@@ -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
|
||||
@@ -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;
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"outDir": "./dist",
|
||||
|
||||
"composite": true
|
||||
},
|
||||
"include": ["config.ts"]
|
||||
}
|
||||
@@ -1,7 +1,18 @@
|
||||
/**
|
||||
* /modules
|
||||
*
|
||||
* Replies module listing as well as description of each.
|
||||
*
|
||||
* Just for the information I guess...
|
||||
*
|
||||
* @packageDocumentation
|
||||
*/
|
||||
|
||||
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
|
||||
|
||||
import Client from "../../../lib/client.js";
|
||||
import logger from "../../../lib/logger.js";
|
||||
import Client from "$lib/client.js";
|
||||
import logger from "$lib/logger.js";
|
||||
|
||||
import replies from "../lib/replies.js";
|
||||
|
||||
async function execute(client: Client, interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
@@ -1,13 +1,21 @@
|
||||
/**
|
||||
* /ping
|
||||
*
|
||||
* Replies websocket ping (ping between bot and discord) and real ping (ping to fetch + process).
|
||||
*
|
||||
* @remarks Probably should double their value as two ways, but nvm.
|
||||
*
|
||||
* @packageDocumentation
|
||||
*/
|
||||
|
||||
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
|
||||
|
||||
import Client from "../../../lib/client.js";
|
||||
import logger from "../../../lib/logger.js";
|
||||
import Client from "$lib/client.js";
|
||||
|
||||
import replies from "../lib/replies.js";
|
||||
|
||||
async function execute(client: Client, interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
if (!interaction.isChatInputCommand()) {
|
||||
logger.warn("Interaction rejected.", { interaction });
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Ready
|
||||
*
|
||||
* Will execute upon emitting Client's Ready event.
|
||||
*
|
||||
* @remarks Will push slash commands, set activity status,
|
||||
* and log bot's initials.
|
||||
*
|
||||
* @packageDocumentation
|
||||
*/
|
||||
|
||||
import { generateDependencyReport } from "@discordjs/voice";
|
||||
import { Events } from "discord.js";
|
||||
|
||||
import config from "$config.js";
|
||||
import Client from "$lib/client.js";
|
||||
import logger from "$lib/logger.js";
|
||||
|
||||
async function execute(client: Client<true>): Promise<void> {
|
||||
const commandData = client.commands.map((value) => value.data);
|
||||
|
||||
// To not reload bot's global slash commands at every dev build.
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
logger.info(
|
||||
"Development mode: Commands will be pushed to the _test guild_!",
|
||||
client.generateApplicationStatus()
|
||||
);
|
||||
logger.info("Dependency report by '@discordjs/voice'.", {
|
||||
report: generateDependencyReport()
|
||||
});
|
||||
|
||||
const guild = await client.guilds.fetch(config.dev.guild);
|
||||
guild.commands.set(commandData);
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
logger.info(
|
||||
"Production mode: Commands will be pushed to the _application_!",
|
||||
client.generateApplicationStatus()
|
||||
);
|
||||
|
||||
await client.application.commands.set(commandData);
|
||||
}
|
||||
|
||||
client.user.setActivity(config.application.activity);
|
||||
}
|
||||
|
||||
const name = Events.ClientReady;
|
||||
|
||||
export { execute, name };
|
||||
@@ -1,20 +1,18 @@
|
||||
import { BaseMessageOptions, Collection, EmbedBuilder, italic } from "discord.js";
|
||||
|
||||
import config from "../../../config.js";
|
||||
import { ModuleMetadata } from "../../../lib/types.js";
|
||||
import config from "$config.js";
|
||||
import { Module } from "$lib/types.js";
|
||||
|
||||
const replies = {
|
||||
modules: (modules: Collection<string, ModuleMetadata>): BaseMessageOptions => {
|
||||
modules: (modules: Collection<string, Module>): BaseMessageOptions => {
|
||||
const embed = new EmbedBuilder()
|
||||
.setColor(config.colors.default)
|
||||
.setTitle("Applied modules")
|
||||
.setTitle("Avaliable modules")
|
||||
.addFields(
|
||||
modules.map((value) => {
|
||||
return {
|
||||
name: value.name + "@" + value.tag,
|
||||
value: italic(value.description ?? "")
|
||||
};
|
||||
})
|
||||
modules.map((value) => ({
|
||||
name: value.name,
|
||||
value: italic(value.description ?? "")
|
||||
}))
|
||||
);
|
||||
|
||||
return { embeds: [embed] };
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"$config.js": ["../../config.js"],
|
||||
"$lib/*": ["../../src/lib/*"],
|
||||
"$mlib/*": ["./lib/*"]
|
||||
},
|
||||
|
||||
"rootDir": ".",
|
||||
"outDir": "../../dist/modules/core",
|
||||
|
||||
"composite": true
|
||||
},
|
||||
"references": [{ "path": "../../src/" }]
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
|
||||
|
||||
import Client from "../../../lib/client.js";
|
||||
import logger from "../../../lib/logger.js";
|
||||
import Client from "$lib/client.js";
|
||||
import logger from "$lib/logger.js";
|
||||
|
||||
import replies from "../lib/replies.js";
|
||||
import { controller } from "../module.js";
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
|
||||
|
||||
import Client from "../../../lib/client.js";
|
||||
import logger from "../../../lib/logger.js";
|
||||
import Client from "$lib/client.js";
|
||||
import logger from "$lib/logger.js";
|
||||
|
||||
import replies from "../lib/replies.js";
|
||||
import { controller } from "../module.js";
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
|
||||
|
||||
import Client from "../../../lib/client.js";
|
||||
import logger from "../../../lib/logger.js";
|
||||
import Client from "$lib/client.js";
|
||||
import logger from "$lib/logger.js";
|
||||
|
||||
import Filter from "../lib/filter.js";
|
||||
import replies from "../lib/replies.js";
|
||||
import { controller } from "../module.js";
|
||||
@@ -35,11 +36,7 @@ const data = new SlashCommandBuilder()
|
||||
option
|
||||
.setName("filter")
|
||||
.setDescription("Filter to apply.")
|
||||
.setChoices(
|
||||
...Object.keys(Filter.filters).map((key) => {
|
||||
return { name: key, value: key };
|
||||
})
|
||||
)
|
||||
.setChoices(...Object.values(Filter.FILTERS))
|
||||
)
|
||||
.setDMPermission(false);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
|
||||
|
||||
import Client from "../../../lib/client.js";
|
||||
import logger from "../../../lib/logger.js";
|
||||
import Client from "$lib/client.js";
|
||||
import logger from "$lib/logger.js";
|
||||
|
||||
import replies from "../lib/replies.js";
|
||||
import { controller } from "../module.js";
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
|
||||
|
||||
import Client from "../../../lib/client.js";
|
||||
import logger from "../../../lib/logger.js";
|
||||
import Client from "$lib/client.js";
|
||||
import logger from "$lib/logger.js";
|
||||
|
||||
import replies from "../lib/replies.js";
|
||||
import { controller } from "../module.js";
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
|
||||
|
||||
import Client from "../../../lib/client.js";
|
||||
import logger from "../../../lib/logger.js";
|
||||
import Client from "$lib/client.js";
|
||||
import logger from "$lib/logger.js";
|
||||
|
||||
import replies from "../lib/replies.js";
|
||||
import { controller } from "../module.js";
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
|
||||
|
||||
import Client from "../../../lib/client.js";
|
||||
import logger from "../../../lib/logger.js";
|
||||
import Client from "$lib/client.js";
|
||||
import logger from "$lib/logger.js";
|
||||
|
||||
import replies from "../lib/replies.js";
|
||||
import { LoopOption } from "../lib/types.js";
|
||||
import { controller } from "../module.js";
|
||||
@@ -1,7 +1,8 @@
|
||||
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
|
||||
|
||||
import Client from "../../../lib/client.js";
|
||||
import logger from "../../../lib/logger.js";
|
||||
import Client from "$lib/client.js";
|
||||
import logger from "$lib/logger.js";
|
||||
|
||||
import replies from "../lib/replies.js";
|
||||
import { controller } from "../module.js";
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
|
||||
|
||||
import Client from "../../../lib/client.js";
|
||||
import logger from "../../../lib/logger.js";
|
||||
import Client from "$lib/client.js";
|
||||
import logger from "$lib/logger.js";
|
||||
|
||||
import replies from "../lib/replies.js";
|
||||
import { controller } from "../module.js";
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
|
||||
|
||||
import Client from "../../../lib/client.js";
|
||||
import logger from "../../../lib/logger.js";
|
||||
import Client from "$lib/client.js";
|
||||
import logger from "$lib/logger.js";
|
||||
|
||||
import replies from "../lib/replies.js";
|
||||
import { controller } from "../module.js";
|
||||
|
||||
@@ -8,8 +8,9 @@ import {
|
||||
SlashCommandBuilder
|
||||
} from "discord.js";
|
||||
|
||||
import Client from "../../../lib/client.js";
|
||||
import logger from "../../../lib/logger.js";
|
||||
import Client from "$lib/client.js";
|
||||
import logger from "$lib/logger.js";
|
||||
|
||||
import replies from "../lib/replies.js";
|
||||
import { controller } from "../module.js";
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
|
||||
|
||||
import Client from "../../../lib/client.js";
|
||||
import logger from "../../../lib/logger.js";
|
||||
import Client from "$lib/client.js";
|
||||
import logger from "$lib/logger.js";
|
||||
|
||||
import replies from "../lib/replies.js";
|
||||
import { controller } from "../module.js";
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
|
||||
|
||||
import Client from "../../../lib/client.js";
|
||||
import logger from "../../../lib/logger.js";
|
||||
import Client from "$lib/client.js";
|
||||
import logger from "$lib/logger.js";
|
||||
|
||||
import replies from "../lib/replies.js";
|
||||
import { controller } from "../module.js";
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
|
||||
|
||||
import Client from "../../../lib/client.js";
|
||||
import logger from "../../../lib/logger.js";
|
||||
import Client from "$lib/client.js";
|
||||
import logger from "$lib/logger.js";
|
||||
|
||||
import replies from "../lib/replies.js";
|
||||
import { controller } from "../module.js";
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
|
||||
|
||||
import Client from "../../../lib/client.js";
|
||||
import logger from "../../../lib/logger.js";
|
||||
import Client from "$lib/client.js";
|
||||
import logger from "$lib/logger.js";
|
||||
|
||||
import replies from "../lib/replies.js";
|
||||
import { controller } from "../module.js";
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
|
||||
|
||||
import Client from "../../../lib/client.js";
|
||||
import logger from "../../../lib/logger.js";
|
||||
import Client from "$lib/client.js";
|
||||
import logger from "$lib/logger.js";
|
||||
|
||||
import replies from "../lib/replies.js";
|
||||
import { controller } from "../module.js";
|
||||
|
||||
@@ -0,0 +1,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 };
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Filter entry.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
type filterValue = {
|
||||
/** Public well formatted name of the filter. */
|
||||
name: string;
|
||||
|
||||
/** FFmpeg argument of this filter. */
|
||||
value: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Record mapping id of filter with its {@link filterValue}.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
const FILTERS: Record<string, filterValue> = {
|
||||
bassboost1x: { name: "Bassboost 1x", value: "bass=g=15:f=110:w=0.3" },
|
||||
bassboost2x: { name: "Bassboost 2x", value: "bass=g=30:f=110:w=0.3" },
|
||||
daycore: { name: "Daycore", value: "aresample=48000,asetrate=48000*0.75" },
|
||||
nightcore: { name: "Nightcore", value: "aresample=48000,asetrate=48000*1.25" },
|
||||
treble: {
|
||||
name: "Treble Boost",
|
||||
value: "firequalizer=gain_entry='entry(2500,3);entry(6300,6);entry(16000,10)'"
|
||||
}
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Type build of {@link FILTERS} keys.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
type filterPreset = keyof typeof FILTERS;
|
||||
|
||||
/**
|
||||
* {@link ./player#Player | Player's } filters options.
|
||||
*
|
||||
* Resolves FFMpeg transcoder arguments that tweak playback stream.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export default class Filter {
|
||||
/**
|
||||
* Filter list
|
||||
*/
|
||||
public static readonly FILTERS = FILTERS;
|
||||
|
||||
/**
|
||||
* Active filters. Mock of {@link FILTERS} object.
|
||||
*/
|
||||
private switches_ = new Map<filterPreset, filterValue>();
|
||||
|
||||
/**
|
||||
* Converts active {@link filterValue | filterValues} to array.
|
||||
*
|
||||
* @returns Array of active filter values.
|
||||
*/
|
||||
public toggled(): filterValue[] {
|
||||
return [...this.switches_.values()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles filter specified. Clears them all if no arguments provided.
|
||||
*
|
||||
* @returns True if filter was toggled, False otherwise.
|
||||
*
|
||||
* @param query - filter id to toggle. Undefined to clear all.
|
||||
*/
|
||||
public toggle(query?: filterPreset): boolean {
|
||||
if (!query) {
|
||||
this.switches_.clear();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!this.switches_.delete(query)) {
|
||||
this.switches_.set(query, FILTERS[query]);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates of active filter map.
|
||||
*
|
||||
* @returns Size of active filter map.
|
||||
*/
|
||||
public size(): number {
|
||||
return this.switches_.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if there are any active filter.
|
||||
*
|
||||
* @returns size === 0 ? True : False.
|
||||
*/
|
||||
public empty(): boolean {
|
||||
return this.size() === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts active filters to FFmpeg compatable argument.
|
||||
*
|
||||
* @returns FFmpeg compatable argument representing active filters.
|
||||
*/
|
||||
public toString(): string {
|
||||
return this.toggled()
|
||||
.map((value) => value.value)
|
||||
.join(",");
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,26 @@ import { AudioResource } from "@discordjs/voice";
|
||||
|
||||
import { LoopOption, Track } from "./types.js";
|
||||
|
||||
/**
|
||||
* 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);
|
||||
@@ -15,6 +33,20 @@ export function statusBarGenerator(resource?: AudioResource<Track>): string {
|
||||
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);
|
||||
@@ -27,19 +59,60 @@ export function toISOTime(amount: number): string {
|
||||
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" ? "⤵️" : "🔂";
|
||||
}
|
||||
|
||||
export function largestCommonSequence(s1: string, s2: string) {
|
||||
/**
|
||||
* Finds larges common sequence (LCS) of characters withing provided strings.
|
||||
*
|
||||
* @remarks Used to fuzzy search query title of Track.
|
||||
*
|
||||
* @returns Length of the LCS found.
|
||||
*
|
||||
* @param s1 - First string.
|
||||
* @param s2 - Second string.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function largestCommonSequence(s1: string, s2: string): number {
|
||||
s1 = s1.toLowerCase().slice(0, 100);
|
||||
s2 = s2.toLowerCase().slice(0, 100);
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Collection, Guild, GuildTextBasedChannel } from "discord.js";
|
||||
|
||||
import logger from "$lib/logger.js";
|
||||
|
||||
import Player from "./player.js";
|
||||
|
||||
/**
|
||||
* Module's controller.
|
||||
*
|
||||
* Binds guilds to player.
|
||||
* Creates and destroys them.
|
||||
*/
|
||||
export default class Music {
|
||||
/**
|
||||
* GuildId to {@link Player} mapping.
|
||||
*/
|
||||
private players_ = new Collection<string, Player>();
|
||||
|
||||
/**
|
||||
* Retrieves {@link Player} by {@link Guild}.
|
||||
*
|
||||
* @returns Player if such mapping exists or undefined otherwise.
|
||||
*
|
||||
* @param guild - Discord guild that might has a Player binded.
|
||||
* @param channel - Text channel that should replace Player's one.
|
||||
*/
|
||||
public get(guild: Guild, channel?: GuildTextBasedChannel): Player | undefined {
|
||||
const player = this.players_.get(guild.id);
|
||||
|
||||
if (player) {
|
||||
if (channel) {
|
||||
player.channel = channel;
|
||||
}
|
||||
|
||||
return player;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates {@link Player} and maps it to GuildId.
|
||||
*
|
||||
* @returns Newly created Player.
|
||||
*
|
||||
* @param guild - {@link Guild} that created Player will be binded to.
|
||||
* @param channel - Text channel that used by Player constructor.
|
||||
*/
|
||||
public set(guild: Guild, channel: GuildTextBasedChannel): Player {
|
||||
const player = new Player(guild, this, channel);
|
||||
|
||||
this.players_.set(guild.id, player);
|
||||
|
||||
logger.info("Player was created.", { player });
|
||||
|
||||
return player;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deleted {@link Player} and its mapping by GuildId.
|
||||
*
|
||||
* @param guildId - Id of {@link Guild} that must be removed from mapping.
|
||||
*/
|
||||
public delete(guildId: string): void {
|
||||
logger.info("Player was deleted.", { guildId });
|
||||
|
||||
this.players_.delete(guildId);
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,9 @@ import {
|
||||
} from "@discordjs/voice";
|
||||
import { Guild, GuildTextBasedChannel, Message, User, VoiceBasedChannel } from "discord.js";
|
||||
|
||||
import { fetchInfo, ytdlStream } from "./fetch.js";
|
||||
import { clamp } from "$lib/misc.js";
|
||||
|
||||
import { dlpInfo, dlpStream } from "./fetch/fetch.js";
|
||||
import Filter from "./filter.js";
|
||||
import { largestCommonSequence } from "./misc.js";
|
||||
import Music from "./music.js";
|
||||
@@ -18,10 +20,33 @@ import Queue from "./queue.js";
|
||||
import replies from "./replies.js";
|
||||
import { Track } from "./types.js";
|
||||
|
||||
/**
|
||||
* Defines guild music player connected to a voice channel by wrapping AudioPlayer, Queue, VoiceConnection, etc.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export default class Player {
|
||||
private music: Music;
|
||||
private guild: Guild;
|
||||
/**
|
||||
* Volume limiter clamp.
|
||||
*/
|
||||
public static readonly VOLUME_CLAMP = clamp(0, 150);
|
||||
|
||||
/**
|
||||
* Module controller orchistrate guild-player mapping.
|
||||
*/
|
||||
private music_: Music;
|
||||
|
||||
/**
|
||||
* {@link Player} owner guild.
|
||||
*
|
||||
* @remarks No different {@link Player} instances can have same {@link Guild}.
|
||||
*/
|
||||
private guild_: Guild;
|
||||
|
||||
/**
|
||||
* {@link Guild | Guild's} text based channel for player notifications,
|
||||
* such as Current Playing Track.
|
||||
*/
|
||||
private channel_: GuildTextBasedChannel;
|
||||
public get channel(): GuildTextBasedChannel {
|
||||
return this.channel_;
|
||||
@@ -30,44 +55,82 @@ export default class Player {
|
||||
this.channel_ = x;
|
||||
}
|
||||
|
||||
/**
|
||||
* Previous notification message.
|
||||
*/
|
||||
private message_?: Message;
|
||||
|
||||
/**
|
||||
* A connection to the voice channel of a {@link Guild}.
|
||||
*
|
||||
* @readonly
|
||||
*/
|
||||
private connection_?: VoiceConnection;
|
||||
public get connection(): VoiceConnection | undefined {
|
||||
return this.connection_;
|
||||
}
|
||||
|
||||
/**
|
||||
* Player for {@link AudioResource} that is subscribed to the most recent {@link VoiceConnection}.
|
||||
*
|
||||
* @readonly
|
||||
*/
|
||||
private player_: AudioPlayer;
|
||||
public get player(): AudioPlayer {
|
||||
return this.player_;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents an Audio Resource that can be played by {@link AudioPlayer}.
|
||||
*
|
||||
* @readonly
|
||||
*/
|
||||
private resource_: AudioResource<Track> | undefined;
|
||||
public get resource(): AudioResource<Track> | undefined {
|
||||
return this.resource_;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline volume for an {@link AudioResource}.
|
||||
* Must be clamped by {@link VOLUME_CLAMP}.
|
||||
*/
|
||||
private volume_ = 100;
|
||||
public get volume(): number {
|
||||
return this.volume_;
|
||||
}
|
||||
public set volume(x: number) {
|
||||
this.volume_ = Math.max(0, Math.min(150, x));
|
||||
this.volume_ = Player.VOLUME_CLAMP(x);
|
||||
|
||||
if (this.resource_ && this.resource_.volume) {
|
||||
if (this.resource_?.volume) {
|
||||
this.resource_.volume.setVolumeLogarithmic(this.volume_ / 100);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue manager of the {@link Player}.
|
||||
*/
|
||||
public readonly queue = new Queue();
|
||||
|
||||
/**
|
||||
* Filter manager of the {@link Player}.
|
||||
*/
|
||||
public readonly filter = new Filter();
|
||||
|
||||
/**
|
||||
* Timestamp when {@link Player} was created.
|
||||
*/
|
||||
public readonly createdAt: Date = new Date();
|
||||
|
||||
/**
|
||||
* Player constructor.
|
||||
*
|
||||
* @param guild - {@link Guild} for which player will be created for
|
||||
* @param music - Module controller for managing player own existence
|
||||
* @param channel - Initial text based channed for player notifications
|
||||
*/
|
||||
public constructor(guild: Guild, music: Music, channel: GuildTextBasedChannel) {
|
||||
this.music = music;
|
||||
this.guild = guild;
|
||||
this.music_ = music;
|
||||
this.guild_ = guild;
|
||||
this.channel_ = channel;
|
||||
|
||||
this.player_ = createAudioPlayer();
|
||||
@@ -75,16 +138,21 @@ export default class Player {
|
||||
this.player_.on(AudioPlayerStatus.Idle, async () => this.resourceEndResolvable());
|
||||
}
|
||||
|
||||
private resourceGenerator(track: Track, seek?: number) {
|
||||
const stream = ytdlStream(track.url, {
|
||||
opusEncoded: true,
|
||||
filter: "audioonly",
|
||||
highWaterMark: 1 << 25,
|
||||
/**
|
||||
* 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,
|
||||
encoderArgs: !this.filter.empty() ? ["-af", this.filter.toString()] : undefined
|
||||
ffmpeg: !this.filter.empty() ? ["-af", this.filter.toString()] : undefined
|
||||
});
|
||||
|
||||
this.resource_ = createAudioResource(stream, {
|
||||
this.resource_ = createAudioResource(probe, {
|
||||
inlineVolume: true,
|
||||
metadata: track
|
||||
});
|
||||
@@ -100,6 +168,13 @@ export default class Player {
|
||||
return this.resource_;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves player behavior after {@link AudioResource} ends.
|
||||
*
|
||||
* @remarks Force destroys ended resource,
|
||||
* and either plays next one or stops,
|
||||
* depending on {@link Queue#next | Queue.next() method}
|
||||
*/
|
||||
private resourceEndResolvable(): void {
|
||||
if (this.resource_) {
|
||||
this.resource_.encoder?.destroy();
|
||||
@@ -113,6 +188,12 @@ export default class Player {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves notification emittion.
|
||||
*
|
||||
* @remarks Tries to delete previous notification message,
|
||||
* and send next one
|
||||
*/
|
||||
private async messageResolvable(track: Track) {
|
||||
if (this.message_?.deletable) {
|
||||
await this.message_.delete().catch();
|
||||
@@ -121,18 +202,27 @@ export default class Player {
|
||||
this.message_ = await this.channel_.send(replies.nowPlaying(track));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches {@link DlpInfo} and converts it to {@link Track | Track metadata}.
|
||||
*
|
||||
* @returns Promise to Track list or undefined, if found nothing.
|
||||
*
|
||||
* @param query - String that Dlp will try to resolve
|
||||
* @param requester - {@link User} that requested Tracks returned to complete convertion
|
||||
*/
|
||||
private async search(query: string, requester: User): Promise<Track[] | undefined> {
|
||||
const info = await fetchInfo(query);
|
||||
|
||||
if (info.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const info = await dlpInfo(query);
|
||||
|
||||
return info.map((value) => {
|
||||
return { ...value, requester: requester };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Plays current {@link Queue} track from position specified.
|
||||
*
|
||||
* @param seek - Amount of seconds to start playback from
|
||||
*/
|
||||
public async play(seek?: number): Promise<void> {
|
||||
const track = this.queue.current();
|
||||
|
||||
@@ -140,13 +230,19 @@ export default class Player {
|
||||
return;
|
||||
}
|
||||
|
||||
this.player_.play(this.resourceGenerator(track, seek));
|
||||
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 &&
|
||||
@@ -168,6 +264,17 @@ export default class Player {
|
||||
this.connection_.subscribe(this.player_);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds {@link Track | Track(s)} found by {@link search | the search method} to the {@link queue}.
|
||||
*
|
||||
* @returns Track metadata if found, undefined otherwise
|
||||
*
|
||||
* @param query - String that will be passed to the search method
|
||||
* @param requester - {@link User} that requested the query
|
||||
* @param index - Position to insert track to
|
||||
* Value < 0 resolves to 0
|
||||
* Value >= (queue size) resolves to (queue end)
|
||||
*/
|
||||
public async add(query: string, requester: User, index?: number): Promise<Track | undefined> {
|
||||
const tracks = await this.search(query, requester);
|
||||
|
||||
@@ -180,6 +287,16 @@ export default class Player {
|
||||
return tracks[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Jumps across {@link queue} to the position determined by the query.
|
||||
*
|
||||
* @remarks If string passed, then position will be determined by
|
||||
* the largest common sequence across all the {@link queue} titles.
|
||||
*
|
||||
* @return Position to which jump was perfomed
|
||||
*
|
||||
* @param query - String of {@link Track} title or position to jump to
|
||||
*/
|
||||
public jump(query: number | string): number | undefined {
|
||||
let position = typeof query === "number" ? query : -1;
|
||||
let diff = 0;
|
||||
@@ -202,6 +319,16 @@ export default class Player {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes {@link Track} from {@link queue | the queue} by the position determined by the query.
|
||||
*
|
||||
* @remarks If string passed, then position will be determined by
|
||||
* the largest common sequence across all the {@link queue} titles.
|
||||
*
|
||||
* @return Metadata of removed track, or undefined if nothing happened
|
||||
*
|
||||
* @param query - String of {@link Track} title or position to perfome removal at
|
||||
*/
|
||||
public remove(query?: number | string): Track | undefined {
|
||||
const oldPosition = this.queue.position;
|
||||
|
||||
@@ -230,10 +357,18 @@ export default class Player {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restarts playback from timecode specified.
|
||||
*
|
||||
* @param time - Amount of seconds to start playback from
|
||||
*/
|
||||
public seek(time: number): void {
|
||||
this.play(time);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears {@link queue} completely - not destroying {@link voiceConnection_}.
|
||||
*/
|
||||
public clear(): void {
|
||||
this.queue.clear();
|
||||
this.resource_ = undefined;
|
||||
@@ -243,15 +378,23 @@ export default class Player {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroys this instance of {@link Player} and deletes it from controller mapping.
|
||||
*/
|
||||
public destroy(): void {
|
||||
if (this.connection_) {
|
||||
this.connection_.destroy();
|
||||
}
|
||||
|
||||
this.player_.stop();
|
||||
this.music.delete(this.guild.id);
|
||||
this.music_.delete(this.guild_.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pauses/unpauses {@link AudioPlayer} playback.
|
||||
*
|
||||
* @returns True if paused, or False if unpaused
|
||||
*/
|
||||
public pause(): boolean {
|
||||
if (this.player_.state.status === AudioPlayerStatus.Paused) {
|
||||
this.player_.unpause();
|
||||
@@ -262,6 +405,9 @@ export default class Player {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reshuffles {@link queue} order - will start new playback from the same position in the queue.
|
||||
*/
|
||||
public shuffle(): void {
|
||||
this.queue.shuffle();
|
||||
|
||||
@@ -271,10 +417,23 @@ export default class Player {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops {@link player_} that will emit {@link resourceEndResolvable}.
|
||||
*
|
||||
* @see {@link resourceEndResolvable} for understanding full behavior
|
||||
*/
|
||||
public skip(): void {
|
||||
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);
|
||||
|
||||
@@ -1,16 +1,32 @@
|
||||
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_;
|
||||
@@ -19,11 +35,21 @@ export default class Queue {
|
||||
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();
|
||||
@@ -46,6 +72,9 @@ export default class Queue {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shuffles queue by using simple Math.random.
|
||||
*/
|
||||
public shuffle(): void {
|
||||
let currentIndex = this.tracks_.length;
|
||||
|
||||
@@ -62,15 +91,23 @@ export default class Queue {
|
||||
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 === undefined || index >= this.tracks_.length) {
|
||||
if (!index || index >= this.tracks_.length) {
|
||||
this.tracks_ = this.tracks_.concat(tracks);
|
||||
|
||||
return this.tracks_[this.tracks_.length - 1];
|
||||
return this.tracks_[this.tracks_.length - tracks.length];
|
||||
}
|
||||
|
||||
this.tracks_.splice(Math.max(0, index), 0, ...tracks);
|
||||
@@ -81,6 +118,13 @@ export default class Queue {
|
||||
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];
|
||||
@@ -94,6 +138,11 @@ export default class Queue {
|
||||
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];
|
||||
@@ -104,14 +153,27 @@ export default class Queue {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns current {@link Track} (at position).
|
||||
*
|
||||
* @returns Track at internal position.
|
||||
*/
|
||||
public current(): Track {
|
||||
return this.tracks_[this.position_];
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if queue is empty.
|
||||
*
|
||||
* @returns True is queue is empty, False otherwise.
|
||||
*/
|
||||
public empty(): boolean {
|
||||
return this.tracks_.length === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears entire queue and sets position to 0.
|
||||
*/
|
||||
public clear(): void {
|
||||
this.tracks_ = [];
|
||||
this.position_ = 0;
|
||||
@@ -1,7 +1,8 @@
|
||||
import { AudioResource } from "@discordjs/voice";
|
||||
import { BaseMessageOptions, EmbedBuilder, italic } from "discord.js";
|
||||
|
||||
import config from "../../../config.js";
|
||||
import config from "$config.js";
|
||||
|
||||
import { loopMoji, statusBarGenerator, toISOTime, volumeMoji } from "./misc.js";
|
||||
import Player from "./player.js";
|
||||
import Queue from "./queue.js";
|
||||
@@ -338,7 +339,7 @@ const replies = {
|
||||
value:
|
||||
player.filter
|
||||
.toggled()
|
||||
.map((value) => value[0])
|
||||
.map((value) => value.name)
|
||||
.join("\n") + "\ndefault",
|
||||
inline: true
|
||||
},
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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/" }]
|
||||
}
|
||||
+28
-35
@@ -1,61 +1,54 @@
|
||||
{
|
||||
"name": "quetza-bot",
|
||||
"version": "4.2.1",
|
||||
"description": "Discord multipurpose bot",
|
||||
"name": "quetza",
|
||||
"version": "2024.3.0",
|
||||
"description": "Awesome Discord bot that performs various duties",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"main": "./dist/src/index.js",
|
||||
"scripts": {
|
||||
"start": "node ./dist/index.js",
|
||||
"build": "pnpm exec tsc --build",
|
||||
"clean": "rm -rf ./dist && pnpm run build",
|
||||
"start": "node ./dist/src/index.js",
|
||||
"build": "./build.sh",
|
||||
"clean": "rm -rf ./dist",
|
||||
"lint": "pnpm exec eslint . && pnpm exec prettier --check .",
|
||||
"format": "pnpm exec eslint --fix . && pnpm exec prettier --write .",
|
||||
"preinstall": "pnpm dlx only-allow pnpm"
|
||||
},
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"discord",
|
||||
"bot",
|
||||
"music"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/unknowableshade/quetza-bot.git"
|
||||
"url": "git+https://github.com/unknowableshade/Quetza.git"
|
||||
},
|
||||
"author": {
|
||||
"name": "unknowableshade",
|
||||
"email": "60275123+unknowableshade@users.noreply.github.com",
|
||||
"url": "https://unknowableshade.ru"
|
||||
"name": "Vladimir Eremin",
|
||||
"email": "personal@ermnvldmr.com",
|
||||
"url": "https://ermnvldmr.com"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/unknowableshade/quetza-bot/issues"
|
||||
"url": "https://github.com/unknowableshade/Quetza/issues"
|
||||
},
|
||||
"readme": "https://github.com/unknowableshade/quetza-bot#readme",
|
||||
"readme": "https://github.com/unknowableshade/Quetza#readme",
|
||||
"engines": {
|
||||
"node": "18.x.x",
|
||||
"node": "20.x.x",
|
||||
"npm": "*"
|
||||
},
|
||||
"dependencies": {
|
||||
"@discordjs/opus": "^0.9.0",
|
||||
"@discordjs/voice": "^0.16.0",
|
||||
"@prisma/client": "5.0.0",
|
||||
"discord.js": "^14.11.0",
|
||||
"@discordjs/voice": "^0.16.1",
|
||||
"discord.js": "^14.14.1",
|
||||
"prism-media": "^1.3.5",
|
||||
"tweetnacl": "^1.0.3",
|
||||
"winston": "^3.10.0",
|
||||
"ytdl-core": "^4.11.4"
|
||||
"winston": "^3.11.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.4.1",
|
||||
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
||||
"@typescript-eslint/parser": "^6.0.0",
|
||||
"eslint": "^8.44.0",
|
||||
"eslint-config-prettier": "^8.8.0",
|
||||
"eslint-plugin-import": "^2.27.5",
|
||||
"eslint-plugin-simple-import-sort": "^10.0.0",
|
||||
"mocha": "^10.2.0",
|
||||
"prettier": "^3.0.0",
|
||||
"prisma": "^5.0.0",
|
||||
"typescript": "^5.1.6"
|
||||
"@types/node": "^20.11.24",
|
||||
"@typescript-eslint/eslint-plugin": "^7.1.0",
|
||||
"@typescript-eslint/parser": "^7.1.0",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-import": "^2.29.1",
|
||||
"eslint-plugin-simple-import-sort": "^12.0.0",
|
||||
"mocha": "^10.3.0",
|
||||
"prettier": "^3.2.5",
|
||||
"tsc-alias": "^1.8.8",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
}
|
||||
Generated
+916
-724
File diff suppressed because it is too large.
Load diff
@@ -1,22 +0,0 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Guild" (
|
||||
"id" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "Guild_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Event" (
|
||||
"objectId" SERIAL NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"Roles" TEXT[],
|
||||
"guildId" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "Event_pkey" PRIMARY KEY ("objectId")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Event_guildId_title_key" ON "Event"("guildId", "title");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Event" ADD CONSTRAINT "Event_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -1,77 +0,0 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- The primary key for the `Event` table will be changed. If it partially fails, the table could be left without primary key constraint.
|
||||
- You are about to drop the column `Roles` on the `Event` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `objectId` on the `Event` table. All the data in the column will be lost.
|
||||
- A unique constraint covering the columns `[title,action,guildId]` on the table `Event` will be added. If there are existing duplicate values, this will fail.
|
||||
- Added the required column `action` to the `Event` table without a default value. This is not possible if the table is not empty.
|
||||
- Added the required column `metadata` to the `Event` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- CreateEnum
|
||||
CREATE TYPE "PenaltyType" AS ENUM ('BAN', 'TIMEOUT', 'WARN');
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "Event_guildId_title_key";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Event" DROP CONSTRAINT "Event_pkey",
|
||||
DROP COLUMN "Roles",
|
||||
DROP COLUMN "objectId",
|
||||
ADD COLUMN "action" TEXT NOT NULL,
|
||||
ADD COLUMN "metadata" JSONB NOT NULL;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Queue" (
|
||||
"title" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"Tracks" JSONB NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"authorId" TEXT NOT NULL,
|
||||
"guildId" TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Member" (
|
||||
"userId" TEXT NOT NULL,
|
||||
"level" INTEGER NOT NULL DEFAULT 0,
|
||||
"guildId" TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Penalty" (
|
||||
"userId" TEXT NOT NULL,
|
||||
"type" "PenaltyType" NOT NULL,
|
||||
"reason" TEXT,
|
||||
"expires" TIMESTAMP(3),
|
||||
"guildId" TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Queue_title_guildId_key" ON "Queue"("title", "guildId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Member_userId_guildId_key" ON "Member"("userId", "guildId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Penalty_type_userId_guildId_key" ON "Penalty"("type", "userId", "guildId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Event_title_action_guildId_key" ON "Event"("title", "action", "guildId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Queue" ADD CONSTRAINT "Queue_userId_guildId_fkey" FOREIGN KEY ("userId", "guildId") REFERENCES "Member"("userId", "guildId") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Queue" ADD CONSTRAINT "Queue_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Member" ADD CONSTRAINT "Member_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Penalty" ADD CONSTRAINT "Penalty_userId_guildId_fkey" FOREIGN KEY ("userId", "guildId") REFERENCES "Member"("userId", "guildId") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Penalty" ADD CONSTRAINT "Penalty_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -1,3 +0,0 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (i.e. Git)
|
||||
provider = "postgresql"
|
||||
@@ -1,68 +0,0 @@
|
||||
// This is your Prisma schema file,
|
||||
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model Guild {
|
||||
id String @id
|
||||
events Event[]
|
||||
members Member[]
|
||||
penalties Penalty[]
|
||||
queues Queue[]
|
||||
}
|
||||
|
||||
model Queue {
|
||||
title String
|
||||
description String?
|
||||
updatedAt DateTime @updatedAt
|
||||
Tracks Json
|
||||
Member Member @relation(fields: [userId, guildId], references: [userId, guildId])
|
||||
userId String
|
||||
authorId String
|
||||
Guild Guild @relation(fields: [guildId], references: [id])
|
||||
guildId String
|
||||
@@unique([title, guildId], name: "queueIdentifier")
|
||||
}
|
||||
|
||||
model Member {
|
||||
userId String
|
||||
level Int @default(0)
|
||||
queues Queue[]
|
||||
Guild Guild @relation(fields: [guildId], references: [id])
|
||||
guildId String
|
||||
penalties Penalty[]
|
||||
@@unique([userId, guildId], name: "memberIdentifier")
|
||||
}
|
||||
|
||||
enum PenaltyType {
|
||||
BAN
|
||||
TIMEOUT
|
||||
WARN
|
||||
}
|
||||
|
||||
model Penalty {
|
||||
Member Member @relation(fields: [userId, guildId], references: [userId, guildId])
|
||||
userId String
|
||||
type PenaltyType
|
||||
reason String?
|
||||
expires DateTime?
|
||||
Guild Guild @relation(fields: [guildId], references: [id])
|
||||
guildId String
|
||||
@@unique([type, userId, guildId], name: "penaltyIdentifier")
|
||||
}
|
||||
|
||||
model Event {
|
||||
title String
|
||||
action String
|
||||
metadata Json
|
||||
Guild Guild @relation(fields: [guildId], references: [id])
|
||||
guildId String
|
||||
@@unique([title, action, guildId], name: "eventIdentifier")
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import { ActivityOptions, ActivityType, ColorResolvable } from "discord.js";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const ROOT = path.dirname(fileURLToPath(import.meta.url));
|
||||
const discordToken = process.env.DISCORD_TOKEN;
|
||||
const modulesDir = path.join(ROOT, "/modules/");
|
||||
const binariesDir = path.join(ROOT, "/../bin/");
|
||||
const activityStatus: ActivityOptions = { type: ActivityType.Watching, name: "over you" };
|
||||
const colors = {
|
||||
default: "#ff6e9b" as ColorResolvable,
|
||||
error: "#b94e59" as ColorResolvable,
|
||||
warning: "#e5925f" as ColorResolvable,
|
||||
info: "#367df6" as ColorResolvable,
|
||||
success: "#83d18e" as ColorResolvable
|
||||
};
|
||||
|
||||
const config = {
|
||||
discordToken,
|
||||
modulesDir,
|
||||
binariesDir,
|
||||
activityStatus,
|
||||
colors
|
||||
};
|
||||
|
||||
export default config;
|
||||
+8
-3
@@ -1,8 +1,13 @@
|
||||
import { GatewayIntentBits } from "discord.js";
|
||||
|
||||
import config from "./config.js";
|
||||
import Client from "./lib/client.js";
|
||||
import config from "$config.js";
|
||||
import Client from "$lib/client.js";
|
||||
|
||||
/**
|
||||
* Ready to go bot instance.
|
||||
*
|
||||
* @remarks Quetza itself.
|
||||
*/
|
||||
const client = new Client({
|
||||
intents: [
|
||||
GatewayIntentBits.Guilds,
|
||||
@@ -11,4 +16,4 @@ const client = new Client({
|
||||
]
|
||||
});
|
||||
|
||||
client.login(config.discordToken);
|
||||
client.login(config.application.token);
|
||||
+108
-67
@@ -1,91 +1,132 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { Client as DiscordClient, ClientOptions, Collection } from "discord.js";
|
||||
import { existsSync, lstatSync, readdirSync } from "fs";
|
||||
import path from "path";
|
||||
import { pathToFileURL } from "url";
|
||||
import { readdirSync } from "fs";
|
||||
|
||||
import config from "../config.js";
|
||||
import { Command, Event, Module, ModuleMetadata } from "./types.js";
|
||||
import config from "$config.js";
|
||||
import { importDir, pathThrough } from "$lib/misc.js";
|
||||
import {
|
||||
ApplicationStatus,
|
||||
Command,
|
||||
CommandBase,
|
||||
Event,
|
||||
EventBase,
|
||||
Module,
|
||||
ModuleBase
|
||||
} from "$lib/types.js";
|
||||
|
||||
const MODULE_ENTRY = "module.js";
|
||||
const MODULE_COMMANDS = "commands";
|
||||
const MODULE_EVENTS = "events";
|
||||
/**
|
||||
* Creating path to {@link Module | Module's} declaration file.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
const toModuleDeclaration = pathThrough([config.path.modules], ["module.js"]);
|
||||
|
||||
export default class Client extends DiscordClient {
|
||||
/**
|
||||
* Creating path to {@link Module | Module's} commands directory.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
const toModuleCommands = pathThrough([config.path.modules], ["commands"]);
|
||||
|
||||
/**
|
||||
* Creating path to {@link Module | Module's} events directory.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
const toModuleEvents = pathThrough([config.path.modules], ["events"]);
|
||||
|
||||
/**
|
||||
* Bot client enchanced with module handling and database access.
|
||||
*
|
||||
* @typeparam T - True if client ready, false otherwise.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export default class Client<T extends boolean = boolean> extends DiscordClient<T> {
|
||||
/**
|
||||
* Mapping command name to its {@link Command | instance}.
|
||||
*/
|
||||
public readonly commands = new Collection<string, Command>();
|
||||
|
||||
public readonly modules = new Collection<string, ModuleMetadata>();
|
||||
/**
|
||||
* Mapping event name to its {@link Event | instance}.
|
||||
*/
|
||||
public readonly events = new Collection<string, Event>();
|
||||
|
||||
public readonly db = new PrismaClient();
|
||||
/**
|
||||
* Mapping module name to its {@link Module | instance}.
|
||||
*/
|
||||
public readonly modules = new Collection<string, Module>();
|
||||
|
||||
/**
|
||||
* Constructs Quetza Client.
|
||||
*
|
||||
* @param options - Discord.js client options
|
||||
*/
|
||||
public constructor(options: ClientOptions) {
|
||||
super(options);
|
||||
|
||||
for (const module of readdirSync(config.modulesDir)) {
|
||||
const modulePath = path.join(config.modulesDir, module);
|
||||
|
||||
if (!lstatSync(modulePath).isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
import(pathToFileURL(path.join(modulePath, MODULE_ENTRY)).toString()).then(
|
||||
(data: Module) => this.loadModule(data)
|
||||
);
|
||||
}
|
||||
readdirSync(config.path.modules).forEach((module) =>
|
||||
this.importModule(
|
||||
toModuleDeclaration(module),
|
||||
toModuleCommands(module),
|
||||
toModuleEvents(module)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private loadModule(data: Module): void {
|
||||
const commands: string[] = [];
|
||||
const events: string[] = [];
|
||||
/**
|
||||
* 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());
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
if (!this.isReady()) {
|
||||
return {
|
||||
applicationId: null,
|
||||
tag: null,
|
||||
modules
|
||||
};
|
||||
}
|
||||
|
||||
importModule(path.join(data.rootDir, MODULE_COMMANDS), (command: Command) => {
|
||||
this.commands.set(command.data.name, command);
|
||||
commands.push(command.data.name);
|
||||
});
|
||||
|
||||
importModule(path.join(data.rootDir, MODULE_EVENTS), (event: Event) => {
|
||||
this.on(
|
||||
event.name,
|
||||
async (...eventee: unknown[]) => await event.execute(this, eventee)
|
||||
);
|
||||
events.push(event.name);
|
||||
});
|
||||
|
||||
this.modules.set(data.name, {
|
||||
...data,
|
||||
commands,
|
||||
events
|
||||
});
|
||||
return {
|
||||
applicationId: this.application.id,
|
||||
tag: this.user.tag,
|
||||
modules
|
||||
};
|
||||
}
|
||||
|
||||
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");
|
||||
/**
|
||||
* 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: [] }))
|
||||
);
|
||||
|
||||
return [`${tag}\n`, "commands:", `${commands}`, "events:", `${events}`].join("\n");
|
||||
importDir<CommandBase>(commands, (command) => {
|
||||
const saved = this.commands.ensure(command.data.name, () => ({ ...command, module }));
|
||||
|
||||
module.commands.push(saved);
|
||||
});
|
||||
|
||||
const clientStatus =
|
||||
this.user && this.application
|
||||
? `✓ ${this.application.id} successfully logged in as ${this.user.tag}.`
|
||||
: "✖ Application is not availiable.";
|
||||
importDir<EventBase>(events, (event) => {
|
||||
const saved = this.events.ensure(event.name, () => ({ ...event, module }));
|
||||
|
||||
return modulesStatus.concat(clientStatus);
|
||||
module.events.push(saved);
|
||||
this.on(event.name, (...eventee: unknown[]) =>
|
||||
saved.execute(this, eventee, module.controller)
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
+24
-11
@@ -1,20 +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({
|
||||
level: "info",
|
||||
levels: winston.config.syslog.levels,
|
||||
format: winston.format.combine(winston.format.timestamp(), winston.format.json()),
|
||||
transports: [
|
||||
new winston.transports.File({ filename: "logs/error.log", level: "error" }),
|
||||
new winston.transports.File({ filename: "logs/combined.log" })
|
||||
new winston.transports.Console({ format: winston.format.simple() }),
|
||||
new winston.transports.File({ filename: errorLog, level: "error" }),
|
||||
new winston.transports.File({ filename: combinedLog })
|
||||
]
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
logger.add(
|
||||
new winston.transports.Console({
|
||||
format: winston.format.simple()
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export default logger;
|
||||
@@ -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
@@ -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;
|
||||
description?: string;
|
||||
author: string;
|
||||
tag: string;
|
||||
|
||||
/** Description of a module. */
|
||||
description: string;
|
||||
|
||||
/** Module controller. */
|
||||
controller?: unknown;
|
||||
rootDir: string;
|
||||
}
|
||||
|
||||
export interface ModuleMetadata extends Module {
|
||||
commands: string[];
|
||||
events: string[];
|
||||
/**
|
||||
* Complete {@link ModuleBase} declaration with commands and events.
|
||||
*
|
||||
* @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;
|
||||
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;
|
||||
execute: (client: Client, eventee: unknown[]) => Promise<void>;
|
||||
|
||||
/** Function that will be executed upon event emitting. */
|
||||
execute: (client: Client, eventee: unknown[], controller?: unknown) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link EventBase} with {@link Module} included.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface Event extends EventBase {
|
||||
/** Module containing this Event. */
|
||||
module: ModuleBase;
|
||||
}
|
||||
|
||||
/**
|
||||
* Application status provides information that may be useful at initialization.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface ApplicationStatus<Ready extends boolean = boolean> {
|
||||
/** Discord's ID of an application. */
|
||||
applicationId: If<Ready, string>;
|
||||
|
||||
/** Tag of a bot user. */
|
||||
tag: If<Ready, string>;
|
||||
|
||||
/** List of each module imported. */
|
||||
modules: Module[];
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { Events, Interaction } from "discord.js";
|
||||
|
||||
import Client from "../../../lib/client.js";
|
||||
import logger from "../../../lib/logger.js";
|
||||
|
||||
async function execute(client: Client, eventee: [Interaction]): Promise<void> {
|
||||
const [interaction] = eventee;
|
||||
|
||||
if (interaction.isChatInputCommand()) {
|
||||
const command = client.commands.get(interaction.commandName);
|
||||
|
||||
if (!command) {
|
||||
logger.error(`No command matching ${interaction.commandName} was found.`, {
|
||||
interaction
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
logger.info(`Interaction ${interaction.commandName} was created.`, interaction);
|
||||
|
||||
await command.execute(client, interaction);
|
||||
} catch (error) {
|
||||
logger.error("Interaction error occured. ", { error, interaction });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const name = Events.InteractionCreate;
|
||||
|
||||
export { execute, name };
|
||||
@@ -1,37 +0,0 @@
|
||||
import { generateDependencyReport } from "@discordjs/voice";
|
||||
import { Events } from "discord.js";
|
||||
|
||||
import config from "../../../config.js";
|
||||
import Client from "../../../lib/client.js";
|
||||
import logger from "../../../lib/logger.js";
|
||||
|
||||
async function execute(client: Client): Promise<void> {
|
||||
if (!client.user || !client.application) {
|
||||
throw new Error("No application or user for it is provided.");
|
||||
}
|
||||
|
||||
const commandData = Array.from(client.commands.values()).map((value) => value.data);
|
||||
|
||||
if (process.env.NODE_ENV === "development" && process.env.TEST_GUILD) {
|
||||
logger.info("Development mode: Commands will be pushed to the test guild!");
|
||||
|
||||
logger.info(generateDependencyReport());
|
||||
|
||||
const guild = await client.guilds.fetch(process.env.TEST_GUILD);
|
||||
guild.commands.set(commandData);
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
logger.info("Production mode: Commands will be pushed to the application!");
|
||||
|
||||
await client.application.commands.set(commandData);
|
||||
}
|
||||
|
||||
client.generateApplicationStatus().forEach((report) => logger.info(report));
|
||||
|
||||
client.user.setActivity(config.activityStatus);
|
||||
}
|
||||
|
||||
const name = Events.ClientReady;
|
||||
|
||||
export { execute, name };
|
||||
@@ -1,11 +0,0 @@
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const name = "core";
|
||||
const description = "Quetza core functionality";
|
||||
const author = "unknowableshade";
|
||||
const tag = "4.2.0";
|
||||
|
||||
const rootDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export { author, description, name, rootDir, tag };
|
||||
@@ -1,36 +0,0 @@
|
||||
import { VoiceState } from "discord.js";
|
||||
|
||||
import Client from "../../../lib/client.js";
|
||||
import { asleep } from "../lib/misc.js";
|
||||
import replies from "../lib/replies.js";
|
||||
import { controller } from "../module.js";
|
||||
|
||||
const WAIT_TIME = 30_000;
|
||||
|
||||
async function execute(client: Client, eventee: [VoiceState]): Promise<void> {
|
||||
const [oldState] = eventee;
|
||||
|
||||
const player = controller.get(oldState.guild);
|
||||
|
||||
if (!player || !oldState.channel || !client.user) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
oldState.channel.members.size === 1 &&
|
||||
oldState.channel.members.first()?.user === client.user
|
||||
) {
|
||||
await asleep(WAIT_TIME);
|
||||
if (
|
||||
oldState.channel.members.size === 1 &&
|
||||
oldState.channel.members.first()?.user === client.user
|
||||
) {
|
||||
await player.channel.send(replies.alone());
|
||||
player.destroy();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const name = "voiceStateUpdate";
|
||||
|
||||
export { execute, name };
|
||||
@@ -1,245 +0,0 @@
|
||||
import { execFile } from "child_process";
|
||||
import path from "path";
|
||||
import prismMedia from "prism-media";
|
||||
import { Duplex, Readable } from "stream";
|
||||
import { promisify } from "util";
|
||||
import ytdl from "ytdl-core";
|
||||
|
||||
import config from "../../../config.js";
|
||||
import logger from "../../../lib/logger.js";
|
||||
import { StreamOptions, YtdlDump, YtdlResult, YtdlStreamOptions } from "./types.js";
|
||||
|
||||
const { opus: Opus, FFmpeg } = prismMedia;
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const SEARCH_DEPTH = 5;
|
||||
const YTDL_EVENTS = [
|
||||
"info",
|
||||
"progress",
|
||||
"abort",
|
||||
"request",
|
||||
"response",
|
||||
"error",
|
||||
"redirect",
|
||||
"retry",
|
||||
"reconnect"
|
||||
];
|
||||
|
||||
function isURL(url: string): boolean {
|
||||
return /^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)$/.test(
|
||||
url
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchInfoRecursive(query: string, depth = 0): Promise<YtdlResult[]> {
|
||||
if (depth === SEARCH_DEPTH) {
|
||||
logger.warn("yt-dlp failed to find anything.", { query });
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
const executable = await execFileAsync(path.join(config.binariesDir, "/yt-dlp"), [
|
||||
query,
|
||||
"-O %(.{id,original_url,title,uploader,duration,extractor_key,thumbnail})j",
|
||||
"-s",
|
||||
"--flat-playlist"
|
||||
]);
|
||||
|
||||
const dump: YtdlDump[] = JSON.parse("[" + executable.stdout.replace(/\n(?=.+)/g, ",") + "]");
|
||||
|
||||
const result: YtdlResult[] = [];
|
||||
|
||||
for (const entry of dump) {
|
||||
if (entry.title && entry.thumbnail && entry.duration && entry.extractor_key === "Youtube") {
|
||||
result.push({
|
||||
url: entry.original_url,
|
||||
title: entry.title,
|
||||
thumbnail: entry.thumbnail,
|
||||
duration: entry.duration
|
||||
});
|
||||
|
||||
logger.info("yt-dlp has found corresponding youtube video.", {
|
||||
result: result[result.length - 1]
|
||||
});
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
let unwind: YtdlResult[];
|
||||
|
||||
if (entry.extractor_key !== "Youtube" && entry.title) {
|
||||
unwind = await fetchInfoRecursive(
|
||||
`ytsearch1:${entry.uploader ? entry.uploader + " - " : ""} ${entry.title}`,
|
||||
depth + 1
|
||||
);
|
||||
} else {
|
||||
unwind = await fetchInfoRecursive(entry.original_url, depth + 1);
|
||||
}
|
||||
|
||||
if (unwind.length !== 0) {
|
||||
result.push(unwind[0]);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function fetchInfo(query: string): Promise<YtdlResult[]> {
|
||||
logger.info("yt-dlp was invoked.", { query });
|
||||
|
||||
try {
|
||||
if (!isURL(query)) {
|
||||
return await fetchInfoRecursive("ytsearch1:" + query);
|
||||
}
|
||||
|
||||
return await fetchInfoRecursive(query);
|
||||
} catch (error) {
|
||||
logger.error("yt-dlp execution invoked error.", { error });
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function ytdlStream(url: string, options?: YtdlStreamOptions) {
|
||||
logger.info("Created ytdl stream.", { url, options });
|
||||
|
||||
let ffmpegArgs: string[] = [
|
||||
"-analyzeduration",
|
||||
"0",
|
||||
"-loglevel",
|
||||
"0",
|
||||
"-f",
|
||||
`${options?.fmt ? options.fmt : "s16le"}`,
|
||||
"-ar",
|
||||
"48000",
|
||||
"-ac",
|
||||
"2"
|
||||
];
|
||||
|
||||
if (options?.seek) {
|
||||
ffmpegArgs.unshift("-ss", options.seek.toString());
|
||||
}
|
||||
|
||||
if (options?.encoderArgs) {
|
||||
ffmpegArgs = ffmpegArgs.concat(options.encoderArgs);
|
||||
}
|
||||
|
||||
const transcoder = new FFmpeg({
|
||||
args: ffmpegArgs
|
||||
});
|
||||
|
||||
const inputStream = ytdl(url, options);
|
||||
const transcoderStream = inputStream.pipe(transcoder);
|
||||
|
||||
if (options && !options.opusEncoded) {
|
||||
YTDL_EVENTS.forEach((event) =>
|
||||
inputStream.on(event, (...args) => transcoderStream.emit(event, ...args))
|
||||
);
|
||||
inputStream.on("error", () => transcoder.destroy());
|
||||
transcoderStream.on("close", () => transcoder.destroy());
|
||||
|
||||
return transcoderStream;
|
||||
}
|
||||
|
||||
const opus = new Opus.Encoder({
|
||||
rate: 48000,
|
||||
channels: 2,
|
||||
frameSize: 960
|
||||
});
|
||||
|
||||
const outputStream = transcoderStream.pipe(opus);
|
||||
|
||||
transcoderStream.on("error", (e) => outputStream.emit("error", e));
|
||||
|
||||
YTDL_EVENTS.forEach((event) =>
|
||||
inputStream.on(event, (...args) => outputStream.emit(event, ...args))
|
||||
);
|
||||
|
||||
outputStream.on("close", () => {
|
||||
transcoder.destroy();
|
||||
opus.destroy();
|
||||
});
|
||||
|
||||
return outputStream;
|
||||
}
|
||||
|
||||
export function arbitraryStream(stream: string | Readable | Duplex, options?: StreamOptions) {
|
||||
logger.info("Created arbitrary stream.", { stream, options });
|
||||
|
||||
let ffmpegArgs: string[];
|
||||
|
||||
if (typeof stream === "string") {
|
||||
ffmpegArgs = [
|
||||
"-reconnect",
|
||||
"1",
|
||||
"-reconnect_streamed",
|
||||
"1",
|
||||
"-reconnect_delay_max",
|
||||
"5",
|
||||
"-i",
|
||||
stream,
|
||||
"-analyzeduration",
|
||||
"0",
|
||||
"-loglevel",
|
||||
"0",
|
||||
"-f",
|
||||
`${options?.fmt ? options.fmt : "s16le"}`,
|
||||
"-ar",
|
||||
"48000",
|
||||
"-ac",
|
||||
"2"
|
||||
];
|
||||
} else {
|
||||
ffmpegArgs = [
|
||||
"-analyzeduration",
|
||||
"0",
|
||||
"-loglevel",
|
||||
"0",
|
||||
"-f",
|
||||
`${options?.fmt ? options.fmt : "s16le"}`,
|
||||
"-ar",
|
||||
"48000",
|
||||
"-ac",
|
||||
"2"
|
||||
];
|
||||
}
|
||||
|
||||
if (options?.seek) {
|
||||
ffmpegArgs.unshift("-ss", options.seek.toString());
|
||||
}
|
||||
|
||||
if (options?.encoderArgs) {
|
||||
ffmpegArgs = ffmpegArgs.concat(options.encoderArgs);
|
||||
}
|
||||
|
||||
let transcoder = new FFmpeg({
|
||||
args: ffmpegArgs
|
||||
});
|
||||
|
||||
if (typeof stream !== "string") {
|
||||
transcoder = stream.pipe(transcoder);
|
||||
|
||||
stream.on("error", () => transcoder.destroy());
|
||||
}
|
||||
|
||||
if (options && !options.opusEncoded) {
|
||||
transcoder.on("close", () => transcoder.destroy());
|
||||
|
||||
return transcoder;
|
||||
}
|
||||
|
||||
const opus = new Opus.Encoder({
|
||||
rate: 48000,
|
||||
channels: 2,
|
||||
frameSize: 960
|
||||
});
|
||||
|
||||
const outputStream = transcoder.pipe(opus);
|
||||
|
||||
outputStream.on("close", () => {
|
||||
transcoder.destroy();
|
||||
opus.destroy();
|
||||
});
|
||||
|
||||
return outputStream;
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
export default class Filter {
|
||||
public static readonly filters = {
|
||||
"Bassboost 1.5": "bass=g=15:f=110:w=0.3",
|
||||
"bassboost 2.0": "bass=g=20:f=110:w=0.3",
|
||||
"bassboost 3.0": "bass=g=30:f=110:w=0.3",
|
||||
"8D": "apulsator=hz=0.09",
|
||||
Daycore: "aresample=48000,asetrate=48000*0.75",
|
||||
Nightcore: "aresample=48000,asetrate=48000*1.25",
|
||||
Phaser: "aphaser=in_gain=0.4",
|
||||
Tremolo: "tremolo",
|
||||
Vibrato: "vibrato=f=6.5",
|
||||
Reverse: "areverse",
|
||||
Treble: "treble=g=5",
|
||||
"Normalizer 1": "dynaudnorm=g=101",
|
||||
"Normalizer 2": "acompressor",
|
||||
"Surround Sound": "surround",
|
||||
Pulsator: "apulsator=hz=1",
|
||||
Subboost: "asubboost",
|
||||
Karaoke: "stereotools=mlev=0.03",
|
||||
Compressor: "compand=points=-80/-105|-62/-80|-15.4/-15.4|0/-12|20/-7.6",
|
||||
Expander:
|
||||
"compand=attacks=0:points=-80/-169|-54/-80|-49.5/-64.6|-41.1/-41.1|-25.8/-15|-10.8/-4.5|0/0|20/8.3",
|
||||
"Soft Limiter": "compand=attacks=0:points=-80/-80|-12.4/-12.4|-6/-8|0/-6.8|20/-2.8",
|
||||
Chorus: "chorus=0.7:0.9:55:0.4:0.25:2",
|
||||
"Fade In": "afade=t=in:ss=0:d=10",
|
||||
Dimming:
|
||||
"afftfilt=\"'real=re * (1-clip((b/nb)*b,0,1))':imag='im * (1-clip((b/nb)*b,0,1))'\"",
|
||||
Earrape: "channelsplit,sidechaingate=level_in=64"
|
||||
};
|
||||
|
||||
private switches_ = new Set<string>();
|
||||
|
||||
public toggled(): [string, string][] {
|
||||
return Object.entries(Filter.filters)
|
||||
.filter((value) => this.switches_.has(value[0]))
|
||||
.map((value) => [value[0], value[1]]);
|
||||
}
|
||||
|
||||
public toggle(query?: string): boolean {
|
||||
if (!query) {
|
||||
this.switches_.clear();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!this.switches_.delete(query) && Object.hasOwn(Filter.filters, query)) {
|
||||
this.switches_.add(query);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public size(): number {
|
||||
return this.switches_.size;
|
||||
}
|
||||
|
||||
public empty(): boolean {
|
||||
return this.size() === 0;
|
||||
}
|
||||
|
||||
public toString(): string {
|
||||
return this.toggled()
|
||||
.map((value) => value[1])
|
||||
.join(",");
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { Collection, Guild, GuildTextBasedChannel } from "discord.js";
|
||||
|
||||
import logger from "../../../lib/logger.js";
|
||||
import Player from "./player.js";
|
||||
|
||||
export default class Music {
|
||||
private players_ = new Collection<string, Player>();
|
||||
|
||||
public get(guild: Guild, channel?: GuildTextBasedChannel): Player | undefined {
|
||||
const player = this.players_.get(guild.id);
|
||||
|
||||
if (player) {
|
||||
if (channel) {
|
||||
player.channel = channel;
|
||||
}
|
||||
|
||||
return player;
|
||||
}
|
||||
}
|
||||
|
||||
public set(guild: Guild, channel: GuildTextBasedChannel): Player {
|
||||
const player = new Player(guild, this, channel);
|
||||
|
||||
this.players_.set(guild.id, player);
|
||||
|
||||
logger.info("Player was created.", { player });
|
||||
|
||||
return player;
|
||||
}
|
||||
|
||||
public delete(guildId: string): void {
|
||||
logger.info("Player was deleted.", { guildId });
|
||||
|
||||
this.players_.delete(guildId);
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { User } from "discord.js";
|
||||
import { downloadOptions } from "ytdl-core";
|
||||
|
||||
export interface Track {
|
||||
url: string;
|
||||
title: string;
|
||||
thumbnail: string;
|
||||
duration: number;
|
||||
requester: User;
|
||||
}
|
||||
|
||||
export type LoopOption = "LOOP" | "SONG" | "NONE" | "AUTO";
|
||||
|
||||
export interface YtdlDump {
|
||||
id: string;
|
||||
original_url: string;
|
||||
title?: string;
|
||||
uploader?: string;
|
||||
duration?: number;
|
||||
extractor_key: string;
|
||||
thumbnail?: string;
|
||||
}
|
||||
|
||||
export interface YtdlResult {
|
||||
url: string;
|
||||
title: string;
|
||||
thumbnail: string;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
export interface YtdlStreamOptions extends downloadOptions {
|
||||
seek?: number;
|
||||
encoderArgs?: string[];
|
||||
fmt?: string;
|
||||
opusEncoded?: boolean;
|
||||
}
|
||||
|
||||
export interface StreamOptions {
|
||||
seek?: number;
|
||||
encoderArgs?: string[];
|
||||
fmt?: string;
|
||||
opusEncoded?: boolean;
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
import Music from "./lib/music.js";
|
||||
|
||||
const name = "music";
|
||||
const description = "Music module";
|
||||
const author = "unknowableshade";
|
||||
const tag = "2.0.0";
|
||||
|
||||
const controller = new Music();
|
||||
|
||||
const rootDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export { author, controller, description, name, rootDir, tag };
|
||||
@@ -1,95 +0,0 @@
|
||||
import {
|
||||
ChatInputCommandInteraction,
|
||||
Events,
|
||||
PermissionFlagsBits,
|
||||
SlashCommandBuilder
|
||||
} from "discord.js";
|
||||
|
||||
import Client from "../../../lib/client.js";
|
||||
import logger from "../../../lib/logger.js";
|
||||
import replies from "../lib/replies.js";
|
||||
|
||||
async function execute(client: Client, interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
if (!interaction.inCachedGuild()) {
|
||||
logger.warn("Interaction rejected.", { interaction });
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const subcommand = interaction.options.getSubcommand();
|
||||
const event = interaction.options.getString("event", true);
|
||||
|
||||
switch (subcommand) {
|
||||
case "role": {
|
||||
const role = interaction.options.getRole("role", true);
|
||||
|
||||
await interaction.deferReply();
|
||||
|
||||
try {
|
||||
await client.db.event.upsert({
|
||||
where: {
|
||||
eventIdentifier: {
|
||||
title: event,
|
||||
action: subcommand,
|
||||
guildId: interaction.guildId
|
||||
}
|
||||
},
|
||||
update: { metadata: [role.id] },
|
||||
create: {
|
||||
Guild: {
|
||||
connectOrCreate: {
|
||||
where: {
|
||||
id: interaction.guildId
|
||||
},
|
||||
create: {
|
||||
id: interaction.guildId
|
||||
}
|
||||
}
|
||||
},
|
||||
title: event,
|
||||
action: subcommand,
|
||||
metadata: [role.id]
|
||||
}
|
||||
});
|
||||
|
||||
await interaction.editReply(replies.auto("add role to a member", event));
|
||||
} catch (error) {
|
||||
logger.error("Error occured while quering db.", { error });
|
||||
} finally {
|
||||
await client.db.$disconnect();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const AUTO_EVENT_CHOICES = [
|
||||
{
|
||||
name: "Member Joins",
|
||||
value: Events.GuildMemberAdd
|
||||
}
|
||||
];
|
||||
|
||||
const data = new SlashCommandBuilder()
|
||||
.setName("auto")
|
||||
.setDescription("Quetza's automatization engine.")
|
||||
.addSubcommand((subcommand) =>
|
||||
subcommand
|
||||
.setName("role")
|
||||
.setDescription("Auto: give role.")
|
||||
.addStringOption((option) =>
|
||||
option
|
||||
.setName("event")
|
||||
.setDescription("Event when perfome an action.")
|
||||
.setChoices(...AUTO_EVENT_CHOICES)
|
||||
.setRequired(true)
|
||||
)
|
||||
.addRoleOption((option) =>
|
||||
option.setName("role").setDescription("Role to grant on event.").setRequired(true)
|
||||
)
|
||||
)
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator)
|
||||
.setDMPermission(false);
|
||||
|
||||
export { data, execute };
|
||||
@@ -1,98 +0,0 @@
|
||||
import { ChatInputCommandInteraction, PermissionFlagsBits, SlashCommandBuilder } from "discord.js";
|
||||
|
||||
import Client from "../../../lib/client.js";
|
||||
import logger from "../../../lib/logger.js";
|
||||
import replies from "../lib/replies.js";
|
||||
|
||||
async function execute(client: Client, interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
if (!interaction.isChatInputCommand() || !interaction.inCachedGuild()) {
|
||||
logger.warn("Interaction rejected.", { interaction });
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const subcommand = interaction.options.getSubcommand();
|
||||
|
||||
switch (subcommand) {
|
||||
case "check": {
|
||||
const user = interaction.options.getUser("member", true);
|
||||
|
||||
const member = await interaction.guild.members.fetch(user).catch(() => undefined);
|
||||
|
||||
if (member) {
|
||||
await interaction.reply(replies.roleList(user.username, member.roles.cache));
|
||||
} else {
|
||||
await interaction.reply(replies.notMember("edited", user));
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
case "edit": {
|
||||
const user = interaction.options.getUser("member", true);
|
||||
const role = interaction.options.getRole("role", true);
|
||||
const reason = interaction.options.getString("reason") ?? undefined;
|
||||
|
||||
const member = await interaction.guild.members.fetch(user).catch(() => undefined);
|
||||
|
||||
if (member) {
|
||||
if (member.roles.cache.some((memberRole) => role.id === memberRole.id)) {
|
||||
await member.roles.remove(role.id, reason);
|
||||
await interaction.reply(
|
||||
replies.roleAction("revoked from", interaction.user, role.name, reason)
|
||||
);
|
||||
} else {
|
||||
await member.roles.add(role.id, reason);
|
||||
await interaction.reply(
|
||||
replies.roleAction("granted to", interaction.user, role.name, reason)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
await interaction.reply(replies.notMember("edited", user));
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
case "list": {
|
||||
const roles = await interaction.guild.roles.fetch();
|
||||
|
||||
await interaction.reply(replies.roleList(interaction.guild.name, roles));
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const data = new SlashCommandBuilder()
|
||||
.setName("role")
|
||||
.setDescription("Quetza's role manager.")
|
||||
.addSubcommand((subcommand) =>
|
||||
subcommand.setName("list").setDescription("Role: list guild's roles.")
|
||||
)
|
||||
.addSubcommand((subcommand) =>
|
||||
subcommand
|
||||
.setName("check")
|
||||
.setDescription("Role: check user roles.")
|
||||
.addUserOption((option) =>
|
||||
option.setName("member").setDescription("Member to check.").setRequired(true)
|
||||
)
|
||||
)
|
||||
.addSubcommand((subcommand) =>
|
||||
subcommand
|
||||
.setName("edit")
|
||||
.setDescription("Role: edit role for a specific user.")
|
||||
.addUserOption((option) =>
|
||||
option.setName("member").setDescription("Member to edit.").setRequired(true)
|
||||
)
|
||||
.addRoleOption((option) =>
|
||||
option.setName("role").setDescription("Member's role to edit.").setRequired(true)
|
||||
)
|
||||
.addStringOption((option) =>
|
||||
option.setName("reason").setDescription("Reason for an action.")
|
||||
)
|
||||
)
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.ManageRoles)
|
||||
.setDMPermission(false);
|
||||
|
||||
export { data, execute };
|
||||
@@ -1,23 +0,0 @@
|
||||
import { Events, GuildMember } from "discord.js";
|
||||
|
||||
import Client from "../../../lib/client.js";
|
||||
|
||||
async function execute(client: Client, eventee: [GuildMember]): Promise<void> {
|
||||
const [member] = eventee;
|
||||
|
||||
try {
|
||||
const event = await client.db.event.findFirst({
|
||||
where: { guildId: member.guild.id, title: name }
|
||||
});
|
||||
|
||||
if (event) {
|
||||
await member.roles.add(event.metadata as string[]);
|
||||
}
|
||||
} finally {
|
||||
await client.db.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
const name = Events.GuildMemberAdd;
|
||||
|
||||
export { execute, name };
|
||||
@@ -1,80 +0,0 @@
|
||||
import {
|
||||
BaseMessageOptions,
|
||||
bold,
|
||||
Collection,
|
||||
EmbedBuilder,
|
||||
InteractionReplyOptions,
|
||||
italic,
|
||||
Role,
|
||||
underscore,
|
||||
User
|
||||
} from "discord.js";
|
||||
|
||||
import config from "../../../config.js";
|
||||
|
||||
const replies = {
|
||||
error: (): InteractionReplyOptions => {
|
||||
const embed = new EmbedBuilder()
|
||||
.setColor(config.colors.error)
|
||||
.setTitle("Internal error occured while resolving interaction.")
|
||||
.setDescription("Submit an issue (bio). Logs were saved.")
|
||||
.setTimestamp();
|
||||
|
||||
return { embeds: [embed], ephemeral: true };
|
||||
},
|
||||
notMember: (action: string, user: User): InteractionReplyOptions => {
|
||||
const embed = new EmbedBuilder()
|
||||
.setColor("Blue")
|
||||
.setTitle(`${user.username} cannot be ${action}`)
|
||||
.setDescription("They're not a guild member.")
|
||||
.setTimestamp();
|
||||
|
||||
return { embeds: [embed], ephemeral: true };
|
||||
},
|
||||
roleAction: (
|
||||
action: string,
|
||||
user: User,
|
||||
roleName: string,
|
||||
reason?: string
|
||||
): InteractionReplyOptions => {
|
||||
const embed = new EmbedBuilder()
|
||||
.setColor(config.colors.success)
|
||||
.setTitle(`Role ${roleName} was ${action} user ${user.username}`)
|
||||
.setDescription(reason ?? "No reason provided.")
|
||||
.setTimestamp();
|
||||
|
||||
return { embeds: [embed], ephemeral: true };
|
||||
},
|
||||
roleList: (observed: string, roles: Collection<string, Role>): InteractionReplyOptions => {
|
||||
const embed = new EmbedBuilder()
|
||||
.setColor(config.colors.success)
|
||||
.setTitle(`${observed} roles:`)
|
||||
.addFields(
|
||||
roles.map((role) => {
|
||||
return {
|
||||
name: role.name,
|
||||
value: italic(role.id)
|
||||
};
|
||||
})
|
||||
)
|
||||
.setTimestamp();
|
||||
|
||||
return { embeds: [embed], ephemeral: true };
|
||||
},
|
||||
auto: (action: string, event: string): BaseMessageOptions => {
|
||||
const embed = new EmbedBuilder()
|
||||
.setColor(config.colors.success)
|
||||
.setTitle(
|
||||
bold(
|
||||
`From now I will ${underscore(italic(action))} when ${underscore(
|
||||
italic(event)
|
||||
)} event happens`
|
||||
)
|
||||
)
|
||||
.setTimestamp();
|
||||
|
||||
return { embeds: [embed] };
|
||||
}
|
||||
};
|
||||
|
||||
export default replies;
|
||||
@@ -1,11 +0,0 @@
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const name = "server";
|
||||
const description = "Server utilities module";
|
||||
const author = "unknowableshade";
|
||||
const tag = "0.0.1";
|
||||
|
||||
const rootDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export { author, description, name, rootDir, tag };
|
||||
@@ -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
@@ -2,18 +2,20 @@
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "node",
|
||||
"outDir": "dist",
|
||||
"removeComments": false,
|
||||
"sourceMap": true,
|
||||
"esModuleInterop": true,
|
||||
|
||||
"target": "ESNext",
|
||||
"lib": ["ESNext"],
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"resolveJsonModule": true,
|
||||
"types": ["node"],
|
||||
|
||||
"outDir": "./dist",
|
||||
"sourceMap": true,
|
||||
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
|
||||
"target": "ES2022",
|
||||
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "src/commands", "src/events"]
|
||||
"references": [{ "path": "./modules/core" }, { "path": "./modules/music" }]
|
||||
}
|
||||
Reference in new issue
Block a user