1use crate::{
10 connection::{Connection, ConnectionState},
11 emsg::EMsg,
12 error::Result,
13 friends::FriendsEvent,
14 message::Packet,
15 protobuf::{
16 CFriendMessagesGetRecentMessagesRequest, CFriendMessagesGetRecentMessagesResponse,
17 CFriendMessagesIncomingMessageNotification, CFriendMessagesSendMessageRequest,
18 CFriendMessagesSendMessageResponse,
19 },
20 service_method::{ServiceMethod, call_authed},
21};
22
23pub const CHAT_ENTRY_TEXT: i32 = 1;
26pub const CHAT_ENTRY_TYPING: i32 = 2;
27
28const INCOMING_MESSAGE_JOB: &str = "FriendMessagesClient.IncomingMessage#1";
30
31#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
33pub struct ChatMessage {
34 pub steamid: u64,
36 pub message: String,
37 pub timestamp: u32,
39 pub ordinal: u32,
41 pub from_local: bool,
43}
44
45pub async fn send_message(
49 connection: &Connection,
50 state: &ConnectionState,
51 steamid: u64,
52 message: String,
53) -> Result<ChatMessage> {
54 let method = ServiceMethod::new("FriendMessages.SendMessage#1");
55 let request = CFriendMessagesSendMessageRequest {
56 steamid: Some(steamid),
57 chat_entry_type: Some(CHAT_ENTRY_TEXT),
58 message: Some(message.clone()),
59 contains_bbcode: Some(false),
61 ..Default::default()
62 };
63 let response: CFriendMessagesSendMessageResponse =
64 call_authed(connection, state, &method, &request).await?;
65 Ok(sent_message(steamid, message, response))
66}
67
68pub async fn send_typing(
70 connection: &Connection,
71 state: &ConnectionState,
72 steamid: u64,
73) -> Result<()> {
74 let method = ServiceMethod::new("FriendMessages.SendMessage#1");
75 let request = CFriendMessagesSendMessageRequest {
76 steamid: Some(steamid),
77 chat_entry_type: Some(CHAT_ENTRY_TYPING),
78 message: Some(String::new()),
79 ..Default::default()
80 };
81 let _response: CFriendMessagesSendMessageResponse =
82 call_authed(connection, state, &method, &request).await?;
83 Ok(())
84}
85
86pub async fn get_recent_messages(
88 connection: &Connection,
89 state: &ConnectionState,
90 steamid: u64,
91) -> Result<Vec<ChatMessage>> {
92 let method = ServiceMethod::new("FriendMessages.GetRecentMessages#1");
93 let request = CFriendMessagesGetRecentMessagesRequest {
94 steamid1: state.steamid,
95 steamid2: Some(steamid),
96 count: Some(50),
97 ..Default::default()
98 };
99 let response: CFriendMessagesGetRecentMessagesResponse =
100 call_authed(connection, state, &method, &request).await?;
101 Ok(recent_to_messages(response, steamid, state.steamid))
102}
103
104pub fn decode_incoming(packet: &Packet) -> Option<FriendsEvent> {
110 let is_service_push = packet.emsg == EMsg::ServiceMethod.raw()
111 || packet.emsg == EMsg::ServiceMethodSendToClient.raw();
112 if !is_service_push || packet.target_job_name() != Some(INCOMING_MESSAGE_JOB) {
113 return None;
114 }
115 let notification = packet
116 .decode_body::<CFriendMessagesIncomingMessageNotification>()
117 .ok()?;
118 let partner = notification.steamid_friend?;
120 match notification.chat_entry_type.unwrap_or(0) {
121 CHAT_ENTRY_TEXT => Some(FriendsEvent::IncomingMessage(ChatMessage {
122 steamid: partner,
123 message: notification.message.unwrap_or_default(),
124 timestamp: notification.rtime32_server_timestamp.unwrap_or(0),
125 ordinal: notification.ordinal.unwrap_or(0),
126 from_local: notification.local_echo.unwrap_or(false),
128 })),
129 CHAT_ENTRY_TYPING => Some(FriendsEvent::TypingNotification { steamid: partner }),
130 _ => None,
131 }
132}
133
134fn sent_message(
136 steamid: u64,
137 original: String,
138 response: CFriendMessagesSendMessageResponse,
139) -> ChatMessage {
140 ChatMessage {
141 steamid,
142 message: response
144 .modified_message
145 .filter(|m| !m.is_empty())
146 .unwrap_or(original),
147 timestamp: response.server_timestamp.unwrap_or(0),
148 ordinal: response.ordinal.unwrap_or(0),
149 from_local: true,
150 }
151}
152
153fn account_id_of(steamid: Option<u64>) -> Option<u32> {
155 steamid.map(|s| (s & 0xFFFF_FFFF) as u32)
156}
157
158fn recent_to_messages(
159 response: CFriendMessagesGetRecentMessagesResponse,
160 partner: u64,
161 self_steamid: Option<u64>,
162) -> Vec<ChatMessage> {
163 let self_account = account_id_of(self_steamid);
164 let mut messages: Vec<ChatMessage> = response
165 .messages
166 .into_iter()
167 .map(|m| ChatMessage {
168 steamid: partner,
169 message: m.message.unwrap_or_default(),
170 timestamp: m.timestamp.unwrap_or(0),
171 ordinal: m.ordinal.unwrap_or(0),
172 from_local: m.accountid.is_some() && m.accountid == self_account,
173 })
174 .collect();
175 messages.sort_by(|a, b| {
177 a.timestamp
178 .cmp(&b.timestamp)
179 .then(a.ordinal.cmp(&b.ordinal))
180 });
181 messages
182}
183
184#[cfg(test)]
185mod tests {
186 use super::*;
187 use crate::message::{decode_frame, encode_message};
188 use crate::protobuf::{
189 CMsgProtoBufHeader, c_friend_messages_get_recent_messages_response::FriendMessage,
190 };
191 use prost::Message;
192
193 const SELF_STEAMID: u64 = 76561198000000001;
194 const PARTNER_STEAMID: u64 = 76561198000000002;
195
196 fn incoming_packet(
197 emsg: EMsg,
198 job: &str,
199 notification: &CFriendMessagesIncomingMessageNotification,
200 ) -> Packet {
201 let header = CMsgProtoBufHeader {
202 target_job_name: Some(job.to_owned()),
203 ..Default::default()
204 };
205 let encoded = encode_message(emsg, &header, notification).unwrap();
206 decode_frame(&encoded)
207 .unwrap()
208 .into_iter()
209 .next()
210 .expect("one packet")
211 }
212
213 #[test]
214 fn send_request_roundtrips() {
215 let request = CFriendMessagesSendMessageRequest {
216 steamid: Some(PARTNER_STEAMID),
217 chat_entry_type: Some(CHAT_ENTRY_TEXT),
218 message: Some("hi there".to_owned()),
219 contains_bbcode: Some(false),
220 ..Default::default()
221 };
222 let bytes = request.encode_to_vec();
223 let back = CFriendMessagesSendMessageRequest::decode(bytes.as_slice()).unwrap();
224 assert_eq!(back.steamid, Some(PARTNER_STEAMID));
225 assert_eq!(back.message.as_deref(), Some("hi there"));
226 assert_eq!(back.chat_entry_type, Some(CHAT_ENTRY_TEXT));
227 }
228
229 #[test]
230 fn sent_message_uses_server_stamp_and_modified_text() {
231 let response = CFriendMessagesSendMessageResponse {
232 modified_message: Some("hi <there>".to_owned()),
233 server_timestamp: Some(1717),
234 ordinal: Some(3),
235 ..Default::default()
236 };
237 let sent = sent_message(PARTNER_STEAMID, "hi <there>".to_owned(), response);
238 assert_eq!(sent.steamid, PARTNER_STEAMID);
239 assert_eq!(sent.message, "hi <there>");
240 assert_eq!(sent.timestamp, 1717);
241 assert_eq!(sent.ordinal, 3);
242 assert!(sent.from_local);
243 }
244
245 #[test]
246 fn sent_message_falls_back_to_original_when_unmodified() {
247 let response = CFriendMessagesSendMessageResponse {
248 server_timestamp: Some(42),
249 ordinal: Some(0),
250 ..Default::default()
251 };
252 let sent = sent_message(PARTNER_STEAMID, "plain".to_owned(), response);
253 assert_eq!(sent.message, "plain");
254 assert_eq!(sent.timestamp, 42);
255 assert!(sent.from_local);
256 }
257
258 #[test]
259 fn decodes_incoming_text_message() {
260 let notification = CFriendMessagesIncomingMessageNotification {
261 steamid_friend: Some(PARTNER_STEAMID),
262 chat_entry_type: Some(CHAT_ENTRY_TEXT),
263 message: Some("hello".to_owned()),
264 rtime32_server_timestamp: Some(1000),
265 ordinal: Some(2),
266 local_echo: Some(false),
267 ..Default::default()
268 };
269 let packet = incoming_packet(EMsg::ServiceMethod, INCOMING_MESSAGE_JOB, ¬ification);
271 match decode_incoming(&packet) {
272 Some(FriendsEvent::IncomingMessage(m)) => {
273 assert_eq!(m.steamid, PARTNER_STEAMID);
274 assert_eq!(m.message, "hello");
275 assert_eq!(m.timestamp, 1000);
276 assert_eq!(m.ordinal, 2);
277 assert!(!m.from_local);
278 }
279 other => panic!("expected IncomingMessage, got {other:?}"),
280 }
281 }
282
283 #[test]
284 fn decodes_incoming_text_message_via_send_to_client() {
285 let notification = CFriendMessagesIncomingMessageNotification {
287 steamid_friend: Some(PARTNER_STEAMID),
288 chat_entry_type: Some(CHAT_ENTRY_TEXT),
289 message: Some("hello".to_owned()),
290 rtime32_server_timestamp: Some(1000),
291 ordinal: Some(2),
292 local_echo: Some(false),
293 ..Default::default()
294 };
295 let packet = incoming_packet(
296 EMsg::ServiceMethodSendToClient,
297 INCOMING_MESSAGE_JOB,
298 ¬ification,
299 );
300 assert!(matches!(
301 decode_incoming(&packet),
302 Some(FriendsEvent::IncomingMessage(_))
303 ));
304 }
305
306 #[test]
307 fn decodes_incoming_typing() {
308 let notification = CFriendMessagesIncomingMessageNotification {
309 steamid_friend: Some(PARTNER_STEAMID),
310 chat_entry_type: Some(CHAT_ENTRY_TYPING),
311 ..Default::default()
312 };
313 let packet = incoming_packet(EMsg::ServiceMethod, INCOMING_MESSAGE_JOB, ¬ification);
314 match decode_incoming(&packet) {
315 Some(FriendsEvent::TypingNotification { steamid }) => {
316 assert_eq!(steamid, PARTNER_STEAMID)
317 }
318 other => panic!("expected TypingNotification, got {other:?}"),
319 }
320 }
321
322 #[test]
323 fn ignores_unrelated_push_job() {
324 let notification = CFriendMessagesIncomingMessageNotification {
325 steamid_friend: Some(PARTNER_STEAMID),
326 chat_entry_type: Some(CHAT_ENTRY_TEXT),
327 message: Some("from a group".to_owned()),
328 ..Default::default()
329 };
330 let packet = incoming_packet(
331 EMsg::ServiceMethod,
332 "ChatRoomClient.NotifyIncomingChatMessage#1",
333 ¬ification,
334 );
335 assert!(decode_incoming(&packet).is_none());
336 }
337
338 #[test]
339 fn recent_messages_marks_local_author_and_sorts_oldest_first() {
340 let self_account = (SELF_STEAMID & 0xFFFF_FFFF) as u32;
341 let response = CFriendMessagesGetRecentMessagesResponse {
342 messages: vec![
343 FriendMessage {
344 accountid: Some(self_account),
345 timestamp: Some(200),
346 message: Some("my reply".to_owned()),
347 ordinal: Some(0),
348 ..Default::default()
349 },
350 FriendMessage {
351 accountid: Some(0xDEAD_BEEF),
352 timestamp: Some(100),
353 message: Some("their hello".to_owned()),
354 ordinal: Some(0),
355 ..Default::default()
356 },
357 ],
358 ..Default::default()
359 };
360 let messages = recent_to_messages(response, PARTNER_STEAMID, Some(SELF_STEAMID));
361 assert_eq!(messages.len(), 2);
362 assert_eq!(messages[0].message, "their hello");
363 assert!(!messages[0].from_local);
364 assert_eq!(messages[0].steamid, PARTNER_STEAMID);
365 assert_eq!(messages[1].message, "my reply");
366 assert!(messages[1].from_local);
367 }
368}