File size: 13,638 Bytes
3f61b9c bd2a0cb 3f61b9c 6e4f01b 3f61b9c 6e4f01b 3f61b9c bd2a0cb 3f61b9c bd2a0cb 3f61b9c bd2a0cb 3f61b9c bd2a0cb 3f61b9c f83d6a5 3f61b9c f83d6a5 3f61b9c f83d6a5 3f61b9c 7bcd006 3f61b9c | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 | 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));
}
}
}
|