Skip to content

Commit 0ff17ec

Browse files
committed
init
0 parents  commit 0ff17ec

18 files changed

Lines changed: 1285 additions & 0 deletions

.dockerignore

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# Build context is ./app. Keep only what the build needs (go files + go.mod/sum).
2+
hoyo-codes
3+
Dockerfile
4+
data/
5+
config.yaml
6+
config.example.yaml
7+
*.md
8+
.gitignore
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
name: Build and publish image
2+
3+
on:
4+
push:
5+
branches: [main]
6+
tags: ["v*"]
7+
# Allow manual runs from the Actions tab.
8+
workflow_dispatch:
9+
10+
env:
11+
REGISTRY: ghcr.io
12+
IMAGE_NAME: ${{ github.repository }}
13+
14+
jobs:
15+
build-and-push:
16+
runs-on: ubuntu-latest
17+
permissions:
18+
contents: read
19+
packages: write # required to push to GitHub Packages (ghcr.io)
20+
21+
steps:
22+
- name: Checkout
23+
uses: actions/checkout@v4
24+
25+
- name: Set up Docker Buildx
26+
uses: docker/setup-buildx-action@v3
27+
28+
- name: Log in to GitHub Container Registry
29+
uses: docker/login-action@v3
30+
with:
31+
registry: ${{ env.REGISTRY }}
32+
username: ${{ github.actor }}
33+
password: ${{ secrets.GITHUB_TOKEN }}
34+
35+
- name: Extract image metadata (tags, labels)
36+
id: meta
37+
uses: docker/metadata-action@v5
38+
with:
39+
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
40+
tags: |
41+
type=ref,event=branch
42+
type=semver,pattern={{version}}
43+
type=semver,pattern={{major}}.{{minor}}
44+
type=sha
45+
type=raw,value=latest,enable={{is_default_branch}}
46+
47+
- name: Build and push
48+
uses: docker/build-push-action@v6
49+
with:
50+
context: .
51+
push: true
52+
tags: ${{ steps.meta.outputs.tags }}
53+
labels: ${{ steps.meta.outputs.labels }}
54+
cache-from: type=gha
55+
cache-to: type=gha,mode=max

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# live config (holds webhook URLs)
2+
config.yaml
3+
# runtime state (sent.json)
4+
/data/
5+
# local build artifact (binary from `go build`)
6+
/hoyo-codes

