Skip to content

Commit fc20e61

Browse files
authored
Merge pull request #1 from exec-astraea/refactor/plain-text-messages
refactor: replace Discord embeds with plain text messages
2 parents b18d555 + e418249 commit fc20e61

5 files changed

Lines changed: 88 additions & 92 deletions

File tree

AGENTS.md

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,9 @@ This is a flat `package main`, one concern per file:
3838
| --------------- | ----------------------------------------------------------------------- |
3939
| `main.go` | Startup, config load, cron loop, the per-check diff/announce. |
4040
| `config.go` | YAML config load (`gopkg.in/yaml.v3`) + resolution/validation. |
41-
| `games.go` | `games` registry: canonical key → per-source slugs, colour, redeem URL. |
41+
| `games.go` | `games` registry: canonical key → per-source slugs, redeem URL. |
4242
| `sources.go` | Fetch + normalize from both upstream APIs, merge/de-dupe. |
43-
| `discord.go` | Build embeds, chunk to Discord limits, POST the webhook. |
43+
| `discord.go` | Render codes as plain text, chunk to 2000 chars, POST the webhook. |
4444
| `state.go` | `data/sent.json` load/save (atomic write). |
4545
| `util.go` | Tiny shared helpers. |
4646
| `discord_test.go` | Unit test for `buildContent` (placeholder/plural expansion). |
@@ -115,8 +115,8 @@ games: # only games listed here are watched
115115

116116
### Pinging a role
117117

118-
To ping a role, put its mention token **in the game's `message`** — the message is
119-
the Discord message body, and mentions only ping from there (never from an embed):
118+
To ping a role, put its mention token **in the game's `message`** — the message
119+
leads the Discord message body, and mentions ping from there:
120120

