Skip to content

Commit 0221853

Browse files
exec-astraeaclaude
andcommitted
feat: add OpenGachaCodes source and Wuthering Waves support
Wire in a self-hosted OpenGachaCodes instance as an optional third code source, gated on the new `opengachaBaseUrl` config value (empty = disabled, so existing deploys are unchanged). - sources.go: add fetchOpengacha (typed client for /games/<slug>/codes; 404 = no data, not a failure) and generalize fetchCodes to run every source that serves a game, skipping empty-slug sources and erroring only when all attempted ones fail. - games.go: add an Opengacha slug per game; add `wuwa` (Wuthering Waves) as an OpenGachaCodes-only, non-HoYo game (in-game redemption). - config.go: add opengachaBaseUrl; fail startup if a watched game has no reachable source (OpenGachaCodes-only with no base URL set). - Docs: update AGENTS.md, README.md, config.example.yaml. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 0ff17ec commit 0221853

7 files changed

Lines changed: 195 additions & 63 deletions

File tree

AGENTS.md

Lines changed: 31 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,19 @@
11
# hoyo-codes — agent guide
22

3-
A small Go service that watches for new **HoYoverse redemption codes** (Genshin
4-
Impact, Honkai: Star Rail, Honkai Impact 3rd) and posts them to **Discord
5-
webhooks** on a cron schedule. Docker-first, no database, no framework.
3+
A small Go service that watches for new gacha **redemption codes** (Genshin
4+
Impact, Honkai: Star Rail, Zenless Zone Zero, Honkai Impact 3rd, and Wuthering
5+
Waves) and posts them to **Discord webhooks** on a cron schedule. Docker-first,
6+
no database, no framework.
67

7-
Codes are pulled from two community APIs and merged/de-duplicated, so a hiccup on
8-
one source doesn't cause a miss:
8+
Codes are pulled from up to three APIs and merged/de-duplicated, so a hiccup on
9+
one source doesn't cause a miss. A source is skipped for any game it doesn't
10+
serve (empty slug), and it only errors when *every* attempted source fails:
911

