deploy #2
@@ -4,11 +4,20 @@ import {
|
|||||||
type MessageCreateOptions,
|
type MessageCreateOptions,
|
||||||
type TextChannel
|
type TextChannel
|
||||||
} from 'discord.js';
|
} from 'discord.js';
|
||||||
import { WelcomeEmbedSchema, expandBracketChannelMentions, parseMessageComponentsV2, t, tf } from '@nexumi/shared';
|
import {
|
||||||
|
DEFAULT_GUILD_TIMEZONE,
|
||||||
|
WelcomeEmbedSchema,
|
||||||
|
expandBracketChannelMentions,
|
||||||
|
parseMessageComponentsV2,
|
||||||
|
resolveScheduleRunAt,
|
||||||
|
t,
|
||||||
|
tf
|
||||||
|
} from '@nexumi/shared';
|
||||||
import type { ScheduledMessage } from '@prisma/client';
|
import type { ScheduledMessage } from '@prisma/client';
|
||||||
import { ensureGuild } from '../../guild.js';
|
import { ensureGuild } from '../../guild.js';
|
||||||
import { buildEmbedFromPayload } from '../../lib/embed-payload.js';
|
import { buildEmbedFromPayload } from '../../lib/embed-payload.js';
|
||||||
import { buildComponentsV2Payload } from '../../lib/components-v2-payload.js';
|
import { buildComponentsV2Payload } from '../../lib/components-v2-payload.js';
|
||||||
|
import { safeRemoveBullJob } from '../../lib/safe-remove-job.js';
|
||||||
import { logger } from '../../logger.js';
|
import { logger } from '../../logger.js';
|
||||||
import { scheduleQueue } from '../../queues.js';
|
import { scheduleQueue } from '../../queues.js';
|
||||||
import type { BotContext } from '../../types.js';
|
import type { BotContext } from '../../types.js';
|
||||||
@@ -36,7 +45,7 @@ export type ScheduleCreateInput = AnnouncementInput & {
|
|||||||
cron?: string | null;
|
cron?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
function parseScheduleWhen(input: string): Date {
|
function parseScheduleWhen(input: string, timeZone: string): Date {
|
||||||
const trimmed = input.trim();
|
const trimmed = input.trim();
|
||||||
if (/^\d+[smhd]$/i.test(trimmed)) {
|
if (/^\d+[smhd]$/i.test(trimmed)) {
|
||||||
const ms = parseDuration(trimmed);
|
const ms = parseDuration(trimmed);
|
||||||
@@ -46,11 +55,27 @@ function parseScheduleWhen(input: string): Date {
|
|||||||
return new Date(Date.now() + ms);
|
return new Date(Date.now() + ms);
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsed = Date.parse(trimmed);
|
try {
|
||||||
if (Number.isNaN(parsed) || parsed <= Date.now()) {
|
const parsed = resolveScheduleRunAt(trimmed, timeZone);
|
||||||
|
if (parsed.getTime() <= Date.now()) {
|
||||||
|
throw new SchedulerError('invalid_when');
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
} catch {
|
||||||
throw new SchedulerError('invalid_when');
|
throw new SchedulerError('invalid_when');
|
||||||
}
|
}
|
||||||
return new Date(parsed);
|
}
|
||||||
|
|
||||||
|
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 {
|
function isValidCron(pattern: string): boolean {
|
||||||
@@ -157,53 +182,74 @@ async function assertSendableChannel(
|
|||||||
async function enqueueScheduleJob(
|
async function enqueueScheduleJob(
|
||||||
schedule: ScheduledMessage,
|
schedule: ScheduledMessage,
|
||||||
cron: string | null,
|
cron: string | null,
|
||||||
runAt: Date | null
|
runAt: Date | null,
|
||||||
|
timeZone: string
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const jobOptions: {
|
const jobId = scheduleJobId(schedule.id);
|
||||||
jobId: string;
|
|
||||||
removeOnComplete: boolean;
|
|
||||||
removeOnFail: number;
|
|
||||||
delay?: number;
|
|
||||||
repeat?: { pattern: string };
|
|
||||||
} = {
|
|
||||||
jobId: `schedule-${schedule.id}`,
|
|
||||||
removeOnComplete: true,
|
|
||||||
removeOnFail: 50
|
|
||||||
};
|
|
||||||
|
|
||||||
if (cron) {
|
if (cron) {
|
||||||
jobOptions.repeat = { pattern: cron };
|
// BullMQ 5 job schedulers honor guild timezone for cron patterns.
|
||||||
} else if (runAt) {
|
await scheduleQueue.upsertJobScheduler(
|
||||||
jobOptions.delay = Math.max(0, runAt.getTime() - Date.now());
|
jobId,
|
||||||
|
{ pattern: cron, tz: timeZone },
|
||||||
|
{
|
||||||
|
name: 'scheduleSend',
|
||||||
|
data: { scheduleId: schedule.id },
|
||||||
|
opts: {
|
||||||
|
removeOnComplete: true,
|
||||||
|
removeOnFail: 50
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return jobId;
|
||||||
}
|
}
|
||||||
|
|
||||||
const job = await scheduleQueue.add('scheduleSend', { scheduleId: schedule.id }, jobOptions);
|
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);
|
return String(job.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Removes a scheduled/failed job if present. Locked (active) jobs cannot be
|
* Removes a one-shot delayed job and/or cron job scheduler. Locked (active)
|
||||||
* removed — do not call this from inside the scheduleSend worker for the
|
* jobs cannot be removed — do not call this from inside the scheduleSend
|
||||||
* job that is currently running.
|
* worker for the job that is currently running.
|
||||||
*/
|
*/
|
||||||
export async function removeScheduleJob(jobId: string | null): Promise<void> {
|
export async function removeScheduleJob(
|
||||||
if (!jobId) {
|
jobId: string | null,
|
||||||
return;
|
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 job = await scheduleQueue.getJob(jobId);
|
const candidateIds = new Set<string>();
|
||||||
if (!job) {
|
if (jobId) {
|
||||||
return;
|
candidateIds.add(jobId);
|
||||||
}
|
}
|
||||||
try {
|
if (schedulerId) {
|
||||||
await job.remove();
|
candidateIds.add(schedulerId);
|
||||||
} catch (error) {
|
}
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
if (message.includes('locked') || message.includes('could not be removed')) {
|
for (const id of candidateIds) {
|
||||||
logger.warn({ jobId, error }, 'Schedule job is locked; skipping remove');
|
const job = await scheduleQueue.getJob(id);
|
||||||
return;
|
await safeRemoveBullJob(job, { label: 'schedule', jobId: id });
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,6 +281,7 @@ export async function createScheduledMessage(
|
|||||||
|
|
||||||
let runAt: Date | null = null;
|
let runAt: Date | null = null;
|
||||||
let cronPattern: string | null = null;
|
let cronPattern: string | null = null;
|
||||||
|
const timeZone = await getGuildTimezone(context.prisma, input.guildId);
|
||||||
|
|
||||||
if (cron) {
|
if (cron) {
|
||||||
if (!isValidCron(cron)) {
|
if (!isValidCron(cron)) {
|
||||||
@@ -242,7 +289,7 @@ export async function createScheduledMessage(
|
|||||||
}
|
}
|
||||||
cronPattern = cron;
|
cronPattern = cron;
|
||||||
} else if (whenInput) {
|
} else if (whenInput) {
|
||||||
runAt = parseScheduleWhen(whenInput);
|
runAt = parseScheduleWhen(whenInput, timeZone);
|
||||||
}
|
}
|
||||||
|
|
||||||
await assertSendableChannel(context, input.channelId);
|
await assertSendableChannel(context, input.channelId);
|
||||||
@@ -262,7 +309,7 @@ export async function createScheduledMessage(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const jobId = await enqueueScheduleJob(schedule, cronPattern, runAt);
|
const jobId = await enqueueScheduleJob(schedule, cronPattern, runAt, timeZone);
|
||||||
return context.prisma.scheduledMessage.update({
|
return context.prisma.scheduledMessage.update({
|
||||||
where: { id: schedule.id },
|
where: { id: schedule.id },
|
||||||
data: { jobId }
|
data: { jobId }
|
||||||
@@ -322,7 +369,7 @@ export async function deleteScheduledMessage(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
await removeScheduleJob(match.jobId);
|
await removeScheduleJob(match.jobId, { scheduleId: match.id, cron: match.cron });
|
||||||
await context.prisma.scheduledMessage.delete({ where: { id: match.id } });
|
await context.prisma.scheduledMessage.delete({ where: { id: match.id } });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
--background: #f8f8fb;
|
--background: #f8f8fb;
|
||||||
--foreground: #111116;
|
--foreground: #111116;
|
||||||
--card: #ffffff;
|
--card: #ffffff;
|
||||||
@@ -26,6 +27,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
|
color-scheme: dark;
|
||||||
--background: #0a0a0d;
|
--background: #0a0a0d;
|
||||||
--foreground: #e6e6ea;
|
--foreground: #e6e6ea;
|
||||||
--card: #131318;
|
--card: #131318;
|
||||||
|
|||||||
@@ -29,10 +29,12 @@ import { Textarea } from '@/components/ui/textarea';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
type ScheduleContentMode = 'text' | 'embed' | 'components_v2';
|
type ScheduleContentMode = 'text' | 'embed' | 'components_v2';
|
||||||
|
type ScheduleTimingMode = 'once' | 'cron';
|
||||||
|
|
||||||
const EMPTY_DRAFT = {
|
const EMPTY_DRAFT = {
|
||||||
channelId: '',
|
channelId: '',
|
||||||
contentMode: 'text' as ScheduleContentMode,
|
contentMode: 'text' as ScheduleContentMode,
|
||||||
|
timingMode: 'once' as ScheduleTimingMode,
|
||||||
content: '',
|
content: '',
|
||||||
embed: null as WelcomeEmbed | null,
|
embed: null as WelcomeEmbed | null,
|
||||||
components: null as MessageComponentsV2 | null,
|
components: null as MessageComponentsV2 | null,
|
||||||
@@ -76,6 +78,19 @@ function schedulePreview(schedule: ScheduledMessageDashboard, t: (key: string) =
|
|||||||
return t('modulePages.scheduler.noContent');
|
return t('modulePages.scheduler.noContent');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function scheduleErrorMessage(code: string | undefined, t: (key: string) => string): string {
|
||||||
|
if (code === 'invalid_cron') {
|
||||||
|
return t('modulePages.scheduler.errors.invalidCron');
|
||||||
|
}
|
||||||
|
if (code === 'invalid_when') {
|
||||||
|
return t('modulePages.scheduler.errors.invalidWhen');
|
||||||
|
}
|
||||||
|
if (code === 'missing_schedule_time') {
|
||||||
|
return t('modulePages.scheduler.errors.missingScheduleTime');
|
||||||
|
}
|
||||||
|
return code?.trim() ? code : t('common.saveError');
|
||||||
|
}
|
||||||
|
|
||||||
interface SchedulerManagerProps {
|
interface SchedulerManagerProps {
|
||||||
guildId: string;
|
guildId: string;
|
||||||
initialSchedules: ScheduledMessageDashboard[];
|
initialSchedules: ScheduledMessageDashboard[];
|
||||||
@@ -98,7 +113,10 @@ export function SchedulerManager({ guildId, initialSchedules }: SchedulerManager
|
|||||||
(draft.contentMode === 'embed' && (embedHasContent(embed) || embed?.color !== undefined)) ||
|
(draft.contentMode === 'embed' && (embedHasContent(embed) || embed?.color !== undefined)) ||
|
||||||
(draft.contentMode === 'components_v2' && componentsV2HasContent(components));
|
(draft.contentMode === 'components_v2' && componentsV2HasContent(components));
|
||||||
|
|
||||||
if (!draft.channelId.trim() || !hasContent || (!draft.cron.trim() && !draft.runAt)) {
|
const cron = draft.timingMode === 'cron' ? draft.cron.trim() : '';
|
||||||
|
const runAt = draft.timingMode === 'once' ? draft.runAt.trim() : '';
|
||||||
|
|
||||||
|
if (!draft.channelId.trim() || !hasContent || (!cron && !runAt)) {
|
||||||
toast.error(t('modulePages.scheduler.formIncomplete'));
|
toast.error(t('modulePages.scheduler.formIncomplete'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -114,15 +132,16 @@ export function SchedulerManager({ guildId, initialSchedules }: SchedulerManager
|
|||||||
embed,
|
embed,
|
||||||
components,
|
components,
|
||||||
rolePingId: draft.rolePingId.trim() || undefined,
|
rolePingId: draft.rolePingId.trim() || undefined,
|
||||||
cron: draft.cron.trim() || null,
|
// Send naive datetime-local; server interprets it in the guild timezone.
|
||||||
runAt: draft.cron.trim() ? null : draft.runAt ? new Date(draft.runAt).toISOString() : null
|
cron: cron || null,
|
||||||
|
runAt: cron ? null : runAt || null
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
const body = (await response.json().catch(() => null)) as
|
const body = (await response.json().catch(() => null)) as
|
||||||
| (ScheduledMessageDashboard & { error?: string })
|
| (ScheduledMessageDashboard & { error?: string })
|
||||||
| null;
|
| null;
|
||||||
if (!response.ok || !body) {
|
if (!response.ok || !body) {
|
||||||
toast.error(body?.error ?? t('common.saveError'));
|
toast.error(scheduleErrorMessage(body?.error, t));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSchedules((prev) => [...prev, body]);
|
setSchedules((prev) => [...prev, body]);
|
||||||
@@ -179,24 +198,55 @@ export function SchedulerManager({ guildId, initialSchedules }: SchedulerManager
|
|||||||
placeholder={t('common.optional')}
|
placeholder={t('common.optional')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t('modulePages.scheduler.timingMode')}</Label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{(['once', 'cron'] as ScheduleTimingMode[]).map((mode) => (
|
||||||
|
<Button
|
||||||
|
key={mode}
|
||||||
|
type="button"
|
||||||
|
variant={draft.timingMode === mode ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
className={cn(draft.timingMode !== mode && 'text-muted-foreground')}
|
||||||
|
onClick={() =>
|
||||||
|
setDraft((prev) => ({
|
||||||
|
...prev,
|
||||||
|
timingMode: mode,
|
||||||
|
cron: mode === 'cron' ? prev.cron : '',
|
||||||
|
runAt: mode === 'once' ? prev.runAt : ''
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t(`modulePages.scheduler.timingModes.${mode}`)}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{draft.timingMode === 'cron' ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t('modulePages.scheduler.cron')}</Label>
|
<Label htmlFor="scheduler-cron">{t('modulePages.scheduler.cron')}</Label>
|
||||||
<Input
|
<Input
|
||||||
|
id="scheduler-cron"
|
||||||
value={draft.cron}
|
value={draft.cron}
|
||||||
onChange={(event) => setDraft((prev) => ({ ...prev, cron: event.target.value }))}
|
onChange={(event) => setDraft((prev) => ({ ...prev, cron: event.target.value }))}
|
||||||
placeholder="0 9 * * *"
|
placeholder="0 9 * * *"
|
||||||
|
autoComplete="off"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t('modulePages.scheduler.runAt')}</Label>
|
<Label htmlFor="scheduler-run-at">{t('modulePages.scheduler.runAt')}</Label>
|
||||||
<Input
|
<Input
|
||||||
|
id="scheduler-run-at"
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
value={draft.runAt}
|
value={draft.runAt}
|
||||||
disabled={Boolean(draft.cron.trim())}
|
|
||||||
onChange={(event) => setDraft((prev) => ({ ...prev, runAt: event.target.value }))}
|
onChange={(event) => setDraft((prev) => ({ ...prev, runAt: event.target.value }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t('modulePages.scheduler.contentMode')}</Label>
|
<Label>{t('modulePages.scheduler.contentMode')}</Label>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(({ className, type,
|
|||||||
type={type}
|
type={type}
|
||||||
data-slot="input"
|
data-slot="input"
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50',
|
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm text-foreground shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
|
DEFAULT_GUILD_TIMEZONE,
|
||||||
MessageComponentsV2Schema,
|
MessageComponentsV2Schema,
|
||||||
WelcomeEmbedSchema,
|
WelcomeEmbedSchema,
|
||||||
|
resolveScheduleRunAt,
|
||||||
type MessageComponentsV2,
|
type MessageComponentsV2,
|
||||||
type ScheduledMessageDashboard,
|
type ScheduledMessageDashboard,
|
||||||
type ScheduledMessageDashboardCreate,
|
type ScheduledMessageDashboardCreate,
|
||||||
@@ -42,6 +44,10 @@ function toDashboard(schedule: ScheduledMessage): ScheduledMessageDashboard {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function scheduleJobId(scheduleId: string): string {
|
||||||
|
return `schedule-${scheduleId}`;
|
||||||
|
}
|
||||||
|
|
||||||
export async function listScheduledMessagesDashboard(guildId: string): Promise<ScheduledMessageDashboard[]> {
|
export async function listScheduledMessagesDashboard(guildId: string): Promise<ScheduledMessageDashboard[]> {
|
||||||
const schedules = await prisma.scheduledMessage.findMany({
|
const schedules = await prisma.scheduledMessage.findMany({
|
||||||
where: { guildId, enabled: true },
|
where: { guildId, enabled: true },
|
||||||
@@ -56,6 +62,14 @@ function isValidCron(pattern: string): boolean {
|
|||||||
return parts.length >= 5 && parts.length <= 6;
|
return parts.length >= 5 && parts.length <= 6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getGuildTimezone(guildId: string): Promise<string> {
|
||||||
|
const settings = await prisma.guildSettings.findUnique({
|
||||||
|
where: { guildId },
|
||||||
|
select: { timezone: true }
|
||||||
|
});
|
||||||
|
return settings?.timezone || DEFAULT_GUILD_TIMEZONE;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mirrors `apps/bot/src/modules/scheduler/service.ts#createScheduledMessage`
|
* Mirrors `apps/bot/src/modules/scheduler/service.ts#createScheduledMessage`
|
||||||
* job scheduling exactly (same queue, same `scheduleSend` job name, same
|
* job scheduling exactly (same queue, same `scheduleSend` job name, same
|
||||||
@@ -77,7 +91,15 @@ export async function createScheduledMessageDashboard(
|
|||||||
throw new SchedulerValidationError('invalid_cron');
|
throw new SchedulerValidationError('invalid_cron');
|
||||||
}
|
}
|
||||||
|
|
||||||
const runAt = !cron && input.runAt ? new Date(input.runAt) : null;
|
const timeZone = await getGuildTimezone(guildId);
|
||||||
|
let runAt: Date | null = null;
|
||||||
|
if (!cron && input.runAt) {
|
||||||
|
try {
|
||||||
|
runAt = resolveScheduleRunAt(input.runAt, timeZone);
|
||||||
|
} catch {
|
||||||
|
throw new SchedulerValidationError('invalid_when');
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!cron && !runAt) {
|
if (!cron && !runAt) {
|
||||||
throw new SchedulerValidationError('missing_schedule_time');
|
throw new SchedulerValidationError('missing_schedule_time');
|
||||||
}
|
}
|
||||||
@@ -102,27 +124,38 @@ export async function createScheduledMessageDashboard(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const jobOptions: {
|
const jobId = scheduleJobId(schedule.id);
|
||||||
jobId: string;
|
const queue = getScheduleQueue();
|
||||||
removeOnComplete: boolean;
|
|
||||||
removeOnFail: number;
|
|
||||||
delay?: number;
|
|
||||||
repeat?: { pattern: string };
|
|
||||||
} = {
|
|
||||||
jobId: `schedule-${schedule.id}`,
|
|
||||||
removeOnComplete: true,
|
|
||||||
removeOnFail: 50
|
|
||||||
};
|
|
||||||
if (cron) {
|
if (cron) {
|
||||||
jobOptions.repeat = { pattern: cron };
|
await queue.upsertJobScheduler(
|
||||||
|
jobId,
|
||||||
|
{ pattern: cron, tz: timeZone },
|
||||||
|
{
|
||||||
|
name: 'scheduleSend',
|
||||||
|
data: { scheduleId: schedule.id },
|
||||||
|
opts: {
|
||||||
|
removeOnComplete: true,
|
||||||
|
removeOnFail: 50
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
} else if (runAt) {
|
} else if (runAt) {
|
||||||
jobOptions.delay = Math.max(0, runAt.getTime() - Date.now());
|
await queue.add(
|
||||||
|
'scheduleSend',
|
||||||
|
{ scheduleId: schedule.id },
|
||||||
|
{
|
||||||
|
jobId,
|
||||||
|
removeOnComplete: true,
|
||||||
|
removeOnFail: 50,
|
||||||
|
delay: Math.max(0, runAt.getTime() - Date.now())
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const job = await getScheduleQueue().add('scheduleSend', { scheduleId: schedule.id }, jobOptions);
|
|
||||||
const updated = await prisma.scheduledMessage.update({
|
const updated = await prisma.scheduledMessage.update({
|
||||||
where: { id: schedule.id },
|
where: { id: schedule.id },
|
||||||
data: { jobId: String(job.id) }
|
data: { jobId }
|
||||||
});
|
});
|
||||||
|
|
||||||
return toDashboard(updated);
|
return toDashboard(updated);
|
||||||
@@ -134,16 +167,34 @@ export async function deleteScheduledMessageDashboard(guildId: string, scheduleI
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const queue = getScheduleQueue();
|
||||||
|
const schedulerId = scheduleJobId(existing.id);
|
||||||
|
|
||||||
|
if (existing.cron) {
|
||||||
|
try {
|
||||||
|
await queue.removeJobScheduler(schedulerId);
|
||||||
|
} catch {
|
||||||
|
// Scheduler may already be gone (legacy repeatables / one-shots).
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidateIds = new Set<string>();
|
||||||
|
candidateIds.add(schedulerId);
|
||||||
if (existing.jobId) {
|
if (existing.jobId) {
|
||||||
const job = await getScheduleQueue().getJob(existing.jobId);
|
candidateIds.add(existing.jobId);
|
||||||
if (job) {
|
}
|
||||||
try {
|
|
||||||
await job.remove();
|
for (const id of candidateIds) {
|
||||||
} catch (error) {
|
const job = await queue.getJob(id);
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
if (!job) {
|
||||||
if (!message.includes('locked') && !message.includes('could not be removed')) {
|
continue;
|
||||||
throw error;
|
}
|
||||||
}
|
try {
|
||||||
|
await job.remove();
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
if (!message.includes('locked') && !message.includes('could not be removed')) {
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -933,6 +933,11 @@
|
|||||||
"rolePingId": "Zu erwähnende Rolle",
|
"rolePingId": "Zu erwähnende Rolle",
|
||||||
"cron": "Cron-Ausdruck",
|
"cron": "Cron-Ausdruck",
|
||||||
"runAt": "Ausführen am",
|
"runAt": "Ausführen am",
|
||||||
|
"timingMode": "Zeitplan",
|
||||||
|
"timingModes": {
|
||||||
|
"once": "Einmalig",
|
||||||
|
"cron": "Wiederkehrend (Cron)"
|
||||||
|
},
|
||||||
"content": "Nachrichtentext",
|
"content": "Nachrichtentext",
|
||||||
"embed": "Embed (optional)",
|
"embed": "Embed (optional)",
|
||||||
"components": "Components-V2-Layout",
|
"components": "Components-V2-Layout",
|
||||||
@@ -943,10 +948,15 @@
|
|||||||
"components_v2": "Components V2"
|
"components_v2": "Components V2"
|
||||||
},
|
},
|
||||||
"componentsPreview": "(Components V2)",
|
"componentsPreview": "(Components V2)",
|
||||||
"cronHint": "Cron-Ausdruck für wiederkehrende Nachrichten ODER ein einmaliges Datum/Uhrzeit — nicht beides. Wähle Text, Embed oder Components V2 (gegenseitig ausgeschlossen).",
|
"cronHint": "Einmalige Zeit oder Cron-Wiederholung — bezogen auf die Server-Zeitzone unter Allgemeine Einstellungen. Wähle Text, Embed oder Components V2 (gegenseitig ausgeschlossen).",
|
||||||
"create": "Nachricht planen",
|
"create": "Nachricht planen",
|
||||||
"created": "Nachricht geplant.",
|
"created": "Nachricht geplant.",
|
||||||
"formIncomplete": "Bitte Kanal, Nachrichtenformat mit Inhalt sowie Cron-Ausdruck oder Datum/Uhrzeit ausfüllen.",
|
"formIncomplete": "Bitte Kanal, Nachrichtenformat mit Inhalt sowie Cron-Ausdruck oder Datum/Uhrzeit ausfüllen.",
|
||||||
|
"errors": {
|
||||||
|
"invalidCron": "Ungültiger Cron-Ausdruck (5–6 Felder, z. B. 0 9 * * *).",
|
||||||
|
"invalidWhen": "Ungültiges Datum/Uhrzeit.",
|
||||||
|
"missingScheduleTime": "Bitte Cron-Ausdruck oder Datum/Uhrzeit angeben."
|
||||||
|
},
|
||||||
"confirmDelete": "Diesen Zeitplan wirklich löschen?",
|
"confirmDelete": "Diesen Zeitplan wirklich löschen?",
|
||||||
"listTitle": "Geplante Nachrichten",
|
"listTitle": "Geplante Nachrichten",
|
||||||
"listDescription": "Anstehende und wiederkehrende geplante Nachrichten.",
|
"listDescription": "Anstehende und wiederkehrende geplante Nachrichten.",
|
||||||
|
|||||||
@@ -933,6 +933,11 @@
|
|||||||
"rolePingId": "Role to ping",
|
"rolePingId": "Role to ping",
|
||||||
"cron": "Cron expression",
|
"cron": "Cron expression",
|
||||||
"runAt": "Run at",
|
"runAt": "Run at",
|
||||||
|
"timingMode": "Schedule",
|
||||||
|
"timingModes": {
|
||||||
|
"once": "One-time",
|
||||||
|
"cron": "Recurring (cron)"
|
||||||
|
},
|
||||||
"content": "Message content",
|
"content": "Message content",
|
||||||
"embed": "Embed (optional)",
|
"embed": "Embed (optional)",
|
||||||
"components": "Components V2 layout",
|
"components": "Components V2 layout",
|
||||||
@@ -943,10 +948,15 @@
|
|||||||
"components_v2": "Components V2"
|
"components_v2": "Components V2"
|
||||||
},
|
},
|
||||||
"componentsPreview": "(Components V2)",
|
"componentsPreview": "(Components V2)",
|
||||||
"cronHint": "Set a cron expression for recurring messages, or a one-time date/time — not both. Choose text, embed, or Components V2 (mutually exclusive).",
|
"cronHint": "One-time time or cron recurrence — evaluated in the server timezone under General Settings. Choose text, embed, or Components V2 (mutually exclusive).",
|
||||||
"create": "Schedule message",
|
"create": "Schedule message",
|
||||||
"created": "Message scheduled.",
|
"created": "Message scheduled.",
|
||||||
"formIncomplete": "Please fill channel, choose a message format with content, and either a cron expression or a date/time.",
|
"formIncomplete": "Please fill channel, choose a message format with content, and either a cron expression or a date/time.",
|
||||||
|
"errors": {
|
||||||
|
"invalidCron": "Invalid cron expression (5–6 fields, e.g. 0 9 * * *).",
|
||||||
|
"invalidWhen": "Invalid date/time.",
|
||||||
|
"missingScheduleTime": "Provide a cron expression or a date/time."
|
||||||
|
},
|
||||||
"confirmDelete": "Really delete this schedule?",
|
"confirmDelete": "Really delete this schedule?",
|
||||||
"listTitle": "Scheduled messages",
|
"listTitle": "Scheduled messages",
|
||||||
"listDescription": "Upcoming and recurring scheduled messages.",
|
"listDescription": "Upcoming and recurring scheduled messages.",
|
||||||
|
|||||||
@@ -598,3 +598,20 @@
|
|||||||
- [ ] Online-Kanal zeigt sinnvolle Zahl (nicht dauerhaft 0)
|
- [ ] Online-Kanal zeigt sinnvolle Zahl (nicht dauerhaft 0)
|
||||||
- [ ] Bot ohne „Kanäle verwalten“ → Config gespeichert; Bot-Log warnt; nach Permission-Fix erneut speichern
|
- [ ] Bot ohne „Kanäle verwalten“ → Config gespeichert; Bot-Log warnt; nach Permission-Fix erneut speichern
|
||||||
|
|
||||||
|
## Post-Phase – Scheduler Cron / Run-at Fix (Status: implementiert)
|
||||||
|
|
||||||
|
### Abgeschlossen (Code)
|
||||||
|
|
||||||
|
- Cron und einmaliges „Ausführen am“ nutzen die Guild-Zeitzone (`GuildSettings.timezone`)
|
||||||
|
- Cron über BullMQ `upsertJobScheduler` inkl. `tz`; Delete entfernt Job-Scheduler korrekt
|
||||||
|
- Dashboard: Timing-Mode (einmalig vs. Cron), naive `datetime-local` an Server, Fehlertexte i18n
|
||||||
|
- Dark Mode: `color-scheme` + Input `text-foreground` für datetime-local Lesbarkeit
|
||||||
|
- Shared Helper `resolveScheduleRunAt` / `guildLocalDateTimeToUtc`
|
||||||
|
|
||||||
|
### Manuell testen
|
||||||
|
|
||||||
|
- [ ] WebUI einmalig: Datum/Uhrzeit in Server-Zeitzone → Nachricht zur erwarteten lokalen Zeit
|
||||||
|
- [ ] WebUI Cron `0 9 * * *` bei Europe/Berlin → ca. 09:00 Berlin (nicht UTC)
|
||||||
|
- [ ] Cron-Plan löschen → keine weiteren Sends
|
||||||
|
- [ ] Dark Mode: Cron-Feld und datetime-local lesbar/bedienbar
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export * from './owner.js';
|
|||||||
export * from './public.js';
|
export * from './public.js';
|
||||||
export * from './premium.js';
|
export * from './premium.js';
|
||||||
export * from './components-v2.js';
|
export * from './components-v2.js';
|
||||||
|
export * from './timezone.js';
|
||||||
|
|
||||||
export const LocaleSchema = z.enum(['de', 'en']);
|
export const LocaleSchema = z.enum(['de', 'en']);
|
||||||
export type Locale = z.infer<typeof LocaleSchema>;
|
export type Locale = z.infer<typeof LocaleSchema>;
|
||||||
|
|||||||
35
packages/shared/src/timezone.test.ts
Normal file
35
packages/shared/src/timezone.test.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
guildLocalDateTimeToUtc,
|
||||||
|
isValidTimezone,
|
||||||
|
resolveScheduleRunAt
|
||||||
|
} from './timezone.js';
|
||||||
|
|
||||||
|
describe('timezone helpers', () => {
|
||||||
|
it('validates IANA timezones', () => {
|
||||||
|
expect(isValidTimezone('Europe/Berlin')).toBe(true);
|
||||||
|
expect(isValidTimezone('Not/AZone')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts Berlin winter wall time to UTC', () => {
|
||||||
|
// 2026-01-15 09:00 Europe/Berlin = 08:00 UTC (CET, UTC+1)
|
||||||
|
const utc = guildLocalDateTimeToUtc('2026-01-15T09:00', 'Europe/Berlin');
|
||||||
|
expect(utc.toISOString()).toBe('2026-01-15T08:00:00.000Z');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts Berlin summer wall time to UTC', () => {
|
||||||
|
// 2026-07-15 09:00 Europe/Berlin = 07:00 UTC (CEST, UTC+2)
|
||||||
|
const utc = guildLocalDateTimeToUtc('2026-07-15T09:00', 'Europe/Berlin');
|
||||||
|
expect(utc.toISOString()).toBe('2026-07-15T07:00:00.000Z');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps absolute ISO strings unchanged', () => {
|
||||||
|
const utc = resolveScheduleRunAt('2026-07-15T07:00:00.000Z', 'Europe/Berlin');
|
||||||
|
expect(utc.toISOString()).toBe('2026-07-15T07:00:00.000Z');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('interprets naive datetime-local in guild timezone', () => {
|
||||||
|
const utc = resolveScheduleRunAt('2026-07-15T09:00', 'Europe/Berlin');
|
||||||
|
expect(utc.toISOString()).toBe('2026-07-15T07:00:00.000Z');
|
||||||
|
});
|
||||||
|
});
|
||||||
117
packages/shared/src/timezone.ts
Normal file
117
packages/shared/src/timezone.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
/** Default guild timezone (matches Prisma `GuildSettings.timezone`). */
|
||||||
|
export const DEFAULT_GUILD_TIMEZONE = 'Europe/Berlin';
|
||||||
|
|
||||||
|
export function isValidTimezone(timezone: string): boolean {
|
||||||
|
try {
|
||||||
|
Intl.DateTimeFormat(undefined, { timeZone: timezone });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ZonedParts = {
|
||||||
|
year: number;
|
||||||
|
month: number;
|
||||||
|
day: number;
|
||||||
|
hour: number;
|
||||||
|
minute: number;
|
||||||
|
second: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getZonedParts(date: Date, timeZone: string): ZonedParts {
|
||||||
|
const formatter = new Intl.DateTimeFormat('en-US', {
|
||||||
|
timeZone,
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
hourCycle: 'h23'
|
||||||
|
});
|
||||||
|
|
||||||
|
const map: Partial<Record<Intl.DateTimeFormatPartTypes, string>> = {};
|
||||||
|
for (const part of formatter.formatToParts(date)) {
|
||||||
|
if (part.type !== 'literal') {
|
||||||
|
map[part.type] = part.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
year: Number(map.year),
|
||||||
|
month: Number(map.month),
|
||||||
|
day: Number(map.day),
|
||||||
|
hour: Number(map.hour),
|
||||||
|
minute: Number(map.minute),
|
||||||
|
second: Number(map.second)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const NAIVE_LOCAL_RE = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2}))?$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interprets a wall-clock datetime (`YYYY-MM-DDTHH:mm` / with seconds / space
|
||||||
|
* separator) as being in `timeZone` and returns the corresponding UTC `Date`.
|
||||||
|
*/
|
||||||
|
export function guildLocalDateTimeToUtc(localDateTime: string, timeZone: string): Date {
|
||||||
|
const match = localDateTime.trim().match(NAIVE_LOCAL_RE);
|
||||||
|
if (!match) {
|
||||||
|
throw new RangeError('invalid_local_datetime');
|
||||||
|
}
|
||||||
|
if (!isValidTimezone(timeZone)) {
|
||||||
|
throw new RangeError('invalid_timezone');
|
||||||
|
}
|
||||||
|
|
||||||
|
const year = Number(match[1]);
|
||||||
|
const month = Number(match[2]);
|
||||||
|
const day = Number(match[3]);
|
||||||
|
const hour = Number(match[4]);
|
||||||
|
const minute = Number(match[5]);
|
||||||
|
const second = Number(match[6] ?? 0);
|
||||||
|
|
||||||
|
// Initial guess: treat the components as UTC, then correct by the TZ offset.
|
||||||
|
let utcMs = Date.UTC(year, month - 1, day, hour, minute, second);
|
||||||
|
for (let i = 0; i < 3; i += 1) {
|
||||||
|
const parts = getZonedParts(new Date(utcMs), timeZone);
|
||||||
|
const asUtc = Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second);
|
||||||
|
const targetAsUtc = Date.UTC(year, month - 1, day, hour, minute, second);
|
||||||
|
const diff = targetAsUtc - asUtc;
|
||||||
|
if (diff === 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
utcMs += diff;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Date(utcMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a schedule `runAt` input to a UTC instant.
|
||||||
|
* - Values with `Z` / numeric offset are treated as absolute.
|
||||||
|
* - Naive `datetime-local` values are interpreted in the guild timezone.
|
||||||
|
*/
|
||||||
|
export function resolveScheduleRunAt(input: string, timeZone: string): Date {
|
||||||
|
const trimmed = input.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
throw new RangeError('invalid_when');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/([zZ]|[+-]\d{2}:?\d{2})$/.test(trimmed)) {
|
||||||
|
const parsed = new Date(trimmed);
|
||||||
|
if (Number.isNaN(parsed.getTime())) {
|
||||||
|
throw new RangeError('invalid_when');
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (NAIVE_LOCAL_RE.test(trimmed)) {
|
||||||
|
return guildLocalDateTimeToUtc(trimmed, timeZone);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = new Date(trimmed);
|
||||||
|
if (Number.isNaN(parsed.getTime())) {
|
||||||
|
throw new RangeError('invalid_when');
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user