commit 840b3af2ebd3e90e6fe7d326ecab7eeb67040db4 Author: Trard Date: Mon Apr 20 22:31:45 2026 +0300 Initial commit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7ea4075 --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +BOT_TOKEN=your_telegram_bot_token_here +BOT_API_ROOT= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6ce8cdb --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.env +node_modules +assets diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..72c90ac --- /dev/null +++ b/package-lock.json @@ -0,0 +1,145 @@ +{ + "name": "bot", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "@grammyjs/files": "^1.2.0", + "dotenv": "^17.4.2", + "grammy": "^1.42.0" + } + }, + "node_modules/@grammyjs/files": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@grammyjs/files/-/files-1.2.0.tgz", + "integrity": "sha512-UhQNGe2gpGgGMdrAUFXrZld3qQjt/3oYBHIo1aU0zIrKKvI5bgxzXSjPaJpx69HUO9mM7TbOaSsJL8lWbqnzJw==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || >=14.13.1" + }, + "peerDependencies": { + "grammy": "^1.3.0" + } + }, + "node_modules/@grammyjs/types": { + "version": "3.26.0", + "resolved": "https://registry.npmjs.org/@grammyjs/types/-/types-3.26.0.tgz", + "integrity": "sha512-jlnyfxfev/2o68HlvAGRocAXgdPPX5QabG7jZlbqC2r9DZyWBfzTlg+nu3O3Fy4EhgLWu28hZ/8wr7DsNamP9A==", + "license": "MIT" + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/grammy": { + "version": "1.42.0", + "resolved": "https://registry.npmjs.org/grammy/-/grammy-1.42.0.tgz", + "integrity": "sha512-1AdCge+AkjSdp2FwfICSFnVbl8Mq3KVHJDy+DgTI9+D6keJ0zWALPRKas5jv/8psiCzL4N2cEOcGW7O45Kn39g==", + "license": "MIT", + "dependencies": { + "@grammyjs/types": "3.26.0", + "abort-controller": "^3.0.0", + "debug": "^4.4.3", + "node-fetch": "^2.7.0" + }, + "engines": { + "node": "^12.20.0 || >=14.13.1" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..1cae7ee --- /dev/null +++ b/package.json @@ -0,0 +1,10 @@ +{ + "scripts": { + "start": "node src/index.js" + }, + "dependencies": { + "@grammyjs/files": "^1.2.0", + "dotenv": "^17.4.2", + "grammy": "^1.42.0" + } +} diff --git a/setup.md b/setup.md new file mode 100644 index 0000000..99c3831 --- /dev/null +++ b/setup.md @@ -0,0 +1,126 @@ +# Setup + +## Requirements + +- Node.js 20+ or newer +- npm +- A Telegram bot token from `@BotFather` + +## Project Setup + +1. Install dependencies: + +```bash +npm install +``` + +2. Create `.env` from `.env.example` and fill in your bot token: + +```env +BOT_TOKEN=your_telegram_bot_token_here +BOT_API_ROOT= +``` + +3. Start the bot: + +```bash +npm start +``` + +## What The Bot Does + +- Saves text messages to `assets/request.txt` by appending new lines +- Saves photos, videos, voice messages, and documents to `assets/` +- Replies with `Ок` for supported message types + +## Optional: Local Bot API Server For Files Larger Than 20 MB + +Telegram's hosted Bot API cannot download files larger than `20 MB`. To support larger files, run a local `telegram-bot-api` server and point the bot to it with `BOT_API_ROOT`. + +Official references: + +- https://github.com/tdlib/telegram-bot-api +- https://core.telegram.org/bots/api#using-a-local-bot-api-server +- https://grammy.dev/guide/api.html + +### 1. Get `api_id` and `api_hash` + +Create them here: + +- https://core.telegram.org/api/obtaining_api_id + +### 2. Build The Local Bot API Server In `~/dev/lamda/bot_api` + +The commands below match the local layout already prepared on this machine. + +```bash +mkdir -p ~/dev/lamda/bot_api + +~/miniconda3/bin/conda create -y \ + -p ~/dev/lamda/bot_api/.buildenv \ + --override-channels \ + -c conda-forge \ + gperf openssl zlib cmake + +git clone --recursive https://github.com/tdlib/telegram-bot-api.git ~/dev/lamda/bot_api/telegram-bot-api + +git -C ~/dev/lamda/bot_api/telegram-bot-api submodule update --init --depth 1 td + +mkdir -p ~/dev/lamda/bot_api/telegram-bot-api/build +mkdir -p ~/dev/lamda/bot_api/install + +export PATH=~/dev/lamda/bot_api/.buildenv/bin:$PATH +export CMAKE_PREFIX_PATH=~/dev/lamda/bot_api/.buildenv + +cmake -S ~/dev/lamda/bot_api/telegram-bot-api \ + -B ~/dev/lamda/bot_api/telegram-bot-api/build \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_INSTALL_PREFIX=~/dev/lamda/bot_api/install \ + -DOPENSSL_ROOT_DIR=~/dev/lamda/bot_api/.buildenv \ + -DZLIB_ROOT=~/dev/lamda/bot_api/.buildenv + +cmake --build ~/dev/lamda/bot_api/telegram-bot-api/build -j"$(nproc)" +cmake --install ~/dev/lamda/bot_api/telegram-bot-api/build +``` + +### 3. Start The Local Bot API Server + +```bash +mkdir -p ~/dev/lamda/bot_api/data +mkdir -p ~/dev/lamda/bot_api/tmp + +~/dev/lamda/bot_api/install/bin/telegram-bot-api \ + --api-id \ + --api-hash \ + --local \ + --http-port 8081 \ + --dir ~/dev/lamda/bot_api/data \ + --temp-dir ~/dev/lamda/bot_api/tmp +``` + +### 4. Switch The Bot To The Local Server + +Before switching away from Telegram's hosted Bot API, log the bot out there once: + +```text +https://api.telegram.org/bot/logOut +``` + +Then set this in `.env`: + +```env +BOT_TOKEN=your_telegram_bot_token_here +BOT_API_ROOT=http://127.0.0.1:8081 +``` + +Restart the bot after that: + +```bash +npm start +``` + +## Notes + +- `BOT_API_ROOT` is optional. Leave it empty to use the default Telegram hosted Bot API. +- The local Bot API server listens over HTTP by default. +- If both the bot and the local Bot API server run on the same machine, `127.0.0.1:8081` is enough. diff --git a/src/bot.js b/src/bot.js new file mode 100644 index 0000000..34a1bf7 --- /dev/null +++ b/src/bot.js @@ -0,0 +1,22 @@ +const { Bot } = require("grammy"); +const { hydrateFiles } = require("@grammyjs/files"); +const { registerMessageHandlers } = require("./handlers/message"); + +function createBot({ token, botApiRoot, assetStore }) { + const bot = new Bot(token, botApiRoot ? { + client: { apiRoot: botApiRoot }, + } : undefined); + bot.api.config.use(hydrateFiles(bot.token)); + + registerMessageHandlers(bot, assetStore); + + bot.catch((error) => { + console.error("Bot error:", error.error); + }); + + return bot; +} + +module.exports = { + createBot, +}; diff --git a/src/config.js b/src/config.js new file mode 100644 index 0000000..04a25de --- /dev/null +++ b/src/config.js @@ -0,0 +1,19 @@ +const path = require("node:path"); +const dotenv = require("dotenv"); + +dotenv.config({ quiet: true }); + +const token = process.env.BOT_TOKEN; +const botApiRoot = process.env.BOT_API_ROOT; + +if (!token) { + throw new Error("Missing BOT_TOKEN in .env"); +} + +const assetsDir = path.join(__dirname, "..", "assets"); + +module.exports = { + assetsDir, + botApiRoot, + token, +}; diff --git a/src/handlers/message.js b/src/handlers/message.js new file mode 100644 index 0000000..010ec18 --- /dev/null +++ b/src/handlers/message.js @@ -0,0 +1,77 @@ +function getAttachmentPayload(message) { + if (message.photo) { + const largestPhoto = message.photo[message.photo.length - 1]; + + return { + fileId: largestPhoto.file_id, + options: { + kind: "photo", + fallbackExtension: ".jpg", + caption: message.caption, + }, + }; + } + + if (message.video) { + return { + fileId: message.video.file_id, + options: { + kind: "video", + fallbackExtension: ".mp4", + caption: message.caption, + }, + }; + } + + if (message.voice) { + return { + fileId: message.voice.file_id, + options: { + kind: "voice", + fallbackExtension: ".ogg", + caption: message.caption, + }, + }; + } + + if (message.document) { + return { + fileId: message.document.file_id, + options: { + kind: "document", + originalName: message.document.file_name, + fallbackExtension: ".bin", + caption: message.caption, + }, + }; + } + + return null; +} + +function registerMessageHandlers(bot, assetStore) { + const deliveredReply = "Сообщение доставлено ИИ агенту, оно обрабатывается"; + + bot.on("message", async (ctx) => { + const { message } = ctx; + + if (message.text) { + await assetStore.saveText(ctx, message.text); + await ctx.reply(deliveredReply); + return; + } + + const attachment = getAttachmentPayload(message); + + if (!attachment) { + return; + } + + await assetStore.saveTelegramFile(ctx, attachment.fileId, attachment.options); + await ctx.reply(deliveredReply); + }); +} + +module.exports = { + registerMessageHandlers, +}; diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..21875ac --- /dev/null +++ b/src/index.js @@ -0,0 +1,20 @@ +const { assetsDir, botApiRoot, token } = require("./config"); +const { createBot } = require("./bot"); +const { createAssetStore } = require("./services/asset-store"); + +const assetStore = createAssetStore({ assetsDir }); +const bot = createBot({ token, botApiRoot, assetStore }); + +async function main() { + await assetStore.ensureDir(); + await bot.start({ + onStart: () => { + console.log(`Bot is running. Files are saved to ${assetsDir}`); + }, + }); +} + +main().catch((error) => { + console.error("Failed to start bot:", error); + process.exit(1); +}); diff --git a/src/services/asset-store.js b/src/services/asset-store.js new file mode 100644 index 0000000..f705257 --- /dev/null +++ b/src/services/asset-store.js @@ -0,0 +1,126 @@ +const fs = require("node:fs/promises"); +const path = require("node:path"); + +function makeMessageKey(ctx, kind) { + const chatId = ctx.chat?.id ?? "unknown-chat"; + const messageId = ctx.message?.message_id ?? Date.now(); + + return `${Date.now()}-${chatId}-${messageId}-${kind}`; +} + +function sanitizeBaseName(name) { + const baseName = path.parse(name).name; + const sanitized = baseName.replace(/[^a-zA-Z0-9._-]/g, "_").replace(/_+/g, "_"); + + return sanitized || "file"; +} + +function pickExtension(filePath, originalName, fallbackExtension) { + return ( + path.extname(originalName || "") || + path.extname(filePath || "") || + fallbackExtension + ); +} + +function formatRequestEntry(text, requestedAt = new Date()) { + const normalizedText = String(text).replace(/\r\n/g, "\n"); + + return `[${requestedAt.toISOString()}]\n${normalizedText}\n\n`; +} + +function getRequestDate(ctx) { + const timestamp = ctx.message?.date; + + if (typeof timestamp === "number") { + return new Date(timestamp * 1000); + } + + return new Date(); +} + +function getRequestLogPath(assetsDir) { + return path.join(assetsDir, "request.txt"); +} + +async function appendRequestEntry(assetsDir, text, requestedAt) { + await fs.appendFile( + getRequestLogPath(assetsDir), + formatRequestEntry(text, requestedAt), + "utf8", + ); +} + +function formatDownloadedFileEntry({ kind, targetPath, originalName, caption }) { + const lines = [ + `downloaded ${kind}: ${path.basename(targetPath)}`, + ]; + + if (originalName) { + lines.push(`original name: ${originalName}`); + } + + if (caption) { + lines.push(`caption: ${caption}`); + } + + return lines.join("\n"); +} + +async function saveCaption(assetsDir, baseName, caption) { + if (!caption) { + return; + } + + const captionPath = path.join(assetsDir, `${baseName}.txt`); + await fs.writeFile(captionPath, caption, "utf8"); +} + +function createAssetStore({ assetsDir }) { + return { + async ensureDir() { + await fs.mkdir(assetsDir, { recursive: true }); + }, + + async saveText(ctx, text, kind = "text") { + const requestedAt = getRequestDate(ctx); + + await appendRequestEntry(assetsDir, text, requestedAt); + }, + + async saveTelegramFile(ctx, fileId, options) { + const telegramFile = await ctx.api.getFile(fileId); + const baseNameParts = [makeMessageKey(ctx, options.kind)]; + const requestedAt = getRequestDate(ctx); + + if (options.originalName) { + baseNameParts.push(sanitizeBaseName(options.originalName)); + } + + const baseName = baseNameParts.join("-"); + const extension = pickExtension( + telegramFile.file_path, + options.originalName, + options.fallbackExtension, + ); + const targetPath = path.join(assetsDir, `${baseName}${extension}`); + + await telegramFile.download(targetPath); + await saveCaption(assetsDir, baseName, options.caption); + await appendRequestEntry( + assetsDir, + formatDownloadedFileEntry({ + kind: options.kind, + targetPath, + originalName: options.originalName, + caption: options.caption, + }), + requestedAt, + ); + }, + }; +} + +module.exports = { + createAssetStore, +};