main.go 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353
  1. // Command faketgmd is a fake Telegram Bot API server for the
  2. // M3 smoke test. It mimics the parts of api.telegram.org
  3. // that broad-announce uses:
  4. //
  5. // POST /bot<token>/sendMessage — accept the message, log it
  6. // POST /bot<token>/getUpdates — return queued updates, or
  7. // block for `timeout` seconds
  8. //
  9. // Plus admin endpoints for the smoke test:
  10. //
  11. // POST /admin/queue — queue a fake incoming
  12. // update (user text) for a
  13. // given bot token. The
  14. // next getUpdates call
  15. // will return it.
  16. // GET /admin/sent — JSON list of every
  17. // sendMessage call received,
  18. // with timestamp. Used by
  19. // the smoke test to confirm
  20. // a delivery happened.
  21. // POST /admin/reset — clear sent + queue.
  22. // GET /health — liveness probe.
  23. //
  24. // M3 uses this purely for tests. The production switch is
  25. // one env var (BA_TELEGRAM_BASE_URL on the deliverd /
  26. // telegramd side); faketgmd is never deployed.
  27. package main
  28. import (
  29. "encoding/json"
  30. "flag"
  31. "io"
  32. "log/slog"
  33. "net/http"
  34. "os"
  35. "strconv"
  36. "strings"
  37. "sync"
  38. "time"
  39. )
  40. // sentMessage and queuedUpdate are the records faketgmd
  41. // keeps in memory. M3 lives in dev; persistence is
  42. // explicitly out of scope.
  43. type sentMessage struct {
  44. BotToken string `json:"bot_token"`
  45. ChatID int64 `json:"chat_id"`
  46. Text string `json:"text"`
  47. SentAt time.Time `json:"sent_at"`
  48. }
  49. type queuedUpdate struct {
  50. BotToken string
  51. UpdateID int64
  52. UserID int64
  53. ChatID int64
  54. Text string
  55. FirstName string
  56. }
  57. type server struct {
  58. mu sync.Mutex
  59. sent []sentMessage
  60. queue []queuedUpdate
  61. offsetByBot map[string]int64
  62. defaultTimeout int
  63. failRatePercent int
  64. logger *slog.Logger
  65. }
  66. func main() {
  67. addr := flag.String("addr", ":8830", "listen address")
  68. timeout := flag.Int("timeout", 25, "default long-poll timeout in seconds")
  69. failRate := flag.Int("fail-rate", 0, "percent of sendMessage calls to fail (0-100)")
  70. flag.Parse()
  71. logger := slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))
  72. s := &server{
  73. offsetByBot: map[string]int64{},
  74. defaultTimeout: *timeout,
  75. failRatePercent: *failRate,
  76. logger: logger,
  77. }
  78. mux := http.NewServeMux()
  79. mux.HandleFunc("/health", s.handleHealth)
  80. mux.HandleFunc("/admin/queue", s.handleAdminQueue)
  81. mux.HandleFunc("/admin/sent", s.handleAdminSent)
  82. mux.HandleFunc("/admin/reset", s.handleAdminReset)
  83. // Telegram-shaped endpoints. ServeMux's `/bot` (no
  84. // trailing slash) is exact-match only. We register
  85. // `/` (root subtree) so we can read the path inside
  86. // the handler. /admin/* and /health are still matched
  87. // by the more specific patterns above because ServeMux
  88. // prefers the longest match.
  89. mux.HandleFunc("/", s.routeAny)
  90. logger.Info("faketgmd listening", "addr", *addr, "timeout", *timeout, "fail_rate", *failRate)
  91. if err := http.ListenAndServe(*addr, mux); err != nil {
  92. logger.Error("listen", "err", err)
  93. os.Exit(1)
  94. }
  95. }
  96. func (s *server) handleHealth(w http.ResponseWriter, r *http.Request) {
  97. s.mu.Lock()
  98. nSent := len(s.sent)
  99. s.mu.Unlock()
  100. w.Header().Set("Content-Type", "application/json")
  101. _ = json.NewEncoder(w).Encode(map[string]any{
  102. "status": "ok",
  103. "service": "faketgmd",
  104. "received": nSent,
  105. "failed": 0,
  106. })
  107. }
  108. // handleBot dispatches /bot<token>/<method> to the right
  109. // handler. ServeMux only matches exact paths unless the
  110. // registered pattern ends in "/", and Telegram's URL
  111. // (`/bot<token>/<method>`) doesn't have a slash after
  112. // "bot", so we register `/` and dispatch on the path
  113. // inside routeAny.
  114. func (s *server) routeAny(w http.ResponseWriter, r *http.Request) {
  115. path := r.URL.Path
  116. switch {
  117. case strings.HasPrefix(path, "/bot"):
  118. rest := strings.TrimPrefix(path, "/bot")
  119. if rest == "" {
  120. http.Error(w, "bad path; expected /bot<token>/<method>", http.StatusBadRequest)
  121. return
  122. }
  123. parts := strings.SplitN(rest, "/", 2)
  124. if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
  125. http.Error(w, "bad path; expected /bot<token>/<method>", http.StatusBadRequest)
  126. return
  127. }
  128. token, method := parts[0], parts[1]
  129. switch method {
  130. case "sendMessage":
  131. s.handleSendMessage(w, r, token)
  132. case "getUpdates":
  133. s.handleGetUpdates(w, r, token)
  134. default:
  135. http.Error(w, "unknown method "+method, http.StatusNotFound)
  136. }
  137. default:
  138. http.NotFound(w, r)
  139. }
  140. }
  141. // handleBot kept for backward-compat with earlier callers; routes
  142. // through routeAny.
  143. func (s *server) handleBot(w http.ResponseWriter, r *http.Request) {
  144. s.routeAny(w, r)
  145. }
  146. // handleSendMessage accepts the JSON body, optionally
  147. // fails, and logs to the sent[] ring.
  148. func (s *server) handleSendMessage(w http.ResponseWriter, r *http.Request, token string) {
  149. body, _ := io.ReadAll(r.Body)
  150. defer r.Body.Close()
  151. var req struct {
  152. ChatID int64 `json:"chat_id"`
  153. Text string `json:"text"`
  154. }
  155. if err := json.Unmarshal(body, &req); err != nil {
  156. http.Error(w, "bad json: "+err.Error(), http.StatusBadRequest)
  157. return
  158. }
  159. // Roll the failure dice.
  160. if s.failRatePercent > 0 {
  161. if n := time.Now().UnixNano() % 100; int(n) < s.failRatePercent {
  162. s.logger.Warn("faketgmd failing this sendMessage", "fail_rate", s.failRatePercent)
  163. http.Error(w, `{"ok":false,"description":"intentional fake fail"}`, http.StatusInternalServerError)
  164. return
  165. }
  166. }
  167. s.mu.Lock()
  168. s.sent = append(s.sent, sentMessage{
  169. BotToken: token,
  170. ChatID: req.ChatID,
  171. Text: req.Text,
  172. SentAt: time.Now().UTC(),
  173. })
  174. s.mu.Unlock()
  175. resp := map[string]any{
  176. "ok": true,
  177. "result": map[string]any{
  178. "message_id": time.Now().UnixNano() % 1_000_000,
  179. "chat": map[string]any{"id": req.ChatID, "type": "private"},
  180. "date": time.Now().Unix(),
  181. "text": req.Text,
  182. },
  183. }
  184. w.Header().Set("Content-Type", "application/json")
  185. _ = json.NewEncoder(w).Encode(resp)
  186. }
  187. // handleGetUpdates long-polls: waits up to defaultTimeout
  188. // seconds for at least one queued update for this bot,
  189. // returns everything queued, advances the offset.
  190. func (s *server) handleGetUpdates(w http.ResponseWriter, r *http.Request, token string) {
  191. body, _ := io.ReadAll(r.Body)
  192. defer r.Body.Close()
  193. var req struct {
  194. Offset int64 `json:"offset"`
  195. Timeout int `json:"timeout"`
  196. }
  197. if len(body) > 0 {
  198. _ = json.Unmarshal(body, &req)
  199. }
  200. if req.Timeout == 0 {
  201. req.Timeout = s.defaultTimeout
  202. }
  203. deadline := time.Now().Add(time.Duration(req.Timeout) * time.Second)
  204. for time.Now().Before(deadline) {
  205. s.mu.Lock()
  206. var updates []map[string]any
  207. for _, u := range s.queue {
  208. if u.BotToken != token {
  209. continue
  210. }
  211. if u.UpdateID < req.Offset {
  212. continue
  213. }
  214. updates = append(updates, map[string]any{
  215. "update_id": u.UpdateID,
  216. "message": map[string]any{
  217. "message_id": u.UpdateID,
  218. "from": map[string]any{
  219. "id": u.UserID,
  220. "is_bot": false,
  221. "first_name": u.FirstName,
  222. },
  223. "chat": map[string]any{
  224. "id": u.ChatID,
  225. "type": "private",
  226. },
  227. "text": u.Text,
  228. "date": time.Now().Unix(),
  229. },
  230. })
  231. }
  232. // Advance offset to last+1.
  233. var maxID int64
  234. for _, u := range s.queue {
  235. if u.BotToken == token && u.UpdateID > maxID {
  236. maxID = u.UpdateID
  237. }
  238. }
  239. if maxID >= req.Offset {
  240. s.offsetByBot[token] = maxID + 1
  241. }
  242. s.mu.Unlock()
  243. if len(updates) > 0 {
  244. w.Header().Set("Content-Type", "application/json")
  245. _ = json.NewEncoder(w).Encode(map[string]any{
  246. "ok": true,
  247. "result": updates,
  248. })
  249. return
  250. }
  251. // Sleep a bit, then poll again. Long-poll in the
  252. // spec keeps the connection open; we simulate by
  253. // re-checking every 500ms.
  254. time.Sleep(500 * time.Millisecond)
  255. }
  256. // Timeout with no updates.
  257. w.Header().Set("Content-Type", "application/json")
  258. _ = json.NewEncoder(w).Encode(map[string]any{
  259. "ok": true,
  260. "result": []map[string]any{},
  261. })
  262. }
  263. // handleAdminQueue accepts a JSON body to enqueue a fake
  264. // incoming update for a bot. Used by the smoke test to
  265. // simulate a user typing /start <code> or /subscribe ...
  266. func (s *server) handleAdminQueue(w http.ResponseWriter, r *http.Request) {
  267. if r.Method != http.MethodPost {
  268. http.Error(w, "POST only", http.StatusMethodNotAllowed)
  269. return
  270. }
  271. body, _ := io.ReadAll(r.Body)
  272. defer r.Body.Close()
  273. var req struct {
  274. BotToken string `json:"bot_token"`
  275. UserID int64 `json:"user_id"`
  276. ChatID int64 `json:"chat_id"`
  277. Text string `json:"text"`
  278. FirstName string `json:"first_name"`
  279. }
  280. if err := json.Unmarshal(body, &req); err != nil {
  281. http.Error(w, "bad json: "+err.Error(), http.StatusBadRequest)
  282. return
  283. }
  284. if req.BotToken == "" || req.UserID == 0 || req.ChatID == 0 {
  285. http.Error(w, "bot_token, user_id, chat_id required", http.StatusBadRequest)
  286. return
  287. }
  288. if req.FirstName == "" {
  289. req.FirstName = "FakeUser"
  290. }
  291. s.mu.Lock()
  292. defer s.mu.Unlock()
  293. s.queue = append(s.queue, queuedUpdate{
  294. BotToken: req.BotToken,
  295. UpdateID: int64(len(s.queue) + 1),
  296. UserID: req.UserID,
  297. ChatID: req.ChatID,
  298. Text: req.Text,
  299. FirstName: req.FirstName,
  300. })
  301. w.Header().Set("Content-Type", "application/json")
  302. _ = json.NewEncoder(w).Encode(map[string]any{
  303. "queued": true,
  304. "update_id": len(s.queue),
  305. })
  306. }
  307. func (s *server) handleAdminSent(w http.ResponseWriter, r *http.Request) {
  308. s.mu.Lock()
  309. defer s.mu.Unlock()
  310. w.Header().Set("Content-Type", "application/json")
  311. _ = json.NewEncoder(w).Encode(map[string]any{
  312. "count": len(s.sent),
  313. "items": s.sent,
  314. })
  315. }
  316. func (s *server) handleAdminReset(w http.ResponseWriter, r *http.Request) {
  317. s.mu.Lock()
  318. defer s.mu.Unlock()
  319. s.sent = nil
  320. s.queue = nil
  321. s.offsetByBot = map[string]int64{}
  322. w.Header().Set("Content-Type", "application/json")
  323. _ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
  324. }
  325. var _ = strconv.Itoa // keep import