Как мы создали Telegram-бота для заказа шавермы. Опыт Selectel

Как сделать бота для заказа шавермы и оставить голодными лишь 1,1% коллег

Владимир Туров Владимир Туров Разработчик 23 июня 2023

В этой статье расскажем про традицию Шавадея в компании и про то, как мы автоматизировали ее с помощью Telegram-бота.

Изображение записи

Полтора года назад в Selectel появилась традиция есть шаверму по четвергам. Акция, названная Шавадеем, быстро обрела популярность. С увеличением количества адептов ее организационные моменты — в частности, сбор и отправка заказов — становились все сложнее. На помощь позвали программиста — меня.

В тексте я буду приводить фрагменты кода для решения поставленных задач. Они написаны на языке программирования Python с использованием фреймворка Python Telegram Bot 20.0. Для веб-интерфейсов используется FastAPI.

Истоки традиции

Традицию собираться и кушать шаверму по четвергам принес наш старший сетевой инженер — Владимир Романенко. Сперва традиция существовала только внутри сетевого отдела, но 23 декабря 2021 года шавадей «вырвался» и стал доступен для всех коллег в офисах Санкт-Петербурга. Сбор заказов был организован в канале #random корпоративного мессенджера в формате голосования, а список блюд — в комментариях к опросу.

Рабочий процесс (workflow) шавадея примерно такой:

  1. Организатор собирает заказы в выбранном мессенджере.
  2. Организатор оформляет онлайн-заказ на еду.
  3. Организатор своими силами доставляет еду в офис.
  4. Коллеги приходят на кофе-поинт, разбирают свои заказы и переводят деньги организатору.

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

  • Меню вынесли в страницу на telegra.ph, а ссылку на страницу — в описание группы.
  • Telegram не показывает результаты до голосования и не позволяет «воздержаться», так что появился пункт «Мне бы только кнопку нажать».

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

  • Опрос нужно создавать каждый раз заново, прописывая все позиции меню. Описание каждой позиции в пункт опроса не влезет, а ссылки в заголовке не кликабельны. 
  • Сводные результаты опроса выводят процентное соотношение вариантов. Точное количество доступно по кнопке «посмотреть результаты», но это окно содержит избыточное количество информации и не влезает в экран.
  • Заказ через опрос ограничивает меню до девяти самых популярных позиций. Если хочется что-то менее популярное, необходимо договариваться с организатором, а ему, в свою очередь, нужно их записать или запомнить.

25 мая 2022 мастер шавадея бросил клич в #random: нет ли в компании того, кто может поделиться исходниками бота для заказа еды? Или может быть, кто-то поможет разработать бота под традицию?

Я раздумывал недолго и вызывался помочь. Тем более, что в тот момент как раз только вышло обновление BotAPI 6.0, которое мне хотелось потрогать. Это обновление добавляет WebApp — идеальную технологию для магазинов. Я думал, что сделаю все быстро, качественно и удобно.

Новая надежда

Впервые за долгое время мой pet-проект будет не для друзей, не для обучения, а для сотни настоящих пользователей, у которых разные навыки обращения с Telegram. Перед началом разработки я определил принципы, которым следую до сих пор:

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

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

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

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

Магазин в Telegram

Официальная документация Telegram радовала не только красивыми анимациями, но и демонстрацией в @DurgerKingBot. Разочарование, впрочем, не заставило себя ждать: в BotAPI 6.0 приложения WebApp-кнопки можно создавать только в личных сообщениях.

В отличие от DurgerKingBot’а, где пользователь делает заказ на себя в любое время, в моем магазине можно делать заказ только в четверг и только если организатор на работе — готов отправить заказ и доставить еду. Также ботом заинтересовались в соседнем офисе, так что из ЛС бота можно было заказать что-то не то или не там.

Делаю вид, что я хороший менеджер, и узнаю заинтересованность в «фиче».
Делаю вид, что я хороший менеджер, и узнаю заинтересованность в «фиче».

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

Получилось так:

  1. Бот создает опрос для тех, кто не хочет изменений.
  2. Бот создает сообщение с одной кнопкой «Заказать» и закрепляет это сообщение.

Такой подход фактически разбивал всех пользователей на староверов и любителей прогресса. А еще создавал новые проблемы.

Проблемы любителей прогресса

