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));
    }
  }
}