Compare commits
3 Commits
f2f856628d
...
1ceb8c3574
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ceb8c3574 | ||
|
|
8ec9b94879 | ||
|
|
a583db7a15 |
@@ -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;
|
||||||
@@ -30,6 +30,20 @@ model Guild {
|
|||||||
polls Poll[]
|
polls Poll[]
|
||||||
reminders Reminder[]
|
reminders Reminder[]
|
||||||
afkStatuses AfkStatus[]
|
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 {
|
model GuildSettings {
|
||||||
@@ -389,3 +403,239 @@ model AfkStatus {
|
|||||||
|
|
||||||
@@unique([guildId, userId])
|
@@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])
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { refreshPhishingDomains } from './modules/automod/filters.js';
|
|||||||
import { completeVerification } from './modules/verification/handlers.js';
|
import { completeVerification } from './modules/verification/handlers.js';
|
||||||
import { closePoll } from './modules/utility/poll.js';
|
import { closePoll } from './modules/utility/poll.js';
|
||||||
import { sendReminder } from './modules/utility/reminders.js';
|
import { sendReminder } from './modules/utility/reminders.js';
|
||||||
|
import { expireSelfRole } from './modules/selfroles/service.js';
|
||||||
import {
|
import {
|
||||||
automodQueue,
|
automodQueue,
|
||||||
automodQueueName,
|
automodQueueName,
|
||||||
@@ -18,6 +19,7 @@ import {
|
|||||||
backupQueueName,
|
backupQueueName,
|
||||||
moderationQueueName,
|
moderationQueueName,
|
||||||
reminderQueueName,
|
reminderQueueName,
|
||||||
|
ticketQueueName,
|
||||||
verificationQueueName
|
verificationQueueName
|
||||||
} from './queues.js';
|
} from './queues.js';
|
||||||
|
|
||||||
@@ -49,6 +51,12 @@ type PollCloseJob = {
|
|||||||
pollId: string;
|
pollId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type SelfRoleExpireJob = {
|
||||||
|
guildId: string;
|
||||||
|
userId: string;
|
||||||
|
roleId: string;
|
||||||
|
};
|
||||||
|
|
||||||
export function startWorkers(context: BotContext): Worker[] {
|
export function startWorkers(context: BotContext): Worker[] {
|
||||||
const moderationWorker = new Worker<TempBanJob>(
|
const moderationWorker = new Worker<TempBanJob>(
|
||||||
moderationQueueName,
|
moderationQueueName,
|
||||||
@@ -135,7 +143,22 @@ export function startWorkers(context: BotContext): Worker[] {
|
|||||||
logger.error({ jobId: job?.id, error }, 'Reminder job failed');
|
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> {
|
export async function ensureRecurringJobs(): Promise<void> {
|
||||||
|
|||||||
45
apps/bot/src/modules/birthdays/command-definitions.ts
Normal file
45
apps/bot/src/modules/birthdays/command-definitions.ts
Normal 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'
|
||||||
|
);
|
||||||
126
apps/bot/src/modules/birthdays/commands.ts
Normal file
126
apps/bot/src/modules/birthdays/commands.ts
Normal 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];
|
||||||
2
apps/bot/src/modules/birthdays/index.ts
Normal file
2
apps/bot/src/modules/birthdays/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { birthdayCommands } from './commands.js';
|
||||||
|
export { runBirthdayCheck, expireBirthdayRole } from './service.js';
|
||||||
304
apps/bot/src/modules/birthdays/service.ts
Normal file
304
apps/bot/src/modules/birthdays/service.ts
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
42
apps/bot/src/modules/giveaways/buttons.ts
Normal file
42
apps/bot/src/modules/giveaways/buttons.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
61
apps/bot/src/modules/giveaways/command-definitions.ts
Normal file
61
apps/bot/src/modules/giveaways/command-definitions.ts
Normal 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'
|
||||||
|
);
|
||||||
166
apps/bot/src/modules/giveaways/commands.ts
Normal file
166
apps/bot/src/modules/giveaways/commands.ts
Normal 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];
|
||||||
3
apps/bot/src/modules/giveaways/index.ts
Normal file
3
apps/bot/src/modules/giveaways/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export { giveawayCommands } from './commands.js';
|
||||||
|
export { isGiveawayButton, handleGiveawayButton } from './buttons.js';
|
||||||
|
export { endGiveaway, scheduleGiveawayEnd } from './service.js';
|
||||||
419
apps/bot/src/modules/giveaways/service.ts
Normal file
419
apps/bot/src/modules/giveaways/service.ts
Normal 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' }
|
||||||
|
});
|
||||||
|
}
|
||||||
95
apps/bot/src/modules/selfroles/command-definitions.ts
Normal file
95
apps/bot/src/modules/selfroles/command-definitions.ts
Normal 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'
|
||||||
|
);
|
||||||
202
apps/bot/src/modules/selfroles/commands.ts
Normal file
202
apps/bot/src/modules/selfroles/commands.ts
Normal 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];
|
||||||
8
apps/bot/src/modules/selfroles/index.ts
Normal file
8
apps/bot/src/modules/selfroles/index.ts
Normal 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';
|
||||||
132
apps/bot/src/modules/selfroles/interactions.ts
Normal file
132
apps/bot/src/modules/selfroles/interactions.ts
Normal 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);
|
||||||
|
}
|
||||||
132
apps/bot/src/modules/selfroles/reactions.ts
Normal file
132
apps/bot/src/modules/selfroles/reactions.ts
Normal 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');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
602
apps/bot/src/modules/selfroles/service.ts
Normal file
602
apps/bot/src/modules/selfroles/service.ts
Normal 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;
|
||||||
|
}
|
||||||
33
apps/bot/src/modules/starboard/command-definitions.ts
Normal file
33
apps/bot/src/modules/starboard/command-definitions.ts
Normal 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'
|
||||||
|
);
|
||||||
49
apps/bot/src/modules/starboard/commands.ts
Normal file
49
apps/bot/src/modules/starboard/commands.ts
Normal 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];
|
||||||
29
apps/bot/src/modules/starboard/events.ts
Normal file
29
apps/bot/src/modules/starboard/events.ts
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
2
apps/bot/src/modules/starboard/index.ts
Normal file
2
apps/bot/src/modules/starboard/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { starboardCommands } from './commands.js';
|
||||||
|
export { registerStarboardEvents } from './events.js';
|
||||||
251
apps/bot/src/modules/starboard/service.ts
Normal file
251
apps/bot/src/modules/starboard/service.ts
Normal 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);
|
||||||
|
}
|
||||||
60
apps/bot/src/modules/suggestions/buttons.ts
Normal file
60
apps/bot/src/modules/suggestions/buttons.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
90
apps/bot/src/modules/suggestions/command-definitions.ts
Normal file
90
apps/bot/src/modules/suggestions/command-definitions.ts
Normal 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'
|
||||||
|
);
|
||||||
121
apps/bot/src/modules/suggestions/commands.ts
Normal file
121
apps/bot/src/modules/suggestions/commands.ts
Normal 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];
|
||||||
2
apps/bot/src/modules/suggestions/index.ts
Normal file
2
apps/bot/src/modules/suggestions/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { suggestionsCommands } from './commands.js';
|
||||||
|
export { isSuggestionButton, handleSuggestionButton } from './buttons.js';
|
||||||
341
apps/bot/src/modules/suggestions/service.ts
Normal file
341
apps/bot/src/modules/suggestions/service.ts
Normal 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;
|
||||||
|
}
|
||||||
107
apps/bot/src/modules/tags/command-definitions.ts
Normal file
107
apps/bot/src/modules/tags/command-definitions.ts
Normal 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'
|
||||||
|
);
|
||||||
203
apps/bot/src/modules/tags/commands.ts
Normal file
203
apps/bot/src/modules/tags/commands.ts
Normal 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];
|
||||||
50
apps/bot/src/modules/tags/events.ts
Normal file
50
apps/bot/src/modules/tags/events.ts
Normal 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');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
2
apps/bot/src/modules/tags/index.ts
Normal file
2
apps/bot/src/modules/tags/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { tagsCommands } from './commands.js';
|
||||||
|
export { registerTagEvents } from './events.js';
|
||||||
291
apps/bot/src/modules/tags/service.ts
Normal file
291
apps/bot/src/modules/tags/service.ts
Normal 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;
|
||||||
|
}
|
||||||
64
apps/bot/src/modules/tempvoice/command-definitions.ts
Normal file
64
apps/bot/src/modules/tempvoice/command-definitions.ts
Normal 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'
|
||||||
|
);
|
||||||
201
apps/bot/src/modules/tempvoice/commands.ts
Normal file
201
apps/bot/src/modules/tempvoice/commands.ts
Normal 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];
|
||||||
28
apps/bot/src/modules/tempvoice/events.ts
Normal file
28
apps/bot/src/modules/tempvoice/events.ts
Normal 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');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
3
apps/bot/src/modules/tempvoice/index.ts
Normal file
3
apps/bot/src/modules/tempvoice/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export { tempVoiceCommands } from './commands.js';
|
||||||
|
export { registerTempVoiceEvents } from './events.js';
|
||||||
|
export { isTempVoiceInteraction, isTempVoiceModal, handleTempVoiceInteraction } from './interactions.js';
|
||||||
268
apps/bot/src/modules/tempvoice/interactions.ts
Normal file
268
apps/bot/src/modules/tempvoice/interactions.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
389
apps/bot/src/modules/tempvoice/service.ts
Normal file
389
apps/bot/src/modules/tempvoice/service.ts
Normal 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;
|
||||||
|
}
|
||||||
78
apps/bot/src/modules/tickets/command-definitions.ts
Normal file
78
apps/bot/src/modules/tickets/command-definitions.ts
Normal 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'
|
||||||
|
);
|
||||||
190
apps/bot/src/modules/tickets/commands.ts
Normal file
190
apps/bot/src/modules/tickets/commands.ts
Normal 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];
|
||||||
12
apps/bot/src/modules/tickets/events.ts
Normal file
12
apps/bot/src/modules/tickets/events.ts
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
4
apps/bot/src/modules/tickets/index.ts
Normal file
4
apps/bot/src/modules/tickets/index.ts
Normal 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';
|
||||||
182
apps/bot/src/modules/tickets/interactions.ts
Normal file
182
apps/bot/src/modules/tickets/interactions.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
886
apps/bot/src/modules/tickets/service.ts
Normal file
886
apps/bot/src/modules/tickets/service.ts
Normal 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
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,3 +11,9 @@ export const verificationQueueName = 'verification';
|
|||||||
export const verificationQueue = new Queue(verificationQueueName, { connection: redis });
|
export const verificationQueue = new Queue(verificationQueueName, { connection: redis });
|
||||||
export const reminderQueueName = 'reminders';
|
export const reminderQueueName = 'reminders';
|
||||||
export const reminderQueue = new Queue(reminderQueueName, { connection: redis });
|
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 });
|
||||||
|
|||||||
@@ -183,5 +183,38 @@ Dieses Dokument hält den aktuellen Implementierungsstand fest. Es wird bei jede
|
|||||||
|
|
||||||
## Nächster geplanter Schritt
|
## 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)
|
||||||
|
|
||||||
|
|||||||
@@ -682,6 +682,518 @@ export const commandLocales = {
|
|||||||
'fun.common.options.opponent': {
|
'fun.common.options.opponent': {
|
||||||
de: 'Gegner',
|
de: 'Gegner',
|
||||||
en: 'Opponent'
|
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 (1–12)',
|
||||||
|
en: 'Month (1–12)'
|
||||||
|
},
|
||||||
|
'birthdays.set.options.day': {
|
||||||
|
de: 'Tag (1–31)',
|
||||||
|
en: 'Day (1–31)'
|
||||||
|
},
|
||||||
|
'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;
|
} as const;
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export * from './command-locales.js';
|
|||||||
export * from './discord-localization.js';
|
export * from './discord-localization.js';
|
||||||
export * from './phase2.js';
|
export * from './phase2.js';
|
||||||
export * from './phase3.js';
|
export * from './phase3.js';
|
||||||
|
export * from './phase4.js';
|
||||||
|
|
||||||
export const LocaleSchema = z.enum(['de', 'en']);
|
export const LocaleSchema = z.enum(['de', 'en']);
|
||||||
export type Locale = z.infer<typeof LocaleSchema>;
|
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.disabled': 'Medien-Commands sind auf diesem Server deaktiviert.',
|
||||||
'fun.media.fetchFailed': 'Bild konnte nicht geladen werden. Versuche es später erneut.',
|
'fun.media.fetchFailed': 'Bild konnte nicht geladen werden. Versuche es später erneut.',
|
||||||
'fun.cat.title': 'Random Cat',
|
'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? (1–5)',
|
||||||
|
'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 (1–32 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 = {
|
const en: Dictionary = {
|
||||||
'generic.noPermission': 'You do not have permission for this action.',
|
'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.disabled': 'Media commands are disabled on this server.',
|
||||||
'fun.media.fetchFailed': 'Could not load image. Try again later.',
|
'fun.media.fetchFailed': 'Could not load image. Try again later.',
|
||||||
'fun.cat.title': 'Random Cat',
|
'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? (1–5)',
|
||||||
|
'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 (1–32 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 };
|
const locales: Record<Locale, Dictionary> = { de, en };
|
||||||
|
|||||||
84
packages/shared/src/phase4.ts
Normal file
84
packages/shared/src/phase4.ts
Normal 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('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
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>`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user