From 914730671312a3386e4c82022b550cce1eaa1626 Mon Sep 17 00:00:00 2001 From: Artemii Peretiachenko Date: Mon, 20 Jul 2026 15:05:59 +0200 Subject: [PATCH] Initial commit: Telegram first-comment bot. Auto-replies in a channel discussion chat with HTML text from comment.txt. Co-authored-by: Cursor --- .env.example | 3 ++ .gitignore | 9 ++++++ README.md | 60 +++++++++++++++++++++++++++++++++++ bot/__init__.py | 0 bot/config.py | 67 +++++++++++++++++++++++++++++++++++++++ bot/main.py | 69 ++++++++++++++++++++++++++++++++++++++++ comment.txt | 20 ++++++++++++ pytest.ini | 3 ++ requirements.txt | 4 +++ tests/test_bot.py | 80 +++++++++++++++++++++++++++++++++++++++++++++++ 10 files changed, 315 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 bot/__init__.py create mode 100644 bot/config.py create mode 100644 bot/main.py create mode 100644 comment.txt create mode 100644 pytest.ini create mode 100644 requirements.txt create mode 100644 tests/test_bot.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1178be6 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +BOT_TOKEN=123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11 +DISCUSSION_CHAT_ID=-1001234567890 +# COMMENT_FILE=comment.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..37be4ee --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.env +.venv/ +__pycache__/ +*.pyc +.Python +*.egg-info/ +dist/ +build/ +.pytest_cache/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..bc9879b --- /dev/null +++ b/README.md @@ -0,0 +1,60 @@ +# First commenting bot + +Telegram-бот, который в discuss-чате канала отвечает заданным текстом на каждый новый пост (автофорвард из канала). + +## Как это работает + +1. В канале выходит пост. +2. Telegram сам дублирует его в привязанный discussion-чат. +3. Бот видит сообщение с `is_automatic_forward` (автофорвард из канала). +4. Бот делает reply с текстом из файла [`comment.txt`](comment.txt). + +Бот слушает **discuss-чат**, не сам канал. В канал его добавлять не нужно. +Обычные сообщения, личка и команды (`/start` и т.п.) бот игнорирует — на них нет обработчиков. + +## Настройка в Telegram + +1. Создайте бота у [@BotFather](https://t.me/BotFather) и скопируйте токен. +2. В настройках канала включите **Discussion** и привяжите супергруппу. +3. Добавьте бота в discuss-чат и сделайте **админом** с правом писать сообщения. +4. Узнайте `chat_id` discuss-чата (отрицательный, вида `-100...`). Удобные способы: + - переслать любое сообщение из чата боту вроде [@userinfobot](https://t.me/userinfobot); + - временно залогировать `message.chat.id` в боте. + +## Установка и запуск + +```bash +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +cp .env.example .env +``` + +Заполните `.env`: + +| Переменная | Описание | +|---|---| +| `BOT_TOKEN` | Токен от BotFather | +| `DISCUSSION_CHAT_ID` | ID discuss-супергруппы (`-100...`) | +| `COMMENT_FILE` | Опционально: путь к файлу с текстом ответа (относительно корня проекта или абсолютный). По умолчанию — `comment.txt` | + +Текст ответа редактируйте в [`comment.txt`](comment.txt) в корне проекта (можно многострочный HTML). + +Запуск: + +```bash +python -m bot.main +``` + +Тесты: + +```bash +python -m pytest +``` + +## Проверка + +1. Запустите бота. +2. Опубликуйте тестовый пост в канале. +3. В discuss-чате появится автофорвард — бот ответит reply’ем текстом из `comment.txt`. +4. Обычное сообщение в чат бот игнорирует. diff --git a/bot/__init__.py b/bot/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bot/config.py b/bot/config.py new file mode 100644 index 0000000..ee49e29 --- /dev/null +++ b/bot/config.py @@ -0,0 +1,67 @@ +import os +from dataclasses import dataclass +from pathlib import Path + +from dotenv import load_dotenv + +ROOT_DIR = Path(__file__).resolve().parent.parent +DEFAULT_COMMENT_FILE = ROOT_DIR / "comment.txt" + + +@dataclass(frozen=True) +class Config: + bot_token: str + discussion_chat_id: int + comment_text: str + + +def _load_comment_text() -> str: + path_raw = os.getenv("COMMENT_FILE", "").strip() + path = Path(path_raw) if path_raw else DEFAULT_COMMENT_FILE + if not path.is_absolute(): + path = ROOT_DIR / path + + if not path.is_file(): + raise SystemExit( + f"Comment file not found: {path}. " + "Create comment.txt in the project root or set COMMENT_FILE." + ) + + text = path.read_text(encoding="utf-8").strip() + if not text: + raise SystemExit(f"Comment file is empty: {path}") + return text + + +def load_config() -> Config: + load_dotenv() + + bot_token = os.getenv("BOT_TOKEN", "").strip() + chat_id_raw = os.getenv("DISCUSSION_CHAT_ID", "").strip() + + missing = [ + name + for name, value in ( + ("BOT_TOKEN", bot_token), + ("DISCUSSION_CHAT_ID", chat_id_raw), + ) + if not value + ] + if missing: + raise SystemExit( + f"Missing required environment variables: {', '.join(missing)}. " + "Copy .env.example to .env and fill in the values." + ) + + try: + discussion_chat_id = int(chat_id_raw) + except ValueError as exc: + raise SystemExit( + f"DISCUSSION_CHAT_ID must be an integer, got: {chat_id_raw!r}" + ) from exc + + return Config( + bot_token=bot_token, + discussion_chat_id=discussion_chat_id, + comment_text=_load_comment_text(), + ) diff --git a/bot/main.py b/bot/main.py new file mode 100644 index 0000000..f0294ed --- /dev/null +++ b/bot/main.py @@ -0,0 +1,69 @@ +import asyncio +import logging + +from aiogram import Bot, Dispatcher, F, Router +from aiogram.enums import ParseMode +from aiogram.filters import Filter +from aiogram.types import Message + +from bot.config import Config, load_config + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s: %(message)s", +) +logger = logging.getLogger(__name__) + + +class FromLinkedChannel(Filter): + """Match messages that Telegram auto-forwards from a linked channel.""" + + async def __call__(self, message: Message) -> bool: + return bool(message.is_automatic_forward) + + +def create_dispatcher(config: Config) -> Dispatcher: + dp = Dispatcher() + router = Router() + + @router.message(F.chat.id == config.discussion_chat_id, FromLinkedChannel()) + async def on_channel_post(message: Message) -> None: + try: + await message.reply(config.comment_text, parse_mode=ParseMode.HTML) + except Exception: + logger.exception( + "Failed to reply to channel post message_id=%s in chat_id=%s", + message.message_id, + message.chat.id, + ) + return + + logger.info( + "Replied to channel post message_id=%s in chat_id=%s", + message.message_id, + message.chat.id, + ) + + dp.include_router(router) + return dp + + +async def main() -> None: + config = load_config() + bot = Bot(token=config.bot_token) + dp = create_dispatcher(config) + + try: + me = await bot.get_me() + logger.info( + "Starting bot @%s; watching discussion chat %s", + me.username, + config.discussion_chat_id, + ) + await dp.start_polling(bot) + finally: + await bot.session.close() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/comment.txt b/comment.txt new file mode 100644 index 0000000..531d668 --- /dev/null +++ b/comment.txt @@ -0,0 +1,20 @@ +Привет! + +Ставьте реакции на пост и делитесь своим мнением здесь, в комментариях. + +Нам в кайф пообщаться с подписчиками. + +🔶 Чтобы бот не удалил ваш комментарий, нужно быть участником чата (вот ссылка). + +Вся информация про нашу команду — на сайте. + +Нажмите, почитайте! + +🐺 428th.com + +Лучшая обменка: @a428th_exchange_bot + +Биржа для братьев: BingX (код ARTEMIUM) +
бонусы в лс
+ +Premium // Обучение // Копитрейдинг // Terminal diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..c8c9c75 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +asyncio_mode = auto +asyncio_default_fixture_loop_scope = function diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..27dee94 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +aiogram==3.22.0 +python-dotenv==1.1.1 +pytest==8.4.1 +pytest-asyncio==1.0.0 diff --git a/tests/test_bot.py b/tests/test_bot.py new file mode 100644 index 0000000..218fb84 --- /dev/null +++ b/tests/test_bot.py @@ -0,0 +1,80 @@ +from unittest.mock import patch + +import pytest +from aiogram.types import Chat, Message, User + +from bot.config import load_config +from bot.main import FromLinkedChannel + + +def _make_message(*, is_automatic_forward: bool = False) -> Message: + return Message( + message_id=1, + date=0, + chat=Chat(id=-1001234567890, type="supergroup"), + from_user=User(id=1, is_bot=False, first_name="Test"), + is_automatic_forward=True if is_automatic_forward else None, + ) + + +@pytest.mark.asyncio +async def test_from_linked_channel_matches_automatic_forward() -> None: + filt = FromLinkedChannel() + assert await filt(_make_message(is_automatic_forward=True)) is True + + +@pytest.mark.asyncio +async def test_from_linked_channel_ignores_regular_message() -> None: + filt = FromLinkedChannel() + assert await filt(_make_message(is_automatic_forward=False)) is False + + +def test_load_config_success(tmp_path, monkeypatch) -> None: + comment = tmp_path / "comment.txt" + comment.write_text("Hello world", encoding="utf-8") + + monkeypatch.setenv("BOT_TOKEN", "123:ABC") + monkeypatch.setenv("DISCUSSION_CHAT_ID", "-100123") + monkeypatch.setenv("COMMENT_FILE", str(comment)) + + with patch("bot.config.load_dotenv"): + config = load_config() + + assert config.bot_token == "123:ABC" + assert config.discussion_chat_id == -100123 + assert config.comment_text == "Hello world" + + +def test_load_config_missing_env(monkeypatch) -> None: + monkeypatch.delenv("BOT_TOKEN", raising=False) + monkeypatch.delenv("DISCUSSION_CHAT_ID", raising=False) + + with patch("bot.config.load_dotenv"): + with pytest.raises(SystemExit, match="BOT_TOKEN"): + load_config() + + +def test_load_config_bad_chat_id(monkeypatch, tmp_path) -> None: + comment = tmp_path / "comment.txt" + comment.write_text("ok", encoding="utf-8") + + monkeypatch.setenv("BOT_TOKEN", "123:ABC") + monkeypatch.setenv("DISCUSSION_CHAT_ID", "not-an-int") + monkeypatch.setenv("COMMENT_FILE", str(comment)) + + with patch("bot.config.load_dotenv"): + with pytest.raises(SystemExit, match="must be an integer"): + load_config() + + +def test_load_config_empty_comment(monkeypatch, tmp_path) -> None: + comment = tmp_path / "comment.txt" + comment.write_text(" \n", encoding="utf-8") + + monkeypatch.setenv("BOT_TOKEN", "123:ABC") + monkeypatch.setenv("DISCUSSION_CHAT_ID", "-100123") + monkeypatch.setenv("COMMENT_FILE", str(comment)) + + with patch("bot.config.load_dotenv"): + with pytest.raises(SystemExit, match="empty"): + load_config()