use serde::{Deserialize, Serialize};
use crate::envelope::{Ack, ServerPush, WsCall};
use crate::error::{ApiError, ValidationDetails};
use crate::ids::{MessageId, RoomId, UserId};
use crate::kinds;
use crate::page::{Page, PageRequest};
use crate::text;
use crate::time::UnixMillis;
pub const DEFAULT_MAX_TEXT_CHARS: usize = 500;
pub const DEFAULT_RATE_MESSAGES: u32 = 5;
pub const DEFAULT_RATE_WINDOW_SECS: u32 = 10;
pub const DEFAULT_HISTORY_RETENTION_DAYS: u32 = 30;
pub const DEFAULT_MAX_ROOM_MEMBERS: u32 = 200;
pub const DEFAULT_MAX_JOINED_ROOMS: u32 = 16;
pub const MAX_ROOM_KEY_BYTES: usize = 64;
pub const MAX_NONCE_BYTES: usize = 64;
pub fn is_valid_room_key(key: &str) -> bool {
let bytes = key.as_bytes();
bytes.len() <= MAX_ROOM_KEY_BYTES
&& bytes.first().is_some_and(|b| b.is_ascii_lowercase() || b.is_ascii_digit())
&& bytes.iter().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'_' | b'-' | b'.'))
}
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(untagged)]
#[non_exhaustive]
pub enum RoomRef {
Id(RoomId),
Key(String),
}
impl From<RoomId> for RoomRef {
fn from(id: RoomId) -> Self {
RoomRef::Id(id)
}
}
impl From<&str> for RoomRef {
fn from(key: &str) -> Self {
RoomRef::Key(key.to_string())
}
}
impl From<String> for RoomRef {
fn from(key: String) -> Self {
RoomRef::Key(key)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum RoomKind {
Room,
Dm,
Group,
#[serde(other)]
Unknown,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct RoomInfo {
pub id: RoomId,
pub kind: RoomKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub key: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub member_count: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_members: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub peer: Option<UserId>,
}
impl RoomInfo {
pub fn new(id: RoomId, kind: RoomKind) -> Self {
Self { id, kind, key: None, name: None, member_count: None, max_members: None, peer: None }
}
pub fn with_peer(mut self, peer: UserId) -> Self {
self.peer = Some(peer);
self
}
pub fn with_key(mut self, key: impl Into<String>) -> Self {
self.key = Some(key.into());
self
}
pub fn with_name(mut self, name: impl Into<String>) -> Self {
self.name = Some(name.into());
self
}
pub fn with_members(mut self, member_count: Option<u32>, max_members: Option<u32>) -> Self {
self.member_count = member_count;
self.max_members = max_members;
self
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct JoinRoom {
pub room: RoomRef,
}
impl JoinRoom {
pub fn new(room: impl Into<RoomRef>) -> Self {
Self { room: room.into() }
}
pub fn validate(&self) -> Result<(), ApiError> {
let mut details = ValidationDetails::new();
if let RoomRef::Key(key) = &self.room {
if !is_valid_room_key(key) {
details.add("room", format!("is not a room key (1 to {MAX_ROOM_KEY_BYTES} bytes of a-z, 0-9, _ - .)"));
}
}
details.into_result()
}
}
impl WsCall for JoinRoom {
type Response = RoomInfo;
const KIND: &'static str = kinds::CHAT_JOIN;
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct LeaveRoom {
pub room: RoomId,
}
impl LeaveRoom {
pub fn new(room: RoomId) -> Self {
Self { room }
}
}
impl WsCall for LeaveRoom {
type Response = Ack;
const KIND: &'static str = kinds::CHAT_LEAVE;
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct SendMessage {
pub room: RoomId,
pub text: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub nonce: Option<String>,
}
impl SendMessage {
pub fn new(room: RoomId, text: impl Into<String>) -> Self {
Self { room, text: text.into(), nonce: None }
}
pub fn with_nonce(mut self, nonce: impl Into<String>) -> Self {
self.nonce = Some(nonce.into());
self
}
pub fn validate(&self, max_chars: usize) -> Result<(), ApiError> {
let mut details = ValidationDetails::new();
if self.text.trim().is_empty() {
details.add("text", "is empty");
}
if self.text.chars().count() > max_chars {
details.add("text", format!("is longer than {max_chars} characters"));
}
if let Some(problem) = text::message_problem(&self.text) {
details.add("text", problem);
}
if let Some(nonce) = &self.nonce {
if nonce.is_empty() || nonce.len() > MAX_NONCE_BYTES || !nonce.bytes().all(|b| b.is_ascii_graphic()) {
details.add("nonce", format!("must be 1 to {MAX_NONCE_BYTES} visible ASCII characters"));
}
}
details.into_result()
}
}
impl WsCall for SendMessage {
type Response = SendAck;
const KIND: &'static str = kinds::CHAT_SEND;
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct SendAck {
pub message_id: MessageId,
pub sent_at: UnixMillis,
}
impl SendAck {
pub fn new(message_id: MessageId, sent_at: UnixMillis) -> Self {
Self { message_id, sent_at }
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ChatHistory {
pub room: RoomId,
#[serde(flatten)]
pub page: PageRequest,
}
impl ChatHistory {
pub fn new(room: RoomId) -> Self {
Self { room, page: PageRequest::first() }
}
pub fn with_page(mut self, page: PageRequest) -> Self {
self.page = page;
self
}
}
impl WsCall for ChatHistory {
type Response = Page<ChatMessage>;
const KIND: &'static str = kinds::CHAT_HISTORY;
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ChatMessage {
pub id: MessageId,
pub room: RoomId,
pub sender: UserId,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sender_name: Option<String>,
pub text: String,
pub sent_at: UnixMillis,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub nonce: Option<String>,
}
impl ChatMessage {
pub fn new(id: MessageId, room: RoomId, sender: UserId, text: impl Into<String>, sent_at: UnixMillis) -> Self {
Self { id, room, sender, sender_name: None, text: text.into(), sent_at, nonce: None }
}
pub fn with_nonce(mut self, nonce: impl Into<String>) -> Self {
self.nonce = Some(nonce.into());
self
}
pub fn with_sender_name(mut self, name: impl Into<String>) -> Self {
self.sender_name = Some(name.into());
self
}
}
impl ServerPush for ChatMessage {
const KIND: &'static str = kinds::CHAT_MESSAGE;
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct MessageDeleted {
pub id: MessageId,
pub room: RoomId,
}
impl MessageDeleted {
pub fn new(id: MessageId, room: RoomId) -> Self {
Self { id, room }
}
}
impl ServerPush for MessageDeleted {
const KIND: &'static str = kinds::CHAT_DELETED;
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct OpenDirect {
pub user: UserId,
}
impl OpenDirect {
pub fn new(user: UserId) -> Self {
Self { user }
}
}
pub const DEFAULT_PRESENCE_MAX_MEMBERS: u32 = 100;
pub const DEFAULT_PRESENCE_PER_SECOND: u32 = 10;
pub const MAX_LISTED_MEMBERS: u32 = 200;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ListMembers {
pub room: RoomId,
}
impl ListMembers {
pub fn new(room: RoomId) -> Self {
Self { room }
}
}
impl WsCall for ListMembers {
type Response = RoomMembers;
const KIND: &'static str = kinds::CHAT_MEMBERS;
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct RoomMember {
pub user: UserId,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
}
impl RoomMember {
pub fn new(user: UserId) -> Self {
Self { user, name: None }
}
pub fn with_name(mut self, name: impl Into<String>) -> Self {
self.name = Some(name.into());
self
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct RoomMembers {
pub room: RoomId,
pub members: Vec<RoomMember>,
pub count: u32,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub truncated: bool,
}
impl RoomMembers {
pub fn new(room: RoomId, members: Vec<RoomMember>, count: u32) -> Self {
Self { room, members, count, truncated: false }
}
pub fn truncated(mut self) -> Self {
self.truncated = true;
self
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum PresenceEvent {
Joined,
Left,
#[serde(other)]
Unknown,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Presence {
pub room: RoomId,
pub user: UserId,
pub event: PresenceEvent,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub count: Option<u32>,
}
impl Presence {
pub fn new(room: RoomId, user: UserId, event: PresenceEvent) -> Self {
Self { room, user, event, name: None, count: None }
}
pub fn with_name(mut self, name: impl Into<String>) -> Self {
self.name = Some(name.into());
self
}
pub fn with_count(mut self, count: u32) -> Self {
self.count = Some(count);
self
}
}
impl ServerPush for Presence {
const KIND: &'static str = kinds::CHAT_PRESENCE;
}
mod calls {
use super::*;
use crate::http_call::{payload_call, HttpCall, NoPayload, PathParams, PayloadKind, NO_PAYLOAD};
use crate::routes::{self, HttpMethod, Route};
payload_call!(OpenDirect, Post, routes::chat::DM, true, Json, RoomInfo);
#[derive(Clone, Debug, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct ListRooms {
pub page: PageRequest,
}
impl ListRooms {
pub fn new() -> Self {
Self::default()
}
pub fn with_page(mut self, page: PageRequest) -> Self {
self.page = page;
self
}
}
impl HttpCall for ListRooms {
type Payload = PageRequest;
type Response = Page<RoomInfo>;
const ROUTE: Route = Route::new(HttpMethod::Get, routes::chat::ROOMS, true);
const PAYLOAD: PayloadKind = PayloadKind::Query;
fn payload(&self) -> &PageRequest {
&self.page
}
fn from_parts(_params: &PathParams, page: PageRequest) -> Result<Self, ApiError> {
Ok(Self::new().with_page(page))
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct ListDirects {
pub page: PageRequest,
}
impl ListDirects {
pub fn new() -> Self {
Self::default()
}
pub fn with_page(mut self, page: PageRequest) -> Self {
self.page = page;
self
}
}
impl HttpCall for ListDirects {
type Payload = PageRequest;
type Response = Page<RoomInfo>;
const ROUTE: Route = Route::new(HttpMethod::Get, routes::chat::DMS, true);
const PAYLOAD: PayloadKind = PayloadKind::Query;
fn payload(&self) -> &PageRequest {
&self.page
}
fn from_parts(_params: &PathParams, page: PageRequest) -> Result<Self, ApiError> {
Ok(Self::new().with_page(page))
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct ListMessages {
pub room: RoomId,
pub page: PageRequest,
}
impl ListMessages {
pub fn new(room: RoomId) -> Self {
Self { room, page: PageRequest::first() }
}
pub fn with_page(mut self, page: PageRequest) -> Self {
self.page = page;
self
}
}
impl HttpCall for ListMessages {
type Payload = PageRequest;
type Response = Page<ChatMessage>;
const ROUTE: Route = Route::new(HttpMethod::Get, routes::chat::HISTORY, true);
const PAYLOAD: PayloadKind = PayloadKind::Query;
fn payload(&self) -> &PageRequest {
&self.page
}
fn path_params(&self) -> PathParams {
PathParams::new().with("room", self.room)
}
fn from_parts(params: &PathParams, page: PageRequest) -> Result<Self, ApiError> {
Ok(Self::new(params.id("room")?).with_page(page))
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct DeleteMessage {
pub room: RoomId,
pub message: MessageId,
}
impl DeleteMessage {
pub fn new(room: RoomId, message: MessageId) -> Self {
Self { room, message }
}
}
impl HttpCall for DeleteMessage {
type Payload = NoPayload;
type Response = Ack;
const ROUTE: Route = Route::new(HttpMethod::Delete, routes::chat::MESSAGE, true);
const PAYLOAD: PayloadKind = PayloadKind::Empty;
fn payload(&self) -> &NoPayload {
&NO_PAYLOAD
}
fn path_params(&self) -> PathParams {
PathParams::new().with("room", self.room).with("message", self.message)
}
fn from_parts(params: &PathParams, _payload: NoPayload) -> Result<Self, ApiError> {
Ok(Self::new(params.id("room")?, params.id("message")?))
}
}
}
pub use calls::{DeleteMessage, ListDirects, ListMessages, ListRooms};
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn room_ref_json() {
assert_eq!(serde_json::to_string(&JoinRoom::new("world")).ok().as_deref(), Some(r#"{"room":"world"}"#));
assert_eq!(serde_json::to_string(&JoinRoom::new(RoomId(12))).ok().as_deref(), Some(r#"{"room":12}"#));
assert_eq!(serde_json::from_str::<JoinRoom>(r#"{"room":12}"#).ok(), Some(JoinRoom::new(RoomId(12))));
assert_eq!(serde_json::from_str::<JoinRoom>(r#"{"room":"trade"}"#).ok(), Some(JoinRoom::new("trade")));
assert!(serde_json::from_str::<JoinRoom>(r#"{"room":true}"#).is_err());
assert!(JoinRoom::new("").validate().is_err());
assert!(JoinRoom::new("k".repeat(MAX_ROOM_KEY_BYTES + 1)).validate().is_err());
assert!(JoinRoom::new("world").validate().is_ok());
for bad in ["a b/c", "\0", "World", "-x", "w\u{200B}"] {
assert!(JoinRoom::new(bad).validate().is_err(), "{bad:?}");
}
}
#[test]
fn send_rules() {
assert!(SendMessage::new(RoomId(1), "hi").validate(DEFAULT_MAX_TEXT_CHARS).is_ok());
assert!(SendMessage::new(RoomId(1), " ").validate(DEFAULT_MAX_TEXT_CHARS).is_err());
assert!(SendMessage::new(RoomId(1), "é".repeat(DEFAULT_MAX_TEXT_CHARS)).validate(DEFAULT_MAX_TEXT_CHARS).is_ok());
assert!(SendMessage::new(RoomId(1), "é".repeat(DEFAULT_MAX_TEXT_CHARS + 1)).validate(DEFAULT_MAX_TEXT_CHARS).is_err());
assert!(SendMessage::new(RoomId(1), "two\nlines").validate(DEFAULT_MAX_TEXT_CHARS).is_ok());
assert!(SendMessage::new(RoomId(1), "x").with_nonce("n-1").validate(DEFAULT_MAX_TEXT_CHARS).is_ok());
for nonce in ["", "has space", &"n".repeat(MAX_NONCE_BYTES + 1)] {
assert!(SendMessage::new(RoomId(1), "x").with_nonce(nonce).validate(DEFAULT_MAX_TEXT_CHARS).is_err(), "{nonce:?}");
}
}
#[test]
fn history_flattens_the_page() {
let request = ChatHistory::new(RoomId(4)).with_page(PageRequest::after(crate::Cursor::new("c1")).with_limit(20));
assert_eq!(serde_json::to_string(&request).ok().as_deref(), Some(r#"{"room":4,"cursor":"c1","limit":20}"#));
assert_eq!(serde_json::to_string(&ChatHistory::new(RoomId(4))).ok().as_deref(), Some(r#"{"room":4}"#));
assert_eq!(serde_json::from_str::<RoomKind>("\"guild_hall\"").ok(), Some(RoomKind::Unknown));
}
}