feat: add session event polling, channel/client list, and text messages

- Add SessionEvent enum with Connected, ChannelList, ClientList, ServerInfo, TextMessage, ClientEntered, ClientLeft, ClientMoved
- Add poll_events, request_channel_list, request_client_list Tauri commands
- Session now parses initserver, channellist, clientlist, notify* commands and emits events
- Frontend polls events every 500ms when connected
- Frontend displays server info, channel list, client list, and text messages
This commit is contained in:
ReTeamSpeak
2026-05-12 19:25:02 +09:00
parent 7a767e9d2e
commit ace2f5deed
5 changed files with 298 additions and 35 deletions
+162 -2
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import { invoke } from '@tauri-apps/api/core';
interface Identity {
@@ -18,6 +18,51 @@ interface Bookmark {
last_connected: string | null;
}
interface ChannelEntry {
cid: number;
pid: number;
channel_order: number;
channel_name: string;
total_clients: number;
channel_needed_subscribe_power: number;
}
interface ClientEntry {
clid: number;
cid: number;
client_database_id: number;
client_nickname: string;
client_type: number;
}
interface ServerInfo {
name: string;
platform: string;
version: string;
max_clients: number;
clients_online: number;
channels_online: number;
}
interface TextMessage {
invoker_id: number;
invoker_name: string;
message: string;
target_mode: number;
}
type SessionEvent =
| { Connected: { client_id: number } }
| { ChannelList: ChannelEntry[] }
| { ClientList: ClientEntry[] }
| { ServerInfo: ServerInfo }
| { TextMessage: TextMessage }
| { ClientEntered: { clid: number; cid: number; client_nickname: string } }
| { ClientLeft: { clid: number; reason: string } }
| { ClientMoved: { clid: number; cid: number } }
| { Error: string }
| { Disconnected: null };
interface ServerQueryChannel {
id: number;
name: string;
@@ -52,10 +97,15 @@ function App() {
const [nickname, setNickname] = useState('');
const [password, setPassword] = useState('');
const [connected, setConnected] = useState(false);
const [serverInfo, setServerInfo] = useState<ServerInfo | null>(null);
const [channels, setChannels] = useState<ChannelEntry[]>([]);
const [clients, setClients] = useState<ClientEntry[]>([]);
const [messages, setMessages] = useState<TextMessage[]>([]);
const [queryPort, setQueryPort] = useState(10011);
const [querySnapshot, setQuerySnapshot] = useState<ServerQuerySnapshot | null>(null);
const [queryLoading, setQueryLoading] = useState(false);
const [queryError, setQueryError] = useState<string | null>(null);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
loadIdentities();
@@ -67,6 +117,56 @@ function App() {
setQueryError(null);
}, [selectedBookmark]);
useEffect(() => {
if (connected) {
pollEvents();
pollRef.current = setInterval(pollEvents, 500);
return () => {
if (pollRef.current) clearInterval(pollRef.current);
};
} else {
if (pollRef.current) clearInterval(pollRef.current);
}
}, [connected]);
async function pollEvents() {
try {
const events = await invoke<SessionEvent[]>('poll_events');
for (const event of events) {
handleEvent(event);
}
} catch (error) {
console.error('Failed to poll events:', error);
}
}
function handleEvent(event: SessionEvent) {
if ('Connected' in event) {
console.log('Connected as client', event.Connected.client_id);
} else if ('ServerInfo' in event) {
setServerInfo(event.ServerInfo);
} else if ('ChannelList' in event) {
setChannels(event.ChannelList);
} else if ('ClientList' in event) {
setClients(event.ClientList);
} else if ('TextMessage' in event) {
setMessages((prev) => [...prev, event.TextMessage]);
} else if ('ClientEntered' in event) {
console.log('Client entered:', event.ClientEntered);
} else if ('ClientLeft' in event) {
console.log('Client left:', event.ClientLeft);
} else if ('ClientMoved' in event) {
console.log('Client moved:', event.ClientMoved);
} else if ('Error' in event) {
console.error('Session error:', event.Error);
} else if ('Disconnected' in event) {
setConnected(false);
setServerInfo(null);
setChannels([]);
setClients([]);
}
}
async function loadIdentities() {
try {
const result = await invoke<Identity[]>('get_identities');
@@ -96,6 +196,7 @@ function App() {
password: password || null,
});
setConnected(true);
setMessages([]);
} catch (error) {
console.error('Failed to connect:', error);
}
@@ -105,6 +206,9 @@ function App() {
try {
await invoke('disconnect');
setConnected(false);
setServerInfo(null);
setChannels([]);
setClients([]);
} catch (error) {
console.error('Failed to disconnect:', error);
}
@@ -141,7 +245,9 @@ function App() {
<h1>ReTeamSpeak</h1>
<div className="connection-status">
{connected ? (
<span className="status connected"></span>
<span className="status connected">
{serverInfo ? `${serverInfo.name}` : ''}
</span>
) : (
<span className="status disconnected"></span>
)}
@@ -212,6 +318,60 @@ function App() {
</div>
</div>
{connected && (
<section className="session-panel">
<div className="query-grid">
<div className="query-card">
<h3></h3>
{serverInfo ? (
<>
<p><strong>{serverInfo.name}</strong></p>
<p>{serverInfo.platform} / {serverInfo.version}</p>
<p>{serverInfo.clients_online} / {serverInfo.max_clients} 线</p>
</>
) : (
<p>...</p>
)}
</div>
<div className="query-card">
<h3> ({channels.length})</h3>
<ul className="query-list">
{channels.map((ch) => (
<li key={ch.cid}>
<span>{ch.channel_name}</span>
<small>{ch.total_clients} </small>
</li>
))}
</ul>
</div>
<div className="query-card">
<h3> ({clients.length})</h3>
<ul className="query-list">
{clients.map((c) => (
<li key={c.clid}>
<span>{c.client_nickname}</span>
<small>#{c.clid}</small>
</li>
))}
</ul>
</div>
</div>
<div className="messages-panel">
<h3> ({messages.length})</h3>
<ul className="message-list">
{messages.map((msg, i) => (
<li key={i}>
<strong>{msg.invoker_name}:</strong> {msg.message}
</li>
))}
</ul>
</div>
</section>
)}
<section className="query-panel">
<div className="query-header">
<div>
+32 -1
View File
@@ -6,7 +6,7 @@ use std::net::SocketAddr;
use std::time::Duration;
use tauri::State;
use tokio::net::lookup_host;
use tscore::{ClientConfig, IdentityKey, QueryClient, Session};
use tscore::{ClientConfig, IdentityKey, QueryClient, Session, SessionEvent};
use crate::AppState;
@@ -273,6 +273,37 @@ pub async fn send_raw_command(state: State<'_, AppState>, command: String) -> Re
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn poll_events(state: State<'_, AppState>) -> Result<Vec<SessionEvent>, String> {
let mut session_guard = state.session_handle.lock().await;
let handle = session_guard.as_mut().ok_or("not connected")?;
let mut events = Vec::new();
while let Some(event) = handle.try_recv_event() {
events.push(event);
}
Ok(events)
}
#[tauri::command]
pub async fn request_channel_list(state: State<'_, AppState>) -> Result<(), String> {
let session_guard = state.session_handle.lock().await;
let handle = session_guard.as_ref().ok_or("not connected")?;
handle
.request_channel_list()
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn request_client_list(state: State<'_, AppState>) -> Result<(), String> {
let session_guard = state.session_handle.lock().await;
let handle = session_guard.as_ref().ok_or("not connected")?;
handle
.request_client_list()
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn get_messages(
state: State<'_, AppState>,
+3
View File
@@ -51,6 +51,9 @@ pub fn run() {
commands::send_server_message,
commands::send_private_message,
commands::send_raw_command,
commands::poll_events,
commands::request_channel_list,
commands::request_client_list,
commands::get_messages,
commands::server_query_snapshot,
])
+95 -29
View File
@@ -283,13 +283,34 @@ impl Session {
fn collect_init_event(cmd: &Command, events: &mut Vec<SessionEvent>) {
match cmd.name.as_str() {
"initserver" => {
let client_id = cmd.get("client_id").and_then(|v| v.parse().ok()).unwrap_or(0);
let name = cmd.get("virtualserver_name").unwrap_or_default().to_string();
let platform = cmd.get("virtualserver_platform").unwrap_or_default().to_string();
let version = cmd.get("virtualserver_version").unwrap_or_default().to_string();
let max_clients = cmd.get("virtualserver_maxclients").and_then(|v| v.parse().ok()).unwrap_or(0);
let clients_online = cmd.get("virtualserver_clientsonline").and_then(|v| v.parse().ok()).unwrap_or(0);
let channels_online = cmd.get("virtualserver_channelsonline").and_then(|v| v.parse().ok()).unwrap_or(0);
let client_id = cmd
.get("client_id")
.and_then(|v| v.parse().ok())
.unwrap_or(0);
let name = cmd
.get("virtualserver_name")
.unwrap_or_default()
.to_string();
let platform = cmd
.get("virtualserver_platform")
.unwrap_or_default()
.to_string();
let version = cmd
.get("virtualserver_version")
.unwrap_or_default()
.to_string();
let max_clients = cmd
.get("virtualserver_maxclients")
.and_then(|v| v.parse().ok())
.unwrap_or(0);
let clients_online = cmd
.get("virtualserver_clientsonline")
.and_then(|v| v.parse().ok())
.unwrap_or(0);
let channels_online = cmd
.get("virtualserver_channelsonline")
.and_then(|v| v.parse().ok())
.unwrap_or(0);
events.push(SessionEvent::ServerInfo {
name,
@@ -313,10 +334,19 @@ impl Session {
let mut channels = Vec::new();
let cid = cmd.get("cid").and_then(|v| v.parse().ok()).unwrap_or(0);
let pid = cmd.get("pid").and_then(|v| v.parse().ok()).unwrap_or(0);
let channel_order = cmd.get("channel_order").and_then(|v| v.parse().ok()).unwrap_or(0);
let channel_order = cmd
.get("channel_order")
.and_then(|v| v.parse().ok())
.unwrap_or(0);
let channel_name = cmd.get("channel_name").unwrap_or_default().to_string();
let total_clients = cmd.get("total_clients").and_then(|v| v.parse().ok()).unwrap_or(0);
let channel_needed_subscribe_power = cmd.get("channel_needed_subscribe_power").and_then(|v| v.parse().ok()).unwrap_or(0);
let total_clients = cmd
.get("total_clients")
.and_then(|v| v.parse().ok())
.unwrap_or(0);
let channel_needed_subscribe_power = cmd
.get("channel_needed_subscribe_power")
.and_then(|v| v.parse().ok())
.unwrap_or(0);
channels.push(ChannelEntry {
cid,
@@ -392,22 +422,46 @@ impl Session {
async fn emit_command_event(&self, cmd: &Command) {
let event = match cmd.name.as_str() {
"initserver" => {
let client_id = cmd.get("client_id").and_then(|v| v.parse().ok()).unwrap_or(0);
let name = cmd.get("virtualserver_name").unwrap_or_default().to_string();
let platform = cmd.get("virtualserver_platform").unwrap_or_default().to_string();
let version = cmd.get("virtualserver_version").unwrap_or_default().to_string();
let max_clients = cmd.get("virtualserver_maxclients").and_then(|v| v.parse().ok()).unwrap_or(0);
let clients_online = cmd.get("virtualserver_clientsonline").and_then(|v| v.parse().ok()).unwrap_or(0);
let channels_online = cmd.get("virtualserver_channelsonline").and_then(|v| v.parse().ok()).unwrap_or(0);
let client_id = cmd
.get("client_id")
.and_then(|v| v.parse().ok())
.unwrap_or(0);
let name = cmd
.get("virtualserver_name")
.unwrap_or_default()
.to_string();
let platform = cmd
.get("virtualserver_platform")
.unwrap_or_default()
.to_string();
let version = cmd
.get("virtualserver_version")
.unwrap_or_default()
.to_string();
let max_clients = cmd
.get("virtualserver_maxclients")
.and_then(|v| v.parse().ok())
.unwrap_or(0);
let clients_online = cmd
.get("virtualserver_clientsonline")
.and_then(|v| v.parse().ok())
.unwrap_or(0);
let channels_online = cmd
.get("virtualserver_channelsonline")
.and_then(|v| v.parse().ok())
.unwrap_or(0);
let _ = self.event_tx.send(SessionEvent::ServerInfo {
name,
platform,
version,
max_clients,
clients_online,
channels_online,
}).await;
let _ = self
.event_tx
.send(SessionEvent::ServerInfo {
name,
platform,
version,
max_clients,
clients_online,
channels_online,
})
.await;
Some(SessionEvent::Connected { client_id })
}
"channellist" => {
@@ -439,10 +493,16 @@ impl Session {
Some(SessionEvent::ClientMoved { clid, cid })
}
"notifytextmessage" => {
let invoker_id = cmd.get("invokerid").and_then(|v| v.parse().ok()).unwrap_or(0);
let invoker_id = cmd
.get("invokerid")
.and_then(|v| v.parse().ok())
.unwrap_or(0);
let invoker_name = cmd.get("invokername").unwrap_or_default().to_string();
let message = cmd.get("msg").unwrap_or_default().to_string();
let target_mode = cmd.get("targetmode").and_then(|v| v.parse().ok()).unwrap_or(0);
let target_mode = cmd
.get("targetmode")
.and_then(|v| v.parse().ok())
.unwrap_or(0);
Some(SessionEvent::TextMessage {
invoker_id,
invoker_name,
@@ -470,9 +530,15 @@ impl Session {
fn parse_client_list(cmd: &Command) -> Vec<ClientEntry> {
let clid = cmd.get("clid").and_then(|v| v.parse().ok()).unwrap_or(0);
let cid = cmd.get("cid").and_then(|v| v.parse().ok()).unwrap_or(0);
let client_database_id = cmd.get("client_database_id").and_then(|v| v.parse().ok()).unwrap_or(0);
let client_database_id = cmd
.get("client_database_id")
.and_then(|v| v.parse().ok())
.unwrap_or(0);
let client_nickname = cmd.get("client_nickname").unwrap_or_default().to_string();
let client_type = cmd.get("client_type").and_then(|v| v.parse().ok()).unwrap_or(0);
let client_type = cmd
.get("client_type")
.and_then(|v| v.parse().ok())
.unwrap_or(0);
vec![ClientEntry {
clid,
+6 -3
View File
@@ -110,13 +110,17 @@ impl CommandArgument {
value: None,
}
}
}
impl fmt::Display for CommandArgument {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match &self.value {
Some(value) => write!(f, "{}={}", escape::escape(&self.name), escape::escape(value)),
Some(value) => write!(
f,
"{}={}",
escape::escape(&self.name),
escape::escape(value)
),
None => write!(f, "{}", escape::escape(&self.name)),
}
}
@@ -226,7 +230,6 @@ impl Command {
})
.collect()
}
}
impl fmt::Display for Command {