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,251 @@
use anyhow::Result;
use tokio::net::TcpStream;
use tracing::{info, warn};
use crate::{
domain::{channels, session},
net::{
io,
state::{BroadcastMsg, State},
},
proto::{
ChannelCreatePayload, ChannelDeletePayload, ChannelListItem, ChannelListPayload,
ChannelStatePayload, PacketId, SessionCrypto, encode_packet, to_payload,
},
};
/// Handle a ChannelCreate request — register a new channel in the store and
/// broadcast the new ChannelState to all sessions so their sidebars update.
pub async fn handle_channel_create(
stream: &mut TcpStream,
seq: &mut u32,
session_id: &str,
payload: &[u8],
crypto: &SessionCrypto,
state: &State,
) -> Result<()> {
let req: ChannelCreatePayload = serde_json::from_slice(payload)?;
let sess = match session::get(&state.sessions, session_id).await {
Some(s) => s,
None => return Ok(()),
};
// Validate kind.
let kind = match req.kind.as_str() {
"text" => channels::ChannelKind::Text,
"voice" => channels::ChannelKind::Voice,
other => {
warn!(
"channel_create from {}: invalid kind '{other}'",
sess.nickname
);
io::send_encrypted(
stream,
PacketId::Error,
seq,
&to_payload(&crate::proto::ErrorPayload {
code: crate::proto::ErrorCode::InvalidPacket as u32,
message: format!("invalid channel kind: {other}"),
}),
crypto,
)
.await?;
return Ok(());
}
};
let channel_id = req.channel_id.trim().to_string();
let channel_name = if req.channel_name.trim().is_empty() {
channel_id.clone()
} else {
req.channel_name.trim().to_string()
};
if channel_id.is_empty() {
io::send_encrypted(
stream,
PacketId::Error,
seq,
&to_payload(&crate::proto::ErrorPayload {
code: crate::proto::ErrorCode::InvalidPacket as u32,
message: "channel_id is required".into(),
}),
crypto,
)
.await?;
return Ok(());
}
// Limit channel-create rate (reuse the per-session token bucket — 1 token
// means 1 channel-create per rate window).
if !state.rate_limiter.try_consume(session_id) {
state.metrics.inc(&state.metrics.rate_limited_events);
warn!("rate-limited channel_create from {}", sess.nickname);
io::send_encrypted(
stream,
PacketId::Error,
seq,
&to_payload(&crate::proto::ErrorPayload {
code: crate::proto::ErrorCode::RateLimited as u32,
message: "slow down — too many channel operations".into(),
}),
crypto,
)
.await?;
return Ok(());
}
let created = channels::create(&state.channels, &channel_id, &channel_name, kind.clone()).await;
if !created {
io::send_encrypted(
stream,
PacketId::Error,
seq,
&to_payload(&crate::proto::ErrorPayload {
code: crate::proto::ErrorCode::ChannelNotFound as u32,
message: "channel already exists".into(),
}),
crypto,
)
.await?;
return Ok(());
}
info!(
"channel_create: {} created '{}' ({}) by {}",
channel_id,
channel_name,
kind.as_str(),
sess.nickname
);
// Reply to creator with ChannelState (no members yet).
let sp = ChannelStatePayload {
channel_id: channel_id.clone(),
channel_name: channel_name.clone(),
kind: kind.as_str().into(),
members: Vec::new(),
voice_endpoint: state.config.voice.bind.clone(),
};
io::send_encrypted(
stream,
PacketId::ChannelState,
seq,
&to_payload(&sp),
crypto,
)
.await?;
// Broadcast a ChannelCreate event to all other sessions so their sidebars update.
let _ = state.broadcast.send(BroadcastMsg {
channel_id: None,
exclude_session: Some(session_id.into()),
target_session_id: None,
data: encode_packet(
PacketId::ChannelCreate,
0,
&to_payload(&ChannelCreatePayload {
channel_id: channel_id.clone(),
channel_name: channel_name.clone(),
kind: kind.as_str().into(),
guild_id: req.guild_id.clone(),
}),
),
});
Ok(())
}
/// Handle a ChannelDelete request — remove the channel from the store and
/// broadcast the deletion to all sessions.
pub async fn handle_channel_delete(
stream: &mut TcpStream,
seq: &mut u32,
session_id: &str,
payload: &[u8],
crypto: &SessionCrypto,
state: &State,
) -> Result<()> {
let req: ChannelDeletePayload = serde_json::from_slice(payload)?;
let sess = match session::get(&state.sessions, session_id).await {
Some(s) => s,
None => return Ok(()),
};
// Protect default channels from deletion.
if req.channel_id == "general" || req.channel_id == "voice" {
io::send_encrypted(
stream,
PacketId::Error,
seq,
&to_payload(&crate::proto::ErrorPayload {
code: crate::proto::ErrorCode::PermissionDenied as u32,
message: "cannot delete default channels".into(),
}),
crypto,
)
.await?;
return Ok(());
}
let existed = channels::delete(&state.channels, &req.channel_id).await;
if !existed {
io::send_encrypted(
stream,
PacketId::Error,
seq,
&to_payload(&crate::proto::ErrorPayload {
code: crate::proto::ErrorCode::ChannelNotFound as u32,
message: "channel not found".into(),
}),
crypto,
)
.await?;
return Ok(());
}
info!(
"channel_delete: '{}' removed by {}",
req.channel_id, sess.nickname
);
// Broadcast deletion to all sessions.
let _ = state.broadcast.send(BroadcastMsg {
channel_id: None,
exclude_session: Some(session_id.into()),
target_session_id: None,
data: encode_packet(
PacketId::ChannelDelete,
0,
&to_payload(&ChannelDeletePayload {
channel_id: req.channel_id.clone(),
}),
),
});
Ok(())
}
/// Handle a ChannelList request — reply with all known channels.
pub async fn handle_channel_list(
stream: &mut TcpStream,
seq: &mut u32,
session_id: &str,
crypto: &SessionCrypto,
state: &State,
) -> Result<()> {
let _sess = session::get(&state.sessions, session_id).await;
let channels = channels::list(&state.channels).await;
let items: Vec<ChannelListItem> = channels
.iter()
.map(|c| ChannelListItem {
channel_id: c.id.clone(),
channel_name: c.name.clone(),
kind: c.kind.as_str().into(),
})
.collect();
let p = ChannelListPayload { channels: items };
io::send_encrypted(stream, PacketId::ChannelList, seq, &to_payload(&p), crypto).await?;
Ok(())
}

View file

