|
@@ -0,0 +1,353 @@
|
|
|
|
|
+// 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
|