Add giveaway, ticket, self-role, starboard, and suggestion features to the bot

- Introduced new models in the Prisma schema for giveaways, tickets, self-role panels, starboard configurations, and suggestions, enhancing the bot's functionality.
- Implemented job handling for self-role expiration and ticket management, improving user experience.
- Updated command localization to support new features in both German and English.
- Enhanced the job processing system to include dedicated workers for managing tickets and self-role expirations.
- Documented the new features and their setup processes in PHASE-TRACKING.md for clarity on implementation steps.
This commit is contained in:
smueller
2026-07-22 13:08:37 +02:00
parent f2f856628d
commit a583db7a15
50 changed files with 7820 additions and 4 deletions

View File

@@ -0,0 +1,246 @@
CREATE TABLE "Giveaway" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"channelId" TEXT NOT NULL,
"messageId" TEXT,
"prize" TEXT NOT NULL,
"winnerCount" INTEGER NOT NULL DEFAULT 1,
"endsAt" TIMESTAMP(3) NOT NULL,
"requiredRoleId" TEXT,
"requiredLevel" INTEGER,
"requiredMemberDays" INTEGER,
"paused" BOOLEAN NOT NULL DEFAULT false,
"endedAt" TIMESTAMP(3),
"winners" TEXT[] DEFAULT ARRAY[]::TEXT[],
"entrants" TEXT[] DEFAULT ARRAY[]::TEXT[],
"hostId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Giveaway_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "TicketConfig" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"mode" TEXT NOT NULL DEFAULT 'CHANNEL',
"categoryId" TEXT,
"logChannelId" TEXT,
"transcriptDm" BOOLEAN NOT NULL DEFAULT false,
"inactivityHours" INTEGER NOT NULL DEFAULT 72,
"ratingEnabled" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "TicketConfig_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "TicketCategory" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"emoji" TEXT,
"supportRoleIds" TEXT[] DEFAULT ARRAY[]::TEXT[],
"formFields" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "TicketCategory_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "Ticket" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"categoryId" TEXT,
"openerId" TEXT NOT NULL,
"claimedById" TEXT,
"channelId" TEXT,
"threadId" TEXT,
"status" TEXT NOT NULL DEFAULT 'OPEN',
"priority" TEXT NOT NULL DEFAULT 'NORMAL',
"subject" TEXT,
"formAnswers" JSONB,
"transcriptHtml" TEXT,
"rating" INTEGER,
"closedAt" TIMESTAMP(3),
"lastActivityAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Ticket_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "SelfRolePanel" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"channelId" TEXT NOT NULL,
"messageId" TEXT,
"title" TEXT NOT NULL,
"description" TEXT,
"mode" TEXT NOT NULL DEFAULT 'BUTTONS',
"behavior" TEXT NOT NULL DEFAULT 'NORMAL',
"roles" JSONB NOT NULL,
"expiresAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SelfRolePanel_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "Tag" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"content" TEXT,
"embed" JSONB,
"responseType" TEXT NOT NULL DEFAULT 'TEXT',
"triggerWord" TEXT,
"allowedRoleIds" TEXT[] DEFAULT ARRAY[]::TEXT[],
"allowedChannelIds" TEXT[] DEFAULT ARRAY[]::TEXT[],
"createdById" TEXT NOT NULL,
"useCount" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Tag_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "StarboardConfig" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT false,
"channelId" TEXT,
"emoji" TEXT NOT NULL DEFAULT E'',
"threshold" INTEGER NOT NULL DEFAULT 3,
"allowSelfStar" BOOLEAN NOT NULL DEFAULT false,
"ignoredChannelIds" TEXT[] DEFAULT ARRAY[]::TEXT[],
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "StarboardConfig_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "StarboardEntry" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"sourceChannelId" TEXT NOT NULL,
"sourceMessageId" TEXT NOT NULL,
"starboardMessageId" TEXT NOT NULL,
"starCount" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "StarboardEntry_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "SuggestionConfig" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"openChannelId" TEXT,
"approvedChannelId" TEXT,
"deniedChannelId" TEXT,
"createThread" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SuggestionConfig_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "Suggestion" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"authorId" TEXT NOT NULL,
"content" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'OPEN',
"messageId" TEXT,
"channelId" TEXT,
"threadId" TEXT,
"upvotes" INTEGER NOT NULL DEFAULT 0,
"downvotes" INTEGER NOT NULL DEFAULT 0,
"voterUpIds" TEXT[] DEFAULT ARRAY[]::TEXT[],
"voterDownIds" TEXT[] DEFAULT ARRAY[]::TEXT[],
"staffReason" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Suggestion_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "BirthdayConfig" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"channelId" TEXT,
"roleId" TEXT,
"message" TEXT,
"timezone" TEXT NOT NULL DEFAULT 'Europe/Berlin',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "BirthdayConfig_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "Birthday" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"month" INTEGER NOT NULL,
"day" INTEGER NOT NULL,
"year" INTEGER,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Birthday_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "TempVoiceConfig" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT false,
"hubChannelId" TEXT,
"categoryId" TEXT,
"defaultName" TEXT NOT NULL DEFAULT '{user}''s Channel',
"defaultLimit" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "TempVoiceConfig_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "TempVoiceChannel" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"channelId" TEXT NOT NULL,
"ownerId" TEXT NOT NULL,
"controlMessageId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "TempVoiceChannel_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "Giveaway_guildId_endsAt_idx" ON "Giveaway"("guildId", "endsAt");
CREATE UNIQUE INDEX "TicketConfig_guildId_key" ON "TicketConfig"("guildId");
CREATE UNIQUE INDEX "TicketCategory_guildId_name_key" ON "TicketCategory"("guildId", "name");
CREATE INDEX "TicketCategory_guildId_idx" ON "TicketCategory"("guildId");
CREATE INDEX "Ticket_guildId_status_idx" ON "Ticket"("guildId", "status");
CREATE INDEX "Ticket_channelId_idx" ON "Ticket"("channelId");
CREATE INDEX "SelfRolePanel_guildId_idx" ON "SelfRolePanel"("guildId");
CREATE UNIQUE INDEX "Tag_guildId_name_key" ON "Tag"("guildId", "name");
CREATE INDEX "Tag_guildId_idx" ON "Tag"("guildId");
CREATE UNIQUE INDEX "StarboardConfig_guildId_key" ON "StarboardConfig"("guildId");
CREATE UNIQUE INDEX "StarboardEntry_guildId_sourceMessageId_key" ON "StarboardEntry"("guildId", "sourceMessageId");
CREATE INDEX "StarboardEntry_guildId_idx" ON "StarboardEntry"("guildId");
CREATE UNIQUE INDEX "SuggestionConfig_guildId_key" ON "SuggestionConfig"("guildId");
CREATE INDEX "Suggestion_guildId_status_idx" ON "Suggestion"("guildId", "status");
CREATE UNIQUE INDEX "BirthdayConfig_guildId_key" ON "BirthdayConfig"("guildId");
CREATE UNIQUE INDEX "Birthday_guildId_userId_key" ON "Birthday"("guildId", "userId");
CREATE INDEX "Birthday_guildId_month_day_idx" ON "Birthday"("guildId", "month", "day");
CREATE UNIQUE INDEX "TempVoiceConfig_guildId_key" ON "TempVoiceConfig"("guildId");
CREATE UNIQUE INDEX "TempVoiceChannel_channelId_key" ON "TempVoiceChannel"("channelId");
CREATE INDEX "TempVoiceChannel_guildId_ownerId_idx" ON "TempVoiceChannel"("guildId", "ownerId");
ALTER TABLE "Giveaway" ADD CONSTRAINT "Giveaway_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "TicketConfig" ADD CONSTRAINT "TicketConfig_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "TicketCategory" ADD CONSTRAINT "TicketCategory_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "Ticket" ADD CONSTRAINT "Ticket_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "Ticket" ADD CONSTRAINT "Ticket_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "TicketCategory"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "SelfRolePanel" ADD CONSTRAINT "SelfRolePanel_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "Tag" ADD CONSTRAINT "Tag_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "StarboardConfig" ADD CONSTRAINT "StarboardConfig_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "StarboardEntry" ADD CONSTRAINT "StarboardEntry_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "SuggestionConfig" ADD CONSTRAINT "SuggestionConfig_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "Suggestion" ADD CONSTRAINT "Suggestion_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "BirthdayConfig" ADD CONSTRAINT "BirthdayConfig_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "Birthday" ADD CONSTRAINT "Birthday_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "TempVoiceConfig" ADD CONSTRAINT "TempVoiceConfig_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "TempVoiceChannel" ADD CONSTRAINT "TempVoiceChannel_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -30,6 +30,20 @@ model Guild {
polls Poll[]
reminders Reminder[]
afkStatuses AfkStatus[]
giveaways Giveaway[]
ticketConfig TicketConfig?
ticketCategories TicketCategory[]
tickets Ticket[]
selfRolePanels SelfRolePanel[]
tags Tag[]
starboardConfig StarboardConfig?
starboardEntries StarboardEntry[]
suggestionConfig SuggestionConfig?
suggestions Suggestion[]
birthdays Birthday[]
birthdayConfig BirthdayConfig?
tempVoiceConfig TempVoiceConfig?
tempVoiceChannels TempVoiceChannel[]
}
model GuildSettings {
@@ -389,3 +403,239 @@ model AfkStatus {
@@unique([guildId, userId])
}
model Giveaway {
id String @id @default(cuid())
guildId String
channelId String
messageId String?
prize String
winnerCount Int @default(1)
endsAt DateTime
requiredRoleId String?
requiredLevel Int?
requiredMemberDays Int?
paused Boolean @default(false)
endedAt DateTime?
winners String[] @default([])
entrants String[] @default([])
hostId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
@@index([guildId, endsAt])
}
model TicketConfig {
id String @id @default(cuid())
guildId String @unique
enabled Boolean @default(true)
mode String @default("CHANNEL")
categoryId String?
logChannelId String?
transcriptDm Boolean @default(false)
inactivityHours Int @default(72)
ratingEnabled Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
}
model TicketCategory {
id String @id @default(cuid())
guildId String
name String
description String?
emoji String?
supportRoleIds String[] @default([])
formFields Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
tickets Ticket[]
@@unique([guildId, name])
@@index([guildId])
}
model Ticket {
id String @id @default(cuid())
guildId String
categoryId String?
openerId String
claimedById String?
channelId String?
threadId String?
status String @default("OPEN")
priority String @default("NORMAL")
subject String?
formAnswers Json?
transcriptHtml String?
rating Int?
closedAt DateTime?
lastActivityAt DateTime @default(now())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
category TicketCategory? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
@@index([guildId, status])
@@index([channelId])
}
model SelfRolePanel {
id String @id @default(cuid())
guildId String
channelId String
messageId String?
title String
description String?
mode String @default("BUTTONS")
behavior String @default("NORMAL")
roles Json
expiresAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
@@index([guildId])
}
model Tag {
id String @id @default(cuid())
guildId String
name String
content String?
embed Json?
responseType String @default("TEXT")
triggerWord String?
allowedRoleIds String[] @default([])
allowedChannelIds String[] @default([])
createdById String
useCount Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
@@unique([guildId, name])
@@index([guildId])
}
model StarboardConfig {
id String @id @default(cuid())
guildId String @unique
enabled Boolean @default(false)
channelId String?
emoji String @default("⭐")
threshold Int @default(3)
allowSelfStar Boolean @default(false)
ignoredChannelIds String[] @default([])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
}
model StarboardEntry {
id String @id @default(cuid())
guildId String
sourceChannelId String
sourceMessageId String
starboardMessageId String
starCount Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
@@unique([guildId, sourceMessageId])
@@index([guildId])
}
model SuggestionConfig {
id String @id @default(cuid())
guildId String @unique
enabled Boolean @default(true)
openChannelId String?
approvedChannelId String?
deniedChannelId String?
createThread Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
}
model Suggestion {
id String @id @default(cuid())
guildId String
authorId String
content String
status String @default("OPEN")
messageId String?
channelId String?
threadId String?
upvotes Int @default(0)
downvotes Int @default(0)
voterUpIds String[] @default([])
voterDownIds String[] @default([])
staffReason String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
@@index([guildId, status])
}
model BirthdayConfig {
id String @id @default(cuid())
guildId String @unique
enabled Boolean @default(true)
channelId String?
roleId String?
message String?
timezone String @default("Europe/Berlin")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
}
model Birthday {
id String @id @default(cuid())
guildId String
userId String
month Int
day Int
year Int?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
@@unique([guildId, userId])
@@index([guildId, month, day])
}
model TempVoiceConfig {
id String @id @default(cuid())
guildId String @unique
enabled Boolean @default(false)
hubChannelId String?
categoryId String?
defaultName String @default("{user}'s Channel")
defaultLimit Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
}
model TempVoiceChannel {
id String @id @default(cuid())
guildId String
channelId String @unique
ownerId String
controlMessageId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
@@index([guildId, ownerId])
}

View File

@@ -11,6 +11,7 @@ import { refreshPhishingDomains } from './modules/automod/filters.js';
import { completeVerification } from './modules/verification/handlers.js';
import { closePoll } from './modules/utility/poll.js';
import { sendReminder } from './modules/utility/reminders.js';
import { expireSelfRole } from './modules/selfroles/service.js';
import {
automodQueue,
automodQueueName,
@@ -18,6 +19,7 @@ import {
backupQueueName,
moderationQueueName,
reminderQueueName,
ticketQueueName,
verificationQueueName
} from './queues.js';
@@ -49,6 +51,12 @@ type PollCloseJob = {
pollId: string;
};
type SelfRoleExpireJob = {
guildId: string;
userId: string;
roleId: string;
};
export function startWorkers(context: BotContext): Worker[] {
const moderationWorker = new Worker<TempBanJob>(
moderationQueueName,
@@ -135,7 +143,22 @@ export function startWorkers(context: BotContext): Worker[] {
logger.error({ jobId: job?.id, error }, 'Reminder job failed');
});
return [moderationWorker, backupWorker, automodWorker, verificationWorker, reminderWorker];
const ticketWorker = new Worker<SelfRoleExpireJob>(
ticketQueueName,
async (job) => {
if (job.name !== 'selfRoleExpire') {
return;
}
await expireSelfRole(context, job.data.guildId, job.data.userId, job.data.roleId);
},
{ connection: redis }
);
ticketWorker.on('failed', (job, error) => {
logger.error({ jobId: job?.id, error }, 'Ticket queue job failed');
});
return [moderationWorker, backupWorker, automodWorker, verificationWorker, reminderWorker, ticketWorker];
}
export async function ensureRecurringJobs(): Promise<void> {

View File

@@ -0,0 +1,45 @@
import { ChannelType } from 'discord.js';
import { applyCommandDescription, applyOptionDescription } from '@nexumi/shared';
import { SlashCommandBuilder } from 'discord.js';
export const birthdayCommandData = applyCommandDescription(
new SlashCommandBuilder()
.setName('birthday')
.addSubcommand((s) =>
applyCommandDescription(s.setName('set'), 'birthdays.set.description')
.addIntegerOption((o) =>
applyOptionDescription(o.setName('month').setRequired(true).setMinValue(1).setMaxValue(12), 'birthdays.set.options.month')
)
.addIntegerOption((o) =>
applyOptionDescription(o.setName('day').setRequired(true).setMinValue(1).setMaxValue(31), 'birthdays.set.options.day')
)
.addIntegerOption((o) =>
applyOptionDescription(
o.setName('year').setMinValue(1900).setMaxValue(new Date().getFullYear()),
'birthdays.set.options.year'
)
)
)
.addSubcommand((s) => applyCommandDescription(s.setName('remove'), 'birthdays.remove.description'))
.addSubcommand((s) => applyCommandDescription(s.setName('next'), 'birthdays.next.description'))
.addSubcommand((s) => applyCommandDescription(s.setName('list'), 'birthdays.list.description'))
.addSubcommand((s) =>
applyCommandDescription(s.setName('setup'), 'birthdays.setup.description')
.addChannelOption((o) =>
applyOptionDescription(
o.setName('channel').addChannelTypes(ChannelType.GuildText).setRequired(true),
'birthdays.setup.options.channel'
)
)
.addRoleOption((o) =>
applyOptionDescription(o.setName('role'), 'birthdays.setup.options.role')
)
.addStringOption((o) =>
applyOptionDescription(o.setName('timezone'), 'birthdays.setup.options.timezone')
)
.addStringOption((o) =>
applyOptionDescription(o.setName('message'), 'birthdays.setup.options.message')
)
),
'birthdays.description'
);

View File

@@ -0,0 +1,126 @@
import { PermissionFlagsBits } from 'discord.js';
import { t, tf } from '@nexumi/shared';
import type { SlashCommand } from '../../types.js';
import { getGuildLocale } from '../../i18n.js';
import { requirePermission } from '../../permissions.js';
import { birthdayCommandData } from './command-definitions.js';
import {
BirthdaySetSchema,
daysUntilBirthday,
getBirthdayConfig,
isValidTimezone,
listBirthdays,
removeBirthday,
setBirthday,
updateBirthdayConfig
} from './service.js';
const birthdayCommand: SlashCommand = {
data: birthdayCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!interaction.inGuild()) {
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
return;
}
const guildId = interaction.guildId!;
const sub = interaction.options.getSubcommand();
if (sub === 'setup') {
if (!(await requirePermission(interaction, PermissionFlagsBits.ManageGuild, locale))) {
return;
}
const channel = interaction.options.getChannel('channel', true);
const role = interaction.options.getRole('role');
const timezone = interaction.options.getString('timezone') ?? 'Europe/Berlin';
const message = interaction.options.getString('message');
if (!isValidTimezone(timezone)) {
await interaction.reply({ content: t(locale, 'birthdays.error.invalidTimezone'), ephemeral: true });
return;
}
await updateBirthdayConfig(context.prisma, guildId, {
enabled: true,
channelId: channel.id,
roleId: role?.id ?? null,
message: message ?? null,
timezone
});
await interaction.reply({ content: t(locale, 'birthdays.setup.done'), ephemeral: true });
return;
}
const config = await getBirthdayConfig(context.prisma, guildId);
if (sub === 'set') {
const month = interaction.options.getInteger('month', true);
const day = interaction.options.getInteger('day', true);
const year = interaction.options.getInteger('year') ?? undefined;
const parsed = BirthdaySetSchema.safeParse({ month, day, year });
if (!parsed.success) {
await interaction.reply({ content: t(locale, 'birthdays.error.invalidDate'), ephemeral: true });
return;
}
try {
await setBirthday(context.prisma, guildId, interaction.user.id, parsed.data);
} catch (error) {
if (error instanceof Error && error.message === 'invalid_date') {
await interaction.reply({ content: t(locale, 'birthdays.error.invalidDate'), ephemeral: true });
return;
}
throw error;
}
await interaction.reply({
content: tf(locale, 'birthdays.set.success', { month, day, year: year ?? '—' }),
ephemeral: true
});
return;
}
if (sub === 'remove') {
const removed = await removeBirthday(context.prisma, guildId, interaction.user.id);
if (!removed) {
await interaction.reply({ content: t(locale, 'birthdays.remove.none'), ephemeral: true });
return;
}
await interaction.reply({ content: t(locale, 'birthdays.remove.success'), ephemeral: true });
return;
}
if (sub === 'list' || sub === 'next') {
const entries = await listBirthdays(context.prisma, guildId, config.timezone);
if (entries.length === 0) {
await interaction.reply({ content: t(locale, 'birthdays.list.empty'), ephemeral: true });
return;
}
const slice = sub === 'next' ? entries.slice(0, 10) : entries.slice(0, 25);
const lines = slice.map((entry, index) => {
const days = daysUntilBirthday(entry.month, entry.day, config.timezone);
const dateLabel = `${String(entry.day).padStart(2, '0')}.${String(entry.month).padStart(2, '0')}`;
const yearLabel = entry.year ? ` (${entry.year})` : '';
return tf(locale, 'birthdays.list.line', {
rank: index + 1,
user: `<@${entry.userId}>`,
date: `${dateLabel}${yearLabel}`,
days
});
});
const header = t(locale, sub === 'next' ? 'birthdays.next.header' : 'birthdays.list.header');
await interaction.reply({
content: `${header}\n${lines.join('\n')}`,
ephemeral: true
});
}
}
};
export const birthdayCommands: SlashCommand[] = [birthdayCommand];

View File

@@ -0,0 +1,2 @@
export { birthdayCommands } from './commands.js';
export { runBirthdayCheck, expireBirthdayRole } from './service.js';

View File

@@ -0,0 +1,304 @@
import { PermissionFlagsBits, type Guild, type GuildMember, type TextChannel } from 'discord.js';
import { z } from 'zod';
import { applyTagPlaceholders, tf } from '@nexumi/shared';
import { ensureGuild } from '../../guild.js';
import { logger } from '../../logger.js';
import { birthdayQueue } from '../../queues.js';
import type { BotContext } from '../../types.js';
export const BirthdaySetSchema = z.object({
month: z.number().int().min(1).max(12),
day: z.number().int().min(1).max(31),
year: z.number().int().min(1900).max(new Date().getFullYear()).optional()
});
export type BirthdaySetInput = z.infer<typeof BirthdaySetSchema>;
const DEFAULT_MESSAGE = 'Happy birthday {user}! 🎂';
const ROLE_EXPIRE_MS = 24 * 60 * 60 * 1000;
function congratsRedisKey(guildId: string, userId: string, dateKey: string): string {
return `birthday:sent:${guildId}:${userId}:${dateKey}`;
}
export function isValidTimezone(timezone: string): boolean {
try {
Intl.DateTimeFormat(undefined, { timeZone: timezone });
return true;
} catch {
return false;
}
}
export function getDatePartsInTimezone(
timezone: string,
date = new Date()
): { month: number; day: number; year: number } {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: timezone,
year: 'numeric',
month: 'numeric',
day: 'numeric'
}).formatToParts(date);
return {
year: Number(parts.find((p) => p.type === 'year')!.value),
month: Number(parts.find((p) => p.type === 'month')!.value),
day: Number(parts.find((p) => p.type === 'day')!.value)
};
}
export function isValidCalendarDate(month: number, day: number, year?: number): boolean {
const probeYear = year ?? 2000;
const date = new Date(probeYear, month - 1, day);
return date.getFullYear() === probeYear && date.getMonth() === month - 1 && date.getDate() === day;
}
export function computeAge(year: number, month: number, day: number, timezone: string): number {
const today = getDatePartsInTimezone(timezone);
let age = today.year - year;
if (today.month < month || (today.month === month && today.day < day)) {
age -= 1;
}
return age;
}
export function daysUntilBirthday(month: number, day: number, timezone: string): number {
const today = getDatePartsInTimezone(timezone);
let year = today.year;
let candidate = new Date(Date.UTC(year, month - 1, day));
const todayUtc = new Date(Date.UTC(today.year, today.month - 1, today.day));
if (candidate.getTime() < todayUtc.getTime()) {
year += 1;
candidate = new Date(Date.UTC(year, month - 1, day));
}
return Math.round((candidate.getTime() - todayUtc.getTime()) / (24 * 60 * 60 * 1000));
}
export async function getBirthdayConfig(prisma: BotContext['prisma'], guildId: string) {
await ensureGuild(prisma, guildId);
let config = await prisma.birthdayConfig.findUnique({ where: { guildId } });
if (!config) {
config = await prisma.birthdayConfig.create({ data: { guildId } });
}
return config;
}
export async function updateBirthdayConfig(
prisma: BotContext['prisma'],
guildId: string,
data: {
enabled?: boolean;
channelId?: string | null;
roleId?: string | null;
message?: string | null;
timezone?: string;
}
) {
await ensureGuild(prisma, guildId);
return prisma.birthdayConfig.upsert({
where: { guildId },
update: data,
create: { guildId, ...data }
});
}
export async function setBirthday(
prisma: BotContext['prisma'],
guildId: string,
userId: string,
input: BirthdaySetInput
) {
if (!isValidCalendarDate(input.month, input.day, input.year)) {
throw new Error('invalid_date');
}
return prisma.birthday.upsert({
where: { guildId_userId: { guildId, userId } },
update: {
month: input.month,
day: input.day,
year: input.year ?? null
},
create: {
guildId,
userId,
month: input.month,
day: input.day,
year: input.year ?? null
}
});
}
export async function removeBirthday(prisma: BotContext['prisma'], guildId: string, userId: string) {
const existing = await prisma.birthday.findUnique({
where: { guildId_userId: { guildId, userId } }
});
if (!existing) {
return null;
}
await prisma.birthday.delete({ where: { guildId_userId: { guildId, userId } } });
return existing;
}
export async function listBirthdays(prisma: BotContext['prisma'], guildId: string, timezone: string) {
const birthdays = await prisma.birthday.findMany({ where: { guildId } });
return birthdays.sort((a, b) => {
const diff = daysUntilBirthday(a.month, a.day, timezone) - daysUntilBirthday(b.month, b.day, timezone);
if (diff !== 0) {
return diff;
}
return a.userId.localeCompare(b.userId);
});
}
export async function scheduleBirthdayRoleExpire(
guildId: string,
userId: string,
roleId: string
): Promise<void> {
await birthdayQueue.add(
'birthdayRoleExpire',
{ guildId, userId, roleId },
{
delay: ROLE_EXPIRE_MS,
jobId: `birthday-role-${guildId}-${userId}-${Date.now()}`,
removeOnComplete: 1000,
removeOnFail: 500
}
);
}
function renderBirthdayMessage(
template: string | null | undefined,
member: GuildMember,
age?: number
): string {
const vars: Record<string, string> = {
user: `<@${member.id}>`,
'user.name': member.displayName,
'user.tag': member.user.tag
};
if (age !== undefined) {
vars.age = String(age);
}
return applyTagPlaceholders(template?.trim() || DEFAULT_MESSAGE, vars);
}
async function sendBirthdayCongrats(
context: BotContext,
guild: Guild,
config: Awaited<ReturnType<typeof getBirthdayConfig>>,
birthday: { userId: string; month: number; day: number; year: number | null },
dateKey: string
): Promise<void> {
if (!config.channelId) {
return;
}
const dedupeKey = congratsRedisKey(guild.id, birthday.userId, dateKey);
const alreadySent = await context.redis.get(dedupeKey);
if (alreadySent) {
return;
}
const channel = await guild.channels.fetch(config.channelId).catch(() => null);
if (!channel?.isTextBased() || channel.isDMBased()) {
logger.warn({ guildId: guild.id, channelId: config.channelId }, 'Birthday channel invalid');
return;
}
const member = await guild.members.fetch(birthday.userId).catch(() => null);
if (!member) {
return;
}
const age =
birthday.year !== null
? computeAge(birthday.year, birthday.month, birthday.day, config.timezone)
: undefined;
const content = renderBirthdayMessage(config.message, member, age);
await (channel as TextChannel).send({ content });
if (config.roleId) {
const me = guild.members.me;
const role = guild.roles.cache.get(config.roleId);
if (
me?.permissions.has(PermissionFlagsBits.ManageRoles) &&
role &&
role.position < me.roles.highest.position &&
!member.roles.cache.has(role.id)
) {
await member.roles.add(role).catch((error) => {
logger.warn({ error, guildId: guild.id, userId: birthday.userId }, 'Failed to assign birthday role');
});
await scheduleBirthdayRoleExpire(guild.id, birthday.userId, role.id);
}
}
await context.redis.set(dedupeKey, '1', 'EX', 60 * 60 * 26);
logger.info({ guildId: guild.id, userId: birthday.userId }, 'Birthday congratulations sent');
}
export async function expireBirthdayRole(
context: BotContext,
guildId: string,
userId: string,
roleId: string
): Promise<void> {
const guild = await context.client.guilds.fetch(guildId).catch(() => null);
if (!guild) {
return;
}
const member = await guild.members.fetch(userId).catch(() => null);
if (!member) {
return;
}
const me = guild.members.me;
const role = guild.roles.cache.get(roleId);
if (
!me?.permissions.has(PermissionFlagsBits.ManageRoles) ||
!role ||
role.position >= me.roles.highest.position
) {
return;
}
if (member.roles.cache.has(roleId)) {
await member.roles.remove(roleId).catch((error) => {
logger.warn({ error, guildId, userId, roleId }, 'Failed to remove birthday role');
});
}
}
export async function runBirthdayCheck(context: BotContext): Promise<void> {
const configs = await context.prisma.birthdayConfig.findMany({
where: { enabled: true, channelId: { not: null } }
});
for (const config of configs) {
try {
const guild = await context.client.guilds.fetch(config.guildId).catch(() => null);
if (!guild) {
continue;
}
const { month, day, year } = getDatePartsInTimezone(config.timezone);
const dateKey = `${year}-${month}-${day}`;
const birthdays = await context.prisma.birthday.findMany({
where: { guildId: config.guildId, month, day }
});
await Promise.all(
birthdays.map((birthday) => sendBirthdayCongrats(context, guild, config, birthday, dateKey))
);
} catch (error) {
logger.error({ error, guildId: config.guildId }, 'Birthday check failed for guild');
}
}
}