10-
- [ennead.cc](https://api.ennead.cc/mihoyo)`/<game>/codes`
11-
- [torikushiii/hoyoverse-api](https://github.com/torikushiii/hoyoverse-api)`hoyo-codes.seria.moe`
12+
- [ennead.cc](https://api.ennead.cc/mihoyo)`/<game>/codes` (HoYoverse only)
13+
- [torikushiii/hoyoverse-api](https://github.com/torikushiii/hoyoverse-api)`hoyo-codes.seria.moe` (HoYoverse only)
14+
- **OpenGachaCodes** — self-hosted read-only API (`opengachaBaseUrl` config).
15+
Supplements the HoYo games it also serves and is the **sole** source for
16+
non-HoYo games like Wuthering Waves. Disabled when `opengachaBaseUrl` is unset.
1217

1318
## Layout
1419

@@ -123,8 +128,9 @@ Advanced → **Developer Mode**, then right-click the role → **Copy ID**).
123128
124129
## How a check works (`runCheck`)
125130

126-
1. For each watched game, `fetchCodes` queries **both** APIs and merges by
127-
upper-cased code. One source failing is tolerated; both failing skips the game.
131+
1. For each watched game, `fetchCodes` queries **every source that serves it**
132+
(empty-slug sources are skipped) and merges by upper-cased code. One source
133+
failing is tolerated; only *all* attempted sources failing skips the game.
128134
2. New codes = fetched codes not in `data/sent.json`. They're marked sent
129135
immediately.
130136
3. First time a game is seen (no state), codes are **marked sent silently** when
@@ -169,7 +175,8 @@ are a fatal error, catching typos). `loadConfig` resolves the file into `Config`
169175
- Each game is self-contained (`GameNotify{Webhook, Message, Username, AvatarURL}`;
170176
`Username` defaults to `defaultUsername`, `AvatarURL` is optional). `loadConfig`
171177
collects validation problems and returns them together: fatal if no games are
172-
configured, or any listed game is missing `webhook` or `message`.
178+
configured, any listed game is missing `webhook` or `message`, or a listed game
179+
has no reachable source (OpenGachaCodes-only with no `opengachaBaseUrl`).
173180

174181
When adding a config knob: add it to `fileConf`, resolve/validate it in
175182
`loadConfig`, and document it in `config.example.yaml` and this file.
@@ -181,11 +188,22 @@ When adding a config knob: add it to `fileConf`, resolve/validate it in
181188
- **torikushiii** — `https://hoyo-codes.seria.moe/codes?game=<slug>`, slugs
182189
`genshin`, `hkrpg`, `nap`, `honkai3rd`. Shape: `{"codes":[{"code","rewards":"A*60;B*5"}]}`
183190
(active only). Rewards get normalized (`A*60;B*5` → `A ×60, B ×5`).
191+
- **OpenGachaCodes** — `<opengachaBaseUrl>/games/<slug>/codes`, slugs `genshin`,
192+
`starrail`, `zenless`, `wuwa` (also endfield/nte, not yet in the registry).
193+
Shape: a flat array `[{"code","rewards":["Astrite x50", ...]}]` (active only).
194+
Self-hosted, no auth/CORS, GET-only, strict paths (no trailing slash). A `404`
195+
is treated as "nothing to contribute" (not a source failure). Reward quantities
196+
use ASCII `x`, not `×`; they're joined with `, ` as-is. `fetchOpengacha` lives
197+
in `sources.go`; the base URL is `opengachaBaseUrl` in config.
184198

185199
The internal key differs from the torikushiii slug for Star Rail (`hsr`/`hkrpg`)
186-
and Zenless (`zzz`/`nap`). Both slugs and reward-normalization live in `games.go` /
187-
`sources.go`. To add another game (e.g. Tears of Themis — ennead `tot` / tori `tot`),
188-
add one entry to the `games` map **and** to `gameOrder`; nothing else needs to change.
200+
and Zenless (`zzz`/`nap`). Per-source slugs live in `games.go`; a `Game` sets a
201+
slug to `""` for any source that doesn't serve it (e.g. `wuwa` has empty
202+
`Ennead`/`Tori` and is OpenGachaCodes-only), and `fetchCodes` skips empty-slug
203+
sources. To add another game, add one entry to the `games` map **and** to
204+
`gameOrder`; nothing else needs to change. A game whose only source is
205+
OpenGachaCodes **fails startup** if `opengachaBaseUrl` is unset (it could never
206+
produce codes) — a validation problem in `loadConfig`, like a missing webhook.
189207

190208
## Conventions
191209

README.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
# hoyo-codes
22

3-
Watches for new **HoYoverse redemption codes** (Genshin Impact, Honkai: Star
4-
Rail, Honkai Impact 3rd) and posts them to **Discord webhooks** on a schedule.
3+
Watches for new **gacha redemption codes** (Genshin Impact, Honkai: Star Rail,
4+
Zenless Zone Zero, Honkai Impact 3rd, and Wuthering Waves) and posts them to
5+
**Discord webhooks** on a schedule. Codes come from community HoYoverse APIs plus
6+
an optional self-hosted [OpenGachaCodes](config.example.yaml) instance
7+
(`opengachaBaseUrl`) — the sole source for non-HoYo games like Wuthering Waves.
58

69
## Run
710

config.example.yaml

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,19 @@ httpTimeout: 20
2929
# (≈5h of retries on the default 30-min schedule).
3030
maxPostAttempts: 10
3131

32+
# Base URL of a self-hosted OpenGachaCodes instance (read-only HTTP JSON API). It
33+
# supplements the built-in HoYoverse sources for the games it also serves
34+
# (genshin, hsr, zzz) and is the SOLE source for non-HoYo games like `wuwa`.
35+
# Leave empty/omit to disable it entirely. No trailing slash needed (trimmed).
36+
# It has no CORS/auth and refreshes ~every 30 min; call it server-side only.
37+
opengachaBaseUrl: "http://localhost:8413"
38+
3239
# Games to watch. ONLY the games listed here are watched — to stop watching one,
3340
# comment it out or delete it (there's no enable/disable flag). Each listed game
3441
# REQUIRES both a `webhook` and a `message` (startup fails otherwise). Known keys:
3542
# genshin (Genshin Impact), hsr (Honkai: Star Rail), zzz (Zenless Zone Zero),
36-
# honkai3rd (Honkai Impact 3rd)
43+
# honkai3rd (Honkai Impact 3rd), wuwa (Wuthering Waves)
44+
# `wuwa` is served ONLY by OpenGachaCodes, so it needs opengachaBaseUrl set above.
3745
games:
3846
genshin:
3947
webhook: "https://discord.com/api/webhooks/xxx/yyy"
@@ -59,3 +67,10 @@ games:
5967
# honkai3rd:
6068
# webhook: "https://discord.com/api/webhooks/ccc/ddd"
6169
# message: "{count} new Honkai Impact 3rd {if-singular:code}{if-plural:codes}"
70+
71+
# Wuthering Waves — codes come only from OpenGachaCodes, so opengachaBaseUrl
72+
# (above) must be set for this game to work. Redeemed in-game (no web link).
73+
# wuwa:
74+
# webhook: "https://discord.com/api/webhooks/eee/fff"
75+
# message: "<@&222222222222222222> {count} new Wuthering Waves {if-singular:code}{if-plural:codes} 🌊"
76+
# username: "Wuthering Waves Codes"

config.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ type Config struct {
3737
MarkExistingOnFirstRun bool // mark active backlog sent on a game's first run
3838
HTTPTimeout int // per-request HTTP timeout, seconds
3939
MaxPostAttempts int // give up announcing a code after this many failed checks
40+
OpengachaBaseURL string // self-hosted OpenGachaCodes base URL ("" = source disabled)
4041
}
4142

4243
// --- on-disk YAML shape ---
@@ -56,6 +57,7 @@ type fileConf struct {
5657
MarkExistingOnFirstRun *bool `yaml:"markExistingOnFirstRun"`
5758
HTTPTimeout int `yaml:"httpTimeout"`
5859
MaxPostAttempts int `yaml:"maxPostAttempts"`
60+
OpengachaBaseURL string `yaml:"opengachaBaseUrl"`
5961
Games map[string]gameConf `yaml:"games"`
6062
}
6163

@@ -103,6 +105,7 @@ func loadConfig(path string) (Config, error) {
103105
MarkExistingOnFirstRun: orBool(fc.MarkExistingOnFirstRun, true),
104106
HTTPTimeout: orInt(fc.HTTPTimeout, 20),
105107
MaxPostAttempts: orInt(fc.MaxPostAttempts, 10),
108+
OpengachaBaseURL: strings.TrimRight(strings.TrimSpace(fc.OpengachaBaseURL), "/"),
106109
Notify: map[string]GameNotify{},
107110
}
108111
cfg.StateFile = filepath.Join(cfg.DataDir, "sent.json")
@@ -130,6 +133,14 @@ func loadConfig(path string) (Config, error) {
130133
if strings.TrimSpace(gc.Message) == "" {
131134
problems = append(problems, fmt.Sprintf("games.%s: message is required", key))
132135
}
136+
// A game with no reachable source (only OpenGachaCodes serves it, but no
137+
// opengachaBaseUrl is set) can never produce codes — fail startup so the
138+
// misconfiguration is loud rather than a silent no-op every check.
139+
g := games[key]
140+
opengachaUsable := g.Opengacha != "" && cfg.OpengachaBaseURL != ""
141+
if g.Ennead == "" && g.Tori == "" && !opengachaUsable {
142+
problems = append(problems, fmt.Sprintf("games.%s: only OpenGachaCodes serves this game — set opengachaBaseUrl to watch it", key))
143+
}
133144
cfg.Games = append(cfg.Games, key)
134145
cfg.Notify[key] = GameNotify{
135146
Webhook: gc.Webhook,

games.go

Lines changed: 47 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -3,52 +3,69 @@ package main
33
// Game holds the canonical key plus the per-source slugs and display metadata.
44
// The Key is what appears in the GAMES env var and the state file.
55
type Game struct {
6-
Key string // canonical internal key (GAMES env, state file)
7-
Name string // human display name
8-
Ennead string // slug for api.ennead.cc/mihoyo/<slug>/codes
9-
Tori string // slug for hoyo-codes.seria.moe/codes?game=<slug>
10-
Color int // Discord embed colour
6+
Key string // canonical internal key (GAMES env, state file)
7+
Name string // human display name
8+
Ennead string // slug for api.ennead.cc/mihoyo/<slug>/codes
9+
Tori string // slug for hoyo-codes.seria.moe/codes?game=<slug>
10+
Opengacha string // slug for <opengachaBaseUrl>/games/<slug>/codes ("" = not served there)
11+
Color int // Discord embed colour
1112
// Redeem is a web redemption URL template with {code} substituted,
1213
// or "" when the game only supports in-game redemption.
1314
Redeem string
1415
}
1516

1617
// gameOrder is the canonical iteration order (the games map is unordered, and
1718
// config game keys should resolve deterministically).
18-
var gameOrder = []string{"genshin", "hsr", "zzz", "honkai3rd"}
19+
var gameOrder = []string{"genshin", "hsr", "zzz", "honkai3rd", "wuwa"}
1920

2021
// games is the registry of everything we know how to watch.
2122
var games = map[string]Game{
2223
"genshin": {
23-
Key: "genshin",
24-
Name: "Genshin Impact",
25-
Ennead: "genshin",
26-
Tori: "genshin",
27-
Color: 0xF2C94C,
28-
Redeem: "https://genshin.hoyoverse.com/en/gift?code={code}",
24+
Key: "genshin",
25+
Name: "Genshin Impact",
26+
Ennead: "genshin",
27+
Tori: "genshin",
28+
Opengacha: "genshin",
29+
Color: 0xF2C94C,
30+
Redeem: "https://genshin.hoyoverse.com/en/gift?code={code}",
2931
},
3032
"hsr": {
31-
Key: "hsr",
32-
Name: "Honkai: Star Rail",
33-
Ennead: "starrail",
34-
Tori: "hkrpg", // torikushiii's slug for Star Rail
35-
Color: 0x9B7BD8,
36-
Redeem: "https://hsr.hoyoverse.com/gift?code={code}",
33+
Key: "hsr",
34+
Name: "Honkai: Star Rail",
35+
Ennead: "starrail",
36+
Tori: "hkrpg", // torikushiii's slug for Star Rail
37+
Opengacha: "starrail", // OpenGachaCodes slug for Star Rail
38+
Color: 0x9B7BD8,
39+
Redeem: "https://hsr.hoyoverse.com/gift?code={code}",
3740
},
3841
"zzz": {
39-
Key: "zzz",
40-
Name: "Zenless Zone Zero",
41-
Ennead: "zenless",
42-
Tori: "nap", // torikushiii's slug for Zenless Zone Zero
43-
Color: 0xFFD400,
44-
Redeem: "https://zenless.hoyoverse.com/redemption?code={code}",
42+
Key: "zzz",
43+
Name: "Zenless Zone Zero",
44+
Ennead: "zenless",
45+
Tori: "nap", // torikushiii's slug for Zenless Zone Zero
46+
Opengacha: "zenless", // OpenGachaCodes slug for Zenless Zone Zero
47+
Color: 0xFFD400,
48+
Redeem: "https://zenless.hoyoverse.com/redemption?code={code}",
4549
},
4650
"honkai3rd": {
47-
Key: "honkai3rd",
48-
Name: "Honkai Impact 3rd",
49-
Ennead: "honkai",
50-
Tori: "honkai3rd",
51-
Color: 0x4A9BE0,
52-
Redeem: "", // Honkai Impact 3rd has no public web redemption page.
51+
Key: "honkai3rd",
52+
Name: "Honkai Impact 3rd",
53+
Ennead: "honkai",
54+
Tori: "honkai3rd",
55+
Opengacha: "", // OpenGachaCodes doesn't serve Honkai Impact 3rd.
56+
Color: 0x4A9BE0,
57+
Redeem: "", // Honkai Impact 3rd has no public web redemption page.
58+
},
59+
"wuwa": {
60+
Key: "wuwa",
61+
Name: "Wuthering Waves",
62+
// Not a HoYoverse game — the HoYo-only sources (ennead, tori) don't
63+
// serve it, so its codes come solely from OpenGachaCodes. A source with
64+
// an empty slug is skipped in fetchCodes.
65+
Ennead: "",
66+
Tori: "",
67+
Opengacha: "wuwa",
68+
Color: 0x3AC8D4,
69+
Redeem: "", // Wuthering Waves redeems codes in-game (no public web page).
5370
},
5471
}

main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ func runCheck(cfg Config, watched []Game, state *State) {
7575
dirty := false
7676

7777
for _, g := range watched {
78-
codes, err := fetchCodes(g)
78+
codes, err := fetchCodes(g, cfg.OpengachaBaseURL)
7979
if err != nil {
8080
log.Printf("[%s] fetch failed: %v", g.Key, err)
8181
continue

0 commit comments

Comments
 (0)