openpetswithchatandmcp/plugins/official/openpets.reminders/index.js
Alvin Unreal 4f4c02eacd working
2026-06-11 20:33:53 +02:00

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() {},
});
}