Простой Telegram бот, который задаёт 1 вопрос +6


gimp творчество
gimp творчество

Основная идея бота - это противодействие спам - регистрациям в группе Telegram.

Ниже расскажу о процессе создания Telegram бота который выполняет простую функцию — задаёт новому пользователю группы вопрос и предоставляет возможностью выбрать ответ на него.

Для всех желающих увидеть код сразу и целиком - добро пожаловать в конец статьи ( там есть ссылки)

Инструменты

  • PHP 7.4

  • Laminas Framework (бывший Zend Framework)

  • Библиотека php-telegram-bot/core

  • Документация с официального сайта Telegram

  • Созданный бот через @BotFather в вашем «месенджере»

  • Postman — для тестирования не прибегая к смартфону

  • Docker (для локального тестирования)

  • Обычный бюджетный Российский хостинг (для боевого тестирования)

Всё началось с того что одна из моих групп доросла до некоторого количество пользователей и стала интересна для различных спам сервисов. Удаление спам сообщений не составляет труда, но постоянно мониторить группы на предмет таких сообщений не самый лучший способ провести время. При этом давно хотел попробовать указанные выше инструменты для реализации хоть сколько полезного бота Telegram.

Идея с тем что бы задавать вопрос пользователю при помощи бота, далеко не новая и успешно была реализована в таких сервисах - ботах как Combot, Terminator (не помню точное название, там ещё мужик похожий на Арнольда в куртке был на логотипе) и другие.

Как это должно работать в теории

  1. Пользователь (человек, авто-спам, злой бот) вступает в сообщество

  2. Бот (добрый бот) — администратор сообщества реагирует на каждое новое вступление и задаёт простой вопрос. (Кто ты?). При этом есть только два варианта ответов — «Я человек» и «Я робот». Так же бот забирает все права и разрешения у пользователя в этой группе.

  3. По сути можно оставить только одну кнопку - «Я человек» и предположить, то что ответить, не ткнув на кнопку пальцем не представляется возможным.

  4. После нажатия на кнопку с ответом, backend этого бота обрабатывает ответ и принимает дальнейшее решение.

  5. Если ответ принадлежит пользователю вступившему в группу — и ответ «Я человек», пользователю возвращаются права и разрешения в группе в которую он вступил.

  6. Бот что-нибудь пишет, например, «добро пожаловать»

Для понимания того что происходит в группе необходимо обрабатывать json-ы которые сервис Telegram будет присылать на URL адрес (webhook) закреплённый за созданным вами ботом.

Достаточно вольное изображение схемы
Достаточно вольное изображение схемы

Наш backend обрабатывает все сообщения приходящие от сервиса Telegram, и по мере необходимости наступления некоторых событий формирует запросы к сервису Telegram. В свою очередь последний — через вашего бота доводит эти команды до группы (где вы можете видеть результат).

Для реализации взаимодействия с сервисом Telegram есть несколько API библиотек на языке PHP

  1. danog/MadelineProto — Мощная, гибкая, с асинхронными «фичами».

  2. php-telegram-bot/core — Выбрал её, так как захотелось попробовать что то новое. Как оказалась она достаточно простая для понимания, достаточно пробежаться по ней xdebug-ом

Немного о php-telegram-bot/core

Вся «сила» библиотеки крутится возле одного крупного класса class Longman\TelegramBot\Telegram. При этом есть возможность создавать отдельные обработчики поступающих json-ов через создание файлов-классов команд.

При инициализации объекта класса Longman\TelegramBot\Telegram необходимо указать путь к папке с вашими обработчиками команд, что бы затем в этих обработчиках описать всю необходимую логику реагирования на события происходящие в вашей группе (события обновления группы).

Так же что бы лаконично внедриться в этот механизм взаимодействия с библиотекой, потребовалось расширить базовый класс библиотеки своим собственным, добавить новый метод для встраивания Interop\Container\ContainerInterface от Laminas и организовать фабрику для удовлетворения всех зависимостей.

Немного кода - реализации

Код сильно упрощён (В конце будут ссылки на репозитарий с модулем и отдельно приложением Laminas)

Пример обработчика поступающих команд

<?php
use Longman\TelegramBot\Commands\SystemCommand;
use Longman\TelegramBot\Entities\ServerResponse;
use Longman\TelegramBot\Request;

