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