Как я научила Telegram-бота наводить порядок в чате с мемами: пересылка по хештегам в соответствующую тему

от автора

Наверняка, у всех есть групповой чат со всякими приколами, но в котором периодически проскакивают нужные вещи, которые теряются в потоке мемов, флуда и всего прочего. У меня есть такой девчачий чат, в котором мы обсуждаем проблемы, скидываем рецепты, раздаем какие-то рекомендации друг другу что посмотреть, что почитать и т.д. Однажды я полчаса листала чат в поисках новой рекомендованной книги, которую скинули между фоткой с котиком и горением от работы, тогда мне в голову пришла гениальная мысль — создать бота, который будет пересылать сообщения в нужную тему.

Нам понадобится:

  • блокнот для кода

  • VPS для проксирования, т.к. в нынешние времена беда с телегой

  • сервер, на котором будет крутиться бот (у меня это Raspberry Pi)

  • перфекционизм, оно же желание навести порядок

Первое, что нужно сделать — разработать систему хештегов. Выделяем самое важное, что нужно обязательно сохранить, например:

  • #цитаты — смешные фразочки или фотки для важных переговоров

  • #рецепт — важный раздел, нужно знать, чем кормить мужа, кроме макарошек с котлеткой

  • #план — мы живем в разных городах и периодически встречаемся, поэтому ко встрече надо иметь план, чтобы все успеть И так далее, но сразу скажу, что слишком много придумывать не нужно, иначе в тегах можно запутаться/забыть/забить

Далее создаем нужные темы и сохраняем их ID в файл переменных .env. Чтобы найти ID темы, нужно перейти в нужную тему, кликнуть на название и сохранить цифры после последнего слэша Чтобы найти ID самого чата, берем цифры после /c/ и добавляем -100

Создаем сущность нашего бота в телеграм через @BotFather: пишем /start, и следующая команда /newbot, и затем отправляем ему уникальное имя бота Не пытайтесь придумать говорящее название — вероятнее всего оно уже занято, в одной статье про микросервисы читала, что лучше давать абстрактные имена, потому что роль может поменяться, неизвестно во что может в дальнейшем превратиться ваш бот. Название нашего бота, допустим, родилось из локального мема.

Теперь перейдем к “внутреннему миру” нашего бота.

Нам нужен telebot и его класс TeleBot для создания души (объекта) и сабмодуль apihelper для настройки проксирования:

from telebot import apihelper, TeleBot

и сразу же dotenv с load_dotenv и os для подгрузки, потому что хранить переменные отдельно — это хороший тон:

import osfrom dotenv import load_dotenvload_dotenv()

Подгружаем переменные:

TOKEN = os.getenv('TOKEN')MAIN = int(os.getenv('MAIN'))QUOTES = int(os.getenv('QUOTES'))PLAN = int(os.getenv('PLAN'))RECOMMEND = int(os.getenv('RECOMMEND'))

Составим таблицу соответствия:

