chore: restore product scaffold to rollback baseline

This commit is contained in:
Edison Jwa
2026-05-29 14:02:04 +09:00
parent 2896f14ec9
commit fe6e07353e
434 changed files with 27278 additions and 63230 deletions
+439 -98
View File
@@ -28,21 +28,44 @@ use tokio::sync::{mpsc, oneshot};
use tracing::{info, warn};
use tsclientlib::data::{self, Channel, Client};
use tsclientlib::messages::s2c::{InClientDbInfoPart, InMessage};
use tsclientlib::prelude::*;
use tsclientlib::{
ChannelId as TsChannelId, Connection, DisconnectOptions, Identity, MessageHandle,
OutCommandExt, StreamItem, Version,
ChannelId as TsChannelId, ClientId as TsClientId, Connection, DisconnectOptions, Identity,
MessageHandle, OutCommandExt, StreamItem, Version,
};
use tsproto_packets::packets::{InAudioBuf, OutPacket};
use tsproto_packets::packets::{Direction, Flags, InAudioBuf, OutCommand, OutPacket, PacketType};
use tsproto_types::ClientType;
use crate::dto::{
ChannelId, ChannelInfo, ChatMessage, ClientId, ClientInfo, MessageTarget, ServerActivity,
ServerSnapshot,
ChannelId, ChannelInfo, ChatMessage, ClientId, ClientInfo, ClientProfile, MessageTarget,
ServerActivity, ServerSnapshot,
};
use crate::ProtocolError;
const SPEAKING_ACTIVITY_WINDOW: Duration = Duration::from_millis(750);
const INBOUND_VOICE_SEND_TIMEOUT: Duration = Duration::from_millis(40);
#[derive(Debug, PartialEq, Eq)]
enum SendTimeoutError<T> {
Timeout(T),
Closed(T),
}
async fn send_with_timeout<T: Send>(
tx: &mpsc::Sender<T>,
value: T,
timeout_duration: Duration,
) -> Result<(), SendTimeoutError<T>> {
match tokio::time::timeout(timeout_duration, tx.reserve()).await {
Ok(Ok(permit)) => {
permit.send(value);
Ok(())
}
Ok(Err(_)) => Err(SendTimeoutError::Closed(value)),
Err(_) => Err(SendTimeoutError::Timeout(value)),
}
}
/// Pick the TeamSpeak `client_version`/platform/signature triple
/// (sourced from `ReSpeak/tsdeclarations/Versions.csv`, baked into
@@ -116,6 +139,11 @@ pub struct ConnectConfig {
/// How long to wait for the initial state snapshot before
/// returning `ProtocolError::Timeout`.
pub ready_timeout: Duration,
/// Optional already-resolved socket address from core's invisible
/// prefetch cache. When present, the protocol layer skips address
/// resolution but still opens a normal TS3 connection only after
/// the user requested Connect.
pub resolved_address: Option<std::net::SocketAddr>,
}
impl Default for ConnectConfig {
@@ -126,6 +154,7 @@ impl Default for ConnectConfig {
password: None,
identity: None,
ready_timeout: Duration::from_secs(10),
resolved_address: None,
}
}
}
@@ -159,6 +188,11 @@ enum Request {
/// Reply channel for outcome.
reply: oneshot::Sender<Result<(), ProtocolError>>,
},
/// Fetch richer profile/connection details for an online client.
FetchClientProfile {
client_id: u64,
reply: oneshot::Sender<Result<ClientProfile, ProtocolError>>,
},
}
/// Why a [`ProtocolClient`] task ended. Distinguishes a user-driven
@@ -297,6 +331,20 @@ impl ProtocolClient {
.map_err(|_| ProtocolError::Lost("snapshot reply dropped".to_string()))?
}
/// Fetch richer profile and live connection details for one online client.
pub async fn client_profile(&self, client_id: u64) -> Result<ClientProfile, ProtocolError> {
let (tx, rx) = oneshot::channel();
self.tx
.send(Request::FetchClientProfile {
client_id,
reply: tx,
})
.await
.map_err(|_| ProtocolError::Lost("connection task is gone".to_string()))?;
rx.await
.map_err(|_| ProtocolError::Lost("client_profile reply dropped".to_string()))?
}
/// Disconnect cleanly. Blocks until the task exits.
pub async fn disconnect(self) {
let (tx, rx) = oneshot::channel();
@@ -493,16 +541,30 @@ async fn connection_task(
}};
}
let resolved = match resolve_server_socket(&cfg.address).await {
Ok(addr) => addr,
Err(err) => fail_ready!(err),
let resolved = match server_socket_from_config(&cfg) {
Some(addr) => {
info!(
target: "chanora_protocol",
input = %cfg.address,
resolved = %addr,
"using prefetched server address"
);
addr
}
None => {
let addr = match resolve_server_socket(&cfg.address).await {
Ok(addr) => addr,
Err(err) => fail_ready!(err),
};
info!(
target: "chanora_protocol",
input = %cfg.address,
resolved = %addr,
"server address resolved"
);
addr
}
};
info!(
target: "chanora_protocol",
input = %cfg.address,
resolved = %resolved,
"server address resolved"
);
// Pass the resolved SocketAddr directly to tsclientlib so it
// skips its own resolver entirely (tsclientlib accepts
@@ -619,14 +681,6 @@ async fn connection_task(
std::time::Instant,
),
> = HashMap::new();
let mut pending_text_messages: HashMap<
MessageHandle,
(
MessageTarget,
oneshot::Sender<Result<(), ProtocolError>>,
std::time::Instant,
),
> = HashMap::new();
let mut voice_activity: HashMap<u64, Instant> = HashMap::new();
// Main loop: pump events, service requests, forward voice.
@@ -650,14 +704,33 @@ async fn connection_task(
let from = packet_sender_id(&buf);
if let Some(from) = from {
voice_activity.insert(from, Instant::now());
if voice_in_tx
.try_send(InboundVoice {
from_client: from,
packet: buf,
})
.is_err()
let inbound = InboundVoice {
from_client: from,
packet: buf,
};
match send_with_timeout(
&voice_in_tx,
inbound,
INBOUND_VOICE_SEND_TIMEOUT,
)
.await
{
// Subscriber is too slow or absent; drop.
Ok(()) => {}
Err(SendTimeoutError::Timeout(_)) => {
warn!(
target: "chanora_protocol",
from_client = from,
timeout_ms = INBOUND_VOICE_SEND_TIMEOUT.as_millis() as u64,
"inbound voice queue stayed full; dropping packet"
);
}
Err(SendTimeoutError::Closed(_)) => {
warn!(
target: "chanora_protocol",
from_client = from,
"inbound voice consumer closed; dropping packet"
);
}
}
}
}
@@ -735,7 +808,28 @@ async fn connection_task(
if let Some((_target_channel, reply, _deadline)) =
pending_moves.remove(&handle)
{
let mapped = map_command_result(result, "client_move");
let mapped = match result {
Ok(()) => Ok(()),
Err(cmd_err) => {
// tsclientlib's CommandError carries a
// typed `TsError` (the canonical TS3
// error code) plus an optional missing
// permission. We convert to our typed
// ProtocolError::ServerRejected so the
// upper layers can render a localised
// explanation by code instead of a
// generic backend string.
let code = cmd_err.error as u32;
let message = cmd_err.error.to_string();
info!(
target: "chanora_protocol",
code,
message = %message,
"server rejected client_move"
);
Err(ProtocolError::ServerRejected { code, message })
}
};
if let Some(reply) = reply {
let _ = reply.send(mapped);
} else if let Err(err) = mapped {
@@ -745,11 +839,6 @@ async fn connection_task(
"client_move completed in background with error"
);
}
} else if let Some((_target, reply, _deadline)) =
pending_text_messages.remove(&handle)
{
let mapped = map_command_result(result, "text_message");
let _ = reply.send(mapped);
}
}
_ => { /* book / message / other events: ignore */ }
@@ -794,24 +883,6 @@ async fn connection_task(
}
}
}
if !pending_text_messages.is_empty() {
let now = std::time::Instant::now();
let expired: Vec<MessageHandle> = pending_text_messages
.iter()
.filter_map(|(handle, (_, _, deadline))| {
if now >= *deadline {
Some(*handle)
} else {
None
}
})
.collect();
for handle in expired {
if let Some((_target, reply, _)) = pending_text_messages.remove(&handle) {
let _ = reply.send(Err(ProtocolError::Timeout));
}
}
}
// 3. Service at most one control request (non-blocking).
match rx.try_recv() {
@@ -861,15 +932,14 @@ async fn connection_task(
message,
target,
reply,
}) => match send_text_message(&mut con, &message, target) {
Ok(handle) => {
let deadline = std::time::Instant::now() + Duration::from_secs(3);
pending_text_messages.insert(handle, (target, reply, deadline));
}
Err(e) => {
let _ = reply.send(Err(e));
}
},
}) => {
let r = send_text_message(&mut con, &message, target);
let _ = reply.send(r);
}
Ok(Request::FetchClientProfile { client_id, reply }) => {
let r = fetch_client_profile(&mut con, client_id).await;
let _ = reply.send(r);
}
Ok(Request::Disconnect(reply)) => {
let _ = con.disconnect(DisconnectOptions::new());
con.events().for_each(|_| future::ready(())).await;
@@ -908,6 +978,10 @@ async fn resolve_server_socket(address: &str) -> Result<SocketAddr, ProtocolErro
})
}
fn server_socket_from_config(cfg: &ConnectConfig) -> Option<SocketAddr> {
cfg.resolved_address
}
/// Move our own client into `channel_id` with an optional password.
/// Looks up our `own_client` in the current state and dispatches the
/// generated `client_move` command via `send_with_result`. The
@@ -973,11 +1047,11 @@ fn send_text_message(
con: &mut Connection,
message: &str,
target: MessageTarget,
) -> Result<MessageHandle, ProtocolError> {
) -> Result<(), ProtocolError> {
use tsproto_types::TextMessageTargetMode;
match target {
MessageTarget::Server => {
send_text_to_mode(con, message, TextMessageTargetMode::Server, "server")
send_text_to_mode(con, message, TextMessageTargetMode::Server, "server")?;
}
MessageTarget::Channel => {
// Fix: previously channel messages were sent via
@@ -985,33 +1059,31 @@ fn send_text_message(
// TextMessageTargetMode::Server. Now correctly uses
// TextMessageTargetMode::Channel so the message is
// scoped to the current channel, not server-wide.
send_text_to_mode(con, message, TextMessageTargetMode::Channel, "channel")
send_text_to_mode(con, message, TextMessageTargetMode::Channel, "channel")?;
}
MessageTarget::Client(client_id) => {
let state = con
.get_state()
.map_err(|e| ProtocolError::Backend(format!("get_state: {e}")))?;
let client = find_client_by_id(state.clients.values(), client_id)?;
let handle = client
client
.send_textmessage(message)
.send_with_result(con)
.send(con)
.map_err(|e| ProtocolError::Backend(format!("send_textmessage(client): {e}")))?;
info!(target: "chanora_protocol", len = message.len(), ?target, "text message queued");
Ok(handle)
}
MessageTarget::Poke(client_id) => {
let state = con
.get_state()
.map_err(|e| ProtocolError::Backend(format!("get_state: {e}")))?;
let client = find_client_by_id(state.clients.values(), client_id)?;
let handle = client
client
.poke(message)
.send_with_result(con)
.send(con)
.map_err(|e| ProtocolError::Backend(format!("poke: {e}")))?;
info!(target: "chanora_protocol", len = message.len(), ?target, "text message queued");
Ok(handle)
}
}
info!(target: "chanora_protocol", len = message.len(), ?target, "text message sent");
Ok(())
}
fn send_text_to_mode(
@@ -1019,7 +1091,7 @@ fn send_text_to_mode(
message: &str,
target: tsproto_types::TextMessageTargetMode,
label: &str,
) -> Result<MessageHandle, ProtocolError> {
) -> Result<(), ProtocolError> {
use ts_bookkeeping::messages::c2s;
c2s::OutSendTextMessageMessage::new(&mut std::iter::once(c2s::OutSendTextMessagePart {
@@ -1027,31 +1099,242 @@ fn send_text_to_mode(
target_client_id: None,
message: message.into(),
}))
.send_with_result(con)
.send(con)
.map_err(|e| ProtocolError::Backend(format!("send_textmessage({label}): {e}")))
}
fn map_command_result(
result: Result<(), tsclientlib::CommandError>,
action: &str,
) -> Result<(), ProtocolError> {
match result {
Ok(()) => Ok(()),
Err(cmd_err) => {
let code = cmd_err.error as u32;
let message = cmd_err.error.to_string();
info!(
target: "chanora_protocol",
action,
code,
message = %message,
"server rejected command"
);
Err(ProtocolError::ServerRejected { code, message })
async fn fetch_client_profile(
con: &mut Connection,
client_id: u64,
) -> Result<ClientProfile, ProtocolError> {
let target_id = TsClientId(client_id as u16);
let (database_id, uid_b64) = {
let state = con
.get_state()
.map_err(|e| ProtocolError::Backend(format!("get_state: {e}")))?;
let client = find_client_by_id(state.clients.values(), client_id)?;
(
client.database_id,
client.uid.as_ref().map(|uid| uid_to_b64(uid.as_ref())),
)
};
let _ = request_messages(con, build_command("servergrouplist", &[], &[])).await;
let _ = request_messages(con, build_command("channelgrouplist", &[], &[])).await;
let _ = request_messages(
con,
build_command(
"clientgetvariables",
&[("clid", client_id.to_string())],
&[],
),
)
.await;
let _ = request_messages(
con,
build_command("getconnectioninfo", &[("clid", client_id.to_string())], &[]),
)
.await;
let db_info = request_client_db_info(con, database_id).await.ok();
let state = con
.get_state()
.map_err(|e| ProtocolError::Backend(format!("get_state: {e}")))?;
let client = state
.clients
.get(&target_id)
.ok_or_else(|| ProtocolError::Backend(format!("client {client_id} not found")))?;
let optional = client.optional_data.as_ref();
let connection = client.connection_data.as_ref();
let server_group_names: HashMap<_, _> = state
.server_groups
.iter()
.map(|(id, group)| (*id, group.name.clone()))
.collect();
let channel_group_names: HashMap<_, _> = state
.channel_groups
.iter()
.map(|(id, group)| (*id, group.name.clone()))
.collect();
let unique_id = db_info
.as_ref()
.map(|info| uid_to_b64(info.uid.as_ref()))
.or(uid_b64)
.unwrap_or_default();
let avatar_path = if client.avatar_hash.is_empty() || unique_id.is_empty() {
String::new()
} else {
format!("/avatar_{}", uid_to_avatar_path(&unique_id))
};
Ok(ClientProfile {
id: ClientId(client.id.0 as u64),
channel: ChannelId(client.channel.0),
name: db_info
.as_ref()
.map(|info| sanitize(&info.name))
.unwrap_or_else(|| sanitize(&client.name)),
unique_id,
database_id: Some(client.database_id.0),
country_code: client.country_code.clone(),
description: db_info
.as_ref()
.map(|info| sanitize(&info.description))
.unwrap_or_else(|| sanitize(&client.description)),
version: optional
.map(|info| info.version.clone())
.unwrap_or_default(),
platform: optional
.map(|info| info.platform.clone())
.unwrap_or_default(),
created_unix_seconds: optional
.map(|info| info.created.unix_timestamp())
.or_else(|| db_info.as_ref().map(|info| info.created.unix_timestamp())),
last_connected_unix_seconds: optional
.map(|info| info.last_connected.unix_timestamp())
.or_else(|| {
db_info
.as_ref()
.map(|info| info.last_connected.unix_timestamp())
}),
connections_total: optional
.map(|info| u64::from(info.connections_total))
.or_else(|| {
db_info
.as_ref()
.map(|info| u64::from(info.connections_total))
}),
online_seconds: connection
.and_then(|info| info.connected_time.map(|duration| duration.whole_seconds())),
idle_milliseconds: connection.map(|info| duration_millis(info.idle_time)),
ping_milliseconds: connection.and_then(|info| info.ping.map(duration_millis)),
client_address: connection
.and_then(|info| info.client_address.map(|address| address.to_string()))
.unwrap_or_default(),
server_groups: format_server_groups(client.server_groups.iter(), &server_group_names),
channel_group: channel_group_names
.get(&client.channel_group)
.cloned()
.unwrap_or_else(|| format!("Unknown ({})", client.channel_group.0)),
avatar_path,
bytes_downloaded_month: optional
.map(|info| info.bytes_downloaded_month)
.or_else(|| db_info.as_ref().map(|info| info.bytes_downloaded_month)),
bytes_uploaded_month: optional
.map(|info| info.bytes_uploaded_month)
.or_else(|| db_info.as_ref().map(|info| info.bytes_uploaded_month)),
bytes_downloaded_total: optional
.map(|info| info.bytes_downloaded_total)
.or_else(|| db_info.as_ref().map(|info| info.bytes_downloaded_total)),
bytes_uploaded_total: optional
.map(|info| info.bytes_uploaded_total)
.or_else(|| db_info.as_ref().map(|info| info.bytes_uploaded_total)),
packet_loss_client_to_server_total: connection
.map(|info| info.client_to_server_packetloss_total),
packet_loss_server_to_client_total: connection
.and_then(|info| info.server_to_client_packetloss_total),
})
}
fn build_command(name: &str, args: &[(&str, String)], flags: &[&str]) -> OutCommand {
let mut command = OutCommand::new(Direction::C2S, Flags::empty(), PacketType::Command, name);
for flag in flags {
command.write_arg(flag, &"");
}
for (key, value) in args {
command.write_arg(key, value);
}
command
}
async fn request_messages(
con: &mut Connection,
command: OutCommand,
) -> Result<Vec<InMessage>, ProtocolError> {
let handle = command
.send_with_result(con)
.map_err(|e| ProtocolError::Backend(format!("send command: {e}")))?;
let mut messages = Vec::new();
loop {
let item = con
.events()
.next()
.await
.ok_or_else(|| ProtocolError::Lost("event stream ended".to_string()))?
.map_err(|e| ProtocolError::Lost(e.to_string()))?;
match item {
StreamItem::MessageEvent(message) => messages.push(message),
StreamItem::MessageResult(reply, status) if reply == handle => {
status.map_err(|error| ProtocolError::ServerRejected {
code: error.error as u32,
message: error.error.to_string(),
})?;
return Ok(messages);
}
_ => {}
}
}
}
async fn request_client_db_info(
con: &mut Connection,
dbid: tsclientlib::ClientDbId,
) -> Result<InClientDbInfoPart, ProtocolError> {
let messages = request_messages(
con,
build_command("clientdbinfo", &[("cldbid", dbid.0.to_string())], &[]),
)
.await?;
for message in messages {
if let InMessage::ClientDbInfo(info) = message {
if let Some(row) = info.iter().next() {
return Ok(row.clone());
}
}
}
Err(ProtocolError::Backend(
"clientdbinfo returned no row".to_string(),
))
}
fn format_server_groups<'a>(
groups: impl IntoIterator<Item = &'a tsclientlib::ServerGroupId>,
names: &HashMap<tsclientlib::ServerGroupId, String>,
) -> Vec<String> {
let mut rendered = groups
.into_iter()
.map(|group| {
names
.get(group)
.cloned()
.unwrap_or_else(|| format!("Unknown ({})", group.0))
})
.collect::<Vec<_>>();
rendered.sort();
rendered
}
fn duration_millis(duration: time::Duration) -> i64 {
duration
.whole_milliseconds()
.clamp(i64::MIN as i128, i64::MAX as i128) as i64
}
fn uid_to_b64(uid: &tsclientlib::Uid) -> String {
BASE64_STANDARD.encode(&uid.0)
}
fn uid_to_avatar_path(uid_b64: &str) -> String {
let decoded = BASE64_STANDARD.decode(uid_b64).unwrap_or_default();
let mut rendered = String::with_capacity(decoded.len() * 2);
for byte in decoded {
rendered.push((b'a' + (byte >> 4)) as char);
rendered.push((b'a' + (byte & 0x0f)) as char);
}
rendered
}
fn find_client_by_id<'a>(
clients: impl IntoIterator<Item = &'a Client>,
client_id: u64,
@@ -1219,11 +1502,6 @@ fn build_snapshot(
.iter()
.map(|c| ClientInfo {
id: ClientId(c.id.0 as u64),
uid: c
.uid
.as_ref()
.map(|uid| BASE64_STANDARD.encode(&uid.0))
.unwrap_or_default(),
channel: ChannelId(c.channel.0),
name: sanitize(&c.name),
input_muted: c.input_muted,
@@ -1441,7 +1719,12 @@ const _: () = {
#[cfg(test)]
mod tests {
use super::{is_server_query_client_type, sort_channels_tree_by};
use super::{
is_server_query_client_type, send_with_timeout, server_socket_from_config,
sort_channels_tree_by, ConnectConfig, SendTimeoutError,
};
use std::time::Duration;
use tokio::sync::mpsc;
use tsproto_types::ClientType;
/// Lightweight fixture mirroring just the (id, parent, order)
@@ -1470,6 +1753,36 @@ mod tests {
}));
}
#[test]
fn connect_config_can_carry_prefetched_socket_address() {
let addr: std::net::SocketAddr = "127.0.0.1:9987".parse().unwrap();
let cfg = ConnectConfig {
address: "example.com".to_string(),
nickname: "Tester".to_string(),
password: None,
identity: None,
ready_timeout: Duration::from_secs(1),
resolved_address: Some(addr),
};
assert_eq!(cfg.resolved_address, Some(addr));
}
#[test]
fn server_socket_from_config_prefers_prefetched_address() {
let addr: std::net::SocketAddr = "127.0.0.1:9987".parse().unwrap();
let cfg = ConnectConfig {
address: "example.com".to_string(),
resolved_address: Some(addr),
nickname: "Tester".to_string(),
password: None,
identity: None,
ready_timeout: Duration::from_secs(1),
};
assert_eq!(server_socket_from_config(&cfg), Some(addr));
}
#[test]
fn channel_sort_linked_list_under_one_parent() {
// Server emits four root-level channels in arbitrary HashMap
@@ -1589,4 +1902,32 @@ mod tests {
assert!(ids.contains(&1));
assert!(ids.contains(&2));
}
#[tokio::test]
async fn send_with_timeout_waits_for_capacity_instead_of_dropping() {
let (tx, mut rx) = mpsc::channel(1);
tx.send(1_u8).await.expect("seed first item");
let drain = tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(10)).await;
assert_eq!(rx.recv().await, Some(1));
rx.recv().await
});
send_with_timeout(&tx, 2_u8, Duration::from_millis(100))
.await
.expect("second item should enqueue once capacity frees");
assert_eq!(drain.await.expect("drain task should succeed"), Some(2));
}
#[tokio::test]
async fn send_with_timeout_times_out_when_capacity_stays_full() {
let (tx, _rx) = mpsc::channel(1);
tx.send(1_u8).await.expect("seed first item");
let result = send_with_timeout(&tx, 2_u8, Duration::from_millis(10)).await;
assert_eq!(result, Err(SendTimeoutError::Timeout(2)));
}
}
+61 -7
View File
@@ -13,7 +13,7 @@ pub struct ChannelId(pub u64);
pub struct ClientId(pub u64);
/// One channel in the server's tree.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChannelInfo {
/// Stable channel id.
pub id: ChannelId,
@@ -44,7 +44,7 @@ pub enum MessageTarget {
}
/// An in-channel text message from a specific client.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChatMessage {
/// The client id of the sender.
pub sender_id: ClientId,
@@ -57,19 +57,17 @@ pub struct ChatMessage {
}
/// A server-activity notification derived from TeamSpeak bookkeeping events.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ServerActivity {
/// Human-readable activity line.
pub message: String,
}
/// One connected client on the server.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ClientInfo {
/// Stable client id.
pub id: ClientId,
/// Stable TeamSpeak unique identifier for cross-session preference keys.
pub uid: String,
/// Channel the client is currently in.
pub channel: ChannelId,
/// Nickname, preserved verbatim per ADR-008.
@@ -88,8 +86,64 @@ pub struct ClientInfo {
pub talk_power_granted: bool,
}
/// Best-effort profile and live connection details for one online
/// client, fetched through the normal TeamSpeak client protocol.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ClientProfile {
/// Stable online client id.
pub id: ClientId,
/// Channel the client is currently in.
pub channel: ChannelId,
/// Nickname, preserved verbatim per ADR-008.
pub name: String,
/// TeamSpeak unique id encoded in the canonical base64 form.
pub unique_id: String,
/// Stable TeamSpeak database id, when visible.
pub database_id: Option<u64>,
/// ISO country code, when the server exposes one.
pub country_code: String,
/// User description, when visible.
pub description: String,
/// Client version string, populated by `clientgetvariables`.
pub version: String,
/// Client platform string, populated by `clientgetvariables`.
pub platform: String,
/// Account creation time as Unix seconds.
pub created_unix_seconds: Option<i64>,
/// Last connection time as Unix seconds.
pub last_connected_unix_seconds: Option<i64>,
/// Total historical connections, when visible.
pub connections_total: Option<u64>,
/// Current online duration in seconds, when visible.
pub online_seconds: Option<i64>,
/// Current idle time in milliseconds, when visible.
pub idle_milliseconds: Option<i64>,
/// Current ping in milliseconds, when visible.
pub ping_milliseconds: Option<i64>,
/// Client address. Empty when permission-gated.
pub client_address: String,
/// Resolved server group names for the online client.
pub server_groups: Vec<String>,
/// Resolved channel group name for the online client.
pub channel_group: String,
/// TeamSpeak avatar file path suffix, when an avatar hash is present.
pub avatar_path: String,
/// Downloaded bytes this month.
pub bytes_downloaded_month: Option<u64>,
/// Uploaded bytes this month.
pub bytes_uploaded_month: Option<u64>,
/// Downloaded bytes across all time.
pub bytes_downloaded_total: Option<u64>,
/// Uploaded bytes across all time.
pub bytes_uploaded_total: Option<u64>,
/// Client-to-server total packet loss ratio.
pub packet_loss_client_to_server_total: Option<f32>,
/// Server-to-client total packet loss ratio.
pub packet_loss_server_to_client_total: Option<f32>,
}
/// Snapshot of the server's published state at a moment in time.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ServerSnapshot {
/// Server name.
pub server_name: String,
+4 -3
View File
@@ -8,7 +8,8 @@
//!
//! * [`ConnectConfig`] — typed connection parameters.
//! * [`ProtocolClient`] — async handle owning the connection task.
//! * [`ServerSnapshot`], [`ChannelInfo`], [`ClientInfo`] — opaque
//! * [`ServerSnapshot`], [`ChannelInfo`], [`ClientInfo`],
//! [`ClientProfile`] — opaque
//! DTOs containing only `String`s and primitives.
//! * [`ProtocolError`] — typed error catalogue.
//!
@@ -37,8 +38,8 @@ mod dto;
pub use adapter::{ConnectConfig, DisconnectReason, InboundVoice, ProtocolClient, SnapshotProbe};
pub use dto::{
ChannelId, ChannelInfo, ChatMessage, ClientId, ClientInfo, MessageTarget, ServerActivity,
ServerSnapshot,
ChannelId, ChannelInfo, ChatMessage, ClientId, ClientInfo, ClientProfile, MessageTarget,
ServerActivity, ServerSnapshot,
};
// Re-export the upstream voice types so chanora_audio can build outbound