Как стать автором
Обновить

Меняем формат розыгрышей призов в Telegram-чатах

Уровень сложностиСредний
Время на прочтение19 мин
Количество просмотров2.2K

Привет, Хабр! Меня зовут Денис, и сегодня я расскажу вам о проекте, над которым я и мой друг работали последние 7 месяцев. Называется он PLAY365 — и это игровой (и не только) бот для групповых чатов Telegram.

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


С чего все началось

Розыгрыши всевозможных призов в больших чатах обычно проходят очень скучно. Все записываются в один большой список/группу/бота, а после окончания записи — админ запускает рандомайзер (рандстафф.ру, рандомус.ру, боты рандома, просто пальцем в небо сам и т. д.), который и выбирает номер победителя из списка. В качестве подтверждения результатов админ либо выкладывает скрин/видео итогов рандомайзера, либо дает ссылку на страничку с розыгрышем. И на наш взгляд, такой подход — мало того, что скучный, так еще и не всегда честный: рандомайзер можно бесконечно перекручивать под нужное админу число. К тому же, от участника не требуется вообще никакой активности: запишись в список и мониторь результаты.
Даже вышедший недавно официальный метод розыгрышей в Telegram использует такой же подход, разве что теперь можно для записи в список установить обязательные для подписки каналы, а честность рандома обеспечена самим Telegram:

Самый главный минус нативных розыгрышей - необходимость приобретать Telegram Premium для выставления в качестве приза! Хоть опцию добавить свой приз к подписке предусмотрели, и на том спасибо...
Самый главный минус нативных розыгрышей - необходимость приобретать Telegram Premium для выставления в качестве приза! Хоть опцию добавить свой приз к подписке предусмотрели, и на том спасибо...

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

Первая игра - Баскетбол

Если в Telegram отправить в чат эмодзи баскетбольного мяча (?), то будет отыграна анимация броска мяча в кольцо, у которой есть 5 вариаций: 3 из них покажут, что мяч промахнулся, 2 — что попал, т. е. шансы на попадание — 40%. Случайность, при этом, определяется на стороне Telegram и на нее никак нельзя повлиять. Отредактировать анимацию также нельзя.

Мы решили использовать это и набросали прототип скрипта для бота, который сам "бросает" мячи и делает это в три раунда: в первом раунде нужно забить хотя бы 1 раз из 3 бросков чтобы пройти дальше, во втором – 2 из 3, в третьем – 3 из 3. Если мяч не попадает нужное количество раз – игрок выбывает. Чей сейчас ход, сколько кто набрал очков и так далее - все считает бот самостоятельно.

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

  1. Админ запускает игру командой /basket

  2. Бот объявляет старт записи на игру сообщением в чат, под сообщением есть три кнопки:

    1. «Присоединиться» - добавит игрока в список участников, при повторном нажатии – удалит его из списка;

    2. «Статус» - сообщит игроку, есть ли он в списке или нет;

    3. «Как играть» - отправит краткие правила текущей игры юзеру в личные сообщения.

  3. После того, как нужное админу количество участников записалось на игру – он стартует ее командой /next, и дальше бот все делает сам – бросает мяч за игроков, считает очки, сообщает о вылетевших и победителе.

Объявление победителя по результатам всех раундов
Объявление победителя по результатам всех раундов

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

Добавляем другие игры

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

  • Киллер (/killer) — каждый раунд бот выбирает случайного киллера из списка игроков и даёт ему список из 10 потенциальных жертв. Киллер пишет в чат номер жертвы для «убийства», если не успел — сам выбывает. Побеждает последний оставшийся в живых. Кстати, иногда киллер может промахнуться!

  • Бинго (/bingo) — игроки загадывают числа из заданного в настройках бота интервала, а потом бот постепенно, рядами, оглашает случайные числа из него. Задача — отследить совпадение всех своих чисел с выпавшими и успеть первым написать слово «Бинго» в чат.

  • Рулетка (/roulette) — бот выбирает случайного игрока, который должен успеть написать что угодно в чат за отведенное время, если не успел — выбывает, и игра продолжается. Побеждает тот, кто успел ответить или остался в списке последним.

  • Заряд (/charge) — Игрокам нужно максимально быстро отправлять любые сообщения в чат, чтобы «заряжать» ими батарейку. Кто первый наберёт настроенное админом количество сообщений — победил.

  • Больше‑меньше (/updown) — Игрокам нужно отгадать число из числового интервала (например, от 1 до 1000, устанавливается в настройках). Каждый раунд бот будет писать текущий интервал и просить случайных игроков написать число из него. После ответа игрока, бот скажет — больше загаданное число написанного или меньше, и скорректирует интервал. Игра продолжается, пока кто‑то не назовет точное число.

  • Кинг‑Конг (/kong) — В каждом раунде один игрок выбирается в качестве цели для Кинг‑Конга. Цель может спрятаться или подразнить Кинг‑Конга, чтобы уменьшить или увеличить шанс поимки следующего игрока (и себя самого). Побеждает последний, оставшийся непойманным игрок.

  • Пандора (/pandora) — игроки по очереди выбирают ключи, чтобы открыть один из двух ящиков. В ящиках — разные бонусы или штрафы, а в одном из ящиков — Грааль. Побеждает тот, кто нашел Грааль.

Ферма - теперь и в Telegram!
Ферма - теперь и в Telegram!

Чуть позже у нас появились мини‑игры, одна из них — классическая Ферма (/farm): нужно выращивать растения разной редкости, поливать и удобрять их, а после того, как они вырастут — собирать и продавать за Респекты (внутриигровую валюту). Более редкие растения приносят больше Респектов. Респектами можно делиться, покупать на них новые растения, а также менять их на токены, которые, в свою очередь, можно потратить на покупку уникальности — например, сделать так, что в списке игроков твое имя будет отображаться не как @username, а как заданный тобой текст (например, ПоБеДиТеЛь777).

Разделяй и властвуй

Сначала мы хотели заранее создать много ботов, чтобы в чате мог быть только какой-то один из них, но это показалось слишком сложным и плохо масштабируемым при большом спросе на бота. Поэтому был придуман другой подход – сперва админ создает своего бота через @BotFather, получает его API-Token (пример такого токена - 1234567890:abcdefghijABCDEfghiJKLMNopqrSTUVwxyz), и потом связывает своего бота с системой PLAY365, отправив API-Token нашему основному боту. После этого админу становится доступен весь функционал PLAY365 в созданном им боте, который он и добавит в свой чат.

Главное меню основного бота
Главное меню основного бота

Кастомизация и инструменты для админов

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

Для каждой игры добавили настройки: сколько длится ожидание ответа игрока, сколько раундов будет в игре, может ли промахиваться киллер и так далее:

Настройки "Бинго"
Настройки "Бинго"

Чтобы админы могли модерировать игры — добавили команды /ban (запретит игроку записываться на игры в течение определенного срока), /mute (запретит игроку писать сообщения в чат), /kick (удалит игрока из текущей игры). Каждую функцию можно отменить:

Бан тестового Руслана
Бан тестового Руслана

Чтобы иметь возможность управлять ходом игры, добавили несколько команд:

  • /reload — Перезапускает игру, восстанавливая ее состояние с последнего действия. Перезагрузка длится 10 секунд;

  • /reset — Досрочно завершает текущую игровую сессию, начать новую можно через 10 секунд;

  • /replay — Переигрывает последнюю игру с теми же игроками, исключив из нее прошлого победителя (в случае, если он не выполнил какие‑то условия админа, например).

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

  • /announce — Зовет всех игроков, которые участвовали в играх чата в ближайший месяц;

  • /announcestart — Зовет игроков, которые записались на текущую игру;

  • /announceall — Зовет всех участников чата.

Пример анонса игры
Пример анонса игры

А что с монетизацией?

А все очень просто. У нас есть три варианта подписки:

Скрин меню оплаты подписки
Скрин меню оплаты подписки

PRO - в любой момент можно запустить любую игру из каталога;
ADVANCED - можно выбрать только 2 игры на период подписки (возможен апгрейд до PRO);
NEWBIE - лучший способ попробовать все! Каждую неделю в боте доступно 2 игры, которых еще не было в этом месяце (возможен апгрейд до PRO или ADVANCED).

Приятный сюрприз внутри

Чтобы каждый из вас мог попробовать функционал бота - вот вам промокод HABR365. Если вы введете его в своем боте - получите неделю подписки на тарифе PRO!

