added frontend + securing beta server invites
This commit is contained in:
488
web/lib/discord.ts
Normal file
488
web/lib/discord.ts
Normal file
@@ -0,0 +1,488 @@
|
||||
export interface Guild {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
owner: boolean;
|
||||
permissions: string;
|
||||
features: string[];
|
||||
botInGuild?: boolean;
|
||||
isBetaServer?: boolean;
|
||||
}
|
||||
|
||||
|
||||
export interface LeaderboardMember {
|
||||
user_id: string;
|
||||
username: string;
|
||||
avatar: string;
|
||||
level: number;
|
||||
xp: number;
|
||||
rank: number;
|
||||
}
|
||||
|
||||
export interface Role {
|
||||
role_id: string;
|
||||
role_name: string;
|
||||
color: number;
|
||||
position: number;
|
||||
}
|
||||
|
||||
export interface TrackRole {
|
||||
role_name: string;
|
||||
role_id: string; // Changed from id to role_id to match backend
|
||||
level: number;
|
||||
}
|
||||
|
||||
export interface LevelTrack {
|
||||
[trackName: string]: TrackRole[];
|
||||
}
|
||||
|
||||
// ... (previous interfaces)
|
||||
|
||||
export interface LevelBridge {
|
||||
in_role_id: string;
|
||||
out_role_id: string;
|
||||
}
|
||||
|
||||
export interface LevelTracksResponse {
|
||||
tracks: LevelTrack[];
|
||||
}
|
||||
|
||||
export async function getGuildLeaderboard(guildId: string): Promise<LeaderboardMember[]> {
|
||||
try {
|
||||
if (!process.env.BOT_API_URL) return [];
|
||||
|
||||
const response = await fetch(`${process.env.BOT_API_URL}/api/${guildId}/leaderboard`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-API-Key": process.env.BOT_API_KEY as string
|
||||
},
|
||||
next: { revalidate: 60 }
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return await response.json();
|
||||
}
|
||||
console.error(`Fetch Leaderboard failed: ${response.status} ${response.statusText}`);
|
||||
return [];
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch leaderboard", e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkBotMembership(guildIds: string[]): Promise<boolean[]> {
|
||||
try {
|
||||
if (!process.env.BOT_API_URL) return guildIds.map(() => false);
|
||||
|
||||
const response = await fetch(`${process.env.BOT_API_URL}/api/is_bot_there`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-API-Key": process.env.BOT_API_KEY as string
|
||||
},
|
||||
body: `{"guild_ids": [${guildIds.join(",")}]}`,
|
||||
next: { revalidate: 0 } // Don't cache this as it might change
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
return data.results;
|
||||
} else {
|
||||
const errorText = await response.text();
|
||||
console.error(`CheckBotMembership failed: ${response.status} ${response.statusText}`, errorText);
|
||||
}
|
||||
return guildIds.map(() => false);
|
||||
} catch (e) {
|
||||
console.error("Failed to check bot membership", e);
|
||||
return guildIds.map(() => false);
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkBetaServer(guildIds: string[]): Promise<boolean[]> {
|
||||
try {
|
||||
if (!process.env.BOT_API_URL) return guildIds.map(() => false);
|
||||
|
||||
// Helper to ensure IDs are numeric strings to prevent injection if we are doing manual JSON
|
||||
const safeGuildIds = guildIds.filter(id => /^\d+$/.test(id));
|
||||
|
||||
if (safeGuildIds.length === 0) return guildIds.map(() => false);
|
||||
|
||||
// Manually construct JSON to preserve u64 precision (native JS numbers lose precision > 2^53)
|
||||
// We want: { "guild_ids": [123, 456] }
|
||||
// checkBotMembership also does this manual construction for the same reason.
|
||||
const body = `{"guild_ids": [${safeGuildIds.join(",")}]}`;
|
||||
|
||||
const response = await fetch(`${process.env.BOT_API_URL}/api/is_beta_server`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-API-Key": process.env.BOT_API_KEY as string
|
||||
},
|
||||
body: body,
|
||||
next: { revalidate: 60 }
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
return data.results;
|
||||
} else {
|
||||
const errorText = await response.text();
|
||||
console.error(`CheckBetaServer failed: ${response.status} ${response.statusText}`, errorText);
|
||||
}
|
||||
return guildIds.map(() => false);
|
||||
} catch (e) {
|
||||
console.error("Failed to check beta server", e);
|
||||
return guildIds.map(() => false);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUserGuilds(accessToken: string): Promise<Guild[]> {
|
||||
try {
|
||||
const response = await fetch("https://discord.com/api/users/@me/guilds", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
next: { revalidate: 60 } // Cache for 60 seconds
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const guilds: Guild[] = await response.json();
|
||||
// Filter: Manage Guild (0x20) or Administrator (0x8)
|
||||
const managedGuilds = guilds.filter((guild: any) => {
|
||||
const perms = BigInt(guild.permissions);
|
||||
const MANAGE_GUILD = BigInt(0x20);
|
||||
const ADMINISTRATOR = BigInt(0x8);
|
||||
return (perms & MANAGE_GUILD) === MANAGE_GUILD || (perms & ADMINISTRATOR) === ADMINISTRATOR;
|
||||
});
|
||||
|
||||
// Check if bot is in these guilds and if they are beta servers
|
||||
const guildIds = managedGuilds.map(g => g.id);
|
||||
if (guildIds.length > 0) {
|
||||
const [memberships, betaStatuses] = await Promise.all([
|
||||
checkBotMembership(guildIds),
|
||||
checkBetaServer(guildIds)
|
||||
]);
|
||||
|
||||
return managedGuilds.map((guild, index) => ({
|
||||
...guild,
|
||||
botInGuild: memberships[index] || false,
|
||||
isBetaServer: betaStatuses[index] || false
|
||||
}));
|
||||
}
|
||||
|
||||
return managedGuilds;
|
||||
}
|
||||
return [];
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch guilds", e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function getGuildRoles(guildId: string, userToken?: string): Promise<Role[]> {
|
||||
try {
|
||||
if (!process.env.BOT_API_URL) return [];
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
"X-API-Key": process.env.BOT_API_KEY as string
|
||||
};
|
||||
if (userToken) {
|
||||
headers["X-User-Token"] = userToken;
|
||||
}
|
||||
|
||||
const response = await fetch(`${process.env.BOT_API_URL}/api/${guildId}/roles`, {
|
||||
method: "GET",
|
||||
headers,
|
||||
next: { revalidate: 60 }
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
// Transform API response: { "0": { role_id, role_name, color, position }, ... }
|
||||
// to array of Role: { role_id, role_name, color, position }
|
||||
if (data && typeof data === 'object') {
|
||||
return Object.values(data).map((role: any) => ({
|
||||
role_id: role.role_id || role.id,
|
||||
role_name: role.role_name || role.name,
|
||||
color: role.color ?? 0,
|
||||
position: role.position ?? 0
|
||||
}));
|
||||
}
|
||||
return [];
|
||||
}
|
||||
console.error(`Fetch Guild Roles failed: ${response.status} ${response.statusText}`);
|
||||
return [];
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch guild roles", e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function getLevelTracks(guildId: string, userToken?: string): Promise<LevelTrack[]> {
|
||||
try {
|
||||
if (!process.env.BOT_API_URL) return [];
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
"X-API-Key": process.env.BOT_API_KEY as string
|
||||
};
|
||||
if (userToken) {
|
||||
headers["X-User-Token"] = userToken;
|
||||
}
|
||||
|
||||
const response = await fetch(`${process.env.BOT_API_URL}/api/${guildId}/level/track`, {
|
||||
method: "GET",
|
||||
headers,
|
||||
next: { revalidate: 0 } // Don't cache as this is frequently edited
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// WORKAROUND: Handle u64 precision loss (JS Numbers are f64)
|
||||
// Backend sends role_id as u64 (might be > 2^53).
|
||||
// We intercept the raw text and wrap role_id numeric values in quotes before parsing.
|
||||
const text = await response.text();
|
||||
const safeText = text.replace(/"role_id":\s*(\d+)/g, '"role_id": "$1"');
|
||||
return JSON.parse(safeText);
|
||||
}
|
||||
console.error(`Fetch Level Tracks failed: ${response.status} ${response.statusText}`);
|
||||
return [];
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch level tracks", e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Type for role in track API
|
||||
export interface TrackRoleUpdate {
|
||||
role_id: string; // Changed to string to handle u64 precision on frontend
|
||||
level: number;
|
||||
}
|
||||
|
||||
// Create a new track (POST)
|
||||
export async function createLevelTrack(
|
||||
guildId: string,
|
||||
data: { track_name: string; roles: TrackRoleUpdate[] },
|
||||
userToken: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
if (!process.env.BOT_API_URL) return { success: false, error: "Bot API URL not configured" };
|
||||
|
||||
// WORKAROUND: Unquote role_id for backend (expects u64)
|
||||
// We kept it as string in frontend/NextJS to preserve precision.
|
||||
const bodyJson = JSON.stringify(data).replace(/"role_id":\s*"(\d+)"/g, '"role_id": $1');
|
||||
|
||||
const response = await fetch(`${process.env.BOT_API_URL}/api/${guildId}/level/track`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-API-Key": process.env.BOT_API_KEY as string,
|
||||
"X-User-Token": userToken
|
||||
},
|
||||
body: bodyJson
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return { success: true };
|
||||
}
|
||||
const errorText = await response.text();
|
||||
return { success: false, error: errorText || `Failed with status ${response.status}` };
|
||||
} catch (e) {
|
||||
console.error("Failed to create level track", e);
|
||||
return { success: false, error: "Internal error" };
|
||||
}
|
||||
}
|
||||
|
||||
// Update existing track (PATCH)
|
||||
export async function updateLevelTrack(
|
||||
guildId: string,
|
||||
data: { track_name: string; roles: TrackRoleUpdate[] },
|
||||
userToken: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
if (!process.env.BOT_API_URL) return { success: false, error: "Bot API URL not configured" };
|
||||
|
||||
// WORKAROUND: Unquote role_id for backend (expects u64)
|
||||
const bodyJson = JSON.stringify(data).replace(/"role_id":\s*"(\d+)"/g, '"role_id": $1');
|
||||
|
||||
const response = await fetch(`${process.env.BOT_API_URL}/api/${guildId}/level/track`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-API-Key": process.env.BOT_API_KEY as string,
|
||||
"X-User-Token": userToken
|
||||
},
|
||||
body: bodyJson
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return { success: true };
|
||||
}
|
||||
const errorText = await response.text();
|
||||
return { success: false, error: errorText || `Failed with status ${response.status}` };
|
||||
} catch (e) {
|
||||
console.error("Failed to update level track", e);
|
||||
return { success: false, error: "Internal error" };
|
||||
}
|
||||
}
|
||||
|
||||
// Delete a track (DELETE)
|
||||
export async function deleteLevelTrack(
|
||||
guildId: string,
|
||||
trackName: string,
|
||||
userToken: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
if (!process.env.BOT_API_URL) return { success: false, error: "Bot API URL not configured" };
|
||||
|
||||
const response = await fetch(`${process.env.BOT_API_URL}/api/${guildId}/level/track`, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-API-Key": process.env.BOT_API_KEY as string,
|
||||
"X-User-Token": userToken
|
||||
},
|
||||
body: JSON.stringify({ track_name: trackName })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return { success: true };
|
||||
}
|
||||
const errorText = await response.text();
|
||||
return { success: false, error: errorText || `Failed with status ${response.status}` };
|
||||
} catch (e) {
|
||||
console.error("Failed to delete level track", e);
|
||||
return { success: false, error: "Internal error" };
|
||||
}
|
||||
}
|
||||
// ... (previous code)
|
||||
|
||||
// Level Bridger API Helpers
|
||||
|
||||
export async function getLevelBridges(guildId: string, userToken: string): Promise<LevelBridge[]> {
|
||||
try {
|
||||
if (!process.env.BOT_API_URL) return [];
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
"X-API-Key": process.env.BOT_API_KEY as string,
|
||||
"X-User-Token": userToken
|
||||
};
|
||||
|
||||
const response = await fetch(`${process.env.BOT_API_URL}/api/${guildId}/level_bridger`, {
|
||||
method: "GET",
|
||||
headers,
|
||||
next: { revalidate: 0 }
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// WORKAROUND: Handle u64 precision loss if needed, though usually these simple objects survive better
|
||||
// But let's be safe like we were with tracks if the backend sends huge numbers.
|
||||
const text = await response.text();
|
||||
// Regex to safely wrap role_id's if they come as raw numbers
|
||||
const safeText = text.replace(/("in_role_id"|"out_role_id"):\s*(\d+)/g, '$1: "$2"');
|
||||
return JSON.parse(safeText);
|
||||
}
|
||||
console.error(`Fetch Level Bridges failed: ${response.status} ${response.statusText}`);
|
||||
return [];
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch level bridges", e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function createLevelBridge(
|
||||
guildId: string,
|
||||
bridge: LevelBridge,
|
||||
userToken: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
if (!process.env.BOT_API_URL) return { success: false, error: "Bot API URL not configured" };
|
||||
|
||||
// WORKAROUND: Unquote role_id for backend (expects u64)
|
||||
const bodyJson = JSON.stringify(bridge).replace(/("in_role_id"|"out_role_id"):\s*"(\d+)"/g, '$1: $2');
|
||||
|
||||
const response = await fetch(`${process.env.BOT_API_URL}/api/${guildId}/level_bridger`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-API-Key": process.env.BOT_API_KEY as string,
|
||||
"X-User-Token": userToken
|
||||
},
|
||||
body: bodyJson
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return { success: true };
|
||||
}
|
||||
const errorText = await response.text();
|
||||
return { success: false, error: errorText || `Failed with status ${response.status}` };
|
||||
} catch (e) {
|
||||
console.error("Failed to create level bridge", e);
|
||||
return { success: false, error: "Internal error" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateLevelBridge(
|
||||
guildId: string,
|
||||
bridge: LevelBridge,
|
||||
userToken: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
if (!process.env.BOT_API_URL) return { success: false, error: "Bot API URL not configured" };
|
||||
|
||||
const bodyJson = JSON.stringify(bridge).replace(/("in_role_id"|"out_role_id"):\s*"(\d+)"/g, '$1: $2');
|
||||
|
||||
const response = await fetch(`${process.env.BOT_API_URL}/api/${guildId}/level_bridger`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-API-Key": process.env.BOT_API_KEY as string,
|
||||
"X-User-Token": userToken
|
||||
},
|
||||
body: bodyJson
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return { success: true };
|
||||
}
|
||||
const errorText = await response.text();
|
||||
return { success: false, error: errorText || `Failed with status ${response.status}` };
|
||||
} catch (e) {
|
||||
console.error("Failed to update level bridge", e);
|
||||
return { success: false, error: "Internal error" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteLevelBridge(
|
||||
guildId: string,
|
||||
inRoleId: string,
|
||||
userToken: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
if (!process.env.BOT_API_URL) return { success: false, error: "Bot API URL not configured" };
|
||||
|
||||
// Even for delete, we might need to handle the u64 issue if the ID is passed in body
|
||||
const bodyJson = JSON.stringify({ in_role_id: inRoleId }).replace(/("in_role_id"):\s*"(\d+)"/g, '$1: $2');
|
||||
|
||||
const response = await fetch(`${process.env.BOT_API_URL}/api/${guildId}/level_bridger`, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-API-Key": process.env.BOT_API_KEY as string,
|
||||
"X-User-Token": userToken
|
||||
},
|
||||
body: bodyJson
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return { success: true };
|
||||
}
|
||||
const errorText = await response.text();
|
||||
return { success: false, error: errorText || `Failed with status ${response.status}` };
|
||||
} catch (e) {
|
||||
console.error("Failed to delete level bridge", e);
|
||||
return { success: false, error: "Internal error" };
|
||||
}
|
||||
}
|
||||
6
web/lib/utils.ts
Normal file
6
web/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
Reference in New Issue
Block a user