Files
MineGeo/scripts/moderator-bots.mjs
pavel444-byte 50b27cf498
All checks were successful
Deploy MineGeo to Dokploy / deploy (push) Successful in 3m27s
Add moderator bots and editable MineGeo usernames
2026-10-29 16:32:55 +05:00

93 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);