feat: /setappttl admin command for runtime cache TTL override

New admin-only command persists APP_CACHE_SECONDS override in admin doc
(admin.appCacheSeconds), bounds [60, 86400], 0/default clears. Fallback
to env when unset.

app-cache-repository now takes a lazy memoized TTL getter — admin doc
read at most once per request, only on cache write paths. app-builder
wires (admin.getAppCacheSeconds() ?? config.appCacheSeconds).

/settings now displays effective TTL with override status alongside the
existing per-group settings.

Also fix /setdayswarning + /setappttl arg parsing: gate reset on string
'0' instead of parseInt === 0, so '/cmd 0.5', '/cmd 0xff', '/cmd 0abc'
no longer silently trigger a reset.
This commit is contained in:
tiennm99 committed 2026-05-10 02:46:26 +07:00
1 parent 1ed82578c0
commit 675f802746
7 files changed
+100 -11

No files matched your search

+7 -2
View File
@@ -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);
+11 -3
View File
@@ -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 = '<b>Group Settings</b>\n' + `<pre>${buildTable(['Setting', 'Value', 'Default'], rows)}</pre>`;
const out =
'<b>Group Settings</b>\n' +
`<pre>${buildTable(['Setting', 'Value', 'Default'], rows)}</pre>`;
await sender.sendMessage(msg.chat.id, out);
};
}
+2
View File
@@ -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.
+43
View File
@@ -0,0 +1,43 @@
import { requireAdminUser } from './command-utils.js';
const MIN_TTL = 60;
const MAX_TTL = 86400;
// /setappttl <n> — 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`);
};
}
+2 -2
View File
@@ -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;
+23 -2
View File
@@ -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,
};
}
+12 -2
View File
@@ -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() },
);
},
};