Yufok1
Claude Fable 5
Free-to-play model: 25 lifetime free crosses, pass gates features not entry
bd2a0cb Download src/server/platform.ts from tostido/weed-sim: direct link, hf CLI and curl.
- Browser
- Download file 13.6 kB
-
https://huggingface.co/spaces/tostido/weed-sim/resolve/main/src/server/platform.ts
- Command line
-
hf download hf://spaces/tostido/weed-sim/src/server/platform.ts
-
curl -L -o platform.ts https://huggingface.co/spaces/tostido/weed-sim/resolve/main/src/server/platform.ts
13.6 kB
| 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<RequestAuth>(); | |
| 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<RuntimeIdentity> { | |
| 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<string, Promise<void>>(); | |
| function enqueue(key: string, work: () => Promise<void>): Promise<void> { | |
| 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<void> { | |
| 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<string | undefined> { | |
| try { | |
| return await fs.readFile(kvPath(key), 'utf8'); | |
| } catch { | |
| return undefined; | |
| } | |
| }, | |
| async set(key: string, value: string): Promise<void> { | |
| await enqueue(key, () => writeFileAtomic(kvPath(key), value)); | |
| }, | |
| async del(key: string): Promise<void> { | |
| 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<void> {}, | |
| }; | |
| // --------------------------------------------------------------------------- | |
| // 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<string> { | |
| 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<string, number>; | |
| 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<boolean> { | |
| 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<string, unknown>): Promise<void> { | |
| 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<number> { | |
| 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<void> { | |
| 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<T = unknown>(method: string, payload: Record<string, unknown>): Promise<T> { | |
| 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<void> { | |
| 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)); | |
| } | |
| } | |
| } | |