И да - при первом посещении бота рандомное количество дней бесплатно и так будет предоставлено, так что используйте промокод только после истечения выданных ботом дней!

Стек и разбивка репозиториев

Наверное, это нужно было написать в самом начале, но тогда структура выглядела бы как-то нелогично...

Стек технологий: javascript, node.js, typescript, mongo, node‑telegram‑bot‑api.

Хостинг: на текущий момент используются AWS, а именно: CodeCommit, CodeDeploy, EC2 Instances (aws linux и ubuntu). Было принято решение переместить все, что возможно, в одну экосистему, т.к. это позволит сократить время между взаимодействиями между разными сервисами. Раньше использовались timeweb (попал под ddos атаки, простой около 2 недель), Gitlab (не получилось связаться с ними, чтобы помогли подобрать подходящий тариф).

Репозитории:

  1. bot — отвечает за сохранение ботов пользователей в базе, а также менеджинг, удаление, отслеживание критических ошибок, проводит первичное демо

  2. orchestra — отвечает за управление всеми пользовательскими ботами

  3. payment‑integration — контролирует подписки, внутриигровую валюту, принимает платежи, отдает информацию о платежных методах, стоимости

  4. manager — позволяет соединить все репозитории между собой через docker‑compose.yml, содержит конфигурационную информацию для сервера, как конфиги, сам собирает конфиги с соответствующим окружением

  5. migrations — контролирует обновления в базе данных, через него можно добавлять/обновлять/удалять данные

  6. admin — бот‑админ, управляет промокодами, пользователями, ботами, рассылка объявлений и т. д.

  7. shared — npm‑модуль содержащий в себе обобщенные типы и данные между разными репозиториями

Разделение функционала ботов

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

Для этого нам надо было обеспечить:

  • Изолированность каждого бота, чтобы при необработанной ошибке падал лишь один бот, а остальные жили;

  • Разделение логов;

  • Качественное распределение ресурсов на каждого бота.

В черновике это было реализовано одним потоком, то есть:

  • Существует массив, который собирает в себе поднятых ботов;

  • Постоянный цикл проверяет, существует ли бот из БД в массиве. Если нет — его нужно поднять и добавить в массив;

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

Такая реализация имела массу проблем:

  • Если бот был удален из БД, то его достаточно сложно остановить его, просто удалив из массива с ботами;

  • Ресурсы процессора и оперативной памяти распределяются непредсказуемо;

  • Логирование идет «единой портянкой» — это лишает возможности следить за тем, какой именно бот какую ошибку выдал;

  • При возникновении ошибки в одном боте — падают все остальные.

Да, проблема с падением решалась обычным правилом в Dockerfile restart:always, но, опять же, перезагружались и все остальные боты.

После были предприняты попытки разделить всех ботов на разные child процессы: циклическая функция проверяет всех ботов из БД, если добавлен новый, то создается новый child процесс и добавляется в массив, если удален, то child процесс убивается.
Данный метод уже был более приемлем, т.к. изолировал ботов друг от друга и не ронял остальных. Но не была решена проблема с логами, а также, при масштабировании ресурсы расходовались в больших объемах, для тестов был отключен весь функционал, кроме вотчера на ботов, в итоге поднятие каждого процесса обходилось примерно в 100мб.

Для решения проблемы масштабируемости были добавлены воркеры: все воркеры равномерно распределялись по доступному количеству ядер процессора через child процессы, а на каждом процессе было примерно одинаковое количество воркеров по схеме ниже:
Количество child процессов = min(кол‑во ботов, кол‑во ядер)
Количество воркеров на каждом процессе = ceil(кол‑во ботов / кол‑во ядер)
Данный подход решал практически все вопросы, кроме логирования и отдельного контроля ботов.

Финальной версией стал подход разделения каждого бота на отдельные docker‑процессы: да, он немного уступал в ресурсоемкости из‑за постоянной смены контекста, но это стало не столь критичной проблемой. Зато это дало массу возможностей:

  • каждый контейнер имеет удобное имя, которое можно найти даже администратору проекта нужного бота и посмотреть в логах портейнера в чем могла быть проблема;

  • стойкая изолированность каждого бота друг от друга, можно контролировать даже на уровне маунтов, кому какие файлы отдавать;

  • ресурсы автоматически распределяются докером, нет шансов допустить ошибку, как в «ручном контроле».