AGENTS.md

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
# hoyo-codes — agent guide
2+
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.
6+
7+
Codes are pulled from two community APIs and merged/de-duplicated, so a hiccup on
8+
one source doesn't cause a miss:
9+
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+
13+
## Layout
14+
15+
This directory is the whole, self-contained project (Go code, `Dockerfile`,
16+
docs, config). It knows nothing about how it's deployed — a separate
17+
`docker-compose.yaml` outside this dir builds the image and supplies its own
18+
config file and data volume; treat that as external.
19+
20+
```
21+
config.yaml live config (gitignored — holds webhook URLs)
22+
config.example.yaml committed, fully-commented template
23+
data/ runtime state (sent.json), gitignored
24+
Dockerfile
25+
README.md dry, user-facing
26+
AGENTS.md this file
27+
*.go the service (flat package main)
28+
```
29+
30+
This is a flat `package main`, one concern per file:
31+
32+
| File | Responsibility |
33+
| --------------- | ----------------------------------------------------------------------- |
34+
| `main.go` | Startup, config load, cron loop, the per-check diff/announce. |
35+
| `config.go` | YAML config load (`gopkg.in/yaml.v3`) + resolution/validation. |
36+
| `games.go` | `games` registry: canonical key → per-source slugs, colour, redeem URL. |
37+
| `sources.go` | Fetch + normalize from both upstream APIs, merge/de-dupe. |
38+
| `discord.go` | Build embeds, chunk to Discord limits, POST the webhook. |
39+
| `state.go` | `data/sent.json` load/save (atomic write). |
40+
| `util.go` | Tiny shared helpers. |
41+
| `discord_test.go` | Unit test for `buildContent` (placeholder/plural expansion). |
42+
43+
## Build / run / verify
44+
45+
All commands run from this directory.
46+
47+
```sh
48+
go build ./... && go vet ./... && go test ./... && gofmt -l . # compile + vet + test + fmt gate
49+
docker build -t hoyo-codes . # build the image
50+
```
51+
52+
Quick local run without Docker. Config path comes from `CONFIG_FILE` (default
53+
`/app/config.yaml`); point it at a scratch file:
54+
55+
```sh
56+
CONFIG_FILE=/path/to/test-config.yaml go run .
57+
```
58+
59+
For a one-shot test, set `schedule: "0 0 1 1 *"` (parks cron far in the future so
60+
only the immediate `runOnStart` check fires) and `markExistingOnFirstRun: false`
61+
(forces the active backlog to post; by default the first run marks it sent silently).
62+
63+
After a `go build .` here, delete the stray `hoyo-codes` binary it drops
64+
(already gitignored).
65+
66+
## Configuration reference
67+
68+
All config lives in `config.yaml` (gitignored — it holds webhook URLs). See
69+
`config.example.yaml` for the fully-commented template.
70+
71+
```yaml
72+
schedule: "*/30 * * * *" # 5-field cron
73+
timezone: "Europe/Amsterdam" # optional; when the schedule runs
74+
markExistingOnFirstRun: true # suppress the old-code backlog on first run
75+
runOnStart: true
76+
httpTimeout: 20
77+
maxPostAttempts: 10 # give up announcing a code after N failed checks
78+
79+
games: # only games listed here are watched
80+
genshin:
81+
webhook: "https://discord.com/api/webhooks/..."
82+
message: "<@&ROLE_ID> {count} new {if-singular:code}{if-plural:codes} 🎁"
83+
username: "Genshin Codes" # optional sender name override
84+
avatarUrl: "https://…/pic.png" # optional sender avatar (public image URL)
85+
hsr:
86+
webhook: "https://discord.com/api/webhooks/..." # its own channel
87+
message: "<@&ROLE_ID> Trailblazers — {count} new {if-singular:code}{if-plural:codes}!"
88+
# honkai3rd: # comment a game out (or delete it) to stop watching
89+
# webhook: "…"
90+
# message: "…"
91+
```
92+
93+
- **Which games run** = the keys under `games:` (known keys: `genshin`, `hsr`,
94+
`zzz`, `honkai3rd`). There is no enable/disable flag — comment a game out or
95+
delete it to stop watching it.
96+
- **Every listed game requires `webhook` and `message`.** There is no shared
97+
default block — each game is self-contained. Startup fails with a clear error if
98+
a game is missing either, if no games are configured, or on any unknown key (typos).
99+
- **Optional per-game sender identity** (both fall back if omitted):
100+
- `username` — the Discord sender name; defaults to `defaultUsername`
101+
(`"Hoyo Codes"`) in `config.go`.
102+
- `avatarUrl` — a public image URL (PNG/JPG/GIF) Discord fetches as the sender
103+
avatar; omit to keep the webhook's own configured avatar. Both map onto the
104+
webhook payload's `username` / `avatar_url` fields.
105+
- **Message placeholders**:
106+
- `{count}` — number of new codes.
107+
- `{if-singular:X}` / `{if-plural:X}``X` is kept only when the count is 1
108+
(singular) or not 1 (plural); the other is dropped. Handles irregular plurals
109+
and other languages. The text inside must not contain a `}`.
110+
111+
### Pinging a role
112+
113+
To ping a role, put its mention token **in the game's `message`** — the message is
114+
the Discord message body, and mentions only ping from there (never from an embed):
115+
116+
```yaml
117+
message: "<@&123456789012345678> {count} new codes!" # pings a role
118+
message: "@everyone {count} new codes!" # literal
119+
```
120+
121+
Role mentions need the role's **numeric ID**, not its name (Discord → Settings →
122+
Advanced → **Developer Mode**, then right-click the role → **Copy ID**).
123+
124+
## How a check works (`runCheck`)
125+
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.
128+
2. New codes = fetched codes not in `data/sent.json`. They're marked sent
129+
immediately.
130+
3. First time a game is seen (no state), codes are **marked sent silently** when
131+
`markExistingOnFirstRun: true` (the default) — avoids dumping the whole
132+
backlog on boot. Set it false to announce the backlog instead. A game with
133+
**no** active codes on its first fetch is still seeded (`state.seed`) so the
134+
next fetch that finds codes is announced as new rather than re-treated as
135+
first-run backlog.
136+
4. New codes become one Discord embed (with a one-click **Redeem** link where the
137+
game supports web redemption), posted to **that game's** webhook
138+
(`cfg.Notify[key]`). `buildContent` expands `{count}` and resolves the
139+
`{if-singular:X}`/`{if-plural:X}` blocks in the game's `message`, then sends it
140+
as the message `content`; a `<@&ROLE_ID>` token pings from there.
141+
5. Codes are marked sent **only after their announce post succeeds** (state is
142+
saved once at the end of the check). A failed post leaves its codes unmarked,
143+
so the next check retries them — nothing reached Discord, so this re-tries
144+
rather than double-posts, and a broken webhook self-heals once fixed instead
145+
of silently dropping codes. A transient `429` is first retried in-place per
146+
its `Retry-After` header (`postPayload`, bounded to 3). Force a re-announce of
147+
already-sent codes by deleting `data/sent.json`.
148+
- **Retry cap:** each failed check bumps a per-code counter in `state.Attempts`
149+
(`recordAttempt`). Once a code reaches `maxPostAttempts` (config, default 10)
150+
it's marked done and **given up on** (never announced) so a persistently
151+
failing post can't retry/log forever. The counter is per-code, so a genuinely
152+
new code arriving mid-retry gets its own fresh budget; `mark` clears a code's
153+
counter on success or give-up.
154+
- **Edge case:** if one check yields enough new codes to span multiple Discord
155+
messages (>25 at once) and an early message succeeds but a later one fails,
156+
the successful ones can repost on retry. HoYo drops codes a few at a time, so
157+
in practice it's one message per game and this can't arise.
158+
159+
## Config internals (`config.go`)
160+
161+
Parsed with `gopkg.in/yaml.v3` in strict mode (`KnownFields(true)` — unknown keys
162+
are a fatal error, catching typos). `loadConfig` resolves the file into `Config`:
163+
164+
- Top-level scalars (`schedule`, `timezone`, `markExistingOnFirstRun`,
165+
`runOnStart`, `httpTimeout`, `maxPostAttempts`, `dataDir`) have defaults via
166+
`orString/orBool/orInt`.
167+
- Watched games = the keys present under `games:` (iterated in `gameOrder` for
168+
stable order). Unknown keys warn and are skipped; there is no disable flag.
169+
- Each game is self-contained (`GameNotify{Webhook, Message, Username, AvatarURL}`;
170+
`Username` defaults to `defaultUsername`, `AvatarURL` is optional). `loadConfig`
171+
collects validation problems and returns them together: fatal if no games are
172+
configured, or any listed game is missing `webhook` or `message`.
173+
174+
When adding a config knob: add it to `fileConf`, resolve/validate it in
175+
`loadConfig`, and document it in `config.example.yaml` and this file.
176+
177+
## Upstream APIs
178+
179+
- **ennead** — `https://api.ennead.cc/mihoyo/<slug>/codes`, slugs `genshin`,
180+
`starrail`, `zenless`, `honkai`. Shape: `{"active":[{"code","rewards":[...]}],"inactive":[...]}`.
181+
- **torikushiii** — `https://hoyo-codes.seria.moe/codes?game=<slug>`, slugs
182+
`genshin`, `hkrpg`, `nap`, `honkai3rd`. Shape: `{"codes":[{"code","rewards":"A*60;B*5"}]}`
183+
(active only). Rewards get normalized (`A*60;B*5` → `A ×60, B ×5`).
184+
185+
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.
189+
190+
## Conventions
191+
192+
- **Config is a YAML file.** Add new knobs to `fileConf`/`Config` in `config.go`
193+
and document them in `config.example.yaml` and this file. Every listed game
194+
must define its own `webhook` and `message` (validated in `loadConfig`).
195+
- **Keep it dependency-light.** Stdlib plus `robfig/cron` and `yaml.v3` only.
196+
Don't pull in an HTTP or Discord SDK.
197+
- **Discord facts worth remembering:** mentions only ping from `content`, never
198+
from an embed; limits are 25 fields/embed and 10 embeds/message — `discord.go`
199+
chunks for both. Preserve that when editing.
200+
- **Runtime state** (`data/`) and the live `config.yaml` (holds webhooks) are
201+
gitignored; committed: source, `Dockerfile`, docs, and `config.example.yaml`.
202+
- **Container runs as uid/gid 1000** and writes only `/app/data`. Keep it non-root.
203+
- **Honkai Impact 3rd** has no public web-redemption page, so its codes post
204+
without a Redeem link (redeem in-game).

