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:
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 } });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user