Add Telegram moderator application mini app
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 1m41s

This commit is contained in:
2026-11-01 10:21:05 +00:00
parent 05854818a0
commit 3afad02686
11 changed files with 753 additions and 17 deletions

View File

@@ -0,0 +1,62 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { createModeratorApplication } from "@/lib/moderator-applications";
import { verifyTelegramInitData } from "@/lib/telegram-webapp";
import { jsonBody } from "@/lib/json-body";
import { ServiceError } from "@/lib/service-error";
export const runtime = "nodejs";
const payload = z.object({
initData: z.string().min(1).max(4096),
username: z
.string()
.min(3)
.max(32)
.regex(/^[A-Za-z0-9_]+$/),
});
export async function POST(request: NextRequest) {
const botToken = process.env.TELEGRAM_BOT_TOKEN;
if (!botToken)
return NextResponse.json(
{ error: "The Telegram bot is not configured" },
{ status: 503 },
);
let decoded: unknown;
try {
decoded = await jsonBody(request);
} catch (error) {
if (error instanceof ServiceError)
return NextResponse.json({ error: error.message }, { status: error.status });
return NextResponse.json({ error: "Invalid request" }, { status: 400 });
}
const input = payload.safeParse(decoded);
if (!input.success)
return NextResponse.json(
{
error:
"Use your MineGeo username (3–32 letters, numbers or underscores).",
},
{ status: 400 },
);
const telegramUser = verifyTelegramInitData(input.data.initData, botToken);
if (!telegramUser)
return NextResponse.json(
{
error:
"Telegram signature could not be verified. Open the form again from the MineGeo bot.",
},
{ status: 401 },
);
const result = await createModeratorApplication({
platform: "telegram",
platformUserId: telegramUser.id,
platformUsername: telegramUser.username,
requestedUsername: input.data.username,
origin: process.env.MINEGEO_URL ?? request.nextUrl.origin,
});
if (!result.ok)
return NextResponse.json({ error: result.message }, { status: 409 });
return NextResponse.json({ url: result.url });
}

View File

