The Better Lyrics community Discord bot. It does two things:
- Hands out curator roles based on where you land on the Unison leaderboard, re-synced hourly.
- Turns YouTube Music links posted in the report channel into "fix the lyrics" cards.
Account linking, identity, and the blacklist all live in Unison. butler reads from Unison and never runs the link flow itself.
Node >= 22.12, TypeScript (ESM, strict), discord.js v14, Postgres via pg, youtubei.js, vitest, Biome. Bundled with esbuild.
One process: a discord.js gateway client, no HTTP server. butler keeps a small Postgres for its own state (role holdings, which keep syncs idempotent, and per-guild config). Everything else (the link map, leaderboard, excluded keys, request board) comes from Unison. The business logic (URL parsing, album-art pick, tier math, the holdings diff, the request payload) is pure and tested with no network, database, or live Discord.
Three are required. The rest default to production values.
| Variable | Required | Notes |
|---|---|---|
DISCORD_BOT_TOKEN |
yes | Discord bot token. |
DATABASE_URL |
yes | Postgres connection string. |
BUTLER_BOT_SECRET |
yes | Must match the value set on Unison. |
GUILD_ID |
yes | The one guild butler serves. Commands register only here, and it ignores every other server. |
UNISON_API_BASE_URL |
no | Defaults to https://unison.boidu.dev. |
LINK_PAGE_URL |
no | Defaults to https://unison.boidu.dev/link. |
COMPOSER_BASE_URL |
no | Defaults to https://composer.betterlyrics.org. |
YTM_COOKIE |
no | Throwaway Google account cookie for richer metadata. |
pnpm install
pnpm dev # watch mode
pnpm build && pnpm start # production: esbuild bundles to dist/, then node runs it
pnpm test # vitest
pnpm run typecheck
pnpm run lintbutler serves the single guild named in GUILD_ID and ignores every other server. Invite it with Manage Roles, Read Message History, and Use External Emojis, then drag its role above the five tier roles. Enable the MessageContent privileged intent.
It ships dormant. Run /setup (admin only) to record the channels and the five tier roles; it posts the connect card but assigns nothing yet. When you are ready, run /activate: it flips butler on and runs the first sync, which seeds and announces every current curator. /deactivate puts it back to sleep (no syncing, announcing, or report watching). /sync runs a sync on demand once active, otherwise it runs hourly. /preview renders any card for a quick look.
Tiers, highest to lowest: #1 Legendary Lyricist, #2 Grandmaster Lyricist, #3 Master Lyricist, top 5% Elite Lyricist, top 20% Lyricist.
The Council role goes through an exam, not a hand-out. A member runs /council-apply; butler checks they are account-linked and hold at least the minimum role (/config exam-min-role, which defaults to the Lyricist role and counts anyone ranked above it), and turns away anyone already on the Council. Eligible members get a one-time link to the exam, which Unison serves on the web, with the lyric guide to read first. Admins skip those checks so they can walk the flow themselves, and the tricky question content, answer keys, and grading all live on the Unison side.
Whoever passes surfaces two ways: butler posts them to the council channel on its own (on the hourly pass), and admins can pull the list any time with /council-applicants. Each applicant card has approve and reject, both behind a confirm step since neither can be undone from Discord. Approving grants the role, registers them in Unison, and DMs them a welcome. /council-welcome-preview sends that welcome to you so you can check it first.
When a lyric owner saves an edit that needs council review (a sealed lyric, or a text or timing change over the limit), butler posts it to the council channel on the hourly pass. Each card shows the reason, how much changed, who made the edit, a short diff preview, and the full diff as an attached file. Any council member can approve it, which makes the edit live, or reject it with an optional reason. The card updates in place. Cards for revisions that were withdrawn, replaced by a newer edit, or decided somewhere else close on the next pass.
Railway, as a worker. No public port, since butler only makes outbound calls. Set the three required vars (plus DEV_GUILD_ID while testing). If BUTLER_BOT_SECRET does not match Unison, the bot reads and the request POST will 401. There is no OAuth to configure here; that is Unison's side.
GPL-3.0-or-later. See LICENSE.