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

@@ -15,6 +15,12 @@ MINEGEO_FORUM_URL=
# Create a bot at https://discord.com/developers/applications and invite it to your server. # Create a bot at https://discord.com/developers/applications and invite it to your server.
# Channel IDs are copied with Discord Developer Mode enabled. Never use NEXT_PUBLIC_ here. # Channel IDs are copied with Discord Developer Mode enabled. Never use NEXT_PUBLIC_ here.
DISCORD_BOT_TOKEN= DISCORD_BOT_TOKEN=
# Separate MineGeo Telegram bot. Run one bot process on the VPS.
TELEGRAM_BOT_TOKEN=
# Enable exactly one bot process on the VPS.
MINEGEO_RUN_BOTS=false
# Optional immutable Discord user ID for the initial admin binding.
DISCORD_MODERATOR_ADMIN_ID=
DISCORD_MAPS_CHANNEL_ID= DISCORD_MAPS_CHANNEL_ID=
DISCORD_PARTIES_CHANNEL_ID= DISCORD_PARTIES_CHANNEL_ID=
# Optional overrides for the read-only changelog (defaults to MineGeo news and release webhook). # Optional overrides for the read-only changelog (defaults to MineGeo news and release webhook).

View File

@@ -42,7 +42,7 @@ COPY --from=production-dependencies --chown=node:node /app/node_modules ./node_m
COPY --from=builder --chown=node:node /app/.next ./.next COPY --from=builder --chown=node:node /app/.next ./.next
COPY --from=builder --chown=node:node /app/public ./public COPY --from=builder --chown=node:node /app/public ./public
COPY --chown=node:node package.json tsconfig.json next.config.ts ./ COPY --chown=node:node package.json tsconfig.json next.config.ts ./
COPY --chown=node:node scripts/supervise.mjs scripts/runtime-config.mjs scripts/assets.mjs ./scripts/ COPY --chown=node:node scripts/supervise.mjs scripts/runtime-config.mjs scripts/assets.mjs scripts/moderator-bots.mjs ./scripts/
COPY --chown=node:node worker ./worker COPY --chown=node:node worker ./worker
COPY --chown=node:node shared ./shared COPY --chown=node:node shared ./shared
COPY --chown=node:node lib ./lib COPY --chown=node:node lib ./lib

View File

@@ -0,0 +1,32 @@
import { createHash } from "node:crypto";
import { createClient } from "@supabase/supabase-js";
import { redirect } from "next/navigation";
import { user } from "@/lib/supabase";
import { Header, Footer } from "@/components/Shell";
export const metadata = { robots: { index: false, follow: false }, referrer: "no-referrer" as const };
function db() {
return createClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.SUPABASE_SECRET_KEY!, { auth: { persistSession: false } });
}
async function confirm(form: FormData) {
"use server";
const token = String(form.get("token") ?? "");
if (!/^[a-f0-9]{64}$/.test(token)) throw new Error("Invalid application link");
const account = await user();
if (!account) redirect(`/log-in?next=${encodeURIComponent(`/moderator-application/${token}`)}`);
const database = db();
const { data: profile, error: profileError } = await database.from("profiles").select("username,account_status").eq("id", account.id).single();
if (profileError || !profile || profile.account_status !== "active" || !profile.username) redirect(`/moderator-application/${token}?error=username`);
const hash = createHash("sha256").update(token).digest("hex");
const { data, error } = await database.from("moderator_applications").update({ status: "pending", profile_id: account.id }).eq("confirmation_hash", hash).eq("requested_username", profile.username).eq("status", "confirming").gt("expires_at", new Date().toISOString()).select("id").maybeSingle();
if (error || !data) redirect(`/moderator-application/${token}?error=link`);
redirect(`/moderator-application/${token}?submitted=1`);
}
export default async function ApplicationPage({ params, searchParams }: { params: Promise<{ token: string }>; searchParams: Promise<{ submitted?: string; error?: string }> }) {
const { token } = await params;
if (!/^[a-f0-9]{64}$/.test(token)) return <main>Invalid application link.</main>;
const account = await user();
if (!account) redirect(`/log-in?next=${encodeURIComponent(`/moderator-application/${token}`)}`);
const { submitted, error } = await searchParams;
return <><Header /><main className="section page-content"><h1>Moderator application</h1>{submitted ? <p>Application sent for review.</p> : <><p>Confirm that this MineGeo account belongs to you.</p>{error && <p role="alert">{error === "username" ? "Set your username in account settings first." : "This link expired, was already used, or belongs to a different username. Start again in the bot."}</p>}<form action={confirm}><input type="hidden" name="token" value={token} /><button className="button primary">Confirm application</button></form></>}</main><Footer /></>;
}

View File

