diff --git a/cmd/server/main.go b/cmd/server/main.go index e24db05..0c2abf5 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -146,12 +146,8 @@ 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. drop_pending_updates=false preserves Telegram's - // buffered queue so the poller drains updates that arrived during cutover - // (lossless cut). - if _, err := b.DeleteWebhook(rootCtx, &bot.DeleteWebhookParams{DropPendingUpdates: false}); err != nil { - log.Warn("deleteWebhook failed; getUpdates may 409 if a webhook is still set", "err", err) - } + // 409 while a webhook is set, so a stuck webhook silently breaks the bot. + clearWebhook(rootCtx, b) deploynotify.Run(rootCtx, deploynotify.Config{ Bot: b, @@ -198,6 +194,41 @@ func main() { } } +// webhookDeleteAttempts bounds the startup webhook-clear retries, and +// webhookDeleteBackoff is the wait between them. Telegram's edge sometimes +// returns an empty body on the first request after a cold container start +// ("unexpected end of JSON input"); a short retry rides over that blip so long +// polling is not left blocked by a stale webhook. +const ( + webhookDeleteAttempts = 5 + webhookDeleteBackoff = 1 * time.Second +) + +// 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) { + for attempt := 1; attempt <= webhookDeleteAttempts; attempt++ { + _, err := b.DeleteWebhook(ctx, &bot.DeleteWebhookParams{DropPendingUpdates: false}) + if err == nil { + log.Info("webhook cleared", "attempt", attempt) + return + } + log.Warn("deleteWebhook failed", "attempt", attempt, "err", err) + if attempt == webhookDeleteAttempts { + break + } + select { + case <-ctx.Done(): + return + case <-time.After(webhookDeleteBackoff): + } + } + log.Warn("deleteWebhook still failing after retries; getUpdates may 409 if a webhook is set") +} + // buildProvider picks the storage backend. Selection order: // 1. Explicit KV_PROVIDER env (memory|mongodb) wins. // 2. Auto-detect: MONGO_URL set → mongodb; otherwise memory. diff --git a/internal/modules/registry.go b/internal/modules/registry.go index 4d4b43c..3cec58b 100644 --- a/internal/modules/registry.go +++ b/internal/modules/registry.go @@ -104,6 +104,18 @@ func Build(enabled []string, factories map[string]Factory, provider storage.Prov return nil, fmt.Errorf("modules: storage Provider is required") } + // Empty/unset MODULES means "load every registered module" — the documented + // contract (.env.example, docker-compose.yml, deploy docs). Expand to the + // full catalog in sorted order so the load order (and thus CommandHook + // registration order) is deterministic across restarts. + if len(enabled) == 0 { + enabled = make([]string, 0, len(factories)) + for name := range factories { + enabled = append(enabled, name) + } + sort.Strings(enabled) + } + reg := &Registry{ AllCommands: map[string]Command{}, publicCmds: map[string]Command{}, diff --git a/internal/modules/registry_test.go b/internal/modules/registry_test.go index b8413ad..a594abf 100644 --- a/internal/modules/registry_test.go +++ b/internal/modules/registry_test.go @@ -47,6 +47,27 @@ func TestBuild_EmptyModulesBootsCleanly(t *testing.T) { } } +func TestBuild_EmptyModulesLoadsAllRegistered(t *testing.T) { + // Empty/unset MODULES is the documented "load every module" contract. + factories := map[string]Factory{ + "alpha": factory("alpha", []Command{noopCmd("a1")}, nil), + "beta": factory("beta", []Command{noopCmd("b1")}, []Cron{noopCron("daily")}), + } + reg, err := Build(nil, factories, newProvider(), BuildOptions{}) + if err != nil { + t.Fatalf("Build nil enabled: %v", err) + } + if len(reg.Modules) != len(factories) { + t.Fatalf("expected all %d modules loaded, got %d", len(factories), len(reg.Modules)) + } + if _, ok := reg.AllCommands["a1"]; !ok { + t.Error("missing command a1 from auto-loaded alpha") + } + if _, ok := reg.Cron("daily"); !ok { + t.Error("missing cron daily from auto-loaded beta") + } +} + func TestBuild_LoadsRequestedModules(t *testing.T) { factories := map[string]Factory{ "alpha": factory("alpha", []Command{noopCmd("a1")}, nil),