offline_protocol/events.rs
1//! Event types and callbacks.
2
3use offline_protocol_core::{ContentType, MediaMetadata, Message, MessageId};
4use offline_protocol_services::ServiceEvent;
5use offline_protocol_transport::TransportType;
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use std::fmt;
9use std::sync::Arc;
10
11/// Presence status for a peer.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum PresenceStatus {
15 /// Peer is online and available.
16 Online,
17 /// Peer is away / idle.
18 Away,
19 /// Peer is explicitly offline.
20 Offline,
21}
22
23/// Where a `PresenceUpdated` event came from.
24///
25/// Apps that render relay-style presence UI (e.g. a direct-chat header's
26/// "Online" / "Last seen …") should filter on `Internet` so a nearby peer's
27/// self-report can't flip a header that is defined as relay-observed.
28#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(rename_all = "snake_case")]
30pub enum PresenceSource {
31 /// Relay-observed presence: the relay's authoritative answer to
32 /// `CheckPresence` (`PresenceStatus` / `PresenceStatusWithLastSeen`),
33 /// or relay-derived reachability — the platform bridges also report
34 /// `offline` here when a relay `DeliveryError` names the recipient
35 /// unreachable, so a failed send can produce an `Internet`-sourced
36 /// offline event without any explicit presence query.
37 Internet,
38 /// Gateway-observed presence over the Reticulum daemon contract: a
39 /// `PresenceStatus` answer from the gateway this device is attached to,
40 /// whether solicited by the SDK's watchlist or pushed when a watched
41 /// peer's state changes.
42 ///
43 /// Named for the carrier rather than for "gateway", because that is what
44 /// [`Self::Internet`] names too: the relay is a gateway, and an app that
45 /// wants to know which carrier answered has to be able to tell them
46 /// apart. A claim from either has the same standing and the same decay.
47 Reticulum,
48 /// A peer-sent `__PRESENCE__` self-report. Transport-agnostic: it may
49 /// arrive over BLE, WiFi Direct, or even relay-forwarded frames — hence
50 /// "peer", not "mesh". Default for deserializing legacy events that
51 /// predate the field.
52 #[default]
53 Peer,
54}
55
56/// Event callback type for handling protocol events.
57pub type EventCallback = Arc<dyn Fn(Event) + Send + Sync>;
58
59/// Machine-readable reason taxonomy for welcome delivery failures.
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
61#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
62pub enum WelcomeReasonCode {
63 /// No transport was available or all transport sends failed.
64 TransportUnavailable,
65 /// The transport is up but this specific peer is unreachable on it
66 /// (e.g. the internet relay reported the recipient offline). Treated as
67 /// per-peer no-carrier: the welcome parks instead of aging.
68 PeerUnreachable,
69 /// Peer was disconnected or became unavailable during send.
70 PeerDisconnected,
71 /// Send operation timed out.
72 Timeout,
73 /// Local/internal error prevented send.
74 InternalError,
75 /// Retry budget or lifecycle TTL was exhausted.
76 RetryExhausted,
77}
78
79impl WelcomeReasonCode {
80 /// Returns the stable machine-readable reason code.
81 pub fn as_str(&self) -> &'static str {
82 match self {
83 Self::TransportUnavailable => "TRANSPORT_UNAVAILABLE",
84 Self::PeerUnreachable => "PEER_UNREACHABLE",
85 Self::PeerDisconnected => "PEER_DISCONNECTED",
86 Self::Timeout => "TIMEOUT",
87 Self::InternalError => "INTERNAL_ERROR",
88 Self::RetryExhausted => "RETRY_EXHAUSTED",
89 }
90 }
91
92 /// The settlement reason for a message dropped because the Welcome that
93 /// would have carried its session never arrived.
94 ///
95 /// Spelled out per variant rather than interpolated into a prefix so the
96 /// result is a `&'static str`: it becomes [`Event::message_failed`]'s
97 /// reason, which the telemetry pipe forwards to the ingest verbatim. See
98 /// that constructor for why the type is the guard.
99 pub fn welcome_failure_reason(&self) -> &'static str {
100 match self {
101 Self::TransportUnavailable => "Welcome delivery failed: TRANSPORT_UNAVAILABLE",
102 Self::PeerUnreachable => "Welcome delivery failed: PEER_UNREACHABLE",
103 Self::PeerDisconnected => "Welcome delivery failed: PEER_DISCONNECTED",
104 Self::Timeout => "Welcome delivery failed: TIMEOUT",
105 Self::InternalError => "Welcome delivery failed: INTERNAL_ERROR",
106 Self::RetryExhausted => "Welcome delivery failed: RETRY_EXHAUSTED",
107 }
108 }
109}
110
111/// Machine-readable taxonomy for [`Event::SecurityWarning`], so consumers can
112/// branch on a stable code instead of matching the human-readable `reason`
113/// string (which is for logs/UI and may change between versions).
114#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
115#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
116pub enum SecurityWarningCode {
117 /// A control message's signature verified, but the key that signed it does
118 /// not derive to the address the frame claims to come from.
119 ///
120 /// There is no benign reading. An address *is* the hash of its identity
121 /// key, so a legitimate peer's frames always re-derive to their own
122 /// address: a rotated key is a *different address*, i.e. a different peer,
123 /// not the same peer with new bytes. This is either an impersonation
124 /// attempt or a peer running a build with a broken derivation.
125 ///
126 /// Replaces `TOFU_KEY_MISMATCH`, whose remedy (`resetTofuForPeer`) no longer
127 /// exists because there is no pin to reset.
128 SenderAddressMismatch,
129 /// A control message's signed sender did not match its transport peer
130 /// identity.
131 TransportIdentityMismatch,
132 /// A control message's signature failed verification (invalid signature or
133 /// malformed metadata).
134 ControlSignatureInvalid,
135 /// An unsigned control message was rejected.
136 ///
137 /// All security-gated control traffic must be signed by the key its sender
138 /// address derives from. This is unconditional: it does not depend on
139 /// `require_transport_identity`, nor on whether the sender was seen before.
140 /// A peer that will not sign is making a claim that cannot be checked.
141 UnsignedControlRejected,
142 /// An encrypted media chunk's MLS group did not match the 1:1 session
143 /// group of the claimed wire sender — a valid ciphertext (from some
144 /// session) delivered under a different sender's name is a media
145 /// forgery/misattribution attempt.
146 MediaSenderGroupMismatch,
147 /// An outbound message left this node as plaintext because encryption is
148 /// disabled or MLS is uninitialized while `require_encryption` is `false`
149 /// (an explicit opt-out — the default fails closed). Emitted at most once
150 /// per peer per protocol instance.
151 PlaintextSend,
152 /// Inbound plaintext content (text or legacy media) was rejected by the
153 /// encryption policy: either `require_encryption` is enabled, or a
154 /// confirmed MLS session exists with the claimed sender and plaintext
155 /// from that peer is a downgrade/forgery attempt (plaintext carries no
156 /// sender authentication). Emitted at most once per peer; the per-peer
157 /// tracking set is bounded, so after a flood of forged sender ids a
158 /// peer may warn again.
159 PlaintextReceiveRejected,
160 /// An encrypted 1:1 envelope named an MLS group that is not the session
161 /// slot shared with the claimed wire sender. The text-path counterpart to
162 /// [`Self::MediaSenderGroupMismatch`]: it catches the same misattribution
163 /// on paths where MLS cannot, because the envelope failed before it
164 /// authenticated anything.
165 SessionSenderGroupMismatch,
166 /// A 1:1 MLS session was torn down and re-advertised after an epoch
167 /// desync. Expected occasionally (a genuine fork heals this way), but the
168 /// triggering frame is not authenticated — see `schedule_session_rekey` —
169 /// so a sustained rate of these for one peer indicates injected frames
170 /// rather than a real fork.
171 SessionRekeyTriggered,
172 /// A Nostr key-package publication slot could not be refilled, so cold
173 /// first contact over Nostr is degraded or unavailable until it succeeds.
174 ///
175 /// Slots are consumed locally — an init key leaves provider storage only
176 /// when *this* node processes a Welcome built against it — so consumption
177 /// itself is normal traffic, and a stranger can drive it only by actually
178 /// establishing sessions with us. What this warns about is the refill
179 /// failing (MLS or storage error), which is the failure the publication
180 /// design must never absorb silently: a stale published record makes every
181 /// stranger who fetches it build a Welcome that can never be processed.
182 NostrKeyPackageSlotExhausted,
183 /// The per-peer key-package pool hit its ceiling, so a peer was advertised
184 /// an init key another peer already holds.
185 ///
186 /// The push path normally gives each peer its own single-use init key. Past
187 /// the ceiling it reuses one instead of growing the pool without bound —
188 /// weakening forward secrecy at session establishment (one compromised init
189 /// key opens every Welcome built against it) and re-introducing the race
190 /// where the second peer to use a package finds it already consumed. Both
191 /// clear as packages are consumed or expire.
192 ///
193 /// Reaching it means a device has accumulated unconsumed advertisements:
194 /// many peers met that never established a session. Sustained emission is
195 /// the signal that the ceiling, not peer churn, is now the binding
196 /// constraint.
197 PushKeyPackagePoolExhausted,
198 /// The relay acknowledged an address declaration naming an address that is
199 /// not this node's.
200 ///
201 /// There is no benign reading. The bridge declares `local_address()` and
202 /// signs a proof over a per-connection challenge; the relay verifies that
203 /// the declared address derives from the key that signed it, and echoes
204 /// back what it bound. So the echo can only differ if the relay bound
205 /// something other than what it verified — a broken or hostile relay, or a
206 /// frame injected into the socket.
207 ///
208 /// Nothing is torn down: a relay that controls the socket already controls
209 /// everything a local refusal could protect, so the mitigation is that the
210 /// signal is loud rather than that the connection dies. What it means in
211 /// practice is that this connection's frames are attributed to an identity
212 /// this node cannot prove, so receivers strict-matching the sender against
213 /// the transport identity will reject its security-gated control traffic.
214 RelayAddressBindingMismatch,
215 /// The relay refused this connection's address declaration, so it stays
216 /// attributed by account name.
217 ///
218 /// Operational degradation, not an attack signal: the refusal path is
219 /// deliberately non-fatal on both sides, and the connection keeps working
220 /// exactly as it did before addresses existed. What is lost is *new* MLS
221 /// session establishment over the relay — `__MLS_KEY_PKG__` and
222 /// `__MLS_WELCOME__` are security-gated, so a receiver that strict-matches
223 /// the account name against the `off1…` sender rejects them, while already
224 /// established sessions keep working because `__MLS_ENC__` is data-plane.
225 ///
226 /// The relay's own text is **not** carried on the event. It is
227 /// remote-chosen, and this event's `reason` is shipped verbatim by the
228 /// telemetry scrubber, so the wording stays bounded in the device log
229 /// while the code above is the classification. The causes it distinguishes
230 /// range from unusable key material to this socket having been displaced
231 /// by a newer login; none of them changes what an app should do. No retry
232 /// is attempted — the next reconnect declares from scratch.
233 RelayAddressDeclarationRefused,
234 /// A group leaf carries a credential its own signature key does not derive
235 /// to — an identity claimed inside a group without the key to prove it.
236 ///
237 /// There is no benign reading, and unlike most codes here it accuses
238 /// someone the user is already in a room with. An address *is* the hash of
239 /// its identity key, so every honest leaf reproduces its own credential;
240 /// producing one that does not means deliberately building a leaf around
241 /// someone else's name.
242 ///
243 /// `peer_id` is **who the finding concerns, not always who to blame**. On
244 /// the three refusal sites it is the peer that delivered the forgery — the
245 /// Welcome's inviter, or the sender of the frame carrying the commit — and
246 /// never the address the forged leaf claimed. That attribution is worth
247 /// something because it is proved independently: `__GRP_MLS_WELCOME__`,
248 /// `__GRP_MLS_COMMIT__` and `__MLS_WELCOME__` are all security-gated, so
249 /// the sender signed with the key its own address derives from. On the
250 /// fourth site there is no delivering peer at all and `peer_id` is this
251 /// device's own id.
252 ///
253 /// `reason` is diagnostic text, must not be parsed, and deliberately
254 /// carries **no identifier** — the impersonated address appears only in
255 /// this device's logs. Telemetry scrubbing hashes `peer_id` and passes
256 /// `reason` through verbatim, and the identity at stake belongs to a third
257 /// party who is not even part of the exchange.
258 ///
259 /// Emitted from four sites. The first three refuse a claim *arriving*, so
260 /// nothing is installed and the frame is dropped:
261 ///
262 /// 1. joining a group Welcome whose ratchet tree contains such a leaf (the
263 /// invite is declined outright);
264 /// 2. processing a membership commit that would install one (the commit is
265 /// not merged, and is never buffered for retry);
266 /// 3. joining a 1:1 session Welcome whose ratchet tree contains one —
267 /// including when we already hold a session with that peer, where the
268 /// refusal is non-destructive and the existing session stays live (so a
269 /// `secure_session_failed` alongside this does *not* mean the working
270 /// session ended).
271 ///
272 /// The fourth is different in kind and needs a different response:
273 ///
274 /// 4. a roster read skipping a leaf **already seated in local group
275 /// state**. No wire gate can have admitted it, so it arrived by a direct
276 /// write to this device's secure store or via a group joined by a build
277 /// predating those gates. Such a leaf is kept out of every roster and
278 /// cannot speak — but it holds live group secrets and reads everything
279 /// sent to the group, which no later refusal undoes. The remedy is to
280 /// abandon the group, not to evict a member of it.
281 ///
282 /// Apps should treat a group that produces this as untrusted for
283 /// attribution — a message shown as coming from a member is exactly what
284 /// the forged leaf was for.
285 GroupLeafIdentityUnproven,
286 /// A control frame was refused for what its signature says about *when*:
287 /// stamped further in the past than this node accepts, further ahead of
288 /// this node's clock than it accepts, or carrying the older signing
289 /// payload from a peer that has proved it can produce the newer one.
290 ///
291 /// The three share a code because they share a remedy, and the remedy is
292 /// the first thing to check: **this device's own clock**. The signed
293 /// timestamp is compared against it, so a device whose clock is wrong
294 /// refuses honest peers in bulk, and the event stream then looks like a
295 /// coordinated attack rather than a local fault. A run of these naming
296 /// *many different* peers is a clock; a run naming *one* peer while others
297 /// are fine is that peer, either replaying captures or running a broken
298 /// build.
299 ///
300 /// It is the fresh-frame counterpart to
301 /// [`Self::UnsignedControlRejected`]: both refuse a frame whose signature
302 /// proves less than this node requires, one for what it omits and one for
303 /// what it fails to state. Recovery of last resort is
304 /// `security.control_freshness_enforced = false`, which returns this node
305 /// to accepting frames of any age (see issue 403 for what that gives back
306 /// to an attacker).
307 StaleControlFrame,
308 /// A gateway echoed an `AddressDeclared` naming an address that is not
309 /// this device's.
310 ///
311 /// The gateway twin of [`Self::RelayAddressBindingMismatch`], and it has
312 /// the same absence of a benign reading: the gateway verifies that the
313 /// declared address derives from the key that signed the proof, so an
314 /// echo naming anything else means it bound what it did not verify. A
315 /// session attached under an address this device does not control is how
316 /// another device's inbound traffic gets drawn here, and how the presence
317 /// answers given about that address are poisoned.
318 ///
319 /// Reported and not acted on, for the reason the relay case gives: a
320 /// gateway that owns the socket already owns everything a local teardown
321 /// would protect. What the bridges do act on is narrower and is not this
322 /// signal: they refuse to offer a carrier whose session was never bound.
323 GatewayAddressBindingMismatch,
324 /// A gateway refused this connection's address declaration.
325 ///
326 /// Unlike the relay case ([`Self::RelayAddressDeclarationRefused`]), this
327 /// is not a degraded-but-working connection. The relay keeps delivering
328 /// on established sessions in account-name space; a gateway has no such
329 /// space, so an unproved session can only be told verdicts and is never
330 /// registered as a recipient. The bridge therefore never reports the
331 /// carrier available, and this warning explains a transport that stays
332 /// down while its socket connects fine.
333 GatewayAddressDeclarationRefused,
334}
335
336impl SecurityWarningCode {
337 /// Returns the stable machine-readable reason code.
338 pub fn as_str(&self) -> &'static str {
339 match self {
340 Self::SenderAddressMismatch => "SENDER_ADDRESS_MISMATCH",
341 Self::TransportIdentityMismatch => "TRANSPORT_IDENTITY_MISMATCH",
342 Self::ControlSignatureInvalid => "CONTROL_SIGNATURE_INVALID",
343 Self::UnsignedControlRejected => "UNSIGNED_CONTROL_REJECTED",
344 Self::MediaSenderGroupMismatch => "MEDIA_SENDER_GROUP_MISMATCH",
345 Self::PlaintextSend => "PLAINTEXT_SEND",
346 Self::PlaintextReceiveRejected => "PLAINTEXT_RECEIVE_REJECTED",
347 Self::SessionSenderGroupMismatch => "SESSION_SENDER_GROUP_MISMATCH",
348 Self::SessionRekeyTriggered => "SESSION_REKEY_TRIGGERED",
349 Self::NostrKeyPackageSlotExhausted => "NOSTR_KEY_PACKAGE_SLOT_EXHAUSTED",
350 Self::PushKeyPackagePoolExhausted => "PUSH_KEY_PACKAGE_POOL_EXHAUSTED",
351 Self::RelayAddressBindingMismatch => "RELAY_ADDRESS_BINDING_MISMATCH",
352 Self::RelayAddressDeclarationRefused => "RELAY_ADDRESS_DECLARATION_REFUSED",
353 Self::GroupLeafIdentityUnproven => "GROUP_LEAF_IDENTITY_UNPROVEN",
354 Self::StaleControlFrame => "STALE_CONTROL_FRAME",
355 Self::GatewayAddressBindingMismatch => "GATEWAY_ADDRESS_BINDING_MISMATCH",
356 Self::GatewayAddressDeclarationRefused => "GATEWAY_ADDRESS_DECLARATION_REFUSED",
357 }
358 }
359}
360
361/// Machine-readable reason for DORS selection or switch (observability).
362#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
363#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
364pub enum DorsReasonCode {
365 /// First selection; no previous transport.
366 InitialSelection,
367 /// DORS selected primary (before send attempt).
368 PrimarySelected,
369 /// Primary send succeeded; active transport is now primary.
370 PrimarySuccess,
371 /// Primary failed; fallback send succeeded.
372 FallbackSuccess,
373 /// BLE → WiFi fallback succeeded (escalation applied).
374 EscalationApplied,
375 /// Previous transport unavailable; switched to best available.
376 CurrentUnavailable,
377}
378
379impl DorsReasonCode {
380 /// Returns the stable machine-readable reason code.
381 pub fn as_str(&self) -> &'static str {
382 match self {
383 Self::InitialSelection => "INITIAL_SELECTION",
384 Self::PrimarySelected => "PRIMARY_SELECTED",
385 Self::PrimarySuccess => "PRIMARY_SUCCESS",
386 Self::FallbackSuccess => "FALLBACK_SUCCESS",
387 Self::EscalationApplied => "ESCALATION_APPLIED",
388 Self::CurrentUnavailable => "CURRENT_UNAVAILABLE",
389 }
390 }
391}
392
393/// Phase of DORS escalation: recommendation (trigger boundary) vs actual transition (applied).
394#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
395#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
396pub enum DorsEscalationPhase {
397 /// DORS decided to escalate (typed trigger reason); fallback may not succeed.
398 Triggered,
399 /// BLE→WiFi fallback send succeeded; escalation was applied.
400 Applied,
401}
402
403impl DorsEscalationPhase {
404 /// Returns the stable string representation (TRIGGERED / APPLIED).
405 pub fn as_str(&self) -> &'static str {
406 match self {
407 Self::Triggered => "TRIGGERED",
408 Self::Applied => "APPLIED",
409 }
410 }
411}
412
413/// Machine-readable reason for DORS escalation (BLE→Wi‑Fi) observability.
414#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
415#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
416pub enum DorsEscalationReasonCode {
417 /// Fallback to WiFi succeeded after primary (BLE) send failed.
418 FallbackSuccess,
419 /// Escalation suggested due to retry threshold (e.g. ble_to_wifi_retry_threshold).
420 RetryThreshold,
421 /// Escalation suggested due to sustained poor BLE signal.
422 PoorSignal,
423 /// Escalation suggested due to congestion.
424 Congestion,
425 /// Escalation suggested due to low TTL on messages.
426 LowTtl,
427 /// Escalation suggested due to BLE success rate below configured minimum (quality degradation).
428 LowSuccessRate,
429}
430
431impl DorsEscalationReasonCode {
432 /// Returns the stable machine-readable reason code.
433 pub fn as_str(&self) -> &'static str {
434 match self {
435 Self::FallbackSuccess => "FALLBACK_SUCCESS",
436 Self::RetryThreshold => "RETRY_THRESHOLD",
437 Self::PoorSignal => "POOR_SIGNAL",
438 Self::Congestion => "CONGESTION",
439 Self::LowTtl => "LOW_TTL",
440 Self::LowSuccessRate => "LOW_SUCCESS_RATE",
441 }
442 }
443}
444
445/// Machine-readable reason taxonomy for inbound decryption failures.
446#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
447#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
448pub enum DecryptionFailureCode {
449 /// Incoming encrypted payload is malformed and cannot be parsed.
450 InvalidPayload,
451 /// MLS subsystem is not initialized locally.
452 NotInitialized,
453 /// Ciphertext is invalid or cannot be decrypted.
454 InvalidCiphertext,
455 /// Sender identity/signature verification failed.
456 IdentityMismatch,
457 /// Cryptographic operation failed.
458 CryptoFailure,
459 /// An encrypted message — a text frame or a media chunk — was evicted from
460 /// the pending-decryption queue (overflow or TTL expiry, or it aged out of
461 /// the persisted queue across restarts) before the sender's session became
462 /// ready. For a media chunk the file transfer it belongs to is currently
463 /// stalled; for a text message the message itself is gone locally.
464 ///
465 /// Under the deferred-ACK model this is **advisory, not terminal**: the
466 /// evicted frame was never ACKed, so a sender still retrying resends it and
467 /// a later resend re-enters the queue and can still complete once the
468 /// session confirms. Treat this as "at risk, may need a resend", not
469 /// "permanently failed" — the terminal failure signal for media is
470 /// `FileReceiveFailed`. It is emitted for text as well as for chunks: a
471 /// relay that pushes ciphertext without store-and-forward has no second
472 /// copy, so a silent text eviction was a lost message with no signal to the
473 /// app. The same is true of a hard decrypt failure while
474 /// `crypto_recovery_enabled`, which surfaces under its own code but is
475 /// equally un-ACKed and equally recoverable by a resend; see the
476 /// `MessageDecryptionFailed` docs.
477 PendingQueueDropped,
478 /// Failure class is unknown.
479 Unknown,
480}
481
482/// Forwarding attribution in events (mirrors `ForwardInfo` from core).
483#[derive(Debug, Clone, Serialize, Deserialize)]
484pub struct ForwardInfoEvent {
485 /// The original sender of the message.
486 pub original_sender: String,
487 /// The original message ID.
488 pub original_message_id: String,
489 /// The original timestamp (wall-clock ms).
490 pub original_timestamp: i64,
491 /// Number of times this message has been forwarded.
492 pub forward_count: u32,
493}
494
495impl From<&offline_protocol_core::ForwardInfo> for ForwardInfoEvent {
496 fn from(fwd: &offline_protocol_core::ForwardInfo) -> Self {
497 Self {
498 original_sender: fwd.original_sender.as_str().to_string(),
499 original_message_id: fwd.original_message_id.as_str(),
500 original_timestamp: fwd.original_timestamp.as_millis(),
501 forward_count: fwd.forward_count,
502 }
503 }
504}
505
506/// Quoted-reply context in events (mirrors `ReplyContext` from core).
507#[derive(Debug, Clone, Serialize, Deserialize)]
508pub struct ReplyContextEvent {
509 /// Sender of the message being replied to.
510 pub sender: String,
511 /// Text (or excerpt) of the message being replied to.
512 pub text: String,
513 /// Timestamp of the quoted message (wall-clock ms).
514 #[serde(default, skip_serializing_if = "Option::is_none")]
515 pub timestamp: Option<i64>,
516 /// Short human-readable label for quoted media (e.g. a file name).
517 #[serde(default, skip_serializing_if = "Option::is_none")]
518 pub reply_media_label: Option<String>,
519 /// Content type of the quoted message, as a display string.
520 #[serde(default, skip_serializing_if = "Option::is_none")]
521 pub reply_content_type: Option<String>,
522}
523
524impl From<&offline_protocol_core::ReplyContext> for ReplyContextEvent {
525 fn from(rc: &offline_protocol_core::ReplyContext) -> Self {
526 Self {
527 sender: rc.sender.as_str().to_string(),
528 text: rc.text.clone(),
529 timestamp: rc.timestamp.map(|t| t.as_millis()),
530 reply_media_label: rc.reply_media_label.clone(),
531 reply_content_type: rc.reply_content_type.clone(),
532 }
533 }
534}
535
536/// One device's verified claim to a username.
537///
538/// Every field here has already passed verification: the address derives from
539/// the public key, the record's signature verifies under that key, and the
540/// record was published under the Nostr key it names. What that proves is
541/// narrow and worth stating exactly — **this key asserts this name**. It does
542/// not prove the name belongs to the claimant, because nothing can: the
543/// directory is non-authoritative by design.
544#[derive(Clone, Debug, Serialize, Deserialize)]
545pub struct UsernameClaim {
546 /// The claimed address, in canonical `off1…` form.
547 ///
548 /// This is the value to keep. An app that stores the *name* has stored
549 /// something anyone can re-claim tomorrow; the address is self-certifying.
550 pub address: String,
551 /// The Ed25519 identity key the address derives from, base64.
552 pub public_key: String,
553 /// When the claimant signed the record, in milliseconds since the epoch.
554 ///
555 /// **Advisory.** A record is not a liveness signal — the key-package fetch
556 /// that follows it is. An old claim from a peer who has been offline for a
557 /// month is still perfectly valid, and rejecting it here would make them
558 /// unreachable by name while their key packages sit live on a relay. Sort
559 /// by it if that helps a user choose; do not filter on it.
560 pub issued_at_ms: i64,
561}
562
563/// Who removed a replicated document, on [`Event::DataDocRemoved`].
564///
565/// A fixed token this crate chooses, never a peer's words, which is what
566/// lets the telemetry scrubber pass it through unhashed. Serialises as
567/// `local` or `peer`, which is the contract every binding types against.
568#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
569#[serde(rename_all = "lowercase")]
570pub enum DocRemovedBy {
571 /// This device removed it.
572 Local,
573 /// A removal learned from the space.
574 Peer,
575}
576
577impl fmt::Display for DocRemovedBy {
578 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
579 f.write_str(match self {
580 Self::Local => "local",
581 Self::Peer => "peer",
582 })
583 }
584}
585
586/// Events that can occur in the protocol.
587///
588/// Note: This type implements a custom Debug that redacts sensitive fields
589/// (message content) to prevent accidental logging of sensitive data.
590#[derive(Clone, Serialize, Deserialize)]
591#[serde(tag = "type", rename_all = "snake_case")]
592pub enum Event {
593 /// A message was sent (queued for delivery).
594 MessageSent {
595 /// ID of the sent message.
596 message_id: String,
597 /// Sender's user ID.
598 sender: String,
599 /// Recipient's user ID.
600 recipient: String,
601 /// Content of the sent message.
602 content: String,
603 /// Priority of the message when sent.
604 priority: String,
605 /// Whether the message requires an acknowledgement.
606 requires_ack: bool,
607 /// When the message was queued for delivery (wall-clock, for display).
608 timestamp: i64,
609 /// Lamport logical clock value for causal ordering.
610 #[serde(default)]
611 lamport_clock: u64,
612 /// Forwarding attribution (present when this is a forwarded message).
613 #[serde(default, skip_serializing_if = "Option::is_none")]
614 forward_info: Option<ForwardInfoEvent>,
615 },
616
617 /// A message was received.
618 MessageReceived {
619 /// ID of the received message.
620 message_id: String,
621 /// Sender's user ID.
622 sender: String,
623 /// Recipient's user ID.
624 recipient: String,
625 /// Message content.
626 content: String,
627 /// Number of hops the message traversed.
628 hop_count: u8,
629 /// Transport used for final delivery.
630 transport: String,
631 /// When the message was received (wall-clock, for display).
632 timestamp: i64,
633 /// Lamport logical clock value for causal ordering.
634 #[serde(default)]
635 lamport_clock: u64,
636 /// ID of the message this is replying to (optional).
637 #[serde(skip_serializing_if = "Option::is_none")]
638 reply_to_msg: Option<String>,
639 /// Quoted-reply context (present when this message quotes another).
640 /// Boxed to keep the `Event` enum's by-value size in check.
641 #[serde(default, skip_serializing_if = "Option::is_none")]
642 reply_context: Option<Box<ReplyContextEvent>>,
643 /// The type of content (text, image, video, etc.).
644 #[serde(default)]
645 content_type: String,
646 /// Media metadata (present for non-text content).
647 #[serde(default, skip_serializing_if = "Option::is_none")]
648 media_metadata: Option<MediaMetadata>,
649 /// Forwarding attribution (present when this is a forwarded message).
650 #[serde(default, skip_serializing_if = "Option::is_none")]
651 forward_info: Option<ForwardInfoEvent>,
652 /// `true` when the content arrived MLS-encrypted and was decrypted by
653 /// this node; `false` for plaintext accepted under the
654 /// `require_encryption = false` opt-out.
655 #[serde(default)]
656 encrypted: bool,
657 },
658
659 /// A message was successfully delivered (ACK received).
660 MessageDelivered {
661 /// ID of the delivered message.
662 message_id: String,
663 /// Latency in milliseconds.
664 latency_ms: u64,
665 /// Number of hops traversed.
666 hop_count: u8,
667 /// Transport used.
668 transport: String,
669 },
670
671 /// A message failed to deliver.
672 MessageFailed {
673 /// ID of the failed message.
674 message_id: String,
675 /// Reason for failure.
676 reason: String,
677 /// Number of retries attempted.
678 retry_count: u32,
679 },
680
681 /// An inbound encrypted message failed to decrypt on this attempt.
682 ///
683 /// **Advisory, not terminal, and emitted once per failed *attempt*.** While
684 /// `EncryptionConfig::crypto_recovery_enabled` is on (the default) a
685 /// message that fails to decrypt is not delivery-ACKed, so the sender keeps
686 /// retrying — and each resend of a DM is re-sealed against the peer's
687 /// current session, so a resend can actually land. Every failing attempt
688 /// reports again, bounded by the sender's ACK retry budget. Read this as
689 /// "this attempt did not decrypt"; the terminal signals are
690 /// [`Event::MessageFailed`] on the sender and `FileReceiveFailed` for
691 /// media.
692 MessageDecryptionFailed {
693 /// ID of the message that could not be decrypted.
694 message_id: String,
695 /// Sender's user ID.
696 sender: String,
697 /// Machine-readable decryption failure code.
698 code: DecryptionFailureCode,
699 /// Clear failure reason for application handling/logging.
700 reason: String,
701 },
702
703 /// Transport was switched by DORS.
704 TransportSwitched {
705 /// Previous transport (if any).
706 from: Option<String>,
707 /// New transport.
708 to: String,
709 /// Reason for switch.
710 reason: String,
711 },
712
713 /// This device started acting as a relay — carrying traffic for other
714 /// devices that cannot reach each other directly.
715 ///
716 /// Reports observed behaviour, not capability: it fires because this
717 /// device has been forwarding, so it needs no battery feed and can fire on
718 /// a device that has never reported a level.
719 RelayPromoted {
720 /// Neighbors held when this device started relaying.
721 connection_count: usize,
722 /// Battery level at the time, or `None` when the host has reported
723 /// none — the standing does not depend on it.
724 battery_level: Option<u8>,
725 },
726
727 /// This device was demoted from relay role.
728 RelayDemoted {
729 /// Reason for demotion.
730 reason: String,
731 },
732
733 /// A new neighbor was discovered.
734 NeighborDiscovered {
735 /// The peer's canonical user id — the same value the peer supplied
736 /// as `ProtocolConfig.user_id`, on every transport. Valid directly
737 /// as the `recipient` of `send_message` / `send_connection_request`.
738 peer_id: String,
739 /// Transport used to discover.
740 transport: String,
741 /// RSSI signal strength (if available).
742 rssi: Option<i16>,
743 },
744
745 /// A neighbor was lost (disconnected).
746 NeighborLost {
747 /// Peer ID of the lost neighbor.
748 peer_id: String,
749 },
750
751 /// This device's own address is known: MLS storage was opened and the
752 /// identity key in it derived to `address`.
753 ///
754 /// Fires once per successful `initialize_mls`, before any frame can be
755 /// sent. `address` is this device's `Message.sender` from here on, and what
756 /// peers must be given to reach it. It is stable across restarts of the
757 /// same profile, so an app that already stored it will be handed the same
758 /// value rather than a new one.
759 IdentityReady {
760 /// This device's self-certifying address (`off1…`).
761 address: String,
762 },
763
764 /// Network metrics update.
765 NetworkMetrics {
766 /// Number of active neighbors.
767 neighbor_count: usize,
768 /// Number of active relays.
769 relay_count: usize,
770 /// Message delivery ratio (0.0-1.0).
771 delivery_ratio: f32,
772 /// Average message latency in milliseconds.
773 avg_latency_ms: u64,
774 },
775
776 /// File transfer progress update.
777 FileProgress {
778 /// File identifier.
779 file_id: String,
780 /// Number of chunks sent so far.
781 chunks_sent: u32,
782 /// Total number of chunks.
783 total_chunks: u32,
784 /// Progress percentage (0-100).
785 percentage: u8,
786 },
787
788 /// File was completely received.
789 FileReceived {
790 /// File identifier.
791 file_id: String,
792 /// File name.
793 file_name: String,
794 /// File size in bytes.
795 file_size: u64,
796 /// Sender's user ID.
797 sender: String,
798 /// The content type of the media (image, video, file, etc.).
799 content_type: String,
800 /// Media metadata from the first chunk (present for typed media).
801 #[serde(default, skip_serializing_if = "Option::is_none")]
802 media_metadata: Option<MediaMetadata>,
803 /// Base64-encoded reassembled file data.
804 file_data: String,
805 /// When the sender queued the transfer (chunk-0 outer message
806 /// timestamp, wall-clock ms) — for display ordering alongside
807 /// `MessageReceived.timestamp`. Absent only if the chunk-0 record
808 /// was evicted before completion.
809 #[serde(default, skip_serializing_if = "Option::is_none")]
810 timestamp: Option<i64>,
811 /// Caption text from the sealed chunk-0 rich extras.
812 #[serde(default, skip_serializing_if = "Option::is_none")]
813 caption: Option<String>,
814 /// ID of the message this media replies to (sealed chunk-0 extras).
815 #[serde(default, skip_serializing_if = "Option::is_none")]
816 reply_to_msg: Option<String>,
817 /// Quoted-reply context (sealed chunk-0 extras). Boxed to keep the
818 /// `Event` enum's by-value size in check.
819 #[serde(default, skip_serializing_if = "Option::is_none")]
820 reply_context: Option<Box<ReplyContextEvent>>,
821 /// Forwarding attribution (sealed chunk-0 extras).
822 #[serde(default, skip_serializing_if = "Option::is_none")]
823 forward_info: Option<ForwardInfoEvent>,
824 },
825
826 /// An inbound file transfer was dropped before completion — the receiver
827 /// hit a resource limit (too many concurrent transfers, per-sender
828 /// quota, or the buffered-bytes budget), the reassembled file failed
829 /// its integrity checks, or the transfer went stale (no chunks within
830 /// the stale timeout). Terminal and fired at most once per transfer:
831 /// the failed transfer's remaining in-flight chunks are dropped
832 /// silently. No `FileReceived` will follow for this `file_id`; the
833 /// sender must re-send the file (under a fresh `file_id`) to retry.
834 FileReceiveFailed {
835 /// File identifier.
836 file_id: String,
837 /// File name from the chunk metadata.
838 file_name: String,
839 /// Sender's user ID.
840 sender: String,
841 /// Machine-readable reason the transfer was dropped.
842 reason: String,
843 },
844
845 /// All chunks of a media attachment were ACK-delivered.
846 MediaSent {
847 /// File identifier for tracking.
848 file_id: String,
849 /// The content type of the media.
850 content_type: String,
851 /// Recipient's user ID.
852 recipient: String,
853 },
854
855 /// An outbound media transfer was aborted before all chunks were
856 /// delivered — a chunk failed to encrypt, or a chunk failed terminally
857 /// in the outbox. No `MediaSent` will follow for this `file_id`; retry
858 /// with a new `send_media` call.
859 MediaSendFailed {
860 /// File identifier for tracking.
861 file_id: String,
862 /// Recipient's user ID.
863 recipient: String,
864 /// Reason the transfer was aborted.
865 reason: String,
866 },
867
868 /// A message was deferred due to network conditions.
869 /// The message will be retried automatically.
870 MessageDeferred {
871 /// ID of the deferred message.
872 message_id: String,
873 /// Recipient's user ID.
874 ///
875 /// Added when `reason` stopped rendering the transport error: the
876 /// counterparty used to reach apps only inside that prose, which put an
877 /// unhashed address on every telemetry record for a routine send
878 /// failure. Carried as a field so the scrubber hashes it like any
879 /// other actor id.
880 recipient: String,
881 /// Stable classification of the deferral, not a rendered error.
882 ///
883 /// One of a fixed local vocabulary (`peer_not_reachable`,
884 /// `transport_not_connected`, `transport_send_failed`, …). Branch on it
885 /// if you must, but do not parse it for detail: the underlying error —
886 /// which renders the peer — stays in the device log.
887 reason: String,
888 /// Current retry count.
889 retry_count: u32,
890 /// Next retry scheduled time (Unix timestamp ms).
891 next_retry_at: Option<i64>,
892 },
893
894 /// A retry for a previously deferred/unacked message was scheduled.
895 ///
896 /// Emitted each time the retry machinery re-queues a message after a
897 /// failed attempt (transport send error or ACK timeout). Non-terminal:
898 /// `MessageDelivered` or `MessageFailed` still settles the message.
899 MessageRetrying {
900 /// ID of the retrying message.
901 message_id: String,
902 /// Recipient's user ID.
903 recipient: String,
904 /// Retry count this schedule corresponds to.
905 retry_count: u32,
906 /// Absolute time the retry is scheduled for (Unix timestamp ms).
907 next_retry_at: i64,
908 },
909
910 /// The transport layer reported the recipient unreachable for an
911 /// in-flight message (e.g. the internet relay's DeliveryError).
912 ///
913 /// Non-terminal: the message stays in the outbox. A plain DM is
914 /// *parked* — its ACK retry budget stops burning while the peer is
915 /// provably offline — and is re-driven on reachability edges (transport
916 /// reconnect, peer discovery, presence-online; the SDK adds parked
917 /// recipients to the presence watchlist). It settles only via
918 /// `MessageDelivered` or, at outbox-lifetime expiry, `MessageFailed`.
919 /// Media chunks are not parked and keep the normal retry machinery.
920 /// May fire multiple times for the same `message_id` while the
921 /// recipient remains offline: a parked DM keeps an escalating
922 /// reachability probe on every carrier (15s → 600s cap), and each probe
923 /// that reaches the relay while the peer is still offline earns a fresh
924 /// verdict and re-emits this event. Apps must treat it as a repeatable
925 /// status signal, never as a terminal one. `file_id` is set when the
926 /// message is a chunk of an outbound media transfer.
927 MessageUndeliverable {
928 /// ID of the affected message.
929 message_id: String,
930 /// Recipient's user ID.
931 recipient: String,
932 /// Stable classification of the failure — `recipient_unreachable` on
933 /// the relay-verdict path this event is normally reached by.
934 ///
935 /// The transport's own wording used to be appended here verbatim, and
936 /// on the relay path that wording is written by the relay: unbounded
937 /// remote text, shipped beside a `recipient` the telemetry scrubber
938 /// hashes. It is now dropped at the boundary — kept in the device log,
939 /// and on the internet path still delivered raw to apps on the
940 /// bridges' `diagnostic` channel.
941 reason: String,
942 /// Owning media transfer when the message is a media chunk.
943 #[serde(default, skip_serializing_if = "Option::is_none")]
944 file_id: Option<String>,
945 },
946
947 /// An outbound media transfer was in flight when the previous process
948 /// died. The SDK persists only the transfer descriptor (never chunk
949 /// bytes), so it cannot resume the transfer itself: the app must
950 /// re-supply the file bytes via `send_media` with this `file_id` —
951 /// they are checksum-validated against the original transfer.
952 MediaResendRequired {
953 /// File identifier of the interrupted transfer.
954 file_id: String,
955 /// Recipient's user ID.
956 recipient: String,
957 /// Original file name.
958 file_name: String,
959 /// Total file size in bytes.
960 file_size: u64,
961 },
962
963 /// A pending ACK was evicted due to capacity constraints.
964 AckEvicted {
965 /// ID of the message whose ACK was evicted.
966 message_id: String,
967 /// Priority of the evicted message.
968 priority: String,
969 /// Reason for eviction.
970 reason: String,
971 },
972
973 /// A fragment assembly was evicted to make room for new fragments.
974 FragmentAssemblyEvicted {
975 /// ID of the message whose fragments were evicted.
976 message_id: String,
977 /// Completion percentage when evicted.
978 completion_percent: u8,
979 /// Reason for eviction.
980 reason: String,
981 },
982
983 /// Relay role was demoted due to battery constraints.
984 RelayDemotedBattery {
985 /// Battery level at time of demotion.
986 battery_level: u8,
987 /// Minimum required battery level.
988 min_required: u8,
989 },
990
991 /// A secure MLS session was successfully established.
992 SecureSessionEstablished {
993 /// Peer ID of the other party.
994 peer_id: String,
995 /// MLS group ID for the session.
996 group_id: String,
997 /// Whether this is a 1:1 session (true) or a multi-party group (false).
998 is_session: bool,
999 /// Whether the local device initiated the session (sent the Welcome).
1000 initiated_by_local: bool,
1001 },
1002
1003 /// Failed to establish a secure MLS session.
1004 ///
1005 /// Reports a failed *handshake attempt*, which is not the same as "the
1006 /// session with this peer is gone". One case makes the difference visible:
1007 /// a Welcome refused for carrying an unprovable identity
1008 /// ([`SecurityWarningCode::GroupLeafIdentityUnproven`], emitted alongside)
1009 /// is refused non-destructively, so any session already held with that peer
1010 /// stays live and usable. Apps must not tear down session state on this
1011 /// event alone.
1012 ///
1013 /// `reason` is diagnostic text and must not be parsed. It carries no
1014 /// identifier, of this peer or of anyone else: the telemetry scrubber
1015 /// hashes `peer_id` and ships `reason` verbatim, so every arm that fills it
1016 /// contributes either a fixed string or
1017 /// [`MlsError::privacy_safe_reason`](offline_protocol_mls::MlsError::privacy_safe_reason),
1018 /// never a rendered error. The full error stays in the device log at the
1019 /// refusal site. This is a classification, so apps get the failure *class*
1020 /// here and must look to the log for the specifics.
1021 SecureSessionFailed {
1022 /// Peer ID of the other party.
1023 peer_id: String,
1024 /// Reason for the failure.
1025 reason: String,
1026 },
1027
1028 /// Diagnostic breadcrumb for the 1:1 MLS convergence (Welcome receive /
1029 /// adopt / confirm) path. Emitted so the receiver side — which otherwise
1030 /// produces no event until it fully establishes or loudly fails — is
1031 /// observable in Metro. Pure instrumentation; carries no protocol effect.
1032 ConvergenceDiag {
1033 /// Fixed stage label, e.g. "welcome_received" or "welcome_branch".
1034 stage: String,
1035 /// Peer ID this breadcrumb concerns.
1036 peer_id: String,
1037 /// Free-form `key=value` context (counts, branch taken, errors).
1038 detail: String,
1039 },
1040
1041 /// Welcome send lifecycle entered attempted state.
1042 WelcomeSendAttempted {
1043 /// Peer identifier for this welcome lifecycle.
1044 peer_id: String,
1045 /// Welcome message identifier for lifecycle correlation.
1046 message_id: String,
1047 /// MLS group identifier associated with the welcome.
1048 group_id: String,
1049 /// 1-based send attempt number.
1050 attempt: u32,
1051 },
1052
1053 /// Welcome send lifecycle reached successful sent state.
1054 WelcomeSendSucceeded {
1055 /// Peer identifier for this welcome lifecycle.
1056 peer_id: String,
1057 /// Welcome message identifier for lifecycle correlation.
1058 message_id: String,
1059 /// MLS group identifier associated with the welcome.
1060 group_id: String,
1061 /// 1-based send attempt number.
1062 attempt: u32,
1063 },
1064
1065 /// Welcome send attempt failed and may be retried.
1066 WelcomeSendFailed {
1067 /// Peer identifier for this welcome lifecycle.
1068 peer_id: String,
1069 /// Welcome message identifier for lifecycle correlation.
1070 message_id: String,
1071 /// MLS group identifier associated with the welcome.
1072 group_id: String,
1073 /// 1-based send attempt number.
1074 attempt: u32,
1075 /// Machine-readable reason code.
1076 reason_code: WelcomeReasonCode,
1077 #[serde(skip_serializing_if = "Option::is_none")]
1078 /// Stable classification of the transport failure, when one was
1079 /// recorded — never the rendered error.
1080 ///
1081 /// Both feeders named an identifier: the platform-supplied string is
1082 /// the relay's own `DeliveryError` prose, and the locally raised one
1083 /// renders a transport error that interpolates the peer. `peer_id` and
1084 /// `group_id` beside it are hashed, so either would have undone that.
1085 /// Classified at the boundary, so the persisted record holds the token
1086 /// too — and again at each emit as a backstop. The second pass is
1087 /// unreachable today (every writer overwrites the field before its
1088 /// emit, so no record restored from an older build can carry raw text
1089 /// into an event) and kept because the field is persisted and has five
1090 /// writers: it holds the invariant if one of them ever becomes
1091 /// conditional.
1092 transport_error: Option<String>,
1093 /// Whether this failure will be retried.
1094 retryable: bool,
1095 #[serde(skip_serializing_if = "Option::is_none")]
1096 /// Next scheduled retry timestamp in Unix ms.
1097 next_retry_at: Option<i64>,
1098 },
1099
1100 /// Welcome send lifecycle reached terminal expiry.
1101 WelcomeSendExpired {
1102 /// Peer identifier for this welcome lifecycle.
1103 peer_id: String,
1104 /// Welcome message identifier for lifecycle correlation.
1105 message_id: String,
1106 /// Final attempt number when lifecycle expired.
1107 attempt: u32,
1108 /// Terminal machine-readable reason code.
1109 reason_code: WelcomeReasonCode,
1110 },
1111
1112 /// A connection request was received from another user.
1113 ConnectionRequestReceived {
1114 /// User ID of the sender.
1115 sender: String,
1116 /// Display name of the sender.
1117 sender_name: String,
1118 /// Timestamp of the request (Unix ms).
1119 timestamp: i64,
1120 /// MLS key package data (if provided for encrypted session setup).
1121 #[serde(skip_serializing_if = "Option::is_none")]
1122 key_package: Option<Vec<u8>>,
1123 /// Optional first message sent along with the request.
1124 #[serde(skip_serializing_if = "Option::is_none")]
1125 initial_message: Option<String>,
1126 },
1127
1128 /// An outbound connection request could not be delivered: the transport
1129 /// reported the recipient unreachable (e.g. the relay's DeliveryError
1130 /// for an offline peer), or the request exhausted its ACK retry budget
1131 /// (in which case a generic `MessageFailed` also fires for the same
1132 /// id). This is a status signal, not proof of permanent failure — the
1133 /// original request may still be delivered by the retry machinery if
1134 /// the peer comes back online. Apps typically surface "user is
1135 /// offline"; a user-initiated resend can therefore arrive alongside the
1136 /// retried original (the recipient sees the request event again, which
1137 /// accept/reject flows already tolerate).
1138 ConnectionRequestUndeliverable {
1139 /// Recipient the request was addressed to.
1140 recipient: String,
1141 /// Message id returned by `send_connection_request`.
1142 message_id: String,
1143 /// Failure reason, drawn from a fixed local vocabulary:
1144 /// `recipient_unreachable` on the transport verdict path, or
1145 /// `max_retries_exceeded` / `outbox_lifetime_exceeded` /
1146 /// `outbox_capacity_exceeded` on the give-up paths.
1147 ///
1148 /// The relay's wording is no longer appended to the first of these —
1149 /// it is remote-chosen text, and `recipient` beside it is hashed by
1150 /// the telemetry scrubber.
1151 reason: String,
1152 },
1153
1154 /// A previously sent connection request was accepted.
1155 ConnectionAccepted {
1156 /// User ID of the accepting party.
1157 accepted_by: String,
1158 /// Display name of the accepting party.
1159 accepted_by_name: String,
1160 /// Timestamp of the acceptance (Unix ms).
1161 timestamp: i64,
1162 /// MLS key package data (if provided for encrypted session setup).
1163 #[serde(skip_serializing_if = "Option::is_none")]
1164 key_package: Option<Vec<u8>>,
1165 },
1166
1167 /// A previously sent connection request was rejected.
1168 ConnectionRejected {
1169 /// User ID of the rejecting party.
1170 rejected_by: String,
1171 },
1172
1173 /// A previously sent connection request was cancelled by the sender.
1174 ConnectionRequestCancelled {
1175 /// User ID of the party who cancelled their request.
1176 cancelled_by: String,
1177 },
1178
1179 // --- Group (relay) events ---
1180 /// A group was created (from relay).
1181 GroupCreated {
1182 /// Group identifier.
1183 group_id: String,
1184 /// Human-readable group name.
1185 name: String,
1186 },
1187
1188 /// A message was received in a group (from relay).
1189 GroupMessageReceived {
1190 /// Group identifier.
1191 group_id: String,
1192 /// User ID of the sender.
1193 sender: String,
1194 /// Message content.
1195 content: String,
1196 /// ISO-8601 timestamp.
1197 timestamp: String,
1198 /// Unique message identifier.
1199 message_id: String,
1200 /// Optional reply-to message ID.
1201 #[serde(default, skip_serializing_if = "Option::is_none")]
1202 reply_to_msg: Option<String>,
1203 /// Forwarding attribution (present when this is a forwarded message).
1204 #[serde(default, skip_serializing_if = "Option::is_none")]
1205 forward_info: Option<ForwardInfoEvent>,
1206 /// Media metadata restored from the sealed `__RICH_V1__` body of a
1207 /// rich group message (cloud attachments — including the
1208 /// `encryption_key`/`iv` secrets, which only ever travel inside the
1209 /// group MLS ciphertext). Absent on plain group messages.
1210 #[serde(default, skip_serializing_if = "Option::is_none")]
1211 media_metadata: Option<MediaMetadata>,
1212 /// Content-type rendering hint from the sealed body (text, image,
1213 /// video, ...). Absent on plain group messages.
1214 #[serde(default, skip_serializing_if = "Option::is_none")]
1215 content_type: Option<String>,
1216 },
1217
1218 /// A member was added to a group (from relay).
1219 GroupMemberAdded {
1220 /// Group identifier.
1221 group_id: String,
1222 /// User ID of the added member.
1223 user_id: String,
1224 /// User ID of who performed the add.
1225 added_by: String,
1226 /// Human-readable group name (present when the invitee first joins).
1227 #[serde(skip_serializing_if = "Option::is_none")]
1228 group_name: Option<String>,
1229 /// Whether `added_by` was authorized to make this change.
1230 ///
1231 /// `Some(false)` means the change **did** happen — MLS accepted the
1232 /// commit and the roster really has changed — but the committer was
1233 /// not a known admin. The membership event is still emitted because
1234 /// the local roster must not diverge from MLS state; see
1235 /// [`Event::GroupUnauthorizedMembershipChange`] for the full signal.
1236 ///
1237 /// `None` (absent in JSON) means authorization was **not evaluated**
1238 /// on the path that produced this event: our own join accepted from
1239 /// a Welcome (there is no prior group state to judge the inviter
1240 /// against) and relay reconciliation frames (no authenticated
1241 /// committer to judge). Events from an older core also omit the
1242 /// field. Only `Some(_)` is a positive statement either way.
1243 ///
1244 /// The judgment is made against this member's **local replica** of
1245 /// role state, which replicates best-effort and can lag — a
1246 /// legitimate change may be flagged `Some(false)`, and different
1247 /// members can disagree about the same commit. Do not act on it
1248 /// automatically.
1249 #[serde(default, skip_serializing_if = "Option::is_none")]
1250 authorized: Option<bool>,
1251 },
1252
1253 /// A member was removed from a group (from relay).
1254 GroupMemberRemoved {
1255 /// Group identifier.
1256 group_id: String,
1257 /// User ID of the removed member.
1258 user_id: String,
1259 /// User ID of who performed the removal.
1260 removed_by: String,
1261 /// Whether this change was authorized (`None` = not evaluated on
1262 /// this path). See [`Event::GroupMemberAdded::authorized`]. On the
1263 /// relay reconciliation path the judgment applies to the frame's
1264 /// authenticated wire sender, which is not necessarily the
1265 /// `removed_by` named here.
1266 #[serde(default, skip_serializing_if = "Option::is_none")]
1267 authorized: Option<bool>,
1268 },
1269
1270 /// Group info was received (from relay).
1271 GroupInfo {
1272 /// Group identifier.
1273 group_id: String,
1274 /// Human-readable group name.
1275 name: String,
1276 /// User ID of the group creator.
1277 created_by: String,
1278 /// ISO-8601 creation timestamp.
1279 created_at: String,
1280 /// Group member list.
1281 members: Vec<GroupInfoMember>,
1282 },
1283
1284 /// User's groups list was received (from relay).
1285 UserGroups {
1286 /// List of group summaries.
1287 groups: Vec<UserGroupSummary>,
1288 },
1289
1290 /// A group operation failed (from relay).
1291 ///
1292 /// The relay's own wording is **not** carried here. `__GROUP_ERROR__` is
1293 /// a relay answer, accepted unsigned on the relay ingest path (the
1294 /// bridges inject it unattributed), so its text is chosen by whoever put
1295 /// the frame on that socket — arbitrary content, arbitrary length, and
1296 /// not something this SDK can vouch for. `reason` is a fixed code
1297 /// classified locally instead; the
1298 /// relay's exact text is logged on-device and reaches apps, when they
1299 /// want it, as the raw `GroupError` frame the bridges dual-emit on the
1300 /// server-message channel.
1301 GroupError {
1302 /// Why the operation failed, from a fixed set: `not_found` (the
1303 /// relay has no such group — invite links and relay fan-out for it
1304 /// are dead, not merely refused), `sync_denied` (the relay refused
1305 /// to register or sync the group for this caller), or `error` (any
1306 /// other refusal).
1307 reason: String,
1308 /// Group the error concerns, when the relay scoped it. Absent for
1309 /// unscoped errors.
1310 ///
1311 /// This is the group-scoping the free-text `reason` used to smuggle
1312 /// past the telemetry scrubber — as a real field it is hashed like
1313 /// every other `group_id` instead.
1314 #[serde(default, skip_serializing_if = "Option::is_none")]
1315 group_id: Option<String>,
1316 },
1317
1318 /// The relay-side registration state of a group changed.
1319 ///
1320 /// `synced: true` fires only on the relay's positive registration
1321 /// acknowledgment (a `GroupCreated` answering a registration this
1322 /// device actually sent, over the Internet transport) — including the
1323 /// idempotent re-sync ack after a membership change. It is the signal
1324 /// that relay-dependent server commands for the group (invite links,
1325 /// server-side fan-out) can be issued. `synced: false` fires when that
1326 /// trust is revoked; `reason` says why. Not emitted for groups the
1327 /// relay was never asked about.
1328 GroupRelaySyncChanged {
1329 /// MLS group identifier.
1330 group_id: String,
1331 /// Whether the relay currently holds a positively acknowledged
1332 /// registration for this group.
1333 synced: bool,
1334 /// Why the state changed: `registered` (relay ack), `error`
1335 /// (group-scoped relay error), `removed` (we were removed from the
1336 /// group), `left` (local leave), `internet_dropped` (Internet
1337 /// transport lost; re-registration re-arms on reconnect), or
1338 /// `ack_timeout` (relay never answered the registration — likely a
1339 /// relay without group support).
1340 reason: String,
1341 },
1342
1343 /// A username resolution finished, carrying **every** claim found.
1344 ///
1345 /// # The set is the whole point, and there is deliberately no "best" claim
1346 ///
1347 /// Anyone may publish any username claim, so a name resolves to a set of
1348 /// assertions, never to an answer. Even a single-device user is a set of
1349 /// one. This event is emitted exactly once per resolution and carries the
1350 /// complete set precisely so an app cannot accidentally treat the first
1351 /// arrival as the winner: there is no per-claim event to race, and no
1352 /// ordering field to mistake for a ranking.
1353 ///
1354 /// **An app MUST let the user choose.** Silently picking one claim converts
1355 /// a non-authoritative directory into an authoritative-looking one, which
1356 /// is worse than not shipping the feature: the user believes they are
1357 /// talking to the name, and the protocol only ever promised them a key.
1358 /// Present the claims, let a human confirm out of band (a QR code, a
1359 /// shared secret, a voice), and remember the address rather than the name.
1360 ///
1361 /// An empty `claims` list is an ordinary outcome: the name may be
1362 /// unclaimed, the claimant offline-published to relays we do not use, or
1363 /// every claim we saw failed verification.
1364 UsernameResolved {
1365 /// The normalized username that was resolved.
1366 username: String,
1367 /// Every claim that verified, in no meaningful order.
1368 claims: Vec<UsernameClaim>,
1369 /// How many records were seen but rejected.
1370 ///
1371 /// Non-zero is *normal*, not an error: the tag is public, anyone may
1372 /// publish to it, and junk arrives. Surfaced because a resolution that
1373 /// returns nothing while rejecting many records means something
1374 /// different from one that returns nothing having seen nothing, and an
1375 /// app showing "not found" may want to distinguish them.
1376 rejected: u32,
1377 /// How many *verified* claims were dropped at the accumulator's
1378 /// ceiling.
1379 ///
1380 /// Separate from `rejected` because it is the opposite statement: these
1381 /// records were good and are missing anyway. Without it a crowded name
1382 /// renders exactly like an uncrowded one, a clean set with nothing
1383 /// refused, which hides the single signal that a name is being
1384 /// squatted at volume. Non-zero means `claims` is a sample and no
1385 /// absence in it can be trusted.
1386 truncated: u32,
1387 },
1388
1389 /// A group message was sent to all members via mesh (MLS-encrypted fan-out).
1390 GroupMessageSent {
1391 /// MLS group identifier.
1392 group_id: String,
1393 /// Per-member message IDs from fan-out.
1394 message_ids: Vec<String>,
1395 /// Number of members the message was sent to.
1396 member_count: u32,
1397 },
1398
1399 /// A group message was only partially delivered (some members failed).
1400 GroupMessagePartialFailure {
1401 /// MLS group identifier.
1402 group_id: String,
1403 /// Members for whom send failed.
1404 failed_members: Vec<String>,
1405 /// Members for whom send succeeded.
1406 succeeded_members: Vec<String>,
1407 },
1408
1409 /// The relay's settled per-recipient delivery report for a group
1410 /// message sent via relay broadcast, after the SDK acted on it.
1411 ///
1412 /// Fires once per broadcast whose report arrived, seconds after
1413 /// `group_message_sent` (the relay reports only when its whole fan-out
1414 /// has settled — correlate by `message_id`, never by order). Members in
1415 /// `delivered` took the message over a live relay socket; members in
1416 /// `pushed` took a device push carrying the ciphertext. Every other
1417 /// MLS roster member — the ones the relay reported missed *and* the
1418 /// ones it did not know about at all — has already been re-sent a
1419 /// per-member copy through the ordinary outbox/ACK/park delivery
1420 /// ladder (`missed_reissued`), so no app action is required; the event
1421 /// is delivery observability, not a failure signal. Not emitted when
1422 /// the report itself is lost — the SDK then re-broadcasts (bounded)
1423 /// and finally downgrades the whole message to per-member fan-out,
1424 /// which reports per-member like any mesh send.
1425 GroupMessageDeliveryReport {
1426 /// MLS group identifier.
1427 group_id: String,
1428 /// The logical group message id, as returned by the send and echoed
1429 /// by the relay.
1430 message_id: String,
1431 /// Members whose relay socket write was confirmed (a relay-side
1432 /// write-ack, not an end-to-end delivery ACK).
1433 delivered: Vec<String>,
1434 /// Members offline at the relay who took a device push.
1435 pushed: Vec<String>,
1436 /// Members re-sent a per-member copy because the relay reached them
1437 /// neither way (or never knew them).
1438 missed_reissued: Vec<String>,
1439 },
1440
1441 /// Rich media metadata was dropped from an outbound group message
1442 /// because the group is not fully rich-capable (not every member
1443 /// advertised sealed rich payload support, or the local kill switch
1444 /// disabled it). The text was still sent; members receive it without
1445 /// the media attachment. Apps can use this to warn the sender that an
1446 /// attachment did not go through.
1447 GroupRichExtrasDropped {
1448 /// MLS group identifier.
1449 group_id: String,
1450 /// Members not known to parse the sealed rich payload — the ones
1451 /// holding the seal gate closed. Absence of knowledge and known
1452 /// non-support are indistinguishable here. Empty when the drop was
1453 /// caused by the local `rich_payload_enabled` kill switch instead.
1454 /// The SDK probes these members for their capability automatically;
1455 /// apps can retry the send once a later attempt stops dropping.
1456 unknown_members: Vec<String>,
1457 },
1458
1459 /// An MLS membership change was applied that its committer was not
1460 /// authorized to make.
1461 ///
1462 /// The change **has been applied** — MLS authenticated the committer as a
1463 /// group member and accepted the commit, and the local epoch advanced with
1464 /// it. The SDK's admin model is an application-layer overlay on top of MLS
1465 /// (RFC 9420 leaves membership access control to the application), enforced
1466 /// on the *sending* side; refusing the change on receipt would mean
1467 /// refusing the merge, which permanently forks this member away from every
1468 /// peer that accepted it. So the change stands and is reported here instead.
1469 ///
1470 /// **This signal can false-positive.** "Unauthorized" is judged against
1471 /// this member's local replica of role state, which replicates
1472 /// best-effort (a joiner receives a point-in-time snapshot, role changes
1473 /// ride unreconciled mesh notifications) and can lag — so a legitimate
1474 /// change, e.g. a voluntary leave committed by a member whose admin role
1475 /// this device has not yet learned, may be reported here, and different
1476 /// members can disagree about the same commit.
1477 ///
1478 /// Apps should treat this as a moderation signal for a *human* admin: an
1479 /// admin can undo the change with `remove_from_group` /
1480 /// `invite_to_group`. Never reverse a change automatically off a single
1481 /// member's event — corroborate first. A member added this way can read
1482 /// all subsequent group traffic until removed.
1483 ///
1484 /// Reports are rate-limited per `(group, committer)`: a repeat within a
1485 /// short window is not re-emitted (divergent role metadata would
1486 /// otherwise re-fire it on every commit), but every affected
1487 /// [`Event::GroupMemberAdded`] / [`Event::GroupMemberRemoved`] still
1488 /// carries `authorized: Some(false)`.
1489 ///
1490 /// Known limitation: the member *removed* by an unauthorized Remove does
1491 /// not receive this event — it can no longer decrypt the commit that
1492 /// removed it, and it refuses to trust a non-admin's unencrypted claim
1493 /// that it was removed. Only the remaining members report the removal.
1494 GroupUnauthorizedMembershipChange {
1495 /// MLS group identifier.
1496 group_id: String,
1497 /// The MLS-authenticated committer that made the change.
1498 committer: String,
1499 /// Members the commit added, sorted. Empty for a pure removal.
1500 added: Vec<String>,
1501 /// Members the commit removed, sorted. Empty for a pure addition.
1502 removed: Vec<String>,
1503 /// Why the change was judged unauthorized. Currently
1504 /// `"sender_not_admin"` (the committer is not a known admin) or
1505 /// `"affected_member_mismatch"` (an admin committed, but the commit's
1506 /// unencrypted framing named a different member than the MLS delta
1507 /// actually changed). Treat as an opaque string — values may be added.
1508 reason: String,
1509 /// Whether the commit was *refused* rather than applied.
1510 ///
1511 /// `false` (the default configuration) means the membership change
1512 /// happened and is being reported after the fact — `added`/`removed`
1513 /// describe real roster changes an admin can undo. `true` means
1514 /// `GroupConfig::enforce_admin_commits` was on and the commit was
1515 /// rejected before merging: no roster event accompanies this one, no
1516 /// membership changed locally, and `added`/`removed` describe what the
1517 /// commit *would* have done.
1518 ///
1519 /// A `true` here also means this device declined an epoch every
1520 /// accepting member advanced to, so it can no longer decrypt that
1521 /// group's traffic and needs re-inviting — treat it as a partition
1522 /// alarm, not just a moderation signal. Note the re-invite arrives as
1523 /// a Welcome, which is not policy-gated, so it readmits us to the
1524 /// group *including* whatever the refused commit did; the change
1525 /// itself still has to be resolved separately.
1526 enforced: bool,
1527 },
1528
1529 /// An epoch fork was detected in a group — concurrent commits caused
1530 /// members to diverge onto different MLS branches. The deterministic
1531 /// leader will attempt automatic resolution via a key-update commit.
1532 GroupEpochForkDetected {
1533 /// MLS group identifier.
1534 group_id: String,
1535 /// Our local epoch when the fork was detected, or `None` if MLS
1536 /// state was unavailable at detection time.
1537 local_epoch: Option<u64>,
1538 },
1539
1540 /// An epoch fork was successfully resolved by the leader issuing a
1541 /// resync commit that re-established a canonical epoch.
1542 ///
1543 /// Members in `failed_members` could not be reached with the resolution
1544 /// commit and may still be on a forked branch — the application layer
1545 /// should consider re-inviting them.
1546 GroupEpochForkResolved {
1547 /// MLS group identifier.
1548 group_id: String,
1549 /// The new canonical epoch after resolution.
1550 resolved_epoch: u64,
1551 /// Members to whom the resolution commit could not be sent.
1552 /// These members may still be on a forked epoch branch and
1553 /// may need a re-invite to rejoin the canonical group state.
1554 failed_members: Vec<String>,
1555 },
1556
1557 /// A member's role was changed in a group.
1558 GroupRoleChanged {
1559 /// Group identifier.
1560 group_id: String,
1561 /// User ID of the member whose role changed.
1562 user_id: String,
1563 /// New role (e.g. "admin", "member").
1564 new_role: String,
1565 /// User ID of who changed the role.
1566 changed_by: String,
1567 },
1568
1569 /// A group was renamed.
1570 GroupRenamed {
1571 /// Group identifier.
1572 group_id: String,
1573 /// New group name.
1574 new_name: String,
1575 /// Previous group name (if known).
1576 old_name: Option<String>,
1577 /// User ID of who renamed the group.
1578 renamed_by: String,
1579 },
1580
1581 // --- Service discovery ---
1582 /// A service was discovered on the mesh in response to a discovery query.
1583 ServiceDiscovered {
1584 /// Query ID that triggered this discovery.
1585 query_id: String,
1586 /// Service identifier.
1587 service_id: String,
1588 /// Service version.
1589 version: String,
1590 /// Peer user ID of the provider.
1591 provider_peer_id: String,
1592 /// Service capabilities.
1593 capabilities: HashMap<String, String>,
1594 /// Number of hops from the provider.
1595 hop_count: u8,
1596 },
1597
1598 /// A service request was received from another peer.
1599 ServiceRequestReceived {
1600 /// Unique request identifier.
1601 request_id: String,
1602 /// Service identifier being invoked.
1603 service_id: String,
1604 /// Method name or action.
1605 method: String,
1606 /// Request body (JSON or arbitrary string).
1607 body: String,
1608 /// Peer user ID of the requester.
1609 sender: String,
1610 },
1611
1612 /// A response to a service request was received.
1613 ServiceResponseReceived {
1614 /// Request identifier this response corresponds to.
1615 request_id: String,
1616 /// Service identifier.
1617 service_id: String,
1618 /// Status: "ok", "not_found", or "error".
1619 status: String,
1620 /// Response body.
1621 body: String,
1622 /// Peer user ID of the provider.
1623 provider_peer_id: String,
1624 },
1625
1626 // --- Presence, typing, and read receipts ---
1627 /// A peer's presence status was updated.
1628 PresenceUpdated {
1629 /// Peer whose presence changed.
1630 peer_id: String,
1631 /// Presence status.
1632 status: PresenceStatus,
1633 /// Timestamp of the update (Unix ms).
1634 timestamp: i64,
1635 /// When the peer was last seen (Unix ms), if the source knows it —
1636 /// e.g. the internet relay's PresenceStatusWithLastSeen. Absent for
1637 /// peer-sent `__PRESENCE__` updates.
1638 #[serde(default, skip_serializing_if = "Option::is_none")]
1639 last_seen_ms: Option<i64>,
1640 /// Which channel produced this update: the internet relay's
1641 /// authoritative answer, or a peer-sent self-report. Always
1642 /// serialized; defaults to `peer` when deserializing legacy events.
1643 #[serde(default)]
1644 source: PresenceSource,
1645 },
1646
1647 /// A typing indicator was received from a peer.
1648 TypingIndicatorReceived {
1649 /// User who is typing (or stopped typing).
1650 sender: String,
1651 /// Conversation identifier, echoed back verbatim from the sender.
1652 ///
1653 /// Opaque to the SDK — never parsed or routed on. Whatever key the
1654 /// sender chose (conventionally a peer address for a DM, the group
1655 /// id for a group) arrives here unchanged.
1656 conversation_id: String,
1657 /// Whether the user is currently typing.
1658 is_typing: bool,
1659 /// Timestamp of the indicator (Unix ms).
1660 timestamp: i64,
1661 },
1662
1663 /// A read receipt was received from a peer.
1664 ReadReceiptReceived {
1665 /// User who read the messages.
1666 sender: String,
1667 /// IDs of the messages that were read.
1668 message_ids: Vec<String>,
1669 /// Timestamp when the messages were read (Unix ms).
1670 timestamp: i64,
1671 },
1672
1673 // --- DORS observability (OFF-258) ---
1674 /// DORS scored all available transports for this decision cycle.
1675 DorsScoreUpdated {
1676 /// Transport type and score pairs (descending by score).
1677 scores: Vec<(String, f32)>,
1678 },
1679 /// DORS selected a transport for the current message.
1680 DorsTransportSelected {
1681 /// Previous transport (if any); provides decision boundary context.
1682 from: Option<String>,
1683 /// Selected transport.
1684 transport: String,
1685 /// Typed reason for selection (initial vs primary selected).
1686 reason_code: DorsReasonCode,
1687 /// Score of the selected transport (supplemental).
1688 score: f32,
1689 },
1690 /// DORS switched from one transport to another. Emitted only when active transport
1691 /// actually changes after a successful send.
1692 DorsTransportSwitched {
1693 /// Previous transport (if any).
1694 from: Option<String>,
1695 /// New transport (now active).
1696 to: String,
1697 /// Stable enum-backed reason for the transition.
1698 reason_code: DorsReasonCode,
1699 /// Optional human-readable context (e.g. "primary failed, fallback succeeded").
1700 #[serde(skip_serializing_if = "Option::is_none")]
1701 reason_detail: Option<String>,
1702 },
1703 /// DORS escalation from BLE to Wi‑Fi. Use `phase` to distinguish recommendation vs applied.
1704 DorsEscalationTriggered {
1705 /// TRIGGERED = DORS decided to escalate (reason = trigger); APPLIED = fallback succeeded.
1706 phase: DorsEscalationPhase,
1707 /// Transport we escalated from (e.g. "ble").
1708 from: String,
1709 /// Transport we escalated to (e.g. "wifiDirect").
1710 to: String,
1711 /// Stable enum-backed reason code.
1712 reason_code: DorsEscalationReasonCode,
1713 /// Optional human-readable context.
1714 #[serde(skip_serializing_if = "Option::is_none")]
1715 reason_detail: Option<String>,
1716 },
1717
1718 /// A security-relevant anomaly was detected (e.g. sender spoofing, a
1719 /// signing key that does not derive to its claimed address, an unsigned
1720 /// control message).
1721 SecurityWarning {
1722 /// Peer ID involved in the warning.
1723 peer_id: String,
1724 /// Stable machine-readable classification of the warning. Branch on
1725 /// this rather than parsing `reason`.
1726 reason_code: SecurityWarningCode,
1727 /// Human-readable description of the security issue.
1728 reason: String,
1729 },
1730
1731 /// A message was relayed (forwarded for a third party).
1732 MessageRelayed {
1733 /// ID of the relayed message.
1734 message_id: String,
1735 /// Original sender.
1736 sender: String,
1737 /// Intended recipient.
1738 recipient: String,
1739 /// Current hop count after increment.
1740 hop_count: u8,
1741 /// Remaining TTL after decrement.
1742 remaining_ttl: u8,
1743 },
1744
1745 /// A user was blocked. Emitted for local UI notification only.
1746 UserBlocked {
1747 /// User ID that was blocked.
1748 user_id: String,
1749 },
1750
1751 /// A user was unblocked. Emitted for local UI notification only.
1752 UserUnblocked {
1753 /// User ID that was unblocked.
1754 user_id: String,
1755 },
1756
1757 /// A replicated document changed and the change reached storage.
1758 ///
1759 /// Emitted after the delta is durable, never before: an application that
1760 /// re-renders on this event is then rendering state that survives a
1761 /// crash.
1762 DataChanged {
1763 /// The space the document belongs to.
1764 space_id: String,
1765 /// The document that changed.
1766 doc_id: String,
1767 /// Size of the change that was persisted.
1768 delta_bytes: u64,
1769 },
1770
1771 /// A replicated document was removed from every replica of its space.
1772 ///
1773 /// Distinct from a document merely disappearing locally: this one is a
1774 /// decision that travels. It fires when this device removes a document
1775 /// and when it learns of a removal from a peer, so an application
1776 /// holding the document open can close it either way.
1777 ///
1778 /// A removal loses to a concurrent edit, so a document may come back
1779 /// after this fires. That arrives as an ordinary `DataChanged` on a
1780 /// document the application thought was gone, which is the honest
1781 /// report: the edit won, and its contents are the contents.
1782 DataDocRemoved {
1783 /// The space the document belonged to.
1784 space_id: String,
1785 /// The document that was removed.
1786 doc_id: String,
1787 /// Who removed it: `local` for this device, `peer` for a removal
1788 /// learned from the space.
1789 by: DocRemovedBy,
1790 },
1791
1792 /// A replicated document is approaching the per-document size cap.
1793 ///
1794 /// Fires while there is still room to act. Without it the cap would be a
1795 /// cliff an application meets for the first time as a failed write.
1796 DataDocSizeWarning {
1797 /// The space the document belongs to.
1798 space_id: String,
1799 /// The document approaching the cap.
1800 doc_id: String,
1801 /// Its current compacted size.
1802 compacted_bytes: u64,
1803 /// The cap it is measured against.
1804 cap_bytes: u64,
1805 },
1806
1807 /// A peer asked for the bytes behind an attachment reference.
1808 ///
1809 /// The SDK does not hold blob bytes, so it cannot answer this on its
1810 /// own: the application either supplies them with
1811 /// `data_provide_attachment` or says it cannot with
1812 /// `data_decline_attachment`. An application that handles neither leaves
1813 /// the asking peer with a reference it can see and never open, which is
1814 /// why declining is a first-class answer rather than silence.
1815 DataAttachmentRequested {
1816 /// The space the request arrived in.
1817 space_id: String,
1818 /// The peer that asked.
1819 peer_id: String,
1820 /// Lowercase hex SHA-256 of the blob they want.
1821 hash: String,
1822 },
1823
1824 /// Attachment bytes arrived and matched the hash that asked for them.
1825 ///
1826 /// The bytes are handed over rather than stored: blobs never enter
1827 /// protocol state, so where this file lives is the application's
1828 /// decision and its storage.
1829 DataAttachmentReceived {
1830 /// The space the attachment arrived in.
1831 space_id: String,
1832 /// The peer that supplied it.
1833 peer_id: String,
1834 /// Lowercase hex SHA-256 of the blob, verified against these bytes.
1835 hash: String,
1836 /// The blob, base64.
1837 data: String,
1838 },
1839
1840 /// A fetch ended without bytes.
1841 ///
1842 /// Either the peer answered that it no longer holds the blob, or the
1843 /// transfer carrying it failed. Both are ordinary: an attachment
1844 /// reference outlives the bytes it names, because the reference
1845 /// replicates and the bytes do not.
1846 DataAttachmentUnavailable {
1847 /// The space the fetch was made in.
1848 space_id: String,
1849 /// The peer that was asked.
1850 peer_id: String,
1851 /// Lowercase hex SHA-256 that was asked for.
1852 hash: String,
1853 /// Why it ended: `declined` (the peer answered that it no longer
1854 /// holds the blob), `timeout`, `evicted` (displaced by newer
1855 /// fetches), `hash_mismatch`, `peer_gone` (the peer was blocked,
1856 /// forgotten, or came back without the replication capability), or a
1857 /// transfer
1858 /// failure reason. A fixed token this crate chooses, never a peer's
1859 /// words.
1860 reason: String,
1861 },
1862
1863 /// A document cannot be replicated, in any form this protocol has.
1864 ///
1865 /// The honest report of a real dead end rather than a warning in a log
1866 /// nobody reads. It fires when a document is too large for a sync frame
1867 /// and the peer cannot carry it over the media path either, which leaves
1868 /// two replicas that will not converge and no further rung to try.
1869 DataDocUnsyncable {
1870 /// The space the document belongs to.
1871 space_id: String,
1872 /// The document that cannot be sent.
1873 doc_id: String,
1874 /// Size of the encoding that had nowhere to go.
1875 bytes: u64,
1876 /// Why it could not be sent.
1877 reason: String,
1878 },
1879}
1880
1881/// Member entry in GroupInfo.
1882#[derive(Debug, Clone, Serialize, Deserialize)]
1883pub struct GroupInfoMember {
1884 /// User ID of the member.
1885 pub user_id: String,
1886 /// Role within the group (e.g. "admin", "member").
1887 pub role: String,
1888 /// ISO-8601 timestamp when the member joined.
1889 pub joined_at: String,
1890}
1891
1892/// Group summary in UserGroups.
1893#[derive(Debug, Clone, Serialize, Deserialize)]
1894pub struct UserGroupSummary {
1895 /// Group identifier.
1896 pub group_id: String,
1897 /// Human-readable group name.
1898 pub name: String,
1899 /// ISO-8601 creation timestamp.
1900 pub created_at: String,
1901}
1902
1903impl Event {
1904 /// Creates a MessageSent event.
1905 pub fn message_sent(message: &Message) -> Self {
1906 use offline_protocol_core::MessagePriority;
1907
1908 let priority = match message.priority {
1909 MessagePriority::Low => "low",
1910 MessagePriority::Medium => "medium",
1911 MessagePriority::High => "high",
1912 MessagePriority::Critical => "critical",
1913 };
1914
1915 let forward_info = message.forwarded_from.as_ref().map(ForwardInfoEvent::from);
1916
1917 Self::MessageSent {
1918 message_id: message.id.as_str(),
1919 sender: message.sender.as_str().to_string(),
1920 recipient: message.recipient.as_str().to_string(),
1921 content: message.content.clone(),
1922 priority: priority.to_string(),
1923 requires_ack: message.requires_ack,
1924 timestamp: message.timestamp.as_millis(),
1925 lamport_clock: message.lamport_clock.value(),
1926 forward_info,
1927 }
1928 }
1929
1930 /// Creates a MessageDelivered event.
1931 pub fn message_delivered(
1932 message_id: MessageId,
1933 latency_ms: u64,
1934 hop_count: u8,
1935 transport: TransportType,
1936 ) -> Self {
1937 Self::MessageDelivered {
1938 message_id: message_id.as_str(),
1939 latency_ms,
1940 hop_count,
1941 transport: transport.to_string(),
1942 }
1943 }
1944
1945 /// Creates a MessageFailed event.
1946 ///
1947 /// `reason` is `&'static str` for the same load-bearing reason
1948 /// [`Event::message_deferred`] is, and
1949 /// `protocol::types::classify_transport_send_error` returns one: the
1950 /// telemetry pipe forwards this field to the ingest verbatim, without the
1951 /// scrubber, because the classification taxonomy is the ingest's. A
1952 /// `String` parameter makes `format!("{err}")` at a call site
1953 /// representable, and the transport layer renders the counterparty into
1954 /// its own error text, so that one interpolation would ship a peer
1955 /// address beside identifiers the same record hashes. A static string
1956 /// cannot carry runtime data, which makes the mistake unrepresentable
1957 /// rather than merely discouraged. Callers with a classified failure pass
1958 /// the token; callers with a fixed condition pass its literal.
1959 pub fn message_failed(message_id: MessageId, reason: &'static str, retry_count: u32) -> Self {
1960 Self::MessageFailed {
1961 message_id: message_id.as_str(),
1962 reason: reason.to_string(),
1963 retry_count,
1964 }
1965 }
1966
1967 /// Creates a MessageDecryptionFailed event.
1968 pub fn message_decryption_failed(
1969 message_id: MessageId,
1970 sender: String,
1971 code: DecryptionFailureCode,
1972 reason: String,
1973 ) -> Self {
1974 Self::MessageDecryptionFailed {
1975 message_id: message_id.as_str(),
1976 sender,
1977 code,
1978 reason,
1979 }
1980 }
1981
1982 /// Creates a TransportSwitched event.
1983 pub fn transport_switched(
1984 from: Option<TransportType>,
1985 to: TransportType,
1986 reason: String,
1987 ) -> Self {
1988 Self::TransportSwitched {
1989 from: from.map(|t| t.to_string()),
1990 to: to.to_string(),
1991 reason,
1992 }
1993 }
1994
1995 /// Creates a RelayPromoted event.
1996 pub fn relay_promoted(connection_count: usize, battery_level: Option<u8>) -> Self {
1997 Self::RelayPromoted {
1998 connection_count,
1999 battery_level,
2000 }
2001 }
2002
2003 /// Creates a RelayDemoted event.
2004 ///
2005 /// `&'static str` for the reason given on [`Event::message_failed`]: the
2006 /// telemetry pipe forwards this field raw.
2007 pub fn relay_demoted(reason: &'static str) -> Self {
2008 Self::RelayDemoted {
2009 reason: reason.to_string(),
2010 }
2011 }
2012
2013 /// Creates a NeighborDiscovered event.
2014 pub fn neighbor_discovered(
2015 peer_id: String,
2016 transport: TransportType,
2017 rssi: Option<i16>,
2018 ) -> Self {
2019 Self::NeighborDiscovered {
2020 peer_id,
2021 transport: format!("{:?}", transport),
2022 rssi,
2023 }
2024 }
2025
2026 /// Creates a NeighborLost event.
2027 pub fn neighbor_lost(peer_id: String) -> Self {
2028 Self::NeighborLost { peer_id }
2029 }
2030
2031 /// Creates an IdentityReady event.
2032 pub fn identity_ready(address: impl Into<String>) -> Self {
2033 Self::IdentityReady {
2034 address: address.into(),
2035 }
2036 }
2037
2038 /// Creates a NetworkMetrics event.
2039 pub fn network_metrics(
2040 neighbor_count: usize,
2041 relay_count: usize,
2042 delivery_ratio: f32,
2043 avg_latency_ms: u64,
2044 ) -> Self {
2045 Self::NetworkMetrics {
2046 neighbor_count,
2047 relay_count,
2048 delivery_ratio,
2049 avg_latency_ms,
2050 }
2051 }
2052
2053 /// Creates a FileProgress event.
2054 pub fn file_progress(file_id: String, chunks_sent: u32, total_chunks: u32) -> Self {
2055 let percentage = if total_chunks > 0 {
2056 ((chunks_sent as f32 / total_chunks as f32) * 100.0) as u8
2057 } else {
2058 0
2059 };
2060
2061 Self::FileProgress {
2062 file_id,
2063 chunks_sent,
2064 total_chunks,
2065 percentage,
2066 }
2067 }
2068
2069 /// Creates a FileReceived event.
2070 #[allow(clippy::too_many_arguments)]
2071 pub fn file_received(
2072 file_id: String,
2073 file_name: String,
2074 file_size: u64,
2075 sender: String,
2076 content_type: ContentType,
2077 media_metadata: Option<MediaMetadata>,
2078 file_data: Vec<u8>,
2079 timestamp: Option<i64>,
2080 caption: Option<String>,
2081 reply_to_msg: Option<String>,
2082 reply_context: Option<&offline_protocol_core::ReplyContext>,
2083 forward_info: Option<&offline_protocol_core::ForwardInfo>,
2084 ) -> Self {
2085 use base64::{engine::general_purpose::STANDARD, Engine};
2086 Self::FileReceived {
2087 file_id,
2088 file_name,
2089 file_size,
2090 sender,
2091 content_type: content_type.to_string(),
2092 media_metadata,
2093 file_data: STANDARD.encode(&file_data),
2094 timestamp,
2095 caption,
2096 reply_to_msg,
2097 reply_context: reply_context.map(|rc| Box::new(ReplyContextEvent::from(rc))),
2098 forward_info: forward_info.map(ForwardInfoEvent::from),
2099 }
2100 }
2101
2102 /// Creates a FileReceiveFailed event.
2103 pub fn file_receive_failed(
2104 file_id: String,
2105 file_name: String,
2106 sender: String,
2107 reason: String,
2108 ) -> Self {
2109 Self::FileReceiveFailed {
2110 file_id,
2111 file_name,
2112 sender,
2113 reason,
2114 }
2115 }
2116
2117 /// Creates a MediaSent event.
2118 pub fn media_sent(file_id: String, content_type: ContentType, recipient: String) -> Self {
2119 Self::MediaSent {
2120 file_id,
2121 content_type: content_type.to_string(),
2122 recipient,
2123 }
2124 }
2125
2126 /// Creates a MediaSendFailed event.
2127 pub fn media_send_failed(file_id: String, recipient: String, reason: String) -> Self {
2128 Self::MediaSendFailed {
2129 file_id,
2130 recipient,
2131 reason,
2132 }
2133 }
2134
2135 /// Creates a MessageDeferred event.
2136 pub fn message_deferred(
2137 message_id: MessageId,
2138 recipient: String,
2139 reason: &'static str,
2140 retry_count: u32,
2141 next_retry_at: Option<i64>,
2142 ) -> Self {
2143 Self::MessageDeferred {
2144 message_id: message_id.as_str(),
2145 recipient,
2146 reason: reason.to_string(),
2147 retry_count,
2148 next_retry_at,
2149 }
2150 }
2151
2152 /// Creates a MessageRetrying event.
2153 pub fn message_retrying(
2154 message_id: MessageId,
2155 recipient: String,
2156 retry_count: u32,
2157 next_retry_at: i64,
2158 ) -> Self {
2159 Self::MessageRetrying {
2160 message_id: message_id.as_str(),
2161 recipient,
2162 retry_count,
2163 next_retry_at,
2164 }
2165 }
2166
2167 /// Creates a MessageUndeliverable event.
2168 pub fn message_undeliverable(
2169 message_id: MessageId,
2170 recipient: String,
2171 reason: &'static str,
2172 file_id: Option<String>,
2173 ) -> Self {
2174 Self::MessageUndeliverable {
2175 message_id: message_id.as_str(),
2176 recipient,
2177 reason: reason.to_string(),
2178 file_id,
2179 }
2180 }
2181
2182 /// Creates a MediaResendRequired event.
2183 pub fn media_resend_required(
2184 file_id: String,
2185 recipient: String,
2186 file_name: String,
2187 file_size: u64,
2188 ) -> Self {
2189 Self::MediaResendRequired {
2190 file_id,
2191 recipient,
2192 file_name,
2193 file_size,
2194 }
2195 }
2196
2197 /// Creates an AckEvicted event.
2198 pub fn ack_evicted(message_id: MessageId, priority: &str, reason: String) -> Self {
2199 Self::AckEvicted {
2200 message_id: message_id.as_str(),
2201 priority: priority.to_string(),
2202 reason,
2203 }
2204 }
2205
2206 /// Creates a FragmentAssemblyEvicted event.
2207 pub fn fragment_assembly_evicted(
2208 message_id: String,
2209 completion_percent: u8,
2210 reason: String,
2211 ) -> Self {
2212 Self::FragmentAssemblyEvicted {
2213 message_id,
2214 completion_percent,
2215 reason,
2216 }
2217 }
2218
2219 /// Creates a RelayDemotedBattery event.
2220 pub fn relay_demoted_battery(battery_level: u8, min_required: u8) -> Self {
2221 Self::RelayDemotedBattery {
2222 battery_level,
2223 min_required,
2224 }
2225 }
2226
2227 /// Creates a SecureSessionEstablished event.
2228 pub fn secure_session_established(
2229 peer_id: String,
2230 group_id: String,
2231 is_session: bool,
2232 initiated_by_local: bool,
2233 ) -> Self {
2234 Self::SecureSessionEstablished {
2235 peer_id,
2236 group_id,
2237 is_session,
2238 initiated_by_local,
2239 }
2240 }
2241
2242 /// Creates a SecureSessionFailed event.
2243 pub fn secure_session_failed(peer_id: String, reason: String) -> Self {
2244 Self::SecureSessionFailed { peer_id, reason }
2245 }
2246
2247 /// Creates a ConvergenceDiag event (1:1 MLS convergence instrumentation).
2248 pub fn convergence_diag(stage: String, peer_id: String, detail: String) -> Self {
2249 Self::ConvergenceDiag {
2250 stage,
2251 peer_id,
2252 detail,
2253 }
2254 }
2255
2256 /// Creates a WelcomeSendAttempted event.
2257 pub fn welcome_send_attempted(
2258 peer_id: String,
2259 message_id: String,
2260 group_id: String,
2261 attempt: u32,
2262 ) -> Self {
2263 Self::WelcomeSendAttempted {
2264 peer_id,
2265 message_id,
2266 group_id,
2267 attempt,
2268 }
2269 }
2270
2271 /// Creates a WelcomeSendSucceeded event.
2272 pub fn welcome_send_succeeded(
2273 peer_id: String,
2274 message_id: String,
2275 group_id: String,
2276 attempt: u32,
2277 ) -> Self {
2278 Self::WelcomeSendSucceeded {
2279 peer_id,
2280 message_id,
2281 group_id,
2282 attempt,
2283 }
2284 }
2285
2286 /// Creates a WelcomeSendFailed event.
2287 #[allow(clippy::too_many_arguments)]
2288 pub fn welcome_send_failed(
2289 peer_id: String,
2290 message_id: String,
2291 group_id: String,
2292 attempt: u32,
2293 reason_code: WelcomeReasonCode,
2294 transport_error: Option<&'static str>,
2295 retryable: bool,
2296 next_retry_at: Option<i64>,
2297 ) -> Self {
2298 Self::WelcomeSendFailed {
2299 peer_id,
2300 message_id,
2301 group_id,
2302 attempt,
2303 reason_code,
2304 transport_error: transport_error.map(str::to_string),
2305 retryable,
2306 next_retry_at,
2307 }
2308 }
2309
2310 /// Creates a WelcomeSendExpired event.
2311 pub fn welcome_send_expired(
2312 peer_id: String,
2313 message_id: String,
2314 attempt: u32,
2315 reason_code: WelcomeReasonCode,
2316 ) -> Self {
2317 Self::WelcomeSendExpired {
2318 peer_id,
2319 message_id,
2320 attempt,
2321 reason_code,
2322 }
2323 }
2324
2325 /// Creates a ConnectionRequestReceived event.
2326 pub fn connection_request_received(
2327 sender: String,
2328 sender_name: String,
2329 timestamp: i64,
2330 key_package: Option<Vec<u8>>,
2331 initial_message: Option<String>,
2332 ) -> Self {
2333 Self::ConnectionRequestReceived {
2334 sender,
2335 sender_name,
2336 timestamp,
2337 key_package,
2338 initial_message,
2339 }
2340 }
2341
2342 /// Creates a ConnectionRequestUndeliverable event.
2343 pub fn connection_request_undeliverable(
2344 recipient: String,
2345 message_id: String,
2346 reason: &'static str,
2347 ) -> Self {
2348 Self::ConnectionRequestUndeliverable {
2349 recipient,
2350 message_id,
2351 reason: reason.to_string(),
2352 }
2353 }
2354
2355 /// Creates a ConnectionAccepted event.
2356 pub fn connection_accepted(
2357 accepted_by: String,
2358 accepted_by_name: String,
2359 timestamp: i64,
2360 key_package: Option<Vec<u8>>,
2361 ) -> Self {
2362 Self::ConnectionAccepted {
2363 accepted_by,
2364 accepted_by_name,
2365 timestamp,
2366 key_package,
2367 }
2368 }
2369
2370 /// Creates a ConnectionRejected event.
2371 pub fn connection_rejected(rejected_by: String) -> Self {
2372 Self::ConnectionRejected { rejected_by }
2373 }
2374
2375 /// Creates a ConnectionRequestCancelled event.
2376 pub fn connection_request_cancelled(cancelled_by: String) -> Self {
2377 Self::ConnectionRequestCancelled { cancelled_by }
2378 }
2379
2380 /// Creates a PresenceUpdated event for a peer-sent `__PRESENCE__`
2381 /// self-report (`source: peer`).
2382 pub fn presence_updated(peer_id: String, status: PresenceStatus, timestamp: i64) -> Self {
2383 Self::PresenceUpdated {
2384 peer_id,
2385 status,
2386 timestamp,
2387 last_seen_ms: None,
2388 source: PresenceSource::Peer,
2389 }
2390 }
2391
2392 /// Creates a PresenceUpdated event carrying a last-seen timestamp
2393 /// (relay-sourced presence, `source: internet`).
2394 pub fn presence_updated_with_last_seen(
2395 peer_id: String,
2396 status: PresenceStatus,
2397 timestamp: i64,
2398 last_seen_ms: Option<i64>,
2399 ) -> Self {
2400 Self::presence_updated_from(
2401 peer_id,
2402 status,
2403 timestamp,
2404 last_seen_ms,
2405 PresenceSource::Internet,
2406 )
2407 }
2408
2409 /// Creates a PresenceUpdated event for a gateway answer, naming which
2410 /// carrier answered.
2411 ///
2412 /// The relay is one gateway among others now, so the source is a
2413 /// parameter rather than a constant. It is still not free-form: a
2414 /// peer-sent self-report goes through [`Self::presence_updated`], which
2415 /// is the only way to produce `PresenceSource::Peer`, so a carrier answer
2416 /// can never be mislabelled as one.
2417 pub fn presence_updated_from(
2418 peer_id: String,
2419 status: PresenceStatus,
2420 timestamp: i64,
2421 last_seen_ms: Option<i64>,
2422 source: PresenceSource,
2423 ) -> Self {
2424 Self::PresenceUpdated {
2425 peer_id,
2426 status,
2427 timestamp,
2428 last_seen_ms,
2429 source,
2430 }
2431 }
2432
2433 /// Creates a TypingIndicatorReceived event.
2434 pub fn typing_indicator_received(
2435 sender: String,
2436 conversation_id: String,
2437 is_typing: bool,
2438 timestamp: i64,
2439 ) -> Self {
2440 Self::TypingIndicatorReceived {
2441 sender,
2442 conversation_id,
2443 is_typing,
2444 timestamp,
2445 }
2446 }
2447
2448 /// Creates a ReadReceiptReceived event.
2449 pub fn read_receipt_received(sender: String, message_ids: Vec<String>, timestamp: i64) -> Self {
2450 Self::ReadReceiptReceived {
2451 sender,
2452 message_ids,
2453 timestamp,
2454 }
2455 }
2456
2457 /// Creates a GroupCreated event.
2458 pub fn group_created(group_id: String, name: String) -> Self {
2459 Self::GroupCreated { group_id, name }
2460 }
2461
2462 /// Creates a GroupMessageReceived event.
2463 #[allow(clippy::too_many_arguments)]
2464 pub fn group_message_received(
2465 group_id: String,
2466 sender: String,
2467 content: String,
2468 timestamp: String,
2469 message_id: String,
2470 reply_to_msg: Option<String>,
2471 forward_info: Option<ForwardInfoEvent>,
2472 media_metadata: Option<MediaMetadata>,
2473 content_type: Option<String>,
2474 ) -> Self {
2475 Self::GroupMessageReceived {
2476 group_id,
2477 sender,
2478 content,
2479 timestamp,
2480 message_id,
2481 reply_to_msg,
2482 forward_info,
2483 media_metadata,
2484 content_type,
2485 }
2486 }
2487
2488 /// Creates a GroupMemberAdded event.
2489 pub fn group_member_added(
2490 group_id: String,
2491 user_id: String,
2492 added_by: String,
2493 group_name: Option<String>,
2494 authorized: Option<bool>,
2495 ) -> Self {
2496 Self::GroupMemberAdded {
2497 group_id,
2498 user_id,
2499 added_by,
2500 group_name,
2501 authorized,
2502 }
2503 }
2504
2505 /// Creates a GroupMemberRemoved event.
2506 pub fn group_member_removed(
2507 group_id: String,
2508 user_id: String,
2509 removed_by: String,
2510 authorized: Option<bool>,
2511 ) -> Self {
2512 Self::GroupMemberRemoved {
2513 group_id,
2514 user_id,
2515 removed_by,
2516 authorized,
2517 }
2518 }
2519
2520 /// Creates a GroupRoleChanged event.
2521 pub fn group_role_changed(
2522 group_id: String,
2523 user_id: String,
2524 new_role: String,
2525 changed_by: String,
2526 ) -> Self {
2527 Self::GroupRoleChanged {
2528 group_id,
2529 user_id,
2530 new_role,
2531 changed_by,
2532 }
2533 }
2534
2535 /// Creates a GroupRenamed event.
2536 pub fn group_renamed(
2537 group_id: String,
2538 new_name: String,
2539 old_name: Option<String>,
2540 renamed_by: String,
2541 ) -> Self {
2542 Self::GroupRenamed {
2543 group_id,
2544 new_name,
2545 old_name,
2546 renamed_by,
2547 }
2548 }
2549
2550 /// Creates a GroupInfo event.
2551 pub fn group_info(
2552 group_id: String,
2553 name: String,
2554 created_by: String,
2555 created_at: String,
2556 members: Vec<GroupInfoMember>,
2557 ) -> Self {
2558 Self::GroupInfo {
2559 group_id,
2560 name,
2561 created_by,
2562 created_at,
2563 members,
2564 }
2565 }
2566
2567 /// Creates a UserGroups event.
2568 pub fn user_groups(groups: Vec<UserGroupSummary>) -> Self {
2569 Self::UserGroups { groups }
2570 }
2571
2572 /// Creates a GroupError event.
2573 ///
2574 /// `reason` must be a locally-minted code, never relay text — callers on
2575 /// the wire path get theirs from `GroupErrorPayload::classify_reason`.
2576 pub fn group_error(reason: String, group_id: Option<String>) -> Self {
2577 Self::GroupError { reason, group_id }
2578 }
2579
2580 /// Creates a GroupRelaySyncChanged event.
2581 pub fn group_relay_sync_changed(group_id: String, synced: bool, reason: &str) -> Self {
2582 Self::GroupRelaySyncChanged {
2583 group_id,
2584 synced,
2585 reason: reason.to_string(),
2586 }
2587 }
2588
2589 /// Creates a GroupMessageSent event.
2590 pub fn group_message_sent(
2591 group_id: String,
2592 message_ids: Vec<String>,
2593 member_count: u32,
2594 ) -> Self {
2595 Self::GroupMessageSent {
2596 group_id,
2597 message_ids,
2598 member_count,
2599 }
2600 }
2601
2602 /// Creates a GroupMessagePartialFailure event.
2603 pub fn group_message_partial_failure(
2604 group_id: String,
2605 failed_members: Vec<String>,
2606 succeeded_members: Vec<String>,
2607 ) -> Self {
2608 Self::GroupMessagePartialFailure {
2609 group_id,
2610 failed_members,
2611 succeeded_members,
2612 }
2613 }
2614
2615 /// Creates a GroupMessageDeliveryReport event.
2616 pub fn group_message_delivery_report(
2617 group_id: String,
2618 message_id: String,
2619 delivered: Vec<String>,
2620 pushed: Vec<String>,
2621 missed_reissued: Vec<String>,
2622 ) -> Self {
2623 Self::GroupMessageDeliveryReport {
2624 group_id,
2625 message_id,
2626 delivered,
2627 pushed,
2628 missed_reissued,
2629 }
2630 }
2631
2632 /// Creates a GroupRichExtrasDropped event.
2633 pub fn group_rich_extras_dropped(group_id: String, unknown_members: Vec<String>) -> Self {
2634 Self::GroupRichExtrasDropped {
2635 group_id,
2636 unknown_members,
2637 }
2638 }
2639
2640 /// Creates a GroupUnauthorizedMembershipChange event.
2641 pub fn group_unauthorized_membership_change(
2642 group_id: String,
2643 committer: String,
2644 added: Vec<String>,
2645 removed: Vec<String>,
2646 reason: String,
2647 enforced: bool,
2648 ) -> Self {
2649 Self::GroupUnauthorizedMembershipChange {
2650 group_id,
2651 committer,
2652 added,
2653 removed,
2654 reason,
2655 enforced,
2656 }
2657 }
2658
2659 /// Creates a GroupEpochForkDetected event.
2660 pub fn group_epoch_fork_detected(group_id: String, local_epoch: Option<u64>) -> Self {
2661 Self::GroupEpochForkDetected {
2662 group_id,
2663 local_epoch,
2664 }
2665 }
2666
2667 /// Creates a GroupEpochForkResolved event.
2668 pub fn group_epoch_fork_resolved(
2669 group_id: String,
2670 resolved_epoch: u64,
2671 failed_members: Vec<String>,
2672 ) -> Self {
2673 Self::GroupEpochForkResolved {
2674 group_id,
2675 resolved_epoch,
2676 failed_members,
2677 }
2678 }
2679
2680 /// Creates a ServiceDiscovered event.
2681 pub fn service_discovered(
2682 query_id: String,
2683 service_id: String,
2684 version: String,
2685 provider_peer_id: String,
2686 capabilities: HashMap<String, String>,
2687 hop_count: u8,
2688 ) -> Self {
2689 Self::ServiceDiscovered {
2690 query_id,
2691 service_id,
2692 version,
2693 provider_peer_id,
2694 capabilities,
2695 hop_count,
2696 }
2697 }
2698
2699 /// Creates a ServiceRequestReceived event.
2700 pub fn service_request_received(
2701 request_id: String,
2702 service_id: String,
2703 method: String,
2704 body: String,
2705 sender: String,
2706 ) -> Self {
2707 Self::ServiceRequestReceived {
2708 request_id,
2709 service_id,
2710 method,
2711 body,
2712 sender,
2713 }
2714 }
2715
2716 /// Creates a ServiceResponseReceived event.
2717 pub fn service_response_received(
2718 request_id: String,
2719 service_id: String,
2720 status: String,
2721 body: String,
2722 provider_peer_id: String,
2723 ) -> Self {
2724 Self::ServiceResponseReceived {
2725 request_id,
2726 service_id,
2727 status,
2728 body,
2729 provider_peer_id,
2730 }
2731 }
2732
2733 /// Creates a DorsScoreUpdated event (DORS observability).
2734 pub fn dors_score_updated(scores: Vec<(String, f32)>) -> Self {
2735 Self::DorsScoreUpdated { scores }
2736 }
2737
2738 /// Creates a DorsTransportSelected event (DORS observability).
2739 pub fn dors_transport_selected(
2740 from: Option<String>,
2741 transport: String,
2742 reason_code: DorsReasonCode,
2743 score: f32,
2744 ) -> Self {
2745 Self::DorsTransportSelected {
2746 from,
2747 transport,
2748 reason_code,
2749 score,
2750 }
2751 }
2752
2753 /// Creates a DorsTransportSwitched event (DORS observability).
2754 /// Emitted only when active transport actually changes after a successful send.
2755 pub fn dors_transport_switched(
2756 from: Option<String>,
2757 to: String,
2758 reason_code: DorsReasonCode,
2759 reason_detail: Option<String>,
2760 ) -> Self {
2761 Self::DorsTransportSwitched {
2762 from,
2763 to,
2764 reason_code,
2765 reason_detail,
2766 }
2767 }
2768
2769 /// Creates a DorsEscalationTriggered event (DORS observability).
2770 /// Use phase Triggered at the DORS trigger boundary, Applied when fallback succeeds.
2771 pub fn dors_escalation_triggered(
2772 phase: DorsEscalationPhase,
2773 from: String,
2774 to: String,
2775 reason_code: DorsEscalationReasonCode,
2776 reason_detail: Option<String>,
2777 ) -> Self {
2778 Self::DorsEscalationTriggered {
2779 phase,
2780 from,
2781 to,
2782 reason_code,
2783 reason_detail,
2784 }
2785 }
2786
2787 /// Creates a SecurityWarning event.
2788 pub fn security_warning(
2789 peer_id: String,
2790 reason_code: SecurityWarningCode,
2791 reason: String,
2792 ) -> Self {
2793 Self::SecurityWarning {
2794 peer_id,
2795 reason_code,
2796 reason,
2797 }
2798 }
2799
2800 /// Creates a MessageRelayed event.
2801 pub fn message_relayed(
2802 message_id: String,
2803 sender: String,
2804 recipient: String,
2805 hop_count: u8,
2806 remaining_ttl: u8,
2807 ) -> Self {
2808 Self::MessageRelayed {
2809 message_id,
2810 sender,
2811 recipient,
2812 hop_count,
2813 remaining_ttl,
2814 }
2815 }
2816
2817 /// Creates a UserBlocked event.
2818 pub fn user_blocked(user_id: String) -> Self {
2819 Self::UserBlocked { user_id }
2820 }
2821
2822 /// Creates a UserUnblocked event.
2823 pub fn user_unblocked(user_id: String) -> Self {
2824 Self::UserUnblocked { user_id }
2825 }
2826
2827 /// Converts the event to JSON.
2828 pub fn to_json(&self) -> Result<String, serde_json::Error> {
2829 serde_json::to_string(self)
2830 }
2831
2832 /// Parses an event from JSON.
2833 pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
2834 serde_json::from_str(json)
2835 }
2836
2837 /// Returns the stable `snake.dot.case` telemetry name for this event.
2838 ///
2839 /// Names are compatible with OpenTelemetry naming conventions and are
2840 /// considered stable across minor versions of the SDK.
2841 pub fn telemetry_name(&self) -> &'static str {
2842 match self {
2843 Self::MessageSent { .. } => "protocol.message.sent",
2844 Self::MessageReceived { .. } => "protocol.message.received",
2845 Self::MessageDelivered { .. } => "protocol.message.delivered",
2846 Self::MessageFailed { .. } => "protocol.message.failed",
2847 Self::MessageDecryptionFailed { .. } => "protocol.message.decryption_failed",
2848 Self::TransportSwitched { .. } => "protocol.transport.switched",
2849 Self::RelayPromoted { .. } => "protocol.relay.promoted",
2850 Self::RelayDemoted { .. } => "protocol.relay.demoted",
2851 Self::NeighborDiscovered { .. } => "protocol.neighbor.discovered",
2852 Self::NeighborLost { .. } => "protocol.neighbor.lost",
2853 Self::IdentityReady { .. } => "protocol.identity.ready",
2854 Self::NetworkMetrics { .. } => "protocol.network.metrics",
2855 Self::FileProgress { .. } => "protocol.file.progress",
2856 Self::FileReceived { .. } => "protocol.file.received",
2857 Self::FileReceiveFailed { .. } => "protocol.file.receive_failed",
2858 Self::MediaSent { .. } => "protocol.media.sent",
2859 Self::MediaSendFailed { .. } => "protocol.media.send_failed",
2860 Self::MessageDeferred { .. } => "protocol.message.deferred",
2861 Self::MessageRetrying { .. } => "protocol.message.retrying",
2862 Self::MessageUndeliverable { .. } => "protocol.message.undeliverable",
2863 Self::MediaResendRequired { .. } => "protocol.media.resend_required",
2864 Self::AckEvicted { .. } => "protocol.ack.evicted",
2865 Self::FragmentAssemblyEvicted { .. } => "protocol.fragment.assembly_evicted",
2866 Self::RelayDemotedBattery { .. } => "protocol.relay.demoted_battery",
2867 Self::SecureSessionEstablished { .. } => "protocol.secure_session.established",
2868 Self::SecureSessionFailed { .. } => "protocol.secure_session.failed",
2869 Self::ConvergenceDiag { .. } => "protocol.convergence.diag",
2870 Self::WelcomeSendAttempted { .. } => "protocol.welcome.send_attempted",
2871 Self::WelcomeSendSucceeded { .. } => "protocol.welcome.send_succeeded",
2872 Self::WelcomeSendFailed { .. } => "protocol.welcome.send_failed",
2873 Self::WelcomeSendExpired { .. } => "protocol.welcome.send_expired",
2874 Self::ConnectionRequestReceived { .. } => "protocol.connection.request_received",
2875 Self::ConnectionRequestUndeliverable { .. } => {
2876 "protocol.connection.request_undeliverable"
2877 }
2878 Self::ConnectionAccepted { .. } => "protocol.connection.accepted",
2879 Self::ConnectionRejected { .. } => "protocol.connection.rejected",
2880 Self::ConnectionRequestCancelled { .. } => "protocol.connection.request_cancelled",
2881 Self::GroupCreated { .. } => "protocol.group.created",
2882 Self::GroupMessageReceived { .. } => "protocol.group.message_received",
2883 Self::GroupMemberAdded { .. } => "protocol.group.member_added",
2884 Self::GroupMemberRemoved { .. } => "protocol.group.member_removed",
2885 Self::GroupInfo { .. } => "protocol.group.info",
2886 Self::UserGroups { .. } => "protocol.group.user_groups",
2887 Self::GroupError { .. } => "protocol.group.error",
2888 Self::GroupRelaySyncChanged { .. } => "protocol.group.relay_sync_changed",
2889 Self::UsernameResolved { .. } => "protocol.username.resolved",
2890 Self::GroupMessageSent { .. } => "protocol.group.message_sent",
2891 Self::GroupMessagePartialFailure { .. } => "protocol.group.message_partial_failure",
2892 Self::GroupMessageDeliveryReport { .. } => "protocol.group.delivery_report",
2893 Self::GroupRichExtrasDropped { .. } => "protocol.group.rich_extras_dropped",
2894 Self::GroupUnauthorizedMembershipChange { .. } => {
2895 "protocol.group.unauthorized_membership_change"
2896 }
2897 Self::GroupEpochForkDetected { .. } => "protocol.group.epoch_fork_detected",
2898 Self::GroupEpochForkResolved { .. } => "protocol.group.epoch_fork_resolved",
2899 Self::GroupRoleChanged { .. } => "protocol.group.role_changed",
2900 Self::GroupRenamed { .. } => "protocol.group.renamed",
2901 Self::ServiceDiscovered { .. } => "protocol.service.discovered",
2902 Self::ServiceRequestReceived { .. } => "protocol.service.request_received",
2903 Self::ServiceResponseReceived { .. } => "protocol.service.response_received",
2904 Self::PresenceUpdated { .. } => "protocol.presence.updated",
2905 Self::TypingIndicatorReceived { .. } => "protocol.typing.received",
2906 Self::ReadReceiptReceived { .. } => "protocol.read_receipt.received",
2907 Self::DorsScoreUpdated { .. } => "protocol.dors.score_updated",
2908 Self::DorsTransportSelected { .. } => "protocol.dors.transport_selected",
2909 Self::DorsTransportSwitched { .. } => "protocol.dors.transport_switched",
2910 Self::DorsEscalationTriggered { .. } => "protocol.dors.escalation_triggered",
2911 Self::SecurityWarning { .. } => "protocol.security.warning",
2912 Self::MessageRelayed { .. } => "protocol.message.relayed",
2913 Self::UserBlocked { .. } => "protocol.user.blocked",
2914 Self::UserUnblocked { .. } => "protocol.user.unblocked",
2915 Self::DataChanged { .. } => "protocol.data.changed",
2916 Self::DataDocRemoved { .. } => "protocol.data.doc_removed",
2917 Self::DataDocSizeWarning { .. } => "protocol.data.doc_size_warning",
2918 Self::DataAttachmentRequested { .. } => "protocol.data.attachment_requested",
2919 Self::DataAttachmentReceived { .. } => "protocol.data.attachment_received",
2920 Self::DataAttachmentUnavailable { .. } => "protocol.data.attachment_unavailable",
2921 Self::DataDocUnsyncable { .. } => "protocol.data.doc_unsyncable",
2922 }
2923 }
2924}
2925
2926impl From<ServiceEvent> for Event {
2927 fn from(event: ServiceEvent) -> Self {
2928 match event {
2929 ServiceEvent::ServiceDiscovered {
2930 query_id,
2931 service_id,
2932 version,
2933 provider_peer_id,
2934 capabilities,
2935 hop_count,
2936 } => Self::ServiceDiscovered {
2937 query_id,
2938 service_id,
2939 version,
2940 provider_peer_id,
2941 capabilities,
2942 hop_count,
2943 },
2944 ServiceEvent::ServiceRequestReceived {
2945 request_id,
2946 service_id,
2947 method,
2948 body,
2949 sender,
2950 } => Self::ServiceRequestReceived {
2951 request_id,
2952 service_id,
2953 method,
2954 body,
2955 sender,
2956 },
2957 ServiceEvent::ServiceResponseReceived {
2958 request_id,
2959 service_id,
2960 status,
2961 body,
2962 provider_peer_id,
2963 } => Self::ServiceResponseReceived {
2964 request_id,
2965 service_id,
2966 status,
2967 body,
2968 provider_peer_id,
2969 },
2970 }
2971 }
2972}
2973
2974/// Custom Debug implementation that redacts sensitive fields to prevent
2975/// accidental logging of message content and other potentially sensitive data.
2976impl fmt::Debug for Event {
2977 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2978 match self {
2979 Self::MessageSent {
2980 message_id,
2981 sender: _,
2982 recipient: _,
2983 content,
2984 priority,
2985 requires_ack,
2986 timestamp,
2987 lamport_clock,
2988 forward_info,
2989 } => f
2990 .debug_struct("MessageSent")
2991 .field("message_id", message_id)
2992 .field("sender", &"[REDACTED]")
2993 .field("recipient", &"[REDACTED]")
2994 .field("content", &format!("[REDACTED {} bytes]", content.len()))
2995 .field("priority", priority)
2996 .field("requires_ack", requires_ack)
2997 .field("timestamp", timestamp)
2998 .field("lamport_clock", lamport_clock)
2999 .field("forward_info", &forward_info.is_some())
3000 .finish(),
3001 Self::MessageReceived {
3002 message_id,
3003 sender: _,
3004 recipient: _,
3005 content,
3006 hop_count,
3007 transport,
3008 timestamp,
3009 lamport_clock,
3010 reply_to_msg: _,
3011 reply_context,
3012 content_type,
3013 media_metadata: _,
3014 forward_info,
3015 encrypted,
3016 } => f
3017 .debug_struct("MessageReceived")
3018 .field("message_id", message_id)
3019 .field("sender", &"[REDACTED]")
3020 .field("recipient", &"[REDACTED]")
3021 .field("content", &format!("[REDACTED {} bytes]", content.len()))
3022 .field("hop_count", hop_count)
3023 .field("transport", transport)
3024 .field("timestamp", timestamp)
3025 .field("lamport_clock", lamport_clock)
3026 .field("reply_to_msg", &"[REDACTED]")
3027 .field("reply_context", &reply_context.is_some())
3028 .field("content_type", content_type)
3029 .field("forward_info", &forward_info.is_some())
3030 .field("encrypted", encrypted)
3031 .finish(),
3032 Self::MessageDelivered {
3033 message_id,
3034 latency_ms,
3035 hop_count,
3036 transport,
3037 } => f
3038 .debug_struct("MessageDelivered")
3039 .field("message_id", message_id)
3040 .field("latency_ms", latency_ms)
3041 .field("hop_count", hop_count)
3042 .field("transport", transport)
3043 .finish(),
3044 Self::MessageFailed {
3045 message_id,
3046 reason,
3047 retry_count,
3048 } => f
3049 .debug_struct("MessageFailed")
3050 .field("message_id", message_id)
3051 .field("reason", reason)
3052 .field("retry_count", retry_count)
3053 .finish(),
3054 Self::MessageDecryptionFailed {
3055 message_id,
3056 sender: _,
3057 code,
3058 reason,
3059 } => f
3060 .debug_struct("MessageDecryptionFailed")
3061 .field("message_id", message_id)
3062 .field("sender", &"[REDACTED]")
3063 .field("code", code)
3064 .field("reason", reason)
3065 .finish(),
3066 Self::TransportSwitched { from, to, reason } => f
3067 .debug_struct("TransportSwitched")
3068 .field("from", from)
3069 .field("to", to)
3070 .field("reason", reason)
3071 .finish(),
3072 Self::RelayPromoted {
3073 connection_count,
3074 battery_level,
3075 } => f
3076 .debug_struct("RelayPromoted")
3077 .field("connection_count", connection_count)
3078 .field("battery_level", battery_level)
3079 .finish(),
3080 Self::RelayDemoted { reason } => f
3081 .debug_struct("RelayDemoted")
3082 .field("reason", reason)
3083 .finish(),
3084 Self::NeighborDiscovered {
3085 peer_id: _,
3086 transport,
3087 rssi,
3088 } => f
3089 .debug_struct("NeighborDiscovered")
3090 .field("peer_id", &"[REDACTED]")
3091 .field("transport", transport)
3092 .field("rssi", rssi)
3093 .finish(),
3094 Self::NeighborLost { peer_id: _ } => f
3095 .debug_struct("NeighborLost")
3096 .field("peer_id", &"[REDACTED]")
3097 .finish(),
3098 // Redacted like any other identifier: this one names *this* device,
3099 // which is the strongest correlator a log can carry.
3100 Self::IdentityReady { address: _ } => f
3101 .debug_struct("IdentityReady")
3102 .field("address", &"[REDACTED]")
3103 .finish(),
3104 Self::NetworkMetrics {
3105 neighbor_count,
3106 relay_count,
3107 delivery_ratio,
3108 avg_latency_ms,
3109 } => f
3110 .debug_struct("NetworkMetrics")
3111 .field("neighbor_count", neighbor_count)
3112 .field("relay_count", relay_count)
3113 .field("delivery_ratio", delivery_ratio)
3114 .field("avg_latency_ms", avg_latency_ms)
3115 .finish(),
3116 Self::FileProgress {
3117 file_id,
3118 chunks_sent,
3119 total_chunks,
3120 percentage,
3121 } => f
3122 .debug_struct("FileProgress")
3123 .field("file_id", file_id)
3124 .field("chunks_sent", chunks_sent)
3125 .field("total_chunks", total_chunks)
3126 .field("percentage", percentage)
3127 .finish(),
3128 Self::FileReceived {
3129 file_id,
3130 file_name,
3131 file_size,
3132 sender: _,
3133 content_type,
3134 media_metadata: _,
3135 file_data,
3136 timestamp,
3137 caption,
3138 reply_to_msg: _,
3139 reply_context,
3140 forward_info,
3141 } => f
3142 .debug_struct("FileReceived")
3143 .field("file_id", file_id)
3144 .field("file_name", file_name)
3145 .field("file_size", file_size)
3146 .field("sender", &"[REDACTED]")
3147 .field("content_type", content_type)
3148 .field("file_data", &format!("[{} bytes base64]", file_data.len()))
3149 .field("timestamp", timestamp)
3150 .field("caption", &caption.is_some())
3151 .field("reply_to_msg", &"[REDACTED]")
3152 .field("reply_context", &reply_context.is_some())
3153 .field("forward_info", &forward_info.is_some())
3154 .finish(),
3155 Self::FileReceiveFailed {
3156 file_id,
3157 file_name,
3158 sender: _,
3159 reason,
3160 } => f
3161 .debug_struct("FileReceiveFailed")
3162 .field("file_id", file_id)
3163 .field("file_name", file_name)
3164 .field("sender", &"[REDACTED]")
3165 .field("reason", reason)
3166 .finish(),
3167 Self::MediaSent {
3168 file_id,
3169 content_type,
3170 recipient: _,
3171 } => f
3172 .debug_struct("MediaSent")
3173 .field("file_id", file_id)
3174 .field("content_type", content_type)
3175 .field("recipient", &"[REDACTED]")
3176 .finish(),
3177 Self::MediaSendFailed {
3178 file_id,
3179 recipient: _,
3180 reason,
3181 } => f
3182 .debug_struct("MediaSendFailed")
3183 .field("file_id", file_id)
3184 .field("recipient", &"[REDACTED]")
3185 .field("reason", reason)
3186 .finish(),
3187 Self::MessageDeferred {
3188 message_id,
3189 recipient: _,
3190 reason,
3191 retry_count,
3192 next_retry_at,
3193 } => f
3194 .debug_struct("MessageDeferred")
3195 .field("message_id", message_id)
3196 .field("recipient", &"[REDACTED]")
3197 .field("reason", reason)
3198 .field("retry_count", retry_count)
3199 .field("next_retry_at", next_retry_at)
3200 .finish(),
3201 Self::MessageRetrying {
3202 message_id,
3203 recipient: _,
3204 retry_count,
3205 next_retry_at,
3206 } => f
3207 .debug_struct("MessageRetrying")
3208 .field("message_id", message_id)
3209 .field("recipient", &"[REDACTED]")
3210 .field("retry_count", retry_count)
3211 .field("next_retry_at", next_retry_at)
3212 .finish(),
3213 Self::MessageUndeliverable {
3214 message_id,
3215 recipient: _,
3216 reason,
3217 file_id,
3218 } => f
3219 .debug_struct("MessageUndeliverable")
3220 .field("message_id", message_id)
3221 .field("recipient", &"[REDACTED]")
3222 .field("reason", reason)
3223 .field("file_id", file_id)
3224 .finish(),
3225 Self::MediaResendRequired {
3226 file_id,
3227 recipient: _,
3228 file_name,
3229 file_size,
3230 } => f
3231 .debug_struct("MediaResendRequired")
3232 .field("file_id", file_id)
3233 .field("recipient", &"[REDACTED]")
3234 .field("file_name", file_name)
3235 .field("file_size", file_size)
3236 .finish(),
3237 Self::AckEvicted {
3238 message_id,
3239 priority,
3240 reason,
3241 } => f
3242 .debug_struct("AckEvicted")
3243 .field("message_id", message_id)
3244 .field("priority", priority)
3245 .field("reason", reason)
3246 .finish(),
3247 Self::FragmentAssemblyEvicted {
3248 message_id,
3249 completion_percent,
3250 reason,
3251 } => f
3252 .debug_struct("FragmentAssemblyEvicted")
3253 .field("message_id", message_id)
3254 .field("completion_percent", completion_percent)
3255 .field("reason", reason)
3256 .finish(),
3257 Self::RelayDemotedBattery {
3258 battery_level,
3259 min_required,
3260 } => f
3261 .debug_struct("RelayDemotedBattery")
3262 .field("battery_level", battery_level)
3263 .field("min_required", min_required)
3264 .finish(),
3265 Self::SecureSessionEstablished {
3266 peer_id: _,
3267 group_id,
3268 is_session,
3269 initiated_by_local,
3270 } => f
3271 .debug_struct("SecureSessionEstablished")
3272 .field("peer_id", &"[REDACTED]")
3273 .field("group_id", group_id)
3274 .field("is_session", is_session)
3275 .field("initiated_by_local", initiated_by_local)
3276 .finish(),
3277 Self::SecureSessionFailed { peer_id: _, reason } => f
3278 .debug_struct("SecureSessionFailed")
3279 .field("peer_id", &"[REDACTED]")
3280 .field("reason", reason)
3281 .finish(),
3282 Self::ConvergenceDiag {
3283 stage,
3284 peer_id: _,
3285 detail,
3286 } => f
3287 .debug_struct("ConvergenceDiag")
3288 .field("stage", stage)
3289 .field("peer_id", &"[REDACTED]")
3290 .field("detail", detail)
3291 .finish(),
3292 Self::WelcomeSendAttempted {
3293 peer_id: _,
3294 message_id,
3295 group_id,
3296 attempt,
3297 } => f
3298 .debug_struct("WelcomeSendAttempted")
3299 .field("peer_id", &"[REDACTED]")
3300 .field("message_id", message_id)
3301 .field("group_id", group_id)
3302 .field("attempt", attempt)
3303 .finish(),
3304 Self::WelcomeSendSucceeded {
3305 peer_id: _,
3306 message_id,
3307 group_id,
3308 attempt,
3309 } => f
3310 .debug_struct("WelcomeSendSucceeded")
3311 .field("peer_id", &"[REDACTED]")
3312 .field("message_id", message_id)
3313 .field("group_id", group_id)
3314 .field("attempt", attempt)
3315 .finish(),
3316 Self::WelcomeSendFailed {
3317 peer_id: _,
3318 message_id,
3319 group_id,
3320 attempt,
3321 reason_code,
3322 transport_error,
3323 retryable,
3324 next_retry_at,
3325 } => f
3326 .debug_struct("WelcomeSendFailed")
3327 .field("peer_id", &"[REDACTED]")
3328 .field("message_id", message_id)
3329 .field("group_id", group_id)
3330 .field("attempt", attempt)
3331 .field("reason_code", reason_code)
3332 .field("transport_error", transport_error)
3333 .field("retryable", retryable)
3334 .field("next_retry_at", next_retry_at)
3335 .finish(),
3336 Self::WelcomeSendExpired {
3337 peer_id: _,
3338 message_id,
3339 attempt,
3340 reason_code,
3341 } => f
3342 .debug_struct("WelcomeSendExpired")
3343 .field("peer_id", &"[REDACTED]")
3344 .field("message_id", message_id)
3345 .field("attempt", attempt)
3346 .field("reason_code", reason_code)
3347 .finish(),
3348 Self::ConnectionRequestReceived {
3349 sender: _,
3350 sender_name: _,
3351 timestamp,
3352 key_package,
3353 initial_message,
3354 } => f
3355 .debug_struct("ConnectionRequestReceived")
3356 .field("sender", &"[REDACTED]")
3357 .field("sender_name", &"[REDACTED]")
3358 .field("timestamp", timestamp)
3359 .field("has_key_package", &key_package.is_some())
3360 .field("has_initial_message", &initial_message.is_some())
3361 .finish(),
3362 Self::ConnectionRequestUndeliverable {
3363 recipient: _,
3364 message_id,
3365 reason,
3366 } => f
3367 .debug_struct("ConnectionRequestUndeliverable")
3368 .field("recipient", &"[REDACTED]")
3369 .field("message_id", message_id)
3370 .field("reason", reason)
3371 .finish(),
3372 Self::ConnectionAccepted {
3373 accepted_by: _,
3374 accepted_by_name: _,
3375 timestamp,
3376 key_package,
3377 } => f
3378 .debug_struct("ConnectionAccepted")
3379 .field("accepted_by", &"[REDACTED]")
3380 .field("accepted_by_name", &"[REDACTED]")
3381 .field("timestamp", timestamp)
3382 .field("has_key_package", &key_package.is_some())
3383 .finish(),
3384 Self::ConnectionRejected { rejected_by: _ } => f
3385 .debug_struct("ConnectionRejected")
3386 .field("rejected_by", &"[REDACTED]")
3387 .finish(),
3388 Self::ConnectionRequestCancelled { cancelled_by: _ } => f
3389 .debug_struct("ConnectionRequestCancelled")
3390 .field("cancelled_by", &"[REDACTED]")
3391 .finish(),
3392 Self::ServiceDiscovered {
3393 query_id,
3394 service_id,
3395 version,
3396 provider_peer_id: _,
3397 capabilities,
3398 hop_count,
3399 } => f
3400 .debug_struct("ServiceDiscovered")
3401 .field("query_id", query_id)
3402 .field("service_id", service_id)
3403 .field("version", version)
3404 .field("provider_peer_id", &"[REDACTED]")
3405 .field("capabilities_count", &capabilities.len())
3406 .field("hop_count", hop_count)
3407 .finish(),
3408 Self::ServiceRequestReceived {
3409 request_id,
3410 service_id,
3411 method,
3412 body,
3413 sender: _,
3414 } => f
3415 .debug_struct("ServiceRequestReceived")
3416 .field("request_id", request_id)
3417 .field("service_id", service_id)
3418 .field("method", method)
3419 .field("body", &format!("[REDACTED {} bytes]", body.len()))
3420 .field("sender", &"[REDACTED]")
3421 .finish(),
3422 Self::ServiceResponseReceived {
3423 request_id,
3424 service_id,
3425 status,
3426 body,
3427 provider_peer_id: _,
3428 } => f
3429 .debug_struct("ServiceResponseReceived")
3430 .field("request_id", request_id)
3431 .field("service_id", service_id)
3432 .field("status", status)
3433 .field("body", &format!("[REDACTED {} bytes]", body.len()))
3434 .field("provider_peer_id", &"[REDACTED]")
3435 .finish(),
3436 Self::GroupCreated { group_id, name } => f
3437 .debug_struct("GroupCreated")
3438 .field("group_id", group_id)
3439 .field("name", name)
3440 .finish(),
3441 Self::GroupMessageReceived {
3442 group_id,
3443 sender: _,
3444 content,
3445 forward_info,
3446 ..
3447 } => f
3448 .debug_struct("GroupMessageReceived")
3449 .field("group_id", group_id)
3450 .field("sender", &"[REDACTED]")
3451 .field("content", &format!("[REDACTED {} bytes]", content.len()))
3452 .field("forward_info", &forward_info.is_some())
3453 .finish(),
3454 Self::GroupMemberAdded {
3455 group_id,
3456 user_id: _,
3457 added_by: _,
3458 group_name,
3459 authorized,
3460 } => f
3461 .debug_struct("GroupMemberAdded")
3462 .field("group_id", group_id)
3463 .field("user_id", &"[REDACTED]")
3464 .field("added_by", &"[REDACTED]")
3465 .field("group_name", group_name)
3466 .field("authorized", authorized)
3467 .finish(),
3468 Self::GroupMemberRemoved {
3469 group_id,
3470 user_id: _,
3471 removed_by: _,
3472 authorized,
3473 } => f
3474 .debug_struct("GroupMemberRemoved")
3475 .field("group_id", group_id)
3476 .field("user_id", &"[REDACTED]")
3477 .field("removed_by", &"[REDACTED]")
3478 .field("authorized", authorized)
3479 .finish(),
3480 Self::GroupInfo {
3481 group_id,
3482 name,
3483 created_by: _,
3484 created_at,
3485 members,
3486 } => f
3487 .debug_struct("GroupInfo")
3488 .field("group_id", group_id)
3489 .field("name", name)
3490 .field("created_by", &"[REDACTED]")
3491 .field("created_at", created_at)
3492 .field("members_count", &members.len())
3493 .finish(),
3494 Self::UserGroups { groups } => f
3495 .debug_struct("UserGroups")
3496 .field("groups_count", &groups.len())
3497 .finish(),
3498 Self::GroupError { reason, group_id } => f
3499 .debug_struct("GroupError")
3500 .field("reason", reason)
3501 .field("group_id", group_id)
3502 .finish(),
3503 Self::GroupRelaySyncChanged {
3504 group_id,
3505 synced,
3506 reason,
3507 } => f
3508 .debug_struct("GroupRelaySyncChanged")
3509 .field("group_id", group_id)
3510 .field("synced", synced)
3511 .field("reason", reason)
3512 .finish(),
3513 // The username is redacted: it is a human-readable name for a
3514 // person, which is exactly the class of identifier this Debug
3515 // exists to keep out of logs. The counts say everything a reader
3516 // debugging a resolution needs.
3517 Self::UsernameResolved {
3518 username: _,
3519 claims,
3520 rejected,
3521 truncated,
3522 } => f
3523 .debug_struct("UsernameResolved")
3524 .field("username", &"<redacted>")
3525 .field("claim_count", &claims.len())
3526 .field("rejected", rejected)
3527 .field("truncated", truncated)
3528 .finish(),
3529 Self::GroupMessageSent {
3530 group_id,
3531 message_ids,
3532 member_count,
3533 } => f
3534 .debug_struct("GroupMessageSent")
3535 .field("group_id", group_id)
3536 .field("message_count", &message_ids.len())
3537 .field("member_count", member_count)
3538 .finish(),
3539 Self::GroupMessagePartialFailure {
3540 group_id,
3541 failed_members,
3542 succeeded_members,
3543 } => f
3544 .debug_struct("GroupMessagePartialFailure")
3545 .field("group_id", group_id)
3546 .field("failed_count", &failed_members.len())
3547 .field("succeeded_count", &succeeded_members.len())
3548 .finish(),
3549 Self::GroupMessageDeliveryReport {
3550 group_id,
3551 message_id,
3552 delivered,
3553 pushed,
3554 missed_reissued,
3555 } => f
3556 .debug_struct("GroupMessageDeliveryReport")
3557 .field("group_id", group_id)
3558 .field("message_id", message_id)
3559 .field("delivered_count", &delivered.len())
3560 .field("pushed_count", &pushed.len())
3561 .field("missed_reissued_count", &missed_reissued.len())
3562 .finish(),
3563 Self::GroupRichExtrasDropped {
3564 group_id,
3565 unknown_members,
3566 } => f
3567 .debug_struct("GroupRichExtrasDropped")
3568 .field("group_id", group_id)
3569 .field("unknown_count", &unknown_members.len())
3570 .finish(),
3571 Self::GroupUnauthorizedMembershipChange {
3572 group_id,
3573 committer: _,
3574 added,
3575 removed,
3576 reason,
3577 enforced,
3578 } => f
3579 .debug_struct("GroupUnauthorizedMembershipChange")
3580 .field("group_id", group_id)
3581 .field("committer", &"[REDACTED]")
3582 .field("added_count", &added.len())
3583 .field("removed_count", &removed.len())
3584 .field("reason", reason)
3585 .field("enforced", enforced)
3586 .finish(),
3587 Self::GroupEpochForkDetected {
3588 group_id,
3589 local_epoch,
3590 } => f
3591 .debug_struct("GroupEpochForkDetected")
3592 .field("group_id", group_id)
3593 .field("local_epoch", local_epoch)
3594 .finish(),
3595 Self::GroupEpochForkResolved {
3596 group_id,
3597 resolved_epoch,
3598 failed_members,
3599 } => f
3600 .debug_struct("GroupEpochForkResolved")
3601 .field("group_id", group_id)
3602 .field("resolved_epoch", resolved_epoch)
3603 .field("failed_members", failed_members)
3604 .finish(),
3605 Self::PresenceUpdated {
3606 peer_id: _,
3607 status,
3608 timestamp,
3609 last_seen_ms,
3610 source,
3611 } => f
3612 .debug_struct("PresenceUpdated")
3613 .field("peer_id", &"[REDACTED]")
3614 .field("status", status)
3615 .field("timestamp", timestamp)
3616 .field("last_seen_ms", last_seen_ms)
3617 .field("source", source)
3618 .finish(),
3619 Self::TypingIndicatorReceived {
3620 sender: _,
3621 conversation_id: _,
3622 is_typing,
3623 timestamp,
3624 } => f
3625 .debug_struct("TypingIndicatorReceived")
3626 .field("sender", &"[REDACTED]")
3627 .field("conversation_id", &"[REDACTED]")
3628 .field("is_typing", is_typing)
3629 .field("timestamp", timestamp)
3630 .finish(),
3631 Self::ReadReceiptReceived {
3632 sender: _,
3633 message_ids,
3634 timestamp,
3635 } => f
3636 .debug_struct("ReadReceiptReceived")
3637 .field("sender", &"[REDACTED]")
3638 .field("message_count", &message_ids.len())
3639 .field("timestamp", timestamp)
3640 .finish(),
3641 Self::DorsScoreUpdated { scores } => f
3642 .debug_struct("DorsScoreUpdated")
3643 .field("scores", scores)
3644 .finish(),
3645 Self::DorsTransportSelected {
3646 from,
3647 transport,
3648 reason_code,
3649 score,
3650 } => f
3651 .debug_struct("DorsTransportSelected")
3652 .field("from", from)
3653 .field("transport", transport)
3654 .field("reason_code", reason_code)
3655 .field("score", score)
3656 .finish(),
3657 Self::DorsTransportSwitched {
3658 from,
3659 to,
3660 reason_code,
3661 reason_detail,
3662 } => f
3663 .debug_struct("DorsTransportSwitched")
3664 .field("from", from)
3665 .field("to", to)
3666 .field("reason_code", reason_code)
3667 .field("reason_detail", reason_detail)
3668 .finish(),
3669 Self::DorsEscalationTriggered {
3670 phase,
3671 from,
3672 to,
3673 reason_code,
3674 reason_detail,
3675 } => f
3676 .debug_struct("DorsEscalationTriggered")
3677 .field("phase", phase)
3678 .field("from", from)
3679 .field("to", to)
3680 .field("reason_code", reason_code)
3681 .field("reason_detail", reason_detail)
3682 .finish(),
3683 // The one arm that used to print `peer_id` verbatim, and the worst
3684 // one to: a security warning is precisely the event an operator
3685 // dumps into a log while investigating, and the peer it names is
3686 // usually attacker-controlled — an injected frame carries whatever
3687 // sender it likes, so this field doubles as a log-injection
3688 // surface. The telemetry scrubber already hashed it; only `{:?}`
3689 // disagreed.
3690 Self::SecurityWarning {
3691 peer_id: _,
3692 reason_code,
3693 reason,
3694 } => f
3695 .debug_struct("SecurityWarning")
3696 .field("peer_id", &"[REDACTED]")
3697 .field("reason_code", reason_code)
3698 .field("reason", reason)
3699 .finish(),
3700 Self::MessageRelayed {
3701 message_id,
3702 sender: _,
3703 recipient: _,
3704 hop_count,
3705 remaining_ttl,
3706 } => f
3707 .debug_struct("MessageRelayed")
3708 .field("message_id", message_id)
3709 .field("sender", &"[REDACTED]")
3710 .field("recipient", &"[REDACTED]")
3711 .field("hop_count", hop_count)
3712 .field("remaining_ttl", remaining_ttl)
3713 .finish(),
3714 Self::UserBlocked { user_id: _ } => f
3715 .debug_struct("UserBlocked")
3716 .field("user_id", &"[REDACTED]")
3717 .finish(),
3718 Self::UserUnblocked { user_id: _ } => f
3719 .debug_struct("UserUnblocked")
3720 .field("user_id", &"[REDACTED]")
3721 .finish(),
3722 Self::DataChanged {
3723 space_id: _,
3724 doc_id: _,
3725 delta_bytes,
3726 } => f
3727 .debug_struct("DataChanged")
3728 // A space id is the peer or group the space rides on, and a
3729 // document name is application content. Both are identifiers,
3730 // so both redact.
3731 .field("space_id", &"[REDACTED]")
3732 .field("doc_id", &"[REDACTED]")
3733 .field("delta_bytes", delta_bytes)
3734 .finish(),
3735 Self::DataDocRemoved {
3736 space_id: _,
3737 doc_id: _,
3738 by,
3739 } => f
3740 .debug_struct("DataDocRemoved")
3741 .field("space_id", &"[REDACTED]")
3742 .field("doc_id", &"[REDACTED]")
3743 // A fixed token this crate chooses, never a peer's words.
3744 .field("by", by)
3745 .finish(),
3746 Self::DataDocSizeWarning {
3747 space_id: _,
3748 doc_id: _,
3749 compacted_bytes,
3750 cap_bytes,
3751 } => f
3752 .debug_struct("DataDocSizeWarning")
3753 .field("space_id", &"[REDACTED]")
3754 .field("doc_id", &"[REDACTED]")
3755 .field("compacted_bytes", compacted_bytes)
3756 .field("cap_bytes", cap_bytes)
3757 .finish(),
3758 Self::DataAttachmentRequested {
3759 space_id: _,
3760 peer_id: _,
3761 hash,
3762 } => f
3763 .debug_struct("DataAttachmentRequested")
3764 .field("space_id", &"[REDACTED]")
3765 .field("peer_id", &"[REDACTED]")
3766 // A blob hash names bytes, not a person, and it is the one
3767 // field that makes a report about a fetch actionable.
3768 .field("hash", hash)
3769 .finish(),
3770 Self::DataAttachmentReceived {
3771 space_id: _,
3772 peer_id: _,
3773 hash,
3774 data,
3775 } => f
3776 .debug_struct("DataAttachmentReceived")
3777 .field("space_id", &"[REDACTED]")
3778 .field("peer_id", &"[REDACTED]")
3779 .field("hash", hash)
3780 // The blob itself is content and never goes near a log.
3781 .field("data", &format!("[{} base64 chars]", data.len()))
3782 .finish(),
3783 Self::DataAttachmentUnavailable {
3784 space_id: _,
3785 peer_id: _,
3786 hash,
3787 reason,
3788 } => f
3789 .debug_struct("DataAttachmentUnavailable")
3790 .field("space_id", &"[REDACTED]")
3791 .field("peer_id", &"[REDACTED]")
3792 .field("hash", hash)
3793 .field("reason", reason)
3794 .finish(),
3795 Self::DataDocUnsyncable {
3796 space_id: _,
3797 doc_id: _,
3798 bytes,
3799 reason,
3800 } => f
3801 .debug_struct("DataDocUnsyncable")
3802 .field("space_id", &"[REDACTED]")
3803 .field("doc_id", &"[REDACTED]")
3804 .field("bytes", bytes)
3805 .field("reason", reason)
3806 .finish(),
3807 Self::GroupRoleChanged {
3808 group_id,
3809 user_id: _,
3810 new_role,
3811 changed_by: _,
3812 } => f
3813 .debug_struct("GroupRoleChanged")
3814 .field("group_id", group_id)
3815 .field("user_id", &"[REDACTED]")
3816 .field("new_role", new_role)
3817 .field("changed_by", &"[REDACTED]")
3818 .finish(),
3819 Self::GroupRenamed {
3820 group_id,
3821 new_name,
3822 old_name: _,
3823 renamed_by: _,
3824 } => f
3825 .debug_struct("GroupRenamed")
3826 .field("group_id", group_id)
3827 .field("new_name", new_name)
3828 .field("renamed_by", &"[REDACTED]")
3829 .finish(),
3830 }
3831 }
3832}
3833
3834#[cfg(test)]
3835mod tests {
3836 use super::*;
3837 use crate::protocol::SEND_FAIL_REASON_RECIPIENT_UNREACHABLE;
3838 use offline_protocol_core::{AppId, Message, MessagePriority, UserId};
3839
3840 #[test]
3841 fn test_message_sent_event() {
3842 let message = Message::builder(
3843 UserId::new("alice").unwrap(),
3844 UserId::new("bob").unwrap(),
3845 AppId::new("app").unwrap(),
3846 )
3847 .content("Hello")
3848 .priority(MessagePriority::High)
3849 .build();
3850
3851 let event = Event::message_sent(&message);
3852
3853 match event {
3854 Event::MessageSent {
3855 message_id,
3856 sender,
3857 recipient,
3858 content,
3859 priority,
3860 requires_ack,
3861 timestamp,
3862 lamport_clock,
3863 forward_info,
3864 } => {
3865 assert_eq!(message_id, message.id.as_str());
3866 assert_eq!(sender, message.sender.as_str());
3867 assert_eq!(recipient, message.recipient.as_str());
3868 assert_eq!(content, message.content);
3869 assert_eq!(priority, "high");
3870 assert!(requires_ack);
3871 assert_eq!(timestamp, message.timestamp.as_millis());
3872 assert_eq!(lamport_clock, message.lamport_clock.value());
3873 assert!(forward_info.is_none());
3874 }
3875 _ => panic!("Wrong event type"),
3876 }
3877 }
3878
3879 #[test]
3880 fn test_message_delivered_event() {
3881 let msg_id = MessageId::new();
3882 let event = Event::message_delivered(msg_id.clone(), 100, 3, TransportType::BLE);
3883
3884 match event {
3885 Event::MessageDelivered {
3886 message_id,
3887 latency_ms,
3888 hop_count,
3889 transport,
3890 } => {
3891 assert_eq!(message_id, msg_id.as_str());
3892 assert_eq!(latency_ms, 100);
3893 assert_eq!(hop_count, 3);
3894 assert_eq!(transport, "ble");
3895 }
3896 _ => panic!("Wrong event type"),
3897 }
3898 }
3899
3900 #[test]
3901 fn test_transport_switched_event() {
3902 let event = Event::transport_switched(
3903 Some(TransportType::BLE),
3904 TransportType::WiFiDirect,
3905 "Poor signal".to_string(),
3906 );
3907
3908 match event {
3909 Event::TransportSwitched { from, to, reason } => {
3910 assert_eq!(from.as_deref(), Some("ble"));
3911 assert_eq!(to, "wifiDirect");
3912 assert_eq!(reason, "Poor signal");
3913 }
3914 _ => panic!("Wrong event type"),
3915 }
3916 }
3917
3918 #[test]
3919 fn test_relay_promoted_event() {
3920 let event = Event::relay_promoted(5, Some(80));
3921
3922 match event {
3923 Event::RelayPromoted {
3924 connection_count,
3925 battery_level,
3926 } => {
3927 assert_eq!(connection_count, 5);
3928 assert_eq!(battery_level, Some(80));
3929 }
3930 _ => panic!("Wrong event type"),
3931 }
3932 }
3933
3934 #[test]
3935 fn test_file_progress_event() {
3936 let event = Event::file_progress("file123".to_string(), 50, 100);
3937
3938 match event {
3939 Event::FileProgress {
3940 file_id,
3941 chunks_sent,
3942 total_chunks,
3943 percentage,
3944 } => {
3945 assert_eq!(file_id, "file123");
3946 assert_eq!(chunks_sent, 50);
3947 assert_eq!(total_chunks, 100);
3948 assert_eq!(percentage, 50);
3949 }
3950 _ => panic!("Wrong event type"),
3951 }
3952 }
3953
3954 #[test]
3955 fn test_event_serialization() {
3956 let event = Event::relay_promoted(5, Some(80));
3957 let json = event.to_json().unwrap();
3958 let deserialized = Event::from_json(&json).unwrap();
3959
3960 match deserialized {
3961 Event::RelayPromoted {
3962 connection_count,
3963 battery_level,
3964 } => {
3965 assert_eq!(connection_count, 5);
3966 assert_eq!(battery_level, Some(80));
3967 }
3968 _ => panic!("Wrong event type after deserialization"),
3969 }
3970 }
3971
3972 /// Pins the JSON wire shape the RN `types.ts` declarations model
3973 /// (`ConnectionRequestUndeliverableEvent` / `initial_message` on
3974 /// `ConnectionRequestReceivedEvent`) — events cross the FFI as JSON, so
3975 /// this contract is otherwise enforced only by convention.
3976 #[test]
3977 fn test_connection_request_event_wire_shapes() {
3978 let event = Event::connection_request_undeliverable(
3979 "bob".to_string(),
3980 "m1".to_string(),
3981 SEND_FAIL_REASON_RECIPIENT_UNREACHABLE,
3982 );
3983 let json: serde_json::Value = serde_json::from_str(&event.to_json().unwrap()).unwrap();
3984 assert_eq!(json["type"], "connection_request_undeliverable");
3985 assert_eq!(json["recipient"], "bob");
3986 assert_eq!(json["message_id"], "m1");
3987 // The bare token, never the relay's `recipient_unreachable: <prose>`.
3988 assert_eq!(json["reason"], "recipient_unreachable");
3989
3990 let received = Event::connection_request_received(
3991 "alice".to_string(),
3992 "Alice".to_string(),
3993 12345,
3994 None,
3995 Some("hey".to_string()),
3996 );
3997 let json: serde_json::Value = serde_json::from_str(&received.to_json().unwrap()).unwrap();
3998 assert_eq!(json["type"], "connection_request_received");
3999 assert_eq!(json["initial_message"], "hey");
4000
4001 // None must omit the key entirely (skip_serializing_if), keeping the
4002 // event byte-shape identical to pre-initial-message builds.
4003 let received = Event::connection_request_received(
4004 "alice".to_string(),
4005 "Alice".to_string(),
4006 12345,
4007 None,
4008 None,
4009 );
4010 assert!(!received.to_json().unwrap().contains("initial_message"));
4011 }
4012
4013 /// Pins the `group_error` wire shape (#349).
4014 ///
4015 /// `group_id` is additive, so a build that never scopes an error must
4016 /// serialize byte-identically to pre-change builds — the same
4017 /// `skip_serializing_if` contract `initial_message` is pinned on above.
4018 #[test]
4019 fn test_group_error_event_wire_shape() {
4020 let scoped = Event::group_error("sync_denied".to_string(), Some("g-1".to_string()));
4021 let json: serde_json::Value = serde_json::from_str(&scoped.to_json().unwrap()).unwrap();
4022 assert_eq!(json["type"], "group_error");
4023 assert_eq!(json["reason"], "sync_denied");
4024 assert_eq!(json["group_id"], "g-1");
4025
4026 let unscoped = Event::group_error("error".to_string(), None);
4027 assert!(!unscoped.to_json().unwrap().contains("group_id"));
4028 }
4029
4030 #[test]
4031 fn test_network_metrics_event() {
4032 let event = Event::network_metrics(10, 3, 0.95, 150);
4033
4034 match event {
4035 Event::NetworkMetrics {
4036 neighbor_count,
4037 relay_count,
4038 delivery_ratio,
4039 avg_latency_ms,
4040 } => {
4041 assert_eq!(neighbor_count, 10);
4042 assert_eq!(relay_count, 3);
4043 assert_eq!(delivery_ratio, 0.95);
4044 assert_eq!(avg_latency_ms, 150);
4045 }
4046 _ => panic!("Wrong event type"),
4047 }
4048 }
4049
4050 #[test]
4051 fn test_file_received_event() {
4052 let event = Event::file_received(
4053 "file123".to_string(),
4054 "photo.jpg".to_string(),
4055 1024000,
4056 "alice".to_string(),
4057 ContentType::Image,
4058 None,
4059 vec![1, 2, 3, 4],
4060 None,
4061 None,
4062 None,
4063 None,
4064 None,
4065 );
4066
4067 match event {
4068 Event::FileReceived {
4069 file_id,
4070 file_name,
4071 file_size,
4072 sender,
4073 content_type,
4074 file_data,
4075 ..
4076 } => {
4077 assert_eq!(file_id, "file123");
4078 assert_eq!(file_name, "photo.jpg");
4079 assert_eq!(file_size, 1024000);
4080 assert_eq!(sender, "alice");
4081 assert_eq!(content_type, "image");
4082 assert!(!file_data.is_empty());
4083 }
4084 _ => panic!("Wrong event type"),
4085 }
4086 }
4087
4088 #[test]
4089 fn test_file_received_event_rich_fields_absent_from_json_when_none() {
4090 // The rich fields are additive on the event's JSON wire form: a
4091 // plain transfer must serialize exactly as it did before they
4092 // existed, so older consumers see no new keys.
4093 let event = Event::file_received(
4094 "file123".to_string(),
4095 "f.bin".to_string(),
4096 4,
4097 "alice".to_string(),
4098 ContentType::File,
4099 None,
4100 vec![1, 2, 3, 4],
4101 None,
4102 None,
4103 None,
4104 None,
4105 None,
4106 );
4107 let json = serde_json::to_value(&event).unwrap();
4108 for key in [
4109 "timestamp",
4110 "caption",
4111 "reply_to_msg",
4112 "reply_context",
4113 "forward_info",
4114 ] {
4115 assert!(json.get(key).is_none(), "{} must be absent when unset", key);
4116 }
4117 }
4118
4119 #[test]
4120 fn test_file_received_event_rich_fields_round_trip() {
4121 use offline_protocol_core::UserId;
4122 let rc = offline_protocol_core::ReplyContext {
4123 sender: UserId::new("carol").unwrap(),
4124 text: "the original".to_string(),
4125 timestamp: None,
4126 reply_media_label: None,
4127 reply_content_type: Some("text".to_string()),
4128 };
4129 let fwd = offline_protocol_core::ForwardInfo {
4130 original_sender: UserId::new("dave").unwrap(),
4131 original_message_id: offline_protocol_core::MessageId::new(),
4132 original_timestamp: offline_protocol_core::Timestamp::now(),
4133 forward_count: 1,
4134 };
4135 let event = Event::file_received(
4136 "file123".to_string(),
4137 "photo.jpg".to_string(),
4138 1024,
4139 "alice".to_string(),
4140 ContentType::Image,
4141 None,
4142 vec![1, 2, 3],
4143 Some(1_700_000_000_000),
4144 Some("look".to_string()),
4145 Some("0192aaaa-bbbb-cccc-dddd-eeeeffff0000".to_string()),
4146 Some(&rc),
4147 Some(&fwd),
4148 );
4149 let json = serde_json::to_string(&event).unwrap();
4150 let parsed: Event = serde_json::from_str(&json).unwrap();
4151 match parsed {
4152 Event::FileReceived {
4153 timestamp,
4154 caption,
4155 reply_to_msg,
4156 reply_context,
4157 forward_info,
4158 ..
4159 } => {
4160 assert_eq!(timestamp, Some(1_700_000_000_000));
4161 assert_eq!(caption.as_deref(), Some("look"));
4162 assert_eq!(
4163 reply_to_msg.as_deref(),
4164 Some("0192aaaa-bbbb-cccc-dddd-eeeeffff0000")
4165 );
4166 let rc = reply_context.expect("reply_context must round-trip");
4167 assert_eq!(rc.sender, "carol");
4168 assert_eq!(rc.text, "the original");
4169 let fwd = forward_info.expect("forward_info must round-trip");
4170 assert_eq!(fwd.original_sender, "dave");
4171 assert_eq!(fwd.forward_count, 1);
4172 }
4173 _ => panic!("Wrong event type"),
4174 }
4175 }
4176
4177 #[test]
4178 fn test_security_warning_code_as_str_matches_serde_wire_form() {
4179 // `SecurityWarningCode::as_str()` is hand-written, while the JSON wire
4180 // form is derived from `#[serde(rename_all = "SCREAMING_SNAKE_CASE")]`.
4181 // JS consumers branch on the wire string (e.g. keying reinstall handling
4182 // off `SENDER_ADDRESS_MISMATCH`), so the two must never drift. Pin every
4183 // variant to its serialized form — a single-variant test would let the
4184 // others rot.
4185 let all = [
4186 SecurityWarningCode::SenderAddressMismatch,
4187 SecurityWarningCode::TransportIdentityMismatch,
4188 SecurityWarningCode::ControlSignatureInvalid,
4189 SecurityWarningCode::UnsignedControlRejected,
4190 SecurityWarningCode::MediaSenderGroupMismatch,
4191 SecurityWarningCode::PlaintextSend,
4192 SecurityWarningCode::PlaintextReceiveRejected,
4193 SecurityWarningCode::SessionSenderGroupMismatch,
4194 SecurityWarningCode::SessionRekeyTriggered,
4195 SecurityWarningCode::NostrKeyPackageSlotExhausted,
4196 SecurityWarningCode::PushKeyPackagePoolExhausted,
4197 SecurityWarningCode::RelayAddressBindingMismatch,
4198 SecurityWarningCode::RelayAddressDeclarationRefused,
4199 SecurityWarningCode::GroupLeafIdentityUnproven,
4200 SecurityWarningCode::StaleControlFrame,
4201 SecurityWarningCode::GatewayAddressBindingMismatch,
4202 SecurityWarningCode::GatewayAddressDeclarationRefused,
4203 ];
4204 for code in all {
4205 // serde renders a unit enum variant as a quoted JSON string.
4206 let wire = serde_json::to_string(&code).unwrap();
4207 assert_eq!(
4208 format!("\"{}\"", code.as_str()),
4209 wire,
4210 "as_str() drifted from the serde wire form for {code:?}",
4211 );
4212 // Exhaustiveness guard (no wildcard): adding a variant makes this
4213 // match fail to compile until it is also added to `all` above and
4214 // pinned to its wire form.
4215 match code {
4216 SecurityWarningCode::SenderAddressMismatch
4217 | SecurityWarningCode::TransportIdentityMismatch
4218 | SecurityWarningCode::ControlSignatureInvalid
4219 | SecurityWarningCode::UnsignedControlRejected
4220 | SecurityWarningCode::MediaSenderGroupMismatch
4221 | SecurityWarningCode::PlaintextSend
4222 | SecurityWarningCode::PlaintextReceiveRejected
4223 | SecurityWarningCode::SessionSenderGroupMismatch
4224 | SecurityWarningCode::SessionRekeyTriggered
4225 | SecurityWarningCode::NostrKeyPackageSlotExhausted
4226 | SecurityWarningCode::PushKeyPackagePoolExhausted
4227 | SecurityWarningCode::RelayAddressBindingMismatch
4228 | SecurityWarningCode::RelayAddressDeclarationRefused
4229 | SecurityWarningCode::GroupLeafIdentityUnproven
4230 | SecurityWarningCode::StaleControlFrame
4231 | SecurityWarningCode::GatewayAddressBindingMismatch
4232 | SecurityWarningCode::GatewayAddressDeclarationRefused => {}
4233 }
4234 }
4235 }
4236
4237 #[test]
4238 fn test_debug_redacts_every_peer_identifying_field() {
4239 // `SecurityWarning` printed its `peer_id` verbatim while the other
4240 // twelve peer-bearing arms redacted theirs, so a single missed arm was
4241 // invisible: `{:?}` on any *other* event looked correct. Pinning one
4242 // arm would leave the next omission just as invisible, so this
4243 // enumerates every variant carrying a peer-identifying field and
4244 // asserts the sentinel never survives formatting.
4245 //
4246 // Deliberately a *value* assertion rather than a field-name one: the
4247 // question is whether the id reaches the log, not how the arm spells
4248 // its placeholder.
4249 const SENTINEL: &str = "off1qy4aspkf0u8qptc6rlpn9ra8vw5jd9ereq4cwpfs";
4250
4251 let events = vec![
4252 Event::NeighborDiscovered {
4253 peer_id: SENTINEL.to_string(),
4254 transport: "ble".to_string(),
4255 rssi: Some(-40),
4256 },
4257 Event::NeighborLost {
4258 peer_id: SENTINEL.to_string(),
4259 },
4260 Event::SecureSessionEstablished {
4261 peer_id: SENTINEL.to_string(),
4262 group_id: "session:a:b".to_string(),
4263 is_session: true,
4264 initiated_by_local: true,
4265 },
4266 Event::SecureSessionFailed {
4267 peer_id: SENTINEL.to_string(),
4268 reason: "nope".to_string(),
4269 },
4270 Event::ConvergenceDiag {
4271 stage: "stage".to_string(),
4272 peer_id: SENTINEL.to_string(),
4273 detail: "detail".to_string(),
4274 },
4275 Event::WelcomeSendAttempted {
4276 peer_id: SENTINEL.to_string(),
4277 message_id: "m1".to_string(),
4278 group_id: "g1".to_string(),
4279 attempt: 1,
4280 },
4281 Event::WelcomeSendSucceeded {
4282 peer_id: SENTINEL.to_string(),
4283 message_id: "m1".to_string(),
4284 group_id: "g1".to_string(),
4285 attempt: 1,
4286 },
4287 Event::WelcomeSendFailed {
4288 peer_id: SENTINEL.to_string(),
4289 message_id: "m1".to_string(),
4290 group_id: "g1".to_string(),
4291 attempt: 1,
4292 reason_code: WelcomeReasonCode::Timeout,
4293 transport_error: None,
4294 retryable: true,
4295 next_retry_at: None,
4296 },
4297 Event::WelcomeSendExpired {
4298 peer_id: SENTINEL.to_string(),
4299 message_id: "m1".to_string(),
4300 attempt: 3,
4301 reason_code: WelcomeReasonCode::RetryExhausted,
4302 },
4303 Event::ServiceDiscovered {
4304 query_id: "q1".to_string(),
4305 service_id: "s1".to_string(),
4306 version: "1".to_string(),
4307 provider_peer_id: SENTINEL.to_string(),
4308 capabilities: HashMap::new(),
4309 hop_count: 1,
4310 },
4311 Event::ServiceResponseReceived {
4312 request_id: "r1".to_string(),
4313 service_id: "s1".to_string(),
4314 status: "ok".to_string(),
4315 body: "body".to_string(),
4316 provider_peer_id: SENTINEL.to_string(),
4317 },
4318 Event::PresenceUpdated {
4319 peer_id: SENTINEL.to_string(),
4320 status: PresenceStatus::Online,
4321 timestamp: 0,
4322 last_seen_ms: None,
4323 source: PresenceSource::Peer,
4324 },
4325 Event::SecurityWarning {
4326 peer_id: SENTINEL.to_string(),
4327 reason_code: SecurityWarningCode::SenderAddressMismatch,
4328 reason: "mismatch".to_string(),
4329 },
4330 ];
4331
4332 for event in &events {
4333 let rendered = format!("{:?}", event);
4334 assert!(
4335 !rendered.contains(SENTINEL),
4336 "Debug leaked a peer identifier: {rendered}",
4337 );
4338 }
4339 }
4340
4341 /// Mirrors serde's `rename_all = "snake_case"` rule.
4342 fn serde_snake_case(name: &str) -> String {
4343 let mut out = String::new();
4344 for (i, ch) in name.chars().enumerate() {
4345 if ch.is_ascii_uppercase() {
4346 if i > 0 {
4347 out.push('_');
4348 }
4349 out.push(ch.to_ascii_lowercase());
4350 } else {
4351 out.push(ch);
4352 }
4353 }
4354 out
4355 }
4356
4357 /// Drift guard: every `Event` variant must have a typed declaration in the
4358 /// React Native bindings. Events cross UniFFI as tagged JSON, so nothing
4359 /// else fails when `types.ts` lags — the event just arrives untyped and
4360 /// invisible to the `ProtocolEvent` union (7 variants had silently drifted
4361 /// before this guard existed).
4362 #[test]
4363 fn react_native_types_cover_all_event_variants() {
4364 use std::collections::BTreeSet;
4365
4366 let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
4367 let events_rs = std::fs::read_to_string(manifest_dir.join("src/events.rs")).unwrap();
4368 let types_ts_path = manifest_dir.join("../../bindings/react-native/src/types.ts");
4369 let types_ts = std::fs::read_to_string(&types_ts_path)
4370 .unwrap_or_else(|e| panic!("cannot read {}: {e}", types_ts_path.display()));
4371
4372 // Collect each `Event` variant's serde tag by scanning the enum block:
4373 // variant names sit at 4-space indent, brace depth 1.
4374 let mut rust_tags = BTreeSet::new();
4375 let mut in_enum = false;
4376 let mut depth = 0usize;
4377 for line in events_rs.lines() {
4378 if !in_enum {
4379 if line.starts_with("pub enum Event {") {
4380 in_enum = true;
4381 depth = 1;
4382 }
4383 continue;
4384 }
4385 let trimmed = line.trim();
4386 if trimmed.starts_with("//") || trimmed.starts_with("#[") {
4387 continue;
4388 }
4389 if depth == 1
4390 && line.starts_with(" ")
4391 && !line.starts_with(" ")
4392 && trimmed
4393 .chars()
4394 .next()
4395 .is_some_and(|c| c.is_ascii_uppercase())
4396 {
4397 let name: String = trimmed
4398 .chars()
4399 .take_while(|c| c.is_ascii_alphanumeric())
4400 .collect();
4401 rust_tags.insert(serde_snake_case(&name));
4402 }
4403 depth += line.matches('{').count();
4404 depth = depth.saturating_sub(line.matches('}').count());
4405 if depth == 0 {
4406 break;
4407 }
4408 }
4409 assert!(
4410 rust_tags.len() >= 60,
4411 "enum scan looks broken: only {} variants found",
4412 rust_tags.len()
4413 );
4414
4415 // Event interface discriminants in types.ts: ` type: '<tag>';` at
4416 // exactly 2-space indent (nested object fields sit deeper).
4417 let ts_tags: BTreeSet<String> = types_ts
4418 .lines()
4419 .filter_map(|l| l.strip_prefix(" type: '"))
4420 .filter_map(|rest| rest.strip_suffix("';"))
4421 .map(str::to_string)
4422 .collect();
4423
4424 // Emitted by the native iOS/Android bridges, not the core enum.
4425 let bridge_only: BTreeSet<String> = [
4426 "diagnostic",
4427 "internet_server_message",
4428 "internet_status_changed",
4429 "internet_session_superseded",
4430 // Android only: the mesh foreground service's notification has no
4431 // iOS counterpart.
4432 "mesh_stopped_by_user",
4433 ]
4434 .map(str::to_string)
4435 .into();
4436
4437 let missing: Vec<_> = rust_tags.difference(&ts_tags).collect();
4438 assert!(
4439 missing.is_empty(),
4440 "Event variants missing a typed declaration in bindings/react-native/src/types.ts \
4441 (add the interface AND its ProtocolEvent union entry): {missing:?}"
4442 );
4443
4444 let stale: Vec<_> = ts_tags
4445 .difference(&rust_tags)
4446 .filter(|t| !bridge_only.contains(*t))
4447 .collect();
4448 assert!(
4449 stale.is_empty(),
4450 "types.ts declares event tags with no core Event variant \
4451 (stale, or a new bridge-only event that needs allowlisting here): {stale:?}"
4452 );
4453 }
4454
4455 /// Drift guard for the *payload* side of the same seam: apps switch on
4456 /// `reason_code`, so a code with no entry in the RN union is invisible to
4457 /// exhaustive handling on the app side and silently falls into whatever
4458 /// default a `switch` has. `NOSTR_KEY_PACKAGE_SLOT_EXHAUSTED` had already
4459 /// drifted this way before this guard existed.
4460 #[test]
4461 fn react_native_types_cover_all_security_warning_codes() {
4462 use std::collections::BTreeSet;
4463
4464 let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
4465 let events_rs = std::fs::read_to_string(manifest_dir.join("src/events.rs")).unwrap();
4466 let types_ts_path = manifest_dir.join("../../bindings/react-native/src/types.ts");
4467 let types_ts = std::fs::read_to_string(&types_ts_path)
4468 .unwrap_or_else(|e| panic!("cannot read {}: {e}", types_ts_path.display()));
4469
4470 // The wire forms are exactly the string literals `as_str` maps to, and
4471 // the test above already pins those to serde's rendering.
4472 // Anchored on the impl block: several enums in this file have an
4473 // identical `as_str` signature, and the first match is a different one.
4474 let as_str_body = events_rs
4475 .split_once("impl SecurityWarningCode {")
4476 .expect("impl SecurityWarningCode not found")
4477 .1
4478 .split_once(" pub fn as_str(&self) -> &'static str {")
4479 .expect("SecurityWarningCode::as_str not found")
4480 .1
4481 .split_once("\n }")
4482 .expect("as_str body not terminated")
4483 .0;
4484 let rust_codes: BTreeSet<String> = as_str_body
4485 .lines()
4486 .filter_map(|l| l.split_once("=> \""))
4487 .filter_map(|(_, rest)| rest.split_once('"'))
4488 .map(|(code, _)| code.to_string())
4489 .collect();
4490
4491 // Negative control: a scan that silently matched nothing would make
4492 // every assertion below vacuously true. The floor tracks the variant
4493 // count loosely — it exists to catch a broken scan, not to freeze the
4494 // taxonomy, so it moves when variants are deliberately added or (as
4495 // when the TOFU codes were deleted) removed.
4496 assert!(
4497 rust_codes.len() >= 11,
4498 "as_str scan looks broken: only {} codes found",
4499 rust_codes.len()
4500 );
4501
4502 let union = types_ts
4503 .split_once("export type SecurityWarningCode =")
4504 .expect("SecurityWarningCode union not found in types.ts")
4505 .1
4506 .split_once(';')
4507 .expect("SecurityWarningCode union not terminated")
4508 .0;
4509 let ts_codes: BTreeSet<String> = union
4510 .lines()
4511 .filter_map(|l| l.trim().strip_prefix("| '"))
4512 .filter_map(|rest| rest.strip_suffix('\''))
4513 .map(str::to_string)
4514 .collect();
4515
4516 let missing: Vec<_> = rust_codes.difference(&ts_codes).collect();
4517 assert!(
4518 missing.is_empty(),
4519 "SecurityWarningCode variants missing from the union in \
4520 bindings/react-native/src/types.ts: {missing:?}"
4521 );
4522
4523 let stale: Vec<_> = ts_codes.difference(&rust_codes).collect();
4524 assert!(
4525 stale.is_empty(),
4526 "types.ts declares security warning codes with no Rust variant: {stale:?}"
4527 );
4528 }
4529
4530 /// The same drift guard for `PresenceSource`, which had none.
4531 ///
4532 /// This union is the discriminator on the one event that merges two
4533 /// unrelated streams, and the documented advice is to filter on it, so an
4534 /// app that switches exhaustively over the TS union silently drops every
4535 /// event carrying a source TypeScript has never heard of. Nothing else
4536 /// catches that: unlike `SecurityWarningCode` there is no `as_str` here,
4537 /// the wire form is serde's alone, and a new variant compiles and ships
4538 /// green on both sides of a seam that disagrees.
4539 #[test]
4540 fn react_native_types_cover_all_presence_sources() {
4541 use std::collections::BTreeSet;
4542
4543 let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
4544 let events_rs = std::fs::read_to_string(manifest_dir.join("src/events.rs")).unwrap();
4545 let types_ts_path = manifest_dir.join("../../bindings/react-native/src/types.ts");
4546 let types_ts = std::fs::read_to_string(&types_ts_path)
4547 .unwrap_or_else(|e| panic!("cannot read {}: {e}", types_ts_path.display()));
4548
4549 // Scanned from the source text rather than from a hand-kept list of
4550 // variants, because a hand-kept list is the thing that drifts.
4551 let body = events_rs
4552 .split_once("pub enum PresenceSource {")
4553 .expect("PresenceSource not found")
4554 .1
4555 .split_once(
4556 "
4557}",
4558 )
4559 .expect("PresenceSource body not terminated")
4560 .0;
4561 let rust_sources: BTreeSet<String> = body
4562 .lines()
4563 .map(str::trim)
4564 .filter(|l| !l.starts_with("//") && !l.starts_with("#["))
4565 .filter_map(|l| l.strip_suffix(','))
4566 .filter(|name| !name.is_empty() && name.chars().all(|c| c.is_ascii_alphanumeric()))
4567 .map(|name| serde_snake_case(name))
4568 .collect();
4569
4570 // Negative control: an empty scan would make both assertions below
4571 // vacuously true.
4572 assert!(
4573 rust_sources.len() >= 2,
4574 "PresenceSource scan looks broken: only {} found",
4575 rust_sources.len()
4576 );
4577
4578 // Pins the scan against serde itself, so a rename rule change is
4579 // caught here rather than in an app's switch statement.
4580 assert_eq!(
4581 serde_json::to_string(&PresenceSource::Internet).unwrap(),
4582 "\"internet\"",
4583 "serde's rendering of PresenceSource changed"
4584 );
4585
4586 let union = types_ts
4587 .split_once("export type PresenceSource =")
4588 .expect("PresenceSource union not found in types.ts")
4589 .1
4590 .split_once(';')
4591 .expect("PresenceSource union not terminated")
4592 .0;
4593 let ts_sources: BTreeSet<String> = union
4594 .split('|')
4595 .map(str::trim)
4596 .filter_map(|t| t.strip_prefix('\''))
4597 .filter_map(|t| t.strip_suffix('\''))
4598 .map(str::to_string)
4599 .collect();
4600
4601 let missing: Vec<_> = rust_sources.difference(&ts_sources).collect();
4602 assert!(
4603 missing.is_empty(),
4604 "PresenceSource variants missing from the union in \
4605 bindings/react-native/src/types.ts: {missing:?}"
4606 );
4607
4608 let stale: Vec<_> = ts_sources.difference(&rust_sources).collect();
4609 assert!(
4610 stale.is_empty(),
4611 "types.ts declares presence sources with no Rust variant: {stale:?}"
4612 );
4613 }
4614}