A Discord ↔ Minecraft bridge bot for Civ-style servers (CivPVP, Eden, anywhere using CivModCore + JukeAlert + NameLayer). Replaces the Kira bridge bot with a leaner, plugin-only deploy: one Velocity plugin + one Paper companion. No broker. No Postgres. No dedicated bot host.
- Discord ↔ MC chat relay scoped per NameLayer chat group
- Multi-bind channels: one Discord channel can bind several NameLayer groups — one writer (2-way) plus read-only readers (
/relay writer <group>to pick the writer) - The high-traffic global group
!can only be relayed from the home guild, to avoid rate-limiting every other relay - JukeAlert snitch alerts forwarded to Discord
- Player login/logout feed: one Discord channel mirroring proxy connect / disconnect / server-switch activity, bound with
/loginfeed bind(home guild only) - Live server stats: player count, TPS, uptime, peak concurrent, and unique/new players — shown as bot presence, an auto-updating embed dashboard, optional DiscordSRV-style voice/topic labels, and an on-demand
/status. Bound with/stats(home guild only) /admin run <server> <command>console executor- In-game ↔ Discord account linking (
/discord link→/link <code>), mintable from the proxy or a Paper backend - Per-guild "verified" auth role on link
- Multi-guild: invite the bot to faction/nation Discords; each manages its own relay rooms
- Optional Patreon role sync
Before installing, register an application with Discord:
-
Open the Discord Developer Portal and click New Application. Name it whatever you want — players see your bot's username, not the application name.
-
Go to the Bot tab. Click Reset Token and copy the new token — this is the value you'll put in
discord.token(orCIVDISCORD_DISCORD_TOKEN) later. The token is shown only once. -
Still on the Bot tab, scroll to Privileged Gateway Intents and enable both:
- Server Members Intent — required for role grants on link and for member-cache lookups.
- Message Content Intent — required for Discord→MC chat relay.
If you skip either, the bot will fail to connect with
WebSocket closed with code 4014/DISALLOWED_INTENTSon startup. -
Go to OAuth2 → URL Generator (or use the template below), pick scopes
botandapplications.commands, then pick the permissions listed under Bot invite scopes & permissions. Use the resulting URL to invite the bot to your home guild.Pre-built invite URL (replace
YOUR_CLIENT_IDwith the Application ID from the General Information tab):https://discord.com/api/oauth2/authorize?client_id=YOUR_CLIENT_ID&permissions=2952817664&scope=bot+applications.commandsThe
permissions=2952817664bitmask encodes exactly the permissions documented below. If you customize the permission set, recompute the bitmask via the OAuth2 URL Generator.
- Drop
CivDiscord-Velocity-*.jarinto your Velocity proxy'splugins/directory. - Drop
CivDiscord-Paper-*.jarinto each Paper backend'splugins/directory. - Start once — both plugins will write default
config.ymlfiles into their data directories. Note: Velocity writes toplugins/civdiscord/(lowercase); Paper writes toplugins/CivDiscord/(capital). Case matters on Linux. - Edit
velocity/plugins/civdiscord/config.yml:
discord:
token: your-bot-token # or omit and set CIVDISCORD_DISCORD_TOKEN env var
home_guild_id: 1234567890
database:
path: civdiscord.db
namelayer_db: # the backend's NameLayer MariaDB — proxy reads it
host: db.example.com # directly for /relay permission checks
port: 3306
user: namelayer
password: ${CIVDISCORD_NAMELAYER_DB_PASSWORD}
database: citadel
# patreon block optional — see the commented template in the default config.yml.- Edit each
<paper-backend>/plugins/CivDiscord/config.yml:
server:
name: citadel # Velocity's name for this backend- Restart the proxy + backends.
- In the home guild, run
/admin guild viewto confirm the bot is online and the config loaded. - (Optional) For each invited guild that wants auth-role grants on link, run
/admin guild auth-role role:@yourrole.
Invite the bot with scopes bot applications.commands and the following minimum permissions:
Manage Roles(only roles below the bot's top role)Manage Webhooks(required per relay channel — without it, MC→Discord chat relay is disabled and Discord shows only the bot's plain text echo)Manage Messages(required per relay channel — used to delete the user's original message after the webhook re-posts it under their MC name)View ChannelsSend MessagesUse Slash CommandsEmbed Links(for snitch alerts and the stats dashboard)Manage Channels(optional — only if you use/statsvoice-channel or topic surfaces; lets the bot rename those channels / edit the topic)
Do NOT grant Administrator. The bot does not need it.
If you're cutting over from an existing upstream Kira deploy, read this first. CivDiscord does not share storage with Kira, and a naive swap silently drops every existing account link.
- Account bindings. Kira's Postgres
discord_id ↔ minecraft_uuidtable is schema-incompatible with CivDiscord's SQLite store. There is no importer. Every player must re-link via/discord link(in-game) →/link <code>(Discord). Announce a re-link window (48h is typical) in#announcementsand your in-game MOTD before cutover. - Patreon role state. CivDiscord recomputes tiers on its first sync after install. Expect supporter roles to be momentarily stale (up to one sync interval) for users who have already re-linked.
- Bridged chat history. Neither system backfills the gap during cutover. Unavoidable.
- Discord channel IDs Kira was bridging to. Point CivDiscord's
/relay bindat the same channels. - Webhook URLs in those channels — CivDiscord will reuse an existing webhook if one is present and it has
Manage Webhooks, or create a new one if not. - Bot token and Patreon OAuth credentials. Reuse the same Discord application and the same Patreon
access_token/refresh_tokenyou fed Kira. (You can also rotate at this point — it's a convenient moment.)
- Preserve Kira state. Before stopping Kira, snapshot Postgres (
pg_dump) and RabbitMQ definitions (rabbitmqctl export_definitions) somewhere safe. Keep these and the live data volumes around for at least 2 weeks — they are your only rollback path. - Stop the Kira bot container (leave RabbitMQ + Postgres running for now).
- Remove
kira-gatewayjars from each backend'splugins/directory. - Install CivDiscord per Installing above.
- Run post-cutover admin commands:
/admin guild view— confirm the bot is online and config loaded./admin guild auth-role role:@yourrole— per invited guild that wants verified-role grants on link./relay bind <group>in each channel that used to be a Kira relay room — CivDiscord doesn't read Kira's routes. (The binder must holdREAD_CHATin that NameLayer group.)
- Announce re-link. Players run
/discord linkin-game, then/link <code>in Discord.
CivDiscord does not touch Kira's RabbitMQ or Postgres, so as long as you preserved them (step 1 above), rollback is:
- Stop and remove CivDiscord (
CivDiscord-Velocity-*.jarfrom Velocity,CivDiscord-Paper-*.jarfrom each backend). - Redeploy the Kira jars (
kiracontainer + per-serverkira-gatewayplugins). - Restart the proxy and backends.
Any binding a user established on CivDiscord during the window is lost on rollback; they go back to their Kira-era binding. Tell affected users.
After ~2 weeks of clean CivDiscord operation, you can delete the RabbitMQ + Postgres data volumes and close out the rollback window.
Authorization rides on Discord's native application-command permissions. Defaults are intentionally narrow; guild owners can widen or narrow them via Server Settings → Integrations → CivDiscord. The defaults are:
| Command | Default permission | Scope |
|---|---|---|
/link <code> |
@everyone |
any guild the bot is in |
/me |
@everyone |
any guild the bot is in |
/discord link, /discord status |
all players | the Velocity proxy or any backend |
/relay … |
MANAGE_CHANNELS (per-channel) |
the channel it's run in |
/admin guild … |
MANAGE_SERVER (per-guild) |
the guild it's run in — affects only that guild's auth role + relay roster |
/admin user … |
MANAGE_SERVER in the home guild |
cross-network |
/admin run … |
MANAGE_SERVER in the home guild |
dispatches console commands to any backend |
/loginfeed … |
MANAGE_SERVER in the home guild |
the channel it's bound to |
/status |
@everyone |
home guild |
/stats … |
MANAGE_SERVER in the home guild |
home guild (voice/topic surfaces also need the bot to hold Manage Channels) |
Note: a Discord guild owner can override these defaults from the Integrations UI. CivDiscord adds no second Discord-layer authorization — if Discord lets the user invoke the command, the bot trusts it. The one exception is NameLayer gating on relay binds: /relay bind and /relay writer require the invoker (a linked account) to hold READ_CHAT in the target NameLayer group, and turning on show-snitches requires SNITCH_NOTIFICATIONS. These are read from the backend's NameLayer MariaDB (namelayer_db).
The bot can surface live server stats in Discord. In the home guild, an admin (MANAGE_SERVER) binds the surfaces with /stats:
/stats dashboard-set— post an auto-updating embed (players X/max, TPS, uptime, peak today/all-time, unique/new players) in the current channel; it refreshes about once a minute and self-heals if deleted./status— anyone can pull the same stats on demand (ephemeral), plus the current online roster./stats players-channel <vc>//stats tps-channel <vc>— rename voice channels to show the headline numbers (DiscordSRV-style). These update at most every ~10 minutes — Discord's channel-edit limit — and needManage Channels./stats topic-add/topic-remove— add or drop the current channel's topic stats line (you can add several channels);/stats topic-clearclears them all. Also ~10 min, and topics show a literal UTC time rather than a live timestamp./stats showlists what's bound;/stats dashboard-clear,voice-clear, andtopic-clearunbind.
TPS and uptime come from each Paper backend over the bridge; when the server is empty there's no carrier player to ferry the frame, so TPS shows — (the count is 0 anyway). Cadence and limits live in the Velocity config.yml:
stats:
enabled: true
max_players: 150 # shown as X/max; 0 hides the denominator
fast_seconds: 60 # presence + embed refresh
slow_minutes: 10 # voice/topic refresh (Discord's floor)
metrics_stale_seconds: 90 # TPS older than this shows as a dashand how often each backend reports, in that backend's config.yml:
metrics:
interval_seconds: 30- Generate a new token in the Discord Developer Portal.
- Edit
velocity/plugins/civdiscord/config.ymland replacediscord.token, or setCIVDISCORD_DISCORD_TOKENin the proxy's environment and delete thetoken:line. - Restart the proxy. The Paper backends do not hold the token.
Patreon access tokens expire after ~31 days. If you've configured Patreon sync:
- Provide a
refresh_tokenalongsideaccess_tokeninconfig.yml. The bot will rotate the access token automatically when it expires and log the rotation. - If the refresh token itself is revoked or expires (~1 year), Patreon role sync will start logging
ERRORlines on every sweep. Re-authorize via the Patreon Creator Dashboard and update both fields.
The SQLite DB runs in WAL mode, so online backups are safe while the bot is running:
sqlite3 velocity/plugins/civdiscord/civdiscord.db ".backup civdiscord-$(date +%F).bak"Schedule this from cron or your backup system. The DB is small (KBs–low MBs); keep daily snapshots.
The DB only holds link bindings, per-guild relay routes, guild config, and Patreon tier caches — none of it is irreplaceable.
- Stop the proxy.
- Move the corrupt
civdiscord.db(and-wal/-shmsiblings) aside. - Start the proxy. A fresh DB will be created and migrations re-run.
- Operators re-run
/admin guild auth-role,/relayfor each channel, and players re-link via/discord link→/link <code>.
Add to your proxy's logback.xml (or backend's):
<logger name="io.github.grepsedawk.civdiscord" level="DEBUG"/>Or set per-package, e.g. …civdiscord.velocity.bridge for just bridge traffic.
WebSocket closed with code 4014/DISALLOWED_INTENTSat startup: you forgot to enable the privileged intents in the Discord Developer Portal. Re-read Creating the Discord bot and toggle on Server Members Intent and Message Content Intent under the Bot tab.InvalidTokenException/4004close code: the token inconfig.yml(orCIVDISCORD_DISCORD_TOKEN) is wrong or was reset. Generate a new one via Bot → Reset Token and update the config.- Bot online but
/relay,/admin, etc. don't appear: you invited the bot without theapplications.commandsscope. Re-invite using the URL from Creating the Discord bot.
libs/ ships the Civ plugin primitives (CivModCore, NameLayer, JukeAlert, CivChat2) plus ACF, vendored because they aren't published to a public Maven repository. Every jar in libs/ has a corresponding entry in libs/CHECKSUMS.sha256 with source URL and SHA-256.
Reviewers MUST verify checksums when accepting any PR that touches libs/. CI runs shasum -a 256 -c libs/CHECKSUMS.sha256 on every push.
To refresh a vendored jar:
- Download the new release from the source URL.
- Replace the file in
libs/. - Update its line in
libs/CHECKSUMS.sha256with the new hash and version comment. - Open a PR; the CI checksum job will pass only if your hash matches.
CivDiscord is designed for the standard Civ-style operator: Velocity proxy fronting trusted Paper backends, none directly reachable on the public internet.
- Bridge protocol (
civdiscord:bridgeplugin-message channel): proxy↔backend traffic is HMAC-SHA-256 authenticated by default. Velocity auto-generatesplugins/civdiscord/secret.keyon first run — copy it toplugins/CivDiscord/secret.keyon every Paper backend (e.g.scp velocity-host:/path/to/secret.key paper-host:/path/to/secret.key). Note the case difference: Velocity usescivdiscord(lower), Paper usesCivDiscord(capital). Paper refuses to register the incoming channel until the key is present, so missing-key installs simply ignore bridge frames instead of accepting them unauthenticated. Do not expose Paper backends to direct connections from the public internet. /admin run: anyone withMANAGE_SERVERin the home guild can dispatch arbitrary console commands (op, deop, stop, ban-ip, anything). Treat home-guildMANAGE_SERVERas equivalent to root on every backend.- Link codes: 12-character codes drawn from a CSPRNG alphabet, single-use, expire after ~10 minutes. Minting is unthrottled, but
/link <code>redemption is rate-limited per Discord user. Each redemption is audit-logged. Codes are not secret long-term — they're one-shot — but the link flow prints them in plain in-game chat (see streamer note below). - Discord-side authorization is entirely delegated to Discord's app-command permission system. CivDiscord does no second authorization check. Guild owners who widen defaults via the Integrations UI accept the consequences.
- Patreon credentials (if configured) are read from disk only. The bot does not log refresh tokens. Restrict
config.ymlpermissions to0600.
/discord link prints the generated code in plain in-game chat. Streamers and content creators should be aware that codes are screenshot- and clip-leakable. Mitigations already in place:
- Codes are single-use and short-lived (TTL on the order of minutes).
- Each Discord user is rate-limited on
/linkattempts. - Every redemption is audit-logged with timestamp + Discord user + claimed MC UUID.
If you accidentally publish a live code, just generate a new one via /discord link again — the old one expires immediately if redeemed, and naturally on TTL.
mise exec -- ./gradlew :velocity:shadowJar :paper:shadowJar
# Output: velocity/build/libs/CivDiscord-Velocity-*.jar
# paper/build/libs/CivDiscord-Paper-*.jarRequires Java 21 (pinned via mise.toml).
Gradle multi-module project:
core/— pure-JVM domain logic: the bridgePayloadcodec, Exposed/SQLite DAOs and numbered migrations, the Patreon client, relay routing, andMarkdownSafeoutput escaping. No Velocity/Paper API on the classpath, so it unit-tests on a plain JVM.velocity/— the proxy plugin: JDA gateway, slash commands (/link,/me,/relay,/admin,/loginfeed,/stats,/status) plus a proxy/discordcommand that mints link tokens locally (no Paper round-trip), snitch fan-out, the chat relay router, and the live server-stats publishers.paper/— the backend plugin: JukeAlert snitch listener, CivChat2 hooks, console executor, and the/discordtree (fallback for backends).
Velocity and each Paper backend talk over the civdiscord:bridge plugin-message channel, framed as kotlinx.serialization JSON Payloads and signed with HMAC-SHA-256 (shared secret.key, on by default). Plugin messages need a connected player to ferry them, so NameLayer permission checks bypass the bridge and query the backend's MariaDB directly from the proxy (namelayer_db config). A Discord channel binds to one or more NameLayer groups: one writer (2-way) plus read-only readers; Discord-origin chat is fanned out in-process to every channel bound to the writer's group.
State lives in a per-proxy SQLite DB (WAL, foreign keys). All user-controlled strings pass through MarkdownSafe before reaching Discord.
See also CONTRIBUTING.md and SECURITY.md. Release notes live on the GitHub releases page.
MIT. See LICENSE.