View File

@@ -0,0 +1,42 @@
import type { ButtonInteraction } from 'discord.js';
import { t } from '@nexumi/shared';
import type { BotContext } from '../../types.js';
import { getGuildLocale } from '../../i18n.js';
import { GiveawayError, joinGiveaway } from './service.js';
export const GIVEAWAY_JOIN_PREFIX = 'gw:join:';
export function joinButtonId(giveawayId: string): string {
return `${GIVEAWAY_JOIN_PREFIX}${giveawayId}`;
}
export function isGiveawayButton(customId: string): boolean {
return customId.startsWith(GIVEAWAY_JOIN_PREFIX);
}
export async function handleGiveawayButton(
interaction: ButtonInteraction,
context: BotContext
): Promise<void> {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
const giveawayId = interaction.customId.slice(GIVEAWAY_JOIN_PREFIX.length);
if (!giveawayId || !interaction.guildId) {
await interaction.reply({ content: t(locale, 'giveaway.error.not_found'), ephemeral: true });
return;
}
try {
await joinGiveaway(context, giveawayId, interaction.user.id, locale);
await interaction.reply({ content: t(locale, 'giveaway.joined'), ephemeral: true });
} catch (error) {
if (error instanceof GiveawayError) {
await interaction.reply({
content: t(locale, `giveaway.error.${error.code}`),
ephemeral: true
});
return;
}
throw error;
}
}

View File

@@ -0,0 +1,61 @@
import { applyCommandDescription, applyOptionDescription } from '@nexumi/shared';
import { SlashCommandBuilder } from 'discord.js';
export const giveawayCommandData = applyCommandDescription(
new SlashCommandBuilder()
.setName('giveaway')
.addSubcommand((s) =>
applyCommandDescription(s.setName('start'), 'giveaway.start.description')
.addStringOption((o) =>
applyOptionDescription(o.setName('duration').setRequired(true), 'giveaway.start.options.duration')
)
.addIntegerOption((o) =>
applyOptionDescription(
o.setName('winners').setRequired(true).setMinValue(1).setMaxValue(25),
'giveaway.start.options.winners'
)
)
.addStringOption((o) =>
applyOptionDescription(o.setName('prize').setRequired(true), 'giveaway.start.options.prize')
)
.addRoleOption((o) =>
applyOptionDescription(o.setName('role'), 'giveaway.start.options.role')
)
.addIntegerOption((o) =>
applyOptionDescription(
o.setName('level').setMinValue(0).setMaxValue(500),
'giveaway.start.options.level'
)
)
.addIntegerOption((o) =>
applyOptionDescription(
o.setName('member_days').setMinValue(0).setMaxValue(3650),
'giveaway.start.options.member_days'
)
)
)
.addSubcommand((s) =>
applyCommandDescription(s.setName('end'), 'giveaway.end.description').addStringOption((o) =>
applyOptionDescription(o.setName('id').setRequired(true), 'giveaway.common.options.id')
)
)
.addSubcommand((s) =>
applyCommandDescription(s.setName('reroll'), 'giveaway.reroll.description').addStringOption(
(o) => applyOptionDescription(o.setName('id').setRequired(true), 'giveaway.common.options.id')
)
)
.addSubcommand((s) =>
applyCommandDescription(s.setName('list'), 'giveaway.list.description')
)
.addSubcommand((s) =>
applyCommandDescription(s.setName('delete'), 'giveaway.delete.description').addStringOption(
(o) => applyOptionDescription(o.setName('id').setRequired(true), 'giveaway.common.options.id')
)
)
.addSubcommand((s) =>
applyCommandDescription(s.setName('pause'), 'giveaway.pause.description').addStringOption(
(o) => applyOptionDescription(o.setName('id').setRequired(true), 'giveaway.common.options.id')
)
),
'giveaway.description'
);

View File

@@ -0,0 +1,166 @@
import { PermissionFlagsBits } from 'discord.js';
import { t, tf } from '@nexumi/shared';
import type { SlashCommand } from '../../types.js';
import { getGuildLocale } from '../../i18n.js';
import { requirePermission } from '../../permissions.js';
import { parseDuration } from '../moderation/duration.js';
import { giveawayCommandData } from './command-definitions.js';
import {
deleteGiveaway,
endGiveaway,
GiveawayError,
listActiveGiveaways,
pauseGiveaway,
rerollGiveaway,
startGiveaway
} from './service.js';
async function ensureManageGuild(
interaction: Parameters<SlashCommand['execute']>[0],
locale: 'de' | 'en'
): Promise<boolean> {
if (!interaction.inGuild()) {
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
return false;
}
return requirePermission(interaction, PermissionFlagsBits.ManageGuild, locale);
}
const giveawayCommand: SlashCommand = {
data: giveawayCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!interaction.inGuild()) {
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
return;
}
const sub = interaction.options.getSubcommand();
if (sub === 'start') {
if (!(await ensureManageGuild(interaction, locale))) {
return;
}
const durationInput = interaction.options.getString('duration', true);
let durationMs: number;
try {
durationMs = parseDuration(durationInput);
} catch {
await interaction.reply({
content: t(locale, 'giveaway.error.invalid_duration'),
ephemeral: true
});
return;
}
if (durationMs < 60_000) {
await interaction.reply({
content: t(locale, 'giveaway.error.duration_too_short'),
ephemeral: true
});
return;
}
const channel = interaction.channel;
if (!channel?.isTextBased() || channel.isDMBased()) {
await interaction.reply({
content: t(locale, 'giveaway.error.invalid_channel'),
ephemeral: true
});
return;
}
const giveaway = await startGiveaway(
context,
{
guildId: interaction.guildId!,
channelId: channel.id,
hostId: interaction.user.id,
prize: interaction.options.getString('prize', true),
winnerCount: interaction.options.getInteger('winners', true),
durationMs,
requiredRoleId: interaction.options.getRole('role')?.id,
requiredLevel: interaction.options.getInteger('level'),
requiredMemberDays: interaction.options.getInteger('member_days')
},
locale
);
await interaction.reply({
content: tf(locale, 'giveaway.started', { id: giveaway.id }),
ephemeral: true
});
return;
}
if (!(await ensureManageGuild(interaction, locale))) {
return;
}
try {
if (sub === 'end') {
const id = interaction.options.getString('id', true);
await endGiveaway(context, id);
await interaction.reply({ content: t(locale, 'giveaway.ended'), ephemeral: true });
return;
}
if (sub === 'reroll') {
const id = interaction.options.getString('id', true);
const updated = await rerollGiveaway(context, id, locale);
await interaction.reply({
content: tf(locale, 'giveaway.rerolled', {
winners: updated.winners.map((w) => `<@${w}>`).join(', ') || '—'
}),
ephemeral: true
});
return;
}
if (sub === 'list') {
const active = await listActiveGiveaways(context, interaction.guildId!);
if (active.length === 0) {
await interaction.reply({ content: t(locale, 'giveaway.list.empty'), ephemeral: true });
return;
}
const lines = active.map(
(g) =>
`\`${g.id}\` — **${g.prize}** (${g.entrants.length} ${t(locale, 'giveaway.list.entrants')}, <t:${Math.floor(g.endsAt.getTime() / 1000)}:R>)`
);
await interaction.reply({
content: `${t(locale, 'giveaway.list.header')}\n${lines.join('\n')}`,
ephemeral: true
});
return;
}
if (sub === 'delete') {
const id = interaction.options.getString('id', true);
await deleteGiveaway(context, id);
await interaction.reply({ content: t(locale, 'giveaway.deleted'), ephemeral: true });
return;
}
if (sub === 'pause') {
const id = interaction.options.getString('id', true);
const updated = await pauseGiveaway(context, id, locale);
await interaction.reply({
content: t(locale, updated.paused ? 'giveaway.paused' : 'giveaway.resumed'),
ephemeral: true
});
}
} catch (error) {
if (error instanceof GiveawayError) {
await interaction.reply({
content: t(locale, `giveaway.error.${error.code}`),
ephemeral: true
});
return;
}
throw error;
}
}
};
export const giveawayCommands = [giveawayCommand];

View File

@@ -0,0 +1,3 @@
export { giveawayCommands } from './commands.js';
export { isGiveawayButton, handleGiveawayButton } from './buttons.js';
export { endGiveaway, scheduleGiveawayEnd } from './service.js';

View File

@@ -0,0 +1,419 @@
import {
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
EmbedBuilder,
type Guild,
type GuildMember,
type TextChannel
} from 'discord.js';
import { pickRandomWinners, t, tf } from '@nexumi/shared';
import type { Giveaway } from '@prisma/client';
import { ensureGuild } from '../../guild.js';
import { logger } from '../../logger.js';
import { giveawayQueue } from '../../queues.js';
import type { BotContext } from '../../types.js';
import { getGuildLocale } from '../../i18n.js';
import { joinButtonId } from './buttons.js';
export class GiveawayError extends Error {
constructor(public readonly code: string) {
super(code);
}
}
export async function scheduleGiveawayEnd(giveawayId: string, endsAt: Date): Promise<void> {
const delay = Math.max(0, endsAt.getTime() - Date.now());
await giveawayQueue.add(
'giveawayEnd',
{ giveawayId },
{
delay,
jobId: `giveaway-end-${giveawayId}`,
removeOnComplete: true,
removeOnFail: 50
}
);
}
export async function cancelGiveawayJob(giveawayId: string): Promise<void> {
const job = await giveawayQueue.getJob(`giveaway-end-${giveawayId}`);
await job?.remove();
}
function requirementLines(locale: 'de' | 'en', giveaway: Giveaway): string[] {
const lines: string[] = [];
if (giveaway.requiredRoleId) {
lines.push(tf(locale, 'giveaway.requirement.role', { role: `<@&${giveaway.requiredRoleId}>` }));
}
if (giveaway.requiredLevel !== null && giveaway.requiredLevel !== undefined) {
lines.push(tf(locale, 'giveaway.requirement.level', { level: giveaway.requiredLevel }));
}
if (giveaway.requiredMemberDays !== null && giveaway.requiredMemberDays !== undefined) {
lines.push(
tf(locale, 'giveaway.requirement.memberDays', { days: giveaway.requiredMemberDays })
);
}
return lines;
}
export function buildGiveawayEmbed(locale: 'de' | 'en', giveaway: Giveaway): EmbedBuilder {
const ended = giveaway.endedAt !== null;
const embed = new EmbedBuilder()
.setTitle(t(locale, 'giveaway.embed.title'))
.setColor(ended ? 0x6b7280 : 0x6366f1)
.addFields(
{ name: t(locale, 'giveaway.embed.prize'), value: giveaway.prize, inline: true },
{
name: t(locale, 'giveaway.embed.winners'),
value: String(giveaway.winnerCount),
inline: true
},
{
name: t(locale, 'giveaway.embed.entrants'),
value: String(giveaway.entrants.length),
inline: true
}
);
if (ended) {
embed.setDescription(
giveaway.winners.length > 0
? tf(locale, 'giveaway.embed.endedWinners', {
winners: giveaway.winners.map((id) => `<@${id}>`).join(', ')
})
: t(locale, 'giveaway.embed.endedNoWinners')
);
embed.setFooter({ text: t(locale, 'giveaway.embed.ended') });
} else if (giveaway.paused) {
embed.setDescription(t(locale, 'giveaway.embed.paused'));
embed.setFooter({
text: tf(locale, 'giveaway.embed.endsAt', {
time: `<t:${Math.floor(giveaway.endsAt.getTime() / 1000)}:R>`
})
});
} else {
embed.setDescription(t(locale, 'giveaway.embed.active'));
embed.setFooter({
text: tf(locale, 'giveaway.embed.endsAt', {
time: `<t:${Math.floor(giveaway.endsAt.getTime() / 1000)}:R>`
})
});
}
const requirements = requirementLines(locale, giveaway);
if (requirements.length > 0) {
embed.addFields({
name: t(locale, 'giveaway.embed.requirements'),
value: requirements.join('\n')
});
}
embed.addFields({
name: t(locale, 'giveaway.embed.host'),
value: `<@${giveaway.hostId}>`,
inline: true
});
return embed;
}
export function buildGiveawayComponents(
locale: 'de' | 'en',
giveaway: Giveaway
): ActionRowBuilder<ButtonBuilder>[] {
if (giveaway.endedAt) {
return [];
}
return [
new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder()
.setCustomId(joinButtonId(giveaway.id))
.setLabel(t(locale, 'giveaway.button.join'))
.setStyle(ButtonStyle.Primary)
.setDisabled(giveaway.paused)
)
];
}
async function updateGiveawayMessage(
context: BotContext,
giveaway: Giveaway,
locale: 'de' | 'en'
): Promise<void> {
if (!giveaway.messageId) {
return;
}
try {
const channel = (await context.client.channels.fetch(giveaway.channelId)) as TextChannel;
const message = await channel.messages.fetch(giveaway.messageId);
await message.edit({
embeds: [buildGiveawayEmbed(locale, giveaway)],
components: buildGiveawayComponents(locale, giveaway)
});
} catch (error) {
logger.warn({ error, giveawayId: giveaway.id }, 'Failed to update giveaway message');
}
}
export async function checkGiveawayRequirements(
context: BotContext,
guild: Guild,
member: GuildMember,
giveaway: Giveaway
): Promise<string | null> {
if (giveaway.requiredRoleId && !member.roles.cache.has(giveaway.requiredRoleId)) {
return 'missing_role';
}
if (giveaway.requiredLevel !== null && giveaway.requiredLevel !== undefined) {
const levelRow = await context.prisma.memberLevel.findUnique({
where: { guildId_userId: { guildId: guild.id, userId: member.id } }
});
const level = levelRow?.level ?? 0;
if (level < giveaway.requiredLevel) {
return 'insufficient_level';
}
}
if (giveaway.requiredMemberDays !== null && giveaway.requiredMemberDays !== undefined) {
const joinedAt = member.joinedAt;
const days = joinedAt
? Math.floor((Date.now() - joinedAt.getTime()) / 86_400_000)
: 0;
if (days < giveaway.requiredMemberDays) {
return 'insufficient_member_days';
}
}
return null;
}
export async function startGiveaway(
context: BotContext,
params: {
guildId: string;
channelId: string;
hostId: string;
prize: string;
winnerCount: number;
durationMs: number;
requiredRoleId?: string | null;
requiredLevel?: number | null;
requiredMemberDays?: number | null;
},
locale: 'de' | 'en'
): Promise<Giveaway> {
await ensureGuild(context.prisma, params.guildId);
const endsAt = new Date(Date.now() + params.durationMs);
const giveaway = await context.prisma.giveaway.create({
data: {
guildId: params.guildId,
channelId: params.channelId,
prize: params.prize,
winnerCount: params.winnerCount,
endsAt,
requiredRoleId: params.requiredRoleId ?? null,
requiredLevel: params.requiredLevel ?? null,
requiredMemberDays: params.requiredMemberDays ?? null,
hostId: params.hostId
}
});
const channel = (await context.client.channels.fetch(params.channelId)) as TextChannel;
const message = await channel.send({
embeds: [buildGiveawayEmbed(locale, giveaway)],
components: buildGiveawayComponents(locale, giveaway)
});
const updated = await context.prisma.giveaway.update({
where: { id: giveaway.id },
data: { messageId: message.id }
});
await scheduleGiveawayEnd(updated.id, updated.endsAt);
return updated;
}
export async function joinGiveaway(
context: BotContext,
giveawayId: string,
userId: string,
locale: 'de' | 'en'
): Promise<Giveaway> {
const giveaway = await context.prisma.giveaway.findUnique({ where: { id: giveawayId } });
if (!giveaway || giveaway.endedAt) {
throw new GiveawayError('not_found');
}
if (giveaway.paused) {
throw new GiveawayError('paused');
}
if (giveaway.entrants.includes(userId)) {
throw new GiveawayError('already_joined');
}
const guild = await context.client.guilds.fetch(giveaway.guildId);
const member = await guild.members.fetch(userId).catch(() => null);
if (!member) {
throw new GiveawayError('member_not_found');
}
const requirementError = await checkGiveawayRequirements(context, guild, member, giveaway);
if (requirementError) {
throw new GiveawayError(requirementError);
}
const updated = await context.prisma.giveaway.update({
where: { id: giveawayId },
data: { entrants: { push: userId } }
});
await updateGiveawayMessage(context, updated, locale);
return updated;
}
async function filterEligibleEntrants(
context: BotContext,
guild: Guild,
giveaway: Giveaway
): Promise<string[]> {
const eligible: string[] = [];
for (const userId of giveaway.entrants) {
const member = await guild.members.fetch(userId).catch(() => null);
if (!member) {
continue;
}
const error = await checkGiveawayRequirements(context, guild, member, giveaway);
if (!error) {
eligible.push(userId);
}
}
return eligible;
}
async function dmWinners(
context: BotContext,
giveaway: Giveaway,
winnerIds: string[],
locale: 'de' | 'en'
): Promise<void> {
for (const userId of winnerIds) {
try {
const user = await context.client.users.fetch(userId);
await user.send(
tf(locale, 'giveaway.dm.won', {
prize: giveaway.prize,
guild: (await context.client.guilds.fetch(giveaway.guildId)).name
})
);
} catch (error) {
logger.warn({ error, userId, giveawayId: giveaway.id }, 'Failed to DM giveaway winner');
}
}
}
export async function endGiveaway(context: BotContext, giveawayId: string): Promise<void> {
const giveaway = await context.prisma.giveaway.findUnique({ where: { id: giveawayId } });
if (!giveaway || giveaway.endedAt) {
return;
}
await cancelGiveawayJob(giveawayId);
const locale = await getGuildLocale(context.prisma, giveaway.guildId);
const guild = await context.client.guilds.fetch(giveaway.guildId);
const eligible = await filterEligibleEntrants(context, guild, giveaway);
const winners = pickRandomWinners(eligible, giveaway.winnerCount);
const updated = await context.prisma.giveaway.update({
where: { id: giveawayId },
data: {
endedAt: new Date(),
winners,
paused: false
}
});
await updateGiveawayMessage(context, updated, locale);
if (winners.length > 0) {
await dmWinners(context, updated, winners, locale);
}
}
export async function rerollGiveaway(
context: BotContext,
giveawayId: string,
locale: 'de' | 'en'
): Promise<Giveaway> {
const giveaway = await context.prisma.giveaway.findUnique({ where: { id: giveawayId } });
if (!giveaway || !giveaway.endedAt) {
throw new GiveawayError('not_ended');
}
const guild = await context.client.guilds.fetch(giveaway.guildId);
const eligible = (await filterEligibleEntrants(context, guild, giveaway)).filter(
(id) => !giveaway.winners.includes(id)
);
const newWinners = pickRandomWinners(eligible, giveaway.winnerCount);
const updated = await context.prisma.giveaway.update({
where: { id: giveawayId },
data: { winners: newWinners }
});
await updateGiveawayMessage(context, updated, locale);
if (newWinners.length > 0) {
await dmWinners(context, updated, newWinners, locale);
}
return updated;
}
export async function pauseGiveaway(
context: BotContext,
giveawayId: string,
locale: 'de' | 'en'
): Promise<Giveaway> {
const giveaway = await context.prisma.giveaway.findUnique({ where: { id: giveawayId } });
if (!giveaway || giveaway.endedAt) {
throw new GiveawayError('not_found');
}
const updated = await context.prisma.giveaway.update({
where: { id: giveawayId },
data: { paused: !giveaway.paused }
});
await updateGiveawayMessage(context, updated, locale);
return updated;
}
export async function deleteGiveaway(context: BotContext, giveawayId: string): Promise<void> {
const giveaway = await context.prisma.giveaway.findUnique({ where: { id: giveawayId } });
if (!giveaway) {
throw new GiveawayError('not_found');
}
await cancelGiveawayJob(giveawayId);
if (giveaway.messageId) {
try {
const channel = (await context.client.channels.fetch(giveaway.channelId)) as TextChannel;
const message = await channel.messages.fetch(giveaway.messageId);
await message.delete();
} catch {
// Message may already be gone
}
}
await context.prisma.giveaway.delete({ where: { id: giveawayId } });
}
export async function listActiveGiveaways(
context: BotContext,
guildId: string
): Promise<Giveaway[]> {
return context.prisma.giveaway.findMany({
where: { guildId, endedAt: null },
orderBy: { endsAt: 'asc' }
});
}

