// 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/sendMessage — accept the message, log it // POST /bot/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/ to the right // handler. ServeMux only matches exact paths unless the // registered pattern ends in "/", and Telegram's URL // (`/bot/`) 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/", http.StatusBadRequest) return } parts := strings.SplitN(rest, "/", 2) if len(parts) != 2 || parts[0] == "" || parts[1] == "" { http.Error(w, "bad path; expected /bot/", 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 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