deploy #2

Merged
smueller merged 24 commits from deploy into main 2026-07-29 07:44:03 +00:00
156 changed files with 8698 additions and 1177 deletions

View File

@@ -0,0 +1,12 @@
-- CreateTable
CREATE TABLE "BotAboutConfig" (
"id" TEXT NOT NULL DEFAULT 'singleton',
"enabled" BOOLEAN NOT NULL DEFAULT true,
"messageType" TEXT NOT NULL DEFAULT 'EMBED',
"content" TEXT,
"embed" JSONB,
"components" JSONB,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "BotAboutConfig_pkey" PRIMARY KEY ("id")
);

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "TicketConfig" ADD COLUMN "panelChannelId" TEXT;
ALTER TABLE "TicketConfig" ADD COLUMN "panelMessageId" TEXT;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Tag" ADD COLUMN "cooldownSeconds" INTEGER NOT NULL DEFAULT 15;

View File

@@ -0,0 +1,16 @@
-- AlterTable
ALTER TABLE "Warning" ADD COLUMN "warningNumber" INTEGER;
-- Backfill per-guild sequential numbers (oldest first)
WITH numbered AS (
SELECT id, ROW_NUMBER() OVER (PARTITION BY "guildId" ORDER BY "createdAt" ASC, id ASC) AS rn
FROM "Warning"
)
UPDATE "Warning" AS w
SET "warningNumber" = numbered.rn
FROM numbered
WHERE w.id = numbered.id;
ALTER TABLE "Warning" ALTER COLUMN "warningNumber" SET NOT NULL;
CREATE UNIQUE INDEX "Warning_guildId_warningNumber_key" ON "Warning"("guildId", "warningNumber");

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "WelcomeConfig" ADD COLUMN "leaveAnnounceBan" BOOLEAN NOT NULL DEFAULT false;

View File

