Compare commits

..

1 Commits

Author SHA1 Message Date
2f01335e61 Merge pull request 'deploy' (#1) from deploy into main
Reviewed-on: #1
2026-07-24 08:41:09 +00:00
156 changed files with 1178 additions and 8699 deletions

View File

@@ -1,12 +0,0 @@
-- 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

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

View File

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

View File

@@ -1,16 +0,0 @@
-- 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

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

View File

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

View File

@@ -84,31 +84,9 @@ export async function evaluateCommandGate(
if (existing > 0) { if (existing > 0) {
return { ok: false, reason: 'cooldown', retryAfterSeconds: existing }; return { ok: false, reason: 'cooldown', retryAfterSeconds: existing };
} }
await redis.set(key, '1', 'EX', override.cooldownSeconds);
} }
} }
return { ok: true }; 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 { env } from './env.js';
import { logger } from './logger.js'; import { logger } from './logger.js';
import { getGuildLocale } from './i18n.js'; import { getGuildLocale } from './i18n.js';
import { evaluateCommandGate, applyCommandCooldown, type CommandGateReason } from './command-gates.js'; import { evaluateCommandGate, type CommandGateReason } from './command-gates.js';
import { import {
isGuildBlacklisted, isGuildBlacklisted,
isModuleEnabled, isModuleEnabled,
@@ -214,11 +214,6 @@ export async function routeCommand(interaction: ChatInputCommandInteraction, con
moduleId moduleId
); );
if (!enabled) { 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({ await interaction.reply({
content: t(locale, 'generic.moduleDisabled'), content: t(locale, 'generic.moduleDisabled'),
ephemeral: true ephemeral: true
@@ -226,7 +221,6 @@ export async function routeCommand(interaction: ChatInputCommandInteraction, con
return; return;
} }
} }
}
const gate = await evaluateCommandGate(interaction, context); const gate = await evaluateCommandGate(interaction, context);
if (!gate.ok) { if (!gate.ok) {
@@ -238,7 +232,6 @@ export async function routeCommand(interaction: ChatInputCommandInteraction, con
} }
await command.execute(interaction, context); await command.execute(interaction, context);
await applyCommandCooldown(interaction, context).catch(() => undefined);
} catch (error) { } catch (error) {
ok = false; ok = false;
captureException(error, { captureException(error, {
@@ -277,16 +270,6 @@ export async function routeContextMenu(
return; 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); const command = contextMenuMap.get(interaction.commandName);
if (!command) { if (!command) {
await interaction.reply({ content: t(locale, 'generic.unknownCommand'), ephemeral: true }); await interaction.reply({ content: t(locale, 'generic.unknownCommand'), ephemeral: true });

View File

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

View File

@@ -7,12 +7,6 @@ import type {
StringSelectMenuInteraction, StringSelectMenuInteraction,
UserSelectMenuInteraction UserSelectMenuInteraction
} from 'discord.js'; } 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 type { BotContext } from './types.js';
import { import {
handleModerationConfirmation, handleModerationConfirmation,
@@ -145,67 +139,11 @@ 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( export async function routeComponentInteraction(
interaction: AnyComponentInteraction, interaction: AnyComponentInteraction,
context: BotContext context: BotContext
): Promise<boolean> { ): Promise<boolean> {
const customId = interaction.customId; 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) { for (const handler of handlers) {
if (handler.match(customId)) { if (handler.match(customId)) {
await handler.handle(interaction, context); await handler.handle(interaction, context);

View File

@@ -9,27 +9,12 @@ import type { BotContext } from './types.js';
import { env } from './env.js'; import { env } from './env.js';
import { refreshPhishingDomains } from './modules/automod/filters.js'; import { refreshPhishingDomains } from './modules/automod/filters.js';
import { activateLockdown, deactivateLockdown } from './modules/automod/actions.js'; import { activateLockdown, deactivateLockdown } from './modules/automod/actions.js';
import { import { completeVerification } from './modules/verification/handlers.js';
completeVerification,
runVerificationPanelSyncJob
} from './modules/verification/handlers.js';
import { closePoll } from './modules/utility/poll.js'; import { closePoll } from './modules/utility/poll.js';
import { sendReminder } from './modules/utility/reminders.js'; import { sendReminder } from './modules/utility/reminders.js';
import { import { expireSelfRole } from './modules/selfroles/service.js';
expireSelfRole, import { endGiveaway, runGiveawayCreateJob, GiveawayError } from './modules/giveaways/index.js';
runSelfRolePanelCreateJob, import { closeInactiveTickets } from './modules/tickets/index.js';
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 { expireBirthdayRole, runBirthdayCheck } from './modules/birthdays/index.js';
import { ensureStatsRecurringJobs, startStatsWorker } from './modules/stats/index.js'; import { ensureStatsRecurringJobs, startStatsWorker } from './modules/stats/index.js';
import { pollAllFeeds } from './modules/feeds/index.js'; import { pollAllFeeds } from './modules/feeds/index.js';
@@ -57,7 +42,6 @@ import {
retentionQueue, retentionQueue,
retentionQueueName, retentionQueueName,
scheduleQueueName, scheduleQueueName,
selfrolesQueueName,
suggestionsQueueName, suggestionsQueueName,
ticketQueue, ticketQueue,
ticketQueueName, ticketQueueName,
@@ -87,10 +71,6 @@ type VerificationCompleteJob = {
captchaProvider?: string | null; captchaProvider?: string | null;
}; };
type VerificationPanelSyncJob = {
guildId: string;
};
type ReminderSendJob = { type ReminderSendJob = {
reminderId: string; reminderId: string;
}; };
@@ -107,8 +87,6 @@ type SelfRoleExpireJob = {
type GiveawayEndJob = { type GiveawayEndJob = {
giveawayId: string; giveawayId: string;
/** When true, end even if the giveaway is paused (dashboard/slash paths). */
force?: boolean;
}; };
type GiveawayCreateJob = { type GiveawayCreateJob = {
@@ -123,15 +101,6 @@ type GiveawayCreateJob = {
requiredMemberDays?: number | null; requiredMemberDays?: number | null;
}; };
type GiveawayPauseJob = {
giveawayId: string;
paused: boolean;
};
type GiveawayDeleteJob = {
giveawayId: string;
};
type BirthdayRoleExpireJob = { type BirthdayRoleExpireJob = {
guildId: string; guildId: string;
@@ -146,36 +115,6 @@ type SuggestionStatusUpdateJob = {
reason: string; 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[] { export function startWorkers(context: BotContext): Worker[] {
const moderationWorker = new Worker<TempBanJob>( const moderationWorker = new Worker<TempBanJob>(
moderationQueueName, moderationQueueName,
@@ -304,20 +243,16 @@ export function startWorkers(context: BotContext): Worker[] {
logger.error({ jobId: job?.id, error }, 'AutoMod job failed'); logger.error({ jobId: job?.id, error }, 'AutoMod job failed');
}); });
const verificationWorker = new Worker<VerificationCompleteJob | VerificationPanelSyncJob>( const verificationWorker = new Worker<VerificationCompleteJob>(
verificationQueueName, verificationQueueName,
async (job) => { async (job) => {
if (job.name === 'verificationPanelSync') {
return runVerificationPanelSyncJob(context, job.data as VerificationPanelSyncJob);
}
if (job.name !== 'verificationComplete') { if (job.name !== 'verificationComplete') {
return; return;
} }
const data = job.data as VerificationCompleteJob; await completeVerification(context, job.data.guildId, job.data.userId, {
await completeVerification(context, data.guildId, data.userId, { ipHash: job.data.ipHash,
ipHash: data.ipHash, userAgentHash: job.data.userAgentHash,
userAgentHash: data.userAgentHash, captchaProvider: job.data.captchaProvider
captchaProvider: data.captchaProvider
}); });
}, },
{ connection: redis } { connection: redis }
@@ -348,22 +283,6 @@ export function startWorkers(context: BotContext): Worker[] {
const ticketWorker = new Worker( const ticketWorker = new Worker(
ticketQueueName, ticketQueueName,
async (job) => { 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') { if (job.name === 'selfRoleExpire') {
const data = job.data as SelfRoleExpireJob; const data = job.data as SelfRoleExpireJob;
await expireSelfRole(context, data.guildId, data.userId, data.roleId); await expireSelfRole(context, data.guildId, data.userId, data.roleId);
@@ -380,47 +299,12 @@ export function startWorkers(context: BotContext): Worker[] {
logger.error({ jobId: job?.id, error }, 'Ticket queue job failed'); logger.error({ jobId: job?.id, error }, 'Ticket queue job failed');
}); });
const selfrolesWorker = new Worker< const giveawayWorker = new Worker<GiveawayEndJob | GiveawayCreateJob>(
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, giveawayQueueName,
async (job) => { async (job) => {
if (job.name === 'giveawayEnd') { if (job.name === 'giveawayEnd') {
try { try {
const data = job.data as GiveawayEndJob; await endGiveaway(context, (job.data as GiveawayEndJob).giveawayId);
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) { } catch (error) {
if ( if (
error instanceof GiveawayError && error instanceof GiveawayError &&
@@ -435,13 +319,6 @@ export function startWorkers(context: BotContext): Worker[] {
if (job.name === 'giveawayCreate') { if (job.name === 'giveawayCreate') {
return runGiveawayCreateJob(context, job.data as GiveawayCreateJob); 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 } { connection: redis }
); );
@@ -576,7 +453,6 @@ export function startWorkers(context: BotContext): Worker[] {
verificationWorker, verificationWorker,
reminderWorker, reminderWorker,
ticketWorker, ticketWorker,
selfrolesWorker,
giveawayWorker, giveawayWorker,
birthdayWorker, birthdayWorker,
statsWorker, statsWorker,

View File

@@ -2,7 +2,6 @@ import { EmbedBuilder } from 'discord.js';
import { import {
embedHasContent, embedHasContent,
mapEmbedTextFields, mapEmbedTextFields,
type EmbedTextField,
type WelcomeEmbed type WelcomeEmbed
} from '@nexumi/shared'; } from '@nexumi/shared';
@@ -19,12 +18,8 @@ function isHttpUrl(value: string | undefined): value is string {
} }
export interface ApplyEmbedOptions { export interface ApplyEmbedOptions {
/** /** Called for every text/URL field before applying to the builder. */
* Called for every text/URL field before applying to the builder. renderText: (value: string) => string;
* `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). * Used when `thumbnailUrl` is unset (Welcome default: member avatar).
* Pass `null` to force no thumbnail. * Pass `null` to force no thumbnail.

View File

@@ -1,35 +0,0 @@
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,7 +20,6 @@ describe('moduleForCommand', () => {
it('leaves core commands unmapped', () => { it('leaves core commands unmapped', () => {
expect(moduleForCommand('help')).toBeNull(); expect(moduleForCommand('help')).toBeNull();
expect(moduleForCommand('info')).toBeNull(); expect(moduleForCommand('info')).toBeNull();
expect(moduleForCommand('about')).toBeNull();
expect(moduleForCommand('gdpr')).toBeNull(); expect(moduleForCommand('gdpr')).toBeNull();
}); });
}); });

View File

@@ -92,41 +92,6 @@ export function moduleForCommand(commandName: string): DashboardModuleId | null
return COMMAND_MODULE_MAP[commandName] ?? 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( export async function isFeatureFlagEnabled(
prisma: PrismaClient, prisma: PrismaClient,
key: string, key: string,

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,98 +0,0 @@
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,11 +13,6 @@ export const infoCommandData = applyCommandDescription(
'core.info.description' 'core.info.description'
); );
export const aboutCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('about'),
'core.about.description'
);
export const inviteCommandData = applyCommandDescription( export const inviteCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('invite'), new SlashCommandBuilder().setName('invite'),
'core.invite.description' 'core.invite.description'

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -22,46 +22,25 @@ 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> { export async function scheduleGiveawayEnd(giveawayId: string, endsAt: Date): Promise<void> {
await cancelGiveawayJob(giveawayId);
const delay = Math.max(0, endsAt.getTime() - Date.now()); const delay = Math.max(0, endsAt.getTime() - Date.now());
await giveawayQueue.add( await giveawayQueue.add(
'giveawayEnd', 'giveawayEnd',
{ giveawayId }, { giveawayId },
{ {
delay, delay,
jobId: giveawayEndJobId(giveawayId), jobId: `giveaway-end-${giveawayId}`,
removeOnComplete: true, removeOnComplete: true,
removeOnFail: 50 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[] { function requirementLines(locale: 'de' | 'en', giveaway: Giveaway): string[] {
const lines: string[] = []; const lines: string[] = [];
if (giveaway.requiredRoleId) { if (giveaway.requiredRoleId) {
@@ -337,15 +316,6 @@ 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> { export async function endGiveaway(context: BotContext, giveawayId: string): Promise<Giveaway> {
const giveaway = await context.prisma.giveaway.findUnique({ where: { id: giveawayId } }); const giveaway = await context.prisma.giveaway.findUnique({ where: { id: giveawayId } });
if (!giveaway) { if (!giveaway) {
@@ -355,31 +325,21 @@ export async function endGiveaway(context: BotContext, giveawayId: string): Prom
throw new GiveawayError('already_ended'); throw new GiveawayError('already_ended');
} }
await cancelGiveawayJob(giveawayId);
const locale = await getGuildLocale(context.prisma, giveaway.guildId); const locale = await getGuildLocale(context.prisma, giveaway.guildId);
const guild = await context.client.guilds.fetch(giveaway.guildId); const guild = await context.client.guilds.fetch(giveaway.guildId);
const eligible = await filterEligibleEntrants(context, guild, giveaway); const eligible = await filterEligibleEntrants(context, guild, giveaway);
const winners = pickRandomWinners(eligible, giveaway.winnerCount); const winners = pickRandomWinners(eligible, giveaway.winnerCount);
const endedAt = new Date();
const claimed = await context.prisma.giveaway.updateMany({ const updated = await context.prisma.giveaway.update({
where: { id: giveawayId, endedAt: null }, where: { id: giveawayId },
data: { data: {
endedAt, endedAt: new Date(),
winners, winners,
paused: false 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); await updateGiveawayMessage(context, updated, locale);
if (winners.length > 0) { if (winners.length > 0) {
await dmWinners(context, updated, winners, locale); await dmWinners(context, updated, winners, locale);
@@ -387,41 +347,6 @@ export async function endGiveaway(context: BotContext, giveawayId: string): Prom
return updated; 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( export async function rerollGiveaway(
context: BotContext, context: BotContext,
giveawayId: string, giveawayId: string,
@@ -453,61 +378,22 @@ export async function rerollGiveaway(
export async function pauseGiveaway( export async function pauseGiveaway(
context: BotContext, context: BotContext,
giveawayId: string, giveawayId: string,
locale: 'de' | 'en', locale: 'de' | 'en'
forcePaused?: boolean
): Promise<Giveaway> { ): Promise<Giveaway> {
const giveaway = await context.prisma.giveaway.findUnique({ where: { id: giveawayId } }); const giveaway = await context.prisma.giveaway.findUnique({ where: { id: giveawayId } });
if (!giveaway || giveaway.endedAt) { if (!giveaway || giveaway.endedAt) {
throw new GiveawayError('not_found'); 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({ const updated = await context.prisma.giveaway.update({
where: { id: giveawayId }, where: { id: giveawayId },
data: { paused: nextPaused } data: { paused: !giveaway.paused }
}); });
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); await updateGiveawayMessage(context, updated, locale);
return updated; 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> { export async function deleteGiveaway(context: BotContext, giveawayId: string): Promise<void> {
const giveaway = await context.prisma.giveaway.findUnique({ where: { id: giveawayId } }); const giveaway = await context.prisma.giveaway.findUnique({ where: { id: giveawayId } });
if (!giveaway) { if (!giveaway) {

View File

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

View File

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

View File

@@ -10,9 +10,6 @@ import type { LogEventType } from '@nexumi/shared';
import { ensureGuild } from '../../guild.js'; import { ensureGuild } from '../../guild.js';
import { logger } from '../../logger.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) { export async function getLoggingConfig(prisma: BotContext['prisma'], guildId: string) {
await ensureGuild(prisma, guildId); await ensureGuild(prisma, guildId);
let config = await prisma.loggingConfig.findUnique({ where: { guildId } }); let config = await prisma.loggingConfig.findUnique({ where: { guildId } });
@@ -22,36 +19,6 @@ export async function getLoggingConfig(prisma: BotContext['prisma'], guildId: st
return config; 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( export async function getLogChannelId(
prisma: BotContext['prisma'], prisma: BotContext['prisma'],
guildId: string, guildId: string,
@@ -157,45 +124,6 @@ export async function logModAction(
await sendLogEmbed(context, guild, 'MOD_ACTION', 'Moderation Action', description, 0xef4444); 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( export async function handleChannelDeleteForAntiNuke(
context: BotContext, context: BotContext,
guild: Guild, guild: Guild,
@@ -325,29 +253,6 @@ export async function registerLoggingEvents(context: BotContext): Promise<void>
if (memberIgnored(config, member as GuildMember, member.user.bot)) { if (memberIgnored(config, member as GuildMember, member.user.bot)) {
return; 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( await sendLogEmbed(
context, context,
member.guild, member.guild,
@@ -360,19 +265,12 @@ export async function registerLoggingEvents(context: BotContext): Promise<void>
client.on('guildBanAdd', async (ban) => { client.on('guildBanAdd', async (ban) => {
const guild = ban.guild; const guild = ban.guild;
await handleBanForAntiNuke(context, 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( await sendLogEmbed(
context, context,
guild, guild,
'MEMBER_BAN', 'MEMBER_BAN',
'Member Banned', 'Member Banned',
`**User:** ${ban.user.tag} (<@${ban.user.id}>)${executorLine}\n**Reason:** ${reason}` `**User:** ${ban.user.tag} (<@${ban.user.id}>)\n**Reason:** ${ban.reason ?? '—'}`
); );
}); });

View File

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

View File

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

View File

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

View File

@@ -1,14 +1,8 @@
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 { moderationQueue } from '../../queues.js';
import type { BotContext } from '../../types.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'; import { MAX_TIMEOUT_MS } from './duration.js';
type CreateCaseInput = { type CreateCaseInput = {
@@ -22,152 +16,6 @@ type CreateCaseInput = {
metadata?: Record<string, unknown>; 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) { export async function createCase(context: BotContext, input: CreateCaseInput) {
await context.prisma.guild.upsert({ await context.prisma.guild.upsert({
where: { id: input.guildId }, where: { id: input.guildId },
@@ -248,7 +96,9 @@ export async function scheduleTempBanExpire(
) { ) {
const jobId = tempBanJobId(guildId, userId); const jobId = tempBanJobId(guildId, userId);
const existing = await moderationQueue.getJob(jobId); const existing = await moderationQueue.getJob(jobId);
await safeRemoveBullJob(existing, { label: 'tempBanExpire', jobId }); if (existing) {
await existing.remove();
}
await moderationQueue.add( await moderationQueue.add(
'tempBanExpire', 'tempBanExpire',
@@ -329,13 +179,6 @@ export async function applyWarnEscalation(
String(warningCount) String(warningCount)
); );
} }
await notifyModerationTargetDm({
user: member.user,
guildName: interaction.guild.name,
reason,
locale,
action: 'kick'
});
await member.kick(reason); await member.kick(reason);
await createCase(context, { await createCase(context, {
guildId, guildId,
@@ -356,13 +199,6 @@ export async function applyWarnEscalation(
String(warningCount) String(warningCount)
); );
} }
await notifyModerationTargetDm({
user: member.user,
guildName: interaction.guild.name,
reason,
locale,
action: 'ban'
});
await interaction.guild.members.ban(userId, { reason }); await interaction.guild.members.ban(userId, { reason });
await createCase(context, { await createCase(context, {
guildId, guildId,

View File

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

View File

@@ -4,20 +4,11 @@ import {
type MessageCreateOptions, type MessageCreateOptions,
type TextChannel type TextChannel
} from 'discord.js'; } from 'discord.js';
import { import { WelcomeEmbedSchema, expandBracketChannelMentions, parseMessageComponentsV2, t, tf } from '@nexumi/shared';
DEFAULT_GUILD_TIMEZONE,
WelcomeEmbedSchema,
expandBracketChannelMentions,
parseMessageComponentsV2,
resolveScheduleRunAt,
t,
tf
} from '@nexumi/shared';
import type { ScheduledMessage } from '@prisma/client'; import type { ScheduledMessage } from '@prisma/client';
import { ensureGuild } from '../../guild.js'; import { ensureGuild } from '../../guild.js';
import { buildEmbedFromPayload } from '../../lib/embed-payload.js'; import { buildEmbedFromPayload } from '../../lib/embed-payload.js';
import { buildComponentsV2Payload } from '../../lib/components-v2-payload.js'; import { buildComponentsV2Payload } from '../../lib/components-v2-payload.js';
import { safeRemoveBullJob } from '../../lib/safe-remove-job.js';
import { logger } from '../../logger.js'; import { logger } from '../../logger.js';
import { scheduleQueue } from '../../queues.js'; import { scheduleQueue } from '../../queues.js';
import type { BotContext } from '../../types.js'; import type { BotContext } from '../../types.js';
@@ -45,7 +36,7 @@ export type ScheduleCreateInput = AnnouncementInput & {
cron?: string | null; cron?: string | null;
}; };
function parseScheduleWhen(input: string, timeZone: string): Date { function parseScheduleWhen(input: string): Date {
const trimmed = input.trim(); const trimmed = input.trim();
if (/^\d+[smhd]$/i.test(trimmed)) { if (/^\d+[smhd]$/i.test(trimmed)) {
const ms = parseDuration(trimmed); const ms = parseDuration(trimmed);
@@ -55,79 +46,11 @@ function parseScheduleWhen(input: string, timeZone: string): Date {
return new Date(Date.now() + ms); return new Date(Date.now() + ms);
} }
try { const parsed = Date.parse(trimmed);
const parsed = resolveScheduleRunAt(trimmed, timeZone); if (Number.isNaN(parsed) || parsed <= Date.now()) {
if (parsed.getTime() <= Date.now()) {
throw new SchedulerError('invalid_when'); throw new SchedulerError('invalid_when');
} }
return parsed; return new Date(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 { function isValidCron(pattern: string): boolean {
@@ -234,85 +157,38 @@ async function assertSendableChannel(
async function enqueueScheduleJob( async function enqueueScheduleJob(
schedule: ScheduledMessage, schedule: ScheduledMessage,
cron: string | null, cron: string | null,
runAt: Date | null, runAt: Date | null
timeZone: string
): Promise<string> { ): Promise<string> {
const jobId = scheduleJobId(schedule.id); const jobOptions: {
jobId: string;
if (cron) { removeOnComplete: boolean;
// BullMQ 5 job schedulers honor guild timezone for cron patterns. removeOnFail: number;
await scheduleQueue.upsertJobScheduler( delay?: number;
jobId, repeat?: { pattern: string };
{ pattern: cron, tz: timeZone }, } = {
{ jobId: `schedule-${schedule.id}`,
name: 'scheduleSend',
data: { scheduleId: schedule.id },
opts: {
removeOnComplete: true, removeOnComplete: true,
removeOnFail: 50 removeOnFail: 50
} };
}
); if (cron) {
return jobId; jobOptions.repeat = { pattern: cron };
} else if (runAt) {
jobOptions.delay = Math.max(0, runAt.getTime() - Date.now());
} }
if (!runAt) { const job = await scheduleQueue.add('scheduleSend', { scheduleId: schedule.id }, jobOptions);
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); return String(job.id);
} }
/** export async function removeScheduleJob(jobId: string | null): Promise<void> {
* Removes a one-shot delayed job and/or cron job scheduler. Locked (active) if (!jobId) {
* jobs cannot be removed — do not call this from inside the scheduleSend return;
* 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');
}
}
} }
// One-shot delayed jobs only — never job.remove() on repeat:* scheduler children. const job = await scheduleQueue.getJob(jobId);
const oneShotIds = new Set<string>(); if (job) {
if (jobId && !jobId.startsWith('repeat:')) { await job.remove();
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 });
} }
} }
@@ -344,7 +220,6 @@ export async function createScheduledMessage(
let runAt: Date | null = null; let runAt: Date | null = null;
let cronPattern: string | null = null; let cronPattern: string | null = null;
const timeZone = await getGuildTimezone(context.prisma, input.guildId);
if (cron) { if (cron) {
if (!isValidCron(cron)) { if (!isValidCron(cron)) {
@@ -352,7 +227,7 @@ export async function createScheduledMessage(
} }
cronPattern = cron; cronPattern = cron;
} else if (whenInput) { } else if (whenInput) {
runAt = parseScheduleWhen(whenInput, timeZone); runAt = parseScheduleWhen(whenInput);
} }
await assertSendableChannel(context, input.channelId); await assertSendableChannel(context, input.channelId);
@@ -372,7 +247,7 @@ export async function createScheduledMessage(
} }
}); });
const jobId = await enqueueScheduleJob(schedule, cronPattern, runAt, timeZone); const jobId = await enqueueScheduleJob(schedule, cronPattern, runAt);
return context.prisma.scheduledMessage.update({ return context.prisma.scheduledMessage.update({
where: { id: schedule.id }, where: { id: schedule.id },
data: { jobId } data: { jobId }
@@ -432,7 +307,7 @@ export async function deleteScheduledMessage(
return false; return false;
} }
await removeScheduleJob(match.jobId, { scheduleId: match.id, cron: match.cron }); await removeScheduleJob(match.jobId);
await context.prisma.scheduledMessage.delete({ where: { id: match.id } }); await context.prisma.scheduledMessage.delete({ where: { id: match.id } });
return true; return true;
} }
@@ -475,10 +350,8 @@ export async function sendScheduledMessage(context: BotContext, scheduleId: stri
throw error; 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) { if (!schedule.cron) {
await removeScheduleJob(schedule.jobId);
await context.prisma.scheduledMessage.delete({ where: { id: scheduleId } }); await context.prisma.scheduledMessage.delete({ where: { id: scheduleId } });
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,18 +1,17 @@
import { PermissionFlagsBits } from 'discord.js'; import { PermissionFlagsBits } from 'discord.js';
import { t, tf } from '@nexumi/shared'; import { t } from '@nexumi/shared';
import type { SlashCommand } from '../../types.js'; import type { SlashCommand } from '../../types.js';
import { getGuildLocale } from '../../i18n.js'; import { getGuildLocale } from '../../i18n.js';
import { requirePermission } from '../../permissions.js'; import { requirePermission } from '../../permissions.js';
import { ephemeral } from '../../interaction-reply.js';
import { starboardCommandData } from './command-definitions.js'; import { starboardCommandData } from './command-definitions.js';
import { botCanPostStarboard, getStarboardConfig, updateStarboardConfig } from './service.js'; import { getStarboardConfig, updateStarboardConfig } from './service.js';
const starboardCommand: SlashCommand = { const starboardCommand: SlashCommand = {
data: starboardCommandData, data: starboardCommandData,
async execute(interaction, context) { async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId); const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!interaction.inGuild()) { if (!interaction.inGuild()) {
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ...ephemeral() }); await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
return; return;
} }
if (!(await requirePermission(interaction, PermissionFlagsBits.ManageGuild, locale))) { if (!(await requirePermission(interaction, PermissionFlagsBits.ManageGuild, locale))) {
@@ -23,21 +22,11 @@ const starboardCommand: SlashCommand = {
const sub = interaction.options.getSubcommand(); const sub = interaction.options.getSubcommand();
if (sub === 'setup') { if (sub === 'setup') {
const channelOption = interaction.options.getChannel('channel', true); const channel = interaction.options.getChannel('channel', true);
const threshold = interaction.options.getInteger('threshold', true); const threshold = interaction.options.getInteger('threshold', true);
const emoji = interaction.options.getString('emoji') ?? '⭐'; const emoji = interaction.options.getString('emoji') ?? '⭐';
const allowSelfStar = interaction.options.getBoolean('allow_self_star') ?? false; 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, { await updateStarboardConfig(context.prisma, guildId, {
enabled: true, enabled: true,
channelId: channel.id, channelId: channel.id,
@@ -46,30 +35,13 @@ const starboardCommand: SlashCommand = {
allowSelfStar allowSelfStar
}); });
await interaction.reply({ content: t(locale, 'starboard.setupDone'), ...ephemeral() }); await interaction.reply({ content: t(locale, 'starboard.setupDone'), ephemeral: true });
return; return;
} }
if (sub === 'status') {
const config = await getStarboardConfig(context.prisma, guildId); const config = await getStarboardConfig(context.prisma, guildId);
if (!config.enabled || !config.channelId) { if (!config.enabled || !config.channelId) {
await interaction.reply({ await interaction.reply({ content: t(locale, 'starboard.notConfigured'), ephemeral: true });
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,11 +1,6 @@
import type { Message, PartialMessage } from 'discord.js';
import type { BotContext } from '../../types.js'; import type { BotContext } from '../../types.js';
import { logger } from '../../logger.js'; import { logger } from '../../logger.js';
import { import { processStarboardReaction } from './service.js';
processStarboardReaction,
processStarboardSourceDelete,
processStarboardSourceUpdate
} from './service.js';
export function registerStarboardEvents(context: BotContext): void { export function registerStarboardEvents(context: BotContext): void {
const handleReaction = async ( const handleReaction = async (
@@ -31,48 +26,4 @@ export function registerStarboardEvents(context: BotContext): void {
} }
await handleReaction(_reaction); 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

@@ -1,85 +0,0 @@
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,17 +1,14 @@
import { import {
ChannelType,
EmbedBuilder, EmbedBuilder,
PermissionFlagsBits,
type Emoji, type Emoji,
type GuildBasedChannel,
type Message, type Message,
type PartialMessage,
type PartialMessageReaction, type PartialMessageReaction,
type MessageReaction, type MessageReaction,
type TextChannel type TextChannel
} from 'discord.js'; } from 'discord.js';
import type { PrismaClient, StarboardConfig } from '@prisma/client'; import type { PrismaClient, StarboardConfig } from '@prisma/client';
import { t, type Locale } from '@nexumi/shared'; import { t } from '@nexumi/shared';
import type { Locale } from '@nexumi/shared';
import { ensureGuild } from '../../guild.js'; import { ensureGuild } from '../../guild.js';
import { logger } from '../../logger.js'; import { logger } from '../../logger.js';
import type { BotContext } from '../../types.js'; import type { BotContext } from '../../types.js';
@@ -76,27 +73,6 @@ export function shouldIgnoreMessage(message: Message, config: StarboardConfig):
return false; 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( export async function countMatchingStars(
message: Message, message: Message,
config: StarboardConfig config: StarboardConfig
@@ -133,10 +109,6 @@ function resolveImageUrl(message: Message): string | undefined {
return embedImage ?? undefined; return embedImage ?? undefined;
} }
function authorDisplayName(message: Message): string {
return message.member?.displayName || message.author.displayName || message.author.username;
}
export function buildStarboardEmbed( export function buildStarboardEmbed(
message: Message, message: Message,
starCount: number, starCount: number,
@@ -147,7 +119,7 @@ export function buildStarboardEmbed(
const embed = new EmbedBuilder() const embed = new EmbedBuilder()
.setColor(0x6366f1) .setColor(0x6366f1)
.setAuthor({ .setAuthor({
name: authorDisplayName(message), name: message.author.tag,
iconURL: message.author.displayAvatarURL() iconURL: message.author.displayAvatarURL()
}) })
.setDescription(content.slice(0, 4096)) .setDescription(content.slice(0, 4096))
@@ -171,17 +143,6 @@ export function buildStarboardEmbed(
return embed; 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> { async function removeStarboardEntry(context: BotContext, entryId: string): Promise<void> {
const entry = await context.prisma.starboardEntry.findUnique({ where: { id: entryId } }); const entry = await context.prisma.starboardEntry.findUnique({ where: { id: entryId } });
if (!entry) { if (!entry) {
@@ -191,14 +152,10 @@ async function removeStarboardEntry(context: BotContext, entryId: string): Promi
try { try {
const config = await getStarboardConfig(context.prisma, entry.guildId); const config = await getStarboardConfig(context.prisma, entry.guildId);
if (config.channelId) { if (config.channelId) {
const channel = await fetchStarboardTextChannel(context, config.channelId); const channel = (await context.client.channels.fetch(config.channelId)) as TextChannel;
if (channel) { const starboardMessage = await channel.messages.fetch(entry.starboardMessageId).catch(() => null);
const starboardMessage = await channel.messages
.fetch(entry.starboardMessageId)
.catch(() => null);
await starboardMessage?.delete().catch(() => undefined); await starboardMessage?.delete().catch(() => undefined);
} }
}
} catch (error) { } catch (error) {
logger.warn({ error, entryId }, 'Failed to delete starboard message'); logger.warn({ error, entryId }, 'Failed to delete starboard message');
} }
@@ -206,82 +163,6 @@ async function removeStarboardEntry(context: BotContext, entryId: string): Promi
await context.prisma.starboardEntry.delete({ where: { id: entryId } }).catch(() => undefined); 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> { export async function processStarboardMessage(context: BotContext, message: Message): Promise<void> {
if (!message.guild) { if (!message.guild) {
return; return;
@@ -313,13 +194,8 @@ export async function processStarboardMessage(context: BotContext, message: Mess
return; 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 embed = buildStarboardEmbed(message, starCount, config, locale);
const starboardChannel = (await context.client.channels.fetch(config.channelId)) as TextChannel;
if (existing) { if (existing) {
try { try {
@@ -330,28 +206,21 @@ export async function processStarboardMessage(context: BotContext, message: Mess
data: { starCount } data: { starCount }
}); });
} catch (error) { } catch (error) {
logger.warn({ error, messageId: message.id }, 'Starboard message missing; recreating'); logger.warn({ error, messageId: message.id }, 'Failed to update starboard message');
await createOrRecreateStarboardPost(
context,
message,
config,
locale,
starCount,
starboardChannel,
existing.id
);
} }
return; return;
} }
await createOrRecreateStarboardPost( const starboardMessage = await starboardChannel.send({ embeds: [embed] });
context, await context.prisma.starboardEntry.create({
message, data: {
config, guildId: message.guild.id,
locale, sourceChannelId: message.channel.id,
starCount, sourceMessageId: message.id,
starboardChannel starboardMessageId: starboardMessage.id,
); starCount
}
});
} }
export async function processStarboardReaction( export async function processStarboardReaction(
@@ -380,41 +249,3 @@ export async function processStarboardReaction(
await processStarboardMessage(context, message as Message); 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 { getInviteLeaderboard, getInviteStatsForUser } from './invites.js';
import { import {
getStatsConfig, getStatsConfig,
refreshGuildStatsChannels,
StatsSetupSchema, StatsSetupSchema,
updateStatsChannels,
upsertStatsConfig upsertStatsConfig
} from './service.js'; } from './service.js';
@@ -47,7 +47,7 @@ const statsCommand: SlashCommand = {
} }
await upsertStatsConfig(context.prisma, guildId, parsed.data); await upsertStatsConfig(context.prisma, guildId, parsed.data);
await refreshGuildStatsChannels(context, guildId); await updateStatsChannels(context);
await interaction.reply({ content: t(locale, 'stats.setup.done'), ephemeral: true }); await interaction.reply({ content: t(locale, 'stats.setup.done'), ephemeral: true });
return; return;
@@ -64,7 +64,7 @@ const statsCommand: SlashCommand = {
return; return;
} }
await refreshGuildStatsChannels(context, guildId); await updateStatsChannels(context);
await interaction.reply({ content: t(locale, 'stats.refresh.done'), ephemeral: true }); await interaction.reply({ content: t(locale, 'stats.refresh.done'), ephemeral: true });
} }
} }

View File

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

View File

@@ -21,8 +21,6 @@ export type StatsSetupInput = z.infer<typeof StatsSetupSchema>;
export const STATS_REFRESH_CRON = '*/10 * * * *'; export const STATS_REFRESH_CRON = '*/10 * * * *';
export const STATS_REFRESH_JOB_NAME = 'statsRefresh'; 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) { export async function getStatsConfig(prisma: BotContext['prisma'], guildId: string) {
await ensureGuild(prisma, guildId); await ensureGuild(prisma, guildId);
@@ -99,17 +97,13 @@ async function renameStatsChannel(
}); });
} }
export async function refreshGuildStatsChannels(context: BotContext, guildId: string): Promise<void> { async function refreshGuildStatsChannels(context: BotContext, guildId: string): Promise<void> {
const config = await context.prisma.statsConfig.findUnique({ where: { guildId } }); const config = await context.prisma.statsConfig.findUnique({ where: { guildId } });
if (!config?.enabled) { if (!config?.enabled) {
return; return;
} }
// withCounts populates approximatePresenceCount / approximateMemberCount. const guild = await context.client.guilds.fetch(guildId).catch(() => null);
// 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) { if (!guild) {
return; return;
} }
@@ -120,7 +114,9 @@ export async function refreshGuildStatsChannels(context: BotContext, guildId: st
return; return;
} }
const memberCount = guild.approximateMemberCount ?? guild.memberCount; await guild.members.fetch().catch(() => undefined);
const memberCount = guild.memberCount;
const onlineCount = estimateOnlineCount(guild); const onlineCount = estimateOnlineCount(guild);
const boostCount = guild.premiumSubscriptionCount ?? 0; const boostCount = guild.premiumSubscriptionCount ?? 0;
@@ -131,13 +127,6 @@ export async function refreshGuildStatsChannels(context: BotContext, guildId: st
]); ]);
} }
export async function runStatsGuildRefreshJob(
context: BotContext,
data: { guildId: string }
): Promise<void> {
await refreshGuildStatsChannels(context, data.guildId);
}
export async function updateStatsChannels(context: BotContext): Promise<void> { export async function updateStatsChannels(context: BotContext): Promise<void> {
const configs = await context.prisma.statsConfig.findMany({ const configs = await context.prisma.statsConfig.findMany({
where: { enabled: true } where: { enabled: true }
@@ -170,15 +159,6 @@ export function startStatsWorker(context: BotContext): Worker {
async (job) => { async (job) => {
if (job.name === STATS_REFRESH_JOB_NAME) { if (job.name === STATS_REFRESH_JOB_NAME) {
await updateStatsChannels(context); 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 } { connection: redis }

View File

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

View File

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

View File

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

View File

@@ -14,44 +14,15 @@ import type { Tag } from '@prisma/client';
import { ensureGuild } from '../../guild.js'; import { ensureGuild } from '../../guild.js';
import { buildEmbedFromPayload } from '../../lib/embed-payload.js'; import { buildEmbedFromPayload } from '../../lib/embed-payload.js';
import { buildComponentsV2Payload } from '../../lib/components-v2-payload.js'; import { buildComponentsV2Payload } from '../../lib/components-v2-payload.js';
import { redis } from '../../redis.js';
import type { BotContext } from '../../types.js'; import type { BotContext } from '../../types.js';
import { assertPremiumLimit, PremiumLimitError } from '../../premium.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 { export class TagError extends Error {
constructor( constructor(public readonly code: string) {
public readonly code: string,
public readonly retryAfterSeconds?: number
) {
super(code); 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 { export function normalizeTagName(name: string): string {
return name.trim().toLowerCase(); return name.trim().toLowerCase();
} }
@@ -176,7 +147,6 @@ export async function createTag(
embed?: WelcomeEmbed | null; embed?: WelcomeEmbed | null;
components?: MessageComponentsV2 | null; components?: MessageComponentsV2 | null;
triggerWord?: string | null; triggerWord?: string | null;
cooldownSeconds?: number;
allowedRoleIds?: string[]; allowedRoleIds?: string[];
allowedChannelIds?: string[]; allowedChannelIds?: string[];
createdById: string; createdById: string;
@@ -220,7 +190,6 @@ export async function createTag(
embed: params.embed ?? undefined, embed: params.embed ?? undefined,
components: params.components ?? undefined, components: params.components ?? undefined,
triggerWord: params.triggerWord?.trim() || null, triggerWord: params.triggerWord?.trim() || null,
cooldownSeconds: params.cooldownSeconds ?? 15,
allowedRoleIds: params.allowedRoleIds ?? [], allowedRoleIds: params.allowedRoleIds ?? [],
allowedChannelIds: params.allowedChannelIds ?? [], allowedChannelIds: params.allowedChannelIds ?? [],
createdById: params.createdById createdById: params.createdById
@@ -239,7 +208,6 @@ export async function updateTag(
embed?: WelcomeEmbed | null; embed?: WelcomeEmbed | null;
components?: MessageComponentsV2 | null; components?: MessageComponentsV2 | null;
triggerWord?: string | null; triggerWord?: string | null;
cooldownSeconds?: number;
allowedRoleIds?: string[]; allowedRoleIds?: string[];
allowedChannelIds?: string[]; allowedChannelIds?: string[];
} }
@@ -287,8 +255,6 @@ export async function updateTag(
components: components ?? undefined, components: components ?? undefined,
triggerWord: triggerWord:
updates.triggerWord !== undefined ? updates.triggerWord?.trim() || null : existing.triggerWord, updates.triggerWord !== undefined ? updates.triggerWord?.trim() || null : existing.triggerWord,
cooldownSeconds:
updates.cooldownSeconds !== undefined ? updates.cooldownSeconds : existing.cooldownSeconds,
allowedRoleIds: updates.allowedRoleIds ?? existing.allowedRoleIds, allowedRoleIds: updates.allowedRoleIds ?? existing.allowedRoleIds,
allowedChannelIds: updates.allowedChannelIds ?? existing.allowedChannelIds allowedChannelIds: updates.allowedChannelIds ?? existing.allowedChannelIds
} }
@@ -328,24 +294,20 @@ export async function executeTag(
throw new TagError('not_allowed'); 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({ await context.prisma.tag.update({
where: { id: tag.id }, where: { id: tag.id },
data: { useCount: { increment: 1 } } data: { useCount: { increment: 1 } }
}); });
await applyTagCooldown(guildId, tag.id, scopeId, tag.cooldownSeconds);
return buildTagReply(tag, member, guild, args); return buildTagReply(tag, member, guild, args);
} }
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);
}
export async function findTriggeredTag( export async function findTriggeredTag(
context: BotContext, context: BotContext,
guildId: string, guildId: string,

View File

@@ -1,15 +0,0 @@
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

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

View File

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

View File

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

View File

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

View File

@@ -10,14 +10,9 @@ import {
StringSelectMenuBuilder, StringSelectMenuBuilder,
TextInputBuilder, TextInputBuilder,
TextInputStyle, TextInputStyle,
UserSelectMenuBuilder,
type APIMessageComponentEmoji,
type Guild, type Guild,
type GuildBasedChannel,
type GuildMember, type GuildMember,
type GuildTextBasedChannel,
type Message, type Message,
type Role,
type SendableChannels, type SendableChannels,
type TextChannel, type TextChannel,
type ThreadChannel type ThreadChannel
@@ -39,14 +34,6 @@ export const TICKET_OPEN_PREFIX = 'ticket:open:';
export const TICKET_SELECT_ID = 'ticket:select'; export const TICKET_SELECT_ID = 'ticket:select';
export const TICKET_MODAL_PREFIX = 'ticket:modal:'; export const TICKET_MODAL_PREFIX = 'ticket:modal:';
export const TICKET_RATE_PREFIX = 'ticket:rate:'; 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({ export const TicketFormFieldSchema = z.object({
id: z.string(), id: z.string(),
@@ -64,189 +51,6 @@ 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( async function fetchSendableChannel(
context: BotContext, context: BotContext,
channelId: string channelId: string
@@ -381,9 +185,8 @@ export function buildPanelComponents(categories: TicketCategory[]): ActionRowBui
.setCustomId(openButtonId(category.id)) .setCustomId(openButtonId(category.id))
.setLabel(category.name.slice(0, 80)) .setLabel(category.name.slice(0, 80))
.setStyle(ButtonStyle.Primary); .setStyle(ButtonStyle.Primary);
const emoji = parseButtonEmoji(category.emoji); if (category.emoji) {
if (emoji) { button.setEmoji(category.emoji);
button.setEmoji(emoji);
} }
row.addComponents(button); row.addComponents(button);
} }
@@ -394,208 +197,17 @@ export function buildPanelComponents(categories: TicketCategory[]): ActionRowBui
.setCustomId(TICKET_SELECT_ID) .setCustomId(TICKET_SELECT_ID)
.setPlaceholder('Select a category') .setPlaceholder('Select a category')
.addOptions( .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), label: category.name.slice(0, 100),
value: category.id, value: category.id,
description: category.description?.slice(0, 100) ?? undefined, description: category.description?.slice(0, 100) ?? undefined,
emoji emoji: category.emoji ? { name: category.emoji } : undefined
}; }))
})
); );
return [new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(select)]; 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 { export function buildOpenModal(category: TicketCategory, locale: 'de' | 'en'): ModalBuilder {
const fields = parseFormFields(category.formFields); const fields = parseFormFields(category.formFields);
const modal = new ModalBuilder() const modal = new ModalBuilder()
@@ -716,11 +328,7 @@ async function fetchTicketMessages(
timestamp: message.createdAt.toISOString() timestamp: message.createdAt.toISOString()
})); }));
} catch (error) { } 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'); logger.warn({ error, ticketId: ticket.id }, 'Failed to fetch ticket messages');
}
return []; return [];
} }
} }
@@ -787,24 +395,13 @@ export async function createTicketChannel(
reason: `Ticket opened by ${params.opener.user.tag}` reason: `Ticket opened by ${params.opener.user.tag}`
}); });
await thread.members.add(params.opener.id); await thread.members.add(params.opener.id);
for (const roleId of supportRoleIds) {
const supportMembers = await resolveSupportMembers(params.guild, supportRoleIds); const role = params.guild.roles.cache.get(roleId);
for (const member of supportMembers) { if (role) {
if (member.id === params.opener.id) { for (const [, member] of role.members) {
continue; await thread.members.add(member.id).catch(() => undefined);
} }
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; threadId = thread.id;
} else { } else {
@@ -839,13 +436,10 @@ export async function createTicketChannel(
const targetId = channelId ?? threadId!; const targetId = channelId ?? threadId!;
const target = (await context.client.channels.fetch(targetId)) as TextChannel | ThreadChannel; const target = (await context.client.channels.fetch(targetId)) as TextChannel | ThreadChannel;
const roleMentions = supportRoleIds.map((roleId) => `<@&${roleId}>`).join(' ');
const introLines = [ const introLines = [
roleMentions || null,
tf(locale, 'ticket.opened', { user: `<@${params.opener.id}>` }), tf(locale, 'ticket.opened', { user: `<@${params.opener.id}>` }),
params.subject ? tf(locale, 'ticket.subjectLine', { subject: params.subject }) : null params.subject ? tf(locale, 'ticket.subjectLine', { subject: params.subject }) : null
].filter(Boolean) as string[]; ].filter(Boolean);
if (params.formAnswers && Object.keys(params.formAnswers).length > 0) { if (params.formAnswers && Object.keys(params.formAnswers).length > 0) {
const answerLines = Object.entries(params.formAnswers).map( const answerLines = Object.entries(params.formAnswers).map(
@@ -854,17 +448,7 @@ export async function createTicketChannel(
introLines.push(answerLines.join('\n')); introLines.push(answerLines.join('\n'));
} }
await target.send({ await target.send({ content: introLines.join('\n') });
content: introLines.join('\n'),
allowedMentions: {
roles: supportRoleIds,
users: [params.opener.id]
}
});
const control = buildTicketControlPanel(locale);
await target.send(control);
return ticket; return ticket;
} }
@@ -1136,13 +720,9 @@ export async function closeTicket(
await channel.delete(`Ticket ${ticket.id} closed`); await channel.delete(`Ticket ${ticket.id} closed`);
} }
} catch (error) { } 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'); logger.warn({ error, ticketId: ticket.id }, 'Failed to delete ticket channel');
} }
} }
}
} }
export async function rateTicket( export async function rateTicket(

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,8 @@
import { PermissionFlagsBits } from 'discord.js'; import {
PermissionFlagsBits,
type GuildBasedChannel,
type GuildTextBasedChannel
} from 'discord.js';
import { t } from '@nexumi/shared'; import { t } from '@nexumi/shared';
import type { SlashCommand } from '../../types.js'; import type { SlashCommand } from '../../types.js';
import { getGuildLocale } from '../../i18n.js'; import { getGuildLocale } from '../../i18n.js';
@@ -6,7 +10,25 @@ import { requirePermission } from '../../permissions.js';
import { ephemeral } from '../../interaction-reply.js'; import { ephemeral } from '../../interaction-reply.js';
import { verifyCommandData } from './command-definitions.js'; import { verifyCommandData } from './command-definitions.js';
import { getVerificationConfig, updateVerificationConfig } from './service.js'; import { getVerificationConfig, updateVerificationConfig } from './service.js';
import { syncVerificationPanel, VerificationPanelError } from './handlers.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)
);
}
const verifyCommand: SlashCommand = { const verifyCommand: SlashCommand = {
data: verifyCommandData, data: verifyCommandData,
@@ -69,21 +91,31 @@ const verifyCommand: SlashCommand = {
} }
const panelChannelOption = interaction.options.getChannel('channel'); 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;
try { const me = interaction.guild!.members.me;
await syncVerificationPanel(context, guildId, panelChannelOption?.id ?? null); if (!panelChannel || !me || !botCanPostPanel(panelChannel, me.id)) {
await interaction.reply({ content: t(locale, 'verification.panelPosted'), ...ephemeral() }); await interaction.reply({
} catch (error) { content: t(locale, 'verification.panelMissingPermission'),
if (error instanceof VerificationPanelError) { ...ephemeral()
const key = });
error.code === 'not_configured'
? 'verification.notConfigured'
: 'verification.panelMissingPermission';
await interaction.reply({ content: t(locale, key), ...ephemeral() });
return; return;
} }
throw error;
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
} }
});
await interaction.reply({ content: t(locale, 'verification.panelPosted'), ...ephemeral() });
} }
}; };

View File

@@ -5,9 +5,7 @@ import {
EmbedBuilder, EmbedBuilder,
PermissionFlagsBits, PermissionFlagsBits,
type ButtonInteraction, type ButtonInteraction,
type GuildBasedChannel, type GuildMember
type GuildMember,
type GuildTextBasedChannel
} from 'discord.js'; } from 'discord.js';
import { CaptchaProviderSchema, t, tf, type CaptchaProvider } from '@nexumi/shared'; import { CaptchaProviderSchema, t, tf, type CaptchaProvider } from '@nexumi/shared';
import type { BotContext } from '../../types.js'; import type { BotContext } from '../../types.js';
@@ -24,32 +22,6 @@ import { detectAltAccount } from './alt-detection.js';
import { env } from '../../env.js'; import { env } from '../../env.js';
import { logger } from '../../logger.js'; import { logger } from '../../logger.js';
import { ephemeral } from '../../interaction-reply.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 = { export type CompleteVerificationOptions = {
ipHash?: string | null; ipHash?: string | null;
@@ -58,61 +30,20 @@ export type CompleteVerificationOptions = {
}; };
async function applyFailAction( async function applyFailAction(
context: BotContext,
member: GuildMember, member: GuildMember,
failAction: string, failAction: string,
reason: string reason: string
): Promise<void> { ): Promise<void> {
const me = member.guild.members.me; 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)) { 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); await member.kick(reason);
return; return;
} }
if (failAction === 'BAN' && me?.permissions.has(PermissionFlagsBits.BanMembers)) { 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 }); 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( export async function completeVerification(
context: BotContext, context: BotContext,
guildId: string, guildId: string,
@@ -132,13 +63,11 @@ export async function completeVerification(
const ageDays = accountAgeDays(member.user.createdAt); const ageDays = accountAgeDays(member.user.createdAt);
if (config.minAccountAgeDays > 0 && ageDays < config.minAccountAgeDays) { if (config.minAccountAgeDays > 0 && ageDays < config.minAccountAgeDays) {
const failReason = `Verification failed: account too young (${ageDays}d)`; await applyFailAction(
await logVerificationFailure(context, member, { member,
reason: 'account_too_young', config.failAction,
detail: `Account age ${ageDays}d (required ${config.minAccountAgeDays}d)`, `Verification failed: account too young (${ageDays}d)`
failAction: config.failAction );
});
await applyFailAction(context, member, config.failAction, failReason);
return { ok: false, reason: 'account_too_young' }; return { ok: false, reason: 'account_too_young' };
} }
@@ -214,23 +143,13 @@ export async function completeVerification(
}); });
if (config.altBlockOnMatch) { if (config.altBlockOnMatch) {
const failReason = `Verification failed: possible alt account (${match.reason})`; await applyFailAction(
await logVerificationFailure(context, member, { member,
reason: 'alt_detected', config.failAction,
detail: `Signal: ${match.reason}`, `Verification failed: possible alt account (${match.reason})`
failAction: config.failAction, );
matchedUserId
});
await applyFailAction(context, member, config.failAction, failReason);
return { ok: false, reason: 'alt_detected' }; 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
});
} }
} }
@@ -309,86 +228,6 @@ export function buildVerificationPanel(
return { embeds: [embed], components: [row] }; 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( export async function handleVerificationButton(
interaction: ButtonInteraction, interaction: ButtonInteraction,
context: BotContext context: BotContext

View File

@@ -1,75 +1,23 @@
import { PermissionFlagsBits, type GuildMember, type Role } from 'discord.js'; import { PermissionFlagsBits, type GuildMember } from 'discord.js';
import { t, tf } from '@nexumi/shared';
import type { BotContext } from '../../types.js'; import type { BotContext } from '../../types.js';
import { getGuildLocale } from '../../i18n.js';
import { getWelcomeConfig, resolveWelcomePayload, resolveLeavePayload, renderWelcomeText } from './service.js'; import { getWelcomeConfig, resolveWelcomePayload, resolveLeavePayload, renderWelcomeText } from './service.js';
import { sendLeaveMessage, sendWelcomeMessage } from './renderer.js'; import { sendLeaveMessage, sendWelcomeMessage } from './renderer.js';
import { logger } from '../../logger.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> { async function applyAutoroles(member: GuildMember, roleIds: string[]): Promise<void> {
const uniqueIds = [...new Set(roleIds.filter(Boolean))]; const me = member.guild.members.me;
if (uniqueIds.length === 0) { if (!me?.permissions.has(PermissionFlagsBits.ManageRoles)) {
return; return;
} }
const assignable = roleIds.filter((roleId) => {
const me = const role = member.guild.roles.cache.get(roleId);
member.guild.members.me ?? (await member.guild.members.fetchMe().catch(() => null)); return role && role.position < me.roles.highest.position;
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) { if (assignable.length === 0) {
return; return;
} }
await member.roles.add(assignable).catch((error) => {
await member.roles.add(assignable, 'Nexumi welcome autorole').catch((error) => { logger.warn({ error, memberId: member.id }, 'Failed to assign autoroles');
logger.warn({ error, memberId: member.id, assignable }, 'Failed to assign autoroles');
}); });
} }
@@ -108,33 +56,6 @@ export async function handleMemberLeave(context: BotContext, member: GuildMember
return; return;
} }
await sendLeaveMessage(channel, resolveLeavePayload(config), member, member.guild); 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 { 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 { buildEmbedFromPayload } from '../../lib/embed-payload.js';
import { buildComponentsV2Payload } from '../../lib/components-v2-payload.js'; import { buildComponentsV2Payload } from '../../lib/components-v2-payload.js';
import type { LeavePayload, WelcomePayload } from './service.js'; import type { LeavePayload, WelcomePayload } from './service.js';
import { renderWelcomeEmbedText, renderWelcomeText } from './service.js'; import { renderWelcomeText } from './service.js';
export async function buildWelcomeMessage( export async function buildWelcomeMessage(
payload: WelcomePayload, payload: WelcomePayload,
@@ -31,7 +31,7 @@ export async function buildWelcomeMessage(
if (payload.type === 'EMBED') { if (payload.type === 'EMBED') {
const embed = buildEmbedFromPayload(payload.embed, { const embed = buildEmbedFromPayload(payload.embed, {
renderText: (value, field) => renderWelcomeEmbedText(value, member, guild, field), renderText: (value) => renderWelcomeText(value, member, guild),
defaultThumbnailUrl: member.user.displayAvatarURL({ size: 256 }) defaultThumbnailUrl: member.user.displayAvatarURL({ size: 256 })
}); });
return embed ? { embeds: [embed] } : { content: renderWelcomeText('{user}', member, guild) }; return embed ? { embeds: [embed] } : { content: renderWelcomeText('{user}', member, guild) };
@@ -77,15 +77,14 @@ async function renderWelcomeCard(
const title = payload.imageTitle ?? 'Welcome!'; const title = payload.imageTitle ?? 'Welcome!';
const subtitle = const subtitle =
payload.imageSubtitle ?? `${member.displayName} joined ${guild.name}`; payload.imageSubtitle ?? `${member.displayName} joined ${guild.name}`;
const plain = { mentionUser: false as const };
ctx.fillStyle = '#f9fafb'; ctx.fillStyle = '#f9fafb';
ctx.font = 'bold 36px sans-serif'; ctx.font = 'bold 36px sans-serif';
ctx.fillText(renderWelcomeText(title, member, guild, plain).slice(0, 40), 220, 130); ctx.fillText(renderWelcomeText(title, member, guild).slice(0, 40), 220, 130);
ctx.fillStyle = '#d1d5db'; ctx.fillStyle = '#d1d5db';
ctx.font = '24px sans-serif'; ctx.font = '24px sans-serif';
ctx.fillText(renderWelcomeText(subtitle, member, guild, plain).slice(0, 60), 220, 180); ctx.fillText(renderWelcomeText(subtitle, member, guild).slice(0, 60), 220, 180);
return canvas.toBuffer('image/png'); return canvas.toBuffer('image/png');
} }
@@ -120,7 +119,7 @@ export async function buildLeaveMessage(
if (payload.type === 'EMBED') { if (payload.type === 'EMBED') {
const embed = buildEmbedFromPayload(payload.embed, { const embed = buildEmbedFromPayload(payload.embed, {
renderText: (value, field) => renderWelcomeEmbedText(value, member, guild, field), renderText: (value) => renderWelcomeText(value, member, guild),
defaultThumbnailUrl: member.user.displayAvatarURL({ size: 256 }) defaultThumbnailUrl: member.user.displayAvatarURL({ size: 256 })
}); });
if (embed) { if (embed) {

View File

@@ -13,23 +13,9 @@ export async function getWelcomeConfig(prisma: PrismaClient, guildId: string) {
return config; return config;
} }
export type WelcomePlaceholderOptions = { export function buildPlaceholderVars(member: GuildMember, guild: Guild) {
/**
* 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 { return {
user: mentionUser ? `<@${member.id}>` : member.displayName, user: `<@${member.id}>`,
'user.name': member.displayName, 'user.name': member.displayName,
'user.tag': member.user.tag, 'user.tag': member.user.tag,
'user.id': member.id, 'user.id': member.id,
@@ -40,27 +26,8 @@ export function buildPlaceholderVars(
}; };
} }
export function renderWelcomeText( export function renderWelcomeText(template: string, member: GuildMember, guild: Guild): string {
template: string, return applyWelcomePlaceholders(template, buildPlaceholderVars(member, guild));
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 { export function parseWelcomeEmbed(raw: unknown): WelcomeEmbed | null {

View File

@@ -1,49 +1,14 @@
import { PermissionFlagsBits, type ChatInputCommandInteraction } from 'discord.js'; import { type ChatInputCommandInteraction } from 'discord.js';
import { t } from '@nexumi/shared'; import { t } from '@nexumi/shared';
import type { Locale } 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( export async function requirePermission(
interaction: ChatInputCommandInteraction, interaction: ChatInputCommandInteraction,
memberPermission: bigint, permission: bigint,
locale: Locale, locale: Locale
options?: RequirePermissionOptions
): Promise<boolean> { ): Promise<boolean> {
const member = interaction.memberPermissions; const member = interaction.memberPermissions;
if (!member?.has(memberPermission)) { if (!member?.has(permission)) {
await interaction.reply({ await interaction.reply({
content: t(locale, 'generic.noPermission'), content: t(locale, 'generic.noPermission'),
ephemeral: true ephemeral: true
@@ -51,36 +16,16 @@ export async function requirePermission(
return false; return false;
} }
const botBits = options?.botPermissions; if (!interaction.guild?.members.me?.permissions.has(permission)) {
if (botBits === undefined) {
return true;
}
const required = Array.isArray(botBits) ? botBits : [botBits];
const me = interaction.guild?.members.me;
if (!me) {
await interaction.reply({ await interaction.reply({
content: t(locale, 'generic.botMissingPermission').replace( content: t(locale, 'generic.botMissingPermission').replace(
'{permission}', '{permission}',
required.map(permissionLabel).join(', ') permission.toString()
), ),
ephemeral: true ephemeral: true
}); });
return false; 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; return true;
} }

View File

@@ -120,23 +120,6 @@ export async function readPresenceConfig(context: BotContext): Promise<BotPresen
export async function applyBotPresence(context: BotContext): Promise<void> { export async function applyBotPresence(context: BotContext): Promise<void> {
const config = await readPresenceConfig(context); 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 = const messages =
config.rotatingMessages.length > 0 ? config.rotatingMessages : [config.activityText]; config.rotatingMessages.length > 0 ? config.rotatingMessages : [config.activityText];
const index = Math.floor(Date.now() / 60_000) % messages.length; const index = Math.floor(Date.now() / 60_000) % messages.length;

View File

@@ -15,8 +15,6 @@ export const giveawayQueueName = 'giveaways';
export const giveawayQueue = new Queue(giveawayQueueName, { connection: redis }); export const giveawayQueue = new Queue(giveawayQueueName, { connection: redis });
export const ticketQueueName = 'tickets'; export const ticketQueueName = 'tickets';
export const ticketQueue = new Queue(ticketQueueName, { connection: redis }); 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 birthdayQueueName = 'birthdays';
export const birthdayQueue = new Queue(birthdayQueueName, { connection: redis }); export const birthdayQueue = new Queue(birthdayQueueName, { connection: redis });
export const statsQueueName = 'stats'; export const statsQueueName = 'stats';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,48 +0,0 @@
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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -18,8 +18,6 @@ interface DashboardShellProps {
otherGuilds: DashboardGuild[]; otherGuilds: DashboardGuild[];
user: SessionUser; user: SessionUser;
showOwnerLink?: boolean; showOwnerLink?: boolean;
/** True when an Owner-panel user opened a guild they do not manage on Discord. */
ownerBypass?: boolean;
children: ReactNode; children: ReactNode;
} }
@@ -29,7 +27,6 @@ export function DashboardShell({
otherGuilds, otherGuilds,
user, user,
showOwnerLink = false, showOwnerLink = false,
ownerBypass = false,
children children
}: DashboardShellProps) { }: DashboardShellProps) {
const t = useTranslations(); const t = useTranslations();
@@ -62,21 +59,7 @@ export function DashboardShell({
</div> </div>
</header> </header>
<main className="flex-1 px-4 py-6 sm:px-6 sm:py-8"> <main className="flex-1 px-4 py-6 sm:px-6 sm:py-8">
<div className="mx-auto w-full max-w-[1200px] space-y-4"> <div className="mx-auto w-full max-w-[1200px]">{children}</div>
{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> </main>
</div> </div>
</div> </div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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