Dockerfile

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# syntax=docker/dockerfile:1
2+
3+
FROM golang:1.26-alpine AS build
4+
WORKDIR /src
5+
COPY go.mod go.sum ./
6+
RUN go mod download
7+
COPY *.go ./
8+
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/hoyo-codes .
9+
10+
FROM alpine:3.20
11+
# ca-certificates: HTTPS to the upstream APIs and the Discord webhook.
12+
# tzdata: so the schedule's timezone (TZ env or config `timezone`) resolves.
13+
RUN apk add --no-cache ca-certificates tzdata \
14+
&& mkdir -p /app/data && chown 1000:1000 /app/data
15+
WORKDIR /app
16+
COPY --from=build /out/hoyo-codes /usr/local/bin/hoyo-codes
17+
# Config is mounted at /app/config.yaml (see docker-compose.yaml).
18+
USER 1000:1000
19+
ENTRYPOINT ["hoyo-codes"]

README.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# hoyo-codes
2+
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.
5+
6+
## Run
7+
8+
```sh
9+
cp config.example.yaml config.yaml # then add your webhook(s) + messages
10+
11+
docker build -t hoyo-codes .
12+
docker run -d --name hoyo-codes \
13+
-e TZ=Europe/Amsterdam \
14+
-v "$PWD/config.yaml:/app/config.yaml:ro" \
15+
-v "$PWD/data:/app/data" \
16+
hoyo-codes
17+
```
18+
19+
Or without Docker: `CONFIG_FILE=config.yaml go run .`
20+
21+
Configuration lives in `config.yaml` — see
22+
[`config.example.yaml`](config.example.yaml) for the fully-commented template.
23+
24+
Architecture, config reference, and development notes: **[AGENTS.md](AGENTS.md)**.