Как я отмечал ранее, чтобы добраться до формы заказа, необходимо было выполнить ряд действий. Сперва бот должен создать кнопку, которая ведет в ЛС с ботом:


    async def create_event(update: Update, context: CallbackContext):
    # Некоторая логика создания события
    keyboard = [
        [
            InlineKeyboardButton("Заказать", url=f"https://t.me/{context.bot.username}?start={chat_id}")
        ],
    ]
    markup = InlineKeyboardMarkup(keyboard)
    await order_message.edit_reply_markup(markup)

Нажатие на ссылку со start-параметром открывает ЛС с ботом с кнопкой «СТАРТ». При этом кнопка появляется в любом случае, даже если вы уже активировали бота и вели с ним переписку.


    async def start_order(update: Update, context: CallbackContext):
    user_id = update.message.from_user.id
    chat_id = update.message.text[7:]
    base_url = context.bot_data["base_url"]
    web_app = WebAppInfo(
        url=f"{base_url}/menu?chat_id={chat_id}"
    )
    keyboard = [
        [InlineKeyboardButton("Открыть меню", web_app=web_app)]
    ]
    markup = InlineKeyboardMarkup(keyboard)
    await update.message.reply_text(
        "Расширенное меню доступно по кнопке под сообщением.\n"
        "\n"
        "Обратите внимание, что учитывается только последний сделанный заказ.\n"
        "\n"
        "При первом открытии вас предупредят, что приложение может получить доступ к вашему IP-адресу. "
        "Но вы же все равно из офиса, у вас одинаковый и уже давно известный адрес...\n",
        reply_markup=markup
    )
async def start(update: Update, context: CallbackContext):
    if update.message.text == "/start":
        return
    return await start_order(update, context)
start_handler = CommandHandler("start", start, filters.ChatType.PRIVATE)

Если для бота передан start-параметр, то клиент отправит обычное /start, а бот увидит этот аргумент в тексте. Магия Telegram! Если параметр есть, то формируем ссылку для WebApp и отвечаем сообщением с кнопкой.

Для первой версии WebApp’а я взял приложение DurgerKing’а и заменил «встроенное» меню на директивы для шаблонизатора Jinja2. А потом с трудом добавил дополнительную «страничку» с описанием каждой позиции.

WebApp предлагает интересный вид аутентификации. Но в первых версиях я пренебрег безопасностью. Вопрос аутентификации будет рассмотрен далее в тексте.

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

От WebApp-решения пострадали пользователи Linux, в частности, пользователи Ubuntu. Ведь вместо приложения открывалось окно с текстом «Unfortunately, you can’t open this menu with your current system configuration». Приходилось доставать телефон.

Тем не менее, некоторые староверы стояли на своем.

Наследие наносит ответный удар

Хорошо, когда пользователь сразу признается.
Хорошо, когда пользователь сразу признается.

Во имя плавного перехода я оставил опрос в группе. Однако опрос приносил неожиданную механику: до закрытия опроса голос можно отменить, тогда отменяется и заказ. В отсутствие полноценного фронтенда в WebApp-приложении я превратил это в «фичу»: для отмены заказа, сделанного в веб-приложении, достаточно проголосовать в опросе и отменить голос.

Следующие три недели существования «гибдридного» формата заказа я делал по «насечке» на клавиатуре каждую неделю. Три пользователя попались в ловушку желания кликать на кнопки. Только один признался своевременно и не потерял обед.

Тем не менее, расширенная функциональность WebApp-версии переманила около 70% пользователей, а трехкратная ошибка пользователей — это отличный повод отключить «Legacy». С отключением опроса я добавил кнопки «Посмотреть заказ» и «Отменить заказ», чтобы дать пользователю возможность убедиться в своем выборе или же отказаться от него.

Рефакторинг

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

Каждое возвращение к разработке бота меня донимала мысль, что «костыль» для доступа в WebApp — это ужасно. Об этом мне косвенно напоминали новые коллеги, которые не сразу разбирались, что на кнопку «Старт» в личных сообщениях с ботом нужно кликать каждыйраз.

Я был готов отказаться от WebApp и сделать «чистую» Web-версию. К счастью, Telegram предоставил мне такой инструмент. Оказывается, есть Telegram Login Widget, который позволяет передавать сайту базовую информацию о пользователе, необходимую для аутентификации.


    login = LoginUrl(
    f"{base_url}/login?event_id={event.id}", 
    request_write_access=True
)
keyboard = [
    [
        InlineKeyboardButton("Заказать", login_url=login)
    ],
]
markup = InlineKeyboardMarkup(keyboard)
await order_message.edit_reply_markup(markup)

