diff --git a/apps/desktop/contracts/plugin-manifest.contract.ts b/apps/desktop/contracts/plugin-manifest.contract.ts index bcc45d02..9328fc43 100644 --- a/apps/desktop/contracts/plugin-manifest.contract.ts +++ b/apps/desktop/contracts/plugin-manifest.contract.ts @@ -32,7 +32,10 @@ const validManifest = { }; assertValid(validManifest); -assertValid({ manifestVersion: 2, id: "js-plugin", name: "JS Plugin", version: "1.0.0", runtime: "javascript", sdkVersion: "1.0.0", entry: "dist/index.js", permissions: ["pet:speak", "network"], network: { hosts: ["api.example.com"] } }); +assertValid({ ...validManifest, icon: "bell" }); +assertInvalid({ ...validManifest, icon: "https://example.com/icon.svg" }, "invalid_icon"); +assertValid({ manifestVersion: 2, id: "js-plugin", name: "JS Plugin", version: "1.0.0", runtime: "javascript", icon: "github", sdkVersion: "1.0.0", entry: "dist/index.js", permissions: ["pet:speak", "network"], network: { hosts: ["api.example.com"] } }); +assertInvalid({ manifestVersion: 2, id: "js-plugin", name: "JS Plugin", version: "1.0.0", runtime: "javascript", icon: "", sdkVersion: "1.0.0", entry: "dist/index.js", permissions: ["pet:speak"] }, "invalid_icon"); assertInvalid({ manifestVersion: 2, id: "js-plugin", name: "JS Plugin", version: "1.0.0", runtime: "declarative", sdkVersion: "1.0.0", entry: "dist/index.js", permissions: ["pet:speak"] }, "invalid_runtime"); assertInvalid({ manifestVersion: 2, id: "js-plugin", name: "JS Plugin", version: "1.0.0", runtime: "javascript", sdkVersion: "1.0.0", entry: "../index.js", permissions: ["pet:speak"] }, "invalid_entry"); assertInvalid({ manifestVersion: 2, id: "js-plugin", name: "JS Plugin", version: "1.0.0", runtime: "javascript", sdkVersion: "1.0.0", entry: "index.js", permissions: ["network"], network: { hosts: ["*.example.com"] } }, "invalid_network_host"); diff --git a/apps/desktop/package.json b/apps/desktop/package.json index f53dc3b4..f0bc4bdc 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@open-pets/desktop", - "version": "2.1.1", + "version": "2.1.2", "private": true, "description": "OpenPets tray-first desktop companion app.", "license": "MIT", @@ -16,7 +16,7 @@ "dev": "pnpm build && electron .", "dev:electron": "pnpm build:main && electron .", "dev:control-center": "concurrently -k -n renderer,electron -c cyan,magenta \"pnpm dev:renderer\" \"wait-on http://127.0.0.1:5173 && cross-env OPENPETS_RENDERER_URL=http://127.0.0.1:5173 pnpm dev:electron\"", - "dev:plugins": "OPENPETS_DISABLE_PLUGIN_CATALOG=1 OPENPETS_DEV_PLUGIN_ROOTS=../../web/plugins/official pnpm dev", + "dev:plugins": "cross-env OPENPETS_DISABLE_PLUGIN_CATALOG=1 OPENPETS_DEV_PLUGIN_ROOTS=../../plugins/official pnpm dev", "dev:debug": "OPENPETS_LOG_LEVEL=debug OPENPETS_LOG_CONSOLE=1 pnpm dev", "package": "pnpm build && node scripts/clean-package-output.cjs && electron-builder", "package:dir": "pnpm build && node scripts/clean-package-output.cjs && electron-builder --dir && node dist/check-packaging-contract.js --output", diff --git a/apps/desktop/scripts/release-local.mjs b/apps/desktop/scripts/release-local.mjs index 0d39a21a..3df74811 100644 --- a/apps/desktop/scripts/release-local.mjs +++ b/apps/desktop/scripts/release-local.mjs @@ -208,25 +208,36 @@ function run(command, args, options) { function defaultReleaseNotes() { return [ - `OpenPets ${tag} is a desktop packaging patch for macOS users.`, + `OpenPets ${tag} ships the first official plugin release with polished desktop plugin management.`, "", - "## Fixed", + "## New: OpenPets Plugins", "", - "- Fixed macOS release packaging so app bundles are ad-hoc signed when a Developer ID certificate is unavailable.", - "- This addresses the misleading macOS Gatekeeper dialog that can say OpenPets is damaged and can't be opened on Apple Silicon/Sequoia.", - "- Rebuilt desktop artifacts with optional packages included: macOS ZIP, Windows portable, Linux DEB/RPM, and Linux tar.gz.", + "OpenPets now includes a first-party plugin platform for optional desktop companion behaviors.", "", - "## Still included from v2.1.0", + "## Included plugins", "", "- Daily Reminders — recurring local reminders with custom messages, reactions, days, and intervals.", - "- Pomodoro — focus and break sessions with pet feedback and controls.", + "- Pomodoro — focus/break sessions with pet feedback and controls.", "- GitHub Notifications — public repository release and failed-workflow notifications. No GitHub login, token, or private repository access is used.", "", + "## Plugin management", + "", + "- New polished Plugins window with install, enable, configure, update, reload, and uninstall actions.", + "- Friendly plugin configuration UI; no JSON editing required.", + "- Plugin permissions, network hosts, and official plugin icons are explicit.", + "- JavaScript plugins run in a sandboxed renderer with a narrow OpenPets SDK.", + "", + "## Developer notes", + "", + "- Official plugin source now lives under the repository-level plugins/official directory.", + "- Local plugin development is available through explicit developer mode and pnpm dev:desktop:plugins.", + "- Legacy sample plugins were removed from public discovery.", + "- This release includes optional desktop artifacts: macOS ZIP, Windows portable, Linux DEB/RPM, and Linux tar.gz.", + "", "## Known limitations", "", "- GitHub Notifications supports public repositories only in this release.", - "- macOS artifacts are ad-hoc signed but not Developer ID notarized yet, so Gatekeeper may still require a first-open confirmation.", - "- Windows artifacts are unsigned, so SmartScreen warnings may appear.", + "- Desktop artifacts are currently unsigned, so OS security warnings may appear.", ].join("\n"); } diff --git a/apps/desktop/src/plugin-catalog-validation.ts b/apps/desktop/src/plugin-catalog-validation.ts index b5afa6dc..f537002e 100644 --- a/apps/desktop/src/plugin-catalog-validation.ts +++ b/apps/desktop/src/plugin-catalog-validation.ts @@ -1,15 +1,16 @@ -import { canonicalizePluginPermissions, type PluginPermission, type KnownPluginRuntime } from "./plugin-manifest.js"; +import { canonicalizePluginPermissions, type PluginIcon, type PluginPermission, type KnownPluginRuntime } from "./plugin-manifest.js"; -export type PluginCatalogEntry = { readonly id: string; readonly name: string; readonly version: string; readonly description: string; readonly runtime: "declarative"; readonly permissions: readonly PluginPermission[]; readonly downloadUrl: string; readonly sha256: string; readonly minOpenPetsVersion?: string }; +export type PluginCatalogEntry = { readonly id: string; readonly name: string; readonly version: string; readonly description: string; readonly runtime: "declarative"; readonly icon?: PluginIcon; readonly permissions: readonly PluginPermission[]; readonly downloadUrl: string; readonly sha256: string; readonly minOpenPetsVersion?: string }; export type PluginCatalogEntryV2 = Omit & { readonly runtime: KnownPluginRuntime; readonly sdkVersion?: string; readonly maxOpenPetsVersion?: string; readonly disabled?: boolean; readonly deprecated?: boolean; readonly statusReason?: string; readonly network?: { readonly hosts: readonly string[] } }; export type PluginCatalog = { readonly version: 1; readonly generatedAt: string; readonly plugins: readonly PluginCatalogEntry[] } | { readonly version: 2; readonly generatedAt: string; readonly plugins: readonly PluginCatalogEntryV2[] }; const catalogFields = new Set(["version", "generatedAt", "plugins"]); -const entryFields = new Set(["id", "name", "version", "description", "runtime", "permissions", "downloadUrl", "sha256", "minOpenPetsVersion"]); +const entryFields = new Set(["id", "name", "version", "description", "runtime", "icon", "permissions", "downloadUrl", "sha256", "minOpenPetsVersion"]); const entryFieldsV2 = new Set([...entryFields, "sdkVersion", "maxOpenPetsVersion", "disabled", "deprecated", "statusReason", "network"]); const idPattern = /^[a-z0-9][a-z0-9._-]{1,62}[a-z0-9]$/; const versionPattern = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/; const shaPattern = /^[0-9a-f]{64}$/; +const supportedPluginIcons = new Set(["plugin", "bell", "timer", "github"]); export function validatePluginCatalog(input: unknown): PluginCatalog { if (!isRecord(input)) throw new Error("Plugin catalog must be an object."); @@ -26,7 +27,7 @@ export function validatePluginCatalog(input: unknown): PluginCatalog { if (seen.has(id)) throw new Error(`Duplicate plugin id: ${id}`); seen.add(id); const permissions = canonicalizePluginPermissions(entry.permissions); - const base = { id, name: requireString(entry.name, "name", 1, 120), version: requireString(entry.version, "version", 1, 80, versionPattern), description: requireString(entry.description, "description", 0, 1000), runtime: requireRuntime(entry.runtime, input.version), permissions, downloadUrl: requireString(entry.downloadUrl, "downloadUrl", 1, 2048), sha256: requireString(entry.sha256, "sha256", 64, 64, shaPattern), minOpenPetsVersion: entry.minOpenPetsVersion === undefined ? undefined : requireString(entry.minOpenPetsVersion, "minOpenPetsVersion", 1, 80, versionPattern) }; + const base = { id, name: requireString(entry.name, "name", 1, 120), version: requireString(entry.version, "version", 1, 80, versionPattern), description: requireString(entry.description, "description", 0, 1000), runtime: requireRuntime(entry.runtime, input.version), icon: normalizeIcon(entry.icon), permissions, downloadUrl: requireString(entry.downloadUrl, "downloadUrl", 1, 2048), sha256: requireString(entry.sha256, "sha256", 64, 64, shaPattern), minOpenPetsVersion: entry.minOpenPetsVersion === undefined ? undefined : requireString(entry.minOpenPetsVersion, "minOpenPetsVersion", 1, 80, versionPattern) }; if (input.version === 1) return base; const hasNetworkPermission = permissions.includes("network"); if (base.runtime === "javascript" && entry.sdkVersion === undefined) throw new Error("Invalid plugin catalog sdkVersion."); @@ -39,6 +40,7 @@ export function validatePluginCatalog(input: unknown): PluginCatalog { } function requireRuntime(value: unknown, catalogVersion: unknown): KnownPluginRuntime { if (value !== "declarative" && !(catalogVersion === 2 && value === "javascript")) throw new Error(catalogVersion === 2 ? 'Plugin runtime must be "declarative" or "javascript".' : 'Plugin runtime must be "declarative".'); return value; } +function normalizeIcon(value: unknown): PluginIcon | undefined { if (value === undefined) return undefined; if (typeof value !== "string" || !supportedPluginIcons.has(value)) throw new Error("Invalid plugin catalog icon."); return value as PluginIcon; } function requireBoolean(value: unknown, name: string): boolean { if (typeof value !== "boolean") throw new Error(`Invalid plugin catalog ${name}.`); return value; } function normalizeNetwork(value: unknown): { readonly hosts: readonly string[] } | undefined { if (value === undefined) return undefined; if (!isRecord(value) || !Array.isArray(value.hosts)) throw new Error("Invalid plugin catalog network.hosts."); rejectUnknown(value, new Set(["hosts"]), "network"); return { hosts: value.hosts.map((host) => requireString(host, "network.hosts", 1, 253, /^[a-z0-9.-]+(?::\d{1,5})?$/i)) }; } function requireString(value: unknown, name: string, min: number, max: number, pattern?: RegExp): string { if (typeof value !== "string" || value.length < min || value.length > max || (min > 0 && value.trim() === "")) throw new Error(`Invalid plugin catalog ${name}.`); if (pattern && !pattern.test(value)) throw new Error(`Invalid plugin catalog ${name}.`); return value; } diff --git a/apps/desktop/src/plugin-manifest.ts b/apps/desktop/src/plugin-manifest.ts index 950de32f..4427ba5f 100644 --- a/apps/desktop/src/plugin-manifest.ts +++ b/apps/desktop/src/plugin-manifest.ts @@ -7,6 +7,7 @@ export type PluginRuntime = "declarative"; export type KnownPluginRuntime = PluginRuntime | "javascript"; export type PluginPermission = "pet:speak" | "pet:reaction" | "timer" | "schedule" | "storage" | "status" | "commands" | "network"; export type PluginJavascriptPermission = Exclude; +export type PluginIcon = "plugin" | "bell" | "timer" | "github"; export type PluginConfigFieldType = "text" | "textarea" | "number" | "boolean" | "select" | "time" | "multiSelect" | "list"; export type PluginConfigField = { @@ -34,6 +35,7 @@ export type OpenPetsDeclarativePluginManifest = { name: string; version: string; runtime: PluginRuntime; + icon?: PluginIcon; permissions: PluginPermission[]; configSchema?: Record; triggers: PluginTrigger[]; @@ -47,6 +49,7 @@ export type OpenPetsJavascriptPluginManifest = { runtime: "javascript"; sdkVersion: string; entry: string; + icon?: PluginIcon; permissions: PluginJavascriptPermission[]; network?: { hosts: string[] }; configSchema?: Record; @@ -64,8 +67,8 @@ export type PluginManifestValidationResult = | { ok: true; manifest: OpenPetsPluginManifest; errors: [] } | { ok: false; errors: PluginManifestValidationError[] }; -const topLevelFields = new Set(["manifestVersion", "id", "name", "version", "runtime", "permissions", "configSchema", "triggers"]); -const jsTopLevelFields = new Set(["manifestVersion", "id", "name", "version", "runtime", "sdkVersion", "entry", "permissions", "network", "configSchema"]); +const topLevelFields = new Set(["manifestVersion", "id", "name", "version", "runtime", "icon", "permissions", "configSchema", "triggers"]); +const jsTopLevelFields = new Set(["manifestVersion", "id", "name", "version", "runtime", "sdkVersion", "entry", "icon", "permissions", "network", "configSchema"]); const configFieldFields = new Set(["type", "label", "description", "default", "options", "min", "max", "step", "maxLength", "maxItems", "itemSchema"]); const configOptionFields = new Set(["label", "value"]); const triggerFields = new Set(["on", "everyMinutes", "actions"]); @@ -74,6 +77,7 @@ const reactActionFields = new Set(["type", "reaction"]); const supportedConfigTypes = new Set(["text", "textarea", "number", "boolean", "select", "time", "multiSelect", "list"]); const deferredConfigTypes = new Set(["multi-select", "date", "schedule", "connection", "secret"]); const deferredConfigFeatures = new Set(["dynamicOptions"]); +const supportedPluginIcons = new Set(["plugin", "bell", "timer", "github"]); export const pluginPermissions = ["pet:speak", "pet:reaction", "timer", "schedule", "storage", "status", "commands", "network"] as const satisfies readonly PluginPermission[]; const javascriptPluginPermissions = ["pet:speak", "pet:reaction", "schedule", "storage", "status", "commands", "network"] as const satisfies readonly PluginJavascriptPermission[]; export const pluginPermissionSet: ReadonlySet = new Set(pluginPermissions); @@ -103,6 +107,7 @@ export function validatePluginManifest(input: unknown): PluginManifestValidation validateString(input.id, "$.id", "id", errors, /^[a-z0-9][a-z0-9._-]{1,62}[a-z0-9]$/); validateString(input.name, "$.name", "name", errors); validateString(input.version, "$.version", "version", errors, /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/); + validatePluginIcon(input.icon, errors); if (input.runtime === "javascript") { addError(errors, "$.runtime", "unsupported_runtime", 'Runtime "javascript" is recognized but unsupported in manifest v1. Use "declarative".'); @@ -124,6 +129,7 @@ function validateJavascriptPluginManifest(input: Record): Plugi validateString(input.id, "$.id", "id", errors, /^[a-z0-9][a-z0-9._-]{1,62}[a-z0-9]$/); validateString(input.name, "$.name", "name", errors); validateString(input.version, "$.version", "version", errors, /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/); + validatePluginIcon(input.icon, errors); if (input.runtime !== "javascript") addError(errors, "$.runtime", "invalid_runtime", 'manifestVersion 2 runtime must be "javascript".'); validateString(input.sdkVersion, "$.sdkVersion", "sdkVersion", errors, /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/); validateEntryPath(input.entry, errors); @@ -141,6 +147,11 @@ function validateJavascriptPermissions(value: unknown, errors: PluginManifestVal return permissions; } +function validatePluginIcon(value: unknown, errors: PluginManifestValidationError[]): void { + if (value === undefined) return; + if (typeof value !== "string" || !supportedPluginIcons.has(value)) addError(errors, "$.icon", "invalid_icon", "icon must be one of plugin, bell, timer, or github."); +} + function validateEntryPath(value: unknown, errors: PluginManifestValidationError[]): void { validateString(value, "$.entry", "entry", errors); if (typeof value !== "string") return; diff --git a/apps/desktop/src/plugin-service.ts b/apps/desktop/src/plugin-service.ts index 7bc25fa9..3f606b91 100644 --- a/apps/desktop/src/plugin-service.ts +++ b/apps/desktop/src/plugin-service.ts @@ -7,7 +7,7 @@ import { getEffectivePluginConfig, validatePluginConfigReplacement, type PluginC import { publishLocalPluginSnapshot, readLocalPluginSourceManifest } from "./plugin-local-loader.js"; import { readSafePluginManifest } from "./plugin-manifest-reader.js"; import type { PluginJsHost } from "./plugin-js-host.js"; -import { OPENPETS_PLUGIN_MANIFEST_FILENAME, type OpenPetsPluginManifest, type PluginPermission } from "./plugin-manifest.js"; +import { OPENPETS_PLUGIN_MANIFEST_FILENAME, type OpenPetsPluginManifest, type PluginIcon, type PluginPermission } from "./plugin-manifest.js"; import { downloadCatalogPluginZip, installCatalogPluginPackage, readCatalogPluginManifestFromZip, resolveSafePluginInstallDir } from "./plugin-package.js"; import type { PluginPetApi } from "./plugin-pet-api.js"; import { JsonPluginStorageStore, type PluginCommand, type PluginLogLevel, type PluginStatus } from "./plugin-sdk-bridge.js"; @@ -18,6 +18,7 @@ export type SafePluginRecord = { readonly id: string; readonly name?: string; readonly version: string; + readonly icon?: PluginIcon; readonly source: PluginSource; readonly enabled: boolean; readonly brokenReason?: string; @@ -35,7 +36,7 @@ export type SafePluginRecord = { }; export type PluginServiceSnapshot = { readonly plugins: readonly SafePluginRecord[] }; -export type SafeCatalogPluginRecord = { readonly id: string; readonly name: string; readonly version: string; readonly description: string; readonly runtime: "declarative" | "javascript"; readonly sdkVersion?: string; readonly permissions: readonly PluginPermission[]; readonly installed: boolean; readonly deprecated?: boolean; readonly statusReason?: string }; +export type SafeCatalogPluginRecord = { readonly id: string; readonly name: string; readonly version: string; readonly description: string; readonly runtime: "declarative" | "javascript"; readonly icon?: PluginIcon; readonly sdkVersion?: string; readonly permissions: readonly PluginPermission[]; readonly installed: boolean; readonly deprecated?: boolean; readonly statusReason?: string }; export type PluginCatalogSnapshot = { readonly plugins: readonly SafeCatalogPluginRecord[] }; export type PluginServiceResult = { readonly ok: true; readonly snapshot: PluginServiceSnapshot } | { readonly ok: false; readonly error: string; readonly snapshot: PluginServiceSnapshot }; export type DevPluginLoadResult = { readonly path: string; readonly id?: string; readonly ok: true } | { readonly path: string; readonly ok: false; readonly error: string }; @@ -157,7 +158,7 @@ export class PluginService { try { const catalog = await getPluginCatalog({ ...this.#catalogOptions, fetchImpl: this.#fetchImpl ?? this.#catalogOptions?.fetchImpl, refresh }); for (const entry of catalog.plugins) await this.#updateCatalogMetadata(entry); - return { plugins: catalog.plugins.filter((entry) => !isEntryDisabled(entry) && isCatalogEntryCompatible(entry.minOpenPetsVersion, getMaxVersion(entry), this.#currentAppVersion)).map((entry) => ({ id: entry.id, name: entry.name, version: entry.version, description: entry.description, runtime: entry.runtime, sdkVersion: getSdkVersion(entry), permissions: entry.permissions, installed: this.stateStore.getRecord(entry.id)?.source === "catalog", deprecated: isEntryDeprecated(entry) || undefined, statusReason: getStatusReason(entry) })) }; + return { plugins: catalog.plugins.filter((entry) => !isEntryDisabled(entry) && isCatalogEntryCompatible(entry.minOpenPetsVersion, getMaxVersion(entry), this.#currentAppVersion)).map((entry) => ({ id: entry.id, name: entry.name, version: entry.version, description: entry.description, runtime: entry.runtime, icon: entry.icon, sdkVersion: getSdkVersion(entry), permissions: entry.permissions, installed: this.stateStore.getRecord(entry.id)?.source === "catalog", deprecated: isEntryDeprecated(entry) || undefined, statusReason: getStatusReason(entry) })) }; } catch { return { plugins: [] }; } @@ -326,7 +327,7 @@ export class PluginService { const manifest = await this.#readManifest(record); const config = getEffectivePluginConfig(manifest, record.config); const runtimeState = typeof (this.runtime as unknown as { getPluginState?: unknown }).getPluginState === "function" ? this.runtime.getPluginState(record.id) : { commands: [] }; - return { ...base, brokenReason: sanitizePluginUiMessage(record.brokenReason), name: manifest.name, configSchema: manifest.configSchema, effectiveConfig: config.ok ? config.config : undefined, configErrors: config.ok ? undefined : config.errors, commands: runtimeState.commands, status: runtimeState.status }; + return { ...base, brokenReason: sanitizePluginUiMessage(record.brokenReason), name: manifest.name, icon: manifest.icon, configSchema: manifest.configSchema, effectiveConfig: config.ok ? config.config : undefined, configErrors: config.ok ? undefined : config.errors, commands: runtimeState.commands, status: runtimeState.status }; } catch (error) { return { ...base, brokenReason: sanitizePluginUiMessage(record.brokenReason) ?? safeError(error) }; } diff --git a/apps/desktop/src/renderer/src/main.tsx b/apps/desktop/src/renderer/src/main.tsx index 212a2ca6..4b0427dc 100644 --- a/apps/desktop/src/renderer/src/main.tsx +++ b/apps/desktop/src/renderer/src/main.tsx @@ -30,14 +30,15 @@ type DashboardSnapshot = { defaultPet: { id: string; displayName: string; previe type ReactionAnimationSettings = { reactions: { id: string; label: string; description: string; defaultAnimation: UserSelectableAnimationState }[]; animations: { id: UserSelectableAnimationState; label: string; description: string }[]; sprite: { frameWidth: number; frameHeight: number; columns: number; rows: number; states: Record }; overrides: ReactionAnimationOverrides; previewSpriteUrl: string }; type PluginFilter = "all" | "installed" | "catalog" | "local" | "broken"; type PluginPermission = "pet:speak" | "pet:reaction" | "timer" | "schedule" | "storage" | "status" | "commands" | "network"; +type PluginIconName = "plugin" | "bell" | "timer" | "github"; type PluginConfigField = { type: "text" | "textarea" | "number" | "boolean" | "select" | "time" | "multiSelect" | "list"; label?: string; description?: string; default?: string | number | boolean | string[] | Array>; options?: Array<{ label: string; value: string }>; min?: number; max?: number; step?: number; maxLength?: number; maxItems?: number; itemSchema?: Record }; type PluginConfigSchema = Record; type PluginConfig = Record; type PluginCommand = { id: string; title: string; description?: string }; type PluginStatus = { text: string; tone?: "info" | "success" | "warning" | "error" }; type PluginConfigError = { path?: string; code?: string; message?: string }; -type SafePluginRecord = { id: string; name?: string; version: string; source: "catalog" | "local"; enabled: boolean; brokenReason?: string; approvedPermissions: PluginPermission[]; runtime?: "declarative" | "javascript"; sdkVersion?: string; catalogDisabled?: boolean; catalogDeprecated?: boolean; catalogStatusReason?: string; configSchema?: PluginConfigSchema; effectiveConfig?: PluginConfig; configErrors?: PluginConfigError[]; commands?: PluginCommand[]; status?: PluginStatus }; -type SafeCatalogPluginRecord = { id: string; name: string; version: string; description: string; runtime: "declarative" | "javascript"; sdkVersion?: string; permissions: PluginPermission[]; installed: boolean; deprecated?: boolean; statusReason?: string }; +type SafePluginRecord = { id: string; name?: string; version: string; icon?: PluginIconName; source: "catalog" | "local"; enabled: boolean; brokenReason?: string; approvedPermissions: PluginPermission[]; runtime?: "declarative" | "javascript"; sdkVersion?: string; catalogDisabled?: boolean; catalogDeprecated?: boolean; catalogStatusReason?: string; configSchema?: PluginConfigSchema; effectiveConfig?: PluginConfig; configErrors?: PluginConfigError[]; commands?: PluginCommand[]; status?: PluginStatus }; +type SafeCatalogPluginRecord = { id: string; name: string; version: string; description: string; runtime: "declarative" | "javascript"; icon?: PluginIconName; sdkVersion?: string; permissions: PluginPermission[]; installed: boolean; deprecated?: boolean; statusReason?: string }; type PluginServiceSnapshot = { plugins: SafePluginRecord[] }; type PluginCatalogSnapshot = { plugins: SafeCatalogPluginRecord[] }; type PluginServiceResult = { ok: true; snapshot: PluginServiceSnapshot } | { ok: false; error: string; snapshot: PluginServiceSnapshot }; @@ -1021,6 +1022,25 @@ function PluginGlyph({ className = "plugin-glyph" }: { className?: string }) { ; } +function PluginIcon({ icon = "plugin", className = "plugin-glyph" }: { icon?: PluginIconName; className?: string }) { + if (icon === "bell") return ; + if (icon === "timer") return ; + if (icon === "github") return ; + return ; +} + +function pluginIcon(entry: PluginEntry): PluginIconName { + return entry.installed?.icon || entry.catalog?.icon || "plugin"; +} + function pluginName(entry: PluginEntry): string { return entry.installed?.name || entry.catalog?.name || entry.id; } @@ -1088,7 +1108,7 @@ function materializeConfigDraft(schema: PluginConfigSchema | undefined, config: return next; } -function ConfigFieldEditor({ fieldKey, field, value, onChange }: { fieldKey: string; field: PluginConfigField; value: unknown; onChange: (value: unknown) => void }) { +function ConfigFieldEditor({ pluginId, fieldKey, field, value, onChange }: { pluginId?: string; fieldKey: string; field: PluginConfigField; value: unknown; onChange: (value: unknown) => void }) { const label = field.label || fieldKey; const description = field.description; const textValue = typeof value === "string" ? value : typeof field.default === "string" ? field.default : ""; @@ -1103,18 +1123,84 @@ function ConfigFieldEditor({ fieldKey, field, value, onChange }: { fieldKey: str if (field.type === "list" && field.itemSchema) { const items = Array.isArray(value) ? value.filter((item): item is Record => item !== null && typeof item === "object" && !Array.isArray(item)) : []; const maxed = typeof field.maxItems === "number" && items.length >= field.maxItems; + + const isReminders = pluginId === "openpets.daily-reminders" && fieldKey === "reminders"; + const addLabel = isReminders ? "Add reminder" : "Add item"; + return
{label}{description && {description}}
- {items.map((item, index) => ( -
-
Item {index + 1}
- {Object.entries(field.itemSchema ?? {}).map(([childKey, childField]) => ( - onChange(items.map((existing, itemIndex) => itemIndex === index ? { ...existing, [childKey]: nextValue } : existing))} /> - ))} -
- ))} - + {items.map((item, index) => { + let itemTitle = `Item ${index + 1}`; + let removeLabel = "Remove"; + + if (isReminders) { + removeLabel = "Remove reminder"; + const id = String(item.id || "").trim(); + const scheduleType = item.scheduleType; + if (scheduleType === "daily") { + const time = String(item.time || "09:00"); + itemTitle = `${id || "Reminder"} · Daily at ${time}`; + } else if (scheduleType === "interval") { + const mins = Number(item.intervalMinutes) || 60; + itemTitle = `${id || "Reminder"} · Every ${mins} min`; + } else if (id) { + itemTitle = id; + } + } + + const schemaEntries = Object.entries(field.itemSchema ?? {}); + const messageField = schemaEntries.find(([k]) => k === "message"); + const scheduleFields = schemaEntries.filter(([k]) => ["scheduleType", "time", "days", "intervalMinutes"].includes(k)); + const behaviorFields = schemaEntries.filter(([k]) => ["id", "enabled", "reaction"].includes(k)); + const otherFields = schemaEntries.filter(([k]) => !["message", "scheduleType", "time", "days", "intervalMinutes", "id", "enabled", "reaction"].includes(k)); + + const renderField = ([childKey, childField]: [string, PluginConfigField]) => { + if (isReminders) { + const scheduleType = item.scheduleType; + if (scheduleType === "daily" && childKey === "intervalMinutes") return null; + if (scheduleType === "interval" && (childKey === "time" || childKey === "days")) return null; + } + return onChange(items.map((existing, itemIndex) => itemIndex === index ? { ...existing, [childKey]: nextValue } : existing))} />; + }; + + return ( +
+
+ {itemTitle} + +
+
+ {isReminders ? ( + <> + {behaviorFields.length > 0 && ( +
+
Identity & Behavior
+ {behaviorFields.map(renderField)} +
+ )} + {messageField && ( +
+
Message
+ {renderField(messageField)} +
+ )} + {scheduleFields.length > 0 && ( +
+
Schedule
+ {scheduleFields.map(renderField)} +
+ )} + {otherFields.map(renderField)} + + ) : ( + schemaEntries.map(renderField) + )} +
+
+ ); + })} +
; } @@ -1692,7 +1778,7 @@ function PluginsView() { {filteredEntries.map((entry) => (
- +
{pluginName(entry)} {pluginDescription(entry)} @@ -1755,7 +1841,7 @@ function PluginsView() { {selected ? <>
- +

Plugin Configuration

{pluginName(selected)}

{pluginDescription(selected)}

@@ -1781,10 +1867,22 @@ function PluginsView() { {!!installed.configErrors?.length &&
ConfigurationNeeds attention
    {installed.configErrors.map((configError, index) =>
  • {configError.message || String(configError)}
  • )}
} {installed.configSchema &&
SettingsConfiguration
-
{Object.entries(installed.configSchema).map(([key, field]) => updateDraft(key, value)} />)}
+
{Object.entries(installed.configSchema).map(([key, field]) => updateDraft(key, value)} />)}
} - {!!installed.commands?.length &&
CommandsQuick actions
{installed.commands.map((command) => )}
} + {!!installed.commands?.length &&
+
CommandsQuick actions
+
+ {installed.commands.map((command) => ( +
+ + {command.description && {command.description}} +
+ ))} +
+
}
{installed.source === "catalog" && catalogPlugin && catalogPlugin.version !== installed.version && } diff --git a/apps/desktop/src/renderer/src/styles.css b/apps/desktop/src/renderer/src/styles.css index 0cfa8aa9..446fdfab 100644 --- a/apps/desktop/src/renderer/src/styles.css +++ b/apps/desktop/src/renderer/src/styles.css @@ -146,6 +146,7 @@ .plugin-card-body { @apply flex items-start gap-4 p-4; } .plugin-card-icon { @apply grid h-14 w-14 shrink-0 place-items-center rounded-2xl border border-blue-100/60 bg-gradient-to-br from-white to-blue-100 text-brand shadow-sm; } + .plugin-card-icon .plugin-glyph { @apply h-7 w-7; } .plugin-glyph { @apply h-7 w-7 stroke-[2.5]; } .plugin-card-content { @apply flex min-w-0 flex-1 flex-col gap-1; } @@ -181,6 +182,7 @@ @keyframes plugin-pop-in { from { opacity: 0; transform: scale(0.96) translateY(10px); } to { opacity: 1; transform: scale(1) translateY(0); } } .plugin-inspector-head { @apply flex items-center gap-4; } .plugin-inspector-icon { @apply grid h-16 w-16 shrink-0 place-items-center rounded-[22px] border border-blue-100/70 bg-gradient-to-br from-white to-blue-100 text-brand shadow-sm; } + .plugin-inspector-icon .plugin-glyph { @apply h-8 w-8; } .plugin-inspector h2 { @apply m-0 font-monoDisplay text-3xl font-black text-navy leading-tight; } .plugin-status-strip { @apply flex items-center gap-2 rounded-2xl border border-blue-100/60 bg-blue-50/60 p-3 text-xs font-semibold text-slatecopy shadow-inner; } .plugin-section { @apply flex flex-col gap-3 rounded-[24px] border border-blue-100/70 bg-white/65 p-5 shadow-sm; } @@ -192,6 +194,8 @@ .plugin-toggle-row { @apply rounded-2xl border-b-0 bg-white/65; } .plugin-permissions { @apply mt-0; } .plugin-config-form { @apply flex flex-col gap-3; } + .plugin-config-group { @apply flex flex-col gap-2 p-3 rounded-2xl border border-blue-100/40 bg-blue-50/10; } + .plugin-config-group-title { @apply font-monoDisplay text-[10px] font-black uppercase tracking-wider text-brand/70 mb-1 px-1; } .plugin-config-row { @apply flex flex-col gap-2 rounded-2xl border border-blue-50 bg-white/60 p-3; } .plugin-config-row > span:first-child { @apply flex flex-col gap-0.5; } .plugin-config-row strong { @apply text-sm font-bold text-navy; } diff --git a/apps/desktop/tests/plugin-catalog-validation.test.ts b/apps/desktop/tests/plugin-catalog-validation.test.ts index d6918fac..8b75424f 100644 --- a/apps/desktop/tests/plugin-catalog-validation.test.ts +++ b/apps/desktop/tests/plugin-catalog-validation.test.ts @@ -11,10 +11,12 @@ assert.throws(() => validatePluginCatalog({ version: 1, generatedAt: "now", plug assert.throws(() => validatePluginCatalog({ version: 1, generatedAt: "now", plugins: [entry, entry] }), /Duplicate plugin id/); assert.throws(() => validatePluginCatalog({ version: 1, generatedAt: "now", plugins: [{ ...entry, sha256: "A".repeat(64) }] }), /sha256/); -const catalogV2 = validatePluginCatalog({ version: 2, generatedAt: "now", plugins: [{ ...entry, id: "js-plugin", runtime: "javascript", sdkVersion: "1.0.0", permissions: ["pet:speak", "network"], minOpenPetsVersion: "2.0.0", maxOpenPetsVersion: "3.0.0", deprecated: true, statusReason: "Use another plugin", network: { hosts: ["api.example.com"] } }] }); +const catalogV2 = validatePluginCatalog({ version: 2, generatedAt: "now", plugins: [{ ...entry, id: "js-plugin", runtime: "javascript", icon: "github", sdkVersion: "1.0.0", permissions: ["pet:speak", "network"], minOpenPetsVersion: "2.0.0", maxOpenPetsVersion: "3.0.0", deprecated: true, statusReason: "Use another plugin", network: { hosts: ["api.example.com"] } }] }); assert.equal(catalogV2.version, 2); assert.equal(catalogV2.plugins[0].runtime, "javascript"); +assert.equal(catalogV2.plugins[0].icon, "github"); assert.throws(() => validatePluginCatalog({ version: 2, generatedAt: "now", plugins: [{ ...entry, runtime: "python" }] }), /runtime/); +assert.throws(() => validatePluginCatalog({ version: 2, generatedAt: "now", plugins: [{ ...entry, icon: "https://example.com/icon.svg" }] }), /icon/); assert.throws(() => validatePluginCatalog({ version: 2, generatedAt: "now", plugins: [{ ...entry, runtime: "javascript", sdkVersion: "1.0.0", permissions: ["pet:speak", "network"], network: { hosts: ["*.example.com"] } }] }), /network/); console.error("Plugin catalog validation passed."); diff --git a/codemap.md b/codemap.md index 2b52edb1..98b57525 100644 --- a/codemap.md +++ b/codemap.md @@ -14,6 +14,7 @@ OpenPets is a pnpm/TypeScript monorepo for an Electron desktop companion app plu - `packages/client/src/index.ts`: public IPC client API consumed by integrations and tools. - `packages/cursor/src/index.ts`: Cursor MCP/rules setup API. - `packages/pi/src/extension.ts`: Pi coding-agent extension runtime entry point. +- `plugins/official/`: first-party plugin product source consumed by desktop dev mode and web catalog sync. ## Directory Map @@ -48,6 +49,8 @@ OpenPets is a pnpm/TypeScript monorepo for an Electron desktop companion app plu | `packages/pi/src/` | Pi extension entry point, event classification, OpenPets command parsing, and validation checks. | [View Map](packages/pi/src/codemap.md) | | `packages/pet-format/` | Minimal package marker/type interface for OpenPets pet package identity. | [View Map](packages/pet-format/codemap.md) | | `packages/pet-format/src/` | Marker source export for pet-format package consumers. | [View Map](packages/pet-format/src/codemap.md) | +| `plugins/` | Root product source for first-party OpenPets plugins before web catalog packaging and R2 upload. | | +| `plugins/official/` | Official plugin manifests and single-file JavaScript entries loaded by desktop dev mode and packaged by web sync. | | ## Architecture Flow diff --git a/docs/plugins.md b/docs/plugins.md index 9ad76a71..12997c1c 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -281,14 +281,20 @@ Plugin ZIP installs validate: - JavaScript entry presence inside package - safe install/uninstall path containment -The web repo builds catalog artifacts with: +The repository root provides safe plugin catalog commands: ```bash -cd web -node scripts/sync-plugins.js --dry-run --skip-r2 +pnpm plugins:test +pnpm plugins:check +pnpm plugins:package ``` -Publishing uploads plugin ZIPs and regenerates public catalog files. +`plugins:check` dry-runs package validation, while `plugins:package` writes `web/public/plugins/*.json` and stages ZIPs under `web/.data/plugin-zips` without uploading to R2. Publishing is separate: + +```bash +pnpm plugins:publish +pnpm plugins:deploy +``` ## Local development workflow @@ -303,7 +309,7 @@ For this repository, run from the root: pnpm dev:desktop:plugins ``` -This points desktop at `web/plugins/official`, snapshots each official plugin into the app data `plugins-dev` directory, auto-approves permissions for those explicit dev paths, preserves enabled state when permissions/hosts remain compatible, and starts the runtime. +This points desktop at `plugins/official`, snapshots each official plugin into the app data `plugins-dev` directory, auto-approves permissions for those explicit dev paths, preserves enabled state when permissions/hosts remain compatible, and starts the runtime. To load one plugin manually: @@ -396,11 +402,12 @@ Desktop plugin/runtime validation: pnpm --filter @open-pets/desktop test ``` -Web plugin catalog dry-run: +Plugin catalog validation and local packaging: ```bash -cd web -node scripts/sync-plugins.js --dry-run --skip-r2 +pnpm plugins:test +pnpm plugins:check +pnpm plugins:package ``` Manual dogfood: diff --git a/docs/release.md b/docs/release.md index 239db299..1bab9b86 100644 --- a/docs/release.md +++ b/docs/release.md @@ -130,32 +130,40 @@ Manual desktop QA: Web release includes: -- `web/plugins/official/**` source plugins. +- `plugins/official/**` source plugins. - `web/public/plugins/catalog.v2.json` with the three official plugins. - `web/public/plugins/catalog.v1.json` with an empty plugin list. - Removal of legacy sample plugin manifests. - Updated `web/docs/plugin-publishing.md`. -Required validation from `web/`: +Required validation from the repository root: ```bash -node scripts/sync-plugins.js --dry-run --skip-r2 -bun run generate +pnpm plugins:test +pnpm plugins:check +pnpm plugins:package +pnpm --dir web generate ``` Publishing sequence: -1. From `web/`, upload plugin ZIPs and regenerate catalogs: +1. From the repository root, validate and stage local catalog/ZIP artifacts: ```bash - bun run sync:plugins + pnpm plugins:test + pnpm plugins:check + pnpm plugins:package ``` -2. Confirm `public/plugins/catalog.v2.json` has only the three official plugins. -3. Confirm `public/plugins/catalog.v1.json` has `plugins: []`. -4. Deploy web: +2. Confirm `web/public/plugins/catalog.v2.json` has only the three official plugins. +3. Confirm `web/public/plugins/catalog.v1.json` has `plugins: []`. +4. Upload plugin ZIPs to R2 and regenerate catalogs: ```bash - bun run deploy + pnpm plugins:publish ``` -5. Verify live endpoints: +5. Deploy web: + ```bash + pnpm plugins:deploy + ``` +6. Verify live endpoints: - `https://openpets.dev/plugins/catalog.v2.json` - `https://openpets.dev/plugins/catalog.v1.json` - each `https://zip.openpets.dev/plugins/.zip` diff --git a/package.json b/package.json index fe8b49ad..6254f59e 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,11 @@ "package:desktop": "pnpm build && pnpm --filter @open-pets/desktop package", "release:desktop": "node apps/desktop/scripts/release-local.mjs", "release:npm": "node scripts/release-npm.mjs", + "plugins:test": "node scripts/test-plugins.mjs", + "plugins:check": "node web/scripts/sync-plugins.js --dry-run --skip-r2", + "plugins:package": "node web/scripts/sync-plugins.js --skip-r2", + "plugins:publish": "node web/scripts/sync-plugins.js", + "plugins:deploy": "pnpm --dir web deploy", "test": "pnpm build && pnpm -r --if-present test", "check": "pnpm -r check", "typecheck": "pnpm -r typecheck", diff --git a/plugins/official/openpets.daily-reminders/index.js b/plugins/official/openpets.daily-reminders/index.js new file mode 100644 index 00000000..e6ab3ca1 --- /dev/null +++ b/plugins/official/openpets.daily-reminders/index.js @@ -0,0 +1,211 @@ +export const MIN_INTERVAL_MINUTES = 10; +export const MAX_INTERVAL_MINUTES = 1440; +export const MAX_MESSAGE_LENGTH = 140; +export const MAX_ID_LENGTH = 64; +export const DEFAULT_REACTION = "waving"; +export const DEFAULT_MESSAGE = "Time for a gentle reminder."; +export const DEFAULT_SNOOZE_MINUTES = 10; +export const VALID_REACTIONS = ["waving", "waiting", "success", "celebrating"]; +const UNSAFE_MESSAGE_PATTERN = /```||\b(class|import|export|const|let|var)\b|https?:\/\/|www\.|\/[\w.-]+\/[\w./-]+|[A-Za-z]:\\|api[_-]?key|secret|token|password|passwd|BEGIN [A-Z ]+PRIVATE KEY/i; +export const DEFAULT_REMINDERS = [ + { + id: "morning-focus", + enabled: true, + message: "Good morning! Pick one meaningful task to start with.", + reaction: "waving", + scheduleType: "daily", + time: "09:00", + days: ["1", "2", "3", "4", "5"], + intervalMinutes: 60, + }, + { + id: "stretch-break", + enabled: true, + message: "Tiny stretch break: relax your shoulders and look away for a moment.", + reaction: "waiting", + scheduleType: "interval", + time: "10:00", + days: ["1", "2", "3", "4", "5"], + intervalMinutes: 90, + }, +]; + +export function sanitizeId(value, index) { + const raw = typeof value === "string" && value.trim() ? value.trim() : `reminder-${index + 1}`; + return raw.replace(/[^A-Za-z0-9._:-]/g, "-").slice(0, MAX_ID_LENGTH) || `reminder-${index + 1}`; +} + +export function normalizeMessage(value) { + const message = typeof value === "string" && value.trim() ? value.trim().replace(/[\r\n]+/g, " ").replace(/\s+/g, " ") : DEFAULT_MESSAGE; + const capped = message.length > MAX_MESSAGE_LENGTH ? message.slice(0, MAX_MESSAGE_LENGTH).trim() : message; + if (!capped || UNSAFE_MESSAGE_PATTERN.test(capped)) return DEFAULT_MESSAGE; + return capped; +} + +export function normalizeTime(value) { + const match = /^(\d{2}):(\d{2})$/.exec(String(value ?? "")); + if (!match) return "09:00"; + const hours = Number(match[1]); + const minutes = Number(match[2]); + return hours >= 0 && hours <= 23 && minutes >= 0 && minutes <= 59 ? `${match[1]}:${match[2]}` : "09:00"; +} + +export function normalizeIntervalMinutes(value) { + const interval = Number(value); + if (!Number.isFinite(interval)) return MIN_INTERVAL_MINUTES; + return Math.min(MAX_INTERVAL_MINUTES, Math.max(MIN_INTERVAL_MINUTES, Math.floor(interval))); +} + +export function normalizeSnoozeMinutes(value) { + const minutes = Number(value); + if (!Number.isFinite(minutes)) return DEFAULT_SNOOZE_MINUTES; + return Math.min(120, Math.max(1, Math.round(minutes))); +} + +export function normalizeReminder(value, index) { + const reminder = value && typeof value === "object" ? value : {}; + const scheduleType = reminder.scheduleType === "interval" ? "interval" : "daily"; + const days = Array.isArray(reminder.days) + ? reminder.days.map((day) => Number(day)).filter((day) => Number.isInteger(day) && day >= 0 && day <= 6) + : undefined; + return { + id: sanitizeId(reminder.id, index), + enabled: reminder.enabled !== false, + message: normalizeMessage(reminder.message), + reaction: VALID_REACTIONS.includes(reminder.reaction) ? reminder.reaction : DEFAULT_REACTION, + scheduleType, + time: normalizeTime(reminder.time), + days, + intervalMinutes: normalizeIntervalMinutes(reminder.intervalMinutes), + }; +} + +export function getConfiguredReminders(config) { + return Array.isArray(config?.reminders) ? config.reminders : DEFAULT_REMINDERS; +} + +export function getReminders(config) { + return getConfiguredReminders(config).map(normalizeReminder).filter((reminder) => reminder.enabled); +} + +export async function fireReminder(ctx, reminder) { + await ctx.pet.speak(reminder.message); + await ctx.pet.react(reminder.reaction); + await ctx.storage.set("lastTriggered", { id: reminder.id, message: reminder.message, reaction: reminder.reaction, at: new Date().toISOString() }); +} + +export async function snoozeLast(ctx, config) { + const last = await ctx.storage.get("lastTriggered"); + if (!last || typeof last !== "object" || !last.id) { + await ctx.pet.speak("No reminder to snooze yet. I will remember after one fires."); + return false; + } + const reminder = normalizeReminder(last, 0); + const minutes = normalizeSnoozeMinutes(config?.snoozeMinutes); + const safeId = `snooze-${sanitizeId(reminder.id, 0)}-${Date.now()}`.slice(0, MAX_ID_LENGTH); + await ctx.schedule.once(safeId, minutes * 60_000, () => fireReminder(ctx, reminder)); + await ctx.storage.set("lastSnoozed", { id: reminder.id, at: new Date().toISOString(), minutes }); + await ctx.pet.speak(`Snoozed ${reminder.id} for ${minutes} minutes.`); + return true; +} + +export function statusText(reminders) { + if (reminders.length === 0) return { text: "No reminders enabled", tone: "warning" }; + const daily = reminders.filter((reminder) => reminder.scheduleType === "daily").length; + const interval = reminders.length - daily; + const parts = []; + if (daily) parts.push(`${daily} daily`); + if (interval) parts.push(`${interval} interval`); + const intervals = reminders.filter((r) => r.scheduleType === "interval"); + const dailies = reminders.filter((r) => r.scheduleType === "daily").sort((a, b) => a.time.localeCompare(b.time) || a.id.localeCompare(b.id)); + const next = intervals.length + ? `Next: every ${Math.min(...intervals.map((r) => r.intervalMinutes))} min` + : dailies[0] + ? `Next: ${dailies[0].id} at ${dailies[0].time}` + : ""; + return { text: `${parts.join(", ")} reminder${reminders.length === 1 ? "" : "s"} enabled${next ? ` · ${next}` : ""}`, tone: "info" }; +} + +export function summaryText(reminders) { + if (reminders.length === 0) return "No enabled reminders. Add or enable one in plugin settings."; + return reminders.map((reminder) => reminder.scheduleType === "interval" ? `${reminder.id}: every ${reminder.intervalMinutes} min` : `${reminder.id}: daily at ${reminder.time}${reminder.days?.length ? ` on days ${reminder.days.join(",")}` : ""}`).join("; "); +} + +export function makeScheduleIds(reminders) { + const seen = new Set(); + return reminders.map((reminder, index) => { + const rawBase = `reminder-${sanitizeId(reminder.id, index)}`; + for (let count = 0; count <= reminders.length; count += 1) { + const suffix = count === 0 ? "" : `-${count + 1}`; + const candidate = `${rawBase.slice(0, MAX_ID_LENGTH - suffix.length)}${suffix}`; + if (!seen.has(candidate)) { + seen.add(candidate); + return candidate; + } + } + const fallback = `reminder-${index + 1}`.slice(0, MAX_ID_LENGTH); + seen.add(fallback); + return fallback; + }); +} + +export async function reschedule(ctx, config) { + await ctx.schedule.cancelAll(); + const reminders = getReminders(config); + const scheduleIds = makeScheduleIds(reminders); + let failed = false; + for (const [index, reminder] of reminders.entries()) { + const scheduleId = scheduleIds[index]; + try { + if (reminder.scheduleType === "interval") { + await ctx.schedule.every(scheduleId, reminder.intervalMinutes * 60_000, () => fireReminder(ctx, reminder)); + } else { + const spec = reminder.days && reminder.days.length > 0 ? { time: reminder.time, days: reminder.days } : { time: reminder.time }; + await ctx.schedule.daily(scheduleId, spec, () => fireReminder(ctx, reminder)); + } + } catch (error) { + failed = true; + ctx.log?.warn?.("Daily reminder schedule registration failed", scheduleId, error?.message || String(error)); + } + } + await ctx.status.set(failed ? { text: "Reminder schedule registration failed", tone: "error" } : statusText(reminders)); +} + +export function register(OpenPetsPlugin) { + OpenPetsPlugin.register({ + async start(ctx) { + let config = await ctx.config.get(); + await reschedule(ctx, config); + + await ctx.commands.register({ id: "preview-first", title: "Preview first reminder", description: "Speak and react with the first enabled reminder now." }, async () => { + const reminders = getReminders(await ctx.config.get()); + if (reminders[0]) await fireReminder(ctx, reminders[0]); + }); + + const startupReminders = getReminders(config).slice(0, 16); + const startupScheduleIds = makeScheduleIds(startupReminders); + for (const [index, reminder] of startupReminders.entries()) { + await ctx.commands.register({ id: `preview-${startupScheduleIds[index]}`, title: `Preview: ${reminder.id}`, description: `Preview reminder ${reminder.id}.` }, async () => fireReminder(ctx, reminder)); + } + + await ctx.commands.register({ id: "show-summary", title: "Reminder Summary", description: "Speak a concise summary of enabled reminders." }, async () => { + await ctx.pet.speak(normalizeMessage(summaryText(getReminders(await ctx.config.get())))); + }); + + await ctx.commands.register({ id: "snooze-last", title: "Snooze last reminder", description: "Remind me again after the configured snooze time." }, async () => { + await snoozeLast(ctx, await ctx.config.get()); + }); + + await ctx.commands.register({ id: "reload-reminders", title: "Reload reminder schedules", description: "Refresh reminder timers from current settings." }, async () => { + config = await ctx.config.get(); + await reschedule(ctx, config); + }); + + ctx.config.onChange(async (nextConfig) => { + config = nextConfig; + await reschedule(ctx, config); + }); + }, + async stop() {} + }); +} diff --git a/plugins/official/openpets.daily-reminders/openpets.plugin.json b/plugins/official/openpets.daily-reminders/openpets.plugin.json new file mode 100644 index 00000000..e89a7698 --- /dev/null +++ b/plugins/official/openpets.daily-reminders/openpets.plugin.json @@ -0,0 +1,121 @@ +{ + "manifestVersion": 2, + "id": "openpets.daily-reminders", + "name": "Daily Reminders", + "version": "1.2.0", + "runtime": "javascript", + "icon": "bell", + "sdkVersion": "1.0.0", + "entry": "index.js", + "permissions": ["pet:speak", "pet:reaction", "schedule", "storage", "commands", "status"], + "configSchema": { + "snoozeMinutes": { + "type": "number", + "label": "Snooze minutes", + "description": "How long Snooze last reminder waits before reminding again.", + "default": 10, + "min": 1, + "max": 120, + "step": 5 + }, + "reminders": { + "type": "list", + "label": "Reminders", + "description": "Daily and interval reminders your pet can announce.", + "maxItems": 16, + "default": [ + { + "id": "morning-focus", + "enabled": true, + "message": "Good morning! Pick one meaningful task to start with.", + "reaction": "waving", + "scheduleType": "daily", + "time": "09:00", + "days": ["1", "2", "3", "4", "5"], + "intervalMinutes": 60 + }, + { + "id": "stretch-break", + "enabled": true, + "message": "Tiny stretch break: relax your shoulders and look away for a moment.", + "reaction": "waiting", + "scheduleType": "interval", + "time": "10:00", + "days": ["1", "2", "3", "4", "5"], + "intervalMinutes": 90 + } + ], + "itemSchema": { + "id": { + "type": "text", + "label": "ID", + "description": "Unique reminder ID using letters, numbers, dot, dash, or underscore.", + "default": "reminder", + "maxLength": 48 + }, + "enabled": { + "type": "boolean", + "label": "Enabled", + "default": true + }, + "message": { + "type": "textarea", + "label": "Message", + "description": "What your pet says when this reminder triggers.", + "default": "Time for a gentle reminder.", + "maxLength": 140 + }, + "reaction": { + "type": "select", + "label": "Reaction", + "default": "waving", + "options": [ + { "label": "Waving", "value": "waving" }, + { "label": "Waiting", "value": "waiting" }, + { "label": "Success", "value": "success" }, + { "label": "Celebrating", "value": "celebrating" } + ] + }, + "scheduleType": { + "type": "select", + "label": "Schedule type", + "default": "daily", + "options": [ + { "label": "Daily", "value": "daily" }, + { "label": "Interval", "value": "interval" } + ] + }, + "time": { + "type": "time", + "label": "Daily time", + "description": "Used for daily reminders in HH:mm format.", + "default": "09:00" + }, + "days": { + "type": "multiSelect", + "label": "Days", + "description": "Days for daily reminders. Leave empty for every day.", + "default": ["1", "2", "3", "4", "5"], + "options": [ + { "label": "Sunday", "value": "0" }, + { "label": "Monday", "value": "1" }, + { "label": "Tuesday", "value": "2" }, + { "label": "Wednesday", "value": "3" }, + { "label": "Thursday", "value": "4" }, + { "label": "Friday", "value": "5" }, + { "label": "Saturday", "value": "6" } + ] + }, + "intervalMinutes": { + "type": "number", + "label": "Interval minutes", + "description": "Used for interval reminders. Minimum runtime interval is 10 minutes.", + "default": 60, + "min": 10, + "max": 1440, + "step": 5 + } + } + } + } +} diff --git a/plugins/official/openpets.daily-reminders/test.js b/plugins/official/openpets.daily-reminders/test.js new file mode 100644 index 00000000..edd62bb2 --- /dev/null +++ b/plugins/official/openpets.daily-reminders/test.js @@ -0,0 +1,133 @@ +import assert from "node:assert/strict"; + +import { + DEFAULT_REMINDERS, + MAX_ID_LENGTH, + MAX_MESSAGE_LENGTH, + DEFAULT_MESSAGE, + getReminders, + makeScheduleIds, + normalizeIntervalMinutes, + normalizeReminder, + normalizeSnoozeMinutes, + register, + reschedule, + snoozeLast, + statusText, + summaryText, +} from "./index.js"; + +function createCtx(config = {}) { + const store = new Map(); + const calls = { speak: [], react: [], storage: [], daily: [], every: [], once: [], cancelAll: 0, status: [], commands: new Map(), warnings: [] }; + const ctx = { + pet: { + speak: async (message) => calls.speak.push(message), + react: async (reaction) => calls.react.push(reaction), + }, + storage: { get: async (key) => store.get(key), set: async (key, value) => { store.set(key, value); calls.storage.push([key, value]); } }, + schedule: { + cancelAll: async () => calls.cancelAll++, + daily: (id, spec, fn) => calls.daily.push({ id, spec, fn }), + every: (id, interval, fn) => calls.every.push({ id, interval, fn }), + once: (id, delay, fn) => calls.once.push({ id, delay, fn }), + }, + status: { set: async (value) => calls.status.push(value) }, + commands: { register: async (command, fn) => calls.commands.set(command.id, { command, fn }) }, + config: { get: async () => config, onChange: () => {} }, + log: { warn: (...args) => calls.warnings.push(args) }, + }; + return { ctx, calls }; +} + +assert.equal(getReminders({}).length, DEFAULT_REMINDERS.length, "missing config uses manifest-equivalent defaults"); +assert.equal(getReminders({ reminders: [] }).length, 0, "explicit empty config keeps reminders disabled"); + +const normalized = normalizeReminder({ + id: "x".repeat(100), + message: "m".repeat(200), + reaction: "not-real", + time: "99:99", + intervalMinutes: Infinity, +}, 0); +assert.equal(normalized.id.length, MAX_ID_LENGTH); +assert.equal(normalized.message.length, MAX_MESSAGE_LENGTH); +assert.equal(normalized.reaction, "waving"); +assert.equal(normalized.time, "09:00"); +assert.equal(normalized.intervalMinutes, 10); +assert.equal(normalizeIntervalMinutes("bad"), 10); +assert.equal(normalizeSnoozeMinutes("bad"), 10); +assert.equal(normalizeSnoozeMinutes(0), 1); +assert.equal(normalizeSnoozeMinutes(999), 120); +assert.equal(normalizeReminder({ message: "line one\nline two" }, 0).message, "line one line two"); +assert.equal(normalizeReminder({ message: "https://example.test" }, 0).message, DEFAULT_MESSAGE); +assert.equal(normalizeReminder({ message: "password reminder" }, 0).message, DEFAULT_MESSAGE); + +assert.deepEqual(getReminders({ reminders: [{ id: "off", enabled: false }] }), [], "disabled reminders are skipped"); + +const duplicateReminders = getReminders({ reminders: [ + { id: "same", enabled: true }, + { id: "same", enabled: true }, + { id: "L".repeat(120), enabled: true }, + { id: `${"L".repeat(80)}-different`, enabled: true }, +] }); +const scheduleIds = makeScheduleIds(duplicateReminders); +assert.equal(new Set(scheduleIds).size, scheduleIds.length, "duplicate reminder ids produce unique schedule ids"); +assert.ok(scheduleIds.every((id) => id.length <= MAX_ID_LENGTH), "schedule ids stay within max length"); + +{ + const { ctx, calls } = createCtx({ reminders: [ + { id: "daily", scheduleType: "daily", time: "08:30", days: ["1", "5"] }, + { id: "interval", scheduleType: "interval", intervalMinutes: 15 }, + { id: "disabled", enabled: false, scheduleType: "interval", intervalMinutes: 15 }, + ] }); + await reschedule(ctx, await ctx.config.get()); + assert.equal(calls.cancelAll, 1); + assert.equal(calls.daily.length, 1); + assert.deepEqual(calls.daily[0].spec, { time: "08:30", days: [1, 5] }); + assert.equal(calls.every.length, 1); + assert.equal(calls.every[0].interval, 15 * 60_000); + assert.ok(calls.status.at(-1).text.includes("Next: every 15 min")); +} + +{ + const { ctx, calls } = createCtx({ reminders: [{ id: "bad", scheduleType: "interval", intervalMinutes: 15 }] }); + ctx.schedule.every = async () => { throw new Error("nope"); }; + await reschedule(ctx, await ctx.config.get()); + assert.equal(calls.warnings.length, 1); + assert.equal(calls.status.at(-1).tone, "error"); +} + +{ + const { ctx, calls } = createCtx({ reminders: [{ id: "preview", message: "Preview me", reaction: "success" }] }); + const plugin = { register: (definition) => { plugin.definition = definition; } }; + register(plugin); + await plugin.definition.start(ctx); + await calls.commands.get("preview-first").fn(); + assert.deepEqual(calls.speak, ["Preview me"]); + assert.deepEqual(calls.react, ["success"]); + assert.equal(calls.storage[0][0], "lastTriggered"); + assert.ok(calls.commands.get("show-summary").command.title.includes("Summary")); + assert.ok(calls.commands.get("snooze-last").command.title.includes("Snooze")); + assert.ok(calls.commands.has("preview-reminder-preview")); + await calls.commands.get("preview-reminder-preview").fn(); + assert.equal(calls.speak.at(-1), "Preview me"); + await calls.commands.get("snooze-last").fn(); + assert.equal(calls.once.length, 1); + assert.equal(calls.once[0].delay, 10 * 60_000); + await calls.commands.get("show-summary").fn(); + assert.ok(calls.speak.at(-1).includes("preview")); +} + +{ + const { ctx, calls } = createCtx({ snoozeMinutes: 1200 }); + await snoozeLast(ctx, await ctx.config.get()); + assert.equal(calls.once.length, 0); + assert.ok(calls.speak[0].includes("No reminder")); +} + +assert.deepEqual(statusText([]), { text: "No reminders enabled", tone: "warning" }); +assert.ok(statusText(duplicateReminders).text.includes("daily")); +assert.ok(summaryText(duplicateReminders).includes("same")); + +console.log("Daily Reminders plugin tests passed."); diff --git a/plugins/official/openpets.github-notifications/index.js b/plugins/official/openpets.github-notifications/index.js new file mode 100644 index 00000000..a508e026 --- /dev/null +++ b/plugins/official/openpets.github-notifications/index.js @@ -0,0 +1,205 @@ +export const MAX_REPOS = 10; +export const MAX_MESSAGE_LENGTH = 140; +export const DEFAULT_NOTIFICATION_MESSAGE = "New GitHub notification."; +export const EMPTY_BASELINE = "__openpets_empty__"; +const UNSAFE_MESSAGE_PATTERN = /```||\b(class|import|export|const|let|var)\b|https?:\/\/|www\.|\/[\w.-]+\/[\w./-]+|[A-Za-z]:\\|api[_-]?key|secret|token|password|passwd|BEGIN [A-Z ]+PRIVATE KEY/i; +let checkRunning = false; + +export function register(OpenPetsPlugin) { + OpenPetsPlugin.register({ + async start(ctx) { + await ctx.commands.register({ id: "check-now", title: "Check GitHub now", description: "Check configured public repositories now." }, async () => { + void checkNow(ctx, true).catch((error) => ctx.log?.warn?.("GitHub manual check failed", error?.message || String(error))); + await ctx.status.set({ text: "GitHub: checking now…", tone: "info" }); + }); + await ctx.commands.register({ id: "reset-baseline", title: "Reset GitHub baseline", description: "Mark current releases, issues, pull requests, and failed workflows as seen." }, async () => { + void resetBaseline(ctx).catch((error) => ctx.log?.warn?.("GitHub baseline reset failed", error?.message || String(error))); + await ctx.status.set({ text: "GitHub: resetting baseline…", tone: "info" }); + }); + await ctx.commands.register({ id: "show-last-check", title: "Show last GitHub check", description: "Speak the latest GitHub notification check summary." }, async () => await showLastCheck(ctx)); + await scheduleNext(ctx); + void checkNow(ctx, false).catch((error) => ctx.log?.warn?.("GitHub initial check failed", error?.message || String(error))); + }, + }); +} + +if (typeof globalThis.OpenPetsPlugin !== "undefined") register(globalThis.OpenPetsPlugin); + +export async function scheduleNext(ctx) { + const config = await ctx.config.get(); + const interval = Math.max(10, Number(config.pollIntervalMinutes || 30)); + await ctx.schedule.cancel("poll"); + await ctx.schedule.every("poll", interval * 60 * 1000, async () => await checkNow(ctx, false)); + await ctx.status.set({ text: `GitHub: next check ${new Date(Date.now() + interval * 60 * 1000).toLocaleTimeString()}`, tone: "info" }); +} + +export async function checkNow(ctx, manual) { + if (checkRunning) { + if (manual) await ctx.pet.speak("GitHub check already running."); + return { at: new Date().toISOString(), repos: 0, notifications: 0, failures: 0, skipped: true, reason: "already-running" }; + } + checkRunning = true; + try { + const config = await ctx.config.get(); + const parsed = parseReposDetailed(config.repositories); + const repoLimit = repoLimitForConfig(config); + const repos = parsed.repos.slice(0, repoLimit); + const truncated = parsed.truncated || parsed.repos.length > repoLimit; + if (repos.length === 0) { await ctx.status.set({ text: parsed.invalid.length ? "GitHub: fix invalid repositories" : "GitHub: add public repositories", tone: "warning" }); if (manual && parsed.invalid.length) await ctx.pet.speak(`Invalid GitHub repositories ignored: ${parsed.invalid.slice(0, 3).join(", ")}.`); return { repos: 0, notifications: 0, failures: 0, invalid: parsed.invalid, truncated }; } + const baseline = (await ctx.storage.get("baselineComplete")) === true; + const events = []; + let failures = 0; + let backoffSkipped = 0; + for (const repo of repos) { + try { + const backoffUntil = Number(await ctx.storage.get(`backoff:${repo}`) || 0); + if (backoffUntil > Date.now()) { backoffSkipped += 1; continue; } + if (config.notifyReleases !== false) events.push(...await checkRelease(ctx, repo, config, baseline)); + if (config.notifyFailedWorkflows !== false) events.push(...await checkWorkflow(ctx, repo, config, baseline)); + if (config.notifyIssues === true) events.push(...await checkIssue(ctx, repo, config, baseline)); + if (config.notifyPullRequests === true) events.push(...await checkPullRequest(ctx, repo, config, baseline)); + } catch (error) { + failures += 1; + if (isBackoffError(error)) await ctx.storage.set(`backoff:${repo}`, Date.now() + 15 * 60 * 1000); + ctx.log?.warn?.("GitHub repo check failed", repo, error?.message || String(error)); + } + } + await notifyBatch(ctx, events); + if (!baseline) await ctx.storage.set("baselineComplete", true); + const notifications = events.length; + const summary = { at: new Date().toISOString(), repos: repos.length, notifications, failures, invalid: parsed.invalid, truncated, backoffSkipped }; + await ctx.storage.set("lastCheck", summary); + await ctx.status.set({ text: `GitHub: checked ${repos.length}, ${notifications} new${failures ? `, ${failures} failed` : ""}`, tone: failures ? "warning" : notifications ? "success" : "info" }); + if (manual) { + if (parsed.invalid.length || truncated) await ctx.pet.speak(`GitHub ignored ${parsed.invalid.length} invalid${truncated ? " and extra" : ""} repository entries.`); + else if (notifications === 0 && failures === 0) await ctx.pet.speak(backoffSkipped ? `No new GitHub notifications. ${backoffSkipped} repo checks are cooling down.` : "No new GitHub notifications."); + else if (failures) await ctx.pet.speak(notifications ? `GitHub check found ${notifications} new notifications, with ${failures} repo failures.` : `GitHub check had ${failures} repo failures and no new notifications.`); + } + const interval = Math.max(10, Number(config.pollIntervalMinutes || 30)); + await ctx.schedule.cancel("poll"); + await ctx.schedule.every("poll", interval * 60 * 1000, async () => await checkNow(ctx, false)); + return summary; + } finally { checkRunning = false; } +} + +export async function showLastCheck(ctx) { + const last = await ctx.storage.get("lastCheck"); + if (!last || typeof last !== "object") { await ctx.pet.speak("No GitHub check has completed yet."); return; } + await ctx.pet.speak(`Last GitHub check: ${last.repos || 0} repos, ${last.notifications || 0} new, ${last.failures || 0} failed.`); +} + +export async function resetBaseline(ctx) { + await ctx.storage.delete("baselineComplete"); + const summary = await checkNow(ctx, false); + if (summary.failures || summary.backoffSkipped) await ctx.pet.speak(`GitHub baseline partially reset. ${summary.failures || 0} failed, ${summary.backoffSkipped || 0} cooling down.`); + else await ctx.pet.speak("GitHub notification baseline reset."); +} + +async function checkRelease(ctx, repo, config, baseline) { + const res = await github(ctx, `/repos/${repo}/releases?per_page=1`, `etag:release:${repo}`); + if (res.notModified) return []; + const release = Array.isArray(res.json) ? res.json[0] : undefined; + return handleNewest(ctx, `release:${repo}`, release && String(release.id || release.tag_name || ""), baseline, { type: "release", repo, message: format(config.releaseMessage || "New release: {repo} {tag}", { repo, tag: release?.tag_name || "" }), reaction: config.releaseReaction || "celebrating" }); +} + +async function checkWorkflow(ctx, repo, config, baseline) { + const branch = await defaultBranch(ctx, repo); + const res = await github(ctx, `/repos/${repo}/actions/runs?status=completed&per_page=10&branch=${encodeURIComponent(branch)}`, `etag:workflow:${repo}`); + if (res.notModified) return []; + const run = (res.json?.workflow_runs || []).find((item) => ["failure", "timed_out", "action_required"].includes(item?.conclusion)); + return handleNewest(ctx, `workflow:${repo}`, run && String(run.id || ""), baseline, { type: "workflow", repo, message: format(config.workflowMessage || "Workflow failed in {repo}: {name}", { repo, name: run?.name || "workflow" }), reaction: config.workflowReaction || "error" }); +} + +async function checkIssue(ctx, repo, config, baseline) { + const res = await github(ctx, `/repos/${repo}/issues?state=open&per_page=10&sort=created&direction=desc`, `etag:issue:${repo}`); + if (res.notModified) return []; + const issue = (Array.isArray(res.json) ? res.json : []).find((item) => item && !item.pull_request); + return handleNewest(ctx, `issue:${repo}`, issue && String(issue.id || issue.number || ""), baseline, { type: "issue", repo, message: format(config.issueMessage || "New issue in {repo}: {name}", { repo, name: issue?.title || `#${issue?.number}` }), reaction: config.issueReaction || "thinking" }); +} + +async function checkPullRequest(ctx, repo, config, baseline) { + const res = await github(ctx, `/repos/${repo}/pulls?state=open&per_page=1&sort=created&direction=desc`, `etag:pr:${repo}`); + if (res.notModified) return []; + const pr = Array.isArray(res.json) ? res.json[0] : undefined; + return handleNewest(ctx, `pr:${repo}`, pr && String(pr.id || pr.number || ""), baseline, { type: "pr", repo, message: format(config.pullRequestMessage || "New pull request in {repo}: {name}", { repo, name: pr?.title || `#${pr?.number}` }), reaction: config.pullRequestReaction || "waving" }); +} + +async function handleNewest(ctx, key, id, baseline, event) { + const previous = String((await ctx.storage.get(key)) || ""); + const next = id || EMPTY_BASELINE; + + if (!baseline || !previous) { + await ctx.storage.set(key, next); + return []; + } + + if (!id) { + if (previous !== EMPTY_BASELINE) await ctx.storage.set(key, EMPTY_BASELINE); + return []; + } + + await ctx.storage.set(key, id); + if (id === previous) return []; + return [event]; +} + +async function defaultBranch(ctx, repo) { + const key = `repo:${repo}`; + const cached = await ctx.storage.get(key); + if (cached?.default_branch) return cached.default_branch; + const res = await github(ctx, `/repos/${repo}`, `etag:repo:${repo}`); + const branch = res.json?.default_branch || "main"; + await ctx.storage.set(key, { default_branch: branch }); + return branch; +} + +async function notifyBatch(ctx, events) { + if (!events.length) return; + const order = { workflow: 0, release: 1, pr: 2, issue: 3 }; + events.sort((a, b) => order[a.type] - order[b.type]); + const first = events[0]; + await ctx.pet.react(safeMessage(first.reaction, "idle")); + await ctx.pet.speak(events.length === 1 ? first.message : `GitHub: ${events.length} new notifications. ${first.message}`); +} + +function isBackoffError(error) { const text = String(error?.message || error); return /\b(403|429)\b|network|fetch|timeout/i.test(text); } + +export async function github(ctx, path, etagKey) { + const headers = { accept: "application/vnd.github+json", "user-agent": "OpenPets GitHub Notifications" }; + const etag = await ctx.storage.get(etagKey); + if (typeof etag === "string" && etag) headers["if-none-match"] = etag; + const res = await ctx.http.fetch(`https://api.github.com${path}`, { headers, timeoutMs: 10000 }); + if (res.headers?.etag) await ctx.storage.set(etagKey, res.headers.etag); + if (res.status === 304) return { ...res, json: undefined, notModified: true }; + if (!res.ok) throw new Error(`GitHub API returned ${res.status}`); + return res; +} + +export async function notify(ctx, message, reaction) { await ctx.pet.react(safeMessage(reaction, "idle")); await ctx.pet.speak(safeMessage(message)); } +export function safeMessage(value, fallback = DEFAULT_NOTIFICATION_MESSAGE) { + const message = typeof value === "string" && value.trim() ? value.trim().replace(/[\r\n]+/g, " ").replace(/\s+/g, " ") : fallback; + const capped = message.length > MAX_MESSAGE_LENGTH ? message.slice(0, MAX_MESSAGE_LENGTH).trim() : message; + if (!capped || UNSAFE_MESSAGE_PATTERN.test(capped)) return fallback; + return capped; +} +export function parseRepos(value) { + return parseReposDetailed(value).repos; +} +export function parseReposDetailed(value) { + const raw = Array.isArray(value) ? value.join("\n") : String(value || ""); + const valid = []; + const invalid = []; + for (const item of raw.split(/[\n,\s]+/).map((x) => x.trim()).filter(Boolean)) (/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(item) ? valid : invalid).push(item); + const unique = Array.from(new Set(valid)); + return { repos: unique.slice(0, MAX_REPOS), invalid, truncated: unique.length > MAX_REPOS }; +} +export function repoLimitForConfig(config = {}) { + let callsPerRepo = 0; + if (config.notifyReleases !== false) callsPerRepo += 1; + if (config.notifyFailedWorkflows !== false) callsPerRepo += 2; + if (config.notifyIssues === true) callsPerRepo += 1; + if (config.notifyPullRequests === true) callsPerRepo += 1; + return Math.max(1, Math.min(MAX_REPOS, Math.floor(28 / Math.max(1, callsPerRepo)))); +} +export function format(template, values) { return safeMessage(String(template).replace(/\{(repo|tag|name)\}/g, (_m, key) => safeTemplateValue(values[key] || ""))); } +export function safeTemplateValue(value) { return String(value).replace(/[\r\n]+/g, " ").replace(/\//g, " ").replace(/\s+/g, " ").trim().slice(0, 80); } diff --git a/plugins/official/openpets.github-notifications/openpets.plugin.json b/plugins/official/openpets.github-notifications/openpets.plugin.json new file mode 100644 index 00000000..4cccdeea --- /dev/null +++ b/plugins/official/openpets.github-notifications/openpets.plugin.json @@ -0,0 +1,28 @@ +{ + "manifestVersion": 2, + "id": "openpets.github-notifications", + "name": "GitHub Notifications", + "version": "1.1.0", + "runtime": "javascript", + "icon": "github", + "sdkVersion": "1.0.0", + "entry": "index.js", + "permissions": ["network", "schedule", "storage", "pet:speak", "pet:reaction", "commands", "status"], + "network": { "hosts": ["api.github.com"] }, + "configSchema": { + "repositories": { "type": "textarea", "label": "Public repositories", "description": "One owner/repo per line. Public repositories only.", "default": "" }, + "pollIntervalMinutes": { "type": "number", "label": "Poll interval minutes", "default": 30, "min": 10, "max": 1440, "step": 5 }, + "notifyReleases": { "type": "boolean", "label": "Notify new releases", "default": true }, + "notifyFailedWorkflows": { "type": "boolean", "label": "Notify failed workflows", "default": true }, + "notifyIssues": { "type": "boolean", "label": "Notify new issues", "default": false }, + "notifyPullRequests": { "type": "boolean", "label": "Notify new pull requests", "default": false }, + "releaseMessage": { "type": "text", "label": "Release message", "default": "New release: {repo} {tag}", "maxLength": 140 }, + "workflowMessage": { "type": "text", "label": "Workflow failure message", "default": "Workflow failed in {repo}: {name}", "maxLength": 140 }, + "issueMessage": { "type": "text", "label": "Issue message", "default": "New issue in {repo}: {name}", "maxLength": 140 }, + "pullRequestMessage": { "type": "text", "label": "Pull request message", "default": "New pull request in {repo}: {name}", "maxLength": 140 }, + "releaseReaction": { "type": "select", "label": "Release reaction", "default": "celebrating", "options": [{ "label": "Celebrating", "value": "celebrating" }, { "label": "Success", "value": "success" }, { "label": "Waving", "value": "waving" }, { "label": "Idle", "value": "idle" }] }, + "workflowReaction": { "type": "select", "label": "Workflow reaction", "default": "error", "options": [{ "label": "Error", "value": "error" }, { "label": "Thinking", "value": "thinking" }, { "label": "Waiting", "value": "waiting" }, { "label": "Idle", "value": "idle" }] }, + "issueReaction": { "type": "select", "label": "Issue reaction", "default": "thinking", "options": [{ "label": "Thinking", "value": "thinking" }, { "label": "Waving", "value": "waving" }, { "label": "Waiting", "value": "waiting" }, { "label": "Idle", "value": "idle" }] }, + "pullRequestReaction": { "type": "select", "label": "Pull request reaction", "default": "waving", "options": [{ "label": "Waving", "value": "waving" }, { "label": "Success", "value": "success" }, { "label": "Thinking", "value": "thinking" }, { "label": "Idle", "value": "idle" }] } + } +} diff --git a/plugins/official/openpets.github-notifications/test.js b/plugins/official/openpets.github-notifications/test.js new file mode 100644 index 00000000..eafec813 --- /dev/null +++ b/plugins/official/openpets.github-notifications/test.js @@ -0,0 +1,115 @@ +import assert from "node:assert/strict"; +import { EMPTY_BASELINE, checkNow, format, parseRepos, parseReposDetailed, register, resetBaseline, repoLimitForConfig, safeMessage, showLastCheck } from "./index.js"; + +function harness(config, routes) { + const store = new Map(); + const calls = { speak: [], react: [], status: [], every: [], cancel: [], commands: new Map(), warnings: [] }; + return { store, calls, ctx: { + config: { get: async () => config }, storage: { get: async (k) => store.get(k), set: async (k, v) => store.set(k, v), delete: async (k) => store.delete(k) }, + schedule: { cancel: async (id) => calls.cancel.push(id), every: async (id, ms, fn) => calls.every.push({ id, ms, fn }) }, status: { set: async (v) => calls.status.push(v) }, + pet: { speak: async (m) => calls.speak.push(m), react: async (r) => calls.react.push(r) }, commands: { register: async (c, f) => calls.commands.set(c.id, { c, f }) }, log: { warn: (...a) => calls.warnings.push(a) }, + http: { fetch: async (url) => { const key = new URL(url).pathname + new URL(url).search; const value = routes[key]; if (value instanceof Error) throw value; return { ok: true, status: 200, headers: {}, json: value || [] }; } } + }}; +} + +assert.deepEqual(parseRepos("a/b\na/b bad nope c/d"), ["a/b", "c/d"]); +assert.deepEqual(parseReposDetailed("a/b bad").invalid, ["bad"]); +assert.equal(repoLimitForConfig({ notifyIssues: true, notifyPullRequests: true }), 5); +assert.equal(repoLimitForConfig({ notifyIssues: false, notifyPullRequests: false }), 9); +assert.equal(safeMessage("secret token"), "New GitHub notification."); +assert.equal(format("New {repo}: {name}", { repo: "o/r", name: "hello/world" }), "New o r: hello world"); + +const routes1 = { "/repos/o/r": { default_branch: "trunk" }, "/repos/o/r/releases?per_page=1": [{ id: 1, tag_name: "v1" }], "/repos/o/r/actions/runs?status=completed&per_page=10&branch=trunk": { workflow_runs: [{ id: 2, name: "ci", conclusion: "failure" }] }, "/repos/o/r/issues?state=open&per_page=10&sort=created&direction=desc": [{ id: 3, title: "bug" }], "/repos/o/r/pulls?state=open&per_page=1&sort=created&direction=desc": [{ id: 4, title: "fix" }] }; +{ + const h = harness({ repositories: "o/r", notifyIssues: true, notifyPullRequests: true }, routes1); + await checkNow(h.ctx, false); + assert.equal(h.calls.speak.length, 0, "baseline does not notify"); + await checkNow(h.ctx, true); + assert.equal(h.calls.speak.at(-1), "No new GitHub notifications."); +} +{ + const emptyRoutes = { ...routes1, "/repos/o/r/releases?per_page=1": [], "/repos/o/r/actions/runs?status=completed&per_page=10&branch=trunk": { workflow_runs: [] }, "/repos/o/r/issues?state=open&per_page=10&sort=created&direction=desc": [], "/repos/o/r/pulls?state=open&per_page=1&sort=created&direction=desc": [] }; + const h = harness({ repositories: "o/r", notifyIssues: true, notifyPullRequests: true }, emptyRoutes); + await checkNow(h.ctx, false); + assert.equal(h.store.get("release:o/r"), EMPTY_BASELINE); + h.ctx.http.fetch = async (url) => ({ ok: true, status: 200, headers: {}, json: routes1[new URL(url).pathname + new URL(url).search] || [] }); + const result = await checkNow(h.ctx, false); + assert.equal(result.notifications, 4, "first real events after empty baseline notify"); +} +{ + const h = harness({ repositories: "o/r", notifyIssues: false, notifyPullRequests: false }, routes1); + await checkNow(h.ctx, false); + h.ctx.config.get = async () => ({ repositories: "o/r", notifyIssues: true, notifyPullRequests: true }); + const result = await checkNow(h.ctx, false); + assert.equal(result.notifications, 0, "newly enabled event types baseline without announcing old items"); + assert.equal(h.store.get("issue:o/r"), "3"); + assert.equal(h.store.get("pr:o/r"), "4"); +} +{ + const h = harness({ repositories: "o/r", notifyFailedWorkflows: false }, routes1); + await checkNow(h.ctx, false); + assert.equal(h.store.get("release:o/r"), "1"); + h.ctx.http.fetch = async () => ({ ok: true, status: 304, headers: {}, json: undefined }); + await checkNow(h.ctx, false); + assert.equal(h.store.get("release:o/r"), "1", "304 does not overwrite existing seen id with empty baseline"); + h.ctx.http.fetch = async (url) => ({ ok: true, status: 200, headers: {}, json: routes1[new URL(url).pathname + new URL(url).search] || [] }); + const result = await checkNow(h.ctx, false); + assert.equal(result.notifications, 0, "200 -> 304 -> 200 same id does not re-announce"); +} +{ + const routes2 = { ...routes1, "/repos/o/r/releases?per_page=1": [{ id: 10, tag_name: "v2" }], "/repos/o/r/actions/runs?status=completed&per_page=10&branch=trunk": { workflow_runs: [{ id: 20, name: "ci", conclusion: "timed_out" }] }, "/repos/o/r/issues?state=open&per_page=10&sort=created&direction=desc": [{ id: 30, title: "bug2" }, { id: 31, pull_request: {}, title: "pr as issue" }], "/repos/o/r/pulls?state=open&per_page=1&sort=created&direction=desc": [{ id: 40, title: "fix2" }] }; + const h = harness({ repositories: "o/r", notifyIssues: true, notifyPullRequests: true }, routes1); + await checkNow(h.ctx, false); h.ctx.http.fetch = async (url) => ({ ok: true, status: 200, headers: {}, json: routes2[new URL(url).pathname + new URL(url).search] || [] }); + const result = await checkNow(h.ctx, false); + assert.equal(result.notifications, 4); + assert.equal(h.calls.speak.length, 1, "batched notifications use one speech"); +} +{ + const h = harness({ repositories: "o/r x/y", notifyIssues: true }, { ...routes1, "/repos/x/y/releases?per_page=1": new Error("boom") }); + h.ctx.http.fetch = async (url) => { const key = new URL(url).pathname + new URL(url).search; const value = h.ctx.config && { ...routes1, "/repos/x/y/releases?per_page=1": new Error("boom") }[key]; if (value instanceof Error) throw value; return { ok: true, status: 200, headers: {}, json: value || [] }; }; + const result = await checkNow(h.ctx, true); + assert.equal(result.failures, 1); + assert.ok(h.calls.speak.at(-1).includes("failures")); + await showLastCheck(h.ctx); assert.ok(h.calls.speak.at(-1).includes("Last GitHub check")); +} +{ + const h = harness({ repositories: "bad o/r" }, routes1); + const result = await checkNow(h.ctx, true); + assert.equal(result.invalid.length, 1); + assert.ok(h.calls.speak.at(-1).includes("invalid")); +} +{ + const h = harness({ repositories: "o/r" }, { ...routes1, "/repos/o/r/releases?per_page=1": new Error("network down") }); + await checkNow(h.ctx, false); + assert.ok(Number(h.store.get("backoff:o/r")) > Date.now()); + const result = await checkNow(h.ctx, true); + assert.equal(result.backoffSkipped, 1); +} +{ + const h = harness({ repositories: "o/r x/y", notifyIssues: true }, { ...routes1, "/repos/x/y/releases?per_page=1": new Error("network down") }); + h.ctx.http.fetch = async (url) => { const key = new URL(url).pathname + new URL(url).search; const value = { ...routes1, "/repos/x/y/releases?per_page=1": new Error("network down") }[key]; if (value instanceof Error) throw value; return { ok: true, status: 200, headers: {}, json: value || [] }; }; + await resetBaseline(h.ctx); + assert.ok(h.calls.speak.at(-1).includes("partially reset")); +} +{ + const h = harness({ repositories: "o/r" }, routes1); + let resolveFetch; + let blocked = true; + h.ctx.http.fetch = async (url) => { + if (blocked) { blocked = false; return await new Promise((resolve) => { resolveFetch = () => resolve({ ok: true, status: 200, headers: {}, json: [] }); }); } + const key = new URL(url).pathname + new URL(url).search; + return { ok: true, status: 200, headers: {}, json: routes1[key] || [] }; + }; + const first = checkNow(h.ctx, false); + await Promise.resolve(); await Promise.resolve(); + const second = await checkNow(h.ctx, true); + assert.equal(second.reason, "already-running"); + assert.equal(h.calls.speak.at(-1), "GitHub check already running."); + resolveFetch(); await first; +} +{ + const h = harness({}, {}); const plugin = { register(def) { this.def = def; } }; register(plugin); await plugin.def.start(h.ctx); assert.ok(h.calls.commands.has("show-last-check")); + await h.calls.commands.get("check-now").f(); + assert.ok(h.calls.status.at(-1).text.includes("checking")); +} +console.log("GitHub Notifications plugin tests passed."); diff --git a/plugins/official/openpets.pomodoro/index.js b/plugins/official/openpets.pomodoro/index.js new file mode 100644 index 00000000..c3af52aa --- /dev/null +++ b/plugins/official/openpets.pomodoro/index.js @@ -0,0 +1,271 @@ +export const STATE_KEY = "pomodoroState"; +export const SCHEDULE_ID = "phase-end"; +const MIN_DELAY_MS = 1; +const MAX_MESSAGE_LENGTH = 140; +const UNSAFE_MESSAGE_PATTERN = /```||\b(class|import|export|const|let|var)\b|https?:\/\/|www\.|\/[\w.-]+\/[\w./-]+|[A-Za-z]:\\|api[_-]?key|secret|token|password|passwd|BEGIN [A-Z ]+PRIVATE KEY/i; + +const DEFAULTS = { + focusMinutes: 25, + shortBreakMinutes: 5, + longBreakMinutes: 15, + sessionsBeforeLongBreak: 4, + autoStartBreaks: false, + autoStartFocus: false, + focusStartMessage: "Focus time! Pick one task and protect your attention.", + focusCompleteMessage: "Focus session complete. Nice work!", + breakStartMessage: "Break time. Stretch, hydrate, and rest your eyes.", + breakCompleteMessage: "Break complete. Ready for the next focus block?", + focusStartReaction: "waving", + focusCompleteReaction: "success", + breakStartReaction: "waiting", + breakCompleteReaction: "waving", +}; + +export function clampNumber(value, fallback, min, max) { + const number = Number(value); + if (!Number.isFinite(number)) return fallback; + return Math.min(max, Math.max(min, Math.round(number))); +} + +export function normalizeConfig(config = {}) { + return { + focusMinutes: clampNumber(config.focusMinutes, DEFAULTS.focusMinutes, 1, 180), + shortBreakMinutes: clampNumber(config.shortBreakMinutes, DEFAULTS.shortBreakMinutes, 1, 60), + longBreakMinutes: clampNumber(config.longBreakMinutes, DEFAULTS.longBreakMinutes, 1, 120), + sessionsBeforeLongBreak: clampNumber(config.sessionsBeforeLongBreak, DEFAULTS.sessionsBeforeLongBreak, 1, 12), + autoStartBreaks: config.autoStartBreaks === true, + autoStartFocus: config.autoStartFocus === true, + focusStartMessage: text(config.focusStartMessage, DEFAULTS.focusStartMessage), + focusCompleteMessage: text(config.focusCompleteMessage, DEFAULTS.focusCompleteMessage), + breakStartMessage: text(config.breakStartMessage, DEFAULTS.breakStartMessage), + breakCompleteMessage: text(config.breakCompleteMessage, DEFAULTS.breakCompleteMessage), + focusStartReaction: text(config.focusStartReaction, DEFAULTS.focusStartReaction), + focusCompleteReaction: text(config.focusCompleteReaction, DEFAULTS.focusCompleteReaction), + breakStartReaction: text(config.breakStartReaction, DEFAULTS.breakStartReaction), + breakCompleteReaction: text(config.breakCompleteReaction, DEFAULTS.breakCompleteReaction), + }; +} + +function text(value, fallback) { + const message = typeof value === "string" && value.trim() ? value.trim().replace(/[\r\n]+/g, " ").replace(/\s+/g, " ") : fallback; + const capped = message.length > MAX_MESSAGE_LENGTH ? message.slice(0, MAX_MESSAGE_LENGTH).trim() : message; + if (!capped || UNSAFE_MESSAGE_PATTERN.test(capped)) return fallback; + return capped; +} + +export function today() { + return new Date().toISOString().slice(0, 10); +} + +export function idleState(completedSessions = 0, completedToday = 0) { + return { phase: "idle", completedSessions, completedToday, lastActiveDate: today() }; +} + +export async function getState(ctx) { + const saved = await ctx.storage.get(STATE_KEY); + if (!saved || typeof saved !== "object") return idleState(); + const activeDate = typeof saved.lastActiveDate === "string" ? saved.lastActiveDate : today(); + const sameDay = activeDate === today(); + return { + phase: typeof saved.phase === "string" ? saved.phase : "idle", + previousPhase: typeof saved.previousPhase === "string" ? saved.previousPhase : undefined, + endAt: typeof saved.endAt === "string" ? saved.endAt : undefined, + remainingMs: Number.isFinite(Number(saved.remainingMs)) ? Number(saved.remainingMs) : undefined, + pendingBreakPhase: ["shortBreak", "longBreak"].includes(saved.pendingBreakPhase) ? saved.pendingBreakPhase : undefined, + completedSessions: Math.max(0, Math.round(Number(saved.completedSessions) || 0)), + completedToday: sameDay ? Math.max(0, Math.round(Number(saved.completedToday) || 0)) : 0, + lastCompletedAt: typeof saved.lastCompletedAt === "string" ? saved.lastCompletedAt : undefined, + lastActiveDate: today(), + }; +} + +export async function setState(ctx, state) { + await ctx.storage.set(STATE_KEY, { ...state, lastActiveDate: today() }); + await updateStatus(ctx, state); +} + +function phaseLabel(phase) { + if (phase === "focus") return "Focus"; + if (phase === "shortBreak") return "Short break"; + if (phase === "longBreak") return "Long break"; + if (phase === "paused") return "Paused"; + return "Idle"; +} + +export async function updateStatus(ctx, state) { + if (state.phase === "idle") { + if (state.pendingBreakPhase) { + await ctx.status.set({ text: `${phaseLabel(state.pendingBreakPhase)} ready (${state.completedToday || 0} completed today)`, tone: "success" }); + return; + } + await ctx.status.set({ text: `Pomodoro idle (${state.completedToday || 0} completed today)`, tone: "info" }); + return; + } + if (state.phase === "paused") { + await ctx.status.set({ text: `Paused with ${formatMs(state.remainingMs || 0)} left`, tone: "warning" }); + return; + } + const end = state.endAt ? new Date(state.endAt) : undefined; + await ctx.status.set({ text: `${phaseLabel(state.phase)} until ${end && !Number.isNaN(end.getTime()) ? end.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) : "soon"}`, tone: "success" }); +} + +export function formatMs(ms) { + const minutes = Math.max(1, Math.ceil(ms / 60_000)); + return `${minutes} min`; +} + +export function durationForPhase(phase, config) { + if (phase === "focus") return config.focusMinutes * 60_000; + if (phase === "longBreak") return config.longBreakMinutes * 60_000; + return config.shortBreakMinutes * 60_000; +} + +export function nextBreakPhase(completedSessions, config) { + return completedSessions > 0 && completedSessions % config.sessionsBeforeLongBreak === 0 ? "longBreak" : "shortBreak"; +} + +async function announce(ctx, message, reaction) { + await ctx.pet.speak(message); + await ctx.pet.react(reaction); +} + +export async function schedulePhaseEnd(ctx, state) { + await ctx.schedule.cancel(SCHEDULE_ID); + if (!state.endAt || !["focus", "shortBreak", "longBreak"].includes(state.phase)) return; + const delay = new Date(state.endAt).getTime() - Date.now(); + if (!Number.isFinite(delay) || delay < MIN_DELAY_MS) return; + await ctx.schedule.once(SCHEDULE_ID, Math.max(MIN_DELAY_MS, delay), () => completePhase(ctx)); +} + +export async function startPhase(ctx, phase, durationMs, options = {}) { + const previous = await getState(ctx); + const state = { phase, endAt: new Date(Date.now() + Math.max(MIN_DELAY_MS, durationMs)).toISOString(), remainingMs: undefined, completedSessions: previous.completedSessions || 0, completedToday: previous.completedToday || 0, lastCompletedAt: previous.lastCompletedAt, lastActiveDate: today() }; + await setState(ctx, state); + await schedulePhaseEnd(ctx, state); + if (options.announce !== false) { + const config = normalizeConfig(await ctx.config.get()); + if (phase === "focus") await announce(ctx, config.focusStartMessage, config.focusStartReaction); + else await announce(ctx, config.breakStartMessage, config.breakStartReaction); + } +} + +export async function completePhase(ctx) { + await ctx.schedule.cancel(SCHEDULE_ID); + const config = normalizeConfig(await ctx.config.get()); + const state = await getState(ctx); + if (state.phase === "focus") { + const completedSessions = (state.completedSessions || 0) + 1; + const completedToday = (state.completedToday || 0) + 1; + const lastCompletedAt = new Date().toISOString(); + await announce(ctx, config.focusCompleteMessage, config.focusCompleteReaction); + const breakPhase = nextBreakPhase(completedSessions, config); + await setState(ctx, { ...idleState(completedSessions, completedToday), lastCompletedAt, phase: "idle", pendingBreakPhase: config.autoStartBreaks ? undefined : breakPhase }); + if (config.autoStartBreaks) await startPhase(ctx, breakPhase, durationForPhase(breakPhase, config), { announce: false }); + return; + } + if (state.phase === "shortBreak" || state.phase === "longBreak") { + await announce(ctx, config.breakCompleteMessage, config.breakCompleteReaction); + const completedSessions = state.completedSessions || 0; + await setState(ctx, { ...idleState(completedSessions, state.completedToday || 0), lastCompletedAt: state.lastCompletedAt }); + if (config.autoStartFocus) await startPhase(ctx, "focus", durationForPhase("focus", config)); + } +} + +export async function pause(ctx) { + const state = await getState(ctx); + if (!["focus", "shortBreak", "longBreak"].includes(state.phase) || !state.endAt) return; + await ctx.schedule.cancel(SCHEDULE_ID); + await setState(ctx, { phase: "paused", previousPhase: state.phase, remainingMs: Math.max(MIN_DELAY_MS, new Date(state.endAt).getTime() - Date.now()), completedSessions: state.completedSessions || 0, completedToday: state.completedToday || 0, lastCompletedAt: state.lastCompletedAt, lastActiveDate: today() }); +} + +export async function resume(ctx) { + const state = await getState(ctx); + if (state.phase !== "paused") return; + const phase = ["focus", "shortBreak", "longBreak"].includes(state.previousPhase) ? state.previousPhase : "focus"; + await startPhase(ctx, phase, Math.max(MIN_DELAY_MS, state.remainingMs || MIN_DELAY_MS), { announce: false }); +} + +export async function stop(ctx) { + const state = await getState(ctx); + await ctx.schedule.cancel(SCHEDULE_ID); + await setState(ctx, { ...idleState(state.completedSessions || 0, state.completedToday || 0), lastCompletedAt: state.lastCompletedAt }); +} + +export function statusSummary(state) { + if (state.phase === "paused") return `Pomodoro paused with ${formatMs(state.remainingMs || 0)} left.`; + if (["focus", "shortBreak", "longBreak"].includes(state.phase)) return `${phaseLabel(state.phase)} running until ${state.endAt ? new Date(state.endAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) : "soon"}. ${state.completedToday || 0} completed today.`; + if (state.pendingBreakPhase) return `Pomodoro idle. ${phaseLabel(state.pendingBreakPhase)} is ready. ${state.completedToday || 0} focus sessions completed today.`; + return `Pomodoro idle. ${state.completedToday || 0} focus sessions completed today.`; +} + +export async function reconcileStartup(ctx) { + const state = await getState(ctx); + if (!["focus", "shortBreak", "longBreak"].includes(state.phase) || !state.endAt || new Date(state.endAt).getTime() > Date.now()) return state; + await ctx.schedule.cancel(SCHEDULE_ID); + const config = normalizeConfig(await ctx.config.get()); + if (state.phase === "focus") { + const completedSessions = (state.completedSessions || 0) + 1; + const completedToday = (state.completedToday || 0) + 1; + const pendingBreakPhase = nextBreakPhase(completedSessions, config); + const next = { ...idleState(completedSessions, completedToday), lastCompletedAt: new Date().toISOString(), pendingBreakPhase }; + await setState(ctx, next); + await announce(ctx, "Focus ended while you were away. Your next break is ready.", config.focusCompleteReaction); + return next; + } + const next = { ...idleState(state.completedSessions || 0, state.completedToday || 0), lastCompletedAt: state.lastCompletedAt }; + await setState(ctx, next); + await announce(ctx, "Break ended while you were away.", config.breakCompleteReaction); + return next; +} + +export async function startNextBreak(ctx) { + const state = await getState(ctx); + const phase = ["shortBreak", "longBreak"].includes(state.pendingBreakPhase) ? state.pendingBreakPhase : undefined; + if (!phase) { await ctx.pet.speak("No pending Pomodoro break is ready."); return false; } + const config = normalizeConfig(await ctx.config.get()); + await startPhase(ctx, phase, durationForPhase(phase, config)); + return true; +} + +export async function resetCount(ctx) { + const state = await getState(ctx); + await setState(ctx, { ...state, completedSessions: 0, completedToday: 0, lastCompletedAt: undefined }); + await ctx.pet.speak("Pomodoro counts reset for today."); +} + +export function register(OpenPetsPlugin) { + OpenPetsPlugin.register({ + async start(ctx) { + const state = await reconcileStartup(ctx); + await updateStatus(ctx, state); + await schedulePhaseEnd(ctx, state); + + await ctx.commands.register({ id: "start-focus", title: "Start focus", description: "Start a Pomodoro focus session." }, async () => { + const config = normalizeConfig(await ctx.config.get()); + await startPhase(ctx, "focus", durationForPhase("focus", config)); + }); + await ctx.commands.register({ id: "start-short-break", title: "Start short break", description: "Start a short Pomodoro break." }, async () => { + const config = normalizeConfig(await ctx.config.get()); + await startPhase(ctx, "shortBreak", durationForPhase("shortBreak", config)); + }); + await ctx.commands.register({ id: "start-long-break", title: "Start long break", description: "Start a long Pomodoro break." }, async () => { + const config = normalizeConfig(await ctx.config.get()); + await startPhase(ctx, "longBreak", durationForPhase("longBreak", config)); + }); + await ctx.commands.register({ id: "start-next-break", title: "Start next break", description: "Start the pending Pomodoro break." }, () => startNextBreak(ctx)); + await ctx.commands.register({ id: "show-status", title: "Show Pomodoro status", description: "Speak the current timer phase and daily count." }, async () => ctx.pet.speak(statusSummary(await getState(ctx)))); + await ctx.commands.register({ id: "reset-count", title: "Reset Pomodoro count", description: "Reset today's completed focus count." }, () => resetCount(ctx)); + await ctx.commands.register({ id: "pause", title: "Pause Pomodoro", description: "Pause the current phase." }, () => pause(ctx)); + await ctx.commands.register({ id: "resume", title: "Resume Pomodoro", description: "Resume a paused phase." }, () => resume(ctx)); + await ctx.commands.register({ id: "stop", title: "Stop Pomodoro", description: "Stop and return to idle." }, () => stop(ctx)); + await ctx.commands.register({ id: "skip-phase", title: "Skip phase", description: "Complete the current phase now." }, () => completePhase(ctx)); + await ctx.commands.register({ id: "test-complete", title: "Test completion", description: "Preview the current phase completion message." }, async () => { + const config = normalizeConfig(await ctx.config.get()); + const current = await getState(ctx); + if (current.phase === "shortBreak" || current.phase === "longBreak") await announce(ctx, config.breakCompleteMessage, config.breakCompleteReaction); + else await announce(ctx, config.focusCompleteMessage, config.focusCompleteReaction); + }); + }, + async stop() {} + }); +} diff --git a/plugins/official/openpets.pomodoro/openpets.plugin.json b/plugins/official/openpets.pomodoro/openpets.plugin.json new file mode 100644 index 00000000..f59733a2 --- /dev/null +++ b/plugins/official/openpets.pomodoro/openpets.plugin.json @@ -0,0 +1,124 @@ +{ + "manifestVersion": 2, + "id": "openpets.pomodoro", + "name": "Pomodoro", + "version": "1.1.0", + "runtime": "javascript", + "icon": "timer", + "sdkVersion": "1.0.0", + "entry": "index.js", + "permissions": ["pet:speak", "pet:reaction", "schedule", "storage", "commands", "status"], + "configSchema": { + "focusMinutes": { + "type": "number", + "label": "Focus minutes", + "description": "Length of a focus session.", + "default": 25, + "min": 1, + "max": 180, + "step": 1 + }, + "shortBreakMinutes": { + "type": "number", + "label": "Short break minutes", + "default": 5, + "min": 1, + "max": 60, + "step": 1 + }, + "longBreakMinutes": { + "type": "number", + "label": "Long break minutes", + "default": 15, + "min": 1, + "max": 120, + "step": 1 + }, + "sessionsBeforeLongBreak": { + "type": "number", + "label": "Sessions before long break", + "default": 4, + "min": 1, + "max": 12, + "step": 1 + }, + "autoStartBreaks": { + "type": "boolean", + "label": "Auto-start breaks", + "default": false + }, + "autoStartFocus": { + "type": "boolean", + "label": "Auto-start focus after breaks", + "default": false + }, + "focusStartMessage": { + "type": "textarea", + "label": "Focus start message", + "default": "Focus time! Pick one task and protect your attention.", + "maxLength": 140 + }, + "focusCompleteMessage": { + "type": "textarea", + "label": "Focus complete message", + "default": "Focus session complete. Nice work!", + "maxLength": 140 + }, + "breakStartMessage": { + "type": "textarea", + "label": "Break start message", + "default": "Break time. Stretch, hydrate, and rest your eyes.", + "maxLength": 140 + }, + "breakCompleteMessage": { + "type": "textarea", + "label": "Break complete message", + "default": "Break complete. Ready for the next focus block?", + "maxLength": 140 + }, + "focusStartReaction": { + "type": "select", + "label": "Focus start reaction", + "default": "waving", + "options": [ + { "label": "Waving", "value": "waving" }, + { "label": "Waiting", "value": "waiting" }, + { "label": "Success", "value": "success" }, + { "label": "Celebrating", "value": "celebrating" } + ] + }, + "focusCompleteReaction": { + "type": "select", + "label": "Focus complete reaction", + "default": "success", + "options": [ + { "label": "Waving", "value": "waving" }, + { "label": "Waiting", "value": "waiting" }, + { "label": "Success", "value": "success" }, + { "label": "Celebrating", "value": "celebrating" } + ] + }, + "breakStartReaction": { + "type": "select", + "label": "Break start reaction", + "default": "waiting", + "options": [ + { "label": "Waving", "value": "waving" }, + { "label": "Waiting", "value": "waiting" }, + { "label": "Success", "value": "success" }, + { "label": "Celebrating", "value": "celebrating" } + ] + }, + "breakCompleteReaction": { + "type": "select", + "label": "Break complete reaction", + "default": "waving", + "options": [ + { "label": "Waving", "value": "waving" }, + { "label": "Waiting", "value": "waiting" }, + { "label": "Success", "value": "success" }, + { "label": "Celebrating", "value": "celebrating" } + ] + } + } +} diff --git a/plugins/official/openpets.pomodoro/test.js b/plugins/official/openpets.pomodoro/test.js new file mode 100644 index 00000000..dfaa5e4d --- /dev/null +++ b/plugins/official/openpets.pomodoro/test.js @@ -0,0 +1,77 @@ +import assert from "node:assert/strict"; +import { completePhase, getState, normalizeConfig, pause, reconcileStartup, register, resetCount, resume, startPhase, statusSummary } from "./index.js"; + +function ctx(config = {}) { + const store = new Map(); + const calls = { speak: [], react: [], status: [], cancel: [], once: [], commands: new Map() }; + return { calls, store, ctx: { + config: { get: async () => config }, + storage: { get: async (k) => store.get(k), set: async (k, v) => store.set(k, v) }, + schedule: { cancel: async (id) => calls.cancel.push(id), once: async (id, ms, fn) => calls.once.push({ id, ms, fn }) }, + status: { set: async (v) => calls.status.push(v) }, + pet: { speak: async (m) => calls.speak.push(m), react: async (r) => calls.react.push(r) }, + commands: { register: async (cmd, fn) => calls.commands.set(cmd.id, { cmd, fn }) }, + }}; +} + +assert.equal(normalizeConfig({ focusMinutes: 999, focusStartMessage: "token leak" }).focusMinutes, 180); +assert.equal(normalizeConfig({ focusStartMessage: "token leak" }).focusStartMessage, "Focus time! Pick one task and protect your attention."); + +{ + const h = ctx({ focusMinutes: 1 }); + await startPhase(h.ctx, "focus", 60_000); + assert.equal(h.calls.once.length, 1); + assert.equal((await getState(h.ctx)).phase, "focus"); + await completePhase(h.ctx); + const state = await getState(h.ctx); + assert.equal(state.completedSessions, 1); + assert.equal(state.completedToday, 1); + assert.equal(state.pendingBreakPhase, "shortBreak"); + assert.ok(statusSummary(state).includes("ready")); +} + +{ + const h = ctx({ autoStartBreaks: true }); + await startPhase(h.ctx, "focus", 60_000, { announce: false }); + await completePhase(h.ctx); + assert.equal(h.calls.speak.length, 1, "auto transition avoids double speech"); + assert.equal((await getState(h.ctx)).phase, "shortBreak"); +} + +{ + const h = ctx(); + await startPhase(h.ctx, "focus", 60_000, { announce: false }); + await pause(h.ctx); + assert.equal((await getState(h.ctx)).phase, "paused"); + await resume(h.ctx); + assert.equal((await getState(h.ctx)).phase, "focus"); +} + +{ + const h = ctx(); + const plugin = { register(def) { this.def = def; } }; + register(plugin); + await plugin.def.start(h.ctx); + for (const id of ["start-focus", "start-short-break", "start-long-break", "start-next-break", "show-status", "reset-count"]) assert.ok(h.calls.commands.has(id), id); + await h.calls.commands.get("show-status").fn(); + assert.ok(h.calls.speak.at(-1).includes("Pomodoro")); + assert.ok(statusSummary(await getState(h.ctx)).includes("idle")); + h.store.set("pomodoroState", { phase: "idle", pendingBreakPhase: "shortBreak", completedSessions: 1, completedToday: 1, lastActiveDate: new Date().toISOString().slice(0, 10) }); + await h.calls.commands.get("start-next-break").fn(); + assert.equal((await getState(h.ctx)).phase, "shortBreak"); + await h.calls.commands.get("reset-count").fn(); + assert.equal((await getState(h.ctx)).completedToday, 0); + await resetCount(h.ctx); +} + +{ + const h = ctx(); + h.store.set("pomodoroState", { phase: "focus", endAt: new Date(Date.now() - 1000).toISOString(), completedSessions: 1, completedToday: 1, lastActiveDate: new Date().toISOString().slice(0, 10) }); + const settled = await reconcileStartup(h.ctx); + assert.equal(settled.phase, "idle"); + assert.equal(settled.completedToday, 2); + assert.equal(settled.pendingBreakPhase, "shortBreak"); + assert.equal(h.calls.speak.length, 1); +} + +console.log("Pomodoro plugin tests passed."); diff --git a/scripts/test-plugins.mjs b/scripts/test-plugins.mjs new file mode 100644 index 00000000..6ceeaeea --- /dev/null +++ b/scripts/test-plugins.mjs @@ -0,0 +1,66 @@ +#!/usr/bin/env node + +import { spawnSync } from "node:child_process"; +import { readdir } from "node:fs/promises"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const repoRoot = fileURLToPath(new URL("..", import.meta.url)); +const officialDir = join(repoRoot, "plugins", "official"); + +const files = []; +const tests = []; + +async function pathExists(path) { + try { + await readdir(path); + return true; + } catch (error) { + if (error.code === "ENOENT") return false; + throw error; + } +} + +async function collectPluginChecks() { + if (!(await pathExists(officialDir))) return; + const plugins = await readdir(officialDir, { withFileTypes: true }); + for (const plugin of plugins) { + if (!plugin.isDirectory() || plugin.name.startsWith(".")) continue; + const pluginDir = join(officialDir, plugin.name); + files.push(join(pluginDir, "index.js")); + await collectTestFiles(pluginDir); + } +} + +async function collectTestFiles(dir) { + const entries = await readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.name.startsWith(".")) continue; + const path = join(dir, entry.name); + if (entry.isDirectory()) { + await collectTestFiles(path); + } else if (entry.isFile() && entry.name === "test.js") { + files.push(path); + tests.push(path); + } + } +} + +await collectPluginChecks(); + +if (files.length === 0) { + console.log("No plugin JavaScript files found."); + process.exit(0); +} + +for (const file of files) { + const result = spawnSync(process.execPath, ["--check", file], { stdio: "inherit" }); + if (result.status !== 0) process.exit(result.status ?? 1); +} + +for (const file of tests) { + const result = spawnSync(process.execPath, [file], { stdio: "inherit" }); + if (result.status !== 0) process.exit(result.status ?? 1); +} + +console.log(`Checked ${files.length} plugin JavaScript file${files.length === 1 ? "" : "s"}; ran ${tests.length} plugin test${tests.length === 1 ? "" : "s"}.`);