feat(protocol): interests, tickers, global room and cant-connect

Adds the server messages the client had no way to speak: user and room
search (42/120), room tickers (113-116), the global room feed (150-152),
interests and the recommendation queries built on them (51/52/54/56/57/
110/111/112/117/118), a keepalive ping (32), gifted privileges (123), a
password change (142) and multi-user private messages (149).

CantConnectToPeer (1001) closes a real gap in both directions: a dial the
server asked us to make now answers the waiting peer instead of falling
back to brokering the same unreachable link the other way, and an incoming
one ends the wait on downloads queued for that peer rather than letting
them sit until a timeout.
This commit is contained in:
Michel de Graaf
2026-09-09 16:19:40 +00:00
parent 374a81bec1
commit 886f1b7092
18 changed files with 1886 additions and 18 deletions
@@ -301,6 +301,175 @@ impl MessageFactory {
.clone()
}
/// A keepalive ping (server code 32, no body). The server sends no reply;
/// its only job is to keep a quiet connection from being reaped by a NAT
/// or the server's own idle timeout, the way other clients ping.
#[must_use]
pub fn build_server_ping() -> Message {
Message::new().write_int32(32).clone()
}
/// Search one user's shares (server code 42). Replies come back over a
/// peer connection as ordinary `FileSearchResponse` frames, so the token
/// is drawn from the same space as a plain `FileSearch`.
#[must_use]
pub fn build_user_search(
username: &str,
token: u32,
query: &str,
) -> Message {
Message::new()
.write_int32(42)
.write_string(username)
.write_int32(token)
.write_string(query)
.clone()
}
/// Search the shares of everyone in `room` (server code 120).
#[must_use]
pub fn build_room_search(room: &str, token: u32, query: &str) -> Message {
Message::new()
.write_int32(120)
.write_string(room)
.write_int32(token)
.write_string(query)
.clone()
}
/// Tell the server (code 1001) that the peer it asked us to connect to,
/// quoting `token`, could not be reached. The server relays that to the
/// peer so it stops waiting on a connection that is never coming.
#[must_use]
pub fn build_cant_connect_to_peer(token: u32, username: &str) -> Message {
Message::new()
.write_int32(1001)
.write_int32(token)
.write_string(username)
.clone()
}
/// Set our ticker (the scrolling one-line message) in `room`, server code
/// 116. An empty ticker removes ours.
#[must_use]
pub fn build_set_room_ticker(room: &str, ticker: &str) -> Message {
Message::new()
.write_int32(116)
.write_string(room)
.write_string(ticker)
.clone()
}
/// Subscribe to the global room feed (server code 150): every public room
/// message on the server, tagged with the room it came from.
#[must_use]
pub fn build_join_global_room() -> Message {
Message::new().write_int32(150).clone()
}
/// Stop the global room feed (server code 151).
#[must_use]
pub fn build_leave_global_room() -> Message {
Message::new().write_int32(151).clone()
}
/// Add an interest (server code 51). Interests drive the recommendation
/// and similar-user queries below.
#[must_use]
pub fn build_add_thing_i_like(item: &str) -> Message {
Message::new().write_int32(51).write_string(item).clone()
}
/// Drop an interest (server code 52).
#[must_use]
pub fn build_remove_thing_i_like(item: &str) -> Message {
Message::new().write_int32(52).write_string(item).clone()
}
/// Add a dislike (server code 117).
#[must_use]
pub fn build_add_thing_i_hate(item: &str) -> Message {
Message::new().write_int32(117).write_string(item).clone()
}
/// Drop a dislike (server code 118).
#[must_use]
pub fn build_remove_thing_i_hate(item: &str) -> Message {
Message::new().write_int32(118).write_string(item).clone()
}
/// Ask for recommendations based on our own interests (server code 54).
#[must_use]
pub fn build_get_recommendations() -> Message {
Message::new().write_int32(54).clone()
}
/// Ask for the server-wide recommendations (server code 56).
#[must_use]
pub fn build_global_recommendations() -> Message {
Message::new().write_int32(56).clone()
}
/// Ask what `username` likes and hates (server code 57).
#[must_use]
pub fn build_user_interests(username: &str) -> Message {
Message::new()
.write_int32(57)
.write_string(username)
.clone()
}
/// Ask for users whose interests overlap ours (server code 110).
#[must_use]
pub fn build_similar_users() -> Message {
Message::new().write_int32(110).clone()
}
/// Ask what else people who like `item` like (server code 111).
#[must_use]
pub fn build_item_recommendations(item: &str) -> Message {
Message::new().write_int32(111).write_string(item).clone()
}
/// Ask which users like `item` (server code 112).
#[must_use]
pub fn build_item_similar_users(item: &str) -> Message {
Message::new().write_int32(112).write_string(item).clone()
}
/// Hand `days` of our own privileges to `username` (server code 123).
#[must_use]
pub fn build_give_privileges(username: &str, days: u32) -> Message {
Message::new()
.write_int32(123)
.write_string(username)
.write_int32(days)
.clone()
}
/// Change our account password (server code 142). The server echoes the
/// accepted password back under the same code.
#[must_use]
pub fn build_change_password(password: &str) -> Message {
Message::new()
.write_int32(142)
.write_string(password)
.clone()
}
/// Send one private message to several users at once (server code 149).
#[must_use]
pub fn build_message_users(usernames: &[String], message: &str) -> Message {
let mut msg = Message::new();
msg.write_int32(149);
msg.write_int32(usernames.len() as u32);
for username in usernames {
msg.write_string(username);
}
msg.write_string(message);
msg
}
#[must_use]
pub fn build_peer_init_message(
own_username: &str,
@@ -517,3 +686,129 @@ fn an_upload_speed_report_carries_the_rate() {
decoded.set_pointer(8);
assert_eq!(decoded.read_int32(), 900);
}
#[test]
fn a_ping_is_just_its_code() {
let message = MessageFactory::build_server_ping();
assert_eq!(message.get_data(), [32, 0, 0, 0]);
}
#[test]
fn a_user_search_names_the_user_before_its_token() {
let message = MessageFactory::build_user_search("bob", 99, "jazz");
let mut decoded = Message::new_with_data(message.get_buffer());
assert_eq!(decoded.get_message_code(), 42);
decoded.set_pointer(8);
assert_eq!(decoded.read_string(), "bob");
assert_eq!(decoded.read_int32(), 99);
assert_eq!(decoded.read_string(), "jazz");
}
#[test]
fn a_room_search_names_the_room_before_its_token() {
let message = MessageFactory::build_room_search("nicotine", 7, "dub");
let mut decoded = Message::new_with_data(message.get_buffer());
assert_eq!(decoded.get_message_code(), 120);
decoded.set_pointer(8);
assert_eq!(decoded.read_string(), "nicotine");
assert_eq!(decoded.read_int32(), 7);
assert_eq!(decoded.read_string(), "dub");
}
#[test]
fn a_cant_connect_carries_the_token_then_the_user() {
let message = MessageFactory::build_cant_connect_to_peer(4242, "bob");
let mut decoded = Message::new_with_data(message.get_buffer());
assert_eq!(decoded.get_message_code(), 1001);
decoded.set_pointer(8);
assert_eq!(decoded.read_int32(), 4242);
assert_eq!(decoded.read_string(), "bob");
}
#[test]
fn a_room_ticker_carries_its_room_and_text() {
let message = MessageFactory::build_set_room_ticker("jazz", "hello");
let mut decoded = Message::new_with_data(message.get_buffer());
assert_eq!(decoded.get_message_code(), 116);
decoded.set_pointer(8);
assert_eq!(decoded.read_string(), "jazz");
assert_eq!(decoded.read_string(), "hello");
}
#[test]
fn the_global_room_is_joined_and_left_by_code_alone() {
assert_eq!(
MessageFactory::build_join_global_room().get_data(),
[150, 0, 0, 0]
);
assert_eq!(
MessageFactory::build_leave_global_room().get_data(),
[151, 0, 0, 0]
);
}
#[test]
fn each_interest_message_uses_its_own_code() {
for (message, code) in [
(MessageFactory::build_add_thing_i_like("jazz"), 51),
(MessageFactory::build_remove_thing_i_like("jazz"), 52),
(MessageFactory::build_add_thing_i_hate("polka"), 117),
(MessageFactory::build_remove_thing_i_hate("polka"), 118),
(MessageFactory::build_user_interests("bob"), 57),
(MessageFactory::build_item_recommendations("jazz"), 111),
(MessageFactory::build_item_similar_users("jazz"), 112),
] {
let mut decoded = Message::new_with_data(message.get_buffer());
assert_eq!(decoded.get_message_code(), code);
decoded.set_pointer(8);
assert!(!decoded.read_string().is_empty());
}
}
#[test]
fn the_bodyless_queries_are_just_their_codes() {
assert_eq!(
MessageFactory::build_get_recommendations().get_data(),
[54, 0, 0, 0]
);
assert_eq!(
MessageFactory::build_global_recommendations().get_data(),
[56, 0, 0, 0]
);
assert_eq!(
MessageFactory::build_similar_users().get_data(),
[110, 0, 0, 0]
);
}
#[test]
fn a_privilege_gift_carries_the_recipient_and_days() {
let message = MessageFactory::build_give_privileges("bob", 3);
let mut decoded = Message::new_with_data(message.get_buffer());
assert_eq!(decoded.get_message_code(), 123);
decoded.set_pointer(8);
assert_eq!(decoded.read_string(), "bob");
assert_eq!(decoded.read_int32(), 3);
}
#[test]
fn a_password_change_carries_the_new_password() {
let message = MessageFactory::build_change_password("hunter2");
let mut decoded = Message::new_with_data(message.get_buffer());
assert_eq!(decoded.get_message_code(), 142);
decoded.set_pointer(8);
assert_eq!(decoded.read_string(), "hunter2");
}
#[test]
fn a_multi_user_message_counts_its_recipients_first() {
let recipients = vec!["alice".to_string(), "bob".to_string()];
let message = MessageFactory::build_message_users(&recipients, "hi all");
let mut decoded = Message::new_with_data(message.get_buffer());
assert_eq!(decoded.get_message_code(), 149);
decoded.set_pointer(8);
assert_eq!(decoded.read_int32(), 2);
assert_eq!(decoded.read_string(), "alice");
assert_eq!(decoded.read_string(), "bob");
assert_eq!(decoded.read_string(), "hi all");
}