Files
VoCat/internal/server/sms_api.go
T

720 lines
23 KiB
Go

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)
filter := s.smsStoreFilter(r.Context(), deviceID, "")
filter.Limit = queryLimit(r, 100)
contacts, err := s.store.ListSMSContacts(r.Context(), filter)
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,
"modem_imei": contact.ModemIMEI,
"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"))
modemIMEI := strings.TrimSpace(r.URL.Query().Get("modem_imei"))
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)
filter := s.smsStoreFilter(r.Context(), deviceID, modemIMEI)
filter.IMSI = imsi
filter.Peer = peer
filter.Limit = queryLimit(r, 100)
if beforeID, parseErr := strconv.ParseInt(strings.TrimSpace(r.URL.Query().Get("before_id")), 10, 64); parseErr == nil && beforeID > 0 {
filter.BeforeID = beforeID
}
messages, err := s.store.ListSMSMessages(r.Context(), filter)
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:
filter := s.smsStoreFilter(r.Context(), deviceID, modemIMEI)
filter.IMSI = imsi
filter.Peer = peer
filter.Limit = 1000
messages, err := s.store.ListSMSMessages(r.Context(), filter)
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
}
// smsStoreFilter resolves a mutable configured device ID to the modem's stable
// IMEI. The ID is still used to address the live modem, but persisted history
// remains attached to the same hardware after the user renames that ID.
func (s *Server) smsStoreFilter(ctx context.Context, deviceID, requestedIMEI string) store.SMSFilter {
filter := store.SMSFilter{ModemIMEI: strings.TrimSpace(requestedIMEI)}
deviceID = strings.TrimSpace(deviceID)
if deviceID == "" {
return filter
}
filter.ModemIMEI = ""
filter.DeviceID = deviceID
config, err := s.store.Device(ctx, deviceID)
if err != nil {
return filter
}
imei := strings.TrimSpace(config.ModemIMEI)
if entry, _, present := s.physicalForConfig(config); present {
imei = firstNonEmpty(
snapshotString(entry.Snapshot, func(snapshot *device.Snapshot) string { return snapshot.IMEI }),
imei,
)
}
if imei != "" {
filter.DeviceID = ""
filter.ModemIMEI = imei
}
return filter
}
// 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 })
modemIMEI := firstNonEmpty(
snapshotString(entry.Snapshot, func(snapshot *device.Snapshot) string { return snapshot.IMEI }),
config.ModemIMEI,
)
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",
firstNonEmpty(modemIMEI, request.DeviceID),
result.MessageReference,
result.SubmittedAt.UnixNano(),
)
saved, err := s.store.SaveSMSMessage(r.Context(), store.SMSMessage{
MessageID: messageID,
DeviceID: request.DeviceID,
ModemIMEI: modemIMEI,
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 })
modemIMEI := snapshotString(entry.Snapshot, func(snapshot *device.Snapshot) string { return snapshot.IMEI })
if config, configErr := s.store.Device(r.Context(), deviceID); configErr == nil {
modemIMEI = firstNonEmpty(modemIMEI, config.ModemIMEI)
}
saved, err := s.store.SaveSMSMessage(r.Context(), store.SMSMessage{
MessageID: fmt.Sprintf("ims-submit:%s:%d", firstNonEmpty(modemIMEI, deviceID), result.SubmittedAt.UnixNano()),
DeviceID: deviceID,
ModemIMEI: modemIMEI,
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 })
modemIMEI := firstNonEmpty(
snapshotString(entry.Snapshot, func(snapshot *device.Snapshot) string { return snapshot.IMEI }),
config.ModemIMEI,
)
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,
ModemIMEI: modemIMEI,
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]),
)
if message.Concat != nil && message.Concat.Total > 1 {
// A segment of a carrier-split long SMS. Address the whole message
// with a stable id so SaveSMSMessage folds every segment into one
// progressively merged row instead of one row per segment.
messageID = store.StableConcatMessageID(
"cellular_at", modemIMEI, config.ID, peer,
message.Concat.Reference, message.Concat.Total,
)
}
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,
ModemIMEI: modemIMEI,
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,
"modem_imei": message.ModemIMEI,
"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")
}