use crate::{
connection::{Connection, ConnectionState},
emsg::EMsg,
error::Result,
friends::FriendsEvent,
message::Packet,
protobuf::{
CFriendMessagesGetRecentMessagesRequest, CFriendMessagesGetRecentMessagesResponse,
CFriendMessagesIncomingMessageNotification, CFriendMessagesSendMessageRequest,
CFriendMessagesSendMessageResponse,
},
service_method::{ServiceMethod, call_authed},
};
pub const CHAT_ENTRY_TEXT: i32 = 1;
pub const CHAT_ENTRY_TYPING: i32 = 2;
const INCOMING_MESSAGE_JOB: &str = "FriendMessagesClient.IncomingMessage#1";
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct ChatMessage {
pub steamid: u64,
pub message: String,
pub timestamp: u32,
pub ordinal: u32,
pub from_local: bool,
}
pub async fn send_message(
connection: &Connection,
state: &ConnectionState,
steamid: u64,
message: String,
) -> Result<ChatMessage> {
let method = ServiceMethod::new("FriendMessages.SendMessage#1");
let request = CFriendMessagesSendMessageRequest {
steamid: Some(steamid),
chat_entry_type: Some(CHAT_ENTRY_TEXT),
message: Some(message.clone()),
contains_bbcode: Some(false),
..Default::default()
};
let response: CFriendMessagesSendMessageResponse =
call_authed(connection, state, &method, &request).await?;
Ok(sent_message(steamid, message, response))
}
pub async fn send_typing(
connection: &Connection,
state: &ConnectionState,
steamid: u64,
) -> Result<()> {
let method = ServiceMethod::new("FriendMessages.SendMessage#1");
let request = CFriendMessagesSendMessageRequest {
steamid: Some(steamid),
chat_entry_type: Some(CHAT_ENTRY_TYPING),
message: Some(String::new()),
..Default::default()
};
let _response: CFriendMessagesSendMessageResponse =
call_authed(connection, state, &method, &request).await?;
Ok(())
}
pub async fn get_recent_messages(
connection: &Connection,
state: &ConnectionState,
steamid: u64,
) -> Result<Vec<ChatMessage>> {
let method = ServiceMethod::new("FriendMessages.GetRecentMessages#1");
let request = CFriendMessagesGetRecentMessagesRequest {
steamid1: state.steamid,
steamid2: Some(steamid),
count: Some(50),
..Default::default()
};
let response: CFriendMessagesGetRecentMessagesResponse =
call_authed(connection, state, &method, &request).await?;
Ok(recent_to_messages(response, steamid, state.steamid))
}
pub fn decode_incoming(packet: &Packet) -> Option<FriendsEvent> {
let is_service_push = packet.emsg == EMsg::ServiceMethod.raw()
|| packet.emsg == EMsg::ServiceMethodSendToClient.raw();
if !is_service_push || packet.target_job_name() != Some(INCOMING_MESSAGE_JOB) {
return None;
}
let notification = packet
.decode_body::<CFriendMessagesIncomingMessageNotification>()
.ok()?;
let partner = notification.steamid_friend?;
match notification.chat_entry_type.unwrap_or(0) {
CHAT_ENTRY_TEXT => Some(FriendsEvent::IncomingMessage(ChatMessage {
steamid: partner,
message: notification.message.unwrap_or_default(),
timestamp: notification.rtime32_server_timestamp.unwrap_or(0),
ordinal: notification.ordinal.unwrap_or(0),
from_local: notification.local_echo.unwrap_or(false),
})),
CHAT_ENTRY_TYPING => Some(FriendsEvent::TypingNotification { steamid: partner }),
_ => None,
}
}
fn sent_message(
steamid: u64,
original: String,
response: CFriendMessagesSendMessageResponse,
) -> ChatMessage {
ChatMessage {
steamid,
message: response
.modified_message
.filter(|m| !m.is_empty())
.unwrap_or(original),
timestamp: response.server_timestamp.unwrap_or(0),
ordinal: response.ordinal.unwrap_or(0),
from_local: true,
}
}
fn account_id_of(steamid: Option<u64>) -> Option<u32> {
steamid.map(|s| (s & 0xFFFF_FFFF) as u32)
}
fn recent_to_messages(
response: CFriendMessagesGetRecentMessagesResponse,
partner: u64,
self_steamid: Option<u64>,
) -> Vec<ChatMessage> {
let self_account = account_id_of(self_steamid);
let mut messages: Vec<ChatMessage> = response
.messages
.into_iter()
.map(|m| ChatMessage {
steamid: partner,
message: m.message.unwrap_or_default(),
timestamp: m.timestamp.unwrap_or(0),
ordinal: m.ordinal.unwrap_or(0),
from_local: m.accountid.is_some() && m.accountid == self_account,
})
.collect();
messages.sort_by(|a, b| {
a.timestamp
.cmp(&b.timestamp)
.then(a.ordinal.cmp(&b.ordinal))
});
messages
}
#[cfg(test)]
mod tests {
use super::*;
use crate::message::{decode_frame, encode_message};
use crate::protobuf::{
CMsgProtoBufHeader, c_friend_messages_get_recent_messages_response::FriendMessage,
};
use prost::Message;
const SELF_STEAMID: u64 = 76561198000000001;
const PARTNER_STEAMID: u64 = 76561198000000002;
fn incoming_packet(
emsg: EMsg,
job: &str,
notification: &CFriendMessagesIncomingMessageNotification,
) -> Packet {
let header = CMsgProtoBufHeader {
target_job_name: Some(job.to_owned()),
..Default::default()
};
let encoded = encode_message(emsg, &header, notification).unwrap();
decode_frame(&encoded)
.unwrap()
.into_iter()
.next()
.expect("one packet")
}
#[test]
fn send_request_roundtrips() {
let request = CFriendMessagesSendMessageRequest {
steamid: Some(PARTNER_STEAMID),
chat_entry_type: Some(CHAT_ENTRY_TEXT),
message: Some("hi there".to_owned()),
contains_bbcode: Some(false),
..Default::default()
};
let bytes = request.encode_to_vec();
let back = CFriendMessagesSendMessageRequest::decode(bytes.as_slice()).unwrap();
assert_eq!(back.steamid, Some(PARTNER_STEAMID));
assert_eq!(back.message.as_deref(), Some("hi there"));
assert_eq!(back.chat_entry_type, Some(CHAT_ENTRY_TEXT));
}
#[test]
fn sent_message_uses_server_stamp_and_modified_text() {
let response = CFriendMessagesSendMessageResponse {
modified_message: Some("hi <there>".to_owned()),
server_timestamp: Some(1717),
ordinal: Some(3),
..Default::default()
};
let sent = sent_message(PARTNER_STEAMID, "hi <there>".to_owned(), response);
assert_eq!(sent.steamid, PARTNER_STEAMID);
assert_eq!(sent.message, "hi <there>");
assert_eq!(sent.timestamp, 1717);
assert_eq!(sent.ordinal, 3);
assert!(sent.from_local);
}
#[test]
fn sent_message_falls_back_to_original_when_unmodified() {
let response = CFriendMessagesSendMessageResponse {
server_timestamp: Some(42),
ordinal: Some(0),
..Default::default()
};
let sent = sent_message(PARTNER_STEAMID, "plain".to_owned(), response);
assert_eq!(sent.message, "plain");
assert_eq!(sent.timestamp, 42);
assert!(sent.from_local);
}
#[test]
fn decodes_incoming_text_message() {
let notification = CFriendMessagesIncomingMessageNotification {
steamid_friend: Some(PARTNER_STEAMID),
chat_entry_type: Some(CHAT_ENTRY_TEXT),
message: Some("hello".to_owned()),
rtime32_server_timestamp: Some(1000),
ordinal: Some(2),
local_echo: Some(false),
..Default::default()
};
let packet = incoming_packet(EMsg::ServiceMethod, INCOMING_MESSAGE_JOB, ¬ification);
match decode_incoming(&packet) {
Some(FriendsEvent::IncomingMessage(m)) => {
assert_eq!(m.steamid, PARTNER_STEAMID);
assert_eq!(m.message, "hello");
assert_eq!(m.timestamp, 1000);
assert_eq!(m.ordinal, 2);
assert!(!m.from_local);
}
other => panic!("expected IncomingMessage, got {other:?}"),
}
}
#[test]
fn decodes_incoming_text_message_via_send_to_client() {
let notification = CFriendMessagesIncomingMessageNotification {
steamid_friend: Some(PARTNER_STEAMID),
chat_entry_type: Some(CHAT_ENTRY_TEXT),
message: Some("hello".to_owned()),
rtime32_server_timestamp: Some(1000),
ordinal: Some(2),
local_echo: Some(false),
..Default::default()
};
let packet = incoming_packet(
EMsg::ServiceMethodSendToClient,
INCOMING_MESSAGE_JOB,
¬ification,
);
assert!(matches!(
decode_incoming(&packet),
Some(FriendsEvent::IncomingMessage(_))
));
}
#[test]
fn decodes_incoming_typing() {
let notification = CFriendMessagesIncomingMessageNotification {
steamid_friend: Some(PARTNER_STEAMID),
chat_entry_type: Some(CHAT_ENTRY_TYPING),
..Default::default()
};
let packet = incoming_packet(EMsg::ServiceMethod, INCOMING_MESSAGE_JOB, ¬ification);
match decode_incoming(&packet) {
Some(FriendsEvent::TypingNotification { steamid }) => {
assert_eq!(steamid, PARTNER_STEAMID)
}
other => panic!("expected TypingNotification, got {other:?}"),
}
}
#[test]
fn ignores_unrelated_push_job() {
let notification = CFriendMessagesIncomingMessageNotification {
steamid_friend: Some(PARTNER_STEAMID),
chat_entry_type: Some(CHAT_ENTRY_TEXT),
message: Some("from a group".to_owned()),
..Default::default()
};
let packet = incoming_packet(
EMsg::ServiceMethod,
"ChatRoomClient.NotifyIncomingChatMessage#1",
¬ification,
);
assert!(decode_incoming(&packet).is_none());
}
#[test]
fn recent_messages_marks_local_author_and_sorts_oldest_first() {
let self_account = (SELF_STEAMID & 0xFFFF_FFFF) as u32;
let response = CFriendMessagesGetRecentMessagesResponse {
messages: vec![
FriendMessage {
accountid: Some(self_account),
timestamp: Some(200),
message: Some("my reply".to_owned()),
ordinal: Some(0),
..Default::default()
},
FriendMessage {
accountid: Some(0xDEAD_BEEF),
timestamp: Some(100),
message: Some("their hello".to_owned()),
ordinal: Some(0),
..Default::default()
},
],
..Default::default()
};
let messages = recent_to_messages(response, PARTNER_STEAMID, Some(SELF_STEAMID));
assert_eq!(messages.len(), 2);
assert_eq!(messages[0].message, "their hello");
assert!(!messages[0].from_local);
assert_eq!(messages[0].steamid, PARTNER_STEAMID);
assert_eq!(messages[1].message, "my reply");
assert!(messages[1].from_local);
}
}