Files
MineGeo/scripts/moderator-bots.mjs
perfect_python500 3afad02686
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 1m41s
Add Telegram moderator application mini app
2026-11-01 10:21:05 +00:00

103 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 applyUrl = new URL("/moderator-apply", origin).href;
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 { message: "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 { message: "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 { message: "MineGeo username not found. Check the username in your account settings." };
if (profile.role !== "contributor") return { message: "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 { message: "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 { url: `${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: [] } });
}
const telegramMenu = () => new InlineKeyboard().webApp("Apply for moderator", applyUrl).row().text("Rock paper scissors", "game").text("Roll dice", "dice");
async function replyWithApplication(ctx, result) {
if (result.url) await ctx.reply("Confirm your MineGeo account within 30 minutes.", { reply_markup: new InlineKeyboard().url("Confirm application", result.url) });
else await ctx.reply(result.message);
}
telegram.command("start", async ctx => { if (ctx.chat.type !== "private") return; await ctx.reply("MineGeo bot", { reply_markup: telegramMenu() }); });
telegram.callbackQuery("apply", async ctx => { if (ctx.chat?.type !== "private") return ctx.answerCallbackQuery(); await ctx.answerCallbackQuery({ text: "Use the Apply button to open the form." }); await ctx.reply("MineGeo bot", { reply_markup: telegramMenu() }); });
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 text = ctx.message.text.trim();
if (/^[a-z0-9_]{3,32}$/.test(text)) return replyWithApplication(ctx, await createApplication("telegram", String(ctx.from.id), ctx.from.username, text));
if (!text.startsWith("/")) await ctx.reply("Send your MineGeo username to apply for moderator.", { reply_markup: telegramMenu() });
});
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(); const result = await createApplication("discord", interaction.user.id, interaction.user.username, interaction.fields.getTextInputValue("username")); return await interaction.editReply(result.url ? `Confirm your MineGeo account within 30 minutes: ${result.url}` : result.message); }
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);