umsh_text/
model.rs

1//! Wire-level model types for text messages.
2
3use umsh_core::{ChannelTag, NodeHint, PublicKey};
4
5use crate::ParseError;
6
7/// Text-message option numbers defined by the base specification.
8pub mod option {
9    pub const MESSAGE_TYPE: u16 = 0;
10    pub const SENDER_HANDLE: u16 = 1;
11    pub const MESSAGE_SEQUENCE: u16 = 2;
12    pub const SEQUENCE_RESET: u16 = 3;
13    pub const REGARDING: u16 = 4;
14    pub const EDITING: u16 = 5;
15    pub const BACKGROUND_COLOR: u16 = 6;
16    pub const TEXT_COLOR: u16 = 7;
17    pub const CHANNEL_GROUP_RESEND: u16 = 8;
18
19    /// First option number outside the base text-message range.
20    ///
21    /// Options at or above this number are preserved as extension options for
22    /// profile-specific validation (for example the chat-room Timestamp
23    /// Received and Sender Sequence options).
24    pub const EXTENSION_BASE: u16 = 9;
25}
26
27/// Maximum body bytes carried by a single fragment.
28pub const FRAGMENT_BODY_MAX: usize = 160;
29
30/// Maximum fragment count of a fragmented message (wire maximum).
31pub const FRAGMENT_COUNT_MAX: u8 = 10;
32
33/// Maximum reassembled body size of a fragmented message.
34pub const REASSEMBLED_BODY_MAX: usize = FRAGMENT_BODY_MAX * FRAGMENT_COUNT_MAX as usize;
35
36/// Text-message rendering/control type.
37///
38/// The type space is open: the base specification defines values 0–3, and
39/// extensions (such as chat-room system events) define more. Unrecognized
40/// values are preserved rather than rejected.
41#[derive(Clone, Copy, Debug, PartialEq, Eq)]
42pub enum MessageType {
43    Basic,
44    Status,
45    ResendRequest,
46    MessageUnavailable,
47    /// A message type outside the base range, preserved for profile-specific
48    /// validation. Never constructed with values 0–3.
49    Extension(u8),
50}
51
52impl MessageType {
53    pub fn from_byte(value: u8) -> Self {
54        match value {
55            0 => Self::Basic,
56            1 => Self::Status,
57            2 => Self::ResendRequest,
58            3 => Self::MessageUnavailable,
59            other => Self::Extension(other),
60        }
61    }
62
63    pub fn to_byte(self) -> u8 {
64        match self {
65            Self::Basic => 0,
66            Self::Status => 1,
67            Self::ResendRequest => 2,
68            Self::MessageUnavailable => 3,
69            Self::Extension(value) => value,
70        }
71    }
72
73    /// True for the control types that are not displayable content.
74    pub fn is_control(self) -> bool {
75        matches!(self, Self::ResendRequest | Self::MessageUnavailable)
76    }
77}
78
79/// Fragment position carried by the 3-byte Message Sequence form.
80#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
81pub struct Fragment {
82    pub index: u8,
83    pub count: u8,
84}
85
86/// Message Sequence option value.
87#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
88pub struct MessageSequence {
89    pub message_id: u8,
90    pub fragment: Option<Fragment>,
91}
92
93impl MessageSequence {
94    pub fn unfragmented(message_id: u8) -> Self {
95        Self {
96            message_id,
97            fragment: None,
98        }
99    }
100}
101
102/// Regarding option value: a reference to a previously sent message.
103#[derive(Clone, Copy, Debug, PartialEq, Eq)]
104pub enum Regarding {
105    /// 1-byte form used in one-to-one conversations and rooms.
106    Unicast { message_id: u8 },
107    /// 4-byte form used in channel-group conversations.
108    Multicast {
109        message_id: u8,
110        source_prefix: NodeHint,
111    },
112}
113
114impl Regarding {
115    pub fn message_id(&self) -> u8 {
116        match self {
117            Self::Unicast { message_id } => *message_id,
118            Self::Multicast { message_id, .. } => *message_id,
119        }
120    }
121}
122
123/// Zero-copy view over the extension options of a decoded text message.
124///
125/// Extension options are every option numbered at or above
126/// [`option::EXTENSION_BASE`]. Because CoAP-style options are ordered by
127/// number, they form a contiguous suffix of the option block, retained here
128/// verbatim together with the option number in effect where the suffix begins.
129#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
130pub struct ExtensionOptions<'a> {
131    pub(crate) base_number: u16,
132    pub(crate) data: &'a [u8],
133}
134
135impl<'a> ExtensionOptions<'a> {
136    /// A view containing no extension options.
137    pub const fn empty() -> Self {
138        Self {
139            base_number: 0,
140            data: &[],
141        }
142    }
143
144    pub fn is_empty(&self) -> bool {
145        self.data.is_empty()
146    }
147
148    /// Iterate over `(option number, value)` pairs.
149    pub fn iter(&self) -> ExtensionOptionsIter<'a> {
150        ExtensionOptionsIter {
151            decoder: umsh_core::options::OptionDecoder::new(self.data),
152            base_number: self.base_number,
153        }
154    }
155}
156
157pub struct ExtensionOptionsIter<'a> {
158    decoder: umsh_core::options::OptionDecoder<'a>,
159    base_number: u16,
160}
161
162impl<'a> Iterator for ExtensionOptionsIter<'a> {
163    type Item = Result<(u16, &'a [u8]), ParseError>;
164
165    fn next(&mut self) -> Option<Self::Item> {
166        let item = self.decoder.next()?;
167        Some(
168            item.map(|(delta_number, value)| (delta_number + self.base_number, value))
169                .map_err(ParseError::from),
170        )
171    }
172}
173
174/// Borrowed decoded text message.
175#[derive(Clone, Copy, Debug, PartialEq, Eq)]
176pub struct TextMessage<'a> {
177    pub message_type: MessageType,
178    pub sender_handle: Option<&'a str>,
179    pub sequence: Option<MessageSequence>,
180    pub sequence_reset: bool,
181    pub regarding: Option<Regarding>,
182    pub editing: Option<u8>,
183    pub bg_color: Option<[u8; 3]>,
184    pub text_color: Option<[u8; 3]>,
185    pub channel_group_resend: bool,
186    /// Options outside the base range, preserved verbatim for
187    /// profile-specific validation.
188    pub extensions: ExtensionOptions<'a>,
189    /// Raw message body bytes.
190    ///
191    /// For unfragmented messages and complete reassembly this is UTF-8, but a
192    /// single fragment of a fragmented message may end mid-code-point, so the
193    /// codec exposes bytes; body text is validated where completeness is
194    /// known.
195    pub body: &'a [u8],
196}
197
198impl<'a> TextMessage<'a> {
199    /// A basic text message with the given body and no options.
200    pub fn basic(body: &'a str) -> Self {
201        Self {
202            message_type: MessageType::Basic,
203            sender_handle: None,
204            sequence: None,
205            sequence_reset: false,
206            regarding: None,
207            editing: None,
208            bg_color: None,
209            text_color: None,
210            channel_group_resend: false,
211            extensions: ExtensionOptions::empty(),
212            body: body.as_bytes(),
213        }
214    }
215
216    /// The body as UTF-8 text, when valid.
217    pub fn body_str(&self) -> Result<&'a str, ParseError> {
218        core::str::from_utf8(self.body).map_err(|_| ParseError::InvalidUtf8)
219    }
220}
221
222/// Identity of a conversation, independent of transport handles.
223#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
224pub enum ConversationKey {
225    /// One-to-one unicast conversation with an authenticated peer.
226    Direct { peer: PublicKey },
227    /// The shared group conversation of a multicast channel.
228    ///
229    /// Keyed by the channel's [tag](ChannelTag) rather than its two-byte
230    /// identifier: the identifier is a hint that distinct keys may share, and a
231    /// conversation must never merge two channels that a receiver can tell
232    /// apart by authentication.
233    ChannelGroup { channel: ChannelTag },
234    /// One-to-one blind-unicast conversation over a channel key.
235    ChannelDirect {
236        channel: ChannelTag,
237        peer: PublicKey,
238    },
239    /// Conversation with a chat-room node (reserved for the room adapter).
240    Room { room: PublicKey },
241}
242
243impl ConversationKey {
244    /// True when message references use the 4-byte multicast Regarding form.
245    pub fn uses_multicast_references(&self) -> bool {
246        matches!(self, Self::ChannelGroup { .. })
247    }
248}
249
250/// The sender identity of a stream, as authenticated (or merely claimed) on
251/// the wire.
252#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
253pub enum SenderScope {
254    /// The local node.
255    Local,
256    /// An individually authenticated peer (unicast or blind-unicast).
257    Peer(PublicKey),
258    /// A multicast channel member identified only by its claimed source hint;
259    /// the channel MIC authenticates membership, not this identity.
260    ClaimedMember(NodeHint),
261}
262
263impl SenderScope {
264    /// The 3-byte hint used for multicast Regarding references, when known.
265    pub fn hint(&self) -> Option<NodeHint> {
266        match self {
267            Self::Local => None,
268            Self::Peer(key) => Some(NodeHint([key.0[0], key.0[1], key.0[2]])),
269            Self::ClaimedMember(hint) => Some(*hint),
270        }
271    }
272}
273
274/// A typed wire reference to a message, with explicit identity domain.
275#[derive(Clone, Copy, Debug, PartialEq, Eq)]
276pub enum WireRef {
277    /// A reference scoped to a particular sender's stream in a conversation.
278    SenderScoped { sender: SenderScope, message_id: u8 },
279    /// A reference in a room's canonical numbering domain.
280    RoomCanonical { message_id: u8 },
281}