View File

@@ -0,0 +1,95 @@
import {
applyCommandDescription,
applyOptionDescription,
localizedChoice
} from '@nexumi/shared';
import { ChannelType, SlashCommandBuilder } from 'discord.js';
export const selfrolesCommandData = applyCommandDescription(
new SlashCommandBuilder()
.setName('selfroles')
.addSubcommandGroup((group) =>
applyCommandDescription(group.setName('panel'), 'selfroles.panel.description')
.addSubcommand((sub) =>
applyCommandDescription(sub.setName('create'), 'selfroles.panel.create.description')
.addStringOption((o) =>
applyOptionDescription(o.setName('title').setRequired(true), 'selfroles.panel.create.options.title')
)
.addStringOption((o) =>
applyOptionDescription(o.setName('description'), 'selfroles.panel.create.options.description')
)
.addStringOption((o) =>
applyOptionDescription(o.setName('mode').setRequired(true), 'selfroles.panel.create.options.mode')
.addChoices(
localizedChoice('selfroles.choice.mode.buttons', 'BUTTONS'),
localizedChoice('selfroles.choice.mode.dropdown', 'DROPDOWN'),
localizedChoice('selfroles.choice.mode.reactions', 'REACTIONS')
)
)
.addStringOption((o) =>
applyOptionDescription(
o.setName('behavior').setRequired(true),
'selfroles.panel.create.options.behavior'
)
.addChoices(
localizedChoice('selfroles.choice.behavior.normal', 'NORMAL'),
localizedChoice('selfroles.choice.behavior.unique', 'UNIQUE'),
localizedChoice('selfroles.choice.behavior.verify', 'VERIFY'),
localizedChoice('selfroles.choice.behavior.temporary', 'TEMPORARY')
)
)
.addStringOption((o) =>
applyOptionDescription(o.setName('roles').setRequired(true), 'selfroles.panel.create.options.roles')
)
.addChannelOption((o) =>
applyOptionDescription(o.setName('channel').setRequired(true), 'selfroles.panel.create.options.channel')
.addChannelTypes(ChannelType.GuildText, ChannelType.GuildAnnouncement)
)
.addStringOption((o) =>
applyOptionDescription(o.setName('duration'), 'selfroles.panel.create.options.duration')
)
)
.addSubcommand((sub) =>
applyCommandDescription(sub.setName('edit'), 'selfroles.panel.edit.description')
.addStringOption((o) =>
applyOptionDescription(o.setName('panel_id').setRequired(true), 'selfroles.panel.common.options.panel_id')
)
.addStringOption((o) =>
applyOptionDescription(o.setName('title'), 'selfroles.panel.create.options.title')
)
.addStringOption((o) =>
applyOptionDescription(o.setName('description'), 'selfroles.panel.create.options.description')
)
.addStringOption((o) =>
applyOptionDescription(o.setName('mode'), 'selfroles.panel.create.options.mode')
.addChoices(
localizedChoice('selfroles.choice.mode.buttons', 'BUTTONS'),
localizedChoice('selfroles.choice.mode.dropdown', 'DROPDOWN'),
localizedChoice('selfroles.choice.mode.reactions', 'REACTIONS')
)
)
.addStringOption((o) =>
applyOptionDescription(o.setName('behavior'), 'selfroles.panel.create.options.behavior')
.addChoices(
localizedChoice('selfroles.choice.behavior.normal', 'NORMAL'),
localizedChoice('selfroles.choice.behavior.unique', 'UNIQUE'),
localizedChoice('selfroles.choice.behavior.verify', 'VERIFY'),
localizedChoice('selfroles.choice.behavior.temporary', 'TEMPORARY')
)
)
.addStringOption((o) =>
applyOptionDescription(o.setName('roles'), 'selfroles.panel.create.options.roles')
)
.addStringOption((o) =>
applyOptionDescription(o.setName('duration'), 'selfroles.panel.create.options.duration')
)
)
.addSubcommand((sub) =>
applyCommandDescription(sub.setName('delete'), 'selfroles.panel.delete.description').addStringOption(
(o) =>
applyOptionDescription(o.setName('panel_id').setRequired(true), 'selfroles.panel.common.options.panel_id')
)
)
),
'selfroles.description'
);

View File

@@ -0,0 +1,203 @@
import { PermissionFlagsBits } from 'discord.js';
import {
SelfRoleBehaviorSchema,
SelfRoleModeSchema,
t,
tf
} from '@nexumi/shared';
import type { SlashCommand } from '../../types.js';
import { getGuildLocale } from '../../i18n.js';
import { requirePermission } from '../../permissions.js';
import { parseDuration } from '../moderation/duration.js';
import { selfrolesCommandData } from './command-definitions.js';
import {
createSelfRolePanel,
deleteSelfRolePanel,
parseRolesInput,
SelfRoleError,
updateSelfRolePanel
} from './service.js';
async function ensureManageGuild(
interaction: Parameters<SlashCommand['execute']>[0],
locale: 'de' | 'en'
): Promise<boolean> {
if (!interaction.inGuild()) {
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
return false;
}
return requirePermission(interaction, PermissionFlagsBits.ManageGuild, locale);
}
function parseOptionalDuration(
input: string | null,
locale: 'de' | 'en',
interaction: Parameters<SlashCommand['execute']>[0]
): number | undefined | null {
if (!input) {
return undefined;
}
try {
return parseDuration(input);
} catch {
interaction.reply({
content: t(locale, 'selfroles.error.invalid_duration'),
ephemeral: true
});
return null;
}
}
const selfrolesCommand: SlashCommand = {
data: selfrolesCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!(await ensureManageGuild(interaction, locale))) {
return;
}
const group = interaction.options.getSubcommandGroup(true);
const sub = interaction.options.getSubcommand(true);
if (group !== 'panel') {
return;
}
try {
if (sub === 'create') {
const behavior = SelfRoleBehaviorSchema.parse(
interaction.options.getString('behavior', true)
);
const mode = SelfRoleModeSchema.parse(interaction.options.getString('mode', true));
const channel = interaction.options.getChannel('channel', true);
if (!channel.isTextBased() || channel.isDMBased()) {
await interaction.reply({
content: t(locale, 'selfroles.error.invalid_channel'),
ephemeral: true
});
return;
}
let temporaryDurationMs: number | undefined;
if (behavior === 'TEMPORARY') {
const durationInput = interaction.options.getString('duration', true);
const parsed = parseOptionalDuration(durationInput, locale, interaction);
if (parsed === null) {
return;
}
if (!parsed || parsed < 60_000) {
await interaction.reply({
content: t(locale, 'selfroles.error.duration_too_short'),
ephemeral: true
});
return;
}
temporaryDurationMs = parsed;
}
const roles = parseRolesInput(interaction.options.getString('roles', true));
const panel = await createSelfRolePanel(
context,
{
guildId: interaction.guildId!,
channelId: channel.id,
title: interaction.options.getString('title', true),
description: interaction.options.getString('description'),
mode,
behavior,
roles,
temporaryDurationMs
},
locale
);
await interaction.reply({
content: tf(locale, 'selfroles.panel.created', { id: panel.id }),
ephemeral: true
});
return;
}
if (sub === 'edit') {
const panelId = interaction.options.getString('panel_id', true);
const behaviorRaw = interaction.options.getString('behavior');
const behavior = behaviorRaw ? SelfRoleBehaviorSchema.parse(behaviorRaw) : undefined;
const modeRaw = interaction.options.getString('mode');
const mode = modeRaw ? SelfRoleModeSchema.parse(modeRaw) : undefined;
let roles;
const rolesInput = interaction.options.getString('roles');
if (rolesInput) {
roles = parseRolesInput(rolesInput);
}
let temporaryDurationMs: number | undefined;
const durationInput = interaction.options.getString('duration');
if (durationInput) {
const parsed = parseOptionalDuration(durationInput, locale, interaction);
if (parsed === null) {
return;
}
if (!parsed || parsed < 60_000) {
await interaction.reply({
content: t(locale, 'selfroles.error.duration_too_short'),
ephemeral: true
});
return;
}
temporaryDurationMs = parsed;
} else if (behavior === 'TEMPORARY') {
await interaction.reply({
content: t(locale, 'selfroles.error.duration_required'),
ephemeral: true
});
return;
}
const descriptionValue = interaction.options.getString('description');
const panel = await updateSelfRolePanel(
context,
panelId,
interaction.guildId!,
{
title: interaction.options.getString('title') ?? undefined,
description: descriptionValue !== null ? descriptionValue : undefined,
mode,
behavior,
roles,
temporaryDurationMs
},
locale
);
await interaction.reply({
content: tf(locale, 'selfroles.panel.updated', { id: panel.id }),
ephemeral: true
});
return;
}
if (sub === 'delete') {
const panelId = interaction.options.getString('panel_id', true);
await deleteSelfRolePanel(context, panelId, interaction.guildId!);
await interaction.reply({
content: t(locale, 'selfroles.panel.deleted'),
ephemeral: true
});
}
} catch (error) {
if (error instanceof SelfRoleError) {
await interaction.reply({
content: t(locale, `selfroles.error.${error.code}`),
ephemeral: true
});
return;
}
throw error;
}
}
};
export const selfrolesCommands = [selfrolesCommand];

View File

@@ -0,0 +1,8 @@
export { selfrolesCommands } from './commands.js';
export { isSelfRoleInteraction, handleSelfRoleInteraction } from './interactions.js';
export { registerSelfRoleEvents } from './reactions.js';
export {
expireSelfRole,
scheduleSelfRoleExpiry,
cancelSelfRoleExpiry
} from './service.js';

View File

@@ -0,0 +1,132 @@
import type { ButtonInteraction, StringSelectMenuInteraction } from 'discord.js';
import { t } from '@nexumi/shared';
import type { BotContext } from '../../types.js';
import { getGuildLocale } from '../../i18n.js';
import {
SELF_ROLE_BTN_PREFIX,
SELF_ROLE_SEL_PREFIX,
SelfRoleError,
toggleSelfRole
} from './service.js';
export function isSelfRoleInteraction(customId: string): boolean {
return customId.startsWith(SELF_ROLE_BTN_PREFIX) || customId.startsWith(SELF_ROLE_SEL_PREFIX);
}
function parseButtonCustomId(customId: string): { panelId: string; roleId: string } | null {
if (!customId.startsWith(SELF_ROLE_BTN_PREFIX)) {
return null;
}
const rest = customId.slice(SELF_ROLE_BTN_PREFIX.length);
const colon = rest.indexOf(':');
if (colon === -1) {
return null;
}
const panelId = rest.slice(0, colon);
const roleId = rest.slice(colon + 1);
if (!panelId || !roleId) {
return null;
}
return { panelId, roleId };
}
function parseSelectCustomId(customId: string): string | null {
if (!customId.startsWith(SELF_ROLE_SEL_PREFIX)) {
return null;
}
return customId.slice(SELF_ROLE_SEL_PREFIX.length) || null;
}
async function replySelfRoleResult(
interaction: ButtonInteraction | StringSelectMenuInteraction,
locale: 'de' | 'en',
result: 'added' | 'removed' | 'unchanged',
behavior: string
): Promise<void> {
let key: string;
if (result === 'added') {
key = behavior === 'VERIFY' ? 'selfroles.role.verified' : 'selfroles.role.added';
} else if (result === 'removed') {
key = 'selfroles.role.removed';
} else {
key = 'selfroles.role.unchanged';
}
const content = t(locale, key);
if (interaction.replied || interaction.deferred) {
await interaction.followUp({ content, ephemeral: true });
} else {
await interaction.reply({ content, ephemeral: true });
}
}
async function handleRoleToggle(
interaction: ButtonInteraction | StringSelectMenuInteraction,
context: BotContext,
panelId: string,
roleId: string
): Promise<void> {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!interaction.inGuild() || !interaction.guild) {
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
return;
}
const panel = await context.prisma.selfRolePanel.findFirst({
where: { id: panelId, guildId: interaction.guild.id }
});
if (!panel) {
await interaction.reply({ content: t(locale, 'selfroles.error.not_found'), ephemeral: true });
return;
}
const member = await interaction.guild.members.fetch(interaction.user.id).catch(() => null);
if (!member) {
await interaction.reply({ content: t(locale, 'selfroles.error.member_not_found'), ephemeral: true });
return;
}
try {
const result = await toggleSelfRole(context, panel, member, roleId);
await replySelfRoleResult(interaction, locale, result, panel.behavior);
} catch (error) {
if (error instanceof SelfRoleError) {
await interaction.reply({
content: t(locale, `selfroles.error.${error.code}`),
ephemeral: true
});
return;
}
throw error;
}
}
export async function handleSelfRoleInteraction(
interaction: ButtonInteraction | StringSelectMenuInteraction,
context: BotContext
): Promise<void> {
if (interaction.isButton()) {
const parsed = parseButtonCustomId(interaction.customId);
if (!parsed) {
return;
}
await handleRoleToggle(interaction, context, parsed.panelId, parsed.roleId);
return;
}
const panelId = parseSelectCustomId(interaction.customId);
if (!panelId) {
return;
}
const roleId = interaction.values[0];
if (!roleId) {
return;
}
await handleRoleToggle(interaction, context, panelId, roleId);
}

View File

@@ -0,0 +1,132 @@
import { Events, type Client, type PartialUser, type User } from 'discord.js';
import { SelfRoleModeSchema, t } from '@nexumi/shared';
import type { BotContext } from '../../types.js';
import { getGuildLocale } from '../../i18n.js';
import { logger } from '../../logger.js';
import {
findPanelByMessageId,
matchReactionEntry,
parseRolesJson,
SelfRoleError,
toggleSelfRole
} from './service.js';
async function handleReactionToggle(
context: BotContext,
messageId: string,
userId: string,
emojiId: string | null,
emojiName: string,
add: boolean
): Promise<void> {
const panel = await findPanelByMessageId(context, messageId);
if (!panel || SelfRoleModeSchema.parse(panel.mode) !== 'REACTIONS') {
return;
}
const { entries } = parseRolesJson(panel.roles);
const entry = matchReactionEntry(entries, emojiId, emojiName);
if (!entry) {
return;
}
const guild = await context.client.guilds.fetch(panel.guildId).catch(() => null);
if (!guild) {
return;
}
const member = await guild.members.fetch(userId).catch(() => null);
if (!member || member.user.bot) {
return;
}
const behavior = panel.behavior;
const hasRole = member.roles.cache.has(entry.roleId);
if (add) {
if (behavior === 'VERIFY' || behavior === 'TEMPORARY') {
if (hasRole) {
return;
}
} else if (behavior === 'NORMAL' && hasRole) {
return;
}
} else {
if (behavior === 'VERIFY') {
return;
}
if (!hasRole) {
return;
}
}
try {
await toggleSelfRole(context, panel, member, entry.roleId);
} catch (error) {
if (error instanceof SelfRoleError) {
const locale = await getGuildLocale(context.prisma, panel.guildId);
try {
const user = await context.client.users.fetch(userId);
await user.send(t(locale, `selfroles.error.${error.code}`));
} catch {
// User may have DMs disabled
}
return;
}
logger.error({ error, panelId: panel.id, userId }, 'Self-role reaction handler failed');
}
}
export function registerSelfRoleEvents(client: Client, context: BotContext): void {
client.on(Events.MessageReactionAdd, async (reaction, user: User | PartialUser) => {
try {
if (user.bot || !reaction.message.guildId) {
return;
}
const message = reaction.message.partial
? await reaction.message.fetch().catch(() => null)
: reaction.message;
if (!message) {
return;
}
await handleReactionToggle(
context,
message.id,
user.id,
reaction.emoji.id,
reaction.emoji.name ?? '',
true
);
} catch (error) {
logger.error({ error }, 'Self-role reaction add failed');
}
});
client.on(Events.MessageReactionRemove, async (reaction: PartialMessageReaction, user: PartialUser) => {
try {
if (user.bot || !reaction.message.guildId) {
return;
}
const message = reaction.message.partial
? await reaction.message.fetch().catch(() => null)
: reaction.message;
if (!message) {
return;
}
await handleReactionToggle(
context,
message.id,
user.id,
reaction.emoji.id,
reaction.emoji.name ?? '',
false
);
} catch (error) {
logger.error({ error }, 'Self-role reaction remove failed');
}
});
}

View File

