Справочник для разработчиков ботов.
Бот — это особый аккаунт со своим владельцем и статическим токеном. Он живёт в тех же чатах, что и люди: отправляет сообщения и медиа, получает входящие события («апдейты»), рисует кнопки под своими сообщениями и может открывать мини-приложение.
Базовый URL: https://api.gambu.ru/bot/v1
Запросы и ответы — JSON в UTF-8. Пишущие методы — POST с телом, читающие — GET с параметрами в строке запроса.
Пять шагов от пустого места до первого ответа бота. Дальше — вебхук вместо опроса (§7), медиа (§9–10) и кнопки (§13).
Боты заводятся прямо в приложении Gambu: профиль → «Мои боты» → создать. Там же живут перевыпуск токена, переключатели поведения и удаление бота.
Имя бота (username) обязано оканчиваться на bot — например weather_bot.
Токен показывается ровно один раз — при создании и при перевыпуске. В базе хранится только его хэш, восстановить потерянный токен нельзя, можно лишь перевыпустить (старый при этом перестаёт работать немедленно).
export TOKEN='bot_aZ12Qw34Er56:xK9...'
getMecurl -sS -H "Authorization: Bot $TOKEN" https://api.gambu.ru/bot/v1/getMe
Схема авторизации — Bot, не Bearer. Ответ с полями bot_id и username означает, что токен рабочий.
getUpdatesНапишите боту из приложения, затем:
curl -sS -H "Authorization: Bot $TOKEN" \
"https://api.gambu.ru/bot/v1/getUpdates?offset=0&timeout=25"
В ответе — массив updates. Возьмите из первого апдейта chat_id и update_id.
sendMessagecurl -sS -X POST https://api.gambu.ru/bot/v1/sendMessage \
-H "Authorization: Bot $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"chat_id":"<chat_id из апдейта>","text":"Привет!"}'
Следующий вызов getUpdates делайте с offset, равным update_id уже обработанного апдейта: выдача идёт строго «больше offset», и тем же числом апдейты подтверждаются и убираются из очереди.
Готовый цикл на Python — в §20.
| Поле | Правило |
|---|---|
username | 3–32 символа, только a-z, 0-9, _; обязан оканчиваться на bot, перед суффиксом нужна осмысленная часть от 2 символов. Регистр приводится к нижнему |
display_name | Имя, которое видно в чате, до 64 символов |
about | Описание, до 500 символов, необязательно |
Суффикс bot для бота обязателен и человеку недоступен: значок «BOT» есть только в интерфейсе, а имя уходит в цитаты, упоминания, ссылки /u/<username>, уведомления и журналы — там бота от человека отличает только имя. Пространство имён общее для людей, каналов и ботов: занятое имя повторно не выдаётся.
Один владелец может держать до 20 ботов.
Токен выглядит как <bot_id>:<секрет>, например bot_aZ12Qw34Er56:xK9.... Часть до двоеточия — это bot_id, он же идентификатор отправителя в сообщениях бота; он неизменен.
Срока годности у токена нет, автоматической ротации тоже: утёкший токен действует, пока владелец не перевыпустит его вручную («Мои боты» → перевыпуск). Перевыпуск действует немедленно — токены нигде не кэшируются.
Каждый запрос к /bot/v1/* несёт заголовок:
Authorization: Bot <token>
Именно Bot. Регистр самого слова схемы значения не имеет (Bot, bot), а Bearer не принимается: такой заголовок разбирается как «схемы Bot нет» и даёт 401 missing_token.
| Код | error | Когда |
|---|---|---|
| 401 | missing_token | Заголовка нет, он пуст или начинается не с Bot (в том числе Bearer) |
| 401 | invalid_token | Токен не разбирается, бота нет, бот выключен или хэш не сошёлся |
| 403 | feature_disabled | Платформа ботов недоступна владельцу этого бота |
| 503 | feature_unwired | Доступность платформы проверить не удалось |
| 403 | account_restricted | Владелец бота или сам бот ограничены модерацией (только на пишущих вызовах) |
| 503 | try_later | Проверить ограничение не удалось (только на пишущих вызовах) |
| 503 | storage_unavailable | Строку бота не удалось прочитать. Это не «токен плохой» — повторите запрос |
Последние две строки важны на практике: 503 означает «проверить не получилось», а не «доступ закрыт». Перевыпускать токен в ответ на них не нужно — нужен повтор с задержкой.
Если владельца бота ограничила модерация, ограничение распространяется и на его ботов: пишущие методы Bot API отвечают 403 account_restricted. Спрашивается мера и владельца, и самого бота.
Читающие методы (getMe, getUpdates, getMyCommands, getWebhookInfo) не ограничиваются. Ответ бота человеку, который сам ограничен, тоже разрешён.
Тело такого отказа шире обычного: кроме error в нём приходят status, until (unix-время окончания), reason (текст для человека) и appeal: "support" — обжалование подаётся через поддержку в приложении.
Успешный ответ — JSON, состав зависит от метода. У пишущих методов он обычно выглядит так:
{"ok": true, "message_id": "b3f1...", "created_at": 1758300000}
Ошибка — HTTP-код и тело с машинным кодом причины:
{"error": "not_a_member"}
Общее правило: 4xx — чините запрос, 5xx — повторяйте позже. Коды storage_unavailable и try_later всегда означают «проверить не удалось», а не «нельзя»; правильная реакция на них — повтор с экспоненциальной задержкой.
Полная таблица кодов — §19.
getMeGET /bot/v1/getMe
Возвращает карточку самого бота. Единственный способ убедиться, что токен рабочий.
curl -sS -H "Authorization: Bot $TOKEN" https://api.gambu.ru/bot/v1/getMe
{
"bot_id": "bot_aZ12Qw34Er56",
"username": "weather_bot",
"display_name": "Погода",
"about": "Прогноз по городу"
}
Ошибки — только авторизационные (§2).
Входящие события лежат в персональной очереди бота. Забрать их можно двумя способами: опросом (getUpdates, §6) или доставкой на ваш URL (вебхук, §7). Вебхук очередь не отменяет — если доставка не удалась, апдейт остаётся в очереди и достаётся опросом.
{
"update_id": 1758300000123456789,
"type": "message",
"chat_id": "c1b2...",
"from_user_id": "u9f8...",
"message_id": "9c4e...",
"msg_type": "image",
"text": "Смотри, что получилось",
"media_key": "media/9f1c....jpg",
"thumbnail_url": "thumbs/2ab4....jpg",
"file_url": "https://storage.yandexcloud.net/...",
"thumbnail_file_url": "https://storage.yandexcloud.net/...",
"topic_id": "t_7k2...",
"created_at": 1758300000
}
| Поле | Что это |
|---|---|
update_id | Монотонно растущий идентификатор. Им подтверждают обработку (offset) и по нему удобно дедуплицировать |
type | message, edited_message, callback_query, web_app_data |
chat_id | Чат, из которого пришло событие. Сюда же обычно и отвечают |
from_user_id | Кто отправил (человек, не бот) |
message_id | Сообщение, о котором апдейт: у message — само пришедшее сообщение (его можно процитировать, §8), у edited_message — исправленное, у callback_query — сообщение с кнопкой |
callback_query_id | Только у callback_query. Передаётся в answerCallbackQuery (§13) |
msg_type | Тип исходного сообщения: text, image, video, audio, video_note, file, sticker, gif, location, poll |
text | Текст сообщения; у медиа — подпись, если она есть, иначе короткая метка ([image], [file], [poll], [call]). Служебные данные карточки (имя и размер документа, звонок, опрос, геопозиция) в text не попадают — тип смотрите в msg_type. У callback_query — callback_data нажатой кнопки. У web_app_data — строка от мини-приложения |
media_key, thumbnail_url | Ключи объектов в хранилище. Напрямую по ним ничего не открывается, но они стабильны и годятся как идентификатор вложения |
file_url, thumbnail_file_url | Готовые ссылки на скачивание. Подписываются в момент выдачи и живут 1 час. Сохраняйте не ссылку, а ключ, и перевыпускайте ссылку через getFile |
topic_id | Тема группы, в которой написано сообщение. Отсутствует в личном чате, в группе без тем и в общей ленте. Есть только у type: "message" |
created_at | Unix-секунды |
Что стоит учесть:
chat_id — это не from_user_id. Отвечать нужно в чат.topic_id не приходит в callback_query и edited_message. Тема сообщения с кнопкой на пути нажатия не передаётся.type | Когда приходит |
|---|---|
message | Человек написал боту в личный чат или отправил команду в группе, где бот состоит |
edited_message | Человек исправил своё сообщение в личном чате с ботом. message_id — какое сообщение, text — новый текст по тому же правилу, что у message (у документа с заменённым файлом — [file]) |
callback_query | Нажата inline-кнопка с callback_data под сообщением бота |
web_app_data | Мини-приложение бота отправило данные боту (§17) |
В группе и канале боту пересылаются только команды, а не весь поток сообщений. Это правило постоянное и не отключается.
Командой считается сообщение, начинающееся с / и непробельного символа: /status, /help@weather_bot. Строки / привет и // — обычный текст.
/cmd без @ получают все боты чата;/cmd@username — только названный бот (сравнение без учёта регистра);Подпись к фото, начинающаяся с /cmd, тоже доходит как команда.
В личном чате пересылается всё: текст, медиа и правки.
Личный чат с ботом может завести любой, кто нашёл бота поиском, и по умолчанию его сообщения попадают в ту же очередь, что сообщения владельца.
Переключатель «только владелец» живёт в приложении, в карточке бота. Когда он включён, в очередь попадают только сообщения владельца; остальные остаются в чате, бот их просто не получает. Фильтр стоит на входе в очередь, поэтому фильтровать входящие на своей стороне не требуется.
getFileGET /bot/v1/getFile?chat_id=<id>&message_id=<id>&file_id=<ключ>
Ссылка file_url из апдейта живёт час. Если бот складывает апдейты в свою очередь, разбирает их пачкой или перезапустился и перечитал getUpdates со старого смещения, ссылка успеет истечь, и хранилище ответит 403. getFile подписывает ссылку заново по ключу, который в апдейте есть всегда.
curl -sSG "https://api.gambu.ru/bot/v1/getFile" \
-H "Authorization: Bot $TOKEN" \
--data-urlencode "chat_id=c1b2..." \
--data-urlencode "message_id=9c4e..."
| Параметр | Обязателен | Правило |
|---|---|---|
chat_id | да | Чат, где бот состоит. Берётся из апдейта |
message_id | да | Сообщение с вложением. Берётся из апдейта |
file_id | нет | Ключ вложения (media_key или thumbnail_url из апдейта). Без него берётся media_key сообщения |
Ответ:
{
"ok": true,
"file_id": "media/9f1c....jpg",
"file_url": "https://storage.yandexcloud.net/...",
"expires_in": 3600,
"msg_type": "image"
}
| Код | error | Когда |
|---|---|---|
| 400 | missing_params | Пустой chat_id или message_id |
| 400 | file_not_found | У сообщения нет такого вложения либо оно удалено |
| 403 | not_a_member | Бот не состоит в этом чате |
| 404 | message_not_found | Такого сообщения в этом чате нет |
| 503 | storage_unavailable | Прочитать не удалось. Повторите с задержкой |
| 503 | media_storage_not_configured | Хранилище недоступно |
Ссылка адресуется парой «чат + сообщение», а не одним ключом: подписывается только то вложение, которое действительно лежит в указанном сообщении указанного чата, и только если бот в этом чате состоит.
Срок выданной ссылки — тот же час, он приходит явным полем expires_in. Сохранять ссылку по-прежнему не нужно, сохраняйте file_id.
getUpdatesGET /bot/v1/getUpdates?offset=<int64>&timeout=<секунды>
Длинный опрос очереди. Отдаёт апдейты с update_id строго больше offset.
| Параметр | Значение |
|---|---|
offset | Подтверждение: «всё до этого номера включительно обработано». Апдейты с update_id <= offset удаляются из очереди. По умолчанию 0 |
timeout | Сколько секунд ждать, если очередь пуста. Максимум 30, по умолчанию 0 (ответить сразу) |
Размер выдачи фиксирован — 100 апдейтов за вызов; параметра limit нет. Если апдейтов больше, заберите остаток следующим вызовом.
curl -sS -H "Authorization: Bot $TOKEN" \
"https://api.gambu.ru/bot/v1/getUpdates?offset=$OFFSET&timeout=25"
{"updates": [{"update_id": 1758300000123456789, "type": "message", "...": "..."}]}
| Код | error | Когда |
|---|---|---|
| 503 | storage_unavailable | Очередь не прочитана. Это не «апдейтов нет» — повторите запрос |
Пустой список и отказ чтения — намеренно разные ответы: пустой ответ бот принимает за спокойный чат и следующую попытку делает нескоро.
Апдейты старше 7 суток из очереди удаляются. Основной способ держать очередь короткой — подтверждать обработанное параметром offset; у бота, который не подтверждает ничего, очередь будет возвращать одно и то же.
Альтернатива опросу: сервис сам делает POST на ваш URL с телом-апдейтом.
setWebhookPOST /bot/v1/setWebhook
{"url": "https://example.com/gambu/hook", "secret": "необязательно"}
| Поле | Обязателен | Правило |
|---|---|---|
url | да | http или https, порт 80 или 443, публичный адрес: внутренние и служебные адреса (localhost, частные сети, link-local) отклоняются, в том числе когда к ним ведёт DNS-имя или редирект. Редиректы не выполняются. Пустая строка эквивалентна deleteWebhook |
secret | нет | До 256 символов. Если не передать, сгенерируется секрет из 32 символов и вернётся в ответе один раз — он нужен для проверки подписи |
Ответ: {"ok": true, "url": "...", "secret": "..."}.
Отказы: 400 invalid_json, 400 invalid_url, 400 secret_too_long, 503 try_later.
deleteWebhookPOST /bot/v1/deleteWebhook — без тела. Снимает вебхук: апдейты снова забираются только опросом.
Ответ {"ok": true}, отказ — 503 try_later.
getWebhookInfoGET /bot/v1/getWebhookInfo
{"url": "https://example.com/gambu/hook", "has_secret": true}
Сам секрет не отдаётся — он показывается один раз при setWebhook. Отказ — 503 storage_unavailable.
POST, Content-Type: application/json, тело — один апдейт в том же формате, что в getUpdates (включая file_url). Плюс три заголовка:
| Заголовок | Значение |
|---|---|
X-Gambu-Update-Id | update_id апдейта |
X-Gambu-Webhook-Timestamp | Unix-секунды момента отправки; у каждой попытки своя |
X-Gambu-Bot-Signature | v1=<hex(HMAC_SHA256(secret, "<update_id>.<timestamp>.<тело>"))> |
Доставка считается успешной при любом ответе 2xx. Попыток три, с линейной задержкой (1 с, 2 с), таймаут одной попытки — 8 секунд. Подпись и метка времени пересчитываются на каждую попытку.
Если доставка не удалась, апдейт остаётся в очереди и его можно забрать через getUpdates. Автоматически вебхук при этом не отключается — доставка будет пробоваться и для следующих апдейтов.
Подписываются идентификатор, метка времени и тело — не только тело. Это закрывает и повтор перехваченного запроса, и подмену update_id, по которому вы дедуплицируете. Метка времени защищает, только если вы её проверяете: отбивайте запросы старше 5 минут.
Порядок обязателен: сначала подпись, потом свежесть. До сверки подписи ни метка времени, ни update_id доверия не заслуживают.
import hmac, hashlib, time
def verify(secret: str, sig: str, update_id: str, ts: str, body: bytes) -> int:
if not sig.startswith("v1="):
raise ValueError("неизвестная версия подписи")
want = hmac.new(secret.encode(),
f"{int(update_id)}.{int(ts)}.".encode() + body,
hashlib.sha256).hexdigest()
if not hmac.compare_digest(want, sig[3:].lower()):
raise ValueError("подпись не сходится")
if abs(time.time() - int(ts)) > 300: # допуск 5 минут в обе стороны
raise ValueError("запрос слишком старый")
return int(update_id)
sendMessagePOST /bot/v1/sendMessage
{
"chat_id": "c1b2...",
"text": "Готово: **отчёт** [по ссылке](https://gambu.ru)",
"parse_mode": "markdown",
"topic_id": "t_7k2...",
"reply_to_message_id": "9c4e...",
"reply_markup": {"inline_keyboard": [[{"text": "Ещё", "callback_data": "more"}]]}
}
| Поле | Обязателен | Правило |
|---|---|---|
chat_id | да | Чат, где бот состоит участником |
text | да | Непустой; обрезается до 4000 символов без ошибки |
parse_mode | нет | "markdown" — разобрать разметку (ниже). Без поля текст уходит как есть |
topic_id | нет | Тема группы (§14) |
reply_to_message_id | нет | Ответ цитатой на сообщение этого же чата (message_id из апдейта). Приложения рисуют его обычной цитатой |
reply_markup | нет | Клавиатура (§13) |
Ответ:
{"ok": true, "message_id": "9c4e...", "created_at": 1758300000, "topic_id": "t_7k2..."}
Поле topic_id в ответе: null, если это личный чат или у чата выключены темы; "general", если темы включены, а сообщение ушло в общую ленту.
parse_modeБез поля parse_mode текст уходит как есть. С "parse_mode": "markdown" сервер разбирает подмножество Markdown:
| Пишете | Получается |
|---|---|
**жирный** | жирный |
_курсив_ | курсив |
| текст между обратными кавычками | моноширинный, содержимое буквальное |
[текст](https://...) | текст-ссылка |
Вложенность работает (**жирный с _курсивом_**, [**жирная ссылка**](https://...)), а внутри моноширинного фрагмента — нет: там всё буквально. Чтобы отправить сам символ разметки, экранируйте его обратным слешем: \*, \_, \[, \\, а также обратный слеш перед обратной кавычкой.
Что важно знать заранее:
* разметкой не считается. В приложениях Gambu *так* — это курсив, а не жирный. Жирный — только **, курсив — только _.http и https, без пробелов. Схемы javascript:, data:, mailto:, tel: и адрес без схемы отбиваются: ссылку от бота открывает системный браузер на устройстве человека.entities нет ни в запросе, ни в апдейте — разметка живёт в самом тексте. Отсюда предел: экранированный символ остаётся в тексте обычным символом, и если таких символов окажется пара (\*раз\*), читатель снова увидит в них разметку. Такой запрос отбивается кодом bad_markup.bad_markup.bad_markup; неизвестная схема разбора (html, markdownv2) — bad_parse_mode.Разметка работает одинаково у sendMessage, у подписей медиа (§10) и у editMessageText (§11) — одним и тем же полем и по тем же правилам.
| Код | error | Когда |
|---|---|---|
| 400 | invalid_json | Тело не разбирается |
| 400 | missing_chat_id | Пустой chat_id |
| 400 | empty_message | Пустой text |
| 400 | bad_markup | Указан parse_mode: "markdown", а разметка не разбирается |
| 400 | bad_parse_mode | Неизвестная схема разбора (html, markdownv2, ...) |
| 400 | invalid_reply_markup | Клавиатура нарушает ограничения §13 |
| 400 | topic_not_found | Темы нет, она удалена, принадлежит другому чату, либо режим тем у чата выключен |
| 400 | reply_not_found | Цитируемого сообщения нет в этом чате |
| 403 | topic_closed | Тема закрыта для новых сообщений |
| 403 | not_a_member | Бот не участник этого чата |
| 403 | not_allowed_to_post, not_allowed_media, not_allowed_links, not_allowed_polls | Права роли в группе запрещают (§15) |
| 429 | rate_limited | Превышен лимит 30 отправок в секунду |
| 500 | internal_error | Сообщение не записалось |
| 503 | storage_unavailable, try_later | Не удалось проверить членство, права, тему или цитируемое сообщение. Повторите |
getUploadURLPOST /bot/v1/getUploadURL
Первый шаг двухшаговой отправки медиа. Ключей к хранилищу у бота нет, поэтому файл он заливает сам по подписанной ссылке, а серверу передаёт только ключ.
{"mime_type": "image/jpeg", "filename": "shot.jpg"}
| Поле | Обязателен | Правило |
|---|---|---|
mime_type | да | MIME-тип файла |
filename | нет | Нужен только ради расширения; без него расширение выводится из MIME-типа |
Ответ:
{
"key": "media/6f2a....jpg",
"upload_url": "https://storage.yandexcloud.net/...",
"thumbnail_key": "thumbs/1c8d....jpg",
"thumbnail_upload_url": "https://storage.yandexcloud.net/..."
}
Поля thumbnail_* приходят только для image/* и video/*. Превью необязательно, но с ним сообщение выглядит так же, как у человека.
Ссылка на загрузку живёт 15 минут. Файл заливается обычным PUT с телом файла — и только после успешного PUT вызывается метод отправки (§10).
Отказы: 400 invalid_json, 400 missing_mime_type, 503 media_storage_not_configured.
sendPhoto, sendDocument, sendVideo, sendAudio, sendVoiceВторой шаг отправки медиа: ключ из getUploadURL плюс необязательные подпись и длительность. Все пять методов принимают одно и то же тело.
| Метод | Тип сообщения | Как выглядит у получателя |
|---|---|---|
POST /bot/v1/sendPhoto | image | Картинка |
POST /bot/v1/sendDocument | file | Плашка файла |
POST /bot/v1/sendVideo | video | Видео с кадром-превью и плеером |
POST /bot/v1/sendAudio | audio | Голосовое сообщение с плеером |
POST /bot/v1/sendVoice | audio | То же самое |
Отдельного музыкального типа сообщения в Gambu нет: audio — это голосовое, приложения рисуют его плеером с длительностью и складывают во вкладку «Голосовые». Поэтому sendAudio и sendVoice дают одинаковый результат, и выбор между ними ни на что не влияет.
{
"chat_id": "c1b2...",
"media_key": "media/6f2a....mp4",
"thumbnail_key": "thumbs/1c8d....jpg",
"duration_ms": 12500,
"caption": "**Отчёт** за сутки",
"parse_mode": "markdown",
"topic_id": "t_7k2...",
"reply_to_message_id": "9c4e...",
"reply_markup": {"inline_keyboard": [[{"text": "Открыть", "url": "https://example.com"}]]}
}
| Поле | Обязателен | Правило |
|---|---|---|
chat_id | да | Чат, где бот состоит |
media_key | да | Ключ из getUploadURL; допустимы только префиксы media/ и thumbs/, без пробелов |
thumbnail_key | нет | Ключ превью; негодный даёт 400 bad_media |
duration_ms | нет | Длительность в миллисекундах, от 1 до 86 400 000 (сутки). Только для sendVideo, sendAudio и sendVoice |
caption | нет | Подпись, обрезается до 1024 символов |
parse_mode | нет | "markdown" — разобрать разметку подписи (§8) |
topic_id | нет | Тема группы (§14) |
reply_to_message_id | нет | Ответ цитатой на сообщение этого же чата (§8) |
reply_markup | нет | Клавиатура (§13) |
Ответ — такой же, как у sendMessage.
Отказы — все коды sendMessage, плюс:
| Код | error | Когда |
|---|---|---|
| 400 | invalid_media_key | Ключ не из загрузочного префикса или содержит пробелы |
| 400 | bad_media | Негодный thumbnail_key; отрицательная duration_ms или больше суток; duration_ms у sendPhoto и sendDocument, где длительности не бывает |
| 400 | media_not_uploaded | Объекта по этому ключу в хранилище нет: файл не залит или PUT не прошёл |
| 503 | try_later | Проверить наличие объекта не удалось |
Порядок обязателен: сначала PUT файла по upload_url, затем метод отправки. Проверяйте код ответа PUT у себя: сообщение, отправленное с ключом, по которому файл так и не появился, окажется в чате без содержимого.
Про видео стоит знать две вещи:
duration_ms — это подпись под сообщением, а не измеренная длительность файла. Значение не проверяется; присылайте настоящее.editMessageTextPOST /bot/v1/editMessageText
Меняет текст и/или клавиатуру своего ранее отправленного текстового сообщения. Открытые приложения обновляют его в реальном времени.
{"chat_id": "c1b2...", "message_id": "9c4e...", "text": "Готово", "reply_markup": null}
| Поле | Обязателен | Правило |
|---|---|---|
chat_id | да | Чат сообщения |
message_id | да | Идентификатор из ответа sendMessage |
text | да | Непустой, обрезается до 4000 символов |
parse_mode | нет | "markdown" — разобрать разметку (§8) |
reply_markup | нет | Новая клавиатура; отсутствие поля снимает прежнюю |
topic_id | нет | Только как подтверждение текущей темы сообщения (§14) |
Ответ:
{"ok": true, "message_id": "9c4e...", "edited_at": 1758300123, "topic_id": null}
| Код | error | Когда |
|---|---|---|
| 400 | invalid_json | Тело не разбирается |
| 400 | missing_params | Пустой chat_id или message_id |
| 400 | empty_message | Пустой text |
| 400 | bad_markup | Указан parse_mode: "markdown", а разметка не разбирается (§8) |
| 400 | bad_parse_mode | Неизвестная схема разбора |
| 400 | invalid_reply_markup | Клавиатура нарушает ограничения §13 |
| 400 | not_editable | Сообщение не текстовое: медиа править нельзя |
| 400 | topic_immutable | Передан topic_id, отличный от текущей темы сообщения |
| 403 | not_author | Сообщение отправлено не этим ботом |
| 404 | message_not_found | Такого сообщения нет |
| 500 | internal_error | Правка не записалась |
| 503 | storage_unavailable | Сообщение не прочитано. Повторите |
Два практических замечания:
chat_id должен указывать на чат правящегося сообщения. Членство бота в чате при правке не перепроверяется, и запрос с неверным chat_id ответит успехом, не изменив ничего.reply_markup заново.Лимита частоты у правки нет — он стоит только на отправке.
deleteMessagePOST /bot/v1/deleteMessage
Убирает своё ранее отправленное сообщение из чата. Служебные реплики («ищу...», одноразовый код, промежуточный результат) незачем оставлять в ленте навсегда, а editMessageText их не убирает — он только меняет текст.
{"chat_id": "c1b2...", "message_id": "9c4e..."}
| Поле | Обязателен | Правило |
|---|---|---|
chat_id | да | Чат сообщения |
message_id | да | Идентификатор из ответа метода отправки |
Ответ:
{"ok": true, "message_id": "9c4e..."}
Повторный вызов на уже удалённом сообщении ошибкой не считается: ответ тот же, плюс "already_deleted": true. Это позволяет просто повторить запрос, если ответ потерялся в сети.
Удалить можно только сообщение самого бота. Роль admin в группе этого не меняет: чужие сообщения удаляются в приложении, а не токеном бота.
Удаление доходит до открытых приложений сразу — и в ленту чата, и в строку списка чатов.
| Код | error | Когда |
|---|---|---|
| 400 | invalid_json | Тело не разбирается |
| 400 | missing_params | Пустой chat_id или message_id |
| 403 | not_own_message | Сообщение отправлено не этим ботом |
| 403 | not_a_member | Бот не участник этого чата |
| 404 | message_not_found | Такого сообщения в этом чате нет |
| 503 | storage_unavailable, try_later | Не удалось прочитать строку или записать пометку. Повторите |
Лимита частоты у удаления нет — он стоит только на отправке.
answerCallbackQueryПоле reply_markup принимает ровно один из трёх режимов; смешивать их нельзя — иначе 400 invalid_reply_markup.
{"inline_keyboard": [[{"text": "Да", "callback_data": "yes"},
{"text": "Сайт", "url": "https://example.com"}]]}
{"keyboard": [[{"text": "Погода"}, {"text": "Курс"}]]}
{"remove_keyboard": true}
| Режим | Что делает |
|---|---|
inline_keyboard | Кнопки под сообщением. У каждой кнопки ровно одно из полей callback_data или url |
keyboard | Заменяет системную клавиатуру человека; нажатие отправляет подпись кнопки обычным сообщением |
remove_keyboard | Убирает ранее показанную keyboard |
Ограничения — нарушение даёт 400 invalid_reply_markup, а не обрезку:
| Что | Предел |
|---|---|
| Рядов в клавиатуре | 100 |
| Кнопок в ряду | 1–8, пустой ряд запрещён |
| Длина подписи кнопки | 64 символа, непустая |
Длина callback_data | 64 символа |
url кнопки | http или https с непустым хостом |
Значение callback_data другим участникам чата не показывается — это приватная нагрузка кнопки.
answerCallbackQueryPOST /bot/v1/answerCallbackQuery
Закрывает нажатие inline-кнопки: показывает нажавшему всплывающее уведомление.
{"callback_query_id": "3f9c...", "text": "Принято", "show_alert": false}
| Поле | Обязателен | Правило |
|---|---|---|
callback_query_id | да | Из апдейта callback_query |
text | нет | Обрезается до 200 символов |
show_alert | нет | true — модальное окно вместо всплывающей подсказки |
Ответ — всегда {"ok": true}. Истёкший, чужой или уже отвеченный идентификатор тоже даёт ok: метод идемпотентен, повторный вызов безопасен.
Отвечать нужно в течение 5 минут — столько живёт незакрытое нажатие. Незакрытые нажатия не переживают обновление сервиса, поэтому ответ на них безопасно повторять: метод идемпотентен.
Отказы: 400 invalid_json, 400 missing_callback_query_id.
Группа с включённым режимом тем — это набор отдельных лент, и каждое сообщение принадлежит одной из них.
message несёт topic_id того сообщения, о котором он. Поля нет — значит это личный чат, группа без тем или общая лента.sendMessage и методы отправки медиа принимают необязательный topic_id и возвращают его в ответе: null вне режима тем, "general" — общая лента чата с темами.editMessageText тему не меняет. Передать topic_id можно, но только равный текущей теме сообщения (у сообщения в общей ленте — "general"), иначе 400 topic_immutable.callback_query и edited_message.Чтобы отвечать туда, откуда пришёл вопрос, просто верните topic_id из апдейта в метод отправки.
| Код | error | Когда |
|---|---|---|
| 400 | topic_not_found | Темы нет, она удалена, принадлежит другому чату — или режим тем у чата выключен, а topic_id передан |
| 403 | topic_closed | Тема закрыта для новых сообщений |
| 400 | topic_immutable | editMessageText с темой, отличной от текущей |
| 503 | try_later | Проверить тему не удалось |
Последняя строка — не формальность: «тему проверить не удалось» не равно «темы нет». В этом случае сообщение не отправляется вовсе, потому что молчаливая отправка в общую ленту увела бы ответ мимо адреса, а перенести его потом нечем.
Бот состоит в группе обычным участником с ролью, и права роли действуют на него так же, как на человека. Если участникам запрещено отправлять медиа или ссылки, бот с ролью member получит 403 not_allowed_media или 403 not_allowed_links. Иначе запрет обходился бы за минуту: достаточно было бы завести бота и отправлять через него.
Для законного случая есть роль admin: владелец чата выдаёт её боту-публикатору (лента, дайджест, оповещения), и ограничения роли member перестают действовать.
Отключение звука и медленный режим к ботам не применяются — их роль играет собственный лимит частоты (§18).
Добавляет бота в группу человек — из приложения, как обычного участника. Методов «вступить в чат» и «выйти из чата» у Bot API нет.
Правило «в группах приходят только команды» (§5) эту дверь не закрывает: оно про то, что бот получает, а не про то, что он отправляет.
setMyCommands и getMyCommandsКоманды — это метаданные для меню в приложении. Сервер их не интерпретирует: /start и всё остальное доходит до бота обычным апдейтом, логика остаётся на вашей стороне.
setMyCommandsPOST /bot/v1/setMyCommands
{"commands": [{"command": "start", "description": "Начать"},
{"command": "help", "description": "Что я умею"}]}
Метод полностью заменяет список. Пустой массив очищает меню.
| Что | Правило |
|---|---|
| Имя команды | 1–32 символа a-z, 0-9, _. Ведущий / снимается, регистр приводится к нижнему |
| Описание | 1–256 символов, непустое |
| Количество | до 100 |
| Дубли по имени | отбрасываются, остаётся первый |
Ответ: {"ok": true, "commands": [...]} — нормализованный список.
Отказы: 400 invalid_json, 400 invalid_commands (негодное имя, пустое или слишком длинное описание, больше 100 команд), 500 internal_error.
getMyCommandsGET /bot/v1/getMyCommands → {"commands": [...]}.
Отказ — 503 storage_unavailable.
Мини-приложение — это бот с веб-страницей: приложение Gambu открывает ваш URL во встроенном браузере и передаёт туда подписанную строку initData с личностью пользователя. Формат — стандартная строка запроса с парами «ключ-значение» и полем hash.
Как включить. Владелец задаёт URL своему боту в приложении; по API это POST /v1/bots/:botId/webapp под токеном владельца, тело {"web_app_url": "https://...", "published": true}. URL обязан быть https. Значение published: true выводит мини-приложение в общий каталог.
Что получает ваш фронтенд. Приложение вызывает POST /v1/apps/:username/launch и получает:
{
"url": "https://example.com/app",
"init_data": "auth_date=1758300000&hash=...&query_id=...&user=%7B...%7D",
"expires_in": 86400,
"bot": {"bot_id": "bot_aZ12...", "username": "shop_bot", "display_name": "Магазин"}
}
Значение init_data — URL-кодированная строка запроса с парами auth_date, query_id, user (компактный JSON: id, first_name, опционально username и photo_url) и hash.
Как проверить initData на своём сервере. Секрет подписи выводится из токена бота:
secret_key = HMAC_SHA256(key="WebAppData", data=<токен бота>) # hex
data_check = пары кроме hash, отсортированные по ключу, "k=v" через "\n"
hash = hex(HMAC_SHA256(key=<байты secret_key>, data=data_check))
Срок годности initData — 24 часа (auth_date входит в подпись, запас на расхождение часов — 5 минут в будущее). Проверять возраст обязаны вы: подпись без проверки срока — это вечный пропуск, а перехваченная строка открывает мини-приложение от чужого имени. Истёк — приложение запросит запуск заново.
Перевыпуск токена бота меняет и секрет подписи: выданные ранее строки initData перестают сходиться сразу.
Если запуск отвечает 400 webapp_unavailable, у бота ещё нет секрета подписи — так бывает у ботов, созданных до появления мини-приложений. Лечится перевыпуском токена.
Обратный канал. Мини-приложение отправляет боту произвольную строку — она приходит апдейтом type: "web_app_data", где text — сама строка (до 4096 символов), а chat_id — личный чат человека с ботом.
| Что | Предел | Что при превышении |
|---|---|---|
| Отправок в секунду на бота | 30 (все методы send*) | 429 rate_limited |
| Длина текста сообщения | 4000 символов | Молча обрезается |
| Длина подписи к медиа | 1024 символа | Молча обрезается |
Длина текста answerCallbackQuery | 200 символов | Молча обрезается |
| Длина строки от мини-приложения | 4096 символов | Молча обрезается |
callback_data | 64 символа | 400 invalid_reply_markup |
| Подпись кнопки | 64 символа | 400 invalid_reply_markup |
| Рядов и кнопок в клавиатуре | 100 рядов по 8 кнопок | 400 invalid_reply_markup |
| Команд в меню | 100 | 400 invalid_commands |
| Ботов на одного владельца | 20 | Отказ при создании |
| Жизнь апдейта в очереди | 7 суток | Удаляется |
Жизнь file_url в апдейте | 1 час | Ссылка перестаёт работать; перевыпустите её через getFile (§5) |
Жизнь ссылки из getFile | 1 час, поле expires_in | То же: вызовите getFile заново |
duration_ms у медиа | 1 сутки | 400 bad_media |
Жизнь upload_url | 15 минут | PUT перестаёт приниматься |
Жизнь callback_query_id | 5 минут | answerCallbackQuery молча отвечает ok |
Срок годности initData | 24 часа | Запросить запуск заново |
timeout у getUpdates | 30 секунд | Значение обрезается до 30 |
Размер выдачи getUpdates | 100 апдейтов | Заберите остаток следующим вызовом |
Лимит частоты считается отдельно для каждого бота.
| Код | error | Где | Что делать |
|---|---|---|---|
| 400 | invalid_json | везде | Починить тело запроса |
| 400 | missing_chat_id | отправка | Заполнить chat_id |
| 400 | missing_params | editMessageText, deleteMessage, getFile | Заполнить chat_id и message_id |
| 400 | empty_message | отправка, правка | Прислать непустой text |
| 400 | bad_markup | отправка, правка | Разметка не разбирается — см. §8 |
| 400 | bad_parse_mode | отправка, правка | Из схем разбора доступна только markdown |
| 400 | invalid_reply_markup | отправка, правка | Привести клавиатуру к ограничениям §13 |
| 400 | invalid_media_key | медиа | Взять ключ из getUploadURL |
| 400 | media_not_uploaded | медиа | Сначала PUT файла, потом отправка |
| 400 | bad_media | медиа | Негодный thumbnail_key либо duration_ms вне правил §10 |
| 400 | file_not_found | getFile | У этого сообщения нет такого вложения либо оно удалено |
| 400 | missing_mime_type | getUploadURL | Указать mime_type |
| 400 | not_editable | editMessageText | Править можно только текстовые сообщения |
| 400 | topic_not_found | отправка | Проверить, что тема жива и режим тем у чата включён |
| 400 | topic_immutable | editMessageText | Не менять тему правкой |
| 400 | reply_not_found | отправка | Цитировать можно только живое сообщение этого же чата |
| 400 | invalid_url | setWebhook | http/https, порт 80/443, публичный адрес |
| 400 | secret_too_long | setWebhook | Секрет до 256 символов |
| 400 | invalid_commands | setMyCommands | Привести список к правилам §16 |
| 400 | missing_callback_query_id | answerCallbackQuery | Взять идентификатор из апдейта |
| 401 | missing_token | везде | Добавить заголовок Authorization: Bot <token>; схема Bearer не принимается |
| 401 | invalid_token | везде | Токен не подошёл, бот удалён или выключен. Повторять запрос бессмысленно |
| 403 | feature_disabled | везде | Платформа ботов недоступна владельцу бота |
| 503 | feature_unwired | везде | Проверить доступность платформы не удалось. Повторить |
| 403 | account_restricted | пишущие | Ограничение модерацией; обжалование — через поддержку в приложении |
| 403 | not_a_member | отправка, deleteMessage, getFile | Добавить бота в чат |
| 403 | not_author | editMessageText | Править можно только свои сообщения |
| 403 | not_own_message | deleteMessage | Удалять можно только свои сообщения |
| 403 | not_allowed_to_post, not_allowed_media, not_allowed_links, not_allowed_polls | отправка | Права роли в группе; поможет роль admin (§15) |
| 403 | topic_closed | отправка | Тема закрыта |
| 404 | message_not_found | editMessageText, deleteMessage, getFile | Такого сообщения нет |
| 429 | rate_limited | отправка | Подождать и повторить |
| 500 | internal_error | запись | Повторить; если повторяется — написать на support@gambu.ru |
| 503 | storage_unavailable | везде | Прочитать не удалось. Повторить с нарастающей задержкой |
| 503 | try_later | пишущие | Проверить не удалось. Повторить с нарастающей задержкой |
| 503 | media_storage_not_configured | getUploadURL, getFile | Хранилище недоступно; повторить позже |
TOKEN='bot_aZ12Qw34Er56:xK9...'
curl -sS -X POST https://api.gambu.ru/bot/v1/sendMessage \
-H "Authorization: Bot $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"chat_id":"c1b2...","text":"Привет из curl"}'
# 1. взять ссылку на загрузку
RESP=$(curl -sS -X POST https://api.gambu.ru/bot/v1/getUploadURL \
-H "Authorization: Bot $TOKEN" -H 'Content-Type: application/json' \
-d '{"mime_type":"image/jpeg","filename":"shot.jpg"}')
KEY=$(printf '%s' "$RESP" | python3 -c 'import json,sys;print(json.load(sys.stdin)["key"])')
URL=$(printf '%s' "$RESP" | python3 -c 'import json,sys;print(json.load(sys.stdin)["upload_url"])')
# 2. залить файл прямо в хранилище
curl -sS -X PUT --upload-file shot.jpg "$URL"
# 3. отправить сообщение с этим ключом
curl -sS -X POST https://api.gambu.ru/bot/v1/sendPhoto \
-H "Authorization: Bot $TOKEN" -H 'Content-Type: application/json' \
-d "{\"chat_id\":\"c1b2...\",\"media_key\":\"$KEY\",\"caption\":\"Отчёт\"}"
import json, urllib.request, urllib.error
BASE = "https://api.gambu.ru/bot/v1"
class Gambu:
def __init__(self, token: str):
self.token = token
def call(self, method: str, payload: dict | None = None, query: str = ""):
url = f"{BASE}/{method}{query}"
data = json.dumps(payload).encode() if payload is not None else None
req = urllib.request.Request(url, data=data, method="POST" if data else "GET")
req.add_header("Authorization", f"Bot {self.token}")
if data:
req.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(req, timeout=40) as r:
return json.load(r)
except urllib.error.HTTPError as e:
body = json.loads(e.read() or b"{}")
raise RuntimeError(f"{e.code} {body.get('error', '?')}") from None
import time
bot = Gambu("bot_aZ12Qw34Er56:xK9...")
offset = 0
while True:
try:
res = bot.call("getUpdates", query=f"?offset={offset}&timeout=25")
except RuntimeError as e:
print("не удалось забрать апдейты:", e) # 503 — повторяем, не сдаёмся
time.sleep(5)
continue
for u in res["updates"]:
offset = u["update_id"] # подтверждаем всё до этого номера
if u["type"] != "message" or not u.get("text"):
continue
bot.call("sendMessage", {
"chat_id": u["chat_id"],
"text": "Вы написали: " + u["text"],
# отвечаем в ту же тему, если сообщение пришло из темы
**({"topic_id": u["topic_id"]} if u.get("topic_id") else {}),
})
Два неочевидных места в этом цикле:
offset двигается на update_id обработанного апдейта, а не на +1 — выдача идёт строго «больше offset».503 — не повод остановиться. Отказ чтения очереди и пустая очередь приходят разными ответами именно затем, чтобы сбой не был принят за тишину.Чтобы не искать: перечисленного ниже в Gambu Bot API нет, и ничем из описанного выше это не заменяется.
entities. Разметка живёт в самом тексте, массива сущностей нет ни в запросе, ни в апдейте. Форматирование при этом есть — parse_mode: "markdown" у отправки, подписей и правки, §8.HTML и MarkdownV2 не поддерживаются; незнакомое значение parse_mode отбивается кодом bad_parse_mode.parse_mode бот их пока не отправляет.reply_to_message_id у методов отправки, §8.limit и выбора типов апдейтов у getUpdates.getFile (§5) перевыпускает ссылку по ключу из апдейта и адресуется парой «чат + сообщение»: отдельный идентификатор, живущий сам по себе, позволял бы подписывать объекты чужих чатов.getFile отдаёт ссылку, качает бот.reply_markup — мини-приложение открывается из каталога и из карточки бота.Вопросы по Bot API — support@gambu.ru.