Initial commit: split VNOX monorepo into VNOX-Server

Unified server binary (vnox-serverd) combining:
- Gateway (TCP, auth, channels, sessions, SQLite)
- Voice-node (UDP relay, Opus, jitter buffer)

Standalone gateway (vnox-gateway) and voice-node (vnox-voice-node) preserved as sub-crates.
This commit is contained in:
loki5512344 2026-07-09 11:52:56 +02:00
commit e751e0bf5b
94 changed files with 9771 additions and 0 deletions

View file

@ -0,0 +1,66 @@
use anyhow::Result;
use tracing::{debug, warn};
use crate::{
net::state::{BroadcastMsg, State},
proto::{ChatMessagePayload, ErrorCode, PacketId, encode_packet, to_payload},
};
pub async fn handle(session_id: &str, mut msg: ChatMessagePayload, state: &State) -> Result<()> {
let sess = match crate::domain::session::get(&state.sessions, session_id).await {
Some(s) => s,
None => return Ok(()),
};
if sess.channel_id.as_deref() != Some(&msg.channel_id) {
return Ok(());
}
// Rate limit check (token bucket per session).
if !state.rate_limiter.try_consume(session_id) {
state.metrics.inc(&state.metrics.rate_limited_events);
warn!("rate-limited session {session_id}");
let _ = state.broadcast.send(BroadcastMsg {
channel_id: None,
exclude_session: None,
target_session_id: Some(session_id.to_string()),
data: encode_packet(
PacketId::Error,
0,
&to_payload(&crate::proto::ErrorPayload {
code: ErrorCode::RateLimited as u32,
message: "you are sending messages too quickly".into(),
}),
),
});
return Ok(());
}
msg.sender_id = sess.user_id.clone();
if msg.timestamp == 0 {
msg.timestamp = now_ms();
}
state.storage.save_message(&msg).await?;
state.metrics.inc(&state.metrics.messages_sent);
let _ = state.broadcast.send(BroadcastMsg {
channel_id: Some(msg.channel_id.clone()),
exclude_session: Some(session_id.into()),
target_session_id: None,
data: encode_packet(PacketId::ChatMessage, 0, &to_payload(&msg)),
});
debug!(
"chat {} → {}: {}",
sess.nickname, msg.channel_id, msg.content
);
Ok(())
}
fn now_ms() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as i64
}

View file

@ -0,0 +1,93 @@
use anyhow::Result;
use tracing::debug;
use crate::{
domain::session,
net::state::{BroadcastMsg, State},
proto::{ChatMessagePayload, MessageDeletePayload, PacketId, encode_packet, to_payload},
};
pub async fn handle_message_edit(session_id: &str, payload: &[u8], state: &State) -> Result<()> {
let edit: crate::proto::MessageEditPayload = serde_json::from_slice(payload)?;
let sess = match session::get(&state.sessions, session_id).await {
Some(s) => s,
None => return Ok(()),
};
if sess.channel_id.as_deref() != Some(&edit.channel_id) {
return Ok(());
}
// Verify sender owns the message
let sender = state.storage.get_message_sender(&edit.message_id).await?;
if sender.as_deref() != Some(&sess.user_id) {
return Ok(());
}
let msg = state.storage.get_message(&edit.message_id).await?;
let msg = match msg {
Some(m) => m,
None => return Ok(()),
};
state
.storage
.edit_message(&edit.message_id, &edit.content)
.await?;
let broadcast_msg = ChatMessagePayload {
message_id: edit.message_id.clone(),
channel_id: edit.channel_id.clone(),
sender_id: sess.user_id.clone(),
content: edit.content,
timestamp: msg.timestamp,
edited: true,
reply_to: msg.reply_to.clone(),
};
let _ = state.broadcast.send(BroadcastMsg {
channel_id: Some(edit.channel_id.clone()),
exclude_session: Some(session_id.into()),
target_session_id: None,
data: encode_packet(PacketId::MessageEdit, 0, &to_payload(&broadcast_msg)),
});
debug!(
"edit {} {}:{}",
sess.nickname, edit.channel_id, edit.message_id
);
Ok(())
}
pub async fn handle_message_delete(session_id: &str, payload: &[u8], state: &State) -> Result<()> {
let delete: MessageDeletePayload = serde_json::from_slice(payload)?;
let sess = match session::get(&state.sessions, session_id).await {
Some(s) => s,
None => return Ok(()),
};
if sess.channel_id.as_deref() != Some(&delete.channel_id) {
return Ok(());
}
// Verify sender owns the message
let sender = state.storage.get_message_sender(&delete.message_id).await?;
if sender.as_deref() != Some(&sess.user_id) {
return Ok(());
}
state.storage.delete_message(&delete.message_id).await?;
let _ = state.broadcast.send(BroadcastMsg {
channel_id: Some(delete.channel_id.clone()),
exclude_session: Some(session_id.into()),
target_session_id: None,
data: encode_packet(PacketId::MessageDelete, 0, &to_payload(&delete)),
});
debug!(
"delete {} {}:{}",
sess.nickname, delete.channel_id, delete.message_id
);
Ok(())
}

