Telegram-бот Гастролог — справочник по первой диете (ака стол №1)

Этот пост был опубликован мной более года назад. Информация, описанная ниже, уже могла потерять актуальность, но всё ещё может быть полезна.

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

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

Знакомься, бот Гастролог — @gastrologbot

Пишешь ему напиток, продукт или блюдо, а он говорит можно его или нельзя в рамках диеты №1.

Как это работает

Стек — php, mysql, sphinxsearch и telegram api. Сервер работает как веб-хук: получает запрос от сервера телеграм с сообщением, определяет что это (команда или текст), выполняет нужное действие и отправляет соответствующий ответ.

Команда пока только одна — /start. А вот текст может быть любым. Любое текстовое сообщение интерпретируется как поисковый запрос. И дальше вступает sphinx, который:

  • с определённой периодичностью индексирует БД и строит поисковый индекс;
  • ищет входящую строку по этому индексу с использованием стемминга.

В БД mysql есть таблица:

CREATE DATABASE IF NOT EXISTS `gastrologbot` DEFAULT CHARACTER SET utf8 COLLATE utf8_general_ci;
USE `gastrologbot`;
CREATE TABLE `products` (
  `id` int(10) UNSIGNED NOT NULL,
  `name` varchar(500) NOT NULL,
  `description` text,
  `notice` text,
  `keywords` text,
  `is_ok` tinyint(1) NOT NULL DEFAULT '1'
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
ALTER TABLE `products` ADD PRIMARY KEY (`id`);
ALTER TABLE `products` MODIFY `id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT;

Опишу поля:

  • name — название блюда, продукта или напитка (например, «Мясо»)
  • description — описание, уточнение (например, «Говядина, куриная грудка»)
  • notice — примечание: способ и частота употребления, ограничения, на что обратить внимание (например, «Нельзя жирное мясо, с кожей»)
  • keywords — слова и словоформы для улучшения поиска, которые также описывают продукт или блюдо, но не встречаются в предыдущих полях в явном виде (например, «курочк,телят,коров»)
  • is_ok — флаг явного разрешения или запрета.

Как это всё выглядит для пользователя:

Конечно, я оставил для себя закрытый функционал для служебных целей. Например, я могу добавлять в базу новые записи. А ещё я вижу больше данных о результатах поиска. Вот так выглядят те же поисковые запросы для меня:

Я пошёл простым путём:

  1. использовал пакет irazasyed/telegram-bot-sdk для работы с Telegram API;
  2. также нацепил пакет gigablah/sphinxphp для интеграции с движком sphinx и не стал заморачиваться с языком sphinxql;
  3. использовать фреймворки и другие готовые решения, просто написал скрипт с нуля на чистом пыхе и указал телеграму адрес к этому скрипту для отправки вебхуков.

По итогу, вот сильно упрощённый код (я оставил только полезную мякотку):

<?php
use Sphinx\SphinxClient;
use Telegram\Bot\Api;
use Telegram\Bot\Exceptions\TelegramSDKException;
const EMOJI_TICK = "\xE2\x9C\x85 ";
const EMOJI_CROSS = "\xE2\x9D\x8C ";
require_once "vendor/autoload.php";
require_once "cfg.inc";
try {
    $telegram = new Api(BOT_TOKEN);
    $response = $telegram->getWebhookUpdate();
    $message_key = empty($response['edit_date']) 
        ? 'message' 
        : 'edited_message';
    if (empty($response[$message_key])) {
        exit;
    }
    $message_text = $response[$message_key]['text'];
    $reply_params['chat_id'] = $response[$message_key]['from']['id'];
    $reply_params['parse_mode'] = 'markdown';
    if ($message_text == "/start") {
        $response_text = "Напиши продукт, напиток или блюдо, а я подскажу можно ли тебе его принимать в пищу:";
    } elseif (mb_strlen($message_text) < 3) {
        $response_text = 'Введи хотя бы 3 символа. Только текст, без эмодзи, стикеров и т.п.';
    } elseif ($user_id == ADMIN_ID && strtolower($message_text) == '/cmd') {
        // ...
    } else {
        $search_string = mb_strtolower($message_text);
        $sphinx = new SphinxClient();
        $sphinx->setServer(SPHINX_HOST, SPHINX_PORT)->setConnectTimeout(2)->setMaxQueryTime(200);
        $sphinx->SetSortMode(SphinxClient::SPH_SORT_EXTENDED, "is_ok desc, name asc");
        $sphinx->SetFieldWeights([
            'name' => 30,
            'description' => 20,
            'keywords' => 10,
            'notice' => 1,
        ]);
        $search_result = $sphinx->query($search_string, 'idx_gastrologbot');
        if (!$search_result || empty($search_result['matches']) || empty($search_result['total_found'])) {
            $response_text = EMOJI_CROSS."Увы, сведений об этом не найдено.\nПопробуйте изменить запрос, либо обратиться к специалисту и другим источникам.";
        } else {
            foreach ($search_result['matches'] as $id => $match) {
                $icon = $match['attrs']['is_ok']
                    ? EMOJI_TICK // галочка
                    : EMOJI_CROSS; // крестик
                $is_ok = $match['attrs']['is_ok']
                    ? "Разрешено"
                    : "Запрещено";
                $description = empty($match['attrs']["description"])
                    ? ""
                    : $match['attrs']["description"]."\n";
                $notice = empty($match['attrs']["notice"])
                    ? ""
                    : "\n*Примечание:* ".$match['attrs']["notice"]."\n";
                $keywords = !empty($match['attrs']["keywords"]) && $user_id == ADMIN_ID
                    ? "\n*Ключевые слова:* ".$match['attrs']["keywords"]."\n"
                    : "";
                $name = $icon." ".($user_id == ADMIN_ID ? "[".$id."]" : "")." *".$match['attrs']["name"]." - ".$is_ok."*\n";
                $response_text .= $name.$description.$notice.$keywords."\n";
            }
            $response_text = trim($response_text)."\n\n*ПОМНИ*: вся информация взята из открытых источников. Необходима консультация специалиста!";
        }
    }
    $reply_params['text'] = $response_text;
    $telegram->sendMessage($reply_params);
} catch (Exception $e) {
    throw $e;
}

Ниже конфиг сфинкса (установить и настроить самого сфинкса можно по этой шпаргалке):

# настройка источника данных для поискового индекса
source src_gastrologbot
{
    type = mysql
    sql_host = localhost
    sql_db = database
    sql_user = database_user
    sql_pass = database_pass
    sql_port = 3306
    sql_query_pre = SET NAMES utf8
    sql_query_pre = SET CHARACTER SET 'utf8'
    sql_query = SELECT id, name, description, notice, keywords, is_ok FROM products
    sql_field_string = name
    sql_field_string = description
    sql_field_string = notice
    sql_field_string = keywords
    sql_attr_bool = is_ok
}
# настройки индекса
index idx_gastrologbot
{
    source = src_gastrologbot
    path = /home/user/sphinxdata/gastrologbot/main
    min_word_len = 2
    index_exact_words = 0
    morphology = stem_ru, stem_en
    min_stemming_len = 2
    ignore_chars = U+00AD
    charset_table  = 0..9, english, russian, _
    expand_keywords = 1
    html_strip = 1
    wordforms = /home/user/sphinxdata/gastrologbot/wordforms.txt
}

Словоформы

Они заслуживают отдельного описания: когда поймёшь что это и зачем, становится очень легко настроить.

Вот список словоформ, которые настроены для Гастролога в данную секунду:

~гречк > греч
~гречн > греч
~манк > манн
~перловк > перлов
~тыквен > тыкв
~картох > картоф
~грибочк > гриб
~пшенич > пшен
напиток > напит
~напито > напит
~напитк > напит
~горош > горох
копчёное > копчен
копчёные > копчен
~вермиш > макарон
~говяж > говяд
~теляч > телят
~селедк > сельдь
консерва > консерв
консервы > консерв
~семечк > семя
~семен > семя
~яблочн > яблок
~маслят > масленок

Если ты обратишь внимание, в каких-то строчках есть тильды (~), а где-то нет. Ставь тильду перед словоформой, чтобы дать стеммеру отработать поисковый запрос. То есть, слова «говяжье» и «говяжий» превратится в «говяд». Именно «говяд» и будет искаться в индексе. Это полезно для тех случаев, когда одно слово может быть написано в разных формах.

Например, есть три слова об одном и том же: семя (ед. ч. = мн. ч.), семена (семя во мн. ч.) и семечки (семячко в ед. ч.). Причём первое используется и в единственном, и во множественном числах. Семена подсолнечника или тыквы мы почти всегда называем «семечки», а вот в аптеке мы встретим «Семя льна».

Как я решил этот кейс? В keywords продуктов я добавил «семя» и к этому же слову привожу все совпадения с «семен» и «семечк». Этот пример ты можешь увидеть в списке словоформ выше.

Заключение

Вся связка не идеальна, и у меня есть мысли как её можно улучшить. Также есть куча мыслей по функционалу. Поиск работает отлично, но всплывает нехватка

Приблизительно так и устроен Гастролог изнутри. На этом всё. Больше показать нечего 🙂

Если не хватило информации, то можно найти её здесь:

Использованные материалы

https://habr.com/ru/company/netologyru/blog/326174/

https://telegram-bot-sdk.readme.io/docs/initial-setup

https://telegram-bot-sdk.readme.io/reference

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *