// Package deploynotify sends a one-shot Telegram DM to the bot owner when // the binary starts up with a git SHA that hasn't been notified before. // // Wiring: cmd/server/main.go calls Run after modules.Install. The package- // level gitSHA variable in main is populated via -ldflags at build time // (see Makefile). An empty gitSHA — e.g. `go run` or a build without the // ldflags — is treated as a signal to stay silent. // // Dedup: a single store record (key=last_notified_sha) holds the most recently // notified SHA. On match we return early; on miss we send first, then write // — so a transient Telegram failure doesn't permanently silence retries. package deploynotify import ( "context" "errors" "fmt" "time" "github.com/go-telegram/bot" "github.com/tiennm99/miti99bot/internal/log" "github.com/tiennm99/miti99bot/internal/storage" ) // storeKey is the single store slot this package owns. const storeKey = "last_notified_sha" // defaultTimeout caps the whole Run path (store read + Telegram send + store write) // so a misbehaving network can never block Lambda init past its 10s budget. const defaultTimeout = 3 * time.Second // Config bundles the runtime dependencies. Sender is a seam for tests; when // nil, Run falls back to bot.SendMessage. type Config struct { Bot *bot.Bot Store storage.DocStore[notifyRecord] OwnerID int64 GitSHA string Timeout time.Duration // Sender is the indirection used by tests. Production wiring leaves it // nil and Run uses cfg.Bot.SendMessage. Sender func(ctx context.Context, chatID int64, text string) error } // notifyRecord is the store value shape. At is informational only — useful for // eyeballing in the database console; not consulted by the code path. type notifyRecord struct { SHA string `json:"sha" bson:"sha"` At int64 `json:"at" bson:"at"` } // NewStore builds the typed store this package needs from a module Collection. // notifyRecord is unexported, so callers cannot name DocStore[notifyRecord] // directly — this is the seam that lets cmd/server wire the store. func NewStore(c storage.Collection) storage.DocStore[notifyRecord] { return storage.Typed[notifyRecord](c) } // Run is the entry point. Fire-and-forget — never returns an error and // never panics. Designed to be called once during process init. func Run(ctx context.Context, cfg Config) { if reason := skipReason(cfg); reason != "" { log.Info("deploynotify skip", "reason", reason) return } timeout := cfg.Timeout if timeout <= 0 { timeout = defaultTimeout } ctx, cancel := context.WithTimeout(ctx, timeout) defer cancel() notify, err := shouldNotify(ctx, cfg.Store, cfg.GitSHA) if err != nil { // Treat store errors as "not notified yet": worst case is one extra // DM on the next cold start, which is far better than going // silent on every deploy because the store threw a transient error. log.Warn("deploynotify store read failed; will attempt send anyway", "err", err) notify = true } if !notify { return } if err := sendMessage(ctx, cfg, renderMessage(cfg.GitSHA)); err != nil { log.Warn("deploynotify telegram send failed", "err", err, "owner", cfg.OwnerID) return } if err := markNotified(ctx, cfg.Store, cfg.GitSHA); err != nil { log.Warn("deploynotify store write failed (owner was notified)", "err", err) return } log.Info("deploynotify sent", "sha", cfg.GitSHA, "owner", cfg.OwnerID) } // skipReason returns a non-empty short string when Run should no-op without // touching the store or Telegram. Empty string ⇒ proceed. func skipReason(cfg Config) string { switch { case cfg.GitSHA == "": return "empty gitSHA (build without -ldflags)" case cfg.OwnerID == 0: return "no OWNER_ID configured" case cfg.Store == nil: return "no Store configured" case cfg.Bot == nil && cfg.Sender == nil: return "no bot or sender configured" } return "" } // shouldNotify reports whether sha differs from the last notified value. // A missing record (ErrNotFound) is treated as "yes, notify". func shouldNotify(ctx context.Context, store storage.DocStore[notifyRecord], sha string) (bool, error) { prev, _, err := store.Get(ctx, storeKey) if errors.Is(err, storage.ErrNotFound) { return true, nil } if err != nil { return false, err } return prev.SHA != sha, nil } // markNotified writes the SHA + current timestamp to the store. func markNotified(ctx context.Context, store storage.DocStore[notifyRecord], sha string) error { return store.Put(ctx, storeKey, notifyRecord{ SHA: sha, At: time.Now().UTC().UnixMilli(), }) } // renderMessage is exposed for tests; keep the format stable enough that the // owner can grep their Telegram history by SHA. func renderMessage(sha string) string { return fmt.Sprintf("🚀 miti99bot deployed: %s", sha) } // sendMessage routes through Config.Sender when set (tests); otherwise it // calls bot.SendMessage directly. Plain text — no parse_mode — so the SHA // renders verbatim even if it ever contained Markdown-special characters. func sendMessage(ctx context.Context, cfg Config, text string) error { if cfg.Sender != nil { return cfg.Sender(ctx, cfg.OwnerID, text) } _, err := cfg.Bot.SendMessage(ctx, &bot.SendMessageParams{ ChatID: cfg.OwnerID, Text: text, }) return err }