Простой инфо-бот с многоуровневым меню на InlineKeyboardButton

Привожу простой пример инфо-бота с практически неограниченной вложенностью. Вывод на экран текстового и медиа (картинка, видео, аудио, документ) сообщения. Многоуровневое меню включено.

View original


Хотел привести пример как отобразить в Телеграм многоуровневое меню, но получился простой в создании информационный бот с вложенностью, ограниченной только возможной длинной значения в параметре callback_data inline-кнопки (1-64 bytes).

Еще интересный момент это — в одном боте можно создать неограниченное количество сценариев, для этого достаточно просто стартовать бота по html-ссылке со специальными параметрами. При обычном старте бота — запускается первый сценарий. Об этом чуть ниже.

Настройки

В настройках бота нужно указать токен бота, id админа бота и заполнить массив с информацией о «шагах».

steps 
   |__
   |__
      |__
      |__
      |__
   |__
      |__
         |__
   |__
 

Каждый шаг имеет простой набор параметров:

[
    "name" => "", // string
    "line" => 0, // int
    "type" => "text|photo|video|audio|document", // string
    "text" => "", // string
    "media" => "" // string | null,
    "steps" => [] // array
]
  1. name* — Название шага, будет отображено на кнопке
  2. line* — уровень ряда в наборе кнопок
  3. type*- тип сообщения (text|photo|video|audio|document)
  4. text — текстовое сообщение, обязательно для type="text"
  5. media — ссылка или file_id медиа файла, при type="text" должно быть значение null
  6. steps — это массив вложенных шагов,


* — обязательный параметр

Для ускорения отрисовки экранов желательно в параметре media указывать file_id файла, он для каждого бота уникальный, поэтому из моего примера медиа у вас не будут подгружаться — их надо будет заменить.

Чтобы вам получить file_id я добавил небольшой функционал (только если указана настройка bot_admin), нужно просто отправить в бот файл: документ, картинку, видео или аудио файлы.

В ответ бот пришлет строку — она же и будет file_id, просто скопируйте ее и подставьте в параметр media в нужном шаге (массива $steps).

* * *

Что из интересного?

Весь контент бота упакован в массив steps он же $content. Важным моментом является конечно же валидность массива.

При старте бота настроен выбор сценария под индексом 0 массива (можно указать любой).

/**
 1 параметр это индекс элемента массива
 2 параметр это индексы родительских элементов массива steps, разделены тире (-), если null то выводим из верхнего уровня вложенности
 3 параметр это id чата пользователя
*/
$printUpdate(0, null, $chat_id);

Если старт по HTML-ссылке то сценарий будет выбран из параметров ссылки

tg://resolve?domain=iMakeBot&start=s_3_0-1

где, 
domain — это username вашего бота
start — это значение для выбора сценария

Параметр start имеет 3 вложенных параметра разделенных знаком нижнего подчеркивания (_), где
1 подпараметр — это action, он всегда будет s
2 подпараметр — это индекс элемента массива steps
3 подпараметр — это индексы родительских элементов массива steps, разделены тире (-)

3 подпараметр может быть пустым, то есть можно передать только первые 2 подпараметра (s_0 или s_3)

Если разбирать приведенный в ссылке пример параметра start (s_3_0-1) то это будет означать, что на экран по ссылке выведется элемент массива steps 

$content['steps'][0]['steps'][1]['steps'][3];
// в раскрытом виде
$content = [
    'steps' => [
        [
            'name' => 'Название элемента 0',
            // ... 
            'steps' => [
                [
                    'name' => 'Название элемента 0-0'
                    // ...
                ],
                [
                    'name' => 'Название элемента 0-1',
                    // ...
                    'steps' => [
                        [
                            'name' => 'Название элемента 0-1-0'
                            // ...
                        ],
                        [
                            'name' => 'Название элемента 0-1-1'
                            // ...
                        ],
                        [
                            'name' => 'Название элемента 0-1-2'
                            // ...
                        ],
                        [
                            'name' => 'Название элемента 0-1-3'
                            // ... Вот этот элемент будет отработан для вывода на экран
                        ],
                    ]
                ]
            ]
        ]
    ]
]

Чтобы получить из массива нужный элемент, пропускаем запрос через рекурсивную (самовызывающуюся) функцию

/** Получаем контент
 * @param $step_idx
 * @param $parents
 * @param $data
 * @return array
 */
$getContent = function ($step_idx, $parents, $data) use (&$getContent) {
    // определим результат по умолчанию
    $result = null;
    // проверим родительские элементы
    if (!is_null($parents)) {
        // получим первого по списку родителя
        $parent = array_shift($parents);
        // проверим наличие родителя в массиве
        if (isset($data['steps'][$parent])) {
            // проверим путь - если еще остались в списке 
            if (count($parents)) {
                // отправим на рекурсию - подставив новые параметры
                $result = $getContent($step_idx, $parents, $data['steps'][$parent]);
            } else {
                // определим результат
                $result = $data['steps'][$parent]['steps'][$step_idx];
            }
        }
    } else {
        // определим результат
        $result = $data["steps"][$step_idx];
    }
    // вернем результат
    return $result;
};

* * *

Отрисовка экрана

/** Выводим сообщение по запросу
 * @param $step_idx
 * @param $parents
 * @param $chat_id
 * @param null $cbq_id
 * @param null $message_id
 */
