Files
miti99bot/internal/telegram/webhook.go
T
tiennm99 6cde38b6f4 refactor: rename module to miti99bot, canonicalize AWS deploy path
Rename:
- Go module github.com/tiennm99/miti99bot-go → github.com/tiennm99/miti99bot
- CloudFormation stack miti99bot-aws-port → miti99bot
- Drop "port", "Cloud Run", "GCP", "cutover", "Phase NN" framing from
  active code and docs — project reads as canonical AWS-Lambda from now on.

AWS deploy guide + flow fix:
- New docs/deploy-aws-free-tier-guide.md — Ubuntu 24.04 ARM64 onboarding
  with project-local venv (pip awscli + sam-cli), SSM secrets via read -s,
  idempotent OIDC provider + role creation, $1 budget alarm.
- Drop sam build from the pipeline — provided.al2023 + makefile builder
  expects a Makefile in CodeUri (build/lambda/, the output dir), so the
  step always fails. sam deploy --template-file template.yaml now reads
  the raw template and zips build/lambda/ directly.
- Rollback section rewritten — use continue-update-rollback /
  cancel-update-stack / git-SHA redeploy. Drop the broken
  --use-previous-template recipe.
- DynamoDB free-tier row corrected (on-demand is 2.5M read / 1M write
  request units, not 25 RCU/WCU).

Updated:
- README.md fully rewritten (drops port/legacy framing, lists modules,
  points new users at the free-tier guide).
- aws/README.md retitled "AWS account setup", phase numbers stripped.
- Makefile / .github/workflows/deploy.yml — sam deploy flow.
- samconfig.toml — stack_name = "miti99bot".
- Go comments — Cloud Run → Lambda, Cloud Scheduler → EventBridge
  Scheduler, Cloud Logging → CloudWatch Logs.
- Struct field GCPProject → FirestoreProject (env GOOGLE_CLOUD_PROJECT
  unchanged).

Plus advisory reports under plans/reports/ from the code-reviewer +
researcher passes that informed the fixes.

Verified: go vet ./..., go build ./..., go test ./... all green.
2026-05-13 22:05:38 +07:00

87 lines
3.0 KiB
Go

package telegram
import (
"context"
"crypto/subtle"
"encoding/json"
"errors"
"net/http"
"runtime/debug"
"time"
"github.com/go-telegram/bot"
"github.com/go-telegram/bot/models"
"github.com/tiennm99/miti99bot/internal/log"
)
// 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
// #nosec G101 — header name, not credential value
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 Lambda 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 {
// MaxBytesReader returns *http.MaxBytesError when the cap is hit;
// surface 413 distinctly so Telegram (and ops dashboards) can
// distinguish "body too big" from generic malformed JSON.
var maxBytesErr *http.MaxBytesError
if errors.As(err, &maxBytesErr) {
http.Error(w, "request body too large", http.StatusRequestEntityTooLarge)
return
}
http.Error(w, "bad request", http.StatusBadRequest)
return
}
ctx, cancel := context.WithTimeout(r.Context(), handlerTimeout)
defer cancel()
// Recover panics so a buggy handler does not propagate up to the
// http.Server (which would close the response mid-write and trigger
// Telegram's 24-hour retry loop on the same poisoned update).
func() {
defer func() {
if rec := recover(); rec != nil {
log.Error("webhook handler panic",
"panic", rec,
"stack", string(debug.Stack()))
}
}()
b.ProcessUpdate(ctx, &update)
}()
w.WriteHeader(http.StatusOK)
}
}