Add moderator bots and editable MineGeo usernames
All checks were successful
Deploy MineGeo to Dokploy / deploy (push) Successful in 3m27s

This commit is contained in:
2026-10-29 16:32:55 +05:00
parent 658c8974ac
commit 50b27cf498
19 changed files with 654 additions and 10 deletions

View File

@@ -0,0 +1,92 @@
import { existsSync } from "node:fs";
import { createHash, randomBytes } from "node:crypto";
import { createClient } from "@supabase/supabase-js";
import { Bot, InlineKeyboard } from "grammy";
import { Client, GatewayIntentBits, Partials, ActionRowBuilder, ButtonBuilder, ButtonStyle, ModalBuilder, TextInputBuilder, TextInputStyle } from "discord.js";
if (existsSync(".env.local")) process.loadEnvFile(".env.local");
const { NEXT_PUBLIC_SUPABASE_URL, SUPABASE_SECRET_KEY, DISCORD_BOT_TOKEN, TELEGRAM_BOT_TOKEN, MINEGEO_URL } = process.env;
if (!NEXT_PUBLIC_SUPABASE_URL || !SUPABASE_SECRET_KEY || !DISCORD_BOT_TOKEN || !TELEGRAM_BOT_TOKEN || !MINEGEO_URL) throw new Error("Set the Supabase, Discord, Telegram and MineGeo URL environment variables before starting the bots.");
const origin = new URL(MINEGEO_URL);
if (origin.protocol !== "https:" || origin.pathname !== "/") throw new Error("MINEGEO_URL must be the public HTTPS origin.");
const database = createClient(NEXT_PUBLIC_SUPABASE_URL, SUPABASE_SECRET_KEY, { auth: { persistSession: false, autoRefreshToken: false } });
const discord = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.DirectMessages], partials: [Partials.Channel] });
discord.once("clientReady", () => console.log("MineGeo Discord bot is connected"));
const telegram = new Bot(TELEGRAM_BOT_TOKEN);
const waiting = new Map();
const clean = (value) => String(value ?? "").replace(/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/gu, " ").slice(0, 80);
const buttons = (id) => new ActionRowBuilder().addComponents(new ButtonBuilder().setCustomId(`approve:${id}`).setLabel("Accept").setStyle(ButtonStyle.Success), new ButtonBuilder().setCustomId(`decline:${id}`).setLabel("Decline").setStyle(ButtonStyle.Danger));
const menu = () => new ActionRowBuilder().addComponents(new ButtonBuilder().setCustomId("apply").setLabel("Apply for moderator").setStyle(ButtonStyle.Primary), new ButtonBuilder().setCustomId("game").setLabel("Rock paper scissors").setStyle(ButtonStyle.Secondary), new ButtonBuilder().setCustomId("dice").setLabel("Roll dice").setStyle(ButtonStyle.Secondary));
const gameButtons = () => new ActionRowBuilder().addComponents(...["rock", "paper", "scissors"].map(x => new ButtonBuilder().setCustomId(`game:${x}`).setLabel(x).setStyle(ButtonStyle.Secondary)));
function play(choice) { const choices = ["rock", "paper", "scissors"]; const bot = choices[randomBytes(1)[0] % 3]; const result = choice === bot ? "Draw" : (choices.indexOf(choice) + 2) % 3 === choices.indexOf(bot) ? "You win" : "I win"; return `You: ${choice} · Me: ${bot}. ${result}!`; }
async function createApplication(platform, id, name, raw) {
const username = raw.trim().toLowerCase();
if (!/^[a-z0-9_]{3,32}$/.test(username)) return "Use your MineGeo username (3–32 letters, numbers or underscores). Set it in MineGeo settings first.";
const { data: allowed, error: rateError } = await database.rpc("check_rate", { p_key: `mod-apply:${platform}:${id}`, p_limit: 5, p_seconds: 3600 });
if (rateError || !allowed) return "Too many attempts. Try again later.";
const { data: profile, error } = await database.from("profiles").select("id,role,account_status").eq("username", username).maybeSingle();
if (error || !profile || profile.account_status !== "active") return "MineGeo username not found. Check the username in your account settings.";
if (profile.role !== "contributor") return "This account already has staff permissions.";
const { data: existing } = await database.from("moderator_applications").select("id").eq("platform", platform).eq("platform_user_id", id).eq("status", "pending").limit(1);
if (existing?.length) return "You already have an application waiting for review.";
const token = randomBytes(32).toString("hex");
const hash = createHash("sha256").update(token).digest("hex");
const result = await database.from("moderator_applications").insert({ platform, platform_user_id: id, platform_username: clean(name), requested_username: username, confirmation_hash: hash });
if (result.error) throw result.error;
return `Confirm your MineGeo account within 30 minutes: ${origin.origin}/moderator-application/${token}`;
}
async function adminId(user) {
const { data, error } = await database.from("moderator_bot_admin").select("discord_user_id").eq("id", true).maybeSingle();
if (error) throw error;
if (data) return data.discord_user_id === user.id;
if (user.username !== "perfect_python500" || !process.env.DISCORD_MODERATOR_ADMIN_ID || process.env.DISCORD_MODERATOR_ADMIN_ID !== user.id) return false;
const inserted = await database.from("moderator_bot_admin").insert({ id: true, discord_user_id: user.id });
if (inserted.error) return false;
return true;
}
async function panel(user) {
const { data, error } = await database.from("moderator_applications").select("id,platform,platform_user_id,platform_username,requested_username,profile_id,created_at").eq("status", "pending").order("created_at").limit(20);
if (error) throw error;
if (!data.length) return user.send("No pending applications.");
for (const app of data) await user.send({ content: `Moderator application\nMineGeo: @${clean(app.requested_username)}\nMineGeo account: ${app.profile_id}\n${app.platform}: ${clean(app.platform_username) || "No username"} (ID: ${app.platform_user_id})\nSubmitted: ${app.created_at}`, components: [buttons(app.id)], allowedMentions: { parse: [] } });
}
telegram.command("start", async ctx => { if (ctx.chat.type !== "private") return; await ctx.reply("MineGeo bot", { reply_markup: new InlineKeyboard().text("Apply for moderator", "apply").row().text("Rock paper scissors", "game").text("Roll dice", "dice") }); });
telegram.callbackQuery("apply", async ctx => { if (ctx.chat?.type !== "private") return ctx.answerCallbackQuery(); waiting.set(String(ctx.from.id), Date.now() + 600000); await ctx.answerCallbackQuery(); await ctx.reply("Send your MineGeo username. Set one in MineGeo account settings if needed."); });
telegram.callbackQuery("game", async ctx => { await ctx.answerCallbackQuery(); await ctx.reply("Choose:", { reply_markup: new InlineKeyboard().text("Rock", "game:rock").text("Paper", "game:paper").text("Scissors", "game:scissors") }); });
telegram.callbackQuery("dice", async ctx => { await ctx.answerCallbackQuery(); await ctx.replyWithDice(); });
telegram.callbackQuery(/^game:(rock|paper|scissors)$/, async ctx => { await ctx.answerCallbackQuery({ text: play(ctx.match[1]) }); });
telegram.on("message:text", async ctx => { if (ctx.chat.type !== "private") return; const id = String(ctx.from.id); if ((waiting.get(id) ?? 0) < Date.now()) return; waiting.delete(id); await ctx.reply(await createApplication("telegram", id, ctx.from.username, ctx.message.text)); });
telegram.catch(err => console.error("Telegram handler failed", err.error));
discord.on("messageCreate", async message => { if (message.author.bot || message.guild) return; try { const isAdmin = await adminId(message.author); await message.reply({ content: "MineGeo bot", components: isAdmin ? [menu(), new ActionRowBuilder().addComponents(new ButtonBuilder().setCustomId("panel").setLabel("Applications").setStyle(ButtonStyle.Secondary))] : [menu()], allowedMentions: { parse: [] } }); } catch (e) { console.error("Discord DM failed", e); } });
discord.on("interactionCreate", async interaction => { try {
if (interaction.guild || !interaction.user) return;
if (interaction.isButton() && interaction.customId === "apply") { const modal = new ModalBuilder().setCustomId("apply-form").setTitle("Moderator application"); modal.addComponents(new ActionRowBuilder().addComponents(new TextInputBuilder().setCustomId("username").setLabel("MineGeo username").setStyle(TextInputStyle.Short).setMinLength(3).setMaxLength(32).setRequired(true))); return await interaction.showModal(modal); }
if (interaction.isModalSubmit() && interaction.customId === "apply-form") { await interaction.deferReply(); return await interaction.editReply(await createApplication("discord", interaction.user.id, interaction.user.username, interaction.fields.getTextInputValue("username"))); }
if (!interaction.isButton()) return;
if (interaction.customId === "game") return await interaction.reply({ content: "Choose:", components: [gameButtons()] });
if (interaction.customId === "dice") return await interaction.reply(`🎲 You rolled ${randomBytes(1)[0] % 6 + 1}.`);
if (/^game:(rock|paper|scissors)$/.test(interaction.customId)) return await interaction.reply(play(interaction.customId.split(":")[1]));
if (interaction.customId === "panel") { if (!await adminId(interaction.user)) return await interaction.reply("Admin only."); await interaction.deferReply(); await panel(interaction.user); return await interaction.editReply("Applications sent below."); }
const match = /^(approve|decline):([a-f0-9-]{36})$/.exec(interaction.customId);
if (!match) return;
if (!await adminId(interaction.user)) return await interaction.reply("Admin only.");
await interaction.deferUpdate();
const { error } = await database.rpc("review_moderator_application", { p_application: match[2], p_decision: match[1] === "approve" ? "approved" : "declined", p_reviewer: interaction.user.id });
if (error) return await interaction.followUp({ content: "Application could not be reviewed: " + clean(error.message) });
await interaction.editReply({ content: interaction.message.content + `\n${match[1] === "approve" ? "Accepted" : "Declined"}.`, components: [] });
} catch (e) { console.error("Discord interaction failed", e); if (interaction.isRepliable()) { const payload = { content: "Something went wrong. Please try again." }; await (interaction.deferred || interaction.replied ? interaction.followUp(payload) : interaction.reply(payload)).catch(() => {}); } } });
let running = false;
async function deliver() {
if (running || !discord.isReady()) return; running = true;
try {
const { data: admin } = await database.from("moderator_bot_admin").select("discord_user_id").eq("id", true).maybeSingle();
if (admin) { const { data: pending } = await database.from("moderator_applications").select("id,platform,platform_user_id,platform_username,requested_username,profile_id,created_at").eq("status", "pending").is("admin_notified_at", null).limit(20); for (const app of pending ?? []) { try { const recipient = await discord.users.fetch(admin.discord_user_id); await recipient.send({ content: `New moderator application\nMineGeo: @${clean(app.requested_username)}\nMineGeo account: ${app.profile_id}\n${app.platform}: ${clean(app.platform_username) || "No username"} (ID: ${app.platform_user_id})`, components: [buttons(app.id)], allowedMentions: { parse: [] } }); await database.from("moderator_applications").update({ admin_notified_at: new Date().toISOString() }).eq("id", app.id).is("admin_notified_at", null); } catch (e) { console.error("Admin notification failed", e); } } }
const { data: reviewed } = await database.from("moderator_applications").select("id,platform,platform_user_id,status").in("status", ["approved", "declined"]).is("applicant_notified_at", null).limit(20);
for (const app of reviewed ?? []) { try { const text = app.status === "approved" ? "Your moderator application was accepted!" : "Your moderator application was declined."; if (app.platform === "telegram") await telegram.api.sendMessage(app.platform_user_id, text); else await (await discord.users.fetch(app.platform_user_id)).send(text); await database.from("moderator_applications").update({ applicant_notified_at: new Date().toISOString() }).eq("id", app.id).is("applicant_notified_at", null); } catch (e) { console.error("Applicant notification failed", e); } }
} catch (e) { console.error("Application delivery failed", e); } finally { running = false; }
}
await discord.login(DISCORD_BOT_TOKEN);
const timer = setInterval(deliver, 15000);
telegram.start({ onStart: () => console.log("MineGeo Telegram bot is running") });
const stop = () => { clearInterval(timer); telegram.stop(); discord.destroy(); };
process.once("SIGINT", stop); process.once("SIGTERM", stop);