@@ -0,0 +1,602 @@
import {
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
EmbedBuilder,
PermissionFlagsBits,
StringSelectMenuBuilder,
type APIMessageComponentEmoji,
type GuildMember,
type Message,
type TextChannel
} from 'discord.js';
import {
SelfRoleBehaviorSchema,
SelfRoleEntrySchema,
SelfRoleModeSchema,
type SelfRoleBehavior,
type SelfRoleEntry,
type SelfRoleMode,
t
} from '@nexumi/shared';
import type { SelfRolePanel } from '@prisma/client';
import { ensureGuild } from '../../guild.js';
import { logger } from '../../logger.js';
import { ticketQueue } from '../../queues.js';
import type { BotContext } from '../../types.js';
export class SelfRoleError extends Error {
constructor(public readonly code: string) {
super(code);
}
}
export const SELF_ROLE_BTN_PREFIX = 'sr:btn:';
export const SELF_ROLE_SEL_PREFIX = 'sr:sel:';
type RolesStorage = {
entries: SelfRoleEntry[];
temporaryDurationMs?: number;
};
export function parseRolesInput(input: string): SelfRoleEntry[] {
const parts = input
.split(',')
.map((part) => part.trim())
.filter(Boolean);
if (parts.length === 0) {
throw new SelfRoleError('invalid_roles');
}
const entries: SelfRoleEntry[] = [];
for (const part of parts) {
const segments = part.split(':');
if (segments.length < 2) {
throw new SelfRoleError('invalid_roles');
}
const [roleId, label, emoji] = segments;
if (!roleId || !label) {
throw new SelfRoleError('invalid_roles');
}
entries.push(
SelfRoleEntrySchema.parse({
roleId: roleId.trim(),
label: label.trim(),
emoji: emoji?.trim() || undefined
})
);
}
if (entries.length > 25) {
throw new SelfRoleError('too_many_roles');
}
return entries;
}
export function parseRolesJson(raw: unknown): RolesStorage {
if (Array.isArray(raw)) {
return {
entries: raw
.map((item) => SelfRoleEntrySchema.safeParse(item))
.filter((result) => result.success)
.map((result) => result.data!)
};
}
if (raw && typeof raw === 'object') {
const obj = raw as { entries?: unknown; temporaryDurationMs?: unknown };
const entries = Array.isArray(obj.entries)
? obj.entries
.map((item) => SelfRoleEntrySchema.safeParse(item))
.filter((result) => result.success)
.map((result) => result.data!)
: [];
const temporaryDurationMs =
typeof obj.temporaryDurationMs === 'number' && obj.temporaryDurationMs > 0
? obj.temporaryDurationMs
: undefined;
return { entries, temporaryDurationMs };
}
return { entries: [] };
}
export function serializeRolesJson(
entries: SelfRoleEntry[],
temporaryDurationMs?: number
): RolesStorage {
if (temporaryDurationMs !== undefined) {
return { entries, temporaryDurationMs };
}
return { entries };
}
function parseButtonEmoji(
emoji?: string
): 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 };
}
function parseReactionEmoji(emoji?: string): string {
if (!emoji) {
throw new SelfRoleError('reaction_emoji_required');
}
const customMatch = emoji.match(/^<(a)?:(\w+):(\d+)>$/);
if (customMatch) {
return customMatch[3]!;
}
if (/^\d+$/.test(emoji)) {
return emoji;
}
return emoji;
}
export function buildPanelEmbed(panel: SelfRolePanel, locale: 'de' | 'en'): EmbedBuilder {
const { entries } = parseRolesJson(panel.roles);
const roleLines = entries.map((entry) => {
const prefix = entry.emoji ? `${entry.emoji} ` : '';
return `${prefix}**${entry.label}** — <@&${entry.roleId}>`;
});
const embed = new EmbedBuilder()
.setTitle(panel.title)
.setColor(0x6366f1)
.setDescription(panel.description ?? t(locale, 'selfroles.panel.defaultDescription'));
if (roleLines.length > 0) {
embed.addFields({
name: t(locale, 'selfroles.panel.rolesField'),
value: roleLines.join('\n')
});
}
const behavior = SelfRoleBehaviorSchema.parse(panel.behavior);
if (behavior === 'TEMPORARY') {
const { temporaryDurationMs } = parseRolesJson(panel.roles);
if (temporaryDurationMs) {
embed.setFooter({
text: t(locale, 'selfroles.panel.temporaryFooter')
});
}
}
return embed;
}
export function buildPanelComponents(panel: SelfRolePanel): ActionRowBuilder<
ButtonBuilder | StringSelectMenuBuilder
>[] {
const mode = SelfRoleModeSchema.parse(panel.mode);
const { entries } = parseRolesJson(panel.roles);
if (mode === 'REACTIONS' || entries.length === 0) {
return [];
}
if (mode === 'DROPDOWN') {
const select = new StringSelectMenuBuilder()
.setCustomId(`${SELF_ROLE_SEL_PREFIX}${panel.id}`)
.setPlaceholder('Select a role')
.setMinValues(1)
.setMaxValues(1)
.addOptions(
entries.map((entry) => ({
label: entry.label.slice(0, 100),
value: entry.roleId,
description: entry.description?.slice(0, 100),
emoji: parseButtonEmoji(entry.emoji)
}))
);
return [new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(select)];
}
const rows: ActionRowBuilder<ButtonBuilder>[] = [];
let currentRow = new ActionRowBuilder<ButtonBuilder>();
let buttonsInRow = 0;
for (const entry of entries) {
if (buttonsInRow >= 5) {
rows.push(currentRow);
currentRow = new ActionRowBuilder<ButtonBuilder>();
buttonsInRow = 0;
}
const button = new ButtonBuilder()
.setCustomId(`${SELF_ROLE_BTN_PREFIX}${panel.id}:${entry.roleId}`)
.setLabel(entry.label.slice(0, 80))
.setStyle(ButtonStyle.Secondary);
const emoji = parseButtonEmoji(entry.emoji);
if (emoji) {
button.setEmoji(emoji);
}
currentRow.addComponents(button);
buttonsInRow += 1;
if (rows.length >= 5) {
break;
}
}
if (buttonsInRow > 0 && rows.length < 5) {
rows.push(currentRow);
}
return rows;
}
async function applyReactionEmojis(message: Message, entries: SelfRoleEntry[]): Promise<void> {
for (const entry of entries) {
try {
const emoji = parseReactionEmoji(entry.emoji);
await message.react(emoji);
} catch (error) {
logger.warn({ error, panelId: message.id, roleId: entry.roleId }, 'Failed to add self-role reaction');
}
}
}
export async function scheduleSelfRoleExpiry(
guildId: string,
userId: string,
roleId: string,
expiresAt: Date
): Promise<void> {
const delay = Math.max(0, expiresAt.getTime() - Date.now());
await ticketQueue.add(
'selfRoleExpire',
{ guildId, userId, roleId },
{
delay,
jobId: `selfrole-expire-${guildId}-${userId}-${roleId}`,
removeOnComplete: true,
removeOnFail: 50
}
);
}
export async function cancelSelfRoleExpiry(
guildId: string,
userId: string,
roleId: string
): Promise<void> {
const job = await ticketQueue.getJob(`selfrole-expire-${guildId}-${userId}-${roleId}`);
await job?.remove();
}
export async function expireSelfRole(
context: BotContext,
guildId: string,
userId: string,
roleId: string
): Promise<void> {
const guild = await context.client.guilds.fetch(guildId).catch(() => null);
if (!guild) {
return;
}
const member = await guild.members.fetch(userId).catch(() => null);
if (!member?.roles.cache.has(roleId)) {
return;
}
const me = guild.members.me;
if (!me?.permissions.has(PermissionFlagsBits.ManageRoles)) {
logger.warn({ guildId, roleId }, 'Missing ManageRoles for self-role expiry');
return;
}
const role = guild.roles.cache.get(roleId);
if (!role || role.position >= me.roles.highest.position) {
logger.warn({ guildId, roleId }, 'Cannot remove self-role above bot hierarchy');
return;
}
await member.roles.remove(roleId, 'Temporary self-role expired').catch((error) => {
logger.warn({ error, guildId, userId, roleId }, 'Failed to remove expired self-role');
});
}
async function assertAssignableRole(member: GuildMember, roleId: string): Promise<void> {
const guild = member.guild;
const me = guild.members.me;
if (!me?.permissions.has(PermissionFlagsBits.ManageRoles)) {
throw new SelfRoleError('bot_missing_permissions');
}
const role = guild.roles.cache.get(roleId);
if (!role) {
throw new SelfRoleError('role_not_found');
}
if (role.position >= me.roles.highest.position) {
throw new SelfRoleError('role_hierarchy');
}
if (role.managed) {
throw new SelfRoleError('role_managed');
}
}
export async function toggleSelfRole(
context: BotContext,
panel: SelfRolePanel,
member: GuildMember,
roleId: string
): Promise<'added' | 'removed' | 'unchanged'> {
const { entries, temporaryDurationMs } = parseRolesJson(panel.roles);
const entry = entries.find((item) => item.roleId === roleId);
if (!entry) {
throw new SelfRoleError('role_not_in_panel');
}
await assertAssignableRole(member, roleId);
const behavior = SelfRoleBehaviorSchema.parse(panel.behavior);
const panelRoleIds = entries.map((item) => item.roleId);
const hasRole = member.roles.cache.has(roleId);
if (behavior === 'VERIFY') {
if (hasRole) {
return 'unchanged';
}
await member.roles.add(roleId);
return 'added';
}
if (behavior === 'UNIQUE') {
if (hasRole) {
await member.roles.remove(roleId);
return 'removed';
}
const toRemove = panelRoleIds.filter((id) => id !== roleId && member.roles.cache.has(id));
if (toRemove.length > 0) {
await member.roles.remove(toRemove);
}
await member.roles.add(roleId);
return 'added';
}
if (behavior === 'TEMPORARY') {
if (hasRole) {
await member.roles.remove(roleId);
await cancelSelfRoleExpiry(member.guild.id, member.id, roleId);
return 'removed';
}
await member.roles.add(roleId);
if (temporaryDurationMs) {
const expiresAt = new Date(Date.now() + temporaryDurationMs);
await scheduleSelfRoleExpiry(member.guild.id, member.id, roleId, expiresAt);
}
return 'added';
}
if (hasRole) {
await member.roles.remove(roleId);
return 'removed';
}
await member.roles.add(roleId);
return 'added';
}
export async function findPanelByMessageId(
context: BotContext,
messageId: string
): Promise<SelfRolePanel | null> {
return context.prisma.selfRolePanel.findFirst({
where: { messageId }
});
}
export async function createSelfRolePanel(
context: BotContext,
params: {
guildId: string;
channelId: string;
title: string;
description?: string | null;
mode: SelfRoleMode;
behavior: SelfRoleBehavior;
roles: SelfRoleEntry[];
temporaryDurationMs?: number;
},
locale: 'de' | 'en'
): Promise<SelfRolePanel> {
await ensureGuild(context.prisma, params.guildId);
if (params.behavior === 'TEMPORARY' && !params.temporaryDurationMs) {
throw new SelfRoleError('duration_required');
}
if (params.mode === 'REACTIONS') {
const missingEmoji = params.roles.some((entry) => !entry.emoji);
if (missingEmoji) {
throw new SelfRoleError('reaction_emoji_required');
}
}
const panel = await context.prisma.selfRolePanel.create({
data: {
guildId: params.guildId,
channelId: params.channelId,
title: params.title,
description: params.description ?? null,
mode: params.mode,
behavior: params.behavior,
roles: serializeRolesJson(params.roles, params.temporaryDurationMs)
}
});
const channel = (await context.client.channels.fetch(params.channelId)) as TextChannel;
const message = await channel.send({
embeds: [buildPanelEmbed(panel, locale)],
components: buildPanelComponents(panel)
});
if (params.mode === 'REACTIONS') {
await applyReactionEmojis(message, params.roles);
}
return context.prisma.selfRolePanel.update({
where: { id: panel.id },
data: { messageId: message.id }
});
}
export async function updateSelfRolePanel(
context: BotContext,
panelId: string,
guildId: string,
updates: {
title?: string;
description?: string | null;
mode?: SelfRoleMode;
behavior?: SelfRoleBehavior;
roles?: SelfRoleEntry[];
temporaryDurationMs?: number;
},
locale: 'de' | 'en'
): Promise<SelfRolePanel> {
const existing = await context.prisma.selfRolePanel.findFirst({
where: { id: panelId, guildId }
});
if (!existing) {
throw new SelfRoleError('not_found');
}
const mode = updates.mode ?? SelfRoleModeSchema.parse(existing.mode);
const behavior = updates.behavior ?? SelfRoleBehaviorSchema.parse(existing.behavior);
const roles = updates.roles ?? parseRolesJson(existing.roles).entries;
const { temporaryDurationMs: existingDuration } = parseRolesJson(existing.roles);
const temporaryDurationMs = updates.temporaryDurationMs ?? existingDuration;
if (behavior === 'TEMPORARY' && !temporaryDurationMs) {
throw new SelfRoleError('duration_required');
}
if (mode === 'REACTIONS' && roles.some((entry) => !entry.emoji)) {
throw new SelfRoleError('reaction_emoji_required');
}
const panel = await context.prisma.selfRolePanel.update({
where: { id: panelId },
data: {
title: updates.title ?? existing.title,
description: updates.description !== undefined ? updates.description : existing.description,
mode,
behavior,
roles: serializeRolesJson(roles, temporaryDurationMs)
}
});
if (panel.messageId) {
try {
const channel = (await context.client.channels.fetch(panel.channelId)) as TextChannel;
const message = await channel.messages.fetch(panel.messageId);
await message.edit({
embeds: [buildPanelEmbed(panel, locale)],
components: buildPanelComponents(panel)
});
if (mode === 'REACTIONS') {
const existingReactions = message.reactions.cache;
for (const reaction of existingReactions.values()) {
await reaction.remove().catch(() => undefined);
}
await applyReactionEmojis(message, roles);
}
} catch (error) {
logger.warn({ error, panelId }, 'Failed to update self-role panel message');
}
}
return panel;
}
export async function deleteSelfRolePanel(
context: BotContext,
panelId: string,
guildId: string
): Promise<void> {
const panel = await context.prisma.selfRolePanel.findFirst({
where: { id: panelId, guildId }
});
if (!panel) {
throw new SelfRoleError('not_found');
}
if (panel.messageId) {
try {
const channel = (await context.client.channels.fetch(panel.channelId)) as TextChannel;
const message = await channel.messages.fetch(panel.messageId);
await message.delete();
} catch {
// Message may already be gone
}
}
await context.prisma.selfRolePanel.delete({ where: { id: panelId } });
}
export function matchReactionEntry(
entries: SelfRoleEntry[],
emojiId: string | null,
emojiName: string
): SelfRoleEntry | null {
for (const entry of entries) {
if (!entry.emoji) {
continue;
}
const customMatch = entry.emoji.match(/^<(a)?:(\w+):(\d+)>$/);
if (customMatch) {
if (emojiId === customMatch[3]) {
return entry;
}
continue;
}
if (/^\d+$/.test(entry.emoji)) {
if (emojiId === entry.emoji) {
return entry;
}
continue;
}
if (!emojiId && entry.emoji === emojiName) {
return entry;
}
}
return null;
}

View File

@@ -0,0 +1,33 @@
import { ChannelType } from 'discord.js';
import { applyCommandDescription, applyOptionDescription } from '@nexumi/shared';
import { SlashCommandBuilder } from 'discord.js';
export const starboardCommandData = applyCommandDescription(
new SlashCommandBuilder()
.setName('starboard')
.addSubcommand((s) =>
applyCommandDescription(s.setName('setup'), 'starboard.setup.description')
.addChannelOption((o) =>
applyOptionDescription(
o.setName('channel').addChannelTypes(ChannelType.GuildText).setRequired(true),
'starboard.setup.options.channel'
)
)
.addIntegerOption((o) =>
applyOptionDescription(o.setName('threshold'), 'starboard.setup.options.threshold')
.setMinValue(1)
.setMaxValue(100)
.setRequired(true)
)
.addStringOption((o) =>
applyOptionDescription(o.setName('emoji'), 'starboard.setup.options.emoji')
)
.addBooleanOption((o) =>
applyOptionDescription(
o.setName('allow_self_star'),
'starboard.setup.options.allow_self_star'
)
)
),
'starboard.description'
);

View File

@@ -0,0 +1,49 @@
import { PermissionFlagsBits } from 'discord.js';
import { t } from '@nexumi/shared';
import type { SlashCommand } from '../../types.js';
import { getGuildLocale } from '../../i18n.js';
import { requirePermission } from '../../permissions.js';
import { starboardCommandData } from './command-definitions.js';
import { getStarboardConfig, updateStarboardConfig } from './service.js';
const starboardCommand: SlashCommand = {
data: starboardCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!interaction.inGuild()) {
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
return;
}
if (!(await requirePermission(interaction, PermissionFlagsBits.ManageGuild, locale))) {
return;
}
const guildId = interaction.guildId!;
const sub = interaction.options.getSubcommand();
if (sub === 'setup') {
const channel = interaction.options.getChannel('channel', true);
const threshold = interaction.options.getInteger('threshold', true);
const emoji = interaction.options.getString('emoji') ?? '⭐';
const allowSelfStar = interaction.options.getBoolean('allow_self_star') ?? false;
await updateStarboardConfig(context.prisma, guildId, {
enabled: true,
channelId: channel.id,
emoji,
threshold,
allowSelfStar
});
await interaction.reply({ content: t(locale, 'starboard.setupDone'), ephemeral: true });
return;
}
const config = await getStarboardConfig(context.prisma, guildId);
if (!config.enabled || !config.channelId) {
await interaction.reply({ content: t(locale, 'starboard.notConfigured'), ephemeral: true });
}
}
};
export const starboardCommands: SlashCommand[] = [starboardCommand];

View File

@@ -0,0 +1,29 @@
import type { BotContext } from '../../types.js';
import { logger } from '../../logger.js';
import { processStarboardReaction } from './service.js';
export function registerStarboardEvents(context: BotContext): void {
const handleReaction = async (
reaction: Parameters<typeof processStarboardReaction>[1]
): Promise<void> => {
try {
await processStarboardReaction(context, reaction);
} catch (error) {
logger.warn({ error }, 'Starboard reaction handler failed');
}
};
context.client.on('messageReactionAdd', async (_reaction, user) => {
if (user.bot) {
return;
}
await handleReaction(_reaction);
});
context.client.on('messageReactionRemove', async (_reaction, user) => {
if (user.bot) {
return;
}
await handleReaction(_reaction);
});
}

View File

@@ -0,0 +1,2 @@
export { starboardCommands } from './commands.js';
export { registerStarboardEvents } from './events.js';

View File

@@ -0,0 +1,251 @@
import {
EmbedBuilder,
type Emoji,
type Message,
type PartialMessageReaction,
type MessageReaction,
type TextChannel
} from 'discord.js';
import type { PrismaClient, StarboardConfig } from '@prisma/client';
import { t } from '@nexumi/shared';
import type { Locale } from '@nexumi/shared';
import { ensureGuild } from '../../guild.js';
import { logger } from '../../logger.js';
import type { BotContext } from '../../types.js';
import { getGuildLocale } from '../../i18n.js';
export async function getStarboardConfig(prisma: PrismaClient, guildId: string) {
await ensureGuild(prisma, guildId);
let config = await prisma.starboardConfig.findUnique({ where: { guildId } });
if (!config) {
config = await prisma.starboardConfig.create({ data: { guildId } });
}
return config;
}
export async function updateStarboardConfig(
prisma: PrismaClient,
guildId: string,
data: {
enabled: boolean;
channelId: string;
emoji: string;
threshold: number;
allowSelfStar: boolean;
}
) {
await ensureGuild(prisma, guildId);
return prisma.starboardConfig.upsert({
where: { guildId },
update: data,
create: { guildId, ...data }
});
}
export function emojiMatches(configEmoji: string, reactionEmoji: Emoji): boolean {
if (reactionEmoji.id) {
return (
configEmoji === reactionEmoji.identifier ||
configEmoji === reactionEmoji.toString() ||
configEmoji === `<:${reactionEmoji.name}:${reactionEmoji.id}>` ||
configEmoji === `<a:${reactionEmoji.name}:${reactionEmoji.id}>`
);
}
return configEmoji === reactionEmoji.name || configEmoji === reactionEmoji.toString();
}
export function shouldIgnoreMessage(message: Message, config: StarboardConfig): boolean {
if (message.author.bot) {
return true;
}
if (!message.guild || !message.channel.isTextBased() || message.channel.isDMBased()) {
return true;
}
if ('nsfw' in message.channel && message.channel.nsfw) {
return true;
}
if (config.ignoredChannelIds.includes(message.channel.id)) {
return true;
}
if (config.channelId && message.channel.id === config.channelId) {
return true;
}
return false;
}
export async function countMatchingStars(
message: Message,
config: StarboardConfig
): Promise<number> {
const reaction = message.reactions.cache.find((r) => emojiMatches(config.emoji, r.emoji));
if (!reaction || reaction.count === 0) {
return 0;
}
const fetchedReaction = reaction.partial ? await reaction.fetch() : reaction;
const users = await fetchedReaction.users.fetch();
let count = 0;
for (const [, user] of users) {
if (user.bot) {
continue;
}
if (!config.allowSelfStar && user.id === message.author.id) {
continue;
}
count++;
}
return count;
}
function resolveImageUrl(message: Message): string | undefined {
const attachment = message.attachments.find((a) => {
const type = a.contentType ?? '';
return type.startsWith('image/') || /\.(png|jpe?g|gif|webp)$/i.test(a.name);
});
if (attachment) {
return attachment.url;
}
const embedImage = message.embeds.find((e) => e.image?.url)?.image?.url;
return embedImage ?? undefined;
}
export function buildStarboardEmbed(
message: Message,
starCount: number,
config: StarboardConfig,
locale: Locale
): EmbedBuilder {
const content = message.content?.trim() || t(locale, 'starboard.noContent');
const embed = new EmbedBuilder()
.setColor(0x6366f1)
.setAuthor({
name: message.author.tag,
iconURL: message.author.displayAvatarURL()
})
.setDescription(content.slice(0, 4096))
.addFields({
name: t(locale, 'starboard.field.stars'),
value: `${config.emoji} **${starCount}**`,
inline: true
})
.addFields({
name: t(locale, 'starboard.field.source'),
value: `[${t(locale, 'starboard.jumpLink')}](${message.url})`,
inline: true
})
.setTimestamp(message.createdAt);
const imageUrl = resolveImageUrl(message);
if (imageUrl) {
embed.setImage(imageUrl);
}
return embed;
}
async function removeStarboardEntry(context: BotContext, entryId: string): Promise<void> {
const entry = await context.prisma.starboardEntry.findUnique({ where: { id: entryId } });
if (!entry) {
return;
}
try {
const config = await getStarboardConfig(context.prisma, entry.guildId);
if (config.channelId) {
const channel = (await context.client.channels.fetch(config.channelId)) as TextChannel;
const starboardMessage = await channel.messages.fetch(entry.starboardMessageId).catch(() => null);
await starboardMessage?.delete().catch(() => undefined);
}
} catch (error) {
logger.warn({ error, entryId }, 'Failed to delete starboard message');
}
await context.prisma.starboardEntry.delete({ where: { id: entryId } }).catch(() => undefined);
}
export async function processStarboardMessage(context: BotContext, message: Message): Promise<void> {
if (!message.guild) {
return;
}
const config = await getStarboardConfig(context.prisma, message.guild.id);
if (!config.enabled || !config.channelId) {
return;
}
if (shouldIgnoreMessage(message, config)) {
return;
}
const starCount = await countMatchingStars(message, config);
const locale = await getGuildLocale(context.prisma, message.guild.id);
const existing = await context.prisma.starboardEntry.findUnique({
where: {
guildId_sourceMessageId: {
guildId: message.guild.id,
sourceMessageId: message.id
}
}
});
if (starCount < config.threshold) {
if (existing) {
await removeStarboardEntry(context, existing.id);
}
return;
}
const embed = buildStarboardEmbed(message, starCount, config, locale);
const starboardChannel = (await context.client.channels.fetch(config.channelId)) as TextChannel;
if (existing) {
try {
const starboardMessage = await starboardChannel.messages.fetch(existing.starboardMessageId);
await starboardMessage.edit({ embeds: [embed] });
await context.prisma.starboardEntry.update({
where: { id: existing.id },
data: { starCount }
});
} catch (error) {
logger.warn({ error, messageId: message.id }, 'Failed to update starboard message');
}
return;
}
const starboardMessage = await starboardChannel.send({ embeds: [embed] });
await context.prisma.starboardEntry.create({
data: {
guildId: message.guild.id,
sourceChannelId: message.channel.id,
sourceMessageId: message.id,
starboardMessageId: starboardMessage.id,
starCount
}
});
}
export async function processStarboardReaction(
context: BotContext,
reaction: MessageReaction | PartialMessageReaction
): Promise<void> {
if (reaction.partial) {
await reaction.fetch();
}
const message = reaction.message;
if (message.partial) {
await message.fetch();
}
if (!message.guild || !message.author) {
return;
}
const config = await getStarboardConfig(context.prisma, message.guild.id);
if (!config.enabled || !config.channelId) {
return;
}
if (!emojiMatches(config.emoji, reaction.emoji)) {
return;
}
await processStarboardMessage(context, message as Message);
}

View File

@@ -0,0 +1,60 @@
import type { ButtonInteraction } from 'discord.js';
import { t } from '@nexumi/shared';
import type { BotContext } from '../../types.js';
import { getGuildLocale } from '../../i18n.js';
import {
applySuggestionVote,
SUGGESTION_VOTE_PREFIX,
SuggestionError
} from './service.js';
export { isSuggestionVoteButton as isSuggestionButton } from './service.js';
function parseSuggestionVoteButton(
customId: string
): { suggestionId: string; direction: 'up' | 'down' } | null {
if (!customId.startsWith(SUGGESTION_VOTE_PREFIX)) {
return null;
}
const rest = customId.slice(SUGGESTION_VOTE_PREFIX.length);
const lastColon = rest.lastIndexOf(':');
if (lastColon === -1) {
return null;
}
const suggestionId = rest.slice(0, lastColon);
const direction = rest.slice(lastColon + 1);
if (!suggestionId || (direction !== 'up' && direction !== 'down')) {
return null;
}
return { suggestionId, direction };
}
export async function handleSuggestionButton(
interaction: ButtonInteraction,
context: BotContext
): Promise<void> {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
const parsed = parseSuggestionVoteButton(interaction.customId);
if (!parsed || !interaction.guildId) {
await interaction.reply({ content: t(locale, 'suggestion.error.not_found'), ephemeral: true });
return;
}
try {
await applySuggestionVote(context, parsed.suggestionId, interaction.user.id, parsed.direction, locale);
await interaction.reply({
content: t(locale, parsed.direction === 'up' ? 'suggestion.votedUp' : 'suggestion.votedDown'),
ephemeral: true
});
} catch (error) {
if (error instanceof SuggestionError) {
await interaction.reply({
content: t(locale, `suggestion.error.${error.code}`),
ephemeral: true
});
return;
}
throw error;
}
}

View File

@@ -0,0 +1,90 @@
import { ChannelType } from 'discord.js';
import { applyCommandDescription, applyOptionDescription } from '@nexumi/shared';
import { SlashCommandBuilder } from 'discord.js';
export const suggestCommandData = applyCommandDescription(
new SlashCommandBuilder()
.setName('suggest')
.addStringOption((o) =>
applyOptionDescription(o.setName('content').setRequired(true), 'suggest.options.content')
),
'suggest.description'
);
export const suggestionCommandData = applyCommandDescription(
new SlashCommandBuilder()
.setName('suggestion')
.addSubcommand((s) =>
applyCommandDescription(s.setName('setup'), 'suggestion.setup.description')
.addChannelOption((o) =>
applyOptionDescription(
o.setName('open_channel').addChannelTypes(ChannelType.GuildText).setRequired(true),
'suggestion.setup.options.open_channel'
)
)
.addChannelOption((o) =>
applyOptionDescription(
o.setName('approved_channel').addChannelTypes(ChannelType.GuildText),
'suggestion.setup.options.approved_channel'
)
)
.addChannelOption((o) =>
applyOptionDescription(
o.setName('denied_channel').addChannelTypes(ChannelType.GuildText),
'suggestion.setup.options.denied_channel'
)
)
.addBooleanOption((o) =>
applyOptionDescription(o.setName('create_thread'), 'suggestion.setup.options.create_thread')
)
)
.addSubcommand((s) =>
applyCommandDescription(s.setName('approve'), 'suggestion.approve.description')
.addStringOption((o) =>
applyOptionDescription(
o.setName('suggestion_id').setRequired(true),
'suggestion.options.suggestion_id'
)
)
.addStringOption((o) =>
applyOptionDescription(o.setName('reason').setRequired(true), 'suggestion.options.reason')
)
)
.addSubcommand((s) =>
applyCommandDescription(s.setName('deny'), 'suggestion.deny.description')
.addStringOption((o) =>
applyOptionDescription(
o.setName('suggestion_id').setRequired(true),
'suggestion.options.suggestion_id'
)
)
.addStringOption((o) =>
applyOptionDescription(o.setName('reason').setRequired(true), 'suggestion.options.reason')
)
)
.addSubcommand((s) =>
applyCommandDescription(s.setName('consider'), 'suggestion.consider.description')
.addStringOption((o) =>
applyOptionDescription(
o.setName('suggestion_id').setRequired(true),
'suggestion.options.suggestion_id'
)
)
.addStringOption((o) =>
applyOptionDescription(o.setName('reason').setRequired(true), 'suggestion.options.reason')
)
)
.addSubcommand((s) =>
applyCommandDescription(s.setName('implement'), 'suggestion.implement.description')
.addStringOption((o) =>
applyOptionDescription(
o.setName('suggestion_id').setRequired(true),
'suggestion.options.suggestion_id'
)
)
.addStringOption((o) =>
applyOptionDescription(o.setName('reason').setRequired(true), 'suggestion.options.reason')
)
),
'suggestion.description'
);

View File

