338 lines
11 KiB
JavaScript
338 lines
11 KiB
JavaScript
// Quick Reminders (openpets.reminders) — a self-contained v3 plugin.
|
|
//
|
|
// Keeps the proven Quick Reminders interaction model (a "Set reminder..." form
|
|
// plus 15/30/60-minute presets from the pet menu) but delivers with the §21.3
|
|
// acknowledge pattern: ctx.ui.alert(...) with Done / Snooze 5m actions,
|
|
// optional custom sound, and optional OS notification. Every
|
|
// user-facing composed string flows through ctx.t(key, vars) so the host can
|
|
// localize it; nothing English is hardcoded below.
|
|
|
|
export const MAX_REMINDERS = 10;
|
|
export const MAX_MESSAGE_LENGTH = 140;
|
|
export const MAX_DELAY_MS = 24 * 60 * 60 * 1000;
|
|
export const SNOOZE_MS = 5 * 60 * 1000;
|
|
|
|
/**
|
|
* Normalize a free-text reminder message: collapse whitespace, strip newlines,
|
|
* cap length, and fall back when empty. `fallback` is already-resolved text
|
|
* (callers pass ctx.t("reminder.defaultMessage")).
|
|
*/
|
|
export function cleanMessage(value, fallback = "Reminder time.") {
|
|
const text =
|
|
typeof value === "string"
|
|
? value.trim().replace(/[\r\n]+/g, " ").replace(/\s+/g, " ")
|
|
: "";
|
|
return (text || fallback).slice(0, MAX_MESSAGE_LENGTH).trim() || fallback;
|
|
}
|
|
|
|
/**
|
|
* Convert a {hours, minutes} form payload into a delay in ms. Clamps each
|
|
* field to its valid range and enforces a 1-minute..24-hour window. Throws on
|
|
* an out-of-range total so the command surfaces the error to the user.
|
|
*/
|
|
export function durationMs(values = {}) {
|
|
const hours = Math.max(0, Math.min(23, Math.round(Number(values.hours ?? 0))));
|
|
const minutes = Math.max(0, Math.min(59, Math.round(Number(values.minutes ?? 0))));
|
|
const ms = (hours * 60 + minutes) * 60_000;
|
|
if (ms < 60_000 || ms > MAX_DELAY_MS) {
|
|
throw new Error("Reminder duration must be 1 minute to 24 hours.");
|
|
}
|
|
return ms;
|
|
}
|
|
|
|
/**
|
|
* A short, plain-text summary of the pending reminders. `now` defaults to the
|
|
* current time. Translation-agnostic on purpose: returns a compact
|
|
* "{minutes} min: {message}" join used by the pure-helper unit tests; runtime
|
|
* UI uses the dynamic ctx.ui.menu list instead.
|
|
*/
|
|
export function summary(reminders, now = Date.now()) {
|
|
if (!reminders.length) return "No active reminders.";
|
|
return reminders
|
|
.slice(0, 5)
|
|
.map((r) => `${Math.max(1, Math.ceil((r.dueAt - now) / 60_000))} min: ${r.message}`)
|
|
.join("; ");
|
|
}
|
|
|
|
// --- storage -------------------------------------------------------------
|
|
|
|
export async function getReminders(ctx) {
|
|
const reminders = await ctx.storage.get("reminders");
|
|
return Array.isArray(reminders)
|
|
? reminders
|
|
.filter(
|
|
(r) =>
|
|
r &&
|
|
typeof r.id === "string" &&
|
|
typeof r.dueAt === "number" &&
|
|
typeof r.message === "string",
|
|
)
|
|
.slice(0, MAX_REMINDERS)
|
|
: [];
|
|
}
|
|
|
|
async function saveReminders(ctx, reminders) {
|
|
const list = reminders.slice(0, MAX_REMINDERS);
|
|
await ctx.storage.set("reminders", list);
|
|
await updateStatus(ctx, list.length);
|
|
return list;
|
|
}
|
|
|
|
async function updateStatus(ctx, count) {
|
|
const text =
|
|
count > 0
|
|
? ctx.t("status.active", { count })
|
|
: ctx.t("status.none");
|
|
await ctx.status.set({ text, tone: "info" });
|
|
}
|
|
|
|
// --- scheduling + delivery ----------------------------------------------
|
|
|
|
export async function scheduleReminder(ctx, reminder) {
|
|
// schedule.once min delay is 1ms; never schedule in the past.
|
|
const delay = Math.max(1, reminder.dueAt - Date.now());
|
|
await ctx.schedule.once(reminder.id, delay, () => fireReminder(ctx, reminder.id));
|
|
}
|
|
|
|
export async function addReminder(ctx, message, delayMs) {
|
|
const reminders = (await getReminders(ctx)).filter((r) => r.dueAt > Date.now());
|
|
if (reminders.length >= MAX_REMINDERS) {
|
|
throw new Error(ctx.t("error.tooMany", { max: MAX_REMINDERS }));
|
|
}
|
|
const reminder = {
|
|
// id matches [A-Za-z0-9._:-]{1,64}
|
|
id: `reminder-${Date.now().toString(36)}-${Math.floor(Math.random() * 1e6).toString(36)}`.slice(0, 64),
|
|
message: cleanMessage(message, ctx.t("reminder.defaultMessage")),
|
|
dueAt: Date.now() + delayMs,
|
|
};
|
|
reminders.push(reminder);
|
|
await saveReminders(ctx, reminders);
|
|
await scheduleReminder(ctx, reminder);
|
|
await ctx.pet.speak(ctx.t("speech.set", { minutes: Math.max(1, Math.round(delayMs / 60_000)) }));
|
|
return reminder;
|
|
}
|
|
|
|
/**
|
|
* Deliver a reminder using the acknowledge pattern (§21.3). Degrades
|
|
* gracefully: a disabled toggle or an unavailable permission must never throw
|
|
* the message away — the sticky bubble is the guaranteed channel.
|
|
*/
|
|
async function deliver(ctx, message, { missed = false } = {}) {
|
|
const config = (await ctx.config.get()) ?? {};
|
|
const soundEnabled = config.soundEnabled !== false;
|
|
const osNotification = config.osNotification !== false;
|
|
|
|
const text = missed
|
|
? ctx.t("bubble.missed", { message })
|
|
: ctx.t("bubble.due", { message });
|
|
const reminderIcon = ctx.assets.icon("reminder");
|
|
|
|
let alert;
|
|
try {
|
|
alert = await ctx.ui.alert({
|
|
text,
|
|
indicator: {
|
|
icon: reminderIcon,
|
|
label: missed ? ctx.t("indicator.missed") : ctx.t("indicator.due"),
|
|
tone: missed ? "warning" : "info",
|
|
color: missed ? "#d97706" : "#7c3aed",
|
|
background: missed ? "#fef3c7" : "#ede9fe",
|
|
borderColor: missed ? "#fbbf24" : "#c4b5fd",
|
|
},
|
|
tone: "info",
|
|
sound: soundEnabled ? config.customSound || "alert" : undefined,
|
|
notify: osNotification
|
|
? {
|
|
title: ctx.t("notify.title"),
|
|
body: missed ? ctx.t("notify.bodyMissed", { message }) : message,
|
|
}
|
|
: undefined,
|
|
dismissOn: ["petClick", "click", "action"],
|
|
actions: [
|
|
{ id: "done", label: ctx.t("action.done"), style: "primary" },
|
|
{ id: "snooze", label: ctx.t("action.snooze") },
|
|
],
|
|
});
|
|
} catch {
|
|
// If interactive bubbles aren't permitted, still surface plain speech.
|
|
try {
|
|
await ctx.pet.speak(text);
|
|
} catch {
|
|
// last resort already attempted; nothing more to do.
|
|
}
|
|
}
|
|
|
|
if (alert) {
|
|
alert.onAction(async (actionId) => {
|
|
if (actionId === "snooze") {
|
|
await addReminder(ctx, message, SNOOZE_MS);
|
|
}
|
|
// "done" needs no extra work; dismissing the bubble is the acknowledgement.
|
|
});
|
|
}
|
|
}
|
|
|
|
export async function fireReminder(ctx, id) {
|
|
const reminders = await getReminders(ctx);
|
|
const item = reminders.find((r) => r.id === id);
|
|
await saveReminders(ctx, reminders.filter((r) => r.id !== id));
|
|
if (!item) return false;
|
|
await deliver(ctx, item.message);
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Reconcile persisted reminders against the wall clock on start(). Schedules
|
|
* are in-memory per session, so future reminders are re-registered and overdue
|
|
* ones (fired while OpenPets was closed) are delivered as "missed".
|
|
*/
|
|
export async function reconcile(ctx) {
|
|
await ctx.schedule.cancelAll();
|
|
const now = Date.now();
|
|
const reminders = await getReminders(ctx);
|
|
const future = reminders.filter((r) => r.dueAt > now);
|
|
const overdue = reminders.filter((r) => r.dueAt <= now);
|
|
await saveReminders(ctx, future);
|
|
for (const item of future) await scheduleReminder(ctx, item);
|
|
for (const item of overdue) await deliver(ctx, item.message, { missed: true });
|
|
}
|
|
|
|
// --- commands ------------------------------------------------------------
|
|
|
|
async function showReminderList(ctx) {
|
|
const reminders = await getReminders(ctx);
|
|
const now = Date.now();
|
|
const pending = reminders.filter((r) => r.dueAt > now);
|
|
if (!pending.length) {
|
|
await ctx.pet.speak(ctx.t("speech.none"));
|
|
await ctx.ui.menu.setItems([]);
|
|
return;
|
|
}
|
|
await ctx.ui.menu.setItems(
|
|
pending.slice(0, MAX_REMINDERS).map((reminder) => ({
|
|
id: `cancel:${reminder.id}`.slice(0, 64),
|
|
title: ctx.t("menu.item", {
|
|
minutes: Math.max(1, Math.ceil((reminder.dueAt - now) / 60_000)),
|
|
message: reminder.message,
|
|
}),
|
|
icon: "bell",
|
|
onSelect: async () => {
|
|
await ctx.schedule.cancel(reminder.id);
|
|
const remaining = (await getReminders(ctx)).filter((r) => r.id !== reminder.id);
|
|
await saveReminders(ctx, remaining);
|
|
await ctx.pet.speak(ctx.t("speech.cancelled", { message: reminder.message }));
|
|
await showReminderList(ctx);
|
|
},
|
|
})),
|
|
);
|
|
}
|
|
|
|
export function register(OpenPetsPlugin) {
|
|
OpenPetsPlugin.register({
|
|
async start(ctx) {
|
|
await reconcile(ctx);
|
|
|
|
await ctx.commands.register(
|
|
{
|
|
id: "set-reminder",
|
|
title: "$t:command.setReminder.title",
|
|
description: "$t:command.setReminder.description",
|
|
icon: "bell",
|
|
form: {
|
|
submitLabel: "$t:command.setReminder.submit",
|
|
fields: [
|
|
{
|
|
id: "message",
|
|
type: "textarea",
|
|
label: "$t:form.message.label",
|
|
required: true,
|
|
maxLength: MAX_MESSAGE_LENGTH,
|
|
},
|
|
{
|
|
id: "hours",
|
|
type: "number",
|
|
label: "$t:form.hours.label",
|
|
default: 0,
|
|
min: 0,
|
|
max: 23,
|
|
},
|
|
{
|
|
id: "minutes",
|
|
type: "number",
|
|
label: "$t:form.minutes.label",
|
|
default: 15,
|
|
min: 0,
|
|
max: 59,
|
|
},
|
|
],
|
|
},
|
|
},
|
|
async (values) => addReminder(ctx, values.message, durationMs(values)),
|
|
);
|
|
|
|
await ctx.commands.register(
|
|
{
|
|
id: "reminder-15",
|
|
title: "$t:command.reminder15.title",
|
|
description: "$t:command.reminder15.description",
|
|
icon: "timer",
|
|
},
|
|
() => addReminder(ctx, ctx.t("reminder.defaultMessage"), 15 * 60_000),
|
|
);
|
|
await ctx.commands.register(
|
|
{
|
|
id: "reminder-30",
|
|
title: "$t:command.reminder30.title",
|
|
description: "$t:command.reminder30.description",
|
|
icon: "timer",
|
|
},
|
|
() => addReminder(ctx, ctx.t("reminder.defaultMessage"), 30 * 60_000),
|
|
);
|
|
await ctx.commands.register(
|
|
{
|
|
id: "reminder-60",
|
|
title: "$t:command.reminder60.title",
|
|
description: "$t:command.reminder60.description",
|
|
icon: "timer",
|
|
},
|
|
() => addReminder(ctx, ctx.t("reminder.defaultMessage"), 60 * 60_000),
|
|
);
|
|
|
|
await ctx.commands.register(
|
|
{
|
|
id: "view-reminders",
|
|
title: "$t:command.viewReminders.title",
|
|
description: "$t:command.viewReminders.description",
|
|
icon: "bell",
|
|
},
|
|
() => showReminderList(ctx),
|
|
);
|
|
|
|
await ctx.commands.register(
|
|
{
|
|
id: "test-reminder",
|
|
title: "$t:command.testReminder.title",
|
|
description: "$t:command.testReminder.description",
|
|
icon: "bell",
|
|
},
|
|
() => deliver(ctx, ctx.t("reminder.testMessage")),
|
|
);
|
|
|
|
await ctx.commands.register(
|
|
{
|
|
id: "clear-reminders",
|
|
title: "$t:command.clearReminders.title",
|
|
description: "$t:command.clearReminders.description",
|
|
icon: "check",
|
|
},
|
|
async () => {
|
|
await ctx.schedule.cancelAll();
|
|
await saveReminders(ctx, []);
|
|
await ctx.ui.menu.setItems([]);
|
|
await ctx.pet.speak(ctx.t("speech.cleared"));
|
|
},
|
|
);
|
|
},
|
|
async stop() {},
|
|
});
|
|
}
|