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