@@ -0,0 +1,121 @@
import { PermissionFlagsBits } from 'discord.js';
import { t, tf } from '@nexumi/shared';
import type { SlashCommand } from '../../types.js';
import { getGuildLocale } from '../../i18n.js';
import { requirePermission } from '../../permissions.js';
import { suggestCommandData, suggestionCommandData } from './command-definitions.js';
import {
createSuggestion,
SuggestionError,
updateSuggestionConfig,
updateSuggestionStatus
} from './service.js';
const suggestCommand: SlashCommand = {
data: suggestCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!interaction.inGuild()) {
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
return;
}
const content = interaction.options.getString('content', true);
await interaction.deferReply({ ephemeral: true });
try {
const suggestion = await createSuggestion(
context,
{
guildId: interaction.guildId!,
authorId: interaction.user.id,
content
},
locale
);
await interaction.editReply({
content: tf(locale, 'suggestion.created', { id: suggestion.id })
});
} catch (error) {
if (error instanceof SuggestionError) {
await interaction.editReply({
content: t(locale, `suggestion.error.${error.code}`)
});
return;
}
throw error;
}
}
};
const suggestionCommand: SlashCommand = {
data: suggestionCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!interaction.inGuild()) {
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
return;
}
if (!(await requirePermission(interaction, PermissionFlagsBits.ManageGuild, locale))) {
return;
}
const guildId = interaction.guildId!;
const sub = interaction.options.getSubcommand();
if (sub === 'setup') {
const openChannel = interaction.options.getChannel('open_channel', true);
const approvedChannel = interaction.options.getChannel('approved_channel');
const deniedChannel = interaction.options.getChannel('denied_channel');
const createThread = interaction.options.getBoolean('create_thread') ?? true;
await updateSuggestionConfig(context.prisma, guildId, {
enabled: true,
openChannelId: openChannel.id,
approvedChannelId: approvedChannel?.id ?? null,
deniedChannelId: deniedChannel?.id ?? null,
createThread
});
await interaction.reply({ content: t(locale, 'suggestion.setupDone'), ephemeral: true });
return;
}
const suggestionId = interaction.options.getString('suggestion_id', true);
const reason = interaction.options.getString('reason', true);
const statusMap = {
approve: 'APPROVED',
deny: 'DENIED',
consider: 'CONSIDERED',
implement: 'IMPLEMENTED'
} as const;
try {
await updateSuggestionStatus(
context,
{
suggestionId,
guildId,
status: statusMap[sub as keyof typeof statusMap],
reason
},
locale
);
await interaction.reply({
content: tf(locale, 'suggestion.staffUpdated', { status: sub }),
ephemeral: true
});
} catch (error) {
if (error instanceof SuggestionError) {
await interaction.reply({
content: t(locale, `suggestion.error.${error.code}`),
ephemeral: true
});
return;
}
throw error;
}
}
};
export const suggestionsCommands: SlashCommand[] = [suggestCommand, suggestionCommand];

View File

@@ -0,0 +1,2 @@
export { suggestionsCommands } from './commands.js';
export { isSuggestionButton, handleSuggestionButton } from './buttons.js';

View File

@@ -0,0 +1,341 @@
import {
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
EmbedBuilder,
type TextChannel
} from 'discord.js';
import type { PrismaClient, Suggestion } from '@prisma/client';
import { SuggestionStatusSchema, t, tf, type SuggestionStatus } from '@nexumi/shared';
import type { Locale } from '@nexumi/shared';
import { ensureGuild } from '../../guild.js';
import { logger } from '../../logger.js';
import type { BotContext } from '../../types.js';
export const SUGGESTION_VOTE_PREFIX = 'sug:vote:';
export function upvoteButtonId(suggestionId: string): string {
return `${SUGGESTION_VOTE_PREFIX}${suggestionId}:up`;
}
export function downvoteButtonId(suggestionId: string): string {
return `${SUGGESTION_VOTE_PREFIX}${suggestionId}:down`;
}
export function isSuggestionVoteButton(customId: string): boolean {
return customId.startsWith(SUGGESTION_VOTE_PREFIX);
}
export class SuggestionError extends Error {
constructor(public readonly code: string) {
super(code);
}
}
const STATUS_COLORS: Record<SuggestionStatus, number> = {
OPEN: 0x6366f1,
APPROVED: 0x22c55e,
DENIED: 0xef4444,
CONSIDERED: 0xeab308,
IMPLEMENTED: 0x3b82f6
};
export async function getSuggestionConfig(prisma: PrismaClient, guildId: string) {
await ensureGuild(prisma, guildId);
let config = await prisma.suggestionConfig.findUnique({ where: { guildId } });
if (!config) {
config = await prisma.suggestionConfig.create({ data: { guildId } });
}
return config;
}
export async function updateSuggestionConfig(
prisma: PrismaClient,
guildId: string,
data: {
enabled: boolean;
openChannelId: string;
approvedChannelId?: string | null;
deniedChannelId?: string | null;
createThread: boolean;
}
) {
await ensureGuild(prisma, guildId);
return prisma.suggestionConfig.upsert({
where: { guildId },
update: data,
create: { guildId, ...data }
});
}
function statusLabel(locale: Locale, status: SuggestionStatus): string {
return t(locale, `suggestion.status.${status.toLowerCase()}`);
}
export function buildSuggestionEmbed(
locale: Locale,
suggestion: Suggestion,
authorTag?: string
): EmbedBuilder {
const status = SuggestionStatusSchema.parse(suggestion.status);
const embed = new EmbedBuilder()
.setColor(STATUS_COLORS[status])
.setTitle(tf(locale, 'suggestion.embed.title', { id: suggestion.id.slice(-8) }))
.setDescription(suggestion.content.slice(0, 4096))
.addFields(
{
name: t(locale, 'suggestion.embed.status'),
value: statusLabel(locale, status),
inline: true
},
{
name: t(locale, 'suggestion.embed.votes'),
value: `👍 ${suggestion.upvotes} · 👎 ${suggestion.downvotes}`,
inline: true
}
)
.setFooter({
text: tf(locale, 'suggestion.embed.author', {
author: authorTag ?? `<@${suggestion.authorId}>`
})
})
.setTimestamp(suggestion.createdAt);
if (suggestion.staffReason) {
embed.addFields({
name: t(locale, 'suggestion.embed.staffReason'),
value: suggestion.staffReason.slice(0, 1024),
inline: false
});
}
return embed;
}
export function buildVoteButtons(suggestion: Suggestion, disabled = false): ActionRowBuilder<ButtonBuilder> {
return new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder()
.setCustomId(upvoteButtonId(suggestion.id))
.setLabel(`👍 ${suggestion.upvotes}`)
.setStyle(ButtonStyle.Success)
.setDisabled(disabled),
new ButtonBuilder()
.setCustomId(downvoteButtonId(suggestion.id))
.setLabel(`👎 ${suggestion.downvotes}`)
.setStyle(ButtonStyle.Danger)
.setDisabled(disabled)
);
}
export async function createSuggestion(
context: BotContext,
params: {
guildId: string;
authorId: string;
content: string;
},
locale: Locale
): Promise<Suggestion> {
const config = await getSuggestionConfig(context.prisma, params.guildId);
if (!config.enabled || !config.openChannelId) {
throw new SuggestionError('not_configured');
}
const channel = (await context.client.channels.fetch(config.openChannelId)) as TextChannel;
if (!channel?.isTextBased() || channel.isDMBased()) {
throw new SuggestionError('invalid_channel');
}
const suggestion = await context.prisma.suggestion.create({
data: {
guildId: params.guildId,
authorId: params.authorId,
content: params.content,
status: 'OPEN',
channelId: config.openChannelId
}
});
const author = await context.client.users.fetch(params.authorId).catch(() => null);
const embed = buildSuggestionEmbed(locale, suggestion, author?.tag);
const components = [buildVoteButtons(suggestion)];
const message = await channel.send({ embeds: [embed], components });
let threadId: string | null = null;
if (config.createThread) {
const thread = await message
.startThread({
name: tf(locale, 'suggestion.threadName', { id: suggestion.id.slice(-8) }),
autoArchiveDuration: 1440
})
.catch((error) => {
logger.warn({ error, suggestionId: suggestion.id }, 'Failed to create suggestion thread');
return null;
});
threadId = thread?.id ?? null;
}
return context.prisma.suggestion.update({
where: { id: suggestion.id },
data: {
messageId: message.id,
threadId
}
});
}
export async function applySuggestionVote(
context: BotContext,
suggestionId: string,
userId: string,
direction: 'up' | 'down',
locale: Locale
): Promise<Suggestion> {
const suggestion = await context.prisma.suggestion.findUnique({ where: { id: suggestionId } });
if (!suggestion || suggestion.status !== 'OPEN') {
throw new SuggestionError('not_open');
}
if (!suggestion.messageId || !suggestion.channelId) {
throw new SuggestionError('not_found');
}
const upIds = suggestion.voterUpIds.filter((id) => id !== userId);
const downIds = suggestion.voterDownIds.filter((id) => id !== userId);
if (direction === 'up') {
if (suggestion.voterUpIds.includes(userId)) {
throw new SuggestionError('already_voted');
}
upIds.push(userId);
} else {
if (suggestion.voterDownIds.includes(userId)) {
throw new SuggestionError('already_voted');
}
downIds.push(userId);
}
const upvotes = upIds.length;
const downvotes = downIds.length;
const updated = await context.prisma.suggestion.update({
where: { id: suggestionId },
data: {
voterUpIds: upIds,
voterDownIds: downIds,
upvotes,
downvotes
}
});
const channel = (await context.client.channels.fetch(suggestion.channelId)) as TextChannel;
const message = await channel.messages.fetch(suggestion.messageId).catch(() => null);
if (message) {
const author = await context.client.users.fetch(updated.authorId).catch(() => null);
await message.edit({
embeds: [buildSuggestionEmbed(locale, updated, author?.tag)],
components: [buildVoteButtons(updated)]
});
}
return updated;
}
async function notifyAuthor(
context: BotContext,
suggestion: Suggestion,
locale: Locale,
status: SuggestionStatus,
reason: string
): Promise<void> {
const user = await context.client.users.fetch(suggestion.authorId).catch(() => null);
if (!user) {
return;
}
await user
.send({
content: tf(locale, 'suggestion.dm.statusUpdate', {
status: statusLabel(locale, status),
reason
})
})
.catch(() => undefined);
}
export async function updateSuggestionStatus(
context: BotContext,
params: {
suggestionId: string;
guildId: string;
status: SuggestionStatus;
reason: string;
},
locale: Locale
): Promise<Suggestion> {
const suggestion = await context.prisma.suggestion.findFirst({
where: { id: params.suggestionId, guildId: params.guildId }
});
if (!suggestion) {
throw new SuggestionError('not_found');
}
const config = await getSuggestionConfig(context.prisma, params.guildId);
const author = await context.client.users.fetch(suggestion.authorId).catch(() => null);
const updatedData = {
status: params.status,
staffReason: params.reason
};
let targetChannelId = suggestion.channelId;
if (params.status === 'APPROVED' || params.status === 'IMPLEMENTED') {
targetChannelId = config.approvedChannelId ?? suggestion.channelId;
} else if (params.status === 'DENIED') {
targetChannelId = config.deniedChannelId ?? suggestion.channelId;
}
const embed = buildSuggestionEmbed(
locale,
{ ...suggestion, ...updatedData },
author?.tag
);
const components = [buildVoteButtons({ ...suggestion, ...updatedData }, true)];
let messageId = suggestion.messageId;
let channelId = suggestion.channelId;
if (targetChannelId && targetChannelId !== suggestion.channelId) {
const targetChannel = (await context.client.channels.fetch(targetChannelId)) as TextChannel;
if (targetChannel?.isTextBased() && !targetChannel.isDMBased()) {
const newMessage = await targetChannel.send({ embeds: [embed], components });
messageId = newMessage.id;
channelId = targetChannelId;
if (suggestion.messageId && suggestion.channelId) {
const oldChannel = (await context.client.channels.fetch(suggestion.channelId)) as TextChannel;
const oldMessage = await oldChannel?.messages.fetch(suggestion.messageId).catch(() => null);
await oldMessage?.delete().catch(() => undefined);
}
} else if (suggestion.messageId && suggestion.channelId) {
const channel = (await context.client.channels.fetch(suggestion.channelId)) as TextChannel;
const message = await channel?.messages.fetch(suggestion.messageId).catch(() => null);
await message?.edit({ embeds: [embed], components });
}
} else if (suggestion.messageId && suggestion.channelId) {
const channel = (await context.client.channels.fetch(suggestion.channelId)) as TextChannel;
const message = await channel?.messages.fetch(suggestion.messageId).catch(() => null);
await message?.edit({ embeds: [embed], components });
}
const updated = await context.prisma.suggestion.update({
where: { id: suggestion.id },
data: {
...updatedData,
messageId,
channelId
}
});
await notifyAuthor(context, updated, locale, params.status, params.reason);
return updated;
}

View File

@@ -0,0 +1,107 @@
import { applyCommandDescription, applyOptionDescription, localizedChoice } from '@nexumi/shared';
import { SlashCommandBuilder } from 'discord.js';
export const tagCommandData = applyCommandDescription(
new SlashCommandBuilder()
.setName('tag')
.addSubcommand((sub) =>
applyCommandDescription(sub.setName('create'), 'tag.create.description')
.addStringOption((o) =>
applyOptionDescription(o.setName('name').setRequired(true), 'tag.common.options.name')
)
.addStringOption((o) =>
applyOptionDescription(o.setName('response_type').setRequired(true), 'tag.create.options.response_type')
.addChoices(
localizedChoice('tag.choice.response_type.text', 'TEXT'),
localizedChoice('tag.choice.response_type.embed', 'EMBED')
)
)
.addStringOption((o) =>
applyOptionDescription(o.setName('content'), 'tag.create.options.content')
)
.addStringOption((o) =>
applyOptionDescription(o.setName('embed_title'), 'tag.create.options.embed_title')
)
.addStringOption((o) =>
applyOptionDescription(o.setName('embed_description'), 'tag.create.options.embed_description')
)
.addIntegerOption((o) =>
applyOptionDescription(
o.setName('embed_color').setMinValue(0).setMaxValue(0xffffff),
'tag.create.options.embed_color'
)
)
.addStringOption((o) =>
applyOptionDescription(o.setName('trigger_word'), 'tag.create.options.trigger_word')
)
.addStringOption((o) =>
applyOptionDescription(o.setName('allowed_roles'), 'tag.create.options.allowed_roles')
)
.addStringOption((o) =>
applyOptionDescription(o.setName('allowed_channels'), 'tag.create.options.allowed_channels')
)
)
.addSubcommand((sub) =>
applyCommandDescription(sub.setName('edit'), 'tag.edit.description')
.addStringOption((o) =>
applyOptionDescription(o.setName('name').setRequired(true), 'tag.common.options.name')
)
.addStringOption((o) =>
applyOptionDescription(o.setName('new_name'), 'tag.edit.options.new_name')
)
.addStringOption((o) =>
applyOptionDescription(o.setName('response_type'), 'tag.create.options.response_type')
.addChoices(
localizedChoice('tag.choice.response_type.text', 'TEXT'),
localizedChoice('tag.choice.response_type.embed', 'EMBED')
)
)
.addStringOption((o) =>
applyOptionDescription(o.setName('content'), 'tag.create.options.content')
)
.addStringOption((o) =>
applyOptionDescription(o.setName('embed_title'), 'tag.create.options.embed_title')
)
.addStringOption((o) =>
applyOptionDescription(o.setName('embed_description'), 'tag.create.options.embed_description')
)
.addIntegerOption((o) =>
applyOptionDescription(
o.setName('embed_color').setMinValue(0).setMaxValue(0xffffff),
'tag.create.options.embed_color'
)
)
.addStringOption((o) =>
applyOptionDescription(o.setName('trigger_word'), 'tag.create.options.trigger_word')
)
.addStringOption((o) =>
applyOptionDescription(o.setName('allowed_roles'), 'tag.create.options.allowed_roles')
)
.addStringOption((o) =>
applyOptionDescription(o.setName('allowed_channels'), 'tag.create.options.allowed_channels')
)
)
.addSubcommand((sub) =>
applyCommandDescription(sub.setName('delete'), 'tag.delete.description').addStringOption((o) =>
applyOptionDescription(o.setName('name').setRequired(true), 'tag.common.options.name')
)
)
.addSubcommand((sub) =>
applyCommandDescription(sub.setName('list'), 'tag.list.description')
)
.addSubcommand((sub) =>
applyCommandDescription(sub.setName('info'), 'tag.info.description').addStringOption((o) =>
applyOptionDescription(o.setName('name').setRequired(true), 'tag.common.options.name')
)
)
.addSubcommand((sub) =>
applyCommandDescription(sub.setName('run'), 'tag.run.description')
.addStringOption((o) =>
applyOptionDescription(o.setName('name').setRequired(true), 'tag.common.options.name')
)
.addStringOption((o) =>
applyOptionDescription(o.setName('args'), 'tag.run.options.args')
)
),
'tag.description'
);

View File

@@ -0,0 +1,199 @@
import { PermissionFlagsBits } from 'discord.js';
import { TagResponseTypeSchema, t, tf } from '@nexumi/shared';
import type { SlashCommand } from '../../types.js';
import { getGuildLocale } from '../../i18n.js';
import { requirePermission } from '../../permissions.js';
import { tagCommandData } from './command-definitions.js';
import {
createTag,
deleteTag,
executeTag,
getTagByName,
listTags,
parseIdList,
TagError,
updateTag
} from './service.js';
async function ensureManageGuild(
interaction: Parameters<SlashCommand['execute']>[0],
locale: 'de' | 'en'
): Promise<boolean> {
if (!interaction.inGuild()) {
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
return false;
}
return requirePermission(interaction, PermissionFlagsBits.ManageGuild, locale);
}
function buildEmbedPayload(interaction: Parameters<SlashCommand['execute']>[0]) {
const title = interaction.options.getString('embed_title');
const description = interaction.options.getString('embed_description');
const color = interaction.options.getInteger('embed_color');
if (!title && !description && color === null) {
return undefined;
}
return {
title: title ?? undefined,
description: description ?? undefined,
color: color ?? undefined
};
}
const tagCommand: SlashCommand = {
data: tagCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!interaction.inGuild() || !interaction.guild) {
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
return;
}
const sub = interaction.options.getSubcommand(true);
try {
if (sub === 'run') {
const name = interaction.options.getString('name', true);
const args = interaction.options.getString('args') ?? '';
const member = await interaction.guild.members.fetch(interaction.user.id).catch(() => null);
const payload = await executeTag(
context,
interaction.guildId!,
name,
member,
interaction.guild,
interaction.channelId!,
args
);
await interaction.reply(payload);
return;
}
if (!(await ensureManageGuild(interaction, locale))) {
return;
}
if (sub === 'create') {
const responseType = TagResponseTypeSchema.parse(
interaction.options.getString('response_type', true)
);
const tag = await createTag(context, {
guildId: interaction.guildId!,
name: interaction.options.getString('name', true),
responseType,
content: interaction.options.getString('content'),
embed: buildEmbedPayload(interaction) ?? null,
triggerWord: interaction.options.getString('trigger_word'),
allowedRoleIds: parseIdList(interaction.options.getString('allowed_roles')),
allowedChannelIds: parseIdList(interaction.options.getString('allowed_channels')),
createdById: interaction.user.id
});
await interaction.reply({
content: tf(locale, 'tag.created', { name: tag.name }),
ephemeral: true
});
return;
}
if (sub === 'edit') {
const tag = await updateTag(context, interaction.guildId!, interaction.options.getString('name', true), {
newName: interaction.options.getString('new_name') ?? undefined,
responseType: interaction.options.getString('response_type')
? TagResponseTypeSchema.parse(interaction.options.getString('response_type'))
: undefined,
content: interaction.options.has('content')
? interaction.options.getString('content')
: undefined,
embed: interaction.options.has('embed_title') ||
interaction.options.has('embed_description') ||
interaction.options.has('embed_color')
? buildEmbedPayload(interaction) ?? null
: undefined,
triggerWord: interaction.options.has('trigger_word')
? interaction.options.getString('trigger_word')
: undefined,
allowedRoleIds: interaction.options.has('allowed_roles')
? parseIdList(interaction.options.getString('allowed_roles'))
: undefined,
allowedChannelIds: interaction.options.has('allowed_channels')
? parseIdList(interaction.options.getString('allowed_channels'))
: undefined
});
await interaction.reply({
content: tf(locale, 'tag.updated', { name: tag.name }),
ephemeral: true
});
return;
}
if (sub === 'delete') {
await deleteTag(context, interaction.guildId!, interaction.options.getString('name', true));
await interaction.reply({ content: t(locale, 'tag.deleted'), ephemeral: true });
return;
}
if (sub === 'list') {
const tags = await listTags(context, interaction.guildId!);
if (tags.length === 0) {
await interaction.reply({ content: t(locale, 'tag.list.empty'), ephemeral: true });
return;
}
const lines = tags.map(
(tag) =>
`\`${tag.name}\`${TagResponseTypeSchema.parse(tag.responseType)} (${tag.useCount} ${t(locale, 'tag.list.uses')})`
);
await interaction.reply({
content: `${t(locale, 'tag.list.header')}\n${lines.join('\n')}`,
ephemeral: true
});
return;
}
if (sub === 'info') {
const tag = await getTagByName(context, interaction.guildId!, interaction.options.getString('name', true));
if (!tag) {
await interaction.reply({ content: t(locale, 'tag.error.not_found'), ephemeral: true });
return;
}
const lines = [
tf(locale, 'tag.info.name', { name: tag.name }),
tf(locale, 'tag.info.type', { type: tag.responseType }),
tf(locale, 'tag.info.uses', { count: tag.useCount }),
tag.triggerWord
? tf(locale, 'tag.info.trigger', { word: tag.triggerWord })
: t(locale, 'tag.info.noTrigger'),
tag.allowedRoleIds.length > 0
? tf(locale, 'tag.info.roles', { roles: tag.allowedRoleIds.map((id) => `<@&${id}>`).join(', ') })
: t(locale, 'tag.info.allRoles'),
tag.allowedChannelIds.length > 0
? tf(locale, 'tag.info.channels', {
channels: tag.allowedChannelIds.map((id) => `<#${id}>`).join(', ')
})
: t(locale, 'tag.info.allChannels')
];
await interaction.reply({
content: lines.join('\n'),
ephemeral: true
});
}
} catch (error) {
if (error instanceof TagError) {
await interaction.reply({
content: t(locale, `tag.error.${error.code}`),
ephemeral: true
});
return;
}
throw error;
}
}
};
export const tagsCommands = [tagCommand];

View File

@@ -0,0 +1,50 @@
import { Events, type Client } from 'discord.js';
import { t } from '@nexumi/shared';
import type { BotContext } from '../../types.js';
import { getGuildLocale } from '../../i18n.js';
import { logger } from '../../logger.js';
import {
buildTagReply,
findTriggeredTag,
memberCanUseTag,
TagError
} from './service.js';
export function registerTagEvents(client: Client, context: BotContext): void {
client.on(Events.MessageCreate, async (message) => {
try {
if (!message.guild || message.author.bot || !message.content.trim()) {
return;
}
const tag = await findTriggeredTag(context, message.guild.id, message.content);
if (!tag) {
return;
}
const member = await message.guild.members.fetch(message.author.id).catch(() => null);
if (!member) {
return;
}
if (!memberCanUseTag(tag, member, message.channel.id)) {
return;
}
await context.prisma.tag.update({
where: { id: tag.id },
data: { useCount: { increment: 1 } }
});
const payload = buildTagReply(tag, member, message.guild, '');
await message.reply(payload);
} catch (error) {
if (error instanceof TagError) {
const locale = await getGuildLocale(context.prisma, message.guild?.id ?? null);
logger.debug({ code: error.code, tag: error.message }, t(locale, `tag.error.${error.code}`));
return;
}
logger.error({ error, messageId: message.id }, 'Tag auto-responder failed');
}
});
}

View File

@@ -0,0 +1,2 @@
export { tagsCommands } from './commands.js';
export { registerTagEvents } from './events.js';

View File