В итоге код стал выглядеть примерно так:

const startWatcherOnTelegramBots = async (): Promise<void> => {
  // корневой путь каталога для получения образа и исполняемого файла для бота
  const rootPath = path.resolve(__dirname, '..', '..');

  // билдим образ каждый раз при пересборке, чтобы не упустить изменений
  const imageName = 'child-bot';
  const docker = new Docker({
    socketPath: '/var/run/docker.sock',
  });
  await checkAndBuildImage(docker, imageName, path.join(rootPath, 'bot'));

  // получаем переменные окружения, чтобы отправить в контейнеры
  const envVariables = getAllEnvVariables();

  // удаляем все контейнеры при пересборке
  await docker.listContainers({ all: true }).then((containers) => {
    const promises: Promise<void>[] = [];

    for (const container of containers) {
      // каждый контейнер имеет специфичный label, чтобы не удалить лишнего
      if (container.Labels.type !== imageName) {
        continue;
      }

      const containerName = container.Names[0];
      promises.push(
        docker
          .getContainer(container.Id)
          .stop()
          .catch((err) => {
            // ...
          })
          .then(() => {
            // контейнер остановлен
            return docker
              .getContainer(container.Id)
              .remove()
              .then(() => {
                // контейнер удален
              })
              .catch((err) => {
                // ...
              });
          }),
      );
    }

    return Promise.all(promises);
  });

  // функция для постоянного наблюдениями за ботами
  void (async function loop() {
    // объявляем таймаут сразу, дабы избежать остановку вотчера в случае возниковения неожиданного выхода из функции
    let timeout = setTimeout(loop, 1000 * 5);

    // получаем всех ботов из базы данных, желательно использовать пагинацию, чтобы не засорять память при масштабировании
    const bots = await getBots();
    const botsSet = new Set(bots.map((bot) => bot.token));

    // проверяем какие контейнеры запущены, но ботов для них уже нет
    const allContainers = await docker.listContainers({ all: true });
    for (const containerInfo of allContainers) {
      const containerToken = containerInfo.Labels['bot.token'];
      if (containerToken && !botsSet.has(containerToken)) {
        const container = docker.getContainer(containerInfo.Id);

        // прерываем обновление вотчера, если процесс остановки контейнера задержится, чтобы не попытаться удалить контейнер повторно
        clearTimeout(timeout);

        await container.stop().catch(
            (err) => // ...
        );
        await container.remove().catch(
            (err) => // ...
        );

        // контейнер успешно удален, можно продолжить следить за новыми контейнерами
        timeout = setTimeout(loop, 1000 * 5);
      }
    }

    // проверяем на отсутствующие контейнеры
    for (const bot of bots) {
      const { token, username, error: botError } = bot;
      const containers = await docker.listContainers({
        all: true,
        filters: { label: [`bot.token=${token}`] },
      });

      if (containers.length > 0) {
        continue;
      }

      clearTimeout(timeout);

      try {
        const container = await docker.createContainer({
          // имя удобное для поиска в контейнерах, начинается на z, чтобы боты попадали в конец списка и не мешали найти другие системные контейнеры
          name: `zc-${config.env}-${username}`.replace(/[^a-zA-Z0-9_.-]/g, ''),
          Image: imageName,
          Env: [...envVariables, `BOT_TOKEN=${token}`],
          Labels: { 'bot.token': token, type: imageName },
          HostConfig: {
            // если бот падает, мы его сразу же поднимаем
            RestartPolicy: {
              Name: 'always',
            },
            // делаем маунты для контейнеров, обязательно прокидываем путь из энвов, тк маунт с родительского контейнера может повлечь за собой проблемы, если билд будет изменен новыми файлами
            Mounts: [
              {
                Type: 'bind',
                Source: `${config.bot.rootPath}/build`,
                Target: '/app/build',
              },
              // также прокидываем папку node_modules, чтобы каждый новый контейнер не начинал сборку тех же самых модулей N (N = кол-во ботов) раз
              {
                Type: 'bind',
                Source: `${config.bot.rootPath}/node_modules`,
                Target: '/app/node_modules',
              },
            ],
            NetworkMode: config.bot.network,
          },
        });

        // контейнер успешно сформирован - стартуем
        await container.start();
      } catch (err) {
        // ...
      }

      // все процессы успешно прошли, можно восстановить вотчер
      timeout = setTimeout(loop, 1000 * 5);
    }
  })();
};

