@@ -13,18 +13,6 @@ import (
1313 "time"
1414)
1515
16- type embedField struct {
17- Name string `json:"name"`
18- Value string `json:"value"`
19- Inline bool `json:"inline"`
20- }
21-
22- type embed struct {
23- Title string `json:"title"`
24- Color int `json:"color"`
25- Fields []embedField `json:"fields,omitempty"`
26- }
27-
2816type allowedMentions struct {
2917 // Parse whitelists which mention types in `content` are allowed to ping.
3018 Parse []string `json:"parse"`
@@ -34,51 +22,77 @@ type webhookPayload struct {
3422 Username string `json:"username,omitempty"`
3523 AvatarURL string `json:"avatar_url,omitempty"`
3624 Content string `json:"content,omitempty"`
37- Embeds []embed `json:"embeds"`
3825 AllowedMentions * allowedMentions `json:"allowed_mentions,omitempty"`
3926}
4027
41- // Discord limits: 25 fields per embed, 10 embeds per message.
42- const (
43- maxFieldsPerEmbed = 25
44- maxEmbedsPerMsg = 10
45- )
46-
47- // buildEmbed renders one game's newly-found codes into a Discord embed.
48- func buildEmbed (g Game , codes []Code ) embed {
49- fields := make ([]embedField , 0 , len (codes ))
50- for _ , c := range codes {
51- value := c .Rewards
52- if value == "" {
53- value = "Reward details unavailable"
54- }
55- if g .Redeem != "" {
56- url := strings .ReplaceAll (g .Redeem , "{code}" , c .Code )
57- value += fmt .Sprintf ("\n [Redeem →](%s)" , url )
58- }
59- fields = append (fields , embedField {Name : c .Code , Value : value })
28+ // maxContentLen is Discord's per-message content limit (2000 characters). We
29+ // chunk on code boundaries so a code block is never split across messages.
30+ const maxContentLen = 2000
31+
32+ // renderCode renders one code as a plain-text block. Masked links ([text](url))
33+ // do not render in normal message content — only in embeds — so the redeem URL
34+ // is written raw, wrapped in <> to suppress its link-preview card.
35+ func renderCode (g Game , c Code ) string {
36+ value := c .Rewards
37+ if value == "" {
38+ value = "Reward details unavailable"
6039 }
40+ block := fmt .Sprintf ("**%s**\n %s" , c .Code , value )
41+ if g .Redeem != "" {
42+ url := strings .ReplaceAll (g .Redeem , "{code}" , c .Code )
43+ block += fmt .Sprintf ("\n Redeem: <%s>" , url )
44+ }
45+ return block
46+ }
47+
48+ // buildMessages renders a game's newly-found codes into one or more Discord
49+ // message bodies (plain text, no embeds), splitting on code boundaries so no
50+ // message exceeds maxContentLen. `header`, if non-empty, is the config message
51+ // (with any "<@&ROLE_ID>" ping) and leads the first message so mentions fire.
52+ func buildMessages (g Game , header string , codes []Code ) []string {
6153 plural := "code"
6254 if len (codes ) > 1 {
6355 plural = "codes"
6456 }
65- return embed {
66- Title : fmt .Sprintf ("🎁 New %s %s" , g .Name , plural ),
67- Color : g .Color ,
68- Fields : fields ,
57+ title := fmt .Sprintf ("🎁 **New %s %s**" , g .Name , plural )
58+
59+ var lead strings.Builder
60+ if header != "" {
61+ lead .WriteString (header )
62+ lead .WriteString ("\n " )
63+ }
64+ lead .WriteString (title )
65+
66+ var msgs []string
67+ cur := lead .String ()
68+ for _ , c := range codes {
69+ block := renderCode (g , c )
70+ candidate := cur + "\n \n " + block
71+ // Start a new message when appending would overflow — unless the current
72+ // message is empty (an oversized lone block is sent as-is).
73+ if len (candidate ) > maxContentLen && cur != "" {
74+ msgs = append (msgs , cur )
75+ cur = block
76+ } else {
77+ cur = candidate
78+ }
79+ }
80+ if cur != "" {
81+ msgs = append (msgs , cur )
6982 }
83+ return msgs
7084}
7185
7286// pluralRe matches {if-singular:TEXT} / {if-plural:TEXT} blocks. TEXT runs up to
7387// the first "}", so it must not itself contain "}" (role mentions / emoji are fine).
7488var pluralRe = regexp .MustCompile (`\{if-(singular|plural):([^}]*)\}` )
7589
76- // buildContent renders the message body posted above the embed . It expands
77- // {count} to the number of new codes, then resolves count-conditional blocks:
78- // {if-singular:X} keeps X only when count == 1, {if-plural:X} only when count != 1
79- // (the other is dropped). This handles irregular plurals and non-English forms,
80- // e.g. "{count} {if-singular:code}{if-plural:codes}". Any "<@&ROLE_ID>" token
81- // pings, because this is the Discord message content (never an embed) .
90+ // buildContent renders the config message that leads the first announcement . It
91+ // expands {count} to the number of new codes, then resolves count-conditional
92+ // blocks: {if-singular:X} keeps X only when count == 1, {if-plural:X} only when
93+ // count != 1 (the other is dropped). This handles irregular plurals and
94+ // non-English forms, e.g. "{count} {if-singular:code}{if-plural:codes}". Any
95+ // "<@&ROLE_ID>" token pings, because this becomes Discord message content.
8296func buildContent (message string , count int ) string {
8397 s := strings .ReplaceAll (message , "{count}" , strconv .Itoa (count ))
8498 plural := count != 1
@@ -92,33 +106,15 @@ func buildContent(message string, count int) string {
92106 })
93107}
94108
95- // postWebhook sends embeds to Discord, chunking to respect the 10-embeds and
96- // 25-fields-per-embed limits. `username`/`avatarURL` set the sender identity on
97- // every message (avatarURL "" leaves the webhook's configured avatar). `content`,
98- // if non-empty, is set on the first message so any mentions in it ping (mentions
99- // never ping from embeds).
100- func postWebhook (webhookURL , username , avatarURL , content string , embeds []embed ) error {
101- // Split any embed with too many fields into multiple embeds.
102- var normalized []embed
103- for _ , e := range embeds {
104- if len (e .Fields ) <= maxFieldsPerEmbed {
105- normalized = append (normalized , e )
106- continue
107- }
108- for i := 0 ; i < len (e .Fields ); i += maxFieldsPerEmbed {
109- end := min (i + maxFieldsPerEmbed , len (e .Fields ))
110- chunk := e
111- chunk .Fields = e .Fields [i :end ]
112- normalized = append (normalized , chunk )
113- }
114- }
115-
116- for i := 0 ; i < len (normalized ); i += maxEmbedsPerMsg {
117- end := min (i + maxEmbedsPerMsg , len (normalized ))
118- payload := webhookPayload {Username : username , AvatarURL : avatarURL , Embeds : normalized [i :end ]}
119- // Only set content on the first message so a chunked send doesn't re-ping.
120- if i == 0 && content != "" {
121- payload .Content = content
109+ // postWebhook posts plain-text messages to Discord in order. `username`/
110+ // `avatarURL` set the sender identity on every message (avatarURL "" leaves the
111+ // webhook's configured avatar). Only the first message may ping: the config
112+ // header (the sole carrier of a "<@&ROLE_ID>" token) leads it, and allowed
113+ // mentions are enabled there alone so a chunked send never re-pings.
114+ func postWebhook (webhookURL , username , avatarURL string , messages []string ) error {
115+ for i , msg := range messages {
116+ payload := webhookPayload {Username : username , AvatarURL : avatarURL , Content : msg }
117+ if i == 0 {
122118 payload .AllowedMentions = & allowedMentions {Parse : []string {"roles" , "users" , "everyone" }}
123119 }
124120 if err := postPayload (webhookURL , payload ); err != nil {
0 commit comments