@ -0,0 +1,115 @@
use anyhow::Result;
use tokio::net::TcpStream;
use tracing::info;
use crate::{
domain::{channels, session},
net::{
io,
state::{BroadcastMsg, State},
},
proto::{
self, ChannelStatePayload, ChatHistoryPayload, MemberInfo, PacketId, SessionCrypto,
UserJoinPayload, encode_packet, to_payload,
},
};
use super::{broadcast_leave, set_channel};
const HISTORY_LIMIT: i64 = 50;
pub async fn join(
stream: &mut TcpStream,
seq: &mut u32,
session_id: &str,
channel_id: &str,
crypto: &SessionCrypto,
state: &State,
) -> Result<()> {
let prev_channel = session::get(&state.sessions, session_id)
.await
.and_then(|s| s.channel_id);
if prev_channel.as_deref() == Some(channel_id) {
return Ok(());
}
if let Some(prev) = prev_channel {
channels::leave(&state.channels, &prev, session_id).await;
broadcast_leave(state, &prev, session_id).await;
}
let ch = match channels::get_channel(&state.channels, channel_id).await {
Some(c) => c,
None => {
io::send_encrypted(
stream,
PacketId::Error,
seq,
&to_payload(&proto::ErrorPayload {
code: proto::ErrorCode::ChannelNotFound as u32,
message: "not found".into(),
}),
crypto,
)
.await?;
return Ok(());
}
};
channels::join(&state.channels, channel_id, session_id).await;
set_channel(state, session_id, Some(channel_id.into())).await;
let mut members = Vec::new();
for sid in channels::members(&state.channels, channel_id).await {
if let Some(s) = session::get(&state.sessions, &sid).await {
members.push(MemberInfo {
user_id: s.user_id.clone(),
nickname: s.nickname.clone(),
in_voice: ch.kind == channels::ChannelKind::Voice,
});
}
}
let sp = ChannelStatePayload {
channel_id: ch.id.clone(),
channel_name: ch.name.clone(),
kind: ch.kind.as_str().into(),
members,
voice_endpoint: state.config.voice.bind.clone(),
};
io::send_encrypted(
stream,
PacketId::ChannelState,
seq,
&to_payload(&sp),
crypto,
)
.await?;
let history = state.storage.get_history(channel_id, HISTORY_LIMIT).await?;
if !history.is_empty() {
let hp = ChatHistoryPayload {
channel_id: channel_id.into(),
messages: history,
};
io::send_encrypted(stream, PacketId::ChatHistory, seq, &to_payload(&hp), crypto).await?;
}
if let Some(sess) = session::get(&state.sessions, session_id).await {
let jp = UserJoinPayload {
channel_id: channel_id.into(),
user_id: sess.user_id.clone(),
nickname: sess.nickname.clone(),
};
let _ = state.broadcast.send(BroadcastMsg {
channel_id: Some(channel_id.into()),
exclude_session: Some(session_id.into()),
target_session_id: None,
data: encode_packet(PacketId::UserJoin, 0, &to_payload(&jp)),
});
}
info!("session {} joined {channel_id}", &session_id[..8]);
Ok(())
}

View file

@ -0,0 +1,22 @@
use anyhow::Result;
use tokio::net::TcpStream;
use tracing::info;
use crate::{domain::channels, net::state::State, proto::SessionCrypto};
use super::{broadcast_leave, set_channel};
pub async fn leave(
_stream: &mut TcpStream,
_seq: &mut u32,
session_id: &str,
channel_id: &str,
_crypto: &SessionCrypto,
state: &State,
) -> Result<()> {
channels::leave(&state.channels, channel_id, session_id).await;
set_channel(state, session_id, None).await;
broadcast_leave(state, channel_id, session_id).await;
info!("session {} left {channel_id}", &session_id[..8]);
Ok(())
}

View file

@ -0,0 +1,33 @@
pub mod create;
pub mod join;
pub mod leave;
pub use create::{handle_channel_create, handle_channel_delete, handle_channel_list};
pub use join::join;
pub use leave::leave;
use crate::{
net::state::{BroadcastMsg, State},
proto::{PacketId, UserLeavePayload, encode_packet, to_payload},
};
pub async fn broadcast_leave(state: &State, channel_id: &str, session_id: &str) {
if let Some(sess) = crate::domain::session::get(&state.sessions, session_id).await {
let p = UserLeavePayload {
channel_id: channel_id.into(),
user_id: sess.user_id.clone(),
};
let _ = state.broadcast.send(BroadcastMsg {
channel_id: Some(channel_id.into()),
exclude_session: Some(session_id.into()),
target_session_id: None,
data: encode_packet(PacketId::UserLeave, 0, &to_payload(&p)),
});
}
}
async fn set_channel(state: &State, session_id: &str, ch: Option<String>) {
if let Some(s) = state.sessions.write().await.get_mut(session_id) {
s.channel_id = ch;
}
}

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(())
}

View file

@ -0,0 +1,39 @@
use anyhow::Result;
use tokio::net::TcpStream;
use crate::{
domain::session,
net::{io, state::BroadcastMsg, state::State},
proto::SessionCrypto,
};
pub async fn deliver_encrypted(
stream: &mut TcpStream,
seq: &mut u32,
session_id: &str,
msg: &BroadcastMsg,
crypto: &SessionCrypto,
state: &State,
) -> Result<()> {
if msg.exclude_session.as_deref() == Some(session_id) {
return Ok(());
}
if let Some(ref target) = msg.target_session_id {
if target != session_id {
return Ok(());
}
io::deliver_encrypted(stream, crypto, seq, &msg.data).await?;
return Ok(());
}
if let Some(ref ch) = msg.channel_id {
let in_ch = session::get(&state.sessions, session_id)
.await
.and_then(|s| s.channel_id)
.as_deref()
== Some(ch.as_str());
if !in_ch {
return Ok(());
}
}
io::deliver_encrypted(stream, crypto, seq, &msg.data).await
}

View file

@ -0,0 +1,46 @@
use anyhow::Result;
use tokio::net::TcpStream;
use crate::{
domain::session,
net::{io, state::State},
proto::{DmHistoryPayload, PacketId, SessionCrypto, to_payload},
};
pub async fn handle_dm_history(
stream: &mut TcpStream,
seq: &mut u32,
session_id: &str,
payload: &[u8],
crypto: &SessionCrypto,
state: &State,
) -> Result<()> {
let req: DmHistoryPayload = serde_json::from_slice(payload)?;
let sess = session::get(&state.sessions, session_id)
.await
.ok_or_else(|| anyhow::anyhow!("session not found"))?;
let my_id = sess.user_id.clone();
drop(sess);
let _other_id = state
.storage
.get_dm_user_id(&req.dm_id, &my_id)
.await?
.ok_or_else(|| anyhow::anyhow!("user not in DM"))?;
let limit = req.limit.unwrap_or(50);
let messages = state
.storage
.get_dm_messages(&req.dm_id, limit, req.search_query.as_deref(), None)
.await?;
let resp = DmHistoryPayload {
dm_id: req.dm_id,
messages,
search_query: None,
limit: None,
};
io::send_encrypted(stream, PacketId::DmHistory, seq, &to_payload(&resp), crypto).await?;
Ok(())
}

View file

@ -0,0 +1,7 @@
mod history;
mod send;
mod start;
pub use history::handle_dm_history;
pub use send::handle_dm_message;
pub use start::{handle_dm_read_ack, handle_dm_start};

View file

@ -0,0 +1,113 @@
use anyhow::Result;
use tokio::net::TcpStream;
use tracing::warn;
use crate::{
domain::session,
net::{
io,
state::{BroadcastMsg, State},
},
proto::{
self, DmMessagePayload, ErrorCode, PacketId, SessionCrypto, encode_packet, to_payload,
},
};
pub async fn handle_dm_message(
stream: &mut TcpStream,
seq: &mut u32,
session_id: &str,
payload: &[u8],
crypto: &SessionCrypto,
state: &State,
) -> Result<()> {
let msg: DmMessagePayload = serde_json::from_slice(payload)?;
let sess = session::get(&state.sessions, session_id)
.await
.ok_or_else(|| anyhow::anyhow!("session not found"))?;
let my_id = sess.user_id.clone();
drop(sess);
// 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 DM session {session_id}");
io::send_encrypted(
stream,
PacketId::Error,
seq,
&to_payload(&proto::ErrorPayload {
code: ErrorCode::RateLimited as u32,
message: "you are sending messages too quickly".into(),
}),
crypto,
)
.await?;
return Ok(());
}
let other_id = state
.storage
.get_dm_user_id(&msg.dm_id, &my_id)
.await?
.ok_or_else(|| anyhow::anyhow!("user not in DM"))?;
if state.storage.is_blocked(&other_id, &my_id).await? {
io::send_encrypted(
stream,
PacketId::Error,
seq,
&to_payload(&proto::ErrorPayload {
code: proto::ErrorCode::Blocked as u32,
message: "blocked".into(),
}),
crypto,
)
.await?;
return Ok(());
}
let saved = state
.storage
.save_dm_message(&msg.dm_id, &my_id, &msg.content)
.await?;
state.metrics.inc(&state.metrics.dm_messages_sent);
state
.storage
.increment_dm_unread(&msg.dm_id, &other_id)
.await?;
if let Some(recipient_sid) =
session::get_session_id_by_user_id(&state.sessions, &other_id).await
{
let data = encode_packet(
PacketId::DmMessage,
0,
&to_payload(&DmMessagePayload {
dm_id: msg.dm_id.clone(),
sender_id: my_id.clone(),
content: msg.content.clone(),
timestamp: saved.timestamp,
}),
);
let _ = state.broadcast.send(BroadcastMsg {
channel_id: None,
exclude_session: None,
target_session_id: Some(recipient_sid),
data,
});
} else {
warn!("dm recipient offline: {}", &other_id[..8]);
}
io::send_encrypted(
stream,
PacketId::DmMessage,
seq,
&to_payload(&saved),
crypto,
)
.await?;
Ok(())
}

