Skip to content

Commit 276bc9b

Browse files
committed
feat: make send to discord more robust
1 parent 7aa6ce2 commit 276bc9b

File tree

2 files changed

+215
-44
lines changed

2 files changed

+215
-44
lines changed

utils.go

Lines changed: 135 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@ import (
77
"html/template"
88
"log"
99
"net/http"
10+
"regexp"
1011
"strconv"
1112
"strings"
1213
"time"
14+
"io"
1315
)
1416

1517
func debug(s string, x ...interface{}) {
@@ -95,47 +97,125 @@ type DiscordEmbedFooter struct {
9597

9698
// format_discord_embeds builds one or more embeds from GotifyMessage.
9799
// It will split long descriptions into multiple embeds if necessary.
98-
func format_discord_embeds(msg *GotifyMessage) []DiscordEmbed {
99-
title := msg.Title
100-
body := msg.Message
101-
102-
// decide color by priority (example mapping)
103-
color := 0x2ECC71 // green default
104-
switch msg.Priority {
100+
// helper: choose color based on priority
101+
func discordColorForPriority(p uint32) int {
102+
switch p {
105103
case 5:
106-
color = 0xFF0000 // red
104+
return 0xFF0000
107105
case 4:
108-
color = 0xFFA500 // orange
106+
return 0xFFA500
109107
case 3:
110-
color = 0xFFFF00 // yellow
108+
return 0xFFFF00
111109
case 2:
112-
color = 0x3498DB // blue
110+
return 0x3498DB
111+
default:
112+
return 0x2ECC71
113113
}
114+
}
114115

115-
// Discord embed description limit is 4096 chars; split into chunks safely
116-
maxDesc := 3800
117-
runes := []rune(body)
118-
var embeds []DiscordEmbed
119-
for i := 0; i < len(runes); i += maxDesc {
120-
end := i + maxDesc
121-
if end > len(runes) {
122-
end = len(runes)
116+
// helper: compute allowed description rune count per embed
117+
func allowedDescFor(title, footer string) int {
118+
const maxPerEmbed = 6000
119+
const overheadMargin = 200
120+
titleLen := len([]rune(title))
121+
footerLen := len([]rune(footer))
122+
allowed := maxPerEmbed - titleLen - footerLen - overheadMargin
123+
if allowed < 200 {
124+
allowed = 200
125+
}
126+
return allowed
127+
}
128+
129+
// parse message into segments of code blocks and plain text
130+
type segment struct{ isCode bool; lang, text string }
131+
132+
func parseSegments(body string) []segment {
133+
codeRe := regexp.MustCompile("(?s)```.*?```")
134+
idxs := codeRe.FindAllStringIndex(body, -1)
135+
segs := []segment{}
136+
last := 0
137+
for _, id := range idxs {
138+
if id[0] > last {
139+
segs = append(segs, segment{isCode: false, text: body[last:id[0]]})
123140
}
124-
desc := string(runes[i:end])
125-
embed := DiscordEmbed{
126-
Title: title,
127-
Description: desc,
128-
Color: color,
129-
Timestamp: msg.Date,
130-
Footer: &DiscordEmbedFooter{
131-
Text: fmt.Sprintf("Gotify Id: %d", msg.Id),
132-
},
141+
block := body[id[0]:id[1]]
142+
inner := strings.TrimPrefix(strings.TrimSuffix(block, "```"), "```")
143+
lang := ""
144+
code := inner
145+
if n := strings.Index(inner, "\n"); n >= 0 {
146+
lang = strings.TrimSpace(inner[:n])
147+
code = inner[n+1:]
133148
}
134-
// For subsequent chunks, omit the title to avoid repetition
135-
if i > 0 {
136-
embed.Title = ""
149+
segs = append(segs, segment{isCode: true, lang: lang, text: code})
150+
last = id[1]
151+
}
152+
if last < len(body) {
153+
segs = append(segs, segment{isCode: false, text: body[last:]})
154+
}
155+
return segs
156+
}
157+
158+
// build embeds from segments while preserving code blocks
159+
func buildEmbedsFromSegments(title string, segs []segment, footerText string, priority uint32) []DiscordEmbed {
160+
color := discordColorForPriority(priority)
161+
allowed := allowedDescFor(title, footerText)
162+
var embeds []DiscordEmbed
163+
cur := []rune{}
164+
hasTitle := true
165+
flush := func() {
166+
t := ""
167+
if hasTitle {
168+
t = title
169+
}
170+
embeds = append(embeds, DiscordEmbed{Title: t, Description: string(cur), Color: color, Timestamp: "", Footer: &DiscordEmbedFooter{Text: footerText}})
171+
hasTitle = false
172+
cur = []rune{}
173+
allowed = allowedDescFor("", footerText)
174+
}
175+
176+
for _, s := range segs {
177+
if s.isCode {
178+
opener := "```"
179+
if s.lang != "" { opener += s.lang + "\n" } else { opener += "\n" }
180+
closer := "```"
181+
r := []rune(s.text)
182+
i := 0
183+
for i < len(r) {
184+
remaining := allowed - len(cur) - len([]rune(opener)) - len([]rune(closer))
185+
if remaining <= 0 { flush(); continue }
186+
take := remaining
187+
if take > len(r)-i { take = len(r)-i }
188+
frag := string(r[i : i+take])
189+
cur = append(cur, []rune(opener+frag+"\n"+closer)...)
190+
i += take
191+
if i < len(r) { flush() }
192+
}
193+
} else {
194+
r := []rune(s.text)
195+
i := 0
196+
for i < len(r) {
197+
remaining := allowed - len(cur)
198+
if remaining <= 0 { flush(); continue }
199+
take := remaining
200+
if take > len(r)-i { take = len(r)-i }
201+
cur = append(cur, r[i:i+take]...)
202+
i += take
203+
if i < len(r) { flush() }
204+
}
137205
}
138-
embeds = append(embeds, embed)
206+
}
207+
if len(cur) > 0 || len(embeds) == 0 { flush() }
208+
return embeds
209+
}
210+
211+
func format_discord_embeds(msg *GotifyMessage) []DiscordEmbed {
212+
title := msg.Title
213+
footerText := fmt.Sprintf("Gotify Id: %d", msg.Id)
214+
segs := parseSegments(msg.Message)
215+
embeds := buildEmbedsFromSegments(title, segs, footerText, msg.Priority)
216+
// set timestamps to message date
217+
for i := range embeds {
218+
embeds[i].Timestamp = msg.Date
139219
}
140220
return embeds
141221
}
@@ -167,20 +247,19 @@ func send_msg_to_discord(embeds []DiscordEmbed, webhookURL string, username stri
167247
log.Println("Create discord json false")
168248
return
169249
}
170-
body := bytes.NewReader(payloadBytes)
171-
172-
req, err := http.NewRequest("POST", webhookURL, body)
173-
if err != nil {
174-
log.Println("Create discord request false")
175-
return
176-
}
177-
req.Header.Set("Content-Type", "application/json")
178250

179251
// Retry loop with exponential backoff and special handling for 429
180252
var resp *http.Response
181253
var attempt int
182254
maxRetries := 5
183255
for attempt = 0; attempt <= maxRetries; attempt++ {
256+
req, err := http.NewRequest("POST", webhookURL, bytes.NewReader(payloadBytes))
257+
if err != nil {
258+
log.Println("Create discord request false")
259+
return
260+
}
261+
req.Header.Set("Content-Type", "application/json")
262+
184263
resp, err = client.Do(req)
185264
if err != nil {
186265
// network error, retry
@@ -192,7 +271,10 @@ func send_msg_to_discord(embeds []DiscordEmbed, webhookURL string, username stri
192271
// handle 429 (rate limit)
193272
if resp.StatusCode == 429 {
194273
ra := resp.Header.Get("Retry-After")
274+
// read and log a small part of body for debugging
275+
b, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
195276
resp.Body.Close()
277+
log.Printf("discord: rate limited (429). Retry-After=%s; body=%s", ra, strings.TrimSpace(string(b)))
196278
var wait time.Duration
197279
if ra != "" {
198280
// try seconds first
@@ -215,24 +297,33 @@ func send_msg_to_discord(embeds []DiscordEmbed, webhookURL string, username stri
215297

216298
// retry on 5xx server errors
217299
if resp.StatusCode >= 500 && resp.StatusCode < 600 {
300+
// read part of body for debug
301+
b, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
218302
resp.Body.Close()
303+
log.Printf("discord: server error %d, body=%s", resp.StatusCode, strings.TrimSpace(string(b)))
219304
backoff := time.Duration(1<<attempt) * 500 * time.Millisecond
220305
time.Sleep(backoff)
221306
continue
222307
}
223308

224-
// other status codes (2xx or 4xx) - do not retry
225-
resp.Body.Close()
309+
// other status codes (2xx success or 4xx client error) - do not retry
310+
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
311+
b, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
312+
resp.Body.Close()
313+
log.Printf("discord: unexpected status %d. body=%s", resp.StatusCode, strings.TrimSpace(string(b)))
314+
} else {
315+
resp.Body.Close()
316+
}
226317
break
227318
}
228319

229320
if err != nil {
230-
fmt.Printf("Send discord request false: %v\n", err)
321+
log.Printf("discord: request failed: %v", err)
231322
return
232323
}
233324
// if we exhausted retries, log and continue to next batch
234325
if attempt > maxRetries {
235-
fmt.Printf("Send discord request failed after %d attempts\n", maxRetries)
326+
log.Printf("discord: send failed after %d attempts", maxRetries)
236327
}
237328
}
238329
}

utils_test.go

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
package main
2+
3+
import (
4+
"strings"
5+
"testing"
6+
)
7+
8+
func TestFormatDiscordEmbeds_SplitsPlainText(t *testing.T) {
9+
// create a long message (~20k chars)
10+
long := strings.Repeat("ABCDEFGHIJKLMNOPQRSTUVWXYZ\n", 800)
11+
msg := &GotifyMessage{
12+
Id: 1,
13+
Appid: 1,
14+
Message: long,
15+
Title: "Long Message",
16+
Priority: 1,
17+
Date: "2025-10-19T00:00:00Z",
18+
}
19+
20+
embeds := format_discord_embeds(msg)
21+
if len(embeds) <= 1 {
22+
t.Fatalf("expected multiple embeds for long message, got %d", len(embeds))
23+
}
24+
25+
// Check embed sizes (title+desc+footer) under 6000
26+
footer := "Gotify Id: 1"
27+
for i, e := range embeds {
28+
total := len([]rune(e.Title)) + len([]rune(e.Description)) + len([]rune(footer))
29+
if total >= 6000 {
30+
t.Fatalf("embed %d too large: %d runes", i, total)
31+
}
32+
}
33+
}
34+
35+
func TestFormatDiscordEmbeds_PreservesCodeBlocks(t *testing.T) {
36+
// generate a long code block
37+
code := strings.Repeat("line_of_code();\n", 2000)
38+
body := "Some intro text\n```go\n" + code + "```\nSome outro"
39+
msg := &GotifyMessage{
40+
Id: 2,
41+
Appid: 1,
42+
Message: body,
43+
Title: "Code Message",
44+
Priority: 1,
45+
Date: "2025-10-19T00:00:00Z",
46+
}
47+
48+
embeds := format_discord_embeds(msg)
49+
if len(embeds) <= 1 {
50+
t.Fatalf("expected multiple embeds for long code block, got %d", len(embeds))
51+
}
52+
53+
// Collect code fragments by removing fences and newlines
54+
var collected strings.Builder
55+
for _, e := range embeds {
56+
// find fenced blocks in description
57+
parts := strings.Split(e.Description, "```")
58+
for j := 1; j+1 < len(parts); j += 2 {
59+
// parts[j] is inner content possibly starting with lang+newline
60+
inner := parts[j]
61+
// remove first line if it is a language tag
62+
if idx := strings.Index(inner, "\n"); idx >= 0 {
63+
inner = inner[idx+1:]
64+
}
65+
collected.WriteString(inner)
66+
}
67+
}
68+
69+
got := collected.String()
70+
// remove trailing whitespace differences
71+
got = strings.TrimSpace(got)
72+
want := strings.TrimSpace(code)
73+
if !strings.Contains(got, "line_of_code();") {
74+
t.Fatalf("collected code does not look correct, length %d", len(got))
75+
}
76+
// ensure at least some of code preserved and concatenated
77+
if len(got) < len(want)/4 {
78+
t.Fatalf("collected code seems too short: got %d want ~%d", len(got), len(want))
79+
}
80+
}

0 commit comments

Comments
 (0)