Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,9 @@ falling back to a legacy shared archive with the per-server snapshot laid over t
| `/banall <players> <reason>` | Ban a list of players across servers |
| `/notice` | Author the in-game notices players see (create, broadcast, list, expire, enable, translate, show) |
| `/reply <player> <text>` | Send a player an in-game message from staff |
| `/link <code>` | Link your Discord to your Minecraft account (code comes from `/link` in game) |
| `/unlink [player]` | Unlink a Minecraft account from your Discord |
| `/linked` | List the Minecraft accounts linked to your Discord |
| `/ping` | Health check |
| `/reloadCommands` | Reload all bot commands |

Expand Down Expand Up @@ -169,6 +172,52 @@ a doc is written at the start of **every** countdown, at the one choke point all
Writes are fire-and-forget and wrapped: a Mongo outage can never fail or delay a reboot. Turn the
relay off with `scheduler.rebootScheduler.rebootEvents: false` in `config/config.json`.

## Discord ↔ Minecraft linking (Bifrost contract)

The proxy mints a 6-character code in game (`/link`), the player brings it here with
`/link code:<code>`, and the link is written onto their `bifrost.players` doc. The proxy watches
that collection, so the in-game confirmation card lands about a second later.

**One Discord ↔ many Minecraft accounts, one Minecraft account ↔ one Discord.** A second Discord
claiming an already-linked account is refused — that account has to run `/unlink` in game (or here)
first.

**The code is claimed, not read.** `/link` burns it with the lookup (one `findOneAndUpdate` on
`usedAt: null`) and only then writes the player, with that update filtered on the account still
being free. Two Discords racing one code therefore make exactly one link, and an in-game link
landing mid-flow wins instead of being overwritten. A refusal still spends the code — the reply
says so, and the player just runs `/link` in game again for a fresh one.

**`bifrost.players`** (the three fields this writes):

| Field | Notes |
| --- | --- |
| `discord_id` | the snowflake **as a string** — a snowflake does not survive a JS number |
| `discord_name` | the Discord username at link time |
| `discord_linked_at` | `Date` |

**`bifrost.discord_link_codes`** (minted in game, burnt here): `code` (6 Crockford base32 chars, no
I/L/O/U), `uuid`, `username`, `createdAt`, `expiresAt` (10 min), `usedAt`, `usedBy` (Discord id).
A typed code is folded before the lookup: upper-cased, spaces, dashes and underscores stripped,
`O`→`0`, `I`/`L`→`1` (`discord/commands/util/linkCode.js`, the same rules as the proxy's
`codes.ts`). The indexes are created here under the proxy's exact names and options (`link_code`
unique, `link_uuid`, `link_ttl` on `expiresAt`), so whichever side gets there first the other finds
them already right.

**`bifrost.discord_link_audit`** (history, nothing reads it in flight): `uuid`, `discordId`,
`action` (`link` / `unlink`), `by: 'discord'`, `discordName`, `at`, plus `actor` when staff undid
someone else's link.

`/unlink [player]` removes a link: players only their own accounts (the `player` option
autocompletes over them, and is only needed with more than one linked), a member with **Manage
Guild** anyone's. `/linked` lists what this Discord holds.

**Optional Verified role:** set `discordLink.verifiedRoleId` in `config/config.json` to a role id
string and `/link` grants it, `/unlink` takes it back once that Discord has no linked accounts
left. `false` (the default) leaves roles alone — don't use `null` or `""`, the config check treats
an empty value as unfilled and stops the bot. The bot needs **Manage Roles** and its own top role
**above** the Verified role; a role failure is logged and never fails the link itself.

## Schedulers