View file

@ -0,0 +1,113 @@
use anyhow::Result;
use tokio::net::TcpStream;
use crate::{
domain::session,
net::{io, state::State},
proto::{self, DmStartPayload, DmStartResponsePayload, PacketId, SessionCrypto, to_payload},
};
pub async fn handle_dm_start(
stream: &mut TcpStream,
seq: &mut u32,
session_id: &str,
payload: &[u8],
crypto: &SessionCrypto,
state: &State,
) -> Result<()> {
let req: DmStartPayload = serde_json::from_slice(payload)?;
let sess = session::get(&state.sessions, session_id)
.await
.ok_or_else(|| anyhow::anyhow!("session not found"))?;
let my_id = sess.user_id.clone();
drop(sess);
if state
.storage
.is_blocked(&req.target_user_id, &my_id)
.await?
{
io::send_encrypted(
stream,
PacketId::Error,
seq,
&to_payload(&proto::ErrorPayload {
code: proto::ErrorCode::Blocked as u32,
message: "blocked".into(),
}),
crypto,
)
.await?;
return Ok(());
}
if req.target_user_id == my_id {
io::send_encrypted(
stream,
PacketId::Error,
seq,
&to_payload(&proto::ErrorPayload {
code: proto::ErrorCode::InvalidPacket as u32,
message: "cannot DM yourself".into(),
}),
crypto,
)
.await?;
return Ok(());
}
let (dm_id, unread_count) = state
.storage
.find_or_create_dm(&my_id, &req.target_user_id)
.await?;
let other_id = state
.storage
.get_dm_user_id(&dm_id, &my_id)
.await?
.ok_or_else(|| anyhow::anyhow!("user not in DM"))?;
let nickname = state
.storage
.get_dm_nickname(&other_id)
.await?
.unwrap_or_else(|| other_id[..8].to_string());
let messages = state
.storage
.get_dm_messages(&dm_id, 50, None, None)
.await?;
let resp = DmStartResponsePayload {
dm_id,
other_user_id: other_id,
other_nickname: nickname,
messages,
unread_count: unread_count as u32,
};
io::send_encrypted(stream, PacketId::DmStart, seq, &to_payload(&resp), crypto).await?;
Ok(())
}
pub async fn handle_dm_read_ack(
stream: &mut TcpStream,
seq: &mut u32,
session_id: &str,
payload: &[u8],
crypto: &SessionCrypto,
state: &State,
) -> Result<()> {
let req: serde_json::Value = serde_json::from_slice(payload)?;
let dm_id = req["dm_id"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("missing dm_id"))?;
let sess = session::get(&state.sessions, session_id)
.await
.ok_or_else(|| anyhow::anyhow!("session not found"))?;
let my_id = sess.user_id.clone();
drop(sess);
state.storage.reset_dm_unread(dm_id, &my_id).await?;
io::send_encrypted(stream, PacketId::DmReadAck, seq, b"{}", crypto).await?;
Ok(())
}

View file

@ -0,0 +1,187 @@
use anyhow::Result;
use tracing::debug;
use crate::{
net::io,
proto::{
ChatMessagePayload, JoinChannelPayload, LeaveChannelPayload, PacketId, PingPayload,
PongPayload, ReactionPayload, to_payload,
},
};
use super::{Ctx, channel, content, direct_message, friends, guild};
pub async fn dispatch(
ctx: &mut Ctx<'_>,
session_id: &str,
pid: PacketId,
payload: &[u8],
addr: std::net::SocketAddr,
) -> Result<()> {
let Ctx {
stream,
seq,
crypto,
state,
} = ctx;
match pid {
PacketId::Ping => {
let ping: PingPayload = serde_json::from_slice(payload)?;
io::send_encrypted(
stream,
PacketId::Pong,
seq,
&to_payload(&PongPayload {
timestamp: ping.timestamp,
}),
crypto,
)
.await?;
}
PacketId::JoinChannel => {
let m: JoinChannelPayload = serde_json::from_slice(payload)?;
channel::join(stream, seq, session_id, &m.channel_id, crypto, state).await?;
}
PacketId::LeaveChannel => {
let m: LeaveChannelPayload = serde_json::from_slice(payload)?;
channel::leave(stream, seq, session_id, &m.channel_id, crypto, state).await?;
}
PacketId::ChannelCreate => {
channel::handle_channel_create(stream, seq, session_id, payload, crypto, state).await?;
}
PacketId::ChannelDelete => {
channel::handle_channel_delete(stream, seq, session_id, payload, crypto, state).await?;
}
PacketId::ChannelList => {
channel::handle_channel_list(stream, seq, session_id, crypto, state).await?;
}
PacketId::ChatMessage => {
let m: ChatMessagePayload = serde_json::from_slice(payload)?;
content::chat::handle(session_id, m, state).await?;
}
PacketId::DmStart => {
direct_message::handle_dm_start(stream, seq, session_id, payload, crypto, state)
.await?;
}
PacketId::DmMessage => {
direct_message::handle_dm_message(stream, seq, session_id, payload, crypto, state)
.await?;
}
PacketId::DmHistory => {
direct_message::handle_dm_history(stream, seq, session_id, payload, crypto, state)
.await?;
}
PacketId::DmReadAck => {
direct_message::handle_dm_read_ack(stream, seq, session_id, payload, crypto, state)
.await?;
}
PacketId::GuildCreate => {
guild::handle_guild_create(stream, seq, session_id, payload, crypto, state).await?;
}
PacketId::GuildDelete => {
guild::handle_guild_delete(stream, seq, session_id, payload, crypto, state).await?;
}
PacketId::GuildList => {
guild::handle_guild_list(stream, seq, session_id, crypto, state).await?;
}
PacketId::GuildMemberJoin => {
guild::handle_guild_member_join(stream, seq, session_id, payload, crypto, state)
.await?;
}
PacketId::GuildMemberLeave => {
guild::handle_guild_member_leave(stream, seq, session_id, payload, crypto, state)
.await?;
}
PacketId::GuildMemberKick => {
guild::handle_guild_member_kick(stream, seq, session_id, payload, crypto, state)
.await?;
}
PacketId::RoleCreate => {
guild::handle_role_create(stream, seq, session_id, payload, crypto, state).await?;
}
PacketId::RoleDelete => {
guild::handle_role_delete(stream, seq, session_id, payload, crypto, state).await?;
}
PacketId::InviteCreate => {
guild::handle_invite_create(stream, seq, session_id, payload, crypto, state).await?;
}
PacketId::InviteAccept => {
guild::handle_invite_accept(stream, seq, session_id, payload, crypto, state).await?;
}
PacketId::InviteDelete => {
guild::handle_invite_delete(stream, seq, session_id, payload, crypto, state).await?;
}
PacketId::GuildAuditLogFetch => {
guild::handle_audit_log_fetch(stream, seq, session_id, payload, crypto, state).await?;
}
PacketId::GuildMemberListFetch => {
guild::handle_member_list_fetch(stream, seq, session_id, payload, crypto, state)
.await?;
}
PacketId::GuildRoleAssign => {
guild::handle_role_assign(stream, seq, session_id, payload, crypto, state).await?;
}
PacketId::GuildRoleUnassign => {
guild::handle_role_unassign(stream, seq, session_id, payload, crypto, state).await?;
}
PacketId::GuildRoleListFetch => {
guild::handle_role_list_fetch(stream, seq, session_id, payload, crypto, state).await?;
}
PacketId::PresenceUpdate => {
content::presence::handle_presence_update(
stream, seq, session_id, payload, crypto, state,
)
.await?;
}
PacketId::PresenceSync => {
content::presence::handle_presence_sync(stream, seq, session_id, crypto, state).await?;
}
PacketId::FriendRequest => {
friends::handle_friend_request(stream, seq, session_id, payload, crypto, state).await?;
}
PacketId::FriendAccept => {
friends::handle_friend_accept(stream, seq, session_id, payload, crypto, state).await?;
}
PacketId::FriendDecline => {
friends::handle_friend_decline(stream, seq, session_id, payload, crypto, state).await?;
}
PacketId::FriendRemove => {
friends::handle_friend_remove(stream, seq, session_id, payload, crypto, state).await?;
}
PacketId::FriendList => {
friends::handle_friend_list(stream, seq, session_id, crypto, state).await?;
}
PacketId::BlockUser => {
friends::handle_block_user(stream, seq, session_id, payload, crypto, state).await?;
}
PacketId::UnblockUser => {
friends::handle_unblock_user(stream, seq, session_id, payload, crypto, state).await?;
}
PacketId::BlockList => {
friends::handle_block_list(stream, seq, session_id, crypto, state).await?;
}
PacketId::MessageReactionAdd => {
let m: ReactionPayload = serde_json::from_slice(payload)?;
content::reaction::handle_reaction_add(session_id, m, state).await?;
}
PacketId::MessageReactionRemove => {
let m: ReactionPayload = serde_json::from_slice(payload)?;
content::reaction::handle_reaction_remove(session_id, m, state).await?;
}
PacketId::MessageEdit => {
content::message_edit::handle_message_edit(session_id, payload, state).await?;
}
PacketId::MessageDelete => {
content::message_edit::handle_message_delete(session_id, payload, state).await?;
}
PacketId::TypingStart => {
content::handle_typing_start(session_id, payload, state).await?;
}
PacketId::ReadReceipt => {
content::handle_read_receipt(stream, seq, session_id, payload, crypto, state).await?;
}
PacketId::Disconnect => debug!("{addr} DISCONNECT"),
other => debug!("{addr} unhandled {:?}", other),
}
Ok(())
}

