Add stats, feeds, scheduler, and guild backup features to the bot
- Introduced new models in the Prisma schema for statistics, social feeds, scheduled messages, and guild backups, enhancing the bot's functionality. - Implemented command modules for managing stats channels, feeds, scheduling messages, and creating/restoring backups, improving user engagement and server management. - Updated job handling to include dedicated workers for stats updates, feed polling, scheduling, and backup restoration, enhancing automation. - Enhanced command localization for new features in both German and English. - Documented the new features and their setup processes in PHASE-TRACKING.md for clarity on implementation steps.
This commit is contained in:
@@ -13,3 +13,5 @@ BACKUP_CRON=0 3 * * *
|
|||||||
BACKUP_DIR=/backups
|
BACKUP_DIR=/backups
|
||||||
HEALTH_PORT=8080
|
HEALTH_PORT=8080
|
||||||
PUBLIC_BASE_URL=http://localhost:8080
|
PUBLIC_BASE_URL=http://localhost:8080
|
||||||
|
TWITCH_CLIENT_ID=
|
||||||
|
TWITCH_CLIENT_SECRET=
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
CREATE TABLE "StatsConfig" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"guildId" TEXT NOT NULL,
|
||||||
|
"enabled" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"membersChannelId" TEXT,
|
||||||
|
"onlineChannelId" TEXT,
|
||||||
|
"boostsChannelId" TEXT,
|
||||||
|
"membersTemplate" TEXT NOT NULL DEFAULT 'Members: {count}',
|
||||||
|
"onlineTemplate" TEXT NOT NULL DEFAULT 'Online: {count}',
|
||||||
|
"boostsTemplate" TEXT NOT NULL DEFAULT 'Boosts: {count}',
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "StatsConfig_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "InviteStat" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"guildId" TEXT NOT NULL,
|
||||||
|
"inviterId" TEXT NOT NULL,
|
||||||
|
"code" TEXT,
|
||||||
|
"joins" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"leaves" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"fakes" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "InviteStat_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "InviteJoin" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"guildId" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"inviterId" TEXT,
|
||||||
|
"code" TEXT,
|
||||||
|
"isFake" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"leftAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "InviteJoin_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "ActivityStat" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"guildId" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"channelId" TEXT,
|
||||||
|
"messageCount" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"voiceMinutes" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"day" TIMESTAMP(3) NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "ActivityStat_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "SocialFeed" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"guildId" TEXT NOT NULL,
|
||||||
|
"type" TEXT NOT NULL,
|
||||||
|
"sourceId" TEXT NOT NULL,
|
||||||
|
"channelId" TEXT NOT NULL,
|
||||||
|
"rolePingId" TEXT,
|
||||||
|
"template" TEXT,
|
||||||
|
"lastItemId" TEXT,
|
||||||
|
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "SocialFeed_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "ScheduledMessage" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"guildId" TEXT NOT NULL,
|
||||||
|
"channelId" TEXT NOT NULL,
|
||||||
|
"creatorId" TEXT NOT NULL,
|
||||||
|
"content" TEXT,
|
||||||
|
"embed" JSONB,
|
||||||
|
"rolePingId" TEXT,
|
||||||
|
"cron" TEXT,
|
||||||
|
"runAt" TIMESTAMP(3),
|
||||||
|
"jobId" TEXT,
|
||||||
|
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "ScheduledMessage_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "GuildBackup" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"guildId" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"createdById" TEXT NOT NULL,
|
||||||
|
"payload" JSONB NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "GuildBackup_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "StatsConfig_guildId_key" ON "StatsConfig"("guildId");
|
||||||
|
CREATE UNIQUE INDEX "InviteStat_guildId_inviterId_key" ON "InviteStat"("guildId", "inviterId");
|
||||||
|
CREATE INDEX "InviteStat_guildId_joins_idx" ON "InviteStat"("guildId", "joins");
|
||||||
|
CREATE UNIQUE INDEX "InviteJoin_guildId_userId_key" ON "InviteJoin"("guildId", "userId");
|
||||||
|
CREATE INDEX "InviteJoin_guildId_inviterId_idx" ON "InviteJoin"("guildId", "inviterId");
|
||||||
|
CREATE UNIQUE INDEX "ActivityStat_guildId_userId_channelId_day_key" ON "ActivityStat"("guildId", "userId", "channelId", "day");
|
||||||
|
CREATE INDEX "ActivityStat_guildId_day_idx" ON "ActivityStat"("guildId", "day");
|
||||||
|
CREATE INDEX "SocialFeed_guildId_type_idx" ON "SocialFeed"("guildId", "type");
|
||||||
|
CREATE INDEX "SocialFeed_enabled_idx" ON "SocialFeed"("enabled");
|
||||||
|
CREATE INDEX "ScheduledMessage_guildId_enabled_idx" ON "ScheduledMessage"("guildId", "enabled");
|
||||||
|
CREATE INDEX "GuildBackup_guildId_createdAt_idx" ON "GuildBackup"("guildId", "createdAt");
|
||||||
|
|
||||||
|
ALTER TABLE "StatsConfig" ADD CONSTRAINT "StatsConfig_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "InviteStat" ADD CONSTRAINT "InviteStat_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "InviteJoin" ADD CONSTRAINT "InviteJoin_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "ActivityStat" ADD CONSTRAINT "ActivityStat_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "SocialFeed" ADD CONSTRAINT "SocialFeed_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "ScheduledMessage" ADD CONSTRAINT "ScheduledMessage_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "GuildBackup" ADD CONSTRAINT "GuildBackup_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -44,6 +44,13 @@ model Guild {
|
|||||||
birthdayConfig BirthdayConfig?
|
birthdayConfig BirthdayConfig?
|
||||||
tempVoiceConfig TempVoiceConfig?
|
tempVoiceConfig TempVoiceConfig?
|
||||||
tempVoiceChannels TempVoiceChannel[]
|
tempVoiceChannels TempVoiceChannel[]
|
||||||
|
statsConfig StatsConfig?
|
||||||
|
inviteStats InviteStat[]
|
||||||
|
inviteJoins InviteJoin[]
|
||||||
|
activityStats ActivityStat[]
|
||||||
|
socialFeeds SocialFeed[]
|
||||||
|
scheduledMessages ScheduledMessage[]
|
||||||
|
guildBackups GuildBackup[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model GuildSettings {
|
model GuildSettings {
|
||||||
@@ -639,3 +646,116 @@ model TempVoiceChannel {
|
|||||||
@@index([guildId, ownerId])
|
@@index([guildId, ownerId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model StatsConfig {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
guildId String @unique
|
||||||
|
enabled Boolean @default(false)
|
||||||
|
membersChannelId String?
|
||||||
|
onlineChannelId String?
|
||||||
|
boostsChannelId String?
|
||||||
|
membersTemplate String @default("Members: {count}")
|
||||||
|
onlineTemplate String @default("Online: {count}")
|
||||||
|
boostsTemplate String @default("Boosts: {count}")
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
|
|
||||||
|
model InviteStat {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
guildId String
|
||||||
|
inviterId String
|
||||||
|
code String?
|
||||||
|
joins Int @default(0)
|
||||||
|
leaves Int @default(0)
|
||||||
|
fakes Int @default(0)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([guildId, inviterId])
|
||||||
|
@@index([guildId, joins])
|
||||||
|
}
|
||||||
|
|
||||||
|
model InviteJoin {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
guildId String
|
||||||
|
userId String
|
||||||
|
inviterId String?
|
||||||
|
code String?
|
||||||
|
isFake Boolean @default(false)
|
||||||
|
leftAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([guildId, userId])
|
||||||
|
@@index([guildId, inviterId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model ActivityStat {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
guildId String
|
||||||
|
userId String
|
||||||
|
channelId String?
|
||||||
|
messageCount Int @default(0)
|
||||||
|
voiceMinutes Int @default(0)
|
||||||
|
day DateTime
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([guildId, userId, channelId, day])
|
||||||
|
@@index([guildId, day])
|
||||||
|
}
|
||||||
|
|
||||||
|
model SocialFeed {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
guildId String
|
||||||
|
type String
|
||||||
|
sourceId String
|
||||||
|
channelId String
|
||||||
|
rolePingId String?
|
||||||
|
template String?
|
||||||
|
lastItemId String?
|
||||||
|
enabled Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([guildId, type])
|
||||||
|
@@index([enabled])
|
||||||
|
}
|
||||||
|
|
||||||
|
model ScheduledMessage {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
guildId String
|
||||||
|
channelId String
|
||||||
|
creatorId String
|
||||||
|
content String?
|
||||||
|
embed Json?
|
||||||
|
rolePingId String?
|
||||||
|
cron String?
|
||||||
|
runAt DateTime?
|
||||||
|
jobId String?
|
||||||
|
enabled Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([guildId, enabled])
|
||||||
|
}
|
||||||
|
|
||||||
|
model GuildBackup {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
guildId String
|
||||||
|
name String
|
||||||
|
createdById String
|
||||||
|
payload Json
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([guildId, createdAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ import { starboardCommands } from './modules/starboard/index.js';
|
|||||||
import { suggestionsCommands } from './modules/suggestions/index.js';
|
import { suggestionsCommands } from './modules/suggestions/index.js';
|
||||||
import { birthdayCommands } from './modules/birthdays/index.js';
|
import { birthdayCommands } from './modules/birthdays/index.js';
|
||||||
import { tempVoiceCommands } from './modules/tempvoice/index.js';
|
import { tempVoiceCommands } from './modules/tempvoice/index.js';
|
||||||
|
import { statsCommands } from './modules/stats/index.js';
|
||||||
|
import { feedsCommands } from './modules/feeds/index.js';
|
||||||
|
import { scheduleCommands } from './modules/scheduler/index.js';
|
||||||
|
import { guildBackupCommands } from './modules/guildbackup/index.js';
|
||||||
import type { BotContext, SlashCommand } from './types.js';
|
import type { BotContext, SlashCommand } from './types.js';
|
||||||
|
|
||||||
const commands: SlashCommand[] = [
|
const commands: SlashCommand[] = [
|
||||||
@@ -42,7 +46,11 @@ const commands: SlashCommand[] = [
|
|||||||
...starboardCommands,
|
...starboardCommands,
|
||||||
...suggestionsCommands,
|
...suggestionsCommands,
|
||||||
...birthdayCommands,
|
...birthdayCommands,
|
||||||
...tempVoiceCommands
|
...tempVoiceCommands,
|
||||||
|
...statsCommands,
|
||||||
|
...feedsCommands,
|
||||||
|
...scheduleCommands,
|
||||||
|
...guildBackupCommands
|
||||||
];
|
];
|
||||||
const map = new Map(commands.map((c) => [c.data.name, c]));
|
const map = new Map(commands.map((c) => [c.data.name, c]));
|
||||||
const contextMenuMap = new Map(utilityContextMenus.map((c) => [c.data.name, c]));
|
const contextMenuMap = new Map(utilityContextMenus.map((c) => [c.data.name, c]));
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ const EnvSchema = z.object({
|
|||||||
BACKUP_DIR: z.string().default('/backups'),
|
BACKUP_DIR: z.string().default('/backups'),
|
||||||
METRICS_TOKEN: z.string().optional(),
|
METRICS_TOKEN: z.string().optional(),
|
||||||
HEALTH_PORT: z.coerce.number().int().positive().default(8080),
|
HEALTH_PORT: z.coerce.number().int().positive().default(8080),
|
||||||
PUBLIC_BASE_URL: z.string().url().default('http://localhost:8080')
|
PUBLIC_BASE_URL: z.string().url().default('http://localhost:8080'),
|
||||||
|
TWITCH_CLIENT_ID: z.string().min(1).optional(),
|
||||||
|
TWITCH_CLIENT_SECRET: z.string().min(1).optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
export const env = EnvSchema.parse(process.env);
|
export const env = EnvSchema.parse(process.env);
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ import {
|
|||||||
isTempVoiceModal,
|
isTempVoiceModal,
|
||||||
registerTempVoiceEvents
|
registerTempVoiceEvents
|
||||||
} from './modules/tempvoice/index.js';
|
} from './modules/tempvoice/index.js';
|
||||||
|
import { registerStatsEvents } from './modules/stats/index.js';
|
||||||
|
import { handleGuildBackupButton, isGuildBackupButton } from './modules/guildbackup/index.js';
|
||||||
|
|
||||||
const isShard = process.argv.includes('--shard');
|
const isShard = process.argv.includes('--shard');
|
||||||
|
|
||||||
@@ -108,6 +110,7 @@ if (!isShard) {
|
|||||||
registerTagEvents(client, context);
|
registerTagEvents(client, context);
|
||||||
registerStarboardEvents(context);
|
registerStarboardEvents(context);
|
||||||
registerTempVoiceEvents(context);
|
registerTempVoiceEvents(context);
|
||||||
|
registerStatsEvents(context);
|
||||||
|
|
||||||
client.on(Events.ClientReady, async () => {
|
client.on(Events.ClientReady, async () => {
|
||||||
logger.info({ tag: client.user?.tag }, 'Bot ready');
|
logger.info({ tag: client.user?.tag }, 'Bot ready');
|
||||||
@@ -201,6 +204,11 @@ if (!isShard) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (interaction.isButton() && isGuildBackupButton(interaction.customId)) {
|
||||||
|
await handleGuildBackupButton(interaction, context);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (interaction.isModalSubmit() && isEmbedModal(interaction.customId)) {
|
if (interaction.isModalSubmit() && isEmbedModal(interaction.customId)) {
|
||||||
await handleEmbedModal(interaction, context);
|
await handleEmbedModal(interaction, context);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ import { expireSelfRole } from './modules/selfroles/service.js';
|
|||||||
import { endGiveaway } from './modules/giveaways/index.js';
|
import { endGiveaway } from './modules/giveaways/index.js';
|
||||||
import { closeInactiveTickets } from './modules/tickets/index.js';
|
import { closeInactiveTickets } from './modules/tickets/index.js';
|
||||||
import { expireBirthdayRole, runBirthdayCheck } from './modules/birthdays/index.js';
|
import { expireBirthdayRole, runBirthdayCheck } from './modules/birthdays/index.js';
|
||||||
|
import { ensureStatsRecurringJobs, startStatsWorker } from './modules/stats/index.js';
|
||||||
|
import { pollAllFeeds } from './modules/feeds/index.js';
|
||||||
|
import { sendScheduledMessage } from './modules/scheduler/index.js';
|
||||||
|
import { runGuildBackupRestoreJob } from './modules/guildbackup/index.js';
|
||||||
import {
|
import {
|
||||||
automodQueue,
|
automodQueue,
|
||||||
automodQueueName,
|
automodQueueName,
|
||||||
@@ -22,9 +26,13 @@ import {
|
|||||||
backupQueueName,
|
backupQueueName,
|
||||||
birthdayQueue,
|
birthdayQueue,
|
||||||
birthdayQueueName,
|
birthdayQueueName,
|
||||||
|
feedsQueue,
|
||||||
|
feedsQueueName,
|
||||||
giveawayQueueName,
|
giveawayQueueName,
|
||||||
|
guildBackupQueueName,
|
||||||
moderationQueueName,
|
moderationQueueName,
|
||||||
reminderQueueName,
|
reminderQueueName,
|
||||||
|
scheduleQueueName,
|
||||||
ticketQueue,
|
ticketQueue,
|
||||||
ticketQueueName,
|
ticketQueueName,
|
||||||
verificationQueueName
|
verificationQueueName
|
||||||
@@ -213,6 +221,50 @@ export function startWorkers(context: BotContext): Worker[] {
|
|||||||
logger.error({ jobId: job?.id, error }, 'Birthday job failed');
|
logger.error({ jobId: job?.id, error }, 'Birthday job failed');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const statsWorker = startStatsWorker(context);
|
||||||
|
|
||||||
|
const feedsWorker = new Worker(
|
||||||
|
feedsQueueName,
|
||||||
|
async (job) => {
|
||||||
|
if (job.name !== 'pollFeeds') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await pollAllFeeds(context);
|
||||||
|
},
|
||||||
|
{ connection: redis }
|
||||||
|
);
|
||||||
|
feedsWorker.on('failed', (job, error) => {
|
||||||
|
logger.error({ jobId: job?.id, error }, 'Feeds job failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
const scheduleWorker = new Worker<{ scheduleId: string }>(
|
||||||
|
scheduleQueueName,
|
||||||
|
async (job) => {
|
||||||
|
if (job.name !== 'scheduleSend') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await sendScheduledMessage(context, job.data.scheduleId);
|
||||||
|
},
|
||||||
|
{ connection: redis }
|
||||||
|
);
|
||||||
|
scheduleWorker.on('failed', (job, error) => {
|
||||||
|
logger.error({ jobId: job?.id, error }, 'Schedule job failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
const guildBackupWorker = new Worker<{ backupId: string; guildId: string }>(
|
||||||
|
guildBackupQueueName,
|
||||||
|
async (job) => {
|
||||||
|
if (job.name !== 'restoreGuildBackup') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await runGuildBackupRestoreJob(context, job.data.backupId, job.data.guildId);
|
||||||
|
},
|
||||||
|
{ connection: redis }
|
||||||
|
);
|
||||||
|
guildBackupWorker.on('failed', (job, error) => {
|
||||||
|
logger.error({ jobId: job?.id, error }, 'Guild backup restore job failed');
|
||||||
|
});
|
||||||
|
|
||||||
return [
|
return [
|
||||||
moderationWorker,
|
moderationWorker,
|
||||||
backupWorker,
|
backupWorker,
|
||||||
@@ -221,7 +273,11 @@ export function startWorkers(context: BotContext): Worker[] {
|
|||||||
reminderWorker,
|
reminderWorker,
|
||||||
ticketWorker,
|
ticketWorker,
|
||||||
giveawayWorker,
|
giveawayWorker,
|
||||||
birthdayWorker
|
birthdayWorker,
|
||||||
|
statsWorker,
|
||||||
|
feedsWorker,
|
||||||
|
scheduleWorker,
|
||||||
|
guildBackupWorker
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,6 +323,18 @@ export async function ensureRecurringJobs(): Promise<void> {
|
|||||||
removeOnFail: 20
|
removeOnFail: 20
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await ensureStatsRecurringJobs();
|
||||||
|
|
||||||
|
await feedsQueue.add(
|
||||||
|
'pollFeeds',
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
repeat: { pattern: '*/5 * * * *' },
|
||||||
|
removeOnComplete: 20,
|
||||||
|
removeOnFail: 20
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runPgDumpBackup(): Promise<void> {
|
async function runPgDumpBackup(): Promise<void> {
|
||||||
|
|||||||
54
apps/bot/src/modules/feeds/command-definitions.ts
Normal file
54
apps/bot/src/modules/feeds/command-definitions.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { ChannelType } from 'discord.js';
|
||||||
|
import {
|
||||||
|
applyCommandDescription,
|
||||||
|
applyOptionDescription,
|
||||||
|
localizedChoice
|
||||||
|
} from '@nexumi/shared';
|
||||||
|
import { SlashCommandBuilder } from 'discord.js';
|
||||||
|
|
||||||
|
const feedTypeChoices = [
|
||||||
|
localizedChoice('feeds.choice.type.twitch', 'TWITCH'),
|
||||||
|
localizedChoice('feeds.choice.type.youtube', 'YOUTUBE'),
|
||||||
|
localizedChoice('feeds.choice.type.rss', 'RSS'),
|
||||||
|
localizedChoice('feeds.choice.type.reddit', 'REDDIT')
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const feedsCommandData = applyCommandDescription(
|
||||||
|
new SlashCommandBuilder()
|
||||||
|
.setName('feeds')
|
||||||
|
.addSubcommand((sub) =>
|
||||||
|
applyCommandDescription(sub.setName('add'), 'feeds.add.description')
|
||||||
|
.addStringOption((o) =>
|
||||||
|
applyOptionDescription(o.setName('type').setRequired(true), 'feeds.add.options.type').addChoices(
|
||||||
|
...feedTypeChoices
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addStringOption((o) =>
|
||||||
|
applyOptionDescription(o.setName('source').setRequired(true), 'feeds.add.options.source')
|
||||||
|
)
|
||||||
|
.addChannelOption((o) =>
|
||||||
|
applyOptionDescription(
|
||||||
|
o.setName('channel').addChannelTypes(ChannelType.GuildText).setRequired(true),
|
||||||
|
'feeds.add.options.channel'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addRoleOption((o) =>
|
||||||
|
applyOptionDescription(o.setName('role'), 'feeds.add.options.role')
|
||||||
|
)
|
||||||
|
.addStringOption((o) =>
|
||||||
|
applyOptionDescription(o.setName('template'), 'feeds.add.options.template')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addSubcommand((sub) =>
|
||||||
|
applyCommandDescription(sub.setName('remove'), 'feeds.remove.description').addStringOption((o) =>
|
||||||
|
applyOptionDescription(o.setName('id').setRequired(true), 'feeds.remove.options.id')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addSubcommand((sub) => applyCommandDescription(sub.setName('list'), 'feeds.list.description'))
|
||||||
|
.addSubcommand((sub) =>
|
||||||
|
applyCommandDescription(sub.setName('test'), 'feeds.test.description').addStringOption((o) =>
|
||||||
|
applyOptionDescription(o.setName('id').setRequired(true), 'feeds.test.options.id')
|
||||||
|
)
|
||||||
|
),
|
||||||
|
'feeds.description'
|
||||||
|
);
|
||||||
141
apps/bot/src/modules/feeds/commands.ts
Normal file
141
apps/bot/src/modules/feeds/commands.ts
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import { PermissionFlagsBits } from 'discord.js';
|
||||||
|
import { SocialFeedTypeSchema, t, tf } from '@nexumi/shared';
|
||||||
|
import type { SlashCommand } from '../../types.js';
|
||||||
|
import { getGuildLocale } from '../../i18n.js';
|
||||||
|
import { requirePermission } from '../../permissions.js';
|
||||||
|
import { feedsCommandData } from './command-definitions.js';
|
||||||
|
import {
|
||||||
|
addFeed,
|
||||||
|
FeedAddSchema,
|
||||||
|
FeedError,
|
||||||
|
getFeedById,
|
||||||
|
listFeeds,
|
||||||
|
removeFeed,
|
||||||
|
testFeed
|
||||||
|
} from './service.js';
|
||||||
|
|
||||||
|
const feedsCommand: SlashCommand = {
|
||||||
|
data: feedsCommandData,
|
||||||
|
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(true);
|
||||||
|
const guildId = interaction.guildId!;
|
||||||
|
|
||||||
|
if (!(await requirePermission(interaction, PermissionFlagsBits.ManageGuild, locale))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (sub === 'add') {
|
||||||
|
const input = FeedAddSchema.parse({
|
||||||
|
type: SocialFeedTypeSchema.parse(interaction.options.getString('type', true)),
|
||||||
|
source: interaction.options.getString('source', true),
|
||||||
|
channelId: interaction.options.getChannel('channel', true).id,
|
||||||
|
rolePingId: interaction.options.getRole('role')?.id,
|
||||||
|
template: interaction.options.getString('template') ?? undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
const feed = await addFeed(context.prisma, guildId, input);
|
||||||
|
await interaction.reply({
|
||||||
|
content: tf(locale, 'feeds.added', { id: feed.id, type: feed.type }),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === 'remove') {
|
||||||
|
const feedId = interaction.options.getString('id', true);
|
||||||
|
const removed = await removeFeed(context.prisma, guildId, feedId);
|
||||||
|
if (!removed) {
|
||||||
|
await interaction.reply({ content: t(locale, 'feeds.error.not_found'), ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await interaction.reply({
|
||||||
|
content: tf(locale, 'feeds.removed', { id: removed.id }),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === 'list') {
|
||||||
|
const feeds = await listFeeds(context.prisma, guildId);
|
||||||
|
if (feeds.length === 0) {
|
||||||
|
await interaction.reply({ content: t(locale, 'feeds.list.empty'), ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = feeds.map((feed) =>
|
||||||
|
tf(locale, 'feeds.list.line', {
|
||||||
|
id: feed.id,
|
||||||
|
type: feed.type,
|
||||||
|
source: feed.sourceId,
|
||||||
|
channel: `<#${feed.channelId}>`
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await interaction.reply({
|
||||||
|
content: `${t(locale, 'feeds.list.header')}\n${lines.join('\n')}`,
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === 'test') {
|
||||||
|
const feedId = interaction.options.getString('id', true);
|
||||||
|
const feed = await getFeedById(context.prisma, guildId, feedId);
|
||||||
|
if (!feed) {
|
||||||
|
await interaction.reply({ content: t(locale, 'feeds.error.not_found'), ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await testFeed(context, feed);
|
||||||
|
if (result.skipped) {
|
||||||
|
const skipKey =
|
||||||
|
result.message === 'twitch_credentials_missing'
|
||||||
|
? 'feeds.test.twitchSkipped'
|
||||||
|
: 'feeds.test.skipped';
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, skipKey),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.message && result.posted === 0) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: tf(locale, 'feeds.test.failed', { reason: result.message }),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.posted === 0) {
|
||||||
|
await interaction.reply({ content: t(locale, 'feeds.test.noItems'), ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await interaction.reply({
|
||||||
|
content: tf(locale, 'feeds.test.posted', { count: result.posted }),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof FeedError) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, `feeds.error.${error.code}`),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const feedsCommands: SlashCommand[] = [feedsCommand];
|
||||||
277
apps/bot/src/modules/feeds/fetchers.ts
Normal file
277
apps/bot/src/modules/feeds/fetchers.ts
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
import { env } from '../../env.js';
|
||||||
|
|
||||||
|
export type FeedItem = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FetchResult =
|
||||||
|
| { ok: true; items: FeedItem[] }
|
||||||
|
| { ok: false; skipped?: boolean; message: string };
|
||||||
|
|
||||||
|
const USER_AGENT = 'NexumiBot/1.0';
|
||||||
|
const YOUTUBE_CHANNEL_ID_RE = /^UC[\w-]{10,}$/i;
|
||||||
|
|
||||||
|
function decodeXmlEntities(value: string): string {
|
||||||
|
return value
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll(''', "'")
|
||||||
|
.replaceAll(''', "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripCdata(value: string): string {
|
||||||
|
return value.replace(/^<!\[CDATA\[([\s\S]*?)\]\]>$/i, '$1').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractTag(block: string, tagNames: string[]): string | null {
|
||||||
|
for (const tag of tagNames) {
|
||||||
|
const match = block.match(new RegExp(`<${tag}[^>]*>([\\s\\S]*?)<\\/${tag}>`, 'i'));
|
||||||
|
if (match?.[1]) {
|
||||||
|
return decodeXmlEntities(stripCdata(match[1].trim()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractLink(block: string): string | null {
|
||||||
|
const atomLink = block.match(/<link[^>]*href=["']([^"']+)["'][^>]*\/?>/i);
|
||||||
|
if (atomLink?.[1]) {
|
||||||
|
return atomLink[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
const rssLink = extractTag(block, ['link']);
|
||||||
|
return rssLink;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractGuid(block: string, link: string | null): string | null {
|
||||||
|
const guid = extractTag(block, ['guid', 'id']);
|
||||||
|
if (guid) {
|
||||||
|
return guid;
|
||||||
|
}
|
||||||
|
return link;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseRssXml(xml: string): FeedItem[] {
|
||||||
|
const items: FeedItem[] = [];
|
||||||
|
const blockRegex = /<(?:item|entry)\b[\s\S]*?<\/(?:item|entry)>/gi;
|
||||||
|
const blocks = xml.match(blockRegex) ?? [];
|
||||||
|
|
||||||
|
for (const block of blocks) {
|
||||||
|
const title = extractTag(block, ['title']);
|
||||||
|
const link = extractLink(block);
|
||||||
|
const id = extractGuid(block, link);
|
||||||
|
if (!title || !link || !id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
items.push({ id, title, url: link });
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchText(url: string): Promise<string> {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: { 'User-Agent': USER_AGENT }
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
return response.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchRssFeed(sourceUrl: string): Promise<FetchResult> {
|
||||||
|
try {
|
||||||
|
const xml = await fetchText(sourceUrl);
|
||||||
|
const items = parseRssXml(xml);
|
||||||
|
return { ok: true, items };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: error instanceof Error ? error.message : 'fetch_failed'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchYouTubeFeed(source: string): Promise<FetchResult> {
|
||||||
|
const trimmed = source.trim();
|
||||||
|
const channelIdMatch = trimmed.match(/channel_id=([\w-]+)/i);
|
||||||
|
const channelId = YOUTUBE_CHANNEL_ID_RE.test(trimmed)
|
||||||
|
? trimmed
|
||||||
|
: (channelIdMatch?.[1] ?? null);
|
||||||
|
|
||||||
|
if (channelId) {
|
||||||
|
return fetchRssFeed(`https://www.youtube.com/feeds/videos.xml?channel_id=${channelId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^https?:\/\//i.test(trimmed)) {
|
||||||
|
return fetchRssFeed(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: false, message: 'invalid_youtube_source' };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchRedditFeed(subreddit: string): Promise<FetchResult> {
|
||||||
|
const name = subreddit.replace(/^r\//i, '').trim();
|
||||||
|
if (!name) {
|
||||||
|
return { ok: false, message: 'invalid_subreddit' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = `https://www.reddit.com/r/${encodeURIComponent(name)}/new.json?limit=5`;
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: { 'User-Agent': USER_AGENT }
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
return { ok: false, message: `HTTP ${response.status}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as {
|
||||||
|
data?: { children?: Array<{ data?: { id?: string; title?: string; permalink?: string } }> };
|
||||||
|
};
|
||||||
|
|
||||||
|
const items: FeedItem[] = [];
|
||||||
|
for (const child of data.data?.children ?? []) {
|
||||||
|
const post = child.data;
|
||||||
|
if (!post?.id || !post.title || !post.permalink) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
items.push({
|
||||||
|
id: post.id,
|
||||||
|
title: post.title,
|
||||||
|
url: post.permalink.startsWith('http')
|
||||||
|
? post.permalink
|
||||||
|
: `https://www.reddit.com${post.permalink}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, items };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: error instanceof Error ? error.message : 'fetch_failed'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let twitchAppToken: { token: string; expiresAt: number } | null = null;
|
||||||
|
|
||||||
|
async function getTwitchAppToken(): Promise<string | null> {
|
||||||
|
const clientId = env.TWITCH_CLIENT_ID;
|
||||||
|
const clientSecret = env.TWITCH_CLIENT_SECRET;
|
||||||
|
if (!clientId || !clientSecret) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (twitchAppToken && twitchAppToken.expiresAt > Date.now() + 60_000) {
|
||||||
|
return twitchAppToken.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
client_id: clientId,
|
||||||
|
client_secret: clientSecret,
|
||||||
|
grant_type: 'client_credentials'
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(`https://id.twitch.tv/oauth2/token?${params.toString()}`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`twitch_token_http_${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = (await response.json()) as { access_token?: string; expires_in?: number };
|
||||||
|
if (!body.access_token) {
|
||||||
|
throw new Error('twitch_token_missing');
|
||||||
|
}
|
||||||
|
|
||||||
|
twitchAppToken = {
|
||||||
|
token: body.access_token,
|
||||||
|
expiresAt: Date.now() + (body.expires_in ?? 3600) * 1000
|
||||||
|
};
|
||||||
|
return twitchAppToken.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchTwitchFeed(login: string): Promise<FetchResult> {
|
||||||
|
const userLogin = login.trim().toLowerCase();
|
||||||
|
if (!userLogin) {
|
||||||
|
return { ok: false, message: 'invalid_twitch_login' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientId = env.TWITCH_CLIENT_ID;
|
||||||
|
const clientSecret = env.TWITCH_CLIENT_SECRET;
|
||||||
|
if (!clientId || !clientSecret) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
skipped: true,
|
||||||
|
message: 'twitch_credentials_missing'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = await getTwitchAppToken();
|
||||||
|
if (!token) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
skipped: true,
|
||||||
|
message: 'twitch_credentials_missing'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `https://api.twitch.tv/helix/streams?user_login=${encodeURIComponent(userLogin)}`;
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
'Client-ID': clientId,
|
||||||
|
Authorization: `Bearer ${token}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return { ok: false, message: `HTTP ${response.status}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = (await response.json()) as {
|
||||||
|
data?: Array<{ id?: string; title?: string; user_login?: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const stream = body.data?.[0];
|
||||||
|
if (!stream?.id || !stream.title) {
|
||||||
|
return { ok: true, items: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const streamLogin = stream.user_login ?? userLogin;
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: stream.id,
|
||||||
|
title: stream.title,
|
||||||
|
url: `https://twitch.tv/${streamLogin}`
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: error instanceof Error ? error.message : 'fetch_failed'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchFeedItems(type: string, sourceId: string): Promise<FetchResult> {
|
||||||
|
switch (type) {
|
||||||
|
case 'RSS':
|
||||||
|
return fetchRssFeed(sourceId);
|
||||||
|
case 'YOUTUBE':
|
||||||
|
return fetchYouTubeFeed(sourceId);
|
||||||
|
case 'REDDIT':
|
||||||
|
return fetchRedditFeed(sourceId);
|
||||||
|
case 'TWITCH':
|
||||||
|
return fetchTwitchFeed(sourceId);
|
||||||
|
default:
|
||||||
|
return { ok: false, message: 'unsupported_type' };
|
||||||
|
}
|
||||||
|
}
|
||||||
2
apps/bot/src/modules/feeds/index.ts
Normal file
2
apps/bot/src/modules/feeds/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { feedsCommands } from './commands.js';
|
||||||
|
export { pollAllFeeds } from './service.js';
|
||||||
228
apps/bot/src/modules/feeds/service.ts
Normal file
228
apps/bot/src/modules/feeds/service.ts
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
import { PermissionFlagsBits, type TextChannel } from 'discord.js';
|
||||||
|
import type { SocialFeed } from '@prisma/client';
|
||||||
|
import { applyFeedTemplate, SocialFeedTypeSchema } from '@nexumi/shared';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { ensureGuild } from '../../guild.js';
|
||||||
|
import { logger } from '../../logger.js';
|
||||||
|
import type { BotContext } from '../../types.js';
|
||||||
|
import { fetchFeedItems, type FeedItem, type FetchResult } from './fetchers.js';
|
||||||
|
|
||||||
|
export const FeedAddSchema = z.object({
|
||||||
|
type: SocialFeedTypeSchema,
|
||||||
|
source: z.string().min(1).max(512),
|
||||||
|
channelId: z.string().min(1),
|
||||||
|
rolePingId: z.string().min(1).optional(),
|
||||||
|
template: z.string().min(1).max(2000).optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
export type FeedAddInput = z.infer<typeof FeedAddSchema>;
|
||||||
|
|
||||||
|
export class FeedError extends Error {
|
||||||
|
constructor(public readonly code: string) {
|
||||||
|
super(code);
|
||||||
|
this.name = 'FeedError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_TEMPLATE = '{title}\n{url}';
|
||||||
|
|
||||||
|
export function normalizeFeedSource(type: z.infer<typeof SocialFeedTypeSchema>, source: string): string {
|
||||||
|
const trimmed = source.trim();
|
||||||
|
switch (type) {
|
||||||
|
case 'TWITCH':
|
||||||
|
return trimmed.replace(/^@/, '').toLowerCase();
|
||||||
|
case 'REDDIT':
|
||||||
|
return trimmed.replace(/^r\//i, '').toLowerCase();
|
||||||
|
case 'YOUTUBE':
|
||||||
|
case 'RSS':
|
||||||
|
return trimmed;
|
||||||
|
default:
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addFeed(
|
||||||
|
prisma: BotContext['prisma'],
|
||||||
|
guildId: string,
|
||||||
|
input: FeedAddInput
|
||||||
|
): Promise<SocialFeed> {
|
||||||
|
await ensureGuild(prisma, guildId);
|
||||||
|
const sourceId = normalizeFeedSource(input.type, input.source);
|
||||||
|
|
||||||
|
return prisma.socialFeed.create({
|
||||||
|
data: {
|
||||||
|
guildId,
|
||||||
|
type: input.type,
|
||||||
|
sourceId,
|
||||||
|
channelId: input.channelId,
|
||||||
|
rolePingId: input.rolePingId ?? null,
|
||||||
|
template: input.template ?? null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeFeed(
|
||||||
|
prisma: BotContext['prisma'],
|
||||||
|
guildId: string,
|
||||||
|
feedId: string
|
||||||
|
): Promise<SocialFeed | null> {
|
||||||
|
const existing = await prisma.socialFeed.findFirst({
|
||||||
|
where: { id: feedId, guildId }
|
||||||
|
});
|
||||||
|
if (!existing) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
await prisma.socialFeed.delete({ where: { id: feedId } });
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listFeeds(prisma: BotContext['prisma'], guildId: string): Promise<SocialFeed[]> {
|
||||||
|
await ensureGuild(prisma, guildId);
|
||||||
|
return prisma.socialFeed.findMany({
|
||||||
|
where: { guildId },
|
||||||
|
orderBy: { createdAt: 'asc' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getFeedById(
|
||||||
|
prisma: BotContext['prisma'],
|
||||||
|
guildId: string,
|
||||||
|
feedId: string
|
||||||
|
): Promise<SocialFeed | null> {
|
||||||
|
return prisma.socialFeed.findFirst({
|
||||||
|
where: { id: feedId, guildId }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFeedMessage(feed: SocialFeed, item: FeedItem): string {
|
||||||
|
const body = applyFeedTemplate(feed.template?.trim() || DEFAULT_TEMPLATE, {
|
||||||
|
title: item.title,
|
||||||
|
url: item.url
|
||||||
|
});
|
||||||
|
if (feed.rolePingId) {
|
||||||
|
return `<@&${feed.rolePingId}>\n${body}`;
|
||||||
|
}
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveTextChannel(
|
||||||
|
context: BotContext,
|
||||||
|
guildId: string,
|
||||||
|
channelId: string
|
||||||
|
): Promise<TextChannel | null> {
|
||||||
|
const guild = await context.client.guilds.fetch(guildId).catch(() => null);
|
||||||
|
if (!guild) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const channel = await guild.channels.fetch(channelId).catch(() => null);
|
||||||
|
if (!channel?.isTextBased() || channel.isDMBased()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const me = guild.members.me;
|
||||||
|
if (!me?.permissionsIn(channel).has(PermissionFlagsBits.SendMessages)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return channel as TextChannel;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postFeedItem(
|
||||||
|
context: BotContext,
|
||||||
|
feed: SocialFeed,
|
||||||
|
item: FeedItem
|
||||||
|
): Promise<boolean> {
|
||||||
|
const channel = await resolveTextChannel(context, feed.guildId, feed.channelId);
|
||||||
|
if (!channel) {
|
||||||
|
logger.warn({ feedId: feed.id, channelId: feed.channelId }, 'Feed channel invalid');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await channel.send({ content: buildFeedMessage(feed, item) });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectNewItems(items: FeedItem[], lastItemId: string | null): FeedItem[] {
|
||||||
|
if (items.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!lastItemId) {
|
||||||
|
return [items[0]!];
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastIndex = items.findIndex((item) => item.id === lastItemId);
|
||||||
|
if (lastIndex === -1) {
|
||||||
|
return [items[0]!];
|
||||||
|
}
|
||||||
|
|
||||||
|
return items.slice(0, lastIndex).reverse();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function processFeedFetch(
|
||||||
|
context: BotContext,
|
||||||
|
feed: SocialFeed,
|
||||||
|
result: FetchResult,
|
||||||
|
options: { forceLatest?: boolean } = {}
|
||||||
|
): Promise<{ posted: number; skipped?: boolean; message?: string }> {
|
||||||
|
if (!result.ok) {
|
||||||
|
return {
|
||||||
|
posted: 0,
|
||||||
|
skipped: result.skipped,
|
||||||
|
message: result.message
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = result.items;
|
||||||
|
if (items.length === 0) {
|
||||||
|
return { posted: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates = options.forceLatest ? [items[0]!] : selectNewItems(items, feed.lastItemId);
|
||||||
|
let posted = 0;
|
||||||
|
|
||||||
|
for (const item of candidates) {
|
||||||
|
const sent = await postFeedItem(context, feed, item);
|
||||||
|
if (sent) {
|
||||||
|
posted += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const newestId = items[0]!.id;
|
||||||
|
if (posted > 0 || feed.lastItemId !== newestId) {
|
||||||
|
await context.prisma.socialFeed.update({
|
||||||
|
where: { id: feed.id },
|
||||||
|
data: { lastItemId: newestId }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { posted };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pollFeed(context: BotContext, feed: SocialFeed): Promise<void> {
|
||||||
|
const result = await fetchFeedItems(feed.type, feed.sourceId);
|
||||||
|
await processFeedFetch(context, feed, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function testFeed(
|
||||||
|
context: BotContext,
|
||||||
|
feed: SocialFeed
|
||||||
|
): Promise<{ posted: number; skipped?: boolean; message?: string }> {
|
||||||
|
const result = await fetchFeedItems(feed.type, feed.sourceId);
|
||||||
|
return processFeedFetch(context, feed, result, { forceLatest: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pollAllFeeds(context: BotContext): Promise<void> {
|
||||||
|
const feeds = await context.prisma.socialFeed.findMany({
|
||||||
|
where: { enabled: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const feed of feeds) {
|
||||||
|
try {
|
||||||
|
await pollFeed(context, feed);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error, feedId: feed.id, guildId: feed.guildId }, 'Feed poll failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
32
apps/bot/src/modules/guildbackup/command-definitions.ts
Normal file
32
apps/bot/src/modules/guildbackup/command-definitions.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { applyCommandDescription, applyOptionDescription } from '@nexumi/shared';
|
||||||
|
import { SlashCommandBuilder } from 'discord.js';
|
||||||
|
|
||||||
|
export const backupCommandData = applyCommandDescription(
|
||||||
|
new SlashCommandBuilder()
|
||||||
|
.setName('backup')
|
||||||
|
.addSubcommand((s) =>
|
||||||
|
applyCommandDescription(s.setName('create'), 'guildbackup.create.description').addStringOption(
|
||||||
|
(o) => applyOptionDescription(o.setName('name'), 'guildbackup.create.options.name')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addSubcommand((s) => applyCommandDescription(s.setName('list'), 'guildbackup.list.description'))
|
||||||
|
.addSubcommand((s) =>
|
||||||
|
applyCommandDescription(s.setName('info'), 'guildbackup.info.description').addStringOption(
|
||||||
|
(o) =>
|
||||||
|
applyOptionDescription(o.setName('id').setRequired(true), 'guildbackup.common.options.id')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addSubcommand((s) =>
|
||||||
|
applyCommandDescription(s.setName('delete'), 'guildbackup.delete.description').addStringOption(
|
||||||
|
(o) =>
|
||||||
|
applyOptionDescription(o.setName('id').setRequired(true), 'guildbackup.common.options.id')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addSubcommand((s) =>
|
||||||
|
applyCommandDescription(s.setName('restore'), 'guildbackup.restore.description').addStringOption(
|
||||||
|
(o) =>
|
||||||
|
applyOptionDescription(o.setName('id').setRequired(true), 'guildbackup.common.options.id')
|
||||||
|
)
|
||||||
|
),
|
||||||
|
'guildbackup.description'
|
||||||
|
);
|
||||||
203
apps/bot/src/modules/guildbackup/commands.ts
Normal file
203
apps/bot/src/modules/guildbackup/commands.ts
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
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 { backupCommandData } from './command-definitions.js';
|
||||||
|
import {
|
||||||
|
createGuildBackup,
|
||||||
|
ensureBotCanRestore,
|
||||||
|
getGuildBackup,
|
||||||
|
GuildBackupError,
|
||||||
|
listGuildBackups,
|
||||||
|
parseBackupPayload
|
||||||
|
} from './service.js';
|
||||||
|
import { promptDeleteConfirmation, promptRestoreConfirmation } from './confirmations.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 defaultBackupName(): string {
|
||||||
|
return `backup-${new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const backupCommand: SlashCommand = {
|
||||||
|
data: backupCommandData,
|
||||||
|
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();
|
||||||
|
const guild = interaction.guild!;
|
||||||
|
|
||||||
|
if (sub === 'create') {
|
||||||
|
if (!(await ensureManageGuild(interaction, locale))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = interaction.options.getString('name')?.trim() || defaultBackupName();
|
||||||
|
if (name.length < 1 || name.length > 100) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'guildbackup.error.invalid_name'),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const backup = await createGuildBackup(context, guild, name, interaction.user.id);
|
||||||
|
await interaction.reply({
|
||||||
|
content: tf(locale, 'guildbackup.created', { id: backup.id, name: backup.name }),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof GuildBackupError) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, `guildbackup.error.${error.code}`),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === 'list') {
|
||||||
|
const backups = await listGuildBackups(context, guild.id);
|
||||||
|
if (backups.length === 0) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'guildbackup.list.empty'),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = backups.map((backup) =>
|
||||||
|
tf(locale, 'guildbackup.list.line', {
|
||||||
|
id: backup.id,
|
||||||
|
name: backup.name,
|
||||||
|
date: backup.createdAt.toISOString().slice(0, 10)
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await interaction.reply({
|
||||||
|
content: `${t(locale, 'guildbackup.list.header')}\n${lines.join('\n')}`,
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === 'info') {
|
||||||
|
const backupId = interaction.options.getString('id', true);
|
||||||
|
const backup = await getGuildBackup(context, guild.id, backupId);
|
||||||
|
if (!backup) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'guildbackup.error.not_found'),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let roleCount = 0;
|
||||||
|
let channelCount = 0;
|
||||||
|
try {
|
||||||
|
const payload = parseBackupPayload(backup.payload);
|
||||||
|
roleCount = payload.roles.length;
|
||||||
|
channelCount = payload.channels.length;
|
||||||
|
} catch {
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'guildbackup.error.invalid_payload'),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await interaction.reply({
|
||||||
|
content: [
|
||||||
|
tf(locale, 'guildbackup.info.id', { id: backup.id }),
|
||||||
|
tf(locale, 'guildbackup.info.name', { name: backup.name }),
|
||||||
|
tf(locale, 'guildbackup.info.created', { date: backup.createdAt.toISOString() }),
|
||||||
|
tf(locale, 'guildbackup.info.createdBy', { userId: backup.createdById }),
|
||||||
|
tf(locale, 'guildbackup.info.roles', { count: roleCount }),
|
||||||
|
tf(locale, 'guildbackup.info.channels', { count: channelCount }),
|
||||||
|
t(locale, 'guildbackup.info.limitations')
|
||||||
|
].join('\n'),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === 'delete') {
|
||||||
|
if (!(await ensureManageGuild(interaction, locale))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const backupId = interaction.options.getString('id', true);
|
||||||
|
const backup = await getGuildBackup(context, guild.id, backupId);
|
||||||
|
if (!backup) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'guildbackup.error.not_found'),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await promptDeleteConfirmation(interaction, locale, backupId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === 'restore') {
|
||||||
|
if (guild.ownerId !== interaction.user.id) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'guildbackup.error.owner_only'),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ensureBotCanRestore(guild)) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'guildbackup.error.bot_missing_permissions'),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const backupId = interaction.options.getString('id', true);
|
||||||
|
const backup = await getGuildBackup(context, guild.id, backupId);
|
||||||
|
if (!backup) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'guildbackup.error.not_found'),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
parseBackupPayload(backup.payload);
|
||||||
|
} catch {
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'guildbackup.error.invalid_payload'),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await promptRestoreConfirmation(interaction, locale, backupId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const guildBackupCommands = [backupCommand];
|
||||||
285
apps/bot/src/modules/guildbackup/confirmations.ts
Normal file
285
apps/bot/src/modules/guildbackup/confirmations.ts
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
import {
|
||||||
|
ActionRowBuilder,
|
||||||
|
ButtonBuilder,
|
||||||
|
ButtonStyle,
|
||||||
|
type ButtonInteraction,
|
||||||
|
type ChatInputCommandInteraction
|
||||||
|
} from 'discord.js';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { type Locale, t, tf } from '@nexumi/shared';
|
||||||
|
import type { BotContext } from '../../types.js';
|
||||||
|
import { getGuildLocale } from '../../i18n.js';
|
||||||
|
import {
|
||||||
|
deleteGuildBackup,
|
||||||
|
enqueueGuildBackupRestore,
|
||||||
|
getGuildBackup,
|
||||||
|
GuildBackupError,
|
||||||
|
parseBackupPayload,
|
||||||
|
restoreGuildBackup,
|
||||||
|
shouldQueueRestore
|
||||||
|
} from './service.js';
|
||||||
|
|
||||||
|
const DELETE_CONFIRM_PREFIX = 'gbackup:confirm:delete:';
|
||||||
|
const RESTORE_CONFIRM_PREFIX = 'gbackup:confirm:restore:';
|
||||||
|
const CANCEL_PREFIX = 'gbackup:cancel:';
|
||||||
|
const TTL_MS = 120_000;
|
||||||
|
|
||||||
|
type PendingDelete = {
|
||||||
|
action: 'delete';
|
||||||
|
backupId: string;
|
||||||
|
userId: string;
|
||||||
|
guildId: string;
|
||||||
|
locale: Locale;
|
||||||
|
expiresAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PendingRestore = {
|
||||||
|
action: 'restore';
|
||||||
|
step: 1 | 2;
|
||||||
|
backupId: string;
|
||||||
|
userId: string;
|
||||||
|
guildId: string;
|
||||||
|
locale: Locale;
|
||||||
|
expiresAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PendingEntry = PendingDelete | PendingRestore;
|
||||||
|
|
||||||
|
const pending = new Map<string, PendingEntry>();
|
||||||
|
|
||||||
|
function pruneExpired(): void {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [token, entry] of pending) {
|
||||||
|
if (entry.expiresAt <= now) {
|
||||||
|
pending.delete(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRows(token: string, locale: Locale, confirmPrefix: string): ActionRowBuilder<ButtonBuilder>[] {
|
||||||
|
return [
|
||||||
|
new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||||
|
new ButtonBuilder()
|
||||||
|
.setCustomId(`${confirmPrefix}${token}`)
|
||||||
|
.setLabel(t(locale, 'guildbackup.confirm.button.confirm'))
|
||||||
|
.setStyle(ButtonStyle.Danger),
|
||||||
|
new ButtonBuilder()
|
||||||
|
.setCustomId(`${CANCEL_PREFIX}${token}`)
|
||||||
|
.setLabel(t(locale, 'guildbackup.confirm.button.cancel'))
|
||||||
|
.setStyle(ButtonStyle.Secondary)
|
||||||
|
)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function promptDeleteConfirmation(
|
||||||
|
interaction: ChatInputCommandInteraction,
|
||||||
|
locale: Locale,
|
||||||
|
backupId: string
|
||||||
|
): Promise<void> {
|
||||||
|
pruneExpired();
|
||||||
|
const token = randomUUID();
|
||||||
|
pending.set(token, {
|
||||||
|
action: 'delete',
|
||||||
|
backupId,
|
||||||
|
userId: interaction.user.id,
|
||||||
|
guildId: interaction.guildId!,
|
||||||
|
locale,
|
||||||
|
expiresAt: Date.now() + TTL_MS
|
||||||
|
});
|
||||||
|
|
||||||
|
await interaction.reply({
|
||||||
|
content: tf(locale, 'guildbackup.confirm.delete', { id: backupId }),
|
||||||
|
components: buildRows(token, locale, DELETE_CONFIRM_PREFIX),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function promptRestoreConfirmation(
|
||||||
|
interaction: ChatInputCommandInteraction,
|
||||||
|
locale: Locale,
|
||||||
|
backupId: string
|
||||||
|
): Promise<void> {
|
||||||
|
pruneExpired();
|
||||||
|
const token = randomUUID();
|
||||||
|
pending.set(token, {
|
||||||
|
action: 'restore',
|
||||||
|
step: 1,
|
||||||
|
backupId,
|
||||||
|
userId: interaction.user.id,
|
||||||
|
guildId: interaction.guildId!,
|
||||||
|
locale,
|
||||||
|
expiresAt: Date.now() + TTL_MS
|
||||||
|
});
|
||||||
|
|
||||||
|
await interaction.reply({
|
||||||
|
content: tf(locale, 'guildbackup.confirm.restore.step1', { id: backupId }),
|
||||||
|
components: buildRows(token, locale, RESTORE_CONFIRM_PREFIX),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleGuildBackupButton(
|
||||||
|
interaction: ButtonInteraction,
|
||||||
|
context: BotContext
|
||||||
|
): Promise<void> {
|
||||||
|
pruneExpired();
|
||||||
|
|
||||||
|
const customId = interaction.customId;
|
||||||
|
const isDeleteConfirm = customId.startsWith(DELETE_CONFIRM_PREFIX);
|
||||||
|
const isRestoreConfirm = customId.startsWith(RESTORE_CONFIRM_PREFIX);
|
||||||
|
const isCancel = customId.startsWith(CANCEL_PREFIX);
|
||||||
|
|
||||||
|
if (!isDeleteConfirm && !isRestoreConfirm && !isCancel) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = customId.slice(
|
||||||
|
isDeleteConfirm
|
||||||
|
? DELETE_CONFIRM_PREFIX.length
|
||||||
|
: isRestoreConfirm
|
||||||
|
? RESTORE_CONFIRM_PREFIX.length
|
||||||
|
: CANCEL_PREFIX.length
|
||||||
|
);
|
||||||
|
|
||||||
|
const entry = pending.get(token);
|
||||||
|
if (!entry) {
|
||||||
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
|
await interaction.reply({ content: t(locale, 'guildbackup.confirm.expired'), ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (interaction.user.id !== entry.userId) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(entry.locale, 'guildbackup.confirm.unauthorized'),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCancel) {
|
||||||
|
pending.delete(token);
|
||||||
|
await interaction.update({
|
||||||
|
content: t(entry.locale, 'guildbackup.confirm.cancelled'),
|
||||||
|
components: []
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.action === 'delete') {
|
||||||
|
pending.delete(token);
|
||||||
|
await executeDelete(interaction, context, entry);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.step === 1) {
|
||||||
|
entry.step = 2;
|
||||||
|
entry.expiresAt = Date.now() + TTL_MS;
|
||||||
|
pending.set(token, entry);
|
||||||
|
await interaction.update({
|
||||||
|
content: tf(entry.locale, 'guildbackup.confirm.restore.step2', { id: entry.backupId }),
|
||||||
|
components: buildRows(token, entry.locale, RESTORE_CONFIRM_PREFIX)
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pending.delete(token);
|
||||||
|
await executeRestore(interaction, context, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeDelete(
|
||||||
|
interaction: ButtonInteraction,
|
||||||
|
context: BotContext,
|
||||||
|
entry: PendingDelete
|
||||||
|
): Promise<void> {
|
||||||
|
const deleted = await deleteGuildBackup(context, entry.guildId, entry.backupId);
|
||||||
|
if (!deleted) {
|
||||||
|
await interaction.update({
|
||||||
|
content: t(entry.locale, 'guildbackup.error.not_found'),
|
||||||
|
components: []
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await interaction.update({
|
||||||
|
content: tf(entry.locale, 'guildbackup.deleted', { id: entry.backupId }),
|
||||||
|
components: []
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeRestore(
|
||||||
|
interaction: ButtonInteraction,
|
||||||
|
context: BotContext,
|
||||||
|
entry: PendingRestore
|
||||||
|
): Promise<void> {
|
||||||
|
const guild = interaction.guild;
|
||||||
|
if (!guild) {
|
||||||
|
await interaction.update({
|
||||||
|
content: t(entry.locale, 'generic.guildOnly'),
|
||||||
|
components: []
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const backup = await getGuildBackup(context, entry.guildId, entry.backupId);
|
||||||
|
if (!backup) {
|
||||||
|
await interaction.update({
|
||||||
|
content: t(entry.locale, 'guildbackup.error.not_found'),
|
||||||
|
components: []
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload;
|
||||||
|
try {
|
||||||
|
payload = parseBackupPayload(backup.payload);
|
||||||
|
} catch {
|
||||||
|
await interaction.update({
|
||||||
|
content: t(entry.locale, 'guildbackup.error.invalid_payload'),
|
||||||
|
components: []
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldQueueRestore(payload)) {
|
||||||
|
await enqueueGuildBackupRestore(entry.backupId, entry.guildId, entry.userId);
|
||||||
|
await interaction.update({
|
||||||
|
content: tf(entry.locale, 'guildbackup.restore.queued', { id: entry.backupId }),
|
||||||
|
components: []
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await interaction.deferUpdate();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const summary = await restoreGuildBackup(guild, payload);
|
||||||
|
await interaction.editReply({
|
||||||
|
content: tf(entry.locale, 'guildbackup.restore.done', {
|
||||||
|
id: entry.backupId,
|
||||||
|
rolesCreated: summary.rolesCreated,
|
||||||
|
rolesSkipped: summary.rolesSkipped,
|
||||||
|
channelsCreated: summary.channelsCreated,
|
||||||
|
channelsUpdated: summary.channelsUpdated,
|
||||||
|
overwritesApplied: summary.overwritesApplied
|
||||||
|
}),
|
||||||
|
components: []
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof GuildBackupError) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: t(entry.locale, `guildbackup.error.${error.code}`),
|
||||||
|
components: []
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isGuildBackupButton(customId: string): boolean {
|
||||||
|
return (
|
||||||
|
customId.startsWith(DELETE_CONFIRM_PREFIX) ||
|
||||||
|
customId.startsWith(RESTORE_CONFIRM_PREFIX) ||
|
||||||
|
customId.startsWith(CANCEL_PREFIX)
|
||||||
|
);
|
||||||
|
}
|
||||||
11
apps/bot/src/modules/guildbackup/index.ts
Normal file
11
apps/bot/src/modules/guildbackup/index.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export { guildBackupCommands } from './commands.js';
|
||||||
|
export { isGuildBackupButton, handleGuildBackupButton } from './confirmations.js';
|
||||||
|
export {
|
||||||
|
createGuildBackup,
|
||||||
|
deleteGuildBackup,
|
||||||
|
getGuildBackup,
|
||||||
|
listGuildBackups,
|
||||||
|
restoreGuildBackup,
|
||||||
|
runGuildBackupRestoreJob,
|
||||||
|
shouldQueueRestore
|
||||||
|
} from './service.js';
|
||||||
632
apps/bot/src/modules/guildbackup/service.ts
Normal file
632
apps/bot/src/modules/guildbackup/service.ts
Normal file
@@ -0,0 +1,632 @@
|
|||||||
|
import {
|
||||||
|
ChannelType,
|
||||||
|
OverwriteType,
|
||||||
|
PermissionFlagsBits,
|
||||||
|
type Guild,
|
||||||
|
type GuildChannel,
|
||||||
|
type NonThreadGuildBasedChannel,
|
||||||
|
type OverwriteResolvable
|
||||||
|
} from 'discord.js';
|
||||||
|
import { GuildBackupPayloadSchema, type GuildBackupPayload } from '@nexumi/shared';
|
||||||
|
import type { GuildBackup } from '@prisma/client';
|
||||||
|
import { ensureGuild } from '../../guild.js';
|
||||||
|
import { logger } from '../../logger.js';
|
||||||
|
import { guildBackupQueue } from '../../queues.js';
|
||||||
|
import type { BotContext } from '../../types.js';
|
||||||
|
|
||||||
|
export class GuildBackupError extends Error {
|
||||||
|
constructor(public readonly code: string) {
|
||||||
|
super(code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const LARGE_RESTORE_THRESHOLD = 50;
|
||||||
|
const RESTORE_REASON = 'Nexumi guild backup restore';
|
||||||
|
|
||||||
|
export type RestoreSummary = {
|
||||||
|
rolesCreated: number;
|
||||||
|
rolesSkipped: number;
|
||||||
|
channelsCreated: number;
|
||||||
|
channelsUpdated: number;
|
||||||
|
overwritesApplied: number;
|
||||||
|
settingsApplied: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function createGuildBackup(
|
||||||
|
context: BotContext,
|
||||||
|
guild: Guild,
|
||||||
|
name: string,
|
||||||
|
createdById: string
|
||||||
|
): Promise<GuildBackup> {
|
||||||
|
await guild.roles.fetch();
|
||||||
|
await guild.channels.fetch();
|
||||||
|
|
||||||
|
const payload = buildPayloadFromGuild(guild);
|
||||||
|
GuildBackupPayloadSchema.parse(payload);
|
||||||
|
|
||||||
|
await ensureGuild(context.prisma, guild.id);
|
||||||
|
|
||||||
|
return context.prisma.guildBackup.create({
|
||||||
|
data: {
|
||||||
|
guildId: guild.id,
|
||||||
|
name,
|
||||||
|
createdById,
|
||||||
|
payload
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildPayloadFromGuild(guild: Guild): GuildBackupPayload {
|
||||||
|
const roles = [...guild.roles.cache.values()]
|
||||||
|
.sort((a, b) => a.position - b.position)
|
||||||
|
.map((role) => ({
|
||||||
|
id: role.id,
|
||||||
|
name: role.name,
|
||||||
|
color: role.color,
|
||||||
|
hoist: role.hoist,
|
||||||
|
mentionable: role.mentionable,
|
||||||
|
permissions: role.permissions.bitfield.toString(),
|
||||||
|
position: role.position
|
||||||
|
}));
|
||||||
|
|
||||||
|
const channels = [...guild.channels.cache.values()]
|
||||||
|
.filter(
|
||||||
|
(channel): channel is NonThreadGuildBasedChannel =>
|
||||||
|
!channel.isThread() && 'permissionOverwrites' in channel
|
||||||
|
)
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (a.parentId !== b.parentId) {
|
||||||
|
return (a.parentId ?? '').localeCompare(b.parentId ?? '');
|
||||||
|
}
|
||||||
|
return a.rawPosition - b.rawPosition;
|
||||||
|
})
|
||||||
|
.map((channel) => {
|
||||||
|
const base = {
|
||||||
|
id: channel.id,
|
||||||
|
name: channel.name,
|
||||||
|
type: channel.type,
|
||||||
|
parentId: channel.parentId,
|
||||||
|
position: channel.rawPosition,
|
||||||
|
permissionOverwrites: channel.permissionOverwrites.cache.map((overwrite) => ({
|
||||||
|
id: overwrite.id,
|
||||||
|
type: overwrite.type,
|
||||||
|
allow: overwrite.allow.bitfield.toString(),
|
||||||
|
deny: overwrite.deny.bitfield.toString()
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
|
||||||
|
if (channel.type === ChannelType.GuildText || channel.type === ChannelType.GuildAnnouncement) {
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
topic: channel.topic ?? undefined,
|
||||||
|
nsfw: channel.nsfw,
|
||||||
|
rateLimitPerUser: channel.rateLimitPerUser ?? undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (channel.type === ChannelType.GuildVoice || channel.type === ChannelType.GuildStageVoice) {
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
bitrate: channel.bitrate ?? undefined,
|
||||||
|
userLimit: channel.userLimit ?? undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (channel.type === ChannelType.GuildForum) {
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
topic: channel.topic ?? undefined,
|
||||||
|
nsfw: channel.nsfw,
|
||||||
|
rateLimitPerUser: channel.rateLimitPerUser ?? undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return base;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
guildName: guild.name,
|
||||||
|
roles,
|
||||||
|
channels,
|
||||||
|
settings: {
|
||||||
|
verificationLevel: guild.verificationLevel,
|
||||||
|
explicitContentFilter: guild.explicitContentFilter,
|
||||||
|
defaultMessageNotifications: guild.defaultMessageNotifications,
|
||||||
|
afkTimeout: guild.afkTimeout
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listGuildBackups(
|
||||||
|
context: BotContext,
|
||||||
|
guildId: string,
|
||||||
|
limit = 15
|
||||||
|
): Promise<GuildBackup[]> {
|
||||||
|
return context.prisma.guildBackup.findMany({
|
||||||
|
where: { guildId },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: limit
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getGuildBackup(
|
||||||
|
context: BotContext,
|
||||||
|
guildId: string,
|
||||||
|
backupId: string
|
||||||
|
): Promise<GuildBackup | null> {
|
||||||
|
return context.prisma.guildBackup.findFirst({
|
||||||
|
where: { id: backupId, guildId }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteGuildBackup(
|
||||||
|
context: BotContext,
|
||||||
|
guildId: string,
|
||||||
|
backupId: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
const result = await context.prisma.guildBackup.deleteMany({
|
||||||
|
where: { id: backupId, guildId }
|
||||||
|
});
|
||||||
|
return result.count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseBackupPayload(raw: unknown): GuildBackupPayload {
|
||||||
|
const parsed = GuildBackupPayloadSchema.safeParse(raw);
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw new GuildBackupError('invalid_payload');
|
||||||
|
}
|
||||||
|
return parsed.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldQueueRestore(payload: GuildBackupPayload): boolean {
|
||||||
|
return payload.roles.length + payload.channels.length >= LARGE_RESTORE_THRESHOLD;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function enqueueGuildBackupRestore(
|
||||||
|
backupId: string,
|
||||||
|
guildId: string,
|
||||||
|
requestedById: string
|
||||||
|
): Promise<void> {
|
||||||
|
await guildBackupQueue.add(
|
||||||
|
'restoreGuildBackup',
|
||||||
|
{ backupId, guildId, requestedById },
|
||||||
|
{
|
||||||
|
jobId: `guild-backup-restore-${backupId}`,
|
||||||
|
removeOnComplete: true,
|
||||||
|
removeOnFail: 25
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runGuildBackupRestoreJob(
|
||||||
|
context: BotContext,
|
||||||
|
backupId: string,
|
||||||
|
guildId: string
|
||||||
|
): Promise<RestoreSummary> {
|
||||||
|
const backup = await getGuildBackup(context, guildId, backupId);
|
||||||
|
if (!backup) {
|
||||||
|
throw new GuildBackupError('not_found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const guild = await context.client.guilds.fetch(guildId);
|
||||||
|
const payload = parseBackupPayload(backup.payload);
|
||||||
|
return restoreGuildBackup(guild, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function restoreGuildBackup(
|
||||||
|
guild: Guild,
|
||||||
|
payload: GuildBackupPayload
|
||||||
|
): Promise<RestoreSummary> {
|
||||||
|
await guild.roles.fetch();
|
||||||
|
await guild.channels.fetch();
|
||||||
|
|
||||||
|
const summary: RestoreSummary = {
|
||||||
|
rolesCreated: 0,
|
||||||
|
rolesSkipped: 0,
|
||||||
|
channelsCreated: 0,
|
||||||
|
channelsUpdated: 0,
|
||||||
|
overwritesApplied: 0,
|
||||||
|
settingsApplied: []
|
||||||
|
};
|
||||||
|
|
||||||
|
const roleIdMap = buildRoleIdMap(guild, payload);
|
||||||
|
await createMissingRoles(guild, payload, roleIdMap, summary);
|
||||||
|
await applyRolePositions(guild, payload, roleIdMap);
|
||||||
|
|
||||||
|
const channelIdMap = new Map<string, string>();
|
||||||
|
await restoreCategories(guild, payload, roleIdMap, channelIdMap, summary);
|
||||||
|
await restoreOtherChannels(guild, payload, roleIdMap, channelIdMap, summary);
|
||||||
|
await applyGuildSettings(guild, payload, summary);
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
{
|
||||||
|
guildId: guild.id,
|
||||||
|
backupGuildName: payload.guildName,
|
||||||
|
...summary
|
||||||
|
},
|
||||||
|
'Guild backup restore completed'
|
||||||
|
);
|
||||||
|
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRoleIdMap(guild: Guild, payload: GuildBackupPayload): Map<string, string> {
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
map.set(guild.id, guild.id);
|
||||||
|
|
||||||
|
for (const roleData of payload.roles) {
|
||||||
|
if (roleData.id === guild.id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const existing = guild.roles.cache.find(
|
||||||
|
(role) => role.name.toLowerCase() === roleData.name.toLowerCase()
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
map.set(roleData.id, existing.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createMissingRoles(
|
||||||
|
guild: Guild,
|
||||||
|
payload: GuildBackupPayload,
|
||||||
|
roleIdMap: Map<string, string>,
|
||||||
|
summary: RestoreSummary
|
||||||
|
): Promise<void> {
|
||||||
|
const sortedRoles = [...payload.roles].sort((a, b) => a.position - b.position);
|
||||||
|
|
||||||
|
for (const roleData of sortedRoles) {
|
||||||
|
if (roleData.id === guild.id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (roleIdMap.has(roleData.id)) {
|
||||||
|
summary.rolesSkipped += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingByName = guild.roles.cache.find(
|
||||||
|
(role) => role.name.toLowerCase() === roleData.name.toLowerCase()
|
||||||
|
);
|
||||||
|
if (existingByName) {
|
||||||
|
roleIdMap.set(roleData.id, existingByName.id);
|
||||||
|
summary.rolesSkipped += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const created = await guild.roles.create({
|
||||||
|
name: roleData.name,
|
||||||
|
color: roleData.color,
|
||||||
|
hoist: roleData.hoist,
|
||||||
|
mentionable: roleData.mentionable,
|
||||||
|
permissions: BigInt(roleData.permissions),
|
||||||
|
reason: RESTORE_REASON
|
||||||
|
});
|
||||||
|
roleIdMap.set(roleData.id, created.id);
|
||||||
|
summary.rolesCreated += 1;
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn({ error, roleName: roleData.name, guildId: guild.id }, 'Failed to create role');
|
||||||
|
summary.rolesSkipped += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyRolePositions(
|
||||||
|
guild: Guild,
|
||||||
|
payload: GuildBackupPayload,
|
||||||
|
roleIdMap: Map<string, string>
|
||||||
|
): Promise<void> {
|
||||||
|
const positions = payload.roles
|
||||||
|
.map((roleData) => {
|
||||||
|
const mappedId = roleIdMap.get(roleData.id);
|
||||||
|
if (!mappedId || mappedId === guild.id) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { role: mappedId, position: roleData.position };
|
||||||
|
})
|
||||||
|
.filter((entry): entry is { role: string; position: number } => entry !== null);
|
||||||
|
|
||||||
|
if (positions.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await guild.roles.setPositions(positions);
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn({ error, guildId: guild.id }, 'Failed to apply role positions during restore');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveParentId(
|
||||||
|
payload: GuildBackupPayload,
|
||||||
|
channelData: GuildBackupPayload['channels'][number],
|
||||||
|
channelIdMap: Map<string, string>,
|
||||||
|
guild: Guild
|
||||||
|
): string | undefined {
|
||||||
|
if (!channelData.parentId) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mappedParentId = channelIdMap.get(channelData.parentId);
|
||||||
|
if (mappedParentId) {
|
||||||
|
return mappedParentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentData = payload.channels.find((channel) => channel.id === channelData.parentId);
|
||||||
|
if (!parentData) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingParent = findChannelByName(guild, parentData.name, undefined);
|
||||||
|
return existingParent?.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findChannelByName(
|
||||||
|
guild: Guild,
|
||||||
|
name: string,
|
||||||
|
parentId: string | undefined
|
||||||
|
): GuildChannel | undefined {
|
||||||
|
return guild.channels.cache.find((channel) => {
|
||||||
|
if (!('name' in channel) || channel.name.toLowerCase() !== name.toLowerCase()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (parentId === undefined) {
|
||||||
|
return channel.parentId === null;
|
||||||
|
}
|
||||||
|
return channel.parentId === parentId;
|
||||||
|
}) as GuildChannel | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapOverwrites(
|
||||||
|
payloadOverwrites: GuildBackupPayload['channels'][number]['permissionOverwrites'],
|
||||||
|
roleIdMap: Map<string, string>
|
||||||
|
): OverwriteResolvable[] {
|
||||||
|
const mapped: OverwriteResolvable[] = [];
|
||||||
|
|
||||||
|
for (const overwrite of payloadOverwrites) {
|
||||||
|
let targetId = overwrite.id;
|
||||||
|
if (overwrite.type === OverwriteType.Role) {
|
||||||
|
const mapped = roleIdMap.get(overwrite.id);
|
||||||
|
if (!mapped) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
targetId = mapped;
|
||||||
|
}
|
||||||
|
|
||||||
|
mapped.push({
|
||||||
|
id: targetId,
|
||||||
|
type: overwrite.type,
|
||||||
|
allow: BigInt(overwrite.allow),
|
||||||
|
deny: BigInt(overwrite.deny)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapped;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restoreCategories(
|
||||||
|
guild: Guild,
|
||||||
|
payload: GuildBackupPayload,
|
||||||
|
roleIdMap: Map<string, string>,
|
||||||
|
channelIdMap: Map<string, string>,
|
||||||
|
summary: RestoreSummary
|
||||||
|
): Promise<void> {
|
||||||
|
const categories = payload.channels
|
||||||
|
.filter((channel) => channel.type === ChannelType.GuildCategory)
|
||||||
|
.sort((a, b) => a.position - b.position);
|
||||||
|
|
||||||
|
for (const categoryData of categories) {
|
||||||
|
const existing = findChannelByName(guild, categoryData.name, undefined);
|
||||||
|
if (existing) {
|
||||||
|
channelIdMap.set(categoryData.id, existing.id);
|
||||||
|
await applyChannelOverwrites(existing, categoryData, roleIdMap, summary);
|
||||||
|
summary.channelsUpdated += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const created = await guild.channels.create({
|
||||||
|
name: categoryData.name,
|
||||||
|
type: ChannelType.GuildCategory,
|
||||||
|
permissionOverwrites: mapOverwrites(categoryData.permissionOverwrites, roleIdMap),
|
||||||
|
reason: RESTORE_REASON
|
||||||
|
});
|
||||||
|
channelIdMap.set(categoryData.id, created.id);
|
||||||
|
summary.channelsCreated += 1;
|
||||||
|
summary.overwritesApplied += categoryData.permissionOverwrites.length;
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(
|
||||||
|
{ error, channelName: categoryData.name, guildId: guild.id },
|
||||||
|
'Failed to create category'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restoreOtherChannels(
|
||||||
|
guild: Guild,
|
||||||
|
payload: GuildBackupPayload,
|
||||||
|
roleIdMap: Map<string, string>,
|
||||||
|
channelIdMap: Map<string, string>,
|
||||||
|
summary: RestoreSummary
|
||||||
|
): Promise<void> {
|
||||||
|
const channels = payload.channels
|
||||||
|
.filter((channel) => channel.type !== ChannelType.GuildCategory)
|
||||||
|
.sort((a, b) => a.position - b.position);
|
||||||
|
|
||||||
|
for (const channelData of channels) {
|
||||||
|
const parentId = resolveParentId(payload, channelData, channelIdMap, guild);
|
||||||
|
const existing = findChannelByName(guild, channelData.name, parentId);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
channelIdMap.set(channelData.id, existing.id);
|
||||||
|
await applyChannelOverwrites(existing, channelData, roleIdMap, summary);
|
||||||
|
summary.channelsUpdated += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const created = await createChannelFromBackup(guild, channelData, parentId, roleIdMap);
|
||||||
|
if (created) {
|
||||||
|
channelIdMap.set(channelData.id, created.id);
|
||||||
|
summary.channelsCreated += 1;
|
||||||
|
summary.overwritesApplied += channelData.permissionOverwrites.length;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(
|
||||||
|
{ error, channelName: channelData.name, guildId: guild.id },
|
||||||
|
'Failed to create channel'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createChannelFromBackup(
|
||||||
|
guild: Guild,
|
||||||
|
channelData: GuildBackupPayload['channels'][number],
|
||||||
|
parentId: string | undefined,
|
||||||
|
roleIdMap: Map<string, string>
|
||||||
|
): Promise<NonThreadGuildBasedChannel | null> {
|
||||||
|
const overwrites = mapOverwrites(channelData.permissionOverwrites, roleIdMap);
|
||||||
|
const base = {
|
||||||
|
name: channelData.name,
|
||||||
|
parent: parentId,
|
||||||
|
permissionOverwrites: overwrites,
|
||||||
|
reason: RESTORE_REASON
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (channelData.type) {
|
||||||
|
case ChannelType.GuildText:
|
||||||
|
return guild.channels.create({
|
||||||
|
...base,
|
||||||
|
type: ChannelType.GuildText,
|
||||||
|
topic: channelData.topic ?? undefined,
|
||||||
|
nsfw: channelData.nsfw ?? false,
|
||||||
|
rateLimitPerUser: channelData.rateLimitPerUser ?? 0
|
||||||
|
});
|
||||||
|
case ChannelType.GuildAnnouncement:
|
||||||
|
return guild.channels.create({
|
||||||
|
...base,
|
||||||
|
type: ChannelType.GuildAnnouncement,
|
||||||
|
topic: channelData.topic ?? undefined,
|
||||||
|
nsfw: channelData.nsfw ?? false,
|
||||||
|
rateLimitPerUser: channelData.rateLimitPerUser ?? 0
|
||||||
|
});
|
||||||
|
case ChannelType.GuildVoice:
|
||||||
|
return guild.channels.create({
|
||||||
|
...base,
|
||||||
|
type: ChannelType.GuildVoice,
|
||||||
|
bitrate: channelData.bitrate ?? 64_000,
|
||||||
|
userLimit: channelData.userLimit ?? 0
|
||||||
|
});
|
||||||
|
case ChannelType.GuildStageVoice:
|
||||||
|
return guild.channels.create({
|
||||||
|
...base,
|
||||||
|
type: ChannelType.GuildStageVoice,
|
||||||
|
bitrate: channelData.bitrate ?? 64_000,
|
||||||
|
userLimit: channelData.userLimit ?? 0
|
||||||
|
});
|
||||||
|
case ChannelType.GuildForum:
|
||||||
|
return guild.channels.create({
|
||||||
|
...base,
|
||||||
|
type: ChannelType.GuildForum,
|
||||||
|
topic: channelData.topic ?? undefined,
|
||||||
|
nsfw: channelData.nsfw ?? false,
|
||||||
|
rateLimitPerUser: channelData.rateLimitPerUser ?? 0
|
||||||
|
});
|
||||||
|
default:
|
||||||
|
logger.warn(
|
||||||
|
{ channelType: channelData.type, channelName: channelData.name },
|
||||||
|
'Unsupported channel type in backup restore'
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyChannelOverwrites(
|
||||||
|
channel: GuildChannel,
|
||||||
|
channelData: GuildBackupPayload['channels'][number],
|
||||||
|
roleIdMap: Map<string, string>,
|
||||||
|
summary: RestoreSummary
|
||||||
|
): Promise<void> {
|
||||||
|
if (!('permissionOverwrites' in channel)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const overwrites = mapOverwrites(channelData.permissionOverwrites, roleIdMap);
|
||||||
|
if (overwrites.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await channel.permissionOverwrites.set(overwrites, RESTORE_REASON);
|
||||||
|
summary.overwritesApplied += overwrites.length;
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(
|
||||||
|
{ error, channelId: channel.id, guildId: channel.guild.id },
|
||||||
|
'Failed to apply channel overwrites'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyGuildSettings(
|
||||||
|
guild: Guild,
|
||||||
|
payload: GuildBackupPayload,
|
||||||
|
summary: RestoreSummary
|
||||||
|
): Promise<void> {
|
||||||
|
const settings = payload.settings;
|
||||||
|
if (!settings) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings.verificationLevel !== undefined) {
|
||||||
|
try {
|
||||||
|
await guild.setVerificationLevel(settings.verificationLevel, RESTORE_REASON);
|
||||||
|
summary.settingsApplied.push('verificationLevel');
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn({ error, guildId: guild.id }, 'Failed to restore verification level');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings.explicitContentFilter !== undefined) {
|
||||||
|
try {
|
||||||
|
await guild.setExplicitContentFilter(settings.explicitContentFilter, RESTORE_REASON);
|
||||||
|
summary.settingsApplied.push('explicitContentFilter');
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn({ error, guildId: guild.id }, 'Failed to restore explicit content filter');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings.defaultMessageNotifications !== undefined) {
|
||||||
|
try {
|
||||||
|
await guild.setDefaultMessageNotifications(
|
||||||
|
settings.defaultMessageNotifications,
|
||||||
|
RESTORE_REASON
|
||||||
|
);
|
||||||
|
summary.settingsApplied.push('defaultMessageNotifications');
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn({ error, guildId: guild.id }, 'Failed to restore default notifications');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings.afkTimeout !== undefined) {
|
||||||
|
try {
|
||||||
|
await guild.setAFKTimeout(settings.afkTimeout, RESTORE_REASON);
|
||||||
|
summary.settingsApplied.push('afkTimeout');
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn({ error, guildId: guild.id }, 'Failed to restore AFK timeout');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensureBotCanRestore(guild: Guild): boolean {
|
||||||
|
const me = guild.members.me;
|
||||||
|
if (!me) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return me.permissions.has(PermissionFlagsBits.ManageRoles | PermissionFlagsBits.ManageChannels);
|
||||||
|
}
|
||||||
76
apps/bot/src/modules/scheduler/command-definitions.ts
Normal file
76
apps/bot/src/modules/scheduler/command-definitions.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { applyCommandDescription, applyOptionDescription } from '@nexumi/shared';
|
||||||
|
import { SlashCommandBuilder } from 'discord.js';
|
||||||
|
|
||||||
|
export const scheduleCommandData = applyCommandDescription(
|
||||||
|
new SlashCommandBuilder()
|
||||||
|
.setName('schedule')
|
||||||
|
.addSubcommand((sub) =>
|
||||||
|
applyCommandDescription(sub.setName('create'), 'schedule.create.description')
|
||||||
|
.addChannelOption((o) =>
|
||||||
|
applyOptionDescription(o.setName('channel').setRequired(true), 'schedule.common.options.channel')
|
||||||
|
)
|
||||||
|
.addStringOption((o) =>
|
||||||
|
applyOptionDescription(o.setName('content').setRequired(true), 'schedule.common.options.content')
|
||||||
|
)
|
||||||
|
.addStringOption((o) =>
|
||||||
|
applyOptionDescription(o.setName('embed_title'), 'schedule.common.options.embed_title')
|
||||||
|
)
|
||||||
|
.addStringOption((o) =>
|
||||||
|
applyOptionDescription(
|
||||||
|
o.setName('embed_description'),
|
||||||
|
'schedule.common.options.embed_description'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addIntegerOption((o) =>
|
||||||
|
applyOptionDescription(
|
||||||
|
o.setName('embed_color').setMinValue(0).setMaxValue(0xffffff),
|
||||||
|
'schedule.common.options.embed_color'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addRoleOption((o) =>
|
||||||
|
applyOptionDescription(o.setName('role'), 'schedule.common.options.role')
|
||||||
|
)
|
||||||
|
.addStringOption((o) =>
|
||||||
|
applyOptionDescription(o.setName('when'), 'schedule.create.options.when')
|
||||||
|
)
|
||||||
|
.addStringOption((o) =>
|
||||||
|
applyOptionDescription(o.setName('cron'), 'schedule.create.options.cron')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addSubcommand((sub) =>
|
||||||
|
applyCommandDescription(sub.setName('list'), 'schedule.list.description')
|
||||||
|
)
|
||||||
|
.addSubcommand((sub) =>
|
||||||
|
applyCommandDescription(sub.setName('delete'), 'schedule.delete.description').addStringOption(
|
||||||
|
(o) => applyOptionDescription(o.setName('id').setRequired(true), 'schedule.common.options.id')
|
||||||
|
)
|
||||||
|
),
|
||||||
|
'schedule.description'
|
||||||
|
);
|
||||||
|
|
||||||
|
export const announceCommandData = applyCommandDescription(
|
||||||
|
new SlashCommandBuilder()
|
||||||
|
.setName('announce')
|
||||||
|
.addChannelOption((o) =>
|
||||||
|
applyOptionDescription(o.setName('channel').setRequired(true), 'schedule.common.options.channel')
|
||||||
|
)
|
||||||
|
.addStringOption((o) =>
|
||||||
|
applyOptionDescription(o.setName('content').setRequired(true), 'schedule.common.options.content')
|
||||||
|
)
|
||||||
|
.addStringOption((o) =>
|
||||||
|
applyOptionDescription(o.setName('embed_title'), 'schedule.common.options.embed_title')
|
||||||
|
)
|
||||||
|
.addStringOption((o) =>
|
||||||
|
applyOptionDescription(o.setName('embed_description'), 'schedule.common.options.embed_description')
|
||||||
|
)
|
||||||
|
.addIntegerOption((o) =>
|
||||||
|
applyOptionDescription(
|
||||||
|
o.setName('embed_color').setMinValue(0).setMaxValue(0xffffff),
|
||||||
|
'schedule.common.options.embed_color'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addRoleOption((o) =>
|
||||||
|
applyOptionDescription(o.setName('role'), 'schedule.common.options.role')
|
||||||
|
),
|
||||||
|
'announce.description'
|
||||||
|
);
|
||||||
152
apps/bot/src/modules/scheduler/commands.ts
Normal file
152
apps/bot/src/modules/scheduler/commands.ts
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import { ChannelType, 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 { announceCommandData, scheduleCommandData } from './command-definitions.js';
|
||||||
|
import {
|
||||||
|
createScheduledMessage,
|
||||||
|
deleteScheduledMessage,
|
||||||
|
listScheduledMessages,
|
||||||
|
SchedulerError,
|
||||||
|
sendAnnouncement
|
||||||
|
} 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 readAnnouncementOptions(
|
||||||
|
interaction: Parameters<SlashCommand['execute']>[0]
|
||||||
|
): {
|
||||||
|
channelId: string;
|
||||||
|
content: string;
|
||||||
|
embedTitle: string | null;
|
||||||
|
embedDescription: string | null;
|
||||||
|
embedColor: number | null;
|
||||||
|
rolePingId: string | null;
|
||||||
|
} {
|
||||||
|
const channel = interaction.options.getChannel('channel', true, [ChannelType.GuildText]);
|
||||||
|
return {
|
||||||
|
channelId: channel.id,
|
||||||
|
content: interaction.options.getString('content', true),
|
||||||
|
embedTitle: interaction.options.getString('embed_title'),
|
||||||
|
embedDescription: interaction.options.getString('embed_description'),
|
||||||
|
embedColor: interaction.options.getInteger('embed_color'),
|
||||||
|
rolePingId: interaction.options.getRole('role')?.id ?? null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const scheduleCommand: SlashCommand = {
|
||||||
|
data: scheduleCommandData,
|
||||||
|
async execute(interaction, context) {
|
||||||
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
|
if (!(await ensureManageGuild(interaction, locale))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sub = interaction.options.getSubcommand();
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (sub === 'create') {
|
||||||
|
const options = readAnnouncementOptions(interaction);
|
||||||
|
const schedule = await createScheduledMessage(context, {
|
||||||
|
guildId: interaction.guildId!,
|
||||||
|
creatorId: interaction.user.id,
|
||||||
|
...options,
|
||||||
|
whenInput: interaction.options.getString('when'),
|
||||||
|
cron: interaction.options.getString('cron')
|
||||||
|
});
|
||||||
|
|
||||||
|
const timing = schedule.cron
|
||||||
|
? tf(locale, 'scheduler.created.cron', { cron: schedule.cron })
|
||||||
|
: schedule.runAt
|
||||||
|
? tf(locale, 'scheduler.created.at', {
|
||||||
|
time: `<t:${Math.floor(schedule.runAt.getTime() / 1000)}:F>`
|
||||||
|
})
|
||||||
|
: '';
|
||||||
|
|
||||||
|
await interaction.reply({
|
||||||
|
content: tf(locale, 'scheduler.created', { id: schedule.id, timing }),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === 'list') {
|
||||||
|
const body = await listScheduledMessages(context, interaction.guildId!, locale);
|
||||||
|
await interaction.reply({
|
||||||
|
content: `${t(locale, 'scheduler.list.header')}\n${body}`,
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === 'delete') {
|
||||||
|
const id = interaction.options.getString('id', true);
|
||||||
|
const deleted = await deleteScheduledMessage(context, interaction.guildId!, id);
|
||||||
|
if (!deleted) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'scheduler.error.not_found'),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'scheduler.deleted'),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof SchedulerError) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, `scheduler.error.${error.code}`),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const announceCommand: SlashCommand = {
|
||||||
|
data: announceCommandData,
|
||||||
|
async execute(interaction, context) {
|
||||||
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
|
if (!(await ensureManageGuild(interaction, locale))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const options = readAnnouncementOptions(interaction);
|
||||||
|
await sendAnnouncement(context, {
|
||||||
|
guildId: interaction.guildId!,
|
||||||
|
creatorId: interaction.user.id,
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, 'scheduler.announce.sent'),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof SchedulerError) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: t(locale, `scheduler.error.${error.code}`),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const scheduleCommands = [scheduleCommand, announceCommand];
|
||||||
2
apps/bot/src/modules/scheduler/index.ts
Normal file
2
apps/bot/src/modules/scheduler/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { scheduleCommands } from './commands.js';
|
||||||
|
export { sendScheduledMessage } from './service.js';
|
||||||
344
apps/bot/src/modules/scheduler/service.ts
Normal file
344
apps/bot/src/modules/scheduler/service.ts
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
import {
|
||||||
|
EmbedBuilder,
|
||||||
|
PermissionFlagsBits,
|
||||||
|
type GuildTextBasedChannel,
|
||||||
|
type MessageCreateOptions,
|
||||||
|
type TextChannel
|
||||||
|
} from 'discord.js';
|
||||||
|
import { WelcomeEmbedSchema, t, tf } from '@nexumi/shared';
|
||||||
|
import type { ScheduledMessage } from '@prisma/client';
|
||||||
|
import { ensureGuild } from '../../guild.js';
|
||||||
|
import { logger } from '../../logger.js';
|
||||||
|
import { scheduleQueue } from '../../queues.js';
|
||||||
|
import type { BotContext } from '../../types.js';
|
||||||
|
import { parseDuration } from '../moderation/duration.js';
|
||||||
|
|
||||||
|
export class SchedulerError extends Error {
|
||||||
|
constructor(public readonly code: string) {
|
||||||
|
super(code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AnnouncementInput = {
|
||||||
|
guildId: string;
|
||||||
|
channelId: string;
|
||||||
|
creatorId: string;
|
||||||
|
content: string;
|
||||||
|
embedTitle?: string | null;
|
||||||
|
embedDescription?: string | null;
|
||||||
|
embedColor?: number | null;
|
||||||
|
rolePingId?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ScheduleCreateInput = AnnouncementInput & {
|
||||||
|
whenInput?: string | null;
|
||||||
|
cron?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseScheduleWhen(input: string): Date {
|
||||||
|
const trimmed = input.trim();
|
||||||
|
if (/^\d+[smhd]$/i.test(trimmed)) {
|
||||||
|
const ms = parseDuration(trimmed);
|
||||||
|
if (ms <= 0) {
|
||||||
|
throw new SchedulerError('invalid_when');
|
||||||
|
}
|
||||||
|
return new Date(Date.now() + ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = Date.parse(trimmed);
|
||||||
|
if (Number.isNaN(parsed) || parsed <= Date.now()) {
|
||||||
|
throw new SchedulerError('invalid_when');
|
||||||
|
}
|
||||||
|
return new Date(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidCron(pattern: string): boolean {
|
||||||
|
const parts = pattern.trim().split(/\s+/);
|
||||||
|
return parts.length >= 5 && parts.length <= 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEmbedJson(input: {
|
||||||
|
embedTitle?: string | null;
|
||||||
|
embedDescription?: string | null;
|
||||||
|
embedColor?: number | null;
|
||||||
|
}): { title?: string; description?: string; color?: number } | null {
|
||||||
|
const title = input.embedTitle?.trim();
|
||||||
|
const description = input.embedDescription?.trim();
|
||||||
|
const color = input.embedColor ?? undefined;
|
||||||
|
|
||||||
|
if (!title && !description && color === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const embed: { title?: string; description?: string; color?: number } = {};
|
||||||
|
if (title) {
|
||||||
|
embed.title = title;
|
||||||
|
}
|
||||||
|
if (description) {
|
||||||
|
embed.description = description;
|
||||||
|
}
|
||||||
|
if (color !== undefined) {
|
||||||
|
embed.color = color;
|
||||||
|
}
|
||||||
|
return embed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseStoredEmbed(raw: unknown) {
|
||||||
|
const parsed = WelcomeEmbedSchema.safeParse(raw);
|
||||||
|
return parsed.success ? parsed.data : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAnnouncementPayload(schedule: {
|
||||||
|
content: string | null;
|
||||||
|
embed: unknown;
|
||||||
|
rolePingId: string | null;
|
||||||
|
}): MessageCreateOptions {
|
||||||
|
const embedData = parseStoredEmbed(schedule.embed);
|
||||||
|
const embeds: EmbedBuilder[] = [];
|
||||||
|
|
||||||
|
if (embedData) {
|
||||||
|
const embed = new EmbedBuilder().setColor(embedData.color ?? 0x6366f1);
|
||||||
|
if (embedData.title) {
|
||||||
|
embed.setTitle(embedData.title);
|
||||||
|
}
|
||||||
|
if (embedData.description) {
|
||||||
|
embed.setDescription(embedData.description);
|
||||||
|
}
|
||||||
|
embeds.push(embed);
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseContent = schedule.content?.trim() ?? '';
|
||||||
|
const roleMention = schedule.rolePingId ? `<@&${schedule.rolePingId}>` : '';
|
||||||
|
const content = [roleMention, baseContent].filter(Boolean).join(' ').trim();
|
||||||
|
|
||||||
|
const payload: MessageCreateOptions = {};
|
||||||
|
if (content) {
|
||||||
|
payload.content = content;
|
||||||
|
}
|
||||||
|
if (embeds.length > 0) {
|
||||||
|
payload.embeds = embeds;
|
||||||
|
}
|
||||||
|
if (schedule.rolePingId) {
|
||||||
|
payload.allowedMentions = { roles: [schedule.rolePingId] };
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertSendableChannel(
|
||||||
|
context: BotContext,
|
||||||
|
channelId: string
|
||||||
|
): Promise<GuildTextBasedChannel> {
|
||||||
|
const channel = await context.client.channels.fetch(channelId);
|
||||||
|
if (!channel?.isTextBased() || channel.isDMBased()) {
|
||||||
|
throw new SchedulerError('invalid_channel');
|
||||||
|
}
|
||||||
|
|
||||||
|
const me = channel.guild.members.me;
|
||||||
|
if (
|
||||||
|
!me?.permissionsIn(channel).has(PermissionFlagsBits.SendMessages) ||
|
||||||
|
!me.permissionsIn(channel).has(PermissionFlagsBits.ViewChannel)
|
||||||
|
) {
|
||||||
|
throw new SchedulerError('channel_unavailable');
|
||||||
|
}
|
||||||
|
|
||||||
|
return channel;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enqueueScheduleJob(
|
||||||
|
schedule: ScheduledMessage,
|
||||||
|
cron: string | null,
|
||||||
|
runAt: Date | null
|
||||||
|
): Promise<string> {
|
||||||
|
const jobOptions: {
|
||||||
|
jobId: string;
|
||||||
|
removeOnComplete: boolean;
|
||||||
|
removeOnFail: number;
|
||||||
|
delay?: number;
|
||||||
|
repeat?: { pattern: string };
|
||||||
|
} = {
|
||||||
|
jobId: `schedule-${schedule.id}`,
|
||||||
|
removeOnComplete: true,
|
||||||
|
removeOnFail: 50
|
||||||
|
};
|
||||||
|
|
||||||
|
if (cron) {
|
||||||
|
jobOptions.repeat = { pattern: cron };
|
||||||
|
} else if (runAt) {
|
||||||
|
jobOptions.delay = Math.max(0, runAt.getTime() - Date.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
const job = await scheduleQueue.add('scheduleSend', { scheduleId: schedule.id }, jobOptions);
|
||||||
|
return String(job.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeScheduleJob(jobId: string | null): Promise<void> {
|
||||||
|
if (!jobId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const job = await scheduleQueue.getJob(jobId);
|
||||||
|
if (job) {
|
||||||
|
await job.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateAnnouncementContent(input: AnnouncementInput): void {
|
||||||
|
const hasContent = input.content.trim().length > 0;
|
||||||
|
const hasEmbed =
|
||||||
|
Boolean(input.embedTitle?.trim()) ||
|
||||||
|
Boolean(input.embedDescription?.trim()) ||
|
||||||
|
input.embedColor !== null && input.embedColor !== undefined;
|
||||||
|
|
||||||
|
if (!hasContent && !hasEmbed) {
|
||||||
|
throw new SchedulerError('content_required');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createScheduledMessage(
|
||||||
|
context: BotContext,
|
||||||
|
input: ScheduleCreateInput
|
||||||
|
): Promise<ScheduledMessage> {
|
||||||
|
await ensureGuild(context.prisma, input.guildId);
|
||||||
|
validateAnnouncementContent(input);
|
||||||
|
|
||||||
|
const whenInput = input.whenInput?.trim();
|
||||||
|
const cron = input.cron?.trim();
|
||||||
|
|
||||||
|
if (!whenInput && !cron) {
|
||||||
|
throw new SchedulerError('missing_schedule_time');
|
||||||
|
}
|
||||||
|
|
||||||
|
let runAt: Date | null = null;
|
||||||
|
let cronPattern: string | null = null;
|
||||||
|
|
||||||
|
if (cron) {
|
||||||
|
if (!isValidCron(cron)) {
|
||||||
|
throw new SchedulerError('invalid_cron');
|
||||||
|
}
|
||||||
|
cronPattern = cron;
|
||||||
|
} else if (whenInput) {
|
||||||
|
runAt = parseScheduleWhen(whenInput);
|
||||||
|
}
|
||||||
|
|
||||||
|
await assertSendableChannel(context, input.channelId);
|
||||||
|
|
||||||
|
const embed = buildEmbedJson(input);
|
||||||
|
|
||||||
|
const schedule = await context.prisma.scheduledMessage.create({
|
||||||
|
data: {
|
||||||
|
guildId: input.guildId,
|
||||||
|
channelId: input.channelId,
|
||||||
|
creatorId: input.creatorId,
|
||||||
|
content: input.content.trim() || null,
|
||||||
|
embed: embed ?? undefined,
|
||||||
|
rolePingId: input.rolePingId ?? null,
|
||||||
|
cron: cronPattern,
|
||||||
|
runAt
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const jobId = await enqueueScheduleJob(schedule, cronPattern, runAt);
|
||||||
|
return context.prisma.scheduledMessage.update({
|
||||||
|
where: { id: schedule.id },
|
||||||
|
data: { jobId }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listScheduledMessages(
|
||||||
|
context: BotContext,
|
||||||
|
guildId: string,
|
||||||
|
locale: 'de' | 'en'
|
||||||
|
): Promise<string> {
|
||||||
|
const schedules = await context.prisma.scheduledMessage.findMany({
|
||||||
|
where: { guildId, enabled: true },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
take: 20
|
||||||
|
});
|
||||||
|
|
||||||
|
if (schedules.length === 0) {
|
||||||
|
return t(locale, 'scheduler.list.empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
return schedules
|
||||||
|
.map((schedule) => {
|
||||||
|
const preview =
|
||||||
|
schedule.content?.slice(0, 60) ??
|
||||||
|
(schedule.embed ? t(locale, 'scheduler.list.embedPreview') : '—');
|
||||||
|
const timing = schedule.cron
|
||||||
|
? tf(locale, 'scheduler.list.cron', { cron: schedule.cron })
|
||||||
|
: schedule.runAt
|
||||||
|
? `<t:${Math.floor(schedule.runAt.getTime() / 1000)}:R>`
|
||||||
|
: '—';
|
||||||
|
|
||||||
|
return tf(locale, 'scheduler.list.line', {
|
||||||
|
id: schedule.id.slice(0, 8),
|
||||||
|
channel: `<#${schedule.channelId}>`,
|
||||||
|
timing,
|
||||||
|
preview
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteScheduledMessage(
|
||||||
|
context: BotContext,
|
||||||
|
guildId: string,
|
||||||
|
scheduleIdPrefix: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
const schedules = await context.prisma.scheduledMessage.findMany({
|
||||||
|
where: { guildId, enabled: true }
|
||||||
|
});
|
||||||
|
const match = schedules.find((entry) => entry.id.startsWith(scheduleIdPrefix));
|
||||||
|
if (!match) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await removeScheduleJob(match.jobId);
|
||||||
|
await context.prisma.scheduledMessage.delete({ where: { id: match.id } });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendAnnouncement(
|
||||||
|
context: BotContext,
|
||||||
|
input: AnnouncementInput
|
||||||
|
): Promise<void> {
|
||||||
|
validateAnnouncementContent(input);
|
||||||
|
const channel = await assertSendableChannel(context, input.channelId);
|
||||||
|
const payload = buildAnnouncementPayload({
|
||||||
|
content: input.content.trim() || null,
|
||||||
|
embed: buildEmbedJson(input),
|
||||||
|
rolePingId: input.rolePingId ?? null
|
||||||
|
});
|
||||||
|
await (channel as TextChannel).send(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendScheduledMessage(context: BotContext, scheduleId: string): Promise<void> {
|
||||||
|
const schedule = await context.prisma.scheduledMessage.findUnique({ where: { id: scheduleId } });
|
||||||
|
if (!schedule || !schedule.enabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const channel = await assertSendableChannel(context, schedule.channelId);
|
||||||
|
const payload = buildAnnouncementPayload(schedule);
|
||||||
|
if (!payload.content && !payload.embeds?.length) {
|
||||||
|
throw new SchedulerError('content_required');
|
||||||
|
}
|
||||||
|
await (channel as TextChannel).send(payload);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ scheduleId, error }, 'Failed to send scheduled message');
|
||||||
|
if (error instanceof SchedulerError && error.code === 'channel_unavailable') {
|
||||||
|
await context.prisma.scheduledMessage.update({
|
||||||
|
where: { id: scheduleId },
|
||||||
|
data: { enabled: false }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!schedule.cron) {
|
||||||
|
await removeScheduleJob(schedule.jobId);
|
||||||
|
await context.prisma.scheduledMessage.delete({ where: { id: scheduleId } });
|
||||||
|
}
|
||||||
|
}
|
||||||
80
apps/bot/src/modules/stats/activity.ts
Normal file
80
apps/bot/src/modules/stats/activity.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import type { PrismaClient } from '@prisma/client';
|
||||||
|
import { ensureGuild } from '../../guild.js';
|
||||||
|
|
||||||
|
export function utcDayStart(date = new Date()): Date {
|
||||||
|
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function voiceSessionKey(guildId: string, userId: string): string {
|
||||||
|
return `stats:voice:${guildId}:${userId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function incrementMessageActivity(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
guildId: string,
|
||||||
|
userId: string,
|
||||||
|
channelId: string
|
||||||
|
): Promise<void> {
|
||||||
|
await ensureGuild(prisma, guildId);
|
||||||
|
const day = utcDayStart();
|
||||||
|
|
||||||
|
await prisma.activityStat.upsert({
|
||||||
|
where: {
|
||||||
|
guildId_userId_channelId_day: {
|
||||||
|
guildId,
|
||||||
|
userId,
|
||||||
|
channelId,
|
||||||
|
day
|
||||||
|
}
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
messageCount: { increment: 1 }
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
guildId,
|
||||||
|
userId,
|
||||||
|
channelId,
|
||||||
|
day,
|
||||||
|
messageCount: 1,
|
||||||
|
voiceMinutes: 0
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addVoiceMinutes(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
guildId: string,
|
||||||
|
userId: string,
|
||||||
|
channelId: string,
|
||||||
|
joinedAtMs: number
|
||||||
|
): Promise<void> {
|
||||||
|
const minutes = Math.floor((Date.now() - joinedAtMs) / 60_000);
|
||||||
|
if (minutes <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await ensureGuild(prisma, guildId);
|
||||||
|
const day = utcDayStart();
|
||||||
|
|
||||||
|
await prisma.activityStat.upsert({
|
||||||
|
where: {
|
||||||
|
guildId_userId_channelId_day: {
|
||||||
|
guildId,
|
||||||
|
userId,
|
||||||
|
channelId,
|
||||||
|
day
|
||||||
|
}
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
voiceMinutes: { increment: minutes }
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
guildId,
|
||||||
|
userId,
|
||||||
|
channelId,
|
||||||
|
day,
|
||||||
|
messageCount: 0,
|
||||||
|
voiceMinutes: minutes
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
60
apps/bot/src/modules/stats/command-definitions.ts
Normal file
60
apps/bot/src/modules/stats/command-definitions.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { applyCommandDescription, applyOptionDescription } from '@nexumi/shared';
|
||||||
|
import { ChannelType, SlashCommandBuilder } from 'discord.js';
|
||||||
|
|
||||||
|
export const statsCommandData = applyCommandDescription(
|
||||||
|
new SlashCommandBuilder()
|
||||||
|
.setName('stats')
|
||||||
|
.addSubcommand((sub) =>
|
||||||
|
applyCommandDescription(sub.setName('setup'), 'stats.setup.description')
|
||||||
|
.addChannelOption((option) =>
|
||||||
|
applyOptionDescription(
|
||||||
|
option
|
||||||
|
.setName('members_channel')
|
||||||
|
.setRequired(true)
|
||||||
|
.addChannelTypes(ChannelType.GuildVoice, ChannelType.GuildText),
|
||||||
|
'stats.setup.options.members_channel'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addChannelOption((option) =>
|
||||||
|
applyOptionDescription(
|
||||||
|
option
|
||||||
|
.setName('online_channel')
|
||||||
|
.setRequired(true)
|
||||||
|
.addChannelTypes(ChannelType.GuildVoice, ChannelType.GuildText),
|
||||||
|
'stats.setup.options.online_channel'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addChannelOption((option) =>
|
||||||
|
applyOptionDescription(
|
||||||
|
option
|
||||||
|
.setName('boosts_channel')
|
||||||
|
.setRequired(true)
|
||||||
|
.addChannelTypes(ChannelType.GuildVoice, ChannelType.GuildText),
|
||||||
|
'stats.setup.options.boosts_channel'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addStringOption((option) =>
|
||||||
|
applyOptionDescription(option.setName('members_template'), 'stats.setup.options.members_template')
|
||||||
|
)
|
||||||
|
.addStringOption((option) =>
|
||||||
|
applyOptionDescription(option.setName('online_template'), 'stats.setup.options.online_template')
|
||||||
|
)
|
||||||
|
.addStringOption((option) =>
|
||||||
|
applyOptionDescription(option.setName('boosts_template'), 'stats.setup.options.boosts_template')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addSubcommand((sub) => applyCommandDescription(sub.setName('refresh'), 'stats.refresh.description')),
|
||||||
|
'stats.description'
|
||||||
|
);
|
||||||
|
|
||||||
|
export const invitesCommandData = applyCommandDescription(
|
||||||
|
new SlashCommandBuilder()
|
||||||
|
.setName('invites')
|
||||||
|
.addSubcommand((sub) =>
|
||||||
|
applyCommandDescription(sub.setName('view'), 'invites.view.description').addUserOption((option) =>
|
||||||
|
applyOptionDescription(option.setName('user'), 'invites.view.options.user')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addSubcommand((sub) => applyCommandDescription(sub.setName('leaderboard'), 'invites.leaderboard.description')),
|
||||||
|
'invites.description'
|
||||||
|
);
|
||||||
138
apps/bot/src/modules/stats/commands.ts
Normal file
138
apps/bot/src/modules/stats/commands.ts
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
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 { invitesCommandData, statsCommandData } from './command-definitions.js';
|
||||||
|
import { getInviteLeaderboard, getInviteStatsForUser } from './invites.js';
|
||||||
|
import {
|
||||||
|
getStatsConfig,
|
||||||
|
StatsSetupSchema,
|
||||||
|
updateStatsChannels,
|
||||||
|
upsertStatsConfig
|
||||||
|
} from './service.js';
|
||||||
|
|
||||||
|
const statsCommand: SlashCommand = {
|
||||||
|
data: statsCommandData,
|
||||||
|
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 membersChannel = interaction.options.getChannel('members_channel', true);
|
||||||
|
const onlineChannel = interaction.options.getChannel('online_channel', true);
|
||||||
|
const boostsChannel = interaction.options.getChannel('boosts_channel', true);
|
||||||
|
const parsed = StatsSetupSchema.safeParse({
|
||||||
|
membersChannelId: membersChannel.id,
|
||||||
|
onlineChannelId: onlineChannel.id,
|
||||||
|
boostsChannelId: boostsChannel.id,
|
||||||
|
membersTemplate: interaction.options.getString('members_template') ?? undefined,
|
||||||
|
onlineTemplate: interaction.options.getString('online_template') ?? undefined,
|
||||||
|
boostsTemplate: interaction.options.getString('boosts_template') ?? undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
await interaction.reply({ content: t(locale, 'stats.error.invalidSetup'), ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await upsertStatsConfig(context.prisma, guildId, parsed.data);
|
||||||
|
await updateStatsChannels(context);
|
||||||
|
|
||||||
|
await interaction.reply({ content: t(locale, 'stats.setup.done'), ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === 'refresh') {
|
||||||
|
if (!(await requirePermission(interaction, PermissionFlagsBits.ManageGuild, locale))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await getStatsConfig(context.prisma, guildId);
|
||||||
|
if (!config?.enabled) {
|
||||||
|
await interaction.reply({ content: t(locale, 'stats.notConfigured'), ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateStatsChannels(context);
|
||||||
|
await interaction.reply({ content: t(locale, 'stats.refresh.done'), ephemeral: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const invitesCommand: SlashCommand = {
|
||||||
|
data: invitesCommandData,
|
||||||
|
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 === 'view') {
|
||||||
|
const targetUser = interaction.options.getUser('user') ?? interaction.user;
|
||||||
|
const stats = await getInviteStatsForUser(context.prisma, guildId, targetUser.id);
|
||||||
|
|
||||||
|
if (!stats || stats.joins === 0) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: tf(locale, 'invites.view.empty', { user: `<@${targetUser.id}>` }),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const net = stats.joins - stats.leaves - stats.fakes;
|
||||||
|
await interaction.reply({
|
||||||
|
content: tf(locale, 'invites.view.result', {
|
||||||
|
user: `<@${targetUser.id}>`,
|
||||||
|
joins: stats.joins,
|
||||||
|
leaves: stats.leaves,
|
||||||
|
fakes: stats.fakes,
|
||||||
|
net
|
||||||
|
}),
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === 'leaderboard') {
|
||||||
|
const entries = await getInviteLeaderboard(context.prisma, guildId, 10);
|
||||||
|
if (entries.length === 0) {
|
||||||
|
await interaction.reply({ content: t(locale, 'invites.leaderboard.empty'), ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = entries.map((entry, index) => {
|
||||||
|
const net = entry.joins - entry.leaves - entry.fakes;
|
||||||
|
return tf(locale, 'invites.leaderboard.line', {
|
||||||
|
rank: index + 1,
|
||||||
|
user: `<@${entry.inviterId}>`,
|
||||||
|
joins: entry.joins,
|
||||||
|
leaves: entry.leaves,
|
||||||
|
fakes: entry.fakes,
|
||||||
|
net
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await interaction.reply({
|
||||||
|
content: `${t(locale, 'invites.leaderboard.header')}\n${lines.join('\n')}`,
|
||||||
|
ephemeral: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const statsCommands: SlashCommand[] = [statsCommand, invitesCommand];
|
||||||
120
apps/bot/src/modules/stats/events.ts
Normal file
120
apps/bot/src/modules/stats/events.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import { Events } from 'discord.js';
|
||||||
|
import { logger } from '../../logger.js';
|
||||||
|
import type { BotContext } from '../../types.js';
|
||||||
|
import { addVoiceMinutes, incrementMessageActivity, voiceSessionKey } from './activity.js';
|
||||||
|
import {
|
||||||
|
isFakeJoin,
|
||||||
|
recordMemberJoin,
|
||||||
|
recordMemberLeave,
|
||||||
|
removeInviteFromCache,
|
||||||
|
resolveUsedInvite,
|
||||||
|
syncAllGuildInviteCaches,
|
||||||
|
syncGuildInviteCache,
|
||||||
|
upsertInviteInCache
|
||||||
|
} from './invites.js';
|
||||||
|
|
||||||
|
export function registerStatsEvents(context: BotContext): void {
|
||||||
|
context.client.once(Events.ClientReady, () => {
|
||||||
|
void syncAllGuildInviteCaches(context.client, context.redis).catch((error) => {
|
||||||
|
logger.error({ error }, 'Initial invite cache sync failed');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
context.client.on(Events.GuildCreate, (guild) => {
|
||||||
|
void syncGuildInviteCache(guild, context.redis).catch((error) => {
|
||||||
|
logger.error({ error, guildId: guild.id }, 'Invite cache sync failed for new guild');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
context.client.on(Events.InviteCreate, (invite) => {
|
||||||
|
if (!invite.guild) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void upsertInviteInCache(context.redis, invite.guild.id, invite).catch((error) => {
|
||||||
|
logger.error({ error, inviteCode: invite.code }, 'Failed to update invite cache on create');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
context.client.on(Events.InviteDelete, (invite) => {
|
||||||
|
if (!invite.guild) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void removeInviteFromCache(context.redis, invite.guild.id, invite.code).catch((error) => {
|
||||||
|
logger.error({ error, inviteCode: invite.code }, 'Failed to update invite cache on delete');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
context.client.on(Events.GuildMemberAdd, async (member) => {
|
||||||
|
try {
|
||||||
|
const usedInvite = await resolveUsedInvite(context.redis, member.guild);
|
||||||
|
const isFake = isFakeJoin(member);
|
||||||
|
|
||||||
|
await recordMemberJoin(
|
||||||
|
context.prisma,
|
||||||
|
member.guild.id,
|
||||||
|
member.id,
|
||||||
|
usedInvite?.inviterId ?? null,
|
||||||
|
usedInvite?.code ?? null,
|
||||||
|
isFake
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error, userId: member.id, guildId: member.guild.id }, 'Invite join tracking failed');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
context.client.on(Events.GuildMemberRemove, async (member) => {
|
||||||
|
try {
|
||||||
|
await recordMemberLeave(context.prisma, member.guild.id, member.id);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error, userId: member.id, guildId: member.guild.id }, 'Invite leave tracking failed');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
context.client.on(Events.MessageCreate, async (message) => {
|
||||||
|
try {
|
||||||
|
if (!message.guild || message.author.bot) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await incrementMessageActivity(
|
||||||
|
context.prisma,
|
||||||
|
message.guild.id,
|
||||||
|
message.author.id,
|
||||||
|
message.channel.id
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error, messageId: message.id }, 'Activity message tracking failed');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
context.client.on(Events.VoiceStateUpdate, async (oldState, newState) => {
|
||||||
|
try {
|
||||||
|
const guildId = newState.guild.id;
|
||||||
|
const userId = newState.id;
|
||||||
|
const oldChannelId = oldState.channelId;
|
||||||
|
const newChannelId = newState.channelId;
|
||||||
|
|
||||||
|
if (oldChannelId && oldChannelId !== newChannelId) {
|
||||||
|
const key = voiceSessionKey(guildId, userId);
|
||||||
|
const joinedAt = await context.redis.get(key);
|
||||||
|
await context.redis.del(key);
|
||||||
|
|
||||||
|
if (joinedAt) {
|
||||||
|
await addVoiceMinutes(
|
||||||
|
context.prisma,
|
||||||
|
guildId,
|
||||||
|
userId,
|
||||||
|
oldChannelId,
|
||||||
|
Number(joinedAt)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newChannelId && newChannelId !== oldChannelId) {
|
||||||
|
await context.redis.set(voiceSessionKey(guildId, userId), String(Date.now()));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error, userId: newState.id }, 'Activity voice tracking failed');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
9
apps/bot/src/modules/stats/index.ts
Normal file
9
apps/bot/src/modules/stats/index.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export { statsCommands } from './commands.js';
|
||||||
|
export { registerStatsEvents } from './events.js';
|
||||||
|
export {
|
||||||
|
ensureStatsRecurringJobs,
|
||||||
|
startStatsWorker,
|
||||||
|
updateStatsChannels,
|
||||||
|
STATS_REFRESH_CRON,
|
||||||
|
STATS_REFRESH_JOB_NAME
|
||||||
|
} from './service.js';
|
||||||
205
apps/bot/src/modules/stats/invites.ts
Normal file
205
apps/bot/src/modules/stats/invites.ts
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
import type { Client, Guild, GuildMember, Invite } from 'discord.js';
|
||||||
|
import type { PrismaClient } from '@prisma/client';
|
||||||
|
import { ensureGuild } from '../../guild.js';
|
||||||
|
import type { Redis } from 'ioredis';
|
||||||
|
|
||||||
|
const FAKE_ACCOUNT_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
export type CachedInvite = {
|
||||||
|
code: string;
|
||||||
|
uses: number;
|
||||||
|
inviterId: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function inviteCacheKey(guildId: string): string {
|
||||||
|
return `stats:invites:${guildId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseInviteCache(raw: string | null): Record<string, CachedInvite> {
|
||||||
|
if (!raw) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as Record<string, CachedInvite>;
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getInviteCache(redis: Redis, guildId: string): Promise<Record<string, CachedInvite>> {
|
||||||
|
const raw = await redis.get(inviteCacheKey(guildId));
|
||||||
|
return parseInviteCache(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setInviteCache(
|
||||||
|
redis: Redis,
|
||||||
|
guildId: string,
|
||||||
|
invites: Record<string, CachedInvite>
|
||||||
|
): Promise<void> {
|
||||||
|
await redis.set(inviteCacheKey(guildId), JSON.stringify(invites));
|
||||||
|
}
|
||||||
|
|
||||||
|
function inviteToCached(invite: Invite): CachedInvite {
|
||||||
|
return {
|
||||||
|
code: invite.code,
|
||||||
|
uses: invite.uses ?? 0,
|
||||||
|
inviterId: invite.inviter?.id ?? null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncGuildInviteCache(guild: Guild, redis: Redis): Promise<void> {
|
||||||
|
const fetched = await guild.invites.fetch().catch(() => null);
|
||||||
|
if (!fetched) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cache: Record<string, CachedInvite> = {};
|
||||||
|
for (const invite of fetched.values()) {
|
||||||
|
cache[invite.code] = inviteToCached(invite);
|
||||||
|
}
|
||||||
|
await setInviteCache(redis, guild.id, cache);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncAllGuildInviteCaches(client: Client, redis: Redis): Promise<void> {
|
||||||
|
await Promise.all(
|
||||||
|
[...client.guilds.cache.values()].map((guild) => syncGuildInviteCache(guild, redis))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function upsertInviteInCache(redis: Redis, guildId: string, invite: Invite): Promise<void> {
|
||||||
|
const cache = await getInviteCache(redis, guildId);
|
||||||
|
cache[invite.code] = inviteToCached(invite);
|
||||||
|
await setInviteCache(redis, guildId, cache);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeInviteFromCache(redis: Redis, guildId: string, code: string): Promise<void> {
|
||||||
|
const cache = await getInviteCache(redis, guildId);
|
||||||
|
delete cache[code];
|
||||||
|
await setInviteCache(redis, guildId, cache);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isFakeJoin(member: GuildMember): boolean {
|
||||||
|
return Date.now() - member.user.createdTimestamp < FAKE_ACCOUNT_AGE_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveUsedInvite(
|
||||||
|
redis: Redis,
|
||||||
|
guild: Guild
|
||||||
|
): Promise<{ code: string; inviterId: string | null } | null> {
|
||||||
|
const previous = await getInviteCache(redis, guild.id);
|
||||||
|
const currentInvites = await guild.invites.fetch().catch(() => null);
|
||||||
|
if (!currentInvites) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let matched: { code: string; inviterId: string | null } | null = null;
|
||||||
|
|
||||||
|
for (const invite of currentInvites.values()) {
|
||||||
|
const before = previous[invite.code];
|
||||||
|
const currentUses = invite.uses ?? 0;
|
||||||
|
const previousUses = before?.uses ?? 0;
|
||||||
|
|
||||||
|
if (currentUses > previousUses) {
|
||||||
|
matched = {
|
||||||
|
code: invite.code,
|
||||||
|
inviterId: invite.inviter?.id ?? before?.inviterId ?? null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
previous[invite.code] = inviteToCached(invite);
|
||||||
|
}
|
||||||
|
|
||||||
|
await setInviteCache(redis, guild.id, previous);
|
||||||
|
return matched;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function recordMemberJoin(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
guildId: string,
|
||||||
|
userId: string,
|
||||||
|
inviterId: string | null,
|
||||||
|
code: string | null,
|
||||||
|
isFake: boolean
|
||||||
|
): Promise<void> {
|
||||||
|
await ensureGuild(prisma, guildId);
|
||||||
|
|
||||||
|
await prisma.inviteJoin.upsert({
|
||||||
|
where: { guildId_userId: { guildId, userId } },
|
||||||
|
update: {
|
||||||
|
inviterId,
|
||||||
|
code,
|
||||||
|
isFake,
|
||||||
|
leftAt: null
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
guildId,
|
||||||
|
userId,
|
||||||
|
inviterId,
|
||||||
|
code,
|
||||||
|
isFake
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!inviterId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.inviteStat.upsert({
|
||||||
|
where: { guildId_inviterId: { guildId, inviterId } },
|
||||||
|
update: {
|
||||||
|
joins: { increment: 1 },
|
||||||
|
...(isFake ? { fakes: { increment: 1 } } : {}),
|
||||||
|
...(code ? { code } : {})
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
guildId,
|
||||||
|
inviterId,
|
||||||
|
code,
|
||||||
|
joins: 1,
|
||||||
|
leaves: 0,
|
||||||
|
fakes: isFake ? 1 : 0
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function recordMemberLeave(prisma: PrismaClient, guildId: string, userId: string): Promise<void> {
|
||||||
|
const join = await prisma.inviteJoin.findUnique({
|
||||||
|
where: { guildId_userId: { guildId, userId } }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!join || join.leftAt) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.inviteJoin.update({
|
||||||
|
where: { guildId_userId: { guildId, userId } },
|
||||||
|
data: { leftAt: new Date() }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!join.inviterId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.inviteStat.updateMany({
|
||||||
|
where: { guildId, inviterId: join.inviterId },
|
||||||
|
data: { leaves: { increment: 1 } }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getInviteStatsForUser(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
guildId: string,
|
||||||
|
inviterId: string
|
||||||
|
) {
|
||||||
|
return prisma.inviteStat.findUnique({
|
||||||
|
where: { guildId_inviterId: { guildId, inviterId } }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getInviteLeaderboard(prisma: PrismaClient, guildId: string, limit = 10) {
|
||||||
|
return prisma.inviteStat.findMany({
|
||||||
|
where: { guildId },
|
||||||
|
orderBy: [{ joins: 'desc' }, { inviterId: 'asc' }],
|
||||||
|
take: limit
|
||||||
|
});
|
||||||
|
}
|
||||||
172
apps/bot/src/modules/stats/service.ts
Normal file
172
apps/bot/src/modules/stats/service.ts
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
import { PermissionFlagsBits, type Guild, type GuildBasedChannel } from 'discord.js';
|
||||||
|
import { Worker } from 'bullmq';
|
||||||
|
import { applyCountTemplate } from '@nexumi/shared';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { ensureGuild } from '../../guild.js';
|
||||||
|
import { logger } from '../../logger.js';
|
||||||
|
import { redis } from '../../redis.js';
|
||||||
|
import { statsQueue, statsQueueName } from '../../queues.js';
|
||||||
|
import type { BotContext } from '../../types.js';
|
||||||
|
|
||||||
|
export const StatsSetupSchema = z.object({
|
||||||
|
membersChannelId: z.string(),
|
||||||
|
onlineChannelId: z.string(),
|
||||||
|
boostsChannelId: z.string(),
|
||||||
|
membersTemplate: z.string().min(1).max(100).optional(),
|
||||||
|
onlineTemplate: z.string().min(1).max(100).optional(),
|
||||||
|
boostsTemplate: z.string().min(1).max(100).optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
export type StatsSetupInput = z.infer<typeof StatsSetupSchema>;
|
||||||
|
|
||||||
|
export const STATS_REFRESH_CRON = '*/10 * * * *';
|
||||||
|
export const STATS_REFRESH_JOB_NAME = 'statsRefresh';
|
||||||
|
|
||||||
|
export async function getStatsConfig(prisma: BotContext['prisma'], guildId: string) {
|
||||||
|
await ensureGuild(prisma, guildId);
|
||||||
|
return prisma.statsConfig.findUnique({ where: { guildId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function upsertStatsConfig(
|
||||||
|
prisma: BotContext['prisma'],
|
||||||
|
guildId: string,
|
||||||
|
input: StatsSetupInput
|
||||||
|
) {
|
||||||
|
await ensureGuild(prisma, guildId);
|
||||||
|
|
||||||
|
const optionalTemplates = {
|
||||||
|
...(input.membersTemplate !== undefined ? { membersTemplate: input.membersTemplate } : {}),
|
||||||
|
...(input.onlineTemplate !== undefined ? { onlineTemplate: input.onlineTemplate } : {}),
|
||||||
|
...(input.boostsTemplate !== undefined ? { boostsTemplate: input.boostsTemplate } : {})
|
||||||
|
};
|
||||||
|
|
||||||
|
return prisma.statsConfig.upsert({
|
||||||
|
where: { guildId },
|
||||||
|
update: {
|
||||||
|
enabled: true,
|
||||||
|
membersChannelId: input.membersChannelId,
|
||||||
|
onlineChannelId: input.onlineChannelId,
|
||||||
|
boostsChannelId: input.boostsChannelId,
|
||||||
|
...optionalTemplates
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
guildId,
|
||||||
|
enabled: true,
|
||||||
|
membersChannelId: input.membersChannelId,
|
||||||
|
onlineChannelId: input.onlineChannelId,
|
||||||
|
boostsChannelId: input.boostsChannelId,
|
||||||
|
...optionalTemplates
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function estimateOnlineCount(guild: Guild): number {
|
||||||
|
if (guild.approximatePresenceCount != null) {
|
||||||
|
return guild.approximatePresenceCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
return guild.members.cache.filter((member) => {
|
||||||
|
const status = member.presence?.status;
|
||||||
|
return status !== undefined && status !== 'offline';
|
||||||
|
}).size;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renameStatsChannel(
|
||||||
|
guild: Guild,
|
||||||
|
channelId: string | null | undefined,
|
||||||
|
template: string,
|
||||||
|
count: number
|
||||||
|
): Promise<void> {
|
||||||
|
if (!channelId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const channel = (await guild.channels.fetch(channelId).catch(() => null)) as GuildBasedChannel | null;
|
||||||
|
if (!channel || !('setName' in channel) || typeof channel.setName !== 'function') {
|
||||||
|
logger.warn({ guildId: guild.id, channelId }, 'Stats channel invalid or not renameable');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextName = applyCountTemplate(template, count).slice(0, 100);
|
||||||
|
if (channel.name === nextName) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await channel.setName(nextName).catch((error) => {
|
||||||
|
logger.warn({ error, guildId: guild.id, channelId }, 'Failed to rename stats channel');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshGuildStatsChannels(context: BotContext, guildId: string): Promise<void> {
|
||||||
|
const config = await context.prisma.statsConfig.findUnique({ where: { guildId } });
|
||||||
|
if (!config?.enabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const guild = await context.client.guilds.fetch(guildId).catch(() => null);
|
||||||
|
if (!guild) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const me = guild.members.me;
|
||||||
|
if (!me?.permissions.has(PermissionFlagsBits.ManageChannels)) {
|
||||||
|
logger.warn({ guildId }, 'Missing ManageChannels for stats channel refresh');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await guild.members.fetch().catch(() => undefined);
|
||||||
|
|
||||||
|
const memberCount = guild.memberCount;
|
||||||
|
const onlineCount = estimateOnlineCount(guild);
|
||||||
|
const boostCount = guild.premiumSubscriptionCount ?? 0;
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
renameStatsChannel(guild, config.membersChannelId, config.membersTemplate, memberCount),
|
||||||
|
renameStatsChannel(guild, config.onlineChannelId, config.onlineTemplate, onlineCount),
|
||||||
|
renameStatsChannel(guild, config.boostsChannelId, config.boostsTemplate, boostCount)
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateStatsChannels(context: BotContext): Promise<void> {
|
||||||
|
const configs = await context.prisma.statsConfig.findMany({
|
||||||
|
where: { enabled: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const config of configs) {
|
||||||
|
try {
|
||||||
|
await refreshGuildStatsChannels(context, config.guildId);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error, guildId: config.guildId }, 'Stats channel refresh failed for guild');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureStatsRecurringJobs(): Promise<void> {
|
||||||
|
await statsQueue.add(
|
||||||
|
STATS_REFRESH_JOB_NAME,
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
repeat: { pattern: STATS_REFRESH_CRON },
|
||||||
|
removeOnComplete: 20,
|
||||||
|
removeOnFail: 20
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startStatsWorker(context: BotContext): Worker {
|
||||||
|
const worker = new Worker(
|
||||||
|
statsQueueName,
|
||||||
|
async (job) => {
|
||||||
|
if (job.name === STATS_REFRESH_JOB_NAME) {
|
||||||
|
await updateStatsChannels(context);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ connection: redis }
|
||||||
|
);
|
||||||
|
|
||||||
|
worker.on('failed', (job, error) => {
|
||||||
|
logger.error({ jobId: job?.id, error }, 'Stats queue job failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
return worker;
|
||||||
|
}
|
||||||
@@ -5,8 +5,6 @@ import { getGuildLocale } from '../../i18n.js';
|
|||||||
import { requirePermission } from '../../permissions.js';
|
import { requirePermission } from '../../permissions.js';
|
||||||
import { voiceCommandData } from './command-definitions.js';
|
import { voiceCommandData } from './command-definitions.js';
|
||||||
import {
|
import {
|
||||||
buildControlPanelEmbed,
|
|
||||||
buildControlPanelRows,
|
|
||||||
claimTempChannel,
|
claimTempChannel,
|
||||||
fetchCategory,
|
fetchCategory,
|
||||||
getTempVoiceChannelForMember,
|
getTempVoiceChannelForMember,
|
||||||
@@ -15,6 +13,7 @@ import {
|
|||||||
kickFromTempChannel,
|
kickFromTempChannel,
|
||||||
lockTempChannel,
|
lockTempChannel,
|
||||||
renameTempChannel,
|
renameTempChannel,
|
||||||
|
sendControlPanelToChannel,
|
||||||
setTempChannelLimit,
|
setTempChannelLimit,
|
||||||
transferTempChannelOwnership,
|
transferTempChannelOwnership,
|
||||||
updateTempVoiceConfig
|
updateTempVoiceConfig
|
||||||
@@ -93,11 +92,8 @@ const voiceCommand: SlashCommand = {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await interaction.reply({
|
await sendControlPanelToChannel(owned.channel, locale);
|
||||||
embeds: [buildControlPanelEmbed(owned.channel.name, locale)],
|
await interaction.reply({ content: t(locale, 'tempvoice.panel.sent'), ephemeral: true });
|
||||||
components: buildControlPanelRows(owned.channel.id, locale),
|
|
||||||
ephemeral: true
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -112,16 +112,14 @@ export function buildControlPanelEmbed(channelName: string, locale: Locale): Emb
|
|||||||
.setColor(0x6366f1);
|
.setColor(0x6366f1);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function sendOwnerControlPanelDm(
|
export async function sendControlPanelToChannel(
|
||||||
member: GuildMember,
|
channel: VoiceChannel,
|
||||||
channelId: string,
|
|
||||||
channelName: string,
|
|
||||||
locale: Locale
|
locale: Locale
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const embed = buildControlPanelEmbed(channelName, locale);
|
const embed = buildControlPanelEmbed(channel.name, locale);
|
||||||
const rows = buildControlPanelRows(channelId, locale);
|
const rows = buildControlPanelRows(channel.id, locale);
|
||||||
await member.send({ embeds: [embed], components: rows }).catch((error) => {
|
await channel.send({ embeds: [embed], components: rows }).catch((error) => {
|
||||||
logger.debug({ error, userId: member.id }, 'Could not DM temp voice control panel');
|
logger.debug({ error, channelId: channel.id }, 'Could not send temp voice control panel');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,7 +158,8 @@ export async function createTempVoiceChannel(
|
|||||||
PermissionFlagsBits.Connect,
|
PermissionFlagsBits.Connect,
|
||||||
PermissionFlagsBits.ViewChannel,
|
PermissionFlagsBits.ViewChannel,
|
||||||
PermissionFlagsBits.ManageChannels,
|
PermissionFlagsBits.ManageChannels,
|
||||||
PermissionFlagsBits.MoveMembers
|
PermissionFlagsBits.MoveMembers,
|
||||||
|
PermissionFlagsBits.SendMessages
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -170,7 +169,8 @@ export async function createTempVoiceChannel(
|
|||||||
PermissionFlagsBits.Connect,
|
PermissionFlagsBits.Connect,
|
||||||
PermissionFlagsBits.ViewChannel,
|
PermissionFlagsBits.ViewChannel,
|
||||||
PermissionFlagsBits.ManageChannels,
|
PermissionFlagsBits.ManageChannels,
|
||||||
PermissionFlagsBits.MoveMembers
|
PermissionFlagsBits.MoveMembers,
|
||||||
|
PermissionFlagsBits.SendMessages
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -197,7 +197,7 @@ export async function createTempVoiceChannel(
|
|||||||
|
|
||||||
const { getGuildLocale } = await import('../../i18n.js');
|
const { getGuildLocale } = await import('../../i18n.js');
|
||||||
const locale = await getGuildLocale(context.prisma, member.guild.id);
|
const locale = await getGuildLocale(context.prisma, member.guild.id);
|
||||||
await sendOwnerControlPanelDm(member, channel.id, channel.name, locale);
|
await sendControlPanelToChannel(channel, locale);
|
||||||
return channel;
|
return channel;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,10 +284,11 @@ export async function transferTempChannelOwnership(
|
|||||||
Connect: true,
|
Connect: true,
|
||||||
ViewChannel: true,
|
ViewChannel: true,
|
||||||
ManageChannels: true,
|
ManageChannels: true,
|
||||||
MoveMembers: true
|
MoveMembers: true,
|
||||||
|
SendMessages: true
|
||||||
});
|
});
|
||||||
|
|
||||||
await sendOwnerControlPanelDm(newOwner, channel.id, channel.name, locale);
|
await sendControlPanelToChannel(channel, locale);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function claimTempChannel(
|
export async function claimTempChannel(
|
||||||
|
|||||||
@@ -17,3 +17,11 @@ export const ticketQueueName = 'tickets';
|
|||||||
export const ticketQueue = new Queue(ticketQueueName, { connection: redis });
|
export const ticketQueue = new Queue(ticketQueueName, { connection: redis });
|
||||||
export const birthdayQueueName = 'birthdays';
|
export const birthdayQueueName = 'birthdays';
|
||||||
export const birthdayQueue = new Queue(birthdayQueueName, { connection: redis });
|
export const birthdayQueue = new Queue(birthdayQueueName, { connection: redis });
|
||||||
|
export const statsQueueName = 'stats';
|
||||||
|
export const statsQueue = new Queue(statsQueueName, { connection: redis });
|
||||||
|
export const feedsQueueName = 'feeds';
|
||||||
|
export const feedsQueue = new Queue(feedsQueueName, { connection: redis });
|
||||||
|
export const scheduleQueueName = 'schedules';
|
||||||
|
export const scheduleQueue = new Queue(scheduleQueueName, { connection: redis });
|
||||||
|
export const guildBackupQueueName = 'guild-backups';
|
||||||
|
export const guildBackupQueue = new Queue(guildBackupQueueName, { connection: redis });
|
||||||
|
|||||||
@@ -177,35 +177,38 @@ Dieses Dokument hält den aktuellen Implementierungsstand fest. Es wird bei jede
|
|||||||
|
|
||||||
- Manuell auf Test-Server bestätigt (User-Freigabe).
|
- Manuell auf Test-Server bestätigt (User-Freigabe).
|
||||||
|
|
||||||
## Phase 4 – Giveaways, Tickets, Selfroles, Tags, Starboard, Suggestions, Birthdays, Temp-Voice (Status: implementiert, manuelle Tests ausstehend)
|
## Phase 4 – Giveaways … Temp-Voice (Status: abgeschlossen, manuell bestätigt)
|
||||||
|
|
||||||
|
- Manuell auf Test-Server bestätigt (User-Freigabe).
|
||||||
|
|
||||||
|
## Phase 5 – Stats/Invites, Feeds, Scheduler, Guild-Backup (Status: implementiert + deployed, manuelle Discord-Tests ausstehend)
|
||||||
|
|
||||||
### Abgeschlossen
|
### Abgeschlossen
|
||||||
|
|
||||||
- Prisma-Migration `20260722160000_phase4_modules`
|
- Prisma-Migration `20260722170000_phase5_modules` (deployed)
|
||||||
- Shared `packages/shared/src/phase4.ts` + Tests
|
- Shared `packages/shared/src/phase5.ts` + Tests
|
||||||
- Alle Module in `commands.ts` / `index.ts` / `jobs.ts` verdrahtet
|
- Module verdrahtet in `commands.ts`, `index.ts`, `jobs.ts`
|
||||||
- Intents: `GuildMessageReactions`; Partials: `Reaction`
|
- Queues/Worker: `stats`, `feeds`, `schedules`, `guild-backups`
|
||||||
- Queues: `giveaways`, `tickets` (inkl. SelfRole-Expire + Auto-Close), `birthdays`
|
- Optional: `TWITCH_CLIENT_ID` / `TWITCH_CLIENT_SECRET`
|
||||||
|
- E2E: `docker compose up -d --build bot` OK; Bot ready; 74 Guild-Commands registriert
|
||||||
|
|
||||||
### Module
|
### Module
|
||||||
|
|
||||||
| Modul | Features |
|
| Modul | Features |
|
||||||
|--------|----------|
|
|--------|----------|
|
||||||
| **Giveaways** | `/giveaway start\|end\|reroll\|list\|delete\|pause`, Join-Button, Auto-End |
|
| **Stats/Invites** | `/stats setup\|refresh`, `/invites view\|leaderboard`, Invite-Tracking, ActivityStat, 10-Min-Kanal-Update |
|
||||||
| **Tickets** | Panel/Kategorie, Close/Claim/…, HTML-Transkript, Rating, Auto-Close |
|
| **Feeds** | `/feeds add\|remove\|list\|test` (Twitch/YouTube/RSS/Reddit), Poll alle 5 Min |
|
||||||
| **Selfroles** | Panel create/edit/delete (Buttons/Dropdown/Reactions + Behaviors) |
|
| **Scheduler** | `/schedule create\|list\|delete`, `/announce`, BullMQ once/cron |
|
||||||
| **Tags** | create/edit/delete/list/info/run, Auto-Responder |
|
| **Guild-Backup** | `/backup create\|list\|info\|delete\|restore` (Owner + Doppelbestätigung) |
|
||||||
| **Starboard** | `/starboard setup`, Reaction-Events |
|
|
||||||
| **Suggestions** | `/suggest`, Staff-Status, Voting-Buttons |
|
|
||||||
| **Birthdays** | set/remove/next/list/setup, stündlicher Check, 24h-Rolle |
|
|
||||||
| **Temp-Voice** | setup + Steuerung + Join-to-Create + Control-Panel |
|
|
||||||
|
|
||||||
### Manuelle Discord-Tests (offen)
|
### Manuelle Discord-Tests (offen)
|
||||||
|
|
||||||
- Giveaway, Ticket-Flow, Selfroles, Tags, Starboard, Suggestions, Birthday, Temp-Voice
|
- `/stats setup` + Kanal-Update; Invite-Join → `/invites view|leaderboard`
|
||||||
- `docker compose up -d --build` (Migration + Guild-Command-Sync)
|
- `/feeds add` (RSS/Reddit) + `/feeds test`
|
||||||
|
- `/schedule create` (once) + `/announce`
|
||||||
|
- `/backup create|list|info` und Delete/Restore-Bestätigung (Owner)
|
||||||
|
|
||||||
## Nächster geplanter Schritt
|
## Nächster geplanter Schritt
|
||||||
|
|
||||||
- Manuelle Phase-4-Tests, dann nach Freigabe Phase 5 (Statistiken/Invite-Tracking, Social-Feeds, Scheduler, Backup).
|
- Manuelle Phase-5-Tests, dann nach Freigabe Phase 6 (WebUI-Fundament: OAuth, Layout, Settings-Framework, API).
|
||||||
|
|
||||||
|
|||||||
@@ -963,6 +963,58 @@ export const commandLocales = {
|
|||||||
de: 'Nachrichtenvorlage ({user}, {age})',
|
de: 'Nachrichtenvorlage ({user}, {age})',
|
||||||
en: 'Message template ({user}, {age})'
|
en: 'Message template ({user}, {age})'
|
||||||
},
|
},
|
||||||
|
'stats.description': {
|
||||||
|
de: 'Server-Statistik-Kanäle verwalten',
|
||||||
|
en: 'Manage server statistics channels'
|
||||||
|
},
|
||||||
|
'stats.setup.description': {
|
||||||
|
de: 'Statistik-Kanäle einrichten',
|
||||||
|
en: 'Configure statistics channels'
|
||||||
|
},
|
||||||
|
'stats.setup.options.members_channel': {
|
||||||
|
de: 'Kanal für Mitgliederzahl',
|
||||||
|
en: 'Channel for member count'
|
||||||
|
},
|
||||||
|
'stats.setup.options.online_channel': {
|
||||||
|
de: 'Kanal für Online-Anzahl',
|
||||||
|
en: 'Channel for online count'
|
||||||
|
},
|
||||||
|
'stats.setup.options.boosts_channel': {
|
||||||
|
de: 'Kanal für Boost-Anzahl',
|
||||||
|
en: 'Channel for boost count'
|
||||||
|
},
|
||||||
|
'stats.setup.options.members_template': {
|
||||||
|
de: 'Vorlage Mitglieder ({count})',
|
||||||
|
en: 'Members template ({count})'
|
||||||
|
},
|
||||||
|
'stats.setup.options.online_template': {
|
||||||
|
de: 'Vorlage Online ({count})',
|
||||||
|
en: 'Online template ({count})'
|
||||||
|
},
|
||||||
|
'stats.setup.options.boosts_template': {
|
||||||
|
de: 'Vorlage Boosts ({count})',
|
||||||
|
en: 'Boosts template ({count})'
|
||||||
|
},
|
||||||
|
'stats.refresh.description': {
|
||||||
|
de: 'Statistik-Kanäle jetzt aktualisieren',
|
||||||
|
en: 'Refresh statistics channels now'
|
||||||
|
},
|
||||||
|
'invites.description': {
|
||||||
|
de: 'Invite-Statistiken anzeigen',
|
||||||
|
en: 'Show invite statistics'
|
||||||
|
},
|
||||||
|
'invites.view.description': {
|
||||||
|
de: 'Invite-Statistik eines Nutzers anzeigen',
|
||||||
|
en: 'Show invite stats for a user'
|
||||||
|
},
|
||||||
|
'invites.view.options.user': {
|
||||||
|
de: 'Nutzer (Standard: du)',
|
||||||
|
en: 'User (defaults to you)'
|
||||||
|
},
|
||||||
|
'invites.leaderboard.description': {
|
||||||
|
de: 'Top-Einlader anzeigen',
|
||||||
|
en: 'Show top inviters'
|
||||||
|
},
|
||||||
'tempvoice.description': {
|
'tempvoice.description': {
|
||||||
de: 'Temp-Voice (Join to Create) steuern',
|
de: 'Temp-Voice (Join to Create) steuern',
|
||||||
en: 'Control temp voice (join to create)'
|
en: 'Control temp voice (join to create)'
|
||||||
@@ -1194,6 +1246,158 @@ export const commandLocales = {
|
|||||||
'tag.choice.response_type.embed': {
|
'tag.choice.response_type.embed': {
|
||||||
de: 'Embed',
|
de: 'Embed',
|
||||||
en: 'Embed'
|
en: 'Embed'
|
||||||
|
},
|
||||||
|
'feeds.description': {
|
||||||
|
de: 'Social-Feeds verwalten',
|
||||||
|
en: 'Manage social feeds'
|
||||||
|
},
|
||||||
|
'feeds.add.description': {
|
||||||
|
de: 'Feed hinzufügen',
|
||||||
|
en: 'Add feed'
|
||||||
|
},
|
||||||
|
'feeds.add.options.type': {
|
||||||
|
de: 'Feed-Typ',
|
||||||
|
en: 'Feed type'
|
||||||
|
},
|
||||||
|
'feeds.add.options.source': {
|
||||||
|
de: 'Quelle (Kanal, URL oder Subreddit)',
|
||||||
|
en: 'Source (channel, URL, or subreddit)'
|
||||||
|
},
|
||||||
|
'feeds.add.options.channel': {
|
||||||
|
de: 'Ziel-Kanal',
|
||||||
|
en: 'Target channel'
|
||||||
|
},
|
||||||
|
'feeds.add.options.role': {
|
||||||
|
de: 'Rolle zum Pingen',
|
||||||
|
en: 'Role to ping'
|
||||||
|
},
|
||||||
|
'feeds.add.options.template': {
|
||||||
|
de: 'Nachrichtenvorlage ({title}, {url})',
|
||||||
|
en: 'Message template ({title}, {url})'
|
||||||
|
},
|
||||||
|
'feeds.remove.description': {
|
||||||
|
de: 'Feed entfernen',
|
||||||
|
en: 'Remove feed'
|
||||||
|
},
|
||||||
|
'feeds.remove.options.id': {
|
||||||
|
de: 'Feed-ID',
|
||||||
|
en: 'Feed ID'
|
||||||
|
},
|
||||||
|
'feeds.list.description': {
|
||||||
|
de: 'Feeds auflisten',
|
||||||
|
en: 'List feeds'
|
||||||
|
},
|
||||||
|
'feeds.test.description': {
|
||||||
|
de: 'Feed einmal abrufen und posten',
|
||||||
|
en: 'Fetch once and post'
|
||||||
|
},
|
||||||
|
'feeds.test.options.id': {
|
||||||
|
de: 'Feed-ID',
|
||||||
|
en: 'Feed ID'
|
||||||
|
},
|
||||||
|
'feeds.choice.type.twitch': {
|
||||||
|
de: 'Twitch',
|
||||||
|
en: 'Twitch'
|
||||||
|
},
|
||||||
|
'feeds.choice.type.youtube': {
|
||||||
|
de: 'YouTube',
|
||||||
|
en: 'YouTube'
|
||||||
|
},
|
||||||
|
'feeds.choice.type.rss': {
|
||||||
|
de: 'RSS',
|
||||||
|
en: 'RSS'
|
||||||
|
},
|
||||||
|
'feeds.choice.type.reddit': {
|
||||||
|
de: 'Reddit',
|
||||||
|
en: 'Reddit'
|
||||||
|
},
|
||||||
|
'schedule.description': {
|
||||||
|
de: 'Geplante Nachrichten verwalten',
|
||||||
|
en: 'Manage scheduled messages'
|
||||||
|
},
|
||||||
|
'schedule.create.description': {
|
||||||
|
de: 'Nachricht planen',
|
||||||
|
en: 'Schedule a message'
|
||||||
|
},
|
||||||
|
'schedule.list.description': {
|
||||||
|
de: 'Geplante Nachrichten anzeigen',
|
||||||
|
en: 'List scheduled messages'
|
||||||
|
},
|
||||||
|
'schedule.delete.description': {
|
||||||
|
de: 'Geplante Nachricht löschen',
|
||||||
|
en: 'Delete a scheduled message'
|
||||||
|
},
|
||||||
|
'schedule.create.options.when': {
|
||||||
|
de: 'Einmalig: Dauer (z. B. 1h) oder ISO-Datum',
|
||||||
|
en: 'One-shot: duration (e.g. 1h) or ISO date'
|
||||||
|
},
|
||||||
|
'schedule.create.options.cron': {
|
||||||
|
de: 'Wiederkehrend: Cron-Ausdruck',
|
||||||
|
en: 'Recurring: cron expression'
|
||||||
|
},
|
||||||
|
'schedule.common.options.channel': {
|
||||||
|
de: 'Zielkanal',
|
||||||
|
en: 'Target channel'
|
||||||
|
},
|
||||||
|
'schedule.common.options.content': {
|
||||||
|
de: 'Nachrichtentext',
|
||||||
|
en: 'Message text'
|
||||||
|
},
|
||||||
|
'schedule.common.options.embed_title': {
|
||||||
|
de: 'Embed-Titel',
|
||||||
|
en: 'Embed title'
|
||||||
|
},
|
||||||
|
'schedule.common.options.embed_description': {
|
||||||
|
de: 'Embed-Beschreibung',
|
||||||
|
en: 'Embed description'
|
||||||
|
},
|
||||||
|
'schedule.common.options.embed_color': {
|
||||||
|
de: 'Embed-Farbe (Dezimal)',
|
||||||
|
en: 'Embed color (decimal)'
|
||||||
|
},
|
||||||
|
'schedule.common.options.role': {
|
||||||
|
de: 'Zu pingende Rolle',
|
||||||
|
en: 'Role to ping'
|
||||||
|
},
|
||||||
|
'schedule.common.options.id': {
|
||||||
|
de: 'ID der geplanten Nachricht',
|
||||||
|
en: 'Scheduled message ID'
|
||||||
|
},
|
||||||
|
'announce.description': {
|
||||||
|
de: 'Sofortige Ankündigung senden',
|
||||||
|
en: 'Send an immediate announcement'
|
||||||
|
},
|
||||||
|
'guildbackup.description': {
|
||||||
|
de: 'Server-Backups erstellen und wiederherstellen',
|
||||||
|
en: 'Create and restore server backups'
|
||||||
|
},
|
||||||
|
'guildbackup.create.description': {
|
||||||
|
de: 'Snapshot von Rollen, Kanälen und Einstellungen erstellen',
|
||||||
|
en: 'Create a snapshot of roles, channels, and settings'
|
||||||
|
},
|
||||||
|
'guildbackup.create.options.name': {
|
||||||
|
de: 'Name des Backups',
|
||||||
|
en: 'Backup name'
|
||||||
|
},
|
||||||
|
'guildbackup.list.description': {
|
||||||
|
de: 'Backups dieses Servers auflisten',
|
||||||
|
en: 'List backups for this server'
|
||||||
|
},
|
||||||
|
'guildbackup.info.description': {
|
||||||
|
de: 'Details zu einem Backup anzeigen',
|
||||||
|
en: 'Show backup details'
|
||||||
|
},
|
||||||
|
'guildbackup.delete.description': {
|
||||||
|
de: 'Backup löschen',
|
||||||
|
en: 'Delete a backup'
|
||||||
|
},
|
||||||
|
'guildbackup.restore.description': {
|
||||||
|
de: 'Backup wiederherstellen (nur Owner)',
|
||||||
|
en: 'Restore a backup (owner only)'
|
||||||
|
},
|
||||||
|
'guildbackup.common.options.id': {
|
||||||
|
de: 'Backup-ID',
|
||||||
|
en: 'Backup ID'
|
||||||
}
|
}
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ 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 * from './phase4.js';
|
||||||
|
export * from './phase5.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>;
|
||||||
@@ -364,6 +365,17 @@ const de: Dictionary = {
|
|||||||
'birthdays.list.header': '**Geburtstage (sortiert nach nächstem Datum):**',
|
'birthdays.list.header': '**Geburtstage (sortiert nach nächstem Datum):**',
|
||||||
'birthdays.next.header': '**Nächste Geburtstage:**',
|
'birthdays.next.header': '**Nächste Geburtstage:**',
|
||||||
'birthdays.list.line': '{rank}. {user} — {date} (in {days} T.)',
|
'birthdays.list.line': '{rank}. {user} — {date} (in {days} T.)',
|
||||||
|
'stats.notConfigured': 'Server-Statistiken sind noch nicht eingerichtet. Nutze `/stats setup`.',
|
||||||
|
'stats.setup.done': 'Statistik-Kanäle konfiguriert und aktualisiert.',
|
||||||
|
'stats.refresh.done': 'Statistik-Kanäle wurden aktualisiert.',
|
||||||
|
'stats.error.invalidSetup': 'Ungültige Eingabe für die Statistik-Konfiguration.',
|
||||||
|
'invites.view.empty': '{user} hat noch keine Invite-Statistiken.',
|
||||||
|
'invites.view.result':
|
||||||
|
'**Invite-Statistik für {user}**\nJoins: {joins} | Leaves: {leaves} | Fakes: {fakes} | Netto: {net}',
|
||||||
|
'invites.leaderboard.empty': 'Noch keine Invite-Daten vorhanden.',
|
||||||
|
'invites.leaderboard.header': '**Top Einlader:**',
|
||||||
|
'invites.leaderboard.line':
|
||||||
|
'{rank}. {user} — Joins: {joins}, Leaves: {leaves}, Fakes: {fakes}, Netto: {net}',
|
||||||
'tempvoice.error.disabled': 'Temp-Voice ist auf diesem Server deaktiviert.',
|
'tempvoice.error.disabled': 'Temp-Voice ist auf diesem Server deaktiviert.',
|
||||||
'tempvoice.error.invalidCategory': 'Ungültige Kategorie.',
|
'tempvoice.error.invalidCategory': 'Ungültige Kategorie.',
|
||||||
'tempvoice.error.notOwner': 'Du bist nicht der Besitzer dieses Temp-Voice-Kanals.',
|
'tempvoice.error.notOwner': 'Du bist nicht der Besitzer dieses Temp-Voice-Kanals.',
|
||||||
@@ -386,6 +398,7 @@ const de: Dictionary = {
|
|||||||
'tempvoice.claim.success': 'Du bist jetzt Besitzer dieses Kanals.',
|
'tempvoice.claim.success': 'Du bist jetzt Besitzer dieses Kanals.',
|
||||||
'tempvoice.panel.title': 'Temp-Voice Steuerung',
|
'tempvoice.panel.title': 'Temp-Voice Steuerung',
|
||||||
'tempvoice.panel.description': 'Verwalte **{channel}** mit den Buttons oder `/voice`-Commands.',
|
'tempvoice.panel.description': 'Verwalte **{channel}** mit den Buttons oder `/voice`-Commands.',
|
||||||
|
'tempvoice.panel.sent': 'Control-Panel wurde in den Voice-Kanal-Chat gesendet.',
|
||||||
'tempvoice.button.rename': 'Umbenennen',
|
'tempvoice.button.rename': 'Umbenennen',
|
||||||
'tempvoice.button.limit': 'Limit',
|
'tempvoice.button.limit': 'Limit',
|
||||||
'tempvoice.button.lock': 'Sperren',
|
'tempvoice.button.lock': 'Sperren',
|
||||||
@@ -436,7 +449,70 @@ const de: Dictionary = {
|
|||||||
'tag.error.not_allowed': 'Du darfst diesen Tag hier nicht nutzen.',
|
'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.invalid_name': 'Ungültiger Tag-Name (1–32 Zeichen, a-z, 0-9, _, -).',
|
||||||
'tag.error.content_required': 'Text-Tags benötigen Inhalt.',
|
'tag.error.content_required': 'Text-Tags benötigen Inhalt.',
|
||||||
'tag.error.embed_required': 'Embed-Tags benötigen Titel oder Beschreibung.'
|
'tag.error.embed_required': 'Embed-Tags benötigen Titel oder Beschreibung.',
|
||||||
|
'guildbackup.description': 'Server-Backups erstellen und wiederherstellen',
|
||||||
|
'guildbackup.created': 'Backup **{name}** erstellt (ID: `{id}`).',
|
||||||
|
'guildbackup.deleted': 'Backup `{id}` gelöscht.',
|
||||||
|
'guildbackup.list.header': '**Server-Backups**',
|
||||||
|
'guildbackup.list.empty': 'Keine Backups vorhanden.',
|
||||||
|
'guildbackup.list.line': '• `{id}` — **{name}** ({date})',
|
||||||
|
'guildbackup.info.id': 'ID: `{id}`',
|
||||||
|
'guildbackup.info.name': 'Name: **{name}**',
|
||||||
|
'guildbackup.info.created': 'Erstellt: {date}',
|
||||||
|
'guildbackup.info.createdBy': 'Erstellt von: <@{userId}>',
|
||||||
|
'guildbackup.info.roles': 'Rollen: {count}',
|
||||||
|
'guildbackup.info.channels': 'Kanäle: {count}',
|
||||||
|
'guildbackup.info.limitations':
|
||||||
|
'Hinweis: Backups enthalten keine Nachrichten. Beim Restore werden IDs nicht übernommen; fehlende Rollen/Kanäle werden nach Name ergänzt, bestehende nicht gelöscht.',
|
||||||
|
'guildbackup.confirm.delete': 'Backup `{id}` wirklich löschen?',
|
||||||
|
'guildbackup.confirm.restore.step1':
|
||||||
|
'Backup `{id}` wiederherstellen? Es werden fehlende Rollen/Kanäle ergänzt und Overwrites best-effort angewendet. Bestehende Kanäle werden **nicht** gelöscht.',
|
||||||
|
'guildbackup.confirm.restore.step2':
|
||||||
|
'**Letzte Bestätigung:** Backup `{id}` jetzt wiederherstellen? Diese Aktion kann nicht rückgängig gemacht werden.',
|
||||||
|
'guildbackup.confirm.button.confirm': 'Bestätigen',
|
||||||
|
'guildbackup.confirm.button.cancel': 'Abbrechen',
|
||||||
|
'guildbackup.confirm.expired': 'Diese Bestätigung ist abgelaufen.',
|
||||||
|
'guildbackup.confirm.unauthorized': 'Nur der auslösende Nutzer kann das bestätigen.',
|
||||||
|
'guildbackup.confirm.cancelled': 'Abgebrochen.',
|
||||||
|
'guildbackup.restore.done':
|
||||||
|
'Backup `{id}` wiederhergestellt. Rollen: {rolesCreated} neu, {rolesSkipped} übersprungen. Kanäle: {channelsCreated} neu, {channelsUpdated} aktualisiert. Overwrites: {overwritesApplied}.',
|
||||||
|
'guildbackup.restore.queued':
|
||||||
|
'Großes Backup `{id}` — Restore wurde in die Warteschlange gestellt und läuft im Hintergrund.',
|
||||||
|
'guildbackup.error.not_found': 'Backup nicht gefunden.',
|
||||||
|
'guildbackup.error.invalid_payload': 'Backup-Daten sind ungültig oder veraltet.',
|
||||||
|
'guildbackup.error.invalid_name': 'Ungültiger Backup-Name (1–100 Zeichen).',
|
||||||
|
'guildbackup.error.owner_only': 'Nur der Server-Owner kann Backups wiederherstellen.',
|
||||||
|
'guildbackup.error.bot_missing_permissions':
|
||||||
|
'Dem Bot fehlen Manage Roles oder Manage Channels für einen Restore.',
|
||||||
|
'feeds.added': 'Feed `{id}` ({type}) hinzugefügt.',
|
||||||
|
'feeds.removed': 'Feed `{id}` entfernt.',
|
||||||
|
'feeds.list.header': '**Social Feeds**',
|
||||||
|
'feeds.list.empty': 'Keine Feeds konfiguriert.',
|
||||||
|
'feeds.list.line': '`{id}` — {type}: {source} → {channel}',
|
||||||
|
'feeds.test.posted': 'Test erfolgreich: {count} Nachricht(en) gesendet.',
|
||||||
|
'feeds.test.noItems': 'Keine neuen Einträge gefunden.',
|
||||||
|
'feeds.test.failed': 'Abruf fehlgeschlagen: {reason}',
|
||||||
|
'feeds.test.skipped': 'Feed-Test übersprungen.',
|
||||||
|
'feeds.test.twitchSkipped':
|
||||||
|
'Twitch-Feed übersprungen: TWITCH_CLIENT_ID und TWITCH_CLIENT_SECRET fehlen in der Bot-Konfiguration.',
|
||||||
|
'feeds.error.not_found': 'Feed nicht gefunden.',
|
||||||
|
'scheduler.created': 'Geplante Nachricht erstellt (ID: `{id}`). {timing}',
|
||||||
|
'scheduler.created.at': 'Sendung: {time}',
|
||||||
|
'scheduler.created.cron': 'Wiederholung: `{cron}`',
|
||||||
|
'scheduler.deleted': 'Geplante Nachricht gelöscht.',
|
||||||
|
'scheduler.announce.sent': 'Ankündigung gesendet.',
|
||||||
|
'scheduler.list.header': '**Geplante Nachrichten**',
|
||||||
|
'scheduler.list.empty': 'Keine geplanten Nachrichten.',
|
||||||
|
'scheduler.list.line': '`{id}` — {channel} — {timing} — {preview}',
|
||||||
|
'scheduler.list.cron': 'Cron `{cron}`',
|
||||||
|
'scheduler.list.embedPreview': '[Embed]',
|
||||||
|
'scheduler.error.missing_schedule_time': 'Gib `when` oder `cron` an.',
|
||||||
|
'scheduler.error.invalid_when': 'Ungültige Zeit. Nutze z. B. 1h, 2d oder ein ISO-Datum.',
|
||||||
|
'scheduler.error.invalid_cron': 'Ungültiger Cron-Ausdruck (5–6 Felder).',
|
||||||
|
'scheduler.error.invalid_channel': 'Ungültiger Kanal.',
|
||||||
|
'scheduler.error.channel_unavailable': 'Der Bot kann in diesem Kanal nicht senden.',
|
||||||
|
'scheduler.error.not_found': 'Geplante Nachricht nicht gefunden.',
|
||||||
|
'scheduler.error.content_required': 'Inhalt oder Embed-Titel/Beschreibung erforderlich.'
|
||||||
};
|
};
|
||||||
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.',
|
||||||
@@ -762,6 +838,17 @@ const en: Dictionary = {
|
|||||||
'birthdays.list.header': '**Birthdays (sorted by next date):**',
|
'birthdays.list.header': '**Birthdays (sorted by next date):**',
|
||||||
'birthdays.next.header': '**Upcoming birthdays:**',
|
'birthdays.next.header': '**Upcoming birthdays:**',
|
||||||
'birthdays.list.line': '{rank}. {user} — {date} (in {days} d)',
|
'birthdays.list.line': '{rank}. {user} — {date} (in {days} d)',
|
||||||
|
'stats.notConfigured': 'Server statistics are not configured yet. Use `/stats setup`.',
|
||||||
|
'stats.setup.done': 'Stats channels configured and updated.',
|
||||||
|
'stats.refresh.done': 'Stats channels have been refreshed.',
|
||||||
|
'stats.error.invalidSetup': 'Invalid input for stats configuration.',
|
||||||
|
'invites.view.empty': '{user} has no invite statistics yet.',
|
||||||
|
'invites.view.result':
|
||||||
|
'**Invite stats for {user}**\nJoins: {joins} | Leaves: {leaves} | Fakes: {fakes} | Net: {net}',
|
||||||
|
'invites.leaderboard.empty': 'No invite data available yet.',
|
||||||
|
'invites.leaderboard.header': '**Top inviters:**',
|
||||||
|
'invites.leaderboard.line':
|
||||||
|
'{rank}. {user} — Joins: {joins}, Leaves: {leaves}, Fakes: {fakes}, Net: {net}',
|
||||||
'tempvoice.error.disabled': 'Temp voice is disabled on this server.',
|
'tempvoice.error.disabled': 'Temp voice is disabled on this server.',
|
||||||
'tempvoice.error.invalidCategory': 'Invalid category.',
|
'tempvoice.error.invalidCategory': 'Invalid category.',
|
||||||
'tempvoice.error.notOwner': 'You are not the owner of this temp voice channel.',
|
'tempvoice.error.notOwner': 'You are not the owner of this temp voice channel.',
|
||||||
@@ -784,6 +871,7 @@ const en: Dictionary = {
|
|||||||
'tempvoice.claim.success': 'You are now the owner of this channel.',
|
'tempvoice.claim.success': 'You are now the owner of this channel.',
|
||||||
'tempvoice.panel.title': 'Temp Voice Control',
|
'tempvoice.panel.title': 'Temp Voice Control',
|
||||||
'tempvoice.panel.description': 'Manage **{channel}** with the buttons below or `/voice` commands.',
|
'tempvoice.panel.description': 'Manage **{channel}** with the buttons below or `/voice` commands.',
|
||||||
|
'tempvoice.panel.sent': 'Control panel posted in the voice channel chat.',
|
||||||
'tempvoice.button.rename': 'Rename',
|
'tempvoice.button.rename': 'Rename',
|
||||||
'tempvoice.button.limit': 'Limit',
|
'tempvoice.button.limit': 'Limit',
|
||||||
'tempvoice.button.lock': 'Lock',
|
'tempvoice.button.lock': 'Lock',
|
||||||
@@ -834,7 +922,70 @@ const en: Dictionary = {
|
|||||||
'tag.error.not_allowed': 'You are not allowed to use this tag here.',
|
'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.invalid_name': 'Invalid tag name (1–32 chars, a-z, 0-9, _, -).',
|
||||||
'tag.error.content_required': 'Text tags require content.',
|
'tag.error.content_required': 'Text tags require content.',
|
||||||
'tag.error.embed_required': 'Embed tags require a title or description.'
|
'tag.error.embed_required': 'Embed tags require a title or description.',
|
||||||
|
'guildbackup.description': 'Create and restore server backups',
|
||||||
|
'guildbackup.created': 'Created backup **{name}** (ID: `{id}`).',
|
||||||
|
'guildbackup.deleted': 'Deleted backup `{id}`.',
|
||||||
|
'guildbackup.list.header': '**Server backups**',
|
||||||
|
'guildbackup.list.empty': 'No backups found.',
|
||||||
|
'guildbackup.list.line': '• `{id}` — **{name}** ({date})',
|
||||||
|
'guildbackup.info.id': 'ID: `{id}`',
|
||||||
|
'guildbackup.info.name': 'Name: **{name}**',
|
||||||
|
'guildbackup.info.created': 'Created: {date}',
|
||||||
|
'guildbackup.info.createdBy': 'Created by: <@{userId}>',
|
||||||
|
'guildbackup.info.roles': 'Roles: {count}',
|
||||||
|
'guildbackup.info.channels': 'Channels: {count}',
|
||||||
|
'guildbackup.info.limitations':
|
||||||
|
'Note: Backups do not include messages. Restore cannot preserve IDs; missing roles/channels are added by name, existing channels are not deleted.',
|
||||||
|
'guildbackup.confirm.delete': 'Really delete backup `{id}`?',
|
||||||
|
'guildbackup.confirm.restore.step1':
|
||||||
|
'Restore backup `{id}`? Missing roles/channels will be added and overwrites applied best-effort. Existing channels will **not** be deleted.',
|
||||||
|
'guildbackup.confirm.restore.step2':
|
||||||
|
'**Final confirmation:** Restore backup `{id}` now? This cannot be undone.',
|
||||||
|
'guildbackup.confirm.button.confirm': 'Confirm',
|
||||||
|
'guildbackup.confirm.button.cancel': 'Cancel',
|
||||||
|
'guildbackup.confirm.expired': 'This confirmation has expired.',
|
||||||
|
'guildbackup.confirm.unauthorized': 'Only the user who started this action can confirm.',
|
||||||
|
'guildbackup.confirm.cancelled': 'Cancelled.',
|
||||||
|
'guildbackup.restore.done':
|
||||||
|
'Restored backup `{id}`. Roles: {rolesCreated} created, {rolesSkipped} skipped. Channels: {channelsCreated} created, {channelsUpdated} updated. Overwrites: {overwritesApplied}.',
|
||||||
|
'guildbackup.restore.queued':
|
||||||
|
'Large backup `{id}` — restore queued and will run in the background.',
|
||||||
|
'guildbackup.error.not_found': 'Backup not found.',
|
||||||
|
'guildbackup.error.invalid_payload': 'Backup data is invalid or outdated.',
|
||||||
|
'guildbackup.error.invalid_name': 'Invalid backup name (1–100 characters).',
|
||||||
|
'guildbackup.error.owner_only': 'Only the server owner can restore backups.',
|
||||||
|
'guildbackup.error.bot_missing_permissions':
|
||||||
|
'The bot is missing Manage Roles or Manage Channels for a restore.',
|
||||||
|
'feeds.added': 'Added feed `{id}` ({type}).',
|
||||||
|
'feeds.removed': 'Removed feed `{id}`.',
|
||||||
|
'feeds.list.header': '**Social Feeds**',
|
||||||
|
'feeds.list.empty': 'No feeds configured.',
|
||||||
|
'feeds.list.line': '`{id}` — {type}: {source} → {channel}',
|
||||||
|
'feeds.test.posted': 'Test successful: sent {count} message(s).',
|
||||||
|
'feeds.test.noItems': 'No new items found.',
|
||||||
|
'feeds.test.failed': 'Fetch failed: {reason}',
|
||||||
|
'feeds.test.skipped': 'Feed test skipped.',
|
||||||
|
'feeds.test.twitchSkipped':
|
||||||
|
'Twitch feed skipped: TWITCH_CLIENT_ID and TWITCH_CLIENT_SECRET are missing in the bot configuration.',
|
||||||
|
'feeds.error.not_found': 'Feed not found.',
|
||||||
|
'scheduler.created': 'Scheduled message created (ID: `{id}`). {timing}',
|
||||||
|
'scheduler.created.at': 'Sends at: {time}',
|
||||||
|
'scheduler.created.cron': 'Repeats: `{cron}`',
|
||||||
|
'scheduler.deleted': 'Scheduled message deleted.',
|
||||||
|
'scheduler.announce.sent': 'Announcement sent.',
|
||||||
|
'scheduler.list.header': '**Scheduled messages**',
|
||||||
|
'scheduler.list.empty': 'No scheduled messages.',
|
||||||
|
'scheduler.list.line': '`{id}` — {channel} — {timing} — {preview}',
|
||||||
|
'scheduler.list.cron': 'Cron `{cron}`',
|
||||||
|
'scheduler.list.embedPreview': '[Embed]',
|
||||||
|
'scheduler.error.missing_schedule_time': 'Provide `when` or `cron`.',
|
||||||
|
'scheduler.error.invalid_when': 'Invalid time. Use e.g. 1h, 2d, or an ISO date.',
|
||||||
|
'scheduler.error.invalid_cron': 'Invalid cron expression (5–6 fields).',
|
||||||
|
'scheduler.error.invalid_channel': 'Invalid channel.',
|
||||||
|
'scheduler.error.channel_unavailable': 'The bot cannot send messages in that channel.',
|
||||||
|
'scheduler.error.not_found': 'Scheduled message not found.',
|
||||||
|
'scheduler.error.content_required': 'Content or embed title/description is required.'
|
||||||
};
|
};
|
||||||
|
|
||||||
const locales: Record<Locale, Dictionary> = { de, en };
|
const locales: Record<Locale, Dictionary> = { de, en };
|
||||||
|
|||||||
14
packages/shared/src/phase5.test.ts
Normal file
14
packages/shared/src/phase5.test.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { applyCountTemplate, applyFeedTemplate } from './phase5.js';
|
||||||
|
|
||||||
|
describe('phase5 helpers', () => {
|
||||||
|
it('applies count template', () => {
|
||||||
|
expect(applyCountTemplate('Members: {count}', 42)).toBe('Members: 42');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies feed template', () => {
|
||||||
|
expect(applyFeedTemplate('{title} - {url}', { title: 'Hi', url: 'https://x.test' })).toBe(
|
||||||
|
'Hi - https://x.test'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
66
packages/shared/src/phase5.ts
Normal file
66
packages/shared/src/phase5.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const SocialFeedTypeSchema = z.enum(['TWITCH', 'YOUTUBE', 'RSS', 'REDDIT']);
|
||||||
|
export type SocialFeedType = z.infer<typeof SocialFeedTypeSchema>;
|
||||||
|
|
||||||
|
export const GuildBackupPayloadSchema = z.object({
|
||||||
|
version: z.literal(1),
|
||||||
|
guildName: z.string(),
|
||||||
|
roles: z.array(
|
||||||
|
z.object({
|
||||||
|
id: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
color: z.number(),
|
||||||
|
hoist: z.boolean(),
|
||||||
|
mentionable: z.boolean(),
|
||||||
|
permissions: z.string(),
|
||||||
|
position: z.number()
|
||||||
|
})
|
||||||
|
),
|
||||||
|
channels: z.array(
|
||||||
|
z.object({
|
||||||
|
id: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
type: z.number(),
|
||||||
|
parentId: z.string().nullable(),
|
||||||
|
position: z.number(),
|
||||||
|
topic: z.string().nullable().optional(),
|
||||||
|
nsfw: z.boolean().optional(),
|
||||||
|
rateLimitPerUser: z.number().optional(),
|
||||||
|
bitrate: z.number().optional(),
|
||||||
|
userLimit: z.number().optional(),
|
||||||
|
permissionOverwrites: z.array(
|
||||||
|
z.object({
|
||||||
|
id: z.string(),
|
||||||
|
type: z.number(),
|
||||||
|
allow: z.string(),
|
||||||
|
deny: z.string()
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
),
|
||||||
|
settings: z
|
||||||
|
.object({
|
||||||
|
name: z.string().optional(),
|
||||||
|
verificationLevel: z.number().optional(),
|
||||||
|
explicitContentFilter: z.number().optional(),
|
||||||
|
defaultMessageNotifications: z.number().optional(),
|
||||||
|
afkTimeout: z.number().optional()
|
||||||
|
})
|
||||||
|
.optional()
|
||||||
|
});
|
||||||
|
export type GuildBackupPayload = z.infer<typeof GuildBackupPayloadSchema>;
|
||||||
|
|
||||||
|
export function applyCountTemplate(template: string, count: number): string {
|
||||||
|
return template.replaceAll('{count}', String(count));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyFeedTemplate(
|
||||||
|
template: string,
|
||||||
|
vars: Record<string, string>
|
||||||
|
): string {
|
||||||
|
return Object.entries(vars).reduce(
|
||||||
|
(acc, [key, value]) => acc.replaceAll(`{${key}}`, value),
|
||||||
|
template
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user