feat(platform): add Discord onboarding and Velocity gate
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "@minecraft-account-manager/minecraft",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": { ".": "./src/index.ts" },
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.1.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
const MINECRAFT_USERNAME = /^[A-Za-z0-9_]{3,16}$/;
|
||||
const MINECRAFT_UUID = /^[0-9a-f]{32}$/i;
|
||||
const DISCORD_NICKNAME_LIMIT = 32;
|
||||
|
||||
export interface JavaProfile {
|
||||
uuid: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
export async function lookupJavaProfile(
|
||||
username: string,
|
||||
request: typeof fetch = fetch,
|
||||
): Promise<JavaProfile | null> {
|
||||
const candidate = username.trim();
|
||||
if (!MINECRAFT_USERNAME.test(candidate)) return null;
|
||||
|
||||
const response = await request(
|
||||
`https://api.mojang.com/users/profiles/minecraft/${encodeURIComponent(candidate)}`,
|
||||
{ headers: { accept: "application/json" }, cache: "no-store" },
|
||||
);
|
||||
|
||||
if (response.status === 204 || response.status === 404) return null;
|
||||
if (!response.ok) throw new Error(`Mojang profile lookup failed (${response.status})`);
|
||||
|
||||
const profile: unknown = await response.json();
|
||||
if (!profile || typeof profile !== "object") return null;
|
||||
const { id, name } = profile as { id?: unknown; name?: unknown };
|
||||
if (typeof id !== "string" || !MINECRAFT_UUID.test(id)) return null;
|
||||
if (typeof name !== "string" || !MINECRAFT_USERNAME.test(name)) return null;
|
||||
|
||||
return { uuid: id.toLowerCase(), username: name };
|
||||
}
|
||||
|
||||
export function formatDiscordNickname(firstName: string, minecraftUsername: string) {
|
||||
const suffix = ` (${minecraftUsername})`;
|
||||
const availableCharacters = DISCORD_NICKNAME_LIMIT - [...suffix].length;
|
||||
const shortenedName = [...firstName.trim()].slice(0, Math.max(1, availableCharacters)).join("").trimEnd();
|
||||
return `${shortenedName}${suffix}`;
|
||||
}
|
||||
|
||||
export async function updateGuildNickname(
|
||||
input: {
|
||||
guildId: string;
|
||||
discordUserId: string;
|
||||
nickname: string;
|
||||
botToken: string;
|
||||
},
|
||||
request: typeof fetch = fetch,
|
||||
) {
|
||||
const response = await request(
|
||||
`https://discord.com/api/v10/guilds/${input.guildId}/members/${input.discordUserId}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
authorization: `Bot ${input.botToken}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ nick: input.nickname }),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Discord nickname update failed (${response.status})`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { updateGuildNickname } from "../src/index";
|
||||
|
||||
describe("Discord nickname updates", () => {
|
||||
it("updates a member in the configured guild using bot authentication", async () => {
|
||||
const request = vi.fn<typeof fetch>().mockResolvedValue(new Response(null, { status: 204 }));
|
||||
|
||||
await updateGuildNickname(
|
||||
{ guildId: "123456789012345678", discordUserId: "987654321098765432", nickname: "Sam (Notch)", botToken: "secret" },
|
||||
request,
|
||||
);
|
||||
|
||||
expect(request).toHaveBeenCalledWith(
|
||||
"https://discord.com/api/v10/guilds/123456789012345678/members/987654321098765432",
|
||||
expect.objectContaining({ method: "PATCH", body: JSON.stringify({ nick: "Sam (Notch)" }) }),
|
||||
);
|
||||
});
|
||||
|
||||
it("reports Discord permission failures without pretending the nickname changed", async () => {
|
||||
const request = vi.fn<typeof fetch>().mockResolvedValue(new Response("Missing Permissions", { status: 403 }));
|
||||
await expect(
|
||||
updateGuildNickname(
|
||||
{ guildId: "123456789012345678", discordUserId: "987654321098765432", nickname: "Sam (Notch)", botToken: "secret" },
|
||||
request,
|
||||
),
|
||||
).rejects.toThrow("Discord nickname update failed (403)");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { formatDiscordNickname, lookupJavaProfile } from "../src/index";
|
||||
|
||||
describe("Java Edition profiles", () => {
|
||||
it("returns Mojang's canonical UUID and username", async () => {
|
||||
const request = vi.fn<typeof fetch>().mockResolvedValue(
|
||||
new Response(JSON.stringify({ id: "069a79f444e94726a5befca90e38aaf5", name: "Notch" }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(lookupJavaProfile("notch", request)).resolves.toEqual({
|
||||
uuid: "069a79f444e94726a5befca90e38aaf5",
|
||||
username: "Notch",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null when Mojang does not recognize the username", async () => {
|
||||
const request = vi.fn<typeof fetch>().mockResolvedValue(new Response(null, { status: 204 }));
|
||||
await expect(lookupJavaProfile("UnknownPlayer", request)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("preserves the Minecraft username while fitting Discord's nickname limit", () => {
|
||||
expect(formatDiscordNickname("Alexandria Catherine", "SixteenCharName1")).toBe(
|
||||
"Alexandria Ca (SixteenCharName1)",
|
||||
);
|
||||
expect(formatDiscordNickname("Sam", "Notch")).toBe("Sam (Notch)");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": { "types": ["vitest/globals"] },
|
||||
"include": ["src/**/*.ts", "test/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user