add discord member match data provider

This commit is contained in:
leander committed 2025-06-22 02:22:54 +02:00
1 parent 1def9d7cd5
commit b277745531
4 files changed
+141 -51

No files matched your search

+126 -39
View File
@@ -1,54 +1,141 @@
import type { Match, NorwegianPersonItem } from "@/types";
import type { DiscordGuildMember, Match } from "@/types";
export async function findNorwegianPerson(input: string): Promise<Match> {
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<DiscordGuildMember[]> {
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<Match> {
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,
+2 -2
View File
@@ -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);
}
+2
View File
@@ -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",
},
};
+11 -10
View File
@@ -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;
}