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