diff --git a/cmd/server/main.go b/cmd/server/main.go index 0c2abf5..8459cdf 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -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 diff --git a/internal/telegram/webhook.go b/internal/telegram/webhook.go new file mode 100644 index 0000000..17c7fbf --- /dev/null +++ b/internal/telegram/webhook.go @@ -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 +} diff --git a/internal/telegram/webhook_test.go b/internal/telegram/webhook_test.go new file mode 100644 index 0000000..929316c --- /dev/null +++ b/internal/telegram/webhook_test.go @@ -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) + } +}