View file

@ -0,0 +1,37 @@
use anyhow::Result;
use tokio::net::TcpStream;
use crate::{
domain::session,
net::io,
proto::{FriendDeclinePayload, PacketId, SessionCrypto, to_payload},
};
pub async fn handle_friend_decline(
stream: &mut TcpStream,
seq: &mut u32,
session_id: &str,
payload: &[u8],
crypto: &SessionCrypto,
state: &crate::net::state::State,
) -> Result<()> {
let req: FriendDeclinePayload = serde_json::from_slice(payload)?;
let sess = session::get(&state.sessions, session_id)
.await
.ok_or_else(|| anyhow::anyhow!("session not found"))?;
state
.storage
.decline_friend_request(&req.from_user_id, &sess.user_id)
.await?;
io::send_encrypted(
stream,
PacketId::FriendDecline,
seq,
&to_payload(&serde_json::json!({"status": "DECLINED"})),
crypto,
)
.await?;
Ok(())
}

View file

@ -0,0 +1,47 @@
use anyhow::Result;
use tokio::net::TcpStream;
use crate::{
domain::session,
net::{io, state::State},
proto::{FriendInfo, FriendListPayload, PacketId, SessionCrypto, to_payload},
};
pub async fn handle_friend_list(
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 friend_ids = state.storage.list_friends(&sess.user_id).await?;
let mut friends = Vec::new();
let presences = state.presences.read().await;
for fid in &friend_ids {
let nick = state.storage.get_nickname(fid).await?.unwrap_or_default();
let presence = presences.get(fid);
friends.push(FriendInfo {
user_id: fid.clone(),
nickname: nick,
status: presence
.map(|p| p.status.clone())
.unwrap_or_else(|| "OFFLINE".into()),
since: 0,
});
}
io::send_encrypted(
stream,
PacketId::FriendList,
seq,
&to_payload(&FriendListPayload { friends }),
crypto,
)
.await?;
Ok(())
}

View file

@ -0,0 +1,122 @@
use anyhow::Result;
use tokio::net::TcpStream;
use crate::{
domain::session,
net::{io, state::State},
proto::{
BlockListPayload, BlockUserPayload, FriendRemovePayload, PacketId, SessionCrypto,
UnblockUserPayload, to_payload,
},
};
pub async fn handle_friend_remove(
stream: &mut TcpStream,
seq: &mut u32,
session_id: &str,
payload: &[u8],
crypto: &SessionCrypto,
state: &State,
) -> Result<()> {
let req: FriendRemovePayload = serde_json::from_slice(payload)?;
let sess = session::get(&state.sessions, session_id)
.await
.ok_or_else(|| anyhow::anyhow!("session not found"))?;
state
.storage
.remove_friend(&sess.user_id, &req.user_id)
.await?;
io::send_encrypted(
stream,
PacketId::FriendRemove,
seq,
&to_payload(&serde_json::json!({"removed": true})),
crypto,
)
.await?;
Ok(())
}
pub async fn handle_block_user(
stream: &mut TcpStream,
seq: &mut u32,
session_id: &str,
payload: &[u8],
crypto: &SessionCrypto,
state: &State,
) -> Result<()> {
let req: BlockUserPayload = serde_json::from_slice(payload)?;
let sess = session::get(&state.sessions, session_id)
.await
.ok_or_else(|| anyhow::anyhow!("session not found"))?;
state
.storage
.block_user(&sess.user_id, &req.user_id)
.await?;
io::send_encrypted(
stream,
PacketId::BlockUser,
seq,
&to_payload(&serde_json::json!({"blocked": true})),
crypto,
)
.await?;
Ok(())
}
pub async fn handle_unblock_user(
stream: &mut TcpStream,
seq: &mut u32,
session_id: &str,
payload: &[u8],
crypto: &SessionCrypto,
state: &State,
) -> Result<()> {
let req: UnblockUserPayload = serde_json::from_slice(payload)?;
let sess = session::get(&state.sessions, session_id)
.await
.ok_or_else(|| anyhow::anyhow!("session not found"))?;
state
.storage
.unblock_user(&sess.user_id, &req.user_id)
.await?;
io::send_encrypted(
stream,
PacketId::UnblockUser,
seq,
&to_payload(&serde_json::json!({"unblocked": true})),
crypto,
)
.await?;
Ok(())
}
pub async fn handle_block_list(
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 blocked = state.storage.list_blocks(&sess.user_id).await?;
io::send_encrypted(
stream,
PacketId::BlockList,
seq,
&to_payload(&BlockListPayload { blocked }),
crypto,
)
.await?;
Ok(())
}

View file

@ -0,0 +1,37 @@
mod decline;
mod list;
mod manage;
mod requests;
use anyhow::Result;
use tokio::net::TcpStream;
use crate::{
net::io,
proto::{ErrorPayload, PacketId, SessionCrypto, to_payload},
};
async fn send_err(
stream: &mut TcpStream,
seq: &mut u32,
code: crate::proto::ErrorCode,
msg: &str,
crypto: &SessionCrypto,
) -> Result<()> {
io::send_encrypted(
stream,
PacketId::Error,
seq,
&to_payload(&ErrorPayload {
code: code as u32,
message: msg.into(),
}),
crypto,
)
.await
}
pub use decline::handle_friend_decline;
pub use list::handle_friend_list;
pub use manage::{handle_block_list, handle_block_user, handle_friend_remove, handle_unblock_user};
pub use requests::{handle_friend_accept, handle_friend_request};