Обход ошибки retry after ... too many attempts

Одна из самых неприятных ошибок, которую можно словить от API Telegram.
Суть ее в том, что если достаточно часто отправлять или редактировать сообщения в чат, то Telegram ограничит тебя на несколько секунд в действиях, отдавая эту ошибку.

Для пользователей количество сообщений в минуту имеет непривередливые условия: около 2 сообщений в секунду. Для групповых чатов же все обстоит намного хуже: около 20 сообщений в минуту.

Ниже представлены параметры отправки сообщений и какой результат получился, в параметрах указано по сколько сообщений отправляется за какое время, под параметрами — на сколько секунд банит (ограничивает возможность отправки) Telegram и сколько сообщений проходит до бана:

  • 10 сообщений в 10 сек

    • бан на ~25 сек, ~21 сообщение перед баном

  • 10 сообщений в 5 сек

    • бан на ~34 сек, ~21 сообщение перед баном

  • 10 сообщений в 3 сек

    • бан на ~36 сек, ~21 сообщение перед баном

  • 5 сообщений в 10 сек

    • бан на ~18 сек, ~21 сообщение перед баном

  • 5 сообщений в 5 сек

    • бан на ~38 сек, ~21 сообщение перед баном

  • 5 сообщений в 3сек

    • бан на ~37 сек, ~21 сообщение перед баном

  • 3 сообщения в 5 сек

    • бан на ~22 сек, ~20 сообщение перед баном

  • 3 сообщения в 3 сек

    • бан на ~33 сек, ~20 сообщение перед баном

  • параметры, не вызывающие бан: 3 сообщения в 9 сек

Стоит учесть, что Telegram банит не только отправку сообщений, а также любое редактирование. Под такой бан не попадают callback query ответы, а также удаление сообщений.

Мы подумали, как можно решить следующие проблемы:

  • правильно обрабатывать ошибку и ждать нужное время;

  • отправлять сообщения оптимальным образом, чтобы как можно реже натыкаться на ошибку

Как работает система бана в Telegram:

  • бот отправляет сообщение;

  • Telegram запоминает, что от этого бота пришло 1 сообщение, если количество сообщений за определенное время больше, чем допустимо, то отправляется ошибка, после некоторого время боту опять разрешено выполнять действия;

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

Допустим, у нас есть функция для отправки сообщений:

const sendMessageNerd = async (
  telegramBot: TelegramBot,
  chatId: TelegramBot.ChatId,
  text: string,
  options?: TelegramBot.SendMessageOptions,
): ReturnType<typeof telegramBot.sendMessage> => {
  const func = telegramBot.sendMessage.bind(telegramBot, chatId, text, options);

  try {
    // пробуем отправить сообщение
    // обязательно используем await, чтобы try {} catch мог словить ошибку
    return await func(chatId, text, options);
  } catch (err) {
    throw err;
  }
};

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

// запоминаем все забаненные сообщения в очереди, идентифицируем чат (в случае если каждый бот изолирован друг от друга, чаты запоминать не нужно)
const chatsActionQueue = new Map<
  ChatId,
  {
    waiting: boolean;
    freeAt: number;
    funcs: (() => void)[];
  }
>();

// добавляем сообщение в очередь ожидания
const waitForActionQueue = (chatId: ChatId, callback: (res: () => void) => void): Promise<void> => {
  return new Promise((res) => {
    if (chatsActionQueue.has(chatId)) {
      chatsActionQueue.get(chatId)!.funcs.push(res);
    } else {
      chatsActionQueue.set(chatId, {
        waiting: false,
        freeAt: 0,
        funcs: [res],
      });
    }

    callback(res);
  });
};

