- One process, two clients. A
discord.jsclient and onemineflayerbot per Hypixel guild run in the same Node process and reach each other through a single shared-state module. - There is no “current bot”. Every Hypixel guild is addressed by key. A scalar “the bot” would silently act on the wrong guild the moment a call site forgot to say which one it meant.
require/module.exports). No build step and no linter; unit tests live in test/ and run
with npm test. Node v22+.
Startup
src/index.js loads the bot’s settings from the database (stopping there if it cannot), seeds the guild
registry, builds the Discord client, populates the shared bridge object,
wires the Discord event handler and logs in.
It deliberately does not create any Minecraft bot. events/discord/clientReady/002autoReconnect.js owns
every connect, so there is exactly one code path for it — and Discord comes up even when no guild can connect,
because /guilds is the only way to repair a broken registry.
Shared state
src/bridge.js is the single shared-state module. Both sides import it to reach each other:
There is no dependency injection. New cross-client state belongs here.
A
BotRecord carries the mineflayer bot, its connection flags, backoff state, the per-bot query queue, the
outbound chat queue, and a disposer that removes the handlers bound to that bot. Three of its flags are
subtle:
connectingis cleared by thespawn/end/error/watchdog handlers, never in afinallyaftercreateMcBot()returns. The bot is still mid-handshake at that point, and clearing it early lets the next poller tick tear down a bot that was about to connect.awaitingDeviceCodesuppresses the poller for that guild. Without it, a guild needing a Microsoft sign-in starts a second flow ten seconds later, then a third.tribridgeRetiredis set on a bot being torn down. Every Minecraft handler bails on it, and nothing caches a bot across anawait— readrecord.botfresh and re-check.
The guild registry
src/utils/guilds.js is the guild registry; src/utils/mcBots.js holds the accessors for
bridge.mcBots. Between them they answer every “which guild?” question.
Resolve a user-supplied guild with guilds.resolveKey(). Autocomplete values are attacker-controlled;
never index bridge.mcBots with a raw option value.
The three settings modules — guilds, globalProfile, auditChannel — share one shape over
utils/configStore.js: an async load() that index.js awaits before login, synchronous reads from an in-memory
cache after that, and async writes through configStore.commit(): the change is made to a copy, the copy is saved
whole to the tribridge.config table, and only then does it replace the cache. A save that fails leaves memory
as it was, so a disguise that was never saved can’t run unaudited, and a failed guild edit can’t ride along on a
later save. Changes are queued per setting, so each starts from the last saved document and none is lost. Each
module keeps its normalise() — the guards a hand-edited file used to need — and applies it to whatever is
loaded, from the database or from a legacy JSON file being imported. Reads stay synchronous on purpose: they
run on every relayed message, and a database round trip there would put the relay at the mercy of the network.
Every successful save, and every load from the database, also rewrites the matching *Config.json as a
snapshot (temp file, then rename, so a crash leaves the previous one intact). The snapshot is written only
after the database accepted the document, which is what makes it safe to start from: it never holds a setting
the database rejected. When every startup retry fails with a DatabaseUnavailableError, index.js loads all
three modules with { fromSnapshot: true }, configStore.isDegraded() turns true, and the ready handler warns in
the log channel. Writes still go to the database first, so in that mode they fail cleanly, and once the database
is back they succeed again with nothing to reconcile. Other startup errors (a corrupt legacy file with no row, an
unset DATABASE_URL) still stop the bot, because those are not outages that fix themselves.
A write that fails rejects. Two callers deliberately don’t wait for it:
createMcBot and 002probeHypixelGuildId record the Minecraft profile and Hypixel guild id with a logged
.catch(), because a save failure must not block a connect (the value is then simply learned again on the next connect), and a rejected event handler is an unhandled
rejection that would end the process.
The event handler
src/handlers/eventHandler.js is used for both clients. Given a client and an events root, each
subdirectory name is treated as an event name and every file inside is invoked in sorted filename order with
(client, ...args):
000resolveServer.js, 001registerCommands.js, 002autoReconnect.js) exist only to force
ordering — keep the convention where order matters.
The first parameter is the client the event came from. In events/minecraft/**, client is the
mineflayer bot, not the Discord client: reach Discord via bridge.discordClient, and the emitting bot’s
Hypixel guild via mcBots.guildForBot(client), backed by bot.tribridgeGuildKey which is set before the bot
can emit anything.
eventHandler returns a disposer that removes everything it registered. The Discord call site ignores it;
createMcBot stores it on the record, so one bot can be torn down without disturbing the others.
Commands
src/commands/<category>/<name>.js. The category folder name becomes a page in /help, so adding a folder
adds a help category.
001registerCommands.js diffs local commands against the registered application commands via
utils/areCommandsDifferent.js and creates, edits or deletes them globally on startup. Global propagation
can take up to an hour on Discord’s side.
defaultMemberPermissions only hides a command — a server administrator can override it in Discord’s
integration settings — so pair it with permissionsRequired, which is what enforces it. It is sent as null
when absent, so removing it from a command clears it on Discord’s side too.
The diff compares autocomplete, maxLength and defaultMemberPermissions and recurses into subcommand options, so nested changes do propagate — but
choices and autocomplete are mutually exclusive in the Discord API. Never set both.
Two permission systems
permissionsRequiredis enforced generically byhandleCommands.js— real Discord permissions likeAdministrator.- Bot-admin is checked inside the callback with
isAdmin(interaction.member)fromutils/adminRoles.js, which reads the community bot’sAdminRoletable for the bridge’s Discord server, cached for 15 seconds. The list is shared and set with the community bot’s/adminrole; TriBridge has no write path. It is async and fails closed — no roles set, or an unreadable database, means no admins — and callers must keep theawait: a bare promise is truthy.
handleCommands.js refuses any interaction whose guildId is not
bridge.discordServerId. Commands are registered globally but the bot serves one Discord server, and without
that check an administrator of any other server the bot is in could set admin roles for that server on the
community bot and inherit bot-admin over the bridge’s Minecraft accounts. It fails closed. Do not add a
command dispatch path that skips it.
Right after it, every interaction handler calls refuseIfBlacklisted() from utils/blacklist.js, and both
Discord→Minecraft relays check isBlacklisted(). The event handler runs each file in an event folder on its
own, so no handler can stop the others — a new handler needs its own check. The blacklist fails open, the
opposite of isAdmin: it reads the community bot’s Blacklist table, and failing closed would stop every
command and the whole bridge during a database outage. It is also the one table in the community bot’s schema
this bot writes, so writes clear its 15-second cache.
Talking to Hypixel
Three helpers, each solving a problem you will otherwise rediscover:queryGuild owns its listener and timer cleanup — never hand-roll a message listener. Pass format: 'motd'
when the colour codes carry meaning (§a marks online players in /g online).
Commands that build a chat command by hand — /invite, /kick, /promote, /demote — bypass
buildGuildChatCommand, so they must validate the username with isValidMinecraftName() and run free text
through sanitizeForChat() first. /send is the deliberate exception: sending an arbitrary command is the
whole point of it, which is why it is admin-gated.
Parsing guild chat
utils/guildChat.js holds parseGuildChat(), parseGuildPresence() and parseOfficerChat() — regex against
Hypixel’s English chat output, and inherently brittle. It lives in one place because several relays read
the same lines. Keep the documented format comment next to any regex you add or change, and keep the officer
pattern separate — see Officer chat for why.
Relay loops
This is the part to understand before touching a relay.relayToDiscord.jsdrops messages authored by any registered bot account viamcBots.isOwnAccountName()— not just the emitting bot’s own name, because two accounts in the same Hypixel guild would otherwise relay each other forever.relayToGuilds.jsdepends on the same check to terminate at all. A forwarded copy is spoken in the target guild by that guild’s own bot, so without it two guilds echo one line at each other until Hypixel mutes both accounts. It is the single most load-bearing line in that file.- On the Discord side,
relayToMinecraft.js’smessage.author.botguard is what stops the webhook repost looping. Don’t weaken it. utils/relayDedupe.jscatches the same player saying the same thing in two guilds within two seconds. Each caller gets its own instance, so one relay consuming a line never hides it from another.
Mentions from Minecraft
utils/mentions.js turns @name in a relayed line into a Discord ping, for both relayToDiscord.js and
relayOfficerToDiscord.js. resolveMentions() tries the bridge’s own account link for that Minecraft name,
then the REST member search (no GuildMembers intent needed) for an exact username, or a display name or
nickname only one member has. mentionPayload() puts the ids in the message content, because a mention in an
embed never notifies, and sets allowedMentions to { parse: [], users: ids }. That is the load-bearing part:
guild chat is open to everyone in the guild, so parsing mentions from it would let any guild member ping
@everyone. Lookups are cached, a line pings at most five people, and a failed lookup drops the ping, never the
message.
Routing Discord → Minecraft
routeMessage() from utils/chatRouting.js decides which guilds a bridge message reaches: !tag for one,
otherwise all. It is pure — test it there rather than through the handler.
Four things the handler must get right:
- Build the
/gccommand once and reuse it for every target. Identical name plus identical body means identical truncation, so the 100-character budget stays deterministic. Hypixel’s duplicate-message filter is per account, so identical text from several accounts is fine — don’t “fix” it by varying the text. bodyis what the repost must show.repostAs(message, identity, {content: body})— the repost is the only surviving copy, and rawmessage.contentwould leave!sbvisible in Discord while guild chat got the stripped text.- React to
result.messagewhen a repost happened. The original is deleted, so a marker on it is either a race or a decoration on a tombstone. - The guild tag never enters the
/gccopy. It is a Discord-side label.
Guild → guild
utils/crossBridge.js decides the targets (crossBridgeTargets(), registry-driven and testable offline) and
does the fan-out. Opt-in per guild via the crossBridge flag, set with /guilds edit.
The flag is symmetric — a guild that doesn’t send doesn’t receive, and the source must have it on too,
otherwise switching it off wouldn’t stop that guild’s chat being pushed everywhere. The [TAG] goes in the
name position (/gc [SB] Notch: hi), unlike the Discord relay where the tag is a Discord-side label.
Forwarded chat passes maxAgeMs to sendChat, so a spam burst in one guild is dropped rather than delivered
minutes late into another — don’t remove that without another backpressure story. Presence lines are not
forwarded, and the global profile disguise deliberately does not apply.
Officer chat
Officer chat has its own rails at every layer —OFFICER_PATTERN and parseOfficerChat(),
buildOfficerChatCommand(), officerCrossBridgeTargets() and relayOfficerAcrossGuilds(), its own
crossBridgeOfficer flag, and its own dedupe instances. Nowhere does it share a code path with ordinary
guild chat.
That duplication is the point, not an oversight. Each shared layer would be a place where one edit silently
moves privileged chat: widening CHAT_PATTERN to (?:Guild|Officer) > would immediately push officer chat
into the bridge channel, the guild-to-guild relay and the chat-command dispatcher, and reusing
crossBridge would mean switching ordinary sharing on also started broadcasting officer chat. The one thing
it does share is routeMessage() from utils/chatRouting.js, and only because that function takes the
candidate guilds as an argument — the officer leg passes the guilds on its channel, never the registry.
Three handlers:
The two Discord-facing legs are inert until a guild has an
officerChannelId; the guild-to-guild leg needs
only crossBridgeOfficer.
isOwnAccountName()terminates both inbound paths. Same reasoning asrelayToGuilds.js, now covering the copy this guild’s own bot just spoke on behalf of the Discord officer channel.- The Discord leg routes with
!tag, scoped to the channel. Guilds may share one officer channel, soguilds.getAllByOfficerChannel()is plural and its result is thecandidatesset handed torouteMessage(). Scoping it is load-bearing: an unscoped tag lookup would turn any officer channel into a way to speak into a guild it was never wired to. There is still no fan-out past the routed targets. - It must not use
resolveIdentity(). That applies the global profile change; the officer leg reads the account link directly.000disguiseMessages.jsalso skips officer channels outright — otherwise a running disguise would repost the message as a webhook and themessage.author.botguard would drop it, losing the line with no error anywhere. crossBridgeOfficerstands alone.guilds.getOfficerCrossBridged()filters the registry on that flag andenabledonly, deliberately not oncrossBridge: sharing officer chat and sharing ordinary chat are separate decisions. It stays symmetric, so a guild with the flag off neither sends nor receives.
Reconnection
002autoReconnect.js polls every 10s and starts at most one connect per tick, iterating
guilds.getEnabled(). The stagger stops a Hypixel restart bringing every account back at once.
createMcBot(guildKey) retires the old bot, re-authenticates and re-registers the Minecraft handlers.
Never call removeAllListeners() with no argument — it strips mineflayer’s own internal listeners and
the error listener, and an EventEmitter with no error listener turns a late socket error on a dead bot
into an uncaught exception that kills the process. Use retireBot(), which removes named events and
re-attaches an error sink.
Device codes go to the requesting admin’s ephemeral reply, else a DM to addedBy, else the console
(utils/deviceCode.js). They must never reach the log channel or any shared channel: whoever holds the
code can complete the sign-in with their own Microsoft account.
Conventions
- “Guild” means two things — keep them apart. A Hypixel guild has a
guildKeyand lives in the guild registry. A Discord server isbridge.discordServerId. discord.js calls a server a “guild”, sointeraction.guildIdis a Discord server id — that is the library’s word, not ours. Never introduce a bareguildIdin new code. - Picking a guild in a command:
resolveTarget(interaction)fromutils/commandGuild.js, withGUILD_OPTIONinoptionsandguildOptionAutocomplete()as theautocompleteexport. UseguildPhrase(guild)/inGuild(guild)in reply text so a single-guild install keeps its original wording. - Replies:
deferReply()(deferUpdate()for panel components) first for anything touching a Minecraft bot or saving a setting, theneditReply(). User-facing strings use ✅ / ⚠️ / ❌ prefixes and>blockquotes. - Minecraft text: strip formatting with
.replace(/§./g, '').jsonMsg.toString()for plain text,jsonMsg.toMotd()when the colour codes carry meaning. - Discord limits: embed description 4096, message content 2000. Truncate explicitly when relaying server output.
- Style: Prettier defaults — 2-space indent, double quotes, semicolons, trailing commas, 80 columns, as
recorded in
.prettierrc. JSDoc on non-trivial helpers.
Testing without connecting
Running the bot connects to live Discord and Hypixel — don’t start it to “verify” a change. A good deal is testable offline against fakes:utils/chatRouting.js, utils/guilds.js, utils/crossBridge.js,
utils/queryGuild.js (drive it with a bare EventEmitter whose chat() emits stub messages), retireBot,
utils/chatQueue.js and utils/areCommandsDifferent.js are all pure or injectable.