Files
VoCat/internal/store/sms_reassembly.go
T

133 lines
4.9 KiB
Go

package store
import (
"encoding/json"
"fmt"
"sort"
"strconv"
"strings"
)
// ConcatMessageIDPrefix marks the stable message id that ingest points assign to
// every segment of one concatenated (long) SMS. Unlike the per-segment modem/IMS
// ids (which embed a storage slot, PDU hash, or RP reference), this id is shared
// by all segments of the message, so SaveSMSMessage folds them into a single row.
const ConcatMessageIDPrefix = "concat:"
// isConcatSMSMessageID reports whether a message id addresses a whole
// concatenated SMS rather than one physical segment.
func isConcatSMSMessageID(messageID string) bool {
return strings.HasPrefix(messageID, ConcatMessageIDPrefix)
}
// ConcatSMSReadyToNotify reports whether an inbound SMS row is ready to surface
// to a notification consumer. A plain message is always ready; a concatenated
// (long) SMS row is ready only once every segment has merged (concat_complete).
// Until then consumers should hold the notification but still advance their
// cursor — the completed message re-enters as a fresh durable id.
func ConcatSMSReadyToNotify(messageID string, extra json.RawMessage) bool {
if !isConcatSMSMessageID(messageID) {
return true
}
document, err := decodeJSONObject(extra)
if err != nil {
return false
}
complete, _ := document["concat_complete"].(bool)
return complete
}
// StableConcatMessageID builds the message id shared by every segment of one
// concatenated SMS. The UDH concat reference is only unique per sender, so the
// hardware identity and peer scope it; total is folded in to further separate the
// rare reference reuse between two different long messages from the same peer.
// The hardware identity matches the row lookup in saveSMSMessage, so a segment
// always finds the row its siblings started.
func StableConcatMessageID(source, modemIMEI, deviceID, peer string, reference, total int) string {
return ConcatMessageIDPrefix + source + ":" + smsHardwareKey(modemIMEI, deviceID) + ":" + peer + ":" +
strconv.Itoa(reference) + ":" + strconv.Itoa(total)
}
// mergeConcatSegment folds one incoming segment into the progressively merged
// body of a concatenated SMS. existingExtra is the stored row's Extra (empty for
// the first segment); segmentBody/segmentExtra are the incoming segment's text
// and Extra, the latter carrying "concat" ({reference,total,sequence}).
//
// Each segment's text is kept under "concat_parts" keyed by its UDH sequence and
// the body is rebuilt by joining the parts in ascending sequence order with no
// separator — exactly how a phone reassembles a long message, and correct for any
// arrival order. The merge is idempotent: redelivering an already-folded sequence
// reports changed=false so callers can skip the write and avoid id churn.
// "concat_complete" flips true once Total segments are present.
func mergeConcatSegment(
existingExtra json.RawMessage,
segmentBody string,
segmentExtra json.RawMessage,
) (body string, extra json.RawMessage, changed bool, err error) {
segment, err := decodeJSONObject(segmentExtra)
if err != nil {
return "", nil, false, fmt.Errorf("decode segment extra: %w", err)
}
concat, _ := segment["concat"].(map[string]any)
sequence := numberAsInt(concat["sequence"])
total := numberAsInt(concat["total"])
if sequence < 1 {
// No usable UDH sequence: keep the incoming segment as the whole body.
return segmentBody, json.RawMessage(segmentExtra), true, nil
}
// Seed the per-segment texts from the previously stored parts so an
// out-of-order arrival always rebuilds in sequence order.
parts := map[int]string{}
if len(existingExtra) > 0 {
if existing, derr := decodeJSONObject(existingExtra); derr == nil {
if stored, ok := existing["concat_parts"].(map[string]any); ok {
for key, value := range stored {
n, aerr := strconv.Atoi(key)
if aerr != nil || n < 1 {
continue
}
if text, ok := value.(string); ok {
parts[n] = text
}
}
}
}
}
prior, alreadyHad := parts[sequence]
changed = !alreadyHad || prior != segmentBody
parts[sequence] = segmentBody
sequences := make([]int, 0, len(parts))
for n := range parts {
sequences = append(sequences, n)
}
sort.Ints(sequences)
var joined strings.Builder
stored := make(map[string]string, len(parts))
for _, n := range sequences {
joined.WriteString(parts[n])
stored[strconv.Itoa(n)] = parts[n]
}
complete := total > 0 && len(parts) >= total
merged := map[string]any{
"concat": concat,
"concat_parts": stored,
"concat_received": len(parts),
"concat_complete": complete,
}
// Preserve non-concat metadata from the latest segment for context.
for _, key := range []string{"encoding", "storage", "transport", "source"} {
if value, ok := segment[key]; ok {
merged[key] = value
}
}
encoded, err := json.Marshal(merged)
if err != nil {
return "", nil, false, fmt.Errorf("encode merged concat extra: %w", err)
}
return joined.String(), json.RawMessage(encoded), changed, nil
}