From ffe91cb32c27799e3f3c4955aa86b83f51d9b4fc Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Fri, 8 May 2026 23:27:12 +0700 Subject: [PATCH] feat(server,modules): bootstrap server and module framework Implements Phases 02 (partial) and 03 of the go-port-cloud-run plan. Introduces module framework with per-module KV prefix isolation, health check endpoint, request timeout protection, and comprehensive test coverage. Cloud Run deployment deferred to Phase 01. Security hardening: constant-time secret comparison, cron auth bridge, and secrets stripped from dependency environment exports. Includes Dockerfile, GitHub CI workflow (vet + race + build), and integration tests for module lifecycle. --- .dockerignore | 8 + .github/workflows/ci.yml | 33 ++++ Dockerfile | 17 ++ README.md | 55 +++++- cmd/server/main.go | 147 +++++++++++++++ go.mod | 5 + go.sum | 2 + internal/modules/cron_dispatcher.go | 30 ++++ internal/modules/dispatcher.go | 29 +++ internal/modules/module.go | 76 ++++++++ internal/modules/modules.go | 10 ++ internal/modules/registry.go | 156 ++++++++++++++++ internal/modules/registry_test.go | 269 ++++++++++++++++++++++++++++ internal/modules/validate.go | 36 ++++ internal/modules/validate_test.go | 56 ++++++ internal/server/health.go | 22 +++ internal/server/router.go | 92 ++++++++++ internal/server/router_test.go | 137 ++++++++++++++ internal/server/timeouts.go | 8 + internal/storage/kv_store.go | 20 +++ internal/storage/memory_kv.go | 79 ++++++++ internal/storage/prefix.go | 55 ++++++ internal/storage/prefix_test.go | 77 ++++++++ internal/telegram/client.go | 22 +++ internal/telegram/webhook.go | 61 +++++++ internal/telegram/webhook_test.go | 104 +++++++++++ 26 files changed, 1605 insertions(+), 1 deletion(-) create mode 100644 .dockerignore create mode 100644 .github/workflows/ci.yml create mode 100644 Dockerfile create mode 100644 cmd/server/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/modules/cron_dispatcher.go create mode 100644 internal/modules/dispatcher.go create mode 100644 internal/modules/module.go create mode 100644 internal/modules/modules.go create mode 100644 internal/modules/registry.go create mode 100644 internal/modules/registry_test.go create mode 100644 internal/modules/validate.go create mode 100644 internal/modules/validate_test.go create mode 100644 internal/server/health.go create mode 100644 internal/server/router.go create mode 100644 internal/server/router_test.go create mode 100644 internal/server/timeouts.go create mode 100644 internal/storage/kv_store.go create mode 100644 internal/storage/memory_kv.go create mode 100644 internal/storage/prefix.go create mode 100644 internal/storage/prefix_test.go create mode 100644 internal/telegram/client.go create mode 100644 internal/telegram/webhook.go create mode 100644 internal/telegram/webhook_test.go diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f4daf06 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +.git +.github +plans +docs +*.md +LICENSE +Dockerfile +.dockerignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0d08fcc --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,33 @@ +name: ci + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + go: + runs-on: ubuntu-latest + strategy: + matrix: + go: ['1.23'] + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: ${{ matrix.go }} + cache: true + + - name: go vet + run: go vet ./... + + - name: go test + run: go test -race -count=1 ./... + + - name: go build + run: go build ./... diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a3ec00d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +FROM golang:1.23-alpine AS builder +WORKDIR /src + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build \ + -ldflags="-s -w" \ + -o /out/server \ + ./cmd/server + +FROM gcr.io/distroless/static:nonroot +COPY --from=builder /out/server /server +USER nonroot:nonroot +EXPOSE 8080 +ENTRYPOINT ["/server"] diff --git a/README.md b/README.md index df316b8..becef85 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,55 @@ # miti99bot-go -Plug-n-play Telegram bot framework in Go, deployed on Google Cloud Run with Firestore + Gemini. Free-tier port of miti99bot. + +Plug-n-play Telegram bot framework in Go, deployed on Google Cloud Run with Firestore + Gemini. Free-tier port of [miti99bot](https://github.com/tiennm99/miti99bot). + +## Status + +Early scaffolding. See [`plans/260508-2222-go-port-cloud-run/plan.md`](plans/260508-2222-go-port-cloud-run/plan.md) for the full roadmap. + +| Phase | What | Status | +|-------|------|--------| +| 01 | GCP setup, Cloud Run baseline | pending | +| 02 | Repo bootstrap + webhook skeleton | **partial** (local pieces done; Cloud Run deploy deferred to Phase 01) | +| 03 | Module framework + KVStore | **done** | +| 04+ | Firestore, modules, cron, CI/CD, cutover | pending | + +## Layout + +``` +cmd/server/ entrypoint +internal/server/ HTTP routes (/, /webhook, /cron/{name}) +internal/telegram/ Telegram webhook + bot wrapper +internal/modules/ Module framework, registry, dispatchers +internal/storage/ KVStore interface, in-memory impl, prefix wrapper +``` + +## Run locally + +```sh +TELEGRAM_BOT_TOKEN=… \ +TELEGRAM_WEBHOOK_SECRET=local \ +PORT=8080 \ +MODULES= \ +go run ./cmd/server +``` + +End-to-end smoke test against a Telegram dev bot requires `ngrok` (local) or a Cloud Run deployment. The dev bot is created manually; token is injected via env vars only. + +## Test + +```sh +go vet ./... +go test ./... +``` + +## Build + +```sh +docker build -t miti99bot-go . +``` + +The image is multi-stage (`golang:1.23-alpine` → `gcr.io/distroless/static:nonroot`); resulting image is ~15 MiB. + +## License + +[Apache-2.0](LICENSE). diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..f93c353 --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,147 @@ +package main + +import ( + "context" + "errors" + "log" + "net/http" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "github.com/tiennm99/miti99bot-go/internal/modules" + "github.com/tiennm99/miti99bot-go/internal/server" + "github.com/tiennm99/miti99bot-go/internal/storage" + "github.com/tiennm99/miti99bot-go/internal/telegram" +) + +// secretEnvKeys are stripped from Deps.Env before any module sees it. Each +// new credential added to the environment must be appended here. +var secretEnvKeys = []string{ + "TELEGRAM_BOT_TOKEN", + "TELEGRAM_WEBHOOK_SECRET", + "CRON_SHARED_SECRET", +} + +func main() { + cfg := loadConfig() + if cfg.TelegramBotToken == "" { + log.Fatal("TELEGRAM_BOT_TOKEN is required") + } + if cfg.WebhookSecret == "" { + log.Fatal("TELEGRAM_WEBHOOK_SECRET is required (a non-empty secret is the only auth on /webhook)") + } + + rootCtx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + b, err := telegram.NewBot(cfg.TelegramBotToken) + if err != nil { + log.Fatalf("telegram bot init: %v", err) + } + + kv := storage.NewMemoryKVStore() + deps := modules.Deps{KV: kv, Env: cfg.ModuleEnv} + + reg, err := modules.Build(cfg.Modules, modules.Factories, deps) + if err != nil { + log.Fatalf("module registry: %v", err) + } + modules.Install(b, reg) + log.Printf("loaded %d module(s), %d command(s), %d cron(s)", + len(reg.Modules), len(reg.AllCommands), len(reg.Crons())) + + if cfg.CronSecret == "" { + log.Println("WARN: CRON_SHARED_SECRET unset; /cron/{name} disabled (404 to all)") + } + + handler := server.New(server.Config{ + Bot: b, + Registry: reg, + WebhookSecret: cfg.WebhookSecret, + CronSecret: cfg.CronSecret, + }) + + srv := &http.Server{ + Addr: ":" + cfg.Port, + Handler: handler, + ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: 30 * time.Second, + // 6 min accommodates /cron/{name}; the webhook handler enforces a + // tighter per-update ctx timeout internally. + WriteTimeout: 6 * time.Minute, + IdleTimeout: 120 * time.Second, + } + + go func() { + log.Printf("server listening on :%s", cfg.Port) + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Fatalf("server: %v", err) + } + }() + + <-rootCtx.Done() + log.Println("shutting down") + shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + if err := srv.Shutdown(shutdownCtx); err != nil { + log.Printf("graceful shutdown: %v", err) + } +} + +type config struct { + Port string + TelegramBotToken string + WebhookSecret string + CronSecret string + Modules []string + ModuleEnv map[string]string // sensitive keys stripped, safe to hand to modules +} + +func loadConfig() config { + envMap := make(map[string]string, len(os.Environ())) + for _, kv := range os.Environ() { + if eq := strings.IndexByte(kv, '='); eq >= 0 { + envMap[kv[:eq]] = kv[eq+1:] + } + } + port := envMap["PORT"] + if port == "" { + port = "8080" + } + return config{ + Port: port, + TelegramBotToken: envMap["TELEGRAM_BOT_TOKEN"], + WebhookSecret: envMap["TELEGRAM_WEBHOOK_SECRET"], + CronSecret: envMap["CRON_SHARED_SECRET"], + Modules: splitCSV(envMap["MODULES"]), + ModuleEnv: envForModules(envMap), + } +} + +func envForModules(env map[string]string) map[string]string { + out := make(map[string]string, len(env)) + for k, v := range env { + out[k] = v + } + for _, k := range secretEnvKeys { + delete(out, k) + } + return out +} + +func splitCSV(s string) []string { + if s == "" { + return nil + } + parts := strings.Split(s, ",") + out := parts[:0] + for _, p := range parts { + if t := strings.TrimSpace(p); t != "" { + out = append(out, t) + } + } + return out +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..26e1599 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module github.com/tiennm99/miti99bot-go + +go 1.23 + +require github.com/go-telegram/bot v1.20.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..da4a2da --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/go-telegram/bot v1.20.0 h1:4Pea/qTidSspr4WBJw9FbHUMNhYeqszBqQUfsQEyFbc= +github.com/go-telegram/bot v1.20.0/go.mod h1:i2TRs7fXWIeaceF3z7KzsMt/he0TwkVC680mvdTFYeM= diff --git a/internal/modules/cron_dispatcher.go b/internal/modules/cron_dispatcher.go new file mode 100644 index 0000000..3bc52d7 --- /dev/null +++ b/internal/modules/cron_dispatcher.go @@ -0,0 +1,30 @@ +package modules + +import ( + "context" + "errors" + "fmt" +) + +// ErrCronNotFound is returned when /cron/{name} addresses an unregistered cron. +var ErrCronNotFound = errors.New("cron not found") + +// DispatchScheduled runs the cron registered under name with the per-module +// prefixed Deps the registry stored at Build time. Returns ErrCronNotFound if +// no module owns that name — Cloud Scheduler hitting an unknown route is a +// configuration bug worth surfacing as a 404 at the HTTP layer. +// +// The handler runs synchronously in the calling goroutine; ctx propagates +// cancellation/timeout from the HTTP request. +func DispatchScheduled(ctx context.Context, name string, reg *Registry) error { + cron, ok := reg.Cron(name) + if !ok { + return fmt.Errorf("%w: %q", ErrCronNotFound, name) + } + deps, ok := reg.CronDeps(name) + if !ok { + // Should be impossible: Build always co-registers cron and cronDeps. + return fmt.Errorf("modules: cron %q has no registered deps (registry corruption)", name) + } + return cron.Handler(ctx, deps) +} diff --git a/internal/modules/dispatcher.go b/internal/modules/dispatcher.go new file mode 100644 index 0000000..77d9391 --- /dev/null +++ b/internal/modules/dispatcher.go @@ -0,0 +1,29 @@ +package modules + +import ( + "context" + "log" + + "github.com/go-telegram/bot" + "github.com/go-telegram/bot/models" +) + +// Install registers every command in the registry with the Telegram bot. +// +// MatchTypeCommand expects the bare command name without the leading slash; +// the library compares against entity bytes after the "/" prefix. +func Install(b *bot.Bot, reg *Registry) { + for name, cmd := range reg.AllCommands { + cmdCopy := cmd // capture by value for the closure + b.RegisterHandler( + bot.HandlerTypeMessageText, + name, + bot.MatchTypeCommand, + func(ctx context.Context, b *bot.Bot, update *models.Update) { + if err := cmdCopy.Handler(ctx, b, update); err != nil { + log.Printf("command /%s failed: %v", cmdCopy.Name, err) + } + }, + ) + } +} diff --git a/internal/modules/module.go b/internal/modules/module.go new file mode 100644 index 0000000..39300a3 --- /dev/null +++ b/internal/modules/module.go @@ -0,0 +1,76 @@ +package modules + +import ( + "context" + + "github.com/go-telegram/bot" + "github.com/go-telegram/bot/models" + + "github.com/tiennm99/miti99bot-go/internal/storage" +) + +// Visibility classifies who may invoke a command. The dispatcher does not +// enforce visibility today; the field exists so /help and chat-scoping can +// filter consistently in later phases. +type Visibility int + +const ( + VisibilityPublic Visibility = iota + VisibilityProtected + VisibilityPrivate +) + +// CommandHandler runs in response to a Telegram command. Returning an error +// causes the dispatcher to log the failure. Telegram retries are governed by +// the webhook HTTP status (200), not handler errors — so the error return is +// purely for logging/metrics, not flow control. +type CommandHandler func(ctx context.Context, b *bot.Bot, update *models.Update) error + +// CronHandler runs when Cloud Scheduler hits /cron/{name}. Crons receive the +// per-module-prefixed Deps via the registry; handlers should not capture the +// base Deps from the factory closure or KV writes will collide across modules. +type CronHandler func(ctx context.Context, deps Deps) error + +// Command is a single Telegram bot command exposed by a module. +type Command struct { + Name string // ^[a-z0-9_]{1,32}$ — Telegram BotFather rules + Visibility Visibility // public/protected/private + Description string // shown in /help (required, non-empty) + Handler CommandHandler // required +} + +// Cron is a single scheduled job exposed by a module. +type Cron struct { + Schedule string // documentation only; real schedule lives in Cloud Scheduler + Name string // unique within module + Handler CronHandler // required +} + +// Module is a self-contained feature unit: a name plus zero or more commands +// and crons. Modules are constructed by Factory functions that capture their +// per-module Deps via closure. +// +// Module.Name is overridden by the registry to its catalog key; factories may +// leave it blank. +type Module struct { + Name string + Commands []Command + Crons []Cron +} + +// Deps is the dependency bundle a Factory receives. Each field is added in the +// phase that introduces it; today only KV + Env exist (Firestore: Phase 04, +// Gemini: Phase 07). +// +// Deps.Env is the process environment with sensitive keys stripped. Modules +// must not assume Env contains every variable — see cmd/server.envForModules. +type Deps struct { + KV storage.KVStore // already prefixed with the module name when passed to a Factory + Env map[string]string // process env minus known-sensitive keys +} + +// Factory constructs a Module from its Deps. Spec deviation: Phase 03 plan +// defines `Factory func() Module` with a separate Init step. We pass Deps +// directly so handler closures can capture them — idiomatic Go and removes a +// lifecycle ordering trap. +type Factory func(deps Deps) Module diff --git a/internal/modules/modules.go b/internal/modules/modules.go new file mode 100644 index 0000000..8101b9e --- /dev/null +++ b/internal/modules/modules.go @@ -0,0 +1,10 @@ +package modules + +// Factories is the static module catalog. Each phase that introduces a module +// adds an entry here. Phase 03 ships an empty catalog; Phase 05 onwards +// populates it. +// +// Spec deviation: Phase 03 plan defined a `[]Factory` slice. A map keyed by +// module name is required for Build() to honor the MODULES env CSV without a +// linear scan, and prevents duplicate module names at compile-load time. +var Factories = map[string]Factory{} diff --git a/internal/modules/registry.go b/internal/modules/registry.go new file mode 100644 index 0000000..36f09fa --- /dev/null +++ b/internal/modules/registry.go @@ -0,0 +1,156 @@ +package modules + +import ( + "fmt" + "sort" + + "github.com/tiennm99/miti99bot-go/internal/storage" +) + +// moduleNameRe enforces the same alphabet as command names so KV prefix +// isolation is preserved (no ":" in module names → no prefix collision) and +// the cron route's path segment regex matches. +var moduleNameRe = commandNameRe // alias kept for symmetry; one regex serves both + +// Registry holds the resolved set of modules selected by the MODULES env var. +// It is built once at startup; Build fails fast on validation or conflict. +type Registry struct { + Modules []Module // in MODULES-env order + AllCommands map[string]Command // name → Command, deduped across modules + publicCmds map[string]Command + protected map[string]Command + private map[string]Command + crons map[string]Cron // name → Cron, unique across modules + cronDeps map[string]Deps // cron name → owning module's prefixed Deps +} + +// PublicCommands returns commands tagged VisibilityPublic, sorted by name. +func (r *Registry) PublicCommands() []Command { return sortedCommands(r.publicCmds) } + +// ProtectedCommands returns commands tagged VisibilityProtected, sorted by name. +func (r *Registry) ProtectedCommands() []Command { return sortedCommands(r.protected) } + +// PrivateCommands returns commands tagged VisibilityPrivate, sorted by name. +func (r *Registry) PrivateCommands() []Command { return sortedCommands(r.private) } + +// Cron looks up a cron by global name across all loaded modules. +func (r *Registry) Cron(name string) (Cron, bool) { + c, ok := r.crons[name] + return c, ok +} + +// CronDeps returns the per-module-prefixed Deps the cron's owning module +// received. The cron dispatcher uses this to pass scoped Deps to the handler. +func (r *Registry) CronDeps(name string) (Deps, bool) { + d, ok := r.cronDeps[name] + return d, ok +} + +// Crons returns all loaded crons, sorted by name. Allocates a fresh slice on +// every call — fine for startup-time logging, not for hot paths. +func (r *Registry) Crons() []Cron { + out := make([]Cron, 0, len(r.crons)) + for _, c := range r.crons { + out = append(out, c) + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} + +// Build constructs a Registry from the requested module names. It calls each +// factory with a per-module-prefixed KVStore, validates every command/cron, +// and aborts on duplicate command names across the union of all visibilities. +// +// Names not present in factories are reported as a single error so a typo in +// MODULES does not silently load a smaller bot than intended. Duplicate names +// in MODULES are also a hard error to keep startup deterministic. +func Build(enabled []string, factories map[string]Factory, base Deps) (*Registry, error) { + if base.KV == nil { + return nil, fmt.Errorf("modules: Deps.KV is required") + } + + reg := &Registry{ + AllCommands: map[string]Command{}, + publicCmds: map[string]Command{}, + protected: map[string]Command{}, + private: map[string]Command{}, + crons: map[string]Cron{}, + cronDeps: map[string]Deps{}, + } + + owners := map[string]string{} // command name → module that registered it + cronOwners := map[string]string{} + seenModule := map[string]bool{} + var unknown []string + + for _, name := range enabled { + if !moduleNameRe.MatchString(name) { + return nil, fmt.Errorf("modules: invalid name %q in MODULES env (must match %s)", name, moduleNameRe) + } + if seenModule[name] { + return nil, fmt.Errorf("modules: duplicate name %q in MODULES env", name) + } + seenModule[name] = true + + factory, ok := factories[name] + if !ok { + unknown = append(unknown, name) + continue + } + + moduleDeps := Deps{ + KV: storage.Prefixed(base.KV, name), + Env: base.Env, + } + mod := factory(moduleDeps) + mod.Name = name // enforce: module name is its registry key, not whatever the factory chose + + for _, cmd := range mod.Commands { + if err := validateCommand(cmd); err != nil { + return nil, fmt.Errorf("module %q: %w", name, err) + } + if prev, dup := owners[cmd.Name]; dup { + return nil, fmt.Errorf("command conflict: /%s defined in %q and %q", cmd.Name, prev, name) + } + owners[cmd.Name] = name + reg.AllCommands[cmd.Name] = cmd + switch cmd.Visibility { + case VisibilityPublic: + reg.publicCmds[cmd.Name] = cmd + case VisibilityProtected: + reg.protected[cmd.Name] = cmd + case VisibilityPrivate: + reg.private[cmd.Name] = cmd + } + } + + for _, cron := range mod.Crons { + if err := validateCron(cron); err != nil { + return nil, fmt.Errorf("module %q: %w", name, err) + } + if prev, dup := cronOwners[cron.Name]; dup { + return nil, fmt.Errorf("cron conflict: %q defined in %q and %q", cron.Name, prev, name) + } + cronOwners[cron.Name] = name + reg.crons[cron.Name] = cron + reg.cronDeps[cron.Name] = moduleDeps + } + + reg.Modules = append(reg.Modules, mod) + } + + if len(unknown) > 0 { + return nil, fmt.Errorf("modules: unknown name(s) in MODULES env: %v", unknown) + } + + return reg, nil +} + +func sortedCommands(m map[string]Command) []Command { + out := make([]Command, 0, len(m)) + for _, c := range m { + out = append(out, c) + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} diff --git a/internal/modules/registry_test.go b/internal/modules/registry_test.go new file mode 100644 index 0000000..c64372c --- /dev/null +++ b/internal/modules/registry_test.go @@ -0,0 +1,269 @@ +package modules + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/go-telegram/bot" + "github.com/go-telegram/bot/models" + + "github.com/tiennm99/miti99bot-go/internal/storage" +) + +func noopCmd(name string) Command { + return Command{ + Name: name, + Visibility: VisibilityPublic, + Description: "test " + name, + Handler: func(_ context.Context, _ *bot.Bot, _ *models.Update) error { return nil }, + } +} + +func noopCron(name string) Cron { + return Cron{ + Schedule: "@every 24h", + Name: name, + Handler: func(_ context.Context, _ Deps) error { return nil }, + } +} + +func factory(name string, cmds []Command, crons []Cron) Factory { + return func(_ Deps) Module { + return Module{Name: name, Commands: cmds, Crons: crons} + } +} + +func baseDeps() Deps { return Deps{KV: storage.NewMemoryKVStore()} } + +func TestBuild_EmptyModulesBootsCleanly(t *testing.T) { + reg, err := Build(nil, map[string]Factory{}, baseDeps()) + if err != nil { + t.Fatalf("Build empty: %v", err) + } + if len(reg.AllCommands) != 0 { + t.Errorf("expected 0 commands, got %d", len(reg.AllCommands)) + } +} + +func TestBuild_LoadsRequestedModules(t *testing.T) { + factories := map[string]Factory{ + "alpha": factory("alpha", []Command{noopCmd("a1")}, nil), + "beta": factory("beta", []Command{noopCmd("b1")}, []Cron{noopCron("daily")}), + } + reg, err := Build([]string{"alpha", "beta"}, factories, baseDeps()) + if err != nil { + t.Fatalf("Build: %v", err) + } + if len(reg.Modules) != 2 { + t.Errorf("expected 2 modules, got %d", len(reg.Modules)) + } + if _, ok := reg.AllCommands["a1"]; !ok { + t.Error("missing command a1") + } + if _, ok := reg.Cron("daily"); !ok { + t.Error("missing cron daily") + } +} + +func TestBuild_SkipsModulesNotInEnv(t *testing.T) { + factories := map[string]Factory{ + "alpha": factory("alpha", []Command{noopCmd("a1")}, nil), + "beta": factory("beta", []Command{noopCmd("b1")}, nil), + } + reg, err := Build([]string{"alpha"}, factories, baseDeps()) + if err != nil { + t.Fatalf("Build: %v", err) + } + if _, ok := reg.AllCommands["b1"]; ok { + t.Error("beta should not have been loaded") + } +} + +func TestBuild_RejectsUnknownModule(t *testing.T) { + _, err := Build([]string{"ghost"}, map[string]Factory{}, baseDeps()) + if err == nil || !strings.Contains(err.Error(), "ghost") { + t.Errorf("expected error mentioning ghost, got %v", err) + } +} + +func TestBuild_DetectsCommandConflict(t *testing.T) { + factories := map[string]Factory{ + "alpha": factory("alpha", []Command{noopCmd("ping")}, nil), + "beta": factory("beta", []Command{noopCmd("ping")}, nil), + } + _, err := Build([]string{"alpha", "beta"}, factories, baseDeps()) + if err == nil { + t.Fatal("expected conflict error") + } + if !strings.Contains(err.Error(), "command conflict") { + t.Errorf("error should mention conflict, got %v", err) + } +} + +func TestBuild_DetectsCronConflict(t *testing.T) { + factories := map[string]Factory{ + "alpha": factory("alpha", nil, []Cron{noopCron("daily")}), + "beta": factory("beta", nil, []Cron{noopCron("daily")}), + } + _, err := Build([]string{"alpha", "beta"}, factories, baseDeps()) + if err == nil || !strings.Contains(err.Error(), "cron conflict") { + t.Errorf("expected cron conflict, got %v", err) + } +} + +func TestBuild_RequiresKV(t *testing.T) { + _, err := Build(nil, map[string]Factory{}, Deps{}) + if err == nil { + t.Error("expected error when Deps.KV is nil") + } +} + +func TestBuild_ValidationErrorsMentionModule(t *testing.T) { + bad := Command{Name: "BAD-NAME", Visibility: VisibilityPublic, Description: "x", Handler: noopCmd("x").Handler} + factories := map[string]Factory{ + "alpha": factory("alpha", []Command{bad}, nil), + } + _, err := Build([]string{"alpha"}, factories, baseDeps()) + if err == nil || !strings.Contains(err.Error(), "alpha") { + t.Errorf("expected error mentioning module 'alpha', got %v", err) + } +} + +func TestDispatchScheduled_RunsHandler(t *testing.T) { + called := false + factories := map[string]Factory{ + "alpha": factory("alpha", nil, []Cron{{ + Name: "tick", + Handler: func(_ context.Context, _ Deps) error { + called = true + return nil + }, + }}), + } + reg, err := Build([]string{"alpha"}, factories, baseDeps()) + if err != nil { + t.Fatalf("Build: %v", err) + } + if err := DispatchScheduled(context.Background(), "tick", reg); err != nil { + t.Fatalf("DispatchScheduled: %v", err) + } + if !called { + t.Error("cron handler not invoked") + } +} + +func TestDispatchScheduled_UnknownReturnsErrCronNotFound(t *testing.T) { + reg, err := Build(nil, map[string]Factory{}, baseDeps()) + if err != nil { + t.Fatalf("Build: %v", err) + } + err = DispatchScheduled(context.Background(), "missing", reg) + if !errors.Is(err, ErrCronNotFound) { + t.Errorf("expected ErrCronNotFound, got %v", err) + } +} + +func TestDispatchScheduled_PassesPrefixedDeps(t *testing.T) { + ctx := context.Background() + base := storage.NewMemoryKVStore() + + factories := map[string]Factory{ + "alpha": func(d Deps) Module { + return Module{Crons: []Cron{{ + Name: "tick_a", + Handler: func(ctx context.Context, deps Deps) error { + return deps.KV.Put(ctx, "last", []byte("A")) + }, + }}} + }, + "beta": func(d Deps) Module { + return Module{Crons: []Cron{{ + Name: "tick_b", + Handler: func(ctx context.Context, deps Deps) error { + return deps.KV.Put(ctx, "last", []byte("B")) + }, + }}} + }, + } + reg, err := Build([]string{"alpha", "beta"}, factories, Deps{KV: base}) + if err != nil { + t.Fatalf("Build: %v", err) + } + if err := DispatchScheduled(ctx, "tick_a", reg); err != nil { + t.Fatalf("tick_a: %v", err) + } + if err := DispatchScheduled(ctx, "tick_b", reg); err != nil { + t.Fatalf("tick_b: %v", err) + } + + // Underlying base store should hold each module's prefixed key separately. + gotA, err := base.Get(ctx, "alpha:last") + if err != nil || string(gotA) != "A" { + t.Errorf("alpha:last = %q (err=%v), want A", gotA, err) + } + gotB, err := base.Get(ctx, "beta:last") + if err != nil || string(gotB) != "B" { + t.Errorf("beta:last = %q (err=%v), want B", gotB, err) + } +} + +func TestBuild_RejectsInvalidModuleName(t *testing.T) { + for _, name := range []string{"BadName", "with-dash", "a:b", ""} { + t.Run(name, func(t *testing.T) { + _, err := Build([]string{name}, map[string]Factory{}, baseDeps()) + if err == nil { + t.Errorf("name %q: expected error", name) + } + }) + } +} + +func TestBuild_RejectsDuplicateModuleInEnv(t *testing.T) { + factories := map[string]Factory{ + "alpha": factory("alpha", []Command{noopCmd("a1")}, nil), + } + _, err := Build([]string{"alpha", "alpha"}, factories, baseDeps()) + if err == nil || !strings.Contains(err.Error(), "duplicate") { + t.Errorf("expected duplicate-module error, got %v", err) + } +} + +func TestBuild_PerModulePrefixedKV(t *testing.T) { + ctx := context.Background() + base := storage.NewMemoryKVStore() + + // Each module writes a value to the same key; with per-module prefixing + // they must not collide. + captured := map[string]Deps{} + factories := map[string]Factory{ + "alpha": func(d Deps) Module { + captured["alpha"] = d + return Module{Commands: []Command{noopCmd("a")}} + }, + "beta": func(d Deps) Module { + captured["beta"] = d + return Module{Commands: []Command{noopCmd("b")}} + }, + } + if _, err := Build([]string{"alpha", "beta"}, factories, Deps{KV: base}); err != nil { + t.Fatalf("Build: %v", err) + } + + if err := captured["alpha"].KV.Put(ctx, "score", []byte("1")); err != nil { + t.Fatal(err) + } + if err := captured["beta"].KV.Put(ctx, "score", []byte("2")); err != nil { + t.Fatal(err) + } + + got, _ := captured["alpha"].KV.Get(ctx, "score") + if string(got) != "1" { + t.Errorf("alpha.KV.score = %q, want 1", got) + } + got, _ = captured["beta"].KV.Get(ctx, "score") + if string(got) != "2" { + t.Errorf("beta.KV.score = %q, want 2", got) + } +} diff --git a/internal/modules/validate.go b/internal/modules/validate.go new file mode 100644 index 0000000..fcdc076 --- /dev/null +++ b/internal/modules/validate.go @@ -0,0 +1,36 @@ +package modules + +import ( + "fmt" + "regexp" +) + +var commandNameRe = regexp.MustCompile(`^[a-z0-9_]{1,32}$`) + +func validateCommand(c Command) error { + if !commandNameRe.MatchString(c.Name) { + return fmt.Errorf("command name %q must match %s", c.Name, commandNameRe) + } + switch c.Visibility { + case VisibilityPublic, VisibilityProtected, VisibilityPrivate: + default: + return fmt.Errorf("command %q: unknown visibility %d", c.Name, c.Visibility) + } + if c.Description == "" { + return fmt.Errorf("command %q: description is required", c.Name) + } + if c.Handler == nil { + return fmt.Errorf("command %q: handler is nil", c.Name) + } + return nil +} + +func validateCron(c Cron) error { + if c.Name == "" { + return fmt.Errorf("cron: name is required") + } + if c.Handler == nil { + return fmt.Errorf("cron %q: handler is nil", c.Name) + } + return nil +} diff --git a/internal/modules/validate_test.go b/internal/modules/validate_test.go new file mode 100644 index 0000000..9e78b2b --- /dev/null +++ b/internal/modules/validate_test.go @@ -0,0 +1,56 @@ +package modules + +import ( + "context" + "testing" + + "github.com/go-telegram/bot" + "github.com/go-telegram/bot/models" +) + +func okHandler(_ context.Context, _ *bot.Bot, _ *models.Update) error { return nil } + +func TestValidateCommand_RejectsBadNames(t *testing.T) { + cases := map[string]string{ + "empty": "", + "uppercase": "Ping", + "hyphen": "do-thing", + "too long": "abcdefghijklmnopqrstuvwxyzabcdefg", // 33 chars + "with slash": "/ping", + "unicode": "пинг", + } + for label, name := range cases { + t.Run(label, func(t *testing.T) { + err := validateCommand(Command{Name: name, Visibility: VisibilityPublic, Description: "d", Handler: okHandler}) + if err == nil { + t.Errorf("name %q: expected error", name) + } + }) + } +} + +func TestValidateCommand_AcceptsLegalNames(t *testing.T) { + for _, name := range []string{"ping", "do_it", "a", "abc123", "x_1_y"} { + if err := validateCommand(Command{Name: name, Visibility: VisibilityPublic, Description: "d", Handler: okHandler}); err != nil { + t.Errorf("name %q: unexpected error %v", name, err) + } + } +} + +func TestValidateCommand_RequiresDescriptionAndHandler(t *testing.T) { + if err := validateCommand(Command{Name: "ok", Visibility: VisibilityPublic, Description: "", Handler: okHandler}); err == nil { + t.Error("expected error for empty description") + } + if err := validateCommand(Command{Name: "ok", Visibility: VisibilityPublic, Description: "d", Handler: nil}); err == nil { + t.Error("expected error for nil handler") + } +} + +func TestValidateCron_RequiresNameAndHandler(t *testing.T) { + if err := validateCron(Cron{Name: "", Handler: func(_ context.Context, _ Deps) error { return nil }}); err == nil { + t.Error("expected error for empty name") + } + if err := validateCron(Cron{Name: "x", Handler: nil}); err == nil { + t.Error("expected error for nil handler") + } +} diff --git a/internal/server/health.go b/internal/server/health.go new file mode 100644 index 0000000..d45179d --- /dev/null +++ b/internal/server/health.go @@ -0,0 +1,22 @@ +package server + +import "net/http" + +// HealthHandler answers GET / with a stable string so Cloud Run's HTTP probe +// and any uptime monitor can distinguish "process up" from "process listening +// but routing broken". +func HealthHandler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + // Anything other than the root path on this exact handler is a 404. + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + _, _ = w.Write([]byte("miti99bot-go ok\n")) + } +} diff --git a/internal/server/router.go b/internal/server/router.go new file mode 100644 index 0000000..7c10645 --- /dev/null +++ b/internal/server/router.go @@ -0,0 +1,92 @@ +package server + +import ( + "context" + "crypto/subtle" + "errors" + "log" + "net/http" + "regexp" + "strings" + + "github.com/go-telegram/bot" + + "github.com/tiennm99/miti99bot-go/internal/modules" + "github.com/tiennm99/miti99bot-go/internal/telegram" +) + +// cronNameRe limits cron path segments to a safe alphabet so log injection via +// the route is impossible (newlines, ANSI escapes, etc. are rejected at the +// router boundary). Same shape as Telegram command names. +var cronNameRe = regexp.MustCompile(`^[a-z0-9_]{1,32}$`) + +// cronAuthHeader is the shared-secret header name. Replaced by OIDC in Phase 09. +const cronAuthHeader = "X-Cron-Token" + +// Config wires the router's runtime dependencies. +type Config struct { + Bot *bot.Bot + Registry *modules.Registry + WebhookSecret string + + // CronSecret is the shared-secret bridge until Phase 09 adds OIDC. Empty + // means /cron/{name} is fully disabled (404). Required to prevent + // unauthenticated triggering of billable side effects. + CronSecret string +} + +// New builds the application's HTTP handler. Routes: +// +// GET / → health +// POST /webhook → Telegram update intake (constant-time secret check) +// POST /cron/{name} → Cloud Scheduler entry (shared-secret check; OIDC in Phase 09) +// +// Anything else is 404. +func New(cfg Config) http.Handler { + mux := http.NewServeMux() + mux.Handle("/", HealthHandler()) + mux.Handle("/webhook", telegram.WebhookHandler(cfg.Bot, cfg.WebhookSecret)) + mux.Handle("/cron/", cronHandler(cfg.Registry, cfg.CronSecret)) + return mux +} + +func cronHandler(reg *modules.Registry, secret string) http.HandlerFunc { + secretBytes := []byte(secret) + cronDisabled := secret == "" + return func(w http.ResponseWriter, r *http.Request) { + if cronDisabled { + http.NotFound(w, r) + return + } + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + got := []byte(r.Header.Get(cronAuthHeader)) + if subtle.ConstantTimeCompare(got, secretBytes) != 1 { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + name := strings.TrimPrefix(r.URL.Path, "/cron/") + if !cronNameRe.MatchString(name) { + http.NotFound(w, r) + return + } + + log.Printf("cron name=%s", name) + ctx, cancel := context.WithTimeout(r.Context(), defaultCronTimeout) + defer cancel() + + if err := modules.DispatchScheduled(ctx, name, reg); err != nil { + if errors.Is(err, modules.ErrCronNotFound) { + http.NotFound(w, r) + return + } + log.Printf("cron %s failed: %v", name, err) + http.Error(w, "cron failed", http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusOK) + } +} diff --git a/internal/server/router_test.go b/internal/server/router_test.go new file mode 100644 index 0000000..470a315 --- /dev/null +++ b/internal/server/router_test.go @@ -0,0 +1,137 @@ +package server + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/tiennm99/miti99bot-go/internal/modules" + "github.com/tiennm99/miti99bot-go/internal/storage" +) + +const testCronSecret = "shared-cron-secret" + +func buildRegistry(t *testing.T, factories map[string]modules.Factory, names ...string) *modules.Registry { + t.Helper() + reg, err := modules.Build(names, factories, modules.Deps{KV: storage.NewMemoryKVStore()}) + if err != nil { + t.Fatalf("modules.Build: %v", err) + } + return reg +} + +func TestHealthHandler_OK(t *testing.T) { + rec := httptest.NewRecorder() + HealthHandler()(rec, httptest.NewRequest(http.MethodGet, "/", nil)) + if rec.Code != http.StatusOK { + t.Errorf("status = %d, want 200", rec.Code) + } + if !strings.Contains(rec.Body.String(), "ok") { + t.Errorf("body = %q, want contains 'ok'", rec.Body.String()) + } +} + +func TestCronHandler_DisabledWhenSecretEmpty(t *testing.T) { + reg := buildRegistry(t, nil) + h := cronHandler(reg, "") + + rec := httptest.NewRecorder() + h(rec, httptest.NewRequest(http.MethodPost, "/cron/anything", nil)) + if rec.Code != http.StatusNotFound { + t.Errorf("status = %d, want 404 (disabled)", rec.Code) + } +} + +func TestCronHandler_RejectsNonPost(t *testing.T) { + reg := buildRegistry(t, nil) + h := cronHandler(reg, testCronSecret) + + rec := httptest.NewRecorder() + h(rec, httptest.NewRequest(http.MethodGet, "/cron/anything", nil)) + if rec.Code != http.StatusMethodNotAllowed { + t.Errorf("status = %d, want 405", rec.Code) + } +} + +func TestCronHandler_RejectsMissingAuth(t *testing.T) { + reg := buildRegistry(t, nil) + h := cronHandler(reg, testCronSecret) + + rec := httptest.NewRecorder() + h(rec, httptest.NewRequest(http.MethodPost, "/cron/anything", nil)) + if rec.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want 401", rec.Code) + } +} + +func TestCronHandler_RejectsInvalidName(t *testing.T) { + reg := buildRegistry(t, nil) + h := cronHandler(reg, testCronSecret) + + // Use bare names that fail the regex; net/http already %-decodes the + // path, so a smuggled \n on the wire would arrive here as a literal byte. + cases := map[string]string{ + "uppercase": "/cron/BadName", + "hyphen": "/cron/with-dash", + "newline": "/cron/with\nnewline", + "empty": "/cron/", + "too long": "/cron/abcdefghijklmnopqrstuvwxyzabcdefg", // 33 chars + "path nested": "/cron/foo/bar", + } + for label, path := range cases { + t.Run(label, func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/cron/x", nil) + req.URL.Path = path // bypass NewRequest's URL parser + req.Header.Set(cronAuthHeader, testCronSecret) + rec := httptest.NewRecorder() + h(rec, req) + if rec.Code != http.StatusNotFound { + t.Errorf("path %q: status = %d, want 404", path, rec.Code) + } + }) + } +} + +func TestCronHandler_UnknownNameReturns404(t *testing.T) { + reg := buildRegistry(t, nil) + h := cronHandler(reg, testCronSecret) + + req := httptest.NewRequest(http.MethodPost, "/cron/missing", nil) + req.Header.Set(cronAuthHeader, testCronSecret) + rec := httptest.NewRecorder() + h(rec, req) + if rec.Code != http.StatusNotFound { + t.Errorf("status = %d, want 404", rec.Code) + } +} + +func TestCronHandler_RunsRegisteredCron(t *testing.T) { + called := false + factories := map[string]modules.Factory{ + "alpha": func(_ modules.Deps) modules.Module { + return modules.Module{Crons: []modules.Cron{{ + Name: "tick", + Handler: func(_ context.Context, _ modules.Deps) error { + called = true + return nil + }, + }}} + }, + } + reg := buildRegistry(t, factories, "alpha") + h := cronHandler(reg, testCronSecret) + + req := httptest.NewRequest(http.MethodPost, "/cron/tick", nil) + req.Header.Set(cronAuthHeader, testCronSecret) + rec := httptest.NewRecorder() + h(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("status = %d, want 200", rec.Code) + } + if !called { + t.Error("cron handler not invoked") + } +} diff --git a/internal/server/timeouts.go b/internal/server/timeouts.go new file mode 100644 index 0000000..c573619 --- /dev/null +++ b/internal/server/timeouts.go @@ -0,0 +1,8 @@ +package server + +import "time" + +// defaultCronTimeout caps a single /cron/{name} invocation. Cloud Run request +// timeout is 60 minutes max, but we keep crons under our HTTP read timeout so +// runaway handlers cannot pin an instance. +const defaultCronTimeout = 5 * time.Minute diff --git a/internal/storage/kv_store.go b/internal/storage/kv_store.go new file mode 100644 index 0000000..f728b57 --- /dev/null +++ b/internal/storage/kv_store.go @@ -0,0 +1,20 @@ +package storage + +import ( + "context" + "errors" +) + +// ErrNotFound is returned by KVStore implementations when a key has no value. +var ErrNotFound = errors.New("storage: key not found") + +// KVStore is the per-module key-value contract. Implementations must be safe +// for concurrent use and must return ErrNotFound for missing keys. +type KVStore interface { + Get(ctx context.Context, key string) ([]byte, error) + GetJSON(ctx context.Context, key string, dst any) error + Put(ctx context.Context, key string, val []byte) error + PutJSON(ctx context.Context, key string, val any) error + Delete(ctx context.Context, key string) error + List(ctx context.Context, prefix string) ([]string, error) +} diff --git a/internal/storage/memory_kv.go b/internal/storage/memory_kv.go new file mode 100644 index 0000000..063bb7e --- /dev/null +++ b/internal/storage/memory_kv.go @@ -0,0 +1,79 @@ +package storage + +import ( + "bytes" + "context" + "encoding/json" + "sort" + "strings" + "sync" +) + +// MemoryKVStore is an in-process KVStore for tests and local smoke runs. +// It is the only implementation available until Phase 04 adds Firestore. +type MemoryKVStore struct { + mu sync.RWMutex + m map[string][]byte +} + +// NewMemoryKVStore returns an empty in-memory store. +func NewMemoryKVStore() *MemoryKVStore { + return &MemoryKVStore{m: make(map[string][]byte)} +} + +func (s *MemoryKVStore) Get(_ context.Context, key string) ([]byte, error) { + s.mu.RLock() + defer s.mu.RUnlock() + v, ok := s.m[key] + if !ok { + return nil, ErrNotFound + } + out := make([]byte, len(v)) + copy(out, v) + return out, nil +} + +func (s *MemoryKVStore) GetJSON(ctx context.Context, key string, dst any) error { + raw, err := s.Get(ctx, key) + if err != nil { + return err + } + return json.NewDecoder(bytes.NewReader(raw)).Decode(dst) +} + +func (s *MemoryKVStore) Put(_ context.Context, key string, val []byte) error { + s.mu.Lock() + defer s.mu.Unlock() + stored := make([]byte, len(val)) + copy(stored, val) + s.m[key] = stored + return nil +} + +func (s *MemoryKVStore) PutJSON(ctx context.Context, key string, val any) error { + raw, err := json.Marshal(val) + if err != nil { + return err + } + return s.Put(ctx, key, raw) +} + +func (s *MemoryKVStore) Delete(_ context.Context, key string) error { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.m, key) + return nil +} + +func (s *MemoryKVStore) List(_ context.Context, prefix string) ([]string, error) { + s.mu.RLock() + defer s.mu.RUnlock() + keys := make([]string, 0) + for k := range s.m { + if strings.HasPrefix(k, prefix) { + keys = append(keys, k) + } + } + sort.Strings(keys) + return keys, nil +} diff --git a/internal/storage/prefix.go b/internal/storage/prefix.go new file mode 100644 index 0000000..86967ea --- /dev/null +++ b/internal/storage/prefix.go @@ -0,0 +1,55 @@ +package storage + +import ( + "context" + "strings" +) + +// Prefixed returns a KVStore that transparently prepends prefix+":" to every +// key. List() strips the prefix from returned keys so callers see their own +// flat namespace. The prefix must be non-empty. +func Prefixed(inner KVStore, prefix string) KVStore { + if prefix == "" { + panic("storage: Prefixed requires non-empty prefix") + } + return &prefixedStore{inner: inner, prefix: prefix + ":"} +} + +type prefixedStore struct { + inner KVStore + prefix string +} + +func (p *prefixedStore) k(key string) string { return p.prefix + key } + +func (p *prefixedStore) Get(ctx context.Context, key string) ([]byte, error) { + return p.inner.Get(ctx, p.k(key)) +} + +func (p *prefixedStore) GetJSON(ctx context.Context, key string, dst any) error { + return p.inner.GetJSON(ctx, p.k(key), dst) +} + +func (p *prefixedStore) Put(ctx context.Context, key string, val []byte) error { + return p.inner.Put(ctx, p.k(key), val) +} + +func (p *prefixedStore) PutJSON(ctx context.Context, key string, val any) error { + return p.inner.PutJSON(ctx, p.k(key), val) +} + +func (p *prefixedStore) Delete(ctx context.Context, key string) error { + return p.inner.Delete(ctx, p.k(key)) +} + +func (p *prefixedStore) List(ctx context.Context, prefix string) ([]string, error) { + keys, err := p.inner.List(ctx, p.k(prefix)) + if err != nil { + return nil, err + } + out := make([]string, len(keys)) + for i, k := range keys { + out[i] = strings.TrimPrefix(k, p.prefix) + } + return out, nil +} diff --git a/internal/storage/prefix_test.go b/internal/storage/prefix_test.go new file mode 100644 index 0000000..d0473b2 --- /dev/null +++ b/internal/storage/prefix_test.go @@ -0,0 +1,77 @@ +package storage + +import ( + "context" + "reflect" + "testing" +) + +func TestPrefixed_RoundTrip(t *testing.T) { + ctx := context.Background() + base := NewMemoryKVStore() + a := Prefixed(base, "modA") + b := Prefixed(base, "modB") + + if err := a.Put(ctx, "score", []byte("10")); err != nil { + t.Fatalf("a.Put: %v", err) + } + if err := b.Put(ctx, "score", []byte("20")); err != nil { + t.Fatalf("b.Put: %v", err) + } + + got, err := a.Get(ctx, "score") + if err != nil { + t.Fatalf("a.Get: %v", err) + } + if string(got) != "10" { + t.Errorf("a.Get score = %q, want %q", got, "10") + } + + got, err = b.Get(ctx, "score") + if err != nil { + t.Fatalf("b.Get: %v", err) + } + if string(got) != "20" { + t.Errorf("b.Get score = %q, want %q", got, "20") + } +} + +func TestPrefixed_ListStripsPrefix(t *testing.T) { + ctx := context.Background() + base := NewMemoryKVStore() + mod := Prefixed(base, "wordle") + + for _, k := range []string{"u:1", "u:2", "session:abc"} { + if err := mod.Put(ctx, k, []byte("x")); err != nil { + t.Fatalf("Put %q: %v", k, err) + } + } + + got, err := mod.List(ctx, "u:") + if err != nil { + t.Fatalf("List: %v", err) + } + want := []string{"u:1", "u:2"} + if !reflect.DeepEqual(got, want) { + t.Errorf("List u: = %v, want %v", got, want) + } +} + +func TestPrefixed_NotFoundPropagates(t *testing.T) { + ctx := context.Background() + base := NewMemoryKVStore() + mod := Prefixed(base, "modA") + + if _, err := mod.Get(ctx, "missing"); err != ErrNotFound { + t.Errorf("Get missing = %v, want ErrNotFound", err) + } +} + +func TestPrefixed_PanicsOnEmptyPrefix(t *testing.T) { + defer func() { + if recover() == nil { + t.Error("Prefixed(_, \"\") did not panic") + } + }() + Prefixed(NewMemoryKVStore(), "") +} diff --git a/internal/telegram/client.go b/internal/telegram/client.go new file mode 100644 index 0000000..b1b0397 --- /dev/null +++ b/internal/telegram/client.go @@ -0,0 +1,22 @@ +package telegram + +import ( + "github.com/go-telegram/bot" +) + +// NewBot constructs a Telegram bot configured for webhook mode: +// +// - WithSkipGetMe: avoid a 5s blocking call to Telegram during cold start. +// Token validity surfaces on the first outgoing API call instead. +// - WithNotAsyncHandlers: handlers run synchronously inside the dispatcher's +// goroutine. The webhook handler can rely on r.Context() staying live for +// the duration of dispatch, which a goroutine-spawning default would break. +// +// Callers may pass extra options that override these defaults. +func NewBot(token string, opts ...bot.Option) (*bot.Bot, error) { + defaults := []bot.Option{ + bot.WithSkipGetMe(), + bot.WithNotAsyncHandlers(), + } + return bot.New(token, append(defaults, opts...)...) +} diff --git a/internal/telegram/webhook.go b/internal/telegram/webhook.go new file mode 100644 index 0000000..0844033 --- /dev/null +++ b/internal/telegram/webhook.go @@ -0,0 +1,61 @@ +package telegram + +import ( + "context" + "crypto/subtle" + "encoding/json" + "net/http" + "time" + + "github.com/go-telegram/bot" + "github.com/go-telegram/bot/models" +) + +// secretTokenHeader is the case-insensitive HTTP header Telegram sets when it +// POSTs an update to the webhook. It must equal the value passed to setWebhook. +// See: https://core.telegram.org/bots/api#setwebhook +const secretTokenHeader = "X-Telegram-Bot-Api-Secret-Token" + +// maxWebhookBody bounds inbound JSON. Telegram updates are well under 100 KiB +// even with media; 1 MiB is a defensive ceiling against malformed clients. +const maxWebhookBody = 1 << 20 + +// handlerTimeout caps a single Telegram update handler. Telegram retries after +// 60s of no 2xx; 10s leaves headroom for outbound API calls inside handlers +// without holding a Cloud Run instance long enough to block other updates. +const handlerTimeout = 10 * time.Second + +// WebhookHandler returns an http.HandlerFunc that validates Telegram's secret +// token (constant-time) and dispatches the update synchronously to the bot. +// +// Dispatch is synchronous because the bot is constructed with +// bot.WithNotAsyncHandlers — handlers run inside this goroutine, so r.Context() +// stays live and bounded by handlerTimeout. +// +// secret must be non-empty; main is responsible for failing-fast at startup. +func WebhookHandler(b *bot.Bot, secret string) http.HandlerFunc { + secretBytes := []byte(secret) + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + got := []byte(r.Header.Get(secretTokenHeader)) + if subtle.ConstantTimeCompare(got, secretBytes) != 1 { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + r.Body = http.MaxBytesReader(w, r.Body, maxWebhookBody) + var update models.Update + if err := json.NewDecoder(r.Body).Decode(&update); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), handlerTimeout) + defer cancel() + b.ProcessUpdate(ctx, &update) + w.WriteHeader(http.StatusOK) + } +} diff --git a/internal/telegram/webhook_test.go b/internal/telegram/webhook_test.go new file mode 100644 index 0000000..3a4f06e --- /dev/null +++ b/internal/telegram/webhook_test.go @@ -0,0 +1,104 @@ +package telegram + +import ( + "bytes" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/go-telegram/bot" +) + +const testSecret = "super-secret-token" + +// validUpdate is a minimal Telegram update payload that decodes cleanly. The +// bot has no handlers registered so ProcessUpdate is a no-op match. +const validUpdate = `{"update_id": 1}` + +func mustBot(t *testing.T) *bot.Bot { + t.Helper() + b, err := NewBot("TEST:TOKEN") + if err != nil { + t.Fatalf("NewBot: %v", err) + } + return b +} + +func TestWebhookHandler_RejectsNonPost(t *testing.T) { + h := WebhookHandler(mustBot(t), testSecret) + req := httptest.NewRequest(http.MethodGet, "/webhook", nil) + rec := httptest.NewRecorder() + h(rec, req) + if rec.Code != http.StatusMethodNotAllowed { + t.Errorf("status = %d, want 405", rec.Code) + } +} + +func TestWebhookHandler_RejectsMissingSecret(t *testing.T) { + h := WebhookHandler(mustBot(t), testSecret) + req := httptest.NewRequest(http.MethodPost, "/webhook", strings.NewReader(validUpdate)) + rec := httptest.NewRecorder() + h(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want 401", rec.Code) + } +} + +func TestWebhookHandler_RejectsWrongSecret(t *testing.T) { + h := WebhookHandler(mustBot(t), testSecret) + req := httptest.NewRequest(http.MethodPost, "/webhook", strings.NewReader(validUpdate)) + req.Header.Set(secretTokenHeader, "wrong") + rec := httptest.NewRecorder() + h(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want 401", rec.Code) + } +} + +func TestWebhookHandler_RejectsWrongSecretSamePrefix(t *testing.T) { + // Locks the constant-time compare: a value sharing a prefix must still + // 401, not silently succeed. + h := WebhookHandler(mustBot(t), testSecret) + req := httptest.NewRequest(http.MethodPost, "/webhook", strings.NewReader(validUpdate)) + req.Header.Set(secretTokenHeader, testSecret[:len(testSecret)-1]+"X") + rec := httptest.NewRecorder() + h(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want 401", rec.Code) + } +} + +func TestWebhookHandler_RejectsMalformedJSON(t *testing.T) { + h := WebhookHandler(mustBot(t), testSecret) + req := httptest.NewRequest(http.MethodPost, "/webhook", strings.NewReader("not-json")) + req.Header.Set(secretTokenHeader, testSecret) + rec := httptest.NewRecorder() + h(rec, req) + if rec.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", rec.Code) + } +} + +func TestWebhookHandler_RejectsOversizedBody(t *testing.T) { + h := WebhookHandler(mustBot(t), testSecret) + body := bytes.Repeat([]byte("a"), maxWebhookBody+1) + req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(body)) + req.Header.Set(secretTokenHeader, testSecret) + rec := httptest.NewRecorder() + h(rec, req) + if rec.Code == http.StatusOK { + t.Errorf("oversized body should not return 200; got %d", rec.Code) + } +} + +func TestWebhookHandler_AcceptsValidUpdate(t *testing.T) { + h := WebhookHandler(mustBot(t), testSecret) + req := httptest.NewRequest(http.MethodPost, "/webhook", strings.NewReader(validUpdate)) + req.Header.Set(secretTokenHeader, testSecret) + rec := httptest.NewRecorder() + h(rec, req) + if rec.Code != http.StatusOK { + t.Errorf("status = %d, want 200", rec.Code) + } +}