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:
commit
e751e0bf5b
94 changed files with 9771 additions and 0 deletions
66
gateway/src/handler/content/chat.rs
Normal file
66
gateway/src/handler/content/chat.rs
Normal 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
|
||||
}
|
||||
93
gateway/src/handler/content/message_edit.rs
Normal file
93
gateway/src/handler/content/message_edit.rs
Normal 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(())
|
||||
}
|
||||
9
gateway/src/handler/content/mod.rs
Normal file
9
gateway/src/handler/content/mod.rs
Normal 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;
|
||||
84
gateway/src/handler/content/presence.rs
Normal file
84
gateway/src/handler/content/presence.rs
Normal 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(())
|
||||
}
|
||||
92
gateway/src/handler/content/reaction.rs
Normal file
92
gateway/src/handler/content/reaction.rs
Normal 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(())
|
||||
}
|
||||
46
gateway/src/handler/content/read_receipt.rs
Normal file
46
gateway/src/handler/content/read_receipt.rs
Normal 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(())
|
||||
}
|
||||
30
gateway/src/handler/content/typing.rs
Normal file
30
gateway/src/handler/content/typing.rs
Normal 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(())
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue