/** * bot.js — Word Grid Solver Bot (pure GramJS MTProto, zero Bot API HTTP calls) * * Download strategy (MTProto only, two attempts): * 1. Re-fetch message via client.getMessages() → fresh fileReference * → downloadFileV2 with explicit InputPhotoFileLocation + correct dcId * 2. client.downloadMedia() on the re-fetched message (GramJS full flow) * * Both private chats and groups are handled identically — GramJS resolves * the entity and handles DC auth export automatically. * * Required env vars: * BOT_TOKEN – Telegram bot token (from @BotFather) * API_ID – Telegram API ID (from https://my.telegram.org/apps) * API_HASH – Telegram API hash (from https://my.telegram.org/apps) * * Optional: * PORT – HTTP dashboard port (default 7860) */ 'use strict'; require('dotenv').config(); const { TelegramClient } = require('telegram'); const { StringSession } = require('telegram/sessions'); const { NewMessage } = require('telegram/events'); const { Api } = require('telegram'); const bigInt = require('big-integer'); const express = require('express'); const fs = require('fs'); const path = require('path'); const { extractGrid } = require('./ocr'); const { solve } = require('./solver'); // ─── downloadFileV2 from GramJS internals ───────────────────────────────────── const { downloadFileV2 } = require('./node_modules/telegram/client/downloads'); // ─── Environment ────────────────────────────────────────────────────────────── const BOT_TOKEN = process.env.BOT_TOKEN; const API_ID = parseInt(process.env.API_ID || '0', 10); const API_HASH = process.env.API_HASH || ''; if (!BOT_TOKEN) { console.error('[FATAL] BOT_TOKEN is required.'); process.exit(1); } if (!API_ID || !API_HASH) { console.error('[FATAL] API_ID and API_HASH are required (https://my.telegram.org/apps)'); process.exit(1); } // ─── Dictionary ─────────────────────────────────────────────────────────────── const dictionaryPath = path.join(__dirname, 'node_modules/check-word/words/en.txt'); const dictionary = new Set(); try { const data = fs.readFileSync(dictionaryPath, 'utf8'); for (const line of data.split('\n')) { const w = line.trim().toLowerCase(); if (w.length >= 3) dictionary.add(w); } console.log(`[Dict] Loaded ${dictionary.size} words.`); } catch (err) { console.error('[Dict] Failed to load dictionary:', err.message); } function isWord(w) { return dictionary.has((w || '').toLowerCase()); } // ─── Stats ──────────────────────────────────────────────────────────────────── const stats = { imagesProcessed: 0, wordsFound: 0, startTime: Date.now(), botUsername: 'loading...', }; // ─── Utilities ──────────────────────────────────────────────────────────────── const sleep = ms => new Promise(r => setTimeout(r, ms)); async function deleteFile(p) { for (let i = 0; i < 5; i++) { try { if (fs.existsSync(p)) fs.unlinkSync(p); return; } catch (_) { await sleep(500); } } } // ─── Photo size picker ──────────────────────────────────────────────────────── /** * Return the best (largest, non-stripped, non-progressive, non-empty) photo size. * Telegram quality tiers: w > y > d > x > c > m > b > a > s */ function pickBestSize(sizes) { if (!sizes || sizes.length === 0) return null; for (const t of ['w', 'y', 'd', 'x', 'c', 'm', 'b', 'a', 's']) { const s = sizes.find(sz => sz.type === t && !(sz instanceof Api.PhotoStrippedSize) && !(sz instanceof Api.PhotoSizeEmpty) && !(sz instanceof Api.PhotoSizeProgressive) ); if (s) return s; } return sizes.find(sz => !(sz instanceof Api.PhotoStrippedSize) && !(sz instanceof Api.PhotoSizeEmpty) ) || null; } // ─── MTProto image download ─────────────────────────────────────────────────── /** * Get the entity for a peer — works for PeerUser, PeerChat, PeerChannel. * GramJS handles all three cases when you pass the peerId directly. */ async function getEntitySafe(client, peerId) { try { return await client.getEntity(peerId); } catch (e) { // Last resort: use the peer object directly (works for most cases) console.warn(`[DL] getEntity failed (${e.message}), using peerId directly`); return peerId; } } /** * Re-fetch the message to get a fresh fileReference, then download via * GramJS downloadFileV2 with an explicit InputPhotoFileLocation. * * This avoids two bugs: * a) Stale fileReference in the event message → AUTH_BYTES_INVALID * b) downloadMedia picking PhotoStrippedSize → 0-byte file */ async function downloadViaMTProto(client, originalMsg, destPath) { console.log('[DL-1] Re-fetching message for fresh fileReference...'); const entity = await getEntitySafe(client, originalMsg.peerId); // Re-fetch to get fresh fileReference const msgs = await client.getMessages(entity, { ids: [originalMsg.id] }); const freshMsg = msgs && msgs[0]; if (!freshMsg || !freshMsg.media) { throw new Error('Re-fetched message has no media'); } const photo = freshMsg.media.photo; if (!photo || photo instanceof Api.PhotoEmpty) { throw new Error('Re-fetched message has no valid photo'); } const size = pickBestSize(photo.sizes); if (!size) throw new Error('Photo has no usable size'); console.log(`[DL-1] Downloading: type=${size.type} dcId=${photo.dcId}`); const location = new Api.InputPhotoFileLocation({ id: photo.id, accessHash: photo.accessHash, fileReference: photo.fileReference, thumbSize: size.type, }); const fileSizeBi = 'size' in size ? bigInt(size.size) : bigInt(512 * 1024); await downloadFileV2(client, location, { outputFile: destPath, fileSize: fileSizeBi, dcId: photo.dcId, }); const bytes = fs.existsSync(destPath) ? fs.statSync(destPath).size : 0; if (bytes === 0) throw new Error('downloadFileV2 produced 0 bytes'); console.log(`[DL-1] Success: ${bytes} bytes`); } /** * Fallback: use client.downloadMedia() on the re-fetched message. * GramJS handles DC export auth internally. */ async function downloadViaDownloadMedia(client, originalMsg, destPath) { console.log('[DL-2] Trying client.downloadMedia() on re-fetched message...'); const entity = await getEntitySafe(client, originalMsg.peerId); const msgs = await client.getMessages(entity, { ids: [originalMsg.id] }); const freshMsg = msgs && msgs[0]; if (!freshMsg || !freshMsg.media) { throw new Error('Re-fetched message has no media'); } const result = await client.downloadMedia(freshMsg, { outputFile: destPath }); const bytes = fs.existsSync(destPath) ? fs.statSync(destPath).size : 0; if (bytes === 0) throw new Error('downloadMedia produced 0 bytes'); console.log(`[DL-2] Success: ${bytes} bytes`); } /** * Master download: try downloadFileV2 first, fall back to downloadMedia. * Both are pure MTProto — no HTTP, no Bot API. */ async function downloadImage(client, msg, destPath) { if (!msg.media) throw new Error('Message has no media'); // Attempt 1: downloadFileV2 with explicit location (avoids stripped-size bug) try { await downloadViaMTProto(client, msg, destPath); return; } catch (e1) { console.warn(`[DL-1] Failed: ${e1.message}`); try { fs.unlinkSync(destPath); } catch (_) {} } // Attempt 2: GramJS downloadMedia on re-fetched message try { await downloadViaDownloadMedia(client, msg, destPath); return; } catch (e2) { console.error(`[DL-2] Failed: ${e2.message}`); try { fs.unlinkSync(destPath); } catch (_) {} throw new Error(`All MTProto download attempts failed. Last: ${e2.message}`); } } // ─── Caption helpers ────────────────────────────────────────────────────────── /** * Returns { gridSize: 8|10 } if caption contains a recognised trigger phrase, * otherwise null (message is silently ignored). * "WORD GRID CHALLENGE" → 8×8 * "HARD MODE CHALLENGE" → 10×10 */ function getChallengeInfo(text) { const u = text.toUpperCase(); if (u.includes('WORD GRID CHALLENGE')) { console.log('[Trigger] WORD GRID CHALLENGE → 8×8'); return { gridSize: 8 }; } if (u.includes('HARD MODE CHALLENGE')) { console.log('[Trigger] HARD MODE CHALLENGE → 10×10'); return { gridSize: 10 }; } return null; } /** * Extract word patterns from caption text — left-to-right, single pass. * Supports: "M--- (4)", "M----", "W---- H--- (4)", mixed prose. */ function parsePatterns(text) { const results = [], seen = new Set(); const re = /([A-Z])(-+)(?:\s*\(\d+\))?/g; let m; while ((m = re.exec(text)) !== null) { if (m[2].length < 2) continue; const pattern = (m[1] + m[2]).toUpperCase(); if (!seen.has(pattern)) { seen.add(pattern); results.push({ pattern }); } } return results; } // ─── Result formatter ───────────────────────────────────────────────────────── function formatResults(results, grid, patterns) { let msg = '🎯 WORD GRID RESULTS\n\n'; let foundAny = false; for (const p of patterns) { const key = p.pattern || p.word; if (!key) continue; const entry = results[key]; if (!entry) { msg += `❓ ${key}: not found\n`; continue; } if (Array.isArray(entry)) { const startChar = key[0].toUpperCase(); // the letter the pattern STARTS with const wordLen = key.length; // exact required length const wordMatches = new Set(); for (const hit of entry) { const raw = hit.match.toUpperCase(); // ── Strict rule 1: length must match exactly ────────────────────── if (raw.length !== wordLen) continue; // ── Strict rule 2: word must start with the pattern letter ──────── // The grid cell may be '?' (wildcard) or a lookalike — in both cases // the real word must begin with startChar. // We only accept the grid-literal (raw) or a version where the first // char is replaced with startChar (OCR correction). let accepted = null; if (raw[0] === startChar && isWord(raw)) { // Perfect: first letter correct AND dictionary word accepted = raw; } else if (isWord(startChar + raw.slice(1))) { // OCR misread first char — substitute the known correct first letter accepted = startChar + raw.slice(1); } // In both cases the accepted word MUST start with startChar — guaranteed above. if (accepted) { wordMatches.add(accepted); } else { console.log(`[Filter] Rejected "${raw}" for pattern "${key}"`); } } if (wordMatches.size > 0) { msg += `✅ ${key}: ${[...wordMatches].map(w => `${w}`).join(', ')}\n`; stats.wordsFound += wordMatches.size; foundAny = true; } else { msg += `❓ ${key}: no dictionary words found\n`; } } else { msg += `✅ ${entry.match} @ [${entry.r},${entry.c}] ${entry.dir}\n`; foundAny = true; } } if (!foundAny) { msg += '😔 No real word matches found.\n'; msg += 'Tip: verify caption patterns match the grid letters.\n'; } msg += '\n🔍 Extracted Grid:\n'; msg += '
' + grid.map(row => row.join(' ')).join('\n') + '
'; return msg; } // ─── GramJS Bot ─────────────────────────────────────────────────────────────── async function startBot() { const session = new StringSession(''); const client = new TelegramClient(session, API_ID, API_HASH, { connectionRetries: 10, retryDelay: 2000, autoReconnect: true, useWSS: false, }); console.log('[GramJS] Connecting via MTProto...'); await client.start({ botAuthToken: BOT_TOKEN }); console.log('[GramJS] Connected.'); const me = await client.getMe(); stats.botUsername = me.username || 'bot'; console.log(`[Bot] Running as @${stats.botUsername}`); // ── Message handler ────────────────────────────────────────────────────────── client.addEventHandler(async (event) => { const msg = event.message; if (!msg) return; const chatId = msg.peerId; const caption = (msg.message || '').trim(); // /start command if (caption === '/start' || caption.startsWith('/start ')) { await client.sendMessage(chatId, { message: [ '👋 Word Grid Solver Bot', '', 'Send a word grid image with the challenge caption and word patterns.', '', 'Triggers (case-insensitive):', '• WORD GRID CHALLENGE → solves 8×8 grid', '• HARD MODE CHALLENGE → solves 10×10 grid', '', 'Example caption:', 'WORD GRID CHALLENGE\nM--- (4) P------- (8) C----- (6)', '', 'The bot only processes images with these exact trigger phrases.', ].join('\n'), parseMode: 'html', }); return; } // ── Gate: only act on challenge captions ─────────────────────────────────── const challenge = getChallengeInfo(caption); if (!challenge) return; // silently ignore everything else // ── Must carry a photo ───────────────────────────────────────────────────── const hasPhoto = msg.media && ( msg.media.className === 'MessageMediaPhoto' || (msg.media.document && msg.media.document.mimeType && msg.media.document.mimeType.startsWith('image/')) ); if (!hasPhoto) { await client.sendMessage(chatId, { message: '⚠️ Please attach a grid image along with the challenge caption.', }); return; } // ── Download + OCR + Solve (all in one guarded block) ───────────────────── const imagePath = path.join( __dirname, `grid_${Date.now()}_${Math.random().toString(36).slice(2)}.jpg` ); // Safe send — never throws, logs errors instead const safeSend = async (text, opts = {}) => { try { await client.sendMessage(chatId, { message: text, ...opts }); } catch (sendErr) { console.error('[safeSend] Failed to send message:', sendErr.message); // Try plain text fallback if HTML parse failed if (opts.parseMode) { try { const plain = text.replace(/<[^>]+>/g, ''); await client.sendMessage(chatId, { message: plain }); } catch (_) {} } } }; try { await safeSend( `🔍 Processing your ${challenge.gridSize}×${challenge.gridSize} word grid...` ); // ── Step 1: Download ──────────────────────────────────────────────────── console.log(`[Handler] Starting download for msg=${msg.id}`); await downloadImage(client, msg, imagePath); console.log(`[Handler] Download complete: ${imagePath}`); // ── Step 2: OCR ───────────────────────────────────────────────────────── stats.imagesProcessed++; console.log(`[Handler] Starting OCR (${challenge.gridSize}×${challenge.gridSize})...`); let grid; try { grid = await extractGrid(imagePath, challenge.gridSize); } catch (ocrErr) { console.error('[Handler] OCR threw:', ocrErr.message, ocrErr.stack); grid = null; } if (!grid || grid.length === 0) { console.warn('[Handler] OCR returned null/empty grid'); await safeSend( '❌ Could not read the grid from this image.\n' + 'Make sure the letters are clearly visible and the image is not blurry.' ); return; } console.log(`[Handler] OCR done — ${grid.length}×${grid[0].length} grid`); // ── Step 3: Parse patterns ─────────────────────────────────────────────── const patterns = parsePatterns(caption); console.log(`[Handler] Patterns: ${patterns.map(p => p.pattern).join(', ') || '(none)'}`); if (patterns.length === 0) { // Show grid even without patterns const gridText = '
' + grid.map(r => r.join(' ')).join('\n') + '
'; await safeSend( `📋 ${challenge.gridSize}×${challenge.gridSize} grid extracted (no patterns found):\n\n` + gridText + '\n\nAdd patterns like M--- (4) to find words!', { parseMode: 'html' } ); return; } // ── Step 4: Solve ──────────────────────────────────────────────────────── console.log('[Handler] Solving...'); const results = solve(grid, patterns); const reply = formatResults(results, grid, patterns); await safeSend(reply, { parseMode: 'html' }); console.log('[Handler] Done ✓'); } catch (err) { // Catch-all for download errors and any unexpected throws console.error('[Handler] Unhandled error:', err.message); console.error(err.stack); await safeSend(`❌ Error: ${err.message}`); } finally { // Always clean up the temp file await deleteFile(imagePath); } }, new NewMessage({})); console.log('[Bot] Listening for messages...'); const shutdown = async sig => { console.log(`[Bot] ${sig} — disconnecting...`); try { await client.disconnect(); } catch (_) {} process.exit(0); }; process.once('SIGINT', () => shutdown('SIGINT')); process.once('SIGTERM', () => shutdown('SIGTERM')); } // ─── Express dashboard ──────────────────────────────────────────────────────── const app = express(); const PORT = parseInt(process.env.PORT || '7860', 10); app.use(express.static(path.join(__dirname, 'public'))); app.get('/api/stats', (_req, res) => { res.json({ ...stats, uptime: Math.floor((Date.now() - stats.startTime) / 1000) }); }); app.listen(PORT, () => console.log(`[Dashboard] Running on port ${PORT}`)); // ─── Boot ───────────────────────────────────────────────────────────────────── startBot().catch(err => { console.error('[FATAL]', err); process.exit(1); });