feat(platform): add Discord onboarding and Velocity gate
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
DATABASE_URL=postgresql://minecraft:minecraft@localhost:5432/minecraft_accounts
|
||||
APP_URL=http://localhost:3000
|
||||
DISCORD_BOT_TOKEN=
|
||||
DISCORD_APPLICATION_ID=
|
||||
DISCORD_GUILD_ID=
|
||||
@@ -1,5 +1,13 @@
|
||||
# Discord bot
|
||||
|
||||
The bot will provide ephemeral `/register` and `/account` responses containing short-lived one-time links. The target guild is read from admin-managed application settings rather than a deployment-only environment variable.
|
||||
The bot provides ephemeral `/register` and `/account` responses containing ten-minute, single-use links. Commands only work in the guild configured by `DISCORD_GUILD_ID`.
|
||||
|
||||
Implementation begins in Phase 2 alongside the one-time login service.
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
cp apps/discord-bot/.env.example apps/discord-bot/.env
|
||||
npm run commands:deploy --workspace @minecraft-account-manager/discord-bot
|
||||
npm run dev --workspace @minecraft-account-manager/discord-bot
|
||||
```
|
||||
|
||||
The bot requires permission to use application commands. Nickname management will additionally require `Manage Nicknames`, with the bot role above managed members.
|
||||
|
||||
@@ -4,13 +4,21 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"start": "tsx src/index.ts",
|
||||
"commands:deploy": "tsx src/deploy-commands.ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@minecraft-account-manager/auth": "*",
|
||||
"@minecraft-account-manager/contracts": "*",
|
||||
"discord.js": "^14.25.1"
|
||||
"@minecraft-account-manager/database": "*",
|
||||
"discord.js": "^14.25.1",
|
||||
"dotenv": "^17.2.3",
|
||||
"drizzle-orm": "^0.45.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { SlashCommandBuilder } from "discord.js";
|
||||
|
||||
export const commands = [
|
||||
new SlashCommandBuilder()
|
||||
.setName("register")
|
||||
.setDescription("Register a Minecraft account through a private login link"),
|
||||
new SlashCommandBuilder()
|
||||
.setName("account")
|
||||
.setDescription("Open your Minecraft account dashboard through a private login link"),
|
||||
].map((command) => command.toJSON());
|
||||
@@ -0,0 +1,7 @@
|
||||
export function requiredEnvironment(name: string) {
|
||||
const value = process.env[name]?.trim();
|
||||
if (!value) throw new Error(`Missing required environment variable: ${name}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
export const commandNames = ["register", "account"] as const;
|
||||
@@ -0,0 +1,11 @@
|
||||
import "dotenv/config";
|
||||
import { REST, Routes } from "discord.js";
|
||||
import { commands } from "./commands";
|
||||
import { requiredEnvironment } from "./config";
|
||||
|
||||
const token = requiredEnvironment("DISCORD_BOT_TOKEN");
|
||||
const applicationId = requiredEnvironment("DISCORD_APPLICATION_ID");
|
||||
const rest = new REST({ version: "10" }).setToken(token);
|
||||
|
||||
await rest.put(Routes.applicationCommands(applicationId), { body: commands });
|
||||
console.log(`Deployed ${commands.length} global Discord commands.`);
|
||||
@@ -1,2 +1,79 @@
|
||||
// Discord command handling is added in Phase 2 after the one-time login service exists.
|
||||
export {};
|
||||
import "dotenv/config";
|
||||
import { createMagicLink, LoginRateLimitedError } from "@minecraft-account-manager/auth";
|
||||
import {
|
||||
createAuthRepository,
|
||||
createDatabase,
|
||||
recordEvent,
|
||||
} from "@minecraft-account-manager/database";
|
||||
import {
|
||||
ActionRowBuilder,
|
||||
ButtonBuilder,
|
||||
ButtonStyle,
|
||||
Client,
|
||||
Events,
|
||||
GatewayIntentBits,
|
||||
} from "discord.js";
|
||||
import { commandNames, requiredEnvironment } from "./config";
|
||||
|
||||
const token = requiredEnvironment("DISCORD_BOT_TOKEN");
|
||||
const appUrl = requiredEnvironment("APP_URL");
|
||||
const databaseUrl = requiredEnvironment("DATABASE_URL");
|
||||
const discordGuildId = requiredEnvironment("DISCORD_GUILD_ID");
|
||||
const { db } = createDatabase(databaseUrl);
|
||||
const authRepository = createAuthRepository(db);
|
||||
const client = new Client({ intents: [GatewayIntentBits.Guilds] });
|
||||
|
||||
client.once(Events.ClientReady, (readyClient) => {
|
||||
console.log(`Discord bot ready as ${readyClient.user.tag}`);
|
||||
});
|
||||
|
||||
client.on(Events.InteractionCreate, async (interaction) => {
|
||||
if (!interaction.isChatInputCommand()) return;
|
||||
if (!commandNames.includes(interaction.commandName as (typeof commandNames)[number])) return;
|
||||
|
||||
await interaction.deferReply({ ephemeral: true });
|
||||
|
||||
try {
|
||||
if (interaction.guildId !== discordGuildId) {
|
||||
await interaction.editReply("This command is only available in the configured community server.");
|
||||
return;
|
||||
}
|
||||
|
||||
const magicLink = await createMagicLink(
|
||||
{
|
||||
id: interaction.user.id,
|
||||
username: interaction.user.username,
|
||||
globalName: interaction.user.globalName,
|
||||
},
|
||||
{ repository: authRepository, appUrl },
|
||||
);
|
||||
|
||||
await recordEvent(db, {
|
||||
type: "games.minecraft.account-manager.auth.magic-link.created",
|
||||
source: "/discord-bot",
|
||||
subject: `discord-user/${interaction.user.id}`,
|
||||
data: { command: interaction.commandName, guildId: interaction.guildId },
|
||||
});
|
||||
|
||||
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||
new ButtonBuilder()
|
||||
.setLabel(interaction.commandName === "register" ? "Start registration" : "Open my account")
|
||||
.setStyle(ButtonStyle.Link)
|
||||
.setURL(magicLink.url),
|
||||
);
|
||||
|
||||
await interaction.editReply({
|
||||
content: "This private link works once and expires in 10 minutes. Do not share it.",
|
||||
components: [row],
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof LoginRateLimitedError) {
|
||||
await interaction.editReply("Please wait 30 seconds before requesting another private account link.");
|
||||
return;
|
||||
}
|
||||
console.error("Failed to create Discord account link", error);
|
||||
await interaction.editReply("I could not create an account link. Please try again shortly.");
|
||||
}
|
||||
});
|
||||
|
||||
await client.login(token);
|
||||
|
||||
Reference in New Issue
Block a user