class GenericmessageCommand extends SystemCommand
{
    /**
     * @var string
     */
    protected $name = 'genericmessage';
    
    protected $description = 'Handle generic message';
    
    protected $version = '1.0.0';
    
    public function execute(): ServerResponse
    {
        /** @var \Longman\TelegramBot\Entities\Message $message */
        $message = $this->getMessage();
        
        // Здесь какие либо действия или обработки
        
        return Request::emptyResponse();
    }
}

В момент поступления сообщения от сервиса Telegram - библиотека вызовет необходимый обработчик из указанной вами папки. Внутри этого обработчика можно получить данные о сообщение, отправителе, получателе, а так же сформировать новое сообщение для отправки — в группу от имени бота.

Так образом, мы можем получить уведомление о новом событие в группе. В данном случае нас интересует событие связанное с вступлением в группу нового пользователя.

Теперь необходимо сформировать сообщение которое будет иметь вопрос и две кнопки с ответом.

Ниже код, формирующий две кнопки. Поле callback_data — содержит значение ответа. В нашем случае это некоторая строка к которой добавлен (методом конкатенации) id вступившего в группу пользователя. Значение id пользователя добавляется в объект при его создании в вызывающем коде.

<?php
use Longman\TelegramBot\Entities\InlineKeyboard;
use QuestionKeyboardMap;

class KeybordQuestion
{
    
    protected $curentUserId = '';
    
    /**
     * Вернёт клавиатуру с вопросом для пользователя
     * @return array [reply_markup => InlineKeyboard]
     */
    public function getQuestion()
    {
        
        $keyboard = new InlineKeyboard([
            ['text' => 'Я бот!', 'callback_data' => QuestionKeyboardMap::CALLBACK_ANSWER_BOT.$this->curentUserId],
            ['text' => 'Я человек!', 'callback_data' => QuestionKeyboardMap::CALLBACK_ANSWER_HUMAN.$this->curentUserId],
        ]);
        
        return ['reply_markup' => $keyboard];
        
    }
    public function setCurentUserId(string $curentUserId)
    {
        $this->curentUserId = $curentUserId;
        return $this;
    }
}

Пример обработки события о вступлении нового пользователя в группу. В данном случае используется триггер события framework-а Laminas, что бы выполнить всю логику обработки этого кода в другой части модуля.

<?php
use TelegramApi;
use Laminas\EventManager\EventManager;
use Longman\TelegramBot\Commands\SystemCommand;
use Longman\TelegramBot\Entities\ServerResponse;
use Longman\TelegramBot\Exception\TelegramException;
use Events;
use Longman\TelegramBot\Request;

class NewchatmembersCommand extends SystemCommand
{
    /**
     * @var string
     */
    protected $name = 'newchatmembers';
    
    /**
     * @var string
     */
    protected $description = 'New Chat Members';
    
    /**
     * @var string
     */
    protected $version = '1.3.0';
    
    public function execute(): ServerResponse
    {
        /** @var TelegramApi $this->telegram */
        /** @var \Laminas\EventManager\EventManager $eventManager */
        $eventManager = $this->telegram->getServiceManager()->get(EventManager::class);
        /** @var \Laminas\ServiceManager\ServiceManager $serviceManager */
        /** @var \Longman\TelegramBot\Entities\Message $message */
        $message = $this->getMessage();
        /** @var array $members */
        $members = $message->getNewChatMembers();
        
        $eventManager->trigger(
            Events::NEW_USER_SENT_REQUEST_TO_JOIN_GROUP,
            null,
            ['message' => $message,'members' => $members]
        );
    
        return Request::emptyResponse();
    }
    
}

Подписываемся на событие при инициализации модуля и обрабатываем его в методе processRequestToJoinGroup класса Events

<?php
use Laminas\EventManager\EventManager;
use Laminas\ModuleManager\Feature\ConfigProviderInterface;
use Laminas\Mvc\MvcEvent;

class Module implements ConfigProviderInterface
{
    
    public function onBootstrap(MvcEvent $e)
    {
        /** @var \Northmule\Telegram\Events\Events $eventsService */
        $eventsService = $e->getApplication()->getServiceManager()->get(Events::class);
        $eventManager = $e->getApplication()->getServiceManager()->get(EventManager::class);

        $eventManager->attach(EventsMap::NEW_USER_SENT_REQUEST_TO_JOIN_GROUP,[$eventsService,'processRequestToJoinGroup']);

    }

}

