Initial commit: Telegram first-comment bot.

Auto-replies in a channel discussion chat with HTML text from comment.txt.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Artemii Peretiachenko 2026-07-20 15:05:59 +02:00
commit 9147306713
10 changed files with 315 additions and 0 deletions

3
.env.example Normal file
View file

@ -0,0 +1,3 @@
BOT_TOKEN=123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11
DISCUSSION_CHAT_ID=-1001234567890
# COMMENT_FILE=comment.txt

9
.gitignore vendored Normal file
View file

@ -0,0 +1,9 @@
.env
.venv/
__pycache__/
*.pyc
.Python
*.egg-info/
dist/
build/
.pytest_cache/

60
README.md Normal file
View file

@ -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. Обычное сообщение в чат бот игнорирует.

0
bot/__init__.py Normal file
View file

67
bot/config.py Normal file
View file

@ -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(),
)

69
bot/main.py Normal file
View file

@ -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())

20
comment.txt Normal file
View file

@ -0,0 +1,20 @@
Привет!
<strong>Ставьте реакции на пост и делитесь своим мнением здесь, в комментариях.</strong>
Нам в кайф пообщаться с подписчиками.
🔶 Чтобы бот не удалил ваш комментарий, нужно быть участником чата (<a href="https://t.me/+jOrJ6bZCfKNkMjMy">вот ссылка</a>).
<strong>Вся информация про нашу команду — на сайте.</strong>
Нажмите, почитайте!
🐺 <a href="http://428th.com">428th.com</a>
Лучшая обменка: <a href="https://t.me/a428th_exchange_bot">@a428th_exchange_bot</a>
Биржа для братьев: <a href="https://bingxdao.com/partner/artemium/">BingX</a> (код <code>ARTEMIUM</code>)
<blockquote>бонусы в лс</blockquote>
<a href="http://428th.com/#premium">Premium</a> // <a href="http://428th.com/#education">Обучение</a> // <a href="http://428th.com/#copytrade">Копитрейдинг</a> // <a href="http://428th.com/terminal">Terminal</a>

3
pytest.ini Normal file
View file

@ -0,0 +1,3 @@
[pytest]
asyncio_mode = auto
asyncio_default_fixture_loop_scope = function

4
requirements.txt Normal file
View file

@ -0,0 +1,4 @@
aiogram==3.22.0
python-dotenv==1.1.1
pytest==8.4.1
pytest-asyncio==1.0.0

80
tests/test_bot.py Normal file
View file

@ -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 <b>world</b>", 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 <b>world</b>"
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()