Files
Discodrome/rpc.go
T
deluan 2f846f2a87 fix: truncate long activity fields before sending to Discord
Apply truncateText to Name, Details, State, and LargeText fields.
Apply truncateURL to DetailsURL, StateURL, LargeURL, and SmallURL fields.
This prevents Discord from silently rejecting the entire presence update.
Fixes #16
2026-03-04 12:23:12 -05:00

477 lines
17 KiB
Go

// 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.
//
// References:
// - Gateway Events (official): https://docs.discord.com/developers/events/gateway-events
// - Activity object (community): https://discord-api-types.dev/api/next/discord-api-types-v10/interface/GatewayActivity
// - Presence resources (community): https://docs.discord.food/resources/presence
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"
)
// Image cache TTL constants
const (
imageCacheTTL int64 = 4 * 60 * 60 // 4 hours for track artwork
defaultImageCacheTTL int64 = 48 * 60 * 60 // 48 hours for default Navidrome logo
)
// Scheduler callback payloads for routing
const (
payloadHeartbeat = "heartbeat"
payloadClearActivity = "clear-activity"
)
// discordRPC handles Discord gateway communication and implements WebSocket callbacks.
type discordRPC struct{}
// ============================================================================
// Discord types and constants
// ============================================================================
// Discord WebSocket Gateway constants
const (
heartbeatOpCode = 1 // Heartbeat operation code
gateOpCode = 2 // Identify operation code
presenceOpCode = 3 // Presence update operation code
)
// Discord status_display_type values control how the activity is shown in the member list.
const (
statusDisplayName = 0 // Show activity name in member list
statusDisplayState = 1 // Show state field in member list
statusDisplayDetails = 2 // Show details field in member list
)
const heartbeatInterval = 41 // Heartbeat interval in seconds
// Discord API field length limits
const (
maxTextLength = 128 // Max characters for text fields (details, state, name, large_text)
maxURLLength = 256 // Max characters for URL fields (details_url, state_url, etc.)
)
// truncateText truncates s to maxTextLength runes, appending "…" if truncated.
func truncateText(s string) string {
runes := []rune(s)
if len(runes) <= maxTextLength {
return s
}
return string(runes[:maxTextLength-1]) + "…"
}
// truncateURL returns s unchanged if within maxURLLength, otherwise returns ""
// (a truncated URL would be broken, so we omit it entirely).
func truncateURL(s string) string {
if len(s) <= maxURLLength {
return s
}
return ""
}
// activity represents a Discord activity sent via Gateway opcode 3.
type activity struct {
Name string `json:"name"`
Type int `json:"type"`
Details string `json:"details"`
DetailsURL string `json:"details_url,omitempty"`
State string `json:"state"`
StateURL string `json:"state_url,omitempty"`
Application string `json:"application_id"`
StatusDisplayType int `json:"status_display_type"`
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"`
LargeURL string `json:"large_url,omitempty"`
SmallImage string `json:"small_image,omitempty"`
SmallText string `json:"small_text,omitempty"`
SmallURL string `json:"small_url,omitempty"`
}
// 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"`
}
// ============================================================================
// 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
}
// ============================================================================
// Image Processing
// ============================================================================
// processImage processes an image URL for Discord. Returns the processed image
// string (mp:prefixed) or an error. No fallback logic — the caller handles retries.
func (r *discordRPC) processImage(imageURL, clientID, token string, ttl int64) (string, error) {
if imageURL == "" {
return "", fmt.Errorf("image URL is empty")
}
if strings.HasPrefix(imageURL, "mp:") {
return imageURL, nil
}
// Check cache first
cacheKey := "discord.image." + hashKey(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)
resp, err := host.HTTPSend(host.HTTPRequest{
Method: "POST",
URL: fmt.Sprintf("https://discord.com/api/v9/applications/%s/external-assets", clientID),
Headers: map[string]string{"Authorization": token, "Content-Type": "application/json"},
Body: []byte(body),
})
if err != nil {
pdk.Log(pdk.LogWarn, fmt.Sprintf("HTTP request failed for image processing: %v", err))
return "", fmt.Errorf("failed to process image: %w", err)
}
if resp.StatusCode >= 400 {
return "", fmt.Errorf("failed to process image: HTTP %d", resp.StatusCode)
}
var data []map[string]string
if err := json.Unmarshal(resp.Body, &data); err != nil {
return "", fmt.Errorf("failed to unmarshal image response: %w", err)
}
if len(data) == 0 {
return "", fmt.Errorf("no data returned for image")
}
image := data[0]["external_asset_path"]
if image == "" {
return "", fmt.Errorf("empty external_asset_path for image")
}
processedImage := fmt.Sprintf("mp:%s", 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))
// Truncate text fields to Discord's 128-character limit
data.Name = truncateText(data.Name)
data.Details = truncateText(data.Details)
data.State = truncateText(data.State)
data.Assets.LargeText = truncateText(data.Assets.LargeText)
// Omit URLs that exceed Discord's 256-character limit
data.DetailsURL = truncateURL(data.DetailsURL)
data.StateURL = truncateURL(data.StateURL)
data.Assets.LargeURL = truncateURL(data.Assets.LargeURL)
data.Assets.SmallURL = truncateURL(data.Assets.SmallURL)
// Try track artwork first, fall back to Navidrome logo
usingDefaultImage := false
processedImage, err := r.processImage(data.Assets.LargeImage, clientID, token, imageCacheTTL)
if err != nil {
pdk.Log(pdk.LogWarn, fmt.Sprintf("Failed to process track image for user %s: %v, falling back to default", username, err))
processedImage, err = r.processImage(navidromeLogoURL, clientID, token, defaultImageCacheTTL)
if err != nil {
pdk.Log(pdk.LogWarn, fmt.Sprintf("Failed to process default image for user %s: %v, continuing without image", username, err))
data.Assets.LargeImage = ""
} else {
data.Assets.LargeImage = processedImage
usingDefaultImage = true
}
} else {
data.Assets.LargeImage = processedImage
}
// Only show SmallImage (Navidrome logo overlay) when LargeImage is actual track artwork
if usingDefaultImage || data.Assets.LargeImage == "" {
data.Assets.SmallImage = ""
data.Assets.SmallText = ""
} else if data.Assets.SmallImage != "" {
processedSmall, err := r.processImage(data.Assets.SmallImage, clientID, token, defaultImageCacheTTL)
if err != nil {
pdk.Log(pdk.LogWarn, fmt.Sprintf("Failed to process small image for user %s: %v", username, err))
data.Assets.SmallImage = ""
data.Assets.SmallText = ""
} else {
data.Assets.SmallImage = processedSmall
}
}
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) {
resp, err := host.HTTPSend(host.HTTPRequest{
Method: "GET",
URL: "https://discord.com/api/gateway",
})
if err != nil {
pdk.Log(pdk.LogWarn, fmt.Sprintf("HTTP request failed for Discord gateway: %v", err))
return "", fmt.Errorf("failed to get Discord gateway: %w", err)
}
if resp.StatusCode != 200 {
return "", fmt.Errorf("failed to get Discord gateway: HTTP %d", resp.StatusCode)
}
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
}