40 Commits
Author SHA1 Message Date
Codex a9e2db352c ci: migrate workflows and dependencies to Gitea 2026-11-05 23:02:12 +03:00
deytenit 039dfdfa10 Revise README for better organization and information
Updated README to improve structure and clarity.
2026-01-28 21:43:40 +03:00
deytenit 7a3220a3aa Merge pull request #11 from deytenit/copilot/rewrite-ci-workflow-for-image-push
Migrate Docker image publishing to GitHub Container Registry
2026-01-28 21:33:29 +03:00
copilot-swe-agent[bot]anddeytenit de7048a213 Add required permissions for GHCR package write
Co-authored-by: deytenit <60275123+deytenit@users.noreply.github.com>
2026-01-28 18:28:54 +00:00
copilot-swe-agent[bot]anddeytenit 40013b5919 Update CI workflow to push Docker images to ghcr.io/deytenit/quetza
Co-authored-by: deytenit <60275123+deytenit@users.noreply.github.com>
2026-01-28 18:27:33 +00:00
copilot-swe-agent[bot] e77a471f81 Initial plan 2026-01-28 18:26:07 +00:00
deytenit 534cd72444 feat: Add AI module
ollama based ai module for more bot entertaiment
2025-06-13 14:42:08 +03:00
deytenit 63e4d85cbb Update dependencies 2024-11-08 22:29:50 +03:00
deytenit d79713eae9 Add privacy policy 2024-11-08 21:27:12 +03:00
deytenit cfb6930ccc Multiplatform docker images from actions 2024-11-08 21:26:06 +03:00
deytenit 6250ba601f Remove prisma support 2024-03-02 14:49:57 +03:00
deytenit 0bc97f89fd Sync pnpm-lock 2024-03-02 14:47:42 +03:00
deytenit ec34590cf7 Documentation complete 2024-03-02 14:42:53 +03:00
deytenit 8259f76615 Merge branch 'development' of https://github.com/unknowableshade/Quetza into development 2024-03-02 02:05:59 +03:00
deytenit afc6fce555 Documentation of Music & Core (almost done) 2024-03-02 02:04:00 +03:00
deytenit be88d20f31 Update README.md 2024-01-30 14:36:57 +03:00
deytenit 811bc3a3c8 Create LICENSE 2024-01-30 14:36:12 +03:00
deytenit 824abc6533 Update README.md 2024-01-30 14:35:43 +03:00
deytenit 3144dd69b7 Refactoring, comments & overall improvements 2024-01-30 14:31:15 +03:00
deytenit d7421251ba TSC Build System 2024-01-16 19:20:05 +03:00
deytenit 29fcdcc06f Runtume version update 2024-01-16 13:33:58 +03:00
deytenit 6587bcd68e Removing ytdl-core from dependencies 2024-01-13 02:18:43 +03:00
deytenit ef61f7fa0a Modules simplification & music logging 2024-01-07 15:39:17 +03:00
deytenit 4ed9e1c6e0 Update README and branding 2023-12-23 02:51:30 +03:00
deytenit a59faab420 Fix Action Workflow 2023-12-17 19:41:19 +03:00
deytenit a725290293 Merge commit '3290962' into HEAD 2023-12-07 14:29:17 +03:00
deytenit 6a3425e2c3 Update Dependencies 2023-12-07 14:24:56 +03:00
deytenit 3290962c77 Update Dependencies 2023-12-07 14:20:15 +03:00
deytenit ace8170323 Comply to Docker Logging 2023-12-06 13:40:18 +03:00
Vladimir (unknowableshade) Eremin 1374bcc25d Merge branch 'master' of https://github.com/unknowableshade/quetza-bot 2023-07-12 02:57:29 +03:00
Vladimir (unknowableshade) Eremin 757d6e3501 Prettier & CI/CD Fixes 2023-07-12 02:57:24 +03:00
Vladimir (unknowableshade) Eremin b023f7134d Merge branch 'master' of https://github.com/unknowableshade/quetza-bot 2023-07-12 02:57:07 +03:00
Vladimir (unknowableshade) Eremin 9f191e6e80 Prettier & CI/CD Fixes 2023-07-12 02:56:53 +03:00
Vladimir (unknowableshade) Eremin 7ca5041725 Merge branch 'master' of https://github.com/unknowableshade/quetza-bot 2023-07-12 02:22:15 +03:00
Vladimir (unknowableshade) Eremin a045be3f2f Prettier & CI/CD Fixes 2023-07-12 02:16:29 +03:00
Vladimir (unknowableshade) Eremin c569661481 Prettier & CI/CD Fixes 2023-07-12 01:10:12 +03:00
Vladimir (unknowableshade) Eremin e8cd52d315 More Fixes 2023-05-09 18:05:41 +03:00
Vladimir (unknowableshade) Eremin d4d2930f58 Formatting 2023-05-09 17:01:40 +03:00
Vladimir (unknowableshade) Eremin 0f6ad29a3c Bug Fixes 2023-05-09 16:58:37 +03:00
Vladimir Eremin e4397c3f54 Merge pull request #5 from unknowableshade/laboratory
v4.2.0
2023-05-09 04:21:33 +03:00
91 changed files with 5970 additions and 3211 deletions

No files matched your search

