init
This commit is contained in:
commit
c7a592933e
32 changed files with 7871 additions and 0 deletions
20
.env.example
Normal file
20
.env.example
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
DATABASE_URL=postgres://postgres:postgres@localhost:5432/chattz
|
||||||
|
|
||||||
|
# Authentik OIDC app values
|
||||||
|
OIDC_CLIENT_ID=replace-me
|
||||||
|
OIDC_CLIENT_SECRET=replace-me
|
||||||
|
OIDC_AUTHORIZE_URL=https://auth.example.com/application/o/authorize/
|
||||||
|
OIDC_TOKEN_URL=https://auth.example.com/application/o/token/
|
||||||
|
OIDC_USERINFO_URL=https://auth.example.com/application/o/userinfo/
|
||||||
|
OIDC_REDIRECT_URL=http://localhost:3000/auth/callback
|
||||||
|
OIDC_SCOPES="openid profile email"
|
||||||
|
|
||||||
|
# WebRTC ICE servers (comma-separated). STUN defaults to Google STUN if omitted.
|
||||||
|
STUN_URLS=stun:stun.l.google.com:19302
|
||||||
|
TURN_URLS=turn:turn.example.com:3478?transport=udp,turn:turn.example.com:3478?transport=tcp
|
||||||
|
TURN_USERNAME=replace-me
|
||||||
|
TURN_PASSWORD=replace-me
|
||||||
|
|
||||||
|
# 32+ random chars; used to sign session cookies
|
||||||
|
SESSION_SECRET=replace-with-long-random-secret
|
||||||
|
COOKIE_SECURE=false
|
||||||
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
/target
|
||||||
|
.env
|
||||||
3302
Cargo.lock
generated
Normal file
3302
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
23
Cargo.toml
Normal file
23
Cargo.toml
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
[package]
|
||||||
|
name = "chattz"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow = "1"
|
||||||
|
axum = { version = "0.8", features = ["macros", "ws"] }
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
dotenvy = "0.15"
|
||||||
|
jsonwebtoken = "9"
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||||
|
sea-orm = { version = "1.1", default-features = false, features = ["sqlx-postgres", "runtime-tokio-rustls", "macros", "with-chrono", "with-uuid"] }
|
||||||
|
sea-orm-migration = { version = "1.1", default-features = false, features = ["sqlx-postgres", "runtime-tokio-rustls"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
futures-util = "0.3"
|
||||||
|
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
|
||||||
|
tower-http = { version = "0.6", features = ["trace", "fs"] }
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
|
||||||
|
urlencoding = "2"
|
||||||
|
uuid = { version = "1", features = ["v4", "serde"] }
|
||||||
103
README.md
Normal file
103
README.md
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
# Chattz
|
||||||
|
|
||||||
|
A simple single-instance Discord-style monolith in Rust using:
|
||||||
|
- `axum` HTTP server
|
||||||
|
- `SeaORM` + PostgreSQL for persistence
|
||||||
|
- Authentik OIDC for login
|
||||||
|
|
||||||
|
## What this includes
|
||||||
|
|
||||||
|
- OIDC login flow (`/auth/login`, `/auth/callback`, `/auth/logout`)
|
||||||
|
- Signed session cookie auth
|
||||||
|
- Channel voice chat over WebRTC (P2P mesh) with server WebSocket signaling
|
||||||
|
- Guild invite codes (create + join)
|
||||||
|
- Direct messages (DM) between users
|
||||||
|
- Core resources:
|
||||||
|
- users
|
||||||
|
- guilds + guild membership
|
||||||
|
- channels
|
||||||
|
- messages
|
||||||
|
- direct_messages
|
||||||
|
- Basic JSON APIs for creating guilds/channels and posting/listing messages
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
1. Create DB and apply schema:
|
||||||
|
1. Create DB:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE DATABASE chattz;
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Configure env:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# edit .env values
|
||||||
|
```
|
||||||
|
|
||||||
|
For voice reliability on restrictive networks, configure TURN in `.env`:
|
||||||
|
- `TURN_URLS`
|
||||||
|
- `TURN_USERNAME`
|
||||||
|
- `TURN_PASSWORD`
|
||||||
|
|
||||||
|
3. Run app:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo run
|
||||||
|
```
|
||||||
|
|
||||||
|
Migrations are applied automatically during startup.
|
||||||
|
|
||||||
|
Server starts on `http://localhost:3000`.
|
||||||
|
Web UI is available at `http://localhost:3000/`.
|
||||||
|
|
||||||
|
## Authentik setup notes
|
||||||
|
|
||||||
|
Create an Authentik OAuth2/OIDC provider + application and set:
|
||||||
|
- Redirect URI: `http://localhost:3000/auth/callback`
|
||||||
|
- Scopes including at least: `openid profile email`
|
||||||
|
|
||||||
|
Then copy provider endpoints into env:
|
||||||
|
- `OIDC_AUTHORIZE_URL`
|
||||||
|
- `OIDC_TOKEN_URL`
|
||||||
|
- `OIDC_USERINFO_URL`
|
||||||
|
|
||||||
|
For Authentik these are commonly under `/application/o/...` for the app slug.
|
||||||
|
|
||||||
|
## API summary
|
||||||
|
|
||||||
|
- `GET /health`
|
||||||
|
- `GET /auth/login`
|
||||||
|
- `GET /auth/callback?code=...&state=...`
|
||||||
|
- `POST /auth/logout`
|
||||||
|
- `GET /me`
|
||||||
|
- `GET /dms`
|
||||||
|
- `GET /dms/:other_user_id/messages?limit=50`
|
||||||
|
- `POST /dms/:other_user_id/messages` body: `{ "body": "hello" }`
|
||||||
|
- `GET /rtc-config`
|
||||||
|
- `GET /guilds`
|
||||||
|
- `POST /guilds` body: `{ "name": "My Guild" }`
|
||||||
|
- `GET /guilds/:guild_id/members`
|
||||||
|
- `GET /guilds/:guild_id/voice-presence`
|
||||||
|
- `POST /guilds/:guild_id/invites` body: `{ "max_uses": 50, "expires_in_hours": 24 }`
|
||||||
|
- `POST /invites/:code/join`
|
||||||
|
- `GET /guilds/:guild_id/channels`
|
||||||
|
- `POST /channels` body: `{ "guild_id": "...", "name": "general", "kind": "text|voice" }`
|
||||||
|
- `GET /channels/:channel_id/messages?limit=50`
|
||||||
|
- `POST /channels/:channel_id/messages` body: `{ "body": "hello" }`
|
||||||
|
- `GET /channels/:channel_id/voice/ws` (WebSocket signaling)
|
||||||
|
|
||||||
|
All endpoints except health and auth flow require the session cookie from successful login.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
This is intentionally minimal and monolithic (single process, single Postgres instance).
|
||||||
|
Voice is implemented as browser-to-browser WebRTC audio with signaling in this server.
|
||||||
|
For two users behind strict NAT/firewall, you may need TURN for reliable connectivity.
|
||||||
|
The web UI remembers the last selected guild in browser local storage and auto-selects it on reload.
|
||||||
|
|
||||||
|
Mic filter modes in the UI:
|
||||||
|
- `NSNet2 (Compat)`: always-on denoising mode (implemented using DeepFilterNet3 with lighter suppression preset)
|
||||||
|
|
||||||
|
Noise processing requires browsers with `AudioWorklet` support (modern Chrome/Edge/Firefox).
|
||||||
44
migrations/0001_init.sql
Normal file
44
migrations/0001_init.sql
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||||
|
|
||||||
|
CREATE TABLE users (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
oidc_sub TEXT UNIQUE NOT NULL,
|
||||||
|
email TEXT,
|
||||||
|
display_name TEXT NOT NULL,
|
||||||
|
avatar_url TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE guilds (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
owner_user_id UUID NOT NULL REFERENCES users(id),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE guild_members (
|
||||||
|
guild_id UUID NOT NULL REFERENCES guilds(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (guild_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE channels (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
guild_id UUID NOT NULL REFERENCES guilds(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE messages (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||||
|
author_user_id UUID NOT NULL REFERENCES users(id),
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_guild_members_user_id ON guild_members(user_id);
|
||||||
|
CREATE INDEX idx_channels_guild_id ON channels(guild_id);
|
||||||
|
CREATE INDEX idx_messages_channel_created_at ON messages(channel_id, created_at DESC);
|
||||||
201
src/auth.rs
Normal file
201
src/auth.rs
Normal file
|
|
@ -0,0 +1,201 @@
|
||||||
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use anyhow::{Context, Result, anyhow};
|
||||||
|
use axum::{
|
||||||
|
Json,
|
||||||
|
extract::{FromRef, FromRequestParts},
|
||||||
|
http::{StatusCode, request::Parts},
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
};
|
||||||
|
use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation, decode, encode};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::{AppState, db};
|
||||||
|
|
||||||
|
const SESSION_COOKIE: &str = "chattz_session";
|
||||||
|
const OAUTH_STATE_COOKIE: &str = "chattz_oauth_state";
|
||||||
|
const SESSION_TTL_SECS: u64 = 60 * 60 * 24 * 7;
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
struct SessionClaims {
|
||||||
|
sub: String,
|
||||||
|
exp: usize,
|
||||||
|
iat: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AuthUser {
|
||||||
|
pub id: Uuid,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct ApiError {
|
||||||
|
pub status: StatusCode,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ApiError {
|
||||||
|
pub fn unauthorized(msg: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
status: StatusCode::UNAUTHORIZED,
|
||||||
|
message: msg.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn bad_request(msg: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
status: StatusCode::BAD_REQUEST,
|
||||||
|
message: msg.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn internal(msg: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
status: StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
message: msg.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct ErrorBody<'a> {
|
||||||
|
error: &'a str,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponse for ApiError {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
(self.status, Json(ErrorBody { error: &self.message })).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<anyhow::Error> for ApiError {
|
||||||
|
fn from(err: anyhow::Error) -> Self {
|
||||||
|
Self::internal(&err.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<S> FromRequestParts<S> for AuthUser
|
||||||
|
where
|
||||||
|
AppState: axum::extract::FromRef<S>,
|
||||||
|
S: Send + Sync,
|
||||||
|
{
|
||||||
|
type Rejection = ApiError;
|
||||||
|
|
||||||
|
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
|
||||||
|
let app = AppState::from_ref(state);
|
||||||
|
let token = read_cookie(parts, SESSION_COOKIE).ok_or_else(|| ApiError::unauthorized("missing session"))?;
|
||||||
|
let user_id = verify_session(&token, &app.settings.session_secret)
|
||||||
|
.map_err(|_| ApiError::unauthorized("invalid session"))?;
|
||||||
|
|
||||||
|
let exists = db::user_exists(&app.db, user_id)
|
||||||
|
.await
|
||||||
|
.map_err(|_| ApiError::unauthorized("session user not found"))?;
|
||||||
|
if !exists {
|
||||||
|
return Err(ApiError::unauthorized("session user not found"));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Self { id: user_id })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new_oauth_state() -> String {
|
||||||
|
Uuid::new_v4().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn make_oauth_state_cookie(value: &str, secure: bool) -> String {
|
||||||
|
format!(
|
||||||
|
"{name}={value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=600{secure_flag}",
|
||||||
|
name = OAUTH_STATE_COOKIE,
|
||||||
|
secure_flag = if secure { "; Secure" } else { "" }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear_oauth_state_cookie(secure: bool) -> String {
|
||||||
|
format!(
|
||||||
|
"{name}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0{secure_flag}",
|
||||||
|
name = OAUTH_STATE_COOKIE,
|
||||||
|
secure_flag = if secure { "; Secure" } else { "" }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read_oauth_state_from_headers(headers: &axum::http::HeaderMap) -> Option<String> {
|
||||||
|
let raw = headers.get(axum::http::header::COOKIE)?.to_str().ok()?;
|
||||||
|
raw.split(';').find_map(|pair| {
|
||||||
|
let mut kv = pair.trim().splitn(2, '=');
|
||||||
|
let key = kv.next()?;
|
||||||
|
let value = kv.next()?;
|
||||||
|
(key == OAUTH_STATE_COOKIE).then(|| value.to_string())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new_session_cookie(user_id: Uuid, secret: &str, secure: bool) -> Result<String> {
|
||||||
|
let now = now_ts();
|
||||||
|
let claims = SessionClaims {
|
||||||
|
sub: user_id.to_string(),
|
||||||
|
iat: now as usize,
|
||||||
|
exp: (now + SESSION_TTL_SECS) as usize,
|
||||||
|
};
|
||||||
|
|
||||||
|
let token = encode(
|
||||||
|
&Header::default(),
|
||||||
|
&claims,
|
||||||
|
&EncodingKey::from_secret(secret.as_bytes()),
|
||||||
|
)
|
||||||
|
.context("failed to encode session token")?;
|
||||||
|
|
||||||
|
Ok(format!(
|
||||||
|
"{name}={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age={ttl}{secure_flag}",
|
||||||
|
name = SESSION_COOKIE,
|
||||||
|
ttl = SESSION_TTL_SECS,
|
||||||
|
secure_flag = if secure { "; Secure" } else { "" }
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear_session_cookie(secure: bool) -> String {
|
||||||
|
format!(
|
||||||
|
"{name}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0{secure_flag}",
|
||||||
|
name = SESSION_COOKIE,
|
||||||
|
secure_flag = if secure { "; Secure" } else { "" }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_session(token: &str, secret: &str) -> Result<Uuid> {
|
||||||
|
let mut validation = Validation::default();
|
||||||
|
validation.validate_exp = true;
|
||||||
|
|
||||||
|
let data = decode::<SessionClaims>(
|
||||||
|
token,
|
||||||
|
&DecodingKey::from_secret(secret.as_bytes()),
|
||||||
|
&validation,
|
||||||
|
)
|
||||||
|
.context("failed to decode session token")?;
|
||||||
|
|
||||||
|
let user_id = Uuid::parse_str(&data.claims.sub).context("invalid sub in session token")?;
|
||||||
|
Ok(user_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate_oauth_state(expected_cookie: Option<String>, query_state: &str) -> Result<()> {
|
||||||
|
let expected = expected_cookie.ok_or_else(|| anyhow!("missing oauth state cookie"))?;
|
||||||
|
if expected != query_state {
|
||||||
|
return Err(anyhow!("invalid oauth state"));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_cookie(parts: &Parts, name: &str) -> Option<String> {
|
||||||
|
let raw = parts.headers.get(axum::http::header::COOKIE)?.to_str().ok()?;
|
||||||
|
raw.split(';').find_map(|pair| {
|
||||||
|
let mut kv = pair.trim().splitn(2, '=');
|
||||||
|
let key = kv.next()?;
|
||||||
|
let value = kv.next()?;
|
||||||
|
(key == name).then(|| value.to_string())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn now_ts() -> u64 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_else(|_| Duration::from_secs(0))
|
||||||
|
.as_secs()
|
||||||
|
}
|
||||||
63
src/config.rs
Normal file
63
src/config.rs
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct Settings {
|
||||||
|
pub database_url: String,
|
||||||
|
pub oidc_client_id: String,
|
||||||
|
pub oidc_client_secret: String,
|
||||||
|
pub oidc_authorize_url: String,
|
||||||
|
pub oidc_token_url: String,
|
||||||
|
pub oidc_userinfo_url: String,
|
||||||
|
pub oidc_redirect_url: String,
|
||||||
|
pub oidc_scopes: String,
|
||||||
|
pub session_secret: String,
|
||||||
|
pub cookie_secure: bool,
|
||||||
|
pub stun_urls: Vec<String>,
|
||||||
|
pub turn_urls: Vec<String>,
|
||||||
|
pub turn_username: Option<String>,
|
||||||
|
pub turn_password: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Settings {
|
||||||
|
pub fn from_env() -> Result<Self> {
|
||||||
|
Ok(Self {
|
||||||
|
database_url: required("DATABASE_URL")?,
|
||||||
|
oidc_client_id: required("OIDC_CLIENT_ID")?,
|
||||||
|
oidc_client_secret: required("OIDC_CLIENT_SECRET")?,
|
||||||
|
oidc_authorize_url: required("OIDC_AUTHORIZE_URL")?,
|
||||||
|
oidc_token_url: required("OIDC_TOKEN_URL")?,
|
||||||
|
oidc_userinfo_url: required("OIDC_USERINFO_URL")?,
|
||||||
|
oidc_redirect_url: required("OIDC_REDIRECT_URL")?,
|
||||||
|
oidc_scopes: std::env::var("OIDC_SCOPES").unwrap_or_else(|_| "openid profile email".to_string()),
|
||||||
|
session_secret: required("SESSION_SECRET")?,
|
||||||
|
cookie_secure: std::env::var("COOKIE_SECURE")
|
||||||
|
.unwrap_or_else(|_| "false".into())
|
||||||
|
.parse()
|
||||||
|
.context("COOKIE_SECURE must be true/false")?,
|
||||||
|
stun_urls: parse_csv_env("STUN_URLS", "stun:stun.l.google.com:19302"),
|
||||||
|
turn_urls: parse_csv_env("TURN_URLS", ""),
|
||||||
|
turn_username: optional("TURN_USERNAME"),
|
||||||
|
turn_password: optional("TURN_PASSWORD"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn required(name: &str) -> Result<String> {
|
||||||
|
std::env::var(name).with_context(|| format!("missing env var {name}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn optional(name: &str) -> Option<String> {
|
||||||
|
std::env::var(name)
|
||||||
|
.ok()
|
||||||
|
.map(|v| v.trim().to_string())
|
||||||
|
.filter(|v| !v.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_csv_env(name: &str, default_value: &str) -> Vec<String> {
|
||||||
|
let raw = std::env::var(name).unwrap_or_else(|_| default_value.to_string());
|
||||||
|
raw.split(',')
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(ToString::to_string)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
505
src/db.rs
Normal file
505
src/db.rs
Normal file
|
|
@ -0,0 +1,505 @@
|
||||||
|
use anyhow::{Result, anyhow};
|
||||||
|
use chrono::{Duration, Utc};
|
||||||
|
use sea_orm::{
|
||||||
|
ActiveModelTrait, ActiveValue::Set, ColumnTrait, DatabaseConnection,
|
||||||
|
Condition, DatabaseTransaction, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect,
|
||||||
|
TransactionTrait, sea_query::OnConflict,
|
||||||
|
};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
entity::{channels, direct_messages, guild_members, guilds, invites, messages, users},
|
||||||
|
models::{BasicUser, Channel, DmConversation, DmMessageWithAuthor, Guild, Invite, MessageWithAuthor, User},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const CHANNEL_KIND_TEXT: &str = "text";
|
||||||
|
pub const CHANNEL_KIND_VOICE: &str = "voice";
|
||||||
|
|
||||||
|
pub async fn user_exists(db: &DatabaseConnection, user_id: Uuid) -> Result<bool> {
|
||||||
|
let count = users::Entity::find_by_id(user_id).count(db).await?;
|
||||||
|
Ok(count > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn upsert_user_from_oidc(
|
||||||
|
db: &DatabaseConnection,
|
||||||
|
oidc_sub: &str,
|
||||||
|
email: Option<&str>,
|
||||||
|
display_name: &str,
|
||||||
|
avatar_url: Option<&str>,
|
||||||
|
) -> Result<User> {
|
||||||
|
let user = users::Entity::insert(users::ActiveModel {
|
||||||
|
id: Set(Uuid::new_v4()),
|
||||||
|
oidc_sub: Set(oidc_sub.to_string()),
|
||||||
|
email: Set(email.map(ToString::to_string)),
|
||||||
|
display_name: Set(display_name.to_string()),
|
||||||
|
avatar_url: Set(avatar_url.map(ToString::to_string)),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.on_conflict(
|
||||||
|
OnConflict::column(users::Column::OidcSub)
|
||||||
|
.update_columns([
|
||||||
|
users::Column::Email,
|
||||||
|
users::Column::DisplayName,
|
||||||
|
users::Column::AvatarUrl,
|
||||||
|
])
|
||||||
|
.value(users::Column::UpdatedAt, sea_orm::sea_query::Expr::current_timestamp())
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.exec_with_returning(db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(map_user(user))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_user_by_id(db: &DatabaseConnection, user_id: Uuid) -> Result<Option<User>> {
|
||||||
|
let row = users::Entity::find_by_id(user_id).one(db).await?;
|
||||||
|
Ok(row.map(map_user))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_guild_members(db: &DatabaseConnection, guild_id: Uuid) -> Result<Vec<BasicUser>> {
|
||||||
|
let rows = guild_members::Entity::find()
|
||||||
|
.filter(guild_members::Column::GuildId.eq(guild_id))
|
||||||
|
.find_also_related(users::Entity)
|
||||||
|
.order_by_asc(guild_members::Column::CreatedAt)
|
||||||
|
.all(db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|(_, user)| user)
|
||||||
|
.map(|u| BasicUser {
|
||||||
|
id: u.id,
|
||||||
|
display_name: u.display_name,
|
||||||
|
avatar_url: u.avatar_url,
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_guilds_for_user(db: &DatabaseConnection, user_id: Uuid) -> Result<Vec<Guild>> {
|
||||||
|
let rows = guild_members::Entity::find()
|
||||||
|
.filter(guild_members::Column::UserId.eq(user_id))
|
||||||
|
.find_also_related(guilds::Entity)
|
||||||
|
.all(db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|(_, guild)| guild.map(map_guild))
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_guild(db: &DatabaseConnection, owner_user_id: Uuid, name: &str) -> Result<Guild> {
|
||||||
|
let guild = guilds::Entity::insert(guilds::ActiveModel {
|
||||||
|
id: Set(Uuid::new_v4()),
|
||||||
|
name: Set(name.to_string()),
|
||||||
|
owner_user_id: Set(owner_user_id),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.exec_with_returning(db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
guild_members::Entity::insert(guild_members::ActiveModel {
|
||||||
|
guild_id: Set(guild.id),
|
||||||
|
user_id: Set(owner_user_id),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.on_conflict(
|
||||||
|
OnConflict::columns([guild_members::Column::GuildId, guild_members::Column::UserId])
|
||||||
|
.do_nothing()
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.exec(db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(map_guild(guild))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_invite(
|
||||||
|
db: &DatabaseConnection,
|
||||||
|
guild_id: Uuid,
|
||||||
|
created_by_user_id: Uuid,
|
||||||
|
max_uses: Option<i32>,
|
||||||
|
expires_in_hours: Option<i64>,
|
||||||
|
) -> Result<Invite> {
|
||||||
|
let expires_at = expires_in_hours.map(|h| (Utc::now() + Duration::hours(h)).fixed_offset());
|
||||||
|
|
||||||
|
for _ in 0..8 {
|
||||||
|
let code = generate_invite_code();
|
||||||
|
let insert = invites::Entity::insert(invites::ActiveModel {
|
||||||
|
code: Set(code.clone()),
|
||||||
|
guild_id: Set(guild_id),
|
||||||
|
created_by_user_id: Set(created_by_user_id),
|
||||||
|
expires_at: Set(expires_at),
|
||||||
|
max_uses: Set(max_uses),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.exec(db)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match insert {
|
||||||
|
Ok(_) => {
|
||||||
|
let row = invites::Entity::find_by_id(code)
|
||||||
|
.one(db)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| anyhow!("created invite missing"))?;
|
||||||
|
return Ok(map_invite(row));
|
||||||
|
}
|
||||||
|
Err(err) if err.to_string().contains("duplicate key") => continue,
|
||||||
|
Err(err) => return Err(err.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(anyhow!("failed to generate unique invite code"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn join_invite(db: &DatabaseConnection, code: &str, user_id: Uuid) -> Result<Guild> {
|
||||||
|
let txn = db.begin().await?;
|
||||||
|
|
||||||
|
let invite = invites::Entity::find_by_id(code.to_string())
|
||||||
|
.one(&txn)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| anyhow!("invite not found"))?;
|
||||||
|
|
||||||
|
validate_invite(&invite)?;
|
||||||
|
let guild_id = invite.guild_id;
|
||||||
|
|
||||||
|
let already_member = guild_members::Entity::find()
|
||||||
|
.filter(guild_members::Column::GuildId.eq(invite.guild_id))
|
||||||
|
.filter(guild_members::Column::UserId.eq(user_id))
|
||||||
|
.one(&txn)
|
||||||
|
.await?
|
||||||
|
.is_some();
|
||||||
|
|
||||||
|
guild_members::Entity::insert(guild_members::ActiveModel {
|
||||||
|
guild_id: Set(invite.guild_id),
|
||||||
|
user_id: Set(user_id),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.on_conflict(
|
||||||
|
OnConflict::columns([guild_members::Column::GuildId, guild_members::Column::UserId])
|
||||||
|
.do_nothing()
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.exec(&txn)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if !already_member {
|
||||||
|
increment_invite_use_count(&txn, invite).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let guild = guilds::Entity::find_by_id(guild_id)
|
||||||
|
.one(&txn)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| anyhow!("guild for invite not found"))?;
|
||||||
|
|
||||||
|
txn.commit().await?;
|
||||||
|
Ok(map_guild(guild))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_channel(
|
||||||
|
db: &DatabaseConnection,
|
||||||
|
guild_id: Uuid,
|
||||||
|
name: &str,
|
||||||
|
kind: &str,
|
||||||
|
) -> Result<Channel> {
|
||||||
|
let channel = channels::Entity::insert(channels::ActiveModel {
|
||||||
|
id: Set(Uuid::new_v4()),
|
||||||
|
guild_id: Set(guild_id),
|
||||||
|
name: Set(name.to_string()),
|
||||||
|
kind: Set(kind.to_string()),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.exec_with_returning(db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(map_channel(channel))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_channel_by_id(db: &DatabaseConnection, channel_id: Uuid) -> Result<Option<Channel>> {
|
||||||
|
let row = channels::Entity::find_by_id(channel_id).one(db).await?;
|
||||||
|
Ok(row.map(map_channel))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_channels_for_guild(db: &DatabaseConnection, guild_id: Uuid) -> Result<Vec<Channel>> {
|
||||||
|
let channels = channels::Entity::find()
|
||||||
|
.filter(channels::Column::GuildId.eq(guild_id))
|
||||||
|
.order_by_asc(channels::Column::CreatedAt)
|
||||||
|
.all(db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(channels.into_iter().map(map_channel).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_voice_channels_for_guild(db: &DatabaseConnection, guild_id: Uuid) -> Result<Vec<Channel>> {
|
||||||
|
let channels = channels::Entity::find()
|
||||||
|
.filter(channels::Column::GuildId.eq(guild_id))
|
||||||
|
.filter(channels::Column::Kind.eq(CHANNEL_KIND_VOICE))
|
||||||
|
.order_by_asc(channels::Column::CreatedAt)
|
||||||
|
.all(db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(channels.into_iter().map(map_channel).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn is_member_of_guild(db: &DatabaseConnection, guild_id: Uuid, user_id: Uuid) -> Result<bool> {
|
||||||
|
let count = guild_members::Entity::find()
|
||||||
|
.filter(guild_members::Column::GuildId.eq(guild_id))
|
||||||
|
.filter(guild_members::Column::UserId.eq(user_id))
|
||||||
|
.count(db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(count > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn guild_id_for_channel(db: &DatabaseConnection, channel_id: Uuid) -> Result<Option<Uuid>> {
|
||||||
|
let guild_id = channels::Entity::find_by_id(channel_id)
|
||||||
|
.select_only()
|
||||||
|
.column(channels::Column::GuildId)
|
||||||
|
.into_tuple::<Uuid>()
|
||||||
|
.one(db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(guild_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_message(
|
||||||
|
db: &DatabaseConnection,
|
||||||
|
channel_id: Uuid,
|
||||||
|
author_user_id: Uuid,
|
||||||
|
body: &str,
|
||||||
|
) -> Result<()> {
|
||||||
|
messages::Entity::insert(messages::ActiveModel {
|
||||||
|
id: Set(Uuid::new_v4()),
|
||||||
|
channel_id: Set(channel_id),
|
||||||
|
author_user_id: Set(author_user_id),
|
||||||
|
body: Set(body.to_string()),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.exec(db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_messages(
|
||||||
|
db: &DatabaseConnection,
|
||||||
|
channel_id: Uuid,
|
||||||
|
limit: i64,
|
||||||
|
) -> Result<Vec<MessageWithAuthor>> {
|
||||||
|
let rows = messages::Entity::find()
|
||||||
|
.filter(messages::Column::ChannelId.eq(channel_id))
|
||||||
|
.find_also_related(users::Entity)
|
||||||
|
.order_by_desc(messages::Column::CreatedAt)
|
||||||
|
.limit(limit as u64)
|
||||||
|
.all(db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|(msg, user)| {
|
||||||
|
let author_display_name = user
|
||||||
|
.map(|u| u.display_name)
|
||||||
|
.unwrap_or_else(|| "Unknown User".to_string());
|
||||||
|
MessageWithAuthor {
|
||||||
|
id: msg.id,
|
||||||
|
channel_id: msg.channel_id,
|
||||||
|
author_user_id: msg.author_user_id,
|
||||||
|
author_display_name,
|
||||||
|
body: msg.body,
|
||||||
|
created_at: msg.created_at,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_direct_message(
|
||||||
|
db: &DatabaseConnection,
|
||||||
|
sender_user_id: Uuid,
|
||||||
|
recipient_user_id: Uuid,
|
||||||
|
body: &str,
|
||||||
|
) -> Result<()> {
|
||||||
|
direct_messages::Entity::insert(direct_messages::ActiveModel {
|
||||||
|
id: Set(Uuid::new_v4()),
|
||||||
|
sender_user_id: Set(sender_user_id),
|
||||||
|
recipient_user_id: Set(recipient_user_id),
|
||||||
|
body: Set(body.to_string()),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.exec(db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_direct_messages(
|
||||||
|
db: &DatabaseConnection,
|
||||||
|
current_user_id: Uuid,
|
||||||
|
other_user_id: Uuid,
|
||||||
|
limit: i64,
|
||||||
|
) -> Result<Vec<DmMessageWithAuthor>> {
|
||||||
|
let rows = direct_messages::Entity::find()
|
||||||
|
.filter(
|
||||||
|
Condition::any()
|
||||||
|
.add(
|
||||||
|
Condition::all()
|
||||||
|
.add(direct_messages::Column::SenderUserId.eq(current_user_id))
|
||||||
|
.add(direct_messages::Column::RecipientUserId.eq(other_user_id)),
|
||||||
|
)
|
||||||
|
.add(
|
||||||
|
Condition::all()
|
||||||
|
.add(direct_messages::Column::SenderUserId.eq(other_user_id))
|
||||||
|
.add(direct_messages::Column::RecipientUserId.eq(current_user_id)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.order_by_desc(direct_messages::Column::CreatedAt)
|
||||||
|
.limit(limit as u64)
|
||||||
|
.all(db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let mut author_ids = rows.iter().map(|m| m.sender_user_id).collect::<Vec<_>>();
|
||||||
|
author_ids.sort_unstable();
|
||||||
|
author_ids.dedup();
|
||||||
|
|
||||||
|
let authors = users::Entity::find()
|
||||||
|
.filter(users::Column::Id.is_in(author_ids))
|
||||||
|
.all(db)
|
||||||
|
.await?;
|
||||||
|
let author_names: std::collections::HashMap<Uuid, String> = authors
|
||||||
|
.into_iter()
|
||||||
|
.map(|u| (u.id, u.display_name))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|msg| DmMessageWithAuthor {
|
||||||
|
id: msg.id,
|
||||||
|
author_user_id: msg.sender_user_id,
|
||||||
|
recipient_user_id: msg.recipient_user_id,
|
||||||
|
author_display_name: author_names
|
||||||
|
.get(&msg.sender_user_id)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| "Unknown User".to_string()),
|
||||||
|
body: msg.body,
|
||||||
|
created_at: msg.created_at,
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_dm_conversations(db: &DatabaseConnection, current_user_id: Uuid) -> Result<Vec<DmConversation>> {
|
||||||
|
let rows = direct_messages::Entity::find()
|
||||||
|
.filter(
|
||||||
|
Condition::any()
|
||||||
|
.add(direct_messages::Column::SenderUserId.eq(current_user_id))
|
||||||
|
.add(direct_messages::Column::RecipientUserId.eq(current_user_id)),
|
||||||
|
)
|
||||||
|
.order_by_desc(direct_messages::Column::CreatedAt)
|
||||||
|
.all(db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let mut latest_by_peer = std::collections::HashMap::<Uuid, chrono::DateTime<chrono::FixedOffset>>::new();
|
||||||
|
for row in rows {
|
||||||
|
let peer_id = if row.sender_user_id == current_user_id {
|
||||||
|
row.recipient_user_id
|
||||||
|
} else {
|
||||||
|
row.sender_user_id
|
||||||
|
};
|
||||||
|
latest_by_peer.entry(peer_id).or_insert(row.created_at);
|
||||||
|
}
|
||||||
|
|
||||||
|
if latest_by_peer.is_empty() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let peer_ids: Vec<Uuid> = latest_by_peer.keys().copied().collect();
|
||||||
|
let peers = users::Entity::find()
|
||||||
|
.filter(users::Column::Id.is_in(peer_ids))
|
||||||
|
.all(db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let mut conversations: Vec<DmConversation> = peers
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|u| {
|
||||||
|
let last = latest_by_peer.get(&u.id)?;
|
||||||
|
Some(DmConversation {
|
||||||
|
user_id: u.id,
|
||||||
|
display_name: u.display_name,
|
||||||
|
avatar_url: u.avatar_url,
|
||||||
|
last_message_at: *last,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
conversations.sort_by(|a, b| b.last_message_at.cmp(&a.last_message_at));
|
||||||
|
Ok(conversations)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_user(model: users::Model) -> User {
|
||||||
|
User {
|
||||||
|
id: model.id,
|
||||||
|
oidc_sub: model.oidc_sub,
|
||||||
|
email: model.email,
|
||||||
|
display_name: model.display_name,
|
||||||
|
avatar_url: model.avatar_url,
|
||||||
|
created_at: model.created_at,
|
||||||
|
updated_at: model.updated_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_guild(model: guilds::Model) -> Guild {
|
||||||
|
Guild {
|
||||||
|
id: model.id,
|
||||||
|
name: model.name,
|
||||||
|
owner_user_id: model.owner_user_id,
|
||||||
|
created_at: model.created_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_channel(model: channels::Model) -> Channel {
|
||||||
|
Channel {
|
||||||
|
id: model.id,
|
||||||
|
guild_id: model.guild_id,
|
||||||
|
name: model.name,
|
||||||
|
kind: model.kind,
|
||||||
|
created_at: model.created_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_invite(model: invites::Model) -> Invite {
|
||||||
|
Invite {
|
||||||
|
code: model.code,
|
||||||
|
guild_id: model.guild_id,
|
||||||
|
created_by_user_id: model.created_by_user_id,
|
||||||
|
created_at: model.created_at,
|
||||||
|
expires_at: model.expires_at,
|
||||||
|
max_uses: model.max_uses,
|
||||||
|
use_count: model.use_count,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_invite_code() -> String {
|
||||||
|
Uuid::new_v4().simple().to_string()[..10].to_uppercase()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_invite(invite: &invites::Model) -> Result<()> {
|
||||||
|
if let Some(expires_at) = invite.expires_at {
|
||||||
|
if expires_at < Utc::now().fixed_offset() {
|
||||||
|
return Err(anyhow!("invite expired"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(max_uses) = invite.max_uses {
|
||||||
|
if invite.use_count >= max_uses {
|
||||||
|
return Err(anyhow!("invite exhausted"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn increment_invite_use_count(txn: &DatabaseTransaction, invite: invites::Model) -> Result<()> {
|
||||||
|
let next_count = invite.use_count + 1;
|
||||||
|
let mut active: invites::ActiveModel = invite.into();
|
||||||
|
active.use_count = Set(next_count);
|
||||||
|
active.update(txn).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
32
src/entity/channels.rs
Normal file
32
src/entity/channels.rs
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||||
|
#[sea_orm(table_name = "channels")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key)]
|
||||||
|
pub id: Uuid,
|
||||||
|
pub guild_id: Uuid,
|
||||||
|
pub name: String,
|
||||||
|
pub kind: String,
|
||||||
|
pub created_at: DateTimeWithTimeZone,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {
|
||||||
|
#[sea_orm(
|
||||||
|
belongs_to = "super::guilds::Entity",
|
||||||
|
from = "Column::GuildId",
|
||||||
|
to = "super::guilds::Column::Id"
|
||||||
|
)]
|
||||||
|
Guilds,
|
||||||
|
#[sea_orm(has_many = "super::messages::Entity")]
|
||||||
|
Messages,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::guilds::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::Guilds.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
31
src/entity/direct_messages.rs
Normal file
31
src/entity/direct_messages.rs
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
|
||||||
|
#[sea_orm(table_name = "direct_messages")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
|
pub id: Uuid,
|
||||||
|
pub sender_user_id: Uuid,
|
||||||
|
pub recipient_user_id: Uuid,
|
||||||
|
pub body: String,
|
||||||
|
pub created_at: DateTimeWithTimeZone,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {
|
||||||
|
#[sea_orm(
|
||||||
|
belongs_to = "super::users::Entity",
|
||||||
|
from = "Column::SenderUserId",
|
||||||
|
to = "super::users::Column::Id"
|
||||||
|
)]
|
||||||
|
SenderUser,
|
||||||
|
#[sea_orm(
|
||||||
|
belongs_to = "super::users::Entity",
|
||||||
|
from = "Column::RecipientUserId",
|
||||||
|
to = "super::users::Column::Id"
|
||||||
|
)]
|
||||||
|
RecipientUser,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
41
src/entity/guild_members.rs
Normal file
41
src/entity/guild_members.rs
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||||
|
#[sea_orm(table_name = "guild_members")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
|
pub guild_id: Uuid,
|
||||||
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
|
pub user_id: Uuid,
|
||||||
|
pub created_at: DateTimeWithTimeZone,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {
|
||||||
|
#[sea_orm(
|
||||||
|
belongs_to = "super::guilds::Entity",
|
||||||
|
from = "Column::GuildId",
|
||||||
|
to = "super::guilds::Column::Id"
|
||||||
|
)]
|
||||||
|
Guilds,
|
||||||
|
#[sea_orm(
|
||||||
|
belongs_to = "super::users::Entity",
|
||||||
|
from = "Column::UserId",
|
||||||
|
to = "super::users::Column::Id"
|
||||||
|
)]
|
||||||
|
Users,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::guilds::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::Guilds.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::users::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::Users.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
31
src/entity/guilds.rs
Normal file
31
src/entity/guilds.rs
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||||
|
#[sea_orm(table_name = "guilds")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key)]
|
||||||
|
pub id: Uuid,
|
||||||
|
pub name: String,
|
||||||
|
pub owner_user_id: Uuid,
|
||||||
|
pub created_at: DateTimeWithTimeZone,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {
|
||||||
|
#[sea_orm(
|
||||||
|
belongs_to = "super::users::Entity",
|
||||||
|
from = "Column::OwnerUserId",
|
||||||
|
to = "super::users::Column::Id"
|
||||||
|
)]
|
||||||
|
Users,
|
||||||
|
#[sea_orm(has_many = "super::channels::Entity")]
|
||||||
|
Channels,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::users::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::Users.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
44
src/entity/invites.rs
Normal file
44
src/entity/invites.rs
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||||
|
#[sea_orm(table_name = "invites")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
|
pub code: String,
|
||||||
|
pub guild_id: Uuid,
|
||||||
|
pub created_by_user_id: Uuid,
|
||||||
|
pub created_at: DateTimeWithTimeZone,
|
||||||
|
pub expires_at: Option<DateTimeWithTimeZone>,
|
||||||
|
pub max_uses: Option<i32>,
|
||||||
|
pub use_count: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {
|
||||||
|
#[sea_orm(
|
||||||
|
belongs_to = "super::guilds::Entity",
|
||||||
|
from = "Column::GuildId",
|
||||||
|
to = "super::guilds::Column::Id"
|
||||||
|
)]
|
||||||
|
Guilds,
|
||||||
|
#[sea_orm(
|
||||||
|
belongs_to = "super::users::Entity",
|
||||||
|
from = "Column::CreatedByUserId",
|
||||||
|
to = "super::users::Column::Id"
|
||||||
|
)]
|
||||||
|
Users,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::guilds::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::Guilds.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::users::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::Users.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
42
src/entity/messages.rs
Normal file
42
src/entity/messages.rs
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||||
|
#[sea_orm(table_name = "messages")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key)]
|
||||||
|
pub id: Uuid,
|
||||||
|
pub channel_id: Uuid,
|
||||||
|
pub author_user_id: Uuid,
|
||||||
|
pub body: String,
|
||||||
|
pub created_at: DateTimeWithTimeZone,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {
|
||||||
|
#[sea_orm(
|
||||||
|
belongs_to = "super::channels::Entity",
|
||||||
|
from = "Column::ChannelId",
|
||||||
|
to = "super::channels::Column::Id"
|
||||||
|
)]
|
||||||
|
Channels,
|
||||||
|
#[sea_orm(
|
||||||
|
belongs_to = "super::users::Entity",
|
||||||
|
from = "Column::AuthorUserId",
|
||||||
|
to = "super::users::Column::Id"
|
||||||
|
)]
|
||||||
|
Users,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::channels::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::Channels.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::users::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::Users.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
7
src/entity/mod.rs
Normal file
7
src/entity/mod.rs
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
pub mod channels;
|
||||||
|
pub mod direct_messages;
|
||||||
|
pub mod guild_members;
|
||||||
|
pub mod guilds;
|
||||||
|
pub mod invites;
|
||||||
|
pub mod messages;
|
||||||
|
pub mod users;
|
||||||
30
src/entity/users.rs
Normal file
30
src/entity/users.rs
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||||
|
#[sea_orm(table_name = "users")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key)]
|
||||||
|
pub id: Uuid,
|
||||||
|
pub oidc_sub: String,
|
||||||
|
pub email: Option<String>,
|
||||||
|
pub display_name: String,
|
||||||
|
pub avatar_url: Option<String>,
|
||||||
|
pub created_at: DateTimeWithTimeZone,
|
||||||
|
pub updated_at: DateTimeWithTimeZone,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {
|
||||||
|
#[sea_orm(has_many = "super::guilds::Entity")]
|
||||||
|
Guilds,
|
||||||
|
#[sea_orm(has_many = "super::messages::Entity")]
|
||||||
|
Messages,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::guilds::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::Guilds.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
617
src/handlers.rs
Normal file
617
src/handlers.rs
Normal file
|
|
@ -0,0 +1,617 @@
|
||||||
|
use axum::{
|
||||||
|
Json, Router,
|
||||||
|
extract::{Path, Query, State, WebSocketUpgrade},
|
||||||
|
http::{HeaderMap, StatusCode, header},
|
||||||
|
response::{Html, IntoResponse, Redirect},
|
||||||
|
routing::{get, post},
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
AppState,
|
||||||
|
auth::{self, ApiError, AuthUser},
|
||||||
|
db, voice,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn routes() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/", get(index))
|
||||||
|
.route("/auth/login", get(auth_login))
|
||||||
|
.route("/auth/callback", get(auth_callback))
|
||||||
|
.route("/auth/logout", post(auth_logout))
|
||||||
|
.route("/me", get(me))
|
||||||
|
.route("/dms", get(list_dm_conversations))
|
||||||
|
.route("/dms/{other_user_id}/messages", get(list_dm_messages).post(send_dm_message))
|
||||||
|
.route("/rtc-config", get(rtc_config))
|
||||||
|
.route("/guilds", get(list_guilds).post(create_guild))
|
||||||
|
.route("/guilds/{guild_id}/members", get(list_guild_members))
|
||||||
|
.route("/guilds/{guild_id}/channels", get(list_channels))
|
||||||
|
.route("/guilds/{guild_id}/voice-presence", get(guild_voice_presence))
|
||||||
|
.route("/guilds/{guild_id}/invites", post(create_invite))
|
||||||
|
.route("/invites/{code}/join", post(join_invite))
|
||||||
|
.route("/channels", post(create_channel))
|
||||||
|
.route("/channels/{channel_id}/messages", get(list_messages).post(send_message))
|
||||||
|
.route("/channels/{channel_id}/voice/ws", get(voice_ws))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn health() -> &'static str {
|
||||||
|
"ok"
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn index() -> Html<&'static str> {
|
||||||
|
Html(include_str!("../static/index.html"))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn auth_login(State(state): State<AppState>) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
let oauth_state = auth::new_oauth_state();
|
||||||
|
|
||||||
|
let authorize_url = format!(
|
||||||
|
"{}?response_type=code&client_id={}&redirect_uri={}&scope={}&state={}",
|
||||||
|
state.settings.oidc_authorize_url,
|
||||||
|
urlencoding::encode(&state.settings.oidc_client_id),
|
||||||
|
urlencoding::encode(&state.settings.oidc_redirect_url),
|
||||||
|
urlencoding::encode(&state.settings.oidc_scopes),
|
||||||
|
urlencoding::encode(&oauth_state)
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(
|
||||||
|
header::SET_COOKIE,
|
||||||
|
auth::make_oauth_state_cookie(&oauth_state, state.settings.cookie_secure)
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| ApiError::internal("failed to set oauth cookie"))?,
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok((headers, Redirect::to(&authorize_url)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct AuthCallbackQuery {
|
||||||
|
code: String,
|
||||||
|
state: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct OidcTokenResponse {
|
||||||
|
access_token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct OidcUserInfo {
|
||||||
|
sub: String,
|
||||||
|
email: Option<String>,
|
||||||
|
preferred_username: Option<String>,
|
||||||
|
nickname: Option<String>,
|
||||||
|
given_name: Option<String>,
|
||||||
|
family_name: Option<String>,
|
||||||
|
name: Option<String>,
|
||||||
|
picture: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalized_claim(value: Option<&str>) -> Option<String> {
|
||||||
|
let v = value?.trim();
|
||||||
|
if v.is_empty() || v.eq_ignore_ascii_case("null") {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(v.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn choose_display_name(profile: &OidcUserInfo) -> String {
|
||||||
|
if let Some(v) = normalized_claim(profile.name.as_deref()) {
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
if let Some(v) = normalized_claim(profile.nickname.as_deref()) {
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
if let Some(v) = normalized_claim(profile.preferred_username.as_deref()) {
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
let given = normalized_claim(profile.given_name.as_deref());
|
||||||
|
let family = normalized_claim(profile.family_name.as_deref());
|
||||||
|
match (given, family) {
|
||||||
|
(Some(g), Some(f)) => format!("{g} {f}"),
|
||||||
|
(Some(g), None) => g,
|
||||||
|
(None, Some(f)) => f,
|
||||||
|
(None, None) => "User".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn auth_callback(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Query(query): Query<AuthCallbackQuery>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
auth::validate_oauth_state(auth::read_oauth_state_from_headers(&headers), &query.state)
|
||||||
|
.map_err(|e| ApiError::bad_request(&e.to_string()))?;
|
||||||
|
|
||||||
|
let token_res = state
|
||||||
|
.http
|
||||||
|
.post(&state.settings.oidc_token_url)
|
||||||
|
.form(&[
|
||||||
|
("grant_type", "authorization_code"),
|
||||||
|
("code", query.code.as_str()),
|
||||||
|
("redirect_uri", state.settings.oidc_redirect_url.as_str()),
|
||||||
|
("client_id", state.settings.oidc_client_id.as_str()),
|
||||||
|
("client_secret", state.settings.oidc_client_secret.as_str()),
|
||||||
|
])
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::bad_request(&format!("token exchange failed: {e}")))?;
|
||||||
|
|
||||||
|
if !token_res.status().is_success() {
|
||||||
|
let text = token_res.text().await.unwrap_or_else(|_| "<no body>".to_string());
|
||||||
|
return Err(ApiError::bad_request(&format!(
|
||||||
|
"token exchange returned non-success: {text}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let token: OidcTokenResponse = token_res
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::bad_request(&format!("invalid token response: {e}")))?;
|
||||||
|
|
||||||
|
let userinfo_res = state
|
||||||
|
.http
|
||||||
|
.get(&state.settings.oidc_userinfo_url)
|
||||||
|
.bearer_auth(&token.access_token)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::bad_request(&format!("userinfo request failed: {e}")))?;
|
||||||
|
|
||||||
|
if !userinfo_res.status().is_success() {
|
||||||
|
let text = userinfo_res.text().await.unwrap_or_else(|_| "<no body>".to_string());
|
||||||
|
return Err(ApiError::bad_request(&format!(
|
||||||
|
"userinfo returned non-success: {text}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let profile: OidcUserInfo = userinfo_res
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::bad_request(&format!("invalid userinfo response: {e}")))?;
|
||||||
|
|
||||||
|
let display_name = choose_display_name(&profile);
|
||||||
|
|
||||||
|
let user = db::upsert_user_from_oidc(
|
||||||
|
&state.db,
|
||||||
|
&profile.sub,
|
||||||
|
profile.email.as_deref(),
|
||||||
|
&display_name,
|
||||||
|
profile.picture.as_deref(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::internal(&format!("failed to persist user: {e}")))?;
|
||||||
|
|
||||||
|
let session_cookie =
|
||||||
|
auth::new_session_cookie(user.id, &state.settings.session_secret, state.settings.cookie_secure)
|
||||||
|
.map_err(|e| ApiError::internal(&e.to_string()))?;
|
||||||
|
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.append(
|
||||||
|
header::SET_COOKIE,
|
||||||
|
session_cookie
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| ApiError::internal("failed to set session cookie"))?,
|
||||||
|
);
|
||||||
|
headers.append(
|
||||||
|
header::SET_COOKIE,
|
||||||
|
auth::clear_oauth_state_cookie(state.settings.cookie_secure)
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| ApiError::internal("failed to clear oauth state cookie"))?,
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok((headers, Redirect::to("/")))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn auth_logout(State(state): State<AppState>) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.append(
|
||||||
|
header::SET_COOKIE,
|
||||||
|
auth::clear_session_cookie(state.settings.cookie_secure)
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| ApiError::internal("failed to clear session cookie"))?,
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok((StatusCode::NO_CONTENT, headers))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct RtcConfigResponse {
|
||||||
|
ice_servers: Vec<RtcIceServer>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct RtcIceServer {
|
||||||
|
urls: Vec<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
username: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
credential: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn rtc_config(State(state): State<AppState>, _user: AuthUser) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
let mut ice_servers = Vec::new();
|
||||||
|
|
||||||
|
if !state.settings.stun_urls.is_empty() {
|
||||||
|
ice_servers.push(RtcIceServer {
|
||||||
|
urls: state.settings.stun_urls.clone(),
|
||||||
|
username: None,
|
||||||
|
credential: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if !state.settings.turn_urls.is_empty() {
|
||||||
|
ice_servers.push(RtcIceServer {
|
||||||
|
urls: state.settings.turn_urls.clone(),
|
||||||
|
username: state.settings.turn_username.clone(),
|
||||||
|
credential: state.settings.turn_password.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if ice_servers.is_empty() {
|
||||||
|
ice_servers.push(RtcIceServer {
|
||||||
|
urls: vec!["stun:stun.l.google.com:19302".to_string()],
|
||||||
|
username: None,
|
||||||
|
credential: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Json(RtcConfigResponse { ice_servers }))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn me(State(state): State<AppState>, user: AuthUser) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
let me = db::get_user_by_id(&state.db, user.id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::internal(&format!("failed to load user: {e}")))?
|
||||||
|
.ok_or_else(|| ApiError::unauthorized("no user"))?;
|
||||||
|
|
||||||
|
Ok(Json(me))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_dm_conversations(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
user: AuthUser,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
let conversations = db::list_dm_conversations(&state.db, user.id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::internal(&format!("failed to list dm conversations: {e}")))?;
|
||||||
|
Ok(Json(conversations))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_guild_members(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
user: AuthUser,
|
||||||
|
Path(guild_id): Path<Uuid>,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
ensure_guild_member(&state, guild_id, user.id).await?;
|
||||||
|
let members = db::list_guild_members(&state.db, guild_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::internal(&format!("failed to list guild members: {e}")))?;
|
||||||
|
Ok(Json(members))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_guilds(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
user: AuthUser,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
let guilds = db::list_guilds_for_user(&state.db, user.id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::internal(&format!("failed to list guilds: {e}")))?;
|
||||||
|
|
||||||
|
Ok(Json(guilds))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct CreateGuildBody {
|
||||||
|
name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_guild(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
user: AuthUser,
|
||||||
|
Json(body): Json<CreateGuildBody>,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
let trimmed = body.name.trim();
|
||||||
|
if trimmed.is_empty() || trimmed.len() > 64 {
|
||||||
|
return Err(ApiError::bad_request("guild name must be 1..64 chars"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let guild = db::create_guild(&state.db, user.id, trimmed)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::internal(&format!("failed to create guild: {e}")))?;
|
||||||
|
Ok((StatusCode::CREATED, Json(guild)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct CreateInviteBody {
|
||||||
|
max_uses: Option<i32>,
|
||||||
|
expires_in_hours: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_invite(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
user: AuthUser,
|
||||||
|
Path(guild_id): Path<Uuid>,
|
||||||
|
Json(body): Json<CreateInviteBody>,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
ensure_guild_member(&state, guild_id, user.id).await?;
|
||||||
|
|
||||||
|
if let Some(v) = body.max_uses
|
||||||
|
&& v <= 0
|
||||||
|
{
|
||||||
|
return Err(ApiError::bad_request("max_uses must be > 0"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(v) = body.expires_in_hours
|
||||||
|
&& v <= 0
|
||||||
|
{
|
||||||
|
return Err(ApiError::bad_request("expires_in_hours must be > 0"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let invite = db::create_invite(
|
||||||
|
&state.db,
|
||||||
|
guild_id,
|
||||||
|
user.id,
|
||||||
|
body.max_uses,
|
||||||
|
body.expires_in_hours,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::internal(&format!("failed to create invite: {e}")))?;
|
||||||
|
|
||||||
|
Ok((StatusCode::CREATED, Json(invite)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn join_invite(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
user: AuthUser,
|
||||||
|
Path(code): Path<String>,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
let guild = db::join_invite(&state.db, &code.to_uppercase(), user.id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::bad_request(&format!("failed to join invite: {e}")))?;
|
||||||
|
|
||||||
|
Ok(Json(guild))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_channels(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
user: AuthUser,
|
||||||
|
Path(guild_id): Path<Uuid>,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
ensure_guild_member(&state, guild_id, user.id).await?;
|
||||||
|
|
||||||
|
let channels = db::list_channels_for_guild(&state.db, guild_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::internal(&format!("failed to list channels: {e}")))?;
|
||||||
|
Ok(Json(channels))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct GuildVoicePresenceItem {
|
||||||
|
channel_id: Uuid,
|
||||||
|
participants: Vec<voice::VoiceParticipant>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct GuildVoicePresenceResponse {
|
||||||
|
channels: Vec<GuildVoicePresenceItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn guild_voice_presence(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
user: AuthUser,
|
||||||
|
Path(guild_id): Path<Uuid>,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
ensure_guild_member(&state, guild_id, user.id).await?;
|
||||||
|
|
||||||
|
let voice_channels = db::list_voice_channels_for_guild(&state.db, guild_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::internal(&format!("failed to list voice channels: {e}")))?;
|
||||||
|
|
||||||
|
let mut channels = Vec::with_capacity(voice_channels.len());
|
||||||
|
for ch in voice_channels {
|
||||||
|
channels.push(GuildVoicePresenceItem {
|
||||||
|
channel_id: ch.id,
|
||||||
|
participants: state.voice.participants(ch.id).await,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Json(GuildVoicePresenceResponse { channels }))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct CreateChannelBody {
|
||||||
|
guild_id: Uuid,
|
||||||
|
name: String,
|
||||||
|
kind: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_channel(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
user: AuthUser,
|
||||||
|
Json(body): Json<CreateChannelBody>,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
ensure_guild_member(&state, body.guild_id, user.id).await?;
|
||||||
|
|
||||||
|
let trimmed = body.name.trim();
|
||||||
|
if trimmed.is_empty() || trimmed.len() > 64 {
|
||||||
|
return Err(ApiError::bad_request("channel name must be 1..64 chars"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let kind = body.kind.unwrap_or_else(|| db::CHANNEL_KIND_TEXT.to_string());
|
||||||
|
if kind != db::CHANNEL_KIND_TEXT && kind != db::CHANNEL_KIND_VOICE {
|
||||||
|
return Err(ApiError::bad_request("channel kind must be 'text' or 'voice'"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let channel = db::create_channel(&state.db, body.guild_id, trimmed, &kind)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::internal(&format!("failed to create channel: {e}")))?;
|
||||||
|
Ok((StatusCode::CREATED, Json(channel)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct ListMessagesQuery {
|
||||||
|
limit: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_messages(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
user: AuthUser,
|
||||||
|
Path(channel_id): Path<Uuid>,
|
||||||
|
Query(query): Query<ListMessagesQuery>,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
ensure_channel_member(&state, channel_id, user.id).await?;
|
||||||
|
|
||||||
|
let limit = query.limit.unwrap_or(50).clamp(1, 200);
|
||||||
|
|
||||||
|
let messages = db::list_messages(&state.db, channel_id, limit)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::internal(&format!("failed to list messages: {e}")))?;
|
||||||
|
|
||||||
|
Ok(Json(messages))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct SendMessageBody {
|
||||||
|
body: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct SendMessageResponse {
|
||||||
|
ok: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_message(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
user: AuthUser,
|
||||||
|
Path(channel_id): Path<Uuid>,
|
||||||
|
Json(body): Json<SendMessageBody>,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
ensure_channel_member(&state, channel_id, user.id).await?;
|
||||||
|
|
||||||
|
let content = body.body.trim();
|
||||||
|
if content.is_empty() || content.len() > 4000 {
|
||||||
|
return Err(ApiError::bad_request("message body must be 1..4000 chars"));
|
||||||
|
}
|
||||||
|
|
||||||
|
db::create_message(&state.db, channel_id, user.id, content)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::internal(&format!("failed to create message: {e}")))?;
|
||||||
|
|
||||||
|
Ok((StatusCode::CREATED, Json(SendMessageResponse { ok: true })))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_dm_messages(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
user: AuthUser,
|
||||||
|
Path(other_user_id): Path<Uuid>,
|
||||||
|
Query(query): Query<ListMessagesQuery>,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
if other_user_id == user.id {
|
||||||
|
return Err(ApiError::bad_request("cannot open dm with yourself"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let other_user_exists = db::user_exists(&state.db, other_user_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::internal(&format!("user check failed: {e}")))?;
|
||||||
|
if !other_user_exists {
|
||||||
|
return Err(ApiError {
|
||||||
|
status: StatusCode::NOT_FOUND,
|
||||||
|
message: "user not found".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let limit = query.limit.unwrap_or(50).clamp(1, 200);
|
||||||
|
let messages = db::list_direct_messages(&state.db, user.id, other_user_id, limit)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::internal(&format!("failed to list dm messages: {e}")))?;
|
||||||
|
|
||||||
|
Ok(Json(messages))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_dm_message(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
user: AuthUser,
|
||||||
|
Path(other_user_id): Path<Uuid>,
|
||||||
|
Json(body): Json<SendMessageBody>,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
if other_user_id == user.id {
|
||||||
|
return Err(ApiError::bad_request("cannot send dm to yourself"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let other_user_exists = db::user_exists(&state.db, other_user_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::internal(&format!("user check failed: {e}")))?;
|
||||||
|
if !other_user_exists {
|
||||||
|
return Err(ApiError {
|
||||||
|
status: StatusCode::NOT_FOUND,
|
||||||
|
message: "user not found".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = body.body.trim();
|
||||||
|
if content.is_empty() || content.len() > 4000 {
|
||||||
|
return Err(ApiError::bad_request("message body must be 1..4000 chars"));
|
||||||
|
}
|
||||||
|
|
||||||
|
db::create_direct_message(&state.db, user.id, other_user_id, content)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::internal(&format!("failed to create dm message: {e}")))?;
|
||||||
|
|
||||||
|
Ok((StatusCode::CREATED, Json(SendMessageResponse { ok: true })))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn voice_ws(
|
||||||
|
ws: WebSocketUpgrade,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
user: AuthUser,
|
||||||
|
Path(channel_id): Path<Uuid>,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
ensure_channel_member(&state, channel_id, user.id).await?;
|
||||||
|
|
||||||
|
let channel = db::get_channel_by_id(&state.db, channel_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::internal(&format!("failed to load channel: {e}")))?
|
||||||
|
.ok_or_else(|| ApiError {
|
||||||
|
status: StatusCode::NOT_FOUND,
|
||||||
|
message: "channel not found".to_string(),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if channel.kind != db::CHANNEL_KIND_VOICE {
|
||||||
|
return Err(ApiError::bad_request("voice websocket requires a voice channel"));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(ws.on_upgrade(move |socket| {
|
||||||
|
voice::handle_socket(state, socket, channel_id, user.id)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn ensure_guild_member(state: &AppState, guild_id: Uuid, user_id: Uuid) -> Result<(), ApiError> {
|
||||||
|
let is_member = db::is_member_of_guild(&state.db, guild_id, user_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::internal(&format!("membership check failed: {e}")))?;
|
||||||
|
|
||||||
|
if !is_member {
|
||||||
|
return Err(ApiError {
|
||||||
|
status: StatusCode::FORBIDDEN,
|
||||||
|
message: "not a member of this guild".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn ensure_channel_member(state: &AppState, channel_id: Uuid, user_id: Uuid) -> Result<(), ApiError> {
|
||||||
|
let guild_id = db::guild_id_for_channel(&state.db, channel_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::internal(&format!("channel lookup failed: {e}")))?
|
||||||
|
.ok_or_else(|| ApiError {
|
||||||
|
status: StatusCode::NOT_FOUND,
|
||||||
|
message: "channel not found".to_string(),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
ensure_guild_member(state, guild_id, user_id).await
|
||||||
|
}
|
||||||
71
src/main.rs
Normal file
71
src/main.rs
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
mod auth;
|
||||||
|
mod config;
|
||||||
|
mod db;
|
||||||
|
mod entity;
|
||||||
|
mod handlers;
|
||||||
|
mod migration;
|
||||||
|
mod models;
|
||||||
|
mod voice;
|
||||||
|
|
||||||
|
use std::{net::SocketAddr, sync::Arc};
|
||||||
|
|
||||||
|
use anyhow::Context;
|
||||||
|
use axum::{Router, routing::get};
|
||||||
|
use sea_orm::Database;
|
||||||
|
use sea_orm_migration::MigratorTrait;
|
||||||
|
use tower_http::{services::ServeDir, trace::TraceLayer};
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
use crate::{config::Settings, handlers::routes, migration::Migrator, voice::VoiceHub};
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AppState {
|
||||||
|
pub db: sea_orm::DatabaseConnection,
|
||||||
|
pub settings: Arc<Settings>,
|
||||||
|
pub http: reqwest::Client,
|
||||||
|
pub voice: Arc<VoiceHub>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> anyhow::Result<()> {
|
||||||
|
dotenvy::dotenv().ok();
|
||||||
|
init_tracing();
|
||||||
|
|
||||||
|
let settings = Arc::new(Settings::from_env()?);
|
||||||
|
|
||||||
|
let db = Database::connect(&settings.database_url)
|
||||||
|
.await
|
||||||
|
.with_context(|| "failed to connect to postgres")?;
|
||||||
|
|
||||||
|
Migrator::up(&db, None)
|
||||||
|
.await
|
||||||
|
.with_context(|| "failed to run migrations")?;
|
||||||
|
|
||||||
|
let state = AppState {
|
||||||
|
db,
|
||||||
|
settings,
|
||||||
|
http: reqwest::Client::new(),
|
||||||
|
voice: Arc::new(VoiceHub::default()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let app = Router::new()
|
||||||
|
.route("/health", get(handlers::health))
|
||||||
|
.nest_service("/static", ServeDir::new("static"))
|
||||||
|
.merge(routes())
|
||||||
|
.with_state(state)
|
||||||
|
.layer(TraceLayer::new_for_http());
|
||||||
|
|
||||||
|
let addr: SocketAddr = "0.0.0.0:3000".parse()?;
|
||||||
|
info!(%addr, "server started");
|
||||||
|
|
||||||
|
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||||
|
axum::serve(listener, app).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn init_tracing() {
|
||||||
|
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
|
||||||
|
.unwrap_or_else(|_| "chattz=info,tower_http=info".into());
|
||||||
|
|
||||||
|
tracing_subscriber::fmt().with_env_filter(filter).init();
|
||||||
|
}
|
||||||
274
src/migration/m20260213_000001_init.rs
Normal file
274
src/migration/m20260213_000001_init.rs
Normal file
|
|
@ -0,0 +1,274 @@
|
||||||
|
use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)]
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
manager
|
||||||
|
.create_table(
|
||||||
|
Table::create()
|
||||||
|
.table(Users::Table)
|
||||||
|
.if_not_exists()
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(Users::Id)
|
||||||
|
.uuid()
|
||||||
|
.not_null()
|
||||||
|
.primary_key(),
|
||||||
|
)
|
||||||
|
.col(ColumnDef::new(Users::OidcSub).string().not_null().unique_key())
|
||||||
|
.col(ColumnDef::new(Users::Email).string())
|
||||||
|
.col(ColumnDef::new(Users::DisplayName).string().not_null())
|
||||||
|
.col(ColumnDef::new(Users::AvatarUrl).string())
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(Users::CreatedAt)
|
||||||
|
.timestamp_with_time_zone()
|
||||||
|
.not_null()
|
||||||
|
.default(Expr::current_timestamp()),
|
||||||
|
)
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(Users::UpdatedAt)
|
||||||
|
.timestamp_with_time_zone()
|
||||||
|
.not_null()
|
||||||
|
.default(Expr::current_timestamp()),
|
||||||
|
)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
manager
|
||||||
|
.create_table(
|
||||||
|
Table::create()
|
||||||
|
.table(Guilds::Table)
|
||||||
|
.if_not_exists()
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(Guilds::Id)
|
||||||
|
.uuid()
|
||||||
|
.not_null()
|
||||||
|
.primary_key(),
|
||||||
|
)
|
||||||
|
.col(ColumnDef::new(Guilds::Name).string().not_null())
|
||||||
|
.col(ColumnDef::new(Guilds::OwnerUserId).uuid().not_null())
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(Guilds::CreatedAt)
|
||||||
|
.timestamp_with_time_zone()
|
||||||
|
.not_null()
|
||||||
|
.default(Expr::current_timestamp()),
|
||||||
|
)
|
||||||
|
.foreign_key(
|
||||||
|
ForeignKey::create()
|
||||||
|
.name("fk_guilds_owner_user")
|
||||||
|
.from(Guilds::Table, Guilds::OwnerUserId)
|
||||||
|
.to(Users::Table, Users::Id),
|
||||||
|
)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
manager
|
||||||
|
.create_table(
|
||||||
|
Table::create()
|
||||||
|
.table(GuildMembers::Table)
|
||||||
|
.if_not_exists()
|
||||||
|
.col(ColumnDef::new(GuildMembers::GuildId).uuid().not_null())
|
||||||
|
.col(ColumnDef::new(GuildMembers::UserId).uuid().not_null())
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(GuildMembers::CreatedAt)
|
||||||
|
.timestamp_with_time_zone()
|
||||||
|
.not_null()
|
||||||
|
.default(Expr::current_timestamp()),
|
||||||
|
)
|
||||||
|
.primary_key(
|
||||||
|
Index::create()
|
||||||
|
.name("pk_guild_members")
|
||||||
|
.col(GuildMembers::GuildId)
|
||||||
|
.col(GuildMembers::UserId),
|
||||||
|
)
|
||||||
|
.foreign_key(
|
||||||
|
ForeignKey::create()
|
||||||
|
.name("fk_guild_members_guild")
|
||||||
|
.from(GuildMembers::Table, GuildMembers::GuildId)
|
||||||
|
.to(Guilds::Table, Guilds::Id)
|
||||||
|
.on_delete(ForeignKeyAction::Cascade),
|
||||||
|
)
|
||||||
|
.foreign_key(
|
||||||
|
ForeignKey::create()
|
||||||
|
.name("fk_guild_members_user")
|
||||||
|
.from(GuildMembers::Table, GuildMembers::UserId)
|
||||||
|
.to(Users::Table, Users::Id)
|
||||||
|
.on_delete(ForeignKeyAction::Cascade),
|
||||||
|
)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
manager
|
||||||
|
.create_table(
|
||||||
|
Table::create()
|
||||||
|
.table(Channels::Table)
|
||||||
|
.if_not_exists()
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(Channels::Id)
|
||||||
|
.uuid()
|
||||||
|
.not_null()
|
||||||
|
.primary_key(),
|
||||||
|
)
|
||||||
|
.col(ColumnDef::new(Channels::GuildId).uuid().not_null())
|
||||||
|
.col(ColumnDef::new(Channels::Name).string().not_null())
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(Channels::CreatedAt)
|
||||||
|
.timestamp_with_time_zone()
|
||||||
|
.not_null()
|
||||||
|
.default(Expr::current_timestamp()),
|
||||||
|
)
|
||||||
|
.foreign_key(
|
||||||
|
ForeignKey::create()
|
||||||
|
.name("fk_channels_guild")
|
||||||
|
.from(Channels::Table, Channels::GuildId)
|
||||||
|
.to(Guilds::Table, Guilds::Id)
|
||||||
|
.on_delete(ForeignKeyAction::Cascade),
|
||||||
|
)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
manager
|
||||||
|
.create_table(
|
||||||
|
Table::create()
|
||||||
|
.table(Messages::Table)
|
||||||
|
.if_not_exists()
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(Messages::Id)
|
||||||
|
.uuid()
|
||||||
|
.not_null()
|
||||||
|
.primary_key(),
|
||||||
|
)
|
||||||
|
.col(ColumnDef::new(Messages::ChannelId).uuid().not_null())
|
||||||
|
.col(ColumnDef::new(Messages::AuthorUserId).uuid().not_null())
|
||||||
|
.col(ColumnDef::new(Messages::Body).text().not_null())
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(Messages::CreatedAt)
|
||||||
|
.timestamp_with_time_zone()
|
||||||
|
.not_null()
|
||||||
|
.default(Expr::current_timestamp()),
|
||||||
|
)
|
||||||
|
.foreign_key(
|
||||||
|
ForeignKey::create()
|
||||||
|
.name("fk_messages_channel")
|
||||||
|
.from(Messages::Table, Messages::ChannelId)
|
||||||
|
.to(Channels::Table, Channels::Id)
|
||||||
|
.on_delete(ForeignKeyAction::Cascade),
|
||||||
|
)
|
||||||
|
.foreign_key(
|
||||||
|
ForeignKey::create()
|
||||||
|
.name("fk_messages_author")
|
||||||
|
.from(Messages::Table, Messages::AuthorUserId)
|
||||||
|
.to(Users::Table, Users::Id),
|
||||||
|
)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
manager
|
||||||
|
.create_index(
|
||||||
|
Index::create()
|
||||||
|
.name("idx_guild_members_user_id")
|
||||||
|
.table(GuildMembers::Table)
|
||||||
|
.col(GuildMembers::UserId)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
manager
|
||||||
|
.create_index(
|
||||||
|
Index::create()
|
||||||
|
.name("idx_channels_guild_id")
|
||||||
|
.table(Channels::Table)
|
||||||
|
.col(Channels::GuildId)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
manager
|
||||||
|
.create_index(
|
||||||
|
Index::create()
|
||||||
|
.name("idx_messages_channel_created_at")
|
||||||
|
.table(Messages::Table)
|
||||||
|
.col(Messages::ChannelId)
|
||||||
|
.col(Messages::CreatedAt)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
manager
|
||||||
|
.drop_table(Table::drop().table(Messages::Table).to_owned())
|
||||||
|
.await?;
|
||||||
|
manager
|
||||||
|
.drop_table(Table::drop().table(Channels::Table).to_owned())
|
||||||
|
.await?;
|
||||||
|
manager
|
||||||
|
.drop_table(Table::drop().table(GuildMembers::Table).to_owned())
|
||||||
|
.await?;
|
||||||
|
manager
|
||||||
|
.drop_table(Table::drop().table(Guilds::Table).to_owned())
|
||||||
|
.await?;
|
||||||
|
manager
|
||||||
|
.drop_table(Table::drop().table(Users::Table).to_owned())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(DeriveIden)]
|
||||||
|
enum Users {
|
||||||
|
Table,
|
||||||
|
Id,
|
||||||
|
OidcSub,
|
||||||
|
Email,
|
||||||
|
DisplayName,
|
||||||
|
AvatarUrl,
|
||||||
|
CreatedAt,
|
||||||
|
UpdatedAt,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(DeriveIden)]
|
||||||
|
enum Guilds {
|
||||||
|
Table,
|
||||||
|
Id,
|
||||||
|
Name,
|
||||||
|
OwnerUserId,
|
||||||
|
CreatedAt,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(DeriveIden)]
|
||||||
|
enum GuildMembers {
|
||||||
|
Table,
|
||||||
|
GuildId,
|
||||||
|
UserId,
|
||||||
|
CreatedAt,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(DeriveIden)]
|
||||||
|
enum Channels {
|
||||||
|
Table,
|
||||||
|
Id,
|
||||||
|
GuildId,
|
||||||
|
Name,
|
||||||
|
CreatedAt,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(DeriveIden)]
|
||||||
|
enum Messages {
|
||||||
|
Table,
|
||||||
|
Id,
|
||||||
|
ChannelId,
|
||||||
|
AuthorUserId,
|
||||||
|
Body,
|
||||||
|
CreatedAt,
|
||||||
|
}
|
||||||
91
src/migration/m20260213_000002_invites.rs
Normal file
91
src/migration/m20260213_000002_invites.rs
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)]
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
manager
|
||||||
|
.create_table(
|
||||||
|
Table::create()
|
||||||
|
.table(Invites::Table)
|
||||||
|
.if_not_exists()
|
||||||
|
.col(ColumnDef::new(Invites::Code).string().not_null().primary_key())
|
||||||
|
.col(ColumnDef::new(Invites::GuildId).uuid().not_null())
|
||||||
|
.col(ColumnDef::new(Invites::CreatedByUserId).uuid().not_null())
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(Invites::CreatedAt)
|
||||||
|
.timestamp_with_time_zone()
|
||||||
|
.not_null()
|
||||||
|
.default(Expr::current_timestamp()),
|
||||||
|
)
|
||||||
|
.col(ColumnDef::new(Invites::ExpiresAt).timestamp_with_time_zone())
|
||||||
|
.col(ColumnDef::new(Invites::MaxUses).integer())
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(Invites::UseCount)
|
||||||
|
.integer()
|
||||||
|
.not_null()
|
||||||
|
.default(0),
|
||||||
|
)
|
||||||
|
.foreign_key(
|
||||||
|
ForeignKey::create()
|
||||||
|
.name("fk_invites_guild")
|
||||||
|
.from(Invites::Table, Invites::GuildId)
|
||||||
|
.to(Guilds::Table, Guilds::Id)
|
||||||
|
.on_delete(ForeignKeyAction::Cascade),
|
||||||
|
)
|
||||||
|
.foreign_key(
|
||||||
|
ForeignKey::create()
|
||||||
|
.name("fk_invites_created_by")
|
||||||
|
.from(Invites::Table, Invites::CreatedByUserId)
|
||||||
|
.to(Users::Table, Users::Id),
|
||||||
|
)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
manager
|
||||||
|
.create_index(
|
||||||
|
Index::create()
|
||||||
|
.name("idx_invites_guild_id")
|
||||||
|
.table(Invites::Table)
|
||||||
|
.col(Invites::GuildId)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
manager
|
||||||
|
.drop_table(Table::drop().table(Invites::Table).to_owned())
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(DeriveIden)]
|
||||||
|
enum Invites {
|
||||||
|
Table,
|
||||||
|
Code,
|
||||||
|
GuildId,
|
||||||
|
CreatedByUserId,
|
||||||
|
CreatedAt,
|
||||||
|
ExpiresAt,
|
||||||
|
MaxUses,
|
||||||
|
UseCount,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(DeriveIden)]
|
||||||
|
enum Guilds {
|
||||||
|
Table,
|
||||||
|
Id,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(DeriveIden)]
|
||||||
|
enum Users {
|
||||||
|
Table,
|
||||||
|
Id,
|
||||||
|
}
|
||||||
65
src/migration/m20260213_000003_channel_kind.rs
Normal file
65
src/migration/m20260213_000003_channel_kind.rs
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)]
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(Channels::Table)
|
||||||
|
.add_column(
|
||||||
|
ColumnDef::new(Channels::Kind)
|
||||||
|
.string()
|
||||||
|
.not_null()
|
||||||
|
.default("text"),
|
||||||
|
)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
manager
|
||||||
|
.create_index(
|
||||||
|
Index::create()
|
||||||
|
.name("idx_channels_guild_kind")
|
||||||
|
.table(Channels::Table)
|
||||||
|
.col(Channels::GuildId)
|
||||||
|
.col(Channels::Kind)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
manager
|
||||||
|
.drop_index(
|
||||||
|
Index::drop()
|
||||||
|
.name("idx_channels_guild_kind")
|
||||||
|
.table(Channels::Table)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(Channels::Table)
|
||||||
|
.drop_column(Channels::Kind)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(DeriveIden)]
|
||||||
|
enum Channels {
|
||||||
|
Table,
|
||||||
|
GuildId,
|
||||||
|
Kind,
|
||||||
|
}
|
||||||
95
src/migration/m20260213_000004_direct_messages.rs
Normal file
95
src/migration/m20260213_000004_direct_messages.rs
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)]
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
manager
|
||||||
|
.create_table(
|
||||||
|
Table::create()
|
||||||
|
.table(DirectMessages::Table)
|
||||||
|
.if_not_exists()
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(DirectMessages::Id)
|
||||||
|
.uuid()
|
||||||
|
.not_null()
|
||||||
|
.primary_key(),
|
||||||
|
)
|
||||||
|
.col(ColumnDef::new(DirectMessages::SenderUserId).uuid().not_null())
|
||||||
|
.col(ColumnDef::new(DirectMessages::RecipientUserId).uuid().not_null())
|
||||||
|
.col(ColumnDef::new(DirectMessages::Body).text().not_null())
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(DirectMessages::CreatedAt)
|
||||||
|
.timestamp_with_time_zone()
|
||||||
|
.not_null()
|
||||||
|
.default(Expr::current_timestamp()),
|
||||||
|
)
|
||||||
|
.foreign_key(
|
||||||
|
ForeignKey::create()
|
||||||
|
.name("fk_direct_messages_sender_user")
|
||||||
|
.from(DirectMessages::Table, DirectMessages::SenderUserId)
|
||||||
|
.to(Users::Table, Users::Id)
|
||||||
|
.on_delete(ForeignKeyAction::Cascade),
|
||||||
|
)
|
||||||
|
.foreign_key(
|
||||||
|
ForeignKey::create()
|
||||||
|
.name("fk_direct_messages_recipient_user")
|
||||||
|
.from(DirectMessages::Table, DirectMessages::RecipientUserId)
|
||||||
|
.to(Users::Table, Users::Id)
|
||||||
|
.on_delete(ForeignKeyAction::Cascade),
|
||||||
|
)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
manager
|
||||||
|
.create_index(
|
||||||
|
Index::create()
|
||||||
|
.name("idx_direct_messages_sender_created_at")
|
||||||
|
.table(DirectMessages::Table)
|
||||||
|
.col(DirectMessages::SenderUserId)
|
||||||
|
.col(DirectMessages::CreatedAt)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
manager
|
||||||
|
.create_index(
|
||||||
|
Index::create()
|
||||||
|
.name("idx_direct_messages_recipient_created_at")
|
||||||
|
.table(DirectMessages::Table)
|
||||||
|
.col(DirectMessages::RecipientUserId)
|
||||||
|
.col(DirectMessages::CreatedAt)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
manager
|
||||||
|
.drop_table(Table::drop().table(DirectMessages::Table).to_owned())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(DeriveIden)]
|
||||||
|
enum DirectMessages {
|
||||||
|
Table,
|
||||||
|
Id,
|
||||||
|
SenderUserId,
|
||||||
|
RecipientUserId,
|
||||||
|
Body,
|
||||||
|
CreatedAt,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(DeriveIden)]
|
||||||
|
enum Users {
|
||||||
|
Table,
|
||||||
|
Id,
|
||||||
|
}
|
||||||
20
src/migration/mod.rs
Normal file
20
src/migration/mod.rs
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
|
mod m20260213_000001_init;
|
||||||
|
mod m20260213_000002_invites;
|
||||||
|
mod m20260213_000003_channel_kind;
|
||||||
|
mod m20260213_000004_direct_messages;
|
||||||
|
|
||||||
|
pub struct Migrator;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigratorTrait for Migrator {
|
||||||
|
fn migrations() -> Vec<Box<dyn MigrationTrait>> {
|
||||||
|
vec![
|
||||||
|
Box::new(m20260213_000001_init::Migration),
|
||||||
|
Box::new(m20260213_000002_invites::Migration),
|
||||||
|
Box::new(m20260213_000003_channel_kind::Migration),
|
||||||
|
Box::new(m20260213_000004_direct_messages::Migration),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
86
src/models.rs
Normal file
86
src/models.rs
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
use sea_orm::prelude::DateTimeWithTimeZone;
|
||||||
|
use serde::Serialize;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct User {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub oidc_sub: String,
|
||||||
|
pub email: Option<String>,
|
||||||
|
pub display_name: String,
|
||||||
|
pub avatar_url: Option<String>,
|
||||||
|
pub created_at: DateTimeWithTimeZone,
|
||||||
|
pub updated_at: DateTimeWithTimeZone,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct Guild {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub name: String,
|
||||||
|
pub owner_user_id: Uuid,
|
||||||
|
pub created_at: DateTimeWithTimeZone,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct Channel {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub guild_id: Uuid,
|
||||||
|
pub name: String,
|
||||||
|
pub kind: String,
|
||||||
|
pub created_at: DateTimeWithTimeZone,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct Message {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub channel_id: Uuid,
|
||||||
|
pub author_user_id: Uuid,
|
||||||
|
pub body: String,
|
||||||
|
pub created_at: DateTimeWithTimeZone,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct BasicUser {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub display_name: String,
|
||||||
|
pub avatar_url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, sea_orm::FromQueryResult)]
|
||||||
|
pub struct MessageWithAuthor {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub channel_id: Uuid,
|
||||||
|
pub author_user_id: Uuid,
|
||||||
|
pub author_display_name: String,
|
||||||
|
pub body: String,
|
||||||
|
pub created_at: DateTimeWithTimeZone,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct DmConversation {
|
||||||
|
pub user_id: Uuid,
|
||||||
|
pub display_name: String,
|
||||||
|
pub avatar_url: Option<String>,
|
||||||
|
pub last_message_at: DateTimeWithTimeZone,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct DmMessageWithAuthor {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub author_user_id: Uuid,
|
||||||
|
pub recipient_user_id: Uuid,
|
||||||
|
pub author_display_name: String,
|
||||||
|
pub body: String,
|
||||||
|
pub created_at: DateTimeWithTimeZone,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct Invite {
|
||||||
|
pub code: String,
|
||||||
|
pub guild_id: Uuid,
|
||||||
|
pub created_by_user_id: Uuid,
|
||||||
|
pub created_at: DateTimeWithTimeZone,
|
||||||
|
pub expires_at: Option<DateTimeWithTimeZone>,
|
||||||
|
pub max_uses: Option<i32>,
|
||||||
|
pub use_count: i32,
|
||||||
|
}
|
||||||
199
src/voice.rs
Normal file
199
src/voice.rs
Normal file
|
|
@ -0,0 +1,199 @@
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use axum::extract::ws::{Message, WebSocket};
|
||||||
|
use futures_util::{SinkExt, StreamExt};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tokio::sync::{RwLock, mpsc};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::{AppState, db};
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct VoiceHub {
|
||||||
|
rooms: RwLock<HashMap<Uuid, HashMap<Uuid, ClientHandle>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct ClientHandle {
|
||||||
|
display_name: String,
|
||||||
|
tx: mpsc::UnboundedSender<ServerEvent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Clone)]
|
||||||
|
pub struct VoiceParticipant {
|
||||||
|
pub user_id: Uuid,
|
||||||
|
pub display_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Clone)]
|
||||||
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
|
enum ServerEvent {
|
||||||
|
Peers { peers: Vec<VoiceParticipant> },
|
||||||
|
PeerJoined { user_id: Uuid, display_name: String },
|
||||||
|
PeerLeft { user_id: Uuid },
|
||||||
|
Signal {
|
||||||
|
from_user_id: Uuid,
|
||||||
|
kind: String,
|
||||||
|
data: serde_json::Value,
|
||||||
|
},
|
||||||
|
Error { message: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
|
enum ClientEvent {
|
||||||
|
Signal {
|
||||||
|
to_user_id: Uuid,
|
||||||
|
kind: String,
|
||||||
|
data: serde_json::Value,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VoiceHub {
|
||||||
|
pub async fn participants(&self, room_id: Uuid) -> Vec<VoiceParticipant> {
|
||||||
|
let rooms = self.rooms.read().await;
|
||||||
|
let Some(room) = rooms.get(&room_id) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
|
||||||
|
room.iter()
|
||||||
|
.map(|(user_id, handle)| VoiceParticipant {
|
||||||
|
user_id: *user_id,
|
||||||
|
display_name: handle.display_name.clone(),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn join(
|
||||||
|
&self,
|
||||||
|
room_id: Uuid,
|
||||||
|
user_id: Uuid,
|
||||||
|
display_name: String,
|
||||||
|
tx: mpsc::UnboundedSender<ServerEvent>,
|
||||||
|
) -> Vec<VoiceParticipant> {
|
||||||
|
let mut rooms = self.rooms.write().await;
|
||||||
|
let room = rooms.entry(room_id).or_default();
|
||||||
|
|
||||||
|
let peers = room
|
||||||
|
.iter()
|
||||||
|
.map(|(peer_id, peer)| VoiceParticipant {
|
||||||
|
user_id: *peer_id,
|
||||||
|
display_name: peer.display_name.clone(),
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
room.insert(
|
||||||
|
user_id,
|
||||||
|
ClientHandle {
|
||||||
|
display_name: display_name.clone(),
|
||||||
|
tx,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
for (peer_id, peer) in room.iter() {
|
||||||
|
if *peer_id != user_id {
|
||||||
|
let _ = peer.tx.send(ServerEvent::PeerJoined {
|
||||||
|
user_id,
|
||||||
|
display_name: display_name.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
peers
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn leave(&self, room_id: Uuid, user_id: Uuid) {
|
||||||
|
let mut rooms = self.rooms.write().await;
|
||||||
|
let Some(room) = rooms.get_mut(&room_id) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
room.remove(&user_id);
|
||||||
|
|
||||||
|
for peer in room.values() {
|
||||||
|
let _ = peer.tx.send(ServerEvent::PeerLeft { user_id });
|
||||||
|
}
|
||||||
|
|
||||||
|
if room.is_empty() {
|
||||||
|
rooms.remove(&room_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn relay_signal(
|
||||||
|
&self,
|
||||||
|
room_id: Uuid,
|
||||||
|
from_user_id: Uuid,
|
||||||
|
to_user_id: Uuid,
|
||||||
|
kind: String,
|
||||||
|
data: serde_json::Value,
|
||||||
|
) {
|
||||||
|
let rooms = self.rooms.read().await;
|
||||||
|
let Some(room) = rooms.get(&room_id) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(target) = room.get(&to_user_id) {
|
||||||
|
let _ = target.tx.send(ServerEvent::Signal {
|
||||||
|
from_user_id,
|
||||||
|
kind,
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn handle_socket(state: AppState, socket: WebSocket, room_id: Uuid, user_id: Uuid) {
|
||||||
|
let Some(user) = db::get_user_by_id(&state.db, user_id).await.ok().flatten() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let (mut ws_sender, mut ws_receiver) = socket.split();
|
||||||
|
let (tx, mut rx) = mpsc::unbounded_channel::<ServerEvent>();
|
||||||
|
|
||||||
|
let peers = state
|
||||||
|
.voice
|
||||||
|
.join(room_id, user_id, user.display_name.clone(), tx.clone())
|
||||||
|
.await;
|
||||||
|
let _ = tx.send(ServerEvent::Peers { peers });
|
||||||
|
|
||||||
|
let send_task = tokio::spawn(async move {
|
||||||
|
while let Some(event) = rx.recv().await {
|
||||||
|
let Ok(payload) = serde_json::to_string(&event) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if ws_sender.send(Message::Text(payload.into())).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
while let Some(Ok(msg)) = ws_receiver.next().await {
|
||||||
|
match msg {
|
||||||
|
Message::Text(text) => {
|
||||||
|
let parsed = serde_json::from_str::<ClientEvent>(&text);
|
||||||
|
match parsed {
|
||||||
|
Ok(ClientEvent::Signal {
|
||||||
|
to_user_id,
|
||||||
|
kind,
|
||||||
|
data,
|
||||||
|
}) => {
|
||||||
|
state
|
||||||
|
.voice
|
||||||
|
.relay_signal(room_id, user_id, to_user_id, kind, data)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
let _ = tx.send(ServerEvent::Error {
|
||||||
|
message: format!("invalid voice message: {err}"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Message::Close(_) => break,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
send_task.abort();
|
||||||
|
state.voice.leave(room_id, user_id).await;
|
||||||
|
}
|
||||||
862
static/app.js
Normal file
862
static/app.js
Normal file
|
|
@ -0,0 +1,862 @@
|
||||||
|
const state = {
|
||||||
|
me: null,
|
||||||
|
guilds: [],
|
||||||
|
channels: [],
|
||||||
|
dmConversations: [],
|
||||||
|
members: [],
|
||||||
|
voicePresence: new Map(),
|
||||||
|
selectedGuildId: null,
|
||||||
|
selectedTextChannelId: null,
|
||||||
|
selectedDmUserId: null,
|
||||||
|
selectedDmDisplayName: null,
|
||||||
|
selectedVoiceChannelId: null,
|
||||||
|
voice: {
|
||||||
|
ws: null,
|
||||||
|
joinedChannelId: null,
|
||||||
|
localStream: null,
|
||||||
|
rawStream: null,
|
||||||
|
audioContext: null,
|
||||||
|
denoiserNode: null,
|
||||||
|
deepFilterCore: null,
|
||||||
|
peerConnections: new Map(),
|
||||||
|
muted: false,
|
||||||
|
iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
|
||||||
|
},
|
||||||
|
voicePresencePollId: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const el = {
|
||||||
|
authScreen: document.getElementById("auth-screen"),
|
||||||
|
loginBtn: document.getElementById("login-btn"),
|
||||||
|
main: document.getElementById("main"),
|
||||||
|
status: document.getElementById("status"),
|
||||||
|
|
||||||
|
// Guilds
|
||||||
|
guildList: document.getElementById("guild-list"),
|
||||||
|
addGuildBtn: document.getElementById("add-guild-btn"),
|
||||||
|
guildTitle: document.getElementById("guild-title"),
|
||||||
|
createInviteBtn: document.getElementById("create-invite-btn"),
|
||||||
|
|
||||||
|
// Channels
|
||||||
|
channelList: document.getElementById("channel-list"),
|
||||||
|
voiceChannelList: document.getElementById("voice-channel-list"),
|
||||||
|
addTextBtn: document.getElementById("add-text-btn"),
|
||||||
|
addVoiceBtn: document.getElementById("add-voice-btn"),
|
||||||
|
dmList: document.getElementById("dm-list"),
|
||||||
|
channelTitle: document.getElementById("channel-title"),
|
||||||
|
|
||||||
|
// Messages
|
||||||
|
messageList: document.getElementById("message-list"),
|
||||||
|
messageForm: document.getElementById("message-form"),
|
||||||
|
messageBody: document.getElementById("message-body"),
|
||||||
|
|
||||||
|
// User Panel
|
||||||
|
userName: document.getElementById("user-name"),
|
||||||
|
userAvatar: document.getElementById("user-avatar"),
|
||||||
|
logoutBtn: document.getElementById("logout-btn"),
|
||||||
|
|
||||||
|
// Voice Connection
|
||||||
|
voiceConnection: document.getElementById("voice-connection"),
|
||||||
|
vcChannelName: document.getElementById("vc-channel-name"),
|
||||||
|
voiceMuteBtn: document.getElementById("voice-mute-btn"),
|
||||||
|
voiceLeaveBtn: document.getElementById("voice-leave-btn"),
|
||||||
|
|
||||||
|
// Members
|
||||||
|
memberList: document.getElementById("member-list"),
|
||||||
|
|
||||||
|
// Modals
|
||||||
|
modalContainer: document.getElementById("modal-container"),
|
||||||
|
guildForm: document.getElementById("guild-form"),
|
||||||
|
guildName: document.getElementById("guild-name"),
|
||||||
|
modalCancel: document.getElementById("modal-cancel"),
|
||||||
|
|
||||||
|
channelModal: document.getElementById("channel-modal"),
|
||||||
|
channelForm: document.getElementById("channel-form"),
|
||||||
|
channelName: document.getElementById("channel-name"),
|
||||||
|
channelModalCancel: document.getElementById("channel-modal-cancel"),
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- API Helpers ---
|
||||||
|
|
||||||
|
async function api(path, options = {}) {
|
||||||
|
const res = await fetch(path, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
...(options.headers || {}),
|
||||||
|
},
|
||||||
|
credentials: "same-origin",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
let detail = "request failed";
|
||||||
|
try {
|
||||||
|
const body = await res.json();
|
||||||
|
detail = body.error || detail;
|
||||||
|
} catch {}
|
||||||
|
throw new Error(`${res.status}: ${detail}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status === 204) return null;
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Utils ---
|
||||||
|
|
||||||
|
function shortName(name) {
|
||||||
|
if (!name || typeof name !== 'string') return "?";
|
||||||
|
const trimmed = name.trim();
|
||||||
|
if (!trimmed) return "?";
|
||||||
|
const words = trimmed.split(/\s+/).filter(w => w.length > 0).slice(0, 2);
|
||||||
|
if (words.length === 0) return "?";
|
||||||
|
if (words.length === 1) return words[0].substring(0, 2).toUpperCase();
|
||||||
|
return words.map((w) => w[0]?.toUpperCase() || "").join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
if (s === null || s === undefined) return "";
|
||||||
|
return String(s)
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll('"', """)
|
||||||
|
.replaceAll("'", "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(isoString) {
|
||||||
|
const d = new Date(isoString);
|
||||||
|
const now = new Date();
|
||||||
|
const isToday = d.toDateString() === now.toDateString();
|
||||||
|
|
||||||
|
if (isToday) {
|
||||||
|
return `Today at ${d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`;
|
||||||
|
}
|
||||||
|
return d.toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEEPFILTERNET_LIB_PATH = "/static/vendor/deepfilternet3/index.esm.js";
|
||||||
|
const NSNET2_COMPAT_SUPPRESSION = 56;
|
||||||
|
|
||||||
|
let deepFilterLibPromise = null;
|
||||||
|
const LAST_GUILD_STORAGE_KEY = "chattz:lastGuildId";
|
||||||
|
|
||||||
|
// --- Renderers ---
|
||||||
|
|
||||||
|
function renderGuilds() {
|
||||||
|
el.guildList.innerHTML = "";
|
||||||
|
|
||||||
|
for (const guild of state.guilds) {
|
||||||
|
const btn = document.createElement("button");
|
||||||
|
btn.className = `guild-pill ${state.selectedGuildId === guild.id ? "active" : ""}`;
|
||||||
|
btn.title = guild.name;
|
||||||
|
btn.textContent = shortName(guild.name);
|
||||||
|
btn.onclick = async () => {
|
||||||
|
state.selectedGuildId = guild.id;
|
||||||
|
state.selectedTextChannelId = null;
|
||||||
|
state.selectedVoiceChannelId = null;
|
||||||
|
state.selectedDmUserId = null;
|
||||||
|
state.selectedDmDisplayName = null;
|
||||||
|
localStorage.setItem(LAST_GUILD_STORAGE_KEY, guild.id);
|
||||||
|
renderGuilds();
|
||||||
|
renderDMs();
|
||||||
|
await loadChannels();
|
||||||
|
await loadGuildMembers();
|
||||||
|
await refreshVoicePresence();
|
||||||
|
startVoicePresencePolling();
|
||||||
|
renderMessages([]);
|
||||||
|
updateHeaderLabels();
|
||||||
|
};
|
||||||
|
el.guildList.appendChild(btn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderChannels() {
|
||||||
|
el.channelList.innerHTML = "";
|
||||||
|
el.voiceChannelList.innerHTML = "";
|
||||||
|
|
||||||
|
for (const channel of state.channels) {
|
||||||
|
const row = document.createElement("button");
|
||||||
|
row.className = `channel-row ${
|
||||||
|
(channel.kind === 'text' && state.selectedTextChannelId === channel.id) ||
|
||||||
|
(channel.kind === 'voice' && state.selectedVoiceChannelId === channel.id) ? "active" : ""
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const iconName = channel.kind === 'text' ? 'hash' : 'volume-2';
|
||||||
|
row.innerHTML = `<i data-lucide="${iconName}"></i> <span>${escapeHtml(channel.name)}</span>`;
|
||||||
|
|
||||||
|
row.onclick = async () => {
|
||||||
|
if (channel.kind === 'text') {
|
||||||
|
state.selectedDmUserId = null;
|
||||||
|
state.selectedDmDisplayName = null;
|
||||||
|
state.selectedTextChannelId = channel.id;
|
||||||
|
renderChannels();
|
||||||
|
renderDMs();
|
||||||
|
updateHeaderLabels();
|
||||||
|
const messages = await api(`/channels/${channel.id}/messages?limit=100`);
|
||||||
|
renderMessages(messages);
|
||||||
|
} else {
|
||||||
|
state.selectedDmUserId = null;
|
||||||
|
state.selectedDmDisplayName = null;
|
||||||
|
state.selectedVoiceChannelId = channel.id;
|
||||||
|
renderChannels();
|
||||||
|
renderDMs();
|
||||||
|
joinVoice();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (channel.kind === 'text') {
|
||||||
|
el.channelList.appendChild(row);
|
||||||
|
} else {
|
||||||
|
el.voiceChannelList.appendChild(row);
|
||||||
|
|
||||||
|
const participants = state.voicePresence.get(channel.id) || [];
|
||||||
|
if (participants.length > 0) {
|
||||||
|
const pList = document.createElement("div");
|
||||||
|
pList.className = "voice-row-members channel-list";
|
||||||
|
pList.style.paddingLeft = "24px";
|
||||||
|
for (const p of participants) {
|
||||||
|
const pRow = document.createElement("div");
|
||||||
|
pRow.className = "channel-row";
|
||||||
|
pRow.style.padding = "2px 8px";
|
||||||
|
pRow.innerHTML = `<div class="avatar" style="width:20px;height:20px;font-size:10px">${shortName(p.display_name)}</div> <span>${escapeHtml(p.display_name)}</span>`;
|
||||||
|
pList.appendChild(pRow);
|
||||||
|
}
|
||||||
|
el.voiceChannelList.appendChild(pList);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lucide.createIcons();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDMs() {
|
||||||
|
el.dmList.innerHTML = "";
|
||||||
|
for (const dm of state.dmConversations) {
|
||||||
|
const row = document.createElement("button");
|
||||||
|
row.className = `channel-row ${state.selectedDmUserId === dm.user_id ? "active" : ""}`;
|
||||||
|
row.innerHTML = `<i data-lucide="message-circle"></i> <span>${escapeHtml(dm.display_name)}</span>`;
|
||||||
|
row.onclick = async () => {
|
||||||
|
state.selectedDmUserId = dm.user_id;
|
||||||
|
state.selectedDmDisplayName = dm.display_name;
|
||||||
|
state.selectedTextChannelId = null;
|
||||||
|
state.selectedVoiceChannelId = null;
|
||||||
|
renderChannels();
|
||||||
|
renderDMs();
|
||||||
|
updateHeaderLabels();
|
||||||
|
const messages = await api(`/dms/${dm.user_id}/messages?limit=100`);
|
||||||
|
renderMessages(messages);
|
||||||
|
};
|
||||||
|
el.dmList.appendChild(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
lucide.createIcons();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMessages(messages) {
|
||||||
|
el.messageList.innerHTML = "";
|
||||||
|
let lastAuthorId = null;
|
||||||
|
let lastTime = null;
|
||||||
|
|
||||||
|
for (const m of messages.slice().reverse()) {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
const mDate = new Date(m.created_at);
|
||||||
|
const isGrouped = lastAuthorId === m.author_user_id &&
|
||||||
|
lastTime && (mDate - lastTime < 300000); // 5 minutes
|
||||||
|
|
||||||
|
row.className = `msg ${isGrouped ? "msg-grouped" : ""}`;
|
||||||
|
|
||||||
|
const displayName = m.author_display_name || "Unknown User";
|
||||||
|
|
||||||
|
if (isGrouped) {
|
||||||
|
row.innerHTML = `<div class="msg-content"><div class="msg-body">${escapeHtml(m.body)}</div></div>`;
|
||||||
|
} else {
|
||||||
|
row.innerHTML = `
|
||||||
|
<div class="msg-avatar">${shortName(displayName)}</div>
|
||||||
|
<div class="msg-content">
|
||||||
|
<div class="msg-header">
|
||||||
|
<span class="msg-author">${escapeHtml(displayName)}</span>
|
||||||
|
<span class="msg-time">${formatDate(m.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="msg-body">${escapeHtml(m.body)}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
el.messageList.appendChild(row);
|
||||||
|
lastAuthorId = m.author_user_id;
|
||||||
|
lastTime = mDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
el.messageList.scrollTop = el.messageList.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMembers() {
|
||||||
|
el.memberList.innerHTML = "";
|
||||||
|
for (const m of state.members) {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "member-row";
|
||||||
|
row.innerHTML = `
|
||||||
|
<div class="member-avatar">${shortName(m.display_name)}</div>
|
||||||
|
<div class="member-name">${escapeHtml(m.display_name)}</div>
|
||||||
|
`;
|
||||||
|
row.style.cursor = "pointer";
|
||||||
|
row.onclick = async () => {
|
||||||
|
if (state.me && m.id === state.me.id) return;
|
||||||
|
state.selectedDmUserId = m.id;
|
||||||
|
state.selectedDmDisplayName = m.display_name;
|
||||||
|
state.selectedTextChannelId = null;
|
||||||
|
state.selectedVoiceChannelId = null;
|
||||||
|
renderChannels();
|
||||||
|
renderDMs();
|
||||||
|
updateHeaderLabels();
|
||||||
|
const messages = await api(`/dms/${m.id}/messages?limit=100`);
|
||||||
|
renderMessages(messages);
|
||||||
|
};
|
||||||
|
el.memberList.appendChild(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateHeaderLabels() {
|
||||||
|
const guild = state.guilds.find((g) => g.id === state.selectedGuildId);
|
||||||
|
el.guildTitle.textContent = guild ? guild.name : "No server selected";
|
||||||
|
|
||||||
|
if (state.selectedDmUserId) {
|
||||||
|
const dmFromConversations = state.dmConversations.find((u) => u.user_id === state.selectedDmUserId);
|
||||||
|
const dmFromMembers = state.members.find((m) => m.id === state.selectedDmUserId);
|
||||||
|
const displayName = dmFromConversations?.display_name || dmFromMembers?.display_name || state.selectedDmDisplayName;
|
||||||
|
const dmName = displayName ? `@${displayName}` : "Direct Message";
|
||||||
|
el.channelTitle.textContent = dmName;
|
||||||
|
el.messageBody.placeholder = displayName
|
||||||
|
? `Message @${displayName}`
|
||||||
|
: "Message";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const channel = state.channels.find((c) => c.id === state.selectedTextChannelId);
|
||||||
|
el.channelTitle.textContent = channel ? channel.name : "Select a channel";
|
||||||
|
el.messageBody.placeholder = channel ? `Message #${channel.name}` : "Select a channel";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createInviteLink() {
|
||||||
|
if (!state.selectedGuildId) {
|
||||||
|
alert("Select a server first.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const invite = await api(`/guilds/${state.selectedGuildId}/invites`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ max_uses: 50, expires_in_hours: 24 }),
|
||||||
|
});
|
||||||
|
const link = `${location.origin}/?invite=${encodeURIComponent(invite.code)}`;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(link);
|
||||||
|
alert(`Invite link copied:\n${link}`);
|
||||||
|
} catch {
|
||||||
|
prompt("Copy invite link:", link);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Logic ---
|
||||||
|
|
||||||
|
async function loadGuilds() {
|
||||||
|
state.guilds = await api("/guilds");
|
||||||
|
renderGuilds();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDMConversations() {
|
||||||
|
state.dmConversations = await api("/dms");
|
||||||
|
renderDMs();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadGuildMembers() {
|
||||||
|
if (!state.selectedGuildId) {
|
||||||
|
state.members = [];
|
||||||
|
renderMembers();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.members = await api(`/guilds/${state.selectedGuildId}/members`);
|
||||||
|
renderMembers();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadChannels() {
|
||||||
|
if (!state.selectedGuildId) {
|
||||||
|
state.channels = [];
|
||||||
|
renderChannels();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.channels = await api(`/guilds/${state.selectedGuildId}/channels`);
|
||||||
|
renderChannels();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshVoicePresence() {
|
||||||
|
if (!state.selectedGuildId) {
|
||||||
|
state.voicePresence.clear();
|
||||||
|
renderChannels();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await api(`/guilds/${state.selectedGuildId}/voice-presence`);
|
||||||
|
state.voicePresence.clear();
|
||||||
|
for (const entry of res.channels || []) {
|
||||||
|
state.voicePresence.set(entry.channel_id, entry.participants || []);
|
||||||
|
}
|
||||||
|
renderChannels();
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("voice presence failed", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startVoicePresencePolling() {
|
||||||
|
if (state.voicePresencePollId) clearInterval(state.voicePresencePollId);
|
||||||
|
state.voicePresencePollId = setInterval(() => {
|
||||||
|
refreshVoicePresence().catch(() => {});
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Voice ---
|
||||||
|
|
||||||
|
function getVoiceWsUrl(channelId) {
|
||||||
|
const proto = location.protocol === "https:" ? "wss" : "ws";
|
||||||
|
return `${proto}://${location.host}/channels/${channelId}/voice/ws`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldInitiateOffer(peerId) {
|
||||||
|
if (!state.me || !state.me.id) return false;
|
||||||
|
return state.me.id > peerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDeepFilterLib() {
|
||||||
|
if (!deepFilterLibPromise) {
|
||||||
|
deepFilterLibPromise = import(DEEPFILTERNET_LIB_PATH);
|
||||||
|
}
|
||||||
|
return deepFilterLibPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildDenoiserNode(audioContext) {
|
||||||
|
if (!audioContext.audioWorklet) {
|
||||||
|
throw new Error("AudioWorklet is not supported in this browser");
|
||||||
|
}
|
||||||
|
|
||||||
|
const df = await loadDeepFilterLib();
|
||||||
|
const core = new df.DeepFilterNet3Core({
|
||||||
|
sampleRate: 48000,
|
||||||
|
noiseReductionLevel: NSNET2_COMPAT_SUPPRESSION,
|
||||||
|
assetConfig: {
|
||||||
|
cdnUrl: "/static/vendor/deepfilternet3",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await core.initialize();
|
||||||
|
const workletNode = await core.createAudioWorkletNode(audioContext);
|
||||||
|
state.voice.deepFilterCore = core;
|
||||||
|
return workletNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopAndClearAudioPipeline() {
|
||||||
|
if (state.voice.deepFilterCore) {
|
||||||
|
state.voice.deepFilterCore.destroy();
|
||||||
|
state.voice.deepFilterCore = null;
|
||||||
|
}
|
||||||
|
if (state.voice.denoiserNode && typeof state.voice.denoiserNode.destroy === "function") {
|
||||||
|
state.voice.denoiserNode.destroy();
|
||||||
|
}
|
||||||
|
if (state.voice.localStream) {
|
||||||
|
for (const track of state.voice.localStream.getTracks()) track.stop();
|
||||||
|
}
|
||||||
|
if (state.voice.rawStream && state.voice.rawStream !== state.voice.localStream) {
|
||||||
|
for (const track of state.voice.rawStream.getTracks()) track.stop();
|
||||||
|
}
|
||||||
|
if (state.voice.audioContext) {
|
||||||
|
state.voice.audioContext.close().catch(() => {});
|
||||||
|
}
|
||||||
|
state.voice.localStream = null;
|
||||||
|
state.voice.rawStream = null;
|
||||||
|
state.voice.audioContext = null;
|
||||||
|
state.voice.denoiserNode = null;
|
||||||
|
state.voice.deepFilterCore = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildAudioPipeline(rawStream) {
|
||||||
|
const audioContext = new AudioContext();
|
||||||
|
const source = audioContext.createMediaStreamSource(rawStream);
|
||||||
|
const destination = audioContext.createMediaStreamDestination();
|
||||||
|
state.voice.audioContext = audioContext;
|
||||||
|
|
||||||
|
let head = source;
|
||||||
|
|
||||||
|
const denoiserNode = await buildDenoiserNode(audioContext);
|
||||||
|
if (denoiserNode) {
|
||||||
|
head.connect(denoiserNode);
|
||||||
|
head = denoiserNode;
|
||||||
|
}
|
||||||
|
head.connect(destination);
|
||||||
|
|
||||||
|
state.voice.denoiserNode = denoiserNode;
|
||||||
|
return destination.stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createLocalVoiceStream() {
|
||||||
|
const constraints = {
|
||||||
|
audio: {
|
||||||
|
channelCount: 1,
|
||||||
|
sampleRate: 48000,
|
||||||
|
echoCancellation: true,
|
||||||
|
noiseSuppression: false,
|
||||||
|
autoGainControl: false,
|
||||||
|
},
|
||||||
|
video: false,
|
||||||
|
};
|
||||||
|
const rawStream = await navigator.mediaDevices.getUserMedia(constraints);
|
||||||
|
const localStream = await buildAudioPipeline(rawStream);
|
||||||
|
state.voice.rawStream = rawStream;
|
||||||
|
state.voice.localStream = localStream;
|
||||||
|
if (state.voice.muted) {
|
||||||
|
state.voice.localStream.getAudioTracks().forEach((t) => {
|
||||||
|
t.enabled = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensurePeerConnection(peerId) {
|
||||||
|
if (state.voice.peerConnections.has(peerId)) {
|
||||||
|
return state.voice.peerConnections.get(peerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pc = new RTCPeerConnection({ iceServers: state.voice.iceServers });
|
||||||
|
|
||||||
|
if (state.voice.localStream) {
|
||||||
|
for (const track of state.voice.localStream.getTracks()) {
|
||||||
|
pc.addTrack(track, state.voice.localStream);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pc.onicecandidate = (event) => {
|
||||||
|
if (!event.candidate || !state.voice.ws) return;
|
||||||
|
state.voice.ws.send(JSON.stringify({
|
||||||
|
type: "signal",
|
||||||
|
to_user_id: peerId,
|
||||||
|
kind: "ice",
|
||||||
|
data: event.candidate,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
pc.ontrack = (event) => {
|
||||||
|
let audio = document.getElementById(`audio-${peerId}`);
|
||||||
|
if (!audio) {
|
||||||
|
audio = document.createElement("audio");
|
||||||
|
audio.id = `audio-${peerId}`;
|
||||||
|
audio.autoplay = true;
|
||||||
|
audio.playsInline = true;
|
||||||
|
document.body.appendChild(audio);
|
||||||
|
}
|
||||||
|
audio.srcObject = event.streams[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
state.voice.peerConnections.set(peerId, pc);
|
||||||
|
return pc;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendOffer(peerId) {
|
||||||
|
const pc = ensurePeerConnection(peerId);
|
||||||
|
const offer = await pc.createOffer();
|
||||||
|
await pc.setLocalDescription(offer);
|
||||||
|
state.voice.ws.send(JSON.stringify({
|
||||||
|
type: "signal",
|
||||||
|
to_user_id: peerId,
|
||||||
|
kind: "offer",
|
||||||
|
data: offer,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSignal(fromPeerId, kind, data) {
|
||||||
|
const pc = ensurePeerConnection(fromPeerId);
|
||||||
|
|
||||||
|
if (kind === "offer") {
|
||||||
|
await pc.setRemoteDescription(new RTCSessionDescription(data));
|
||||||
|
const answer = await pc.createAnswer();
|
||||||
|
await pc.setLocalDescription(answer);
|
||||||
|
state.voice.ws.send(JSON.stringify({
|
||||||
|
type: "signal",
|
||||||
|
to_user_id: fromPeerId,
|
||||||
|
kind: "answer",
|
||||||
|
data: answer,
|
||||||
|
}));
|
||||||
|
} else if (kind === "answer") {
|
||||||
|
await pc.setRemoteDescription(new RTCSessionDescription(data));
|
||||||
|
} else if (kind === "ice") {
|
||||||
|
try {
|
||||||
|
await pc.addIceCandidate(data ? new RTCIceCandidate(data) : null);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("failed to add ice candidate", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function joinVoice() {
|
||||||
|
if (!state.selectedVoiceChannelId) return;
|
||||||
|
if (state.voice.joinedChannelId === state.selectedVoiceChannelId) return;
|
||||||
|
|
||||||
|
await leaveVoice();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createLocalVoiceStream();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("microphone denied", err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ws = new WebSocket(getVoiceWsUrl(state.selectedVoiceChannelId));
|
||||||
|
state.voice.ws = ws;
|
||||||
|
state.voice.joinedChannelId = state.selectedVoiceChannelId;
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
const channel = state.channels.find(c => c.id === state.selectedVoiceChannelId);
|
||||||
|
el.vcChannelName.textContent = channel ? channel.name : "Voice";
|
||||||
|
el.voiceConnection.classList.remove("hidden");
|
||||||
|
refreshVoicePresence().catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = async (event) => {
|
||||||
|
const msg = JSON.parse(event.data);
|
||||||
|
if (msg.type === "peers") {
|
||||||
|
for (const peer of msg.peers) {
|
||||||
|
if (shouldInitiateOffer(peer.user_id)) await sendOffer(peer.user_id);
|
||||||
|
}
|
||||||
|
} else if (msg.type === "peer_joined") {
|
||||||
|
if (shouldInitiateOffer(msg.user_id)) await sendOffer(msg.user_id);
|
||||||
|
} else if (msg.type === "peer_left") {
|
||||||
|
const pc = state.voice.peerConnections.get(msg.user_id);
|
||||||
|
if (pc) {
|
||||||
|
pc.close();
|
||||||
|
state.voice.peerConnections.delete(msg.user_id);
|
||||||
|
}
|
||||||
|
document.getElementById(`audio-${msg.user_id}`)?.remove();
|
||||||
|
} else if (msg.type === "signal") {
|
||||||
|
await handleSignal(msg.from_user_id, msg.kind, msg.data);
|
||||||
|
}
|
||||||
|
refreshVoicePresence().catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
el.voiceConnection.classList.add("hidden");
|
||||||
|
for (const pc of state.voice.peerConnections.values()) pc.close();
|
||||||
|
state.voice.peerConnections.clear();
|
||||||
|
stopAndClearAudioPipeline();
|
||||||
|
state.voice.joinedChannelId = null;
|
||||||
|
state.voice.ws = null;
|
||||||
|
refreshVoicePresence().catch(() => {});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function leaveVoice() {
|
||||||
|
if (state.voice.ws) state.voice.ws.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleMute() {
|
||||||
|
if (!state.voice.localStream) return;
|
||||||
|
state.voice.muted = !state.voice.muted;
|
||||||
|
state.voice.localStream.getAudioTracks().forEach((t) => {
|
||||||
|
t.enabled = !state.voice.muted;
|
||||||
|
});
|
||||||
|
el.voiceMuteBtn.innerHTML = state.voice.muted ? '<i data-lucide="mic-off"></i>' : '<i data-lucide="mic"></i>';
|
||||||
|
el.voiceMuteBtn.style.color = state.voice.muted ? 'var(--danger)' : 'var(--text-muted)';
|
||||||
|
lucide.createIcons();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Initialization ---
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
lucide.createIcons();
|
||||||
|
|
||||||
|
el.loginBtn.onclick = () => { location.href = "/auth/login"; };
|
||||||
|
|
||||||
|
el.logoutBtn.onclick = async () => {
|
||||||
|
await leaveVoice();
|
||||||
|
await api("/auth/logout", { method: "POST" });
|
||||||
|
location.reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
el.addGuildBtn.onclick = () => { el.modalContainer.classList.remove("hidden"); };
|
||||||
|
el.createInviteBtn.onclick = createInviteLink;
|
||||||
|
el.modalCancel.onclick = () => { el.modalContainer.classList.add("hidden"); };
|
||||||
|
|
||||||
|
el.addTextBtn.onclick = () => {
|
||||||
|
if (!state.selectedGuildId) {
|
||||||
|
alert("Create or select a server first.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
document.querySelector('input[name="channel-kind"][value="text"]').checked = true;
|
||||||
|
el.channelModal.classList.remove("hidden");
|
||||||
|
};
|
||||||
|
el.addVoiceBtn.onclick = () => {
|
||||||
|
if (!state.selectedGuildId) {
|
||||||
|
alert("Create or select a server first.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
document.querySelector('input[name="channel-kind"][value="voice"]').checked = true;
|
||||||
|
el.channelModal.classList.remove("hidden");
|
||||||
|
};
|
||||||
|
el.channelModalCancel.onclick = () => { el.channelModal.classList.add("hidden"); };
|
||||||
|
|
||||||
|
el.guildForm.onsubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
const guild = await api("/guilds", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ name: el.guildName.value }),
|
||||||
|
});
|
||||||
|
el.guildName.value = "";
|
||||||
|
el.modalContainer.classList.add("hidden");
|
||||||
|
state.guilds.push(guild);
|
||||||
|
state.selectedGuildId = guild.id;
|
||||||
|
state.selectedTextChannelId = null;
|
||||||
|
state.selectedVoiceChannelId = null;
|
||||||
|
state.selectedDmUserId = null;
|
||||||
|
state.selectedDmDisplayName = null;
|
||||||
|
localStorage.setItem(LAST_GUILD_STORAGE_KEY, guild.id);
|
||||||
|
renderGuilds();
|
||||||
|
await loadChannels();
|
||||||
|
await loadGuildMembers();
|
||||||
|
await refreshVoicePresence();
|
||||||
|
renderMessages([]);
|
||||||
|
updateHeaderLabels();
|
||||||
|
} catch (err) { alert(err.message); }
|
||||||
|
};
|
||||||
|
|
||||||
|
el.channelForm.onsubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!state.selectedGuildId) {
|
||||||
|
alert("Select a server first.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const kindInput = document.querySelector('input[name="channel-kind"]:checked');
|
||||||
|
const kind = kindInput ? kindInput.value : "text";
|
||||||
|
const channelName = el.channelName.value.trim();
|
||||||
|
if (!channelName) {
|
||||||
|
alert("Channel name is required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const created = await api("/channels", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
guild_id: state.selectedGuildId,
|
||||||
|
name: channelName,
|
||||||
|
kind: kind,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
el.channelName.value = "";
|
||||||
|
el.channelModal.classList.add("hidden");
|
||||||
|
await loadChannels();
|
||||||
|
if (created.kind === "text") {
|
||||||
|
state.selectedTextChannelId = created.id;
|
||||||
|
state.selectedVoiceChannelId = null;
|
||||||
|
state.selectedDmUserId = null;
|
||||||
|
state.selectedDmDisplayName = null;
|
||||||
|
renderChannels();
|
||||||
|
renderDMs();
|
||||||
|
updateHeaderLabels();
|
||||||
|
const messages = await api(`/channels/${created.id}/messages?limit=100`);
|
||||||
|
renderMessages(messages);
|
||||||
|
} else {
|
||||||
|
state.selectedVoiceChannelId = created.id;
|
||||||
|
state.selectedTextChannelId = null;
|
||||||
|
state.selectedDmUserId = null;
|
||||||
|
state.selectedDmDisplayName = null;
|
||||||
|
renderChannels();
|
||||||
|
renderDMs();
|
||||||
|
updateHeaderLabels();
|
||||||
|
}
|
||||||
|
} catch (err) { alert(err.message); }
|
||||||
|
};
|
||||||
|
|
||||||
|
el.messageForm.onsubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const body = el.messageBody.value.trim();
|
||||||
|
if (!body) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (state.selectedTextChannelId) {
|
||||||
|
await api(`/channels/${state.selectedTextChannelId}/messages`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ body }),
|
||||||
|
});
|
||||||
|
} else if (state.selectedDmUserId) {
|
||||||
|
await api(`/dms/${state.selectedDmUserId}/messages`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ body }),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.messageBody.value = "";
|
||||||
|
const messages = state.selectedTextChannelId
|
||||||
|
? await api(`/channels/${state.selectedTextChannelId}/messages?limit=100`)
|
||||||
|
: await api(`/dms/${state.selectedDmUserId}/messages?limit=100`);
|
||||||
|
renderMessages(messages);
|
||||||
|
await loadDMConversations();
|
||||||
|
renderDMs();
|
||||||
|
} catch (err) { alert(err.message); }
|
||||||
|
};
|
||||||
|
|
||||||
|
el.voiceMuteBtn.onclick = toggleMute;
|
||||||
|
el.voiceLeaveBtn.onclick = leaveVoice;
|
||||||
|
|
||||||
|
try {
|
||||||
|
state.me = await api("/me");
|
||||||
|
if (state.me && state.me.display_name) {
|
||||||
|
el.userName.textContent = state.me.display_name;
|
||||||
|
el.userAvatar.textContent = shortName(state.me.display_name);
|
||||||
|
}
|
||||||
|
el.authScreen.classList.add("hidden");
|
||||||
|
el.main.classList.remove("hidden");
|
||||||
|
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
const inviteCode = params.get("invite");
|
||||||
|
if (inviteCode) {
|
||||||
|
try {
|
||||||
|
const joinedGuild = await api(`/invites/${encodeURIComponent(inviteCode)}/join`, {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
localStorage.setItem(LAST_GUILD_STORAGE_KEY, joinedGuild.id);
|
||||||
|
} catch (err) {
|
||||||
|
alert(`Failed to join invite: ${err.message}`);
|
||||||
|
} finally {
|
||||||
|
params.delete("invite");
|
||||||
|
const nextQuery = params.toString();
|
||||||
|
const nextUrl = `${location.pathname}${nextQuery ? `?${nextQuery}` : ""}`;
|
||||||
|
history.replaceState(null, "", nextUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadGuilds();
|
||||||
|
await loadDMConversations();
|
||||||
|
|
||||||
|
if (state.guilds.length > 0) {
|
||||||
|
const lastGuildId = localStorage.getItem(LAST_GUILD_STORAGE_KEY);
|
||||||
|
const guild = state.guilds.find((g) => g.id === lastGuildId) || state.guilds[0];
|
||||||
|
state.selectedGuildId = guild.id;
|
||||||
|
localStorage.setItem(LAST_GUILD_STORAGE_KEY, guild.id);
|
||||||
|
renderGuilds();
|
||||||
|
await loadChannels();
|
||||||
|
await loadGuildMembers();
|
||||||
|
await refreshVoicePresence();
|
||||||
|
updateHeaderLabels();
|
||||||
|
} else {
|
||||||
|
state.members = [];
|
||||||
|
renderMembers();
|
||||||
|
updateHeaderLabels();
|
||||||
|
}
|
||||||
|
|
||||||
|
startVoicePresencePolling();
|
||||||
|
lucide.createIcons();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("init failed", err);
|
||||||
|
el.authScreen.classList.remove("hidden");
|
||||||
|
el.main.classList.add("hidden");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init();
|
||||||
195
static/index.html
Normal file
195
static/index.html
Normal file
|
|
@ -0,0 +1,195 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Chattz</title>
|
||||||
|
<link rel="stylesheet" href="/static/styles.css" />
|
||||||
|
<!-- Lucide Icons -->
|
||||||
|
<script src="https://unpkg.com/lucide@latest"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="auth-screen" id="auth-screen">
|
||||||
|
<div class="auth-card">
|
||||||
|
<div class="brand-large">C</div>
|
||||||
|
<h1>Chattz</h1>
|
||||||
|
<p>Sign in to open your servers.</p>
|
||||||
|
<button id="login-btn">Login with Authentik</button>
|
||||||
|
<p id="status" class="status-line"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="shell hidden" id="main">
|
||||||
|
<aside class="server-rail">
|
||||||
|
<div class="brand" title="Home">
|
||||||
|
<i data-lucide="message-square"></i>
|
||||||
|
</div>
|
||||||
|
<div class="separator"></div>
|
||||||
|
<div id="guild-list" class="guild-list"></div>
|
||||||
|
<button id="add-guild-btn" class="guild-pill action-pill" title="Add a Server">
|
||||||
|
<i data-lucide="plus"></i>
|
||||||
|
</button>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<aside class="channel-sidebar">
|
||||||
|
<header class="sidebar-header clickable" id="guild-header">
|
||||||
|
<h2 id="guild-title">No server selected</h2>
|
||||||
|
<div class="sidebar-header-actions">
|
||||||
|
<button id="create-invite-btn" class="header-action-btn" title="Create Invite Link">
|
||||||
|
<i data-lucide="link-2"></i>
|
||||||
|
</button>
|
||||||
|
<i data-lucide="chevron-down"></i>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="sidebar-scroll">
|
||||||
|
<section class="sidebar-group">
|
||||||
|
<div class="group-title">
|
||||||
|
<i data-lucide="chevron-down" class="group-toggle"></i>
|
||||||
|
<span>Text Channels</span>
|
||||||
|
<button id="add-text-btn" class="add-btn" type="button" title="Create Text Channel">
|
||||||
|
<i data-lucide="plus"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="channel-list" class="channel-list"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="sidebar-group">
|
||||||
|
<div class="group-title">
|
||||||
|
<i data-lucide="chevron-down" class="group-toggle"></i>
|
||||||
|
<span>Voice Channels</span>
|
||||||
|
<button id="add-voice-btn" class="add-btn" type="button" title="Create Voice Channel">
|
||||||
|
<i data-lucide="plus"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="voice-channel-list" class="channel-list"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="sidebar-group">
|
||||||
|
<div class="group-title">
|
||||||
|
<i data-lucide="chevron-down" class="group-toggle"></i>
|
||||||
|
<span>Direct Messages</span>
|
||||||
|
</div>
|
||||||
|
<div id="dm-list" class="channel-list"></div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sidebar-footer">
|
||||||
|
<div id="voice-connection" class="voice-connection hidden">
|
||||||
|
<div class="vc-info">
|
||||||
|
<i data-lucide="signal-high" class="vc-icon"></i>
|
||||||
|
<div class="vc-text">
|
||||||
|
<span class="vc-status">Voice Connected</span>
|
||||||
|
<span id="vc-channel-name" class="vc-name">General</span>
|
||||||
|
</div>
|
||||||
|
<div class="vc-actions">
|
||||||
|
<button id="voice-mute-btn" title="Mute"><i data-lucide="mic"></i></button>
|
||||||
|
<button id="voice-leave-btn" title="Disconnect"><i data-lucide="phone-off"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="user-panel">
|
||||||
|
<div class="avatar-wrapper">
|
||||||
|
<div id="user-avatar" class="avatar">U</div>
|
||||||
|
<div class="status-dot online"></div>
|
||||||
|
</div>
|
||||||
|
<div class="user-info">
|
||||||
|
<div id="user-name" class="display-name">Username</div>
|
||||||
|
<div class="user-status">Online</div>
|
||||||
|
</div>
|
||||||
|
<div class="user-actions">
|
||||||
|
<button id="user-settings-btn" title="Settings"><i data-lucide="settings"></i></button>
|
||||||
|
<button id="logout-btn" title="Logout"><i data-lucide="log-out"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="chat-pane">
|
||||||
|
<header class="chat-header">
|
||||||
|
<i data-lucide="hash" class="header-icon"></i>
|
||||||
|
<h3 id="channel-title">Select a channel</h3>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div id="message-list" class="message-list"></div>
|
||||||
|
|
||||||
|
<div class="chat-input-wrapper">
|
||||||
|
<form id="message-form" class="message-form">
|
||||||
|
<input id="message-body" autocomplete="off" placeholder="Message #channel" maxlength="4000" required />
|
||||||
|
<button type="submit" class="hidden"></button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<aside class="utility-sidebar">
|
||||||
|
<header class="sidebar-header">
|
||||||
|
<h2>Members</h2>
|
||||||
|
</header>
|
||||||
|
<div class="member-list-wrapper">
|
||||||
|
<div id="member-list" class="member-list"></div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modals -->
|
||||||
|
<div id="modal-container" class="modal-container hidden">
|
||||||
|
<div class="modal">
|
||||||
|
<h2 id="modal-title">Create Server</h2>
|
||||||
|
<form id="guild-form" class="modal-form">
|
||||||
|
<div class="form-item">
|
||||||
|
<label for="guild-name">SERVER NAME</label>
|
||||||
|
<input id="guild-name" placeholder="My Awesome Server" maxlength="64" required />
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="cancel-btn" id="modal-cancel">Cancel</button>
|
||||||
|
<button type="submit" class="submit-btn">Create</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="channel-modal" class="modal-container hidden">
|
||||||
|
<div class="modal">
|
||||||
|
<h2>Create Channel</h2>
|
||||||
|
<form id="channel-form" class="modal-form">
|
||||||
|
<div class="form-item">
|
||||||
|
<label>CHANNEL TYPE</label>
|
||||||
|
<div class="radio-group">
|
||||||
|
<label class="radio-item">
|
||||||
|
<input type="radio" name="channel-kind" value="text" checked>
|
||||||
|
<div class="radio-box">
|
||||||
|
<i data-lucide="hash"></i>
|
||||||
|
<div class="radio-text">
|
||||||
|
<strong>Text</strong>
|
||||||
|
<span>Send messages, images, and GIFs.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label class="radio-item">
|
||||||
|
<input type="radio" name="channel-kind" value="voice">
|
||||||
|
<div class="radio-box">
|
||||||
|
<i data-lucide="volume-2"></i>
|
||||||
|
<div class="radio-text">
|
||||||
|
<strong>Voice</strong>
|
||||||
|
<span>Hang out together with voice and video.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-item">
|
||||||
|
<label for="channel-name">CHANNEL NAME</label>
|
||||||
|
<input id="channel-name" placeholder="new-channel" maxlength="64" required />
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="cancel-btn" id="channel-modal-cancel">Cancel</button>
|
||||||
|
<button type="submit" class="submit-btn">Create Channel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/static/app.js" defer></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
526
static/styles.css
Normal file
526
static/styles.css
Normal file
|
|
@ -0,0 +1,526 @@
|
||||||
|
:root {
|
||||||
|
--bg-darker: #1e1f22;
|
||||||
|
--bg-sidebar: #2b2d31;
|
||||||
|
--bg-main: #313338;
|
||||||
|
--bg-secondary: #232428;
|
||||||
|
--bg-tertiary: #111214;
|
||||||
|
--bg-modifier-selected: rgba(78, 80, 88, 0.6);
|
||||||
|
--bg-modifier-hover: rgba(78, 80, 88, 0.3);
|
||||||
|
--bg-input: #383a40;
|
||||||
|
|
||||||
|
--text-normal: #dbdee1;
|
||||||
|
--text-muted: #949ba4;
|
||||||
|
--text-strong: #f2f3f5;
|
||||||
|
--text-link: #00a8fc;
|
||||||
|
|
||||||
|
--brand: #5865f2;
|
||||||
|
--brand-hover: #4752c4;
|
||||||
|
--green: #23a559;
|
||||||
|
--danger: #f23f43;
|
||||||
|
--yellow: #f0b232;
|
||||||
|
|
||||||
|
--font-main: "gg sans", "Inter", "Noto Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
height: 100vh;
|
||||||
|
background: var(--bg-darker);
|
||||||
|
color: var(--text-normal);
|
||||||
|
font-family: var(--font-main);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbars */
|
||||||
|
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||||
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
::-webkit-scrollbar-thumb { background: var(--bg-tertiary); border-radius: 4px; }
|
||||||
|
::-webkit-scrollbar-thumb:hover { background: #242529; }
|
||||||
|
|
||||||
|
button { border: 0; cursor: pointer; transition: all 150ms ease; background: none; color: inherit; font: inherit; padding: 0; }
|
||||||
|
input, select { border: 0; outline: none; background: var(--bg-input); color: var(--text-normal); font: inherit; }
|
||||||
|
|
||||||
|
.hidden { display: none !important; }
|
||||||
|
|
||||||
|
/* Auth Screen */
|
||||||
|
.auth-screen {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
height: 100vh;
|
||||||
|
background-image: url("https://discord.com/assets/f9a15998e94589d343f7.png");
|
||||||
|
background-size: cover;
|
||||||
|
}
|
||||||
|
.auth-card {
|
||||||
|
width: min(480px, 92vw);
|
||||||
|
background: var(--bg-sidebar);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 32px;
|
||||||
|
box-shadow: 0 2px 10px rgba(0,0,0,0.2);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.brand-large {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
background: var(--brand);
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 20px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-size: 40px;
|
||||||
|
font-weight: 800;
|
||||||
|
margin: 0 auto 20px;
|
||||||
|
}
|
||||||
|
.auth-card h1 { margin: 0 0 8px; color: var(--text-strong); }
|
||||||
|
.auth-card p { margin: 0 0 24px; color: var(--text-muted); }
|
||||||
|
#login-btn {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--brand);
|
||||||
|
color: #fff;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
#login-btn:hover { background: var(--brand-hover); }
|
||||||
|
|
||||||
|
/* Main Layout */
|
||||||
|
.shell {
|
||||||
|
height: 100vh;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 72px 240px 1fr 240px;
|
||||||
|
background: var(--bg-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Server Rail */
|
||||||
|
.server-rail {
|
||||||
|
background: var(--bg-darker);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 0;
|
||||||
|
gap: 8px;
|
||||||
|
overflow-y: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
.server-rail::-webkit-scrollbar { display: none; }
|
||||||
|
.guild-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand, .guild-pill {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
position: relative;
|
||||||
|
background: var(--bg-sidebar);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand { background: var(--brand); color: #fff; border-radius: 16px; margin-bottom: 2px; }
|
||||||
|
.brand:hover { border-radius: 16px; }
|
||||||
|
|
||||||
|
.separator {
|
||||||
|
width: 32px;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--bg-modifier-selected);
|
||||||
|
margin-bottom: 2px;
|
||||||
|
border-radius: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guild-pill:hover, .guild-pill.active {
|
||||||
|
border-radius: 16px;
|
||||||
|
background: var(--brand);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guild-pill::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: -12px;
|
||||||
|
width: 4px;
|
||||||
|
height: 0;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 0 4px 4px 0;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
.guild-pill:hover::before { height: 20px; }
|
||||||
|
.guild-pill.active::before { height: 40px; }
|
||||||
|
|
||||||
|
.action-pill { color: var(--green); }
|
||||||
|
.action-pill:hover { background: var(--green); color: #fff; }
|
||||||
|
|
||||||
|
/* Channel Sidebar */
|
||||||
|
.channel-sidebar {
|
||||||
|
background: var(--bg-sidebar);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header {
|
||||||
|
padding: 0 16px;
|
||||||
|
height: 48px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
border-bottom: 1px solid var(--bg-darker);
|
||||||
|
box-shadow: 0 1px 0 rgba(0,0,0,0.1);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.1s;
|
||||||
|
}
|
||||||
|
.sidebar-header:hover { background: var(--bg-modifier-hover); }
|
||||||
|
.sidebar-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-strong);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.sidebar-header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.header-action-btn {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.header-action-btn:hover {
|
||||||
|
background: var(--bg-modifier-hover);
|
||||||
|
color: var(--text-normal);
|
||||||
|
}
|
||||||
|
.header-action-btn i {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-scroll { flex: 1; overflow-y: auto; padding-top: 12px; }
|
||||||
|
|
||||||
|
.sidebar-group { margin-bottom: 20px; }
|
||||||
|
.group-title {
|
||||||
|
padding: 0 8px 0 2px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.24px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.group-title:hover { color: var(--text-normal); }
|
||||||
|
.group-toggle { width: 12px; height: 12px; margin-right: 2px; }
|
||||||
|
.group-title span { flex: 1; }
|
||||||
|
.add-btn {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 4px;
|
||||||
|
opacity: 0.7;
|
||||||
|
transition: opacity 0.2s, background-color 0.2s;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
.add-btn:hover {
|
||||||
|
opacity: 1;
|
||||||
|
background: var(--bg-modifier-hover);
|
||||||
|
}
|
||||||
|
.add-btn i { width: 16px; height: 16px; }
|
||||||
|
|
||||||
|
.channel-list { padding: 0 8px; display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.channel-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 500;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.channel-row:hover { background: var(--bg-modifier-hover); color: var(--text-normal); }
|
||||||
|
.channel-row.active { background: var(--bg-modifier-selected); color: var(--text-strong); }
|
||||||
|
.channel-row i { width: 20px; height: 20px; opacity: 0.6; }
|
||||||
|
|
||||||
|
/* Sidebar Footer */
|
||||||
|
.sidebar-footer { background: var(--bg-secondary); padding: 0; }
|
||||||
|
|
||||||
|
.user-panel {
|
||||||
|
padding: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
height: 52px;
|
||||||
|
}
|
||||||
|
.user-panel:hover { background: var(--bg-modifier-hover); }
|
||||||
|
|
||||||
|
.avatar-wrapper { position: relative; }
|
||||||
|
.avatar {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--brand);
|
||||||
|
color: #fff;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.status-dot {
|
||||||
|
position: absolute;
|
||||||
|
bottom: -2px;
|
||||||
|
right: -2px;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 3px solid var(--bg-secondary);
|
||||||
|
}
|
||||||
|
.status-dot.online { background: var(--green); }
|
||||||
|
|
||||||
|
.user-info { flex: 1; min-width: 0; }
|
||||||
|
.display-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-strong);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.user-status { font-size: 12px; color: var(--text-muted); }
|
||||||
|
|
||||||
|
.user-actions { display: flex; gap: 2px; }
|
||||||
|
.user-actions button {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.user-actions button:hover { background: var(--bg-modifier-hover); color: var(--text-normal); }
|
||||||
|
.user-actions i { width: 20px; height: 20px; }
|
||||||
|
|
||||||
|
/* Voice Connection */
|
||||||
|
.voice-connection {
|
||||||
|
padding: 8px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.05);
|
||||||
|
}
|
||||||
|
.vc-info { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.vc-icon { color: var(--green); width: 20px; }
|
||||||
|
.vc-text { flex: 1; display: flex; flex-direction: column; }
|
||||||
|
.vc-status { color: var(--green); font-size: 14px; font-weight: 700; }
|
||||||
|
.vc-name { color: var(--text-muted); font-size: 12px; }
|
||||||
|
.vc-actions { display: flex; gap: 4px; }
|
||||||
|
.vc-actions button {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
.vc-actions button:hover { background: var(--bg-modifier-hover); color: var(--text-normal); }
|
||||||
|
|
||||||
|
/* Chat Pane */
|
||||||
|
.chat-pane {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--bg-main);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-header {
|
||||||
|
height: 48px;
|
||||||
|
padding: 0 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
border-bottom: 1px solid rgba(0,0,0,0.2);
|
||||||
|
box-shadow: 0 1px 0 rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
.header-icon { color: var(--text-muted); width: 24px; }
|
||||||
|
.chat-header h3 { margin: 0; font-size: 16px; font-weight: 700; color: var(--text-strong); }
|
||||||
|
|
||||||
|
.message-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: scroll;
|
||||||
|
padding: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg {
|
||||||
|
padding: 2px 16px;
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 1.0625rem;
|
||||||
|
}
|
||||||
|
.msg:hover { background: rgba(0,0,0,0.05); }
|
||||||
|
|
||||||
|
.msg-avatar {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--brand);
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-content { flex: 1; min-width: 0; }
|
||||||
|
.msg-header { display: flex; align-items: baseline; gap: 8px; margin-bottom: 4px; }
|
||||||
|
.msg-author {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-strong);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
.msg-author:hover { text-decoration: underline; }
|
||||||
|
.msg-time { font-size: 12px; color: var(--text-muted); }
|
||||||
|
.msg-body { color: var(--text-normal); line-height: 1.375rem; white-space: pre-wrap; word-wrap: break-word; }
|
||||||
|
|
||||||
|
.msg-grouped { margin-top: 0; padding-top: 0; padding-bottom: 0; }
|
||||||
|
.msg-grouped .msg-avatar, .msg-grouped .msg-header { display: none; }
|
||||||
|
.msg-grouped .msg-content { padding-left: 56px; }
|
||||||
|
|
||||||
|
/* Chat Input */
|
||||||
|
.chat-input-wrapper { padding: 0 16px 24px; }
|
||||||
|
.message-form {
|
||||||
|
background: var(--bg-input);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 11px 16px;
|
||||||
|
}
|
||||||
|
.message-form input {
|
||||||
|
width: 100%;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-normal);
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
.message-form input::placeholder { color: var(--text-muted); }
|
||||||
|
|
||||||
|
/* Member List */
|
||||||
|
.utility-sidebar {
|
||||||
|
background: var(--bg-sidebar);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.member-list-wrapper { flex: 1; overflow-y: auto; padding: 12px 8px; }
|
||||||
|
.member-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.member-row:hover { background: var(--bg-modifier-hover); color: var(--text-normal); }
|
||||||
|
.member-avatar {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--brand);
|
||||||
|
color: #fff;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.member-name {
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 15px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modals */
|
||||||
|
.modal-container {
|
||||||
|
position: fixed;
|
||||||
|
top: 0; left: 0; right: 0; bottom: 0;
|
||||||
|
background: rgba(0,0,0,0.85);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
.modal {
|
||||||
|
background: var(--bg-sidebar);
|
||||||
|
width: min(440px, 95vw);
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 24px;
|
||||||
|
color: var(--text-normal);
|
||||||
|
}
|
||||||
|
.modal h2 { margin: 0 0 16px; text-align: center; color: var(--text-strong); }
|
||||||
|
.form-item { margin-bottom: 20px; }
|
||||||
|
.form-item label {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.form-item input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--bg-darker);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
margin-top: 24px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.cancel-btn { padding: 10px 20px; color: var(--text-strong); font-weight: 500; }
|
||||||
|
.cancel-btn:hover { text-decoration: underline; }
|
||||||
|
.submit-btn {
|
||||||
|
background: var(--brand);
|
||||||
|
color: #fff;
|
||||||
|
padding: 10px 24px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.submit-btn:hover { background: var(--brand-hover); }
|
||||||
|
|
||||||
|
/* Radio Group for Channel Type */
|
||||||
|
.radio-group { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.radio-item { cursor: pointer; position: relative; }
|
||||||
|
.radio-item input { position: absolute; opacity: 0; }
|
||||||
|
.radio-box {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px;
|
||||||
|
background: var(--bg-modifier-hover);
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.1s;
|
||||||
|
}
|
||||||
|
.radio-item input:checked + .radio-box { background: var(--bg-modifier-selected); color: var(--text-strong); }
|
||||||
|
.radio-box i { width: 24px; height: 24px; color: var(--text-muted); }
|
||||||
|
.radio-text { display: flex; flex-direction: column; }
|
||||||
|
.radio-text strong { font-size: 16px; }
|
||||||
|
.radio-text span { font-size: 12px; color: var(--text-muted); }
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.shell { grid-template-columns: 72px 240px 1fr; }
|
||||||
|
.utility-sidebar { display: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.shell { grid-template-columns: 72px 1fr; }
|
||||||
|
.channel-sidebar { display: none; }
|
||||||
|
}
|
||||||
249
static/vendor/deepfilternet3/index.esm.js
vendored
Normal file
249
static/vendor/deepfilternet3/index.esm.js
vendored
Normal file
File diff suppressed because one or more lines are too long
BIN
static/vendor/deepfilternet3/v2/models/DeepFilterNet3_onnx.tar.gz
vendored
Normal file
BIN
static/vendor/deepfilternet3/v2/models/DeepFilterNet3_onnx.tar.gz
vendored
Normal file
Binary file not shown.
BIN
static/vendor/deepfilternet3/v2/pkg/df_bg.wasm
vendored
Normal file
BIN
static/vendor/deepfilternet3/v2/pkg/df_bg.wasm
vendored
Normal file
Binary file not shown.
Loading…
Add table
Add a link
Reference in a new issue