//! The seams each host app implements. //! //! The crate knows nothing about communities, streams, subscriptions, or bans. //! Everything app-specific arrives through these four traits, which is what lets //! Multithreaded gate on `CommunityScope` and the MNW server gate on //! `SubscriptionGate` without either concept appearing here. //! //! `async_trait` rather than native AFIT: the returned futures need `Send` to be //! spawned onto tokio, and the MNW server already depends on the crate. use std::collections::HashMap; use std::time::Duration; use async_trait::async_trait; use crate::error::ChatError; use crate::ids::{MessageId, RoomId, UserId}; use crate::room::Room; /// Resolves an app-specific key to a room. #[async_trait] pub trait ChatRooms: Send + Sync { /// Resolve a host-namespaced key: a community slug in Multithreaded, a stream /// id in the MNW server. /// /// Returns `Ok(None)` when no such room exists. A room whose chat is disabled /// should come back as [`crate::room::RoomState::Closed`] rather than `None`, /// so the host can decide whether "disabled" and "absent" look different. async fn resolve(&self, key: &str) -> Result, ChatError>; } /// Why a write was refused. Carried back so the client can say something /// specific instead of a bare refusal. #[derive(Debug, Clone, PartialEq, Eq)] pub enum DenyReason { /// Not signed in. Anonymous, /// Signed in, but not a member of this community. NotAMember, /// Banned from the room. Banned, /// Muted: still reads, cannot write. Muted, /// Suspended at the platform level. Suspended, /// The room requires a paid tier the user does not have. TierRequired, /// Sending too fast. RateLimited { retry_after: Duration }, } /// The outcome of a write check. #[derive(Debug, Clone, PartialEq, Eq)] pub enum WriteAccess { Allow, Deny(DenyReason), } impl WriteAccess { pub fn is_allowed(&self) -> bool { matches!(self, Self::Allow) } } /// Per-user read and write gating. #[async_trait] pub trait ChatAuthz: Send + Sync { /// Whether this viewer may read the room. /// /// `viewer` is `None` for a logged-out request, which is a real case: /// Multithreaded's `public_read` policy shows the room to anyone. async fn can_read(&self, viewer: Option, room: &Room) -> Result; /// Whether this user may send, and if not, why. async fn can_write(&self, user: UserId, room: &Room) -> Result; /// Whether this user holds moderator powers in the room. async fn is_moderator(&self, user: UserId, room: &Room) -> Result; } /// How a user is displayed. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Identity { pub display_name: String, pub avatar_url: Option, /// Role or badge text, if the host shows one. pub flair: Option, } /// Display information for participants. #[async_trait] pub trait ChatIdentity: Send + Sync { /// Resolve many users at once. /// /// Batched because backlog replay needs every author in the window, and doing /// that one query at a time is an N+1 on the reconnect path. Neither app gets /// this for free: Multithreaded's `SessionUser` carries no `avatar_url`, so /// this is a join plus a cache. /// /// Users the host cannot resolve are omitted from the map rather than /// erroring. A deleted account should not take down the room. async fn identify(&self, users: &[UserId]) -> Result, ChatError>; } /// Moderation actions. /// /// There is no edit anywhere in this trait, and that is deliberate: chat is a /// stream nobody re-reads, so an edit path would be surface with no benefit and /// would drag an edit-history table behind it. /// /// Deletion is a real delete, not a tombstone. The row expires anyway, and a /// tombstone would outlive the content it describes, which is the opposite of /// what bounded retention is for. #[async_trait] pub trait ChatModeration: Send + Sync { /// Remove one message. `actor` is the author for a self-delete, or a /// moderator. Implementations must verify the actor is entitled to this /// message rather than trusting the caller. async fn delete_message( &self, actor: UserId, room: &Room, message: MessageId, ) -> Result<(), ChatError>; /// Remove every message by `target` in the room's current retention window, /// returning how many rows went. /// /// Issued as part of a ban. Implementations should do this as one statement /// keyed by (room, author); the caller broadcasts a single /// [`crate::event::ChatEvent::Purge`] rather than one event per message. async fn purge_user( &self, actor: UserId, room: &Room, target: UserId, ) -> Result; /// Block a user from sending for a bounded period. Reads are unaffected. async fn timeout_user( &self, actor: UserId, room: &Room, target: UserId, duration: Duration, ) -> Result<(), ChatError>; /// Ban a user from the room. /// /// The caller pairs this with [`ChatModeration::purge_user`]: a banned user's /// messages in the retention window are the damage, so the ban removes them. async fn ban_user( &self, actor: UserId, room: &Room, target: UserId, reason: Option<&str>, ) -> Result<(), ChatError>; /// Record an action in the host's moderation log. /// /// Separate from the actions above so a host can log in the same transaction /// as the mutation, which is what Multithreaded's `mod_log` expects. async fn log_action( &self, actor: UserId, room_id: RoomId, action: &str, detail: Option<&str>, ) -> Result<(), ChatError>; } #[cfg(test)] mod tests { use super::*; #[test] fn only_allow_is_allowed() { assert!(WriteAccess::Allow.is_allowed()); assert!(!WriteAccess::Deny(DenyReason::Muted).is_allowed()); assert!( !WriteAccess::Deny(DenyReason::RateLimited { retry_after: Duration::from_secs(3) }) .is_allowed() ); } }