Я уже частично писал об этом боте в своём канале, но здесь я решил раскрыть тему полностью с технической стороны, приведу примеры кода и конфигов.
На фоне своих ограничений по питанию, пока сижу на больничном и всячески остраняюсь от работы, решил сделать что-то полезное.
Знакомься, бот Гастролог — @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 — флаг явного разрешения или запрета.
Как это всё выглядит для пользователя:
Конечно, я оставил для себя закрытый функционал для служебных целей. Например, я могу добавлять в базу новые записи. А ещё я вижу больше данных о результатах поиска. Вот так выглядят те же поисковые запросы для меня:
Я пошёл простым путём:
- использовал пакет irazasyed/telegram-bot-sdk для работы с Telegram API;
- также нацепил пакет gigablah/sphinxphp для интеграции с движком sphinx и не стал заморачиваться с языком sphinxql;
- использовать фреймворки и другие готовые решения, просто написал скрипт с нуля на чистом пыхе и указал телеграму адрес к этому скрипту для отправки вебхуков.
По итогу, вот сильно упрощённый код (я оставил только полезную мякотку):
<?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