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