main.go 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326
  1. // Command deliverd-fcm consumes deliveries.fcm.<company_id> subjects
  2. // and posts each alert to the FCM HTTP v1 endpoint. M3 splits
  3. // this out of the original `cmd/deliverd` (which is now two
  4. // per-channel binaries: deliverd-fcm and deliverd-telegram).
  5. // That gives us the option to scale, restart, and deploy each
  6. // channel independently — your Q2 answer.
  7. //
  8. // M0: per-channel worker binary that connects to NATS, /health, /metrics.
  9. // M1: deliverd-fcm — consumes deliveries.fcm.*, posts to fakefcmd,
  10. // records a row in Postgres per attempt.
  11. // M3: rename only. No behavior change. The HTTP target URL is
  12. // BA_FAKECMD_URL (dev) or BA_FCM_BASE_URL (prod); M11+ will
  13. // add real FCM auth.
  14. package main
  15. import (
  16. "bytes"
  17. "context"
  18. "encoding/json"
  19. "fmt"
  20. "io"
  21. "log/slog"
  22. "net/http"
  23. "os"
  24. "os/signal"
  25. "strings"
  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. "github.com/nats-io/nats.go/jetstream"
  34. )
  35. // M2: only the FCM channel. M3+ adds telegram, sms, etc.
  36. const fcmChannel = "fcm"
  37. type deliveryEnvelope struct {
  38. Alert json.RawMessage `json:"alert"`
  39. IndividualID string `json:"individual_id"`
  40. Channel string `json:"channel"`
  41. Endpoint string `json:"endpoint"`
  42. Locale string `json:"locale,omitempty"`
  43. }
  44. func main() {
  45. cfg, err := config.LoadCommon("deliverd")
  46. if err != nil {
  47. os.Stderr.WriteString("config: " + err.Error() + "\n")
  48. os.Exit(1)
  49. }
  50. logger := observability.Init(cfg.Env, cfg.LogLevel, "deliverd")
  51. logger.Info("starting", "env", cfg.Env, "addr", cfg.HTTPAddr)
  52. ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
  53. defer stop()
  54. br, err := broker.Connect(ctx, cfg.NATSURL)
  55. if err != nil {
  56. logger.Error("nats connect", "err", err)
  57. os.Exit(1)
  58. }
  59. defer br.Close()
  60. pool, err := postgres.Connect(ctx, cfg.PostgresDSN)
  61. if err != nil {
  62. logger.Error("postgres connect", "err", err)
  63. os.Exit(1)
  64. }
  65. defer pool.Close()
  66. // M1 only: fakefcmd URL. M3+ uses the real FCM HTTP v1 endpoint.
  67. fakefcmdURL := os.Getenv("BA_FAKECMD_URL")
  68. if fakefcmdURL == "" {
  69. fakefcmdURL = "http://fakefcmd:8820"
  70. }
  71. logger.Info("fcm target", "url", fakefcmdURL)
  72. httpClient := &http.Client{Timeout: 10 * time.Second}
  73. js := br.JS()
  74. stream, err := js.Stream(ctx, "DELIVERIES")
  75. if err != nil {
  76. logger.Error("nats stream DELIVERIES", "err", err)
  77. os.Exit(1)
  78. }
  79. consumer, err := stream.CreateOrUpdateConsumer(ctx, jetstream.ConsumerConfig{
  80. Name: "deliverd-fcm",
  81. Durable: "deliverd-fcm",
  82. FilterSubjects: []string{"deliveries.fcm.>"},
  83. AckPolicy: jetstream.AckExplicitPolicy,
  84. })
  85. if err != nil {
  86. logger.Error("nats consumer", "err", err)
  87. os.Exit(1)
  88. }
  89. runCtx, runCancel := context.WithCancel(ctx)
  90. defer runCancel()
  91. go consume(runCtx, logger, consumer, pool, httpClient, fakefcmdURL)
  92. reg, _ := observability.NewRegistry("deliverd")
  93. srv := httpserver.New(httpserver.Config{
  94. Addr: cfg.HTTPAddr,
  95. ServiceName: "deliverd",
  96. ShutdownGrace: cfg.ShutdownGrace,
  97. }, logger, observability.MetricsHandler(reg))
  98. errCh := make(chan error, 1)
  99. go func() { errCh <- srv.Start() }()
  100. select {
  101. case <-ctx.Done():
  102. logger.Info("shutdown signal received")
  103. case err := <-errCh:
  104. if err != nil {
  105. logger.Error("http server", "err", err)
  106. os.Exit(1)
  107. }
  108. }
  109. runCancel()
  110. time.Sleep(500 * time.Millisecond)
  111. if err := srv.Shutdown(ctx); err != nil {
  112. logger.Warn("graceful shutdown", "err", err)
  113. }
  114. logger.Info("bye")
  115. }
  116. func consume(ctx context.Context, logger *slog.Logger, c jetstream.Consumer, pool *postgres.Pool, httpClient *http.Client, fakefcmdURL string) {
  117. for {
  118. if ctx.Err() != nil {
  119. return
  120. }
  121. batch, err := c.Fetch(16, jetstream.FetchMaxWait(2*time.Second))
  122. if err != nil {
  123. if ctx.Err() != nil {
  124. return
  125. }
  126. logger.Warn("nats fetch", "err", err)
  127. time.Sleep(500 * time.Millisecond)
  128. continue
  129. }
  130. for m := range batch.Messages() {
  131. handleOne(ctx, logger, m, pool, httpClient, fakefcmdURL)
  132. if batch.Error() != nil {
  133. logger.Warn("batch error", "err", batch.Error())
  134. break
  135. }
  136. }
  137. }
  138. }
  139. func handleOne(ctx context.Context, logger *slog.Logger, m jetstream.Msg, pool *postgres.Pool, httpClient *http.Client, fakefcmdURL string) {
  140. var env deliveryEnvelope
  141. if err := json.Unmarshal(m.Data(), &env); err != nil {
  142. logger.Warn("malformed delivery envelope", "err", err, "subject", m.Subject())
  143. _ = m.Ack() // poison message; we don't have a DLQ for malformed yet
  144. return
  145. }
  146. // Parse out the alert_id and company_id from the inner alert JSON.
  147. var alertHeader struct {
  148. ID string `json:"id"`
  149. CompanyID string `json:"company_id"`
  150. Title string `json:"title"`
  151. Body string `json:"body"`
  152. Data map[string]string `json:"data"`
  153. Category string `json:"category"`
  154. Severity string `json:"severity"`
  155. // M6: dedupe_count. 0 or 1 means "first arrival",
  156. // ≥2 means this alert has been seen N times in the
  157. // sliding dedupe window. Native FCM clients (the
  158. // Android app) can display it or hide it via the
  159. // data map; the notification body is also suffixed
  160. // with ` (×N)` for clients that render the body
  161. // verbatim.
  162. DedupeCount uint32 `json:"dedupe_count"`
  163. }
  164. _ = json.Unmarshal(env.Alert, &alertHeader)
  165. companyID := alertHeader.CompanyID
  166. if companyID == "" {
  167. // subject is "deliveries.fcm.<company_id>"
  168. parts := strings.SplitN(m.Subject(), ".", 3)
  169. if len(parts) == 3 {
  170. companyID = parts[2]
  171. }
  172. }
  173. if companyID == "" || env.Endpoint == "" || alertHeader.ID == "" {
  174. logger.Warn("delivery envelope missing fields", "subject", m.Subject(), "company", companyID, "endpoint_present", env.Endpoint != "", "alert_id", alertHeader.ID)
  175. _ = m.Ack()
  176. return
  177. }
  178. // Build the FCM HTTP v1 message body. The shape matches what
  179. // real FCM expects, so the M3 swap is a no-op at this layer.
  180. //
  181. // M6: dedupe_count flows through three surfaces:
  182. // 1. notification.title is suffixed with ` (×N)` when N>1
  183. // 2. notification.body is the source's pre-localized body
  184. // verbatim; the title is where the count goes so the
  185. // body isn't double-formatted.
  186. // 3. data.dedupe_count is the raw count for clients that
  187. // want to render it themselves (e.g. an Android app
  188. // that shows "×5" in a corner badge).
  189. notificationTitle := alertHeader.Title
  190. if alertHeader.DedupeCount > 1 {
  191. notificationTitle = fmt.Sprintf("%s (×%d)", alertHeader.Title, alertHeader.DedupeCount)
  192. }
  193. fcmBody := map[string]any{
  194. "message": map[string]any{
  195. "token": env.Endpoint,
  196. "notification": map[string]any{
  197. "title": notificationTitle,
  198. "body": alertHeader.Body,
  199. },
  200. "data": mergeData(alertHeader.Data, map[string]string{
  201. "company_id": companyID,
  202. "alert_id": alertHeader.ID,
  203. "severity": alertHeader.Severity,
  204. "category": alertHeader.Category,
  205. "dedupe_count": fmt.Sprintf("%d", alertHeader.DedupeCount),
  206. "individual_id": env.IndividualID,
  207. "locale": env.Locale,
  208. "deep_link": fmt.Sprintf("broadannounce://alert/%s", alertHeader.ID),
  209. }),
  210. "android": map[string]any{
  211. "priority": androidPriority(alertHeader.Severity),
  212. "notification": map[string]any{
  213. "sound": androidSound(alertHeader.Category, alertHeader.Severity),
  214. "channel_id": "alerts." + channelForSeverity(alertHeader.Severity),
  215. },
  216. },
  217. },
  218. }
  219. body, _ := json.Marshal(fcmBody)
  220. // POST to fakefcmd. M3: real FCM endpoint.
  221. url := fakefcmdURL + "/v1/projects/fakefcmd/messages:send"
  222. req, _ := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
  223. req.Header.Set("Content-Type", "application/json")
  224. resp, err := httpClient.Do(req)
  225. status := "failed"
  226. lastErr := ""
  227. if err != nil {
  228. lastErr = err.Error()
  229. } else {
  230. defer resp.Body.Close()
  231. respBody, _ := io.ReadAll(resp.Body)
  232. if resp.StatusCode/100 == 2 {
  233. status = "sent"
  234. } else {
  235. lastErr = fmt.Sprintf("status %d: %s", resp.StatusCode, string(respBody))
  236. }
  237. }
  238. // Persist delivery row. M1: just insert. M3+: per-channel
  239. // retry with exp backoff and DLQ on terminal failure.
  240. _, dbErr := pool.Exec(ctx, `
  241. INSERT INTO deliveries
  242. (alert_id, company_id, individual_id, channel, target, status, attempts, last_error, payload, sent_at)
  243. VALUES ($1, $2, $3, $4, $5, $6, 1, NULLIF($7, ''), $8,
  244. CASE WHEN $6 = 'sent' THEN now() ELSE NULL END)
  245. `, alertHeader.ID, companyID, env.IndividualID, fcmChannel, env.Endpoint, status, lastErr, json.RawMessage(m.Data()))
  246. if dbErr != nil {
  247. logger.Warn("delivery row insert", "err", dbErr)
  248. }
  249. logger.Info("delivery",
  250. "alert_id", alertHeader.ID,
  251. "company", companyID,
  252. "individual", env.IndividualID,
  253. "channel", fcmChannel,
  254. "status", status,
  255. "err", lastErr,
  256. )
  257. _ = m.Ack()
  258. }
  259. func mergeData(base, add map[string]string) map[string]string {
  260. if base == nil {
  261. base = map[string]string{}
  262. }
  263. for k, v := range add {
  264. if _, ok := base[k]; !ok {
  265. base[k] = v
  266. }
  267. }
  268. return base
  269. }
  270. func androidPriority(sev string) string {
  271. if sev == "critical" || sev == "inminent_colapse" {
  272. return "HIGH"
  273. }
  274. return "NORMAL"
  275. }
  276. // androidSound picks a sound file. SPEC §4 says the Android app
  277. // uses data.category to pick siren_<category>.ogg; the per-severity
  278. // override for inminent_colapse wins.
  279. func androidSound(category, sev string) string {
  280. if sev == "inminent_colapse" {
  281. return "klaxon"
  282. }
  283. if category == "" {
  284. return "default"
  285. }
  286. return "siren_" + category
  287. }
  288. func channelForSeverity(sev string) string {
  289. switch sev {
  290. case "critical":
  291. return "critical"
  292. case "inminent_colapse":
  293. return "imminent"
  294. case "warning":
  295. return "warning"
  296. default:
  297. return "info"
  298. }
  299. }