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