121121
```yaml
122122
message: "<@&123456789012345678> {count} new codes!" # pings a role
@@ -139,11 +139,14 @@ Advanced → **Developer Mode**, then right-click the role → **Copy ID**).
139139
**no** active codes on its first fetch is still seeded (`state.seed`) so the
140140
next fetch that finds codes is announced as new rather than re-treated as
141141
first-run backlog.
142-
4. New codes become one Discord embed (with a one-click **Redeem** link where the
143-
game supports web redemption), posted to **that game's** webhook
142+
4. New codes are rendered as **plain-text** message(s) (with a raw **Redeem** URL
143+
where the game supports web redemption), posted to **that game's** webhook
144144
(`cfg.Notify[key]`). `buildContent` expands `{count}` and resolves the
145-
`{if-singular:X}`/`{if-plural:X}` blocks in the game's `message`, then sends it
146-
as the message `content`; a `<@&ROLE_ID>` token pings from there.
145+
`{if-singular:X}`/`{if-plural:X}` blocks in the game's `message` to form the
146+
header; `buildMessages` prepends it to the rendered codes, splitting on code
147+
boundaries so no message exceeds 2000 chars. A `<@&ROLE_ID>` token in the
148+
header pings, and only the first message is allowed to (a chunked send never
149+
re-pings).
147150
5. Codes are marked sent **only after their announce post succeeds** (state is
148151
saved once at the end of the check). A failed post leaves its codes unmarked,
149152
so the next check retries them — nothing reached Discord, so this re-tries
@@ -212,9 +215,12 @@ produce codes) — a validation problem in `loadConfig`, like a missing webhook.
212215
must define its own `webhook` and `message` (validated in `loadConfig`).
213216
- **Keep it dependency-light.** Stdlib plus `robfig/cron` and `yaml.v3` only.
214217
Don't pull in an HTTP or Discord SDK.
215-
- **Discord facts worth remembering:** mentions only ping from `content`, never
216-
from an embed; limits are 25 fields/embed and 10 embeds/message — `discord.go`
217-
chunks for both. Preserve that when editing.
218+
- **Discord facts worth remembering:** messages are plain `content` (no embeds).
219+
Masked links (`[text](url)`) do **not** render in normal content — only in
220+
embeds — so redeem URLs are written raw, wrapped in `<>` to suppress the
221+
link-preview card. Content is capped at 2000 chars/message; `discord.go` chunks
222+
on code boundaries. Mentions ping only from the first message. Preserve that
223+
when editing.
218224
- **Runtime state** (`data/`) and the live `config.yaml` (holds webhooks) are
219225
gitignored; committed: source, `Dockerfile`, docs, and `config.example.yaml`.
220226
- **Container runs as uid/gid 1000** and writes only `/app/data`. Keep it non-root.

config.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,11 @@ const defaultUsername = "Hoyo Codes"
1616
// GameNotify is the notification config for one game: where to post, what to
1717
// say, and how the sender appears. Webhook and Message are required; Username
1818
// and AvatarURL are optional per-game overrides. To ping a role, include its
19-
// "<@&ROLE_ID>" token in Message — that is the Discord message body, so mentions
20-
// in it fire.
19+
// "<@&ROLE_ID>" token in Message — it leads the Discord message body, so
20+
// mentions in it fire.
2121
type GameNotify struct {
2222
Webhook string // Discord webhook URL for this game
23-
Message string // content text above the embed; supports {count}
23+
Message string // text leading the announcement; supports {count}
2424
Username string // Discord sender name (defaults to defaultUsername)
2525
AvatarURL string // Discord sender avatar image URL ("" = webhook's own avatar)
2626
}

discord.go

Lines changed: 65 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -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-
2816
type 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("\nRedeem: <%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).
7488
var 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.
8296
func 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 {

games.go

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ type Game struct {
88
Ennead string // slug for api.ennead.cc/mihoyo/<slug>/codes
99
Tori string // slug for hoyo-codes.seria.moe/codes?game=<slug>
1010
Opengacha string // slug for <opengachaBaseUrl>/games/<slug>/codes ("" = not served there)
11-
Color int // Discord embed colour
1211
// Redeem is a web redemption URL template with {code} substituted,
1312
// or "" when the game only supports in-game redemption.
1413
Redeem string
@@ -26,7 +25,6 @@ var games = map[string]Game{
2625
Ennead: "genshin",
2726
Tori: "genshin",
2827
Opengacha: "genshin",
29-
Color: 0xF2C94C,
3028
Redeem: "https://genshin.hoyoverse.com/en/gift?code={code}",
3129
},
3230
"hsr": {
@@ -35,7 +33,6 @@ var games = map[string]Game{
3533
Ennead: "starrail",
3634
Tori: "hkrpg", // torikushiii's slug for Star Rail
3735
Opengacha: "starrail", // OpenGachaCodes slug for Star Rail
38-
Color: 0x9B7BD8,
3936
Redeem: "https://hsr.hoyoverse.com/gift?code={code}",
4037
},
4138
"zzz": {
@@ -44,7 +41,6 @@ var games = map[string]Game{
4441
Ennead: "zenless",
4542
Tori: "nap", // torikushiii's slug for Zenless Zone Zero
4643
Opengacha: "zenless", // OpenGachaCodes slug for Zenless Zone Zero
47-
Color: 0xFFD400,
4844
Redeem: "https://zenless.hoyoverse.com/redemption?code={code}",
4945
},
5046
"honkai3rd": {
@@ -53,7 +49,6 @@ var games = map[string]Game{
5349
Ennead: "honkai",
5450
Tori: "honkai3rd",
5551
Opengacha: "", // OpenGachaCodes doesn't serve Honkai Impact 3rd.
56-
Color: 0x4A9BE0,
5752
Redeem: "", // Honkai Impact 3rd has no public web redemption page.
5853
},
5954
"wuwa": {
@@ -65,7 +60,6 @@ var games = map[string]Game{
6560
Ennead: "",
6661
Tori: "",
6762
Opengacha: "wuwa",
68-
Color: 0x3AC8D4,
6963
Redeem: "", // Wuthering Waves redeems codes in-game (no public web page).
7064
},
7165
"endfield": {
@@ -75,7 +69,6 @@ var games = map[string]Game{
7569
Ennead: "",
7670
Tori: "",
7771
Opengacha: "endfield",
78-
Color: 0xE8763A,
7972
Redeem: "", // Arknights: Endfield redeems codes in-game (no public web page).
8073
},
8174
}

main.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -115,8 +115,9 @@ func runCheck(cfg Config, watched []Game, state *State) {
115115
log.Printf("[%s] first run: marked %d existing code(s) as sent", g.Key, len(fresh))
116116
default:
117117
nc := cfg.Notify[g.Key]
118-
content := buildContent(nc.Message, len(fresh))
119-
if err := postWebhook(nc.Webhook, nc.Username, nc.AvatarURL, content, []embed{buildEmbed(g, fresh)}); err != nil {
118+
header := buildContent(nc.Message, len(fresh))
119+
messages := buildMessages(g, header, fresh)
120+
if err := postWebhook(nc.Webhook, nc.Username, nc.AvatarURL, messages); err != nil {
120121
// Nothing reached Discord, so leave codes unmarked and retry them
121122
// next check — but cap attempts so a persistently failing post
122123
// (bad webhook, a bug) can't retry and log forever. A code that

0 commit comments

Comments
 (0)