View file

@ -0,0 +1,9 @@
pub mod chat;
pub mod message_edit;
pub mod presence;
pub mod reaction;
pub mod read_receipt;
pub mod typing;
pub use read_receipt::handle_read_receipt;
pub use typing::handle_typing_start;

View file

@ -0,0 +1,84 @@
use anyhow::Result;
use tokio::net::TcpStream;
use crate::{
domain::session,
net::{
io,
state::{BroadcastMsg, State},
},
proto::{
PacketId, PresenceEventPayload, PresenceInfo, PresenceSyncPayload, PresenceUpdatePayload,
SessionCrypto, encode_packet, to_payload,
},
};
pub async fn handle_presence_update(
_stream: &mut TcpStream,
seq: &mut u32,
session_id: &str,
payload: &[u8],
_crypto: &SessionCrypto,
state: &State,
) -> Result<()> {
let req: PresenceUpdatePayload = serde_json::from_slice(payload)?;
let sess = session::get(&state.sessions, session_id)
.await
.ok_or_else(|| anyhow::anyhow!("session not found"))?;
let info = PresenceInfo {
user_id: sess.user_id.clone(),
nickname: sess.nickname.clone(),
status: req.status.clone(),
activity_type: req.activity_type,
activity_text: req.activity_text,
};
state
.presences
.write()
.await
.insert(sess.user_id.clone(), info.clone());
// Broadcast to everyone (friends/guild-mates will filter client-side for now)
let event = PresenceEventPayload {
user_id: info.user_id,
nickname: info.nickname,
status: info.status,
activity_type: info.activity_type,
activity_text: info.activity_text,
};
let _ = state.broadcast.send(BroadcastMsg {
channel_id: None,
exclude_session: Some(session_id.into()),
target_session_id: None,
data: encode_packet(PacketId::PresenceEvent, *seq, &to_payload(&event)),
});
Ok(())
}
pub async fn handle_presence_sync(
stream: &mut TcpStream,
seq: &mut u32,
session_id: &str,
crypto: &SessionCrypto,
state: &State,
) -> Result<()> {
let _sess = session::get(&state.sessions, session_id)
.await
.ok_or_else(|| anyhow::anyhow!("session not found"))?;
let presences: Vec<PresenceInfo> = state.presences.read().await.values().cloned().collect();
io::send_encrypted(
stream,
PacketId::PresenceSync,
seq,
&to_payload(&PresenceSyncPayload { presences }),
crypto,
)
.await?;
Ok(())
}

View file

