feat: integrate chat voice and diagnostics client
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 /
|
||||
|
||||
Reference in New Issue
Block a user