initial version

This commit is contained in:
Robin Appelman 2025-09-09 02:15:07 +02:00
commit 5710d6858e
5 changed files with 394 additions and 3 deletions

127
Cargo.lock generated
View file

@ -48,6 +48,56 @@ dependencies = [
"sha-1", "sha-1",
] ]
[[package]]
name = "anstream"
version = "0.6.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ae563653d1938f79b1ab1b5e668c87c76a9930414574a6583a7b7e11a8e6192"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd"
[[package]]
name = "anstyle-parse"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2"
dependencies = [
"windows-sys 0.60.2",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.60.2",
]
[[package]] [[package]]
name = "array-init" name = "array-init"
version = "2.1.0" version = "2.1.0"
@ -310,6 +360,46 @@ dependencies = [
"libloading", "libloading",
] ]
[[package]]
name = "clap"
version = "4.5.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7eac00902d9d136acd712710d71823fb8ac8004ca445a89e73a41d45aa712931"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.5.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ad9bbf750e73b5884fb8a211a9424a1906c1e156724260fdae972f31d70e1d6"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.5.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbfd7eae0b0f1a6e63d4b13c9c478de77c2eb546fba158ad50b4203dc24b9f9c"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "clap_lex"
version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675"
[[package]] [[package]]
name = "cmake" name = "cmake"
version = "0.1.54" version = "0.1.54"
@ -319,6 +409,12 @@ dependencies = [
"cc", "cc",
] ]
[[package]]
name = "colorchoice"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
[[package]] [[package]]
name = "const-oid" name = "const-oid"
version = "0.9.6" version = "0.9.6"
@ -628,6 +724,12 @@ version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]] [[package]]
name = "hmac" name = "hmac"
version = "0.12.1" version = "0.12.1"
@ -902,6 +1004,12 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf"
[[package]] [[package]]
name = "itertools" name = "itertools"
version = "0.12.1" version = "0.12.1"
@ -1154,6 +1262,12 @@ version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "once_cell_polyfill"
version = "1.70.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad"
[[package]] [[package]]
name = "option-ext" name = "option-ext"
version = "0.2.0" version = "0.2.0"
@ -1810,6 +1924,7 @@ dependencies = [
name = "steam-vent-chat" name = "steam-vent-chat"
version = "0.4.0" version = "0.4.0"
dependencies = [ dependencies = [
"clap",
"steam-vent", "steam-vent",
"steamid-ng", "steamid-ng",
"tokio", "tokio",
@ -1869,6 +1984,12 @@ version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b23da1d8a59091a1e8805d1355f74181f8e13fb7b446c2de17b9fd1bacce6058" checksum = "b23da1d8a59091a1e8805d1355f74181f8e13fb7b446c2de17b9fd1bacce6058"
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]] [[package]]
name = "subtle" name = "subtle"
version = "2.6.1" version = "2.6.1"
@ -2246,6 +2367,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]] [[package]]
name = "valuable" name = "valuable"
version = "0.1.1" version = "0.1.1"

View file

@ -3,7 +3,7 @@ name = "steam-vent-chat"
version = "0.4.0" version = "0.4.0"
authors = ["Robin Appelman <robin@icewind.nl>"] authors = ["Robin Appelman <robin@icewind.nl>"]
edition = "2024" edition = "2024"
description = "Steam chat client" description = "Steam chat client library"
license = "MIT" license = "MIT"
repository = "https://codeberg.org/steam-vent/chat" repository = "https://codeberg.org/steam-vent/chat"
rust-version = "1.85.0" rust-version = "1.85.0"
@ -11,8 +11,9 @@ rust-version = "1.85.0"
[dependencies] [dependencies]
steam-vent = "0.4.0" steam-vent = "0.4.0"
steamid-ng = "2.0.0" steamid-ng = "2.0.0"
tokio-stream = "0.1.17"
[dev-dependencies] [dev-dependencies]
tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread"] } tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread"] }
tokio-stream = "0.1.17"
tracing-subscriber = "0.3.20" tracing-subscriber = "0.3.20"
clap = { version = "4.5.47", features = ["derive"] }

