main.go 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315
  1. // Command deliverd-telegram consumes deliveries.telegram.<company_id>
  2. // subjects and posts each alert to the Telegram Bot API
  3. // (sendMessage). It is the M3 counterpart of deliverd-fcm;
  4. // both consume from the same alerts flow but are deployed,
  5. // scaled, and restarted independently.
  6. //
  7. // Per the user's M3 Q2 answer: two binaries, not one with a
  8. // per-channel registry. Pros: independent deploys, no
  9. // cross-channel blast radius, simpler per-channel config.
  10. // Cons: more containers to operate. For v1 with a small
  11. // channel set (fcm, telegram) this is the right call.
  12. //
  13. // Configuration:
  14. // BA_TELEGRAM_BOT_TOKEN — default bot token if envelope
  15. // doesn't carry a per-company one
  16. // BA_TELEGRAM_BASE_URL — override for tests (default
  17. // https://api.telegram.org).
  18. // M3 dev: point at faketgmd.
  19. // BA_TELEGRAM_FAKE_URL — alias kept for the smoke
  20. // script; if set, takes priority.
  21. //
  22. // M3 ships single-attempt sends (no retry, no DLQ). That
  23. // follows the same pattern as deliverd-fcm in M1. M9 adds
  24. // the retry + DLQ chain.
  25. package main
  26. import (
  27. "bytes"
  28. "context"
  29. "encoding/json"
  30. "fmt"
  31. "io"
  32. "log/slog"
  33. "net/http"
  34. "os"
  35. "os/signal"
  36. "strings"
  37. "syscall"
  38. "time"
  39. "git3.techno-world.net/lrosales/broad-announce/internal/alert"
  40. "git3.techno-world.net/lrosales/broad-announce/internal/broker"
  41. "git3.techno-world.net/lrosales/broad-announce/internal/config"
  42. "git3.techno-world.net/lrosales/broad-announce/internal/httpserver"
  43. "git3.techno-world.net/lrosales/broad-announce/internal/observability"
  44. "git3.techno-world.net/lrosales/broad-announce/internal/postgres"
  45. "git3.techno-world.net/lrosales/broad-announce/internal/telegram"
  46. "github.com/nats-io/nats.go/jetstream"
  47. )
  48. const telegramChannel = "telegram"
  49. type deliveryEnvelope struct {
  50. Alert json.RawMessage `json:"alert"`
  51. IndividualID string `json:"individual_id"`
  52. Channel string `json:"channel"`
  53. Endpoint string `json:"endpoint"`
  54. Locale string `json:"locale,omitempty"`
  55. }
  56. func main() {
  57. cfg, err := config.LoadCommon("deliverd-telegram")
  58. if err != nil {
  59. os.Stderr.WriteString("config: " + err.Error() + "\n")
  60. os.Exit(1)
  61. }
  62. logger := observability.Init(cfg.Env, cfg.LogLevel, "deliverd-telegram")
  63. logger.Info("starting", "env", cfg.Env, "addr", cfg.HTTPAddr)
  64. ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
  65. defer stop()
  66. br, err := broker.Connect(ctx, cfg.NATSURL)
  67. if err != nil {
  68. logger.Error("nats connect", "err", err)
  69. os.Exit(1)
  70. }
  71. defer br.Close()
  72. pool, err := postgres.Connect(ctx, cfg.PostgresDSN)
  73. if err != nil {
  74. logger.Error("postgres connect", "err", err)
  75. os.Exit(1)
  76. }
  77. defer pool.Close()
  78. // Bot token resolution. M3: read from env. M3+:
  79. // look up the per-company bot via the company_id encoded
  80. // in the alert or the subject. For now the env-supplied
  81. // default works because the M3 seed has one bot per
  82. // company.
  83. botToken := os.Getenv("BA_TELEGRAM_BOT_TOKEN")
  84. if botToken == "" {
  85. botToken = "fake-tg-bot-token-acme-001" // dev convenience
  86. }
  87. baseURL := os.Getenv("BA_TELEGRAM_FAKE_URL")
  88. if baseURL == "" {
  89. baseURL = os.Getenv("BA_TELEGRAM_BASE_URL")
  90. }
  91. if baseURL == "" {
  92. baseURL = "http://faketgmd:8830" // M3 docker-compose default
  93. }
  94. logger.Info("telegram target", "base_url", baseURL)
  95. client := &telegram.HTTPBotClient{
  96. BaseURL: baseURL,
  97. HTTP: &http.Client{Timeout: 15 * time.Second},
  98. }
  99. js := br.JS()
  100. stream, err := js.Stream(ctx, "DELIVERIES")
  101. if err != nil {
  102. logger.Error("nats stream DELIVERIES", "err", err)
  103. os.Exit(1)
  104. }
  105. consumer, err := stream.CreateOrUpdateConsumer(ctx, jetstream.ConsumerConfig{
  106. Name: "deliverd-telegram",
  107. Durable: "deliverd-telegram",
  108. FilterSubjects: []string{"deliveries.telegram.>"},
  109. AckPolicy: jetstream.AckExplicitPolicy,
  110. })
  111. if err != nil {
  112. logger.Error("nats consumer", "err", err)
  113. os.Exit(1)
  114. }
  115. runCtx, runCancel := context.WithCancel(ctx)
  116. defer runCancel()
  117. go consume(runCtx, logger, consumer, pool, client, botToken)
  118. reg, _ := observability.NewRegistry("deliverd-telegram")
  119. srv := httpserver.New(httpserver.Config{
  120. Addr: cfg.HTTPAddr,
  121. ServiceName: "deliverd-telegram",
  122. ShutdownGrace: cfg.ShutdownGrace,
  123. }, logger, observability.MetricsHandler(reg))
  124. errCh := make(chan error, 1)
  125. go func() { errCh <- srv.Start() }()
  126. select {
  127. case <-ctx.Done():
  128. logger.Info("shutdown signal received")
  129. case err := <-errCh:
  130. if err != nil {
  131. logger.Error("http server", "err", err)
  132. os.Exit(1)
  133. }
  134. }
  135. runCancel()
  136. time.Sleep(500 * time.Millisecond)
  137. if err := srv.Shutdown(ctx); err != nil {
  138. logger.Warn("graceful shutdown", "err", err)
  139. }
  140. logger.Info("bye")
  141. }
  142. func consume(ctx context.Context, logger *slog.Logger, c jetstream.Consumer, pool *postgres.Pool, client telegram.BotClient, botToken string) {
  143. for {
  144. if ctx.Err() != nil {
  145. return
  146. }
  147. batch, err := c.Fetch(16, jetstream.FetchMaxWait(2*time.Second))
  148. if err != nil {
  149. if ctx.Err() != nil {
  150. return
  151. }
  152. logger.Warn("nats fetch", "err", err)
  153. time.Sleep(500 * time.Millisecond)
  154. continue
  155. }
  156. for m := range batch.Messages() {
  157. handleOne(ctx, logger, m, pool, client, botToken)
  158. if batch.Error() != nil {
  159. logger.Warn("batch error", "err", batch.Error())
  160. break
  161. }
  162. }
  163. }
  164. }
  165. func handleOne(ctx context.Context, logger *slog.Logger, m jetstream.Msg, pool *postgres.Pool, client telegram.BotClient, botToken string) {
  166. var env deliveryEnvelope
  167. if err := json.Unmarshal(m.Data(), &env); err != nil {
  168. logger.Warn("malformed delivery envelope", "err", err, "subject", m.Subject())
  169. _ = m.Ack()
  170. return
  171. }
  172. // Pull the alert fields we need.
  173. var ah struct {
  174. ID string `json:"id"`
  175. CompanyID string `json:"company_id"`
  176. Title string `json:"title"`
  177. Body string `json:"body"`
  178. Severity alert.Severity `json:"severity"`
  179. Category string `json:"category"`
  180. SourceID string `json:"source_id"`
  181. // M6: dedupe_count. 0 or 1 means "not a duplicate",
  182. // ≥2 means the dedupe window has seen this alert N
  183. // times; we surface that to the recipient via the
  184. // inline `(×N)` suffix on the title.
  185. DedupeCount uint32 `json:"dedupe_count"`
  186. }
  187. _ = json.Unmarshal(env.Alert, &ah)
  188. companyID := ah.CompanyID
  189. if companyID == "" {
  190. // subject is "deliveries.telegram.<company_id>"
  191. parts := strings.SplitN(m.Subject(), ".", 3)
  192. if len(parts) == 3 {
  193. companyID = parts[2]
  194. }
  195. }
  196. if companyID == "" || env.Endpoint == "" || ah.ID == "" {
  197. logger.Warn("delivery envelope missing fields", "subject", m.Subject(), "company", companyID, "endpoint_present", env.Endpoint != "", "alert_id", ah.ID)
  198. _ = m.Ack()
  199. return
  200. }
  201. chatID, err := parseInt64(env.Endpoint)
  202. if err != nil {
  203. logger.Warn("endpoint is not a telegram chat id", "endpoint", env.Endpoint)
  204. _ = m.Ack()
  205. return
  206. }
  207. // Build the text. SPEC §8 says the Android app does
  208. // localization; for Telegram we use the source's
  209. // pre-localized title + body verbatim, with a severity
  210. // prefix so a glance at the chat shows priority.
  211. text := formatMessage(ah.Severity, ah.Title, ah.Body, ah.ID, ah.DedupeCount)
  212. status := "failed"
  213. lastErr := ""
  214. if _, err := client.SendMessage(ctx, botToken, chatID, text); err != nil {
  215. lastErr = err.Error()
  216. logger.Warn("sendMessage failed", "err", err, "chat_id", chatID, "alert_id", ah.ID)
  217. } else {
  218. status = "sent"
  219. }
  220. _, dbErr := pool.Exec(ctx, `
  221. INSERT INTO deliveries
  222. (alert_id, company_id, individual_id, channel, target, status, attempts, last_error, payload, sent_at)
  223. VALUES ($1, $2, $3, $4, $5, $6, 1, NULLIF($7, ''), $8,
  224. CASE WHEN $6 = 'sent' THEN now() ELSE NULL END)
  225. `, ah.ID, companyID, env.IndividualID, telegramChannel, env.Endpoint, status, lastErr, json.RawMessage(m.Data()))
  226. if dbErr != nil {
  227. logger.Warn("delivery row insert", "err", dbErr)
  228. }
  229. logger.Info("delivery",
  230. "alert_id", ah.ID,
  231. "company", companyID,
  232. "individual", env.IndividualID,
  233. "channel", telegramChannel,
  234. "status", status,
  235. "err", lastErr,
  236. )
  237. _ = m.Ack()
  238. }
  239. // formatMessage produces a Telegram-friendly rendering of
  240. // the alert. The format is intentionally plain (Telegram
  241. // supports Markdown/HTML but they vary across clients);
  242. // M3.5+ can add formatting once the Android-side payload
  243. // shape is locked.
  244. //
  245. // M6: when DedupeCount > 1, we append ` (×N)` to the title
  246. // so a glance at the chat shows how many times the source
  247. // has fired this alert inside the sliding dedupe window.
  248. // For DedupeCount == 0 (not set) or == 1 (first arrival),
  249. // no suffix is added.
  250. func formatMessage(sev alert.Severity, title, body, alertID string, dedupeCount uint32) string {
  251. var prefix string
  252. switch sev {
  253. case alert.SeverityInminentColapse:
  254. prefix = "🟥🟥🟥 IMMINENT"
  255. case alert.SeverityCritical:
  256. prefix = "🟥 CRITICAL"
  257. case alert.SeverityWarning:
  258. prefix = "🟧 WARNING"
  259. default:
  260. prefix = "🟦 INFO"
  261. }
  262. // M6: inline `(×N)` suffix on the title when this alert
  263. // was suppressed N-1 times by the dedupe gate. We render
  264. // the count as a small visual hint that doesn't
  265. // interfere with the severity prefix or the body.
  266. displayedTitle := title
  267. if dedupeCount > 1 {
  268. displayedTitle = fmt.Sprintf("%s (×%d)", title, dedupeCount)
  269. }
  270. out := fmt.Sprintf("%s: %s", prefix, displayedTitle)
  271. if body != "" {
  272. out += "\n" + body
  273. }
  274. short := alertID
  275. if len(short) > 12 {
  276. short = short[:12]
  277. }
  278. out += fmt.Sprintf("\n— alert %s", short)
  279. return out
  280. }
  281. func parseInt64(s string) (int64, error) {
  282. var n int64
  283. _, err := fmt.Sscanf(s, "%d", &n)
  284. return n, err
  285. }
  286. var _ = bytes.NewReader
  287. var _ = io.ReadAll