hansaka01 commited on
Commit
9d58301
Β·
verified Β·
1 Parent(s): 12ea892

Upload 11 files

Browse files
Files changed (1) hide show
  1. bot.js +300 -121
bot.js CHANGED
@@ -1,13 +1,17 @@
1
  /**
2
- * bot.js β€” Word Grid Solver Bot (GramJS / pure MTProto)
3
  *
4
- * Uses the `telegram` npm package (GramJS) with a BOT_TOKEN for pure MTProto.
5
- * No Telegraf, no HTTP Bot API polling β€” raw MTProto layer.
 
 
 
 
6
  *
7
  * Required env vars:
8
- * BOT_TOKEN – Telegram bot token (from @BotFather)
9
- * API_ID – Telegram API ID (from https://my.telegram.org/apps)
10
- * API_HASH – Telegram API hash (from https://my.telegram.org/apps)
11
  *
12
  * Optional:
13
  * PORT – HTTP dashboard port (default 7860)
@@ -17,12 +21,16 @@
17
 
18
  require('dotenv').config();
19
 
20
- const { TelegramClient } = require('telegram');
21
- const { StringSession } = require('telegram/sessions');
22
- const { NewMessage } = require('telegram/events');
 
 
 
23
 
24
  const express = require('express');
25
  const fs = require('fs');
 
26
  const path = require('path');
27
 
28
  const { extractGrid } = require('./ocr');
@@ -39,7 +47,6 @@ if (!BOT_TOKEN) {
39
  }
40
  if (!API_ID || !API_HASH) {
41
  console.error('[FATAL] API_ID and API_HASH are required for GramJS MTProto.');
42
- console.error(' Get them from https://my.telegram.org/apps');
43
  process.exit(1);
44
  }
45
 
@@ -69,7 +76,7 @@ const stats = {
69
  botUsername: 'loading...',
70
  };
71
 
72
- // ─── Utility helpers ──────────────────────────────────────────────────────────
73
  function sleep(ms) {
74
  return new Promise(r => setTimeout(r, ms));
75
  }
@@ -80,61 +87,274 @@ async function deleteFile(filePath) {
80
  if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
81
  return;
82
  } catch (_) {
83
- await sleep(800);
84
  }
85
  }
86
  }
87
 
88
- // ─── Caption parsers ──────────────────────────────────────────────────────────
 
89
  /**
90
- * Parse word patterns from caption text β€” single left-to-right pass.
91
- * Supports all of:
92
- * M--- (4) β†’ pattern + explicit length hint
93
- * M---- β†’ standalone dashes (2+ required)
94
- * W---- H--- (4) β†’ multiple patterns on one line
95
- * Find M--- and P-- β†’ mixed prose + patterns
96
  */
97
- function parsePatterns(text) {
98
- const results = [];
99
- const seen = new Set();
100
-
101
- // Single combined regex: captures "X---" optionally followed by " (N)"
102
- // Minimum 2 dashes (so 3-letter minimum words).
103
- const re = /([A-Z])(-+)(?:\s*\(\d+\))?/g;
104
- let m;
105
- while ((m = re.exec(text)) !== null) {
106
- if (m[2].length < 2) continue; // skip single-dash noise
107
- const pattern = (m[1] + m[2]).toUpperCase();
108
- if (!seen.has(pattern)) {
109
- seen.add(pattern);
110
- results.push({ pattern });
111
- }
112
  }
 
 
 
 
 
 
113
 
114
- return results;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
  }
116
 
117
  /**
118
- * Check whether this caption should trigger grid processing at all.
 
 
119
  *
120
- * Rules (case-insensitive):
121
- * "WORD GRID CHALLENGE" β†’ process as 8Γ—8
122
- * "HARD MODE CHALLENGE" β†’ process as 10Γ—10
123
- * anything else β†’ ignore (return null)
124
  *
125
- * Returns: { gridSize: 8|10 } or null (skip this message)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
126
  */
127
  function getChallengeInfo(text) {
128
  const upper = text.toUpperCase();
129
  if (upper.includes('WORD GRID CHALLENGE')) {
130
- console.log('[Trigger] WORD GRID CHALLENGE detected β†’ 8Γ—8');
131
  return { gridSize: 8 };
132
  }
133
  if (upper.includes('HARD MODE CHALLENGE')) {
134
- console.log('[Trigger] HARD MODE CHALLENGE detected β†’ 10Γ—10');
135
  return { gridSize: 10 };
136
  }
137
- return null; // not a recognised challenge caption β€” ignore
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
  }
