Лимиты Telegram bot API и работа с ними на Go

в 6:40, , рубрики: Gamedev, Go, telegram, Программирование, разработка игр

Довольно часто на Хабре появляются статьи о написании бота для Telegram, которые в своем роде, если откинуть уникальность идеи, являются самым обычным туториалом на тему «как получить сообщение от Telegram, обработать его и отправить ответ пользователю». Однако ни в одной из статей, прочтенных мной (конечно же, не берусь утверждать, что прочел их все, но тем не менее) я не встретил упоминания о лимитах отправки сообщений пользователям и как с ними работать. Кого заинтересовал, прошу под кат.

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

Опыт работы с API бота уже имелся, однако на меньшей аудитории и с меньшей интенсивностью отправки. Про лимиты тоже было известно, но реально сталкивался с ними только при работе с группами. Там все намного жестче, чем при работе с персональными чатами. Чтобы узнать больше о лимитах, достаточно обратится к FAQ на официальном сайте Telegram.

My bot is hitting limits, how do I avoid this?
When sending messages inside a particular chat, avoid sending more than one message per second. We may allow short bursts that go over this limit, but eventually you'll begin receiving 429 errors.

If you're sending bulk notifications to multiple users, the API will not allow more than 30 messages per second or so. Consider spreading out notifications over large intervals of 8—12 hours for best results.

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

Так как разработка изначально велась на языке Go, где имеются каналы и сопрограммы, (они же горутины), то на ум пришла сразу же идея отправка отложенных сообщений. Сначала складываем обработанный ответ в канал, а в отдельном потоке разгребаем этот канал каждые дозволенные нам 1/30 секунд. Но идея с одним каналом для всех сообщений не сработала. Достав из канала сообщение и убедившись, что этому пользователю мы пока слать сообщения не можем, нам необходимо это сообщение куда-то деть. Отправлять снова в этот же канал не хорошо, потому что мы сломаем хронологический порядок сообщений пользователя, а так же сильно отложим доставку этого сообщения при большом количестве активных игроков. Проверить сообщение, не доставая его из канала и перейти к следующему, насколько я знаю, мы тоже не можем.

Тогда появляется идея завести по каналу на пользователя. С этого момента по подробней.

// Мап каналов для сообщений, где ключом является id пользователя
var deferredMessages = make(map[int]chan deferredMessage)
// Здесь будем хранить время последней отправки сообщения для каждого пользователя
var lastMessageTimes = make(map[int]int64)

// chatId – id пользователя, которому шлем сообщения
// method, params, photo – заранее подготовленные параметры для запроса согласно bot API Telegram
// callback будем вызывать для обработки ошибок при обращении к API
type deferredMessage struct {
	chatId		int
	method 		string
	params 		map[string]string
	photo 		string
	callback 	func (SendError)
}

// Метод для отправки отложенного сообщения
func MakeRequestDeferred(chatId int, method string, params map[string]string, photo string, callback func (SendError)) {
	dm := deferredMessage{
		chatId: 	chatId,
		method: 	method,
		params: 	params,
		photo: 		photo,
		callback: 	callback,
	}

	if _, ok := deferredMessages[chatId]; !ok {
		deferredMessages[chatId] = make(chan deferredMessage, 1000)
	}

	deferredMessages[chatId] <- dm
}

// error.go, где ChatId – id пользователя
type SendError struct {
	ChatId 	int
	Msg	string
}

// Имплементация интерфейса error
func (e *SendError) Error() string {
	return e.Msg
}

Теперь с ходу для обработки получившегося набора каналов хочется воспользоваться select case конструкцией, однако проблема в том, что она описывает фиксированный набор каналов для каждого case, а в нашем случае набор каналов динамический, так как пользователи добавляются в процессе игры, создавая новые каналы для своих сообщений. В противном случае не обойтись без блокировок. Тогда, обратившись к Google, как обычно, на просторах StackOverflow нашлось отличное решение. А заключается оно в использовании функции Select из пакета reflect.

Если коротко, то эта функция позволяет нам извлечь из заранее сформированного массива SelectCase'ов, каждый из которых содержит канал, сообщение, готовое для отправки. Принцип тот же, что и в select case, но с неопределенным числом каналов. То что нам и нужно.

func (c *Client) sendDeferredMessages() {
        // Создаем тикер с периодичностью 1/30 секунд
	timer := time.NewTicker(time.Second / 30)

	for range timer.C {
                // Формируем массив SelectCase'ов из каналов, пользователи которых готовы получить следующее сообщение
		cases := []reflect.SelectCase{}
		for userId, ch := range deferredMessages {
			if userCanReceiveMessage(userId) && len(ch) > 0 {
                                // Формирование case
				cs := reflect.SelectCase{Dir: reflect.SelectRecv, Chan: reflect.ValueOf(ch)}
				cases = append(cases, cs)
			}
		}

		if len(cases) > 0 {
                        // Достаем одно сообщение из всех каналов
			_, value, ok := reflect.Select(cases)

			if ok {
				dm := value.Interface().(deferredMessage)
                                // Выполняем запрос к API
				_, err := c.makeRequest(dm.method, dm.params, dm.photo)
				if err != nil {
					dm.callback(SendError{ChatId: dm.chatId, Msg: err.Error()})
				}
                                // Записываем пользователю время последней отправки сообщения.
				lastMessageTimes[dm.chatId] = time.Now().UnixNano()
			}
		}
	}
}

// Проверка может ли уже пользователь получить следующее сообщение
func userCanReceiveMessage(userId int) bool {
	t, ok := lastMessageTimes[userId]

	return !ok || t + int64(time.Second) <= time.Now().UnixNano()
}

Теперь по порядку.

  • Для начала мы создаем таймер, который будет «тикать» каждые необходимые нам 1/30 секунд, и запускаем на нем цикл for.
  • После чего начинаем формировать необходимый нам массив SelectCase'ов, перебирая наш мап каналов, и складывая в массив только те непустые каналы, пользователи которых уже могут получать сообщения, то есть прошла одна секунда с момента прошлой отправки.
  • Создаем для каждого канала структуру reflect.SelectCase, в которой нам нужно заполнить два поля: Dir – направление (отправка в канал или извлечение из канала), в нашем случае устанавливаем флаг reflect.SelectRecv (извлечение) и Chan – собственно сам канал.
  • Закончив формировать массив SelectCase'ов, отдаем его в reflect.Select() и получаем на выходе id канала в массиве SelectCase'ов, значение, извлеченное из канала и флаг успешного выполнения операции. Если все хорошо, делаем запрос на API и получаем ответ. Получив ошибку, вызываем callback и передаем туда ошибку. Не забываем записать пользователю дату последней отправки сообщения

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

Кстати вспомним об оговорены в FAQ погрешностях. Я в своей реализации шлю пользователям два сообщения в секунду вместо одного и не раз в 1/30 секунду, а раз в 1/40, что намного чаще чем рекомендуется. Но пока проблем не возникало.

Исходный код клиента можно посмотреть на gitlab

Ну а если кому-то стало интересно о чем речь, то в Telegram @BastionSiegeBot

Автор: DorianPeregrim

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js