| 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'; |
|
|
| |
| |
| |
|
|
| 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); |
|
|
| |
| |
| 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 }; |
|
|
| |
| export async function identity(): Promise<RuntimeIdentity> { |
| return (authStore.getStore() ?? ANONYMOUS).identity; |
| } |
|
|
| export function currentTgUser(): TelegramUser | null { |
| return (authStore.getStore() ?? ANONYMOUS).tgUser; |
| } |
|
|
| |
| |
| |
| |
|
|
| 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 }); |
| }); |
| }, |
| }; |
|
|
| |
| |
| |
| |
|
|
| export const realtime = { |
| async send(_channel: string, _message: unknown): Promise<void> {}, |
| }; |
|
|
| |
| |
| |
|
|
| 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; |
| } |
| } |
|
|
| |
| |
| |
| |
|
|
| 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 { |
| |
| } |
| 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; |
| } |
|
|
| |
| |
| |
|
|
| function passPath(userId: number): string { |
| return path.join(DATA_DIR, 'passes', `pass-${userId}.json`); |
| } |
|
|
| |
| 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)); |
| } |
|
|
| |
| |
| |
|
|
| 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() })); |
| } |
|
|
| |
| |
| |
|
|
| 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); |
| }; |
|
|
| |
| |
| 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(); |
| }; |
|
|
| |
| |
| |
|
|
| |
| |
| 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; |
| } |
|
|
| |
| 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; |
| } |
| |
| |
| 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)); |
| } |
| } |
| } |
|
|