139
 
140
  // ─── Result formatter ─────────────────────────────────────────────────────────
@@ -148,63 +368,48 @@ function formatResults(results, grid, patterns) {
148
 
149
  const entry = results[key];
150
  if (!entry) {
151
- msg += `❓ ${key}: not found in grid\n`;
152
  continue;
153
  }
154
 
155
  if (Array.isArray(entry)) {
156
- // Pattern search β€” filter candidates against dictionary
157
- const startChar = key[0].toUpperCase();
158
  const wordMatches = new Set();
159
 
160
  for (const hit of entry) {
161
  const raw = hit.match.toUpperCase();
162
-
163
- if (isWord(raw)) {
164
- wordMatches.add(raw);
165
- continue;
166
- }
167
-
168
- // OCR may have mis-read the first character β€” force the known start char
169
  const forced = startChar + raw.slice(1);
170
- if (isWord(forced)) {
171
- wordMatches.add(forced);
172
- continue;
173
- }
174
-
175
  console.log(`[Debug] Rejected non-word for "${key}": ${raw}`);
176
  }
177
 
178
  if (wordMatches.size > 0) {
179
- const list = [...wordMatches].map(w => `<code>${w}</code>`).join(', ');
180
- msg += `βœ… ${key}: ${list}\n`;
181
  stats.wordsFound += wordMatches.size;
182
  foundAny = true;
183
  } else {
184
  msg += `❓ ${key}: no dictionary words matched\n`;
185
  }
186
  } else {
187
- // Exact word search result
188
  msg += `βœ… <code>${entry.match}</code> @ [${entry.r},${entry.c}] ${entry.dir}\n`;
189
  foundAny = true;
190
  }
191
  }
192
 
193
  if (!foundAny) {
194
- msg += 'πŸ˜” No real word matches found for these patterns.\n';
195
- msg += 'Tip: check that your caption patterns match the grid letters.\n';
196
  }
197
 
198
- // Always show the extracted grid for verification
199
  msg += '\nπŸ” <b>Extracted Grid:</b>\n';
200
  msg += '<pre>' + grid.map(row => row.join(' ')).join('\n') + '</pre>';
201
-
202
  return msg;
203
  }
204
 
205
  // ─── GramJS Bot ───────────────────────────────────────────────────────────────