THREAD_MAP = {QUOTES: {'#цитат'}, #кто-то пишет "цитата", кто-то - "цитаты", поэтому оставляем часть слова    PLAN: {'#план'},    RECOMMEND: {'#рецепт', '#читать', '#смотреть'}, #сгруппировали рекомендации в одну тему}

Где есть возможность всегда использую множества вместо списков, т.к. они обрабатываются быстрее, понятное дело, что здесь это необязательно, но мало ли, будет решено добавить еще какой-то функционал.

Создаем душу:

bot = TeleBot(TOKEN)

И переходим к ее наполнению: у нас в чате демократия, поэтому пересылка происходит, когда кто угодно ответит на сообщение соответствующим тегом. Реализуем такой подход.

Будем использовать декоратор @bot.message_handler с лямбдой, который реагирует на входящие сообщения. В самом простом варианте будет выглядеть так:

@bot.message_handler(func=lambda message: message.reply_to_message is not None and '#цитат' in message.text.lower())

Сначала разберем, что у нас здесь вообще происходит

  • @bot.message_handler — реагирует на входящие сообщения

  • func=lambda message — анонимная функция для фильтрации входящих сообщений

  • message.reply_to_message is not None — проверка, что сообщение является ответом на какое-либо сообщение

  • ‘#цитат’ in message.text.lower() — проверка, что это не просто ответ, а наша метка, приводим к нижнему регистру, чтобы не держать в голове лишнюю информацию о формате тегов

Теперь нужна сама функция пересылки:

def reply_message_quotes(message):    bot.forward_message(chat_id=MAIN,            from_chat_id=message.chat.id,            message_id=message.reply_to_message.message_id,            message_thread_id=QUOTES)
  • bot.forward_message — вызываем метод пересылки сообщений с необходимыми параметрами

  • chat_id=MAIN — ID группы, куда пересылаем сообщение

  • from_chat_id=message.chat.id — ID, откуда пересылаем, получаем сразу же из сообщения, с которым работаем

  • message_id=message.reply_to_message.message_id — ID сообщения, которое пересылаем, получаем из нашего сообщения с тегом (message.reply_to_message), последний message_id говорит об id сообщения, которое мы будем пересылать (сообщение, на которое повесили тег)

  • message_thread_id=QUOTES — ID темы, в которую пересылаем сообщение

Но в нашем примере 3 группы тегов, а это значит, что мы будем копипастить 3 функции, но WET — плохая практика, поэтому выделим универсальные функции:

  • фильтр для хэндлера + получение ID темы для пересылки

  • функция пересылки

Объединим фильтр и получение ID, т.к. фильтр является часть логики получения ID.

Проверяем, что сообщение является ответом:

if message.reply_to_message is None:    return None

Чтобы получить ID темы, нам нужно пройтись по нашему словарю, и в зависимости от тега, вернуть необходимый ID. Т.к. в качестве значений у нас множества, чтобы не городить вложенные циклы, воспользуемся встроенной функцией any, которая возвращает True, если хотя бы один элемент множества проходит условие:

for thread_id, tags in THREAD_MAP.items():    if any(tag in message.text.lower() for tag in tags):        return thread_id

Таким образом, получаем первую готовую функцию:

def get_thread_id(message):    if message.reply_to_message is None:        return None    for thread_id, tags in THREAD_MAP.items():        if any(tag in message.text.lower() for tag in tags):            return thread_id

Далее приступаем к основной функции пересылки, по сути это будет та же функция reply_message_quotes(message), но в которую мы передаем thread_id, полученный из get_thread_id(message). Я предлагаю добавить сюда логирование, чтобы было видно, что наша пересылка работает + обработку исключений:

Настроим логирование, я это делаю в отдельном файле logging_config.py, кому-то это может показаться избыточным, но я частенько экспериментирую со своими ботами, поэтому настройки и переменные выношу в отдельные файлы, чтобы быстро найти нужную мне часть:

import loggingdef setup_logger():    logging.basicConfig(        level=logging.INFO,        format='%(asctime)s - %(levelname)s - %(message)s',        datefmt='%Y-%m-%d %H:%M:%S'    )    return logging.getLogger(__name__)

и не забываем импортировать в основной файл:

from logging_config import setup_loggerlogger = setup_logger()

Теперь возвращаемся к нашей функции пересылки. message.reply_to_message — укоротим, т.к. далее будем обращаться еще к атрибутам этого сообщения:

replied_message = message.reply_to_message

Мы можем пересылать разный контент, и мне было важно видеть, что именно пересылается:

        if replied_message.text:            logger.info(f"Пересылаем {replied_message.text} в {thread_id}")        else:            logger.info(f"Пересылаем {replied_message.content_type} в {thread_id}")

Затем идет наша функция:

bot.forward_message(chat_id=MAIN,        from_chat_id=message.chat.id,        message_id=message.reply_to_message.message_id,        message_thread_id=thread_id)

и логируем успешность:

logger.info("Сообщение успешно переслано")

Чтобы отследить что пошло не так, обернем это в try/except и получим в итоговом виде функцию:

def forward_to_thread(message, thread_id):    replied_message = message.reply_to_message    try:        if replied_message.text:            logger.info(f"Пересылаем {replied_message.text} в {thread_id}")        else:            logger.info(f"Пересылаем {replied_message.content_type} в {thread_id}")        bot.forward_message(chat_id=MAIN,                from_chat_id=message.chat.id,                message_id=message.reply_to_message.message_id,                message_thread_id=thread_id)        logger.info("Сообщение успешно переслано")    except Exception as e:        logger.error(f"Ошибка при пересылке: {e}", exc_info=True)

Теперь мы готовы собрать наш коротенький хэндлер в кучу:

@bot.message_handler(func=lambda message: get_thread_id(message) is not None)def send_message(message):    thread_id = get_thread_id(message)    forward_to_thread(message, thread_id)

Ну и финальная часть скрипта, настроить на постоянную прослушку чата:

if __name__ == '__main__':    logger.info("Погнали")    bot.infinity_polling()

Мы чуть не забыли про проксирование. Эта часть была делегирована (+ заслуживает отдельной статьи), поэтому предполагается, что у вас есть SSH-туннель до VPS Вносим адрес в файл с переменными:

HTTP=socks5://127.0.0.1:1080HTTPS=socks5://127.0.0.1:1080

Подгружаем переменные в основной файл:

HTTP = os.getenv('HTTP')HTTPS = os.getenv('HTTPS')

и настраиваем apihelper:

apihelper.proxy = {    'http': HTTP,    'https': HTTPS}

Собственно, это все: apihelper — тень, то есть работает, пока вы не видите, все запросы на взаимодействие с телеграмом проходят через него.

Итоговый скрипт выглядит так
import osfrom dotenv import load_dotenvfrom telebot import apihelper, TeleBotfrom logging_config import setup_loggerlogger = setup_logger()load_dotenv()TOKEN = os.getenv('TOKEN')MAIN = int(os.getenv('MAIN'))QUOTES = int(os.getenv('QUOTES'))PLAN = int(os.getenv('PLAN'))RECOMMEND = int(os.getenv('RECOMMEND'))HTTP = os.getenv('HTTP')HTTPS = os.getenv('HTTPS')THREAD_MAP = {    QUOTES: {'#цитат'},    PLAN: {'#план'},    RECOMMEND: {'#рецепт', '#читать', '#смотреть'},}apihelper.proxy = {    'http': HTTP,    'https': HTTPS}bot = TeleBot(TOKEN)def get_thread_id(message):    if message.reply_to_message is None:        return None    for thread_id, tags in THREAD_MAP.items():        if any(tag in message.text.lower() for tag in tags):            return thread_iddef forward_to_thread(message, thread_id):    replied_message = message.reply_to_message    try:        if replied_message.text:            logger.info(f"Пересылаем {replied_message.text} в {thread_id}")        else:            logger.info(f"Пересылаем {replied_message.content_type} в {thread_id}")        bot.forward_message(chat_id=MAIN,                from_chat_id=message.chat.id,                message_id=message.reply_to_message.message_id,                message_thread_id=thread_id)        logger.info("Сообщение успешно переслано")    except Exception as e:        logger.error(f"Ошибка при пересылке: {e}", exc_info=True)    @bot.message_handler(func=lambda message: get_thread_id(message) is not None)def send_message(message):    thread_id = get_thread_id(message)    forward_to_thread(message, thread_id)if __name__ == '__main__':    logger.info("Погнали")    bot.infinity_polling()

Теперь осталось его только запустить. Запускать будем в докере. О выборе способа запуска (контейнер, демон) и другие подробности вынесу в отдельную статью

Понадобится создать 3 файла:

requirements.txt
pyTelegramBotAPI==4.18.0python-dotenv==1.0.0equests[socks]
Dockerfile
FROM python:3.12-slimWORKDIR /appCOPY requirements.txt .RUN pip install --no-cache-dir -r requirements.txtCOPY script.py .COPY logging_config.py .CMD ["python", "script.py"]
docker-compose.yml
version: '3.12'services:  telegram-bot:    build: .    container_name: pizhma-bot    restart: unless-stopped    network_mode: host    env_file:      - .env    volumes:      - ./logs:/app/logs

Собираем образ, запускаем и наслаждаемся:

docker compose up -d --build

Осталось добавить бота в нужный чат и выдать ему права администратора.

ссылка на оригинал статьи https://habr.com/ru/articles/1039584/