refactor: drop dead repo + telegram-api exports, inline createStore

Delete repository/store.js (one-line aggregator) — wiring now inline in
app-builder.js. Drop unused exports: scan + UpstashUnavailable from
upstash.js, getMe + TelegramApiError class from telegram-api.js,
init/getAdmin/save from admin-repository.js, exists/saveGroup from
group-repository.js. Generic Error replaces the named error classes.
This commit is contained in:
tiennm99 committed 2026-05-09 22:32:12 +07:00
1 parent 32b14217cc
commit 9bcdaa9130
6 files changed
+43 -117

No files matched your search

+10 -2
View File
@@ -4,7 +4,10 @@
import { loadConfig } from './config.js';
import { createUpstashClient } from './repository/upstash.js';
import { createStore } from './repository/store.js';
import { createAdminRepository } from './repository/admin-repository.js';
import { createGroupRepository } from './repository/group-repository.js';
import { createAppleAppRepository } from './repository/apple-app-repository.js';
import { createGoogleAppRepository } from './repository/google-app-repository.js';
import { createAppleScraper } from './api/apple-scraper.js';
import { createGoogleScraper } from './api/google-scraper.js';
import { createBot } from './bot/bot.js';
@@ -12,7 +15,12 @@ import { createBot } from './bot/bot.js';
export function buildApp(env) {
const config = loadConfig(env);
const handle = createUpstashClient(env);
const store = createStore(handle, config.appCacheSeconds);
const store = {
admin: createAdminRepository(handle),
group: createGroupRepository(handle),
appleApp: createAppleAppRepository(handle, config.appCacheSeconds),
googleApp: createGoogleAppRepository(handle, config.appCacheSeconds),
};
const appleScraper = createAppleScraper(config, store);
const googleScraper = createGoogleScraper(config, store);
const { sender, commands } = createBot(config, store, appleScraper, googleScraper);
+4 -17
View File
@@ -1,18 +1,7 @@
// Raw fetch wrapper for the Telegram Bot API. Replaces node-telegram-bot-api
// (which uses Node-only request/streams and bloats the Worker bundle).
// Raw fetch wrapper for the Telegram Bot API.
const TELEGRAM_BASE = 'https://api.telegram.org';
export class TelegramApiError extends Error {
constructor(method, status, body) {
super(`telegram ${method} failed: ${status} ${body}`);
this.name = 'TelegramApiError';
this.method = method;
this.status = status;
this.body = body;
}
}
export function createTelegramApi(token) {
const base = `${TELEGRAM_BASE}/bot${token}`;
@@ -23,12 +12,11 @@ export function createTelegramApi(token) {
body: JSON.stringify(payload),
});
const text = await res.text();
if (!res.ok) throw new TelegramApiError(method, res.status, text);
if (!res.ok) throw new Error(`telegram ${method} failed: ${res.status} ${text}`);
return JSON.parse(text);
}
// multipart/form-data — for sendDocument. WHATWG FormData/Blob is native to
// Workers; no `form-data` npm dep needed.
// multipart/form-data — for sendDocument. WHATWG FormData/Blob is native.
async function callMultipart(method, fields, file) {
const form = new FormData();
for (const [k, v] of Object.entries(fields)) form.set(k, String(v));
@@ -41,12 +29,11 @@ export function createTelegramApi(token) {
}
const res = await fetch(`${base}/${method}`, { method: 'POST', body: form });
const text = await res.text();
if (!res.ok) throw new TelegramApiError(method, res.status, text);
if (!res.ok) throw new Error(`telegram ${method} failed: ${res.status} ${text}`);
return JSON.parse(text);
}
return {
getMe: () => callJson('getMe', {}),
sendMessage: (chatId, text, opts = {}) =>
callJson('sendMessage', { chat_id: chatId, text, ...opts }),
sendDocument: (chatId, filename, body, opts = {}) =>
+7 -15
View File
@@ -4,15 +4,8 @@ const ADMIN_KEY = 'admin';
// Upstash-backed admin singleton. Holds the authorized chat ID allowlist.
export function createAdminRepository(handle) {
async function init() {
const existing = await getJson(handle, ADMIN_KEY);
if (existing) return;
await save({ _id: ADMIN_KEY, groups: [] });
}
async function getAdmin() {
const doc = await getJson(handle, ADMIN_KEY);
return doc ?? { _id: ADMIN_KEY, groups: [] };
async function load() {
return (await getJson(handle, ADMIN_KEY)) ?? { groups: [] };
}
async function save(admin) {
@@ -20,7 +13,7 @@ export function createAdminRepository(handle) {
}
async function addGroup(groupId) {
const admin = await getAdmin();
const admin = await load();
if (admin.groups.includes(groupId)) return false;
admin.groups.push(groupId);
await save(admin);
@@ -28,7 +21,7 @@ export function createAdminRepository(handle) {
}
async function removeGroup(groupId) {
const admin = await getAdmin();
const admin = await load();
const i = admin.groups.indexOf(groupId);
if (i < 0) return false;
admin.groups.splice(i, 1);
@@ -37,14 +30,13 @@ export function createAdminRepository(handle) {
}
async function hasGroup(groupId) {
const admin = await getAdmin();
const admin = await load();
return admin.groups.includes(groupId);
}
async function getAllGroups() {
const admin = await getAdmin();
return admin.groups;
return (await load()).groups;
}
return { init, getAdmin, save, addGroup, removeGroup, hasGroup, getAllGroups };
return { addGroup, removeGroup, hasGroup, getAllGroups };
}
+15 -23
View File
@@ -3,36 +3,37 @@ import { del, getJson, putJson } from './upstash.js';
// Upstash-backed per-group state. Logical key shape: `group:{chatId}`.
export function createGroupRepository(handle) {
function key(groupId) {
return `group:${String(groupId)}`;
return `group:${groupId}`;
}
function newGroup(groupId) {
return { _id: String(groupId), appleApps: [], googleApps: [] };
}
async function exists(groupId) {
const doc = await getJson(handle, key(groupId));
return doc !== null;
function emptyGroup() {
return { appleApps: [], googleApps: [] };
}
async function getGroup(groupId) {
const doc = await getJson(handle, key(groupId));
return doc ?? newGroup(groupId);
return (await getJson(handle, key(groupId))) ?? emptyGroup();
}
async function saveGroup(group) {
await putJson(handle, key(group._id), group);
async function save(groupId, group) {
await putJson(handle, key(groupId), group);
}
async function initGroup(groupId) {
if (await exists(groupId)) return;
await saveGroup(newGroup(groupId));
if (await getJson(handle, key(groupId))) return;
await save(groupId, emptyGroup());
}
async function deleteGroup(groupId) {
await del(handle, key(groupId));
}
async function mutateAndSave(groupId, mutator) {
const group = await getGroup(groupId);
if (!mutator(group)) return false;
await save(groupId, group);
return true;
}
function addApp(list, appId, country) {
if (list.some((a) => a.appId === appId)) return false;
list.push({ appId, country });
@@ -46,17 +47,8 @@ export function createGroupRepository(handle) {
return true;
}
async function mutateAndSave(groupId, mutator) {
const group = await getGroup(groupId);
if (!mutator(group)) return false;
await saveGroup(group);
return true;
}
return {
exists,
getGroup,
saveGroup,
initGroup,
deleteGroup,
addAppleApp: (groupId, appId, country) =>
-16
View File
@@ -1,16 +0,0 @@
import { createAdminRepository } from './admin-repository.js';
import { createGroupRepository } from './group-repository.js';
import { createAppleAppRepository } from './apple-app-repository.js';
import { createGoogleAppRepository } from './google-app-repository.js';
// Single binding point for all repositories. Threads the Upstash handle
// (client + key prefix) once so command handlers don't need to know about
// process.env or the Redis client construction.
export function createStore(handle, appCacheSeconds) {
return {
admin: createAdminRepository(handle),
group: createGroupRepository(handle),
appleApp: createAppleAppRepository(handle, appCacheSeconds),
googleApp: createGoogleAppRepository(handle, appCacheSeconds),
};
}
+7 -44
View File
@@ -8,38 +8,23 @@
//
// Multi-tenancy: every physical Redis key carries a configurable prefix
// (env.KEY_PREFIX, default 'store-scraper-bot:') so this bot can safely share
// an Upstash database with other Vercel projects without collision. Repository
// callers pass logical keys; the adapter applies the prefix transparently.
// an Upstash database with other Vercel projects without collision.
//
// 60s minimum TTL clamp is preserved from the KV days for parity safety,
// even though Redis would accept lower values.
// 60s minimum TTL clamp is preserved from the KV days for parity safety.
import { Redis } from '@upstash/redis';
const MIN_TTL_SECONDS = 60;
const DEFAULT_KEY_PREFIX = 'store-scraper-bot:';
export class UpstashUnavailable extends Error {
constructor(missing) {
super(`Upstash env var missing: ${missing}`);
this.name = 'UpstashUnavailable';
}
}
// Build a handle bundling the Redis client and the key prefix together.
// The handle is what callers pass into getJson/putJson/del/scan — it stays
// opaque so repositories never need to know about prefixing themselves.
//
// Accepts both env var naming conventions:
// - UPSTASH_REDIS_REST_URL / UPSTASH_REDIS_REST_TOKEN (vanilla Upstash signup)
// - KV_REST_API_URL / KV_REST_API_TOKEN (Vercel Marketplace integration)
// so the operator doesn't have to duplicate vars when the bot shares an
// Upstash DB provisioned via Vercel.
// UPSTASH_REDIS_REST_URL / UPSTASH_REDIS_REST_TOKEN (vanilla Upstash signup)
// KV_REST_API_URL / KV_REST_API_TOKEN (Vercel Marketplace integration)
export function createUpstashClient(env) {
const url = env?.UPSTASH_REDIS_REST_URL ?? env?.KV_REST_API_URL;
const token = env?.UPSTASH_REDIS_REST_TOKEN ?? env?.KV_REST_API_TOKEN;
if (!url) throw new UpstashUnavailable('UPSTASH_REDIS_REST_URL or KV_REST_API_URL');
if (!token) throw new UpstashUnavailable('UPSTASH_REDIS_REST_TOKEN or KV_REST_API_TOKEN');
if (!url) throw new Error('UPSTASH_REDIS_REST_URL or KV_REST_API_URL is required');
if (!token) throw new Error('UPSTASH_REDIS_REST_TOKEN or KV_REST_API_TOKEN is required');
const client = new Redis({ url, token });
const prefix = env.KEY_PREFIX ?? DEFAULT_KEY_PREFIX;
return { client, prefix };
@@ -49,14 +34,10 @@ function physicalKey(handle, key) {
return `${handle.prefix}${key}`;
}
// Upstash auto-deserializes values that look like JSON. We always store via
// JSON.stringify, so reads can return the parsed object directly. Returns null
// on missing key, matching the prior KV semantics.
export async function getJson(handle, key) {
const value = await handle.client.get(physicalKey(handle, key));
if (value == null) return null;
// Some SDK versions return strings, others return parsed objects depending
// on content. Normalize: if string, parse; if object, pass through.
// Some SDK versions return strings, others return parsed objects.
return typeof value === 'string' ? JSON.parse(value) : value;
}
@@ -70,21 +51,3 @@ export async function putJson(handle, key, value, opts = {}) {
export async function del(handle, key) {
await handle.client.del(physicalKey(handle, key));
}
// Suffix-based scan. Caller passes a logical match like 'group:*'; adapter
// prepends the key prefix so only this bot's keys are returned.
// Returns the list of *logical* keys (prefix stripped) so callers stay
// prefix-unaware.
export async function scan(handle, matchSuffix) {
const match = `${handle.prefix}${matchSuffix}`;
const out = [];
let cursor = '0';
do {
const [next, batch] = await handle.client.scan(cursor, { match, count: 100 });
cursor = next;
for (const physical of batch) {
out.push(physical.startsWith(handle.prefix) ? physical.slice(handle.prefix.length) : physical);
}
} while (cursor !== '0');
return out;
}