Add sidebar: Weather, Stocks, and Bookmarks widgets

A persistent right-hand sidebar (hidden only on /admin/**) with three
independent widgets: current-conditions weather (Open-Meteo, no API
key) linking to a new /weather forecast page; Dow/S&P/crypto/stock
tickers (Stooq, polled every 15 minutes); and admin-curated bookmark
links reusing the existing private-access lock per entry.

Weather and stocks are each self-contained modules (client + poller)
under backend/src/weather and backend/src/stocks, mirroring how
telegram/ is separated from the rest of the ingestion pipeline, so
either can be modified or removed independently. Bookmarks has no
external service, so it follows the plainer categories/events
DB-module + CRUD-route pattern instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014c1L8ghNBFjfiH64UMViP8
This commit is contained in:
Claude
2026-07-24 22:14:50 +00:00
parent dbc922f3bd
commit a45a813a41
29 changed files with 1779 additions and 17 deletions
+74
View File
@@ -3,12 +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 { 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';
// 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
@@ -31,6 +36,11 @@ export async function registerAdminRoutes(app: FastifyInstance) {
delete body.categoryPriority;
}
const settings = withStorageUsed(settingsDb.updateSettings(body));
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}`));
}
return { ...settings, categoryPriority: categoriesDb.listCategories() };
});
@@ -202,6 +212,70 @@ 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}` });
}
});
// --- 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) => {
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();
});
// --- Logs ---
app.get('/api/admin/logs', async (req) => {
const { level, limit } = req.query as { level?: string; limit?: string };
+14
View File
@@ -3,6 +3,9 @@ import * as articlesDb from '../storage/db/articles.js';
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 { hasPrivateAccess } from './privateAccess.js';
export async function registerPublicRoutes(app: FastifyInstance) {
@@ -55,4 +58,15 @@ export async function registerPublicRoutes(app: FastifyInstance) {
if (hasPrivateAccess(req)) return categories;
return categories.filter((c) => !c.isPrivate);
});
// 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);
});
}
+19 -1
View File
@@ -5,10 +5,14 @@ import { runRetentionSweep } from './retention.js';
import { OllamaProvider } from '../inference/ollama-provider.js';
import * as settingsDb from '../storage/db/settings.js';
import { logger } from '../storage/db/logs.js';
import { pollWeatherNow } from '../weather/poller.js';
import { pollStocksNow } from '../stocks/poller.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
export function startScheduler() {
const provider = () => {
@@ -61,5 +65,19 @@ export function startScheduler() {
}
}, RETENTION_TICK_MS);
logger.info('scheduler', 'Started: poll every 1m, synthesis every 1m, retention every 1h');
// Immediate first call for both — unlike RSS sources (whose "due" check makes a
// brand-new source eligible on the very next 1-minute tick), weather/stocks have no
// such shortcut; without this the sidebar is empty for up to 45/15 minutes after
// every restart.
pollWeatherNow().catch((err) => logger.error('weather', `Initial poll failed: ${err.message}`));
setInterval(() => {
pollWeatherNow().catch((err) => logger.error('weather', `Poll tick failed: ${err.message}`));
}, WEATHER_TICK_MS);
pollStocksNow().catch((err) => logger.error('stocks', `Initial poll failed: ${err.message}`));
setInterval(() => {
pollStocksNow().catch((err) => logger.error('stocks', `Poll tick failed: ${err.message}`));
}, STOCKS_TICK_MS);
logger.info('scheduler', 'Started: poll every 1m, synthesis every 1m, retention every 1h, weather every 45m, stocks every 15m');
}
+50
View File
@@ -0,0 +1,50 @@
// Stooq (stooq.com) — free CSV quote endpoint, no account or API key required, and it
// accepts multiple symbols batched into one request. This is the only file that talks to
// it; poller.ts orchestrates when/how results get saved, same separation as
// backend/src/telegram/ keeps between the raw client and its callers.
//
// Stooq's quote line has no prior-close field, so "change %" here is computed as
// (close - open) / open * 100 — an intraday-vs-open approximation, not a true
// prior-day change. Accepted simplification for a basic ticker widget.
export interface StockQuote {
price: number;
changePercent: number;
}
export async function fetchQuotes(symbols: string[]): Promise<Map<string, StockQuote | Error>> {
const results = new Map<string, StockQuote | Error>();
if (symbols.length === 0) return results;
const url = `https://stooq.com/q/l/?s=${symbols.map(encodeURIComponent).join(',')}&f=sd2t2ohlcv&h&e=csv`;
const res = await fetch(url);
if (!res.ok) throw new Error(`Stooq returned ${res.status}`);
const text = await res.text();
// Header: Symbol,Date,Time,Open,High,Low,Close,Volume — no quoted/embedded-comma
// fields in this format, so a plain split is sufficient (no CSV library needed).
const lines = text.trim().split('\n').slice(1);
const bySymbol = new Map<string, string[]>();
for (const line of lines) {
const cols = line.split(',');
if (cols.length < 7) continue;
bySymbol.set(cols[0].toLowerCase(), cols);
}
for (const symbol of symbols) {
const cols = bySymbol.get(symbol.toLowerCase());
if (!cols) {
results.set(symbol, new Error('Symbol not found in Stooq response'));
continue;
}
const open = Number(cols[3]);
const close = Number(cols[6]);
if (cols[3] === 'N/D' || cols[6] === 'N/D' || !Number.isFinite(open) || !Number.isFinite(close) || open === 0) {
results.set(symbol, new Error('Stooq has no data for this symbol'));
continue;
}
results.set(symbol, { price: close, changePercent: ((close - open) / open) * 100 });
}
return results;
}
+30
View File
@@ -0,0 +1,30 @@
import * as stocksDb from '../storage/db/stocks.js';
import { logger } from '../storage/db/logs.js';
import { fetchQuotes } from './client.js';
// Called on a schedule (see queue/scheduler.ts) and immediately after the admin adds a
// ticker (see api/admin.ts) — one batched Stooq request for every configured ticker. A
// symbol Stooq can't resolve gets its own lastError, it never aborts the whole batch.
export async function pollStocksNow(): Promise<void> {
const tickers = stocksDb.listStockTickers();
if (tickers.length === 0) return;
let quotes: Map<string, { price: number; changePercent: number } | Error>;
try {
quotes = await fetchQuotes(tickers.map((t) => t.symbol));
} catch (err) {
logger.error('stocks', `Poll failed: ${(err as Error).message}`);
return;
}
for (const ticker of tickers) {
const quote = quotes.get(ticker.symbol);
if (!quote) {
stocksDb.markStockPolled(ticker.id, null, null, 'No quote returned');
} else if (quote instanceof Error) {
stocksDb.markStockPolled(ticker.id, null, null, quote.message);
} else {
stocksDb.markStockPolled(ticker.id, quote.price, quote.changePercent, null);
}
}
}
+46
View File
@@ -0,0 +1,46 @@
import { randomUUID } from 'node:crypto';
import { db } from './index.js';
import type { Bookmark } from './types.js';
function rowToBookmark(row: any): Bookmark {
return {
id: row.id,
name: row.name,
url: row.url,
priorityRank: row.priority_rank,
isPrivate: !!row.is_private,
createdAt: row.created_at
};
}
// Always returns every bookmark, private or not — filtering for unauthenticated visitors
// happens at the route layer (GET /api/bookmarks), same as categoriesDb.listCategories().
export function listBookmarks(): Bookmark[] {
const rows = db.prepare('SELECT * FROM bookmarks ORDER BY priority_rank').all();
return rows.map(rowToBookmark);
}
export function createBookmark(name: string, url: string, isPrivate = false): Bookmark {
const id = `bm-${name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')}-${randomUUID().slice(0, 6)}`;
const maxRank = db.prepare('SELECT COALESCE(MAX(priority_rank), 0) as m FROM bookmarks').get() as { m: number };
const createdAt = new Date().toISOString();
db.prepare(
'INSERT INTO bookmarks (id, name, url, priority_rank, is_private, created_at) VALUES (?, ?, ?, ?, ?, ?)'
).run(id, name, url, maxRank.m + 1, isPrivate ? 1 : 0, createdAt);
return { id, name, url, priorityRank: maxRank.m + 1, isPrivate, createdAt };
}
export function updateBookmark(id: string, patch: { name?: string; url?: string; isPrivate?: boolean }): Bookmark | null {
const existing = db.prepare('SELECT * FROM bookmarks WHERE id = ?').get(id);
if (!existing) return null;
const current = rowToBookmark(existing);
const merged = { ...current, ...patch };
db.prepare('UPDATE bookmarks SET name = ?, url = ?, is_private = ? WHERE id = ?').run(
merged.name, merged.url, merged.isPrivate ? 1 : 0, id
);
return merged;
}
export function deleteBookmark(id: string) {
db.prepare('DELETE FROM bookmarks WHERE id = ?').run(id);
}
+62 -1
View File
@@ -176,7 +176,41 @@ export function migrate() {
storage_cap_unit TEXT NOT NULL DEFAULT 'GB',
nitter_media_mode TEXT NOT NULL DEFAULT 'proxy', -- self-host | proxy | direct
fxtwitter_base_url TEXT NOT NULL DEFAULT 'https://api.fxtwitter.com',
telegram_media_mode TEXT NOT NULL DEFAULT 'self-host' -- self-host | proxy (no "direct" — Telegram has no public hotlinkable media URL)
telegram_media_mode TEXT NOT NULL DEFAULT 'self-host', -- self-host | proxy (no "direct" — Telegram has no public hotlinkable media URL)
weather_location_name TEXT,
weather_latitude REAL,
weather_longitude REAL,
weather_unit TEXT NOT NULL DEFAULT 'fahrenheit', -- celsius | fahrenheit
weather_current TEXT, -- JSON {temp, conditionText, icon}, NULL pre-first-poll
weather_hourly TEXT NOT NULL DEFAULT '[]', -- JSON array
weather_daily TEXT NOT NULL DEFAULT '[]', -- JSON array
weather_updated_at TEXT -- ISO timestamp, NULL pre-first-poll
);
-- Sidebar "Stocks" widget — polled every 15 minutes from Stooq (see stocks/poller.ts).
-- Price/change/poll-state live directly on the row, same as sources.last_polled_at,
-- rather than a separate quote-cache table.
CREATE TABLE IF NOT EXISTS stock_tickers (
id TEXT PRIMARY KEY,
label TEXT NOT NULL,
symbol TEXT NOT NULL, -- Stooq symbol syntax, e.g. "^dji", "aapl.us", "btcusd"
priority_rank INTEGER NOT NULL,
last_price REAL,
last_change_percent REAL,
last_polled_at TEXT,
last_error TEXT,
created_at TEXT NOT NULL
);
-- Sidebar "Bookmarks" widget — admin-curated links, each independently hidden/public
-- via is_private (same private-access lock feature as categories.is_private).
CREATE TABLE IF NOT EXISTS bookmarks (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
url TEXT NOT NULL,
priority_rank INTEGER NOT NULL,
is_private INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
);
-- Singleton row (see storage/crypto.ts) — encrypted Telegram API credentials and
@@ -248,6 +282,33 @@ export function migrate() {
if (!hasColumn('merged_articles', 'is_recap')) {
db.exec('ALTER TABLE merged_articles ADD COLUMN is_recap INTEGER NOT NULL DEFAULT 0');
}
if (!hasColumn('global_settings', 'weather_unit')) {
db.exec('ALTER TABLE global_settings ADD COLUMN weather_location_name TEXT');
db.exec('ALTER TABLE global_settings ADD COLUMN weather_latitude REAL');
db.exec('ALTER TABLE global_settings ADD COLUMN weather_longitude REAL');
db.exec("ALTER TABLE global_settings ADD COLUMN weather_unit TEXT NOT NULL DEFAULT 'fahrenheit'");
db.exec('ALTER TABLE global_settings ADD COLUMN weather_current TEXT');
db.exec("ALTER TABLE global_settings ADD COLUMN weather_hourly TEXT NOT NULL DEFAULT '[]'");
db.exec("ALTER TABLE global_settings ADD COLUMN weather_daily TEXT NOT NULL DEFAULT '[]'");
db.exec('ALTER TABLE global_settings ADD COLUMN weather_updated_at TEXT');
}
// Seed a handful of sensible default tickers so the Stocks widget isn't empty on a
// fresh install — the admin can remove/replace any of them via the Stocks tab.
const tickerCount = db.prepare('SELECT COUNT(*) as c FROM stock_tickers').get() as { c: number };
if (tickerCount.c === 0) {
const defaults: [string, string][] = [
['Dow Jones', '^dji'],
['S&P 500', '^spx'],
['Bitcoin', 'btcusd']
];
const stmt = db.prepare(
'INSERT INTO stock_tickers (id, label, symbol, priority_rank, created_at) VALUES (?, ?, ?, ?, ?)'
);
defaults.forEach(([label, symbol], i) => {
stmt.run(`stk-${symbol.replace(/[^a-z0-9]+/gi, '-')}`, label, symbol, i + 1, new Date().toISOString());
});
}
// Seed default categories if none exist yet. "News" sits right under "Top stories" —
// general news sources belong here, not on "Top stories" itself, which isn't a real
+25 -3
View File
@@ -22,6 +22,17 @@ function rowToSettings(row: any): GlobalSettings {
storageCapEnabled: !!row.storage_cap_enabled,
storageCapValue: row.storage_cap_value,
storageCapUnit: row.storage_cap_unit
},
weather: {
locationName: row.weather_location_name,
latitude: row.weather_latitude,
longitude: row.weather_longitude,
unit: row.weather_unit,
// Unlike retention, this is genuinely absent pre-first-poll (and pre-location-config) — null-safe parse.
current: row.weather_current ? JSON.parse(row.weather_current) : null,
hourly: JSON.parse(row.weather_hourly),
daily: JSON.parse(row.weather_daily),
updatedAt: row.weather_updated_at
}
};
}
@@ -37,7 +48,8 @@ export function updateSettings(patch: Partial<GlobalSettings>): GlobalSettings {
...current,
...patch,
retention: { ...current.retention, ...(patch.retention ?? {}) },
selectedModels: { ...current.selectedModels, ...(patch.selectedModels ?? {}) }
selectedModels: { ...current.selectedModels, ...(patch.selectedModels ?? {}) },
weather: { ...current.weather, ...(patch.weather ?? {}) }
};
db.prepare(
`UPDATE global_settings SET
@@ -46,7 +58,9 @@ export function updateSettings(patch: Partial<GlobalSettings>): GlobalSettings {
ai_service_host=?, ai_service_port=?, selected_models=?,
nitter_media_mode=?, fxtwitter_base_url=?, telegram_media_mode=?,
published_article_max_age_days=?, raw_item_max_age_days=?,
storage_cap_enabled=?, storage_cap_value=?, storage_cap_unit=?
storage_cap_enabled=?, storage_cap_value=?, storage_cap_unit=?,
weather_location_name=?, weather_latitude=?, weather_longitude=?, weather_unit=?,
weather_current=?, weather_hourly=?, weather_daily=?, weather_updated_at=?
WHERE id = 1`
).run(
merged.mergeStrictness,
@@ -66,7 +80,15 @@ export function updateSettings(patch: Partial<GlobalSettings>): GlobalSettings {
merged.retention.rawItemMaxAgeDays,
merged.retention.storageCapEnabled ? 1 : 0,
merged.retention.storageCapValue,
merged.retention.storageCapUnit
merged.retention.storageCapUnit,
merged.weather.locationName,
merged.weather.latitude,
merged.weather.longitude,
merged.weather.unit,
merged.weather.current ? JSON.stringify(merged.weather.current) : null,
JSON.stringify(merged.weather.hourly),
JSON.stringify(merged.weather.daily),
merged.weather.updatedAt
);
return getSettings();
}
+54
View File
@@ -0,0 +1,54 @@
import { randomUUID } from 'node:crypto';
import { db } from './index.js';
import type { StockTicker } from './types.js';
function rowToTicker(row: any): StockTicker {
return {
id: row.id,
label: row.label,
symbol: row.symbol,
priorityRank: row.priority_rank,
lastPrice: row.last_price,
lastChangePercent: row.last_change_percent,
lastPolledAt: row.last_polled_at,
lastError: row.last_error,
createdAt: row.created_at
};
}
export function listStockTickers(): StockTicker[] {
const rows = db.prepare('SELECT * FROM stock_tickers ORDER BY priority_rank').all();
return rows.map(rowToTicker);
}
export function createStockTicker(label: string, symbol: string): StockTicker {
const id = `stk-${symbol.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')}-${randomUUID().slice(0, 6)}`;
const maxRank = db.prepare('SELECT COALESCE(MAX(priority_rank), 0) as m FROM stock_tickers').get() as { m: number };
const createdAt = new Date().toISOString();
db.prepare(
'INSERT INTO stock_tickers (id, label, symbol, priority_rank, created_at) VALUES (?, ?, ?, ?, ?)'
).run(id, label, symbol, maxRank.m + 1, createdAt);
return {
id, label, symbol, priorityRank: maxRank.m + 1,
lastPrice: null, lastChangePercent: null, lastPolledAt: null, lastError: null, createdAt
};
}
export function updateStockTicker(id: string, patch: { label?: string; symbol?: string }): StockTicker | null {
const existing = db.prepare('SELECT * FROM stock_tickers WHERE id = ?').get(id);
if (!existing) return null;
const current = rowToTicker(existing);
const merged = { ...current, ...patch };
db.prepare('UPDATE stock_tickers SET label = ?, symbol = ? WHERE id = ?').run(merged.label, merged.symbol, id);
return { ...merged };
}
export function deleteStockTicker(id: string) {
db.prepare('DELETE FROM stock_tickers WHERE id = ?').run(id);
}
export function markStockPolled(id: string, price: number | null, changePercent: number | null, error: string | null) {
db.prepare(
'UPDATE stock_tickers SET last_price = ?, last_change_percent = ?, last_polled_at = ?, last_error = ? WHERE id = ?'
).run(price, changePercent, new Date().toISOString(), error, id);
}
+47
View File
@@ -206,6 +206,42 @@ export interface Category {
isSpillover: boolean;
}
export interface WeatherHourEntry {
time: string;
temp: number;
conditionText: string;
icon: string;
}
export interface WeatherDayEntry {
date: string;
tempMax: number;
tempMin: number;
conditionText: string;
icon: string;
}
export interface StockTicker {
id: string;
label: string;
symbol: string;
priorityRank: number;
lastPrice: number | null;
lastChangePercent: number | null;
lastPolledAt: string | null;
lastError: string | null;
createdAt: string;
}
export interface Bookmark {
id: string;
name: string;
url: string;
priorityRank: number;
isPrivate: boolean;
createdAt: string;
}
export interface GlobalSettings {
mergeStrictness: 1 | 2 | 3 | 4 | 5;
defaultPollIntervalMinutes: number;
@@ -230,4 +266,15 @@ export interface GlobalSettings {
storageCapValue: number;
storageCapUnit: 'MB' | 'GB';
};
/** Sidebar "Weather" widget config + cache — see weather/poller.ts. Singleton, since there's only ever one configured location. */
weather: {
locationName: string | null;
latitude: number | null;
longitude: number | null;
unit: 'celsius' | 'fahrenheit';
current: { temp: number; conditionText: string; icon: string } | null;
hourly: WeatherHourEntry[];
daily: WeatherDayEntry[];
updatedAt: string | null;
};
}
+124
View File
@@ -0,0 +1,124 @@
// Open-Meteo (api.open-meteo.com / geocoding-api.open-meteo.com) — free, no account or API
// key required, which is why it was picked over any commercial weather provider. This is
// the only file that talks to it; poller.ts orchestrates when/how the result gets saved,
// same separation as backend/src/telegram/ keeps between the raw client and its callers.
export interface GeocodeResult {
name: string;
admin1: string | null;
country: string | null;
latitude: number;
longitude: number;
}
export interface WeatherCondition {
text: string;
icon: string;
}
// WMO weather interpretation codes, as returned by Open-Meteo's weather_code field —
// https://open-meteo.com/en/docs lists the full table this summarizes.
const WMO_CONDITIONS: Record<number, WeatherCondition> = {
0: { text: 'Clear sky', icon: '☀️' },
1: { text: 'Mainly clear', icon: '🌤️' },
2: { text: 'Partly cloudy', icon: '⛅' },
3: { text: 'Overcast', icon: '☁️' },
45: { text: 'Fog', icon: '🌫️' },
48: { text: 'Depositing rime fog', icon: '🌫️' },
51: { text: 'Light drizzle', icon: '🌦️' },
53: { text: 'Moderate drizzle', icon: '🌦️' },
55: { text: 'Dense drizzle', icon: '🌦️' },
56: { text: 'Light freezing drizzle', icon: '🌧️' },
57: { text: 'Dense freezing drizzle', icon: '🌧️' },
61: { text: 'Slight rain', icon: '🌧️' },
63: { text: 'Moderate rain', icon: '🌧️' },
65: { text: 'Heavy rain', icon: '🌧️' },
66: { text: 'Light freezing rain', icon: '🌧️' },
67: { text: 'Heavy freezing rain', icon: '🌧️' },
71: { text: 'Slight snow', icon: '🌨️' },
73: { text: 'Moderate snow', icon: '🌨️' },
75: { text: 'Heavy snow', icon: '❄️' },
77: { text: 'Snow grains', icon: '❄️' },
80: { text: 'Slight rain showers', icon: '🌦️' },
81: { text: 'Moderate rain showers', icon: '🌦️' },
82: { text: 'Violent rain showers', icon: '⛈️' },
85: { text: 'Slight snow showers', icon: '🌨️' },
86: { text: 'Heavy snow showers', icon: '🌨️' },
95: { text: 'Thunderstorm', icon: '⛈️' },
96: { text: 'Thunderstorm, slight hail', icon: '⛈️' },
99: { text: 'Thunderstorm, heavy hail', icon: '⛈️' }
};
export function wmoToCondition(code: number): WeatherCondition {
return WMO_CONDITIONS[code] ?? { text: 'Unknown', icon: '❔' };
}
export async function geocodeLocation(query: string): Promise<GeocodeResult[]> {
const url = `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(query)}&count=8`;
const res = await fetch(url);
if (!res.ok) throw new Error(`Geocoding API returned ${res.status}`);
const data = (await res.json()) as {
results?: { name: string; admin1?: string; country?: string; latitude: number; longitude: number }[];
};
return (data.results ?? []).map((r) => ({
name: r.name,
admin1: r.admin1 ?? null,
country: r.country ?? null,
latitude: r.latitude,
longitude: r.longitude
}));
}
export interface ForecastResult {
current: { temp: number; conditionText: string; icon: string };
hourly: { time: string; temp: number; conditionText: string; icon: string }[];
daily: { date: string; tempMax: number; tempMin: number; conditionText: string; icon: string }[];
}
export async function fetchForecast(
latitude: number,
longitude: number,
unit: 'celsius' | 'fahrenheit'
): Promise<ForecastResult> {
const url =
`https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}` +
`&current=temperature_2m,weather_code&hourly=temperature_2m,weather_code` +
`&daily=temperature_2m_max,temperature_2m_min,weather_code` +
`&temperature_unit=${unit}&timezone=auto&forecast_days=7`;
const res = await fetch(url);
if (!res.ok) throw new Error(`Forecast API returned ${res.status}`);
const data = (await res.json()) as {
current: { temperature_2m: number; weather_code: number };
hourly: { time: string[]; temperature_2m: number[]; weather_code: number[] };
daily: { time: string[]; temperature_2m_max: number[]; temperature_2m_min: number[]; weather_code: number[] };
};
const currentCondition = wmoToCondition(data.current.weather_code);
const current = { temp: data.current.temperature_2m, conditionText: currentCondition.text, icon: currentCondition.icon };
// hourly.time starts at today's midnight, not the current hour — find the first entry
// at or after now so the strip shown to the user starts from "now", not from midnight.
const now = Date.now();
const startIdx = Math.max(
0,
data.hourly.time.findIndex((t) => new Date(t).getTime() >= now)
);
const hourly = data.hourly.time.slice(startIdx, startIdx + 24).map((time, i) => {
const idx = startIdx + i;
const condition = wmoToCondition(data.hourly.weather_code[idx]);
return { time, temp: data.hourly.temperature_2m[idx], conditionText: condition.text, icon: condition.icon };
});
const daily = data.daily.time.map((date, i) => {
const condition = wmoToCondition(data.daily.weather_code[i]);
return {
date,
tempMax: data.daily.temperature_2m_max[i],
tempMin: data.daily.temperature_2m_min[i],
conditionText: condition.text,
icon: condition.icon
};
});
return { current, hourly, daily };
}
+23
View File
@@ -0,0 +1,23 @@
import * as settingsDb from '../storage/db/settings.js';
import { logger } from '../storage/db/logs.js';
import { fetchForecast } from './client.js';
// Called on a schedule (see queue/scheduler.ts) and immediately after the admin changes
// the weather location/unit (see api/admin.ts) — writes straight into global_settings'
// weather_* columns via settingsDb, same singleton-row approach as retention.
export async function pollWeatherNow(): Promise<void> {
const { weather } = settingsDb.getSettings();
if (weather.latitude === null || weather.longitude === null) {
// No location configured yet — not an error, just nothing to do.
return;
}
try {
const { current, hourly, daily } = await fetchForecast(weather.latitude, weather.longitude, weather.unit);
settingsDb.updateSettings({
weather: { ...weather, current, hourly, daily, updatedAt: new Date().toISOString() }
});
} catch (err) {
// Leave the existing cache untouched — a stale forecast beats a blank widget.
logger.error('weather', `Poll failed: ${(err as Error).message}`);
}
}