@@ -85,6 +85,7 @@ export default async function Settings({
accountId={account.id} accountId={account.id}
email={account.email ?? ""} email={account.email ?? ""}
displayName={profile.display_name} displayName={profile.display_name}
username={profile.username}
avatarPath={profile.avatar_path} avatarPath={profile.avatar_path}
appearance={profile} appearance={profile}
passwordUpdated={query.password === "updated"} passwordUpdated={query.password === "updated"}

View File

@@ -19,6 +19,7 @@ export function AccountSettings({
accountId, accountId,
email, email,
displayName, displayName,
username,
avatarPath, avatarPath,
appearance, appearance,
passwordUpdated = false, passwordUpdated = false,
@@ -26,6 +27,7 @@ export function AccountSettings({
accountId: string; accountId: string;
email: string; email: string;
displayName: string; displayName: string;
username: string | null;
avatarPath: string | null; avatarPath: string | null;
appearance: ProfileAppearance; appearance: ProfileAppearance;
passwordUpdated?: boolean; passwordUpdated?: boolean;
@@ -39,6 +41,21 @@ export function AccountSettings({
[recoveryError, setRecoveryError] = useState(""), [recoveryError, setRecoveryError] = useState(""),
[sending, setSending] = useState(false); [sending, setSending] = useState(false);
const router = useRouter(); const router = useRouter();
const [handle, setHandle] = useState(username ?? "");
const [handleMessage, setHandleMessage] = useState("");
const [handleBusy, setHandleBusy] = useState(false);
async function saveHandle(event: React.FormEvent) {
event.preventDefault();
setHandleBusy(true);
setHandleMessage("");
try {
const result = await api<{ profile: { username: string } }>("profile", { username: handle }, "PATCH");
setHandle(result.profile.username);
setHandleMessage("Username saved.");
router.refresh();
} catch (e) { setHandleMessage((e as Error).message); }
finally { setHandleBusy(false); }
}
const [color, setColor] = useState(appearance.glow_color ?? defaultGlowColor); const [color, setColor] = useState(appearance.glow_color ?? defaultGlowColor);
const [colorBusy, setColorBusy] = useState(false), const [colorBusy, setColorBusy] = useState(false),
[colorError, setColorError] = useState(""), [colorError, setColorError] = useState(""),
@@ -246,6 +263,18 @@ export function AccountSettings({
</button> </button>
</form> </form>
</section> </section>
<section className="settings-card">
<h2>{t("MineGeo username")}</h2>
<p className="muted">{t("Your unique username for applications. You can change it anytime.")}</p>
<form onSubmit={saveHandle}>
<label className="field">{t("Username")}
<input value={handle} onChange={(e) => setHandle(e.target.value)} minLength={3} maxLength={32} pattern="[A-Za-z0-9_]{3,32}" required disabled={handleBusy} />
<small>{t("3–32 letters, numbers or underscores.")}</small>
</label>
{handleMessage && <p role="status">{t(handleMessage)}</p>}
<button className="button primary" disabled={handleBusy}>{t("Save username")}</button>
</form>
</section>
<section className="settings-card glow-settings"> <section className="settings-card glow-settings">
<h2>{t("Username glow")}</h2> <h2>{t("Username glow")}</h2>
<p className="muted"> <p className="muted">

9
docs/moderator-bots.md Normal file
View File

@@ -0,0 +1,9 @@
# Moderator application bots
MineGeo usernames are unique, lowercase and changeable in account settings. An application uses a short-lived link that requires the applicant to sign in to the matching MineGeo account. Approval changes that confirmed account's role to moderator; rejection leaves its role unchanged.
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.
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

@@ -256,10 +256,10 @@ export async function accountApi(
{ error: "Earn a username glow before choosing its color" }, { error: "Earn a username glow before choosing its color" },
403, 403,
); );
const profile = required( const updatedProfile = await admin()
await admin()
.from("profiles") .from("profiles")
.update({ .update({
...(body.username !== undefined ? { username: body.username } : {}),
...(body.displayName !== undefined ...(body.displayName !== undefined
? { display_name: body.displayName } ? { display_name: body.displayName }
: {}), : {}),
@@ -272,10 +272,11 @@ export async function accountApi(
}) })
.eq("id", account.id) .eq("id", account.id)
.select( .select(
"id,display_name,avatar_path,role,name_glow,glow_color,bug_finder,custom_prefix,custom_prefix_icon", "id,username,display_name,avatar_path,role,name_glow,glow_color,bug_finder,custom_prefix,custom_prefix_icon",
) )
.single(), .single();
); if (updatedProfile.error?.code === "23505") return json({ error: "That username is already taken." }, 409);
const profile = required(updatedProfile);
if ( if (
body.avatarPath !== undefined && body.avatarPath !== undefined &&
body.avatarPath !== current.avatar_path body.avatarPath !== current.avatar_path

View File

@@ -21,7 +21,7 @@ export async function accountProfile(account: User) {
await admin() await admin()
.from("profiles") .from("profiles")
.select( .select(
"id,display_name,avatar_path,role,name_glow,glow_color,bug_finder,custom_prefix,custom_prefix_icon,account_status,account_reason,account_changed_at", "id,username,display_name,avatar_path,role,name_glow,glow_color,bug_finder,custom_prefix,custom_prefix_icon,account_status,account_reason,account_changed_at",
) )
.eq("id", account.id) .eq("id", account.id)
.single(), .single(),

351
package-lock.json generated
View File

@@ -11,7 +11,9 @@
"@supabase/ssr": "0.7.0", "@supabase/ssr": "0.7.0",
"@supabase/supabase-js": "2.117.2", "@supabase/supabase-js": "2.117.2",
"@vercel/analytics": "2.0.1", "@vercel/analytics": "2.0.1",
"discord.js": "14.27.0",
"fflate": "0.8.3", "fflate": "0.8.3",
"grammy": "1.46.0",
"leaflet": "1.9.4", "leaflet": "1.9.4",
"lucide-react": "0.468.0", "lucide-react": "0.468.0",
"next": "16.3.8", "next": "16.3.8",
@@ -263,6 +265,145 @@
"dev": true, "dev": true,
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/@discordjs/builders": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.14.1.tgz",
"integrity": "sha512-gSKkhXLqs96TCzk66VZuHHl8z2bQMJFGwrXC0f33ngK+FLNau4hU1PYny3DNJfNdSH+gVMzE85/d5FQ2BpcNwQ==",
"license": "Apache-2.0",
"dependencies": {
"@discordjs/formatters": "^0.6.2",
"@discordjs/util": "^1.2.0",
"@sapphire/shapeshift": "^4.0.0",
"discord-api-types": "^0.38.40",
"fast-deep-equal": "^3.1.3",
"ts-mixer": "^6.0.4",
"tslib": "^2.6.3"
},
"engines": {
"node": ">=16.11.0"
},
"funding": {
"url": "https://github.com/discordjs/discord.js?sponsor"
}
},
"node_modules/@discordjs/collection": {
"version": "1.5.3",
"resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-1.5.3.tgz",
"integrity": "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=16.11.0"
}
},
"node_modules/@discordjs/formatters": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.2.tgz",
"integrity": "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==",
"license": "Apache-2.0",
"dependencies": {
"discord-api-types": "^0.38.33"
},
"engines": {
"node": ">=16.11.0"
},
"funding": {
"url": "https://github.com/discordjs/discord.js?sponsor"
}
},
"node_modules/@discordjs/rest": {
"version": "2.6.3",
"resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.3.tgz",
"integrity": "sha512-wvOylxNYJkwKjctS/Mn5GP1w9r3/rzyH+ThD1JlAca6zEdlHs8QWBBUQJpU5Q+W6DoIj/Ljh1IPlZs7hTU+UAg==",
"license": "Apache-2.0",
"dependencies": {
"@discordjs/collection": "^2.1.1",
"@discordjs/util": "^1.2.0",
"@sapphire/async-queue": "^1.5.3",
"@sapphire/snowflake": "^3.5.5",
"@vladfrangu/async_event_emitter": "^2.4.6",
"discord-api-types": "^0.38.50",
"magic-bytes.js": "^1.13.0",
"tslib": "^2.6.3",
"undici": "^6.27.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/discordjs/discord.js?sponsor"
}
},
"node_modules/@discordjs/rest/node_modules/@discordjs/collection": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz",
"integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==",
"license": "Apache-2.0",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/discordjs/discord.js?sponsor"
}
},
"node_modules/@discordjs/rest/node_modules/undici": {
"version": "6.29.1",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.29.1.tgz",
"integrity": "sha512-EvRlUt/QqAEvXdQeRMYUfwOQsfzpDztykrG2owGEE/rBOBoYv9uF39ZzsNDh4lTFHhTr2XOZjKkYgueD12ZmGA==",
"license": "MIT",
"engines": {
"node": ">=18.17"
}
},
"node_modules/@discordjs/util": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.2.0.tgz",
"integrity": "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==",
"license": "Apache-2.0",
"dependencies": {
"discord-api-types": "^0.38.33"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/discordjs/discord.js?sponsor"
}
},
"node_modules/@discordjs/ws": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-1.2.3.tgz",
"integrity": "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==",
"license": "Apache-2.0",
"dependencies": {
"@discordjs/collection": "^2.1.0",
"@discordjs/rest": "^2.5.1",
"@discordjs/util": "^1.1.0",
"@sapphire/async-queue": "^1.5.2",
"@types/ws": "^8.5.10",
"@vladfrangu/async_event_emitter": "^2.2.4",
"discord-api-types": "^0.38.1",
"tslib": "^2.6.2",
"ws": "^8.17.0"
},
"engines": {
"node": ">=16.11.0"
},
"funding": {
"url": "https://github.com/discordjs/discord.js?sponsor"
}
},
"node_modules/@discordjs/ws/node_modules/@discordjs/collection": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz",
"integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==",
"license": "Apache-2.0",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/discordjs/discord.js?sponsor"
}
},
"node_modules/@emnapi/runtime": { "node_modules/@emnapi/runtime": {
"version": "1.11.3", "version": "1.11.3",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
@@ -689,6 +830,12 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@grammyjs/types": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@grammyjs/types/-/types-5.0.0.tgz",
"integrity": "sha512-iq1Qrq1iPKkB8yAa0qSuIURMZOCuqTY5pWy5gHpCeL1oQ+GPadGhw/cDTVE8waJwuCzacUzuIjRv1sESvk7u7A==",
"license": "MIT"
},
"node_modules/@img/colour": { "node_modules/@img/colour": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
@@ -1934,6 +2081,39 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@sapphire/async-queue": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz",
"integrity": "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==",
"license": "MIT",
"engines": {
"node": ">=v14.0.0",
"npm": ">=7.0.0"
}
},
"node_modules/@sapphire/shapeshift": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-4.0.0.tgz",
"integrity": "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"lodash": "^4.17.21"
},
"engines": {
"node": ">=v16"
}
},
"node_modules/@sapphire/snowflake": {
"version": "3.5.5",
"resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.5.tgz",
"integrity": "sha512-xzvBr1Q1c4lCe7i6sRnrofxeO1QTP/LKQ6A6qy0iB4x5yfiSfARMEQEghojzTNALDTcv8En04qYNIco9/K9eZQ==",
"license": "MIT",
"engines": {
"node": ">=v14.0.0",
"npm": ">=7.0.0"
}
},
"node_modules/@sec-ant/readable-stream": { "node_modules/@sec-ant/readable-stream": {
"version": "0.4.1", "version": "0.4.1",
"resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz",
@@ -2806,7 +2986,6 @@
"version": "24.7.2", "version": "24.7.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.7.2.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.7.2.tgz",
"integrity": "sha512-/NbVmcGTP+lj5oa4yiYxxeBjRivKQ5Ns1eSZeB99ExsEQ6rX5XYU1Zy/gGxY/ilqtD4Etx9mKyrPxZRetiahhA==", "integrity": "sha512-/NbVmcGTP+lj5oa4yiYxxeBjRivKQ5Ns1eSZeB99ExsEQ6rX5XYU1Zy/gGxY/ilqtD4Etx9mKyrPxZRetiahhA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"undici-types": "~7.14.0" "undici-types": "~7.14.0"
@@ -2874,6 +3053,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/ws": {
"version": "8.18.2",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.2.tgz",
"integrity": "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@vercel/analytics": { "node_modules/@vercel/analytics": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/@vercel/analytics/-/analytics-2.0.1.tgz", "resolved": "https://registry.npmjs.org/@vercel/analytics/-/analytics-2.0.1.tgz",
@@ -3113,6 +3301,16 @@
"node": ">=20.0.0" "node": ">=20.0.0"
} }
}, },
"node_modules/@vladfrangu/async_event_emitter": {
"version": "2.4.7",
"resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.7.tgz",
"integrity": "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==",
"license": "MIT",
"engines": {
"node": ">=v14.0.0",
"npm": ">=7.0.0"
}
},
"node_modules/@webgpu/types": { "node_modules/@webgpu/types": {
"version": "0.1.74", "version": "0.1.74",
"resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.74.tgz", "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.74.tgz",
@@ -5341,6 +5539,18 @@
"node": ">=20" "node": ">=20"
} }
}, },
"node_modules/abort-controller": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
"license": "MIT",
"dependencies": {
"event-target-shim": "^5.0.0"
},
"engines": {
"node": ">=6.5"
}
},
"node_modules/acorn": { "node_modules/acorn": {
"version": "8.16.0", "version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
@@ -6364,6 +6574,48 @@
"integrity": "sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg==", "integrity": "sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/discord-api-types": {
"version": "0.38.57",
"resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.57.tgz",
"integrity": "sha512-R1CTSSt4RshqCq23j2Xq2ZscvzB4LzG9OB1CYQdKUGdw8Ti/oF/72EEtK2mNpagG/Vj9NcBT7dw9lKLf+Dacfg==",
"license": "MIT"
},
"node_modules/discord.js": {
"version": "14.27.0",
"resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.27.0.tgz",
"integrity": "sha512-qHbFlFG2N7y3LjPySYsL6A1+BnX6bkTVgo842EX0CqVPk/KTMwZkojPHEXKsQUpWZNyz5BISNHK1cPpQw0+m4A==",
"license": "Apache-2.0",
"dependencies": {
"@discordjs/builders": "^1.14.1",
"@discordjs/collection": "1.5.3",
"@discordjs/formatters": "^0.6.2",
"@discordjs/rest": "^2.6.2",
"@discordjs/util": "^1.2.0",
"@discordjs/ws": "^1.2.3",
"@sapphire/snowflake": "3.5.5",
"discord-api-types": "^0.38.49",
"fast-deep-equal": "3.1.3",
"lodash.snakecase": "4.1.1",
"magic-bytes.js": "^1.13.0",
"tslib": "^2.6.3",
"undici": "^6.27.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/discordjs/discord.js?sponsor"
}
},
"node_modules/discord.js/node_modules/undici": {
"version": "6.29.1",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.29.1.tgz",
"integrity": "sha512-EvRlUt/QqAEvXdQeRMYUfwOQsfzpDztykrG2owGEE/rBOBoYv9uF39ZzsNDh4lTFHhTr2XOZjKkYgueD12ZmGA==",
"license": "MIT",
"engines": {
"node": ">=18.17"
}
},
"node_modules/dotenv": { "node_modules/dotenv": {
"version": "17.4.2", "version": "17.4.2",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
@@ -6503,6 +6755,15 @@
"@types/estree": "^1.0.0" "@types/estree": "^1.0.0"
} }
}, },
"node_modules/event-target-shim": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/events-universal": { "node_modules/events-universal": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz",
@@ -6581,6 +6842,12 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"license": "MIT"
},
"node_modules/fast-fifo": { "node_modules/fast-fifo": {
"version": "1.3.2", "version": "1.3.2",
"resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz",
@@ -6896,6 +7163,21 @@
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/grammy": {
"version": "1.46.0",
"resolved": "https://registry.npmjs.org/grammy/-/grammy-1.46.0.tgz",
"integrity": "sha512-/8Qw+iisrUdOMk+p2mjEHouMm/BBdBEN1DHh16wiTpRUZkxDG3PxexdjCvR+wvK3LWPdrEvnQbdrwpU954sPhg==",
"license": "MIT",
"dependencies": {
"@grammyjs/types": "5.0.0",
"abort-controller": "^3.0.0",
"debug": "^4.4.3",
"node-fetch": "^2.7.0"
},
"engines": {
"node": "^12.20.0 || >=14.13.1"
}
},
"node_modules/has-flag": { "node_modules/has-flag": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
@@ -7519,6 +7801,12 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/lodash": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"license": "MIT"
},
"node_modules/lodash._baseiteratee": { "node_modules/lodash._baseiteratee": {
"version": "4.7.0", "version": "4.7.0",
"resolved": "https://registry.npmjs.org/lodash._baseiteratee/-/lodash._baseiteratee-4.7.0.tgz", "resolved": "https://registry.npmjs.org/lodash._baseiteratee/-/lodash._baseiteratee-4.7.0.tgz",
@@ -7565,6 +7853,12 @@
"lodash._basetostring": "~4.12.0" "lodash._basetostring": "~4.12.0"
} }
}, },
"node_modules/lodash.snakecase": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz",
"integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==",
"license": "MIT"
},
"node_modules/lodash.throttle": { "node_modules/lodash.throttle": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz", "resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz",
@@ -7631,6 +7925,12 @@
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc"
} }
}, },
"node_modules/magic-bytes.js": {
"version": "1.13.1",
"resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.13.1.tgz",
"integrity": "sha512-x5sn4UX2k5gCWlcfmoFwG4TPie8+dctESyqOBdhB5p6MsgWXdBKGmt9nXPObj/JI50TTL928lc5Yt1WntMn1bw==",
"license": "MIT"
},
"node_modules/magic-string": { "node_modules/magic-string": {
"version": "0.30.21", "version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -7910,6 +8210,26 @@
"node": "^10 || ^12 || >=14" "node": "^10 || ^12 || >=14"
} }
}, },
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/node-gyp-build-optional-packages": { "node_modules/node-gyp-build-optional-packages": {
"version": "5.1.1", "version": "5.1.1",
"resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.1.1.tgz", "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.1.1.tgz",
@@ -9265,6 +9585,18 @@
"url": "https://github.com/sponsors/Borewit" "url": "https://github.com/sponsors/Borewit"
} }
}, },
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/ts-mixer": {
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz",
"integrity": "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==",
"license": "MIT"
},
"node_modules/tslib": { "node_modules/tslib": {
"version": "2.8.1", "version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
@@ -9411,7 +9743,6 @@
"version": "7.14.0", "version": "7.14.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.14.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.14.0.tgz",
"integrity": "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA==", "integrity": "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/unicorn-magic": { "node_modules/unicorn-magic": {
@@ -9515,12 +9846,28 @@
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true "peer": true
}, },
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/webpack-virtual-modules": { "node_modules/webpack-virtual-modules": {
"version": "0.6.2", "version": "0.6.2",
"resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
"integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/which": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

View File

@@ -18,8 +18,10 @@
"reprocess": "node scripts/reprocess.mjs", "reprocess": "node scripts/reprocess.mjs",
"repair:panoramas": "node scripts/repair-panoramas.mjs", "repair:panoramas": "node scripts/repair-panoramas.mjs",
"worker": "tsx worker/main.ts", "worker": "tsx worker/main.ts",
"bots": "node scripts/moderator-bots.mjs",
"test": "tsx --test tests/*.test.ts", "test": "tsx --test tests/*.test.ts",
"test:leaderboard": "node scripts/test-leaderboard-sql.mjs", "test:leaderboard": "node scripts/test-leaderboard-sql.mjs",
"test:moderator-applications": "node scripts/test-moderator-applications-sql.mjs",
"test:multiplayer": "node scripts/test-multiplayer-sql.mjs", "test:multiplayer": "node scripts/test-multiplayer-sql.mjs",
"test:round-panoramas": "node scripts/test-round-panoramas-sql.mjs", "test:round-panoramas": "node scripts/test-round-panoramas-sql.mjs",
"test:difficulty-presets": "node scripts/test-difficulty-presets-sql.mjs", "test:difficulty-presets": "node scripts/test-difficulty-presets-sql.mjs",
@@ -36,7 +38,9 @@
"@supabase/ssr": "0.7.0", "@supabase/ssr": "0.7.0",
"@supabase/supabase-js": "2.117.2", "@supabase/supabase-js": "2.117.2",
"@vercel/analytics": "2.0.1", "@vercel/analytics": "2.0.1",
"discord.js": "14.27.0",
"fflate": "0.8.3", "fflate": "0.8.3",
"grammy": "1.46.0",
"leaflet": "1.9.4", "leaflet": "1.9.4",
"lucide-react": "0.468.0", "lucide-react": "0.468.0",
"next": "16.3.8", "next": "16.3.8",

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);

View File

@@ -72,3 +72,6 @@ run([
"tsx", "tsx",
"worker/main.ts", "worker/main.ts",
]); ]);
if (!dev && process.env.MINEGEO_RUN_BOTS === "true") {
run(["scripts/moderator-bots.mjs"]);
}

