feat: integrate chat voice and diagnostics client

This commit is contained in:
Edison Jwa
2026-05-23 06:51:55 +09:00
parent 7e28791ec2
commit 7d5d8c2c90
93 changed files with 10273 additions and 3347 deletions
+1
View File
@@ -24,6 +24,7 @@ tsclientlib = { git = "https://github.com/ReSpeak/tsclientlib.git", rev = "04aa2
# avoids any version-skew confusion.
tsproto-packets = { git = "https://github.com/ReSpeak/tsclientlib.git", rev = "04aa2491" }
tsproto-types = { git = "https://github.com/ReSpeak/tsclientlib.git", rev = "04aa2491" }
ts-bookkeeping = { git = "https://github.com/ReSpeak/tsclientlib.git", rev = "04aa2491" }
# Async runtime utilities used by the connection task.
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time", "sync"] }
+162 -5
View File
@@ -33,7 +33,9 @@ use tsclientlib::{
use tsproto_packets::packets::{InAudioBuf, OutPacket};
use tsproto_types::ClientType;
use crate::dto::{ChannelId, ChannelInfo, ClientId, ClientInfo, ServerSnapshot};
use crate::dto::{
ChannelId, ChannelInfo, ChatMessage, ClientId, ClientInfo, MessageTarget, ServerSnapshot,
};
use crate::ProtocolError;
const SPEAKING_ACTIVITY_WINDOW: Duration = Duration::from_millis(750);
@@ -104,8 +106,8 @@ pub struct ConnectConfig {
pub password: Option<String>,
/// Optional pre-existing identity (base64 string accepted by
/// `tsclientlib::Identity::new_from_str`). If `None`, a fresh
/// identity is generated and **not persisted** — production
/// callers must wire this to `chanora_storage::SecretStorageRepository`.
/// identity is generated and **not persisted** — production callers
/// should provide one from secure identity storage.
pub identity: Option<String>,
/// How long to wait for the initial state snapshot before
/// returning `ProtocolError::Timeout`.
@@ -139,6 +141,15 @@ enum Request {
output: Option<bool>,
reply: oneshot::Sender<Result<(), ProtocolError>>,
},
/// Send a text message to a target.
SendTextMessage {
/// Message content.
message: String,
/// Target scope.
target: MessageTarget,
/// Reply channel for outcome.
reply: oneshot::Sender<Result<(), ProtocolError>>,
},
}
/// Why a [`ProtocolClient`] task ended. Distinguishes a user-driven
@@ -170,6 +181,9 @@ pub struct ProtocolClient {
/// auto-reconnect. Wrapped in a Mutex<Option<_>> so it can be
/// taken once by the supervisor and never resurfaced.
lost_rx: std::sync::Mutex<Option<oneshot::Receiver<DisconnectReason>>>,
/// Inbound chat message stream from the connection task. The
/// receiver is taken by the supervisor and forwarded to UI.
chat_rx: std::sync::Mutex<Option<mpsc::Receiver<ChatMessage>>>,
}
/// One inbound voice packet from a remote client.
@@ -228,6 +242,7 @@ impl ProtocolClient {
let (tx, rx) = mpsc::channel::<Request>(8);
let (voice_out_tx, voice_out_rx) = mpsc::channel::<OutPacket>(64);
let (voice_in_tx, voice_in_rx) = mpsc::channel::<InboundVoice>(64);
let (chat_tx, chat_rx) = mpsc::channel::<ChatMessage>(64);
let (ready_tx, ready_rx) = oneshot::channel::<Result<(), ProtocolError>>();
let (lost_tx, lost_rx) = oneshot::channel::<DisconnectReason>();
@@ -236,6 +251,7 @@ impl ProtocolClient {
rx,
voice_out_rx,
voice_in_tx,
chat_tx,
ready_tx,
lost_tx,
));
@@ -246,6 +262,7 @@ impl ProtocolClient {
voice_out_tx,
voice_in_rx: std::sync::Mutex::new(Some(voice_in_rx)),
lost_rx: std::sync::Mutex::new(Some(lost_rx)),
chat_rx: std::sync::Mutex::new(Some(chat_rx)),
}),
Ok(Ok(Err(e))) => Err(e),
Ok(Err(_)) => Err(ProtocolError::Backend(
@@ -360,6 +377,40 @@ impl ProtocolClient {
pub fn take_loss_notifier(&self) -> Option<oneshot::Receiver<DisconnectReason>> {
self.lost_rx.lock().ok().and_then(|mut g| g.take())
}
/// Take the inbound-chat receiver. Returns `None` if it has
/// already been taken; only one consumer is allowed.
pub fn take_chat_rx(&self) -> Option<mpsc::Receiver<ChatMessage>> {
self.chat_rx.lock().ok().and_then(|mut g| g.take())
}
/// Put a previously-taken chat_rx receiver back.
pub fn put_chat_rx(&self, rx: mpsc::Receiver<ChatMessage>) {
if let Ok(mut g) = self.chat_rx.lock() {
if g.is_none() {
*g = Some(rx);
}
}
}
/// Send a text message to the specified target.
pub async fn send_text_message(
&self,
message: String,
target: MessageTarget,
) -> Result<(), ProtocolError> {
let (tx, rx) = oneshot::channel();
self.tx
.send(Request::SendTextMessage {
message,
target,
reply: tx,
})
.await
.map_err(|_| ProtocolError::Lost("connection task is gone".to_string()))?;
rx.await
.map_err(|_| ProtocolError::Lost("send_text_message reply dropped".to_string()))?
}
}
async fn connection_task(
@@ -367,6 +418,7 @@ async fn connection_task(
mut rx: mpsc::Receiver<Request>,
mut voice_out_rx: mpsc::Receiver<OutPacket>,
voice_in_tx: mpsc::Sender<InboundVoice>,
chat_tx: mpsc::Sender<ChatMessage>,
ready_tx: oneshot::Sender<Result<(), ProtocolError>>,
lost_tx: oneshot::Sender<DisconnectReason>,
) {
@@ -545,6 +597,33 @@ async fn connection_task(
}
}
}
StreamItem::BookEvents(events) => {
for ev in events {
if let tsclientlib::events::Event::Message {
target,
invoker,
message,
} = ev
{
let mapped = match target {
tsclientlib::MessageTarget::Server => MessageTarget::Server,
tsclientlib::MessageTarget::Channel => MessageTarget::Channel,
tsclientlib::MessageTarget::Client(id) => {
MessageTarget::Client(id.0 as u64)
}
tsclientlib::MessageTarget::Poke(id) => {
MessageTarget::Poke(id.0 as u64)
}
};
let _ = chat_tx.try_send(ChatMessage {
sender_id: ClientId(invoker.id.0 as u64),
sender_name: sanitize(&invoker.name),
message: sanitize(&message),
target: mapped,
});
}
}
}
StreamItem::MessageResult(handle, result) => {
if let Some((reply, _deadline)) = pending_moves.remove(&handle) {
let mapped = match result {
@@ -644,6 +723,14 @@ async fn connection_task(
let r = set_self_muted(&mut con, input, output);
let _ = reply.send(r);
}
Ok(Request::SendTextMessage {
message,
target,
reply,
}) => {
let r = send_text_message(&mut con, &message, target);
let _ = reply.send(r);
}
Ok(Request::Disconnect(reply)) => {
let _ = con.disconnect(DisconnectOptions::new());
con.events().for_each(|_| future::ready(())).await;
@@ -723,6 +810,74 @@ fn set_self_muted(
Ok(())
}
fn send_text_message(
con: &mut Connection,
message: &str,
target: MessageTarget,
) -> Result<(), ProtocolError> {
use ts_bookkeeping::messages::c2s;
use tsproto_types::TextMessageTargetMode;
match target {
MessageTarget::Server => {
c2s::OutSendTextMessageMessage::new(&mut std::iter::once(
c2s::OutSendTextMessagePart {
target: TextMessageTargetMode::Server,
target_client_id: None,
message: message.into(),
},
))
.send(con)
.map_err(|e| ProtocolError::Backend(format!("send_textmessage(server): {e}")))?;
}
MessageTarget::Channel => {
// Fix: previously channel messages were sent via
// state.server.send_textmessage() which always uses
// TextMessageTargetMode::Server. Now correctly uses
// TextMessageTargetMode::Channel so the message is
// scoped to the current channel, not server-wide.
c2s::OutSendTextMessageMessage::new(&mut std::iter::once(
c2s::OutSendTextMessagePart {
target: TextMessageTargetMode::Channel,
target_client_id: None,
message: message.into(),
},
))
.send(con)
.map_err(|e| ProtocolError::Backend(format!("send_textmessage(channel): {e}")))?;
}
MessageTarget::Client(client_id) => {
let state = con
.get_state()
.map_err(|e| ProtocolError::Backend(format!("get_state: {e}")))?;
let client = state
.clients
.values()
.find(|c| c.id.0 as u64 == client_id)
.ok_or_else(|| ProtocolError::Backend(format!("client {client_id} not found")))?;
client
.send_textmessage(message)
.send(con)
.map_err(|e| ProtocolError::Backend(format!("send_textmessage(client): {e}")))?;
}
MessageTarget::Poke(client_id) => {
let state = con
.get_state()
.map_err(|e| ProtocolError::Backend(format!("get_state: {e}")))?;
let client = state
.clients
.values()
.find(|c| c.id.0 as u64 == client_id)
.ok_or_else(|| ProtocolError::Backend(format!("client {client_id} not found")))?;
client
.poke(message)
.send(con)
.map_err(|e| ProtocolError::Backend(format!("poke: {e}")))?;
}
}
info!(target: "chanora_protocol", len = message.len(), ?target, "text message sent");
Ok(())
}
/// Extract the originating `client_id` from an inbound voice packet.
fn packet_sender_id(buf: &InAudioBuf) -> Option<u64> {
use tsproto_packets::packets::AudioData;
@@ -872,6 +1027,7 @@ fn build_snapshot(
name: sanitize(&c.name),
order: c.order.0 as i64,
has_password: c.has_password.unwrap_or(false),
needed_talk_power: c.needed_talk_power,
})
.collect();
@@ -887,6 +1043,8 @@ fn build_snapshot(
.get(&(c.id.0 as u64))
.is_some_and(|last| last.elapsed() <= SPEAKING_ACTIVITY_WINDOW),
is_server_query: is_server_query_client_type(&c.client_type),
talk_power: c.talk_power,
talk_power_granted: c.talk_power_granted,
})
.collect();
@@ -915,8 +1073,7 @@ fn sanitize(s: &str) -> String {
.collect()
}
#[allow(dead_code)]
const _ROOT_MATCHES_UPSTREAM: () = {
const _: () = {
// Compile-time assertion that ChannelId(0) maps to what tsclientlib
// also considers the root.
let _ = TsChannelId(0);
+33
View File
@@ -25,6 +25,35 @@ pub struct ChannelInfo {
pub order: i64,
/// True when the server marks the channel as password-protected.
pub has_password: bool,
/// Talk power threshold required to speak in this channel.
/// `None` means no talk-power restriction.
pub needed_talk_power: Option<i32>,
}
/// The target scope of a text message (mirrors TS3 `TextMessageTargetMode`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum MessageTarget {
/// Broadcast to entire server.
Server,
/// Broadcast to current channel.
Channel,
/// Private message to a specific client.
Client(u64),
/// Poke a specific client.
Poke(u64),
}
/// An in-channel text message from a specific client.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatMessage {
/// The client id of the sender.
pub sender_id: ClientId,
/// Nickname of the sender, preserved verbatim.
pub sender_name: String,
/// Message content, preserved verbatim.
pub message: String,
/// Target scope of this message.
pub target: MessageTarget,
}
/// One connected client on the server.
@@ -44,6 +73,10 @@ pub struct ClientInfo {
pub is_speaking: bool,
/// True for TeamSpeak ServerQuery clients.
pub is_server_query: bool,
/// Current talk power value assigned by the server.
pub talk_power: i32,
/// True when the server has granted talk power regardless of numeric value.
pub talk_power_granted: bool,
}
/// Snapshot of the server's published state at a moment in time.
+3 -1
View File
@@ -41,7 +41,9 @@ mod dto;
mod resolver;
pub use adapter::{ConnectConfig, DisconnectReason, InboundVoice, ProtocolClient, SnapshotProbe};
pub use dto::{ChannelId, ChannelInfo, ClientId, ClientInfo, ServerSnapshot};
pub use dto::{
ChannelId, ChannelInfo, ChatMessage, ClientId, ClientInfo, MessageTarget, ServerSnapshot,
};
// Re-export the upstream voice types so chanora_audio can build outbound
// voice packets without taking a direct dependency on tsclientlib /