A Discord bot that reports new and updated game mods from Thunderstore, Hexium and Nexus Mods. It runs entirely on the Cloudflare Workers free plan, triggered by cron, with no inbound HTTP routes. Each operator deploys their own instance for their own game (primary target: Valheim).
Named after the squirrel that carries messages up and down Yggdrasil.
- Unofficial. ratatoskr is an independent project. It is not affiliated with, endorsed by, or sponsored by Thunderstore, Hexium or Nexus Mods. All names belong to their respective owners.
- Licence. AGPL-3.0-or-later; see LICENSE. The AGPL network clause applies to a running instance: users of your deployment must be able to obtain its source. Messages posted by the bot carry a link to the source repository: https://github.com/odin-sons/ratatoskr. If you modify the code, publish your modified source.
- Nexus Mods. The Nexus source is optional and disabled by default. It requires your own personal Nexus API key, supplied as a Worker secret; no key is ever shipped in this repository, and there is no hosted instance. Personal keys are intended for personal use and testing. You are responsible for complying with the Nexus Mods API Acceptable Use Policy, including registering your application with Nexus if your use falls outside what the policy allows for personal keys. See docs/legal.md.
- You are responsible for your deployment. The operator is responsible for what their instance posts and where it posts it, and for complying with the terms of Discord, Cloudflare and each upstream service.
- NSFW filtering. Content flagged as adult or NSFW by any upstream is
excluded by default. A subscription must explicitly opt in with
"allowNsfw": true. - Mod content (names, descriptions, changelogs, icons) belongs to the mod authors. The bot posts short excerpts and links to the original; it does not proxy or mirror downloads.
Every 5 minutes a cron trigger polls the enabled sources once per deployment
(never per guild), detects new packages and version changes, and delivers them
to Discord webhooks according to each subscription's filter. Delivery is either
immediate or a periodic digest (default every 30 minutes). Digests never
drop a mod: detail is reduced before messages are split.
| Source | Detects new | Detects updates |
|---|---|---|
| Thunderstore | yes | yes |
| Hexium | yes | yes, with up to ~15 minutes of extra latency |
| Nexus Mods (optional) | yes | yes |
The first run for each source only records current state and posts nothing.
A cron-triggered Worker reads each source's listing, diffs it against state in D1, writes events and outbox rows in one transaction together with the source cursor, then drains the outbox to Discord within a per-tick budget. The core (diff, matcher, renderer) is platform-agnostic; Cloudflare specifics live in an adapter layer. Details: docs/spec.md. Verified upstream endpoints: docs/api-notes.md.
Measured on a live instance, extrapolated to a full UTC day (see docs/spec.md):
| Resource | Free limit | Used | Share |
|---|---|---|---|
| Worker requests | 100,000/day | 288 | 0.3 % |
| D1 rows written | 100,000/day | ~9,000 | 9 % |
| D1 rows read | 5,000,000/day | ~220,000 | 4 % |
| Cron triggers | 5/account | 2 | 40 % |
| External subrequests | 50/invocation | <= 21 | n/a |
The binding limit is the 10 ms CPU budget per invocation, not quotas.
Requirements: a Cloudflare account, Node.js 24, pnpm.
Deploy to Cloudflare button. The one-click button needs a public repository
URL. This repository is currently private; once it is public, the button will
be https://deploy.workers.cloudflare.com/?url=https://github.com/odin-sons/ratatoskr.
Until then, use the manual steps below.
-
Fork or clone the repository and install dependencies.
pnpm install
-
Create the D1 database and put the printed
database_idinto.env(copy.env.exampleto.envfirst) asD1_DATABASE_ID.wrangler d1 create ratatoskr # or the name you set as D1_DATABASE_NAME -
Apply the schema to the remote database. Raw
wranglercommands need the realdatabase_id, whichwrangler.jsoncnever carries (see below); usepnpm run wranglerinstead ofwranglerdirectly for any command that touches the remote database.pnpm run wrangler d1 execute ratatoskr --remote --file=./schema.sql # your D1_DATABASE_NAME, if setUpgrading an existing database: apply the files in
migrations/you have not applied yet, in order, beforeschema.sql(for examplepnpm run wrangler d1 execute ratatoskr --remote --file=./migrations/0003_package_likes_and_website.sql). Apply each file once; the current ones are0001_outbox_delivered_at.sql,0002_package_download_url_and_downloads.sql,0003_package_likes_and_website.sqland0004_subscription_thread_id.sql. A database created from the currentschema.sqlneeds none of them. -
Review
ratatoskr.config.json(sources, game, User-Agent) and validate it.pnpm validate-config
-
Create a Discord webhook (channel settings, Integrations, Webhooks) and add a subscription. The script prints a
pnpm run wrangler d1 executecommand; it does not run it. Review the output, then run it.# put the webhook URL into .env (git-ignored, from step 2) pnpm add-subscription --guild-id <guild id> --webhook-url-env DISCORD_WEBHOOK_URL
pnpm add-subscriptionloads.envautomatically; a variable already exported in your shell takes precedence over the file.The webhook URL is a credential: anyone holding it can post to the channel. Passing it with
--webhook-urlleaves it in your shell history, so prefer--webhook-url-env. It is also stored in D1 in plain text and appears in the printed command. -
Optional: a Discord channel for the bot's own warnings. Create a webhook in it and store the URL as a secret. The bot posts there when an internal limit nears (for example the Hexium package index at 70, 85 and 95 % of its cap) and again every day while a limit is exceeded. Without it nothing is sent.
pnpm run wrangler secret put ALERT_WEBHOOK_URL
Keep this webhook out of every subscription: it is for you, not for the readers of the mod channels. An existing database needs
schema.sqlapplied again (step 3) before the first deploy that carries alerts; it adds thealert_statetable. -
Optional: the D1 usage monitor. It reads the day's D1 usage from Cloudflare's analytics API, alerts in the channel from step 6 at 50, 70, 85 and 95 % of a daily limit, switches optional work off from 70 %, and sends a usage chart every Friday at 20:00 UTC+3. Create a token that can do nothing else: in the Cloudflare dashboard, Profile → API Tokens → Create Token → Custom, one permission, Account → Account Analytics → Read, limited to your account. Store it and your account id (on the Workers & Pages overview, or from
pnpm run wrangler whoami) as secrets, separate from the deploy token.pnpm run wrangler secret put CLOUDFLARE_ANALYTICS_TOKEN
pnpm run wrangler secret put CLOUDFLARE_ACCOUNT_ID
Put the same two values in
.envto see the numbers from your machine withpnpm run usage. -
Only if you enable Nexus (see the disclaimer above): set
"enabled": trueon thenexus:*source inratatoskr.config.jsonand store your personal key.pnpm run wrangler secret put NEXUS_API_KEY
-
Deploy. Use
pnpm run deploy; plainpnpm deployis a different, built-in pnpm command.pnpm run deploy
wrangler.jsonccommits a placeholderdatabase_id, never your real one.pnpm run deployreadsD1_DATABASE_IDfrom.env(step 2), substitutes it into a throwaway copy ofwrangler.jsoncnext to the real one, deploys with that, and deletes it immediately after — your id never touches a file that could be committed. It also loads.envfor the optional store emoji (STORE_EMOJI_*),RATATOSKR_EMOJIandRATATOSKR_LANGUAGE(see below), so they reach the Worker without being committed either. A plainwrangler deploy(bypassing this script) is expected to be rejected by the Cloudflare API for the placeholder id;--dry-runskips that check entirely (it makes no network calls), so it succeeds either way and proves nothing about the id. Usepnpm run wranglerfor any otherwranglercommand that needs the real one.Pushing a
vX.Y.Ztag also deploys, via.github/workflows/release.yml, after cutting the GitHub Release: gated on approving theproductionenvironment (Settings → Environments → production, a required reviewer — not automatic). NeedsCLOUDFLARE_API_TOKENandD1_DATABASE_ID(same value as your.env) as that environment's secrets — not repository secrets, so they stay unreadable by any workflow run that hasn't cleared the approval gate. The optional presentation ones below aren't credentials, so they go in the same environment's variables instead, if you want a tag-triggered deploy to carry them too.
The Worker is named ratatoskr and the D1 database ratatoskr unless you say otherwise. Two
instances on one Cloudflare account (for two games, say) need different names, because both
are unique per account. Set WORKER_NAME and D1_DATABASE_NAME in each checkout's
.env (or as production environment variables for a tag-triggered deploy). pnpm run deploy
and pnpm run wrangler substitute them into the same throwaway copy of wrangler.jsonc that
carries the real database_id, and pnpm add-subscription and pnpm subscriptions use
D1_DATABASE_NAME as their --database default. Create the database under the same name:
wrangler d1 create <D1_DATABASE_NAME>.
Each instance needs its own ratatoskr.config.json, .env and secrets, so use one checkout or
fork per instance, and keep every wrangler call going through pnpm run wrangler (for secrets too), because the
plain command reads the committed names. They share the account's limits: 5 cron triggers (an instance uses 2),
100,000 Worker requests and 5M D1 row reads plus 100,000 row writes per day.
Deployment-wide settings, validated at build time (pnpm validate-config, run
in CI) and not bundled into the Worker.
{
"userAgent": "ratatoskr/1.0.2 (+https://github.com/odin-sons/ratatoskr; unofficial mod notifier)",
"sources": [
{ "id": "thunderstore:valheim", "store": "thunderstore", "community": "valheim", "enabled": true },
{ "id": "hexium:valheim", "store": "hexium", "community": "valheim", "enabled": true },
{ "id": "nexus:valheim", "store": "nexus", "community": "valheim", "enabled": false }
]
}userAgent: sent to every upstream. Keep the project name, version and a repository URL; if you fork, point it at your fork and add a contact route.sources[].id: must be<store>:<community>.sources[].store:thunderstore,hexiumornexus.sources[].community: Thunderstore community identifier, Hexium game slug, or Nexus game domain.sources[].enabled: Nexus is disabled by default. The validator warns if a Nexus source is enabled.
Optional. Messages can show a custom emoji per store at the start of the title
(# <emoji> [Name](url)), on the Mod page button and at the start of each
per-store digest heading. Without one the title is just the linked name and the
button uses a built-in Unicode emoji. It is a Worker variable, an object (or a
JSON string of one) keyed by store:
{
"thunderstore": "<:thunderstore:123456789012345678>",
"hexium": "<:hexium:123456789012345678>",
"nexus": "<:nexus:123456789012345678>"
}- Values are full Discord emoji markup,
<:name:id>(or<a:name:id>for an animated one); plain:name:does not work through the API. To get the markup, type\:name:in a Discord message and copy what it shows. - They must be custom emoji from a server the webhook can use; the safest choice
is an emoji of the server that owns the channel. Otherwise Discord shows the
raw
<:name:id>text. - Each value must match
<a?:[A-Za-z0-9_]{2,32}:[0-9]{17,20}>. Invalid entries and unknown keys are ignored (one warning in the log names only the keys); a store without an entry shows no emoji. wrangler.jsoncships"vars": { "STORE_EMOJIS": {}, "LANGUAGE": "en" }. The easy way to set it: putSTORE_EMOJI_THUNDERSTORE,STORE_EMOJI_HEXIUMandSTORE_EMOJI_NEXUSin the git-ignored.env(see.env.example);pnpm run deployvalidates them and passes them to the Worker asSTORE_EMOJIS. Do not commit your ids. Always deploy withpnpm run deployon a machine that has your.env: a plainwrangler deployis expected to be rejected forwrangler.jsonc's placeholderdatabase_id(see the deploy step above), but apnpm run deployon a machine or CI runner missing only the optional emoji/language variables still succeeds and silently publishes the config's emptySTORE_EMOJIS. The same goes forRATATOSKR_LANGUAGEandRATATOSKR_EMOJIbelow.
Optional. Every message ends with a small -# <emoji> [ratatoskr vX](url)
line linking to this project's source (the AGPL notice); it shows a squirrel
by default. Set the
Worker variable RATATOSKR_EMOJI to full custom emoji markup (same format and
rules as STORE_EMOJIS) to use your own. An invalid value is ignored with one
warning that names only the variable. Easiest: put RATATOSKR_EMOJI in the
git-ignored .env; pnpm run deploy validates it and passes it to the Worker.
Optional Worker variable, default en. Every text the bot writes into a
message (labels, plural forms, button captions, number and size formats) comes
from a catalog in src/i18n: en and ru exist. An unknown value falls back
to en with one warning that names only the variable. wrangler.jsonc ships
"LANGUAGE": "en"; the easy way to change it is RATATOSKR_LANGUAGE=ru in the
git-ignored .env, which pnpm run deploy validates against the catalogs and
passes to the Worker as LANGUAGE (a bad value stops the deploy). The .env key
is not called LANGUAGE because that is the POSIX locale variable, which many
shells already set. To add a language, add a
src/i18n/<code>.ts that satisfies the Messages type (TypeScript fails if a
key is missing) and register it in src/i18n/index.ts. Log lines, warnings and
the run report stay English.
An immediate message is one Discord Components V2 message: a container in the store's colour holding
- the header: a linked h2 title (
## <store emoji> [Name](url)), the kind line (⬆️ Updated by Owner · 1.2.2 → 1.2.3 · <relative time>, or🆕 New by Owner · 1.0.0 · ...), an info line (ℹ️ 94.2 MB · Downloaded 12,345 times · 21 likes, each part only when known) and the description excerpt, with the mod icon as a thumbnail beside it; - a
Changelogblock (about 500 characters, ending with a link to the full changelog) and a🗂️ Categoriesblock, each only when there is something to show; - a row of link buttons:
Mod page,DownloadandWebsite, each only when the source provides a URL. Discord refuses a button whose host has no real top-level domain (https://mysite), so such a URL gets no button; if Discord rejects a message anyway, the bot resends it once with onlyMod pagebefore giving up (counted asdegradedin the run log); - outside the coloured block, a trailing small
-# <emoji> [ratatoskr v<version>](url)line linking to this project's source (the AGPL notice). It is always present, whatever else in the message failed to render.
In immediate mode every update also gets a changelog excerpt, within the
per-tick fetch cap. Discord only accepts this format because the bot sends
with_components=true and the Components V2 flag; such a message cannot carry
content or embeds.
Digests hold many mods per message, so they stay classic embeds (no buttons):
new packages and watchlist hits are detailed embeds with the same header,
Changelog and 🗂️ Categories fields; updates are a compact list per store
(**Thunderstore** · 37 updates). The last embed of every digest message ends
with a small -# ratatoskr v<version> line linking to the source repository
(the AGPL notice), and a digest that spans several messages numbers them in the
last embed's footer.
Subscriptions live in the D1 subscriptions table and are created with
pnpm add-subscription (run pnpm add-subscription --help for all options).
Delivery options: --mode immediate|digest (default digest) and
--interval <minutes> (5 to 1440, default 30, digest only).
The filter is built from flags (--source, --kind, --package,
--exclude-package, --category, --exclude-category, --allow-nsfw; the
list flags can be repeated) or given as raw JSON with --filter '<json>' or
--filter-file <path>. Flags and raw JSON cannot be combined. Every field is
optional; the empty filter {} matches everything except NSFW content. All
fields apply together (a package must pass every one that is set).
| Field | Flag | Meaning |
|---|---|---|
sources |
--source |
Restrict to these source ids. Absent or empty means all. |
kinds |
--kind |
"new", "update", or both. Absent or empty means both. |
packages |
--package |
Allowlist: only these packages. Each entry is a full Owner-Name package id or a bare owner name, case-insensitive. Absent or empty means no restriction. |
excludePackages |
--exclude-package |
Never deliver these packages. Same entries and matching as packages; wins over every other field. |
allowNsfw |
--allow-nsfw |
NSFW content is excluded unless this is exactly true. |
watchlist |
(JSON only) | Highlights these packages (same entries as packages) in digests with a detailed embed. It never restricts what is delivered; use packages for that. |
includeCategories |
--category |
Only packages in these categories. |
excludeCategories |
--exclude-category |
Drop packages in these categories. |
dedupAcrossStores |
(JSON only) | Collapse the same release seen on several stores into one item. Default true. |
includeChangelog |
--no-changelog |
Set to false to never show the Changelog block for this subscription. Default true. Most mods ship a long CHANGELOG.md, and showing it in every message makes each card much taller; turn it off for a broad subscription and keep it on for a subscription that follows one mod or a small set. |
Examples:
# Only these authors or packages, delivered immediately
pnpm add-subscription --guild-id <id> --webhook-url-env DISCORD_WEBHOOK_URL \
--mode immediate --package Azumatt --package RandyKnapp-EpicLoot
# Everything, but highlight these authors in the digest (watchlist does not restrict)
pnpm add-subscription --guild-id <id> --webhook-url-env DISCORD_WEBHOOK_URL \
--filter '{"watchlist":["Azumatt","RandyKnapp-EpicLoot"]}'
# NSFW opt-in
pnpm add-subscription --guild-id <id> --webhook-url-env DISCORD_WEBHOOK_URL \
--filter '{"allowNsfw":true}'
# Digest every 60 minutes, new packages only, Thunderstore only
pnpm add-subscription --guild-id <id> --webhook-url-env DISCORD_WEBHOOK_URL \
--mode digest --interval 60 \
--filter '{"sources":["thunderstore:valheim"],"kinds":["new"]}'--thread-id <id> delivers into an existing forum post or text-channel thread
instead of the webhook's parent channel — a numeric Discord snowflake, the same
id you'd copy from the thread's own URL. The webhook still lives on the parent
(forum or text) channel; only where each subscription's messages land changes.
The thread must already exist: this project never creates one, and a subscription
pointed at a deleted or archived-forever thread just fails delivery like any other
bad destination (see "Managing subscriptions" in docs/spec.md).
# Deliver into one forum post instead of the forum channel itself
pnpm add-subscription --guild-id <id> --webhook-url-env DISCORD_WEBHOOK_URL \
--thread-id 222233334444555566 --filter '{"sources":["hexium:valheim"]}'Every subscription is its own row: one webhook (one channel), its own filter,
mode and interval. Rows are independent, so you can add as many as you like and
the same event can go to several channels. Create one webhook per channel, put
each URL into its own variable in the git-ignored .env (see .env.example,
which already lists DISCORD_WEBHOOK_URL_HEXIUM and DISCORD_WEBHOOK_URL_NEXUS),
and give every subscription a readable --id. Inserting an id that already
exists fails instead of replacing the row.
# One channel per store
pnpm add-subscription --id thunderstore --guild-id <id> --webhook-url-env DISCORD_WEBHOOK_URL \
--source thunderstore:valheim
pnpm add-subscription --id hexium --guild-id <id> --webhook-url-env DISCORD_WEBHOOK_URL_HEXIUM \
--source hexium:valheim
pnpm add-subscription --id nexus --guild-id <id> --webhook-url-env DISCORD_WEBHOOK_URL_NEXUS \
--source nexus:valheim
# A channel that only follows the updates of one mod (full Owner-Name package id)
pnpm add-subscription --id epicloot-updates --guild-id <id> --webhook-url-env DISCORD_WEBHOOK_URL_EPICLOOT \
--mode immediate --package RandyKnapp-EpicLoot --kind update
# A channel that follows one author (every mod and every event of that owner)
pnpm add-subscription --id azumatt --guild-id <id> --webhook-url-env DISCORD_WEBHOOK_URL_AZUMATT \
--mode immediate --package Azumatt
# Everything except one noisy mod (or all mods of one owner)
pnpm add-subscription --id main --guild-id <id> --webhook-url-env DISCORD_WEBHOOK_URL \
--exclude-package SomeAuthor-NoisyModEvery command prints a pnpm run wrangler d1 execute command and runs nothing;
review it, then run it. Package entries match the package id or the owner exactly
(case-insensitive, no partial matches); when a package is both allowed and
excluded, the exclusion wins. Because the sources are matched separately, a
per-store channel receives the releases of its own store even when the same
release is also on another store.
Managing existing subscriptions works the same way, with pnpm subscriptions
(it also only prints the command; add --local for the local database):
pnpm subscriptions list # id, guild, mode, filter, enabled, thread id, webhook id (never the token)
pnpm subscriptions disable --id azumatt # stop delivering, keep the row
pnpm subscriptions enable --id azumatt
pnpm subscriptions set-filter --id main --exclude-package SomeAuthor-NoisyMod --exclude-package Other
pnpm subscriptions remove --id azumatt # deletes the row and its undelivered queue entriesset-filter replaces the whole filter with the flags you pass (use
--filter '{}' to clear it), so run list first to see the current one.
disable, enable and set-filter are silent when the id does not exist (0
rows changed in the wrangler output).
The filter is checked twice: when an event is queued and again just before
delivery against the subscription's current filter. After set-filter,
disable or remove, anything already queued that no longer matches is
dropped instead of being sent.
pnpm dev # wrangler dev --test-scheduledApply the schema to the local database once with
wrangler d1 execute ratatoskr --local --file=./schema.sql (your D1_DATABASE_NAME, if set). With
--test-scheduled, wrangler exposes a development-only route that fires the
cron handler; the deployed Worker has no such route.
curl "http://localhost:8787/cdn-cgi/local/scheduled?cron=*%2F5+*+*+*+*"The reconcile trigger runs at 03:01, 04:01 and 05:01 UTC and picks its run from the hour of the scheduled time, so pass a time inside one of those minutes:
curl "http://localhost:8787/cdn-cgi/local/scheduled?cron=1+3%2C4%2C5+*+*+*&time=1791082860000"Other commands:
pnpm test # vitest
pnpm typecheck
pnpm validate-config
pnpm lint
pnpm check # typecheck, lint, tests and config validation- Nothing is posted after the first deploy. Expected: the first run per source records current state only. New and updated mods are posted from the second run onward.
pnpm validate-configfails. The message names the offending field inratatoskr.config.json.add-subscriptionreports an invalid webhook URL. It must look likehttps://discord.com/api/webhooks/<numeric id>/<token>, with no query string.- Nothing arrives for a subscription. Check that its filter is not
excluding everything (for example
kindsorwatchlist), that NSFW content is not the only thing being reported, and that the row isenabled. - Updates from Hexium arrive late. By design: updates to existing Hexium packages are detected on a slower cadence (about every 15 minutes).
- Upstream stopped returning data. Parts of the Thunderstore and Hexium APIs are undocumented and may change. The Worker fails soft and keeps serving the other sources; see docs/api-notes.md.
wrangler d1 executechanged nothing in production. Without--remotewrangler targets the local database.
Conventional commits; TypeScript strict; zero runtime dependencies. See CLAUDE.md for the hard constraints of the Workers free plan.