feat(admin): expose read-only Discord suggestions API
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
import { suggestionQuery, suggestionsApi, suggestionsReadOnly } from "@/lib/discord/suggestions-api";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export function GET(request: Request, context: { params: Promise<{ id: string }> }) {
|
||||
return suggestionsApi(request, async (client) => client.messages((await context.params).id, suggestionQuery(request)));
|
||||
}
|
||||
|
||||
export const POST = suggestionsReadOnly;
|
||||
export const PUT = suggestionsReadOnly;
|
||||
export const PATCH = suggestionsReadOnly;
|
||||
export const DELETE = suggestionsReadOnly;
|
||||
export const OPTIONS = suggestionsReadOnly;
|
||||
@@ -0,0 +1,14 @@
|
||||
import { suggestionsApi, suggestionsReadOnly } from "@/lib/discord/suggestions-api";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export function GET(request: Request, context: { params: Promise<{ id: string }> }) {
|
||||
return suggestionsApi(request, async (client) => client.detail((await context.params).id));
|
||||
}
|
||||
|
||||
export const POST = suggestionsReadOnly;
|
||||
export const PUT = suggestionsReadOnly;
|
||||
export const PATCH = suggestionsReadOnly;
|
||||
export const DELETE = suggestionsReadOnly;
|
||||
export const OPTIONS = suggestionsReadOnly;
|
||||
@@ -0,0 +1,71 @@
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
const auth = vi.hoisted(() => ({ session: vi.fn() }));
|
||||
vi.mock("next-auth", () => ({ getServerSession: auth.session }));
|
||||
vi.mock("@/lib/auth/admin-auth", () => ({ adminAuthOptions: {}, requiredAdminRole: "ops" }));
|
||||
import { GET, POST } from "./route";
|
||||
import { GET as detail } from "./[id]/route";
|
||||
import { GET as messages } from "./[id]/messages/route";
|
||||
const context = { params: Promise.resolve({ id: "100000000000000009" }) };
|
||||
|
||||
const request = () => new Request("https://portal.example/api/suggestions");
|
||||
afterEach(() => { vi.resetAllMocks(); vi.unstubAllGlobals(); vi.unstubAllEnvs(); });
|
||||
it("serves suggestions to the existing admin session using runtime env configuration", async () => {
|
||||
auth.session.mockResolvedValue({ user: { roles: ["ops"] } });
|
||||
vi.stubEnv("DISCORD_BOT_TOKEN", "test-token");
|
||||
vi.stubEnv("DISCORD_GUILD_ID", "100000000000000001");
|
||||
vi.stubEnv("DISCORD_SUGGESTIONS_FORUM_ID", "100000000000000002");
|
||||
const fetcher = vi.fn().mockResolvedValueOnce(Response.json({ id: "100000000000000002", guild_id: "100000000000000001", type: 15, available_tags: [] })).mockResolvedValueOnce(Response.json({ threads: [] }));
|
||||
vi.stubGlobal("fetch", fetcher);
|
||||
const response = await GET(request());
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({ items: [], nextCursor: null });
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
expect(fetcher).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
it.each([detail, messages])("independently protects detail and message routes", async (handler) => {
|
||||
const fetcher = vi.fn();
|
||||
vi.stubGlobal("fetch", fetcher);
|
||||
auth.session.mockResolvedValue(null);
|
||||
expect((await handler(request(), context)).status).toBe(401);
|
||||
auth.session.mockResolvedValue({ user: { roles: ["player"] } });
|
||||
expect((await handler(request(), context)).status).toBe(403);
|
||||
expect(fetcher).not.toHaveBeenCalled();
|
||||
});
|
||||
it.each(["?limit=0", "?limit=101", "?limit=1.2", "?limit=", "?limit=1&limit=2", "?channel=100000000000000099", "?status=all", "?cursor=../secret"])('rejects invalid query %s without contacting Discord', async (query) => {
|
||||
auth.session.mockResolvedValue({ user: { roles: ["ops"] } });
|
||||
const fetcher = vi.fn();
|
||||
vi.stubGlobal("fetch", fetcher);
|
||||
const response = await GET(new Request(`https://portal.example/api/suggestions${query}`));
|
||||
expect(response.status).toBe(400);
|
||||
expect(fetcher).not.toHaveBeenCalled();
|
||||
});
|
||||
it("returns a read-only problem for writes", async () => {
|
||||
auth.session.mockResolvedValue({ user: { roles: ["ops"] } });
|
||||
const response = await POST(request());
|
||||
expect(response.status).toBe(405);
|
||||
expect(response.headers.get("allow")).toBe("GET, HEAD");
|
||||
});
|
||||
it("reports missing configuration without exposing environment values", async () => {
|
||||
auth.session.mockResolvedValue({ user: { roles: ["ops"] } });
|
||||
vi.stubEnv("DISCORD_SUGGESTIONS_FORUM_ID", "");
|
||||
const response = await GET(request());
|
||||
expect(response.status).toBe(503);
|
||||
expect(await response.json()).toMatchObject({ type: "urn:error:suggestions-not-configured", status: 503, instance: "/api/suggestions" });
|
||||
});
|
||||
it("returns sanitized problems for unexpected failures", async () => {
|
||||
auth.session.mockRejectedValue(new Error("private session details"));
|
||||
const response = await GET(request());
|
||||
expect(response.status).toBe(503);
|
||||
expect(await response.text()).not.toContain("private session details");
|
||||
});
|
||||
it("rejects a signed-in user without the required admin role", async () => {
|
||||
auth.session.mockResolvedValue({ user: { roles: ["player"] } });
|
||||
expect((await GET(request())).status).toBe(403);
|
||||
});
|
||||
it("rejects unauthenticated readers with a JSON problem instead of a redirect", async () => {
|
||||
auth.session.mockResolvedValue(null);
|
||||
const response = await GET(request());
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.headers.get("content-type")).toBe("application/problem+json");
|
||||
expect(response.headers.get("location")).toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { suggestionQuery, suggestionsApi, suggestionsReadOnly } from "@/lib/discord/suggestions-api";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export function GET(request: Request) {
|
||||
return suggestionsApi(request, (client) => client.list(suggestionQuery(request, true)));
|
||||
}
|
||||
|
||||
export const POST = suggestionsReadOnly;
|
||||
export const PUT = suggestionsReadOnly;
|
||||
export const PATCH = suggestionsReadOnly;
|
||||
export const DELETE = suggestionsReadOnly;
|
||||
export const OPTIONS = suggestionsReadOnly;
|
||||
@@ -0,0 +1,24 @@
|
||||
export type SuggestionTag = { id: string; name: string };
|
||||
export type Suggestion = {
|
||||
id: string;
|
||||
title: string;
|
||||
authorId: string;
|
||||
createdAt: string;
|
||||
archived: boolean;
|
||||
locked: boolean;
|
||||
tags: SuggestionTag[];
|
||||
messageCount: number;
|
||||
discordUrl: string;
|
||||
};
|
||||
export type SuggestionMessage = {
|
||||
id: string;
|
||||
author: { id: string; name: string };
|
||||
content: string;
|
||||
createdAt: string;
|
||||
editedAt: string | null;
|
||||
reactions: { emoji: string; count: number }[];
|
||||
discordUrl: string;
|
||||
};
|
||||
export type SuggestionPage = { items: Suggestion[]; nextCursor: string | null };
|
||||
export type MessagePage = { items: SuggestionMessage[]; nextCursor: string | null };
|
||||
export type SuggestionDetail = Suggestion & { originalPost: SuggestionMessage | null };
|
||||
@@ -0,0 +1,50 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import { problemDetails } from "@minecraft-account-manager/contracts";
|
||||
import { adminAuthOptions, requiredAdminRole } from "@/lib/auth/admin-auth";
|
||||
import { problemInstance, problemResponse } from "@/lib/problem-response";
|
||||
import { createSuggestionsClient, SuggestionsError } from "./suggestions";
|
||||
|
||||
type Client = ReturnType<typeof createSuggestionsClient>;
|
||||
let runtime: { token: string; guildId: string; forumId: string; client: Client } | undefined;
|
||||
function getClient() {
|
||||
const token = process.env.DISCORD_BOT_TOKEN?.trim() ?? "";
|
||||
const guildId = process.env.DISCORD_GUILD_ID?.trim() ?? "";
|
||||
const forumId = process.env.DISCORD_SUGGESTIONS_FORUM_ID?.trim() ?? "";
|
||||
if (!runtime || runtime.token !== token || runtime.guildId !== guildId || runtime.forumId !== forumId) {
|
||||
runtime = { token, guildId, forumId, client: createSuggestionsClient({ token, guildId, forumId }) };
|
||||
}
|
||||
return runtime.client;
|
||||
}
|
||||
|
||||
export async function suggestionsApi(request: Request, operation: (client: Client) => Promise<unknown>) {
|
||||
try {
|
||||
const session = await getServerSession(adminAuthOptions);
|
||||
if (!session) throw new SuggestionsError(401, "unauthorized", "Sign in as an administrator.");
|
||||
const roles = (session.user as { roles?: unknown } | undefined)?.roles;
|
||||
if (!Array.isArray(roles) || !roles.includes(requiredAdminRole)) throw new SuggestionsError(403, "forbidden", "This API is restricted to administrators.");
|
||||
return Response.json(await operation(getClient()), { headers: { "cache-control": "no-store" } });
|
||||
} catch (error) {
|
||||
const safe = error instanceof SuggestionsError ? error : new SuggestionsError(503, "discord-unavailable", "Discord suggestions are unavailable.");
|
||||
const titles: Record<number, string> = { 400: "Invalid request", 401: "Authentication required", 403: "Administrator role required", 404: "Suggestion not found", 405: "Method not allowed", 503: "Suggestions unavailable" };
|
||||
const response = problemResponse(problemDetails(`urn:error:${safe.code}`, titles[safe.status] ?? "Suggestions unavailable", safe.status, safe.message, problemInstance(request)));
|
||||
if (safe.retryAfter) response.headers.set("retry-after", String(safe.retryAfter));
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
export function suggestionQuery(request: Request, list = false) {
|
||||
const params = new URL(request.url).searchParams;
|
||||
const allowed = list ? ["limit", "cursor", "status"] : ["limit", "cursor"];
|
||||
for (const key of params.keys()) {
|
||||
if (!allowed.includes(key) || params.getAll(key).length !== 1 || !params.get(key)) throw new SuggestionsError(400, "invalid-request", "Unsupported or repeated query parameter.");
|
||||
}
|
||||
const rawLimit = params.get("limit");
|
||||
if (rawLimit !== null && !/^\d{1,3}$/.test(rawLimit)) throw new SuggestionsError(400, "invalid-request", "Limit must be between 1 and 100.");
|
||||
return { limit: rawLimit === null ? undefined : Number(rawLimit), cursor: params.get("cursor") ?? undefined, ...(list ? { status: params.get("status") ?? undefined } : {}) };
|
||||
}
|
||||
|
||||
export function suggestionsReadOnly(request: Request) {
|
||||
return suggestionsApi(request, async () => {
|
||||
throw new SuggestionsError(405, "method-not-allowed", "Suggestions are read-only. Use GET.");
|
||||
}).then((response) => { if (response.status === 405) response.headers.set("allow", "GET, HEAD"); return response; });
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { createSuggestionsClient } from "./suggestions";
|
||||
afterEach(() => vi.useRealTimers());
|
||||
const guildId = "100000000000000001";
|
||||
const forumId = "100000000000000002";
|
||||
const threadId = "100000000000000009";
|
||||
const thread = { id: threadId, guild_id: guildId, parent_id: forumId, type: 11, name: "More railway stations", owner_id: "100000000000000003", applied_tags: ["100000000000000004"], message_count: 3, thread_metadata: { archived: false, locked: false, archive_timestamp: "2026-01-01T00:00:00.000Z" } };
|
||||
function setup(responses: Record<string, unknown>) {
|
||||
const fetcher = vi.fn<typeof fetch>(async (input) => {
|
||||
const path = String(input).replace("https://discord.com/api/v10", "");
|
||||
if (!(path in responses)) throw new Error(`Unexpected path: ${path}`);
|
||||
const value = responses[path];
|
||||
return value instanceof Response ? value : Response.json(value);
|
||||
});
|
||||
const client = createSuggestionsClient({ token: "test-token", guildId, forumId, fetch: fetcher });
|
||||
return { client, fetcher };
|
||||
}
|
||||
it("reads archived forum pages using Discord's archive timestamp cursor", async () => {
|
||||
const cursor = "2026-01-01T00:00:00Z";
|
||||
const { client } = setup({
|
||||
[`/channels/${forumId}`]: forum,
|
||||
[`/channels/${forumId}/threads/archived/public?limit=1`]: { threads: [{ ...thread, thread_metadata: { ...thread.thread_metadata, archived: true } }], has_more: true },
|
||||
[`/channels/${forumId}/threads/archived/public?limit=1&before=${encodeURIComponent(cursor)}`]: { threads: [], has_more: false },
|
||||
});
|
||||
expect(await client.list({ status: "archived", limit: 1 })).toMatchObject({ items: [{ archived: true }], nextCursor: cursor });
|
||||
expect(await client.list({ status: "archived", limit: 1, cursor })).toEqual({ items: [], nextCursor: null });
|
||||
});
|
||||
|
||||
const message = { id: threadId, content: "Please add stations", timestamp: "2026-01-01T00:00:00.000Z", edited_timestamp: null, author: { id: "100000000000000003", username: "builder", global_name: "Builder" }, reactions: [{ emoji: { name: "👍" }, count: 4 }] };
|
||||
it("returns the starter post and paginates discussion with authors and reactions", async () => {
|
||||
const { client } = setup({
|
||||
[`/channels/${forumId}`]: forum,
|
||||
[`/channels/${threadId}`]: thread,
|
||||
[`/channels/${threadId}/messages/${threadId}`]: message,
|
||||
[`/channels/${threadId}/messages?limit=1`]: [{ ...message, id: "100000000000000020" }],
|
||||
[`/channels/${threadId}/messages?limit=1&before=100000000000000020`]: [],
|
||||
});
|
||||
expect(await client.detail(threadId)).toMatchObject({ title: thread.name, originalPost: { content: message.content, author: { name: "Builder" }, reactions: [{ emoji: "👍", count: 4 }], createdAt: "2026-01-01T00:00:00Z" } });
|
||||
expect(await client.messages(threadId, { limit: 1 })).toMatchObject({ items: [{ id: "100000000000000020" }], nextCursor: "100000000000000020" });
|
||||
expect(await client.messages(threadId, { limit: 1, cursor: "100000000000000020" })).toEqual({ items: [], nextCursor: null });
|
||||
});
|
||||
|
||||
it.each(["detail", "messages"] as const)("blocks %s of a thread outside the forum before reading messages", async (method) => {
|
||||
const { client, fetcher } = setup({ [`/channels/${forumId}`]: forum, [`/channels/${threadId}`]: { ...thread, parent_id: "100000000000000099" } });
|
||||
await expect(client[method](threadId)).rejects.toMatchObject({ status: 404 });
|
||||
expect(fetcher).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
it("keeps a deleted starter post distinguishable from an empty message", async () => {
|
||||
const { client } = setup({ [`/channels/${forumId}`]: forum, [`/channels/${threadId}`]: thread, [`/channels/${threadId}/messages/${threadId}`]: new Response(null, { status: 404 }) });
|
||||
expect(await client.detail(threadId)).toMatchObject({ originalPost: null });
|
||||
});
|
||||
it("backs off on Discord rate limits without exposing Discord error bodies", async () => {
|
||||
const { client, fetcher } = setup({ [`/channels/${forumId}`]: Response.json({ retry_after: 2.5, message: "secret upstream details" }, { status: 429 }) });
|
||||
await expect(client.list()).rejects.toMatchObject({ status: 503, code: "discord-rate-limited", retryAfter: 3 });
|
||||
await expect(client.list()).rejects.toMatchObject({ status: 503, code: "discord-rate-limited" });
|
||||
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it.each([401, 403, 404, 500])("translates Discord %s into a safe service error", async (status) => {
|
||||
const { client } = setup({ [`/channels/${forumId}`]: new Response("sensitive error", { status }) });
|
||||
await expect(client.list()).rejects.toMatchObject({ status: 503 });
|
||||
await expect(client.list()).rejects.not.toThrow("sensitive error");
|
||||
});
|
||||
it("sanitizes network failures", async () => {
|
||||
const client = createSuggestionsClient({ token: "test", guildId, forumId, fetch: vi.fn().mockRejectedValue(new Error("token leaked by upstream")) });
|
||||
await expect(client.list()).rejects.toMatchObject({ status: 503, message: "Discord suggestions are unavailable." });
|
||||
});
|
||||
|
||||
it("coalesces concurrent reads and refreshes expired cache entries", async () => {
|
||||
vi.useFakeTimers();
|
||||
const { client, fetcher } = setup({ [`/channels/${forumId}`]: forum, [`/guilds/${guildId}/threads/active`]: { threads: [thread] } });
|
||||
await Promise.all([client.list(), client.list()]);
|
||||
expect(fetcher).toHaveBeenCalledTimes(2);
|
||||
vi.advanceTimersByTime(30_001);
|
||||
await client.list();
|
||||
expect(fetcher).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
it.each([{ status: "all" }, { limit: 0 }, { limit: 101 }, { limit: 1.5 }, { cursor: "../secret" }, { status: "archived", cursor: "bad-date" }])("validates list query %j before network access", async (query) => {
|
||||
const { client, fetcher } = setup({});
|
||||
await expect(client.list(query)).rejects.toMatchObject({ status: 400 });
|
||||
expect(fetcher).not.toHaveBeenCalled();
|
||||
});
|
||||
it.each(["detail", "messages"] as const)("validates %s IDs before network access", async (method) => {
|
||||
const { client, fetcher } = setup({});
|
||||
await expect(client[method]("../secret")).rejects.toMatchObject({ status: 400 });
|
||||
expect(fetcher).not.toHaveBeenCalled();
|
||||
});
|
||||
it.each([{ ...forumPlaceholder(), type: 0 }, { ...forumPlaceholder(), guild_id: "100000000000000099" }])("refuses a non-forum or wrong-guild configured channel", async (value) => {
|
||||
const { client, fetcher } = setup({ [`/channels/${forumId}`]: value });
|
||||
await expect(client.list()).rejects.toMatchObject({ status: 503 });
|
||||
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it("bounds concurrent upstream requests instead of flooding Discord", async () => {
|
||||
const releases: (() => void)[] = [];
|
||||
const fetcher = vi.fn<typeof fetch>(async (input) => {
|
||||
if (String(input).endsWith(`/channels/${forumId}`)) return Response.json(forum);
|
||||
if (String(input).endsWith("/threads/active")) return Response.json({ threads: [] });
|
||||
return new Promise<Response>((resolve) => { releases.push(() => resolve(new Response(null, { status: 404 }))); });
|
||||
});
|
||||
const client = createSuggestionsClient({ token: "test", guildId, forumId, fetch: fetcher });
|
||||
await client.list();
|
||||
const results = Array.from({ length: 9 }, (_, index) => client.detail(`1000000000000001${index.toString().padStart(2, "0")}`).catch((error: unknown) => error));
|
||||
await vi.waitFor(() => expect(fetcher.mock.calls.length).toBeGreaterThanOrEqual(10));
|
||||
const count = releases.length;
|
||||
releases.forEach((release) => release());
|
||||
const errors = await Promise.all(results);
|
||||
expect(count).toBe(8);
|
||||
expect(errors).toContainEqual(expect.objectContaining({ code: "discord-busy", status: 503 }));
|
||||
});
|
||||
function forumPlaceholder() { return { id: forumId, guild_id: guildId, type: 15 }; }
|
||||
|
||||
const forum = { id: forumId, guild_id: guildId, type: 15, available_tags: [{ id: "100000000000000004", name: "World" }] };
|
||||
|
||||
it("lists only configured-forum active suggestions, resolves tags and paginates newest first", async () => {
|
||||
const { client, fetcher } = setup({
|
||||
[`/channels/${forumId}`]: forum,
|
||||
[`/guilds/${guildId}/threads/active`]: { threads: [
|
||||
{ ...thread, id: "100000000000000008" }, thread,
|
||||
{ ...thread, id: "100000000000000010", parent_id: "100000000000000099" },
|
||||
] },
|
||||
});
|
||||
const first = await client.list({ limit: 1 });
|
||||
expect(first).toMatchObject({ items: [{ id: threadId, title: "More railway stations", tags: [{ name: "World" }], discordUrl: `https://discord.com/channels/${guildId}/${threadId}` }], nextCursor: threadId });
|
||||
const second = await client.list({ limit: 1, cursor: threadId });
|
||||
expect(second).toMatchObject({ items: [{ id: "100000000000000008" }], nextCursor: null });
|
||||
expect(fetcher).toHaveBeenCalledTimes(2);
|
||||
expect(fetcher.mock.calls[0]?.[1]).toMatchObject({ headers: { Authorization: "Bot test-token" }, cache: "no-store", redirect: "error" });
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
import type { MessagePage, SuggestionDetail, SuggestionMessage, Suggestion, SuggestionPage, SuggestionTag } from "./suggestion-types";
|
||||
|
||||
type Forum = { id: string; guild_id: string; type: number; available_tags: SuggestionTag[] };
|
||||
type Thread = {
|
||||
id: string; guild_id?: string; parent_id: string; type: number; name: string; owner_id: string;
|
||||
applied_tags?: string[]; message_count?: number;
|
||||
thread_metadata: { archived: boolean; locked: boolean; archive_timestamp: string };
|
||||
};
|
||||
type Message = {
|
||||
id: string; content: string; timestamp: string; edited_timestamp?: string | null;
|
||||
author: { id: string; username: string; global_name?: string | null };
|
||||
reactions?: { emoji: { id?: string | null; name: string | null }; count: number }[];
|
||||
};
|
||||
export type ListQuery = { status?: string; cursor?: string; limit?: number };
|
||||
|
||||
export class SuggestionsError extends Error {
|
||||
constructor(public readonly status: number, public readonly code: string, message: string, public readonly retryAfter?: number) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
const snowflake = /^[1-9]\d{16,19}$/;
|
||||
function checkId(id: string) {
|
||||
if (!snowflake.test(id)) throw new SuggestionsError(400, "invalid-request", "A valid Discord ID is required.");
|
||||
}
|
||||
function limitValue(limit = 25) {
|
||||
if (!Number.isInteger(limit) || limit < 1 || limit > 100) throw new SuggestionsError(400, "invalid-request", "Limit must be between 1 and 100.");
|
||||
return limit;
|
||||
}
|
||||
function timestamp(value: string | number) {
|
||||
return new Date(value).toISOString().replace(/\.\d{3}Z$/, "Z");
|
||||
}
|
||||
|
||||
export function createSuggestionsClient(options: { token: string; guildId: string; forumId: string; fetch?: typeof fetch }) {
|
||||
const { token, guildId, forumId } = options;
|
||||
const fetcher = options.fetch ?? fetch;
|
||||
const cache = new Map<string, { expires: number; value: unknown }>();
|
||||
let retryAt = 0;
|
||||
let activeRequests = 0;
|
||||
const pending = new Map<string, Promise<unknown>>();
|
||||
async function get<T>(path: string): Promise<T> {
|
||||
const existing = pending.get(path);
|
||||
if (existing) return existing as Promise<T>;
|
||||
const request = load<T>(path);
|
||||
pending.set(path, request);
|
||||
try { return await request; } finally { pending.delete(path); }
|
||||
}
|
||||
async function load<T>(path: string): Promise<T> {
|
||||
const cached = cache.get(path);
|
||||
if (cached && cached.expires > Date.now()) return cached.value as T;
|
||||
if (Date.now() < retryAt) throw new SuggestionsError(503, "discord-rate-limited", "Discord is rate limited. Try again shortly.", Math.ceil((retryAt - Date.now()) / 1000));
|
||||
if (activeRequests >= 8) throw new SuggestionsError(503, "discord-busy", "Suggestions are busy. Try again shortly.", 1);
|
||||
activeRequests += 1;
|
||||
try {
|
||||
const response = await fetcher(`https://discord.com/api/v10${path}`, {
|
||||
headers: { Authorization: `Bot ${token}` }, cache: "no-store", redirect: "error", signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
if (response.status === 429) {
|
||||
const body = await response.json().catch(() => null) as { retry_after?: number } | null;
|
||||
const raw = Number(body?.retry_after ?? response.headers.get("retry-after") ?? 1);
|
||||
const seconds = Number.isFinite(raw) && raw > 0 ? Math.ceil(raw) : 1;
|
||||
retryAt = Date.now() + seconds * 1000;
|
||||
throw new SuggestionsError(503, "discord-rate-limited", "Discord is rate limited. Try again shortly.", seconds);
|
||||
}
|
||||
if (response.status === 404) throw new SuggestionsError(404, "suggestion-not-found", "The suggestion or message was not found.");
|
||||
if (!response.ok) throw new SuggestionsError(503, "discord-unavailable", "Discord suggestions are unavailable.");
|
||||
const value: unknown = await response.json();
|
||||
if (cache.size >= 200) cache.delete(cache.keys().next().value!);
|
||||
cache.set(path, { value, expires: Date.now() + 30_000 });
|
||||
return value as T;
|
||||
} catch (error) {
|
||||
if (error instanceof SuggestionsError) throw error;
|
||||
throw new SuggestionsError(503, "discord-unavailable", "Discord suggestions are unavailable.");
|
||||
} finally {
|
||||
activeRequests -= 1;
|
||||
}
|
||||
}
|
||||
async function getForum() {
|
||||
if (!token || !snowflake.test(guildId) || !snowflake.test(forumId)) throw new SuggestionsError(503, "suggestions-not-configured", "Discord suggestions are not configured.");
|
||||
const forum = await get<Forum>(`/channels/${forumId}`).catch((error: unknown) => {
|
||||
if (error instanceof SuggestionsError && error.status === 404) throw new SuggestionsError(503, "suggestions-not-configured", "The configured suggestions forum is unavailable.");
|
||||
throw error;
|
||||
});
|
||||
if (forum.id !== forumId || forum.guild_id !== guildId || forum.type !== 15) throw new SuggestionsError(503, "suggestions-not-configured", "The configured channel must be a forum in the configured guild.");
|
||||
return forum;
|
||||
}
|
||||
function belongs(thread: Thread) {
|
||||
return thread.parent_id === forumId && (!thread.guild_id || thread.guild_id === guildId) && thread.type === 11;
|
||||
}
|
||||
function summary(thread: Thread, forum: Forum): Suggestion {
|
||||
return {
|
||||
id: thread.id, title: thread.name, authorId: thread.owner_id,
|
||||
createdAt: timestamp(Number((BigInt(thread.id) >> 22n) + 1420070400000n)),
|
||||
archived: thread.thread_metadata.archived, locked: thread.thread_metadata.locked,
|
||||
tags: (forum.available_tags ?? []).filter((tag) => thread.applied_tags?.includes(tag.id)).map(({ id, name }) => ({ id, name })),
|
||||
messageCount: thread.message_count ?? 0, discordUrl: `https://discord.com/channels/${guildId}/${thread.id}`,
|
||||
};
|
||||
}
|
||||
async function getThread(id: string) {
|
||||
checkId(id);
|
||||
const forum = await getForum();
|
||||
const thread = await get<Thread>(`/channels/${id}`);
|
||||
if (thread.id !== id || !belongs(thread)) throw new SuggestionsError(404, "suggestion-not-found", "The suggestion was not found in the configured forum.");
|
||||
return { thread, forum };
|
||||
}
|
||||
function messageView(message: Message, threadId: string): SuggestionMessage {
|
||||
return {
|
||||
id: message.id, content: message.content,
|
||||
author: { id: message.author.id, name: message.author.global_name || message.author.username },
|
||||
createdAt: timestamp(message.timestamp), editedAt: message.edited_timestamp ? timestamp(message.edited_timestamp) : null,
|
||||
reactions: (message.reactions ?? []).map(({ emoji, count }) => ({ emoji: emoji.id ? `:${emoji.name ?? "emoji"}:` : emoji.name ?? "emoji", count })),
|
||||
discordUrl: `https://discord.com/channels/${guildId}/${threadId}/${message.id}`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
async detail(id: string): Promise<SuggestionDetail> {
|
||||
const { thread, forum } = await getThread(id);
|
||||
const message = await get<Message>(`/channels/${id}/messages/${id}`).catch((error: unknown) => {
|
||||
if (error instanceof SuggestionsError && error.status === 404) return null;
|
||||
throw error;
|
||||
});
|
||||
return { ...summary(thread, forum), originalPost: message ? messageView(message, id) : null };
|
||||
},
|
||||
async messages(id: string, query: { cursor?: string; limit?: number } = {}): Promise<MessagePage> {
|
||||
const limit = limitValue(query.limit);
|
||||
if (query.cursor) checkId(query.cursor);
|
||||
await getThread(id);
|
||||
const before = query.cursor ? `&before=${query.cursor}` : "";
|
||||
const messages = await get<Message[]>(`/channels/${id}/messages?limit=${limit}${before}`);
|
||||
return { items: messages.map((message) => messageView(message, id)), nextCursor: messages.length === limit ? messages.at(-1)!.id : null };
|
||||
},
|
||||
async list(query: ListQuery = {}): Promise<SuggestionPage> {
|
||||
const limit = limitValue(query.limit);
|
||||
const status = query.status ?? "active";
|
||||
if (status !== "active" && status !== "archived") throw new SuggestionsError(400, "invalid-request", "Status must be active or archived.");
|
||||
if (query.cursor) {
|
||||
if (status === "active") checkId(query.cursor);
|
||||
else if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,6})?Z$/.test(query.cursor) || !Number.isFinite(Date.parse(query.cursor))) {
|
||||
throw new SuggestionsError(400, "invalid-request", "The archive cursor must be a UTC timestamp.");
|
||||
}
|
||||
}
|
||||
const forum = await getForum();
|
||||
if (status === "archived") {
|
||||
const before = query.cursor ? `&before=${encodeURIComponent(query.cursor)}` : "";
|
||||
const data = await get<{ threads: Thread[]; has_more: boolean }>(`/channels/${forumId}/threads/archived/public?limit=${limit}${before}`);
|
||||
return {
|
||||
items: data.threads.filter(belongs).map((thread) => summary(thread, forum)),
|
||||
nextCursor: data.has_more && data.threads.length ? data.threads.at(-1)!.thread_metadata.archive_timestamp.replace(/\.000Z$/, "Z") : null,
|
||||
};
|
||||
}
|
||||
const data = await get<{ threads: Thread[] }>(`/guilds/${guildId}/threads/active`);
|
||||
const threads = data.threads.filter(belongs).sort((a, b) => BigInt(a.id) > BigInt(b.id) ? -1 : 1)
|
||||
.filter((thread) => !query.cursor || BigInt(thread.id) < BigInt(query.cursor));
|
||||
const page = threads.slice(0, limit);
|
||||
return { items: page.map((thread) => summary(thread, forum)), nextCursor: threads.length > limit ? page.at(-1)!.id : null };
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user