Initial commit

This commit is contained in:
Eduard Baturin 2026-04-20 22:31:45 +03:00
commit 840b3af2eb
10 changed files with 550 additions and 0 deletions

2
.env.example Normal file
View file

@ -0,0 +1,2 @@
BOT_TOKEN=your_telegram_bot_token_here
BOT_API_ROOT=

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
.env
node_modules
assets

145
package-lock.json generated Normal file
View file

@ -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"
}
}
}
}

10
package.json Normal file
View file

@ -0,0 +1,10 @@
{
"scripts": {
"start": "node src/index.js"
},
"dependencies": {
"@grammyjs/files": "^1.2.0",
"dotenv": "^17.4.2",
"grammy": "^1.42.0"
}
}

126
setup.md Normal file
View file

@ -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_ID> \
--api-hash <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<BOT_TOKEN>/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.

22
src/bot.js Normal file
View file

@ -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,
};

19
src/config.js Normal file
View file

@ -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,
};

77
src/handlers/message.js Normal file
View file

@ -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,
};

20
src/index.js Normal file
View file

@ -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);
});

126
src/services/asset-store.js Normal file
View file

@ -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,
};