From 22c817b0db7fe025357d49b40d9237fe2157404c Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Fri, 22 May 2026 11:40:25 +0700 Subject: [PATCH] feat(deploynotify): DM owner once per new deployed git SHA New internal/deploynotify package fires a single Telegram DM to BOT_OWNER_ID on the first cold start that observes a new gitSHA. Dedup via a DynamoDB KV record so repeat cold starts of the same version stay silent. Send-then-write order means a failed Telegram call doesn't permanently silence retries. gitSHA is baked into the binary via `-ldflags "-X main.gitSHA=..."` from Makefile; empty SHA (non-make builds) silently disables the feature. No new env vars or IAM permissions. --- Makefile | 11 +- cmd/server/main.go | 13 ++ internal/deploynotify/deploy_notify.go | 146 ++++++++++++++++ internal/deploynotify/deploy_notify_test.go | 174 ++++++++++++++++++++ 4 files changed, 342 insertions(+), 2 deletions(-) create mode 100644 internal/deploynotify/deploy_notify.go create mode 100644 internal/deploynotify/deploy_notify_test.go diff --git a/Makefile b/Makefile index a31b3f9..6a78261 100644 --- a/Makefile +++ b/Makefile @@ -5,6 +5,13 @@ LAMBDA_GOOS ?= linux LAMBDA_GOARCH ?= arm64 LAMBDA_OUT := build/lambda/bootstrap +# Short git SHA baked into the binary at link time. Consumed by +# internal/deploynotify to DM the owner once per new version. Falls back to +# empty string outside a git checkout (tarball, fresh clone without history) +# — deploynotify treats empty as "stay silent". +GIT_SHA := $(shell git rev-parse --short HEAD 2>/dev/null) +LDFLAGS := -s -w -X main.gitSHA=$(GIT_SHA) + # AWS deploy defaults. Override as needed: # make telegram-webhook AWS_PROFILE=admin STACK_NAME=miti99bot STACK_ENV=prod AWS_PROFILE ?= admin @@ -49,12 +56,12 @@ vet: ## go vet # ---- Build ---------------------------------------------------------------- build: ## Build the local server binary (host arch) - CGO_ENABLED=0 go build -ldflags="-s -w" -o ./bin/server ./cmd/server + CGO_ENABLED=0 go build -ldflags="$(LDFLAGS)" -o ./bin/server ./cmd/server build-lambda: ## Cross-compile bootstrap for Lambda (linux/arm64) @mkdir -p $(dir $(LAMBDA_OUT)) CGO_ENABLED=0 GOOS=$(LAMBDA_GOOS) GOARCH=$(LAMBDA_GOARCH) \ - go build -tags lambda.norpc -ldflags="-s -w" \ + go build -tags lambda.norpc -ldflags="$(LDFLAGS)" \ -o $(LAMBDA_OUT) ./cmd/server @chmod +x $(LAMBDA_OUT) @ls -lh $(LAMBDA_OUT) | awk '{print "lambda binary:", $$5}' diff --git a/cmd/server/main.go b/cmd/server/main.go index 135353d..9653afd 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -16,6 +16,7 @@ import ( awsconfig "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/service/ssm" "github.com/tiennm99/miti99bot/internal/ai" + "github.com/tiennm99/miti99bot/internal/deploynotify" "github.com/tiennm99/miti99bot/internal/log" "github.com/tiennm99/miti99bot/internal/metrics" "github.com/tiennm99/miti99bot/internal/modules" @@ -31,6 +32,11 @@ import ( "github.com/tiennm99/miti99bot/internal/telegram" ) +// gitSHA is populated at build time via `-ldflags "-X main.gitSHA="` +// (see Makefile). Empty value means the binary was built without that flag — +// deploynotify treats it as a signal to stay silent. +var gitSHA string + // factories is the static module catalog. Adding a new module is a one-line // change here. Lives in main rather than the modules package to avoid an // import cycle (modules → util → modules). @@ -124,6 +130,13 @@ func main() { log.Warn("CRON_SHARED_SECRET unset; /cron/{name} disabled (404 to all)") } + deploynotify.Run(rootCtx, deploynotify.Config{ + Bot: b, + KV: provider.For("deploynotify"), + OwnerID: cfg.BotOwnerID, + GitSHA: gitSHA, + }) + handler := server.New(server.Config{ Bot: b, Registry: reg, diff --git a/internal/deploynotify/deploy_notify.go b/internal/deploynotify/deploy_notify.go new file mode 100644 index 0000000..b3c91a1 --- /dev/null +++ b/internal/deploynotify/deploy_notify.go @@ -0,0 +1,146 @@ +// 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 KV 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" +) + +// kvKey is the single KV slot this package owns. +const kvKey = "last_notified_sha" + +// defaultTimeout caps the whole Run path (KV read + Telegram send + KV 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 + KV storage.KVStore + 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 KV value shape. At is informational only — useful for +// eyeballing in the DynamoDB console; not consulted by the code path. +type notifyRecord struct { + SHA string `json:"sha"` + At int64 `json:"at"` +} + +// 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.KV, cfg.GitSHA) + if err != nil { + // Treat KV 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 DynamoDB threw a transient. + log.Warn("deploynotify kv 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.KV, cfg.GitSHA); err != nil { + log.Warn("deploynotify kv 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 KV 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 BOT_OWNER_ID configured" + case cfg.KV == nil: + return "no KV 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, kv storage.KVStore, sha string) (bool, error) { + var prev notifyRecord + err := kv.GetJSON(ctx, kvKey, &prev) + 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 KV. +func markNotified(ctx context.Context, kv storage.KVStore, sha string) error { + return kv.PutJSON(ctx, kvKey, 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 +} diff --git a/internal/deploynotify/deploy_notify_test.go b/internal/deploynotify/deploy_notify_test.go new file mode 100644 index 0000000..dbc5520 --- /dev/null +++ b/internal/deploynotify/deploy_notify_test.go @@ -0,0 +1,174 @@ +package deploynotify + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/tiennm99/miti99bot/internal/storage" +) + +func TestShouldNotify_FirstRun(t *testing.T) { + kv := storage.NewMemoryKVStore() + got, err := shouldNotify(context.Background(), kv, "abc123") + if err != nil { + t.Fatalf("err: %v", err) + } + if !got { + t.Errorf("first run with empty KV → want true, got false") + } +} + +func TestShouldNotify_SameSHA(t *testing.T) { + kv := storage.NewMemoryKVStore() + if err := markNotified(context.Background(), kv, "abc123"); err != nil { + t.Fatal(err) + } + got, err := shouldNotify(context.Background(), kv, "abc123") + if err != nil { + t.Fatalf("err: %v", err) + } + if got { + t.Errorf("same SHA → want false, got true") + } +} + +func TestShouldNotify_DifferentSHA(t *testing.T) { + kv := storage.NewMemoryKVStore() + if err := markNotified(context.Background(), kv, "old111"); err != nil { + t.Fatal(err) + } + got, err := shouldNotify(context.Background(), kv, "new222") + if err != nil { + t.Fatalf("err: %v", err) + } + if !got { + t.Errorf("changed SHA → want true, got false") + } +} + +// recorder is a Sender that captures the last (chatID, text) it received and +// flags whether it was ever invoked. Replaces a full RecordingBot since this +// package only needs send-or-not signal. +type recorder struct { + called bool + chatID int64 + text string + err error +} + +func (r *recorder) send(_ context.Context, chatID int64, text string) error { + r.called = true + r.chatID = chatID + r.text = text + return r.err +} + +func TestRun_SkipsWhenSHAEmpty(t *testing.T) { + kv := storage.NewMemoryKVStore() + rec := &recorder{} + Run(context.Background(), Config{ + KV: kv, + OwnerID: 42, + GitSHA: "", + Sender: rec.send, + }) + if rec.called { + t.Errorf("empty SHA must not send; got call with text=%q", rec.text) + } + // And no KV write either. + if _, err := kv.Get(context.Background(), kvKey); !errors.Is(err, storage.ErrNotFound) { + t.Errorf("empty SHA must not write KV; got err=%v", err) + } +} + +func TestRun_SkipsWhenNoOwner(t *testing.T) { + kv := storage.NewMemoryKVStore() + rec := &recorder{} + Run(context.Background(), Config{ + KV: kv, + OwnerID: 0, + GitSHA: "abc123", + Sender: rec.send, + }) + if rec.called { + t.Errorf("zero owner must not send") + } + if _, err := kv.Get(context.Background(), kvKey); !errors.Is(err, storage.ErrNotFound) { + t.Errorf("zero owner must not write KV; got err=%v", err) + } +} + +func TestRun_SendsAndPersistsOnFirstRun(t *testing.T) { + kv := storage.NewMemoryKVStore() + rec := &recorder{} + Run(context.Background(), Config{ + KV: kv, + OwnerID: 42, + GitSHA: "abc123", + Sender: rec.send, + }) + if !rec.called { + t.Fatalf("first run with fresh KV must send") + } + if rec.chatID != 42 { + t.Errorf("chatID = %d, want 42", rec.chatID) + } + if !strings.Contains(rec.text, "abc123") { + t.Errorf("message %q missing SHA", rec.text) + } + // KV must now hold the SHA so the next Run is silent. + var got notifyRecord + if err := kv.GetJSON(context.Background(), kvKey, &got); err != nil { + t.Fatalf("post-send KV read: %v", err) + } + if got.SHA != "abc123" { + t.Errorf("persisted SHA = %q, want abc123", got.SHA) + } +} + +func TestRun_SilentOnSecondRunSameSHA(t *testing.T) { + kv := storage.NewMemoryKVStore() + if err := markNotified(context.Background(), kv, "abc123"); err != nil { + t.Fatal(err) + } + rec := &recorder{} + Run(context.Background(), Config{ + KV: kv, + OwnerID: 42, + GitSHA: "abc123", + Sender: rec.send, + }) + if rec.called { + t.Errorf("repeat run with same SHA must not send") + } +} + +func TestRun_DoesNotPersistOnSendFailure(t *testing.T) { + kv := storage.NewMemoryKVStore() + rec := &recorder{err: errors.New("telegram is down")} + Run(context.Background(), Config{ + KV: kv, + OwnerID: 42, + GitSHA: "abc123", + Sender: rec.send, + }) + if !rec.called { + t.Fatalf("sender should have been called") + } + // Send failed → SHA must NOT be persisted, so the next cold start retries. + if _, err := kv.Get(context.Background(), kvKey); !errors.Is(err, storage.ErrNotFound) { + t.Errorf("failed send must not write KV; got err=%v", err) + } +} + +func TestRenderMessage_ContainsSHA(t *testing.T) { + got := renderMessage("deadbeef") + if !strings.Contains(got, "deadbeef") { + t.Errorf("message %q missing SHA", got) + } + if !strings.Contains(got, "miti99bot") { + t.Errorf("message %q missing bot name", got) + } +}