mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-09-08 04:17:34 +00:00
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:
+8
-8
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user