Compare commits
51
Commits
No files matched your search
+19
-2
@@ -1,2 +1,19 @@
|
|||||||
node_modules
|
# Ignore everything by default
|
||||||
Procfile
|
*
|
||||||
|
|
||||||
|
# Dockerfile
|
||||||
|
!Dockerfile
|
||||||
|
|
||||||
|
# Source
|
||||||
|
!config.ts
|
||||||
|
!src/
|
||||||
|
!modules/
|
||||||
|
|
||||||
|
# Dependencies
|
||||||
|
!package.json
|
||||||
|
!pnpm-lock.yaml
|
||||||
|
|
||||||
|
# Build
|
||||||
|
!build.sh
|
||||||
|
!config.tsconfig.json
|
||||||
|
!tsconfig.json
|
||||||
+16
-10
@@ -1,15 +1,21 @@
|
|||||||
{
|
{
|
||||||
"env": { "browser": true, "es2021": true },
|
"root": true,
|
||||||
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
|
|
||||||
"parser": "@typescript-eslint/parser",
|
"parser": "@typescript-eslint/parser",
|
||||||
"parserOptions": { "ecmaVersion": "latest", "sourceType": "module" },
|
"parserOptions": {
|
||||||
"plugins": ["@typescript-eslint"],
|
"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": {
|
"rules": {
|
||||||
"indent": ["error", 4],
|
"curly": ["error", "all"],
|
||||||
"linebreak-style": ["error", "windows"],
|
"simple-import-sort/imports": "error",
|
||||||
"quotes": ["error", "double"],
|
"simple-import-sort/exports": "error"
|
||||||
"semi": ["error", "always"],
|
|
||||||
"brace-style": ["error", "stroustrup"],
|
|
||||||
"arrow-body-style": ["error", "as-needed"]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
|
||||||
@@ -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 }}
|
|
||||||
@@ -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
@@ -1,374 +1,64 @@
|
|||||||
## Ignore Visual Studio temporary files, build results, and
|
# Logs
|
||||||
## files generated by popular Visual Studio add-ons.
|
logs
|
||||||
##
|
|
||||||
## 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
|
|
||||||
*.log
|
*.log
|
||||||
*.vspscc
|
npm-debug.log*
|
||||||
*.vssscc
|
yarn-debug.log*
|
||||||
.builds
|
yarn-error.log*
|
||||||
*.pidb
|
lerna-debug.log*
|
||||||
*.svclog
|
.pnpm-debug.log*
|
||||||
*.scc
|
|
||||||
|
|
||||||
# Chutzpah Test files
|
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||||
_Chutzpah*
|
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||||
|
|
||||||
# Visual C++ cache files
|
# Runtime data
|
||||||
ipch/
|
pids
|
||||||
*.aps
|
*.pid
|
||||||
*.ncb
|
*.seed
|
||||||
*.opendb
|
*.pid.lock
|
||||||
*.opensdf
|
|
||||||
*.sdf
|
|
||||||
*.cachefile
|
|
||||||
*.VC.db
|
|
||||||
*.VC.VC.opendb
|
|
||||||
|
|
||||||
# Visual Studio profiler
|
# Binaries
|
||||||
*.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
|
|
||||||
bin/
|
bin/
|
||||||
|
|
||||||
# Keep enviromental variables out of version control
|
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||||
*.env
|
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.*
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Node.js Tools
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
# Typescript compiller artifacts
|
||||||
|
dist/
|
||||||
@@ -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
@@ -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 python3
|
||||||
|
RUN apk add --no-cache curl
|
||||||
|
RUN apk add --no-cache make
|
||||||
|
RUN apk add --no-cache gcc
|
||||||
|
RUN apk add --no-cache build-base
|
||||||
|
|
||||||
|
RUN npm install -g pnpm
|
||||||
|
|
||||||
WORKDIR /usr/src/app
|
WORKDIR /usr/src/app
|
||||||
|
|
||||||
RUN mkdir bin
|
COPY package.json pnpm-lock.yaml ./
|
||||||
RUN curl -L https://yt-dl.org/downloads/latest/youtube-dl -o /usr/src/app/bin/youtube-dl
|
|
||||||
RUN chmod a+rx /usr/src/app/bin/youtube-dl
|
|
||||||
|
|
||||||
COPY package*.json ./
|
RUN pnpm install
|
||||||
RUN npm ci
|
|
||||||
|
|
||||||
COPY . .
|
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,16 +1,148 @@
|
|||||||
# Quetza - 2302.1 <soundrewrite>
|

|
||||||
|
|
||||||
_Neat typescript discord bot._
|
# Quetza │ [](https://github.com/unknowableshade/quetza/actions/workflows/ci.yml)
|
||||||
|
|
||||||
## About
|
Awesome Discord bot that performs various duties on my server.
|
||||||
|
|
||||||
- Base player features.
|
## Architecture
|
||||||
- Play music from YouTube and many other sites.
|
|
||||||
- Lots of different filters _(nightcore, earrape etc.)_.
|
|
||||||
- Slash commands.
|
|
||||||
|
|
||||||
## Links
|
At its core **Quetza** is simply a **[user-client](./src/lib/client.ts)** with a **[logger](./src/lib/logger.ts)**.
|
||||||
|
|
||||||
- **[Dependencies](https://github.com/unknowableshade/quetza-bot/blob/master/package.json)**
|
### Modules
|
||||||
- **[Docker](https://hub.docker.com/repository/docker/unknowableshade/quetza-bot)**
|
|
||||||
- **[Licence](https://github.com/unknowableshade/5a0b-bot/blob/master/LICENSE)**
|
**Modules** define the features of **Quetza**.
|
||||||
|
|
||||||
|
> 🗂 **The structure** of a **module**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
modules/example/
|
||||||
|
├ module.ts
|
||||||
|
├ commands/
|
||||||
|
│ ├ [command-name].ts
|
||||||
|
├ events/
|
||||||
|
│ ├ [event-name].ts
|
||||||
|
├ lib/
|
||||||
|
│ ├ [...libraries] // Including controller if exists.
|
||||||
|
```
|
||||||
|
|
||||||
|
#### **module.ts**
|
||||||
|
|
||||||
|
**The module** must have a definition file that **adheres** to the following **interface** as _export_.
|
||||||
|
|
||||||
|
> 📐 **Module** export **interface** [defined in _[src/lib/types.ts](./src/lib/types.ts)_]
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface Module {
|
||||||
|
name: string; // Name of the module.
|
||||||
|
description?: string; // Description of the module.
|
||||||
|
controller?: unknown;
|
||||||
|
// The service orchestrating the module. (passed as third argument to command or event executor)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### commands/[command-name].ts
|
||||||
|
|
||||||
|
Each **command** has to be defined in its own file, preferably named after **the command** itself.
|
||||||
|
**The client** indexes **the commands** of each **module** and sends them to **the Discord API**.
|
||||||
|
|
||||||
|
> 📐 **Command** **interface** to export [defined in _[src/lib/types.ts](./src/lib/types.ts)_]
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface Command {
|
||||||
|
data: ApplicationCommandData; // Definition of command specifically for the Discord API.
|
||||||
|
execute: (client: Client, interaction: CommandInteraction, controller?: unknown) => Promise<void>;
|
||||||
|
// Function that will be called upon interaction invocation.
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> ⚠️ Each **command** must comply with the _command interaction_ requirements of **the Discord API**.
|
||||||
|
|
||||||
|
#### events/[event-name].ts
|
||||||
|
|
||||||
|
**Events** are pre-defined by **the Discord API** and can only have new functions
|
||||||
|
bound to **them** by **modules**, despite being declared similarly to **commands**.
|
||||||
|
|
||||||
|
> 📐 **Event** **interface** to export [defined in _[src/lib/types.ts](./src/lib/types.ts)_]
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export interface Event {
|
||||||
|
name: string;
|
||||||
|
execute: (client: Client, eventee: unknown[], controller?: unknown) => Promise<void>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> ✅ **These** can be re-defined so that they can operate independently in their own **module**.
|
||||||
|
|
||||||
|
#### lib/[...libraries] & controller
|
||||||
|
|
||||||
|
If you expand the provided **module** **structure**, it will still be a valid **module**.
|
||||||
|
**_lib/_** is simply a suggestion to add consistency to the codebase.
|
||||||
|
|
||||||
|
**The controller** is responsible for orchestrating the behaviour of your **module**.
|
||||||
|
_Imagine it like a web application service._
|
||||||
|
|
||||||
|
> 📘 **For example**, creating players for listening to music on voice channels
|
||||||
|
|
||||||
|
## Build System
|
||||||
|
|
||||||
|
**Quetza** relies on various dependencies to function correctly. The **[package.json](./package.json)** file lists the _engines_, _toolchain_, _dependencies_, and other _metadata_.
|
||||||
|
|
||||||
|
> ⚠️ **pnpm** is being used as package manager.
|
||||||
|
|
||||||
|
### External Dependencies
|
||||||
|
|
||||||
|
**Modules** (or maybe **Quetza** itself) can leverage external _binaries_
|
||||||
|
that shall be located at _bin/_ directory or be accessible from _the environment_.
|
||||||
|
|
||||||
|
> 🔗 **Listing** of extenal _dependencies_
|
||||||
|
|
||||||
|
| **Base/Module** | **Dependency** |
|
||||||
|
| --------------- | ------------------------------------------ |
|
||||||
|
| Music | [yt-dlp](https://github.com/yt-dlp/yt-dlp) |
|
||||||
|
|
||||||
|
### Shipping
|
||||||
|
|
||||||
|
#### Docker
|
||||||
|
|
||||||
|
[](https://github.com/unknowableshade/quetza/actions/workflows/ci.yml)
|
||||||
|
|
||||||
|
The primary method of deploying **Quetza** is through
|
||||||
|
a **[Docker Image](https://hub.docker.com/repository/docker/unknowableshade/quetza)**
|
||||||
|
provided by the respective
|
||||||
|
**[action](./.github/workflows/ci.yml)**.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run \
|
||||||
|
-e DISCORD_TOKEN=*aquired via Discord Developer Portal* \
|
||||||
|
--name quetza -d \
|
||||||
|
unknowableshade/quetza-bot:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
#### From Source
|
||||||
|
|
||||||
|
Although it is possible to run it directly from the source, some additional steps are required.
|
||||||
|
|
||||||
|
- Compile _TypeScript_
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
- Run **Quetza**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm start
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Environment
|
||||||
|
|
||||||
|
In any case, you have to provide **environmental variables** for **bot** to run.
|
||||||
|
|
||||||
|
- **DISCORD_TOKEN**: generated token issued by **Discord** itself at
|
||||||
|
**[Developer Portal](https://discord.com/developers/applications)**.
|
||||||
|
|
||||||
|
## Licence
|
||||||
|
|
||||||
|
**Quetza's** source code is licensed under **[the MIT Licence](,/LICENSE)**.
|
||||||
|
|
||||||
|
**Notice!** This does not cover '**_Quetza_**' branding such as **[logo](./assets/quetza-logo.png)** or other **[assets listed](./assets/)**, as these are protected by **[the separate licence](./assets/LICENSE)**.
|
||||||
+407
@@ -0,0 +1,407 @@
|
|||||||
|
Attribution-NonCommercial 4.0 International
|
||||||
|
|
||||||
|
=======================================================================
|
||||||
|
|
||||||
|
Creative Commons Corporation ("Creative Commons") is not a law firm and
|
||||||
|
does not provide legal services or legal advice. Distribution of
|
||||||
|
Creative Commons public licenses does not create a lawyer-client or
|
||||||
|
other relationship. Creative Commons makes its licenses and related
|
||||||
|
information available on an "as-is" basis. Creative Commons gives no
|
||||||
|
warranties regarding its licenses, any material licensed under their
|
||||||
|
terms and conditions, or any related information. Creative Commons
|
||||||
|
disclaims all liability for damages resulting from their use to the
|
||||||
|
fullest extent possible.
|
||||||
|
|
||||||
|
Using Creative Commons Public Licenses
|
||||||
|
|
||||||
|
Creative Commons public licenses provide a standard set of terms and
|
||||||
|
conditions that creators and other rights holders may use to share
|
||||||
|
original works of authorship and other material subject to copyright
|
||||||
|
and certain other rights specified in the public license below. The
|
||||||
|
following considerations are for informational purposes only, are not
|
||||||
|
exhaustive, and do not form part of our licenses.
|
||||||
|
|
||||||
|
Considerations for licensors: Our public licenses are
|
||||||
|
intended for use by those authorized to give the public
|
||||||
|
permission to use material in ways otherwise restricted by
|
||||||
|
copyright and certain other rights. Our licenses are
|
||||||
|
irrevocable. Licensors should read and understand the terms
|
||||||
|
and conditions of the license they choose before applying it.
|
||||||
|
Licensors should also secure all rights necessary before
|
||||||
|
applying our licenses so that the public can reuse the
|
||||||
|
material as expected. Licensors should clearly mark any
|
||||||
|
material not subject to the license. This includes other CC-
|
||||||
|
licensed material, or material used under an exception or
|
||||||
|
limitation to copyright. More considerations for licensors:
|
||||||
|
wiki.creativecommons.org/Considerations_for_licensors
|
||||||
|
|
||||||
|
Considerations for the public: By using one of our public
|
||||||
|
licenses, a licensor grants the public permission to use the
|
||||||
|
licensed material under specified terms and conditions. If
|
||||||
|
the licensor's permission is not necessary for any reason--for
|
||||||
|
example, because of any applicable exception or limitation to
|
||||||
|
copyright--then that use is not regulated by the license. Our
|
||||||
|
licenses grant only permissions under copyright and certain
|
||||||
|
other rights that a licensor has authority to grant. Use of
|
||||||
|
the licensed material may still be restricted for other
|
||||||
|
reasons, including because others have copyright or other
|
||||||
|
rights in the material. A licensor may make special requests,
|
||||||
|
such as asking that all changes be marked or described.
|
||||||
|
Although not required by our licenses, you are encouraged to
|
||||||
|
respect those requests where reasonable. More considerations
|
||||||
|
for the public:
|
||||||
|
wiki.creativecommons.org/Considerations_for_licensees
|
||||||
|
|
||||||
|
=======================================================================
|
||||||
|
|
||||||
|
Creative Commons Attribution-NonCommercial 4.0 International Public
|
||||||
|
License
|
||||||
|
|
||||||
|
By exercising the Licensed Rights (defined below), You accept and agree
|
||||||
|
to be bound by the terms and conditions of this Creative Commons
|
||||||
|
Attribution-NonCommercial 4.0 International Public License ("Public
|
||||||
|
License"). To the extent this Public License may be interpreted as a
|
||||||
|
contract, You are granted the Licensed Rights in consideration of Your
|
||||||
|
acceptance of these terms and conditions, and the Licensor grants You
|
||||||
|
such rights in consideration of benefits the Licensor receives from
|
||||||
|
making the Licensed Material available under these terms and
|
||||||
|
conditions.
|
||||||
|
|
||||||
|
|
||||||
|
Section 1 -- Definitions.
|
||||||
|
|
||||||
|
a. Adapted Material means material subject to Copyright and Similar
|
||||||
|
Rights that is derived from or based upon the Licensed Material
|
||||||
|
and in which the Licensed Material is translated, altered,
|
||||||
|
arranged, transformed, or otherwise modified in a manner requiring
|
||||||
|
permission under the Copyright and Similar Rights held by the
|
||||||
|
Licensor. For purposes of this Public License, where the Licensed
|
||||||
|
Material is a musical work, performance, or sound recording,
|
||||||
|
Adapted Material is always produced where the Licensed Material is
|
||||||
|
synched in timed relation with a moving image.
|
||||||
|
|
||||||
|
b. Adapter's License means the license You apply to Your Copyright
|
||||||
|
and Similar Rights in Your contributions to Adapted Material in
|
||||||
|
accordance with the terms and conditions of this Public License.
|
||||||
|
|
||||||
|
c. Copyright and Similar Rights means copyright and/or similar rights
|
||||||
|
closely related to copyright including, without limitation,
|
||||||
|
performance, broadcast, sound recording, and Sui Generis Database
|
||||||
|
Rights, without regard to how the rights are labeled or
|
||||||
|
categorized. For purposes of this Public License, the rights
|
||||||
|
specified in Section 2(b)(1)-(2) are not Copyright and Similar
|
||||||
|
Rights.
|
||||||
|
d. Effective Technological Measures means those measures that, in the
|
||||||
|
absence of proper authority, may not be circumvented under laws
|
||||||
|
fulfilling obligations under Article 11 of the WIPO Copyright
|
||||||
|
Treaty adopted on December 20, 1996, and/or similar international
|
||||||
|
agreements.
|
||||||
|
|
||||||
|
e. Exceptions and Limitations means fair use, fair dealing, and/or
|
||||||
|
any other exception or limitation to Copyright and Similar Rights
|
||||||
|
that applies to Your use of the Licensed Material.
|
||||||
|
|
||||||
|
f. Licensed Material means the artistic or literary work, database,
|
||||||
|
or other material to which the Licensor applied this Public
|
||||||
|
License.
|
||||||
|
|
||||||
|
g. Licensed Rights means the rights granted to You subject to the
|
||||||
|
terms and conditions of this Public License, which are limited to
|
||||||
|
all Copyright and Similar Rights that apply to Your use of the
|
||||||
|
Licensed Material and that the Licensor has authority to license.
|
||||||
|
|
||||||
|
h. Licensor means the individual(s) or entity(ies) granting rights
|
||||||
|
under this Public License.
|
||||||
|
|
||||||
|
i. NonCommercial means not primarily intended for or directed towards
|
||||||
|
commercial advantage or monetary compensation. For purposes of
|
||||||
|
this Public License, the exchange of the Licensed Material for
|
||||||
|
other material subject to Copyright and Similar Rights by digital
|
||||||
|
file-sharing or similar means is NonCommercial provided there is
|
||||||
|
no payment of monetary compensation in connection with the
|
||||||
|
exchange.
|
||||||
|
|
||||||
|
j. Share means to provide material to the public by any means or
|
||||||
|
process that requires permission under the Licensed Rights, such
|
||||||
|
as reproduction, public display, public performance, distribution,
|
||||||
|
dissemination, communication, or importation, and to make material
|
||||||
|
available to the public including in ways that members of the
|
||||||
|
public may access the material from a place and at a time
|
||||||
|
individually chosen by them.
|
||||||
|
|
||||||
|
k. Sui Generis Database Rights means rights other than copyright
|
||||||
|
resulting from Directive 96/9/EC of the European Parliament and of
|
||||||
|
the Council of 11 March 1996 on the legal protection of databases,
|
||||||
|
as amended and/or succeeded, as well as other essentially
|
||||||
|
equivalent rights anywhere in the world.
|
||||||
|
|
||||||
|
l. You means the individual or entity exercising the Licensed Rights
|
||||||
|
under this Public License. Your has a corresponding meaning.
|
||||||
|
|
||||||
|
|
||||||
|
Section 2 -- Scope.
|
||||||
|
|
||||||
|
a. License grant.
|
||||||
|
|
||||||
|
1. Subject to the terms and conditions of this Public License,
|
||||||
|
the Licensor hereby grants You a worldwide, royalty-free,
|
||||||
|
non-sublicensable, non-exclusive, irrevocable license to
|
||||||
|
exercise the Licensed Rights in the Licensed Material to:
|
||||||
|
|
||||||
|
a. reproduce and Share the Licensed Material, in whole or
|
||||||
|
in part, for NonCommercial purposes only; and
|
||||||
|
|
||||||
|
b. produce, reproduce, and Share Adapted Material for
|
||||||
|
NonCommercial purposes only.
|
||||||
|
|
||||||
|
2. Exceptions and Limitations. For the avoidance of doubt, where
|
||||||
|
Exceptions and Limitations apply to Your use, this Public
|
||||||
|
License does not apply, and You do not need to comply with
|
||||||
|
its terms and conditions.
|
||||||
|
|
||||||
|
3. Term. The term of this Public License is specified in Section
|
||||||
|
6(a).
|
||||||
|
|
||||||
|
4. Media and formats; technical modifications allowed. The
|
||||||
|
Licensor authorizes You to exercise the Licensed Rights in
|
||||||
|
all media and formats whether now known or hereafter created,
|
||||||
|
and to make technical modifications necessary to do so. The
|
||||||
|
Licensor waives and/or agrees not to assert any right or
|
||||||
|
authority to forbid You from making technical modifications
|
||||||
|
necessary to exercise the Licensed Rights, including
|
||||||
|
technical modifications necessary to circumvent Effective
|
||||||
|
Technological Measures. For purposes of this Public License,
|
||||||
|
simply making modifications authorized by this Section 2(a)
|
||||||
|
(4) never produces Adapted Material.
|
||||||
|
|
||||||
|
5. Downstream recipients.
|
||||||
|
|
||||||
|
a. Offer from the Licensor -- Licensed Material. Every
|
||||||
|
recipient of the Licensed Material automatically
|
||||||
|
receives an offer from the Licensor to exercise the
|
||||||
|
Licensed Rights under the terms and conditions of this
|
||||||
|
Public License.
|
||||||
|
|
||||||
|
b. No downstream restrictions. You may not offer or impose
|
||||||
|
any additional or different terms or conditions on, or
|
||||||
|
apply any Effective Technological Measures to, the
|
||||||
|
Licensed Material if doing so restricts exercise of the
|
||||||
|
Licensed Rights by any recipient of the Licensed
|
||||||
|
Material.
|
||||||
|
|
||||||
|
6. No endorsement. Nothing in this Public License constitutes or
|
||||||
|
may be construed as permission to assert or imply that You
|
||||||
|
are, or that Your use of the Licensed Material is, connected
|
||||||
|
with, or sponsored, endorsed, or granted official status by,
|
||||||
|
the Licensor or others designated to receive attribution as
|
||||||
|
provided in Section 3(a)(1)(A)(i).
|
||||||
|
|
||||||
|
b. Other rights.
|
||||||
|
|
||||||
|
1. Moral rights, such as the right of integrity, are not
|
||||||
|
licensed under this Public License, nor are publicity,
|
||||||
|
privacy, and/or other similar personality rights; however, to
|
||||||
|
the extent possible, the Licensor waives and/or agrees not to
|
||||||
|
assert any such rights held by the Licensor to the limited
|
||||||
|
extent necessary to allow You to exercise the Licensed
|
||||||
|
Rights, but not otherwise.
|
||||||
|
|
||||||
|
2. Patent and trademark rights are not licensed under this
|
||||||
|
Public License.
|
||||||
|
|
||||||
|
3. To the extent possible, the Licensor waives any right to
|
||||||
|
collect royalties from You for the exercise of the Licensed
|
||||||
|
Rights, whether directly or through a collecting society
|
||||||
|
under any voluntary or waivable statutory or compulsory
|
||||||
|
licensing scheme. In all other cases the Licensor expressly
|
||||||
|
reserves any right to collect such royalties, including when
|
||||||
|
the Licensed Material is used other than for NonCommercial
|
||||||
|
purposes.
|
||||||
|
|
||||||
|
|
||||||
|
Section 3 -- License Conditions.
|
||||||
|
|
||||||
|
Your exercise of the Licensed Rights is expressly made subject to the
|
||||||
|
following conditions.
|
||||||
|
|
||||||
|
a. Attribution.
|
||||||
|
|
||||||
|
1. If You Share the Licensed Material (including in modified
|
||||||
|
form), You must:
|
||||||
|
|
||||||
|
a. retain the following if it is supplied by the Licensor
|
||||||
|
with the Licensed Material:
|
||||||
|
|
||||||
|
i. identification of the creator(s) of the Licensed
|
||||||
|
Material and any others designated to receive
|
||||||
|
attribution, in any reasonable manner requested by
|
||||||
|
the Licensor (including by pseudonym if
|
||||||
|
designated);
|
||||||
|
|
||||||
|
ii. a copyright notice;
|
||||||
|
|
||||||
|
iii. a notice that refers to this Public License;
|
||||||
|
|
||||||
|
iv. a notice that refers to the disclaimer of
|
||||||
|
warranties;
|
||||||
|
|
||||||
|
v. a URI or hyperlink to the Licensed Material to the
|
||||||
|
extent reasonably practicable;
|
||||||
|
|
||||||
|
b. indicate if You modified the Licensed Material and
|
||||||
|
retain an indication of any previous modifications; and
|
||||||
|
|
||||||
|
c. indicate the Licensed Material is licensed under this
|
||||||
|
Public License, and include the text of, or the URI or
|
||||||
|
hyperlink to, this Public License.
|
||||||
|
|
||||||
|
2. You may satisfy the conditions in Section 3(a)(1) in any
|
||||||
|
reasonable manner based on the medium, means, and context in
|
||||||
|
which You Share the Licensed Material. For example, it may be
|
||||||
|
reasonable to satisfy the conditions by providing a URI or
|
||||||
|
hyperlink to a resource that includes the required
|
||||||
|
information.
|
||||||
|
|
||||||
|
3. If requested by the Licensor, You must remove any of the
|
||||||
|
information required by Section 3(a)(1)(A) to the extent
|
||||||
|
reasonably practicable.
|
||||||
|
|
||||||
|
4. If You Share Adapted Material You produce, the Adapter's
|
||||||
|
License You apply must not prevent recipients of the Adapted
|
||||||
|
Material from complying with this Public License.
|
||||||
|
|
||||||
|
|
||||||
|
Section 4 -- Sui Generis Database Rights.
|
||||||
|
|
||||||
|
Where the Licensed Rights include Sui Generis Database Rights that
|
||||||
|
apply to Your use of the Licensed Material:
|
||||||
|
|
||||||
|
a. for the avoidance of doubt, Section 2(a)(1) grants You the right
|
||||||
|
to extract, reuse, reproduce, and Share all or a substantial
|
||||||
|
portion of the contents of the database for NonCommercial purposes
|
||||||
|
only;
|
||||||
|
|
||||||
|
b. if You include all or a substantial portion of the database
|
||||||
|
contents in a database in which You have Sui Generis Database
|
||||||
|
Rights, then the database in which You have Sui Generis Database
|
||||||
|
Rights (but not its individual contents) is Adapted Material; and
|
||||||
|
|
||||||
|
c. You must comply with the conditions in Section 3(a) if You Share
|
||||||
|
all or a substantial portion of the contents of the database.
|
||||||
|
|
||||||
|
For the avoidance of doubt, this Section 4 supplements and does not
|
||||||
|
replace Your obligations under this Public License where the Licensed
|
||||||
|
Rights include other Copyright and Similar Rights.
|
||||||
|
|
||||||
|
|
||||||
|
Section 5 -- Disclaimer of Warranties and Limitation of Liability.
|
||||||
|
|
||||||
|
a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE
|
||||||
|
EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS
|
||||||
|
AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF
|
||||||
|
ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS,
|
||||||
|
IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION,
|
||||||
|
WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS,
|
||||||
|
ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT
|
||||||
|
KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT
|
||||||
|
ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU.
|
||||||
|
|
||||||
|
b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE
|
||||||
|
TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION,
|
||||||
|
NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT,
|
||||||
|
INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES,
|
||||||
|
COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR
|
||||||
|
USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN
|
||||||
|
ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR
|
||||||
|
DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR
|
||||||
|
IN PART, THIS LIMITATION MAY NOT APPLY TO YOU.
|
||||||
|
|
||||||
|
c. The disclaimer of warranties and limitation of liability provided
|
||||||
|
above shall be interpreted in a manner that, to the extent
|
||||||
|
possible, most closely approximates an absolute disclaimer and
|
||||||
|
waiver of all liability.
|
||||||
|
|
||||||
|
|
||||||
|
Section 6 -- Term and Termination.
|
||||||
|
|
||||||
|
a. This Public License applies for the term of the Copyright and
|
||||||
|
Similar Rights licensed here. However, if You fail to comply with
|
||||||
|
this Public License, then Your rights under this Public License
|
||||||
|
terminate automatically.
|
||||||
|
|
||||||
|
b. Where Your right to use the Licensed Material has terminated under
|
||||||
|
Section 6(a), it reinstates:
|
||||||
|
|
||||||
|
1. automatically as of the date the violation is cured, provided
|
||||||
|
it is cured within 30 days of Your discovery of the
|
||||||
|
violation; or
|
||||||
|
|
||||||
|
2. upon express reinstatement by the Licensor.
|
||||||
|
|
||||||
|
For the avoidance of doubt, this Section 6(b) does not affect any
|
||||||
|
right the Licensor may have to seek remedies for Your violations
|
||||||
|
of this Public License.
|
||||||
|
|
||||||
|
c. For the avoidance of doubt, the Licensor may also offer the
|
||||||
|
Licensed Material under separate terms or conditions or stop
|
||||||
|
distributing the Licensed Material at any time; however, doing so
|
||||||
|
will not terminate this Public License.
|
||||||
|
|
||||||
|
d. Sections 1, 5, 6, 7, and 8 survive termination of this Public
|
||||||
|
License.
|
||||||
|
|
||||||
|
|
||||||
|
Section 7 -- Other Terms and Conditions.
|
||||||
|
|
||||||
|
a. The Licensor shall not be bound by any additional or different
|
||||||
|
terms or conditions communicated by You unless expressly agreed.
|
||||||
|
|
||||||
|
b. Any arrangements, understandings, or agreements regarding the
|
||||||
|
Licensed Material not stated herein are separate from and
|
||||||
|
independent of the terms and conditions of this Public License.
|
||||||
|
|
||||||
|
|
||||||
|
Section 8 -- Interpretation.
|
||||||
|
|
||||||
|
a. For the avoidance of doubt, this Public License does not, and
|
||||||
|
shall not be interpreted to, reduce, limit, restrict, or impose
|
||||||
|
conditions on any use of the Licensed Material that could lawfully
|
||||||
|
be made without permission under this Public License.
|
||||||
|
|
||||||
|
b. To the extent possible, if any provision of this Public License is
|
||||||
|
deemed unenforceable, it shall be automatically reformed to the
|
||||||
|
minimum extent necessary to make it enforceable. If the provision
|
||||||
|
cannot be reformed, it shall be severed from this Public License
|
||||||
|
without affecting the enforceability of the remaining terms and
|
||||||
|
conditions.
|
||||||
|
|
||||||
|
c. No term or condition of this Public License will be waived and no
|
||||||
|
failure to comply consented to unless expressly agreed to by the
|
||||||
|
Licensor.
|
||||||
|
|
||||||
|
d. Nothing in this Public License constitutes or may be interpreted
|
||||||
|
as a limitation upon, or waiver of, any privileges and immunities
|
||||||
|
that apply to the Licensor or You, including from the legal
|
||||||
|
processes of any jurisdiction or authority.
|
||||||
|
|
||||||
|
=======================================================================
|
||||||
|
|
||||||
|
Creative Commons is not a party to its public
|
||||||
|
licenses. Notwithstanding, Creative Commons may elect to apply one of
|
||||||
|
its public licenses to material it publishes and in those instances
|
||||||
|
will be considered the “Licensor.” The text of the Creative Commons
|
||||||
|
public licenses is dedicated to the public domain under the CC0 Public
|
||||||
|
Domain Dedication. Except for the limited purpose of indicating that
|
||||||
|
material is shared under a Creative Commons public license or as
|
||||||
|
otherwise permitted by the Creative Commons policies published at
|
||||||
|
creativecommons.org/policies, Creative Commons does not authorize the
|
||||||
|
use of the trademark "Creative Commons" or any other trademark or logo
|
||||||
|
of Creative Commons without its prior written consent including,
|
||||||
|
without limitation, in connection with any unauthorized modifications
|
||||||
|
to any of its public licenses or any other arrangements,
|
||||||
|
understandings, or agreements concerning use of licensed material. For
|
||||||
|
the avoidance of doubt, this paragraph does not form part of the
|
||||||
|
public licenses.
|
||||||
|
|
||||||
|
Creative Commons may be contacted at creativecommons.org.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 224 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.1 MiB |
@@ -0,0 +1,9 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
# Build typescript
|
||||||
|
pnpm exec tsc --build
|
||||||
|
|
||||||
|
# Map path aliases from tsconfigs
|
||||||
|
for TS_CONFIG in ./config.tsconfig.json ./src/tsconfig.json ./modules/*/tsconfig.json; do
|
||||||
|
pnpm exec tsc-alias -p "$TS_CONFIG"
|
||||||
|
done
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { ActivityOptions, ActivityType, ColorResolvable } from "discord.js";
|
||||||
|
import { dirname, join } from "path";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
|
||||||
|
/** Quetza project root directory. */
|
||||||
|
const rootDir = dirname(fileURLToPath(import.meta.url));
|
||||||
|
/** Quetza's modules directory. */
|
||||||
|
const modulesDir = join(rootDir, "/modules/");
|
||||||
|
/** External binaries directory. */
|
||||||
|
const binariesDir = join(rootDir, "/bin/");
|
||||||
|
/** Logger files directory. */
|
||||||
|
const loggerDir = join(rootDir, "/log/");
|
||||||
|
|
||||||
|
/** Locations that might be useful in application. */
|
||||||
|
const path = {
|
||||||
|
root: rootDir,
|
||||||
|
modules: modulesDir,
|
||||||
|
binaries: binariesDir,
|
||||||
|
log: loggerDir
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Discord bot token. */
|
||||||
|
const appToken = process.env.DISCORD_TOKEN;
|
||||||
|
/** Discord bot user activity status */
|
||||||
|
const appActivity = { type: ActivityType.Watching, name: "over you" };
|
||||||
|
|
||||||
|
/** Bot application configuration variables. */
|
||||||
|
const application = {
|
||||||
|
token: appToken,
|
||||||
|
activity: appActivity as ActivityOptions
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Colors Quetza uses in responses. */
|
||||||
|
const colors = {
|
||||||
|
default: "#ff6e9b" as ColorResolvable,
|
||||||
|
error: "#b94e59" as ColorResolvable,
|
||||||
|
warning: "#e5925f" as ColorResolvable,
|
||||||
|
info: "#367df6" as ColorResolvable,
|
||||||
|
success: "#83d18e" as ColorResolvable
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Guild where testing occures. */
|
||||||
|
const testGuild = "912401672939139142";
|
||||||
|
|
||||||
|
/** Developing options. */
|
||||||
|
const dev = {
|
||||||
|
guild: testGuild
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Exported bundled configuration object. */
|
||||||
|
const config = {
|
||||||
|
application,
|
||||||
|
colors,
|
||||||
|
path,
|
||||||
|
dev
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": ".",
|
||||||
|
"outDir": "./dist",
|
||||||
|
|
||||||
|
"composite": true
|
||||||
|
},
|
||||||
|
"include": ["config.ts"]
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* InteractionCreate
|
||||||
|
*
|
||||||
|
* Resolves creation of an interaction by users.
|
||||||
|
*
|
||||||
|
* @remark Only {@link ChatInputCommand} currently being resolved.
|
||||||
|
*
|
||||||
|
* @packageDocumentation
|
||||||
|
*/
|
||||||
|
import { Events, Interaction } from "discord.js";
|
||||||
|
|
||||||
|
import Client from "$lib/client.js";
|
||||||
|
import logger from "$lib/logger.js";
|
||||||
|
|
||||||
|
async function execute(client: Client, eventee: [Interaction]): Promise<void> {
|
||||||
|
const [interaction] = eventee;
|
||||||
|
|
||||||
|
if (!interaction.isChatInputCommand()) {
|
||||||
|
logger.notice("Interaction is not a ChatInputCommand.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const command = client.commands.get(interaction.commandName);
|
||||||
|
|
||||||
|
if (!command) {
|
||||||
|
logger.notice("Interaction does not exist.", interaction);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
logger.info("Interaction was created.", interaction);
|
||||||
|
|
||||||
|
await command.execute(client, interaction, command.module.controller);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Interaction has and error occured.", error, interaction);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = Events.InteractionCreate;
|
||||||
|
|
||||||
|
export { execute, name };
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* Ready
|
||||||
|
*
|
||||||
|
* Will execute upon emitting Client's Ready event.
|
||||||
|
*
|
||||||
|
* @remarks Will push slash commands, set activity status,
|
||||||
|
* and log bot's initials.
|
||||||
|
*
|
||||||
|
* @packageDocumentation
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { generateDependencyReport } from "@discordjs/voice";
|
||||||
|
import { Events } from "discord.js";
|
||||||
|
|
||||||
|
import config from "$config.js";
|
||||||
|
import Client from "$lib/client.js";
|
||||||
|
import logger from "$lib/logger.js";
|
||||||
|
|
||||||
|
async function execute(client: Client<true>): Promise<void> {
|
||||||
|
const commandData = client.commands.map((value) => value.data);
|
||||||
|
|
||||||
|
// To not reload bot's global slash commands at every dev build.
|
||||||
|
if (process.env.NODE_ENV === "development") {
|
||||||
|
logger.info(
|
||||||
|
"Development mode: Commands will be pushed to the _test guild_!",
|
||||||
|
client.generateApplicationStatus()
|
||||||
|
);
|
||||||
|
logger.info("Dependency report by '@discordjs/voice'.", {
|
||||||
|
report: generateDependencyReport()
|
||||||
|
});
|
||||||
|
|
||||||
|
const guild = await client.guilds.fetch(config.dev.guild);
|
||||||
|
guild.commands.set(commandData);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV === "production") {
|
||||||
|
logger.info(
|
||||||
|
"Production mode: Commands will be pushed to the _application_!",
|
||||||
|
client.generateApplicationStatus()
|
||||||
|
);
|
||||||
|
|
||||||
|
await client.application.commands.set(commandData);
|
||||||
|
}
|
||||||
|
|
||||||
|
client.user.setActivity(config.application.activity);
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = Events.ClientReady;
|
||||||
|
|
||||||
|
export { execute, name };
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* Core Quetza Module definition.
|
||||||
|
*
|
||||||
|
* This module provides default capabilities.
|
||||||
|
* - Comands are being resolved here at InteractionCreated event.
|
||||||
|
* - Aplication status being generated as well as commands pushed to Discord at Ready event.
|
||||||
|
*
|
||||||
|
* Also this module provides basic commands:
|
||||||
|
* - ping
|
||||||
|
* - modules
|
||||||
|
*
|
||||||
|
* There a lot of speaking because it's as important as $lib.
|
||||||
|
*
|
||||||
|
* @packageDocumentation
|
||||||
|
*/
|
||||||
|
|
||||||
|
const name = "core";
|
||||||
|
const description = "Quetza's core functionality";
|
||||||
|
|
||||||
|
export { description, name };
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"paths": {
|
||||||
|
"$config.js": ["../../config.js"],
|
||||||
|
"$lib/*": ["../../src/lib/*"],
|
||||||
|
"$mlib/*": ["./lib/*"]
|
||||||
|
},
|
||||||
|
|
||||||
|
"rootDir": ".",
|
||||||
|
"outDir": "../../dist/modules/core",
|
||||||
|
|
||||||
|
"composite": true
|
||||||
|
},
|
||||||
|
"references": [{ "path": "../../src/" }]
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* @see ../../../src/lib/error.ts
|
||||||
|
*
|
||||||
|
* @packageDocumentation
|
||||||
|
*/
|
||||||
|
|
||||||
|
import QuetzaError from "$lib/error.js";
|
||||||
|
|
||||||
|
export default class MusicError extends QuetzaError {
|
||||||
|
public static readonly PREFIX = "MZ";
|
||||||
|
|
||||||
|
constructor(msg: string, code: number) {
|
||||||
|
super(msg, code, MusicError.PREFIX);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { DlpDump, dlpExtractorKey, DlpInfo } from "$mlib/types.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Avaliable extractors.
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
export const dlpExtractorsValues = ["Youtube", "Soundcloud"] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines structure for an extractor that will be called by dlpInfo.
|
||||||
|
*
|
||||||
|
* For example:
|
||||||
|
*
|
||||||
|
* @see the {@link youtubeExtractor | youtubeExtractor(...) } function
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
type dlpExtractor = (arg0: DlpDump, arg1: (arg0: string) => Promise<DlpInfo>) => Promise<DlpInfo>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves data that was extracted by dlp (depending on extractor_key).
|
||||||
|
*
|
||||||
|
* @returns DlpInfo generated from dump provided
|
||||||
|
*
|
||||||
|
* @param dump - Parsed dlp output
|
||||||
|
* @param dlpInfo - Function to call if any other information needed
|
||||||
|
* (will recurcively call next extractor after its run)
|
||||||
|
*
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
const youtubeExtractor: dlpExtractor = async (dump, dlpInfo) => {
|
||||||
|
if (dump.id && dump.title && dump.duration) {
|
||||||
|
const result = {
|
||||||
|
url: `https://www.youtube.com/watch?v=${dump.id}`,
|
||||||
|
title: dump.title,
|
||||||
|
thumbnail: `https://img.youtube.com/vi/${dump.id}/default.jpg`,
|
||||||
|
duration: dump.duration
|
||||||
|
};
|
||||||
|
|
||||||
|
return [result];
|
||||||
|
}
|
||||||
|
|
||||||
|
return await dlpInfo(dump.original_url);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritdoc youtubeExtractor}
|
||||||
|
*/
|
||||||
|
const soundcloudExtractor: dlpExtractor = async (dump, dlpInfo) => {
|
||||||
|
if (dump.title && dump.duration && dump.thumbnail) {
|
||||||
|
const result = {
|
||||||
|
url: dump.webpage_url ?? dump.original_url,
|
||||||
|
title: dump.title,
|
||||||
|
thumbnail: dump.thumbnail,
|
||||||
|
duration: dump.duration
|
||||||
|
};
|
||||||
|
|
||||||
|
return [result];
|
||||||
|
}
|
||||||
|
|
||||||
|
return await dlpInfo(dump.original_url);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DlpDump extractor_key values mapped to corresponding extractor.
|
||||||
|
*
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
const extractors: Record<dlpExtractorKey, dlpExtractor> = {
|
||||||
|
Youtube: youtubeExtractor,
|
||||||
|
Soundcloud: soundcloudExtractor
|
||||||
|
};
|
||||||
|
|
||||||
|
export default extractors;
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import { execFile } from "child_process";
|
||||||
|
import path from "path";
|
||||||
|
import prismMedia from "prism-media";
|
||||||
|
import { Readable } from "stream";
|
||||||
|
import { promisify } from "util";
|
||||||
|
|
||||||
|
import config from "$config.js";
|
||||||
|
import logger from "$lib/logger.js";
|
||||||
|
import MusicError from "$mlib/MusicError.js";
|
||||||
|
|
||||||
|
import { DlpDump, DlpInfo, dlpStreamOptions } from "../types.js";
|
||||||
|
import extractors from "./extractors.js";
|
||||||
|
|
||||||
|
const { FFmpeg } = prismMedia;
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum length of dlp search stack.
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
const DLPINFO_DEPTH = 3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Path to the Dlp binary.
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
const DLP_BINARY = path.join(config.path.binaries, "/yt-dlp");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base arguments for Ffmpeg transcoder.
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
const FFMPEG_BASE_ARGS = [
|
||||||
|
"-analyzeduration",
|
||||||
|
"0",
|
||||||
|
"-loglevel",
|
||||||
|
"0",
|
||||||
|
"-acodec",
|
||||||
|
"libopus",
|
||||||
|
"-f",
|
||||||
|
"opus",
|
||||||
|
"-ar",
|
||||||
|
"48000",
|
||||||
|
"-ac",
|
||||||
|
"2"
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Predicate to check whether is string a valid URL.
|
||||||
|
*
|
||||||
|
* @returns true if given string is a valid URL, false otherwise
|
||||||
|
*
|
||||||
|
* @param str - Any string
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
function isURL(str: string): boolean {
|
||||||
|
return /^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)$/.test(
|
||||||
|
str
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sub-function of dlpInfo.
|
||||||
|
*
|
||||||
|
* @tutorial Shall be used only inside original function
|
||||||
|
*
|
||||||
|
* @returns Info of the media that related to query, and ones that cannot be found
|
||||||
|
*
|
||||||
|
* @param query - Dlp search request or compatible URL
|
||||||
|
* @param depth - Recursion depth (force stops at 0)
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
async function dlpInfoRecursive(query: string, depth = DLPINFO_DEPTH): Promise<DlpInfo> {
|
||||||
|
const error = (message: string, reason: number, ...meta: unknown[]): DlpInfo => {
|
||||||
|
logger.warn(message, { reason, query, ...meta });
|
||||||
|
throw new MusicError(message, reason);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!depth) {
|
||||||
|
return error("Dlp: recursion depth reached", 1010);
|
||||||
|
}
|
||||||
|
|
||||||
|
const subprocess = await execFileAsync(DLP_BINARY, [
|
||||||
|
query,
|
||||||
|
"-O",
|
||||||
|
"%(.{id,original_url,title,uploader,duration,extractor_key,thumbnail})j",
|
||||||
|
"-s",
|
||||||
|
"--flat-playlist"
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!subprocess.stdout) {
|
||||||
|
return error("Dlp: no stdout", 1011);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subprocess.stderr) {
|
||||||
|
return error("Dlp: stderr", 1012, {
|
||||||
|
stderr: subprocess.stderr
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const dumps: DlpDump[] = subprocess.stdout
|
||||||
|
.trim()
|
||||||
|
.split("\n")
|
||||||
|
.map((out) => JSON.parse(out));
|
||||||
|
|
||||||
|
const resolveDump = async (dump: DlpDump): Promise<DlpInfo> => {
|
||||||
|
if (!Object.hasOwn(extractors, dump.extractor_key)) {
|
||||||
|
error("Dlp: illegal extractor", 1013);
|
||||||
|
}
|
||||||
|
|
||||||
|
return extractors[dump.extractor_key](dump, (subquery: string) =>
|
||||||
|
dlpInfoRecursive(subquery, depth - 1)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await Promise.all(dumps.map(resolveDump));
|
||||||
|
|
||||||
|
return result.flat();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Leverages dlp to fetch metadata of media accessible by the extractors.
|
||||||
|
*
|
||||||
|
* @returns Metadata to a youtube video that related to query by title or URL of findings
|
||||||
|
*
|
||||||
|
* @param query - Dlp search request or compatible URL
|
||||||
|
*
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
export async function dlpInfo(query: string): Promise<DlpInfo> {
|
||||||
|
return await dlpInfoRecursive(isURL(query) ? query : "ytsearch1:" + query);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates Opus stream by downloading media via dlp.
|
||||||
|
*
|
||||||
|
* @returns Readable stream of requested media
|
||||||
|
*
|
||||||
|
* @param url - Dlp compatible URL to the requested resource
|
||||||
|
* @param options - Transcoders and dlp options
|
||||||
|
*
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
export function dlpStream(url: string, options?: dlpStreamOptions): Promise<Readable> {
|
||||||
|
let ffmpegArgs = FFMPEG_BASE_ARGS;
|
||||||
|
|
||||||
|
if (options?.seek) {
|
||||||
|
ffmpegArgs.unshift("-ss", options.seek.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.ffmpeg) {
|
||||||
|
ffmpegArgs = ffmpegArgs.concat(options.ffmpeg);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise<Readable>((resolve, reject) => {
|
||||||
|
const subprocess = execFile(
|
||||||
|
DLP_BINARY,
|
||||||
|
[
|
||||||
|
url,
|
||||||
|
"-o",
|
||||||
|
"-",
|
||||||
|
"--verbose",
|
||||||
|
"-f",
|
||||||
|
"bestaudio[ext=webm][acodec=opus][asr=48000]/bestaudio",
|
||||||
|
"--rm-cache-dir",
|
||||||
|
"-r",
|
||||||
|
"1M"
|
||||||
|
],
|
||||||
|
{ encoding: null, maxBuffer: 25000000 }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!subprocess.stdout || !subprocess.stderr) {
|
||||||
|
throw new Error("No stdout");
|
||||||
|
}
|
||||||
|
|
||||||
|
const ffmpegTranscoder = new FFmpeg({ args: ffmpegArgs });
|
||||||
|
|
||||||
|
const stream = subprocess.stdout.pipe(ffmpegTranscoder);
|
||||||
|
|
||||||
|
const onError = (err: Error) => {
|
||||||
|
if (!subprocess.killed) {
|
||||||
|
subprocess.kill();
|
||||||
|
}
|
||||||
|
reject(err);
|
||||||
|
stream.resume();
|
||||||
|
};
|
||||||
|
|
||||||
|
subprocess
|
||||||
|
.once("spawn", () => {
|
||||||
|
resolve(stream);
|
||||||
|
})
|
||||||
|
.on("error", onError);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
/**
|
||||||
|
* Filter entry.
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
type filterValue = {
|
||||||
|
/** Public well formatted name of the filter. */
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
/** FFmpeg argument of this filter. */
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record mapping id of filter with its {@link filterValue}.
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
const FILTERS: Record<string, filterValue> = {
|
||||||
|
bassboost1x: { name: "Bassboost 1x", value: "bass=g=15:f=110:w=0.3" },
|
||||||
|
bassboost2x: { name: "Bassboost 2x", value: "bass=g=30:f=110:w=0.3" },
|
||||||
|
daycore: { name: "Daycore", value: "aresample=48000,asetrate=48000*0.75" },
|
||||||
|
nightcore: { name: "Nightcore", value: "aresample=48000,asetrate=48000*1.25" },
|
||||||
|
treble: {
|
||||||
|
name: "Treble Boost",
|
||||||
|
value: "firequalizer=gain_entry='entry(2500,3);entry(6300,6);entry(16000,10)'"
|
||||||
|
}
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type build of {@link FILTERS} keys.
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
type filterPreset = keyof typeof FILTERS;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link ./player#Player | Player's } filters options.
|
||||||
|
*
|
||||||
|
* Resolves FFMpeg transcoder arguments that tweak playback stream.
|
||||||
|
*
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
export default class Filter {
|
||||||
|
/**
|
||||||
|
* Filter list
|
||||||
|
*/
|
||||||
|
public static readonly FILTERS = FILTERS;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Active filters. Mock of {@link FILTERS} object.
|
||||||
|
*/
|
||||||
|
private switches_ = new Map<filterPreset, filterValue>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts active {@link filterValue | filterValues} to array.
|
||||||
|
*
|
||||||
|
* @returns Array of active filter values.
|
||||||
|
*/
|
||||||
|
public toggled(): filterValue[] {
|
||||||
|
return [...this.switches_.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggles filter specified. Clears them all if no arguments provided.
|
||||||
|
*
|
||||||
|
* @returns True if filter was toggled, False otherwise.
|
||||||
|
*
|
||||||
|
* @param query - filter id to toggle. Undefined to clear all.
|
||||||
|
*/
|
||||||
|
public toggle(query?: filterPreset): boolean {
|
||||||
|
if (!query) {
|
||||||
|
this.switches_.clear();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.switches_.delete(query)) {
|
||||||
|
this.switches_.set(query, FILTERS[query]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates of active filter map.
|
||||||
|
*
|
||||||
|
* @returns Size of active filter map.
|
||||||
|
*/
|
||||||
|
public size(): number {
|
||||||
|
return this.switches_.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if there are any active filter.
|
||||||
|
*
|
||||||
|
* @returns size === 0 ? True : False.
|
||||||
|
*/
|
||||||
|
public empty(): boolean {
|
||||||
|
return this.size() === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts active filters to FFmpeg compatable argument.
|
||||||
|
*
|
||||||
|
* @returns FFmpeg compatable argument representing active filters.
|
||||||
|
*/
|
||||||
|
public toString(): string {
|
||||||
|
return this.toggled()
|
||||||
|
.map((value) => value.value)
|
||||||
|
.join(",");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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];
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { User } from "discord.js";
|
||||||
|
|
||||||
|
import { dlpExtractorsValues } from "./fetch/extractors.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track information fetched by Dlp.
|
||||||
|
*
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
export interface TrackBase {
|
||||||
|
/** URL of track. */
|
||||||
|
url: string;
|
||||||
|
|
||||||
|
/** Track title. */
|
||||||
|
title: string;
|
||||||
|
|
||||||
|
/** Link to track thumbnail. */
|
||||||
|
thumbnail: string;
|
||||||
|
|
||||||
|
/** Track duration in seconds. */
|
||||||
|
duration: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track information with {@link User} that requested it.
|
||||||
|
*
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
export interface Track extends TrackBase {
|
||||||
|
/** User requested this track. */
|
||||||
|
requester: User;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Player loop options.
|
||||||
|
*
|
||||||
|
* @remarks LOOP - Looping queue;
|
||||||
|
* SONG - Looping single song;
|
||||||
|
* NONE - Stops after queue end; (default)
|
||||||
|
* AUTO - Random order;
|
||||||
|
*
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
export type LoopOption = "LOOP" | "SONG" | "NONE" | "AUTO";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dlp fetched track list.
|
||||||
|
*
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
export type DlpInfo = TrackBase[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dlp supported extractors as types.
|
||||||
|
*
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
export type dlpExtractorKey = (typeof dlpExtractorsValues)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Actual Dlp dump that later translates to {@link DlpInfo}.
|
||||||
|
*
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
export interface DlpDump {
|
||||||
|
/** Id of resource. */
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
/** URL passed to Dlp or link to resource found. */
|
||||||
|
original_url: string;
|
||||||
|
|
||||||
|
/** Link to webpage of resource requested. */
|
||||||
|
webpage_url?: string;
|
||||||
|
|
||||||
|
/** Resource title. */
|
||||||
|
title?: string;
|
||||||
|
|
||||||
|
/** Resource duration in seconds. */
|
||||||
|
duration?: number;
|
||||||
|
|
||||||
|
/** Link to resource thumbnail. */
|
||||||
|
thumbnail?: string;
|
||||||
|
|
||||||
|
/** Resource's extractor identifier. */
|
||||||
|
extractor_key: dlpExtractorKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options that shall be passed to DlpStream function.
|
||||||
|
*
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
export interface dlpStreamOptions {
|
||||||
|
/** Amount of seconds to start playback from. */
|
||||||
|
seek?: number;
|
||||||
|
|
||||||
|
/** Args for FFMPEG transcoder. */
|
||||||
|
ffmpeg?: string[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* Music module definition.
|
||||||
|
*
|
||||||
|
* Module to play media content from various sites.
|
||||||
|
* @see ./lib/fetch/extractors.ts for site extractors.
|
||||||
|
*
|
||||||
|
* @packageDocumentation
|
||||||
|
*/
|
||||||
|
import Music from "./lib/music.js";
|
||||||
|
|
||||||
|
const name = "music";
|
||||||
|
const description = "Music module";
|
||||||
|
|
||||||
|
const controller = new Music();
|
||||||
|
|
||||||
|
export { controller, description, name };
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"paths": {
|
||||||
|
"$config.js": ["../../config.js"],
|
||||||
|
"$lib/*": ["../../src/lib/*"],
|
||||||
|
"$mlib/*": ["./lib/*"]
|
||||||
|
},
|
||||||
|
|
||||||
|
"rootDir": ".",
|
||||||
|
"outDir": "../../dist/modules/music",
|
||||||
|
|
||||||
|
"composite": true
|
||||||
|
},
|
||||||
|
"references": [{ "path": "../../src/" }]
|
||||||
|
}
|
||||||
Generated
-2402
File diff suppressed because it is too large.
Load diff
+39
-31
@@ -1,46 +1,54 @@
|
|||||||
{
|
{
|
||||||
"name": "quetza-bot",
|
"name": "quetza",
|
||||||
"engines": {
|
"version": "2024.3.0",
|
||||||
"node": "18.x.x",
|
"description": "Awesome Discord bot that performs various duties",
|
||||||
"npm": "*"
|
|
||||||
},
|
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "2302.1.0",
|
"main": "./dist/src/index.js",
|
||||||
"description": "Discord multifunctional bot",
|
"scripts": {
|
||||||
"author": {
|
"start": "node ./dist/src/index.js",
|
||||||
"name": "unknowableshade",
|
"build": "./build.sh",
|
||||||
"email": "60275123+unknowableshade@users.noreply.github.com",
|
"clean": "rm -rf ./dist",
|
||||||
"url": "https://unknowableshade.ru"
|
"lint": "pnpm exec eslint . && pnpm exec prettier --check .",
|
||||||
|
"format": "pnpm exec eslint --fix . && pnpm exec prettier --write .",
|
||||||
|
"preinstall": "pnpm dlx only-allow pnpm"
|
||||||
},
|
},
|
||||||
|
"license": "MIT",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "git+https://github.com/unknowableshade/quetza-bot.git"
|
"url": "git+https://github.com/unknowableshade/Quetza.git"
|
||||||
|
},
|
||||||
|
"author": {
|
||||||
|
"name": "Vladimir Eremin",
|
||||||
|
"email": "personal@ermnvldmr.com",
|
||||||
|
"url": "https://ermnvldmr.com"
|
||||||
},
|
},
|
||||||
"bugs": {
|
"bugs": {
|
||||||
"url": "https://github.com/unknowableshade/quetza-bot/issues"
|
"url": "https://github.com/unknowableshade/Quetza/issues"
|
||||||
},
|
},
|
||||||
"readme": "https://github.com/unknowableshade/quetza-bot#readme",
|
"readme": "https://github.com/unknowableshade/Quetza#readme",
|
||||||
"license": "MIT",
|
"engines": {
|
||||||
"main": "dist/index.js",
|
"node": "20.x.x",
|
||||||
"scripts": {
|
"npm": "*"
|
||||||
"poststart": "npm run build",
|
|
||||||
"start": "node dist/index.js",
|
|
||||||
"build": "tsc --build",
|
|
||||||
"clean": "tsc --build --clean"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@discordjs/opus": "^0.8.0",
|
"@discordjs/opus": "^0.9.0",
|
||||||
"@discordjs/voice": "^0.11.0",
|
"@discordjs/voice": "^0.16.1",
|
||||||
"discord-ytdl-core": "^5.0.4",
|
"discord.js": "^14.14.1",
|
||||||
"discord.js": "^14.3.0",
|
"prism-media": "^1.3.5",
|
||||||
"tweetnacl": "^1.0.3",
|
"tweetnacl": "^1.0.3",
|
||||||
"ytdl-core": "^4.11.1"
|
"winston": "^3.11.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^18.7.13",
|
"@types/node": "^20.11.24",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.35.1",
|
"@typescript-eslint/eslint-plugin": "^7.1.0",
|
||||||
"@typescript-eslint/parser": "^5.35.1",
|
"@typescript-eslint/parser": "^7.1.0",
|
||||||
"eslint": "^8.22.0",
|
"eslint": "^8.57.0",
|
||||||
"typescript": "^4.8.2"
|
"eslint-config-prettier": "^9.1.0",
|
||||||
|
"eslint-plugin-import": "^2.29.1",
|
||||||
|
"eslint-plugin-simple-import-sort": "^12.0.0",
|
||||||
|
"mocha": "^10.3.0",
|
||||||
|
"prettier": "^3.2.5",
|
||||||
|
"tsc-alias": "^1.8.8",
|
||||||
|
"typescript": "^5.3.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Generated
+3749
File diff suppressed because it is too large.
Load diff
@@ -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 };
|
|
||||||
@@ -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 };
|
|
||||||
@@ -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 };
|
|
||||||
@@ -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 };
|
|
||||||
@@ -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 };
|
|
||||||
@@ -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 };
|
|
||||||
@@ -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 };
|
|
||||||
@@ -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 };
|
|
||||||
@@ -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 };
|
|
||||||
@@ -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 };
|
|
||||||
@@ -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 };
|
|
||||||
@@ -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 };
|
|
||||||
@@ -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 };
|
|
||||||
@@ -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 };
|
|
||||||
@@ -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 };
|
|
||||||
@@ -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 };
|
|
||||||
@@ -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 };
|
|
||||||
@@ -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;
|
|
||||||
@@ -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";
|
|
||||||
@@ -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";
|
|
||||||
@@ -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
@@ -1,6 +1,19 @@
|
|||||||
import Client from "./lib/Client.js";
|
import { GatewayIntentBits } from "discord.js";
|
||||||
import config from "./config.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);
|
||||||
@@ -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)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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}**_`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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];
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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[];
|
|
||||||
};
|
|
||||||
@@ -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)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"extends": "../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"paths": {
|
||||||
|
"$config.js": ["../config.js"],
|
||||||
|
"$lib/*": ["./lib/*"]
|
||||||
|
},
|
||||||
|
|
||||||
|
"rootDir": ".",
|
||||||
|
"outDir": "../dist/src",
|
||||||
|
|
||||||
|
"composite": true
|
||||||
|
},
|
||||||
|
"references": [{ "path": "../config.tsconfig.json" }]
|
||||||
|
}
|
||||||
+13
-11
@@ -2,18 +2,20 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
|
||||||
"module": "ESNext",
|
"module": "NodeNext",
|
||||||
"moduleResolution": "node",
|
"moduleResolution": "NodeNext",
|
||||||
"outDir": "dist",
|
"resolveJsonModule": true,
|
||||||
"removeComments": false,
|
|
||||||
"sourceMap": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
|
|
||||||
"target": "ESNext",
|
|
||||||
"lib": ["ESNext"],
|
|
||||||
"types": ["node"],
|
"types": ["node"],
|
||||||
|
|
||||||
|
"outDir": "./dist",
|
||||||
|
"sourceMap": true,
|
||||||
|
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
|
||||||
|
"target": "ES2022",
|
||||||
|
|
||||||
"skipLibCheck": true
|
"skipLibCheck": true
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"],
|
"references": [{ "path": "./modules/core" }, { "path": "./modules/music" }]
|
||||||
"exclude": ["node_modules", "dist"]
|
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user