$printUpdate = function ($step_idx, $parents, $chat_id, $cbq_id = null, $message_id = null) use ($getContent, $query, $notice, $content) {
    // переопределим вложенность
    $parents = !is_null($parents) ? explode("-", $parents) : null;
    // получаем шаг
    $step = $getContent($step_idx, $parents, $content);
    // проверим
    if (!is_null($step)) {
        // готовим данные
        $data = [
            "chat_id" => $chat_id,
        ];
        // если это нажатие по кнопке то удалим текущее сообщение
        if (!is_null($cbq_id)) {
            // гасим запрос
            $notice($cbq_id);
            // удаляем текущее сообщение
            $query("deleteMessage", array_merge($data, ["message_id" => $message_id]));
        }
        // дополним данные
        $data["parse_mode"] = "html";
        // определим кнопки если они есть
        $buttons = [];
        // проверим
        if (count($step['steps'])) {
            // определим путь
            $parents_ = !is_null($parents) ? implode("-", array_merge($parents, [$step_idx])) : $step_idx;
            // переберем
            foreach ($step['steps'] as $key => $next) {
                // добавим кнопку
                $buttons[$next['line']][] = [
                    "text" => $next['name'],
                    "callback_data" => "s_" . $key . "_" . $parents_
                ];
            }
        }
        // кнопка вернуться
        if (!is_null($parents)) {
            // получим первого
            $parent = array_pop($parents);
            // добавим кнопку последним рядом
            $buttons[count($buttons)][] = [
                "text" => "Вернуться",
                "callback_data" => "s_" . $parent . "_" . implode("-", $parents)
            ];
        }
        // проверим добавление кнопок
        if (count($buttons)) {
            // добавим кнопки
            $data["reply_markup"] = json_encode(['inline_keyboard' => array_values($buttons)]);
        }
        // поддерживаемые типы
        if (!is_null($step['media']) && in_array($step['type'], ['photo', 'video', 'audio', 'document'])) {
            // проверим описание
            if (!empty($step['text'])) {
                // добавим описание
                $data['caption'] = $step['text'];
            }
            // добавим медиа
            $data[$step['type']] = $step['media'];
            // отправим сообщение
            $query("send" . ucfirst($step['type']), $data);
        } elseif ($step['type'] === "text" && !empty($step['text'])) {
            // добавим текст
            $data['text'] = $step['text'];
            // отправим сообщение
            $query("sendMessage", $data);
        } else {
            // выведем ошибку о не поддерживаемом методе
            $query("sendMessage", array_merge($data, ["text" => "Sorry, error 405"]));
        }
    } else {
        // проверим на нажатие кнопки
        if (!is_null($cbq_id)) {
            // выведем уведомление
            $notice($cbq_id, "Error 404 STEP");
        }
    }
};

* * *

Простой роутер для бота

/**
 * Простой роутер бота
 */
if (isset($data->message)) {
    // получим id чата
    $chat_id = $data->message->from->id;
    // если это текстовое сообщение
    if (isset($data->message->text)) {
        // проверим что это старт бота
        if ($data->message->text == "/start") {
            // выводим сообщение
            $printUpdate(0, null, $chat_id);
        }
        // если это старт по ссылке
        elseif (preg_match("~\/start s_([\d]+)_?([\d-]*)~", $data->message->text, $matches)) {
            // выведем сообщение по ссылке
            $printUpdate($matches[1], $matches[2], $chat_id);
        }
    }
    // другие типы сообщений
    else {
        // если это админ бота направляет сообщение
        if ($chat_id === $bot_admin) {
            // по умолчанию
            $file_id = null;
            // если это картинка
            if (isset($data->message->photo)) {
                // file_id последней картикни
                $file_id = end($data->message->photo)->file_id;
            } 
            // если это видео-файл
            elseif (isset($data->message->video)) {
                // file_id видео-файла
                $file_id = $data->message->video->file_id;
            } 
            // если это аудио-файл
            elseif (isset($data->message->audio)) {
                // file_id аудио-файла
                $file_id = $data->message->audio->file_id;
            } 
            // если это документ
            elseif (isset($data->message->document)) {
                // file_id документа
                $file_id = $data->message->document->file_id;
            }
            // проверим необходимость отправки
            if (!is_null($file_id)) {
                // отправим file_id
                $query("sendMessage", [
                    "chat_id" => $chat_id,
                    "text" => $file_id
                ]);
            }
        }
    }
// если это нажатие по кнопке
} elseif (isset($data->callback_query)) {
    // получим id чата
    $chat_id = $data->callback_query->from->id;
    // получим callBackQuery_id
    $cbq_id = $data->callback_query->id;
    // получим переданное значение в кнопке
    $c_data = $data->callback_query->data;
    // спарсим значения
    $params = explode("_", $c_data);
    // если это переход по шагам
    if ($params[0] == "s") {
        // выводим сообщение
        $printUpdate(
            $params[1],
            ($params[2] !== "")
                ? $params[2]
                : null,
            $chat_id,
            $cbq_id,
            $data->callback_query->message->message_id
        );
    }
    // если это другие кнопки
    else {
        // заглушим просто запрос
        $notice($cbq_id, "This is notice for bot");
    }
}

* * *

Исходный код бота

Бот настроен под работу с Webhook