chore: restore product scaffold to rollback baseline
This commit is contained in:
@@ -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)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user