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