Как Google Apps Script незаметно ломает вебхуки Telegram-ботов

от автора

Недавно собирал Telegram-бота на связке Google Apps Script + Telegram Bot API — казалось бы, стандартная и хорошо описанная схема: webhook, doPost(e), никакого сервера держать не надо. Всё прошло тесты, бот отвечал, я был доволен — а потом бот стал молчать по ночам без видимой причины, и найти причину оказалось неожиданно непросто, потому что мои же инструменты для тестирования её маскировали.

Симптом

Бот работал отлично, когда я тестировал его сам — отправлял POST-запросы напрямую на .../exec через requests в Python, получал 200 ok, всё логично. Но когда реальный пользователь писал боту с телефона, сообщения иногда просто не доходили. Никакой закономерности на первый взгляд: то работает, то нет.

Ложный след

Первым делом заподозрил гонку состояний или проблему с повторной доставкой апдейтов — благо, дело было не на пустом месте: чуть раньше у бота уже был реальный баг с отсутствием идемпотентности, из-за которого один зависший ретрай Telegram превратился в ~300 дублей сообщений за ночь. Добавил проверку по update_id через CacheService — правильное исправление само по себе, но проблема с молчанием бота осталась.

Настоящая причина

Разгадка нашлась в getWebhookInfo — методе Telegram Bot API, который показывает статус вебхука, включая последнюю ошибку доставки:

{  "url": "https://script.google.com/macros/s/AKfycb.../exec",  "last_error_message": "Wrong response from the webhook: 302 Found"}

Вот и всё объяснение. У Google Apps Script есть не очень широко известная особенность: адрес вида https://script.google.com/macros/s/{id}/exec, задеплоенный как Web App, может отвечать неаутентифицированному вызывающему (а Telegram именно такой — у него нет и не может быть Google-сессии) редиректом 302 на script.googleusercontent.com вместо прямого 200. Браузеры и большинство HTTP-клиентов эту редирекцию просто прозрачно проходят — а вот система доставки вебхуков Telegram редиректы не обрабатывает вообще. Она либо получает 200 сразу же, либо считает доставку неуспешной и повторяет попытку позже.

Почему я не увидел это раньше

Потому что я тестировал через requests.post(url, ...) в Python — а эта библиотека по умолчанию сама следует за редиректами, полностью прозрачно для вызывающего кода. Мои собственные проверки показывали стабильные 200 ok на каждый запрос, потому что requests незаметно доходил до финального адреса за меня. Проблема была видна только с точки зрения самого Telegram, у которого такой прозрачности нет.

Решение

Раз Google не отвечает надёжным 200 на входящие POST-запросы для неаутентифицированных вызывающих, самый чистый выход — вообще не полагаться на то, что Telegram сможет достучаться до Apps Script напрямую. Вместо doPost (webhook) — time-based триггер, который сам, раз в минуту, спрашивает Telegram: «есть что-то новое?» через getUpdates.

function pollTelegram() {  var props = PropertiesService.getScriptProperties();  var offset = Number(props.getProperty('TG_OFFSET') || '0');  var resp = UrlFetchApp.fetch(    API + '/getUpdates?offset=' + offset + '&timeout=0',    { method: 'get', muteHttpExceptions: true }  );  var data = JSON.parse(resp.getContentText());  if (!data.ok || !data.result.length) return;  data.result.forEach(function (update) {    offset = update.update_id + 1;    // ...обработка апдейта...  });  props.setProperty('TG_OFFSET', String(offset));}ScriptApp.newTrigger('pollTelegram').timeBased().everyMinutes(1).create();

Исходящие запросы через UrlFetchApp (когда Apps Script сам является инициатором) работают надёжно — проблема именно во входящих запросах на неаутентифицированный /exec-эндпоинт. Поменяв направление связи, редирект просто перестаёт быть релевантным.

Бонусный баг, который я сам туда внёс

Первая версия pollTelegram вызывала UrlFetchApp.fetch с method: 'get' и параметрами в payload. Для GET-запросов payload в UrlFetchApp не превращается в query-параметры — их нужно вручную собирать в саму строку URL. В итоге offset тихо никогда не отправлялся, и каждый запуск заново вычитывал один и тот же бэклог вместо только новых сообщений. Не опасно (идемпотентность спасала от дублей), но неэффективно и сбивало с толку, пока не заметил.

Что унести с собой

Если интеграция полагается на то, что внешний сервис (Telegram, Stripe, GitHub — неважно) сможет достучаться POST-запросом до Apps Script, стоит проверять доставку тем же способом, каким её проверяет реальный отправитель — а не HTTP-клиентом, который сам решает за вас часть проблем. curl -sI без -L, или прямая проверка last_error_message в getWebhookInfo — самый быстрый способ поймать именно эту категорию бага.


Автор ведёт блог о разработке — КодБлог.

ссылка на оригинал статьи https://habr.com/ru/articles/1066054/