Implement command cooldown management and enhance command execution flow

- Introduced `applyCommandCooldown` function to manage per-command cooldowns after execution, improving command rate limiting.
- Updated command execution flow to include cooldown application, ensuring users are informed of cooldown status.
- Enhanced error handling in interaction replies, allowing for more graceful error management and user feedback.
- Improved the handling of interaction responses across various commands, ensuring consistent user experience.
- Added permission checks for new commands, ensuring only authorized users can execute specific actions.
This commit is contained in:
TheOnlyMace
2026-07-25 15:37:56 +02:00
parent 57cdf847f5
commit 8513848440
42 changed files with 856 additions and 160 deletions

View File

@@ -2,10 +2,18 @@ import { redirect } from 'next/navigation';
import { NextResponse } from 'next/server';
import { ZodError } from 'zod';
import { hasManageGuildPermission } from '@nexumi/shared';
import { fetchDiscordUserGuildsCached } from './discord-oauth';
import { fetchDiscordUserGuildsCached, refreshDiscordToken } from './discord-oauth';
import { BadRequestError, UpstreamError } from './api-errors';
import { hasOwnerGuildBypass } from './owner-auth';
import { prisma } from './prisma';
import { getSession, type SessionPayload } from './session';
import {
getSession,
SessionRequiredError,
updateSession,
type SessionPayload
} from './session';
export { BadRequestError, UpstreamError } from './api-errors';
export class UnauthorizedError extends Error {
constructor(message = 'Authentication required') {
@@ -21,6 +29,37 @@ export class ForbiddenError extends Error {
}
}
const TOKEN_REFRESH_SKEW_MS = 60_000;
/**
* Refreshes the Discord access token when expired (or about to expire).
* Throws {@link UnauthorizedError} when refresh is impossible.
*/
export async function ensureFreshSession(session: SessionPayload): Promise<SessionPayload> {
const expiresAt = session.tokenExpiresAt ?? 0;
if (expiresAt > Date.now() + TOKEN_REFRESH_SKEW_MS) {
return session;
}
if (!session.refreshToken) {
throw new UnauthorizedError('Session expired please log in again');
}
try {
const token = await refreshDiscordToken(session.refreshToken);
const next: SessionPayload = {
...session,
accessToken: token.access_token,
refreshToken: token.refresh_token || session.refreshToken,
tokenExpiresAt: Date.now() + token.expires_in * 1000
};
await updateSession(next);
return next;
} catch {
throw new UnauthorizedError('Session expired please log in again');
}
}
/**
* Use in API routes. Throws {@link UnauthorizedError} when no session exists.
*/
@@ -29,7 +68,7 @@ export async function requireAuth(): Promise<SessionPayload> {
if (!session) {
throw new UnauthorizedError();
}
return session;
return ensureFreshSession(session);
}
/**
@@ -41,14 +80,18 @@ export async function requireAuthOrRedirect(): Promise<SessionPayload> {
if (!session) {
redirect('/login');
}
return session;
try {
return await ensureFreshSession(session);
} catch {
redirect('/login');
}
}
/**
* Ensures the current session user has Manage Guild permission on the given
* guild (via Discord OAuth guild list) and that the bot is actually installed
* there (tracked via the `Guild` table). Owner-panel users bypass Discord
* membership checks. Use in API routes.
* there (tracked via the `Guild` table). Owner-panel users with SUPPORT+
* bypass Discord membership checks. Use in API routes.
*/
export async function requireGuildAccess(guildId: string): Promise<SessionPayload> {
const session = await requireAuth();
@@ -61,12 +104,19 @@ export async function requireGuildAccess(guildId: string): Promise<SessionPayloa
return session;
}
const guilds = await fetchDiscordUserGuildsCached(session.accessToken);
const guild = guilds.find((entry) => entry.id === guildId);
if (!guild || !hasManageGuildPermission(guild.permissions)) {
throw new ForbiddenError('Missing Manage Guild permission for this server');
try {
const guilds = await fetchDiscordUserGuildsCached(session.accessToken);
const guild = guilds.find((entry) => entry.id === guildId);
if (!guild || !hasManageGuildPermission(guild.permissions)) {
throw new ForbiddenError('Missing Manage Guild permission for this server');
}
return session;
} catch (error) {
if (error instanceof ForbiddenError) {
throw error;
}
throw new UnauthorizedError('Discord session expired please log in again');
}
return session;
}
/**
@@ -84,21 +134,31 @@ export async function requireGuildAccessOrRedirect(guildId: string): Promise<Ses
return session;
}
const guilds = await fetchDiscordUserGuildsCached(session.accessToken);
const guild = guilds.find((entry) => entry.id === guildId);
if (!guild || !hasManageGuildPermission(guild.permissions)) {
redirect('/dashboard');
try {
const guilds = await fetchDiscordUserGuildsCached(session.accessToken);
const guild = guilds.find((entry) => entry.id === guildId);
if (!guild || !hasManageGuildPermission(guild.permissions)) {
redirect('/dashboard');
}
return session;
} catch {
redirect('/login');
}
return session;
}
export function toApiErrorResponse(error: unknown): NextResponse {
if (error instanceof UnauthorizedError) {
if (error instanceof UnauthorizedError || error instanceof SessionRequiredError) {
return NextResponse.json({ error: error.message }, { status: 401 });
}
if (error instanceof ForbiddenError) {
return NextResponse.json({ error: error.message }, { status: 403 });
}
if (error instanceof BadRequestError) {
return NextResponse.json({ error: error.message }, { status: 400 });
}
if (error instanceof UpstreamError) {
return NextResponse.json({ error: error.message }, { status: error.status });
}
if (error instanceof ZodError) {
return NextResponse.json(
{ error: 'Validation failed', issues: error.issues },