// ловим ошибку и проверяем, что она относится к too many attempts
const catchTooManyAttempts = async <T>(err: Error, func: () => Promise<T>, chatId?: ChatId): Promise<T | null> => {
  const msg = err.message;
  if (!msg.includes('retry after')) {
    return null;
  }

  // извелкаем время ожидания из ошибки
  const timeout = parseInt(msg.split('retry after ')[1]);
  if (Number.isNaN(timeout)) {
    return null;
  }

  // если указан чат, в котором произошла ошибка, то помещаем сообщение в очередь ожидания
  // в противном случае просто дожидаемся таймаута
  if (chatId != null) {
    let queueRes: () => void;

    // ждем того, что произойдет первым: освобождение очереди через новое сообщение или истечение таймаута
    await Promise.race([
      wait(timeout * 1000),
      waitForActionQueue(chatId, (res) => {
        queueRes = res;
      }),
    ]);

    // если чат, в котором произошла ошибка не найден в очереди на ожидание, то где-то произошла утечка памяти, которую важно не упустить
    if (chatsActionQueue.has(chatId)) {
      // освобождаем очередь
      const queue = chatsActionQueue.get(chatId)!.funcs;
      queue.splice(queue.indexOf(queueRes!), 1);
    } else {
      console.error('!! MEMORY LEAK !! chats action queue not found');
    }

    // пробуем отправить сообщение еще раз и повторяем процесс рекурсивно
    try {
      return await func();
    } catch (e) {
      const m = (e as Error).message;
      if (!m.includes('retry after')) {
        throw e;
      }

      return catchTooManyAttempts(e as Error, func, chatId);
    }
  } else {
    // просто ждем таймаут
    await wait((timeout + 0.5) * 1000);
    return func();
  }
};

// задержка между сообщениями, которые освобождают очередь
const delayBetweenQueue = 3000;

// функция, которая вызывается при получении сообщения, которое должно освободить очередь
export const resolveActionQueue = (chatId: number): void => {
  // пропускаем, если очередь для чата пуста
  if (!chatsActionQueue.has(chatId)) {
    return;
  }

  const queue = chatsActionQueue.get(chatId)!;

  // если очередь ждет задержку между освобождающими сообщениями или сообщений в очереди нет, то прпоускаем
  if (queue.funcs.length > 0 && !queue.waiting) {
    const now = Date.now();

    // сколько времени осталось ждать
    const diff = queue.freeAt + delayBetweenQueue - now;

    // если нужно подождать еще времени, то ставим таймаут на ожидание, а также блокируем возможность освобождения сообщения из очереди
    if (diff > 0) {
      queue.waiting = true;

      setTimeout(() => {
        // освобождаем очередь
        queue.waiting = false;

        if (queue.funcs.length > 0) {
          queue.freeAt = Date.now();
          queue.funcs.splice(0, 1)[0]();
        }
      }, Math.max(diff, 0));
    } else {
      // если задержка прошла, то просто освобождаем первое сообщение из очереди
      if (queue.funcs.length > 0) {
        queue.freeAt = now;
        queue.funcs.splice(0, 1)[0]();
      }
    }
  }
};

Дополним изначальную функцию отправки сообщений:

const sendMessageNerd = async (
  telegramBot: TelegramBot,
  chatId: TelegramBot.ChatId,
  text: string,
  options?: TelegramBot.SendMessageOptions,
): ReturnType<typeof telegramBot.sendMessage> => {
  const func = telegramBot.sendMessage.bind(telegramBot, chatId, text, options);

  try {
    return await func(chatId, text, options);
  } catch (err) {
    const msg = (err as Error).message;

    // отправляем ошибку в нашу функцию, если результат null, то ошибка другого характера и ее стоит обработать иным образом
    const tooManyAttemptsResult = await catchTooManyAttempts(err as Error, () => func(chatId, text, options), chatId);
    if (tooManyAttemptsResult != null) {
      return tooManyAttemptsResult;
    }

    throw err;
  }
};

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

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

Такой подход позволяет отправлять большое количество сообщений намного быстрее, особенно в активных чатах: отправляется много сообщений, натыкаемся на ошибку too many attempts, пользователи общаются между собой, между тем освобождается очередь — бот отправляет дополнительные сообщения.

Помните данные по отправке сообщений и банам? Мы использовали небольшой лайфхак, связанный с этим: написали функцию, которая принимает в себя callback и количество сообщений, которое планируется отправить. В зависимости от количества сообщений меняется характер частоты отправки сообщений.