@@ -0,0 +1,277 @@
"use client";
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type CSSProperties,
type FormEvent,
} from "react";
interface TelegramUser {
id: number;
username?: string;
first_name?: string;
last_name?: string;
}
interface ThemeParams {
bg_color?: string;
text_color?: string;
hint_color?: string;
link_color?: string;
button_color?: string;
button_text_color?: string;
secondary_bg_color?: string;
}
interface TelegramWebApp {
initData: string;
initDataUnsafe: { user?: TelegramUser };
colorScheme?: string;
themeParams?: ThemeParams;
ready: () => void;
expand: () => void;
close: () => void;
openLink: (url: string) => void;
setHeaderColor?: (color: string) => void;
setBackgroundColor?: (color: string) => void;
MainButton?: {
text: string;
color: string;
textColor: string;
isVisible: boolean;
setText: (text: string) => void;
show: () => void;
hide: () => void;
enable: () => void;
disable: () => void;
showProgress: (leaveActive?: boolean) => void;
hideProgress: () => void;
onClick: (handler: () => void) => void;
offClick: (handler: () => void) => void;
};
BackButton?: { show: () => void; hide: () => void; onClick: (handler: () => void) => void };
}
declare global {
interface Window {
Telegram?: { WebApp?: TelegramWebApp };
}
}
type Status = "loading" | "form" | "success" | "outside";
const TELEGRAM_SCRIPT = "https://telegram.org/js/telegram-web-app.js";
const BOT_LINK = "https://t.me/MineGeoModeratorBot";
function loadTelegramScript(): Promise<void> {
if (window.Telegram?.WebApp) return Promise.resolve();
return new Promise((resolve, reject) => {
const existing = document.querySelector<HTMLScriptElement>(
`script[src="${TELEGRAM_SCRIPT}"]`,
);
if (existing) {
existing.addEventListener("load", () => resolve(), { once: true });
existing.addEventListener("error", () => reject(new Error("load failed")), {
once: true,
});
return;
}
const script = document.createElement("script");
script.src = TELEGRAM_SCRIPT;
script.async = true;
script.onload = () => resolve();
script.onerror = () => reject(new Error("load failed"));
document.head.appendChild(script);
});
}
export function ModeratorApplyApp() {
const [status, setStatus] = useState<Status>("loading");
const [username, setUsername] = useState("");
const [error, setError] = useState<string | null>(null);
const [confirmationUrl, setConfirmationUrl] = useState<string | null>(null);
const [theme, setTheme] = useState<ThemeParams>({});
const tgRef = useRef<TelegramWebApp | null>(null);
const submitRef = useRef<() => void>(() => {});
const submittingRef = useRef(false);
useEffect(() => {
let cancelled = false;
loadTelegramScript()
.then(() => {
if (cancelled) return;
const tg = window.Telegram?.WebApp;
if (!tg) {
setStatus("outside");
return;
}
tgRef.current = tg;
tg.ready();
tg.expand();
setTheme(tg.themeParams ?? {});
const background = tg.themeParams?.bg_color ?? "#0e1621";
tg.setBackgroundColor?.(background);
tg.setHeaderColor?.(tg.themeParams?.secondary_bg_color ?? background);
if (tg.initData) setStatus("form");
else setStatus("outside");
})
.catch(() => !cancelled && setStatus("outside"));
return () => {
cancelled = true;
};
}, []);
const submit = useCallback(async () => {
const tg = tgRef.current;
if (!tg?.initData || !/^[A-Za-z0-9_]{3,32}$/.test(username.trim()) || submittingRef.current) return;
submittingRef.current = true;
const mainButton = tg.MainButton;
setError(null);
mainButton?.disable();
mainButton?.showProgress(true);
try {
const response = await fetch("/api/moderator-apply", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ initData: tg.initData, username: username.trim() }),
});
const data = (await response.json().catch(() => null)) as {
url?: string;
error?: string;
} | null;
if (!response.ok || !data?.url) {
setError(data?.error ?? "Something went wrong. Please try again.");
return;
}
setConfirmationUrl(data.url);
setStatus("success");
mainButton?.hide();
} catch {
setError("Network error. Please try again.");
} finally {
submittingRef.current = false;
mainButton?.hideProgress();
mainButton?.enable();
}
}, [username]);
submitRef.current = submit;
useEffect(() => {
if (status !== "form") return;
const tg = tgRef.current;
const mainButton = tg?.MainButton;
if (!tg || !mainButton) return;
mainButton.text = "Submit application";
if (tg.themeParams?.button_color) mainButton.color = tg.themeParams.button_color;
if (tg.themeParams?.button_text_color)
mainButton.textColor = tg.themeParams.button_text_color;
const handler = () => submitRef.current();
mainButton.onClick(handler);
mainButton.show();
return () => {
mainButton.offClick(handler);
mainButton.hide();
};
}, [status]);
const onSubmit = (event: FormEvent) => {
event.preventDefault();
void submit();
};
const style = useMemo(
() =>
({
"--tg-bg": theme.bg_color ?? (theme.secondary_bg_color ? "#0e1621" : "#ffffff"),
"--tg-secondary-bg": theme.secondary_bg_color ?? "#f4f4f5",
"--tg-text": theme.text_color ?? "#000000",
"--tg-hint": theme.hint_color ?? "#707579",
"--tg-link": theme.link_color ?? "#3390ec",
"--tg-button": theme.button_color ?? "#3390ec",
"--tg-button-text": theme.button_text_color ?? "#ffffff",
}) as CSSProperties,
[theme],
);
const displayName = useMemo(() => {
const user = tgRef.current?.initDataUnsafe?.user;
if (!user) return null;
const name = [user.first_name, user.last_name].filter(Boolean).join(" ").trim();
return name || user.username || null;
}, [status]);
return (
<div className="tg-apply-card" style={style}>
{status === "loading" && <p className="tg-apply-hint">Loading…</p>}
{status === "outside" && (
<div className="tg-apply-body">
<h1 className="tg-apply-title">Moderator application</h1>
<p className="tg-apply-hint">
This form opens inside Telegram. Use the MineGeo bot to apply.
</p>
<a className="tg-apply-button" href={BOT_LINK} target="_blank" rel="noreferrer">
Open @MineGeoModeratorBot
</a>
</div>
)}
{status === "form" && (
<form className="tg-apply-body" onSubmit={onSubmit}>
<h1 className="tg-apply-title">Apply for moderator</h1>
<p className="tg-apply-hint">
{displayName ? `Signed in as ${displayName}. ` : ""}
Enter your MineGeo username. You will confirm the application from that
account.
</p>
<label className="tg-apply-label" htmlFor="minegeo-username">
MineGeo username
</label>
<input
id="minegeo-username"
className="tg-apply-input"
value={username}
onChange={(event) => setUsername(event.target.value)}
placeholder="your_username"
autoCapitalize="none"
autoCorrect="off"
autoComplete="username"
minLength={3}
maxLength={32}
pattern="[A-Za-z0-9_]{3,32}"
required
/>
<p className="tg-apply-hint">3–32 letters, numbers or underscores.</p>
{error && (
<p className="tg-apply-error" role="alert">
{error}
</p>
)}
<button className="tg-apply-button" type="submit">
Submit application
</button>
</form>
)}
{status === "success" && confirmationUrl && (
<div className="tg-apply-body">
<h1 className="tg-apply-title">Application created</h1>
<p className="tg-apply-hint">
Confirm it from your MineGeo account within 30 minutes. The staff will
review it after confirmation.
</p>
<button
className="tg-apply-button"
type="button"
onClick={() => tgRef.current?.openLink(confirmationUrl)}
>
Confirm application
</button>
<p className="tg-apply-url">{confirmationUrl}</p>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,118 @@
.tg-apply {
min-height: 100vh;
min-height: 100dvh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
background: var(--tg-bg, #0e1621);
color: var(--tg-text, #fff);
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
Arial, sans-serif;
}
.tg-apply *,
.tg-apply *::before,
.tg-apply *::after {
box-sizing: border-box;
}
.tg-apply-card {
width: 100%;
max-width: 420px;
}
.tg-apply-body {
display: flex;
flex-direction: column;
gap: 10px;
width: 100%;
}
.tg-apply-title {
margin: 0 0 4px;
font-size: 22px;
font-weight: 700;
line-height: 1.25;
color: var(--tg-text, inherit);
}
.tg-apply-hint {
margin: 0;
font-size: 14px;
line-height: 1.45;
color: var(--tg-hint, #707579);
}
.tg-apply-label {
margin-top: 8px;
font-size: 14px;
font-weight: 600;
color: var(--tg-text, inherit);
}
.tg-apply-input {
width: 100%;
padding: 12px 14px;
border: 1px solid transparent;
border-radius: 12px;
background: var(--tg-secondary-bg, #f4f4f5);
color: var(--tg-text, #000);
font-size: 16px;
line-height: 1.3;
outline: none;
transition: border-color 0.15s ease;
}
.tg-apply-input:focus {
border-color: var(--tg-link, #3390ec);
}
.tg-apply-input::placeholder {
color: var(--tg-hint, #707579);
}
.tg-apply-error {
margin: 0;
padding: 10px 12px;
border-radius: 12px;
background: color-mix(in srgb, #e53935 14%, transparent);
color: #e53935;
font-size: 14px;
line-height: 1.4;
}
.tg-apply-button {
margin-top: 10px;
width: 100%;
padding: 13px 16px;
border: none;
border-radius: 12px;
background: var(--tg-button, #3390ec);
color: var(--tg-button-text, #fff);
font-size: 16px;
font-weight: 600;
line-height: 1.2;
text-align: center;
text-decoration: none;
cursor: pointer;
transition: filter 0.15s ease;
}
.tg-apply-button:hover {
filter: brightness(1.06);
}
.tg-apply-button:active {
filter: brightness(0.94);
}
.tg-apply-url {
margin: 6px 0 0;
font-size: 12px;
line-height: 1.5;
word-break: break-all;
color: var(--tg-hint, #707579);
user-select: all;
}

View File

@@ -0,0 +1,23 @@
import type { Metadata, Viewport } from "next";
import { ModeratorApplyApp } from "./apply";
import "./moderator-apply.css";
export const metadata: Metadata = {
title: "Moderator application",
robots: { index: false, follow: false },
referrer: "no-referrer",
};
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
viewportFit: "cover",
};
export default function ModeratorApplyPage() {
return (
<main className="tg-apply">
<ModeratorApplyApp />
</main>
);
}

View File

@@ -4,6 +4,6 @@ MineGeo usernames are unique, lowercase and changeable in account settings. An a
Run one bot process on the VPS. In Dokploy's runtime environment set `MINEGEO_RUN_BOTS=true`, `TELEGRAM_BOT_TOKEN`, the existing `DISCORD_BOT_TOKEN`, `NEXT_PUBLIC_SUPABASE_URL`, `SUPABASE_SECRET_KEY`, and the public HTTPS `MINEGEO_URL`. Optionally set `DISCORD_MODERATOR_ADMIN_ID` to Pavel's Discord numeric ID before first use. Never put tokens in the repository. The Discord bot needs the Direct Messages gateway intent and must be installed where applicants can open a DM. Telegram long polling requires that the Telegram bot is not receiving updates through a webhook or another polling process.
Pavel's Discord account is bound in the MineGeo database by its immutable Discord ID. In a fresh database, set `DISCORD_MODERATOR_ADMIN_ID` to that numeric ID and send the Discord bot a DM from `perfect_python500` to bind it. The bot provides an applications button, sends new confirmed applications to that DM, and provides accept and decline buttons. Telegram users start the separate MineGeo bot and choose the application button. Both bots offer rock paper scissors and dice.
Pavel's Discord account is bound in the MineGeo database by its immutable Discord ID. In a fresh database, set `DISCORD_MODERATOR_ADMIN_ID` to that numeric ID and send the Discord bot a DM from `perfect_python500` to bind it. The bot provides an applications button, sends new confirmed applications to that DM, and provides accept and decline buttons. Telegram users start the separate MineGeo bot and choose the application button to open the Mini App. After entering their MineGeo username, they must open the confirmation link and sign in to that account within 30 minutes. The server verifies Telegram's signed identity data before creating the application. Both bots offer rock paper scissors and dice.
The bot runs under the existing production supervisor when enabled, or can be run independently with `npm run bots`. Do not run both at once. A single process polls Telegram and sends queued notifications; failed notifications are retried. Users can start again if a confirmation link expires.

View File

@@ -0,0 +1,80 @@
import "server-only";
import { createHash, randomBytes } from "node:crypto";
import { admin } from "@/lib/supabase";
const clean = (value: unknown) =>
String(value ?? "")
.replace(/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/gu, " ")
.slice(0, 80);
export type ModeratorApplicationResult =
| { ok: true; url: string }
| { ok: false; message: string };
/**
* Creates a moderator application for a confirmed platform identity (Telegram
* or Discord user verified by the caller). The applicant still has to open the
* returned link while signed in to the matching MineGeo account.
*/
export async function createModeratorApplication(input: {
platform: "telegram" | "discord";
platformUserId: string;
platformUsername?: string | undefined;
requestedUsername: string;
origin: string;
}): Promise<ModeratorApplicationResult> {
const username = input.requestedUsername.trim().toLowerCase();
if (!/^[a-z0-9_]{3,32}$/.test(username))
return {
ok: false,
message:
"Use your MineGeo username (3–32 letters, numbers or underscores). Set it in MineGeo settings first.",
};
const database = admin();
const { data: allowed, error: rateError } = await database.rpc("check_rate", {
p_key: `mod-apply:${input.platform}:${input.platformUserId}`,
p_limit: 5,
p_seconds: 3600,
});
if (rateError || !allowed)
return { ok: false, 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) return { ok: false, message: "MineGeo is unavailable. Try again later." };
if (!profile || profile.account_status !== "active")
return {
ok: false,
message: "MineGeo username not found. Check the username in your account settings.",
};
if (profile.role !== "contributor")
return { ok: false, message: "This account already has staff permissions." };
const { data: existing } = await database
.from("moderator_applications")
.select("id")
.eq("platform", input.platform)
.eq("platform_user_id", input.platformUserId)
.eq("status", "pending")
.limit(1);
if (existing?.length)
return { ok: false, message: "You already have an application waiting for review." };
const token = randomBytes(32).toString("hex");
const hash = createHash("sha256").update(token).digest("hex");
const insert = await database.from("moderator_applications").insert({
platform: input.platform,
platform_user_id: input.platformUserId,
platform_username: clean(input.platformUsername),
requested_username: username,
confirmation_hash: hash,
});
if (insert.error) {
console.error("Moderator application insert failed", insert.error);
return { ok: false, message: "MineGeo is unavailable. Try again later." };
}
return {
ok: true,
url: `${new URL(input.origin).origin}/moderator-application/${token}`,
};
}

57
lib/telegram-webapp.ts Normal file
View File

@@ -0,0 +1,57 @@
import { createHmac, timingSafeEqual } from "node:crypto";
export interface TelegramInitDataUser {
id: string;
username?: string;
firstName?: string;
lastName?: string;
}
/**
* Validates Telegram Mini App initData against the bot token.
* https://core.telegram.org/bots/webapps#validating-data-received-via-the-mini-app
*/
export function verifyTelegramInitData(
initData: string,
botToken: string,
maxAgeSeconds = 86400,
): TelegramInitDataUser | null {
if (!initData || initData.length > 4096 || !botToken) return null;
const params = new URLSearchParams(initData);
if (new Set(params.keys()).size !== [...params.keys()].length) return null;
const hash = params.get("hash");
if (!hash || !/^[a-f0-9]{64}$/.test(hash)) return null;
params.delete("hash");
const checkString = [...params.keys()]
.sort()
.map((key) => `${key}=${params.get(key)}`)
.join("\n");
const secret = createHmac("sha256", "WebAppData").update(botToken).digest();
const expected = createHmac("sha256", secret).update(checkString).digest("hex");
const valid =
hash.length === expected.length &&
timingSafeEqual(Buffer.from(hash), Buffer.from(expected));
if (!valid) return null;
const authDate = Number(params.get("auth_date"));
if (!Number.isFinite(authDate) || Math.abs(Date.now() / 1000 - authDate) > maxAgeSeconds)
return null;
const raw = params.get("user");
if (!raw) return null;
try {
const user = JSON.parse(raw) as {
id?: unknown;
username?: unknown;
first_name?: unknown;
last_name?: unknown;
};
if (typeof user.id !== "number" && typeof user.id !== "string") return null;
return {
id: String(user.id),
...(typeof user.username === "string" ? { username: user.username } : {}),
...(typeof user.first_name === "string" ? { firstName: user.first_name } : {}),
...(typeof user.last_name === "string" ? { lastName: user.last_name } : {}),
};
} catch {
return null;
}
}

View File

@@ -23,16 +23,24 @@ const config: NextConfig = {
source: "/:path*",
headers: [
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "X-Frame-Options", value: "DENY" },
{ key: "Referrer-Policy", value: "same-origin" },
{
key: "Permissions-Policy",
value: "camera=(), microphone=(), geolocation=()",
},
{
key: "Content-Security-Policy",
value: "frame-ancestors 'none'; base-uri 'self'; object-src 'none'",
],
},
{
source: "/:path((?!moderator-apply$).*)",
headers: [
{ key: "X-Frame-Options", value: "DENY" },
{ key: "Content-Security-Policy", value: "frame-ancestors 'none'; base-uri 'self'; object-src 'none'" },
],
},
{
source: "/moderator-apply",
headers: [
{ key: "Content-Security-Policy", value: "frame-ancestors https://telegram.org https://*.telegram.org; base-uri 'self'; object-src 'none'" },
],
},
];

View File

@@ -13,7 +13,7 @@ const database = createClient(NEXT_PUBLIC_SUPABASE_URL, SUPABASE_SECRET_KEY, { a
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 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));
@@ -21,19 +21,19 @@ const gameButtons = () => new ActionRowBuilder().addComponents(...["rock", "pape
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.";
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 "Too many attempts. Try again later.";
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 "MineGeo username not found. Check the username in your account settings.";
if (profile.role !== "contributor") return "This account already has staff permissions.";
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 "You already have an application waiting for review.";
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 `Confirm your MineGeo account within 30 minutes: ${origin.origin}/moderator-application/${token}`;
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();
@@ -50,18 +50,28 @@ async function panel(user) {
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."); });
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 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.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(); return await interaction.editReply(await createApplication("discord", interaction.user.id, interaction.user.username, interaction.fields.getTextInputValue("username"))); }
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}.`);

View File

@@ -678,6 +678,80 @@ export type Database = {
};
Relationships: [];
};
moderator_applications: {
Row: {
id: string;
platform: string;
platform_user_id: string;
platform_username: string | null;
requested_username: string;
profile_id: string | null;
confirmation_hash: string;
status: string;
expires_at: string;
created_at: string;
reviewed_at: string | null;
reviewer_discord_id: string | null;
admin_notified_at: string | null;
applicant_notified_at: string | null;
};
Insert: {
id?: string;
platform: string;
platform_user_id: string;
platform_username?: string | null;
requested_username: string;
profile_id?: string | null;
confirmation_hash: string;
status?: string;
expires_at?: string;
created_at?: string;
reviewed_at?: string | null;
reviewer_discord_id?: string | null;
admin_notified_at?: string | null;
applicant_notified_at?: string | null;
};
Update: {
id?: string;
platform?: string;
platform_user_id?: string;
platform_username?: string | null;
requested_username?: string;
profile_id?: string | null;
confirmation_hash?: string;
status?: string;
expires_at?: string;
created_at?: string;
reviewed_at?: string | null;
reviewer_discord_id?: string | null;
admin_notified_at?: string | null;
applicant_notified_at?: string | null;
};
Relationships: [
{
foreignKeyName: "moderator_applications_profile_id_fkey";
columns: ["profile_id"];
isOneToOne: false;
referencedRelation: "profiles";
referencedColumns: ["id"];
},
];
};
moderator_bot_admin: {
Row: {
id: boolean;
discord_user_id: string;
};
Insert: {
id?: boolean;
discord_user_id: string;
};
Update: {
id?: boolean;
discord_user_id?: string;
};
Relationships: [];
};
profiles: {
Row: AccountStatusFields & {
username: string | null;

View File

@@ -0,0 +1,27 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { createHmac } from "node:crypto";
import { verifyTelegramInitData } from "../lib/telegram-webapp";
const token = "123:test-token";
function signedData(authDate = Math.floor(Date.now() / 1000)) {
const params = new URLSearchParams({
auth_date: String(authDate),
query_id: "query",
user: JSON.stringify({ id: 12345, username: "applicant" }),
});
const check = [...params.entries()].sort(([a], [b]) => a.localeCompare(b))
.map(([key, value]) => key + "=" + value).join("\n");
const secret = createHmac("sha256", "WebAppData").update(token).digest();
params.set("hash", createHmac("sha256", secret).update(check).digest("hex"));
return params.toString();
}
test("Telegram init data verifies identity and rejects tampering, duplicates, and expired data", () => {
const data = signedData();
assert.deepEqual(verifyTelegramInitData(data, token), { id: "12345", username: "applicant" });
assert.equal(verifyTelegramInitData(data, "wrong-token"), null);
assert.equal(verifyTelegramInitData(data.replace("applicant", "impostor"), token), null);
assert.equal(verifyTelegramInitData(data + "&user=other", token), null);
assert.equal(verifyTelegramInitData(signedData(Math.floor(Date.now() / 1000) - 90000), token), null);
});