View file

@ -0,0 +1,152 @@
use anyhow::Result;
use tokio::net::TcpStream;
use crate::{
domain::session,
net::{
io,
state::{BroadcastMsg, State},
},
proto::{
FriendAcceptPayload, FriendEventPayload, FriendRequestPayload, PacketId, SessionCrypto,
encode_packet, to_payload,
},
};
use super::send_err;
pub async fn handle_friend_request(
stream: &mut TcpStream,
seq: &mut u32,
session_id: &str,
payload: &[u8],
crypto: &SessionCrypto,
state: &State,
) -> Result<()> {
let req: FriendRequestPayload = serde_json::from_slice(payload)?;
let sess = session::get(&state.sessions, session_id)
.await
.ok_or_else(|| anyhow::anyhow!("session not found"))?;
if state
.storage
.is_blocked(&req.to_user_id, &sess.user_id)
.await?
{
send_err(
stream,
seq,
crate::proto::ErrorCode::Blocked,
"you are blocked by this user",
crypto,
)
.await?;
return Ok(());
}
if state
.storage
.is_friend(&sess.user_id, &req.to_user_id)
.await?
{
send_err(
stream,
seq,
crate::proto::ErrorCode::InvalidPacket,
"already friends",
crypto,
)
.await?;
return Ok(());
}
let ok = state
.storage
.create_friend_request(&sess.user_id, &req.to_user_id)
.await?;
if !ok {
send_err(
stream,
seq,
crate::proto::ErrorCode::InvalidPacket,
"already friends or request pending",
crypto,
)
.await?;
return Ok(());
}
let ev = FriendEventPayload {
event: "REQUEST_RECEIVED".into(),
user_id: sess.user_id.clone(),
nickname: Some(sess.nickname.clone()),
};
let _ = state.broadcast.send(BroadcastMsg {
channel_id: None,
exclude_session: None,
target_session_id: Some(req.to_user_id.clone()),
data: encode_packet(PacketId::FriendRequest, *seq, &to_payload(&ev)),
});
io::send_encrypted(
stream,
PacketId::FriendRequest,
seq,
&to_payload(&serde_json::json!({"status": "PENDING", "to_user_id": req.to_user_id})),
crypto,
)
.await?;
Ok(())
}
pub async fn handle_friend_accept(
stream: &mut TcpStream,
seq: &mut u32,
session_id: &str,
payload: &[u8],
crypto: &SessionCrypto,
state: &State,
) -> Result<()> {
let req: FriendAcceptPayload = serde_json::from_slice(payload)?;
let sess = session::get(&state.sessions, session_id)
.await
.ok_or_else(|| anyhow::anyhow!("session not found"))?;
let ok = state
.storage
.accept_friend_request(&req.from_user_id, &sess.user_id)
.await?;
if !ok {
send_err(
stream,
seq,
crate::proto::ErrorCode::InvalidPacket,
"no pending request",
crypto,
)
.await?;
return Ok(());
}
let ev = FriendEventPayload {
event: "REQUEST_ACCEPTED".into(),
user_id: sess.user_id.clone(),
nickname: Some(sess.nickname.clone()),
};
let _ = state.broadcast.send(BroadcastMsg {
channel_id: None,
exclude_session: None,
target_session_id: Some(req.from_user_id.clone()),
data: encode_packet(PacketId::FriendAccept, *seq, &to_payload(&ev)),
});
io::send_encrypted(
stream,
PacketId::FriendAccept,
seq,
&to_payload(&serde_json::json!({"status": "ACCEPTED", "from_user_id": req.from_user_id})),
crypto,
)
.await?;
Ok(())
}

View file

@ -0,0 +1,86 @@
use anyhow::Result;
use tokio::net::TcpStream;
use crate::{
domain::permissions::Permissions,
net::{io, state::State},
proto::{
AuditLogEntryPayload, GuildAuditLogFetchPayload, GuildAuditLogPayload, PacketId,
SessionCrypto, to_payload,
},
};
/// Fetch audit log entries for a guild (admin-only: requires VIEW_AUDIT_LOG or owner).
pub async fn handle_audit_log_fetch(
stream: &mut TcpStream,
seq: &mut u32,
session_id: &str,
payload: &[u8],
crypto: &SessionCrypto,
state: &State,
) -> Result<()> {
let req: GuildAuditLogFetchPayload = serde_json::from_slice(payload)?;
let sess = match crate::domain::session::get(&state.sessions, session_id).await {
Some(s) => s,
None => return Ok(()),
};
// Permission check: owner always passes; otherwise require VIEW_AUDIT_LOG.
// We reuse the existing helper via the same logic.
let allowed = if let Some(g) = state.storage.get_guild(&req.guild_id).await?
&& g.owner_id == sess.user_id
{
true
} else {
// No VIEW_AUDIT_LOG bit defined yet — fall back to MANAGE_GUILD.
let perms = state
.storage
.get_user_role_perms(&req.guild_id, &sess.user_id)
.await?;
Permissions::from_role_perms(&perms).has(Permissions::MANAGE_GUILD)
};
if !allowed {
io::send_encrypted(
stream,
PacketId::Error,
seq,
&to_payload(&crate::proto::ErrorPayload {
code: crate::proto::ErrorCode::PermissionDenied as u32,
message: "audit log access requires MANAGE_GUILD".into(),
}),
crypto,
)
.await?;
return Ok(());
}
let limit = req.limit.clamp(1, 200);
let rows = state.storage.get_audit_log(&req.guild_id, limit).await?;
let entries: Vec<AuditLogEntryPayload> = rows
.into_iter()
.map(|r| AuditLogEntryPayload {
id: r.id,
guild_id: r.guild_id,
actor_id: r.actor_id,
action: r.action,
target_id: r.target_id,
target_type: r.target_type,
reason: r.reason,
created_at: r.created_at,
})
.collect();
let resp = GuildAuditLogPayload {
guild_id: req.guild_id,
entries,
};
io::send_encrypted(
stream,
PacketId::GuildAuditLog,
seq,
&to_payload(&resp),
crypto,
)
.await?;
Ok(())
}

View file

@ -0,0 +1,124 @@
use anyhow::Result;
use tokio::net::TcpStream;
use tracing::debug;
use crate::{
domain::session,
net::{io, state::State},
proto::{GuildCreatePayload, GuildInfo, PacketId, SessionCrypto, to_payload},
};
use super::send_err;
pub async fn handle_guild_create(
stream: &mut TcpStream,
seq: &mut u32,
session_id: &str,
payload: &[u8],
crypto: &SessionCrypto,
state: &State,
) -> Result<()> {
let req: GuildCreatePayload = serde_json::from_slice(payload)?;
let sess = session::get(&state.sessions, session_id)
.await
.ok_or_else(|| anyhow::anyhow!("session not found"))?;
if req.name.len() < 2 || req.name.len() > 100 {
send_err(
stream,
seq,
crate::proto::ErrorCode::InvalidPacket,
"guild name must be 2-100 chars",
crypto,
)
.await?;
return Ok(());
}
let guild_id = state.storage.create_guild(&sess.user_id, &req.name).await?;
let guild = state
.storage
.get_guild(&guild_id)
.await?
.ok_or_else(|| anyhow::anyhow!("guild not found after create"))?;
let info = GuildInfo {
id: guild.id.clone(),
owner_id: guild.owner_id,
name: guild.name,
member_count: guild.member_count,
created_at: guild.created_at,
};
io::send_encrypted(
stream,
PacketId::GuildCreate,
seq,
&to_payload(&info),
crypto,
)
.await?;
debug!(
"guild: {} created {} (owner={})",
sess.nickname, guild_id, sess.user_id
);
Ok(())
}
pub async fn handle_guild_delete(
stream: &mut TcpStream,
seq: &mut u32,
session_id: &str,
payload: &[u8],
crypto: &SessionCrypto,
state: &State,
) -> Result<()> {
#[derive(serde::Deserialize)]
struct Req {
guild_id: String,
}
let req: Req = serde_json::from_slice(payload)?;
let sess = session::get(&state.sessions, session_id)
.await
.ok_or_else(|| anyhow::anyhow!("session not found"))?;
let guild = state
.storage
.get_guild(&req.guild_id)
.await?
.ok_or_else(|| anyhow::anyhow!("guild not found"))?;
if guild.owner_id != sess.user_id {
send_err(
stream,
seq,
crate::proto::ErrorCode::PermissionDenied,
"only owner can delete guild",
crypto,
)
.await?;
return Ok(());
}
state.storage.delete_guild(&req.guild_id).await?;
state
.storage
.append_audit_log(
&req.guild_id,
&sess.user_id,
"GUILD_DELETE",
None,
None,
None,
)
.await?;
io::send_encrypted(
stream,
PacketId::GuildDelete,
seq,
&to_payload(&serde_json::json!({"guild_id": req.guild_id})),
crypto,
)
.await?;
Ok(())
}