30
README.md Normal file
View file

@ -0,0 +1,30 @@
# steam-vent-chat
Steam chat client library.
A high-level companion library for
[steam-vent](https://codeberg.org/steam-vent/steam-vent) for sending and
receiving steam chat messages.
## Usage
```rust
let friend_to_bother: steamid_ng::SteamID = get_steam_id();
let connection: steam_vent::Connection = get_steam_vent_connection();
let chat = ChatClient::new(connection);
chat.send_message(friend_to_bother, "Hey!".into()).await?;
let mut events = chat.listen();
while let Some(Ok(event)) = events.next().await {
match event {
ChatEvent::Typing(event) => println!("{} is tying...", event.source.steam64()),
ChatEvent::Message(event) => println!("{}: {}", event.source.steam64(), event.message_no_bbcode.unwrap_or(event.message)),
ChatEvent::EchoMessage(event) => println!("me: {}", event.message_no_bbcode.unwrap_or(event.message)),
}
}
```
See `examples/chat.rs` for a more complete example or
[steam-vent](https://codeberg.org/steam-vent/steam-vent) for more details about
getting a connection.

81
examples/chat.rs Normal file
View file

@ -0,0 +1,81 @@
use clap::Parser;
use std::error::Error;
use std::io::stdin;
use std::str::FromStr;
use steam_vent::auth::{
AuthConfirmationHandler, ConsoleAuthConfirmationHandler, DeviceConfirmationHandler,
FileGuardDataStore, SharedSecretAuthConfirmationHandler,
};
use steam_vent::{Connection, ServerList};
use steam_vent_chat::{ChatClient, ChatEvent};
use steamid_ng::SteamID;
use tokio::spawn;
use tokio_stream::StreamExt;
#[derive(Parser)]
pub struct Args {
/// Username to log in with
username: String,
/// Password to log in with
password: String,
/// User to chat with
target: String,
/// base64 encoded steam-guard secret
#[arg(long)]
guard_secret: Option<String>,
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
tracing_subscriber::fmt::init();
let args: Args = Args::parse();
let target_steam_id = SteamID::from_str(args.target.as_str()).expect("invalid steam id");
let server_list = ServerList::discover().await?;
let connection = match args.guard_secret {
Some(secret) => {
Connection::login(
&server_list,
&args.username,
&args.password,
FileGuardDataStore::user_cache(),
SharedSecretAuthConfirmationHandler::new(&secret),
)
.await?
}
None => {
Connection::login(
&server_list,
&args.username,
&args.password,
FileGuardDataStore::user_cache(),
ConsoleAuthConfirmationHandler::default().or(DeviceConfirmationHandler),
)
.await?
}
};
let chat = ChatClient::new(connection);
let mut events = chat.listen();
spawn(async move {
while let Some(Ok(event)) = events.next().await {
match event {
ChatEvent::Typing(event) => println!("{} is tying...", event.source.steam64()),
ChatEvent::Message(event) => println!("{}: {}", event.source.steam64(), event.message_no_bbcode.unwrap_or(event.message)),
ChatEvent::EchoMessage(event) => println!("me: {}", event.message_no_bbcode.unwrap_or(event.message)),
}
}
});
let mut read_buff = String::with_capacity(32);
loop {
read_buff.clear();
stdin().read_line(&mut read_buff).expect("stdin error");
let input = read_buff.trim();
if !input.is_empty() {
chat.send_message(target_steam_id, input.into()).await?;
}
}
}

View file

@ -0,0 +1,152 @@
use steam_vent::proto::steammessages_friendmessages_steamclient::{CFriendMessages_IncomingMessage_Notification, CFriendMessages_SendMessage_Request};
use steam_vent::{Connection, ConnectionTrait, NetworkError};
use steamid_ng::SteamID;
use tokio_stream::{Stream, StreamExt};
/// High level wrapper around steam-vent's [`Connection`] for implementing a chat client
pub struct ChatClient {
connection: Connection,
}
impl ChatClient {
/// Create a chat client from a [`Connection`]
pub fn new(connection: Connection) -> Self {
ChatClient { connection }
}
/// Listen for incoming events
pub fn listen(&self) -> impl Stream<Item = Result<ChatEvent, NetworkError>> + 'static {
self.connection
.on_notification::<CFriendMessages_IncomingMessage_Notification>()
.filter_map(|notification| {
notification
.map(|notification| ChatEvent::try_from(notification).ok())
.transpose()
})
}
/// Send a chat message to a user
pub async fn send_message(&self, target: SteamID, message: String) -> Result<MessageResult, NetworkError> {
let req = CFriendMessages_SendMessage_Request {
steamid: Some(target.into()),
message: Some(message),
chat_entry_type: Some(MessageType::Chat as i32),
..CFriendMessages_SendMessage_Request::default()
};
let result = self.connection.service_method(req).await?;
Ok(MessageResult {
server_timestamp: result.server_timestamp() as u64,
modified_message: result.modified_message,
})
}
}
/// Incoming chat event
#[derive(Debug)]
pub enum ChatEvent {
/// Another user sent a message
Message(MessageEvent),
/// The local user sent a message from another device
EchoMessage(MessageEvent),
/// Another user is typing
Typing(TypingEvent),
}
impl TryFrom<CFriendMessages_IncomingMessage_Notification> for ChatEvent {
type Error = ();
fn try_from(
notification: CFriendMessages_IncomingMessage_Notification,
) -> Result<Self, Self::Error> {
let source = SteamID::try_from(notification.steamid_friend()).unwrap_or_default();
let message_type =
MessageType::try_from(notification.chat_entry_type()).unwrap_or_default();
Ok(match message_type {
MessageType::Chat if notification.local_echo() => ChatEvent::Message(MessageEvent {
source,
server_timestamp: notification.rtime32_server_timestamp() as u64,
message: notification.message.unwrap_or_default(),
message_no_bbcode: notification.message_no_bbcode,
}),
MessageType::Chat if notification.local_echo() => ChatEvent::EchoMessage(MessageEvent {
source,
server_timestamp: notification.rtime32_server_timestamp() as u64,
message: notification.message.unwrap_or_default(),
message_no_bbcode: notification.message_no_bbcode,
}),
MessageType::Typing => ChatEvent::Typing(TypingEvent {
source,
server_timestamp: notification.rtime32_server_timestamp() as u64,
}),
_ => return Err(()),
})
}
}
/// Incoming chat message
#[derive(Debug)]
pub struct MessageEvent {
/// SteamID of the sender
pub source: SteamID,
/// Raw message contents
pub message: String,
/// Message contents without any bbcode markup
pub message_no_bbcode: Option<String>,
/// Service side time when the message was sent
pub server_timestamp: u64,
}
/// Incoming typing event
#[derive(Debug)]
pub struct TypingEvent {
/// SteamID of the typer
pub source: SteamID,
/// Service side time when the user was typing
pub server_timestamp: u64,
}
#[repr(i32)]
#[derive(Default)]
enum MessageType {
#[default]
Invalid,
Chat = 1,
Typing,
GameInvite,
Left = 6,
Entered,
Kicked,
Banned,
Disconnected,
Historical,
LinkBlocked = 14,
}
impl TryFrom<i32> for MessageType {
type Error = ();
fn try_from(value: i32) -> Result<Self, Self::Error> {
Ok(match value {
1 => MessageType::Chat,
2 => MessageType::Typing,
3 => MessageType::GameInvite,
6 => MessageType::Left,
7 => MessageType::Entered,
8 => MessageType::Kicked,
9 => MessageType::Banned,
10 => MessageType::Disconnected,
11 => MessageType::Historical,
14 => MessageType::LinkBlocked,
_ => return Err(()),
})
}
}
/// Result of a sent chat message
#[derive(Debug)]
pub struct MessageResult {
/// Chat message with bbcode added
pub modified_message: Option<String>,
pub server_timestamp: u64,
}