Skip to main content

inline_client/
lib.rs

1#![doc = include_str!("../README.md")]
2#![warn(missing_docs)]
3#![forbid(unsafe_code)]
4
5use std::{fmt, str::FromStr};
6
7use serde::{Deserialize, Serialize};
8use thiserror::Error;
9
10/// Backend/store boundary for client runtime operations.
11pub mod backend;
12
13/// Async client facade and runner.
14pub mod runtime;
15
16/// Realtime connector boundary.
17pub mod realtime;
18
19/// SDK-backed backend implementation.
20pub mod sdk_backend;
21
22/// Inline-native update discovery and bucket recovery policy.
23pub mod sync;
24
25/// Durable store boundary.
26pub mod store;
27
28/// Native Inline client request and record types.
29pub mod types;
30
31pub use backend::{
32    BackendError, BackendResult, ClientBackend, InMemoryBackend, OperationOutcome, SendTextOutcome,
33};
34pub use inline_sdk::ClientIdentity;
35pub use realtime::{
36    FakeRealtimeAttempt, FakeRealtimeConnector, RealtimeConnectRequest, RealtimeConnectionInfo,
37    RealtimeConnector, SdkRealtimeConnector,
38};
39pub use runtime::{
40    ClientCommandError, ClientRequestError, ClientRunner, DEFAULT_COMMAND_QUEUE_CAPACITY,
41    DEFAULT_EVENT_QUEUE_CAPACITY, DEFAULT_LOSSLESS_EVENT_QUEUE_CAPACITY,
42    DEFAULT_MAX_CONCURRENT_REQUESTS, InlineClient, InlineClientBuilder, InlineClientRuntime,
43    LosslessEventDelivery, LosslessEventReceiver, ReconnectPolicy,
44};
45pub use sdk_backend::{SdkBackend, SdkBackendBuildError, SdkBackendBuilder};
46pub use store::{
47    AccountStateSnapshot, ChatStateSnapshot, ClientEventDelivery, ClientStore, InMemoryStore,
48    PendingSyncBatch, SqliteStore, StoreError, StoreResult, StoredReaction, StoredReadState,
49    StoredSession, StoredTransaction, SyncBucketKey, SyncBucketPeer, SyncBucketState, SyncState,
50};
51pub use sync::SyncConfig;
52pub use types::{
53    AddChatParticipantRequest, AuthContactKind, AuthCredential, AuthStartRequest, AuthStartResult,
54    AuthToken, AuthVerifyRequest, AuthVerifyResult, ChatCreateParticipant, ChatParticipantRecord,
55    ChatParticipantsPage, ChatParticipantsRequest, ClientStatusSnapshot, ConnectRequest,
56    CreateDmRequest, CreateReplyThreadRequest, CreateThreadRequest, CreatedChat, DeleteChatRequest,
57    DeleteMessageRequest, DialogFollowMode, DialogNotificationMode, DialogRecord, DialogsOrder,
58    DialogsPage, DialogsRequest, EditMessageRequest, HistoryPage, HistoryRequest, MediaKind,
59    MessageContent, MessageMutation, MessageRecord, NotificationMode, PeerRef, ReactRequest,
60    ReadRequest, RemoveChatParticipantRequest, SendTextRequest, SetMarkedUnreadRequest,
61    SpaceMemberRecord, SpaceMemberRole, SpaceRecord, TypingRequest, UpdateChatInfoRequest,
62    UpdateDialogNotificationsRequest, UploadHandle, UploadRequest, UserRecord, UserSettingsRecord,
63};
64
65/// Published package version.
66pub const VERSION: &str = env!("CARGO_PKG_VERSION");
67
68/// Lossless bucket-sync schema implemented by this client release.
69pub const CORE_SYNC_SCHEMA_REVISION: u32 = 1;
70
71/// Errors returned by public `inline-client` helper types.
72#[derive(Clone, Debug, Error, PartialEq, Eq)]
73#[non_exhaustive]
74pub enum ClientError {
75    /// A required string field was empty or whitespace-only.
76    #[error("{field} must not be empty")]
77    EmptyField {
78        /// Field name.
79        field: &'static str,
80    },
81
82    /// A string field exceeded its public API limit.
83    #[error("{field} is too long ({len} > {max})")]
84    FieldTooLong {
85        /// Field name.
86        field: &'static str,
87        /// Actual length in bytes.
88        len: usize,
89        /// Maximum allowed length in bytes.
90        max: usize,
91    },
92
93    /// A string field contained ASCII control characters.
94    #[error("{field} must not contain ASCII control characters")]
95    ControlCharacter {
96        /// Field name.
97        field: &'static str,
98    },
99}
100
101fn validate_token<'a>(
102    field: &'static str,
103    value: &'a str,
104    max_len: usize,
105) -> Result<&'a str, ClientError> {
106    let trimmed = value.trim();
107    if trimmed.is_empty() {
108        return Err(ClientError::EmptyField { field });
109    }
110    if trimmed.len() > max_len {
111        return Err(ClientError::FieldTooLong {
112            field,
113            len: trimmed.len(),
114            max: max_len,
115        });
116    }
117    if trimmed.chars().any(|ch| ch.is_ascii_control()) {
118        return Err(ClientError::ControlCharacter { field });
119    }
120    Ok(trimmed)
121}
122
123/// Opaque Inline ID used by high-level client events.
124///
125/// Inline server IDs are represented as signed 64-bit integers across existing
126/// clients. This wrapper avoids mixing user, chat, and message IDs with other
127/// integers in public event structures while keeping serialization simple.
128#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
129#[serde(transparent)]
130pub struct InlineId(i64);
131
132impl InlineId {
133    /// Creates an Inline ID from its raw wire value.
134    pub const fn new(value: i64) -> Self {
135        Self(value)
136    }
137
138    /// Returns the raw wire value.
139    pub const fn get(self) -> i64 {
140        self.0
141    }
142}
143
144impl From<i64> for InlineId {
145    fn from(value: i64) -> Self {
146        Self::new(value)
147    }
148}
149
150impl fmt::Display for InlineId {
151    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
152        self.0.fmt(f)
153    }
154}
155
156/// Deterministic random ID attached to an Inline mutation.
157///
158/// Hosts should derive this from their own durable event or operation ID plus
159/// message content so retries after restart remain idempotent.
160#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
161#[serde(transparent)]
162pub struct RandomId(i64);
163
164impl RandomId {
165    /// Creates a random ID wrapper from a caller-provided deterministic value.
166    pub const fn new(value: i64) -> Self {
167        Self(value)
168    }
169
170    /// Returns the raw random ID.
171    pub const fn get(self) -> i64 {
172        self.0
173    }
174}
175
176impl From<i64> for RandomId {
177    fn from(value: i64) -> Self {
178        Self::new(value)
179    }
180}
181
182impl fmt::Display for RandomId {
183    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
184        self.0.fmt(f)
185    }
186}
187
188/// Opaque host-provided ID used for idempotency and echo reconciliation.
189///
190/// Hosts can use their own source namespaces, such as `host-event` or
191/// `agent-task`. The core client treats this as metadata and does not depend on
192/// host-specific semantics.
193#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
194pub struct ExternalId {
195    source: String,
196    id: String,
197}
198
199impl ExternalId {
200    /// Maximum source namespace length in bytes.
201    pub const MAX_SOURCE_LEN: usize = 64;
202
203    /// Maximum external ID length in bytes.
204    pub const MAX_ID_LEN: usize = 512;
205
206    /// Creates a validated external ID.
207    pub fn try_new(source: impl AsRef<str>, id: impl AsRef<str>) -> Result<Self, ClientError> {
208        let source = validate_token("source", source.as_ref(), Self::MAX_SOURCE_LEN)?;
209        let id = validate_token("id", id.as_ref(), Self::MAX_ID_LEN)?;
210        Ok(Self {
211            source: source.to_owned(),
212            id: id.to_owned(),
213        })
214    }
215
216    /// Returns the source namespace.
217    pub fn source(&self) -> &str {
218        &self.source
219    }
220
221    /// Returns the source-local external ID.
222    pub fn id(&self) -> &str {
223        &self.id
224    }
225}
226
227/// Stable client transaction ID.
228#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
229#[serde(transparent)]
230pub struct TransactionId(String);
231
232impl TransactionId {
233    /// Maximum transaction ID length in bytes.
234    pub const MAX_LEN: usize = 128;
235
236    /// Creates a validated transaction ID.
237    pub fn try_new(value: impl AsRef<str>) -> Result<Self, ClientError> {
238        Ok(Self(
239            validate_token("transaction_id", value.as_ref(), Self::MAX_LEN)?.to_owned(),
240        ))
241    }
242
243    /// Returns the transaction ID string.
244    pub fn as_str(&self) -> &str {
245        &self.0
246    }
247}
248
249impl fmt::Display for TransactionId {
250    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
251        self.0.fmt(f)
252    }
253}
254
255impl FromStr for TransactionId {
256    type Err = ClientError;
257
258    fn from_str(value: &str) -> Result<Self, Self::Err> {
259        Self::try_new(value)
260    }
261}
262
263/// Identity data used to reconcile a mutation across host, client, and server.
264#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
265pub struct TransactionIdentity {
266    /// Stable client transaction ID.
267    pub transaction_id: TransactionId,
268    /// Optional host-provided idempotency key.
269    pub external_id: Option<ExternalId>,
270    /// Inline mutation random ID.
271    pub random_id: RandomId,
272    /// Temporary local message ID, when the mutation created one.
273    pub temporary_message_id: Option<InlineId>,
274    /// Final server message ID, when known.
275    pub final_message_id: Option<InlineId>,
276}
277
278impl TransactionIdentity {
279    /// Creates a transaction identity before a temporary or final message ID is known.
280    pub fn new(
281        transaction_id: TransactionId,
282        external_id: Option<ExternalId>,
283        random_id: RandomId,
284    ) -> Self {
285        Self {
286            transaction_id,
287            external_id,
288            random_id,
289            temporary_message_id: None,
290            final_message_id: None,
291        }
292    }
293
294    /// Records the temporary message ID assigned by optimistic local state.
295    pub fn with_temporary_message_id(mut self, message_id: impl Into<InlineId>) -> Self {
296        self.temporary_message_id = Some(message_id.into());
297        self
298    }
299
300    /// Records the final server message ID.
301    pub fn with_final_message_id(mut self, message_id: impl Into<InlineId>) -> Self {
302        self.final_message_id = Some(message_id.into());
303        self
304    }
305}
306
307/// High-level connection/auth state for apps, bridges, and agents.
308#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
309#[non_exhaustive]
310pub enum ClientStatus {
311    /// Client is not connected.
312    Disconnected,
313    /// Client is establishing a session.
314    Connecting,
315    /// Client is connected and able to send/receive realtime updates.
316    Connected,
317    /// Client lost realtime connectivity and is retrying.
318    Reconnecting,
319    /// Client needs interactive auth before it can connect.
320    AuthRequired,
321    /// Client credentials expired and a relogin or refresh is required.
322    AuthExpired,
323    /// The account was logged out or invalidated elsewhere.
324    LoggedOut,
325    /// The client is shutting down.
326    ShuttingDown,
327}
328
329/// Stable error categories exposed to client hosts.
330#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
331#[non_exhaustive]
332pub enum ClientErrorCategory {
333    /// Caller supplied invalid input.
334    InvalidInput,
335    /// Authentication is missing.
336    AuthRequired,
337    /// Authentication expired and must be refreshed.
338    AuthExpired,
339    /// The user must relogin interactively.
340    ReloginRequired,
341    /// Network connectivity failed.
342    Network,
343    /// Operation timed out.
344    Timeout,
345    /// Remote server rate limited the operation.
346    RateLimited,
347    /// Local and remote protocol versions are incompatible.
348    ProtocolMismatch,
349    /// The target entity was not found.
350    NotFound,
351    /// The account does not have permission for the operation.
352    PermissionDenied,
353    /// The operation is unsupported by the current client or server.
354    Unsupported,
355    /// The operation conflicted with newer state.
356    Conflict,
357    /// An internal client error occurred.
358    Internal,
359}
360
361/// Redacted failure value suitable for status endpoints and bridge errors.
362#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
363pub struct ClientFailure {
364    /// Machine-readable category.
365    pub category: ClientErrorCategory,
366    /// Human-readable message with secrets and message bodies redacted.
367    pub message: String,
368}
369
370impl ClientFailure {
371    /// Creates a redacted failure.
372    pub fn new(category: ClientErrorCategory, message: impl Into<String>) -> Self {
373        Self {
374            category,
375            message: message.into(),
376        }
377    }
378}
379
380/// State of a durable client transaction.
381#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
382#[non_exhaustive]
383pub enum TransactionState {
384    /// Transaction is queued locally.
385    Queued,
386    /// Transaction was sent to the realtime or API transport.
387    Sent,
388    /// Transport acknowledged receipt, but final server result is not known.
389    Acked,
390    /// Server result completed and final state was applied.
391    Completed,
392    /// Transaction failed permanently or exhausted retries.
393    Failed,
394    /// Transaction was cancelled locally.
395    Cancelled,
396}
397
398/// Transaction event emitted after client state changes.
399#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
400pub struct TransactionEvent {
401    /// Mutation identity and reconciliation IDs.
402    pub identity: TransactionIdentity,
403    /// Current transaction state.
404    pub state: TransactionState,
405    /// Redacted failure, present for failed transactions.
406    pub failure: Option<ClientFailure>,
407}
408
409/// Reliability class for events delivered to hosts.
410#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
411pub enum EventReliability {
412    /// Event must be delivered or recovered from durable state.
413    Lossless,
414    /// Event may be dropped under backpressure.
415    BestEffort,
416}
417
418/// Committed client event for apps, bridges, agents, and hosts.
419#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
420#[non_exhaustive]
421pub enum ClientEvent {
422    /// Connection/auth status changed.
423    StatusChanged {
424        /// New client status.
425        status: ClientStatus,
426        /// Optional redacted failure details.
427        failure: Option<ClientFailure>,
428    },
429    /// A durable transaction changed state.
430    TransactionChanged(TransactionEvent),
431    /// A chat was inserted or updated in the local store.
432    ChatUpserted {
433        /// Inline chat ID.
434        chat_id: InlineId,
435    },
436    /// A chat was deleted from durable client state.
437    ChatDeleted {
438        /// Inline chat ID.
439        chat_id: InlineId,
440    },
441    /// The participant snapshot for a chat changed.
442    ChatParticipantsChanged {
443        /// Inline chat ID.
444        chat_id: InlineId,
445    },
446    /// A user was inserted or updated in the local store.
447    UserUpserted {
448        /// Inline user ID.
449        user_id: InlineId,
450    },
451    /// A space was inserted or updated in durable client state.
452    SpaceUpserted {
453        /// Inline space ID.
454        space_id: InlineId,
455    },
456    /// Durable membership for a space changed.
457    SpaceMemberChanged {
458        /// Inline space ID.
459        space_id: InlineId,
460        /// Inline user ID.
461        user_id: InlineId,
462        /// Whether the member was removed.
463        removed: bool,
464    },
465    /// Durable global user settings changed.
466    UserSettingsChanged {},
467    /// A bot message action was invoked and requires lossless host handling.
468    MessageActionInvoked {
469        /// Server interaction ID.
470        interaction_id: InlineId,
471        /// Chat containing the action.
472        chat_id: InlineId,
473        /// Message containing the action.
474        message_id: InlineId,
475        /// User who invoked the action.
476        actor_user_id: InlineId,
477        /// Bot-defined action ID.
478        action_id: String,
479        /// Opaque bot-defined payload.
480        data: Vec<u8>,
481    },
482    /// A previously invoked message action received a UI response.
483    MessageActionAnswered {
484        /// Server interaction ID.
485        interaction_id: InlineId,
486        /// Optional toast text supplied by the bot.
487        toast: Option<String>,
488    },
489    /// A message was inserted or updated in the local store.
490    MessageUpserted {
491        /// Inline chat ID.
492        chat_id: InlineId,
493        /// Inline message ID.
494        message_id: InlineId,
495    },
496    /// A message was inserted or updated and the committed record is available.
497    MessageStored {
498        /// Committed message record.
499        message: MessageRecord,
500    },
501    /// A message was deleted or unsent.
502    MessageDeleted {
503        /// Inline chat ID.
504        chat_id: InlineId,
505        /// Inline message ID.
506        message_id: InlineId,
507    },
508    /// Stored history was cleared for a chat, optionally before a timestamp.
509    ChatHistoryCleared {
510        /// Inline chat ID.
511        chat_id: InlineId,
512        /// Unix timestamp cutoff; `None` means all history.
513        before_date: Option<i64>,
514    },
515    /// Reactions for a message changed.
516    ReactionChanged {
517        /// Inline chat ID.
518        chat_id: InlineId,
519        /// Inline message ID.
520        message_id: InlineId,
521        /// Inline user ID that changed the reaction.
522        user_id: InlineId,
523        /// Reaction emoji.
524        reaction: String,
525        /// Whether the reaction was removed.
526        removed: bool,
527    },
528    /// Read state changed for a chat.
529    ReadStateChanged {
530        /// Inline chat ID.
531        chat_id: InlineId,
532    },
533    /// Typing state changed.
534    Typing {
535        /// Inline chat ID.
536        chat_id: InlineId,
537        /// Inline user ID.
538        user_id: InlineId,
539        /// Whether the user is typing.
540        is_typing: bool,
541    },
542    /// Transient user presence changed.
543    UserStatusChanged {
544        /// Inline user ID.
545        user_id: InlineId,
546        /// `Some(true)` for online, `Some(false)` for offline, or `None` when hidden/unknown.
547        is_online: Option<bool>,
548        /// Last-online Unix timestamp, when disclosed.
549        last_online: Option<i64>,
550    },
551    /// Transient bot activity/presence changed for a peer.
552    BotPresenceChanged {
553        /// Inline bot user ID.
554        bot_user_id: InlineId,
555        /// Resolved chat ID, when available.
556        chat_id: Option<InlineId>,
557        /// Stable protobuf presence kind name.
558        kind: String,
559        /// Optional bot-supplied status comment.
560        comment: Option<String>,
561        /// Whether the bot avatar changed.
562        avatar_changed: bool,
563    },
564    /// A notification-class message update was received.
565    NewMessageNotification {
566        /// Message referenced by the notification.
567        message: MessageRecord,
568        /// Stable protobuf notification reason name.
569        reason: String,
570    },
571}
572
573impl ClientEvent {
574    /// Returns the event delivery reliability class.
575    pub const fn reliability(&self) -> EventReliability {
576        match self {
577            Self::Typing { .. }
578            | Self::UserStatusChanged { .. }
579            | Self::BotPresenceChanged { .. }
580            | Self::NewMessageNotification { .. } => EventReliability::BestEffort,
581            Self::StatusChanged { .. }
582            | Self::TransactionChanged(_)
583            | Self::ChatUpserted { .. }
584            | Self::ChatDeleted { .. }
585            | Self::ChatParticipantsChanged { .. }
586            | Self::UserUpserted { .. }
587            | Self::SpaceUpserted { .. }
588            | Self::SpaceMemberChanged { .. }
589            | Self::UserSettingsChanged { .. }
590            | Self::MessageActionInvoked { .. }
591            | Self::MessageActionAnswered { .. }
592            | Self::MessageUpserted { .. }
593            | Self::MessageStored { .. }
594            | Self::MessageDeleted { .. }
595            | Self::ChatHistoryCleared { .. }
596            | Self::ReactionChanged { .. }
597            | Self::ReadStateChanged { .. } => EventReliability::Lossless,
598        }
599    }
600}
601
602/// Convenient imports for common client consumers.
603pub mod prelude {
604    pub use crate::{
605        AddChatParticipantRequest, AuthCredential, AuthToken, BackendError, BackendResult,
606        ClientBackend, ClientCommandError, ClientError, ClientErrorCategory, ClientEvent,
607        ClientEventDelivery, ClientFailure, ClientIdentity, ClientRequestError, ClientRunner,
608        ClientStatus, ClientStore, ConnectRequest, DEFAULT_COMMAND_QUEUE_CAPACITY,
609        DEFAULT_EVENT_QUEUE_CAPACITY, DEFAULT_LOSSLESS_EVENT_QUEUE_CAPACITY, DeleteChatRequest,
610        DeleteMessageRequest, DialogFollowMode, DialogNotificationMode, DialogRecord, DialogsPage,
611        DialogsRequest, EditMessageRequest, EventReliability, ExternalId, FakeRealtimeAttempt,
612        FakeRealtimeConnector, HistoryPage, HistoryRequest, InMemoryBackend, InMemoryStore,
613        InlineClient, InlineClientBuilder, InlineClientRuntime, InlineId, LosslessEventDelivery,
614        LosslessEventReceiver, MessageContent, MessageMutation, MessageRecord, NotificationMode,
615        PeerRef, RandomId, ReactRequest, ReadRequest, RealtimeConnectRequest,
616        RealtimeConnectionInfo, RealtimeConnector, ReconnectPolicy, RemoveChatParticipantRequest,
617        SdkBackend, SdkBackendBuildError, SdkBackendBuilder, SdkRealtimeConnector, SendTextOutcome,
618        SendTextRequest, SetMarkedUnreadRequest, SpaceMemberRecord, SpaceMemberRole, SpaceRecord,
619        SqliteStore, StoreError, StoreResult, StoredReaction, StoredReadState, StoredSession,
620        StoredTransaction, SyncBucketKey, SyncBucketPeer, SyncBucketState, SyncConfig, SyncState,
621        TransactionEvent, TransactionId, TransactionIdentity, TransactionState, TypingRequest,
622        UpdateChatInfoRequest, UpdateDialogNotificationsRequest, UploadHandle, UploadRequest,
623        UserSettingsRecord, VERSION,
624    };
625}
626
627#[cfg(test)]
628mod tests {
629    use super::*;
630
631    #[test]
632    fn external_id_rejects_empty_values() {
633        assert_eq!(
634            ExternalId::try_new("host-event", " "),
635            Err(ClientError::EmptyField { field: "id" })
636        );
637    }
638
639    #[test]
640    fn transaction_identity_records_message_ids() {
641        let identity = TransactionIdentity::new(
642            TransactionId::try_new("txn-1").unwrap(),
643            Some(ExternalId::try_new("host-event", "event-1").unwrap()),
644            RandomId::new(42),
645        )
646        .with_temporary_message_id(-1)
647        .with_final_message_id(100);
648
649        assert_eq!(identity.random_id.get(), 42);
650        assert_eq!(identity.temporary_message_id.unwrap().get(), -1);
651        assert_eq!(identity.final_message_id.unwrap().get(), 100);
652    }
653
654    #[test]
655    fn typing_is_best_effort() {
656        let event = ClientEvent::Typing {
657            chat_id: InlineId::new(1),
658            user_id: InlineId::new(2),
659            is_typing: true,
660        };
661
662        assert_eq!(event.reliability(), EventReliability::BestEffort);
663    }
664}