View file

@ -0,0 +1,219 @@
use anyhow::Result;
use tokio::net::TcpStream;
use crate::{
domain::{permissions::Permissions, session},
net::{io, state::State},
proto::{
InviteAcceptPayload, InviteCreatePayload, InviteDeletePayload, InviteInfo, PacketId,
SessionCrypto, to_payload,
},
};
use super::{now_ms, send_err};
pub async fn handle_invite_create(
stream: &mut TcpStream,
seq: &mut u32,
session_id: &str,
payload: &[u8],
crypto: &SessionCrypto,
state: &State,
) -> Result<()> {
let req: InviteCreatePayload = serde_json::from_slice(payload)?;
let sess = session::get(&state.sessions, session_id)
.await
.ok_or_else(|| anyhow::anyhow!("session not found"))?;
if !super::require_perm(
state,
&req.guild_id,
&sess.user_id,
Permissions::CREATE_INVITE,
)
.await?
{
send_err(
stream,
seq,
crate::proto::ErrorCode::PermissionDenied,
"missing CREATE_INVITE permission",
crypto,
)
.await?;
return Ok(());
}
let inv = state
.storage
.create_invite(
&req.guild_id,
&sess.user_id,
req.max_uses,
req.expires_in_seconds,
)
.await?;
state
.storage
.append_audit_log(
&req.guild_id,
&sess.user_id,
"INVITE_CREATE",
Some(&inv.id),
Some("invite"),
None,
)
.await?;
let info = InviteInfo {
id: inv.id,
guild_id: inv.guild_id,
guild_name: inv.guild_name,
code: inv.code,
creator_id: inv.creator_id,
max_uses: inv.max_uses,
uses: inv.uses,
expires_at: inv.expires_at,
created_at: inv.created_at,
};
io::send_encrypted(
stream,
PacketId::InviteCreate,
seq,
&to_payload(&info),
crypto,
)
.await?;
Ok(())
}
pub async fn handle_invite_accept(
stream: &mut TcpStream,
seq: &mut u32,
session_id: &str,
payload: &[u8],
crypto: &SessionCrypto,
state: &State,
) -> Result<()> {
let req: InviteAcceptPayload = serde_json::from_slice(payload)?;
let sess = session::get(&state.sessions, session_id)
.await
.ok_or_else(|| anyhow::anyhow!("session not found"))?;
let inv = state
.storage
.get_invite_by_code(&req.code)
.await?
.ok_or_else(|| anyhow::anyhow!("invite not found"))?;
if let Some(exp) = inv.expires_at {
let now = now_ms();
if now > exp {
send_err(
stream,
seq,
crate::proto::ErrorCode::InvalidPacket,
"invite expired",
crypto,
)
.await?;
return Ok(());
}
}
if let Some(max) = inv.max_uses
&& inv.uses >= max
{
send_err(
stream,
seq,
crate::proto::ErrorCode::InvalidPacket,
"invite max uses reached",
crypto,
)
.await?;
return Ok(());
}
state
.storage
.add_guild_member(&inv.guild_id, &sess.user_id)
.await?;
state.storage.use_invite(&inv.id).await?;
state
.storage
.append_audit_log(
&inv.guild_id,
&sess.user_id,
"MEMBER_JOIN_INVITE",
Some(&sess.user_id),
Some("member"),
None,
)
.await?;
io::send_encrypted(
stream,
PacketId::InviteAccept,
seq,
&to_payload(&serde_json::json!({"guild_id": inv.guild_id, "guild_name": inv.guild_name})),
crypto,
)
.await?;
Ok(())
}
pub async fn handle_invite_delete(
stream: &mut TcpStream,
seq: &mut u32,
session_id: &str,
payload: &[u8],
crypto: &SessionCrypto,
state: &State,
) -> Result<()> {
let req: InviteDeletePayload = serde_json::from_slice(payload)?;
let sess = session::get(&state.sessions, session_id)
.await
.ok_or_else(|| anyhow::anyhow!("session not found"))?;
if !super::require_perm(
state,
&req.guild_id,
&sess.user_id,
Permissions::MANAGE_GUILD,
)
.await?
{
send_err(
stream,
seq,
crate::proto::ErrorCode::PermissionDenied,
"missing MANAGE_GUILD permission",
crypto,
)
.await?;
return Ok(());
}
state.storage.delete_invite(&req.invite_id).await?;
state
.storage
.append_audit_log(
&req.guild_id,
&sess.user_id,
"INVITE_DELETE",
Some(&req.invite_id),
Some("invite"),
None,
)
.await?;
io::send_encrypted(
stream,
PacketId::InviteDelete,
seq,
&to_payload(&serde_json::json!({"invite_id": req.invite_id})),
crypto,
)
.await?;
Ok(())
}

View file

@ -0,0 +1,61 @@
use anyhow::Result;
use tokio::net::TcpStream;
use crate::{
domain::session,
net::{io, state::State},
proto::{GuildInfo, GuildListPayload, PacketId, SessionCrypto, to_payload},
};
pub async fn handle_guild_list(
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 rows = state.storage.list_user_guilds(&sess.user_id).await?;
let guilds: Vec<GuildInfo> = rows
.iter()
.map(|g| GuildInfo {
id: g.id.clone(),
owner_id: g.owner_id.clone(),
name: g.name.clone(),
member_count: g.member_count,
created_at: g.created_at,
})
.collect();
for g in &rows {
let roles = state.storage.get_user_roles(&g.id, &sess.user_id).await?;
let color = roles
.iter()
.max_by_key(|r| r.position)
.map(|r| r.color.clone())
.unwrap_or_else(|| "#ffffff".into());
io::send_encrypted(
stream,
PacketId::UserRoleUpdate,
seq,
&to_payload(
&serde_json::json!({"user_id": sess.user_id, "guild_id": g.id, "color": color}),
),
crypto,
)
.await?;
}
io::send_encrypted(
stream,
PacketId::GuildList,
seq,
&to_payload(&GuildListPayload { guilds }),
crypto,
)
.await?;
Ok(())
}

View file

