Files
minabot/src/discord/preferences.ts

114 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { eq } from 'drizzle-orm';
import { users } from '../db/schema';
import { timezoneInput } from '../habits/contracts';
import { ApiError } from '../habits/service';
import { ReminderService } from '../reminders/service';
import type { ReminderEntry } from '../reminders/contracts';
import type { Context, ControlData, Reply } from './types';
import { fields } from './types';
import { button, message, modal, pages, row, select, text } from './ui';
const deliveryLabels: Record<string, string> = {
sent: 'Delivered', deferred: 'Waiting to retry', uncertain: 'Delivery unconfirmed; check Discord. This attempt will not be retried.',
failed: 'Could not deliver. Check your DM permissions and that you share a server with the bot.', skipped: 'Skipped because check-ins or settings changed',
};
const weekdays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const service = (c: Context) => new ReminderService(c.db, c.service.user.id, c.remindersAvailable ?? false, c.now);
const user = (c: Context) => c.db.select().from(users).where(eq(users.id, c.service.user.id)).get()!;
const timezoneVersion = (c: Context) => { const u = user(c); return JSON.stringify([u.timezone, u.updatedAt]); };
const snapshot = (c: Context, data: ControlData = {}): ControlData => ({ ...data, preferenceVersion: service(c).version(), timezoneVersion: timezoneVersion(c) });
function current(c: Context, data: ControlData, timezoneOnly = false) {
if (data.timezoneVersion !== timezoneVersion(c) || (!timezoneOnly && data.preferenceVersion !== service(c).version()))
throw new ApiError(409, 'Settings changed since this control was opened. Run /settings or /reminders to refresh.');
}
function groups(c: Context) {
const result: { name: string; entry: ReminderEntry; indexes: number[]; times: string[] }[] = [];
service(c).read().reminders.forEach((entry, index) => {
const key = (e: ReminderEntry) => JSON.stringify([e.name ?? '', e.type, e.type === 'date' ? e.date : [...e.days].sort()]);
let group = result.find(g => key(g.entry) === key(entry));
if (!group) { group = { name: entry.name || 'Unnamed reminder', entry, indexes: [], times: [] }; result.push(group); }
group.indexes.push(index); group.times.push(entry.time);
});
return result;
}
function group(c: Context, data: ControlData) {
const found = groups(c)[data.index ?? -1];
if (!found) throw new ApiError(404, 'Reminder not found. Run /reminders to refresh.');
return found;
}
const schedule = (e: ReminderEntry) => e.type === 'date' ? e.date : e.days.length === 7 ? 'Every day' : e.days.map(d => weekdays[d]).join(', ');
export function preferences(c: Context, command: string, data: ControlData = {}): Reply {
const prefs = service(c).read(), tz = user(c).timezone, state = snapshot(c, data);
const status = `Delivery: ${prefs.available ? prefs.enabled ? 'enabled' : 'disabled' : 'not configured on this server'}\nLast delivery: ${prefs.lastDelivery ? `${prefs.lastDelivery.date} · ${deliveryLabels[prefs.lastDelivery.status] ?? 'Unknown status'}` : 'none yet'}\nQuiet hours: ${prefs.quietStart}–${prefs.quietEnd}${prefs.quietStart === prefs.quietEnd ? ' (off)' : ''}`;
if (command === 'settings') return message(c, `**Settings**\nTimezone: ${text(tz)}${tz === 'UTC' ? '\nNew accounts start in UTC. Set your timezone for local habit days and reminder times.' : ''}\n${status}`, [row(button(c, 'Change timezone', 'prefs.timezone', state), button(c, 'Manage reminders', 'prefs.list'))]);
const all = groups(c), page = Math.max(0, Math.min(data.page ?? 0, Math.ceil(all.length / 10) - 1)), visible = all.slice(page * 10, page * 10 + 10);
return message(c, `**Reminders · ${text(tz)}**\n${status}\nReminders arrive by DM only when habits remain unfinished. Times use your timezone.\n${tz === 'UTC' ? 'Use Settings to choose your local timezone.\n' : ''}\n${visible.map(g => `• ${text(g.name)}: ${g.times.join(', ')} · ${schedule(g.entry)}`).join('\n') || 'No reminders. Add one to get started.'}\n${prefs.reminders.length}/20 time slots`, [
...(visible.length ? [select(c, 'Choose a reminder', 'prefs.select', state, visible.map((g, i) => ({ id: String(page * 10 + i), name: g.name, description: `${g.times.join(', ')} · ${schedule(g.entry)}` })))] : []),
...pages(c, 'prefs.list', { page }, all.length),
row(button(c, 'Add reminder', 'prefs.edit', { ...state, index: undefined }), button(c, prefs.enabled ? 'Disable delivery' : 'Enable delivery', 'prefs.toggle', { ...state, done: !prefs.enabled }), button(c, 'Quiet hours', 'prefs.quiet', state)),
row(button(c, 'Settings', 'prefs.settings'), button(c, 'Refresh', 'prefs.list', { page }), button(c, 'View schedule card', 'graphic.reminders')),
]);
}
function edit(c: Context, data: ControlData): Reply {
const g = data.index === undefined ? undefined : group(c, data), draft = data.draft;
return modal(c, g ? 'Edit reminder' : 'Add reminder', 'prefs.save', data, [
{ id: 'name', label: 'Reminder name', value: draft?.name ?? g?.entry.name ?? '', max: 80 },
{ id: 'times', label: 'Times (24-hour, separated by commas)', value: draft?.times ?? g?.times.join(', ') ?? '', placeholder: '09:00, 18:30', max: 160 },
{ id: 'days', label: 'Weekdays (leave blank for a specific date)', value: draft?.days ?? (g?.entry.type === 'weekly' ? g.entry.days.map(d => weekdays[d]).join(', ') : ''), placeholder: 'Mon, Wed, Fri or every day', required: false, max: 100 },
{ id: 'date', label: 'Specific date (leave blank for weekdays)', value: draft?.date ?? (g?.entry.type === 'date' ? g.entry.date : ''), placeholder: '2026-09-30', required: false, max: 10 },
]);
}
function parseReminder(input: Record<string, string>): ReminderEntry[] {
const name = input.name?.trim(), times = input.times?.split(',').map(t => t.trim()) ?? [], daysText = input.days?.trim() ?? '', date = input.date?.trim() ?? '';
if (!name || name.length > 80) throw new ApiError(422, 'Enter a reminder name (up to 80 characters).');
if (!times.length || times.some(t => !/^(?:[01]\d|2[0-3]):[0-5]\d$/.test(t)) || new Set(times).size !== times.length)
throw new ApiError(422, 'Use distinct 24-hour times separated by commas, for example 09:00, 18:30.');
if (!!daysText === !!date) throw new ApiError(422, 'Enter either weekdays or a specific date, leaving the other field blank.');
if (date) return times.map(time => ({ type: 'date', name, time, date }));
const days = /^(every day|daily)$/i.test(daysText) ? [0, 1, 2, 3, 4, 5, 6] : daysText.toLowerCase().split(/[\s,]+/).map(day => ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'].findIndex(full => day === full || day === full.slice(0, 3)));
if (days.some(d => d < 0) || new Set(days).size !== days.length) throw new ApiError(422, 'Choose distinct weekdays such as Mon, Wed, Fri, or enter every day.');
return times.map(time => ({ type: 'weekly', name, time, days }));
}
function save(c: Context, data: ControlData, changes: Record<string, unknown>) {
const prefs = service(c).read();
service(c).save({ enabled: prefs.enabled, reminders: prefs.reminders, quietStart: prefs.quietStart, quietEnd: prefs.quietEnd, ...changes }, data.preferenceVersion);
c.controls.consume(c.interaction.data!.custom_id!); c.changed();
return preferences(c, 'reminders');
}
export function preferencesAction(c: Context, action: string, data: ControlData): Reply | undefined {
if (!action.startsWith('prefs.')) return;
if (action === 'prefs.list') return preferences(c, 'reminders', data);
if (action === 'prefs.settings') return preferences(c, 'settings');
current(c, data, action === 'prefs.timezone' || action === 'prefs.timezoneSave');
if (action === 'prefs.timezone') return modal(c, 'Change timezone', 'prefs.timezoneSave', data, [{ id: 'timezone', label: 'Timezone (region/city)', value: data.draft?.timezone ?? user(c).timezone, placeholder: 'Europe/Belgrade, America/New_York, UTC', max: 100 }]);
if (action === 'prefs.quiet') { const p = service(c).read(); return modal(c, 'Quiet hours (same times = off)', 'prefs.quietSave', data, [{ id: 'start', label: 'Start (24-hour HH:MM)', value: data.draft?.start ?? p.quietStart, max: 5 }, { id: 'end', label: 'End (24-hour HH:MM)', value: data.draft?.end ?? p.quietEnd, max: 5 }]); }
if (action === 'prefs.edit') return edit(c, data);
if (action === 'prefs.select') {
const selected = c.interaction.data?.values?.[0] ?? '';
if (!/^\d+$/.test(selected)) throw new ApiError(404, 'Reminder not found.');
const state = { ...data, index: Number(selected) }, g = group(c, state);
return message(c, `**${text(g.name)}**\n${g.times.join(', ')} · ${schedule(g.entry)}\nTimezone: ${text(user(c).timezone)}`, [row(button(c, 'Edit reminder', 'prefs.edit', state), button(c, 'Remove reminder', 'prefs.remove', state, 4), button(c, 'Back', 'prefs.list'))]);
}
if (action === 'prefs.remove') return message(c, `Remove **${text(group(c, data).name)}** and all its times?`, [row(button(c, 'Confirm removal', 'prefs.removeSave', data, 4), button(c, 'Cancel', 'prefs.list'))]);
try {
if (action === 'prefs.timezoneSave') {
const parsed = timezoneInput.safeParse({ timezone: fields(c.interaction).timezone?.trim() });
if (!parsed.success) throw new ApiError(422, 'Use a timezone such as Europe/Belgrade, America/New_York, or UTC.');
c.service.setTimezone(parsed.data.timezone); c.controls.consume(c.interaction.data!.custom_id!); c.changed();
return preferences(c, 'settings');
}
if (action === 'prefs.quietSave') { const input = fields(c.interaction); return save(c, data, { quietStart: input.start?.trim(), quietEnd: input.end?.trim() }); }
if (action === 'prefs.toggle') return save(c, data, { enabled: data.done });
if (action === 'prefs.save' || action === 'prefs.removeSave') {
const removed = data.index === undefined ? [] : group(c, data).indexes;
const remaining = service(c).read().reminders.filter((_, i) => !removed.includes(i));
const reminders = [...remaining, ...(action === 'prefs.save' ? parseReminder(fields(c.interaction)) : [])];
return save(c, data, { reminders, ...(!reminders.length ? { enabled: false } : {}) });
}
} catch (error) {
if (!(error instanceof ApiError) || error.status !== 422) throw error;
const retry = { 'prefs.save': 'prefs.edit', 'prefs.quietSave': 'prefs.quiet', 'prefs.timezoneSave': 'prefs.timezone' }[action];
return message(c, error.message, [row(...(retry ? [button(c, 'Try again', retry, { ...data, draft: fields(c.interaction) })] : []), button(c, 'Reminders', 'prefs.list'), button(c, 'Settings', 'prefs.settings'))], false);
}
}