View File

@@ -0,0 +1,30 @@
import assert from "node:assert/strict";
import { randomUUID } from "node:crypto";
import { readFile } from "node:fs/promises";
import { PGlite } from "../.data/sql-verification/node_modules/@electric-sql/pglite/dist/index.js";
const db = new PGlite();
try {
await db.exec(`create role anon; create role authenticated; create role service_role;
create table public.profiles(id uuid primary key, role text not null default 'contributor', account_status text not null default 'active');`);
for (const name of ["20261029105929_moderator_applications.sql", "20261029111310_fix_moderator_review.sql"])
await db.exec(await readFile(`supabase/migrations/${name}`, "utf8"));
const player = randomUUID(), other = randomUUID();
await db.query("insert into public.profiles(id,username) values ($1,'player_one'),($2,'player_two')", [player, other]);
await assert.rejects(db.query("update public.profiles set username='player_one' where id=$1", [other]), /unique/i);
await db.query("update public.profiles set username='renamed' where id=$1", [player]);
await db.exec("insert into public.moderator_bot_admin(id,discord_user_id) values(true,'admin-id')");
const create = async (id, status) => (await db.query("insert into public.moderator_applications(platform,platform_user_id,requested_username,profile_id,confirmation_hash,status) values('discord',$1,'renamed',$2,$3,$4) returning id", [id, player, randomUUID(), status])).rows[0].id;
const review = (id, decision, reviewer) => db.query("select public.review_moderator_application($1,$2,$3)", [id, decision, reviewer]);
const pending = await create("one", "pending");
await assert.rejects(review(pending, "approved", "wrong-id"), /Admin permission required/);
await review(pending, "declined", "admin-id");
assert.equal((await db.query("select role from public.profiles where id=$1", [player])).rows[0].role, "contributor");
const unconfirmed = await create("two", "confirming");
await assert.rejects(review(unconfirmed, "approved", "admin-id"), /no longer pending/);
const approved = await create("three", "pending");
await review(approved, "approved", "admin-id");
assert.equal((await db.query("select role from public.profiles where id=$1", [player])).rows[0].role, "moderator");
await assert.rejects(review(approved, "approved", "admin-id"), /no longer pending/);
console.log("Moderator application SQL checks passed.");
} finally { await db.close(); }

