51 Commits
Author SHA1 Message Date
deytenit 8259f76615 Merge branch 'development' of https://github.com/unknowableshade/Quetza into development 2024-03-02 02:05:59 +03:00
deytenit afc6fce555 Documentation of Music & Core (almost done) 2024-03-02 02:04:00 +03:00
deytenit be88d20f31 Update README.md 2024-01-30 14:36:57 +03:00
deytenit 811bc3a3c8 Create LICENSE 2024-01-30 14:36:12 +03:00
deytenit 824abc6533 Update README.md 2024-01-30 14:35:43 +03:00
deytenit 3144dd69b7 Refactoring, comments & overall improvements 2024-01-30 14:31:15 +03:00
deytenit d7421251ba TSC Build System 2024-01-16 19:20:05 +03:00
deytenit 29fcdcc06f Runtume version update 2024-01-16 13:33:58 +03:00
deytenit 6587bcd68e Removing ytdl-core from dependencies 2024-01-13 02:18:43 +03:00
deytenit ef61f7fa0a Modules simplification & music logging 2024-01-07 15:39:17 +03:00
deytenit 4ed9e1c6e0 Update README and branding 2023-12-23 02:51:30 +03:00
deytenit a59faab420 Fix Action Workflow 2023-12-17 19:41:19 +03:00
deytenit a725290293 Merge commit '3290962' into HEAD 2023-12-07 14:29:17 +03:00
deytenit 6a3425e2c3 Update Dependencies 2023-12-07 14:24:56 +03:00
deytenit 3290962c77 Update Dependencies 2023-12-07 14:20:15 +03:00
deytenit ace8170323 Comply to Docker Logging 2023-12-06 13:40:18 +03:00
Vladimir (unknowableshade) Eremin 1374bcc25d Merge branch 'master' of https://github.com/unknowableshade/quetza-bot 2023-07-12 02:57:29 +03:00
Vladimir (unknowableshade) Eremin 757d6e3501 Prettier & CI/CD Fixes 2023-07-12 02:57:24 +03:00
Vladimir (unknowableshade) Eremin b023f7134d Merge branch 'master' of https://github.com/unknowableshade/quetza-bot 2023-07-12 02:57:07 +03:00
Vladimir (unknowableshade) Eremin 9f191e6e80 Prettier & CI/CD Fixes 2023-07-12 02:56:53 +03:00
Vladimir (unknowableshade) Eremin 7ca5041725 Merge branch 'master' of https://github.com/unknowableshade/quetza-bot 2023-07-12 02:22:15 +03:00
Vladimir (unknowableshade) Eremin a045be3f2f Prettier & CI/CD Fixes 2023-07-12 02:16:29 +03:00
Vladimir (unknowableshade) Eremin c569661481 Prettier & CI/CD Fixes 2023-07-12 01:10:12 +03:00
Vladimir (unknowableshade) Eremin e8cd52d315 More Fixes 2023-05-09 18:05:41 +03:00
Vladimir (unknowableshade) Eremin d4d2930f58 Formatting 2023-05-09 17:01:40 +03:00
Vladimir (unknowableshade) Eremin 0f6ad29a3c Bug Fixes 2023-05-09 16:58:37 +03:00
Vladimir Eremin e4397c3f54 Merge pull request #5 from unknowableshade/laboratory
v4.2.0
2023-05-09 04:21:33 +03:00
Vladimir (unknowableshade) Eremin ae41f11bef Docker & Versions 2023-05-09 04:11:25 +03:00
Vladimir (unknowableshade) Eremin af57e4a25f Minor Changes 2023-05-09 02:49:11 +03:00
Vladimir (unknowableshade) Eremin 483bc4895d Pretty 2023-05-09 02:48:58 +03:00
Vladimir (unknowableshade) Eremin 615921a2d9 Dependencies 2023-05-09 02:48:17 +03:00
Vladimir (unknowableshade) Eremin 93f3bd1047 Server Module Changes 2023-05-08 23:52:21 +03:00
Vladimir (unknowableshade) Eremin c2f666c408 Music Improvements 2023-05-08 23:51:55 +03:00
Vladimir (unknowableshade) Eremin 3f55646bce Core Improvements 2023-05-08 23:51:37 +03:00
Vladimir (unknowableshade) Eremin 0fdc80aeab Configuration changes 2023-05-08 23:51:13 +03:00
Vladimir (unknowableshade) Eremin 0b451c2c4e Music v2.0.0 (early) 2023-05-07 18:35:13 +03:00
Vladimir (unknowableshade) Eremin bb9448c9c1 Core improvements 2023-05-07 18:34:20 +03:00
Vladimir (unknowableshade) Eremin d726ab6571 Logging, Migrations & Updates 2023-05-07 18:33:14 +03:00
Vladimir (unknowableshade) Eremin 85976917b7 Server Module + DB (very early) 2023-05-04 03:54:34 +03:00
Vladimir (unknowableshade) Eremin 1b218fa7c3 Dependencies & CI/CD 2023-04-01 16:07:55 +03:00
Vladimir (unknowableshade) Eremin 5fe2d9f99d Submit to node-semver 2023-03-30 16:33:06 +03:00
Vladimir (unknowableshade) Eremin 411c2ce262 Pnpm Migration 2023-03-30 16:16:11 +03:00
Vladimir (unknowableshade) Eremin 8e9ce884a6 CI/CD Building & Testing 2023-03-29 12:47:06 +03:00
Vladimir Eremin 151437e7a8 Merge pull request #4 from unknowableshade/laboratory
Laboratory collapse
2023-03-28 23:43:07 +03:00
Vladimir Eremin 076d9a71cb Merge branch 'master' into laboratory 2023-03-28 23:39:33 +03:00
Vladimir (unknowableshade) Eremin b562d74a5c Modular Design + Minor Fixes 2023-03-28 23:34:53 +03:00
Vladimir (unknowableshade) Eremin dea1a5564e Version Update + Misc 2023-03-28 23:34:12 +03:00
Vladimir (unknowableshade) Eremin c178fb21f3 Refactoring flexing 2023-03-02 00:10:01 +03:00
Vladimir (unknowableshade) Eremin b2bc8b928a Modular concept 2023-02-28 16:41:00 +03:00
Vladimir Eremin 8cff02e081 Update package.json 2023-02-27 23:02:13 +03:00
Vladimir (unknowableshade) Eremin d068a6fb01 2302.2 Clean Up & Docker Fix 2023-02-26 20:40:43 +03:00
94 changed files with 7836 additions and 4958 deletions

No files matched your search

