main.go 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468
  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.
  24. // M8 adds the retry + DLQ chain (SPEC §9).
  25. package main
  26. import (
  27. "context"
  28. "encoding/json"
  29. "fmt"
  30. "log/slog"
  31. "net/http"
  32. "os"
  33. "os/signal"
  34. "strings"
  35. "syscall"
  36. "time"
  37. "git3.techno-world.net/lrosales/broad-announce/internal/alert"
  38. "git3.techno-world.net/lrosales/broad-announce/internal/broker"
  39. "git3.techno-world.net/lrosales/broad-announce/internal/config"
  40. "git3.techno-world.net/lrosales/broad-announce/internal/dlq"
  41. "git3.techno-world.net/lrosales/broad-announce/internal/httpserver"
  42. "git3.techno-world.net/lrosales/broad-announce/internal/observability"
  43. "git3.techno-world.net/lrosales/broad-announce/internal/postgres"
  44. "git3.techno-world.net/lrosales/broad-announce/internal/retry"
  45. "git3.techno-world.net/lrosales/broad-announce/internal/telegram"
  46. "github.com/nats-io/nats.go/jetstream"
  47. )
  48. const telegramChannel = "telegram"
  49. // deliverdMetrics is the package-level metrics instance.
  50. // Wired in main() from observability.NewDeliverdMetrics.
  51. var deliverdMetrics *observability.DeliverdMetrics
  52. type deliveryEnvelope struct {
  53. Alert json.RawMessage `json:"alert"`
  54. IndividualID string `json:"individual_id"`
  55. Channel string `json:"channel"`
  56. Endpoint string `json:"endpoint"`
  57. Locale string `json:"locale,omitempty"`
  58. }
  59. func main() {
  60. cfg, err := config.LoadCommon("deliverd-telegram")
  61. if err != nil {
  62. os.Stderr.WriteString("config: " + err.Error() + "\n")
  63. os.Exit(1)
  64. }
  65. logger := observability.Init(cfg.Env, cfg.LogLevel, "deliverd-telegram")
  66. logger.Info("starting",
  67. "env", cfg.Env,
  68. "addr", cfg.HTTPAddr,
  69. "max_attempts", cfg.DeliverdMaxAttempts,
  70. "retry_base_ms", cfg.DeliverdRetryBaseMs,
  71. "retry_max_ms", cfg.DeliverdRetryMaxMs,
  72. "retry_budget_ms", cfg.DeliverdRetryBudgetMs,
  73. )
  74. ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
  75. defer stop()
  76. br, err := broker.Connect(ctx, cfg.NATSURL)
  77. if err != nil {
  78. logger.Error("nats connect", "err", err)
  79. os.Exit(1)
  80. }
  81. defer br.Close()
  82. pool, err := postgres.Connect(ctx, cfg.PostgresDSN)
  83. if err != nil {
  84. logger.Error("postgres connect", "err", err)
  85. os.Exit(1)
  86. }
  87. defer pool.Close()
  88. // Bot token resolution. M3: read from env. M3+:
  89. // look up the per-company bot via the company_id encoded
  90. // in the alert or the subject. For now the env-supplied
  91. // default works because the M3 seed has one bot per
  92. // company.
  93. botToken := os.Getenv("BA_TELEGRAM_BOT_TOKEN")
  94. if botToken == "" {
  95. botToken = "fake-tg-bot-token-acme-001" // dev convenience
  96. }
  97. baseURL := os.Getenv("BA_TELEGRAM_FAKE_URL")
  98. if baseURL == "" {
  99. baseURL = os.Getenv("BA_TELEGRAM_BASE_URL")
  100. }
  101. if baseURL == "" {
  102. baseURL = "http://faketgmd:8830" // M3 docker-compose default
  103. }
  104. logger.Info("telegram target", "base_url", baseURL)
  105. client := &telegram.HTTPBotClient{
  106. BaseURL: baseURL,
  107. HTTP: &http.Client{Timeout: 15 * time.Second},
  108. }
  109. // M8: same retry config as deliverd-fcm. See
  110. // internal/retry for the policy.
  111. retryCfg := retry.Config{
  112. MaxAttempts: cfg.DeliverdMaxAttempts,
  113. BaseDelay: time.Duration(cfg.DeliverdRetryBaseMs) * time.Millisecond,
  114. MaxDelay: time.Duration(cfg.DeliverdRetryMaxMs) * time.Millisecond,
  115. Budget: time.Duration(cfg.DeliverdRetryBudgetMs) * time.Millisecond,
  116. }
  117. js := br.JS()
  118. stream, err := js.Stream(ctx, "DELIVERIES")
  119. if err != nil {
  120. logger.Error("nats stream DELIVERIES", "err", err)
  121. os.Exit(1)
  122. }
  123. consumer, err := stream.CreateOrUpdateConsumer(ctx, jetstream.ConsumerConfig{
  124. Name: "deliverd-telegram",
  125. Durable: "deliverd-telegram",
  126. FilterSubjects: []string{"deliveries.telegram.>"},
  127. AckPolicy: jetstream.AckExplicitPolicy,
  128. })
  129. if err != nil {
  130. logger.Error("nats consumer", "err", err)
  131. os.Exit(1)
  132. }
  133. runCtx, runCancel := context.WithCancel(ctx)
  134. defer runCancel()
  135. // M9: deliverd metrics. Separate registry from deliverd-fcm
  136. // so each gets its own service label in Prometheus.
  137. reg, _ := observability.NewRegistry("deliverd-telegram")
  138. deliverdMetrics = observability.NewDeliverdMetrics(reg, "deliverd-telegram")
  139. go consume(runCtx, logger, consumer, pool, client, botToken, retryCfg)
  140. srv := httpserver.New(httpserver.Config{
  141. Addr: cfg.HTTPAddr,
  142. ServiceName: "deliverd-telegram",
  143. ShutdownGrace: cfg.ShutdownGrace,
  144. }, logger, observability.MetricsHandler(reg))
  145. // M13a W5: admin routes (JWT-gated). When BA_AUTHD_JWT_SECRET
  146. // is unset, wireAdminRoutes is a no-op so the LAN deploy
  147. // path keeps working unchanged.
  148. wireAdminRoutes(srv.Mux(), pool, logger)
  149. errCh := make(chan error, 1)
  150. go func() { errCh <- srv.Start() }()
  151. select {
  152. case <-ctx.Done():
  153. logger.Info("shutdown signal received")
  154. case err := <-errCh:
  155. if err != nil {
  156. logger.Error("http server", "err", err)
  157. os.Exit(1)
  158. }
  159. }
  160. runCancel()
  161. time.Sleep(500 * time.Millisecond)
  162. if err := srv.Shutdown(ctx); err != nil {
  163. logger.Warn("graceful shutdown", "err", err)
  164. }
  165. logger.Info("bye")
  166. }
  167. func consume(ctx context.Context, logger *slog.Logger, c jetstream.Consumer, pool *postgres.Pool, client telegram.BotClient, botToken string, retryCfg retry.Config) {
  168. for {
  169. if ctx.Err() != nil {
  170. return
  171. }
  172. batch, err := c.Fetch(16, jetstream.FetchMaxWait(2*time.Second))
  173. if err != nil {
  174. if ctx.Err() != nil {
  175. return
  176. }
  177. logger.Warn("nats fetch", "err", err)
  178. time.Sleep(500 * time.Millisecond)
  179. continue
  180. }
  181. for m := range batch.Messages() {
  182. handleOne(ctx, logger, m, pool, client, botToken, retryCfg)
  183. if batch.Error() != nil {
  184. logger.Warn("batch error", "err", batch.Error())
  185. break
  186. }
  187. }
  188. }
  189. }
  190. // handleOne mirrors deliverd-fcm's structure:
  191. // 1. Parse the envelope (drop poison messages).
  192. // 2. Retry loop: each attempt calls SendMessage and
  193. // writes a `deliveries` audit row. On success,
  194. // break out. On exhaustion, write a `deliveries_dlq`
  195. // row + a final 'dlq' audit row.
  196. // 3. Ack the NATS message either way.
  197. func handleOne(ctx context.Context, logger *slog.Logger, m jetstream.Msg, pool *postgres.Pool, client telegram.BotClient, botToken string, retryCfg retry.Config) {
  198. var env deliveryEnvelope
  199. if err := json.Unmarshal(m.Data(), &env); err != nil {
  200. logger.Warn("malformed delivery envelope", "err", err, "subject", m.Subject())
  201. _ = m.Ack()
  202. return
  203. }
  204. var ah struct {
  205. ID string `json:"id"`
  206. CompanyID string `json:"company_id"`
  207. Title string `json:"title"`
  208. Body string `json:"body"`
  209. Severity alert.Severity `json:"severity"`
  210. Category string `json:"category"`
  211. SourceID string `json:"source_id"`
  212. DedupeCount uint32 `json:"dedupe_count"`
  213. }
  214. _ = json.Unmarshal(env.Alert, &ah)
  215. companyID := ah.CompanyID
  216. if companyID == "" {
  217. parts := strings.SplitN(m.Subject(), ".", 3)
  218. if len(parts) == 3 {
  219. companyID = parts[2]
  220. }
  221. }
  222. if companyID == "" || env.Endpoint == "" || ah.ID == "" {
  223. logger.Warn("delivery envelope missing fields", "subject", m.Subject(), "company", companyID, "endpoint_present", env.Endpoint != "", "alert_id", ah.ID)
  224. _ = m.Ack()
  225. return
  226. }
  227. chatID, err := parseInt64(env.Endpoint)
  228. if err != nil {
  229. logger.Warn("endpoint is not a telegram chat id", "endpoint", env.Endpoint)
  230. _ = m.Ack()
  231. return
  232. }
  233. // Build the text once, retry the same text. Format
  234. // unchanged from M3.
  235. text := formatMessage(ah.Severity, ah.Title, ah.Body, ah.ID, ah.DedupeCount)
  236. // M9: track attempt start time for DLQ latency metric.
  237. firstAttemptTime := time.Now()
  238. // M8: retry loop. Telegram returns errors on:
  239. // - 4xx (chat not found, bot blocked, etc.) — these
  240. // are permanent; we use PermanentError to skip
  241. // further retries.
  242. // - 5xx, network timeouts — these are transient;
  243. // retry with backoff.
  244. // - 429 (rate limited) — transient; retry.
  245. var lastErr error
  246. var sent bool
  247. res := retry.Run(ctx, retryCfg, func(attemptCtx context.Context, attempt int) error {
  248. attemptCtx, cancel := context.WithTimeout(attemptCtx, 15*time.Second)
  249. defer cancel()
  250. _, sendErr := client.SendMessage(attemptCtx, botToken, chatID, text)
  251. status := "failed"
  252. lastErrStr := ""
  253. if sendErr == nil {
  254. status = "sent"
  255. sent = true
  256. } else {
  257. lastErrStr = sendErr.Error()
  258. }
  259. _, dbErr := pool.Exec(attemptCtx, `
  260. INSERT INTO deliveries
  261. (alert_id, company_id, individual_id, channel, target, status, attempts, last_error, payload, sent_at)
  262. VALUES ($1, $2, $3, $4, $5, $6, $7, NULLIF($8, ''), $9,
  263. CASE WHEN $6 = 'sent' THEN now() ELSE NULL END)
  264. `, ah.ID, companyID, env.IndividualID, telegramChannel, env.Endpoint, status, attempt, lastErrStr, json.RawMessage(m.Data()))
  265. if dbErr != nil {
  266. logger.Warn("delivery row insert", "err", dbErr, "attempt", attempt)
  267. }
  268. // M9: record per-channel delivery attempt metric.
  269. if deliverdMetrics != nil {
  270. deliverdMetrics.DeliveryAttempts.WithLabelValues(telegramChannel, status).Inc()
  271. }
  272. if status == "sent" {
  273. logger.Info("delivery sent",
  274. "alert_id", ah.ID,
  275. "company", companyID,
  276. "individual", env.IndividualID,
  277. "channel", telegramChannel,
  278. "attempt", attempt,
  279. )
  280. return nil
  281. }
  282. // Permanent-error detection. The Telegram Bot API
  283. // returns errors of the form "telegram api <code>:
  284. // <description>". 4xx codes (400, 401, 403, 404,
  285. // 409) are not retryable; 429 (Too Many Requests) is
  286. // transient and stays in the retry path. The
  287. // faketgmd test double mirrors this convention.
  288. if isTelegramPermanent(lastErrStr) {
  289. logger.Warn("permanent telegram error",
  290. "alert_id", ah.ID,
  291. "err", lastErrStr,
  292. "attempt", attempt,
  293. )
  294. lastErr = &retry.PermanentError{Err: fmt.Errorf("telegram permanent %s", lastErrStr)}
  295. return lastErr
  296. }
  297. logger.Warn("delivery attempt failed",
  298. "alert_id", ah.ID,
  299. "company", companyID,
  300. "channel", telegramChannel,
  301. "attempt", attempt,
  302. "err", lastErrStr,
  303. )
  304. lastErr = fmt.Errorf("%s", lastErrStr)
  305. return lastErr
  306. })
  307. if sent {
  308. _ = m.Ack()
  309. return
  310. }
  311. // All attempts exhausted (or permanent error). DLQ
  312. // insert for the operator UI; replay re-INSERTs the
  313. // original payload into the same NATS subject.
  314. dlqID, dlqErr := dlq.Write(ctx, pool, dlq.Entry{
  315. AlertID: ah.ID,
  316. CompanyID: companyID,
  317. IndividualID: env.IndividualID,
  318. Channel: telegramChannel,
  319. Target: env.Endpoint,
  320. OriginalSubject: m.Subject(),
  321. Attempts: res.Attempts,
  322. LastError: errString(lastErr),
  323. Payload: json.RawMessage(m.Data()),
  324. })
  325. if dlqErr != nil {
  326. logger.Error("dlq insert FAILED",
  327. "err", dlqErr,
  328. "alert_id", ah.ID,
  329. "company", companyID,
  330. )
  331. } else {
  332. logger.Warn("delivery → DLQ",
  333. "dlq_id", dlqID,
  334. "alert_id", ah.ID,
  335. "company", companyID,
  336. "channel", telegramChannel,
  337. "attempts", res.Attempts,
  338. "err", errString(lastErr),
  339. )
  340. // M9: record DLQ metric and latency.
  341. if deliverdMetrics != nil {
  342. deliverdMetrics.DLQTotal.WithLabelValues(telegramChannel).Inc()
  343. deliverdMetrics.DLQLatency.Observe(time.Since(firstAttemptTime).Seconds())
  344. deliverdMetrics.RetryAttempts.WithLabelValues(telegramChannel).Add(float64(res.Attempts))
  345. }
  346. }
  347. _ = m.Ack()
  348. }
  349. // isTelegramPermanent classifies a Telegram Bot API
  350. // error string as permanent (no point retrying). The
  351. // faketgmd test double prefixes errors with "telegram
  352. // api <code>:" so we can match on the code.
  353. //
  354. // 429 (rate limited) is intentionally NOT permanent —
  355. // the retry helper will space it out via backoff.
  356. func isTelegramPermanent(errStr string) bool {
  357. if errStr == "" {
  358. return false
  359. }
  360. // Look for "telegram api NNN" or "status NNN" in the
  361. // error string. 4xx (except 429) is permanent.
  362. for _, prefix := range []string{"telegram api ", "status "} {
  363. i := strings.Index(errStr, prefix)
  364. if i < 0 {
  365. continue
  366. }
  367. rest := errStr[i+len(prefix):]
  368. // Parse a 3-digit code.
  369. if len(rest) < 3 {
  370. continue
  371. }
  372. codeStr := rest[:3]
  373. var code int
  374. if _, err := fmt.Sscanf(codeStr, "%d", &code); err != nil {
  375. continue
  376. }
  377. if code >= 400 && code < 500 && code != 429 && code != 408 {
  378. return true
  379. }
  380. }
  381. return false
  382. }
  383. // formatMessage produces a Telegram-friendly rendering of
  384. // the alert. The format is intentionally plain (Telegram
  385. // supports Markdown/HTML but they vary across clients);
  386. // M3.5+ can add formatting once the Android-side payload
  387. // shape is locked.
  388. //
  389. // M6: when DedupeCount > 1, we append ` (×N)` to the title
  390. // so a glance at the chat shows how many times the source
  391. // has fired this alert inside the sliding dedupe window.
  392. // For DedupeCount == 0 (not set) or == 1 (first arrival),
  393. // no suffix is added.
  394. func formatMessage(sev alert.Severity, title, body, alertID string, dedupeCount uint32) string {
  395. var prefix string
  396. switch sev {
  397. case alert.SeverityInminentColapse:
  398. prefix = "🟥🟥🟥 IMMINENT"
  399. case alert.SeverityCritical:
  400. prefix = "🟥 CRITICAL"
  401. case alert.SeverityWarning:
  402. prefix = "🟧 WARNING"
  403. default:
  404. prefix = "🟦 INFO"
  405. }
  406. displayedTitle := title
  407. if dedupeCount > 1 {
  408. displayedTitle = fmt.Sprintf("%s (×%d)", title, dedupeCount)
  409. }
  410. out := fmt.Sprintf("%s: %s", prefix, displayedTitle)
  411. if body != "" {
  412. out += "\n" + body
  413. }
  414. short := alertID
  415. if len(short) > 12 {
  416. short = short[:12]
  417. }
  418. out += fmt.Sprintf("\n— alert %s", short)
  419. return out
  420. }
  421. func parseInt64(s string) (int64, error) {
  422. var n int64
  423. _, err := fmt.Sscanf(s, "%d", &n)
  424. return n, err
  425. }
  426. func errString(err error) string {
  427. if err == nil {
  428. return ""
  429. }
  430. return err.Error()
  431. }