fix(telegram): clear webhook via Bot API GET to avoid empty-body POST

go-telegram's DeleteWebhook posts an empty multipart form (DropPendingUpdates
is omitempty); some networks answer that bodyless POST with an empty response,
failing to parse and leaving the webhook active so getUpdates 409s forever.
Replace it with a plain GET /deleteWebhook, which sends no body and parses the
JSON reliably. Keeps pending updates for a lossless cutover.
This commit is contained in:
2026-06-28 22:41:49 +07:00
parent d84c7b619c
commit 341a0b7a1d
3 changed files with 123 additions and 8 deletions
+8 -8
View File
@@ -15,7 +15,6 @@ import (
"github.com/aws/aws-sdk-go-v2/aws"
awsconfig "github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/ssm"
"github.com/go-telegram/bot"
"github.com/tiennm99/miti99bot/internal/ai"
"github.com/tiennm99/miti99bot/internal/cron"
"github.com/tiennm99/miti99bot/internal/deploynotify"
@@ -147,7 +146,7 @@ func main() {
// Clear any webhook left over from the AWS deployment at startup, before the
// owner DM and before polling. getUpdates (long polling, below) returns HTTP
// 409 while a webhook is set, so a stuck webhook silently breaks the bot.
clearWebhook(rootCtx, b)
clearWebhook(rootCtx, cfg.TelegramBotToken)
deploynotify.Run(rootCtx, deploynotify.Config{
Bot: b,
@@ -205,13 +204,14 @@ const (
)
// clearWebhook deletes any configured webhook so getUpdates (long polling) does
// not 409. It is best-effort but retried: a transient empty-body decode error
// on the first attempt must not leave the bot permanently unable to poll.
// DropPendingUpdates=false preserves Telegram's buffered queue so the poller
// drains updates that arrived during cutover (lossless cut).
func clearWebhook(ctx context.Context, b *bot.Bot) {
// not 409. It is best-effort but retried so a flaky network does not leave the
// bot permanently unable to poll. Uses telegram.DeleteWebhook (a plain Bot API
// GET) rather than the go-telegram helper, whose empty-multipart POST comes back
// with an empty body in some environments. Pending updates are kept so the
// poller drains the buffered queue for a lossless cutover.
func clearWebhook(ctx context.Context, token string) {
for attempt := 1; attempt <= webhookDeleteAttempts; attempt++ {
_, err := b.DeleteWebhook(ctx, &bot.DeleteWebhookParams{DropPendingUpdates: false})
err := telegram.DeleteWebhook(ctx, token)
if err == nil {
log.Info("webhook cleared", "attempt", attempt)
return
+64
View File
@@ -0,0 +1,64 @@
package telegram
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
// telegramAPIBase is the Bot API host (matches go-telegram's default).
const telegramAPIBase = "https://api.telegram.org"
// webhookDeleteTimeout bounds a single deleteWebhook HTTP call so a stalled
// connection cannot wedge startup.
const webhookDeleteTimeout = 10 * time.Second
// DeleteWebhook clears any configured webhook via a plain Bot API GET.
//
// go-telegram's b.DeleteWebhook posts an empty multipart form (DeleteWebhookParams
// is all-omitempty), and some networks answer that bodyless POST with an empty
// response body — decoded as "unexpected end of JSON input" — so the webhook is
// never actually removed and getUpdates keeps returning 409. A GET with no body
// sidesteps that request shape. Pending updates are intentionally kept (the API
// default) so the long poller drains the buffered queue for a lossless cutover.
func DeleteWebhook(ctx context.Context, token string) error {
return deleteWebhookAt(ctx, telegramAPIBase, token)
}
// deleteWebhookAt is the testable core; base lets tests point at an httptest
// server instead of the live API.
func deleteWebhookAt(ctx context.Context, base, token string) error {
ctx, cancel := context.WithTimeout(ctx, webhookDeleteTimeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+"/bot"+token+"/deleteWebhook", nil)
if err != nil {
return err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
var r struct {
OK bool `json:"ok"`
Description string `json:"description"`
}
if err := json.Unmarshal(body, &r); err != nil {
// Body is safe to log (no secret); never include the URL (carries the token).
return fmt.Errorf("decode deleteWebhook response (status %d, body %q): %w", resp.StatusCode, string(body), err)
}
if !r.OK {
return fmt.Errorf("deleteWebhook rejected (status %d): %s", resp.StatusCode, r.Description)
}
return nil
}
+51
View File
@@ -0,0 +1,51 @@
package telegram
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestDeleteWebhookAt_OK(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
t.Errorf("method = %s, want GET", r.Method)
}
if !strings.HasSuffix(r.URL.Path, "/deleteWebhook") {
t.Errorf("path = %s, want suffix /deleteWebhook", r.URL.Path)
}
_, _ = w.Write([]byte(`{"ok":true,"result":true}`))
}))
defer srv.Close()
if err := deleteWebhookAt(context.Background(), srv.URL, "token"); err != nil {
t.Fatalf("deleteWebhookAt: %v", err)
}
}
func TestDeleteWebhookAt_EmptyBody(t *testing.T) {
// Reproduces the failing environment: 200 with an empty body. Must surface
// as an error so the caller retries / warns rather than assuming success.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK) // no body
}))
defer srv.Close()
if err := deleteWebhookAt(context.Background(), srv.URL, "token"); err == nil {
t.Fatal("expected error on empty body, got nil")
}
}
func TestDeleteWebhookAt_APIRejected(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"ok":false,"description":"Unauthorized"}`))
}))
defer srv.Close()
err := deleteWebhookAt(context.Background(), srv.URL, "token")
if err == nil || !strings.Contains(err.Error(), "Unauthorized") {
t.Fatalf("want rejection error mentioning Unauthorized, got %v", err)
}
}