- Updated scheduling logic to utilize the guild's timezone for cron expressions and one-time scheduling. - Introduced a new `getGuildTimezone` function to retrieve the timezone from guild settings. - Enhanced error handling for scheduling inputs, providing localized error messages for invalid cron expressions and missing schedule times. - Improved the WebUI to support a new timing mode selection (once vs. cron) and updated input fields accordingly. - Added localization entries for new scheduling-related messages in both English and German. - Refactored job scheduling and removal logic to ensure proper handling of scheduled jobs in the queue.
422 lines
12 KiB
TypeScript
422 lines
12 KiB
TypeScript
import {
|
|
PermissionFlagsBits,
|
|
type GuildTextBasedChannel,
|
|
type MessageCreateOptions,
|
|
type TextChannel
|
|
} from 'discord.js';
|
|
import {
|
|
DEFAULT_GUILD_TIMEZONE,
|
|
WelcomeEmbedSchema,
|
|
expandBracketChannelMentions,
|
|
parseMessageComponentsV2,
|
|
resolveScheduleRunAt,
|
|
t,
|
|
tf
|
|
} from '@nexumi/shared';
|
|
import type { ScheduledMessage } from '@prisma/client';
|
|
import { ensureGuild } from '../../guild.js';
|
|
import { buildEmbedFromPayload } from '../../lib/embed-payload.js';
|
|
import { buildComponentsV2Payload } from '../../lib/components-v2-payload.js';
|
|
import { safeRemoveBullJob } from '../../lib/safe-remove-job.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, timeZone: 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);
|
|
}
|
|
|
|
try {
|
|
const parsed = resolveScheduleRunAt(trimmed, timeZone);
|
|
if (parsed.getTime() <= Date.now()) {
|
|
throw new SchedulerError('invalid_when');
|
|
}
|
|
return parsed;
|
|
} catch {
|
|
throw new SchedulerError('invalid_when');
|
|
}
|
|
}
|
|
|
|
async function getGuildTimezone(prisma: BotContext['prisma'], guildId: string): Promise<string> {
|
|
const settings = await prisma.guildSettings.findUnique({
|
|
where: { guildId },
|
|
select: { timezone: true }
|
|
});
|
|
return settings?.timezone || DEFAULT_GUILD_TIMEZONE;
|
|
}
|
|
|
|
export function scheduleJobId(scheduleId: string): string {
|
|
return `schedule-${scheduleId}`;
|
|
}
|
|
|
|
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;
|
|
components?: unknown;
|
|
rolePingId: string | null;
|
|
id?: string;
|
|
}): MessageCreateOptions {
|
|
const componentsData = parseMessageComponentsV2(schedule.components);
|
|
if (componentsData && schedule.id) {
|
|
const componentsPayload = buildComponentsV2Payload(componentsData, {
|
|
source: 's',
|
|
ref: schedule.id,
|
|
renderText: expandBracketChannelMentions
|
|
});
|
|
if (componentsPayload) {
|
|
if (schedule.rolePingId) {
|
|
// Components V2 cannot mix classic content; role ping is skipped.
|
|
}
|
|
return componentsPayload;
|
|
}
|
|
}
|
|
|
|
const embedData = parseStoredEmbed(schedule.embed);
|
|
const embed = buildEmbedFromPayload(embedData, {
|
|
renderText: expandBracketChannelMentions
|
|
});
|
|
|
|
const baseContent = expandBracketChannelMentions(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 (embed) {
|
|
payload.embeds = [embed];
|
|
}
|
|
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,
|
|
timeZone: string
|
|
): Promise<string> {
|
|
const jobId = scheduleJobId(schedule.id);
|
|
|
|
if (cron) {
|
|
// BullMQ 5 job schedulers honor guild timezone for cron patterns.
|
|
await scheduleQueue.upsertJobScheduler(
|
|
jobId,
|
|
{ pattern: cron, tz: timeZone },
|
|
{
|
|
name: 'scheduleSend',
|
|
data: { scheduleId: schedule.id },
|
|
opts: {
|
|
removeOnComplete: true,
|
|
removeOnFail: 50
|
|
}
|
|
}
|
|
);
|
|
return jobId;
|
|
}
|
|
|
|
if (!runAt) {
|
|
throw new SchedulerError('missing_schedule_time');
|
|
}
|
|
|
|
const job = await scheduleQueue.add(
|
|
'scheduleSend',
|
|
{ scheduleId: schedule.id },
|
|
{
|
|
jobId,
|
|
removeOnComplete: true,
|
|
removeOnFail: 50,
|
|
delay: Math.max(0, runAt.getTime() - Date.now())
|
|
}
|
|
);
|
|
return String(job.id);
|
|
}
|
|
|
|
/**
|
|
* Removes a one-shot delayed job and/or cron job scheduler. Locked (active)
|
|
* jobs cannot be removed — do not call this from inside the scheduleSend
|
|
* worker for the job that is currently running.
|
|
*/
|
|
export async function removeScheduleJob(
|
|
jobId: string | null,
|
|
options?: { scheduleId?: string; cron?: string | null }
|
|
): Promise<void> {
|
|
const schedulerId = options?.scheduleId ? scheduleJobId(options.scheduleId) : jobId;
|
|
if (schedulerId && options?.cron) {
|
|
try {
|
|
await scheduleQueue.removeJobScheduler(schedulerId);
|
|
} catch (error) {
|
|
logger.warn({ schedulerId, error }, 'Failed to remove schedule job scheduler');
|
|
}
|
|
}
|
|
|
|
const candidateIds = new Set<string>();
|
|
if (jobId) {
|
|
candidateIds.add(jobId);
|
|
}
|
|
if (schedulerId) {
|
|
candidateIds.add(schedulerId);
|
|
}
|
|
|
|
for (const id of candidateIds) {
|
|
const job = await scheduleQueue.getJob(id);
|
|
await safeRemoveBullJob(job, { label: 'schedule', jobId: id });
|
|
}
|
|
}
|
|
|
|
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;
|
|
const timeZone = await getGuildTimezone(context.prisma, input.guildId);
|
|
|
|
if (cron) {
|
|
if (!isValidCron(cron)) {
|
|
throw new SchedulerError('invalid_cron');
|
|
}
|
|
cronPattern = cron;
|
|
} else if (whenInput) {
|
|
runAt = parseScheduleWhen(whenInput, timeZone);
|
|
}
|
|
|
|
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, timeZone);
|
|
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.components
|
|
? t(locale, 'scheduler.list.componentsPreview')
|
|
: 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, { scheduleId: match.id, cron: match.cron });
|
|
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 && !payload.components?.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;
|
|
}
|
|
|
|
// One-shot: delete the DB row only. Do not remove the active BullMQ job
|
|
// from inside its own worker (locked remove throws → retry → duplicate send).
|
|
// removeOnComplete on enqueue cleans the job up after the processor returns.
|
|
if (!schedule.cron) {
|
|
await context.prisma.scheduledMessage.delete({ where: { id: scheduleId } });
|
|
}
|
|
}
|