@@ -130,6 +130,7 @@ model Case {
model Warning {
id String @id @default(cuid())
warningNumber Int
guildId String
userId String
moderatorId String
@@ -140,6 +141,7 @@ model Warning {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
case Case? @relation(fields: [caseId], references: [id], onDelete: SetNull)
@@unique([guildId, warningNumber])
@@index([guildId, userId])
}
@@ -252,6 +254,8 @@ model WelcomeConfig {
leaveContent String?
leaveEmbed Json?
leaveComponents Json?
/// When true, leave channel also posts a notice if the member was banned.
leaveAnnounceBan Boolean @default(false)
welcomeDmEnabled Boolean @default(false)
welcomeDmContent String?
userAutoroleIds String[] @default([])
@@ -513,6 +517,8 @@ model TicketConfig {
mode String @default("CHANNEL")
categoryId String?
logChannelId String?
panelChannelId String?
panelMessageId String?
transcriptDm Boolean @default(false)
inactivityHours Int @default(72)
ratingEnabled Boolean @default(true)
@@ -590,6 +596,7 @@ model Tag {
components Json?
responseType String @default("TEXT")
triggerWord String?
cooldownSeconds Int @default(15)
allowedRoleIds String[] @default([])
allowedChannelIds String[] @default([])
createdById String
@@ -914,6 +921,18 @@ model BotPresenceConfig {
updatedAt DateTime @updatedAt
}
/// Global `/about` message (Owner panel). Singleton row id = "singleton".
model BotAboutConfig {
id String @id @default("singleton")
enabled Boolean @default(true)
/// TEXT | EMBED | COMPONENTS_V2
messageType String @default("EMBED")
content String?
embed Json?
components Json?
updatedAt DateTime @updatedAt
}
model ChangelogEntry {
id String @id @default(cuid())
version String

View File

@@ -84,9 +84,31 @@ export async function evaluateCommandGate(
if (existing > 0) {
return { ok: false, reason: 'cooldown', retryAfterSeconds: existing };
}
await redis.set(key, '1', 'EX', override.cooldownSeconds);
}
}
return { ok: true };
}
/** Sets the per-command cooldown after a successful execute. */
export async function applyCommandCooldown(
interaction: ChatInputCommandInteraction,
context: BotContext
): Promise<void> {
if (!interaction.guildId) {
return;
}
const override = await context.prisma.commandOverride.findUnique({
where: {
guildId_commandName: {
guildId: interaction.guildId,
commandName: interaction.commandName
}
}
});
if (!override?.cooldownSeconds || override.cooldownSeconds <= 0) {
return;
}
const key = `command:cooldown:${interaction.guildId}:${interaction.commandName}:${interaction.user.id}`;
await redis.set(key, '1', 'EX', override.cooldownSeconds);
}

View File

@@ -8,7 +8,7 @@ import { t, tf, type Locale } from '@nexumi/shared';
import { env } from './env.js';
import { logger } from './logger.js';
import { getGuildLocale } from './i18n.js';
import { evaluateCommandGate, type CommandGateReason } from './command-gates.js';
import { evaluateCommandGate, applyCommandCooldown, type CommandGateReason } from './command-gates.js';
import {
isGuildBlacklisted,
isModuleEnabled,
@@ -214,6 +214,11 @@ export async function routeCommand(interaction: ChatInputCommandInteraction, con
moduleId
);
if (!enabled) {
// Allow first-time setup while the module is still disabled (chicken-egg).
const sub = interaction.options.getSubcommand(false);
const isBootstrapSetup =
interaction.commandName === 'starboard' && sub === 'setup';
if (!isBootstrapSetup) {
await interaction.reply({
content: t(locale, 'generic.moduleDisabled'),
ephemeral: true
@@ -221,6 +226,7 @@ export async function routeCommand(interaction: ChatInputCommandInteraction, con
return;
}
}
}
const gate = await evaluateCommandGate(interaction, context);
if (!gate.ok) {
@@ -232,6 +238,7 @@ export async function routeCommand(interaction: ChatInputCommandInteraction, con
}
await command.execute(interaction, context);
await applyCommandCooldown(interaction, context).catch(() => undefined);
} catch (error) {
ok = false;
captureException(error, {
@@ -270,6 +277,16 @@ export async function routeContextMenu(
return;
}
const maintenance = await isMaintenanceMode(context);
const ownerIds = env.OWNER_USER_IDS ?? [];
if (maintenance.enabled && !ownerIds.includes(interaction.user.id)) {
await interaction.reply({
content: maintenance.message?.trim() || t(locale, 'generic.maintenance'),
ephemeral: true
});
return;
}
const command = contextMenuMap.get(interaction.commandName);
if (!command) {
await interaction.reply({ content: t(locale, 'generic.unknownCommand'), ephemeral: true });

View File

@@ -15,6 +15,7 @@ import { ensureRecurringJobs, startWorkers } from './jobs.js';
import type { BotContext } from './types.js';
import { startHealthServer } from './health.js';
import { getGuildLocale } from './i18n.js';
import { ephemeral } from './interaction-reply.js';
import { t } from '@nexumi/shared';
import { handleAutoModMessage } from './modules/automod/actions.js';
import { registerLoggingEvents } from './modules/logging/events.js';
@@ -116,6 +117,21 @@ if (!isShard) {
logger.error({ error }, 'Failed to ensure recurring jobs');
captureException(error, { phase: 'ensureRecurringJobs' });
}
try {
await runOncePerCluster(
'nexumi:lock:recover-overdue-giveaways',
60,
async () => {
const { recoverOverdueGiveaways } = await import('./modules/giveaways/index.js');
await recoverOverdueGiveaways(context);
},
'Overdue giveaway recovery'
);
} catch (error) {
logger.error({ error }, 'Failed to recover overdue giveaways');
captureException(error, { phase: 'recoverOverdueGiveaways' });
}
}
try {
@@ -207,15 +223,19 @@ if (!isShard) {
interactionType: interaction.type,
guildId: interaction.guildId
});
try {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
const message = t(locale, 'generic.error');
if (interaction.isRepliable()) {
if (interaction.replied || interaction.deferred) {
await interaction.followUp({ content: message, ephemeral: true });
await interaction.followUp({ content: message, ...ephemeral() });
} else {
await interaction.reply({ content: message, ephemeral: true });
await interaction.reply({ content: message, ...ephemeral() });
}
}
} catch (replyError) {
logger.warn({ replyError }, 'Failed to send interaction error reply');
}
}
});

View File

@@ -7,6 +7,12 @@ import type {
StringSelectMenuInteraction,
UserSelectMenuInteraction
} from 'discord.js';
import { decodeComponentCustomId, t, type DashboardModuleId } from '@nexumi/shared';
import { env } from './env.js';
import { getGuildLocale } from './i18n.js';
import { ephemeral } from './interaction-reply.js';
import { isModuleEnabled, moduleForComponent } from './module-gates.js';
import { isMaintenanceMode } from './presence.js';
import type { BotContext } from './types.js';
import {
handleModerationConfirmation,
@@ -139,11 +145,67 @@ const handlers: InteractionHandler[] = [
}
];
function moduleForComponentsV2(customId: string): DashboardModuleId | null {
const decoded = decodeComponentCustomId(customId);
if (!decoded) {
return null;
}
switch (decoded.source) {
case 'w':
return 'welcome';
case 't':
return 'tags';
case 's':
return 'scheduler';
case 'm':
return 'messages';
default:
return 'messages';
}
}
export async function routeComponentInteraction(
interaction: AnyComponentInteraction,
context: BotContext
): Promise<boolean> {
const customId = interaction.customId;
const locale = await getGuildLocale(context.prisma, interaction.guildId);
const maintenance = await isMaintenanceMode(context);
const ownerIds = env.OWNER_USER_IDS ?? [];
if (maintenance.enabled && !ownerIds.includes(interaction.user.id)) {
if (!interaction.replied && !interaction.deferred) {
await interaction.reply({
content: maintenance.message?.trim() || t(locale, 'generic.maintenance'),
...ephemeral()
});
}
return true;
}
let moduleId = moduleForComponent(customId);
if (!moduleId && isComponentsV2Interaction(customId)) {
moduleId = moduleForComponentsV2(customId);
}
if (moduleId && interaction.guildId) {
const enabled = await isModuleEnabled(
context.prisma,
context.redis,
interaction.guildId,
moduleId
);
if (!enabled) {
if (!interaction.replied && !interaction.deferred) {
await interaction.reply({
content: t(locale, 'generic.moduleDisabled'),
...ephemeral()
});
}
return true;
}
}
for (const handler of handlers) {
if (handler.match(customId)) {
await handler.handle(interaction, context);

View File

@@ -9,12 +9,27 @@ import type { BotContext } from './types.js';
import { env } from './env.js';
import { refreshPhishingDomains } from './modules/automod/filters.js';
import { activateLockdown, deactivateLockdown } from './modules/automod/actions.js';
import { completeVerification } from './modules/verification/handlers.js';
import {
completeVerification,
runVerificationPanelSyncJob
} from './modules/verification/handlers.js';
import { closePoll } from './modules/utility/poll.js';
import { sendReminder } from './modules/utility/reminders.js';
import { expireSelfRole } from './modules/selfroles/service.js';
import { endGiveaway, runGiveawayCreateJob, GiveawayError } from './modules/giveaways/index.js';
import { closeInactiveTickets } from './modules/tickets/index.js';
import {
expireSelfRole,
runSelfRolePanelCreateJob,
runSelfRolePanelUpdateJob,
runSelfRolePanelDeleteJob
} from './modules/selfroles/index.js';
import type { SelfRoleBehavior, SelfRoleEntry, SelfRoleMode } from '@nexumi/shared';
import {
endGiveaway,
runGiveawayCreateJob,
runGiveawayPauseJob,
runGiveawayDeleteJob,
GiveawayError
} from './modules/giveaways/index.js';
import { closeInactiveTickets, runTicketDashboardActionJob, runTicketPanelSyncJob } from './modules/tickets/index.js';
import { expireBirthdayRole, runBirthdayCheck } from './modules/birthdays/index.js';
import { ensureStatsRecurringJobs, startStatsWorker } from './modules/stats/index.js';
import { pollAllFeeds } from './modules/feeds/index.js';
@@ -42,6 +57,7 @@ import {
retentionQueue,
retentionQueueName,
scheduleQueueName,
selfrolesQueueName,
suggestionsQueueName,
ticketQueue,
ticketQueueName,
@@ -71,6 +87,10 @@ type VerificationCompleteJob = {
captchaProvider?: string | null;
};
type VerificationPanelSyncJob = {
guildId: string;
};
type ReminderSendJob = {
reminderId: string;
};
@@ -87,6 +107,8 @@ type SelfRoleExpireJob = {
type GiveawayEndJob = {
giveawayId: string;
/** When true, end even if the giveaway is paused (dashboard/slash paths). */
force?: boolean;
};
type GiveawayCreateJob = {
@@ -101,6 +123,15 @@ type GiveawayCreateJob = {
requiredMemberDays?: number | null;
};
type GiveawayPauseJob = {
giveawayId: string;
paused: boolean;
};
type GiveawayDeleteJob = {
giveawayId: string;
};
type BirthdayRoleExpireJob = {
guildId: string;
@@ -115,6 +146,36 @@ type SuggestionStatusUpdateJob = {
reason: string;
};
type SelfRolePanelCreateJob = {
guildId: string;
channelId: string;
title: string;
description?: string | null;
mode: SelfRoleMode;
behavior: SelfRoleBehavior;
roles: SelfRoleEntry[];
temporaryDurationMs?: number;
expiresAt?: string | null;
};
type SelfRolePanelUpdateJob = {
panelId: string;
guildId: string;
channelId?: string;
title?: string;
description?: string | null;
mode?: SelfRoleMode;
behavior?: SelfRoleBehavior;
roles?: SelfRoleEntry[];
temporaryDurationMs?: number;
expiresAt?: string | null;
};
type SelfRolePanelDeleteJob = {
panelId: string;
guildId: string;
};
export function startWorkers(context: BotContext): Worker[] {
const moderationWorker = new Worker<TempBanJob>(
moderationQueueName,
@@ -243,16 +304,20 @@ export function startWorkers(context: BotContext): Worker[] {
logger.error({ jobId: job?.id, error }, 'AutoMod job failed');
});
const verificationWorker = new Worker<VerificationCompleteJob>(
const verificationWorker = new Worker<VerificationCompleteJob | VerificationPanelSyncJob>(
verificationQueueName,
async (job) => {
if (job.name === 'verificationPanelSync') {
return runVerificationPanelSyncJob(context, job.data as VerificationPanelSyncJob);
}
if (job.name !== 'verificationComplete') {
return;
}
await completeVerification(context, job.data.guildId, job.data.userId, {
ipHash: job.data.ipHash,
userAgentHash: job.data.userAgentHash,
captchaProvider: job.data.captchaProvider
const data = job.data as VerificationCompleteJob;
await completeVerification(context, data.guildId, data.userId, {
ipHash: data.ipHash,
userAgentHash: data.userAgentHash,
captchaProvider: data.captchaProvider
});
},
{ connection: redis }
@@ -283,6 +348,22 @@ export function startWorkers(context: BotContext): Worker[] {
const ticketWorker = new Worker(
ticketQueueName,
async (job) => {
if (job.name === 'ticketPanelSync') {
return runTicketPanelSyncJob(context, job.data as { guildId: string });
}
if (job.name === 'ticketDashboardAction') {
return runTicketDashboardActionJob(
context,
job.data as {
ticketId: string;
guildId: string;
actorUserId: string;
action: 'close' | 'claim' | 'priority';
reason?: string;
priority?: string;
}
);
}
if (job.name === 'selfRoleExpire') {
const data = job.data as SelfRoleExpireJob;
await expireSelfRole(context, data.guildId, data.userId, data.roleId);
@@ -299,12 +380,47 @@ export function startWorkers(context: BotContext): Worker[] {
logger.error({ jobId: job?.id, error }, 'Ticket queue job failed');
});
const giveawayWorker = new Worker<GiveawayEndJob | GiveawayCreateJob>(
const selfrolesWorker = new Worker<
SelfRolePanelCreateJob | SelfRolePanelUpdateJob | SelfRolePanelDeleteJob
>(
selfrolesQueueName,
async (job) => {
if (job.name === 'selfRolePanelCreate') {
return runSelfRolePanelCreateJob(context, job.data as SelfRolePanelCreateJob);
}
if (job.name === 'selfRolePanelUpdate') {
return runSelfRolePanelUpdateJob(context, job.data as SelfRolePanelUpdateJob);
}
if (job.name === 'selfRolePanelDelete') {
const data = job.data as SelfRolePanelDeleteJob;
await runSelfRolePanelDeleteJob(context, data.panelId, data.guildId);
}
},
{ connection: redis }
);
selfrolesWorker.on('failed', (job, error) => {
logger.error({ jobId: job?.id, error }, 'Self-roles queue job failed');
});
const giveawayWorker = new Worker<
GiveawayEndJob | GiveawayCreateJob | GiveawayPauseJob | GiveawayDeleteJob
>(
giveawayQueueName,
async (job) => {
if (job.name === 'giveawayEnd') {
try {
await endGiveaway(context, (job.data as GiveawayEndJob).giveawayId);
const data = job.data as GiveawayEndJob;
if (!data.force) {
const current = await context.prisma.giveaway.findUnique({
where: { id: data.giveawayId }
});
// Timer/recover must not end a paused giveaway.
if (current?.paused) {
return;
}
}
await endGiveaway(context, data.giveawayId);
} catch (error) {
if (
error instanceof GiveawayError &&
@@ -319,6 +435,13 @@ export function startWorkers(context: BotContext): Worker[] {
if (job.name === 'giveawayCreate') {
return runGiveawayCreateJob(context, job.data as GiveawayCreateJob);
}
if (job.name === 'giveawayPause') {
const data = job.data as GiveawayPauseJob;
return runGiveawayPauseJob(context, data.giveawayId, data.paused);
}
if (job.name === 'giveawayDelete') {
await runGiveawayDeleteJob(context, (job.data as GiveawayDeleteJob).giveawayId);
}
},
{ connection: redis }
);
@@ -453,6 +576,7 @@ export function startWorkers(context: BotContext): Worker[] {
verificationWorker,
reminderWorker,
ticketWorker,
selfrolesWorker,
giveawayWorker,
birthdayWorker,
statsWorker,

View File

@@ -2,6 +2,7 @@ import { EmbedBuilder } from 'discord.js';
import {
embedHasContent,
mapEmbedTextFields,
type EmbedTextField,
type WelcomeEmbed
} from '@nexumi/shared';
@@ -18,8 +19,12 @@ function isHttpUrl(value: string | undefined): value is string {
}
export interface ApplyEmbedOptions {
/** Called for every text/URL field before applying to the builder. */
renderText: (value: string) => string;
/**
* Called for every text/URL field before applying to the builder.
* `field` lets callers render `{user}` as a mention in the description
* but as a plain display name in title/author/footer.
*/
renderText: (value: string, field: EmbedTextField) => string;
/**
* Used when `thumbnailUrl` is unset (Welcome default: member avatar).
* Pass `null` to force no thumbnail.

View File

@@ -0,0 +1,35 @@
import type { Job } from 'bullmq';
import { logger } from '../logger.js';
/**
* Removes a BullMQ job when present. Locked (active) jobs cannot be removed —
* those errors are swallowed so callers can still schedule a replacement jobId
* or rely on removeOnComplete after the worker finishes.
* Jobs that belong to a Job Scheduler must be removed via removeJobScheduler;
* direct remove is ignored here.
*/
export async function safeRemoveBullJob(
job: Job | null | undefined,
context?: { label?: string; jobId?: string }
): Promise<void> {
if (!job) {
return;
}
try {
await job.remove();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (
message.includes('locked') ||
message.includes('could not be removed') ||
message.includes('belongs to a job scheduler')
) {
logger.warn(
{ error, jobId: context?.jobId ?? job.id, label: context?.label },
'BullMQ job could not be removed directly; skipping'
);
return;
}
throw error;
}
}

View File

@@ -20,6 +20,7 @@ describe('moduleForCommand', () => {
it('leaves core commands unmapped', () => {
expect(moduleForCommand('help')).toBeNull();
expect(moduleForCommand('info')).toBeNull();
expect(moduleForCommand('about')).toBeNull();
expect(moduleForCommand('gdpr')).toBeNull();
});
});

View File

@@ -92,6 +92,41 @@ export function moduleForCommand(commandName: string): DashboardModuleId | null
return COMMAND_MODULE_MAP[commandName] ?? null;
}
/** Component customId → dashboard module (null = no module gate). */
export function moduleForComponent(customId: string): DashboardModuleId | null {
if (customId.startsWith('mod:confirm:') || customId.startsWith('mod:cancel:')) {
return 'moderation';
}
if (customId.startsWith('verify:')) {
return 'verification';
}
if (customId.startsWith('eco:bj:')) {
return 'economy';
}
if (customId.startsWith('fun:')) {
return 'fun';
}
if (customId.startsWith('gw:')) {
return 'giveaways';
}
if (customId.startsWith('ticket:') || customId === 'ticket:select') {
return 'tickets';
}
if (customId.startsWith('sr:')) {
return 'selfroles';
}
if (customId.startsWith('sug:')) {
return 'suggestions';
}
if (customId.startsWith('tv:')) {
return 'tempvoice';
}
if (customId.startsWith('gbackup:')) {
return 'guildbackup';
}
return null;
}
export async function isFeatureFlagEnabled(
prisma: PrismaClient,
key: string,

View File

@@ -1,6 +1,7 @@
import { ChannelType, PermissionFlagsBits, type Guild, type GuildMember, type Message, type TextChannel } from 'discord.js';
import type { BotContext } from '../../types.js';
import { createCase } from '../moderation/service.js';
import { createCase, createWarning, notifyModerationTargetDm } from '../moderation/service.js';
import { getGuildLocale } from '../../i18n.js';
import {
getAutoModConfig,
getEnabledAutoModRules,
@@ -41,15 +42,8 @@ async function applyAutoModAction(
const baseReason = `AutoMod (${ruleType}): ${reason}`;
if (action === 'WARN') {
await context.prisma.warning.create({
data: {
guildId: guild.id,
userId: message.author.id,
moderatorId,
reason: baseReason
}
});
await createCase(context, {
const locale = await getGuildLocale(context.prisma, guild.id);
const modCase = await createCase(context, {
guildId: guild.id,
targetUserId: message.author.id,
moderatorId,
@@ -59,6 +53,16 @@ async function applyAutoModAction(
source: 'automod',
metadata: { ruleType, automod: true }
});
await createWarning(context, {
guildId: guild.id,
userId: message.author.id,
moderatorId,
reason: baseReason,
caseId: modCase.id,
guildName: guild.name,
locale,
notifyUser: message.author
});
return;
}
@@ -81,6 +85,14 @@ async function applyAutoModAction(
if (action === 'KICK') {
if (me.permissions.has(PermissionFlagsBits.KickMembers)) {
const locale = await getGuildLocale(context.prisma, guild.id);
await notifyModerationTargetDm({
user: message.author,
guildName: guild.name,
reason: baseReason,
locale,
action: 'kick'
});
await message.member.kick(baseReason);
await createCase(context, {
guildId: guild.id,
@@ -98,6 +110,14 @@ async function applyAutoModAction(
if (action === 'BAN') {
if (me.permissions.has(PermissionFlagsBits.BanMembers)) {
const locale = await getGuildLocale(context.prisma, guild.id);
await notifyModerationTargetDm({
user: message.author,
guildName: guild.name,
reason: baseReason,
locale,
action: 'ban'
});
await guild.members.ban(message.author.id, { reason: baseReason });
await createCase(context, {
guildId: guild.id,

View File

@@ -1,8 +1,11 @@
import { SlashCommandBuilder } from 'discord.js';
import { PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
import { applyCommandDescription } from '@nexumi/shared';
export const automodStatusCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('automod').addSubcommand((s) =>
new SlashCommandBuilder()
.setName('automod')
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild)
.addSubcommand((s) =>
applyCommandDescription(s.setName('status'), 'automod.status.description')
),
'automod.description'

View File

@@ -1,6 +1,7 @@
import type { Redis } from 'ioredis';
import {
capsRatio,
contentMatchesBlockedWord,
countEmojis,
extractUrls,
hasZalgo,
@@ -124,15 +125,14 @@ function checkExternalLink(content: string, threshold: AutoModThreshold): Filter
}
function checkWordFilter(content: string, wordList: AutoModWordList): FilterResult | null {
const lower = content.toLowerCase();
for (const word of wordList.words) {
if (word.length > 0 && lower.includes(word.toLowerCase())) {
if (contentMatchesBlockedWord(content, word)) {
return { matched: true, ruleType: 'WORD_FILTER', reason: 'Blocked word' };
}
}
for (const pattern of wordList.regexPatterns) {
try {
const regex = new RegExp(pattern, 'i');
const regex = new RegExp(pattern, 'iu');
if (regex.test(content)) {
return { matched: true, ruleType: 'WORD_FILTER', reason: 'Blocked pattern' };
}

View File

@@ -1,6 +1,8 @@
import type { PrismaClient } from '@prisma/client';
import type { Prisma, PrismaClient } from '@prisma/client';
import {
DEFAULT_AUTOMOD_RULES,
defaultAutoModWordList,
shouldSeedDefaultBlockedWords,
type AutoModExceptions,
type AutoModThreshold,
type AutoModWordList
@@ -16,6 +18,26 @@ export async function getAutoModConfig(prisma: PrismaClient, guildId: string) {
return config;
}
async function seedDefaultWordFilterIfNeeded(prisma: PrismaClient, guildId: string) {
const rule = await prisma.autoModRule.findUnique({
where: { guildId_ruleType: { guildId, ruleType: 'WORD_FILTER' } },
select: { id: true, wordList: true }
});
if (!rule || !shouldSeedDefaultBlockedWords(rule.wordList)) {
return;
}
const existing = parseWordList(rule.wordList);
await prisma.autoModRule.update({
where: { id: rule.id },
data: {
wordList: {
...defaultAutoModWordList(),
regexPatterns: existing.regexPatterns
} as Prisma.InputJsonValue
}
});
}
export async function ensureDefaultAutoModRules(prisma: PrismaClient, guildId: string) {
await ensureGuild(prisma, guildId);
const existing = await prisma.autoModRule.findMany({
@@ -24,9 +46,7 @@ export async function ensureDefaultAutoModRules(prisma: PrismaClient, guildId: s
});
const have = new Set(existing.map((row) => row.ruleType));
const missing = DEFAULT_AUTOMOD_RULES.filter((rule) => !have.has(rule.ruleType));
if (missing.length === 0) {
return;
}
if (missing.length > 0) {
await prisma.autoModRule.createMany({
data: missing.map((rule) => ({
guildId,
@@ -37,6 +57,8 @@ export async function ensureDefaultAutoModRules(prisma: PrismaClient, guildId: s
enabled: ['SPAM', 'MASS_MENTION', 'INVITE_LINK', 'PHISHING'].includes(rule.ruleType)
}))
});
}
await seedDefaultWordFilterIfNeeded(prisma, guildId);
}
export async function getEnabledAutoModRules(prisma: PrismaClient, guildId: string) {
@@ -71,7 +93,8 @@ export function parseWordList(raw: unknown): AutoModWordList {
const obj = raw as Record<string, unknown>;
return {
words: Array.isArray(obj.words) ? obj.words.map(String) : [],
regexPatterns: Array.isArray(obj.regexPatterns) ? obj.regexPatterns.map(String) : []
regexPatterns: Array.isArray(obj.regexPatterns) ? obj.regexPatterns.map(String) : [],
...(obj.userCleared === true ? { userCleared: true } : {})
};
}

View File

@@ -67,6 +67,13 @@ async function loadPayload(
}
return parseMessageComponentsV2(binding.payload);
}
case 'a': {
if (ref !== 'singleton') {
return null;
}
const config = await context.prisma.botAboutConfig.findUnique({ where: { id: 'singleton' } });
return parseMessageComponentsV2(config?.components);
}
default:
return null;
}

View File

@@ -0,0 +1,98 @@
import {
ABOUT_REDIS_KEY,
BotAboutConfigSchema,
expandBracketChannelMentions,
parseMessageComponentsV2,
t,
type BotAboutConfig,
type Locale,
type WelcomeEmbed
} from '@nexumi/shared';
import type { InteractionReplyOptions, MessageCreateOptions } from 'discord.js';
import { EmbedBuilder } from 'discord.js';
import { buildComponentsV2Payload } from '../../lib/components-v2-payload.js';
import { buildEmbedFromPayload } from '../../lib/embed-payload.js';
import { redis } from '../../redis.js';
import type { BotContext } from '../../types.js';
const NEXUMI_COLOR = 0x6366f1;
function parseEmbed(raw: unknown): WelcomeEmbed | null {
if (!raw || typeof raw !== 'object') {
return null;
}
return raw as WelcomeEmbed;
}
export async function readAboutConfig(context: BotContext): Promise<BotAboutConfig> {
const cached = await redis.get(ABOUT_REDIS_KEY);
if (cached) {
const parsed = BotAboutConfigSchema.safeParse(JSON.parse(cached));
if (parsed.success) {
return parsed.data;
}
}
const row = await context.prisma.botAboutConfig.upsert({
where: { id: 'singleton' },
create: { id: 'singleton' },
update: {}
});
const config = BotAboutConfigSchema.parse({
enabled: row.enabled,
messageType: row.messageType,
content: row.content,
embed: parseEmbed(row.embed),
components: parseMessageComponentsV2(row.components)
});
await redis.set(ABOUT_REDIS_KEY, JSON.stringify(config));
return config;
}
function defaultAboutReply(locale: Locale): InteractionReplyOptions {
return {
embeds: [
new EmbedBuilder()
.setTitle(t(locale, 'core.about.defaultTitle'))
.setDescription(t(locale, 'core.about.defaultBody'))
.setColor(NEXUMI_COLOR)
.setFooter({ text: 'Nexumi' })
]
};
}
export async function buildAboutReply(
context: BotContext,
locale: Locale
): Promise<InteractionReplyOptions | MessageCreateOptions> {
const config = await readAboutConfig(context);
if (!config.enabled) {
return { content: t(locale, 'core.about.disabled'), ephemeral: true };
}
const renderText = (value: string) => expandBracketChannelMentions(value);
if (config.messageType === 'COMPONENTS_V2') {
const payload = buildComponentsV2Payload(config.components, {
source: 'a',
ref: 'singleton',
renderText
});
if (payload) {
return payload;
}
}
if (config.messageType === 'EMBED') {
const embed = buildEmbedFromPayload(config.embed, { renderText });
if (embed) {
return { embeds: [embed] };
}
}
if (config.messageType === 'TEXT' && config.content?.trim()) {
return { content: renderText(config.content) };
}
return defaultAboutReply(locale);
}

View File

@@ -13,6 +13,11 @@ export const infoCommandData = applyCommandDescription(
'core.info.description'
);
export const aboutCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('about'),
'core.about.description'
);
export const inviteCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('invite'),
'core.invite.description'

View File

@@ -6,10 +6,12 @@ import { buildBotInviteUrl, env } from '../../env.js';
import {
helpCommandData,
infoCommandData,
aboutCommandData,
inviteCommandData,
supportCommandData
} from './command-definitions.js';
import { HELP_MODULES } from './help-catalog.js';
import { buildAboutReply } from './about.js';
const NEXUMI_COLOR = 0x6366f1;
const PACKAGE_VERSION = '0.1.0';
@@ -118,6 +120,15 @@ const infoCommand: SlashCommand = {
}
};
const aboutCommand: SlashCommand = {
data: aboutCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
const reply = await buildAboutReply(context, locale);
await interaction.reply(reply as import('discord.js').InteractionReplyOptions);
}
};
const inviteCommand: SlashCommand = {
data: inviteCommandData,
async execute(interaction, context) {
@@ -153,6 +164,7 @@ const supportCommand: SlashCommand = {
export const coreCommands: SlashCommand[] = [
helpCommand,
infoCommand,
aboutCommand,
inviteCommand,
supportCommand
];

View File

@@ -1,6 +1,6 @@
/** Module id → top-level slash command names for `/help`. */
export const HELP_MODULES: ReadonlyArray<{ id: string; commands: readonly string[] }> = [
{ id: 'core', commands: ['help', 'info', 'invite', 'support', 'privacy', 'gdpr'] },
{ id: 'core', commands: ['help', 'info', 'about', 'invite', 'support', 'privacy', 'gdpr'] },
{
id: 'moderation',
commands: [

View File

@@ -327,7 +327,12 @@ async function handleMediaError(
): Promise<void> {
if (error instanceof FunMediaError) {
const key = error.code === 'disabled' ? 'fun.media.disabled' : 'fun.media.fetchFailed';
await interaction.reply({ content: t(locale, key), ephemeral: true });
const content = t(locale, key);
if (interaction.deferred || interaction.replied) {
await interaction.editReply({ content });
return;
}
await interaction.reply({ content, ephemeral: true });
return;
}
throw error;
@@ -342,12 +347,13 @@ const memeCommand: SlashCommand = {
try {
await assertMediaEnabled(context.redis, guildId);
await interaction.deferReply();
const meme = await fetchMeme();
const embed = new EmbedBuilder()
.setTitle(meme.title)
.setImage(meme.url)
.setColor(0x6366f1);
await interaction.reply({ embeds: [embed] });
await interaction.editReply({ embeds: [embed] });
} catch (error) {
await handleMediaError(interaction, locale, error);
}
@@ -363,12 +369,13 @@ const catCommand: SlashCommand = {
try {
await assertMediaEnabled(context.redis, guildId);
await interaction.deferReply();
const url = await fetchCatImage();
const embed = new EmbedBuilder()
.setTitle(t(locale, 'fun.cat.title'))
.setImage(url)
.setColor(0x6366f1);
await interaction.reply({ embeds: [embed] });
await interaction.editReply({ embeds: [embed] });
} catch (error) {
await handleMediaError(interaction, locale, error);
}
@@ -384,12 +391,13 @@ const dogCommand: SlashCommand = {
try {
await assertMediaEnabled(context.redis, guildId);
await interaction.deferReply();
const url = await fetchDogImage();
const embed = new EmbedBuilder()
.setTitle(t(locale, 'fun.dog.title'))
.setImage(url)
.setColor(0x6366f1);
await interaction.reply({ embeds: [embed] });
await interaction.editReply({ embeds: [embed] });
} catch (error) {
await handleMediaError(interaction, locale, error);
}

View File

@@ -1,9 +1,10 @@
import { applyCommandDescription, applyOptionDescription } from '@nexumi/shared';
import { SlashCommandBuilder } from 'discord.js';
import { PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
export const giveawayCommandData = applyCommandDescription(
new SlashCommandBuilder()
.setName('giveaway')
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild)
.addSubcommand((s) =>
applyCommandDescription(s.setName('start'), 'giveaway.start.description')
.addStringOption((o) =>

View File

@@ -6,6 +6,7 @@ import { requirePermission } from '../../permissions.js';
import { parseDuration } from '../moderation/duration.js';
import { giveawayCommandData } from './command-definitions.js';
import {
cancelGiveawayJob,
deleteGiveaway,
endGiveaway,
GiveawayError,
@@ -26,6 +27,17 @@ async function ensureManageGuild(
return requirePermission(interaction, PermissionFlagsBits.ManageGuild, locale);
}
async function replyEphemeral(
interaction: Parameters<SlashCommand['execute']>[0],
content: string
): Promise<void> {
if (interaction.deferred || interaction.replied) {
await interaction.editReply({ content });
return;
}
await interaction.reply({ content, ephemeral: true });
}
const giveawayCommand: SlashCommand = {
data: giveawayCommandData,
async execute(interaction, context) {
@@ -71,6 +83,8 @@ const giveawayCommand: SlashCommand = {
return;
}
await interaction.deferReply({ ephemeral: true });
const giveaway = await startGiveaway(
context,
{
@@ -87,9 +101,8 @@ const giveawayCommand: SlashCommand = {
locale
);
await interaction.reply({
content: tf(locale, 'giveaway.started', { id: giveaway.id }),
ephemeral: true
await interaction.editReply({
content: tf(locale, 'giveaway.started', { id: giveaway.id })
});
return;
}
@@ -100,20 +113,30 @@ const giveawayCommand: SlashCommand = {
try {
if (sub === 'end') {
const id = interaction.options.getString('id', true);
const id = interaction.options.getString('id', true).trim();
const giveaway = await context.prisma.giveaway.findUnique({ where: { id } });
if (!giveaway || giveaway.guildId !== interaction.guildId) {
throw new GiveawayError('not_found');
}
await interaction.deferReply({ ephemeral: true });
await cancelGiveawayJob(id);
await endGiveaway(context, id);
await interaction.reply({ content: t(locale, 'giveaway.ended'), ephemeral: true });
await interaction.editReply({ content: t(locale, 'giveaway.ended') });
return;
}
if (sub === 'reroll') {
const id = interaction.options.getString('id', true);
const id = interaction.options.getString('id', true).trim();
const existing = await context.prisma.giveaway.findUnique({ where: { id } });
if (!existing || existing.guildId !== interaction.guildId) {
throw new GiveawayError('not_found');
}
await interaction.deferReply({ ephemeral: true });
const updated = await rerollGiveaway(context, id, locale);
await interaction.reply({
await interaction.editReply({
content: tf(locale, 'giveaway.rerolled', {
winners: updated.winners.map((w) => `<@${w}>`).join(', ') || '—'
}),
ephemeral: true
})
});
return;
}
@@ -136,14 +159,22 @@ const giveawayCommand: SlashCommand = {
}
if (sub === 'delete') {
const id = interaction.options.getString('id', true);
const id = interaction.options.getString('id', true).trim();
const existing = await context.prisma.giveaway.findUnique({ where: { id } });
if (!existing || existing.guildId !== interaction.guildId) {
throw new GiveawayError('not_found');
}
await deleteGiveaway(context, id);
await interaction.reply({ content: t(locale, 'giveaway.deleted'), ephemeral: true });
return;
}
if (sub === 'pause') {
const id = interaction.options.getString('id', true);
const id = interaction.options.getString('id', true).trim();
const existing = await context.prisma.giveaway.findUnique({ where: { id } });
if (!existing || existing.guildId !== interaction.guildId) {
throw new GiveawayError('not_found');
}
const updated = await pauseGiveaway(context, id, locale);
await interaction.reply({
content: t(locale, updated.paused ? 'giveaway.paused' : 'giveaway.resumed'),
@@ -152,10 +183,7 @@ const giveawayCommand: SlashCommand = {
}
} catch (error) {
if (error instanceof GiveawayError) {
await interaction.reply({
content: t(locale, `giveaway.error.${error.code}`),
ephemeral: true
});
await replyEphemeral(interaction, t(locale, `giveaway.error.${error.code}`));
return;
}
throw error;

View File

@@ -1,3 +1,11 @@
export { giveawayCommands } from './commands.js';
export { isGiveawayButton, handleGiveawayButton } from './buttons.js';
export { endGiveaway, scheduleGiveawayEnd, runGiveawayCreateJob, GiveawayError } from './service.js';
export {
endGiveaway,
scheduleGiveawayEnd,
runGiveawayCreateJob,
runGiveawayPauseJob,
runGiveawayDeleteJob,
recoverOverdueGiveaways,
GiveawayError
} from './service.js';

View File

@@ -22,25 +22,46 @@ export class GiveawayError extends Error {
}
}
export function giveawayEndJobId(giveawayId: string): string {
return `giveaway-end-${giveawayId}`;
}
/**
* Removes a scheduled/failed end job if present. Locked (active) jobs cannot be
* removed — callers must not invoke this from inside the giveawayEnd worker.
*/
export async function cancelGiveawayJob(giveawayId: string): Promise<void> {
const job = await giveawayQueue.getJob(giveawayEndJobId(giveawayId));
if (!job) {
return;
}
try {
await job.remove();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (message.includes('locked') || message.includes('could not be removed')) {
logger.warn({ giveawayId, error }, 'Giveaway end job is locked; skipping remove');
return;
}
throw error;
}
}
export async function scheduleGiveawayEnd(giveawayId: string, endsAt: Date): Promise<void> {
await cancelGiveawayJob(giveawayId);
const delay = Math.max(0, endsAt.getTime() - Date.now());
await giveawayQueue.add(
'giveawayEnd',
{ giveawayId },
{
delay,
jobId: `giveaway-end-${giveawayId}`,
jobId: giveawayEndJobId(giveawayId),
removeOnComplete: true,
removeOnFail: 50
}
);
}
export async function cancelGiveawayJob(giveawayId: string): Promise<void> {
const job = await giveawayQueue.getJob(`giveaway-end-${giveawayId}`);
await job?.remove();
}
function requirementLines(locale: 'de' | 'en', giveaway: Giveaway): string[] {
const lines: string[] = [];
if (giveaway.requiredRoleId) {
@@ -316,6 +337,15 @@ async function dmWinners(
}
}
/**
* Draws winners and marks the giveaway ended. Does **not** cancel BullMQ jobs —
* the slash-command / dashboard paths cancel the delayed job first; the
* `giveawayEnd` worker must never remove its own locked job (that threw and
* left giveaways stuck with the join button still visible).
*
* Uses a conditional update so concurrent ends (timer + slash + recover)
* cannot each write different winners.
*/
export async function endGiveaway(context: BotContext, giveawayId: string): Promise<Giveaway> {
const giveaway = await context.prisma.giveaway.findUnique({ where: { id: giveawayId } });
if (!giveaway) {
@@ -325,21 +355,31 @@ export async function endGiveaway(context: BotContext, giveawayId: string): Prom
throw new GiveawayError('already_ended');
}
await cancelGiveawayJob(giveawayId);
const locale = await getGuildLocale(context.prisma, giveaway.guildId);
const guild = await context.client.guilds.fetch(giveaway.guildId);
const eligible = await filterEligibleEntrants(context, guild, giveaway);
const winners = pickRandomWinners(eligible, giveaway.winnerCount);
const endedAt = new Date();
const updated = await context.prisma.giveaway.update({
where: { id: giveawayId },
const claimed = await context.prisma.giveaway.updateMany({
where: { id: giveawayId, endedAt: null },
data: {
endedAt: new Date(),
endedAt,
winners,
paused: false
}
});
if (claimed.count === 0) {
const current = await context.prisma.giveaway.findUnique({ where: { id: giveawayId } });
if (!current) {
throw new GiveawayError('not_found');
}
throw new GiveawayError('already_ended');
}
const updated = await context.prisma.giveaway.findUniqueOrThrow({ where: { id: giveawayId } });
await updateGiveawayMessage(context, updated, locale);
if (winners.length > 0) {
await dmWinners(context, updated, winners, locale);
@@ -347,6 +387,41 @@ export async function endGiveaway(context: BotContext, giveawayId: string): Prom
return updated;
}
/**
* Ends giveaways whose `endsAt` has passed but `endedAt` is still null
* (e.g. after a failed/locked BullMQ job). Skips paused giveaways.
*/
export async function recoverOverdueGiveaways(context: BotContext): Promise<number> {
const overdue = await context.prisma.giveaway.findMany({
where: {
endedAt: null,
paused: false,
endsAt: { lte: new Date() }
},
take: 100,
orderBy: { endsAt: 'asc' }
});
let recovered = 0;
for (const giveaway of overdue) {
try {
await cancelGiveawayJob(giveaway.id);
await endGiveaway(context, giveaway.id);
recovered += 1;
} catch (error) {
if (error instanceof GiveawayError && error.code === 'already_ended') {
continue;
}
logger.error({ error, giveawayId: giveaway.id }, 'Failed to recover overdue giveaway');
}
}
if (recovered > 0) {
logger.info({ recovered }, 'Recovered overdue giveaways');
}
return recovered;
}
export async function rerollGiveaway(
context: BotContext,
giveawayId: string,
@@ -378,22 +453,61 @@ export async function rerollGiveaway(
export async function pauseGiveaway(
context: BotContext,
giveawayId: string,
locale: 'de' | 'en'
locale: 'de' | 'en',
forcePaused?: boolean
): Promise<Giveaway> {
const giveaway = await context.prisma.giveaway.findUnique({ where: { id: giveawayId } });
if (!giveaway || giveaway.endedAt) {
throw new GiveawayError('not_found');
}
const nextPaused = forcePaused ?? !giveaway.paused;
if (nextPaused === giveaway.paused) {
await updateGiveawayMessage(context, giveaway, locale);
return giveaway;
}
if (nextPaused) {
// Stop the end timer while paused; remaining wall-clock is preserved via endsAt.
await cancelGiveawayJob(giveawayId);
}
const updated = await context.prisma.giveaway.update({
where: { id: giveawayId },
data: { paused: !giveaway.paused }
data: { paused: nextPaused }
});
if (!nextPaused) {
// Resume: re-schedule with delay based on remaining time until endsAt
// (delay 0 if endsAt already passed while paused → ends immediately).
await scheduleGiveawayEnd(updated.id, updated.endsAt);
}
await updateGiveawayMessage(context, updated, locale);
return updated;
}
/**
* Dashboard entry points — same Discord side-effects as slash commands.
*/
export async function runGiveawayPauseJob(
context: BotContext,
giveawayId: string,
paused: boolean
): Promise<{ id: string; paused: boolean }> {
const giveaway = await context.prisma.giveaway.findUnique({ where: { id: giveawayId } });
if (!giveaway) {
throw new GiveawayError('not_found');
}
const locale = await getGuildLocale(context.prisma, giveaway.guildId);
const updated = await pauseGiveaway(context, giveawayId, locale, paused);
return { id: updated.id, paused: updated.paused };
}
export async function runGiveawayDeleteJob(context: BotContext, giveawayId: string): Promise<void> {
await deleteGiveaway(context, giveawayId);
}
export async function deleteGiveaway(context: BotContext, giveawayId: string): Promise<void> {
const giveaway = await context.prisma.giveaway.findUnique({ where: { id: giveawayId } });
if (!giveaway) {

View File

@@ -1,9 +1,10 @@
import { applyCommandDescription, applyOptionDescription } from '@nexumi/shared';
import { SlashCommandBuilder } from 'discord.js';
import { PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
export const backupCommandData = applyCommandDescription(
new SlashCommandBuilder()
.setName('backup')
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild)
.addSubcommand((s) =>
applyCommandDescription(s.setName('create'), 'guildbackup.create.description').addStringOption(
(o) => applyOptionDescription(o.setName('name'), 'guildbackup.create.options.name')

View File

@@ -55,17 +55,17 @@ const backupCommand: SlashCommand = {
return;
}
await interaction.deferReply({ ephemeral: true });
try {
const backup = await createGuildBackup(context, guild, name, interaction.user.id);
await interaction.reply({
content: tf(locale, 'guildbackup.created', { id: backup.id, name: backup.name }),
ephemeral: true
await interaction.editReply({
content: tf(locale, 'guildbackup.created', { id: backup.id, name: backup.name })
});
} catch (error) {
if (error instanceof GuildBackupError) {
await interaction.reply({
content: t(locale, `guildbackup.error.${error.code}`),
ephemeral: true
await interaction.editReply({
content: t(locale, `guildbackup.error.${error.code}`)
});
return;
}
@@ -75,6 +75,9 @@ const backupCommand: SlashCommand = {
}
if (sub === 'list') {
if (!(await ensureManageGuild(interaction, locale))) {
return;
}
const backups = await listGuildBackups(context, guild.id);
if (backups.length === 0) {
await interaction.reply({
@@ -100,6 +103,9 @@ const backupCommand: SlashCommand = {
}
if (sub === 'info') {
if (!(await ensureManageGuild(interaction, locale))) {
return;
}
const backupId = interaction.options.getString('id', true);
const backup = await getGuildBackup(context, guild.id, backupId);
if (!backup) {

View File

@@ -10,6 +10,9 @@ import type { LogEventType } from '@nexumi/shared';
import { ensureGuild } from '../../guild.js';
import { logger } from '../../logger.js';
/** Audit-log entries can lag a moment behind the gateway event. */
const AUDIT_LOG_MAX_AGE_MS = 15_000;
export async function getLoggingConfig(prisma: BotContext['prisma'], guildId: string) {
await ensureGuild(prisma, guildId);
let config = await prisma.loggingConfig.findUnique({ where: { guildId } });
@@ -19,6 +22,36 @@ export async function getLoggingConfig(prisma: BotContext['prisma'], guildId: st
return config;
}
/**
* Discord does not send ban/kick reasons over the gateway.
* Reasons must be read from the audit log (requires View Audit Log).
*/
async function fetchRecentAuditEntry(
guild: Guild,
type: AuditLogEvent,
targetId: string
): Promise<{ reason: string | null; executorId: string | null } | null> {
try {
const auditLogs = await guild.fetchAuditLogs({ type, limit: 5 });
const entry = auditLogs.entries.find((item) => {
if (item.targetId !== targetId) {
return false;
}
return Date.now() - item.createdTimestamp <= AUDIT_LOG_MAX_AGE_MS;
});
if (!entry) {
return null;
}
return {
reason: entry.reason ?? null,
executorId: entry.executorId ?? entry.executor?.id ?? null
};
} catch (error) {
logger.warn({ error, guildId: guild.id, type }, 'Failed to fetch audit log for logging');
return null;
}
}
export async function getLogChannelId(
prisma: BotContext['prisma'],
guildId: string,
@@ -124,6 +157,45 @@ export async function logModAction(
await sendLogEmbed(context, guild, 'MOD_ACTION', 'Moderation Action', description, 0xef4444);
}
export async function logVerificationFail(
context: BotContext,
input: {
guildId: string;
userId: string;
userTag?: string;
reason: string;
detail?: string;
failAction?: string;
matchedUserId?: string | null;
}
): Promise<void> {
const guild = await context.client.guilds.fetch(input.guildId).catch(() => null);
if (!guild) {
return;
}
const lines = [
`**User:** ${input.userTag ? `${input.userTag} ` : ''}(<@${input.userId}>) (\`${input.userId}\`)`,
`**Reason:** ${input.reason}`
];
if (input.detail) {
lines.push(`**Detail:** ${input.detail}`);
}
if (input.matchedUserId) {
lines.push(`**Matched user:** <@${input.matchedUserId}> (\`${input.matchedUserId}\`)`);
}
if (input.failAction) {
lines.push(`**Fail action:** ${input.failAction}`);
}
await sendLogEmbed(
context,
guild,
'VERIFICATION_FAIL',
'Verification Failed',
lines.join('\n'),
0xf97316
);
}
export async function handleChannelDeleteForAntiNuke(
context: BotContext,
guild: Guild,
@@ -253,6 +325,29 @@ export async function registerLoggingEvents(context: BotContext): Promise<void>
if (memberIgnored(config, member as GuildMember, member.user.bot)) {
return;
}
const kickEntry = await fetchRecentAuditEntry(
member.guild,
AuditLogEvent.MemberKick,
member.id
);
if (kickEntry) {
await sendLogEmbed(
context,
member.guild,
'MEMBER_LEAVE',
'Member Kicked',
[
`**User:** ${member.user.tag} (<@${member.id}>)`,
`**Moderator:** ${kickEntry.executorId ? `<@${kickEntry.executorId}>` : '—'}`,
`**Reason:** ${kickEntry.reason ?? '—'}`
].join('\n'),
0xf59e0b
);
return;
}
await sendLogEmbed(
context,
member.guild,
@@ -265,12 +360,19 @@ export async function registerLoggingEvents(context: BotContext): Promise<void>
client.on('guildBanAdd', async (ban) => {
const guild = ban.guild;
await handleBanForAntiNuke(context, guild);
const audit = await fetchRecentAuditEntry(guild, AuditLogEvent.MemberBanAdd, ban.user.id);
const reason = audit?.reason ?? ban.reason ?? '—';
const executorLine = audit?.executorId
? `\n**Moderator:** <@${audit.executorId}>`
: '';
await sendLogEmbed(
context,
guild,
'MEMBER_BAN',
'Member Banned',
`**User:** ${ban.user.tag} (<@${ban.user.id}>)\n**Reason:** ${ban.reason ?? '—'}`
`**User:** ${ban.user.tag} (<@${ban.user.id}>)${executorLine}\n**Reason:** ${reason}`
);
});

View File

@@ -2,7 +2,7 @@ import { TextChannel, type ButtonInteraction } from 'discord.js';
import { type Locale, t, tf } from '@nexumi/shared';
import type { BotContext } from '../../types.js';
import { assertBannableForConfirm } from '../../hierarchy.js';
import { createCase, scheduleTempBanExpire } from './service.js';
import { createCase, notifyModerationTargetDm, scheduleTempBanExpire } from './service.js';
import { tryParseDuration } from './duration.js';
type BanPayload = {
@@ -53,6 +53,20 @@ export async function executeBan(
}
}
await interaction.deferUpdate();
const targetUser = await context.client.users.fetch(payload.userId).catch(() => null);
if (targetUser) {
await notifyModerationTargetDm({
user: targetUser,
guildName: guild.name,
reason: payload.reason,
locale,
action: 'ban',
duration: payload.duration
});
}
await guild.members.ban(payload.userId, { reason: payload.reason });
const modCase = await createCase(context, {
guildId: guild.id,
@@ -73,8 +87,11 @@ export async function executeBan(
await scheduleTempBanExpire(guild.id, payload.userId, durationMs, payload.reason);
}
await interaction.update({
content: tf(locale, 'moderation.success.case', { caseNumber: modCase.caseNumber }),
await interaction.editReply({
content: tf(locale, 'moderation.success.caseWithReason', {
caseNumber: modCase.caseNumber,
reason: payload.reason
}),
components: []
});
}
@@ -91,6 +108,8 @@ export async function executePurge(
return;
}
await interaction.deferUpdate();
const channel = await guild.channels.fetch(payload.channelId);
let deletedCount = 0;
const regex = payload.regex ? new RegExp(payload.regex, 'i') : null;
@@ -134,7 +153,7 @@ export async function executePurge(
}
});
await interaction.update({
await interaction.editReply({
content: tf(locale, 'moderation.purge.done', {
deletedCount,
caseNumber: modCase.caseNumber

View File

@@ -96,9 +96,12 @@ export const warnCommandData = applyCommandDescription(
)
)
.addSubcommand((s) =>
applyCommandDescription(s.setName('remove'), 'moderation.warn.remove.description').addStringOption(
applyCommandDescription(s.setName('remove'), 'moderation.warn.remove.description').addIntegerOption(
(o) =>
applyOptionDescription(o.setName('warning_id').setRequired(true), 'moderation.warn.options.warning_id')
applyOptionDescription(
o.setName('id').setRequired(true).setMinValue(1),
'moderation.warn.options.warning_id'
)
)
)
.addSubcommand((s) =>

View File

@@ -8,7 +8,7 @@ import { t, tf } from '@nexumi/shared';
import type { SlashCommand } from '../../types.js';
import { requirePermission } from '../../permissions.js';
import { assertModerableMember } from '../../hierarchy.js';
import { applyWarnEscalation, createCase } from './service.js';
import { applyWarnEscalation, createCase, createWarning, notifyModerationTargetDm } from './service.js';
import { parseDuration, parseTimeoutDuration, tryParseDuration } from './duration.js';
import { getGuildLocale } from '../../i18n.js';
import { promptDestructiveConfirmation } from './confirmations.js';
@@ -48,16 +48,24 @@ async function ensureMod(
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
return false;
}
return requirePermission(interaction, permission, locale);
return requirePermission(interaction, permission, locale, { botPermissions: permission });
}
async function replySuccessCase(
interaction: ChatInputCommandInteraction,
locale: 'de' | 'en',
caseNumber: number
caseNumber: number,
reason?: string
): Promise<void> {
const content = reason
? tf(locale, 'moderation.success.caseWithReason', { caseNumber, reason })
: tf(locale, 'moderation.success.case', { caseNumber });
if (interaction.deferred || interaction.replied) {
await interaction.editReply({ content });
return;
}
await interaction.reply({
content: tf(locale, 'moderation.success.case', { caseNumber }),
content,
ephemeral: true
});
}
@@ -112,7 +120,7 @@ const unbanCommand: SlashCommand = {
action: 'UNBAN',
reason
});
await replySuccessCase(interaction, locale, modCase.caseNumber);
await replySuccessCase(interaction, locale, modCase.caseNumber, reason);
}
};
@@ -125,6 +133,13 @@ const kickCommand: SlashCommand = {
const reason = reasonFrom(interaction, locale);
const hierarchy = await assertModerableMember(interaction, user.id, locale, 'kick');
if (!hierarchy.ok || !hierarchy.member) return;
await notifyModerationTargetDm({
user,
guildName: interaction.guild!.name,
reason,
locale,
action: 'kick'
});
await hierarchy.member.kick(reason);
const modCase = await createCase(context, {
guildId: interaction.guildId!,
@@ -134,7 +149,7 @@ const kickCommand: SlashCommand = {
action: 'KICK',
reason
});
await replySuccessCase(interaction, locale, modCase.caseNumber);
await replySuccessCase(interaction, locale, modCase.caseNumber, reason);
}
};
@@ -167,7 +182,7 @@ const timeoutCommand: SlashCommand = {
reason,
metadata: { durationMs: duration }
});
await replySuccessCase(interaction, locale, modCase.caseNumber);
await replySuccessCase(interaction, locale, modCase.caseNumber, reason);
}
};
@@ -189,7 +204,7 @@ const untimeoutCommand: SlashCommand = {
action: 'UN_TIMEOUT',
reason
});
await replySuccessCase(interaction, locale, modCase.caseNumber);
await replySuccessCase(interaction, locale, modCase.caseNumber, reason);
}
};
@@ -299,19 +314,15 @@ const warnCommand: SlashCommand = {
action: 'WARN_ADD',
reason
});
await context.prisma.user.upsert({
where: { id: user.id },
update: {},
create: { id: user.id }
});
const warning = await context.prisma.warning.create({
data: {
const { warning } = await createWarning(context, {
guildId: interaction.guildId!,
userId: user.id,
moderatorId: interaction.user.id,
reason,
caseId: modCase.id
}
caseId: modCase.id,
guildName: interaction.guild!.name,
locale,
notifyUser: user
});
const escalationResult = await applyWarnEscalation(
interaction,
@@ -322,8 +333,8 @@ const warnCommand: SlashCommand = {
);
await interaction.reply({
content: escalationResult
? `${tf(locale, 'moderation.warning.created', { warningId: warning.id })}\n${escalationResult}`
: tf(locale, 'moderation.warning.created', { warningId: warning.id }),
? `${tf(locale, 'moderation.warning.created', { warningNumber: warning.warningNumber })}\n${escalationResult}`
: tf(locale, 'moderation.warning.created', { warningNumber: warning.warningNumber }),
ephemeral: true
});
return;
@@ -341,8 +352,8 @@ const warnCommand: SlashCommand = {
? t(locale, 'moderation.warning.none')
: warnings
.map(
(w: { id: string; reason: string | null }) =>
`- ${w.id}: ${w.reason ?? t(locale, 'moderation.warning.defaultReason')}`
(w: { warningNumber: number; reason: string | null }) =>
`- #${w.warningNumber}: ${w.reason ?? t(locale, 'moderation.warning.defaultReason')}`
)
.join('\n');
await interaction.reply({ content, ephemeral: true });
@@ -350,9 +361,9 @@ const warnCommand: SlashCommand = {
}
if (sub === 'remove') {
const warningId = interaction.options.getString('warning_id', true);
const warningNumber = interaction.options.getInteger('id', true);
const warning = await context.prisma.warning.findFirst({
where: { id: warningId, guildId: interaction.guildId! }
where: { warningNumber, guildId: interaction.guildId! }
});
if (!warning) {
await interaction.reply({
@@ -368,7 +379,7 @@ const warnCommand: SlashCommand = {
moderatorId: interaction.user.id,
...auditFrom(interaction),
action: 'WARN_REMOVE',
reason: `Warning ${warningId} removed`
reason: `Warning #${warningNumber} removed`
});
await replySuccessCase(interaction, locale, modCase.caseNumber);
return;
@@ -460,6 +471,7 @@ const lockCommand: SlashCommand = {
const guild = interaction.guild!;
if (serverWide) {
await interaction.deferReply({ ephemeral: true });
for (const channel of guild.channels.cache.values()) {
if (channel.type !== ChannelType.GuildText && channel.type !== ChannelType.GuildAnnouncement) {
continue;
@@ -495,6 +507,7 @@ const unlockCommand: SlashCommand = {
const guild = interaction.guild!;
if (serverWide) {
await interaction.deferReply({ ephemeral: true });
for (const channel of guild.channels.cache.values()) {
if (channel.type !== ChannelType.GuildText && channel.type !== ChannelType.GuildAnnouncement) {
continue;

View File

@@ -1,8 +1,14 @@
import {
PermissionFlagsBits,
type ChatInputCommandInteraction,
type User
} from 'discord.js';
import type { Prisma, Warning } from '@prisma/client';
import { buildCaseMetadata, type CaseAuditSource, t, tf, type Locale } from '@nexumi/shared';
import { logger } from '../../logger.js';
import { safeRemoveBullJob } from '../../lib/safe-remove-job.js';
import { moderationQueue } from '../../queues.js';
import type { BotContext } from '../../types.js';
import { PermissionFlagsBits, type ChatInputCommandInteraction } from 'discord.js';
import type { Prisma } from '@prisma/client';
import { buildCaseMetadata, type CaseAuditSource, t, type Locale } from '@nexumi/shared';
import { MAX_TIMEOUT_MS } from './duration.js';
type CreateCaseInput = {
@@ -16,6 +22,152 @@ type CreateCaseInput = {
metadata?: Record<string, unknown>;
};
type CreateWarningInput = {
guildId: string;
userId: string;
moderatorId: string;
reason?: string;
caseId?: string;
guildName: string;
locale: Locale;
/** Discord user to DM; when omitted the user is fetched by id. */
notifyUser?: User;
};
export type CreatedWarning = {
warning: Warning;
warningCount: number;
};
async function notifyWarnedUser(params: {
user: User;
guildName: string;
reason: string;
warningCount: number;
warningNumber: number;
locale: Locale;
}): Promise<void> {
try {
await params.user.send(
tf(params.locale, 'moderation.warning.dm', {
guild: params.guildName,
reason: params.reason,
count: params.warningCount,
warningNumber: params.warningNumber
})
);
} catch (error) {
logger.warn(
{ error, userId: params.user.id, warningNumber: params.warningNumber },
'Failed to DM warned user'
);
}
}
export async function notifyModerationTargetDm(params: {
user: User;
guildName: string;
reason: string;
locale: Locale;
action: 'ban' | 'kick';
duration?: string | null;
}): Promise<void> {
const reason = params.reason || t(params.locale, 'moderation.reason.none');
const key =
params.action === 'kick'
? 'moderation.kick.dm'
: params.duration
? 'moderation.ban.dm.temporary'
: 'moderation.ban.dm';
try {
await params.user.send(
tf(params.locale, key, {
guild: params.guildName,
reason,
...(params.duration ? { duration: params.duration } : {})
})
);
} catch (error) {
logger.warn(
{ error, userId: params.user.id, action: params.action },
'Failed to DM moderation target'
);
}
}
export async function createWarning(
context: BotContext,
input: CreateWarningInput
): Promise<CreatedWarning> {
await context.prisma.user.upsert({
where: { id: input.userId },
update: {},
create: { id: input.userId }
});
let warning: Warning | null = null;
for (let attempt = 0; attempt < 5; attempt++) {
try {
warning = await context.prisma.$transaction(
async (tx) => {
const last = await tx.warning.findFirst({
where: { guildId: input.guildId },
orderBy: { warningNumber: 'desc' },
select: { warningNumber: true }
});
return tx.warning.create({
data: {
guildId: input.guildId,
warningNumber: (last?.warningNumber ?? 0) + 1,
userId: input.userId,
moderatorId: input.moderatorId,
reason: input.reason,
caseId: input.caseId
}
});
},
{ isolationLevel: 'Serializable' }
);
break;
} catch (error) {
const code =
typeof error === 'object' && error && 'code' in error
? String((error as { code: unknown }).code)
: '';
if (code !== 'P2002' && attempt === 4) {
throw error;
}
if (attempt === 4) {
throw error;
}
}
}
if (!warning) {
throw new Error('Failed to create warning');
}
const warningCount = await context.prisma.warning.count({
where: { guildId: input.guildId, userId: input.userId }
});
const reason = input.reason ?? t(input.locale, 'moderation.warning.defaultReason');
const notifyUser =
input.notifyUser ?? (await context.client.users.fetch(input.userId).catch(() => null));
if (notifyUser) {
await notifyWarnedUser({
user: notifyUser,
guildName: input.guildName,
reason,
warningCount,
warningNumber: warning.warningNumber,
locale: input.locale
});
}
return { warning, warningCount };
}
export async function createCase(context: BotContext, input: CreateCaseInput) {
await context.prisma.guild.upsert({
where: { id: input.guildId },
@@ -96,9 +248,7 @@ export async function scheduleTempBanExpire(
) {
const jobId = tempBanJobId(guildId, userId);
const existing = await moderationQueue.getJob(jobId);
if (existing) {
await existing.remove();
}
await safeRemoveBullJob(existing, { label: 'tempBanExpire', jobId });
await moderationQueue.add(
'tempBanExpire',
@@ -179,6 +329,13 @@ export async function applyWarnEscalation(
String(warningCount)
);
}
await notifyModerationTargetDm({
user: member.user,
guildName: interaction.guild.name,
reason,
locale,
action: 'kick'
});
await member.kick(reason);
await createCase(context, {
guildId,
@@ -199,6 +356,13 @@ export async function applyWarnEscalation(
String(warningCount)
);
}
await notifyModerationTargetDm({
user: member.user,
guildName: interaction.guild.name,
reason,
locale,
action: 'ban'
});
await interaction.guild.members.ban(userId, { reason });
await createCase(context, {
guildId,

View File

@@ -1,9 +1,10 @@
import { applyCommandDescription, applyOptionDescription } from '@nexumi/shared';
import { SlashCommandBuilder } from 'discord.js';
import { PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
export const scheduleCommandData = applyCommandDescription(
new SlashCommandBuilder()
.setName('schedule')
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild)
.addSubcommand((sub) =>
applyCommandDescription(sub.setName('create'), 'schedule.create.description')
.addChannelOption((o) =>
@@ -51,6 +52,7 @@ export const scheduleCommandData = applyCommandDescription(
export const announceCommandData = applyCommandDescription(
new SlashCommandBuilder()
.setName('announce')
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild)
.addChannelOption((o) =>
applyOptionDescription(o.setName('channel').setRequired(true), 'schedule.common.options.channel')
)

View File

@@ -4,11 +4,20 @@ import {
type MessageCreateOptions,
type TextChannel
} from 'discord.js';
import { WelcomeEmbedSchema, expandBracketChannelMentions, parseMessageComponentsV2, t, tf } from '@nexumi/shared';
import {
DEFAULT_GUILD_TIMEZONE,
WelcomeEmbedSchema,
expandBracketChannelMentions,
parseMessageComponentsV2,
resolveScheduleRunAt,
t,
tf
} from '@nexumi/shared';
import type { ScheduledMessage } from '@prisma/client';
import { ensureGuild } from '../../guild.js';
import { buildEmbedFromPayload } from '../../lib/embed-payload.js';
import { buildComponentsV2Payload } from '../../lib/components-v2-payload.js';
import { safeRemoveBullJob } from '../../lib/safe-remove-job.js';
import { logger } from '../../logger.js';
import { scheduleQueue } from '../../queues.js';
import type { BotContext } from '../../types.js';
@@ -36,7 +45,7 @@ export type ScheduleCreateInput = AnnouncementInput & {
cron?: string | null;
};
function parseScheduleWhen(input: string): Date {
function parseScheduleWhen(input: string, timeZone: string): Date {
const trimmed = input.trim();
if (/^\d+[smhd]$/i.test(trimmed)) {
const ms = parseDuration(trimmed);
@@ -46,11 +55,79 @@ function parseScheduleWhen(input: string): Date {
return new Date(Date.now() + ms);
}
const parsed = Date.parse(trimmed);
if (Number.isNaN(parsed) || parsed <= Date.now()) {
try {
const parsed = resolveScheduleRunAt(trimmed, timeZone);
if (parsed.getTime() <= Date.now()) {
throw new SchedulerError('invalid_when');
}
return new Date(parsed);
return parsed;
} catch {
throw new SchedulerError('invalid_when');
}
}
async function getGuildTimezone(prisma: BotContext['prisma'], guildId: string): Promise<string> {
const settings = await prisma.guildSettings.findUnique({
where: { guildId },
select: { timezone: true }
});
return settings?.timezone || DEFAULT_GUILD_TIMEZONE;
}
export function scheduleJobId(scheduleId: string): string {
return `schedule-${scheduleId}`;
}
/** BullMQ delayed instance ids look like `repeat:{schedulerId}:{millis}`. */
const REPEAT_INSTANCE_ID_RE = /^repeat:(.+):(\d+)$/;
function collectSchedulerIds(scheduleId: string, jobId: string | null | undefined): string[] {
const ids = new Set<string>();
ids.add(scheduleJobId(scheduleId));
if (!jobId) {
return [...ids];
}
const repeatMatch = jobId.match(REPEAT_INSTANCE_ID_RE);
if (repeatMatch?.[1]) {
ids.add(repeatMatch[1]);
} else if (!jobId.startsWith('repeat:')) {
ids.add(jobId);
}
return [...ids];
}
async function removeScheduleJobSchedulers(
queue: typeof scheduleQueue,
scheduleId: string,
jobId: string | null | undefined
): Promise<void> {
for (const schedulerId of collectSchedulerIds(scheduleId, jobId)) {
try {
await queue.removeJobScheduler(schedulerId);
} catch (error) {
logger.warn({ schedulerId, error }, 'Failed to remove schedule job scheduler');
}
}
// Legacy / hash-keyed schedulers: match by payload scheduleId.
try {
const schedulers = await queue.getJobSchedulers();
for (const scheduler of schedulers) {
const data = scheduler.template?.data as { scheduleId?: string } | undefined;
if (data?.scheduleId !== scheduleId) {
continue;
}
const id = scheduler.id ?? scheduler.key;
try {
await queue.removeJobScheduler(id);
} catch (error) {
logger.warn({ schedulerId: id, error }, 'Failed to remove matched schedule job scheduler');
}
}
} catch (error) {
logger.warn({ scheduleId, error }, 'Failed to scan job schedulers for schedule');
}
}
function isValidCron(pattern: string): boolean {
@@ -157,38 +234,85 @@ async function assertSendableChannel(
async function enqueueScheduleJob(
schedule: ScheduledMessage,
cron: string | null,
runAt: Date | null
runAt: Date | null,
timeZone: string
): Promise<string> {
const jobOptions: {
jobId: string;
removeOnComplete: boolean;
removeOnFail: number;
delay?: number;
repeat?: { pattern: string };
} = {
jobId: `schedule-${schedule.id}`,
removeOnComplete: true,
removeOnFail: 50
};
const jobId = scheduleJobId(schedule.id);
if (cron) {
jobOptions.repeat = { pattern: cron };
} else if (runAt) {
jobOptions.delay = Math.max(0, runAt.getTime() - Date.now());
// BullMQ 5 job schedulers honor guild timezone for cron patterns.
await scheduleQueue.upsertJobScheduler(
jobId,
{ pattern: cron, tz: timeZone },
{
name: 'scheduleSend',
data: { scheduleId: schedule.id },
opts: {
removeOnComplete: true,
removeOnFail: 50
}
}
);
return jobId;
}
const job = await scheduleQueue.add('scheduleSend', { scheduleId: schedule.id }, jobOptions);
if (!runAt) {
throw new SchedulerError('missing_schedule_time');
}
const job = await scheduleQueue.add(
'scheduleSend',
{ scheduleId: schedule.id },
{
jobId,
removeOnComplete: true,
removeOnFail: 50,
delay: Math.max(0, runAt.getTime() - Date.now())
}
);
return String(job.id);
}
export async function removeScheduleJob(jobId: string | null): Promise<void> {
if (!jobId) {
return;
/**
* Removes a one-shot delayed job and/or cron job scheduler. Locked (active)
* jobs cannot be removed — do not call this from inside the scheduleSend
* worker for the job that is currently running.
*/
export async function removeScheduleJob(
jobId: string | null,
options?: { scheduleId?: string; cron?: string | null }
): Promise<void> {
if (options?.scheduleId) {
await removeScheduleJobSchedulers(scheduleQueue, options.scheduleId, jobId);
} else if (options?.cron && jobId) {
// Best-effort for callers without scheduleId: extract scheduler from repeat instance id.
for (const schedulerId of collectSchedulerIds('unknown', jobId)) {
if (schedulerId === 'schedule-unknown') {
continue;
}
try {
await scheduleQueue.removeJobScheduler(schedulerId);
} catch (error) {
logger.warn({ schedulerId, error }, 'Failed to remove schedule job scheduler');
}
}
}
const job = await scheduleQueue.getJob(jobId);
if (job) {
await job.remove();
// One-shot delayed jobs only — never job.remove() on repeat:* scheduler children.
const oneShotIds = new Set<string>();
if (jobId && !jobId.startsWith('repeat:')) {
oneShotIds.add(jobId);
}
if (options?.scheduleId) {
oneShotIds.add(scheduleJobId(options.scheduleId));
}
for (const id of oneShotIds) {
const job = await scheduleQueue.getJob(id);
if (job?.id && String(job.id).startsWith('repeat:')) {
continue;
}
await safeRemoveBullJob(job, { label: 'schedule', jobId: id });
}
}
@@ -220,6 +344,7 @@ export async function createScheduledMessage(
let runAt: Date | null = null;
let cronPattern: string | null = null;
const timeZone = await getGuildTimezone(context.prisma, input.guildId);
if (cron) {
if (!isValidCron(cron)) {
@@ -227,7 +352,7 @@ export async function createScheduledMessage(
}
cronPattern = cron;
} else if (whenInput) {
runAt = parseScheduleWhen(whenInput);
runAt = parseScheduleWhen(whenInput, timeZone);
}
await assertSendableChannel(context, input.channelId);
@@ -247,7 +372,7 @@ export async function createScheduledMessage(
}
});
const jobId = await enqueueScheduleJob(schedule, cronPattern, runAt);
const jobId = await enqueueScheduleJob(schedule, cronPattern, runAt, timeZone);
return context.prisma.scheduledMessage.update({
where: { id: schedule.id },
data: { jobId }
@@ -307,7 +432,7 @@ export async function deleteScheduledMessage(
return false;
}
await removeScheduleJob(match.jobId);
await removeScheduleJob(match.jobId, { scheduleId: match.id, cron: match.cron });
await context.prisma.scheduledMessage.delete({ where: { id: match.id } });
return true;
}
@@ -350,8 +475,10 @@ export async function sendScheduledMessage(context: BotContext, scheduleId: stri
throw error;
}
// One-shot: delete the DB row only. Do not remove the active BullMQ job
// from inside its own worker (locked remove throws → retry → duplicate send).
// removeOnComplete on enqueue cleans the job up after the processor returns.
if (!schedule.cron) {
await removeScheduleJob(schedule.jobId);
await context.prisma.scheduledMessage.delete({ where: { id: scheduleId } });
}
}

View File

@@ -3,11 +3,12 @@ import {
applyOptionDescription,
localizedChoice
} from '@nexumi/shared';
import { ChannelType, SlashCommandBuilder } from 'discord.js';
import { ChannelType, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
export const selfrolesCommandData = applyCommandDescription(
new SlashCommandBuilder()
.setName('selfroles')
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild)
.addSubcommandGroup((group) =>
applyCommandDescription(group.setName('panel'), 'selfroles.panel.description')
.addSubcommand((sub) =>

View File

@@ -4,5 +4,9 @@ export { registerSelfRoleEvents } from './reactions.js';
export {
expireSelfRole,
scheduleSelfRoleExpiry,
cancelSelfRoleExpiry
cancelSelfRoleExpiry,
runSelfRolePanelCreateJob,
runSelfRolePanelUpdateJob,
runSelfRolePanelDeleteJob,
SelfRoleError
} from './service.js';

View File

@@ -21,7 +21,9 @@ import {
} from '@nexumi/shared';
import type { SelfRolePanel } from '@prisma/client';
import { ensureGuild } from '../../guild.js';
import { getGuildLocale } from '../../i18n.js';
import { logger } from '../../logger.js';
import { safeRemoveBullJob } from '../../lib/safe-remove-job.js';
import { ticketQueue } from '../../queues.js';
import type { BotContext } from '../../types.js';
@@ -266,13 +268,17 @@ export async function scheduleSelfRoleExpiry(
roleId: string,
expiresAt: Date
): Promise<void> {
const jobId = `selfrole-expire-${guildId}-${userId}-${roleId}`;
const existing = await ticketQueue.getJob(jobId);
await safeRemoveBullJob(existing, { label: 'selfRoleExpire', jobId });
const delay = Math.max(0, expiresAt.getTime() - Date.now());
await ticketQueue.add(
'selfRoleExpire',
{ guildId, userId, roleId },
{
delay,
jobId: `selfrole-expire-${guildId}-${userId}-${roleId}`,
jobId,
removeOnComplete: true,
removeOnFail: 50
}
@@ -284,8 +290,9 @@ export async function cancelSelfRoleExpiry(
userId: string,
roleId: string
): Promise<void> {
const job = await ticketQueue.getJob(`selfrole-expire-${guildId}-${userId}-${roleId}`);
await job?.remove();
const jobId = `selfrole-expire-${guildId}-${userId}-${roleId}`;
const job = await ticketQueue.getJob(jobId);
await safeRemoveBullJob(job, { label: 'selfRoleExpire', jobId });
}
export async function expireSelfRole(
@@ -416,6 +423,69 @@ export async function findPanelByMessageId(
});
}
async function postOrEditPanelMessage(
context: BotContext,
panel: SelfRolePanel,
locale: 'de' | 'en',
previous?: { channelId: string; messageId: string | null }
): Promise<SelfRolePanel> {
const mode = SelfRoleModeSchema.parse(panel.mode);
const { entries } = parseRolesJson(panel.roles);
const channelChanged =
previous !== undefined && previous.channelId !== panel.channelId;
if (panel.messageId && !channelChanged) {
try {
const channel = (await context.client.channels.fetch(panel.channelId)) as TextChannel;
const message = await channel.messages.fetch(panel.messageId);
await message.edit({
embeds: [buildPanelEmbed(panel, locale)],
components: buildPanelComponents(panel)
});
if (mode === 'REACTIONS') {
for (const reaction of message.reactions.cache.values()) {
await reaction.remove().catch(() => undefined);
}
await applyReactionEmojis(message, entries);
}
return panel;
} catch (error) {
logger.warn({ error, panelId: panel.id }, 'Failed to edit self-role panel message; recreating');
}
}
if (previous?.messageId && channelChanged) {
try {
const oldChannel = (await context.client.channels.fetch(previous.channelId)) as TextChannel;
const oldMessage = await oldChannel.messages.fetch(previous.messageId);
await oldMessage.delete();
} catch {
// Previous message may already be gone
}
}
const channel = await context.client.channels.fetch(panel.channelId);
if (!channel || !channel.isTextBased() || channel.isDMBased()) {
throw new SelfRoleError('invalid_channel');
}
const message = await channel.send({
embeds: [buildPanelEmbed(panel, locale)],
components: buildPanelComponents(panel)
});
if (mode === 'REACTIONS') {
await applyReactionEmojis(message, entries);
}
return context.prisma.selfRolePanel.update({
where: { id: panel.id },
data: { messageId: message.id }
});
}
export async function createSelfRolePanel(
context: BotContext,
params: {
@@ -427,6 +497,7 @@ export async function createSelfRolePanel(
behavior: SelfRoleBehavior;
roles: SelfRoleEntry[];
temporaryDurationMs?: number;
expiresAt?: Date | null;
},
locale: 'de' | 'en'
): Promise<SelfRolePanel> {
@@ -451,24 +522,12 @@ export async function createSelfRolePanel(
description: params.description ?? null,
mode: params.mode,
behavior: params.behavior,
roles: serializeRolesJson(params.roles, params.temporaryDurationMs)
roles: serializeRolesJson(params.roles, params.temporaryDurationMs),
expiresAt: params.expiresAt ?? null
}
});
const channel = (await context.client.channels.fetch(params.channelId)) as TextChannel;
const message = await channel.send({
embeds: [buildPanelEmbed(panel, locale)],
components: buildPanelComponents(panel)
});
if (params.mode === 'REACTIONS') {
await applyReactionEmojis(message, params.roles);
}
return context.prisma.selfRolePanel.update({
where: { id: panel.id },
data: { messageId: message.id }
});
return postOrEditPanelMessage(context, panel, locale);
}
export async function updateSelfRolePanel(
@@ -476,12 +535,14 @@ export async function updateSelfRolePanel(
panelId: string,
guildId: string,
updates: {
channelId?: string;
title?: string;
description?: string | null;
mode?: SelfRoleMode;
behavior?: SelfRoleBehavior;
roles?: SelfRoleEntry[];
temporaryDurationMs?: number;
expiresAt?: Date | null;
},
locale: 'de' | 'en'
): Promise<SelfRolePanel> {
@@ -507,39 +568,100 @@ export async function updateSelfRolePanel(
throw new SelfRoleError('reaction_emoji_required');
}
const previous = { channelId: existing.channelId, messageId: existing.messageId };
const panel = await context.prisma.selfRolePanel.update({
where: { id: panelId },
data: {
channelId: updates.channelId ?? existing.channelId,
title: updates.title ?? existing.title,
description: updates.description !== undefined ? updates.description : existing.description,
mode,
behavior,
roles: serializeRolesJson(roles, temporaryDurationMs)
roles: serializeRolesJson(roles, temporaryDurationMs),
...(updates.expiresAt !== undefined ? { expiresAt: updates.expiresAt } : {})
}
});
if (panel.messageId) {
try {
const channel = (await context.client.channels.fetch(panel.channelId)) as TextChannel;
const message = await channel.messages.fetch(panel.messageId);
await message.edit({
embeds: [buildPanelEmbed(panel, locale)],
components: buildPanelComponents(panel)
});
return postOrEditPanelMessage(context, panel, locale, previous);
}
if (mode === 'REACTIONS') {
const existingReactions = message.reactions.cache;
for (const reaction of existingReactions.values()) {
await reaction.remove().catch(() => undefined);
}
await applyReactionEmojis(message, roles);
}
} catch (error) {
logger.warn({ error, panelId }, 'Failed to update self-role panel message');
}
/**
* BullMQ entry points for dashboard create/update/delete. The WebUI has no
* discord.js Client, so panel posting is delegated here (same pattern as
* giveaways / dashboard messages).
*/
export async function runSelfRolePanelCreateJob(
context: BotContext,
params: {
guildId: string;
channelId: string;
title: string;
description?: string | null;
mode: SelfRoleMode;
behavior: SelfRoleBehavior;
roles: SelfRoleEntry[];
temporaryDurationMs?: number;
expiresAt?: string | null;
}
): Promise<{ id: string }> {
const locale = await getGuildLocale(context.prisma, params.guildId);
const panel = await createSelfRolePanel(
context,
{
...params,
expiresAt: params.expiresAt ? new Date(params.expiresAt) : null
},
locale
);
return { id: panel.id };
}
return panel;
export async function runSelfRolePanelUpdateJob(
context: BotContext,
params: {
panelId: string;
guildId: string;
channelId?: string;
title?: string;
description?: string | null;
mode?: SelfRoleMode;
behavior?: SelfRoleBehavior;
roles?: SelfRoleEntry[];
temporaryDurationMs?: number;
expiresAt?: string | null;
}
): Promise<{ id: string }> {
const locale = await getGuildLocale(context.prisma, params.guildId);
const panel = await updateSelfRolePanel(
context,
params.panelId,
params.guildId,
{
channelId: params.channelId,
title: params.title,
description: params.description,
mode: params.mode,
behavior: params.behavior,
roles: params.roles,
temporaryDurationMs: params.temporaryDurationMs,
expiresAt:
params.expiresAt === undefined
? undefined
: params.expiresAt
? new Date(params.expiresAt)
: null
},
locale
);
return { id: panel.id };
}
export async function runSelfRolePanelDeleteJob(
context: BotContext,
panelId: string,
guildId: string
): Promise<void> {
await deleteSelfRolePanel(context, panelId, guildId);
}
export async function deleteSelfRolePanel(

View File

@@ -1,15 +1,19 @@
import { ChannelType } from 'discord.js';
import { ChannelType, PermissionFlagsBits } from 'discord.js';
import { applyCommandDescription, applyOptionDescription } from '@nexumi/shared';
import { SlashCommandBuilder } from 'discord.js';
export const starboardCommandData = applyCommandDescription(
new SlashCommandBuilder()
.setName('starboard')
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild)
.addSubcommand((s) =>
applyCommandDescription(s.setName('setup'), 'starboard.setup.description')
.addChannelOption((o) =>
applyOptionDescription(
o.setName('channel').addChannelTypes(ChannelType.GuildText).setRequired(true),
o
.setName('channel')
.addChannelTypes(ChannelType.GuildText, ChannelType.GuildAnnouncement)
.setRequired(true),
'starboard.setup.options.channel'
)
)
@@ -28,6 +32,9 @@ export const starboardCommandData = applyCommandDescription(
'starboard.setup.options.allow_self_star'
)
)
)
.addSubcommand((s) =>
applyCommandDescription(s.setName('status'), 'starboard.status.description')
),
'starboard.description'
);

View File

@@ -1,17 +1,18 @@
import { PermissionFlagsBits } from 'discord.js';
import { t } from '@nexumi/shared';
import { t, tf } from '@nexumi/shared';
import type { SlashCommand } from '../../types.js';
import { getGuildLocale } from '../../i18n.js';
import { requirePermission } from '../../permissions.js';
import { ephemeral } from '../../interaction-reply.js';
import { starboardCommandData } from './command-definitions.js';
import { getStarboardConfig, updateStarboardConfig } from './service.js';
import { botCanPostStarboard, getStarboardConfig, updateStarboardConfig } from './service.js';
const starboardCommand: SlashCommand = {
data: starboardCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!interaction.inGuild()) {
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ...ephemeral() });
return;
}
if (!(await requirePermission(interaction, PermissionFlagsBits.ManageGuild, locale))) {
@@ -22,11 +23,21 @@ const starboardCommand: SlashCommand = {
const sub = interaction.options.getSubcommand();
if (sub === 'setup') {
const channel = interaction.options.getChannel('channel', true);
const channelOption = interaction.options.getChannel('channel', true);
const threshold = interaction.options.getInteger('threshold', true);
const emoji = interaction.options.getString('emoji') ?? '⭐';
const allowSelfStar = interaction.options.getBoolean('allow_self_star') ?? false;
const channel = await interaction.guild!.channels.fetch(channelOption.id).catch(() => null);
const me = interaction.guild!.members.me;
if (!channel || !me || !botCanPostStarboard(channel, me.id)) {
await interaction.reply({
content: t(locale, 'starboard.invalidChannel'),
...ephemeral()
});
return;
}
await updateStarboardConfig(context.prisma, guildId, {
enabled: true,
channelId: channel.id,
@@ -35,13 +46,30 @@ const starboardCommand: SlashCommand = {
allowSelfStar
});
await interaction.reply({ content: t(locale, 'starboard.setupDone'), ephemeral: true });
await interaction.reply({ content: t(locale, 'starboard.setupDone'), ...ephemeral() });
return;
}
if (sub === 'status') {
const config = await getStarboardConfig(context.prisma, guildId);
if (!config.enabled || !config.channelId) {
await interaction.reply({ content: t(locale, 'starboard.notConfigured'), ephemeral: true });
await interaction.reply({
content: t(locale, 'starboard.notConfigured'),
...ephemeral()
});
return;
}
await interaction.reply({
content: tf(locale, 'starboard.statusLine', {
channel: `<#${config.channelId}>`,
emoji: config.emoji,
threshold: String(config.threshold),
selfStar: config.allowSelfStar
? t(locale, 'starboard.selfStarOn')
: t(locale, 'starboard.selfStarOff')
}),
...ephemeral()
});
}
}
};

View File

@@ -1,6 +1,11 @@
import type { Message, PartialMessage } from 'discord.js';
import type { BotContext } from '../../types.js';
import { logger } from '../../logger.js';
import { processStarboardReaction } from './service.js';
import {
processStarboardReaction,
processStarboardSourceDelete,
processStarboardSourceUpdate
} from './service.js';
export function registerStarboardEvents(context: BotContext): void {
const handleReaction = async (
@@ -26,4 +31,48 @@ export function registerStarboardEvents(context: BotContext): void {
}
await handleReaction(_reaction);
});
context.client.on('messageReactionRemoveAll', async (message) => {
try {
if (message.partial) {
await message.fetch().catch(() => null);
}
if (!message.guild || !('author' in message) || !message.author) {
return;
}
await processStarboardSourceUpdate(context, message as Message | PartialMessage);
} catch (error) {
logger.warn({ error }, 'Starboard remove-all handler failed');
}
});
context.client.on('messageReactionRemoveEmoji', async (reaction) => {
await handleReaction(reaction);
});
context.client.on('messageDelete', async (message) => {
try {
await processStarboardSourceDelete(context, message);
} catch (error) {
logger.warn({ error }, 'Starboard source-delete handler failed');
}
});
context.client.on('messageDeleteBulk', async (messages) => {
for (const message of messages.values()) {
try {
await processStarboardSourceDelete(context, message);
} catch (error) {
logger.warn({ error }, 'Starboard bulk-delete handler failed');
}
}
});
context.client.on('messageUpdate', async (_oldMessage, newMessage) => {
try {
await processStarboardSourceUpdate(context, newMessage);
} catch (error) {
logger.warn({ error }, 'Starboard source-update handler failed');
}
});
}

View File

@@ -0,0 +1,85 @@
import { describe, expect, it } from 'vitest';
import { emojiMatches, shouldIgnoreMessage } from './service.js';
function reactionEmoji(partial: { id?: string | null; name?: string | null }) {
return {
id: partial.id ?? null,
name: partial.name ?? null,
identifier: partial.id ? `${partial.name}:${partial.id}` : (partial.name ?? ''),
toString() {
if (partial.id) {
return `<:${partial.name}:${partial.id}>`;
}
return partial.name ?? '';
}
} as unknown as import('discord.js').Emoji;
}
describe('starboard emojiMatches', () => {
it('matches unicode emoji', () => {
expect(emojiMatches('⭐', reactionEmoji({ name: '⭐' }))).toBe(true);
});
it('matches custom emoji forms', () => {
const emoji = reactionEmoji({ id: '123', name: 'star' });
expect(emojiMatches('<:star:123>', emoji)).toBe(true);
expect(emojiMatches('star:123', emoji)).toBe(true);
});
});
describe('starboard shouldIgnoreMessage', () => {
const baseConfig = {
id: 'cfg',
guildId: 'g1',
enabled: true,
channelId: 'star-channel',
emoji: '⭐',
threshold: 3,
allowSelfStar: false,
ignoredChannelIds: ['ignored'],
createdAt: new Date(),
updatedAt: new Date()
};
it('ignores bot authors', () => {
const message = {
author: { bot: true },
guild: { id: 'g1' },
channel: {
id: 'c1',
isTextBased: () => true,
isDMBased: () => false,
nsfw: false
}
} as unknown as import('discord.js').Message;
expect(shouldIgnoreMessage(message, baseConfig)).toBe(true);
});
it('ignores the starboard channel itself', () => {
const message = {
author: { bot: false, id: 'u1' },
guild: { id: 'g1' },
channel: {
id: 'star-channel',
isTextBased: () => true,
isDMBased: () => false,
nsfw: false
}
} as unknown as import('discord.js').Message;
expect(shouldIgnoreMessage(message, baseConfig)).toBe(true);
});
it('ignores listed channels', () => {
const message = {
author: { bot: false, id: 'u1' },
guild: { id: 'g1' },
channel: {
id: 'ignored',
isTextBased: () => true,
isDMBased: () => false,
nsfw: false
}
} as unknown as import('discord.js').Message;
expect(shouldIgnoreMessage(message, baseConfig)).toBe(true);
});
});

View File

@@ -1,14 +1,17 @@
import {
ChannelType,
EmbedBuilder,
PermissionFlagsBits,
type Emoji,
type GuildBasedChannel,
type Message,
type PartialMessage,
type PartialMessageReaction,
type MessageReaction,
type TextChannel
} from 'discord.js';
import type { PrismaClient, StarboardConfig } from '@prisma/client';
import { t } from '@nexumi/shared';
import type { Locale } from '@nexumi/shared';
import { t, type Locale } from '@nexumi/shared';
import { ensureGuild } from '../../guild.js';
import { logger } from '../../logger.js';
import type { BotContext } from '../../types.js';
@@ -73,6 +76,27 @@ export function shouldIgnoreMessage(message: Message, config: StarboardConfig):
return false;
}
export function botCanPostStarboard(channel: GuildBasedChannel, meId: string): channel is TextChannel {
if (!channel.isTextBased() || channel.isDMBased()) {
return false;
}
if (
channel.type !== ChannelType.GuildText &&
channel.type !== ChannelType.GuildAnnouncement
) {
return false;
}
const permissions = channel.permissionsFor(meId);
if (!permissions) {
return false;
}
return (
permissions.has(PermissionFlagsBits.ViewChannel) &&
permissions.has(PermissionFlagsBits.SendMessages) &&
permissions.has(PermissionFlagsBits.EmbedLinks)
);
}
export async function countMatchingStars(
message: Message,
config: StarboardConfig
@@ -109,6 +133,10 @@ function resolveImageUrl(message: Message): string | undefined {
return embedImage ?? undefined;
}
function authorDisplayName(message: Message): string {
return message.member?.displayName || message.author.displayName || message.author.username;
}
export function buildStarboardEmbed(
message: Message,
starCount: number,
@@ -119,7 +147,7 @@ export function buildStarboardEmbed(
const embed = new EmbedBuilder()
.setColor(0x6366f1)
.setAuthor({
name: message.author.tag,
name: authorDisplayName(message),
iconURL: message.author.displayAvatarURL()
})
.setDescription(content.slice(0, 4096))
@@ -143,6 +171,17 @@ export function buildStarboardEmbed(
return embed;
}
async function fetchStarboardTextChannel(
context: BotContext,
channelId: string
): Promise<TextChannel | null> {
const channel = await context.client.channels.fetch(channelId).catch(() => null);
if (!channel || !channel.isTextBased() || channel.isDMBased()) {
return null;
}
return channel as TextChannel;
}
async function removeStarboardEntry(context: BotContext, entryId: string): Promise<void> {
const entry = await context.prisma.starboardEntry.findUnique({ where: { id: entryId } });
if (!entry) {
@@ -152,10 +191,14 @@ async function removeStarboardEntry(context: BotContext, entryId: string): Promi
try {
const config = await getStarboardConfig(context.prisma, entry.guildId);
if (config.channelId) {
const channel = (await context.client.channels.fetch(config.channelId)) as TextChannel;
const starboardMessage = await channel.messages.fetch(entry.starboardMessageId).catch(() => null);
const channel = await fetchStarboardTextChannel(context, config.channelId);
if (channel) {
const starboardMessage = await channel.messages
.fetch(entry.starboardMessageId)
.catch(() => null);
await starboardMessage?.delete().catch(() => undefined);
}
}
} catch (error) {
logger.warn({ error, entryId }, 'Failed to delete starboard message');
}
@@ -163,6 +206,82 @@ async function removeStarboardEntry(context: BotContext, entryId: string): Promi
await context.prisma.starboardEntry.delete({ where: { id: entryId } }).catch(() => undefined);
}
export async function removeStarboardEntryBySource(
context: BotContext,
guildId: string,
sourceMessageId: string
): Promise<void> {
const entry = await context.prisma.starboardEntry.findUnique({
where: {
guildId_sourceMessageId: { guildId, sourceMessageId }
}
});
if (entry) {
await removeStarboardEntry(context, entry.id);
}
}
async function createOrRecreateStarboardPost(
context: BotContext,
message: Message,
config: StarboardConfig,
locale: Locale,
starCount: number,
starboardChannel: TextChannel,
existingId?: string
): Promise<void> {
const embed = buildStarboardEmbed(message, starCount, config, locale);
const starboardMessage = await starboardChannel.send({ embeds: [embed] });
if (existingId) {
await context.prisma.starboardEntry.update({
where: { id: existingId },
data: {
starboardMessageId: starboardMessage.id,
starCount,
sourceChannelId: message.channel.id
}
});
return;
}
try {
await context.prisma.starboardEntry.create({
data: {
guildId: message.guild!.id,
sourceChannelId: message.channel.id,
sourceMessageId: message.id,
starboardMessageId: starboardMessage.id,
starCount
}
});
} catch (error) {
// Race: another reaction created the row first — keep the newer post, drop ours.
logger.warn({ error, messageId: message.id }, 'Starboard entry create raced; cleaning up');
await starboardMessage.delete().catch(() => undefined);
const winner = await context.prisma.starboardEntry.findUnique({
where: {
guildId_sourceMessageId: {
guildId: message.guild!.id,
sourceMessageId: message.id
}
}
});
if (winner) {
await context.prisma.starboardEntry.update({
where: { id: winner.id },
data: { starCount }
});
const existingMsg = await starboardChannel.messages
.fetch(winner.starboardMessageId)
.catch(() => null);
if (existingMsg) {
await existingMsg.edit({ embeds: [embed] }).catch(() => undefined);
}
}
}
}
export async function processStarboardMessage(context: BotContext, message: Message): Promise<void> {
if (!message.guild) {
return;
@@ -194,8 +313,13 @@ export async function processStarboardMessage(context: BotContext, message: Mess
return;
}
const starboardChannel = await fetchStarboardTextChannel(context, config.channelId);
if (!starboardChannel) {
logger.warn({ guildId: message.guild.id, channelId: config.channelId }, 'Starboard channel missing');
return;
}
const embed = buildStarboardEmbed(message, starCount, config, locale);
const starboardChannel = (await context.client.channels.fetch(config.channelId)) as TextChannel;
if (existing) {
try {
@@ -206,21 +330,28 @@ export async function processStarboardMessage(context: BotContext, message: Mess
data: { starCount }
});
} catch (error) {
logger.warn({ error, messageId: message.id }, 'Failed to update starboard message');
logger.warn({ error, messageId: message.id }, 'Starboard message missing; recreating');
await createOrRecreateStarboardPost(
context,
message,
config,
locale,
starCount,
starboardChannel,
existing.id
);
}
return;
}
const starboardMessage = await starboardChannel.send({ embeds: [embed] });
await context.prisma.starboardEntry.create({
data: {
guildId: message.guild.id,
sourceChannelId: message.channel.id,
sourceMessageId: message.id,
starboardMessageId: starboardMessage.id,
starCount
}
});
await createOrRecreateStarboardPost(
context,
message,
config,
locale,
starCount,
starboardChannel
);
}
export async function processStarboardReaction(
@@ -249,3 +380,41 @@ export async function processStarboardReaction(
await processStarboardMessage(context, message as Message);
}
export async function processStarboardSourceDelete(
context: BotContext,
message: Message | PartialMessage
): Promise<void> {
if (!message.guildId || !message.id) {
return;
}
await removeStarboardEntryBySource(context, message.guildId, message.id);
}
export async function processStarboardSourceUpdate(
context: BotContext,
message: Message | PartialMessage
): Promise<void> {
if (!message.guildId || !message.id) {
return;
}
const entry = await context.prisma.starboardEntry.findUnique({
where: {
guildId_sourceMessageId: {
guildId: message.guildId,
sourceMessageId: message.id
}
}
});
if (!entry) {
return;
}
const full = message.partial ? await message.fetch().catch(() => null) : message;
if (!full || !full.guild) {
return;
}
await processStarboardMessage(context, full as Message);
}

View File

@@ -7,8 +7,8 @@ import { invitesCommandData, statsCommandData } from './command-definitions.js';
import { getInviteLeaderboard, getInviteStatsForUser } from './invites.js';
import {
getStatsConfig,
refreshGuildStatsChannels,
StatsSetupSchema,
updateStatsChannels,
upsertStatsConfig
} from './service.js';
@@ -47,7 +47,7 @@ const statsCommand: SlashCommand = {
}
await upsertStatsConfig(context.prisma, guildId, parsed.data);
await updateStatsChannels(context);
await refreshGuildStatsChannels(context, guildId);
await interaction.reply({ content: t(locale, 'stats.setup.done'), ephemeral: true });
return;
@@ -64,7 +64,7 @@ const statsCommand: SlashCommand = {
return;
}
await updateStatsChannels(context);
await refreshGuildStatsChannels(context, guildId);
await interaction.reply({ content: t(locale, 'stats.refresh.done'), ephemeral: true });
}
}

View File

@@ -4,6 +4,9 @@ export {
ensureStatsRecurringJobs,
startStatsWorker,
updateStatsChannels,
refreshGuildStatsChannels,
runStatsGuildRefreshJob,
STATS_REFRESH_CRON,
STATS_REFRESH_JOB_NAME
STATS_REFRESH_JOB_NAME,
STATS_GUILD_REFRESH_JOB_NAME
} from './service.js';

View File

@@ -21,6 +21,8 @@ export type StatsSetupInput = z.infer<typeof StatsSetupSchema>;
export const STATS_REFRESH_CRON = '*/10 * * * *';
export const STATS_REFRESH_JOB_NAME = 'statsRefresh';
/** One-shot refresh for a single guild (dashboard save / slash refresh). */
export const STATS_GUILD_REFRESH_JOB_NAME = 'statsRefreshGuild';
export async function getStatsConfig(prisma: BotContext['prisma'], guildId: string) {
await ensureGuild(prisma, guildId);
@@ -97,13 +99,17 @@ async function renameStatsChannel(
});
}
async function refreshGuildStatsChannels(context: BotContext, guildId: string): Promise<void> {
export async function refreshGuildStatsChannels(context: BotContext, guildId: string): Promise<void> {
const config = await context.prisma.statsConfig.findUnique({ where: { guildId } });
if (!config?.enabled) {
return;
}
const guild = await context.client.guilds.fetch(guildId).catch(() => null);
// withCounts populates approximatePresenceCount / approximateMemberCount.
// GuildPresences is not privileged for us — presence cache would stay empty.
const guild = await context.client.guilds
.fetch({ guild: guildId, withCounts: true })
.catch(() => null);
if (!guild) {
return;
}
@@ -114,9 +120,7 @@ async function refreshGuildStatsChannels(context: BotContext, guildId: string):
return;
}
await guild.members.fetch().catch(() => undefined);
const memberCount = guild.memberCount;
const memberCount = guild.approximateMemberCount ?? guild.memberCount;
const onlineCount = estimateOnlineCount(guild);
const boostCount = guild.premiumSubscriptionCount ?? 0;
@@ -127,6 +131,13 @@ async function refreshGuildStatsChannels(context: BotContext, guildId: string):
]);
}
export async function runStatsGuildRefreshJob(
context: BotContext,
data: { guildId: string }
): Promise<void> {
await refreshGuildStatsChannels(context, data.guildId);
}
export async function updateStatsChannels(context: BotContext): Promise<void> {
const configs = await context.prisma.statsConfig.findMany({
where: { enabled: true }
@@ -159,6 +170,15 @@ export function startStatsWorker(context: BotContext): Worker {
async (job) => {
if (job.name === STATS_REFRESH_JOB_NAME) {
await updateStatsChannels(context);
return;
}
if (job.name === STATS_GUILD_REFRESH_JOB_NAME) {
const guildId = typeof job.data?.guildId === 'string' ? job.data.guildId : null;
if (!guildId) {
logger.warn({ jobId: job.id }, 'statsRefreshGuild missing guildId');
return;
}
await runStatsGuildRefreshJob(context, { guildId });
}
},
{ connection: redis }

View File

@@ -1,9 +1,10 @@
import { applyCommandDescription, applyOptionDescription, localizedChoice } from '@nexumi/shared';
import { SlashCommandBuilder } from 'discord.js';
import { PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
export const tagCommandData = applyCommandDescription(
new SlashCommandBuilder()
.setName('tag')
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild)
.addSubcommand((sub) =>
applyCommandDescription(sub.setName('create'), 'tag.create.description')
.addStringOption((o) =>

View File

@@ -172,6 +172,9 @@ const tagCommand: SlashCommand = {
tag.triggerWord
? tf(locale, 'tag.info.trigger', { word: tag.triggerWord })
: t(locale, 'tag.info.noTrigger'),
tag.cooldownSeconds > 0
? tf(locale, 'tag.info.cooldown', { seconds: tag.cooldownSeconds })
: t(locale, 'tag.info.noCooldown'),
tag.allowedRoleIds.length > 0
? tf(locale, 'tag.info.roles', { roles: tag.allowedRoleIds.map((id) => `<@&${id}>`).join(', ') })
: t(locale, 'tag.info.allRoles'),
@@ -189,8 +192,12 @@ const tagCommand: SlashCommand = {
}
} catch (error) {
if (error instanceof TagError) {
const content =
error.code === 'cooldown' && error.retryAfterSeconds !== undefined
? tf(locale, 'tag.error.cooldown', { seconds: error.retryAfterSeconds })
: t(locale, `tag.error.${error.code}`);
await interaction.reply({
content: t(locale, `tag.error.${error.code}`),
content,
ephemeral: true
});
return;

View File

@@ -4,8 +4,10 @@ import type { BotContext } from '../../types.js';
import { getGuildLocale } from '../../i18n.js';
import { logger } from '../../logger.js';
import {
applyTagCooldown,
buildTagReply,
findTriggeredTag,
getTagCooldownRemaining,
memberCanUseTag,
TagError
} from './service.js';
@@ -31,6 +33,18 @@ export function registerTagEvents(client: Client, context: BotContext): void {
return;
}
// Channel-scoped cooldown: silent skip to avoid spam replies.
if (tag.cooldownSeconds > 0) {
const remaining = await getTagCooldownRemaining(
message.guild.id,
tag.id,
message.channel.id
);
if (remaining > 0) {
return;
}
}
await context.prisma.tag.update({
where: { id: tag.id },
data: { useCount: { increment: 1 } }
@@ -38,6 +52,7 @@ export function registerTagEvents(client: Client, context: BotContext): void {
const payload = buildTagReply(tag, member, message.guild, '');
await message.reply(payload);
await applyTagCooldown(message.guild.id, tag.id, message.channel.id, tag.cooldownSeconds);
} catch (error) {
if (error instanceof TagError) {
const locale = await getGuildLocale(context.prisma, message.guild?.id ?? null);

View File

@@ -14,15 +14,44 @@ import type { Tag } from '@prisma/client';
import { ensureGuild } from '../../guild.js';
import { buildEmbedFromPayload } from '../../lib/embed-payload.js';
import { buildComponentsV2Payload } from '../../lib/components-v2-payload.js';
import { redis } from '../../redis.js';
import type { BotContext } from '../../types.js';
import { assertPremiumLimit, PremiumLimitError } from '../../premium.js';
import { matchesTriggerWord, tagCooldownKey } from './tag-helpers.js';
export { matchesTriggerWord, tagCooldownKey } from './tag-helpers.js';
export class TagError extends Error {
constructor(public readonly code: string) {
constructor(
public readonly code: string,
public readonly retryAfterSeconds?: number
) {
super(code);
}
}
/** Returns remaining cooldown seconds, or 0 if ready. */
export async function getTagCooldownRemaining(
guildId: string,
tagId: string,
scopeId: string
): Promise<number> {
const ttl = await redis.ttl(tagCooldownKey(guildId, tagId, scopeId));
return ttl > 0 ? ttl : 0;
}
export async function applyTagCooldown(
guildId: string,
tagId: string,
scopeId: string,
cooldownSeconds: number
): Promise<void> {
if (cooldownSeconds <= 0) {
return;
}
await redis.set(tagCooldownKey(guildId, tagId, scopeId), '1', 'EX', cooldownSeconds);
}
export function normalizeTagName(name: string): string {
return name.trim().toLowerCase();
}
@@ -147,6 +176,7 @@ export async function createTag(
embed?: WelcomeEmbed | null;
components?: MessageComponentsV2 | null;
triggerWord?: string | null;
cooldownSeconds?: number;
allowedRoleIds?: string[];
allowedChannelIds?: string[];
createdById: string;
@@ -190,6 +220,7 @@ export async function createTag(
embed: params.embed ?? undefined,
components: params.components ?? undefined,
triggerWord: params.triggerWord?.trim() || null,
cooldownSeconds: params.cooldownSeconds ?? 15,
allowedRoleIds: params.allowedRoleIds ?? [],
allowedChannelIds: params.allowedChannelIds ?? [],
createdById: params.createdById
@@ -208,6 +239,7 @@ export async function updateTag(
embed?: WelcomeEmbed | null;
components?: MessageComponentsV2 | null;
triggerWord?: string | null;
cooldownSeconds?: number;
allowedRoleIds?: string[];
allowedChannelIds?: string[];
}
@@ -255,6 +287,8 @@ export async function updateTag(
components: components ?? undefined,
triggerWord:
updates.triggerWord !== undefined ? updates.triggerWord?.trim() || null : existing.triggerWord,
cooldownSeconds:
updates.cooldownSeconds !== undefined ? updates.cooldownSeconds : existing.cooldownSeconds,
allowedRoleIds: updates.allowedRoleIds ?? existing.allowedRoleIds,
allowedChannelIds: updates.allowedChannelIds ?? existing.allowedChannelIds
}
@@ -294,18 +328,22 @@ export async function executeTag(
throw new TagError('not_allowed');
}
const scopeId = member?.id ?? channelId;
if (tag.cooldownSeconds > 0) {
const remaining = await getTagCooldownRemaining(guildId, tag.id, scopeId);
if (remaining > 0) {
throw new TagError('cooldown', remaining);
}
}
await context.prisma.tag.update({
where: { id: tag.id },
data: { useCount: { increment: 1 } }
});
return buildTagReply(tag, member, guild, args);
}
await applyTagCooldown(guildId, tag.id, scopeId, tag.cooldownSeconds);
export function matchesTriggerWord(content: string, triggerWord: string): boolean {
const escaped = triggerWord.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const pattern = new RegExp(`\\b${escaped}\\b`, 'i');
return pattern.test(content);
return buildTagReply(tag, member, guild, args);
}
export async function findTriggeredTag(

View File

@@ -0,0 +1,15 @@
import { describe, expect, it } from 'vitest';
import { matchesTriggerWord, tagCooldownKey } from './tag-helpers.js';
describe('tagCooldownKey', () => {
it('builds a stable redis key', () => {
expect(tagCooldownKey('g1', 't1', 'c1')).toBe('tag:cooldown:g1:t1:c1');
});
});
describe('matchesTriggerWord', () => {
it('matches whole words case-insensitively', () => {
expect(matchesTriggerWord('Hello world', 'hello')).toBe(true);
expect(matchesTriggerWord('say helloworld', 'hello')).toBe(false);
});
});

View File

@@ -0,0 +1,10 @@
/** Redis key for tag cooldowns (guild + tag + user or channel scope). */
export function tagCooldownKey(guildId: string, tagId: string, scopeId: string): string {
return `tag:cooldown:${guildId}:${tagId}:${scopeId}`;
}
export function matchesTriggerWord(content: string, triggerWord: string): boolean {
const escaped = triggerWord.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const pattern = new RegExp(`\\b${escaped}\\b`, 'i');
return pattern.test(content);
}

View File

@@ -3,13 +3,14 @@ import {
applyOptionDescription,
localizedChoice
} from '@nexumi/shared';
import { SlashCommandBuilder } from 'discord.js';
import { PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
// Discord forbids mixing SUB_COMMAND_GROUP and SUB_COMMAND siblings.
// SPEC `/ticket panel create` is represented as subcommand `panel_create`.
export const ticketCommandData = applyCommandDescription(
new SlashCommandBuilder()
.setName('ticket')
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild)
.addSubcommand((s) =>
applyCommandDescription(s.setName('panel_create'), 'ticket.panel.create.description')
.addStringOption((o) =>

View File

@@ -3,12 +3,11 @@ import { t, tf } from '@nexumi/shared';
import type { SlashCommand } from '../../types.js';
import { getGuildLocale } from '../../i18n.js';
import { requirePermission } from '../../permissions.js';
import { ephemeral } from '../../interaction-reply.js';
import { ticketCommandData } from './command-definitions.js';
import {
addUserToTicket,
assertTicketStaff,
buildPanelComponents,
buildPanelEmbed,
claimTicket,
closeTicket,
createCategoryWithRole,
@@ -17,7 +16,9 @@ import {
removeUserFromTicket,
renameTicket,
setTicketPriority,
syncTicketPanel,
TicketError,
TicketPanelError,
upsertCategoryByName
} from './service.js';
@@ -26,7 +27,7 @@ async function ensureManageGuild(
locale: 'de' | 'en'
): Promise<boolean> {
if (!interaction.inGuild()) {
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ...ephemeral() });
return false;
}
return requirePermission(interaction, PermissionFlagsBits.ManageGuild, locale);
@@ -37,7 +38,7 @@ const ticketCommand: SlashCommand = {
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!interaction.inGuild()) {
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ...ephemeral() });
return;
}
@@ -54,7 +55,10 @@ const ticketCommand: SlashCommand = {
const categories = await getGuildCategories(context, interaction.guildId!);
if (categories.length === 0) {
await interaction.reply({ content: t(locale, 'ticket.error.no_categories'), ephemeral: true });
await interaction.reply({
content: t(locale, 'ticket.error.no_categories'),
...ephemeral()
});
return;
}
@@ -62,15 +66,31 @@ const ticketCommand: SlashCommand = {
if (!channel?.isTextBased() || channel.isDMBased()) {
await interaction.reply({
content: t(locale, 'ticket.error.invalid_channel'),
ephemeral: true
...ephemeral()
});
return;
}
const embed = buildPanelEmbed(locale, name, description);
const components = buildPanelComponents(categories);
await channel.send({ embeds: [embed], components });
await interaction.reply({ content: t(locale, 'ticket.panel.created'), ephemeral: true });
try {
await syncTicketPanel(context, interaction.guildId!, {
channelId: channel.id,
title: name,
description
});
await interaction.reply({ content: t(locale, 'ticket.panel.created'), ...ephemeral() });
} catch (error) {
if (error instanceof TicketPanelError) {
const key =
error.code === 'no_categories'
? 'ticket.error.no_categories'
: error.code === 'not_configured'
? 'ticket.error.invalid_channel'
: 'ticket.error.missing_permission';
await interaction.reply({ content: t(locale, key), ...ephemeral() });
return;
}
throw error;
}
return;
}
@@ -89,18 +109,28 @@ const ticketCommand: SlashCommand = {
);
await interaction.reply({
content: tf(locale, 'ticket.category.created', { name: category.name }),
ephemeral: true
...ephemeral()
});
return;
}
const ticket = await findTicketByChannel(context.prisma, interaction.channelId!);
if (!ticket) {
await interaction.reply({ content: t(locale, 'ticket.error.not_in_ticket'), ephemeral: true });
await interaction.reply({
content: t(locale, 'ticket.error.not_in_ticket'),
...ephemeral()
});
return;
}
const member = await interaction.guild!.members.fetch(interaction.user.id);
const member = await interaction.guild!.members.fetch(interaction.user.id).catch(() => null);
if (!member) {
await interaction.reply({
content: t(locale, 'ticket.error.member_not_found'),
...ephemeral()
});
return;
}
try {
if (sub === 'close') {
@@ -115,40 +145,57 @@ const ticketCommand: SlashCommand = {
const isOpener = ticket.openerId === interaction.user.id;
if (!isStaff && !isOpener) {
await interaction.reply({ content: t(locale, 'ticket.error.not_staff'), ephemeral: true });
await interaction.reply({
content: t(locale, 'ticket.error.not_staff'),
...ephemeral()
});
return;
}
const reason = interaction.options.getString('reason') ?? undefined;
await closeTicket(context, ticket, interaction.user.id, locale, reason);
await interaction.reply({ content: t(locale, 'ticket.close.success'), ephemeral: true });
await interaction.reply({ content: t(locale, 'ticket.close.success'), ...ephemeral() });
return;
}
if (sub === 'claim') {
await claimTicket(context, ticket, member, locale);
await interaction.reply({ content: t(locale, 'ticket.claim.success'), ephemeral: true });
await interaction.reply({ content: t(locale, 'ticket.claim.success'), ...ephemeral() });
return;
}
if (sub === 'add') {
const user = interaction.options.getUser('user', true);
const target = await interaction.guild!.members.fetch(user.id);
const target = await interaction.guild!.members.fetch(user.id).catch(() => null);
if (!target) {
await interaction.reply({
content: t(locale, 'ticket.error.member_not_found'),
...ephemeral()
});
return;
}
await addUserToTicket(context, ticket, member, target, locale);
await interaction.reply({
content: tf(locale, 'ticket.add.success', { user: `<@${user.id}>` }),
ephemeral: true
...ephemeral()
});
return;
}
if (sub === 'remove') {
const user = interaction.options.getUser('user', true);
const target = await interaction.guild!.members.fetch(user.id);
const target = await interaction.guild!.members.fetch(user.id).catch(() => null);
if (!target) {
await interaction.reply({
content: t(locale, 'ticket.error.member_not_found'),
...ephemeral()
});
return;
}
await removeUserFromTicket(context, ticket, member, target, locale);
await interaction.reply({
content: tf(locale, 'ticket.remove.success', { user: `<@${user.id}>` }),
ephemeral: true
...ephemeral()
});
return;
}
@@ -158,7 +205,7 @@ const ticketCommand: SlashCommand = {
await renameTicket(context, ticket, member, name, locale);
await interaction.reply({
content: tf(locale, 'ticket.rename.success', { name }),
ephemeral: true
...ephemeral()
});
return;
}
@@ -170,14 +217,14 @@ const ticketCommand: SlashCommand = {
content: tf(locale, 'ticket.priority.success', {
priority: t(locale, `ticket.priority.${level}`)
}),
ephemeral: true
...ephemeral()
});
}
} catch (error) {
if (error instanceof TicketError) {
await interaction.reply({
content: t(locale, `ticket.error.${error.code}`),
ephemeral: true
...ephemeral()
});
return;
}

View File

@@ -1,4 +1,11 @@
export { ticketsCommands } from './commands.js';
export { isTicketInteraction, handleTicketInteraction } from './interactions.js';
export { closeInactiveTickets, checkInactiveTickets } from './service.js';
export {
closeInactiveTickets,
checkInactiveTickets,
runTicketPanelSyncJob,
runTicketDashboardActionJob,
TicketPanelError
} from './service.js';
export { registerTicketEvents } from './events.js';

View File

@@ -1,17 +1,32 @@
import type {
ButtonInteraction,
ModalSubmitInteraction,
StringSelectMenuInteraction
StringSelectMenuInteraction,
UserSelectMenuInteraction
} from 'discord.js';
import { t, tf } from '@nexumi/shared';
import type { BotContext } from '../../types.js';
import { getGuildLocale } from '../../i18n.js';
import { ephemeral } from '../../interaction-reply.js';
import {
addUserToTicket,
assertTicketStaff,
buildOpenModal,
claimTicket,
closeTicket,
findTicketByChannel,
isTicketControlInteraction,
openTicketForCategory,
parseFormFields,
parseRateButton,
rateTicket,
removeUserFromTicket,
setTicketPriority,
TICKET_CTRL_ADD,
TICKET_CTRL_CLAIM,
TICKET_CTRL_CLOSE,
TICKET_CTRL_PRIORITY,
TICKET_CTRL_REMOVE,
TICKET_MODAL_PREFIX,
TICKET_OPEN_PREFIX,
TICKET_RATE_PREFIX,
@@ -24,37 +39,45 @@ export function isTicketInteraction(customId: string): boolean {
customId.startsWith(TICKET_OPEN_PREFIX) ||
customId === TICKET_SELECT_ID ||
customId.startsWith(TICKET_MODAL_PREFIX) ||
customId.startsWith(TICKET_RATE_PREFIX)
customId.startsWith(TICKET_RATE_PREFIX) ||
isTicketControlInteraction(customId)
);
}
async function replyTicketError(
interaction: ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction,
interaction:
| ButtonInteraction
| StringSelectMenuInteraction
| ModalSubmitInteraction
| UserSelectMenuInteraction,
locale: 'de' | 'en',
code: string
): Promise<void> {
const content = t(locale, `ticket.error.${code}`);
if (interaction.replied || interaction.deferred) {
await interaction.followUp({ content, ephemeral: true });
await interaction.followUp({ content, ...ephemeral() });
} else {
await interaction.reply({ content, ephemeral: true });
await interaction.reply({ content, ...ephemeral() });
}
}
export async function handleOpenTicketRequest(
async function handleOpenTicketRequest(
interaction: ButtonInteraction | StringSelectMenuInteraction,
context: BotContext,
categoryId: string
): Promise<void> {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!interaction.inGuild() || !interaction.guild) {
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ...ephemeral() });
return;
}
const category = await context.prisma.ticketCategory.findUnique({ where: { id: categoryId } });
if (!category || category.guildId !== interaction.guildId) {
await interaction.reply({ content: t(locale, 'ticket.error.category_not_found'), ephemeral: true });
await interaction.reply({
content: t(locale, 'ticket.error.category_not_found'),
...ephemeral()
});
return;
}
@@ -65,7 +88,14 @@ export async function handleOpenTicketRequest(
}
try {
const member = await interaction.guild.members.fetch(interaction.user.id);
const member = await interaction.guild.members.fetch(interaction.user.id).catch(() => null);
if (!member) {
await interaction.reply({
content: t(locale, 'ticket.error.member_not_found'),
...ephemeral()
});
return;
}
const ticket = await openTicketForCategory(
context,
interaction.guild,
@@ -76,7 +106,7 @@ export async function handleOpenTicketRequest(
const channelRef = ticket.channelId ?? ticket.threadId;
await interaction.reply({
content: tf(locale, 'ticket.created', { channel: `<#${channelRef}>` }),
ephemeral: true
...ephemeral()
});
} catch (error) {
if (error instanceof TicketError) {
@@ -87,8 +117,131 @@ export async function handleOpenTicketRequest(
}
}
async function handleTicketControl(
interaction: ButtonInteraction | UserSelectMenuInteraction | StringSelectMenuInteraction,
context: BotContext
): Promise<void> {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!interaction.inGuild() || !interaction.guild) {
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ...ephemeral() });
return;
}
const ticket = await findTicketByChannel(context.prisma, interaction.channelId);
if (!ticket) {
await interaction.reply({ content: t(locale, 'ticket.error.not_in_ticket'), ...ephemeral() });
return;
}
const member = await interaction.guild.members.fetch(interaction.user.id).catch(() => null);
if (!member) {
await interaction.reply({
content: t(locale, 'ticket.error.member_not_found'),
...ephemeral()
});
return;
}
try {
if (interaction.isButton() && interaction.customId === TICKET_CTRL_CLOSE) {
const isOpener = ticket.openerId === interaction.user.id;
let isStaff = false;
try {
await assertTicketStaff(member, ticket);
isStaff = true;
} catch {
isStaff = false;
}
if (!isStaff && !isOpener) {
await interaction.reply({ content: t(locale, 'ticket.error.not_staff'), ...ephemeral() });
return;
}
await interaction.deferReply(ephemeral());
await closeTicket(context, ticket, interaction.user.id, locale);
await interaction.editReply({ content: t(locale, 'ticket.close.success') });
return;
}
if (interaction.isButton() && interaction.customId === TICKET_CTRL_CLAIM) {
await claimTicket(context, ticket, member, locale);
await interaction.reply({ content: t(locale, 'ticket.claim.success'), ...ephemeral() });
return;
}
if (interaction.isStringSelectMenu() && interaction.customId === TICKET_CTRL_PRIORITY) {
const level = interaction.values[0];
if (!level) {
await interaction.reply({ content: t(locale, 'ticket.error.not_found'), ...ephemeral() });
return;
}
await setTicketPriority(context, ticket, member, level, locale);
await interaction.reply({
content: tf(locale, 'ticket.priority.success', {
priority: t(locale, `ticket.priority.${level}`)
}),
...ephemeral()
});
return;
}
if (interaction.isUserSelectMenu() && interaction.customId === TICKET_CTRL_ADD) {
const userId = interaction.values[0];
if (!userId) {
await interaction.reply({ content: t(locale, 'ticket.error.not_found'), ...ephemeral() });
return;
}
const target = await interaction.guild.members.fetch(userId).catch(() => null);
if (!target) {
await interaction.reply({
content: t(locale, 'ticket.error.member_not_found'),
...ephemeral()
});
return;
}
await addUserToTicket(context, ticket, member, target, locale);
await interaction.reply({
content: tf(locale, 'ticket.add.success', { user: `<@${userId}>` }),
...ephemeral()
});
return;
}
if (interaction.isUserSelectMenu() && interaction.customId === TICKET_CTRL_REMOVE) {
const userId = interaction.values[0];
if (!userId) {
await interaction.reply({ content: t(locale, 'ticket.error.not_found'), ...ephemeral() });
return;
}
const target = await interaction.guild.members.fetch(userId).catch(() => null);
if (!target) {
await interaction.reply({
content: t(locale, 'ticket.error.member_not_found'),
...ephemeral()
});
return;
}
await removeUserFromTicket(context, ticket, member, target, locale);
await interaction.reply({
content: tf(locale, 'ticket.remove.success', { user: `<@${userId}>` }),
...ephemeral()
});
}
} catch (error) {
if (error instanceof TicketError) {
await replyTicketError(interaction, locale, error.code);
return;
}
throw error;
}
}
export async function handleTicketInteraction(
interaction: ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction,
interaction:
| ButtonInteraction
| StringSelectMenuInteraction
| ModalSubmitInteraction
| UserSelectMenuInteraction,
context: BotContext
): Promise<void> {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
@@ -96,13 +249,27 @@ export async function handleTicketInteraction(
if (interaction.isStringSelectMenu() && interaction.customId === TICKET_SELECT_ID) {
const categoryId = interaction.values[0];
if (!categoryId) {
await interaction.reply({ content: t(locale, 'ticket.error.category_not_found'), ephemeral: true });
await interaction.reply({
content: t(locale, 'ticket.error.category_not_found'),
...ephemeral()
});
return;
}
await handleOpenTicketRequest(interaction, context, categoryId);
return;
}
if (
interaction.isButton() ||
interaction.isUserSelectMenu() ||
interaction.isStringSelectMenu()
) {
if (isTicketControlInteraction(interaction.customId)) {
await handleTicketControl(interaction, context);
return;
}
}
if (interaction.isButton()) {
if (interaction.customId.startsWith(TICKET_OPEN_PREFIX)) {
const categoryId = interaction.customId.slice(TICKET_OPEN_PREFIX.length);
@@ -122,7 +289,7 @@ export async function handleTicketInteraction(
if (error instanceof TicketError) {
await interaction.reply({
content: t(locale, `ticket.error.${error.code}`),
ephemeral: true
...ephemeral()
});
return;
}
@@ -134,14 +301,17 @@ export async function handleTicketInteraction(
if (interaction.isModalSubmit() && interaction.customId.startsWith(TICKET_MODAL_PREFIX)) {
if (!interaction.inGuild() || !interaction.guild) {
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ...ephemeral() });
return;
}
const categoryId = interaction.customId.slice(TICKET_MODAL_PREFIX.length);
const category = await context.prisma.ticketCategory.findUnique({ where: { id: categoryId } });
if (!category) {
await interaction.reply({ content: t(locale, 'ticket.error.category_not_found'), ephemeral: true });
await interaction.reply({
content: t(locale, 'ticket.error.category_not_found'),
...ephemeral()
});
return;
}
@@ -156,7 +326,14 @@ export async function handleTicketInteraction(
}
try {
const member = await interaction.guild.members.fetch(interaction.user.id);
const member = await interaction.guild.members.fetch(interaction.user.id).catch(() => null);
if (!member) {
await interaction.reply({
content: t(locale, 'ticket.error.member_not_found'),
...ephemeral()
});
return;
}
const ticket = await openTicketForCategory(
context,
interaction.guild,
@@ -169,7 +346,7 @@ export async function handleTicketInteraction(
const channelRef = ticket.channelId ?? ticket.threadId;
await interaction.reply({
content: tf(locale, 'ticket.created', { channel: `<#${channelRef}>` }),
ephemeral: true
...ephemeral()
});
} catch (error) {
if (error instanceof TicketError) {

View File

@@ -10,9 +10,14 @@ import {
StringSelectMenuBuilder,
TextInputBuilder,
TextInputStyle,
UserSelectMenuBuilder,
type APIMessageComponentEmoji,
type Guild,
type GuildBasedChannel,
type GuildMember,
type GuildTextBasedChannel,
type Message,
type Role,
type SendableChannels,
type TextChannel,
type ThreadChannel
@@ -34,6 +39,14 @@ export const TICKET_OPEN_PREFIX = 'ticket:open:';
export const TICKET_SELECT_ID = 'ticket:select';
export const TICKET_MODAL_PREFIX = 'ticket:modal:';
export const TICKET_RATE_PREFIX = 'ticket:rate:';
export const TICKET_CTRL_CLAIM = 'ticket:ctrl:claim';
export const TICKET_CTRL_CLOSE = 'ticket:ctrl:close';
export const TICKET_CTRL_ADD = 'ticket:ctrl:add';
export const TICKET_CTRL_REMOVE = 'ticket:ctrl:remove';
export const TICKET_CTRL_PRIORITY = 'ticket:ctrl:priority';
/** Cap private-thread staff adds to stay within Discord rate limits. */
const MAX_THREAD_SUPPORT_MEMBERS = 50;
export const TicketFormFieldSchema = z.object({
id: z.string(),
@@ -51,6 +64,189 @@ export class TicketError extends Error {
}
}
export class TicketPanelError extends Error {
constructor(public readonly code: 'not_configured' | 'missing_permission' | 'no_categories') {
super(code);
this.name = 'TicketPanelError';
}
}
function isUnknownChannelError(error: unknown): boolean {
return Boolean(
error &&
typeof error === 'object' &&
'code' in error &&
(error as { code: unknown }).code === 10003
);
}
function botCanPostPanel(
channel: GuildBasedChannel,
meId: string
): channel is GuildTextBasedChannel {
if (!channel.isTextBased() || channel.isDMBased()) {
return false;
}
const permissions = channel.permissionsFor(meId);
if (!permissions) {
return false;
}
return (
permissions.has(PermissionFlagsBits.ViewChannel) &&
permissions.has(PermissionFlagsBits.SendMessages) &&
permissions.has(PermissionFlagsBits.EmbedLinks)
);
}
function parseButtonEmoji(emoji?: string | null): APIMessageComponentEmoji | undefined {
if (!emoji) {
return undefined;
}
const customMatch = emoji.match(/^<(a)?:(\w+):(\d+)>$/);
if (customMatch) {
return {
id: customMatch[3]!,
name: customMatch[2],
animated: Boolean(customMatch[1])
};
}
if (/^\d+$/.test(emoji)) {
return { id: emoji };
}
return { name: emoji };
}
/**
* `Role#members` only includes cached guild members. Fetch the member list when
* the cache looks empty so support staff can be added to private threads.
*/
async function resolveSupportMembers(guild: Guild, roleIds: string[]): Promise<GuildMember[]> {
if (roleIds.length === 0) {
return [];
}
const roles: Role[] = [];
for (const roleId of roleIds) {
const role =
guild.roles.cache.get(roleId) ?? (await guild.roles.fetch(roleId).catch(() => null));
if (role) {
roles.push(role);
}
}
if (roles.length === 0) {
return [];
}
const cachedCount = roles.reduce((sum, role) => sum + role.members.size, 0);
if (cachedCount === 0) {
await guild.members.fetch().catch((error) => {
logger.warn({ error, guildId: guild.id }, 'Failed to fetch members for ticket support roles');
});
}
const seen = new Set<string>();
const members: GuildMember[] = [];
for (const role of roles) {
for (const member of role.members.values()) {
if (member.user.bot || seen.has(member.id)) {
continue;
}
seen.add(member.id);
members.push(member);
if (members.length >= MAX_THREAD_SUPPORT_MEMBERS) {
return members;
}
}
}
return members;
}
export function isTicketControlInteraction(customId: string): boolean {
return (
customId === TICKET_CTRL_CLAIM ||
customId === TICKET_CTRL_CLOSE ||
customId === TICKET_CTRL_ADD ||
customId === TICKET_CTRL_REMOVE ||
customId === TICKET_CTRL_PRIORITY
);
}
export function buildTicketControlPanel(locale: 'de' | 'en'): {
embeds: EmbedBuilder[];
components: ActionRowBuilder<
ButtonBuilder | UserSelectMenuBuilder | StringSelectMenuBuilder
>[];
} {
const embed = new EmbedBuilder()
.setTitle(t(locale, 'ticket.control.title'))
.setDescription(t(locale, 'ticket.control.description'))
.setColor(0x6366f1);
const buttons = new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder()
.setCustomId(TICKET_CTRL_CLAIM)
.setLabel(t(locale, 'ticket.control.claim'))
.setStyle(ButtonStyle.Success),
new ButtonBuilder()
.setCustomId(TICKET_CTRL_CLOSE)
.setLabel(t(locale, 'ticket.control.close'))
.setStyle(ButtonStyle.Danger)
);
const priorityRow = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(
new StringSelectMenuBuilder()
.setCustomId(TICKET_CTRL_PRIORITY)
.setPlaceholder(t(locale, 'ticket.control.priorityPlaceholder'))
.addOptions(
{
label: t(locale, 'ticket.priority.LOW'),
value: 'LOW',
description: t(locale, 'ticket.control.priorityLowHint')
},
{
label: t(locale, 'ticket.priority.NORMAL'),
value: 'NORMAL',
description: t(locale, 'ticket.control.priorityNormalHint')
},
{
label: t(locale, 'ticket.priority.HIGH'),
value: 'HIGH',
description: t(locale, 'ticket.control.priorityHighHint')
},
{
label: t(locale, 'ticket.priority.URGENT'),
value: 'URGENT',
description: t(locale, 'ticket.control.priorityUrgentHint')
}
)
);
const addRow = new ActionRowBuilder<UserSelectMenuBuilder>().addComponents(
new UserSelectMenuBuilder()
.setCustomId(TICKET_CTRL_ADD)
.setPlaceholder(t(locale, 'ticket.control.addPlaceholder'))
.setMinValues(1)
.setMaxValues(1)
);
const removeRow = new ActionRowBuilder<UserSelectMenuBuilder>().addComponents(
new UserSelectMenuBuilder()
.setCustomId(TICKET_CTRL_REMOVE)
.setPlaceholder(t(locale, 'ticket.control.removePlaceholder'))
.setMinValues(1)
.setMaxValues(1)
);
return {
embeds: [embed],
components: [buttons, priorityRow, addRow, removeRow]
};
}
async function fetchSendableChannel(
context: BotContext,
channelId: string
@@ -185,8 +381,9 @@ export function buildPanelComponents(categories: TicketCategory[]): ActionRowBui
.setCustomId(openButtonId(category.id))
.setLabel(category.name.slice(0, 80))
.setStyle(ButtonStyle.Primary);
if (category.emoji) {
button.setEmoji(category.emoji);
const emoji = parseButtonEmoji(category.emoji);
if (emoji) {
button.setEmoji(emoji);
}
row.addComponents(button);
}
@@ -197,17 +394,208 @@ export function buildPanelComponents(categories: TicketCategory[]): ActionRowBui
.setCustomId(TICKET_SELECT_ID)
.setPlaceholder('Select a category')
.addOptions(
categories.slice(0, 25).map((category) => ({
categories.slice(0, 25).map((category) => {
const emoji = parseButtonEmoji(category.emoji);
return {
label: category.name.slice(0, 100),
value: category.id,
description: category.description?.slice(0, 100) ?? undefined,
emoji: category.emoji ? { name: category.emoji } : undefined
}))
emoji
};
})
);
return [new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(select)];
}
/**
* Posts or updates the ticket panel. Used by `/ticket panel_create` and by the
* dashboard via BullMQ (`ticketPanelSync`).
*/
export async function syncTicketPanel(
context: BotContext,
guildId: string,
options: {
channelId?: string | null;
title?: string;
description?: string | null;
} = {}
): Promise<{ panelChannelId: string; panelMessageId: string }> {
const locale = await getGuildLocale(context.prisma, guildId);
const config = await getTicketConfig(context.prisma, guildId);
const categories = await getGuildCategories(context, guildId);
if (categories.length === 0) {
throw new TicketPanelError('no_categories');
}
const targetChannelId = options.channelId || config.panelChannelId;
if (!targetChannelId) {
throw new TicketPanelError('not_configured');
}
const guild = await context.client.guilds.fetch(guildId);
const me = guild.members.me;
if (!me) {
throw new TicketPanelError('missing_permission');
}
const channel = await guild.channels.fetch(targetChannelId).catch(() => null);
if (!channel || !botCanPostPanel(channel, me.id)) {
throw new TicketPanelError('missing_permission');
}
const title = options.title ?? t(locale, 'ticket.panel.defaultTitle');
const description =
options.description !== undefined
? options.description
: t(locale, 'ticket.panel.defaultDescription');
const payload = {
embeds: [buildPanelEmbed(locale, title, description)],
components: buildPanelComponents(categories)
};
const sameChannel =
Boolean(config.panelMessageId) && config.panelChannelId === channel.id;
if (sameChannel && config.panelMessageId) {
try {
const existing = await channel.messages.fetch(config.panelMessageId);
await existing.edit(payload);
if (!config.panelChannelId) {
await context.prisma.ticketConfig.update({
where: { guildId },
data: { panelChannelId: channel.id }
});
}
return { panelChannelId: channel.id, panelMessageId: existing.id };
} catch (error) {
logger.warn(
{ error, guildId, messageId: config.panelMessageId },
'Failed to edit ticket panel; recreating'
);
}
}
if (config.panelMessageId && config.panelChannelId && config.panelChannelId !== channel.id) {
try {
const oldChannel = await guild.channels.fetch(config.panelChannelId);
if (oldChannel?.isTextBased() && !oldChannel.isDMBased()) {
const oldMessage = await oldChannel.messages.fetch(config.panelMessageId);
await oldMessage.delete();
}
} catch {
// Previous panel may already be gone
}
}
const message = await channel.send(payload);
await context.prisma.ticketConfig.update({
where: { guildId },
data: {
panelChannelId: channel.id,
panelMessageId: message.id
}
});
return { panelChannelId: channel.id, panelMessageId: message.id };
}
export async function runTicketPanelSyncJob(
context: BotContext,
data: { guildId: string }
): Promise<{ panelChannelId: string; panelMessageId: string }> {
return syncTicketPanel(context, data.guildId);
}
/**
* Dashboard ticket actions (close / claim / priority). Staff checks are done by
* the WebUI (`requireGuildAccess`); the bot applies Discord side effects.
*/
export async function runTicketDashboardActionJob(
context: BotContext,
data: {
ticketId: string;
guildId: string;
actorUserId: string;
action: 'close' | 'claim' | 'priority';
reason?: string;
priority?: string;
}
): Promise<{ id: string; status: string; priority: string; claimedById: string | null }> {
const ticket = await context.prisma.ticket.findFirst({
where: { id: data.ticketId, guildId: data.guildId },
include: { category: true }
});
if (!ticket) {
throw new TicketError('not_found');
}
if (ticket.status === 'CLOSED') {
throw new TicketError('closed');
}
const locale = await getGuildLocale(context.prisma, data.guildId);
if (data.action === 'close') {
await closeTicket(context, ticket, data.actorUserId, locale, data.reason);
const closed = await context.prisma.ticket.findUniqueOrThrow({ where: { id: ticket.id } });
return {
id: closed.id,
status: closed.status,
priority: closed.priority,
claimedById: closed.claimedById
};
}
if (data.action === 'claim') {
if (ticket.claimedById === data.actorUserId) {
throw new TicketError('already_claimed');
}
const updated = await context.prisma.ticket.update({
where: { id: ticket.id },
data: {
claimedById: data.actorUserId,
status: 'CLAIMED',
lastActivityAt: new Date()
}
});
const channelId = ticket.channelId ?? ticket.threadId;
if (channelId) {
const channel = await fetchSendableChannel(context, channelId);
if (channel) {
await channel.send(tf(locale, 'ticket.claimed', { user: `<@${data.actorUserId}>` }));
}
}
return {
id: updated.id,
status: updated.status,
priority: updated.priority,
claimedById: updated.claimedById
};
}
const parsed = TicketPrioritySchema.parse(data.priority);
const updated = await context.prisma.ticket.update({
where: { id: ticket.id },
data: { priority: parsed, lastActivityAt: new Date() }
});
const channelId = ticket.channelId ?? ticket.threadId;
if (channelId) {
const channel = await fetchSendableChannel(context, channelId);
if (channel) {
await channel.send(
tf(locale, 'ticket.prioritySet', { priority: t(locale, `ticket.priority.${parsed}`) })
);
}
}
return {
id: updated.id,
status: updated.status,
priority: updated.priority,
claimedById: updated.claimedById
};
}
export function buildOpenModal(category: TicketCategory, locale: 'de' | 'en'): ModalBuilder {
const fields = parseFormFields(category.formFields);
const modal = new ModalBuilder()
@@ -328,7 +716,11 @@ async function fetchTicketMessages(
timestamp: message.createdAt.toISOString()
}));
} catch (error) {
if (isUnknownChannelError(error)) {
logger.debug({ ticketId: ticket.id }, 'Ticket channel already gone while fetching messages');
} else {
logger.warn({ error, ticketId: ticket.id }, 'Failed to fetch ticket messages');
}
return [];
}
}
@@ -395,13 +787,24 @@ export async function createTicketChannel(
reason: `Ticket opened by ${params.opener.user.tag}`
});
await thread.members.add(params.opener.id);
for (const roleId of supportRoleIds) {
const role = params.guild.roles.cache.get(roleId);
if (role) {
for (const [, member] of role.members) {
await thread.members.add(member.id).catch(() => undefined);
const supportMembers = await resolveSupportMembers(params.guild, supportRoleIds);
for (const member of supportMembers) {
if (member.id === params.opener.id) {
continue;
}
await thread.members.add(member.id).catch((error) => {
logger.warn(
{ error, threadId: thread.id, userId: member.id },
'Failed to add support member to ticket thread'
);
});
}
if (supportRoleIds.length > 0 && supportMembers.length === 0) {
logger.warn(
{ guildId: params.guild.id, supportRoleIds },
'No cached members found for ticket support roles staff may need Server Members intent coverage'
);
}
threadId = thread.id;
} else {
@@ -436,10 +839,13 @@ export async function createTicketChannel(
const targetId = channelId ?? threadId!;
const target = (await context.client.channels.fetch(targetId)) as TextChannel | ThreadChannel;
const roleMentions = supportRoleIds.map((roleId) => `<@&${roleId}>`).join(' ');
const introLines = [
roleMentions || null,
tf(locale, 'ticket.opened', { user: `<@${params.opener.id}>` }),
params.subject ? tf(locale, 'ticket.subjectLine', { subject: params.subject }) : null
].filter(Boolean);
].filter(Boolean) as string[];
if (params.formAnswers && Object.keys(params.formAnswers).length > 0) {
const answerLines = Object.entries(params.formAnswers).map(
@@ -448,7 +854,17 @@ export async function createTicketChannel(
introLines.push(answerLines.join('\n'));
}
await target.send({ content: introLines.join('\n') });
await target.send({
content: introLines.join('\n'),
allowedMentions: {
roles: supportRoleIds,
users: [params.opener.id]
}
});
const control = buildTicketControlPanel(locale);
await target.send(control);
return ticket;
}
@@ -720,9 +1136,13 @@ export async function closeTicket(
await channel.delete(`Ticket ${ticket.id} closed`);
}
} catch (error) {
if (isUnknownChannelError(error)) {
logger.debug({ ticketId: ticket.id }, 'Ticket channel already gone while deleting');
} else {
logger.warn({ error, ticketId: ticket.id }, 'Failed to delete ticket channel');
}
}
}
}
export async function rateTicket(

View File

@@ -257,7 +257,9 @@ const emojiCommand: SlashCommand = {
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!(await ensureGuildCommand(interaction, locale))) return;
if (!(await requirePermission(interaction, PermissionFlagsBits.ManageGuildExpressions, locale))) {
if (!(await requirePermission(interaction, PermissionFlagsBits.ManageGuildExpressions, locale, {
botPermissions: PermissionFlagsBits.ManageGuildExpressions
}))) {
return;
}
@@ -326,7 +328,9 @@ const stickerCommand: SlashCommand = {
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!(await ensureGuildCommand(interaction, locale))) return;
if (!(await requirePermission(interaction, PermissionFlagsBits.ManageGuildExpressions, locale))) {
if (!(await requirePermission(interaction, PermissionFlagsBits.ManageGuildExpressions, locale, {
botPermissions: PermissionFlagsBits.ManageGuildExpressions
}))) {
return;
}
@@ -419,7 +423,9 @@ const embedCommand: SlashCommand = {
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!(await ensureGuildCommand(interaction, locale))) return;
if (!(await requirePermission(interaction, PermissionFlagsBits.ManageMessages, locale))) {
if (!(await requirePermission(interaction, PermissionFlagsBits.ManageMessages, locale, {
botPermissions: PermissionFlagsBits.ManageMessages
}))) {
return;
}

View File

@@ -32,17 +32,18 @@ const translateContextMenu: ContextMenuCommand = {
return;
}
await interaction.deferReply({ ephemeral: true });
const targetLang = locale === 'de' ? 'de' : 'en';
const translated = await translateText(content, targetLang);
if (!translated) {
await interaction.reply({ content: t(locale, 'utility.translate.failed'), ephemeral: true });
await interaction.editReply({ content: t(locale, 'utility.translate.failed') });
return;
}
await interaction.reply({
content: t(locale, 'utility.translate.result').replace('{text}', translated),
ephemeral: true
await interaction.editReply({
content: t(locale, 'utility.translate.result').replace('{text}', translated)
});
}
};

View File

@@ -2,6 +2,7 @@ import { type TextChannel } from 'discord.js';
import { t, tf } from '@nexumi/shared';
import type { BotContext } from '../../types.js';
import { ensureGuild } from '../../guild.js';
import { safeRemoveBullJob } from '../../lib/safe-remove-job.js';
import { reminderQueue } from '../../queues.js';
import { parseWhen, UtilityError } from './service.js';
@@ -107,9 +108,7 @@ export async function deleteReminder(
if (match.jobId) {
const job = await reminderQueue.getJob(match.jobId);
if (job) {
await job.remove();
}
await safeRemoveBullJob(job, { label: 'reminder', jobId: match.jobId });
}
await context.prisma.reminder.delete({ where: { id: match.id } });

View File

@@ -1,8 +1,4 @@
import {
PermissionFlagsBits,
type GuildBasedChannel,
type GuildTextBasedChannel
} from 'discord.js';
import { PermissionFlagsBits } from 'discord.js';
import { t } from '@nexumi/shared';
import type { SlashCommand } from '../../types.js';
import { getGuildLocale } from '../../i18n.js';
@@ -10,25 +6,7 @@ import { requirePermission } from '../../permissions.js';
import { ephemeral } from '../../interaction-reply.js';
import { verifyCommandData } from './command-definitions.js';
import { getVerificationConfig, updateVerificationConfig } from './service.js';
import { buildVerificationPanel } from './handlers.js';
function botCanPostPanel(
channel: GuildBasedChannel,
meId: string
): channel is GuildTextBasedChannel {
if (!channel.isTextBased() || channel.isDMBased()) {
return false;
}
const permissions = channel.permissionsFor(meId);
if (!permissions) {
return false;
}
return (
permissions.has(PermissionFlagsBits.ViewChannel) &&
permissions.has(PermissionFlagsBits.SendMessages) &&
permissions.has(PermissionFlagsBits.EmbedLinks)
);
}
import { syncVerificationPanel, VerificationPanelError } from './handlers.js';
const verifyCommand: SlashCommand = {
data: verifyCommandData,
@@ -91,31 +69,21 @@ const verifyCommand: SlashCommand = {
}
const panelChannelOption = interaction.options.getChannel('channel');
const panelChannel = panelChannelOption
? await interaction.guild!.channels.fetch(panelChannelOption.id)
: config.channelId
? await interaction.guild!.channels.fetch(config.channelId)
: null;
const me = interaction.guild!.members.me;
if (!panelChannel || !me || !botCanPostPanel(panelChannel, me.id)) {
await interaction.reply({
content: t(locale, 'verification.panelMissingPermission'),
...ephemeral()
});
try {
await syncVerificationPanel(context, guildId, panelChannelOption?.id ?? null);
await interaction.reply({ content: t(locale, 'verification.panelPosted'), ...ephemeral() });
} catch (error) {
if (error instanceof VerificationPanelError) {
const key =
error.code === 'not_configured'
? 'verification.notConfigured'
: 'verification.panelMissingPermission';
await interaction.reply({ content: t(locale, key), ...ephemeral() });
return;
}
const panel = buildVerificationPanel(config, locale);
const message = await panelChannel.send(panel);
await context.prisma.verificationConfig.update({
where: { guildId },
data: {
panelChannelId: panelChannel.id,
panelMessageId: message.id
throw error;
}
});
await interaction.reply({ content: t(locale, 'verification.panelPosted'), ...ephemeral() });
}
};

View File

@@ -5,7 +5,9 @@ import {
EmbedBuilder,
PermissionFlagsBits,
type ButtonInteraction,
type GuildMember
type GuildBasedChannel,
type GuildMember,
type GuildTextBasedChannel
} from 'discord.js';
import { CaptchaProviderSchema, t, tf, type CaptchaProvider } from '@nexumi/shared';
import type { BotContext } from '../../types.js';
@@ -22,6 +24,32 @@ import { detectAltAccount } from './alt-detection.js';
import { env } from '../../env.js';
import { logger } from '../../logger.js';
import { ephemeral } from '../../interaction-reply.js';
import { notifyModerationTargetDm } from '../moderation/service.js';
export class VerificationPanelError extends Error {
constructor(public readonly code: 'not_configured' | 'missing_permission') {
super(code);
this.name = 'VerificationPanelError';
}
}
function botCanPostPanel(
channel: GuildBasedChannel,
meId: string
): channel is GuildTextBasedChannel {
if (!channel.isTextBased() || channel.isDMBased()) {
return false;
}
const permissions = channel.permissionsFor(meId);
if (!permissions) {
return false;
}
return (
permissions.has(PermissionFlagsBits.ViewChannel) &&
permissions.has(PermissionFlagsBits.SendMessages) &&
permissions.has(PermissionFlagsBits.EmbedLinks)
);
}
export type CompleteVerificationOptions = {
ipHash?: string | null;
@@ -30,20 +58,61 @@ export type CompleteVerificationOptions = {
};
async function applyFailAction(
context: BotContext,
member: GuildMember,
failAction: string,
reason: string
): Promise<void> {
const me = member.guild.members.me;
if (failAction !== 'KICK' && failAction !== 'BAN') {
return;
}
const locale = await getGuildLocale(context.prisma, member.guild.id);
if (failAction === 'KICK' && me?.permissions.has(PermissionFlagsBits.KickMembers)) {
await notifyModerationTargetDm({
user: member.user,
guildName: member.guild.name,
reason,
locale,
action: 'kick'
});
await member.kick(reason);
return;
}
if (failAction === 'BAN' && me?.permissions.has(PermissionFlagsBits.BanMembers)) {
await notifyModerationTargetDm({
user: member.user,
guildName: member.guild.name,
reason,
locale,
action: 'ban'
});
await member.guild.members.ban(member.id, { reason });
}
}
async function logVerificationFailure(
context: BotContext,
member: GuildMember,
input: {
reason: string;
detail?: string;
failAction?: string;
matchedUserId?: string | null;
}
): Promise<void> {
const { logVerificationFail } = await import('../logging/events.js');
await logVerificationFail(context, {
guildId: member.guild.id,
userId: member.id,
userTag: member.user.tag,
reason: input.reason,
detail: input.detail,
failAction: input.failAction,
matchedUserId: input.matchedUserId
});
}
export async function completeVerification(
context: BotContext,
guildId: string,
@@ -63,11 +132,13 @@ export async function completeVerification(
const ageDays = accountAgeDays(member.user.createdAt);
if (config.minAccountAgeDays > 0 && ageDays < config.minAccountAgeDays) {
await applyFailAction(
member,
config.failAction,
`Verification failed: account too young (${ageDays}d)`
);
const failReason = `Verification failed: account too young (${ageDays}d)`;
await logVerificationFailure(context, member, {
reason: 'account_too_young',
detail: `Account age ${ageDays}d (required ${config.minAccountAgeDays}d)`,
failAction: config.failAction
});
await applyFailAction(context, member, config.failAction, failReason);
return { ok: false, reason: 'account_too_young' };
}
@@ -143,13 +214,23 @@ export async function completeVerification(
});
if (config.altBlockOnMatch) {
await applyFailAction(
member,
config.failAction,
`Verification failed: possible alt account (${match.reason})`
);
const failReason = `Verification failed: possible alt account (${match.reason})`;
await logVerificationFailure(context, member, {
reason: 'alt_detected',
detail: `Signal: ${match.reason}`,
failAction: config.failAction,
matchedUserId
});
await applyFailAction(context, member, config.failAction, failReason);
return { ok: false, reason: 'alt_detected' };
}
await logVerificationFailure(context, member, {
reason: 'alt_detected_flagged',
detail: `Signal: ${match.reason} (flagged only, not blocked)`,
failAction: 'NONE',
matchedUserId
});
}
}
@@ -228,6 +309,86 @@ export function buildVerificationPanel(
return { embeds: [embed], components: [row] };
}
/**
* Posts or updates the verification panel in the configured channel.
* Used by `/verify panel` and by the dashboard via BullMQ (`verificationPanelSync`).
*/
export async function syncVerificationPanel(
context: BotContext,
guildId: string,
preferredChannelId?: string | null
): Promise<{ panelChannelId: string; panelMessageId: string }> {
const locale = await getGuildLocale(context.prisma, guildId);
const config = await getVerificationConfig(context.prisma, guildId);
if (!config.enabled || !config.verifiedRoleId) {
throw new VerificationPanelError('not_configured');
}
const targetChannelId = preferredChannelId || config.channelId;
if (!targetChannelId) {
throw new VerificationPanelError('not_configured');
}
const guild = await context.client.guilds.fetch(guildId);
const me = guild.members.me;
if (!me) {
throw new VerificationPanelError('missing_permission');
}
const channel = await guild.channels.fetch(targetChannelId).catch(() => null);
if (!channel || !botCanPostPanel(channel, me.id)) {
throw new VerificationPanelError('missing_permission');
}
const panel = buildVerificationPanel(config, locale);
const sameChannel =
Boolean(config.panelMessageId) && config.panelChannelId === channel.id;
if (sameChannel && config.panelMessageId) {
try {
const existing = await channel.messages.fetch(config.panelMessageId);
await existing.edit(panel);
return { panelChannelId: channel.id, panelMessageId: existing.id };
} catch (error) {
logger.warn(
{ error, guildId, messageId: config.panelMessageId },
'Failed to edit verification panel; recreating'
);
}
}
if (config.panelMessageId && config.panelChannelId && config.panelChannelId !== channel.id) {
try {
const oldChannel = await guild.channels.fetch(config.panelChannelId);
if (oldChannel?.isTextBased() && !oldChannel.isDMBased()) {
const oldMessage = await oldChannel.messages.fetch(config.panelMessageId);
await oldMessage.delete();
}
} catch {
// Previous panel may already be gone
}
}
const message = await channel.send(panel);
await context.prisma.verificationConfig.update({
where: { guildId },
data: {
panelChannelId: channel.id,
panelMessageId: message.id
}
});
return { panelChannelId: channel.id, panelMessageId: message.id };
}
export async function runVerificationPanelSyncJob(
context: BotContext,
data: { guildId: string }
): Promise<{ panelChannelId: string; panelMessageId: string }> {
return syncVerificationPanel(context, data.guildId);
}
export async function handleVerificationButton(
interaction: ButtonInteraction,
context: BotContext

View File

@@ -1,23 +1,75 @@
import { PermissionFlagsBits, type GuildMember } from 'discord.js';
import { PermissionFlagsBits, type GuildMember, type Role } from 'discord.js';
import { t, tf } from '@nexumi/shared';
import type { BotContext } from '../../types.js';
import { getGuildLocale } from '../../i18n.js';
import { getWelcomeConfig, resolveWelcomePayload, resolveLeavePayload, renderWelcomeText } from './service.js';
import { sendLeaveMessage, sendWelcomeMessage } from './renderer.js';
import { logger } from '../../logger.js';
async function resolveRole(member: GuildMember, roleId: string): Promise<Role | null> {
const cached = member.guild.roles.cache.get(roleId);
if (cached) {
return cached;
}
return member.guild.roles.fetch(roleId).catch(() => null);
}
async function applyAutoroles(member: GuildMember, roleIds: string[]): Promise<void> {
const me = member.guild.members.me;
if (!me?.permissions.has(PermissionFlagsBits.ManageRoles)) {
const uniqueIds = [...new Set(roleIds.filter(Boolean))];
if (uniqueIds.length === 0) {
return;
}
const assignable = roleIds.filter((roleId) => {
const role = member.guild.roles.cache.get(roleId);
return role && role.position < me.roles.highest.position;
});
const me =
member.guild.members.me ?? (await member.guild.members.fetchMe().catch(() => null));
if (!me) {
logger.warn({ guildId: member.guild.id }, 'Autoroles skipped: bot member unavailable');
return;
}
if (!me.permissions.has(PermissionFlagsBits.ManageRoles)) {
logger.warn({ guildId: member.guild.id }, 'Autoroles skipped: missing ManageRoles permission');
return;
}
const assignable: string[] = [];
for (const roleId of uniqueIds) {
if (roleId === member.guild.id) {
continue;
}
if (member.roles.cache.has(roleId)) {
continue;
}
const role = await resolveRole(member, roleId);
if (!role) {
logger.warn({ guildId: member.guild.id, roleId }, 'Autorole skipped: role not found');
continue;
}
if (role.managed) {
logger.warn({ guildId: member.guild.id, roleId }, 'Autorole skipped: managed role');
continue;
}
if (!role.editable) {
logger.warn(
{
guildId: member.guild.id,
roleId,
rolePosition: role.position,
botHighest: me.roles.highest.position
},
'Autorole skipped: role above bot or not editable'
);
continue;
}
assignable.push(roleId);
}
if (assignable.length === 0) {
return;
}
await member.roles.add(assignable).catch((error) => {
logger.warn({ error, memberId: member.id }, 'Failed to assign autoroles');
await member.roles.add(assignable, 'Nexumi welcome autorole').catch((error) => {
logger.warn({ error, memberId: member.id, assignable }, 'Failed to assign autoroles');
});
}
@@ -56,6 +108,33 @@ export async function handleMemberLeave(context: BotContext, member: GuildMember
return;
}
await sendLeaveMessage(channel, resolveLeavePayload(config), member, member.guild);
if (!config.leaveAnnounceBan || member.user.bot) {
return;
}
const ban = await member.guild.bans.fetch(member.id).catch(() => null);
if (!ban) {
return;
}
const locale = await getGuildLocale(context.prisma, member.guild.id);
const reason = ban.reason ?? t(locale, 'moderation.reason.none');
if ('send' in channel) {
await channel
.send(
tf(locale, 'welcome.leave.bannedNotice', {
user: member.user.tag,
reason
})
)
.catch((error) => {
logger.warn(
{ error, guildId: member.guild.id, userId: member.id },
'Failed to send leave ban notice'
);
});
}
}
export function registerWelcomeEvents(context: BotContext): void {

View File

@@ -10,7 +10,7 @@ import { createCanvas, loadImage } from '@napi-rs/canvas';
import { buildEmbedFromPayload } from '../../lib/embed-payload.js';
import { buildComponentsV2Payload } from '../../lib/components-v2-payload.js';
import type { LeavePayload, WelcomePayload } from './service.js';
import { renderWelcomeText } from './service.js';
import { renderWelcomeEmbedText, renderWelcomeText } from './service.js';
export async function buildWelcomeMessage(
payload: WelcomePayload,
@@ -31,7 +31,7 @@ export async function buildWelcomeMessage(
if (payload.type === 'EMBED') {
const embed = buildEmbedFromPayload(payload.embed, {
renderText: (value) => renderWelcomeText(value, member, guild),
renderText: (value, field) => renderWelcomeEmbedText(value, member, guild, field),
defaultThumbnailUrl: member.user.displayAvatarURL({ size: 256 })
});
return embed ? { embeds: [embed] } : { content: renderWelcomeText('{user}', member, guild) };
@@ -77,14 +77,15 @@ async function renderWelcomeCard(
const title = payload.imageTitle ?? 'Welcome!';
const subtitle =
payload.imageSubtitle ?? `${member.displayName} joined ${guild.name}`;
const plain = { mentionUser: false as const };
ctx.fillStyle = '#f9fafb';
ctx.font = 'bold 36px sans-serif';
ctx.fillText(renderWelcomeText(title, member, guild).slice(0, 40), 220, 130);
ctx.fillText(renderWelcomeText(title, member, guild, plain).slice(0, 40), 220, 130);
ctx.fillStyle = '#d1d5db';
ctx.font = '24px sans-serif';
ctx.fillText(renderWelcomeText(subtitle, member, guild).slice(0, 60), 220, 180);
ctx.fillText(renderWelcomeText(subtitle, member, guild, plain).slice(0, 60), 220, 180);
return canvas.toBuffer('image/png');
}
@@ -119,7 +120,7 @@ export async function buildLeaveMessage(
if (payload.type === 'EMBED') {
const embed = buildEmbedFromPayload(payload.embed, {
renderText: (value) => renderWelcomeText(value, member, guild),
renderText: (value, field) => renderWelcomeEmbedText(value, member, guild, field),
defaultThumbnailUrl: member.user.displayAvatarURL({ size: 256 })
});
if (embed) {

View File

@@ -13,9 +13,23 @@ export async function getWelcomeConfig(prisma: PrismaClient, guildId: string) {
return config;
}
export function buildPlaceholderVars(member: GuildMember, guild: Guild) {
export type WelcomePlaceholderOptions = {
/**
* When false, `{user}` becomes the member display name instead of `<@id>`.
* Required for embed title/author/footer and canvas text — Discord does not
* resolve mentions in those surfaces.
*/
mentionUser?: boolean;
};
export function buildPlaceholderVars(
member: GuildMember,
guild: Guild,
options: WelcomePlaceholderOptions = {}
) {
const mentionUser = options.mentionUser !== false;
return {
user: `<@${member.id}>`,
user: mentionUser ? `<@${member.id}>` : member.displayName,
'user.name': member.displayName,
'user.tag': member.user.tag,
'user.id': member.id,
@@ -26,8 +40,27 @@ export function buildPlaceholderVars(member: GuildMember, guild: Guild) {
};
}
export function renderWelcomeText(template: string, member: GuildMember, guild: Guild): string {
return applyWelcomePlaceholders(template, buildPlaceholderVars(member, guild));
export function renderWelcomeText(
template: string,
member: GuildMember,
guild: Guild,
options: WelcomePlaceholderOptions = {}
): string {
return applyWelcomePlaceholders(template, buildPlaceholderVars(member, guild, options));
}
/** Fields where Discord will not turn `<@id>` into a clickable mention. */
const EMBED_PLAIN_USER_FIELDS = new Set(['title', 'authorName', 'footerText']);
export function renderWelcomeEmbedText(
template: string,
member: GuildMember,
guild: Guild,
field: string
): string {
return renderWelcomeText(template, member, guild, {
mentionUser: !EMBED_PLAIN_USER_FIELDS.has(field)
});
}
export function parseWelcomeEmbed(raw: unknown): WelcomeEmbed | null {

View File

@@ -1,14 +1,49 @@
import { type ChatInputCommandInteraction } from 'discord.js';
import { PermissionFlagsBits, type ChatInputCommandInteraction } from 'discord.js';
import { t } from '@nexumi/shared';
import type { Locale } from '@nexumi/shared';
const PERMISSION_LABELS: Partial<Record<string, string>> = {
[PermissionFlagsBits.Administrator.toString()]: 'Administrator',
[PermissionFlagsBits.ManageGuild.toString()]: 'Manage Server',
[PermissionFlagsBits.ManageChannels.toString()]: 'Manage Channels',
[PermissionFlagsBits.ManageRoles.toString()]: 'Manage Roles',
[PermissionFlagsBits.ManageMessages.toString()]: 'Manage Messages',
[PermissionFlagsBits.ManageNicknames.toString()]: 'Manage Nicknames',
[PermissionFlagsBits.ManageGuildExpressions.toString()]: 'Manage Expressions',
[PermissionFlagsBits.KickMembers.toString()]: 'Kick Members',
[PermissionFlagsBits.BanMembers.toString()]: 'Ban Members',
[PermissionFlagsBits.ModerateMembers.toString()]: 'Timeout Members',
[PermissionFlagsBits.ViewChannel.toString()]: 'View Channel',
[PermissionFlagsBits.SendMessages.toString()]: 'Send Messages',
[PermissionFlagsBits.EmbedLinks.toString()]: 'Embed Links',
[PermissionFlagsBits.AttachFiles.toString()]: 'Attach Files',
[PermissionFlagsBits.ReadMessageHistory.toString()]: 'Read Message History'
};
function permissionLabel(permission: bigint): string {
return PERMISSION_LABELS[permission.toString()] ?? permission.toString();
}
export type RequirePermissionOptions = {
/**
* Discord permissions the bot itself must hold. Omit for dashboard-style
* member gates (e.g. ManageGuild) where the bot does not need the same bit.
*/
botPermissions?: bigint | readonly bigint[];
};
/**
* Ensures the invoking member has `memberPermission`. Optionally also checks
* that the bot has one or more `botPermissions` (moderation actions).
*/
export async function requirePermission(
interaction: ChatInputCommandInteraction,
permission: bigint,
locale: Locale
memberPermission: bigint,
locale: Locale,
options?: RequirePermissionOptions
): Promise<boolean> {
const member = interaction.memberPermissions;
if (!member?.has(permission)) {
if (!member?.has(memberPermission)) {
await interaction.reply({
content: t(locale, 'generic.noPermission'),
ephemeral: true
@@ -16,16 +51,36 @@ export async function requirePermission(
return false;
}
if (!interaction.guild?.members.me?.permissions.has(permission)) {
const botBits = options?.botPermissions;
if (botBits === undefined) {
return true;
}
const required = Array.isArray(botBits) ? botBits : [botBits];
const me = interaction.guild?.members.me;
if (!me) {
await interaction.reply({
content: t(locale, 'generic.botMissingPermission').replace(
'{permission}',
permission.toString()
required.map(permissionLabel).join(', ')
),
ephemeral: true
});
return false;
}
for (const bit of required) {
if (!me.permissions.has(bit)) {
await interaction.reply({
content: t(locale, 'generic.botMissingPermission').replace(
'{permission}',
permissionLabel(bit)
),
ephemeral: true
});
return false;
}
}
return true;
}

View File

@@ -120,6 +120,23 @@ export async function readPresenceConfig(context: BotContext): Promise<BotPresen
export async function applyBotPresence(context: BotContext): Promise<void> {
const config = await readPresenceConfig(context);
if (config.maintenanceMode) {
const text =
config.maintenanceMessage?.trim() ||
'Maintenance mode — please try again later.';
await context.client.user?.setPresence({
status: 'dnd',
activities: [
{
name: text.slice(0, 128),
type: ActivityType.Watching
}
]
});
return;
}
const messages =
config.rotatingMessages.length > 0 ? config.rotatingMessages : [config.activityText];
const index = Math.floor(Date.now() / 60_000) % messages.length;

View File

@@ -15,6 +15,8 @@ export const giveawayQueueName = 'giveaways';
export const giveawayQueue = new Queue(giveawayQueueName, { connection: redis });
export const ticketQueueName = 'tickets';
export const ticketQueue = new Queue(ticketQueueName, { connection: redis });
export const selfrolesQueueName = 'selfroles';
export const selfrolesQueue = new Queue(selfrolesQueueName, { connection: redis });
export const birthdayQueueName = 'birthdays';
export const birthdayQueue = new Queue(birthdayQueueName, { connection: redis });
export const statsQueueName = 'stats';

View File

@@ -12,6 +12,7 @@
"prisma:generate": "prisma generate --schema=../bot/prisma/schema.prisma"
},
"dependencies": {
"@emoji-mart/data": "^1.2.1",
"@nexumi/shared": "workspace:^",
"@prisma/client": "^5.22.0",
"@radix-ui/react-avatar": "^1.2.3",

View File

@@ -51,6 +51,7 @@ export async function GET(request: NextRequest) {
cookieValue = await createSession({
user,
accessToken: token.access_token,
refreshToken: token.refresh_token,
tokenExpiresAt: Date.now() + token.expires_in * 1000
});
} catch (error) {

View File

@@ -0,0 +1,18 @@
import { type NextRequest, NextResponse } from 'next/server';
import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth';
import { listGuildEmojisForDashboard } from '@/lib/discord-guild-resources';
interface RouteParams {
params: Promise<{ guildId: string }>;
}
export async function GET(_request: NextRequest, { params }: RouteParams) {
const { guildId } = await params;
try {
await requireGuildAccess(guildId);
const emojis = await listGuildEmojisForDashboard(guildId);
return NextResponse.json(emojis);
} catch (error) {
return toApiErrorResponse(error);
}
}

View File

@@ -2,7 +2,11 @@ import { GiveawayActionSchema } from '@nexumi/shared';
import { type NextRequest, NextResponse } from 'next/server';
import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth';
import { writeDashboardAudit } from '@/lib/audit';
import { endGiveawayDashboard, setGiveawayPaused } from '@/lib/module-configs/giveaways';
import {
endGiveawayDashboard,
GiveawayEndError,
setGiveawayPaused
} from '@/lib/module-configs/giveaways';
import { prisma } from '@/lib/prisma';
interface RouteParams {
@@ -39,6 +43,9 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
return NextResponse.json(giveaway);
} catch (error) {
if (error instanceof GiveawayEndError) {
return NextResponse.json({ error: error.message }, { status: 502 });
}
return toApiErrorResponse(error);
}
}

View File

@@ -2,7 +2,11 @@ import { SelfRolePanelDashboardUpdateSchema } from '@nexumi/shared';
import { type NextRequest, NextResponse } from 'next/server';
import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth';
import { writeDashboardAudit } from '@/lib/audit';
import { deleteSelfRolePanel, updateSelfRolePanel } from '@/lib/module-configs/selfroles';
import {
deleteSelfRolePanel,
SelfRolePanelSyncError,
updateSelfRolePanel
} from '@/lib/module-configs/selfroles';
import { prisma } from '@/lib/prisma';
interface RouteParams {
@@ -31,6 +35,9 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
return NextResponse.json(updated);
} catch (error) {
if (error instanceof SelfRolePanelSyncError) {
return NextResponse.json({ error: error.message }, { status: 504 });
}
return toApiErrorResponse(error);
}
}
@@ -54,6 +61,9 @@ export async function DELETE(_request: NextRequest, { params }: RouteParams) {
return NextResponse.json({ ok: true });
} catch (error) {
if (error instanceof SelfRolePanelSyncError) {
return NextResponse.json({ error: error.message }, { status: 504 });
}
return toApiErrorResponse(error);
}
}

View File

@@ -2,7 +2,11 @@ import { SelfRolePanelDashboardCreateSchema } from '@nexumi/shared';
import { type NextRequest, NextResponse } from 'next/server';
import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth';
import { writeDashboardAudit } from '@/lib/audit';
import { createSelfRolePanel, listSelfRolePanels } from '@/lib/module-configs/selfroles';
import {
createSelfRolePanel,
listSelfRolePanels,
SelfRolePanelSyncError
} from '@/lib/module-configs/selfroles';
import { prisma } from '@/lib/prisma';
interface RouteParams {
@@ -39,6 +43,9 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
return NextResponse.json(panel, { status: 201 });
} catch (error) {
if (error instanceof SelfRolePanelSyncError) {
return NextResponse.json({ error: error.message }, { status: 504 });
}
return toApiErrorResponse(error);
}
}

View File

@@ -5,6 +5,7 @@ import { writeDashboardAudit } from '@/lib/audit';
import {
deleteTicketCategory,
TicketCategoryConflictError,
TicketPanelSyncError,
updateTicketCategory
} from '@/lib/module-configs/tickets';
import { prisma } from '@/lib/prisma';
@@ -38,6 +39,12 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
if (error instanceof TicketCategoryConflictError) {
return NextResponse.json({ error: error.message }, { status: 409 });
}
if (error instanceof TicketPanelSyncError) {
return NextResponse.json(
{ error: error.message, code: error.code },
{ status: error.code === 'timeout' ? 504 : 502 }
);
}
return toApiErrorResponse(error);
}
}
@@ -61,6 +68,12 @@ export async function DELETE(_request: NextRequest, { params }: RouteParams) {
return NextResponse.json({ ok: true });
} catch (error) {
if (error instanceof TicketPanelSyncError) {
return NextResponse.json(
{ error: error.message, code: error.code },
{ status: error.code === 'timeout' ? 504 : 502 }
);
}
return toApiErrorResponse(error);
}
}

View File

@@ -2,7 +2,7 @@ import { TicketCategoryDashboardCreateSchema } from '@nexumi/shared';
import { type NextRequest, NextResponse } from 'next/server';
import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth';
import { writeDashboardAudit } from '@/lib/audit';
import { createTicketCategory, listTicketCategories, TicketCategoryConflictError } from '@/lib/module-configs/tickets';
import { createTicketCategory, listTicketCategories, TicketCategoryConflictError, TicketPanelSyncError } from '@/lib/module-configs/tickets';
import { prisma } from '@/lib/prisma';
interface RouteParams {
@@ -42,6 +42,12 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
if (error instanceof TicketCategoryConflictError) {
return NextResponse.json({ error: error.message }, { status: 409 });
}
if (error instanceof TicketPanelSyncError) {
return NextResponse.json(
{ error: error.message, code: error.code },
{ status: error.code === 'timeout' ? 504 : 502 }
);
}
return toApiErrorResponse(error);
}
}

View File

@@ -0,0 +1,48 @@
import { TicketDashboardActionSchema } from '@nexumi/shared';
import { type NextRequest, NextResponse } from 'next/server';
import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth';
import { writeDashboardAudit } from '@/lib/audit';
import {
applyTicketDashboardAction,
TicketActionSyncError
} from '@/lib/module-configs/tickets';
import { prisma } from '@/lib/prisma';
interface RouteParams {
params: Promise<{ guildId: string; ticketId: string }>;
}
export async function POST(request: NextRequest, { params }: RouteParams) {
const { guildId, ticketId } = await params;
try {
const session = await requireGuildAccess(guildId);
const body = await request.json();
const action = TicketDashboardActionSchema.parse(body);
const after = await applyTicketDashboardAction(
guildId,
ticketId,
action,
session.user.id
);
if (!after) {
return NextResponse.json({ error: 'Ticket not found' }, { status: 404 });
}
await writeDashboardAudit(prisma, {
guildId,
actorUserId: session.user.id,
action: `tickets.${action.action}`,
path: `/dashboard/${guildId}/tickets`,
before: { ticketId },
after
});
return NextResponse.json(after);
} catch (error) {
if (error instanceof TicketActionSyncError) {
return NextResponse.json({ error: error.message }, { status: 504 });
}
return toApiErrorResponse(error);
}
}

View File

@@ -0,0 +1,18 @@
import { type NextRequest, NextResponse } from 'next/server';
import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth';
import { listOpenTickets } from '@/lib/module-configs/tickets';
interface RouteParams {
params: Promise<{ guildId: string }>;
}
export async function GET(_request: NextRequest, { params }: RouteParams) {
const { guildId } = await params;
try {
await requireGuildAccess(guildId);
const tickets = await listOpenTickets(guildId);
return NextResponse.json({ tickets });
} catch (error) {
return toApiErrorResponse(error);
}
}

View File

@@ -2,7 +2,11 @@ import { TicketConfigDashboardPatchSchema } from '@nexumi/shared';
import { type NextRequest, NextResponse } from 'next/server';
import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth';
import { writeDashboardAudit } from '@/lib/audit';
import { getTicketConfigDashboard, updateTicketConfigDashboard } from '@/lib/module-configs/tickets';
import {
getTicketConfigDashboard,
TicketPanelSyncError,
updateTicketConfigDashboard
} from '@/lib/module-configs/tickets';
import { prisma } from '@/lib/prisma';
interface RouteParams {
@@ -41,6 +45,12 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
return NextResponse.json(after);
} catch (error) {
if (error instanceof TicketPanelSyncError) {
return NextResponse.json(
{ error: error.message, code: error.code },
{ status: error.code === 'timeout' ? 504 : 502 }
);
}
return toApiErrorResponse(error);
}
}

View File

@@ -2,7 +2,11 @@ import { VerificationConfigDashboardPatchSchema } from '@nexumi/shared';
import { type NextRequest, NextResponse } from 'next/server';
import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth';
import { writeDashboardAudit } from '@/lib/audit';
import { getVerificationDashboard, updateVerificationDashboard } from '@/lib/module-configs/verification';
import {
getVerificationDashboard,
updateVerificationDashboard,
VerificationPanelSyncError
} from '@/lib/module-configs/verification';
import { prisma } from '@/lib/prisma';
interface RouteParams {
@@ -45,6 +49,12 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
return NextResponse.json(after);
} catch (error) {
if (error instanceof VerificationPanelSyncError) {
return NextResponse.json(
{ error: error.message, code: error.code },
{ status: error.code === 'timeout' ? 504 : 502 }
);
}
return toApiErrorResponse(error);
}
}

View File

@@ -0,0 +1,36 @@
import { NextResponse } from 'next/server';
import { BotAboutConfigPatchSchema } from '@nexumi/shared';
import { toApiErrorResponse } from '@/lib/auth';
import { getAboutConfig, updateAboutConfig } from '@/lib/owner-about';
import { writeOwnerAudit } from '@/lib/owner-audit';
import { requireOwner } from '@/lib/owner-auth';
import { prisma } from '@/lib/prisma';
export async function GET() {
try {
await requireOwner('VIEWER');
return NextResponse.json(await getAboutConfig());
} catch (error) {
return toApiErrorResponse(error);
}
}
export async function PATCH(request: Request) {
try {
const session = await requireOwner('ADMIN');
const body = BotAboutConfigPatchSchema.parse(await request.json());
const before = await getAboutConfig();
const after = await updateAboutConfig(body);
await writeOwnerAudit(prisma, {
actorUserId: session.user.id,
action: 'about.update',
targetType: 'BotAboutConfig',
targetId: 'singleton',
before,
after
});
return NextResponse.json(after);
} catch (error) {
return toApiErrorResponse(error);
}
}

View File

@@ -4,27 +4,39 @@ import { toApiErrorResponse } from '@/lib/auth';
import { env } from '@/lib/env';
import { writeOwnerAudit } from '@/lib/owner-audit';
import { requireOwner } from '@/lib/owner-auth';
import { createOwnerGuildInvite, enrichOwnerGuildListItems } from '@/lib/owner-guilds';
import { prisma } from '@/lib/prisma';
export async function GET(request: Request) {
try {
await requireOwner('VIEWER');
const q = new URL(request.url).searchParams.get('q')?.trim() ?? '';
const q = new URL(request.url).searchParams.get('q')?.trim().toLowerCase() ?? '';
const guilds = await prisma.guild.findMany({
where: q ? { id: { contains: q } } : undefined,
include: { settings: true },
orderBy: { createdAt: 'desc' },
take: 100
take: 200
});
const blacklist = await prisma.guildBlacklist.findMany();
const blacklisted = new Set(blacklist.map((entry) => entry.guildId));
return NextResponse.json({
guilds: guilds.map((guild) => ({
const enriched = await enrichOwnerGuildListItems(
guilds.map((guild) => ({
id: guild.id,
locale: guild.settings?.locale ?? 'de',
createdAt: guild.createdAt.toISOString(),
blacklisted: blacklisted.has(guild.id)
})),
}))
);
const filtered =
q.length === 0
? enriched
: enriched.filter(
(guild) =>
guild.id.includes(q) || (guild.name?.toLowerCase().includes(q) ?? false)
);
return NextResponse.json({
guilds: filtered,
blacklist
});
} catch (error) {
@@ -34,12 +46,31 @@ export async function GET(request: Request) {
export async function POST(request: Request) {
try {
const session = await requireOwner('ADMIN');
const body = (await request.json()) as { action?: string; guildId?: string; reason?: string };
if (!body.guildId || !/^\d{17,20}$/.test(body.guildId)) {
return NextResponse.json({ error: 'Invalid guildId' }, { status: 400 });
}
if (body.action === 'createInvite') {
const session = await requireOwner('SUPPORT');
try {
const invite = await createOwnerGuildInvite(body.guildId);
await writeOwnerAudit(prisma, {
actorUserId: session.user.id,
action: 'guilds.createInvite',
targetType: 'Guild',
targetId: body.guildId,
after: invite
});
return NextResponse.json({ ok: true, ...invite });
} catch (error) {
const message = error instanceof Error ? error.message : 'Invite creation failed';
return NextResponse.json({ error: message }, { status: 502 });
}
}
const session = await requireOwner('ADMIN');
if (body.action === 'leave') {
const response = await fetch(`https://discord.com/api/v10/users/@me/guilds/${body.guildId}`, {
method: 'DELETE',

View File

@@ -2,7 +2,7 @@ import { notFound } from 'next/navigation';
import type { ReactNode } from 'react';
import { DashboardShell } from '@/components/layout/dashboard-shell';
import { requireGuildAccessOrRedirect } from '@/lib/auth';
import { getManageableGuilds } from '@/lib/guilds';
import { getManageableGuilds, resolveDashboardGuild } from '@/lib/guilds';
import { isOwnerUser } from '@/lib/owner-auth';
interface GuildLayoutProps {
@@ -13,23 +13,26 @@ interface GuildLayoutProps {
export default async function GuildLayout({ children, params }: GuildLayoutProps) {
const { guildId } = await params;
const session = await requireGuildAccessOrRedirect(guildId);
const guilds = await getManageableGuilds(session);
const currentGuild = guilds.find((guild) => guild.id === guildId);
const [resolved, guilds, showOwnerLink] = await Promise.all([
resolveDashboardGuild(session, guildId),
getManageableGuilds(session),
isOwnerUser(session.user.id)
]);
if (!currentGuild) {
if (!resolved) {
notFound();
}
const otherGuilds = guilds.filter((guild) => guild.id !== guildId);
const showOwnerLink = await isOwnerUser(session.user.id);
return (
<DashboardShell
guildId={guildId}
currentGuild={currentGuild}
currentGuild={resolved.guild}
otherGuilds={otherGuilds}
user={session.user}
showOwnerLink={showOwnerLink}
ownerBypass={resolved.ownerBypass}
>
{children}
</DashboardShell>

View File

@@ -1,7 +1,12 @@
import { OpenTicketsManager } from '@/components/modules/open-tickets-manager';
import { TicketCategoriesManager } from '@/components/modules/ticket-categories-manager';
import { TicketConfigForm } from '@/components/modules/ticket-config-form';
import { getLocale, t } from '@/lib/i18n';
import { getTicketConfigDashboard, listTicketCategories } from '@/lib/module-configs/tickets';
import {
getTicketConfigDashboard,
listOpenTickets,
listTicketCategories
} from '@/lib/module-configs/tickets';
interface TicketsPageProps {
params: Promise<{ guildId: string }>;
@@ -9,10 +14,11 @@ interface TicketsPageProps {
export default async function TicketsPage({ params }: TicketsPageProps) {
const { guildId } = await params;
const [locale, config, categories] = await Promise.all([
const [locale, config, categories, openTickets] = await Promise.all([
getLocale(),
getTicketConfigDashboard(guildId),
listTicketCategories(guildId)
listTicketCategories(guildId),
listOpenTickets(guildId)
]);
return (
@@ -21,6 +27,7 @@ export default async function TicketsPage({ params }: TicketsPageProps) {
<h1 className="text-2xl font-semibold">{t(locale, 'modules.tickets.label')}</h1>
<p className="text-sm text-muted-foreground">{t(locale, 'modules.tickets.description')}</p>
</div>
<OpenTicketsManager guildId={guildId} initialTickets={openTickets} />
<TicketConfigForm guildId={guildId} initialValue={config} />
<TicketCategoriesManager guildId={guildId} initialCategories={categories} />
</div>

View File

@@ -1,6 +1,10 @@
import { notFound } from 'next/navigation';
import { WelcomeForm } from '@/components/modules/welcome-form';
import { requireGuildAccessOrRedirect } from '@/lib/auth';
import { resolveDashboardGuild } from '@/lib/guilds';
import { getLocale, t } from '@/lib/i18n';
import { getWelcomeDashboard } from '@/lib/module-configs/welcome';
import { buildWelcomePreviewVars } from '@/lib/welcome-preview-vars';
interface WelcomePageProps {
params: Promise<{ guildId: string }>;
@@ -8,7 +12,16 @@ interface WelcomePageProps {
export default async function WelcomePage({ params }: WelcomePageProps) {
const { guildId } = await params;
const [locale, config] = await Promise.all([getLocale(), getWelcomeDashboard(guildId)]);
const session = await requireGuildAccessOrRedirect(guildId);
const [locale, config, resolved] = await Promise.all([
getLocale(),
getWelcomeDashboard(guildId),
resolveDashboardGuild(session, guildId)
]);
if (!resolved) {
notFound();
}
return (
<div className="space-y-6">
@@ -16,7 +29,11 @@ export default async function WelcomePage({ params }: WelcomePageProps) {
<h1 className="text-2xl font-semibold">{t(locale, 'modules.welcome.label')}</h1>
<p className="text-sm text-muted-foreground">{t(locale, 'modules.welcome.description')}</p>
</div>
<WelcomeForm guildId={guildId} initialValue={config} />
<WelcomeForm
guildId={guildId}
initialValue={config}
previewVars={buildWelcomePreviewVars(session.user, resolved.guild)}
/>
</div>
);
}

View File

@@ -3,6 +3,7 @@
@custom-variant dark (&:is(.dark *));
:root {
color-scheme: light;
--background: #f8f8fb;
--foreground: #111116;
--card: #ffffff;
@@ -26,6 +27,7 @@
}
.dark {
color-scheme: dark;
--background: #0a0a0d;
--foreground: #e6e6ea;
--card: #131318;

View File

@@ -0,0 +1,21 @@
import { ownerRoleAtLeast } from '@nexumi/shared';
import { AboutForm } from '@/components/owner/about-form';
import { getLocale, t } from '@/lib/i18n';
import { getAboutConfig } from '@/lib/owner-about';
import { requireOwnerOrRedirect } from '@/lib/owner-auth';
export default async function OwnerAboutPage() {
const session = await requireOwnerOrRedirect('VIEWER');
const [locale, config] = await Promise.all([getLocale(), getAboutConfig()]);
const canEdit = ownerRoleAtLeast(session.ownerRole, 'ADMIN');
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold">{t(locale, 'owner.about.title')}</h1>
<p className="text-sm text-muted-foreground">{t(locale, 'owner.about.subtitle')}</p>
</div>
<AboutForm initialValue={config} canEdit={canEdit} />
</div>
);
}

View File

@@ -1,5 +1,6 @@
import { GuildsManager } from '@/components/owner/owner-forms';
import { getLocale, t } from '@/lib/i18n';
import { enrichOwnerGuildListItems } from '@/lib/owner-guilds';
import { prisma } from '@/lib/prisma';
export default async function OwnerGuildsPage() {
@@ -13,6 +14,14 @@ export default async function OwnerGuildsPage() {
prisma.guildBlacklist.findMany()
]);
const blacklisted = new Set(blacklist.map((entry) => entry.guildId));
const initialGuilds = await enrichOwnerGuildListItems(
guilds.map((guild) => ({
id: guild.id,
locale: guild.settings?.locale ?? 'de',
createdAt: guild.createdAt.toISOString(),
blacklisted: blacklisted.has(guild.id)
}))
);
return (
<div className="space-y-6">
@@ -20,14 +29,7 @@ export default async function OwnerGuildsPage() {
<h1 className="text-2xl font-semibold">{t(locale, 'owner.guilds.title')}</h1>
<p className="text-sm text-muted-foreground">{t(locale, 'owner.guilds.subtitle')}</p>
</div>
<GuildsManager
initialGuilds={guilds.map((guild) => ({
id: guild.id,
locale: guild.settings?.locale ?? 'de',
createdAt: guild.createdAt.toISOString(),
blacklisted: blacklisted.has(guild.id)
}))}
/>
<GuildsManager initialGuilds={initialGuilds} />
</div>
);
}

View File

@@ -1,16 +1,21 @@
import { PresenceForm } from '@/components/owner/owner-forms';
import { ownerRoleAtLeast } from '@nexumi/shared';
import { PresenceForm } from '@/components/owner/presence-form';
import { getLocale, t } from '@/lib/i18n';
import { requireOwnerOrRedirect } from '@/lib/owner-auth';
import { getPresenceConfig } from '@/lib/owner-presence';
export default async function OwnerPresencePage() {
const session = await requireOwnerOrRedirect('VIEWER');
const [locale, config] = await Promise.all([getLocale(), getPresenceConfig()]);
const canEdit = ownerRoleAtLeast(session.ownerRole, 'ADMIN');
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold">{t(locale, 'owner.presence.title')}</h1>
<p className="text-sm text-muted-foreground">{t(locale, 'owner.presence.subtitle')}</p>
</div>
<PresenceForm initialValue={config} />
<PresenceForm initialValue={config} canEdit={canEdit} />
</div>
);
}

View File

@@ -18,6 +18,8 @@ interface DashboardShellProps {
otherGuilds: DashboardGuild[];
user: SessionUser;
showOwnerLink?: boolean;
/** True when an Owner-panel user opened a guild they do not manage on Discord. */
ownerBypass?: boolean;
children: ReactNode;
}
@@ -27,6 +29,7 @@ export function DashboardShell({
otherGuilds,
user,
showOwnerLink = false,
ownerBypass = false,
children
}: DashboardShellProps) {
const t = useTranslations();
@@ -59,7 +62,21 @@ export function DashboardShell({
</div>
</header>
<main className="flex-1 px-4 py-6 sm:px-6 sm:py-8">
<div className="mx-auto w-full max-w-[1200px]">{children}</div>
<div className="mx-auto w-full max-w-[1200px] space-y-4">
{ownerBypass ? (
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 px-4 py-3 text-sm text-amber-100">
<p className="font-medium">{t('dashboard.ownerBypass.title')}</p>
<p className="mt-1 text-amber-100/80">{t('dashboard.ownerBypass.description')}</p>
<a
href="/owner/guilds"
className="mt-2 inline-block text-sm font-medium text-amber-50 underline underline-offset-4 hover:text-white"
>
{t('dashboard.ownerBypass.backToOwner')}
</a>
</div>
) : null}
{children}
</div>
</main>
</div>
</div>

View File

@@ -6,8 +6,10 @@ import type {
AutomodConfigDashboard,
AutomodRuleDashboard
} from '@nexumi/shared';
import { mergeDefaultBlockedWords } from '@nexumi/shared';
import { FieldAnchor } from '@/components/layout/field-anchor';
import { useTranslations } from '@/components/locale-provider';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { DiscordChannelMultiSelect } from '@/components/ui/discord-channel-select';
import { DiscordRoleMultiSelect } from '@/components/ui/discord-role-select';
@@ -25,7 +27,12 @@ async function saveAutomod(guildId: string, value: AutomodConfigDashboard): Prom
try {
const cleaned: AutomodConfigDashboard = {
...value,
rules: value.rules.map((rule) => ({
rules: value.rules.map((rule) => {
const words = (rule.wordList.words ?? []).map((s) => s.trim()).filter(Boolean);
const regexPatterns = (rule.wordList.regexPatterns ?? [])
.map((s) => s.trim())
.filter(Boolean);
return {
...rule,
durationMs: rule.action === 'TIMEOUT' ? rule.durationMs ?? 60_000 : null,
threshold: {
@@ -34,10 +41,12 @@ async function saveAutomod(guildId: string, value: AutomodConfigDashboard): Prom
linkBlacklist: (rule.threshold.linkBlacklist ?? []).map((s) => s.trim()).filter(Boolean)
},
wordList: {
words: (rule.wordList.words ?? []).map((s) => s.trim()).filter(Boolean),
regexPatterns: (rule.wordList.regexPatterns ?? []).map((s) => s.trim()).filter(Boolean)
words,
regexPatterns,
...(words.length === 0 ? { userCleared: true as const } : {})
}
}))
};
})
};
const response = await fetch(`/api/guilds/${guildId}/automod`, {
method: 'PATCH',
@@ -279,7 +288,24 @@ function RuleCard({
{type === 'WORD_FILTER' ? (
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<Label>{t('modulePages.automod.wordList')}</Label>
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
onChange({
wordList: {
...rule.wordList,
words: mergeDefaultBlockedWords(rule.wordList.words ?? [])
}
})
}
>
{t('modulePages.automod.loadDefaultWords')}
</Button>
</div>
<StringListEditor
value={rule.wordList.words}
onChange={(words) => onChange({ wordList: { ...rule.wordList, words } })}

View File

@@ -42,7 +42,8 @@ const LOG_EVENT_TYPES: LogEventTypeDashboard[] = [
'THREAD_UPDATE',
'THREAD_DELETE',
'GUILD_UPDATE',
'MOD_ACTION'
'MOD_ACTION',
'VERIFICATION_FAIL'
];
interface LocalLogChannelGroup {

View File

@@ -0,0 +1,237 @@
'use client';
import type { TicketDashboard, TicketPriority } from '@nexumi/shared';
import { ExternalLink, RefreshCw } from 'lucide-react';
import { useState } from 'react';
import { toast } from 'sonner';
import { FieldAnchor } from '@/components/layout/field-anchor';
import { useTranslations } from '@/components/locale-provider';
import { Badge, type BadgeProps } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
const STATUS_VARIANTS: Record<'OPEN' | 'CLAIMED', BadgeProps['variant']> = {
OPEN: 'secondary',
CLAIMED: 'success'
};
const PRIORITY_VARIANTS: Record<TicketPriority, BadgeProps['variant']> = {
LOW: 'outline',
NORMAL: 'secondary',
HIGH: 'default',
URGENT: 'destructive'
};
const PRIORITIES: TicketPriority[] = ['LOW', 'NORMAL', 'HIGH', 'URGENT'];
function formatDate(iso: string): string {
return new Date(iso).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });
}
function discordChannelUrl(guildId: string, ticket: TicketDashboard): string | null {
const channelId = ticket.channelId ?? ticket.threadId;
if (!channelId) {
return null;
}
return `https://discord.com/channels/${guildId}/${channelId}`;
}
interface OpenTicketsManagerProps {
guildId: string;
initialTickets: TicketDashboard[];
}
export function OpenTicketsManager({ guildId, initialTickets }: OpenTicketsManagerProps) {
const t = useTranslations();
const [tickets, setTickets] = useState<TicketDashboard[]>(initialTickets);
const [pendingId, setPendingId] = useState<string | null>(null);
const [reasons, setReasons] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(false);
async function reload() {
setLoading(true);
try {
const response = await fetch(`/api/guilds/${guildId}/tickets/open`);
const body = (await response.json().catch(() => null)) as { tickets?: TicketDashboard[] } | null;
if (response.ok && body?.tickets) {
setTickets(body.tickets);
} else {
toast.error(t('common.saveError'));
}
} catch {
toast.error(t('common.networkError'));
} finally {
setLoading(false);
}
}
async function runAction(
ticket: TicketDashboard,
payload:
| { action: 'close'; reason?: string }
| { action: 'claim' }
| { action: 'priority'; priority: TicketPriority }
) {
setPendingId(ticket.id);
try {
const response = await fetch(`/api/guilds/${guildId}/tickets/open/${ticket.id}/action`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const body = (await response.json().catch(() => null)) as
| (TicketDashboard & { error?: string })
| null;
if (!response.ok || !body) {
toast.error(body?.error ?? t('common.saveError'));
return;
}
if (payload.action === 'close') {
setTickets((prev) => prev.filter((entry) => entry.id !== ticket.id));
toast.success(t('modulePages.tickets.openList.closed'));
} else {
setTickets((prev) => prev.map((entry) => (entry.id === ticket.id ? body : entry)));
toast.success(t('common.saveSuccess'));
}
} catch {
toast.error(t('common.networkError'));
} finally {
setPendingId(null);
}
}
return (
<FieldAnchor id="field-tickets-open">
<Card>
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<CardTitle>{t('modulePages.tickets.openList.title')}</CardTitle>
<CardDescription>{t('modulePages.tickets.openList.description')}</CardDescription>
</div>
<Button type="button" variant="outline" size="sm" onClick={reload} disabled={loading}>
<RefreshCw className={`size-4 ${loading ? 'animate-spin' : ''}`} />
{t('modulePages.tickets.openList.refresh')}
</Button>
</CardHeader>
<CardContent className="space-y-3">
{!loading && tickets.length === 0 && (
<p className="text-sm text-muted-foreground">{t('modulePages.tickets.openList.empty')}</p>
)}
{tickets.map((ticket) => {
const channelUrl = discordChannelUrl(guildId, ticket);
const busy = pendingId === ticket.id;
const statusKey = ticket.status === 'CLAIMED' ? 'CLAIMED' : 'OPEN';
return (
<div key={ticket.id} className="space-y-3 rounded-md border border-border p-4">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0 space-y-1">
<p className="font-medium">
{ticket.subject?.trim() || t('modulePages.tickets.openList.noSubject')}
</p>
<p className="text-xs text-muted-foreground">
{ticket.categoryName
? t('modulePages.tickets.openList.categoryLine', {
category: ticket.categoryName
})
: t('modulePages.tickets.openList.noCategory')}
{' · '}
&lt;@{ticket.openerId}&gt;
{ticket.claimedById ? (
<>
{' · '}
{t('modulePages.tickets.openList.claimedBy', {
user: `<@${ticket.claimedById}>`
})}
</>
) : null}
{' · '}
{formatDate(ticket.lastActivityAt)}
</p>
</div>
<div className="flex flex-wrap gap-2">
<Badge variant={STATUS_VARIANTS[statusKey]}>
{t(`modulePages.tickets.openList.status.${statusKey}`)}
</Badge>
<Badge variant={PRIORITY_VARIANTS[ticket.priority]}>
{t(`modulePages.tickets.openList.priority.${ticket.priority}`)}
</Badge>
</div>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<Input
className="sm:max-w-xs"
placeholder={t('modulePages.tickets.openList.closeReasonPlaceholder')}
value={reasons[ticket.id] ?? ''}
disabled={busy}
onChange={(event) =>
setReasons((prev) => ({ ...prev, [ticket.id]: event.target.value }))
}
/>
<div className="flex flex-wrap gap-2">
{channelUrl ? (
<Button type="button" variant="outline" size="sm" asChild>
<a href={channelUrl} target="_blank" rel="noreferrer">
<ExternalLink className="size-4" />
{t('modulePages.tickets.openList.openInDiscord')}
</a>
</Button>
) : null}
<Button
type="button"
variant="secondary"
size="sm"
disabled={busy}
onClick={() => runAction(ticket, { action: 'claim' })}
>
{t('modulePages.tickets.openList.claim')}
</Button>
<Button
type="button"
variant="destructive"
size="sm"
disabled={busy}
onClick={() =>
runAction(ticket, {
action: 'close',
reason: reasons[ticket.id]?.trim() || undefined
})
}
>
{t('modulePages.tickets.openList.close')}
</Button>
<Select
value={ticket.priority}
disabled={busy}
onValueChange={(priority) =>
runAction(ticket, {
action: 'priority',
priority: priority as TicketPriority
})
}
>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PRIORITIES.map((priority) => (
<SelectItem key={priority} value={priority}>
{t(`modulePages.tickets.openList.priority.${priority}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
);
})}
</CardContent>
</Card>
</FieldAnchor>
);
}

View File

@@ -29,10 +29,12 @@ import { Textarea } from '@/components/ui/textarea';
import { cn } from '@/lib/utils';
type ScheduleContentMode = 'text' | 'embed' | 'components_v2';
type ScheduleTimingMode = 'once' | 'cron';
const EMPTY_DRAFT = {
channelId: '',
contentMode: 'text' as ScheduleContentMode,
timingMode: 'once' as ScheduleTimingMode,
content: '',
embed: null as WelcomeEmbed | null,
components: null as MessageComponentsV2 | null,
@@ -76,6 +78,19 @@ function schedulePreview(schedule: ScheduledMessageDashboard, t: (key: string) =
return t('modulePages.scheduler.noContent');
}
function scheduleErrorMessage(code: string | undefined, t: (key: string) => string): string {
if (code === 'invalid_cron') {
return t('modulePages.scheduler.errors.invalidCron');
}
if (code === 'invalid_when') {
return t('modulePages.scheduler.errors.invalidWhen');
}
if (code === 'missing_schedule_time') {
return t('modulePages.scheduler.errors.missingScheduleTime');
}
return code?.trim() ? code : t('common.saveError');
}
interface SchedulerManagerProps {
guildId: string;
initialSchedules: ScheduledMessageDashboard[];
@@ -98,7 +113,10 @@ export function SchedulerManager({ guildId, initialSchedules }: SchedulerManager
(draft.contentMode === 'embed' && (embedHasContent(embed) || embed?.color !== undefined)) ||
(draft.contentMode === 'components_v2' && componentsV2HasContent(components));
if (!draft.channelId.trim() || !hasContent || (!draft.cron.trim() && !draft.runAt)) {
const cron = draft.timingMode === 'cron' ? draft.cron.trim() : '';
const runAt = draft.timingMode === 'once' ? draft.runAt.trim() : '';
if (!draft.channelId.trim() || !hasContent || (!cron && !runAt)) {
toast.error(t('modulePages.scheduler.formIncomplete'));
return;
}
@@ -114,15 +132,16 @@ export function SchedulerManager({ guildId, initialSchedules }: SchedulerManager
embed,
components,
rolePingId: draft.rolePingId.trim() || undefined,
cron: draft.cron.trim() || null,
runAt: draft.cron.trim() ? null : draft.runAt ? new Date(draft.runAt).toISOString() : null
// Send naive datetime-local; server interprets it in the guild timezone.
cron: cron || null,
runAt: cron ? null : runAt || null
})
});
const body = (await response.json().catch(() => null)) as
| (ScheduledMessageDashboard & { error?: string })
| null;
if (!response.ok || !body) {
toast.error(body?.error ?? t('common.saveError'));
toast.error(scheduleErrorMessage(body?.error, t));
return;
}
setSchedules((prev) => [...prev, body]);
@@ -179,24 +198,55 @@ export function SchedulerManager({ guildId, initialSchedules }: SchedulerManager
placeholder={t('common.optional')}
/>
</div>
</div>
<div className="space-y-2">
<Label>{t('modulePages.scheduler.cron')}</Label>
<Label>{t('modulePages.scheduler.timingMode')}</Label>
<div className="flex flex-wrap gap-2">
{(['once', 'cron'] as ScheduleTimingMode[]).map((mode) => (
<Button
key={mode}
type="button"
variant={draft.timingMode === mode ? 'default' : 'outline'}
size="sm"
className={cn(draft.timingMode !== mode && 'text-muted-foreground')}
onClick={() =>
setDraft((prev) => ({
...prev,
timingMode: mode,
cron: mode === 'cron' ? prev.cron : '',
runAt: mode === 'once' ? prev.runAt : ''
}))
}
>
{t(`modulePages.scheduler.timingModes.${mode}`)}
</Button>
))}
</div>
</div>
{draft.timingMode === 'cron' ? (
<div className="space-y-2">
<Label htmlFor="scheduler-cron">{t('modulePages.scheduler.cron')}</Label>
<Input
id="scheduler-cron"
value={draft.cron}
onChange={(event) => setDraft((prev) => ({ ...prev, cron: event.target.value }))}
placeholder="0 9 * * *"
autoComplete="off"
/>
</div>
) : (
<div className="space-y-2">
<Label>{t('modulePages.scheduler.runAt')}</Label>
<Label htmlFor="scheduler-run-at">{t('modulePages.scheduler.runAt')}</Label>
<Input
id="scheduler-run-at"
type="datetime-local"
value={draft.runAt}
disabled={Boolean(draft.cron.trim())}
onChange={(event) => setDraft((prev) => ({ ...prev, runAt: event.target.value }))}
/>
</div>
</div>
)}
<div className="space-y-2">
<Label>{t('modulePages.scheduler.contentMode')}</Label>

View File

@@ -1,6 +1,6 @@
'use client';
import type { SelfRoleBehavior, SelfRoleEntry, SelfRoleMode, SelfRolePanelDashboard } from '@nexumi/shared';
import type { SelfRoleBehavior, SelfRoleMode, SelfRolePanelDashboard } from '@nexumi/shared';
import { Plus, Trash2 } from 'lucide-react';
import { useState } from 'react';
import { toast } from 'sonner';
@@ -12,35 +12,20 @@ import { DiscordChannelSelect } from '@/components/ui/discord-channel-select';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import {
builderRowsToRoles,
emptyBuilderRow,
rolesToBuilderRows,
type SelfRoleBuilderRow,
SelfRoleEntriesBuilder
} from '@/components/ui/self-role-entries-builder';
const MODES: SelfRoleMode[] = ['BUTTONS', 'DROPDOWN', 'REACTIONS'];
const BEHAVIORS: SelfRoleBehavior[] = ['NORMAL', 'UNIQUE', 'VERIFY', 'TEMPORARY'];
function rolesToText(roles: SelfRoleEntry[]): string {
return roles.map((role) => [role.roleId, role.label, role.emoji ?? '', role.description ?? ''].join(' | ')).join('\n');
}
function textToRoles(text: string): SelfRoleEntry[] {
return text
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0)
.map((line) => {
const [roleId, label, emoji, description] = line.split('|').map((part) => part.trim());
return {
roleId: roleId ?? '',
label: label || roleId || '',
emoji: emoji || undefined,
description: description || undefined
};
})
.filter((role) => role.roleId.length > 0);
}
interface LocalPanel extends SelfRolePanelDashboard {
interface LocalPanel extends Omit<SelfRolePanelDashboard, 'roles'> {
clientKey: string;
rolesText: string;
roles: SelfRoleBuilderRow[];
saving?: boolean;
}
@@ -50,7 +35,7 @@ const EMPTY_DRAFT = {
description: '',
mode: 'BUTTONS' as SelfRoleMode,
behavior: 'NORMAL' as SelfRoleBehavior,
rolesText: ''
roles: [emptyBuilderRow()] as SelfRoleBuilderRow[]
};
interface SelfRolesManagerProps {
@@ -58,24 +43,43 @@ interface SelfRolesManagerProps {
initialPanels: SelfRolePanelDashboard[];
}
function toastBuilderError(
t: ReturnType<typeof useTranslations>,
error: 'incomplete' | 'emojiRequired' | 'tooMany'
) {
if (error === 'emojiRequired') {
toast.error(t('modulePages.selfroles.emojiRequired'));
return;
}
if (error === 'tooMany') {
toast.error(t('modulePages.selfroles.tooManyRoles'));
return;
}
toast.error(t('modulePages.selfroles.formIncomplete'));
}
export function SelfRolesManager({ guildId, initialPanels }: SelfRolesManagerProps) {
const t = useTranslations();
const [panels, setPanels] = useState<LocalPanel[]>(
initialPanels.map((panel, index) => ({
...panel,
clientKey: panel.id ?? `existing-${index}`,
rolesText: rolesToText(panel.roles)
roles: rolesToBuilderRows(panel.roles)
}))
);
const [draft, setDraft] = useState(EMPTY_DRAFT);
const [creating, setCreating] = useState(false);
async function handleCreate() {
const roles = textToRoles(draft.rolesText);
if (!draft.channelId.trim() || !draft.title.trim() || roles.length === 0) {
if (!draft.channelId.trim() || !draft.title.trim()) {
toast.error(t('modulePages.selfroles.formIncomplete'));
return;
}
const parsed = builderRowsToRoles(draft.roles, draft.mode === 'REACTIONS');
if (parsed.error || !parsed.value.length) {
toastBuilderError(t, parsed.error ?? 'incomplete');
return;
}
setCreating(true);
try {
const response = await fetch(`/api/guilds/${guildId}/selfroles`, {
@@ -87,7 +91,7 @@ export function SelfRolesManager({ guildId, initialPanels }: SelfRolesManagerPro
description: draft.description.trim() || null,
mode: draft.mode,
behavior: draft.behavior,
roles
roles: parsed.value
})
});
const body = (await response.json().catch(() => null)) as (SelfRolePanelDashboard & { error?: string }) | null;
@@ -96,10 +100,14 @@ export function SelfRolesManager({ guildId, initialPanels }: SelfRolesManagerPro
return;
}
setPanels((prev) => [
{ ...body, clientKey: body.id ?? `new-${Date.now()}`, rolesText: rolesToText(body.roles) },
{
...body,
clientKey: body.id ?? `new-${Date.now()}`,
roles: rolesToBuilderRows(body.roles)
},
...prev
]);
setDraft(EMPTY_DRAFT);
setDraft({ ...EMPTY_DRAFT, roles: [emptyBuilderRow()] });
toast.success(t('common.saveSuccess'));
} catch {
toast.error(t('common.saveError'));
@@ -109,6 +117,14 @@ export function SelfRolesManager({ guildId, initialPanels }: SelfRolesManagerPro
}
async function handleSave(panel: LocalPanel) {
if (!panel.id) {
return;
}
const parsed = builderRowsToRoles(panel.roles, panel.mode === 'REACTIONS');
if (parsed.error || !parsed.value.length) {
toastBuilderError(t, parsed.error ?? 'incomplete');
return;
}
setPanels((prev) => prev.map((entry) => (entry.clientKey === panel.clientKey ? { ...entry, saving: true } : entry)));
try {
const response = await fetch(`/api/guilds/${guildId}/selfroles/${panel.id}`, {
@@ -120,7 +136,7 @@ export function SelfRolesManager({ guildId, initialPanels }: SelfRolesManagerPro
description: panel.description,
mode: panel.mode,
behavior: panel.behavior,
roles: textToRoles(panel.rolesText)
roles: parsed.value
})
});
const body = (await response.json().catch(() => null)) as (SelfRolePanelDashboard & { error?: string }) | null;
@@ -128,11 +144,25 @@ export function SelfRolesManager({ guildId, initialPanels }: SelfRolesManagerPro
toast.error(body?.error ?? t('common.saveError'));
return;
}
setPanels((prev) =>
prev.map((entry) =>
entry.clientKey === panel.clientKey
? {
...entry,
...body,
roles: rolesToBuilderRows(body.roles),
saving: false
}
: entry
)
);
toast.success(t('common.saveSuccess'));
} catch {
toast.error(t('common.saveError'));
} finally {
setPanels((prev) => prev.map((entry) => (entry.clientKey === panel.clientKey ? { ...entry, saving: false } : entry)));
setPanels((prev) =>
prev.map((entry) => (entry.clientKey === panel.clientKey ? { ...entry, saving: false } : entry))
);
}
}
@@ -173,11 +203,17 @@ export function SelfRolesManager({ guildId, initialPanels }: SelfRolesManagerPro
</div>
<div className="space-y-2">
<Label>{t('modulePages.selfroles.panelTitle')}</Label>
<Input value={draft.title} onChange={(event) => setDraft((prev) => ({ ...prev, title: event.target.value }))} />
<Input
value={draft.title}
onChange={(event) => setDraft((prev) => ({ ...prev, title: event.target.value }))}
/>
</div>
<div className="space-y-2">
<Label>{t('modulePages.selfroles.mode')}</Label>
<Select value={draft.mode} onValueChange={(mode) => setDraft((prev) => ({ ...prev, mode: mode as SelfRoleMode }))}>
<Select
value={draft.mode}
onValueChange={(mode) => setDraft((prev) => ({ ...prev, mode: mode as SelfRoleMode }))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
@@ -192,7 +228,12 @@ export function SelfRolesManager({ guildId, initialPanels }: SelfRolesManagerPro
</div>
<div className="space-y-2">
<Label>{t('modulePages.selfroles.behavior')}</Label>
<Select value={draft.behavior} onValueChange={(behavior) => setDraft((prev) => ({ ...prev, behavior: behavior as SelfRoleBehavior }))}>
<Select
value={draft.behavior}
onValueChange={(behavior) =>
setDraft((prev) => ({ ...prev, behavior: behavior as SelfRoleBehavior }))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
@@ -208,17 +249,19 @@ export function SelfRolesManager({ guildId, initialPanels }: SelfRolesManagerPro
</div>
<div className="space-y-2">
<Label>{t('modulePages.selfroles.description')}</Label>
<Input value={draft.description} onChange={(event) => setDraft((prev) => ({ ...prev, description: event.target.value }))} />
<Input
value={draft.description}
onChange={(event) => setDraft((prev) => ({ ...prev, description: event.target.value }))}
/>
</div>
<div className="space-y-2">
<Label>{t('modulePages.selfroles.roles')}</Label>
<Textarea
rows={5}
value={draft.rolesText}
onChange={(event) => setDraft((prev) => ({ ...prev, rolesText: event.target.value }))}
placeholder={t('modulePages.selfroles.rolesPlaceholder')}
<SelfRoleEntriesBuilder
guildId={guildId}
value={draft.roles}
requireEmoji={draft.mode === 'REACTIONS'}
onChange={(roles) => setDraft((prev) => ({ ...prev, roles }))}
/>
<p className="text-xs text-muted-foreground">{t('modulePages.selfroles.rolesHint')}</p>
</div>
<Button type="button" onClick={handleCreate} disabled={creating}>
<Plus className="size-4" />
@@ -229,14 +272,15 @@ export function SelfRolesManager({ guildId, initialPanels }: SelfRolesManagerPro
</FieldAnchor>
<FieldAnchor id="field-selfroles-list">
<Card>
<CardHeader>
<CardTitle>{t('modulePages.selfroles.listTitle')}</CardTitle>
<CardDescription>{t('modulePages.selfroles.listDescription')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{panels.length === 0 && <p className="text-sm text-muted-foreground">{t('modulePages.selfroles.empty')}</p>}
{panels.length === 0 && (
<p className="text-sm text-muted-foreground">{t('modulePages.selfroles.empty')}</p>
)}
{panels.map((panel) => (
<div key={panel.clientKey} className="space-y-3 rounded-md border border-border p-4">
<div className="grid gap-3 sm:grid-cols-2">
@@ -247,7 +291,9 @@ export function SelfRolesManager({ guildId, initialPanels }: SelfRolesManagerPro
value={panel.channelId}
onChange={(channelId) =>
setPanels((prev) =>
prev.map((entry) => (entry.clientKey === panel.clientKey ? { ...entry, channelId } : entry))
prev.map((entry) =>
entry.clientKey === panel.clientKey ? { ...entry, channelId } : entry
)
)
}
/>
@@ -257,7 +303,13 @@ export function SelfRolesManager({ guildId, initialPanels }: SelfRolesManagerPro
<Input
value={panel.title}
onChange={(event) =>
setPanels((prev) => prev.map((entry) => (entry.clientKey === panel.clientKey ? { ...entry, title: event.target.value } : entry)))
setPanels((prev) =>
prev.map((entry) =>
entry.clientKey === panel.clientKey
? { ...entry, title: event.target.value }
: entry
)
)
}
/>
</div>
@@ -266,7 +318,13 @@ export function SelfRolesManager({ guildId, initialPanels }: SelfRolesManagerPro
<Select
value={panel.mode}
onValueChange={(mode) =>
setPanels((prev) => prev.map((entry) => (entry.clientKey === panel.clientKey ? { ...entry, mode: mode as SelfRoleMode } : entry)))
setPanels((prev) =>
prev.map((entry) =>
entry.clientKey === panel.clientKey
? { ...entry, mode: mode as SelfRoleMode }
: entry
)
)
}
>
<SelectTrigger>
@@ -287,7 +345,11 @@ export function SelfRolesManager({ guildId, initialPanels }: SelfRolesManagerPro
value={panel.behavior}
onValueChange={(behavior) =>
setPanels((prev) =>
prev.map((entry) => (entry.clientKey === panel.clientKey ? { ...entry, behavior: behavior as SelfRoleBehavior } : entry))
prev.map((entry) =>
entry.clientKey === panel.clientKey
? { ...entry, behavior: behavior as SelfRoleBehavior }
: entry
)
)
}
>
@@ -306,11 +368,17 @@ export function SelfRolesManager({ guildId, initialPanels }: SelfRolesManagerPro
</div>
<div className="space-y-2">
<Label>{t('modulePages.selfroles.roles')}</Label>
<Textarea
rows={4}
value={panel.rolesText}
onChange={(event) =>
setPanels((prev) => prev.map((entry) => (entry.clientKey === panel.clientKey ? { ...entry, rolesText: event.target.value } : entry)))
<SelfRoleEntriesBuilder
guildId={guildId}
value={panel.roles}
requireEmoji={panel.mode === 'REACTIONS'}
disabled={panel.saving}
onChange={(roles) =>
setPanels((prev) =>
prev.map((entry) =>
entry.clientKey === panel.clientKey ? { ...entry, roles } : entry
)
)
}
/>
</div>
@@ -318,7 +386,13 @@ export function SelfRolesManager({ guildId, initialPanels }: SelfRolesManagerPro
<Button type="button" variant="outline" disabled={panel.saving} onClick={() => handleSave(panel)}>
{panel.saving ? t('common.saving') : t('common.save')}
</Button>
<Button type="button" variant="ghost" size="icon" aria-label={t('common.remove')} onClick={() => handleDelete(panel)}>
<Button
type="button"
variant="ghost"
size="icon"
aria-label={t('common.remove')}
onClick={() => handleDelete(panel)}
>
<Trash2 className="size-4" />
</Button>
</div>

View File

@@ -12,7 +12,18 @@ import { Switch } from '@/components/ui/switch';
import type { SettingsSaveResult } from '@/components/settings/settings-form';
import { SettingsForm } from '@/components/settings/settings-form';
async function saveStarboard(guildId: string, value: StarboardConfigDashboard): Promise<SettingsSaveResult> {
async function saveStarboard(
guildId: string,
value: StarboardConfigDashboard,
t: (key: string) => string
): Promise<SettingsSaveResult> {
if (value.enabled && !value.channelId) {
return { ok: false, error: t('modulePages.starboard.errors.channelRequired') };
}
if (value.threshold < 1 || value.threshold > 100) {
return { ok: false, error: t('modulePages.starboard.errors.thresholdRange') };
}
try {
const response = await fetch(`/api/guilds/${guildId}/starboard`, {
method: 'PATCH',
@@ -42,7 +53,7 @@ export function StarboardForm({ guildId, initialValue }: StarboardFormProps) {
return (
<SettingsForm<StarboardConfigDashboard>
initialValue={initialValue}
onSave={(value) => saveStarboard(guildId, value)}
onSave={(value) => saveStarboard(guildId, value, t)}
>
{({ value, setValue }) => (
<Card>
@@ -53,8 +64,14 @@ export function StarboardForm({ guildId, initialValue }: StarboardFormProps) {
<CardContent className="space-y-4">
<FieldAnchor id="field-starboard-enabled">
<div className="flex items-center justify-between rounded-md border border-border p-4">
<div className="space-y-0.5 pr-4">
<Label>{t('modulePages.starboard.enabledLabel')}</Label>
<Switch checked={value.enabled} onCheckedChange={(enabled) => setValue((prev) => ({ ...prev, enabled }))} />
<p className="text-xs text-muted-foreground">{t('modulePages.starboard.enabledHint')}</p>
</div>
<Switch
checked={value.enabled}
onCheckedChange={(enabled) => setValue((prev) => ({ ...prev, enabled }))}
/>
</div>
</FieldAnchor>
<div className="grid gap-4 sm:grid-cols-3">
@@ -71,20 +88,46 @@ export function StarboardForm({ guildId, initialValue }: StarboardFormProps) {
<FieldAnchor id="field-starboard-emoji">
<div className="space-y-2">
<Label htmlFor={emojiId}>{t('modulePages.starboard.emoji')}</Label>
<Input id={emojiId} value={value.emoji} onChange={(event) => setValue((prev) => ({ ...prev, emoji: event.target.value }))} />
<Input
id={emojiId}
value={value.emoji}
maxLength={80}
onChange={(event) =>
setValue((prev) => ({ ...prev, emoji: event.target.value }))
}
placeholder="⭐ or <:name:id>"
/>
<p className="text-xs text-muted-foreground">{t('modulePages.starboard.emojiHint')}</p>
</div>
</FieldAnchor>
<FieldAnchor id="field-starboard-threshold">
<div className="space-y-2">
<Label htmlFor={thresholdId}>{t('modulePages.starboard.threshold')}</Label>
<Input id={thresholdId} type="number" min={1} value={value.threshold} onChange={(event) => setValue((prev) => ({ ...prev, threshold: Number(event.target.value) || 1 }))} />
<Input
id={thresholdId}
type="number"
min={1}
max={100}
value={value.threshold}
onChange={(event) =>
setValue((prev) => ({
...prev,
threshold: Number(event.target.value) || 1
}))
}
/>
</div>
</FieldAnchor>
</div>
<FieldAnchor id="field-starboard-self">
<div className="flex items-center justify-between rounded-md border border-border p-4">
<Label>{t('modulePages.starboard.allowSelfStar')}</Label>
<Switch checked={value.allowSelfStar} onCheckedChange={(allowSelfStar) => setValue((prev) => ({ ...prev, allowSelfStar }))} />
<Switch
checked={value.allowSelfStar}
onCheckedChange={(allowSelfStar) =>
setValue((prev) => ({ ...prev, allowSelfStar }))
}
/>
</div>
</FieldAnchor>
<FieldAnchor id="field-starboard-ignored">
@@ -93,8 +136,11 @@ export function StarboardForm({ guildId, initialValue }: StarboardFormProps) {
<DiscordChannelMultiSelect
guildId={guildId}
value={value.ignoredChannelIds}
onChange={(ignoredChannelIds) => setValue((prev) => ({ ...prev, ignoredChannelIds }))}
onChange={(ignoredChannelIds) =>
setValue((prev) => ({ ...prev, ignoredChannelIds }))
}
/>
<p className="text-xs text-muted-foreground">{t('modulePages.starboard.nsfwHint')}</p>
</div>
</FieldAnchor>
</CardContent>

View File

@@ -42,6 +42,7 @@ const EMPTY_DRAFT = {
components: null as MessageComponentsV2 | null,
responseType: 'TEXT' as TagResponseType,
triggerWord: '',
cooldownSeconds: 15,
allowedRoleIds: [] as string[],
allowedChannelIds: [] as string[]
};
@@ -56,6 +57,7 @@ export function TagsManager({ guildId, initialTags }: TagsManagerProps) {
const [tags, setTags] = useState<LocalTag[]>(
initialTags.map((tag, index) => ({
...tag,
cooldownSeconds: tag.cooldownSeconds ?? 15,
embed: tag.embed ?? null,
components: tag.components ?? null,
clientKey: tag.id ?? `existing-${index}`
@@ -100,6 +102,7 @@ export function TagsManager({ guildId, initialTags }: TagsManagerProps) {
components: validated.components,
responseType: draft.responseType,
triggerWord: draft.triggerWord.trim() || null,
cooldownSeconds: draft.cooldownSeconds,
allowedRoleIds: draft.allowedRoleIds,
allowedChannelIds: draft.allowedChannelIds
})
@@ -142,6 +145,7 @@ export function TagsManager({ guildId, initialTags }: TagsManagerProps) {
components: validated.components,
responseType: tag.responseType,
triggerWord: tag.triggerWord,
cooldownSeconds: tag.cooldownSeconds,
allowedRoleIds: tag.allowedRoleIds,
allowedChannelIds: tag.allowedChannelIds
})
@@ -255,6 +259,22 @@ export function TagsManager({ guildId, initialTags }: TagsManagerProps) {
<Label>{t('modulePages.tags.triggerWord')}</Label>
<Input value={draft.triggerWord} onChange={(event) => setDraft((prev) => ({ ...prev, triggerWord: event.target.value }))} placeholder={t('common.optional')} />
</div>
<div className="space-y-2">
<Label>{t('modulePages.tags.cooldownSeconds')}</Label>
<Input
type="number"
min={0}
max={3600}
value={draft.cooldownSeconds}
onChange={(event) =>
setDraft((prev) => ({
...prev,
cooldownSeconds: Math.min(3600, Math.max(0, Number(event.target.value) || 0))
}))
}
/>
<p className="text-xs text-muted-foreground">{t('modulePages.tags.cooldownHint')}</p>
</div>
</div>
{renderResponseEditor(
draft.responseType,
@@ -325,6 +345,28 @@ export function TagsManager({ guildId, initialTags }: TagsManagerProps) {
<Label>{t('modulePages.tags.triggerWord')}</Label>
<Input value={tag.triggerWord ?? ''} onChange={(event) => setTags((prev) => prev.map((entry) => (entry.clientKey === tag.clientKey ? { ...entry, triggerWord: event.target.value || null } : entry)))} />
</div>
<div className="space-y-2">
<Label>{t('modulePages.tags.cooldownSeconds')}</Label>
<Input
type="number"
min={0}
max={3600}
value={tag.cooldownSeconds}
onChange={(event) =>
setTags((prev) =>
prev.map((entry) =>
entry.clientKey === tag.clientKey
? {
...entry,
cooldownSeconds: Math.min(3600, Math.max(0, Number(event.target.value) || 0))
}
: entry
)
)
}
/>
<p className="text-xs text-muted-foreground">{t('modulePages.tags.cooldownHint')}</p>
</div>
</div>
{renderResponseEditor(
tag.responseType,

Some files were not shown because too many files have changed in this diff Show More