diff --git a/rpc.go b/rpc.go index 7b2c573..8cc8fea 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"` @@ -200,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 be5dca5..352a281 100644 --- a/rpc_test.go +++ b/rpc_test.go @@ -1,6 +1,7 @@ package main import ( + "encoding/json" "errors" "strings" @@ -435,6 +436,66 @@ 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) + + 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 { + 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{ + 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() { @@ -448,4 +509,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("")) + }) + }) })