| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353 |
- // Command faketgmd is a fake Telegram Bot API server for the
- // M3 smoke test. It mimics the parts of api.telegram.org
- // that broad-announce uses:
- //
- // POST /bot<token>/sendMessage — accept the message, log it
- // POST /bot<token>/getUpdates — return queued updates, or
- // block for `timeout` seconds
- //
- // Plus admin endpoints for the smoke test:
- //
- // POST /admin/queue — queue a fake incoming
- // update (user text) for a
- // given bot token. The
- // next getUpdates call
- // will return it.
- // GET /admin/sent — JSON list of every
- // sendMessage call received,
- // with timestamp. Used by
- // the smoke test to confirm
- // a delivery happened.
- // POST /admin/reset — clear sent + queue.
- // GET /health — liveness probe.
- //
- // M3 uses this purely for tests. The production switch is
- // one env var (BA_TELEGRAM_BASE_URL on the deliverd /
- // telegramd side); faketgmd is never deployed.
- package main
- import (
- "encoding/json"
- "flag"
- "io"
- "log/slog"
- "net/http"
- "os"
- "strconv"
- "strings"
- "sync"
- "time"
- )
- // sentMessage and queuedUpdate are the records faketgmd
- // keeps in memory. M3 lives in dev; persistence is
- // explicitly out of scope.
- type sentMessage struct {
- BotToken string `json:"bot_token"`
- ChatID int64 `json:"chat_id"`
- Text string `json:"text"`
- SentAt time.Time `json:"sent_at"`
- }
- type queuedUpdate struct {
- BotToken string
- UpdateID int64
- UserID int64
- ChatID int64
- Text string
- FirstName string
- }
- type server struct {
- mu sync.Mutex
- sent []sentMessage
- queue []queuedUpdate
- offsetByBot map[string]int64
- defaultTimeout int
- failRatePercent int
- logger *slog.Logger
- }
- func main() {
- addr := flag.String("addr", ":8830", "listen address")
- timeout := flag.Int("timeout", 25, "default long-poll timeout in seconds")
- failRate := flag.Int("fail-rate", 0, "percent of sendMessage calls to fail (0-100)")
- flag.Parse()
- logger := slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))
- s := &server{
- offsetByBot: map[string]int64{},
- defaultTimeout: *timeout,
- failRatePercent: *failRate,
- logger: logger,
- }
- mux := http.NewServeMux()
- mux.HandleFunc("/health", s.handleHealth)
- mux.HandleFunc("/admin/queue", s.handleAdminQueue)
- mux.HandleFunc("/admin/sent", s.handleAdminSent)
- mux.HandleFunc("/admin/reset", s.handleAdminReset)
- // Telegram-shaped endpoints. ServeMux's `/bot` (no
- // trailing slash) is exact-match only. We register
- // `/` (root subtree) so we can read the path inside
- // the handler. /admin/* and /health are still matched
- // by the more specific patterns above because ServeMux
- // prefers the longest match.
- mux.HandleFunc("/", s.routeAny)
- logger.Info("faketgmd listening", "addr", *addr, "timeout", *timeout, "fail_rate", *failRate)
- if err := http.ListenAndServe(*addr, mux); err != nil {
- logger.Error("listen", "err", err)
- os.Exit(1)
- }
- }
- func (s *server) handleHealth(w http.ResponseWriter, r *http.Request) {
- s.mu.Lock()
- nSent := len(s.sent)
- s.mu.Unlock()
- w.Header().Set("Content-Type", "application/json")
- _ = json.NewEncoder(w).Encode(map[string]any{
- "status": "ok",
- "service": "faketgmd",
- "received": nSent,
- "failed": 0,
- })
- }
- // handleBot dispatches /bot<token>/<method> to the right
- // handler. ServeMux only matches exact paths unless the
- // registered pattern ends in "/", and Telegram's URL
- // (`/bot<token>/<method>`) doesn't have a slash after
- // "bot", so we register `/` and dispatch on the path
- // inside routeAny.
- func (s *server) routeAny(w http.ResponseWriter, r *http.Request) {
- path := r.URL.Path
- switch {
- case strings.HasPrefix(path, "/bot"):
- rest := strings.TrimPrefix(path, "/bot")
- if rest == "" {
- http.Error(w, "bad path; expected /bot<token>/<method>", http.StatusBadRequest)
- return
- }
- parts := strings.SplitN(rest, "/", 2)
- if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
- http.Error(w, "bad path; expected /bot<token>/<method>", http.StatusBadRequest)
- return
- }
- token, method := parts[0], parts[1]
- switch method {
- case "sendMessage":
- s.handleSendMessage(w, r, token)
- case "getUpdates":
- s.handleGetUpdates(w, r, token)
- default:
- http.Error(w, "unknown method "+method, http.StatusNotFound)
- }
- default:
- http.NotFound(w, r)
- }
- }
- // handleBot kept for backward-compat with earlier callers; routes
- // through routeAny.
- func (s *server) handleBot(w http.ResponseWriter, r *http.Request) {
- s.routeAny(w, r)
- }
- // handleSendMessage accepts the JSON body, optionally
- // fails, and logs to the sent[] ring.
- func (s *server) handleSendMessage(w http.ResponseWriter, r *http.Request, token string) {
- body, _ := io.ReadAll(r.Body)
- defer r.Body.Close()
- var req struct {
- ChatID int64 `json:"chat_id"`
- Text string `json:"text"`
- }
- if err := json.Unmarshal(body, &req); err != nil {
- http.Error(w, "bad json: "+err.Error(), http.StatusBadRequest)
- return
- }
- // Roll the failure dice.
- if s.failRatePercent > 0 {
- if n := time.Now().UnixNano() % 100; int(n) < s.failRatePercent {
- s.logger.Warn("faketgmd failing this sendMessage", "fail_rate", s.failRatePercent)
- http.Error(w, `{"ok":false,"description":"intentional fake fail"}`, http.StatusInternalServerError)
- return
- }
- }
- s.mu.Lock()
- s.sent = append(s.sent, sentMessage{
- BotToken: token,
- ChatID: req.ChatID,
- Text: req.Text,
- SentAt: time.Now().UTC(),
- })
- s.mu.Unlock()
- resp := map[string]any{
- "ok": true,
- "result": map[string]any{
- "message_id": time.Now().UnixNano() % 1_000_000,
- "chat": map[string]any{"id": req.ChatID, "type": "private"},
- "date": time.Now().Unix(),
- "text": req.Text,
- },
- }
- w.Header().Set("Content-Type", "application/json")
- _ = json.NewEncoder(w).Encode(resp)
- }
- // handleGetUpdates long-polls: waits up to defaultTimeout
- // seconds for at least one queued update for this bot,
- // returns everything queued, advances the offset.
- func (s *server) handleGetUpdates(w http.ResponseWriter, r *http.Request, token string) {
- body, _ := io.ReadAll(r.Body)
- defer r.Body.Close()
- var req struct {
- Offset int64 `json:"offset"`
- Timeout int `json:"timeout"`
- }
- if len(body) > 0 {
- _ = json.Unmarshal(body, &req)
- }
- if req.Timeout == 0 {
- req.Timeout = s.defaultTimeout
- }
- deadline := time.Now().Add(time.Duration(req.Timeout) * time.Second)
- for time.Now().Before(deadline) {
- s.mu.Lock()
- var updates []map[string]any
- for _, u := range s.queue {
- if u.BotToken != token {
- continue
- }
- if u.UpdateID < req.Offset {
- continue
- }
- updates = append(updates, map[string]any{
- "update_id": u.UpdateID,
- "message": map[string]any{
- "message_id": u.UpdateID,
- "from": map[string]any{
- "id": u.UserID,
- "is_bot": false,
- "first_name": u.FirstName,
- },
- "chat": map[string]any{
- "id": u.ChatID,
- "type": "private",
- },
- "text": u.Text,
- "date": time.Now().Unix(),
- },
- })
- }
- // Advance offset to last+1.
- var maxID int64
- for _, u := range s.queue {
- if u.BotToken == token && u.UpdateID > maxID {
- maxID = u.UpdateID
- }
- }
- if maxID >= req.Offset {
- s.offsetByBot[token] = maxID + 1
- }
- s.mu.Unlock()
- if len(updates) > 0 {
- w.Header().Set("Content-Type", "application/json")
- _ = json.NewEncoder(w).Encode(map[string]any{
- "ok": true,
- "result": updates,
- })
- return
- }
- // Sleep a bit, then poll again. Long-poll in the
- // spec keeps the connection open; we simulate by
- // re-checking every 500ms.
- time.Sleep(500 * time.Millisecond)
- }
- // Timeout with no updates.
- w.Header().Set("Content-Type", "application/json")
- _ = json.NewEncoder(w).Encode(map[string]any{
- "ok": true,
- "result": []map[string]any{},
- })
- }
- // handleAdminQueue accepts a JSON body to enqueue a fake
- // incoming update for a bot. Used by the smoke test to
- // simulate a user typing /start <code> or /subscribe ...
- func (s *server) handleAdminQueue(w http.ResponseWriter, r *http.Request) {
- if r.Method != http.MethodPost {
- http.Error(w, "POST only", http.StatusMethodNotAllowed)
- return
- }
- body, _ := io.ReadAll(r.Body)
- defer r.Body.Close()
- var req struct {
- BotToken string `json:"bot_token"`
- UserID int64 `json:"user_id"`
- ChatID int64 `json:"chat_id"`
- Text string `json:"text"`
- FirstName string `json:"first_name"`
- }
- if err := json.Unmarshal(body, &req); err != nil {
- http.Error(w, "bad json: "+err.Error(), http.StatusBadRequest)
- return
- }
- if req.BotToken == "" || req.UserID == 0 || req.ChatID == 0 {
- http.Error(w, "bot_token, user_id, chat_id required", http.StatusBadRequest)
- return
- }
- if req.FirstName == "" {
- req.FirstName = "FakeUser"
- }
- s.mu.Lock()
- defer s.mu.Unlock()
- s.queue = append(s.queue, queuedUpdate{
- BotToken: req.BotToken,
- UpdateID: int64(len(s.queue) + 1),
- UserID: req.UserID,
- ChatID: req.ChatID,
- Text: req.Text,
- FirstName: req.FirstName,
- })
- w.Header().Set("Content-Type", "application/json")
- _ = json.NewEncoder(w).Encode(map[string]any{
- "queued": true,
- "update_id": len(s.queue),
- })
- }
- func (s *server) handleAdminSent(w http.ResponseWriter, r *http.Request) {
- s.mu.Lock()
- defer s.mu.Unlock()
- w.Header().Set("Content-Type", "application/json")
- _ = json.NewEncoder(w).Encode(map[string]any{
- "count": len(s.sent),
- "items": s.sent,
- })
- }
- func (s *server) handleAdminReset(w http.ResponseWriter, r *http.Request) {
- s.mu.Lock()
- defer s.mu.Unlock()
- s.sent = nil
- s.queue = nil
- s.offsetByBot = map[string]int64{}
- w.Header().Set("Content-Type", "application/json")
- _ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
- }
- var _ = strconv.Itoa // keep import
|