Skip to main content

steam_client/services/
chat.rs

1//! Chat functionality for Steam client.
2//!
3//! This module provides friend messaging, typing indicators, and chat history.
4
5use prost::Message;
6use steam_enums::EChatEntryType;
7use steamid::SteamID;
8use tracing::{error, info};
9
10use crate::{error::SteamError, SteamClient};
11
12/// Chat message sent to a friend.
13#[derive(Debug, Clone)]
14pub struct ChatMessage {
15    /// The message content.
16    pub message: String,
17    /// The chat entry type.
18    pub chat_entry_type: EChatEntryType,
19    /// Whether the message contains BBCode.
20    pub contains_bbcode: bool,
21}
22
23impl Default for ChatMessage {
24    fn default() -> Self {
25        Self { message: String::new(), chat_entry_type: EChatEntryType::ChatMsg, contains_bbcode: true }
26    }
27}
28
29/// Result of sending a chat message.
30#[derive(Debug, Clone)]
31pub struct SendMessageResult {
32    /// The modified message (after server processing).
33    pub modified_message: String,
34    /// Server timestamp of the message.
35    pub server_timestamp: u32,
36    /// Message ordinal.
37    pub ordinal: u32,
38}
39
40/// A message from chat history.
41#[derive(Debug, Clone)]
42pub struct HistoryMessage {
43    /// The sender's SteamID.
44    pub sender: SteamID,
45    /// Server timestamp.
46    pub timestamp: u32,
47    /// Message ordinal.
48    pub ordinal: u32,
49    /// The message content.
50    pub message: String,
51    /// Whether this message was unread.
52    pub unread: bool,
53}
54
55/// Active message session with a friend.
56#[derive(Debug, Clone)]
57pub struct FriendMessageSession {
58    /// Friend's SteamID.
59    pub friend: SteamID,
60    /// Time of last message.
61    pub time_last_message: u32,
62    /// Time last viewed.
63    pub time_last_view: u32,
64    /// Count of unread messages.
65    pub unread_count: u32,
66}
67
68impl SteamClient {
69    /// Send a chat message to a friend.
70    ///
71    /// # Arguments
72    /// * `friend` - The friend's SteamID
73    /// * `message` - The message to send
74    ///
75    /// # Example
76    /// ```rust,ignore
77    /// client.send_friend_message(friend_id, "Hello!").await?;
78    /// ```
79    pub async fn send_friend_message(&mut self, friend: SteamID, message: &str) -> Result<SendMessageResult, SteamError> {
80        self.send_friend_message_with_options(friend, message, EChatEntryType::ChatMsg, true).await
81    }
82
83    /// Send a chat message to a friend without awaiting the server response.
84    ///
85    /// This returns a receiver that will yield the result once the message
86    /// is processed by the event loop.
87    pub fn send_friend_message_async(&mut self, friend: SteamID, message: &str) -> Result<tokio::sync::oneshot::Receiver<Result<SendMessageResult, SteamError>>, SteamError> {
88        info!("[SteamClient] send_friend_message_async called");
89        info!("[SteamClient]   Target friend: {}", friend);
90        info!("[SteamClient]   Message length: {}", message.len());
91        info!("[SteamClient]   is_logged_in: {}", self.is_logged_in());
92        info!("[SteamClient]   steam_id: {:?}", self.steam_id);
93
94        if !self.is_logged_in() {
95            error!("[SteamClient] NOT LOGGED IN - returning NotLoggedOn error");
96            return Err(SteamError::NotLoggedOn);
97        }
98
99        let (tx, rx) = tokio::sync::oneshot::channel();
100
101        let queued = crate::client::steam_client::QueuedMessage {
102            friend,
103            message: message.to_string(),
104            entry_type: EChatEntryType::ChatMsg,
105            contains_bbcode: true,
106            respond_to: tx,
107        };
108
109        match self.chat.tx.send(queued) {
110            Ok(_) => {
111                info!("[SteamClient] Message queued to chat_queue_tx successfully for {}", friend);
112            }
113            Err(e) => {
114                error!("[SteamClient] Failed to queue message to chat_queue_tx: {:?}", e);
115                return Err(SteamError::Other("Failed to queue message".to_string()));
116            }
117        }
118
119        Ok(rx)
120    }
121
122    /// Send a chat message to a friend with custom options.
123    ///
124    /// # Arguments
125    /// * `friend` - The friend's SteamID
126    /// * `message` - The message to send
127    /// * `entry_type` - The chat entry type
128    /// * `contains_bbcode` - Whether the message contains BBCode
129    pub async fn send_friend_message_with_options(&mut self, friend: SteamID, message: &str, entry_type: EChatEntryType, contains_bbcode: bool) -> Result<SendMessageResult, SteamError> {
130        if !self.is_logged_in() {
131            return Err(SteamError::NotLoggedOn);
132        }
133
134        let (tx, rx) = tokio::sync::oneshot::channel();
135
136        let queued = crate::client::steam_client::QueuedMessage { friend, message: message.to_string(), entry_type, contains_bbcode, respond_to: tx };
137
138        self.chat.tx.send(queued).map_err(|_| SteamError::Other("Failed to queue message".to_string()))?;
139
140        rx.await.map_err(|_| SteamError::Other("Response channel closed".to_string()))?
141    }
142
143    /// Send a typing indicator to a friend.
144    ///
145    /// # Arguments
146    /// * `friend` - The friend's SteamID
147    pub async fn send_friend_typing(&mut self, friend: SteamID) -> Result<(), SteamError> {
148        self.send_friend_message_with_options(friend, "", EChatEntryType::Typing, false).await?;
149        Ok(())
150    }
151
152    /// Get active friend message sessions.
153    ///
154    /// Returns a list of friends with active (recent) conversations.
155    pub async fn get_active_friend_sessions(&mut self) -> Result<Vec<FriendMessageSession>, SteamError> {
156        if !self.is_logged_in() {
157            return Err(SteamError::NotLoggedOn);
158        }
159
160        let msg = steam_protos::CFriendMessagesGetActiveMessageSessionsRequest::default();
161        let rx = self.send_service_method_with_job("FriendMessages.GetActiveMessageSessions#1", &msg).await?;
162
163        // Wait for response
164        let job_response = rx.await.map_err(|_| SteamError::ResponseTimeout)?;
165
166        let body = match job_response {
167            crate::internal::jobs::JobResponse::Success(bytes) => bytes,
168            crate::internal::jobs::JobResponse::Timeout => return Err(SteamError::ResponseTimeout),
169            crate::internal::jobs::JobResponse::Error(msg) => return Err(SteamError::ProtocolError(msg)),
170        };
171
172        let response = steam_protos::CFriendMessagesGetActiveMessageSessionsResponse::decode(&body[..]).map_err(|_| SteamError::DeserializationFailed)?;
173
174        let sessions = response
175            .message_sessions
176            .into_iter()
177            .map(|s| FriendMessageSession {
178                friend: SteamID::from_steam_id64(s.accountid_friend.unwrap_or(0) as u64),
179                time_last_message: s.last_message.unwrap_or(0),
180                time_last_view: s.last_view.unwrap_or(0),
181                unread_count: s.unread_message_count.unwrap_or(0),
182            })
183            .collect();
184
185        Ok(sessions)
186    }
187
188    /// Acknowledge (mark as read) a friend message.
189    ///
190    /// # Arguments
191    /// * `friend` - The friend's SteamID
192    /// * `timestamp` - Unix timestamp of the newest message to acknowledge
193    pub async fn ack_friend_message(&mut self, friend: SteamID, timestamp: u32) -> Result<(), SteamError> {
194        if !self.is_logged_in() {
195            return Err(SteamError::NotLoggedOn);
196        }
197
198        let msg = steam_protos::CFriendMessagesAckMessageNotification { steamid_partner: Some(friend.steam_id64()), timestamp: Some(timestamp) };
199
200        // Update local state
201        self.social.write().chat_last_view.insert(friend, timestamp);
202
203        self.send_service_method("FriendMessages.AckMessage#1", &msg).await
204    }
205
206    /// Get chat history with a friend.
207    ///
208    /// # Arguments
209    /// * `friend` - The friend's SteamID
210    /// * `start_time` - Unix timestamp to get messages from (0 to get most
211    ///   recent)
212    /// * `count` - Maximum number of messages to retrieve
213    ///
214    /// # Returns
215    /// A list of messages in the conversation.
216    pub async fn get_chat_history(&mut self, friend: SteamID, start_time: u32, count: u32) -> Result<Vec<HistoryMessage>, SteamError> {
217        if !self.is_logged_in() {
218            return Err(SteamError::NotLoggedOn);
219        }
220
221        let msg = steam_protos::CFriendMessagesGetRecentMessagesRequest {
222            steamid1: self.steam_id.as_ref().map(|s| s.steam_id64()),
223            steamid2: Some(friend.steam_id64()),
224            count: Some(count),
225            most_recent_conversation: Some(start_time == 0),
226            rtime32_start_time: if start_time > 0 { Some(start_time) } else { None },
227            ..Default::default()
228        };
229
230        let rx = self.send_service_method_with_job("FriendMessages.GetRecentMessages#1", &msg).await?;
231
232        // Wait for response
233        let job_response = rx.await.map_err(|_| SteamError::ResponseTimeout)?;
234
235        let body = match job_response {
236            crate::internal::jobs::JobResponse::Success(bytes) => bytes,
237            crate::internal::jobs::JobResponse::Timeout => return Err(SteamError::ResponseTimeout),
238            crate::internal::jobs::JobResponse::Error(msg) => return Err(SteamError::ProtocolError(msg)),
239        };
240
241        let response = steam_protos::CFriendMessagesGetRecentMessagesResponse::decode(&body[..]).map_err(|_| SteamError::DeserializationFailed)?;
242
243        let last_view_timestamp = self.social.read().chat_last_view.get(&friend).copied().unwrap_or(0);
244
245        let messages = response
246            .messages
247            .into_iter()
248            .map(|m| HistoryMessage {
249                sender: SteamID::from_steam_id64(m.accountid.unwrap_or(0) as u64),
250                timestamp: m.timestamp.unwrap_or(0),
251                ordinal: m.ordinal.unwrap_or(0),
252                message: m.message.unwrap_or_default(),
253                unread: m.timestamp.unwrap_or(0) > last_view_timestamp,
254            })
255            .collect();
256
257        Ok(messages)
258    }
259
260    /// Get the chat history for the most recent messages with a friend.
261    ///
262    /// # Arguments
263    /// * `friend` - The friend's SteamID
264    ///
265    /// # Returns
266    /// A list of recent messages in the conversation (up to 20).
267    pub async fn get_recent_chat_history(&mut self, friend: SteamID) -> Result<Vec<HistoryMessage>, SteamError> {
268        self.get_chat_history(friend, 0, 20).await
269    }
270
271    /// Helper to send a unified service method call.
272    pub(crate) async fn send_service_method<T: prost::Message>(&mut self, method: &str, body: &T) -> Result<(), SteamError> {
273        use crate::protocol::{ProtobufMessageHeader, SteamMessage};
274
275        let header = ProtobufMessageHeader {
276            header_length: 0,
277            session_id: self.auth.read().session_id,
278            steam_id: self.steam_id.as_ref().map(|s| s.steam_id64()).unwrap_or(0),
279            job_id_source: u64::MAX,
280            job_id_target: u64::MAX,
281            target_job_name: Some(method.to_string()),
282            routing_appid: None,
283        };
284
285        let msg = SteamMessage::new_proto(steam_enums::EMsg::ServiceMethodCallFromClient, header, body);
286
287        if let Some(ref mut conn) = self.connection {
288            conn.send(msg.encode()).await?;
289        }
290
291        Ok(())
292    }
293
294    /// Helper to send a unified service method call with job tracking.
295    pub(crate) async fn send_service_method_with_job<T: prost::Message>(&mut self, method: &str, body: &T) -> Result<tokio::sync::oneshot::Receiver<crate::internal::jobs::JobResponse>, SteamError> {
296        use crate::protocol::{ProtobufMessageHeader, SteamMessage};
297
298        info!("[SteamClient] send_service_method_with_job: method={}", method);
299
300        // Create a job to track this request
301        let (job_id, response_rx) = self.job_manager.create_job().await;
302        info!("[SteamClient] send_service_method_with_job: created job_id={}", job_id);
303
304        let header = ProtobufMessageHeader {
305            header_length: 0,
306            session_id: self.auth.read().session_id,
307            steam_id: self.steam_id.as_ref().map(|s| s.steam_id64()).unwrap_or(0),
308            job_id_source: job_id,
309            job_id_target: u64::MAX,
310            target_job_name: Some(method.to_string()),
311            routing_appid: None,
312        };
313
314        let msg = SteamMessage::new_proto(steam_enums::EMsg::ServiceMethodCallFromClient, header, body);
315
316        if let Some(ref mut conn) = self.connection {
317            let encoded = msg.encode();
318            info!("[SteamClient] send_service_method_with_job: Sending {} bytes to Steam connection", encoded.len());
319            conn.send(encoded).await?;
320            info!("[SteamClient] send_service_method_with_job: Sent successfully");
321        } else {
322            error!("[SteamClient] send_service_method_with_job: No connection available!");
323            return Err(SteamError::NotConnected);
324        }
325
326        Ok(response_rx)
327    }
328
329    /// Get chat history with a friend in the background.
330    ///
331    /// The results will be delivered via a `ChatEvent::OfflineMessagesFetched`
332    /// event.
333    pub async fn get_chat_history_background(&mut self, friend: SteamID, start_time: u32, count: u32) -> Result<(), SteamError> {
334        if !self.is_logged_in() {
335            return Err(SteamError::NotLoggedOn);
336        }
337
338        let msg = steam_protos::CFriendMessagesGetRecentMessagesRequest {
339            steamid1: self.steam_id.as_ref().map(|s| s.steam_id64()),
340            steamid2: Some(friend.steam_id64()),
341            count: Some(count),
342            most_recent_conversation: Some(start_time == 0),
343            rtime32_start_time: if start_time > 0 { Some(start_time) } else { None },
344            ..Default::default()
345        };
346
347        self.send_service_method_background("FriendMessages.GetRecentMessages#1", &msg, crate::client::steam_client::BackgroundTask::OfflineMessages(friend)).await
348    }
349}