mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-08-08 02:25:01 +00:00
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.
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
.git
|
||||
.github
|
||||
plans
|
||||
docs
|
||||
*.md
|
||||
LICENSE
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
@@ -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 ./...
|
||||
+17
@@ -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"]
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module github.com/tiennm99/miti99bot-go
|
||||
|
||||
go 1.23
|
||||
|
||||
require github.com/go-telegram/bot v1.20.0
|
||||
@@ -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=
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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{}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(), "")
|
||||
}
|
||||
@@ -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...)...)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user