+19 -2
View File
@@ -1,2 +1,19 @@
node_modules
Procfile
# Ignore everything by default
*
# Dockerfile
!Dockerfile
# Source
!config.ts
!src/
!modules/
# Dependencies
!package.json
!pnpm-lock.yaml
# Build
!build.sh
!config.tsconfig.json
!tsconfig.json
+19 -13
View File
@@ -1,15 +1,21 @@
{
"env": { "browser": true, "es2021": true },
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
"parser": "@typescript-eslint/parser",
"parserOptions": { "ecmaVersion": "latest", "sourceType": "module" },
"plugins": ["@typescript-eslint"],
"rules": {
"indent": ["error", 4],
"linebreak-style": ["error", "windows"],
"quotes": ["error", "double"],
"semi": ["error", "always"],
"brace-style": ["error", "stroustrup"],
"arrow-body-style": ["error", "as-needed"]
}
"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"
}
}
-63
View File
@@ -1,63 +0,0 @@
###############################################################################
# Set default behavior to automatically normalize line endings.
###############################################################################
* text=auto
###############################################################################
# Set default behavior for command prompt diff.
#
# This is need for earlier builds of msysgit that does not have it on by
# default for csharp files.
# Note: This is only used by command line
###############################################################################
#*.cs diff=csharp
###############################################################################
# Set the merge driver for project and solution files
#
# Merging from the command prompt will add diff markers to the files if there
# are conflicts (Merging from VS is not affected by the settings below, in VS
# the diff markers are never inserted). Diff markers may cause the following
# file extensions to fail to load in VS. An alternative would be to treat
# these files as binary and thus will always conflict and require user
# intervention with every merge. To do so, just uncomment the entries below
###############################################################################
#*.sln merge=binary
#*.csproj merge=binary
#*.vbproj merge=binary
#*.vcxproj merge=binary
#*.vcproj merge=binary
#*.dbproj merge=binary
#*.fsproj merge=binary
#*.lsproj merge=binary
#*.wixproj merge=binary
#*.modelproj merge=binary
#*.sqlproj merge=binary
#*.wwaproj merge=binary
###############################################################################
# behavior for image files
#
# image files are treated as binary by default.
###############################################################################
#*.jpg binary
#*.png binary
#*.gif binary
###############################################################################
# diff behavior for common document formats
#
# Convert binary document formats to text before diffing them. This feature
# is only available from the command line. Turn it on by uncommenting the
# entries below.
###############################################################################
#*.doc diff=astextplain
#*.DOC diff=astextplain
#*.docx diff=astextplain
#*.DOCX diff=astextplain
#*.dot diff=astextplain
#*.DOT diff=astextplain
#*.pdf diff=astextplain
#*.PDF diff=astextplain
#*.rtf diff=astextplain
#*.RTF diff=astextplain
-51
View File
@@ -1,51 +0,0 @@
# This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs
name: CI/CD
on:
push:
tags:
- "*"
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18.x]
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
steps:
- uses: actions/checkout@v3
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
cache: "npm"
- run: npm ci
- run: npm run build --if-present
- name: Check out the repo
uses: actions/checkout@v3
- name: Log in to Docker Hub
uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38
with:
images: unknowableshade/quetza-bot
- name: Build and push Docker image
uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+86
View File
@@ -0,0 +1,86 @@
name: Continuous Integration
on:
push:
tags:
- "*"
branches:
- master
jobs:
mocha:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20.x]
pnpm-version: [8]
steps:
- name: Check out the repo
uses: actions/checkout@v3
- uses: pnpm/action-setup@v2
with:
version: ${{ matrix.pnpm-version }}
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
- name: Install dependencies
run: pnpm install
- name: ESLint & Prettier
run: pnpm run lint
- name: Prisma Migration
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
run: pnpm exec prisma migrate deploy
- name: Build
run: pnpm run build
- name: Test
run: pnpm run --if-present test
docker:
runs-on: ubuntu-latest
needs: mocha
steps:
- name: Check out the repo
uses: actions/checkout@v3
- name: Log in to Docker Hub
uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Docker Generate Meta
id: meta
uses: docker/metadata-action@v5
with:
images: |
unknowableshade/quetza
# generate Docker tags based on the following events/attributes
# on tag event: tag using git tag, and as latest if the tag doesn't contain hyphens (pre-releases)
# on push event: tag using git sha, branch name and as latest-dev
tags: |
type=raw,value=latest,enable=${{ github.ref_type == 'tag' && !contains(github.ref_name, '-') }}
type=raw,value=latest-dev,enable=${{ github.ref_type == 'branch' && github.ref_name == 'master' }}
type=raw,value=${{ github.ref_name }}
type=raw,value=${{ github.sha }},enable=${{ github.ref_type == 'branch' }}
flavor: |
latest=false
- name: Build and Push Docker Image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+58 -368
View File
@@ -1,374 +1,64 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Oo]ut/
[Ll]og/
[Ll]ogs/
# Visual Studio 2015/2017 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET Core
project.lock.json
project.fragment.lock.json
artifacts/
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
# Logs
logs
*.log
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Chutzpah Test files
_Chutzpah*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Coverlet is a free, cross platform Code Coverage Tool
coverage*.json
coverage*.xml
coverage*.info
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# NuGet Symbol Packages
*.snupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
*.appxbundle
*.appxupload
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!?*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
ServiceFabricBackup/
*.rptproj.bak
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
*- [Bb]ackup.rdl
*- [Bb]ackup ([0-9]).rdl
*- [Bb]ackup ([0-9][0-9]).rdl
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
.paket/paket.exe
paket-files/
# FAKE - F# Make
.fake/
# CodeRush personal settings
.cr/personal
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
# Cake - Uncomment if you are using it
# tools/**
# !tools/packages.config
# Tabs Studio
*.tss
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
.mfractor/
# Local History for Visual Studio
.localhistory/
# BeatPulse healthcheck temp database
healthchecksdb
# Backup folder for Package Reference Convert tool in Visual Studio 2017
MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
.ionide/
# Fody - auto-generated XML schema
FodyWeavers.xsd
# assetscript restrictions
dist/
*.js
*.js.map
# binaries is not bundled
# Binaries
bin/
# Keep enviromental variables out of version control
*.env
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# TypeScript cache
*.tsbuildinfo
dist/
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# VSCode
.vscode-test
.vscode
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
+5
View File
@@ -0,0 +1,5 @@
# Node.js Tools
node_modules
# Typescript compiller artifacts
dist/
+23
View File
@@ -0,0 +1,23 @@
{
"semi": true,
"trailingComma": "none",
"singleQuote": false,
"printWidth": 100,
"arrowParens": "always",
"bracketSpacing": true,
"endOfLine": "lf",
"overrides": [
{
"files": ["**/*.json, **/*.yml"],
"options": {
"tabWidth": 2
}
},
{
"files": ["**/*.ts"],
"options": {
"tabWidth": 4
}
}
]
}
+35 -12
View File
@@ -1,21 +1,44 @@
FROM node:18-alpine
FROM node:20-alpine as builder
RUN apk update
RUN apk add --no-cache bash
RUN apk add --no-cache curl
RUN apk add --no-cache ffmpeg
RUN apk add --no-cache python3
RUN apk add --no-cache curl
RUN apk add --no-cache make
RUN apk add --no-cache gcc
RUN apk add --no-cache build-base
RUN npm install -g pnpm
WORKDIR /usr/src/app
RUN mkdir bin
RUN curl -L https://yt-dl.org/downloads/latest/youtube-dl -o /usr/src/app/bin/youtube-dl
RUN chmod a+rx /usr/src/app/bin/youtube-dl
COPY package.json pnpm-lock.yaml ./
COPY package*.json ./
RUN npm ci
RUN pnpm install
COPY . .
run npm run build
CMD [ "npm", "start" ]
RUN pnpm run build
RUN mkdir ./dist/bin/
RUN curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /usr/src/app/dist/bin/yt-dlp
RUN chmod a+rx /usr/src/app/dist/bin/yt-dlp
RUN pnpm install --prod
FROM node:20-alpine
RUN apk update
RUN apk add --no-cache ffmpeg
RUN apk add --no-cache python3
USER node
ENV NODE_ENV production
WORKDIR /usr/src/app
COPY package.json ./
COPY --from=builder /usr/src/app/node_modules ./node_modules
COPY --from=builder /usr/src/app/dist ./
CMD [ "node", "./src/index.js" ]
-1
View File
@@ -1 +0,0 @@
worker: npm start
+143 -11
View File
@@ -1,16 +1,148 @@
# Quetza - 2302.1 <soundrewrite>
![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.
- Play music from YouTube and many other sites.
- Lots of different filters _(nightcore, earrape etc.)_.
- Slash commands.
## Architecture
## Links
At its core **Quetza** is simply a **[user-client](./src/lib/client.ts)** with a **[logger](./src/lib/logger.ts)**.
- **[Dependencies](https://github.com/unknowableshade/quetza-bot/blob/master/package.json)**
- **[Docker](https://hub.docker.com/repository/docker/unknowableshade/quetza-bot)**
- **[Licence](https://github.com/unknowableshade/5a0b-bot/blob/master/LICENSE)**
### Modules
**Modules** define the features of **Quetza**.
> 🗂 **The structure** of a **module**
```sh
modules/example/
├ module.ts
├ commands/
│ ├ [command-name].ts
├ events/
│ ├ [event-name].ts
├ lib/
│ ├ [...libraries] // Including controller if exists.
```
#### **module.ts**
**The module** must have a definition file that **adheres** to the following **interface** as _export_.
> 📐 **Module** export **interface** [defined in _[src/lib/types.ts](./src/lib/types.ts)_]
```ts
interface Module {
name: string; // Name of the module.
description?: string; // Description of the module.
controller?: unknown;
// The service orchestrating the module. (passed as third argument to command or event executor)
}
```
#### commands/[command-name].ts
Each **command** has to be defined in its own file, preferably named after **the command** itself.
**The client** indexes **the commands** of each **module** and sends them to **the Discord API**.
> 📐 **Command** **interface** to export [defined in _[src/lib/types.ts](./src/lib/types.ts)_]
```ts
interface Command {
data: ApplicationCommandData; // Definition of command specifically for the Discord API.
execute: (client: Client, interaction: CommandInteraction, controller?: unknown) => Promise<void>;
// Function that will be called upon interaction invocation.
}
```
> ⚠️ Each **command** must comply with the _command interaction_ requirements of **the Discord API**.
#### events/[event-name].ts
**Events** are pre-defined by **the Discord API** and can only have new functions
bound to **them** by **modules**, despite being declared similarly to **commands**.
> 📐 **Event** **interface** to export [defined in _[src/lib/types.ts](./src/lib/types.ts)_]
```ts
export interface Event {
name: string;
execute: (client: Client, eventee: unknown[], controller?: unknown) => Promise<void>;
}
```
> ✅ **These** can be re-defined so that they can operate independently in their own **module**.
#### lib/[...libraries] & controller
If you expand the provided **module** **structure**, it will still be a valid **module**.
**_lib/_** is simply a suggestion to add consistency to the codebase.
**The controller** is responsible for orchestrating the behaviour of your **module**.
_Imagine it like a web application service._
> 📘 **For example**, creating players for listening to music on voice channels
## Build System
**Quetza** relies on various dependencies to function correctly. The **[package.json](./package.json)** file lists the _engines_, _toolchain_, _dependencies_, and other _metadata_.
> ⚠️ **pnpm** is being used as package manager.
### External Dependencies
**Modules** (or maybe **Quetza** itself) can leverage external _binaries_
that shall be located at _bin/_ directory or be accessible from _the environment_.
> 🔗 **Listing** of extenal _dependencies_
| **Base/Module** | **Dependency** |
| --------------- | ------------------------------------------ |
| Music | [yt-dlp](https://github.com/yt-dlp/yt-dlp) |
### Shipping
#### Docker
[![Build Status](https://github.com/unknowableshade/quetza/actions/workflows/ci.yml/badge.svg?branch=master&event=push)](https://github.com/unknowableshade/quetza/actions/workflows/ci.yml)
The primary method of deploying **Quetza** is through
a **[Docker Image](https://hub.docker.com/repository/docker/unknowableshade/quetza)**
provided by the respective
**[action](./.github/workflows/ci.yml)**.
```bash
docker run \
-e DISCORD_TOKEN=*aquired via Discord Developer Portal* \
--name quetza -d \
unknowableshade/quetza-bot:latest
```
#### From Source
Although it is possible to run it directly from the source, some additional steps are required.
- Compile _TypeScript_
```bash
pnpm run build
```
- Run **Quetza**
```bash
pnpm start
```
#### Environment
In any case, you have to provide **environmental variables** for **bot** to run.
- **DISCORD_TOKEN**: generated token issued by **Discord** itself at
**[Developer Portal](https://discord.com/developers/applications)**.
## Licence
**Quetza's** source code is licensed under **[the MIT Licence](,/LICENSE)**.
**Notice!** This does not cover '**_Quetza_**' branding such as **[logo](./assets/quetza-logo.png)** or other **[assets listed](./assets/)**, as these are protected by **[the separate licence](./assets/LICENSE)**.
+407
View File
@@ -0,0 +1,407 @@
Attribution-NonCommercial 4.0 International
=======================================================================
Creative Commons Corporation ("Creative Commons") is not a law firm and
does not provide legal services or legal advice. Distribution of
Creative Commons public licenses does not create a lawyer-client or
other relationship. Creative Commons makes its licenses and related
information available on an "as-is" basis. Creative Commons gives no
warranties regarding its licenses, any material licensed under their
terms and conditions, or any related information. Creative Commons
disclaims all liability for damages resulting from their use to the
fullest extent possible.
Using Creative Commons Public Licenses
Creative Commons public licenses provide a standard set of terms and
conditions that creators and other rights holders may use to share
original works of authorship and other material subject to copyright
and certain other rights specified in the public license below. The
following considerations are for informational purposes only, are not
exhaustive, and do not form part of our licenses.
Considerations for licensors: Our public licenses are
intended for use by those authorized to give the public
permission to use material in ways otherwise restricted by
copyright and certain other rights. Our licenses are
irrevocable. Licensors should read and understand the terms
and conditions of the license they choose before applying it.
Licensors should also secure all rights necessary before
applying our licenses so that the public can reuse the
material as expected. Licensors should clearly mark any
material not subject to the license. This includes other CC-
licensed material, or material used under an exception or
limitation to copyright. More considerations for licensors:
wiki.creativecommons.org/Considerations_for_licensors
Considerations for the public: By using one of our public
licenses, a licensor grants the public permission to use the
licensed material under specified terms and conditions. If
the licensor's permission is not necessary for any reason--for
example, because of any applicable exception or limitation to
copyright--then that use is not regulated by the license. Our
licenses grant only permissions under copyright and certain
other rights that a licensor has authority to grant. Use of
the licensed material may still be restricted for other
reasons, including because others have copyright or other
rights in the material. A licensor may make special requests,
such as asking that all changes be marked or described.
Although not required by our licenses, you are encouraged to
respect those requests where reasonable. More considerations
for the public:
wiki.creativecommons.org/Considerations_for_licensees
=======================================================================
Creative Commons Attribution-NonCommercial 4.0 International Public
License
By exercising the Licensed Rights (defined below), You accept and agree
to be bound by the terms and conditions of this Creative Commons
Attribution-NonCommercial 4.0 International Public License ("Public
License"). To the extent this Public License may be interpreted as a
contract, You are granted the Licensed Rights in consideration of Your
acceptance of these terms and conditions, and the Licensor grants You
such rights in consideration of benefits the Licensor receives from
making the Licensed Material available under these terms and
conditions.
Section 1 -- Definitions.
a. Adapted Material means material subject to Copyright and Similar
Rights that is derived from or based upon the Licensed Material
and in which the Licensed Material is translated, altered,
arranged, transformed, or otherwise modified in a manner requiring
permission under the Copyright and Similar Rights held by the
Licensor. For purposes of this Public License, where the Licensed
Material is a musical work, performance, or sound recording,
Adapted Material is always produced where the Licensed Material is
synched in timed relation with a moving image.
b. Adapter's License means the license You apply to Your Copyright
and Similar Rights in Your contributions to Adapted Material in
accordance with the terms and conditions of this Public License.
c. Copyright and Similar Rights means copyright and/or similar rights
closely related to copyright including, without limitation,
performance, broadcast, sound recording, and Sui Generis Database
Rights, without regard to how the rights are labeled or
categorized. For purposes of this Public License, the rights
specified in Section 2(b)(1)-(2) are not Copyright and Similar
Rights.
d. Effective Technological Measures means those measures that, in the
absence of proper authority, may not be circumvented under laws
fulfilling obligations under Article 11 of the WIPO Copyright
Treaty adopted on December 20, 1996, and/or similar international
agreements.
e. Exceptions and Limitations means fair use, fair dealing, and/or
any other exception or limitation to Copyright and Similar Rights
that applies to Your use of the Licensed Material.
f. Licensed Material means the artistic or literary work, database,
or other material to which the Licensor applied this Public
License.
g. Licensed Rights means the rights granted to You subject to the
terms and conditions of this Public License, which are limited to
all Copyright and Similar Rights that apply to Your use of the
Licensed Material and that the Licensor has authority to license.
h. Licensor means the individual(s) or entity(ies) granting rights
under this Public License.
i. NonCommercial means not primarily intended for or directed towards
commercial advantage or monetary compensation. For purposes of
this Public License, the exchange of the Licensed Material for
other material subject to Copyright and Similar Rights by digital
file-sharing or similar means is NonCommercial provided there is
no payment of monetary compensation in connection with the
exchange.
j. Share means to provide material to the public by any means or
process that requires permission under the Licensed Rights, such
as reproduction, public display, public performance, distribution,
dissemination, communication, or importation, and to make material
available to the public including in ways that members of the
public may access the material from a place and at a time
individually chosen by them.
k. Sui Generis Database Rights means rights other than copyright
resulting from Directive 96/9/EC of the European Parliament and of
the Council of 11 March 1996 on the legal protection of databases,
as amended and/or succeeded, as well as other essentially
equivalent rights anywhere in the world.
l. You means the individual or entity exercising the Licensed Rights
under this Public License. Your has a corresponding meaning.
Section 2 -- Scope.
a. License grant.
1. Subject to the terms and conditions of this Public License,
the Licensor hereby grants You a worldwide, royalty-free,
non-sublicensable, non-exclusive, irrevocable license to
exercise the Licensed Rights in the Licensed Material to:
a. reproduce and Share the Licensed Material, in whole or
in part, for NonCommercial purposes only; and
b. produce, reproduce, and Share Adapted Material for
NonCommercial purposes only.
2. Exceptions and Limitations. For the avoidance of doubt, where
Exceptions and Limitations apply to Your use, this Public
License does not apply, and You do not need to comply with
its terms and conditions.
3. Term. The term of this Public License is specified in Section
6(a).
4. Media and formats; technical modifications allowed. The
Licensor authorizes You to exercise the Licensed Rights in
all media and formats whether now known or hereafter created,
and to make technical modifications necessary to do so. The
Licensor waives and/or agrees not to assert any right or
authority to forbid You from making technical modifications
necessary to exercise the Licensed Rights, including
technical modifications necessary to circumvent Effective
Technological Measures. For purposes of this Public License,
simply making modifications authorized by this Section 2(a)
(4) never produces Adapted Material.
5. Downstream recipients.
a. Offer from the Licensor -- Licensed Material. Every
recipient of the Licensed Material automatically
receives an offer from the Licensor to exercise the
Licensed Rights under the terms and conditions of this
Public License.
b. No downstream restrictions. You may not offer or impose
any additional or different terms or conditions on, or
apply any Effective Technological Measures to, the
Licensed Material if doing so restricts exercise of the
Licensed Rights by any recipient of the Licensed
Material.
6. No endorsement. Nothing in this Public License constitutes or
may be construed as permission to assert or imply that You
are, or that Your use of the Licensed Material is, connected
with, or sponsored, endorsed, or granted official status by,
the Licensor or others designated to receive attribution as
provided in Section 3(a)(1)(A)(i).
b. Other rights.
1. Moral rights, such as the right of integrity, are not
licensed under this Public License, nor are publicity,
privacy, and/or other similar personality rights; however, to
the extent possible, the Licensor waives and/or agrees not to
assert any such rights held by the Licensor to the limited
extent necessary to allow You to exercise the Licensed
Rights, but not otherwise.
2. Patent and trademark rights are not licensed under this
Public License.
3. To the extent possible, the Licensor waives any right to
collect royalties from You for the exercise of the Licensed
Rights, whether directly or through a collecting society
under any voluntary or waivable statutory or compulsory
licensing scheme. In all other cases the Licensor expressly
reserves any right to collect such royalties, including when
the Licensed Material is used other than for NonCommercial
purposes.
Section 3 -- License Conditions.
Your exercise of the Licensed Rights is expressly made subject to the
following conditions.
a. Attribution.
1. If You Share the Licensed Material (including in modified
form), You must:
a. retain the following if it is supplied by the Licensor
with the Licensed Material:
i. identification of the creator(s) of the Licensed
Material and any others designated to receive
attribution, in any reasonable manner requested by
the Licensor (including by pseudonym if
designated);
ii. a copyright notice;
iii. a notice that refers to this Public License;
iv. a notice that refers to the disclaimer of
warranties;
v. a URI or hyperlink to the Licensed Material to the
extent reasonably practicable;
b. indicate if You modified the Licensed Material and
retain an indication of any previous modifications; and
c. indicate the Licensed Material is licensed under this
Public License, and include the text of, or the URI or
hyperlink to, this Public License.
2. You may satisfy the conditions in Section 3(a)(1) in any
reasonable manner based on the medium, means, and context in
which You Share the Licensed Material. For example, it may be
reasonable to satisfy the conditions by providing a URI or
hyperlink to a resource that includes the required
information.
3. If requested by the Licensor, You must remove any of the
information required by Section 3(a)(1)(A) to the extent
reasonably practicable.
4. If You Share Adapted Material You produce, the Adapter's
License You apply must not prevent recipients of the Adapted
Material from complying with this Public License.
Section 4 -- Sui Generis Database Rights.
Where the Licensed Rights include Sui Generis Database Rights that
apply to Your use of the Licensed Material:
a. for the avoidance of doubt, Section 2(a)(1) grants You the right
to extract, reuse, reproduce, and Share all or a substantial
portion of the contents of the database for NonCommercial purposes
only;
b. if You include all or a substantial portion of the database
contents in a database in which You have Sui Generis Database
Rights, then the database in which You have Sui Generis Database
Rights (but not its individual contents) is Adapted Material; and
c. You must comply with the conditions in Section 3(a) if You Share
all or a substantial portion of the contents of the database.
For the avoidance of doubt, this Section 4 supplements and does not
replace Your obligations under this Public License where the Licensed
Rights include other Copyright and Similar Rights.
Section 5 -- Disclaimer of Warranties and Limitation of Liability.
a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE
EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS
AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF
ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS,
IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION,
WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR
PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS,
ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT
KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT
ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU.
b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE
TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION,
NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT,
INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES,
COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR
USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN
ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR
DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR
IN PART, THIS LIMITATION MAY NOT APPLY TO YOU.
c. The disclaimer of warranties and limitation of liability provided
above shall be interpreted in a manner that, to the extent
possible, most closely approximates an absolute disclaimer and
waiver of all liability.
Section 6 -- Term and Termination.
a. This Public License applies for the term of the Copyright and
Similar Rights licensed here. However, if You fail to comply with
this Public License, then Your rights under this Public License
terminate automatically.
b. Where Your right to use the Licensed Material has terminated under
Section 6(a), it reinstates:
1. automatically as of the date the violation is cured, provided
it is cured within 30 days of Your discovery of the
violation; or
2. upon express reinstatement by the Licensor.
For the avoidance of doubt, this Section 6(b) does not affect any
right the Licensor may have to seek remedies for Your violations
of this Public License.
c. For the avoidance of doubt, the Licensor may also offer the
Licensed Material under separate terms or conditions or stop
distributing the Licensed Material at any time; however, doing so
will not terminate this Public License.
d. Sections 1, 5, 6, 7, and 8 survive termination of this Public
License.
Section 7 -- Other Terms and Conditions.
a. The Licensor shall not be bound by any additional or different
terms or conditions communicated by You unless expressly agreed.
b. Any arrangements, understandings, or agreements regarding the
Licensed Material not stated herein are separate from and
independent of the terms and conditions of this Public License.
Section 8 -- Interpretation.
a. For the avoidance of doubt, this Public License does not, and
shall not be interpreted to, reduce, limit, restrict, or impose
conditions on any use of the Licensed Material that could lawfully
be made without permission under this Public License.
b. To the extent possible, if any provision of this Public License is
deemed unenforceable, it shall be automatically reformed to the
minimum extent necessary to make it enforceable. If the provision
cannot be reformed, it shall be severed from this Public License
without affecting the enforceability of the remaining terms and
conditions.
c. No term or condition of this Public License will be waived and no
failure to comply consented to unless expressly agreed to by the
Licensor.
d. Nothing in this Public License constitutes or may be interpreted
as a limitation upon, or waiver of, any privileges and immunities
that apply to the Licensor or You, including from the legal
processes of any jurisdiction or authority.
=======================================================================
Creative Commons is not a party to its public
licenses. Notwithstanding, Creative Commons may elect to apply one of
its public licenses to material it publishes and in those instances
will be considered the “Licensor.” The text of the Creative Commons
public licenses is dedicated to the public domain under the CC0 Public
Domain Dedication. Except for the limited purpose of indicating that
material is shared under a Creative Commons public license or as
otherwise permitted by the Creative Commons policies published at
creativecommons.org/policies, Creative Commons does not authorize the
use of the trademark "Creative Commons" or any other trademark or logo
of Creative Commons without its prior written consent including,
without limitation, in connection with any unauthorized modifications
to any of its public licenses or any other arrangements,
understandings, or agreements concerning use of licensed material. For
the avoidance of doubt, this paragraph does not form part of the
public licenses.
Creative Commons may be contacted at creativecommons.org.
Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Executable
+9
View File
@@ -0,0 +1,9 @@
#!/bin/sh
# Build typescript
pnpm exec tsc --build
# Map path aliases from tsconfigs
for TS_CONFIG in ./config.tsconfig.json ./src/tsconfig.json ./modules/*/tsconfig.json; do
pnpm exec tsc-alias -p "$TS_CONFIG"
done
+58
View File
@@ -0,0 +1,58 @@
import { ActivityOptions, ActivityType, ColorResolvable } from "discord.js";
import { dirname, join } from "path";
import { fileURLToPath } from "url";
/** Quetza project root directory. */
const rootDir = dirname(fileURLToPath(import.meta.url));
/** Quetza's modules directory. */
const modulesDir = join(rootDir, "/modules/");
/** External binaries directory. */
const binariesDir = join(rootDir, "/bin/");
/** Logger files directory. */
const loggerDir = join(rootDir, "/log/");
/** Locations that might be useful in application. */
const path = {
root: rootDir,
modules: modulesDir,
binaries: binariesDir,
log: loggerDir
};
/** Discord bot token. */
const appToken = process.env.DISCORD_TOKEN;
/** Discord bot user activity status */
const appActivity = { type: ActivityType.Watching, name: "over you" };
/** Bot application configuration variables. */
const application = {
token: appToken,
activity: appActivity as ActivityOptions
};
/** Colors Quetza uses in responses. */
const colors = {
default: "#ff6e9b" as ColorResolvable,
error: "#b94e59" as ColorResolvable,
warning: "#e5925f" as ColorResolvable,
info: "#367df6" as ColorResolvable,
success: "#83d18e" as ColorResolvable
};
/** Guild where testing occures. */
const testGuild = "912401672939139142";
/** Developing options. */
const dev = {
guild: testGuild
};
/** Exported bundled configuration object. */
const config = {
application,
colors,
path,
dev
};
export default config;
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "./dist",
"composite": true
},
"include": ["config.ts"]
}
+32
View File
@@ -0,0 +1,32 @@
/**
* /modules
*
* Replies module listing as well as description of each.
*
* Just for the information I guess...
*
* @packageDocumentation
*/
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
import Client from "$lib/client.js";
import logger from "$lib/logger.js";
import replies from "../lib/replies.js";
async function execute(client: Client, interaction: ChatInputCommandInteraction): Promise<void> {
if (!interaction.isChatInputCommand()) {
logger.warn("Interaction rejected.", { interaction });
return;
}
await interaction.reply(replies.modules(client.modules));
}
const data = new SlashCommandBuilder()
.setName("modules")
.setDescription("List of availiable modules.");
export { data, execute };
+31
View File
@@ -0,0 +1,31 @@
/**
* /ping
*
* Replies websocket ping (ping between bot and discord) and real ping (ping to fetch + process).
*
* @remarks Probably should double their value as two ways, but nvm.
*
* @packageDocumentation
*/
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
import Client from "$lib/client.js";
import replies from "../lib/replies.js";
async function execute(client: Client, interaction: ChatInputCommandInteraction): Promise<void> {
if (!interaction.isChatInputCommand()) {
return;
}
const reply = await interaction.reply({ ...replies.ping(client.ws.ping), fetchReply: true });
await interaction.editReply(
replies.ping(client.ws.ping, reply.createdTimestamp - interaction.createdTimestamp)
);
}
const data = new SlashCommandBuilder().setName("ping").setDescription("Try to ping me.");
export { data, execute };
+42
View File
@@ -0,0 +1,42 @@
/**
* InteractionCreate
*
* Resolves creation of an interaction by users.
*
* @remark Only {@link ChatInputCommand} currently being resolved.
*
* @packageDocumentation
*/
import { Events, Interaction } from "discord.js";
import Client from "$lib/client.js";
import logger from "$lib/logger.js";
async function execute(client: Client, eventee: [Interaction]): Promise<void> {
const [interaction] = eventee;
if (!interaction.isChatInputCommand()) {
logger.notice("Interaction is not a ChatInputCommand.");
return;
}
const command = client.commands.get(interaction.commandName);
if (!command) {
logger.notice("Interaction does not exist.", interaction);
return;
}
try {
logger.info("Interaction was created.", interaction);
await command.execute(client, interaction, command.module.controller);
} catch (error) {
logger.error("Interaction has and error occured.", error, interaction);
}
}
const name = Events.InteractionCreate;
export { execute, name };
+50
View File
@@ -0,0 +1,50 @@
/**
* Ready
*
* Will execute upon emitting Client's Ready event.
*
* @remarks Will push slash commands, set activity status,
* and log bot's initials.
*
* @packageDocumentation
*/
import { generateDependencyReport } from "@discordjs/voice";
import { Events } from "discord.js";
import config from "$config.js";
import Client from "$lib/client.js";
import logger from "$lib/logger.js";
async function execute(client: Client<true>): Promise<void> {
const commandData = client.commands.map((value) => value.data);
// To not reload bot's global slash commands at every dev build.
if (process.env.NODE_ENV === "development") {
logger.info(
"Development mode: Commands will be pushed to the _test guild_!",
client.generateApplicationStatus()
);
logger.info("Dependency report by '@discordjs/voice'.", {
report: generateDependencyReport()
});
const guild = await client.guilds.fetch(config.dev.guild);
guild.commands.set(commandData);
}
if (process.env.NODE_ENV === "production") {
logger.info(
"Production mode: Commands will be pushed to the _application_!",
client.generateApplicationStatus()
);
await client.application.commands.set(commandData);
}
client.user.setActivity(config.application.activity);
}
const name = Events.ClientReady;
export { execute, name };
+34
View File
@@ -0,0 +1,34 @@
import { BaseMessageOptions, Collection, EmbedBuilder, italic } from "discord.js";
import config from "$config.js";
import { Module } from "$lib/types.js";
const replies = {
modules: (modules: Collection<string, Module>): BaseMessageOptions => {
const embed = new EmbedBuilder()
.setColor(config.colors.default)
.setTitle("Avaliable modules")
.addFields(
modules.map((value) => ({
name: value.name,
value: italic(value.description ?? "")
}))
);
return { embeds: [embed] };
},
ping: (websocket: number, roundtrip?: number): BaseMessageOptions => {
const title = roundtrip ? `I've pinged ${roundtrip} ms. late` : "Pinging...";
const comment = `Websocket ping is ${websocket} ms.`;
const embed = new EmbedBuilder()
.setColor(config.colors.default)
.setTitle(title)
.setDescription(italic(comment));
return { embeds: [embed] };
}
};
export default replies;
+20
View File
@@ -0,0 +1,20 @@
/**
* Core Quetza Module definition.
*
* This module provides default capabilities.
* - Comands are being resolved here at InteractionCreated event.
* - Aplication status being generated as well as commands pushed to Discord at Ready event.
*
* Also this module provides basic commands:
* - ping
* - modules
*
* There a lot of speaking because it's as important as $lib.
*
* @packageDocumentation
*/
const name = "core";
const description = "Quetza's core functionality";
export { description, name };
+16
View File
@@ -0,0 +1,16 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"paths": {
"$config.js": ["../../config.js"],
"$lib/*": ["../../src/lib/*"],
"$mlib/*": ["./lib/*"]
},
"rootDir": ".",
"outDir": "../../dist/modules/core",
"composite": true
},
"references": [{ "path": "../../src/" }]
}
+36
View File
@@ -0,0 +1,36 @@
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
import Client from "$lib/client.js";
import logger from "$lib/logger.js";
import replies from "../lib/replies.js";
import { controller } from "../module.js";
async function execute(client: Client, interaction: ChatInputCommandInteraction): Promise<void> {
if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) {
logger.warn("Interaction rejected.", { interaction });
return;
}
const player = controller.get(interaction.guild, interaction.channel);
if (!player) {
await interaction.reply(replies.notExists());
return;
}
const amount = player.queue.tracks.length;
player.clear();
await interaction.reply(replies.cleared(amount));
}
const data = new SlashCommandBuilder()
.setName("clear")
.setDescription("Clear the player's queue.")
.setDMPermission(false);
export { data, execute };
+44
View File
@@ -0,0 +1,44 @@
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
import Client from "$lib/client.js";
import logger from "$lib/logger.js";
import replies from "../lib/replies.js";
import { controller } from "../module.js";
async function execute(client: Client, interaction: ChatInputCommandInteraction): Promise<void> {
if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) {
logger.warn("Interaction rejected.", { interaction });
return;
}
await interaction.deferReply();
const player =
controller.get(interaction.guild, interaction.channel) ??
controller.set(interaction.guild, interaction.channel);
const channel = interaction.member.voice.channel;
if (!channel) {
controller.delete(interaction.guildId);
await interaction.editReply(replies.notConnected());
return;
}
player.connect(channel);
await interaction.editReply(replies.okConnected(channel.name));
return;
}
const data = new SlashCommandBuilder()
.setName("connect")
.setDescription("Connect me explicitly to the voice channel.")
.setDMPermission(false);
export { data, execute };
+43
View File
@@ -0,0 +1,43 @@
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
import Client from "$lib/client.js";
import logger from "$lib/logger.js";
import Filter from "../lib/filter.js";
import replies from "../lib/replies.js";
import { controller } from "../module.js";
async function execute(client: Client, interaction: ChatInputCommandInteraction) {
if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) {
logger.warn("Interaction rejected.", { interaction });
return;
}
const filter = interaction.options.getString("filter") ?? undefined;
const player = controller.get(interaction.guild, interaction.channel);
if (!player) {
await interaction.reply(replies.notExists());
return;
}
const status = player.setFilter(filter);
await interaction.reply(replies.filtered(filter, status));
}
const data = new SlashCommandBuilder()
.setName("filter")
.setDescription("Apply unique filters to the playback.")
.addStringOption((option) =>
option
.setName("filter")
.setDescription("Filter to apply.")
.setChoices(...Object.values(Filter.FILTERS))
)
.setDMPermission(false);
export { data, execute };
+32
View File
@@ -0,0 +1,32 @@
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
import Client from "$lib/client.js";
import logger from "$lib/logger.js";
import replies from "../lib/replies.js";
import { controller } from "../module.js";
async function execute(client: Client, interaction: ChatInputCommandInteraction) {
if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) {
logger.warn("Interaction rejected.", { interaction });
return;
}
const player = controller.get(interaction.guild, interaction.channel);
if (!player) {
await interaction.reply(replies.notExists());
return;
}
await interaction.reply(replies.info(player));
}
const data = new SlashCommandBuilder()
.setName("info")
.setDescription("Verbose information about player and stats.")
.setDMPermission(false);
export { data, execute };
+45
View File
@@ -0,0 +1,45 @@
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
import Client from "$lib/client.js";
import logger from "$lib/logger.js";
import replies from "../lib/replies.js";
import { controller } from "../module.js";
async function execute(client: Client, interaction: ChatInputCommandInteraction) {
if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) {
logger.warn("Interaction rejected.", { interaction });
return;
}
const query = interaction.options.getString("query", true);
const pos = interaction.options.getInteger("position", true);
await interaction.deferReply();
const player = controller.get(interaction.guild, interaction.channel);
if (!player) {
await interaction.editReply(replies.notExists());
return;
}
const track = await player.add(query, interaction.user, pos - 1);
await interaction.editReply(replies.appended(track));
}
const data = new SlashCommandBuilder()
.setName("insert")
.setDescription("Insert a new track to specific position in the queue.")
.addStringOption((option) =>
option.setName("query").setDescription("Title or URL of the song.").setRequired(true)
)
.addIntegerOption((option) =>
option.setName("position").setDescription("Position to insert.").setRequired(true)
)
.setDMPermission(false);
export { data, execute };
+44
View File
@@ -0,0 +1,44 @@
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
import Client from "$lib/client.js";
import logger from "$lib/logger.js";
import replies from "../lib/replies.js";
import { controller } from "../module.js";
async function execute(client: Client, interaction: ChatInputCommandInteraction) {
if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) {
logger.warn("Interaction rejected.", { interaction });
return;
}
const query = interaction.options.getString("query", true);
const player = controller.get(interaction.guild, interaction.channel);
if (!player) {
await interaction.reply(replies.notExists());
return;
}
const previous = player.resource?.metadata;
const position =
!isNaN(+query) && isFinite(+query) && !/e/i.test(query)
? player.jump(parseInt(query) - 1)
: player.jump(query);
await interaction.reply(replies.jumped(previous, position));
}
const data = new SlashCommandBuilder()
.setName("jump")
.setDescription("Jump to specific track in the queue.")
.addStringOption((option) =>
option.setName("query").setDescription("Position or title to jump to.").setRequired(true)
)
.setDMPermission(false);
export { data, execute };
+49
View File
@@ -0,0 +1,49 @@
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
import Client from "$lib/client.js";
import logger from "$lib/logger.js";
import replies from "../lib/replies.js";
import { LoopOption } from "../lib/types.js";
import { controller } from "../module.js";
async function execute(client: Client, interaction: ChatInputCommandInteraction) {
if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) {
logger.warn("Interaction rejected.", { interaction });
return;
}
const option = interaction.options.getString("option", true) as LoopOption;
const player = controller.get(interaction.guild, interaction.channel);
if (!player) {
await interaction.reply(replies.notExists());
return;
}
player.queue.loop = option;
await interaction.reply(replies.looped(option));
}
const data = new SlashCommandBuilder()
.setName("loop")
.setDescription("Select player's loop mode.")
.addStringOption((option) =>
option
.setName("option")
.setDescription("Loop option.")
.setChoices(
{ name: "Loop queue", value: "LOOP" },
{ name: "Loop single track", value: "SONG" },
{ name: "Drop on final", value: "NONE" },
{ name: "Random", value: "AUTO" }
)
.setRequired(true)
)
.setDMPermission(false);
export { data, execute };
+34
View File
@@ -0,0 +1,34 @@
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
import Client from "$lib/client.js";
import logger from "$lib/logger.js";
import replies from "../lib/replies.js";
import { controller } from "../module.js";
async function execute(client: Client, interaction: ChatInputCommandInteraction) {
if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) {
logger.warn("Interaction rejected.", { interaction });
return;
}
const player = controller.get(interaction.guild, interaction.channel);
if (!player) {
await interaction.reply(replies.notExists());
return;
}
player.skip();
await interaction.reply(replies.skipped());
}
const data = new SlashCommandBuilder()
.setName("next")
.setDescription("Skip current song.")
.setDMPermission(false);
export { data, execute };
+34
View File
@@ -0,0 +1,34 @@
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
import Client from "$lib/client.js";
import logger from "$lib/logger.js";
import replies from "../lib/replies.js";
import { controller } from "../module.js";
async function execute(client: Client, interaction: ChatInputCommandInteraction) {
if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) {
logger.warn("Interaction rejected.", { interaction });
return;
}
const player = controller.get(interaction.guild, interaction.channel);
if (!player) {
await interaction.reply(replies.notExists());
return;
}
const state = player.pause();
await interaction.reply(replies.paused(state));
}
const data = new SlashCommandBuilder()
.setName("pause")
.setDescription("Toggle pause on playback.")
.setDMPermission(false);
export { data, execute };
+59
View File
@@ -0,0 +1,59 @@
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
import Client from "$lib/client.js";
import logger from "$lib/logger.js";
import replies from "../lib/replies.js";
import { controller } from "../module.js";
async function execute(client: Client, interaction: ChatInputCommandInteraction) {
if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) {
logger.warn("Interaction rejected.", { interaction });
return;
}
const query = interaction.options.getString("query");
if (query) {
await interaction.reply(replies.searching());
} else {
await interaction.deferReply();
}
const player =
controller.get(interaction.guild, interaction.channel) ??
controller.set(interaction.guild, interaction.channel);
const channel = interaction.member.voice.channel;
if (!channel) {
controller.delete(interaction.guildId);
await interaction.editReply(replies.notConnected());
return;
}
player.connect(channel);
if (query) {
const track = await player.add(query, interaction.user);
await interaction.editReply(replies.appended(track));
}
if (!player.resource) {
await player.play();
}
}
const data = new SlashCommandBuilder()
.setName("play")
.setDescription("Launch player and add songs to the queue.")
.addStringOption((option) =>
option.setName("query").setDescription("Title or URL of the song.")
)
.setDMPermission(false);
export { data, execute };
+87
View File
@@ -0,0 +1,87 @@
import {
ActionRowBuilder,
ButtonBuilder,
ButtonInteraction,
ButtonStyle,
ChatInputCommandInteraction,
MessageActionRowComponentBuilder,
SlashCommandBuilder
} from "discord.js";
import Client from "$lib/client.js";
import logger from "$lib/logger.js";
import replies from "../lib/replies.js";
import { controller } from "../module.js";
async function execute(client: Client, interaction: ChatInputCommandInteraction) {
if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) {
logger.warn("Interaction rejected.", { interaction });
return;
}
await interaction.deferReply();
const player = controller.get(interaction.guild, interaction.channel);
if (!player) {
await interaction.editReply(replies.notExists());
return;
}
if (player.queue.empty()) {
await interaction.editReply(replies.queue(player.queue));
return;
}
let position = 1;
const row = new ActionRowBuilder<MessageActionRowComponentBuilder>().addComponents(
new ButtonBuilder().setCustomId("Top").setLabel("🔼").setStyle(ButtonStyle.Primary),
new ButtonBuilder().setCustomId("Up").setLabel("🔺").setStyle(ButtonStyle.Secondary),
new ButtonBuilder().setCustomId("Down").setLabel("🔻").setStyle(ButtonStyle.Secondary),
new ButtonBuilder().setCustomId("End").setLabel("🔽").setStyle(ButtonStyle.Primary)
);
const message = await interaction.editReply({
...replies.queue(player.queue, 0, player.resource),
components: [row]
});
const collector = message.createMessageComponentCollector({ time: 30000 });
collector.on("collect", async (btn: ButtonInteraction) => {
switch (btn.customId) {
case "Top": {
position = 1;
break;
}
case "Up": {
position = Math.max(position - 10, 1);
break;
}
case "Down": {
position = Math.min(
position + 10,
Math.floor(player.queue.tracks.length / 10) * 10
);
break;
}
case "End": {
position = Math.floor(player.queue.tracks.length / 10) * 10;
}
}
await btn.update(replies.queue(player.queue, position - 1, player.resource));
});
}
const data = new SlashCommandBuilder()
.setName("queue")
.setDescription("Print interactive queue pointing to current track.")
.setDMPermission(false);
export { data, execute };
+42
View File
@@ -0,0 +1,42 @@
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
import Client from "$lib/client.js";
import logger from "$lib/logger.js";
import replies from "../lib/replies.js";
import { controller } from "../module.js";
async function execute(client: Client, interaction: ChatInputCommandInteraction) {
if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) {
logger.warn("Interaction rejected.", { interaction });
return;
}
const query = interaction.options.getString("query", true);
const player = controller.get(interaction.guild, interaction.channel);
if (!player) {
await interaction.reply(replies.notExists());
return;
}
const track =
!isNaN(+query) && isFinite(+query) && !/e/i.test(query)
? player.remove(parseInt(query) - 1)
: player.remove(query);
await interaction.reply(replies.removed(track));
}
const data = new SlashCommandBuilder()
.setName("remove")
.setDescription("Remove specific track in the queue.")
.addStringOption((option) =>
option.setName("query").setDescription("Position or title to remove.").setRequired(true)
)
.setDMPermission(false);
export { data, execute };
+34
View File
@@ -0,0 +1,34 @@
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
import Client from "$lib/client.js";
import logger from "$lib/logger.js";
import replies from "../lib/replies.js";
import { controller } from "../module.js";
async function execute(client: Client, interaction: ChatInputCommandInteraction) {
if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) {
logger.warn("Interaction rejected.", { interaction });
return;
}
const player = controller.get(interaction.guild, interaction.channel);
if (!player) {
await interaction.reply(replies.notExists());
return;
}
player.shuffle();
await interaction.reply(replies.reshuffle());
}
const data = new SlashCommandBuilder()
.setName("reshuffle")
.setDescription("Reshuffle the queue. (Will change queue order)")
.setDMPermission(false);
export { data, execute };
+51
View File
@@ -0,0 +1,51 @@
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
import Client from "$lib/client.js";
import logger from "$lib/logger.js";
import replies from "../lib/replies.js";
import { controller } from "../module.js";
async function execute(client: Client, interaction: ChatInputCommandInteraction) {
if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) {
logger.warn("Interaction rejected.", { interaction });
return;
}
const hours = interaction.options.getInteger("hrs") ?? 0;
const minutes = interaction.options.getInteger("mins") ?? 0;
const seconds = interaction.options.getInteger("secs") ?? 0;
const player = controller.get(interaction.guild, interaction.channel);
if (!player) {
await interaction.reply(replies.notExists());
return;
}
if (!player.resource) {
await interaction.reply(replies.notPlaying());
return;
}
const prevTime = player.resource.playbackDuration / 1000;
const nextTime = hours * 3600 + minutes * 60 + seconds;
player.seek(nextTime * 1000);
await interaction.reply(replies.seeked(prevTime, nextTime));
}
const data = new SlashCommandBuilder()
.setName("seek")
.setDescription("Fast-forward/Fast-reverse by the given time.")
.addIntegerOption((option) => option.setName("hrs").setDescription("Hours to seek."))
.addIntegerOption((option) => option.setName("mins").setDescription("Minutes to seek."))
.addIntegerOption((option) => option.setName("secs").setDescription("Seconds to seek."))
.setDMPermission(false);
export { data, execute };
+34
View File
@@ -0,0 +1,34 @@
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
import Client from "$lib/client.js";
import logger from "$lib/logger.js";
import replies from "../lib/replies.js";
import { controller } from "../module.js";
async function execute(client: Client, interaction: ChatInputCommandInteraction) {
if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) {
logger.warn("Interaction rejected.", { interaction });
return;
}
const player = controller.get(interaction.guild, interaction.channel);
if (!player) {
await interaction.reply(replies.notExists());
return;
}
player.destroy();
await interaction.reply(replies.destroyed());
}
const data = new SlashCommandBuilder()
.setName("stop")
.setDescription("Destroy the player.")
.setDMPermission(false);
export { data, execute };
+39
View File
@@ -0,0 +1,39 @@
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
import Client from "$lib/client.js";
import logger from "$lib/logger.js";
import replies from "../lib/replies.js";
import { controller } from "../module.js";
async function execute(client: Client, interaction: ChatInputCommandInteraction) {
if (!interaction.isChatInputCommand() || !interaction.inCachedGuild() || !interaction.channel) {
logger.warn("Interaction rejected.", { interaction });
return;
}
const volume = interaction.options.getInteger("volume") ?? 100;
const player = controller.get(interaction.guild, interaction.channel);
if (!player) {
await interaction.reply(replies.notExists());
return;
}
player.volume = volume;
await interaction.reply(replies.volumeSet(player.volume));
}
const data = new SlashCommandBuilder()
.setName("volume")
.setDescription("Change player's volume amount.")
.addIntegerOption((option) =>
option.setName("volume").setDescription("Volume in percents.").setRequired(true)
)
.setDMPermission(false);
export { data, execute };
@@ -0,0 +1,66 @@
/**
* voiceStateUpdate
*
* Monitors voice states of attended guilds.
* If Quetza happens to be alone in the Voice Channel - destroys guilds player and disconnects.
*
* @packageDocumentation
*/
import { BaseGuildVoiceChannel, User, VoiceState } from "discord.js";
import Client from "$lib/client.js";
import Music from "$mlib/music.js";
import { asleep } from "../lib/misc.js";
import replies from "../lib/replies.js";
/** Amount of time Quetza can be alone in voice channel. */
const TIME_TILL_DISCONNECT = 30_000;
/**
* Deduct {@link BaseGuildVoiceChannel} user is a member of.
*
* @returns BaseGuildVoiceChannel user is a member of, null if not a member of any.
*
* @param user - User that might be in one of a channels.
* @param channels - List of channels that might have user as it member.
*
* @internal
*/
function resolveVoiceChannel(
user: User,
...channels: (BaseGuildVoiceChannel | null)[]
): BaseGuildVoiceChannel | null {
return (
channels.find((channel) => channel?.members.some((member) => member.user === user)) ?? null
);
}
async function execute(
client: Client<true>,
eventee: [VoiceState, VoiceState],
controller: Music
): Promise<void> {
const [oldState, newState] = eventee;
const player = controller.get(oldState.guild);
const channel = resolveVoiceChannel(client.user, oldState.channel, newState.channel);
if (!player || !channel) {
return;
}
if (channel.members.size === 1) {
await asleep(TIME_TILL_DISCONNECT);
if (channel.members.size === 1 && channel.members.first()?.user === client.user) {
await player.channel.send(replies.alone());
player.destroy();
}
}
}
const name = "voiceStateUpdate";
export { execute, name };
+15
View File
@@ -0,0 +1,15 @@
/**
* @see ../../../src/lib/error.ts
*
* @packageDocumentation
*/
import QuetzaError from "$lib/error.js";
export default class MusicError extends QuetzaError {
public static readonly PREFIX = "MZ";
constructor(msg: string, code: number) {
super(msg, code, MusicError.PREFIX);
}
}
+75
View File
@@ -0,0 +1,75 @@
import { DlpDump, dlpExtractorKey, DlpInfo } from "$mlib/types.js";
/**
* Avaliable extractors.
*
* @internal
*/
export const dlpExtractorsValues = ["Youtube", "Soundcloud"] as const;
/**
* Defines structure for an extractor that will be called by dlpInfo.
*
* For example:
*
* @see the {@link youtubeExtractor | youtubeExtractor(...) } function
*
* @internal
*/
type dlpExtractor = (arg0: DlpDump, arg1: (arg0: string) => Promise<DlpInfo>) => Promise<DlpInfo>;
/**
* Resolves data that was extracted by dlp (depending on extractor_key).
*
* @returns DlpInfo generated from dump provided
*
* @param dump - Parsed dlp output
* @param dlpInfo - Function to call if any other information needed
* (will recurcively call next extractor after its run)
*
* @public
*/
const youtubeExtractor: dlpExtractor = async (dump, dlpInfo) => {
if (dump.id && dump.title && dump.duration) {
const result = {
url: `https://www.youtube.com/watch?v=${dump.id}`,
title: dump.title,
thumbnail: `https://img.youtube.com/vi/${dump.id}/default.jpg`,
duration: dump.duration
};
return [result];
}
return await dlpInfo(dump.original_url);
};
/**
* {@inheritdoc youtubeExtractor}
*/
const soundcloudExtractor: dlpExtractor = async (dump, dlpInfo) => {
if (dump.title && dump.duration && dump.thumbnail) {
const result = {
url: dump.webpage_url ?? dump.original_url,
title: dump.title,
thumbnail: dump.thumbnail,
duration: dump.duration
};
return [result];
}
return await dlpInfo(dump.original_url);
};
/**
* DlpDump extractor_key values mapped to corresponding extractor.
*
* @public
*/
const extractors: Record<dlpExtractorKey, dlpExtractor> = {
Youtube: youtubeExtractor,
Soundcloud: soundcloudExtractor
};
export default extractors;
+199
View File
@@ -0,0 +1,199 @@
import { execFile } from "child_process";
import path from "path";
import prismMedia from "prism-media";
import { Readable } from "stream";
import { promisify } from "util";
import config from "$config.js";
import logger from "$lib/logger.js";
import MusicError from "$mlib/MusicError.js";
import { DlpDump, DlpInfo, dlpStreamOptions } from "../types.js";
import extractors from "./extractors.js";
const { FFmpeg } = prismMedia;
const execFileAsync = promisify(execFile);
/**
* Maximum length of dlp search stack.
*
* @internal
*/
const DLPINFO_DEPTH = 3;
/**
* Path to the Dlp binary.
*
* @internal
*/
const DLP_BINARY = path.join(config.path.binaries, "/yt-dlp");
/**
* Base arguments for Ffmpeg transcoder.
*
* @internal
*/
const FFMPEG_BASE_ARGS = [
"-analyzeduration",
"0",
"-loglevel",
"0",
"-acodec",
"libopus",
"-f",
"opus",
"-ar",
"48000",
"-ac",
"2"
];
/**
* Predicate to check whether is string a valid URL.
*
* @returns true if given string is a valid URL, false otherwise
*
* @param str - Any string
*
* @internal
*/
function isURL(str: string): boolean {
return /^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)$/.test(
str
);
}
/**
* Sub-function of dlpInfo.
*
* @tutorial Shall be used only inside original function
*
* @returns Info of the media that related to query, and ones that cannot be found
*
* @param query - Dlp search request or compatible URL
* @param depth - Recursion depth (force stops at 0)
*
* @internal
*/
async function dlpInfoRecursive(query: string, depth = DLPINFO_DEPTH): Promise<DlpInfo> {
const error = (message: string, reason: number, ...meta: unknown[]): DlpInfo => {
logger.warn(message, { reason, query, ...meta });
throw new MusicError(message, reason);
};
if (!depth) {
return error("Dlp: recursion depth reached", 1010);
}
const subprocess = await execFileAsync(DLP_BINARY, [
query,
"-O",
"%(.{id,original_url,title,uploader,duration,extractor_key,thumbnail})j",
"-s",
"--flat-playlist"
]);
if (!subprocess.stdout) {
return error("Dlp: no stdout", 1011);
}
if (subprocess.stderr) {
return error("Dlp: stderr", 1012, {
stderr: subprocess.stderr
});
}
const dumps: DlpDump[] = subprocess.stdout
.trim()
.split("\n")
.map((out) => JSON.parse(out));
const resolveDump = async (dump: DlpDump): Promise<DlpInfo> => {
if (!Object.hasOwn(extractors, dump.extractor_key)) {
error("Dlp: illegal extractor", 1013);
}
return extractors[dump.extractor_key](dump, (subquery: string) =>
dlpInfoRecursive(subquery, depth - 1)
);
};
const result = await Promise.all(dumps.map(resolveDump));
return result.flat();
}
/**
* Leverages dlp to fetch metadata of media accessible by the extractors.
*
* @returns Metadata to a youtube video that related to query by title or URL of findings
*
* @param query - Dlp search request or compatible URL
*
* @public
*/
export async function dlpInfo(query: string): Promise<DlpInfo> {
return await dlpInfoRecursive(isURL(query) ? query : "ytsearch1:" + query);
}
/**
* Creates Opus stream by downloading media via dlp.
*
* @returns Readable stream of requested media
*
* @param url - Dlp compatible URL to the requested resource
* @param options - Transcoders and dlp options
*
* @public
*/
export function dlpStream(url: string, options?: dlpStreamOptions): Promise<Readable> {
let ffmpegArgs = FFMPEG_BASE_ARGS;
if (options?.seek) {
ffmpegArgs.unshift("-ss", options.seek.toString());
}
if (options?.ffmpeg) {
ffmpegArgs = ffmpegArgs.concat(options.ffmpeg);
}
return new Promise<Readable>((resolve, reject) => {
const subprocess = execFile(
DLP_BINARY,
[
url,
"-o",
"-",
"--verbose",
"-f",
"bestaudio[ext=webm][acodec=opus][asr=48000]/bestaudio",
"--rm-cache-dir",
"-r",
"1M"
],
{ encoding: null, maxBuffer: 25000000 }
);
if (!subprocess.stdout || !subprocess.stderr) {
throw new Error("No stdout");
}
const ffmpegTranscoder = new FFmpeg({ args: ffmpegArgs });
const stream = subprocess.stdout.pipe(ffmpegTranscoder);
const onError = (err: Error) => {
if (!subprocess.killed) {
subprocess.kill();
}
reject(err);
stream.resume();
};
subprocess
.once("spawn", () => {
resolve(stream);
})
.on("error", onError);
});
}
+113
View File
@@ -0,0 +1,113 @@
/**
* Filter entry.
*
* @internal
*/
type filterValue = {
/** Public well formatted name of the filter. */
name: string;
/** FFmpeg argument of this filter. */
value: string;
};
/**
* Record mapping id of filter with its {@link filterValue}.
*
* @internal
*/
const FILTERS: Record<string, filterValue> = {
bassboost1x: { name: "Bassboost 1x", value: "bass=g=15:f=110:w=0.3" },
bassboost2x: { name: "Bassboost 2x", value: "bass=g=30:f=110:w=0.3" },
daycore: { name: "Daycore", value: "aresample=48000,asetrate=48000*0.75" },
nightcore: { name: "Nightcore", value: "aresample=48000,asetrate=48000*1.25" },
treble: {
name: "Treble Boost",
value: "firequalizer=gain_entry='entry(2500,3);entry(6300,6);entry(16000,10)'"
}
} as const;
/**
* Type build of {@link FILTERS} keys.
*
* @internal
*/
type filterPreset = keyof typeof FILTERS;
/**
* {@link ./player#Player | Player's } filters options.
*
* Resolves FFMpeg transcoder arguments that tweak playback stream.
*
* @public
*/
export default class Filter {
/**
* Filter list
*/
public static readonly FILTERS = FILTERS;
/**
* Active filters. Mock of {@link FILTERS} object.
*/
private switches_ = new Map<filterPreset, filterValue>();
/**
* Converts active {@link filterValue | filterValues} to array.
*
* @returns Array of active filter values.
*/
public toggled(): filterValue[] {
return [...this.switches_.values()];
}
/**
* Toggles filter specified. Clears them all if no arguments provided.
*
* @returns True if filter was toggled, False otherwise.
*
* @param query - filter id to toggle. Undefined to clear all.
*/
public toggle(query?: filterPreset): boolean {
if (!query) {
this.switches_.clear();
return false;
}
if (!this.switches_.delete(query)) {
this.switches_.set(query, FILTERS[query]);
return true;
}
return false;
}
/**
* Calculates of active filter map.
*
* @returns Size of active filter map.
*/
public size(): number {
return this.switches_.size;
}
/**
* Checks if there are any active filter.
*
* @returns size === 0 ? True : False.
*/
public empty(): boolean {
return this.size() === 0;
}
/**
* Converts active filters to FFmpeg compatable argument.
*
* @returns FFmpeg compatable argument representing active filters.
*/
public toString(): string {
return this.toggled()
.map((value) => value.value)
.join(",");
}
}
+132
View File
@@ -0,0 +1,132 @@
import { AudioResource } from "@discordjs/voice";
import { LoopOption, Track } from "./types.js";
/**
* Length of playback status bar in emoji symbols.
*
* @intenal
*/
const STATUSBAR_LENGTH = 15;
/**
* Generates pretty playback status bar out of {@link AudioResource}.
*
* @remarks Takes duration and current playback duration to 'draw' bar.
*
* @returns String representing generated status bar.
*
* @param resource - AudioResource with {@link Track} metadata, so duration and playback duration is available.
*
* @privateRemarks I don't know why resource can be undefined - I forgor.
*
* @public
*/
export function statusBarGenerator(resource?: AudioResource<Track>): string {
const duration = resource?.metadata.duration || 1;
const playback = Math.min((resource?.playbackDuration || 0) / 1000, duration);
const filled = Array<string>(Math.floor((STATUSBAR_LENGTH * playback) / duration)).fill("🔹");
const empty = Array<string>(STATUSBAR_LENGTH - filled.length).fill("▫️");
return `[${filled.join("")}${empty.join("")}] ${toISOTime(playback)}/${toISOTime(duration)}`;
}
/**
* Converts seconds to ISO time string.
*
* @returns String representing given seconds in ISO format.
*
* @param amount - Seconds to convert.
*
* @example
* ```
* toISOTime(1337228); // "371:27:08"
* ```
*
* @public
*/
export function toISOTime(amount: number): string {
const h = Math.floor(amount / 3600);
const m = Math.floor((amount % 3600) / 60);
const s = Math.floor((amount % 3600) % 60);
const hDisplay = h > 0 ? `${h}:` : "";
const mDisplay = `${Math.floor(m / 10)}${Math.floor(m % 10)}:`;
const sDisplay = `${Math.floor(s / 10)}${Math.floor(s % 10)}`;
return hDisplay + mDisplay + sDisplay;
}
/**
* Asynchronous timeout.
*
* @returns Promise that will be settled after timeout pass.
*
* @param amount - Milliseconds to wait before settle.
*
* @public
*/
export async function asleep(amount: number): Promise<unknown> {
return new Promise((r) => setTimeout(r, amount));
}
/**
* Converts volume value to emoji representation.
*
* @remarks In span of 0 ... 100 - emoji from muted to loud.
*
* @returns String with single emoji representing specified volume.
*
* @param amount - volume to convert.
*
* @public
*/
export function volumeMoji(amount: number): string {
return amount > 100 ? "🔊" : amount > 50 ? "🔉" : amount > 0 ? "🔈" : "🔇";
}
/**
* Converts {@link LoopOption} to emoji representation..
*
* @returns String with single emoji representing specified loop option.
*
* @param loop - Loop option that will be converted.
*
* @public
*/
export function loopMoji(loop: LoopOption): string {
return loop === "AUTO" ? "🔀" : loop === "LOOP" ? "🔁" : loop === "NONE" ? "⤵️" : "🔂";
}
/**
* Finds larges common sequence (LCS) of characters withing provided strings.
*
* @remarks Used to fuzzy search query title of Track.
*
* @returns Length of the LCS found.
*
* @param s1 - First string.
* @param s2 - Second string.
*
* @public
*/
export function largestCommonSequence(s1: string, s2: string): number {
s1 = s1.toLowerCase().slice(0, 100);
s2 = s2.toLowerCase().slice(0, 100);
const buffer = Array.from(Array(s1.length + 1), () => new Array<number>(s2.length + 1).fill(0));
for (let i = 1; i <= s1.length; i++) {
for (let j = 1; j <= s2.length; j++) {
if (s1[i - 1] === s2[j - 1]) {
buffer[i][j] = buffer[i - 1][j - 1] + 1;
} else {
buffer[i][j] = Math.max(buffer[i - 1][j], buffer[i][j - 1]);
}
}
}
return buffer[s1.length][s2.length];
}
+67
View File
@@ -0,0 +1,67 @@
import { Collection, Guild, GuildTextBasedChannel } from "discord.js";
import logger from "$lib/logger.js";
import Player from "./player.js";
/**
* Module's controller.
*
* Binds guilds to player.
* Creates and destroys them.
*/
export default class Music {
/**
* GuildId to {@link Player} mapping.
*/
private players_ = new Collection<string, Player>();
/**
* Retrieves {@link Player} by {@link Guild}.
*
* @returns Player if such mapping exists or undefined otherwise.
*
* @param guild - Discord guild that might has a Player binded.
* @param channel - Text channel that should replace Player's one.
*/
public get(guild: Guild, channel?: GuildTextBasedChannel): Player | undefined {
const player = this.players_.get(guild.id);
if (player) {
if (channel) {
player.channel = channel;
}
return player;
}
}
/**
* Creates {@link Player} and maps it to GuildId.
*
* @returns Newly created Player.
*
* @param guild - {@link Guild} that created Player will be binded to.
* @param channel - Text channel that used by Player constructor.
*/
public set(guild: Guild, channel: GuildTextBasedChannel): Player {
const player = new Player(guild, this, channel);
this.players_.set(guild.id, player);
logger.info("Player was created.", { player });
return player;
}
/**
* Deleted {@link Player} and its mapping by GuildId.
*
* @param guildId - Id of {@link Guild} that must be removed from mapping.
*/
public delete(guildId: string): void {
logger.info("Player was deleted.", { guildId });
this.players_.delete(guildId);
}
}
+446
View File
@@ -0,0 +1,446 @@
import {
AudioPlayer,
AudioPlayerStatus,
AudioResource,
createAudioPlayer,
createAudioResource,
DiscordGatewayAdapterCreator,
joinVoiceChannel,
VoiceConnection
} from "@discordjs/voice";
import { Guild, GuildTextBasedChannel, Message, User, VoiceBasedChannel } from "discord.js";
import { clamp } from "$lib/misc.js";
import { dlpInfo, dlpStream } from "./fetch/fetch.js";
import Filter from "./filter.js";
import { largestCommonSequence } from "./misc.js";
import Music from "./music.js";
import Queue from "./queue.js";
import replies from "./replies.js";
import { Track } from "./types.js";
/**
* Defines guild music player connected to a voice channel by wrapping AudioPlayer, Queue, VoiceConnection, etc.
*
* @public
*/
export default class Player {
/**
* Volume limiter clamp.
*/
public static readonly VOLUME_CLAMP = clamp(0, 150);
/**
* Module controller orchistrate guild-player mapping.
*/
private music_: Music;
/**
* {@link Player} owner guild.
*
* @remarks No different {@link Player} instances can have same {@link Guild}.
*/
private guild_: Guild;
/**
* {@link Guild | Guild's} text based channel for player notifications,
* such as Current Playing Track.
*/
private channel_: GuildTextBasedChannel;
public get channel(): GuildTextBasedChannel {
return this.channel_;
}
public set channel(x: GuildTextBasedChannel) {
this.channel_ = x;
}
/**
* Previous notification message.
*/
private message_?: Message;
/**
* A connection to the voice channel of a {@link Guild}.
*
* @readonly
*/
private connection_?: VoiceConnection;
public get connection(): VoiceConnection | undefined {
return this.connection_;
}
/**
* Player for {@link AudioResource} that is subscribed to the most recent {@link VoiceConnection}.
*
* @readonly
*/
private player_: AudioPlayer;
public get player(): AudioPlayer {
return this.player_;
}
/**
* Represents an Audio Resource that can be played by {@link AudioPlayer}.
*
* @readonly
*/
private resource_: AudioResource<Track> | undefined;
public get resource(): AudioResource<Track> | undefined {
return this.resource_;
}
/**
* Inline volume for an {@link AudioResource}.
* Must be clamped by {@link VOLUME_CLAMP}.
*/
private volume_ = 100;
public get volume(): number {
return this.volume_;
}
public set volume(x: number) {
this.volume_ = Player.VOLUME_CLAMP(x);
if (this.resource_?.volume) {
this.resource_.volume.setVolumeLogarithmic(this.volume_ / 100);
}
}
/**
* Queue manager of the {@link Player}.
*/
public readonly queue = new Queue();
/**
* Filter manager of the {@link Player}.
*/
public readonly filter = new Filter();
/**
* Timestamp when {@link Player} was created.
*/
public readonly createdAt: Date = new Date();
/**
* Player constructor.
*
* @param guild - {@link Guild} for which player will be created for
* @param music - Module controller for managing player own existence
* @param channel - Initial text based channed for player notifications
*/
public constructor(guild: Guild, music: Music, channel: GuildTextBasedChannel) {
this.music_ = music;
this.guild_ = guild;
this.channel_ = channel;
this.player_ = createAudioPlayer();
this.player_.on(AudioPlayerStatus.Idle, async () => this.resourceEndResolvable());
}
/**
* Creates {@link AudioResource | an Audio Resource} for {@link an AudioPlayer}.
*
* @returns Configured audio resource ready to be played
*
* @param track - Metadata to generate resource from and embed into it
* @param seek - Amount of seconds to start playback from
*/
private async resourceGenerator(track: Track, seek?: number): Promise<AudioResource<Track>> {
const probe = await dlpStream(track.url, {
seek: seek ? seek / 1000 : undefined,
ffmpeg: !this.filter.empty() ? ["-af", this.filter.toString()] : undefined
});
this.resource_ = createAudioResource(probe, {
inlineVolume: true,
metadata: track
});
if (seek) {
this.resource_.playbackDuration = seek;
}
if (this.resource_.volume) {
this.resource_.volume.setVolumeLogarithmic(this.volume_ / 100);
}
return this.resource_;
}
/**
* Resolves player behavior after {@link AudioResource} ends.
*
* @remarks Force destroys ended resource,
* and either plays next one or stops,
* depending on {@link Queue#next | Queue.next() method}
*/
private resourceEndResolvable(): void {
if (this.resource_) {
this.resource_.encoder?.destroy();
}
if (this.queue.next()) {
this.play();
} else {
this.player_.stop(true);
this.resource_ = undefined;
}
}
/**
* Resolves notification emittion.
*
* @remarks Tries to delete previous notification message,
* and send next one
*/
private async messageResolvable(track: Track) {
if (this.message_?.deletable) {
await this.message_.delete().catch();
}
this.message_ = await this.channel_.send(replies.nowPlaying(track));
}
/**
* Fetches {@link DlpInfo} and converts it to {@link Track | Track metadata}.
*
* @returns Promise to Track list or undefined, if found nothing.
*
* @param query - String that Dlp will try to resolve
* @param requester - {@link User} that requested Tracks returned to complete convertion
*/
private async search(query: string, requester: User): Promise<Track[] | undefined> {
const info = await dlpInfo(query);
return info.map((value) => {
return { ...value, requester: requester };
});
}
/**
* Plays current {@link Queue} track from position specified.
*
* @param seek - Amount of seconds to start playback from
*/
public async play(seek?: number): Promise<void> {
const track = this.queue.current();
if (!this.connection_ || !track) {
return;
}
this.player_.play(await this.resourceGenerator(track, seek));
this.connection_.subscribe(this.player_);
await this.messageResolvable(track);
}
/**
* Creates connection to guild's voice based channel,
* previously destroying existing connection.
*
* @param channel - {@link VoiceBasedChannel} to connect to
*/
public connect(channel: VoiceBasedChannel): void {
if (
this.connection_?.joinConfig.channelId === channel.id &&
this.connection_.joinConfig.guildId === channel.guildId
) {
return;
}
if (this.connection_) {
this.connection_.destroy();
}
this.connection_ = joinVoiceChannel({
channelId: channel.id,
guildId: channel.guildId,
adapterCreator: channel.guild.voiceAdapterCreator as DiscordGatewayAdapterCreator
});
this.connection_.subscribe(this.player_);
}
/**
* Adds {@link Track | Track(s)} found by {@link search | the search method} to the {@link queue}.
*
* @returns Track metadata if found, undefined otherwise
*
* @param query - String that will be passed to the search method
* @param requester - {@link User} that requested the query
* @param index - Position to insert track to
* Value < 0 resolves to 0
* Value >= (queue size) resolves to (queue end)
*/
public async add(query: string, requester: User, index?: number): Promise<Track | undefined> {
const tracks = await this.search(query, requester);
if (!tracks) {
return;
}
this.queue.push(tracks, index);
return tracks[0];
}
/**
* Jumps across {@link queue} to the position determined by the query.
*
* @remarks If string passed, then position will be determined by
* the largest common sequence across all the {@link queue} titles.
*
* @return Position to which jump was perfomed
*
* @param query - String of {@link Track} title or position to jump to
*/
public jump(query: number | string): number | undefined {
let position = typeof query === "number" ? query : -1;
let diff = 0;
if (typeof query === "string") {
this.queue.tracks.forEach((track, index) => {
const tempDiff = largestCommonSequence(track.title, query);
if (diff < tempDiff) {
diff = tempDiff;
position = index;
}
});
}
if (position !== -1 && this.queue.jump(position)) {
this.play();
return position;
}
return undefined;
}
/**
* Removes {@link Track} from {@link queue | the queue} by the position determined by the query.
*
* @remarks If string passed, then position will be determined by
* the largest common sequence across all the {@link queue} titles.
*
* @return Metadata of removed track, or undefined if nothing happened
*
* @param query - String of {@link Track} title or position to perfome removal at
*/
public remove(query?: number | string): Track | undefined {
const oldPosition = this.queue.position;
let newPosition = typeof query === "number" ? query : this.queue.position;
let diff = 0;
if (typeof query === "string") {
this.queue.tracks.forEach((track, index) => {
const tempDiff = largestCommonSequence(track.title, query);
if (diff < tempDiff) {
diff = tempDiff;
newPosition = index;
}
});
}
const track = this.queue.pop(newPosition);
if (track) {
if (oldPosition === newPosition) {
this.play();
}
return track;
}
return undefined;
}
/**
* Restarts playback from timecode specified.
*
* @param time - Amount of seconds to start playback from
*/
public seek(time: number): void {
this.play(time);
}
/**
* Clears {@link queue} completely - not destroying {@link voiceConnection_}.
*/
public clear(): void {
this.queue.clear();
this.resource_ = undefined;
if (this.player_) {
this.player_.stop();
}
}
/**
* Destroys this instance of {@link Player} and deletes it from controller mapping.
*/
public destroy(): void {
if (this.connection_) {
this.connection_.destroy();
}
this.player_.stop();
this.music_.delete(this.guild_.id);
}
/**
* Pauses/unpauses {@link AudioPlayer} playback.
*
* @returns True if paused, or False if unpaused
*/
public pause(): boolean {
if (this.player_.state.status === AudioPlayerStatus.Paused) {
this.player_.unpause();
return false;
} else {
this.player_.pause();
return true;
}
}
/**
* Reshuffles {@link queue} order - will start new playback from the same position in the queue.
*/
public shuffle(): void {
this.queue.shuffle();
if (this.resource_) {
this.player_.stop();
this.play();
}
}
/**
* Stops {@link player_} that will emit {@link resourceEndResolvable}.
*
* @see {@link resourceEndResolvable} for understanding full behavior
*/
public skip(): void {
this.player_.stop();
}
/**
* Applies {@link Filter} preset to the current playback.
*
* @returns True if applied, false if revoked
*
* @param filter - Name of a filter to apply
* Must exist in Filter class
*/
public setFilter(filter?: string): boolean {
const status = this.filter.toggle(filter);
if (this.resource_) {
this.play(this.resource_.playbackDuration);
}
return status;
}
}
+181
View File
@@ -0,0 +1,181 @@
import { LoopOption, Track } from "./types.js";
/**
* Class of {@link ./player#Player | Player's} queue.
*
* @public
*/
export default class Queue {
/**
* Array of {@link Track | Tracks} - content of queue.
*/
private tracks_: Track[] = [];
public get tracks(): Track[] {
return this.tracks_;
}
/**
* Position of current playing track.
*/
private position_ = 0;
public get position(): number {
return this.position_;
}
/**
* Defines behavior of queue.
*
* @see ./types.ts for more info about values.
*/
private loop_: LoopOption = "NONE";
public get loop(): LoopOption {
return this.loop_;
}
public set loop(x: LoopOption) {
this.loop_ = x;
}
/**
* Queue overall duration in seconds.
*/
private duration_ = 0;
public get duration(): number {
return this.duration_;
}
/**
* Skips current track.
*
* @remarks Behavior depends on loop option.
*
* @returns next {@link Track} or undefined if end was reached.
*/
public next(): Track | undefined {
if (this.loop_ === "SONG") {
return this.current();
}
if (this.loop_ === "AUTO") {
this.position_ = Math.floor(Math.random() * this.tracks_.length);
return this.current();
}
if (this.position_ + 1 < this.tracks_.length) {
this.position_++;
return this.current();
} else if (this.loop_ === "LOOP") {
this.position_ = 0;
return this.current();
} else if (this.loop_ === "NONE") {
this.position_ = 0;
return undefined;
}
}
/**
* Shuffles queue by using simple Math.random.
*/
public shuffle(): void {
let currentIndex = this.tracks_.length;
while (currentIndex != 0) {
const randomIndex = Math.floor(Math.random() * currentIndex);
currentIndex--;
[this.tracks_[currentIndex], this.tracks_[randomIndex]] = [
this.tracks_[randomIndex],
this.tracks_[currentIndex]
];
}
this.position_ = 0;
}
/**
* Inserts tracks from provided array at specific index.
*
* @returns first {@link Track} of inserted Tracks.
*
* @param tracks - List of Tracks to insert to the queue.
* @param index - Position at which insertion will be occurred.
*/
public push(tracks: Track[], index?: number): Track {
for (const track of tracks) {
this.duration_ += track.duration;
}
if (!index || index >= this.tracks_.length) {
this.tracks_ = this.tracks_.concat(tracks);
return this.tracks_[this.tracks_.length - tracks.length];
}
this.tracks_.splice(Math.max(0, index), 0, ...tracks);
if (this.position_ >= index) {
this.position_ += tracks.length;
}
return this.tracks_[Math.max(0, index)];
}
/**
* Removes {@link Track} from the queue at position specified.
*
* @returns Track that was deleted or undefined if not.
*
* @param query - Index of element that must be deleted.
*/
public pop(query: number): Track | undefined {
if (query >= 0 && query < this.tracks_.length) {
const track = this.tracks_[query];
this.tracks_.splice(query, 1);
this.position_ = Math.max(0, this.position_ - 1);
this.duration_ -= track.duration;
return track;
}
return undefined;
}
/**
* Assigns position new value.
*
* @returns {@link Track} at position specified or undefined if index out of bounds.
*/
public jump(query: number): Track | undefined {
if (query >= 0 && query < this.tracks_.length) {
const track = this.tracks_[query];
this.position_ = query;
return track;
}
return undefined;
}
/**
* Returns current {@link Track} (at position).
*
* @returns Track at internal position.
*/
public current(): Track {
return this.tracks_[this.position_];
}
/**
* Checks if queue is empty.
*
* @returns True is queue is empty, False otherwise.
*/
public empty(): boolean {
return this.tracks_.length === 0;
}
/**
* Clears entire queue and sets position to 0.
*/
public clear(): void {
this.tracks_ = [];
this.position_ = 0;
}
}
+377
View File
@@ -0,0 +1,377 @@
import { AudioResource } from "@discordjs/voice";
import { BaseMessageOptions, EmbedBuilder, italic } from "discord.js";
import config from "$config.js";
import { loopMoji, statusBarGenerator, toISOTime, volumeMoji } from "./misc.js";
import Player from "./player.js";
import Queue from "./queue.js";
import { LoopOption, Track } from "./types.js";
const replies = {
notExists: (): BaseMessageOptions => {
const embed = new EmbedBuilder()
.setColor(config.colors.warning)
.setTitle("⚠️ Command ignored - player does not exist")
.setDescription("Use '/play' to create a new one.");
return { embeds: [embed] };
},
searching: (): BaseMessageOptions => {
const embed = new EmbedBuilder()
.setColor(config.colors.default)
.setTitle("🔍 Searching...")
.setDescription("It will take longer if playlist or non-youtube URL was queried.");
return { embeds: [embed] };
},
notPlaying: (): BaseMessageOptions => {
const embed = new EmbedBuilder()
.setColor(config.colors.default)
.setTitle("💿 Insert the disk...")
.setURL("https://www.youtube.com/watch?v=dQw4w9WgXcQ")
.setDescription("Use '/play' to start the playback.");
return { embeds: [embed] };
},
nowPlaying: (track: Track): BaseMessageOptions => {
const embed = new EmbedBuilder()
.setColor(config.colors.default)
.setAuthor({
iconURL: track.requester.avatarURL() ?? undefined,
name: track.requester.username
})
.setTitle(track.title)
.setURL(track.url)
.setDescription("playing now.")
.setThumbnail(track.thumbnail);
return { embeds: [embed] };
},
okConnected: (channel: string): BaseMessageOptions => {
const embed = new EmbedBuilder()
.setColor(config.colors.success)
.setTitle(`✅ Successfuly connected to: ${italic(channel)}`)
.setDescription("Will leave after some time, if left alone.");
return { embeds: [embed] };
},
notConnected: (): BaseMessageOptions => {
const embed = new EmbedBuilder()
.setColor(config.colors.error)
.setTitle("❗ Cannot create new player.")
.setDescription("Make yourself connected to a voice channel beforehand.");
return { embeds: [embed] };
},
destroyed: (): BaseMessageOptions => {
const embed = new EmbedBuilder()
.setColor(config.colors.error)
.setTitle("💀 Destroying the player")
.setDescription("Everything is lost now. Use '/play' to create a new one.");
return { embeds: [embed] };
},
alone: (): BaseMessageOptions => {
const embed = new EmbedBuilder()
.setColor(config.colors.default)
.setTitle("🥬 I have decided to leave to conserve my energy")
.setDescription("Since there was nobody in the voice channel.");
return { embeds: [embed] };
},
paused: (state: boolean): BaseMessageOptions => {
let embed: EmbedBuilder;
if (state) {
embed = new EmbedBuilder()
.setColor(config.colors.success)
.setTitle("▶️ The playback was resumed");
} else {
embed = new EmbedBuilder()
.setColor(config.colors.warning)
.setTitle("⏸️ The playback was paused")
.setDescription("Invoke this command again to resume.");
}
return { embeds: [embed] };
},
seeked: (from: number, to: number): BaseMessageOptions => {
let embed: EmbedBuilder;
if (from < to) {
embed = new EmbedBuilder()
.setColor(config.colors.info)
.setTitle(`⏩ Fast-forwarding by ${toISOTime(to - from)}`);
} else {
embed = new EmbedBuilder()
.setColor(config.colors.info)
.setTitle(`⏪ Fast-reversing by ${toISOTime(from - to)}`);
}
embed.setDescription(
`Moving playback from ${italic(toISOTime(from))} to ${italic(toISOTime(to))}.`
);
return { embeds: [embed] };
},
looped: (option: LoopOption) => {
let embed: EmbedBuilder;
switch (option) {
case "AUTO": {
embed = new EmbedBuilder()
.setColor(config.colors.default)
.setTitle(`${loopMoji(option)} Shuffle mode.`)
.setDescription("Does not affect the queue order. Endless playback.");
break;
}
case "LOOP": {
embed = new EmbedBuilder()
.setColor(config.colors.info)
.setTitle(`${loopMoji(option)} Looping over the queue.`)
.setDescription("Will start over after final track. Endless playback.");
break;
}
case "SONG": {
embed = new EmbedBuilder()
.setColor(config.colors.warning)
.setTitle(`${loopMoji(option)} Looping a single track.`)
.setDescription("Endless playback of a single track. Over and over again...");
break;
}
case "NONE": {
embed = new EmbedBuilder()
.setColor(config.colors.error)
.setTitle(`${loopMoji(option)} Drop on final.`)
.setDescription(
"Will end playback after final track. Action will be needed to continue."
);
break;
}
}
return { embeds: [embed] };
},
reshuffle: (): BaseMessageOptions => {
const embed = new EmbedBuilder()
.setColor(config.colors.success)
.setTitle("✅ Successfuly reshuffled queue")
.setDescription("Use 'random' mode, if you want shuffle without reshuffle.");
return { embeds: [embed] };
},
skipped: (): BaseMessageOptions => {
const embed = new EmbedBuilder()
.setColor(config.colors.info)
.setTitle("⏭️ Skipping current track")
.setDescription("Behavior depends on the loop option.");
return { embeds: [embed] };
},
jumped: (previous?: Track, to?: number): BaseMessageOptions => {
if (!to) {
const embed = new EmbedBuilder()
.setColor(config.colors.error)
.setTitle("❗ Cannot perfome jump over the queue")
.setDescription("Either not found by query, or index is not in range.");
return { embeds: [embed] };
}
const embed = new EmbedBuilder()
.setColor(config.colors.success)
.setTitle(`✅ Jumping over the queue`);
if (previous) {
embed.setDescription(`Switching from ${italic(previous.title)}.`);
} else {
embed.setDescription("Playback will start again.");
}
return { embeds: [embed] };
},
appended: (track?: Track): BaseMessageOptions => {
let embed: EmbedBuilder;
if (track) {
embed = new EmbedBuilder()
.setColor(config.colors.success)
.setAuthor({
iconURL: track.requester.avatarURL() ?? undefined,
name: track.requester.username
})
.setTitle(track.title)
.setURL(track.url)
.setDescription("has been added to the queue.")
.setThumbnail(track.thumbnail)
.setTimestamp();
} else {
embed = new EmbedBuilder()
.setColor(config.colors.error)
.setTitle("❗ Cannot add track to the queue")
.setDescription("Either not found by query, or URL is broken.");
}
return { embeds: [embed] };
},
removed: (track?: Track): BaseMessageOptions => {
let embed: EmbedBuilder;
if (track) {
embed = new EmbedBuilder()
.setColor(config.colors.success)
.setTitle(track.title)
.setURL(track.url)
.setDescription("was removed from the queue.")
.setThumbnail(track.thumbnail)
.setTimestamp();
} else {
embed = new EmbedBuilder()
.setColor(config.colors.error)
.setTitle("❗ Cannot delete track from queue")
.setDescription("Either not found by query, or index is not in range.");
}
return { embeds: [embed] };
},
cleared: (amount: number): BaseMessageOptions => {
const embed = new EmbedBuilder()
.setColor(config.colors.success)
.setTitle(`♻️ Queue has been cleared`)
.setDescription(
`${italic(amount.toString())} track${amount > 1 ? "s were" : " was"} deleted.`
);
return { embeds: [embed] };
},
filtered: (filter?: string, status = true): BaseMessageOptions => {
let embed: EmbedBuilder;
if (filter) {
embed = new EmbedBuilder().setTitle(
`✅ ${italic(filter)} was ${status ? "applied to the playback" : "declined"}.`
);
} else {
embed = new EmbedBuilder().setTitle("♻️ All filters were declined");
}
embed
.setColor(config.colors.success)
.setDescription("Playback can experience some lags during initial transformation.");
return { embeds: [embed] };
},
volumeSet: (amount: number): BaseMessageOptions => {
const embed = new EmbedBuilder()
.setColor(config.colors.success)
.setTitle(`${volumeMoji(amount)} Volume has been set to ${amount}%`)
.setDescription("This will affect everyone listening.");
return { embeds: [embed] };
},
queue: (queue: Queue, position = 0, resource?: AudioResource<Track>): BaseMessageOptions => {
if (queue.empty()) {
const embed = new EmbedBuilder()
.setColor(config.colors.warning)
.setTitle("⚠️ Queue is empty")
.setDescription("Use '/play' to add some tracks.");
return { embeds: [embed] };
}
let embed: EmbedBuilder;
if (resource) {
embed = new EmbedBuilder()
.setTitle(`${queue.position + 1}. ${resource.metadata.title}`)
.setAuthor({
iconURL: resource.metadata.requester.avatarURL() ?? undefined,
name: resource.metadata.requester.username
})
.setURL(resource.metadata.url)
.setDescription(statusBarGenerator(resource))
.setThumbnail(resource.metadata.thumbnail);
} else {
embed = new EmbedBuilder()
.setTitle("💿 Insert the disk...")
.setURL("https://www.youtube.com/watch?v=5mGuCdlCcNM&ab_channel=Ra%C3%BAlBlanco")
.setDescription(statusBarGenerator())
.setThumbnail("https://img.youtube.com/vi/5mGuCdlCcNM/default.jpg");
}
embed.setColor(config.colors.info).setFields(
queue.tracks.slice(position, position + 10).map((value, index) => ({
name: `${position + index + 1}. ${value.title.padEnd(35)}`,
value: `[${toISOTime(value.duration)}] | ${value.requester.username}`
}))
);
return { embeds: [embed] };
},
info: (player: Player): BaseMessageOptions => {
let embed: EmbedBuilder;
if (player.resource) {
embed = new EmbedBuilder()
.setTitle(`${player.queue.position + 1}. ${player.resource.metadata.title}`)
.setAuthor({
iconURL: player.resource.metadata.requester.avatarURL() ?? undefined,
name: player.resource.metadata.requester.username
})
.setURL(player.resource.metadata.url)
.setDescription(statusBarGenerator(player.resource))
.setThumbnail(player.resource.metadata.thumbnail);
} else {
embed = new EmbedBuilder()
.setTitle("💿 Insert the disk...")
.setURL("https://www.youtube.com/watch?v=dQw4w9WgXcQ")
.setDescription(statusBarGenerator());
}
embed.setColor(config.colors.info).addFields([
{
name: "Applied Filters",
value:
player.filter
.toggled()
.map((value) => value.name)
.join("\n") + "\ndefault",
inline: true
},
{
name: "Queue Length",
value: `🔢 ${player.queue.tracks.length}`,
inline: true
},
{
name: "Queue Duration",
value: `💿 ${toISOTime(player.queue.duration)}`,
inline: true
},
{
name: "Queue Looping",
value: `${loopMoji(player.queue.loop)} ${player.queue.loop}`,
inline: true
},
{
name: "Volume Amount",
value: `${volumeMoji(player.volume)} ${player.volume}/150`,
inline: true
},
{
name: "Player Lifetime",
value: `🕓 ${toISOTime((Date.now() - player.createdAt.getTime()) / 1000)}`,
inline: true
}
]);
return { embeds: [embed] };
}
};
export default replies;
+99
View File
@@ -0,0 +1,99 @@
import { User } from "discord.js";
import { dlpExtractorsValues } from "./fetch/extractors.js";
/**
* Track information fetched by Dlp.
*
* @public
*/
export interface TrackBase {
/** URL of track. */
url: string;
/** Track title. */
title: string;
/** Link to track thumbnail. */
thumbnail: string;
/** Track duration in seconds. */
duration: number;
}
/**
* Track information with {@link User} that requested it.
*
* @public
*/
export interface Track extends TrackBase {
/** User requested this track. */
requester: User;
}
/**
* Player loop options.
*
* @remarks LOOP - Looping queue;
* SONG - Looping single song;
* NONE - Stops after queue end; (default)
* AUTO - Random order;
*
* @public
*/
export type LoopOption = "LOOP" | "SONG" | "NONE" | "AUTO";
/**
* Dlp fetched track list.
*
* @public
*/
export type DlpInfo = TrackBase[];
/**
* Dlp supported extractors as types.
*
* @public
*/
export type dlpExtractorKey = (typeof dlpExtractorsValues)[number];
/**
* Actual Dlp dump that later translates to {@link DlpInfo}.
*
* @public
*/
export interface DlpDump {
/** Id of resource. */
id: string;
/** URL passed to Dlp or link to resource found. */
original_url: string;
/** Link to webpage of resource requested. */
webpage_url?: string;
/** Resource title. */
title?: string;
/** Resource duration in seconds. */
duration?: number;
/** Link to resource thumbnail. */
thumbnail?: string;
/** Resource's extractor identifier. */
extractor_key: dlpExtractorKey;
}
/**
* Options that shall be passed to DlpStream function.
*
* @public
*/
export interface dlpStreamOptions {
/** Amount of seconds to start playback from. */
seek?: number;
/** Args for FFMPEG transcoder. */
ffmpeg?: string[];
}
+16
View File
@@ -0,0 +1,16 @@
/**
* Music module definition.
*
* Module to play media content from various sites.
* @see ./lib/fetch/extractors.ts for site extractors.
*
* @packageDocumentation
*/
import Music from "./lib/music.js";
const name = "music";
const description = "Music module";
const controller = new Music();
export { controller, description, name };
+16
View File
@@ -0,0 +1,16 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"paths": {
"$config.js": ["../../config.js"],
"$lib/*": ["../../src/lib/*"],
"$mlib/*": ["./lib/*"]
},
"rootDir": ".",
"outDir": "../../dist/modules/music",
"composite": true
},
"references": [{ "path": "../../src/" }]
}
-2402
View File
File diff suppressed because it is too large. Load diff
+39 -31
View File
@@ -1,46 +1,54 @@
{
"name": "quetza-bot",
"engines": {
"node": "18.x.x",
"npm": "*"
},
"name": "quetza",
"version": "2024.3.0",
"description": "Awesome Discord bot that performs various duties",
"type": "module",
"version": "2302.1.0",
"description": "Discord multifunctional bot",
"author": {
"name": "unknowableshade",
"email": "60275123+unknowableshade@users.noreply.github.com",
"url": "https://unknowableshade.ru"
"main": "./dist/src/index.js",
"scripts": {
"start": "node ./dist/src/index.js",
"build": "./build.sh",
"clean": "rm -rf ./dist",
"lint": "pnpm exec eslint . && pnpm exec prettier --check .",
"format": "pnpm exec eslint --fix . && pnpm exec prettier --write .",
"preinstall": "pnpm dlx only-allow pnpm"
},
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/unknowableshade/quetza-bot.git"
"url": "git+https://github.com/unknowableshade/Quetza.git"
},
"author": {
"name": "Vladimir Eremin",
"email": "personal@ermnvldmr.com",
"url": "https://ermnvldmr.com"
},
"bugs": {
"url": "https://github.com/unknowableshade/quetza-bot/issues"
"url": "https://github.com/unknowableshade/Quetza/issues"
},
"readme": "https://github.com/unknowableshade/quetza-bot#readme",
"license": "MIT",
"main": "dist/index.js",
"scripts": {
"poststart": "npm run build",
"start": "node dist/index.js",
"build": "tsc --build",
"clean": "tsc --build --clean"
"readme": "https://github.com/unknowableshade/Quetza#readme",
"engines": {
"node": "20.x.x",
"npm": "*"
},
"dependencies": {
"@discordjs/opus": "^0.8.0",
"@discordjs/voice": "^0.11.0",
"discord-ytdl-core": "^5.0.4",
"discord.js": "^14.3.0",
"@discordjs/opus": "^0.9.0",
"@discordjs/voice": "^0.16.1",
"discord.js": "^14.14.1",
"prism-media": "^1.3.5",
"tweetnacl": "^1.0.3",
"ytdl-core": "^4.11.1"
"winston": "^3.11.0"
},
"devDependencies": {
"@types/node": "^18.7.13",
"@typescript-eslint/eslint-plugin": "^5.35.1",
"@typescript-eslint/parser": "^5.35.1",
"eslint": "^8.22.0",
"typescript": "^4.8.2"
"@types/node": "^20.11.24",
"@typescript-eslint/eslint-plugin": "^7.1.0",
"@typescript-eslint/parser": "^7.1.0",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-import": "^2.29.1",
"eslint-plugin-simple-import-sort": "^12.0.0",
"mocha": "^10.3.0",
"prettier": "^3.2.5",
"tsc-alias": "^1.8.8",
"typescript": "^5.3.3"
}
}
+3749
View File
File diff suppressed because it is too large. Load diff
-24
View File
@@ -1,24 +0,0 @@
import Client from "../lib/Client.js";
import { CommandInteraction, SlashCommandBuilder, TextChannel } from "discord.js";
import I18n from "../lib/I18n.js";
export async function run(client: Client, ctx: CommandInteraction) {
if (!ctx.guild || !ctx.channel) return;
const player = client.modules.music.get(ctx.guild.id, ctx.channel as TextChannel);
if (!player) return;
const amount = player.Queue.Tracks.length;
player.clear();
await ctx.reply({ embeds: [I18n.en.cleared(amount)] });
}
const data = new SlashCommandBuilder()
.setName("clear")
.setDescription("Clear the player's queue.");
export { data };
-40
View File
@@ -1,40 +0,0 @@
import Client from "../lib/Client.js";
import { CommandInteraction, GuildMember, SlashCommandBuilder, TextChannel } from "discord.js";
import I18n from "../lib/I18n.js";
export async function run(client: Client, ctx: CommandInteraction) {
if (!ctx.guild || !ctx.channel || !ctx.member) return;
await ctx.deferReply();
const player =
client.modules.music.get(ctx.guild.id, ctx.channel as TextChannel) ||
client.modules.music.set(ctx.guild, ctx.channel as TextChannel);
if (!player.Connection) {
const channel = (ctx.member as GuildMember).voice.channel;
if (channel) {
player.connect(channel);
await ctx.editReply({ embeds: [I18n.en.okConnected(channel.name)] });
}
else {
client.modules.music.del(ctx.guild.id);
await ctx.editReply({ embeds: [I18n.en.notConnected()] });
return undefined;
}
}
else {
await ctx.editReply({ embeds: [I18n.en.wasConnected()] });
}
return player;
}
const data = new SlashCommandBuilder()
.setName("connect")
.setDescription("Connect me to a voice channel.");
export { data };
-121
View File
@@ -1,121 +0,0 @@
import { CommandInteraction, SlashCommandBuilder, TextChannel } from "discord.js";
import Client from "../lib/Client.js";
import I18n from "../lib/I18n.js";
export async function run(client: Client, ctx: CommandInteraction) {
const filter = ctx.options.get("filter")?.value as string;
if (!ctx.guild || !ctx.channel) return;
const player = client.modules.music.get(ctx.guild.id, ctx.channel as TextChannel);
if (!player) return;
player.setFilter(filter || undefined);
await ctx.reply({ embeds: [I18n.en.filtered(filter || undefined)] });
}
const data = new SlashCommandBuilder()
.setName("filter")
.setDescription("Apply unique filters to the tracks.")
.addStringOption(option => option
.setName("filter")
.setDescription("Filter to apply.")
.setChoices(
{
name: "bassboost_low",
value: "bassboost_low",
},
{
name: "bassboost",
value: "bassboost",
},
{
name: "bassboost_high",
value: "bassboost_high",
},
{
name: "8D",
value: "8D",
},
{
name: "vaporwave",
value: "vaporwave",
},
{
name: "nightcore",
value: "nightcore",
},
{
name: "phaser",
value: "phaser",
},
{
name: "tremolo",
value: "tremolo",
},
{
name: "vibrato",
value: "vibrato",
},
{
name: "reverse",
value: "reverse",
},
{
name: "treble",
value: "treble",
},
{
name: "normalizer",
value: "normalizer",
},
{
name: "surrounding",
value: "surrounding",
},
{
name: "pulsator",
value: "pulsator",
},
{
name: "subboost",
value: "subboost",
},
{
name: "mono",
value: "mono",
},
{
name: "compressor",
value: "compressor",
},
{
name: "expander",
value: "expander",
},
{
name: "softlimiter",
value: "softlimiter",
},
{
name: "chorus",
value: "chorus",
},
{
name: "fadein",
value: "fadein",
},
{
name: "dim",
value: "dim",
},
{
name: "earrape",
value: "earrape",
})
.setRequired(true)
);
export { data };
-20
View File
@@ -1,20 +0,0 @@
import Client from "../lib/Client.js";
import { CommandInteraction, SlashCommandBuilder, TextChannel } from "discord.js";
import I18n from "../lib/I18n.js";
export async function run(client: Client, ctx: CommandInteraction) {
if (!ctx.guild || !ctx.channel || !ctx.member) return;
await ctx.deferReply();
const player = client.modules.music.get(ctx.guild.id, ctx.channel as TextChannel);
await ctx.editReply({ embeds: [I18n.en.playerInfo(player)] });
}
const data = new SlashCommandBuilder()
.setName("info")
.setDescription("Information about the playback.");
export { data };
-36
View File
@@ -1,36 +0,0 @@
import { CommandInteraction, SlashCommandBuilder, TextChannel } from "discord.js";
import Client from "../lib/Client.js";
import I18n from "../lib/I18n.js";
export async function run(client: Client, ctx: CommandInteraction) {
const pos = ctx.options.get("position")?.value as number;
const query = ctx.options.get("query")?.value as string;
if (!ctx.guild || !query || !pos || !ctx.channel) return;
const player = client.modules.music.get(ctx.guild.id, ctx.channel as TextChannel);
if (!player) return;
await ctx.deferReply();
const track = await player.add(query, ctx.user, pos - 1);
await ctx.editReply({ embeds: [I18n.en.appended(track)] });
}
const data = new SlashCommandBuilder()
.setName("insert")
.setDescription("Insert a new track to specific position in the queue.")
.addStringOption(option => option
.setName("query")
.setDescription("Title or URL of the song.")
.setRequired(true)
)
.addIntegerOption(option => option
.setName("position")
.setDescription("Position to insert.",)
.setRequired(true)
);
export { data };
-28
View File
@@ -1,28 +0,0 @@
import { CommandInteraction, SlashCommandBuilder, TextChannel } from "discord.js";
import Client from "../lib/Client.js";
import I18n from "../lib/I18n.js";
export async function run(client: Client, ctx: CommandInteraction) {
const query = ctx.options.get("query")?.value as string;
if (!ctx.guild || !query || !ctx.channel) return;
const player = client.modules.music.get(ctx.guild.id, ctx.channel as TextChannel);
if (!player) return;
const state = !isNaN(+query) && isFinite(+query) && !/e/i.test(query) ? player.jump(parseInt(query) - 1) : player.jump(query);
await ctx.reply({ embeds: [I18n.en.jumped(state)] });
}
const data = new SlashCommandBuilder()
.setName("jump")
.setDescription("Jump to specific track in the queue.")
.addStringOption(option => option
.setName("query")
.setDescription("Position or title to jump to.")
.setRequired(true)
);
export { data };
-32
View File
@@ -1,32 +0,0 @@
import { CommandInteraction, SlashCommandBuilder, TextChannel } from "discord.js";
import Client from "../lib/Client.js";
import { loopOption } from "../lib/Types.js";
import I18n from "../lib/I18n.js";
export async function run(client: Client, ctx: CommandInteraction) {
const query = ctx.options.get("option")?.value as string;
if (!ctx.guild || !query || !ctx.channel) return;
const player = client.modules.music.get(ctx.guild.id, ctx.channel as TextChannel);
if (!player) return;
player.Queue.Loop = query as loopOption;
await ctx.reply({ embeds: [I18n.en.looped(query as loopOption)] });
}
const data = new SlashCommandBuilder()
.setName("loop")
.setDescription("Select player's loop mode.")
.addStringOption(option => option
.setName("option")
.setDescription("Select loop option.")
.setChoices( { name: "loop queue", value: "LOOP" },
{ name: "loop single track", value: "SONG" },
{ name: "Till end", value: "NONE" },
{ name: "Random order", value: "AUTO" })
.setRequired(true));
export { data };
-21
View File
@@ -1,21 +0,0 @@
import { CommandInteraction, SlashCommandBuilder, TextChannel } from "discord.js";
import Client from "../lib/Client.js";
import I18n from "../lib/I18n.js";
export async function run(client: Client, ctx: CommandInteraction) {
if (!ctx.guild || !ctx.channel) return;
const player = client.modules.music.get(ctx.guild.id, ctx.channel as TextChannel);
if (!player) return;
player.skip();
await ctx.reply({ embeds: [I18n.en.skipped()] });
}
const data = new SlashCommandBuilder()
.setName("next")
.setDescription("Skip current song.");
export { data };
-21
View File
@@ -1,21 +0,0 @@
import { CommandInteraction, SlashCommandBuilder, TextChannel } from "discord.js";
import Client from "../lib/Client.js";
import I18n from "../lib/I18n.js";
export async function run(client: Client, ctx: CommandInteraction) {
if (!ctx.guild || !ctx.channel) return;
const player = client.modules.music.get(ctx.guild.id, ctx.channel as TextChannel);
if (!player) return;
const state = player.pause();
await ctx.reply({ embeds: [I18n.en.paused(state)] });
}
const data = new SlashCommandBuilder()
.setName("pause")
.setDescription("Toggle pause on player.");
export { data };
-22
View File
@@ -1,22 +0,0 @@
import { CommandInteraction, SlashCommandBuilder } from "discord.js";
import Client from "../lib/Client.js";
import I18n from "../lib/I18n.js";
export async function run(client: Client, ctx: CommandInteraction) {
const start = Date.now();
if (!client.application) {
await ctx.reply({ embeds: [I18n.en.ping(undefined)] });
return;
}
await client.application.fetch();
await ctx.reply({ embeds: [I18n.en.ping(Date.now() - start)] });
}
const data = new SlashCommandBuilder()
.setName("ping")
.setDescription("Try to ping me.");
export { data };
-32
View File
@@ -1,32 +0,0 @@
import { CommandInteraction, SlashCommandBuilder } from "discord.js";
import Client from "../lib/Client.js";
import { run as connect } from "./connect.js";
import I18n from "../lib/I18n.js";
export async function run(client: Client, ctx: CommandInteraction) {
const query = ctx.options.get("query")?.value as string;
const player = await connect(client, ctx);
if (!player) return;
if (query) {
const track = await player.add(query, ctx.user);
await ctx.editReply({ embeds: [I18n.en.appended(track)] });
}
if (!player.Resource) {
player.play();
}
}
const data = new SlashCommandBuilder()
.setName("play")
.setDescription("Launch player and add songs to the queue.")
.addStringOption(option => option
.setName("query")
.setDescription("Title or URL of the song.")
);
export { data };
-86
View File
@@ -1,86 +0,0 @@
import {
ActionRowBuilder,
ButtonBuilder,
ButtonInteraction,
ButtonStyle,
CommandInteraction,
Message,
MessageActionRowComponentBuilder,
SlashCommandBuilder,
TextChannel
} from "discord.js";
import Client from "../lib/Client.js";
import I18n from "../lib/I18n.js";
export async function run(client: Client, ctx: CommandInteraction) {
if (!ctx.guild || !ctx.channel) return;
const player = client.modules.music.get(ctx.guild.id, ctx.channel as TextChannel);
if (!player) return;
await ctx.deferReply();
if (player.Queue.empty()) {
await ctx.editReply({ embeds: [I18n.en.queueEmpty()] });
return;
}
let page = 0;
const row = new ActionRowBuilder<MessageActionRowComponentBuilder>().addComponents(
new ButtonBuilder()
.setCustomId("QueueTop")
.setLabel("🔼")
.setStyle(ButtonStyle.Primary),
new ButtonBuilder()
.setCustomId("QueueUp")
.setLabel("🔺")
.setStyle(ButtonStyle.Secondary),
new ButtonBuilder()
.setCustomId("QueueDown")
.setLabel("🔻")
.setStyle(ButtonStyle.Secondary),
new ButtonBuilder()
.setCustomId("QueueEnd")
.setLabel("🔽")
.setStyle(ButtonStyle.Primary)
);
const message = (await ctx.editReply({
embeds: [I18n.en.queueDesigner(player.Queue, 0, player.Resource)],
components: [row],
})) as Message;
const collector = message.createMessageComponentCollector({ time: 30000 });
collector.on("collect", async (btn: ButtonInteraction) => {
switch (btn.customId) {
case "QueueTop": {
page = 0;
break;
}
case "QueueUp": {
page = Math.max(page - 1, 0);
break;
}
case "QueueDown": {
page = Math.min(page + 1, Math.floor(player.Queue.Tracks.length / 11));
break;
}
case "QueueEnd":
page = Math.floor(player.Queue.Tracks.length / 11);
}
await btn.update({
embeds: [I18n.en.queueDesigner(player.Queue, page, player.Resource)],
});
});
}
const data = new SlashCommandBuilder()
.setName("queue")
.setDescription("Print out the queue.");
export { data };
-28
View File
@@ -1,28 +0,0 @@
import { CommandInteraction, SlashCommandBuilder, TextChannel } from "discord.js";
import Client from "../lib/Client.js";
import I18n from "../lib/I18n.js";
export async function run(client: Client, ctx: CommandInteraction) {
const query = ctx.options.get("query")?.value as string;
if (!ctx.guild || !query || !ctx.channel) return;
const player = client.modules.music.get(ctx.guild.id, ctx.channel as TextChannel);
if (!player) return;
const track = !isNaN(+query) && isFinite(+query) && !/e/i.test(query) ? player.remove(parseInt(query) - 1) : player.remove(query);
await ctx.reply({ embeds: [I18n.en.removed(track)] });
}
const data = new SlashCommandBuilder()
.setName("remove")
.setDescription("Remove specific track in the queue.")
.addStringOption(option => option
.setName("query")
.setDescription("Position or title to remove.")
.setRequired(true)
);
export { data };
-21
View File
@@ -1,21 +0,0 @@
import { CommandInteraction, SlashCommandBuilder, TextChannel } from "discord.js";
import Client from "../lib/Client.js";
import I18n from "../lib/I18n.js";
export async function run(client: Client, ctx: CommandInteraction) {
if (!ctx.guild || !ctx.channel) return;
const player = client.modules.music.get(ctx.guild.id, ctx.channel as TextChannel);
if (!player) return;
player.shuffle();
await ctx.reply({ embeds: [I18n.en.reshuffle()] });
}
const data = new SlashCommandBuilder()
.setName("reshuffle")
.setDescription("Shuffle the queue.");
export { data };
-41
View File
@@ -1,41 +0,0 @@
import { CommandInteraction, SlashCommandBuilder, TextChannel } from "discord.js";
import Client from "../lib/Client.js";
import I18n from "../lib/I18n.js";
export async function run(client: Client, ctx: CommandInteraction) {
const hours = ctx.options.get("hrs")?.value as number || 0;
const minutes = ctx.options.get("mins")?.value as number || 0;
const seconds = ctx.options.get("secs")?.value as number || 0;
if (!ctx.guild || !ctx.channel) return;
const player = client.modules.music.get(ctx.guild.id, ctx.channel as TextChannel);
if (!player) return;
const time = hours * 3600 + minutes * 60 + seconds;
player.seek(time * 1000);
await ctx.reply({
embeds: [I18n.en.fastForwarded(time)],
});
}
const data = new SlashCommandBuilder()
.setName("seek")
.setDescription("Fast-forward by the time.")
.addIntegerOption(option => option
.setName("hrs")
.setDescription("Hours to seek.")
)
.addIntegerOption(option => option
.setName("mins")
.setDescription("Minutes to seek.")
)
.addIntegerOption(option => option
.setName("secs")
.setDescription("Seconds to seek.")
);
export { data };
-21
View File
@@ -1,21 +0,0 @@
import { CommandInteraction, SlashCommandBuilder, TextChannel } from "discord.js";
import Client from "../lib/Client.js";
import I18n from "../lib/I18n.js";
export async function run(client: Client, ctx: CommandInteraction) {
if (!ctx.guild || !ctx.channel) return;
const player = client.modules.music.get(ctx.guild.id, ctx.channel as TextChannel);
if (!player) return;
player.destroy();
await ctx.reply({ embeds: [I18n.en.destroyed()] });
}
const data = new SlashCommandBuilder()
.setName("stop")
.setDescription("Destroy the player.");
export { data };
-28
View File
@@ -1,28 +0,0 @@
import { CommandInteraction, SlashCommandBuilder, TextChannel } from "discord.js";
import Client from "../lib/Client.js";
import I18n from "../lib/I18n.js";
export async function run(client: Client, ctx: CommandInteraction) {
const volume = ctx.options.get("volume")?.value as number || 100;
if (!ctx.guild || !ctx.channel) return;
const player = client.modules.music.get(ctx.guild.id, ctx.channel as TextChannel);
if (!player) return;
player.Volume = volume;
await ctx.reply({ embeds: [I18n.en.volumeSet(player.Volume)] });
}
const data = new SlashCommandBuilder()
.setName("volume")
.setDescription("Change player's volume amount.")
.addIntegerOption(option => option
.setName("volume")
.setDescription("Volume in percents.")
.setRequired(true)
);
export { data };
-13
View File
@@ -1,13 +0,0 @@
import path from "path";
import { fileURLToPath } from "url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const config = {
token: process.env.DISCORD_TOKEN,
commands: path.join(__dirname, "/commands/"),
events: path.join(__dirname, "/events/"),
binaries: path.join(__dirname, "/../bin/")
};
export default config;
-14
View File
@@ -1,14 +0,0 @@
import { Interaction } from "discord.js";
import Client from "../lib/Client.js";
export async function run(client: Client, args: Interaction[]): Promise<void> {
const [interaction] = args;
if (interaction.isCommand()) {
const command = client.Commands.get(interaction.commandName);
if (command) command.run(client, interaction);
}
}
export const name = "interactionCreate";
-22
View File
@@ -1,22 +0,0 @@
import Client from "../lib/Client.js";
import { generateDependencyReport } from "@discordjs/voice";
import { ActivityType } from "discord.js";
export async function run(client: Client): Promise<void> {
if (!client.user || !client.application) {
console.log("No user or application is provided!");
process.exit(1);
}
console.log(`${client.user.username} logged in as ${client.user.tag}!`);
console.log(generateDependencyReport());
client.user.setActivity("over you", { type: ActivityType.Watching });
const restCommands = Array.from(client.Commands.values()).map(
value => value.data
);
client.application.commands.set(restCommands);
}
export const name = "ready";
-24
View File
@@ -1,24 +0,0 @@
import { VoiceState } from "discord.js";
import I18n from "../lib/I18n.js";
import Client from "../lib/Client.js";
import { asleep } from "../lib/Misc.js";
export async function run(client: Client, args: VoiceState[]): Promise<void> {
const [oldState] = args;
const player = client.modules.music.get(oldState.guild.id);
if (!player || !oldState.channel || !client.user)
return;
if (oldState.channel.members.filter(value => value.user !== client.user).size === 0) {
await asleep(30000);
if (oldState.channel.members.filter(value => value.user !== client.user).size === 0) {
await player.Channel.send({ embeds: [I18n.en.fullyAlone()] });
player.destroy();
}
}
}
export const name = "voiceStateUpdate";
+17 -4
View File
@@ -1,6 +1,19 @@
import Client from "./lib/Client.js";
import config from "./config.js";
import { GatewayIntentBits } from "discord.js";
const client = new Client();
import config from "$config.js";
import Client from "$lib/client.js";
client.login(config.token);
/**
* Ready to go bot instance.
*
* @remarks Quetza itself.
*/
const client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildVoiceStates,
GatewayIntentBits.GuildMembers
]
});
client.login(config.application.token);
-57
View File
@@ -1,57 +0,0 @@
import {
Client as DiscordClient,
GatewayIntentBits
} from "discord.js";
import { existsSync, readdirSync } from "fs";
import path from "path";
import Music from "./Music.js";
import { command, event } from "./Types.js";
import config from "../config.js";
import { pathToFileURL } from "url";
export default class Client extends DiscordClient {
private commands = new Map<string, command>();
get Commands(): Map<string, command> {
return this.commands;
}
public readonly modules = {
music: new Music()
};
public constructor() {
super({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildVoiceStates
],
});
if (existsSync(config.commands)) {
const files = readdirSync(config.commands).filter((file) =>
file.endsWith(".js")
);
for (const file of files) {
import(pathToFileURL(path.join(config.commands, file)).toString()).then((cmd: command) =>
this.commands.set(cmd.data.name, cmd)
);
}
}
if (existsSync(config.events)) {
const files = readdirSync(config.events).filter((file) =>
file.endsWith(".js")
);
for (const file of files) {
import(pathToFileURL(path.join(config.events, file)).toString()).then((evnt: event) => {
this.on(evnt.name, (...args: unknown[]) =>
evnt.run(this, args)
);
});
}
}
}
}
-54
View File
@@ -1,54 +0,0 @@
import { ytdlArgs, ytdlResponse } from "./Types.js";
import { execFile } from "child_process";
import { promisify } from "util";
import config from "../config.js";
import path from "path";
const execFileAsync = promisify(execFile);
function argumentsResolver(args: ytdlArgs): string[] {
const options = Object.entries(args);
return options.map(([key, value]) => {
if (typeof value === "boolean" && value) {
return `--${key.split(/(?=[A-Z])/).join("-")}`.toLowerCase();
}
else {
return `--${key
.split(/(?=[A-Z])/)
.join("-")}=${value}`.toLowerCase();
}
});
}
async function ytdlExec(
query: string,
args: ytdlArgs
): Promise<ytdlResponse | undefined> {
try {
const executable = await execFileAsync(
path.join(config.binaries, "/youtube-dl"),
[query].concat(argumentsResolver(args))
);
return JSON.parse(executable.stdout);
}
catch (error) {
console.log(error);
return undefined;
}
}
export async function fetchStream(
query: string,
args: ytdlArgs
): Promise<ytdlResponse | undefined> {
let response = await ytdlExec(query, args);
if (response && !response.extractor.includes("youtube")) {
response = await ytdlExec(
`${response.title} - ${response.creator}`,
args
);
}
return response;
}
-77
View File
@@ -1,77 +0,0 @@
export default class Filter {
private filters: Record<string, [boolean, string]> = {
bassboost_low: [false, "bass=g=15:f=110:w=0.3"],
bassboost: [false, "bass=g=20:f=110:w=0.3"],
bassboost_high: [false, "bass=g=30:f=110:w=0.3"],
"8D": [false, "apulsator=hz=0.09"],
vaporwave: [false, "aresample=48000,asetrate=48000*0.75"],
nightcore: [false, "aresample=48000,asetrate=48000*1.25"],
phaser: [false, "aphaser=in_gain=0.4"],
tremolo: [false, "tremolo"],
vibrato: [false, "vibrato=f=6.5"],
reverse: [false, "areverse"],
treble: [false, "treble=g=5"],
normalizer: [false, "dynaudnorm=g=101"],
normalizer2: [false, "acompressor"],
surrounding: [false, "surround"],
pulsator: [false, "apulsator=hz=1"],
subboost: [false, "asubboost"],
karaoke: [false, "stereotools=mlev=0.03"],
flanger: [false, "flanger"],
gate: [false, "agate"],
haas: [false, "haas"],
mcompand: [false, "mcompand"],
mono: [false, "pan=mono|c0=.5*c0+.5*c1"],
mstlr: [false, "stereotools=mode=ms>lr"],
mstrr: [false, "stereotools=mode=ms>rr"],
compressor: [
false,
"compand=points=-80/-105|-62/-80|-15.4/-15.4|0/-12|20/-7.6",
],
expander: [
false,
"compand=attacks=0:points=-80/-169|-54/-80|-49.5/-64.6|-41.1/-41.1|-25.8/-15|-10.8/-4.5|0/0|20/8.3",
],
softlimiter: [
false,
"compand=attacks=0:points=-80/-80|-12.4/-12.4|-6/-8|0/-6.8|20/-2.8",
],
chorus: [false, "chorus=0.7:0.9:55:0.4:0.25:2"],
chorus2d: [false, "chorus=0.6:0.9:50|60:0.4|0.32:0.25|0.4:2|1.3"],
chorus3d: [
false,
"chorus=0.5:0.9:50|60|40:0.4|0.32|0.3:0.25|0.4|0.3:2|2.3|1.3",
],
fadein: [false, "afade=t=in:ss=0:d=10"],
dim: [
false,
"afftfilt=\"'real=re * (1-clip((b/nb)*b,0,1))':imag='im * (1-clip((b/nb)*b,0,1))'\"",
],
earrape: [false, "channelsplit,sidechaingate=level_in=64"],
};
public get Filters(): [string, string][] {
return Object.entries(this.filters)
.filter(value => value[1][0]).map(value => [value[0], value[1][1]]);
}
public toggle(filter?: string): void {
if (!filter) {
Object.values(this.filters).forEach((value) => {
value[0] = false;
});
return;
}
if (Object.prototype.hasOwnProperty.call(this.filters, filter))
this.filters[filter][0] = !this.filters[filter][0];
}
public toString(): string {
return this.Filters.map(value => value[1]).join(",");
}
public empty(): boolean {
return this.Filters.length === 0;
}
}
-294
View File
@@ -1,294 +0,0 @@
import Player from "./Player.js";
import { loopOption, track } from "./Types.js";
import { loopMoji, secToISO, statusBarGenerator, volumeMoji } from "./Misc.js";
import Queue from "./Queue.js";
import { AudioResource } from "@discordjs/voice";
import { EmbedBuilder } from "discord.js";
export default class I18n {
public static en = {
nowPlaying: (track: track) => new EmbedBuilder()
.setColor("LuminousVividPink")
.setAuthor({
iconURL: track.requester.avatarURL() || undefined,
name: track.requester.tag,
})
.setTitle(track.title)
.setURL(track.url)
.setDescription("**playing now.**")
.setThumbnail(track.thumbnail),
cleared: (amount: number) => new EmbedBuilder()
.setColor("Green")
.setTitle(
`**♻️ ${amount} tracks have been cleared from the queue.**`
)
.setTimestamp(new Date()),
okConnected: (channel: string) => new EmbedBuilder()
.setColor("Green")
.setTitle(`**✅ Successfuly connected to:** _${channel}_.`),
notConnected: () => new EmbedBuilder()
.setColor("Red")
.setTitle(
"**🚫 Make yourself connected to a voice channel beforehand.**"
),
wasConnected: () => new EmbedBuilder()
.setColor("Yellow")
.setTitle("**⚠️ Already connected to a voice channel.**"),
filtered: (filter?: string) => new EmbedBuilder()
.setColor("Green")
.setTitle(filter ? `**✅ ${filter} was applied to the player.**` : "**✅ All filters were declined.**"),
appended: (track?: track) => !track
? new EmbedBuilder()
.setColor("Red")
.setTitle("**🚫 Could not add track to the queue.**")
: new EmbedBuilder()
.setColor("Green")
.setTitle(track.title)
.setURL(track.url)
.setThumbnail(track.thumbnail)
.setAuthor({
iconURL: track.requester.avatarURL() || undefined,
name: track.requester.tag,
})
.setDescription("**has been added to the queue.**")
.setTimestamp(new Date()),
jumped: (state: boolean) => state
? new EmbedBuilder()
.setColor("Green")
.setTitle("_**Perfoming jump.**_")
: new EmbedBuilder()
.setColor("Red")
.setTitle("**❗ Bad _jump_ request.**"),
looped: (query: loopOption) => {
switch (query) {
case "AUTO": {
return new EmbedBuilder()
.setColor("LuminousVividPink")
.setTitle(
"**🔀 Shuffle mode** _(does not affect the queue order)_."
);
}
case "LOOP": {
return new EmbedBuilder()
.setColor("Blue")
.setTitle("**🔁 Looping over the queue.**");
}
case "NONE": {
return new EmbedBuilder()
.setColor("Red")
.setTitle("**⤵️ Player will stop on the queue end.**");
}
case "SONG": {
return new EmbedBuilder()
.setColor("Yellow")
.setTitle("**🔂 Looping a single track.**");
}
}
},
skipped: () => new EmbedBuilder()
.setColor("Green")
.setTitle("**⏭️ Skipping.**"),
paused: (state: boolean) => state
? new EmbedBuilder()
.setColor("Green")
.setTitle("_**▶️ Resumed.**_")
: new EmbedBuilder()
.setColor("Yellow")
.setTitle("_**⏸️ Paused.**_"),
removed: (track?: track) => track
? new EmbedBuilder()
.setColor("Green")
.setTitle(track.title)
.setDescription("**was removed from the queue.**")
.setTimestamp(new Date())
.setURL(track.url)
.setThumbnail(track.thumbnail)
: new EmbedBuilder()
.setColor("Red")
.setTitle("**❗ Bad _remove_ request.**"),
fastForwarded: (amount: number) => new EmbedBuilder()
.setColor("Blue")
.setTitle(
`**⏩ Fast-forwarding to ${secToISO(
amount
)}**`
),
destroyed: () => new EmbedBuilder()
.setColor("Red")
.setTitle("**💀 Destroying the player.**"),
volumeSet: (amount: number) => new EmbedBuilder()
.setColor("Green")
.setTitle(`**${volumeMoji(amount)} Volume has been set to ${amount}%**`),
queueEmpty: () => new EmbedBuilder()
.setColor("Yellow")
.setTitle("**❎ Queue is empty.**"),
playerInfo: (player?: Player) => {
if (!player)
return new EmbedBuilder()
.setColor("Red")
.setTitle("**🚫 Player does not exist.**");
if (player.Resource) {
return new EmbedBuilder()
.setColor("LuminousVividPink")
.setTitle(
`${player.Queue.Position + 1}. ${player.Resource.metadata.title}`
)
.setDescription(
statusBarGenerator(
player.Resource.playbackDuration / 1000,
player.Resource.metadata.duration
)
)
.setThumbnail(player.Resource.metadata.thumbnail)
.setURL(player.Resource.metadata.url)
.addFields([
{
name: "Applied Filters",
value:
player.Filter.Filters.map(
(value) => value[0]
).join("\n") + "\ndefault",
inline: true,
},
{
name: "Queue Length",
value: `🔢 ${player.Queue.Tracks.length}`,
inline: true,
},
{
name: "Queue Duration",
value: `💿 ${secToISO(player.Queue.Duration)}`,
inline: true,
},
{
name: "Queue Looping",
value: `${loopMoji(player.Queue.Loop)} ${player.Queue.Loop}`,
inline: true,
},
{
name: "Volume amount",
value: `${volumeMoji(player.Volume)} ${player.Volume}/150`,
inline: true,
},
{
name: "Player lifetime",
value: `🕓 ${secToISO((Date.now() - player.Created.getTime()) / 1000)}`,
inline: true,
},
]);
}
else {
return new EmbedBuilder()
.setColor("Blue")
.setTitle("💿 Insert the disk...")
.setDescription(statusBarGenerator(0, 0))
.setURL(
"https://www.youtube.com/watch?v=dQw4w9WgXcQ&ab_channel=RickAstley"
)
.addFields([
{
name: "Applied Filters",
value:
player.Filter.Filters.map(
(value) => value[0]
).join("\n") + "\ndefault",
inline: true,
},
{
name: "Queue Length",
value: `🔢 ${player.Queue.Tracks.length}`,
inline: true,
},
{
name: "Queue Duration",
value: `💿 ${secToISO(player.Queue.Duration)}`,
inline: true,
},
{
name: "Queue Looping",
value: `${loopMoji(player.Queue.Loop)} ${player.Queue.Loop}`,
inline: true,
},
{
name: "Volume amount",
value: `${volumeMoji(player.Volume)} ${player.Volume}/150`,
inline: true,
},
{
name: "Player lifetime",
value: `🕓 ${secToISO((Date.now() - player.Created.getTime()) / 1000)}`,
inline: true,
},
]);
}
},
queueDesigner: (
queue: Queue,
page: number,
resource: AudioResource<track> | undefined
) => {
page = Math.max(
0,
Math.min(Math.floor(queue.Tracks.length / 11), page)
);
return new EmbedBuilder()
.setColor("LuminousVividPink")
.setTitle(
resource
? `${queue.Position + 1}. ${resource.metadata.title}`
: "💿 Insert the disk..."
)
.setURL(
resource
? resource.metadata.url
: "https://www.youtube.com/watch?v=5mGuCdlCcNM&ab_channel=Ra%C3%BAlBlanco"
)
.setThumbnail(
resource
? resource.metadata.thumbnail
: "https/img.youtube.com/vi/5mGuCdlCcNM/default.jpg"
)
.setDescription(
resource
? statusBarGenerator(
resource.playbackDuration / 1000,
resource.metadata.duration
)
: statusBarGenerator(0, 0)
)
.setFields(
queue.Tracks.slice(page * 10, page * 10 + 10).map(
(value, index) => ({
name: `${
page * 10 + index + 1
}. ${value.title.padEnd(35)}`,
value: `**[${secToISO(value.duration)}]**`,
})
)
);
},
fullyAlone: () => new EmbedBuilder()
.setColor("LuminousVividPink")
.setTitle("🥬 I have decided to leave to conserve my energy.")
.setDescription(
"_**Since there was nobody in the voice channel.**_"
),
reshuffle: () => new EmbedBuilder()
.setColor("Green")
.setTitle("**✅ Successfuly reshuffled queue.**"),
ping: (ping?: number) => {
const title = ping ? `I have pinged ${ping} ms late.` : "There is no application.";
const comment = !ping ? "How am I speaking then?" : ping > 250 ? "I'm kinda slowpoke ngl." :
ping > 200 ? "I can do better than this." :
"Dream's speed!";
return new EmbedBuilder()
.setColor("Random")
.setTitle(`**${title}**`)
.setDescription(`_**${comment}**_`);
}
};
}
-65
View File
@@ -1,65 +0,0 @@
import { loopOption } from "./Types.js";
export function statusBarGenerator(duration: number, length: number): string {
duration = Math.max(0, duration);
length = Math.max(0, length);
if (duration > length) duration = length;
const filled = Array<string>(
Math.floor((15 * duration) / (length === 0 ? 1 : length))
).fill(":small_blue_diamond:");
const empty = Array<string>(
15 - Math.floor((15 * duration) / (length === 0 ? 1 : length))
).fill(":white_small_square:");
return `**[${filled.join("")}${empty.join("")}] ${secToISO(
duration
)}/${secToISO(length)}**`;
}
export function secToISO(amount: number): string {
const h = Math.floor(amount / 3600);
const m = Math.floor((amount % 3600) / 60);
const s = Math.floor((amount % 3600) % 60);
const hDisplay = h > 0 ? `${h}:` : "";
const mDisplay = `${Math.floor(m / 10)}${Math.floor(m % 10)}:`;
const sDisplay = `${Math.floor(s / 10)}${Math.floor(s % 10)}`;
return hDisplay + mDisplay + sDisplay;
}
export async function asleep(amount: number): Promise<unknown> {
return new Promise((r) => setTimeout(r, amount));
}
export function volumeMoji(amount: number): string {
return amount > 100 ? "🔊" : amount > 50 ? "🔉" : amount > 0 ? "🔈" : "🔇";
}
export function loopMoji(loop: loopOption): string {
return loop === "AUTO"
? "🔀"
: loop === "LOOP"
? "🔁"
: loop === "NONE"
? "⤵️"
: "🔂";
}
export function largestCommonSequence(s1: string, s2: string) {
s1 = s1.toLowerCase().slice(0, 100);
s2 = s2.toLowerCase().slice(0, 100);
const buffer = Array.from(
Array(s1.length + 1),
() => new Array<number>(s2.length + 1).fill(0)
);
for (let i = 1; i <= s1.length; i++) {
for (let j = 1; j <= s2.length; j++) {
if (s1[i - 1] === s2[j - 1])
buffer[i][j] = buffer[i - 1][j - 1] + 1;
else buffer[i][j] = Math.max(buffer[i - 1][j], buffer[i][j - 1]);
}
}
return buffer[s1.length][s2.length];
}
-25
View File
@@ -1,25 +0,0 @@
import { Guild, TextChannel } from "discord.js";
import Player from "./Player.js";
export default class Music {
private players = new Map<string, Player>();
public get(guildId: string, channel?: TextChannel): Player | undefined {
const player = this.players.get(guildId);
if (player) {
if (channel) player.Channel = channel;
return player;
}
}
public set(guild: Guild, channel: TextChannel): Player {
const player = new Player(guild, this, channel);
this.players.set(guild.id, player);
return player;
}
public del(guildId: string): void {
this.players.delete(guildId);
}
}
-335
View File
@@ -1,335 +0,0 @@
import {
VoiceConnection,
AudioPlayer,
createAudioResource,
AudioPlayerStatus,
joinVoiceChannel,
createAudioPlayer,
AudioResource,
DiscordGatewayAdapterCreator,
VoiceConnectionStatus,
entersState,
} from "@discordjs/voice";
import {
Guild,
Message,
TextChannel,
User,
VoiceBasedChannel,
} from "discord.js";
import Music from "./Music.js";
import Filter from "./Filter.js";
import Queue from "./Queue.js";
import { fetchStream } from "./Fetch.js";
import I18n from "./I18n.js";
import ytdl from "discord-ytdl-core";
import { track } from "./Types.js";
import { largestCommonSequence } from "./Misc.js";
export default class Player {
private music: Music;
private guild: Guild;
private channel: TextChannel;
public get Channel(): TextChannel {
return this.channel;
}
public set Channel(x: TextChannel) {
this.channel = x;
}
private connection: VoiceConnection | undefined;
public get Connection(): VoiceConnection | undefined {
return this.connection;
}
private player: AudioPlayer;
public get Player(): AudioPlayer {
return this.player;
}
private filter = new Filter();
public get Filter(): Filter {
return this.filter;
}
private queue = new Queue();
public get Queue(): Queue {
return this.queue;
}
private resource: AudioResource<track> | undefined;
public get Resource(): AudioResource<track> | undefined {
return this.resource;
}
private message: Message | undefined;
private created: Date = new Date();
public get Created(): Date {
return this.created;
}
private volume = 100;
public get Volume(): number {
return this.volume;
}
public set Volume(x: number) {
this.volume = Math.max(0, Math.min(150, x));
if (this.resource && this.resource.volume) this.resource.volume.setVolumeLogarithmic(this.volume / 100);
}
public constructor(
guild: Guild,
music: Music,
textChannel: TextChannel
) {
this.music = music;
this.guild = guild;
this.channel = textChannel;
this.player = createAudioPlayer();
this.player.on(AudioPlayerStatus.Idle, async () =>
this.resourceEndResolvable()
);
}
private resourceGenerator(track: track, seek?: number) {
const stream = ytdl(track.url, {
opusEncoded: false,
fmt: "mp3",
filter: "audioonly",
highWaterMark: 1 << 25,
seek: seek ? seek / 1000 : undefined,
encoderArgs: !this.filter.empty()
? ["-af", this.filter.toString()]
: undefined,
});
this.resource = createAudioResource(stream, {
inlineVolume: true,
metadata: track,
});
if (seek) this.resource.playbackDuration = seek;
if (this.resource.volume) this.resource.volume.setVolumeLogarithmic(this.volume / 100);
return this.resource;
}
private resourceEndResolvable(): void {
if (this.resource) this.resource.encoder?.destroy();
if (this.queue.next()) {
this.play();
}
else {
this.resource = undefined;
this.player.stop();
}
}
private async messageResolvable(track: track) {
if (this.message) await this.message.delete();
this.message = await this.channel.send({
embeds: [I18n.en.nowPlaying(track)],
});
}
private async search(
query: string,
requester: User
): Promise<track[] | undefined> {
const response = await fetchStream(query, {
dumpSingleJson: true,
noWarnings: true,
noCheckCertificate: true,
flatPlaylist: true,
skipDownload: true,
defaultSearch: "ytsearch",
});
if (!response) return undefined;
const tracks: track[] = [];
if (response.entries) {
for (const entry of response.entries) {
tracks.push({
url: `https://youtube.com/watch?v=${entry.id}`,
title: entry.title,
thumbnail: `https://img.youtube.com/vi/${entry.id}/default.jpg`,
duration: entry.duration,
requester: requester,
});
}
}
else {
tracks.push({
url: `https://youtube.com/watch?v=${response.id}`,
title: response.title,
thumbnail: `https://img.youtube.com/vi/${response.id}/default.jpg`,
duration: response.duration,
requester: requester,
});
}
return tracks;
}
public play(seek?: number): void {
const track = this.queue.current();
if (!this.connection || !track) return;
this.player.play(this.resourceGenerator(track, seek));
this.connection.subscribe(this.player);
this.messageResolvable(track);
}
public connect(channel: VoiceBasedChannel): void {
if (this.connection) this.connection.destroy();
this.connection = joinVoiceChannel({
channelId: channel.id,
guildId: channel.guildId,
adapterCreator: channel.guild
.voiceAdapterCreator as DiscordGatewayAdapterCreator,
});
this.connection.on(
VoiceConnectionStatus.Disconnected,
async () => {
try {
await Promise.race([
entersState(
this.connection as VoiceConnection,
VoiceConnectionStatus.Signalling,
10_000
),
entersState(
this.connection as VoiceConnection,
VoiceConnectionStatus.Connecting,
10_000
),
]);
}
catch (error) {
this.destroy();
}
}
);
}
public async add(
query: string,
requester: User,
index?: number
): Promise<track | undefined> {
const tracks = await this.search(query, requester);
if (!tracks) return;
this.queue.push(tracks, index);
return tracks[0];
}
public jump(query: number | string): boolean {
let position = typeof query === "number" ? query : -1;
let diff = 0;
if (typeof query === "string") {
this.queue.Tracks.forEach((track, index) => {
const tempDiff = largestCommonSequence(track.title, query);
if (diff < tempDiff) {
diff = tempDiff;
position = index;
}
});
}
if (position !== -1 && this.queue.jump(position)) {
this.play();
return true;
}
return false;
}
public remove(query?: number | string): track | undefined {
const oldPosition = this.queue.Position;
let newPosition = typeof query === "number" ? query : this.queue.Position;
let diff = 0;
if (typeof query === "string") {
this.queue.Tracks.forEach((track, index) => {
const tempDiff = largestCommonSequence(track.title, query);
if (diff < tempDiff) {
diff = tempDiff;
newPosition = index;
}
});
}
const track = this.queue.pop(newPosition);
if (track) {
if (oldPosition === newPosition) this.play();
return track;
}
return undefined;
}
public seek(time: number): void {
this.play(time);
}
public clear(): void {
this.queue.clear();
this.resource = undefined;
if (this.player) this.player.stop();
}
public destroy(): void {
if (this.connection) this.connection.destroy();
this.player.stop();
this.music.del(this.guild.id);
}
public pause(): boolean {
if (this.player.state.status === AudioPlayerStatus.Paused) {
this.player.unpause();
return false;
}
else {
this.player.pause();
return true;
}
}
public shuffle(): void {
this.queue.shuffle();
if (this.resource) {
this.player.stop();
this.play();
}
}
public skip(): void {
this.player.stop();
}
public setFilter(filter?: string): void {
this.filter.toggle(filter);
if (this.resource) this.play(this.resource.playbackDuration);
}
}
-115
View File
@@ -1,115 +0,0 @@
import { loopOption, track } from "./Types.js";
export default class Queue {
private tracks: track[] = [];
public get Tracks(): track[] {
return this.tracks;
}
private position = 0;
public get Position(): number {
return this.position;
}
private loop: loopOption = "NONE";
public get Loop(): loopOption {
return this.loop;
}
public set Loop(x: loopOption) {
this.loop = x;
}
private duration = 0;
public get Duration(): number {
return this.duration;
}
public next(): track | undefined {
if (this.loop === "SONG") return this.current();
if (this.loop === "AUTO") {
this.position = Math.floor(Math.random() * this.tracks.length);
return this.current();
}
if (this.position + 1 < this.tracks.length) {
this.position++;
return this.current();
}
else if (this.loop === "LOOP") {
this.position = 0;
return this.current();
}
else if (this.loop === "NONE") {
this.position = 0;
return undefined;
}
}
public shuffle(): void {
let currentIndex = this.tracks.length;
while (currentIndex != 0) {
const randomIndex = Math.floor(Math.random() * currentIndex);
currentIndex--;
[this.tracks[currentIndex], this.tracks[randomIndex]] = [
this.tracks[randomIndex],
this.tracks[currentIndex],
];
}
this.position = 0;
}
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) {
this.duration += track.duration;
}
return this.tracks[index || this.tracks.length - 1];
}
public pop(query: number): track | undefined {
if (query >= 0 && query < this.tracks.length) {
const track = this.tracks[query];
this.tracks.splice(query, 1);
this.position = Math.max(0, this.position - 1);
this.duration -= track.duration;
return track;
}
return undefined;
}
public jump(query: number): track | undefined {
if (query >= 0 && query < this.tracks.length) {
const track = this.tracks[query];
this.position = query;
return track;
}
return undefined;
}
public current(): track {
return this.tracks[this.position];
}
public empty(): boolean {
return this.tracks.length === 0;
}
public clear(): void {
this.tracks = [];
this.position = 0;
}
}
-272
View File
@@ -1,272 +0,0 @@
import { ApplicationCommandData, User, CommandInteraction } from "discord.js";
import Client from "./Client.js";
export type track = {
url: string;
title: string;
thumbnail: string;
duration: number;
requester: User;
};
export type command = {
data: ApplicationCommandData;
run: (arg0: Client, arg1: CommandInteraction) => Promise<void>;
};
export type event = {
name: string;
run: (arg0: Client, arg1: unknown[]) => Promise<void>;
};
export type loopOption = "LOOP" | "SONG" | "NONE" | "AUTO";
export type 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;
};
type 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;
};
type ytdlThumbnail = {
height: number;
url: string;
width: number;
resolution: string;
id: string;
};
type ytdlEntry = {
uploader: string;
duration: number;
id: string;
title: string;
url: string;
};
export type 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[];
};
+132
View File
@@ -0,0 +1,132 @@
import { Client as DiscordClient, ClientOptions, Collection } from "discord.js";
import { readdirSync } from "fs";
import config from "$config.js";
import { importDir, pathThrough } from "$lib/misc.js";
import {
ApplicationStatus,
Command,
CommandBase,
Event,
EventBase,
Module,
ModuleBase
} from "$lib/types.js";
/**
* Creating path to {@link Module | Module's} declaration file.
*
* @internal
*/
const toModuleDeclaration = pathThrough([config.path.modules], ["module.js"]);
/**
* 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>();
/**
* Mapping event name to its {@link Event | instance}.
*/
public readonly events = new Collection<string, Event>();
/**
* Mapping module name to its {@link Module | instance}.
*/
public readonly modules = new Collection<string, Module>();
/**
* Constructs Quetza Client.
*
* @param options - Discord.js client options
*/
public constructor(options: ClientOptions) {
super(options);
readdirSync(config.path.modules).forEach((module) =>
this.importModule(
toModuleDeclaration(module),
toModuleCommands(module),
toModuleEvents(module)
)
);
}
/**
* Generates client's status by providing Bot User and Loaded Modules info.
*
* @returns Status of application.
* Additional info such as Application ID provided if {@link Client} is ready.
*/
public generateApplicationStatus(): ApplicationStatus {
const modules = Array.from(this.modules.values());
if (!this.isReady()) {
return {
applicationId: null,
tag: null,
modules
};
}
return {
applicationId: this.application.id,
tag: this.user.tag,
modules
};
}
/**
* Imports module metadata, commands and events to the client.
*
* @param declaration - File declaring module information.
* @param commands - Directory containing module's commands.
* @param events - Directory containing module's events.
*/
private async importModule(
declaration: string,
commands: string,
events: string
): Promise<void> {
const module = await import(declaration).then((module: ModuleBase) =>
this.modules.ensure(module.name, () => ({ ...module, commands: [], events: [] }))
);
importDir<CommandBase>(commands, (command) => {
const saved = this.commands.ensure(command.data.name, () => ({ ...command, module }));
module.commands.push(saved);
});
importDir<EventBase>(events, (event) => {
const saved = this.events.ensure(event.name, () => ({ ...event, module }));
module.events.push(saved);
this.on(event.name, (...eventee: unknown[]) =>
saved.execute(this, eventee, module.controller)
);
});
}
}
+52
View File
@@ -0,0 +1,52 @@
/**
* Base class for all quetza's errors.
*
* @public
*/
export default class QuetzaError extends Error {
/**
* Default prefix value stands for quetza's base features.
*/
public static readonly DEFAULT_PREFIX = "QZ";
/**
* Error code is an identifier for specific Error in the same module.
*/
public readonly code: number;
/**
* Prefix defines module where Error has occured.
*
* @defaultValue `QuetzaError.DEFAULT_PREFIX`
*/
public readonly prefix: string;
/**
* Constructs QuetzaError class.
*
* @param msg - Error message (that shall not be displayed to end user)
* @param code - {@link code | Code} unique to error in module
* @param prefix - Identifier of module where error has occurred
*/
public constructor(msg: string, code: number, prefix = QuetzaError.DEFAULT_PREFIX) {
super(msg);
this.code = code;
this.prefix = prefix;
}
/**
* Must create a unique error ID if no collision bugs were written.
*
* @returns error ID as concatenation of {@link prefix} and {@link code}
*
* @example
* ```ts
* const error = new QuetzaError("Some error.", 404, "NF");
*
* error.getErrorId(); // "NF404"
* ```
*/
public getErrorId(): string {
return this.prefix + this.code.toString();
}
}
+33
View File
@@ -0,0 +1,33 @@
import path from "path";
import winston from "winston";
import config from "$config.js";
/** Error logging file path.
*
* @internal
*/
const errorLog = path.join(config.path.log, "error.log");
/** Combined logging file path.
*
* @internal
*/
const combinedLog = path.join(config.path.log, "combined.log");
/**
* Logger instance. Use it for logging.
*
* @public
*/
const logger = winston.createLogger({
levels: winston.config.syslog.levels,
format: winston.format.combine(winston.format.timestamp(), winston.format.json()),
transports: [
new winston.transports.Console({ format: winston.format.simple() }),
new winston.transports.File({ filename: errorLog, level: "error" }),
new winston.transports.File({ filename: combinedLog })
]
});
export default logger;
+77
View File
@@ -0,0 +1,77 @@
/**
* Definitions for miscellaneous functions used in this project.
*/
import assert from "assert";
import { existsSync, readdirSync } from "fs";
import path from "path";
import { pathToFileURL } from "url";
/**
* Clamps value between min and max params.
*
* @returns Function with defined min and max, expecting a value to clamp.
*
* @param min - value less than min will resolve to min.
* @param max - value greater than max will resolve to max.
*
* @public
*/
export function clamp(min: number, max: number) {
assert(min <= max);
return (value: number) => Math.max(min, Math.max(max, value));
}
/**
* Converts Path to File URI Scheme String.
*
* @returns URI.toString() made from joined paths.
*
* @param paths - Strings that should be joined and converted to URI.
*
* @public
*/
export function pathToURI(...paths: string[]): string {
return pathToFileURL(path.join(...paths)).toString();
}
/**
* Imports every module withing provided folder with callback.
*
* @remarks Will import nothing if directory not exists.
*
* @typeparam T - Type of whatever you're importing.
* If not 'any', expecting every import to have the same semantics.
*
* @param dir - Directory where importing modules resides.
* @param callback - Function to run on every import.
*
* @public
*/
export function importDir<T>(dir: string, callback: (module: T) => void) {
if (!existsSync(dir)) {
return;
}
readdirSync(dir)
.filter((source) => source.endsWith(".js"))
.forEach((source) => import(pathToURI(dir, source)).then(callback));
}
/**
* Creates function that joins paths with predefined origin and target.
*
* @returns String with following structure: '...origins/...through/...target'.
*
* @param origins - Paths that will be joined and prepended to 'through'.
* @param targets - Paths that will be joined and appended to 'through'.
*
* @public
*/
export function pathThrough(
origins: string[],
targets: string[]
): (...through: string[]) => string {
return (...through: string[]) => path.join(...origins, ...through, ...targets);
}
+103
View File
@@ -0,0 +1,103 @@
/**
* Quetza's base type declarations.
*
* @see Architecture documentation for more understanding of its purpose.
*/
import { ApplicationCommandData, CommandInteraction, If } from "discord.js";
import Client from "$lib/client.js";
/**
* Quetza's module declaration.
*
* @public
*/
export interface ModuleBase {
/** Name of a module. */
name: string;
/** Description of a module. */
description: string;
/** Module controller. */
controller?: unknown;
}
/**
* Complete {@link ModuleBase} declaration with commands and events.
*
* @public
*/
export interface Module extends ModuleBase {
/** Module's commands. */
commands: CommandBase[];
/** Module's events. */
events: EventBase[];
}
/**
* Quetza's command interaction declaration.
*
* @public
*/
export interface CommandBase {
/** Command metadata that will be passed to the Discord API. */
data: ApplicationCommandData;
/** Function that will be executed upon invoking this interaction. */
execute: (
client: Client,
interaction: CommandInteraction,
controller?: unknown
) => Promise<void>;
}
/**
* {@link ModuleBase} with {@link Module} included.
*
* @public
*/
export interface Command extends CommandBase {
/** Module containing this Command. */
module: Module;
}
/**
* Quetza's event declaration.
*
* @public
*/
export interface EventBase {
/** Name of an event to listen. */
name: string;
/** Function that will be executed upon event emitting. */
execute: (client: Client, eventee: unknown[], controller?: unknown) => Promise<void>;
}
/**
* {@link EventBase} with {@link Module} included.
*
* @public
*/
export interface Event extends EventBase {
/** Module containing this Event. */
module: ModuleBase;
}
/**
* Application status provides information that may be useful at initialization.
*
* @public
*/
export interface ApplicationStatus<Ready extends boolean = boolean> {
/** Discord's ID of an application. */
applicationId: If<Ready, string>;
/** Tag of a bot user. */
tag: If<Ready, string>;
/** List of each module imported. */
modules: Module[];
}
+15
View File
@@ -0,0 +1,15 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"paths": {
"$config.js": ["../config.js"],
"$lib/*": ["./lib/*"]
},
"rootDir": ".",
"outDir": "../dist/src",
"composite": true
},
"references": [{ "path": "../config.tsconfig.json" }]
}
+13 -11
View File
@@ -2,18 +2,20 @@
"compilerOptions": {
"strict": true,
"module": "ESNext",
"moduleResolution": "node",
"outDir": "dist",
"removeComments": false,
"sourceMap": true,
"esModuleInterop": true,
"target": "ESNext",
"lib": ["ESNext"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"resolveJsonModule": true,
"types": ["node"],
"outDir": "./dist",
"sourceMap": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"target": "ES2022",
"skipLibCheck": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
"references": [{ "path": "./modules/core" }, { "path": "./modules/music" }]
}