| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| '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'); |
|
|
| |
| const { downloadFileV2 } = require('./node_modules/telegram/client/downloads'); |
|
|
| |
| 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); |
| } |
|
|
| |
| 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()); } |
|
|
| |
| const stats = { |
| imagesProcessed: 0, |
| wordsFound: 0, |
| startTime: Date.now(), |
| botUsername: 'loading...', |
| }; |
|
|
| |
| 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); } |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| 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; |
| } |
|
|
| |
| |
| |
| |
| |
| async function getEntitySafe(client, peerId) { |
| try { |
| return await client.getEntity(peerId); |
| } catch (e) { |
| |
| console.warn(`[DL] getEntity failed (${e.message}), using peerId directly`); |
| return peerId; |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| async function downloadViaMTProto(client, originalMsg, destPath) { |
| console.log('[DL-1] Re-fetching message for fresh fileReference...'); |
|
|
| 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 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`); |
| } |
|
|
| |
| |
| |
| |
| 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`); |
| } |
|
|
| |
| |
| |
| |
| async function downloadImage(client, msg, destPath) { |
| if (!msg.media) throw new Error('Message has no media'); |
|
|
| |
| try { |
| await downloadViaMTProto(client, msg, destPath); |
| return; |
| } catch (e1) { |
| console.warn(`[DL-1] Failed: ${e1.message}`); |
| try { fs.unlinkSync(destPath); } catch (_) {} |
| } |
|
|
| |
| 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}`); |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| 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; |
| } |
|
|
| |
| |
| |
| |
| 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; |
| } |
|
|
| |
| function formatResults(results, grid, patterns) { |
| let msg = 'π― <b>WORD GRID RESULTS</b>\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(); |
| const wordLen = key.length; |
| const wordMatches = new Set(); |
|
|
| for (const hit of entry) { |
| const raw = hit.match.toUpperCase(); |
|
|
| |
| if (raw.length !== wordLen) continue; |
|
|
| |
| |
| |
| |
| |
| let accepted = null; |
|
|
| if (raw[0] === startChar && isWord(raw)) { |
| |
| accepted = raw; |
| } else if (isWord(startChar + raw.slice(1))) { |
| |
| accepted = startChar + raw.slice(1); |
| } |
| |
|
|
| if (accepted) { |
| wordMatches.add(accepted); |
| } else { |
| console.log(`[Filter] Rejected "${raw}" for pattern "${key}"`); |
| } |
| } |
|
|
| if (wordMatches.size > 0) { |
| msg += `β
${key}: ${[...wordMatches].map(w => `<code>${w}</code>`).join(', ')}\n`; |
| stats.wordsFound += wordMatches.size; |
| foundAny = true; |
| } else { |
| msg += `β ${key}: no dictionary words found\n`; |
| } |
| } else { |
| msg += `β
<code>${entry.match}</code> @ [${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π <b>Extracted Grid:</b>\n'; |
| msg += '<pre>' + grid.map(row => row.join(' ')).join('\n') + '</pre>'; |
| return msg; |
| } |
|
|
| |
| 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}`); |
|
|
| |
| client.addEventHandler(async (event) => { |
| const msg = event.message; |
| if (!msg) return; |
|
|
| const chatId = msg.peerId; |
| const caption = (msg.message || '').trim(); |
|
|
| |
| if (caption === '/start' || caption.startsWith('/start ')) { |
| await client.sendMessage(chatId, { |
| message: [ |
| 'π <b>Word Grid Solver Bot</b>', |
| '', |
| 'Send a word grid image with the challenge caption and word patterns.', |
| '', |
| '<b>Triggers (case-insensitive):</b>', |
| 'β’ <code>WORD GRID CHALLENGE</code> β solves 8Γ8 grid', |
| 'β’ <code>HARD MODE CHALLENGE</code> β solves 10Γ10 grid', |
| '', |
| '<b>Example caption:</b>', |
| '<code>WORD GRID CHALLENGE\nM--- (4) P------- (8) C----- (6)</code>', |
| '', |
| 'The bot only processes images with these exact trigger phrases.', |
| ].join('\n'), |
| parseMode: 'html', |
| }); |
| return; |
| } |
|
|
| |
| const challenge = getChallengeInfo(caption); |
| if (!challenge) return; |
|
|
| |
| 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; |
| } |
|
|
| |
| const imagePath = path.join( |
| __dirname, |
| `grid_${Date.now()}_${Math.random().toString(36).slice(2)}.jpg` |
| ); |
|
|
| |
| const safeSend = async (text, opts = {}) => { |
| try { |
| await client.sendMessage(chatId, { message: text, ...opts }); |
| } catch (sendErr) { |
| console.error('[safeSend] Failed to send message:', sendErr.message); |
| |
| 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...` |
| ); |
|
|
| |
| console.log(`[Handler] Starting download for msg=${msg.id}`); |
| await downloadImage(client, msg, imagePath); |
| console.log(`[Handler] Download complete: ${imagePath}`); |
|
|
| |
| 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`); |
|
|
| |
| const patterns = parsePatterns(caption); |
| console.log(`[Handler] Patterns: ${patterns.map(p => p.pattern).join(', ') || '(none)'}`); |
|
|
| if (patterns.length === 0) { |
| |
| const gridText = '<pre>' + grid.map(r => r.join(' ')).join('\n') + '</pre>'; |
| await safeSend( |
| `π <b>${challenge.gridSize}Γ${challenge.gridSize} grid extracted</b> (no patterns found):\n\n` + |
| gridText + '\n\nAdd patterns like <code>M--- (4)</code> to find words!', |
| { parseMode: 'html' } |
| ); |
| return; |
| } |
|
|
| |
| 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) { |
| |
| console.error('[Handler] Unhandled error:', err.message); |
| console.error(err.stack); |
| await safeSend(`β Error: ${err.message}`); |
| } finally { |
| |
| 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')); |
| } |
|
|
| |
| 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}`)); |
|
|
| |
| startBot().catch(err => { console.error('[FATAL]', err); process.exit(1); }); |
|
|