mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-09-05 02:20:00 +00:00
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:
@@ -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}'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user