@@ -0,0 +1,286 @@
import { EmbedBuilder, type Guild, type GuildMember, type MessageReplyOptions } from 'discord.js';
import {
applyTagPlaceholders,
TagResponseTypeSchema,
WelcomeEmbedSchema,
type TagResponseType
} from '@nexumi/shared';
import type { Tag } from '@prisma/client';
import { ensureGuild } from '../../guild.js';
import type { BotContext } from '../../types.js';
export class TagError extends Error {
constructor(public readonly code: string) {
super(code);
}
}
export function normalizeTagName(name: string): string {
return name.trim().toLowerCase();
}
export function parseIdList(input: string | null | undefined): string[] {
if (!input?.trim()) {
return [];
}
return input
.split(',')
.map((part) => part.trim())
.filter(Boolean);
}
function applyRandomPlaceholders(template: string): string {
return template.replace(/\{random:([^}]+)\}/g, (_match, group: string) => {
const options = group.split('|').map((part) => part.trim()).filter(Boolean);
if (options.length === 0) {
return '';
}
return options[Math.floor(Math.random() * options.length)]!;
});
}
export function buildTagPlaceholderVars(
member: GuildMember | null,
guild: Guild,
args: string
): Record<string, string> {
return {
user: member ? `<@${member.id}>` : '',
'user.name': member?.displayName ?? '',
'user.tag': member?.user.tag ?? '',
'user.id': member?.id ?? '',
server: guild.name,
args
};
}
export function renderTagContent(template: string, vars: Record<string, string>): string {
const withRandom = applyRandomPlaceholders(template);
return applyTagPlaceholders(withRandom, vars);
}
export function parseTagEmbed(raw: unknown) {
const parsed = WelcomeEmbedSchema.safeParse(raw);
return parsed.success ? parsed.data : null;
}
export function buildTagReply(
tag: Tag,
member: GuildMember | null,
guild: Guild,
args: string
): MessageReplyOptions {
const vars = buildTagPlaceholderVars(member, guild, args);
const responseType = TagResponseTypeSchema.parse(tag.responseType);
if (responseType === 'EMBED') {
const embedData = parseTagEmbed(tag.embed);
const embed = new EmbedBuilder().setColor(embedData?.color ?? 0x6366f1);
if (embedData?.title) {
embed.setTitle(renderTagContent(embedData.title, vars));
}
if (embedData?.description) {
embed.setDescription(renderTagContent(embedData.description, vars));
}
return { embeds: [embed] };
}
const content = renderTagContent(tag.content ?? '', vars);
return { content: content || '—' };
}
export function memberCanUseTag(tag: Tag, member: GuildMember, channelId: string): boolean {
if (tag.allowedRoleIds.length > 0) {
const hasRole = tag.allowedRoleIds.some((roleId) => member.roles.cache.has(roleId));
if (!hasRole) {
return false;
}
}
if (tag.allowedChannelIds.length > 0 && !tag.allowedChannelIds.includes(channelId)) {
return false;
}
return true;
}
export async function getTagByName(
context: BotContext,
guildId: string,
name: string
): Promise<Tag | null> {
return context.prisma.tag.findUnique({
where: {
guildId_name: {
guildId,
name: normalizeTagName(name)
}
}
});
}
export async function createTag(
context: BotContext,
params: {
guildId: string;
name: string;
responseType: TagResponseType;
content?: string | null;
embed?: { title?: string; description?: string; color?: number } | null;
triggerWord?: string | null;
allowedRoleIds?: string[];
allowedChannelIds?: string[];
createdById: string;
}
): Promise<Tag> {
await ensureGuild(context.prisma, params.guildId);
const name = normalizeTagName(params.name);
if (!name || !/^[a-z0-9_-]{1,32}$/.test(name)) {
throw new TagError('invalid_name');
}
if (params.responseType === 'TEXT' && !params.content?.trim()) {
throw new TagError('content_required');
}
if (params.responseType === 'EMBED' && !params.embed?.title && !params.embed?.description) {
throw new TagError('embed_required');
}
return context.prisma.tag.create({
data: {
guildId: params.guildId,
name,
responseType: params.responseType,
content: params.content ?? null,
embed: params.embed ?? null,
triggerWord: params.triggerWord?.trim() || null,
allowedRoleIds: params.allowedRoleIds ?? [],
allowedChannelIds: params.allowedChannelIds ?? [],
createdById: params.createdById
}
});
}
export async function updateTag(
context: BotContext,
guildId: string,
name: string,
updates: {
newName?: string;
responseType?: TagResponseType;
content?: string | null;
embed?: { title?: string; description?: string; color?: number } | null;
triggerWord?: string | null;
allowedRoleIds?: string[];
allowedChannelIds?: string[];
}
): Promise<Tag> {
const existing = await getTagByName(context, guildId, name);
if (!existing) {
throw new TagError('not_found');
}
const responseType = updates.responseType ?? TagResponseTypeSchema.parse(existing.responseType);
const content = updates.content !== undefined ? updates.content : existing.content;
const embed = updates.embed !== undefined ? updates.embed : parseTagEmbed(existing.embed);
if (responseType === 'TEXT' && !content?.trim()) {
throw new TagError('content_required');
}
if (responseType === 'EMBED' && !embed?.title && !embed?.description) {
throw new TagError('embed_required');
}
let newName: string | undefined;
if (updates.newName) {
newName = normalizeTagName(updates.newName);
if (!newName || !/^[a-z0-9_-]{1,32}$/.test(newName)) {
throw new TagError('invalid_name');
}
}
return context.prisma.tag.update({
where: { id: existing.id },
data: {
name: newName ?? existing.name,
responseType,
content,
embed: embed ?? null,
triggerWord:
updates.triggerWord !== undefined ? updates.triggerWord?.trim() || null : existing.triggerWord,
allowedRoleIds: updates.allowedRoleIds ?? existing.allowedRoleIds,
allowedChannelIds: updates.allowedChannelIds ?? existing.allowedChannelIds
}
});
}
export async function deleteTag(context: BotContext, guildId: string, name: string): Promise<void> {
const existing = await getTagByName(context, guildId, name);
if (!existing) {
throw new TagError('not_found');
}
await context.prisma.tag.delete({ where: { id: existing.id } });
}
export async function listTags(context: BotContext, guildId: string): Promise<Tag[]> {
return context.prisma.tag.findMany({
where: { guildId },
orderBy: { name: 'asc' }
});
}
export async function executeTag(
context: BotContext,
guildId: string,
name: string,
member: GuildMember | null,
guild: Guild,
channelId: string,
args: string
): Promise<MessageReplyOptions> {
const tag = await getTagByName(context, guildId, name);
if (!tag) {
throw new TagError('not_found');
}
if (member && !memberCanUseTag(tag, member, channelId)) {
throw new TagError('not_allowed');
}
await context.prisma.tag.update({
where: { id: tag.id },
data: { useCount: { increment: 1 } }
});
return buildTagReply(tag, member, guild, args);
}
export function matchesTriggerWord(content: string, triggerWord: string): boolean {
const escaped = triggerWord.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const pattern = new RegExp(`\\b${escaped}\\b`, 'i');
return pattern.test(content);
}
export async function findTriggeredTag(
context: BotContext,
guildId: string,
content: string
): Promise<Tag | null> {
const tags = await context.prisma.tag.findMany({
where: {
guildId,
triggerWord: { not: null }
}
});
for (const tag of tags) {
if (tag.triggerWord && matchesTriggerWord(content, tag.triggerWord)) {
return tag;
}
}
return null;
}

View File

@@ -0,0 +1,64 @@
import { ChannelType } from 'discord.js';
import { applyCommandDescription, applyOptionDescription } from '@nexumi/shared';
import { SlashCommandBuilder } from 'discord.js';
export const voiceCommandData = applyCommandDescription(
new SlashCommandBuilder()
.setName('voice')
.addSubcommand((s) =>
applyCommandDescription(s.setName('setup'), 'tempvoice.setup.description')
.addChannelOption((o) =>
applyOptionDescription(
o.setName('hub_channel').addChannelTypes(ChannelType.GuildVoice).setRequired(true),
'tempvoice.setup.options.hub_channel'
)
)
.addChannelOption((o) =>
applyOptionDescription(
o.setName('category').addChannelTypes(ChannelType.GuildCategory).setRequired(true),
'tempvoice.setup.options.category'
)
)
.addStringOption((o) =>
applyOptionDescription(o.setName('default_name'), 'tempvoice.setup.options.default_name')
)
.addIntegerOption((o) =>
applyOptionDescription(
o.setName('limit').setMinValue(0).setMaxValue(99),
'tempvoice.setup.options.limit'
)
)
)
.addSubcommand((s) =>
applyCommandDescription(s.setName('rename'), 'tempvoice.rename.description')
.addStringOption((o) =>
applyOptionDescription(o.setName('name').setRequired(true), 'tempvoice.rename.options.name')
)
)
.addSubcommand((s) =>
applyCommandDescription(s.setName('limit'), 'tempvoice.limit.description')
.addIntegerOption((o) =>
applyOptionDescription(
o.setName('limit').setRequired(true).setMinValue(0).setMaxValue(99),
'tempvoice.limit.options.limit'
)
)
)
.addSubcommand((s) => applyCommandDescription(s.setName('lock'), 'tempvoice.lock.description'))
.addSubcommand((s) => applyCommandDescription(s.setName('hide'), 'tempvoice.hide.description'))
.addSubcommand((s) =>
applyCommandDescription(s.setName('kick'), 'tempvoice.kick.description')
.addUserOption((o) =>
applyOptionDescription(o.setName('user').setRequired(true), 'tempvoice.kick.options.user')
)
)
.addSubcommand((s) =>
applyCommandDescription(s.setName('transfer'), 'tempvoice.transfer.description')
.addUserOption((o) =>
applyOptionDescription(o.setName('user').setRequired(true), 'tempvoice.transfer.options.user')
)
)
.addSubcommand((s) => applyCommandDescription(s.setName('claim'), 'tempvoice.claim.description'))
.addSubcommand((s) => applyCommandDescription(s.setName('panel'), 'tempvoice.panel.description')),
'tempvoice.description'
);

View File

@@ -0,0 +1,201 @@
import { PermissionFlagsBits } from 'discord.js';
import { t, tf } from '@nexumi/shared';
import type { SlashCommand } from '../../types.js';
import { getGuildLocale } from '../../i18n.js';
import { requirePermission } from '../../permissions.js';
import { voiceCommandData } from './command-definitions.js';
import {
buildControlPanelEmbed,
buildControlPanelRows,
claimTempChannel,
fetchCategory,
getTempVoiceChannelForMember,
getTempVoiceConfig,
hideTempChannel,
kickFromTempChannel,
lockTempChannel,
renameTempChannel,
setTempChannelLimit,
transferTempChannelOwnership,
updateTempVoiceConfig
} from './service.js';
async function requireOwnedTempChannel(
interaction: Parameters<SlashCommand['execute']>[0],
context: Parameters<SlashCommand['execute']>[1],
locale: 'de' | 'en'
) {
if (!interaction.inGuild()) {
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
return null;
}
const guildMember =
interaction.guild!.members.cache.get(interaction.user.id) ??
(await interaction.guild!.members.fetch(interaction.user.id));
const owned = await getTempVoiceChannelForMember(context.prisma, interaction.guildId!, guildMember);
if (!owned || owned.record.ownerId !== interaction.user.id) {
await interaction.reply({ content: t(locale, 'tempvoice.error.notOwner'), ephemeral: true });
return null;
}
return owned;
}
const voiceCommand: SlashCommand = {
data: voiceCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!interaction.inGuild()) {
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
return;
}
const guildId = interaction.guildId!;
const sub = interaction.options.getSubcommand();
if (sub === 'setup') {
if (!(await requirePermission(interaction, PermissionFlagsBits.ManageGuild, locale))) {
return;
}
const hub = interaction.options.getChannel('hub_channel', true);
const category = interaction.options.getChannel('category', true);
const defaultName = interaction.options.getString('default_name');
const limit = interaction.options.getInteger('limit');
const categoryChannel = await fetchCategory(interaction.guild!, category.id);
if (!categoryChannel) {
await interaction.reply({ content: t(locale, 'tempvoice.error.invalidCategory'), ephemeral: true });
return;
}
await updateTempVoiceConfig(context.prisma, guildId, {
enabled: true,
hubChannelId: hub.id,
categoryId: category.id,
defaultName: defaultName ?? undefined,
defaultLimit: limit ?? undefined
});
await interaction.reply({ content: t(locale, 'tempvoice.setup.done'), ephemeral: true });
return;
}
const config = await getTempVoiceConfig(context.prisma, guildId);
if (!config.enabled) {
await interaction.reply({ content: t(locale, 'tempvoice.error.disabled'), ephemeral: true });
return;
}
if (sub === 'panel') {
const owned = await requireOwnedTempChannel(interaction, context, locale);
if (!owned) {
return;
}
await interaction.reply({
embeds: [buildControlPanelEmbed(owned.channel.name, locale)],
components: buildControlPanelRows(owned.channel.id, locale),
ephemeral: true
});
return;
}
if (sub === 'claim') {
const member = await interaction.guild!.members.fetch(interaction.user.id);
const inChannel = await getTempVoiceChannelForMember(context.prisma, guildId, member);
if (!inChannel) {
await interaction.reply({ content: t(locale, 'tempvoice.error.notInChannel'), ephemeral: true });
return;
}
const result = await claimTempChannel(context, inChannel.record, inChannel.channel, member, locale);
if (result === 'owner_present') {
await interaction.reply({ content: t(locale, 'tempvoice.claim.ownerPresent'), ephemeral: true });
return;
}
if (result === 'not_in_channel') {
await interaction.reply({ content: t(locale, 'tempvoice.error.notInChannel'), ephemeral: true });
return;
}
await interaction.reply({ content: t(locale, 'tempvoice.claim.success'), ephemeral: true });
return;
}
const owned = await requireOwnedTempChannel(interaction, context, locale);
if (!owned) {
return;
}
if (sub === 'rename') {
const name = interaction.options.getString('name', true);
await renameTempChannel(owned.channel, name);
await interaction.reply({
content: tf(locale, 'tempvoice.rename.success', { name: name.slice(0, 100) }),
ephemeral: true
});
return;
}
if (sub === 'limit') {
const limit = interaction.options.getInteger('limit', true);
await setTempChannelLimit(owned.channel, limit);
await interaction.reply({
content: tf(locale, 'tempvoice.limit.success', { limit }),
ephemeral: true
});
return;
}
if (sub === 'lock') {
await lockTempChannel(owned.channel);
await interaction.reply({ content: t(locale, 'tempvoice.lock.success'), ephemeral: true });
return;
}
if (sub === 'hide') {
await hideTempChannel(owned.channel);
await interaction.reply({ content: t(locale, 'tempvoice.hide.success'), ephemeral: true });
return;
}
if (sub === 'kick') {
const user = interaction.options.getUser('user', true);
if (user.id === interaction.user.id) {
await interaction.reply({ content: t(locale, 'tempvoice.kick.self'), ephemeral: true });
return;
}
const ok = await kickFromTempChannel(owned.channel, user.id);
if (!ok) {
await interaction.reply({ content: t(locale, 'tempvoice.kick.notInChannel'), ephemeral: true });
return;
}
await interaction.reply({
content: tf(locale, 'tempvoice.kick.success', { user: `<@${user.id}>` }),
ephemeral: true
});
return;
}
if (sub === 'transfer') {
const user = interaction.options.getUser('user', true);
if (user.id === interaction.user.id) {
await interaction.reply({ content: t(locale, 'tempvoice.transfer.self'), ephemeral: true });
return;
}
const target = await interaction.guild!.members.fetch(user.id).catch(() => null);
if (!target || !owned.channel.members.has(target.id)) {
await interaction.reply({ content: t(locale, 'tempvoice.transfer.notInChannel'), ephemeral: true });
return;
}
await transferTempChannelOwnership(context, owned.record, owned.channel, target, locale);
await interaction.reply({
content: tf(locale, 'tempvoice.transfer.success', { user: `<@${user.id}>` }),
ephemeral: true
});
}
}
};
export const tempVoiceCommands: SlashCommand[] = [voiceCommand];

View File

@@ -0,0 +1,28 @@
import type { BotContext } from '../../types.js';
import { logger } from '../../logger.js';
import { resolveHubJoin, resolveVoiceLeave } from './service.js';
export function registerTempVoiceEvents(context: BotContext): void {
context.client.on('voiceStateUpdate', async (oldState, newState) => {
try {
const guild = newState.guild;
const member = newState.member;
if (!member || member.user.bot) {
return;
}
const oldChannelId = oldState.channelId;
const newChannelId = newState.channelId;
if (newChannelId && newChannelId !== oldChannelId) {
await resolveHubJoin(context, member, newChannelId);
}
if (oldChannelId && oldChannelId !== newChannelId) {
await resolveVoiceLeave(context, guild, oldChannelId);
}
} catch (error) {
logger.error({ error, userId: newState.id }, 'Temp voice state handler failed');
}
});
}

View File

@@ -0,0 +1,3 @@
export { tempVoiceCommands } from './commands.js';
export { registerTempVoiceEvents } from './events.js';
export { isTempVoiceInteraction, isTempVoiceModal, handleTempVoiceInteraction } from './interactions.js';

View File

@@ -0,0 +1,268 @@
import {
ActionRowBuilder,
ModalBuilder,
TextInputBuilder,
TextInputStyle,
type ButtonInteraction,
type ModalSubmitInteraction,
type VoiceChannel
} from 'discord.js';
import { t, tf } from '@nexumi/shared';
import type { BotContext } from '../../types.js';
import { getGuildLocale } from '../../i18n.js';
import {
assertTempVoiceOwner,
claimTempChannel,
hideTempChannel,
kickFromTempChannel,
lockTempChannel,
parseTempVoiceButton,
parseTempVoiceModal,
tempVoiceModalId,
TV_BUTTON_PREFIX,
TV_MODAL_PREFIX,
transferTempChannelOwnership
} from './service.js';
export function isTempVoiceInteraction(customId: string): boolean {
return customId.startsWith(TV_BUTTON_PREFIX);
}
export function isTempVoiceModal(customId: string): boolean {
return customId.startsWith(TV_MODAL_PREFIX);
}
async function showRenameModal(interaction: ButtonInteraction, channelId: string): Promise<void> {
const modal = new ModalBuilder()
.setCustomId(tempVoiceModalId('rename', channelId))
.setTitle('Rename channel')
.addComponents(
new ActionRowBuilder<TextInputBuilder>().addComponents(
new TextInputBuilder()
.setCustomId('name')
.setLabel('New name')
.setStyle(TextInputStyle.Short)
.setRequired(true)
.setMaxLength(100)
)
);
await interaction.showModal(modal);
}
async function showLimitModal(interaction: ButtonInteraction, channelId: string): Promise<void> {
const modal = new ModalBuilder()
.setCustomId(tempVoiceModalId('limit', channelId))
.setTitle('Set user limit')
.addComponents(
new ActionRowBuilder<TextInputBuilder>().addComponents(
new TextInputBuilder()
.setCustomId('limit')
.setLabel('Limit (0 = unlimited)')
.setStyle(TextInputStyle.Short)
.setRequired(true)
.setMaxLength(2)
)
);
await interaction.showModal(modal);
}
async function showKickModal(interaction: ButtonInteraction, channelId: string): Promise<void> {
const modal = new ModalBuilder()
.setCustomId(tempVoiceModalId('kick', channelId))
.setTitle('Kick user')
.addComponents(
new ActionRowBuilder<TextInputBuilder>().addComponents(
new TextInputBuilder()
.setCustomId('user_id')
.setLabel('User ID')
.setStyle(TextInputStyle.Short)
.setRequired(true)
.setMaxLength(20)
)
);
await interaction.showModal(modal);
}
async function showTransferModal(interaction: ButtonInteraction, channelId: string): Promise<void> {
const modal = new ModalBuilder()
.setCustomId(tempVoiceModalId('transfer', channelId))
.setTitle('Transfer ownership')
.addComponents(
new ActionRowBuilder<TextInputBuilder>().addComponents(
new TextInputBuilder()
.setCustomId('user_id')
.setLabel('New owner user ID')
.setStyle(TextInputStyle.Short)
.setRequired(true)
.setMaxLength(20)
)
);
await interaction.showModal(modal);
}
async function handleTempVoiceButton(
interaction: ButtonInteraction,
context: BotContext,
locale: 'de' | 'en'
): Promise<void> {
const parsed = parseTempVoiceButton(interaction.customId);
if (!parsed || !interaction.inGuild()) {
return;
}
const member = await interaction.guild!.members.fetch(interaction.user.id);
const owned = await assertTempVoiceOwner(context, member, parsed.channelId);
if (parsed.action === 'claim') {
const channel = interaction.guild!.channels.cache.get(parsed.channelId);
if (!channel?.isVoiceBased() || channel.isDMBased()) {
await interaction.reply({ content: t(locale, 'tempvoice.error.notFound'), ephemeral: true });
return;
}
const record = await context.prisma.tempVoiceChannel.findUnique({ where: { channelId: parsed.channelId } });
if (!record) {
await interaction.reply({ content: t(locale, 'tempvoice.error.notFound'), ephemeral: true });
return;
}
const result = await claimTempChannel(context, record, channel as VoiceChannel, member, locale);
if (result === 'owner_present') {
await interaction.reply({ content: t(locale, 'tempvoice.claim.ownerPresent'), ephemeral: true });
return;
}
if (result === 'not_in_channel') {
await interaction.reply({ content: t(locale, 'tempvoice.error.notInChannel'), ephemeral: true });
return;
}
await interaction.reply({ content: t(locale, 'tempvoice.claim.success'), ephemeral: true });
return;
}
if (!owned) {
await interaction.reply({ content: t(locale, 'tempvoice.error.notOwner'), ephemeral: true });
return;
}
switch (parsed.action) {
case 'rename':
await showRenameModal(interaction, parsed.channelId);
return;
case 'limit':
await showLimitModal(interaction, parsed.channelId);
return;
case 'kick':
await showKickModal(interaction, parsed.channelId);
return;
case 'transfer':
await showTransferModal(interaction, parsed.channelId);
return;
case 'lock':
await lockTempChannel(owned.channel);
await interaction.reply({ content: t(locale, 'tempvoice.lock.success'), ephemeral: true });
return;
case 'hide':
await hideTempChannel(owned.channel);
await interaction.reply({ content: t(locale, 'tempvoice.hide.success'), ephemeral: true });
return;
default:
await interaction.reply({ content: t(locale, 'generic.error'), ephemeral: true });
}
}
async function handleTempVoiceModal(
interaction: ModalSubmitInteraction,
context: BotContext,
locale: 'de' | 'en'
): Promise<void> {
const parsed = parseTempVoiceModal(interaction.customId);
if (!parsed || !interaction.inGuild()) {
return;
}
const member = await interaction.guild!.members.fetch(interaction.user.id);
const owned = await assertTempVoiceOwner(context, member, parsed.channelId);
if (!owned) {
await interaction.reply({ content: t(locale, 'tempvoice.error.notOwner'), ephemeral: true });
return;
}
if (parsed.action === 'rename') {
const name = interaction.fields.getTextInputValue('name').trim();
if (!name) {
await interaction.reply({ content: t(locale, 'tempvoice.error.invalidName'), ephemeral: true });
return;
}
await owned.channel.setName(name.slice(0, 100));
await interaction.reply({
content: tf(locale, 'tempvoice.rename.success', { name: name.slice(0, 100) }),
ephemeral: true
});
return;
}
if (parsed.action === 'limit') {
const raw = interaction.fields.getTextInputValue('limit').trim();
const limit = Number.parseInt(raw, 10);
if (Number.isNaN(limit) || limit < 0 || limit > 99) {
await interaction.reply({ content: t(locale, 'tempvoice.error.invalidLimit'), ephemeral: true });
return;
}
await owned.channel.setUserLimit(limit);
await interaction.reply({
content: tf(locale, 'tempvoice.limit.success', { limit }),
ephemeral: true
});
return;
}
if (parsed.action === 'kick') {
const userId = interaction.fields.getTextInputValue('user_id').trim();
if (userId === interaction.user.id) {
await interaction.reply({ content: t(locale, 'tempvoice.kick.self'), ephemeral: true });
return;
}
const ok = await kickFromTempChannel(owned.channel, userId);
if (!ok) {
await interaction.reply({ content: t(locale, 'tempvoice.kick.notInChannel'), ephemeral: true });
return;
}
await interaction.reply({
content: tf(locale, 'tempvoice.kick.success', { user: `<@${userId}>` }),
ephemeral: true
});
return;
}
if (parsed.action === 'transfer') {
const userId = interaction.fields.getTextInputValue('user_id').trim();
if (userId === interaction.user.id) {
await interaction.reply({ content: t(locale, 'tempvoice.transfer.self'), ephemeral: true });
return;
}
const target = await interaction.guild!.members.fetch(userId).catch(() => null);
if (!target || !owned.channel.members.has(target.id)) {
await interaction.reply({ content: t(locale, 'tempvoice.transfer.notInChannel'), ephemeral: true });
return;
}
await transferTempChannelOwnership(context, owned.record, owned.channel, target, locale);
await interaction.reply({
content: tf(locale, 'tempvoice.transfer.success', { user: `<@${userId}>` }),
ephemeral: true
});
}
}
export async function handleTempVoiceInteraction(
interaction: ButtonInteraction | ModalSubmitInteraction,
context: BotContext
): Promise<void> {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (interaction.isButton()) {
await handleTempVoiceButton(interaction, context, locale);
return;
}
if (interaction.isModalSubmit() && interaction.customId.startsWith(TV_MODAL_PREFIX)) {
await handleTempVoiceModal(interaction, context, locale);
}
}

