From c5af3c1d631d7f82c0d00369a698d877375c1de0 Mon Sep 17 00:00:00 2001 From: deluan Date: Wed, 4 Mar 2026 12:14:58 -0500 Subject: [PATCH 1/3] feat: add truncateText and truncateURL helpers for Discord field limits Discord silently rejects presence updates when text fields exceed 128 characters or URL fields exceed 256 characters. Fixes #16 --- rpc.go | 24 ++++++++++++++++++++++++ rpc_test.go | 51 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/rpc.go b/rpc.go index 7b2c573..bc33b60 100644 --- a/rpc.go +++ b/rpc.go @@ -55,6 +55,30 @@ const ( 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"` diff --git a/rpc_test.go b/rpc_test.go index be5dca5..1b3931b 100644 --- a/rpc_test.go +++ b/rpc_test.go @@ -448,4 +448,55 @@ var _ = Describe("discordRPC", func() { Expect(err).ToNot(HaveOccurred()) }) }) + + Describe("truncateText", func() { + It("returns short strings unchanged", func() { + Expect(truncateText("hello")).To(Equal("hello")) + }) + + It("returns exactly 128-char strings unchanged", func() { + s := strings.Repeat("a", 128) + Expect(truncateText(s)).To(Equal(s)) + }) + + It("truncates strings over 128 chars to 127 + ellipsis", func() { + s := strings.Repeat("a", 200) + result := truncateText(s) + Expect([]rune(result)).To(HaveLen(128)) + Expect(result).To(HaveSuffix("…")) + }) + + It("handles multi-byte characters correctly", func() { + // 130 Japanese characters — each is one rune but 3 bytes + s := strings.Repeat("あ", 130) + result := truncateText(s) + runes := []rune(result) + Expect(runes).To(HaveLen(128)) + Expect(string(runes[127])).To(Equal("…")) + }) + + It("returns empty string unchanged", func() { + Expect(truncateText("")).To(Equal("")) + }) + }) + + Describe("truncateURL", func() { + It("returns short URLs unchanged", func() { + Expect(truncateURL("https://example.com")).To(Equal("https://example.com")) + }) + + It("returns exactly 256-char URLs unchanged", func() { + u := "https://example.com/" + strings.Repeat("a", 236) + Expect(truncateURL(u)).To(Equal(u)) + }) + + It("returns empty string for URLs over 256 chars", func() { + u := "https://example.com/" + strings.Repeat("a", 237) + Expect(truncateURL(u)).To(Equal("")) + }) + + It("returns empty string unchanged", func() { + Expect(truncateURL("")).To(Equal("")) + }) + }) }) -- 2.52.0 From 2f846f2a8760aae9ea2b145623c1747633a711bc Mon Sep 17 00:00:00 2001 From: deluan Date: Wed, 4 Mar 2026 12:17:58 -0500 Subject: [PATCH 2/3] 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 --- rpc.go | 12 ++++++++++++ rpc_test.go | 43 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/rpc.go b/rpc.go index bc33b60..8cc8fea 100644 --- a/rpc.go +++ b/rpc.go @@ -224,6 +224,18 @@ func (r *discordRPC) processImage(imageURL, clientID, token string, ttl int64) ( 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) diff --git a/rpc_test.go b/rpc_test.go index 1b3931b..ba5270d 100644 --- a/rpc_test.go +++ b/rpc_test.go @@ -435,6 +435,49 @@ var _ = Describe("discordRPC", func() { }) Expect(err).ToNot(HaveOccurred()) }) + + It("truncates long text fields and omits long URLs", func() { + host.CacheMock.On("GetString", discordImageKey).Return("mp:cached/art", true, nil).Once() + host.CacheMock.On("GetString", discordImageKey).Return("mp:cached/logo", true, nil).Once() + + longName := strings.Repeat("N", 200) + longTitle := strings.Repeat("T", 200) + longArtist := strings.Repeat("A", 200) + longAlbum := strings.Repeat("B", 200) + longURL := "https://example.com/" + strings.Repeat("x", 237) + + host.WebSocketMock.On("SendText", "testuser", mock.MatchedBy(func(msg string) bool { + // Text fields should be truncated to 128 runes (127 + "…") + truncatedName := strings.Repeat("N", 127) + "…" + truncatedTitle := strings.Repeat("T", 127) + "…" + truncatedArtist := strings.Repeat("A", 127) + "…" + truncatedAlbum := strings.Repeat("B", 127) + "…" + return strings.Contains(msg, truncatedName) && + strings.Contains(msg, truncatedTitle) && + strings.Contains(msg, truncatedArtist) && + strings.Contains(msg, truncatedAlbum) && + !strings.Contains(msg, longURL) // URL should be omitted + })).Return(nil) + + err := r.sendActivity("client123", "testuser", "token123", activity{ + Application: "client123", + Name: longName, + Type: 2, + Details: longTitle, + DetailsURL: longURL, + State: longArtist, + StateURL: longURL, + Assets: activityAssets{ + LargeImage: "https://example.com/art.jpg", + LargeText: longAlbum, + LargeURL: longURL, + SmallImage: navidromeLogoURL, + SmallText: "Navidrome", + SmallURL: longURL, + }, + }) + Expect(err).ToNot(HaveOccurred()) + }) }) Describe("clearActivity", func() { -- 2.52.0 From bb7d6aaa5e5be4d89e93d94bb97e38e50971ff88 Mon Sep 17 00:00:00 2001 From: deluan Date: Wed, 4 Mar 2026 12:44:24 -0500 Subject: [PATCH 3/3] test: use JSON unmarshalling for truncation test assertions Replace fragile strings.Contains checks with proper JSON unmarshalling to validate truncated fields by their exact values and verify URL fields are correctly omitted. --- rpc_test.go | 38 ++++++++++++++++++++++++++++---------- 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/rpc_test.go b/rpc_test.go index ba5270d..352a281 100644 --- a/rpc_test.go +++ b/rpc_test.go @@ -1,6 +1,7 @@ package main import ( + "encoding/json" "errors" "strings" @@ -446,17 +447,34 @@ var _ = Describe("discordRPC", func() { longAlbum := strings.Repeat("B", 200) longURL := "https://example.com/" + strings.Repeat("x", 237) + truncatedName := strings.Repeat("N", 127) + "…" + truncatedTitle := strings.Repeat("T", 127) + "…" + truncatedArtist := strings.Repeat("A", 127) + "…" + truncatedAlbum := strings.Repeat("B", 127) + "…" + host.WebSocketMock.On("SendText", "testuser", mock.MatchedBy(func(msg string) bool { - // Text fields should be truncated to 128 runes (127 + "…") - truncatedName := strings.Repeat("N", 127) + "…" - truncatedTitle := strings.Repeat("T", 127) + "…" - truncatedArtist := strings.Repeat("A", 127) + "…" - truncatedAlbum := strings.Repeat("B", 127) + "…" - return strings.Contains(msg, truncatedName) && - strings.Contains(msg, truncatedTitle) && - strings.Contains(msg, truncatedArtist) && - strings.Contains(msg, truncatedAlbum) && - !strings.Contains(msg, longURL) // URL should be omitted + var message struct { + D json.RawMessage `json:"d"` + } + if err := json.Unmarshal([]byte(msg), &message); err != nil { + return false + } + var presence presencePayload + if err := json.Unmarshal(message.D, &presence); err != nil { + return false + } + if len(presence.Activities) != 1 { + return false + } + act := presence.Activities[0] + return act.Name == truncatedName && + act.Details == truncatedTitle && + act.State == truncatedArtist && + act.Assets.LargeText == truncatedAlbum && + act.DetailsURL == "" && + act.StateURL == "" && + act.Assets.LargeURL == "" && + act.Assets.SmallURL == "" })).Return(nil) err := r.sendActivity("client123", "testuser", "token123", activity{ -- 2.52.0