#![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 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, InlineClient, InlineClientBuilder, InlineClientRuntime,
};
pub use sdk_backend::{SdkBackend, SdkBackendBuildError, SdkBackendBuilder};
pub use store::{
ClientStore, InMemoryStore, SqliteStore, StoreError, StoreResult, StoredSession,
StoredTransaction,
};
pub use types::{
AuthContactKind, AuthCredential, AuthStartRequest, AuthStartResult, AuthToken,
AuthVerifyRequest, AuthVerifyResult, ChatCreateParticipant, ChatParticipantRecord,
ChatParticipantsPage, ChatParticipantsRequest, ClientStatusSnapshot, ConnectRequest,
CreateDmRequest, CreateReplyThreadRequest, CreateThreadRequest, CreatedChat,
DeleteMessageRequest, DialogRecord, DialogsPage, DialogsRequest, EditMessageRequest,
HistoryPage, HistoryRequest, MediaKind, MessageContent, MessageMutation, MessageRecord,
PeerRef, ReactRequest, ReadRequest, SendTextRequest, TypingRequest, UploadHandle,
UploadRequest, UserRecord,
};
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,
},
UserUpserted {
user_id: InlineId,
},
MessageUpserted {
chat_id: InlineId,
message_id: InlineId,
},
MessageStored {
message: MessageRecord,
},
MessageDeleted {
chat_id: InlineId,
message_id: InlineId,
},
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,
},
}
impl ClientEvent {
pub const fn reliability(&self) -> EventReliability {
match self {
Self::Typing { .. } => EventReliability::BestEffort,
Self::StatusChanged { .. }
| Self::TransactionChanged(_)
| Self::ChatUpserted { .. }
| Self::UserUpserted { .. }
| Self::MessageUpserted { .. }
| Self::MessageStored { .. }
| Self::MessageDeleted { .. }
| Self::ReactionChanged { .. }
| Self::ReadStateChanged { .. } => EventReliability::Lossless,
}
}
}
pub mod prelude {
pub use crate::{
AuthCredential, AuthToken, BackendError, BackendResult, ClientBackend, ClientCommandError,
ClientError, ClientErrorCategory, ClientEvent, ClientFailure, ClientIdentity,
ClientRequestError, ClientRunner, ClientStatus, ClientStore, ConnectRequest,
DEFAULT_COMMAND_QUEUE_CAPACITY, DEFAULT_EVENT_QUEUE_CAPACITY, DeleteMessageRequest,
DialogRecord, DialogsPage, DialogsRequest, EditMessageRequest, EventReliability,
ExternalId, FakeRealtimeAttempt, FakeRealtimeConnector, HistoryPage, HistoryRequest,
InMemoryBackend, InMemoryStore, InlineClient, InlineClientBuilder, InlineClientRuntime,
InlineId, MessageContent, MessageMutation, MessageRecord, PeerRef, RandomId, ReactRequest,
ReadRequest, RealtimeConnectRequest, RealtimeConnectionInfo, RealtimeConnector, SdkBackend,
SdkBackendBuildError, SdkBackendBuilder, SdkRealtimeConnector, SendTextOutcome,
SendTextRequest, SqliteStore, StoreError, StoreResult, StoredSession, StoredTransaction,
TransactionEvent, TransactionId, TransactionIdentity, TransactionState, TypingRequest,
UploadHandle, UploadRequest, 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);
}
}