Add pluggable widget system; migrate PoE2 as the pilot implementation
Widgets are no longer hand-wired per-feature across scheduler.ts, settings.ts, and the API routes. A new WidgetPlugin interface (backend/src/widgets/types.ts) lets a widget declare its own schema migration, poll interval, routes, and uninstall hook; an installed_widgets registry table replaces the closed widgets/widgetOrder unions on global_settings, and the scheduler/route registration now iterate loaded widgets generically instead of one hardcoded block per widget. PoE2 moves into backend/src/widgets/poe2/ as the first real plugin (its tables renamed to the widget_poe2_ convention, league cache moved into a new generic widget_kv store). Weather/Stocks/Bookmarks get thin wrapper plugins so they share the same dispatch loop without migrating their schema. New admin routes let a widget be uploaded live (POST /api/admin/widgets, JSON body with inline file contents — no archive dependency needed) and removed with full data pruning (DELETE /api/admin/widgets/:id): a host-side safety-net sweep drops any widget_<id>_* table and widget_kv rows regardless of whether the widget's own uninstall() hook runs, so deleted widgets don't leave dead data behind. Built-in widgets can't be deleted through this route. Verified against a copy of the real dev DB: old poe2_watchlist data survives the rename, the migration is idempotent on a second boot, and a live-uploaded test widget was polled, queried, and fully deleted (table/kv/on-disk directory all gone) without a restart. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014c1L8ghNBFjfiH64UMViP8
This commit is contained in:
+38
-117
@@ -3,20 +3,17 @@ import * as settingsDb from '../storage/db/settings.js';
|
||||
import * as sourcesDb from '../storage/db/sources.js';
|
||||
import * as eventsDb from '../storage/db/events.js';
|
||||
import * as categoriesDb from '../storage/db/categories.js';
|
||||
import * as stocksDb from '../storage/db/stocks.js';
|
||||
import * as bookmarksDb from '../storage/db/bookmarks.js';
|
||||
import * as poe2WatchlistDb from '../storage/db/poe2Watchlist.js';
|
||||
import * as installedWidgetsDb from '../storage/db/installedWidgets.js';
|
||||
import { clearSourceContent, reissueSourceContent, clearAllArticles, clearAllMedia } from '../storage/contentCascade.js';
|
||||
import { totalStorageBytes } from '../storage/media/index.js';
|
||||
import { OllamaProvider } from '../inference/ollama-provider.js';
|
||||
import { pollSourceNow } from '../ingestion/poller.js';
|
||||
import { logger, listLogs } from '../storage/db/logs.js';
|
||||
import * as telegramClient from '../telegram/client.js';
|
||||
import { geocodeLocation } from '../weather/client.js';
|
||||
import { pollWeatherNow } from '../weather/poller.js';
|
||||
import { pollStocksNow } from '../stocks/poller.js';
|
||||
import { browseCurrencies, fetchCurrentLeague } from '../poe2/client.js';
|
||||
import { pollPoe2Now } from '../poe2/poller.js';
|
||||
import { loadedWidgets } from '../widgets/registry.js';
|
||||
import { installUploadedWidget } from '../widgets/install.js';
|
||||
import { uninstallWidget } from '../widgets/uninstall.js';
|
||||
import type { GlobalSettings } from '../storage/db/types.js';
|
||||
|
||||
// Not part of GlobalSettings itself (nothing to persist) — computed fresh on every
|
||||
// settings read/write so the Retention tab's "currently using" line and usage bar
|
||||
@@ -43,21 +40,24 @@ export async function registerAdminRoutes(app: FastifyInstance) {
|
||||
if (body.weather) {
|
||||
// Poll immediately rather than waiting for the next scheduler tick (up to 45
|
||||
// minutes) — the admin just changed the location/unit and expects to see it reflected.
|
||||
pollWeatherNow().catch((err) => logger.error('weather', `Immediate poll failed: ${err.message}`));
|
||||
loadedWidgets
|
||||
.get('weather')
|
||||
?.poll?.run()
|
||||
.catch((err) => logger.error('weather', `Immediate poll failed: ${err.message}`));
|
||||
}
|
||||
if (body.widgets) {
|
||||
// Re-enabling a widget (see the Widgets tab) should show fresh data right away
|
||||
// instead of waiting out its normal cadence (up to 45m/15m/1h) — scheduler.ts
|
||||
// skips polling entirely while a widget is disabled, so there's nothing recent
|
||||
// to fall back on otherwise.
|
||||
if (body.widgets.weather && !before.widgets.weather) {
|
||||
pollWeatherNow().catch((err) => logger.error('weather', `Immediate poll failed: ${err.message}`));
|
||||
}
|
||||
if (body.widgets.stocks && !before.widgets.stocks) {
|
||||
pollStocksNow().catch((err) => logger.error('stocks', `Immediate poll failed: ${err.message}`));
|
||||
}
|
||||
if (body.widgets.poe2 && !before.widgets.poe2) {
|
||||
pollPoe2Now().catch((err) => logger.error('poe2', `Immediate poll failed: ${err.message}`));
|
||||
// instead of waiting out its normal cadence — scheduler.ts skips polling
|
||||
// entirely while a widget is disabled, so there's nothing recent to fall back
|
||||
// on otherwise. Generic over every loaded widget with a poll hook, rather than
|
||||
// one hardcoded branch per widget.
|
||||
for (const id of Object.keys(body.widgets) as (keyof GlobalSettings['widgets'])[]) {
|
||||
if (body.widgets[id] && !before.widgets[id]) {
|
||||
loadedWidgets
|
||||
.get(id)
|
||||
?.poll?.run()
|
||||
.catch((err) => logger.error(id, `Immediate poll failed: ${err.message}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
return { ...settings, categoryPriority: categoriesDb.listCategories() };
|
||||
@@ -231,106 +231,27 @@ export async function registerAdminRoutes(app: FastifyInstance) {
|
||||
return reply.code(200).send(telegramClient.getStatus());
|
||||
});
|
||||
|
||||
// --- Weather (config lives in global_settings — see PATCH /api/admin/settings above) ---
|
||||
app.get('/api/admin/weather/geocode', async (req, reply) => {
|
||||
const { query } = req.query as { query?: string };
|
||||
if (!query || !query.trim()) return reply.code(400).send({ error: 'query required' });
|
||||
try {
|
||||
return await geocodeLocation(query.trim());
|
||||
} catch (err) {
|
||||
return reply.code(502).send({ error: `Geocoding service unreachable: ${(err as Error).message}` });
|
||||
}
|
||||
// Per-widget routes (weather geocode, stocks CRUD, bookmarks CRUD, poe2 browse/
|
||||
// watchlist) are registered by each widget's own plugin — see widgets/registry.ts's
|
||||
// generic registerAdminRoutes loop in index.ts. What's left here is the
|
||||
// upload/delete lifecycle for pluggable widgets themselves.
|
||||
|
||||
// --- Pluggable widgets (upload/list/delete — see widgets/install.ts, uninstall.ts) ---
|
||||
app.get('/api/admin/widgets', async () => installedWidgetsDb.listInstalled());
|
||||
|
||||
app.post('/api/admin/widgets', async (req, reply) => {
|
||||
const { manifest, files } = req.body as { manifest?: unknown; files?: unknown };
|
||||
const result = await installUploadedWidget(manifest, files);
|
||||
if (!result.ok) return reply.code(400).send({ error: result.error });
|
||||
return reply.code(201).send({ id: result.id });
|
||||
});
|
||||
|
||||
// --- Stocks ---
|
||||
app.get('/api/admin/stocks', async () => stocksDb.listStockTickers());
|
||||
|
||||
app.post('/api/admin/stocks', async (req, reply) => {
|
||||
const { label, symbol } = req.body as { label?: string; symbol?: string };
|
||||
if (!label || !label.trim() || !symbol || !symbol.trim()) {
|
||||
return reply.code(400).send({ error: 'label and symbol are required' });
|
||||
}
|
||||
const created = stocksDb.createStockTicker(label.trim(), symbol.trim());
|
||||
// Poll immediately rather than waiting for the next tick (up to 15 minutes) — cheap,
|
||||
// and refreshes every existing ticker's price too.
|
||||
pollStocksNow().catch((err) => logger.error('stocks', `Immediate poll failed: ${err.message}`));
|
||||
return reply.code(201).send(created);
|
||||
});
|
||||
|
||||
app.patch('/api/admin/stocks/:id', async (req, reply) => {
|
||||
app.delete('/api/admin/widgets/:id', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const updated = stocksDb.updateStockTicker(id, req.body as any);
|
||||
if (!updated) return reply.code(404).send({ error: 'not found' });
|
||||
return updated;
|
||||
});
|
||||
|
||||
app.delete('/api/admin/stocks/:id', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
stocksDb.deleteStockTicker(id);
|
||||
return reply.code(204).send();
|
||||
});
|
||||
|
||||
// --- Bookmarks ---
|
||||
app.get('/api/admin/bookmarks', async () => bookmarksDb.listBookmarks());
|
||||
|
||||
app.post('/api/admin/bookmarks', async (req, reply) => {
|
||||
const { name, url, isPrivate } = req.body as { name?: string; url?: string; isPrivate?: boolean };
|
||||
if (!name || !name.trim() || !url || !url.trim()) {
|
||||
return reply.code(400).send({ error: 'name and url are required' });
|
||||
}
|
||||
const created = bookmarksDb.createBookmark(name.trim(), url.trim(), !!isPrivate);
|
||||
return reply.code(201).send(created);
|
||||
});
|
||||
|
||||
app.patch('/api/admin/bookmarks/:id', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const updated = bookmarksDb.updateBookmark(id, req.body as any);
|
||||
if (!updated) return reply.code(404).send({ error: 'not found' });
|
||||
return updated;
|
||||
});
|
||||
|
||||
app.delete('/api/admin/bookmarks/:id', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
bookmarksDb.deleteBookmark(id);
|
||||
return reply.code(204).send();
|
||||
});
|
||||
|
||||
// --- PoE2 (league is always auto-detected, never admin-set — see poe2/poller.ts) ---
|
||||
app.get('/api/admin/poe2/browse', async (_req, reply) => {
|
||||
try {
|
||||
const league = await fetchCurrentLeague();
|
||||
return await browseCurrencies(league.id);
|
||||
} catch (err) {
|
||||
return reply.code(502).send({ error: `poe.ninja unreachable: ${(err as Error).message}` });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/admin/poe2/watchlist', async () => poe2WatchlistDb.listWatchlist());
|
||||
|
||||
app.post('/api/admin/poe2/watchlist', async (req, reply) => {
|
||||
const { base, quote } = req.body as {
|
||||
base?: { currencyId?: string; name?: string };
|
||||
quote?: { currencyId?: string; name?: string };
|
||||
};
|
||||
if (!base?.currencyId || !base?.name || !quote?.currencyId || !quote?.name) {
|
||||
return reply.code(400).send({ error: 'base and quote currencies (currencyId, name) are required' });
|
||||
}
|
||||
if (base.currencyId === quote.currencyId) {
|
||||
return reply.code(400).send({ error: 'Base and quote currencies must be different' });
|
||||
}
|
||||
const created = poe2WatchlistDb.addWatchlistEntry(
|
||||
{ currencyId: base.currencyId, name: base.name },
|
||||
{ currencyId: quote.currencyId, name: quote.name }
|
||||
);
|
||||
// Poll immediately rather than waiting for the next tick (up to 1 hour) — cheap,
|
||||
// and refreshes every existing entry's rate too.
|
||||
pollPoe2Now().catch((err) => logger.error('poe2', `Immediate poll failed: ${err.message}`));
|
||||
return reply.code(201).send(created);
|
||||
});
|
||||
|
||||
app.delete('/api/admin/poe2/watchlist/:id', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
poe2WatchlistDb.removeWatchlistEntry(id);
|
||||
const widget = installedWidgetsDb.getInstalled(id);
|
||||
if (!widget) return reply.code(404).send({ error: 'not found' });
|
||||
if (widget.source === 'builtin') return reply.code(400).send({ error: 'built-in widgets cannot be deleted' });
|
||||
await uninstallWidget(id);
|
||||
return reply.code(204).send();
|
||||
});
|
||||
|
||||
|
||||
@@ -4,9 +4,6 @@ import * as tagsDb from '../storage/db/tags.js';
|
||||
import * as eventsDb from '../storage/db/events.js';
|
||||
import * as categoriesDb from '../storage/db/categories.js';
|
||||
import * as settingsDb from '../storage/db/settings.js';
|
||||
import * as stocksDb from '../storage/db/stocks.js';
|
||||
import * as bookmarksDb from '../storage/db/bookmarks.js';
|
||||
import * as poe2WatchlistDb from '../storage/db/poe2Watchlist.js';
|
||||
import { hasPrivateAccess } from './privateAccess.js';
|
||||
|
||||
export async function registerPublicRoutes(app: FastifyInstance) {
|
||||
@@ -70,19 +67,7 @@ export async function registerPublicRoutes(app: FastifyInstance) {
|
||||
return { ...widgets, order: widgetOrder };
|
||||
});
|
||||
|
||||
// Sidebar widgets — see WeatherTab/StocksTab/BookmarksTab in the admin panel.
|
||||
app.get('/api/weather', async () => settingsDb.getSettings().weather);
|
||||
|
||||
app.get('/api/stocks', async () => stocksDb.listStockTickers());
|
||||
|
||||
app.get('/api/bookmarks', async (req) => {
|
||||
const bookmarks = bookmarksDb.listBookmarks();
|
||||
if (hasPrivateAccess(req)) return bookmarks;
|
||||
return bookmarks.filter((b) => !b.isPrivate);
|
||||
});
|
||||
|
||||
app.get('/api/poe2', async () => {
|
||||
const { leagueName, updatedAt } = settingsDb.getSettings().poe2;
|
||||
return { leagueName, updatedAt, entries: poe2WatchlistDb.listWatchlist() };
|
||||
});
|
||||
// Per-widget public routes (GET /api/weather, /api/stocks, /api/bookmarks, /api/poe2)
|
||||
// are registered by each widget's own plugin — see widgets/registry.ts's generic
|
||||
// registerPublicRoutes loop in index.ts.
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { WidgetPlugin } from '../widgets/types.js';
|
||||
import * as bookmarksDb from '../storage/db/bookmarks.js';
|
||||
import { hasPrivateAccess } from '../api/privateAccess.js';
|
||||
|
||||
// Built-in sidebar "Bookmarks" widget — thin wrapper so it funnels into the same
|
||||
// route-registration loop as pluggable widgets (see widgets/registry.ts); no poll (purely
|
||||
// admin-curated links) and no migrate()/uninstall() since its schema isn't moving. Not
|
||||
// going through the upload/delete lifecycle — see widgets/types.ts.
|
||||
export const bookmarksPlugin: WidgetPlugin = {
|
||||
id: 'bookmarks',
|
||||
displayName: 'Bookmarks',
|
||||
version: '1.0.0',
|
||||
|
||||
registerPublicRoutes(app) {
|
||||
app.get('/api/bookmarks', async (req) => {
|
||||
const bookmarks = bookmarksDb.listBookmarks();
|
||||
if (hasPrivateAccess(req)) return bookmarks;
|
||||
return bookmarks.filter((b) => !b.isPrivate);
|
||||
});
|
||||
},
|
||||
|
||||
registerAdminRoutes(app) {
|
||||
app.get('/api/admin/bookmarks', async () => bookmarksDb.listBookmarks());
|
||||
|
||||
app.post('/api/admin/bookmarks', async (req, reply) => {
|
||||
const { name, url, isPrivate } = req.body as { name?: string; url?: string; isPrivate?: boolean };
|
||||
if (!name || !name.trim() || !url || !url.trim()) {
|
||||
return reply.code(400).send({ error: 'name and url are required' });
|
||||
}
|
||||
const created = bookmarksDb.createBookmark(name.trim(), url.trim(), !!isPrivate);
|
||||
return reply.code(201).send(created);
|
||||
});
|
||||
|
||||
app.patch('/api/admin/bookmarks/:id', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const updated = bookmarksDb.updateBookmark(id, req.body as any);
|
||||
if (!updated) return reply.code(404).send({ error: 'not found' });
|
||||
return updated;
|
||||
});
|
||||
|
||||
app.delete('/api/admin/bookmarks/:id', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
bookmarksDb.deleteBookmark(id);
|
||||
return reply.code(204).send();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -14,6 +14,7 @@ import { registerPrivateAccess, privateAccessConfigured } from './api/privateAcc
|
||||
import { startScheduler } from './queue/scheduler.js';
|
||||
import { initFromSavedSession } from './telegram/client.js';
|
||||
import { logger } from './storage/db/logs.js';
|
||||
import { loadAllWidgets, loadedWidgets } from './widgets/registry.js';
|
||||
|
||||
const PORT = Number(process.env.PORT) || 4000;
|
||||
const FRONTEND_ORIGIN = process.env.FRONTEND_ORIGIN || 'http://localhost:5173';
|
||||
@@ -35,6 +36,7 @@ async function main() {
|
||||
migrate();
|
||||
printApiKeyBanner();
|
||||
await initFromSavedSession();
|
||||
await loadAllWidgets();
|
||||
|
||||
const app = Fastify({ logger: false });
|
||||
|
||||
@@ -74,6 +76,15 @@ async function main() {
|
||||
await registerAdminRoutes(app);
|
||||
await registerPrivateAccess(app);
|
||||
|
||||
// Each loaded widget (built-in or uploaded — see widgets/registry.ts) registers its
|
||||
// own routes here rather than being hardcoded into public.ts/admin.ts. Runs after
|
||||
// registerAuth so any /api/admin/* route a widget registers is gated by the same
|
||||
// X-Api-Key preHandler automatically.
|
||||
for (const plugin of loadedWidgets.values()) {
|
||||
plugin.registerPublicRoutes?.(app);
|
||||
plugin.registerAdminRoutes?.(app);
|
||||
}
|
||||
|
||||
// Fastify's own logger is off (see below) — without this, an unhandled exception
|
||||
// in any route handler produces a bare 500 with zero trace anywhere, including the
|
||||
// admin panel's own Logs tab. This is what "Save failed" with no log entry was.
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import * as poe2WatchlistDb from '../storage/db/poe2Watchlist.js';
|
||||
import * as settingsDb from '../storage/db/settings.js';
|
||||
import { logger } from '../storage/db/logs.js';
|
||||
import { fetchCurrentLeague, fetchCurrencyValues } from './client.js';
|
||||
|
||||
const DAY_MS = 24 * 60 * 60_000;
|
||||
|
||||
function pctChange(current: number, past: number | null): number | null {
|
||||
if (past === null || past === 0) return null;
|
||||
return ((current - past) / past) * 100;
|
||||
}
|
||||
|
||||
// Called on a schedule (see queue/scheduler.ts) and immediately after the admin adds a pair
|
||||
// (see api/admin.ts) — always re-detects the current challenge league fresh (cheap, guarantees
|
||||
// correctness across league rotations with no separate staleness logic), then one overview
|
||||
// request covers the whole watchlist. A pair whose base or quote currency is no longer traded
|
||||
// this league gets its own lastError, it never aborts the rest of the batch.
|
||||
export async function pollPoe2Now(): Promise<void> {
|
||||
let league;
|
||||
try {
|
||||
league = await fetchCurrentLeague();
|
||||
} catch (err) {
|
||||
logger.error('poe2', `League lookup failed: ${(err as Error).message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = poe2WatchlistDb.listWatchlist();
|
||||
if (entries.length === 0) {
|
||||
const { poe2 } = settingsDb.getSettings();
|
||||
settingsDb.updateSettings({
|
||||
poe2: { ...poe2, leagueId: league.id, leagueName: league.name, updatedAt: new Date().toISOString() }
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const valuesById = await fetchCurrencyValues(league.id);
|
||||
const now = new Date();
|
||||
const nowIso = now.toISOString();
|
||||
const cutoff24h = new Date(now.getTime() - DAY_MS).toISOString();
|
||||
|
||||
for (const entry of entries) {
|
||||
const baseValue = valuesById.get(entry.baseCurrencyId);
|
||||
const quoteValue = valuesById.get(entry.quoteCurrencyId);
|
||||
if (baseValue === undefined || quoteValue === undefined) {
|
||||
poe2WatchlistDb.markPolled(entry.id, null, null, 'One or both currencies no longer traded in this league');
|
||||
continue;
|
||||
}
|
||||
|
||||
const rate = baseValue / quoteValue;
|
||||
const change24h = pctChange(rate, poe2WatchlistDb.rateAtOrBefore(entry.id, cutoff24h));
|
||||
poe2WatchlistDb.recordRate(entry.id, rate, nowIso);
|
||||
poe2WatchlistDb.markPolled(entry.id, rate, change24h, null);
|
||||
}
|
||||
|
||||
poe2WatchlistDb.pruneOldHistory();
|
||||
settingsDb.updateSettings({
|
||||
poe2: { leagueId: league.id, leagueName: league.name, updatedAt: nowIso }
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('poe2', `Watchlist poll failed: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
@@ -4,17 +4,48 @@ import { runEventRecaps } from './eventsRecap.js';
|
||||
import { runRetentionSweep } from './retention.js';
|
||||
import { OllamaProvider } from '../inference/ollama-provider.js';
|
||||
import * as settingsDb from '../storage/db/settings.js';
|
||||
import * as installedWidgetsDb from '../storage/db/installedWidgets.js';
|
||||
import { logger } from '../storage/db/logs.js';
|
||||
import { pollWeatherNow } from '../weather/poller.js';
|
||||
import { pollStocksNow } from '../stocks/poller.js';
|
||||
import { pollPoe2Now } from '../poe2/poller.js';
|
||||
import { loadedWidgets } from '../widgets/registry.js';
|
||||
import type { WidgetPlugin } from '../widgets/types.js';
|
||||
|
||||
const POLL_TICK_MS = 60_000; // checks which sources are due every minute; each source's own interval governs actual fetch frequency
|
||||
const SYNTHESIS_TICK_MS = 60_000;
|
||||
const RETENTION_TICK_MS = 60 * 60_000; // hourly
|
||||
const WEATHER_TICK_MS = 45 * 60_000;
|
||||
const STOCKS_TICK_MS = 15 * 60_000; // per admin spec — stock prices move faster than weather
|
||||
const POE2_TICK_MS = 60 * 60_000; // poe.ninja's own overview data doesn't refresh faster than hourly, so polling more often than this just re-fetches the same numbers
|
||||
|
||||
// Per-widget setInterval handles, keyed by widget id — lets a single widget's polling be
|
||||
// started/stopped independently (on live upload/delete, or an enable toggle) without
|
||||
// touching any other widget's interval. Exported so widgets/install.ts and
|
||||
// widgets/uninstall.ts can drive it directly.
|
||||
export const widgetIntervals = new Map<string, NodeJS.Timeout>();
|
||||
|
||||
// Starts (or re-starts) polling for one widget — an immediate poll if it's currently
|
||||
// enabled (unlike RSS sources, whose "due" check makes a brand-new source eligible on the
|
||||
// very next 1-minute tick, a widget has no such shortcut; without this the sidebar would
|
||||
// sit empty for up to a full poll interval after every restart or fresh install), then a
|
||||
// recurring interval that re-checks the enabled flag on every tick — so disabling a widget
|
||||
// stops the actual external polling, not just hides it in the sidebar.
|
||||
export function startWidgetPolling(plugin: WidgetPlugin) {
|
||||
if (!plugin.poll) return;
|
||||
stopWidgetPolling(plugin.id);
|
||||
|
||||
if (installedWidgetsDb.getInstalled(plugin.id)?.enabled) {
|
||||
plugin.poll.run().catch((err) => logger.error(plugin.id, `Initial poll failed: ${(err as Error).message}`));
|
||||
}
|
||||
const handle = setInterval(() => {
|
||||
if (!installedWidgetsDb.getInstalled(plugin.id)?.enabled) return;
|
||||
plugin.poll!.run().catch((err) => logger.error(plugin.id, `Poll tick failed: ${(err as Error).message}`));
|
||||
}, plugin.poll.intervalMs);
|
||||
widgetIntervals.set(plugin.id, handle);
|
||||
}
|
||||
|
||||
export function stopWidgetPolling(id: string) {
|
||||
const handle = widgetIntervals.get(id);
|
||||
if (handle) {
|
||||
clearInterval(handle);
|
||||
widgetIntervals.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
export function startScheduler() {
|
||||
const provider = () => {
|
||||
@@ -66,37 +97,12 @@ export function startScheduler() {
|
||||
}
|
||||
}, RETENTION_TICK_MS);
|
||||
|
||||
// Immediate first call for all three — unlike RSS sources (whose "due" check makes a
|
||||
// brand-new source eligible on the very next 1-minute tick), weather/stocks/poe2 have
|
||||
// no such shortcut; without this the sidebar is empty for up to 45/15/60 minutes after
|
||||
// every restart. Each is also gated on its Widgets-tab enabled flag (see
|
||||
// admin/settings' consolidated Widgets tab) — disabling a widget stops these external
|
||||
// calls entirely rather than just hiding the sidebar box, so there's no pointless
|
||||
// polling for something nobody's looking at. Re-enabling it triggers an immediate
|
||||
// poll instead (see admin.ts's PATCH /api/admin/settings), same as this initial call.
|
||||
if (settingsDb.getSettings().widgets.weather) {
|
||||
pollWeatherNow().catch((err) => logger.error('weather', `Initial poll failed: ${err.message}`));
|
||||
for (const plugin of loadedWidgets.values()) {
|
||||
startWidgetPolling(plugin);
|
||||
}
|
||||
setInterval(() => {
|
||||
if (!settingsDb.getSettings().widgets.weather) return;
|
||||
pollWeatherNow().catch((err) => logger.error('weather', `Poll tick failed: ${err.message}`));
|
||||
}, WEATHER_TICK_MS);
|
||||
|
||||
if (settingsDb.getSettings().widgets.stocks) {
|
||||
pollStocksNow().catch((err) => logger.error('stocks', `Initial poll failed: ${err.message}`));
|
||||
}
|
||||
setInterval(() => {
|
||||
if (!settingsDb.getSettings().widgets.stocks) return;
|
||||
pollStocksNow().catch((err) => logger.error('stocks', `Poll tick failed: ${err.message}`));
|
||||
}, STOCKS_TICK_MS);
|
||||
|
||||
if (settingsDb.getSettings().widgets.poe2) {
|
||||
pollPoe2Now().catch((err) => logger.error('poe2', `Initial poll failed: ${err.message}`));
|
||||
}
|
||||
setInterval(() => {
|
||||
if (!settingsDb.getSettings().widgets.poe2) return;
|
||||
pollPoe2Now().catch((err) => logger.error('poe2', `Poll tick failed: ${err.message}`));
|
||||
}, POE2_TICK_MS);
|
||||
|
||||
logger.info('scheduler', 'Started: poll every 1m, synthesis every 1m, retention every 1h, weather every 45m, stocks every 15m, poe2 every 1h');
|
||||
logger.info(
|
||||
'scheduler',
|
||||
`Started: poll every 1m, synthesis every 1m, retention every 1h, ${loadedWidgets.size} widget(s) polling on their own intervals`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { WidgetPlugin } from '../widgets/types.js';
|
||||
import * as stocksDb from '../storage/db/stocks.js';
|
||||
import { logger } from '../storage/db/logs.js';
|
||||
import { pollStocksNow } from './poller.js';
|
||||
|
||||
// Built-in sidebar "Stocks" widget — thin wrapper so it funnels into the same
|
||||
// scheduler/route-registration loop as pluggable widgets (see widgets/registry.ts); its
|
||||
// tickers stay in the dedicated stock_tickers table exactly as before. Not going through
|
||||
// the upload/delete lifecycle — see widgets/types.ts; no migrate()/uninstall() since its
|
||||
// schema isn't moving.
|
||||
export const stocksPlugin: WidgetPlugin = {
|
||||
id: 'stocks',
|
||||
displayName: 'Stocks',
|
||||
version: '1.0.0',
|
||||
|
||||
poll: {
|
||||
// Per admin spec — stock prices move faster than weather.
|
||||
intervalMs: 15 * 60_000,
|
||||
run: pollStocksNow
|
||||
},
|
||||
|
||||
registerPublicRoutes(app) {
|
||||
app.get('/api/stocks', async () => stocksDb.listStockTickers());
|
||||
},
|
||||
|
||||
registerAdminRoutes(app) {
|
||||
app.get('/api/admin/stocks', async () => stocksDb.listStockTickers());
|
||||
|
||||
app.post('/api/admin/stocks', async (req, reply) => {
|
||||
const { label, symbol } = req.body as { label?: string; symbol?: string };
|
||||
if (!label || !label.trim() || !symbol || !symbol.trim()) {
|
||||
return reply.code(400).send({ error: 'label and symbol are required' });
|
||||
}
|
||||
const created = stocksDb.createStockTicker(label.trim(), symbol.trim());
|
||||
// Poll immediately rather than waiting for the next tick (up to 15 minutes) — cheap,
|
||||
// and refreshes every existing ticker's price too.
|
||||
pollStocksNow().catch((err) => logger.error('stocks', `Immediate poll failed: ${err.message}`));
|
||||
return reply.code(201).send(created);
|
||||
});
|
||||
|
||||
app.patch('/api/admin/stocks/:id', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const updated = stocksDb.updateStockTicker(id, req.body as any);
|
||||
if (!updated) return reply.code(404).send({ error: 'not found' });
|
||||
return updated;
|
||||
});
|
||||
|
||||
app.delete('/api/admin/stocks/:id', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
stocksDb.deleteStockTicker(id);
|
||||
return reply.code(204).send();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -33,6 +33,18 @@ export function migrate() {
|
||||
db.exec('DROP TABLE IF EXISTS poe2_watchlist;');
|
||||
}
|
||||
|
||||
// PoE2 moved into the pluggable-widget system (backend/src/widgets/poe2/) and its
|
||||
// schema now follows the widget_<id>_ naming convention its plugin.migrate() owns —
|
||||
// a plain rename (metadata-only in SQLite, no row copy) rather than grandfathering
|
||||
// the old names in via an exceptions list, since it's cheap and PoE2 is meant to be
|
||||
// the reference implementation of the convention it establishes.
|
||||
const hasOldPoe2Table = db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='poe2_watchlist'`).get();
|
||||
if (hasOldPoe2Table) {
|
||||
db.exec('ALTER TABLE poe2_watchlist RENAME TO widget_poe2_watchlist;');
|
||||
db.exec('ALTER TABLE poe2_rate_history RENAME TO widget_poe2_rate_history;');
|
||||
db.exec('DROP INDEX IF EXISTS idx_poe2_rate_history_watchlist;');
|
||||
}
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS sources (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -223,35 +235,32 @@ export function migrate() {
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Sidebar "PoE2" widget — tracks exchange rates between arbitrary currency pairs
|
||||
-- (see poe2/poller.ts), always against the current challenge league (auto-detected,
|
||||
-- no admin config). Rate is "1 base = last_rate quote"; both currencies' names are
|
||||
-- captured at add-time from the browse picker, not re-resolved. No icon columns —
|
||||
-- the UI doesn't display them.
|
||||
CREATE TABLE IF NOT EXISTS poe2_watchlist (
|
||||
id TEXT PRIMARY KEY,
|
||||
base_currency_id TEXT NOT NULL, -- opaque id from the exchange overview's lines[].id, e.g. "exalted"
|
||||
base_name TEXT NOT NULL,
|
||||
quote_currency_id TEXT NOT NULL,
|
||||
quote_name TEXT NOT NULL,
|
||||
priority_rank INTEGER NOT NULL,
|
||||
last_rate REAL,
|
||||
last_change_24h REAL,
|
||||
last_polled_at TEXT,
|
||||
last_error TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
-- Generic config/cache store for pluggable widgets (see widgets/types.ts,
|
||||
-- storage/db/widgetKv.ts) — lets a widget stay fully prunable by widget_id alone on
|
||||
-- uninstall without a bespoke table for simple key/value state.
|
||||
CREATE TABLE IF NOT EXISTS widget_kv (
|
||||
widget_id TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
value TEXT NOT NULL, -- JSON
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (widget_id, key)
|
||||
);
|
||||
|
||||
-- Per-poll rate snapshots for the pairs above — poe.ninja doesn't expose a matching
|
||||
-- 24h change window, so it's computed ourselves from this history (see
|
||||
-- poe2/poller.ts). Pruned to the last 2 days on every poll.
|
||||
CREATE TABLE IF NOT EXISTS poe2_rate_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
watchlist_id TEXT NOT NULL,
|
||||
rate REAL NOT NULL,
|
||||
recorded_at TEXT NOT NULL
|
||||
-- Registry of installed sidebar widgets, both built-in (weather/stocks/bookmarks,
|
||||
-- and poe2 as the pluggable reference implementation) and uploaded (see
|
||||
-- widgets/registry.ts, widgets/install.ts). Replaces the old closed
|
||||
-- widgets/widgetOrder unions on global_settings — see storage/db/settings.ts.
|
||||
CREATE TABLE IF NOT EXISTS installed_widgets (
|
||||
id TEXT PRIMARY KEY,
|
||||
display_name TEXT NOT NULL,
|
||||
source TEXT NOT NULL, -- 'builtin' | 'uploaded'
|
||||
code_path TEXT NOT NULL, -- builtin: identifying module path segment; uploaded: on-disk install dir under ./data/
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
priority_rank INTEGER NOT NULL,
|
||||
version TEXT NOT NULL DEFAULT '1.0.0',
|
||||
owned_tables TEXT NOT NULL DEFAULT '[]', -- JSON array, self-reported by the plugin — used by the uninstall safety-net sweep
|
||||
installed_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_poe2_rate_history_watchlist ON poe2_rate_history(watchlist_id, recorded_at);
|
||||
|
||||
-- Sidebar "Bookmarks" widget — admin-curated links, each independently hidden/public
|
||||
-- via is_private (same private-access lock feature as categories.is_private).
|
||||
@@ -417,4 +426,58 @@ export function migrate() {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// One-time seed of the installed_widgets registry from whatever the pre-registry
|
||||
// install had (upgrade path) or sensible defaults (fresh install) — see
|
||||
// widgets/registry.ts, storage/db/installedWidgets.ts. Raw SQL rather than importing
|
||||
// installedWidgets.ts here to avoid a circular import (it imports `db` from this
|
||||
// file).
|
||||
const widgetCount = db.prepare('SELECT COUNT(*) as c FROM installed_widgets').get() as { c: number };
|
||||
if (widgetCount.c === 0) {
|
||||
const settingsRow = db.prepare('SELECT * FROM global_settings WHERE id = 1').get() as any;
|
||||
const order: string[] = settingsRow
|
||||
? JSON.parse(settingsRow.widget_order ?? '["weather","stocks","poe2","bookmarks"]')
|
||||
: ['weather', 'stocks', 'poe2', 'bookmarks'];
|
||||
const displayNames: Record<string, string> = { weather: 'Weather', stocks: 'Stocks', bookmarks: 'Bookmarks', poe2: 'PoE2' };
|
||||
const codePaths: Record<string, string> = { weather: 'weather', stocks: 'stocks', bookmarks: 'bookmarks', poe2: 'widgets/poe2' };
|
||||
const ownedTables: Record<string, string[]> = { poe2: ['widget_poe2_watchlist', 'widget_poe2_rate_history'] };
|
||||
const enabledOf = (id: string) => (settingsRow ? !!settingsRow[`widget_${id}_enabled`] : true);
|
||||
const insertWidget = db.prepare(
|
||||
`INSERT INTO installed_widgets (id, display_name, source, code_path, enabled, priority_rank, owned_tables, installed_at)
|
||||
VALUES (?, ?, 'builtin', ?, ?, ?, ?, ?)`
|
||||
);
|
||||
const installedAt = new Date().toISOString();
|
||||
order.forEach((id, i) => {
|
||||
insertWidget.run(
|
||||
id,
|
||||
displayNames[id] ?? id,
|
||||
codePaths[id] ?? id,
|
||||
enabledOf(id) ? 1 : 0,
|
||||
i + 1,
|
||||
JSON.stringify(ownedTables[id] ?? []),
|
||||
installedAt
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// PoE2's league cache (previously bare global_settings columns) moves into the
|
||||
// generic widget_kv store so it's prunable via the same deleteAllKv('poe2') path as
|
||||
// everything else the widget owns, rather than needing bespoke column-nulling logic
|
||||
// on uninstall. One-time copy, guarded on the widget_kv row not already existing.
|
||||
const poe2CacheRow = db.prepare('SELECT poe2_league_id, poe2_league_name, poe2_updated_at FROM global_settings WHERE id = 1').get() as
|
||||
| { poe2_league_id: string | null; poe2_league_name: string | null; poe2_updated_at: string | null }
|
||||
| undefined;
|
||||
const hasPoe2Kv = db.prepare("SELECT 1 FROM widget_kv WHERE widget_id = 'poe2' AND key = 'leagueCache'").get();
|
||||
if (poe2CacheRow?.poe2_league_id && !hasPoe2Kv) {
|
||||
db.prepare(
|
||||
`INSERT INTO widget_kv (widget_id, key, value, updated_at) VALUES ('poe2', 'leagueCache', ?, ?)`
|
||||
).run(
|
||||
JSON.stringify({
|
||||
leagueId: poe2CacheRow.poe2_league_id,
|
||||
leagueName: poe2CacheRow.poe2_league_name,
|
||||
updatedAt: poe2CacheRow.poe2_updated_at
|
||||
}),
|
||||
new Date().toISOString()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { db } from './index.js';
|
||||
import type { InstalledWidget } from './types.js';
|
||||
|
||||
function rowToWidget(row: any): InstalledWidget {
|
||||
return {
|
||||
id: row.id,
|
||||
displayName: row.display_name,
|
||||
source: row.source,
|
||||
codePath: row.code_path,
|
||||
enabled: !!row.enabled,
|
||||
priorityRank: row.priority_rank,
|
||||
version: row.version,
|
||||
ownedTables: JSON.parse(row.owned_tables),
|
||||
installedAt: row.installed_at
|
||||
};
|
||||
}
|
||||
|
||||
export function listInstalled(): InstalledWidget[] {
|
||||
return (db.prepare('SELECT * FROM installed_widgets ORDER BY priority_rank').all() as any[]).map(rowToWidget);
|
||||
}
|
||||
|
||||
export function getInstalled(id: string): InstalledWidget | null {
|
||||
const row = db.prepare('SELECT * FROM installed_widgets WHERE id = ?').get(id);
|
||||
return row ? rowToWidget(row) : null;
|
||||
}
|
||||
|
||||
export function insertWidget(widget: {
|
||||
id: string;
|
||||
displayName: string;
|
||||
source: 'builtin' | 'uploaded';
|
||||
codePath: string;
|
||||
enabled?: boolean;
|
||||
priorityRank?: number;
|
||||
version?: string;
|
||||
ownedTables?: string[];
|
||||
}): InstalledWidget {
|
||||
const maxRank = db.prepare('SELECT COALESCE(MAX(priority_rank), 0) as m FROM installed_widgets').get() as { m: number };
|
||||
const priorityRank = widget.priorityRank ?? maxRank.m + 1;
|
||||
const installedAt = new Date().toISOString();
|
||||
db.prepare(
|
||||
`INSERT INTO installed_widgets (id, display_name, source, code_path, enabled, priority_rank, version, owned_tables, installed_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
).run(
|
||||
widget.id,
|
||||
widget.displayName,
|
||||
widget.source,
|
||||
widget.codePath,
|
||||
widget.enabled === false ? 0 : 1,
|
||||
priorityRank,
|
||||
widget.version ?? '1.0.0',
|
||||
JSON.stringify(widget.ownedTables ?? []),
|
||||
installedAt
|
||||
);
|
||||
return getInstalled(widget.id)!;
|
||||
}
|
||||
|
||||
export function setEnabled(id: string, enabled: boolean) {
|
||||
db.prepare('UPDATE installed_widgets SET enabled = ? WHERE id = ?').run(enabled ? 1 : 0, id);
|
||||
}
|
||||
|
||||
// Ids in display order — every id must already exist as a row; unlisted ids keep their
|
||||
// current rank (mirrors the old widget_order JSON array's "admin-sortable" behavior).
|
||||
export function reorder(ids: string[]) {
|
||||
const stmt = db.prepare('UPDATE installed_widgets SET priority_rank = ? WHERE id = ?');
|
||||
ids.forEach((id, i) => stmt.run(i + 1, id));
|
||||
}
|
||||
|
||||
export function deleteInstalled(id: string) {
|
||||
db.prepare('DELETE FROM installed_widgets WHERE id = ?').run(id);
|
||||
}
|
||||
@@ -1,7 +1,39 @@
|
||||
import { db } from './index.js';
|
||||
import type { GlobalSettings } from './types.js';
|
||||
import * as installedWidgetsDb from './installedWidgets.js';
|
||||
import { getKv, setKv } from './widgetKv.js';
|
||||
|
||||
const BUILTIN_WIDGET_IDS = ['weather', 'stocks', 'bookmarks', 'poe2'] as const;
|
||||
|
||||
interface Poe2LeagueCache {
|
||||
leagueId: string | null;
|
||||
leagueName: string | null;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
// widgets/widgetOrder are computed from the installed_widgets registry (see
|
||||
// storage/db/installedWidgets.ts, widgets/registry.ts) rather than stored as their own
|
||||
// global_settings columns — the registry is the single source of truth for enable state
|
||||
// and ordering for every widget, built-in or uploaded. Only the four built-in ids are
|
||||
// reflected here since GlobalSettings.widgets/widgetOrder are closed unions the frontend
|
||||
// depends on (uploaded widgets have no frontend representation yet).
|
||||
function widgetsAndOrder(): Pick<GlobalSettings, 'widgets' | 'widgetOrder'> {
|
||||
const installed = installedWidgetsDb.listInstalled();
|
||||
const byId = new Map(installed.map((w) => [w.id, w]));
|
||||
const widgets = {
|
||||
weather: !!byId.get('weather')?.enabled,
|
||||
stocks: !!byId.get('stocks')?.enabled,
|
||||
bookmarks: !!byId.get('bookmarks')?.enabled,
|
||||
poe2: !!byId.get('poe2')?.enabled
|
||||
};
|
||||
const widgetOrder = installed.map((w) => w.id).filter((id): id is (typeof BUILTIN_WIDGET_IDS)[number] =>
|
||||
(BUILTIN_WIDGET_IDS as readonly string[]).includes(id)
|
||||
);
|
||||
return { widgets, widgetOrder };
|
||||
}
|
||||
|
||||
function rowToSettings(row: any): GlobalSettings {
|
||||
const poe2Cache = getKv<Poe2LeagueCache>('poe2', 'leagueCache') ?? { leagueId: null, leagueName: null, updatedAt: null };
|
||||
return {
|
||||
mergeStrictness: row.merge_strictness,
|
||||
holdBeforePublishMinutes: row.hold_before_publish_minutes,
|
||||
@@ -16,13 +48,7 @@ function rowToSettings(row: any): GlobalSettings {
|
||||
fxtwitterBaseUrl: row.fxtwitter_base_url,
|
||||
nitterInstanceUrl: row.nitter_instance_url,
|
||||
telegramMediaMode: row.telegram_media_mode,
|
||||
widgets: {
|
||||
weather: !!row.widget_weather_enabled,
|
||||
stocks: !!row.widget_stocks_enabled,
|
||||
bookmarks: !!row.widget_bookmarks_enabled,
|
||||
poe2: !!row.widget_poe2_enabled
|
||||
},
|
||||
widgetOrder: JSON.parse(row.widget_order),
|
||||
...widgetsAndOrder(),
|
||||
retention: {
|
||||
publishedArticleMaxAgeDays: row.published_article_max_age_days,
|
||||
rawItemMaxAgeDays: row.raw_item_max_age_days,
|
||||
@@ -44,11 +70,7 @@ function rowToSettings(row: any): GlobalSettings {
|
||||
alerts: JSON.parse(row.weather_alerts),
|
||||
updatedAt: row.weather_updated_at
|
||||
},
|
||||
poe2: {
|
||||
leagueId: row.poe2_league_id,
|
||||
leagueName: row.poe2_league_name,
|
||||
updatedAt: row.poe2_updated_at
|
||||
}
|
||||
poe2: poe2Cache
|
||||
};
|
||||
}
|
||||
|
||||
@@ -68,6 +90,19 @@ export function updateSettings(patch: Partial<GlobalSettings>): GlobalSettings {
|
||||
poe2: { ...current.poe2, ...(patch.poe2 ?? {}) },
|
||||
widgets: { ...current.widgets, ...(patch.widgets ?? {}) }
|
||||
};
|
||||
|
||||
if (patch.widgets) {
|
||||
for (const id of BUILTIN_WIDGET_IDS) {
|
||||
if (patch.widgets[id] !== undefined) installedWidgetsDb.setEnabled(id, patch.widgets[id]);
|
||||
}
|
||||
}
|
||||
if (patch.widgetOrder) {
|
||||
installedWidgetsDb.reorder(patch.widgetOrder);
|
||||
}
|
||||
if (patch.poe2) {
|
||||
setKv('poe2', 'leagueCache', merged.poe2);
|
||||
}
|
||||
|
||||
// Named params (rather than positional `?`) so this list can be reordered or
|
||||
// extended without the column list and the bound-values list silently drifting
|
||||
// out of sync — node:sqlite binds each by its `$name` key, not position.
|
||||
@@ -80,16 +115,12 @@ export function updateSettings(patch: Partial<GlobalSettings>): GlobalSettings {
|
||||
ai_service_host=$ai_service_host, ai_service_port=$ai_service_port, selected_models=$selected_models,
|
||||
nitter_media_mode=$nitter_media_mode, fxtwitter_base_url=$fxtwitter_base_url, nitter_instance_url=$nitter_instance_url,
|
||||
telegram_media_mode=$telegram_media_mode,
|
||||
widget_weather_enabled=$widget_weather_enabled, widget_stocks_enabled=$widget_stocks_enabled,
|
||||
widget_bookmarks_enabled=$widget_bookmarks_enabled, widget_poe2_enabled=$widget_poe2_enabled,
|
||||
widget_order=$widget_order,
|
||||
published_article_max_age_days=$published_article_max_age_days, raw_item_max_age_days=$raw_item_max_age_days,
|
||||
storage_cap_enabled=$storage_cap_enabled, storage_cap_value=$storage_cap_value, storage_cap_unit=$storage_cap_unit,
|
||||
weather_location_name=$weather_location_name, weather_latitude=$weather_latitude, weather_longitude=$weather_longitude,
|
||||
weather_unit=$weather_unit, weather_wind_unit=$weather_wind_unit, weather_pressure_unit=$weather_pressure_unit,
|
||||
weather_current=$weather_current, weather_hourly=$weather_hourly, weather_daily=$weather_daily,
|
||||
weather_alerts=$weather_alerts, weather_updated_at=$weather_updated_at,
|
||||
poe2_league_id=$poe2_league_id, poe2_league_name=$poe2_league_name, poe2_updated_at=$poe2_updated_at
|
||||
weather_alerts=$weather_alerts, weather_updated_at=$weather_updated_at
|
||||
WHERE id = 1`
|
||||
).run({
|
||||
$merge_strictness: merged.mergeStrictness,
|
||||
@@ -105,11 +136,6 @@ export function updateSettings(patch: Partial<GlobalSettings>): GlobalSettings {
|
||||
$fxtwitter_base_url: merged.fxtwitterBaseUrl,
|
||||
$nitter_instance_url: merged.nitterInstanceUrl,
|
||||
$telegram_media_mode: merged.telegramMediaMode,
|
||||
$widget_weather_enabled: merged.widgets.weather ? 1 : 0,
|
||||
$widget_stocks_enabled: merged.widgets.stocks ? 1 : 0,
|
||||
$widget_bookmarks_enabled: merged.widgets.bookmarks ? 1 : 0,
|
||||
$widget_poe2_enabled: merged.widgets.poe2 ? 1 : 0,
|
||||
$widget_order: JSON.stringify(merged.widgetOrder),
|
||||
$published_article_max_age_days: merged.retention.publishedArticleMaxAgeDays,
|
||||
$raw_item_max_age_days: merged.retention.rawItemMaxAgeDays,
|
||||
$storage_cap_enabled: merged.retention.storageCapEnabled ? 1 : 0,
|
||||
@@ -125,10 +151,7 @@ export function updateSettings(patch: Partial<GlobalSettings>): GlobalSettings {
|
||||
$weather_hourly: JSON.stringify(merged.weather.hourly),
|
||||
$weather_daily: JSON.stringify(merged.weather.daily),
|
||||
$weather_alerts: JSON.stringify(merged.weather.alerts),
|
||||
$weather_updated_at: merged.weather.updatedAt,
|
||||
$poe2_league_id: merged.poe2.leagueId,
|
||||
$poe2_league_name: merged.poe2.leagueName,
|
||||
$poe2_updated_at: merged.poe2.updatedAt
|
||||
$weather_updated_at: merged.weather.updatedAt
|
||||
});
|
||||
return getSettings();
|
||||
}
|
||||
|
||||
@@ -256,6 +256,21 @@ export interface Poe2WatchlistEntry {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** A row in `installed_widgets` — the registry of both built-in and uploaded sidebar widgets (see widgets/registry.ts). */
|
||||
export interface InstalledWidget {
|
||||
id: string;
|
||||
displayName: string;
|
||||
source: 'builtin' | 'uploaded';
|
||||
/** Builtin: the module's identifying path segment (e.g. 'weather', 'widgets/poe2'). Uploaded: the on-disk install directory, e.g. './data/widgets-installed/<id>'. */
|
||||
codePath: string;
|
||||
enabled: boolean;
|
||||
priorityRank: number;
|
||||
version: string;
|
||||
/** Self-reported by the plugin (WidgetPlugin.ownedTables) at install/load time — used by the uninstall safety-net sweep. */
|
||||
ownedTables: string[];
|
||||
installedAt: string;
|
||||
}
|
||||
|
||||
export interface Bookmark {
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { db } from './index.js';
|
||||
|
||||
// Generic config/cache store for pluggable widgets (see widgets/types.ts) — a widget with
|
||||
// simple needs (weather-shaped: "last fetched value + timestamp") uses this instead of
|
||||
// getting bolted-on global_settings columns, so it stays fully prunable by widget_id alone
|
||||
// on uninstall (see widgets/uninstall.ts) without any host-side schema change.
|
||||
|
||||
export function getKv<T>(widgetId: string, key: string): T | null {
|
||||
const row = db.prepare('SELECT value FROM widget_kv WHERE widget_id = ? AND key = ?').get(widgetId, key) as
|
||||
| { value: string }
|
||||
| undefined;
|
||||
return row ? (JSON.parse(row.value) as T) : null;
|
||||
}
|
||||
|
||||
export function setKv(widgetId: string, key: string, value: unknown) {
|
||||
db.prepare(
|
||||
`INSERT INTO widget_kv (widget_id, key, value, updated_at) VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(widget_id, key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`
|
||||
).run(widgetId, key, JSON.stringify(value), new Date().toISOString());
|
||||
}
|
||||
|
||||
export function deleteAllKv(widgetId: string) {
|
||||
db.prepare('DELETE FROM widget_kv WHERE widget_id = ?').run(widgetId);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { WidgetPlugin } from '../widgets/types.js';
|
||||
import * as settingsDb from '../storage/db/settings.js';
|
||||
import { geocodeLocation } from './client.js';
|
||||
import { pollWeatherNow } from './poller.js';
|
||||
|
||||
// Built-in sidebar "Weather" widget — thin wrapper so it funnels into the same
|
||||
// scheduler/route-registration loop as pluggable widgets (see widgets/registry.ts), while
|
||||
// its config/cache stay on global_settings' weather_* columns exactly as before. Not going
|
||||
// through the upload/delete lifecycle — see widgets/types.ts and the plan's "built-in vs
|
||||
// pluggable" split; no migrate()/uninstall() since its schema isn't moving.
|
||||
export const weatherPlugin: WidgetPlugin = {
|
||||
id: 'weather',
|
||||
displayName: 'Weather',
|
||||
version: '1.0.0',
|
||||
|
||||
poll: {
|
||||
intervalMs: 45 * 60_000,
|
||||
run: pollWeatherNow
|
||||
},
|
||||
|
||||
registerPublicRoutes(app) {
|
||||
app.get('/api/weather', async () => settingsDb.getSettings().weather);
|
||||
},
|
||||
|
||||
registerAdminRoutes(app) {
|
||||
app.get('/api/admin/weather/geocode', async (req, reply) => {
|
||||
const { query } = req.query as { query?: string };
|
||||
if (!query || !query.trim()) return reply.code(400).send({ error: 'query required' });
|
||||
try {
|
||||
return await geocodeLocation(query.trim());
|
||||
} catch (err) {
|
||||
return reply.code(502).send({ error: `Geocoding service unreachable: ${(err as Error).message}` });
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import * as installedWidgetsDb from '../storage/db/installedWidgets.js';
|
||||
import { loadUploadedWidget } from './registry.js';
|
||||
import { startWidgetPolling } from '../queue/scheduler.js';
|
||||
import { validateManifest, type WidgetManifest } from './manifest.js';
|
||||
|
||||
const WIDGETS_INSTALLED_DIR = process.env.WIDGETS_INSTALLED_DIR || './data/widgets-installed';
|
||||
|
||||
export type InstallResult = { ok: true; id: string } | { ok: false; error: string };
|
||||
|
||||
// Installs and hot-loads a widget uploaded live to the running backend (see
|
||||
// api/admin.ts's POST /api/admin/widgets) — writes its files under ./data/, never
|
||||
// dist/ or src/, so it survives a rebuild/redeploy of the core app. Its migrate()
|
||||
// runs and its poll interval (if declared) starts immediately, with no restart
|
||||
// required. NOTE: its registerPublicRoutes/registerAdminRoutes, if declared, do NOT
|
||||
// take effect until the next restart — Fastify throws "instance is already
|
||||
// listening" if you try to add a route after app.listen() has resolved, and there's
|
||||
// no supported way around that short of a much larger request-dispatch redesign. A
|
||||
// live-installed widget's data/poll side works immediately; its custom HTTP routes
|
||||
// don't until the process restarts (see widgets/registry.ts's startup discovery,
|
||||
// which re-registers everything, routes included, on every boot).
|
||||
export async function installUploadedWidget(manifest: unknown, files: unknown): Promise<InstallResult> {
|
||||
const validationError = validateManifest(manifest, files);
|
||||
if (validationError) return { ok: false, error: validationError };
|
||||
const m = manifest as WidgetManifest;
|
||||
const f = files as Record<string, string>;
|
||||
|
||||
if (installedWidgetsDb.getInstalled(m.id)) {
|
||||
return { ok: false, error: `widget "${m.id}" is already installed` };
|
||||
}
|
||||
|
||||
const dir = path.join(WIDGETS_INSTALLED_DIR, m.id);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
for (const [relPath, content] of Object.entries(f)) {
|
||||
const filePath = path.join(dir, relPath);
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, content, 'utf8');
|
||||
}
|
||||
fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(m, null, 2), 'utf8');
|
||||
|
||||
const plugin = await loadUploadedWidget(m.id, dir);
|
||||
if (!plugin) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
return { ok: false, error: "widget failed to load — check its entry file's default export satisfies WidgetPlugin" };
|
||||
}
|
||||
|
||||
installedWidgetsDb.insertWidget({
|
||||
id: m.id,
|
||||
displayName: m.displayName,
|
||||
source: 'uploaded',
|
||||
codePath: dir,
|
||||
version: m.version,
|
||||
ownedTables: plugin.ownedTables ?? []
|
||||
});
|
||||
|
||||
startWidgetPolling(plugin);
|
||||
return { ok: true, id: m.id };
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
const ID_PATTERN = /^[a-z0-9-]{1,40}$/;
|
||||
const RESERVED_IDS = new Set(['weather', 'stocks', 'bookmarks', 'poe2']);
|
||||
const MAX_TOTAL_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
export interface WidgetManifest {
|
||||
id: string;
|
||||
displayName: string;
|
||||
version: string;
|
||||
/** Relative path within `files` to the ESM entry point, e.g. "index.mjs" — a default export satisfying WidgetPlugin. */
|
||||
entry: string;
|
||||
}
|
||||
|
||||
// Upload body shape: { manifest, files: { "index.mjs": "<source text>", ... } } — plain
|
||||
// JSON rather than a literal zip, so no archive/multipart dependency is needed (see
|
||||
// widgets/install.ts). Returns a human-readable error string, or null if valid.
|
||||
export function validateManifest(manifest: unknown, files: unknown): string | null {
|
||||
if (!manifest || typeof manifest !== 'object') return 'manifest is required';
|
||||
const m = manifest as Record<string, unknown>;
|
||||
if (typeof m.id !== 'string' || !ID_PATTERN.test(m.id)) return 'manifest.id must match /^[a-z0-9-]{1,40}$/';
|
||||
if (RESERVED_IDS.has(m.id)) return `id "${m.id}" is reserved for a built-in widget`;
|
||||
if (typeof m.displayName !== 'string' || !m.displayName.trim()) return 'manifest.displayName is required';
|
||||
if (typeof m.version !== 'string' || !m.version.trim()) return 'manifest.version is required';
|
||||
if (typeof m.entry !== 'string' || !m.entry.trim()) return 'manifest.entry is required';
|
||||
|
||||
if (!files || typeof files !== 'object' || Array.isArray(files)) return 'files must be a non-empty object';
|
||||
const entries = Object.entries(files as Record<string, unknown>);
|
||||
if (entries.length === 0) return 'files must be a non-empty object';
|
||||
if (!(m.entry in (files as Record<string, unknown>))) return `entry "${m.entry as string}" not found in files`;
|
||||
|
||||
let totalBytes = 0;
|
||||
for (const [relPath, content] of entries) {
|
||||
if (typeof content !== 'string') return `files["${relPath}"] must be a string`;
|
||||
if (relPath.startsWith('/') || relPath.split('/').some((seg) => seg === '..')) return `invalid file path "${relPath}"`;
|
||||
totalBytes += Buffer.byteLength(content, 'utf8');
|
||||
}
|
||||
if (totalBytes > MAX_TOTAL_BYTES) return `bundle exceeds ${MAX_TOTAL_BYTES}-byte limit`;
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
// poe.ninja's public PoE2 economy API — free, no account or API key required. This is the
|
||||
// only file that talks to it; poller.ts orchestrates when/how results get saved, same
|
||||
// only file that talks to it; poll.ts orchestrates when/how results get saved, same
|
||||
// separation as backend/src/telegram/ and backend/src/weather/ keep between the raw client
|
||||
// and their callers.
|
||||
//
|
||||
@@ -13,7 +13,7 @@
|
||||
// change, which poe.ninja's overview doesn't expose directly. Every line's `primaryValue` is
|
||||
// expressed in the same (unspecified, and irrelevant) reference currency, so any pair's rate
|
||||
// is just baseValue / quoteValue with the reference cancelling out — see fetchCurrencyValues
|
||||
// below and poe2/poller.ts, which self-computes change from its own polling history instead.
|
||||
// below and poll.ts, which self-computes change from its own polling history instead.
|
||||
const BASE_URL = 'https://poe.ninja';
|
||||
|
||||
export interface LeagueInfo {
|
||||
@@ -57,8 +57,9 @@ async function fetchCurrencyOverview(leagueId: string): Promise<RawCurrencyOverv
|
||||
}
|
||||
|
||||
// Fetches the whole traded-currency list for the admin's search-and-pick UI (see
|
||||
// api/admin.ts's GET /api/admin/poe2/browse) — only currencies that actually have a `lines`
|
||||
// entry (i.e. are currently traded), not every currency poe.ninja has ever known about.
|
||||
// widgets/poe2/plugin.ts's GET /api/admin/poe2/browse) — only currencies that actually have
|
||||
// a `lines` entry (i.e. are currently traded), not every currency poe.ninja has ever known
|
||||
// about.
|
||||
export async function browseCurrencies(leagueId: string): Promise<CurrencyBrowseEntry[]> {
|
||||
const { lines, items } = await fetchCurrencyOverview(leagueId);
|
||||
const nameById = new Map(items.map((item) => [item.id, item.name]));
|
||||
@@ -1,6 +1,6 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { db } from './index.js';
|
||||
import type { Poe2WatchlistEntry } from './types.js';
|
||||
import { db } from '../../storage/db/index.js';
|
||||
import type { Poe2WatchlistEntry } from '../../storage/db/types.js';
|
||||
|
||||
interface CurrencyRef {
|
||||
currencyId: string;
|
||||
@@ -24,21 +24,21 @@ function rowToEntry(row: any): Poe2WatchlistEntry {
|
||||
}
|
||||
|
||||
export function listWatchlist(): Poe2WatchlistEntry[] {
|
||||
const rows = db.prepare('SELECT * FROM poe2_watchlist ORDER BY priority_rank').all();
|
||||
const rows = db.prepare('SELECT * FROM widget_poe2_watchlist ORDER BY priority_rank').all();
|
||||
return rows.map(rowToEntry);
|
||||
}
|
||||
|
||||
// No update() — currencies are picked from a live browse list (see poe2/client.ts's
|
||||
// No update() — currencies are picked from a live browse list (see widgets/poe2/client.ts's
|
||||
// browseCurrencies), not typed, so there's nothing to edit; remove and re-add covers the
|
||||
// rare "picked the wrong one" case.
|
||||
export function addWatchlistEntry(base: CurrencyRef, quote: CurrencyRef): Poe2WatchlistEntry {
|
||||
const id = `poe2-${base.currencyId.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${quote.currencyId.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${randomUUID().slice(0, 6)}`
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/(^-|-$)/g, '');
|
||||
const maxRank = db.prepare('SELECT COALESCE(MAX(priority_rank), 0) as m FROM poe2_watchlist').get() as { m: number };
|
||||
const maxRank = db.prepare('SELECT COALESCE(MAX(priority_rank), 0) as m FROM widget_poe2_watchlist').get() as { m: number };
|
||||
const createdAt = new Date().toISOString();
|
||||
db.prepare(
|
||||
`INSERT INTO poe2_watchlist
|
||||
`INSERT INTO widget_poe2_watchlist
|
||||
(id, base_currency_id, base_name, quote_currency_id, quote_name, priority_rank, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
||||
).run(id, base.currencyId, base.name, quote.currencyId, quote.name, maxRank.m + 1, createdAt);
|
||||
@@ -58,14 +58,14 @@ export function addWatchlistEntry(base: CurrencyRef, quote: CurrencyRef): Poe2Wa
|
||||
}
|
||||
|
||||
export function removeWatchlistEntry(id: string) {
|
||||
db.prepare('DELETE FROM poe2_rate_history WHERE watchlist_id = ?').run(id);
|
||||
db.prepare('DELETE FROM poe2_watchlist WHERE id = ?').run(id);
|
||||
db.prepare('DELETE FROM widget_poe2_rate_history WHERE watchlist_id = ?').run(id);
|
||||
db.prepare('DELETE FROM widget_poe2_watchlist WHERE id = ?').run(id);
|
||||
}
|
||||
|
||||
// One snapshot per poll (see poe2/poller.ts) — the raw material 24h change is computed
|
||||
// One snapshot per poll (see widgets/poe2/poll.ts) — the raw material 24h change is computed
|
||||
// from, since poe.ninja itself doesn't expose per-pair rates or a matching change window.
|
||||
export function recordRate(watchlistId: string, rate: number, recordedAt: string) {
|
||||
db.prepare('INSERT INTO poe2_rate_history (watchlist_id, rate, recorded_at) VALUES (?, ?, ?)').run(
|
||||
db.prepare('INSERT INTO widget_poe2_rate_history (watchlist_id, rate, recorded_at) VALUES (?, ?, ?)').run(
|
||||
watchlistId,
|
||||
rate,
|
||||
recordedAt
|
||||
@@ -77,14 +77,14 @@ export function recordRate(watchlistId: string, rate: number, recordedAt: string
|
||||
// than fabricating a 0% figure.
|
||||
export function rateAtOrBefore(watchlistId: string, cutoffIso: string): number | null {
|
||||
const row = db
|
||||
.prepare('SELECT rate FROM poe2_rate_history WHERE watchlist_id = ? AND recorded_at <= ? ORDER BY recorded_at DESC LIMIT 1')
|
||||
.prepare('SELECT rate FROM widget_poe2_rate_history WHERE watchlist_id = ? AND recorded_at <= ? ORDER BY recorded_at DESC LIMIT 1')
|
||||
.get(watchlistId, cutoffIso) as { rate: number } | undefined;
|
||||
return row ? row.rate : null;
|
||||
}
|
||||
|
||||
export function markPolled(id: string, rate: number | null, change24h: number | null, error: string | null) {
|
||||
db.prepare(
|
||||
`UPDATE poe2_watchlist
|
||||
`UPDATE widget_poe2_watchlist
|
||||
SET last_rate = ?, last_change_24h = ?, last_polled_at = ?, last_error = ?
|
||||
WHERE id = ?`
|
||||
).run(rate, change24h, new Date().toISOString(), error, id);
|
||||
@@ -94,5 +94,5 @@ export function markPolled(id: string, rate: number | null, change24h: number |
|
||||
// poll to be briefly late without losing the data point it needs.
|
||||
export function pruneOldHistory() {
|
||||
const cutoff = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString();
|
||||
db.prepare('DELETE FROM poe2_rate_history WHERE recorded_at < ?').run(cutoff);
|
||||
db.prepare('DELETE FROM widget_poe2_rate_history WHERE recorded_at < ?').run(cutoff);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
import type { WidgetPlugin } from '../types.js';
|
||||
import { deleteAllKv } from '../../storage/db/widgetKv.js';
|
||||
import { logger } from '../../storage/db/logs.js';
|
||||
import * as poe2Db from './db.js';
|
||||
import { browseCurrencies, fetchCurrentLeague } from './client.js';
|
||||
import { pollPoe2Now, getLeagueCache } from './poll.js';
|
||||
|
||||
const OWNED_TABLES = ['widget_poe2_watchlist', 'widget_poe2_rate_history'];
|
||||
|
||||
// Sidebar "PoE2" widget — tracks exchange rates between arbitrary currency pairs, always
|
||||
// against the current challenge league (auto-detected, no admin config). The pilot
|
||||
// implementation of the pluggable-widget system (see widgets/types.ts) — every other
|
||||
// widget still ships in-process, but this one exercises the full migrate/poll/routes/
|
||||
// uninstall lifecycle a live-uploaded widget would also go through.
|
||||
export const poe2Plugin: WidgetPlugin = {
|
||||
id: 'poe2',
|
||||
displayName: 'PoE2',
|
||||
version: '1.0.0',
|
||||
ownedTables: OWNED_TABLES,
|
||||
|
||||
migrate(db: DatabaseSync) {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS widget_poe2_watchlist (
|
||||
id TEXT PRIMARY KEY,
|
||||
base_currency_id TEXT NOT NULL,
|
||||
base_name TEXT NOT NULL,
|
||||
quote_currency_id TEXT NOT NULL,
|
||||
quote_name TEXT NOT NULL,
|
||||
priority_rank INTEGER NOT NULL,
|
||||
last_rate REAL,
|
||||
last_change_24h REAL,
|
||||
last_polled_at TEXT,
|
||||
last_error TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS widget_poe2_rate_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
watchlist_id TEXT NOT NULL,
|
||||
rate REAL NOT NULL,
|
||||
recorded_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_widget_poe2_rate_history_watchlist ON widget_poe2_rate_history(watchlist_id, recorded_at);
|
||||
`);
|
||||
},
|
||||
|
||||
poll: {
|
||||
// poe.ninja's own overview data doesn't refresh faster than hourly, so polling
|
||||
// more often than this just re-fetches the same numbers.
|
||||
intervalMs: 60 * 60_000,
|
||||
run: pollPoe2Now
|
||||
},
|
||||
|
||||
registerPublicRoutes(app) {
|
||||
app.get('/api/poe2', async () => {
|
||||
const { leagueName, updatedAt } = getLeagueCache();
|
||||
return { leagueName, updatedAt, entries: poe2Db.listWatchlist() };
|
||||
});
|
||||
},
|
||||
|
||||
registerAdminRoutes(app) {
|
||||
// League is always auto-detected, never admin-set.
|
||||
app.get('/api/admin/poe2/browse', async (_req, reply) => {
|
||||
try {
|
||||
const league = await fetchCurrentLeague();
|
||||
return await browseCurrencies(league.id);
|
||||
} catch (err) {
|
||||
return reply.code(502).send({ error: `poe.ninja unreachable: ${(err as Error).message}` });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/admin/poe2/watchlist', async () => poe2Db.listWatchlist());
|
||||
|
||||
app.post('/api/admin/poe2/watchlist', async (req, reply) => {
|
||||
const { base, quote } = req.body as {
|
||||
base?: { currencyId?: string; name?: string };
|
||||
quote?: { currencyId?: string; name?: string };
|
||||
};
|
||||
if (!base?.currencyId || !base?.name || !quote?.currencyId || !quote?.name) {
|
||||
return reply.code(400).send({ error: 'base and quote currencies (currencyId, name) are required' });
|
||||
}
|
||||
if (base.currencyId === quote.currencyId) {
|
||||
return reply.code(400).send({ error: 'Base and quote currencies must be different' });
|
||||
}
|
||||
const created = poe2Db.addWatchlistEntry(
|
||||
{ currencyId: base.currencyId, name: base.name },
|
||||
{ currencyId: quote.currencyId, name: quote.name }
|
||||
);
|
||||
// Poll immediately rather than waiting for the next tick (up to 1 hour) — cheap,
|
||||
// and refreshes every existing entry's rate too.
|
||||
pollPoe2Now().catch((err) => logger.error('poe2', `Immediate poll failed: ${err.message}`));
|
||||
return reply.code(201).send(created);
|
||||
});
|
||||
|
||||
app.delete('/api/admin/poe2/watchlist/:id', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
poe2Db.removeWatchlistEntry(id);
|
||||
return reply.code(204).send();
|
||||
});
|
||||
},
|
||||
|
||||
uninstall(db: DatabaseSync) {
|
||||
db.exec('DROP TABLE IF EXISTS widget_poe2_rate_history;');
|
||||
db.exec('DROP TABLE IF EXISTS widget_poe2_watchlist;');
|
||||
deleteAllKv('poe2');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,74 @@
|
||||
import * as poe2Db from './db.js';
|
||||
import { getKv, setKv } from '../../storage/db/widgetKv.js';
|
||||
import { logger } from '../../storage/db/logs.js';
|
||||
import { fetchCurrentLeague, fetchCurrencyValues } from './client.js';
|
||||
|
||||
const DAY_MS = 24 * 60 * 60_000;
|
||||
const WIDGET_ID = 'poe2';
|
||||
|
||||
interface LeagueCache {
|
||||
leagueId: string | null;
|
||||
leagueName: string | null;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
// Cache of what the last poll learned about the current league — no admin-set config
|
||||
// (unlike weather): the league is always auto-detected, so this is purely a cache. Lives
|
||||
// in the generic widget_kv store (see storage/db/widgetKv.ts) so it's prunable via the
|
||||
// same deleteAllKv('poe2') path as everything else this widget owns.
|
||||
export function getLeagueCache(): LeagueCache {
|
||||
return getKv<LeagueCache>(WIDGET_ID, 'leagueCache') ?? { leagueId: null, leagueName: null, updatedAt: null };
|
||||
}
|
||||
|
||||
function pctChange(current: number, past: number | null): number | null {
|
||||
if (past === null || past === 0) return null;
|
||||
return ((current - past) / past) * 100;
|
||||
}
|
||||
|
||||
// Called on a schedule (see queue/scheduler.ts, via plugin.poll) and immediately after the
|
||||
// admin adds a pair (see plugin.ts's admin routes) — always re-detects the current
|
||||
// challenge league fresh (cheap, guarantees correctness across league rotations with no
|
||||
// separate staleness logic), then one overview request covers the whole watchlist. A pair
|
||||
// whose base or quote currency is no longer traded this league gets its own lastError, it
|
||||
// never aborts the rest of the batch.
|
||||
export async function pollPoe2Now(): Promise<void> {
|
||||
let league;
|
||||
try {
|
||||
league = await fetchCurrentLeague();
|
||||
} catch (err) {
|
||||
logger.error('poe2', `League lookup failed: ${(err as Error).message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = poe2Db.listWatchlist();
|
||||
if (entries.length === 0) {
|
||||
setKv(WIDGET_ID, 'leagueCache', { leagueId: league.id, leagueName: league.name, updatedAt: new Date().toISOString() });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const valuesById = await fetchCurrencyValues(league.id);
|
||||
const now = new Date();
|
||||
const nowIso = now.toISOString();
|
||||
const cutoff24h = new Date(now.getTime() - DAY_MS).toISOString();
|
||||
|
||||
for (const entry of entries) {
|
||||
const baseValue = valuesById.get(entry.baseCurrencyId);
|
||||
const quoteValue = valuesById.get(entry.quoteCurrencyId);
|
||||
if (baseValue === undefined || quoteValue === undefined) {
|
||||
poe2Db.markPolled(entry.id, null, null, 'One or both currencies no longer traded in this league');
|
||||
continue;
|
||||
}
|
||||
|
||||
const rate = baseValue / quoteValue;
|
||||
const change24h = pctChange(rate, poe2Db.rateAtOrBefore(entry.id, cutoff24h));
|
||||
poe2Db.recordRate(entry.id, rate, nowIso);
|
||||
poe2Db.markPolled(entry.id, rate, change24h, null);
|
||||
}
|
||||
|
||||
poe2Db.pruneOldHistory();
|
||||
setKv(WIDGET_ID, 'leagueCache', { leagueId: league.id, leagueName: league.name, updatedAt: nowIso });
|
||||
} catch (err) {
|
||||
logger.error('poe2', `Watchlist poll failed: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { db } from '../storage/db/index.js';
|
||||
import * as installedWidgetsDb from '../storage/db/installedWidgets.js';
|
||||
import { logger } from '../storage/db/logs.js';
|
||||
import type { WidgetPlugin } from './types.js';
|
||||
import { sweepOrphanedWidgetData } from './sweep.js';
|
||||
import { weatherPlugin } from '../weather/plugin.js';
|
||||
import { stocksPlugin } from '../stocks/plugin.js';
|
||||
import { bookmarksPlugin } from '../bookmarks/plugin.js';
|
||||
import { poe2Plugin } from './poe2/plugin.js';
|
||||
|
||||
// Both built-in and uploaded widgets funnel into this one map — scheduler dispatch and
|
||||
// route registration (see index.ts, queue/scheduler.ts) iterate it generically instead of
|
||||
// hardcoding a block per widget, mirroring ingestion/poller.ts's
|
||||
// Record<Source['type'], SourceAdapter> dispatch, just keyed by a dynamic string id.
|
||||
export const loadedWidgets = new Map<string, WidgetPlugin>();
|
||||
|
||||
function registerBuiltinWidget(plugin: WidgetPlugin) {
|
||||
plugin.migrate?.(db);
|
||||
loadedWidgets.set(plugin.id, plugin);
|
||||
}
|
||||
|
||||
// Called once at startup (see index.ts, after migrate() and before route registration /
|
||||
// startScheduler()) and again after a live upload (see widgets/install.ts) to pick up just
|
||||
// the newly-installed one without reloading everything else.
|
||||
export async function loadAllWidgets(): Promise<void> {
|
||||
loadedWidgets.clear();
|
||||
|
||||
registerBuiltinWidget(weatherPlugin);
|
||||
registerBuiltinWidget(stocksPlugin);
|
||||
registerBuiltinWidget(bookmarksPlugin);
|
||||
registerBuiltinWidget(poe2Plugin);
|
||||
|
||||
for (const row of installedWidgetsDb.listInstalled().filter((w) => w.source === 'uploaded')) {
|
||||
await loadUploadedWidget(row.id, row.codePath);
|
||||
}
|
||||
|
||||
sweepOrphanedWidgetData(
|
||||
db,
|
||||
installedWidgetsDb.listInstalled().map((w) => w.id)
|
||||
);
|
||||
}
|
||||
|
||||
// A widget whose code fails to load logs an error and is simply absent from
|
||||
// loadedWidgets — scheduler/route registration skip an id with no entry. Its
|
||||
// installed_widgets row stays, so it's still visible and deletable from the admin side
|
||||
// (see widgets/uninstall.ts, which doesn't require the widget's own code to be loadable).
|
||||
export async function loadUploadedWidget(id: string, codePath: string): Promise<WidgetPlugin | null> {
|
||||
try {
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(codePath, 'manifest.json'), 'utf8')) as { entry: string };
|
||||
const entryUrl = pathToFileURL(path.join(codePath, manifest.entry)).href;
|
||||
const mod = await import(entryUrl);
|
||||
const plugin: WidgetPlugin = mod.default;
|
||||
if (!plugin || typeof plugin.id !== 'string') {
|
||||
throw new Error('module has no default-exported WidgetPlugin');
|
||||
}
|
||||
if (plugin.id !== id) {
|
||||
throw new Error(`plugin id "${plugin.id}" does not match installed id "${id}"`);
|
||||
}
|
||||
plugin.migrate?.(db);
|
||||
loadedWidgets.set(id, plugin);
|
||||
return plugin;
|
||||
} catch (err) {
|
||||
logger.error('widgets', `Failed to load uploaded widget "${id}": ${(err as Error).message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
|
||||
// Host-side safety net for widget data pruning (see widgets/types.ts's uninstall contract
|
||||
// and the plan's "data isolation strategy"). This alone is sufficient to fully clean a
|
||||
// widget's data even with zero cooperation from its own code — a widget's own uninstall()
|
||||
// hook (if present) is an optimization/extension point, not a requirement for correctness.
|
||||
|
||||
function ownedTablesForId(db: DatabaseSync, widgetId: string, extraOwnedTables: string[] = []): string[] {
|
||||
const prefix = `widget_${widgetId}_`;
|
||||
const rows = db
|
||||
.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name LIKE ? ESCAPE '\\'`)
|
||||
.all(`${prefix.replace(/_/g, '\\_')}%`) as { name: string }[];
|
||||
return Array.from(new Set([...rows.map((r) => r.name), ...extraOwnedTables]));
|
||||
}
|
||||
|
||||
// Drops every table owned by a single widget (by the widget_<id>_ naming convention, plus
|
||||
// any self-reported extraOwnedTables for a grandfathered name), and clears its widget_kv
|
||||
// rows. Safe to call even if the widget's own code is missing/broken/never uninstall()ed.
|
||||
export function sweepWidgetData(db: DatabaseSync, widgetId: string, extraOwnedTables: string[] = []) {
|
||||
for (const table of ownedTablesForId(db, widgetId, extraOwnedTables)) {
|
||||
db.exec(`DROP TABLE IF EXISTS "${table}";`);
|
||||
}
|
||||
db.prepare('DELETE FROM widget_kv WHERE widget_id = ?').run(widgetId);
|
||||
}
|
||||
|
||||
// Full-registry sweep, run once at every startup after the registry loads (see
|
||||
// widgets/registry.ts) — catches anything left behind by a process crash mid-uninstall, or
|
||||
// a manually-edited installed_widgets table, that the per-widget sweep above never got a
|
||||
// chance to run for.
|
||||
export function sweepOrphanedWidgetData(db: DatabaseSync, knownWidgetIds: string[]) {
|
||||
const known = new Set(knownWidgetIds);
|
||||
const tables = db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'widget\\_%' ESCAPE '\\'`).all() as {
|
||||
name: string;
|
||||
}[];
|
||||
for (const { name } of tables) {
|
||||
if (name === 'widget_kv') continue; // the shared kv table itself, not a per-widget owned table
|
||||
const id = name.slice('widget_'.length).split('_')[0];
|
||||
if (!known.has(id)) {
|
||||
db.exec(`DROP TABLE IF EXISTS "${name}";`);
|
||||
}
|
||||
}
|
||||
const kvWidgetIds = db.prepare('SELECT DISTINCT widget_id FROM widget_kv').all() as { widget_id: string }[];
|
||||
const orphanedKvIds = kvWidgetIds.map((r) => r.widget_id).filter((id) => !known.has(id));
|
||||
if (orphanedKvIds.length > 0) {
|
||||
const placeholders = orphanedKvIds.map(() => '?').join(',');
|
||||
db.prepare(`DELETE FROM widget_kv WHERE widget_id IN (${placeholders})`).run(...orphanedKvIds);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
|
||||
/**
|
||||
* A widget's own lifecycle hooks — deliberately all-optional (unlike
|
||||
* ingestion/adapters/base.ts's SourceAdapter, which has one mandatory fetch()) because
|
||||
* unlike source adapters, widgets don't share a common downstream table or data shape
|
||||
* to normalize into; each is a self-contained module that opts into only what it needs.
|
||||
*/
|
||||
export interface WidgetPlugin {
|
||||
/** Stable id — also the table-name (`widget_<id>_*`) and directory-name namespace. Validated on install: /^[a-z0-9-]{1,40}$/ */
|
||||
id: string;
|
||||
displayName: string;
|
||||
version: string;
|
||||
|
||||
/** Widget-owned schema. Must be idempotent (CREATE TABLE IF NOT EXISTS style), same contract as the host's own migrate(). */
|
||||
migrate?(db: DatabaseSync): void;
|
||||
|
||||
/** Self-reported table names, for the uninstall safety-net sweep — belt-and-suspenders alongside the widget_<id>_ naming convention (see widgets/sweep.ts). */
|
||||
ownedTables?: string[];
|
||||
|
||||
/** Host owns the setInterval/enable-gating (see queue/scheduler.ts); the plugin just does the fetch-and-persist work. */
|
||||
poll?: { intervalMs: number; run(): Promise<void> };
|
||||
|
||||
/** Called once at load time (startup, or immediately after a live upload). Admin routes registered here are auto-gated by the existing X-Api-Key preHandler (see api/auth.ts), since it matches on any /api/admin/* path. */
|
||||
registerPublicRoutes?(app: FastifyInstance): void;
|
||||
registerAdminRoutes?(app: FastifyInstance): void;
|
||||
|
||||
/** Best-effort cleanup, called before the host's safety-net sweep on delete. Should not assume its own migrate() succeeded or that external APIs are reachable — the sweep is the real guarantee, this is just an extension point. */
|
||||
uninstall?(db: DatabaseSync): void;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { db } from '../storage/db/index.js';
|
||||
import * as installedWidgetsDb from '../storage/db/installedWidgets.js';
|
||||
import { logger } from '../storage/db/logs.js';
|
||||
import { loadedWidgets } from './registry.js';
|
||||
import { sweepWidgetData } from './sweep.js';
|
||||
import { stopWidgetPolling } from '../queue/scheduler.js';
|
||||
|
||||
const WIDGETS_INSTALLED_DIR = process.env.WIDGETS_INSTALLED_DIR || './data/widgets-installed';
|
||||
const WIDGETS_DATA_DIR = process.env.WIDGETS_DATA_DIR || './data/widgets-data';
|
||||
|
||||
// Fully removes an uploaded widget — safe to call even if its code is already
|
||||
// broken/missing (the plugin.uninstall() step is best-effort; sweepWidgetData is the real
|
||||
// guarantee, matching every table/kv row/on-disk file regardless of the widget's own
|
||||
// cooperation). Callers (see api/admin.ts's DELETE /api/admin/widgets/:id) are
|
||||
// responsible for rejecting built-in widgets before calling this.
|
||||
export async function uninstallWidget(id: string): Promise<void> {
|
||||
stopWidgetPolling(id);
|
||||
|
||||
const plugin = loadedWidgets.get(id);
|
||||
if (plugin?.uninstall) {
|
||||
try {
|
||||
plugin.uninstall(db);
|
||||
} catch (err) {
|
||||
logger.error('widgets', `uninstall() hook failed for "${id}": ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const row = installedWidgetsDb.getInstalled(id);
|
||||
sweepWidgetData(db, id, row?.ownedTables ?? []);
|
||||
|
||||
fs.rmSync(path.join(WIDGETS_INSTALLED_DIR, id), { recursive: true, force: true });
|
||||
fs.rmSync(path.join(WIDGETS_DATA_DIR, id), { recursive: true, force: true });
|
||||
|
||||
loadedWidgets.delete(id);
|
||||
installedWidgetsDb.deleteInstalled(id);
|
||||
}
|
||||
Reference in New Issue
Block a user