| Scheduler | Description |
Expand Down
99 changes: 99 additions & 0 deletions discord/commands/link.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/*
* /link code:<code>
*
* The Discord half of the in-game /link flow: the proxy mints a 6-character code and
* shows it on a card, the player brings it here, and this writes the link onto their
* bifrost.players doc. The proxy watches that collection, so the confirmation card
* lands in game about a second later.
*
* One Discord may hold several Minecraft accounts; one Minecraft account holds at most
* one Discord (a second Discord has to wait for an in-game /unlink). Any member can run
* it, replies are ephemeral, and the optional Verified role never fails a link.
*
* The code is CLAIMED before anything else: reading it first and burning it after let two
* Discords redeeming the same code both pass the check and both write the player. The
* write is guarded the same way, so an in-game link landing in between loses the race
* rather than getting overwritten - a refusal spends the code, and the player just runs
* /link in game again.
*/

const { SlashCommandBuilder } = require('discord.js');
const mongo = require('../../modules/mongo');
const sessionLogger = require('../../modules/sessionLogger');
const { CODE_LENGTH, normalizeCode, isValidCode, buildLinkAudit } = require('./util/linkCode');
const verifiedRole = require('./util/verifiedRole');

const BAD_CODE = '❌ That code is not valid or has expired — run `/link` in game for a fresh one.';

module.exports = {
data: new SlashCommandBuilder()
.setName('link')
.setDescription('Link your Discord to your Minecraft account with the code from /link in game')
.setDMPermission(false)
.addStringOption(option =>
option.setName('code')
.setDescription(`The ${CODE_LENGTH}-character code the proxy showed you in game`)
.setRequired(true)),

async execute(interaction) {
await interaction.deferReply({ ephemeral: true });

const code = normalizeCode(interaction.options.getString('code'));
if (!isValidCode(code)) {
await interaction.editReply(BAD_CODE);
return;
}

// Claim first - one atomic write decides who holds this code.
const codeDoc = await mongo.claimLinkCode(code, interaction.user.id);
if (!codeDoc || !codeDoc.uuid) {
await interaction.editReply(BAD_CODE);
return;
}

const player = await mongo.getBifrostPlayerByUuid(codeDoc.uuid);
if (!player) {
await interaction.editReply(
'❌ That code points at an account the proxy no longer knows — run `/link` in game again.');
return;
}

const username = player.username || codeDoc.username || 'your account';

const result = await mongo.setBifrostDiscordLink(codeDoc.uuid, {
discordId: interaction.user.id,
discordName: interaction.user.username
});

if (result && result.matchedCount === 0) {
// The account was linked between the claim and the write. Never overwrite it -
// the code is spent either way, so say what to do about it.
const current = await mongo.getBifrostPlayerByUuid(codeDoc.uuid);
const linkedTo = !current || current.discord_id == null ? null : String(current.discord_id);
if (linkedTo === interaction.user.id) {
await interaction.editReply(`✅ **${username}** is already linked to this Discord account.`);
return;
}
await interaction.editReply(
`❌ **${username}** is linked to another Discord account — run \`/unlink\` in game first, then \`/link\` again for a fresh code.`);
return;
}

try {
await mongo.insertLinkAudit(buildLinkAudit({
uuid: codeDoc.uuid,
discordId: interaction.user.id,
action: 'link',
discordName: interaction.user.username
}));
} catch (error) {
// The link itself is already written - an audit row is not worth failing it.
sessionLogger.error('DiscordLink', `Could not audit the link for ${codeDoc.uuid}`, error.message);
}

await verifiedRole.addVerifiedRole(interaction, interaction.user.id);

sessionLogger.info('DiscordLink', `${interaction.user.username} linked ${username} (${codeDoc.uuid})`);
await interaction.editReply(`✅ Linked to **${username}** — you'll see it in game.`);
},
};
39 changes: 39 additions & 0 deletions discord/commands/linked.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/*
* /linked
*
* Shows which Minecraft accounts this Discord holds. One Discord may hold several, so
* this is also how someone finds the name to pass to /unlink.
*/

const { SlashCommandBuilder } = require('discord.js');
const mongo = require('../../modules/mongo');

