feat(protocol): classify poke notification strength
This commit is contained in:
@@ -41,6 +41,7 @@ use crate::dto::{
|
|||||||
ChannelId, ChannelInfo, ChatMessage, ClientId, ClientInfo, ClientProfile, MessageTarget,
|
ChannelId, ChannelInfo, ChatMessage, ClientId, ClientInfo, ClientProfile, MessageTarget,
|
||||||
ProtocolDelta, ServerActivity, ServerSnapshot,
|
ProtocolDelta, ServerActivity, ServerSnapshot,
|
||||||
};
|
};
|
||||||
|
use crate::poke_limiter::PokeLimiter;
|
||||||
use crate::ProtocolError;
|
use crate::ProtocolError;
|
||||||
|
|
||||||
const SPEAKING_ACTIVITY_WINDOW: Duration = Duration::from_millis(750);
|
const SPEAKING_ACTIVITY_WINDOW: Duration = Duration::from_millis(750);
|
||||||
@@ -713,6 +714,7 @@ async fn connection_task(
|
|||||||
// 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 voice_activity: HashMap<u64, Instant> = HashMap::new();
|
let mut voice_activity: HashMap<u64, Instant> = HashMap::new();
|
||||||
|
let mut poke_limiter = PokeLimiter::new();
|
||||||
|
|
||||||
// Main loop: pump events, service requests, forward voice.
|
// Main loop: pump events, service requests, forward voice.
|
||||||
loop {
|
loop {
|
||||||
@@ -742,6 +744,7 @@ async fn connection_task(
|
|||||||
&channels.activity,
|
&channels.activity,
|
||||||
&channels.delta,
|
&channels.delta,
|
||||||
&mut pending_moves,
|
&mut pending_moves,
|
||||||
|
&mut poke_limiter,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
Ok(Some(Err(e))) => {
|
Ok(Some(Err(e))) => {
|
||||||
@@ -841,6 +844,7 @@ async fn connection_task(
|
|||||||
&channels,
|
&channels,
|
||||||
&mut pending_moves,
|
&mut pending_moves,
|
||||||
&mut voice_activity,
|
&mut voice_activity,
|
||||||
|
&mut poke_limiter,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
let _ = reply.send(r);
|
let _ = reply.send(r);
|
||||||
@@ -903,6 +907,7 @@ fn handle_non_audio_stream_item(
|
|||||||
activity_tx: &mpsc::Sender<ServerActivity>,
|
activity_tx: &mpsc::Sender<ServerActivity>,
|
||||||
delta_tx: &mpsc::Sender<ProtocolDelta>,
|
delta_tx: &mpsc::Sender<ProtocolDelta>,
|
||||||
pending_moves: &mut PendingMoves,
|
pending_moves: &mut PendingMoves,
|
||||||
|
poke_limiter: &mut PokeLimiter,
|
||||||
) {
|
) {
|
||||||
match item {
|
match item {
|
||||||
StreamItem::BookEvents(events) => {
|
StreamItem::BookEvents(events) => {
|
||||||
@@ -964,19 +969,25 @@ fn handle_non_audio_stream_item(
|
|||||||
message,
|
message,
|
||||||
} = ev
|
} = ev
|
||||||
{
|
{
|
||||||
let mapped = match target {
|
let (mapped, poke_strength) = match target {
|
||||||
tsclientlib::MessageTarget::Server => MessageTarget::Server,
|
tsclientlib::MessageTarget::Server => (MessageTarget::Server, None),
|
||||||
tsclientlib::MessageTarget::Channel => MessageTarget::Channel,
|
tsclientlib::MessageTarget::Channel => (MessageTarget::Channel, None),
|
||||||
tsclientlib::MessageTarget::Client(id) => {
|
tsclientlib::MessageTarget::Client(id) => {
|
||||||
MessageTarget::Client(id.0 as u64)
|
(MessageTarget::Client(id.0 as u64), None)
|
||||||
|
}
|
||||||
|
tsclientlib::MessageTarget::Poke(id) => {
|
||||||
|
let own_client_id =
|
||||||
|
con.get_state().ok().map(|state| state.own_client.0 as u64);
|
||||||
|
let strength = poke_limiter.record(invoker.id.0 as u64, own_client_id);
|
||||||
|
(MessageTarget::Poke(id.0 as u64), Some(strength))
|
||||||
}
|
}
|
||||||
tsclientlib::MessageTarget::Poke(id) => MessageTarget::Poke(id.0 as u64),
|
|
||||||
};
|
};
|
||||||
let _ = chat_tx.try_send(ChatMessage {
|
let _ = chat_tx.try_send(ChatMessage {
|
||||||
sender_id: ClientId(invoker.id.0 as u64),
|
sender_id: ClientId(invoker.id.0 as u64),
|
||||||
sender_name: sanitize(&invoker.name),
|
sender_name: sanitize(&invoker.name),
|
||||||
message: sanitize(&message),
|
message: sanitize(&message),
|
||||||
target: mapped,
|
target: mapped,
|
||||||
|
poke_strength,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1164,6 +1175,7 @@ async fn fetch_client_profile(
|
|||||||
channels: &EventChannels,
|
channels: &EventChannels,
|
||||||
pending_moves: &mut PendingMoves,
|
pending_moves: &mut PendingMoves,
|
||||||
voice_activity: &mut HashMap<u64, Instant>,
|
voice_activity: &mut HashMap<u64, Instant>,
|
||||||
|
poke_limiter: &mut PokeLimiter,
|
||||||
) -> Result<ClientProfile, ProtocolError> {
|
) -> Result<ClientProfile, ProtocolError> {
|
||||||
let target_id = TsClientId(client_id as u16);
|
let target_id = TsClientId(client_id as u16);
|
||||||
|
|
||||||
@@ -1209,6 +1221,7 @@ async fn fetch_client_profile(
|
|||||||
channels,
|
channels,
|
||||||
pending_moves,
|
pending_moves,
|
||||||
voice_activity,
|
voice_activity,
|
||||||
|
poke_limiter,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
@@ -1219,6 +1232,7 @@ async fn fetch_client_profile(
|
|||||||
channels,
|
channels,
|
||||||
pending_moves,
|
pending_moves,
|
||||||
voice_activity,
|
voice_activity,
|
||||||
|
poke_limiter,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
@@ -1233,6 +1247,7 @@ async fn fetch_client_profile(
|
|||||||
channels,
|
channels,
|
||||||
pending_moves,
|
pending_moves,
|
||||||
voice_activity,
|
voice_activity,
|
||||||
|
poke_limiter,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
@@ -1251,6 +1266,7 @@ async fn fetch_client_profile(
|
|||||||
channels,
|
channels,
|
||||||
pending_moves,
|
pending_moves,
|
||||||
voice_activity,
|
voice_activity,
|
||||||
|
poke_limiter,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
@@ -1264,9 +1280,16 @@ async fn fetch_client_profile(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let db_info = if refresh_plan.needs_client_db_info {
|
let db_info = if refresh_plan.needs_client_db_info {
|
||||||
request_client_db_info(con, database_id, channels, pending_moves, voice_activity)
|
request_client_db_info(
|
||||||
.await
|
con,
|
||||||
.ok()
|
database_id,
|
||||||
|
channels,
|
||||||
|
pending_moves,
|
||||||
|
voice_activity,
|
||||||
|
poke_limiter,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
@@ -1426,6 +1449,7 @@ async fn request_messages(
|
|||||||
channels: &EventChannels,
|
channels: &EventChannels,
|
||||||
pending_moves: &mut PendingMoves,
|
pending_moves: &mut PendingMoves,
|
||||||
voice_activity: &mut HashMap<u64, Instant>,
|
voice_activity: &mut HashMap<u64, Instant>,
|
||||||
|
poke_limiter: &mut PokeLimiter,
|
||||||
) -> Result<Vec<InMessage>, ProtocolError> {
|
) -> Result<Vec<InMessage>, ProtocolError> {
|
||||||
let handle = command
|
let handle = command
|
||||||
.send_with_result(con)
|
.send_with_result(con)
|
||||||
@@ -1468,6 +1492,7 @@ async fn request_messages(
|
|||||||
&channels.activity,
|
&channels.activity,
|
||||||
&channels.delta,
|
&channels.delta,
|
||||||
pending_moves,
|
pending_moves,
|
||||||
|
poke_limiter,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1479,6 +1504,7 @@ async fn request_client_db_info(
|
|||||||
channels: &EventChannels,
|
channels: &EventChannels,
|
||||||
pending_moves: &mut PendingMoves,
|
pending_moves: &mut PendingMoves,
|
||||||
voice_activity: &mut HashMap<u64, Instant>,
|
voice_activity: &mut HashMap<u64, Instant>,
|
||||||
|
poke_limiter: &mut PokeLimiter,
|
||||||
) -> Result<InClientDbInfoPart, ProtocolError> {
|
) -> Result<InClientDbInfoPart, ProtocolError> {
|
||||||
let messages = request_messages(
|
let messages = request_messages(
|
||||||
con,
|
con,
|
||||||
@@ -1486,6 +1512,7 @@ async fn request_client_db_info(
|
|||||||
channels,
|
channels,
|
||||||
pending_moves,
|
pending_moves,
|
||||||
voice_activity,
|
voice_activity,
|
||||||
|
poke_limiter,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
for message in messages {
|
for message in messages {
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
pub use crate::poke_limiter::PokeStrength;
|
||||||
|
|
||||||
/// Opaque server-side channel identifier. Internal representation is
|
/// Opaque server-side channel identifier. Internal representation is
|
||||||
/// the upstream u64 but callers must treat it as opaque.
|
/// the upstream u64 but callers must treat it as opaque.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
@@ -54,6 +56,8 @@ pub struct ChatMessage {
|
|||||||
pub message: String,
|
pub message: String,
|
||||||
/// Target scope of this message.
|
/// Target scope of this message.
|
||||||
pub target: MessageTarget,
|
pub target: MessageTarget,
|
||||||
|
/// Strength classification for poke notifications.
|
||||||
|
pub poke_strength: Option<PokeStrength>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A server-activity notification derived from TeamSpeak bookkeeping events.
|
/// A server-activity notification derived from TeamSpeak bookkeeping events.
|
||||||
|
|||||||
@@ -35,12 +35,14 @@
|
|||||||
|
|
||||||
mod adapter;
|
mod adapter;
|
||||||
mod dto;
|
mod dto;
|
||||||
|
pub mod poke_limiter;
|
||||||
|
|
||||||
pub use adapter::{ConnectConfig, DisconnectReason, InboundVoice, ProtocolClient, SnapshotProbe};
|
pub use adapter::{ConnectConfig, DisconnectReason, InboundVoice, ProtocolClient, SnapshotProbe};
|
||||||
pub use dto::{
|
pub use dto::{
|
||||||
ChannelId, ChannelInfo, ChatMessage, ClientId, ClientInfo, ClientProfile, MessageTarget,
|
ChannelId, ChannelInfo, ChatMessage, ClientId, ClientInfo, ClientProfile, MessageTarget,
|
||||||
ProtocolDelta, ServerActivity, ServerSnapshot,
|
PokeStrength, ProtocolDelta, ServerActivity, ServerSnapshot,
|
||||||
};
|
};
|
||||||
|
pub use poke_limiter::PokeLimiter;
|
||||||
|
|
||||||
// Re-export the upstream voice types so chanora_audio can build outbound
|
// Re-export the upstream voice types so chanora_audio can build outbound
|
||||||
// voice packets without taking a direct dependency on tsclientlib /
|
// voice packets without taking a direct dependency on tsclientlib /
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
//! Per-connection poke strength classification.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
/// Notification strength assigned to an inbound poke.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub enum PokeStrength {
|
||||||
|
/// Poke should be surfaced at full strength.
|
||||||
|
Strong,
|
||||||
|
/// Poke is rate-limited but below overflow severity.
|
||||||
|
Suppressed,
|
||||||
|
/// Poke remains suppressed after repeated suppressed pokes.
|
||||||
|
SuppressedOverflow,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-connection poke limiter.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct PokeLimiter {
|
||||||
|
window: Duration,
|
||||||
|
entries: HashMap<u64, PokeEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct PokeEntry {
|
||||||
|
tokens: u8,
|
||||||
|
last_refill: Instant,
|
||||||
|
suppressed_in_window: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PokeLimiter {
|
||||||
|
const CAPACITY: u8 = 2;
|
||||||
|
const OVERFLOW_THRESHOLD: u8 = 3;
|
||||||
|
|
||||||
|
/// Create a limiter using the default five-minute refill interval.
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
window: Duration::from_secs(5 * 60),
|
||||||
|
entries: HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record a poke at the current instant.
|
||||||
|
pub fn record(&mut self, sender_id: u64, own_client_id: Option<u64>) -> PokeStrength {
|
||||||
|
self.record_at(sender_id, own_client_id, Instant::now())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record a poke at an injected instant.
|
||||||
|
pub fn record_at(
|
||||||
|
&mut self,
|
||||||
|
sender_id: u64,
|
||||||
|
own_client_id: Option<u64>,
|
||||||
|
now: Instant,
|
||||||
|
) -> PokeStrength {
|
||||||
|
if own_client_id == Some(sender_id) {
|
||||||
|
return PokeStrength::Suppressed;
|
||||||
|
}
|
||||||
|
|
||||||
|
let entry = self.entries.entry(sender_id).or_insert(PokeEntry {
|
||||||
|
tokens: Self::CAPACITY,
|
||||||
|
last_refill: now,
|
||||||
|
suppressed_in_window: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
if now.duration_since(entry.last_refill) >= self.window {
|
||||||
|
entry.tokens = Self::CAPACITY;
|
||||||
|
entry.last_refill = now;
|
||||||
|
entry.suppressed_in_window = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if entry.tokens > 0 {
|
||||||
|
entry.tokens -= 1;
|
||||||
|
return PokeStrength::Strong;
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.suppressed_in_window = entry.suppressed_in_window.saturating_add(1);
|
||||||
|
if entry.suppressed_in_window >= Self::OVERFLOW_THRESHOLD {
|
||||||
|
PokeStrength::SuppressedOverflow
|
||||||
|
} else {
|
||||||
|
PokeStrength::Suppressed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for PokeLimiter {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn first_two_pokes_are_strong_third_is_suppressed() {
|
||||||
|
let mut limiter = PokeLimiter::new();
|
||||||
|
let now = Instant::now();
|
||||||
|
|
||||||
|
assert_eq!(limiter.record_at(7, Some(1), now), PokeStrength::Strong);
|
||||||
|
assert_eq!(
|
||||||
|
limiter.record_at(7, Some(1), now + Duration::from_secs(1)),
|
||||||
|
PokeStrength::Strong
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
limiter.record_at(7, Some(1), now + Duration::from_secs(2)),
|
||||||
|
PokeStrength::Suppressed
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn self_poke_is_suppressed_without_consuming_token() {
|
||||||
|
let mut limiter = PokeLimiter::new();
|
||||||
|
let now = Instant::now();
|
||||||
|
|
||||||
|
assert_eq!(limiter.record_at(7, Some(7), now), PokeStrength::Suppressed);
|
||||||
|
assert_eq!(
|
||||||
|
limiter.record_at(7, Some(1), now + Duration::from_secs(1)),
|
||||||
|
PokeStrength::Strong
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
limiter.record_at(7, Some(1), now + Duration::from_secs(2)),
|
||||||
|
PokeStrength::Strong
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn overflow_after_three_suppressed_pokes_in_five_minutes() {
|
||||||
|
let mut limiter = PokeLimiter::new();
|
||||||
|
let now = Instant::now();
|
||||||
|
|
||||||
|
assert_eq!(limiter.record_at(7, Some(1), now), PokeStrength::Strong);
|
||||||
|
assert_eq!(
|
||||||
|
limiter.record_at(7, Some(1), now + Duration::from_secs(1)),
|
||||||
|
PokeStrength::Strong
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
limiter.record_at(7, Some(1), now + Duration::from_secs(2)),
|
||||||
|
PokeStrength::Suppressed
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
limiter.record_at(7, Some(1), now + Duration::from_secs(3)),
|
||||||
|
PokeStrength::Suppressed
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
limiter.record_at(7, Some(1), now + Duration::from_secs(4)),
|
||||||
|
PokeStrength::SuppressedOverflow
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn refill_after_interval_uses_injected_time_without_sleeping() {
|
||||||
|
let mut limiter = PokeLimiter::new();
|
||||||
|
let now = Instant::now();
|
||||||
|
|
||||||
|
assert_eq!(limiter.record_at(7, Some(1), now), PokeStrength::Strong);
|
||||||
|
assert_eq!(
|
||||||
|
limiter.record_at(7, Some(1), now + Duration::from_secs(1)),
|
||||||
|
PokeStrength::Strong
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
limiter.record_at(7, Some(1), now + Duration::from_secs(2)),
|
||||||
|
PokeStrength::Suppressed
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
limiter.record_at(7, Some(1), now + Duration::from_secs(5 * 60)),
|
||||||
|
PokeStrength::Strong
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
limiter.record_at(7, Some(1), now + Duration::from_secs(5 * 60 + 1)),
|
||||||
|
PokeStrength::Strong
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user