diff --git a/src/app/api/guess/dataProvider.ts b/src/app/api/guess/dataProvider.ts index 416db4f..9c3c6f2 100644 --- a/src/app/api/guess/dataProvider.ts +++ b/src/app/api/guess/dataProvider.ts @@ -1,54 +1,141 @@ -import type { Match, NorwegianPersonItem } from "@/types"; +import type { DiscordGuildMember, Match } from "@/types"; -export async function findNorwegianPerson(input: string): Promise { - const apiUrl = `https://snl.no/api/v1/search?query=${encodeURIComponent(input)}&limit=5`; - const response = await fetch(apiUrl); - const data = await response.json(); +interface CacheEntry { + data: DiscordGuildMember[]; + timestamp: number; +} - function isNorwegianPerson(item: NorwegianPersonItem): boolean { - // Must be a biography - if (item.article_type_id !== 2) return false; +// In-memory cache for Discord API responses +let discordMembersCache: CacheEntry | null = null; +const CACHE_TTL = 5 * 60 * 1000; // 5 minutes in milliseconds - if (typeof item.rank !== "number" || item.rank <= 100) return false; +async function getDiscordMembers(): Promise { + const guildId = process.env.DISCORD_GUILD_ID; + const botToken = process.env.DISCORD_BOT_TOKEN; - // Must mention Norwegian-ness in any relevant field - const combinedText = [ - item.taxonomy_title || "", - item.subject_title || "", - item.first_sentences || "", - item.snippet || "", - ].join(" "); - return /(norsk|norske|norges|norge)/i.test(combinedText); + if (!guildId || !botToken) { + throw new Error("Missing required Discord environment variables"); } - // Filter for Norwegian biographies with strong match - const people = (data.hits || data).filter(isNorwegianPerson); + // Check if cache is valid + if (discordMembersCache && Date.now() - discordMembersCache.timestamp < CACHE_TTL) { + return discordMembersCache.data; + } - if (people.length > 0) { - const person = people[0]; - const description = await (async () => { - try { - const articleJsonRes = await fetch(person.article_url_json); - const articleJson = await articleJsonRes.json(); + // Fetch fresh data from Discord API with pagination + const allMembers: DiscordGuildMember[] = []; + let after: string | undefined = undefined; + const limit = 1000; // Maximum allowed by Discord API - if (articleJson.metadata && articleJson.metadata.occupation) { - return articleJson.metadata.occupation; - } else if (articleJson.subject_title) { - return articleJson.subject_title; - } - } catch { - // fallback to undefined - } - return undefined; - })(); + do { + const url = new URL(`https://discord.com/api/v10/guilds/${guildId}/members`); + url.searchParams.set("limit", limit.toString()); + if (after) { + url.searchParams.set("after", after); + } + + const response = await fetch(url.toString(), { + headers: { + Authorization: `Bot ${botToken}`, + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch guild members: ${response.status} ${response.statusText}`); + } + + const members: DiscordGuildMember[] = await response.json(); + allMembers.push(...members); + + // Update 'after' for next page (use the last member's user ID) + if (members.length === limit) { + after = members[members.length - 1].user.id; + } else { + after = undefined; // No more pages + } + } while (after); + + // Update cache + discordMembersCache = { + data: allMembers, + timestamp: Date.now(), + }; + + return allMembers; +} + +function getDisplayName(member: DiscordGuildMember): string { + return member.nick || member.user.global_name || member.user.username; +} + +function normalizeInput(input: string): string { + return input.toLowerCase().trim(); +} + +function memberMatchesInput(member: DiscordGuildMember, normalizedInput: string): boolean { + const username = member.user.username.toLowerCase(); + const globalName = member.user.global_name?.toLowerCase() || ""; + const nickname = member.nick?.toLowerCase() || ""; + + return username === normalizedInput || globalName === normalizedInput || nickname === normalizedInput; +} + +export async function findDiscordMember(input: string): Promise { + const payingMemberRoleId = process.env.DISCORD_REQUIRED_ROLE_ID; + + if (!payingMemberRoleId) { + console.error("Missing required Discord environment variables"); + return { + input, + name: undefined, + description: undefined, + correct: false, + }; + } + + try { + const members = await getDiscordMembers(); + const payingMembers = members.filter((member) => member.roles.includes(payingMemberRoleId)); + const normalizedInput = normalizeInput(input); + + // First check if any member matches the input (regardless of role) + const anyMatchingMember = members.find((member) => memberMatchesInput(member, normalizedInput)); + + // If member exists but doesn't have the paying member role + if (anyMatchingMember && !anyMatchingMember.roles.includes(payingMemberRoleId)) { + return { + input, + name: getDisplayName(anyMatchingMember), + description: undefined, + translationKey: "notPayingMember", + correct: false, + }; + } + + // Find matching member among paying members + const matchingMember = payingMembers.find((member) => memberMatchesInput(member, normalizedInput)); + + if (matchingMember) { + const displayName = getDisplayName(matchingMember); + const username = matchingMember.user.username; + + return { + input, + name: displayName, + description: displayName === username ? undefined : username, + correct: true, + }; + } return { input, - name: person.title || person.headword, - description, - correct: true, + name: undefined, + description: undefined, + correct: false, }; - } else { + } catch (error) { + console.error("Error finding Discord member:", error); return { input, name: undefined, diff --git a/src/app/api/guess/route.ts b/src/app/api/guess/route.ts index 81cc435..4c85982 100644 --- a/src/app/api/guess/route.ts +++ b/src/app/api/guess/route.ts @@ -2,7 +2,7 @@ import { authOptions } from "@/api/auth/[...nextauth]/route"; import type { Match } from "@/types"; import { getServerSession } from "next-auth/next"; import { NextResponse } from "next/server"; -import { findNorwegianPerson } from "./dataProvider"; +import { findDiscordMember } from "./dataProvider"; export async function POST(request: Request) { const session = await getServerSession(authOptions); @@ -10,6 +10,6 @@ export async function POST(request: Request) { return new Response("Unauthorized", { status: 401 }); } const { input } = await request.json(); - const match: Match = await findNorwegianPerson(input); + const match: Match = await findDiscordMember(input); return NextResponse.json(match); } diff --git a/src/app/translations.ts b/src/app/translations.ts index 63a4eca..15cc667 100644 --- a/src/app/translations.ts +++ b/src/app/translations.ts @@ -19,6 +19,7 @@ export const translations = { accessDenied: "Access Denied", accessDeniedMessage: "You don't have the paying member role in the UiO Gaming Discord server.", tryAgain: "Try again logging in again", + notPayingMember: "not a paying member", }, no: { submittedAnswers: "Dine svar", @@ -40,5 +41,6 @@ export const translations = { accessDenied: "Tilgang nektet", accessDeniedMessage: 'Du har ikke rollen "Betalende medlem" i UiO Gaming sin Discord-server.', tryAgain: "Logg på igjen", + notPayingMember: "ikke betalende medlem", }, }; diff --git a/src/app/types.ts b/src/app/types.ts index 31fcf13..e071f3d 100644 --- a/src/app/types.ts +++ b/src/app/types.ts @@ -6,14 +6,15 @@ export interface Match { correct: boolean; } -export interface NorwegianPersonItem { - article_type_id: number; - rank: number; - taxonomy_title?: string; - subject_title?: string; - first_sentences?: string; - snippet?: string; - title?: string; - headword?: string; - article_url_json?: string; +export interface DiscordGuildMember { + user: { + id: string; + username: string; + global_name: string | null; + discriminator: string; + avatar: string | null; + }; + nick: string | null; + roles: string[]; + joined_at: string; }