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.
This commit is contained in:
2026-05-22 11:40:25 +07:00
parent 3182179e65
commit 22c817b0db
4 changed files with 342 additions and 2 deletions
+9 -2
View File
@@ -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}'
+13
View File
@@ -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=<sha>"`
// (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,
+146
View File
@@ -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
}
+174
View File
@@ -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)
}
}