@ -0,0 +1,154 @@
use anyhow::Result;
use tokio::net::TcpStream;
use crate::{
domain::{permissions::Permissions, session},
net::{io, state::State},
proto::{
GuildMemberJoinPayload, GuildMemberKickPayload, GuildMemberLeavePayload, PacketId,
SessionCrypto, to_payload,
},
};
use super::send_err;
pub async fn handle_guild_member_join(
stream: &mut TcpStream,
seq: &mut u32,
session_id: &str,
payload: &[u8],
crypto: &SessionCrypto,
state: &State,
) -> Result<()> {
let req: GuildMemberJoinPayload = serde_json::from_slice(payload)?;
let sess = session::get(&state.sessions, session_id)
.await
.ok_or_else(|| anyhow::anyhow!("session not found"))?;
state
.storage
.add_guild_member(&req.guild_id, &sess.user_id)
.await?;
let roles = state
.storage
.get_user_roles(&req.guild_id, &sess.user_id)
.await?;
let color = roles
.iter()
.max_by_key(|r| r.position)
.map(|r| r.color.clone())
.unwrap_or_else(|| "#ffffff".into());
io::send_encrypted(
stream,
PacketId::GuildMemberJoin,
seq,
&to_payload(&serde_json::json!({"guild_id": req.guild_id, "user_id": sess.user_id})),
crypto,
)
.await?;
io::send_encrypted(
stream,
PacketId::UserRoleUpdate,
seq,
&to_payload(
&serde_json::json!({"user_id": sess.user_id, "guild_id": req.guild_id, "color": color}),
),
crypto,
)
.await?;
Ok(())
}
pub async fn handle_guild_member_leave(
stream: &mut TcpStream,
seq: &mut u32,
session_id: &str,
payload: &[u8],
crypto: &SessionCrypto,
state: &State,
) -> Result<()> {
let req: GuildMemberLeavePayload = serde_json::from_slice(payload)?;
let sess = session::get(&state.sessions, session_id)
.await
.ok_or_else(|| anyhow::anyhow!("session not found"))?;
let target = if req.user_id.is_empty() {
&sess.user_id
} else {
&req.user_id
};
state
.storage
.remove_guild_member(&req.guild_id, target)
.await?;
io::send_encrypted(
stream,
PacketId::GuildMemberLeave,
seq,
&to_payload(&serde_json::json!({"guild_id": req.guild_id, "user_id": target})),
crypto,
)
.await?;
Ok(())
}
pub async fn handle_guild_member_kick(
stream: &mut TcpStream,
seq: &mut u32,
session_id: &str,
payload: &[u8],
crypto: &SessionCrypto,
state: &State,
) -> Result<()> {
let req: GuildMemberKickPayload = serde_json::from_slice(payload)?;
let sess = session::get(&state.sessions, session_id)
.await
.ok_or_else(|| anyhow::anyhow!("session not found"))?;
if !super::require_perm(
state,
&req.guild_id,
&sess.user_id,
Permissions::KICK_MEMBERS,
)
.await?
{
send_err(
stream,
seq,
crate::proto::ErrorCode::PermissionDenied,
"missing KICK_MEMBERS permission",
crypto,
)
.await?;
return Ok(());
}
state
.storage
.remove_guild_member(&req.guild_id, &req.user_id)
.await?;
state
.storage
.append_audit_log(
&req.guild_id,
&sess.user_id,
"MEMBER_KICK",
Some(&req.user_id),
Some("member"),
None,
)
.await?;
io::send_encrypted(
stream,
PacketId::GuildMemberKick,
seq,
&to_payload(&serde_json::json!({"guild_id": req.guild_id, "user_id": req.user_id})),
crypto,
)
.await?;
Ok(())
}

View file

@ -0,0 +1,230 @@
use anyhow::Result;
use tokio::net::TcpStream;
use crate::{
domain::{permissions::Permissions, session},
net::{io, state::State},
proto::{
GuildMemberInfoPayload, GuildMemberListFetchPayload, GuildMemberListPayload,
GuildRoleInfoPayload, GuildRoleListFetchPayload, GuildRoleListPayload, PacketId,
RoleAssignPayload, SessionCrypto, to_payload,
},
};
use super::{require_perm, send_err};
/// Fetch the member list for a guild (visible to all members).
pub async fn handle_member_list_fetch(
stream: &mut TcpStream,
seq: &mut u32,
session_id: &str,
payload: &[u8],
crypto: &SessionCrypto,
state: &State,
) -> Result<()> {
let req: GuildMemberListFetchPayload = serde_json::from_slice(payload)?;
let sess = match session::get(&state.sessions, session_id).await {
Some(s) => s,
None => return Ok(()),
};
// Optional: verify the requester is a member of the guild.
let owner_id = state
.storage
.get_guild(&req.guild_id)
.await?
.map(|g| g.owner_id);
let rows = state.storage.list_guild_members(&req.guild_id).await?;
let members: Vec<GuildMemberInfoPayload> = rows
.into_iter()
.map(|r| GuildMemberInfoPayload {
user_id: r.user_id.clone(),
nickname: r.nickname,
joined_at: r.joined_at,
role_color: r.role_color,
role_name: r.role_name,
is_owner: owner_id.as_deref() == Some(&r.user_id),
})
.collect();
let _ = sess;
let resp = GuildMemberListPayload {
guild_id: req.guild_id,
members,
};
io::send_encrypted(
stream,
PacketId::GuildMemberList,
seq,
&to_payload(&resp),
crypto,
)
.await?;
Ok(())
}
/// Assign a role to a user (admin only: requires MANAGE_ROLES).
pub async fn handle_role_assign(
stream: &mut TcpStream,
seq: &mut u32,
session_id: &str,
payload: &[u8],
crypto: &SessionCrypto,
state: &State,
) -> Result<()> {
let req: RoleAssignPayload = serde_json::from_slice(payload)?;
let sess = match session::get(&state.sessions, session_id).await {
Some(s) => s,
None => return Ok(()),
};
if !require_perm(
state,
&req.guild_id,
&sess.user_id,
Permissions::MANAGE_ROLES,
)
.await?
{
send_err(
stream,
seq,
crate::proto::ErrorCode::PermissionDenied,
"missing MANAGE_ROLES permission",
crypto,
)
.await?;
return Ok(());
}
state
.storage
.assign_role(&req.guild_id, &req.user_id, &req.role_id)
.await?;
state
.storage
.append_audit_log(
&req.guild_id,
&sess.user_id,
"ROLE_ASSIGN",
Some(&req.user_id),
Some("user"),
Some(&req.role_id),
)
.await?;
// Confirm to caller.
io::send_encrypted(
stream,
PacketId::GuildRoleAssign,
seq,
&to_payload(
&serde_json::json!({"ok": true, "user_id": req.user_id, "role_id": req.role_id}),
),
crypto,
)
.await?;
Ok(())
}
/// Remove a role from a user (admin only: requires MANAGE_ROLES).
pub async fn handle_role_unassign(
stream: &mut TcpStream,
seq: &mut u32,
session_id: &str,
payload: &[u8],
crypto: &SessionCrypto,
state: &State,
) -> Result<()> {
let req: RoleAssignPayload = serde_json::from_slice(payload)?;
let sess = match session::get(&state.sessions, session_id).await {
Some(s) => s,
None => return Ok(()),
};
if !require_perm(
state,
&req.guild_id,
&sess.user_id,
Permissions::MANAGE_ROLES,
)
.await?
{
send_err(
stream,
seq,
crate::proto::ErrorCode::PermissionDenied,
"missing MANAGE_ROLES permission",
crypto,
)
.await?;
return Ok(());
}
state
.storage
.remove_role_from_user(&req.guild_id, &req.user_id, &req.role_id)
.await?;
state
.storage
.append_audit_log(
&req.guild_id,
&sess.user_id,
"ROLE_UNASSIGN",
Some(&req.user_id),
Some("user"),
Some(&req.role_id),
)
.await?;
io::send_encrypted(
stream,
PacketId::GuildRoleUnassign,
seq,
&to_payload(
&serde_json::json!({"ok": true, "user_id": req.user_id, "role_id": req.role_id}),
),
crypto,
)
.await?;
Ok(())
}
/// Fetch all roles defined in a guild (visible to all members).
pub async fn handle_role_list_fetch(
stream: &mut TcpStream,
seq: &mut u32,
session_id: &str,
payload: &[u8],
crypto: &SessionCrypto,
state: &State,
) -> Result<()> {
let req: GuildRoleListFetchPayload = serde_json::from_slice(payload)?;
let _sess = session::get(&state.sessions, session_id).await;
let rows = state.storage.list_guild_roles(&req.guild_id).await?;
let roles: Vec<GuildRoleInfoPayload> = rows
.into_iter()
.map(|r| GuildRoleInfoPayload {
id: r.id,
guild_id: r.guild_id,
name: r.name,
color: r.color,
permissions: r.permissions as u64,
position: r.position,
})
.collect();
let resp = GuildRoleListPayload {
guild_id: req.guild_id,
roles,
};
io::send_encrypted(
stream,
PacketId::GuildRoleList,
seq,
&to_payload(&resp),
crypto,
)
.await?;
Ok(())
}