Теперь необходимо отправить приветственное сообщение новому пользователю и добавить к сообщению наши кнопки с ответами. А так же лишить пользователя всех прав в группе до того момента пока он не предоставит ответ на вопрос.

Большой код
<?php

use Laminas\EventManager\Event;
use Longman\TelegramBot\Entities\Message;
use Longman\TelegramBot\Entities\User;
use Longman\TelegramBot\Entities\User as UserEntities;
use Longman\TelegramBot\Request;
use KeybordQuestion;
use TelegramRestrict;


class Events
{

    /**
     * Обработка действия пользователя на вступление в группу
     * @param Event $event
     */
    public function processRequestToJoinGroup(Event $event)
    {
        $message = $event->getParam('message',null);
        $members = $event->getParam('members',[]);
        if (!($message instanceof Message)) {
            return;
        }
        
        /** @var KeybordQuestion $keybord */
        $keybord = $this->serviceManager->get(KeybordQuestion::class); // Наша клавиатура
        $keybord->setCurentUserId((string)$message->getFrom()->getId()); // Вступивший пользователь
        /** @var  TelegramRestrict $restrictionService */
        $restrictionService = $this->serviceManager->get(TelegramRestrict::class);
        $question = $keybord->getQuestion();
        try {
            // Ограничить нового пользователя в правах, до ответа на вопрос
            $restrictionService->setRestrict($message->getChat()->getId(),$message->getFrom()->getId());
            
            if ($message->botAddedInChat()) { // Если это бот
                return  Request::sendMessage([
                    'chat_id' => $message->getChat()->getId(),
                    'text'    => "Привет бот!",
                    'disable_notification' => true,
                ]);
            }
            
            $member_names = [];
            /** @var UserEntities $member */
            foreach ($members as $member) {
                $member_names[] = $member->tryMention();
            }
            
            return  Request::sendMessage(
                array_merge([
                    'chat_id' => $message->getChat()->getId(),
                    'text'    => 'Привет! ' . implode(', ', $member_names) . '! Скажи, кто ты?',
                    'disable_notification' => true,
                ],$question)
            );
        } catch (\Exception $e) {
            $this->logger->err($e->getMessage(),$e->getTrace());
            return;
        }
        
        
    }

}

Дальше, нужно обработать ответ от пользователя, снять ограничения и написать ему что нибудь в ответ, например «Добро пожаловать»

Опять большой код
<?php

use Laminas\EventManager\Event;
use Laminas\EventManager\EventManager;
use Longman\TelegramBot\Entities\CallbackQuery;
use Longman\TelegramBot\Entities\Message;
use Longman\TelegramBot\Entities\User;
use Longman\TelegramBot\Request;
use EventsMap;
use QuestionKeyboard;
use TelegramRestrict;


class Events
{

    /**
     * Проверка ответа пользователя на вопрос от Бота
     */
    public function checkUsersResponse(Event $event)
    {
        $message = $event->getParam('message',null);
        $user = $event->getParam('user',null);
        $callback = $event->getParam('callback', null);
        
        if (!($message instanceof Message)) {
            return;
        }
        if (!($user instanceof User)) {
            return;
        }
        if (!($callback instanceof CallbackQuery)) {
            return;
        }
        
        $approved = false;
        
        if ($callback->getData() === QuestionKeyboard::CALLBACK_ANSWER_HUMAN.$user->getId()) {
            $approved = true;
        }
        
        /** @var  TelegramRestrict $restrictionService */
        $restrictionService = $this->serviceManager->get(TelegramRestrict::class);
        
        try {
            if ($approved) {
                $eventManager = $this->serviceManager->get(EventManager::class);
                $eventManager->trigger(
                    EventsMap::THE_NEW_USER_ANSWERED_CORRECTLY,
                    null,
                    ['user' => $user,'message' => $message]
                );
                // Снимаем ограничения
                $restrictionService->unsetRestrict($message->getChat()->getId(),$user->getId());
                
                // Удаление сообщения
                Request::deleteMessage(
                    [
                        'chat_id' => $message->getChat()->getId(),
                        'message_id' => $message->getMessageId(),
                    ]
                );
                
                // Отправляем сообщение
                return  Request::sendMessage([
                    'chat_id' => $message->getChat()->getId(),
                    'text'    => "Добро пожаловать!",
                    'disable_notification' => true,
                ]);
                
            }
        } catch (\Exception $e) {
            $this->logger->err($e->getMessage(),$e->getTrace());
            return;
        }
        
    }

}

