mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-09-03 16:16:57 +00:00
feat(telegram): switch to long polling, remove webhook path
Replace webhook-based updates with long-polling GetUpdates loop. Eliminates external HTTP endpoint requirement and simplifies self-hosted deployments. Removes webhook.go and associated webhook routing from server.
This commit is contained in:
+14
-16
@@ -13,7 +13,6 @@ import (
|
||||
|
||||
"github.com/tiennm99/miti99bot/internal/log"
|
||||
"github.com/tiennm99/miti99bot/internal/modules"
|
||||
"github.com/tiennm99/miti99bot/internal/telegram"
|
||||
)
|
||||
|
||||
// cronNameRe limits cron path segments to a safe alphabet so log injection via
|
||||
@@ -21,35 +20,34 @@ import (
|
||||
// router boundary). Same shape as Telegram command names.
|
||||
var cronNameRe = regexp.MustCompile(`^[a-z0-9_]{1,32}$`)
|
||||
|
||||
// cronAuthHeader is the shared-secret header EventBridge Scheduler attaches when
|
||||
// invoking /cron/{name}.
|
||||
// cronAuthHeader is the shared-secret header a caller attaches when invoking
|
||||
// /cron/{name} for a manual trigger.
|
||||
const cronAuthHeader = "X-Cron-Token"
|
||||
|
||||
// Config wires the router's runtime dependencies.
|
||||
type Config struct {
|
||||
Bot *bot.Bot
|
||||
Registry *modules.Registry
|
||||
WebhookSecret string
|
||||
Bot *bot.Bot
|
||||
Registry *modules.Registry
|
||||
|
||||
// CronSecret protects /cron/{name} against unauthenticated calls; EventBridge
|
||||
// Scheduler attaches it as the X-Cron-Token header. Empty means /cron/{name}
|
||||
// is fully disabled (404).
|
||||
// CronSecret protects /cron/{name} against unauthenticated calls; a caller
|
||||
// attaches it as the X-Cron-Token header. Empty means /cron/{name} is fully
|
||||
// disabled (404) — the default on self-host, where the in-process scheduler
|
||||
// (internal/cron) is the sole cron trigger.
|
||||
CronSecret string
|
||||
}
|
||||
|
||||
// New builds the application's HTTP handler. Routes:
|
||||
//
|
||||
// GET / → health
|
||||
// POST /webhook → Telegram update intake (constant-time secret check)
|
||||
// POST /cron/{name} → EventBridge Scheduler entry (shared-secret check)
|
||||
// GET / → health (Coolify container monitor; not publicly routed)
|
||||
// POST /cron/{name} → optional manual cron trigger (shared-secret check)
|
||||
//
|
||||
// Anything else is 404. All routes pass through LogRequests so every
|
||||
// request emits a structured `req` log line (CloudWatch Logs consumes them
|
||||
// for 5xx-rate alerts and per-route latency).
|
||||
// There is no /webhook route: Telegram updates arrive via long polling
|
||||
// (cmd/server runs b.Start), so the bot needs no public inbound ingress.
|
||||
// Anything else is 404. All routes pass through LogRequests so every request
|
||||
// emits a structured `req` log line.
|
||||
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 LogRequests(mux)
|
||||
}
|
||||
|
||||
@@ -4,19 +4,29 @@ import (
|
||||
"github.com/go-telegram/bot"
|
||||
)
|
||||
|
||||
// NewBot constructs a Telegram bot configured for webhook mode:
|
||||
// pollingAllowedUpdates restricts getUpdates to the update kinds the modules
|
||||
// actually handle (text commands + inline-keyboard callbacks), matching the
|
||||
// allowed_updates the old webhook registration set. Anything else (channel
|
||||
// posts, edited messages, etc.) is dropped server-side by Telegram.
|
||||
var pollingAllowedUpdates = bot.AllowedUpdates{"message", "callback_query"}
|
||||
|
||||
// NewBot constructs a Telegram bot for long-polling mode (the sole transport
|
||||
// on self-host — b.Start runs the getUpdates loop in cmd/server):
|
||||
//
|
||||
// - 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.
|
||||
// - WithSkipGetMe: avoid a blocking GetMe call at startup. Token validity
|
||||
// surfaces on the first outgoing API call instead.
|
||||
// - WithNotAsyncHandlers: handlers run synchronously inside the dispatch
|
||||
// goroutine. Module handlers take their own ctx (not r.Context()), so this
|
||||
// is safe; it also bounds in-flight work to one update at a time, which
|
||||
// suits the single-replica polling deployment.
|
||||
// - WithAllowedUpdates: only request the update kinds the bot handles.
|
||||
//
|
||||
// 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(),
|
||||
bot.WithAllowedUpdates(pollingAllowedUpdates),
|
||||
}
|
||||
return bot.New(token, append(defaults, opts...)...)
|
||||
}
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
package telegram
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"runtime/debug"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"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) {
|
||||
// Rejection paths use bare status codes (no response body) so internet
|
||||
// scanners hitting the public Function URL can't fingerprint this as a
|
||||
// Telegram webhook from the response text. CloudWatch metric filters
|
||||
// still see the distinct status codes (401 / 405 / 413 / 400), and the
|
||||
// structured log lines below carry the *reason* for operator triage.
|
||||
if r.Method != http.MethodPost {
|
||||
log.Warn("webhook rejected", "reason", "method", "method", r.Method)
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
got := []byte(r.Header.Get(secretTokenHeader))
|
||||
if subtle.ConstantTimeCompare(got, secretBytes) != 1 {
|
||||
log.Warn("webhook rejected", "reason", "secret_mismatch")
|
||||
w.WriteHeader(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) {
|
||||
log.Warn("webhook rejected", "reason", "body_too_large")
|
||||
w.WriteHeader(http.StatusRequestEntityTooLarge)
|
||||
return
|
||||
}
|
||||
log.Warn("webhook rejected", "reason", "bad_json", "err", err)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
logDispatch(&update)
|
||||
|
||||
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).
|
||||
panicked := false
|
||||
func() {
|
||||
defer func() {
|
||||
if rec := recover(); rec != nil {
|
||||
panicked = true
|
||||
log.Error("webhook handler panic",
|
||||
"panic", rec,
|
||||
"stack", string(debug.Stack()))
|
||||
}
|
||||
}()
|
||||
b.ProcessUpdate(ctx, &update)
|
||||
}()
|
||||
// Suppress the trailing 200 if a panic occurred: a poisoned handler
|
||||
// may have already written headers/body, and a second WriteHeader
|
||||
// here emits `superfluous response.WriteHeader` noise. The
|
||||
// LogRequests middleware will mark this as 500 from its own recover
|
||||
// path; we just stay quiet here.
|
||||
if !panicked {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// dispatchTextPreview caps message text in dispatch logs so chatty media
|
||||
// captions or long DM threads don't bloat CloudWatch / drive up cost.
|
||||
const dispatchTextPreview = 64
|
||||
|
||||
// truncateRunes returns the longest prefix of s whose UTF-8 byte length is
|
||||
// <= maxBytes AND that ends on a rune boundary. Byte-slicing alone would
|
||||
// split a multi-byte rune (Vietnamese, emoji, CJK), producing invalid UTF-8
|
||||
// in the log line that downstream JSON encoders replace with U+FFFD.
|
||||
func truncateRunes(s string, maxBytes int) string {
|
||||
if len(s) <= maxBytes {
|
||||
return s
|
||||
}
|
||||
cut := maxBytes
|
||||
for cut > 0 && !utf8.RuneStart(s[cut]) {
|
||||
cut--
|
||||
}
|
||||
return s[:cut]
|
||||
}
|
||||
|
||||
// logDispatch emits a single structured line per inbound update so the
|
||||
// CloudWatch trail has chat type + command text without resorting to
|
||||
// the library's pointer-printing debug mode. Cheap (no allocation when
|
||||
// the message is short) and fires once per webhook hit.
|
||||
func logDispatch(u *models.Update) {
|
||||
if u == nil || u.Message == nil {
|
||||
return
|
||||
}
|
||||
text := u.Message.Text
|
||||
if text == "" {
|
||||
text = u.Message.Caption
|
||||
}
|
||||
if len(text) > dispatchTextPreview {
|
||||
text = truncateRunes(text, dispatchTextPreview) + "…"
|
||||
}
|
||||
log.Info("dispatch",
|
||||
"update_id", u.ID,
|
||||
"chat_id", u.Message.Chat.ID,
|
||||
"chat_type", string(u.Message.Chat.Type),
|
||||
"text", text,
|
||||
)
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
package telegram
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/go-telegram/bot"
|
||||
"github.com/go-telegram/bot/models"
|
||||
)
|
||||
|
||||
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)
|
||||
// Valid-prefixed JSON so the decoder doesn't bail on the first byte; the
|
||||
// long string field forces a read past maxWebhookBody, triggering
|
||||
// *http.MaxBytesError. Plain "aaaa…" without the JSON wrapper would fail
|
||||
// at byte 1 with a SyntaxError and never exercise the cap.
|
||||
body := bytes.Buffer{}
|
||||
body.WriteString(`{"update_id":1,"message":{"text":"`)
|
||||
body.Write(bytes.Repeat([]byte("a"), maxWebhookBody+1))
|
||||
body.WriteString(`"}}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/webhook", &body)
|
||||
req.Header.Set(secretTokenHeader, testSecret)
|
||||
rec := httptest.NewRecorder()
|
||||
h(rec, req)
|
||||
if rec.Code != http.StatusRequestEntityTooLarge {
|
||||
t.Errorf("status = %d, want 413", 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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateRunes_KeepsUTF8Valid(t *testing.T) {
|
||||
// Single-byte (ASCII): output must equal a byte slice when boundary aligns.
|
||||
if got := truncateRunes("hello world", 5); got != "hello" {
|
||||
t.Errorf("ascii: got %q, want %q", got, "hello")
|
||||
}
|
||||
// Multi-byte (Vietnamese): max=5 bytes, "ầ" is 3 bytes ("\xe1\xba\xa7").
|
||||
// "h" (1) + "ầ" (3) = 4 bytes; next rune would push to 7. truncate at 5
|
||||
// would land mid-rune; the helper must walk back to byte 4 so the slice
|
||||
// ends on a rune boundary and the result decodes cleanly.
|
||||
if got := truncateRunes("hầuhầuhầu", 5); got != "hầu" {
|
||||
t.Errorf("vietnamese: got %q (len %d), want %q (len %d)", got, len(got), "hầu", len("hầu"))
|
||||
}
|
||||
// Length-below-cap path: pass through unchanged.
|
||||
if got := truncateRunes("abc", 10); got != "abc" {
|
||||
t.Errorf("short: got %q, want %q", got, "abc")
|
||||
}
|
||||
}
|
||||
|
||||
// panicUpdate matches the panicHandler registered below by /panic command.
|
||||
const panicUpdate = `{"update_id":2,"message":{"message_id":1,"date":1,"chat":{"id":1,"type":"private"},"from":{"id":1,"is_bot":false,"first_name":"x"},"text":"/panic","entities":[{"type":"bot_command","offset":0,"length":6}]}}`
|
||||
|
||||
func TestWebhookHandler_RecoversPanicAndReturns200(t *testing.T) {
|
||||
// A panicking handler must NOT propagate to the http.Server (would close
|
||||
// the response mid-write and trigger Telegram's 24-hour retry storm on the
|
||||
// same poisoned update). Recovery returns 200; Telegram does not retry.
|
||||
b := mustBot(t)
|
||||
b.RegisterHandler(bot.HandlerTypeMessageText, "panic", bot.MatchTypeCommand,
|
||||
func(ctx context.Context, _ *bot.Bot, _ *models.Update) {
|
||||
panic("boom")
|
||||
})
|
||||
|
||||
h := WebhookHandler(b, testSecret)
|
||||
req := httptest.NewRequest(http.MethodPost, "/webhook", strings.NewReader(panicUpdate))
|
||||
req.Header.Set(secretTokenHeader, testSecret)
|
||||
rec := httptest.NewRecorder()
|
||||
h(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200 after recover", rec.Code)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user