Compare commits

...

3 Commits

Author SHA1 Message Date
smueller
1ceb8c3574 Refactor tag editing command to improve option handling and embed structure
- Simplified the retrieval of command options for editing tags, enhancing readability and maintainability.
- Updated embed handling to use an object structure instead of null, ensuring consistency in the updateTag function.
- Improved overall clarity by using descriptive variable names for command options.
2026-07-22 13:09:06 +02:00
smueller
8ec9b94879 Refactor reaction handling and tag response types for improved type safety
- Updated the event handler for message reaction removal to enhance type safety by specifying user types.
- Changed the return type of the buildTagReply function from MessageReplyOptions to InteractionReplyOptions for better compatibility with interaction responses.
- Adjusted embed handling in tag creation and update functions to use undefined instead of null for consistency.
2026-07-22 13:08:58 +02:00
smueller
a583db7a15 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.
2026-07-22 13:08:37 +02:00
50 changed files with 7828 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,202 @@
import { ChannelType, 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.type !== ChannelType.GuildText && channel.type !== ChannelType.GuildAnnouncement) {
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, 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 ?? '',
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,203 @@
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 contentValue = interaction.options.getString('content');
const triggerValue = interaction.options.getString('trigger_word');
const rolesValue = interaction.options.getString('allowed_roles');
const channelsValue = interaction.options.getString('allowed_channels');
const embedTitle = interaction.options.getString('embed_title');
const embedDescription = interaction.options.getString('embed_description');
const embedColor = interaction.options.getInteger('embed_color');
const embedProvided =
embedTitle !== null || embedDescription !== null || embedColor !== null;
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: contentValue !== null ? contentValue : undefined,
embed: embedProvided
? {
title: embedTitle ?? undefined,
description: embedDescription ?? undefined,
color: embedColor ?? undefined
}
: undefined,
triggerWord: triggerValue !== null ? triggerValue : undefined,
allowedRoleIds: rolesValue !== null ? parseIdList(rolesValue) : undefined,
allowedChannelIds: channelsValue !== null ? parseIdList(channelsValue) : 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,291 @@
import { EmbedBuilder, type Guild, type GuildMember } 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 type TagReplyPayload = {
content?: string;
embeds?: EmbedBuilder[];
};
export function buildTagReply(
tag: Tag,
member: GuildMember | null,
guild: Guild,
args: string
): TagReplyPayload {
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 ?? undefined,
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 ?? undefined,
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<TagReplyPayload> {
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 });

View File

@@ -183,5 +183,38 @@ Dieses Dokument hält den aktuellen Implementierungsstand fest. Es wird bei jede
## Nächster geplanter Schritt
- Manuelle Phase-3-Tests, dann nach Freigabe Phase 4 (Giveaways, Tickets, Reaction Roles, Custom Commands, Starboard, Suggestions, Geburtstage, Temp-Voice).
- Manuelle Phase-3-Tests, dann nach Freigabe Phase 4 (Giveaways, Tickets, Reaction Roles, Custom Commands, Geburtstage, Temp-Voice).
### Starboard (`apps/bot/src/modules/starboard/`)
- `/starboard setup` (Kanal, Schwellenwert, Emoji, Self-Star)
- Reaktions-Events: Nachrichten mit genügend ⭐ (konfigurierbar) → Embed im Starboard-Kanal
- NSFW-/Bot-/Ignore-Kanal-Ausschluss, Self-Star-Regel, Eintrag in `StarboardEntry`
### Suggestions (`apps/bot/src/modules/suggestions/`)
- `/suggest`, `/suggestion setup|approve|deny|consider|implement`
- Upvote/Downvote-Buttons (`sug:vote:{id}:up|down`), optional Thread
- Status-Update mit Kanal-Verschiebung (approved/denied) und DM an Autor
### Manuelle Discord-Tests Starboard/Suggestions (offen)
- `/starboard setup`, Reaktionen bis/unter Schwellenwert, Self-Star aus/an
- `/suggestion setup`, `/suggest`, Voting, Staff-Status + DM
### Giveaways (`apps/bot/src/modules/giveaways/`)
- `/giveaway start|end|reroll|list|delete|pause`
- Button `gw:join:{giveawayId}`, BullMQ-Job `giveawayEnd` (Export: `endGiveaway`)
- Anforderungen: Rolle, Level, Mitgliedschaftsdauer; DM an Gewinner
### Tickets (`apps/bot/src/modules/tickets/`)
- `/ticket panel create`, `/ticket category create`, `/ticket close|claim|add|remove|rename|priority`
- Panel-Buttons/Select, Modal, Kanal-/Thread-Modus, HTML-Transkript, Bewertung, Auto-Close-Export
### Manuelle Discord-Tests Giveaways/Tickets (offen)
- Giveaway: Start, Join, Ende/Reroll/Pause; Ticket: Panel, Öffnen, Close, Transkript, Rating
- Wiring in `commands.ts`, `index.ts`, `jobs.ts` (Parent-Agent)

View File

@@ -682,6 +682,518 @@ export const commandLocales = {
'fun.common.options.opponent': {
de: 'Gegner',
en: 'Opponent'
},
'starboard.description': {
de: 'Starboard konfigurieren',
en: 'Configure starboard'
},
'starboard.setup.description': {
de: 'Starboard einrichten',
en: 'Set up starboard'
},
'starboard.setup.options.channel': {
de: 'Starboard-Kanal',
en: 'Starboard channel'
},
'starboard.setup.options.threshold': {
de: 'Mindestanzahl Reaktionen',
en: 'Minimum reaction count'
},
'starboard.setup.options.emoji': {
de: 'Emoji (Standard: ⭐)',
en: 'Emoji (default: ⭐)'
},
'starboard.setup.options.allow_self_star': {
de: 'Self-Star erlauben',
en: 'Allow self-star'
},
'suggest.description': {
de: 'Einen Vorschlag einreichen',
en: 'Submit a suggestion'
},
'suggest.options.content': {
de: 'Vorschlagstext',
en: 'Suggestion content'
},
'suggestion.description': {
de: 'Vorschläge verwalten',
en: 'Manage suggestions'
},
'suggestion.setup.description': {
de: 'Vorschlagssystem einrichten',
en: 'Set up suggestions'
},
'suggestion.setup.options.open_channel': {
de: 'Kanal für offene Vorschläge',
en: 'Open suggestions channel'
},
'suggestion.setup.options.approved_channel': {
de: 'Kanal für angenommene Vorschläge',
en: 'Approved suggestions channel'
},
'suggestion.setup.options.denied_channel': {
de: 'Kanal für abgelehnte Vorschläge',
en: 'Denied suggestions channel'
},
'suggestion.setup.options.create_thread': {
de: 'Diskussions-Thread erstellen',
en: 'Create discussion thread'
},
'suggestion.approve.description': {
de: 'Vorschlag annehmen',
en: 'Approve suggestion'
},
'suggestion.deny.description': {
de: 'Vorschlag ablehnen',
en: 'Deny suggestion'
},
'suggestion.consider.description': {
de: 'Vorschlag in Prüfung markieren',
en: 'Mark suggestion as under consideration'
},
'suggestion.implement.description': {
de: 'Vorschlag als umgesetzt markieren',
en: 'Mark suggestion as implemented'
},
'suggestion.options.suggestion_id': {
de: 'Vorschlags-ID',
en: 'Suggestion ID'
},
'suggestion.options.reason': {
de: 'Begründung',
en: 'Reason'
},
'giveaway.description': {
de: 'Giveaways verwalten',
en: 'Manage giveaways'
},
'giveaway.start.description': {
de: 'Giveaway starten',
en: 'Start a giveaway'
},
'giveaway.start.options.duration': {
de: 'Dauer (z. B. 1h, 2d)',
en: 'Duration (e.g. 1h, 2d)'
},
'giveaway.start.options.winners': {
de: 'Anzahl Gewinner',
en: 'Number of winners'
},
'giveaway.start.options.prize': {
de: 'Preis',
en: 'Prize'
},
'giveaway.start.options.role': {
de: 'Erforderliche Rolle',
en: 'Required role'
},
'giveaway.start.options.level': {
de: 'Mindest-Level',
en: 'Minimum level'
},
'giveaway.start.options.member_days': {
de: 'Mindest-Mitgliedschaft in Tagen',
en: 'Minimum membership in days'
},
'giveaway.end.description': {
de: 'Giveaway vorzeitig beenden',
en: 'End a giveaway early'
},
'giveaway.reroll.description': {
de: 'Gewinner neu ziehen',
en: 'Reroll winners'
},
'giveaway.list.description': {
de: 'Aktive Giveaways anzeigen',
en: 'List active giveaways'
},
'giveaway.delete.description': {
de: 'Giveaway löschen',
en: 'Delete a giveaway'
},
'giveaway.pause.description': {
de: 'Giveaway pausieren oder fortsetzen',
en: 'Pause or resume a giveaway'
},
'giveaway.common.options.id': {
de: 'Giveaway-ID',
en: 'Giveaway ID'
},
'ticket.description': {
de: 'Ticket-System verwalten',
en: 'Manage the ticket system'
},
'ticket.panel.description': {
de: 'Ticket-Panels',
en: 'Ticket panels'
},
'ticket.panel.create.description': {
de: 'Ticket-Panel erstellen',
en: 'Create a ticket panel'
},
'ticket.panel.create.options.name': {
de: 'Kategoriename',
en: 'Category name'
},
'ticket.panel.create.options.description': {
de: 'Panel-Beschreibung',
en: 'Panel description'
},
'ticket.category.description': {
de: 'Ticket-Kategorien',
en: 'Ticket categories'
},
'ticket.category.create.description': {
de: 'Ticket-Kategorie mit Support-Rolle erstellen',
en: 'Create a ticket category with support role'
},
'ticket.category.create.options.name': {
de: 'Kategoriename',
en: 'Category name'
},
'ticket.category.create.options.support_role': {
de: 'Support-Rolle',
en: 'Support role'
},
'ticket.close.description': {
de: 'Ticket schließen',
en: 'Close the ticket'
},
'ticket.close.options.reason': {
de: 'Optionaler Schließgrund',
en: 'Optional close reason'
},
'ticket.claim.description': {
de: 'Ticket übernehmen',
en: 'Claim the ticket'
},
'ticket.add.description': {
de: 'Nutzer zum Ticket hinzufügen',
en: 'Add a user to the ticket'
},
'ticket.remove.description': {
de: 'Nutzer aus dem Ticket entfernen',
en: 'Remove a user from the ticket'
},
'ticket.rename.description': {
de: 'Ticket umbenennen',
en: 'Rename the ticket'
},
'ticket.rename.options.name': {
de: 'Neuer Name',
en: 'New name'
},
'ticket.priority.description': {
de: 'Ticket-Priorität setzen',
en: 'Set ticket priority'
},
'ticket.priority.options.level': {
de: 'Priorität',
en: 'Priority'
},
'ticket.priority.choice.low': {
de: 'Niedrig',
en: 'Low'
},
'ticket.priority.choice.normal': {
de: 'Normal',
en: 'Normal'
},
'ticket.priority.choice.high': {
de: 'Hoch',
en: 'High'
},
'ticket.priority.choice.urgent': {
de: 'Dringend',
en: 'Urgent'
},
'ticket.common.options.user': {
de: 'Nutzer',
en: 'User'
},
'birthdays.description': {
de: 'Geburtstage verwalten und anzeigen',
en: 'Manage and view birthdays'
},
'birthdays.set.description': {
de: 'Eigenen Geburtstag eintragen',
en: 'Set your birthday'
},
'birthdays.set.options.month': {
de: 'Monat (112)',
en: 'Month (112)'
},
'birthdays.set.options.day': {
de: 'Tag (131)',
en: 'Day (131)'
},
'birthdays.set.options.year': {
de: 'Optionales Geburtsjahr',
en: 'Optional birth year'
},
'birthdays.remove.description': {
de: 'Eigenen Geburtstag entfernen',
en: 'Remove your birthday'
},
'birthdays.next.description': {
de: 'Nächste Geburtstage anzeigen',
en: 'Show upcoming birthdays'
},
'birthdays.list.description': {
de: 'Alle Geburtstage auflisten',
en: 'List all birthdays'
},
'birthdays.setup.description': {
de: 'Geburtstags-Modul einrichten',
en: 'Configure the birthday module'
},
'birthdays.setup.options.channel': {
de: 'Kanal für Glückwünsche',
en: 'Channel for congratulations'
},
'birthdays.setup.options.role': {
de: 'Optionale 24h-Geburtstagsrolle',
en: 'Optional 24h birthday role'
},
'birthdays.setup.options.timezone': {
de: 'Zeitzone (z. B. Europe/Berlin)',
en: 'Timezone (e.g. Europe/Berlin)'
},
'birthdays.setup.options.message': {
de: 'Nachrichtenvorlage ({user}, {age})',
en: 'Message template ({user}, {age})'
},
'tempvoice.description': {
de: 'Temp-Voice (Join to Create) steuern',
en: 'Control temp voice (join to create)'
},
'tempvoice.setup.description': {
de: 'Temp-Voice einrichten',
en: 'Configure temp voice'
},
'tempvoice.setup.options.hub_channel': {
de: 'Hub-Sprachkanal (Join to Create)',
en: 'Hub voice channel (join to create)'
},
'tempvoice.setup.options.category': {
de: 'Kategorie für neue Kanäle',
en: 'Category for new channels'
},
'tempvoice.setup.options.default_name': {
de: 'Standardname ({user})',
en: 'Default name ({user})'
},
'tempvoice.setup.options.limit': {
de: 'Standard-Nutzerlimit (0 = unbegrenzt)',
en: 'Default user limit (0 = unlimited)'
},
'tempvoice.rename.description': {
de: 'Eigenen Temp-Kanal umbenennen',
en: 'Rename your temp channel'
},
'tempvoice.rename.options.name': {
de: 'Neuer Kanalname',
en: 'New channel name'
},
'tempvoice.limit.description': {
de: 'Nutzerlimit des Temp-Kanals setzen',
en: 'Set temp channel user limit'
},
'tempvoice.limit.options.limit': {
de: 'Limit (0 = unbegrenzt)',
en: 'Limit (0 = unlimited)'
},
'tempvoice.lock.description': {
de: 'Temp-Kanal sperren',
en: 'Lock temp channel'
},
'tempvoice.hide.description': {
de: 'Temp-Kanal verstecken',
en: 'Hide temp channel'
},
'tempvoice.kick.description': {
de: 'Nutzer aus Temp-Kanal entfernen',
en: 'Kick user from temp channel'
},
'tempvoice.kick.options.user': {
de: 'Zielnutzer',
en: 'Target user'
},
'tempvoice.transfer.description': {
de: 'Besitz an anderen Nutzer übertragen',
en: 'Transfer ownership to another user'
},
'tempvoice.transfer.options.user': {
de: 'Neuer Besitzer',
en: 'New owner'
},
'tempvoice.claim.description': {
de: 'Temp-Kanal übernehmen wenn Besitzer weg ist',
en: 'Claim temp channel when owner left'
},
'tempvoice.panel.description': {
de: 'Control-Panel mit Buttons anzeigen',
en: 'Show control panel with buttons'
},
'selfroles.description': {
de: 'Self-Roles (Reaction Roles) verwalten',
en: 'Manage self roles (reaction roles)'
},
'selfroles.panel.description': {
de: 'Self-Role-Panels',
en: 'Self-role panels'
},
'selfroles.panel.create.description': {
de: 'Self-Role-Panel erstellen',
en: 'Create a self-role panel'
},
'selfroles.panel.edit.description': {
de: 'Self-Role-Panel bearbeiten',
en: 'Edit a self-role panel'
},
'selfroles.panel.delete.description': {
de: 'Self-Role-Panel löschen',
en: 'Delete a self-role panel'
},
'selfroles.panel.create.options.title': {
de: 'Panel-Titel',
en: 'Panel title'
},
'selfroles.panel.create.options.description': {
de: 'Panel-Beschreibung',
en: 'Panel description'
},
'selfroles.panel.create.options.mode': {
de: 'Darstellungsmodus',
en: 'Display mode'
},
'selfroles.panel.create.options.behavior': {
de: 'Rollen-Verhalten',
en: 'Role behavior'
},
'selfroles.panel.create.options.roles': {
de: 'Rollen (roleId:label:emoji, …)',
en: 'Roles (roleId:label:emoji, …)'
},
'selfroles.panel.create.options.channel': {
de: 'Zielkanal',
en: 'Target channel'
},
'selfroles.panel.create.options.duration': {
de: 'Temporäre Rollendauer (z. B. 24h)',
en: 'Temporary role duration (e.g. 24h)'
},
'selfroles.panel.common.options.panel_id': {
de: 'Panel-ID',
en: 'Panel ID'
},
'selfroles.choice.mode.buttons': {
de: 'Buttons',
en: 'Buttons'
},
'selfroles.choice.mode.dropdown': {
de: 'Dropdown',
en: 'Dropdown'
},
'selfroles.choice.mode.reactions': {
de: 'Reaktionen',
en: 'Reactions'
},
'selfroles.choice.behavior.normal': {
de: 'Normal (Toggle)',
en: 'Normal (toggle)'
},
'selfroles.choice.behavior.unique': {
de: 'Nur eine Rolle',
en: 'Unique (one role)'
},
'selfroles.choice.behavior.verify': {
de: 'Nur hinzufügen',
en: 'Verify (add only)'
},
'selfroles.choice.behavior.temporary': {
de: 'Temporär',
en: 'Temporary'
},
'tag.description': {
de: 'Custom Commands (Tags) verwalten',
en: 'Manage custom commands (tags)'
},
'tag.create.description': {
de: 'Tag erstellen',
en: 'Create a tag'
},
'tag.edit.description': {
de: 'Tag bearbeiten',
en: 'Edit a tag'
},
'tag.delete.description': {
de: 'Tag löschen',
en: 'Delete a tag'
},
'tag.list.description': {
de: 'Tags auflisten',
en: 'List tags'
},
'tag.info.description': {
de: 'Tag-Details anzeigen',
en: 'Show tag details'
},
'tag.run.description': {
de: 'Tag ausführen',
en: 'Run a tag'
},
'tag.common.options.name': {
de: 'Tag-Name',
en: 'Tag name'
},
'tag.edit.options.new_name': {
de: 'Neuer Name',
en: 'New name'
},
'tag.create.options.response_type': {
de: 'Antworttyp',
en: 'Response type'
},
'tag.create.options.content': {
de: 'Textinhalt',
en: 'Text content'
},
'tag.create.options.embed_title': {
de: 'Embed-Titel',
en: 'Embed title'
},
'tag.create.options.embed_description': {
de: 'Embed-Beschreibung',
en: 'Embed description'
},
'tag.create.options.embed_color': {
de: 'Embed-Farbe (Dezimal)',
en: 'Embed color (decimal)'
},
'tag.create.options.trigger_word': {
de: 'Trigger-Wort (Auto-Responder)',
en: 'Trigger word (auto-responder)'
},
'tag.create.options.allowed_roles': {
de: 'Erlaubte Rollen-IDs (kommagetrennt)',
en: 'Allowed role IDs (comma-separated)'
},
'tag.create.options.allowed_channels': {
de: 'Erlaubte Kanal-IDs (kommagetrennt)',
en: 'Allowed channel IDs (comma-separated)'
},
'tag.run.options.args': {
de: 'Argumente für Platzhalter {args}',
en: 'Arguments for {args} placeholder'
},
'tag.choice.response_type.text': {
de: 'Text',
en: 'Text'
},
'tag.choice.response_type.embed': {
de: 'Embed',
en: 'Embed'
}
} as const;

View File

@@ -5,6 +5,7 @@ export * from './command-locales.js';
export * from './discord-localization.js';
export * from './phase2.js';
export * from './phase3.js';
export * from './phase4.js';
export const LocaleSchema = z.enum(['de', 'en']);
export type Locale = z.infer<typeof LocaleSchema>;
@@ -238,7 +239,204 @@ const de: Dictionary = {
'fun.media.disabled': 'Medien-Commands sind auf diesem Server deaktiviert.',
'fun.media.fetchFailed': 'Bild konnte nicht geladen werden. Versuche es später erneut.',
'fun.cat.title': 'Random Cat',
'fun.dog.title': 'Random Dog'
'fun.dog.title': 'Random Dog',
'starboard.notConfigured': 'Starboard ist noch nicht konfiguriert. Nutze `/starboard setup`.',
'starboard.invalidChannel': 'Der Starboard-Kanal ist ungültig.',
'starboard.setupDone': 'Starboard konfiguriert.',
'starboard.noContent': '*Kein Textinhalt*',
'starboard.field.stars': 'Sterne',
'starboard.field.source': 'Quelle',
'starboard.jumpLink': 'Zur Nachricht',
'suggestion.created': 'Vorschlag eingereicht (ID: `{id}`).',
'suggestion.setupDone': 'Vorschlagssystem konfiguriert.',
'suggestion.staffUpdated': 'Vorschlag aktualisiert ({status}).',
'suggestion.votedUp': 'Upvote gezählt.',
'suggestion.votedDown': 'Downvote gezählt.',
'suggestion.threadName': 'Diskussion #{id}',
'suggestion.error.not_configured': 'Vorschläge sind nicht konfiguriert. Nutze `/suggestion setup`.',
'suggestion.error.invalid_channel': 'Der konfigurierte Kanal ist ungültig.',
'suggestion.error.not_found': 'Vorschlag nicht gefunden.',
'suggestion.error.not_open': 'Dieser Vorschlag ist nicht mehr offen.',
'suggestion.error.already_voted': 'Du hast bereits so abgestimmt.',
'suggestion.embed.title': 'Vorschlag #{id}',
'suggestion.embed.status': 'Status',
'suggestion.embed.votes': 'Stimmen',
'suggestion.embed.author': 'Von {author}',
'suggestion.embed.staffReason': 'Team-Begründung',
'suggestion.status.open': 'Offen',
'suggestion.status.approved': 'Angenommen',
'suggestion.status.denied': 'Abgelehnt',
'suggestion.status.considered': 'In Prüfung',
'suggestion.status.implemented': 'Umgesetzt',
'suggestion.dm.statusUpdate': 'Dein Vorschlag wurde aktualisiert: **{status}**\nBegründung: {reason}',
'giveaway.embed.title': 'Giveaway',
'giveaway.embed.prize': 'Preis',
'giveaway.embed.winners': 'Gewinner',
'giveaway.embed.entrants': 'Teilnehmer',
'giveaway.embed.host': 'Host',
'giveaway.embed.requirements': 'Anforderungen',
'giveaway.embed.active': 'Klicke auf **Join**, um teilzunehmen!',
'giveaway.embed.paused': '⏸️ Dieses Giveaway ist pausiert.',
'giveaway.embed.ended': 'Beendet',
'giveaway.embed.endedWinners': 'Gewinner: {winners}',
'giveaway.embed.endedNoWinners': 'Keine gültigen Teilnehmer.',
'giveaway.embed.endsAt': 'Endet {time}',
'giveaway.button.join': 'Teilnehmen',
'giveaway.requirement.role': 'Rolle: {role}',
'giveaway.requirement.level': 'Mindest-Level: {level}',
'giveaway.requirement.memberDays': 'Server-Mitglied seit mindestens {days} Tagen',
'giveaway.started': 'Giveaway gestartet (ID: `{id}`).',
'giveaway.ended': 'Giveaway beendet.',
'giveaway.rerolled': 'Neue Gewinner: {winners}',
'giveaway.deleted': 'Giveaway gelöscht.',
'giveaway.paused': 'Giveaway pausiert.',
'giveaway.resumed': 'Giveaway fortgesetzt.',
'giveaway.joined': 'Du nimmst am Giveaway teil!',
'giveaway.list.header': '**Aktive Giveaways**',
'giveaway.list.empty': 'Keine aktiven Giveaways.',
'giveaway.list.entrants': 'Teilnehmer',
'giveaway.dm.won': 'Glückwunsch! Du hast **{prize}** auf **{guild}** gewonnen!',
'giveaway.error.not_found': 'Giveaway nicht gefunden oder bereits beendet.',
'giveaway.error.not_ended': 'Giveaway ist noch nicht beendet.',
'giveaway.error.paused': 'Dieses Giveaway ist pausiert.',
'giveaway.error.already_joined': 'Du nimmst bereits teil.',
'giveaway.error.member_not_found': 'Mitglied nicht gefunden.',
'giveaway.error.missing_role': 'Dir fehlt die erforderliche Rolle.',
'giveaway.error.insufficient_level': 'Dein Level ist zu niedrig.',
'giveaway.error.insufficient_member_days': 'Du bist noch nicht lange genug auf dem Server.',
'giveaway.error.invalid_duration': 'Ungültige Dauer. Nutze z. B. 1h, 2d.',
'giveaway.error.duration_too_short': 'Die Dauer muss mindestens 1 Minute betragen.',
'giveaway.error.invalid_channel': 'Ungültiger Kanal.',
'ticket.panel.defaultDescription': 'Wähle eine Kategorie, um ein Ticket zu öffnen.',
'ticket.panel.created': 'Ticket-Panel erstellt.',
'ticket.category.created': 'Kategorie **{name}** erstellt.',
'ticket.created': 'Dein Ticket wurde erstellt: {channel}',
'ticket.opened': '{user} hat ein Ticket geöffnet.',
'ticket.subjectLine': 'Betreff: {subject}',
'ticket.claimed': '{user} hat dieses Ticket übernommen.',
'ticket.userAdded': '{user} wurde zum Ticket hinzugefügt.',
'ticket.userRemoved': '{user} wurde aus dem Ticket entfernt.',
'ticket.renamed': 'Ticket umbenannt in **{name}**.',
'ticket.prioritySet': 'Priorität gesetzt: **{priority}**',
'ticket.closed': 'Ticket geschlossen von {user}.',
'ticket.closedWithReason': 'Ticket geschlossen von {user}.\nGrund: {reason}',
'ticket.close.success': 'Ticket geschlossen.',
'ticket.claim.success': 'Ticket übernommen.',
'ticket.add.success': '{user} hinzugefügt.',
'ticket.remove.success': '{user} entfernt.',
'ticket.rename.success': 'Ticket umbenannt in **{name}**.',
'ticket.priority.success': 'Priorität: **{priority}**',
'ticket.transcriptDm': 'Hier ist dein Ticket-Transkript.',
'ticket.ratePrompt': 'Wie zufrieden warst du mit dem Support? (15)',
'ticket.rated': 'Danke für deine Bewertung: {rating}/5',
'ticket.autoCloseReason': 'Automatisch wegen Inaktivität geschlossen',
'ticket.modal.subject': 'Betreff',
'ticket.log.closed': 'Ticket geschlossen',
'ticket.log.opener': 'Ersteller',
'ticket.log.closedBy': 'Geschlossen von',
'ticket.priority.LOW': 'Niedrig',
'ticket.priority.NORMAL': 'Normal',
'ticket.priority.HIGH': 'Hoch',
'ticket.priority.URGENT': 'Dringend',
'ticket.error.disabled': 'Das Ticket-System ist deaktiviert.',
'ticket.error.category_not_found': 'Kategorie nicht gefunden.',
'ticket.error.no_categories': 'Keine Ticket-Kategorien vorhanden.',
'ticket.error.not_in_ticket': 'Dieser Befehl kann nur in einem Ticket-Kanal genutzt werden.',
'ticket.error.not_staff': 'Du hast keine Berechtigung für diese Ticket-Aktion.',
'ticket.error.already_open': 'Du hast bereits ein offenes Ticket.',
'ticket.error.closed': 'Dieses Ticket ist bereits geschlossen.',
'ticket.error.already_claimed': 'Du hast dieses Ticket bereits übernommen.',
'ticket.error.cannot_remove_opener': 'Der Ticket-Ersteller kann nicht entfernt werden.',
'ticket.error.no_channel': 'Ticket-Kanal nicht gefunden.',
'ticket.error.invalid_name': 'Ungültiger Name.',
'ticket.error.invalid_channel': 'Ungültiger Kanal.',
'ticket.error.no_parent_channel': 'Kein Elternkanal für Thread-Tickets konfiguriert.',
'ticket.error.invalid_parent_channel': 'Der konfigurierte Elternkanal ist ungültig.',
'ticket.error.not_found': 'Ticket nicht gefunden.',
'ticket.error.already_rated': 'Du hast dieses Ticket bereits bewertet.',
'birthdays.error.invalidDate': 'Ungültiges Datum. Prüfe Tag, Monat und optionales Jahr.',
'birthdays.error.invalidTimezone': 'Ungültige Zeitzone. Nutze z. B. Europe/Berlin.',
'birthdays.setup.done': 'Geburtstags-Modul konfiguriert.',
'birthdays.set.success': 'Geburtstag gespeichert: {day}.{month}.{year}',
'birthdays.remove.none': 'Du hast keinen Geburtstag eingetragen.',
'birthdays.remove.success': 'Dein Geburtstag wurde entfernt.',
'birthdays.list.empty': 'Noch keine Geburtstage eingetragen.',
'birthdays.list.header': '**Geburtstage (sortiert nach nächstem Datum):**',
'birthdays.next.header': '**Nächste Geburtstage:**',
'birthdays.list.line': '{rank}. {user} — {date} (in {days} T.)',
'tempvoice.error.disabled': 'Temp-Voice ist auf diesem Server deaktiviert.',
'tempvoice.error.invalidCategory': 'Ungültige Kategorie.',
'tempvoice.error.notOwner': 'Du bist nicht der Besitzer dieses Temp-Voice-Kanals.',
'tempvoice.error.notInChannel': 'Du musst in einem Temp-Voice-Kanal sein.',
'tempvoice.error.notFound': 'Temp-Voice-Kanal nicht gefunden.',
'tempvoice.error.invalidName': 'Ungültiger Kanalname.',
'tempvoice.error.invalidLimit': 'Limit muss zwischen 0 und 99 liegen.',
'tempvoice.setup.done': 'Temp-Voice konfiguriert.',
'tempvoice.rename.success': 'Kanal umbenannt in **{name}**.',
'tempvoice.limit.success': 'Nutzerlimit auf **{limit}** gesetzt (0 = unbegrenzt).',
'tempvoice.lock.success': 'Kanal gesperrt (Connect für @everyone entfernt).',
'tempvoice.hide.success': 'Kanal versteckt (ViewChannel für @everyone entfernt).',
'tempvoice.kick.self': 'Du kannst dich nicht selbst kicken.',
'tempvoice.kick.notInChannel': 'Dieser Nutzer ist nicht in deinem Kanal.',
'tempvoice.kick.success': '{user} wurde aus dem Kanal entfernt.',
'tempvoice.transfer.self': 'Du kannst den Kanal nicht an dich selbst übertragen.',
'tempvoice.transfer.notInChannel': 'Der neue Besitzer muss in deinem Kanal sein.',
'tempvoice.transfer.success': 'Kanal an {user} übertragen.',
'tempvoice.claim.ownerPresent': 'Der Besitzer ist noch im Kanal.',
'tempvoice.claim.success': 'Du bist jetzt Besitzer dieses Kanals.',
'tempvoice.panel.title': 'Temp-Voice Steuerung',
'tempvoice.panel.description': 'Verwalte **{channel}** mit den Buttons oder `/voice`-Commands.',
'tempvoice.button.rename': 'Umbenennen',
'tempvoice.button.limit': 'Limit',
'tempvoice.button.lock': 'Sperren',
'tempvoice.button.hide': 'Verstecken',
'tempvoice.button.kick': 'Kicken',
'tempvoice.button.transfer': 'Übertragen',
'tempvoice.button.claim': 'Übernehmen',
'selfroles.panel.defaultDescription': 'Wähle unten deine Rollen aus.',
'selfroles.panel.rolesField': 'Rollen',
'selfroles.panel.temporaryFooter': 'Rollen sind temporär und laufen automatisch ab.',
'selfroles.panel.created': 'Self-Role-Panel erstellt (ID: `{id}`).',
'selfroles.panel.updated': 'Self-Role-Panel aktualisiert (ID: `{id}`).',
'selfroles.panel.deleted': 'Self-Role-Panel gelöscht.',
'selfroles.role.added': 'Rolle hinzugefügt.',
'selfroles.role.removed': 'Rolle entfernt.',
'selfroles.role.verified': 'Rolle vergeben.',
'selfroles.role.unchanged': 'Du hast diese Rolle bereits.',
'selfroles.error.not_found': 'Self-Role-Panel nicht gefunden.',
'selfroles.error.invalid_roles': 'Ungültiges Rollen-Format. Nutze roleId:label:emoji.',
'selfroles.error.too_many_roles': 'Maximal 25 Rollen pro Panel.',
'selfroles.error.invalid_channel': 'Ungültiger Kanal.',
'selfroles.error.invalid_duration': 'Ungültige Dauer. Nutze z. B. 1h, 2d.',
'selfroles.error.duration_too_short': 'Die Dauer muss mindestens 1 Minute betragen.',
'selfroles.error.duration_required': 'Für temporäre Panels ist eine Dauer erforderlich.',
'selfroles.error.reaction_emoji_required': 'Reaktions-Modus erfordert Emojis für alle Rollen.',
'selfroles.error.member_not_found': 'Mitglied nicht gefunden.',
'selfroles.error.role_not_in_panel': 'Diese Rolle gehört nicht zu diesem Panel.',
'selfroles.error.role_not_found': 'Rolle nicht gefunden.',
'selfroles.error.role_hierarchy': 'Die Rolle liegt über der höchsten Bot-Rolle.',
'selfroles.error.role_managed': 'Integrierte Rollen können nicht vergeben werden.',
'selfroles.error.bot_missing_permissions': 'Dem Bot fehlt die Berechtigung Rollen zu verwalten.',
'tag.created': 'Tag `{name}` erstellt.',
'tag.updated': 'Tag `{name}` aktualisiert.',
'tag.deleted': 'Tag gelöscht.',
'tag.list.header': '**Tags**',
'tag.list.empty': 'Keine Tags vorhanden.',
'tag.list.uses': 'Aufrufe',
'tag.info.name': 'Name: `{name}`',
'tag.info.type': 'Typ: {type}',
'tag.info.uses': 'Aufrufe: {count}',
'tag.info.trigger': 'Trigger: `{word}`',
'tag.info.noTrigger': 'Kein Auto-Responder.',
'tag.info.roles': 'Rollen: {roles}',
'tag.info.allRoles': 'Rollen: alle',
'tag.info.channels': 'Kanäle: {channels}',
'tag.info.allChannels': 'Kanäle: alle',
'tag.error.not_found': 'Tag nicht gefunden.',
'tag.error.not_allowed': 'Du darfst diesen Tag hier nicht nutzen.',
'tag.error.invalid_name': 'Ungültiger Tag-Name (132 Zeichen, a-z, 0-9, _, -).',
'tag.error.content_required': 'Text-Tags benötigen Inhalt.',
'tag.error.embed_required': 'Embed-Tags benötigen Titel oder Beschreibung.'
};
const en: Dictionary = {
'generic.noPermission': 'You do not have permission for this action.',
@@ -439,7 +637,204 @@ const en: Dictionary = {
'fun.media.disabled': 'Media commands are disabled on this server.',
'fun.media.fetchFailed': 'Could not load image. Try again later.',
'fun.cat.title': 'Random Cat',
'fun.dog.title': 'Random Dog'
'fun.dog.title': 'Random Dog',
'starboard.notConfigured': 'Starboard is not configured yet. Use `/starboard setup`.',
'starboard.invalidChannel': 'The starboard channel is invalid.',
'starboard.setupDone': 'Starboard configured.',
'starboard.noContent': '*No text content*',
'starboard.field.stars': 'Stars',
'starboard.field.source': 'Source',
'starboard.jumpLink': 'Jump to message',
'suggestion.created': 'Suggestion submitted (ID: `{id}`).',
'suggestion.setupDone': 'Suggestion system configured.',
'suggestion.staffUpdated': 'Suggestion updated ({status}).',
'suggestion.votedUp': 'Upvote recorded.',
'suggestion.votedDown': 'Downvote recorded.',
'suggestion.threadName': 'Discussion #{id}',
'suggestion.error.not_configured': 'Suggestions are not configured. Use `/suggestion setup`.',
'suggestion.error.invalid_channel': 'The configured channel is invalid.',
'suggestion.error.not_found': 'Suggestion not found.',
'suggestion.error.not_open': 'This suggestion is no longer open.',
'suggestion.error.already_voted': 'You already voted this way.',
'suggestion.embed.title': 'Suggestion #{id}',
'suggestion.embed.status': 'Status',
'suggestion.embed.votes': 'Votes',
'suggestion.embed.author': 'By {author}',
'suggestion.embed.staffReason': 'Staff reason',
'suggestion.status.open': 'Open',
'suggestion.status.approved': 'Approved',
'suggestion.status.denied': 'Denied',
'suggestion.status.considered': 'Under consideration',
'suggestion.status.implemented': 'Implemented',
'suggestion.dm.statusUpdate': 'Your suggestion was updated: **{status}**\nReason: {reason}',
'giveaway.embed.title': 'Giveaway',
'giveaway.embed.prize': 'Prize',
'giveaway.embed.winners': 'Winners',
'giveaway.embed.entrants': 'Entrants',
'giveaway.embed.host': 'Host',
'giveaway.embed.requirements': 'Requirements',
'giveaway.embed.active': 'Click **Join** to enter!',
'giveaway.embed.paused': '⏸️ This giveaway is paused.',
'giveaway.embed.ended': 'Ended',
'giveaway.embed.endedWinners': 'Winners: {winners}',
'giveaway.embed.endedNoWinners': 'No valid entrants.',
'giveaway.embed.endsAt': 'Ends {time}',
'giveaway.button.join': 'Join',
'giveaway.requirement.role': 'Role: {role}',
'giveaway.requirement.level': 'Minimum level: {level}',
'giveaway.requirement.memberDays': 'Member for at least {days} days',
'giveaway.started': 'Giveaway started (ID: `{id}`).',
'giveaway.ended': 'Giveaway ended.',
'giveaway.rerolled': 'New winners: {winners}',
'giveaway.deleted': 'Giveaway deleted.',
'giveaway.paused': 'Giveaway paused.',
'giveaway.resumed': 'Giveaway resumed.',
'giveaway.joined': 'You entered the giveaway!',
'giveaway.list.header': '**Active giveaways**',
'giveaway.list.empty': 'No active giveaways.',
'giveaway.list.entrants': 'entrants',
'giveaway.dm.won': 'Congratulations! You won **{prize}** on **{guild}**!',
'giveaway.error.not_found': 'Giveaway not found or already ended.',
'giveaway.error.not_ended': 'Giveaway has not ended yet.',
'giveaway.error.paused': 'This giveaway is paused.',
'giveaway.error.already_joined': 'You already entered.',
'giveaway.error.member_not_found': 'Member not found.',
'giveaway.error.missing_role': 'You are missing the required role.',
'giveaway.error.insufficient_level': 'Your level is too low.',
'giveaway.error.insufficient_member_days': 'You have not been on the server long enough.',
'giveaway.error.invalid_duration': 'Invalid duration. Use e.g. 1h, 2d.',
'giveaway.error.duration_too_short': 'Duration must be at least 1 minute.',
'giveaway.error.invalid_channel': 'Invalid channel.',
'ticket.panel.defaultDescription': 'Select a category to open a ticket.',
'ticket.panel.created': 'Ticket panel created.',
'ticket.category.created': 'Category **{name}** created.',
'ticket.created': 'Your ticket was created: {channel}',
'ticket.opened': '{user} opened a ticket.',
'ticket.subjectLine': 'Subject: {subject}',
'ticket.claimed': '{user} claimed this ticket.',
'ticket.userAdded': '{user} was added to the ticket.',
'ticket.userRemoved': '{user} was removed from the ticket.',
'ticket.renamed': 'Ticket renamed to **{name}**.',
'ticket.prioritySet': 'Priority set to **{priority}**',
'ticket.closed': 'Ticket closed by {user}.',
'ticket.closedWithReason': 'Ticket closed by {user}.\nReason: {reason}',
'ticket.close.success': 'Ticket closed.',
'ticket.claim.success': 'Ticket claimed.',
'ticket.add.success': 'Added {user}.',
'ticket.remove.success': 'Removed {user}.',
'ticket.rename.success': 'Ticket renamed to **{name}**.',
'ticket.priority.success': 'Priority: **{priority}**',
'ticket.transcriptDm': 'Here is your ticket transcript.',
'ticket.ratePrompt': 'How satisfied were you with support? (15)',
'ticket.rated': 'Thanks for your rating: {rating}/5',
'ticket.autoCloseReason': 'Auto-closed due to inactivity',
'ticket.modal.subject': 'Subject',
'ticket.log.closed': 'Ticket closed',
'ticket.log.opener': 'Opener',
'ticket.log.closedBy': 'Closed by',
'ticket.priority.LOW': 'Low',
'ticket.priority.NORMAL': 'Normal',
'ticket.priority.HIGH': 'High',
'ticket.priority.URGENT': 'Urgent',
'ticket.error.disabled': 'The ticket system is disabled.',
'ticket.error.category_not_found': 'Category not found.',
'ticket.error.no_categories': 'No ticket categories configured.',
'ticket.error.not_in_ticket': 'This command can only be used inside a ticket channel.',
'ticket.error.not_staff': 'You do not have permission for this ticket action.',
'ticket.error.already_open': 'You already have an open ticket.',
'ticket.error.closed': 'This ticket is already closed.',
'ticket.error.already_claimed': 'You already claimed this ticket.',
'ticket.error.cannot_remove_opener': 'The ticket opener cannot be removed.',
'ticket.error.no_channel': 'Ticket channel not found.',
'ticket.error.invalid_name': 'Invalid name.',
'ticket.error.invalid_channel': 'Invalid channel.',
'ticket.error.no_parent_channel': 'No parent channel configured for thread tickets.',
'ticket.error.invalid_parent_channel': 'The configured parent channel is invalid.',
'ticket.error.not_found': 'Ticket not found.',
'ticket.error.already_rated': 'You already rated this ticket.',
'birthdays.error.invalidDate': 'Invalid date. Check day, month, and optional year.',
'birthdays.error.invalidTimezone': 'Invalid timezone. Use e.g. Europe/Berlin.',
'birthdays.setup.done': 'Birthday module configured.',
'birthdays.set.success': 'Birthday saved: {day}/{month}/{year}',
'birthdays.remove.none': 'You have not set a birthday.',
'birthdays.remove.success': 'Your birthday was removed.',
'birthdays.list.empty': 'No birthdays registered yet.',
'birthdays.list.header': '**Birthdays (sorted by next date):**',
'birthdays.next.header': '**Upcoming birthdays:**',
'birthdays.list.line': '{rank}. {user} — {date} (in {days} d)',
'tempvoice.error.disabled': 'Temp voice is disabled on this server.',
'tempvoice.error.invalidCategory': 'Invalid category.',
'tempvoice.error.notOwner': 'You are not the owner of this temp voice channel.',
'tempvoice.error.notInChannel': 'You must be in a temp voice channel.',
'tempvoice.error.notFound': 'Temp voice channel not found.',
'tempvoice.error.invalidName': 'Invalid channel name.',
'tempvoice.error.invalidLimit': 'Limit must be between 0 and 99.',
'tempvoice.setup.done': 'Temp voice configured.',
'tempvoice.rename.success': 'Channel renamed to **{name}**.',
'tempvoice.limit.success': 'User limit set to **{limit}** (0 = unlimited).',
'tempvoice.lock.success': 'Channel locked (Connect denied for @everyone).',
'tempvoice.hide.success': 'Channel hidden (ViewChannel denied for @everyone).',
'tempvoice.kick.self': 'You cannot kick yourself.',
'tempvoice.kick.notInChannel': 'That user is not in your channel.',
'tempvoice.kick.success': 'Removed {user} from the channel.',
'tempvoice.transfer.self': 'You cannot transfer the channel to yourself.',
'tempvoice.transfer.notInChannel': 'The new owner must be in your channel.',
'tempvoice.transfer.success': 'Channel transferred to {user}.',
'tempvoice.claim.ownerPresent': 'The owner is still in the channel.',
'tempvoice.claim.success': 'You are now the owner of this channel.',
'tempvoice.panel.title': 'Temp Voice Control',
'tempvoice.panel.description': 'Manage **{channel}** with the buttons below or `/voice` commands.',
'tempvoice.button.rename': 'Rename',
'tempvoice.button.limit': 'Limit',
'tempvoice.button.lock': 'Lock',
'tempvoice.button.hide': 'Hide',
'tempvoice.button.kick': 'Kick',
'tempvoice.button.transfer': 'Transfer',
'tempvoice.button.claim': 'Claim',
'selfroles.panel.defaultDescription': 'Choose your roles below.',
'selfroles.panel.rolesField': 'Roles',
'selfroles.panel.temporaryFooter': 'Roles are temporary and expire automatically.',
'selfroles.panel.created': 'Self-role panel created (ID: `{id}`).',
'selfroles.panel.updated': 'Self-role panel updated (ID: `{id}`).',
'selfroles.panel.deleted': 'Self-role panel deleted.',
'selfroles.role.added': 'Role added.',
'selfroles.role.removed': 'Role removed.',
'selfroles.role.verified': 'Role granted.',
'selfroles.role.unchanged': 'You already have this role.',
'selfroles.error.not_found': 'Self-role panel not found.',
'selfroles.error.invalid_roles': 'Invalid roles format. Use roleId:label:emoji.',
'selfroles.error.too_many_roles': 'Maximum 25 roles per panel.',
'selfroles.error.invalid_channel': 'Invalid channel.',
'selfroles.error.invalid_duration': 'Invalid duration. Use e.g. 1h, 2d.',
'selfroles.error.duration_too_short': 'Duration must be at least 1 minute.',
'selfroles.error.duration_required': 'Temporary panels require a duration.',
'selfroles.error.reaction_emoji_required': 'Reaction mode requires emojis for all roles.',
'selfroles.error.member_not_found': 'Member not found.',
'selfroles.error.role_not_in_panel': 'This role is not part of this panel.',
'selfroles.error.role_not_found': 'Role not found.',
'selfroles.error.role_hierarchy': 'The role is above the bot\'s highest role.',
'selfroles.error.role_managed': 'Managed roles cannot be assigned.',
'selfroles.error.bot_missing_permissions': 'The bot lacks Manage Roles permission.',
'tag.created': 'Tag `{name}` created.',
'tag.updated': 'Tag `{name}` updated.',
'tag.deleted': 'Tag deleted.',
'tag.list.header': '**Tags**',
'tag.list.empty': 'No tags configured.',
'tag.list.uses': 'uses',
'tag.info.name': 'Name: `{name}`',
'tag.info.type': 'Type: {type}',
'tag.info.uses': 'Uses: {count}',
'tag.info.trigger': 'Trigger: `{word}`',
'tag.info.noTrigger': 'No auto-responder.',
'tag.info.roles': 'Roles: {roles}',
'tag.info.allRoles': 'Roles: all',
'tag.info.channels': 'Channels: {channels}',
'tag.info.allChannels': 'Channels: all',
'tag.error.not_found': 'Tag not found.',
'tag.error.not_allowed': 'You are not allowed to use this tag here.',
'tag.error.invalid_name': 'Invalid tag name (132 chars, a-z, 0-9, _, -).',
'tag.error.content_required': 'Text tags require content.',
'tag.error.embed_required': 'Embed tags require a title or description.'
};
const locales: Record<Locale, Dictionary> = { de, en };

View File

@@ -0,0 +1,84 @@
import { z } from 'zod';
export const GiveawayStatusSchema = z.enum(['ACTIVE', 'PAUSED', 'ENDED']);
export type GiveawayStatus = z.infer<typeof GiveawayStatusSchema>;
export const TicketModeSchema = z.enum(['CHANNEL', 'THREAD']);
export type TicketMode = z.infer<typeof TicketModeSchema>;
export const TicketStatusSchema = z.enum(['OPEN', 'CLAIMED', 'CLOSED']);
export type TicketStatus = z.infer<typeof TicketStatusSchema>;
export const TicketPrioritySchema = z.enum(['LOW', 'NORMAL', 'HIGH', 'URGENT']);
export type TicketPriority = z.infer<typeof TicketPrioritySchema>;
export const SelfRoleModeSchema = z.enum(['BUTTONS', 'DROPDOWN', 'REACTIONS']);
export type SelfRoleMode = z.infer<typeof SelfRoleModeSchema>;
export const SelfRoleBehaviorSchema = z.enum(['NORMAL', 'UNIQUE', 'VERIFY', 'TEMPORARY']);
export type SelfRoleBehavior = z.infer<typeof SelfRoleBehaviorSchema>;
export const SelfRoleEntrySchema = z.object({
roleId: z.string(),
label: z.string(),
emoji: z.string().optional(),
description: z.string().optional()
});
export type SelfRoleEntry = z.infer<typeof SelfRoleEntrySchema>;
export const TagResponseTypeSchema = z.enum(['TEXT', 'EMBED']);
export type TagResponseType = z.infer<typeof TagResponseTypeSchema>;
export const SuggestionStatusSchema = z.enum([
'OPEN',
'APPROVED',
'DENIED',
'CONSIDERED',
'IMPLEMENTED'
]);
export type SuggestionStatus = z.infer<typeof SuggestionStatusSchema>;
export function applyTagPlaceholders(
template: string,
vars: Record<string, string>
): string {
return Object.entries(vars).reduce(
(acc, [key, value]) => acc.replaceAll(`{${key}}`, value),
template
);
}
export function pickRandomWinners<T>(items: T[], count: number): T[] {
const pool = [...items];
const winners: T[] = [];
while (winners.length < count && pool.length > 0) {
const index = Math.floor(Math.random() * pool.length);
winners.push(pool.splice(index, 1)[0]!);
}
return winners;
}
export function escapeHtml(input: string): string {
return input
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;');
}
export function buildTicketTranscriptHtml(input: {
guildName: string;
ticketId: string;
openerTag: string;
messages: Array<{ author: string; content: string; timestamp: string }>;
}): string {
const rows = input.messages
.map(
(m) =>
`<div class="msg"><span class="meta">${escapeHtml(m.timestamp)}${escapeHtml(m.author)}</span><div>${escapeHtml(m.content)}</div></div>`
)
.join('\n');
return `<!DOCTYPE html><html><head><meta charset="utf-8"/><title>Ticket ${escapeHtml(input.ticketId)}</title>
<style>body{font-family:sans-serif;background:#111;color:#eee;padding:1rem}.msg{margin:.75rem 0;padding:.5rem;border-left:3px solid #6366f1}.meta{color:#9ca3af;font-size:.85rem}</style>
</head><body><h1>Ticket ${escapeHtml(input.ticketId)}</h1><p>${escapeHtml(input.guildName)}${escapeHtml(input.openerTag)}</p>${rows}</body></html>`;
}