@ -0,0 +1,92 @@
use anyhow::Result;
use tracing::debug;
use crate::{
domain::session,
net::state::{BroadcastMsg, State},
proto::{PacketId, ReactionPayload, encode_packet, to_payload},
};
pub async fn handle_reaction_add(
session_id: &str,
mut payload: ReactionPayload,
state: &State,
) -> Result<()> {
let sess = match session::get(&state.sessions, session_id).await {
Some(s) => s,
None => return Ok(()),
};
if sess.channel_id.as_deref() != Some(&payload.channel_id) {
return Ok(());
}
// Verify message exists in the channel
let msg = state.storage.get_message(&payload.message_id).await?;
if msg.is_none() || msg.as_ref().unwrap().channel_id != payload.channel_id {
return Ok(());
}
payload.user_id = sess.user_id.clone();
state
.storage
.add_reaction(&payload.message_id, &sess.user_id, &payload.emoji)
.await?;
let _ = state.broadcast.send(BroadcastMsg {
channel_id: Some(payload.channel_id.clone()),
exclude_session: Some(session_id.into()),
target_session_id: None,
data: encode_packet(PacketId::MessageReactionAdd, 0, &to_payload(&payload)),
});
debug!(
"reaction {} +{} on {}",
sess.nickname, payload.emoji, payload.message_id
);
Ok(())
}
pub async fn handle_reaction_remove(
session_id: &str,
mut payload: ReactionPayload,
state: &State,
) -> Result<()> {
let sess = match session::get(&state.sessions, session_id).await {
Some(s) => s,
None => return Ok(()),
};
if sess.channel_id.as_deref() != Some(&payload.channel_id) {
return Ok(());
}
payload.user_id = sess.user_id.clone();
// Check user owns the reaction
let has = state
.storage
.has_user_reacted(&payload.message_id, &sess.user_id, &payload.emoji)
.await?;
if !has {
return Ok(());
}
state
.storage
.remove_reaction(&payload.message_id, &sess.user_id, &payload.emoji)
.await?;
let _ = state.broadcast.send(BroadcastMsg {
channel_id: Some(payload.channel_id.clone()),
exclude_session: Some(session_id.into()),
target_session_id: None,
data: encode_packet(PacketId::MessageReactionRemove, 0, &to_payload(&payload)),
});
debug!(
"reaction {} -{} on {}",
sess.nickname, payload.emoji, payload.message_id
);
Ok(())
}

View file

@ -0,0 +1,46 @@
use anyhow::Result;
use crate::{
domain::session,
net::state::{BroadcastMsg, State},
proto::{PacketId, ReadReceiptPayload, encode_packet, to_payload},
};
pub async fn handle_read_receipt(
_stream: &mut tokio::net::TcpStream,
seq: &mut u32,
session_id: &str,
payload: &[u8],
_crypto: &crate::proto::SessionCrypto,
state: &State,
) -> Result<()> {
let req: ReadReceiptPayload = serde_json::from_slice(payload)?;
let sess = match session::get(&state.sessions, session_id).await {
Some(s) => s,
None => return Ok(()),
};
state
.storage
.update_read_receipt(&req.channel_id, &sess.user_id, &req.last_read_message_id)
.await?;
let broadcast_data = serde_json::json!({
"channel_id": req.channel_id,
"user_id": sess.user_id,
"last_read_message_id": req.last_read_message_id,
});
let _ = state.broadcast.send(BroadcastMsg {
channel_id: Some(req.channel_id),
exclude_session: Some(session_id.into()),
target_session_id: None,
data: encode_packet(
PacketId::ReadReceiptBroadcast,
*seq,
&to_payload(&broadcast_data),
),
});
Ok(())
}

View file

@ -0,0 +1,30 @@
use anyhow::Result;
use crate::{
domain::session,
net::state::{BroadcastMsg, State},
proto::{PacketId, TypingStartPayload, encode_packet, to_payload},
};
pub async fn handle_typing_start(session_id: &str, payload: &[u8], state: &State) -> Result<()> {
let req: TypingStartPayload = serde_json::from_slice(payload)?;
let sess = match session::get(&state.sessions, session_id).await {
Some(s) => s,
None => return Ok(()),
};
let data = serde_json::json!({
"user_id": sess.user_id,
"nickname": sess.nickname,
"channel_id": req.channel_id,
});
let _ = state.broadcast.send(BroadcastMsg {
channel_id: Some(req.channel_id),
exclude_session: Some(session_id.into()),
target_session_id: None,
data: encode_packet(PacketId::TypingStart, 0, &to_payload(&data)),
});
Ok(())
}