main.go 8.4 KB

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