View File

@@ -0,0 +1,389 @@
import {
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
ChannelType,
EmbedBuilder,
OverwriteType,
PermissionFlagsBits,
type CategoryChannel,
type Guild,
type GuildMember,
type VoiceChannel
} from 'discord.js';
import { applyTagPlaceholders, t, tf, type Locale } from '@nexumi/shared';
import { ensureGuild } from '../../guild.js';
import { logger } from '../../logger.js';
import type { BotContext } from '../../types.js';
export const TV_BUTTON_PREFIX = 'tv:';
export const TV_MODAL_PREFIX = 'tv:modal:';
export async function getTempVoiceConfig(prisma: BotContext['prisma'], guildId: string) {
await ensureGuild(prisma, guildId);
let config = await prisma.tempVoiceConfig.findUnique({ where: { guildId } });
if (!config) {
config = await prisma.tempVoiceConfig.create({ data: { guildId } });
}
return config;
}
export async function updateTempVoiceConfig(
prisma: BotContext['prisma'],
guildId: string,
data: {
enabled?: boolean;
hubChannelId?: string | null;
categoryId?: string | null;
defaultName?: string;
defaultLimit?: number;
}
) {
await ensureGuild(prisma, guildId);
return prisma.tempVoiceConfig.upsert({
where: { guildId },
update: data,
create: { guildId, ...data }
});
}
export async function getTempVoiceChannel(prisma: BotContext['prisma'], channelId: string) {
return prisma.tempVoiceChannel.findUnique({ where: { channelId } });
}
export async function getTempVoiceChannelForMember(
prisma: BotContext['prisma'],
guildId: string,
member: GuildMember
): Promise<{ record: NonNullable<Awaited<ReturnType<typeof getTempVoiceChannel>>>; channel: VoiceChannel } | null> {
const voiceChannelId = member.voice.channelId;
if (!voiceChannelId) {
return null;
}
const record = await getTempVoiceChannel(prisma, voiceChannelId);
if (!record || record.guildId !== guildId) {
return null;
}
const channel = member.guild.channels.cache.get(voiceChannelId);
if (!channel?.isVoiceBased() || channel.isDMBased()) {
return null;
}
return { record, channel: channel as VoiceChannel };
}
export function renderDefaultChannelName(template: string, member: GuildMember): string {
const name = applyTagPlaceholders(template, {
user: member.displayName,
'user.name': member.displayName,
'user.tag': member.user.username
});
return name.slice(0, 100);
}
export function buildControlPanelRows(channelId: string, locale: Locale): ActionRowBuilder<ButtonBuilder>[] {
const mk = (action: string, labelKey: string, style: ButtonStyle = ButtonStyle.Secondary) =>
new ButtonBuilder()
.setCustomId(`${TV_BUTTON_PREFIX}${action}:${channelId}`)
.setLabel(t(locale, labelKey))
.setStyle(style);
return [
new ActionRowBuilder<ButtonBuilder>().addComponents(
mk('rename', 'tempvoice.button.rename'),
mk('limit', 'tempvoice.button.limit'),
mk('lock', 'tempvoice.button.lock'),
mk('hide', 'tempvoice.button.hide')
),
new ActionRowBuilder<ButtonBuilder>().addComponents(
mk('kick', 'tempvoice.button.kick', ButtonStyle.Danger),
mk('transfer', 'tempvoice.button.transfer'),
mk('claim', 'tempvoice.button.claim', ButtonStyle.Success)
)
];
}
export function buildControlPanelEmbed(channelName: string, locale: Locale): EmbedBuilder {
return new EmbedBuilder()
.setTitle(t(locale, 'tempvoice.panel.title'))
.setDescription(tf(locale, 'tempvoice.panel.description', { channel: channelName }))
.setColor(0x6366f1);
}
export async function sendOwnerControlPanelDm(
member: GuildMember,
channelId: string,
channelName: string,
locale: Locale
): Promise<void> {
const embed = buildControlPanelEmbed(channelName, locale);
const rows = buildControlPanelRows(channelId, locale);
await member.send({ embeds: [embed], components: rows }).catch((error) => {
logger.debug({ error, userId: member.id }, 'Could not DM temp voice control panel');
});
}
export async function createTempVoiceChannel(
context: BotContext,
member: GuildMember,
config: Awaited<ReturnType<typeof getTempVoiceConfig>>
): Promise<VoiceChannel | null> {
if (!config.categoryId) {
return null;
}
const category = await member.guild.channels.fetch(config.categoryId).catch(() => null);
if (!category || category.type !== ChannelType.GuildCategory) {
return null;
}
const channelName = renderDefaultChannelName(config.defaultName, member);
const limit = config.defaultLimit > 0 ? config.defaultLimit : undefined;
const channel = await member.guild.channels.create({
name: channelName,
type: ChannelType.GuildVoice,
parent: category.id,
userLimit: limit,
permissionOverwrites: [
{
id: member.guild.roles.everyone.id,
type: OverwriteType.Role,
allow: [PermissionFlagsBits.Connect, PermissionFlagsBits.ViewChannel]
},
{
id: member.id,
type: OverwriteType.Member,
allow: [
PermissionFlagsBits.Connect,
PermissionFlagsBits.ViewChannel,
PermissionFlagsBits.ManageChannels,
PermissionFlagsBits.MoveMembers
]
},
{
id: member.guild.members.me!.id,
type: OverwriteType.Member,
allow: [
PermissionFlagsBits.Connect,
PermissionFlagsBits.ViewChannel,
PermissionFlagsBits.ManageChannels,
PermissionFlagsBits.MoveMembers
]
}
],
reason: 'Temp voice join-to-create'
});
await context.prisma.tempVoiceChannel.create({
data: {
guildId: member.guild.id,
channelId: channel.id,
ownerId: member.id
}
});
await member.voice.setChannel(channel).catch(async (error) => {
logger.warn({ error, channelId: channel.id }, 'Failed to move member into temp voice channel');
await channel.delete('Temp voice move failed').catch(() => undefined);
await context.prisma.tempVoiceChannel.delete({ where: { channelId: channel.id } }).catch(() => undefined);
});
if (!member.voice.channelId || member.voice.channelId !== channel.id) {
return null;
}
const { getGuildLocale } = await import('../../i18n.js');
const locale = await getGuildLocale(context.prisma, member.guild.id);
await sendOwnerControlPanelDm(member, channel.id, channel.name, locale);
return channel;
}
export async function deleteTempVoiceChannel(context: BotContext, channelId: string): Promise<void> {
await context.prisma.tempVoiceChannel.delete({ where: { channelId } }).catch(() => undefined);
const channel = await context.client.channels.fetch(channelId).catch(() => null);
if (channel?.isVoiceBased()) {
await channel.delete('Temp voice channel empty').catch((error) => {
logger.warn({ error, channelId }, 'Failed to delete temp voice channel');
});
}
}
export async function assertTempVoiceOwner(
context: BotContext,
member: GuildMember,
channelId: string
): Promise<{ record: NonNullable<Awaited<ReturnType<typeof getTempVoiceChannel>>>; channel: VoiceChannel } | null> {
const record = await getTempVoiceChannel(context.prisma, channelId);
if (!record || record.guildId !== member.guild.id) {
return null;
}
const channel = member.guild.channels.cache.get(channelId);
if (!channel?.isVoiceBased() || channel.isDMBased()) {
return null;
}
if (record.ownerId !== member.id) {
return null;
}
return { record, channel: channel as VoiceChannel };
}
export async function renameTempChannel(channel: VoiceChannel, name: string): Promise<void> {
await channel.setName(name.slice(0, 100));
}
export async function setTempChannelLimit(channel: VoiceChannel, limit: number): Promise<void> {
await channel.setUserLimit(limit);
}
export async function lockTempChannel(channel: VoiceChannel): Promise<boolean> {
await channel.permissionOverwrites.edit(channel.guild.roles.everyone, {
Connect: false
});
return true;
}
export async function hideTempChannel(channel: VoiceChannel): Promise<boolean> {
await channel.permissionOverwrites.edit(channel.guild.roles.everyone, {
ViewChannel: false
});
return true;
}
export async function kickFromTempChannel(channel: VoiceChannel, targetId: string): Promise<boolean> {
const target = channel.members.get(targetId);
if (!target) {
return false;
}
await target.voice.disconnect('Temp voice kick');
return true;
}
export async function transferTempChannelOwnership(
context: BotContext,
record: { channelId: string; ownerId: string },
channel: VoiceChannel,
newOwner: GuildMember,
locale: Locale
): Promise<void> {
await context.prisma.tempVoiceChannel.update({
where: { channelId: record.channelId },
data: { ownerId: newOwner.id }
});
await channel.permissionOverwrites.edit(record.ownerId, {
ManageChannels: null,
MoveMembers: null
});
await channel.permissionOverwrites.edit(newOwner.id, {
Connect: true,
ViewChannel: true,
ManageChannels: true,
MoveMembers: true
});
await sendOwnerControlPanelDm(newOwner, channel.id, channel.name, locale);
}
export async function claimTempChannel(
context: BotContext,
record: { channelId: string; ownerId: string },
channel: VoiceChannel,
claimer: GuildMember,
locale: Locale
): Promise<'owner_present' | 'claimed' | 'not_in_channel'> {
if (channel.members.has(record.ownerId)) {
return 'owner_present';
}
if (!channel.members.has(claimer.id)) {
return 'not_in_channel';
}
await transferTempChannelOwnership(context, record, channel, claimer, locale);
return 'claimed';
}
export async function resolveHubJoin(
context: BotContext,
member: GuildMember,
hubChannelId: string
): Promise<void> {
const config = await getTempVoiceConfig(context.prisma, member.guild.id);
if (!config.enabled || config.hubChannelId !== hubChannelId || !config.categoryId) {
return;
}
await createTempVoiceChannel(context, member, config);
}
export async function resolveVoiceLeave(
context: BotContext,
guild: Guild,
channelId: string
): Promise<void> {
const record = await getTempVoiceChannel(context.prisma, channelId);
if (!record) {
return;
}
const channel = guild.channels.cache.get(channelId);
if (!channel?.isVoiceBased()) {
await context.prisma.tempVoiceChannel.delete({ where: { channelId } }).catch(() => undefined);
return;
}
if (channel.members.size === 0) {
await deleteTempVoiceChannel(context, channelId);
}
}
export function parseTempVoiceButton(customId: string): { action: string; channelId: string } | null {
if (!customId.startsWith(TV_BUTTON_PREFIX) || customId.startsWith(TV_MODAL_PREFIX)) {
return null;
}
const body = customId.slice(TV_BUTTON_PREFIX.length);
const sep = body.indexOf(':');
if (sep <= 0) {
return null;
}
return {
action: body.slice(0, sep),
channelId: body.slice(sep + 1)
};
}
export function parseTempVoiceModal(customId: string): { action: string; channelId: string } | null {
if (!customId.startsWith(TV_MODAL_PREFIX)) {
return null;
}
const body = customId.slice(TV_MODAL_PREFIX.length);
const sep = body.indexOf(':');
if (sep <= 0) {
return null;
}
return {
action: body.slice(0, sep),
channelId: body.slice(sep + 1)
};
}
export function tempVoiceModalId(action: string, channelId: string): string {
return `${TV_MODAL_PREFIX}${action}:${channelId}`;
}
export function tempVoiceButtonId(action: string, channelId: string): string {
return `${TV_BUTTON_PREFIX}${action}:${channelId}`;
}
export async function fetchCategory(guild: Guild, categoryId: string): Promise<CategoryChannel | null> {
const channel = await guild.channels.fetch(categoryId).catch(() => null);
if (!channel || channel.type !== ChannelType.GuildCategory) {
return null;
}
return channel;
}

View File

@@ -0,0 +1,78 @@
import {
applyCommandDescription,
applyOptionDescription,
localizedChoice
} from '@nexumi/shared';
import { SlashCommandBuilder } from 'discord.js';
export const ticketCommandData = applyCommandDescription(
new SlashCommandBuilder()
.setName('ticket')
.addSubcommandGroup((group) =>
applyCommandDescription(group.setName('panel'), 'ticket.panel.description').addSubcommand(
(s) =>
applyCommandDescription(s.setName('create'), 'ticket.panel.create.description')
.addStringOption((o) =>
applyOptionDescription(o.setName('name').setRequired(true), 'ticket.panel.create.options.name')
)
.addStringOption((o) =>
applyOptionDescription(
o.setName('description'),
'ticket.panel.create.options.description'
)
)
)
)
.addSubcommandGroup((group) =>
applyCommandDescription(group.setName('category'), 'ticket.category.description').addSubcommand(
(s) =>
applyCommandDescription(s.setName('create'), 'ticket.category.create.description')
.addStringOption((o) =>
applyOptionDescription(
o.setName('name').setRequired(true),
'ticket.category.create.options.name'
)
)
.addRoleOption((o) =>
applyOptionDescription(
o.setName('support_role').setRequired(true),
'ticket.category.create.options.support_role'
)
)
)
)
.addSubcommand((s) =>
applyCommandDescription(s.setName('close'), 'ticket.close.description').addStringOption((o) =>
applyOptionDescription(o.setName('reason'), 'ticket.close.options.reason')
)
)
.addSubcommand((s) => applyCommandDescription(s.setName('claim'), 'ticket.claim.description'))
.addSubcommand((s) =>
applyCommandDescription(s.setName('add'), 'ticket.add.description').addUserOption((o) =>
applyOptionDescription(o.setName('user').setRequired(true), 'ticket.common.options.user')
)
)
.addSubcommand((s) =>
applyCommandDescription(s.setName('remove'), 'ticket.remove.description').addUserOption((o) =>
applyOptionDescription(o.setName('user').setRequired(true), 'ticket.common.options.user')
)
)
.addSubcommand((s) =>
applyCommandDescription(s.setName('rename'), 'ticket.rename.description').addStringOption((o) =>
applyOptionDescription(o.setName('name').setRequired(true), 'ticket.rename.options.name')
)
)
.addSubcommand((s) =>
applyCommandDescription(s.setName('priority'), 'ticket.priority.description').addStringOption(
(o) =>
applyOptionDescription(o.setName('level').setRequired(true), 'ticket.priority.options.level')
.addChoices(
localizedChoice('ticket.priority.choice.low', 'LOW'),
localizedChoice('ticket.priority.choice.normal', 'NORMAL'),
localizedChoice('ticket.priority.choice.high', 'HIGH'),
localizedChoice('ticket.priority.choice.urgent', 'URGENT')
)
)
),
'ticket.description'
);

View File

@@ -0,0 +1,190 @@
import { PermissionFlagsBits } from 'discord.js';
import { t, tf } from '@nexumi/shared';
import type { SlashCommand } from '../../types.js';
import { getGuildLocale } from '../../i18n.js';
import { requirePermission } from '../../permissions.js';
import { ticketCommandData } from './command-definitions.js';
import {
addUserToTicket,
assertTicketStaff,
buildPanelComponents,
buildPanelEmbed,
claimTicket,
closeTicket,
createCategoryWithRole,
findTicketByChannel,
getGuildCategories,
removeUserFromTicket,
renameTicket,
setTicketPriority,
TicketError,
upsertCategoryByName
} from './service.js';
async function ensureManageGuild(
interaction: Parameters<SlashCommand['execute']>[0],
locale: 'de' | 'en'
): Promise<boolean> {
if (!interaction.inGuild()) {
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
return false;
}
return requirePermission(interaction, PermissionFlagsBits.ManageGuild, locale);
}
const ticketCommand: SlashCommand = {
data: ticketCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!interaction.inGuild()) {
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
return;
}
const group = interaction.options.getSubcommandGroup(false);
const sub = interaction.options.getSubcommand(false);
if (group === 'panel' && sub === 'create') {
if (!(await ensureManageGuild(interaction, locale))) {
return;
}
const name = interaction.options.getString('name', true);
const description = interaction.options.getString('description');
await upsertCategoryByName(context, interaction.guildId!, name, description);
const categories = await getGuildCategories(context, interaction.guildId!);
if (categories.length === 0) {
await interaction.reply({ content: t(locale, 'ticket.error.no_categories'), ephemeral: true });
return;
}
const channel = interaction.channel;
if (!channel?.isTextBased() || channel.isDMBased()) {
await interaction.reply({
content: t(locale, 'ticket.error.invalid_channel'),
ephemeral: true
});
return;
}
const embed = buildPanelEmbed(locale, name, description);
const components = buildPanelComponents(categories);
await channel.send({ embeds: [embed], components });
await interaction.reply({ content: t(locale, 'ticket.panel.created'), ephemeral: true });
return;
}
if (group === 'category' && sub === 'create') {
if (!(await ensureManageGuild(interaction, locale))) {
return;
}
const name = interaction.options.getString('name', true);
const role = interaction.options.getRole('support_role', true);
const category = await createCategoryWithRole(
context,
interaction.guildId!,
name,
role.id
);
await interaction.reply({
content: tf(locale, 'ticket.category.created', { name: category.name }),
ephemeral: true
});
return;
}
const ticket = await findTicketByChannel(context.prisma, interaction.channelId!);
if (!ticket) {
await interaction.reply({ content: t(locale, 'ticket.error.not_in_ticket'), ephemeral: true });
return;
}
const member = await interaction.guild!.members.fetch(interaction.user.id);
try {
if (sub === 'close') {
const isStaff = await (async () => {
try {
await assertTicketStaff(member, ticket);
return true;
} catch {
return false;
}
})();
const isOpener = ticket.openerId === interaction.user.id;
if (!isStaff && !isOpener) {
await interaction.reply({ content: t(locale, 'ticket.error.not_staff'), ephemeral: true });
return;
}
const reason = interaction.options.getString('reason') ?? undefined;
await closeTicket(context, ticket, interaction.user.id, locale, reason);
await interaction.reply({ content: t(locale, 'ticket.close.success'), ephemeral: true });
return;
}
if (sub === 'claim') {
await claimTicket(context, ticket, member, locale);
await interaction.reply({ content: t(locale, 'ticket.claim.success'), ephemeral: true });
return;
}
if (sub === 'add') {
const user = interaction.options.getUser('user', true);
const target = await interaction.guild!.members.fetch(user.id);
await addUserToTicket(context, ticket, member, target, locale);
await interaction.reply({
content: tf(locale, 'ticket.add.success', { user: `<@${user.id}>` }),
ephemeral: true
});
return;
}
if (sub === 'remove') {
const user = interaction.options.getUser('user', true);
const target = await interaction.guild!.members.fetch(user.id);
await removeUserFromTicket(context, ticket, member, target, locale);
await interaction.reply({
content: tf(locale, 'ticket.remove.success', { user: `<@${user.id}>` }),
ephemeral: true
});
return;
}
if (sub === 'rename') {
const name = interaction.options.getString('name', true);
await renameTicket(context, ticket, member, name, locale);
await interaction.reply({
content: tf(locale, 'ticket.rename.success', { name }),
ephemeral: true
});
return;
}
if (sub === 'priority') {
const level = interaction.options.getString('level', true);
await setTicketPriority(context, ticket, member, level, locale);
await interaction.reply({
content: tf(locale, 'ticket.priority.success', {
priority: t(locale, `ticket.priority.${level}`)
}),
ephemeral: true
});
}
} catch (error) {
if (error instanceof TicketError) {
await interaction.reply({
content: t(locale, `ticket.error.${error.code}`),
ephemeral: true
});
return;
}
throw error;
}
}
};
export const ticketsCommands = [ticketCommand];

View File

@@ -0,0 +1,12 @@
import { Events } from 'discord.js';
import type { BotContext } from '../../types.js';
import { touchTicketActivity } from './service.js';
export function registerTicketEvents(context: BotContext): void {
context.client.on(Events.MessageCreate, async (message) => {
if (message.author.bot || !message.guildId) {
return;
}
await touchTicketActivity(context, message.channelId);
});
}

View File

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

View File

@@ -0,0 +1,182 @@
import type {
ButtonInteraction,
ModalSubmitInteraction,
StringSelectMenuInteraction
} from 'discord.js';
import { t, tf } from '@nexumi/shared';
import type { BotContext } from '../../types.js';
import { getGuildLocale } from '../../i18n.js';
import {
buildOpenModal,
openTicketForCategory,
parseFormFields,
parseRateButton,
rateTicket,
TICKET_MODAL_PREFIX,
TICKET_OPEN_PREFIX,
TICKET_RATE_PREFIX,
TICKET_SELECT_ID,
TicketError
} from './service.js';
export function isTicketInteraction(customId: string): boolean {
return (
customId.startsWith(TICKET_OPEN_PREFIX) ||
customId === TICKET_SELECT_ID ||
customId.startsWith(TICKET_MODAL_PREFIX) ||
customId.startsWith(TICKET_RATE_PREFIX)
);
}
async function replyTicketError(
interaction: ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction,
locale: 'de' | 'en',
code: string
): Promise<void> {
const content = t(locale, `ticket.error.${code}`);
if (interaction.replied || interaction.deferred) {
await interaction.followUp({ content, ephemeral: true });
} else {
await interaction.reply({ content, ephemeral: true });
}
}
export async function handleOpenTicketRequest(
interaction: ButtonInteraction | StringSelectMenuInteraction,
context: BotContext,
categoryId: string
): Promise<void> {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!interaction.inGuild() || !interaction.guild) {
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
return;
}
const category = await context.prisma.ticketCategory.findUnique({ where: { id: categoryId } });
if (!category || category.guildId !== interaction.guildId) {
await interaction.reply({ content: t(locale, 'ticket.error.category_not_found'), ephemeral: true });
return;
}
const fields = parseFormFields(category.formFields);
if (fields.length > 0) {
await interaction.showModal(buildOpenModal(category, locale));
return;
}
try {
const member = await interaction.guild.members.fetch(interaction.user.id);
const ticket = await openTicketForCategory(
context,
interaction.guild,
member,
categoryId,
interaction.channelId ?? undefined
);
const channelRef = ticket.channelId ?? ticket.threadId;
await interaction.reply({
content: tf(locale, 'ticket.created', { channel: `<#${channelRef}>` }),
ephemeral: true
});
} catch (error) {
if (error instanceof TicketError) {
await replyTicketError(interaction, locale, error.code);
return;
}
throw error;
}
}
export async function handleTicketInteraction(
interaction: ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction,
context: BotContext
): Promise<void> {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (interaction.isStringSelectMenu() && interaction.customId === TICKET_SELECT_ID) {
const categoryId = interaction.values[0];
if (!categoryId) {
await interaction.reply({ content: t(locale, 'ticket.error.category_not_found'), ephemeral: true });
return;
}
await handleOpenTicketRequest(interaction, context, categoryId);
return;
}
if (interaction.isButton()) {
if (interaction.customId.startsWith(TICKET_OPEN_PREFIX)) {
const categoryId = interaction.customId.slice(TICKET_OPEN_PREFIX.length);
await handleOpenTicketRequest(interaction, context, categoryId);
return;
}
const parsedRate = parseRateButton(interaction.customId);
if (parsedRate) {
try {
await rateTicket(context, parsedRate.ticketId, interaction.user.id, parsedRate.rating, locale);
await interaction.update({
content: tf(locale, 'ticket.rated', { rating: parsedRate.rating }),
components: []
});
} catch (error) {
if (error instanceof TicketError) {
await interaction.reply({
content: t(locale, `ticket.error.${error.code}`),
ephemeral: true
});
return;
}
throw error;
}
return;
}
}
if (interaction.isModalSubmit() && interaction.customId.startsWith(TICKET_MODAL_PREFIX)) {
if (!interaction.inGuild() || !interaction.guild) {
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
return;
}
const categoryId = interaction.customId.slice(TICKET_MODAL_PREFIX.length);
const category = await context.prisma.ticketCategory.findUnique({ where: { id: categoryId } });
if (!category) {
await interaction.reply({ content: t(locale, 'ticket.error.category_not_found'), ephemeral: true });
return;
}
const fields = parseFormFields(category.formFields);
const formAnswers: Record<string, string> = {};
if (fields.length > 0) {
for (const field of fields) {
formAnswers[field.label] = interaction.fields.getTextInputValue(field.id);
}
} else {
formAnswers[t(locale, 'ticket.modal.subject')] = interaction.fields.getTextInputValue('subject');
}
try {
const member = await interaction.guild.members.fetch(interaction.user.id);
const ticket = await openTicketForCategory(
context,
interaction.guild,
member,
categoryId,
interaction.channelId ?? undefined,
formAnswers,
formAnswers[t(locale, 'ticket.modal.subject')] ?? category.name
);
const channelRef = ticket.channelId ?? ticket.threadId;
await interaction.reply({
content: tf(locale, 'ticket.created', { channel: `<#${channelRef}>` }),
ephemeral: true
});
} catch (error) {
if (error instanceof TicketError) {
await replyTicketError(interaction, locale, error.code);
return;
}
throw error;
}
}
}