У кнопок под сообщением есть параметр login_url, который позволяет открыть ссылку в браузере. Ссылки в login_url могут вести только на домен, который «привязан» к боту. Привязать домен к боту можно у @BotFather с помощью команды /setdomain.

При нажатии на кнопку клиент Telegram спросит согласия пользователя авторизоваться на сайте. Если пользователь согласится, запрос будет расширен следующими данными:

  • отображаемые имя и фамилия,
  • ник (username) и внутренний идентификатор (id),
  • ссылка на текущую аватарку,
  • время получение разрешения,
  • хэш.

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

Рассмотрим способ вычисления хэша сразу в коде. Обратите внимание, что login_router обрабатывает запрос POST, а браузер по умолчанию делает GET-запрос. Это связано с тем, что в моем решении Telegram открывает веб-интерфейс на Vue.JS, который формирует POST-запрос для бэкэнда.


    ort hmac
import hashlib
from typing import Optional
from fastapi import APIRouter
from pydantic import BaseModel
from starlette.requests import Request
from starlette.responses import Response
login_router = APIRouter(prefix="/login", tags=["Login"])
class LoginModel(BaseModel):
    event_id: Optional[int]
    id: int
    first_name: Optional[str]
    last_name: Optional[str]
    photo_url: Optional[str]
    username: Optional[str]
    auth_date: int
    hash: str
@login_router.post("/")
async def check(request: Request, response: Response, data: LoginModel):
    bot_token: str # получите токен бота любым удобным способом
    # Строка, от которой будем считать хэш
    data_str = ""
    # 1. Сортируем аргументы в алфавитном порядке
    keys = list(data.__fields__.keys())
    keys.sort()
    # 2. Для каждого аргумента...
    for key in keys:
        value = getattr(data, key)
        # Если значение не определено -- игнорируем
        if not value:
            continue
        # Если значение не от Telegram или это хэш -- игнорируем
        if key in ["hash", "event_id"]:
            continue
        # Остальные значения добавляем к результирующей строке в заданном формате
        data_str += f"{key}={value}\n"
    # Убираем перенос строки в конце
    data_str = data_str.strip()
    # Считаем секретный ключ: это SHA256 от токена. Забираем байтовое представление
    secret_key = hashlib.sha256(bot_token.encode("utf-8")).digest()
    # 3. Считаем хэш и забираем строковое представление
    computed = hmac.new(
        key=secret_key,
        msg=data_str.encode("utf-8"),
        digestmod=hashlib.sha256
    ).hexdigest()
    # 4. Если строковые представления расходятся, то нас пытаются обмануть
    if data.hash != computed:
        raise HTTPException(status_code=403, detail="Invalid login")
    # 5. Рекомендуется обработать auth_date, чтобы нельзя было прийти со старым хэшем.

После внесенных изменений кнопка «Заказать» открывает браузер, в котором сразу можно выбирать еду на обед.

Пусть я переделал «магазин» полностью в веб-сайт, надежда когда-нибудь вернуться к «прогрессивным» WebApp’ам не покидала меня.

Возвращение WebApp

В конце апреля 2023-его, то есть почти через год после релиза WebApp’ов, вышло обновление BotAPI 6.7, которое открывает возможность использовать WebApp из групповых чатов. Это ли не повод вернуться к старым идеям?

Демонстрация DurgerKingBot предлагает перейти по ссылке вида https://t.me/durgerkingbot/menu, и Telegram-клиент немедленно откроет WebApp. Однако замена ника бота не поможет магии свершиться, вы увидите уведомление «Bot application not found». Более того, попытка сделать кнопку с WebApp в групповом чате также не сработает, так что без ссылки не обойтись.

Для создания такой ссылки необходимо обратиться к BotFather с командой /newapp. В процессе создания вам потребуется предоставить:

  • имя бота, которому принадлежит создаваемое приложение,
  • заголовок (title) страницы,
  • короткое описание приложения,
  • изображение размером 640х360 пикселей,
  • GIF-файл с демонстрацией (можно пропустить),
  • ссылка, которая будет открываться при открытии WebApp,
  • короткое имя ссылки, которое будет привязано к приложению. В примере это menu.

