Compare commits
24 Commits
2f01335e61
...
deploy
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2bab9cceb | ||
|
|
03bb406d82 | ||
|
|
2058713e03 | ||
|
|
061e287390 | ||
|
|
f67b9c61d7 | ||
|
|
bc4fae5415 | ||
|
|
5f932f5d63 | ||
|
|
b5bf214d7f | ||
|
|
5c11b68fc0 | ||
|
|
ac5ecc9a1c | ||
|
|
dbee58cd81 | ||
|
|
1eec10ba80 | ||
|
|
dba7bb3cdc | ||
|
|
ef1803f0ab | ||
|
|
67ac16d36b | ||
|
|
ba2d144038 | ||
|
|
8513848440 | ||
|
|
57cdf847f5 | ||
|
|
c7fb3d7041 | ||
|
|
20593b7173 | ||
|
|
e45642b6f9 | ||
|
|
211403d54d | ||
|
|
0ebe540c3f | ||
|
|
ccdf9aafe8 |
@@ -0,0 +1,12 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "BotAboutConfig" (
|
||||
"id" TEXT NOT NULL DEFAULT 'singleton',
|
||||
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||
"messageType" TEXT NOT NULL DEFAULT 'EMBED',
|
||||
"content" TEXT,
|
||||
"embed" JSONB,
|
||||
"components" JSONB,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "BotAboutConfig_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "TicketConfig" ADD COLUMN "panelChannelId" TEXT;
|
||||
ALTER TABLE "TicketConfig" ADD COLUMN "panelMessageId" TEXT;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Tag" ADD COLUMN "cooldownSeconds" INTEGER NOT NULL DEFAULT 15;
|
||||
@@ -0,0 +1,16 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Warning" ADD COLUMN "warningNumber" INTEGER;
|
||||
|
||||
-- Backfill per-guild sequential numbers (oldest first)
|
||||
WITH numbered AS (
|
||||
SELECT id, ROW_NUMBER() OVER (PARTITION BY "guildId" ORDER BY "createdAt" ASC, id ASC) AS rn
|
||||
FROM "Warning"
|
||||
)
|
||||
UPDATE "Warning" AS w
|
||||
SET "warningNumber" = numbered.rn
|
||||
FROM numbered
|
||||
WHERE w.id = numbered.id;
|
||||
|
||||
ALTER TABLE "Warning" ALTER COLUMN "warningNumber" SET NOT NULL;
|
||||
|
||||
CREATE UNIQUE INDEX "Warning_guildId_warningNumber_key" ON "Warning"("guildId", "warningNumber");
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "WelcomeConfig" ADD COLUMN "leaveAnnounceBan" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -130,6 +130,7 @@ model Case {
|
||||
|
||||
model Warning {
|
||||
id String @id @default(cuid())
|
||||
warningNumber Int
|
||||
guildId String
|
||||
userId String
|
||||
moderatorId String
|
||||
@@ -140,6 +141,7 @@ model Warning {
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
case Case? @relation(fields: [caseId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@unique([guildId, warningNumber])
|
||||
@@index([guildId, userId])
|
||||
}
|
||||
|
||||
@@ -252,6 +254,8 @@ model WelcomeConfig {
|
||||
leaveContent String?
|
||||
leaveEmbed Json?
|
||||
leaveComponents Json?
|
||||
/// When true, leave channel also posts a notice if the member was banned.
|
||||
leaveAnnounceBan Boolean @default(false)
|
||||
welcomeDmEnabled Boolean @default(false)
|
||||
welcomeDmContent String?
|
||||
userAutoroleIds String[] @default([])
|
||||
@@ -513,6 +517,8 @@ model TicketConfig {
|
||||
mode String @default("CHANNEL")
|
||||
categoryId String?
|
||||
logChannelId String?
|
||||
panelChannelId String?
|
||||
panelMessageId String?
|
||||
transcriptDm Boolean @default(false)
|
||||
inactivityHours Int @default(72)
|
||||
ratingEnabled Boolean @default(true)
|
||||
@@ -590,6 +596,7 @@ model Tag {
|
||||
components Json?
|
||||
responseType String @default("TEXT")
|
||||
triggerWord String?
|
||||
cooldownSeconds Int @default(15)
|
||||
allowedRoleIds String[] @default([])
|
||||
allowedChannelIds String[] @default([])
|
||||
createdById String
|
||||
@@ -914,6 +921,18 @@ model BotPresenceConfig {
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
/// Global `/about` message (Owner panel). Singleton row id = "singleton".
|
||||
model BotAboutConfig {
|
||||
id String @id @default("singleton")
|
||||
enabled Boolean @default(true)
|
||||
/// TEXT | EMBED | COMPONENTS_V2
|
||||
messageType String @default("EMBED")
|
||||
content String?
|
||||
embed Json?
|
||||
components Json?
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model ChangelogEntry {
|
||||
id String @id @default(cuid())
|
||||
version String
|
||||
|
||||
@@ -84,9 +84,31 @@ export async function evaluateCommandGate(
|
||||
if (existing > 0) {
|
||||
return { ok: false, reason: 'cooldown', retryAfterSeconds: existing };
|
||||
}
|
||||
await redis.set(key, '1', 'EX', override.cooldownSeconds);
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** Sets the per-command cooldown after a successful execute. */
|
||||
export async function applyCommandCooldown(
|
||||
interaction: ChatInputCommandInteraction,
|
||||
context: BotContext
|
||||
): Promise<void> {
|
||||
if (!interaction.guildId) {
|
||||
return;
|
||||
}
|
||||
const override = await context.prisma.commandOverride.findUnique({
|
||||
where: {
|
||||
guildId_commandName: {
|
||||
guildId: interaction.guildId,
|
||||
commandName: interaction.commandName
|
||||
}
|
||||
}
|
||||
});
|
||||
if (!override?.cooldownSeconds || override.cooldownSeconds <= 0) {
|
||||
return;
|
||||
}
|
||||
const key = `command:cooldown:${interaction.guildId}:${interaction.commandName}:${interaction.user.id}`;
|
||||
await redis.set(key, '1', 'EX', override.cooldownSeconds);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { t, tf, type Locale } from '@nexumi/shared';
|
||||
import { env } from './env.js';
|
||||
import { logger } from './logger.js';
|
||||
import { getGuildLocale } from './i18n.js';
|
||||
import { evaluateCommandGate, type CommandGateReason } from './command-gates.js';
|
||||
import { evaluateCommandGate, applyCommandCooldown, type CommandGateReason } from './command-gates.js';
|
||||
import {
|
||||
isGuildBlacklisted,
|
||||
isModuleEnabled,
|
||||
@@ -214,6 +214,11 @@ export async function routeCommand(interaction: ChatInputCommandInteraction, con
|
||||
moduleId
|
||||
);
|
||||
if (!enabled) {
|
||||
// Allow first-time setup while the module is still disabled (chicken-egg).
|
||||
const sub = interaction.options.getSubcommand(false);
|
||||
const isBootstrapSetup =
|
||||
interaction.commandName === 'starboard' && sub === 'setup';
|
||||
if (!isBootstrapSetup) {
|
||||
await interaction.reply({
|
||||
content: t(locale, 'generic.moduleDisabled'),
|
||||
ephemeral: true
|
||||
@@ -221,6 +226,7 @@ export async function routeCommand(interaction: ChatInputCommandInteraction, con
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const gate = await evaluateCommandGate(interaction, context);
|
||||
if (!gate.ok) {
|
||||
@@ -232,6 +238,7 @@ export async function routeCommand(interaction: ChatInputCommandInteraction, con
|
||||
}
|
||||
|
||||
await command.execute(interaction, context);
|
||||
await applyCommandCooldown(interaction, context).catch(() => undefined);
|
||||
} catch (error) {
|
||||
ok = false;
|
||||
captureException(error, {
|
||||
@@ -270,6 +277,16 @@ export async function routeContextMenu(
|
||||
return;
|
||||
}
|
||||
|
||||
const maintenance = await isMaintenanceMode(context);
|
||||
const ownerIds = env.OWNER_USER_IDS ?? [];
|
||||
if (maintenance.enabled && !ownerIds.includes(interaction.user.id)) {
|
||||
await interaction.reply({
|
||||
content: maintenance.message?.trim() || t(locale, 'generic.maintenance'),
|
||||
ephemeral: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const command = contextMenuMap.get(interaction.commandName);
|
||||
if (!command) {
|
||||
await interaction.reply({ content: t(locale, 'generic.unknownCommand'), ephemeral: true });
|
||||
|
||||
@@ -15,6 +15,7 @@ import { ensureRecurringJobs, startWorkers } from './jobs.js';
|
||||
import type { BotContext } from './types.js';
|
||||
import { startHealthServer } from './health.js';
|
||||
import { getGuildLocale } from './i18n.js';
|
||||
import { ephemeral } from './interaction-reply.js';
|
||||
import { t } from '@nexumi/shared';
|
||||
import { handleAutoModMessage } from './modules/automod/actions.js';
|
||||
import { registerLoggingEvents } from './modules/logging/events.js';
|
||||
@@ -116,6 +117,21 @@ if (!isShard) {
|
||||
logger.error({ error }, 'Failed to ensure recurring jobs');
|
||||
captureException(error, { phase: 'ensureRecurringJobs' });
|
||||
}
|
||||
|
||||
try {
|
||||
await runOncePerCluster(
|
||||
'nexumi:lock:recover-overdue-giveaways',
|
||||
60,
|
||||
async () => {
|
||||
const { recoverOverdueGiveaways } = await import('./modules/giveaways/index.js');
|
||||
await recoverOverdueGiveaways(context);
|
||||
},
|
||||
'Overdue giveaway recovery'
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error({ error }, 'Failed to recover overdue giveaways');
|
||||
captureException(error, { phase: 'recoverOverdueGiveaways' });
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -207,15 +223,19 @@ if (!isShard) {
|
||||
interactionType: interaction.type,
|
||||
guildId: interaction.guildId
|
||||
});
|
||||
try {
|
||||
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||
const message = t(locale, 'generic.error');
|
||||
if (interaction.isRepliable()) {
|
||||
if (interaction.replied || interaction.deferred) {
|
||||
await interaction.followUp({ content: message, ephemeral: true });
|
||||
await interaction.followUp({ content: message, ...ephemeral() });
|
||||
} else {
|
||||
await interaction.reply({ content: message, ephemeral: true });
|
||||
await interaction.reply({ content: message, ...ephemeral() });
|
||||
}
|
||||
}
|
||||
} catch (replyError) {
|
||||
logger.warn({ replyError }, 'Failed to send interaction error reply');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -7,6 +7,12 @@ import type {
|
||||
StringSelectMenuInteraction,
|
||||
UserSelectMenuInteraction
|
||||
} from 'discord.js';
|
||||
import { decodeComponentCustomId, t, type DashboardModuleId } from '@nexumi/shared';
|
||||
import { env } from './env.js';
|
||||
import { getGuildLocale } from './i18n.js';
|
||||
import { ephemeral } from './interaction-reply.js';
|
||||
import { isModuleEnabled, moduleForComponent } from './module-gates.js';
|
||||
import { isMaintenanceMode } from './presence.js';
|
||||
import type { BotContext } from './types.js';
|
||||
import {
|
||||
handleModerationConfirmation,
|
||||
@@ -139,11 +145,67 @@ const handlers: InteractionHandler[] = [
|
||||
}
|
||||
];
|
||||
|
||||
function moduleForComponentsV2(customId: string): DashboardModuleId | null {
|
||||
const decoded = decodeComponentCustomId(customId);
|
||||
if (!decoded) {
|
||||
return null;
|
||||
}
|
||||
switch (decoded.source) {
|
||||
case 'w':
|
||||
return 'welcome';
|
||||
case 't':
|
||||
return 'tags';
|
||||
case 's':
|
||||
return 'scheduler';
|
||||
case 'm':
|
||||
return 'messages';
|
||||
default:
|
||||
return 'messages';
|
||||
}
|
||||
}
|
||||
|
||||
export async function routeComponentInteraction(
|
||||
interaction: AnyComponentInteraction,
|
||||
context: BotContext
|
||||
): Promise<boolean> {
|
||||
const customId = interaction.customId;
|
||||
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||
|
||||
const maintenance = await isMaintenanceMode(context);
|
||||
const ownerIds = env.OWNER_USER_IDS ?? [];
|
||||
if (maintenance.enabled && !ownerIds.includes(interaction.user.id)) {
|
||||
if (!interaction.replied && !interaction.deferred) {
|
||||
await interaction.reply({
|
||||
content: maintenance.message?.trim() || t(locale, 'generic.maintenance'),
|
||||
...ephemeral()
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
let moduleId = moduleForComponent(customId);
|
||||
if (!moduleId && isComponentsV2Interaction(customId)) {
|
||||
moduleId = moduleForComponentsV2(customId);
|
||||
}
|
||||
|
||||
if (moduleId && interaction.guildId) {
|
||||
const enabled = await isModuleEnabled(
|
||||
context.prisma,
|
||||
context.redis,
|
||||
interaction.guildId,
|
||||
moduleId
|
||||
);
|
||||
if (!enabled) {
|
||||
if (!interaction.replied && !interaction.deferred) {
|
||||
await interaction.reply({
|
||||
content: t(locale, 'generic.moduleDisabled'),
|
||||
...ephemeral()
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
for (const handler of handlers) {
|
||||
if (handler.match(customId)) {
|
||||
await handler.handle(interaction, context);
|
||||
|
||||
@@ -9,12 +9,27 @@ import type { BotContext } from './types.js';
|
||||
import { env } from './env.js';
|
||||
import { refreshPhishingDomains } from './modules/automod/filters.js';
|
||||
import { activateLockdown, deactivateLockdown } from './modules/automod/actions.js';
|
||||
import { completeVerification } from './modules/verification/handlers.js';
|
||||
import {
|
||||
completeVerification,
|
||||
runVerificationPanelSyncJob
|
||||
} from './modules/verification/handlers.js';
|
||||
import { closePoll } from './modules/utility/poll.js';
|
||||
import { sendReminder } from './modules/utility/reminders.js';
|
||||
import { expireSelfRole } from './modules/selfroles/service.js';
|
||||
import { endGiveaway, runGiveawayCreateJob, GiveawayError } from './modules/giveaways/index.js';
|
||||
import { closeInactiveTickets } from './modules/tickets/index.js';
|
||||
import {
|
||||
expireSelfRole,
|
||||
runSelfRolePanelCreateJob,
|
||||
runSelfRolePanelUpdateJob,
|
||||
runSelfRolePanelDeleteJob
|
||||
} from './modules/selfroles/index.js';
|
||||
import type { SelfRoleBehavior, SelfRoleEntry, SelfRoleMode } from '@nexumi/shared';
|
||||
import {
|
||||
endGiveaway,
|
||||
runGiveawayCreateJob,
|
||||
runGiveawayPauseJob,
|
||||
runGiveawayDeleteJob,
|
||||
GiveawayError
|
||||
} from './modules/giveaways/index.js';
|
||||
import { closeInactiveTickets, runTicketDashboardActionJob, runTicketPanelSyncJob } from './modules/tickets/index.js';
|
||||
import { expireBirthdayRole, runBirthdayCheck } from './modules/birthdays/index.js';
|
||||
import { ensureStatsRecurringJobs, startStatsWorker } from './modules/stats/index.js';
|
||||
import { pollAllFeeds } from './modules/feeds/index.js';
|
||||
@@ -42,6 +57,7 @@ import {
|
||||
retentionQueue,
|
||||
retentionQueueName,
|
||||
scheduleQueueName,
|
||||
selfrolesQueueName,
|
||||
suggestionsQueueName,
|
||||
ticketQueue,
|
||||
ticketQueueName,
|
||||
@@ -71,6 +87,10 @@ type VerificationCompleteJob = {
|
||||
captchaProvider?: string | null;
|
||||
};
|
||||
|
||||
type VerificationPanelSyncJob = {
|
||||
guildId: string;
|
||||
};
|
||||
|
||||
type ReminderSendJob = {
|
||||
reminderId: string;
|
||||
};
|
||||
@@ -87,6 +107,8 @@ type SelfRoleExpireJob = {
|
||||
|
||||
type GiveawayEndJob = {
|
||||
giveawayId: string;
|
||||
/** When true, end even if the giveaway is paused (dashboard/slash paths). */
|
||||
force?: boolean;
|
||||
};
|
||||
|
||||
type GiveawayCreateJob = {
|
||||
@@ -101,6 +123,15 @@ type GiveawayCreateJob = {
|
||||
requiredMemberDays?: number | null;
|
||||
};
|
||||
|
||||
type GiveawayPauseJob = {
|
||||
giveawayId: string;
|
||||
paused: boolean;
|
||||
};
|
||||
|
||||
type GiveawayDeleteJob = {
|
||||
giveawayId: string;
|
||||
};
|
||||
|
||||
|
||||
type BirthdayRoleExpireJob = {
|
||||
guildId: string;
|
||||
@@ -115,6 +146,36 @@ type SuggestionStatusUpdateJob = {
|
||||
reason: string;
|
||||
};
|
||||
|
||||
type SelfRolePanelCreateJob = {
|
||||
guildId: string;
|
||||
channelId: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
mode: SelfRoleMode;
|
||||
behavior: SelfRoleBehavior;
|
||||
roles: SelfRoleEntry[];
|
||||
temporaryDurationMs?: number;
|
||||
expiresAt?: string | null;
|
||||
};
|
||||
|
||||
type SelfRolePanelUpdateJob = {
|
||||
panelId: string;
|
||||
guildId: string;
|
||||
channelId?: string;
|
||||
title?: string;
|
||||
description?: string | null;
|
||||
mode?: SelfRoleMode;
|
||||
behavior?: SelfRoleBehavior;
|
||||
roles?: SelfRoleEntry[];
|
||||
temporaryDurationMs?: number;
|
||||
expiresAt?: string | null;
|
||||
};
|
||||
|
||||
type SelfRolePanelDeleteJob = {
|
||||
panelId: string;
|
||||
guildId: string;
|
||||
};
|
||||
|
||||
export function startWorkers(context: BotContext): Worker[] {
|
||||
const moderationWorker = new Worker<TempBanJob>(
|
||||
moderationQueueName,
|
||||
@@ -243,16 +304,20 @@ export function startWorkers(context: BotContext): Worker[] {
|
||||
logger.error({ jobId: job?.id, error }, 'AutoMod job failed');
|
||||
});
|
||||
|
||||
const verificationWorker = new Worker<VerificationCompleteJob>(
|
||||
const verificationWorker = new Worker<VerificationCompleteJob | VerificationPanelSyncJob>(
|
||||
verificationQueueName,
|
||||
async (job) => {
|
||||
if (job.name === 'verificationPanelSync') {
|
||||
return runVerificationPanelSyncJob(context, job.data as VerificationPanelSyncJob);
|
||||
}
|
||||
if (job.name !== 'verificationComplete') {
|
||||
return;
|
||||
}
|
||||
await completeVerification(context, job.data.guildId, job.data.userId, {
|
||||
ipHash: job.data.ipHash,
|
||||
userAgentHash: job.data.userAgentHash,
|
||||
captchaProvider: job.data.captchaProvider
|
||||
const data = job.data as VerificationCompleteJob;
|
||||
await completeVerification(context, data.guildId, data.userId, {
|
||||
ipHash: data.ipHash,
|
||||
userAgentHash: data.userAgentHash,
|
||||
captchaProvider: data.captchaProvider
|
||||
});
|
||||
},
|
||||
{ connection: redis }
|
||||
@@ -283,6 +348,22 @@ export function startWorkers(context: BotContext): Worker[] {
|
||||
const ticketWorker = new Worker(
|
||||
ticketQueueName,
|
||||
async (job) => {
|
||||
if (job.name === 'ticketPanelSync') {
|
||||
return runTicketPanelSyncJob(context, job.data as { guildId: string });
|
||||
}
|
||||
if (job.name === 'ticketDashboardAction') {
|
||||
return runTicketDashboardActionJob(
|
||||
context,
|
||||
job.data as {
|
||||
ticketId: string;
|
||||
guildId: string;
|
||||
actorUserId: string;
|
||||
action: 'close' | 'claim' | 'priority';
|
||||
reason?: string;
|
||||
priority?: string;
|
||||
}
|
||||
);
|
||||
}
|
||||
if (job.name === 'selfRoleExpire') {
|
||||
const data = job.data as SelfRoleExpireJob;
|
||||
await expireSelfRole(context, data.guildId, data.userId, data.roleId);
|
||||
@@ -299,12 +380,47 @@ export function startWorkers(context: BotContext): Worker[] {
|
||||
logger.error({ jobId: job?.id, error }, 'Ticket queue job failed');
|
||||
});
|
||||
|
||||
const giveawayWorker = new Worker<GiveawayEndJob | GiveawayCreateJob>(
|
||||
const selfrolesWorker = new Worker<
|
||||
SelfRolePanelCreateJob | SelfRolePanelUpdateJob | SelfRolePanelDeleteJob
|
||||
>(
|
||||
selfrolesQueueName,
|
||||
async (job) => {
|
||||
if (job.name === 'selfRolePanelCreate') {
|
||||
return runSelfRolePanelCreateJob(context, job.data as SelfRolePanelCreateJob);
|
||||
}
|
||||
if (job.name === 'selfRolePanelUpdate') {
|
||||
return runSelfRolePanelUpdateJob(context, job.data as SelfRolePanelUpdateJob);
|
||||
}
|
||||
if (job.name === 'selfRolePanelDelete') {
|
||||
const data = job.data as SelfRolePanelDeleteJob;
|
||||
await runSelfRolePanelDeleteJob(context, data.panelId, data.guildId);
|
||||
}
|
||||
},
|
||||
{ connection: redis }
|
||||
);
|
||||
|
||||
selfrolesWorker.on('failed', (job, error) => {
|
||||
logger.error({ jobId: job?.id, error }, 'Self-roles queue job failed');
|
||||
});
|
||||
|
||||
const giveawayWorker = new Worker<
|
||||
GiveawayEndJob | GiveawayCreateJob | GiveawayPauseJob | GiveawayDeleteJob
|
||||
>(
|
||||
giveawayQueueName,
|
||||
async (job) => {
|
||||
if (job.name === 'giveawayEnd') {
|
||||
try {
|
||||
await endGiveaway(context, (job.data as GiveawayEndJob).giveawayId);
|
||||
const data = job.data as GiveawayEndJob;
|
||||
if (!data.force) {
|
||||
const current = await context.prisma.giveaway.findUnique({
|
||||
where: { id: data.giveawayId }
|
||||
});
|
||||
// Timer/recover must not end a paused giveaway.
|
||||
if (current?.paused) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
await endGiveaway(context, data.giveawayId);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof GiveawayError &&
|
||||
@@ -319,6 +435,13 @@ export function startWorkers(context: BotContext): Worker[] {
|
||||
if (job.name === 'giveawayCreate') {
|
||||
return runGiveawayCreateJob(context, job.data as GiveawayCreateJob);
|
||||
}
|
||||
if (job.name === 'giveawayPause') {
|
||||
const data = job.data as GiveawayPauseJob;
|
||||
return runGiveawayPauseJob(context, data.giveawayId, data.paused);
|
||||
}
|
||||
if (job.name === 'giveawayDelete') {
|
||||
await runGiveawayDeleteJob(context, (job.data as GiveawayDeleteJob).giveawayId);
|
||||
}
|
||||
},
|
||||
{ connection: redis }
|
||||
);
|
||||
@@ -453,6 +576,7 @@ export function startWorkers(context: BotContext): Worker[] {
|
||||
verificationWorker,
|
||||
reminderWorker,
|
||||
ticketWorker,
|
||||
selfrolesWorker,
|
||||
giveawayWorker,
|
||||
birthdayWorker,
|
||||
statsWorker,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { EmbedBuilder } from 'discord.js';
|
||||
import {
|
||||
embedHasContent,
|
||||
mapEmbedTextFields,
|
||||
type EmbedTextField,
|
||||
type WelcomeEmbed
|
||||
} from '@nexumi/shared';
|
||||
|
||||
@@ -18,8 +19,12 @@ function isHttpUrl(value: string | undefined): value is string {
|
||||
}
|
||||
|
||||
export interface ApplyEmbedOptions {
|
||||
/** Called for every text/URL field before applying to the builder. */
|
||||
renderText: (value: string) => string;
|
||||
/**
|
||||
* Called for every text/URL field before applying to the builder.
|
||||
* `field` lets callers render `{user}` as a mention in the description
|
||||
* but as a plain display name in title/author/footer.
|
||||
*/
|
||||
renderText: (value: string, field: EmbedTextField) => string;
|
||||
/**
|
||||
* Used when `thumbnailUrl` is unset (Welcome default: member avatar).
|
||||
* Pass `null` to force no thumbnail.
|
||||
|
||||
35
apps/bot/src/lib/safe-remove-job.ts
Normal file
35
apps/bot/src/lib/safe-remove-job.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { Job } from 'bullmq';
|
||||
import { logger } from '../logger.js';
|
||||
|
||||
/**
|
||||
* Removes a BullMQ job when present. Locked (active) jobs cannot be removed —
|
||||
* those errors are swallowed so callers can still schedule a replacement jobId
|
||||
* or rely on removeOnComplete after the worker finishes.
|
||||
* Jobs that belong to a Job Scheduler must be removed via removeJobScheduler;
|
||||
* direct remove is ignored here.
|
||||
*/
|
||||
export async function safeRemoveBullJob(
|
||||
job: Job | null | undefined,
|
||||
context?: { label?: string; jobId?: string }
|
||||
): Promise<void> {
|
||||
if (!job) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await job.remove();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (
|
||||
message.includes('locked') ||
|
||||
message.includes('could not be removed') ||
|
||||
message.includes('belongs to a job scheduler')
|
||||
) {
|
||||
logger.warn(
|
||||
{ error, jobId: context?.jobId ?? job.id, label: context?.label },
|
||||
'BullMQ job could not be removed directly; skipping'
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ describe('moduleForCommand', () => {
|
||||
it('leaves core commands unmapped', () => {
|
||||
expect(moduleForCommand('help')).toBeNull();
|
||||
expect(moduleForCommand('info')).toBeNull();
|
||||
expect(moduleForCommand('about')).toBeNull();
|
||||
expect(moduleForCommand('gdpr')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -92,6 +92,41 @@ export function moduleForCommand(commandName: string): DashboardModuleId | null
|
||||
return COMMAND_MODULE_MAP[commandName] ?? null;
|
||||
}
|
||||
|
||||
/** Component customId → dashboard module (null = no module gate). */
|
||||
export function moduleForComponent(customId: string): DashboardModuleId | null {
|
||||
if (customId.startsWith('mod:confirm:') || customId.startsWith('mod:cancel:')) {
|
||||
return 'moderation';
|
||||
}
|
||||
if (customId.startsWith('verify:')) {
|
||||
return 'verification';
|
||||
}
|
||||
if (customId.startsWith('eco:bj:')) {
|
||||
return 'economy';
|
||||
}
|
||||
if (customId.startsWith('fun:')) {
|
||||
return 'fun';
|
||||
}
|
||||
if (customId.startsWith('gw:')) {
|
||||
return 'giveaways';
|
||||
}
|
||||
if (customId.startsWith('ticket:') || customId === 'ticket:select') {
|
||||
return 'tickets';
|
||||
}
|
||||
if (customId.startsWith('sr:')) {
|
||||
return 'selfroles';
|
||||
}
|
||||
if (customId.startsWith('sug:')) {
|
||||
return 'suggestions';
|
||||
}
|
||||
if (customId.startsWith('tv:')) {
|
||||
return 'tempvoice';
|
||||
}
|
||||
if (customId.startsWith('gbackup:')) {
|
||||
return 'guildbackup';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function isFeatureFlagEnabled(
|
||||
prisma: PrismaClient,
|
||||
key: string,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ChannelType, PermissionFlagsBits, type Guild, type GuildMember, type Message, type TextChannel } from 'discord.js';
|
||||
import type { BotContext } from '../../types.js';
|
||||
import { createCase } from '../moderation/service.js';
|
||||
import { createCase, createWarning, notifyModerationTargetDm } from '../moderation/service.js';
|
||||
import { getGuildLocale } from '../../i18n.js';
|
||||
import {
|
||||
getAutoModConfig,
|
||||
getEnabledAutoModRules,
|
||||
@@ -41,15 +42,8 @@ async function applyAutoModAction(
|
||||
const baseReason = `AutoMod (${ruleType}): ${reason}`;
|
||||
|
||||
if (action === 'WARN') {
|
||||
await context.prisma.warning.create({
|
||||
data: {
|
||||
guildId: guild.id,
|
||||
userId: message.author.id,
|
||||
moderatorId,
|
||||
reason: baseReason
|
||||
}
|
||||
});
|
||||
await createCase(context, {
|
||||
const locale = await getGuildLocale(context.prisma, guild.id);
|
||||
const modCase = await createCase(context, {
|
||||
guildId: guild.id,
|
||||
targetUserId: message.author.id,
|
||||
moderatorId,
|
||||
@@ -59,6 +53,16 @@ async function applyAutoModAction(
|
||||
source: 'automod',
|
||||
metadata: { ruleType, automod: true }
|
||||
});
|
||||
await createWarning(context, {
|
||||
guildId: guild.id,
|
||||
userId: message.author.id,
|
||||
moderatorId,
|
||||
reason: baseReason,
|
||||
caseId: modCase.id,
|
||||
guildName: guild.name,
|
||||
locale,
|
||||
notifyUser: message.author
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -81,6 +85,14 @@ async function applyAutoModAction(
|
||||
|
||||
if (action === 'KICK') {
|
||||
if (me.permissions.has(PermissionFlagsBits.KickMembers)) {
|
||||
const locale = await getGuildLocale(context.prisma, guild.id);
|
||||
await notifyModerationTargetDm({
|
||||
user: message.author,
|
||||
guildName: guild.name,
|
||||
reason: baseReason,
|
||||
locale,
|
||||
action: 'kick'
|
||||
});
|
||||
await message.member.kick(baseReason);
|
||||
await createCase(context, {
|
||||
guildId: guild.id,
|
||||
@@ -98,6 +110,14 @@ async function applyAutoModAction(
|
||||
|
||||
if (action === 'BAN') {
|
||||
if (me.permissions.has(PermissionFlagsBits.BanMembers)) {
|
||||
const locale = await getGuildLocale(context.prisma, guild.id);
|
||||
await notifyModerationTargetDm({
|
||||
user: message.author,
|
||||
guildName: guild.name,
|
||||
reason: baseReason,
|
||||
locale,
|
||||
action: 'ban'
|
||||
});
|
||||
await guild.members.ban(message.author.id, { reason: baseReason });
|
||||
await createCase(context, {
|
||||
guildId: guild.id,
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { SlashCommandBuilder } from 'discord.js';
|
||||
import { PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
|
||||
import { applyCommandDescription } from '@nexumi/shared';
|
||||
|
||||
export const automodStatusCommandData = applyCommandDescription(
|
||||
new SlashCommandBuilder().setName('automod').addSubcommand((s) =>
|
||||
new SlashCommandBuilder()
|
||||
.setName('automod')
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild)
|
||||
.addSubcommand((s) =>
|
||||
applyCommandDescription(s.setName('status'), 'automod.status.description')
|
||||
),
|
||||
'automod.description'
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Redis } from 'ioredis';
|
||||
import {
|
||||
capsRatio,
|
||||
contentMatchesBlockedWord,
|
||||
countEmojis,
|
||||
extractUrls,
|
||||
hasZalgo,
|
||||
@@ -124,15 +125,14 @@ function checkExternalLink(content: string, threshold: AutoModThreshold): Filter
|
||||
}
|
||||
|
||||
function checkWordFilter(content: string, wordList: AutoModWordList): FilterResult | null {
|
||||
const lower = content.toLowerCase();
|
||||
for (const word of wordList.words) {
|
||||
if (word.length > 0 && lower.includes(word.toLowerCase())) {
|
||||
if (contentMatchesBlockedWord(content, word)) {
|
||||
return { matched: true, ruleType: 'WORD_FILTER', reason: 'Blocked word' };
|
||||
}
|
||||
}
|
||||
for (const pattern of wordList.regexPatterns) {
|
||||
try {
|
||||
const regex = new RegExp(pattern, 'i');
|
||||
const regex = new RegExp(pattern, 'iu');
|
||||
if (regex.test(content)) {
|
||||
return { matched: true, ruleType: 'WORD_FILTER', reason: 'Blocked pattern' };
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { PrismaClient } from '@prisma/client';
|
||||
import type { Prisma, PrismaClient } from '@prisma/client';
|
||||
import {
|
||||
DEFAULT_AUTOMOD_RULES,
|
||||
defaultAutoModWordList,
|
||||
shouldSeedDefaultBlockedWords,
|
||||
type AutoModExceptions,
|
||||
type AutoModThreshold,
|
||||
type AutoModWordList
|
||||
@@ -16,6 +18,26 @@ export async function getAutoModConfig(prisma: PrismaClient, guildId: string) {
|
||||
return config;
|
||||
}
|
||||
|
||||
async function seedDefaultWordFilterIfNeeded(prisma: PrismaClient, guildId: string) {
|
||||
const rule = await prisma.autoModRule.findUnique({
|
||||
where: { guildId_ruleType: { guildId, ruleType: 'WORD_FILTER' } },
|
||||
select: { id: true, wordList: true }
|
||||
});
|
||||
if (!rule || !shouldSeedDefaultBlockedWords(rule.wordList)) {
|
||||
return;
|
||||
}
|
||||
const existing = parseWordList(rule.wordList);
|
||||
await prisma.autoModRule.update({
|
||||
where: { id: rule.id },
|
||||
data: {
|
||||
wordList: {
|
||||
...defaultAutoModWordList(),
|
||||
regexPatterns: existing.regexPatterns
|
||||
} as Prisma.InputJsonValue
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function ensureDefaultAutoModRules(prisma: PrismaClient, guildId: string) {
|
||||
await ensureGuild(prisma, guildId);
|
||||
const existing = await prisma.autoModRule.findMany({
|
||||
@@ -24,9 +46,7 @@ export async function ensureDefaultAutoModRules(prisma: PrismaClient, guildId: s
|
||||
});
|
||||
const have = new Set(existing.map((row) => row.ruleType));
|
||||
const missing = DEFAULT_AUTOMOD_RULES.filter((rule) => !have.has(rule.ruleType));
|
||||
if (missing.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (missing.length > 0) {
|
||||
await prisma.autoModRule.createMany({
|
||||
data: missing.map((rule) => ({
|
||||
guildId,
|
||||
@@ -37,6 +57,8 @@ export async function ensureDefaultAutoModRules(prisma: PrismaClient, guildId: s
|
||||
enabled: ['SPAM', 'MASS_MENTION', 'INVITE_LINK', 'PHISHING'].includes(rule.ruleType)
|
||||
}))
|
||||
});
|
||||
}
|
||||
await seedDefaultWordFilterIfNeeded(prisma, guildId);
|
||||
}
|
||||
|
||||
export async function getEnabledAutoModRules(prisma: PrismaClient, guildId: string) {
|
||||
@@ -71,7 +93,8 @@ export function parseWordList(raw: unknown): AutoModWordList {
|
||||
const obj = raw as Record<string, unknown>;
|
||||
return {
|
||||
words: Array.isArray(obj.words) ? obj.words.map(String) : [],
|
||||
regexPatterns: Array.isArray(obj.regexPatterns) ? obj.regexPatterns.map(String) : []
|
||||
regexPatterns: Array.isArray(obj.regexPatterns) ? obj.regexPatterns.map(String) : [],
|
||||
...(obj.userCleared === true ? { userCleared: true } : {})
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -67,6 +67,13 @@ async function loadPayload(
|
||||
}
|
||||
return parseMessageComponentsV2(binding.payload);
|
||||
}
|
||||
case 'a': {
|
||||
if (ref !== 'singleton') {
|
||||
return null;
|
||||
}
|
||||
const config = await context.prisma.botAboutConfig.findUnique({ where: { id: 'singleton' } });
|
||||
return parseMessageComponentsV2(config?.components);
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
98
apps/bot/src/modules/core/about.ts
Normal file
98
apps/bot/src/modules/core/about.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import {
|
||||
ABOUT_REDIS_KEY,
|
||||
BotAboutConfigSchema,
|
||||
expandBracketChannelMentions,
|
||||
parseMessageComponentsV2,
|
||||
t,
|
||||
type BotAboutConfig,
|
||||
type Locale,
|
||||
type WelcomeEmbed
|
||||
} from '@nexumi/shared';
|
||||
import type { InteractionReplyOptions, MessageCreateOptions } from 'discord.js';
|
||||
import { EmbedBuilder } from 'discord.js';
|
||||
import { buildComponentsV2Payload } from '../../lib/components-v2-payload.js';
|
||||
import { buildEmbedFromPayload } from '../../lib/embed-payload.js';
|
||||
import { redis } from '../../redis.js';
|
||||
import type { BotContext } from '../../types.js';
|
||||
|
||||
const NEXUMI_COLOR = 0x6366f1;
|
||||
|
||||
function parseEmbed(raw: unknown): WelcomeEmbed | null {
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
return null;
|
||||
}
|
||||
return raw as WelcomeEmbed;
|
||||
}
|
||||
|
||||
export async function readAboutConfig(context: BotContext): Promise<BotAboutConfig> {
|
||||
const cached = await redis.get(ABOUT_REDIS_KEY);
|
||||
if (cached) {
|
||||
const parsed = BotAboutConfigSchema.safeParse(JSON.parse(cached));
|
||||
if (parsed.success) {
|
||||
return parsed.data;
|
||||
}
|
||||
}
|
||||
|
||||
const row = await context.prisma.botAboutConfig.upsert({
|
||||
where: { id: 'singleton' },
|
||||
create: { id: 'singleton' },
|
||||
update: {}
|
||||
});
|
||||
const config = BotAboutConfigSchema.parse({
|
||||
enabled: row.enabled,
|
||||
messageType: row.messageType,
|
||||
content: row.content,
|
||||
embed: parseEmbed(row.embed),
|
||||
components: parseMessageComponentsV2(row.components)
|
||||
});
|
||||
await redis.set(ABOUT_REDIS_KEY, JSON.stringify(config));
|
||||
return config;
|
||||
}
|
||||
|
||||
function defaultAboutReply(locale: Locale): InteractionReplyOptions {
|
||||
return {
|
||||
embeds: [
|
||||
new EmbedBuilder()
|
||||
.setTitle(t(locale, 'core.about.defaultTitle'))
|
||||
.setDescription(t(locale, 'core.about.defaultBody'))
|
||||
.setColor(NEXUMI_COLOR)
|
||||
.setFooter({ text: 'Nexumi' })
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
export async function buildAboutReply(
|
||||
context: BotContext,
|
||||
locale: Locale
|
||||
): Promise<InteractionReplyOptions | MessageCreateOptions> {
|
||||
const config = await readAboutConfig(context);
|
||||
if (!config.enabled) {
|
||||
return { content: t(locale, 'core.about.disabled'), ephemeral: true };
|
||||
}
|
||||
|
||||
const renderText = (value: string) => expandBracketChannelMentions(value);
|
||||
|
||||
if (config.messageType === 'COMPONENTS_V2') {
|
||||
const payload = buildComponentsV2Payload(config.components, {
|
||||
source: 'a',
|
||||
ref: 'singleton',
|
||||
renderText
|
||||
});
|
||||
if (payload) {
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
|
||||
if (config.messageType === 'EMBED') {
|
||||
const embed = buildEmbedFromPayload(config.embed, { renderText });
|
||||
if (embed) {
|
||||
return { embeds: [embed] };
|
||||
}
|
||||
}
|
||||
|
||||
if (config.messageType === 'TEXT' && config.content?.trim()) {
|
||||
return { content: renderText(config.content) };
|
||||
}
|
||||
|
||||
return defaultAboutReply(locale);
|
||||
}
|
||||
@@ -13,6 +13,11 @@ export const infoCommandData = applyCommandDescription(
|
||||
'core.info.description'
|
||||
);
|
||||
|
||||
export const aboutCommandData = applyCommandDescription(
|
||||
new SlashCommandBuilder().setName('about'),
|
||||
'core.about.description'
|
||||
);
|
||||
|
||||
export const inviteCommandData = applyCommandDescription(
|
||||
new SlashCommandBuilder().setName('invite'),
|
||||
'core.invite.description'
|
||||
|
||||
@@ -6,10 +6,12 @@ import { buildBotInviteUrl, env } from '../../env.js';
|
||||
import {
|
||||
helpCommandData,
|
||||
infoCommandData,
|
||||
aboutCommandData,
|
||||
inviteCommandData,
|
||||
supportCommandData
|
||||
} from './command-definitions.js';
|
||||
import { HELP_MODULES } from './help-catalog.js';
|
||||
import { buildAboutReply } from './about.js';
|
||||
|
||||
const NEXUMI_COLOR = 0x6366f1;
|
||||
const PACKAGE_VERSION = '0.1.0';
|
||||
@@ -118,6 +120,15 @@ const infoCommand: SlashCommand = {
|
||||
}
|
||||
};
|
||||
|
||||
const aboutCommand: SlashCommand = {
|
||||
data: aboutCommandData,
|
||||
async execute(interaction, context) {
|
||||
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||
const reply = await buildAboutReply(context, locale);
|
||||
await interaction.reply(reply as import('discord.js').InteractionReplyOptions);
|
||||
}
|
||||
};
|
||||
|
||||
const inviteCommand: SlashCommand = {
|
||||
data: inviteCommandData,
|
||||
async execute(interaction, context) {
|
||||
@@ -153,6 +164,7 @@ const supportCommand: SlashCommand = {
|
||||
export const coreCommands: SlashCommand[] = [
|
||||
helpCommand,
|
||||
infoCommand,
|
||||
aboutCommand,
|
||||
inviteCommand,
|
||||
supportCommand
|
||||
];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** Module id → top-level slash command names for `/help`. */
|
||||
export const HELP_MODULES: ReadonlyArray<{ id: string; commands: readonly string[] }> = [
|
||||
{ id: 'core', commands: ['help', 'info', 'invite', 'support', 'privacy', 'gdpr'] },
|
||||
{ id: 'core', commands: ['help', 'info', 'about', 'invite', 'support', 'privacy', 'gdpr'] },
|
||||
{
|
||||
id: 'moderation',
|
||||
commands: [
|
||||
|
||||
@@ -327,7 +327,12 @@ async function handleMediaError(
|
||||
): Promise<void> {
|
||||
if (error instanceof FunMediaError) {
|
||||
const key = error.code === 'disabled' ? 'fun.media.disabled' : 'fun.media.fetchFailed';
|
||||
await interaction.reply({ content: t(locale, key), ephemeral: true });
|
||||
const content = t(locale, key);
|
||||
if (interaction.deferred || interaction.replied) {
|
||||
await interaction.editReply({ content });
|
||||
return;
|
||||
}
|
||||
await interaction.reply({ content, ephemeral: true });
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
@@ -342,12 +347,13 @@ const memeCommand: SlashCommand = {
|
||||
|
||||
try {
|
||||
await assertMediaEnabled(context.redis, guildId);
|
||||
await interaction.deferReply();
|
||||
const meme = await fetchMeme();
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(meme.title)
|
||||
.setImage(meme.url)
|
||||
.setColor(0x6366f1);
|
||||
await interaction.reply({ embeds: [embed] });
|
||||
await interaction.editReply({ embeds: [embed] });
|
||||
} catch (error) {
|
||||
await handleMediaError(interaction, locale, error);
|
||||
}
|
||||
@@ -363,12 +369,13 @@ const catCommand: SlashCommand = {
|
||||
|
||||
try {
|
||||
await assertMediaEnabled(context.redis, guildId);
|
||||
await interaction.deferReply();
|
||||
const url = await fetchCatImage();
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(t(locale, 'fun.cat.title'))
|
||||
.setImage(url)
|
||||
.setColor(0x6366f1);
|
||||
await interaction.reply({ embeds: [embed] });
|
||||
await interaction.editReply({ embeds: [embed] });
|
||||
} catch (error) {
|
||||
await handleMediaError(interaction, locale, error);
|
||||
}
|
||||
@@ -384,12 +391,13 @@ const dogCommand: SlashCommand = {
|
||||
|
||||
try {
|
||||
await assertMediaEnabled(context.redis, guildId);
|
||||
await interaction.deferReply();
|
||||
const url = await fetchDogImage();
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(t(locale, 'fun.dog.title'))
|
||||
.setImage(url)
|
||||
.setColor(0x6366f1);
|
||||
await interaction.reply({ embeds: [embed] });
|
||||
await interaction.editReply({ embeds: [embed] });
|
||||
} catch (error) {
|
||||
await handleMediaError(interaction, locale, error);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { applyCommandDescription, applyOptionDescription } from '@nexumi/shared';
|
||||
import { SlashCommandBuilder } from 'discord.js';
|
||||
import { PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
|
||||
|
||||
export const giveawayCommandData = applyCommandDescription(
|
||||
new SlashCommandBuilder()
|
||||
.setName('giveaway')
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild)
|
||||
.addSubcommand((s) =>
|
||||
applyCommandDescription(s.setName('start'), 'giveaway.start.description')
|
||||
.addStringOption((o) =>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { requirePermission } from '../../permissions.js';
|
||||
import { parseDuration } from '../moderation/duration.js';
|
||||
import { giveawayCommandData } from './command-definitions.js';
|
||||
import {
|
||||
cancelGiveawayJob,
|
||||
deleteGiveaway,
|
||||
endGiveaway,
|
||||
GiveawayError,
|
||||
@@ -26,6 +27,17 @@ async function ensureManageGuild(
|
||||
return requirePermission(interaction, PermissionFlagsBits.ManageGuild, locale);
|
||||
}
|
||||
|
||||
async function replyEphemeral(
|
||||
interaction: Parameters<SlashCommand['execute']>[0],
|
||||
content: string
|
||||
): Promise<void> {
|
||||
if (interaction.deferred || interaction.replied) {
|
||||
await interaction.editReply({ content });
|
||||
return;
|
||||
}
|
||||
await interaction.reply({ content, ephemeral: true });
|
||||
}
|
||||
|
||||
const giveawayCommand: SlashCommand = {
|
||||
data: giveawayCommandData,
|
||||
async execute(interaction, context) {
|
||||
@@ -71,6 +83,8 @@ const giveawayCommand: SlashCommand = {
|
||||
return;
|
||||
}
|
||||
|
||||
await interaction.deferReply({ ephemeral: true });
|
||||
|
||||
const giveaway = await startGiveaway(
|
||||
context,
|
||||
{
|
||||
@@ -87,9 +101,8 @@ const giveawayCommand: SlashCommand = {
|
||||
locale
|
||||
);
|
||||
|
||||
await interaction.reply({
|
||||
content: tf(locale, 'giveaway.started', { id: giveaway.id }),
|
||||
ephemeral: true
|
||||
await interaction.editReply({
|
||||
content: tf(locale, 'giveaway.started', { id: giveaway.id })
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -100,20 +113,30 @@ const giveawayCommand: SlashCommand = {
|
||||
|
||||
try {
|
||||
if (sub === 'end') {
|
||||
const id = interaction.options.getString('id', true);
|
||||
const id = interaction.options.getString('id', true).trim();
|
||||
const giveaway = await context.prisma.giveaway.findUnique({ where: { id } });
|
||||
if (!giveaway || giveaway.guildId !== interaction.guildId) {
|
||||
throw new GiveawayError('not_found');
|
||||
}
|
||||
await interaction.deferReply({ ephemeral: true });
|
||||
await cancelGiveawayJob(id);
|
||||
await endGiveaway(context, id);
|
||||
await interaction.reply({ content: t(locale, 'giveaway.ended'), ephemeral: true });
|
||||
await interaction.editReply({ content: t(locale, 'giveaway.ended') });
|
||||
return;
|
||||
}
|
||||
|
||||
if (sub === 'reroll') {
|
||||
const id = interaction.options.getString('id', true);
|
||||
const id = interaction.options.getString('id', true).trim();
|
||||
const existing = await context.prisma.giveaway.findUnique({ where: { id } });
|
||||
if (!existing || existing.guildId !== interaction.guildId) {
|
||||
throw new GiveawayError('not_found');
|
||||
}
|
||||
await interaction.deferReply({ ephemeral: true });
|
||||
const updated = await rerollGiveaway(context, id, locale);
|
||||
await interaction.reply({
|
||||
await interaction.editReply({
|
||||
content: tf(locale, 'giveaway.rerolled', {
|
||||
winners: updated.winners.map((w) => `<@${w}>`).join(', ') || '—'
|
||||
}),
|
||||
ephemeral: true
|
||||
})
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -136,14 +159,22 @@ const giveawayCommand: SlashCommand = {
|
||||
}
|
||||
|
||||
if (sub === 'delete') {
|
||||
const id = interaction.options.getString('id', true);
|
||||
const id = interaction.options.getString('id', true).trim();
|
||||
const existing = await context.prisma.giveaway.findUnique({ where: { id } });
|
||||
if (!existing || existing.guildId !== interaction.guildId) {
|
||||
throw new GiveawayError('not_found');
|
||||
}
|
||||
await deleteGiveaway(context, id);
|
||||
await interaction.reply({ content: t(locale, 'giveaway.deleted'), ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (sub === 'pause') {
|
||||
const id = interaction.options.getString('id', true);
|
||||
const id = interaction.options.getString('id', true).trim();
|
||||
const existing = await context.prisma.giveaway.findUnique({ where: { id } });
|
||||
if (!existing || existing.guildId !== interaction.guildId) {
|
||||
throw new GiveawayError('not_found');
|
||||
}
|
||||
const updated = await pauseGiveaway(context, id, locale);
|
||||
await interaction.reply({
|
||||
content: t(locale, updated.paused ? 'giveaway.paused' : 'giveaway.resumed'),
|
||||
@@ -152,10 +183,7 @@ const giveawayCommand: SlashCommand = {
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof GiveawayError) {
|
||||
await interaction.reply({
|
||||
content: t(locale, `giveaway.error.${error.code}`),
|
||||
ephemeral: true
|
||||
});
|
||||
await replyEphemeral(interaction, t(locale, `giveaway.error.${error.code}`));
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
export { giveawayCommands } from './commands.js';
|
||||
export { isGiveawayButton, handleGiveawayButton } from './buttons.js';
|
||||
export { endGiveaway, scheduleGiveawayEnd, runGiveawayCreateJob, GiveawayError } from './service.js';
|
||||
export {
|
||||
endGiveaway,
|
||||
scheduleGiveawayEnd,
|
||||
runGiveawayCreateJob,
|
||||
runGiveawayPauseJob,
|
||||
runGiveawayDeleteJob,
|
||||
recoverOverdueGiveaways,
|
||||
GiveawayError
|
||||
} from './service.js';
|
||||
|
||||
@@ -22,25 +22,46 @@ export class GiveawayError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export function giveawayEndJobId(giveawayId: string): string {
|
||||
return `giveaway-end-${giveawayId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a scheduled/failed end job if present. Locked (active) jobs cannot be
|
||||
* removed — callers must not invoke this from inside the giveawayEnd worker.
|
||||
*/
|
||||
export async function cancelGiveawayJob(giveawayId: string): Promise<void> {
|
||||
const job = await giveawayQueue.getJob(giveawayEndJobId(giveawayId));
|
||||
if (!job) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await job.remove();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (message.includes('locked') || message.includes('could not be removed')) {
|
||||
logger.warn({ giveawayId, error }, 'Giveaway end job is locked; skipping remove');
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function scheduleGiveawayEnd(giveawayId: string, endsAt: Date): Promise<void> {
|
||||
await cancelGiveawayJob(giveawayId);
|
||||
const delay = Math.max(0, endsAt.getTime() - Date.now());
|
||||
await giveawayQueue.add(
|
||||
'giveawayEnd',
|
||||
{ giveawayId },
|
||||
{
|
||||
delay,
|
||||
jobId: `giveaway-end-${giveawayId}`,
|
||||
jobId: giveawayEndJobId(giveawayId),
|
||||
removeOnComplete: true,
|
||||
removeOnFail: 50
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export async function cancelGiveawayJob(giveawayId: string): Promise<void> {
|
||||
const job = await giveawayQueue.getJob(`giveaway-end-${giveawayId}`);
|
||||
await job?.remove();
|
||||
}
|
||||
|
||||
function requirementLines(locale: 'de' | 'en', giveaway: Giveaway): string[] {
|
||||
const lines: string[] = [];
|
||||
if (giveaway.requiredRoleId) {
|
||||
@@ -316,6 +337,15 @@ async function dmWinners(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws winners and marks the giveaway ended. Does **not** cancel BullMQ jobs —
|
||||
* the slash-command / dashboard paths cancel the delayed job first; the
|
||||
* `giveawayEnd` worker must never remove its own locked job (that threw and
|
||||
* left giveaways stuck with the join button still visible).
|
||||
*
|
||||
* Uses a conditional update so concurrent ends (timer + slash + recover)
|
||||
* cannot each write different winners.
|
||||
*/
|
||||
export async function endGiveaway(context: BotContext, giveawayId: string): Promise<Giveaway> {
|
||||
const giveaway = await context.prisma.giveaway.findUnique({ where: { id: giveawayId } });
|
||||
if (!giveaway) {
|
||||
@@ -325,21 +355,31 @@ export async function endGiveaway(context: BotContext, giveawayId: string): Prom
|
||||
throw new GiveawayError('already_ended');
|
||||
}
|
||||
|
||||
await cancelGiveawayJob(giveawayId);
|
||||
const locale = await getGuildLocale(context.prisma, giveaway.guildId);
|
||||
const guild = await context.client.guilds.fetch(giveaway.guildId);
|
||||
const eligible = await filterEligibleEntrants(context, guild, giveaway);
|
||||
const winners = pickRandomWinners(eligible, giveaway.winnerCount);
|
||||
const endedAt = new Date();
|
||||
|
||||
const updated = await context.prisma.giveaway.update({
|
||||
where: { id: giveawayId },
|
||||
const claimed = await context.prisma.giveaway.updateMany({
|
||||
where: { id: giveawayId, endedAt: null },
|
||||
data: {
|
||||
endedAt: new Date(),
|
||||
endedAt,
|
||||
winners,
|
||||
paused: false
|
||||
}
|
||||
});
|
||||
|
||||
if (claimed.count === 0) {
|
||||
const current = await context.prisma.giveaway.findUnique({ where: { id: giveawayId } });
|
||||
if (!current) {
|
||||
throw new GiveawayError('not_found');
|
||||
}
|
||||
throw new GiveawayError('already_ended');
|
||||
}
|
||||
|
||||
const updated = await context.prisma.giveaway.findUniqueOrThrow({ where: { id: giveawayId } });
|
||||
|
||||
await updateGiveawayMessage(context, updated, locale);
|
||||
if (winners.length > 0) {
|
||||
await dmWinners(context, updated, winners, locale);
|
||||
@@ -347,6 +387,41 @@ export async function endGiveaway(context: BotContext, giveawayId: string): Prom
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ends giveaways whose `endsAt` has passed but `endedAt` is still null
|
||||
* (e.g. after a failed/locked BullMQ job). Skips paused giveaways.
|
||||
*/
|
||||
export async function recoverOverdueGiveaways(context: BotContext): Promise<number> {
|
||||
const overdue = await context.prisma.giveaway.findMany({
|
||||
where: {
|
||||
endedAt: null,
|
||||
paused: false,
|
||||
endsAt: { lte: new Date() }
|
||||
},
|
||||
take: 100,
|
||||
orderBy: { endsAt: 'asc' }
|
||||
});
|
||||
|
||||
let recovered = 0;
|
||||
for (const giveaway of overdue) {
|
||||
try {
|
||||
await cancelGiveawayJob(giveaway.id);
|
||||
await endGiveaway(context, giveaway.id);
|
||||
recovered += 1;
|
||||
} catch (error) {
|
||||
if (error instanceof GiveawayError && error.code === 'already_ended') {
|
||||
continue;
|
||||
}
|
||||
logger.error({ error, giveawayId: giveaway.id }, 'Failed to recover overdue giveaway');
|
||||
}
|
||||
}
|
||||
|
||||
if (recovered > 0) {
|
||||
logger.info({ recovered }, 'Recovered overdue giveaways');
|
||||
}
|
||||
return recovered;
|
||||
}
|
||||
|
||||
export async function rerollGiveaway(
|
||||
context: BotContext,
|
||||
giveawayId: string,
|
||||
@@ -378,22 +453,61 @@ export async function rerollGiveaway(
|
||||
export async function pauseGiveaway(
|
||||
context: BotContext,
|
||||
giveawayId: string,
|
||||
locale: 'de' | 'en'
|
||||
locale: 'de' | 'en',
|
||||
forcePaused?: boolean
|
||||
): Promise<Giveaway> {
|
||||
const giveaway = await context.prisma.giveaway.findUnique({ where: { id: giveawayId } });
|
||||
if (!giveaway || giveaway.endedAt) {
|
||||
throw new GiveawayError('not_found');
|
||||
}
|
||||
|
||||
const nextPaused = forcePaused ?? !giveaway.paused;
|
||||
if (nextPaused === giveaway.paused) {
|
||||
await updateGiveawayMessage(context, giveaway, locale);
|
||||
return giveaway;
|
||||
}
|
||||
|
||||
if (nextPaused) {
|
||||
// Stop the end timer while paused; remaining wall-clock is preserved via endsAt.
|
||||
await cancelGiveawayJob(giveawayId);
|
||||
}
|
||||
|
||||
const updated = await context.prisma.giveaway.update({
|
||||
where: { id: giveawayId },
|
||||
data: { paused: !giveaway.paused }
|
||||
data: { paused: nextPaused }
|
||||
});
|
||||
|
||||
if (!nextPaused) {
|
||||
// Resume: re-schedule with delay based on remaining time until endsAt
|
||||
// (delay 0 if endsAt already passed while paused → ends immediately).
|
||||
await scheduleGiveawayEnd(updated.id, updated.endsAt);
|
||||
}
|
||||
|
||||
await updateGiveawayMessage(context, updated, locale);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard entry points — same Discord side-effects as slash commands.
|
||||
*/
|
||||
export async function runGiveawayPauseJob(
|
||||
context: BotContext,
|
||||
giveawayId: string,
|
||||
paused: boolean
|
||||
): Promise<{ id: string; paused: boolean }> {
|
||||
const giveaway = await context.prisma.giveaway.findUnique({ where: { id: giveawayId } });
|
||||
if (!giveaway) {
|
||||
throw new GiveawayError('not_found');
|
||||
}
|
||||
const locale = await getGuildLocale(context.prisma, giveaway.guildId);
|
||||
const updated = await pauseGiveaway(context, giveawayId, locale, paused);
|
||||
return { id: updated.id, paused: updated.paused };
|
||||
}
|
||||
|
||||
export async function runGiveawayDeleteJob(context: BotContext, giveawayId: string): Promise<void> {
|
||||
await deleteGiveaway(context, giveawayId);
|
||||
}
|
||||
|
||||
export async function deleteGiveaway(context: BotContext, giveawayId: string): Promise<void> {
|
||||
const giveaway = await context.prisma.giveaway.findUnique({ where: { id: giveawayId } });
|
||||
if (!giveaway) {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { applyCommandDescription, applyOptionDescription } from '@nexumi/shared';
|
||||
import { SlashCommandBuilder } from 'discord.js';
|
||||
import { PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
|
||||
|
||||
export const backupCommandData = applyCommandDescription(
|
||||
new SlashCommandBuilder()
|
||||
.setName('backup')
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild)
|
||||
.addSubcommand((s) =>
|
||||
applyCommandDescription(s.setName('create'), 'guildbackup.create.description').addStringOption(
|
||||
(o) => applyOptionDescription(o.setName('name'), 'guildbackup.create.options.name')
|
||||
|
||||
@@ -55,17 +55,17 @@ const backupCommand: SlashCommand = {
|
||||
return;
|
||||
}
|
||||
|
||||
await interaction.deferReply({ ephemeral: true });
|
||||
|
||||
try {
|
||||
const backup = await createGuildBackup(context, guild, name, interaction.user.id);
|
||||
await interaction.reply({
|
||||
content: tf(locale, 'guildbackup.created', { id: backup.id, name: backup.name }),
|
||||
ephemeral: true
|
||||
await interaction.editReply({
|
||||
content: tf(locale, 'guildbackup.created', { id: backup.id, name: backup.name })
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof GuildBackupError) {
|
||||
await interaction.reply({
|
||||
content: t(locale, `guildbackup.error.${error.code}`),
|
||||
ephemeral: true
|
||||
await interaction.editReply({
|
||||
content: t(locale, `guildbackup.error.${error.code}`)
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -75,6 +75,9 @@ const backupCommand: SlashCommand = {
|
||||
}
|
||||
|
||||
if (sub === 'list') {
|
||||
if (!(await ensureManageGuild(interaction, locale))) {
|
||||
return;
|
||||
}
|
||||
const backups = await listGuildBackups(context, guild.id);
|
||||
if (backups.length === 0) {
|
||||
await interaction.reply({
|
||||
@@ -100,6 +103,9 @@ const backupCommand: SlashCommand = {
|
||||
}
|
||||
|
||||
if (sub === 'info') {
|
||||
if (!(await ensureManageGuild(interaction, locale))) {
|
||||
return;
|
||||
}
|
||||
const backupId = interaction.options.getString('id', true);
|
||||
const backup = await getGuildBackup(context, guild.id, backupId);
|
||||
if (!backup) {
|
||||
|
||||
@@ -10,6 +10,9 @@ import type { LogEventType } from '@nexumi/shared';
|
||||
import { ensureGuild } from '../../guild.js';
|
||||
import { logger } from '../../logger.js';
|
||||
|
||||
/** Audit-log entries can lag a moment behind the gateway event. */
|
||||
const AUDIT_LOG_MAX_AGE_MS = 15_000;
|
||||
|
||||
export async function getLoggingConfig(prisma: BotContext['prisma'], guildId: string) {
|
||||
await ensureGuild(prisma, guildId);
|
||||
let config = await prisma.loggingConfig.findUnique({ where: { guildId } });
|
||||
@@ -19,6 +22,36 @@ export async function getLoggingConfig(prisma: BotContext['prisma'], guildId: st
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discord does not send ban/kick reasons over the gateway.
|
||||
* Reasons must be read from the audit log (requires View Audit Log).
|
||||
*/
|
||||
async function fetchRecentAuditEntry(
|
||||
guild: Guild,
|
||||
type: AuditLogEvent,
|
||||
targetId: string
|
||||
): Promise<{ reason: string | null; executorId: string | null } | null> {
|
||||
try {
|
||||
const auditLogs = await guild.fetchAuditLogs({ type, limit: 5 });
|
||||
const entry = auditLogs.entries.find((item) => {
|
||||
if (item.targetId !== targetId) {
|
||||
return false;
|
||||
}
|
||||
return Date.now() - item.createdTimestamp <= AUDIT_LOG_MAX_AGE_MS;
|
||||
});
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
reason: entry.reason ?? null,
|
||||
executorId: entry.executorId ?? entry.executor?.id ?? null
|
||||
};
|
||||
} catch (error) {
|
||||
logger.warn({ error, guildId: guild.id, type }, 'Failed to fetch audit log for logging');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getLogChannelId(
|
||||
prisma: BotContext['prisma'],
|
||||
guildId: string,
|
||||
@@ -124,6 +157,45 @@ export async function logModAction(
|
||||
await sendLogEmbed(context, guild, 'MOD_ACTION', 'Moderation Action', description, 0xef4444);
|
||||
}
|
||||
|
||||
export async function logVerificationFail(
|
||||
context: BotContext,
|
||||
input: {
|
||||
guildId: string;
|
||||
userId: string;
|
||||
userTag?: string;
|
||||
reason: string;
|
||||
detail?: string;
|
||||
failAction?: string;
|
||||
matchedUserId?: string | null;
|
||||
}
|
||||
): Promise<void> {
|
||||
const guild = await context.client.guilds.fetch(input.guildId).catch(() => null);
|
||||
if (!guild) {
|
||||
return;
|
||||
}
|
||||
const lines = [
|
||||
`**User:** ${input.userTag ? `${input.userTag} ` : ''}(<@${input.userId}>) (\`${input.userId}\`)`,
|
||||
`**Reason:** ${input.reason}`
|
||||
];
|
||||
if (input.detail) {
|
||||
lines.push(`**Detail:** ${input.detail}`);
|
||||
}
|
||||
if (input.matchedUserId) {
|
||||
lines.push(`**Matched user:** <@${input.matchedUserId}> (\`${input.matchedUserId}\`)`);
|
||||
}
|
||||
if (input.failAction) {
|
||||
lines.push(`**Fail action:** ${input.failAction}`);
|
||||
}
|
||||
await sendLogEmbed(
|
||||
context,
|
||||
guild,
|
||||
'VERIFICATION_FAIL',
|
||||
'Verification Failed',
|
||||
lines.join('\n'),
|
||||
0xf97316
|
||||
);
|
||||
}
|
||||
|
||||
export async function handleChannelDeleteForAntiNuke(
|
||||
context: BotContext,
|
||||
guild: Guild,
|
||||
@@ -253,6 +325,29 @@ export async function registerLoggingEvents(context: BotContext): Promise<void>
|
||||
if (memberIgnored(config, member as GuildMember, member.user.bot)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const kickEntry = await fetchRecentAuditEntry(
|
||||
member.guild,
|
||||
AuditLogEvent.MemberKick,
|
||||
member.id
|
||||
);
|
||||
|
||||
if (kickEntry) {
|
||||
await sendLogEmbed(
|
||||
context,
|
||||
member.guild,
|
||||
'MEMBER_LEAVE',
|
||||
'Member Kicked',
|
||||
[
|
||||
`**User:** ${member.user.tag} (<@${member.id}>)`,
|
||||
`**Moderator:** ${kickEntry.executorId ? `<@${kickEntry.executorId}>` : '—'}`,
|
||||
`**Reason:** ${kickEntry.reason ?? '—'}`
|
||||
].join('\n'),
|
||||
0xf59e0b
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await sendLogEmbed(
|
||||
context,
|
||||
member.guild,
|
||||
@@ -265,12 +360,19 @@ export async function registerLoggingEvents(context: BotContext): Promise<void>
|
||||
client.on('guildBanAdd', async (ban) => {
|
||||
const guild = ban.guild;
|
||||
await handleBanForAntiNuke(context, guild);
|
||||
|
||||
const audit = await fetchRecentAuditEntry(guild, AuditLogEvent.MemberBanAdd, ban.user.id);
|
||||
const reason = audit?.reason ?? ban.reason ?? '—';
|
||||
const executorLine = audit?.executorId
|
||||
? `\n**Moderator:** <@${audit.executorId}>`
|
||||
: '';
|
||||
|
||||
await sendLogEmbed(
|
||||
context,
|
||||
guild,
|
||||
'MEMBER_BAN',
|
||||
'Member Banned',
|
||||
`**User:** ${ban.user.tag} (<@${ban.user.id}>)\n**Reason:** ${ban.reason ?? '—'}`
|
||||
`**User:** ${ban.user.tag} (<@${ban.user.id}>)${executorLine}\n**Reason:** ${reason}`
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { TextChannel, type ButtonInteraction } from 'discord.js';
|
||||
import { type Locale, t, tf } from '@nexumi/shared';
|
||||
import type { BotContext } from '../../types.js';
|
||||
import { assertBannableForConfirm } from '../../hierarchy.js';
|
||||
import { createCase, scheduleTempBanExpire } from './service.js';
|
||||
import { createCase, notifyModerationTargetDm, scheduleTempBanExpire } from './service.js';
|
||||
import { tryParseDuration } from './duration.js';
|
||||
|
||||
type BanPayload = {
|
||||
@@ -53,6 +53,20 @@ export async function executeBan(
|
||||
}
|
||||
}
|
||||
|
||||
await interaction.deferUpdate();
|
||||
|
||||
const targetUser = await context.client.users.fetch(payload.userId).catch(() => null);
|
||||
if (targetUser) {
|
||||
await notifyModerationTargetDm({
|
||||
user: targetUser,
|
||||
guildName: guild.name,
|
||||
reason: payload.reason,
|
||||
locale,
|
||||
action: 'ban',
|
||||
duration: payload.duration
|
||||
});
|
||||
}
|
||||
|
||||
await guild.members.ban(payload.userId, { reason: payload.reason });
|
||||
const modCase = await createCase(context, {
|
||||
guildId: guild.id,
|
||||
@@ -73,8 +87,11 @@ export async function executeBan(
|
||||
await scheduleTempBanExpire(guild.id, payload.userId, durationMs, payload.reason);
|
||||
}
|
||||
|
||||
await interaction.update({
|
||||
content: tf(locale, 'moderation.success.case', { caseNumber: modCase.caseNumber }),
|
||||
await interaction.editReply({
|
||||
content: tf(locale, 'moderation.success.caseWithReason', {
|
||||
caseNumber: modCase.caseNumber,
|
||||
reason: payload.reason
|
||||
}),
|
||||
components: []
|
||||
});
|
||||
}
|
||||
@@ -91,6 +108,8 @@ export async function executePurge(
|
||||
return;
|
||||
}
|
||||
|
||||
await interaction.deferUpdate();
|
||||
|
||||
const channel = await guild.channels.fetch(payload.channelId);
|
||||
let deletedCount = 0;
|
||||
const regex = payload.regex ? new RegExp(payload.regex, 'i') : null;
|
||||
@@ -134,7 +153,7 @@ export async function executePurge(
|
||||
}
|
||||
});
|
||||
|
||||
await interaction.update({
|
||||
await interaction.editReply({
|
||||
content: tf(locale, 'moderation.purge.done', {
|
||||
deletedCount,
|
||||
caseNumber: modCase.caseNumber
|
||||
|
||||
@@ -96,9 +96,12 @@ export const warnCommandData = applyCommandDescription(
|
||||
)
|
||||
)
|
||||
.addSubcommand((s) =>
|
||||
applyCommandDescription(s.setName('remove'), 'moderation.warn.remove.description').addStringOption(
|
||||
applyCommandDescription(s.setName('remove'), 'moderation.warn.remove.description').addIntegerOption(
|
||||
(o) =>
|
||||
applyOptionDescription(o.setName('warning_id').setRequired(true), 'moderation.warn.options.warning_id')
|
||||
applyOptionDescription(
|
||||
o.setName('id').setRequired(true).setMinValue(1),
|
||||
'moderation.warn.options.warning_id'
|
||||
)
|
||||
)
|
||||
)
|
||||
.addSubcommand((s) =>
|
||||
|
||||
@@ -8,7 +8,7 @@ import { t, tf } from '@nexumi/shared';
|
||||
import type { SlashCommand } from '../../types.js';
|
||||
import { requirePermission } from '../../permissions.js';
|
||||
import { assertModerableMember } from '../../hierarchy.js';
|
||||
import { applyWarnEscalation, createCase } from './service.js';
|
||||
import { applyWarnEscalation, createCase, createWarning, notifyModerationTargetDm } from './service.js';
|
||||
import { parseDuration, parseTimeoutDuration, tryParseDuration } from './duration.js';
|
||||
import { getGuildLocale } from '../../i18n.js';
|
||||
import { promptDestructiveConfirmation } from './confirmations.js';
|
||||
@@ -48,16 +48,24 @@ async function ensureMod(
|
||||
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
|
||||
return false;
|
||||
}
|
||||
return requirePermission(interaction, permission, locale);
|
||||
return requirePermission(interaction, permission, locale, { botPermissions: permission });
|
||||
}
|
||||
|
||||
async function replySuccessCase(
|
||||
interaction: ChatInputCommandInteraction,
|
||||
locale: 'de' | 'en',
|
||||
caseNumber: number
|
||||
caseNumber: number,
|
||||
reason?: string
|
||||
): Promise<void> {
|
||||
const content = reason
|
||||
? tf(locale, 'moderation.success.caseWithReason', { caseNumber, reason })
|
||||
: tf(locale, 'moderation.success.case', { caseNumber });
|
||||
if (interaction.deferred || interaction.replied) {
|
||||
await interaction.editReply({ content });
|
||||
return;
|
||||
}
|
||||
await interaction.reply({
|
||||
content: tf(locale, 'moderation.success.case', { caseNumber }),
|
||||
content,
|
||||
ephemeral: true
|
||||
});
|
||||
}
|
||||
@@ -112,7 +120,7 @@ const unbanCommand: SlashCommand = {
|
||||
action: 'UNBAN',
|
||||
reason
|
||||
});
|
||||
await replySuccessCase(interaction, locale, modCase.caseNumber);
|
||||
await replySuccessCase(interaction, locale, modCase.caseNumber, reason);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -125,6 +133,13 @@ const kickCommand: SlashCommand = {
|
||||
const reason = reasonFrom(interaction, locale);
|
||||
const hierarchy = await assertModerableMember(interaction, user.id, locale, 'kick');
|
||||
if (!hierarchy.ok || !hierarchy.member) return;
|
||||
await notifyModerationTargetDm({
|
||||
user,
|
||||
guildName: interaction.guild!.name,
|
||||
reason,
|
||||
locale,
|
||||
action: 'kick'
|
||||
});
|
||||
await hierarchy.member.kick(reason);
|
||||
const modCase = await createCase(context, {
|
||||
guildId: interaction.guildId!,
|
||||
@@ -134,7 +149,7 @@ const kickCommand: SlashCommand = {
|
||||
action: 'KICK',
|
||||
reason
|
||||
});
|
||||
await replySuccessCase(interaction, locale, modCase.caseNumber);
|
||||
await replySuccessCase(interaction, locale, modCase.caseNumber, reason);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -167,7 +182,7 @@ const timeoutCommand: SlashCommand = {
|
||||
reason,
|
||||
metadata: { durationMs: duration }
|
||||
});
|
||||
await replySuccessCase(interaction, locale, modCase.caseNumber);
|
||||
await replySuccessCase(interaction, locale, modCase.caseNumber, reason);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -189,7 +204,7 @@ const untimeoutCommand: SlashCommand = {
|
||||
action: 'UN_TIMEOUT',
|
||||
reason
|
||||
});
|
||||
await replySuccessCase(interaction, locale, modCase.caseNumber);
|
||||
await replySuccessCase(interaction, locale, modCase.caseNumber, reason);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -299,19 +314,15 @@ const warnCommand: SlashCommand = {
|
||||
action: 'WARN_ADD',
|
||||
reason
|
||||
});
|
||||
await context.prisma.user.upsert({
|
||||
where: { id: user.id },
|
||||
update: {},
|
||||
create: { id: user.id }
|
||||
});
|
||||
const warning = await context.prisma.warning.create({
|
||||
data: {
|
||||
const { warning } = await createWarning(context, {
|
||||
guildId: interaction.guildId!,
|
||||
userId: user.id,
|
||||
moderatorId: interaction.user.id,
|
||||
reason,
|
||||
caseId: modCase.id
|
||||
}
|
||||
caseId: modCase.id,
|
||||
guildName: interaction.guild!.name,
|
||||
locale,
|
||||
notifyUser: user
|
||||
});
|
||||
const escalationResult = await applyWarnEscalation(
|
||||
interaction,
|
||||
@@ -322,8 +333,8 @@ const warnCommand: SlashCommand = {
|
||||
);
|
||||
await interaction.reply({
|
||||
content: escalationResult
|
||||
? `${tf(locale, 'moderation.warning.created', { warningId: warning.id })}\n${escalationResult}`
|
||||
: tf(locale, 'moderation.warning.created', { warningId: warning.id }),
|
||||
? `${tf(locale, 'moderation.warning.created', { warningNumber: warning.warningNumber })}\n${escalationResult}`
|
||||
: tf(locale, 'moderation.warning.created', { warningNumber: warning.warningNumber }),
|
||||
ephemeral: true
|
||||
});
|
||||
return;
|
||||
@@ -341,8 +352,8 @@ const warnCommand: SlashCommand = {
|
||||
? t(locale, 'moderation.warning.none')
|
||||
: warnings
|
||||
.map(
|
||||
(w: { id: string; reason: string | null }) =>
|
||||
`- ${w.id}: ${w.reason ?? t(locale, 'moderation.warning.defaultReason')}`
|
||||
(w: { warningNumber: number; reason: string | null }) =>
|
||||
`- #${w.warningNumber}: ${w.reason ?? t(locale, 'moderation.warning.defaultReason')}`
|
||||
)
|
||||
.join('\n');
|
||||
await interaction.reply({ content, ephemeral: true });
|
||||
@@ -350,9 +361,9 @@ const warnCommand: SlashCommand = {
|
||||
}
|
||||
|
||||
if (sub === 'remove') {
|
||||
const warningId = interaction.options.getString('warning_id', true);
|
||||
const warningNumber = interaction.options.getInteger('id', true);
|
||||
const warning = await context.prisma.warning.findFirst({
|
||||
where: { id: warningId, guildId: interaction.guildId! }
|
||||
where: { warningNumber, guildId: interaction.guildId! }
|
||||
});
|
||||
if (!warning) {
|
||||
await interaction.reply({
|
||||
@@ -368,7 +379,7 @@ const warnCommand: SlashCommand = {
|
||||
moderatorId: interaction.user.id,
|
||||
...auditFrom(interaction),
|
||||
action: 'WARN_REMOVE',
|
||||
reason: `Warning ${warningId} removed`
|
||||
reason: `Warning #${warningNumber} removed`
|
||||
});
|
||||
await replySuccessCase(interaction, locale, modCase.caseNumber);
|
||||
return;
|
||||
@@ -460,6 +471,7 @@ const lockCommand: SlashCommand = {
|
||||
const guild = interaction.guild!;
|
||||
|
||||
if (serverWide) {
|
||||
await interaction.deferReply({ ephemeral: true });
|
||||
for (const channel of guild.channels.cache.values()) {
|
||||
if (channel.type !== ChannelType.GuildText && channel.type !== ChannelType.GuildAnnouncement) {
|
||||
continue;
|
||||
@@ -495,6 +507,7 @@ const unlockCommand: SlashCommand = {
|
||||
const guild = interaction.guild!;
|
||||
|
||||
if (serverWide) {
|
||||
await interaction.deferReply({ ephemeral: true });
|
||||
for (const channel of guild.channels.cache.values()) {
|
||||
if (channel.type !== ChannelType.GuildText && channel.type !== ChannelType.GuildAnnouncement) {
|
||||
continue;
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import {
|
||||
PermissionFlagsBits,
|
||||
type ChatInputCommandInteraction,
|
||||
type User
|
||||
} from 'discord.js';
|
||||
import type { Prisma, Warning } from '@prisma/client';
|
||||
import { buildCaseMetadata, type CaseAuditSource, t, tf, type Locale } from '@nexumi/shared';
|
||||
import { logger } from '../../logger.js';
|
||||
import { safeRemoveBullJob } from '../../lib/safe-remove-job.js';
|
||||
import { moderationQueue } from '../../queues.js';
|
||||
import type { BotContext } from '../../types.js';
|
||||
import { PermissionFlagsBits, type ChatInputCommandInteraction } from 'discord.js';
|
||||
import type { Prisma } from '@prisma/client';
|
||||
import { buildCaseMetadata, type CaseAuditSource, t, type Locale } from '@nexumi/shared';
|
||||
import { MAX_TIMEOUT_MS } from './duration.js';
|
||||
|
||||
type CreateCaseInput = {
|
||||
@@ -16,6 +22,152 @@ type CreateCaseInput = {
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type CreateWarningInput = {
|
||||
guildId: string;
|
||||
userId: string;
|
||||
moderatorId: string;
|
||||
reason?: string;
|
||||
caseId?: string;
|
||||
guildName: string;
|
||||
locale: Locale;
|
||||
/** Discord user to DM; when omitted the user is fetched by id. */
|
||||
notifyUser?: User;
|
||||
};
|
||||
|
||||
export type CreatedWarning = {
|
||||
warning: Warning;
|
||||
warningCount: number;
|
||||
};
|
||||
|
||||
async function notifyWarnedUser(params: {
|
||||
user: User;
|
||||
guildName: string;
|
||||
reason: string;
|
||||
warningCount: number;
|
||||
warningNumber: number;
|
||||
locale: Locale;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
await params.user.send(
|
||||
tf(params.locale, 'moderation.warning.dm', {
|
||||
guild: params.guildName,
|
||||
reason: params.reason,
|
||||
count: params.warningCount,
|
||||
warningNumber: params.warningNumber
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
{ error, userId: params.user.id, warningNumber: params.warningNumber },
|
||||
'Failed to DM warned user'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function notifyModerationTargetDm(params: {
|
||||
user: User;
|
||||
guildName: string;
|
||||
reason: string;
|
||||
locale: Locale;
|
||||
action: 'ban' | 'kick';
|
||||
duration?: string | null;
|
||||
}): Promise<void> {
|
||||
const reason = params.reason || t(params.locale, 'moderation.reason.none');
|
||||
const key =
|
||||
params.action === 'kick'
|
||||
? 'moderation.kick.dm'
|
||||
: params.duration
|
||||
? 'moderation.ban.dm.temporary'
|
||||
: 'moderation.ban.dm';
|
||||
try {
|
||||
await params.user.send(
|
||||
tf(params.locale, key, {
|
||||
guild: params.guildName,
|
||||
reason,
|
||||
...(params.duration ? { duration: params.duration } : {})
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
{ error, userId: params.user.id, action: params.action },
|
||||
'Failed to DM moderation target'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createWarning(
|
||||
context: BotContext,
|
||||
input: CreateWarningInput
|
||||
): Promise<CreatedWarning> {
|
||||
await context.prisma.user.upsert({
|
||||
where: { id: input.userId },
|
||||
update: {},
|
||||
create: { id: input.userId }
|
||||
});
|
||||
|
||||
let warning: Warning | null = null;
|
||||
for (let attempt = 0; attempt < 5; attempt++) {
|
||||
try {
|
||||
warning = await context.prisma.$transaction(
|
||||
async (tx) => {
|
||||
const last = await tx.warning.findFirst({
|
||||
where: { guildId: input.guildId },
|
||||
orderBy: { warningNumber: 'desc' },
|
||||
select: { warningNumber: true }
|
||||
});
|
||||
return tx.warning.create({
|
||||
data: {
|
||||
guildId: input.guildId,
|
||||
warningNumber: (last?.warningNumber ?? 0) + 1,
|
||||
userId: input.userId,
|
||||
moderatorId: input.moderatorId,
|
||||
reason: input.reason,
|
||||
caseId: input.caseId
|
||||
}
|
||||
});
|
||||
},
|
||||
{ isolationLevel: 'Serializable' }
|
||||
);
|
||||
break;
|
||||
} catch (error) {
|
||||
const code =
|
||||
typeof error === 'object' && error && 'code' in error
|
||||
? String((error as { code: unknown }).code)
|
||||
: '';
|
||||
if (code !== 'P2002' && attempt === 4) {
|
||||
throw error;
|
||||
}
|
||||
if (attempt === 4) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!warning) {
|
||||
throw new Error('Failed to create warning');
|
||||
}
|
||||
|
||||
const warningCount = await context.prisma.warning.count({
|
||||
where: { guildId: input.guildId, userId: input.userId }
|
||||
});
|
||||
|
||||
const reason = input.reason ?? t(input.locale, 'moderation.warning.defaultReason');
|
||||
const notifyUser =
|
||||
input.notifyUser ?? (await context.client.users.fetch(input.userId).catch(() => null));
|
||||
if (notifyUser) {
|
||||
await notifyWarnedUser({
|
||||
user: notifyUser,
|
||||
guildName: input.guildName,
|
||||
reason,
|
||||
warningCount,
|
||||
warningNumber: warning.warningNumber,
|
||||
locale: input.locale
|
||||
});
|
||||
}
|
||||
|
||||
return { warning, warningCount };
|
||||
}
|
||||
|
||||
export async function createCase(context: BotContext, input: CreateCaseInput) {
|
||||
await context.prisma.guild.upsert({
|
||||
where: { id: input.guildId },
|
||||
@@ -96,9 +248,7 @@ export async function scheduleTempBanExpire(
|
||||
) {
|
||||
const jobId = tempBanJobId(guildId, userId);
|
||||
const existing = await moderationQueue.getJob(jobId);
|
||||
if (existing) {
|
||||
await existing.remove();
|
||||
}
|
||||
await safeRemoveBullJob(existing, { label: 'tempBanExpire', jobId });
|
||||
|
||||
await moderationQueue.add(
|
||||
'tempBanExpire',
|
||||
@@ -179,6 +329,13 @@ export async function applyWarnEscalation(
|
||||
String(warningCount)
|
||||
);
|
||||
}
|
||||
await notifyModerationTargetDm({
|
||||
user: member.user,
|
||||
guildName: interaction.guild.name,
|
||||
reason,
|
||||
locale,
|
||||
action: 'kick'
|
||||
});
|
||||
await member.kick(reason);
|
||||
await createCase(context, {
|
||||
guildId,
|
||||
@@ -199,6 +356,13 @@ export async function applyWarnEscalation(
|
||||
String(warningCount)
|
||||
);
|
||||
}
|
||||
await notifyModerationTargetDm({
|
||||
user: member.user,
|
||||
guildName: interaction.guild.name,
|
||||
reason,
|
||||
locale,
|
||||
action: 'ban'
|
||||
});
|
||||
await interaction.guild.members.ban(userId, { reason });
|
||||
await createCase(context, {
|
||||
guildId,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { applyCommandDescription, applyOptionDescription } from '@nexumi/shared';
|
||||
import { SlashCommandBuilder } from 'discord.js';
|
||||
import { PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
|
||||
|
||||
export const scheduleCommandData = applyCommandDescription(
|
||||
new SlashCommandBuilder()
|
||||
.setName('schedule')
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild)
|
||||
.addSubcommand((sub) =>
|
||||
applyCommandDescription(sub.setName('create'), 'schedule.create.description')
|
||||
.addChannelOption((o) =>
|
||||
@@ -51,6 +52,7 @@ export const scheduleCommandData = applyCommandDescription(
|
||||
export const announceCommandData = applyCommandDescription(
|
||||
new SlashCommandBuilder()
|
||||
.setName('announce')
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild)
|
||||
.addChannelOption((o) =>
|
||||
applyOptionDescription(o.setName('channel').setRequired(true), 'schedule.common.options.channel')
|
||||
)
|
||||
|
||||
@@ -4,11 +4,20 @@ import {
|
||||
type MessageCreateOptions,
|
||||
type TextChannel
|
||||
} from 'discord.js';
|
||||
import { WelcomeEmbedSchema, expandBracketChannelMentions, parseMessageComponentsV2, t, tf } from '@nexumi/shared';
|
||||
import {
|
||||
DEFAULT_GUILD_TIMEZONE,
|
||||
WelcomeEmbedSchema,
|
||||
expandBracketChannelMentions,
|
||||
parseMessageComponentsV2,
|
||||
resolveScheduleRunAt,
|
||||
t,
|
||||
tf
|
||||
} from '@nexumi/shared';
|
||||
import type { ScheduledMessage } from '@prisma/client';
|
||||
import { ensureGuild } from '../../guild.js';
|
||||
import { buildEmbedFromPayload } from '../../lib/embed-payload.js';
|
||||
import { buildComponentsV2Payload } from '../../lib/components-v2-payload.js';
|
||||
import { safeRemoveBullJob } from '../../lib/safe-remove-job.js';
|
||||
import { logger } from '../../logger.js';
|
||||
import { scheduleQueue } from '../../queues.js';
|
||||
import type { BotContext } from '../../types.js';
|
||||
@@ -36,7 +45,7 @@ export type ScheduleCreateInput = AnnouncementInput & {
|
||||
cron?: string | null;
|
||||
};
|
||||
|
||||
function parseScheduleWhen(input: string): Date {
|
||||
function parseScheduleWhen(input: string, timeZone: string): Date {
|
||||
const trimmed = input.trim();
|
||||
if (/^\d+[smhd]$/i.test(trimmed)) {
|
||||
const ms = parseDuration(trimmed);
|
||||
@@ -46,11 +55,79 @@ function parseScheduleWhen(input: string): Date {
|
||||
return new Date(Date.now() + ms);
|
||||
}
|
||||
|
||||
const parsed = Date.parse(trimmed);
|
||||
if (Number.isNaN(parsed) || parsed <= Date.now()) {
|
||||
try {
|
||||
const parsed = resolveScheduleRunAt(trimmed, timeZone);
|
||||
if (parsed.getTime() <= Date.now()) {
|
||||
throw new SchedulerError('invalid_when');
|
||||
}
|
||||
return new Date(parsed);
|
||||
return parsed;
|
||||
} catch {
|
||||
throw new SchedulerError('invalid_when');
|
||||
}
|
||||
}
|
||||
|
||||
async function getGuildTimezone(prisma: BotContext['prisma'], guildId: string): Promise<string> {
|
||||
const settings = await prisma.guildSettings.findUnique({
|
||||
where: { guildId },
|
||||
select: { timezone: true }
|
||||
});
|
||||
return settings?.timezone || DEFAULT_GUILD_TIMEZONE;
|
||||
}
|
||||
|
||||
export function scheduleJobId(scheduleId: string): string {
|
||||
return `schedule-${scheduleId}`;
|
||||
}
|
||||
|
||||
/** BullMQ delayed instance ids look like `repeat:{schedulerId}:{millis}`. */
|
||||
const REPEAT_INSTANCE_ID_RE = /^repeat:(.+):(\d+)$/;
|
||||
|
||||
function collectSchedulerIds(scheduleId: string, jobId: string | null | undefined): string[] {
|
||||
const ids = new Set<string>();
|
||||
ids.add(scheduleJobId(scheduleId));
|
||||
if (!jobId) {
|
||||
return [...ids];
|
||||
}
|
||||
|
||||
const repeatMatch = jobId.match(REPEAT_INSTANCE_ID_RE);
|
||||
if (repeatMatch?.[1]) {
|
||||
ids.add(repeatMatch[1]);
|
||||
} else if (!jobId.startsWith('repeat:')) {
|
||||
ids.add(jobId);
|
||||
}
|
||||
return [...ids];
|
||||
}
|
||||
|
||||
async function removeScheduleJobSchedulers(
|
||||
queue: typeof scheduleQueue,
|
||||
scheduleId: string,
|
||||
jobId: string | null | undefined
|
||||
): Promise<void> {
|
||||
for (const schedulerId of collectSchedulerIds(scheduleId, jobId)) {
|
||||
try {
|
||||
await queue.removeJobScheduler(schedulerId);
|
||||
} catch (error) {
|
||||
logger.warn({ schedulerId, error }, 'Failed to remove schedule job scheduler');
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy / hash-keyed schedulers: match by payload scheduleId.
|
||||
try {
|
||||
const schedulers = await queue.getJobSchedulers();
|
||||
for (const scheduler of schedulers) {
|
||||
const data = scheduler.template?.data as { scheduleId?: string } | undefined;
|
||||
if (data?.scheduleId !== scheduleId) {
|
||||
continue;
|
||||
}
|
||||
const id = scheduler.id ?? scheduler.key;
|
||||
try {
|
||||
await queue.removeJobScheduler(id);
|
||||
} catch (error) {
|
||||
logger.warn({ schedulerId: id, error }, 'Failed to remove matched schedule job scheduler');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn({ scheduleId, error }, 'Failed to scan job schedulers for schedule');
|
||||
}
|
||||
}
|
||||
|
||||
function isValidCron(pattern: string): boolean {
|
||||
@@ -157,38 +234,85 @@ async function assertSendableChannel(
|
||||
async function enqueueScheduleJob(
|
||||
schedule: ScheduledMessage,
|
||||
cron: string | null,
|
||||
runAt: Date | null
|
||||
runAt: Date | null,
|
||||
timeZone: string
|
||||
): Promise<string> {
|
||||
const jobOptions: {
|
||||
jobId: string;
|
||||
removeOnComplete: boolean;
|
||||
removeOnFail: number;
|
||||
delay?: number;
|
||||
repeat?: { pattern: string };
|
||||
} = {
|
||||
jobId: `schedule-${schedule.id}`,
|
||||
removeOnComplete: true,
|
||||
removeOnFail: 50
|
||||
};
|
||||
const jobId = scheduleJobId(schedule.id);
|
||||
|
||||
if (cron) {
|
||||
jobOptions.repeat = { pattern: cron };
|
||||
} else if (runAt) {
|
||||
jobOptions.delay = Math.max(0, runAt.getTime() - Date.now());
|
||||
// BullMQ 5 job schedulers honor guild timezone for cron patterns.
|
||||
await scheduleQueue.upsertJobScheduler(
|
||||
jobId,
|
||||
{ pattern: cron, tz: timeZone },
|
||||
{
|
||||
name: 'scheduleSend',
|
||||
data: { scheduleId: schedule.id },
|
||||
opts: {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: 50
|
||||
}
|
||||
}
|
||||
);
|
||||
return jobId;
|
||||
}
|
||||
|
||||
const job = await scheduleQueue.add('scheduleSend', { scheduleId: schedule.id }, jobOptions);
|
||||
if (!runAt) {
|
||||
throw new SchedulerError('missing_schedule_time');
|
||||
}
|
||||
|
||||
const job = await scheduleQueue.add(
|
||||
'scheduleSend',
|
||||
{ scheduleId: schedule.id },
|
||||
{
|
||||
jobId,
|
||||
removeOnComplete: true,
|
||||
removeOnFail: 50,
|
||||
delay: Math.max(0, runAt.getTime() - Date.now())
|
||||
}
|
||||
);
|
||||
return String(job.id);
|
||||
}
|
||||
|
||||
export async function removeScheduleJob(jobId: string | null): Promise<void> {
|
||||
if (!jobId) {
|
||||
return;
|
||||
/**
|
||||
* Removes a one-shot delayed job and/or cron job scheduler. Locked (active)
|
||||
* jobs cannot be removed — do not call this from inside the scheduleSend
|
||||
* worker for the job that is currently running.
|
||||
*/
|
||||
export async function removeScheduleJob(
|
||||
jobId: string | null,
|
||||
options?: { scheduleId?: string; cron?: string | null }
|
||||
): Promise<void> {
|
||||
if (options?.scheduleId) {
|
||||
await removeScheduleJobSchedulers(scheduleQueue, options.scheduleId, jobId);
|
||||
} else if (options?.cron && jobId) {
|
||||
// Best-effort for callers without scheduleId: extract scheduler from repeat instance id.
|
||||
for (const schedulerId of collectSchedulerIds('unknown', jobId)) {
|
||||
if (schedulerId === 'schedule-unknown') {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await scheduleQueue.removeJobScheduler(schedulerId);
|
||||
} catch (error) {
|
||||
logger.warn({ schedulerId, error }, 'Failed to remove schedule job scheduler');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const job = await scheduleQueue.getJob(jobId);
|
||||
if (job) {
|
||||
await job.remove();
|
||||
// One-shot delayed jobs only — never job.remove() on repeat:* scheduler children.
|
||||
const oneShotIds = new Set<string>();
|
||||
if (jobId && !jobId.startsWith('repeat:')) {
|
||||
oneShotIds.add(jobId);
|
||||
}
|
||||
if (options?.scheduleId) {
|
||||
oneShotIds.add(scheduleJobId(options.scheduleId));
|
||||
}
|
||||
|
||||
for (const id of oneShotIds) {
|
||||
const job = await scheduleQueue.getJob(id);
|
||||
if (job?.id && String(job.id).startsWith('repeat:')) {
|
||||
continue;
|
||||
}
|
||||
await safeRemoveBullJob(job, { label: 'schedule', jobId: id });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,6 +344,7 @@ export async function createScheduledMessage(
|
||||
|
||||
let runAt: Date | null = null;
|
||||
let cronPattern: string | null = null;
|
||||
const timeZone = await getGuildTimezone(context.prisma, input.guildId);
|
||||
|
||||
if (cron) {
|
||||
if (!isValidCron(cron)) {
|
||||
@@ -227,7 +352,7 @@ export async function createScheduledMessage(
|
||||
}
|
||||
cronPattern = cron;
|
||||
} else if (whenInput) {
|
||||
runAt = parseScheduleWhen(whenInput);
|
||||
runAt = parseScheduleWhen(whenInput, timeZone);
|
||||
}
|
||||
|
||||
await assertSendableChannel(context, input.channelId);
|
||||
@@ -247,7 +372,7 @@ export async function createScheduledMessage(
|
||||
}
|
||||
});
|
||||
|
||||
const jobId = await enqueueScheduleJob(schedule, cronPattern, runAt);
|
||||
const jobId = await enqueueScheduleJob(schedule, cronPattern, runAt, timeZone);
|
||||
return context.prisma.scheduledMessage.update({
|
||||
where: { id: schedule.id },
|
||||
data: { jobId }
|
||||
@@ -307,7 +432,7 @@ export async function deleteScheduledMessage(
|
||||
return false;
|
||||
}
|
||||
|
||||
await removeScheduleJob(match.jobId);
|
||||
await removeScheduleJob(match.jobId, { scheduleId: match.id, cron: match.cron });
|
||||
await context.prisma.scheduledMessage.delete({ where: { id: match.id } });
|
||||
return true;
|
||||
}
|
||||
@@ -350,8 +475,10 @@ export async function sendScheduledMessage(context: BotContext, scheduleId: stri
|
||||
throw error;
|
||||
}
|
||||
|
||||
// One-shot: delete the DB row only. Do not remove the active BullMQ job
|
||||
// from inside its own worker (locked remove throws → retry → duplicate send).
|
||||
// removeOnComplete on enqueue cleans the job up after the processor returns.
|
||||
if (!schedule.cron) {
|
||||
await removeScheduleJob(schedule.jobId);
|
||||
await context.prisma.scheduledMessage.delete({ where: { id: scheduleId } });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,12 @@ import {
|
||||
applyOptionDescription,
|
||||
localizedChoice
|
||||
} from '@nexumi/shared';
|
||||
import { ChannelType, SlashCommandBuilder } from 'discord.js';
|
||||
import { ChannelType, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
|
||||
|
||||
export const selfrolesCommandData = applyCommandDescription(
|
||||
new SlashCommandBuilder()
|
||||
.setName('selfroles')
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild)
|
||||
.addSubcommandGroup((group) =>
|
||||
applyCommandDescription(group.setName('panel'), 'selfroles.panel.description')
|
||||
.addSubcommand((sub) =>
|
||||
|
||||
@@ -4,5 +4,9 @@ export { registerSelfRoleEvents } from './reactions.js';
|
||||
export {
|
||||
expireSelfRole,
|
||||
scheduleSelfRoleExpiry,
|
||||
cancelSelfRoleExpiry
|
||||
cancelSelfRoleExpiry,
|
||||
runSelfRolePanelCreateJob,
|
||||
runSelfRolePanelUpdateJob,
|
||||
runSelfRolePanelDeleteJob,
|
||||
SelfRoleError
|
||||
} from './service.js';
|
||||
|
||||
@@ -21,7 +21,9 @@ import {
|
||||
} from '@nexumi/shared';
|
||||
import type { SelfRolePanel } from '@prisma/client';
|
||||
import { ensureGuild } from '../../guild.js';
|
||||
import { getGuildLocale } from '../../i18n.js';
|
||||
import { logger } from '../../logger.js';
|
||||
import { safeRemoveBullJob } from '../../lib/safe-remove-job.js';
|
||||
import { ticketQueue } from '../../queues.js';
|
||||
import type { BotContext } from '../../types.js';
|
||||
|
||||
@@ -266,13 +268,17 @@ export async function scheduleSelfRoleExpiry(
|
||||
roleId: string,
|
||||
expiresAt: Date
|
||||
): Promise<void> {
|
||||
const jobId = `selfrole-expire-${guildId}-${userId}-${roleId}`;
|
||||
const existing = await ticketQueue.getJob(jobId);
|
||||
await safeRemoveBullJob(existing, { label: 'selfRoleExpire', jobId });
|
||||
|
||||
const delay = Math.max(0, expiresAt.getTime() - Date.now());
|
||||
await ticketQueue.add(
|
||||
'selfRoleExpire',
|
||||
{ guildId, userId, roleId },
|
||||
{
|
||||
delay,
|
||||
jobId: `selfrole-expire-${guildId}-${userId}-${roleId}`,
|
||||
jobId,
|
||||
removeOnComplete: true,
|
||||
removeOnFail: 50
|
||||
}
|
||||
@@ -284,8 +290,9 @@ export async function cancelSelfRoleExpiry(
|
||||
userId: string,
|
||||
roleId: string
|
||||
): Promise<void> {
|
||||
const job = await ticketQueue.getJob(`selfrole-expire-${guildId}-${userId}-${roleId}`);
|
||||
await job?.remove();
|
||||
const jobId = `selfrole-expire-${guildId}-${userId}-${roleId}`;
|
||||
const job = await ticketQueue.getJob(jobId);
|
||||
await safeRemoveBullJob(job, { label: 'selfRoleExpire', jobId });
|
||||
}
|
||||
|
||||
export async function expireSelfRole(
|
||||
@@ -416,6 +423,69 @@ export async function findPanelByMessageId(
|
||||
});
|
||||
}
|
||||
|
||||
async function postOrEditPanelMessage(
|
||||
context: BotContext,
|
||||
panel: SelfRolePanel,
|
||||
locale: 'de' | 'en',
|
||||
previous?: { channelId: string; messageId: string | null }
|
||||
): Promise<SelfRolePanel> {
|
||||
const mode = SelfRoleModeSchema.parse(panel.mode);
|
||||
const { entries } = parseRolesJson(panel.roles);
|
||||
const channelChanged =
|
||||
previous !== undefined && previous.channelId !== panel.channelId;
|
||||
|
||||
if (panel.messageId && !channelChanged) {
|
||||
try {
|
||||
const channel = (await context.client.channels.fetch(panel.channelId)) as TextChannel;
|
||||
const message = await channel.messages.fetch(panel.messageId);
|
||||
await message.edit({
|
||||
embeds: [buildPanelEmbed(panel, locale)],
|
||||
components: buildPanelComponents(panel)
|
||||
});
|
||||
|
||||
if (mode === 'REACTIONS') {
|
||||
for (const reaction of message.reactions.cache.values()) {
|
||||
await reaction.remove().catch(() => undefined);
|
||||
}
|
||||
await applyReactionEmojis(message, entries);
|
||||
}
|
||||
|
||||
return panel;
|
||||
} catch (error) {
|
||||
logger.warn({ error, panelId: panel.id }, 'Failed to edit self-role panel message; recreating');
|
||||
}
|
||||
}
|
||||
|
||||
if (previous?.messageId && channelChanged) {
|
||||
try {
|
||||
const oldChannel = (await context.client.channels.fetch(previous.channelId)) as TextChannel;
|
||||
const oldMessage = await oldChannel.messages.fetch(previous.messageId);
|
||||
await oldMessage.delete();
|
||||
} catch {
|
||||
// Previous message may already be gone
|
||||
}
|
||||
}
|
||||
|
||||
const channel = await context.client.channels.fetch(panel.channelId);
|
||||
if (!channel || !channel.isTextBased() || channel.isDMBased()) {
|
||||
throw new SelfRoleError('invalid_channel');
|
||||
}
|
||||
|
||||
const message = await channel.send({
|
||||
embeds: [buildPanelEmbed(panel, locale)],
|
||||
components: buildPanelComponents(panel)
|
||||
});
|
||||
|
||||
if (mode === 'REACTIONS') {
|
||||
await applyReactionEmojis(message, entries);
|
||||
}
|
||||
|
||||
return context.prisma.selfRolePanel.update({
|
||||
where: { id: panel.id },
|
||||
data: { messageId: message.id }
|
||||
});
|
||||
}
|
||||
|
||||
export async function createSelfRolePanel(
|
||||
context: BotContext,
|
||||
params: {
|
||||
@@ -427,6 +497,7 @@ export async function createSelfRolePanel(
|
||||
behavior: SelfRoleBehavior;
|
||||
roles: SelfRoleEntry[];
|
||||
temporaryDurationMs?: number;
|
||||
expiresAt?: Date | null;
|
||||
},
|
||||
locale: 'de' | 'en'
|
||||
): Promise<SelfRolePanel> {
|
||||
@@ -451,24 +522,12 @@ export async function createSelfRolePanel(
|
||||
description: params.description ?? null,
|
||||
mode: params.mode,
|
||||
behavior: params.behavior,
|
||||
roles: serializeRolesJson(params.roles, params.temporaryDurationMs)
|
||||
roles: serializeRolesJson(params.roles, params.temporaryDurationMs),
|
||||
expiresAt: params.expiresAt ?? null
|
||||
}
|
||||
});
|
||||
|
||||
const channel = (await context.client.channels.fetch(params.channelId)) as TextChannel;
|
||||
const message = await channel.send({
|
||||
embeds: [buildPanelEmbed(panel, locale)],
|
||||
components: buildPanelComponents(panel)
|
||||
});
|
||||
|
||||
if (params.mode === 'REACTIONS') {
|
||||
await applyReactionEmojis(message, params.roles);
|
||||
}
|
||||
|
||||
return context.prisma.selfRolePanel.update({
|
||||
where: { id: panel.id },
|
||||
data: { messageId: message.id }
|
||||
});
|
||||
return postOrEditPanelMessage(context, panel, locale);
|
||||
}
|
||||
|
||||
export async function updateSelfRolePanel(
|
||||
@@ -476,12 +535,14 @@ export async function updateSelfRolePanel(
|
||||
panelId: string,
|
||||
guildId: string,
|
||||
updates: {
|
||||
channelId?: string;
|
||||
title?: string;
|
||||
description?: string | null;
|
||||
mode?: SelfRoleMode;
|
||||
behavior?: SelfRoleBehavior;
|
||||
roles?: SelfRoleEntry[];
|
||||
temporaryDurationMs?: number;
|
||||
expiresAt?: Date | null;
|
||||
},
|
||||
locale: 'de' | 'en'
|
||||
): Promise<SelfRolePanel> {
|
||||
@@ -507,39 +568,100 @@ export async function updateSelfRolePanel(
|
||||
throw new SelfRoleError('reaction_emoji_required');
|
||||
}
|
||||
|
||||
const previous = { channelId: existing.channelId, messageId: existing.messageId };
|
||||
const panel = await context.prisma.selfRolePanel.update({
|
||||
where: { id: panelId },
|
||||
data: {
|
||||
channelId: updates.channelId ?? existing.channelId,
|
||||
title: updates.title ?? existing.title,
|
||||
description: updates.description !== undefined ? updates.description : existing.description,
|
||||
mode,
|
||||
behavior,
|
||||
roles: serializeRolesJson(roles, temporaryDurationMs)
|
||||
roles: serializeRolesJson(roles, temporaryDurationMs),
|
||||
...(updates.expiresAt !== undefined ? { expiresAt: updates.expiresAt } : {})
|
||||
}
|
||||
});
|
||||
|
||||
if (panel.messageId) {
|
||||
try {
|
||||
const channel = (await context.client.channels.fetch(panel.channelId)) as TextChannel;
|
||||
const message = await channel.messages.fetch(panel.messageId);
|
||||
await message.edit({
|
||||
embeds: [buildPanelEmbed(panel, locale)],
|
||||
components: buildPanelComponents(panel)
|
||||
});
|
||||
return postOrEditPanelMessage(context, panel, locale, previous);
|
||||
}
|
||||
|
||||
if (mode === 'REACTIONS') {
|
||||
const existingReactions = message.reactions.cache;
|
||||
for (const reaction of existingReactions.values()) {
|
||||
await reaction.remove().catch(() => undefined);
|
||||
}
|
||||
await applyReactionEmojis(message, roles);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn({ error, panelId }, 'Failed to update self-role panel message');
|
||||
}
|
||||
/**
|
||||
* BullMQ entry points for dashboard create/update/delete. The WebUI has no
|
||||
* discord.js Client, so panel posting is delegated here (same pattern as
|
||||
* giveaways / dashboard messages).
|
||||
*/
|
||||
export async function runSelfRolePanelCreateJob(
|
||||
context: BotContext,
|
||||
params: {
|
||||
guildId: string;
|
||||
channelId: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
mode: SelfRoleMode;
|
||||
behavior: SelfRoleBehavior;
|
||||
roles: SelfRoleEntry[];
|
||||
temporaryDurationMs?: number;
|
||||
expiresAt?: string | null;
|
||||
}
|
||||
): Promise<{ id: string }> {
|
||||
const locale = await getGuildLocale(context.prisma, params.guildId);
|
||||
const panel = await createSelfRolePanel(
|
||||
context,
|
||||
{
|
||||
...params,
|
||||
expiresAt: params.expiresAt ? new Date(params.expiresAt) : null
|
||||
},
|
||||
locale
|
||||
);
|
||||
return { id: panel.id };
|
||||
}
|
||||
|
||||
return panel;
|
||||
export async function runSelfRolePanelUpdateJob(
|
||||
context: BotContext,
|
||||
params: {
|
||||
panelId: string;
|
||||
guildId: string;
|
||||
channelId?: string;
|
||||
title?: string;
|
||||
description?: string | null;
|
||||
mode?: SelfRoleMode;
|
||||
behavior?: SelfRoleBehavior;
|
||||
roles?: SelfRoleEntry[];
|
||||
temporaryDurationMs?: number;
|
||||
expiresAt?: string | null;
|
||||
}
|
||||
): Promise<{ id: string }> {
|
||||
const locale = await getGuildLocale(context.prisma, params.guildId);
|
||||
const panel = await updateSelfRolePanel(
|
||||
context,
|
||||
params.panelId,
|
||||
params.guildId,
|
||||
{
|
||||
channelId: params.channelId,
|
||||
title: params.title,
|
||||
description: params.description,
|
||||
mode: params.mode,
|
||||
behavior: params.behavior,
|
||||
roles: params.roles,
|
||||
temporaryDurationMs: params.temporaryDurationMs,
|
||||
expiresAt:
|
||||
params.expiresAt === undefined
|
||||
? undefined
|
||||
: params.expiresAt
|
||||
? new Date(params.expiresAt)
|
||||
: null
|
||||
},
|
||||
locale
|
||||
);
|
||||
return { id: panel.id };
|
||||
}
|
||||
|
||||
export async function runSelfRolePanelDeleteJob(
|
||||
context: BotContext,
|
||||
panelId: string,
|
||||
guildId: string
|
||||
): Promise<void> {
|
||||
await deleteSelfRolePanel(context, panelId, guildId);
|
||||
}
|
||||
|
||||
export async function deleteSelfRolePanel(
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import { ChannelType } from 'discord.js';
|
||||
import { ChannelType, PermissionFlagsBits } from 'discord.js';
|
||||
import { applyCommandDescription, applyOptionDescription } from '@nexumi/shared';
|
||||
import { SlashCommandBuilder } from 'discord.js';
|
||||
|
||||
export const starboardCommandData = applyCommandDescription(
|
||||
new SlashCommandBuilder()
|
||||
.setName('starboard')
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild)
|
||||
.addSubcommand((s) =>
|
||||
applyCommandDescription(s.setName('setup'), 'starboard.setup.description')
|
||||
.addChannelOption((o) =>
|
||||
applyOptionDescription(
|
||||
o.setName('channel').addChannelTypes(ChannelType.GuildText).setRequired(true),
|
||||
o
|
||||
.setName('channel')
|
||||
.addChannelTypes(ChannelType.GuildText, ChannelType.GuildAnnouncement)
|
||||
.setRequired(true),
|
||||
'starboard.setup.options.channel'
|
||||
)
|
||||
)
|
||||
@@ -28,6 +32,9 @@ export const starboardCommandData = applyCommandDescription(
|
||||
'starboard.setup.options.allow_self_star'
|
||||
)
|
||||
)
|
||||
)
|
||||
.addSubcommand((s) =>
|
||||
applyCommandDescription(s.setName('status'), 'starboard.status.description')
|
||||
),
|
||||
'starboard.description'
|
||||
);
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import { PermissionFlagsBits } from 'discord.js';
|
||||
import { t } from '@nexumi/shared';
|
||||
import { t, tf } from '@nexumi/shared';
|
||||
import type { SlashCommand } from '../../types.js';
|
||||
import { getGuildLocale } from '../../i18n.js';
|
||||
import { requirePermission } from '../../permissions.js';
|
||||
import { ephemeral } from '../../interaction-reply.js';
|
||||
import { starboardCommandData } from './command-definitions.js';
|
||||
import { getStarboardConfig, updateStarboardConfig } from './service.js';
|
||||
import { botCanPostStarboard, getStarboardConfig, updateStarboardConfig } from './service.js';
|
||||
|
||||
const starboardCommand: SlashCommand = {
|
||||
data: starboardCommandData,
|
||||
async execute(interaction, context) {
|
||||
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||
if (!interaction.inGuild()) {
|
||||
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
|
||||
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ...ephemeral() });
|
||||
return;
|
||||
}
|
||||
if (!(await requirePermission(interaction, PermissionFlagsBits.ManageGuild, locale))) {
|
||||
@@ -22,11 +23,21 @@ const starboardCommand: SlashCommand = {
|
||||
const sub = interaction.options.getSubcommand();
|
||||
|
||||
if (sub === 'setup') {
|
||||
const channel = interaction.options.getChannel('channel', true);
|
||||
const channelOption = interaction.options.getChannel('channel', true);
|
||||
const threshold = interaction.options.getInteger('threshold', true);
|
||||
const emoji = interaction.options.getString('emoji') ?? '⭐';
|
||||
const allowSelfStar = interaction.options.getBoolean('allow_self_star') ?? false;
|
||||
|
||||
const channel = await interaction.guild!.channels.fetch(channelOption.id).catch(() => null);
|
||||
const me = interaction.guild!.members.me;
|
||||
if (!channel || !me || !botCanPostStarboard(channel, me.id)) {
|
||||
await interaction.reply({
|
||||
content: t(locale, 'starboard.invalidChannel'),
|
||||
...ephemeral()
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await updateStarboardConfig(context.prisma, guildId, {
|
||||
enabled: true,
|
||||
channelId: channel.id,
|
||||
@@ -35,13 +46,30 @@ const starboardCommand: SlashCommand = {
|
||||
allowSelfStar
|
||||
});
|
||||
|
||||
await interaction.reply({ content: t(locale, 'starboard.setupDone'), ephemeral: true });
|
||||
await interaction.reply({ content: t(locale, 'starboard.setupDone'), ...ephemeral() });
|
||||
return;
|
||||
}
|
||||
|
||||
if (sub === 'status') {
|
||||
const config = await getStarboardConfig(context.prisma, guildId);
|
||||
if (!config.enabled || !config.channelId) {
|
||||
await interaction.reply({ content: t(locale, 'starboard.notConfigured'), ephemeral: true });
|
||||
await interaction.reply({
|
||||
content: t(locale, 'starboard.notConfigured'),
|
||||
...ephemeral()
|
||||
});
|
||||
return;
|
||||
}
|
||||
await interaction.reply({
|
||||
content: tf(locale, 'starboard.statusLine', {
|
||||
channel: `<#${config.channelId}>`,
|
||||
emoji: config.emoji,
|
||||
threshold: String(config.threshold),
|
||||
selfStar: config.allowSelfStar
|
||||
? t(locale, 'starboard.selfStarOn')
|
||||
: t(locale, 'starboard.selfStarOff')
|
||||
}),
|
||||
...ephemeral()
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import type { Message, PartialMessage } from 'discord.js';
|
||||
import type { BotContext } from '../../types.js';
|
||||
import { logger } from '../../logger.js';
|
||||
import { processStarboardReaction } from './service.js';
|
||||
import {
|
||||
processStarboardReaction,
|
||||
processStarboardSourceDelete,
|
||||
processStarboardSourceUpdate
|
||||
} from './service.js';
|
||||
|
||||
export function registerStarboardEvents(context: BotContext): void {
|
||||
const handleReaction = async (
|
||||
@@ -26,4 +31,48 @@ export function registerStarboardEvents(context: BotContext): void {
|
||||
}
|
||||
await handleReaction(_reaction);
|
||||
});
|
||||
|
||||
context.client.on('messageReactionRemoveAll', async (message) => {
|
||||
try {
|
||||
if (message.partial) {
|
||||
await message.fetch().catch(() => null);
|
||||
}
|
||||
if (!message.guild || !('author' in message) || !message.author) {
|
||||
return;
|
||||
}
|
||||
await processStarboardSourceUpdate(context, message as Message | PartialMessage);
|
||||
} catch (error) {
|
||||
logger.warn({ error }, 'Starboard remove-all handler failed');
|
||||
}
|
||||
});
|
||||
|
||||
context.client.on('messageReactionRemoveEmoji', async (reaction) => {
|
||||
await handleReaction(reaction);
|
||||
});
|
||||
|
||||
context.client.on('messageDelete', async (message) => {
|
||||
try {
|
||||
await processStarboardSourceDelete(context, message);
|
||||
} catch (error) {
|
||||
logger.warn({ error }, 'Starboard source-delete handler failed');
|
||||
}
|
||||
});
|
||||
|
||||
context.client.on('messageDeleteBulk', async (messages) => {
|
||||
for (const message of messages.values()) {
|
||||
try {
|
||||
await processStarboardSourceDelete(context, message);
|
||||
} catch (error) {
|
||||
logger.warn({ error }, 'Starboard bulk-delete handler failed');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
context.client.on('messageUpdate', async (_oldMessage, newMessage) => {
|
||||
try {
|
||||
await processStarboardSourceUpdate(context, newMessage);
|
||||
} catch (error) {
|
||||
logger.warn({ error }, 'Starboard source-update handler failed');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
85
apps/bot/src/modules/starboard/service.test.ts
Normal file
85
apps/bot/src/modules/starboard/service.test.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { emojiMatches, shouldIgnoreMessage } from './service.js';
|
||||
|
||||
function reactionEmoji(partial: { id?: string | null; name?: string | null }) {
|
||||
return {
|
||||
id: partial.id ?? null,
|
||||
name: partial.name ?? null,
|
||||
identifier: partial.id ? `${partial.name}:${partial.id}` : (partial.name ?? ''),
|
||||
toString() {
|
||||
if (partial.id) {
|
||||
return `<:${partial.name}:${partial.id}>`;
|
||||
}
|
||||
return partial.name ?? '';
|
||||
}
|
||||
} as unknown as import('discord.js').Emoji;
|
||||
}
|
||||
|
||||
describe('starboard emojiMatches', () => {
|
||||
it('matches unicode emoji', () => {
|
||||
expect(emojiMatches('⭐', reactionEmoji({ name: '⭐' }))).toBe(true);
|
||||
});
|
||||
|
||||
it('matches custom emoji forms', () => {
|
||||
const emoji = reactionEmoji({ id: '123', name: 'star' });
|
||||
expect(emojiMatches('<:star:123>', emoji)).toBe(true);
|
||||
expect(emojiMatches('star:123', emoji)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('starboard shouldIgnoreMessage', () => {
|
||||
const baseConfig = {
|
||||
id: 'cfg',
|
||||
guildId: 'g1',
|
||||
enabled: true,
|
||||
channelId: 'star-channel',
|
||||
emoji: '⭐',
|
||||
threshold: 3,
|
||||
allowSelfStar: false,
|
||||
ignoredChannelIds: ['ignored'],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
};
|
||||
|
||||
it('ignores bot authors', () => {
|
||||
const message = {
|
||||
author: { bot: true },
|
||||
guild: { id: 'g1' },
|
||||
channel: {
|
||||
id: 'c1',
|
||||
isTextBased: () => true,
|
||||
isDMBased: () => false,
|
||||
nsfw: false
|
||||
}
|
||||
} as unknown as import('discord.js').Message;
|
||||
expect(shouldIgnoreMessage(message, baseConfig)).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores the starboard channel itself', () => {
|
||||
const message = {
|
||||
author: { bot: false, id: 'u1' },
|
||||
guild: { id: 'g1' },
|
||||
channel: {
|
||||
id: 'star-channel',
|
||||
isTextBased: () => true,
|
||||
isDMBased: () => false,
|
||||
nsfw: false
|
||||
}
|
||||
} as unknown as import('discord.js').Message;
|
||||
expect(shouldIgnoreMessage(message, baseConfig)).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores listed channels', () => {
|
||||
const message = {
|
||||
author: { bot: false, id: 'u1' },
|
||||
guild: { id: 'g1' },
|
||||
channel: {
|
||||
id: 'ignored',
|
||||
isTextBased: () => true,
|
||||
isDMBased: () => false,
|
||||
nsfw: false
|
||||
}
|
||||
} as unknown as import('discord.js').Message;
|
||||
expect(shouldIgnoreMessage(message, baseConfig)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,17 @@
|
||||
import {
|
||||
ChannelType,
|
||||
EmbedBuilder,
|
||||
PermissionFlagsBits,
|
||||
type Emoji,
|
||||
type GuildBasedChannel,
|
||||
type Message,
|
||||
type PartialMessage,
|
||||
type PartialMessageReaction,
|
||||
type MessageReaction,
|
||||
type TextChannel
|
||||
} from 'discord.js';
|
||||
import type { PrismaClient, StarboardConfig } from '@prisma/client';
|
||||
import { t } from '@nexumi/shared';
|
||||
import type { Locale } from '@nexumi/shared';
|
||||
import { t, type Locale } from '@nexumi/shared';
|
||||
import { ensureGuild } from '../../guild.js';
|
||||
import { logger } from '../../logger.js';
|
||||
import type { BotContext } from '../../types.js';
|
||||
@@ -73,6 +76,27 @@ export function shouldIgnoreMessage(message: Message, config: StarboardConfig):
|
||||
return false;
|
||||
}
|
||||
|
||||
export function botCanPostStarboard(channel: GuildBasedChannel, meId: string): channel is TextChannel {
|
||||
if (!channel.isTextBased() || channel.isDMBased()) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
channel.type !== ChannelType.GuildText &&
|
||||
channel.type !== ChannelType.GuildAnnouncement
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const permissions = channel.permissionsFor(meId);
|
||||
if (!permissions) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
permissions.has(PermissionFlagsBits.ViewChannel) &&
|
||||
permissions.has(PermissionFlagsBits.SendMessages) &&
|
||||
permissions.has(PermissionFlagsBits.EmbedLinks)
|
||||
);
|
||||
}
|
||||
|
||||
export async function countMatchingStars(
|
||||
message: Message,
|
||||
config: StarboardConfig
|
||||
@@ -109,6 +133,10 @@ function resolveImageUrl(message: Message): string | undefined {
|
||||
return embedImage ?? undefined;
|
||||
}
|
||||
|
||||
function authorDisplayName(message: Message): string {
|
||||
return message.member?.displayName || message.author.displayName || message.author.username;
|
||||
}
|
||||
|
||||
export function buildStarboardEmbed(
|
||||
message: Message,
|
||||
starCount: number,
|
||||
@@ -119,7 +147,7 @@ export function buildStarboardEmbed(
|
||||
const embed = new EmbedBuilder()
|
||||
.setColor(0x6366f1)
|
||||
.setAuthor({
|
||||
name: message.author.tag,
|
||||
name: authorDisplayName(message),
|
||||
iconURL: message.author.displayAvatarURL()
|
||||
})
|
||||
.setDescription(content.slice(0, 4096))
|
||||
@@ -143,6 +171,17 @@ export function buildStarboardEmbed(
|
||||
return embed;
|
||||
}
|
||||
|
||||
async function fetchStarboardTextChannel(
|
||||
context: BotContext,
|
||||
channelId: string
|
||||
): Promise<TextChannel | null> {
|
||||
const channel = await context.client.channels.fetch(channelId).catch(() => null);
|
||||
if (!channel || !channel.isTextBased() || channel.isDMBased()) {
|
||||
return null;
|
||||
}
|
||||
return channel as TextChannel;
|
||||
}
|
||||
|
||||
async function removeStarboardEntry(context: BotContext, entryId: string): Promise<void> {
|
||||
const entry = await context.prisma.starboardEntry.findUnique({ where: { id: entryId } });
|
||||
if (!entry) {
|
||||
@@ -152,10 +191,14 @@ async function removeStarboardEntry(context: BotContext, entryId: string): Promi
|
||||
try {
|
||||
const config = await getStarboardConfig(context.prisma, entry.guildId);
|
||||
if (config.channelId) {
|
||||
const channel = (await context.client.channels.fetch(config.channelId)) as TextChannel;
|
||||
const starboardMessage = await channel.messages.fetch(entry.starboardMessageId).catch(() => null);
|
||||
const channel = await fetchStarboardTextChannel(context, config.channelId);
|
||||
if (channel) {
|
||||
const starboardMessage = await channel.messages
|
||||
.fetch(entry.starboardMessageId)
|
||||
.catch(() => null);
|
||||
await starboardMessage?.delete().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn({ error, entryId }, 'Failed to delete starboard message');
|
||||
}
|
||||
@@ -163,6 +206,82 @@ async function removeStarboardEntry(context: BotContext, entryId: string): Promi
|
||||
await context.prisma.starboardEntry.delete({ where: { id: entryId } }).catch(() => undefined);
|
||||
}
|
||||
|
||||
export async function removeStarboardEntryBySource(
|
||||
context: BotContext,
|
||||
guildId: string,
|
||||
sourceMessageId: string
|
||||
): Promise<void> {
|
||||
const entry = await context.prisma.starboardEntry.findUnique({
|
||||
where: {
|
||||
guildId_sourceMessageId: { guildId, sourceMessageId }
|
||||
}
|
||||
});
|
||||
if (entry) {
|
||||
await removeStarboardEntry(context, entry.id);
|
||||
}
|
||||
}
|
||||
|
||||
async function createOrRecreateStarboardPost(
|
||||
context: BotContext,
|
||||
message: Message,
|
||||
config: StarboardConfig,
|
||||
locale: Locale,
|
||||
starCount: number,
|
||||
starboardChannel: TextChannel,
|
||||
existingId?: string
|
||||
): Promise<void> {
|
||||
const embed = buildStarboardEmbed(message, starCount, config, locale);
|
||||
const starboardMessage = await starboardChannel.send({ embeds: [embed] });
|
||||
|
||||
if (existingId) {
|
||||
await context.prisma.starboardEntry.update({
|
||||
where: { id: existingId },
|
||||
data: {
|
||||
starboardMessageId: starboardMessage.id,
|
||||
starCount,
|
||||
sourceChannelId: message.channel.id
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await context.prisma.starboardEntry.create({
|
||||
data: {
|
||||
guildId: message.guild!.id,
|
||||
sourceChannelId: message.channel.id,
|
||||
sourceMessageId: message.id,
|
||||
starboardMessageId: starboardMessage.id,
|
||||
starCount
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
// Race: another reaction created the row first — keep the newer post, drop ours.
|
||||
logger.warn({ error, messageId: message.id }, 'Starboard entry create raced; cleaning up');
|
||||
await starboardMessage.delete().catch(() => undefined);
|
||||
const winner = await context.prisma.starboardEntry.findUnique({
|
||||
where: {
|
||||
guildId_sourceMessageId: {
|
||||
guildId: message.guild!.id,
|
||||
sourceMessageId: message.id
|
||||
}
|
||||
}
|
||||
});
|
||||
if (winner) {
|
||||
await context.prisma.starboardEntry.update({
|
||||
where: { id: winner.id },
|
||||
data: { starCount }
|
||||
});
|
||||
const existingMsg = await starboardChannel.messages
|
||||
.fetch(winner.starboardMessageId)
|
||||
.catch(() => null);
|
||||
if (existingMsg) {
|
||||
await existingMsg.edit({ embeds: [embed] }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function processStarboardMessage(context: BotContext, message: Message): Promise<void> {
|
||||
if (!message.guild) {
|
||||
return;
|
||||
@@ -194,8 +313,13 @@ export async function processStarboardMessage(context: BotContext, message: Mess
|
||||
return;
|
||||
}
|
||||
|
||||
const starboardChannel = await fetchStarboardTextChannel(context, config.channelId);
|
||||
if (!starboardChannel) {
|
||||
logger.warn({ guildId: message.guild.id, channelId: config.channelId }, 'Starboard channel missing');
|
||||
return;
|
||||
}
|
||||
|
||||
const embed = buildStarboardEmbed(message, starCount, config, locale);
|
||||
const starboardChannel = (await context.client.channels.fetch(config.channelId)) as TextChannel;
|
||||
|
||||
if (existing) {
|
||||
try {
|
||||
@@ -206,21 +330,28 @@ export async function processStarboardMessage(context: BotContext, message: Mess
|
||||
data: { starCount }
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn({ error, messageId: message.id }, 'Failed to update starboard message');
|
||||
logger.warn({ error, messageId: message.id }, 'Starboard message missing; recreating');
|
||||
await createOrRecreateStarboardPost(
|
||||
context,
|
||||
message,
|
||||
config,
|
||||
locale,
|
||||
starCount,
|
||||
starboardChannel,
|
||||
existing.id
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const starboardMessage = await starboardChannel.send({ embeds: [embed] });
|
||||
await context.prisma.starboardEntry.create({
|
||||
data: {
|
||||
guildId: message.guild.id,
|
||||
sourceChannelId: message.channel.id,
|
||||
sourceMessageId: message.id,
|
||||
starboardMessageId: starboardMessage.id,
|
||||
starCount
|
||||
}
|
||||
});
|
||||
await createOrRecreateStarboardPost(
|
||||
context,
|
||||
message,
|
||||
config,
|
||||
locale,
|
||||
starCount,
|
||||
starboardChannel
|
||||
);
|
||||
}
|
||||
|
||||
export async function processStarboardReaction(
|
||||
@@ -249,3 +380,41 @@ export async function processStarboardReaction(
|
||||
|
||||
await processStarboardMessage(context, message as Message);
|
||||
}
|
||||
|
||||
export async function processStarboardSourceDelete(
|
||||
context: BotContext,
|
||||
message: Message | PartialMessage
|
||||
): Promise<void> {
|
||||
if (!message.guildId || !message.id) {
|
||||
return;
|
||||
}
|
||||
await removeStarboardEntryBySource(context, message.guildId, message.id);
|
||||
}
|
||||
|
||||
export async function processStarboardSourceUpdate(
|
||||
context: BotContext,
|
||||
message: Message | PartialMessage
|
||||
): Promise<void> {
|
||||
if (!message.guildId || !message.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entry = await context.prisma.starboardEntry.findUnique({
|
||||
where: {
|
||||
guildId_sourceMessageId: {
|
||||
guildId: message.guildId,
|
||||
sourceMessageId: message.id
|
||||
}
|
||||
}
|
||||
});
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
|
||||
const full = message.partial ? await message.fetch().catch(() => null) : message;
|
||||
if (!full || !full.guild) {
|
||||
return;
|
||||
}
|
||||
|
||||
await processStarboardMessage(context, full as Message);
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@ import { invitesCommandData, statsCommandData } from './command-definitions.js';
|
||||
import { getInviteLeaderboard, getInviteStatsForUser } from './invites.js';
|
||||
import {
|
||||
getStatsConfig,
|
||||
refreshGuildStatsChannels,
|
||||
StatsSetupSchema,
|
||||
updateStatsChannels,
|
||||
upsertStatsConfig
|
||||
} from './service.js';
|
||||
|
||||
@@ -47,7 +47,7 @@ const statsCommand: SlashCommand = {
|
||||
}
|
||||
|
||||
await upsertStatsConfig(context.prisma, guildId, parsed.data);
|
||||
await updateStatsChannels(context);
|
||||
await refreshGuildStatsChannels(context, guildId);
|
||||
|
||||
await interaction.reply({ content: t(locale, 'stats.setup.done'), ephemeral: true });
|
||||
return;
|
||||
@@ -64,7 +64,7 @@ const statsCommand: SlashCommand = {
|
||||
return;
|
||||
}
|
||||
|
||||
await updateStatsChannels(context);
|
||||
await refreshGuildStatsChannels(context, guildId);
|
||||
await interaction.reply({ content: t(locale, 'stats.refresh.done'), ephemeral: true });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@ export {
|
||||
ensureStatsRecurringJobs,
|
||||
startStatsWorker,
|
||||
updateStatsChannels,
|
||||
refreshGuildStatsChannels,
|
||||
runStatsGuildRefreshJob,
|
||||
STATS_REFRESH_CRON,
|
||||
STATS_REFRESH_JOB_NAME
|
||||
STATS_REFRESH_JOB_NAME,
|
||||
STATS_GUILD_REFRESH_JOB_NAME
|
||||
} from './service.js';
|
||||
|
||||
@@ -21,6 +21,8 @@ export type StatsSetupInput = z.infer<typeof StatsSetupSchema>;
|
||||
|
||||
export const STATS_REFRESH_CRON = '*/10 * * * *';
|
||||
export const STATS_REFRESH_JOB_NAME = 'statsRefresh';
|
||||
/** One-shot refresh for a single guild (dashboard save / slash refresh). */
|
||||
export const STATS_GUILD_REFRESH_JOB_NAME = 'statsRefreshGuild';
|
||||
|
||||
export async function getStatsConfig(prisma: BotContext['prisma'], guildId: string) {
|
||||
await ensureGuild(prisma, guildId);
|
||||
@@ -97,13 +99,17 @@ async function renameStatsChannel(
|
||||
});
|
||||
}
|
||||
|
||||
async function refreshGuildStatsChannels(context: BotContext, guildId: string): Promise<void> {
|
||||
export async function refreshGuildStatsChannels(context: BotContext, guildId: string): Promise<void> {
|
||||
const config = await context.prisma.statsConfig.findUnique({ where: { guildId } });
|
||||
if (!config?.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const guild = await context.client.guilds.fetch(guildId).catch(() => null);
|
||||
// withCounts populates approximatePresenceCount / approximateMemberCount.
|
||||
// GuildPresences is not privileged for us — presence cache would stay empty.
|
||||
const guild = await context.client.guilds
|
||||
.fetch({ guild: guildId, withCounts: true })
|
||||
.catch(() => null);
|
||||
if (!guild) {
|
||||
return;
|
||||
}
|
||||
@@ -114,9 +120,7 @@ async function refreshGuildStatsChannels(context: BotContext, guildId: string):
|
||||
return;
|
||||
}
|
||||
|
||||
await guild.members.fetch().catch(() => undefined);
|
||||
|
||||
const memberCount = guild.memberCount;
|
||||
const memberCount = guild.approximateMemberCount ?? guild.memberCount;
|
||||
const onlineCount = estimateOnlineCount(guild);
|
||||
const boostCount = guild.premiumSubscriptionCount ?? 0;
|
||||
|
||||
@@ -127,6 +131,13 @@ async function refreshGuildStatsChannels(context: BotContext, guildId: string):
|
||||
]);
|
||||
}
|
||||
|
||||
export async function runStatsGuildRefreshJob(
|
||||
context: BotContext,
|
||||
data: { guildId: string }
|
||||
): Promise<void> {
|
||||
await refreshGuildStatsChannels(context, data.guildId);
|
||||
}
|
||||
|
||||
export async function updateStatsChannels(context: BotContext): Promise<void> {
|
||||
const configs = await context.prisma.statsConfig.findMany({
|
||||
where: { enabled: true }
|
||||
@@ -159,6 +170,15 @@ export function startStatsWorker(context: BotContext): Worker {
|
||||
async (job) => {
|
||||
if (job.name === STATS_REFRESH_JOB_NAME) {
|
||||
await updateStatsChannels(context);
|
||||
return;
|
||||
}
|
||||
if (job.name === STATS_GUILD_REFRESH_JOB_NAME) {
|
||||
const guildId = typeof job.data?.guildId === 'string' ? job.data.guildId : null;
|
||||
if (!guildId) {
|
||||
logger.warn({ jobId: job.id }, 'statsRefreshGuild missing guildId');
|
||||
return;
|
||||
}
|
||||
await runStatsGuildRefreshJob(context, { guildId });
|
||||
}
|
||||
},
|
||||
{ connection: redis }
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { applyCommandDescription, applyOptionDescription, localizedChoice } from '@nexumi/shared';
|
||||
import { SlashCommandBuilder } from 'discord.js';
|
||||
import { PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
|
||||
|
||||
export const tagCommandData = applyCommandDescription(
|
||||
new SlashCommandBuilder()
|
||||
.setName('tag')
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild)
|
||||
.addSubcommand((sub) =>
|
||||
applyCommandDescription(sub.setName('create'), 'tag.create.description')
|
||||
.addStringOption((o) =>
|
||||
|
||||
@@ -172,6 +172,9 @@ const tagCommand: SlashCommand = {
|
||||
tag.triggerWord
|
||||
? tf(locale, 'tag.info.trigger', { word: tag.triggerWord })
|
||||
: t(locale, 'tag.info.noTrigger'),
|
||||
tag.cooldownSeconds > 0
|
||||
? tf(locale, 'tag.info.cooldown', { seconds: tag.cooldownSeconds })
|
||||
: t(locale, 'tag.info.noCooldown'),
|
||||
tag.allowedRoleIds.length > 0
|
||||
? tf(locale, 'tag.info.roles', { roles: tag.allowedRoleIds.map((id) => `<@&${id}>`).join(', ') })
|
||||
: t(locale, 'tag.info.allRoles'),
|
||||
@@ -189,8 +192,12 @@ const tagCommand: SlashCommand = {
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof TagError) {
|
||||
const content =
|
||||
error.code === 'cooldown' && error.retryAfterSeconds !== undefined
|
||||
? tf(locale, 'tag.error.cooldown', { seconds: error.retryAfterSeconds })
|
||||
: t(locale, `tag.error.${error.code}`);
|
||||
await interaction.reply({
|
||||
content: t(locale, `tag.error.${error.code}`),
|
||||
content,
|
||||
ephemeral: true
|
||||
});
|
||||
return;
|
||||
|
||||
@@ -4,8 +4,10 @@ import type { BotContext } from '../../types.js';
|
||||
import { getGuildLocale } from '../../i18n.js';
|
||||
import { logger } from '../../logger.js';
|
||||
import {
|
||||
applyTagCooldown,
|
||||
buildTagReply,
|
||||
findTriggeredTag,
|
||||
getTagCooldownRemaining,
|
||||
memberCanUseTag,
|
||||
TagError
|
||||
} from './service.js';
|
||||
@@ -31,6 +33,18 @@ export function registerTagEvents(client: Client, context: BotContext): void {
|
||||
return;
|
||||
}
|
||||
|
||||
// Channel-scoped cooldown: silent skip to avoid spam replies.
|
||||
if (tag.cooldownSeconds > 0) {
|
||||
const remaining = await getTagCooldownRemaining(
|
||||
message.guild.id,
|
||||
tag.id,
|
||||
message.channel.id
|
||||
);
|
||||
if (remaining > 0) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await context.prisma.tag.update({
|
||||
where: { id: tag.id },
|
||||
data: { useCount: { increment: 1 } }
|
||||
@@ -38,6 +52,7 @@ export function registerTagEvents(client: Client, context: BotContext): void {
|
||||
|
||||
const payload = buildTagReply(tag, member, message.guild, '');
|
||||
await message.reply(payload);
|
||||
await applyTagCooldown(message.guild.id, tag.id, message.channel.id, tag.cooldownSeconds);
|
||||
} catch (error) {
|
||||
if (error instanceof TagError) {
|
||||
const locale = await getGuildLocale(context.prisma, message.guild?.id ?? null);
|
||||
|
||||
@@ -14,15 +14,44 @@ import type { Tag } from '@prisma/client';
|
||||
import { ensureGuild } from '../../guild.js';
|
||||
import { buildEmbedFromPayload } from '../../lib/embed-payload.js';
|
||||
import { buildComponentsV2Payload } from '../../lib/components-v2-payload.js';
|
||||
import { redis } from '../../redis.js';
|
||||
import type { BotContext } from '../../types.js';
|
||||
import { assertPremiumLimit, PremiumLimitError } from '../../premium.js';
|
||||
import { matchesTriggerWord, tagCooldownKey } from './tag-helpers.js';
|
||||
|
||||
export { matchesTriggerWord, tagCooldownKey } from './tag-helpers.js';
|
||||
|
||||
export class TagError extends Error {
|
||||
constructor(public readonly code: string) {
|
||||
constructor(
|
||||
public readonly code: string,
|
||||
public readonly retryAfterSeconds?: number
|
||||
) {
|
||||
super(code);
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns remaining cooldown seconds, or 0 if ready. */
|
||||
export async function getTagCooldownRemaining(
|
||||
guildId: string,
|
||||
tagId: string,
|
||||
scopeId: string
|
||||
): Promise<number> {
|
||||
const ttl = await redis.ttl(tagCooldownKey(guildId, tagId, scopeId));
|
||||
return ttl > 0 ? ttl : 0;
|
||||
}
|
||||
|
||||
export async function applyTagCooldown(
|
||||
guildId: string,
|
||||
tagId: string,
|
||||
scopeId: string,
|
||||
cooldownSeconds: number
|
||||
): Promise<void> {
|
||||
if (cooldownSeconds <= 0) {
|
||||
return;
|
||||
}
|
||||
await redis.set(tagCooldownKey(guildId, tagId, scopeId), '1', 'EX', cooldownSeconds);
|
||||
}
|
||||
|
||||
export function normalizeTagName(name: string): string {
|
||||
return name.trim().toLowerCase();
|
||||
}
|
||||
@@ -147,6 +176,7 @@ export async function createTag(
|
||||
embed?: WelcomeEmbed | null;
|
||||
components?: MessageComponentsV2 | null;
|
||||
triggerWord?: string | null;
|
||||
cooldownSeconds?: number;
|
||||
allowedRoleIds?: string[];
|
||||
allowedChannelIds?: string[];
|
||||
createdById: string;
|
||||
@@ -190,6 +220,7 @@ export async function createTag(
|
||||
embed: params.embed ?? undefined,
|
||||
components: params.components ?? undefined,
|
||||
triggerWord: params.triggerWord?.trim() || null,
|
||||
cooldownSeconds: params.cooldownSeconds ?? 15,
|
||||
allowedRoleIds: params.allowedRoleIds ?? [],
|
||||
allowedChannelIds: params.allowedChannelIds ?? [],
|
||||
createdById: params.createdById
|
||||
@@ -208,6 +239,7 @@ export async function updateTag(
|
||||
embed?: WelcomeEmbed | null;
|
||||
components?: MessageComponentsV2 | null;
|
||||
triggerWord?: string | null;
|
||||
cooldownSeconds?: number;
|
||||
allowedRoleIds?: string[];
|
||||
allowedChannelIds?: string[];
|
||||
}
|
||||
@@ -255,6 +287,8 @@ export async function updateTag(
|
||||
components: components ?? undefined,
|
||||
triggerWord:
|
||||
updates.triggerWord !== undefined ? updates.triggerWord?.trim() || null : existing.triggerWord,
|
||||
cooldownSeconds:
|
||||
updates.cooldownSeconds !== undefined ? updates.cooldownSeconds : existing.cooldownSeconds,
|
||||
allowedRoleIds: updates.allowedRoleIds ?? existing.allowedRoleIds,
|
||||
allowedChannelIds: updates.allowedChannelIds ?? existing.allowedChannelIds
|
||||
}
|
||||
@@ -294,18 +328,22 @@ export async function executeTag(
|
||||
throw new TagError('not_allowed');
|
||||
}
|
||||
|
||||
const scopeId = member?.id ?? channelId;
|
||||
if (tag.cooldownSeconds > 0) {
|
||||
const remaining = await getTagCooldownRemaining(guildId, tag.id, scopeId);
|
||||
if (remaining > 0) {
|
||||
throw new TagError('cooldown', remaining);
|
||||
}
|
||||
}
|
||||
|
||||
await context.prisma.tag.update({
|
||||
where: { id: tag.id },
|
||||
data: { useCount: { increment: 1 } }
|
||||
});
|
||||
|
||||
return buildTagReply(tag, member, guild, args);
|
||||
}
|
||||
await applyTagCooldown(guildId, tag.id, scopeId, tag.cooldownSeconds);
|
||||
|
||||
export function matchesTriggerWord(content: string, triggerWord: string): boolean {
|
||||
const escaped = triggerWord.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const pattern = new RegExp(`\\b${escaped}\\b`, 'i');
|
||||
return pattern.test(content);
|
||||
return buildTagReply(tag, member, guild, args);
|
||||
}
|
||||
|
||||
export async function findTriggeredTag(
|
||||
|
||||
15
apps/bot/src/modules/tags/tag-helpers.test.ts
Normal file
15
apps/bot/src/modules/tags/tag-helpers.test.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { matchesTriggerWord, tagCooldownKey } from './tag-helpers.js';
|
||||
|
||||
describe('tagCooldownKey', () => {
|
||||
it('builds a stable redis key', () => {
|
||||
expect(tagCooldownKey('g1', 't1', 'c1')).toBe('tag:cooldown:g1:t1:c1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('matchesTriggerWord', () => {
|
||||
it('matches whole words case-insensitively', () => {
|
||||
expect(matchesTriggerWord('Hello world', 'hello')).toBe(true);
|
||||
expect(matchesTriggerWord('say helloworld', 'hello')).toBe(false);
|
||||
});
|
||||
});
|
||||
10
apps/bot/src/modules/tags/tag-helpers.ts
Normal file
10
apps/bot/src/modules/tags/tag-helpers.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/** Redis key for tag cooldowns (guild + tag + user or channel scope). */
|
||||
export function tagCooldownKey(guildId: string, tagId: string, scopeId: string): string {
|
||||
return `tag:cooldown:${guildId}:${tagId}:${scopeId}`;
|
||||
}
|
||||
|
||||
export function matchesTriggerWord(content: string, triggerWord: string): boolean {
|
||||
const escaped = triggerWord.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const pattern = new RegExp(`\\b${escaped}\\b`, 'i');
|
||||
return pattern.test(content);
|
||||
}
|
||||
@@ -3,13 +3,14 @@ import {
|
||||
applyOptionDescription,
|
||||
localizedChoice
|
||||
} from '@nexumi/shared';
|
||||
import { SlashCommandBuilder } from 'discord.js';
|
||||
import { PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
|
||||
|
||||
// Discord forbids mixing SUB_COMMAND_GROUP and SUB_COMMAND siblings.
|
||||
// SPEC `/ticket panel create` is represented as subcommand `panel_create`.
|
||||
export const ticketCommandData = applyCommandDescription(
|
||||
new SlashCommandBuilder()
|
||||
.setName('ticket')
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild)
|
||||
.addSubcommand((s) =>
|
||||
applyCommandDescription(s.setName('panel_create'), 'ticket.panel.create.description')
|
||||
.addStringOption((o) =>
|
||||
|
||||
@@ -3,12 +3,11 @@ import { t, tf } from '@nexumi/shared';
|
||||
import type { SlashCommand } from '../../types.js';
|
||||
import { getGuildLocale } from '../../i18n.js';
|
||||
import { requirePermission } from '../../permissions.js';
|
||||
import { ephemeral } from '../../interaction-reply.js';
|
||||
import { ticketCommandData } from './command-definitions.js';
|
||||
import {
|
||||
addUserToTicket,
|
||||
assertTicketStaff,
|
||||
buildPanelComponents,
|
||||
buildPanelEmbed,
|
||||
claimTicket,
|
||||
closeTicket,
|
||||
createCategoryWithRole,
|
||||
@@ -17,7 +16,9 @@ import {
|
||||
removeUserFromTicket,
|
||||
renameTicket,
|
||||
setTicketPriority,
|
||||
syncTicketPanel,
|
||||
TicketError,
|
||||
TicketPanelError,
|
||||
upsertCategoryByName
|
||||
} from './service.js';
|
||||
|
||||
@@ -26,7 +27,7 @@ async function ensureManageGuild(
|
||||
locale: 'de' | 'en'
|
||||
): Promise<boolean> {
|
||||
if (!interaction.inGuild()) {
|
||||
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
|
||||
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ...ephemeral() });
|
||||
return false;
|
||||
}
|
||||
return requirePermission(interaction, PermissionFlagsBits.ManageGuild, locale);
|
||||
@@ -37,7 +38,7 @@ const ticketCommand: SlashCommand = {
|
||||
async execute(interaction, context) {
|
||||
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||
if (!interaction.inGuild()) {
|
||||
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
|
||||
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ...ephemeral() });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -54,7 +55,10 @@ const ticketCommand: SlashCommand = {
|
||||
|
||||
const categories = await getGuildCategories(context, interaction.guildId!);
|
||||
if (categories.length === 0) {
|
||||
await interaction.reply({ content: t(locale, 'ticket.error.no_categories'), ephemeral: true });
|
||||
await interaction.reply({
|
||||
content: t(locale, 'ticket.error.no_categories'),
|
||||
...ephemeral()
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -62,15 +66,31 @@ const ticketCommand: SlashCommand = {
|
||||
if (!channel?.isTextBased() || channel.isDMBased()) {
|
||||
await interaction.reply({
|
||||
content: t(locale, 'ticket.error.invalid_channel'),
|
||||
ephemeral: true
|
||||
...ephemeral()
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const embed = buildPanelEmbed(locale, name, description);
|
||||
const components = buildPanelComponents(categories);
|
||||
await channel.send({ embeds: [embed], components });
|
||||
await interaction.reply({ content: t(locale, 'ticket.panel.created'), ephemeral: true });
|
||||
try {
|
||||
await syncTicketPanel(context, interaction.guildId!, {
|
||||
channelId: channel.id,
|
||||
title: name,
|
||||
description
|
||||
});
|
||||
await interaction.reply({ content: t(locale, 'ticket.panel.created'), ...ephemeral() });
|
||||
} catch (error) {
|
||||
if (error instanceof TicketPanelError) {
|
||||
const key =
|
||||
error.code === 'no_categories'
|
||||
? 'ticket.error.no_categories'
|
||||
: error.code === 'not_configured'
|
||||
? 'ticket.error.invalid_channel'
|
||||
: 'ticket.error.missing_permission';
|
||||
await interaction.reply({ content: t(locale, key), ...ephemeral() });
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -89,18 +109,28 @@ const ticketCommand: SlashCommand = {
|
||||
);
|
||||
await interaction.reply({
|
||||
content: tf(locale, 'ticket.category.created', { name: category.name }),
|
||||
ephemeral: true
|
||||
...ephemeral()
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const ticket = await findTicketByChannel(context.prisma, interaction.channelId!);
|
||||
if (!ticket) {
|
||||
await interaction.reply({ content: t(locale, 'ticket.error.not_in_ticket'), ephemeral: true });
|
||||
await interaction.reply({
|
||||
content: t(locale, 'ticket.error.not_in_ticket'),
|
||||
...ephemeral()
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const member = await interaction.guild!.members.fetch(interaction.user.id);
|
||||
const member = await interaction.guild!.members.fetch(interaction.user.id).catch(() => null);
|
||||
if (!member) {
|
||||
await interaction.reply({
|
||||
content: t(locale, 'ticket.error.member_not_found'),
|
||||
...ephemeral()
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (sub === 'close') {
|
||||
@@ -115,40 +145,57 @@ const ticketCommand: SlashCommand = {
|
||||
const isOpener = ticket.openerId === interaction.user.id;
|
||||
|
||||
if (!isStaff && !isOpener) {
|
||||
await interaction.reply({ content: t(locale, 'ticket.error.not_staff'), ephemeral: true });
|
||||
await interaction.reply({
|
||||
content: t(locale, 'ticket.error.not_staff'),
|
||||
...ephemeral()
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const reason = interaction.options.getString('reason') ?? undefined;
|
||||
await closeTicket(context, ticket, interaction.user.id, locale, reason);
|
||||
await interaction.reply({ content: t(locale, 'ticket.close.success'), ephemeral: true });
|
||||
await interaction.reply({ content: t(locale, 'ticket.close.success'), ...ephemeral() });
|
||||
return;
|
||||
}
|
||||
|
||||
if (sub === 'claim') {
|
||||
await claimTicket(context, ticket, member, locale);
|
||||
await interaction.reply({ content: t(locale, 'ticket.claim.success'), ephemeral: true });
|
||||
await interaction.reply({ content: t(locale, 'ticket.claim.success'), ...ephemeral() });
|
||||
return;
|
||||
}
|
||||
|
||||
if (sub === 'add') {
|
||||
const user = interaction.options.getUser('user', true);
|
||||
const target = await interaction.guild!.members.fetch(user.id);
|
||||
const target = await interaction.guild!.members.fetch(user.id).catch(() => null);
|
||||
if (!target) {
|
||||
await interaction.reply({
|
||||
content: t(locale, 'ticket.error.member_not_found'),
|
||||
...ephemeral()
|
||||
});
|
||||
return;
|
||||
}
|
||||
await addUserToTicket(context, ticket, member, target, locale);
|
||||
await interaction.reply({
|
||||
content: tf(locale, 'ticket.add.success', { user: `<@${user.id}>` }),
|
||||
ephemeral: true
|
||||
...ephemeral()
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (sub === 'remove') {
|
||||
const user = interaction.options.getUser('user', true);
|
||||
const target = await interaction.guild!.members.fetch(user.id);
|
||||
const target = await interaction.guild!.members.fetch(user.id).catch(() => null);
|
||||
if (!target) {
|
||||
await interaction.reply({
|
||||
content: t(locale, 'ticket.error.member_not_found'),
|
||||
...ephemeral()
|
||||
});
|
||||
return;
|
||||
}
|
||||
await removeUserFromTicket(context, ticket, member, target, locale);
|
||||
await interaction.reply({
|
||||
content: tf(locale, 'ticket.remove.success', { user: `<@${user.id}>` }),
|
||||
ephemeral: true
|
||||
...ephemeral()
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -158,7 +205,7 @@ const ticketCommand: SlashCommand = {
|
||||
await renameTicket(context, ticket, member, name, locale);
|
||||
await interaction.reply({
|
||||
content: tf(locale, 'ticket.rename.success', { name }),
|
||||
ephemeral: true
|
||||
...ephemeral()
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -170,14 +217,14 @@ const ticketCommand: SlashCommand = {
|
||||
content: tf(locale, 'ticket.priority.success', {
|
||||
priority: t(locale, `ticket.priority.${level}`)
|
||||
}),
|
||||
ephemeral: true
|
||||
...ephemeral()
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof TicketError) {
|
||||
await interaction.reply({
|
||||
content: t(locale, `ticket.error.${error.code}`),
|
||||
ephemeral: true
|
||||
...ephemeral()
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
export { ticketsCommands } from './commands.js';
|
||||
export { isTicketInteraction, handleTicketInteraction } from './interactions.js';
|
||||
export { closeInactiveTickets, checkInactiveTickets } from './service.js';
|
||||
export {
|
||||
closeInactiveTickets,
|
||||
checkInactiveTickets,
|
||||
runTicketPanelSyncJob,
|
||||
runTicketDashboardActionJob,
|
||||
TicketPanelError
|
||||
} from './service.js';
|
||||
export { registerTicketEvents } from './events.js';
|
||||
|
||||
|
||||
@@ -1,17 +1,32 @@
|
||||
import type {
|
||||
ButtonInteraction,
|
||||
ModalSubmitInteraction,
|
||||
StringSelectMenuInteraction
|
||||
StringSelectMenuInteraction,
|
||||
UserSelectMenuInteraction
|
||||
} from 'discord.js';
|
||||
import { t, tf } from '@nexumi/shared';
|
||||
import type { BotContext } from '../../types.js';
|
||||
import { getGuildLocale } from '../../i18n.js';
|
||||
import { ephemeral } from '../../interaction-reply.js';
|
||||
import {
|
||||
addUserToTicket,
|
||||
assertTicketStaff,
|
||||
buildOpenModal,
|
||||
claimTicket,
|
||||
closeTicket,
|
||||
findTicketByChannel,
|
||||
isTicketControlInteraction,
|
||||
openTicketForCategory,
|
||||
parseFormFields,
|
||||
parseRateButton,
|
||||
rateTicket,
|
||||
removeUserFromTicket,
|
||||
setTicketPriority,
|
||||
TICKET_CTRL_ADD,
|
||||
TICKET_CTRL_CLAIM,
|
||||
TICKET_CTRL_CLOSE,
|
||||
TICKET_CTRL_PRIORITY,
|
||||
TICKET_CTRL_REMOVE,
|
||||
TICKET_MODAL_PREFIX,
|
||||
TICKET_OPEN_PREFIX,
|
||||
TICKET_RATE_PREFIX,
|
||||
@@ -24,37 +39,45 @@ export function isTicketInteraction(customId: string): boolean {
|
||||
customId.startsWith(TICKET_OPEN_PREFIX) ||
|
||||
customId === TICKET_SELECT_ID ||
|
||||
customId.startsWith(TICKET_MODAL_PREFIX) ||
|
||||
customId.startsWith(TICKET_RATE_PREFIX)
|
||||
customId.startsWith(TICKET_RATE_PREFIX) ||
|
||||
isTicketControlInteraction(customId)
|
||||
);
|
||||
}
|
||||
|
||||
async function replyTicketError(
|
||||
interaction: ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction,
|
||||
interaction:
|
||||
| ButtonInteraction
|
||||
| StringSelectMenuInteraction
|
||||
| ModalSubmitInteraction
|
||||
| UserSelectMenuInteraction,
|
||||
locale: 'de' | 'en',
|
||||
code: string
|
||||
): Promise<void> {
|
||||
const content = t(locale, `ticket.error.${code}`);
|
||||
if (interaction.replied || interaction.deferred) {
|
||||
await interaction.followUp({ content, ephemeral: true });
|
||||
await interaction.followUp({ content, ...ephemeral() });
|
||||
} else {
|
||||
await interaction.reply({ content, ephemeral: true });
|
||||
await interaction.reply({ content, ...ephemeral() });
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleOpenTicketRequest(
|
||||
async function handleOpenTicketRequest(
|
||||
interaction: ButtonInteraction | StringSelectMenuInteraction,
|
||||
context: BotContext,
|
||||
categoryId: string
|
||||
): Promise<void> {
|
||||
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||
if (!interaction.inGuild() || !interaction.guild) {
|
||||
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
|
||||
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ...ephemeral() });
|
||||
return;
|
||||
}
|
||||
|
||||
const category = await context.prisma.ticketCategory.findUnique({ where: { id: categoryId } });
|
||||
if (!category || category.guildId !== interaction.guildId) {
|
||||
await interaction.reply({ content: t(locale, 'ticket.error.category_not_found'), ephemeral: true });
|
||||
await interaction.reply({
|
||||
content: t(locale, 'ticket.error.category_not_found'),
|
||||
...ephemeral()
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -65,7 +88,14 @@ export async function handleOpenTicketRequest(
|
||||
}
|
||||
|
||||
try {
|
||||
const member = await interaction.guild.members.fetch(interaction.user.id);
|
||||
const member = await interaction.guild.members.fetch(interaction.user.id).catch(() => null);
|
||||
if (!member) {
|
||||
await interaction.reply({
|
||||
content: t(locale, 'ticket.error.member_not_found'),
|
||||
...ephemeral()
|
||||
});
|
||||
return;
|
||||
}
|
||||
const ticket = await openTicketForCategory(
|
||||
context,
|
||||
interaction.guild,
|
||||
@@ -76,7 +106,7 @@ export async function handleOpenTicketRequest(
|
||||
const channelRef = ticket.channelId ?? ticket.threadId;
|
||||
await interaction.reply({
|
||||
content: tf(locale, 'ticket.created', { channel: `<#${channelRef}>` }),
|
||||
ephemeral: true
|
||||
...ephemeral()
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof TicketError) {
|
||||
@@ -87,8 +117,131 @@ export async function handleOpenTicketRequest(
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTicketControl(
|
||||
interaction: ButtonInteraction | UserSelectMenuInteraction | StringSelectMenuInteraction,
|
||||
context: BotContext
|
||||
): Promise<void> {
|
||||
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||
if (!interaction.inGuild() || !interaction.guild) {
|
||||
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ...ephemeral() });
|
||||
return;
|
||||
}
|
||||
|
||||
const ticket = await findTicketByChannel(context.prisma, interaction.channelId);
|
||||
if (!ticket) {
|
||||
await interaction.reply({ content: t(locale, 'ticket.error.not_in_ticket'), ...ephemeral() });
|
||||
return;
|
||||
}
|
||||
|
||||
const member = await interaction.guild.members.fetch(interaction.user.id).catch(() => null);
|
||||
if (!member) {
|
||||
await interaction.reply({
|
||||
content: t(locale, 'ticket.error.member_not_found'),
|
||||
...ephemeral()
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (interaction.isButton() && interaction.customId === TICKET_CTRL_CLOSE) {
|
||||
const isOpener = ticket.openerId === interaction.user.id;
|
||||
let isStaff = false;
|
||||
try {
|
||||
await assertTicketStaff(member, ticket);
|
||||
isStaff = true;
|
||||
} catch {
|
||||
isStaff = false;
|
||||
}
|
||||
if (!isStaff && !isOpener) {
|
||||
await interaction.reply({ content: t(locale, 'ticket.error.not_staff'), ...ephemeral() });
|
||||
return;
|
||||
}
|
||||
|
||||
await interaction.deferReply(ephemeral());
|
||||
await closeTicket(context, ticket, interaction.user.id, locale);
|
||||
await interaction.editReply({ content: t(locale, 'ticket.close.success') });
|
||||
return;
|
||||
}
|
||||
|
||||
if (interaction.isButton() && interaction.customId === TICKET_CTRL_CLAIM) {
|
||||
await claimTicket(context, ticket, member, locale);
|
||||
await interaction.reply({ content: t(locale, 'ticket.claim.success'), ...ephemeral() });
|
||||
return;
|
||||
}
|
||||
|
||||
if (interaction.isStringSelectMenu() && interaction.customId === TICKET_CTRL_PRIORITY) {
|
||||
const level = interaction.values[0];
|
||||
if (!level) {
|
||||
await interaction.reply({ content: t(locale, 'ticket.error.not_found'), ...ephemeral() });
|
||||
return;
|
||||
}
|
||||
await setTicketPriority(context, ticket, member, level, locale);
|
||||
await interaction.reply({
|
||||
content: tf(locale, 'ticket.priority.success', {
|
||||
priority: t(locale, `ticket.priority.${level}`)
|
||||
}),
|
||||
...ephemeral()
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (interaction.isUserSelectMenu() && interaction.customId === TICKET_CTRL_ADD) {
|
||||
const userId = interaction.values[0];
|
||||
if (!userId) {
|
||||
await interaction.reply({ content: t(locale, 'ticket.error.not_found'), ...ephemeral() });
|
||||
return;
|
||||
}
|
||||
const target = await interaction.guild.members.fetch(userId).catch(() => null);
|
||||
if (!target) {
|
||||
await interaction.reply({
|
||||
content: t(locale, 'ticket.error.member_not_found'),
|
||||
...ephemeral()
|
||||
});
|
||||
return;
|
||||
}
|
||||
await addUserToTicket(context, ticket, member, target, locale);
|
||||
await interaction.reply({
|
||||
content: tf(locale, 'ticket.add.success', { user: `<@${userId}>` }),
|
||||
...ephemeral()
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (interaction.isUserSelectMenu() && interaction.customId === TICKET_CTRL_REMOVE) {
|
||||
const userId = interaction.values[0];
|
||||
if (!userId) {
|
||||
await interaction.reply({ content: t(locale, 'ticket.error.not_found'), ...ephemeral() });
|
||||
return;
|
||||
}
|
||||
const target = await interaction.guild.members.fetch(userId).catch(() => null);
|
||||
if (!target) {
|
||||
await interaction.reply({
|
||||
content: t(locale, 'ticket.error.member_not_found'),
|
||||
...ephemeral()
|
||||
});
|
||||
return;
|
||||
}
|
||||
await removeUserFromTicket(context, ticket, member, target, locale);
|
||||
await interaction.reply({
|
||||
content: tf(locale, 'ticket.remove.success', { user: `<@${userId}>` }),
|
||||
...ephemeral()
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof TicketError) {
|
||||
await replyTicketError(interaction, locale, error.code);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleTicketInteraction(
|
||||
interaction: ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction,
|
||||
interaction:
|
||||
| ButtonInteraction
|
||||
| StringSelectMenuInteraction
|
||||
| ModalSubmitInteraction
|
||||
| UserSelectMenuInteraction,
|
||||
context: BotContext
|
||||
): Promise<void> {
|
||||
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||
@@ -96,13 +249,27 @@ export async function handleTicketInteraction(
|
||||
if (interaction.isStringSelectMenu() && interaction.customId === TICKET_SELECT_ID) {
|
||||
const categoryId = interaction.values[0];
|
||||
if (!categoryId) {
|
||||
await interaction.reply({ content: t(locale, 'ticket.error.category_not_found'), ephemeral: true });
|
||||
await interaction.reply({
|
||||
content: t(locale, 'ticket.error.category_not_found'),
|
||||
...ephemeral()
|
||||
});
|
||||
return;
|
||||
}
|
||||
await handleOpenTicketRequest(interaction, context, categoryId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
interaction.isButton() ||
|
||||
interaction.isUserSelectMenu() ||
|
||||
interaction.isStringSelectMenu()
|
||||
) {
|
||||
if (isTicketControlInteraction(interaction.customId)) {
|
||||
await handleTicketControl(interaction, context);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (interaction.isButton()) {
|
||||
if (interaction.customId.startsWith(TICKET_OPEN_PREFIX)) {
|
||||
const categoryId = interaction.customId.slice(TICKET_OPEN_PREFIX.length);
|
||||
@@ -122,7 +289,7 @@ export async function handleTicketInteraction(
|
||||
if (error instanceof TicketError) {
|
||||
await interaction.reply({
|
||||
content: t(locale, `ticket.error.${error.code}`),
|
||||
ephemeral: true
|
||||
...ephemeral()
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -134,14 +301,17 @@ export async function handleTicketInteraction(
|
||||
|
||||
if (interaction.isModalSubmit() && interaction.customId.startsWith(TICKET_MODAL_PREFIX)) {
|
||||
if (!interaction.inGuild() || !interaction.guild) {
|
||||
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
|
||||
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ...ephemeral() });
|
||||
return;
|
||||
}
|
||||
|
||||
const categoryId = interaction.customId.slice(TICKET_MODAL_PREFIX.length);
|
||||
const category = await context.prisma.ticketCategory.findUnique({ where: { id: categoryId } });
|
||||
if (!category) {
|
||||
await interaction.reply({ content: t(locale, 'ticket.error.category_not_found'), ephemeral: true });
|
||||
await interaction.reply({
|
||||
content: t(locale, 'ticket.error.category_not_found'),
|
||||
...ephemeral()
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -156,7 +326,14 @@ export async function handleTicketInteraction(
|
||||
}
|
||||
|
||||
try {
|
||||
const member = await interaction.guild.members.fetch(interaction.user.id);
|
||||
const member = await interaction.guild.members.fetch(interaction.user.id).catch(() => null);
|
||||
if (!member) {
|
||||
await interaction.reply({
|
||||
content: t(locale, 'ticket.error.member_not_found'),
|
||||
...ephemeral()
|
||||
});
|
||||
return;
|
||||
}
|
||||
const ticket = await openTicketForCategory(
|
||||
context,
|
||||
interaction.guild,
|
||||
@@ -169,7 +346,7 @@ export async function handleTicketInteraction(
|
||||
const channelRef = ticket.channelId ?? ticket.threadId;
|
||||
await interaction.reply({
|
||||
content: tf(locale, 'ticket.created', { channel: `<#${channelRef}>` }),
|
||||
ephemeral: true
|
||||
...ephemeral()
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof TicketError) {
|
||||
|
||||
@@ -10,9 +10,14 @@ import {
|
||||
StringSelectMenuBuilder,
|
||||
TextInputBuilder,
|
||||
TextInputStyle,
|
||||
UserSelectMenuBuilder,
|
||||
type APIMessageComponentEmoji,
|
||||
type Guild,
|
||||
type GuildBasedChannel,
|
||||
type GuildMember,
|
||||
type GuildTextBasedChannel,
|
||||
type Message,
|
||||
type Role,
|
||||
type SendableChannels,
|
||||
type TextChannel,
|
||||
type ThreadChannel
|
||||
@@ -34,6 +39,14 @@ export const TICKET_OPEN_PREFIX = 'ticket:open:';
|
||||
export const TICKET_SELECT_ID = 'ticket:select';
|
||||
export const TICKET_MODAL_PREFIX = 'ticket:modal:';
|
||||
export const TICKET_RATE_PREFIX = 'ticket:rate:';
|
||||
export const TICKET_CTRL_CLAIM = 'ticket:ctrl:claim';
|
||||
export const TICKET_CTRL_CLOSE = 'ticket:ctrl:close';
|
||||
export const TICKET_CTRL_ADD = 'ticket:ctrl:add';
|
||||
export const TICKET_CTRL_REMOVE = 'ticket:ctrl:remove';
|
||||
export const TICKET_CTRL_PRIORITY = 'ticket:ctrl:priority';
|
||||
|
||||
/** Cap private-thread staff adds to stay within Discord rate limits. */
|
||||
const MAX_THREAD_SUPPORT_MEMBERS = 50;
|
||||
|
||||
export const TicketFormFieldSchema = z.object({
|
||||
id: z.string(),
|
||||
@@ -51,6 +64,189 @@ export class TicketError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export class TicketPanelError extends Error {
|
||||
constructor(public readonly code: 'not_configured' | 'missing_permission' | 'no_categories') {
|
||||
super(code);
|
||||
this.name = 'TicketPanelError';
|
||||
}
|
||||
}
|
||||
|
||||
function isUnknownChannelError(error: unknown): boolean {
|
||||
return Boolean(
|
||||
error &&
|
||||
typeof error === 'object' &&
|
||||
'code' in error &&
|
||||
(error as { code: unknown }).code === 10003
|
||||
);
|
||||
}
|
||||
|
||||
function botCanPostPanel(
|
||||
channel: GuildBasedChannel,
|
||||
meId: string
|
||||
): channel is GuildTextBasedChannel {
|
||||
if (!channel.isTextBased() || channel.isDMBased()) {
|
||||
return false;
|
||||
}
|
||||
const permissions = channel.permissionsFor(meId);
|
||||
if (!permissions) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
permissions.has(PermissionFlagsBits.ViewChannel) &&
|
||||
permissions.has(PermissionFlagsBits.SendMessages) &&
|
||||
permissions.has(PermissionFlagsBits.EmbedLinks)
|
||||
);
|
||||
}
|
||||
|
||||
function parseButtonEmoji(emoji?: string | null): APIMessageComponentEmoji | undefined {
|
||||
if (!emoji) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const customMatch = emoji.match(/^<(a)?:(\w+):(\d+)>$/);
|
||||
if (customMatch) {
|
||||
return {
|
||||
id: customMatch[3]!,
|
||||
name: customMatch[2],
|
||||
animated: Boolean(customMatch[1])
|
||||
};
|
||||
}
|
||||
|
||||
if (/^\d+$/.test(emoji)) {
|
||||
return { id: emoji };
|
||||
}
|
||||
|
||||
return { name: emoji };
|
||||
}
|
||||
|
||||
/**
|
||||
* `Role#members` only includes cached guild members. Fetch the member list when
|
||||
* the cache looks empty so support staff can be added to private threads.
|
||||
*/
|
||||
async function resolveSupportMembers(guild: Guild, roleIds: string[]): Promise<GuildMember[]> {
|
||||
if (roleIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const roles: Role[] = [];
|
||||
for (const roleId of roleIds) {
|
||||
const role =
|
||||
guild.roles.cache.get(roleId) ?? (await guild.roles.fetch(roleId).catch(() => null));
|
||||
if (role) {
|
||||
roles.push(role);
|
||||
}
|
||||
}
|
||||
|
||||
if (roles.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const cachedCount = roles.reduce((sum, role) => sum + role.members.size, 0);
|
||||
if (cachedCount === 0) {
|
||||
await guild.members.fetch().catch((error) => {
|
||||
logger.warn({ error, guildId: guild.id }, 'Failed to fetch members for ticket support roles');
|
||||
});
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
const members: GuildMember[] = [];
|
||||
for (const role of roles) {
|
||||
for (const member of role.members.values()) {
|
||||
if (member.user.bot || seen.has(member.id)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(member.id);
|
||||
members.push(member);
|
||||
if (members.length >= MAX_THREAD_SUPPORT_MEMBERS) {
|
||||
return members;
|
||||
}
|
||||
}
|
||||
}
|
||||
return members;
|
||||
}
|
||||
|
||||
export function isTicketControlInteraction(customId: string): boolean {
|
||||
return (
|
||||
customId === TICKET_CTRL_CLAIM ||
|
||||
customId === TICKET_CTRL_CLOSE ||
|
||||
customId === TICKET_CTRL_ADD ||
|
||||
customId === TICKET_CTRL_REMOVE ||
|
||||
customId === TICKET_CTRL_PRIORITY
|
||||
);
|
||||
}
|
||||
|
||||
export function buildTicketControlPanel(locale: 'de' | 'en'): {
|
||||
embeds: EmbedBuilder[];
|
||||
components: ActionRowBuilder<
|
||||
ButtonBuilder | UserSelectMenuBuilder | StringSelectMenuBuilder
|
||||
>[];
|
||||
} {
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(t(locale, 'ticket.control.title'))
|
||||
.setDescription(t(locale, 'ticket.control.description'))
|
||||
.setColor(0x6366f1);
|
||||
|
||||
const buttons = new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId(TICKET_CTRL_CLAIM)
|
||||
.setLabel(t(locale, 'ticket.control.claim'))
|
||||
.setStyle(ButtonStyle.Success),
|
||||
new ButtonBuilder()
|
||||
.setCustomId(TICKET_CTRL_CLOSE)
|
||||
.setLabel(t(locale, 'ticket.control.close'))
|
||||
.setStyle(ButtonStyle.Danger)
|
||||
);
|
||||
|
||||
const priorityRow = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(
|
||||
new StringSelectMenuBuilder()
|
||||
.setCustomId(TICKET_CTRL_PRIORITY)
|
||||
.setPlaceholder(t(locale, 'ticket.control.priorityPlaceholder'))
|
||||
.addOptions(
|
||||
{
|
||||
label: t(locale, 'ticket.priority.LOW'),
|
||||
value: 'LOW',
|
||||
description: t(locale, 'ticket.control.priorityLowHint')
|
||||
},
|
||||
{
|
||||
label: t(locale, 'ticket.priority.NORMAL'),
|
||||
value: 'NORMAL',
|
||||
description: t(locale, 'ticket.control.priorityNormalHint')
|
||||
},
|
||||
{
|
||||
label: t(locale, 'ticket.priority.HIGH'),
|
||||
value: 'HIGH',
|
||||
description: t(locale, 'ticket.control.priorityHighHint')
|
||||
},
|
||||
{
|
||||
label: t(locale, 'ticket.priority.URGENT'),
|
||||
value: 'URGENT',
|
||||
description: t(locale, 'ticket.control.priorityUrgentHint')
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const addRow = new ActionRowBuilder<UserSelectMenuBuilder>().addComponents(
|
||||
new UserSelectMenuBuilder()
|
||||
.setCustomId(TICKET_CTRL_ADD)
|
||||
.setPlaceholder(t(locale, 'ticket.control.addPlaceholder'))
|
||||
.setMinValues(1)
|
||||
.setMaxValues(1)
|
||||
);
|
||||
|
||||
const removeRow = new ActionRowBuilder<UserSelectMenuBuilder>().addComponents(
|
||||
new UserSelectMenuBuilder()
|
||||
.setCustomId(TICKET_CTRL_REMOVE)
|
||||
.setPlaceholder(t(locale, 'ticket.control.removePlaceholder'))
|
||||
.setMinValues(1)
|
||||
.setMaxValues(1)
|
||||
);
|
||||
|
||||
return {
|
||||
embeds: [embed],
|
||||
components: [buttons, priorityRow, addRow, removeRow]
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchSendableChannel(
|
||||
context: BotContext,
|
||||
channelId: string
|
||||
@@ -185,8 +381,9 @@ export function buildPanelComponents(categories: TicketCategory[]): ActionRowBui
|
||||
.setCustomId(openButtonId(category.id))
|
||||
.setLabel(category.name.slice(0, 80))
|
||||
.setStyle(ButtonStyle.Primary);
|
||||
if (category.emoji) {
|
||||
button.setEmoji(category.emoji);
|
||||
const emoji = parseButtonEmoji(category.emoji);
|
||||
if (emoji) {
|
||||
button.setEmoji(emoji);
|
||||
}
|
||||
row.addComponents(button);
|
||||
}
|
||||
@@ -197,17 +394,208 @@ export function buildPanelComponents(categories: TicketCategory[]): ActionRowBui
|
||||
.setCustomId(TICKET_SELECT_ID)
|
||||
.setPlaceholder('Select a category')
|
||||
.addOptions(
|
||||
categories.slice(0, 25).map((category) => ({
|
||||
categories.slice(0, 25).map((category) => {
|
||||
const emoji = parseButtonEmoji(category.emoji);
|
||||
return {
|
||||
label: category.name.slice(0, 100),
|
||||
value: category.id,
|
||||
description: category.description?.slice(0, 100) ?? undefined,
|
||||
emoji: category.emoji ? { name: category.emoji } : undefined
|
||||
}))
|
||||
emoji
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return [new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(select)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Posts or updates the ticket panel. Used by `/ticket panel_create` and by the
|
||||
* dashboard via BullMQ (`ticketPanelSync`).
|
||||
*/
|
||||
export async function syncTicketPanel(
|
||||
context: BotContext,
|
||||
guildId: string,
|
||||
options: {
|
||||
channelId?: string | null;
|
||||
title?: string;
|
||||
description?: string | null;
|
||||
} = {}
|
||||
): Promise<{ panelChannelId: string; panelMessageId: string }> {
|
||||
const locale = await getGuildLocale(context.prisma, guildId);
|
||||
const config = await getTicketConfig(context.prisma, guildId);
|
||||
const categories = await getGuildCategories(context, guildId);
|
||||
|
||||
if (categories.length === 0) {
|
||||
throw new TicketPanelError('no_categories');
|
||||
}
|
||||
|
||||
const targetChannelId = options.channelId || config.panelChannelId;
|
||||
if (!targetChannelId) {
|
||||
throw new TicketPanelError('not_configured');
|
||||
}
|
||||
|
||||
const guild = await context.client.guilds.fetch(guildId);
|
||||
const me = guild.members.me;
|
||||
if (!me) {
|
||||
throw new TicketPanelError('missing_permission');
|
||||
}
|
||||
|
||||
const channel = await guild.channels.fetch(targetChannelId).catch(() => null);
|
||||
if (!channel || !botCanPostPanel(channel, me.id)) {
|
||||
throw new TicketPanelError('missing_permission');
|
||||
}
|
||||
|
||||
const title = options.title ?? t(locale, 'ticket.panel.defaultTitle');
|
||||
const description =
|
||||
options.description !== undefined
|
||||
? options.description
|
||||
: t(locale, 'ticket.panel.defaultDescription');
|
||||
const payload = {
|
||||
embeds: [buildPanelEmbed(locale, title, description)],
|
||||
components: buildPanelComponents(categories)
|
||||
};
|
||||
|
||||
const sameChannel =
|
||||
Boolean(config.panelMessageId) && config.panelChannelId === channel.id;
|
||||
|
||||
if (sameChannel && config.panelMessageId) {
|
||||
try {
|
||||
const existing = await channel.messages.fetch(config.panelMessageId);
|
||||
await existing.edit(payload);
|
||||
if (!config.panelChannelId) {
|
||||
await context.prisma.ticketConfig.update({
|
||||
where: { guildId },
|
||||
data: { panelChannelId: channel.id }
|
||||
});
|
||||
}
|
||||
return { panelChannelId: channel.id, panelMessageId: existing.id };
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
{ error, guildId, messageId: config.panelMessageId },
|
||||
'Failed to edit ticket panel; recreating'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (config.panelMessageId && config.panelChannelId && config.panelChannelId !== channel.id) {
|
||||
try {
|
||||
const oldChannel = await guild.channels.fetch(config.panelChannelId);
|
||||
if (oldChannel?.isTextBased() && !oldChannel.isDMBased()) {
|
||||
const oldMessage = await oldChannel.messages.fetch(config.panelMessageId);
|
||||
await oldMessage.delete();
|
||||
}
|
||||
} catch {
|
||||
// Previous panel may already be gone
|
||||
}
|
||||
}
|
||||
|
||||
const message = await channel.send(payload);
|
||||
await context.prisma.ticketConfig.update({
|
||||
where: { guildId },
|
||||
data: {
|
||||
panelChannelId: channel.id,
|
||||
panelMessageId: message.id
|
||||
}
|
||||
});
|
||||
|
||||
return { panelChannelId: channel.id, panelMessageId: message.id };
|
||||
}
|
||||
|
||||
export async function runTicketPanelSyncJob(
|
||||
context: BotContext,
|
||||
data: { guildId: string }
|
||||
): Promise<{ panelChannelId: string; panelMessageId: string }> {
|
||||
return syncTicketPanel(context, data.guildId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard ticket actions (close / claim / priority). Staff checks are done by
|
||||
* the WebUI (`requireGuildAccess`); the bot applies Discord side effects.
|
||||
*/
|
||||
export async function runTicketDashboardActionJob(
|
||||
context: BotContext,
|
||||
data: {
|
||||
ticketId: string;
|
||||
guildId: string;
|
||||
actorUserId: string;
|
||||
action: 'close' | 'claim' | 'priority';
|
||||
reason?: string;
|
||||
priority?: string;
|
||||
}
|
||||
): Promise<{ id: string; status: string; priority: string; claimedById: string | null }> {
|
||||
const ticket = await context.prisma.ticket.findFirst({
|
||||
where: { id: data.ticketId, guildId: data.guildId },
|
||||
include: { category: true }
|
||||
});
|
||||
if (!ticket) {
|
||||
throw new TicketError('not_found');
|
||||
}
|
||||
if (ticket.status === 'CLOSED') {
|
||||
throw new TicketError('closed');
|
||||
}
|
||||
|
||||
const locale = await getGuildLocale(context.prisma, data.guildId);
|
||||
|
||||
if (data.action === 'close') {
|
||||
await closeTicket(context, ticket, data.actorUserId, locale, data.reason);
|
||||
const closed = await context.prisma.ticket.findUniqueOrThrow({ where: { id: ticket.id } });
|
||||
return {
|
||||
id: closed.id,
|
||||
status: closed.status,
|
||||
priority: closed.priority,
|
||||
claimedById: closed.claimedById
|
||||
};
|
||||
}
|
||||
|
||||
if (data.action === 'claim') {
|
||||
if (ticket.claimedById === data.actorUserId) {
|
||||
throw new TicketError('already_claimed');
|
||||
}
|
||||
const updated = await context.prisma.ticket.update({
|
||||
where: { id: ticket.id },
|
||||
data: {
|
||||
claimedById: data.actorUserId,
|
||||
status: 'CLAIMED',
|
||||
lastActivityAt: new Date()
|
||||
}
|
||||
});
|
||||
const channelId = ticket.channelId ?? ticket.threadId;
|
||||
if (channelId) {
|
||||
const channel = await fetchSendableChannel(context, channelId);
|
||||
if (channel) {
|
||||
await channel.send(tf(locale, 'ticket.claimed', { user: `<@${data.actorUserId}>` }));
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: updated.id,
|
||||
status: updated.status,
|
||||
priority: updated.priority,
|
||||
claimedById: updated.claimedById
|
||||
};
|
||||
}
|
||||
|
||||
const parsed = TicketPrioritySchema.parse(data.priority);
|
||||
const updated = await context.prisma.ticket.update({
|
||||
where: { id: ticket.id },
|
||||
data: { priority: parsed, lastActivityAt: new Date() }
|
||||
});
|
||||
const channelId = ticket.channelId ?? ticket.threadId;
|
||||
if (channelId) {
|
||||
const channel = await fetchSendableChannel(context, channelId);
|
||||
if (channel) {
|
||||
await channel.send(
|
||||
tf(locale, 'ticket.prioritySet', { priority: t(locale, `ticket.priority.${parsed}`) })
|
||||
);
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: updated.id,
|
||||
status: updated.status,
|
||||
priority: updated.priority,
|
||||
claimedById: updated.claimedById
|
||||
};
|
||||
}
|
||||
|
||||
export function buildOpenModal(category: TicketCategory, locale: 'de' | 'en'): ModalBuilder {
|
||||
const fields = parseFormFields(category.formFields);
|
||||
const modal = new ModalBuilder()
|
||||
@@ -328,7 +716,11 @@ async function fetchTicketMessages(
|
||||
timestamp: message.createdAt.toISOString()
|
||||
}));
|
||||
} catch (error) {
|
||||
if (isUnknownChannelError(error)) {
|
||||
logger.debug({ ticketId: ticket.id }, 'Ticket channel already gone while fetching messages');
|
||||
} else {
|
||||
logger.warn({ error, ticketId: ticket.id }, 'Failed to fetch ticket messages');
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -395,13 +787,24 @@ export async function createTicketChannel(
|
||||
reason: `Ticket opened by ${params.opener.user.tag}`
|
||||
});
|
||||
await thread.members.add(params.opener.id);
|
||||
for (const roleId of supportRoleIds) {
|
||||
const role = params.guild.roles.cache.get(roleId);
|
||||
if (role) {
|
||||
for (const [, member] of role.members) {
|
||||
await thread.members.add(member.id).catch(() => undefined);
|
||||
|
||||
const supportMembers = await resolveSupportMembers(params.guild, supportRoleIds);
|
||||
for (const member of supportMembers) {
|
||||
if (member.id === params.opener.id) {
|
||||
continue;
|
||||
}
|
||||
await thread.members.add(member.id).catch((error) => {
|
||||
logger.warn(
|
||||
{ error, threadId: thread.id, userId: member.id },
|
||||
'Failed to add support member to ticket thread'
|
||||
);
|
||||
});
|
||||
}
|
||||
if (supportRoleIds.length > 0 && supportMembers.length === 0) {
|
||||
logger.warn(
|
||||
{ guildId: params.guild.id, supportRoleIds },
|
||||
'No cached members found for ticket support roles – staff may need Server Members intent coverage'
|
||||
);
|
||||
}
|
||||
threadId = thread.id;
|
||||
} else {
|
||||
@@ -436,10 +839,13 @@ export async function createTicketChannel(
|
||||
|
||||
const targetId = channelId ?? threadId!;
|
||||
const target = (await context.client.channels.fetch(targetId)) as TextChannel | ThreadChannel;
|
||||
|
||||
const roleMentions = supportRoleIds.map((roleId) => `<@&${roleId}>`).join(' ');
|
||||
const introLines = [
|
||||
roleMentions || null,
|
||||
tf(locale, 'ticket.opened', { user: `<@${params.opener.id}>` }),
|
||||
params.subject ? tf(locale, 'ticket.subjectLine', { subject: params.subject }) : null
|
||||
].filter(Boolean);
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
if (params.formAnswers && Object.keys(params.formAnswers).length > 0) {
|
||||
const answerLines = Object.entries(params.formAnswers).map(
|
||||
@@ -448,7 +854,17 @@ export async function createTicketChannel(
|
||||
introLines.push(answerLines.join('\n'));
|
||||
}
|
||||
|
||||
await target.send({ content: introLines.join('\n') });
|
||||
await target.send({
|
||||
content: introLines.join('\n'),
|
||||
allowedMentions: {
|
||||
roles: supportRoleIds,
|
||||
users: [params.opener.id]
|
||||
}
|
||||
});
|
||||
|
||||
const control = buildTicketControlPanel(locale);
|
||||
await target.send(control);
|
||||
|
||||
return ticket;
|
||||
}
|
||||
|
||||
@@ -720,9 +1136,13 @@ export async function closeTicket(
|
||||
await channel.delete(`Ticket ${ticket.id} closed`);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isUnknownChannelError(error)) {
|
||||
logger.debug({ ticketId: ticket.id }, 'Ticket channel already gone while deleting');
|
||||
} else {
|
||||
logger.warn({ error, ticketId: ticket.id }, 'Failed to delete ticket channel');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function rateTicket(
|
||||
|
||||
@@ -257,7 +257,9 @@ const emojiCommand: SlashCommand = {
|
||||
async execute(interaction, context) {
|
||||
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||
if (!(await ensureGuildCommand(interaction, locale))) return;
|
||||
if (!(await requirePermission(interaction, PermissionFlagsBits.ManageGuildExpressions, locale))) {
|
||||
if (!(await requirePermission(interaction, PermissionFlagsBits.ManageGuildExpressions, locale, {
|
||||
botPermissions: PermissionFlagsBits.ManageGuildExpressions
|
||||
}))) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -326,7 +328,9 @@ const stickerCommand: SlashCommand = {
|
||||
async execute(interaction, context) {
|
||||
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||
if (!(await ensureGuildCommand(interaction, locale))) return;
|
||||
if (!(await requirePermission(interaction, PermissionFlagsBits.ManageGuildExpressions, locale))) {
|
||||
if (!(await requirePermission(interaction, PermissionFlagsBits.ManageGuildExpressions, locale, {
|
||||
botPermissions: PermissionFlagsBits.ManageGuildExpressions
|
||||
}))) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -419,7 +423,9 @@ const embedCommand: SlashCommand = {
|
||||
async execute(interaction, context) {
|
||||
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||
if (!(await ensureGuildCommand(interaction, locale))) return;
|
||||
if (!(await requirePermission(interaction, PermissionFlagsBits.ManageMessages, locale))) {
|
||||
if (!(await requirePermission(interaction, PermissionFlagsBits.ManageMessages, locale, {
|
||||
botPermissions: PermissionFlagsBits.ManageMessages
|
||||
}))) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -32,17 +32,18 @@ const translateContextMenu: ContextMenuCommand = {
|
||||
return;
|
||||
}
|
||||
|
||||
await interaction.deferReply({ ephemeral: true });
|
||||
|
||||
const targetLang = locale === 'de' ? 'de' : 'en';
|
||||
const translated = await translateText(content, targetLang);
|
||||
|
||||
if (!translated) {
|
||||
await interaction.reply({ content: t(locale, 'utility.translate.failed'), ephemeral: true });
|
||||
await interaction.editReply({ content: t(locale, 'utility.translate.failed') });
|
||||
return;
|
||||
}
|
||||
|
||||
await interaction.reply({
|
||||
content: t(locale, 'utility.translate.result').replace('{text}', translated),
|
||||
ephemeral: true
|
||||
await interaction.editReply({
|
||||
content: t(locale, 'utility.translate.result').replace('{text}', translated)
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import { type TextChannel } from 'discord.js';
|
||||
import { t, tf } from '@nexumi/shared';
|
||||
import type { BotContext } from '../../types.js';
|
||||
import { ensureGuild } from '../../guild.js';
|
||||
import { safeRemoveBullJob } from '../../lib/safe-remove-job.js';
|
||||
import { reminderQueue } from '../../queues.js';
|
||||
import { parseWhen, UtilityError } from './service.js';
|
||||
|
||||
@@ -107,9 +108,7 @@ export async function deleteReminder(
|
||||
|
||||
if (match.jobId) {
|
||||
const job = await reminderQueue.getJob(match.jobId);
|
||||
if (job) {
|
||||
await job.remove();
|
||||
}
|
||||
await safeRemoveBullJob(job, { label: 'reminder', jobId: match.jobId });
|
||||
}
|
||||
|
||||
await context.prisma.reminder.delete({ where: { id: match.id } });
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import {
|
||||
PermissionFlagsBits,
|
||||
type GuildBasedChannel,
|
||||
type GuildTextBasedChannel
|
||||
} from 'discord.js';
|
||||
import { PermissionFlagsBits } from 'discord.js';
|
||||
import { t } from '@nexumi/shared';
|
||||
import type { SlashCommand } from '../../types.js';
|
||||
import { getGuildLocale } from '../../i18n.js';
|
||||
@@ -10,25 +6,7 @@ import { requirePermission } from '../../permissions.js';
|
||||
import { ephemeral } from '../../interaction-reply.js';
|
||||
import { verifyCommandData } from './command-definitions.js';
|
||||
import { getVerificationConfig, updateVerificationConfig } from './service.js';
|
||||
import { buildVerificationPanel } from './handlers.js';
|
||||
|
||||
function botCanPostPanel(
|
||||
channel: GuildBasedChannel,
|
||||
meId: string
|
||||
): channel is GuildTextBasedChannel {
|
||||
if (!channel.isTextBased() || channel.isDMBased()) {
|
||||
return false;
|
||||
}
|
||||
const permissions = channel.permissionsFor(meId);
|
||||
if (!permissions) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
permissions.has(PermissionFlagsBits.ViewChannel) &&
|
||||
permissions.has(PermissionFlagsBits.SendMessages) &&
|
||||
permissions.has(PermissionFlagsBits.EmbedLinks)
|
||||
);
|
||||
}
|
||||
import { syncVerificationPanel, VerificationPanelError } from './handlers.js';
|
||||
|
||||
const verifyCommand: SlashCommand = {
|
||||
data: verifyCommandData,
|
||||
@@ -91,31 +69,21 @@ const verifyCommand: SlashCommand = {
|
||||
}
|
||||
|
||||
const panelChannelOption = interaction.options.getChannel('channel');
|
||||
const panelChannel = panelChannelOption
|
||||
? await interaction.guild!.channels.fetch(panelChannelOption.id)
|
||||
: config.channelId
|
||||
? await interaction.guild!.channels.fetch(config.channelId)
|
||||
: null;
|
||||
|
||||
const me = interaction.guild!.members.me;
|
||||
if (!panelChannel || !me || !botCanPostPanel(panelChannel, me.id)) {
|
||||
await interaction.reply({
|
||||
content: t(locale, 'verification.panelMissingPermission'),
|
||||
...ephemeral()
|
||||
});
|
||||
try {
|
||||
await syncVerificationPanel(context, guildId, panelChannelOption?.id ?? null);
|
||||
await interaction.reply({ content: t(locale, 'verification.panelPosted'), ...ephemeral() });
|
||||
} catch (error) {
|
||||
if (error instanceof VerificationPanelError) {
|
||||
const key =
|
||||
error.code === 'not_configured'
|
||||
? 'verification.notConfigured'
|
||||
: 'verification.panelMissingPermission';
|
||||
await interaction.reply({ content: t(locale, key), ...ephemeral() });
|
||||
return;
|
||||
}
|
||||
|
||||
const panel = buildVerificationPanel(config, locale);
|
||||
const message = await panelChannel.send(panel);
|
||||
await context.prisma.verificationConfig.update({
|
||||
where: { guildId },
|
||||
data: {
|
||||
panelChannelId: panelChannel.id,
|
||||
panelMessageId: message.id
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
await interaction.reply({ content: t(locale, 'verification.panelPosted'), ...ephemeral() });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -5,7 +5,9 @@ import {
|
||||
EmbedBuilder,
|
||||
PermissionFlagsBits,
|
||||
type ButtonInteraction,
|
||||
type GuildMember
|
||||
type GuildBasedChannel,
|
||||
type GuildMember,
|
||||
type GuildTextBasedChannel
|
||||
} from 'discord.js';
|
||||
import { CaptchaProviderSchema, t, tf, type CaptchaProvider } from '@nexumi/shared';
|
||||
import type { BotContext } from '../../types.js';
|
||||
@@ -22,6 +24,32 @@ import { detectAltAccount } from './alt-detection.js';
|
||||
import { env } from '../../env.js';
|
||||
import { logger } from '../../logger.js';
|
||||
import { ephemeral } from '../../interaction-reply.js';
|
||||
import { notifyModerationTargetDm } from '../moderation/service.js';
|
||||
|
||||
export class VerificationPanelError extends Error {
|
||||
constructor(public readonly code: 'not_configured' | 'missing_permission') {
|
||||
super(code);
|
||||
this.name = 'VerificationPanelError';
|
||||
}
|
||||
}
|
||||
|
||||
function botCanPostPanel(
|
||||
channel: GuildBasedChannel,
|
||||
meId: string
|
||||
): channel is GuildTextBasedChannel {
|
||||
if (!channel.isTextBased() || channel.isDMBased()) {
|
||||
return false;
|
||||
}
|
||||
const permissions = channel.permissionsFor(meId);
|
||||
if (!permissions) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
permissions.has(PermissionFlagsBits.ViewChannel) &&
|
||||
permissions.has(PermissionFlagsBits.SendMessages) &&
|
||||
permissions.has(PermissionFlagsBits.EmbedLinks)
|
||||
);
|
||||
}
|
||||
|
||||
export type CompleteVerificationOptions = {
|
||||
ipHash?: string | null;
|
||||
@@ -30,20 +58,61 @@ export type CompleteVerificationOptions = {
|
||||
};
|
||||
|
||||
async function applyFailAction(
|
||||
context: BotContext,
|
||||
member: GuildMember,
|
||||
failAction: string,
|
||||
reason: string
|
||||
): Promise<void> {
|
||||
const me = member.guild.members.me;
|
||||
if (failAction !== 'KICK' && failAction !== 'BAN') {
|
||||
return;
|
||||
}
|
||||
const locale = await getGuildLocale(context.prisma, member.guild.id);
|
||||
if (failAction === 'KICK' && me?.permissions.has(PermissionFlagsBits.KickMembers)) {
|
||||
await notifyModerationTargetDm({
|
||||
user: member.user,
|
||||
guildName: member.guild.name,
|
||||
reason,
|
||||
locale,
|
||||
action: 'kick'
|
||||
});
|
||||
await member.kick(reason);
|
||||
return;
|
||||
}
|
||||
if (failAction === 'BAN' && me?.permissions.has(PermissionFlagsBits.BanMembers)) {
|
||||
await notifyModerationTargetDm({
|
||||
user: member.user,
|
||||
guildName: member.guild.name,
|
||||
reason,
|
||||
locale,
|
||||
action: 'ban'
|
||||
});
|
||||
await member.guild.members.ban(member.id, { reason });
|
||||
}
|
||||
}
|
||||
|
||||
async function logVerificationFailure(
|
||||
context: BotContext,
|
||||
member: GuildMember,
|
||||
input: {
|
||||
reason: string;
|
||||
detail?: string;
|
||||
failAction?: string;
|
||||
matchedUserId?: string | null;
|
||||
}
|
||||
): Promise<void> {
|
||||
const { logVerificationFail } = await import('../logging/events.js');
|
||||
await logVerificationFail(context, {
|
||||
guildId: member.guild.id,
|
||||
userId: member.id,
|
||||
userTag: member.user.tag,
|
||||
reason: input.reason,
|
||||
detail: input.detail,
|
||||
failAction: input.failAction,
|
||||
matchedUserId: input.matchedUserId
|
||||
});
|
||||
}
|
||||
|
||||
export async function completeVerification(
|
||||
context: BotContext,
|
||||
guildId: string,
|
||||
@@ -63,11 +132,13 @@ export async function completeVerification(
|
||||
|
||||
const ageDays = accountAgeDays(member.user.createdAt);
|
||||
if (config.minAccountAgeDays > 0 && ageDays < config.minAccountAgeDays) {
|
||||
await applyFailAction(
|
||||
member,
|
||||
config.failAction,
|
||||
`Verification failed: account too young (${ageDays}d)`
|
||||
);
|
||||
const failReason = `Verification failed: account too young (${ageDays}d)`;
|
||||
await logVerificationFailure(context, member, {
|
||||
reason: 'account_too_young',
|
||||
detail: `Account age ${ageDays}d (required ${config.minAccountAgeDays}d)`,
|
||||
failAction: config.failAction
|
||||
});
|
||||
await applyFailAction(context, member, config.failAction, failReason);
|
||||
return { ok: false, reason: 'account_too_young' };
|
||||
}
|
||||
|
||||
@@ -143,13 +214,23 @@ export async function completeVerification(
|
||||
});
|
||||
|
||||
if (config.altBlockOnMatch) {
|
||||
await applyFailAction(
|
||||
member,
|
||||
config.failAction,
|
||||
`Verification failed: possible alt account (${match.reason})`
|
||||
);
|
||||
const failReason = `Verification failed: possible alt account (${match.reason})`;
|
||||
await logVerificationFailure(context, member, {
|
||||
reason: 'alt_detected',
|
||||
detail: `Signal: ${match.reason}`,
|
||||
failAction: config.failAction,
|
||||
matchedUserId
|
||||
});
|
||||
await applyFailAction(context, member, config.failAction, failReason);
|
||||
return { ok: false, reason: 'alt_detected' };
|
||||
}
|
||||
|
||||
await logVerificationFailure(context, member, {
|
||||
reason: 'alt_detected_flagged',
|
||||
detail: `Signal: ${match.reason} (flagged only, not blocked)`,
|
||||
failAction: 'NONE',
|
||||
matchedUserId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,6 +309,86 @@ export function buildVerificationPanel(
|
||||
return { embeds: [embed], components: [row] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Posts or updates the verification panel in the configured channel.
|
||||
* Used by `/verify panel` and by the dashboard via BullMQ (`verificationPanelSync`).
|
||||
*/
|
||||
export async function syncVerificationPanel(
|
||||
context: BotContext,
|
||||
guildId: string,
|
||||
preferredChannelId?: string | null
|
||||
): Promise<{ panelChannelId: string; panelMessageId: string }> {
|
||||
const locale = await getGuildLocale(context.prisma, guildId);
|
||||
const config = await getVerificationConfig(context.prisma, guildId);
|
||||
|
||||
if (!config.enabled || !config.verifiedRoleId) {
|
||||
throw new VerificationPanelError('not_configured');
|
||||
}
|
||||
|
||||
const targetChannelId = preferredChannelId || config.channelId;
|
||||
if (!targetChannelId) {
|
||||
throw new VerificationPanelError('not_configured');
|
||||
}
|
||||
|
||||
const guild = await context.client.guilds.fetch(guildId);
|
||||
const me = guild.members.me;
|
||||
if (!me) {
|
||||
throw new VerificationPanelError('missing_permission');
|
||||
}
|
||||
|
||||
const channel = await guild.channels.fetch(targetChannelId).catch(() => null);
|
||||
if (!channel || !botCanPostPanel(channel, me.id)) {
|
||||
throw new VerificationPanelError('missing_permission');
|
||||
}
|
||||
|
||||
const panel = buildVerificationPanel(config, locale);
|
||||
const sameChannel =
|
||||
Boolean(config.panelMessageId) && config.panelChannelId === channel.id;
|
||||
|
||||
if (sameChannel && config.panelMessageId) {
|
||||
try {
|
||||
const existing = await channel.messages.fetch(config.panelMessageId);
|
||||
await existing.edit(panel);
|
||||
return { panelChannelId: channel.id, panelMessageId: existing.id };
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
{ error, guildId, messageId: config.panelMessageId },
|
||||
'Failed to edit verification panel; recreating'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (config.panelMessageId && config.panelChannelId && config.panelChannelId !== channel.id) {
|
||||
try {
|
||||
const oldChannel = await guild.channels.fetch(config.panelChannelId);
|
||||
if (oldChannel?.isTextBased() && !oldChannel.isDMBased()) {
|
||||
const oldMessage = await oldChannel.messages.fetch(config.panelMessageId);
|
||||
await oldMessage.delete();
|
||||
}
|
||||
} catch {
|
||||
// Previous panel may already be gone
|
||||
}
|
||||
}
|
||||
|
||||
const message = await channel.send(panel);
|
||||
await context.prisma.verificationConfig.update({
|
||||
where: { guildId },
|
||||
data: {
|
||||
panelChannelId: channel.id,
|
||||
panelMessageId: message.id
|
||||
}
|
||||
});
|
||||
|
||||
return { panelChannelId: channel.id, panelMessageId: message.id };
|
||||
}
|
||||
|
||||
export async function runVerificationPanelSyncJob(
|
||||
context: BotContext,
|
||||
data: { guildId: string }
|
||||
): Promise<{ panelChannelId: string; panelMessageId: string }> {
|
||||
return syncVerificationPanel(context, data.guildId);
|
||||
}
|
||||
|
||||
export async function handleVerificationButton(
|
||||
interaction: ButtonInteraction,
|
||||
context: BotContext
|
||||
|
||||
@@ -1,23 +1,75 @@
|
||||
import { PermissionFlagsBits, type GuildMember } from 'discord.js';
|
||||
import { PermissionFlagsBits, type GuildMember, type Role } from 'discord.js';
|
||||
import { t, tf } from '@nexumi/shared';
|
||||
import type { BotContext } from '../../types.js';
|
||||
import { getGuildLocale } from '../../i18n.js';
|
||||
import { getWelcomeConfig, resolveWelcomePayload, resolveLeavePayload, renderWelcomeText } from './service.js';
|
||||
import { sendLeaveMessage, sendWelcomeMessage } from './renderer.js';
|
||||
import { logger } from '../../logger.js';
|
||||
|
||||
async function resolveRole(member: GuildMember, roleId: string): Promise<Role | null> {
|
||||
const cached = member.guild.roles.cache.get(roleId);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
return member.guild.roles.fetch(roleId).catch(() => null);
|
||||
}
|
||||
|
||||
async function applyAutoroles(member: GuildMember, roleIds: string[]): Promise<void> {
|
||||
const me = member.guild.members.me;
|
||||
if (!me?.permissions.has(PermissionFlagsBits.ManageRoles)) {
|
||||
const uniqueIds = [...new Set(roleIds.filter(Boolean))];
|
||||
if (uniqueIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
const assignable = roleIds.filter((roleId) => {
|
||||
const role = member.guild.roles.cache.get(roleId);
|
||||
return role && role.position < me.roles.highest.position;
|
||||
});
|
||||
|
||||
const me =
|
||||
member.guild.members.me ?? (await member.guild.members.fetchMe().catch(() => null));
|
||||
if (!me) {
|
||||
logger.warn({ guildId: member.guild.id }, 'Autoroles skipped: bot member unavailable');
|
||||
return;
|
||||
}
|
||||
if (!me.permissions.has(PermissionFlagsBits.ManageRoles)) {
|
||||
logger.warn({ guildId: member.guild.id }, 'Autoroles skipped: missing ManageRoles permission');
|
||||
return;
|
||||
}
|
||||
|
||||
const assignable: string[] = [];
|
||||
for (const roleId of uniqueIds) {
|
||||
if (roleId === member.guild.id) {
|
||||
continue;
|
||||
}
|
||||
if (member.roles.cache.has(roleId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const role = await resolveRole(member, roleId);
|
||||
if (!role) {
|
||||
logger.warn({ guildId: member.guild.id, roleId }, 'Autorole skipped: role not found');
|
||||
continue;
|
||||
}
|
||||
if (role.managed) {
|
||||
logger.warn({ guildId: member.guild.id, roleId }, 'Autorole skipped: managed role');
|
||||
continue;
|
||||
}
|
||||
if (!role.editable) {
|
||||
logger.warn(
|
||||
{
|
||||
guildId: member.guild.id,
|
||||
roleId,
|
||||
rolePosition: role.position,
|
||||
botHighest: me.roles.highest.position
|
||||
},
|
||||
'Autorole skipped: role above bot or not editable'
|
||||
);
|
||||
continue;
|
||||
}
|
||||
assignable.push(roleId);
|
||||
}
|
||||
|
||||
if (assignable.length === 0) {
|
||||
return;
|
||||
}
|
||||
await member.roles.add(assignable).catch((error) => {
|
||||
logger.warn({ error, memberId: member.id }, 'Failed to assign autoroles');
|
||||
|
||||
await member.roles.add(assignable, 'Nexumi welcome autorole').catch((error) => {
|
||||
logger.warn({ error, memberId: member.id, assignable }, 'Failed to assign autoroles');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -56,6 +108,33 @@ export async function handleMemberLeave(context: BotContext, member: GuildMember
|
||||
return;
|
||||
}
|
||||
await sendLeaveMessage(channel, resolveLeavePayload(config), member, member.guild);
|
||||
|
||||
if (!config.leaveAnnounceBan || member.user.bot) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ban = await member.guild.bans.fetch(member.id).catch(() => null);
|
||||
if (!ban) {
|
||||
return;
|
||||
}
|
||||
|
||||
const locale = await getGuildLocale(context.prisma, member.guild.id);
|
||||
const reason = ban.reason ?? t(locale, 'moderation.reason.none');
|
||||
if ('send' in channel) {
|
||||
await channel
|
||||
.send(
|
||||
tf(locale, 'welcome.leave.bannedNotice', {
|
||||
user: member.user.tag,
|
||||
reason
|
||||
})
|
||||
)
|
||||
.catch((error) => {
|
||||
logger.warn(
|
||||
{ error, guildId: member.guild.id, userId: member.id },
|
||||
'Failed to send leave ban notice'
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function registerWelcomeEvents(context: BotContext): void {
|
||||
|
||||
@@ -10,7 +10,7 @@ import { createCanvas, loadImage } from '@napi-rs/canvas';
|
||||
import { buildEmbedFromPayload } from '../../lib/embed-payload.js';
|
||||
import { buildComponentsV2Payload } from '../../lib/components-v2-payload.js';
|
||||
import type { LeavePayload, WelcomePayload } from './service.js';
|
||||
import { renderWelcomeText } from './service.js';
|
||||
import { renderWelcomeEmbedText, renderWelcomeText } from './service.js';
|
||||
|
||||
export async function buildWelcomeMessage(
|
||||
payload: WelcomePayload,
|
||||
@@ -31,7 +31,7 @@ export async function buildWelcomeMessage(
|
||||
|
||||
if (payload.type === 'EMBED') {
|
||||
const embed = buildEmbedFromPayload(payload.embed, {
|
||||
renderText: (value) => renderWelcomeText(value, member, guild),
|
||||
renderText: (value, field) => renderWelcomeEmbedText(value, member, guild, field),
|
||||
defaultThumbnailUrl: member.user.displayAvatarURL({ size: 256 })
|
||||
});
|
||||
return embed ? { embeds: [embed] } : { content: renderWelcomeText('{user}', member, guild) };
|
||||
@@ -77,14 +77,15 @@ async function renderWelcomeCard(
|
||||
const title = payload.imageTitle ?? 'Welcome!';
|
||||
const subtitle =
|
||||
payload.imageSubtitle ?? `${member.displayName} joined ${guild.name}`;
|
||||
const plain = { mentionUser: false as const };
|
||||
|
||||
ctx.fillStyle = '#f9fafb';
|
||||
ctx.font = 'bold 36px sans-serif';
|
||||
ctx.fillText(renderWelcomeText(title, member, guild).slice(0, 40), 220, 130);
|
||||
ctx.fillText(renderWelcomeText(title, member, guild, plain).slice(0, 40), 220, 130);
|
||||
|
||||
ctx.fillStyle = '#d1d5db';
|
||||
ctx.font = '24px sans-serif';
|
||||
ctx.fillText(renderWelcomeText(subtitle, member, guild).slice(0, 60), 220, 180);
|
||||
ctx.fillText(renderWelcomeText(subtitle, member, guild, plain).slice(0, 60), 220, 180);
|
||||
|
||||
return canvas.toBuffer('image/png');
|
||||
}
|
||||
@@ -119,7 +120,7 @@ export async function buildLeaveMessage(
|
||||
|
||||
if (payload.type === 'EMBED') {
|
||||
const embed = buildEmbedFromPayload(payload.embed, {
|
||||
renderText: (value) => renderWelcomeText(value, member, guild),
|
||||
renderText: (value, field) => renderWelcomeEmbedText(value, member, guild, field),
|
||||
defaultThumbnailUrl: member.user.displayAvatarURL({ size: 256 })
|
||||
});
|
||||
if (embed) {
|
||||
|
||||
@@ -13,9 +13,23 @@ export async function getWelcomeConfig(prisma: PrismaClient, guildId: string) {
|
||||
return config;
|
||||
}
|
||||
|
||||
export function buildPlaceholderVars(member: GuildMember, guild: Guild) {
|
||||
export type WelcomePlaceholderOptions = {
|
||||
/**
|
||||
* When false, `{user}` becomes the member display name instead of `<@id>`.
|
||||
* Required for embed title/author/footer and canvas text — Discord does not
|
||||
* resolve mentions in those surfaces.
|
||||
*/
|
||||
mentionUser?: boolean;
|
||||
};
|
||||
|
||||
export function buildPlaceholderVars(
|
||||
member: GuildMember,
|
||||
guild: Guild,
|
||||
options: WelcomePlaceholderOptions = {}
|
||||
) {
|
||||
const mentionUser = options.mentionUser !== false;
|
||||
return {
|
||||
user: `<@${member.id}>`,
|
||||
user: mentionUser ? `<@${member.id}>` : member.displayName,
|
||||
'user.name': member.displayName,
|
||||
'user.tag': member.user.tag,
|
||||
'user.id': member.id,
|
||||
@@ -26,8 +40,27 @@ export function buildPlaceholderVars(member: GuildMember, guild: Guild) {
|
||||
};
|
||||
}
|
||||
|
||||
export function renderWelcomeText(template: string, member: GuildMember, guild: Guild): string {
|
||||
return applyWelcomePlaceholders(template, buildPlaceholderVars(member, guild));
|
||||
export function renderWelcomeText(
|
||||
template: string,
|
||||
member: GuildMember,
|
||||
guild: Guild,
|
||||
options: WelcomePlaceholderOptions = {}
|
||||
): string {
|
||||
return applyWelcomePlaceholders(template, buildPlaceholderVars(member, guild, options));
|
||||
}
|
||||
|
||||
/** Fields where Discord will not turn `<@id>` into a clickable mention. */
|
||||
const EMBED_PLAIN_USER_FIELDS = new Set(['title', 'authorName', 'footerText']);
|
||||
|
||||
export function renderWelcomeEmbedText(
|
||||
template: string,
|
||||
member: GuildMember,
|
||||
guild: Guild,
|
||||
field: string
|
||||
): string {
|
||||
return renderWelcomeText(template, member, guild, {
|
||||
mentionUser: !EMBED_PLAIN_USER_FIELDS.has(field)
|
||||
});
|
||||
}
|
||||
|
||||
export function parseWelcomeEmbed(raw: unknown): WelcomeEmbed | null {
|
||||
|
||||
@@ -1,14 +1,49 @@
|
||||
import { type ChatInputCommandInteraction } from 'discord.js';
|
||||
import { PermissionFlagsBits, type ChatInputCommandInteraction } from 'discord.js';
|
||||
import { t } from '@nexumi/shared';
|
||||
import type { Locale } from '@nexumi/shared';
|
||||
|
||||
const PERMISSION_LABELS: Partial<Record<string, string>> = {
|
||||
[PermissionFlagsBits.Administrator.toString()]: 'Administrator',
|
||||
[PermissionFlagsBits.ManageGuild.toString()]: 'Manage Server',
|
||||
[PermissionFlagsBits.ManageChannels.toString()]: 'Manage Channels',
|
||||
[PermissionFlagsBits.ManageRoles.toString()]: 'Manage Roles',
|
||||
[PermissionFlagsBits.ManageMessages.toString()]: 'Manage Messages',
|
||||
[PermissionFlagsBits.ManageNicknames.toString()]: 'Manage Nicknames',
|
||||
[PermissionFlagsBits.ManageGuildExpressions.toString()]: 'Manage Expressions',
|
||||
[PermissionFlagsBits.KickMembers.toString()]: 'Kick Members',
|
||||
[PermissionFlagsBits.BanMembers.toString()]: 'Ban Members',
|
||||
[PermissionFlagsBits.ModerateMembers.toString()]: 'Timeout Members',
|
||||
[PermissionFlagsBits.ViewChannel.toString()]: 'View Channel',
|
||||
[PermissionFlagsBits.SendMessages.toString()]: 'Send Messages',
|
||||
[PermissionFlagsBits.EmbedLinks.toString()]: 'Embed Links',
|
||||
[PermissionFlagsBits.AttachFiles.toString()]: 'Attach Files',
|
||||
[PermissionFlagsBits.ReadMessageHistory.toString()]: 'Read Message History'
|
||||
};
|
||||
|
||||
function permissionLabel(permission: bigint): string {
|
||||
return PERMISSION_LABELS[permission.toString()] ?? permission.toString();
|
||||
}
|
||||
|
||||
export type RequirePermissionOptions = {
|
||||
/**
|
||||
* Discord permissions the bot itself must hold. Omit for dashboard-style
|
||||
* member gates (e.g. ManageGuild) where the bot does not need the same bit.
|
||||
*/
|
||||
botPermissions?: bigint | readonly bigint[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Ensures the invoking member has `memberPermission`. Optionally also checks
|
||||
* that the bot has one or more `botPermissions` (moderation actions).
|
||||
*/
|
||||
export async function requirePermission(
|
||||
interaction: ChatInputCommandInteraction,
|
||||
permission: bigint,
|
||||
locale: Locale
|
||||
memberPermission: bigint,
|
||||
locale: Locale,
|
||||
options?: RequirePermissionOptions
|
||||
): Promise<boolean> {
|
||||
const member = interaction.memberPermissions;
|
||||
if (!member?.has(permission)) {
|
||||
if (!member?.has(memberPermission)) {
|
||||
await interaction.reply({
|
||||
content: t(locale, 'generic.noPermission'),
|
||||
ephemeral: true
|
||||
@@ -16,16 +51,36 @@ export async function requirePermission(
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!interaction.guild?.members.me?.permissions.has(permission)) {
|
||||
const botBits = options?.botPermissions;
|
||||
if (botBits === undefined) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const required = Array.isArray(botBits) ? botBits : [botBits];
|
||||
const me = interaction.guild?.members.me;
|
||||
if (!me) {
|
||||
await interaction.reply({
|
||||
content: t(locale, 'generic.botMissingPermission').replace(
|
||||
'{permission}',
|
||||
permission.toString()
|
||||
required.map(permissionLabel).join(', ')
|
||||
),
|
||||
ephemeral: true
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const bit of required) {
|
||||
if (!me.permissions.has(bit)) {
|
||||
await interaction.reply({
|
||||
content: t(locale, 'generic.botMissingPermission').replace(
|
||||
'{permission}',
|
||||
permissionLabel(bit)
|
||||
),
|
||||
ephemeral: true
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -120,6 +120,23 @@ export async function readPresenceConfig(context: BotContext): Promise<BotPresen
|
||||
|
||||
export async function applyBotPresence(context: BotContext): Promise<void> {
|
||||
const config = await readPresenceConfig(context);
|
||||
|
||||
if (config.maintenanceMode) {
|
||||
const text =
|
||||
config.maintenanceMessage?.trim() ||
|
||||
'Maintenance mode — please try again later.';
|
||||
await context.client.user?.setPresence({
|
||||
status: 'dnd',
|
||||
activities: [
|
||||
{
|
||||
name: text.slice(0, 128),
|
||||
type: ActivityType.Watching
|
||||
}
|
||||
]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const messages =
|
||||
config.rotatingMessages.length > 0 ? config.rotatingMessages : [config.activityText];
|
||||
const index = Math.floor(Date.now() / 60_000) % messages.length;
|
||||
|
||||
@@ -15,6 +15,8 @@ export const giveawayQueueName = 'giveaways';
|
||||
export const giveawayQueue = new Queue(giveawayQueueName, { connection: redis });
|
||||
export const ticketQueueName = 'tickets';
|
||||
export const ticketQueue = new Queue(ticketQueueName, { connection: redis });
|
||||
export const selfrolesQueueName = 'selfroles';
|
||||
export const selfrolesQueue = new Queue(selfrolesQueueName, { connection: redis });
|
||||
export const birthdayQueueName = 'birthdays';
|
||||
export const birthdayQueue = new Queue(birthdayQueueName, { connection: redis });
|
||||
export const statsQueueName = 'stats';
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"prisma:generate": "prisma generate --schema=../bot/prisma/schema.prisma"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emoji-mart/data": "^1.2.1",
|
||||
"@nexumi/shared": "workspace:^",
|
||||
"@prisma/client": "^5.22.0",
|
||||
"@radix-ui/react-avatar": "^1.2.3",
|
||||
|
||||
@@ -51,6 +51,7 @@ export async function GET(request: NextRequest) {
|
||||
cookieValue = await createSession({
|
||||
user,
|
||||
accessToken: token.access_token,
|
||||
refreshToken: token.refresh_token,
|
||||
tokenExpiresAt: Date.now() + token.expires_in * 1000
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
18
apps/webui/src/app/api/guilds/[guildId]/emojis/route.ts
Normal file
18
apps/webui/src/app/api/guilds/[guildId]/emojis/route.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth';
|
||||
import { listGuildEmojisForDashboard } from '@/lib/discord-guild-resources';
|
||||
|
||||
interface RouteParams {
|
||||
params: Promise<{ guildId: string }>;
|
||||
}
|
||||
|
||||
export async function GET(_request: NextRequest, { params }: RouteParams) {
|
||||
const { guildId } = await params;
|
||||
try {
|
||||
await requireGuildAccess(guildId);
|
||||
const emojis = await listGuildEmojisForDashboard(guildId);
|
||||
return NextResponse.json(emojis);
|
||||
} catch (error) {
|
||||
return toApiErrorResponse(error);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,11 @@ import { GiveawayActionSchema } from '@nexumi/shared';
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth';
|
||||
import { writeDashboardAudit } from '@/lib/audit';
|
||||
import { endGiveawayDashboard, setGiveawayPaused } from '@/lib/module-configs/giveaways';
|
||||
import {
|
||||
endGiveawayDashboard,
|
||||
GiveawayEndError,
|
||||
setGiveawayPaused
|
||||
} from '@/lib/module-configs/giveaways';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
interface RouteParams {
|
||||
@@ -39,6 +43,9 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
|
||||
return NextResponse.json(giveaway);
|
||||
} catch (error) {
|
||||
if (error instanceof GiveawayEndError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 502 });
|
||||
}
|
||||
return toApiErrorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,11 @@ import { SelfRolePanelDashboardUpdateSchema } from '@nexumi/shared';
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth';
|
||||
import { writeDashboardAudit } from '@/lib/audit';
|
||||
import { deleteSelfRolePanel, updateSelfRolePanel } from '@/lib/module-configs/selfroles';
|
||||
import {
|
||||
deleteSelfRolePanel,
|
||||
SelfRolePanelSyncError,
|
||||
updateSelfRolePanel
|
||||
} from '@/lib/module-configs/selfroles';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
interface RouteParams {
|
||||
@@ -31,6 +35,9 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
||||
|
||||
return NextResponse.json(updated);
|
||||
} catch (error) {
|
||||
if (error instanceof SelfRolePanelSyncError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 504 });
|
||||
}
|
||||
return toApiErrorResponse(error);
|
||||
}
|
||||
}
|
||||
@@ -54,6 +61,9 @@ export async function DELETE(_request: NextRequest, { params }: RouteParams) {
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
if (error instanceof SelfRolePanelSyncError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 504 });
|
||||
}
|
||||
return toApiErrorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,11 @@ import { SelfRolePanelDashboardCreateSchema } from '@nexumi/shared';
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth';
|
||||
import { writeDashboardAudit } from '@/lib/audit';
|
||||
import { createSelfRolePanel, listSelfRolePanels } from '@/lib/module-configs/selfroles';
|
||||
import {
|
||||
createSelfRolePanel,
|
||||
listSelfRolePanels,
|
||||
SelfRolePanelSyncError
|
||||
} from '@/lib/module-configs/selfroles';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
interface RouteParams {
|
||||
@@ -39,6 +43,9 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
|
||||
return NextResponse.json(panel, { status: 201 });
|
||||
} catch (error) {
|
||||
if (error instanceof SelfRolePanelSyncError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 504 });
|
||||
}
|
||||
return toApiErrorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { writeDashboardAudit } from '@/lib/audit';
|
||||
import {
|
||||
deleteTicketCategory,
|
||||
TicketCategoryConflictError,
|
||||
TicketPanelSyncError,
|
||||
updateTicketCategory
|
||||
} from '@/lib/module-configs/tickets';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
@@ -38,6 +39,12 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
||||
if (error instanceof TicketCategoryConflictError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 409 });
|
||||
}
|
||||
if (error instanceof TicketPanelSyncError) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message, code: error.code },
|
||||
{ status: error.code === 'timeout' ? 504 : 502 }
|
||||
);
|
||||
}
|
||||
return toApiErrorResponse(error);
|
||||
}
|
||||
}
|
||||
@@ -61,6 +68,12 @@ export async function DELETE(_request: NextRequest, { params }: RouteParams) {
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
if (error instanceof TicketPanelSyncError) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message, code: error.code },
|
||||
{ status: error.code === 'timeout' ? 504 : 502 }
|
||||
);
|
||||
}
|
||||
return toApiErrorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { TicketCategoryDashboardCreateSchema } from '@nexumi/shared';
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth';
|
||||
import { writeDashboardAudit } from '@/lib/audit';
|
||||
import { createTicketCategory, listTicketCategories, TicketCategoryConflictError } from '@/lib/module-configs/tickets';
|
||||
import { createTicketCategory, listTicketCategories, TicketCategoryConflictError, TicketPanelSyncError } from '@/lib/module-configs/tickets';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
interface RouteParams {
|
||||
@@ -42,6 +42,12 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
if (error instanceof TicketCategoryConflictError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 409 });
|
||||
}
|
||||
if (error instanceof TicketPanelSyncError) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message, code: error.code },
|
||||
{ status: error.code === 'timeout' ? 504 : 502 }
|
||||
);
|
||||
}
|
||||
return toApiErrorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { TicketDashboardActionSchema } from '@nexumi/shared';
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth';
|
||||
import { writeDashboardAudit } from '@/lib/audit';
|
||||
import {
|
||||
applyTicketDashboardAction,
|
||||
TicketActionSyncError
|
||||
} from '@/lib/module-configs/tickets';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
interface RouteParams {
|
||||
params: Promise<{ guildId: string; ticketId: string }>;
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
const { guildId, ticketId } = await params;
|
||||
try {
|
||||
const session = await requireGuildAccess(guildId);
|
||||
const body = await request.json();
|
||||
const action = TicketDashboardActionSchema.parse(body);
|
||||
|
||||
const after = await applyTicketDashboardAction(
|
||||
guildId,
|
||||
ticketId,
|
||||
action,
|
||||
session.user.id
|
||||
);
|
||||
if (!after) {
|
||||
return NextResponse.json({ error: 'Ticket not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
await writeDashboardAudit(prisma, {
|
||||
guildId,
|
||||
actorUserId: session.user.id,
|
||||
action: `tickets.${action.action}`,
|
||||
path: `/dashboard/${guildId}/tickets`,
|
||||
before: { ticketId },
|
||||
after
|
||||
});
|
||||
|
||||
return NextResponse.json(after);
|
||||
} catch (error) {
|
||||
if (error instanceof TicketActionSyncError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 504 });
|
||||
}
|
||||
return toApiErrorResponse(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth';
|
||||
import { listOpenTickets } from '@/lib/module-configs/tickets';
|
||||
|
||||
interface RouteParams {
|
||||
params: Promise<{ guildId: string }>;
|
||||
}
|
||||
|
||||
export async function GET(_request: NextRequest, { params }: RouteParams) {
|
||||
const { guildId } = await params;
|
||||
try {
|
||||
await requireGuildAccess(guildId);
|
||||
const tickets = await listOpenTickets(guildId);
|
||||
return NextResponse.json({ tickets });
|
||||
} catch (error) {
|
||||
return toApiErrorResponse(error);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,11 @@ import { TicketConfigDashboardPatchSchema } from '@nexumi/shared';
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth';
|
||||
import { writeDashboardAudit } from '@/lib/audit';
|
||||
import { getTicketConfigDashboard, updateTicketConfigDashboard } from '@/lib/module-configs/tickets';
|
||||
import {
|
||||
getTicketConfigDashboard,
|
||||
TicketPanelSyncError,
|
||||
updateTicketConfigDashboard
|
||||
} from '@/lib/module-configs/tickets';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
interface RouteParams {
|
||||
@@ -41,6 +45,12 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
||||
|
||||
return NextResponse.json(after);
|
||||
} catch (error) {
|
||||
if (error instanceof TicketPanelSyncError) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message, code: error.code },
|
||||
{ status: error.code === 'timeout' ? 504 : 502 }
|
||||
);
|
||||
}
|
||||
return toApiErrorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,11 @@ import { VerificationConfigDashboardPatchSchema } from '@nexumi/shared';
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth';
|
||||
import { writeDashboardAudit } from '@/lib/audit';
|
||||
import { getVerificationDashboard, updateVerificationDashboard } from '@/lib/module-configs/verification';
|
||||
import {
|
||||
getVerificationDashboard,
|
||||
updateVerificationDashboard,
|
||||
VerificationPanelSyncError
|
||||
} from '@/lib/module-configs/verification';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
interface RouteParams {
|
||||
@@ -45,6 +49,12 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
||||
|
||||
return NextResponse.json(after);
|
||||
} catch (error) {
|
||||
if (error instanceof VerificationPanelSyncError) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message, code: error.code },
|
||||
{ status: error.code === 'timeout' ? 504 : 502 }
|
||||
);
|
||||
}
|
||||
return toApiErrorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
36
apps/webui/src/app/api/owner/about/route.ts
Normal file
36
apps/webui/src/app/api/owner/about/route.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { BotAboutConfigPatchSchema } from '@nexumi/shared';
|
||||
import { toApiErrorResponse } from '@/lib/auth';
|
||||
import { getAboutConfig, updateAboutConfig } from '@/lib/owner-about';
|
||||
import { writeOwnerAudit } from '@/lib/owner-audit';
|
||||
import { requireOwner } from '@/lib/owner-auth';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
await requireOwner('VIEWER');
|
||||
return NextResponse.json(await getAboutConfig());
|
||||
} catch (error) {
|
||||
return toApiErrorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request) {
|
||||
try {
|
||||
const session = await requireOwner('ADMIN');
|
||||
const body = BotAboutConfigPatchSchema.parse(await request.json());
|
||||
const before = await getAboutConfig();
|
||||
const after = await updateAboutConfig(body);
|
||||
await writeOwnerAudit(prisma, {
|
||||
actorUserId: session.user.id,
|
||||
action: 'about.update',
|
||||
targetType: 'BotAboutConfig',
|
||||
targetId: 'singleton',
|
||||
before,
|
||||
after
|
||||
});
|
||||
return NextResponse.json(after);
|
||||
} catch (error) {
|
||||
return toApiErrorResponse(error);
|
||||
}
|
||||
}
|
||||
@@ -4,27 +4,39 @@ import { toApiErrorResponse } from '@/lib/auth';
|
||||
import { env } from '@/lib/env';
|
||||
import { writeOwnerAudit } from '@/lib/owner-audit';
|
||||
import { requireOwner } from '@/lib/owner-auth';
|
||||
import { createOwnerGuildInvite, enrichOwnerGuildListItems } from '@/lib/owner-guilds';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
await requireOwner('VIEWER');
|
||||
const q = new URL(request.url).searchParams.get('q')?.trim() ?? '';
|
||||
const q = new URL(request.url).searchParams.get('q')?.trim().toLowerCase() ?? '';
|
||||
const guilds = await prisma.guild.findMany({
|
||||
where: q ? { id: { contains: q } } : undefined,
|
||||
include: { settings: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 100
|
||||
take: 200
|
||||
});
|
||||
const blacklist = await prisma.guildBlacklist.findMany();
|
||||
const blacklisted = new Set(blacklist.map((entry) => entry.guildId));
|
||||
return NextResponse.json({
|
||||
guilds: guilds.map((guild) => ({
|
||||
const enriched = await enrichOwnerGuildListItems(
|
||||
guilds.map((guild) => ({
|
||||
id: guild.id,
|
||||
locale: guild.settings?.locale ?? 'de',
|
||||
createdAt: guild.createdAt.toISOString(),
|
||||
blacklisted: blacklisted.has(guild.id)
|
||||
})),
|
||||
}))
|
||||
);
|
||||
|
||||
const filtered =
|
||||
q.length === 0
|
||||
? enriched
|
||||
: enriched.filter(
|
||||
(guild) =>
|
||||
guild.id.includes(q) || (guild.name?.toLowerCase().includes(q) ?? false)
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
guilds: filtered,
|
||||
blacklist
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -34,12 +46,31 @@ export async function GET(request: Request) {
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await requireOwner('ADMIN');
|
||||
const body = (await request.json()) as { action?: string; guildId?: string; reason?: string };
|
||||
if (!body.guildId || !/^\d{17,20}$/.test(body.guildId)) {
|
||||
return NextResponse.json({ error: 'Invalid guildId' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (body.action === 'createInvite') {
|
||||
const session = await requireOwner('SUPPORT');
|
||||
try {
|
||||
const invite = await createOwnerGuildInvite(body.guildId);
|
||||
await writeOwnerAudit(prisma, {
|
||||
actorUserId: session.user.id,
|
||||
action: 'guilds.createInvite',
|
||||
targetType: 'Guild',
|
||||
targetId: body.guildId,
|
||||
after: invite
|
||||
});
|
||||
return NextResponse.json({ ok: true, ...invite });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Invite creation failed';
|
||||
return NextResponse.json({ error: message }, { status: 502 });
|
||||
}
|
||||
}
|
||||
|
||||
const session = await requireOwner('ADMIN');
|
||||
|
||||
if (body.action === 'leave') {
|
||||
const response = await fetch(`https://discord.com/api/v10/users/@me/guilds/${body.guildId}`, {
|
||||
method: 'DELETE',
|
||||
|
||||
@@ -2,7 +2,7 @@ import { notFound } from 'next/navigation';
|
||||
import type { ReactNode } from 'react';
|
||||
import { DashboardShell } from '@/components/layout/dashboard-shell';
|
||||
import { requireGuildAccessOrRedirect } from '@/lib/auth';
|
||||
import { getManageableGuilds } from '@/lib/guilds';
|
||||
import { getManageableGuilds, resolveDashboardGuild } from '@/lib/guilds';
|
||||
import { isOwnerUser } from '@/lib/owner-auth';
|
||||
|
||||
interface GuildLayoutProps {
|
||||
@@ -13,23 +13,26 @@ interface GuildLayoutProps {
|
||||
export default async function GuildLayout({ children, params }: GuildLayoutProps) {
|
||||
const { guildId } = await params;
|
||||
const session = await requireGuildAccessOrRedirect(guildId);
|
||||
const guilds = await getManageableGuilds(session);
|
||||
const currentGuild = guilds.find((guild) => guild.id === guildId);
|
||||
const [resolved, guilds, showOwnerLink] = await Promise.all([
|
||||
resolveDashboardGuild(session, guildId),
|
||||
getManageableGuilds(session),
|
||||
isOwnerUser(session.user.id)
|
||||
]);
|
||||
|
||||
if (!currentGuild) {
|
||||
if (!resolved) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const otherGuilds = guilds.filter((guild) => guild.id !== guildId);
|
||||
const showOwnerLink = await isOwnerUser(session.user.id);
|
||||
|
||||
return (
|
||||
<DashboardShell
|
||||
guildId={guildId}
|
||||
currentGuild={currentGuild}
|
||||
currentGuild={resolved.guild}
|
||||
otherGuilds={otherGuilds}
|
||||
user={session.user}
|
||||
showOwnerLink={showOwnerLink}
|
||||
ownerBypass={resolved.ownerBypass}
|
||||
>
|
||||
{children}
|
||||
</DashboardShell>
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { OpenTicketsManager } from '@/components/modules/open-tickets-manager';
|
||||
import { TicketCategoriesManager } from '@/components/modules/ticket-categories-manager';
|
||||
import { TicketConfigForm } from '@/components/modules/ticket-config-form';
|
||||
import { getLocale, t } from '@/lib/i18n';
|
||||
import { getTicketConfigDashboard, listTicketCategories } from '@/lib/module-configs/tickets';
|
||||
import {
|
||||
getTicketConfigDashboard,
|
||||
listOpenTickets,
|
||||
listTicketCategories
|
||||
} from '@/lib/module-configs/tickets';
|
||||
|
||||
interface TicketsPageProps {
|
||||
params: Promise<{ guildId: string }>;
|
||||
@@ -9,10 +14,11 @@ interface TicketsPageProps {
|
||||
|
||||
export default async function TicketsPage({ params }: TicketsPageProps) {
|
||||
const { guildId } = await params;
|
||||
const [locale, config, categories] = await Promise.all([
|
||||
const [locale, config, categories, openTickets] = await Promise.all([
|
||||
getLocale(),
|
||||
getTicketConfigDashboard(guildId),
|
||||
listTicketCategories(guildId)
|
||||
listTicketCategories(guildId),
|
||||
listOpenTickets(guildId)
|
||||
]);
|
||||
|
||||
return (
|
||||
@@ -21,6 +27,7 @@ export default async function TicketsPage({ params }: TicketsPageProps) {
|
||||
<h1 className="text-2xl font-semibold">{t(locale, 'modules.tickets.label')}</h1>
|
||||
<p className="text-sm text-muted-foreground">{t(locale, 'modules.tickets.description')}</p>
|
||||
</div>
|
||||
<OpenTicketsManager guildId={guildId} initialTickets={openTickets} />
|
||||
<TicketConfigForm guildId={guildId} initialValue={config} />
|
||||
<TicketCategoriesManager guildId={guildId} initialCategories={categories} />
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import { WelcomeForm } from '@/components/modules/welcome-form';
|
||||
import { requireGuildAccessOrRedirect } from '@/lib/auth';
|
||||
import { resolveDashboardGuild } from '@/lib/guilds';
|
||||
import { getLocale, t } from '@/lib/i18n';
|
||||
import { getWelcomeDashboard } from '@/lib/module-configs/welcome';
|
||||
import { buildWelcomePreviewVars } from '@/lib/welcome-preview-vars';
|
||||
|
||||
interface WelcomePageProps {
|
||||
params: Promise<{ guildId: string }>;
|
||||
@@ -8,7 +12,16 @@ interface WelcomePageProps {
|
||||
|
||||
export default async function WelcomePage({ params }: WelcomePageProps) {
|
||||
const { guildId } = await params;
|
||||
const [locale, config] = await Promise.all([getLocale(), getWelcomeDashboard(guildId)]);
|
||||
const session = await requireGuildAccessOrRedirect(guildId);
|
||||
const [locale, config, resolved] = await Promise.all([
|
||||
getLocale(),
|
||||
getWelcomeDashboard(guildId),
|
||||
resolveDashboardGuild(session, guildId)
|
||||
]);
|
||||
|
||||
if (!resolved) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -16,7 +29,11 @@ export default async function WelcomePage({ params }: WelcomePageProps) {
|
||||
<h1 className="text-2xl font-semibold">{t(locale, 'modules.welcome.label')}</h1>
|
||||
<p className="text-sm text-muted-foreground">{t(locale, 'modules.welcome.description')}</p>
|
||||
</div>
|
||||
<WelcomeForm guildId={guildId} initialValue={config} />
|
||||
<WelcomeForm
|
||||
guildId={guildId}
|
||||
initialValue={config}
|
||||
previewVars={buildWelcomePreviewVars(session.user, resolved.guild)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--background: #f8f8fb;
|
||||
--foreground: #111116;
|
||||
--card: #ffffff;
|
||||
@@ -26,6 +27,7 @@
|
||||
}
|
||||
|
||||
.dark {
|
||||
color-scheme: dark;
|
||||
--background: #0a0a0d;
|
||||
--foreground: #e6e6ea;
|
||||
--card: #131318;
|
||||
|
||||
21
apps/webui/src/app/owner/about/page.tsx
Normal file
21
apps/webui/src/app/owner/about/page.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { ownerRoleAtLeast } from '@nexumi/shared';
|
||||
import { AboutForm } from '@/components/owner/about-form';
|
||||
import { getLocale, t } from '@/lib/i18n';
|
||||
import { getAboutConfig } from '@/lib/owner-about';
|
||||
import { requireOwnerOrRedirect } from '@/lib/owner-auth';
|
||||
|
||||
export default async function OwnerAboutPage() {
|
||||
const session = await requireOwnerOrRedirect('VIEWER');
|
||||
const [locale, config] = await Promise.all([getLocale(), getAboutConfig()]);
|
||||
const canEdit = ownerRoleAtLeast(session.ownerRole, 'ADMIN');
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">{t(locale, 'owner.about.title')}</h1>
|
||||
<p className="text-sm text-muted-foreground">{t(locale, 'owner.about.subtitle')}</p>
|
||||
</div>
|
||||
<AboutForm initialValue={config} canEdit={canEdit} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { GuildsManager } from '@/components/owner/owner-forms';
|
||||
import { getLocale, t } from '@/lib/i18n';
|
||||
import { enrichOwnerGuildListItems } from '@/lib/owner-guilds';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
export default async function OwnerGuildsPage() {
|
||||
@@ -13,6 +14,14 @@ export default async function OwnerGuildsPage() {
|
||||
prisma.guildBlacklist.findMany()
|
||||
]);
|
||||
const blacklisted = new Set(blacklist.map((entry) => entry.guildId));
|
||||
const initialGuilds = await enrichOwnerGuildListItems(
|
||||
guilds.map((guild) => ({
|
||||
id: guild.id,
|
||||
locale: guild.settings?.locale ?? 'de',
|
||||
createdAt: guild.createdAt.toISOString(),
|
||||
blacklisted: blacklisted.has(guild.id)
|
||||
}))
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -20,14 +29,7 @@ export default async function OwnerGuildsPage() {
|
||||
<h1 className="text-2xl font-semibold">{t(locale, 'owner.guilds.title')}</h1>
|
||||
<p className="text-sm text-muted-foreground">{t(locale, 'owner.guilds.subtitle')}</p>
|
||||
</div>
|
||||
<GuildsManager
|
||||
initialGuilds={guilds.map((guild) => ({
|
||||
id: guild.id,
|
||||
locale: guild.settings?.locale ?? 'de',
|
||||
createdAt: guild.createdAt.toISOString(),
|
||||
blacklisted: blacklisted.has(guild.id)
|
||||
}))}
|
||||
/>
|
||||
<GuildsManager initialGuilds={initialGuilds} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
import { PresenceForm } from '@/components/owner/owner-forms';
|
||||
import { ownerRoleAtLeast } from '@nexumi/shared';
|
||||
import { PresenceForm } from '@/components/owner/presence-form';
|
||||
import { getLocale, t } from '@/lib/i18n';
|
||||
import { requireOwnerOrRedirect } from '@/lib/owner-auth';
|
||||
import { getPresenceConfig } from '@/lib/owner-presence';
|
||||
|
||||
export default async function OwnerPresencePage() {
|
||||
const session = await requireOwnerOrRedirect('VIEWER');
|
||||
const [locale, config] = await Promise.all([getLocale(), getPresenceConfig()]);
|
||||
const canEdit = ownerRoleAtLeast(session.ownerRole, 'ADMIN');
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">{t(locale, 'owner.presence.title')}</h1>
|
||||
<p className="text-sm text-muted-foreground">{t(locale, 'owner.presence.subtitle')}</p>
|
||||
</div>
|
||||
<PresenceForm initialValue={config} />
|
||||
<PresenceForm initialValue={config} canEdit={canEdit} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ interface DashboardShellProps {
|
||||
otherGuilds: DashboardGuild[];
|
||||
user: SessionUser;
|
||||
showOwnerLink?: boolean;
|
||||
/** True when an Owner-panel user opened a guild they do not manage on Discord. */
|
||||
ownerBypass?: boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
@@ -27,6 +29,7 @@ export function DashboardShell({
|
||||
otherGuilds,
|
||||
user,
|
||||
showOwnerLink = false,
|
||||
ownerBypass = false,
|
||||
children
|
||||
}: DashboardShellProps) {
|
||||
const t = useTranslations();
|
||||
@@ -59,7 +62,21 @@ export function DashboardShell({
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex-1 px-4 py-6 sm:px-6 sm:py-8">
|
||||
<div className="mx-auto w-full max-w-[1200px]">{children}</div>
|
||||
<div className="mx-auto w-full max-w-[1200px] space-y-4">
|
||||
{ownerBypass ? (
|
||||
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 px-4 py-3 text-sm text-amber-100">
|
||||
<p className="font-medium">{t('dashboard.ownerBypass.title')}</p>
|
||||
<p className="mt-1 text-amber-100/80">{t('dashboard.ownerBypass.description')}</p>
|
||||
<a
|
||||
href="/owner/guilds"
|
||||
className="mt-2 inline-block text-sm font-medium text-amber-50 underline underline-offset-4 hover:text-white"
|
||||
>
|
||||
{t('dashboard.ownerBypass.backToOwner')}
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,8 +6,10 @@ import type {
|
||||
AutomodConfigDashboard,
|
||||
AutomodRuleDashboard
|
||||
} from '@nexumi/shared';
|
||||
import { mergeDefaultBlockedWords } from '@nexumi/shared';
|
||||
import { FieldAnchor } from '@/components/layout/field-anchor';
|
||||
import { useTranslations } from '@/components/locale-provider';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { DiscordChannelMultiSelect } from '@/components/ui/discord-channel-select';
|
||||
import { DiscordRoleMultiSelect } from '@/components/ui/discord-role-select';
|
||||
@@ -25,7 +27,12 @@ async function saveAutomod(guildId: string, value: AutomodConfigDashboard): Prom
|
||||
try {
|
||||
const cleaned: AutomodConfigDashboard = {
|
||||
...value,
|
||||
rules: value.rules.map((rule) => ({
|
||||
rules: value.rules.map((rule) => {
|
||||
const words = (rule.wordList.words ?? []).map((s) => s.trim()).filter(Boolean);
|
||||
const regexPatterns = (rule.wordList.regexPatterns ?? [])
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
return {
|
||||
...rule,
|
||||
durationMs: rule.action === 'TIMEOUT' ? rule.durationMs ?? 60_000 : null,
|
||||
threshold: {
|
||||
@@ -34,10 +41,12 @@ async function saveAutomod(guildId: string, value: AutomodConfigDashboard): Prom
|
||||
linkBlacklist: (rule.threshold.linkBlacklist ?? []).map((s) => s.trim()).filter(Boolean)
|
||||
},
|
||||
wordList: {
|
||||
words: (rule.wordList.words ?? []).map((s) => s.trim()).filter(Boolean),
|
||||
regexPatterns: (rule.wordList.regexPatterns ?? []).map((s) => s.trim()).filter(Boolean)
|
||||
words,
|
||||
regexPatterns,
|
||||
...(words.length === 0 ? { userCleared: true as const } : {})
|
||||
}
|
||||
}))
|
||||
};
|
||||
})
|
||||
};
|
||||
const response = await fetch(`/api/guilds/${guildId}/automod`, {
|
||||
method: 'PATCH',
|
||||
@@ -279,7 +288,24 @@ function RuleCard({
|
||||
{type === 'WORD_FILTER' ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<Label>{t('modulePages.automod.wordList')}</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
onChange({
|
||||
wordList: {
|
||||
...rule.wordList,
|
||||
words: mergeDefaultBlockedWords(rule.wordList.words ?? [])
|
||||
}
|
||||
})
|
||||
}
|
||||
>
|
||||
{t('modulePages.automod.loadDefaultWords')}
|
||||
</Button>
|
||||
</div>
|
||||
<StringListEditor
|
||||
value={rule.wordList.words}
|
||||
onChange={(words) => onChange({ wordList: { ...rule.wordList, words } })}
|
||||
|
||||
@@ -42,7 +42,8 @@ const LOG_EVENT_TYPES: LogEventTypeDashboard[] = [
|
||||
'THREAD_UPDATE',
|
||||
'THREAD_DELETE',
|
||||
'GUILD_UPDATE',
|
||||
'MOD_ACTION'
|
||||
'MOD_ACTION',
|
||||
'VERIFICATION_FAIL'
|
||||
];
|
||||
|
||||
interface LocalLogChannelGroup {
|
||||
|
||||
237
apps/webui/src/components/modules/open-tickets-manager.tsx
Normal file
237
apps/webui/src/components/modules/open-tickets-manager.tsx
Normal file
@@ -0,0 +1,237 @@
|
||||
'use client';
|
||||
|
||||
import type { TicketDashboard, TicketPriority } from '@nexumi/shared';
|
||||
import { ExternalLink, RefreshCw } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { FieldAnchor } from '@/components/layout/field-anchor';
|
||||
import { useTranslations } from '@/components/locale-provider';
|
||||
import { Badge, type BadgeProps } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
|
||||
const STATUS_VARIANTS: Record<'OPEN' | 'CLAIMED', BadgeProps['variant']> = {
|
||||
OPEN: 'secondary',
|
||||
CLAIMED: 'success'
|
||||
};
|
||||
|
||||
const PRIORITY_VARIANTS: Record<TicketPriority, BadgeProps['variant']> = {
|
||||
LOW: 'outline',
|
||||
NORMAL: 'secondary',
|
||||
HIGH: 'default',
|
||||
URGENT: 'destructive'
|
||||
};
|
||||
|
||||
const PRIORITIES: TicketPriority[] = ['LOW', 'NORMAL', 'HIGH', 'URGENT'];
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });
|
||||
}
|
||||
|
||||
function discordChannelUrl(guildId: string, ticket: TicketDashboard): string | null {
|
||||
const channelId = ticket.channelId ?? ticket.threadId;
|
||||
if (!channelId) {
|
||||
return null;
|
||||
}
|
||||
return `https://discord.com/channels/${guildId}/${channelId}`;
|
||||
}
|
||||
|
||||
interface OpenTicketsManagerProps {
|
||||
guildId: string;
|
||||
initialTickets: TicketDashboard[];
|
||||
}
|
||||
|
||||
export function OpenTicketsManager({ guildId, initialTickets }: OpenTicketsManagerProps) {
|
||||
const t = useTranslations();
|
||||
const [tickets, setTickets] = useState<TicketDashboard[]>(initialTickets);
|
||||
const [pendingId, setPendingId] = useState<string | null>(null);
|
||||
const [reasons, setReasons] = useState<Record<string, string>>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function reload() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(`/api/guilds/${guildId}/tickets/open`);
|
||||
const body = (await response.json().catch(() => null)) as { tickets?: TicketDashboard[] } | null;
|
||||
if (response.ok && body?.tickets) {
|
||||
setTickets(body.tickets);
|
||||
} else {
|
||||
toast.error(t('common.saveError'));
|
||||
}
|
||||
} catch {
|
||||
toast.error(t('common.networkError'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function runAction(
|
||||
ticket: TicketDashboard,
|
||||
payload:
|
||||
| { action: 'close'; reason?: string }
|
||||
| { action: 'claim' }
|
||||
| { action: 'priority'; priority: TicketPriority }
|
||||
) {
|
||||
setPendingId(ticket.id);
|
||||
try {
|
||||
const response = await fetch(`/api/guilds/${guildId}/tickets/open/${ticket.id}/action`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
const body = (await response.json().catch(() => null)) as
|
||||
| (TicketDashboard & { error?: string })
|
||||
| null;
|
||||
if (!response.ok || !body) {
|
||||
toast.error(body?.error ?? t('common.saveError'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.action === 'close') {
|
||||
setTickets((prev) => prev.filter((entry) => entry.id !== ticket.id));
|
||||
toast.success(t('modulePages.tickets.openList.closed'));
|
||||
} else {
|
||||
setTickets((prev) => prev.map((entry) => (entry.id === ticket.id ? body : entry)));
|
||||
toast.success(t('common.saveSuccess'));
|
||||
}
|
||||
} catch {
|
||||
toast.error(t('common.networkError'));
|
||||
} finally {
|
||||
setPendingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<FieldAnchor id="field-tickets-open">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<CardTitle>{t('modulePages.tickets.openList.title')}</CardTitle>
|
||||
<CardDescription>{t('modulePages.tickets.openList.description')}</CardDescription>
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="sm" onClick={reload} disabled={loading}>
|
||||
<RefreshCw className={`size-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
{t('modulePages.tickets.openList.refresh')}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{!loading && tickets.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">{t('modulePages.tickets.openList.empty')}</p>
|
||||
)}
|
||||
{tickets.map((ticket) => {
|
||||
const channelUrl = discordChannelUrl(guildId, ticket);
|
||||
const busy = pendingId === ticket.id;
|
||||
const statusKey = ticket.status === 'CLAIMED' ? 'CLAIMED' : 'OPEN';
|
||||
|
||||
return (
|
||||
<div key={ticket.id} className="space-y-3 rounded-md border border-border p-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<p className="font-medium">
|
||||
{ticket.subject?.trim() || t('modulePages.tickets.openList.noSubject')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{ticket.categoryName
|
||||
? t('modulePages.tickets.openList.categoryLine', {
|
||||
category: ticket.categoryName
|
||||
})
|
||||
: t('modulePages.tickets.openList.noCategory')}
|
||||
{' · '}
|
||||
<@{ticket.openerId}>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -29,10 +29,12 @@ import { Textarea } from '@/components/ui/textarea';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type ScheduleContentMode = 'text' | 'embed' | 'components_v2';
|
||||
type ScheduleTimingMode = 'once' | 'cron';
|
||||
|
||||
const EMPTY_DRAFT = {
|
||||
channelId: '',
|
||||
contentMode: 'text' as ScheduleContentMode,
|
||||
timingMode: 'once' as ScheduleTimingMode,
|
||||
content: '',
|
||||
embed: null as WelcomeEmbed | null,
|
||||
components: null as MessageComponentsV2 | null,
|
||||
@@ -76,6 +78,19 @@ function schedulePreview(schedule: ScheduledMessageDashboard, t: (key: string) =
|
||||
return t('modulePages.scheduler.noContent');
|
||||
}
|
||||
|
||||
function scheduleErrorMessage(code: string | undefined, t: (key: string) => string): string {
|
||||
if (code === 'invalid_cron') {
|
||||
return t('modulePages.scheduler.errors.invalidCron');
|
||||
}
|
||||
if (code === 'invalid_when') {
|
||||
return t('modulePages.scheduler.errors.invalidWhen');
|
||||
}
|
||||
if (code === 'missing_schedule_time') {
|
||||
return t('modulePages.scheduler.errors.missingScheduleTime');
|
||||
}
|
||||
return code?.trim() ? code : t('common.saveError');
|
||||
}
|
||||
|
||||
interface SchedulerManagerProps {
|
||||
guildId: string;
|
||||
initialSchedules: ScheduledMessageDashboard[];
|
||||
@@ -98,7 +113,10 @@ export function SchedulerManager({ guildId, initialSchedules }: SchedulerManager
|
||||
(draft.contentMode === 'embed' && (embedHasContent(embed) || embed?.color !== undefined)) ||
|
||||
(draft.contentMode === 'components_v2' && componentsV2HasContent(components));
|
||||
|
||||
if (!draft.channelId.trim() || !hasContent || (!draft.cron.trim() && !draft.runAt)) {
|
||||
const cron = draft.timingMode === 'cron' ? draft.cron.trim() : '';
|
||||
const runAt = draft.timingMode === 'once' ? draft.runAt.trim() : '';
|
||||
|
||||
if (!draft.channelId.trim() || !hasContent || (!cron && !runAt)) {
|
||||
toast.error(t('modulePages.scheduler.formIncomplete'));
|
||||
return;
|
||||
}
|
||||
@@ -114,15 +132,16 @@ export function SchedulerManager({ guildId, initialSchedules }: SchedulerManager
|
||||
embed,
|
||||
components,
|
||||
rolePingId: draft.rolePingId.trim() || undefined,
|
||||
cron: draft.cron.trim() || null,
|
||||
runAt: draft.cron.trim() ? null : draft.runAt ? new Date(draft.runAt).toISOString() : null
|
||||
// Send naive datetime-local; server interprets it in the guild timezone.
|
||||
cron: cron || null,
|
||||
runAt: cron ? null : runAt || null
|
||||
})
|
||||
});
|
||||
const body = (await response.json().catch(() => null)) as
|
||||
| (ScheduledMessageDashboard & { error?: string })
|
||||
| null;
|
||||
if (!response.ok || !body) {
|
||||
toast.error(body?.error ?? t('common.saveError'));
|
||||
toast.error(scheduleErrorMessage(body?.error, t));
|
||||
return;
|
||||
}
|
||||
setSchedules((prev) => [...prev, body]);
|
||||
@@ -179,24 +198,55 @@ export function SchedulerManager({ guildId, initialSchedules }: SchedulerManager
|
||||
placeholder={t('common.optional')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.scheduler.cron')}</Label>
|
||||
<Label>{t('modulePages.scheduler.timingMode')}</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(['once', 'cron'] as ScheduleTimingMode[]).map((mode) => (
|
||||
<Button
|
||||
key={mode}
|
||||
type="button"
|
||||
variant={draft.timingMode === mode ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className={cn(draft.timingMode !== mode && 'text-muted-foreground')}
|
||||
onClick={() =>
|
||||
setDraft((prev) => ({
|
||||
...prev,
|
||||
timingMode: mode,
|
||||
cron: mode === 'cron' ? prev.cron : '',
|
||||
runAt: mode === 'once' ? prev.runAt : ''
|
||||
}))
|
||||
}
|
||||
>
|
||||
{t(`modulePages.scheduler.timingModes.${mode}`)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{draft.timingMode === 'cron' ? (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="scheduler-cron">{t('modulePages.scheduler.cron')}</Label>
|
||||
<Input
|
||||
id="scheduler-cron"
|
||||
value={draft.cron}
|
||||
onChange={(event) => setDraft((prev) => ({ ...prev, cron: event.target.value }))}
|
||||
placeholder="0 9 * * *"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.scheduler.runAt')}</Label>
|
||||
<Label htmlFor="scheduler-run-at">{t('modulePages.scheduler.runAt')}</Label>
|
||||
<Input
|
||||
id="scheduler-run-at"
|
||||
type="datetime-local"
|
||||
value={draft.runAt}
|
||||
disabled={Boolean(draft.cron.trim())}
|
||||
onChange={(event) => setDraft((prev) => ({ ...prev, runAt: event.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.scheduler.contentMode')}</Label>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import type { SelfRoleBehavior, SelfRoleEntry, SelfRoleMode, SelfRolePanelDashboard } from '@nexumi/shared';
|
||||
import type { SelfRoleBehavior, SelfRoleMode, SelfRolePanelDashboard } from '@nexumi/shared';
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
@@ -12,35 +12,20 @@ import { DiscordChannelSelect } from '@/components/ui/discord-channel-select';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
builderRowsToRoles,
|
||||
emptyBuilderRow,
|
||||
rolesToBuilderRows,
|
||||
type SelfRoleBuilderRow,
|
||||
SelfRoleEntriesBuilder
|
||||
} from '@/components/ui/self-role-entries-builder';
|
||||
|
||||
const MODES: SelfRoleMode[] = ['BUTTONS', 'DROPDOWN', 'REACTIONS'];
|
||||
const BEHAVIORS: SelfRoleBehavior[] = ['NORMAL', 'UNIQUE', 'VERIFY', 'TEMPORARY'];
|
||||
|
||||
function rolesToText(roles: SelfRoleEntry[]): string {
|
||||
return roles.map((role) => [role.roleId, role.label, role.emoji ?? '', role.description ?? ''].join(' | ')).join('\n');
|
||||
}
|
||||
|
||||
function textToRoles(text: string): SelfRoleEntry[] {
|
||||
return text
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0)
|
||||
.map((line) => {
|
||||
const [roleId, label, emoji, description] = line.split('|').map((part) => part.trim());
|
||||
return {
|
||||
roleId: roleId ?? '',
|
||||
label: label || roleId || '',
|
||||
emoji: emoji || undefined,
|
||||
description: description || undefined
|
||||
};
|
||||
})
|
||||
.filter((role) => role.roleId.length > 0);
|
||||
}
|
||||
|
||||
interface LocalPanel extends SelfRolePanelDashboard {
|
||||
interface LocalPanel extends Omit<SelfRolePanelDashboard, 'roles'> {
|
||||
clientKey: string;
|
||||
rolesText: string;
|
||||
roles: SelfRoleBuilderRow[];
|
||||
saving?: boolean;
|
||||
}
|
||||
|
||||
@@ -50,7 +35,7 @@ const EMPTY_DRAFT = {
|
||||
description: '',
|
||||
mode: 'BUTTONS' as SelfRoleMode,
|
||||
behavior: 'NORMAL' as SelfRoleBehavior,
|
||||
rolesText: ''
|
||||
roles: [emptyBuilderRow()] as SelfRoleBuilderRow[]
|
||||
};
|
||||
|
||||
interface SelfRolesManagerProps {
|
||||
@@ -58,24 +43,43 @@ interface SelfRolesManagerProps {
|
||||
initialPanels: SelfRolePanelDashboard[];
|
||||
}
|
||||
|
||||
function toastBuilderError(
|
||||
t: ReturnType<typeof useTranslations>,
|
||||
error: 'incomplete' | 'emojiRequired' | 'tooMany'
|
||||
) {
|
||||
if (error === 'emojiRequired') {
|
||||
toast.error(t('modulePages.selfroles.emojiRequired'));
|
||||
return;
|
||||
}
|
||||
if (error === 'tooMany') {
|
||||
toast.error(t('modulePages.selfroles.tooManyRoles'));
|
||||
return;
|
||||
}
|
||||
toast.error(t('modulePages.selfroles.formIncomplete'));
|
||||
}
|
||||
|
||||
export function SelfRolesManager({ guildId, initialPanels }: SelfRolesManagerProps) {
|
||||
const t = useTranslations();
|
||||
const [panels, setPanels] = useState<LocalPanel[]>(
|
||||
initialPanels.map((panel, index) => ({
|
||||
...panel,
|
||||
clientKey: panel.id ?? `existing-${index}`,
|
||||
rolesText: rolesToText(panel.roles)
|
||||
roles: rolesToBuilderRows(panel.roles)
|
||||
}))
|
||||
);
|
||||
const [draft, setDraft] = useState(EMPTY_DRAFT);
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
async function handleCreate() {
|
||||
const roles = textToRoles(draft.rolesText);
|
||||
if (!draft.channelId.trim() || !draft.title.trim() || roles.length === 0) {
|
||||
if (!draft.channelId.trim() || !draft.title.trim()) {
|
||||
toast.error(t('modulePages.selfroles.formIncomplete'));
|
||||
return;
|
||||
}
|
||||
const parsed = builderRowsToRoles(draft.roles, draft.mode === 'REACTIONS');
|
||||
if (parsed.error || !parsed.value.length) {
|
||||
toastBuilderError(t, parsed.error ?? 'incomplete');
|
||||
return;
|
||||
}
|
||||
setCreating(true);
|
||||
try {
|
||||
const response = await fetch(`/api/guilds/${guildId}/selfroles`, {
|
||||
@@ -87,7 +91,7 @@ export function SelfRolesManager({ guildId, initialPanels }: SelfRolesManagerPro
|
||||
description: draft.description.trim() || null,
|
||||
mode: draft.mode,
|
||||
behavior: draft.behavior,
|
||||
roles
|
||||
roles: parsed.value
|
||||
})
|
||||
});
|
||||
const body = (await response.json().catch(() => null)) as (SelfRolePanelDashboard & { error?: string }) | null;
|
||||
@@ -96,10 +100,14 @@ export function SelfRolesManager({ guildId, initialPanels }: SelfRolesManagerPro
|
||||
return;
|
||||
}
|
||||
setPanels((prev) => [
|
||||
{ ...body, clientKey: body.id ?? `new-${Date.now()}`, rolesText: rolesToText(body.roles) },
|
||||
{
|
||||
...body,
|
||||
clientKey: body.id ?? `new-${Date.now()}`,
|
||||
roles: rolesToBuilderRows(body.roles)
|
||||
},
|
||||
...prev
|
||||
]);
|
||||
setDraft(EMPTY_DRAFT);
|
||||
setDraft({ ...EMPTY_DRAFT, roles: [emptyBuilderRow()] });
|
||||
toast.success(t('common.saveSuccess'));
|
||||
} catch {
|
||||
toast.error(t('common.saveError'));
|
||||
@@ -109,6 +117,14 @@ export function SelfRolesManager({ guildId, initialPanels }: SelfRolesManagerPro
|
||||
}
|
||||
|
||||
async function handleSave(panel: LocalPanel) {
|
||||
if (!panel.id) {
|
||||
return;
|
||||
}
|
||||
const parsed = builderRowsToRoles(panel.roles, panel.mode === 'REACTIONS');
|
||||
if (parsed.error || !parsed.value.length) {
|
||||
toastBuilderError(t, parsed.error ?? 'incomplete');
|
||||
return;
|
||||
}
|
||||
setPanels((prev) => prev.map((entry) => (entry.clientKey === panel.clientKey ? { ...entry, saving: true } : entry)));
|
||||
try {
|
||||
const response = await fetch(`/api/guilds/${guildId}/selfroles/${panel.id}`, {
|
||||
@@ -120,7 +136,7 @@ export function SelfRolesManager({ guildId, initialPanels }: SelfRolesManagerPro
|
||||
description: panel.description,
|
||||
mode: panel.mode,
|
||||
behavior: panel.behavior,
|
||||
roles: textToRoles(panel.rolesText)
|
||||
roles: parsed.value
|
||||
})
|
||||
});
|
||||
const body = (await response.json().catch(() => null)) as (SelfRolePanelDashboard & { error?: string }) | null;
|
||||
@@ -128,11 +144,25 @@ export function SelfRolesManager({ guildId, initialPanels }: SelfRolesManagerPro
|
||||
toast.error(body?.error ?? t('common.saveError'));
|
||||
return;
|
||||
}
|
||||
setPanels((prev) =>
|
||||
prev.map((entry) =>
|
||||
entry.clientKey === panel.clientKey
|
||||
? {
|
||||
...entry,
|
||||
...body,
|
||||
roles: rolesToBuilderRows(body.roles),
|
||||
saving: false
|
||||
}
|
||||
: entry
|
||||
)
|
||||
);
|
||||
toast.success(t('common.saveSuccess'));
|
||||
} catch {
|
||||
toast.error(t('common.saveError'));
|
||||
} finally {
|
||||
setPanels((prev) => prev.map((entry) => (entry.clientKey === panel.clientKey ? { ...entry, saving: false } : entry)));
|
||||
setPanels((prev) =>
|
||||
prev.map((entry) => (entry.clientKey === panel.clientKey ? { ...entry, saving: false } : entry))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,11 +203,17 @@ export function SelfRolesManager({ guildId, initialPanels }: SelfRolesManagerPro
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.selfroles.panelTitle')}</Label>
|
||||
<Input value={draft.title} onChange={(event) => setDraft((prev) => ({ ...prev, title: event.target.value }))} />
|
||||
<Input
|
||||
value={draft.title}
|
||||
onChange={(event) => setDraft((prev) => ({ ...prev, title: event.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.selfroles.mode')}</Label>
|
||||
<Select value={draft.mode} onValueChange={(mode) => setDraft((prev) => ({ ...prev, mode: mode as SelfRoleMode }))}>
|
||||
<Select
|
||||
value={draft.mode}
|
||||
onValueChange={(mode) => setDraft((prev) => ({ ...prev, mode: mode as SelfRoleMode }))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
@@ -192,7 +228,12 @@ export function SelfRolesManager({ guildId, initialPanels }: SelfRolesManagerPro
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.selfroles.behavior')}</Label>
|
||||
<Select value={draft.behavior} onValueChange={(behavior) => setDraft((prev) => ({ ...prev, behavior: behavior as SelfRoleBehavior }))}>
|
||||
<Select
|
||||
value={draft.behavior}
|
||||
onValueChange={(behavior) =>
|
||||
setDraft((prev) => ({ ...prev, behavior: behavior as SelfRoleBehavior }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
@@ -208,17 +249,19 @@ export function SelfRolesManager({ guildId, initialPanels }: SelfRolesManagerPro
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.selfroles.description')}</Label>
|
||||
<Input value={draft.description} onChange={(event) => setDraft((prev) => ({ ...prev, description: event.target.value }))} />
|
||||
<Input
|
||||
value={draft.description}
|
||||
onChange={(event) => setDraft((prev) => ({ ...prev, description: event.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.selfroles.roles')}</Label>
|
||||
<Textarea
|
||||
rows={5}
|
||||
value={draft.rolesText}
|
||||
onChange={(event) => setDraft((prev) => ({ ...prev, rolesText: event.target.value }))}
|
||||
placeholder={t('modulePages.selfroles.rolesPlaceholder')}
|
||||
<SelfRoleEntriesBuilder
|
||||
guildId={guildId}
|
||||
value={draft.roles}
|
||||
requireEmoji={draft.mode === 'REACTIONS'}
|
||||
onChange={(roles) => setDraft((prev) => ({ ...prev, roles }))}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t('modulePages.selfroles.rolesHint')}</p>
|
||||
</div>
|
||||
<Button type="button" onClick={handleCreate} disabled={creating}>
|
||||
<Plus className="size-4" />
|
||||
@@ -229,14 +272,15 @@ export function SelfRolesManager({ guildId, initialPanels }: SelfRolesManagerPro
|
||||
</FieldAnchor>
|
||||
|
||||
<FieldAnchor id="field-selfroles-list">
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('modulePages.selfroles.listTitle')}</CardTitle>
|
||||
<CardDescription>{t('modulePages.selfroles.listDescription')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{panels.length === 0 && <p className="text-sm text-muted-foreground">{t('modulePages.selfroles.empty')}</p>}
|
||||
{panels.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">{t('modulePages.selfroles.empty')}</p>
|
||||
)}
|
||||
{panels.map((panel) => (
|
||||
<div key={panel.clientKey} className="space-y-3 rounded-md border border-border p-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
@@ -247,7 +291,9 @@ export function SelfRolesManager({ guildId, initialPanels }: SelfRolesManagerPro
|
||||
value={panel.channelId}
|
||||
onChange={(channelId) =>
|
||||
setPanels((prev) =>
|
||||
prev.map((entry) => (entry.clientKey === panel.clientKey ? { ...entry, channelId } : entry))
|
||||
prev.map((entry) =>
|
||||
entry.clientKey === panel.clientKey ? { ...entry, channelId } : entry
|
||||
)
|
||||
)
|
||||
}
|
||||
/>
|
||||
@@ -257,7 +303,13 @@ export function SelfRolesManager({ guildId, initialPanels }: SelfRolesManagerPro
|
||||
<Input
|
||||
value={panel.title}
|
||||
onChange={(event) =>
|
||||
setPanels((prev) => prev.map((entry) => (entry.clientKey === panel.clientKey ? { ...entry, title: event.target.value } : entry)))
|
||||
setPanels((prev) =>
|
||||
prev.map((entry) =>
|
||||
entry.clientKey === panel.clientKey
|
||||
? { ...entry, title: event.target.value }
|
||||
: entry
|
||||
)
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
@@ -266,7 +318,13 @@ export function SelfRolesManager({ guildId, initialPanels }: SelfRolesManagerPro
|
||||
<Select
|
||||
value={panel.mode}
|
||||
onValueChange={(mode) =>
|
||||
setPanels((prev) => prev.map((entry) => (entry.clientKey === panel.clientKey ? { ...entry, mode: mode as SelfRoleMode } : entry)))
|
||||
setPanels((prev) =>
|
||||
prev.map((entry) =>
|
||||
entry.clientKey === panel.clientKey
|
||||
? { ...entry, mode: mode as SelfRoleMode }
|
||||
: entry
|
||||
)
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
@@ -287,7 +345,11 @@ export function SelfRolesManager({ guildId, initialPanels }: SelfRolesManagerPro
|
||||
value={panel.behavior}
|
||||
onValueChange={(behavior) =>
|
||||
setPanels((prev) =>
|
||||
prev.map((entry) => (entry.clientKey === panel.clientKey ? { ...entry, behavior: behavior as SelfRoleBehavior } : entry))
|
||||
prev.map((entry) =>
|
||||
entry.clientKey === panel.clientKey
|
||||
? { ...entry, behavior: behavior as SelfRoleBehavior }
|
||||
: entry
|
||||
)
|
||||
)
|
||||
}
|
||||
>
|
||||
@@ -306,11 +368,17 @@ export function SelfRolesManager({ guildId, initialPanels }: SelfRolesManagerPro
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.selfroles.roles')}</Label>
|
||||
<Textarea
|
||||
rows={4}
|
||||
value={panel.rolesText}
|
||||
onChange={(event) =>
|
||||
setPanels((prev) => prev.map((entry) => (entry.clientKey === panel.clientKey ? { ...entry, rolesText: event.target.value } : entry)))
|
||||
<SelfRoleEntriesBuilder
|
||||
guildId={guildId}
|
||||
value={panel.roles}
|
||||
requireEmoji={panel.mode === 'REACTIONS'}
|
||||
disabled={panel.saving}
|
||||
onChange={(roles) =>
|
||||
setPanels((prev) =>
|
||||
prev.map((entry) =>
|
||||
entry.clientKey === panel.clientKey ? { ...entry, roles } : entry
|
||||
)
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
@@ -318,7 +386,13 @@ export function SelfRolesManager({ guildId, initialPanels }: SelfRolesManagerPro
|
||||
<Button type="button" variant="outline" disabled={panel.saving} onClick={() => handleSave(panel)}>
|
||||
{panel.saving ? t('common.saving') : t('common.save')}
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="icon" aria-label={t('common.remove')} onClick={() => handleDelete(panel)}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t('common.remove')}
|
||||
onClick={() => handleDelete(panel)}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -12,7 +12,18 @@ import { Switch } from '@/components/ui/switch';
|
||||
import type { SettingsSaveResult } from '@/components/settings/settings-form';
|
||||
import { SettingsForm } from '@/components/settings/settings-form';
|
||||
|
||||
async function saveStarboard(guildId: string, value: StarboardConfigDashboard): Promise<SettingsSaveResult> {
|
||||
async function saveStarboard(
|
||||
guildId: string,
|
||||
value: StarboardConfigDashboard,
|
||||
t: (key: string) => string
|
||||
): Promise<SettingsSaveResult> {
|
||||
if (value.enabled && !value.channelId) {
|
||||
return { ok: false, error: t('modulePages.starboard.errors.channelRequired') };
|
||||
}
|
||||
if (value.threshold < 1 || value.threshold > 100) {
|
||||
return { ok: false, error: t('modulePages.starboard.errors.thresholdRange') };
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/guilds/${guildId}/starboard`, {
|
||||
method: 'PATCH',
|
||||
@@ -42,7 +53,7 @@ export function StarboardForm({ guildId, initialValue }: StarboardFormProps) {
|
||||
return (
|
||||
<SettingsForm<StarboardConfigDashboard>
|
||||
initialValue={initialValue}
|
||||
onSave={(value) => saveStarboard(guildId, value)}
|
||||
onSave={(value) => saveStarboard(guildId, value, t)}
|
||||
>
|
||||
{({ value, setValue }) => (
|
||||
<Card>
|
||||
@@ -53,8 +64,14 @@ export function StarboardForm({ guildId, initialValue }: StarboardFormProps) {
|
||||
<CardContent className="space-y-4">
|
||||
<FieldAnchor id="field-starboard-enabled">
|
||||
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
||||
<div className="space-y-0.5 pr-4">
|
||||
<Label>{t('modulePages.starboard.enabledLabel')}</Label>
|
||||
<Switch checked={value.enabled} onCheckedChange={(enabled) => setValue((prev) => ({ ...prev, enabled }))} />
|
||||
<p className="text-xs text-muted-foreground">{t('modulePages.starboard.enabledHint')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={value.enabled}
|
||||
onCheckedChange={(enabled) => setValue((prev) => ({ ...prev, enabled }))}
|
||||
/>
|
||||
</div>
|
||||
</FieldAnchor>
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
@@ -71,20 +88,46 @@ export function StarboardForm({ guildId, initialValue }: StarboardFormProps) {
|
||||
<FieldAnchor id="field-starboard-emoji">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={emojiId}>{t('modulePages.starboard.emoji')}</Label>
|
||||
<Input id={emojiId} value={value.emoji} onChange={(event) => setValue((prev) => ({ ...prev, emoji: event.target.value }))} />
|
||||
<Input
|
||||
id={emojiId}
|
||||
value={value.emoji}
|
||||
maxLength={80}
|
||||
onChange={(event) =>
|
||||
setValue((prev) => ({ ...prev, emoji: event.target.value }))
|
||||
}
|
||||
placeholder="⭐ or <:name:id>"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t('modulePages.starboard.emojiHint')}</p>
|
||||
</div>
|
||||
</FieldAnchor>
|
||||
<FieldAnchor id="field-starboard-threshold">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={thresholdId}>{t('modulePages.starboard.threshold')}</Label>
|
||||
<Input id={thresholdId} type="number" min={1} value={value.threshold} onChange={(event) => setValue((prev) => ({ ...prev, threshold: Number(event.target.value) || 1 }))} />
|
||||
<Input
|
||||
id={thresholdId}
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
value={value.threshold}
|
||||
onChange={(event) =>
|
||||
setValue((prev) => ({
|
||||
...prev,
|
||||
threshold: Number(event.target.value) || 1
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</FieldAnchor>
|
||||
</div>
|
||||
<FieldAnchor id="field-starboard-self">
|
||||
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
||||
<Label>{t('modulePages.starboard.allowSelfStar')}</Label>
|
||||
<Switch checked={value.allowSelfStar} onCheckedChange={(allowSelfStar) => setValue((prev) => ({ ...prev, allowSelfStar }))} />
|
||||
<Switch
|
||||
checked={value.allowSelfStar}
|
||||
onCheckedChange={(allowSelfStar) =>
|
||||
setValue((prev) => ({ ...prev, allowSelfStar }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</FieldAnchor>
|
||||
<FieldAnchor id="field-starboard-ignored">
|
||||
@@ -93,8 +136,11 @@ export function StarboardForm({ guildId, initialValue }: StarboardFormProps) {
|
||||
<DiscordChannelMultiSelect
|
||||
guildId={guildId}
|
||||
value={value.ignoredChannelIds}
|
||||
onChange={(ignoredChannelIds) => setValue((prev) => ({ ...prev, ignoredChannelIds }))}
|
||||
onChange={(ignoredChannelIds) =>
|
||||
setValue((prev) => ({ ...prev, ignoredChannelIds }))
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t('modulePages.starboard.nsfwHint')}</p>
|
||||
</div>
|
||||
</FieldAnchor>
|
||||
</CardContent>
|
||||
|
||||
@@ -42,6 +42,7 @@ const EMPTY_DRAFT = {
|
||||
components: null as MessageComponentsV2 | null,
|
||||
responseType: 'TEXT' as TagResponseType,
|
||||
triggerWord: '',
|
||||
cooldownSeconds: 15,
|
||||
allowedRoleIds: [] as string[],
|
||||
allowedChannelIds: [] as string[]
|
||||
};
|
||||
@@ -56,6 +57,7 @@ export function TagsManager({ guildId, initialTags }: TagsManagerProps) {
|
||||
const [tags, setTags] = useState<LocalTag[]>(
|
||||
initialTags.map((tag, index) => ({
|
||||
...tag,
|
||||
cooldownSeconds: tag.cooldownSeconds ?? 15,
|
||||
embed: tag.embed ?? null,
|
||||
components: tag.components ?? null,
|
||||
clientKey: tag.id ?? `existing-${index}`
|
||||
@@ -100,6 +102,7 @@ export function TagsManager({ guildId, initialTags }: TagsManagerProps) {
|
||||
components: validated.components,
|
||||
responseType: draft.responseType,
|
||||
triggerWord: draft.triggerWord.trim() || null,
|
||||
cooldownSeconds: draft.cooldownSeconds,
|
||||
allowedRoleIds: draft.allowedRoleIds,
|
||||
allowedChannelIds: draft.allowedChannelIds
|
||||
})
|
||||
@@ -142,6 +145,7 @@ export function TagsManager({ guildId, initialTags }: TagsManagerProps) {
|
||||
components: validated.components,
|
||||
responseType: tag.responseType,
|
||||
triggerWord: tag.triggerWord,
|
||||
cooldownSeconds: tag.cooldownSeconds,
|
||||
allowedRoleIds: tag.allowedRoleIds,
|
||||
allowedChannelIds: tag.allowedChannelIds
|
||||
})
|
||||
@@ -255,6 +259,22 @@ export function TagsManager({ guildId, initialTags }: TagsManagerProps) {
|
||||
<Label>{t('modulePages.tags.triggerWord')}</Label>
|
||||
<Input value={draft.triggerWord} onChange={(event) => setDraft((prev) => ({ ...prev, triggerWord: event.target.value }))} placeholder={t('common.optional')} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.tags.cooldownSeconds')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={3600}
|
||||
value={draft.cooldownSeconds}
|
||||
onChange={(event) =>
|
||||
setDraft((prev) => ({
|
||||
...prev,
|
||||
cooldownSeconds: Math.min(3600, Math.max(0, Number(event.target.value) || 0))
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t('modulePages.tags.cooldownHint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
{renderResponseEditor(
|
||||
draft.responseType,
|
||||
@@ -325,6 +345,28 @@ export function TagsManager({ guildId, initialTags }: TagsManagerProps) {
|
||||
<Label>{t('modulePages.tags.triggerWord')}</Label>
|
||||
<Input value={tag.triggerWord ?? ''} onChange={(event) => setTags((prev) => prev.map((entry) => (entry.clientKey === tag.clientKey ? { ...entry, triggerWord: event.target.value || null } : entry)))} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.tags.cooldownSeconds')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={3600}
|
||||
value={tag.cooldownSeconds}
|
||||
onChange={(event) =>
|
||||
setTags((prev) =>
|
||||
prev.map((entry) =>
|
||||
entry.clientKey === tag.clientKey
|
||||
? {
|
||||
...entry,
|
||||
cooldownSeconds: Math.min(3600, Math.max(0, Number(event.target.value) || 0))
|
||||
}
|
||||
: entry
|
||||
)
|
||||
)
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t('modulePages.tags.cooldownHint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
{renderResponseEditor(
|
||||
tag.responseType,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user