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 }