Initial commit, copy from examples folder
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
*.wasm
|
||||
*.ndp
|
||||
16
Makefile
Normal file
16
Makefile
Normal file
@@ -0,0 +1,16 @@
|
||||
.PHONY: test build package clean
|
||||
|
||||
PLUGIN_NAME := discord-rich-presence
|
||||
WASM_FILE := plugin.wasm
|
||||
|
||||
test:
|
||||
go test -race ./...
|
||||
|
||||
build:
|
||||
tinygo build -target wasip1 -buildmode=c-shared -o $(WASM_FILE) -scheduler=none .
|
||||
|
||||
package: build
|
||||
zip $(PLUGIN_NAME).ndp $(WASM_FILE) manifest.json
|
||||
|
||||
clean:
|
||||
rm -f $(WASM_FILE) $(PLUGIN_NAME).ndp
|
||||
129
README.md
Normal file
129
README.md
Normal file
@@ -0,0 +1,129 @@
|
||||
# Discord Rich Presence Plugin
|
||||
|
||||
This plugin integrates Navidrome with Discord Rich Presence, displaying your currently playing track in your Discord status. It demonstrates how a Navidrome plugin can maintain real-time connections to external services while remaining completely stateless. Based on the [Navicord](https://github.com/logixism/navicord) project.
|
||||
|
||||
**⚠️ WARNING: This plugin requires storing Discord user tokens, which may violate Discord's Terms of Service. Use at your own risk.**
|
||||
|
||||
## Features
|
||||
|
||||
- Shows currently playing track with title, artist, and album art
|
||||
- Displays playback progress with start/end timestamps
|
||||
- Automatic presence clearing when track finishes
|
||||
- Multi-user support with individual Discord tokens
|
||||
|
||||
## How It Works
|
||||
|
||||
### Plugin Capabilities
|
||||
|
||||
The plugin implements three Navidrome capabilities:
|
||||
|
||||
| Capability | Purpose |
|
||||
|-----------------------|------------------------------------------------------------------------------|
|
||||
| **Scrobbler** | Receives `NowPlaying` events when users start playing tracks |
|
||||
| **WebSocketCallback** | Handles incoming Discord gateway messages (heartbeat ACKs, sequence numbers) |
|
||||
| **SchedulerCallback** | Processes scheduled events for heartbeats and presence clearing |
|
||||
|
||||
### Files
|
||||
|
||||
| File | Description |
|
||||
|--------------------------------|------------------------------------------------------------------------|
|
||||
| [main.go](main.go) | Plugin entry point, scrobbler and scheduler implementations |
|
||||
| [rpc.go](rpc.go) | Discord gateway communication, WebSocket handling, activity management |
|
||||
| [manifest.json](manifest.json) | Plugin metadata and permission declarations |
|
||||
| [Makefile](Makefile) | Build automation |
|
||||
|
||||
### Host Services
|
||||
|
||||
| Service | Usage |
|
||||
|---------------|---------------------------------------------------------------------|
|
||||
| **HTTP** | Discord API calls (gateway discovery, external assets registration) |
|
||||
| **WebSocket** | Persistent connection to Discord gateway |
|
||||
| **Cache** | Sequence numbers, processed image URLs |
|
||||
| **Scheduler** | Recurring heartbeats, one-time presence clearing |
|
||||
| **Artwork** | Track artwork URL resolution |
|
||||
|
||||
### Flow
|
||||
|
||||
1. **Track starts playing** - Navidrome calls `NowPlaying`
|
||||
2. **Plugin connects** - If not already connected, establishes WebSocket to Discord gateway
|
||||
3. **Authentication** - Sends identify payload with user's Discord token
|
||||
4. **Presence update** - Sends activity with track info and processed artwork URL
|
||||
5. **Heartbeat loop** - Recurring scheduler sends heartbeats every 41 seconds to keep connection alive
|
||||
6. **Track ends** - One-time scheduler callback clears presence and disconnects
|
||||
|
||||
### Stateless Design
|
||||
|
||||
Navidrome plugins are stateless - each call creates a fresh instance. This plugin handles that by:
|
||||
|
||||
- **WebSocket connections**: Managed by host, keyed by username
|
||||
- **Sequence numbers**: Stored in cache for heartbeat messages
|
||||
- **Configuration**: Reloaded on every method call
|
||||
- **Artwork URLs**: Cached after processing through Discord's external assets API
|
||||
|
||||
### Image Processing
|
||||
|
||||
Discord requires images to be registered via their external assets API. The plugin:
|
||||
1. Fetches track artwork URL from Navidrome
|
||||
2. Registers it with Discord's API to get an `mp:` prefixed URL
|
||||
3. Caches the result (4 hours for track art, 48 hours for default image)
|
||||
4. Falls back to a default image if artwork is unavailable
|
||||
|
||||
## Configuration
|
||||
|
||||
Configure via the Navidrome UI under **Settings > Plugins > Discord Rich Presence**:
|
||||
|
||||
| Field | Description |
|
||||
|---------------|-----------------------------------------------------------------------------------------------------------------|
|
||||
| **Client ID** | Your Discord Application ID (create at [Discord Developer Portal](https://discord.com/developers/applications)) |
|
||||
| **Users** | Array of username/token pairs mapping Navidrome users to Discord tokens |
|
||||
|
||||
Example JSON configuration:
|
||||
```json
|
||||
{
|
||||
"clientid": "123456789012345678",
|
||||
"users": [
|
||||
{"username": "alice", "token": "discord-token-here"},
|
||||
{"username": "bob", "token": "another-discord-token"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
Although the plugin can be compiled to WebAssembly with standard Go, it is recommended to use
|
||||
[TinyGo](https://tinygo.org/getting-started/install/) for smaller binary size.
|
||||
|
||||
|
||||
```sh
|
||||
# Run tests
|
||||
make test
|
||||
|
||||
# Build plugin.wasm
|
||||
make build
|
||||
|
||||
# Create distributable package
|
||||
make package
|
||||
```
|
||||
|
||||
The `make package` command creates `discord-rich-presence.ndp` containing the compiled WebAssembly module and manifest.
|
||||
|
||||
Manual build:
|
||||
```sh
|
||||
tinygo build -target wasip1 -buildmode=c-shared -o plugin.wasm -scheduler=none .
|
||||
zip discord-rich-presence.ndp plugin.wasm manifest.json
|
||||
```
|
||||
|
||||
Using standard Go:
|
||||
```sh
|
||||
GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o plugin.wasm .
|
||||
zip discord-rich-presence.ndp plugin.wasm manifest.json
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
1. Copy the `discord-rich-presence.ndp` file to your Navidrome plugins folder (default is `plugins/` under the Navidrome data directory).
|
||||
2. Configure the plugin in **Settings > Plugins > Discord Rich Presence**
|
||||
3. Enable the plugin
|
||||
|
||||
There is no need to restart Navidrome; Check the logs for any errors during initialization.
|
||||
|
||||
33
go.mod
Normal file
33
go.mod
Normal file
@@ -0,0 +1,33 @@
|
||||
module discord-rich-presence
|
||||
|
||||
go 1.25
|
||||
|
||||
require (
|
||||
github.com/navidrome/navidrome/plugins/pdk/go v0.0.0-20260202223454-15526b25e5c3
|
||||
github.com/onsi/ginkgo/v2 v2.28.1
|
||||
github.com/onsi/gomega v1.39.1
|
||||
github.com/stretchr/testify v1.11.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/Masterminds/semver/v3 v3.4.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/extism/go-pdk v1.1.3 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/google/pprof v0.0.0-20260202012954-cb029daf43ef // indirect
|
||||
github.com/maruel/natural v1.3.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
github.com/stretchr/objx v0.5.2 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/mod v0.32.0 // indirect
|
||||
golang.org/x/net v0.49.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
golang.org/x/text v0.33.0 // indirect
|
||||
golang.org/x/tools v0.41.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
75
go.sum
Normal file
75
go.sum
Normal file
@@ -0,0 +1,75 @@
|
||||
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
|
||||
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/extism/go-pdk v1.1.3 h1:hfViMPWrqjN6u67cIYRALZTZLk/enSPpNKa+rZ9X2SQ=
|
||||
github.com/extism/go-pdk v1.1.3/go.mod h1:Gz+LIU/YCKnKXhgge8yo5Yu1F/lbv7KtKFkiCSzW/P4=
|
||||
github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs=
|
||||
github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo=
|
||||
github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M=
|
||||
github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk=
|
||||
github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE=
|
||||
github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
|
||||
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/pprof v0.0.0-20260202012954-cb029daf43ef h1:xpF9fUHpoIrrjX24DURVKiwHcFpw19ndIs+FwTSMbno=
|
||||
github.com/google/pprof v0.0.0-20260202012954-cb029daf43ef/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
|
||||
github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE=
|
||||
github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/maruel/natural v1.3.0 h1:VsmCsBmEyrR46RomtgHs5hbKADGRVtliHTyCOLFBpsg=
|
||||
github.com/maruel/natural v1.3.0/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg=
|
||||
github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE=
|
||||
github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A=
|
||||
github.com/navidrome/navidrome/plugins/pdk/go v0.0.0-20260202223454-15526b25e5c3 h1:Lnnc4Qf8/QcyeoW9zslgrGz/4IvoyZ8NCoI0WcDzEmY=
|
||||
github.com/navidrome/navidrome/plugins/pdk/go v0.0.0-20260202223454-15526b25e5c3/go.mod h1:5aedoevIXlwUFuR7kbd/WkjaiLg87D3XUFRGIwDBroo=
|
||||
github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI=
|
||||
github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE=
|
||||
github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28=
|
||||
github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
||||
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
||||
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
||||
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
||||
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
||||
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
219
main.go
Normal file
219
main.go
Normal file
@@ -0,0 +1,219 @@
|
||||
// Discord Rich Presence Plugin for Navidrome
|
||||
//
|
||||
// This plugin integrates Navidrome with Discord Rich Presence. It shows how a plugin can
|
||||
// keep a real-time connection to an external service while remaining completely stateless.
|
||||
//
|
||||
// Capabilities: Scrobbler, SchedulerCallback, WebSocketCallback
|
||||
//
|
||||
// NOTE: This plugin is for demonstration purposes only. It relies on the user's Discord
|
||||
// token being stored in the Navidrome configuration file, which is not secure and may be
|
||||
// against Discord's terms of service. Use it at your own risk.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/plugins/pdk/go/host"
|
||||
"github.com/navidrome/navidrome/plugins/pdk/go/pdk"
|
||||
"github.com/navidrome/navidrome/plugins/pdk/go/scheduler"
|
||||
"github.com/navidrome/navidrome/plugins/pdk/go/scrobbler"
|
||||
"github.com/navidrome/navidrome/plugins/pdk/go/websocket"
|
||||
)
|
||||
|
||||
// Configuration keys
|
||||
const (
|
||||
clientIDKey = "clientid"
|
||||
usersKey = "users"
|
||||
)
|
||||
|
||||
// userToken represents a user-token mapping from the config
|
||||
type userToken struct {
|
||||
Username string `json:"username"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
// discordPlugin implements the scrobbler and scheduler interfaces.
|
||||
type discordPlugin struct{}
|
||||
|
||||
// rpc handles Discord gateway communication (via websockets).
|
||||
var rpc = &discordRPC{}
|
||||
|
||||
// init registers the plugin capabilities
|
||||
func init() {
|
||||
scrobbler.Register(&discordPlugin{})
|
||||
scheduler.Register(&discordPlugin{})
|
||||
websocket.Register(rpc)
|
||||
}
|
||||
|
||||
// getConfig loads the plugin configuration.
|
||||
func getConfig() (clientID string, users map[string]string, err error) {
|
||||
clientID, ok := pdk.GetConfig(clientIDKey)
|
||||
if !ok || clientID == "" {
|
||||
pdk.Log(pdk.LogWarn, "missing ClientID in configuration")
|
||||
return "", nil, nil
|
||||
}
|
||||
|
||||
// Get the users array from config
|
||||
usersJSON, ok := pdk.GetConfig(usersKey)
|
||||
if !ok || usersJSON == "" {
|
||||
pdk.Log(pdk.LogWarn, "no users configured")
|
||||
return clientID, nil, nil
|
||||
}
|
||||
|
||||
// Parse the JSON array
|
||||
var userTokens []userToken
|
||||
if err := json.Unmarshal([]byte(usersJSON), &userTokens); err != nil {
|
||||
pdk.Log(pdk.LogError, fmt.Sprintf("failed to parse users config: %v", err))
|
||||
return clientID, nil, nil
|
||||
}
|
||||
|
||||
if len(userTokens) == 0 {
|
||||
pdk.Log(pdk.LogWarn, "no users configured")
|
||||
return clientID, nil, nil
|
||||
}
|
||||
|
||||
// Build the users map
|
||||
users = make(map[string]string)
|
||||
for _, ut := range userTokens {
|
||||
if ut.Username != "" && ut.Token != "" {
|
||||
users[ut.Username] = ut.Token
|
||||
}
|
||||
}
|
||||
|
||||
if len(users) == 0 {
|
||||
pdk.Log(pdk.LogWarn, "no valid users configured")
|
||||
return clientID, nil, nil
|
||||
}
|
||||
|
||||
return clientID, users, nil
|
||||
}
|
||||
|
||||
// getImageURL retrieves the track artwork URL.
|
||||
func getImageURL(trackID string) string {
|
||||
artworkURL, err := host.ArtworkGetTrackUrl(trackID, 300)
|
||||
if err != nil {
|
||||
pdk.Log(pdk.LogWarn, fmt.Sprintf("Failed to get artwork URL: %v", err))
|
||||
return ""
|
||||
}
|
||||
|
||||
// Don't use localhost URLs
|
||||
if strings.HasPrefix(artworkURL, "http://localhost") {
|
||||
return ""
|
||||
}
|
||||
return artworkURL
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Scrobbler Implementation
|
||||
// ============================================================================
|
||||
|
||||
// IsAuthorized checks if a user is authorized for Discord Rich Presence.
|
||||
func (p *discordPlugin) IsAuthorized(input scrobbler.IsAuthorizedRequest) (bool, error) {
|
||||
_, users, err := getConfig()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to check user authorization: %w", err)
|
||||
}
|
||||
|
||||
_, authorized := users[input.Username]
|
||||
pdk.Log(pdk.LogInfo, fmt.Sprintf("IsAuthorized for user %s: %v", input.Username, authorized))
|
||||
return authorized, nil
|
||||
}
|
||||
|
||||
// NowPlaying sends a now playing notification to Discord.
|
||||
func (p *discordPlugin) NowPlaying(input scrobbler.NowPlayingRequest) error {
|
||||
pdk.Log(pdk.LogInfo, fmt.Sprintf("Setting presence for user %s, track: %s", input.Username, input.Track.Title))
|
||||
|
||||
// Load configuration
|
||||
clientID, users, err := getConfig()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: failed to get config: %v", scrobbler.ScrobblerErrorRetryLater, err)
|
||||
}
|
||||
|
||||
// Check authorization
|
||||
userToken, authorized := users[input.Username]
|
||||
if !authorized {
|
||||
return fmt.Errorf("%w: user '%s' not authorized", scrobbler.ScrobblerErrorNotAuthorized, input.Username)
|
||||
}
|
||||
|
||||
// Connect to Discord
|
||||
if err := rpc.connect(input.Username, userToken); err != nil {
|
||||
return fmt.Errorf("%w: failed to connect to Discord: %v", scrobbler.ScrobblerErrorRetryLater, err)
|
||||
}
|
||||
|
||||
// Cancel any existing completion schedule
|
||||
_ = host.SchedulerCancelSchedule(fmt.Sprintf("%s-clear", input.Username))
|
||||
|
||||
// Calculate timestamps
|
||||
now := time.Now().Unix()
|
||||
startTime := (now - int64(input.Position)) * 1000
|
||||
endTime := startTime + int64(input.Track.Duration)*1000
|
||||
|
||||
// Send activity update
|
||||
if err := rpc.sendActivity(clientID, input.Username, userToken, activity{
|
||||
Application: clientID,
|
||||
Name: "Navidrome",
|
||||
Type: 2, // Listening
|
||||
Details: input.Track.Title,
|
||||
State: input.Track.Artist,
|
||||
Timestamps: activityTimestamps{
|
||||
Start: startTime,
|
||||
End: endTime,
|
||||
},
|
||||
Assets: activityAssets{
|
||||
LargeImage: getImageURL(input.Track.ID),
|
||||
LargeText: input.Track.Album,
|
||||
},
|
||||
}); err != nil {
|
||||
return fmt.Errorf("%w: failed to send activity: %v", scrobbler.ScrobblerErrorRetryLater, err)
|
||||
}
|
||||
|
||||
// Schedule a timer to clear the activity after the track completes
|
||||
remainingSeconds := int32(input.Track.Duration) - input.Position + 5
|
||||
_, err = host.SchedulerScheduleOneTime(remainingSeconds, payloadClearActivity, fmt.Sprintf("%s-clear", input.Username))
|
||||
if err != nil {
|
||||
pdk.Log(pdk.LogWarn, fmt.Sprintf("Failed to schedule completion timer: %v", err))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Scrobble handles scrobble requests (no-op for Discord).
|
||||
func (p *discordPlugin) Scrobble(_ scrobbler.ScrobbleRequest) error {
|
||||
// Discord Rich Presence doesn't need scrobble events
|
||||
return nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Scheduler Callback Implementation
|
||||
// ============================================================================
|
||||
|
||||
// OnCallback handles scheduler callbacks.
|
||||
func (p *discordPlugin) OnCallback(input scheduler.SchedulerCallbackRequest) error {
|
||||
pdk.Log(pdk.LogDebug, fmt.Sprintf("Scheduler callback: id=%s, payload=%s, recurring=%v", input.ScheduleID, input.Payload, input.IsRecurring))
|
||||
|
||||
// Route based on payload
|
||||
switch input.Payload {
|
||||
case payloadHeartbeat:
|
||||
// Heartbeat callback - scheduleId is the username
|
||||
if err := rpc.handleHeartbeatCallback(input.ScheduleID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
case payloadClearActivity:
|
||||
// Clear activity callback - scheduleId is "username-clear"
|
||||
username := strings.TrimSuffix(input.ScheduleID, "-clear")
|
||||
if err := rpc.handleClearActivityCallback(username); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
default:
|
||||
pdk.Log(pdk.LogWarn, fmt.Sprintf("Unknown scheduler callback payload: %s", input.Payload))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func main() {}
|
||||
221
main_test.go
Normal file
221
main_test.go
Normal file
@@ -0,0 +1,221 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/navidrome/navidrome/plugins/pdk/go/host"
|
||||
"github.com/navidrome/navidrome/plugins/pdk/go/pdk"
|
||||
"github.com/navidrome/navidrome/plugins/pdk/go/scheduler"
|
||||
"github.com/navidrome/navidrome/plugins/pdk/go/scrobbler"
|
||||
"github.com/stretchr/testify/mock"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestDiscordPlugin(t *testing.T) {
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Discord Plugin Main Suite")
|
||||
}
|
||||
|
||||
var _ = Describe("discordPlugin", func() {
|
||||
var plugin discordPlugin
|
||||
|
||||
BeforeEach(func() {
|
||||
plugin = discordPlugin{}
|
||||
pdk.ResetMock()
|
||||
host.CacheMock.ExpectedCalls = nil
|
||||
host.CacheMock.Calls = nil
|
||||
host.ConfigMock.ExpectedCalls = nil
|
||||
host.ConfigMock.Calls = nil
|
||||
host.WebSocketMock.ExpectedCalls = nil
|
||||
host.WebSocketMock.Calls = nil
|
||||
host.SchedulerMock.ExpectedCalls = nil
|
||||
host.SchedulerMock.Calls = nil
|
||||
host.ArtworkMock.ExpectedCalls = nil
|
||||
host.ArtworkMock.Calls = nil
|
||||
})
|
||||
|
||||
Describe("getConfig", func() {
|
||||
It("returns config values when properly set", func() {
|
||||
pdk.PDKMock.On("GetConfig", clientIDKey).Return("test-client-id", true)
|
||||
pdk.PDKMock.On("GetConfig", usersKey).Return(`[{"username":"user1","token":"token1"},{"username":"user2","token":"token2"}]`, true)
|
||||
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
|
||||
|
||||
clientID, users, err := getConfig()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(clientID).To(Equal("test-client-id"))
|
||||
Expect(users).To(HaveLen(2))
|
||||
Expect(users["user1"]).To(Equal("token1"))
|
||||
Expect(users["user2"]).To(Equal("token2"))
|
||||
})
|
||||
|
||||
It("returns empty client ID when not set", func() {
|
||||
pdk.PDKMock.On("GetConfig", clientIDKey).Return("", false)
|
||||
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
|
||||
|
||||
clientID, users, err := getConfig()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(clientID).To(BeEmpty())
|
||||
Expect(users).To(BeNil())
|
||||
})
|
||||
|
||||
It("returns nil users when users not configured", func() {
|
||||
pdk.PDKMock.On("GetConfig", clientIDKey).Return("test-client-id", true)
|
||||
pdk.PDKMock.On("GetConfig", usersKey).Return("", false)
|
||||
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
|
||||
|
||||
clientID, users, err := getConfig()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(clientID).To(Equal("test-client-id"))
|
||||
Expect(users).To(BeNil())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("IsAuthorized", func() {
|
||||
BeforeEach(func() {
|
||||
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
|
||||
})
|
||||
|
||||
It("returns true for authorized user", func() {
|
||||
pdk.PDKMock.On("GetConfig", clientIDKey).Return("test-client-id", true)
|
||||
pdk.PDKMock.On("GetConfig", usersKey).Return(`[{"username":"testuser","token":"token123"}]`, true)
|
||||
|
||||
authorized, err := plugin.IsAuthorized(scrobbler.IsAuthorizedRequest{
|
||||
Username: "testuser",
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(authorized).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns false for unauthorized user", func() {
|
||||
pdk.PDKMock.On("GetConfig", clientIDKey).Return("test-client-id", true)
|
||||
pdk.PDKMock.On("GetConfig", usersKey).Return(`[{"username":"otheruser","token":"token123"}]`, true)
|
||||
|
||||
authorized, err := plugin.IsAuthorized(scrobbler.IsAuthorizedRequest{
|
||||
Username: "testuser",
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(authorized).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("NowPlaying", func() {
|
||||
BeforeEach(func() {
|
||||
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
|
||||
})
|
||||
|
||||
It("returns not authorized error when user not in config", func() {
|
||||
pdk.PDKMock.On("GetConfig", clientIDKey).Return("test-client-id", true)
|
||||
pdk.PDKMock.On("GetConfig", usersKey).Return(`[{"username":"otheruser","token":"token"}]`, true)
|
||||
|
||||
err := plugin.NowPlaying(scrobbler.NowPlayingRequest{
|
||||
Username: "testuser",
|
||||
Track: scrobbler.TrackInfo{Title: "Test Song"},
|
||||
})
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(errors.Is(err, scrobbler.ScrobblerErrorNotAuthorized)).To(BeTrue())
|
||||
})
|
||||
|
||||
It("successfully sends now playing update", func() {
|
||||
pdk.PDKMock.On("GetConfig", clientIDKey).Return("test-client-id", true)
|
||||
pdk.PDKMock.On("GetConfig", usersKey).Return(`[{"username":"testuser","token":"test-token"}]`, true)
|
||||
|
||||
// Connect mocks (isConnected check via heartbeat)
|
||||
host.CacheMock.On("GetInt", "discord.seq.testuser").Return(int64(0), false, errors.New("not found"))
|
||||
|
||||
// Mock HTTP GET request for gateway discovery
|
||||
gatewayResp := []byte(`{"url":"wss://gateway.discord.gg"}`)
|
||||
gatewayReq := &pdk.HTTPRequest{}
|
||||
pdk.PDKMock.On("NewHTTPRequest", pdk.MethodGet, "https://discord.com/api/gateway").Return(gatewayReq).Once()
|
||||
pdk.PDKMock.On("Send", gatewayReq).Return(pdk.NewStubHTTPResponse(200, nil, gatewayResp)).Once()
|
||||
|
||||
// Mock WebSocket connection
|
||||
host.WebSocketMock.On("Connect", mock.MatchedBy(func(url string) bool {
|
||||
return strings.Contains(url, "gateway.discord.gg")
|
||||
}), mock.Anything, "testuser").Return("testuser", nil)
|
||||
host.WebSocketMock.On("SendText", "testuser", mock.Anything).Return(nil)
|
||||
host.SchedulerMock.On("ScheduleRecurring", mock.Anything, payloadHeartbeat, "testuser").Return("testuser", nil)
|
||||
|
||||
// Cancel existing clear schedule (may or may not exist)
|
||||
host.SchedulerMock.On("CancelSchedule", "testuser-clear").Return(nil)
|
||||
|
||||
// Image mocks - cache miss, will make HTTP request to Discord
|
||||
host.CacheMock.On("GetString", mock.MatchedBy(func(key string) bool {
|
||||
return strings.HasPrefix(key, "discord.image.")
|
||||
})).Return("", false, nil)
|
||||
host.CacheMock.On("SetString", mock.Anything, mock.Anything, mock.Anything).Return(nil)
|
||||
host.ArtworkMock.On("GetTrackUrl", "track1", int32(300)).Return("https://example.com/art.jpg", nil)
|
||||
|
||||
// Mock HTTP request for Discord external assets API
|
||||
assetsReq := &pdk.HTTPRequest{}
|
||||
pdk.PDKMock.On("NewHTTPRequest", pdk.MethodPost, mock.MatchedBy(func(url string) bool {
|
||||
return strings.Contains(url, "external-assets")
|
||||
})).Return(assetsReq)
|
||||
pdk.PDKMock.On("Send", assetsReq).Return(pdk.NewStubHTTPResponse(200, nil, []byte(`{"key":"test-key"}`)))
|
||||
|
||||
// Schedule clear activity callback
|
||||
host.SchedulerMock.On("ScheduleOneTime", mock.Anything, payloadClearActivity, "testuser-clear").Return("testuser-clear", nil)
|
||||
|
||||
err := plugin.NowPlaying(scrobbler.NowPlayingRequest{
|
||||
Username: "testuser",
|
||||
Position: 10,
|
||||
Track: scrobbler.TrackInfo{
|
||||
ID: "track1",
|
||||
Title: "Test Song",
|
||||
Artist: "Test Artist",
|
||||
Album: "Test Album",
|
||||
Duration: 180,
|
||||
},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Scrobble", func() {
|
||||
It("does nothing (returns nil)", func() {
|
||||
err := plugin.Scrobble(scrobbler.ScrobbleRequest{})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("OnCallback", func() {
|
||||
BeforeEach(func() {
|
||||
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
|
||||
})
|
||||
|
||||
It("handles heartbeat callback", func() {
|
||||
host.CacheMock.On("GetInt", "discord.seq.testuser").Return(int64(42), true, nil)
|
||||
host.WebSocketMock.On("SendText", "testuser", mock.Anything).Return(nil)
|
||||
|
||||
err := plugin.OnCallback(scheduler.SchedulerCallbackRequest{
|
||||
ScheduleID: "testuser",
|
||||
Payload: payloadHeartbeat,
|
||||
IsRecurring: true,
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("handles clearActivity callback", func() {
|
||||
host.WebSocketMock.On("SendText", "testuser", mock.Anything).Return(nil)
|
||||
host.SchedulerMock.On("CancelSchedule", "testuser").Return(nil)
|
||||
host.WebSocketMock.On("CloseConnection", "testuser", int32(1000), "Navidrome disconnect").Return(nil)
|
||||
|
||||
err := plugin.OnCallback(scheduler.SchedulerCallbackRequest{
|
||||
ScheduleID: "testuser-clear",
|
||||
Payload: payloadClearActivity,
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("logs warning for unknown payload", func() {
|
||||
err := plugin.OnCallback(scheduler.SchedulerCallbackRequest{
|
||||
ScheduleID: "testuser",
|
||||
Payload: "unknown",
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
})
|
||||
})
|
||||
98
manifest.json
Normal file
98
manifest.json
Normal file
@@ -0,0 +1,98 @@
|
||||
{
|
||||
"name": "Discord Rich Presence",
|
||||
"author": "Navidrome Team",
|
||||
"version": "0.1.0",
|
||||
"description": "Discord Rich Presence integration for Navidrome",
|
||||
"website": "https://github.com/navidrome/discord-rich-presence-plugin",
|
||||
"permissions": {
|
||||
"users": {
|
||||
"reason": "To process scrobbles on behalf of users"
|
||||
},
|
||||
"http": {
|
||||
"reason": "To communicate with Discord API for gateway discovery and image uploads",
|
||||
"requiredHosts": ["discord.com"]
|
||||
},
|
||||
"websocket": {
|
||||
"reason": "To maintain real-time connection with Discord gateway",
|
||||
"requiredHosts": ["gateway.discord.gg"]
|
||||
},
|
||||
"cache": {
|
||||
"reason": "To store connection state and sequence numbers"
|
||||
},
|
||||
"scheduler": {
|
||||
"reason": "To schedule heartbeat messages and activity clearing"
|
||||
},
|
||||
"artwork": {
|
||||
"reason": "To get track artwork URLs for rich presence display"
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"clientid": {
|
||||
"type": "string",
|
||||
"title": "Discord Application Client ID",
|
||||
"description": "The Client ID from your Discord Developer Application. Create one at https://discord.com/developers/applications",
|
||||
"minLength": 17,
|
||||
"maxLength": 20,
|
||||
"pattern": "^[0-9]+$"
|
||||
},
|
||||
"users": {
|
||||
"type": "array",
|
||||
"title": "User Tokens",
|
||||
"description": "Discord tokens for each Navidrome user. WARNING: Store tokens securely!",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"username": {
|
||||
"type": "string",
|
||||
"title": "Navidrome Username",
|
||||
"description": "The Navidrome username to associate with this Discord token",
|
||||
"minLength": 1
|
||||
},
|
||||
"token": {
|
||||
"type": "string",
|
||||
"title": "Discord Token",
|
||||
"description": "The user's Discord token (keep this secret!)",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"required": ["username", "token"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["clientid", "users"]
|
||||
},
|
||||
"uiSchema": {
|
||||
"type": "VerticalLayout",
|
||||
"elements": [
|
||||
{
|
||||
"type": "Control",
|
||||
"scope": "#/properties/clientid"
|
||||
},
|
||||
{
|
||||
"type": "Control",
|
||||
"scope": "#/properties/users",
|
||||
"options": {
|
||||
"elementLabelProp": "username",
|
||||
"detail": {
|
||||
"type": "HorizontalLayout",
|
||||
"elements": [
|
||||
{
|
||||
"type": "Control",
|
||||
"scope": "#/properties/username"
|
||||
},
|
||||
{
|
||||
"type": "Control",
|
||||
"scope": "#/properties/token"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
400
rpc.go
Normal file
400
rpc.go
Normal file
@@ -0,0 +1,400 @@
|
||||
// Discord Rich Presence Plugin - RPC Communication
|
||||
//
|
||||
// This file handles all Discord gateway communication including WebSocket connections,
|
||||
// presence updates, and heartbeat management. The discordRPC struct implements WebSocket
|
||||
// callback interfaces and encapsulates all Discord communication logic.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/plugins/pdk/go/host"
|
||||
"github.com/navidrome/navidrome/plugins/pdk/go/pdk"
|
||||
"github.com/navidrome/navidrome/plugins/pdk/go/websocket"
|
||||
)
|
||||
|
||||
// Discord WebSocket Gateway constants
|
||||
const (
|
||||
heartbeatOpCode = 1 // Heartbeat operation code
|
||||
gateOpCode = 2 // Identify operation code
|
||||
presenceOpCode = 3 // Presence update operation code
|
||||
)
|
||||
|
||||
const (
|
||||
heartbeatInterval = 41 // Heartbeat interval in seconds
|
||||
defaultImage = "https://i.imgur.com/hb3XPzA.png"
|
||||
)
|
||||
|
||||
// Scheduler callback payloads for routing
|
||||
const (
|
||||
payloadHeartbeat = "heartbeat"
|
||||
payloadClearActivity = "clear-activity"
|
||||
)
|
||||
|
||||
// discordRPC handles Discord gateway communication and implements WebSocket callbacks.
|
||||
type discordRPC struct{}
|
||||
|
||||
// ============================================================================
|
||||
// WebSocket Callback Implementation
|
||||
// ============================================================================
|
||||
|
||||
// OnTextMessage handles incoming WebSocket text messages.
|
||||
func (r *discordRPC) OnTextMessage(input websocket.OnTextMessageRequest) error {
|
||||
return r.handleWebSocketMessage(input.ConnectionID, input.Message)
|
||||
}
|
||||
|
||||
// OnBinaryMessage handles incoming WebSocket binary messages.
|
||||
func (r *discordRPC) OnBinaryMessage(input websocket.OnBinaryMessageRequest) error {
|
||||
pdk.Log(pdk.LogDebug, fmt.Sprintf("Received unexpected binary message for connection '%s'", input.ConnectionID))
|
||||
return nil
|
||||
}
|
||||
|
||||
// OnError handles WebSocket errors.
|
||||
func (r *discordRPC) OnError(input websocket.OnErrorRequest) error {
|
||||
pdk.Log(pdk.LogWarn, fmt.Sprintf("WebSocket error for connection '%s': %s", input.ConnectionID, input.Error))
|
||||
return nil
|
||||
}
|
||||
|
||||
// OnClose handles WebSocket connection closure.
|
||||
func (r *discordRPC) OnClose(input websocket.OnCloseRequest) error {
|
||||
pdk.Log(pdk.LogInfo, fmt.Sprintf("WebSocket connection '%s' closed with code %d: %s", input.ConnectionID, input.Code, input.Reason))
|
||||
return nil
|
||||
}
|
||||
|
||||
// activity represents a Discord activity.
|
||||
type activity struct {
|
||||
Name string `json:"name"`
|
||||
Type int `json:"type"`
|
||||
Details string `json:"details"`
|
||||
State string `json:"state"`
|
||||
Application string `json:"application_id"`
|
||||
Timestamps activityTimestamps `json:"timestamps"`
|
||||
Assets activityAssets `json:"assets"`
|
||||
}
|
||||
|
||||
type activityTimestamps struct {
|
||||
Start int64 `json:"start"`
|
||||
End int64 `json:"end"`
|
||||
}
|
||||
|
||||
type activityAssets struct {
|
||||
LargeImage string `json:"large_image"`
|
||||
LargeText string `json:"large_text"`
|
||||
}
|
||||
|
||||
// presencePayload represents a Discord presence update.
|
||||
type presencePayload struct {
|
||||
Activities []activity `json:"activities"`
|
||||
Since int64 `json:"since"`
|
||||
Status string `json:"status"`
|
||||
Afk bool `json:"afk"`
|
||||
}
|
||||
|
||||
// identifyPayload represents a Discord identify payload.
|
||||
type identifyPayload struct {
|
||||
Token string `json:"token"`
|
||||
Intents int `json:"intents"`
|
||||
Properties identifyProperties `json:"properties"`
|
||||
}
|
||||
|
||||
type identifyProperties struct {
|
||||
OS string `json:"os"`
|
||||
Browser string `json:"browser"`
|
||||
Device string `json:"device"`
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Image Processing
|
||||
// ============================================================================
|
||||
|
||||
// processImage processes an image URL for Discord, with fallback to default image.
|
||||
func (r *discordRPC) processImage(imageURL, clientID, token string, isDefaultImage bool) (string, error) {
|
||||
if imageURL == "" {
|
||||
if isDefaultImage {
|
||||
return "", fmt.Errorf("default image URL is empty")
|
||||
}
|
||||
return r.processImage(defaultImage, clientID, token, true)
|
||||
}
|
||||
|
||||
if strings.HasPrefix(imageURL, "mp:") {
|
||||
return imageURL, nil
|
||||
}
|
||||
|
||||
// Check cache first
|
||||
cacheKey := fmt.Sprintf("discord.image.%x", imageURL)
|
||||
cachedValue, exists, err := host.CacheGetString(cacheKey)
|
||||
if err == nil && exists {
|
||||
pdk.Log(pdk.LogDebug, fmt.Sprintf("Cache hit for image URL: %s", imageURL))
|
||||
return cachedValue, nil
|
||||
}
|
||||
|
||||
// Process via Discord API
|
||||
body := fmt.Sprintf(`{"urls":[%q]}`, imageURL)
|
||||
req := pdk.NewHTTPRequest(pdk.MethodPost, fmt.Sprintf("https://discord.com/api/v9/applications/%s/external-assets", clientID))
|
||||
req.SetHeader("Authorization", token)
|
||||
req.SetHeader("Content-Type", "application/json")
|
||||
req.SetBody([]byte(body))
|
||||
|
||||
resp := req.Send()
|
||||
if resp.Status() >= 400 {
|
||||
if isDefaultImage {
|
||||
return "", fmt.Errorf("failed to process default image: HTTP %d", resp.Status())
|
||||
}
|
||||
return r.processImage(defaultImage, clientID, token, true)
|
||||
}
|
||||
|
||||
var data []map[string]string
|
||||
if err := json.Unmarshal(resp.Body(), &data); err != nil {
|
||||
if isDefaultImage {
|
||||
return "", fmt.Errorf("failed to unmarshal default image response: %w", err)
|
||||
}
|
||||
return r.processImage(defaultImage, clientID, token, true)
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
if isDefaultImage {
|
||||
return "", fmt.Errorf("no data returned for default image")
|
||||
}
|
||||
return r.processImage(defaultImage, clientID, token, true)
|
||||
}
|
||||
|
||||
image := data[0]["external_asset_path"]
|
||||
if image == "" {
|
||||
if isDefaultImage {
|
||||
return "", fmt.Errorf("empty external_asset_path for default image")
|
||||
}
|
||||
return r.processImage(defaultImage, clientID, token, true)
|
||||
}
|
||||
|
||||
processedImage := fmt.Sprintf("mp:%s", image)
|
||||
|
||||
// Cache the processed image URL
|
||||
var ttl int64 = 4 * 60 * 60 // 4 hours for regular images
|
||||
if isDefaultImage {
|
||||
ttl = 48 * 60 * 60 // 48 hours for default image
|
||||
}
|
||||
|
||||
_ = host.CacheSetString(cacheKey, processedImage, ttl)
|
||||
pdk.Log(pdk.LogDebug, fmt.Sprintf("Cached processed image URL for %s (TTL: %ds)", imageURL, ttl))
|
||||
|
||||
return processedImage, nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Activity Management
|
||||
// ============================================================================
|
||||
|
||||
// sendActivity sends an activity update to Discord.
|
||||
func (r *discordRPC) sendActivity(clientID, username, token string, data activity) error {
|
||||
pdk.Log(pdk.LogInfo, fmt.Sprintf("Sending activity for user %s: %s - %s", username, data.Details, data.State))
|
||||
|
||||
processedImage, err := r.processImage(data.Assets.LargeImage, clientID, token, false)
|
||||
if err != nil {
|
||||
pdk.Log(pdk.LogWarn, fmt.Sprintf("Failed to process image for user %s, continuing without image: %v", username, err))
|
||||
data.Assets.LargeImage = ""
|
||||
} else {
|
||||
data.Assets.LargeImage = processedImage
|
||||
}
|
||||
|
||||
presence := presencePayload{
|
||||
Activities: []activity{data},
|
||||
Status: "dnd",
|
||||
Afk: false,
|
||||
}
|
||||
return r.sendMessage(username, presenceOpCode, presence)
|
||||
}
|
||||
|
||||
// clearActivity clears the Discord activity for a user.
|
||||
func (r *discordRPC) clearActivity(username string) error {
|
||||
pdk.Log(pdk.LogInfo, fmt.Sprintf("Clearing activity for user %s", username))
|
||||
return r.sendMessage(username, presenceOpCode, presencePayload{})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Low-level Communication
|
||||
// ============================================================================
|
||||
|
||||
// sendMessage sends a message over the WebSocket connection.
|
||||
func (r *discordRPC) sendMessage(username string, opCode int, payload any) error {
|
||||
message := map[string]any{
|
||||
"op": opCode,
|
||||
"d": payload,
|
||||
}
|
||||
b, err := json.Marshal(message)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal message: %w", err)
|
||||
}
|
||||
|
||||
err = host.WebSocketSendText(username, string(b))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send message: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// getDiscordGateway retrieves the Discord gateway URL.
|
||||
func (r *discordRPC) getDiscordGateway() (string, error) {
|
||||
req := pdk.NewHTTPRequest(pdk.MethodGet, "https://discord.com/api/gateway")
|
||||
resp := req.Send()
|
||||
if resp.Status() != 200 {
|
||||
return "", fmt.Errorf("failed to get Discord gateway: HTTP %d", resp.Status())
|
||||
}
|
||||
|
||||
var result map[string]string
|
||||
if err := json.Unmarshal(resp.Body(), &result); err != nil {
|
||||
return "", fmt.Errorf("failed to parse Discord gateway response: %w", err)
|
||||
}
|
||||
return result["url"], nil
|
||||
}
|
||||
|
||||
// sendHeartbeat sends a heartbeat to Discord.
|
||||
func (r *discordRPC) sendHeartbeat(username string) error {
|
||||
seqNum, _, err := host.CacheGetInt(fmt.Sprintf("discord.seq.%s", username))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get sequence number: %w", err)
|
||||
}
|
||||
|
||||
pdk.Log(pdk.LogDebug, fmt.Sprintf("Sending heartbeat for user %s: %d", username, seqNum))
|
||||
return r.sendMessage(username, heartbeatOpCode, seqNum)
|
||||
}
|
||||
|
||||
// cleanupFailedConnection cleans up a failed Discord connection.
|
||||
func (r *discordRPC) cleanupFailedConnection(username string) {
|
||||
pdk.Log(pdk.LogInfo, fmt.Sprintf("Cleaning up failed connection for user %s", username))
|
||||
|
||||
// Cancel the heartbeat schedule
|
||||
if err := host.SchedulerCancelSchedule(username); err != nil {
|
||||
pdk.Log(pdk.LogWarn, fmt.Sprintf("Failed to cancel heartbeat schedule for user %s: %v", username, err))
|
||||
}
|
||||
|
||||
// Close the WebSocket connection
|
||||
if err := host.WebSocketCloseConnection(username, 1000, "Connection lost"); err != nil {
|
||||
pdk.Log(pdk.LogWarn, fmt.Sprintf("Failed to close WebSocket connection for user %s: %v", username, err))
|
||||
}
|
||||
|
||||
// Clean up cache entries
|
||||
_ = host.CacheRemove(fmt.Sprintf("discord.seq.%s", username))
|
||||
|
||||
pdk.Log(pdk.LogInfo, fmt.Sprintf("Cleaned up connection for user %s", username))
|
||||
}
|
||||
|
||||
// isConnected checks if a user is connected to Discord by testing the heartbeat.
|
||||
func (r *discordRPC) isConnected(username string) bool {
|
||||
err := r.sendHeartbeat(username)
|
||||
if err != nil {
|
||||
pdk.Log(pdk.LogDebug, fmt.Sprintf("Heartbeat test failed for user %s: %v", username, err))
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// connect establishes a connection to Discord for a user.
|
||||
func (r *discordRPC) connect(username, token string) error {
|
||||
if r.isConnected(username) {
|
||||
pdk.Log(pdk.LogInfo, fmt.Sprintf("Reusing existing connection for user %s", username))
|
||||
return nil
|
||||
}
|
||||
pdk.Log(pdk.LogInfo, fmt.Sprintf("Creating new connection for user %s", username))
|
||||
|
||||
// Get Discord Gateway URL
|
||||
gateway, err := r.getDiscordGateway()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get Discord gateway: %w", err)
|
||||
}
|
||||
pdk.Log(pdk.LogDebug, fmt.Sprintf("Using gateway: %s", gateway))
|
||||
|
||||
// Connect to Discord Gateway
|
||||
_, err = host.WebSocketConnect(gateway, nil, username)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to WebSocket: %w", err)
|
||||
}
|
||||
|
||||
// Send identify payload
|
||||
payload := identifyPayload{
|
||||
Token: token,
|
||||
Intents: 0,
|
||||
Properties: identifyProperties{
|
||||
OS: "Windows 10",
|
||||
Browser: "Discord Client",
|
||||
Device: "Discord Client",
|
||||
},
|
||||
}
|
||||
if err := r.sendMessage(username, gateOpCode, payload); err != nil {
|
||||
return fmt.Errorf("failed to send identify payload: %w", err)
|
||||
}
|
||||
|
||||
// Schedule heartbeats for this user/connection
|
||||
cronExpr := fmt.Sprintf("@every %ds", heartbeatInterval)
|
||||
scheduleID, err := host.SchedulerScheduleRecurring(cronExpr, payloadHeartbeat, username)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to schedule heartbeat: %w", err)
|
||||
}
|
||||
pdk.Log(pdk.LogInfo, fmt.Sprintf("Scheduled heartbeat for user %s with ID %s", username, scheduleID))
|
||||
|
||||
pdk.Log(pdk.LogInfo, fmt.Sprintf("Successfully authenticated user %s", username))
|
||||
return nil
|
||||
}
|
||||
|
||||
// disconnect closes the Discord connection for a user.
|
||||
func (r *discordRPC) disconnect(username string) error {
|
||||
if err := host.SchedulerCancelSchedule(username); err != nil {
|
||||
return fmt.Errorf("failed to cancel schedule: %w", err)
|
||||
}
|
||||
|
||||
if err := host.WebSocketCloseConnection(username, 1000, "Navidrome disconnect"); err != nil {
|
||||
return fmt.Errorf("failed to close WebSocket connection: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleWebSocketMessage processes incoming WebSocket messages from Discord.
|
||||
func (r *discordRPC) handleWebSocketMessage(connectionID, message string) error {
|
||||
if len(message) < 1024 {
|
||||
pdk.Log(pdk.LogTrace, fmt.Sprintf("Received WebSocket message for connection '%s': %s", connectionID, message))
|
||||
} else {
|
||||
pdk.Log(pdk.LogTrace, fmt.Sprintf("Received WebSocket message for connection '%s' (truncated): %s...", connectionID, message[:1021]))
|
||||
}
|
||||
|
||||
// Parse the message
|
||||
var msg map[string]any
|
||||
if err := json.Unmarshal([]byte(message), &msg); err != nil {
|
||||
return fmt.Errorf("failed to parse WebSocket message: %w", err)
|
||||
}
|
||||
|
||||
// Store sequence number if present
|
||||
if v := msg["s"]; v != nil {
|
||||
seq := int64(v.(float64))
|
||||
pdk.Log(pdk.LogTrace, fmt.Sprintf("Received sequence number for connection '%s': %d", connectionID, seq))
|
||||
if err := host.CacheSetInt(fmt.Sprintf("discord.seq.%s", connectionID), seq, int64(heartbeatInterval*2)); err != nil {
|
||||
return fmt.Errorf("failed to store sequence number for user %s: %w", connectionID, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleHeartbeatCallback processes heartbeat scheduler callbacks.
|
||||
func (r *discordRPC) handleHeartbeatCallback(username string) error {
|
||||
if err := r.sendHeartbeat(username); err != nil {
|
||||
// On first heartbeat failure, immediately clean up the connection
|
||||
pdk.Log(pdk.LogWarn, fmt.Sprintf("Heartbeat failed for user %s, cleaning up connection: %v", username, err))
|
||||
r.cleanupFailedConnection(username)
|
||||
return fmt.Errorf("heartbeat failed, connection cleaned up: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleClearActivityCallback processes clear activity scheduler callbacks.
|
||||
func (r *discordRPC) handleClearActivityCallback(username string) error {
|
||||
pdk.Log(pdk.LogInfo, fmt.Sprintf("Removing presence for user %s", username))
|
||||
if err := r.clearActivity(username); err != nil {
|
||||
return fmt.Errorf("failed to clear activity: %w", err)
|
||||
}
|
||||
|
||||
pdk.Log(pdk.LogInfo, fmt.Sprintf("Disconnecting user %s", username))
|
||||
if err := r.disconnect(username); err != nil {
|
||||
return fmt.Errorf("failed to disconnect from Discord: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
279
rpc_test.go
Normal file
279
rpc_test.go
Normal file
@@ -0,0 +1,279 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/plugins/pdk/go/host"
|
||||
"github.com/navidrome/navidrome/plugins/pdk/go/pdk"
|
||||
"github.com/navidrome/navidrome/plugins/pdk/go/websocket"
|
||||
"github.com/stretchr/testify/mock"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("discordRPC", func() {
|
||||
var r *discordRPC
|
||||
|
||||
BeforeEach(func() {
|
||||
r = &discordRPC{}
|
||||
pdk.ResetMock()
|
||||
host.CacheMock.ExpectedCalls = nil
|
||||
host.CacheMock.Calls = nil
|
||||
host.WebSocketMock.ExpectedCalls = nil
|
||||
host.WebSocketMock.Calls = nil
|
||||
host.SchedulerMock.ExpectedCalls = nil
|
||||
host.SchedulerMock.Calls = nil
|
||||
})
|
||||
|
||||
Describe("sendMessage", func() {
|
||||
It("sends JSON message over WebSocket", func() {
|
||||
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
|
||||
host.WebSocketMock.On("SendText", "testuser", mock.MatchedBy(func(msg string) bool {
|
||||
return strings.Contains(msg, `"op":3`)
|
||||
})).Return(nil)
|
||||
|
||||
err := r.sendMessage("testuser", presenceOpCode, map[string]string{"status": "online"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
host.WebSocketMock.AssertExpectations(GinkgoT())
|
||||
})
|
||||
|
||||
It("returns error when WebSocket send fails", func() {
|
||||
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
|
||||
host.WebSocketMock.On("SendText", mock.Anything, mock.Anything).
|
||||
Return(errors.New("connection closed"))
|
||||
|
||||
err := r.sendMessage("testuser", presenceOpCode, map[string]string{})
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("connection closed"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("sendHeartbeat", func() {
|
||||
It("retrieves sequence number from cache and sends heartbeat", func() {
|
||||
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
|
||||
host.CacheMock.On("GetInt", "discord.seq.testuser").Return(int64(123), true, nil)
|
||||
host.WebSocketMock.On("SendText", "testuser", mock.MatchedBy(func(msg string) bool {
|
||||
return strings.Contains(msg, `"op":1`) && strings.Contains(msg, "123")
|
||||
})).Return(nil)
|
||||
|
||||
err := r.sendHeartbeat("testuser")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
host.CacheMock.AssertExpectations(GinkgoT())
|
||||
host.WebSocketMock.AssertExpectations(GinkgoT())
|
||||
})
|
||||
|
||||
It("returns error when cache get fails", func() {
|
||||
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
|
||||
host.CacheMock.On("GetInt", "discord.seq.testuser").Return(int64(0), false, errors.New("cache error"))
|
||||
|
||||
err := r.sendHeartbeat("testuser")
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("cache error"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("connect", func() {
|
||||
It("establishes WebSocket connection and sends identify payload", func() {
|
||||
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
|
||||
host.CacheMock.On("GetInt", "discord.seq.testuser").Return(int64(0), false, errors.New("not found"))
|
||||
|
||||
// Mock HTTP GET request for gateway discovery
|
||||
gatewayResp := []byte(`{"url":"wss://gateway.discord.gg"}`)
|
||||
httpReq := &pdk.HTTPRequest{}
|
||||
pdk.PDKMock.On("NewHTTPRequest", pdk.MethodGet, "https://discord.com/api/gateway").Return(httpReq)
|
||||
pdk.PDKMock.On("Send", mock.Anything).Return(pdk.NewStubHTTPResponse(200, nil, gatewayResp))
|
||||
|
||||
// Mock WebSocket connection
|
||||
host.WebSocketMock.On("Connect", mock.MatchedBy(func(url string) bool {
|
||||
return strings.Contains(url, "gateway.discord.gg")
|
||||
}), mock.Anything, "testuser").Return("testuser", nil)
|
||||
host.WebSocketMock.On("SendText", "testuser", mock.MatchedBy(func(msg string) bool {
|
||||
return strings.Contains(msg, `"op":2`) && strings.Contains(msg, "test-token")
|
||||
})).Return(nil)
|
||||
host.SchedulerMock.On("ScheduleRecurring", "@every 41s", payloadHeartbeat, "testuser").
|
||||
Return("testuser", nil)
|
||||
|
||||
err := r.connect("testuser", "test-token")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("reuses existing connection if connected", func() {
|
||||
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
|
||||
host.CacheMock.On("GetInt", "discord.seq.testuser").Return(int64(42), true, nil)
|
||||
host.WebSocketMock.On("SendText", "testuser", mock.Anything).Return(nil)
|
||||
|
||||
err := r.connect("testuser", "test-token")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
host.WebSocketMock.AssertNotCalled(GinkgoT(), "Connect", mock.Anything, mock.Anything, mock.Anything)
|
||||
})
|
||||
})
|
||||
|
||||
Describe("disconnect", func() {
|
||||
It("cancels schedule and closes WebSocket connection", func() {
|
||||
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
|
||||
host.SchedulerMock.On("CancelSchedule", "testuser").Return(nil)
|
||||
host.WebSocketMock.On("CloseConnection", "testuser", int32(1000), "Navidrome disconnect").Return(nil)
|
||||
|
||||
err := r.disconnect("testuser")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
host.SchedulerMock.AssertExpectations(GinkgoT())
|
||||
host.WebSocketMock.AssertExpectations(GinkgoT())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("cleanupFailedConnection", func() {
|
||||
It("cancels schedule, closes WebSocket, and clears cache", func() {
|
||||
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
|
||||
host.SchedulerMock.On("CancelSchedule", "testuser").Return(nil)
|
||||
host.WebSocketMock.On("CloseConnection", "testuser", int32(1000), "Connection lost").Return(nil)
|
||||
host.CacheMock.On("Remove", "discord.seq.testuser").Return(nil)
|
||||
|
||||
r.cleanupFailedConnection("testuser")
|
||||
|
||||
host.SchedulerMock.AssertExpectations(GinkgoT())
|
||||
host.WebSocketMock.AssertExpectations(GinkgoT())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("handleHeartbeatCallback", func() {
|
||||
It("sends heartbeat successfully", func() {
|
||||
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
|
||||
host.CacheMock.On("GetInt", "discord.seq.testuser").Return(int64(42), true, nil)
|
||||
host.WebSocketMock.On("SendText", "testuser", mock.Anything).Return(nil)
|
||||
|
||||
err := r.handleHeartbeatCallback("testuser")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("cleans up connection on heartbeat failure", func() {
|
||||
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
|
||||
host.CacheMock.On("GetInt", "discord.seq.testuser").Return(int64(0), false, errors.New("cache miss"))
|
||||
host.SchedulerMock.On("CancelSchedule", "testuser").Return(nil)
|
||||
host.WebSocketMock.On("CloseConnection", "testuser", int32(1000), "Connection lost").Return(nil)
|
||||
host.CacheMock.On("Remove", "discord.seq.testuser").Return(nil)
|
||||
|
||||
err := r.handleHeartbeatCallback("testuser")
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("connection cleaned up"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("handleClearActivityCallback", func() {
|
||||
It("clears activity and disconnects", func() {
|
||||
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
|
||||
host.WebSocketMock.On("SendText", "testuser", mock.MatchedBy(func(msg string) bool {
|
||||
return strings.Contains(msg, `"op":3`) && strings.Contains(msg, `"activities":null`)
|
||||
})).Return(nil)
|
||||
host.SchedulerMock.On("CancelSchedule", "testuser").Return(nil)
|
||||
host.WebSocketMock.On("CloseConnection", "testuser", int32(1000), "Navidrome disconnect").Return(nil)
|
||||
|
||||
err := r.handleClearActivityCallback("testuser")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("WebSocket callbacks", func() {
|
||||
Describe("OnTextMessage", func() {
|
||||
It("handles valid JSON message", func() {
|
||||
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
|
||||
host.CacheMock.On("SetInt", mock.Anything, mock.Anything, mock.Anything).Return(nil)
|
||||
|
||||
err := r.OnTextMessage(websocket.OnTextMessageRequest{
|
||||
ConnectionID: "testuser",
|
||||
Message: `{"s":42}`,
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("returns error for invalid JSON", func() {
|
||||
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
|
||||
err := r.OnTextMessage(websocket.OnTextMessageRequest{
|
||||
ConnectionID: "testuser",
|
||||
Message: `not json`,
|
||||
})
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("OnBinaryMessage", func() {
|
||||
It("handles binary message without error", func() {
|
||||
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
|
||||
err := r.OnBinaryMessage(websocket.OnBinaryMessageRequest{
|
||||
ConnectionID: "testuser",
|
||||
Data: "AQID", // base64 encoded [0x01, 0x02, 0x03]
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("OnError", func() {
|
||||
It("handles error without returning error", func() {
|
||||
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
|
||||
err := r.OnError(websocket.OnErrorRequest{
|
||||
ConnectionID: "testuser",
|
||||
Error: "test error",
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("OnClose", func() {
|
||||
It("handles close without returning error", func() {
|
||||
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
|
||||
err := r.OnClose(websocket.OnCloseRequest{
|
||||
ConnectionID: "testuser",
|
||||
Code: 1000,
|
||||
Reason: "normal close",
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("sendActivity", func() {
|
||||
BeforeEach(func() {
|
||||
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
|
||||
host.CacheMock.On("GetString", mock.MatchedBy(func(key string) bool {
|
||||
return strings.HasPrefix(key, "discord.image.")
|
||||
})).Return("", false, nil)
|
||||
host.CacheMock.On("SetString", mock.Anything, mock.Anything, mock.Anything).Return(nil)
|
||||
|
||||
// Mock HTTP request for Discord external assets API (image processing)
|
||||
// When processImage is called, it makes an HTTP request
|
||||
httpReq := &pdk.HTTPRequest{}
|
||||
pdk.PDKMock.On("NewHTTPRequest", pdk.MethodPost, mock.Anything).Return(httpReq)
|
||||
pdk.PDKMock.On("Send", mock.Anything).Return(pdk.NewStubHTTPResponse(200, nil, []byte(`{"key":"test-key"}`)))
|
||||
})
|
||||
|
||||
It("sends activity update to Discord", func() {
|
||||
host.WebSocketMock.On("SendText", "testuser", mock.MatchedBy(func(msg string) bool {
|
||||
return strings.Contains(msg, `"op":3`) &&
|
||||
strings.Contains(msg, `"name":"Test Song"`) &&
|
||||
strings.Contains(msg, `"state":"Test Artist"`)
|
||||
})).Return(nil)
|
||||
|
||||
err := r.sendActivity("client123", "testuser", "token123", activity{
|
||||
Application: "client123",
|
||||
Name: "Test Song",
|
||||
Type: 2,
|
||||
State: "Test Artist",
|
||||
Details: "Test Album",
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("clearActivity", func() {
|
||||
It("sends presence update with nil activities", func() {
|
||||
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
|
||||
host.WebSocketMock.On("SendText", "testuser", mock.MatchedBy(func(msg string) bool {
|
||||
return strings.Contains(msg, `"op":3`) && strings.Contains(msg, `"activities":null`)
|
||||
})).Return(nil)
|
||||
|
||||
err := r.clearActivity("testuser")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user