module.exports = {
data: new SlashCommandBuilder()
.setName('linked')
.setDescription('Show the Minecraft accounts linked to your Discord')
.setDMPermission(false),

async execute(interaction) {
await interaction.deferReply({ ephemeral: true });

const mine = await mongo.findBifrostPlayersByDiscordId(interaction.user.id);
if (mine.length === 0) {
await interaction.editReply(
'No Minecraft accounts are linked to this Discord — run `/link` in game to get a code.');
return;
}

const lines = mine
.sort((a, b) => String(a.username || '').localeCompare(String(b.username || '')))
.map(p => {
const since = p.discord_linked_at
? ` — linked <t:${Math.floor(new Date(p.discord_linked_at).getTime() / 1000)}:R>`
: '';
return `• **${p.username || p.uuid}**${since}`;
});

await interaction.editReply(
`Linked to this Discord:\n${lines.join('\n')}\n\nUse \`/unlink\` to remove one.`);
},
};
107 changes: 107 additions & 0 deletions discord/commands/unlink.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/*
* /unlink [player]
*
* Drops a Discord <-> Minecraft link from the Discord side (the in-game /unlink does the
* same from the other end). Players only ever unlink their own accounts; a member with
* Manage Guild may unlink anyone's, which is how staff undo a mislink.
*
* The Verified role comes off once that Discord user has no linked accounts left.
*/

const { SlashCommandBuilder, PermissionFlagsBits } = require('discord.js');
const mongo = require('../../modules/mongo');
const sessionLogger = require('../../modules/sessionLogger');
const verifiedRole = require('./util/verifiedRole');
const { buildLinkAudit } = require('./util/linkCode');

/** Manage Guild is the staff bar - the same permission that edits the server itself. */
function isStaff(interaction) {
try {
const perms = interaction.memberPermissions || (interaction.member && interaction.member.permissions);
return Boolean(perms && perms.has(PermissionFlagsBits.ManageGuild));
} catch (_) {
return false;
}
}

module.exports = {
data: new SlashCommandBuilder()
.setName('unlink')
.setDescription('Unlink a Minecraft account from your Discord')
.setDMPermission(false)
.addStringOption(option =>
option.setName('player')
.setDescription('Which account (only needed when you have more than one linked)')
.setRequired(false)
.setAutocomplete(true)),

async autocomplete(interaction) {
const focused = interaction.options.getFocused(true);
if (focused.name !== 'player') return;

const mine = await mongo.findBifrostPlayersByDiscordId(interaction.user.id);
const typed = String(focused.value || '').toLowerCase();
const names = mine
.map(p => p.username)
.filter(Boolean)
.filter(name => name.toLowerCase().startsWith(typed))
.sort((a, b) => a.localeCompare(b))
.slice(0, 25);
await interaction.respond(names.map(name => ({ name: name, value: name })));
},

async execute(interaction) {
await interaction.deferReply({ ephemeral: true });

const wanted = interaction.options.getString('player');
const mine = await mongo.findBifrostPlayersByDiscordId(interaction.user.id);

let target = null;
if (wanted) {
target = mine.find(p => String(p.username || '').toLowerCase() === wanted.toLowerCase()) || null;
if (!target && isStaff(interaction)) {
const identity = await mongo.getPlayerIdentity(wanted);
if (identity && identity.uuid && identity.discord_id != null) target = identity;
}
if (!target) {
await interaction.editReply(`❌ **${wanted}** is not linked to your Discord account.`);
return;
}
} else {
if (mine.length === 0) {
await interaction.editReply(
'❌ You have no linked Minecraft accounts — run `/link` in game to get a code.');
return;
}
if (mine.length > 1) {
const names = mine.map(p => `\`${p.username}\``).join(', ');
await interaction.editReply(`❓ You have several accounts linked (${names}) — say which one: \`/unlink player:<name>\`.`);
return;
}
target = mine[0];
}

const ownerId = String(target.discord_id);
await mongo.unsetBifrostDiscordLink(target.uuid);

try {
// `actor` only lands when staff undid someone else's link (buildLinkAudit drops it otherwise).
await mongo.insertLinkAudit(buildLinkAudit({
uuid: target.uuid,
discordId: ownerId,
action: 'unlink',
discordName: target.discord_name || null,
actor: interaction.user.id
}));
} catch (error) {
sessionLogger.error('DiscordLink', `Could not audit the unlink for ${target.uuid}`, error.message);
}

const remaining = await mongo.findBifrostPlayersByDiscordId(ownerId);
if (remaining.length === 0) await verifiedRole.removeVerifiedRole(interaction, ownerId);

sessionLogger.info('DiscordLink',
`${interaction.user.username} unlinked ${target.username} (${target.uuid}) from ${ownerId}`);
await interaction.editReply(`✅ **${target.username}** is no longer linked${ownerId === interaction.user.id ? '' : ` to <@${ownerId}>`}.`);
},
};
74 changes: 74 additions & 0 deletions discord/commands/util/linkCode.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/*
* File: linkCode.js
* Project: valhalla-updater
* -----
* The link-code rules and the two doc shapes /link and /unlink write, shared with the
* proxy (Bifrost's discord-link `codes.ts` mints the codes). Codes are 6 Crockford
* base32 chars - the alphabet drops I, L, O and U so a player reading one off a chat
* card can't turn it into a different code by mistyping.
*
* Normalisation forgives what people actually type: lower case, spaces, dashes (including
* the ones a phone keyboard autocorrects into U+2010-U+2015), underscores, and the three
* lookalikes (O for zero, I and L for one). Anything else is simply not a code.
* (The commands/ loader only picks up top-level files, so this util is never a command.)
*/