View File

@@ -10,6 +10,7 @@ export const displayNameSchema = z
(value) => !/[\u0000-\u001f\u007f\u202a-\u202e\u2066-\u2069]/u.test(value), (value) => !/[\u0000-\u001f\u007f\u202a-\u202e\u2066-\u2069]/u.test(value),
"Use a single line without hidden control characters", "Use a single line without hidden control characters",
); );
export const usernameSchema = z.string().trim().toLowerCase().regex(/^[a-z0-9_]{3,32}$/, "Use 3–32 letters, numbers or underscores.");
export const newPasswordSchema = z export const newPasswordSchema = z
.string() .string()
.min(12, "Use at least 12 characters") .min(12, "Use at least 12 characters")
@@ -41,6 +42,7 @@ export const authActionSchema = z.discriminatedUnion("action", [
export const profileUpdateSchema = z export const profileUpdateSchema = z
.object({ .object({
displayName: displayNameSchema.optional(), displayName: displayNameSchema.optional(),
username: usernameSchema.optional(),
glowColor: glowColorSchema.optional(), glowColor: glowColorSchema.optional(),
avatarPath: z.string().max(128).nullable().optional(), avatarPath: z.string().max(128).nullable().optional(),
}) })
@@ -48,6 +50,7 @@ export const profileUpdateSchema = z
.refine( .refine(
(value) => (value) =>
value.displayName !== undefined || value.displayName !== undefined ||
value.username !== undefined ||
value.glowColor !== undefined || value.glowColor !== undefined ||
value.avatarPath !== undefined, value.avatarPath !== undefined,
"Choose a profile change", "Choose a profile change",
@@ -81,5 +84,6 @@ export function authDestination(value: string | null | undefined): string {
if (value && /^\/support\/[0-9a-f-]{36}$/i.test(value)) return value; if (value && /^\/support\/[0-9a-f-]{36}$/i.test(value)) return value;
if (value && /^\/tournaments\?map=[0-9a-f-]{36}$/i.test(value)) return value; if (value && /^\/tournaments\?map=[0-9a-f-]{36}$/i.test(value)) return value;
if (value && /^\/pair\?code=[A-F0-9]{8}$/.test(value)) return value; if (value && /^\/pair\?code=[A-F0-9]{8}$/.test(value)) return value;
if (value && /^\/moderator-application\/[a-f0-9]{64}$/.test(value)) return value;
return "/dashboard"; return "/dashboard";
} }

View File

@@ -642,6 +642,7 @@ export type Database = {
}; };
profiles: { profiles: {
Row: AccountStatusFields & { Row: AccountStatusFields & {
username: string | null;
avatar_path: string | null; avatar_path: string | null;
glow_color: string; glow_color: string;
bug_finder: boolean; bug_finder: boolean;
@@ -654,6 +655,7 @@ export type Database = {
role: string; role: string;
}; };
Insert: Partial<AccountStatusFields> & { Insert: Partial<AccountStatusFields> & {
username?: string | null;
avatar_path?: string | null; avatar_path?: string | null;
glow_color?: string; glow_color?: string;
bug_finder?: boolean; bug_finder?: boolean;
@@ -666,6 +668,7 @@ export type Database = {
role?: string; role?: string;
}; };
Update: Partial<AccountStatusFields> & { Update: Partial<AccountStatusFields> & {
username?: string | null;
avatar_path?: string | null; avatar_path?: string | null;
glow_color?: string; glow_color?: string;
bug_finder?: boolean; bug_finder?: boolean;

View File

@@ -1103,5 +1103,12 @@
"Unban": "Разблокировать", "Unban": "Разблокировать",
"Chat slow mode: wait before sending another message": "Медленный режим чата: подождите перед отправкой следующего сообщения", "Chat slow mode: wait before sending another message": "Медленный режим чата: подождите перед отправкой следующего сообщения",
"You are banned from this tournament": "Вы заблокированы в этом турнире", "You are banned from this tournament": "Вы заблокированы в этом турнире",
"You are banned from this party": "Вы заблокированы в этой группе" "You are banned from this party": "Вы заблокированы в этой группе",
"MineGeo username": "Имя пользователя MineGeo",
"Your unique username for applications. You can change it anytime.": "Ваше уникальное имя для заявок. Его можно изменить в любой момент.",
"Username": "Имя пользователя",
"3–32 letters, numbers or underscores.": "3–32 латинские буквы, цифры или символы подчёркивания.",
"Save username": "Сохранить имя пользователя",
"Username saved.": "Имя пользователя сохранено.",
"That username is already taken.": "Это имя пользователя уже занято."
} }

View File

@@ -0,0 +1,49 @@
alter table public.profiles add column username text;
alter table public.profiles add constraint profiles_username_format check (username is null or username ~ '^[a-z0-9_]{3,32}$');
create unique index profiles_username_unique on public.profiles (username) where username is not null;
create table public.moderator_applications (
id uuid primary key default gen_random_uuid(),
platform text not null check (platform in ('telegram', 'discord')),
platform_user_id text not null,
platform_username text,
requested_username text not null,
profile_id uuid references public.profiles(id) on delete set null,
confirmation_hash text not null unique,
status text not null default 'confirming' check (status in ('confirming', 'pending', 'approved', 'declined')),
expires_at timestamptz not null default (now() + interval '30 minutes'),
created_at timestamptz not null default now(),
reviewed_at timestamptz,
reviewer_discord_id text,
admin_notified_at timestamptz,
applicant_notified_at timestamptz
);
create index moderator_applications_queue on public.moderator_applications (created_at) where status = 'pending';
create unique index moderator_applications_one_open on public.moderator_applications (platform, platform_user_id) where status = 'pending';
alter table public.moderator_applications enable row level security;
revoke all on public.moderator_applications from public, anon, authenticated;
grant all on public.moderator_applications to service_role;
create table public.moderator_bot_admin (id boolean primary key default true check (id), discord_user_id text not null);
alter table public.moderator_bot_admin enable row level security;
revoke all on public.moderator_bot_admin from public, anon, authenticated;
grant all on public.moderator_bot_admin to service_role;
create function public.review_moderator_application(p_application uuid, p_decision text, p_reviewer text)
returns text language plpgsql security invoker set search_path = '' as $$
declare application public.moderator_applications; current_role text; current_status text;
begin
if not exists (select 1 from public.moderator_bot_admin where discord_user_id = p_reviewer) then raise exception 'Admin permission required'; end if;
if p_decision not in ('approved', 'declined') then raise exception 'Invalid decision'; end if;
select * into application from public.moderator_applications where id = p_application for update;
if not found or application.status <> 'pending' then raise exception 'Application is no longer pending'; end if;
if p_decision = 'approved' then
select role, account_status into current_role, current_status from public.profiles where id = application.profile_id for update;
if not found or current_status <> 'active' or current_role <> 'contributor' then raise exception 'Account cannot be promoted'; end if;
update public.profiles set role = 'moderator' where id = application.profile_id;
end if;
update public.moderator_applications set status = p_decision, reviewed_at = now(), reviewer_discord_id = p_reviewer where id = p_application;
return p_decision;
end; $$;
revoke all on function public.review_moderator_application(uuid,text,text) from public, anon, authenticated;
grant execute on function public.review_moderator_application(uuid,text,text) to service_role;

View File

@@ -0,0 +1,18 @@
create or replace function public.review_moderator_application(p_application uuid, p_decision text, p_reviewer text)
returns text language plpgsql security invoker set search_path = '' as $$
declare application public.moderator_applications; account_role text; account_state text;
begin
if not exists (select 1 from public.moderator_bot_admin where discord_user_id = p_reviewer) then raise exception 'Admin permission required'; end if;
if p_decision not in ('approved', 'declined') then raise exception 'Invalid decision'; end if;
select * into application from public.moderator_applications where id = p_application for update;
if not found or application.status <> 'pending' then raise exception 'Application is no longer pending'; end if;
if p_decision = 'approved' then
select role, account_status into account_role, account_state from public.profiles where id = application.profile_id for update;
if not found or account_state <> 'active' or account_role <> 'contributor' then raise exception 'Account cannot be promoted'; end if;
update public.profiles set role = 'moderator' where id = application.profile_id;
end if;
update public.moderator_applications set status = p_decision, reviewed_at = now(), reviewer_discord_id = p_reviewer where id = p_application;
return p_decision;
end; $$;
revoke all on function public.review_moderator_application(uuid,text,text) from public, anon, authenticated;
grant execute on function public.review_moderator_application(uuid,text,text) to service_role;

View File

@@ -6,7 +6,16 @@ import {
displayNameSchema, displayNameSchema,
newPasswordSchema, newPasswordSchema,
profileUpdateSchema, profileUpdateSchema,
usernameSchema,
} from "../shared/account"; } from "../shared/account";
test("usernames are normalized, unique-key compatible and changeable through profile updates", () => {
assert.equal(usernameSchema.parse(" Perfect_Python500 "), "perfect_python500");
assert.deepEqual(profileUpdateSchema.parse({ username: "New_Name" }), { username: "new_name" });
for (const bad of ["ab", "a".repeat(33), "name space", "name@server", "юзер", "a\nb"]) assert.equal(usernameSchema.safeParse(bad).success, false);
const path = "/moderator-application/" + "a".repeat(64);
assert.equal(authDestination(path), path);
assert.equal(authDestination(path + "?next=https://evil.example"), "/dashboard");
});
import { import {
createRecoveryGrant, createRecoveryGrant,
verifyRecoveryGrant, verifyRecoveryGrant,