206
  async function startBot() {
207
- const session = new StringSession(''); // blank = new session each time (fine for bots)
208
 
209
  const client = new TelegramClient(session, API_ID, API_HASH, {
210
  connectionRetries: 10,
@@ -214,12 +419,8 @@ async function startBot() {
214
  });
215
 
216
  console.log('[GramJS] Connecting via MTProto...');
217
-
218
- await client.start({
219
- botAuthToken: BOT_TOKEN,
220
- });
221
-
222
- console.log('[GramJS] Connected successfully.');
223
 
224
  const me = await client.getMe();
225
  stats.botUsername = me.username || 'bot';
@@ -233,33 +434,31 @@ async function startBot() {
233
  const chatId = msg.peerId;
234
  const caption = (msg.message || '').trim();
235
 
236
- // /start command
237
  if (caption === '/start' || caption.startsWith('/start ')) {
238
  await client.sendMessage(chatId, {
239
  message: [
240
  'πŸ‘‹ <b>Word Grid Solver Bot</b>',
241
  '',
242
- 'Send me a word grid image with patterns in the caption.',
243
  '',
244
- '<b>Caption format:</b>',
245
- '<code>M--- (4) P------- (8) S----- (6)</code>',
 
246
  '',
247
- 'πŸ“ Supports <b>8Γ—8 and 10Γ—10</b> grids (auto-detected)',
248
- 'Override with: <code>10x10</code> in caption',
249
  ].join('\n'),
250
  parseMode: 'html',
251
  });
252
  return;
253
  }
254
 
255
- // ── Gate: only act on recognised challenge captions ───────────────────────
256
- // Caption MUST contain "WORD GRID CHALLENGE" (β†’ 8Γ—8)
257
- // or "HARD MODE CHALLENGE" (β†’ 10Γ—10).
258
- // All other messages (including plain photos) are silently ignored.
259
  const challenge = getChallengeInfo(caption);
260
- if (!challenge) return; // not a challenge message β€” do nothing
261
 
262
- // ── Only handle messages that carry a photo ────────────────────────────────
263
  const hasPhoto = msg.media && (
264
  msg.media.className === 'MessageMediaPhoto' ||
265
  (msg.media.document &&
@@ -268,16 +467,13 @@ async function startBot() {
268
  );
269
 
270
  if (!hasPhoto) {
271
- // It's a recognised challenge caption but has no image β€” let them know
272
  await client.sendMessage(chatId, {
273
- message: '⚠️ Please attach a grid image together with your challenge caption.',
274
  });
275
  return;
276
  }
277
 
278
- // ── Download image to disk via MTProto ─────────────────────────────────────
279
- // Pass a string path as outputFile β€” GramJS routes this to createWriteStream
280
- // (NOT Buffer constructor, which would cause "writer.write is not a function")
281
  const imagePath = path.join(
282
  __dirname,
283
  `grid_${Date.now()}_${Math.random().toString(36).slice(2)}.jpg`
@@ -285,37 +481,30 @@ async function startBot() {
285
 
286
  try {
287
  await client.sendMessage(chatId, {
288
- message: `πŸ” Processing your ${challenge.gridSize}Γ—${challenge.gridSize} grid...`,
289
- });
290
-
291
- const result = await client.downloadMedia(msg, {
292
- outputFile: imagePath,
293
  });
294
 
295
- if (!result) throw new Error('downloadMedia returned null/undefined');
296
- if (!fs.existsSync(imagePath)) throw new Error('File not written to disk');
297
-
298
- console.log(`[Download] Saved ${imagePath} (${fs.statSync(imagePath).size} bytes)`);
299
 
300
  } catch (dlErr) {
301
  console.error('[Download] Failed:', dlErr.message);
 
302
  await client.sendMessage(chatId, {
303
- message: '❌ Failed to download the image. Please try again.',
304
  });
305
  await deleteFile(imagePath);
306
  return;
307
  }
308
 
309
- // ── Process the image ──────────────────────────────────────────────────────
310
  try {
311
  stats.imagesProcessed++;
312
 
313
- // Grid size is already determined by the challenge keyword β€” no guessing
314
  const grid = await extractGrid(imagePath, challenge.gridSize);
315
 
316
  if (!grid || grid.length === 0) {
317
  await client.sendMessage(chatId, {
318
- message: '❌ Could not extract a grid from the image.\nMake sure the grid letters are clearly visible.',
319
  });
320
  return;
321
  }
@@ -324,7 +513,8 @@ async function startBot() {
324
 
325
  if (patterns.length === 0) {
326
  const noPatMsg =
327
- 'πŸ“‹ <b>Grid extracted</b> (no patterns in caption):\n\n' +
 
328
  '<pre>' + grid.map(r => r.join(' ')).join('\n') + '</pre>\n\n' +
329
  'Add patterns like <code>M--- (4)</code> to find words!';
330
  await client.sendMessage(chatId, { message: noPatMsg, parseMode: 'html' });
@@ -333,14 +523,11 @@ async function startBot() {
333
 
334
  const results = solve(grid, patterns);
335
  const reply = formatResults(results, grid, patterns);
336
-
337
  await client.sendMessage(chatId, { message: reply, parseMode: 'html' });
338
 
339
  } catch (err) {
340
- console.error('[Handler] Processing error:', err);
341
- await client.sendMessage(chatId, {
342
- message: '🚨 An error occurred while processing. Please try again.',
343
- });
344
  } finally {
345
  await deleteFile(imagePath);
346
  }
@@ -349,9 +536,8 @@ async function startBot() {
349
 
350
  console.log('[Bot] Listening for messages...');
351
 
352
- // Graceful shutdown
353
  const shutdown = async (sig) => {
354
- console.log(`[Bot] ${sig} received β€” disconnecting...`);
355
  try { await client.disconnect(); } catch (_) {}
356
  process.exit(0);
357
  };
@@ -364,20 +550,13 @@ const app = express();
364
  const PORT = parseInt(process.env.PORT || '7860', 10);
365
 
366
  app.use(express.static(path.join(__dirname, 'public')));
367
-
368
  app.get('/api/stats', (_req, res) => {
369
- res.json({
370
- ...stats,
371
- uptime: Math.floor((Date.now() - stats.startTime) / 1000),
372
- });
373
- });
374
-
375
- app.listen(PORT, () => {
376
- console.log(`[Dashboard] Running on port ${PORT}`);
377
  });
 
378
 
379
  // ─── Boot ─────────────────────────────────────────────────────────────────────
380
  startBot().catch(err => {
381
- console.error('[FATAL] Bot startup failed:', err);
382
  process.exit(1);
383
  });
 
1
  /**
2
+ * bot.js β€” Word Grid Solver Bot (GramJS MTProto + Bot API file download)
3
  *
4
+ * Architecture:
5
+ * β€’ GramJS (MTProto) handles ALL message events β€” no HTTP polling
6
+ * β€’ Image download: GramJS downloadFileV2 via InputPhotoFileLocation
7
+ * (passing the full Message to downloadMedia silently returns 0 bytes
8
+ * for bots because stripped sizes; we build the location manually)
9
+ * β€’ Final fallback: Bot API getFile β†’ HTTPS stream (always works)
10
  *
11
  * Required env vars:
12
+ * BOT_TOKEN – Telegram bot token (from @BotFather)
13
+ * API_ID – Telegram API ID (from https://my.telegram.org/apps)
14
+ * API_HASH – Telegram API hash (from https://my.telegram.org/apps)
15
  *
16
  * Optional:
17
  * PORT – HTTP dashboard port (default 7860)
 
21
 
22
  require('dotenv').config();
23
 
24
+ const { TelegramClient } = require('telegram');
25
+ const { StringSession } = require('telegram/sessions');
26
+ const { NewMessage } = require('telegram/events');
27
+ const { Api } = require('telegram');
28
+ const { downloadFileV2 } = require('telegram/client/downloads');
29
+ const bigInt = require('big-integer');
30
 
31
  const express = require('express');
32
  const fs = require('fs');
33
+ const https = require('https');
34
  const path = require('path');
35
 
36
  const { extractGrid } = require('./ocr');
 
47
  }
48
  if (!API_ID || !API_HASH) {
49
  console.error('[FATAL] API_ID and API_HASH are required for GramJS MTProto.');
 
50
  process.exit(1);
51
  }
52
 
 
76
  botUsername: 'loading...',
77
  };
78
 
79
+ // ─── Utilities ────────────────────────────────────────────────────────────────
80
  function sleep(ms) {
81
  return new Promise(r => setTimeout(r, ms));
82
  }
 
87
  if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
88
  return;
89
  } catch (_) {
90
+ await sleep(500);
91
  }
92
  }
93
  }
94
 
95
+ // ─── Image download ───────────────────────────────────────────────────────────
96
+
97
  /**
98
+ * Pick the largest non-stripped, non-empty PhotoSize from photo.sizes.
99
+ * Returns the size object or null.
 
 
 
 
100
  */
101
+ function pickBestPhotoSize(sizes) {
102
+ if (!sizes || sizes.length === 0) return null;
103
+ // Prefer types in descending quality order
104
+ const preferOrder = ['w', 'y', 'd', 'x', 'c', 'm', 'b', 'a', 's'];
105
+ for (const t of preferOrder) {
106
+ const s = sizes.find(sz =>
107
+ sz.type === t &&
108
+ !(sz instanceof Api.PhotoStrippedSize) &&
109
+ !(sz instanceof Api.PhotoSizeEmpty) &&
110
+ !(sz instanceof Api.PhotoSizeProgressive)
111
+ );
112
+ if (s) return s;
 
 
 
113
  }
114
+ // Fallback: any non-stripped, non-empty
115
+ return sizes.find(sz =>
116
+ !(sz instanceof Api.PhotoStrippedSize) &&
117
+ !(sz instanceof Api.PhotoSizeEmpty)
118
+ ) || null;
119
+ }
120
 
121
+ /**
122
+ * Attempt 1 β€” GramJS downloadFileV2 via InputPhotoFileLocation.
123
+ * Builds the TL location manually to avoid the stripped-size bug in downloadMedia.
124
+ */
125
+ async function downloadViaMTProto(client, photo, destPath) {
126
+ const size = pickBestPhotoSize(photo.sizes);
127
+ if (!size) throw new Error('No usable photo size in TL photo object');
128
+
129
+ console.log(`[DL-1] MTProto InputPhotoFileLocation type=${size.type} dcId=${photo.dcId}`);
130
+
131
+ const fileLocation = new Api.InputPhotoFileLocation({
132
+ id: photo.id,
133
+ accessHash: photo.accessHash,
134
+ fileReference: photo.fileReference,
135
+ thumbSize: size.type,
136
+ });
137
+
138
+ const fileSize = 'size' in size
139
+ ? bigInt(size.size)
140
+ : bigInt(512 * 1024); // safe fallback estimate
141
+
142
+ await downloadFileV2(client, fileLocation, {
143
+ outputFile: destPath,
144
+ fileSize,
145
+ dcId: photo.dcId,
146
+ });
147
+
148
+ if (!fs.existsSync(destPath)) throw new Error('File not written');
149
+ const bytes = fs.statSync(destPath).size;
150
+ if (bytes === 0) throw new Error('Downloaded file is 0 bytes');
151
+ console.log(`[DL-1] MTProto success β€” ${bytes} bytes`);
152
  }
153
 
154
  /**
155
+ * Attempt 2 β€” Bot API getFile β†’ HTTPS stream.
156
+ * Uses Bot API to resolve a file_id to a download URL, then streams it.
157
+ * This always works for bots regardless of DC or file reference freshness.
158
  *
159
+ * To get the Bot API file_id we call Bot API getUpdates is NOT available
160
+ * during long-polling conflicts β€” instead we use a trick:
161
+ * forward the message to ourselves to get a fresh file_id from Bot API.
 
162
  *
163
+ * Simpler approach: call Bot API sendDocument/getFile with the message's
164
+ * photo directly. Since we're a bot, we can use the message_id + chat_id
165
+ * to call Bot API copyMessage and get file_id β€” but that's wasteful.
166
+ *
167
+ * THE REAL TRICK: GramJS photo.id is NOT the Bot API file_id.
168
+ * But we can encode a Bot API file_id from the TL photo using the
169
+ * standard Telegram encoding scheme (type 2 = photo).
170
+ * Format: pack(type, dc_id, id, access_hash, file_reference) β†’ base64url
171
+ *
172
+ * This is what python-telegram-bot, aiogram etc. all do internally.
173
+ */
174
+
175
+ /**
176
+ * Encode a Bot API file_id from a TL Photo object.
177
+ * Telegram Bot API file_id encoding for photos (type_id = 2):
178
+ * byte 0: file_type (2 = photo)
179
+ * byte 1: dc_id
180
+ * bytes 2-9: id (int64 LE)
181
+ * bytes 10-17: access_hash (int64 LE)
182
+ * byte 18: len(file_reference)
183
+ * bytes 19+: file_reference bytes
184
+ * byte 19+N: thumbnail type byte (e.g. 'y'.charCodeAt(0))
185
+ * Then base64url encode the whole thing.
186
+ */
187
+ function encodePhotoFileId(photo, thumbType) {
188
+ const fileRef = Buffer.isBuffer(photo.fileReference)
189
+ ? photo.fileReference
190
+ : Buffer.from(photo.fileReference);
191
+
192
+ // Pack id and access_hash as signed int64 LE (BigInt β†’ Buffer)
193
+ function bigIntToLE8(bi) {
194
+ // Handle both native BigInt and big-integer library
195
+ const hex = (typeof bi === 'bigint' ? bi : BigInt(bi.toString()))
196
+ .toString(16)
197
+ .replace('-', '');
198
+ const padded = hex.padStart(16, '0');
199
+ const buf = Buffer.from(padded, 'hex');
200
+ buf.reverse();
201
+ return buf;
202
+ }
203
+
204
+ const typeFlag = Buffer.from([2, photo.dcId]); // type=photo, dcId
205
+ const idBuf = bigIntToLE8(photo.id);
206
+ const hashBuf = bigIntToLE8(photo.accessHash);
207
+ const refLen = Buffer.from([fileRef.length]);
208
+ const thumbBuf = Buffer.from([thumbType.charCodeAt(0)]);
209
+
210
+ const combined = Buffer.concat([typeFlag, idBuf, hashBuf, refLen, fileRef, thumbBuf]);
211
+ return combined.toString('base64')
212
+ .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
213
+ }
214
+
215
+ /**
216
+ * Download via Bot API: encode file_id β†’ getFile β†’ HTTPS stream.
217
+ */
218
+ async function downloadViaBotApi(photo, thumbType, destPath) {
219
+ console.log(`[DL-2] Bot API getFile...`);
220
+
221
+ let fileId;
222
+ try {
223
+ fileId = encodePhotoFileId(photo, thumbType);
224
+ } catch (e) {
225
+ throw new Error(`file_id encoding failed: ${e.message}`);
226
+ }
227
+
228
+ // Call Bot API getFile
229
+ const fileInfo = await new Promise((resolve, reject) => {
230
+ const body = JSON.stringify({ file_id: fileId });
231
+ const req = https.request({
232
+ hostname: 'api.telegram.org',
233
+ path: `/bot${BOT_TOKEN}/getFile`,
234
+ method: 'POST',
235
+ headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) },
236
+ }, (res) => {
237
+ let d = '';
238
+ res.on('data', c => d += c);
239
+ res.on('end', () => {
240
+ try {
241
+ const p = JSON.parse(d);
242
+ if (p.ok) resolve(p.result);
243
+ else reject(new Error(`getFile API error: ${p.description}`));
244
+ } catch (e) { reject(e); }
245
+ });
246
+ });
247
+ req.on('error', reject);
248
+ req.setTimeout(10000, () => req.destroy(new Error('getFile timeout')));
249
+ req.write(body);
250
+ req.end();
251
+ });
252
+
253
+ if (!fileInfo.file_path) throw new Error('getFile returned no file_path');
254
+
255
+ const downloadUrl = `https://api.telegram.org/file/bot${BOT_TOKEN}/${fileInfo.file_path}`;
256
+ console.log(`[DL-2] Streaming from Bot API URL...`);
257
+
258
+ // Stream to disk
259
+ await new Promise((resolve, reject) => {
260
+ const fileStream = fs.createWriteStream(destPath);
261
+ https.get(downloadUrl, (res) => {
262
+ if (res.statusCode !== 200) {
263
+ reject(new Error(`HTTP ${res.statusCode} downloading file`));
264
+ return;
265
+ }
266
+ res.pipe(fileStream);
267
+ fileStream.on('finish', () => fileStream.close(resolve));
268
+ fileStream.on('error', reject);
269
+ }).on('error', reject)
270
+ .setTimeout(30000, function() { this.destroy(new Error('Download timeout')); });
271
+ });
272
+
273
+ const bytes = fs.existsSync(destPath) ? fs.statSync(destPath).size : 0;
274
+ if (bytes === 0) throw new Error('Bot API download produced 0 bytes');
275
+ console.log(`[DL-2] Bot API success β€” ${bytes} bytes`);
276
+ }
277
+
278
+ /**
279
+ * Master download function β€” tries MTProto first, Bot API second.
280
+ */
281
+ async function downloadImage(client, msg, destPath) {
282
+ // Extract the TL Photo object
283
+ const media = msg.media;
284
+ if (!media) throw new Error('Message has no media');
285
+
286
+ let photo = null;
287
+ if (media instanceof Api.MessageMediaPhoto) {
288
+ photo = media.photo;
289
+ } else if (media instanceof Api.Photo) {
290
+ photo = media;
291
+ }
292
+
293
+ if (!photo || photo instanceof Api.PhotoEmpty) {
294
+ throw new Error('Message media contains no valid photo');
295
+ }
296
+
297
+ const bestSize = pickBestPhotoSize(photo.sizes);
298
+ if (!bestSize) throw new Error('Photo has no usable sizes');
299
+ const thumbType = bestSize.type || 'y';
300
+
301
+ // Attempt 1: MTProto
302
+ try {
303
+ await downloadViaMTProto(client, photo, destPath);
304
+ return;
305
+ } catch (e1) {
306
+ console.warn(`[DL-1] MTProto failed: ${e1.message} β€” trying Bot API...`);
307
+ try { fs.unlinkSync(destPath); } catch (_) {}
308
+ }
309
+
310
+ // Attempt 2: Bot API
311
+ try {
312
+ await downloadViaBotApi(photo, thumbType, destPath);
313
+ return;
314
+ } catch (e2) {
315
+ console.error(`[DL-2] Bot API failed: ${e2.message}`);
316
+ throw new Error(`All download methods failed. MTProto: ${e2.message}`);
317
+ }
318
+ }
319
+
320
+ // ─── Caption helpers ──────────────────────────────────────────────────────────
321
+ /**
322
+ * Returns { gridSize: 8|10 } if caption contains a recognised challenge phrase,
323
+ * or null if the message should be ignored.
324
+ * "WORD GRID CHALLENGE" β†’ 8Γ—8
325
+ * "HARD MODE CHALLENGE" β†’ 10Γ—10
326
  */
327
  function getChallengeInfo(text) {
328
  const upper = text.toUpperCase();
329
  if (upper.includes('WORD GRID CHALLENGE')) {
330
+ console.log('[Trigger] WORD GRID CHALLENGE β†’ 8Γ—8');
331
  return { gridSize: 8 };
332
  }
333
  if (upper.includes('HARD MODE CHALLENGE')) {
334
+ console.log('[Trigger] HARD MODE CHALLENGE β†’ 10Γ—10');
335
  return { gridSize: 10 };
336
  }
337
+ return null;
338
+ }
339
+
340
+ /**
341
+ * Parse word patterns from caption β€” single left-to-right pass.
342
+ * Handles: "M--- (4)", "M----", "W---- H--- (4)", mixed prose.
343
+ */
344
+ function parsePatterns(text) {
345
+ const results = [];
346
+ const seen = new Set();
347
+ const re = /([A-Z])(-+)(?:\s*\(\d+\))?/g;
348
+ let m;
349
+ while ((m = re.exec(text)) !== null) {
350
+ if (m[2].length < 2) continue;
351
+ const pattern = (m[1] + m[2]).toUpperCase();
352
+ if (!seen.has(pattern)) {
353
+ seen.add(pattern);
354
+ results.push({ pattern });
355
+ }
356
+ }
357
+ return results;
358
  }
359
 
360
  // ─── Result formatter ─────────────────────────────────────────────────────────
 
368
 
369
  const entry = results[key];
370
  if (!entry) {
371
+ msg += `❓ ${key}: not found\n`;
372
  continue;
373
  }
374
 
375
  if (Array.isArray(entry)) {
376
+ const startChar = key[0].toUpperCase();
 
377
  const wordMatches = new Set();
378
 
379
  for (const hit of entry) {
380
  const raw = hit.match.toUpperCase();
381
+ if (isWord(raw)) { wordMatches.add(raw); continue; }
 
 
 
 
 
 
382
  const forced = startChar + raw.slice(1);
383
+ if (isWord(forced)) { wordMatches.add(forced); continue; }
 
 
 
 
384
  console.log(`[Debug] Rejected non-word for "${key}": ${raw}`);
385
  }
386
 
387
  if (wordMatches.size > 0) {
388
+ msg += `βœ… ${key}: ${[...wordMatches].map(w => `<code>${w}</code>`).join(', ')}\n`;
 
389
  stats.wordsFound += wordMatches.size;
390
  foundAny = true;
391
  } else {
392
  msg += `❓ ${key}: no dictionary words matched\n`;
393
  }
394
  } else {
 
395
  msg += `βœ… <code>${entry.match}</code> @ [${entry.r},${entry.c}] ${entry.dir}\n`;
396
  foundAny = true;
397
  }
398
  }
399
 
400
  if (!foundAny) {
401
+ msg += 'πŸ˜” No real word matches found.\n';
402
+ msg += 'Tip: verify caption patterns match grid letters.\n';
403
  }
404
 
 
405
  msg += '\nπŸ” <b>Extracted Grid:</b>\n';
406
  msg += '<pre>' + grid.map(row => row.join(' ')).join('\n') + '</pre>';
 
407
  return msg;
408
  }
409
 
410
  // ─── GramJS Bot ───────────────────────────────────────────────────────────────
411
  async function startBot() {
412
+ const session = new StringSession('');
413
 
414
  const client = new TelegramClient(session, API_ID, API_HASH, {
415
  connectionRetries: 10,
 
419
  });
420
 
421
  console.log('[GramJS] Connecting via MTProto...');
422
+ await client.start({ botAuthToken: BOT_TOKEN });
423
+ console.log('[GramJS] Connected.');
 
 
 
 
424
 
425
  const me = await client.getMe();
426
  stats.botUsername = me.username || 'bot';
 
434
  const chatId = msg.peerId;
435
  const caption = (msg.message || '').trim();
436
 
437
+ // /start
438
  if (caption === '/start' || caption.startsWith('/start ')) {
439
  await client.sendMessage(chatId, {
440
  message: [
441
  'πŸ‘‹ <b>Word Grid Solver Bot</b>',
442
  '',
443
+ 'Send a word grid image with the challenge caption and word patterns.',
444
  '',
445
+ '<b>Triggers:</b>',
446
+ 'β€’ <code>WORD GRID CHALLENGE</code> β†’ solves 8Γ—8 grid',
447
+ 'β€’ <code>HARD MODE CHALLENGE</code> β†’ solves 10Γ—10 grid',
448
  '',
449
+ '<b>Add word patterns in the caption:</b>',
450
+ '<code>WORD GRID CHALLENGE\nM--- (4) P------- (8) S----- (6)</code>',
451
  ].join('\n'),
452
  parseMode: 'html',
453
  });
454
  return;
455
  }
456
 
457
+ // ── Gate: only recognised challenge captions ───────────────────────────────
 
 
 
458
  const challenge = getChallengeInfo(caption);
459
+ if (!challenge) return; // silently ignore
460
 
461
+ // ── Must carry a photo ─────────────────────────────────────────────────────
462
  const hasPhoto = msg.media && (
463
  msg.media.className === 'MessageMediaPhoto' ||
464
  (msg.media.document &&
 
467
  );
468
 
469
  if (!hasPhoto) {
 
470
  await client.sendMessage(chatId, {
471
+ message: '⚠️ Please attach a grid image along with the challenge caption.',
472
  });
473
  return;
474
  }
475
 
476
+ // ── Download ───────────────────────────────────────────────────────────────
 
 
477
  const imagePath = path.join(
478
  __dirname,
479
  `grid_${Date.now()}_${Math.random().toString(36).slice(2)}.jpg`
 
481
 
482
  try {
483
  await client.sendMessage(chatId, {
484
+ message: `πŸ” Processing your ${challenge.gridSize}Γ—${challenge.gridSize} word grid...`,
 
 
 
 
485
  });
486
 
487
+ await downloadImage(client, msg, imagePath);
 
 
 
488
 
489
  } catch (dlErr) {
490
  console.error('[Download] Failed:', dlErr.message);
491
+ console.error(dlErr.stack);
492
  await client.sendMessage(chatId, {
493
+ message: `❌ Could not download image: ${dlErr.message}`,
494
  });
495
  await deleteFile(imagePath);
496
  return;
497
  }
498
 
499
+ // ── OCR + Solve ────────────────────────────────────────────────────────────
500
  try {
501
  stats.imagesProcessed++;
502
 
 
503
  const grid = await extractGrid(imagePath, challenge.gridSize);
504
 
505
  if (!grid || grid.length === 0) {
506
  await client.sendMessage(chatId, {
507
+ message: '❌ Could not read the grid from the image. Make sure letters are clearly visible.',
508
  });
509
  return;
510
  }
 
513
 
514
  if (patterns.length === 0) {
515
  const noPatMsg =
516
+ `πŸ“‹ <b>${challenge.gridSize}Γ—${challenge.gridSize} grid extracted</b> ` +
517
+ `(no word patterns in caption):\n\n` +
518
  '<pre>' + grid.map(r => r.join(' ')).join('\n') + '</pre>\n\n' +
519
  'Add patterns like <code>M--- (4)</code> to find words!';
520
  await client.sendMessage(chatId, { message: noPatMsg, parseMode: 'html' });
 
523
 
524
  const results = solve(grid, patterns);
525
  const reply = formatResults(results, grid, patterns);
 
526
  await client.sendMessage(chatId, { message: reply, parseMode: 'html' });
527
 
528
  } catch (err) {
529
+ console.error('[Handler] Error:', err);
530
+ await client.sendMessage(chatId, { message: '🚨 Processing error. Please try again.' });
 
 
531
  } finally {
532
  await deleteFile(imagePath);
533
  }
 
536
 
537
  console.log('[Bot] Listening for messages...');
538
 
 
539
  const shutdown = async (sig) => {
540
+ console.log(`[Bot] ${sig} β€” disconnecting...`);
541
  try { await client.disconnect(); } catch (_) {}
542
  process.exit(0);
543
  };
 
550
  const PORT = parseInt(process.env.PORT || '7860', 10);
551
 
552
  app.use(express.static(path.join(__dirname, 'public')));
 
553
  app.get('/api/stats', (_req, res) => {
554
+ res.json({ ...stats, uptime: Math.floor((Date.now() - stats.startTime) / 1000) });
 
 
 
 
 
 
 
555
  });
556
+ app.listen(PORT, () => console.log(`[Dashboard] Running on port ${PORT}`));
557
 
558
  // ─── Boot ─────────────────────────────────────────────────────────────────────
559
  startBot().catch(err => {
560
+ console.error('[FATAL]', err);
561
  process.exit(1);
562
  });