feat(protocol): add file download support for avatars and icons
- Add Request::DownloadFile variant with oneshot reply - Add ProtocolClient::download_avatar(client_uid) and download_icon(icon_id) - Track pending file downloads by FiletransferHandle - Handle StreamItem::FileDownload: read bytes from TCP stream - Handle StreamItem::FiletransferFailed: map to ProtocolError - Add ProtocolError::FileTransfer(String) variant - Add path helper tests for avatar/icon download paths - No tsclientlib types leak across the adapter boundary
This commit is contained in:
@@ -24,6 +24,7 @@ use base64::prelude::*;
|
|||||||
use chanora_resolver::ChanoraResolver;
|
use chanora_resolver::ChanoraResolver;
|
||||||
use futures::prelude::*;
|
use futures::prelude::*;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use tokio::io::AsyncReadExt;
|
||||||
use tokio::sync::{mpsc, oneshot};
|
use tokio::sync::{mpsc, oneshot};
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
|
|
||||||
@@ -32,7 +33,8 @@ use tsclientlib::messages::s2c::{InClientDbInfoPart, InMessage};
|
|||||||
use tsclientlib::prelude::*;
|
use tsclientlib::prelude::*;
|
||||||
use tsclientlib::{
|
use tsclientlib::{
|
||||||
ChannelId as TsChannelId, ClientId as TsClientId, Connection, ConnectionStats,
|
ChannelId as TsChannelId, ClientId as TsClientId, Connection, ConnectionStats,
|
||||||
DisconnectOptions, Identity, MessageHandle, OutCommandExt, StreamItem, Version,
|
DisconnectOptions, FileDownloadResult, FiletransferHandle, Identity, MessageHandle,
|
||||||
|
OutCommandExt, StreamItem, Version,
|
||||||
};
|
};
|
||||||
use tsproto_packets::packets::{Direction, Flags, InAudioBuf, OutCommand, OutPacket, PacketType};
|
use tsproto_packets::packets::{Direction, Flags, InAudioBuf, OutCommand, OutPacket, PacketType};
|
||||||
use tsproto_types::ClientType;
|
use tsproto_types::ClientType;
|
||||||
@@ -60,6 +62,9 @@ type PendingMoves = HashMap<
|
|||||||
),
|
),
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
type PendingDownloads =
|
||||||
|
HashMap<FiletransferHandle, oneshot::Sender<Result<Vec<u8>, ProtocolError>>>;
|
||||||
|
|
||||||
struct EventChannels {
|
struct EventChannels {
|
||||||
voice_in: mpsc::Sender<InboundVoice>,
|
voice_in: mpsc::Sender<InboundVoice>,
|
||||||
chat: mpsc::Sender<ChatMessage>,
|
chat: mpsc::Sender<ChatMessage>,
|
||||||
@@ -207,6 +212,10 @@ enum Request {
|
|||||||
client_id: u64,
|
client_id: u64,
|
||||||
reply: oneshot::Sender<Result<ClientProfile, ProtocolError>>,
|
reply: oneshot::Sender<Result<ClientProfile, ProtocolError>>,
|
||||||
},
|
},
|
||||||
|
DownloadFile {
|
||||||
|
path: String,
|
||||||
|
reply: oneshot::Sender<Result<Vec<u8>, ProtocolError>>,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Why a [`ProtocolClient`] task ended. Distinguishes a user-driven
|
/// Why a [`ProtocolClient`] task ended. Distinguishes a user-driven
|
||||||
@@ -366,6 +375,26 @@ impl ProtocolClient {
|
|||||||
.map_err(|_| ProtocolError::Lost("client_profile reply dropped".to_string()))?
|
.map_err(|_| ProtocolError::Lost("client_profile reply dropped".to_string()))?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn download_file(&self, path: String) -> Result<Vec<u8>, ProtocolError> {
|
||||||
|
let (tx, rx) = oneshot::channel();
|
||||||
|
self.tx
|
||||||
|
.send(Request::DownloadFile { path, reply: tx })
|
||||||
|
.await
|
||||||
|
.map_err(|_| ProtocolError::Lost("connection task is gone".to_string()))?;
|
||||||
|
rx.await
|
||||||
|
.map_err(|_| ProtocolError::Lost("download_file reply dropped".to_string()))?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Download the current avatar bytes for a TeamSpeak client UID.
|
||||||
|
pub async fn download_avatar(&self, client_uid: &str) -> Result<Vec<u8>, ProtocolError> {
|
||||||
|
self.download_file(avatar_download_path(client_uid)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Download the current channel/server icon bytes for an icon id.
|
||||||
|
pub async fn download_icon(&self, icon_id: u64) -> Result<Vec<u8>, ProtocolError> {
|
||||||
|
self.download_file(icon_download_path(icon_id)).await
|
||||||
|
}
|
||||||
|
|
||||||
/// Disconnect cleanly. Blocks until the task exits.
|
/// Disconnect cleanly. Blocks until the task exits.
|
||||||
pub async fn disconnect(self) {
|
pub async fn disconnect(self) {
|
||||||
let (tx, rx) = oneshot::channel();
|
let (tx, rx) = oneshot::channel();
|
||||||
@@ -713,6 +742,7 @@ async fn connection_task(
|
|||||||
// deadline so a server that never replies doesn't leak the
|
// deadline so a server that never replies doesn't leak the
|
||||||
// reply channel — at most 3 s of pending state per move.
|
// reply channel — at most 3 s of pending state per move.
|
||||||
let mut pending_moves: PendingMoves = HashMap::new();
|
let mut pending_moves: PendingMoves = HashMap::new();
|
||||||
|
let mut pending_downloads: PendingDownloads = HashMap::new();
|
||||||
let mut voice_activity: HashMap<u64, Instant> = HashMap::new();
|
let mut voice_activity: HashMap<u64, Instant> = HashMap::new();
|
||||||
let mut poke_limiter = PokeLimiter::new();
|
let mut poke_limiter = PokeLimiter::new();
|
||||||
|
|
||||||
@@ -737,6 +767,12 @@ async fn connection_task(
|
|||||||
StreamItem::Audio(buf) => {
|
StreamItem::Audio(buf) => {
|
||||||
handle_audio_stream_item(&channels.voice_in, &mut voice_activity, buf).await;
|
handle_audio_stream_item(&channels.voice_in, &mut voice_activity, buf).await;
|
||||||
}
|
}
|
||||||
|
StreamItem::FileDownload(handle, result) => {
|
||||||
|
handle_download_stream_item(&mut pending_downloads, handle, result).await;
|
||||||
|
}
|
||||||
|
StreamItem::FiletransferFailed(handle, error) => {
|
||||||
|
handle_download_failure(&mut pending_downloads, handle, error);
|
||||||
|
}
|
||||||
other => handle_non_audio_stream_item(
|
other => handle_non_audio_stream_item(
|
||||||
&con,
|
&con,
|
||||||
other,
|
other,
|
||||||
@@ -843,12 +879,25 @@ async fn connection_task(
|
|||||||
client_id,
|
client_id,
|
||||||
&channels,
|
&channels,
|
||||||
&mut pending_moves,
|
&mut pending_moves,
|
||||||
|
&mut pending_downloads,
|
||||||
&mut voice_activity,
|
&mut voice_activity,
|
||||||
&mut poke_limiter,
|
&mut poke_limiter,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
let _ = reply.send(r);
|
let _ = reply.send(r);
|
||||||
}
|
}
|
||||||
|
Ok(Request::DownloadFile { path, reply }) => {
|
||||||
|
match con.download_file(TsChannelId(0), &path, None, None) {
|
||||||
|
Ok(handle) => {
|
||||||
|
pending_downloads.insert(handle, reply);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let _ = reply.send(Err(ProtocolError::FileTransfer(format!(
|
||||||
|
"start download {path}: {e}"
|
||||||
|
))));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(Request::Disconnect(reply)) => {
|
Ok(Request::Disconnect(reply)) => {
|
||||||
let _ = con.disconnect(DisconnectOptions::new());
|
let _ = con.disconnect(DisconnectOptions::new());
|
||||||
bounded_drain_stream(con.events(), DISCONNECT_EVENT_DRAIN_TIMEOUT).await;
|
bounded_drain_stream(con.events(), DISCONNECT_EVENT_DRAIN_TIMEOUT).await;
|
||||||
@@ -1024,6 +1073,39 @@ fn handle_non_audio_stream_item(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn handle_download_stream_item(
|
||||||
|
pending_downloads: &mut PendingDownloads,
|
||||||
|
handle: FiletransferHandle,
|
||||||
|
result: FileDownloadResult,
|
||||||
|
) {
|
||||||
|
if let Some(reply) = pending_downloads.remove(&handle) {
|
||||||
|
let _ = reply.send(read_download_bytes(result).await);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_download_failure(
|
||||||
|
pending_downloads: &mut PendingDownloads,
|
||||||
|
handle: FiletransferHandle,
|
||||||
|
error: tsclientlib::Error,
|
||||||
|
) {
|
||||||
|
if let Some(reply) = pending_downloads.remove(&handle) {
|
||||||
|
let _ = reply.send(Err(ProtocolError::FileTransfer(error.to_string())));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read_download_bytes(result: FileDownloadResult) -> Result<Vec<u8>, ProtocolError> {
|
||||||
|
let size = usize::try_from(result.size).map_err(|_| {
|
||||||
|
ProtocolError::FileTransfer(format!("download too large to buffer: {} bytes", result.size))
|
||||||
|
})?;
|
||||||
|
let mut stream = result.stream;
|
||||||
|
let mut bytes = vec![0_u8; size];
|
||||||
|
stream
|
||||||
|
.read_exact(&mut bytes)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ProtocolError::FileTransfer(e.to_string()))?;
|
||||||
|
Ok(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
async fn resolve_server_socket(address: &str) -> Result<SocketAddr, ProtocolError> {
|
async fn resolve_server_socket(address: &str) -> Result<SocketAddr, ProtocolError> {
|
||||||
let resolver = ChanoraResolver::new().map_err(|err| ProtocolError::DnsFailed {
|
let resolver = ChanoraResolver::new().map_err(|err| ProtocolError::DnsFailed {
|
||||||
host: address.to_string(),
|
host: address.to_string(),
|
||||||
@@ -1174,6 +1256,7 @@ async fn fetch_client_profile(
|
|||||||
client_id: u64,
|
client_id: u64,
|
||||||
channels: &EventChannels,
|
channels: &EventChannels,
|
||||||
pending_moves: &mut PendingMoves,
|
pending_moves: &mut PendingMoves,
|
||||||
|
pending_downloads: &mut PendingDownloads,
|
||||||
voice_activity: &mut HashMap<u64, Instant>,
|
voice_activity: &mut HashMap<u64, Instant>,
|
||||||
poke_limiter: &mut PokeLimiter,
|
poke_limiter: &mut PokeLimiter,
|
||||||
) -> Result<ClientProfile, ProtocolError> {
|
) -> Result<ClientProfile, ProtocolError> {
|
||||||
@@ -1220,6 +1303,7 @@ async fn fetch_client_profile(
|
|||||||
build_command("servergrouplist", &[], &[]),
|
build_command("servergrouplist", &[], &[]),
|
||||||
channels,
|
channels,
|
||||||
pending_moves,
|
pending_moves,
|
||||||
|
pending_downloads,
|
||||||
voice_activity,
|
voice_activity,
|
||||||
poke_limiter,
|
poke_limiter,
|
||||||
)
|
)
|
||||||
@@ -1231,6 +1315,7 @@ async fn fetch_client_profile(
|
|||||||
build_command("channelgrouplist", &[], &[]),
|
build_command("channelgrouplist", &[], &[]),
|
||||||
channels,
|
channels,
|
||||||
pending_moves,
|
pending_moves,
|
||||||
|
pending_downloads,
|
||||||
voice_activity,
|
voice_activity,
|
||||||
poke_limiter,
|
poke_limiter,
|
||||||
)
|
)
|
||||||
@@ -1246,6 +1331,7 @@ async fn fetch_client_profile(
|
|||||||
),
|
),
|
||||||
channels,
|
channels,
|
||||||
pending_moves,
|
pending_moves,
|
||||||
|
pending_downloads,
|
||||||
voice_activity,
|
voice_activity,
|
||||||
poke_limiter,
|
poke_limiter,
|
||||||
)
|
)
|
||||||
@@ -1265,6 +1351,7 @@ async fn fetch_client_profile(
|
|||||||
build_command("getconnectioninfo", &[("clid", client_id.to_string())], &[]),
|
build_command("getconnectioninfo", &[("clid", client_id.to_string())], &[]),
|
||||||
channels,
|
channels,
|
||||||
pending_moves,
|
pending_moves,
|
||||||
|
pending_downloads,
|
||||||
voice_activity,
|
voice_activity,
|
||||||
poke_limiter,
|
poke_limiter,
|
||||||
)
|
)
|
||||||
@@ -1285,6 +1372,7 @@ async fn fetch_client_profile(
|
|||||||
database_id,
|
database_id,
|
||||||
channels,
|
channels,
|
||||||
pending_moves,
|
pending_moves,
|
||||||
|
pending_downloads,
|
||||||
voice_activity,
|
voice_activity,
|
||||||
poke_limiter,
|
poke_limiter,
|
||||||
)
|
)
|
||||||
@@ -1448,6 +1536,7 @@ async fn request_messages(
|
|||||||
command: OutCommand,
|
command: OutCommand,
|
||||||
channels: &EventChannels,
|
channels: &EventChannels,
|
||||||
pending_moves: &mut PendingMoves,
|
pending_moves: &mut PendingMoves,
|
||||||
|
pending_downloads: &mut PendingDownloads,
|
||||||
voice_activity: &mut HashMap<u64, Instant>,
|
voice_activity: &mut HashMap<u64, Instant>,
|
||||||
poke_limiter: &mut PokeLimiter,
|
poke_limiter: &mut PokeLimiter,
|
||||||
) -> Result<Vec<InMessage>, ProtocolError> {
|
) -> Result<Vec<InMessage>, ProtocolError> {
|
||||||
@@ -1485,6 +1574,12 @@ async fn request_messages(
|
|||||||
StreamItem::Audio(buf) => {
|
StreamItem::Audio(buf) => {
|
||||||
handle_audio_stream_item(&channels.voice_in, voice_activity, buf).await;
|
handle_audio_stream_item(&channels.voice_in, voice_activity, buf).await;
|
||||||
}
|
}
|
||||||
|
StreamItem::FileDownload(handle, result) => {
|
||||||
|
handle_download_stream_item(pending_downloads, handle, result).await;
|
||||||
|
}
|
||||||
|
StreamItem::FiletransferFailed(handle, error) => {
|
||||||
|
handle_download_failure(pending_downloads, handle, error);
|
||||||
|
}
|
||||||
other => handle_non_audio_stream_item(
|
other => handle_non_audio_stream_item(
|
||||||
con,
|
con,
|
||||||
other,
|
other,
|
||||||
@@ -1503,6 +1598,7 @@ async fn request_client_db_info(
|
|||||||
dbid: tsclientlib::ClientDbId,
|
dbid: tsclientlib::ClientDbId,
|
||||||
channels: &EventChannels,
|
channels: &EventChannels,
|
||||||
pending_moves: &mut PendingMoves,
|
pending_moves: &mut PendingMoves,
|
||||||
|
pending_downloads: &mut PendingDownloads,
|
||||||
voice_activity: &mut HashMap<u64, Instant>,
|
voice_activity: &mut HashMap<u64, Instant>,
|
||||||
poke_limiter: &mut PokeLimiter,
|
poke_limiter: &mut PokeLimiter,
|
||||||
) -> Result<InClientDbInfoPart, ProtocolError> {
|
) -> Result<InClientDbInfoPart, ProtocolError> {
|
||||||
@@ -1511,6 +1607,7 @@ async fn request_client_db_info(
|
|||||||
build_command("clientdbinfo", &[("cldbid", dbid.0.to_string())], &[]),
|
build_command("clientdbinfo", &[("cldbid", dbid.0.to_string())], &[]),
|
||||||
channels,
|
channels,
|
||||||
pending_moves,
|
pending_moves,
|
||||||
|
pending_downloads,
|
||||||
voice_activity,
|
voice_activity,
|
||||||
poke_limiter,
|
poke_limiter,
|
||||||
)
|
)
|
||||||
@@ -1568,6 +1665,14 @@ fn uid_to_avatar_path(uid_b64: &str) -> String {
|
|||||||
rendered
|
rendered
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn avatar_download_path(client_uid: &str) -> String {
|
||||||
|
format!("/avatar_{}", uid_to_avatar_path(client_uid))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn icon_download_path(icon_id: u64) -> String {
|
||||||
|
format!("/icon_{icon_id}")
|
||||||
|
}
|
||||||
|
|
||||||
fn find_client_by_id<'a>(
|
fn find_client_by_id<'a>(
|
||||||
clients: impl IntoIterator<Item = &'a Client>,
|
clients: impl IntoIterator<Item = &'a Client>,
|
||||||
client_id: u64,
|
client_id: u64,
|
||||||
@@ -1951,10 +2056,11 @@ const _: () = {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
bounded_drain_stream, client_profile_refresh_plan, drain_voice_packets_for_tick,
|
avatar_download_path, bounded_drain_stream, client_profile_refresh_plan,
|
||||||
is_server_query_client_type, send_with_timeout, server_socket_from_config,
|
drain_voice_packets_for_tick, icon_download_path, is_server_query_client_type,
|
||||||
sort_channels_tree_by, std_duration_millis, ConnectConfig, ProtocolClient, Request,
|
send_with_timeout, server_socket_from_config, sort_channels_tree_by,
|
||||||
SendTimeoutError, DISCONNECT_REPLY_TIMEOUT,
|
std_duration_millis, ConnectConfig, ProtocolClient, Request, SendTimeoutError,
|
||||||
|
DISCONNECT_REPLY_TIMEOUT,
|
||||||
};
|
};
|
||||||
use futures::stream;
|
use futures::stream;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
@@ -2052,6 +2158,16 @@ mod tests {
|
|||||||
assert!(plan.needs_channel_groups);
|
assert!(plan.needs_channel_groups);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn avatar_download_path_uses_uid_hex_encoding() {
|
||||||
|
assert_eq!(avatar_download_path("AQID"), "/avatar_abacad");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn icon_download_path_uses_unsigned_icon_id() {
|
||||||
|
assert_eq!(icon_download_path(42), "/icon_42");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn channel_sort_linked_list_under_one_parent() {
|
fn channel_sort_linked_list_under_one_parent() {
|
||||||
// Server emits four root-level channels in arbitrary HashMap
|
// Server emits four root-level channels in arbitrary HashMap
|
||||||
|
|||||||
@@ -115,4 +115,8 @@ pub enum ProtocolError {
|
|||||||
/// should never see this; if they do, it is a mapping bug here.
|
/// should never see this; if they do, it is a mapping bug here.
|
||||||
#[error("protocol backend: {0}")]
|
#[error("protocol backend: {0}")]
|
||||||
Backend(String),
|
Backend(String),
|
||||||
|
|
||||||
|
/// A file transfer failed while downloading protocol-owned assets.
|
||||||
|
#[error("file transfer failed: {0}")]
|
||||||
|
FileTransfer(String),
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user