View File

@@ -0,0 +1,886 @@
import { z } from 'zod';
import {
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
ChannelType,
EmbedBuilder,
ModalBuilder,
PermissionFlagsBits,
StringSelectMenuBuilder,
TextInputBuilder,
TextInputStyle,
type Guild,
type GuildMember,
type Message,
type SendableChannels,
type TextChannel,
type ThreadChannel
} from 'discord.js';
import {
buildTicketTranscriptHtml,
TicketModeSchema,
TicketPrioritySchema,
t,
tf
} from '@nexumi/shared';
import type { Ticket, TicketCategory, TicketConfig } from '@prisma/client';
import { ensureGuild } from '../../guild.js';
import { logger } from '../../logger.js';
import type { BotContext } from '../../types.js';
import { getGuildLocale } from '../../i18n.js';
export const TICKET_OPEN_PREFIX = 'ticket:open:';
export const TICKET_SELECT_ID = 'ticket:select';
export const TICKET_MODAL_PREFIX = 'ticket:modal:';
export const TICKET_RATE_PREFIX = 'ticket:rate:';
export const TicketFormFieldSchema = z.object({
id: z.string(),
label: z.string(),
style: z.enum(['SHORT', 'PARAGRAPH']).default('SHORT'),
required: z.boolean().default(true),
placeholder: z.string().optional()
});
export type TicketFormField = z.infer<typeof TicketFormFieldSchema>;
export class TicketError extends Error {
constructor(public readonly code: string) {
super(code);
}
}
async function fetchSendableChannel(
context: BotContext,
channelId: string
): Promise<SendableChannels | null> {
const channel = await context.client.channels.fetch(channelId).catch(() => null);
if (!channel?.isTextBased() || channel.isDMBased()) {
return null;
}
return channel as SendableChannels;
}
export async function getTicketConfig(
prisma: BotContext['prisma'],
guildId: string
): Promise<TicketConfig> {
await ensureGuild(prisma, guildId);
return prisma.ticketConfig.upsert({
where: { guildId },
update: {},
create: { guildId }
});
}
export function parseFormFields(raw: unknown): TicketFormField[] {
if (!Array.isArray(raw)) {
return [];
}
return raw
.map((item) => TicketFormFieldSchema.safeParse(item))
.filter((result) => result.success)
.map((result) => result.data!)
.slice(0, 5);
}
export function openButtonId(categoryId: string): string {
return `${TICKET_OPEN_PREFIX}${categoryId}`;
}
export function modalId(categoryId: string): string {
return `${TICKET_MODAL_PREFIX}${categoryId}`;
}
export function rateButtonId(ticketId: string, rating: number): string {
return `${TICKET_RATE_PREFIX}${ticketId}:${rating}`;
}
export function parseRateButton(customId: string): { ticketId: string; rating: number } | null {
if (!customId.startsWith(TICKET_RATE_PREFIX)) {
return null;
}
const rest = customId.slice(TICKET_RATE_PREFIX.length);
const colon = rest.lastIndexOf(':');
if (colon === -1) {
return null;
}
const ticketId = rest.slice(0, colon);
const rating = Number.parseInt(rest.slice(colon + 1), 10);
if (!ticketId || Number.isNaN(rating) || rating < 1 || rating > 5) {
return null;
}
return { ticketId, rating };
}
export async function findTicketByChannel(
prisma: BotContext['prisma'],
channelId: string
): Promise<(Ticket & { category: TicketCategory | null }) | null> {
return prisma.ticket.findFirst({
where: {
OR: [{ channelId }, { threadId: channelId }],
status: { not: 'CLOSED' }
},
include: { category: true }
});
}
export async function findOpenTicketForUser(
prisma: BotContext['prisma'],
guildId: string,
userId: string
): Promise<Ticket | null> {
return prisma.ticket.findFirst({
where: {
guildId,
openerId: userId,
status: { in: ['OPEN', 'CLAIMED'] }
}
});
}
function isTicketStaff(member: GuildMember, category: TicketCategory | null): boolean {
if (member.permissions.has(PermissionFlagsBits.ManageChannels)) {
return true;
}
if (!category) {
return false;
}
return category.supportRoleIds.some((roleId) => member.roles.cache.has(roleId));
}
export async function assertTicketStaff(
member: GuildMember,
ticket: Ticket & { category: TicketCategory | null }
): Promise<void> {
if (!isTicketStaff(member, ticket.category)) {
throw new TicketError('not_staff');
}
}
export function buildPanelEmbed(
locale: 'de' | 'en',
title: string,
description?: string | null
): EmbedBuilder {
return new EmbedBuilder()
.setTitle(title)
.setDescription(description ?? t(locale, 'ticket.panel.defaultDescription'))
.setColor(0x6366f1);
}
export function buildPanelComponents(categories: TicketCategory[]): ActionRowBuilder<
ButtonBuilder | StringSelectMenuBuilder
>[] {
if (categories.length === 0) {
return [];
}
if (categories.length <= 5) {
const row = new ActionRowBuilder<ButtonBuilder>();
for (const category of categories) {
const button = new ButtonBuilder()
.setCustomId(openButtonId(category.id))
.setLabel(category.name.slice(0, 80))
.setStyle(ButtonStyle.Primary);
if (category.emoji) {
button.setEmoji(category.emoji);
}
row.addComponents(button);
}
return [row];
}
const select = new StringSelectMenuBuilder()
.setCustomId(TICKET_SELECT_ID)
.setPlaceholder('Select a category')
.addOptions(
categories.slice(0, 25).map((category) => ({
label: category.name.slice(0, 100),
value: category.id,
description: category.description?.slice(0, 100) ?? undefined,
emoji: category.emoji ? { name: category.emoji } : undefined
}))
);
return [new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(select)];
}
export function buildOpenModal(category: TicketCategory, locale: 'de' | 'en'): ModalBuilder {
const fields = parseFormFields(category.formFields);
const modal = new ModalBuilder()
.setCustomId(modalId(category.id))
.setTitle(category.name.slice(0, 45));
for (const field of fields) {
const input = new TextInputBuilder()
.setCustomId(field.id)
.setLabel(field.label.slice(0, 45))
.setStyle(field.style === 'PARAGRAPH' ? TextInputStyle.Paragraph : TextInputStyle.Short)
.setRequired(field.required);
if (field.placeholder) {
input.setPlaceholder(field.placeholder.slice(0, 100));
}
modal.addComponents(new ActionRowBuilder<TextInputBuilder>().addComponents(input));
}
if (fields.length === 0) {
modal.addComponents(
new ActionRowBuilder<TextInputBuilder>().addComponents(
new TextInputBuilder()
.setCustomId('subject')
.setLabel(t(locale, 'ticket.modal.subject'))
.setStyle(TextInputStyle.Short)
.setRequired(true)
.setMaxLength(100)
)
);
}
return modal;
}
async function permissionOverwrites(
guild: Guild,
openerId: string,
supportRoleIds: string[]
) {
const me = await guild.members.fetchMe();
const overwrites = [
{
id: guild.roles.everyone.id,
deny: [PermissionFlagsBits.ViewChannel]
},
{
id: openerId,
allow: [
PermissionFlagsBits.ViewChannel,
PermissionFlagsBits.SendMessages,
PermissionFlagsBits.ReadMessageHistory,
PermissionFlagsBits.AttachFiles,
PermissionFlagsBits.EmbedLinks
]
},
{
id: me.id,
allow: [
PermissionFlagsBits.ViewChannel,
PermissionFlagsBits.SendMessages,
PermissionFlagsBits.ReadMessageHistory,
PermissionFlagsBits.ManageChannels,
PermissionFlagsBits.ManageThreads,
PermissionFlagsBits.AttachFiles,
PermissionFlagsBits.EmbedLinks
]
}
];
for (const roleId of supportRoleIds) {
overwrites.push({
id: roleId,
allow: [
PermissionFlagsBits.ViewChannel,
PermissionFlagsBits.SendMessages,
PermissionFlagsBits.ReadMessageHistory,
PermissionFlagsBits.AttachFiles,
PermissionFlagsBits.EmbedLinks,
PermissionFlagsBits.ManageChannels
]
});
}
return overwrites;
}
async function fetchTicketMessages(
context: BotContext,
ticket: Ticket
): Promise<Array<{ author: string; content: string; timestamp: string }>> {
const channelId = ticket.channelId ?? ticket.threadId;
if (!channelId) {
return [];
}
try {
const channel = await context.client.channels.fetch(channelId);
if (!channel?.isTextBased()) {
return [];
}
const collected: Message[] = [];
let lastId: string | undefined;
while (collected.length < 500) {
const batch = await channel.messages.fetch({ limit: 100, before: lastId });
if (batch.size === 0) {
break;
}
collected.push(...batch.values());
lastId = batch.last()?.id;
}
return collected
.reverse()
.map((message) => ({
author: message.author.tag,
content: message.content || `[${message.attachments.size} attachment(s)]`,
timestamp: message.createdAt.toISOString()
}));
} catch (error) {
logger.warn({ error, ticketId: ticket.id }, 'Failed to fetch ticket messages');
return [];
}
}
function buildRatingComponents(ticketId: string, locale: 'de' | 'en'): ActionRowBuilder<ButtonBuilder> {
const row = new ActionRowBuilder<ButtonBuilder>();
for (let rating = 1; rating <= 5; rating += 1) {
row.addComponents(
new ButtonBuilder()
.setCustomId(rateButtonId(ticketId, rating))
.setLabel(String(rating))
.setStyle(ButtonStyle.Secondary)
);
}
return row;
}
export async function createTicketChannel(
context: BotContext,
params: {
guild: Guild;
opener: GuildMember;
category: TicketCategory;
config: TicketConfig;
parentChannelId?: string;
subject?: string;
formAnswers?: Record<string, string>;
},
locale: 'de' | 'en'
): Promise<Ticket> {
const existing = await findOpenTicketForUser(
context.prisma,
params.guild.id,
params.opener.id
);
if (existing) {
throw new TicketError('already_open');
}
const mode = TicketModeSchema.parse(params.config.mode);
const supportRoleIds = params.category.supportRoleIds;
const channelName = `ticket-${params.opener.user.username}`
.toLowerCase()
.replace(/[^a-z0-9-]/g, '-')
.slice(0, 90);
let channelId: string | null = null;
let threadId: string | null = null;
if (mode === 'THREAD') {
const parentId = params.parentChannelId ?? params.config.categoryId;
if (!parentId) {
throw new TicketError('no_parent_channel');
}
const parent = await params.guild.channels.fetch(parentId);
if (!parent?.isTextBased() || parent.isDMBased()) {
throw new TicketError('invalid_parent_channel');
}
const thread = await (parent as TextChannel).threads.create({
name: channelName,
type: ChannelType.PrivateThread,
invitable: false,
reason: `Ticket opened by ${params.opener.user.tag}`
});
await thread.members.add(params.opener.id);
for (const roleId of supportRoleIds) {
const role = params.guild.roles.cache.get(roleId);
if (role) {
for (const [, member] of role.members) {
await thread.members.add(member.id).catch(() => undefined);
}
}
}
threadId = thread.id;
} else {
const overwrites = await permissionOverwrites(
params.guild,
params.opener.id,
supportRoleIds
);
const channel = await params.guild.channels.create({
name: channelName,
type: ChannelType.GuildText,
parent: params.config.categoryId ?? undefined,
permissionOverwrites: overwrites,
reason: `Ticket opened by ${params.opener.user.tag}`
});
channelId = channel.id;
}
const ticket = await context.prisma.ticket.create({
data: {
guildId: params.guild.id,
categoryId: params.category.id,
openerId: params.opener.id,
channelId,
threadId,
subject: params.subject ?? params.category.name,
formAnswers: params.formAnswers ?? undefined,
status: 'OPEN',
lastActivityAt: new Date()
}
});
const targetId = channelId ?? threadId!;
const target = (await context.client.channels.fetch(targetId)) as TextChannel | ThreadChannel;
const introLines = [
tf(locale, 'ticket.opened', { user: `<@${params.opener.id}>` }),
params.subject ? tf(locale, 'ticket.subjectLine', { subject: params.subject }) : null
].filter(Boolean);
if (params.formAnswers && Object.keys(params.formAnswers).length > 0) {
const answerLines = Object.entries(params.formAnswers).map(
([key, value]) => `**${key}:** ${value}`
);
introLines.push(answerLines.join('\n'));
}
await target.send({ content: introLines.join('\n') });
return ticket;
}
export async function claimTicket(
context: BotContext,
ticket: Ticket & { category: TicketCategory | null },
member: GuildMember,
locale: 'de' | 'en'
): Promise<Ticket> {
await assertTicketStaff(member, ticket);
if (ticket.status === 'CLOSED') {
throw new TicketError('closed');
}
if (ticket.claimedById === member.id) {
throw new TicketError('already_claimed');
}
const updated = await context.prisma.ticket.update({
where: { id: ticket.id },
data: {
claimedById: member.id,
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: `<@${member.id}>` }));
}
}
return updated;
}
export async function addUserToTicket(
context: BotContext,
ticket: Ticket & { category: TicketCategory | null },
member: GuildMember,
target: GuildMember,
locale: 'de' | 'en'
): Promise<void> {
await assertTicketStaff(member, ticket);
const channelId = ticket.channelId ?? ticket.threadId;
if (!channelId) {
throw new TicketError('no_channel');
}
const channel = await context.client.channels.fetch(channelId);
if (channel?.isThread()) {
await channel.members.add(target.id);
} else if (channel?.type === ChannelType.GuildText) {
await channel.permissionOverwrites.edit(target.id, {
ViewChannel: true,
SendMessages: true,
ReadMessageHistory: true,
AttachFiles: true
});
}
await context.prisma.ticket.update({
where: { id: ticket.id },
data: { lastActivityAt: new Date() }
});
if (channel?.isTextBased()) {
const sendable = channel as SendableChannels;
await sendable.send(tf(locale, 'ticket.userAdded', { user: `<@${target.id}>` }));
}
}
export async function removeUserFromTicket(
context: BotContext,
ticket: Ticket & { category: TicketCategory | null },
member: GuildMember,
target: GuildMember,
locale: 'de' | 'en'
): Promise<void> {
await assertTicketStaff(member, ticket);
if (target.id === ticket.openerId) {
throw new TicketError('cannot_remove_opener');
}
const channelId = ticket.channelId ?? ticket.threadId;
if (!channelId) {
throw new TicketError('no_channel');
}
const channel = await context.client.channels.fetch(channelId);
if (channel?.isThread()) {
await channel.members.remove(target.id);
} else if (channel?.type === ChannelType.GuildText) {
await channel.permissionOverwrites.delete(target.id);
}
if (channel?.isTextBased()) {
await (channel as SendableChannels).send(
tf(locale, 'ticket.userRemoved', { user: `<@${target.id}>` })
);
}
}
export async function renameTicket(
context: BotContext,
ticket: Ticket & { category: TicketCategory | null },
member: GuildMember,
name: string,
locale: 'de' | 'en'
): Promise<void> {
await assertTicketStaff(member, ticket);
const sanitized = name
.toLowerCase()
.replace(/[^a-z0-9-]/g, '-')
.slice(0, 90);
if (!sanitized) {
throw new TicketError('invalid_name');
}
const channelId = ticket.channelId ?? ticket.threadId;
if (!channelId) {
throw new TicketError('no_channel');
}
const channel = await context.client.channels.fetch(channelId);
if (channel && 'setName' in channel) {
await channel.setName(sanitized);
}
await context.prisma.ticket.update({
where: { id: ticket.id },
data: { subject: name, lastActivityAt: new Date() }
});
if (channel?.isTextBased()) {
await (channel as SendableChannels).send(tf(locale, 'ticket.renamed', { name }));
}
}
export async function setTicketPriority(
context: BotContext,
ticket: Ticket & { category: TicketCategory | null },
member: GuildMember,
priority: string,
locale: 'de' | 'en'
): Promise<Ticket> {
await assertTicketStaff(member, ticket);
const parsed = TicketPrioritySchema.parse(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 updated;
}
export async function closeTicket(
context: BotContext,
ticket: Ticket & { category: TicketCategory | null },
closedById: string,
locale: 'de' | 'en',
reason?: string
): Promise<void> {
if (ticket.status === 'CLOSED') {
throw new TicketError('closed');
}
const config = await getTicketConfig(context.prisma, ticket.guildId);
const guild = await context.client.guilds.fetch(ticket.guildId);
const opener = await context.client.users.fetch(ticket.openerId).catch(() => null);
const messages = await fetchTicketMessages(context, ticket);
const transcriptHtml = buildTicketTranscriptHtml({
guildName: guild.name,
ticketId: ticket.id,
openerTag: opener?.tag ?? ticket.openerId,
messages
});
await context.prisma.ticket.update({
where: { id: ticket.id },
data: {
status: 'CLOSED',
closedAt: new Date(),
transcriptHtml,
lastActivityAt: new Date()
}
});
const channelId = ticket.channelId ?? ticket.threadId;
if (channelId) {
const channel = await fetchSendableChannel(context, channelId);
if (channel) {
const closeMessage = reason
? tf(locale, 'ticket.closedWithReason', {
user: `<@${closedById}>`,
reason
})
: tf(locale, 'ticket.closed', { user: `<@${closedById}>` });
await channel.send(closeMessage);
}
}
if (config.logChannelId) {
try {
const logChannel = (await context.client.channels.fetch(
config.logChannelId
)) as TextChannel;
await logChannel.send({
embeds: [
new EmbedBuilder()
.setTitle(t(locale, 'ticket.log.closed'))
.setColor(0x6366f1)
.addFields(
{ name: 'ID', value: ticket.id, inline: true },
{ name: t(locale, 'ticket.log.opener'), value: `<@${ticket.openerId}>`, inline: true },
{
name: t(locale, 'ticket.log.closedBy'),
value: `<@${closedById}>`,
inline: true
}
)
]
});
} catch (error) {
logger.warn({ error, ticketId: ticket.id }, 'Failed to post ticket close log');
}
}
if (config.transcriptDm && opener) {
try {
await opener.send({
content: t(locale, 'ticket.transcriptDm'),
files: [{ attachment: Buffer.from(transcriptHtml, 'utf8'), name: `ticket-${ticket.id}.html` }]
});
} catch (error) {
logger.warn({ error, ticketId: ticket.id }, 'Failed to DM ticket transcript');
}
}
if (config.ratingEnabled && opener) {
try {
await opener.send({
content: t(locale, 'ticket.ratePrompt'),
components: [buildRatingComponents(ticket.id, locale)]
});
} catch {
// User may have DMs disabled
}
}
if (channelId) {
try {
const channel = await context.client.channels.fetch(channelId);
if (channel?.isThread()) {
await channel.setArchived(true);
} else if (channel && 'delete' in channel) {
await channel.delete(`Ticket ${ticket.id} closed`);
}
} catch (error) {
logger.warn({ error, ticketId: ticket.id }, 'Failed to delete ticket channel');
}
}
}
export async function rateTicket(
context: BotContext,
ticketId: string,
userId: string,
rating: number,
locale: 'de' | 'en'
): Promise<void> {
const ticket = await context.prisma.ticket.findUnique({ where: { id: ticketId } });
if (!ticket || ticket.openerId !== userId) {
throw new TicketError('not_found');
}
if (ticket.rating !== null) {
throw new TicketError('already_rated');
}
await context.prisma.ticket.update({
where: { id: ticketId },
data: { rating }
});
}
export async function touchTicketActivity(
context: BotContext,
channelId: string
): Promise<void> {
const ticket = await context.prisma.ticket.findFirst({
where: {
OR: [{ channelId }, { threadId: channelId }],
status: { in: ['OPEN', 'CLAIMED'] }
}
});
if (!ticket) {
return;
}
await context.prisma.ticket.update({
where: { id: ticket.id },
data: { lastActivityAt: new Date() }
});
}
export async function closeInactiveTickets(context: BotContext): Promise<number> {
const configs = await context.prisma.ticketConfig.findMany({
where: { enabled: true }
});
let closed = 0;
for (const config of configs) {
const cutoff = new Date(Date.now() - config.inactivityHours * 3_600_000);
const stale = await context.prisma.ticket.findMany({
where: {
guildId: config.guildId,
status: { in: ['OPEN', 'CLAIMED'] },
lastActivityAt: { lt: cutoff }
},
include: { category: true }
});
const locale = await getGuildLocale(context.prisma, config.guildId);
for (const ticket of stale) {
try {
await closeTicket(context, ticket, context.client.user!.id, locale, t(locale, 'ticket.autoCloseReason'));
closed += 1;
} catch (error) {
logger.warn({ error, ticketId: ticket.id }, 'Auto-close ticket failed');
}
}
}
return closed;
}
export const checkInactiveTickets = closeInactiveTickets;
export async function upsertCategoryByName(
context: BotContext,
guildId: string,
name: string,
description?: string | null
): Promise<TicketCategory> {
await ensureGuild(context.prisma, guildId);
return context.prisma.ticketCategory.upsert({
where: { guildId_name: { guildId, name } },
update: { description: description ?? undefined },
create: { guildId, name, description: description ?? null }
});
}
export async function createCategoryWithRole(
context: BotContext,
guildId: string,
name: string,
supportRoleId: string
): Promise<TicketCategory> {
await ensureGuild(context.prisma, guildId);
const existing = await context.prisma.ticketCategory.findUnique({
where: { guildId_name: { guildId, name } }
});
if (existing) {
const roleIds = existing.supportRoleIds.includes(supportRoleId)
? existing.supportRoleIds
: [...existing.supportRoleIds, supportRoleId];
return context.prisma.ticketCategory.update({
where: { id: existing.id },
data: { supportRoleIds: roleIds }
});
}
return context.prisma.ticketCategory.create({
data: {
guildId,
name,
supportRoleIds: [supportRoleId]
}
});
}
export async function getGuildCategories(
context: BotContext,
guildId: string
): Promise<TicketCategory[]> {
return context.prisma.ticketCategory.findMany({
where: { guildId },
orderBy: { name: 'asc' }
});
}
export async function openTicketForCategory(
context: BotContext,
guild: Guild,
member: GuildMember,
categoryId: string,
parentChannelId?: string,
formAnswers?: Record<string, string>,
subject?: string
): Promise<Ticket> {
const locale = await getGuildLocale(context.prisma, guild.id);
const config = await getTicketConfig(context.prisma, guild.id);
if (!config.enabled) {
throw new TicketError('disabled');
}
const category = await context.prisma.ticketCategory.findUnique({ where: { id: categoryId } });
if (!category || category.guildId !== guild.id) {
throw new TicketError('category_not_found');
}
return createTicketChannel(
context,
{
guild,
opener: member,
category,
config,
parentChannelId,
subject,
formAnswers
},
locale
);
}

View File

@@ -11,3 +11,9 @@ export const verificationQueueName = 'verification';
export const verificationQueue = new Queue(verificationQueueName, { connection: redis });
export const reminderQueueName = 'reminders';
export const reminderQueue = new Queue(reminderQueueName, { connection: redis });
export const giveawayQueueName = 'giveaways';
export const giveawayQueue = new Queue(giveawayQueueName, { connection: redis });
export const ticketQueueName = 'tickets';
export const ticketQueue = new Queue(ticketQueueName, { connection: redis });
export const birthdayQueueName = 'birthdays';
export const birthdayQueue = new Queue(birthdayQueueName, { connection: redis });