1use futures_util::{SinkExt, StreamExt};
4use prost::Message;
5use std::fmt;
6use std::future::Future;
7use std::time::Duration;
8use tokio_tungstenite::connect_async;
9use tokio_tungstenite::tungstenite::Message as WsMessage;
10use tokio_tungstenite::tungstenite::client::IntoClientRequest;
11use tokio_tungstenite::tungstenite::http::HeaderValue;
12use url::Url;
13
14use crate::client_info::{self, ClientIdentity};
15use inline_protocol::proto;
16
17pub const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(30);
19pub const DEFAULT_RPC_TIMEOUT: Duration = Duration::from_secs(60);
21
22#[derive(thiserror::Error)]
24#[non_exhaustive]
25pub enum RealtimeError {
26 #[error("invalid realtime URL: {message}")]
28 InvalidUrl {
29 url: String,
31 message: String,
33 },
34 #[error("websocket error: {0}")]
36 WebSocket(Box<tokio_tungstenite::tungstenite::Error>),
37 #[error("{field} contains characters that are invalid in realtime headers")]
39 InvalidHeaderValue {
40 field: &'static str,
42 },
43 #[error("protocol error: {0}")]
45 Protocol(#[from] prost::DecodeError),
46 #[error("missing rpc result")]
48 MissingResult,
49 #[error("unexpected rpc result for {method}: expected {expected}, got {actual}")]
51 UnexpectedResult {
52 method: &'static str,
54 expected: &'static str,
56 actual: &'static str,
58 },
59 #[error("realtime {operation} timed out after {timeout:?}")]
61 Timeout {
62 operation: &'static str,
64 timeout: Duration,
66 },
67 #[error("{friendly}")]
69 ConnectionError {
70 reason: i32,
72 reason_name: String,
74 friendly: String,
76 },
77 #[error("realtime connection closed")]
79 ConnectionClosed,
80 #[error("{friendly}")]
82 RpcError {
83 code: i32,
85 error_code: i32,
87 error_name: String,
89 message: String,
91 friendly: String,
93 },
94}
95
96impl fmt::Debug for RealtimeError {
97 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
98 match self {
99 RealtimeError::InvalidUrl { url, message } => f
100 .debug_struct("InvalidUrl")
101 .field("url", &realtime_url_for_debug(url))
102 .field("message", message)
103 .finish(),
104 RealtimeError::WebSocket(error) => f.debug_tuple("WebSocket").field(error).finish(),
105 RealtimeError::InvalidHeaderValue { field } => f
106 .debug_struct("InvalidHeaderValue")
107 .field("field", field)
108 .finish(),
109 RealtimeError::Protocol(error) => f.debug_tuple("Protocol").field(error).finish(),
110 RealtimeError::MissingResult => f.debug_struct("MissingResult").finish(),
111 RealtimeError::UnexpectedResult {
112 method,
113 expected,
114 actual,
115 } => f
116 .debug_struct("UnexpectedResult")
117 .field("method", method)
118 .field("expected", expected)
119 .field("actual", actual)
120 .finish(),
121 RealtimeError::Timeout { operation, timeout } => f
122 .debug_struct("Timeout")
123 .field("operation", operation)
124 .field("timeout", timeout)
125 .finish(),
126 RealtimeError::ConnectionError {
127 reason,
128 reason_name,
129 friendly,
130 } => f
131 .debug_struct("ConnectionError")
132 .field("reason", reason)
133 .field("reason_name", reason_name)
134 .field("friendly", friendly)
135 .finish(),
136 RealtimeError::ConnectionClosed => f.debug_struct("ConnectionClosed").finish(),
137 RealtimeError::RpcError {
138 code,
139 error_code,
140 error_name,
141 message,
142 friendly,
143 } => f
144 .debug_struct("RpcError")
145 .field("code", code)
146 .field("error_code", error_code)
147 .field("error_name", error_name)
148 .field("message", message)
149 .field("friendly", friendly)
150 .finish(),
151 }
152 }
153}
154
155impl From<tokio_tungstenite::tungstenite::Error> for RealtimeError {
156 fn from(error: tokio_tungstenite::tungstenite::Error) -> Self {
157 Self::WebSocket(Box::new(error))
158 }
159}
160
161pub struct RealtimeClient {
163 ws: tokio_tungstenite::WebSocketStream<
164 tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
165 >,
166 seq: u32,
167 id_gen: IdGenerator,
168 rpc_timeout: Option<Duration>,
169}
170
171#[must_use]
173#[derive(Clone)]
174pub struct RealtimeClientBuilder {
175 url: String,
176 token: String,
177 identity: ClientIdentity,
178 connect_timeout: Option<Duration>,
179 rpc_timeout: Option<Duration>,
180}
181
182impl fmt::Debug for RealtimeClientBuilder {
183 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
184 f.debug_struct("RealtimeClientBuilder")
185 .field("url", &realtime_url_for_debug(&self.url))
186 .field("token", &"<redacted>")
187 .field("identity", &self.identity)
188 .field("connect_timeout", &self.connect_timeout)
189 .field("rpc_timeout", &self.rpc_timeout)
190 .finish()
191 }
192}
193
194pub trait RpcRequest: Sized {
200 type Response;
202
203 const METHOD: proto::Method;
205
206 fn into_rpc_input(self) -> proto::rpc_call::Input;
208
209 fn response_from_rpc_result(
211 result: proto::rpc_result::Result,
212 ) -> Result<Self::Response, RealtimeError>;
213}
214
215impl RealtimeClient {
216 pub fn builder(url: impl Into<String>, token: impl Into<String>) -> RealtimeClientBuilder {
218 RealtimeClientBuilder::new(url, token)
219 }
220
221 pub async fn connect(url: &str, token: &str) -> Result<Self, RealtimeError> {
223 Self::builder(url, token).connect().await
224 }
225
226 pub async fn connect_with_identity(
228 url: &str,
229 token: &str,
230 identity: ClientIdentity,
231 ) -> Result<Self, RealtimeError> {
232 Self::builder(url, token).identity(identity).connect().await
233 }
234}
235
236impl RealtimeClientBuilder {
237 pub fn new(url: impl Into<String>, token: impl Into<String>) -> Self {
239 Self {
240 url: url.into(),
241 token: token.into(),
242 identity: ClientIdentity::sdk(),
243 connect_timeout: Some(DEFAULT_CONNECT_TIMEOUT),
244 rpc_timeout: Some(DEFAULT_RPC_TIMEOUT),
245 }
246 }
247
248 pub fn identity(mut self, identity: ClientIdentity) -> Self {
250 self.identity = identity;
251 self
252 }
253
254 pub fn connect_timeout(mut self, timeout: Duration) -> Self {
256 self.connect_timeout = Some(timeout);
257 self
258 }
259
260 pub fn without_connect_timeout(mut self) -> Self {
262 self.connect_timeout = None;
263 self
264 }
265
266 pub fn rpc_timeout(mut self, timeout: Duration) -> Self {
268 self.rpc_timeout = Some(timeout);
269 self
270 }
271
272 pub fn without_rpc_timeout(mut self) -> Self {
274 self.rpc_timeout = None;
275 self
276 }
277
278 pub async fn connect(self) -> Result<RealtimeClient, RealtimeError> {
280 let url = normalize_realtime_url(self.url)?;
281 log::debug!(
282 target: "inline_sdk::realtime",
283 "opening realtime websocket url={} identity_type={} connect_timeout={:?} rpc_timeout={:?}",
284 realtime_url_for_log(&url),
285 self.identity.client_type(),
286 self.connect_timeout,
287 self.rpc_timeout
288 );
289 let mut request = url.into_client_request()?;
290 request.headers_mut().insert(
291 client_info::CLIENT_TYPE_HEADER,
292 realtime_header_value("client_type", self.identity.client_type())?,
293 );
294 request.headers_mut().insert(
295 client_info::CLIENT_VERSION_HEADER,
296 realtime_header_value("client_version", self.identity.client_version())?,
297 );
298 request.headers_mut().insert(
299 "user-agent",
300 realtime_header_value("user_agent", &client_info::user_agent_for(&self.identity))?,
301 );
302
303 let (ws, _) =
304 with_optional_timeout("connect", self.connect_timeout, connect_async(request)).await?;
305 log::debug!(target: "inline_sdk::realtime", "websocket connected");
306 let mut client = RealtimeClient {
307 ws,
308 seq: 0,
309 id_gen: IdGenerator::new(),
310 rpc_timeout: self.rpc_timeout,
311 };
312
313 with_optional_timeout(
314 "connection_init",
315 self.connect_timeout,
316 client.send_connection_init(&self.token, &self.identity),
317 )
318 .await?;
319 log::trace!(target: "inline_sdk::realtime", "connection init sent");
320 with_optional_timeout(
321 "connection_open",
322 self.connect_timeout,
323 client.wait_for_connection_open(),
324 )
325 .await?;
326 log::debug!(target: "inline_sdk::realtime", "realtime protocol open");
327 Ok(client)
328 }
329}
330
331impl RealtimeClient {
332 pub async fn call<R>(&mut self, request: R) -> Result<R::Response, RealtimeError>
334 where
335 R: RpcRequest,
336 {
337 log::trace!(
338 target: "inline_sdk::realtime",
339 "calling typed rpc method={}",
340 R::METHOD.as_str_name()
341 );
342 let result = self.invoke(R::METHOD, request.into_rpc_input()).await?;
343 R::response_from_rpc_result(result)
344 }
345
346 pub async fn invoke(
348 &mut self,
349 method: proto::Method,
350 input: proto::rpc_call::Input,
351 ) -> Result<proto::rpc_result::Result, RealtimeError> {
352 let rpc_call = proto::RpcCall {
353 method: method as i32,
354 input: Some(input),
355 };
356 let message_id = self.next_id();
357 log::trace!(
358 target: "inline_sdk::realtime",
359 "sending rpc method={} msg_id={message_id}",
360 method.as_str_name()
361 );
362 let message = proto::ClientMessage {
363 id: message_id,
364 seq: self.next_seq(),
365 body: Some(proto::client_message::Body::RpcCall(rpc_call)),
366 };
367
368 self.send_client_message(message).await?;
369
370 with_optional_timeout(
371 "rpc",
372 self.rpc_timeout,
373 self.wait_for_rpc_result(message_id),
374 )
375 .await
376 }
377
378 pub fn rpc_timeout(&self) -> Option<Duration> {
380 self.rpc_timeout
381 }
382
383 async fn send_connection_init(
384 &mut self,
385 token: &str,
386 identity: &ClientIdentity,
387 ) -> Result<(), RealtimeError> {
388 let init = connection_init_for_token(token, identity);
389 let message = proto::ClientMessage {
390 id: self.next_id(),
391 seq: self.next_seq(),
392 body: Some(proto::client_message::Body::ConnectionInit(init)),
393 };
394
395 self.send_client_message(message).await
396 }
397
398 async fn wait_for_connection_open(&mut self) -> Result<(), RealtimeError> {
399 loop {
400 let message = self.read_server_message().await?;
401 match message.body {
402 Some(proto::server_protocol_message::Body::ConnectionOpen(_)) => return Ok(()),
403 Some(proto::server_protocol_message::Body::ConnectionError(error)) => {
404 log::warn!(
405 target: "inline_sdk::realtime",
406 "connection open rejected reason={}",
407 proto::connection_error::Reason::try_from(error.reason)
408 .map(|reason| reason.as_str_name())
409 .unwrap_or("UNKNOWN")
410 );
411 return Err(connection_error_from_proto(error));
412 }
413 _ => {}
414 }
415 }
416 }
417
418 async fn wait_for_rpc_result(
419 &mut self,
420 message_id: u64,
421 ) -> Result<proto::rpc_result::Result, RealtimeError> {
422 loop {
423 let message = self.read_server_message().await?;
424 match message.body {
425 Some(proto::server_protocol_message::Body::RpcResult(result))
426 if result.req_msg_id == message_id =>
427 {
428 log::trace!(
429 target: "inline_sdk::realtime",
430 "received rpc result msg_id={message_id}"
431 );
432 return result.result.ok_or(RealtimeError::MissingResult);
433 }
434 Some(proto::server_protocol_message::Body::RpcError(error))
435 if error.req_msg_id == message_id =>
436 {
437 let message = error.message;
438 let error_name = rpc_error_code_name(error.error_code);
439 let friendly =
440 format_rpc_error(error.error_code, &error_name, &message, error.code);
441 log::warn!(
442 target: "inline_sdk::realtime",
443 "received rpc error msg_id={message_id} error={error_name} status={}",
444 error.code
445 );
446 return Err(RealtimeError::RpcError {
447 code: error.code,
448 error_code: error.error_code,
449 error_name,
450 message,
451 friendly,
452 });
453 }
454 Some(proto::server_protocol_message::Body::ConnectionError(error)) => {
455 log::warn!(
456 target: "inline_sdk::realtime",
457 "connection error while waiting for rpc msg_id={message_id} reason={}",
458 proto::connection_error::Reason::try_from(error.reason)
459 .map(|reason| reason.as_str_name())
460 .unwrap_or("UNKNOWN")
461 );
462 return Err(connection_error_from_proto(error));
463 }
464 _ => {}
465 }
466 }
467 }
468
469 async fn send_client_message(
470 &mut self,
471 message: proto::ClientMessage,
472 ) -> Result<(), RealtimeError> {
473 let bytes = message.encode_to_vec();
474 self.ws.send(WsMessage::Binary(bytes)).await?;
475 Ok(())
476 }
477
478 async fn read_server_message(&mut self) -> Result<proto::ServerProtocolMessage, RealtimeError> {
479 loop {
480 let message = self
481 .ws
482 .next()
483 .await
484 .ok_or(RealtimeError::ConnectionClosed)??;
485 match message {
486 WsMessage::Binary(data) => {
487 return Ok(proto::ServerProtocolMessage::decode(&*data)?);
488 }
489 WsMessage::Text(_) => continue,
490 WsMessage::Close(_) => return Err(RealtimeError::ConnectionClosed),
491 WsMessage::Ping(_) | WsMessage::Pong(_) => continue,
492 _ => continue,
493 }
494 }
495 }
496
497 fn next_seq(&mut self) -> u32 {
498 self.seq = self.seq.wrapping_add(1);
499 self.seq
500 }
501
502 fn next_id(&mut self) -> u64 {
503 self.id_gen.next_id()
504 }
505}
506
507macro_rules! rpc_requests {
508 ($(($input_ty:ident, $method:ident, $input_variant:ident, $result_ty:ident, $result_variant:ident)),+ $(,)?) => {
509 $(
510 impl RpcRequest for proto::$input_ty {
511 type Response = proto::$result_ty;
512
513 const METHOD: proto::Method = proto::Method::$method;
514
515 fn into_rpc_input(self) -> proto::rpc_call::Input {
516 proto::rpc_call::Input::$input_variant(self)
517 }
518
519 fn response_from_rpc_result(
520 result: proto::rpc_result::Result,
521 ) -> Result<Self::Response, RealtimeError> {
522 match result {
523 proto::rpc_result::Result::$result_variant(response) => Ok(response),
524 other => {
525 let actual = rpc_result_variant_name(&other);
526 log::warn!(
527 target: "inline_sdk::realtime",
528 "unexpected rpc result method={} expected={} actual={actual}",
529 Self::METHOD.as_str_name(),
530 stringify!($result_variant)
531 );
532 Err(RealtimeError::UnexpectedResult {
533 method: Self::METHOD.as_str_name(),
534 expected: stringify!($result_variant),
535 actual,
536 })
537 }
538 }
539 }
540 }
541 )+
542
543 fn rpc_result_variant_name(result: &proto::rpc_result::Result) -> &'static str {
544 match result {
545 $(proto::rpc_result::Result::$result_variant(_) => stringify!($result_variant),)+
546 }
547 }
548 };
549}
550
551rpc_requests!(
552 (GetMeInput, GetMe, GetMe, GetMeResult, GetMe),
553 (
554 GetPeerPhotoInput,
555 GetPeerPhoto,
556 GetPeerPhoto,
557 GetPeerPhotoResult,
558 GetPeerPhoto
559 ),
560 (
561 DeleteMessagesInput,
562 DeleteMessages,
563 DeleteMessages,
564 DeleteMessagesResult,
565 DeleteMessages
566 ),
567 (
568 SendMessageInput,
569 SendMessage,
570 SendMessage,
571 SendMessageResult,
572 SendMessage
573 ),
574 (
575 GetChatHistoryInput,
576 GetChatHistory,
577 GetChatHistory,
578 GetChatHistoryResult,
579 GetChatHistory
580 ),
581 (
582 AddReactionInput,
583 AddReaction,
584 AddReaction,
585 AddReactionResult,
586 AddReaction
587 ),
588 (
589 DeleteReactionInput,
590 DeleteReaction,
591 DeleteReaction,
592 DeleteReactionResult,
593 DeleteReaction
594 ),
595 (
596 EditMessageInput,
597 EditMessage,
598 EditMessage,
599 EditMessageResult,
600 EditMessage
601 ),
602 (
603 CreateChatInput,
604 CreateChat,
605 CreateChat,
606 CreateChatResult,
607 CreateChat
608 ),
609 (
610 GetSpaceMembersInput,
611 GetSpaceMembers,
612 GetSpaceMembers,
613 GetSpaceMembersResult,
614 GetSpaceMembers
615 ),
616 (
617 DeleteChatInput,
618 DeleteChat,
619 DeleteChat,
620 DeleteChatResult,
621 DeleteChat
622 ),
623 (
624 InviteToSpaceInput,
625 InviteToSpace,
626 InviteToSpace,
627 InviteToSpaceResult,
628 InviteToSpace
629 ),
630 (
631 GetChatParticipantsInput,
632 GetChatParticipants,
633 GetChatParticipants,
634 GetChatParticipantsResult,
635 GetChatParticipants
636 ),
637 (
638 AddChatParticipantInput,
639 AddChatParticipant,
640 AddChatParticipant,
641 AddChatParticipantResult,
642 AddChatParticipant
643 ),
644 (
645 RemoveChatParticipantInput,
646 RemoveChatParticipant,
647 RemoveChatParticipant,
648 RemoveChatParticipantResult,
649 RemoveChatParticipant
650 ),
651 (
652 TranslateMessagesInput,
653 TranslateMessages,
654 TranslateMessages,
655 TranslateMessagesResult,
656 TranslateMessages
657 ),
658 (GetChatsInput, GetChats, GetChats, GetChatsResult, GetChats),
659 (
660 UpdateUserSettingsInput,
661 UpdateUserSettings,
662 UpdateUserSettings,
663 UpdateUserSettingsResult,
664 UpdateUserSettings
665 ),
666 (
667 GetUserSettingsInput,
668 GetUserSettings,
669 GetUserSettings,
670 GetUserSettingsResult,
671 GetUserSettings
672 ),
673 (
674 SendComposeActionInput,
675 SendComposeAction,
676 SendComposeAction,
677 SendComposeActionResult,
678 SendComposeAction
679 ),
680 (
681 CreateBotInput,
682 CreateBot,
683 CreateBot,
684 CreateBotResult,
685 CreateBot
686 ),
687 (
688 DeleteMemberInput,
689 DeleteMember,
690 DeleteMember,
691 DeleteMemberResult,
692 DeleteMember
693 ),
694 (
695 MarkAsUnreadInput,
696 MarkAsUnread,
697 MarkAsUnread,
698 MarkAsUnreadResult,
699 MarkAsUnread
700 ),
701 (
702 GetUpdatesStateInput,
703 GetUpdatesState,
704 GetUpdatesState,
705 GetUpdatesStateResult,
706 GetUpdatesState
707 ),
708 (GetChatInput, GetChat, GetChat, GetChatResult, GetChat),
709 (
710 GetUpdatesInput,
711 GetUpdates,
712 GetUpdates,
713 GetUpdatesResult,
714 GetUpdates
715 ),
716 (
717 UpdateMemberAccessInput,
718 UpdateMemberAccess,
719 UpdateMemberAccess,
720 UpdateMemberAccessResult,
721 UpdateMemberAccess
722 ),
723 (
724 SearchMessagesInput,
725 SearchMessages,
726 SearchMessages,
727 SearchMessagesResult,
728 SearchMessages
729 ),
730 (
731 ForwardMessagesInput,
732 ForwardMessages,
733 ForwardMessages,
734 ForwardMessagesResult,
735 ForwardMessages
736 ),
737 (
738 UpdateChatVisibilityInput,
739 UpdateChatVisibility,
740 UpdateChatVisibility,
741 UpdateChatVisibilityResult,
742 UpdateChatVisibility
743 ),
744 (
745 PinMessageInput,
746 PinMessage,
747 PinMessage,
748 PinMessageResult,
749 PinMessage
750 ),
751 (
752 UpdateChatInfoInput,
753 UpdateChatInfo,
754 UpdateChatInfo,
755 UpdateChatInfoResult,
756 UpdateChatInfo
757 ),
758 (ListBotsInput, ListBots, ListBots, ListBotsResult, ListBots),
759 (
760 RevealBotTokenInput,
761 RevealBotToken,
762 RevealBotToken,
763 RevealBotTokenResult,
764 RevealBotToken
765 ),
766 (
767 MoveThreadInput,
768 MoveThread,
769 MoveThread,
770 MoveThreadResult,
771 MoveThread
772 ),
773 (
774 RotateBotTokenInput,
775 RotateBotToken,
776 RotateBotToken,
777 RotateBotTokenResult,
778 RotateBotToken
779 ),
780 (
781 UpdateBotProfileInput,
782 UpdateBotProfile,
783 UpdateBotProfile,
784 UpdateBotProfileResult,
785 UpdateBotProfile
786 ),
787 (
788 GetMessagesInput,
789 GetMessages,
790 GetMessages,
791 GetMessagesResult,
792 GetMessages
793 ),
794 (
795 UpdateDialogNotificationSettingsInput,
796 UpdateDialogNotificationSettings,
797 UpdateDialogNotificationSettings,
798 UpdateDialogNotificationSettingsResult,
799 UpdateDialogNotificationSettings
800 ),
801 (
802 ReadMessagesInput,
803 ReadMessages,
804 ReadMessages,
805 ReadMessagesResult,
806 ReadMessages
807 ),
808 (
809 UpdatePushNotificationDetailsInput,
810 UpdatePushNotificationDetails,
811 UpdatePushNotificationDetails,
812 UpdatePushNotificationDetailsResult,
813 UpdatePushNotificationDetails
814 ),
815 (
816 CreateSubthreadInput,
817 CreateSubthread,
818 CreateSubthread,
819 CreateSubthreadResult,
820 CreateSubthread
821 ),
822 (
823 GetBotCommandsInput,
824 GetBotCommands,
825 GetBotCommands,
826 GetBotCommandsResult,
827 GetBotCommands
828 ),
829 (
830 SetBotCommandsInput,
831 SetBotCommands,
832 SetBotCommands,
833 SetBotCommandsResult,
834 SetBotCommands
835 ),
836 (
837 GetPeerBotCommandsInput,
838 GetPeerBotCommands,
839 GetPeerBotCommands,
840 GetPeerBotCommandsResult,
841 GetPeerBotCommands
842 ),
843 (
844 ShowInChatListInput,
845 ShowInChatList,
846 ShowInChatList,
847 ShowInChatListResult,
848 ShowInChatList
849 ),
850 (
851 ReserveChatIdsInput,
852 ReserveChatIds,
853 ReserveChatIds,
854 ReserveChatIdsResult,
855 ReserveChatIds
856 ),
857 (
858 InvokeMessageActionInput,
859 InvokeMessageAction,
860 InvokeMessageAction,
861 InvokeMessageActionResult,
862 InvokeMessageAction
863 ),
864 (
865 AnswerMessageActionInput,
866 AnswerMessageAction,
867 AnswerMessageAction,
868 AnswerMessageActionResult,
869 AnswerMessageAction
870 ),
871 (
872 RevokeSessionInput,
873 RevokeSession,
874 RevokeSession,
875 RevokeSessionResult,
876 RevokeSession
877 ),
878 (
879 UpdateDialogOpenInput,
880 UpdateDialogOpen,
881 UpdateDialogOpen,
882 UpdateDialogOpenResult,
883 UpdateDialogOpen
884 ),
885 (
886 UpdateDialogOrderInput,
887 UpdateDialogOrder,
888 UpdateDialogOrder,
889 UpdateDialogOrderResult,
890 UpdateDialogOrder
891 ),
892 (
893 ClearChatHistoryInput,
894 ClearChatHistory,
895 ClearChatHistory,
896 ClearChatHistoryResult,
897 ClearChatHistory
898 ),
899 (
900 DeleteBotInput,
901 DeleteBot,
902 DeleteBot,
903 DeleteBotResult,
904 DeleteBot
905 ),
906 (
907 DeleteMessageAttachmentInput,
908 DeleteMessageAttachment,
909 DeleteMessageAttachment,
910 DeleteMessageAttachmentResult,
911 DeleteMessageAttachment
912 ),
913 (
914 SetBotAvatarInput,
915 SetBotAvatar,
916 SetBotAvatar,
917 SetBotAvatarResult,
918 SetBotAvatar
919 ),
920 (
921 ClearBotAvatarInput,
922 ClearBotAvatar,
923 ClearBotAvatar,
924 ClearBotAvatarResult,
925 ClearBotAvatar
926 ),
927 (
928 GetBotPresenceInput,
929 GetBotPresence,
930 GetBotPresence,
931 GetBotPresenceResult,
932 GetBotPresence
933 ),
934 (
935 SetBotPresenceStateInput,
936 SetBotPresenceState,
937 SetBotPresenceState,
938 SetBotPresenceStateResult,
939 SetBotPresenceState
940 ),
941 (
942 UpdateDialogFollowModeInput,
943 UpdateDialogFollowMode,
944 UpdateDialogFollowMode,
945 UpdateDialogFollowModeResult,
946 UpdateDialogFollowMode
947 ),
948 (
949 GetSessionsInput,
950 GetSessions,
951 GetSessions,
952 GetSessionsResult,
953 GetSessions
954 ),
955 (
956 CheckUsernameInput,
957 CheckUsername,
958 CheckUsername,
959 CheckUsernameResult,
960 CheckUsername
961 ),
962 (
963 ChangeUsernameInput,
964 ChangeUsername,
965 ChangeUsername,
966 ChangeUsernameResult,
967 ChangeUsername
968 ),
969 (
970 UpdateProfileInput,
971 UpdateProfile,
972 UpdateProfile,
973 UpdateProfileResult,
974 UpdateProfile
975 ),
976 (
977 GetSpaceUrlPreviewExclusionsInput,
978 GetSpaceUrlPreviewExclusions,
979 GetSpaceUrlPreviewExclusions,
980 GetSpaceUrlPreviewExclusionsResult,
981 GetSpaceUrlPreviewExclusions
982 ),
983 (
984 AddSpaceUrlPreviewExclusionInput,
985 AddSpaceUrlPreviewExclusion,
986 AddSpaceUrlPreviewExclusion,
987 AddSpaceUrlPreviewExclusionResult,
988 AddSpaceUrlPreviewExclusion
989 ),
990 (
991 RemoveSpaceUrlPreviewExclusionInput,
992 RemoveSpaceUrlPreviewExclusion,
993 RemoveSpaceUrlPreviewExclusion,
994 RemoveSpaceUrlPreviewExclusionResult,
995 RemoveSpaceUrlPreviewExclusion
996 ),
997);
998
999fn connection_init_for_token(token: &str, identity: &ClientIdentity) -> proto::ConnectionInit {
1000 proto::ConnectionInit {
1001 token: token.to_string(),
1002 build_number: None,
1003 layer: None,
1004 client_version: Some(identity.client_version().to_string()),
1005 os_version: client_info::current_os_version(),
1006 }
1007}
1008
1009fn rpc_error_code_name(error_code: i32) -> String {
1010 proto::rpc_error::Code::try_from(error_code)
1011 .map(|code| code.as_str_name())
1012 .unwrap_or("UNKNOWN")
1013 .to_string()
1014}
1015
1016fn format_rpc_error(error_code: i32, error_name: &str, message: &str, status_code: i32) -> String {
1017 let label = match error_name {
1018 "UNKNOWN" => "Unknown RPC error",
1019 "BAD_REQUEST" => "Bad request",
1020 "UNAUTHENTICATED" => "Not authenticated",
1021 "RATE_LIMIT" => "Rate limited",
1022 "INTERNAL_ERROR" => "Internal server error",
1023 "PEER_ID_INVALID" => "Invalid peer (chat/user id)",
1024 "MESSAGE_ID_INVALID" => "Invalid message id",
1025 "USER_ID_INVALID" => "Invalid user id",
1026 "USER_ALREADY_MEMBER" => "User already in chat/space",
1027 "SPACE_ID_INVALID" => "Invalid space id",
1028 "CHAT_ID_INVALID" => "Invalid chat id",
1029 "EMAIL_INVALID" => "Invalid email address",
1030 "PHONE_NUMBER_INVALID" => "Invalid phone number",
1031 "SPACE_ADMIN_REQUIRED" => "Space admin required",
1032 "SPACE_OWNER_REQUIRED" => "Space owner required",
1033 "USERNAME_INVALID" => "Invalid username",
1034 "USERNAME_TAKEN" => "Username already taken",
1035 "FIRST_NAME_INVALID" => "Invalid first name",
1036 _ => "Unknown RPC error",
1037 };
1038
1039 let mut formatted = String::from(label);
1040 if error_name == "UNKNOWN" && error_code != 0 {
1041 formatted.push_str(&format!(" {error_code}"));
1042 }
1043 if !message.is_empty() && !message.eq_ignore_ascii_case(label) {
1044 formatted.push_str(": ");
1045 formatted.push_str(message);
1046 }
1047 if status_code != 0 {
1048 formatted.push_str(&format!(" (HTTP {status_code})"));
1049 }
1050 formatted
1051}
1052
1053fn connection_error_from_proto(error: proto::ConnectionError) -> RealtimeError {
1054 let reason = error.reason;
1055 let reason_name = proto::connection_error::Reason::try_from(reason)
1056 .map(|reason| reason.as_str_name())
1057 .unwrap_or("UNKNOWN")
1058 .to_string();
1059 let friendly = format_connection_error(reason, &reason_name);
1060 RealtimeError::ConnectionError {
1061 reason,
1062 reason_name,
1063 friendly,
1064 }
1065}
1066
1067fn format_connection_error(reason: i32, reason_name: &str) -> String {
1068 match reason_name {
1069 "UNAUTHORIZED" => "Realtime connection unauthorized".to_string(),
1070 "INVALID_AUTH" => "Realtime auth token is invalid".to_string(),
1071 "SESSION_REVOKED" => "Realtime session was revoked".to_string(),
1072 "REASON_UNSPECIFIED" => "Realtime connection rejected".to_string(),
1073 _ => format!("Realtime connection rejected: unknown reason {reason}"),
1074 }
1075}
1076
1077fn normalize_realtime_url(url: impl Into<String>) -> Result<Url, RealtimeError> {
1078 let original = url.into();
1079 let normalized = original.trim().to_string();
1080 if normalized.is_empty() {
1081 return Err(RealtimeError::InvalidUrl {
1082 url: original,
1083 message: "realtime URL cannot be empty".to_string(),
1084 });
1085 }
1086
1087 let parsed = Url::parse(&normalized).map_err(|err| RealtimeError::InvalidUrl {
1088 url: normalized.clone(),
1089 message: err.to_string(),
1090 })?;
1091
1092 if !matches!(parsed.scheme(), "ws" | "wss") {
1093 return Err(RealtimeError::InvalidUrl {
1094 url: normalized,
1095 message: "scheme must be ws or wss".to_string(),
1096 });
1097 }
1098
1099 if parsed.host_str().is_none() {
1100 return Err(RealtimeError::InvalidUrl {
1101 url: normalized,
1102 message: "host is required".to_string(),
1103 });
1104 }
1105
1106 if !parsed.username().is_empty() || parsed.password().is_some() {
1107 return Err(RealtimeError::InvalidUrl {
1108 url: normalized,
1109 message: "credentials are not valid in the realtime URL".to_string(),
1110 });
1111 }
1112
1113 if parsed.fragment().is_some() {
1114 return Err(RealtimeError::InvalidUrl {
1115 url: normalized,
1116 message: "fragments are not valid in the realtime URL".to_string(),
1117 });
1118 }
1119
1120 Ok(parsed)
1121}
1122
1123fn realtime_url_for_log(url: &Url) -> String {
1124 let host = url.host_str().unwrap_or("<missing-host>");
1125 let port = url
1126 .port()
1127 .map(|port| format!(":{port}"))
1128 .unwrap_or_default();
1129 format!("{}://{}{}{}", url.scheme(), host, port, url.path())
1130}
1131
1132fn realtime_url_for_debug(raw_url: &str) -> String {
1133 Url::parse(raw_url.trim())
1134 .map(|url| realtime_url_for_log(&url))
1135 .unwrap_or_else(|_| "<invalid>".to_string())
1136}
1137
1138fn realtime_header_value(field: &'static str, value: &str) -> Result<HeaderValue, RealtimeError> {
1139 HeaderValue::from_str(value).map_err(|_| RealtimeError::InvalidHeaderValue { field })
1140}
1141
1142async fn with_optional_timeout<T, E, Fut>(
1143 operation: &'static str,
1144 timeout: Option<Duration>,
1145 future: Fut,
1146) -> Result<T, RealtimeError>
1147where
1148 Fut: Future<Output = Result<T, E>>,
1149 RealtimeError: From<E>,
1150{
1151 match timeout {
1152 Some(timeout) => tokio::time::timeout(timeout, future)
1153 .await
1154 .map_err(|_| RealtimeError::Timeout { operation, timeout })?
1155 .map_err(RealtimeError::from),
1156 None => future.await.map_err(RealtimeError::from),
1157 }
1158}
1159
1160struct IdGenerator {
1161 last_timestamp: u64,
1162 sequence: u32,
1163}
1164
1165impl IdGenerator {
1166 fn new() -> Self {
1167 Self {
1168 last_timestamp: 0,
1169 sequence: 0,
1170 }
1171 }
1172
1173 fn next_id(&mut self) -> u64 {
1174 let timestamp = current_epoch_seconds().saturating_sub(EPOCH_SECONDS);
1175 if timestamp == self.last_timestamp {
1176 self.sequence = self.sequence.wrapping_add(1);
1177 } else {
1178 self.sequence = 0;
1179 self.last_timestamp = timestamp;
1180 }
1181
1182 (timestamp << 32) | self.sequence as u64
1183 }
1184}
1185
1186const EPOCH_SECONDS: u64 = 1_735_689_600; fn current_epoch_seconds() -> u64 {
1189 use std::time::{SystemTime, UNIX_EPOCH};
1190 SystemTime::now()
1191 .duration_since(UNIX_EPOCH)
1192 .unwrap_or_default()
1193 .as_secs()
1194}
1195
1196#[cfg(test)]
1197mod tests {
1198 use super::*;
1199
1200 #[test]
1201 fn rpc_error_code_name_uses_stable_proto_name() {
1202 assert_eq!(
1203 rpc_error_code_name(proto::rpc_error::Code::PeerIdInvalid as i32),
1204 "PEER_ID_INVALID"
1205 );
1206 assert_eq!(rpc_error_code_name(999), "UNKNOWN");
1207 }
1208
1209 #[test]
1210 fn rpc_error_formatter_covers_new_profile_codes() {
1211 assert_eq!(
1212 format_rpc_error(
1213 proto::rpc_error::Code::UsernameInvalid as i32,
1214 "USERNAME_INVALID",
1215 "",
1216 400
1217 ),
1218 "Invalid username (HTTP 400)"
1219 );
1220 assert_eq!(
1221 format_rpc_error(
1222 proto::rpc_error::Code::UsernameTaken as i32,
1223 "USERNAME_TAKEN",
1224 "handle exists",
1225 409
1226 ),
1227 "Username already taken: handle exists (HTTP 409)"
1228 );
1229 assert_eq!(
1230 format_rpc_error(
1231 proto::rpc_error::Code::FirstNameInvalid as i32,
1232 "FIRST_NAME_INVALID",
1233 "",
1234 400
1235 ),
1236 "Invalid first name (HTTP 400)"
1237 );
1238 }
1239
1240 #[test]
1241 fn connection_error_preserves_proto_reason() {
1242 let err = connection_error_from_proto(proto::ConnectionError {
1243 reason: proto::connection_error::Reason::InvalidAuth as i32,
1244 });
1245
1246 match err {
1247 RealtimeError::ConnectionError {
1248 reason,
1249 reason_name,
1250 friendly,
1251 } => {
1252 assert_eq!(reason, 2);
1253 assert_eq!(reason_name, "INVALID_AUTH");
1254 assert_eq!(friendly, "Realtime auth token is invalid");
1255 }
1256 other => panic!("expected connection error, got {other:?}"),
1257 }
1258 }
1259
1260 #[test]
1261 fn unknown_connection_error_reason_is_preserved() {
1262 let err = connection_error_from_proto(proto::ConnectionError { reason: 999 });
1263
1264 match err {
1265 RealtimeError::ConnectionError {
1266 reason,
1267 reason_name,
1268 friendly,
1269 } => {
1270 assert_eq!(reason, 999);
1271 assert_eq!(reason_name, "UNKNOWN");
1272 assert_eq!(friendly, "Realtime connection rejected: unknown reason 999");
1273 }
1274 other => panic!("expected connection error, got {other:?}"),
1275 }
1276 }
1277
1278 #[test]
1279 fn connection_init_uses_custom_client_identity() {
1280 let init =
1281 connection_init_for_token("token-1", &ClientIdentity::new("integration-test", "9.9.9"));
1282
1283 assert_eq!(init.token, "token-1");
1284 assert_eq!(init.client_version.as_deref(), Some("9.9.9"));
1285 assert!(init.build_number.is_none());
1286 assert!(init.layer.is_none());
1287 }
1288
1289 #[test]
1290 fn realtime_url_validation_accepts_ws_urls() {
1291 let url = normalize_realtime_url(" wss://api.inline.chat/realtime?edge=iad ").unwrap();
1292 assert_eq!(url.as_str(), "wss://api.inline.chat/realtime?edge=iad");
1293 }
1294
1295 #[test]
1296 fn realtime_url_validation_rejects_invalid_urls() {
1297 let err = normalize_realtime_url("inline.test").unwrap_err();
1298 match err {
1299 RealtimeError::InvalidUrl { url, message } => {
1300 assert_eq!(url, "inline.test");
1301 assert!(message.contains("relative URL without a base"));
1302 }
1303 other => panic!("expected invalid URL, got {other:?}"),
1304 }
1305
1306 let err = normalize_realtime_url("https://api.inline.chat/realtime").unwrap_err();
1307 match err {
1308 RealtimeError::InvalidUrl { message, .. } => {
1309 assert_eq!(message, "scheme must be ws or wss");
1310 }
1311 other => panic!("expected invalid URL, got {other:?}"),
1312 }
1313
1314 let err = normalize_realtime_url("wss://user:secret@api.inline.chat/realtime").unwrap_err();
1315 match &err {
1316 RealtimeError::InvalidUrl { message, .. } => {
1317 assert_eq!(message, "credentials are not valid in the realtime URL");
1318 assert!(!err.to_string().contains("secret"));
1319 }
1320 other => panic!("expected invalid URL, got {other:?}"),
1321 }
1322
1323 let err = normalize_realtime_url("wss://api.inline.chat/realtime#token").unwrap_err();
1324 match err {
1325 RealtimeError::InvalidUrl { message, .. } => {
1326 assert_eq!(message, "fragments are not valid in the realtime URL");
1327 }
1328 other => panic!("expected invalid URL, got {other:?}"),
1329 }
1330 }
1331
1332 #[test]
1333 fn realtime_invalid_url_debug_redacts_unsafe_url_parts() {
1334 let err = normalize_realtime_url(
1335 "wss://user:url-secret@api.inline.chat/realtime?token=query-secret#frag",
1336 )
1337 .unwrap_err();
1338 let debug = format!("{err:?}");
1339
1340 assert!(debug.contains("wss://api.inline.chat/realtime"));
1341 assert!(!debug.contains("url-secret"));
1342 assert!(!debug.contains("query-secret"));
1343 }
1344
1345 #[test]
1346 fn realtime_url_for_log_omits_query_and_fragment() {
1347 let url = Url::parse("wss://api.inline.chat/realtime?token=secret#frag").unwrap();
1348 assert_eq!(realtime_url_for_log(&url), "wss://api.inline.chat/realtime");
1349 }
1350
1351 #[test]
1352 fn realtime_header_value_rejects_invalid_values() {
1353 let err = realtime_header_value("user_agent", "bad\nvalue").unwrap_err();
1354 match err {
1355 RealtimeError::InvalidHeaderValue { field } => {
1356 assert_eq!(field, "user_agent");
1357 }
1358 other => panic!("expected invalid header value, got {other:?}"),
1359 }
1360 }
1361
1362 #[test]
1363 fn realtime_builder_keeps_custom_identity_and_default_timeouts() {
1364 let builder = RealtimeClient::builder("wss://api.inline.chat/realtime", "token-1")
1365 .identity(ClientIdentity::new("agent", "0.2.0"));
1366
1367 assert_eq!(builder.url, "wss://api.inline.chat/realtime");
1368 assert_eq!(builder.token, "token-1");
1369 assert_eq!(builder.identity.client_type(), "agent");
1370 assert_eq!(builder.identity.client_version(), "0.2.0");
1371 assert_eq!(builder.connect_timeout, Some(DEFAULT_CONNECT_TIMEOUT));
1372 assert_eq!(builder.rpc_timeout, Some(DEFAULT_RPC_TIMEOUT));
1373 }
1374
1375 #[test]
1376 fn realtime_builder_debug_redacts_token() {
1377 let builder = RealtimeClient::builder(
1378 "wss://user:url-secret@api.inline.chat/realtime?token=query-secret",
1379 "secret-token-1",
1380 );
1381 let debug = format!("{builder:?}");
1382
1383 assert!(debug.contains("RealtimeClientBuilder"));
1384 assert!(debug.contains("wss://api.inline.chat/realtime"));
1385 assert!(debug.contains("<redacted>"));
1386 assert!(!debug.contains("secret-token-1"));
1387 assert!(!debug.contains("url-secret"));
1388 assert!(!debug.contains("query-secret"));
1389 }
1390
1391 #[test]
1392 fn realtime_builder_can_override_or_disable_timeouts() {
1393 let builder = RealtimeClient::builder("wss://api.inline.chat/realtime", "token-1")
1394 .connect_timeout(Duration::from_secs(5))
1395 .rpc_timeout(Duration::from_secs(10));
1396
1397 assert_eq!(builder.connect_timeout, Some(Duration::from_secs(5)));
1398 assert_eq!(builder.rpc_timeout, Some(Duration::from_secs(10)));
1399
1400 let builder = builder.without_connect_timeout().without_rpc_timeout();
1401 assert_eq!(builder.connect_timeout, None);
1402 assert_eq!(builder.rpc_timeout, None);
1403 }
1404
1405 #[test]
1406 fn typed_rpc_request_maps_method_input_and_result() {
1407 assert_eq!(
1408 <proto::GetChatsInput as RpcRequest>::METHOD,
1409 proto::Method::GetChats
1410 );
1411
1412 let input = proto::GetChatsInput {};
1413 match input.into_rpc_input() {
1414 proto::rpc_call::Input::GetChats(_) => {}
1415 other => panic!("expected GetChats input, got {other:?}"),
1416 }
1417
1418 let response = <proto::GetChatsInput as RpcRequest>::response_from_rpc_result(
1419 proto::rpc_result::Result::GetChats(proto::GetChatsResult::default()),
1420 )
1421 .unwrap();
1422 assert!(response.dialogs.is_empty());
1423 }
1424
1425 #[test]
1426 fn typed_rpc_request_rejects_unexpected_result_variant() {
1427 let err = <proto::GetChatsInput as RpcRequest>::response_from_rpc_result(
1428 proto::rpc_result::Result::GetMe(proto::GetMeResult::default()),
1429 )
1430 .unwrap_err();
1431
1432 match err {
1433 RealtimeError::UnexpectedResult {
1434 method,
1435 expected,
1436 actual,
1437 } => {
1438 assert_eq!(method, "GET_CHATS");
1439 assert_eq!(expected, "GetChats");
1440 assert_eq!(actual, "GetMe");
1441 }
1442 other => panic!("expected unexpected result error, got {other:?}"),
1443 }
1444 }
1445
1446 #[tokio::test]
1447 async fn optional_timeout_reports_elapsed_operation() {
1448 let err = with_optional_timeout("test", Some(Duration::from_millis(1)), async {
1449 tokio::time::sleep(Duration::from_millis(20)).await;
1450 Ok::<(), RealtimeError>(())
1451 })
1452 .await
1453 .unwrap_err();
1454
1455 match err {
1456 RealtimeError::Timeout { operation, timeout } => {
1457 assert_eq!(operation, "test");
1458 assert_eq!(timeout, Duration::from_millis(1));
1459 }
1460 other => panic!("expected timeout, got {other:?}"),
1461 }
1462 }
1463
1464 #[tokio::test]
1465 async fn optional_timeout_can_be_disabled() {
1466 with_optional_timeout("test", None, async { Ok::<_, RealtimeError>("done") })
1467 .await
1468 .unwrap();
1469 }
1470}