diff --git a/src/app-builder.js b/src/app-builder.js index b7762b8..3741102 100644 --- a/src/app-builder.js +++ b/src/app-builder.js @@ -18,8 +18,13 @@ export function buildApp(env) { admin: createAdminRepository(handle), group: createGroupRepository(handle), }; - const appleCache = createAppCacheRepository(handle, 'apple', config.appCacheSeconds); - const googleCache = createAppCacheRepository(handle, 'google', config.appCacheSeconds); + // Resolves to admin override if set, else env default. Per-repo memoization + // means each cache repo reads the admin doc at most once per request, and + // only on the first cache write — read-only paths cost zero extra Redis ops. + const ttlGetter = async () => + (await store.admin.getAppCacheSeconds()) ?? config.appCacheSeconds; + const appleCache = createAppCacheRepository(handle, 'apple', ttlGetter); + const googleCache = createAppCacheRepository(handle, 'google', ttlGetter); const appleScraper = createAppleScraper(config, appleCache); const googleScraper = createGoogleScraper(config, googleCache); const { sender, commands } = createBot(config, store, appleScraper, googleScraper); diff --git a/src/bot/commands/get-settings.js b/src/bot/commands/get-settings.js index 8c3f9ec..1e56e79 100644 --- a/src/bot/commands/get-settings.js +++ b/src/bot/commands/get-settings.js @@ -1,8 +1,8 @@ import { buildTable } from '../../util/table.js'; import { authorizeGroup } from './command-utils.js'; -// /settings — table of per-group setting overrides + their env defaults. -// Add new rows here when introducing new per-group settings. +// /settings — table of per-group setting overrides + their env defaults, +// plus admin-global runtime overrides (cache TTL). export function createGetSettingsCommand(config, store) { return async (msg, sender, args) => { if (!(await authorizeGroup(msg.chat.id, store, sender))) return; @@ -12,14 +12,22 @@ export function createGetSettingsCommand(config, store) { } const group = await store.group.getGroup(msg.chat.id); const s = group?.settings ?? {}; + const adminTtl = await store.admin.getAppCacheSeconds(); const rows = [ [ 'numDaysWarningNotUpdated', formatValue(s.numDaysWarningNotUpdated), String(config.numDaysWarningNotUpdated), ], + [ + 'appCacheSeconds (admin)', + adminTtl === undefined ? '(unset)' : `${adminTtl}`, + String(config.appCacheSeconds), + ], ]; - const out = 'Group Settings\n' + `
${buildTable(['Setting', 'Value', 'Default'], rows)}
`; + const out = + 'Group Settings\n' + + `
${buildTable(['Setting', 'Value', 'Default'], rows)}
`; await sender.sendMessage(msg.chat.id, out); }; } diff --git a/src/bot/commands/index.js b/src/bot/commands/index.js index 835cace..eada2ce 100644 --- a/src/bot/commands/index.js +++ b/src/bot/commands/index.js @@ -22,6 +22,7 @@ import { createRawAppleAppCommand } from './raw-apple-app.js'; import { createRawGoogleAppCommand } from './raw-google-app.js'; import { createGetSettingsCommand } from './get-settings.js'; import { createSetDaysWarningCommand } from './set-days-warning.js'; +import { createSetAppTtlCommand } from './set-app-ttl.js'; export const COMMAND_CATALOG = [ { name: 'info', description: 'Show this group ID', adminOnly: false, build: () => createInfoCommand() }, @@ -39,6 +40,7 @@ export const COMMAND_CATALOG = [ { name: 'rawgoogleapp', description: 'Dump raw Google API JSON for an app', adminOnly: false, build: (c) => createRawGoogleAppCommand(c.store, c.googleScraper) }, { name: 'settings', description: "Show this group's settings", adminOnly: false, build: (c) => createGetSettingsCommand(c.config, c.store) }, { name: 'setdayswarning', description: 'Set warning threshold (days, 0 = default)', adminOnly: false, build: (c) => createSetDaysWarningCommand(c.config, c.store) }, + { name: 'setappttl', description: '[admin] Set cache TTL (seconds, 0 = default)', adminOnly: true, build: (c) => createSetAppTtlCommand(c.config, c.store) }, ]; // Telegram menu projections. diff --git a/src/bot/commands/set-app-ttl.js b/src/bot/commands/set-app-ttl.js new file mode 100644 index 0000000..fb8798d --- /dev/null +++ b/src/bot/commands/set-app-ttl.js @@ -0,0 +1,43 @@ +import { requireAdminUser } from './command-utils.js'; + +const MIN_TTL = 60; +const MAX_TTL = 86400; + +// /setappttl — admin-only. Overrides upstream cache TTL (seconds). +// `0` or `default` clears the override (falls back to APP_CACHE_SECONDS env). +export function createSetAppTtlCommand(config, store) { + return async (msg, sender, args) => { + if (!(await requireAdminUser(msg.from.id, msg.chat.id, config, sender))) return; + if (args.length !== 1) { + await sender.sendMessage(msg.chat.id, 'Invalid arguments'); + return; + } + const arg = args[0]; + + if (arg === 'default' || arg === '0') { + await store.admin.setAppCacheSeconds(undefined); + await sender.sendMessage( + msg.chat.id, + `Reset to default (${config.appCacheSeconds}s)`, + ); + return; + } + + const parsed = Number.parseInt(arg, 10); + if ( + !Number.isFinite(parsed) || + String(parsed) !== arg || + parsed < MIN_TTL || + parsed > MAX_TTL + ) { + await sender.sendMessage( + msg.chat.id, + `Invalid arguments (allowed range: ${MIN_TTL}-${MAX_TTL}s)`, + ); + return; + } + + await store.admin.setAppCacheSeconds(parsed); + await sender.sendMessage(msg.chat.id, `App cache TTL set to ${parsed}s`); + }; +} diff --git a/src/bot/commands/set-days-warning.js b/src/bot/commands/set-days-warning.js index 65b9bf1..f15b679 100644 --- a/src/bot/commands/set-days-warning.js +++ b/src/bot/commands/set-days-warning.js @@ -12,9 +12,8 @@ export function createSetDaysWarningCommand(config, store) { return; } const arg = args[0]; - const parsed = Number.parseInt(arg, 10); - if (arg === 'default' || parsed === 0) { + if (arg === 'default' || arg === '0') { await store.group.setSetting(msg.chat.id, 'numDaysWarningNotUpdated', undefined); await sender.sendMessage( msg.chat.id, @@ -23,6 +22,7 @@ export function createSetDaysWarningCommand(config, store) { return; } + const parsed = Number.parseInt(arg, 10); if (!Number.isFinite(parsed) || String(parsed) !== arg || parsed < 1 || parsed > MAX_DAYS) { await sender.sendMessage(msg.chat.id, 'Invalid arguments'); return; diff --git a/src/repository/admin-repository.js b/src/repository/admin-repository.js index bde7109..a4af2fc 100644 --- a/src/repository/admin-repository.js +++ b/src/repository/admin-repository.js @@ -2,7 +2,8 @@ import { getJson, putJson } from './upstash.js'; const ADMIN_KEY = 'admin'; -// Upstash-backed admin singleton. Holds the authorized chat ID allowlist. +// Upstash-backed admin singleton. Holds the authorized chat ID allowlist +// plus optional admin-global runtime settings (e.g. cache TTL override). export function createAdminRepository(handle) { async function load() { return (await getJson(handle, ADMIN_KEY)) ?? { groups: [] }; @@ -38,5 +39,25 @@ export function createAdminRepository(handle) { return (await load()).groups; } - return { addGroup, removeGroup, hasGroup, getAllGroups }; + // undefined when no override set — caller falls back to env default. + async function getAppCacheSeconds() { + return (await load()).appCacheSeconds; + } + + // Pass `undefined` to clear the override. + async function setAppCacheSeconds(seconds) { + const admin = await load(); + if (seconds === undefined) delete admin.appCacheSeconds; + else admin.appCacheSeconds = seconds; + await save(admin); + } + + return { + addGroup, + removeGroup, + hasGroup, + getAllGroups, + getAppCacheSeconds, + setAppCacheSeconds, + }; } diff --git a/src/repository/app-cache-repository.js b/src/repository/app-cache-repository.js index 937c053..e950bee 100644 --- a/src/repository/app-cache-repository.js +++ b/src/repository/app-cache-repository.js @@ -3,11 +3,21 @@ import { getJson, putJson } from './upstash.js'; // Per-store cache for upstream app responses. TTL via Redis EX — // expired keys are deleted, so a null read is the cache miss. // `prefix` is the logical key namespace ('apple' or 'google'). -export function createAppCacheRepository(handle, prefix, appCacheSeconds) { +// `getTtl` is an async function returning the current TTL in seconds; the +// resolved value is memoized for the lifetime of this repo instance, so the +// admin override doc is read at most once per request even with multiple +// cache writes. +export function createAppCacheRepository(handle, prefix, getTtl) { function key(appId) { return `${prefix}:${appId}`; } + let cachedTtl; + async function ttl() { + if (cachedTtl === undefined) cachedTtl = await getTtl(); + return cachedTtl; + } + return { async getCached(appId) { return getJson(handle, key(appId)); @@ -17,7 +27,7 @@ export function createAppCacheRepository(handle, prefix, appCacheSeconds) { handle, key(appId), { app: response, millis: Date.now() }, - { expirationTtl: appCacheSeconds }, + { expirationTtl: await ttl() }, ); }, };