mirror of
https://github.com/artemium428/first-commenting-bot.git
synced 2026-09-15 16:46:19 +00:00
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:
commit
9147306713
10 changed files with 315 additions and 0 deletions
3
.env.example
Normal file
3
.env.example
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
BOT_TOKEN=123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11
|
||||||
|
DISCUSSION_CHAT_ID=-1001234567890
|
||||||
|
# COMMENT_FILE=comment.txt
|
||||||
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
.env
|
||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.Python
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.pytest_cache/
|
||||||
60
README.md
Normal file
60
README.md
Normal 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
0
bot/__init__.py
Normal file
67
bot/config.py
Normal file
67
bot/config.py
Normal 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
69
bot/main.py
Normal 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
20
comment.txt
Normal 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
3
pytest.ini
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
[pytest]
|
||||||
|
asyncio_mode = auto
|
||||||
|
asyncio_default_fixture_loop_scope = function
|
||||||
4
requirements.txt
Normal file
4
requirements.txt
Normal 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
80
tests/test_bot.py
Normal 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()
|
||||||
Loading…
Add table
Reference in a new issue