В будущем можно будет изменить все, кроме принадлежности к боту и короткого имени. Аутентификация WebApp’ов похожа на Login Widget, но имеет свои особенности. Согласно документации фронтенд должен передать бэкэнду строку initData, в которой содержится информация о клиенте, а бэкэнд должен провести валидацию полученных данных.


    import requests
class WebAppLoginModel(BaseModel):
    initData: str
@login_router.post("/webapp")
async def check_webapp(request: Request, response: Response, data: WebAppLoginModel):
    bot_token: str # получите токен бота любым удобным способом
    # Разбиваем записи по символу &
    query_dict = dict()
    for entry in data.initData.split("&"):
        # Ключ и значение разбиты символом =
        key, value = entry.split("=", 1)
        query_dict[key] = value
    # Извлекаем хэш
    hash = query_dict.pop("hash")
    # Сортируем ключи в алфавитном порядке
    keys = list(query_dict.keys())
    keys.sort()
    # Собираем строку, где на каждой строке ключ=значение.     
    # Но перед этим декодируем значение из url-encoded-формата
    data_check_list = [f"{key}={requests.utils.unquote(query_dict[key])}" for key in keys]
    data_check_string = "\n".join(data_check_list)
    # Секретный ключ – это HMAC-SHA256 от строки WebAppData с ключом-токеном
    secret_key = hmac.new(
        key=b"WebAppData",
        msg=bot_token.encode("utf-8"),
        digestmod=hashlib.sha256
    ).digest()
    # Хэш — это HMAC-SHA256, от данных с ключом, который посчитали ранее
    computed = hmac.new(
        key=secret_key,
        msg=data_check_string.encode(),
        digestmod=hashlib.sha256
    ).hexdigest()
    # Если строковые представления расходятся, то нас пытаются обмануть
    if hash != computed:
        raise HTTPException(status_code=403, detail="Invalid login")

В отличие от Login Widget, где информация о пользователе передается отдельными полями, в initData информация о пользователе передается в JSON-объекте user.

Дополнительно есть поля chat_type и chat_instance, которые заполняются, если пользователь открывает WebApp по ссылке. Предполагается, что это позволит создавать приложения для совместной работы над общим ресурсом. Вместе с тем эта информация не позволяет идентифицировать чат, из которого запущено приложение.

У моей тестовой супергруппы идентификатор — отрицательное число из 13 цифр, а идентификатор chat_instance — положительное число из 19 цифр. При этом chat_instance не зависит от идентификатора сообщения.

DurgerKingBot, затаившийся во вложениях.
DurgerKingBot, затаившийся во вложениях.

Получить идентификатор чата и базовую информацию о чате можно только при запуске бота из «вложений». По умолчанию бот не может попасть в это меню. Эта опция доступна только пользователям, которые дают рекламу на площадке Telegram Ad Platform. Входной порог — два миллиона евро.

Обновление BotAPI 6.7 также дает возможность ботам отправлять «премиум» эмодзи в своих текстах. Для этого нужно купить ник на платформе Fragment и «улучшить» его за 5000 TON (~900 тысяч рублей по текущему курсу), а затем привязать к боту.

Эти ограничения выглядит неподъемными, поэтому на WebApp я «перевез» кнопку «Посмотреть мой заказ», чтобы у пользователей была возможность управлять своим заказом с одной кнопки. И опять пострадал один пользователь Linux: его Telegram Desktop на Fedora закрывался при открытии веб-приложения.

Вот и закончилась техническая часть, переходим к статистике.

Статистика

За 46 недель работы бота прошло 40 шавадеев. 165 коллег заказали 955 шаверм на 318 тысяч рублей.

Помимо 955 случаев успешного обеда, было 37 исключений. 26 позиций были заменены на другие из-за недостатка ингредиентов. И лишь в 11 случаях произошла печалька.

В двух случаях шаверма не была приготовлена. Проблема исправлена беседой с поставщиком. Еще в четырех случаях заказ был доставлен, но заказчик его не находил. Эти ситуации завязаны на «спецзаказах», то есть на шавермах без определенных ингредиентов в составе. Коллеги по невнимательности забирали специальную версию, а «общая» не устраивала заказчика. Урок вынесен — спецзаказы теперь выдают лично в руки.

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

Статистика заказов, позиции с 1st Food Factory.

Заключение

За время разработки этого бота-автоматизатора я узнал много интересных особенностей Telegram и потратил несколько наборов нервных клеточек на волнения за возможные ошибки в ПО.

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