#![doc = include_str!("../README.md")]
#![warn(missing_docs)]
#![forbid(unsafe_code)]
use std::{fmt, str::FromStr};
use serde::{Deserialize, Serialize};
use thiserror::Error;
pub mod backend;
pub mod runtime;
pub mod realtime;
pub mod sdk_backend;
pub mod sync;
pub mod store;
pub mod types;
pub use backend::{
BackendError, BackendResult, ClientBackend, InMemoryBackend, OperationOutcome, SendTextOutcome,
};
pub use inline_sdk::ClientIdentity;
pub use realtime::{
FakeRealtimeAttempt, FakeRealtimeConnector, RealtimeConnectRequest, RealtimeConnectionInfo,
RealtimeConnector, SdkRealtimeConnector,
};
pub use runtime::{
ClientCommandError, ClientRequestError, ClientRunner, DEFAULT_COMMAND_QUEUE_CAPACITY,
DEFAULT_EVENT_QUEUE_CAPACITY, DEFAULT_LOSSLESS_EVENT_QUEUE_CAPACITY,
DEFAULT_MAX_CONCURRENT_REQUESTS, InlineClient, InlineClientBuilder, InlineClientRuntime,
LosslessEventReceiver, ReconnectPolicy,
};
pub use sdk_backend::{SdkBackend, SdkBackendBuildError, SdkBackendBuilder};
pub use store::{
AccountStateSnapshot, ChatStateSnapshot, ClientStore, InMemoryStore, PendingSyncBatch,
SqliteStore, StoreError, StoreResult, StoredReaction, StoredReadState, StoredSession,
StoredTransaction, SyncBucketKey, SyncBucketPeer, SyncBucketState, SyncState,
};
pub use sync::SyncConfig;
pub use types::{
AddChatParticipantRequest, AuthContactKind, AuthCredential, AuthStartRequest, AuthStartResult,
AuthToken, AuthVerifyRequest, AuthVerifyResult, ChatCreateParticipant, ChatParticipantRecord,
ChatParticipantsPage, ChatParticipantsRequest, ClientStatusSnapshot, ConnectRequest,
CreateDmRequest, CreateReplyThreadRequest, CreateThreadRequest, CreatedChat, DeleteChatRequest,
DeleteMessageRequest, DialogFollowMode, DialogNotificationMode, DialogRecord, DialogsPage,
DialogsRequest, EditMessageRequest, HistoryPage, HistoryRequest, MediaKind, MessageContent,
MessageMutation, MessageRecord, NotificationMode, PeerRef, ReactRequest, ReadRequest,
RemoveChatParticipantRequest, SendTextRequest, SetMarkedUnreadRequest, SpaceMemberRecord,
SpaceMemberRole, SpaceRecord, TypingRequest, UpdateChatInfoRequest,
UpdateDialogNotificationsRequest, UploadHandle, UploadRequest, UserRecord, UserSettingsRecord,
};
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
#[derive(Clone, Debug, Error, PartialEq, Eq)]
#[non_exhaustive]
pub enum ClientError {
#[error("{field} must not be empty")]
EmptyField {
field: &'static str,
},
#[error("{field} is too long ({len} > {max})")]
FieldTooLong {
field: &'static str,
len: usize,
max: usize,
},
#[error("{field} must not contain ASCII control characters")]
ControlCharacter {
field: &'static str,
},
}
fn validate_token<'a>(
field: &'static str,
value: &'a str,
max_len: usize,
) -> Result<&'a str, ClientError> {
let trimmed = value.trim();
if trimmed.is_empty() {
return Err(ClientError::EmptyField { field });
}
if trimmed.len() > max_len {
return Err(ClientError::FieldTooLong {
field,
len: trimmed.len(),
max: max_len,
});
}
if trimmed.chars().any(|ch| ch.is_ascii_control()) {
return Err(ClientError::ControlCharacter { field });
}
Ok(trimmed)
}
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct InlineId(i64);
impl InlineId {
pub const fn new(value: i64) -> Self {
Self(value)
}
pub const fn get(self) -> i64 {
self.0
}
}
impl From<i64> for InlineId {
fn from(value: i64) -> Self {
Self::new(value)
}
}
impl fmt::Display for InlineId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.fmt(f)
}
}
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct RandomId(i64);
impl RandomId {
pub const fn new(value: i64) -> Self {
Self(value)
}
pub const fn get(self) -> i64 {
self.0
}
}
impl From<i64> for RandomId {
fn from(value: i64) -> Self {
Self::new(value)
}
}
impl fmt::Display for RandomId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.fmt(f)
}
}
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct ExternalId {
source: String,
id: String,
}
impl ExternalId {
pub const MAX_SOURCE_LEN: usize = 64;
pub const MAX_ID_LEN: usize = 512;
pub fn try_new(source: impl AsRef<str>, id: impl AsRef<str>) -> Result<Self, ClientError> {
let source = validate_token("source", source.as_ref(), Self::MAX_SOURCE_LEN)?;
let id = validate_token("id", id.as_ref(), Self::MAX_ID_LEN)?;
Ok(Self {
source: source.to_owned(),
id: id.to_owned(),
})
}
pub fn source(&self) -> &str {
&self.source
}
pub fn id(&self) -> &str {
&self.id
}
}
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct TransactionId(String);
impl TransactionId {
pub const MAX_LEN: usize = 128;
pub fn try_new(value: impl AsRef<str>) -> Result<Self, ClientError> {
Ok(Self(
validate_token("transaction_id", value.as_ref(), Self::MAX_LEN)?.to_owned(),
))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for TransactionId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.fmt(f)
}
}
impl FromStr for TransactionId {
type Err = ClientError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
Self::try_new(value)
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct TransactionIdentity {
pub transaction_id: TransactionId,
pub external_id: Option<ExternalId>,
pub random_id: RandomId,
pub temporary_message_id: Option<InlineId>,
pub final_message_id: Option<InlineId>,
}
impl TransactionIdentity {
pub fn new(
transaction_id: TransactionId,
external_id: Option<ExternalId>,
random_id: RandomId,
) -> Self {
Self {
transaction_id,
external_id,
random_id,
temporary_message_id: None,
final_message_id: None,
}
}
pub fn with_temporary_message_id(mut self, message_id: impl Into<InlineId>) -> Self {
self.temporary_message_id = Some(message_id.into());
self
}
pub fn with_final_message_id(mut self, message_id: impl Into<InlineId>) -> Self {
self.final_message_id = Some(message_id.into());
self
}
}
#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum ClientStatus {
Disconnected,
Connecting,
Connected,
Reconnecting,
AuthRequired,
AuthExpired,
LoggedOut,
ShuttingDown,
}
#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum ClientErrorCategory {
InvalidInput,
AuthRequired,
AuthExpired,
ReloginRequired,
Network,
Timeout,
RateLimited,
ProtocolMismatch,
NotFound,
PermissionDenied,
Unsupported,
Conflict,
Internal,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ClientFailure {
pub category: ClientErrorCategory,
pub message: String,
}
impl ClientFailure {
pub fn new(category: ClientErrorCategory, message: impl Into<String>) -> Self {
Self {
category,
message: message.into(),
}
}
}
#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum TransactionState {
Queued,
Sent,
Acked,
Completed,
Failed,
Cancelled,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct TransactionEvent {
pub identity: TransactionIdentity,
pub state: TransactionState,
pub failure: Option<ClientFailure>,
}
#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum EventReliability {
Lossless,
BestEffort,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum ClientEvent {
StatusChanged {
status: ClientStatus,
failure: Option<ClientFailure>,
},
TransactionChanged(TransactionEvent),
ChatUpserted {
chat_id: InlineId,
},
ChatDeleted {
chat_id: InlineId,
},
ChatParticipantsChanged {
chat_id: InlineId,
},
UserUpserted {
user_id: InlineId,
},
SpaceUpserted {
space_id: InlineId,
},
SpaceMemberChanged {
space_id: InlineId,
user_id: InlineId,
removed: bool,
},
UserSettingsChanged {},
MessageActionInvoked {
interaction_id: InlineId,
chat_id: InlineId,
message_id: InlineId,
actor_user_id: InlineId,
action_id: String,
data: Vec<u8>,
},
MessageActionAnswered {
interaction_id: InlineId,
toast: Option<String>,
},
MessageUpserted {
chat_id: InlineId,
message_id: InlineId,
},
MessageStored {
message: MessageRecord,
},
MessageDeleted {
chat_id: InlineId,
message_id: InlineId,
},
ChatHistoryCleared {
chat_id: InlineId,
before_date: Option<i64>,
},
ReactionChanged {
chat_id: InlineId,
message_id: InlineId,
user_id: InlineId,
reaction: String,
removed: bool,
},
ReadStateChanged {
chat_id: InlineId,
},
Typing {
chat_id: InlineId,
user_id: InlineId,
is_typing: bool,
},
UserStatusChanged {
user_id: InlineId,
is_online: Option<bool>,
last_online: Option<i64>,
},
BotPresenceChanged {
bot_user_id: InlineId,
chat_id: Option<InlineId>,
kind: String,
comment: Option<String>,
avatar_changed: bool,
},
NewMessageNotification {
message: MessageRecord,
reason: String,
},
}
impl ClientEvent {
pub const fn reliability(&self) -> EventReliability {
match self {
Self::Typing { .. }
| Self::UserStatusChanged { .. }
| Self::BotPresenceChanged { .. }
| Self::NewMessageNotification { .. } => EventReliability::BestEffort,
Self::StatusChanged { .. }
| Self::TransactionChanged(_)
| Self::ChatUpserted { .. }
| Self::ChatDeleted { .. }
| Self::ChatParticipantsChanged { .. }
| Self::UserUpserted { .. }
| Self::SpaceUpserted { .. }
| Self::SpaceMemberChanged { .. }
| Self::UserSettingsChanged { .. }
| Self::MessageActionInvoked { .. }
| Self::MessageActionAnswered { .. }
| Self::MessageUpserted { .. }
| Self::MessageStored { .. }
| Self::MessageDeleted { .. }
| Self::ChatHistoryCleared { .. }
| Self::ReactionChanged { .. }
| Self::ReadStateChanged { .. } => EventReliability::Lossless,
}
}
}
pub mod prelude {
pub use crate::{
AddChatParticipantRequest, AuthCredential, AuthToken, BackendError, BackendResult,
ClientBackend, ClientCommandError, ClientError, ClientErrorCategory, ClientEvent,
ClientFailure, ClientIdentity, ClientRequestError, ClientRunner, ClientStatus, ClientStore,
ConnectRequest, DEFAULT_COMMAND_QUEUE_CAPACITY, DEFAULT_EVENT_QUEUE_CAPACITY,
DEFAULT_LOSSLESS_EVENT_QUEUE_CAPACITY, DeleteChatRequest, DeleteMessageRequest,
DialogFollowMode, DialogNotificationMode, DialogRecord, DialogsPage, DialogsRequest,
EditMessageRequest, EventReliability, ExternalId, FakeRealtimeAttempt,
FakeRealtimeConnector, HistoryPage, HistoryRequest, InMemoryBackend, InMemoryStore,
InlineClient, InlineClientBuilder, InlineClientRuntime, InlineId, LosslessEventReceiver,
MessageContent, MessageMutation, MessageRecord, NotificationMode, PeerRef, RandomId,
ReactRequest, ReadRequest, RealtimeConnectRequest, RealtimeConnectionInfo,
RealtimeConnector, ReconnectPolicy, RemoveChatParticipantRequest, SdkBackend,
SdkBackendBuildError, SdkBackendBuilder, SdkRealtimeConnector, SendTextOutcome,
SendTextRequest, SetMarkedUnreadRequest, SpaceMemberRecord, SpaceMemberRole, SpaceRecord,
SqliteStore, StoreError, StoreResult, StoredReaction, StoredReadState, StoredSession,
StoredTransaction, SyncBucketKey, SyncBucketPeer, SyncBucketState, SyncConfig, SyncState,
TransactionEvent, TransactionId, TransactionIdentity, TransactionState, TypingRequest,
UpdateChatInfoRequest, UpdateDialogNotificationsRequest, UploadHandle, UploadRequest,
UserSettingsRecord, VERSION,
};
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn external_id_rejects_empty_values() {
assert_eq!(
ExternalId::try_new("host-event", " "),
Err(ClientError::EmptyField { field: "id" })
);
}
#[test]
fn transaction_identity_records_message_ids() {
let identity = TransactionIdentity::new(
TransactionId::try_new("txn-1").unwrap(),
Some(ExternalId::try_new("host-event", "event-1").unwrap()),
RandomId::new(42),
)
.with_temporary_message_id(-1)
.with_final_message_id(100);
assert_eq!(identity.random_id.get(), 42);
assert_eq!(identity.temporary_message_id.unwrap().get(), -1);
assert_eq!(identity.final_message_id.unwrap().get(), 100);
}
#[test]
fn typing_is_best_effort() {
let event = ClientEvent::Typing {
chat_id: InlineId::new(1),
user_id: InlineId::new(2),
is_typing: true,
};
assert_eq!(event.reliability(), EventReliability::BestEffort);
}
}