const CODE_LENGTH = 6;
const ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
const CODE_RE = /^[0-9A-HJKMNP-TV-Z]{6}$/;

/**
* Folds what someone typed into the canonical code form.
* @param {string} input Whatever came in on the slash command.
* @returns {string} Upper-case, unspaced, lookalikes resolved (not necessarily valid).
*/
function normalizeCode(input) {
return String(input == null ? '' : input)
.toUpperCase()
.replace(/[\s\-_‐-―]+/g, '')
.replace(/O/g, '0')
.replace(/[IL]/g, '1');
}

/**
* Checks a normalised code against the alphabet and length.
* @param {string} code A code from normalizeCode.
* @returns {boolean} Whether it can exist at all.
*/
function isValidCode(code) {
return CODE_RE.test(String(code == null ? '' : code));
}

/**
* Builds the three fields the proxy reads off a bifrost.players doc. `discord_id` is a
* STRING: a snowflake is past 2^53, so a JS number loses its last digits.
* @param {object} input `{discordId, discordName, now}`.
* @returns {object} The `$set` for the player doc.
*/
function buildLinkFields(input) {
return {
discord_id: String(input.discordId),
discord_name: String(input.discordName || ''),
discord_linked_at: input.now instanceof Date ? input.now : new Date()
};
}

/**
* Builds one row for bifrost.discord_link_audit. History only - the proxy never reads it.
* @param {object} input `{uuid, discordId, action, discordName, actor, now}`.
* @returns {object} The audit doc (`actor` only when someone unlinked another's account).
*/
function buildLinkAudit(input) {
const doc = {
uuid: String(input.uuid),
discordId: String(input.discordId),
action: input.action,
by: 'discord',
discordName: input.discordName == null ? null : String(input.discordName),
at: input.now instanceof Date ? input.now : new Date()
};
if (input.actor && String(input.actor) !== doc.discordId) doc.actor = String(input.actor);
return doc;
}

module.exports = { CODE_LENGTH, ALPHABET, normalizeCode, isValidCode, buildLinkFields, buildLinkAudit };
Loading