main.go 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195
  1. // Command telegramd is the long-polling bot loop for
  2. // broad-announce M3 (SPEC §8). It pulls the list of
  3. // active telegram_bots from Postgres, then for each bot
  4. // long-polls getUpdates, parses commands, applies them
  5. // via internal/telegram.Handler, and replies via
  6. // SendMessage.
  7. //
  8. // M3 ships long-polling only. Webhook mode is M5/M9.
  9. //
  10. // Configuration:
  11. // BA_TELEGRAM_BOT_TOKEN — fallback if DB has no row
  12. // BA_TELEGRAM_BASE_URL — override for tests (default
  13. // https://api.telegram.org).
  14. // M3 dev: point at faketgmd.
  15. //
  16. // One process polls all bots. With M3's one-bot-per-company
  17. // this is a single update loop; M3+ can shard by company
  18. // hash if the volume warrants.
  19. package main
  20. import (
  21. "context"
  22. "log/slog"
  23. "net/http"
  24. "os"
  25. "os/signal"
  26. "syscall"
  27. "time"
  28. "git3.techno-world.net/lrosales/broad-announce/internal/broker"
  29. "git3.techno-world.net/lrosales/broad-announce/internal/config"
  30. "git3.techno-world.net/lrosales/broad-announce/internal/httpserver"
  31. "git3.techno-world.net/lrosales/broad-announce/internal/observability"
  32. "git3.techno-world.net/lrosales/broad-announce/internal/postgres"
  33. "git3.techno-world.net/lrosales/broad-announce/internal/telegram"
  34. )
  35. type botConfig struct {
  36. BotID string
  37. CompanyID string
  38. BotToken string
  39. }
  40. func main() {
  41. cfg, err := config.LoadCommon("telegramd")
  42. if err != nil {
  43. os.Stderr.WriteString("config: " + err.Error() + "\n")
  44. os.Exit(1)
  45. }
  46. logger := observability.Init(cfg.Env, cfg.LogLevel, "telegramd")
  47. logger.Info("starting", "env", cfg.Env, "addr", cfg.HTTPAddr)
  48. ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
  49. defer stop()
  50. br, err := broker.Connect(ctx, cfg.NATSURL)
  51. if err != nil {
  52. logger.Error("nats connect", "err", err)
  53. os.Exit(1)
  54. }
  55. defer br.Close()
  56. _ = br // M3: long-polling only; NATS used later for ack fan-out
  57. pool, err := postgres.Connect(ctx, cfg.PostgresDSN)
  58. if err != nil {
  59. logger.Error("postgres connect", "err", err)
  60. os.Exit(1)
  61. }
  62. defer pool.Close()
  63. baseURL := os.Getenv("BA_TELEGRAM_FAKE_URL")
  64. if baseURL == "" {
  65. baseURL = os.Getenv("BA_TELEGRAM_BASE_URL")
  66. }
  67. if baseURL == "" {
  68. baseURL = "http://faketgmd:8830" // M3 docker-compose default
  69. }
  70. logger.Info("telegram target", "base_url", baseURL)
  71. client := &telegram.HTTPBotClient{
  72. BaseURL: baseURL,
  73. HTTP: &http.Client{Timeout: 60 * time.Second},
  74. }
  75. handler := telegram.NewHandler(pool, logger.With("subsystem", "telegram"))
  76. runCtx, runCancel := context.WithCancel(ctx)
  77. defer runCancel()
  78. go pollAllBots(runCtx, logger, pool, client, handler)
  79. reg, _ := observability.NewRegistry("telegramd")
  80. srv := httpserver.New(httpserver.Config{
  81. Addr: cfg.HTTPAddr,
  82. ServiceName: "telegramd",
  83. ShutdownGrace: cfg.ShutdownGrace,
  84. }, logger, observability.MetricsHandler(reg))
  85. errCh := make(chan error, 1)
  86. go func() { errCh <- srv.Start() }()
  87. select {
  88. case <-ctx.Done():
  89. logger.Info("shutdown signal received")
  90. case err := <-errCh:
  91. if err != nil {
  92. logger.Error("http server", "err", err)
  93. os.Exit(1)
  94. }
  95. }
  96. runCancel()
  97. time.Sleep(500 * time.Millisecond)
  98. if err := srv.Shutdown(ctx); err != nil {
  99. logger.Warn("graceful shutdown", "err", err)
  100. }
  101. logger.Info("bye")
  102. }
  103. // pollAllBots loads the bot list once at startup, then
  104. // long-polls each in a goroutine. M3: a single reload on
  105. // SIGUSR1 is overkill; restarting the binary picks up new
  106. // bots. M5 can add a watch on the table.
  107. func pollAllBots(ctx context.Context, logger *slog.Logger, pool *postgres.Pool, client telegram.BotClient, h *telegram.Handler) {
  108. bots, err := loadBots(ctx, pool)
  109. if err != nil {
  110. logger.Error("load bots", "err", err)
  111. return
  112. }
  113. logger.Info("loaded bots", "count", len(bots))
  114. if len(bots) == 0 {
  115. // No bots yet. Block on ctx so the process stays up.
  116. <-ctx.Done()
  117. return
  118. }
  119. for _, b := range bots {
  120. go pollOneBot(ctx, logger.With("bot", b.BotID, "company", b.CompanyID), client, h, b)
  121. }
  122. <-ctx.Done()
  123. }
  124. func loadBots(ctx context.Context, pool *postgres.Pool) ([]botConfig, error) {
  125. rows, err := pool.Query(ctx, `
  126. SELECT bot_id, company_id, bot_token
  127. FROM telegram_bots
  128. WHERE status = 'active'
  129. ORDER BY company_id, bot_id
  130. `)
  131. if err != nil {
  132. return nil, err
  133. }
  134. defer rows.Close()
  135. var out []botConfig
  136. for rows.Next() {
  137. var b botConfig
  138. if err := rows.Scan(&b.BotID, &b.CompanyID, &b.BotToken); err != nil {
  139. return nil, err
  140. }
  141. out = append(out, b)
  142. }
  143. return out, rows.Err()
  144. }
  145. func pollOneBot(ctx context.Context, logger *slog.Logger, client telegram.BotClient, h *telegram.Handler, b botConfig) {
  146. var offset int64
  147. for {
  148. if ctx.Err() != nil {
  149. return
  150. }
  151. updates, err := client.GetUpdates(ctx, b.BotToken, offset, 25)
  152. if err != nil {
  153. if ctx.Err() != nil {
  154. return
  155. }
  156. logger.Warn("getUpdates", "err", err)
  157. time.Sleep(2 * time.Second)
  158. continue
  159. }
  160. for _, u := range updates {
  161. if u.UpdateID >= offset {
  162. offset = u.UpdateID + 1
  163. }
  164. if u.Message == nil {
  165. continue
  166. }
  167. reply, err := h.Handle(ctx, u.Message)
  168. if err != nil {
  169. logger.Warn("command handle", "err", err, "update_id", u.UpdateID)
  170. continue
  171. }
  172. if reply == "" {
  173. continue
  174. }
  175. if _, err := client.SendMessage(ctx, b.BotToken, u.Message.Chat.ID, reply); err != nil {
  176. logger.Warn("sendMessage reply", "err", err, "chat_id", u.Message.Chat.ID)
  177. }
  178. }
  179. }
  180. }