| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662 |
- package server
- import (
- "context"
- "crypto/sha256"
- "encoding/hex"
- "encoding/json"
- "errors"
- "fmt"
- "net/http"
- "strconv"
- "strings"
- "time"
- "vocat/internal/device"
- "vocat/internal/store"
- "vocat/internal/vowifi"
- )
- type imsSMSController interface {
- SendSMS(context.Context, string, vowifi.SMSSubmitRequest) (vowifi.SMSSubmitResult, error)
- }
- func (s *Server) routeSMSAPI(w http.ResponseWriter, r *http.Request, cleanPath string) bool {
- switch cleanPath {
- case "sms/contacts":
- s.handleSMSContacts(w, r)
- case "sms/thread":
- s.handleSMSThread(w, r)
- case "sms/send":
- s.handleSMSSend(w, r)
- default:
- segments := splitAPIPath(cleanPath)
- if len(segments) == 3 && segments[0] == "sms" && segments[1] == "messages" {
- s.handleSMSMessage(w, r, segments[2])
- return true
- }
- return false
- }
- return true
- }
- func (s *Server) handleSMSContacts(w http.ResponseWriter, r *http.Request) {
- if !requireMethod(w, r, http.MethodGet) {
- return
- }
- deviceID := normalizeSMSDeviceFilter(r.URL.Query().Get("device_id"))
- s.syncModemSMS(r.Context(), deviceID)
- contacts, err := s.store.ListSMSContacts(r.Context(), store.SMSFilter{
- DeviceID: deviceID,
- Limit: queryLimit(r, 100),
- })
- if err != nil {
- s.writeStoreError(w, err)
- return
- }
- result := make([]map[string]any, 0, len(contacts))
- for _, contact := range contacts {
- result = append(result, map[string]any{
- "device_id": contact.DeviceID,
- "device_name": contact.DeviceName,
- "imsi": contact.IMSI,
- "local_phone": contact.LocalPhone,
- "peer": contact.Peer,
- "display_name": contact.DisplayName,
- "last_message": contact.LastMessage,
- "last_content": contact.LastMessage,
- "last_timestamp": contact.LastTimestamp,
- "direction": contact.Direction,
- "last_type": "sms",
- "last_sms_id": contact.LastSMSID,
- "unread_count": contact.UnreadCount,
- "message_count": contact.MessageCount,
- })
- }
- writeJSON(w, http.StatusOK, map[string]any{"data": result})
- }
- func (s *Server) handleSMSThread(w http.ResponseWriter, r *http.Request) {
- deviceID := normalizeSMSDeviceFilter(r.URL.Query().Get("device_id"))
- imsi := strings.TrimSpace(r.URL.Query().Get("imsi"))
- peer := strings.TrimSpace(r.URL.Query().Get("peer"))
- if peer == "" {
- writeError(w, http.StatusBadRequest, "invalid_peer", "SMS peer is required")
- return
- }
- switch r.Method {
- case http.MethodGet:
- s.syncModemSMS(r.Context(), deviceID)
- messages, err := s.store.ListSMSMessages(r.Context(), store.SMSFilter{
- DeviceID: deviceID,
- IMSI: imsi,
- Peer: peer,
- Limit: queryLimit(r, 100),
- })
- if err != nil {
- s.writeStoreError(w, err)
- return
- }
- for _, message := range messages {
- if !message.Read && (message.Direction == "inbound" || message.Direction == "received") {
- message.Read = true
- _, _ = s.store.SaveSMSMessage(r.Context(), message)
- }
- }
- reverseSMS(messages)
- result := make([]map[string]any, 0, len(messages))
- for _, message := range messages {
- result = append(result, storedSMSResponse(message))
- }
- writeJSON(w, http.StatusOK, map[string]any{"data": result})
- case http.MethodDelete:
- messages, err := s.store.ListSMSMessages(r.Context(), store.SMSFilter{
- DeviceID: deviceID,
- IMSI: imsi,
- Peer: peer,
- Limit: 1000,
- })
- if err != nil {
- s.writeStoreError(w, err)
- return
- }
- if len(messages) == 0 {
- writeError(w, http.StatusNotFound, "not_found", "SMS thread was not found")
- return
- }
- for _, message := range messages {
- if err := s.store.DeleteSMSMessage(r.Context(), message.ID); err != nil {
- s.writeStoreError(w, err)
- return
- }
- }
- writeJSON(w, http.StatusOK, map[string]any{
- "data": map[string]any{"deleted": len(messages)},
- })
- default:
- w.Header().Set("Allow", "GET, DELETE")
- writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
- }
- }
- func normalizeSMSDeviceFilter(value string) string {
- value = strings.TrimSpace(value)
- if strings.EqualFold(value, "all") {
- return ""
- }
- return value
- }
- // blockedSMSDestination reports whether the recipient is in a barred country.
- // Normalization mirrors the PDU/IMS paths so the block cannot be sidestepped by
- // dropping the leading "+" or using a 00 international prefix.
- func blockedSMSDestination(phone string) (bool, string) {
- var digits strings.Builder
- for _, c := range strings.TrimSpace(phone) {
- if c >= '0' && c <= '9' {
- digits.WriteRune(c)
- }
- }
- d := digits.String()
- if strings.HasPrefix(d, "00") {
- d = d[2:]
- }
- if strings.HasPrefix(d, "86") {
- return true, "SMS to +86 (China) destinations is not allowed"
- }
- return false, ""
- }
- func (s *Server) handleSMSSend(w http.ResponseWriter, r *http.Request) {
- if !requireMethod(w, r, http.MethodPost) {
- return
- }
- if s.devices == nil {
- writeError(w, http.StatusServiceUnavailable, "device_manager_unavailable", "device manager is unavailable")
- return
- }
- var request struct {
- Phone string `json:"phone"`
- Message string `json:"message"`
- DeviceID string `json:"device_id"`
- }
- if err := s.decodeJSON(w, r, &request); err != nil {
- writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
- return
- }
- request.DeviceID = strings.TrimSpace(request.DeviceID)
- if request.DeviceID == "" {
- writeError(w, http.StatusBadRequest, "device_required", "a sending device is required")
- return
- }
- if blocked, reason := blockedSMSDestination(request.Phone); blocked {
- writeError(w, http.StatusBadRequest, "blocked_destination", reason)
- return
- }
- config, err := s.store.Device(r.Context(), request.DeviceID)
- if err != nil {
- s.writeStoreError(w, err)
- return
- }
- entry, physicalID, present := s.physicalForConfig(config)
- if !s.requirePhysicalDevice(w, present) {
- return
- }
- if config.VoWiFiEnabled && s.vowifi != nil {
- state, stateErr := s.vowifi.State(request.DeviceID)
- sender, canSendIMS := s.vowifi.(imsSMSController)
- if stateErr == nil && state.IMSReady && state.SMSReady && canSendIMS {
- result, sendErr := sender.SendSMS(r.Context(), request.DeviceID, vowifi.SMSSubmitRequest{
- Recipient: request.Phone,
- Text: request.Message,
- })
- if sendErr == nil || result.PartsAttempted > 0 || !errors.Is(sendErr, vowifi.ErrSMSNotReady) {
- s.writeIMSSMSSendResult(w, r, request.DeviceID, request.Message, entry, result, sendErr)
- return
- }
- }
- }
- result, sendErr := s.devices.SendSMS(
- r.Context(),
- physicalID,
- request.Phone,
- request.Message,
- )
- if sendErr != nil && result.PartsAttempted == 0 {
- s.writeDeviceError(w, sendErr)
- return
- }
- imsi := snapshotString(entry.Snapshot, func(snapshot *device.Snapshot) string { return snapshot.IMSI })
- extra, _ := json.Marshal(map[string]any{
- "encoding": result.Encoding,
- "message_reference": result.MessageReference,
- "reference_known": result.ReferenceKnown,
- "accepted_by_modem": result.AcceptedByModem,
- "delivery_confirmed": result.DeliveryConfirmed,
- "submission_status": result.SubmissionStatus,
- "modem_final": result.ModemFinal,
- "modem_evidence_count": len(result.ModemEvidence),
- "parts_total": result.PartsTotal,
- "parts_attempted": result.PartsAttempted,
- "parts_accepted": result.PartsAccepted,
- "all_parts_accepted": result.AllPartsAccepted,
- "concat_reference": result.ConcatReference,
- "part_results": result.PartResults,
- })
- messageID := fmt.Sprintf(
- "at-submit:%s:%d:%d",
- request.DeviceID,
- result.MessageReference,
- result.SubmittedAt.UnixNano(),
- )
- saved, err := s.store.SaveSMSMessage(r.Context(), store.SMSMessage{
- MessageID: messageID,
- DeviceID: request.DeviceID,
- IMSI: imsi,
- Peer: result.To,
- Direction: "outbound",
- Body: request.Message,
- Timestamp: result.SubmittedAt,
- Status: result.SubmissionStatus,
- Source: "cellular_at",
- PartsTotal: result.PartsTotal,
- DeliveryState: result.DeliveryStatus,
- Read: true,
- Extra: extra,
- })
- if err != nil {
- s.writeStoreError(w, err)
- return
- }
- data := map[string]any{
- "message_id": saved.MessageID,
- "id": saved.ID,
- "parts_total": saved.PartsTotal,
- "parts_attempted": result.PartsAttempted,
- "parts_accepted": result.PartsAccepted,
- "all_parts_accepted": result.AllPartsAccepted,
- "concat_reference": result.ConcatReference,
- "part_results": result.PartResults,
- "delivery_state": saved.DeliveryState,
- "submission_state": saved.Status,
- "message_reference": result.MessageReference,
- "reference_known": result.ReferenceKnown,
- "submission_accepted": result.AllPartsAccepted,
- "delivery_confirmed": result.DeliveryConfirmed,
- "outcome": smsSendOutcome(result.AllPartsAccepted, result.PartsAccepted, result.PartsTotal, result.DeliveryConfirmed),
- "transport": "cellular_at",
- }
- if sendErr != nil {
- data["retry_safe"] = false
- if result.PartsAccepted > 0 {
- data["warning"] = "Only part of the multipart SMS was accepted by the modem. Do not retry the whole message."
- writeJSON(w, http.StatusAccepted, map[string]any{"data": data})
- return
- }
- s.logger.Warn(
- "SMS submission failed after modem interaction",
- "device_id", request.DeviceID,
- "parts_attempted", result.PartsAttempted,
- "parts_accepted", result.PartsAccepted,
- "error", sendErr,
- )
- writeJSON(w, http.StatusBadGateway, map[string]any{
- "error": apiError{
- Code: "sms_submission_failed",
- Message: "The modem did not provide complete proof that the SMS was accepted. Inspect part_results before retrying.",
- },
- "data": data,
- })
- return
- }
- if !result.AllPartsAccepted {
- writeJSON(w, http.StatusBadGateway, map[string]any{
- "error": apiError{
- Code: "sms_submission_unconfirmed",
- Message: "The modem did not confirm acceptance of every SMS part.",
- },
- "data": data,
- })
- return
- }
- writeJSON(w, http.StatusAccepted, map[string]any{"data": data})
- }
- func (s *Server) writeIMSSMSSendResult(
- w http.ResponseWriter,
- r *http.Request,
- deviceID string,
- body string,
- entry device.Device,
- result vowifi.SMSSubmitResult,
- sendErr error,
- ) {
- if sendErr != nil && result.PartsAttempted == 0 {
- if errors.Is(sendErr, device.ErrSMSInvalidRecipient) ||
- errors.Is(sendErr, device.ErrSMSEmpty) ||
- errors.Is(sendErr, device.ErrSMSTooLong) {
- s.writeDeviceError(w, sendErr)
- return
- }
- writeError(w, http.StatusBadGateway, "ims_sms_submission_failed", sendErr.Error())
- return
- }
- extra, _ := json.Marshal(map[string]any{
- "transport": "ims",
- "encoding": result.Encoding,
- "parts_total": result.PartsTotal,
- "parts_attempted": result.PartsAttempted,
- "parts_accepted": result.PartsAccepted,
- "all_parts_accepted": result.AllPartsAccepted,
- "concat_reference": result.ConcatReference,
- "part_results": result.PartResults,
- "delivery_confirmed": result.DeliveryConfirmed,
- "submission_status": result.SubmissionStatus,
- })
- imsi := snapshotString(entry.Snapshot, func(snapshot *device.Snapshot) string { return snapshot.IMSI })
- saved, err := s.store.SaveSMSMessage(r.Context(), store.SMSMessage{
- MessageID: fmt.Sprintf("ims-submit:%s:%d", deviceID, result.SubmittedAt.UnixNano()),
- DeviceID: deviceID,
- IMSI: imsi,
- Peer: result.To,
- Direction: "outbound",
- Body: body,
- Timestamp: result.SubmittedAt,
- Status: result.SubmissionStatus,
- Source: "ims",
- PartsTotal: result.PartsTotal,
- DeliveryState: imsSMSDeliveryState(result),
- Read: true,
- Extra: extra,
- })
- if err != nil {
- s.writeStoreError(w, err)
- return
- }
- data := map[string]any{
- "message_id": saved.MessageID,
- "id": saved.ID,
- "parts_total": result.PartsTotal,
- "parts_attempted": result.PartsAttempted,
- "parts_accepted": result.PartsAccepted,
- "all_parts_accepted": result.AllPartsAccepted,
- "concat_reference": result.ConcatReference,
- "part_results": result.PartResults,
- "delivery_state": saved.DeliveryState,
- "submission_state": saved.Status,
- "transport": "ims",
- "submission_accepted": result.AllPartsAccepted,
- "delivery_confirmed": result.DeliveryConfirmed,
- "outcome": smsSendOutcome(result.AllPartsAccepted, result.PartsAccepted, result.PartsTotal, result.DeliveryConfirmed),
- }
- if sendErr != nil {
- data["retry_safe"] = false
- data["warning"] = sendErr.Error()
- if result.PartsAccepted == 0 {
- writeJSON(w, http.StatusBadGateway, map[string]any{
- "error": apiError{
- Code: "ims_sms_submission_failed",
- Message: "IMS did not accept the SMS submission.",
- },
- "data": data,
- })
- return
- }
- }
- if !result.AllPartsAccepted && result.PartsAccepted == 0 {
- writeJSON(w, http.StatusBadGateway, map[string]any{
- "error": apiError{
- Code: "ims_sms_submission_unconfirmed",
- Message: "IMS did not confirm acceptance of every SMS part.",
- },
- "data": data,
- })
- return
- }
- writeJSON(w, http.StatusAccepted, map[string]any{"data": data})
- }
- func smsSendOutcome(allAccepted bool, partsAccepted, partsTotal int, deliveryConfirmed bool) string {
- switch {
- case deliveryConfirmed:
- return "delivered"
- case allAccepted && partsTotal > 0 && partsAccepted == partsTotal:
- return "accepted_unconfirmed"
- case partsAccepted > 0:
- return "partial"
- default:
- return "failed"
- }
- }
- func imsSMSDeliveryState(result vowifi.SMSSubmitResult) string {
- switch smsSendOutcome(result.AllPartsAccepted, result.PartsAccepted, result.PartsTotal, result.DeliveryConfirmed) {
- case "delivered":
- return "delivered"
- case "accepted_unconfirmed":
- return "accepted_by_ims"
- case "partial":
- return "partial"
- default:
- return "failed"
- }
- }
- func (s *Server) handleSMSMessage(w http.ResponseWriter, r *http.Request, idText string) {
- if !requireMethod(w, r, http.MethodDelete) {
- return
- }
- id, err := strconv.ParseInt(idText, 10, 64)
- if err != nil || id < 1 {
- writeError(w, http.StatusBadRequest, "invalid_sms_id", "SMS message ID must be a positive integer")
- return
- }
- if err := s.store.DeleteSMSMessage(r.Context(), id); err != nil {
- s.writeStoreError(w, err)
- return
- }
- writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"deleted": true}})
- }
- func (s *Server) syncModemSMS(ctx context.Context, onlyDevice string) {
- if s.devices == nil {
- return
- }
- configs, err := s.store.ListDevices(ctx)
- if err != nil {
- s.logger.Warn("list devices for SMS synchronization failed", "error", err)
- return
- }
- for _, config := range configs {
- if onlyDevice != "" && config.ID != onlyDevice {
- continue
- }
- // Do not queue CMGL traffic on the same serial actor while VoWiFi is
- // reading the SIM or running AKA. Once the session is stable, resume the
- // SM/ME scan as a catch-up path: an SMS submitted while the card was
- // offline may be delivered to modem storage when it comes back, even
- // though subsequent live SMS is delivered by SIP MESSAGE.
- if s.vowifi != nil {
- state, stateErr := s.vowifi.State(config.ID)
- if shouldDeferModemSMSSync(state, stateErr) {
- continue
- }
- }
- entry, physicalID, present := s.physicalForConfig(config)
- if !present {
- continue
- }
- listContext, cancelList := context.WithTimeout(ctx, 30*time.Second)
- messages, err := s.devices.ListSMS(listContext, physicalID)
- cancelList()
- if err != nil {
- s.logger.Debug("modem SMS synchronization skipped", "device_id", config.ID, "error", err)
- continue
- }
- imsi := snapshotString(entry.Snapshot, func(snapshot *device.Snapshot) string { return snapshot.IMSI })
- for _, message := range messages {
- if message.Direction == device.SMSDirectionStatusReport &&
- message.MessageReference != nil && message.StatusCode != nil {
- _, applyErr := s.store.ApplySMSDeliveryReport(ctx, store.SMSDeliveryReport{
- DeviceID: config.ID,
- IMSI: imsi,
- Peer: message.To,
- Source: "cellular_at",
- MessageReference: *message.MessageReference,
- StatusCode: *message.StatusCode,
- DeliveryState: message.DeliveryStatus,
- ServiceCenterTime: message.ServiceCenterTimestamp,
- DischargeTime: message.DischargeTimestamp,
- ReceivedAt: time.Now().UTC(),
- })
- if applyErr != nil && !errors.Is(applyErr, store.ErrNotFound) {
- s.logger.Warn("apply modem SMS delivery report failed", "device_id", config.ID, "error", applyErr)
- }
- continue
- }
- peer := firstNonEmpty(message.From, message.To)
- if peer == "" {
- continue
- }
- timestamp := time.Now().UTC()
- if message.ServiceCenterTimestamp != nil {
- timestamp = message.ServiceCenterTimestamp.UTC()
- } else if message.DischargeTimestamp != nil {
- timestamp = message.DischargeTimestamp.UTC()
- }
- direction := "inbound"
- if message.Direction == device.SMSDirectionSubmitted {
- direction = "outbound"
- }
- digest := sha256.Sum256([]byte(message.RawPDU))
- messageID := fmt.Sprintf(
- "modem:%s:%d:%s",
- message.Storage,
- message.Index,
- hex.EncodeToString(digest[:8]),
- )
- extra, _ := json.Marshal(map[string]any{
- "modem_index": message.Index,
- "storage": message.Storage,
- "storage_status": message.StorageStatus,
- "encoding": message.Encoding,
- "concat": message.Concat,
- "decode_error": message.DecodeError,
- "status_code": message.StatusCode,
- "message_reference": message.MessageReference,
- "delivery_status": message.DeliveryStatus,
- "data_coding_scheme": message.DataCodingScheme,
- })
- _, saveErr := s.store.SaveSMSMessage(ctx, store.SMSMessage{
- MessageID: messageID,
- DeviceID: config.ID,
- IMSI: imsi,
- Peer: peer,
- Direction: direction,
- Body: message.Text,
- Timestamp: timestamp,
- Status: string(message.StorageStatus),
- Source: "cellular_at",
- PartsTotal: concatTotal(message.Concat),
- DeliveryState: message.DeliveryStatus,
- Read: message.StorageStatus == device.SMSStatusReceivedRead,
- Extra: extra,
- })
- if saveErr != nil {
- s.logger.Warn("persist modem SMS failed", "device_id", config.ID, "error", saveErr)
- }
- }
- }
- }
- func shouldDeferModemSMSSync(state vowifi.State, stateErr error) bool {
- if stateErr != nil || !state.Enabled {
- return false
- }
- // SMSReady is a quiescent runtime state: SIM/AKA setup has finished and
- // reading stored messages cannot race the eSIM/VoWiFi startup sequence.
- // Failed is also safe because the orchestrator has restored cellular radio
- // operation before publishing the terminal failure state.
- return state.Phase != vowifi.PhaseSMSReady && state.Phase != vowifi.PhaseFailed
- }
- // StartSMSSyncLoop periodically persists inbound cellular SMS even when no
- // client has the SMS page open. The first tick is delayed so startup SIM/AKA
- // work gets exclusive use of the modem. Stable VoWiFi sessions still scan SM
- // and ME as a catch-up path for messages delivered while the card was offline.
- func (s *Server) StartSMSSyncLoop(ctx context.Context, interval time.Duration) {
- if interval <= 0 {
- interval = 15 * time.Second
- }
- ticker := time.NewTicker(interval)
- defer ticker.Stop()
- for {
- select {
- case <-ctx.Done():
- return
- case <-ticker.C:
- s.syncModemSMS(ctx, "")
- }
- }
- }
- func storedSMSResponse(message store.SMSMessage) map[string]any {
- return map[string]any{
- "id": message.ID,
- "message_id": message.MessageID,
- "device_id": message.DeviceID,
- "imsi": message.IMSI,
- "peer": message.Peer,
- "direction": message.Direction,
- "body": message.Body,
- "content": message.Body,
- "sender": ternaryString(message.Direction == "outbound", "", message.Peer),
- "recipient": ternaryString(message.Direction == "outbound", message.Peer, ""),
- "type": "sms",
- "timestamp": message.Timestamp,
- "status": message.Status,
- "source": message.Source,
- "parts_total": message.PartsTotal,
- "delivery_state": message.DeliveryState,
- }
- }
- func reverseSMS(messages []store.SMSMessage) {
- for left, right := 0, len(messages)-1; left < right; left, right = left+1, right-1 {
- messages[left], messages[right] = messages[right], messages[left]
- }
- }
- func concatTotal(value *device.SMSConcatInfo) int {
- if value == nil || value.Total < 1 {
- return 1
- }
- return value.Total
- }
- func ternaryString(condition bool, yes string, no string) string {
- if condition {
- return yes
- }
- return no
- }
- func queryLimit(r *http.Request, fallback int) int {
- value, err := strconv.Atoi(r.URL.Query().Get("limit"))
- if err != nil || value < 1 {
- return fallback
- }
- if value > 1000 {
- return 1000
- }
- return value
- }
- func (s *Server) writeStoreError(w http.ResponseWriter, err error) {
- if errors.Is(err, store.ErrNotFound) {
- writeError(w, http.StatusNotFound, "not_found", "the requested record was not found")
- return
- }
- s.logger.Error("database operation failed", "error", err)
- writeError(w, http.StatusInternalServerError, "database_error", "the database operation failed")
- }
|