Skip to main content

vapour_protocol/
chat.rs

1//! 1-on-1 friend chat over the modern unified `FriendMessages.*` service.
2//!
3//! Mirrors the `library.rs` / `service_method.rs` pattern: outbound requests go through
4//! `call_authed` (`ServiceMethodCallFromClient`, EMsg 151 → `ServiceMethodResponse` 147), and
5//! incoming messages arrive unsolicited as a `ServiceMethod` (EMsg 146) server push, identified by
6//! `target_job_name`. No per-conversation subscribe is needed, but the logon MUST set
7//! `chat_mode = 2` (see `client.rs`) or Steam never pushes friend messages to the session.
8
9use 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
23/// `EChatEntryType` values we use. The enum isn't present in any compiled proto, so the raw
24/// ints are hardcoded (values per SteamKit `enums.steamd`).
25pub const CHAT_ENTRY_TEXT: i32 = 1;
26pub const CHAT_ENTRY_TYPING: i32 = 2;
27
28/// Job name carried by the incoming-message server push (EMsg 146 `ServiceMethod`).
29const INCOMING_MESSAGE_JOB: &str = "FriendMessagesClient.IncomingMessage#1";
30
31/// A single 1-on-1 chat message, normalised for the UI/cache layers.
32#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
33pub struct ChatMessage {
34    /// The conversation partner (the friend), regardless of who authored the message.
35    pub steamid: u64,
36    pub message: String,
37    /// Steam `rtime32` server timestamp (Unix seconds).
38    pub timestamp: u32,
39    /// Disambiguates multiple messages sharing the same second.
40    pub ordinal: u32,
41    /// True when the local user authored this message.
42    pub from_local: bool,
43}
44
45/// Send a text message to `steamid`, returning the confirmed message stamped with Steam's
46/// authoritative `server_timestamp` + `ordinal` (the same `(timestamp, ordinal)` key the message
47/// later carries in history and cross-session echoes — so callers can dedupe without heuristics).
48pub 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        // Send the raw text verbatim; brackets render literally in the terminal.
60        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
68/// Send a typing indicator to `steamid` (best-effort).
69pub 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
86/// Fetch recent message history for the conversation with `steamid`, oldest first.
87pub 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
104/// Decode an incoming friend-message server push. Steam delivers server-initiated unified
105/// notifications as EMsg `ServiceMethod` (146) — NOT `ServiceMethodSendToClient` (152) — and
106/// demultiplexes them purely by `target_job_name` (verified against node-steam-user and SteamKit).
107/// Accept both EMsgs and gate on the job name; return `None` for any other push (reactions,
108/// session notices, group-chat client pushes) so the run loop ignores them.
109pub 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    // `steamid_friend` is always the partner, even for cross-session echoes of our own sends.
119    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            // Direction comes from `local_echo`, never from comparing steamids.
127            from_local: notification.local_echo.unwrap_or(false),
128        })),
129        CHAT_ENTRY_TYPING => Some(FriendsEvent::TypingNotification { steamid: partner }),
130        _ => None,
131    }
132}
133
134/// Build the confirmed `ChatMessage` for one of our own sends from the SendMessage response.
135fn sent_message(
136    steamid: u64,
137    original: String,
138    response: CFriendMessagesSendMessageResponse,
139) -> ChatMessage {
140    ChatMessage {
141        steamid,
142        // Steam may normalise the message (e.g. bbcode); prefer its version when present.
143        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
153/// The low 32 bits of a SteamID64 are the account id used in `GetRecentMessages` rows.
154fn 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    // Steam does not guarantee ordering; sort oldest-first for display.
176    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 &lt;there&gt;".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 &lt;there&gt;");
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        // Real-world pushes arrive as EMsg ServiceMethod (146), keyed by target_job_name.
270        let packet = incoming_packet(EMsg::ServiceMethod, INCOMING_MESSAGE_JOB, &notification);
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        // Defensive: also accept the rarer ServiceMethodSendToClient (152) EMsg.
286        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            &notification,
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, &notification);
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            &notification,
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}