Если количество сообщений к отправке меньше 20 штук, то отправляем по 10 сообщений в 3 секунды, т.к. Telegram дает бан только при ~21 отправленных сообщениях (можно было бы отправлять еще чаще, но нет уверенности, что перед этим бот не отправит каких‑либо других сообщений, таким образом выбрали наиболее оптимальную частоту отправки, чтобы если уж словить бан, то самый щадящий).
Если количество сообщений к отправке больше 20 штук, то выбирается один из самых оптимальных способов отправки: 3 сообщения в 5 секунд.
Вы спросите зачем, если можно отправлять по 3 сообщения в 10 секунд и не ловить бан? — вопрос законный, но вспомните, что мы сделали функцию, которая освобождает очередь и позволяет отправлять сообщения чаще, чем разрешает Telegram, поэтому мы очень хотим наткнуться на бан, чтобы ускорить процесс отправки.

export const telegramExecuteBatch = async (count: number, callback: () => Promise<boolean>): Promise<void> => {
  // повторяем, пока callback не вернет false и не отправит все сообщения
  while (true) {
    if (count > 20) {
      for (let i = 0; i < 3; i++) {
        if (!(await callback())) {
          return;
        }
      }

      await wait(5000);
    } else {
      for (let i = 0; i < 10; i++) {
        if (!(await callback())) {
          return;
        }
      }

      await wait(3000);
    }
  }
};

Иной подход к интерфейсу бота

Telegram API предоставляет несколько возможностей пользователям, как можно взаимодействовать с ботами:

  • отправка сообщений/фото/видео/и пр. медиа;

  • взаимодействие с кнопками (callback_query);

  • взаимодействие через inline_query;

  • голосование в опросе;

  • и еще не самые важные (в данном контексте) способы.

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

Один из вариантов — сделать пагинацию и выводить список в виде кнопок:

  • ДОБАВИТЬ АДМИНА

  • АДМИН#1 УДАЛИТЬ

  • АДМИН#2 УДАЛИТЬ

  • ...

  • ПРЕДЫДУЩАЯ СТРАНИЦА

  • СЛЕДУЮЩАЯ СТРАНИЦА

У такого подхода есть несколько недостатков:

  • много кнопок, мало кто любит разбираться куда и зачем ему тыкать;

  • кнопки могут полностью не вместить в себя имя админа;

  • если мы захотим добавить дополнительные действия, например: редактирование или переход на личную страницу админа, то придется добавлять еще новые кнопки, которые будут плодиться x N (N = кол‑во новых функций) и взрывать пользователям мозг.

Вот как мы решили эту проблему:
Telegram дает возможность работать с deep links, в частности можно сбилдить практически любой запрос к боту через url в виде https://t.me/play365_bot?start=.
Мы воспользовались этим и вывели список админов в самом тексте сообщения, напротив каждого админа оставили ссылку в виде [удалить](https://t.me/play365_bot?start=da_<USER_ID>).
da - сокращенно от delete admin, стоит следить за размером url, т.к. при слишком длинном query Telegram просто проигнорирует действие.

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

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

Подведение итогов

В этой статье мы постарались описать многое, но, конечно же, не все. Изначальный черновик скрипта Баскетбола, например, был на 100 строк кода, сейчас же кодовая база — это 7 раздельных репозиториев на несколько сотен тысяч строк :) Будем писать еще, если эта статья найдет отклик среди аудитории.

Всем заинтересовавшимся, советчикам, «да вы криво сделали, смотри как надо» и прочим — просьба писать в комментарии, обещаем ответить на каждый.


Ссылки на PLAY365:

Теги:
Хабы:
Всего голосов 4: ↑3 и ↓1+4
Комментарии8

Публикации

Истории

Работа

Ближайшие события

27 августа – 7 октября
Премия digital-кейсов «Проксима»
МоскваОнлайн
28 сентября – 5 октября
О! Хакатон
Онлайн
3 – 18 октября
Kokoc Hackathon 2024
Онлайн
10 – 11 октября
HR IT & Team Lead конференция «Битва за IT-таланты»
МоскваОнлайн
25 октября
Конференция по росту продуктов EGC’24
МоскваОнлайн
7 – 8 ноября
Конференция byteoilgas_conf 2024
МоскваОнлайн