Shawarma as a service: как создать бота для заказа шавермы и оставить голодными лишь 1,1% коллег

kfjfglclaupkuri7oialxdbv4xc.png


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

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

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


Используйте навигацию, чтобы перейти сразу на статистику шавадея:

→ Истоки традиции
→ Новая надежда
→ Магазин в Telegram
→ Проблемы любителей прогресса
→ Наследие наносит ответный удар
→ Рефакторинг
→ Возвращение WebApp
→ Статистика
→ Заключение

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


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

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

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


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

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


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

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


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

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

4yutsuhoodxrltivssdxkawljcq.png

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


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

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


tlpvfnh-orynmsgpk18gm5s2ecm.jpeg


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

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


hqveig-htp5ndsrqyah7siyx7lo.png


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

Магазин в Telegram


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

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

wyuxfsudus4h_eqaizcikliidsi.png


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

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

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

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


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

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


rdplm2xh71epnne6ozkzv9e1me8.jpeg


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

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 и отвечаем сообщением с кнопкой.

x1ffm_16cxn5vodqipch4qvnbn8.jpeg


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

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


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

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


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

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


fn3jykac-czsq4ystxoldeomita.png


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

Во имя плавного перехода я оставил опрос в группе. Однако опрос приносил неожиданную механику: до закрытия опроса голос можно отменить, тогда отменяется и заказ. В отсутствие полноценного фронтенда в 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-запрос для бэкэнда.

import 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 не зависит от идентификатора сообщения.


to9loiw0yojn5pkmwv72da-0psq.png


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

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

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


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

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

Статистика


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

1msogmqx9qzbyu5mqxo-lr8iaiq.png


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

eav4trq9xp81gvgl9mce7tqlnqs.png


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

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

w1747xhzjbkl5a2yuklzfb5wdys.png


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

Заключение


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

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

Другие мои тексты, которые могут вам понравиться:

→ FlexGen на практике: получится ли запустить тяжелую модель без мощной видеокарты
→ Создаем подругу, записывающую кружочки в Telegram, с помощью 4 нейросетей
→ Локализуем игру в слова с искусственным интеллектом

© Habrahabr.ru