weed-sim / src /server /platform.ts
Yufok1
Free-to-play model: 25 lifetime free crosses, pass gates features not entry
bd2a0cb
Raw
History Blame Contribute Delete
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));
}
}
}