sms_api.go 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662
  1. package server
  2. import (
  3. "context"
  4. "crypto/sha256"
  5. "encoding/hex"
  6. "encoding/json"
  7. "errors"
  8. "fmt"
  9. "net/http"
  10. "strconv"
  11. "strings"
  12. "time"
  13. "vocat/internal/device"
  14. "vocat/internal/store"
  15. "vocat/internal/vowifi"
  16. )
  17. type imsSMSController interface {
  18. SendSMS(context.Context, string, vowifi.SMSSubmitRequest) (vowifi.SMSSubmitResult, error)
  19. }
  20. func (s *Server) routeSMSAPI(w http.ResponseWriter, r *http.Request, cleanPath string) bool {
  21. switch cleanPath {
  22. case "sms/contacts":
  23. s.handleSMSContacts(w, r)
  24. case "sms/thread":
  25. s.handleSMSThread(w, r)
  26. case "sms/send":
  27. s.handleSMSSend(w, r)
  28. default:
  29. segments := splitAPIPath(cleanPath)
  30. if len(segments) == 3 && segments[0] == "sms" && segments[1] == "messages" {
  31. s.handleSMSMessage(w, r, segments[2])
  32. return true
  33. }
  34. return false
  35. }
  36. return true
  37. }
  38. func (s *Server) handleSMSContacts(w http.ResponseWriter, r *http.Request) {
  39. if !requireMethod(w, r, http.MethodGet) {
  40. return
  41. }
  42. deviceID := normalizeSMSDeviceFilter(r.URL.Query().Get("device_id"))
  43. s.syncModemSMS(r.Context(), deviceID)
  44. contacts, err := s.store.ListSMSContacts(r.Context(), store.SMSFilter{
  45. DeviceID: deviceID,
  46. Limit: queryLimit(r, 100),
  47. })
  48. if err != nil {
  49. s.writeStoreError(w, err)
  50. return
  51. }
  52. result := make([]map[string]any, 0, len(contacts))
  53. for _, contact := range contacts {
  54. result = append(result, map[string]any{
  55. "device_id": contact.DeviceID,
  56. "device_name": contact.DeviceName,
  57. "imsi": contact.IMSI,
  58. "local_phone": contact.LocalPhone,
  59. "peer": contact.Peer,
  60. "display_name": contact.DisplayName,
  61. "last_message": contact.LastMessage,
  62. "last_content": contact.LastMessage,
  63. "last_timestamp": contact.LastTimestamp,
  64. "direction": contact.Direction,
  65. "last_type": "sms",
  66. "last_sms_id": contact.LastSMSID,
  67. "unread_count": contact.UnreadCount,
  68. "message_count": contact.MessageCount,
  69. })
  70. }
  71. writeJSON(w, http.StatusOK, map[string]any{"data": result})
  72. }
  73. func (s *Server) handleSMSThread(w http.ResponseWriter, r *http.Request) {
  74. deviceID := normalizeSMSDeviceFilter(r.URL.Query().Get("device_id"))
  75. imsi := strings.TrimSpace(r.URL.Query().Get("imsi"))
  76. peer := strings.TrimSpace(r.URL.Query().Get("peer"))
  77. if peer == "" {
  78. writeError(w, http.StatusBadRequest, "invalid_peer", "SMS peer is required")
  79. return
  80. }
  81. switch r.Method {
  82. case http.MethodGet:
  83. s.syncModemSMS(r.Context(), deviceID)
  84. messages, err := s.store.ListSMSMessages(r.Context(), store.SMSFilter{
  85. DeviceID: deviceID,
  86. IMSI: imsi,
  87. Peer: peer,
  88. Limit: queryLimit(r, 100),
  89. })
  90. if err != nil {
  91. s.writeStoreError(w, err)
  92. return
  93. }
  94. for _, message := range messages {
  95. if !message.Read && (message.Direction == "inbound" || message.Direction == "received") {
  96. message.Read = true
  97. _, _ = s.store.SaveSMSMessage(r.Context(), message)
  98. }
  99. }
  100. reverseSMS(messages)
  101. result := make([]map[string]any, 0, len(messages))
  102. for _, message := range messages {
  103. result = append(result, storedSMSResponse(message))
  104. }
  105. writeJSON(w, http.StatusOK, map[string]any{"data": result})
  106. case http.MethodDelete:
  107. messages, err := s.store.ListSMSMessages(r.Context(), store.SMSFilter{
  108. DeviceID: deviceID,
  109. IMSI: imsi,
  110. Peer: peer,
  111. Limit: 1000,
  112. })
  113. if err != nil {
  114. s.writeStoreError(w, err)
  115. return
  116. }
  117. if len(messages) == 0 {
  118. writeError(w, http.StatusNotFound, "not_found", "SMS thread was not found")
  119. return
  120. }
  121. for _, message := range messages {
  122. if err := s.store.DeleteSMSMessage(r.Context(), message.ID); err != nil {
  123. s.writeStoreError(w, err)
  124. return
  125. }
  126. }
  127. writeJSON(w, http.StatusOK, map[string]any{
  128. "data": map[string]any{"deleted": len(messages)},
  129. })
  130. default:
  131. w.Header().Set("Allow", "GET, DELETE")
  132. writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
  133. }
  134. }
  135. func normalizeSMSDeviceFilter(value string) string {
  136. value = strings.TrimSpace(value)
  137. if strings.EqualFold(value, "all") {
  138. return ""
  139. }
  140. return value
  141. }
  142. // blockedSMSDestination reports whether the recipient is in a barred country.
  143. // Normalization mirrors the PDU/IMS paths so the block cannot be sidestepped by
  144. // dropping the leading "+" or using a 00 international prefix.
  145. func blockedSMSDestination(phone string) (bool, string) {
  146. var digits strings.Builder
  147. for _, c := range strings.TrimSpace(phone) {
  148. if c >= '0' && c <= '9' {
  149. digits.WriteRune(c)
  150. }
  151. }
  152. d := digits.String()
  153. if strings.HasPrefix(d, "00") {
  154. d = d[2:]
  155. }
  156. if strings.HasPrefix(d, "86") {
  157. return true, "SMS to +86 (China) destinations is not allowed"
  158. }
  159. return false, ""
  160. }
  161. func (s *Server) handleSMSSend(w http.ResponseWriter, r *http.Request) {
  162. if !requireMethod(w, r, http.MethodPost) {
  163. return
  164. }
  165. if s.devices == nil {
  166. writeError(w, http.StatusServiceUnavailable, "device_manager_unavailable", "device manager is unavailable")
  167. return
  168. }
  169. var request struct {
  170. Phone string `json:"phone"`
  171. Message string `json:"message"`
  172. DeviceID string `json:"device_id"`
  173. }
  174. if err := s.decodeJSON(w, r, &request); err != nil {
  175. writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
  176. return
  177. }
  178. request.DeviceID = strings.TrimSpace(request.DeviceID)
  179. if request.DeviceID == "" {
  180. writeError(w, http.StatusBadRequest, "device_required", "a sending device is required")
  181. return
  182. }
  183. if blocked, reason := blockedSMSDestination(request.Phone); blocked {
  184. writeError(w, http.StatusBadRequest, "blocked_destination", reason)
  185. return
  186. }
  187. config, err := s.store.Device(r.Context(), request.DeviceID)
  188. if err != nil {
  189. s.writeStoreError(w, err)
  190. return
  191. }
  192. entry, physicalID, present := s.physicalForConfig(config)
  193. if !s.requirePhysicalDevice(w, present) {
  194. return
  195. }
  196. if config.VoWiFiEnabled && s.vowifi != nil {
  197. state, stateErr := s.vowifi.State(request.DeviceID)
  198. sender, canSendIMS := s.vowifi.(imsSMSController)
  199. if stateErr == nil && state.IMSReady && state.SMSReady && canSendIMS {
  200. result, sendErr := sender.SendSMS(r.Context(), request.DeviceID, vowifi.SMSSubmitRequest{
  201. Recipient: request.Phone,
  202. Text: request.Message,
  203. })
  204. if sendErr == nil || result.PartsAttempted > 0 || !errors.Is(sendErr, vowifi.ErrSMSNotReady) {
  205. s.writeIMSSMSSendResult(w, r, request.DeviceID, request.Message, entry, result, sendErr)
  206. return
  207. }
  208. }
  209. }
  210. result, sendErr := s.devices.SendSMS(
  211. r.Context(),
  212. physicalID,
  213. request.Phone,
  214. request.Message,
  215. )
  216. if sendErr != nil && result.PartsAttempted == 0 {
  217. s.writeDeviceError(w, sendErr)
  218. return
  219. }
  220. imsi := snapshotString(entry.Snapshot, func(snapshot *device.Snapshot) string { return snapshot.IMSI })
  221. extra, _ := json.Marshal(map[string]any{
  222. "encoding": result.Encoding,
  223. "message_reference": result.MessageReference,
  224. "reference_known": result.ReferenceKnown,
  225. "accepted_by_modem": result.AcceptedByModem,
  226. "delivery_confirmed": result.DeliveryConfirmed,
  227. "submission_status": result.SubmissionStatus,
  228. "modem_final": result.ModemFinal,
  229. "modem_evidence_count": len(result.ModemEvidence),
  230. "parts_total": result.PartsTotal,
  231. "parts_attempted": result.PartsAttempted,
  232. "parts_accepted": result.PartsAccepted,
  233. "all_parts_accepted": result.AllPartsAccepted,
  234. "concat_reference": result.ConcatReference,
  235. "part_results": result.PartResults,
  236. })
  237. messageID := fmt.Sprintf(
  238. "at-submit:%s:%d:%d",
  239. request.DeviceID,
  240. result.MessageReference,
  241. result.SubmittedAt.UnixNano(),
  242. )
  243. saved, err := s.store.SaveSMSMessage(r.Context(), store.SMSMessage{
  244. MessageID: messageID,
  245. DeviceID: request.DeviceID,
  246. IMSI: imsi,
  247. Peer: result.To,
  248. Direction: "outbound",
  249. Body: request.Message,
  250. Timestamp: result.SubmittedAt,
  251. Status: result.SubmissionStatus,
  252. Source: "cellular_at",
  253. PartsTotal: result.PartsTotal,
  254. DeliveryState: result.DeliveryStatus,
  255. Read: true,
  256. Extra: extra,
  257. })
  258. if err != nil {
  259. s.writeStoreError(w, err)
  260. return
  261. }
  262. data := map[string]any{
  263. "message_id": saved.MessageID,
  264. "id": saved.ID,
  265. "parts_total": saved.PartsTotal,
  266. "parts_attempted": result.PartsAttempted,
  267. "parts_accepted": result.PartsAccepted,
  268. "all_parts_accepted": result.AllPartsAccepted,
  269. "concat_reference": result.ConcatReference,
  270. "part_results": result.PartResults,
  271. "delivery_state": saved.DeliveryState,
  272. "submission_state": saved.Status,
  273. "message_reference": result.MessageReference,
  274. "reference_known": result.ReferenceKnown,
  275. "submission_accepted": result.AllPartsAccepted,
  276. "delivery_confirmed": result.DeliveryConfirmed,
  277. "outcome": smsSendOutcome(result.AllPartsAccepted, result.PartsAccepted, result.PartsTotal, result.DeliveryConfirmed),
  278. "transport": "cellular_at",
  279. }
  280. if sendErr != nil {
  281. data["retry_safe"] = false
  282. if result.PartsAccepted > 0 {
  283. data["warning"] = "Only part of the multipart SMS was accepted by the modem. Do not retry the whole message."
  284. writeJSON(w, http.StatusAccepted, map[string]any{"data": data})
  285. return
  286. }
  287. s.logger.Warn(
  288. "SMS submission failed after modem interaction",
  289. "device_id", request.DeviceID,
  290. "parts_attempted", result.PartsAttempted,
  291. "parts_accepted", result.PartsAccepted,
  292. "error", sendErr,
  293. )
  294. writeJSON(w, http.StatusBadGateway, map[string]any{
  295. "error": apiError{
  296. Code: "sms_submission_failed",
  297. Message: "The modem did not provide complete proof that the SMS was accepted. Inspect part_results before retrying.",
  298. },
  299. "data": data,
  300. })
  301. return
  302. }
  303. if !result.AllPartsAccepted {
  304. writeJSON(w, http.StatusBadGateway, map[string]any{
  305. "error": apiError{
  306. Code: "sms_submission_unconfirmed",
  307. Message: "The modem did not confirm acceptance of every SMS part.",
  308. },
  309. "data": data,
  310. })
  311. return
  312. }
  313. writeJSON(w, http.StatusAccepted, map[string]any{"data": data})
  314. }
  315. func (s *Server) writeIMSSMSSendResult(
  316. w http.ResponseWriter,
  317. r *http.Request,
  318. deviceID string,
  319. body string,
  320. entry device.Device,
  321. result vowifi.SMSSubmitResult,
  322. sendErr error,
  323. ) {
  324. if sendErr != nil && result.PartsAttempted == 0 {
  325. if errors.Is(sendErr, device.ErrSMSInvalidRecipient) ||
  326. errors.Is(sendErr, device.ErrSMSEmpty) ||
  327. errors.Is(sendErr, device.ErrSMSTooLong) {
  328. s.writeDeviceError(w, sendErr)
  329. return
  330. }
  331. writeError(w, http.StatusBadGateway, "ims_sms_submission_failed", sendErr.Error())
  332. return
  333. }
  334. extra, _ := json.Marshal(map[string]any{
  335. "transport": "ims",
  336. "encoding": result.Encoding,
  337. "parts_total": result.PartsTotal,
  338. "parts_attempted": result.PartsAttempted,
  339. "parts_accepted": result.PartsAccepted,
  340. "all_parts_accepted": result.AllPartsAccepted,
  341. "concat_reference": result.ConcatReference,
  342. "part_results": result.PartResults,
  343. "delivery_confirmed": result.DeliveryConfirmed,
  344. "submission_status": result.SubmissionStatus,
  345. })
  346. imsi := snapshotString(entry.Snapshot, func(snapshot *device.Snapshot) string { return snapshot.IMSI })
  347. saved, err := s.store.SaveSMSMessage(r.Context(), store.SMSMessage{
  348. MessageID: fmt.Sprintf("ims-submit:%s:%d", deviceID, result.SubmittedAt.UnixNano()),
  349. DeviceID: deviceID,
  350. IMSI: imsi,
  351. Peer: result.To,
  352. Direction: "outbound",
  353. Body: body,
  354. Timestamp: result.SubmittedAt,
  355. Status: result.SubmissionStatus,
  356. Source: "ims",
  357. PartsTotal: result.PartsTotal,
  358. DeliveryState: imsSMSDeliveryState(result),
  359. Read: true,
  360. Extra: extra,
  361. })
  362. if err != nil {
  363. s.writeStoreError(w, err)
  364. return
  365. }
  366. data := map[string]any{
  367. "message_id": saved.MessageID,
  368. "id": saved.ID,
  369. "parts_total": result.PartsTotal,
  370. "parts_attempted": result.PartsAttempted,
  371. "parts_accepted": result.PartsAccepted,
  372. "all_parts_accepted": result.AllPartsAccepted,
  373. "concat_reference": result.ConcatReference,
  374. "part_results": result.PartResults,
  375. "delivery_state": saved.DeliveryState,
  376. "submission_state": saved.Status,
  377. "transport": "ims",
  378. "submission_accepted": result.AllPartsAccepted,
  379. "delivery_confirmed": result.DeliveryConfirmed,
  380. "outcome": smsSendOutcome(result.AllPartsAccepted, result.PartsAccepted, result.PartsTotal, result.DeliveryConfirmed),
  381. }
  382. if sendErr != nil {
  383. data["retry_safe"] = false
  384. data["warning"] = sendErr.Error()
  385. if result.PartsAccepted == 0 {
  386. writeJSON(w, http.StatusBadGateway, map[string]any{
  387. "error": apiError{
  388. Code: "ims_sms_submission_failed",
  389. Message: "IMS did not accept the SMS submission.",
  390. },
  391. "data": data,
  392. })
  393. return
  394. }
  395. }
  396. if !result.AllPartsAccepted && result.PartsAccepted == 0 {
  397. writeJSON(w, http.StatusBadGateway, map[string]any{
  398. "error": apiError{
  399. Code: "ims_sms_submission_unconfirmed",
  400. Message: "IMS did not confirm acceptance of every SMS part.",
  401. },
  402. "data": data,
  403. })
  404. return
  405. }
  406. writeJSON(w, http.StatusAccepted, map[string]any{"data": data})
  407. }
  408. func smsSendOutcome(allAccepted bool, partsAccepted, partsTotal int, deliveryConfirmed bool) string {
  409. switch {
  410. case deliveryConfirmed:
  411. return "delivered"
  412. case allAccepted && partsTotal > 0 && partsAccepted == partsTotal:
  413. return "accepted_unconfirmed"
  414. case partsAccepted > 0:
  415. return "partial"
  416. default:
  417. return "failed"
  418. }
  419. }
  420. func imsSMSDeliveryState(result vowifi.SMSSubmitResult) string {
  421. switch smsSendOutcome(result.AllPartsAccepted, result.PartsAccepted, result.PartsTotal, result.DeliveryConfirmed) {
  422. case "delivered":
  423. return "delivered"
  424. case "accepted_unconfirmed":
  425. return "accepted_by_ims"
  426. case "partial":
  427. return "partial"
  428. default:
  429. return "failed"
  430. }
  431. }
  432. func (s *Server) handleSMSMessage(w http.ResponseWriter, r *http.Request, idText string) {
  433. if !requireMethod(w, r, http.MethodDelete) {
  434. return
  435. }
  436. id, err := strconv.ParseInt(idText, 10, 64)
  437. if err != nil || id < 1 {
  438. writeError(w, http.StatusBadRequest, "invalid_sms_id", "SMS message ID must be a positive integer")
  439. return
  440. }
  441. if err := s.store.DeleteSMSMessage(r.Context(), id); err != nil {
  442. s.writeStoreError(w, err)
  443. return
  444. }
  445. writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"deleted": true}})
  446. }
  447. func (s *Server) syncModemSMS(ctx context.Context, onlyDevice string) {
  448. if s.devices == nil {
  449. return
  450. }
  451. configs, err := s.store.ListDevices(ctx)
  452. if err != nil {
  453. s.logger.Warn("list devices for SMS synchronization failed", "error", err)
  454. return
  455. }
  456. for _, config := range configs {
  457. if onlyDevice != "" && config.ID != onlyDevice {
  458. continue
  459. }
  460. // Do not queue CMGL traffic on the same serial actor while VoWiFi is
  461. // reading the SIM or running AKA. Once the session is stable, resume the
  462. // SM/ME scan as a catch-up path: an SMS submitted while the card was
  463. // offline may be delivered to modem storage when it comes back, even
  464. // though subsequent live SMS is delivered by SIP MESSAGE.
  465. if s.vowifi != nil {
  466. state, stateErr := s.vowifi.State(config.ID)
  467. if shouldDeferModemSMSSync(state, stateErr) {
  468. continue
  469. }
  470. }
  471. entry, physicalID, present := s.physicalForConfig(config)
  472. if !present {
  473. continue
  474. }
  475. listContext, cancelList := context.WithTimeout(ctx, 30*time.Second)
  476. messages, err := s.devices.ListSMS(listContext, physicalID)
  477. cancelList()
  478. if err != nil {
  479. s.logger.Debug("modem SMS synchronization skipped", "device_id", config.ID, "error", err)
  480. continue
  481. }
  482. imsi := snapshotString(entry.Snapshot, func(snapshot *device.Snapshot) string { return snapshot.IMSI })
  483. for _, message := range messages {
  484. if message.Direction == device.SMSDirectionStatusReport &&
  485. message.MessageReference != nil && message.StatusCode != nil {
  486. _, applyErr := s.store.ApplySMSDeliveryReport(ctx, store.SMSDeliveryReport{
  487. DeviceID: config.ID,
  488. IMSI: imsi,
  489. Peer: message.To,
  490. Source: "cellular_at",
  491. MessageReference: *message.MessageReference,
  492. StatusCode: *message.StatusCode,
  493. DeliveryState: message.DeliveryStatus,
  494. ServiceCenterTime: message.ServiceCenterTimestamp,
  495. DischargeTime: message.DischargeTimestamp,
  496. ReceivedAt: time.Now().UTC(),
  497. })
  498. if applyErr != nil && !errors.Is(applyErr, store.ErrNotFound) {
  499. s.logger.Warn("apply modem SMS delivery report failed", "device_id", config.ID, "error", applyErr)
  500. }
  501. continue
  502. }
  503. peer := firstNonEmpty(message.From, message.To)
  504. if peer == "" {
  505. continue
  506. }
  507. timestamp := time.Now().UTC()
  508. if message.ServiceCenterTimestamp != nil {
  509. timestamp = message.ServiceCenterTimestamp.UTC()
  510. } else if message.DischargeTimestamp != nil {
  511. timestamp = message.DischargeTimestamp.UTC()
  512. }
  513. direction := "inbound"
  514. if message.Direction == device.SMSDirectionSubmitted {
  515. direction = "outbound"
  516. }
  517. digest := sha256.Sum256([]byte(message.RawPDU))
  518. messageID := fmt.Sprintf(
  519. "modem:%s:%d:%s",
  520. message.Storage,
  521. message.Index,
  522. hex.EncodeToString(digest[:8]),
  523. )
  524. extra, _ := json.Marshal(map[string]any{
  525. "modem_index": message.Index,
  526. "storage": message.Storage,
  527. "storage_status": message.StorageStatus,
  528. "encoding": message.Encoding,
  529. "concat": message.Concat,
  530. "decode_error": message.DecodeError,
  531. "status_code": message.StatusCode,
  532. "message_reference": message.MessageReference,
  533. "delivery_status": message.DeliveryStatus,
  534. "data_coding_scheme": message.DataCodingScheme,
  535. })
  536. _, saveErr := s.store.SaveSMSMessage(ctx, store.SMSMessage{
  537. MessageID: messageID,
  538. DeviceID: config.ID,
  539. IMSI: imsi,
  540. Peer: peer,
  541. Direction: direction,
  542. Body: message.Text,
  543. Timestamp: timestamp,
  544. Status: string(message.StorageStatus),
  545. Source: "cellular_at",
  546. PartsTotal: concatTotal(message.Concat),
  547. DeliveryState: message.DeliveryStatus,
  548. Read: message.StorageStatus == device.SMSStatusReceivedRead,
  549. Extra: extra,
  550. })
  551. if saveErr != nil {
  552. s.logger.Warn("persist modem SMS failed", "device_id", config.ID, "error", saveErr)
  553. }
  554. }
  555. }
  556. }
  557. func shouldDeferModemSMSSync(state vowifi.State, stateErr error) bool {
  558. if stateErr != nil || !state.Enabled {
  559. return false
  560. }
  561. // SMSReady is a quiescent runtime state: SIM/AKA setup has finished and
  562. // reading stored messages cannot race the eSIM/VoWiFi startup sequence.
  563. // Failed is also safe because the orchestrator has restored cellular radio
  564. // operation before publishing the terminal failure state.
  565. return state.Phase != vowifi.PhaseSMSReady && state.Phase != vowifi.PhaseFailed
  566. }
  567. // StartSMSSyncLoop periodically persists inbound cellular SMS even when no
  568. // client has the SMS page open. The first tick is delayed so startup SIM/AKA
  569. // work gets exclusive use of the modem. Stable VoWiFi sessions still scan SM
  570. // and ME as a catch-up path for messages delivered while the card was offline.
  571. func (s *Server) StartSMSSyncLoop(ctx context.Context, interval time.Duration) {
  572. if interval <= 0 {
  573. interval = 15 * time.Second
  574. }
  575. ticker := time.NewTicker(interval)
  576. defer ticker.Stop()
  577. for {
  578. select {
  579. case <-ctx.Done():
  580. return
  581. case <-ticker.C:
  582. s.syncModemSMS(ctx, "")
  583. }
  584. }
  585. }
  586. func storedSMSResponse(message store.SMSMessage) map[string]any {
  587. return map[string]any{
  588. "id": message.ID,
  589. "message_id": message.MessageID,
  590. "device_id": message.DeviceID,
  591. "imsi": message.IMSI,
  592. "peer": message.Peer,
  593. "direction": message.Direction,
  594. "body": message.Body,
  595. "content": message.Body,
  596. "sender": ternaryString(message.Direction == "outbound", "", message.Peer),
  597. "recipient": ternaryString(message.Direction == "outbound", message.Peer, ""),
  598. "type": "sms",
  599. "timestamp": message.Timestamp,
  600. "status": message.Status,
  601. "source": message.Source,
  602. "parts_total": message.PartsTotal,
  603. "delivery_state": message.DeliveryState,
  604. }
  605. }
  606. func reverseSMS(messages []store.SMSMessage) {
  607. for left, right := 0, len(messages)-1; left < right; left, right = left+1, right-1 {
  608. messages[left], messages[right] = messages[right], messages[left]
  609. }
  610. }
  611. func concatTotal(value *device.SMSConcatInfo) int {
  612. if value == nil || value.Total < 1 {
  613. return 1
  614. }
  615. return value.Total
  616. }
  617. func ternaryString(condition bool, yes string, no string) string {
  618. if condition {
  619. return yes
  620. }
  621. return no
  622. }
  623. func queryLimit(r *http.Request, fallback int) int {
  624. value, err := strconv.Atoi(r.URL.Query().Get("limit"))
  625. if err != nil || value < 1 {
  626. return fallback
  627. }
  628. if value > 1000 {
  629. return 1000
  630. }
  631. return value
  632. }
  633. func (s *Server) writeStoreError(w http.ResponseWriter, err error) {
  634. if errors.Is(err, store.ErrNotFound) {
  635. writeError(w, http.StatusNotFound, "not_found", "the requested record was not found")
  636. return
  637. }
  638. s.logger.Error("database operation failed", "error", err)
  639. writeError(w, http.StatusInternalServerError, "database_error", "the database operation failed")
  640. }