View file

@ -0,0 +1,68 @@
mod audit;
mod crud;
mod invites;
mod list;
mod members;
mod members_list;
mod roles;
use anyhow::Result;
use tokio::net::TcpStream;
use crate::{
domain::permissions::Permissions,
net::{io, state::State},
proto::{ErrorPayload, PacketId, SessionCrypto, to_payload},
};
async fn send_err(
stream: &mut TcpStream,
seq: &mut u32,
code: crate::proto::ErrorCode,
msg: &str,
crypto: &SessionCrypto,
) -> Result<()> {
io::send_encrypted(
stream,
PacketId::Error,
seq,
&to_payload(&ErrorPayload {
code: code as u32,
message: msg.into(),
}),
crypto,
)
.await
}
async fn require_perm(
state: &State,
guild_id: &str,
user_id: &str,
required: Permissions,
) -> Result<bool> {
if let Some(g) = state.storage.get_guild(guild_id).await?
&& g.owner_id == user_id
{
return Ok(true);
}
let perms = state.storage.get_user_role_perms(guild_id, user_id).await?;
Ok(Permissions::from_role_perms(&perms).has(required))
}
fn now_ms() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as i64
}
pub use audit::handle_audit_log_fetch;
pub use crud::{handle_guild_create, handle_guild_delete};
pub use invites::{handle_invite_accept, handle_invite_create, handle_invite_delete};
pub use list::handle_guild_list;
pub use members::{handle_guild_member_join, handle_guild_member_kick, handle_guild_member_leave};
pub use members_list::{
handle_member_list_fetch, handle_role_assign, handle_role_list_fetch, handle_role_unassign,
};
pub use roles::{handle_role_create, handle_role_delete};

View file

@ -0,0 +1,128 @@
use anyhow::Result;
use tokio::net::TcpStream;
use crate::{
domain::{permissions::Permissions, session},
net::{io, state::State},
proto::{PacketId, RoleCreatePayload, RoleDeletePayload, SessionCrypto, to_payload},
};
use super::send_err;
pub async fn handle_role_create(
stream: &mut TcpStream,
seq: &mut u32,
session_id: &str,
payload: &[u8],
crypto: &SessionCrypto,
state: &State,
) -> Result<()> {
let req: RoleCreatePayload = serde_json::from_slice(payload)?;
let sess = session::get(&state.sessions, session_id)
.await
.ok_or_else(|| anyhow::anyhow!("session not found"))?;
if !super::require_perm(
state,
&req.guild_id,
&sess.user_id,
Permissions::MANAGE_ROLES,
)
.await?
{
send_err(
stream,
seq,
crate::proto::ErrorCode::PermissionDenied,
"missing MANAGE_ROLES permission",
crypto,
)
.await?;
return Ok(());
}
let color = req.color.as_deref().unwrap_or("#ffffff");
let permissions = req.permissions.unwrap_or(0);
let role_id = state
.storage
.create_role(&req.guild_id, &req.name, color, permissions, 1)
.await?;
state
.storage
.append_audit_log(
&req.guild_id,
&sess.user_id,
"ROLE_CREATE",
Some(&role_id),
Some("role"),
None,
)
.await?;
io::send_encrypted(
stream,
PacketId::RoleCreate,
seq,
&to_payload(
&serde_json::json!({"id": role_id, "guild_id": req.guild_id, "name": req.name}),
),
crypto,
)
.await?;
Ok(())
}
pub async fn handle_role_delete(
stream: &mut TcpStream,
seq: &mut u32,
session_id: &str,
payload: &[u8],
crypto: &SessionCrypto,
state: &State,
) -> Result<()> {
let req: RoleDeletePayload = serde_json::from_slice(payload)?;
let sess = session::get(&state.sessions, session_id)
.await
.ok_or_else(|| anyhow::anyhow!("session not found"))?;
if !super::require_perm(
state,
&req.guild_id,
&sess.user_id,
Permissions::MANAGE_ROLES,
)
.await?
{
send_err(
stream,
seq,
crate::proto::ErrorCode::PermissionDenied,
"missing MANAGE_ROLES permission",
crypto,
)
.await?;
return Ok(());
}
state.storage.delete_role(&req.role_id).await?;
state
.storage
.append_audit_log(
&req.guild_id,
&sess.user_id,
"ROLE_DELETE",
Some(&req.role_id),
Some("role"),
None,
)
.await?;
io::send_encrypted(
stream,
PacketId::RoleDelete,
seq,
&to_payload(&serde_json::json!({"role_id": req.role_id})),
crypto,
)
.await?;
Ok(())
}

View file

@ -0,0 +1,22 @@
pub mod channel;
pub mod content;
pub mod deliver;
pub mod direct_message;
pub mod dispatch;
pub mod friends;
pub mod guild;
pub mod run;
pub use dispatch::dispatch;
pub use run::run_session;
use tokio::net::TcpStream;
use crate::{net::state::State, proto::SessionCrypto};
pub struct Ctx<'a> {
pub stream: &'a mut TcpStream,
pub seq: &'a mut u32,
pub crypto: &'a SessionCrypto,
pub state: &'a State,
}

View file

@ -0,0 +1,55 @@
use anyhow::Result;
use tokio::net::TcpStream;
use tokio::sync::broadcast;
use tracing::warn;
use crate::{
net::{io, state::BroadcastMsg, state::State},
proto::{self, PacketId, SessionCrypto, to_payload},
};
use super::{Ctx, deliver::deliver_encrypted, dispatch};
pub async fn run_session(
stream: &mut TcpStream,
addr: std::net::SocketAddr,
seq: &mut u32,
session_id: &str,
crypto: &SessionCrypto,
state: &State,
bcast_rx: &mut broadcast::Receiver<BroadcastMsg>,
) -> Result<()> {
let mut ctx = Ctx {
stream,
seq,
crypto,
state,
};
loop {
tokio::select! {
result = io::read_encrypted(ctx.stream, ctx.crypto) => {
let (hdr, payload) = result?;
let pid = match PacketId::from_u16(hdr.packet_id) {
Some(p) => p,
None => {
warn!("{addr} unknown 0x{:04X}", hdr.packet_id);
io::send_encrypted(ctx.stream, PacketId::Error, ctx.seq, &to_payload(&proto::ErrorPayload {
code: proto::ErrorCode::InvalidPacket as u32,
message: "unknown packet".into(),
}), ctx.crypto).await?;
continue;
}
};
dispatch(&mut ctx, session_id, pid, &payload, addr).await?;
if pid == PacketId::Disconnect { return Ok(()); }
}
bcast = bcast_rx.recv() => {
match bcast {
Ok(msg) => deliver_encrypted(ctx.stream, ctx.seq, session_id, &msg, ctx.crypto, ctx.state).await?,
Err(broadcast::error::RecvError::Lagged(n)) => warn!("{addr} lagged {n}"),
Err(broadcast::error::RecvError::Closed) => return Ok(()),
}
}
}
}
}