import { AsyncLocalStorage } from 'node:async_hooks'; import { createHash, createHmac, timingSafeEqual } from 'node:crypto'; import { promises as fs } from 'node:fs'; import path from 'node:path'; import type { MiddlewareHandler } from 'hono'; // Telegram/HF replacement for the Devvit runtime services (redis, context, reddit, realtime). // State lives as JSON files under DATA_DIR (an HF Storage Bucket mount in production, // ./data in local dev). Identity comes from Telegram WebApp initData instead of Reddit. export type RuntimeIdentity = { postId: string; username: string }; export type TelegramUser = { id: number; username?: string; first_name?: string }; export const DATA_DIR = process.env.WEEDSIM_DATA_DIR ?? path.resolve('data'); export const WORLD_ID = process.env.WEEDSIM_WORLD_ID ?? 'world-1'; export const BOT_TOKEN = process.env.BOT_TOKEN ?? ''; export const WEBAPP_URL = (process.env.WEBAPP_URL ?? '').replace(/\/$/, ''); export const PASS_PRICE_STARS = Math.max(1, Number(process.env.PASS_PRICE_STARS ?? 420) || 420); export const PASS_PAYLOAD = 'breeders-pass'; export const FREE_BREED_LIMIT = Math.max(0, Number(process.env.FREE_BREED_LIMIT ?? 25) || 25); const INIT_DATA_MAX_AGE_SECONDS = Math.max(300, Number(process.env.INIT_DATA_MAX_AGE_SECONDS ?? 86_400) || 86_400); // Dev mode: no bot token means no Telegram to verify against — run open, as the // Devvit build did with context.postId/user absent ('local-post'/'anonymous'). export const DEV_MODE = !BOT_TOKEN; type RequestAuth = { identity: RuntimeIdentity; tgUser: TelegramUser | null }; const authStore = new AsyncLocalStorage(); const ANONYMOUS: RequestAuth = { identity: { postId: WORLD_ID, username: 'anonymous' }, tgUser: null }; /** Drop-in for the old per-route identity() helpers. */ export async function identity(): Promise { return (authStore.getStore() ?? ANONYMOUS).identity; } export function currentTgUser(): TelegramUser | null { return (authStore.getStore() ?? ANONYMOUS).tgUser; } // --------------------------------------------------------------------------- // File-backed KV with the same contract the routes used from Devvit redis: // string values, get/set/del, last-write-wins on whole values. // --------------------------------------------------------------------------- function kvPath(key: string): string { const safe = key.replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 120); const digest = createHash('sha1').update(key).digest('hex').slice(0, 10); return path.join(DATA_DIR, 'kv', `${safe}.${digest}.json`); } const writeQueues = new Map>(); function enqueue(key: string, work: () => Promise): Promise { const next = (writeQueues.get(key) ?? Promise.resolve()).then(work, work); writeQueues.set(key, next); void next.finally(() => { if (writeQueues.get(key) === next) writeQueues.delete(key); }); return next; } async function writeFileAtomic(file: string, value: string): Promise { await fs.mkdir(path.dirname(file), { recursive: true }); const temp = `${file}.${process.pid}.${Math.random().toString(36).slice(2, 8)}.tmp`; await fs.writeFile(temp, value, 'utf8'); await fs.rename(temp, file); } export const redis = { async get(key: string): Promise { try { return await fs.readFile(kvPath(key), 'utf8'); } catch { return undefined; } }, async set(key: string, value: string): Promise { await enqueue(key, () => writeFileAtomic(kvPath(key), value)); }, async del(key: string): Promise { await enqueue(key, async () => { await fs.rm(kvPath(key), { force: true }); }); }, }; // --------------------------------------------------------------------------- // Realtime: the Devvit push channel was a fail-open convenience; the client // already polls /api/agent-link/events, so push is intentionally a no-op here. // --------------------------------------------------------------------------- export const realtime = { async send(_channel: string, _message: unknown): Promise {}, }; // --------------------------------------------------------------------------- // Telegram initData verification (https://core.telegram.org/bots/webapps). // --------------------------------------------------------------------------- export function verifyInitData(raw: string): TelegramUser | null { if (!raw || !BOT_TOKEN) return null; const params = new URLSearchParams(raw); const hash = params.get('hash'); if (!hash) return null; params.delete('hash'); const dataCheckString = [...params.entries()] .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) .map(([key, value]) => `${key}=${value}`) .join('\n'); const secret = createHmac('sha256', 'WebAppData').update(BOT_TOKEN).digest(); const expected = createHmac('sha256', secret).update(dataCheckString).digest('hex'); const expectedBuffer = Buffer.from(expected, 'hex'); let providedBuffer: Buffer; try { providedBuffer = Buffer.from(hash, 'hex'); } catch { return null; } if (expectedBuffer.length !== providedBuffer.length || !timingSafeEqual(expectedBuffer, providedBuffer)) return null; const authDate = Number(params.get('auth_date') ?? 0); if (!authDate || Date.now() / 1000 - authDate > INIT_DATA_MAX_AGE_SECONDS) return null; try { const user = JSON.parse(params.get('user') ?? '') as TelegramUser; return typeof user?.id === 'number' ? user : null; } catch { return null; } } // --------------------------------------------------------------------------- // Stable player names. Telegram handles can change; state keys must not. // First visit persists a name for the numeric user id and keeps it forever. // --------------------------------------------------------------------------- function userNamePath(userId: number): string { return path.join(DATA_DIR, 'users', `user-${userId}.json`); } const NAME_INDEX_KEY = 'weedsim:v1:users:name-index'; function sanitizeName(name: string | undefined): string { return (name ?? '').replace(/[^A-Za-z0-9_]/g, '').slice(0, 24); } async function usernameFor(tgUser: TelegramUser): Promise { try { const stored = JSON.parse(await fs.readFile(userNamePath(tgUser.id), 'utf8')) as { username?: string }; if (typeof stored.username === 'string' && stored.username) return stored.username; } catch { // first visit } const index = JSON.parse((await redis.get(NAME_INDEX_KEY)) ?? '{}') as Record; const base = sanitizeName(tgUser.username) || sanitizeName(tgUser.first_name) || `grower_${String(tgUser.id).slice(-6)}`; let candidate = base; for (let suffix = 2; index[candidate.toLowerCase()] !== undefined && index[candidate.toLowerCase()] !== tgUser.id; suffix += 1) { candidate = `${base}_${suffix}`; } index[candidate.toLowerCase()] = tgUser.id; await redis.set(NAME_INDEX_KEY, JSON.stringify(index)); await writeFileAtomic(userNamePath(tgUser.id), JSON.stringify({ username: candidate, tgUserId: tgUser.id, createdAt: new Date().toISOString() })); return candidate; } // --------------------------------------------------------------------------- // Breeder's Pass entitlements, keyed by the stable Telegram user id. // --------------------------------------------------------------------------- function passPath(userId: number): string { return path.join(DATA_DIR, 'passes', `pass-${userId}.json`); } // Comma-separated Telegram user ids that enter without buying (operator/testers). const FREE_PASS_IDS = new Set( (process.env.PASS_FREE_USER_IDS ?? '') .split(',') .map((value) => Number(value.trim())) .filter((value) => Number.isFinite(value) && value > 0) ); export async function hasPass(userId: number): Promise { if (FREE_PASS_IDS.has(userId)) return true; try { await fs.access(passPath(userId)); return true; } catch { return false; } } export async function grantPass(userId: number, details: Record): Promise { await writeFileAtomic(passPath(userId), JSON.stringify({ userId, grantedAt: new Date().toISOString(), ...details }, null, 2)); } // --------------------------------------------------------------------------- // Free tier: lifetime breed counter per Telegram user id. // --------------------------------------------------------------------------- function freeBreedPath(userId: number): string { return path.join(DATA_DIR, 'trials', `breeds-${userId}.json`); } export async function freeBreedsUsed(userId: number): Promise { try { const stored = JSON.parse(await fs.readFile(freeBreedPath(userId), 'utf8')) as { used?: number }; return typeof stored.used === 'number' && stored.used >= 0 ? stored.used : 0; } catch { return 0; } } export async function recordFreeBreed(userId: number): Promise { const used = await freeBreedsUsed(userId); await writeFileAtomic(freeBreedPath(userId), JSON.stringify({ used: used + 1, updatedAt: new Date().toISOString() })); } // --------------------------------------------------------------------------- // Hono middleware: establish identity, then gate game routes on the pass. // --------------------------------------------------------------------------- export const identityMiddleware: MiddlewareHandler = async (c, next) => { if (DEV_MODE) { return authStore.run(ANONYMOUS, next); } const raw = c.req.header('x-telegram-init-data') ?? ''; const tgUser = verifyInitData(raw); if (!tgUser) { return c.json({ ok: false, status: 'error', message: 'Open WEED-SIM inside Telegram to play.' }, 401); } const username = await usernameFor(tgUser); return authStore.run({ identity: { postId: WORLD_ID, username }, tgUser }, next); }; // Free model: entry is free; the pass unlocks trading, cloning, Agent Control, // and breeding beyond the lifetime free-cross limit. Reads are never gated. const PASS_ONLY_POSTS: RegExp[] = [/^\/api\/clone$/, /^\/api\/market\//, /^\/api\/agent-link\/session$/]; export const paywallGate: MiddlewareHandler = async (c, next) => { if (DEV_MODE) return next(); const tgUser = currentTgUser(); if (tgUser && (await hasPass(tgUser.id))) return next(); if (c.req.method.toUpperCase() !== 'POST') return next(); const requestPath = c.req.path; if (PASS_ONLY_POSTS.some((pattern) => pattern.test(requestPath))) { return c.json({ ok: false, status: 'error', message: "Breeder's Pass required: trading, cloning, and Agent Control unlock with the pass." }, 402); } if (requestPath === '/api/breed') { if (!tgUser) return c.json({ ok: false, status: 'error', message: 'Open WEED-SIM inside Telegram to play.' }, 401); const used = await freeBreedsUsed(tgUser.id); if (used >= FREE_BREED_LIMIT) { return c.json({ ok: false, status: 'error', message: `Your ${FREE_BREED_LIMIT} free crosses are used up. The Breeder's Pass unlocks unlimited breeding - your garden is waiting.` }, 402); } await next(); if (c.res.status === 200) await recordFreeBreed(tgUser.id); return; } return next(); }; // --------------------------------------------------------------------------- // Telegram Bot API client + one-time webhook self-configuration at boot. // --------------------------------------------------------------------------- // HF Spaces block direct egress to api.telegram.org; point TELEGRAM_API_BASE at a // relay (see tools/telegram-relay.ts) to route Bot API calls through a reachable host. const TELEGRAM_API_BASE = (process.env.TELEGRAM_API_BASE ?? 'https://api.telegram.org').replace(/\/$/, ''); const TELEGRAM_RELAY_KEY = process.env.TELEGRAM_RELAY_KEY ?? ''; export async function tgApi(method: string, payload: Record): Promise { const response = await fetch(`${TELEGRAM_API_BASE}/bot${BOT_TOKEN}/${method}`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(TELEGRAM_RELAY_KEY ? { 'X-Relay-Key': TELEGRAM_RELAY_KEY } : {}) }, body: JSON.stringify(payload), }); const body = (await response.json()) as { ok: boolean; result?: T; description?: string }; if (!body.ok) throw new Error(`Telegram ${method} failed: ${body.description ?? response.status}`); return body.result as T; } /** Shared secret Telegram echoes back on webhook calls; derived so it needs no extra config. */ export function webhookSecret(): string { return createHash('sha256').update(`weedsim-webhook:${BOT_TOKEN}`).digest('hex').slice(0, 40); } export async function configureTelegramWebhook(): Promise { if (DEV_MODE || !WEBAPP_URL) { console.log('WEED-SIM: webhook setup skipped', DEV_MODE ? '(dev mode, no BOT_TOKEN)' : '(WEBAPP_URL unset)'); return; } // Boot-time networking can be flaky and Telegram is unreachable until this // succeeds, so retry forever; persistent failure in the logs = egress problem. for (let attempt = 1; ; attempt += 1) { try { await tgApi('setWebhook', { url: `${WEBAPP_URL}/telegram/webhook`, secret_token: webhookSecret(), allowed_updates: ['message', 'pre_checkout_query'], }); console.log(`WEED-SIM: Telegram webhook configured for ${WEBAPP_URL} (attempt ${attempt})`); return; } catch (error) { const message = error instanceof Error ? error.message : String(error); if (attempt <= 5 || attempt % 10 === 0) { console.error(`WEED-SIM: webhook setup attempt ${attempt} failed (${message}); retrying`); } await new Promise((resolve) => setTimeout(resolve, attempt <= 5 ? 15_000 : 60_000)); } } }