fix: truncate long activity fields to prevent Discord rejection (#18)
This commit is contained in:
36
rpc.go
36
rpc.go
@@ -55,6 +55,30 @@ const (
|
|||||||
|
|
||||||
const heartbeatInterval = 41 // Heartbeat interval in seconds
|
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.
|
// activity represents a Discord activity sent via Gateway opcode 3.
|
||||||
type activity struct {
|
type activity struct {
|
||||||
Name string `json:"name"`
|
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 {
|
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))
|
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
|
// Try track artwork first, fall back to Navidrome logo
|
||||||
usingDefaultImage := false
|
usingDefaultImage := false
|
||||||
processedImage, err := r.processImage(data.Assets.LargeImage, clientID, token, imageCacheTTL)
|
processedImage, err := r.processImage(data.Assets.LargeImage, clientID, token, imageCacheTTL)
|
||||||
|
|||||||
112
rpc_test.go
112
rpc_test.go
@@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -435,6 +436,66 @@ var _ = Describe("discordRPC", func() {
|
|||||||
})
|
})
|
||||||
Expect(err).ToNot(HaveOccurred())
|
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() {
|
Describe("clearActivity", func() {
|
||||||
@@ -448,4 +509,55 @@ var _ = Describe("discordRPC", func() {
|
|||||||
Expect(err).ToNot(HaveOccurred())
|
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(""))
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user