+9 -2
View File
@@ -5,12 +5,19 @@
!Dockerfile !Dockerfile
# Source # Source
!config.ts
!src/ !src/
!prisma/ !modules/
# Dependencies # Dependencies
!package.json !package.json
!pnpm-lock.yaml !pnpm-lock.yaml
# Typescript compiller config # Build
!build.sh
!config.tsconfig.json
!tsconfig.json !tsconfig.json
!.ci/
!.ci/vendor/
!.ci/vendor/yt-dlp
-21
View File
@@ -1,21 +0,0 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"parserOptions": {
"warnOnUnsupportedTypeScriptVersion": false,
"sourceType": "module"
},
"env": {
"browser": false,
"node": true,
"es6": true
},
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier"],
"plugins": ["@typescript-eslint", "import", "simple-import-sort"],
"ignorePatterns": ["**/node_modules/**", "/dist/**", "/tests/**"],
"rules": {
"curly": ["error", "all"],
"simple-import-sort/imports": "error",
"simple-import-sort/exports": "error"
}
}
+64
View File
@@ -0,0 +1,64 @@
import json
import os
import re
import subprocess
import sys
from pathlib import Path
def lines(value):
return [line.strip() for line in value.splitlines() if line.strip()]
def output(name, value):
with open(os.environ['GITHUB_OUTPUT'], 'a') as stream:
stream.write(f'{name}<<GITEA_VALUE\n{value}\nGITEA_VALUE\n')
def metadata(images, rules, sha):
result = []
for rule in lines(rules):
fields = dict(part.split('=', 1) for part in rule.split(',') if '=' in part)
if fields.get('enable', 'true').lower() == 'false':
continue
kind = fields.get('type')
if kind == 'sha':
tag = 'sha-' + sha[:7]
elif kind == 'raw':
tag = fields['value']
else:
raise ValueError('Unsupported metadata rule')
if not re.fullmatch(r'[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}', tag):
raise ValueError('Invalid image tag')
result.extend(image.lower() + ':' + tag for image in lines(images))
return result
def main():
mode = sys.argv[1]
if mode == 'login':
subprocess.run(['docker', 'login', os.environ['REGISTRY'], '--username', os.environ['REGISTRY_USER'], '--password-stdin'], input=os.environ['REGISTRY_TOKEN'], text=True, check=True)
elif mode == 'metadata':
output('tags', '\n'.join(metadata(os.environ['IMAGES'], os.environ['TAG_RULES'], os.environ['SOURCE_SHA'])))
output('labels', 'org.opencontainers.image.revision=' + os.environ['SOURCE_SHA'])
elif mode == 'build':
command = ['docker', 'buildx', 'build', '--metadata-file', '/tmp/image-metadata.json']
for env, flag in [('TAGS', '--tag'), ('LABELS', '--label'), ('BUILD_ARGS', '--build-arg')]:
for value in lines(os.environ.get(env, '')):
command.extend([flag, value])
command += ['--label', 'org.opencontainers.image.revision=' + os.environ['SOURCE_SHA'], '--label', 'com.ermnvldmr.ci.run=' + os.environ['RUN_ID']]
if os.environ.get('PLATFORMS'):
command += ['--platform', os.environ['PLATFORMS']]
if os.environ.get('DOCKERFILE'):
command += ['--file', os.environ['DOCKERFILE']]
if os.environ.get('PUSH', 'false').lower() == 'true':
command.append('--push')
command.append(os.environ.get('CONTEXT', '.'))
subprocess.run(command, check=True)
output('digest', json.loads(Path('/tmp/image-metadata.json').read_text())['containerimage.digest'])
else:
raise ValueError('Unknown docker operation')
if __name__ == '__main__':
main()
+145
View File
@@ -0,0 +1,145 @@
name: Continuous Integration
'on':
push:
tags:
- '*'
branches:
- master
jobs:
mocha:
runs-on: apex-debian13
strategy:
matrix:
node-version:
- 22.x
pnpm-version:
- 9
steps:
- name: Check out the repo
run: "set -euo pipefail\ncase \"$CHECKOUT_REF\" in -*) exit 1;; esac\nGITHUB_SERVER_URL=${GITHUB_SERVER_URL%/}\n\
case \"$GITHUB_SERVER_URL\" in https://git.ermnvldmr.com) ;; *) exit 1;; esac\n\
AUTH=$(printf 'deytenit:%s' \"$CHECKOUT_TOKEN\" | base64 -w0)\nHEADER=\"http.${GITHUB_SERVER_URL}/.extraheader=AUTHORIZATION:\
\ basic $AUTH\"\ngit init \"$GITHUB_WORKSPACE\"\ncd \"$GITHUB_WORKSPACE\"\n\
git remote add origin \"$GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git\"\ngit -c\
\ \"$HEADER\" fetch --tags origin '+refs/heads/*:refs/remotes/origin/*'\n\
git -c \"$HEADER\" fetch origin \"$CHECKOUT_REF\"\ngit checkout --detach FETCH_HEAD\n\
test \"$(git rev-parse HEAD)\" = \"$GITHUB_SHA\"\nif [ \"$CHECKOUT_SUBMODULES\"\
\ = recursive ]; then\n key_file=$(mktemp \"${RUNNER_TEMP:-/tmp}/hextra-key.XXXXXX\"\
)\n trap 'rm -f \"$key_file\"' EXIT\n umask 077\n printf '%s\\n'\
\ \"$HEXTRA_READ_KEY\" > \"$key_file\"\n chmod 600 \"$key_file\"\n export\
\ GIT_SSH_COMMAND=\"ssh -T -i $key_file -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes\
\ -o HostKeyAlgorithms=ssh-ed25519 -o UserKnownHostsFile=$GITHUB_WORKSPACE/.gitea/known_hosts\"\
\n git submodule update --init --recursive\nfi\nif [ \"$PERSIST_CREDENTIALS\"\
\ = true ]; then\n git config \"http.${GITHUB_SERVER_URL}/.extraheader\"\
\ \"AUTHORIZATION: basic $AUTH\"\nfi\n"
env:
CHECKOUT_REF: ${{ github.sha }}
CHECKOUT_SUBMODULES: 'false'
CHECKOUT_TOKEN: ${{ github.token }}
PERSIST_CREDENTIALS: 'false'
HEXTRA_READ_KEY: ''
- run: 'case "$PNPM_VERSION" in 8|9|9.12.3) ;; *) exit 1;; esac
test -x "/opt/ci/pnpm/$PNPM_VERSION/bin/pnpm"
echo "/opt/ci/pnpm/$PNPM_VERSION/bin" >> "$GITHUB_PATH"'
env:
PNPM_VERSION: ${{ matrix.pnpm-version }}
- name: Use Node.js ${{ matrix.node-version }}
run: "case \"$NODE_VERSION\" in\n 18*) major=18;; 20*) major=20;; 22*) major=22;;\
\ *) exit 1;;\nesac\ntest -x \"/opt/ci/node/$major/bin/node\"\necho \"/opt/ci/node/$major/bin\"\
\ >> \"$GITHUB_PATH\""
env:
NODE_VERSION: ${{ matrix.node-version }}
- name: Install dependencies
run: pnpm install
- name: ESLint & Prettier
run: pnpm run lint
- name: Build
run: pnpm run build
- name: Test
run: pnpm run --if-present test
env:
npm_config_build_from_source: 'true'
docker:
runs-on: apex-debian13
needs: mocha
permissions:
contents: read
packages: write
steps:
- name: Check out the repo
run: "set -euo pipefail\ncase \"$CHECKOUT_REF\" in -*) exit 1;; esac\nGITHUB_SERVER_URL=${GITHUB_SERVER_URL%/}\n\
case \"$GITHUB_SERVER_URL\" in https://git.ermnvldmr.com) ;; *) exit 1;; esac\n\
AUTH=$(printf 'deytenit:%s' \"$CHECKOUT_TOKEN\" | base64 -w0)\nHEADER=\"http.${GITHUB_SERVER_URL}/.extraheader=AUTHORIZATION:\
\ basic $AUTH\"\ngit init \"$GITHUB_WORKSPACE\"\ncd \"$GITHUB_WORKSPACE\"\n\
git remote add origin \"$GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git\"\ngit -c\
\ \"$HEADER\" fetch --tags origin '+refs/heads/*:refs/remotes/origin/*'\n\
git -c \"$HEADER\" fetch origin \"$CHECKOUT_REF\"\ngit checkout --detach FETCH_HEAD\n\
test \"$(git rev-parse HEAD)\" = \"$GITHUB_SHA\"\nif [ \"$CHECKOUT_SUBMODULES\"\
\ = recursive ]; then\n key_file=$(mktemp \"${RUNNER_TEMP:-/tmp}/hextra-key.XXXXXX\"\
)\n trap 'rm -f \"$key_file\"' EXIT\n umask 077\n printf '%s\\n'\
\ \"$HEXTRA_READ_KEY\" > \"$key_file\"\n chmod 600 \"$key_file\"\n export\
\ GIT_SSH_COMMAND=\"ssh -T -i $key_file -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes\
\ -o HostKeyAlgorithms=ssh-ed25519 -o UserKnownHostsFile=$GITHUB_WORKSPACE/.gitea/known_hosts\"\
\n git submodule update --init --recursive\nfi\nif [ \"$PERSIST_CREDENTIALS\"\
\ = true ]; then\n git config \"http.${GITHUB_SERVER_URL}/.extraheader\"\
\ \"AUTHORIZATION: basic $AUTH\"\nfi\n"
env:
CHECKOUT_REF: ${{ github.sha }}
CHECKOUT_SUBMODULES: 'false'
CHECKOUT_TOKEN: ${{ github.token }}
PERSIST_CREDENTIALS: 'false'
HEXTRA_READ_KEY: ''
- name: Stage verified yt-dlp asset
run: 'mkdir -p .ci/vendor
cp /opt/ci/assets/yt-dlp .ci/vendor/yt-dlp'
- name: Set up QEMU
run: test -r /proc/sys/fs/binfmt_misc/qemu-aarch64
- name: Set up Docker Buildx
run: 'docker buildx version
docker buildx create --name apex-ci --driver docker-container --use
docker buildx inspect --bootstrap'
- name: Log in to GitHub Container Registry
run: python3 .gitea/scripts/docker.py login
env:
REGISTRY: git.ermnvldmr.com
REGISTRY_USER: ${{ github.repository_owner }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
- name: Docker Generate Meta
id: meta
run: python3 .gitea/scripts/docker.py metadata
env:
IMAGES: 'git.ermnvldmr.com/deytenit/quetza
'
TAG_RULES: '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''
}}
'
SOURCE_SHA: ${{ github.sha }}
- name: Build and Push Docker Image
run: python3 .gitea/scripts/docker.py build
env:
TAGS: ${{ steps.meta.outputs.tags }}
LABELS: ${{ steps.meta.outputs.labels }}
BUILD_ARGS: ''
DOCKERFILE: ''
PLATFORMS: linux/amd64,linux/arm64
PUSH: 'true'
CONTEXT: .
SOURCE_SHA: ${{ github.sha }}
RUN_ID: ${{ github.run_id }}
env:
npm_config_build_from_source: 'true'
-68
View File
@@ -1,68 +0,0 @@
name: actions
on:
push:
tags:
- "*"
jobs:
mocha:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18.x]
pnpm-version: [8]
steps:
- name: Check out the repo
uses: actions/checkout@v3
- uses: pnpm/action-setup@v2
with:
version: ${{ matrix.pnpm-version }}
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
- name: Install dependencies
run: pnpm install
- name: ESLint & Prettier
run: pnpm run lint
- name: Build
run: pnpm run build
- name: Test
run: pnpm run --if-present test
docker:
runs-on: ubuntu-latest
needs: mocha
steps:
- name: Check out the repo
uses: actions/checkout@v3
- name: Log in to Docker Hub
uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38
with:
images: unknowableshade/quetza-bot
- name: Build and push Docker image
uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+1 -1
View File
@@ -5,7 +5,7 @@
"printWidth": 100, "printWidth": 100,
"arrowParens": "always", "arrowParens": "always",
"bracketSpacing": true, "bracketSpacing": true,
"endOfLine": "crlf", "endOfLine": "lf",
"overrides": [ "overrides": [
{ {
"files": ["**/*.json, **/*.yml"], "files": ["**/*.json, **/*.yml"],
+28 -26
View File
@@ -1,6 +1,13 @@
FROM node:18-alpine as builder FROM node:20-alpine as builder
RUN npm install -g pnpm RUN apk add --no-cache python3
RUN apk add --no-cache curl
RUN apk add --no-cache make
RUN apk add --no-cache gcc
RUN apk add --no-cache build-base
RUN npm install -g pnpm@9.12.3
ENV npm_config_build_from_source=true
WORKDIR /usr/src/app WORKDIR /usr/src/app
@@ -12,32 +19,27 @@ COPY . .
RUN pnpm run build RUN pnpm run build
FROM node:18-alpine RUN mkdir ./dist/bin/
COPY .ci/vendor/yt-dlp /usr/src/app/dist/bin/yt-dlp
RUN apk update RUN chmod a+rx /usr/src/app/dist/bin/yt-dlp
RUN apk add --no-cache bash
RUN apk add --no-cache curl
RUN apk add --no-cache ffmpeg
RUN apk add --no-cache python3
RUN npm install -g pnpm
ENV NODE_ENV production
USER node
WORKDIR /usr/src/app
RUN mkdir bin
RUN curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /usr/src/app/bin/yt-dlp
RUN chmod a+rx /usr/src/app/bin/yt-dlp
COPY prisma/ .
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --prod RUN pnpm install --prod
RUN pnpx prisma generate
COPY --from=builder /usr/src/app/dist ./dist FROM node:20-alpine
CMD [ "pnpm", "start" ] RUN apk update
RUN apk add --no-cache ffmpeg
RUN apk add --no-cache python3
USER node
ENV NODE_ENV production
WORKDIR /usr/src/app
COPY package.json ./
COPY --from=builder /usr/src/app/node_modules ./node_modules
COPY --from=builder /usr/src/app/dist ./
CMD [ "node", "./src/index.js" ]
-1
View File
@@ -1 +0,0 @@
worker: npm start
+61 -11
View File
@@ -1,16 +1,66 @@
# Quetza 4 ![quetza-logo](./assets/quetza-logo-readme.png)
_Neat typescript discord bot._ # Quetza │ [![Build Status](https://github.com/unknowableshade/quetza/actions/workflows/ci.yml/badge.svg?branch=master&event=push)](https://github.com/unknowableshade/quetza/actions/workflows/ci.yml)
## About Awesome Discord bot that performs various duties on my server.
- Base player features. ## Quick Start
- Play music from YouTube and many other sites.
- Lots of different filters _(nightcore, earrape etc.)_.
- Slash commands.
## Links - [Documentation](<https://docs.ermnvldmr.com/en/quetza/>)
- [Docker Image](<https://ghcr.io/deytenit/quetza>)
- [Report an issue](<https://github.com/deyteit/Quetza/issues>)
- **[Dependencies](https://github.com/unknowableshade/quetza-bot/blob/master/package.json)** ## Who Are You?
- **[Docker](https://hub.docker.com/repository/docker/unknowableshade/quetza-bot)**
- **[Licence](https://github.com/unknowableshade/5a0b-bot/blob/master/LICENSE)** ### Visitor
Just interested in what Quetza can do.
- **See Quetza in Action by running an image**: [ghcr.io: quetza image](https://ghcr.io/deytenit/quetza)
### Developer
Want to run or contribute to the bot.
- **Documentation**: [Docs: Quetza](https://docs.ermnvldmr.com/en/quetza/)
- **Run with Docker** (recommended):
```bash
docker run \
-e DISCORD_TOKEN=<your-token> \
--name quetza -d \
ghcr.io/deyteit/quetza:latest
```
- **Build from Source**:
```bash
pnpm install
pnpm run build
pnpm start
```
- **Environment Variables**:
- `DISCORD_TOKEN`: Generated at [Discord Developer Portal](https://discord.com/developers/applications)
### Designer
Interested in the visual design and messaging.
- **Icons & Branding**: See [assets/](./assets/) directory for logos and visual elements
- **Message Typography**: Explore Discord embeds and message formatting in the `lib/replies.ts` files of the respectful modules.
- **Design License**: Branding assets are separately licensed (see [LICENSE](./assets/LICENSE))
### Contributor
Want to submit improvements?
Just create a Pull-Request, and I would be greatful. (or just fork repo for yourself, then I won't even be needed)
## Communication & Support
- **Issues**: [Github: issues](https://github.com/deyteit/Quetza/issues)
> Not a fact that I would care about that
## License
**Quetza's** source code is licensed under **[the MIT Licence](./LICENSE)**.
**Notice!** This does not cover '**_Quetza_**' branding such as **[logo](./assets/quetza-logo.png)** or other **[assets listed](./assets/)**, as these are protected by **[the separate licence](./assets/LICENSE)**.
+407
View File
@@ -0,0 +1,407 @@
Attribution-NonCommercial 4.0 International
=======================================================================
Creative Commons Corporation ("Creative Commons") is not a law firm and
does not provide legal services or legal advice. Distribution of
Creative Commons public licenses does not create a lawyer-client or
other relationship. Creative Commons makes its licenses and related
information available on an "as-is" basis. Creative Commons gives no
warranties regarding its licenses, any material licensed under their
terms and conditions, or any related information. Creative Commons
disclaims all liability for damages resulting from their use to the
fullest extent possible.
Using Creative Commons Public Licenses
Creative Commons public licenses provide a standard set of terms and
conditions that creators and other rights holders may use to share
original works of authorship and other material subject to copyright
and certain other rights specified in the public license below. The
following considerations are for informational purposes only, are not
exhaustive, and do not form part of our licenses.
Considerations for licensors: Our public licenses are
intended for use by those authorized to give the public
permission to use material in ways otherwise restricted by
copyright and certain other rights. Our licenses are
irrevocable. Licensors should read and understand the terms
and conditions of the license they choose before applying it.
Licensors should also secure all rights necessary before
applying our licenses so that the public can reuse the
material as expected. Licensors should clearly mark any
material not subject to the license. This includes other CC-
licensed material, or material used under an exception or
limitation to copyright. More considerations for licensors:
wiki.creativecommons.org/Considerations_for_licensors
Considerations for the public: By using one of our public
licenses, a licensor grants the public permission to use the
licensed material under specified terms and conditions. If
the licensor's permission is not necessary for any reason--for
example, because of any applicable exception or limitation to
copyright--then that use is not regulated by the license. Our
licenses grant only permissions under copyright and certain
other rights that a licensor has authority to grant. Use of
the licensed material may still be restricted for other
reasons, including because others have copyright or other
rights in the material. A licensor may make special requests,
such as asking that all changes be marked or described.
Although not required by our licenses, you are encouraged to
respect those requests where reasonable. More considerations
for the public:
wiki.creativecommons.org/Considerations_for_licensees
=======================================================================
Creative Commons Attribution-NonCommercial 4.0 International Public
License
By exercising the Licensed Rights (defined below), You accept and agree
to be bound by the terms and conditions of this Creative Commons
Attribution-NonCommercial 4.0 International Public License ("Public
License"). To the extent this Public License may be interpreted as a
contract, You are granted the Licensed Rights in consideration of Your
acceptance of these terms and conditions, and the Licensor grants You
such rights in consideration of benefits the Licensor receives from
making the Licensed Material available under these terms and
conditions.
Section 1 -- Definitions.
a. Adapted Material means material subject to Copyright and Similar
Rights that is derived from or based upon the Licensed Material
and in which the Licensed Material is translated, altered,
arranged, transformed, or otherwise modified in a manner requiring
permission under the Copyright and Similar Rights held by the
Licensor. For purposes of this Public License, where the Licensed
Material is a musical work, performance, or sound recording,
Adapted Material is always produced where the Licensed Material is
synched in timed relation with a moving image.
b. Adapter's License means the license You apply to Your Copyright
and Similar Rights in Your contributions to Adapted Material in
accordance with the terms and conditions of this Public License.
c. Copyright and Similar Rights means copyright and/or similar rights
closely related to copyright including, without limitation,
performance, broadcast, sound recording, and Sui Generis Database
Rights, without regard to how the rights are labeled or
categorized. For purposes of this Public License, the rights
specified in Section 2(b)(1)-(2) are not Copyright and Similar
Rights.
d. Effective Technological Measures means those measures that, in the
absence of proper authority, may not be circumvented under laws
fulfilling obligations under Article 11 of the WIPO Copyright
Treaty adopted on December 20, 1996, and/or similar international
agreements.
e. Exceptions and Limitations means fair use, fair dealing, and/or
any other exception or limitation to Copyright and Similar Rights
that applies to Your use of the Licensed Material.
f. Licensed Material means the artistic or literary work, database,
or other material to which the Licensor applied this Public
License.
g. Licensed Rights means the rights granted to You subject to the
terms and conditions of this Public License, which are limited to
all Copyright and Similar Rights that apply to Your use of the
Licensed Material and that the Licensor has authority to license.
h. Licensor means the individual(s) or entity(ies) granting rights
under this Public License.
i. NonCommercial means not primarily intended for or directed towards
commercial advantage or monetary compensation. For purposes of
this Public License, the exchange of the Licensed Material for
other material subject to Copyright and Similar Rights by digital
file-sharing or similar means is NonCommercial provided there is
no payment of monetary compensation in connection with the
exchange.
j. Share means to provide material to the public by any means or
process that requires permission under the Licensed Rights, such
as reproduction, public display, public performance, distribution,
dissemination, communication, or importation, and to make material
available to the public including in ways that members of the
public may access the material from a place and at a time
individually chosen by them.
k. Sui Generis Database Rights means rights other than copyright
resulting from Directive 96/9/EC of the European Parliament and of
the Council of 11 March 1996 on the legal protection of databases,
as amended and/or succeeded, as well as other essentially
equivalent rights anywhere in the world.
l. You means the individual or entity exercising the Licensed Rights
under this Public License. Your has a corresponding meaning.
Section 2 -- Scope.
a. License grant.
1. Subject to the terms and conditions of this Public License,
the Licensor hereby grants You a worldwide, royalty-free,
non-sublicensable, non-exclusive, irrevocable license to
exercise the Licensed Rights in the Licensed Material to:
a. reproduce and Share the Licensed Material, in whole or
in part, for NonCommercial purposes only; and
b. produce, reproduce, and Share Adapted Material for
NonCommercial purposes only.
2. Exceptions and Limitations. For the avoidance of doubt, where
Exceptions and Limitations apply to Your use, this Public
License does not apply, and You do not need to comply with
its terms and conditions.
3. Term. The term of this Public License is specified in Section
6(a).
4. Media and formats; technical modifications allowed. The
Licensor authorizes You to exercise the Licensed Rights in
all media and formats whether now known or hereafter created,
and to make technical modifications necessary to do so. The
Licensor waives and/or agrees not to assert any right or
authority to forbid You from making technical modifications
necessary to exercise the Licensed Rights, including
technical modifications necessary to circumvent Effective
Technological Measures. For purposes of this Public License,
simply making modifications authorized by this Section 2(a)
(4) never produces Adapted Material.
5. Downstream recipients.
a. Offer from the Licensor -- Licensed Material. Every
recipient of the Licensed Material automatically
receives an offer from the Licensor to exercise the
Licensed Rights under the terms and conditions of this
Public License.
b. No downstream restrictions. You may not offer or impose
any additional or different terms or conditions on, or
apply any Effective Technological Measures to, the
Licensed Material if doing so restricts exercise of the
Licensed Rights by any recipient of the Licensed
Material.
6. No endorsement. Nothing in this Public License constitutes or
may be construed as permission to assert or imply that You
are, or that Your use of the Licensed Material is, connected
with, or sponsored, endorsed, or granted official status by,
the Licensor or others designated to receive attribution as
provided in Section 3(a)(1)(A)(i).
b. Other rights.
1. Moral rights, such as the right of integrity, are not
licensed under this Public License, nor are publicity,
privacy, and/or other similar personality rights; however, to
the extent possible, the Licensor waives and/or agrees not to
assert any such rights held by the Licensor to the limited
extent necessary to allow You to exercise the Licensed
Rights, but not otherwise.
2. Patent and trademark rights are not licensed under this
Public License.
3. To the extent possible, the Licensor waives any right to
collect royalties from You for the exercise of the Licensed
Rights, whether directly or through a collecting society
under any voluntary or waivable statutory or compulsory
licensing scheme. In all other cases the Licensor expressly
reserves any right to collect such royalties, including when
the Licensed Material is used other than for NonCommercial
purposes.
Section 3 -- License Conditions.
Your exercise of the Licensed Rights is expressly made subject to the
following conditions.
a. Attribution.
1. If You Share the Licensed Material (including in modified
form), You must:
a. retain the following if it is supplied by the Licensor
with the Licensed Material:
i. identification of the creator(s) of the Licensed
Material and any others designated to receive
attribution, in any reasonable manner requested by
the Licensor (including by pseudonym if
designated);
ii. a copyright notice;
iii. a notice that refers to this Public License;
iv. a notice that refers to the disclaimer of
warranties;
v. a URI or hyperlink to the Licensed Material to the
extent reasonably practicable;
b. indicate if You modified the Licensed Material and
retain an indication of any previous modifications; and
c. indicate the Licensed Material is licensed under this
Public License, and include the text of, or the URI or
hyperlink to, this Public License.
2. You may satisfy the conditions in Section 3(a)(1) in any
reasonable manner based on the medium, means, and context in
which You Share the Licensed Material. For example, it may be
reasonable to satisfy the conditions by providing a URI or
hyperlink to a resource that includes the required
information.
3. If requested by the Licensor, You must remove any of the
information required by Section 3(a)(1)(A) to the extent
reasonably practicable.
4. If You Share Adapted Material You produce, the Adapter's
License You apply must not prevent recipients of the Adapted
Material from complying with this Public License.
Section 4 -- Sui Generis Database Rights.
Where the Licensed Rights include Sui Generis Database Rights that
apply to Your use of the Licensed Material:
a. for the avoidance of doubt, Section 2(a)(1) grants You the right
to extract, reuse, reproduce, and Share all or a substantial
portion of the contents of the database for NonCommercial purposes
only;
b. if You include all or a substantial portion of the database
contents in a database in which You have Sui Generis Database
Rights, then the database in which You have Sui Generis Database
Rights (but not its individual contents) is Adapted Material; and
c. You must comply with the conditions in Section 3(a) if You Share
all or a substantial portion of the contents of the database.
For the avoidance of doubt, this Section 4 supplements and does not
replace Your obligations under this Public License where the Licensed
Rights include other Copyright and Similar Rights.
Section 5 -- Disclaimer of Warranties and Limitation of Liability.
a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE
EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS
AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF
ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS,
IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION,
WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR
PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS,
ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT
KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT
ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU.
b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE
TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION,
NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT,
INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES,
COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR
USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN
ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR
DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR
IN PART, THIS LIMITATION MAY NOT APPLY TO YOU.
c. The disclaimer of warranties and limitation of liability provided
above shall be interpreted in a manner that, to the extent
possible, most closely approximates an absolute disclaimer and
waiver of all liability.
Section 6 -- Term and Termination.
a. This Public License applies for the term of the Copyright and
Similar Rights licensed here. However, if You fail to comply with
this Public License, then Your rights under this Public License
terminate automatically.
b. Where Your right to use the Licensed Material has terminated under
Section 6(a), it reinstates:
1. automatically as of the date the violation is cured, provided
it is cured within 30 days of Your discovery of the
violation; or
2. upon express reinstatement by the Licensor.
For the avoidance of doubt, this Section 6(b) does not affect any
right the Licensor may have to seek remedies for Your violations
of this Public License.
c. For the avoidance of doubt, the Licensor may also offer the
Licensed Material under separate terms or conditions or stop
distributing the Licensed Material at any time; however, doing so
will not terminate this Public License.
d. Sections 1, 5, 6, 7, and 8 survive termination of this Public
License.
Section 7 -- Other Terms and Conditions.
a. The Licensor shall not be bound by any additional or different
terms or conditions communicated by You unless expressly agreed.
b. Any arrangements, understandings, or agreements regarding the
Licensed Material not stated herein are separate from and
independent of the terms and conditions of this Public License.
Section 8 -- Interpretation.
a. For the avoidance of doubt, this Public License does not, and
shall not be interpreted to, reduce, limit, restrict, or impose
conditions on any use of the Licensed Material that could lawfully
be made without permission under this Public License.
b. To the extent possible, if any provision of this Public License is
deemed unenforceable, it shall be automatically reformed to the
minimum extent necessary to make it enforceable. If the provision
cannot be reformed, it shall be severed from this Public License
without affecting the enforceability of the remaining terms and
conditions.
c. No term or condition of this Public License will be waived and no
failure to comply consented to unless expressly agreed to by the
Licensor.
d. Nothing in this Public License constitutes or may be interpreted
as a limitation upon, or waiver of, any privileges and immunities
that apply to the Licensor or You, including from the legal
processes of any jurisdiction or authority.
=======================================================================
Creative Commons is not a party to its public
licenses. Notwithstanding, Creative Commons may elect to apply one of
its public licenses to material it publishes and in those instances
will be considered the “Licensor.” The text of the Creative Commons
public licenses is dedicated to the public domain under the CC0 Public
Domain Dedication. Except for the limited purpose of indicating that
material is shared under a Creative Commons public license or as
otherwise permitted by the Creative Commons policies published at
creativecommons.org/policies, Creative Commons does not authorize the
use of the trademark "Creative Commons" or any other trademark or logo
of Creative Commons without its prior written consent including,
without limitation, in connection with any unauthorized modifications
to any of its public licenses or any other arrangements,
understandings, or agreements concerning use of licensed material. For
the avoidance of doubt, this paragraph does not form part of the
public licenses.
Creative Commons may be contacted at creativecommons.org.
Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Executable
+9
View File
@@ -0,0 +1,9 @@
#!/bin/sh
# Build typescript
pnpm exec tsc --build
# Map path aliases from tsconfigs
for TS_CONFIG in ./config.tsconfig.json ./src/tsconfig.json ./modules/*/tsconfig.json; do
pnpm exec tsc-alias -p "$TS_CONFIG"
done
+65
View File
@@ -0,0 +1,65 @@
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
};
/** AI module secrets. */
const llama = {
apiUrl: process.env.LLAMA_API_URL,
model: process.env.LLAMA_MODEL
};
/** Exported bundled configuration object. */
const config = {
application,
colors,
path,
dev,
llama
};
export default config;
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "./dist",
"composite": true
},
"include": ["config.ts"]
}
+36
View File
@@ -0,0 +1,36 @@
# Privacy Policy of Quetza
This privacy policy applies to the Discord bot Quetza (hereby referred to as "Quetza")
that was created by Vladimir Eremin (hereby referred to as "Service Provider")
as an Open Source service.
Quetza is intended for use "AS IS",
in compliance with [Discord Privacy Policy](https://discord.com/privacy).
## What information does Quetza store and how is it used?
Quetza does not store any information.
## Do third parties see and/or have access to information obtained by the Quetza?
Since Quetza does not store any information, no data is shared with third parties.
## Changes
This Privacy Policy may be updated from time to time for any reason.
The Service Provider will notify you of any changes to their Privacy Policy
by updating this page with the new Privacy Policy.
You are advised to consult this Privacy Policy regularly for any changes,
as continued use is deemed approval of all changes.
_This privacy policy is effective as of 2024-11-08_
## Your Consent
By using Quetza, you are consenting to the processing of your information
as set forth in this Privacy Policy now and as amended by the Service Provider.
## Contact Us
If you have any questions regarding privacy while using Quetza, or have questions
about the practices, please contact the Service Provider via email at [personal@ermnvldmr.com](mailto:personal@ermnvldmr.com).
+44
View File
@@ -0,0 +1,44 @@
import typescriptEslint from "@typescript-eslint/eslint-plugin";
import globals from "globals";
import tsParser from "@typescript-eslint/parser";
import path from "node:path";
import { fileURLToPath } from "node:url";
import js from "@eslint/js";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
recommendedConfig: js.configs.recommended,
allConfig: js.configs.all
});
export default [
{ ignores: ["**/node_modules/**/*", "dist/**/*", "tests/**/*"] },
...compat.extends("eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier"),
{
plugins: {
"@typescript-eslint": typescriptEslint
},
languageOptions: {
globals: {
...Object.fromEntries(Object.entries(globals.browser).map(([key]) => [key, "off"])),
...globals.node
},
parser: tsParser,
ecmaVersion: "latest",
sourceType: "module",
parserOptions: {
warnOnUnsupportedTypeScriptVersion: false
}
},
rules: {
curly: ["error", "all"]
}
}
];
+79
View File
@@ -0,0 +1,79 @@
/**
* /ask
*
* Sends a prompt to the locally deployed Llama LLM and returns the response.
* Maintains conversation history per user and guild.
*/
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
import Client from "$lib/client.js";
import logger from "$lib/logger.js";
import AI from "../lib/ai.js";
import replies from "../lib/replies.js";
async function execute(
_: Client,
interaction: ChatInputCommandInteraction,
controller: AI
): Promise<void> {
if (!interaction.isChatInputCommand() || !interaction.inCachedGuild()) {
logger.warn("Interaction rejected.", { interaction });
return;
}
const prompt = interaction.options.getString("prompt", true);
await interaction.deferReply();
try {
// Test Llama connection
const isConnected = await controller.llama.testConnection();
if (!isConnected) {
await interaction.editReply(replies.connectionError());
return;
}
// Get or create conversation
const conversation = controller.getConversation(interaction.guildId, interaction.user.id);
// Clear stale conversations
if (conversation.isStale()) {
conversation.clear();
}
// Add user message to conversation
conversation.addUserMessage(prompt);
// Send to Llama
const response = await controller.llama.chat(conversation.getHistory());
// Add assistant response to conversation
conversation.addAssistantMessage(response);
// Reply with the response
await interaction.editReply(replies.response(response));
} catch (error) {
logger.error("Error in ask command", {
error,
userId: interaction.user.id,
guildId: interaction.guildId
});
await interaction.editReply(replies.error());
}
}
const data = new SlashCommandBuilder()
.setName("ask")
.setDescription("Ask a question to the AI assistant")
.addStringOption((option) =>
option
.setName("prompt")
.setDescription("Your question or prompt")
.setRequired(true)
.setMaxLength(2000)
)
.setDMPermission(false);
export { data, execute };
+35
View File
@@ -0,0 +1,35 @@
/**
* /askclear
*
* Clears the conversation history for the current user in the current guild.
*/
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
import Client from "$lib/client.js";
import logger from "$lib/logger.js";
import AI from "../lib/ai.js";
import replies from "../lib/replies.js";
async function execute(
_: Client,
interaction: ChatInputCommandInteraction,
controller: AI
): Promise<void> {
if (!interaction.isChatInputCommand() || !interaction.inCachedGuild()) {
logger.warn("Interaction rejected.", { interaction });
return;
}
controller.clearConversation(interaction.guildId, interaction.user.id);
await interaction.reply(replies.historyCleared());
}
const data = new SlashCommandBuilder()
.setName("askclear")
.setDescription("Clear your conversation history with the AI assistant")
.setDMPermission(false);
export { data, execute };
+49
View File
@@ -0,0 +1,49 @@
import { Collection } from "discord.js";
import { Conversation } from "./conversation.js";
import { LlamaClient } from "./llama.js";
/**
* AI module controller.
*
* Manages conversations and Llama LLM interactions.
*/
export default class AI {
/**
* Llama API client.
*/
public readonly llama = new LlamaClient();
/**
* Guild + User ID to Conversation mapping.
*/
private conversations_ = new Collection<string, Conversation>();
/**
* Gets or creates a conversation for a user in a guild.
*
* @param guildId - Discord guild ID
* @param userId - Discord user ID
* @returns Conversation instance
*/
public getConversation(guildId: string, userId: string): Conversation {
const key = `${guildId}:${userId}`;
if (!this.conversations_.has(key)) {
this.conversations_.set(key, new Conversation(guildId, userId));
}
return this.conversations_.get(key)!;
}
/**
* Clears conversation history for a user in a guild.
*
* @param guildId - Discord guild ID
* @param userId - Discord user ID
*/
public clearConversation(guildId: string, userId: string): void {
const key = `${guildId}:${userId}`;
this.conversations_.delete(key);
}
}
+77
View File
@@ -0,0 +1,77 @@
import { LlamaMessage } from "./llama.js";
/**
* Manages conversation history for a specific user in a guild.
*/
export class Conversation {
public readonly guildId: string;
public readonly userId: string;
public readonly messages: LlamaMessage[] = [];
public readonly createdAt: Date = new Date();
public lastActivity: Date = new Date();
private readonly maxMessages = 50; // Limit conversation history
public constructor(guildId: string, userId: string) {
this.guildId = guildId;
this.userId = userId;
}
/**
* Adds a user message to the conversation.
*
* @param content - User's message content
*/
public addUserMessage(content: string): void {
this.messages.push({ role: "user", content });
this.lastActivity = new Date();
this.trimHistory();
}
/**
* Adds an assistant response to the conversation.
*
* @param content - Assistant's response content
*/
public addAssistantMessage(content: string): void {
this.messages.push({ role: "assistant", content });
this.lastActivity = new Date();
this.trimHistory();
}
/**
* Gets the conversation history.
*
* @returns Array of messages
*/
public getHistory(): LlamaMessage[] {
return [...this.messages];
}
/**
* Clears the conversation history.
*/
public clear(): void {
this.messages.length = 0;
this.lastActivity = new Date();
}
/**
* Trims conversation history to stay within limits.
*/
private trimHistory(): void {
if (this.messages.length > this.maxMessages) {
this.messages.splice(0, this.messages.length - this.maxMessages);
}
}
/**
* Checks if the conversation is stale (inactive for over 1 hour).
*
* @returns True if conversation should be considered stale
*/
public isStale(): boolean {
const oneHour = 60 * 60 * 1000;
return Date.now() - this.lastActivity.getTime() > oneHour;
}
}
+66
View File
@@ -0,0 +1,66 @@
import logger from "$lib/logger.js";
import config from "$config.js";
export interface LlamaResponse {
content: string;
done: boolean;
}
export interface LlamaMessage {
role: "system" | "user" | "assistant";
content: string;
}
/**
* Client for communicating with locally deployed Llama LLM.
*/
export class LlamaClient {
private readonly baseUrl = config.llama.apiUrl || "http://localhost:11434";
private readonly model = config.llama.model || "llama3.2";
/**
* Sends a chat completion request to Llama.
*
* @param messages - Array of conversation messages
* @returns Promise resolving to Llama response
*/
public async chat(messages: LlamaMessage[]): Promise<string> {
try {
const response = await fetch(`${this.baseUrl}/api/chat`, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
model: this.model,
messages,
stream: false
})
});
if (!response.ok) {
throw new Error(`Llama API error: ${response.status} ${response.statusText}`);
}
const data = (await response.json()) as { message: { content: string } };
return data.message.content;
} catch (error) {
logger.error("Failed to communicate with Llama API", { error });
throw new Error("Unable to process your request. Please try again later.");
}
}
/**
* Tests connection to Llama API.
*
* @returns Promise resolving to true if connection is successful
*/
public async testConnection(): Promise<boolean> {
try {
const response = await fetch(`${this.baseUrl}/api/tags`);
return response.ok;
} catch {
return false;
}
}
}
+42
View File
@@ -0,0 +1,42 @@
import { BaseMessageOptions, EmbedBuilder, italic } from "discord.js";
import config from "$config.js";
const replies = {
response: (content: string): BaseMessageOptions => {
return { content };
},
error: (
message: string = "Sorry, I encountered an error processing your request."
): BaseMessageOptions => {
const embed = new EmbedBuilder()
.setColor(config.colors.error || "#ff0000")
.setTitle("⚠️ Error")
.setDescription(italic(message));
return { embeds: [embed] };
},
connectionError: (): BaseMessageOptions => {
const embed = new EmbedBuilder()
.setColor(config.colors.warning || "#ffaa00")
.setTitle("🔌 Connection Issue")
.setDescription(
italic("Unable to connect to the LLAMA service. Please try again later.")
);
return { embeds: [embed] };
},
historyCleared: (): BaseMessageOptions => {
const embed = new EmbedBuilder()
.setColor(config.colors.success || "#00ff00")
.setTitle("🧹 History Cleared")
.setDescription(italic("Your conversation history has been cleared."));
return { embeds: [embed] };
}
};
export default replies;
+17
View File
@@ -0,0 +1,17 @@
/**
* AI module definition.
*
* Module to provide AI conversational capabilities using locally deployed Llama LLM.
* Supports slash commands and reply-based interactions with conversation persistence.
*
* @packageDocumentation
*/
import AI from "./lib/ai.js";
const name = "ai";
const description = "AI conversational module with Llama LLM integration";
const controller = new AI();
export { controller, description, name };
+16
View File
@@ -0,0 +1,16 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"paths": {
"$config.js": ["../../config.js"],
"$lib/*": ["../../src/lib/*"],
"$mlib/*": ["./lib/*"]
},
"rootDir": ".",
"outDir": "../../dist/modules/ai",
"composite": true
},
"references": [{ "path": "../../src/" }]
}
@@ -1,10 +1,19 @@
import { Interaction, SlashCommandBuilder } from "discord.js"; /**
* /modules
*
* Replies module listing as well as description of each.
*
* Just for the information I guess...
*/
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 replies from "../lib/replies.js";
async function execute(client: Client, interaction: Interaction): Promise<void> { async function execute(client: Client, interaction: ChatInputCommandInteraction): Promise<void> {
if (!interaction.isChatInputCommand()) { if (!interaction.isChatInputCommand()) {
logger.warn("Interaction rejected.", { interaction }); logger.warn("Interaction rejected.", { interaction });
@@ -1,13 +1,19 @@
import { Interaction, SlashCommandBuilder } from "discord.js"; /**
* /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.
*/
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
import Client from "$lib/client.js";
import Client from "../../../lib/client.js";
import logger from "../../../lib/logger.js";
import replies from "../lib/replies.js"; import replies from "../lib/replies.js";
async function execute(client: Client, interaction: Interaction): Promise<void> { async function execute(client: Client, interaction: ChatInputCommandInteraction): Promise<void> {
if (!interaction.isChatInputCommand()) { if (!interaction.isChatInputCommand()) {
logger.warn("Interaction rejected.", { interaction });
return; return;
} }
+41
View File
@@ -0,0 +1,41 @@
/**
* InteractionCreate
*
* Resolves creation of an interaction by users.
*
* @remark Only {@link ChatInputCommand} currently being resolved.
*/
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 };
+48
View File
@@ -0,0 +1,48 @@
/**
* Ready
*
* Will execute upon emitting Client's Ready event.
*
* @remarks Will push slash commands, set activity status,
* and log bot's initials.
*/
import { generateDependencyReport } from "@discordjs/voice";
import { Events } from "discord.js";
import config from "$config.js";
import Client from "$lib/client.js";
import logger from "$lib/logger.js";
async function execute(client: Client<true>): Promise<void> {
const commandData = client.commands.map((value) => value.data);
// To not reload bot's global slash commands at every dev build.
if (process.env.NODE_ENV === "development") {
logger.info(
"Development mode: Commands will be pushed to the _test guild_!",
client.generateApplicationStatus()
);
logger.info("Dependency report by '@discordjs/voice'.", {
report: generateDependencyReport()
});
const guild = await client.guilds.fetch(config.dev.guild);
guild.commands.set(commandData);
}
if (process.env.NODE_ENV === "production") {
logger.info(
"Production mode: Commands will be pushed to the _application_!",
client.generateApplicationStatus()
);
await client.application.commands.set(commandData);
}
client.user.setActivity(config.application.activity);
}
const name = Events.ClientReady;
export { execute, name };
@@ -1,20 +1,18 @@
import { BaseMessageOptions, Collection, EmbedBuilder, italic } from "discord.js"; import { BaseMessageOptions, Collection, EmbedBuilder, italic } from "discord.js";
import config from "../../../config.js"; import config from "$config.js";
import { ModuleMetadata } from "../../../lib/types.js"; import { Module } from "$lib/types.js";
const replies = { const replies = {
modules: (modules: Collection<string, ModuleMetadata>): BaseMessageOptions => { modules: (modules: Collection<string, Module>): BaseMessageOptions => {
const embed = new EmbedBuilder() const embed = new EmbedBuilder()
.setColor(config.colors.default) .setColor(config.colors.default)
.setTitle("Applied modules") .setTitle("Avaliable modules")
.addFields( .addFields(
modules.map((value) => { modules.map((value) => ({
return { name: value.name,
name: value.name + "@" + value.tag, value: italic(value.description ?? "")
value: italic(value.description ?? "") }))
};
})
); );
return { embeds: [embed] }; return { embeds: [embed] };
+20
View File
@@ -0,0 +1,20 @@
/**
* Core Quetza Module definition.
*
* This module provides default capabilities.
* - Comands are being resolved here at InteractionCreated event.
* - Aplication status being generated as well as commands pushed to Discord at Ready event.
*
* Also this module provides basic commands:
* - ping
* - modules
*
* There a lot of speaking because it's as important as $lib.
*
* @packageDocumentation
*/
const name = "core";
const description = "Quetza's core functionality";
export { description, name };
+16
View File
@@ -0,0 +1,16 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"paths": {
"$config.js": ["../../config.js"],
"$lib/*": ["../../src/lib/*"],
"$mlib/*": ["./lib/*"]
},
"rootDir": ".",
"outDir": "../../dist/modules/core",
"composite": true
},
"references": [{ "path": "../../src/" }]
}
@@ -1,11 +1,25 @@
import { Interaction, SlashCommandBuilder } from "discord.js"; /**
* /clear
*
* Clears Player queue entirely.
*
* Possible replies:
* - Success with amount of tracks deleted.
* - Player does not exists.
*/
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
import Client from "$lib/client.js";
import logger from "$lib/logger.js";
import Music from "$mlib/music.js";
import Client from "../../../lib/client.js";
import logger from "../../../lib/logger.js";
import replies from "../lib/replies.js"; import replies from "../lib/replies.js";
import { controller } from "../module.js";
async function execute(client: Client, interaction: Interaction): Promise<void> { async function execute(
_: Client,
interaction: ChatInputCommandInteraction,
controller: Music
): Promise<void> {
if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) { if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) {
logger.warn("Interaction rejected.", { interaction }); logger.warn("Interaction rejected.", { interaction });
@@ -1,11 +1,29 @@
import { Interaction, SlashCommandBuilder } from "discord.js"; /**
* /connect
*
* Connects bot to the voice channel.
*
* Just like play but without query.
*
* Possible replies:
* - Success with Voice Channel bot connected to.
* - Requester does not connected to the Voice Channel.
*
* @deprecated Does not make sense.
*/
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
import Client from "$lib/client.js";
import logger from "$lib/logger.js";
import Music from "$mlib/music.js";
import Client from "../../../lib/client.js";
import logger from "../../../lib/logger.js";
import replies from "../lib/replies.js"; import replies from "../lib/replies.js";
import { controller } from "../module.js";
async function execute(client: Client, interaction: Interaction): Promise<void> { async function execute(
_: Client,
interaction: ChatInputCommandInteraction,
controller: Music
): Promise<void> {
if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) { if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) {
logger.warn("Interaction rejected.", { interaction }); logger.warn("Interaction rejected.", { interaction });
@@ -1,12 +1,30 @@
import { Interaction, SlashCommandBuilder } from "discord.js"; /**
* /filter <filter id>
*
* Toggles filter that will be applied to the playback.
* Such as nightcore, etc.
*
* If no filter id was provided clears them all.
*
* Possible replies:
* - Success with chosen filter status or filters being cleared.
* - Player does not exists.
*/
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
import Client from "$lib/client.js";
import logger from "$lib/logger.js";
import Music from "$mlib/music.js";
import Client from "../../../lib/client.js";
import logger from "../../../lib/logger.js";
import Filter from "../lib/filter.js"; import Filter from "../lib/filter.js";
import replies from "../lib/replies.js"; import replies from "../lib/replies.js";
import { controller } from "../module.js";
async function execute(client: Client, interaction: Interaction) { async function execute(
_: Client,
interaction: ChatInputCommandInteraction,
controller: Music
): Promise<void> {
if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) { if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) {
logger.warn("Interaction rejected.", { interaction }); logger.warn("Interaction rejected.", { interaction });
@@ -35,11 +53,7 @@ const data = new SlashCommandBuilder()
option option
.setName("filter") .setName("filter")
.setDescription("Filter to apply.") .setDescription("Filter to apply.")
.setChoices( .setChoices(...Filter.filterChoices())
...Object.keys(Filter.filters).map((key) => {
return { name: key, value: key };
})
)
) )
.setDMPermission(false); .setDMPermission(false);
@@ -1,11 +1,32 @@
import { Interaction, SlashCommandBuilder } from "discord.js"; /**
* /info
*
* Replies with very stylish player info card.
*
* Playback status bar with title, thumbnail and duration.
* Queue length and duration.
* Applied filters.
* Current loop option.
* Player lifetime.
*
* Possible replies:
* - Success with info card.
* - Player does not exists.
*/
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
import Client from "$lib/client.js";
import logger from "$lib/logger.js";
import Music from "$mlib/music.js";
import Client from "../../../lib/client.js";
import logger from "../../../lib/logger.js";
import replies from "../lib/replies.js"; import replies from "../lib/replies.js";
import { controller } from "../module.js";
async function execute(client: Client, interaction: Interaction) { async function execute(
_: Client,
interaction: ChatInputCommandInteraction,
controller: Music
): Promise<void> {
if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) { if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) {
logger.warn("Interaction rejected.", { interaction }); logger.warn("Interaction rejected.", { interaction });
@@ -1,19 +1,34 @@
import { Interaction, SlashCommandBuilder } from "discord.js"; /**
* /insert (query) (position)
*
* Inserts specified track/album to the position specified.
*
* Possible replies:
* - Success with first added track info.
* - Player does not exists.
* - No track was added.
*/
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
import Client from "$lib/client.js";
import logger from "$lib/logger.js";
import Music from "$mlib/music.js";
import Client from "../../../lib/client.js";
import logger from "../../../lib/logger.js";
import replies from "../lib/replies.js"; import replies from "../lib/replies.js";
import { controller } from "../module.js";
async function execute(client: Client, interaction: Interaction) { async function execute(
_: Client,
interaction: ChatInputCommandInteraction,
controller: Music
): Promise<void> {
if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) { if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) {
logger.warn("Interaction rejected.", { interaction }); logger.warn("Interaction rejected.", { interaction });
return; return;
} }
const pos = interaction.options.getNumber("position", true);
const query = interaction.options.getString("query", true); const query = interaction.options.getString("query", true);
const pos = interaction.options.getInteger("position", true);
await interaction.deferReply(); await interaction.deferReply();
@@ -1,11 +1,29 @@
import { Interaction, SlashCommandBuilder } from "discord.js"; /**
* /jump (query)
*
* Jumps to the specified Track from the queue.
* If number was provided - resolves as position at where queue position should be set.
* If string was provided - jumps to the most similar if terms of {@link ../lib/misc#LargestCommonSequence} Track title.
*
* Possible replies:
* - Success with selected track data.
* - Such track does not exists, though cannot be jumped to.
* - Player does not exists.
*/
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
import Client from "$lib/client.js";
import logger from "$lib/logger.js";
import Music from "$mlib/music.js";
import Client from "../../../lib/client.js";
import logger from "../../../lib/logger.js";
import replies from "../lib/replies.js"; import replies from "../lib/replies.js";
import { controller } from "../module.js";
async function execute(client: Client, interaction: Interaction) { async function execute(
_: Client,
interaction: ChatInputCommandInteraction,
controller: Music
): Promise<void> {
if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) { if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) {
logger.warn("Interaction rejected.", { interaction }); logger.warn("Interaction rejected.", { interaction });
@@ -1,12 +1,29 @@
import { Interaction, SlashCommandBuilder } from "discord.js"; /**
* /loop (option)
*
* Changes loop option.
*
* @remarks
* @see {@link ../lib/types} for LoopOption definition.
*
* Possible replies:
* - Success with new loop option.
* - Player does not exists.
*/
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
import Client from "$lib/client.js";
import logger from "$lib/logger.js";
import Music from "$mlib/music.js";
import Client from "../../../lib/client.js";
import logger from "../../../lib/logger.js";
import replies from "../lib/replies.js"; import replies from "../lib/replies.js";
import { LoopOption } from "../lib/types.js"; import { LoopOption } from "../lib/types.js";
import { controller } from "../module.js";
async function execute(client: Client, interaction: Interaction) { async function execute(
_: Client,
interaction: ChatInputCommandInteraction,
controller: Music
): Promise<void> {
if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) { if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) {
logger.warn("Interaction rejected.", { interaction }); logger.warn("Interaction rejected.", { interaction });
@@ -1,11 +1,25 @@
import { Interaction, SlashCommandBuilder } from "discord.js"; /**
* /next
*
* Skips current track.
*
* Possible replies:
* - Success with confirmation of skip. (even there is nothing to skip)
* - Player does not exists.
*/
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
import Client from "$lib/client.js";
import logger from "$lib/logger.js";
import Music from "$mlib/music.js";
import Client from "../../../lib/client.js";
import logger from "../../../lib/logger.js";
import replies from "../lib/replies.js"; import replies from "../lib/replies.js";
import { controller } from "../module.js";
async function execute(client: Client, interaction: Interaction) { async function execute(
_: Client,
interaction: ChatInputCommandInteraction,
controller: Music
): Promise<void> {
if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) { if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) {
logger.warn("Interaction rejected.", { interaction }); logger.warn("Interaction rejected.", { interaction });
@@ -1,11 +1,25 @@
import { Interaction, SlashCommandBuilder } from "discord.js"; /**
* /pause
*
* Toggles playback pause.
*
* Possible replies:
* - Success with setted state (paused, resumed).
* - Player does not exists.
*/
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
import Client from "$lib/client.js";
import logger from "$lib/logger.js";
import Music from "$mlib/music.js";
import Client from "../../../lib/client.js";
import logger from "../../../lib/logger.js";
import replies from "../lib/replies.js"; import replies from "../lib/replies.js";
import { controller } from "../module.js";
async function execute(client: Client, interaction: Interaction) { async function execute(
_: Client,
interaction: ChatInputCommandInteraction,
controller: Music
): Promise<void> {
if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) { if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) {
logger.warn("Interaction rejected.", { interaction }); logger.warn("Interaction rejected.", { interaction });
@@ -1,11 +1,27 @@
import { Interaction, SlashCommandBuilder } from "discord.js"; /**
* /play <query>
*
* Starts up the Player.
* If query provided searches corresponding track/album and adds it to the queue end.
*
* Possible replies:
* - Success with first added track info.
* - Denies if requester is not connected to the Voice Channel.
* - No track was added, but player was set.
*/
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
import Client from "$lib/client.js";
import logger from "$lib/logger.js";
import Music from "$mlib/music.js";
import Client from "../../../lib/client.js";
import logger from "../../../lib/logger.js";
import replies from "../lib/replies.js"; import replies from "../lib/replies.js";
import { controller } from "../module.js";
async function execute(client: Client, interaction: Interaction) { async function execute(
_: Client,
interaction: ChatInputCommandInteraction,
controller: Music
): Promise<void> {
if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) { if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) {
logger.warn("Interaction rejected.", { interaction }); logger.warn("Interaction rejected.", { interaction });
@@ -43,7 +59,7 @@ async function execute(client: Client, interaction: Interaction) {
} }
if (!player.resource) { if (!player.resource) {
player.play(); await player.play();
} }
} }
@@ -1,19 +1,41 @@
/**
* /queue
*
* Replies interactive queue with current playing track at the top and 10 tracks from queue on each page.
* Interactiveness provided by buttons:
* - First page.
* - Previous page.
* - Next page.
* - Last page.
*
* Buttons live up to 30 seconds.
*
* Possible replies:
* - Success with interactive queue.
* - Player does not exists.
*/
import { import {
ActionRowBuilder, ActionRowBuilder,
ButtonBuilder, ButtonBuilder,
ButtonInteraction, ButtonInteraction,
ButtonStyle, ButtonStyle,
Interaction, ChatInputCommandInteraction,
MessageActionRowComponentBuilder, MessageActionRowComponentBuilder,
SlashCommandBuilder SlashCommandBuilder
} from "discord.js"; } from "discord.js";
import Client from "../../../lib/client.js"; import Client from "$lib/client.js";
import logger from "../../../lib/logger.js"; import logger from "$lib/logger.js";
import replies from "../lib/replies.js"; import Music from "$mlib/music.js";
import { controller } from "../module.js";
async function execute(client: Client, interaction: Interaction) { import replies from "../lib/replies.js";
async function execute(
_: Client,
interaction: ChatInputCommandInteraction,
controller: Music
): Promise<void> {
if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) { if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) {
logger.warn("Interaction rejected.", { interaction }); logger.warn("Interaction rejected.", { interaction });
@@ -1,11 +1,28 @@
import { Interaction, SlashCommandBuilder } from "discord.js"; /**
* /remove (query)
*
* Removes Track from the queue.
* If number was provided - resolves as position at where deletion should occurr.
* If string was provided - removes most similar if terms of {@link ../lib/misc#LargestCommonSequence} Track title.
*
* Possible replies:
* - Success with removed track data.
* - Such track does not exists, though cannot be removed.
* - Player does not exists.
*/
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
import Client from "$lib/client.js";
import logger from "$lib/logger.js";
import Music from "$mlib/music.js";
import Client from "../../../lib/client.js";
import logger from "../../../lib/logger.js";
import replies from "../lib/replies.js"; import replies from "../lib/replies.js";
import { controller } from "../module.js";
async function execute(client: Client, interaction: Interaction) { async function execute(
_: Client,
interaction: ChatInputCommandInteraction,
controller: Music
): Promise<void> {
if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) { if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) {
logger.warn("Interaction rejected.", { interaction }); logger.warn("Interaction rejected.", { interaction });
@@ -1,11 +1,26 @@
import { Interaction, SlashCommandBuilder } from "discord.js"; /**
* /reshuffle
* Shuffles the queue, so real order is changed.
* DO NOT Confuse it with AUTO LoopOption.
*
* Possible replies:
* - Success with queue being reshuffled.
* - Player does not exists.
*/
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
import Client from "$lib/client.js";
import logger from "$lib/logger.js";
import Music from "$mlib/music.js";
import Client from "../../../lib/client.js";
import logger from "../../../lib/logger.js";
import replies from "../lib/replies.js"; import replies from "../lib/replies.js";
import { controller } from "../module.js";
async function execute(client: Client, interaction: Interaction) { async function execute(
_: Client,
interaction: ChatInputCommandInteraction,
controller: Music
): Promise<void> {
if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) { if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) {
logger.warn("Interaction rejected.", { interaction }); logger.warn("Interaction rejected.", { interaction });
@@ -1,11 +1,31 @@
import { Interaction, SlashCommandBuilder } from "discord.js"; /**
* /seek <hrs> <mins> <secs>
*
* Changes current Track playback duration to value given.
*
* @remarks
* If not stated - default value for all imputs is 0.
* Input resolution is defined by FFmpeg.
*
* Possible replies:
* - Success with previous and new playback duration.
* - Player does not exists.
* - No resource is currently playing.
*/
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
import Client from "$lib/client.js";
import logger from "$lib/logger.js";
import Music from "$mlib/music.js";
import Client from "../../../lib/client.js";
import logger from "../../../lib/logger.js";
import replies from "../lib/replies.js"; import replies from "../lib/replies.js";
import { controller } from "../module.js";
async function execute(client: Client, interaction: Interaction) { async function execute(
_: Client,
interaction: ChatInputCommandInteraction,
controller: Music
): Promise<void> {
if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) { if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) {
logger.warn("Interaction rejected.", { interaction }); logger.warn("Interaction rejected.", { interaction });
@@ -1,11 +1,27 @@
import { Interaction, SlashCommandBuilder } from "discord.js"; /**
* /stop
*
* Destroys player of the Guild where it was emitted.
* Also disconnects user from Voice Channel Quetza was in.
*
* Possible replies:
* - Success with player being destroyed.
* - Player does not exists.
*/
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
import Client from "$lib/client.js";
import logger from "$lib/logger.js";
import Music from "$mlib/music.js";
import Client from "../../../lib/client.js";
import logger from "../../../lib/logger.js";
import replies from "../lib/replies.js"; import replies from "../lib/replies.js";
import { controller } from "../module.js";
async function execute(client: Client, interaction: Interaction) { async function execute(
_: Client,
interaction: ChatInputCommandInteraction,
controller: Music
): Promise<void> {
if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) { if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) {
logger.warn("Interaction rejected.", { interaction }); logger.warn("Interaction rejected.", { interaction });
@@ -1,18 +1,34 @@
import { Interaction, SlashCommandBuilder } from "discord.js"; /**
* /volume (amount)
*
* Sets volume of {@link ../lib/player#Player | Player} in percent scale,
* clamping it in range defined in Player.
*
* Possible replies:
* - Success with new volume value.
* - Player does not exists.
*/
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
import Client from "$lib/client.js";
import logger from "$lib/logger.js";
import Music from "$mlib/music.js";
import Client from "../../../lib/client.js";
import logger from "../../../lib/logger.js";
import replies from "../lib/replies.js"; import replies from "../lib/replies.js";
import { controller } from "../module.js";
async function execute(client: Client, interaction: Interaction) { async function execute(
_: Client,
interaction: ChatInputCommandInteraction,
controller: Music
): Promise<void> {
if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) { if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) {
logger.warn("Interaction rejected.", { interaction }); logger.warn("Interaction rejected.", { interaction });
return; return;
} }
const volume = interaction.options.getInteger("volume") ?? 100; const volume = interaction.options.getInteger("volume", true);
const player = controller.get(interaction.guild, interaction.channel); const player = controller.get(interaction.guild, interaction.channel);
@@ -0,0 +1,64 @@
/**
* voiceStateUpdate
*
* Monitors voice states of attended guilds.
* If Quetza happens to be alone in the Voice Channel - destroys guilds player and disconnects.
*/
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 };
+13
View File
@@ -0,0 +1,13 @@
/**
* @see ../../../src/lib/error.ts
*/
import QuetzaError from "$lib/error.js";
export default class MusicError extends QuetzaError {
public static readonly PREFIX = "MZ";
constructor(msg: string, code: number) {
super(msg, code, MusicError.PREFIX);
}
}
+75
View File
@@ -0,0 +1,75 @@
import { DlpDump, dlpExtractorKey, DlpInfo } from "$mlib/types.js";
/**
* Avaliable extractors.
*
* @internal
*/
export const dlpExtractorsValues = ["Youtube", "Soundcloud"] as const;
/**
* Defines structure for an extractor that will be called by dlpInfo.
*
* For example:
*
* @see the {@link youtubeExtractor | youtubeExtractor(...) } function
*
* @internal
*/
type dlpExtractor = (arg0: DlpDump, arg1: (arg0: string) => Promise<DlpInfo>) => Promise<DlpInfo>;
/**
* Resolves data that was extracted by dlp (depending on extractor_key).
*
* @returns DlpInfo generated from dump provided
*
* @param dump - Parsed dlp output
* @param dlpInfo - Function to call if any other information needed
* (will recurcively call next extractor after its run)
*
* @public
*/
const youtubeExtractor: dlpExtractor = async (dump, dlpInfo) => {
if (dump.id && dump.title && dump.duration) {
const result = {
url: `https://www.youtube.com/watch?v=${dump.id}`,
title: dump.title,
thumbnail: `https://img.youtube.com/vi/${dump.id}/default.jpg`,
duration: dump.duration
};
return [result];
}
return await dlpInfo(dump.original_url);
};
/**
* {@inheritdoc youtubeExtractor}
*/
const soundcloudExtractor: dlpExtractor = async (dump, dlpInfo) => {
if (dump.title && dump.duration && dump.thumbnail) {
const result = {
url: dump.webpage_url ?? dump.original_url,
title: dump.title,
thumbnail: dump.thumbnail,
duration: dump.duration
};
return [result];
}
return await dlpInfo(dump.original_url);
};
/**
* DlpDump extractor_key values mapped to corresponding extractor.
*
* @public
*/
const extractors: Record<dlpExtractorKey, dlpExtractor> = {
Youtube: youtubeExtractor,
Soundcloud: soundcloudExtractor
};
export default extractors;
+199
View File
@@ -0,0 +1,199 @@
import { execFile } from "child_process";
import path from "path";
import prismMedia from "prism-media";
import { Readable } from "stream";
import { promisify } from "util";
import config from "$config.js";
import logger from "$lib/logger.js";
import MusicError from "$mlib/MusicError.js";
import { DlpDump, DlpInfo, dlpStreamOptions } from "../types.js";
import extractors from "./extractors.js";
const { FFmpeg } = prismMedia;
const execFileAsync = promisify(execFile);
/**
* Maximum length of dlp search stack.
*
* @internal
*/
const DLPINFO_DEPTH = 3;
/**
* Path to the Dlp binary.
*
* @internal
*/
const DLP_BINARY = path.join(config.path.binaries, "/yt-dlp");
/**
* Base arguments for Ffmpeg transcoder.
*
* @internal
*/
const FFMPEG_BASE_ARGS = [
"-analyzeduration",
"0",
"-loglevel",
"0",
"-acodec",
"libopus",
"-f",
"opus",
"-ar",
"48000",
"-ac",
"2"
];
/**
* Predicate to check whether is string a valid URL.
*
* @returns true if given string is a valid URL, false otherwise
*
* @param str - Any string
*
* @internal
*/
function isURL(str: string): boolean {
return /^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)$/.test(
str
);
}
/**
* Sub-function of dlpInfo.
*
* @tutorial Shall be used only inside original function
*
* @returns Info of the media that related to query, and ones that cannot be found
*
* @param query - Dlp search request or compatible URL
* @param depth - Recursion depth (force stops at 0)
*
* @internal
*/
async function dlpInfoRecursive(query: string, depth = DLPINFO_DEPTH): Promise<DlpInfo> {
const error = (message: string, reason: number, ...meta: unknown[]): DlpInfo => {
logger.warn(message, { reason, query, ...meta });
throw new MusicError(message, reason);
};
if (!depth) {
return error("Dlp: recursion depth reached", 1010);
}
const subprocess = await execFileAsync(DLP_BINARY, [
query,
"-O",
"%(.{id,original_url,title,uploader,duration,extractor_key,thumbnail})j",
"-s",
"--flat-playlist"
]);
if (!subprocess.stdout) {
return error("Dlp: no stdout", 1011);
}
if (subprocess.stderr) {
return error("Dlp: stderr", 1012, {
stderr: subprocess.stderr
});
}
const dumps: DlpDump[] = subprocess.stdout
.trim()
.split("\n")
.map((out) => JSON.parse(out));
const resolveDump = async (dump: DlpDump): Promise<DlpInfo> => {
if (!Object.hasOwn(extractors, dump.extractor_key)) {
error("Dlp: illegal extractor", 1013);
}
return extractors[dump.extractor_key](dump, (subquery: string) =>
dlpInfoRecursive(subquery, depth - 1)
);
};
const result = await Promise.all(dumps.map(resolveDump));
return result.flat();
}
/**
* Leverages dlp to fetch metadata of media accessible by the extractors.
*
* @returns Metadata to a youtube video that related to query by title or URL of findings
*
* @param query - Dlp search request or compatible URL
*
* @public
*/
export async function dlpInfo(query: string): Promise<DlpInfo> {
return await dlpInfoRecursive(isURL(query) ? query : "ytsearch1:" + query);
}
/**
* Creates Opus stream by downloading media via dlp.
*
* @returns Readable stream of requested media
*
* @param url - Dlp compatible URL to the requested resource
* @param options - Transcoders and dlp options
*
* @public
*/
export function dlpStream(url: string, options?: dlpStreamOptions): Promise<Readable> {
let ffmpegArgs = FFMPEG_BASE_ARGS;
if (options?.seek) {
ffmpegArgs.unshift("-ss", options.seek.toString());
}
if (options?.ffmpeg) {
ffmpegArgs = ffmpegArgs.concat(options.ffmpeg);
}
return new Promise<Readable>((resolve, reject) => {
const subprocess = execFile(
DLP_BINARY,
[
url,
"-o",
"-",
"--verbose",
"-f",
"bestaudio[ext=webm][acodec=opus][asr=48000]/bestaudio",
"--rm-cache-dir",
"-r",
"1M"
],
{ encoding: null, maxBuffer: 25000000 }
);
if (!subprocess.stdout || !subprocess.stderr) {
throw new Error("No stdout");
}
const ffmpegTranscoder = new FFmpeg({ args: ffmpegArgs });
const stream = subprocess.stdout.pipe(ffmpegTranscoder);
const onError = (err: Error) => {
if (!subprocess.killed) {
subprocess.kill();
}
reject(err);
stream.resume();
};
subprocess
.once("spawn", () => {
resolve(stream);
})
.on("error", onError);
});
}
+125
View File
@@ -0,0 +1,125 @@
/**
* 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>();
/**
* Returns filters as Discord API Choices compatible array.
*
* @returns {@link Filter#FILTERS} as { name: FILTERS[key].name, value: key }.
*/
public static filterChoices(): { name: string; value: string }[] {
return Object.entries(Filter.FILTERS).map(([key, value]) => ({
name: value.name,
value: key
}));
}
/**
* 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 compatible argument.
*
* @returns FFmpeg compatible argument representing active filters.
*/
public toString(): string {
return this.toggled()
.map((value) => value.value)
.join(",");
}
}
@@ -2,11 +2,29 @@ import { AudioResource } from "@discordjs/voice";
import { LoopOption, Track } from "./types.js"; import { LoopOption, Track } from "./types.js";
/**
* Length of playback status bar in emoji symbols.
*
* @intenal
*/
const STATUSBAR_LENGTH = 15; const STATUSBAR_LENGTH = 15;
/**
* Generates pretty playback status bar out of {@link AudioResource}.
*
* @remarks Takes duration and current playback duration to 'draw' bar.
*
* @returns String representing generated status bar.
*
* @param resource - AudioResource with {@link Track} metadata, so duration and playback duration is available.
*
* @privateRemarks I don't know why resource can be undefined - I forgor.
*
* @public
*/
export function statusBarGenerator(resource?: AudioResource<Track>): string { export function statusBarGenerator(resource?: AudioResource<Track>): string {
const duration = resource?.metadata.duration || 1; const duration = resource?.metadata.duration || 1;
const playback = Math.min(resource?.playbackDuration || 0 / 1000, duration); const playback = Math.min((resource?.playbackDuration || 0) / 1000, duration);
const filled = Array<string>(Math.floor((STATUSBAR_LENGTH * playback) / duration)).fill("🔹"); const filled = Array<string>(Math.floor((STATUSBAR_LENGTH * playback) / duration)).fill("🔹");
@@ -15,6 +33,20 @@ export function statusBarGenerator(resource?: AudioResource<Track>): string {
return `[${filled.join("")}${empty.join("")}] ${toISOTime(playback)}/${toISOTime(duration)}`; return `[${filled.join("")}${empty.join("")}] ${toISOTime(playback)}/${toISOTime(duration)}`;
} }
/**
* Converts seconds to ISO time string.
*
* @returns String representing given seconds in ISO format.
*
* @param amount - Seconds to convert.
*
* @example
* ```
* toISOTime(1337228); // "371:27:08"
* ```
*
* @public
*/
export function toISOTime(amount: number): string { export function toISOTime(amount: number): string {
const h = Math.floor(amount / 3600); const h = Math.floor(amount / 3600);
const m = Math.floor((amount % 3600) / 60); const m = Math.floor((amount % 3600) / 60);
@@ -27,19 +59,60 @@ export function toISOTime(amount: number): string {
return hDisplay + mDisplay + sDisplay; return hDisplay + mDisplay + sDisplay;
} }
/**
* Asynchronous timeout.
*
* @returns Promise that will be settled after timeout pass.
*
* @param amount - Milliseconds to wait before settle.
*
* @public
*/
export async function asleep(amount: number): Promise<unknown> { export async function asleep(amount: number): Promise<unknown> {
return new Promise((r) => setTimeout(r, amount)); return new Promise((r) => setTimeout(r, amount));
} }
/**
* Converts volume value to emoji representation.
*
* @remarks In span of 0 ... 100 - emoji from muted to loud.
*
* @returns String with single emoji representing specified volume.
*
* @param amount - volume to convert.
*
* @public
*/
export function volumeMoji(amount: number): string { export function volumeMoji(amount: number): string {
return amount > 100 ? "🔊" : amount > 50 ? "🔉" : amount > 0 ? "🔈" : "🔇"; return amount > 100 ? "🔊" : amount > 50 ? "🔉" : amount > 0 ? "🔈" : "🔇";
} }
/**
* Converts {@link LoopOption} to emoji representation..
*
* @returns String with single emoji representing specified loop option.
*
* @param loop - Loop option that will be converted.
*
* @public
*/
export function loopMoji(loop: LoopOption): string { export function loopMoji(loop: LoopOption): string {
return loop === "AUTO" ? "🔀" : loop === "LOOP" ? "🔁" : loop === "NONE" ? "⤵️" : "🔂"; return loop === "AUTO" ? "🔀" : loop === "LOOP" ? "🔁" : loop === "NONE" ? "⤵️" : "🔂";
} }
export function largestCommonSequence(s1: string, s2: string) { /**
* Finds larges common sequence (LCS) of characters withing provided strings.
*
* @remarks Used to fuzzy search query title of Track.
*
* @returns Length of the LCS found.
*
* @param s1 - First string.
* @param s2 - Second string.
*
* @public
*/
export function largestCommonSequence(s1: string, s2: string): number {
s1 = s1.toLowerCase().slice(0, 100); s1 = s1.toLowerCase().slice(0, 100);
s2 = s2.toLowerCase().slice(0, 100); s2 = s2.toLowerCase().slice(0, 100);
+67
View File
@@ -0,0 +1,67 @@
import { Collection, Guild, GuildTextBasedChannel } from "discord.js";
import logger from "$lib/logger.js";
import Player from "./player.js";
/**
* Module's controller.
*
* Binds guilds to player.
* Creates and destroys them.
*/
export default class Music {
/**
* GuildId to {@link Player} mapping.
*/
private players_ = new Collection<string, Player>();
/**
* Retrieves {@link Player} by {@link Guild}.
*
* @returns Player if such mapping exists or undefined otherwise.
*
* @param guild - Discord guild that might has a Player binded.
* @param channel - Text channel that should replace Player's one.
*/
public get(guild: Guild, channel?: GuildTextBasedChannel): Player | undefined {
const player = this.players_.get(guild.id);
if (player) {
if (channel) {
player.channel = channel;
}
return player;
}
}
/**
* Creates {@link Player} and maps it to GuildId.
*
* @returns Newly created Player.
*
* @param guild - {@link Guild} that created Player will be binded to.
* @param channel - Text channel that used by Player constructor.
*/
public set(guild: Guild, channel: GuildTextBasedChannel): Player {
const player = new Player(guild, this, channel);
this.players_.set(guild.id, player);
logger.info("Player was created.", { player });
return player;
}
/**
* Deleted {@link Player} and its mapping by GuildId.
*
* @param guildId - Id of {@link Guild} that must be removed from mapping.
*/
public delete(guildId: string): void {
logger.info("Player was deleted.", { guildId });
this.players_.delete(guildId);
}
}
@@ -9,9 +9,10 @@ import {
VoiceConnection VoiceConnection
} from "@discordjs/voice"; } from "@discordjs/voice";
import { Guild, GuildTextBasedChannel, Message, User, VoiceBasedChannel } from "discord.js"; import { Guild, GuildTextBasedChannel, Message, User, VoiceBasedChannel } from "discord.js";
import ytdl from "discord-ytdl-core";
import { fetchInfo } from "./fetch.js"; import { clamp } from "$lib/misc.js";
import { dlpInfo, dlpStream } from "./fetch/fetch.js";
import Filter from "./filter.js"; import Filter from "./filter.js";
import { largestCommonSequence } from "./misc.js"; import { largestCommonSequence } from "./misc.js";
import Music from "./music.js"; import Music from "./music.js";
@@ -19,10 +20,33 @@ import Queue from "./queue.js";
import replies from "./replies.js"; import replies from "./replies.js";
import { Track } from "./types.js"; import { Track } from "./types.js";
/**
* Defines guild music player connected to a voice channel by wrapping AudioPlayer, Queue, VoiceConnection, etc.
*
* @public
*/
export default class Player { export default class Player {
private music: Music; /**
private guild: Guild; * Volume limiter clamp.
*/
public static readonly VOLUME_CLAMP = clamp(0, 150);
/**
* Module controller orchistrate guild-player mapping.
*/
private music_: Music;
/**
* {@link Player} owner guild.
*
* @remarks No different {@link Player} instances can have same {@link Guild}.
*/
private guild_: Guild;
/**
* {@link Guild | Guild's} text based channel for player notifications,
* such as Current Playing Track.
*/
private channel_: GuildTextBasedChannel; private channel_: GuildTextBasedChannel;
public get channel(): GuildTextBasedChannel { public get channel(): GuildTextBasedChannel {
return this.channel_; return this.channel_;
@@ -31,44 +55,82 @@ export default class Player {
this.channel_ = x; this.channel_ = x;
} }
/**
* Previous notification message.
*/
private message_?: Message; private message_?: Message;
/**
* A connection to the voice channel of a {@link Guild}.
*
* @readonly
*/
private connection_?: VoiceConnection; private connection_?: VoiceConnection;
public get connection(): VoiceConnection | undefined { public get connection(): VoiceConnection | undefined {
return this.connection_; return this.connection_;
} }
/**
* Player for {@link AudioResource} that is subscribed to the most recent {@link VoiceConnection}.
*
* @readonly
*/
private player_: AudioPlayer; private player_: AudioPlayer;
public get player(): AudioPlayer { public get player(): AudioPlayer {
return this.player_; return this.player_;
} }
/**
* Represents an Audio Resource that can be played by {@link AudioPlayer}.
*
* @readonly
*/
private resource_: AudioResource<Track> | undefined; private resource_: AudioResource<Track> | undefined;
public get resource(): AudioResource<Track> | undefined { public get resource(): AudioResource<Track> | undefined {
return this.resource_; return this.resource_;
} }
/**
* Inline volume for an {@link AudioResource}.
* Must be clamped by {@link VOLUME_CLAMP}.
*/
private volume_ = 100; private volume_ = 100;
public get volume(): number { public get volume(): number {
return this.volume_; return this.volume_;
} }
public set volume(x: number) { public set volume(x: number) {
this.volume_ = Math.max(0, Math.min(150, x)); this.volume_ = Player.VOLUME_CLAMP(x);
if (this.resource_ && this.resource_.volume) { if (this.resource_?.volume) {
this.resource_.volume.setVolumeLogarithmic(this.volume_ / 100); this.resource_.volume.setVolumeLogarithmic(this.volume_ / 100);
} }
} }
/**
* Queue manager of the {@link Player}.
*/
public readonly queue = new Queue(); public readonly queue = new Queue();
/**
* Filter manager of the {@link Player}.
*/
public readonly filter = new Filter(); public readonly filter = new Filter();
/**
* Timestamp when {@link Player} was created.
*/
public readonly createdAt: Date = new Date(); public readonly createdAt: Date = new Date();
/**
* Player constructor.
*
* @param guild - {@link Guild} for which player will be created for
* @param music - Module controller for managing player own existence
* @param channel - Initial text based channed for player notifications
*/
public constructor(guild: Guild, music: Music, channel: GuildTextBasedChannel) { public constructor(guild: Guild, music: Music, channel: GuildTextBasedChannel) {
this.music = music; this.music_ = music;
this.guild = guild; this.guild_ = guild;
this.channel_ = channel; this.channel_ = channel;
this.player_ = createAudioPlayer(); this.player_ = createAudioPlayer();
@@ -76,16 +138,21 @@ export default class Player {
this.player_.on(AudioPlayerStatus.Idle, async () => this.resourceEndResolvable()); this.player_.on(AudioPlayerStatus.Idle, async () => this.resourceEndResolvable());
} }
private resourceGenerator(track: Track, seek?: number) { /**
const stream = ytdl(track.url, { * Creates {@link AudioResource | an Audio Resource} for {@link an AudioPlayer}.
opusEncoded: true, *
filter: "audioonly", * @returns Configured audio resource ready to be played
highWaterMark: 1 << 25, *
* @param track - Metadata to generate resource from and embed into it
* @param seek - Amount of seconds to start playback from
*/
private async resourceGenerator(track: Track, seek?: number): Promise<AudioResource<Track>> {
const probe = await dlpStream(track.url, {
seek: seek ? seek / 1000 : undefined, seek: seek ? seek / 1000 : undefined,
encoderArgs: !this.filter.empty() ? ["-af", this.filter.toString()] : undefined ffmpeg: !this.filter.empty() ? ["-af", this.filter.toString()] : undefined
}); });
this.resource_ = createAudioResource(stream, { this.resource_ = createAudioResource(probe, {
inlineVolume: true, inlineVolume: true,
metadata: track metadata: track
}); });
@@ -101,6 +168,13 @@ export default class Player {
return this.resource_; return this.resource_;
} }
/**
* Resolves player behavior after {@link AudioResource} ends.
*
* @remarks Force destroys ended resource,
* and either plays next one or stops,
* depending on {@link Queue#next | Queue.next() method}
*/
private resourceEndResolvable(): void { private resourceEndResolvable(): void {
if (this.resource_) { if (this.resource_) {
this.resource_.encoder?.destroy(); this.resource_.encoder?.destroy();
@@ -114,6 +188,12 @@ export default class Player {
} }
} }
/**
* Resolves notification emittion.
*
* @remarks Tries to delete previous notification message,
* and send next one
*/
private async messageResolvable(track: Track) { private async messageResolvable(track: Track) {
if (this.message_?.deletable) { if (this.message_?.deletable) {
await this.message_.delete().catch(); await this.message_.delete().catch();
@@ -122,32 +202,47 @@ export default class Player {
this.message_ = await this.channel_.send(replies.nowPlaying(track)); this.message_ = await this.channel_.send(replies.nowPlaying(track));
} }
/**
* Fetches {@link DlpInfo} and converts it to {@link Track | Track metadata}.
*
* @returns Promise to Track list or undefined, if found nothing.
*
* @param query - String that Dlp will try to resolve
* @param requester - {@link User} that requested Tracks returned to complete convertion
*/
private async search(query: string, requester: User): Promise<Track[] | undefined> { private async search(query: string, requester: User): Promise<Track[] | undefined> {
const info = await fetchInfo(query); const info = await dlpInfo(query);
if (info.length === 0) {
return undefined;
}
return info.map((value) => { return info.map((value) => {
return { ...value, requester: requester }; return { ...value, requester: requester };
}); });
} }
public play(seek?: number): void { /**
* 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(); const track = this.queue.current();
if (!this.connection_ || !track) { if (!this.connection_ || !track) {
return; return;
} }
this.player_.play(this.resourceGenerator(track, seek)); this.player_.play(await this.resourceGenerator(track, seek));
this.connection_.subscribe(this.player_); this.connection_.subscribe(this.player_);
this.messageResolvable(track); await this.messageResolvable(track);
} }
/**
* Creates connection to guild's voice based channel,
* previously destroying existing connection.
*
* @param channel - {@link VoiceBasedChannel} to connect to
*/
public connect(channel: VoiceBasedChannel): void { public connect(channel: VoiceBasedChannel): void {
if ( if (
this.connection_?.joinConfig.channelId === channel.id && this.connection_?.joinConfig.channelId === channel.id &&
@@ -169,6 +264,17 @@ export default class Player {
this.connection_.subscribe(this.player_); this.connection_.subscribe(this.player_);
} }
/**
* Adds {@link Track | Track(s)} found by {@link search | the search method} to the {@link queue}.
*
* @returns Track metadata if found, undefined otherwise
*
* @param query - String that will be passed to the search method
* @param requester - {@link User} that requested the query
* @param index - Position to insert track to
* Value < 0 resolves to 0
* Value >= (queue size) resolves to (queue end)
*/
public async add(query: string, requester: User, index?: number): Promise<Track | undefined> { public async add(query: string, requester: User, index?: number): Promise<Track | undefined> {
const tracks = await this.search(query, requester); const tracks = await this.search(query, requester);
@@ -181,6 +287,16 @@ export default class Player {
return tracks[0]; return tracks[0];
} }
/**
* Jumps across {@link queue} to the position determined by the query.
*
* @remarks If string passed, then position will be determined by
* the largest common sequence across all the {@link queue} titles.
*
* @return Position to which jump was perfomed
*
* @param query - String of {@link Track} title or position to jump to
*/
public jump(query: number | string): number | undefined { public jump(query: number | string): number | undefined {
let position = typeof query === "number" ? query : -1; let position = typeof query === "number" ? query : -1;
let diff = 0; let diff = 0;
@@ -203,6 +319,16 @@ export default class Player {
return undefined; return undefined;
} }
/**
* Removes {@link Track} from {@link queue | the queue} by the position determined by the query.
*
* @remarks If string passed, then position will be determined by
* the largest common sequence across all the {@link queue} titles.
*
* @return Metadata of removed track, or undefined if nothing happened
*
* @param query - String of {@link Track} title or position to perfome removal at
*/
public remove(query?: number | string): Track | undefined { public remove(query?: number | string): Track | undefined {
const oldPosition = this.queue.position; const oldPosition = this.queue.position;
@@ -231,10 +357,18 @@ export default class Player {
return undefined; return undefined;
} }
/**
* Restarts playback from timecode specified.
*
* @param time - Amount of seconds to start playback from
*/
public seek(time: number): void { public seek(time: number): void {
this.play(time); this.play(time);
} }
/**
* Clears {@link queue} completely - not destroying {@link voiceConnection_}.
*/
public clear(): void { public clear(): void {
this.queue.clear(); this.queue.clear();
this.resource_ = undefined; this.resource_ = undefined;
@@ -244,15 +378,23 @@ export default class Player {
} }
} }
/**
* Destroys this instance of {@link Player} and deletes it from controller mapping.
*/
public destroy(): void { public destroy(): void {
if (this.connection_) { if (this.connection_) {
this.connection_.destroy(); this.connection_.destroy();
} }
this.player_.stop(); this.player_.stop();
this.music.delete(this.guild.id); this.music_.delete(this.guild_.id);
} }
/**
* Pauses/unpauses {@link AudioPlayer} playback.
*
* @returns True if paused, or False if unpaused
*/
public pause(): boolean { public pause(): boolean {
if (this.player_.state.status === AudioPlayerStatus.Paused) { if (this.player_.state.status === AudioPlayerStatus.Paused) {
this.player_.unpause(); this.player_.unpause();
@@ -263,6 +405,9 @@ export default class Player {
} }
} }
/**
* Reshuffles {@link queue} order - will start new playback from the same position in the queue.
*/
public shuffle(): void { public shuffle(): void {
this.queue.shuffle(); this.queue.shuffle();
@@ -272,10 +417,23 @@ export default class Player {
} }
} }
/**
* Stops {@link player_} that will emit {@link resourceEndResolvable}.
*
* @see {@link resourceEndResolvable} for understanding full behavior
*/
public skip(): void { public skip(): void {
this.player_.stop(); this.player_.stop();
} }
/**
* Applies {@link Filter} preset to the current playback.
*
* @returns True if applied, false if revoked
*
* @param filter - Name of a filter to apply
* Must exist in Filter class
*/
public setFilter(filter?: string): boolean { public setFilter(filter?: string): boolean {
const status = this.filter.toggle(filter); const status = this.filter.toggle(filter);
@@ -1,16 +1,32 @@
import { LoopOption, Track } from "./types.js"; import { LoopOption, Track } from "./types.js";
/**
* Class of {@link ./player#Player | Player's} queue.
*
* @public
*/
export default class Queue { export default class Queue {
/**
* Array of {@link Track | Tracks} - content of queue.
*/
private tracks_: Track[] = []; private tracks_: Track[] = [];
public get tracks(): Track[] { public get tracks(): Track[] {
return this.tracks_; return this.tracks_;
} }
/**
* Position of current playing track.
*/
private position_ = 0; private position_ = 0;
public get position(): number { public get position(): number {
return this.position_; return this.position_;
} }
/**
* Defines behavior of queue.
*
* @see ./types.ts for more info about values.
*/
private loop_: LoopOption = "NONE"; private loop_: LoopOption = "NONE";
public get loop(): LoopOption { public get loop(): LoopOption {
return this.loop_; return this.loop_;
@@ -19,11 +35,21 @@ export default class Queue {
this.loop_ = x; this.loop_ = x;
} }
/**
* Queue overall duration in seconds.
*/
private duration_ = 0; private duration_ = 0;
public get duration(): number { public get duration(): number {
return this.duration_; return this.duration_;
} }
/**
* Skips current track.
*
* @remarks Behavior depends on loop option.
*
* @returns next {@link Track} or undefined if end was reached.
*/
public next(): Track | undefined { public next(): Track | undefined {
if (this.loop_ === "SONG") { if (this.loop_ === "SONG") {
return this.current(); return this.current();
@@ -46,6 +72,9 @@ export default class Queue {
} }
} }
/**
* Shuffles queue by using simple Math.random.
*/
public shuffle(): void { public shuffle(): void {
let currentIndex = this.tracks_.length; let currentIndex = this.tracks_.length;
@@ -62,23 +91,40 @@ export default class Queue {
this.position_ = 0; this.position_ = 0;
} }
/**
* Inserts tracks from provided array at specific index.
*
* @returns first {@link Track} of inserted Tracks.
*
* @param tracks - List of Tracks to insert to the queue.
* @param index - Position at which insertion will be occurred.
*/
public push(tracks: Track[], index?: number): Track { public push(tracks: Track[], index?: number): Track {
if (!index || index >= this.tracks_.length) {
this.tracks_ = this.tracks_.concat(tracks);
} else {
this.tracks_.splice(Math.max(0, index), 0, ...tracks);
if (this.position_ >= index) {
this.position_ += tracks.length;
}
}
for (const track of tracks) { for (const track of tracks) {
this.duration_ += track.duration; this.duration_ += track.duration;
} }
return this.tracks_[index || this.tracks_.length - 1]; if (!index || index >= this.tracks_.length) {
this.tracks_ = this.tracks_.concat(tracks);
return this.tracks_[this.tracks_.length - tracks.length];
}
this.tracks_.splice(Math.max(0, index), 0, ...tracks);
if (this.position_ >= index) {
this.position_ += tracks.length;
}
return this.tracks_[Math.max(0, index)];
} }
/**
* Removes {@link Track} from the queue at position specified.
*
* @returns Track that was deleted or undefined if not.
*
* @param query - Index of element that must be deleted.
*/
public pop(query: number): Track | undefined { public pop(query: number): Track | undefined {
if (query >= 0 && query < this.tracks_.length) { if (query >= 0 && query < this.tracks_.length) {
const track = this.tracks_[query]; const track = this.tracks_[query];
@@ -92,6 +138,11 @@ export default class Queue {
return undefined; return undefined;
} }
/**
* Assigns position new value.
*
* @returns {@link Track} at position specified or undefined if index out of bounds.
*/
public jump(query: number): Track | undefined { public jump(query: number): Track | undefined {
if (query >= 0 && query < this.tracks_.length) { if (query >= 0 && query < this.tracks_.length) {
const track = this.tracks_[query]; const track = this.tracks_[query];
@@ -102,14 +153,27 @@ export default class Queue {
return undefined; return undefined;
} }
/**
* Returns current {@link Track} (at position).
*
* @returns Track at internal position.
*/
public current(): Track { public current(): Track {
return this.tracks_[this.position_]; return this.tracks_[this.position_];
} }
/**
* Checks if queue is empty.
*
* @returns True is queue is empty, False otherwise.
*/
public empty(): boolean { public empty(): boolean {
return this.tracks_.length === 0; return this.tracks_.length === 0;
} }
/**
* Clears entire queue and sets position to 0.
*/
public clear(): void { public clear(): void {
this.tracks_ = []; this.tracks_ = [];
this.position_ = 0; this.position_ = 0;
@@ -1,7 +1,8 @@
import { AudioResource } from "@discordjs/voice"; import { AudioResource } from "@discordjs/voice";
import { BaseMessageOptions, EmbedBuilder, italic } from "discord.js"; import { BaseMessageOptions, EmbedBuilder, italic } from "discord.js";
import config from "../../../config.js"; import config from "$config.js";
import { loopMoji, statusBarGenerator, toISOTime, volumeMoji } from "./misc.js"; import { loopMoji, statusBarGenerator, toISOTime, volumeMoji } from "./misc.js";
import Player from "./player.js"; import Player from "./player.js";
import Queue from "./queue.js"; import Queue from "./queue.js";
@@ -38,7 +39,7 @@ const replies = {
.setColor(config.colors.default) .setColor(config.colors.default)
.setAuthor({ .setAuthor({
iconURL: track.requester.avatarURL() ?? undefined, iconURL: track.requester.avatarURL() ?? undefined,
name: track.requester.tag name: track.requester.username
}) })
.setTitle(track.title) .setTitle(track.title)
.setURL(track.url) .setURL(track.url)
@@ -202,7 +203,7 @@ const replies = {
.setColor(config.colors.success) .setColor(config.colors.success)
.setAuthor({ .setAuthor({
iconURL: track.requester.avatarURL() ?? undefined, iconURL: track.requester.avatarURL() ?? undefined,
name: track.requester.tag name: track.requester.username
}) })
.setTitle(track.title) .setTitle(track.title)
.setURL(track.url) .setURL(track.url)
@@ -261,7 +262,7 @@ const replies = {
embed embed
.setColor(config.colors.success) .setColor(config.colors.success)
.setDescription("Playback can experience some lags during intial transformation."); .setDescription("Playback can experience some lags during initial transformation.");
return { embeds: [embed] }; return { embeds: [embed] };
}, },
@@ -290,7 +291,7 @@ const replies = {
.setTitle(`${queue.position + 1}. ${resource.metadata.title}`) .setTitle(`${queue.position + 1}. ${resource.metadata.title}`)
.setAuthor({ .setAuthor({
iconURL: resource.metadata.requester.avatarURL() ?? undefined, iconURL: resource.metadata.requester.avatarURL() ?? undefined,
name: resource.metadata.requester.tag name: resource.metadata.requester.username
}) })
.setURL(resource.metadata.url) .setURL(resource.metadata.url)
.setDescription(statusBarGenerator(resource)) .setDescription(statusBarGenerator(resource))
@@ -306,7 +307,7 @@ const replies = {
embed.setColor(config.colors.info).setFields( embed.setColor(config.colors.info).setFields(
queue.tracks.slice(position, position + 10).map((value, index) => ({ queue.tracks.slice(position, position + 10).map((value, index) => ({
name: `${position + index + 1}. ${value.title.padEnd(35)}`, name: `${position + index + 1}. ${value.title.padEnd(35)}`,
value: `[${toISOTime(value.duration)}] | ${value.requester.tag}` value: `[${toISOTime(value.duration)}] | ${value.requester.username}`
})) }))
); );
@@ -320,7 +321,7 @@ const replies = {
.setTitle(`${player.queue.position + 1}. ${player.resource.metadata.title}`) .setTitle(`${player.queue.position + 1}. ${player.resource.metadata.title}`)
.setAuthor({ .setAuthor({
iconURL: player.resource.metadata.requester.avatarURL() ?? undefined, iconURL: player.resource.metadata.requester.avatarURL() ?? undefined,
name: player.resource.metadata.requester.tag name: player.resource.metadata.requester.username
}) })
.setURL(player.resource.metadata.url) .setURL(player.resource.metadata.url)
.setDescription(statusBarGenerator(player.resource)) .setDescription(statusBarGenerator(player.resource))
@@ -338,7 +339,7 @@ const replies = {
value: value:
player.filter player.filter
.toggled() .toggled()
.map((value) => value[0]) .map((value) => value.name)
.join("\n") + "\ndefault", .join("\n") + "\ndefault",
inline: true inline: true
}, },
+99
View File
@@ -0,0 +1,99 @@
import { User } from "discord.js";
import { dlpExtractorsValues } from "./fetch/extractors.js";
/**
* Track information fetched by Dlp.
*
* @public
*/
export interface TrackBase {
/** URL of track. */
url: string;
/** Track title. */
title: string;
/** Link to track thumbnail. */
thumbnail: string;
/** Track duration in seconds. */
duration: number;
}
/**
* Track information with {@link User} that requested it.
*
* @public
*/
export interface Track extends TrackBase {
/** User requested this track. */
requester: User;
}
/**
* Player loop options.
*
* @remarks LOOP - Looping queue;
* SONG - Looping single song;
* NONE - Stops after queue end; (default)
* AUTO - Random order;
*
* @public
*/
export type LoopOption = "LOOP" | "SONG" | "NONE" | "AUTO";
/**
* Dlp fetched track list.
*
* @public
*/
export type DlpInfo = TrackBase[];
/**
* Dlp supported extractors as types.
*
* @public
*/
export type dlpExtractorKey = (typeof dlpExtractorsValues)[number];
/**
* Actual Dlp dump that later translates to {@link DlpInfo}.
*
* @public
*/
export interface DlpDump {
/** Id of resource. */
id: string;
/** URL passed to Dlp or link to resource found. */
original_url: string;
/** Link to webpage of resource requested. */
webpage_url?: string;
/** Resource title. */
title?: string;
/** Resource duration in seconds. */
duration?: number;
/** Link to resource thumbnail. */
thumbnail?: string;
/** Resource's extractor identifier. */
extractor_key: dlpExtractorKey;
}
/**
* Options that shall be passed to DlpStream function.
*
* @public
*/
export interface dlpStreamOptions {
/** Amount of seconds to start playback from. */
seek?: number;
/** Args for FFMPEG transcoder. */
ffmpeg?: string[];
}
+16
View File
@@ -0,0 +1,16 @@
/**
* Music module definition.
*
* Module to play media content from various sites.
* @see ./lib/fetch/extractors.ts for site extractors.
*
* @packageDocumentation
*/
import Music from "./lib/music.js";
const name = "music";
const description = "Music module";
const controller = new Music();
export { controller, description, name };
+16
View File
@@ -0,0 +1,16 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"paths": {
"$config.js": ["../../config.js"],
"$lib/*": ["../../src/lib/*"],
"$mlib/*": ["./lib/*"]
},
"rootDir": ".",
"outDir": "../../dist/modules/music",
"composite": true
},
"references": [{ "path": "../../src/" }]
}
+41 -38
View File
@@ -1,54 +1,57 @@
{ {
"name": "quetza-bot", "name": "quetza",
"engines": { "version": "2024.11.0",
"node": "18.x.x", "description": "Awesome Discord bot that performs various duties",
"npm": "*"
},
"type": "module", "type": "module",
"version": "4.0.0", "main": "./dist/src/index.js",
"description": "Discord multifunctional bot", "scripts": {
"author": { "start": "node ./dist/src/index.js",
"name": "unknowableshade", "build": "./build.sh",
"email": "60275123+unknowableshade@users.noreply.github.com", "clean": "rm -rf ./dist",
"url": "https://unknowableshade.ru" "lint": "pnpm exec eslint . && pnpm exec prettier --check .",
"format": "pnpm exec eslint --fix . && pnpm exec prettier --write .",
"preinstall": "pnpm dlx only-allow pnpm"
}, },
"license": "MIT",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "git+https://github.com/unknowableshade/quetza-bot.git" "url": "git+https://github.com/deytenit/Quetza.git"
},
"author": {
"name": "Vladimir Eremin",
"email": "personal@ermnvldmr.com",
"url": "https://ermnvldmr.com"
}, },
"bugs": { "bugs": {
"url": "https://github.com/unknowableshade/quetza-bot/issues" "url": "https://github.com/deytenit/Quetza/issues"
}, },
"readme": "https://github.com/unknowableshade/quetza-bot#readme", "readme": "https://github.com/deytenit/Quetza#readme",
"license": "MIT", "engines": {
"main": "dist/index.js", "node": "22.x.x",
"scripts": { "npm": "*"
"start": "node dist/index.js",
"build": "pnpm exec tsc --build",
"clean": "pnpm exec tsc --build --clean",
"lint": "pnpm exec eslint --fix . && pnpm exec prettier --write .",
"preinstall": "pnpx only-allow pnpm"
}, },
"dependencies": { "dependencies": {
"@discordjs/opus": "^0.9.0", "@discordjs/opus": "^0.9.0",
"@discordjs/voice": "^0.16.0", "@discordjs/voice": "^0.17.0",
"@prisma/client": "4.13.0", "discord.js": "^14.16.3",
"discord-ytdl-core": "^5.0.4", "prism-media": "^1.3.5",
"discord.js": "^14.11.0",
"tweetnacl": "^1.0.3", "tweetnacl": "^1.0.3",
"winston": "^3.8.2", "winston": "^3.16.0"
"ytdl-core": "^4.11.4"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^18.16.6", "@eslint/eslintrc": "^3.1.0",
"@typescript-eslint/eslint-plugin": "^5.59.5", "@eslint/js": "^9.14.0",
"@typescript-eslint/parser": "^5.59.5", "@types/node": "^22.9.0",
"eslint": "^8.40.0", "@typescript-eslint/eslint-plugin": "^8.13.0",
"eslint-config-prettier": "^8.8.0", "@typescript-eslint/parser": "^8.13.0",
"eslint-plugin-import": "^2.27.5", "eslint": "^9.14.0",
"eslint-plugin-simple-import-sort": "^10.0.0", "eslint-config-prettier": "^9.1.0",
"prettier": "^2.8.8", "eslint-plugin-import": "^2.31.0",
"prisma": "^4.13.0", "eslint-plugin-simple-import-sort": "^12.1.1",
"typescript": "^4.9.5" "globals": "^15.12.0",
"mocha": "^10.8.2",
"prettier": "^3.3.3",
"tsc-alias": "^1.8.10",
"typescript": "^5.6.3"
} }
} }
+2724 -1696
View File
File diff suppressed because it is too large. Load diff
@@ -1,22 +0,0 @@
-- CreateTable
CREATE TABLE "Guild" (
"id" TEXT NOT NULL,
CONSTRAINT "Guild_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Event" (
"objectId" SERIAL NOT NULL,
"title" TEXT NOT NULL,
"Roles" TEXT[],
"guildId" TEXT NOT NULL,
CONSTRAINT "Event_pkey" PRIMARY KEY ("objectId")
);
-- CreateIndex
CREATE UNIQUE INDEX "Event_guildId_title_key" ON "Event"("guildId", "title");
-- AddForeignKey
ALTER TABLE "Event" ADD CONSTRAINT "Event_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -1,77 +0,0 @@
/*
Warnings:
- The primary key for the `Event` table will be changed. If it partially fails, the table could be left without primary key constraint.
- You are about to drop the column `Roles` on the `Event` table. All the data in the column will be lost.
- You are about to drop the column `objectId` on the `Event` table. All the data in the column will be lost.
- A unique constraint covering the columns `[title,action,guildId]` on the table `Event` will be added. If there are existing duplicate values, this will fail.
- Added the required column `action` to the `Event` table without a default value. This is not possible if the table is not empty.
- Added the required column `metadata` to the `Event` table without a default value. This is not possible if the table is not empty.
*/
-- CreateEnum
CREATE TYPE "PenaltyType" AS ENUM ('BAN', 'TIMEOUT', 'WARN');
-- DropIndex
DROP INDEX "Event_guildId_title_key";
-- AlterTable
ALTER TABLE "Event" DROP CONSTRAINT "Event_pkey",
DROP COLUMN "Roles",
DROP COLUMN "objectId",
ADD COLUMN "action" TEXT NOT NULL,
ADD COLUMN "metadata" JSONB NOT NULL;
-- CreateTable
CREATE TABLE "Queue" (
"title" TEXT NOT NULL,
"description" TEXT,
"updatedAt" TIMESTAMP(3) NOT NULL,
"Tracks" JSONB NOT NULL,
"userId" TEXT NOT NULL,
"authorId" TEXT NOT NULL,
"guildId" TEXT NOT NULL
);
-- CreateTable
CREATE TABLE "Member" (
"userId" TEXT NOT NULL,
"level" INTEGER NOT NULL DEFAULT 0,
"guildId" TEXT NOT NULL
);
-- CreateTable
CREATE TABLE "Penalty" (
"userId" TEXT NOT NULL,
"type" "PenaltyType" NOT NULL,
"reason" TEXT,
"expires" TIMESTAMP(3),
"guildId" TEXT NOT NULL
);
-- CreateIndex
CREATE UNIQUE INDEX "Queue_title_guildId_key" ON "Queue"("title", "guildId");
-- CreateIndex
CREATE UNIQUE INDEX "Member_userId_guildId_key" ON "Member"("userId", "guildId");
-- CreateIndex
CREATE UNIQUE INDEX "Penalty_type_userId_guildId_key" ON "Penalty"("type", "userId", "guildId");
-- CreateIndex
CREATE UNIQUE INDEX "Event_title_action_guildId_key" ON "Event"("title", "action", "guildId");
-- AddForeignKey
ALTER TABLE "Queue" ADD CONSTRAINT "Queue_userId_guildId_fkey" FOREIGN KEY ("userId", "guildId") REFERENCES "Member"("userId", "guildId") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Queue" ADD CONSTRAINT "Queue_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Member" ADD CONSTRAINT "Member_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Penalty" ADD CONSTRAINT "Penalty_userId_guildId_fkey" FOREIGN KEY ("userId", "guildId") REFERENCES "Member"("userId", "guildId") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Penalty" ADD CONSTRAINT "Penalty_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-3
View File
@@ -1,3 +0,0 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"
-68
View File
@@ -1,68 +0,0 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Guild {
id String @id
events Event[]
members Member[]
penalties Penalty[]
queues Queue[]
}
model Queue {
title String
description String?
updatedAt DateTime @updatedAt
Tracks Json
Member Member @relation(fields: [userId, guildId], references: [userId, guildId])
userId String
authorId String
Guild Guild @relation(fields: [guildId], references: [id])
guildId String
@@unique([title, guildId], name: "queueIdentifier")
}
model Member {
userId String
level Int @default(0)
queues Queue[]
Guild Guild @relation(fields: [guildId], references: [id])
guildId String
penalties Penalty[]
@@unique([userId, guildId], name: "memberIdentifier")
}
enum PenaltyType {
BAN
TIMEOUT
WARN
}
model Penalty {
Member Member @relation(fields: [userId, guildId], references: [userId, guildId])
userId String
type PenaltyType
reason String?
expires DateTime?
Guild Guild @relation(fields: [guildId], references: [id])
guildId String
@@unique([type, userId, guildId], name: "penaltyIdentifier")
}
model Event {
title String
action String
metadata Json
Guild Guild @relation(fields: [guildId], references: [id])
guildId String
@@unique([title, action, guildId], name: "eventIdentifier")
}
+19
View File
@@ -0,0 +1,19 @@
#!/bin/bash
set -e
mkdir -p .git/hooks
HOOK_CONTENT="#!/bin/sh
pnpm run lint
if [ \$? -ne 0 ]; then
echo 'Pre-commit hook failed. Please fix the issues and commit again.'
exit 1
fi
"
echo "$HOOK_CONTENT" > .git/hooks/pre-commit
chmod +x .git/hooks/pre-commit
echo "Pre-commit hook installed successfully!"
-26
View File
@@ -1,26 +0,0 @@
import { ActivityOptions, ActivityType, ColorResolvable } from "discord.js";
import path from "path";
import { fileURLToPath } from "url";
const ROOT = path.dirname(fileURLToPath(import.meta.url));
const discordToken = process.env.DISCORD_TOKEN;
const modulesDir = path.join(ROOT, "/modules/");
const binariesDir = path.join(ROOT, "/../bin/");
const activityStatus: ActivityOptions = { type: ActivityType.Watching, name: "over you" };
const colors = {
default: "#ff6e9b" as ColorResolvable,
error: "#b94e59" as ColorResolvable,
warning: "#e5925f" as ColorResolvable,
info: "#367df6" as ColorResolvable,
success: "#83d18e" as ColorResolvable
};
const config = {
discordToken,
modulesDir,
binariesDir,
activityStatus,
colors
};
export default config;
+8 -3
View File
@@ -1,8 +1,13 @@
import { GatewayIntentBits } from "discord.js"; import { GatewayIntentBits } from "discord.js";
import config from "./config.js"; import config from "$config.js";
import Client from "./lib/client.js"; import Client from "$lib/client.js";
/**
* Ready to go bot instance.
*
* @remarks Quetza itself.
*/
const client = new Client({ const client = new Client({
intents: [ intents: [
GatewayIntentBits.Guilds, GatewayIntentBits.Guilds,
@@ -11,4 +16,4 @@ const client = new Client({
] ]
}); });
client.login(config.discordToken); client.login(config.application.token);
+115 -71
View File
@@ -1,88 +1,132 @@
import { PrismaClient } from "@prisma/client";
import { Client as DiscordClient, ClientOptions, Collection } from "discord.js"; import { Client as DiscordClient, ClientOptions, Collection } from "discord.js";
import { existsSync, lstatSync, readdirSync } from "fs"; import { readdirSync } from "fs";
import path from "path";
import { pathToFileURL } from "url";
import config from "../config.js"; import config from "$config.js";
import { Command, Event, Module, ModuleMetadata } from "./types.js"; import { importDir, pathThrough } from "$lib/misc.js";
import {
ApplicationStatus,
Command,
CommandBase,
Event,
EventBase,
Module,
ModuleBase
} from "$lib/types.js";
const MODULE_ENTRY = "module.js"; /**
const MODULE_COMMANDS = "commands"; * Creating path to {@link Module | Module's} declaration file.
const MODULE_EVENTS = "events"; *
* @internal
*/
const toModuleDeclaration = pathThrough([config.path.modules], ["module.js"]);
export default class Client extends DiscordClient { /**
* Creating path to {@link Module | Module's} commands directory.
*
* @internal
*/
const toModuleCommands = pathThrough([config.path.modules], ["commands"]);
/**
* Creating path to {@link Module | Module's} events directory.
*
* @internal
*/
const toModuleEvents = pathThrough([config.path.modules], ["events"]);
/**
* Bot client enchanced with module handling and database access.
*
* @typeparam T - True if client ready, false otherwise.
*
* @public
*/
export default class Client<T extends boolean = boolean> extends DiscordClient<T> {
/**
* Mapping command name to its {@link Command | instance}.
*/
public readonly commands = new Collection<string, Command>(); public readonly commands = new Collection<string, Command>();
public readonly modules = new Collection<string, ModuleMetadata>(); /**
* Mapping event name to its {@link Event | instance}.
*/
public readonly events = new Collection<string, Event>();
public readonly db = new PrismaClient(); /**
* Mapping module name to its {@link Module | instance}.
*/
public readonly modules = new Collection<string, Module>();
/**
* Constructs Quetza Client.
*
* @param options - Discord.js client options
*/
public constructor(options: ClientOptions) { public constructor(options: ClientOptions) {
super(options); super(options);
for (const module of readdirSync(config.modulesDir)) { readdirSync(config.path.modules).forEach((module) =>
const modulePath = path.join(config.modulesDir, module); this.importModule(
toModuleDeclaration(module),
toModuleCommands(module),
toModuleEvents(module)
)
);
}
if (!lstatSync(modulePath).isDirectory()) { /**
continue; * 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());
import(pathToFileURL(path.join(modulePath, MODULE_ENTRY)).toString()).then( if (!this.isReady()) {
(data: Module) => this.loadModule(data) return {
applicationId: null,
tag: null,
modules
};
}
return {
applicationId: this.application.id,
tag: this.user.tag,
modules
};
}
/**
* Imports module metadata, commands and events to the client.
*
* @param declaration - File declaring module information.
* @param commands - Directory containing module's commands.
* @param events - Directory containing module's events.
*/
private async importModule(
declaration: string,
commands: string,
events: string
): Promise<void> {
const module = await import(declaration).then((module: ModuleBase) =>
this.modules.ensure(module.name, () => ({ ...module, commands: [], events: [] }))
);
importDir<CommandBase>(commands, (command) => {
const saved = this.commands.ensure(command.data.name, () => ({ ...command, module }));
module.commands.push(saved);
});
importDir<EventBase>(events, (event) => {
const saved = this.events.ensure(event.name, () => ({ ...event, module }));
module.events.push(saved);
this.on(event.name, (...eventee: unknown[]) =>
saved.execute(this, eventee, module.controller)
); );
}
}
private loadModule(data: Module): void {
const commands: string[] = [];
const events: string[] = [];
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);
}
}
}
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, (...eventee: unknown[]) => event.execute(this, eventee));
events.push(event.name);
});
this.modules.set(data.name, {
...data,
commands,
events
});
}
public generateApplicationStatus(): string[] {
const modulesStatus = this.modules.map((module) => {
const tag = `✓ ${module.name}@${module.tag} module is loaded.`;
const commands = module.commands.map((name) => " - " + name).join("\n");
const events = module.events.map((name) => " - " + name).join("\n");
return [`${tag}\n`, "commands:", `${commands}`, "events:", `${events}`].join("\n");
});
const clientStatus =
this.user && this.application
? `✓ ${this.application.id} successfully logged in as ${this.user.tag}.`
: "✖ Application is not availiable.";
return modulesStatus.concat(clientStatus);
} }
} }
+52
View File
@@ -0,0 +1,52 @@
/**
* Base class for all quetza's errors.
*
* @public
*/
export default class QuetzaError extends Error {
/**
* Default prefix value stands for quetza's base features.
*/
public static readonly DEFAULT_PREFIX = "QZ";
/**
* Error code is an identifier for specific Error in the same module.
*/
public readonly code: number;
/**
* Prefix defines module where Error has occured.
*
* @defaultValue `QuetzaError.DEFAULT_PREFIX`
*/
public readonly prefix: string;
/**
* Constructs QuetzaError class.
*
* @param msg - Error message (that shall not be displayed to end user)
* @param code - {@link code | Code} unique to error in module
* @param prefix - Identifier of module where error has occurred
*/
public constructor(msg: string, code: number, prefix = QuetzaError.DEFAULT_PREFIX) {
super(msg);
this.code = code;
this.prefix = prefix;
}
/**
* Must create a unique error ID if no collision bugs were written.
*
* @returns error ID as concatenation of {@link prefix} and {@link code}
*
* @example
* ```ts
* const error = new QuetzaError("Some error.", 404, "NF");
*
* error.getErrorId(); // "NF404"
* ```
*/
public getErrorId(): string {
return this.prefix + this.code.toString();
}
}
+24 -11
View File
@@ -1,20 +1,33 @@
import path from "path";
import winston from "winston"; import winston from "winston";
import config from "$config.js";
/** Error logging file path.
*
* @internal
*/
const errorLog = path.join(config.path.log, "error.log");
/** Combined logging file path.
*
* @internal
*/
const combinedLog = path.join(config.path.log, "combined.log");
/**
* Logger instance. Use it for logging.
*
* @public
*/
const logger = winston.createLogger({ const logger = winston.createLogger({
level: "info", levels: winston.config.syslog.levels,
format: winston.format.combine(winston.format.timestamp(), winston.format.json()), format: winston.format.combine(winston.format.timestamp(), winston.format.json()),
transports: [ transports: [
new winston.transports.File({ filename: "logs/error.log", level: "error" }), new winston.transports.Console({ format: winston.format.simple() }),
new winston.transports.File({ filename: "logs/combined.log" }) 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; export default logger;
+77
View File
@@ -0,0 +1,77 @@
/**
* Definitions for miscellaneous functions used in this project.
*/
import assert from "assert";
import { existsSync, readdirSync } from "fs";
import path from "path";
import { pathToFileURL } from "url";
/**
* Clamps value between min and max params.
*
* @returns Function with defined min and max, expecting a value to clamp.
*
* @param min - value less than min will resolve to min.
* @param max - value greater than max will resolve to max.
*
* @public
*/
export function clamp(min: number, max: number) {
assert(min <= max);
return (value: number) => Math.max(min, Math.max(max, value));
}
/**
* Converts Path to File URI Scheme String.
*
* @returns URI.toString() made from joined paths.
*
* @param paths - Strings that should be joined and converted to URI.
*
* @public
*/
export function pathToURI(...paths: string[]): string {
return pathToFileURL(path.join(...paths)).toString();
}
/**
* Imports every module withing provided folder with callback.
*
* @remarks Will import nothing if directory not exists.
*
* @typeparam T - Type of whatever you're importing.
* If not 'any', expecting every import to have the same semantics.
*
* @param dir - Directory where importing modules resides.
* @param callback - Function to run on every import.
*
* @public
*/
export function importDir<T>(dir: string, callback: (module: T) => void) {
if (!existsSync(dir)) {
return;
}
readdirSync(dir)
.filter((source) => source.endsWith(".js"))
.forEach((source) => import(pathToURI(dir, source)).then(callback));
}
/**
* Creates function that joins paths with predefined origin and target.
*
* @returns String with following structure: '...origins/...through/...target'.
*
* @param origins - Paths that will be joined and prepended to 'through'.
* @param targets - Paths that will be joined and appended to 'through'.
*
* @public
*/
export function pathThrough(
origins: string[],
targets: string[]
): (...through: string[]) => string {
return (...through: string[]) => path.join(...origins, ...through, ...targets);
}
+90 -14
View File
@@ -1,27 +1,103 @@
import { ApplicationCommandData, CommandInteraction } from "discord.js"; /**
* Quetza's base type declarations.
*
* @see Architecture documentation for more understanding of its purpose.
*/
import { ApplicationCommandData, CommandInteraction, If } from "discord.js";
import Client from "./client.js"; import Client from "$lib/client.js";
export interface Module { /**
* Quetza's module declaration.
*
* @public
*/
export interface ModuleBase {
/** Name of a module. */
name: string; name: string;
description?: string;
author: string; /** Description of a module. */
tag: string; description: string;
/** Module controller. */
controller?: unknown; controller?: unknown;
rootDir: string;
} }
export interface ModuleMetadata extends Module { /**
commands: string[]; * Complete {@link ModuleBase} declaration with commands and events.
events: string[]; *
* @public
*/
export interface Module extends ModuleBase {
/** Module's commands. */
commands: CommandBase[];
/** Module's events. */
events: EventBase[];
} }
export interface Command { /**
* Quetza's command interaction declaration.
*
* @public
*/
export interface CommandBase {
/** Command metadata that will be passed to the Discord API. */
data: ApplicationCommandData; data: ApplicationCommandData;
execute: (client: Client, interaction: CommandInteraction) => Promise<void>;
/** Function that will be executed upon invoking this interaction. */
execute: (
client: Client,
interaction: CommandInteraction,
controller?: unknown
) => Promise<void>;
} }
export interface Event { /**
* {@link ModuleBase} with {@link Module} included.
*
* @public
*/
export interface Command extends CommandBase {
/** Module containing this Command. */
module: Module;
}
/**
* Quetza's event declaration.
*
* @public
*/
export interface EventBase {
/** Name of an event to listen. */
name: string; name: string;
execute: (client: Client, eventee: unknown[]) => Promise<void>;
/** Function that will be executed upon event emitting. */
execute: (client: Client, eventee: unknown[], controller?: unknown) => Promise<void>;
}
/**
* {@link EventBase} with {@link Module} included.
*
* @public
*/
export interface Event extends EventBase {
/** Module containing this Event. */
module: ModuleBase;
}
/**
* Application status provides information that may be useful at initialization.
*
* @public
*/
export interface ApplicationStatus<Ready extends boolean = boolean> {
/** Discord's ID of an application. */
applicationId: If<Ready, string>;
/** Tag of a bot user. */
tag: If<Ready, string>;
/** List of each module imported. */
modules: Module[];
} }
@@ -1,22 +0,0 @@
import { Events, Interaction } from "discord.js";
import Client from "../../../lib/client.js";
import logger from "../../../lib/logger.js";
async function execute(client: Client, eventee: [Interaction]): Promise<void> {
const [interaction] = eventee;
logger.info("Interaction created.", interaction);
if (interaction.isCommand()) {
const command = client.commands.get(interaction.commandName);
if (command) {
command.execute(client, interaction);
}
}
}
const name = Events.InteractionCreate;
export { execute, name };
-37
View File
@@ -1,37 +0,0 @@
import { generateDependencyReport } from "@discordjs/voice";
import { Events } from "discord.js";
import config from "../../../config.js";
import Client from "../../../lib/client.js";
import logger from "../../../lib/logger.js";
async function execute(client: Client): Promise<void> {
if (!client.user || !client.application) {
throw new Error("No application or user for it is provided.");
}
const commandData = Array.from(client.commands.values()).map((value) => value.data);
if (process.env.NODE_ENV === "development" && process.env.TEST_GUILD) {
logger.info("Development mode: Commands will be pushed to the test guild!");
logger.info(generateDependencyReport());
const guild = await client.guilds.fetch(process.env.TEST_GUILD);
guild.commands.set(commandData);
}
if (process.env.NODE_ENV === "production") {
logger.info("Production mode: Commands will be pushed to the application!");
await client.application.commands.set(commandData);
}
client.generateApplicationStatus().forEach((report) => logger.info(report));
client.user.setActivity(config.activityStatus);
}
const name = Events.ClientReady;
export { execute, name };
-11
View File
@@ -1,11 +0,0 @@
import path from "path";
import { fileURLToPath } from "url";
const name = "core";
const description = "Quetza core functionality";
const author = "unknowableshade";
const tag = "4.2.0";
const rootDir = path.dirname(fileURLToPath(import.meta.url));
export { author, description, name, rootDir, tag };
@@ -1,36 +0,0 @@
import { VoiceState } from "discord.js";
import Client from "../../../lib/client.js";
import { asleep } from "../lib/misc.js";
import replies from "../lib/replies.js";
import { controller } from "../module.js";
const WAIT_TIME = 30_000;
async function execute(client: Client, eventee: [VoiceState]): Promise<void> {
const [oldState] = eventee;
const player = controller.get(oldState.guild);
if (!player || !oldState.channel || !client.user) {
return;
}
if (
oldState.channel.members.size === 1 &&
oldState.channel.members.first()?.user === client.user
) {
await asleep(WAIT_TIME);
if (
oldState.channel.members.size === 1 &&
oldState.channel.members.first()?.user === client.user
) {
await player.channel.send(replies.alone());
player.destroy();
}
}
}
const name = "voiceStateUpdate";
export { execute, name };
-102
View File
@@ -1,102 +0,0 @@
import { execFile } from "child_process";
import path from "path";
import { promisify } from "util";
import config from "../../../config.js";
import logger from "../../../lib/logger.js";
const execFileAsync = promisify(execFile);
const SEARCH_DEPTH = 5;
function isURL(url: string): boolean {
return /^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)$/.test(
url
);
}
interface YtdlDump {
id: string;
original_url: string;
title?: string;
uploader?: string;
duration?: number;
extractor_key: string;
thumbnail?: string;
}
interface YtdlResult {
url: string;
title: string;
thumbnail: string;
duration: number;
}
async function fetchInfoRecursive(query: string, depth = 0): Promise<YtdlResult[]> {
if (depth === SEARCH_DEPTH) {
logger.warn("yt-dlp failed to find anything.", { query });
return [];
}
const executable = await execFileAsync(path.join(config.binariesDir, "/yt-dlp"), [
query,
"-O %(.{id,original_url,title,uploader,duration,extractor_key,thumbnail})j",
"-s",
"--flat-playlist"
]);
const dump: YtdlDump[] = JSON.parse("[" + executable.stdout.replace(/\n(?=.+)/g, ",") + "]");
const result: YtdlResult[] = [];
for (const entry of dump) {
if (entry.title && entry.thumbnail && entry.duration && entry.extractor_key === "Youtube") {
result.push({
url: entry.original_url,
title: entry.title,
thumbnail: entry.thumbnail,
duration: entry.duration
});
logger.info("yt-dlp has found corresponding youtube video.", {
result: result[result.length - 1]
});
continue;
}
let unwind: YtdlResult[];
if (entry.extractor_key !== "Youtube" && entry.title) {
unwind = await fetchInfoRecursive(
`ytsearch1:${entry.uploader ? entry.uploader + " - " : ""} ${entry.title}`,
depth + 1
);
} else {
unwind = await fetchInfoRecursive(entry.original_url, depth + 1);
}
if (unwind.length !== 0) {
result.push(unwind[0]);
}
}
return result;
}
export async function fetchInfo(query: string): Promise<YtdlResult[]> {
logger.info("yt-dlp was invoked.", { query });
try {
if (!isURL(query)) {
return await fetchInfoRecursive("ytsearch1:" + query);
}
return await fetchInfoRecursive(query);
} catch (error) {
logger.error("yt-dlp execution invoked error.", { error });
return [];
}
}
-66
View File
@@ -1,66 +0,0 @@
export default class Filter {
public static readonly filters = {
"Bassboost 1.5": "bass=g=15:f=110:w=0.3",
"bassboost 2.0": "bass=g=20:f=110:w=0.3",
"bassboost 3.0": "bass=g=30:f=110:w=0.3",
"8D": "apulsator=hz=0.09",
Daycore: "aresample=48000,asetrate=48000*0.75",
Nightcore: "aresample=48000,asetrate=48000*1.25",
Phaser: "aphaser=in_gain=0.4",
Tremolo: "tremolo",
Vibrato: "vibrato=f=6.5",
Reverse: "areverse",
Treble: "treble=g=5",
"Normalizer 1": "dynaudnorm=g=101",
"Normalizer 2": "acompressor",
"Surround Sound": "surround",
Pulsator: "apulsator=hz=1",
Subboost: "asubboost",
Karaoke: "stereotools=mlev=0.03",
Compressor: "compand=points=-80/-105|-62/-80|-15.4/-15.4|0/-12|20/-7.6",
Expander:
"compand=attacks=0:points=-80/-169|-54/-80|-49.5/-64.6|-41.1/-41.1|-25.8/-15|-10.8/-4.5|0/0|20/8.3",
"Soft Limiter": "compand=attacks=0:points=-80/-80|-12.4/-12.4|-6/-8|0/-6.8|20/-2.8",
Chorus: "chorus=0.7:0.9:55:0.4:0.25:2",
"Fade In": "afade=t=in:ss=0:d=10",
Dimming:
"afftfilt=\"'real=re * (1-clip((b/nb)*b,0,1))':imag='im * (1-clip((b/nb)*b,0,1))'\"",
Earrape: "channelsplit,sidechaingate=level_in=64"
};
private switches_ = new Set<string>();
public toggled(): [string, string][] {
return Object.entries(Filter.filters)
.filter((value) => this.switches_.has(value[0]))
.map((value) => [value[0], value[1]]);
}
public toggle(query?: string): boolean {
if (!query) {
this.switches_.clear();
return false;
}
if (!this.switches_.delete(query) && Object.hasOwn(Filter.filters, query)) {
this.switches_.add(query);
return true;
}
return false;
}
public size(): number {
return this.switches_.size;
}
public empty(): boolean {
return this.size() === 0;
}
public toString(): string {
return this.toggled()
.map((value) => value[1])
.join(",");
}
}
-36
View File
@@ -1,36 +0,0 @@
import { Collection, Guild, GuildTextBasedChannel } from "discord.js";
import logger from "../../../lib/logger.js";
import Player from "./player.js";
export default class Music {
private players_ = new Collection<string, Player>();
public get(guild: Guild, channel?: GuildTextBasedChannel): Player | undefined {
const player = this.players_.get(guild.id);
if (player) {
if (channel) {
player.channel = channel;
}
return player;
}
}
public set(guild: Guild, channel: GuildTextBasedChannel): Player {
const player = new Player(guild, this, channel);
this.players_.set(guild.id, player);
logger.info("Player was created.", { player });
return player;
}
public delete(guildId: string): void {
logger.info("Player was deleted.", { guildId });
this.players_.delete(guildId);
}
}
-261
View File
@@ -1,261 +0,0 @@
import { User } from "discord.js";
export interface Track {
url: string;
title: string;
thumbnail: string;
duration: number;
requester: User;
}
export type LoopOption = "LOOP" | "SONG" | "NONE" | "AUTO";
export interface YtdlArgs {
help?: boolean;
version?: boolean;
update?: boolean;
ignoreErrors?: boolean;
abortOnError?: boolean;
dumpUserAgent?: boolean;
listExtractors?: boolean;
extractorDescriptions?: boolean;
forceGenericExtractor?: boolean;
defaultSearch?: string;
igonreConfig?: boolean;
configLocation?: string;
flatPlaylist?: boolean;
markWatched?: boolean;
noColor?: boolean;
proxy?: string;
socketTimeout?: number;
sourceAddress?: string;
forceIpv4?: boolean;
forceIpv6?: boolean;
geoVerificationProxy?: string;
geoBypass?: boolean;
geoBypassCountry?: string;
geoBypassIpBlock?: string;
playlistStart?: number;
playlistEnd?: number | "last";
playlistItems?: string;
matchTitle?: string;
rejectTitle?: string;
maxDownloads?: number;
minFilesize?: string;
maxFilesize?: string;
date?: string;
datebefore?: string;
dateafter?: string;
minViews?: number;
maxViews?: number;
matchFilter?: string;
noPlaylist?: boolean;
yesPlaylist?: boolean;
ageLimit?: number;
downloadArchive?: string;
includeAds?: boolean;
limitRate?: string;
retries?: number | "infinite";
skipUnavailableFragments?: boolean;
abortOnUnavailableFragment?: boolean;
keepFragments?: boolean;
bufferSize?: string;
noResizeBuffer?: boolean;
httpChunkSize?: string;
playlistReverse?: boolean;
playlistRandom?: boolean;
xattrSetFilesize?: boolean;
hlsPreferNative?: boolean;
hlsPreferFfmpeg?: boolean;
hlsUseMpegts?: boolean;
externalDownloader?: string;
externalDownloaderArgs?: string;
batchFile?: string;
id?: boolean;
output?: string;
outputNaPlaceholder?: string;
autonumberStart?: number;
restrictFilenames?: boolean;
noOverwrites?: boolean;
continue?: boolean;
noPart?: boolean;
noMtime?: boolean;
writeDescription?: boolean;
writeInfoJson?: boolean;
writeAnnotations?: boolean;
loadInfoJson?: string;
cookies?: string;
cacheDir?: string;
noCacheDir?: boolean;
rmCacheDir?: boolean;
writeThumbnail?: boolean;
writeAllThumbnails?: boolean;
listThumbnails?: boolean;
quiet?: boolean;
noWarnings?: boolean;
simulate?: boolean;
skipDownload?: boolean;
getUrl?: boolean;
getTitle?: boolean;
getId?: boolean;
getThumbnail?: boolean;
getDuration?: boolean;
getFilename?: boolean;
getFormat?: boolean;
dumpJson?: boolean;
dumpSingleJson?: boolean;
printJson?: boolean;
newline?: boolean;
noProgress?: boolean;
consoleTitle?: boolean;
verbose?: boolean;
dumpPages?: boolean;
writePages?: boolean;
printTraffic?: boolean;
callHome?: boolean;
encoding?: string;
noCheckCertificate?: boolean;
preferInsecure?: boolean;
userAgent?: string;
referer?: string;
addHeader?: string;
bidiWorkaround?: boolean;
sleepInterval?: number;
maxSleepInterval?: number;
format?: string;
allFormats?: boolean;
preferFreeFormats?: boolean;
listFormats?: boolean;
youtubeSkipDashManifest?: boolean;
mergeOutputFormat?: string;
writeSub?: boolean;
writeAutoSub?: boolean;
allSubs?: boolean;
listSubs?: boolean;
subFormat?: string;
subLang?: string;
username?: string;
password?: string;
twofactor?: string;
netrc?: boolean;
videoPassword?: string;
apMso?: string;
apUsername?: string;
apPassword?: string;
apListMso?: boolean;
extractAudio?: boolean;
audioFormat?: string;
audioQuality?: number;
recodeVideo?: string;
postprocessorArgs?: string;
keepVideo?: boolean;
noPostOverwrites?: boolean;
embedSubs?: boolean;
embedThumbnail?: boolean;
addMetadata?: boolean;
metadataFromFile?: string;
xattrs?: boolean;
fixup?: string;
preferAvconv?: boolean;
preferFfmpeg?: boolean;
ffmpegLocation?: string;
exec?: string;
convertSubs?: string;
}
interface YtdlFormat {
asr: number;
filesize: number;
format_id: string;
format_note: string;
fps: number;
height: number;
quality: number;
tbr: number;
url: string;
width: number;
ext: string;
vcodec: string;
acodec: string;
abr: number;
downloader_options: unknown;
container: string;
format: string;
protocol: string;
http_headers: unknown;
}
interface YtdlThumbnail {
height: number;
url: string;
width: number;
resolution: string;
id: string;
}
interface YtdlEntry {
uploader: string;
duration: number;
id: string;
title: string;
url: string;
}
export interface YtdlResponse {
id: string;
title: string;
formats: YtdlFormat[];
thumbnails: YtdlThumbnail[];
description: string;
upload_date: string;
uploader: string;
uploader_id: string;
uploader_url: string;
channel_id: string;
channel_url: string;
duration: number;
view_count: number;
average_rating: number;
age_limit: number;
webpage_url: string;
categories: string[];
tags: string[];
is_live: boolean;
like_count: number;
dislike_count: number;
channel: string;
track: string;
artist: string;
creator: string;
alt_title: string;
extractor: string;
webpage_url_basename: string;
extractor_key: string;
playlist: string;
playlist_index: number;
thumbnail: string;
display_id: string;
requested_subtitles: unknown;
asr: number;
filesize: number;
format_id: string;
format_note: string;
fps: number;
height: number;
quality: number;
tbr: number;
url: string;
width: number;
ext: string;
vcodec: string;
acodec: string;
abr: number;
downloader_options: { http_chunk_size: number };
container: string;
format: string;
protocol: string;
http_headers: unknown;
fulltitle: string;
_filename: string;
entries: YtdlEntry[];
}
-15
View File
@@ -1,15 +0,0 @@
import path from "path";
import { fileURLToPath } from "url";
import Music from "./lib/music.js";
const name = "music";
const description = "Music module";
const author = "unknowableshade";
const tag = "2.0.0";
const controller = new Music();
const rootDir = path.dirname(fileURLToPath(import.meta.url));
export { author, controller, description, name, rootDir, tag };
-90
View File
@@ -1,90 +0,0 @@
import { Events, Interaction, PermissionFlagsBits, SlashCommandBuilder } from "discord.js";
import Client from "../../../lib/client.js";
import logger from "../../../lib/logger.js";
import replies from "../lib/replies.js";
async function execute(client: Client, interaction: Interaction): Promise<void> {
if (!interaction.isChatInputCommand() || !interaction.inCachedGuild()) {
logger.warn("Interaction rejected.", { interaction });
return;
}
const subcommand = interaction.options.getSubcommand();
const event = interaction.options.getString("event", true);
switch (subcommand) {
case "role": {
const role = interaction.options.getRole("role", true);
await interaction.deferReply();
try {
await client.db.event.upsert({
where: {
eventIdentifier: {
title: event,
action: subcommand,
guildId: interaction.guildId
}
},
update: { metadata: [role.id] },
create: {
Guild: {
connectOrCreate: {
where: {
id: interaction.guildId
},
create: {
id: interaction.guildId
}
}
},
title: event,
action: subcommand,
metadata: [role.id]
}
});
await interaction.editReply(replies.auto("add role to a member", event));
} catch (error) {
logger.error("Error occured while quering db.", { interaction });
} finally {
await client.db.$disconnect();
}
return;
}
}
}
const AUTO_EVENT_CHOICES = [
{
name: "Member Joins",
value: Events.GuildMemberAdd
}
];
const data = new SlashCommandBuilder()
.setName("auto")
.setDescription("Quetza's automatization engine.")
.addSubcommand((subcommand) =>
subcommand
.setName("role")
.setDescription("Auto: give role.")
.addStringOption((option) =>
option
.setName("event")
.setDescription("Event when perfome an action.")
.setChoices(...AUTO_EVENT_CHOICES)
.setRequired(true)
)
.addRoleOption((option) =>
option.setName("role").setDescription("Role to grant on event.").setRequired(true)
)
)
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator)
.setDMPermission(false);
export { data, execute };
-98
View File
@@ -1,98 +0,0 @@
import { Interaction, PermissionFlagsBits, SlashCommandBuilder } from "discord.js";
import Client from "../../../lib/client.js";
import logger from "../../../lib/logger.js";
import replies from "../lib/replies.js";
async function execute(client: Client, interaction: Interaction): Promise<void> {
if (!interaction.isChatInputCommand() || !interaction.inCachedGuild()) {
logger.warn("Interaction rejected.", { interaction });
return;
}
const subcommand = interaction.options.getSubcommand();
switch (subcommand) {
case "check": {
const user = interaction.options.getUser("member", true);
const member = await interaction.guild.members.fetch(user).catch(() => undefined);
if (member) {
await interaction.reply(replies.roleList(user.tag, member.roles.cache));
} else {
await interaction.reply(replies.notMember("edited", user));
}
return;
}
case "edit": {
const user = interaction.options.getUser("member", true);
const role = interaction.options.getRole("role", true);
const reason = interaction.options.getString("reason") ?? undefined;
const member = await interaction.guild.members.fetch(user).catch(() => undefined);
if (member) {
if (member.roles.cache.some((memberRole) => role.id === memberRole.id)) {
await member.roles.remove(role.id, reason);
await interaction.reply(
replies.roleAction("revoked from", interaction.user, role.name, reason)
);
} else {
await member.roles.add(role.id, reason);
await interaction.reply(
replies.roleAction("granted to", interaction.user, role.name, reason)
);
}
} else {
await interaction.reply(replies.notMember("edited", user));
}
return;
}
case "list": {
const roles = await interaction.guild.roles.fetch();
await interaction.reply(replies.roleList(interaction.guild.name, roles));
return;
}
}
}
const data = new SlashCommandBuilder()
.setName("role")
.setDescription("Quetza's role manager.")
.addSubcommand((subcommand) =>
subcommand.setName("list").setDescription("Role: list guild's roles.")
)
.addSubcommand((subcommand) =>
subcommand
.setName("check")
.setDescription("Role: check user roles.")
.addUserOption((option) =>
option.setName("member").setDescription("Member to check.").setRequired(true)
)
)
.addSubcommand((subcommand) =>
subcommand
.setName("edit")
.setDescription("Role: edit role for a specific user.")
.addUserOption((option) =>
option.setName("member").setDescription("Member to edit.").setRequired(true)
)
.addRoleOption((option) =>
option.setName("role").setDescription("Member's role to edit.").setRequired(true)
)
.addStringOption((option) =>
option.setName("reason").setDescription("Reason for an action.")
)
)
.setDefaultMemberPermissions(PermissionFlagsBits.ManageRoles)
.setDMPermission(false);
export { data, execute };
@@ -1,23 +0,0 @@
import { Events, GuildMember } from "discord.js";
import Client from "../../../lib/client.js";
async function execute(client: Client, eventee: [GuildMember]): Promise<void> {
const [member] = eventee;
try {
const event = await client.db.event.findFirst({
where: { guildId: member.guild.id, title: name }
});
if (event) {
await member.roles.add(event.metadata as string[]);
}
} finally {
await client.db.$disconnect();
}
}
const name = Events.GuildMemberAdd;
export { execute, name };
-80
View File
@@ -1,80 +0,0 @@
import {
BaseMessageOptions,
bold,
Collection,
EmbedBuilder,
InteractionReplyOptions,
italic,
Role,
underscore,
User
} from "discord.js";
import config from "../../../config.js";
const replies = {
error: (): InteractionReplyOptions => {
const embed = new EmbedBuilder()
.setColor(config.colors.error)
.setTitle("Internal error occured while resolving interaction.")
.setDescription("Submit an issue (bio). Logs were saved.")
.setTimestamp();
return { embeds: [embed], ephemeral: true };
},
notMember: (action: string, user: User): InteractionReplyOptions => {
const embed = new EmbedBuilder()
.setColor("Blue")
.setTitle(`${user.tag} cannot be ${action}`)
.setDescription("They're not a guild member.")
.setTimestamp();
return { embeds: [embed], ephemeral: true };
},
roleAction: (
action: string,
user: User,
roleName: string,
reason?: string
): InteractionReplyOptions => {
const embed = new EmbedBuilder()
.setColor(config.colors.success)
.setTitle(`Role ${roleName} was ${action} user ${user.tag}`)
.setDescription(reason ?? "No reason provided.")
.setTimestamp();
return { embeds: [embed], ephemeral: true };
},
roleList: (observed: string, roles: Collection<string, Role>): InteractionReplyOptions => {
const embed = new EmbedBuilder()
.setColor(config.colors.success)
.setTitle(`${observed} roles:`)
.addFields(
roles.map((role) => {
return {
name: role.name,
value: italic(role.id)
};
})
)
.setTimestamp();
return { embeds: [embed], ephemeral: true };
},
auto: (action: string, event: string): BaseMessageOptions => {
const embed = new EmbedBuilder()
.setColor(config.colors.success)
.setTitle(
bold(
`From now I will ${underscore(italic(action))} when ${underscore(
italic(event)
)} event happens`
)
)
.setTimestamp();
return { embeds: [embed] };
}
};
export default replies;
-11
View File
@@ -1,11 +0,0 @@
import path from "path";
import { fileURLToPath } from "url";
const name = "server";
const description = "Server utilities module";
const author = "unknowableshade";
const tag = "0.0.1";
const rootDir = path.dirname(fileURLToPath(import.meta.url));
export { author, description, name, rootDir, tag };
+15
View File
@@ -0,0 +1,15 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"paths": {
"$config.js": ["../config.js"],
"$lib/*": ["./lib/*"]
},
"rootDir": ".",
"outDir": "../dist/src",
"composite": true
},
"references": [{ "path": "../config.tsconfig.json" }]
}
+17 -11
View File
@@ -2,18 +2,24 @@
"compilerOptions": { "compilerOptions": {
"strict": true, "strict": true,
"module": "ESNext", "module": "NodeNext",
"moduleResolution": "node", "moduleResolution": "NodeNext",
"outDir": "dist", "resolveJsonModule": true,
"removeComments": false,
"sourceMap": true,
"esModuleInterop": true,
"target": "ESNext",
"lib": ["ESNext"],
"types": ["node"], "types": ["node"],
"outDir": "./dist",
"sourceMap": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"target": "ES2022",
"skipLibCheck": true "skipLibCheck": true
}, },
"include": ["src/**/*"], "references": [
"exclude": ["node_modules", "dist", "src/commands", "src/events"] { "path": "./modules/core" },
{ "path": "./modules/music" },
{ "path": "./modules/ai" }
]
} }