Это метод так же вызывается как триггер на событие созданное при инициализации модуля Laminas. (В рамках статьи этот код здесь не указан)

Так же в этом методе есть триггер успешного прохождения проверки пользователя (здесь пример реализации не приводится) — его можно использовать для записи каких-либо данных о пользователе в базу данных.

Как это работает на самом деле, и какие есть оправданные сомнения

  • «Хороший» пользователь вступает в группу, получает вопрос и отвечает «Я человек». У него есть все возможности для общения в группе и за ним больше ни кто не наблюдает и его действия ни как не обрабатываются. Хотя при желании можно и дальше отслеживать, сервис Telegram будет отправлять изменения в группе на webhook.

  • «Плохой бот» вступает в группу (именно бот в понятии Telegram), эта «единица» пользователей отмечены специальным полем. Такие по задумке блокируются на «подлёте» без дополнительных вопросов.

  • «Плохой» пользователь (чаще всего автоматизированный) — вступает в группу и кидает сообщение с здоровенной картинкой (сейчас модно почему то про Биткоин спамить таким способом). Вот тут возникает предположение, что наш «добрый» бот не успеет сделать всё как надо, потому - что ему потребуется время на отправку команды для лишения пользователя всех прав в группе и отправка команды с приветствием и вопросом. (два запроса, в обоих случаях это вызов Longman\TelegramBot\Request). И с большей долей вероятности «Плохой» пользователь сможет выполнить свой корыстный спам-запрос и нагадить в нашу группу между Request.

  • Если групп очень крупная, популярная, тогда есть вероятность что сообщения по линии webhook от сервиса Telegram на наш backend встанут в очередь и часть пользователей не сможет дать ответ на вопрос (тупо запрос повиснет в воздухе) или по достижению 100-а запросов в единицу времени, 101-й будет проходить мимо «доброго» бота. От части можно снизить очередь сообщений об обновлениях в группе, указав при создании webhook-а какие обновления от сервиса Телеграм отправлять (через параметр allowed_updates)

Какие ещё были мысли перед реализацией и что остаётся за рамками сейчас

Для проверки отправителя ответа нужно записывать информацию в базу данных. Т.е с начало сохранить данные о попытке вступления, потом сверить эти данные с теми что придут после ответа на вопрос (если они придут).

Показалось что это слишком сложно, и как указал выше, можно просто «приплюсовать» конкатенировать id пользователя к значению ответа перед формированием кнопок с ответами. Потом сопоставить ответ с тем значением от кого пришёл ответ.

Пример Json от сервиса Telegram c полем конкатенации строки и ИД пользователя
Пример Json от сервиса Telegram c полем конкатенации строки и ИД пользователя

Удалять все сервисные сообщения от бота. Группа может быть просто завален вопросами от бота без ответа. Так как при нормальной логике (после верного ответа), вопрос удаляется. Для очистки нужно подключать cron, а так же вести лог с номерами сервисных сообщений в базе данных.

Что делать если бот не доступен, тогда новые пользователи в чат не попадут без ручного модерирования.

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

Это не будет работать. Да, действительно, решение достаточно спорное и пока оно тестируется в весьма «тепличных» условиях.

Скриншоты
Перед вступлением
Перед вступлением
Пользователь собрался вступить в группу
Пользователь собрался вступить в группу
Пользователь ответил - Я человек
Пользователь ответил - Я человек

Ссылки

  • Библиотека для запросов а API Telegram php-telegram-bot/core здесь

  • Весь код модуля бота для Laminas здесь

  • Скелетон приложения Laminas с модулем для развёртывания здесь

Благодарность за тестирование @PabloR

P.S.: Весь код в примерах на PHP, выбрать язык не позволил новый редактор, отправил баг в специальный раздел




К сожалению, не доступен сервер mySQL