From 4982c0942d59e70a9ca0faacd95206fd2e18a897 Mon Sep 17 00:00:00 2001 From: topi314 Date: Mon, 8 Jul 2024 02:21:50 +0200 Subject: [PATCH] rework --- .dockerignore | 4 +- .github/workflows/docker.yml | 67 ++++++ .gitignore | 2 +- cmd/backend/Dockerfile | 2 +- cmd/bot/Dockerfile | 17 +- cmd/bot/bot.go | 41 ++-- config.example.toml | 27 +++ go.mod | 35 +-- go.sum | 92 +++++--- interal/config/config.go | 54 ----- interal/database/database.go | 73 ------ interal/database/histories.go | 48 ---- interal/database/players.go | 66 ------ interal/database/queues.go | 67 ------ interal/database/schema.sql | 62 ------ interal/database/track.go | 13 -- internal/config/config.go | 18 ++ internal/log/log.go | 72 ++++++ service/bot/bot.go | 176 +++++++-------- service/bot/commands/commands.go | 89 ++++++++ service/bot/{handlers => commands}/history.go | 14 +- .../bot/{handlers => commands}/liked_songs.go | 81 ++++--- service/bot/commands/middlewares.go | 18 ++ service/bot/{handlers => commands}/ping.go | 4 +- service/bot/commands/play.go | 203 +++++++++++++++++ service/bot/{handlers => commands}/player.go | 188 ++++++++-------- .../bot/{handlers => commands}/playlists.go | 169 +++++++------- service/bot/commands/queue.go | 175 +++++++++++++++ service/bot/config.go | 75 +++++-- service/bot/db/db.go | 99 +++++++++ service/bot/db/lavalink.go | 24 ++ .../bot/db}/liked_tracks.go | 4 +- .../bot/db}/play_histories.go | 4 +- .../database => service/bot/db}/playlists.go | 4 +- service/bot/handlers.go | 46 ++++ service/bot/handlers/commands.go | 89 -------- service/bot/handlers/middlewares.go | 24 -- service/bot/handlers/play.go | 207 ------------------ service/bot/handlers/queue.go | 170 -------------- service/bot/lavalink.go | 51 ----- service/bot/res/track.go | 2 +- service/bot/sql/migration.sql | 0 service/bot/sql/schema.sql | 27 +++ 43 files changed, 1373 insertions(+), 1330 deletions(-) create mode 100644 .github/workflows/docker.yml create mode 100644 config.example.toml delete mode 100644 interal/config/config.go delete mode 100644 interal/database/database.go delete mode 100644 interal/database/histories.go delete mode 100644 interal/database/players.go delete mode 100644 interal/database/queues.go delete mode 100644 interal/database/schema.sql delete mode 100644 interal/database/track.go create mode 100644 internal/config/config.go create mode 100644 internal/log/log.go create mode 100644 service/bot/commands/commands.go rename service/bot/{handlers => commands}/history.go (66%) rename service/bot/{handlers => commands}/liked_songs.go (59%) create mode 100644 service/bot/commands/middlewares.go rename service/bot/{handlers => commands}/ping.go (71%) create mode 100644 service/bot/commands/play.go rename service/bot/{handlers => commands}/player.go (56%) rename service/bot/{handlers => commands}/playlists.go (63%) create mode 100644 service/bot/commands/queue.go create mode 100644 service/bot/db/db.go create mode 100644 service/bot/db/lavalink.go rename {interal/database => service/bot/db}/liked_tracks.go (96%) rename {interal/database => service/bot/db}/play_histories.go (95%) rename {interal/database => service/bot/db}/playlists.go (97%) create mode 100644 service/bot/handlers.go delete mode 100644 service/bot/handlers/commands.go delete mode 100644 service/bot/handlers/middlewares.go delete mode 100644 service/bot/handlers/play.go delete mode 100644 service/bot/handlers/queue.go delete mode 100644 service/bot/lavalink.go create mode 100644 service/bot/sql/migration.sql create mode 100644 service/bot/sql/schema.sql diff --git a/.dockerignore b/.dockerignore index eebf6dd..952ad2f 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,3 +1,3 @@ .github/ -config.json -example.config.json +config.toml +example.config.toml diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..c90e99b --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,67 @@ +name: Build and Deploy + +on: [ push ] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Docker meta + id: meta + uses: docker/metadata-action@v4 + with: + images: | + ghcr.io/${{ github.repository }} + tags: | + type=ref,event=branch + type=ref,event=tag + type=ref,event=pr + type=sha,prefix= + + - name: Set up QEMU + uses: docker/setup-qemu-action@v2 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v2 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Prepare Version Info + run: | + echo "VERSION=$(git describe --tags)" >> $GITHUB_ENV + echo "COMMIT=$(git rev-parse HEAD)" >> $GITHUB_ENV + + - name: Build and push + uses: docker/build-push-action@v3 + with: + context: . + platforms: linux/amd64,linux/arm/v7,linux/arm64/v8 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + build-args: | + VERSION=${{ env.VERSION }} + COMMIT=${{ env.COMMIT }} + + deploy: + needs: + - build + runs-on: ubuntu-latest + steps: + - name: Deploy + uses: fjogeleit/http-request-action@v1 + with: + url: "https://watchtower.topi.wtf/v1/update?image=ghcr.io/kittybotgo/kittybot" + method: "POST" + timeout: 60000 + customHeaders: '{"Authorization": "Bearer ${{ secrets.TOKEN }}"}' diff --git a/.gitignore b/.gitignore index d5265c4..95bdbbc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ .idea/ -config.json +config.toml go.work go.work.sum diff --git a/cmd/backend/Dockerfile b/cmd/backend/Dockerfile index 1c1a36a..22cc0e0 100644 --- a/cmd/backend/Dockerfile +++ b/cmd/backend/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.20-alpine AS build +FROM golang:1.22-alpine AS build WORKDIR /build diff --git a/cmd/bot/Dockerfile b/cmd/bot/Dockerfile index 895c447..c70c2b9 100644 --- a/cmd/bot/Dockerfile +++ b/cmd/bot/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.20-alpine AS build +FROM --platform=$BUILDPLATFORM golang:1.22-alpine AS build WORKDIR /build @@ -8,7 +8,18 @@ RUN go mod download COPY . . -RUN CGO_ENABLED=0 go build -o bot cmd/bot/bot.go +ARG TARGETOS +ARG TARGETARCH +ARG VERSION +ARG COMMIT + +RUN --mount=type=cache,target=/root/.cache/go-build \ + --mount=type=cache,target=/go/pkg \ + CGO_ENABLED=0 \ + GOOS=$TARGETOS \ + GOARCH=$TARGETARCH \ + go build -ldflags="-X 'main.Version=$VERSION' -X 'main.Commit=$COMMIT'" -o bot github.com/KittyBot-Org/KittyBotGo/cmd/bot + FROM alpine @@ -16,4 +27,4 @@ COPY --from=build /build/bot /bin/bot ENTRYPOINT ["/bin/bot"] -CMD ["-config", "/var/lib/bot/config.json"] +CMD ["-config", "/var/lib/bot/config.yoml"] diff --git a/cmd/bot/bot.go b/cmd/bot/bot.go index 5f82dd7..b7c1ab1 100644 --- a/cmd/bot/bot.go +++ b/cmd/bot/bot.go @@ -2,45 +2,54 @@ package main import ( "flag" + "log/slog" "os" "os/signal" "syscall" - "github.com/disgoorg/log" + "github.com/topi314/tint" - "github.com/KittyBot-Org/KittyBotGo/interal/config" + "github.com/KittyBot-Org/KittyBotGo/internal/config" + "github.com/KittyBot-Org/KittyBotGo/internal/log" "github.com/KittyBot-Org/KittyBotGo/service/bot" - "github.com/KittyBot-Org/KittyBotGo/service/bot/handlers" + "github.com/KittyBot-Org/KittyBotGo/service/bot/commands" +) + +var ( + Version = "dev" + Commit = "unknown" ) func main() { - cfgPath := flag.String("config", "config.json", "path to config.json") + cfgPath := flag.String("config", "config.toml", "path to config file") flag.Parse() - logger := log.New(log.Ldate | log.Ltime | log.Lshortfile) - logger.Infof("Bot is starting... (config path:%s)", *cfgPath) + slog.Info("Bot is starting...", slog.String("config", *cfgPath)) var cfg bot.Config if err := config.Load(*cfgPath, &cfg); err != nil { - logger.Fatalf("Failed to load config: %v", err) + slog.Error("failed to load config", tint.Err(err)) + return } - logger.SetLevel(config.ParseLogLevel(cfg.LogLevel)) - b, err := bot.New(logger, *cfgPath, cfg) + slog.Info("Config loaded", slog.String("config", cfg.String())) + log.Setup(cfg.Log) + + b, err := bot.New(cfg, Version, Commit) if err != nil { - logger.Fatalf("Failed to create bot: %v", err) + slog.Error("Failed to create bot: %v", err) } defer b.Close() - h := handlers.New(b) - b.Discord.AddEventListeners(h) + b.Discord.AddEventListeners(commands.New(b)) - if err = b.Start(h.Commands); err != nil { - logger.Fatalf("Failed to start bot: %v", err) + if err = b.Start(commands.Commands); err != nil { + slog.Error("Failed to start bot: %v", err) + return } - logger.Info("Bot is running. Press CTRL-C to exit.") + slog.Info("Bot is running. Press CTRL-C to exit.") s := make(chan os.Signal, 1) - signal.Notify(s, syscall.SIGINT, syscall.SIGTERM, os.Interrupt) + signal.Notify(s, syscall.SIGINT, syscall.SIGTERM) <-s } diff --git a/config.example.toml b/config.example.toml new file mode 100644 index 0000000..2a2111e --- /dev/null +++ b/config.example.toml @@ -0,0 +1,27 @@ +[log] +level = 'info' +format = 'text' +add_source = true +no_color = false + +[bot] +dev_mode = false +sync_commands = true +guild_ids = [123456789] +# gateway_url = 'ws://localhost:7878' +# rest_url = 'http://lcaolhost:7979/api/v10' +token = '...' + +[database] +host = 'localhost' +port = 5432 +username = 'kittybotgo' +password = '...' +database = 'kittybotgo' +ssl_mode = 'disable' + +[[nodes]] +name = 'node1' +address = 'localhost:2333' +password = '...' +secure = false diff --git a/go.mod b/go.mod index 8b5250f..fbe0b86 100644 --- a/go.mod +++ b/go.mod @@ -1,24 +1,31 @@ module github.com/KittyBot-Org/KittyBotGo -go 1.20 +go 1.22 require ( - github.com/disgoorg/disgo v0.15.2 - github.com/disgoorg/disgolink/v2 v2.0.1-0.20230310234351-c994a1155237 - github.com/disgoorg/json v1.0.0 - github.com/disgoorg/log v1.2.0 + github.com/disgoorg/disgo v0.18.8 + github.com/disgoorg/disgolink/v3 v3.0.1-0.20240708001940-d7bce1c07287 + github.com/disgoorg/json v1.1.0 + github.com/disgoorg/lavaqueue-plugin v0.0.0-20240708001834-dafe3f63f5a0 + github.com/disgoorg/log v1.2.1 github.com/disgoorg/snowflake/v2 v2.0.1 - github.com/jackc/pgx/v5 v5.3.1 - github.com/jmoiron/sqlx v1.3.5 - golang.org/x/exp v0.0.0-20230310171629-522b1b587ee0 + github.com/jackc/pgx/v5 v5.6.0 + github.com/jmoiron/sqlx v1.4.0 + github.com/mattn/go-colorable v0.1.13 + github.com/pelletier/go-toml/v2 v2.2.2 + github.com/topi314/tint v0.0.0-20240303212505-44dd4a1b4f7f + go.gopad.dev/fuzzysearch v0.0.0-20240526153819-c12185e04fe2 ) require ( - github.com/gorilla/websocket v1.5.0 // indirect + github.com/gorilla/websocket v1.5.3 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect - github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect - github.com/sasha-s/go-csync v0.0.0-20210812194225-61421b77c44b // indirect - golang.org/x/crypto v0.7.0 // indirect - golang.org/x/sys v0.6.0 // indirect - golang.org/x/text v0.8.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.1 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/sasha-s/go-csync v0.0.0-20240107134140-fcbab37b09ad // indirect + golang.org/x/crypto v0.25.0 // indirect + golang.org/x/sync v0.7.0 // indirect + golang.org/x/sys v0.22.0 // indirect + golang.org/x/text v0.16.0 // indirect ) diff --git a/go.sum b/go.sum index 6439cef..e768042 100644 --- a/go.sum +++ b/go.sum @@ -1,47 +1,75 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/disgoorg/disgo v0.15.2 h1:cmdMCHBL17GgkYOJl6d2Btc3MbQiIdzwF6D8lwdJ7L8= -github.com/disgoorg/disgo v0.15.2/go.mod h1:hUkznOmm+f+owh/MuOBX0sDviQV5cL0FqcWbpIyV1Y0= -github.com/disgoorg/disgolink/v2 v2.0.1-0.20230310234351-c994a1155237 h1:YpKuYdjR5Zo0sOKWSo9/KYxuoSlyarB8FwUylCk/ruw= -github.com/disgoorg/disgolink/v2 v2.0.1-0.20230310234351-c994a1155237/go.mod h1:7qhsIFqN2udhazah1m7z4YarQ5h6iRIkUvotIcZkFhE= -github.com/disgoorg/json v1.0.0 h1:kDhSM661fgIuNoZF3BO5/odaR5NSq80AWb937DH+Pdo= -github.com/disgoorg/json v1.0.0/go.mod h1:BHDwdde0rpQFDVsRLKhma6Y7fTbQKub/zdGO5O9NqqA= -github.com/disgoorg/log v1.2.0 h1:sqlXnu/ZKAlIlHV9IO+dbMto7/hCQ474vlIdMWk8QKo= -github.com/disgoorg/log v1.2.0/go.mod h1:3x1KDG6DI1CE2pDwi3qlwT3wlXpeHW/5rVay+1qDqOo= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/disgoorg/disgo v0.18.8 h1:qysxgI5jY+v8crQ6oWIe316CmX761jjDfhuL0RVf0FU= +github.com/disgoorg/disgo v0.18.8/go.mod h1:gkl6DBdbKUvmOOJayWPSvS52KPN/8uJGJ2f13gCEB1o= +github.com/disgoorg/disgolink/v3 v3.0.1-0.20240708001940-d7bce1c07287 h1:8/7I8a3CKlnxUKRhlpdS3H5jMQhXPI+0GJXBFGQ4u/A= +github.com/disgoorg/disgolink/v3 v3.0.1-0.20240708001940-d7bce1c07287/go.mod h1:YIwjIteZcjfI7HYZWH241iRI7RjTLoN51HLDOUHVSFI= +github.com/disgoorg/json v1.1.0 h1:7xigHvomlVA9PQw9bMGO02PHGJJPqvX5AnwlYg/Tnys= +github.com/disgoorg/json v1.1.0/go.mod h1:BHDwdde0rpQFDVsRLKhma6Y7fTbQKub/zdGO5O9NqqA= +github.com/disgoorg/lavaqueue-plugin v0.0.0-20240708001834-dafe3f63f5a0 h1:ZvHEx0WER8DWyJewm+mKMRi3omJAbpzIbWCLAyaiZ8Y= +github.com/disgoorg/lavaqueue-plugin v0.0.0-20240708001834-dafe3f63f5a0/go.mod h1:pvlIraap6FdqFCOUEeeadAIGnapApELXXM/+z1rxD6c= +github.com/disgoorg/log v1.2.1 h1:kZYAWkUBcGy4LbZcgYtgYu49xNVLy+xG5Uq3yz5VVQs= +github.com/disgoorg/log v1.2.1/go.mod h1:hhQWYTFTnIGzAuFPZyXJEi11IBm9wq+/TVZt/FEwX0o= github.com/disgoorg/snowflake/v2 v2.0.1 h1:CuUxGLwggUxEswZOmZ+mZ5i0xSumQdXW9tXW7uGqe+0= github.com/disgoorg/snowflake/v2 v2.0.1/go.mod h1:SPU9c2CNn5DSyb86QcKtdZgix9osEtKrHLW4rMhfLCs= -github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE= -github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= -github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= -github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= -github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= -github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgx/v5 v5.3.1 h1:Fcr8QJ1ZeLi5zsPZqQeUZhNhxfkkKBOgJuYkJHoBOtU= -github.com/jackc/pgx/v5 v5.3.1/go.mod h1:t3JDKnCBlYIc0ewLF0Q7B8MXmoIaBOZj/ic7iHozM/8= -github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g= -github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ= -github.com/lib/pq v1.2.0 h1:LXpIM/LZ5xGFhOpXAQUIMM1HdyqzVYM13zNdjCEEcA0= -github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= -github.com/mattn/go-sqlite3 v1.14.6 h1:dNPt6NO46WmLVt2DLNpwczCmdV5boIZ6g/tlDrlRUbg= -github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY= +github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw= +github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= +github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= +github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= +github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/sasha-s/go-csync v0.0.0-20210812194225-61421b77c44b h1:qYTY2tN72LhgDj2rtWG+LI6TXFl2ygFQQ4YezfVaGQE= -github.com/sasha-s/go-csync v0.0.0-20210812194225-61421b77c44b/go.mod h1:/pA7k3zsXKdjjAiUhB5CjuKib9KJGCaLvZwtxGC8U0s= +github.com/sasha-s/go-csync v0.0.0-20240107134140-fcbab37b09ad h1:qIQkSlF5vAUHxEmTbaqt1hkJ/t6skqEGYiMag343ucI= +github.com/sasha-s/go-csync v0.0.0-20240107134140-fcbab37b09ad/go.mod h1:/pA7k3zsXKdjjAiUhB5CjuKib9KJGCaLvZwtxGC8U0s= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= -golang.org/x/crypto v0.7.0 h1:AvwMYaRytfdeVt3u6mLaxYtErKYjxA2OXjJ1HHq6t3A= -golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= -golang.org/x/exp v0.0.0-20230310171629-522b1b587ee0 h1:LGJsf5LRplCck6jUCH3dBL2dmycNruWNF5xugkSlfXw= -golang.org/x/exp v0.0.0-20230310171629-522b1b587ee0/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= -golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/topi314/tint v0.0.0-20240303212505-44dd4a1b4f7f h1:UcEiP9p/5CaDOIq7vnRxBrfFgxCsfNd+SqHIiGCUkW8= +github.com/topi314/tint v0.0.0-20240303212505-44dd4a1b4f7f/go.mod h1:1NIyBIBWnL8n9bts9NoV3/QQUjCsbu7j3xOnpOf0t8o= +go.gopad.dev/fuzzysearch v0.0.0-20240526153819-c12185e04fe2 h1:TJfF4DCI0kn8rOfKkp8r11YRW4I+fr1iMdof9EJ1oaI= +go.gopad.dev/fuzzysearch v0.0.0-20240526153819-c12185e04fe2/go.mod h1:MSXvJXowkplateQlS2yCqIZhHzEkKsJTSORMBdRFSGM= +golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30= +golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M= +golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/text v0.8.0 h1:57P1ETyNKtuIjB4SRd15iJxuhj8Gc416Y78H3qgMh68= -golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= +golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/interal/config/config.go b/interal/config/config.go deleted file mode 100644 index fbad951..0000000 --- a/interal/config/config.go +++ /dev/null @@ -1,54 +0,0 @@ -package config - -import ( - "fmt" - "os" - "strings" - - "github.com/disgoorg/json" - "github.com/disgoorg/log" -) - -func ParseLogLevel(level string) log.Level { - logLevel := log.LevelInfo - switch strings.ToLower(level) { - case "trace": - logLevel = log.LevelTrace - case "debug": - logLevel = log.LevelDebug - case "info": - logLevel = log.LevelInfo - case "warn": - logLevel = log.LevelWarn - case "error": - logLevel = log.LevelError - case "fatal": - logLevel = log.LevelFatal - case "panic": - logLevel = log.LevelPanic - } - - return logLevel -} - -func Load(path string, cfg any) error { - file, err := os.Open(path) - if err != nil { - return fmt.Errorf("failed to open config file: %w", err) - } - defer file.Close() - - return json.NewDecoder(file).Decode(cfg) -} - -func Save(path string, cfg any) error { - file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0666) - if err != nil { - return fmt.Errorf("failed to create config file: %w", err) - } - defer file.Close() - - encoder := json.NewEncoder(file) - encoder.SetIndent("", "\t") - return encoder.Encode(cfg) -} diff --git a/interal/database/database.go b/interal/database/database.go deleted file mode 100644 index 32b2a96..0000000 --- a/interal/database/database.go +++ /dev/null @@ -1,73 +0,0 @@ -package database - -import ( - "context" - _ "embed" - "fmt" - "strings" - - "github.com/disgoorg/snowflake/v2" - _ "github.com/jackc/pgx/v5/stdlib" - "github.com/jmoiron/sqlx" -) - -//go:embed schema.sql -var schema string - -type Config struct { - Host string `json:"host"` - Port int `json:"port"` - Username string `json:"username"` - Password string `json:"password"` - Database string `json:"database"` - SSLMode string `json:"ssl_mode"` -} - -func (c Config) String() string { - return fmt.Sprintf("Host: %s,\n Port: %d,\n Username: %s,\n Password: %s,\n Database: %s,\n SSLMode: %s", c.Host, c.Port, c.Username, strings.Repeat("*", len(c.Password)), c.Database, c.SSLMode) -} - -func New(ctx context.Context, cfg Config) (*DB, error) { - dbx, err := sqlx.ConnectContext(ctx, "pgx", fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=%s", cfg.Host, cfg.Port, cfg.Username, cfg.Password, cfg.Database, cfg.SSLMode)) - if err != nil { - return nil, err - } - - // execute schema - if _, err = dbx.ExecContext(ctx, schema); err != nil { - return nil, err - } - - return &DB{ - dbx: dbx, - }, nil -} - -type DB struct { - dbx *sqlx.DB -} - -func (d *DB) Close() error { - return d.dbx.Close() -} - -type PlayTrack struct { - ID int `db:"id"` - Type string `db:"type"` - Name string `db:"name"` -} - -func (d *DB) SearchPlay(userID snowflake.ID, query string, limit int) ([]PlayTrack, error) { - var tracks []PlayTrack - if err := d.dbx.Select(&tracks, `SELECT * FROM( - SELECT id, 'liked_track' as type, track -> 'info' ->> 'title' as name FROM liked_tracks WHERE user_id = $1 - UNION ALL - SELECT id, 'playlist' as type, name FROM playlists WHERE user_id = $1 - UNION ALL - SELECT id, 'play_history' as type, track -> 'info' ->> 'title' as name FROM play_histories WHERE user_id = $1 - ) t ORDER BY name <->> $2 ASC LIMIT $3;`, userID, query, limit); err != nil { - return nil, err - } - - return tracks, nil -} diff --git a/interal/database/histories.go b/interal/database/histories.go deleted file mode 100644 index 82e13df..0000000 --- a/interal/database/histories.go +++ /dev/null @@ -1,48 +0,0 @@ -package database - -import ( - "github.com/disgoorg/disgolink/v2/lavalink" - "github.com/disgoorg/snowflake/v2" -) - -func (d *DB) GetHistory(guildID snowflake.ID) ([]Track, error) { - var history []Track - if err := d.dbx.Select(&history, "SELECT * FROM histories WHERE guild_id = $1 ORDER BY position DESC", guildID); err != nil { - return nil, err - } - - return history, nil -} - -func (d *DB) AddHistoryTracks(guildID snowflake.ID, tracks []lavalink.Track) error { - dbTracks := make([]Track, len(tracks)) - for i, track := range tracks { - dbTracks[i] = Track{ - GuildID: guildID, - Track: track, - } - } - - _, err := d.dbx.NamedExec("INSERT INTO histories (guild_id, track) VALUES (:guild_id, :track)", dbTracks) - return err -} - -func (d *DB) PreviousHistoryTrack(guildID snowflake.ID) (*Track, error) { - var track Track - err := d.dbx.Get(&track, "DELETE FROM histories WHERE position = (SELECT MAX(position) from histories WHERE guild_id = $1) RETURNING *", guildID) - if err != nil { - return nil, err - } - - return &track, nil -} - -func (d *DB) RemoveHistoryTrack(trackID int) error { - _, err := d.dbx.Exec("DELETE FROM histories WHERE id = $1", trackID) - return err -} - -func (d *DB) ClearHistory(guildID snowflake.ID) error { - _, err := d.dbx.Exec("DELETE FROM histories WHERE guild_id = $1", guildID) - return err -} diff --git a/interal/database/players.go b/interal/database/players.go deleted file mode 100644 index c2dd293..0000000 --- a/interal/database/players.go +++ /dev/null @@ -1,66 +0,0 @@ -package database - -import ( - "database/sql" - "errors" - - "github.com/disgoorg/snowflake/v2" -) - -type QueueType int - -const ( - QueueTypeNormal QueueType = iota - QueueTypeRepeatTrack - QueueTypeRepeatQueue -) - -func (q QueueType) String() string { - switch q { - case QueueTypeNormal: - return "Normal" - case QueueTypeRepeatTrack: - return "Repeat Track" - case QueueTypeRepeatQueue: - return "Repeat Queue" - } - return "Unknown" -} - -type Player struct { - GuildID snowflake.ID `db:"guild_id"` - Node string `db:"node"` - QueueType QueueType `db:"queue_type"` -} - -func (d *DB) HasPlayer(guildID snowflake.ID) (bool, error) { - var count int - err := d.dbx.Get(&count, "SELECT COUNT(*) FROM players WHERE guild_id = $1", guildID) - if err != nil { - return false, err - } - return count > 0, nil -} - -func (d *DB) GetPlayer(guildID snowflake.ID, node string) (*Player, error) { - var player Player - err := d.dbx.Get(&player, "SELECT * FROM players WHERE guild_id = $1", guildID) - if errors.Is(err, sql.ErrNoRows) { - _, err = d.dbx.Exec("INSERT INTO players (guild_id, node, queue_type) VALUES ($1, $2, $3)", guildID, node, QueueTypeNormal) - } - if err != nil { - return nil, err - } - - return &player, err -} - -func (d *DB) UpdatePlayer(player Player) error { - _, err := d.dbx.NamedExec("INSERT INTO players (guild_id, node, queue_type) VALUES (:guild_id, :node, :queue_type) ON CONFLICT (guild_id) DO UPDATE SET node = :node, queue_type = :queue_type", player) - return err -} - -func (d *DB) DeletePlayer(guildID snowflake.ID) error { - _, err := d.dbx.Exec("DELETE FROM players WHERE guild_id = $1", guildID) - return err -} diff --git a/interal/database/queues.go b/interal/database/queues.go deleted file mode 100644 index e6f910d..0000000 --- a/interal/database/queues.go +++ /dev/null @@ -1,67 +0,0 @@ -package database - -import ( - "github.com/disgoorg/disgolink/v2/lavalink" - "github.com/disgoorg/snowflake/v2" -) - -func (d *DB) GetQueue(guildID snowflake.ID) ([]Track, error) { - var queue []Track - if err := d.dbx.Select(&queue, "SELECT * FROM queues WHERE guild_id = $1 ORDER BY position ASC", guildID); err != nil { - return nil, err - } - - return queue, nil -} - -func (d *DB) SearchQueue(guildID snowflake.ID, query string, limit int) ([]Track, error) { - var queue []Track - if err := d.dbx.Select(&queue, "SELECT * FROM queues WHERE guild_id = $1 ORDER BY track -> 'info' ->> 'title' <->> $2 ASC LIMIT $3", guildID, query, limit); err != nil { - return nil, err - } - - return queue, nil -} - -func (d *DB) AddQueueTracks(guildID snowflake.ID, tracks []lavalink.Track) error { - dbTracks := make([]Track, len(tracks)) - for i, track := range tracks { - dbTracks[i] = Track{ - GuildID: guildID, - Track: track, - } - } - - _, err := d.dbx.NamedExec("INSERT INTO queues (guild_id, track) VALUES (:guild_id, :track)", dbTracks) - return err -} - -func (d *DB) NextQueueTrack(guildID snowflake.ID) (*Track, error) { - var track Track - err := d.dbx.Get(&track, "DELETE FROM queues WHERE position = (SELECT MIN(position) from queues WHERE guild_id = $1) RETURNING *", guildID) - if err != nil { - return nil, err - } - - return &track, nil -} - -func (d *DB) RemoveQueueTrack(trackID int) error { - _, err := d.dbx.Exec("DELETE FROM queues WHERE id = $1", trackID) - return err -} - -func (d *DB) ClearQueue(guildID snowflake.ID) error { - _, err := d.dbx.Exec("DELETE FROM queues WHERE guild_id = $1", guildID) - return err -} - -func (d *DB) ShuffleQueue(guildID snowflake.ID) error { - var queueSize int - err := d.dbx.Get(&queueSize, "SELECT COUNT(*) FROM queues WHERE guild_id = $1", guildID) - if err != nil { - return err - } - _, err = d.dbx.Exec("UPDATE queues SET position = floor(random() * $1) + 1 WHERE guild_id = $2", queueSize, guildID) - return err -} diff --git a/interal/database/schema.sql b/interal/database/schema.sql deleted file mode 100644 index 7add2cf..0000000 --- a/interal/database/schema.sql +++ /dev/null @@ -1,62 +0,0 @@ -CREATE TABLE IF NOT EXISTS players -( - guild_id bigint NOT NULL, - node varchar NOT NULL, - queue_type int NOT NULL, - CONSTRAINT players_pkey PRIMARY KEY (guild_id) -); - -CREATE TABLE IF NOT EXISTS queues -( - id bigserial NOT NULL, - guild_id bigint NOT NULL REFERENCES players (guild_id) ON DELETE CASCADE, - position bigserial NOT NULL, - track json NOT NULL, - CONSTRAINT queues_pkey PRIMARY KEY (id) -); - -CREATE TABLE IF NOT EXISTS histories -( - id bigserial NOT NULL, - guild_id bigint NOT NULL REFERENCES players (guild_id) ON DELETE CASCADE, - position bigserial NOT NULL, - track json NOT NULL, - CONSTRAINT histories_pkey PRIMARY KEY (id) -); - -CREATE TABLE IF NOT EXISTS playlists -( - id bigserial NOT NULL, - user_id bigint NOT NULL, - name varchar NOT NULL, - CONSTRAINT playlists_pkey PRIMARY KEY (id) -); - -CREATE TABLE IF NOT EXISTS playlist_tracks -( - id bigserial NOT NULL, - playlist_id bigint NOT NULL REFERENCES playlists (id) ON DELETE CASCADE, - position bigserial NOT NULL, - track json NOT NULL, - CONSTRAINT playlist_tracks_pkey PRIMARY KEY (id) -); - -CREATE TABLE IF NOT EXISTS liked_tracks -( - id bigserial NOT NULL, - user_id bigint NOT NULL, - track json NOT NULL, - CONSTRAINT liked_tracks_pkey PRIMARY KEY (id) -); - -CREATE TABLE IF NOT EXISTS play_histories -( - id bigserial NOT NULL, - user_id bigint NOT NULL, - played_at timestamp NOT NULL, - track jsonb NOT NULL, - CONSTRAINT play_histories_pkey PRIMARY KEY (id), - CONSTRAINT play_histories_user_id_track_key UNIQUE (user_id, track) -); - -CREATE EXTENSION IF NOT EXISTS pg_trgm; diff --git a/interal/database/track.go b/interal/database/track.go deleted file mode 100644 index 5d515da..0000000 --- a/interal/database/track.go +++ /dev/null @@ -1,13 +0,0 @@ -package database - -import ( - "github.com/disgoorg/disgolink/v2/lavalink" - "github.com/disgoorg/snowflake/v2" -) - -type Track struct { - ID int `db:"id"` - GuildID snowflake.ID `db:"guild_id"` - Position int `db:"position"` - Track lavalink.Track `db:"track"` -} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..93e776f --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,18 @@ +package config + +import ( + "fmt" + "os" + + "github.com/pelletier/go-toml/v2" +) + +func Load(path string, cfg any) error { + file, err := os.Open(path) + if err != nil { + return fmt.Errorf("failed to open config file: %w", err) + } + defer file.Close() + + return toml.NewDecoder(file).Decode(cfg) +} diff --git a/internal/log/log.go b/internal/log/log.go new file mode 100644 index 0000000..3be718b --- /dev/null +++ b/internal/log/log.go @@ -0,0 +1,72 @@ +package log + +import ( + "log/slog" + "os" + + "github.com/mattn/go-colorable" + "github.com/topi314/tint" +) + +type Config struct { + Level slog.Level `toml:"level"` + Format string `toml:"format"` + AddSource bool `toml:"add_source"` + NoColor bool `toml:"no_color"` +} + +const ( + ansiFaint = "\033[2m" + ansiWhiteBold = "\033[37;1m" + ansiYellowBold = "\033[33;1m" + ansiCyanBold = "\033[36;1m" + ansiCyanBoldFaint = "\033[36;1;2m" + ansiRedFaint = "\033[31;2m" + ansiRedBold = "\033[31;1m" + + ansiRed = "\033[31m" + ansiYellow = "\033[33m" + ansiGreen = "\033[32m" + ansiMagenta = "\033[35m" +) + +func Setup(cfg Config) { + var h slog.Handler + switch cfg.Format { + case "json": + h = slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ + AddSource: cfg.AddSource, + Level: cfg.Level, + }) + + case "text": + h = tint.NewHandler(colorable.NewColorable(os.Stdout), &tint.Options{ + AddSource: cfg.AddSource, + Level: cfg.Level, + NoColor: cfg.NoColor, + LevelColors: map[slog.Level]string{ + slog.LevelDebug: ansiMagenta, + slog.LevelInfo: ansiGreen, + slog.LevelWarn: ansiYellow, + slog.LevelError: ansiRed, + }, + Colors: map[tint.Kind]string{ + tint.KindTime: ansiYellowBold, + tint.KindSourceFile: ansiCyanBold, + tint.KindSourceSeparator: ansiCyanBoldFaint, + tint.KindSourceLine: ansiCyanBold, + tint.KindMessage: ansiWhiteBold, + tint.KindKey: ansiFaint, + tint.KindSeparator: ansiFaint, + tint.KindValue: ansiWhiteBold, + tint.KindErrorKey: ansiRedFaint, + tint.KindErrorSeparator: ansiFaint, + tint.KindErrorValue: ansiRedBold, + }, + }) + default: + slog.Error("Unknown log format", slog.String("format", cfg.Format)) + os.Exit(-1) + } + slog.SetDefault(slog.New(h)) +} diff --git a/service/bot/bot.go b/service/bot/bot.go index 3fffef0..ce1fa64 100644 --- a/service/bot/bot.go +++ b/service/bot/bot.go @@ -2,7 +2,10 @@ package bot import ( "context" + _ "embed" "fmt" + "log/slog" + "slices" "sync" "time" @@ -10,40 +13,55 @@ import ( "github.com/disgoorg/disgo/bot" "github.com/disgoorg/disgo/cache" "github.com/disgoorg/disgo/discord" - "github.com/disgoorg/disgo/events" "github.com/disgoorg/disgo/gateway" + "github.com/disgoorg/disgo/handler" + "github.com/disgoorg/disgo/rest" "github.com/disgoorg/disgo/sharding" - "github.com/disgoorg/disgolink/v2/disgolink" - "github.com/disgoorg/disgolink/v2/lavalink" - "github.com/disgoorg/json" - "github.com/disgoorg/log" + "github.com/disgoorg/disgolink/v3/disgolink" + "github.com/topi314/tint" - "github.com/KittyBot-Org/KittyBotGo/interal/config" - "github.com/KittyBot-Org/KittyBotGo/interal/database" + "github.com/KittyBot-Org/KittyBotGo/service/bot/db" ) -func New(logger log.Logger, cfgPath string, cfg Config) (*Bot, error) { +//go:embed sql/schema.sql +var schema string + +func New(cfg Config, version string, commit string) (*Bot, error) { b := &Bot{ - CfgPath: cfgPath, Config: cfg, - Logger: logger, + Version: version, + Commit: commit, } - dc, err := disgo.New(cfg.Token, - bot.WithLogger(logger), - bot.WithShardManagerConfigOpts( - sharding.WithGatewayConfigOpts( - gateway.WithURL(cfg.GatewayURL), + gatewayConfigOpts := []gateway.ConfigOpt{ + gateway.WithIntents(gateway.IntentGuilds, gateway.IntentGuildVoiceStates), + } + shardManagerConfigOpts := []sharding.ConfigOpt{ + sharding.WithGatewayConfigOpts(gatewayConfigOpts...), + } + if cfg.Bot.GatewayURL != "" { + shardManagerConfigOpts = []sharding.ConfigOpt{ + sharding.WithGatewayConfigOpts(append(gatewayConfigOpts, + gateway.WithURL(cfg.Bot.GatewayURL), gateway.WithCompress(false), - ), + )...), sharding.WithRateLimiter(sharding.NewNoopRateLimiter()), - ), - //bot.WithRestClientConfigOpts( - // rest.WithURL(cfg.RestURL), - // rest.WithRateLimiter(rest.NewNoopRateLimiter()), - //), + } + } + + var restClientConfigOpts []rest.ConfigOpt + if cfg.Bot.RestURL != "" { + restClientConfigOpts = []rest.ConfigOpt{ + rest.WithURL(cfg.Bot.RestURL), + rest.WithRateLimiter(rest.NewNoopRateLimiter()), + } + } + + d, err := disgo.New(cfg.Bot.Token, + bot.WithShardManagerConfigOpts(shardManagerConfigOpts...), + bot.WithRestClientConfigOpts(restClientConfigOpts...), bot.WithCacheConfigOpts( - cache.WithCaches(cache.FlagGuilds, cache.FlagMembers, cache.FlagVoiceStates), + cache.WithCaches(cache.FlagGuilds, cache.FlagVoiceStates), ), bot.WithEventListenerFunc(b.OnDiscordEvent), ) @@ -51,109 +69,93 @@ func New(logger log.Logger, cfgPath string, cfg Config) (*Bot, error) { return nil, fmt.Errorf("failed to create discord client: %w", err) } - ll := disgolink.New(dc.ApplicationID(), - disgolink.WithLogger(logger), + lavalink := disgolink.New(d.ApplicationID(), disgolink.WithListenerFunc(b.OnLavalinkEvent), ) - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - db, err := database.New(ctx, cfg.Database) + database, err := db.New(cfg.Database, schema) if err != nil { return nil, fmt.Errorf("failed to create database: %w", err) } - b.Discord = dc - b.Database = db - b.Lavalink = ll + b.Discord = d + b.Database = database + b.Lavalink = lavalink return b, nil } type Bot struct { - CfgPath string Config Config - Logger log.Logger + Version string + Commit string Discord bot.Client Lavalink disgolink.Client - Database *database.DB + Database *db.DB } func (b *Bot) Start(commands []discord.ApplicationCommandCreate) error { - if b.Config.SyncCommands { - if b.Config.DevMode { - b.Logger.Info("starting in dev mode") - for _, guildID := range b.Config.GuildIDs { - if _, err := b.Discord.Rest().SetGuildCommands(b.Discord.ApplicationID(), guildID, commands); err != nil { - return fmt.Errorf("failed to update guild handlers: %w", err) - } - } - } else { - if _, err := b.Discord.Rest().SetGlobalCommands(b.Discord.ApplicationID(), commands); err != nil { - return fmt.Errorf("failed to update global handlers: %w", err) - } + if b.Config.Bot.SyncCommands { + if err := handler.SyncCommands(b.Discord, commands, b.Config.Bot.GuildIDs); err != nil { + slog.Error("failed to sync commands", tint.Err(err)) } } + b.ConnectLavalinkNodes() + + return b.Discord.OpenShardManager(context.Background()) +} + +func (b *Bot) ConnectLavalinkNodes() { + nodes, err := b.Database.GetLavalinkNodes(context.Background()) + if err != nil { + slog.Error("failed to get lavalink node session ids", tint.Err(err)) + } + var wg sync.WaitGroup for i := range b.Config.Nodes { wg.Add(1) cfg := b.Config.Nodes[i] go func() { defer wg.Done() - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - node, err := b.Lavalink.AddNode(ctx, cfg) - if err != nil { - b.Logger.Error("failed to add node:", err) - return + + nodeIndex := slices.IndexFunc(nodes, func(node db.LavalinkNode) bool { + return node.Name == cfg.Name + }) + + var sessionID string + if nodeIndex > -1 { + sessionID = nodes[nodeIndex].SessionID } - if err = node.Update(context.Background(), lavalink.SessionUpdate{ - Resuming: json.Ptr(true), - Timeout: json.Ptr(180), - }); err != nil { - b.Logger.Error("failed to update node:", err) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if _, err = b.Lavalink.AddNode(ctx, cfg.ToLavalink(sessionID)); err != nil { + slog.Error("failed to add node", tint.Err(err)) } + slog.Info("connected to lavalink node", slog.String("name", cfg.Name)) }() } wg.Wait() - - return b.Discord.OpenShardManager(context.Background()) -} - -func (b *Bot) OnDiscordEvent(event bot.Event) { - switch e := event.(type) { - case *events.VoiceServerUpdate: - b.Logger.Debug("received voice server update") - if e.Endpoint == nil { - return - } - b.Lavalink.OnVoiceServerUpdate(context.Background(), e.GuildID, e.Token, *e.Endpoint) - case *events.GuildVoiceStateUpdate: - if e.VoiceState.UserID != b.Discord.ApplicationID() { - return - } - b.Logger.Debug("received voice state update") - b.Lavalink.OnVoiceStateUpdate(context.Background(), e.VoiceState.GuildID, e.VoiceState.ChannelID, e.VoiceState.SessionID) - case *events.GuildsReady: - b.Logger.Debug("received guilds ready") - b.RestorePlayers() - } } func (b *Bot) Close() { - b.Lavalink.ForNodes(func(node disgolink.Node) { - for i, cfgNode := range b.Config.Nodes { - if node.Config().Name == cfgNode.Name { - b.Config.Nodes[i].SessionID = node.SessionID() + if b.Lavalink != nil { + var nodes []db.LavalinkNode + b.Lavalink.ForNodes(func(node disgolink.Node) { + nodes = append(nodes, db.LavalinkNode{ + Name: node.Config().Name, + SessionID: node.SessionID(), + }) + }) + + if len(nodes) > 0 { + if err := b.Database.AddLavalinkNodes(context.Background(), nodes); err != nil { + slog.Error("failed to set lavalink node session ids", tint.Err(err)) } } - }) - - if err := config.Save(b.CfgPath, b.Config); err != nil { - b.Logger.Error("failed to save config:", err) + b.Lavalink.Close() } - b.Lavalink.Close() + b.Discord.Close(context.Background()) _ = b.Database.Close() } diff --git a/service/bot/commands/commands.go b/service/bot/commands/commands.go new file mode 100644 index 0000000..cf5f2b9 --- /dev/null +++ b/service/bot/commands/commands.go @@ -0,0 +1,89 @@ +package commands + +import ( + "github.com/disgoorg/disgo/discord" + "github.com/disgoorg/disgo/handler" + "github.com/disgoorg/disgo/handler/middleware" + + "github.com/KittyBot-Org/KittyBotGo/service/bot" +) + +var Commands = []discord.ApplicationCommandCreate{ + pingCommand, + playerCommand, + queueCommand, + historyCommand, + playlistsCommand, + likedSongsCommand, +} + +type commands struct { + *bot.Bot +} + +func New(b *bot.Bot) handler.Router { + cmds := &commands{b} + + router := handler.New() + router.Use(middleware.Go) + router.SlashCommand("/ping", cmds.OnPing) + router.Route("/player", func(r handler.Router) { + r.SlashCommand("/play", cmds.OnPlayerPlay) + r.Autocomplete("/play", cmds.OnPlayerPlayAutocomplete) + r.Group(func(r handler.Router) { + r.Use(cmds.OnHasPlayer) + r.SlashCommand("/status", cmds.OnPlayerStatus) + r.SlashCommand("/pause", cmds.OnPlayerPause) + r.SlashCommand("/resume", cmds.OnPlayerResume) + r.SlashCommand("/stop", cmds.OnPlayerStop) + r.SlashCommand("/next", cmds.OnPlayerNext) + r.SlashCommand("/previous", cmds.OnPlayerPrevious) + r.SlashCommand("/volume", cmds.OnPlayerVolume) + r.SlashCommand("/bass-boost", cmds.OnPlayerBassBoost) + r.SlashCommand("/seek", cmds.OnPlayerSeek) + + r.Component("/previous", cmds.OnPlayerPreviousButton) + r.Component("/pause_play", cmds.OnPlayerPlayPauseButton) + r.Component("/next", cmds.OnPlayerNextButton) + r.Component("/stop", cmds.OnPlayerStopButton) + }) + }) + router.Route("/queue", func(r handler.Router) { + r.Use(cmds.OnHasPlayer) + r.SlashCommand("/clear", cmds.OnQueueClear) + r.SlashCommand("/remove", cmds.OnQueueRemove) + r.Autocomplete("/remove", cmds.OnQueueAutocomplete) + r.SlashCommand("/shuffle", cmds.OnQueueShuffle) + r.SlashCommand("/show", cmds.OnQueueShow) + r.SlashCommand("/type", cmds.OnQueueType) + }) + router.Route("/history", func(r handler.Router) { + r.Use(cmds.OnHasPlayer) + r.SlashCommand("/clear", cmds.OnHistoryClear) + r.SlashCommand("/show", cmds.OnHistoryShow) + }) + router.Route("/playlists", func(r handler.Router) { + r.SlashCommand("/list", cmds.OnPlaylistsList) + r.SlashCommand("/show", cmds.OnPlaylistShow) + r.Autocomplete("/show", cmds.OnPlaylistAutocomplete) + r.SlashCommand("/play", cmds.OnPlaylistPlay) + r.Autocomplete("/play", cmds.OnPlaylistAutocomplete) + r.SlashCommand("/create", cmds.OnPlaylistCreate) + r.SlashCommand("/delete", cmds.OnPlaylistDelete) + r.Autocomplete("/delete", cmds.OnPlaylistAutocomplete) + r.SlashCommand("/add", cmds.OnPlaylistAdd) + r.Autocomplete("/add", cmds.OnPlaylistAutocomplete) + r.SlashCommand("/remove", cmds.OnPlaylistRemove) + r.Autocomplete("/remove", cmds.OnPlaylistRemoveAutocomplete) + }) + router.Route("/liked-songs", func(r handler.Router) { + r.SlashCommand("/show", cmds.OnLikedSongsShow) + // r.SlashCommand("/add", cmds.OnLikedSongsAdd) + r.SlashCommand("/remove", cmds.OnLikedSongsRemove) + r.Autocomplete("/remove", cmds.OnLikedSongsAutocomplete) + r.SlashCommand("/clear", cmds.OnLikedSongsClear) + r.Component("/add", cmds.OnLikedSongsAddButton) + }) + + return router +} diff --git a/service/bot/handlers/history.go b/service/bot/commands/history.go similarity index 66% rename from service/bot/handlers/history.go rename to service/bot/commands/history.go index fecaff1..dcb6087 100644 --- a/service/bot/handlers/history.go +++ b/service/bot/commands/history.go @@ -1,10 +1,11 @@ -package handlers +package commands import ( "fmt" "github.com/disgoorg/disgo/discord" "github.com/disgoorg/disgo/handler" + "github.com/disgoorg/lavaqueue-plugin" "github.com/KittyBot-Org/KittyBotGo/service/bot/res" ) @@ -24,17 +25,16 @@ var historyCommand = discord.SlashCommandCreate{ }, } -func (h *Handlers) OnHistoryClear(e *handler.CommandEvent) error { - err := h.Database.ClearHistory(*e.GuildID()) - if err != nil { +func (c *commands) OnHistoryClear(_ discord.SlashCommandInteractionData, e *handler.CommandEvent) error { + if err := lavaqueue.ClearHistory(e.Ctx, c.Lavalink.Player(*e.GuildID()).Node(), *e.GuildID()); err != nil { return e.CreateMessage(res.CreateErr("Failed to clear history", err)) } return e.CreateMessage(res.Createf("Cleared history")) } -func (h *Handlers) OnHistoryShow(e *handler.CommandEvent) error { - tracks, err := h.Database.GetHistory(*e.GuildID()) +func (c *commands) OnHistoryShow(_ discord.SlashCommandInteractionData, e *handler.CommandEvent) error { + tracks, err := lavaqueue.GetHistory(e.Ctx, c.Lavalink.Player(*e.GuildID()).Node(), *e.GuildID()) if err != nil { return e.CreateMessage(res.CreateErr("Failed to get history", err)) } @@ -45,7 +45,7 @@ func (h *Handlers) OnHistoryShow(e *handler.CommandEvent) error { content := fmt.Sprintf("History(`%d`):\n", len(tracks)) for i, track := range tracks { - line := fmt.Sprintf("%d. %s\n", i+1, res.FormatTrack(track.Track, 0)) + line := fmt.Sprintf("%d. %s\n", i+1, res.FormatTrack(track, 0)) if len([]rune(content))+len([]rune(line)) > 2000 { break } diff --git a/service/bot/handlers/liked_songs.go b/service/bot/commands/liked_songs.go similarity index 59% rename from service/bot/handlers/liked_songs.go rename to service/bot/commands/liked_songs.go index 6af5005..88c6d17 100644 --- a/service/bot/handlers/liked_songs.go +++ b/service/bot/commands/liked_songs.go @@ -1,14 +1,15 @@ -package handlers +package commands import ( "context" "database/sql" + "errors" "fmt" "regexp" "github.com/disgoorg/disgo/discord" "github.com/disgoorg/disgo/handler" - "github.com/disgoorg/disgolink/v2/lavalink" + "github.com/disgoorg/disgolink/v3/lavalink" "github.com/KittyBot-Org/KittyBotGo/service/bot/res" ) @@ -62,7 +63,7 @@ func findTrackURL(content string) string { return allMatches[0][trackRegex.SubexpIndex("url")] } -func (h *Handlers) OnLikedSongsAddButton(e *handler.ComponentEvent) error { +func (c *commands) OnLikedSongsAddButton(e *handler.ComponentEvent) error { url := findTrackURL(e.Message.Content) if url == "" { for _, embed := range e.Message.Embeds { @@ -76,32 +77,52 @@ func (h *Handlers) OnLikedSongsAddButton(e *handler.ComponentEvent) error { return e.CreateMessage(res.CreateError("Failed to find a song URL.")) } - likedTrack, err := h.Database.FindLikedTrack(e.User().ID, url) - if err != nil && err != sql.ErrNoRows { + likedTrack, err := c.Database.FindLikedTrack(e.User().ID, url) + if err != nil && !errors.Is(err, sql.ErrNoRows) { return e.CreateMessage(res.CreateErr("Failed to like song", err)) } - if err == sql.ErrNoRows { - result, err := h.Lavalink.BestNode().Rest().LoadTracks(context.Background(), url) + if errors.Is(err, sql.ErrNoRows) { + if err = e.DeferCreateMessage(true); err != nil { + return err + } + + result, err := c.Lavalink.BestNode().Rest().LoadTracks(context.Background(), url) if err != nil { - return e.CreateMessage(res.CreateErr("Failed to like song", err)) + _, err = e.UpdateInteractionResponse(res.UpdateErr("Failed to like song", err)) + return err } - if result.LoadType == lavalink.LoadTypeLoadFailed { - return e.CreateMessage(res.CreateErr("Failed to like song", err)) - } else if result.LoadType == lavalink.LoadTypeNoMatches || len(result.Tracks) == 0 { - return e.CreateMessage(res.CreateError("Failed to like song: No matches found.")) + + var track lavalink.Track + switch d := result.Data.(type) { + case lavalink.Exception: + _, err = e.UpdateInteractionResponse(res.UpdateErr("Failed to like song", err)) + return err + case lavalink.Empty: + _, err = e.UpdateInteractionResponse(res.UpdateError("Failed to like song: No matches found.")) + return err + case lavalink.Track: + track = d + case lavalink.Search: + if len(d) == 0 { + _, err = e.UpdateInteractionResponse(res.UpdateError("Failed to like song: No matches found.")) + return err + } + track = d[0] + case lavalink.Playlist: + _, err = e.UpdateInteractionResponse(res.UpdateError("Failed to like song: Playlists are not supported.")) + return err } - track := result.Tracks[0] - if err = h.Database.AddLikedTrack(e.User().ID, track); err != nil { - return e.CreateMessage(res.CreateError("Failed to add song to your liked songs. Please try again.")) + if err = c.Database.AddLikedTrack(e.User().ID, track); err != nil { + _, err = e.UpdateInteractionResponse(res.UpdateError("Failed to add song to your liked songs. Please try again.")) + return err } - create := res.Createf("โค Added %s to your liked songs.", res.FormatTrack(track, 0)) - create.Flags = discord.MessageFlagEphemeral - return e.CreateMessage(create) + _, err = e.UpdateInteractionResponse(res.Updatef("โค Added %s to your liked songs.", res.FormatTrack(track, 0))) + return err } - if err = h.Database.RemoveLikedTrack(likedTrack.ID); err != nil { + if err = c.Database.RemoveLikedTrack(likedTrack.ID); err != nil { return e.CreateMessage(res.CreateErr("Failed to remove song from your liked songs", err)) } @@ -110,8 +131,8 @@ func (h *Handlers) OnLikedSongsAddButton(e *handler.ComponentEvent) error { return e.CreateMessage(create) } -func (h *Handlers) OnLikedSongsShow(e *handler.CommandEvent) error { - likedTracks, err := h.Database.GetLikedTracks(e.User().ID) +func (c *commands) OnLikedSongsShow(_ discord.SlashCommandInteractionData, e *handler.CommandEvent) error { + likedTracks, err := c.Database.GetLikedTracks(e.User().ID) if err != nil { return e.CreateMessage(res.CreateErr("Failed to get liked songs", err)) } @@ -131,21 +152,21 @@ func (h *Handlers) OnLikedSongsShow(e *handler.CommandEvent) error { return e.CreateMessage(res.Create(content)) } -func (h *Handlers) OnLikedSongsRemove(e *handler.CommandEvent) error { - trackID := e.SlashCommandInteractionData().Int("song") +func (c *commands) OnLikedSongsRemove(data discord.SlashCommandInteractionData, e *handler.CommandEvent) error { + trackID := data.Int("song") - if err := h.Database.RemoveLikedTrack(trackID); err != nil { + if err := c.Database.RemoveLikedTrack(trackID); err != nil { return e.CreateMessage(res.CreateErr("Failed to remove song from your liked songs", err)) } return e.CreateMessage(res.Create("Removed song from your liked songs.")) } -func (h *Handlers) OnLikedSongsAutocomplete(e *handler.AutocompleteEvent) error { +func (c *commands) OnLikedSongsAutocomplete(e *handler.AutocompleteEvent) error { query := e.Data.String("song") - likedTracks, err := h.Database.SearchLikedTracks(e.User().ID, query, 25) + likedTracks, err := c.Database.SearchLikedTracks(e.User().ID, query, 25) if err != nil { - return e.Result(nil) + return e.AutocompleteResult(nil) } choices := make([]discord.AutocompleteChoice, len(likedTracks)) @@ -155,11 +176,11 @@ func (h *Handlers) OnLikedSongsAutocomplete(e *handler.AutocompleteEvent) error Value: track.ID, } } - return e.Result(choices) + return e.AutocompleteResult(choices) } -func (h *Handlers) OnLikedSongsClear(e *handler.CommandEvent) error { - if err := h.Database.ClearLikedTracks(e.User().ID); err != nil { +func (c *commands) OnLikedSongsClear(_ discord.SlashCommandInteractionData, e *handler.CommandEvent) error { + if err := c.Database.ClearLikedTracks(e.User().ID); err != nil { return e.CreateMessage(res.CreateErr("Failed to clear liked songs", err)) } return e.CreateMessage(res.Create("Cleared liked songs.")) diff --git a/service/bot/commands/middlewares.go b/service/bot/commands/middlewares.go new file mode 100644 index 0000000..f51b461 --- /dev/null +++ b/service/bot/commands/middlewares.go @@ -0,0 +1,18 @@ +package commands + +import ( + "github.com/disgoorg/disgo/discord" + "github.com/disgoorg/disgo/handler" + + "github.com/KittyBot-Org/KittyBotGo/service/bot/res" +) + +func (c *commands) OnHasPlayer(next handler.Handler) handler.Handler { + return func(e *handler.InteractionEvent) error { + player := c.Lavalink.ExistingPlayer(*e.GuildID()) + if player == nil { + return e.Respond(discord.InteractionResponseTypeCreateMessage, res.CreateError("No player found")) + } + return next(e) + } +} diff --git a/service/bot/handlers/ping.go b/service/bot/commands/ping.go similarity index 71% rename from service/bot/handlers/ping.go rename to service/bot/commands/ping.go index 245ecdc..22a41f6 100644 --- a/service/bot/handlers/ping.go +++ b/service/bot/commands/ping.go @@ -1,4 +1,4 @@ -package handlers +package commands import ( "github.com/disgoorg/disgo/discord" @@ -12,6 +12,6 @@ var pingCommand = discord.SlashCommandCreate{ Description: "Ping the bot", } -func (h *Handlers) OnPing(e *handler.CommandEvent) error { +func (c *commands) OnPing(_ discord.SlashCommandInteractionData, e *handler.CommandEvent) error { return e.CreateMessage(res.Create("Pong!")) } diff --git a/service/bot/commands/play.go b/service/bot/commands/play.go new file mode 100644 index 0000000..42cacde --- /dev/null +++ b/service/bot/commands/play.go @@ -0,0 +1,203 @@ +package commands + +import ( + "context" + "fmt" + "log/slog" + "regexp" + "slices" + "strconv" + "strings" + + "github.com/disgoorg/disgo/discord" + "github.com/disgoorg/disgo/handler" + "github.com/disgoorg/disgolink/v3/disgolink" + "github.com/disgoorg/disgolink/v3/lavalink" + "github.com/disgoorg/lavaqueue-plugin" + "github.com/disgoorg/snowflake/v2" + "github.com/topi314/tint" + + "github.com/KittyBot-Org/KittyBotGo/service/bot/res" +) + +var ( + urlPattern = regexp.MustCompile("^https?://[-a-zA-Z0-9+&@#/%?=~_|!:,.;]*[-a-zA-Z0-9+&@#/%=~_|]?") + searchPattern = regexp.MustCompile(`^(.{2})(search|isrc):(.+)`) + queryTypes = []string{"liked_track", "playlist", "play_history"} +) + +func (c *commands) OnPlayerPlay(data discord.SlashCommandInteractionData, e *handler.CommandEvent) error { + query := data.String("query") + + var ( + id int + loadType string + ) + parts := strings.SplitN(query, ":", 2) + if len(parts) == 2 && slices.Contains(queryTypes, parts[0]) { + if loadID, err := strconv.Atoi(parts[1]); err == nil { + id = loadID + loadType = parts[0] + } + } else if !urlPattern.MatchString(query) && !searchPattern.MatchString(query) { + if source, ok := data.OptString("source"); ok { + query = lavalink.SearchType(source).Apply(query) + } else { + query = lavalink.SearchTypeYouTube.Apply(query) + } + } + + voiceState, ok := c.Discord.Caches().VoiceState(*e.GuildID(), e.User().ID) + if !ok { + return e.CreateMessage(res.CreateError("You are not in a voice channel")) + } + + player := c.Lavalink.Player(*e.GuildID()) + + if err := e.DeferCreateMessage(false); err != nil { + return err + } + + switch loadType { + case "liked_track": + track, err := c.Database.GetLikedTrack(id) + if err != nil { + _, err = e.UpdateInteractionResponse(res.UpdateErr("Failed to get liked song", err)) + return err + } + return c.handleTracks(e, *voiceState.ChannelID, track.Track) + + case "playlist": + _, playlistTracks, err := c.Database.GetPlaylist(id) + if err != nil { + _, err = e.UpdateInteractionResponse(res.UpdateErr("Failed to get playlist", err)) + return err + } + + tracks := make([]lavalink.Track, len(playlistTracks)) + for i, track := range playlistTracks { + tracks[i] = track.Track + } + return c.handleTracks(e, *voiceState.ChannelID, tracks...) + + case "play_history": + track, err := c.Database.GetPlayHistoryTrack(id) + if err != nil { + _, err = e.UpdateInteractionResponse(res.UpdateErr("Failed to get play history song", err)) + return err + } + + return c.handleTracks(e, *voiceState.ChannelID, track.Track) + } + + var loadErr error + player.Node().LoadTracksHandler(e.Ctx, query, disgolink.NewResultHandler( + func(track lavalink.Track) { + loadErr = c.handleTracks(e, *voiceState.ChannelID, track) + }, + func(playlist lavalink.Playlist) { + loadErr = c.handleTracks(e, *voiceState.ChannelID, playlist.Tracks...) + }, + func(tracks []lavalink.Track) { + loadErr = c.handleTracks(e, *voiceState.ChannelID, tracks[0]) + }, + func() { + _, loadErr = e.UpdateInteractionResponse(res.UpdateError("No results found for %s", query)) + }, + func(err error) { + _, loadErr = e.UpdateInteractionResponse(res.UpdateErr("An error occurred", err)) + }, + )) + + return loadErr +} + +func (c *commands) handleTracks(e *handler.CommandEvent, channelID snowflake.ID, tracks ...lavalink.Track) error { + _, ok := c.Discord.Caches().VoiceState(*e.GuildID(), e.ApplicationID()) + if !ok { + if err := c.Discord.UpdateVoiceState(context.Background(), *e.GuildID(), &channelID, false, false); err != nil { + _, err = e.UpdateInteractionResponse(res.UpdateErr("An error occurred", err)) + return err + } + } + + queueTracks := make([]lavaqueue.QueueTrack, len(tracks)) + for i, track := range tracks { + queueTracks[i] = lavaqueue.QueueTrack{ + Encoded: track.Encoded, + UserData: nil, // TODO: Add user data + } + } + + player := c.Lavalink.Player(*e.GuildID()) + track, err := lavaqueue.AddQueueTracks(e.Ctx, player.Node(), *e.GuildID(), queueTracks) + if err != nil { + _, err = e.UpdateInteractionResponse(res.UpdateErr("An error occurred playing the song", err)) + return err + } + + var ( + content string + likeButton bool + tracksCount = len(tracks) + ) + if track != nil { + content = fmt.Sprintf("โ–ถ Playing: %s", res.FormatTrack(*track, 0)) + likeButton = true + tracksCount-- + } + if len(tracks) > 0 { + content += fmt.Sprintf("\nAdded %d songs to the queue", tracksCount) + } + + go func() { + if err := c.Database.AddPlayHistoryTracks(e.User().ID, tracks); err != nil { + slog.Error("error adding play history songs", tint.Err(err)) + } + }() + + _, err = e.UpdateInteractionResponse(res.UpdatePlayer(content, likeButton)) + return err +} + +func (c *commands) OnPlayerPlayAutocomplete(e *handler.AutocompleteEvent) error { + query := e.Data.String("query") + + limit := 24 + if strings.TrimSpace(query) == "" { + limit = 25 + } + + tracks, err := c.Database.SearchPlay(e.User().ID, query, limit) + if err != nil { + slog.Error("error searching play", tint.Err(err)) + return e.AutocompleteResult(nil) + } + + choices := make([]discord.AutocompleteChoice, 0, 25) + if limit == 24 { + choices = append(choices, discord.AutocompleteChoiceString{ + Name: res.Trim(fmt.Sprintf("๐Ÿ”Ž %s", query), 100), + Value: query, + }) + } + + for _, track := range tracks { + var prefix string + switch track.Type { + case "liked_track": + prefix = "โค " + case "play_history": + prefix = "๐Ÿ•’ " + case "playlist": + prefix = "๐Ÿ“œ " + } + + choices = append(choices, discord.AutocompleteChoiceString{ + Name: res.Trim(prefix+track.Name, 100), + Value: fmt.Sprintf("%s:%d", track.Type, track.ID), + }) + } + + return e.AutocompleteResult(choices) +} diff --git a/service/bot/handlers/player.go b/service/bot/commands/player.go similarity index 56% rename from service/bot/handlers/player.go rename to service/bot/commands/player.go index 9776d6a..0ee9e7b 100644 --- a/service/bot/handlers/player.go +++ b/service/bot/commands/player.go @@ -1,18 +1,17 @@ -package handlers +package commands import ( - "context" - "database/sql" "errors" "fmt" + "net/http" "time" "github.com/disgoorg/disgo/discord" "github.com/disgoorg/disgo/handler" - "github.com/disgoorg/disgolink/v2/lavalink" + "github.com/disgoorg/disgolink/v3/lavalink" "github.com/disgoorg/json" + "github.com/disgoorg/lavaqueue-plugin" - "github.com/KittyBot-Org/KittyBotGo/interal/database" "github.com/KittyBot-Org/KittyBotGo/service/bot/res" ) @@ -193,12 +192,14 @@ var playerCommand = discord.SlashCommandCreate{ }, } -func (h *Handlers) OnPlayerStatus(e *handler.CommandEvent) error { - player := h.Lavalink.Player(*e.GuildID()) - dbPlayer, _ := h.Database.GetPlayer(*e.GuildID(), player.Node().Config().Name) - tracks, _ := h.Database.GetQueue(*e.GuildID()) - track := player.Track() +func (c *commands) OnPlayerStatus(_ discord.SlashCommandInteractionData, e *handler.CommandEvent) error { + player := c.Lavalink.Player(*e.GuildID()) + queue, err := lavaqueue.GetQueue(e.Ctx, player.Node(), *e.GuildID()) + if err != nil { + return e.CreateMessage(res.CreateErr("Failed to get queue", err)) + } + track := player.Track() if track == nil { return e.CreateMessage(res.CreateError("There is no song playing right now.")) } @@ -209,7 +210,7 @@ func (h *Handlers) OnPlayerStatus(e *handler.CommandEvent) error { SetDescription(res.FormatTrack(*track, player.Position())). AddField("Author:", track.Info.Author, true). AddField("Volume:", fmt.Sprintf("%d%%", player.Volume()), true). - SetFooterText(fmt.Sprintf("Songs in queue: %d", len(tracks))) + SetFooterText(fmt.Sprintf("Songs in queue: %d", len(queue.Tracks))) if track.Info.ArtworkURL != nil { embed.SetThumbnail(*track.Info.ArtworkURL) @@ -222,9 +223,9 @@ func (h *Handlers) OnPlayerStatus(e *handler.CommandEvent) error { p := int(float64(t1) / float64(t2) * 10) bar[p] = "๐Ÿ”˜" loopString := "" - if dbPlayer.QueueType == database.QueueTypeRepeatTrack { + if queue.Type == lavaqueue.QueueTypeRepeatTrack { loopString = "๐Ÿ”‚" - } else if dbPlayer.QueueType == database.QueueTypeRepeatQueue { + } else if queue.Type == lavaqueue.QueueTypeRepeatQueue { loopString = "๐Ÿ”" } embed.Description += fmt.Sprintf("\n\n%s / %s %s\n%s", res.FormatDuration(t1), res.FormatDuration(t2), loopString, bar) @@ -235,97 +236,88 @@ func (h *Handlers) OnPlayerStatus(e *handler.CommandEvent) error { return e.CreateMessage(create) } -func (h *Handlers) OnPlayerPause(e *handler.CommandEvent) error { - player := h.Lavalink.Player(*e.GuildID()) - if err := player.Update(context.Background(), lavalink.WithPaused(true)); err != nil { +func (c *commands) OnPlayerPause(_ discord.SlashCommandInteractionData, e *handler.CommandEvent) error { + player := c.Lavalink.Player(*e.GuildID()) + if err := player.Update(e.Ctx, lavalink.WithPaused(true)); err != nil { return e.CreateMessage(res.CreateErr("Failed to pause the player", err)) } return e.CreateMessage(res.CreatePlayer("โธ Paused the player.", false)) } -func (h *Handlers) OnPlayerResume(e *handler.CommandEvent) error { - player := h.Lavalink.Player(*e.GuildID()) - if err := player.Update(context.Background(), lavalink.WithPaused(false)); err != nil { +func (c *commands) OnPlayerResume(_ discord.SlashCommandInteractionData, e *handler.CommandEvent) error { + player := c.Lavalink.Player(*e.GuildID()) + if err := player.Update(e.Ctx, lavalink.WithPaused(false)); err != nil { return e.CreateMessage(res.CreateErr("Failed to resume the player", err)) } return e.CreateMessage(res.CreatePlayer("โ–ถ Resumed the player.", false)) } -func (h *Handlers) OnPlayerStop(e *handler.CommandEvent) error { - player := h.Lavalink.Player(*e.GuildID()) - if err := player.Destroy(context.Background()); err != nil { +func (c *commands) OnPlayerStop(_ discord.SlashCommandInteractionData, e *handler.CommandEvent) error { + player := c.Lavalink.Player(*e.GuildID()) + if err := player.Destroy(e.Ctx); err != nil { return e.CreateMessage(res.CreateErr("Failed to stop the player", err)) } - if err := h.Database.DeletePlayer(*e.GuildID()); err != nil { - return e.CreateMessage(res.CreateErr("Failed to delete the player from the database", err)) - } - - if err := h.Discord.UpdateVoiceState(context.Background(), *e.GuildID(), nil, false, false); err != nil { + if err := c.Discord.UpdateVoiceState(e.Ctx, *e.GuildID(), nil, false, false); err != nil { return e.CreateMessage(res.CreateErr("Failed to disconnect from the voice channel", err)) } return e.CreateMessage(res.Create("โน Stopped the player.")) } -func (h *Handlers) OnPlayerNext(e *handler.CommandEvent) error { - player := h.Lavalink.Player(*e.GuildID()) - track, err := h.Database.NextQueueTrack(*e.GuildID()) - if errors.Is(err, sql.ErrNoRows) { - return e.CreateMessage(res.CreateError("No more songs in queue")) - } +func (c *commands) OnPlayerNext(_ discord.SlashCommandInteractionData, e *handler.CommandEvent) error { + player := c.Lavalink.Player(*e.GuildID()) + track, err := lavaqueue.QueueNextTrack(e.Ctx, player.Node(), *e.GuildID()) if err != nil { - return e.CreateMessage(res.CreateErr("Failed to get next song", err)) + var eErr *lavalink.Error + if errors.As(err, &eErr) && eErr.Status == http.StatusNotFound { + return e.CreateMessage(res.CreateError("No more songs in queue")) + } + return e.CreateMessage(res.CreateErr("Failed to skip to the next song", err)) } - if err = player.Update(context.Background(), lavalink.WithTrack(track.Track)); err != nil { - return e.CreateMessage(res.CreateErr("Failed to play next song", err)) - } - return e.CreateMessage(res.CreatePlayerf("โ–ถ Playing: %s", true, res.FormatTrack(track.Track, 0))) + return e.CreateMessage(res.CreatePlayerf("โ–ถ Playing: %s", true, res.FormatTrack(*track, 0))) } -func (h *Handlers) OnPlayerPrevious(e *handler.CommandEvent) error { - player := h.Lavalink.Player(*e.GuildID()) - track, err := h.Database.PreviousHistoryTrack(*e.GuildID()) - if errors.Is(err, sql.ErrNoRows) { - return e.CreateMessage(res.CreateError("No more songs in queue")) - } +func (c *commands) OnPlayerPrevious(_ discord.SlashCommandInteractionData, e *handler.CommandEvent) error { + player := c.Lavalink.Player(*e.GuildID()) + track, err := lavaqueue.QueuePreviousTrack(e.Ctx, player.Node(), *e.GuildID()) if err != nil { - return e.CreateMessage(res.CreateErr("Failed to get previous song", err)) + var eErr *lavalink.Error + if errors.As(err, &eErr) && eErr.Status == http.StatusNotFound { + return e.CreateMessage(res.CreateError("No songs in history")) + } + return e.CreateMessage(res.CreateErr("Failed to skip to the next song", err)) } - if err = player.Update(context.Background(), lavalink.WithTrack(track.Track)); err != nil { - return e.CreateMessage(res.CreateErr("Failed to play previous song", err)) - } - return e.CreateMessage(res.CreatePlayerf("โ–ถ Playing: %s", true, res.FormatTrack(track.Track, 0))) + return e.CreateMessage(res.CreatePlayerf("โ–ถ Playing: %s", true, res.FormatTrack(*track, 0))) } -func (h *Handlers) OnPlayerVolume(e *handler.CommandEvent) error { - player := h.Lavalink.Player(*e.GuildID()) - volume := e.SlashCommandInteractionData().Int("volume") +func (c *commands) OnPlayerVolume(data discord.SlashCommandInteractionData, e *handler.CommandEvent) error { + player := c.Lavalink.Player(*e.GuildID()) + volume := data.Int("volume") - if err := player.Update(context.Background(), lavalink.WithVolume(volume)); err != nil { + if err := player.Update(e.Ctx, lavalink.WithVolume(volume)); err != nil { return e.CreateMessage(res.CreateErr("Failed to set the volume", err)) } return e.CreateMessage(res.CreatePlayerf("๐Ÿ”Š Set the volume to %d%%.", false, volume)) } -func (h *Handlers) OnPlayerBassBoost(e *handler.CommandEvent) error { - player := h.Lavalink.Player(*e.GuildID()) - level := e.SlashCommandInteractionData().String("level") +func (c *commands) OnPlayerBassBoost(data discord.SlashCommandInteractionData, e *handler.CommandEvent) error { + player := c.Lavalink.Player(*e.GuildID()) + level := data.String("level") filters := player.Filters() filters.Equalizer = bassBoostLevels[level] - if err := player.Update(context.Background(), lavalink.WithFilters(filters)); err != nil { + if err := player.Update(e.Ctx, lavalink.WithFilters(filters)); err != nil { return e.CreateMessage(res.CreateErr("Failed to set bass boost: %s", err)) } return e.CreateMessage(res.CreatePlayerf("๐Ÿ”Š Set bass boost to %s.", false, level)) } -func (h *Handlers) OnPlayerSeek(e *handler.CommandEvent) error { - player := h.Lavalink.Player(*e.GuildID()) - data := e.SlashCommandInteractionData() +func (c *commands) OnPlayerSeek(data discord.SlashCommandInteractionData, e *handler.CommandEvent) error { + player := c.Lavalink.Player(*e.GuildID()) position := data.Int("position") duration, ok := data.OptInt("time-unit") if !ok { @@ -333,69 +325,61 @@ func (h *Handlers) OnPlayerSeek(e *handler.CommandEvent) error { } newPos := lavalink.Duration(position * duration) - if err := player.Update(context.Background(), lavalink.WithPosition(newPos)); err != nil { + if err := player.Update(e.Ctx, lavalink.WithPosition(newPos)); err != nil { return e.CreateMessage(res.CreateErr("Failed to seek to %d", err)) } - return e.CreateMessage(res.CreatePlayerf("โฉ Seeked to %d.", false, res.FormatDuration(newPos))) + return e.CreateMessage(res.CreatePlayerf("โฉ Seeked to %s.", false, res.FormatDuration(newPos))) } -func (h *Handlers) OnPlayerPreviousButton(e *handler.ComponentEvent) error { - player := h.Lavalink.Player(*e.GuildID()) - track, err := h.Database.PreviousHistoryTrack(*e.GuildID()) - if errors.Is(err, sql.ErrNoRows) { - return e.CreateMessage(res.CreateError("No songs in history")) - } +func (c *commands) OnPlayerNextButton(e *handler.ComponentEvent) error { + player := c.Lavalink.Player(*e.GuildID()) + track, err := lavaqueue.QueueNextTrack(e.Ctx, player.Node(), *e.GuildID()) if err != nil { - return e.CreateMessage(res.CreateErr("Failed to get previous song", err)) + var eErr *lavalink.Error + if errors.As(err, &eErr) && eErr.Status == http.StatusNotFound { + return e.CreateMessage(res.CreateError("No more songs in queue")) + } + return e.CreateMessage(res.CreateErr("Failed to skip to the next song", err)) } - if err = player.Update(context.Background(), lavalink.WithTrack(track.Track)); err != nil { - return e.CreateMessage(res.CreateErr("Failed to play previous song", err)) + return e.UpdateMessage(res.UpdatePlayerf("โ–ถ Playing: %s", true, res.FormatTrack(*track, 0))) +} + +func (c *commands) OnPlayerPreviousButton(e *handler.ComponentEvent) error { + player := c.Lavalink.Player(*e.GuildID()) + track, err := lavaqueue.QueuePreviousTrack(e.Ctx, player.Node(), *e.GuildID()) + if err != nil { + var eErr *lavalink.Error + if errors.As(err, &eErr) && eErr.Status == http.StatusNotFound { + return e.CreateMessage(res.CreateError("No songs in history")) + } + return e.CreateMessage(res.CreateErr("Failed to skip to the next song", err)) } - return e.CreateMessage(res.CreatePlayerf("โ–ถ Playing: %s", true, res.FormatTrack(track.Track, 0))) + + return e.UpdateMessage(res.UpdatePlayerf("โ–ถ Playing: %s", true, res.FormatTrack(*track, 0))) } -func (h *Handlers) OnPlayerPlayPauseButton(e *handler.ComponentEvent) error { - player := h.Lavalink.Player(*e.GuildID()) +func (c *commands) OnPlayerPlayPauseButton(e *handler.ComponentEvent) error { + player := c.Lavalink.Player(*e.GuildID()) paused := !player.Paused() - if err := player.Update(context.Background(), lavalink.WithPaused(paused)); err != nil { + if err := player.Update(e.Ctx, lavalink.WithPaused(paused)); err != nil { return e.CreateMessage(res.CreateErr("Failed to pause the player", err)) } if paused { - return e.CreateMessage(res.CreatePlayer("โธ Paused the player.", false)) + return e.UpdateMessage(res.UpdatePlayerf("โธ Paused the player.", false)) } - return e.CreateMessage(res.CreatePlayer("โ–ถ Resumed the player.", false)) + return e.UpdateMessage(res.UpdatePlayerf("โ–ถ Resumed the player.", false)) } -func (h *Handlers) OnPlayerNextButton(e *handler.ComponentEvent) error { - player := h.Lavalink.Player(*e.GuildID()) - track, err := h.Database.NextQueueTrack(*e.GuildID()) - if errors.Is(err, sql.ErrNoRows) { - return e.CreateMessage(res.CreateError("No more songs in queue")) - } - if err != nil { - return e.CreateMessage(res.CreateErr("Failed to get next song", err)) - } - - if err = player.Update(context.Background(), lavalink.WithTrack(track.Track)); err != nil { - return e.CreateMessage(res.CreateErr("Failed to play next song", err)) - } - return e.CreateMessage(res.CreatePlayerf("โ–ถ Playing: %s", true, res.FormatTrack(track.Track, 0))) -} - -func (h *Handlers) OnPlayerStopButton(e *handler.ComponentEvent) error { - player := h.Lavalink.Player(*e.GuildID()) - if err := player.Destroy(context.Background()); err != nil { +func (c *commands) OnPlayerStopButton(e *handler.ComponentEvent) error { + player := c.Lavalink.Player(*e.GuildID()) + if err := player.Destroy(e.Ctx); err != nil { return e.CreateMessage(res.CreateErr("Failed to stop the player", err)) } - if err := h.Database.DeletePlayer(*e.GuildID()); err != nil { - return e.CreateMessage(res.CreateErr("Failed to delete the player from the database", err)) - } - - if err := h.Discord.UpdateVoiceState(context.Background(), *e.GuildID(), nil, false, false); err != nil { + if err := c.Discord.UpdateVoiceState(e.Ctx, *e.GuildID(), nil, false, false); err != nil { return e.CreateMessage(res.CreateErr("Failed to disconnect from the voice channel", err)) } - return e.CreateMessage(res.Create("โน Stopped the player.")) + return e.UpdateMessage(res.Update("โน Stopped the player.")) } diff --git a/service/bot/handlers/playlists.go b/service/bot/commands/playlists.go similarity index 63% rename from service/bot/handlers/playlists.go rename to service/bot/commands/playlists.go index 83bb794..35b9cc1 100644 --- a/service/bot/handlers/playlists.go +++ b/service/bot/commands/playlists.go @@ -1,13 +1,13 @@ -package handlers +package commands import ( "context" "fmt" - "time" "github.com/disgoorg/disgo/discord" "github.com/disgoorg/disgo/handler" - "github.com/disgoorg/disgolink/v2/lavalink" + "github.com/disgoorg/disgolink/v3/lavalink" + "github.com/disgoorg/lavaqueue-plugin" "github.com/KittyBot-Org/KittyBotGo/service/bot/res" ) @@ -131,8 +131,8 @@ var playlistsCommand = discord.SlashCommandCreate{ }, } -func (h *Handlers) OnPlaylistsList(e *handler.CommandEvent) error { - playlists, err := h.Database.GetPlaylists(e.User().ID) +func (c *commands) OnPlaylistsList(data discord.SlashCommandInteractionData, e *handler.CommandEvent) error { + playlists, err := c.Database.GetPlaylists(e.User().ID) if err != nil { return e.CreateMessage(res.CreateErr("Failed to get playlists", err)) } @@ -149,9 +149,8 @@ func (h *Handlers) OnPlaylistsList(e *handler.CommandEvent) error { return e.CreateMessage(res.Create(content)) } -func (h *Handlers) OnPlaylistCreate(e *handler.CommandEvent) error { - data := e.SlashCommandInteractionData() - playlist, err := h.Database.CreatePlaylist(e.User().ID, data.String("name")) +func (c *commands) OnPlaylistCreate(data discord.SlashCommandInteractionData, e *handler.CommandEvent) error { + playlist, err := c.Database.CreatePlaylist(e.User().ID, data.String("name")) if err != nil { return e.CreateMessage(res.CreateErr("Failed to create playlist", err)) } @@ -159,18 +158,16 @@ func (h *Handlers) OnPlaylistCreate(e *handler.CommandEvent) error { return e.CreateMessage(res.Create(fmt.Sprintf("Created playlist: `%s`", playlist.Name))) } -func (h *Handlers) OnPlaylistDelete(e *handler.CommandEvent) error { - data := e.SlashCommandInteractionData() - if err := h.Database.DeletePlaylist(data.Int("playlist"), e.User().ID); err != nil { +func (c *commands) OnPlaylistDelete(data discord.SlashCommandInteractionData, e *handler.CommandEvent) error { + if err := c.Database.DeletePlaylist(data.Int("playlist"), e.User().ID); err != nil { return e.CreateMessage(res.CreateErr("Failed to delete playlist", err)) } return e.CreateMessage(res.Create(fmt.Sprintf("Deleted playlist"))) } -func (h *Handlers) OnPlaylistShow(e *handler.CommandEvent) error { - data := e.SlashCommandInteractionData() - playlist, tracks, err := h.Database.GetPlaylist(data.Int("playlist")) +func (c *commands) OnPlaylistShow(data discord.SlashCommandInteractionData, e *handler.CommandEvent) error { + playlist, tracks, err := c.Database.GetPlaylist(data.Int("playlist")) if err != nil { return e.CreateMessage(res.CreateErr("Failed to get playlist", err)) } @@ -191,15 +188,13 @@ func (h *Handlers) OnPlaylistShow(e *handler.CommandEvent) error { return e.CreateMessage(res.Create(content)) } -func (h *Handlers) OnPlaylistPlay(e *handler.CommandEvent) error { - data := e.SlashCommandInteractionData() - - voiceState, ok := h.Discord.Caches().VoiceState(*e.GuildID(), e.User().ID) +func (c *commands) OnPlaylistPlay(data discord.SlashCommandInteractionData, e *handler.CommandEvent) error { + voiceState, ok := c.Discord.Caches().VoiceState(*e.GuildID(), e.User().ID) if !ok { return e.CreateMessage(res.CreateError("You are not in a voice channel")) } - playlist, dbTracks, err := h.Database.GetPlaylist(data.Int("playlist")) + playlist, dbTracks, err := c.Database.GetPlaylist(data.Int("playlist")) if err != nil { return e.CreateMessage(res.CreateErr("Failed to get playlist", err)) } @@ -208,47 +203,45 @@ func (h *Handlers) OnPlaylistPlay(e *handler.CommandEvent) error { return e.CreateMessage(res.CreateError("Playlist is empty")) } - _, ok = h.Discord.Caches().VoiceState(*e.GuildID(), e.ApplicationID()) + _, ok = c.Discord.Caches().VoiceState(*e.GuildID(), e.ApplicationID()) if !ok { - if err = h.Discord.UpdateVoiceState(context.Background(), *e.GuildID(), voiceState.ChannelID, false, false); err != nil { - _, err = e.UpdateInteractionResponse(res.UpdateErr("Failed to join channel", err)) - return err + if err = c.Discord.UpdateVoiceState(context.Background(), *e.GuildID(), voiceState.ChannelID, false, false); err != nil { + return e.CreateMessage(res.CreateErr("Failed to join channel", err)) } } - player := h.Lavalink.Player(*e.GuildID()) - if _, err = h.Database.GetPlayer(*e.GuildID(), player.Node().Config().Name); err != nil { - return e.CreateMessage(res.CreateErr("Failed to get or create player", err)) + queueTracks := make([]lavaqueue.QueueTrack, len(dbTracks)) + for i, track := range dbTracks { + queueTracks[i] = lavaqueue.QueueTrack{ + Encoded: track.Track.Encoded, + UserData: nil, // TODO: Add user data + } } - var content string - if player.Track() == nil { - track := dbTracks[0] - dbTracks = dbTracks[1:] - - if err = player.Update(context.Background(), lavalink.WithTrack(track.Track)); err != nil { - return e.CreateMessage(res.CreateErr("An error occurred", err)) - } - content = fmt.Sprintf("โ–ถ Playing: %s from playlist `%s`", res.FormatTrack(track.Track, 0), playlist.Name) + player := c.Lavalink.Player(*e.GuildID()) + track, err := lavaqueue.AddQueueTracks(e.Ctx, player.Node(), *e.GuildID(), queueTracks) + if err != nil { + return e.CreateMessage(res.CreateErr("An error occurred playing the song", err)) } + var ( + content string + likeButton bool + tracksCount = len(dbTracks) + ) + if track != nil { + content = fmt.Sprintf("โ–ถ Playing: %s from playlist `%s`", res.FormatTrack(*track, 0), playlist.Name) + likeButton = true + tracksCount-- + } if len(dbTracks) > 0 { - tracks := make([]lavalink.Track, len(dbTracks)) - for i := range dbTracks { - tracks[i] = dbTracks[i].Track - } - - content += fmt.Sprintf("\nAdded %d songs to the queue from playlist `%s`", len(tracks), playlist.Name) - if err = h.Database.AddQueueTracks(*e.GuildID(), tracks); err != nil { - return e.CreateMessage(res.CreateErr("An error occurred", err)) - } + content += fmt.Sprintf("\nAdded %d songs to the queue from playlist `%s`", tracksCount, playlist.Name) } - return e.CreateMessage(res.Create(content)) + return e.CreateMessage(res.CreatePlayer(content, likeButton)) } -func (h *Handlers) OnPlaylistAdd(e *handler.CommandEvent) error { - data := e.SlashCommandInteractionData() +func (c *commands) OnPlaylistAdd(data discord.SlashCommandInteractionData, e *handler.CommandEvent) error { playlistID := data.Int("playlist") query := data.String("query") @@ -264,65 +257,67 @@ func (h *Handlers) OnPlaylistAdd(e *handler.CommandEvent) error { return err } - go func() { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - result, err := h.Lavalink.BestNode().Rest().LoadTracks(ctx, query) - if err != nil { - _, err = e.UpdateInteractionResponse(res.UpdateErr("Failed to load song", err)) - return - } - if result.LoadType == lavalink.LoadTypeLoadFailed { - _, err = e.UpdateInteractionResponse(res.UpdateErr("Failed to like song", err)) - } else if result.LoadType == lavalink.LoadTypeNoMatches || len(result.Tracks) == 0 { - _, err = e.UpdateInteractionResponse(res.UpdateError("Failed to like song: No matches found.")) - } - if err != nil { - h.Logger.Errorf("error loading songs: %s", err) - return - } - tracks := result.Tracks - if result.LoadType == lavalink.LoadTypeSearchResult { - tracks = tracks[:1] - } + result, err := c.Lavalink.BestNode().Rest().LoadTracks(context.Background(), query) + if err != nil { + _, err = e.UpdateInteractionResponse(res.UpdateErr("Failed to load song", err)) + return err + } - if err = h.Database.AddTracksToPlaylist(playlistID, tracks); err != nil { - _, _ = e.UpdateInteractionResponse(res.UpdateErr("Failed to add song to playlist", err)) - return + var tracks []lavalink.Track + switch d := result.Data.(type) { + case lavalink.Exception: + _, err = e.UpdateInteractionResponse(res.UpdateErr("Failed to find song", err)) + return err + case lavalink.Empty: + _, err = e.UpdateInteractionResponse(res.UpdateError("Failed to find song: No matches found.")) + return err + case lavalink.Track: + tracks = append(tracks, d) + case lavalink.Search: + if len(d) == 0 { + _, err = e.UpdateInteractionResponse(res.UpdateError("Failed to find song: No matches found.")) + return err } - _, _ = e.UpdateInteractionResponse(res.Updatef("Added `%d` songs to playlist", len(tracks))) - }() + tracks = d[:1] + case lavalink.Playlist: + tracks = d.Tracks + } - return nil + if err = c.Database.AddTracksToPlaylist(playlistID, tracks); err != nil { + _, err = e.UpdateInteractionResponse(res.UpdateErr("Failed to add song to playlist", err)) + return err + } + _, err = e.UpdateInteractionResponse(res.Updatef("Added `%d` songs to playlist", len(tracks))) + return err } -func (h *Handlers) OnPlaylistRemove(e *handler.CommandEvent) error { - trackID := e.SlashCommandInteractionData().Int("song") +func (c *commands) OnPlaylistRemove(data discord.SlashCommandInteractionData, e *handler.CommandEvent) error { + trackID := data.Int("song") - if err := h.Database.RemoveTrackFromPlaylist(trackID); err != nil { + if err := c.Database.RemoveTrackFromPlaylist(trackID); err != nil { return e.CreateMessage(res.CreateErr("Failed to remove song from playlist", err)) } return e.CreateMessage(res.Create("Removed song from playlist")) } -func (h *Handlers) OnPlaylistRemoveAutocomplete(e *handler.AutocompleteEvent) error { +func (c *commands) OnPlaylistRemoveAutocomplete(e *handler.AutocompleteEvent) error { option, ok := e.Data.Option("playlist") if ok && option.Focused { - return h.OnPlaylistAutocomplete(e) + return c.OnPlaylistAutocomplete(e) } option, ok = e.Data.Option("song") if !ok || !option.Focused { - return e.Result(nil) + return e.AutocompleteResult(nil) } playlistID := e.Data.Int("playlist") track := e.Data.String("song") - tracks, err := h.Database.SearchPlaylistTracks(playlistID, track, 25) + tracks, err := c.Database.SearchPlaylistTracks(playlistID, track, 25) if err != nil { - return e.Result(nil) + return e.AutocompleteResult(nil) } choices := make([]discord.AutocompleteChoice, len(tracks)) @@ -332,13 +327,13 @@ func (h *Handlers) OnPlaylistRemoveAutocomplete(e *handler.AutocompleteEvent) er Value: track.ID, } } - return e.Result(choices) + return e.AutocompleteResult(choices) } -func (h *Handlers) OnPlaylistAutocomplete(e *handler.AutocompleteEvent) error { - playlists, err := h.Database.SearchPlaylists(e.User().ID, e.Data.String("track"), 25) +func (c *commands) OnPlaylistAutocomplete(e *handler.AutocompleteEvent) error { + playlists, err := c.Database.SearchPlaylists(e.User().ID, e.Data.String("track"), 25) if err != nil { - return e.Result(nil) + return e.AutocompleteResult(nil) } choices := make([]discord.AutocompleteChoice, len(playlists)) @@ -348,5 +343,5 @@ func (h *Handlers) OnPlaylistAutocomplete(e *handler.AutocompleteEvent) error { Value: playlist.ID, } } - return e.Result(choices) + return e.AutocompleteResult(choices) } diff --git a/service/bot/commands/queue.go b/service/bot/commands/queue.go new file mode 100644 index 0000000..d65e9c7 --- /dev/null +++ b/service/bot/commands/queue.go @@ -0,0 +1,175 @@ +package commands + +import ( + "fmt" + + "github.com/disgoorg/disgo/discord" + "github.com/disgoorg/disgo/handler" + "github.com/disgoorg/disgolink/v3/lavalink" + "github.com/disgoorg/lavaqueue-plugin" + "go.gopad.dev/fuzzysearch/fuzzy" + + "github.com/KittyBot-Org/KittyBotGo/service/bot/res" +) + +var queueCommand = discord.SlashCommandCreate{ + Name: "queue", + Description: "Lets you manage the queue", + Options: []discord.ApplicationCommandOption{ + discord.ApplicationCommandOptionSubCommand{ + Name: "clear", + Description: "Clears the queue", + }, + discord.ApplicationCommandOptionSubCommand{ + Name: "remove", + Description: "Removes a song from the queue", + Options: []discord.ApplicationCommandOption{ + discord.ApplicationCommandOptionInt{ + Name: "song", + Description: "The song to remove", + Required: true, + Autocomplete: true, + }, + }, + }, + discord.ApplicationCommandOptionSubCommand{ + Name: "shuffle", + Description: "Shuffles the queue", + }, + discord.ApplicationCommandOptionSubCommand{ + Name: "show", + Description: "Shows the queue", + }, + discord.ApplicationCommandOptionSubCommand{ + Name: "type", + Description: "Lets you change the queue type", + Options: []discord.ApplicationCommandOption{ + discord.ApplicationCommandOptionString{ + Name: "type", + Description: "The type of queue", + Required: true, + Choices: []discord.ApplicationCommandOptionChoiceString{ + { + Name: "Normal", + Value: string(lavaqueue.QueueTypeNormal), + }, + { + Name: "Repeat Track", + Value: string(lavaqueue.QueueTypeRepeatTrack), + }, + { + Name: "Repeat Queue", + Value: string(lavaqueue.QueueTypeRepeatQueue), + }, + }, + }, + }, + }, + }, +} + +func (c *commands) OnQueueShow(data discord.SlashCommandInteractionData, e *handler.CommandEvent) error { + player := c.Lavalink.ExistingPlayer(*e.GuildID()) + queue, err := lavaqueue.GetQueue(e.Ctx, player.Node(), *e.GuildID()) + if err != nil { + return e.CreateMessage(res.CreateErr("Failed to get queue", err)) + } + + if len(queue.Tracks) == 0 { + return e.CreateMessage(res.Create("The queue is empty")) + } + + content := fmt.Sprintf("Queue(`%d`):\n", len(queue.Tracks)) + for i, track := range queue.Tracks { + line := fmt.Sprintf("%d. %s\n", i+1, res.FormatTrack(track, 0)) + if len([]rune(content))+len([]rune(line)) > 2000 { + break + } + content += line + } + + return e.CreateMessage(res.Create(content)) +} + +func (c *commands) OnQueueType(data discord.SlashCommandInteractionData, e *handler.CommandEvent) error { + player := c.Lavalink.Player(*e.GuildID()) + queueType := lavaqueue.QueueType(data.String("type")) + + if _, err := lavaqueue.UpdateQueue(e.Ctx, player.Node(), *e.GuildID(), lavaqueue.QueueUpdate{ + Type: &queueType, + }); err != nil { + return e.CreateMessage(res.CreateErr("Failed to update player", err)) + } + + var emoji string + switch queueType { + case lavaqueue.QueueTypeNormal: + emoji = "โžก๏ธ" + case lavaqueue.QueueTypeRepeatTrack: + emoji = "๐Ÿ”‚" + case lavaqueue.QueueTypeRepeatQueue: + emoji = "๐Ÿ”" + } + return e.CreateMessage(discord.MessageCreate{ + Content: fmt.Sprintf("%s Queuetype changed to: %s", emoji, queueType), + }) +} + +func (c *commands) OnQueueShuffle(_ discord.SlashCommandInteractionData, e *handler.CommandEvent) error { + player := c.Lavalink.Player(*e.GuildID()) + if err := lavaqueue.ShuffleQueue(e.Ctx, player.Node(), *e.GuildID()); err != nil { + return e.CreateMessage(res.CreateErr("Failed to shuffle queue", err)) + } + + return e.CreateMessage(res.Createf("๐Ÿ”€ Shuffled queue")) +} + +func (c *commands) OnQueueClear(_ discord.SlashCommandInteractionData, e *handler.CommandEvent) error { + player := c.Lavalink.Player(*e.GuildID()) + if err := lavaqueue.ClearQueue(e.Ctx, player.Node(), *e.GuildID()); err != nil { + return e.CreateMessage(res.CreateErr("Failed to clear queue", err)) + } + + return e.CreateMessage(res.Createf("๐Ÿงน Cleared queue")) +} + +func (c *commands) OnQueueRemove(data discord.SlashCommandInteractionData, e *handler.CommandEvent) error { + trackID := data.Int("song") + + player := c.Lavalink.Player(*e.GuildID()) + if err := lavaqueue.RemoveQueueTrack(e.Ctx, player.Node(), *e.GuildID(), trackID); err != nil { + return e.CreateMessage(res.CreateErr("Failed to remove song", err)) + } + + return e.CreateMessage(res.Createf("Removed song from queue")) +} + +type Track lavalink.Track + +func (t Track) FilterValue() string { + return t.Encoded +} + +func (c *commands) OnQueueAutocomplete(e *handler.AutocompleteEvent) error { + player := c.Lavalink.ExistingPlayer(*e.GuildID()) + queue, err := lavaqueue.GetQueue(e.Ctx, player.Node(), *e.GuildID()) + if err != nil { + return e.AutocompleteResult(nil) + } + + tracks := make([]Track, len(queue.Tracks)) + for i, track := range queue.Tracks { + tracks[i] = Track(track) + } + + ranks := fuzzy.RankFindFold(e.Data.String("query"), tracks) + + choices := make([]discord.AutocompleteChoice, len(ranks)) + for i, rank := range ranks { + choices[i] = discord.AutocompleteChoiceInt{ + Name: res.Trim(fmt.Sprintf("%d. %s", i+1, rank.Target.Info.Title), 100), + Value: rank.OriginalIndex, + } + } + return e.AutocompleteResult(choices) +} diff --git a/service/bot/config.go b/service/bot/config.go index 051680d..cd6745d 100644 --- a/service/bot/config.go +++ b/service/bot/config.go @@ -4,34 +4,79 @@ import ( "fmt" "strings" - "github.com/disgoorg/disgolink/v2/disgolink" + "github.com/disgoorg/disgolink/v3/disgolink" "github.com/disgoorg/snowflake/v2" - "github.com/KittyBot-Org/KittyBotGo/interal/database" + "github.com/KittyBot-Org/KittyBotGo/internal/log" + "github.com/KittyBot-Org/KittyBotGo/service/bot/db" ) type Config struct { - DevMode bool `json:"dev_mode"` - SyncCommands bool `json:"sync_commands"` - GuildIDs []snowflake.ID `json:"guild_ids"` - GatewayURL string `json:"gateway_url"` - RestURL string `json:"rest_url"` - Token string `json:"token"` - LogLevel string `json:"log_level"` - Database database.Config `json:"database"` - Nodes Nodes `json:"nodes"` + Log log.Config `toml:"log"` + Bot BotConfig `toml:"bot"` + Database db.Config `json:"database"` + Nodes Nodes `json:"nodes"` } func (c Config) String() string { - return fmt.Sprintf("\n DevMode: %t,\n Sync Commands: %t,\n Guild IDs: %v,\n Token: %s,\n Log Level: %s,\n Database: %s\n", c.DevMode, c.SyncCommands, c.GuildIDs, strings.Repeat("*", len(c.Token)), c.LogLevel, c.Database) + return fmt.Sprintf("\n Log: %v\n Bot: %s\n Database: %s\n Nodes: %s\n", + c.Log, + c.Bot, + c.Database, + c.Nodes, + ) } -type Nodes []disgolink.NodeConfig +type BotConfig struct { + SyncCommands bool `toml:"sync_commands"` + GuildIDs []snowflake.ID `toml:"guild_ids"` + GatewayURL string `toml:"gateway_url"` + RestURL string `toml:"rest_url"` + Token string `toml:"token"` +} + +func (c BotConfig) String() string { + return fmt.Sprintf("\n SyncCommands: %t\n GuildIDs: %v\n GatewayURL: %s\n RestURL: %s\n Token: %s\n", + c.SyncCommands, + c.GuildIDs, + c.GatewayURL, + c.RestURL, + strings.Repeat("*", len(c.Token)), + ) +} + +type NodeConfig struct { + Name string `toml:"name"` + Address string `toml:"address"` + Password string `toml:"password"` + Secure bool `toml:"secure"` +} + +func (n NodeConfig) String() string { + return fmt.Sprintf("\n Name: %s\n Address: %s\n Password: %s\n Secure: %t\n", + n.Name, + n.Address, + strings.Repeat("*", len(n.Password)), + n.Secure, + ) +} + +func (n NodeConfig) ToLavalink(sessionID string) disgolink.NodeConfig { + return disgolink.NodeConfig{ + Name: n.Name, + Address: n.Address, + Password: n.Password, + Secure: n.Secure, + SessionID: sessionID, + } +} + +type Nodes []NodeConfig func (n Nodes) String() string { - s := "" + var s string for _, node := range n { - s += fmt.Sprintf("\n Name: %s,\n Address: %s,\n Password: %s,\n Secure: %t,\n Session ID: %s\n", node.Name, node.Address, strings.Repeat("*", len(node.Password)), node.Secure, node.SessionID) + s += node.String() } return s } diff --git a/service/bot/db/db.go b/service/bot/db/db.go new file mode 100644 index 0000000..b10c8c9 --- /dev/null +++ b/service/bot/db/db.go @@ -0,0 +1,99 @@ +package db + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/disgoorg/snowflake/v2" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/stdlib" + "github.com/jmoiron/sqlx" +) + +type Config struct { + Host string `toml:"host"` + Port int `toml:"port"` + Username string `toml:"username"` + Password string `toml:"password"` + Database string `toml:"database"` + SSLMode string `toml:"ssl_mode"` +} + +func (c Config) String() string { + return fmt.Sprintf("\n Host: %s\n Port: %dn Username: %s\n Password: %s\n Database: %s\n SSLMode: %s", + c.Host, + c.Port, + c.Username, + strings.Repeat("*", len(c.Password)), + c.Database, + c.SSLMode, + ) +} + +func (c Config) PostgresDataSourceName() string { + return fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=%s", + c.Host, + c.Port, + c.Username, + c.Password, + c.Database, + c.SSLMode, + ) +} + +func New(cfg Config, schema string) (*DB, error) { + pgCfg, err := pgx.ParseConfig(cfg.PostgresDataSourceName()) + if err != nil { + return nil, err + } + + db, err := sqlx.Open("pgx", stdlib.RegisterConnConfig(pgCfg)) + if err != nil { + return nil, fmt.Errorf("failed to open database: %w", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err = db.PingContext(ctx); err != nil { + return nil, fmt.Errorf("failed to ping database: %w", err) + } + + if _, err = db.ExecContext(ctx, schema); err != nil { + return nil, fmt.Errorf("failed to execute schema: %w", err) + } + + return &DB{ + dbx: db, + }, nil +} + +type DB struct { + dbx *sqlx.DB +} + +func (d *DB) Close() error { + return d.dbx.Close() +} + +type PlayTrack struct { + ID int `db:"id"` + Type string `db:"type"` + Name string `db:"name"` +} + +func (d *DB) SearchPlay(userID snowflake.ID, query string, limit int) ([]PlayTrack, error) { + var tracks []PlayTrack + if err := d.dbx.Select(&tracks, `SELECT * FROM( + SELECT id, 'liked_track' as type, track -> 'info' ->> 'title' as name FROM liked_tracks WHERE user_id = $1 + UNION ALL + SELECT id, 'playlist' as type, name FROM playlists WHERE user_id = $1 + UNION ALL + SELECT id, 'play_history' as type, track -> 'info' ->> 'title' as name FROM play_histories WHERE user_id = $1 + ) t ORDER BY name <->> $2 ASC LIMIT $3;`, userID, query, limit); err != nil { + return nil, err + } + + return tracks, nil +} diff --git a/service/bot/db/lavalink.go b/service/bot/db/lavalink.go new file mode 100644 index 0000000..37bf6d9 --- /dev/null +++ b/service/bot/db/lavalink.go @@ -0,0 +1,24 @@ +package db + +import ( + "context" +) + +type LavalinkNode struct { + Name string `db:"name"` + SessionID string `db:"session_id"` +} + +func (d *DB) GetLavalinkNodes(ctx context.Context) ([]LavalinkNode, error) { + var nodes []LavalinkNode + if err := d.dbx.SelectContext(ctx, &nodes, "SELECT * FROM lavalink_nodes"); err != nil { + return nil, err + } + + return nodes, nil +} + +func (d *DB) AddLavalinkNodes(ctx context.Context, nodes []LavalinkNode) error { + _, err := d.dbx.NamedExecContext(ctx, "INSERT INTO lavalink_nodes (name, session_id) VALUES (:name, :session_id) ON CONFLICT DO UPDATE SET session_id = :session_id", nodes) + return err +} diff --git a/interal/database/liked_tracks.go b/service/bot/db/liked_tracks.go similarity index 96% rename from interal/database/liked_tracks.go rename to service/bot/db/liked_tracks.go index f394c1e..718c205 100644 --- a/interal/database/liked_tracks.go +++ b/service/bot/db/liked_tracks.go @@ -1,7 +1,7 @@ -package database +package db import ( - "github.com/disgoorg/disgolink/v2/lavalink" + "github.com/disgoorg/disgolink/v3/lavalink" "github.com/disgoorg/snowflake/v2" ) diff --git a/interal/database/play_histories.go b/service/bot/db/play_histories.go similarity index 95% rename from interal/database/play_histories.go rename to service/bot/db/play_histories.go index 7f3a155..cee8277 100644 --- a/interal/database/play_histories.go +++ b/service/bot/db/play_histories.go @@ -1,9 +1,9 @@ -package database +package db import ( "time" - "github.com/disgoorg/disgolink/v2/lavalink" + "github.com/disgoorg/disgolink/v3/lavalink" "github.com/disgoorg/snowflake/v2" ) diff --git a/interal/database/playlists.go b/service/bot/db/playlists.go similarity index 97% rename from interal/database/playlists.go rename to service/bot/db/playlists.go index 91ea245..562bacf 100644 --- a/interal/database/playlists.go +++ b/service/bot/db/playlists.go @@ -1,7 +1,7 @@ -package database +package db import ( - "github.com/disgoorg/disgolink/v2/lavalink" + "github.com/disgoorg/disgolink/v3/lavalink" "github.com/disgoorg/snowflake/v2" ) diff --git a/service/bot/handlers.go b/service/bot/handlers.go new file mode 100644 index 0000000..4f48179 --- /dev/null +++ b/service/bot/handlers.go @@ -0,0 +1,46 @@ +package bot + +import ( + "context" + "log/slog" + + "github.com/disgoorg/disgo/bot" + "github.com/disgoorg/disgo/events" + "github.com/disgoorg/disgolink/v3/disgolink" + "github.com/disgoorg/disgolink/v3/lavalink" + "github.com/disgoorg/lavaqueue-plugin" + "github.com/topi314/tint" +) + +func (b *Bot) OnDiscordEvent(event bot.Event) { + switch e := event.(type) { + case *events.VoiceServerUpdate: + slog.Debug("received voice server update") + if e.Endpoint == nil { + return + } + b.Lavalink.OnVoiceServerUpdate(context.Background(), e.GuildID, e.Token, *e.Endpoint) + case *events.GuildVoiceStateUpdate: + if e.VoiceState.UserID != b.Discord.ApplicationID() { + return + } + slog.Debug("received voice state update") + b.Lavalink.OnVoiceStateUpdate(context.Background(), e.VoiceState.GuildID, e.VoiceState.ChannelID, e.VoiceState.SessionID) + } +} + +func (b *Bot) OnLavalinkEvent(p disgolink.Player, event lavalink.Event) { + // player := b.Lavalink.Player(p.GuildID()) + switch e := event.(type) { + case lavaqueue.QueueEndEvent: + slog.Info("queue end", slog.String("guild", p.GuildID().String())) + + case lavalink.TrackStartEvent: + + case lavalink.TrackExceptionEvent: + slog.Error("track exception", tint.Err(e.Exception)) + + case lavalink.TrackStuckEvent: + + } +} diff --git a/service/bot/handlers/commands.go b/service/bot/handlers/commands.go deleted file mode 100644 index 490013a..0000000 --- a/service/bot/handlers/commands.go +++ /dev/null @@ -1,89 +0,0 @@ -package handlers - -import ( - "github.com/disgoorg/disgo/discord" - "github.com/disgoorg/disgo/handler" - - "github.com/KittyBot-Org/KittyBotGo/service/bot" -) - -func New(b *bot.Bot) *Handlers { - handlers := &Handlers{ - Bot: b, - Router: handler.New(), - Commands: []discord.ApplicationCommandCreate{ - pingCommand, - playerCommand, - queueCommand, - historyCommand, - playlistsCommand, - likedSongsCommand, - }, - } - handlers.Command("/ping", handlers.OnPing) - - handlers.Route("/player", func(r handler.Router) { - r.Command("/play", handlers.OnPlayerPlay) - r.Autocomplete("/play", handlers.OnPlayerPlayAutocomplete) - r.Group(func(r handler.Router) { - r.Use(handlers.OnHasPlayer) - r.Command("/status", handlers.OnPlayerStatus) - r.Command("/pause", handlers.OnPlayerPause) - r.Command("/resume", handlers.OnPlayerResume) - r.Command("/stop", handlers.OnPlayerStop) - r.Command("/next", handlers.OnPlayerNext) - r.Command("/previous", handlers.OnPlayerPrevious) - r.Command("/volume", handlers.OnPlayerVolume) - r.Command("/bass-boost", handlers.OnPlayerBassBoost) - - r.Component("/previous", handlers.OnPlayerPreviousButton) - r.Component("/pause_play", handlers.OnPlayerPlayPauseButton) - r.Component("/next", handlers.OnPlayerNextButton) - r.Component("/stop", handlers.OnPlayerStopButton) - }) - }) - handlers.Route("/queue", func(r handler.Router) { - r.Use(handlers.OnHasPlayer) - r.Command("/clear", handlers.OnQueueClear) - r.Command("/remove", handlers.OnQueueRemove) - r.Autocomplete("/remove", handlers.OnQueueAutocomplete) - r.Command("/shuffle", handlers.OnQueueShuffle) - r.Command("/show", handlers.OnQueueShow) - r.Command("/type", handlers.OnQueueType) - }) - handlers.Route("/history", func(r handler.Router) { - r.Use(handlers.OnHasPlayer) - r.Command("/clear", handlers.OnHistoryClear) - r.Command("/show", handlers.OnHistoryShow) - }) - handlers.Route("/playlists", func(r handler.Router) { - r.Command("/list", handlers.OnPlaylistsList) - r.Command("/show", handlers.OnPlaylistShow) - r.Autocomplete("/show", handlers.OnPlaylistAutocomplete) - r.Command("/play", handlers.OnPlaylistPlay) - r.Autocomplete("/play", handlers.OnPlaylistAutocomplete) - r.Command("/create", handlers.OnPlaylistCreate) - r.Command("/delete", handlers.OnPlaylistDelete) - r.Autocomplete("/delete", handlers.OnPlaylistAutocomplete) - r.Command("/add", handlers.OnPlaylistAdd) - r.Autocomplete("/add", handlers.OnPlaylistAutocomplete) - r.Command("/remove", handlers.OnPlaylistRemove) - r.Autocomplete("/remove", handlers.OnPlaylistRemoveAutocomplete) - }) - handlers.Route("/liked-songs", func(r handler.Router) { - r.Command("/show", handlers.OnLikedSongsShow) - //r.Command("/add", handlers.OnLikedSongsAdd) - r.Command("/remove", handlers.OnLikedSongsRemove) - r.Autocomplete("/remove", handlers.OnLikedSongsAutocomplete) - r.Command("/clear", handlers.OnLikedSongsClear) - r.Component("/add", handlers.OnLikedSongsAddButton) - }) - - return handlers -} - -type Handlers struct { - *bot.Bot - handler.Router - Commands []discord.ApplicationCommandCreate -} diff --git a/service/bot/handlers/middlewares.go b/service/bot/handlers/middlewares.go deleted file mode 100644 index 3481e41..0000000 --- a/service/bot/handlers/middlewares.go +++ /dev/null @@ -1,24 +0,0 @@ -package handlers - -import ( - "github.com/disgoorg/disgo/discord" - "github.com/disgoorg/disgo/events" - "github.com/disgoorg/disgo/handler" - - "github.com/KittyBot-Org/KittyBotGo/service/bot/res" -) - -func (h *Handlers) OnHasPlayer(next handler.Handler) handler.Handler { - return func(e *events.InteractionCreate) { - ok, err := h.Database.HasPlayer(*e.GuildID()) - if err != nil { - _ = e.Respond(discord.InteractionResponseTypeCreateMessage, res.CreateErr("Error checking player", err)) - return - } - if !ok { - _ = e.Respond(discord.InteractionResponseTypeCreateMessage, res.CreateError("No player found")) - return - } - next(e) - } -} diff --git a/service/bot/handlers/play.go b/service/bot/handlers/play.go deleted file mode 100644 index 1818d89..0000000 --- a/service/bot/handlers/play.go +++ /dev/null @@ -1,207 +0,0 @@ -package handlers - -import ( - "context" - "fmt" - "regexp" - "strconv" - "strings" - "time" - - "github.com/disgoorg/disgo/discord" - "github.com/disgoorg/disgo/handler" - "github.com/disgoorg/disgolink/v2/disgolink" - "github.com/disgoorg/disgolink/v2/lavalink" - "github.com/disgoorg/snowflake/v2" - "golang.org/x/exp/slices" - - "github.com/KittyBot-Org/KittyBotGo/service/bot/res" -) - -var ( - urlPattern = regexp.MustCompile("^https?://[-a-zA-Z0-9+&@#/%?=~_|!:,.;]*[-a-zA-Z0-9+&@#/%=~_|]?") - searchPattern = regexp.MustCompile(`^(.{2})(search|isrc):(.+)`) - queryTypes = []string{"liked_track", "playlist", "play_history"} -) - -func (h *Handlers) OnPlayerPlay(e *handler.CommandEvent) error { - data := e.SlashCommandInteractionData() - query := data.String("query") - - var ( - id int - loadType string - ) - parts := strings.SplitN(query, ":", 2) - if len(parts) == 2 && slices.Contains(queryTypes, parts[0]) { - if loadID, err := strconv.Atoi(parts[1]); err == nil { - id = loadID - loadType = parts[0] - } - } else if !urlPattern.MatchString(query) && !searchPattern.MatchString(query) { - if source, ok := data.OptString("source"); ok { - query = lavalink.SearchType(source).Apply(query) - } else { - query = lavalink.SearchTypeYouTube.Apply(query) - } - } - - voiceState, ok := h.Discord.Caches().VoiceState(*e.GuildID(), e.User().ID) - if !ok { - return e.CreateMessage(res.CreateError("You are not in a voice channel")) - } - - player := h.Lavalink.Player(*e.GuildID()) - if _, err := h.Database.GetPlayer(*e.GuildID(), player.Node().Config().Name); err != nil { - return e.CreateMessage(res.CreateErr("Failed to get or create player", err)) - } - - if err := e.DeferCreateMessage(false); err != nil { - return err - } - - switch loadType { - case "liked_track": - track, err := h.Database.GetLikedTrack(id) - if err != nil { - _, err = e.UpdateInteractionResponse(res.UpdateErr("Failed to get liked song", err)) - return err - } - return h.handleTracks(context.Background(), e, *voiceState.ChannelID, track.Track) - - case "playlist": - _, playlistTracks, err := h.Database.GetPlaylist(id) - if err != nil { - _, err = e.UpdateInteractionResponse(res.UpdateErr("Failed to get playlist", err)) - return err - } - - tracks := make([]lavalink.Track, len(playlistTracks)) - for i, track := range playlistTracks { - tracks[i] = track.Track - } - return h.handleTracks(context.Background(), e, *voiceState.ChannelID, tracks...) - - case "play_history": - track, err := h.Database.GetPlayHistoryTrack(id) - if err != nil { - _, err = e.UpdateInteractionResponse(res.UpdateErr("Failed to get play history song", err)) - return err - } - - return h.handleTracks(context.Background(), e, *voiceState.ChannelID, track.Track) - } - - go func() { - var loadErr error - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - player.Node().LoadTracksHandler(ctx, query, disgolink.NewResultHandler( - func(track lavalink.Track) { - loadErr = h.handleTracks(ctx, e, *voiceState.ChannelID, track) - }, - func(playlist lavalink.Playlist) { - loadErr = h.handleTracks(ctx, e, *voiceState.ChannelID, playlist.Tracks...) - }, - func(tracks []lavalink.Track) { - loadErr = h.handleTracks(ctx, e, *voiceState.ChannelID, tracks[0]) - }, - func() { - _, loadErr = e.UpdateInteractionResponse(res.UpdateError("No results found for %s", query)) - }, - func(err error) { - _, loadErr = e.UpdateInteractionResponse(res.UpdateErr("An error occurred", err)) - }, - )) - if loadErr != nil { - h.Logger.Errorf("error loading songs: %s", loadErr) - } - }() - - return nil -} - -func (h *Handlers) handleTracks(ctx context.Context, e *handler.CommandEvent, channelID snowflake.ID, tracks ...lavalink.Track) error { - _, ok := h.Discord.Caches().VoiceState(*e.GuildID(), e.ApplicationID()) - if !ok { - if err := h.Discord.UpdateVoiceState(context.Background(), *e.GuildID(), &channelID, false, false); err != nil { - _, err = e.UpdateInteractionResponse(res.UpdateErr("An error occurred", err)) - return err - } - } - player := h.Lavalink.Player(*e.GuildID()) - var ( - content string - likeButton bool - ) - if player.Track() == nil { - track := tracks[0] - tracks = tracks[1:] - - if err := player.Update(ctx, lavalink.WithTrack(track)); err != nil { - _, err = e.UpdateInteractionResponse(res.UpdateErr("An error occurred", err)) - return err - } - content = fmt.Sprintf("โ–ถ Playing: %s", res.FormatTrack(track, 0)) - likeButton = true - } - - if len(tracks) > 0 { - content += fmt.Sprintf("\nAdded %d songs to the queue", len(tracks)) - if err := h.Database.AddQueueTracks(*e.GuildID(), tracks); err != nil { - _, err = e.UpdateInteractionResponse(res.UpdateErr("An error occurred", err)) - return err - } - } - - go func() { - if err := h.Database.AddPlayHistoryTracks(e.User().ID, tracks); err != nil { - h.Logger.Errorf("error adding play history songs: %s", err) - } - }() - - _, err := e.UpdateInteractionResponse(res.UpdatePlayer(content, likeButton)) - return err -} - -func (h *Handlers) OnPlayerPlayAutocomplete(e *handler.AutocompleteEvent) error { - query := e.Data.String("query") - - limit := 24 - if strings.TrimSpace(query) == "" { - limit = 25 - } - - tracks, err := h.Database.SearchPlay(e.User().ID, query, limit) - if err != nil { - h.Logger.Errorf("error searching play: %s", err) - return e.Result(nil) - } - - choices := make([]discord.AutocompleteChoice, 0, 25) - if limit == 24 { - choices = append(choices, discord.AutocompleteChoiceString{ - Name: res.Trim(fmt.Sprintf("๐Ÿ”Ž %s", query), 100), - Value: query, - }) - } - - for _, track := range tracks { - var prefix string - switch track.Type { - case "liked_track": - prefix = "โค " - case "play_history": - prefix = "๐Ÿ•’ " - case "playlist": - prefix = "๐Ÿ“œ " - } - - choices = append(choices, discord.AutocompleteChoiceString{ - Name: res.Trim(prefix+track.Name, 100), - Value: fmt.Sprintf("%s:%d", track.Type, track.ID), - }) - } - - return e.Result(choices) -} diff --git a/service/bot/handlers/queue.go b/service/bot/handlers/queue.go deleted file mode 100644 index 789c954..0000000 --- a/service/bot/handlers/queue.go +++ /dev/null @@ -1,170 +0,0 @@ -package handlers - -import ( - "database/sql" - "errors" - "fmt" - - "github.com/disgoorg/disgo/discord" - "github.com/disgoorg/disgo/handler" - - "github.com/KittyBot-Org/KittyBotGo/interal/database" - "github.com/KittyBot-Org/KittyBotGo/service/bot/res" -) - -var queueCommand = discord.SlashCommandCreate{ - Name: "queue", - Description: "Lets you manage the queue", - Options: []discord.ApplicationCommandOption{ - discord.ApplicationCommandOptionSubCommand{ - Name: "clear", - Description: "Clears the queue", - }, - discord.ApplicationCommandOptionSubCommand{ - Name: "remove", - Description: "Removes a song from the queue", - Options: []discord.ApplicationCommandOption{ - discord.ApplicationCommandOptionInt{ - Name: "song", - Description: "The song to remove", - Required: true, - Autocomplete: true, - }, - }, - }, - discord.ApplicationCommandOptionSubCommand{ - Name: "shuffle", - Description: "Shuffles the queue", - }, - discord.ApplicationCommandOptionSubCommand{ - Name: "show", - Description: "Shows the queue", - }, - discord.ApplicationCommandOptionSubCommand{ - Name: "type", - Description: "Lets you change the queue type", - Options: []discord.ApplicationCommandOption{ - discord.ApplicationCommandOptionInt{ - Name: "type", - Description: "The type of queue", - Required: true, - Choices: []discord.ApplicationCommandOptionChoiceInt{ - { - Name: "Normal", - Value: int(database.QueueTypeNormal), - }, - { - Name: "Repeat Track", - Value: int(database.QueueTypeRepeatTrack), - }, - { - Name: "Repeat Queue", - Value: int(database.QueueTypeRepeatQueue), - }, - }, - }, - }, - }, - }, -} - -func (h *Handlers) OnQueueShow(e *handler.CommandEvent) error { - tracks, err := h.Database.GetQueue(*e.GuildID()) - if err != nil { - return e.CreateMessage(res.CreateErr("Failed to get queue", err)) - } - - if len(tracks) == 0 { - return e.CreateMessage(res.Create("The queue is empty")) - } - - content := fmt.Sprintf("Queue(`%d`):\n", len(tracks)) - for i, track := range tracks { - line := fmt.Sprintf("%d. %s\n", i+1, res.FormatTrack(track.Track, 0)) - if len([]rune(content))+len([]rune(line)) > 2000 { - break - } - content += line - } - - return e.CreateMessage(res.Create(content)) -} - -func (h *Handlers) OnQueueType(e *handler.CommandEvent) error { - data := e.SlashCommandInteractionData() - player := h.Lavalink.Player(*e.GuildID()) - queueType := database.QueueType(data.Int("type")) - - if err := h.Database.UpdatePlayer(database.Player{ - GuildID: *e.GuildID(), - Node: player.Node().Config().Name, - QueueType: queueType, - }); err != nil { - return e.CreateMessage(res.CreateErr("Failed to update player", err)) - } - - var emoji string - switch queueType { - case database.QueueTypeNormal: - emoji = "โžก๏ธ" - case database.QueueTypeRepeatTrack: - emoji = "๐Ÿ”‚" - case database.QueueTypeRepeatQueue: - emoji = "๐Ÿ”" - } - return e.CreateMessage(discord.MessageCreate{ - Content: fmt.Sprintf("%s Queuetype changed to: %s", emoji, queueType), - }) -} - -func (h *Handlers) OnQueueShuffle(e *handler.CommandEvent) error { - err := h.Database.ShuffleQueue(*e.GuildID()) - if errors.Is(err, sql.ErrNoRows) { - return e.CreateMessage(res.CreateError("No more songs in queue")) - } - if err != nil { - return e.CreateMessage(res.CreateErr("Failed to get next song", err)) - } - - return e.CreateMessage(res.Createf("Shuffled queue")) -} - -func (h *Handlers) OnQueueClear(e *handler.CommandEvent) error { - err := h.Database.ClearQueue(*e.GuildID()) - if err != nil { - return e.CreateMessage(res.CreateErr("Failed to clear queue", err)) - } - - return e.CreateMessage(res.Createf("Cleared queue")) -} - -func (h *Handlers) OnQueueRemove(e *handler.CommandEvent) error { - data := e.SlashCommandInteractionData() - trackID := data.Int("song") - - err := h.Database.RemoveQueueTrack(trackID) - if errors.Is(err, sql.ErrNoRows) { - return e.CreateMessage(res.CreateError("No more songs in queue")) - } - if err != nil { - return e.CreateMessage(res.CreateErr("Failed to remove song", err)) - } - - return e.CreateMessage(res.Createf("Removed song from queue")) -} - -func (h *Handlers) OnQueueAutocomplete(e *handler.AutocompleteEvent) error { - tracks, err := h.Database.SearchQueue(*e.GuildID(), e.Data.String("song"), 25) - if err != nil { - return e.Result(nil) - } - - choices := make([]discord.AutocompleteChoice, len(tracks)) - for i, track := range tracks { - choices[i] = discord.AutocompleteChoiceInt{ - Name: res.Trim(fmt.Sprintf("%d. %s", i+1, track.Track.Info.Title), 100), - Value: track.ID, - } - } - return e.Result(choices) -} diff --git a/service/bot/lavalink.go b/service/bot/lavalink.go deleted file mode 100644 index dd76302..0000000 --- a/service/bot/lavalink.go +++ /dev/null @@ -1,51 +0,0 @@ -package bot - -import ( - "context" - - "github.com/disgoorg/disgolink/v2/disgolink" - "github.com/disgoorg/disgolink/v2/lavalink" -) - -func (b *Bot) OnLavalinkEvent(p disgolink.Player, event lavalink.Event) { - player := b.Lavalink.Player(p.GuildID()) - switch e := event.(type) { - case lavalink.TrackStartEvent: - - case lavalink.TrackEndEvent: - if err := b.Database.AddHistoryTracks(p.GuildID(), []lavalink.Track{e.Track}); err != nil { - b.Logger.Error("failed to add history tracks: ", err) - } - if !e.Reason.MayStartNext() { - return - } - - track, err := b.Database.NextQueueTrack(p.GuildID()) - if err != nil { - if err = player.Destroy(context.Background()); err != nil { - b.Logger.Error("failed to destroy player: ", err) - } - return - } - if err = player.Update(context.Background(), lavalink.WithEncodedTrack(track.Track.Encoded)); err != nil { - b.Logger.Error("failed to update player: ", err) - } - - case lavalink.TrackExceptionEvent: - b.Logger.Debug("received track exception event") - - case lavalink.TrackStuckEvent: - b.Logger.Debug("received track stuck event") - } -} - -func (b *Bot) RestorePlayers() { - b.Lavalink.ForPlayers(func(player disgolink.Player) { - voiceState, ok := b.Discord.Caches().VoiceState(player.GuildID(), b.Discord.ApplicationID()) - if !ok { - b.Logger.Error("failed to get voice state") - return - } - player.OnVoiceStateUpdate(context.Background(), voiceState.ChannelID, voiceState.SessionID) - }) -} diff --git a/service/bot/res/track.go b/service/bot/res/track.go index c490cf1..62c604c 100644 --- a/service/bot/res/track.go +++ b/service/bot/res/track.go @@ -3,7 +3,7 @@ package res import ( "fmt" - "github.com/disgoorg/disgolink/v2/lavalink" + "github.com/disgoorg/disgolink/v3/lavalink" ) func FormatTrack(track lavalink.Track, position lavalink.Duration) string { diff --git a/service/bot/sql/migration.sql b/service/bot/sql/migration.sql new file mode 100644 index 0000000..e69de29 diff --git a/service/bot/sql/schema.sql b/service/bot/sql/schema.sql new file mode 100644 index 0000000..56dd822 --- /dev/null +++ b/service/bot/sql/schema.sql @@ -0,0 +1,27 @@ +CREATE TABLE IF NOT EXISTS lavalink_nodes +( + name VARCHAR PRIMARY KEY, + session_id VARCHAR +); + +CREATE TABLE IF NOT EXISTS playlists +( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL, + name VARCHAR NOT NULL +); + +CREATE TABLE IF NOT EXISTS playlist_tracks +( + id BIGSERIAL PRIMARY KEY, + playlist_id BIGINT NOT NULL REFERENCES playlists (id) ON DELETE CASCADE, + position BIGSERIAL NOT NULL, + track JSONB NOT NULL +); + +CREATE TABLE IF NOT EXISTS liked_tracks +( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL, + track JSONB NOT NULL +); \ No newline at end of file