config.example.yaml

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# hoyo-codes configuration.
2+
#
3+
# Copy to config.yaml (gitignored — it holds your webhook URLs) and edit:
4+
# cp config.example.yaml config.yaml
5+
# docker-compose mounts ./config.yaml into the container read-only.
6+
7+
# How often to check for new codes. Standard 5-field cron.
8+
schedule: "*/30 * * * *"
9+
10+
# IANA timezone the schedule runs in (e.g. "Europe/Amsterdam"). Optional;
11+
# defaults to the container's TZ. Only affects cron timing.
12+
timezone: "Europe/Amsterdam"
13+
14+
# On a game's FIRST run (no prior state), record all currently-active codes as
15+
# already-sent instead of announcing them. true (default) prevents spamming the
16+
# whole backlog of old codes on first boot. Set false to announce the backlog.
17+
markExistingOnFirstRun: true
18+
19+
# Run one check immediately on startup, in addition to the schedule. Default true.
20+
runOnStart: true
21+
22+
# Per-request HTTP timeout, seconds. Default 20.
23+
httpTimeout: 20
24+
25+
# Cap on how many checks may fail to announce a given code before we give up on
26+
# it (mark it done without posting). Stops a persistently broken webhook or a bug
27+
# from retrying and log-spamming forever. Each failing check counts as one
28+
# attempt; a successful post resets nothing (the code is simply done). Default 10
29+
# (≈5h of retries on the default 30-min schedule).
30+
maxPostAttempts: 10
31+
32+
# Games to watch. ONLY the games listed here are watched — to stop watching one,
33+
# comment it out or delete it (there's no enable/disable flag). Each listed game
34+
# REQUIRES both a `webhook` and a `message` (startup fails otherwise). Known keys:
35+
# genshin (Genshin Impact), hsr (Honkai: Star Rail), zzz (Zenless Zone Zero),
36+
# honkai3rd (Honkai Impact 3rd)
37+
games:
38+
genshin:
39+
webhook: "https://discord.com/api/webhooks/xxx/yyy"
40+
# Placeholders: {count} = number of new codes. {if-singular:X}/{if-plural:X}
41+
# keep X only when count is 1 / not 1 (the other is dropped) — handles
42+
# irregular plurals and other languages. To ping a role, drop its mention
43+
# token into the message: <@&ROLE_ID> (Dev Mode -> right-click role -> Copy ID).
44+
message: "<@&000000000000000000> {count} new Genshin Impact {if-singular:code}{if-plural:codes} 🎁"
45+
# Optional per-game sender identity. `username` overrides the sender name;
46+
# `avatarUrl` is a public image URL (PNG/JPG/GIF) Discord fetches as the
47+
# avatar. Omit either to fall back to the default name / the webhook's own
48+
# avatar.
49+
username: "Genshin Codes"
50+
avatarUrl: "https://example.com/genshin-avatar.png"
51+
52+
hsr:
53+
# Post Star Rail to a different channel, with its own sender name:
54+
webhook: "https://discord.com/api/webhooks/aaa/bbb"
55+
message: "<@&111111111111111111> Trailblazers — {count} new {if-singular:code}{if-plural:codes}!"
56+
username: "Star Rail Codes"
57+
58+
# To stop watching a game, comment it out (or delete it) — only listed games run:
59+
# honkai3rd:
60+
# webhook: "https://discord.com/api/webhooks/ccc/ddd"
61+
# message: "{count} new Honkai Impact 3rd {if-singular:code}{if-plural:codes}"

0 commit comments

Comments
 (0)