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:
@@ -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?
|
||||
tempVoiceConfig TempVoiceConfig?
|
||||
tempVoiceChannels TempVoiceChannel[]
|
||||
statsConfig StatsConfig?
|
||||
inviteStats InviteStat[]
|
||||
inviteJoins InviteJoin[]
|
||||
activityStats ActivityStat[]
|
||||
socialFeeds SocialFeed[]
|
||||
scheduledMessages ScheduledMessage[]
|
||||
guildBackups GuildBackup[]
|
||||
}
|
||||
|
||||
model GuildSettings {
|
||||
@@ -639,3 +646,116 @@ model TempVoiceChannel {
|
||||
@@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 { birthdayCommands } from './modules/birthdays/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';
|
||||
|
||||
const commands: SlashCommand[] = [
|
||||
@@ -42,7 +46,11 @@ const commands: SlashCommand[] = [
|
||||
...starboardCommands,
|
||||
...suggestionsCommands,
|
||||
...birthdayCommands,
|
||||
...tempVoiceCommands
|
||||
...tempVoiceCommands,
|
||||
...statsCommands,
|
||||
...feedsCommands,
|
||||
...scheduleCommands,
|
||||
...guildBackupCommands
|
||||
];
|
||||
const map = new Map(commands.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'),
|
||||
METRICS_TOKEN: z.string().optional(),
|
||||
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);
|
||||
|
||||
@@ -62,6 +62,8 @@ import {
|
||||
isTempVoiceModal,
|
||||
registerTempVoiceEvents
|
||||
} 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');
|
||||
|
||||
@@ -108,6 +110,7 @@ if (!isShard) {
|
||||
registerTagEvents(client, context);
|
||||
registerStarboardEvents(context);
|
||||
registerTempVoiceEvents(context);
|
||||
registerStatsEvents(context);
|
||||
|
||||
client.on(Events.ClientReady, async () => {
|
||||
logger.info({ tag: client.user?.tag }, 'Bot ready');
|
||||
@@ -201,6 +204,11 @@ if (!isShard) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (interaction.isButton() && isGuildBackupButton(interaction.customId)) {
|
||||
await handleGuildBackupButton(interaction, context);
|
||||
return;
|
||||
}
|
||||
|
||||
if (interaction.isModalSubmit() && isEmbedModal(interaction.customId)) {
|
||||
await handleEmbedModal(interaction, context);
|
||||
return;
|
||||
|
||||
@@ -15,6 +15,10 @@ import { expireSelfRole } from './modules/selfroles/service.js';
|
||||
import { endGiveaway } from './modules/giveaways/index.js';
|
||||
import { closeInactiveTickets } from './modules/tickets/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 {
|
||||
automodQueue,
|
||||
automodQueueName,
|
||||
@@ -22,9 +26,13 @@ import {
|
||||
backupQueueName,
|
||||
birthdayQueue,
|
||||
birthdayQueueName,
|
||||
feedsQueue,
|
||||
feedsQueueName,
|
||||
giveawayQueueName,
|
||||
guildBackupQueueName,
|
||||
moderationQueueName,
|
||||
reminderQueueName,
|
||||
scheduleQueueName,
|
||||
ticketQueue,
|
||||
ticketQueueName,
|
||||
verificationQueueName
|
||||
@@ -213,6 +221,50 @@ export function startWorkers(context: BotContext): Worker[] {
|
||||
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 [
|
||||
moderationWorker,
|
||||
backupWorker,
|
||||
@@ -221,7 +273,11 @@ export function startWorkers(context: BotContext): Worker[] {
|
||||
reminderWorker,
|
||||
ticketWorker,
|
||||
giveawayWorker,
|
||||
birthdayWorker
|
||||
birthdayWorker,
|
||||
statsWorker,
|
||||
feedsWorker,
|
||||
scheduleWorker,
|
||||
guildBackupWorker
|
||||
];
|
||||
}
|
||||
|
||||
@@ -267,6 +323,18 @@ export async function ensureRecurringJobs(): Promise<void> {
|
||||
removeOnFail: 20
|
||||
}
|
||||
);
|
||||
|
||||
await ensureStatsRecurringJobs();
|
||||
|
||||
await feedsQueue.add(
|
||||
'pollFeeds',
|
||||
{},
|
||||
{
|
||||
repeat: { pattern: '*/5 * * * *' },
|
||||
removeOnComplete: 20,
|
||||
removeOnFail: 20
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
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 { voiceCommandData } from './command-definitions.js';
|
||||
import {
|
||||
buildControlPanelEmbed,
|
||||
buildControlPanelRows,
|
||||
claimTempChannel,
|
||||
fetchCategory,
|
||||
getTempVoiceChannelForMember,
|
||||
@@ -15,6 +13,7 @@ import {
|
||||
kickFromTempChannel,
|
||||
lockTempChannel,
|
||||
renameTempChannel,
|
||||
sendControlPanelToChannel,
|
||||
setTempChannelLimit,
|
||||
transferTempChannelOwnership,
|
||||
updateTempVoiceConfig
|
||||
@@ -93,11 +92,8 @@ const voiceCommand: SlashCommand = {
|
||||
return;
|
||||
}
|
||||
|
||||
await interaction.reply({
|
||||
embeds: [buildControlPanelEmbed(owned.channel.name, locale)],
|
||||
components: buildControlPanelRows(owned.channel.id, locale),
|
||||
ephemeral: true
|
||||
});
|
||||
await sendControlPanelToChannel(owned.channel, locale);
|
||||
await interaction.reply({ content: t(locale, 'tempvoice.panel.sent'), ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -112,16 +112,14 @@ export function buildControlPanelEmbed(channelName: string, locale: Locale): Emb
|
||||
.setColor(0x6366f1);
|
||||
}
|
||||
|
||||
export async function sendOwnerControlPanelDm(
|
||||
member: GuildMember,
|
||||
channelId: string,
|
||||
channelName: string,
|
||||
export async function sendControlPanelToChannel(
|
||||
channel: VoiceChannel,
|
||||
locale: Locale
|
||||
): Promise<void> {
|
||||
const embed = buildControlPanelEmbed(channelName, locale);
|
||||
const rows = buildControlPanelRows(channelId, locale);
|
||||
await member.send({ embeds: [embed], components: rows }).catch((error) => {
|
||||
logger.debug({ error, userId: member.id }, 'Could not DM temp voice control panel');
|
||||
const embed = buildControlPanelEmbed(channel.name, locale);
|
||||
const rows = buildControlPanelRows(channel.id, locale);
|
||||
await channel.send({ embeds: [embed], components: rows }).catch((error) => {
|
||||
logger.debug({ error, channelId: channel.id }, 'Could not send temp voice control panel');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -160,7 +158,8 @@ export async function createTempVoiceChannel(
|
||||
PermissionFlagsBits.Connect,
|
||||
PermissionFlagsBits.ViewChannel,
|
||||
PermissionFlagsBits.ManageChannels,
|
||||
PermissionFlagsBits.MoveMembers
|
||||
PermissionFlagsBits.MoveMembers,
|
||||
PermissionFlagsBits.SendMessages
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -170,7 +169,8 @@ export async function createTempVoiceChannel(
|
||||
PermissionFlagsBits.Connect,
|
||||
PermissionFlagsBits.ViewChannel,
|
||||
PermissionFlagsBits.ManageChannels,
|
||||
PermissionFlagsBits.MoveMembers
|
||||
PermissionFlagsBits.MoveMembers,
|
||||
PermissionFlagsBits.SendMessages
|
||||
]
|
||||
}
|
||||
],
|
||||
@@ -197,7 +197,7 @@ export async function createTempVoiceChannel(
|
||||
|
||||
const { getGuildLocale } = await import('../../i18n.js');
|
||||
const locale = await getGuildLocale(context.prisma, member.guild.id);
|
||||
await sendOwnerControlPanelDm(member, channel.id, channel.name, locale);
|
||||
await sendControlPanelToChannel(channel, locale);
|
||||
return channel;
|
||||
}
|
||||
|
||||
@@ -284,10 +284,11 @@ export async function transferTempChannelOwnership(
|
||||
Connect: true,
|
||||
ViewChannel: 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(
|
||||
|
||||
@@ -17,3 +17,11 @@ export const ticketQueueName = 'tickets';
|
||||
export const ticketQueue = new Queue(ticketQueueName, { connection: redis });
|
||||
export const birthdayQueueName = 'birthdays';
|
||||
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 });
|
||||
|
||||
Reference in New Issue
Block a user