feat(admin): expose read-only Discord suggestions API
CI / validate (push) Successful in 6m28s
Release / release (push) Successful in 10m33s

This commit is contained in:
dmg
2026-09-10 07:50:01 -04:00
parent df17c021c6
commit 1a01c0ed64
11 changed files with 524 additions and 0 deletions
@@ -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();
});
+14
View File
@@ -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" });
});
+157
View File
@@ -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 };
},
};
}