feat(chat): add chat module with messaging, presence, and reactions functionality
ZhenYi committed
This change is very large, so some diffs are not shown. Clone the repository to see the full change.
Modified app/apihost/src/main.rs app/apihost/src/main.rs +2 −0 View app/apihost/src/main.rs
@@ -24,6 +24,8 @@
2424 .context("building AppState")?;
2525 info!("dependencies ready");
2626
27 tokio::spawn(chat::worker::run_background_tasks(state.clone()));
28
2729 let api_router: Router = router(state)
2830 .await
2931 .map_err(|err| anyhow::anyhow!("failed to build router: {err}"))?;
Modified app/apihost/Cargo.toml app/apihost/Cargo.toml +1 −0 View app/apihost/Cargo.toml
@@ -6,6 +6,7 @@
66
77[dependencies]
88webapi = { workspace = true }
9chat = { workspace = true }
910export = { workspace = true }
1011session = { workspace = true }
1112config = { workspace = true }
Modified config/default.toml config/default.toml +26 −0 View config/default.toml
@@ -128,6 +128,32 @@
128128max_invite_email_len = 254
129129allow_self_join_groups = false
130130
131[chat]
132max_pagination = 200
133default_pagination = 50
134max_message_len = 4000
135max_topic_len = 1024
136max_thread_name_len = 255
137max_reaction_emoji_len = 64
138max_emoji_name_len = 64
139max_bookmark_title_len = 255
140max_webhook_name_len = 100
141max_mentions_per_message = 20
142typing_ttl_secs = 5
143presence_ttl_secs = 300
144ticket_secret = ""
145
146[chat.ws]
147ticket_ttl_secs = 60
148heartbeat_secs = 30
149max_subscriptions = 64
150max_frame_bytes = 262144
151history_limit = 200
152
153[chat.upload]
154max_size = 104857600
155presign_ttl_secs = 3600
156
131157[issues]
132158max_pagination = 100
133159default_pagination = 20
Modified lib/auth/src/share/crypto.rs lib/auth/src/share/crypto.rs +3 −9 View lib/auth/src/share/crypto.rs
@@ -2,9 +2,7 @@
22
33use aes_gcm::Aes256Gcm;
44use aes_gcm::aead::{Aead, AeadCore, KeyInit, OsRng};
5use argon2::password_hash::SaltString;
6use argon2::password_hash::rand_core::OsRng as ArgonOsRng;
7use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
5use argon2::{Argon2, PasswordHasher, PasswordVerifier};
86use base64::Engine;
97use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD};
108use config::auth::PasswordPolicy;
@@ -14,19 +12,15 @@
1412use crate::errors::AuthError;
1513
1614pub fn hash_password(password: &str) -> Result<String, AuthError> {
17 let salt = SaltString::generate(&mut ArgonOsRng);
1815 Argon2::default()
19 .hash_password(password.as_bytes(), &salt)
16 .hash_password(password.as_bytes())
2017 .map(|hash| hash.to_string())
2118 .map_err(|error| AuthError::Internal(format!("password hashing failed: {error}")))
2219}
2320
2421pub fn verify_password(password: &str, hash: &str) -> bool {
25 let Ok(parsed) = PasswordHash::new(hash) else {
26 return false;
27 };
2822 Argon2::default()
29 .verify_password(password.as_bytes(), &parsed)
23 .verify_password(password.as_bytes(), hash)
3024 .is_ok()
3125}
3226
Added lib/chat/src/handlers/attachments.rs lib/chat/src/handlers/attachments.rs +101 −0 View lib/chat/src/handlers/attachments.rs
@@ -0,0 +1,101 @@
1use axum::extract::{Json, Path, State};
2use axum::http::{HeaderMap, StatusCode};
3use axum::response::{IntoResponse, Response};
4use export::rest::{AppResponseData, NoData};
5use session::Session;
6use session::share::session::require_uid;
7use uuid::Uuid;
8
9use crate::errors::ApiError;
10use crate::services::attachments::{
11 AttachmentDto, CreateAttachmentCmd, FinalizeAttachmentCmd, UploadIntentDto,
12};
13use crate::services::service::ChatService;
14
15#[utoipa::path(
16 post,
17 path = "/chat/attachments",
18 tag = "chat",
19 request_body = CreateAttachmentCmd,
20 responses(
21 (status = 200, description = "upload intent", body = AppResponseData<UploadIntentDto>),
22 (status = 400, description = "bad request", body = AppResponseData<NoData>)
23 )
24)]
25pub async fn create_attachment(
26 State(service): State<ChatService>,
27 session: Session,
28 Json(cmd): Json<CreateAttachmentCmd>,
29) -> Result<AppResponseData<UploadIntentDto>, ApiError> {
30 let uid = require_uid(&session)?;
31 let intent = service.create_attachment(uid, cmd).await?;
32 Ok(AppResponseData::ok(intent))
33}
34
35#[utoipa::path(
36 post,
37 path = "/chat/attachments/{attachment_id}/finalize",
38 tag = "chat",
39 params(("attachment_id" = Uuid, Path, description = "attachment id")),
40 request_body = FinalizeAttachmentCmd,
41 responses(
42 (status = 200, description = "attachment finalized", body = AppResponseData<AttachmentDto>),
43 (status = 409, description = "already finalized", body = AppResponseData<NoData>)
44 )
45)]
46pub async fn finalize_attachment(
47 State(service): State<ChatService>,
48 session: Session,
49 Path(attachment_id): Path<Uuid>,
50 Json(cmd): Json<FinalizeAttachmentCmd>,
51) -> Result<AppResponseData<AttachmentDto>, ApiError> {
52 let uid = require_uid(&session)?;
53 let dto = service.finalize_attachment(uid, attachment_id, cmd).await?;
54 Ok(AppResponseData::ok(dto))
55}
56
57#[utoipa::path(
58 get,
59 path = "/chat/attachments/{attachment_id}",
60 tag = "chat",
61 params(("attachment_id" = Uuid, Path, description = "attachment id")),
62 responses(
63 (status = 200, description = "attachment meta", body = AppResponseData<AttachmentDto>),
64 (status = 404, description = "not found", body = AppResponseData<NoData>)
65 )
66)]
67pub async fn get_attachment(
68 State(service): State<ChatService>,
69 session: Session,
70 Path(attachment_id): Path<Uuid>,
71) -> Result<AppResponseData<AttachmentDto>, ApiError> {
72 let uid = require_uid(&session)?;
73 let dto = service.get_attachment_meta(uid, attachment_id).await?;
74 Ok(AppResponseData::ok(dto))
75}
76
77#[utoipa::path(
78 get,
79 path = "/chat/attachments/{attachment_id}/file",
80 tag = "chat",
81 params(("attachment_id" = Uuid, Path, description = "attachment id")),
82 responses(
83 (status = 200, description = "file stream"),
84 (status = 404, description = "not found", body = AppResponseData<NoData>)
85 )
86)]
87pub async fn download_attachment(
88 State(service): State<ChatService>,
89 session: Session,
90 Path(attachment_id): Path<Uuid>,
91) -> Result<Response, ApiError> {
92 let uid = require_uid(&session)?;
93 let (row, body) = service.download_attachment(uid, attachment_id).await?;
94 let mut headers = HeaderMap::new();
95 for (name, value) in ChatService::attachment_content_headers(&row) {
96 if let Ok(value) = value.parse() {
97 headers.insert(name, value);
98 }
99 }
100 Ok((StatusCode::OK, headers, body).into_response())
101}
Added lib/chat/src/handlers/bookmarks.rs lib/chat/src/handlers/bookmarks.rs +99 −0 View lib/chat/src/handlers/bookmarks.rs
@@ -0,0 +1,99 @@
1use axum::extract::{Json, Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4use session::share::session::require_uid;
5use uuid::Uuid;
6
7use crate::errors::ApiError;
8use crate::services::bookmarks::{BookmarkDto, CreateBookmarkCmd, UpdateBookmarkCmd};
9use crate::services::service::ChatService;
10
11#[utoipa::path(
12 get,
13 path = "/chat/rooms/{room_id}/bookmarks",
14 tag = "chat",
15 params(("room_id" = Uuid, Path, description = "room id")),
16 responses(
17 (status = 200, description = "bookmark list", body = AppResponseData<Vec<BookmarkDto>>),
18 (status = 404, description = "not found", body = AppResponseData<NoData>)
19 )
20)]
21pub async fn list_bookmarks(
22 State(service): State<ChatService>,
23 session: Session,
24 Path(room_id): Path<Uuid>,
25) -> Result<AppResponseData<Vec<BookmarkDto>>, ApiError> {
26 let uid = require_uid(&session)?;
27 let bookmarks = service.list_bookmarks(uid, room_id).await?;
28 Ok(AppResponseData::ok(bookmarks))
29}
30
31#[utoipa::path(
32 post,
33 path = "/chat/rooms/{room_id}/bookmarks",
34 tag = "chat",
35 params(("room_id" = Uuid, Path, description = "room id")),
36 request_body = CreateBookmarkCmd,
37 responses(
38 (status = 200, description = "bookmark created", body = AppResponseData<BookmarkDto>),
39 (status = 400, description = "bad request", body = AppResponseData<NoData>)
40 )
41)]
42pub async fn create_bookmark(
43 State(service): State<ChatService>,
44 session: Session,
45 Path(room_id): Path<Uuid>,
46 Json(cmd): Json<CreateBookmarkCmd>,
47) -> Result<AppResponseData<BookmarkDto>, ApiError> {
48 let uid = require_uid(&session)?;
49 let dto = service.create_bookmark(uid, room_id, cmd).await?;
50 Ok(AppResponseData::ok(dto))
51}
52
53#[utoipa::path(
54 patch,
55 path = "/chat/rooms/{room_id}/bookmarks/{bookmark_id}",
56 tag = "chat",
57 params(
58 ("room_id" = Uuid, Path, description = "room id"),
59 ("bookmark_id" = Uuid, Path, description = "bookmark id")
60 ),
61 request_body = UpdateBookmarkCmd,
62 responses(
63 (status = 200, description = "bookmark updated", body = AppResponseData<BookmarkDto>),
64 (status = 404, description = "not found", body = AppResponseData<NoData>)
65 )
66)]
67pub async fn update_bookmark(
68 State(service): State<ChatService>,
69 session: Session,
70 Path((room_id, bookmark_id)): Path<(Uuid, Uuid)>,
71 Json(cmd): Json<UpdateBookmarkCmd>,
72) -> Result<AppResponseData<BookmarkDto>, ApiError> {
73 let uid = require_uid(&session)?;
74 let dto = service.update_bookmark(uid, room_id, bookmark_id, cmd).await?;
75 Ok(AppResponseData::ok(dto))
76}
77
78#[utoipa::path(
79 delete,
80 path = "/chat/rooms/{room_id}/bookmarks/{bookmark_id}",
81 tag = "chat",
82 params(
83 ("room_id" = Uuid, Path, description = "room id"),
84 ("bookmark_id" = Uuid, Path, description = "bookmark id")
85 ),
86 responses(
87 (status = 200, description = "bookmark deleted", body = AppResponseData<NoData>),
88 (status = 404, description = "not found", body = AppResponseData<NoData>)
89 )
90)]
91pub async fn delete_bookmark(
92 State(service): State<ChatService>,
93 session: Session,
94 Path((room_id, bookmark_id)): Path<(Uuid, Uuid)>,
95) -> Result<AppResponseData<NoData>, ApiError> {
96 let uid = require_uid(&session)?;
97 service.delete_bookmark(uid, room_id, bookmark_id).await?;
98 Ok(AppResponseData::empty())
99}
Added lib/chat/src/handlers/dms.rs lib/chat/src/handlers/dms.rs +67 −0 View lib/chat/src/handlers/dms.rs
@@ -0,0 +1,67 @@
1use axum::extract::{Json, Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4use session::share::session::require_uid;
5use uuid::Uuid;
6
7use crate::errors::ApiError;
8use crate::services::dms::{DmDto, OpenDmCmd};
9use crate::services::service::ChatService;
10
11#[utoipa::path(
12 post,
13 path = "/chat/dms",
14 tag = "chat",
15 request_body = OpenDmCmd,
16 responses(
17 (status = 200, description = "dm opened", body = AppResponseData<DmDto>),
18 (status = 404, description = "not found", body = AppResponseData<NoData>)
19 )
20)]
21pub async fn open_dm(
22 State(service): State<ChatService>,
23 session: Session,
24 Json(cmd): Json<OpenDmCmd>,
25) -> Result<AppResponseData<DmDto>, ApiError> {
26 let uid = require_uid(&session)?;
27 let dto = service.open_dm(uid, cmd).await?;
28 Ok(AppResponseData::ok(dto))
29}
30
31#[utoipa::path(
32 get,
33 path = "/chat/dms",
34 tag = "chat",
35 responses(
36 (status = 200, description = "my dms", body = AppResponseData<Vec<DmDto>>),
37 (status = 401, description = "unauthorized", body = AppResponseData<NoData>)
38 )
39)]
40pub async fn list_dms(
41 State(service): State<ChatService>,
42 session: Session,
43) -> Result<AppResponseData<Vec<DmDto>>, ApiError> {
44 let uid = require_uid(&session)?;
45 let dms = service.list_dms(uid).await?;
46 Ok(AppResponseData::ok(dms))
47}
48
49#[utoipa::path(
50 delete,
51 path = "/chat/dms/{room_id}",
52 tag = "chat",
53 params(("room_id" = Uuid, Path, description = "dm room id")),
54 responses(
55 (status = 200, description = "dm closed", body = AppResponseData<NoData>),
56 (status = 404, description = "not found", body = AppResponseData<NoData>)
57 )
58)]
59pub async fn close_dm(
60 State(service): State<ChatService>,
61 session: Session,
62 Path(room_id): Path<Uuid>,
63) -> Result<AppResponseData<NoData>, ApiError> {
64 let uid = require_uid(&session)?;
65 service.close_dm(uid, room_id).await?;
66 Ok(AppResponseData::empty())
67}
Added lib/chat/src/handlers/drafts.rs lib/chat/src/handlers/drafts.rs +75 −0 View lib/chat/src/handlers/drafts.rs
@@ -0,0 +1,75 @@
1use axum::extract::{Json, Path, Query, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4use session::share::session::require_uid;
5use uuid::Uuid;
6
7use crate::errors::ApiError;
8use crate::services::drafts::{DraftDto, UpsertDraftCmd};
9use crate::services::service::ChatService;
10
11#[derive(Debug, Clone, serde::Deserialize, utoipa::IntoParams, utoipa::ToSchema)]
12pub struct DraftQuery {
13 pub thread_id: Option<Uuid>,
14}
15
16#[utoipa::path(
17 put,
18 path = "/chat/rooms/{room_id}/draft",
19 tag = "chat",
20 params(("room_id" = Uuid, Path, description = "room id")),
21 request_body = UpsertDraftCmd,
22 responses(
23 (status = 200, description = "draft saved", body = AppResponseData<DraftDto>),
24 (status = 400, description = "bad request", body = AppResponseData<NoData>)
25 )
26)]
27pub async fn upsert_draft(
28 State(service): State<ChatService>,
29 session: Session,
30 Path(room_id): Path<Uuid>,
31 Json(cmd): Json<UpsertDraftCmd>,
32) -> Result<AppResponseData<DraftDto>, ApiError> {
33 let uid = require_uid(&session)?;
34 let dto = service.upsert_draft(uid, room_id, cmd).await?;
35 Ok(AppResponseData::ok(dto))
36}
37
38#[utoipa::path(
39 get,
40 path = "/chat/rooms/{room_id}/draft",
41 tag = "chat",
42 params(("room_id" = Uuid, Path, description = "room id"), DraftQuery),
43 responses(
44 (status = 200, description = "draft", body = AppResponseData<Option<DraftDto>>),
45 (status = 404, description = "not found", body = AppResponseData<NoData>)
46 )
47)]
48pub async fn get_draft(
49 State(service): State<ChatService>,
50 session: Session,
51 Path(room_id): Path<Uuid>,
52 Query(q): Query<DraftQuery>,
53) -> Result<AppResponseData<Option<DraftDto>>, ApiError> {
54 let uid = require_uid(&session)?;
55 let dto = service.get_draft(uid, room_id, q.thread_id).await?;
56 Ok(AppResponseData::ok(dto))
57}
58
59#[utoipa::path(
60 get,
61 path = "/chat/drafts",
62 tag = "chat",
63 responses(
64 (status = 200, description = "my drafts", body = AppResponseData<Vec<DraftDto>>),
65 (status = 401, description = "unauthorized", body = AppResponseData<NoData>)
66 )
67)]
68pub async fn list_my_drafts(
69 State(service): State<ChatService>,
70 session: Session,
71) -> Result<AppResponseData<Vec<DraftDto>>, ApiError> {
72 let uid = require_uid(&session)?;
73 let drafts = service.list_my_drafts(uid).await?;
74 Ok(AppResponseData::ok(drafts))
75}
Added lib/chat/src/handlers/emojis.rs lib/chat/src/handlers/emojis.rs +102 −0 View lib/chat/src/handlers/emojis.rs
@@ -0,0 +1,102 @@
1use axum::extract::{Json, Path, State};
2use axum::http::StatusCode;
3use axum::response::{IntoResponse, Response};
4use export::rest::{AppResponseData, NoData};
5use session::Session;
6use session::share::session::require_uid;
7use uuid::Uuid;
8
9use crate::errors::ApiError;
10use crate::services::emojis::{EmojiDto, UploadEmojiCmd};
11use crate::services::service::ChatService;
12use crate::share::lookup::{lookup_space_by_name, require_space_member};
13
14#[utoipa::path(
15 get,
16 path = "/chat/spaces/{space_name}/emojis",
17 tag = "chat",
18 params(("space_name" = String, Path, description = "space name")),
19 responses(
20 (status = 200, description = "emoji list", body = AppResponseData<Vec<EmojiDto>>),
21 (status = 404, description = "not found", body = AppResponseData<NoData>)
22 )
23)]
24pub async fn list_emojis(
25 State(service): State<ChatService>,
26 session: Session,
27 Path(space_name): Path<String>,
28) -> Result<AppResponseData<Vec<EmojiDto>>, ApiError> {
29 let uid = require_uid(&session)?;
30 let emojis = service.list_emojis(uid, &space_name).await?;
31 Ok(AppResponseData::ok(emojis))
32}
33
34#[utoipa::path(
35 post,
36 path = "/chat/spaces/{space_name}/emojis",
37 tag = "chat",
38 params(("space_name" = String, Path, description = "space name")),
39 request_body = UploadEmojiCmd,
40 responses(
41 (status = 200, description = "emoji created", body = AppResponseData<EmojiDto>),
42 (status = 403, description = "forbidden", body = AppResponseData<NoData>)
43 )
44)]
45pub async fn upload_emoji(
46 State(service): State<ChatService>,
47 session: Session,
48 Path(space_name): Path<String>,
49 Json(cmd): Json<UploadEmojiCmd>,
50) -> Result<AppResponseData<EmojiDto>, ApiError> {
51 let uid = require_uid(&session)?;
52 let dto = service.upload_emoji(uid, &space_name, cmd).await?;
53 Ok(AppResponseData::ok(dto))
54}
55
56#[utoipa::path(
57 delete,
58 path = "/chat/spaces/{space_name}/emojis/{emoji_id}",
59 tag = "chat",
60 params(
61 ("space_name" = String, Path, description = "space name"),
62 ("emoji_id" = Uuid, Path, description = "emoji id")
63 ),
64 responses(
65 (status = 200, description = "emoji deleted", body = AppResponseData<NoData>),
66 (status = 404, description = "not found", body = AppResponseData<NoData>)
67 )
68)]
69pub async fn delete_emoji(
70 State(service): State<ChatService>,
71 session: Session,
72 Path((space_name, emoji_id)): Path<(String, Uuid)>,
73) -> Result<AppResponseData<NoData>, ApiError> {
74 let uid = require_uid(&session)?;
75 service.delete_emoji(uid, &space_name, emoji_id).await?;
76 Ok(AppResponseData::empty())
77}
78
79#[utoipa::path(
80 get,
81 path = "/chat/spaces/{space_name}/emojis/{emoji_id}/image",
82 tag = "chat",
83 params(
84 ("space_name" = String, Path, description = "space name"),
85 ("emoji_id" = Uuid, Path, description = "emoji id")
86 ),
87 responses(
88 (status = 200, description = "emoji image"),
89 (status = 404, description = "not found", body = AppResponseData<NoData>)
90 )
91)]
92pub async fn emoji_image(
93 State(service): State<ChatService>,
94 session: Session,
95 Path((space_name, emoji_id)): Path<(String, Uuid)>,
96) -> Result<Response, ApiError> {
97 let uid = require_uid(&session)?;
98 let space = lookup_space_by_name(&service, &space_name).await?;
99 require_space_member(&service, &space, uid).await?;
100 let bytes = service.emoji_image(&space, emoji_id).await?;
101 Ok((StatusCode::OK, bytes).into_response())
102}
Added lib/chat/src/handlers/messages.rs lib/chat/src/handlers/messages.rs +232 −0 View lib/chat/src/handlers/messages.rs
@@ -0,0 +1,232 @@
1use axum::extract::{Json, Path, Query, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4use session::share::session::require_uid;
5use uuid::Uuid;
6
7use crate::errors::ApiError;
8use crate::services::messages::{
9 BulkDeleteCmd, ForwardMessageCmd, ListMessageQuery, MessageDto, MessageEditDto, MessagePage,
10 MessagePath, PinCmd, SendMessageCmd, UpdateMessageCmd,
11};
12use crate::services::service::ChatService;
13
14#[utoipa::path(
15 post,
16 path = "/chat/rooms/{room_id}/messages",
17 tag = "chat",
18 params(("room_id" = Uuid, Path, description = "room id")),
19 request_body = SendMessageCmd,
20 responses(
21 (status = 200, description = "message sent", body = AppResponseData<MessageDto>),
22 (status = 400, description = "bad request", body = AppResponseData<NoData>),
23 (status = 403, description = "forbidden", body = AppResponseData<NoData>)
24 )
25)]
26pub async fn send_message(
27 State(service): State<ChatService>,
28 session: Session,
29 Path(room_id): Path<Uuid>,
30 Json(cmd): Json<SendMessageCmd>,
31) -> Result<AppResponseData<MessageDto>, ApiError> {
32 let uid = require_uid(&session)?;
33 let dto = service.send_message(uid, room_id, cmd).await?;
34 Ok(AppResponseData::ok(dto))
35}
36
37#[utoipa::path(
38 get,
39 path = "/chat/rooms/{room_id}/messages",
40 tag = "chat",
41 params(("room_id" = Uuid, Path, description = "room id"), ListMessageQuery),
42 responses(
43 (status = 200, description = "message page", body = AppResponseData<MessagePage>),
44 (status = 404, description = "not found", body = AppResponseData<NoData>)
45 )
46)]
47pub async fn list_messages(
48 State(service): State<ChatService>,
49 session: Session,
50 Path(room_id): Path<Uuid>,
51 Query(q): Query<ListMessageQuery>,
52) -> Result<AppResponseData<MessagePage>, ApiError> {
53 let uid = require_uid(&session)?;
54 let page = service.list_messages(uid, room_id, q).await?;
55 Ok(AppResponseData::ok(page))
56}
57
58#[utoipa::path(
59 get,
60 path = "/chat/rooms/{room_id}/messages/{message_id}",
61 tag = "chat",
62 params(MessagePath),
63 responses(
64 (status = 200, description = "message detail", body = AppResponseData<MessageDto>),
65 (status = 404, description = "not found", body = AppResponseData<NoData>)
66 )
67)]
68pub async fn get_message(
69 State(service): State<ChatService>,
70 session: Session,
71 Path(path): Path<MessagePath>,
72) -> Result<AppResponseData<MessageDto>, ApiError> {
73 let uid = require_uid(&session)?;
74 let dto = service.get_message(uid, path.room_id, path.message_id).await?;
75 Ok(AppResponseData::ok(dto))
76}
77
78#[utoipa::path(
79 patch,
80 path = "/chat/rooms/{room_id}/messages/{message_id}",
81 tag = "chat",
82 params(MessagePath),
83 request_body = UpdateMessageCmd,
84 responses(
85 (status = 200, description = "message updated", body = AppResponseData<MessageDto>),
86 (status = 403, description = "forbidden", body = AppResponseData<NoData>)
87 )
88)]
89pub async fn update_message(
90 State(service): State<ChatService>,
91 session: Session,
92 Path(path): Path<MessagePath>,
93 Json(cmd): Json<UpdateMessageCmd>,
94) -> Result<AppResponseData<MessageDto>, ApiError> {
95 let uid = require_uid(&session)?;
96 let dto = service
97 .update_message(uid, path.room_id, path.message_id, cmd)
98 .await?;
99 Ok(AppResponseData::ok(dto))
100}
101
102#[utoipa::path(
103 delete,
104 path = "/chat/rooms/{room_id}/messages/{message_id}",
105 tag = "chat",
106 params(MessagePath),
107 responses(
108 (status = 200, description = "message deleted", body = AppResponseData<NoData>),
109 (status = 403, description = "forbidden", body = AppResponseData<NoData>)
110 )
111)]
112pub async fn delete_message(
113 State(service): State<ChatService>,
114 session: Session,
115 Path(path): Path<MessagePath>,
116) -> Result<AppResponseData<NoData>, ApiError> {
117 let uid = require_uid(&session)?;
118 service.delete_message(uid, path.room_id, path.message_id).await?;
119 Ok(AppResponseData::empty())
120}
121
122#[utoipa::path(
123 post,
124 path = "/chat/rooms/{room_id}/messages/bulk-delete",
125 tag = "chat",
126 params(("room_id" = Uuid, Path, description = "room id")),
127 request_body = BulkDeleteCmd,
128 responses(
129 (status = 200, description = "messages deleted", body = AppResponseData<NoData>),
130 (status = 403, description = "forbidden", body = AppResponseData<NoData>)
131 )
132)]
133pub async fn bulk_delete_messages(
134 State(service): State<ChatService>,
135 session: Session,
136 Path(room_id): Path<Uuid>,
137 Json(cmd): Json<BulkDeleteCmd>,
138) -> Result<AppResponseData<NoData>, ApiError> {
139 let uid = require_uid(&session)?;
140 service.bulk_delete_messages(uid, room_id, cmd).await?;
141 Ok(AppResponseData::empty())
142}
143
144#[utoipa::path(
145 put,
146 path = "/chat/rooms/{room_id}/messages/{message_id}/pin",
147 tag = "chat",
148 params(MessagePath),
149 request_body = PinCmd,
150 responses(
151 (status = 200, description = "pin updated", body = AppResponseData<MessageDto>),
152 (status = 404, description = "not found", body = AppResponseData<NoData>)
153 )
154)]
155pub async fn set_message_pin(
156 State(service): State<ChatService>,
157 session: Session,
158 Path(path): Path<MessagePath>,
159 Json(cmd): Json<PinCmd>,
160) -> Result<AppResponseData<MessageDto>, ApiError> {
161 let uid = require_uid(&session)?;
162 let dto = service
163 .set_message_pin(uid, path.room_id, path.message_id, cmd)
164 .await?;
165 Ok(AppResponseData::ok(dto))
166}
167
168#[utoipa::path(
169 get,
170 path = "/chat/rooms/{room_id}/pins",
171 tag = "chat",
172 params(("room_id" = Uuid, Path, description = "room id")),
173 responses(
174 (status = 200, description = "pinned messages", body = AppResponseData<Vec<MessageDto>>),
175 (status = 404, description = "not found", body = AppResponseData<NoData>)
176 )
177)]
178pub async fn list_pins(
179 State(service): State<ChatService>,
180 session: Session,
181 Path(room_id): Path<Uuid>,
182) -> Result<AppResponseData<Vec<MessageDto>>, ApiError> {
183 let uid = require_uid(&session)?;
184 let pins = service.list_pins(uid, room_id).await?;
185 Ok(AppResponseData::ok(pins))
186}
187
188#[utoipa::path(
189 post,
190 path = "/chat/rooms/{room_id}/messages/{message_id}/forward",
191 tag = "chat",
192 params(MessagePath),
193 request_body = ForwardMessageCmd,
194 responses(
195 (status = 200, description = "message forwarded", body = AppResponseData<MessageDto>),
196 (status = 404, description = "not found", body = AppResponseData<NoData>)
197 )
198)]
199pub async fn forward_message(
200 State(service): State<ChatService>,
201 session: Session,
202 Path(path): Path<MessagePath>,
203 Json(cmd): Json<ForwardMessageCmd>,
204) -> Result<AppResponseData<MessageDto>, ApiError> {
205 let uid = require_uid(&session)?;
206 let dto = service
207 .forward_message(uid, path.room_id, path.message_id, cmd)
208 .await?;
209 Ok(AppResponseData::ok(dto))
210}
211
212#[utoipa::path(
213 get,
214 path = "/chat/rooms/{room_id}/messages/{message_id}/history",
215 tag = "chat",
216 params(MessagePath),
217 responses(
218 (status = 200, description = "edit history", body = AppResponseData<Vec<MessageEditDto>>),
219 (status = 404, description = "not found", body = AppResponseData<NoData>)
220 )
221)]
222pub async fn list_message_history(
223 State(service): State<ChatService>,
224 session: Session,
225 Path(path): Path<MessagePath>,
226) -> Result<AppResponseData<Vec<MessageEditDto>>, ApiError> {
227 let uid = require_uid(&session)?;
228 let history = service
229 .list_message_history(uid, path.room_id, path.message_id)
230 .await?;
231 Ok(AppResponseData::ok(history))
232}
Added lib/chat/src/handlers/mod.rs lib/chat/src/handlers/mod.rs +16 −0 View lib/chat/src/handlers/mod.rs
@@ -0,0 +1,16 @@
1pub mod attachments;
2pub mod bookmarks;
3pub mod drafts;
4pub mod dms;
5pub mod emojis;
6pub mod messages;
7pub mod presence;
8pub mod reactions;
9pub mod read_state;
10pub mod reminders;
11pub mod rooms;
12pub mod search;
13pub mod settings;
14pub mod threads;
15pub mod webhooks;
16pub mod ws;
Added lib/chat/src/handlers/presence.rs lib/chat/src/handlers/presence.rs +69 −0 View lib/chat/src/handlers/presence.rs
@@ -0,0 +1,69 @@
1use axum::extract::{Json, Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4use session::share::session::require_uid;
5use uuid::Uuid;
6
7use crate::errors::ApiError;
8use crate::services::presence::{PresenceDto, SetPresenceCmd};
9use crate::services::service::ChatService;
10
11#[utoipa::path(
12 put,
13 path = "/chat/presence",
14 tag = "chat",
15 request_body = SetPresenceCmd,
16 responses(
17 (status = 200, description = "presence updated", body = AppResponseData<PresenceDto>),
18 (status = 401, description = "unauthorized", body = AppResponseData<NoData>)
19 )
20)]
21pub async fn set_presence(
22 State(service): State<ChatService>,
23 session: Session,
24 Json(cmd): Json<SetPresenceCmd>,
25) -> Result<AppResponseData<PresenceDto>, ApiError> {
26 let uid = require_uid(&session)?;
27 let dto = service.set_presence(uid, cmd).await?;
28 Ok(AppResponseData::ok(dto))
29}
30
31#[utoipa::path(
32 get,
33 path = "/chat/users/{user_id}/presence",
34 tag = "chat",
35 params(("user_id" = Uuid, Path, description = "user id")),
36 responses(
37 (status = 200, description = "presence", body = AppResponseData<PresenceDto>),
38 (status = 401, description = "unauthorized", body = AppResponseData<NoData>)
39 )
40)]
41pub async fn get_presence(
42 State(service): State<ChatService>,
43 session: Session,
44 Path(user_id): Path<Uuid>,
45) -> Result<AppResponseData<PresenceDto>, ApiError> {
46 let _uid = require_uid(&session)?;
47 let dto = service.get_presence(user_id).await?;
48 Ok(AppResponseData::ok(dto))
49}
50
51#[utoipa::path(
52 get,
53 path = "/chat/spaces/{space_name}/presences",
54 tag = "chat",
55 params(("space_name" = String, Path, description = "space name")),
56 responses(
57 (status = 200, description = "space presences", body = AppResponseData<Vec<PresenceDto>>),
58 (status = 404, description = "not found", body = AppResponseData<NoData>)
59 )
60)]
61pub async fn list_space_presences(
62 State(service): State<ChatService>,
63 session: Session,
64 Path(space_name): Path<String>,
65) -> Result<AppResponseData<Vec<PresenceDto>>, ApiError> {
66 let uid = require_uid(&session)?;
67 let presences = service.list_space_presences(uid, &space_name).await?;
68 Ok(AppResponseData::ok(presences))
69}
Added lib/chat/src/handlers/reactions.rs lib/chat/src/handlers/reactions.rs +82 −0 View lib/chat/src/handlers/reactions.rs
@@ -0,0 +1,82 @@
1use axum::extract::{Json, Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4use session::share::session::require_uid;
5use uuid::Uuid;
6
7use crate::errors::ApiError;
8use crate::services::reactions::{ReactionAggDto, ReactionCmd, ReactionUserDto};
9use crate::services::service::ChatService;
10
11#[utoipa::path(
12 put,
13 path = "/chat/rooms/{room_id}/messages/{message_id}/reactions/@me",
14 tag = "chat",
15 params(
16 ("room_id" = Uuid, Path, description = "room id"),
17 ("message_id" = Uuid, Path, description = "message id")
18 ),
19 request_body = ReactionCmd,
20 responses(
21 (status = 200, description = "reaction added", body = AppResponseData<Vec<ReactionAggDto>>),
22 (status = 404, description = "not found", body = AppResponseData<NoData>)
23 )
24)]
25pub async fn add_reaction(
26 State(service): State<ChatService>,
27 session: Session,
28 Path((room_id, message_id)): Path<(Uuid, Uuid)>,
29 Json(cmd): Json<ReactionCmd>,
30) -> Result<AppResponseData<Vec<ReactionAggDto>>, ApiError> {
31 let uid = require_uid(&session)?;
32 let agg = service.add_reaction(uid, room_id, message_id, cmd).await?;
33 Ok(AppResponseData::ok(agg))
34}
35
36#[utoipa::path(
37 delete,
38 path = "/chat/rooms/{room_id}/messages/{message_id}/reactions/@me",
39 tag = "chat",
40 params(
41 ("room_id" = Uuid, Path, description = "room id"),
42 ("message_id" = Uuid, Path, description = "message id")
43 ),
44 request_body = ReactionCmd,
45 responses(
46 (status = 200, description = "reaction removed", body = AppResponseData<Vec<ReactionAggDto>>),
47 (status = 404, description = "not found", body = AppResponseData<NoData>)
48 )
49)]
50pub async fn remove_reaction(
51 State(service): State<ChatService>,
52 session: Session,
53 Path((room_id, message_id)): Path<(Uuid, Uuid)>,
54 Json(cmd): Json<ReactionCmd>,
55) -> Result<AppResponseData<Vec<ReactionAggDto>>, ApiError> {
56 let uid = require_uid(&session)?;
57 let agg = service.remove_reaction(uid, room_id, message_id, cmd).await?;
58 Ok(AppResponseData::ok(agg))
59}
60
61#[utoipa::path(
62 get,
63 path = "/chat/rooms/{room_id}/messages/{message_id}/reactions",
64 tag = "chat",
65 params(
66 ("room_id" = Uuid, Path, description = "room id"),
67 ("message_id" = Uuid, Path, description = "message id")
68 ),
69 responses(
70 (status = 200, description = "reaction users", body = AppResponseData<Vec<ReactionUserDto>>),
71 (status = 404, description = "not found", body = AppResponseData<NoData>)
72 )
73)]
74pub async fn list_reactions(
75 State(service): State<ChatService>,
76 session: Session,
77 Path((room_id, message_id)): Path<(Uuid, Uuid)>,
78) -> Result<AppResponseData<Vec<ReactionUserDto>>, ApiError> {
79 let uid = require_uid(&session)?;
80 let rows = service.list_message_reactions(uid, room_id, message_id).await?;
81 Ok(AppResponseData::ok(rows))
82}
Added lib/chat/src/handlers/read_state.rs lib/chat/src/handlers/read_state.rs +97 −0 View lib/chat/src/handlers/read_state.rs
@@ -0,0 +1,97 @@
1use axum::extract::{Json, Path, Query, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4use session::share::session::require_uid;
5use uuid::Uuid;
6
7use crate::errors::ApiError;
8use crate::services::read_state::{MarkReadCmd, ReadStateDto, SpaceReadStatePage};
9use crate::services::service::ChatService;
10
11#[utoipa::path(
12 put,
13 path = "/chat/rooms/{room_id}/read-state",
14 tag = "chat",
15 params(("room_id" = Uuid, Path, description = "room id")),
16 request_body = MarkReadCmd,
17 responses(
18 (status = 200, description = "read state updated", body = AppResponseData<ReadStateDto>),
19 (status = 404, description = "not found", body = AppResponseData<NoData>)
20 )
21)]
22pub async fn mark_read(
23 State(service): State<ChatService>,
24 session: Session,
25 Path(room_id): Path<Uuid>,
26 Json(cmd): Json<MarkReadCmd>,
27) -> Result<AppResponseData<ReadStateDto>, ApiError> {
28 let uid = require_uid(&session)?;
29 let dto = service.mark_read(uid, room_id, cmd).await?;
30 Ok(AppResponseData::ok(dto))
31}
32
33#[utoipa::path(
34 get,
35 path = "/chat/rooms/{room_id}/read-state",
36 tag = "chat",
37 params(("room_id" = Uuid, Path, description = "room id")),
38 responses(
39 (status = 200, description = "read state", body = AppResponseData<ReadStateDto>),
40 (status = 404, description = "not found", body = AppResponseData<NoData>)
41 )
42)]
43pub async fn get_read_state(
44 State(service): State<ChatService>,
45 session: Session,
46 Path(room_id): Path<Uuid>,
47) -> Result<AppResponseData<ReadStateDto>, ApiError> {
48 let uid = require_uid(&session)?;
49 let dto = service.get_read_state(uid, room_id).await?;
50 Ok(AppResponseData::ok(dto))
51}
52
53#[utoipa::path(
54 get,
55 path = "/chat/spaces/{space_name}/read-states",
56 tag = "chat",
57 params(("space_name" = String, Path, description = "space name")),
58 responses(
59 (status = 200, description = "space read states", body = AppResponseData<SpaceReadStatePage>),
60 (status = 404, description = "not found", body = AppResponseData<NoData>)
61 )
62)]
63pub async fn list_space_read_states(
64 State(service): State<ChatService>,
65 session: Session,
66 Path(space_name): Path<String>,
67) -> Result<AppResponseData<SpaceReadStatePage>, ApiError> {
68 let uid = require_uid(&session)?;
69 let page = service.list_space_read_states(uid, &space_name).await?;
70 Ok(AppResponseData::ok(page))
71}
72
73#[derive(Debug, Clone, serde::Deserialize, utoipa::IntoParams, utoipa::ToSchema)]
74pub struct MarkThreadReadQuery {
75 pub message_id: Option<Uuid>,
76}
77
78#[utoipa::path(
79 put,
80 path = "/chat/threads/{thread_id}/read-state",
81 tag = "chat",
82 params(("thread_id" = Uuid, Path, description = "thread id"), MarkThreadReadQuery),
83 responses(
84 (status = 200, description = "thread marked read", body = AppResponseData<NoData>),
85 (status = 404, description = "not found", body = AppResponseData<NoData>)
86 )
87)]
88pub async fn mark_thread_read(
89 State(service): State<ChatService>,
90 session: Session,
91 Path(thread_id): Path<Uuid>,
92 Query(q): Query<MarkThreadReadQuery>,
93) -> Result<AppResponseData<NoData>, ApiError> {
94 let uid = require_uid(&session)?;
95 service.mark_thread_read(uid, thread_id, q.message_id).await?;
96 Ok(AppResponseData::empty())
97}
Added lib/chat/src/handlers/reminders.rs lib/chat/src/handlers/reminders.rs +72 −0 View lib/chat/src/handlers/reminders.rs
@@ -0,0 +1,72 @@
1use axum::extract::{Json, Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4use session::share::session::require_uid;
5use uuid::Uuid;
6
7use crate::errors::ApiError;
8use crate::services::reminders::{CreateReminderCmd, ReminderDto};
9use crate::services::service::ChatService;
10
11#[utoipa::path(
12 post,
13 path = "/chat/rooms/{room_id}/messages/{message_id}/reminders",
14 tag = "chat",
15 params(
16 ("room_id" = Uuid, Path, description = "room id"),
17 ("message_id" = Uuid, Path, description = "message id")
18 ),
19 request_body = CreateReminderCmd,
20 responses(
21 (status = 200, description = "reminder created", body = AppResponseData<ReminderDto>),
22 (status = 404, description = "not found", body = AppResponseData<NoData>)
23 )
24)]
25pub async fn create_reminder(
26 State(service): State<ChatService>,
27 session: Session,
28 Path((room_id, message_id)): Path<(Uuid, Uuid)>,
29 Json(cmd): Json<CreateReminderCmd>,
30) -> Result<AppResponseData<ReminderDto>, ApiError> {
31 let uid = require_uid(&session)?;
32 let dto = service.create_reminder(uid, room_id, message_id, cmd).await?;
33 Ok(AppResponseData::ok(dto))
34}
35
36#[utoipa::path(
37 get,
38 path = "/chat/reminders",
39 tag = "chat",
40 responses(
41 (status = 200, description = "pending reminders", body = AppResponseData<Vec<ReminderDto>>),
42 (status = 401, description = "unauthorized", body = AppResponseData<NoData>)
43 )
44)]
45pub async fn list_reminders(
46 State(service): State<ChatService>,
47 session: Session,
48) -> Result<AppResponseData<Vec<ReminderDto>>, ApiError> {
49 let uid = require_uid(&session)?;
50 let reminders = service.list_reminders(uid).await?;
51 Ok(AppResponseData::ok(reminders))
52}
53
54#[utoipa::path(
55 delete,
56 path = "/chat/reminders/{reminder_id}",
57 tag = "chat",
58 params(("reminder_id" = Uuid, Path, description = "reminder id")),
59 responses(
60 (status = 200, description = "reminder cancelled", body = AppResponseData<NoData>),
61 (status = 404, description = "not found", body = AppResponseData<NoData>)
62 )
63)]
64pub async fn cancel_reminder(
65 State(service): State<ChatService>,
66 session: Session,
67 Path(reminder_id): Path<Uuid>,
68) -> Result<AppResponseData<NoData>, ApiError> {
69 let uid = require_uid(&session)?;
70 service.cancel_reminder(uid, reminder_id).await?;
71 Ok(AppResponseData::empty())
72}
Added lib/chat/src/handlers/rooms.rs lib/chat/src/handlers/rooms.rs +200 −0 View lib/chat/src/handlers/rooms.rs
@@ -0,0 +1,200 @@
1use axum::extract::{Json, Path, Query, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4use session::share::session::require_uid;
5use uuid::Uuid;
6
7use crate::errors::ApiError;
8use crate::services::rooms::{
9 AddMembersCmd, CreateRoomInviteCmd, RoomBriefDto, RoomInviteDto, RoomMemberDto,
10 RoomMemberQuery, UpdateRoomCmd,
11};
12use crate::services::service::ChatService;
13
14#[utoipa::path(
15 get,
16 path = "/chat/spaces/{space_name}/rooms",
17 tag = "chat",
18 params(("space_name" = String, Path, description = "space name")),
19 responses(
20 (status = 200, description = "list rooms", body = AppResponseData<Vec<RoomBriefDto>>),
21 (status = 401, description = "unauthorized", body = AppResponseData<NoData>),
22 (status = 404, description = "not found", body = AppResponseData<NoData>)
23 )
24)]
25pub async fn list_space_rooms(
26 State(service): State<ChatService>,
27 session: Session,
28 Path(space_name): Path<String>,
29) -> Result<AppResponseData<Vec<RoomBriefDto>>, ApiError> {
30 let uid = require_uid(&session)?;
31 let rooms = service.list_space_rooms(uid, &space_name).await?;
32 Ok(AppResponseData::ok(rooms))
33}
34
35#[utoipa::path(
36 get,
37 path = "/chat/rooms/{room_id}",
38 tag = "chat",
39 params(("room_id" = Uuid, Path, description = "room id")),
40 responses(
41 (status = 200, description = "room detail", body = AppResponseData<RoomBriefDto>),
42 (status = 404, description = "not found", body = AppResponseData<NoData>)
43 )
44)]
45pub async fn get_room(
46 State(service): State<ChatService>,
47 session: Session,
48 Path(room_id): Path<Uuid>,
49) -> Result<AppResponseData<RoomBriefDto>, ApiError> {
50 let uid = require_uid(&session)?;
51 let room = service.get_room(uid, room_id).await?;
52 Ok(AppResponseData::ok(room))
53}
54
55#[utoipa::path(
56 patch,
57 path = "/chat/rooms/{room_id}",
58 tag = "chat",
59 params(("room_id" = Uuid, Path, description = "room id")),
60 request_body = UpdateRoomCmd,
61 responses(
62 (status = 200, description = "room updated", body = AppResponseData<RoomBriefDto>),
63 (status = 403, description = "forbidden", body = AppResponseData<NoData>)
64 )
65)]
66pub async fn update_room(
67 State(service): State<ChatService>,
68 session: Session,
69 Path(room_id): Path<Uuid>,
70 Json(cmd): Json<UpdateRoomCmd>,
71) -> Result<AppResponseData<RoomBriefDto>, ApiError> {
72 let uid = require_uid(&session)?;
73 let room = service.update_room(uid, room_id, cmd).await?;
74 Ok(AppResponseData::ok(room))
75}
76
77#[utoipa::path(
78 put,
79 path = "/chat/rooms/{room_id}/members/@me",
80 tag = "chat",
81 params(("room_id" = Uuid, Path, description = "room id")),
82 responses(
83 (status = 200, description = "joined", body = AppResponseData<NoData>),
84 (status = 403, description = "forbidden", body = AppResponseData<NoData>)
85 )
86)]
87pub async fn join_room(
88 State(service): State<ChatService>,
89 session: Session,
90 Path(room_id): Path<Uuid>,
91) -> Result<AppResponseData<NoData>, ApiError> {
92 let uid = require_uid(&session)?;
93 service.join_room(uid, room_id).await?;
94 Ok(AppResponseData::empty())
95}
96
97#[utoipa::path(
98 delete,
99 path = "/chat/rooms/{room_id}/members/@me",
100 tag = "chat",
101 params(("room_id" = Uuid, Path, description = "room id")),
102 responses(
103 (status = 200, description = "left", body = AppResponseData<NoData>),
104 (status = 403, description = "forbidden", body = AppResponseData<NoData>)
105 )
106)]
107pub async fn leave_room(
108 State(service): State<ChatService>,
109 session: Session,
110 Path(room_id): Path<Uuid>,
111) -> Result<AppResponseData<NoData>, ApiError> {
112 let uid = require_uid(&session)?;
113 service.leave_room(uid, room_id).await?;
114 Ok(AppResponseData::empty())
115}
116
117#[utoipa::path(
118 post,
119 path = "/chat/rooms/{room_id}/members",
120 tag = "chat",
121 params(("room_id" = Uuid, Path, description = "room id")),
122 request_body = AddMembersCmd,
123 responses(
124 (status = 200, description = "members added", body = AppResponseData<NoData>),
125 (status = 403, description = "forbidden", body = AppResponseData<NoData>)
126 )
127)]
128pub async fn add_room_members(
129 State(service): State<ChatService>,
130 session: Session,
131 Path(room_id): Path<Uuid>,
132 Json(cmd): Json<AddMembersCmd>,
133) -> Result<AppResponseData<NoData>, ApiError> {
134 let uid = require_uid(&session)?;
135 service.add_room_members(uid, room_id, cmd).await?;
136 Ok(AppResponseData::empty())
137}
138
139#[utoipa::path(
140 get,
141 path = "/chat/rooms/{room_id}/members",
142 tag = "chat",
143 params(("room_id" = Uuid, Path, description = "room id"), RoomMemberQuery),
144 responses(
145 (status = 200, description = "member list", body = AppResponseData<Vec<RoomMemberDto>>),
146 (status = 404, description = "not found", body = AppResponseData<NoData>)
147 )
148)]
149pub async fn list_room_members(
150 State(service): State<ChatService>,
151 session: Session,
152 Path(room_id): Path<Uuid>,
153 Query(q): Query<RoomMemberQuery>,
154) -> Result<AppResponseData<Vec<RoomMemberDto>>, ApiError> {
155 let uid = require_uid(&session)?;
156 let members = service.list_room_members(uid, room_id, q).await?;
157 Ok(AppResponseData::ok(members))
158}
159
160#[utoipa::path(
161 post,
162 path = "/chat/rooms/{room_id}/invites",
163 tag = "chat",
164 params(("room_id" = Uuid, Path, description = "room id")),
165 request_body = CreateRoomInviteCmd,
166 responses(
167 (status = 200, description = "invite created", body = AppResponseData<RoomInviteDto>),
168 (status = 403, description = "forbidden", body = AppResponseData<NoData>)
169 )
170)]
171pub async fn create_room_invite(
172 State(service): State<ChatService>,
173 session: Session,
174 Path(room_id): Path<Uuid>,
175 Json(cmd): Json<CreateRoomInviteCmd>,
176) -> Result<AppResponseData<RoomInviteDto>, ApiError> {
177 let uid = require_uid(&session)?;
178 let invite = service.create_room_invite(uid, room_id, cmd).await?;
179 Ok(AppResponseData::ok(invite))
180}
181
182#[utoipa::path(
183 post,
184 path = "/chat/invites/{accept_code}/accept",
185 tag = "chat",
186 params(("accept_code" = String, Path, description = "invite accept code")),
187 responses(
188 (status = 200, description = "joined via invite", body = AppResponseData<RoomBriefDto>),
189 (status = 404, description = "not found", body = AppResponseData<NoData>)
190 )
191)]
192pub async fn accept_room_invite(
193 State(service): State<ChatService>,
194 session: Session,
195 Path(accept_code): Path<String>,
196) -> Result<AppResponseData<RoomBriefDto>, ApiError> {
197 let uid = require_uid(&session)?;
198 let room = service.accept_room_invite(uid, &accept_code).await?;
199 Ok(AppResponseData::ok(room))
200}
Added lib/chat/src/handlers/search.rs lib/chat/src/handlers/search.rs +29 −0 View lib/chat/src/handlers/search.rs
@@ -0,0 +1,29 @@
1use axum::extract::{Path, Query, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4use session::share::session::require_uid;
5
6use crate::errors::ApiError;
7use crate::services::search::{SearchPage, SearchQuery};
8use crate::services::service::ChatService;
9
10#[utoipa::path(
11 get,
12 path = "/chat/spaces/{space_name}/messages/search",
13 tag = "chat",
14 params(("space_name" = String, Path, description = "space name"), SearchQuery),
15 responses(
16 (status = 200, description = "search results", body = AppResponseData<SearchPage>),
17 (status = 404, description = "not found", body = AppResponseData<NoData>)
18 )
19)]
20pub async fn search_messages(
21 State(service): State<ChatService>,
22 session: Session,
23 Path(space_name): Path<String>,
24 Query(q): Query<SearchQuery>,
25) -> Result<AppResponseData<SearchPage>, ApiError> {
26 let uid = require_uid(&session)?;
27 let page = service.search_messages(uid, &space_name, q).await?;
28 Ok(AppResponseData::ok(page))
29}
Added lib/chat/src/handlers/settings.rs lib/chat/src/handlers/settings.rs +51 −0 View lib/chat/src/handlers/settings.rs
@@ -0,0 +1,51 @@
1use axum::extract::{Json, Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4use session::share::session::require_uid;
5use uuid::Uuid;
6
7use crate::errors::ApiError;
8use crate::services::service::ChatService;
9use crate::services::settings::{RoomSettingDto, UpdateRoomSettingCmd};
10
11#[utoipa::path(
12 get,
13 path = "/chat/rooms/{room_id}/settings",
14 tag = "chat",
15 params(("room_id" = Uuid, Path, description = "room id")),
16 responses(
17 (status = 200, description = "room setting", body = AppResponseData<RoomSettingDto>),
18 (status = 404, description = "not found", body = AppResponseData<NoData>)
19 )
20)]
21pub async fn get_room_setting(
22 State(service): State<ChatService>,
23 session: Session,
24 Path(room_id): Path<Uuid>,
25) -> Result<AppResponseData<RoomSettingDto>, ApiError> {
26 let uid = require_uid(&session)?;
27 let dto = service.get_room_setting(uid, room_id).await?;
28 Ok(AppResponseData::ok(dto))
29}
30
31#[utoipa::path(
32 put,
33 path = "/chat/rooms/{room_id}/settings",
34 tag = "chat",
35 params(("room_id" = Uuid, Path, description = "room id")),
36 request_body = UpdateRoomSettingCmd,
37 responses(
38 (status = 200, description = "room setting updated", body = AppResponseData<RoomSettingDto>),
39 (status = 404, description = "not found", body = AppResponseData<NoData>)
40 )
41)]
42pub async fn update_room_setting(
43 State(service): State<ChatService>,
44 session: Session,
45 Path(room_id): Path<Uuid>,
46 Json(cmd): Json<UpdateRoomSettingCmd>,
47) -> Result<AppResponseData<RoomSettingDto>, ApiError> {
48 let uid = require_uid(&session)?;
49 let dto = service.update_room_setting(uid, room_id, cmd).await?;
50 Ok(AppResponseData::ok(dto))
51}
Added lib/chat/src/handlers/threads.rs lib/chat/src/handlers/threads.rs +159 −0 View lib/chat/src/handlers/threads.rs
@@ -0,0 +1,159 @@
1use axum::extract::{Json, Path, Query, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4use session::share::session::require_uid;
5use uuid::Uuid;
6
7use crate::errors::ApiError;
8use crate::services::service::ChatService;
9use crate::services::threads::{
10 CreateThreadCmd, ListThreadQuery, ThreadDto, ThreadMemberCmd, ThreadPage, UpdateThreadCmd,
11};
12
13#[utoipa::path(
14 post,
15 path = "/chat/rooms/{room_id}/messages/{message_id}/threads",
16 tag = "chat",
17 params(
18 ("room_id" = Uuid, Path, description = "room id"),
19 ("message_id" = Uuid, Path, description = "root message id")
20 ),
21 request_body = CreateThreadCmd,
22 responses(
23 (status = 200, description = "thread created", body = AppResponseData<ThreadDto>),
24 (status = 404, description = "not found", body = AppResponseData<NoData>)
25 )
26)]
27pub async fn create_thread(
28 State(service): State<ChatService>,
29 session: Session,
30 Path((room_id, message_id)): Path<(Uuid, Uuid)>,
31 Json(cmd): Json<CreateThreadCmd>,
32) -> Result<AppResponseData<ThreadDto>, ApiError> {
33 let uid = require_uid(&session)?;
34 let dto = service.create_thread(uid, room_id, message_id, cmd).await?;
35 Ok(AppResponseData::ok(dto))
36}
37
38#[utoipa::path(
39 get,
40 path = "/chat/rooms/{room_id}/threads",
41 tag = "chat",
42 params(("room_id" = Uuid, Path, description = "room id"), ListThreadQuery),
43 responses(
44 (status = 200, description = "thread page", body = AppResponseData<ThreadPage>),
45 (status = 404, description = "not found", body = AppResponseData<NoData>)
46 )
47)]
48pub async fn list_threads(
49 State(service): State<ChatService>,
50 session: Session,
51 Path(room_id): Path<Uuid>,
52 Query(q): Query<ListThreadQuery>,
53) -> Result<AppResponseData<ThreadPage>, ApiError> {
54 let uid = require_uid(&session)?;
55 let page = service.list_threads(uid, room_id, q).await?;
56 Ok(AppResponseData::ok(page))
57}
58
59#[utoipa::path(
60 get,
61 path = "/chat/threads",
62 tag = "chat",
63 responses(
64 (status = 200, description = "my threads", body = AppResponseData<Vec<ThreadDto>>),
65 (status = 401, description = "unauthorized", body = AppResponseData<NoData>)
66 )
67)]
68pub async fn list_my_threads(
69 State(service): State<ChatService>,
70 session: Session,
71) -> Result<AppResponseData<Vec<ThreadDto>>, ApiError> {
72 let uid = require_uid(&session)?;
73 let threads = service.list_my_threads(uid).await?;
74 Ok(AppResponseData::ok(threads))
75}
76
77#[utoipa::path(
78 get,
79 path = "/chat/threads/{thread_id}",
80 tag = "chat",
81 params(("thread_id" = Uuid, Path, description = "thread id")),
82 responses(
83 (status = 200, description = "thread detail", body = AppResponseData<ThreadDto>),
84 (status = 404, description = "not found", body = AppResponseData<NoData>)
85 )
86)]
87pub async fn get_thread(
88 State(service): State<ChatService>,
89 session: Session,
90 Path(thread_id): Path<Uuid>,
91) -> Result<AppResponseData<ThreadDto>, ApiError> {
92 let uid = require_uid(&session)?;
93 let dto = service.get_thread(uid, thread_id).await?;
94 Ok(AppResponseData::ok(dto))
95}
96
97#[utoipa::path(
98 patch,
99 path = "/chat/threads/{thread_id}",
100 tag = "chat",
101 params(("thread_id" = Uuid, Path, description = "thread id")),
102 request_body = UpdateThreadCmd,
103 responses(
104 (status = 200, description = "thread updated", body = AppResponseData<ThreadDto>),
105 (status = 403, description = "forbidden", body = AppResponseData<NoData>)
106 )
107)]
108pub async fn update_thread(
109 State(service): State<ChatService>,
110 session: Session,
111 Path(thread_id): Path<Uuid>,
112 Json(cmd): Json<UpdateThreadCmd>,
113) -> Result<AppResponseData<ThreadDto>, ApiError> {
114 let uid = require_uid(&session)?;
115 let dto = service.update_thread(uid, thread_id, cmd).await?;
116 Ok(AppResponseData::ok(dto))
117}
118
119#[utoipa::path(
120 put,
121 path = "/chat/threads/{thread_id}/members/@me",
122 tag = "chat",
123 params(("thread_id" = Uuid, Path, description = "thread id")),
124 request_body = ThreadMemberCmd,
125 responses(
126 (status = 200, description = "followed", body = AppResponseData<NoData>),
127 (status = 404, description = "not found", body = AppResponseData<NoData>)
128 )
129)]
130pub async fn follow_thread(
131 State(service): State<ChatService>,
132 session: Session,
133 Path(thread_id): Path<Uuid>,
134 Json(cmd): Json<ThreadMemberCmd>,
135) -> Result<AppResponseData<NoData>, ApiError> {
136 let uid = require_uid(&session)?;
137 service.set_thread_membership(uid, thread_id, cmd).await?;
138 Ok(AppResponseData::empty())
139}
140
141#[utoipa::path(
142 delete,
143 path = "/chat/threads/{thread_id}/members/@me",
144 tag = "chat",
145 params(("thread_id" = Uuid, Path, description = "thread id")),
146 responses(
147 (status = 200, description = "unfollowed", body = AppResponseData<NoData>),
148 (status = 404, description = "not found", body = AppResponseData<NoData>)
149 )
150)]
151pub async fn unfollow_thread(
152 State(service): State<ChatService>,
153 session: Session,
154 Path(thread_id): Path<Uuid>,
155) -> Result<AppResponseData<NoData>, ApiError> {
156 let uid = require_uid(&session)?;
157 service.leave_thread(uid, thread_id).await?;
158 Ok(AppResponseData::empty())
159}
Added lib/chat/src/handlers/webhooks.rs lib/chat/src/handlers/webhooks.rs +96 −0 View lib/chat/src/handlers/webhooks.rs
@@ -0,0 +1,96 @@
1use axum::extract::{Json, Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4use session::share::session::require_uid;
5use uuid::Uuid;
6
7use crate::errors::ApiError;
8use crate::services::messages::MessageDto;
9use crate::services::service::ChatService;
10use crate::services::webhooks::{CreateWebhookCmd, WebhookDto, WebhookPostCmd, WebhookSecretDto};
11
12#[utoipa::path(
13 post,
14 path = "/chat/rooms/{room_id}/webhooks",
15 tag = "chat",
16 params(("room_id" = Uuid, Path, description = "room id")),
17 request_body = CreateWebhookCmd,
18 responses(
19 (status = 200, description = "webhook created", body = AppResponseData<WebhookSecretDto>),
20 (status = 403, description = "forbidden", body = AppResponseData<NoData>)
21 )
22)]
23pub async fn create_webhook(
24 State(service): State<ChatService>,
25 session: Session,
26 Path(room_id): Path<Uuid>,
27 Json(cmd): Json<CreateWebhookCmd>,
28) -> Result<AppResponseData<WebhookSecretDto>, ApiError> {
29 let uid = require_uid(&session)?;
30 let dto = service.create_webhook(uid, room_id, cmd).await?;
31 Ok(AppResponseData::ok(dto))
32}
33
34#[utoipa::path(
35 get,
36 path = "/chat/rooms/{room_id}/webhooks",
37 tag = "chat",
38 params(("room_id" = Uuid, Path, description = "room id")),
39 responses(
40 (status = 200, description = "webhook list", body = AppResponseData<Vec<WebhookDto>>),
41 (status = 403, description = "forbidden", body = AppResponseData<NoData>)
42 )
43)]
44pub async fn list_webhooks(
45 State(service): State<ChatService>,
46 session: Session,
47 Path(room_id): Path<Uuid>,
48) -> Result<AppResponseData<Vec<WebhookDto>>, ApiError> {
49 let uid = require_uid(&session)?;
50 let rows = service.list_webhooks(uid, room_id).await?;
51 Ok(AppResponseData::ok(rows))
52}
53
54#[utoipa::path(
55 delete,
56 path = "/chat/rooms/{room_id}/webhooks/{webhook_id}",
57 tag = "chat",
58 params(
59 ("room_id" = Uuid, Path, description = "room id"),
60 ("webhook_id" = Uuid, Path, description = "webhook id")
61 ),
62 responses(
63 (status = 200, description = "webhook deleted", body = AppResponseData<NoData>),
64 (status = 404, description = "not found", body = AppResponseData<NoData>)
65 )
66)]
67pub async fn delete_webhook(
68 State(service): State<ChatService>,
69 session: Session,
70 Path((room_id, webhook_id)): Path<(Uuid, Uuid)>,
71) -> Result<AppResponseData<NoData>, ApiError> {
72 let uid = require_uid(&session)?;
73 service.delete_webhook(uid, room_id, webhook_id).await?;
74 Ok(AppResponseData::empty())
75}
76
77#[utoipa::path(
78 post,
79 path = "/chat/hooks/{token}",
80 tag = "chat",
81 params(("token" = String, Path, description = "webhook token")),
82 request_body = WebhookPostCmd,
83 responses(
84 (status = 200, description = "message posted", body = AppResponseData<MessageDto>),
85 (status = 401, description = "invalid token", body = AppResponseData<NoData>)
86 )
87)]
88pub async fn post_via_webhook(
89 State(service): State<ChatService>,
90 Path(token): Path<String>,
91 Json(cmd): Json<WebhookPostCmd>,
92) -> Result<AppResponseData<MessageDto>, ApiError> {
93 let value = service.post_via_webhook(&token, cmd).await?;
94 let dto: MessageDto = serde_json::from_value(value)?;
95 Ok(AppResponseData::ok(dto))
96}
Added lib/chat/src/handlers/ws.rs lib/chat/src/handlers/ws.rs +39 −0 View lib/chat/src/handlers/ws.rs
@@ -0,0 +1,39 @@
1use axum::extract::State;
2use export::rest::{AppResponseData, NoData};
3use serde::Serialize;
4use session::Session;
5use session::share::session::require_uid;
6use utoipa::ToSchema;
7
8use crate::errors::ApiError;
9use crate::services::service::ChatService;
10use crate::ws::ticket::issue_ticket;
11
12#[derive(Debug, Clone, Serialize, ToSchema)]
13pub struct WsTicketDto {
14 pub ticket: String,
15 pub ttl_secs: u64,
16}
17
18#[utoipa::path(
19 post,
20 path = "/chat/ws/ticket",
21 tag = "chat",
22 responses(
23 (status = 200, description = "ws ticket issued", body = AppResponseData<WsTicketDto>),
24 (status = 401, description = "unauthorized", body = AppResponseData<NoData>)
25 )
26)]
27pub async fn issue_ws_ticket(
28 State(service): State<ChatService>,
29 session: Session,
30) -> Result<AppResponseData<WsTicketDto>, ApiError> {
31 let uid = require_uid(&session)?;
32 let ttl = service.config().chat.ws.ticket_ttl_secs;
33 let secret = service.ticket_secret();
34 let ticket = issue_ticket(&secret, uid, ttl)?;
35 Ok(AppResponseData::ok(WsTicketDto {
36 ticket,
37 ttl_secs: ttl,
38 }))
39}
Added lib/chat/src/services/attachments.rs lib/chat/src/services/attachments.rs +245 −0 View lib/chat/src/services/attachments.rs
@@ -0,0 +1,245 @@
1use axum::body::Body;
2use axum::http::header;
3use models::chat::attachments::{self, AttachmentStatus};
4use sea_orm::{ActiveModelTrait, EntityTrait, Set};
5use serde::{Deserialize, Serialize};
6use time::OffsetDateTime;
7use utoipa::ToSchema;
8use uuid::Uuid;
9
10use crate::errors::ChatError;
11use crate::services::service::ChatService;
12use crate::share::lookup::{is_room_member, lookup_room};
13
14pub const ATTACHMENT_PREFIX: &str = "chat/attachments";
15
16#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
17pub struct AttachmentDto {
18 pub id: Uuid,
19 pub user_id: Uuid,
20 pub room_id: Option<Uuid>,
21 pub message_id: Option<Uuid>,
22 pub filename: String,
23 pub description: Option<String>,
24 pub content_type: String,
25 pub size: i64,
26 pub status: AttachmentStatus,
27 pub width: Option<i32>,
28 pub height: Option<i32>,
29 pub duration_secs: Option<i32>,
30 pub created_at: OffsetDateTime,
31}
32
33impl From<attachments::Model> for AttachmentDto {
34 fn from(a: attachments::Model) -> Self {
35 Self {
36 id: a.id,
37 user_id: a.user_id,
38 room_id: a.room_id,
39 message_id: a.message_id,
40 filename: a.filename,
41 description: a.description,
42 content_type: a.content_type,
43 size: a.size,
44 status: a.status,
45 width: a.width,
46 height: a.height,
47 duration_secs: a.duration_secs,
48 created_at: a.created_at,
49 }
50 }
51}
52
53#[derive(Debug, Clone, Deserialize, ToSchema)]
54pub struct CreateAttachmentCmd {
55 pub filename: String,
56 pub content_type: String,
57 pub size: i64,
58 #[serde(default)]
59 pub description: Option<String>,
60}
61
62#[derive(Debug, Clone, Serialize, ToSchema)]
63pub struct UploadIntentDto {
64 pub id: Uuid,
65 pub upload_url: String,
66 pub expires_secs: u64,
67}
68
69#[derive(Debug, Clone, Deserialize, ToSchema)]
70pub struct FinalizeAttachmentCmd {
71 #[serde(default)]
72 pub width: Option<i32>,
73 #[serde(default)]
74 pub height: Option<i32>,
75 #[serde(default)]
76 pub duration_secs: Option<i32>,
77}
78
79fn sanitize_filename(name: &str) -> String {
80 let base = name.rsplit(['/', '\\']).next().unwrap_or("file");
81 let cleaned: String = base
82 .chars()
83 .map(|c| match c {
84 'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' | '.' => c,
85 _ => '_',
86 })
87 .collect();
88 if cleaned.is_empty() || cleaned.chars().all(|c| c == '.') {
89 "file".to_string()
90 } else {
91 cleaned
92 }
93}
94
95impl ChatService {
96 pub async fn create_attachment(
97 &self,
98 caller: Uuid,
99 cmd: CreateAttachmentCmd,
100 ) -> Result<UploadIntentDto, ChatError> {
101 let max_size = self.config().chat.upload.max_size;
102 if cmd.size <= 0 || cmd.size > max_size {
103 return Err(ChatError::BadRequest(format!(
104 "file size must be within 1..{max_size} bytes"
105 )));
106 }
107 if cmd.filename.trim().is_empty() || cmd.filename.chars().count() > 255 {
108 return Err(ChatError::BadRequest("invalid filename".to_string()));
109 }
110
111 let now = OffsetDateTime::now_utc();
112 let id = Uuid::now_v7();
113 let filename = sanitize_filename(&cmd.filename);
114 let key = format!("{ATTACHMENT_PREFIX}/{caller}/{id}/{filename}");
115 let bucket = self.storage().client().bucket_name().to_string();
116
117 let row = attachments::ActiveModel {
118 id: Set(id),
119 user_id: Set(caller),
120 room_id: Set(None),
121 message_id: Set(None),
122 filename: Set(filename.clone()),
123 description: Set(cmd.description.clone()),
124 content_type: Set(cmd.content_type.clone()),
125 size: Set(cmd.size),
126 bucket: Set(bucket),
127 s3_key: Set(key.clone()),
128 status: Set(AttachmentStatus::Pending),
129 width: Set(None),
130 height: Set(None),
131 duration_secs: Set(None),
132 variants: Set(serde_json::json!([])),
133 created_at: Set(now),
134 updated_at: Set(now),
135 }
136 .insert(self.db().write())
137 .await?;
138
139 let ttl = self.config().chat.upload.presign_ttl_secs.min(604_800);
140 let upload_url = self
141 .storage()
142 .client()
143 .presign_put(&key, ttl as u32)
144 .map_err(ChatError::from)?;
145
146 let _ = row;
147 Ok(UploadIntentDto {
148 id,
149 upload_url,
150 expires_secs: ttl,
151 })
152 }
153
154 pub async fn finalize_attachment(
155 &self,
156 caller: Uuid,
157 attachment_id: Uuid,
158 cmd: FinalizeAttachmentCmd,
159 ) -> Result<AttachmentDto, ChatError> {
160 let row = attachments::Entity::find_by_id(attachment_id)
161 .one(self.db().write())
162 .await?
163 .filter(|a| a.user_id == caller)
164 .ok_or(ChatError::NotFound)?;
165 if row.status != AttachmentStatus::Pending {
166 return Err(ChatError::Conflict("attachment already finalized".to_string()));
167 }
168 let exists = self.storage().client().exists(&row.s3_key).await?;
169 if !exists {
170 return Err(ChatError::BadRequest(
171 "object not uploaded yet".to_string(),
172 ));
173 }
174 let now = OffsetDateTime::now_utc();
175 let mut am: attachments::ActiveModel = row.into();
176 am.status = Set(AttachmentStatus::Uploaded);
177 am.width = Set(cmd.width);
178 am.height = Set(cmd.height);
179 am.duration_secs = Set(cmd.duration_secs);
180 am.updated_at = Set(now);
181 let updated = am.update(self.db().write()).await?;
182 Ok(AttachmentDto::from(updated))
183 }
184
185 pub async fn get_attachment_meta(
186 &self,
187 caller: Uuid,
188 attachment_id: Uuid,
189 ) -> Result<AttachmentDto, ChatError> {
190 let row = attachments::Entity::find_by_id(attachment_id)
191 .one(self.db().read())
192 .await?
193 .ok_or(ChatError::NotFound)?;
194 self.check_attachment_access(caller, &row).await?;
195 Ok(AttachmentDto::from(row))
196 }
197
198 pub async fn download_attachment(
199 &self,
200 caller: Uuid,
201 attachment_id: Uuid,
202 ) -> Result<(attachments::Model, Body), ChatError> {
203 let row = attachments::Entity::find_by_id(attachment_id)
204 .one(self.db().read())
205 .await?
206 .ok_or(ChatError::NotFound)?;
207 self.check_attachment_access(caller, &row).await?;
208 if row.status == AttachmentStatus::Pending {
209 return Err(ChatError::NotFound);
210 }
211 let stream = self.storage().client().get_stream(&row.s3_key).await?;
212 let stream = futures::StreamExt::map(stream, |item| {
213 item.map_err(|e| std::io::Error::other(e.to_string()))
214 });
215 let body = Body::from_stream(stream);
216 Ok((row, body))
217 }
218
219 async fn check_attachment_access(
220 &self,
221 caller: Uuid,
222 row: &attachments::Model,
223 ) -> Result<(), ChatError> {
224 if row.user_id == caller {
225 return Ok(());
226 }
227 if let Some(room_id) = row.room_id
228 && lookup_room(self, room_id).await.is_ok()
229 && is_room_member(self, room_id, caller).await?
230 {
231 return Ok(());
232 }
233 Err(ChatError::Forbidden)
234 }
235
236 pub fn attachment_content_headers(row: &attachments::Model) -> [(header::HeaderName, String); 2] {
237 [
238 (header::CONTENT_TYPE, row.content_type.clone()),
239 (
240 header::CONTENT_DISPOSITION,
241 format!("inline; filename=\"{}\"", row.filename.replace('"', "_")),
242 ),
243 ]
244 }
245}
Added lib/chat/src/services/bookmarks.rs lib/chat/src/services/bookmarks.rs +178 −0 View lib/chat/src/services/bookmarks.rs
@@ -0,0 +1,178 @@
1use models::chat::room_bookmarks;
2use sea_orm::sea_query::Expr;
3use sea_orm::{
4 ActiveModelTrait, ColumnTrait, EntityTrait, ExprTrait, QueryFilter, QueryOrder, QuerySelect,
5 Set,
6};
7use serde::{Deserialize, Serialize};
8use time::OffsetDateTime;
9use utoipa::ToSchema;
10use uuid::Uuid;
11
12use crate::errors::ChatError;
13use crate::services::service::ChatService;
14use crate::share::lookup::require_room_readable;
15
16#[derive(Debug, Clone, Serialize, ToSchema)]
17pub struct BookmarkDto {
18 pub id: Uuid,
19 pub room_id: Uuid,
20 pub title: String,
21 pub url: Option<String>,
22 pub emoji: Option<String>,
23 pub position: i64,
24 pub created_by: Uuid,
25 pub created_at: OffsetDateTime,
26}
27
28#[derive(Debug, Clone, Deserialize, ToSchema)]
29pub struct CreateBookmarkCmd {
30 pub title: String,
31 #[serde(default)]
32 pub url: Option<String>,
33 #[serde(default)]
34 pub emoji: Option<String>,
35}
36
37#[derive(Debug, Clone, Deserialize, ToSchema)]
38pub struct UpdateBookmarkCmd {
39 #[serde(default)]
40 pub title: Option<String>,
41 #[serde(default)]
42 pub url: Option<String>,
43 #[serde(default)]
44 pub emoji: Option<String>,
45 #[serde(default)]
46 pub position: Option<i64>,
47}
48
49impl From<room_bookmarks::Model> for BookmarkDto {
50 fn from(b: room_bookmarks::Model) -> Self {
51 Self {
52 id: b.id,
53 room_id: b.room_id,
54 title: b.title,
55 url: b.url,
56 emoji: b.emoji,
57 position: b.position,
58 created_by: b.user_id,
59 created_at: b.created_at,
60 }
61 }
62}
63
64impl ChatService {
65 pub async fn list_bookmarks(
66 &self,
67 caller: Uuid,
68 room_id: Uuid,
69 ) -> Result<Vec<BookmarkDto>, ChatError> {
70 let _ctx = require_room_readable(self, room_id, caller).await?;
71 let rows = room_bookmarks::Entity::find()
72 .filter(room_bookmarks::Column::RoomId.eq(room_id))
73 .order_by_asc(room_bookmarks::Column::Position)
74 .order_by_asc(room_bookmarks::Column::CreatedAt)
75 .all(self.db().read())
76 .await?;
77 Ok(rows.into_iter().map(BookmarkDto::from).collect())
78 }
79
80 pub async fn create_bookmark(
81 &self,
82 caller: Uuid,
83 room_id: Uuid,
84 cmd: CreateBookmarkCmd,
85 ) -> Result<BookmarkDto, ChatError> {
86 let _ctx = require_room_readable(self, room_id, caller).await?;
87 let title = cmd.title.trim().to_string();
88 if title.is_empty() || title.chars().count() > self.config().chat.max_bookmark_title_len {
89 return Err(ChatError::BadRequest("invalid bookmark title".to_string()));
90 }
91 if cmd.url.is_none() && cmd.emoji.is_none() {
92 return Err(ChatError::BadRequest(
93 "bookmark requires url or emoji".to_string(),
94 ));
95 }
96
97 let max_pos: i64 = room_bookmarks::Entity::find()
98 .filter(room_bookmarks::Column::RoomId.eq(room_id))
99 .select_only()
100 .column_as(Expr::col(room_bookmarks::Column::Position).max(), "max_pos")
101 .into_tuple::<Option<i64>>()
102 .one(self.db().read())
103 .await?
104 .flatten()
105 .unwrap_or(-1);
106
107 let now = OffsetDateTime::now_utc();
108 let row = room_bookmarks::ActiveModel {
109 id: Set(Uuid::now_v7()),
110 room_id: Set(room_id),
111 user_id: Set(caller),
112 title: Set(title),
113 url: Set(cmd.url),
114 emoji: Set(cmd.emoji),
115 position: Set(max_pos + 1),
116 created_at: Set(now),
117 updated_at: Set(now),
118 }
119 .insert(self.db().write())
120 .await?;
121 Ok(BookmarkDto::from(row))
122 }
123
124 pub async fn update_bookmark(
125 &self,
126 caller: Uuid,
127 room_id: Uuid,
128 bookmark_id: Uuid,
129 cmd: UpdateBookmarkCmd,
130 ) -> Result<BookmarkDto, ChatError> {
131 let _ctx = require_room_readable(self, room_id, caller).await?;
132 let row = room_bookmarks::Entity::find_by_id(bookmark_id)
133 .filter(room_bookmarks::Column::RoomId.eq(room_id))
134 .one(self.db().write())
135 .await?
136 .ok_or(ChatError::NotFound)?;
137 let mut am: room_bookmarks::ActiveModel = row.into();
138 if let Some(title) = cmd.title {
139 let title = title.trim().to_string();
140 if title.is_empty() || title.chars().count() > self.config().chat.max_bookmark_title_len
141 {
142 return Err(ChatError::BadRequest("invalid bookmark title".to_string()));
143 }
144 am.title = Set(title);
145 }
146 if let Some(url) = cmd.url {
147 am.url = Set(Some(url));
148 }
149 if let Some(emoji) = cmd.emoji {
150 am.emoji = Set(Some(emoji));
151 }
152 if let Some(position) = cmd.position {
153 am.position = Set(position);
154 }
155 am.updated_at = Set(OffsetDateTime::now_utc());
156 let updated = am.update(self.db().write()).await?;
157 Ok(BookmarkDto::from(updated))
158 }
159
160 pub async fn delete_bookmark(
161 &self,
162 caller: Uuid,
163 room_id: Uuid,
164 bookmark_id: Uuid,
165 ) -> Result<(), ChatError> {
166 let _ctx = require_room_readable(self, room_id, caller).await?;
167 let deleted = room_bookmarks::Entity::delete_many()
168 .filter(room_bookmarks::Column::Id.eq(bookmark_id))
169 .filter(room_bookmarks::Column::RoomId.eq(room_id))
170 .exec(self.db().write())
171 .await?;
172 if deleted.rows_affected == 0 {
173 return Err(ChatError::NotFound);
174 }
175 let _ = caller;
176 Ok(())
177 }
178}
Added lib/chat/src/services/dms.rs lib/chat/src/services/dms.rs +220 −0 View lib/chat/src/services/dms.rs
@@ -0,0 +1,220 @@
1use models::chat::read_states;
2use models::chat::room_seq;
3use models::room::room_members;
4use models::room::rooms::{self, RoomKind};
5use models::users::user;
6use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
7use serde::{Deserialize, Serialize};
8use space::services::spaces::UserBriefDto;
9use time::OffsetDateTime;
10use utoipa::ToSchema;
11use uuid::Uuid;
12
13use crate::errors::ChatError;
14use crate::services::service::ChatService;
15use crate::share::realtime::RoomEvent;
16
17#[derive(Debug, Clone, Serialize, ToSchema)]
18pub struct DmDto {
19 pub id: Uuid,
20 pub kind: RoomKind,
21 pub name: String,
22 pub peer: Option<UserBriefDto>,
23 pub last_message_id: Option<Uuid>,
24 pub last_read_seq: i64,
25 pub tip_seq: i64,
26 pub unread_count: i64,
27 pub created_at: OffsetDateTime,
28}
29
30#[derive(Debug, Clone, Deserialize, ToSchema)]
31pub struct OpenDmCmd {
32 pub peer_user_id: Uuid,
33}
34
35fn dm_pair_key(a: Uuid, b: Uuid) -> String {
36 let (lo, hi) = if a < b { (a, b) } else { (b, a) };
37 format!("dm:{lo}:{hi}")
38}
39
40impl ChatService {
41 pub async fn open_dm(&self, caller: Uuid, cmd: OpenDmCmd) -> Result<DmDto, ChatError> {
42 if cmd.peer_user_id == caller {
43 return Err(ChatError::BadRequest("cannot open DM with self".to_string()));
44 }
45 let peer = user::Entity::find_by_id(cmd.peer_user_id)
46 .one(self.db().read())
47 .await?
48 .filter(|u| u.is_active)
49 .ok_or(ChatError::NotFound)?;
50
51 let pair = dm_pair_key(caller, peer.uid);
52 let existing = rooms::Entity::find()
53 .filter(rooms::Column::LowerName.eq(&pair))
54 .filter(rooms::Column::Kind.eq(RoomKind::DirectMessage))
55 .one(self.db().read())
56 .await?;
57
58 let room = match existing {
59 Some(room) if room.is_active => room,
60 Some(mut room) => {
61 room.is_active = true;
62 let mut am: rooms::ActiveModel = room.clone().into();
63 am.is_active = Set(true);
64 am.updated_at = Set(OffsetDateTime::now_utc());
65 am.update(self.db().write()).await?;
66 room
67 }
68 None => {
69 let now = OffsetDateTime::now_utc();
70 let room_id = Uuid::now_v7();
71 rooms::ActiveModel {
72 id: Set(room_id),
73 space_id: Set(None),
74 kind: Set(RoomKind::DirectMessage),
75 lower_name: Set(pair.clone()),
76 name: Set(peer.username.clone()),
77 topic: Set(None),
78 position: Set(0),
79 parent_id: Set(None),
80 owner_id: Set(caller),
81 is_private: Set(true),
82 nsfw: Set(false),
83 user_limit: Set(Some(2)),
84 rate_limit_per_user: Set(None),
85 bitrate: Set(None),
86 last_message_id: Set(None),
87 is_active: Set(true),
88 created_at: Set(now),
89 updated_at: Set(now),
90 }
91 .insert(self.db().write())
92 .await?
93 }
94 };
95
96 let now = OffsetDateTime::now_utc();
97 for uid in [caller, peer.uid] {
98 let existing_member = room_members::Entity::find_by_id((room.id, uid))
99 .one(self.db().write())
100 .await?;
101 match existing_member {
102 Some(row) if row.left_at.is_some() => {
103 let mut am: room_members::ActiveModel = row.into();
104 am.left_at = Set(None);
105 am.updated_at = Set(Some(now));
106 am.update(self.db().write()).await?;
107 }
108 Some(_) => {}
109 None => {
110 room_members::ActiveModel {
111 room_id: Set(room.id),
112 user_id: Set(uid),
113 nick: Set(None),
114 joined_at: Set(now),
115 left_at: Set(None),
116 created_at: Set(now),
117 updated_at: Set(Some(now)),
118 }
119 .insert(self.db().write())
120 .await?;
121 }
122 }
123 }
124
125 self.build_dm_dto(caller, room, Some(peer.into())).await
126 }
127
128 pub async fn list_dms(&self, caller: Uuid) -> Result<Vec<DmDto>, ChatError> {
129 let memberships = room_members::Entity::find()
130 .filter(room_members::Column::UserId.eq(caller))
131 .filter(room_members::Column::LeftAt.is_null())
132 .all(self.db().read())
133 .await?;
134 let room_ids: Vec<Uuid> = memberships.into_iter().map(|m| m.room_id).collect();
135 if room_ids.is_empty() {
136 return Ok(Vec::new());
137 }
138 let rooms = rooms::Entity::find()
139 .filter(rooms::Column::Id.is_in(room_ids))
140 .filter(rooms::Column::IsActive.eq(true))
141 .filter(rooms::Column::SpaceId.is_null())
142 .all(self.db().read())
143 .await?;
144
145 let mut result = Vec::new();
146 for room in rooms {
147 let peers = room_members::Entity::find()
148 .filter(room_members::Column::RoomId.eq(room.id))
149 .filter(room_members::Column::UserId.ne(caller))
150 .filter(room_members::Column::LeftAt.is_null())
151 .one(self.db().read())
152 .await?;
153 let peer_brief = match peers {
154 Some(peer) => user::Entity::find_by_id(peer.user_id)
155 .one(self.db().read())
156 .await?
157 .map(UserBriefDto::from),
158 None => None,
159 };
160 result.push(self.build_dm_dto(caller, room, peer_brief).await?);
161 }
162 result.sort_by_key(|a| std::cmp::Reverse(a.last_message_id));
163 Ok(result)
164 }
165
166 async fn build_dm_dto(
167 &self,
168 caller: Uuid,
169 room: rooms::Model,
170 peer: Option<UserBriefDto>,
171 ) -> Result<DmDto, ChatError> {
172 let tip = room_seq::Entity::find_by_id(room.id)
173 .one(self.db().read())
174 .await?
175 .map(|s| s.seq)
176 .unwrap_or(0);
177 let last_read = read_states::Entity::find_by_id((caller, room.id))
178 .one(self.db().read())
179 .await?
180 .map(|r| r.last_read_seq)
181 .unwrap_or(0);
182 Ok(DmDto {
183 id: room.id,
184 kind: room.kind,
185 name: room.name,
186 peer,
187 last_message_id: room.last_message_id,
188 last_read_seq: last_read,
189 tip_seq: tip,
190 unread_count: (tip - last_read).max(0),
191 created_at: room.created_at,
192 })
193 }
194
195 pub async fn close_dm(&self, caller: Uuid, room_id: Uuid) -> Result<(), ChatError> {
196 let room = rooms::Entity::find_by_id(room_id)
197 .one(self.db().read())
198 .await?
199 .filter(|r| r.is_active && r.space_id.is_none())
200 .ok_or(ChatError::NotFound)?;
201 let member = room_members::Entity::find_by_id((room_id, caller))
202 .one(self.db().write())
203 .await?
204 .ok_or(ChatError::NotFound)?;
205 if member.left_at.is_none() {
206 let now = OffsetDateTime::now_utc();
207 let mut am: room_members::ActiveModel = member.into();
208 am.left_at = Set(Some(now));
209 am.updated_at = Set(Some(now));
210 am.update(self.db().write()).await?;
211 let event = RoomEvent::MemberLeft {
212 room_id,
213 user_id: caller,
214 };
215 self.realtime.publish_room(&event).await;
216 }
217 let _ = room;
218 Ok(())
219 }
220}
Added lib/chat/src/services/drafts.rs lib/chat/src/services/drafts.rs +134 −0 View lib/chat/src/services/drafts.rs
@@ -0,0 +1,134 @@
1use models::chat::user_drafts;
2use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
3use serde::{Deserialize, Serialize};
4use time::OffsetDateTime;
5use utoipa::ToSchema;
6use uuid::Uuid;
7
8use crate::errors::ChatError;
9use crate::services::service::ChatService;
10use crate::share::lookup::require_room_readable;
11
12#[derive(Debug, Clone, Serialize, ToSchema)]
13pub struct DraftDto {
14 pub room_id: Uuid,
15 pub thread_id: Option<Uuid>,
16 pub content: String,
17 pub updated_at: OffsetDateTime,
18}
19
20#[derive(Debug, Clone, Deserialize, ToSchema)]
21pub struct UpsertDraftCmd {
22 #[serde(default)]
23 pub thread_id: Option<Uuid>,
24 pub content: String,
25}
26
27impl ChatService {
28 pub async fn upsert_draft(
29 &self,
30 caller: Uuid,
31 room_id: Uuid,
32 cmd: UpsertDraftCmd,
33 ) -> Result<DraftDto, ChatError> {
34 let _ctx = require_room_readable(self, room_id, caller).await?;
35 if cmd.content.chars().count() > self.config().chat.max_message_len {
36 return Err(ChatError::BadRequest("draft too long".to_string()));
37 }
38 let now = OffsetDateTime::now_utc();
39
40 let mut existing: Option<user_drafts::Model> = None;
41 let candidates = user_drafts::Entity::find()
42 .filter(user_drafts::Column::UserId.eq(caller))
43 .filter(user_drafts::Column::RoomId.eq(room_id))
44 .all(self.db().write())
45 .await?;
46 for row in candidates {
47 if row.thread_id == cmd.thread_id {
48 existing = Some(row);
49 break;
50 }
51 }
52
53 match existing {
54 Some(row) => {
55 if cmd.content.is_empty() {
56 user_drafts::Entity::delete_by_id(row.id)
57 .exec(self.db().write())
58 .await?;
59 return Ok(DraftDto {
60 room_id,
61 thread_id: cmd.thread_id,
62 content: String::new(),
63 updated_at: now,
64 });
65 }
66 let mut am: user_drafts::ActiveModel = row.into();
67 am.content = Set(cmd.content.clone());
68 am.updated_at = Set(now);
69 am.update(self.db().write()).await?;
70 }
71 None => {
72 if !cmd.content.is_empty() {
73 user_drafts::ActiveModel {
74 id: Set(Uuid::now_v7()),
75 user_id: Set(caller),
76 room_id: Set(room_id),
77 thread_id: Set(cmd.thread_id),
78 content: Set(cmd.content.clone()),
79 created_at: Set(now),
80 updated_at: Set(now),
81 }
82 .insert(self.db().write())
83 .await?;
84 }
85 }
86 }
87
88 Ok(DraftDto {
89 room_id,
90 thread_id: cmd.thread_id,
91 content: cmd.content,
92 updated_at: now,
93 })
94 }
95
96 pub async fn get_draft(
97 &self,
98 caller: Uuid,
99 room_id: Uuid,
100 thread_id: Option<Uuid>,
101 ) -> Result<Option<DraftDto>, ChatError> {
102 let _ctx = require_room_readable(self, room_id, caller).await?;
103 let rows = user_drafts::Entity::find()
104 .filter(user_drafts::Column::UserId.eq(caller))
105 .filter(user_drafts::Column::RoomId.eq(room_id))
106 .all(self.db().read())
107 .await?;
108 Ok(rows
109 .into_iter()
110 .find(|r| r.thread_id == thread_id)
111 .map(|r| DraftDto {
112 room_id,
113 thread_id: r.thread_id,
114 content: r.content,
115 updated_at: r.updated_at,
116 }))
117 }
118
119 pub async fn list_my_drafts(&self, caller: Uuid) -> Result<Vec<DraftDto>, ChatError> {
120 let rows = user_drafts::Entity::find()
121 .filter(user_drafts::Column::UserId.eq(caller))
122 .all(self.db().read())
123 .await?;
124 Ok(rows
125 .into_iter()
126 .map(|r| DraftDto {
127 room_id: r.room_id,
128 thread_id: r.thread_id,
129 content: r.content,
130 updated_at: r.updated_at,
131 })
132 .collect())
133 }
134}
Added lib/chat/src/services/emojis.rs lib/chat/src/services/emojis.rs +185 −0 View lib/chat/src/services/emojis.rs
@@ -0,0 +1,185 @@
1use models::chat::emojis;
2use models::space::spaces;
3use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set};
4use serde::{Deserialize, Serialize};
5use time::OffsetDateTime;
6use utoipa::ToSchema;
7use uuid::Uuid;
8
9use crate::errors::ChatError;
10use crate::services::service::ChatService;
11use crate::share::lookup::{lookup_space_by_name, require_space_member};
12
13pub const EMOJI_PREFIX: &str = "chat/emojis";
14
15#[derive(Debug, Clone, Serialize, ToSchema)]
16pub struct EmojiDto {
17 pub id: Uuid,
18 pub space_id: Uuid,
19 pub name: String,
20 pub animated: bool,
21 pub disabled: bool,
22 pub created_at: OffsetDateTime,
23}
24
25#[derive(Debug, Clone, Deserialize, ToSchema)]
26pub struct UploadEmojiCmd {
27 pub name: String,
28 pub content_type: String,
29 pub data_base64: String,
30 #[serde(default)]
31 pub animated: bool,
32}
33
34const MAX_EMOJI_BYTES: usize = 512 * 1024;
35
36impl ChatService {
37 pub async fn list_emojis(
38 &self,
39 caller: Uuid,
40 space_name: &str,
41 ) -> Result<Vec<EmojiDto>, ChatError> {
42 let space = lookup_space_by_name(self, space_name).await?;
43 require_space_member(self, &space, caller).await?;
44 let rows = emojis::Entity::find()
45 .filter(emojis::Column::SpaceId.eq(space.id))
46 .order_by_asc(emojis::Column::LowerName)
47 .all(self.db().read())
48 .await?;
49 Ok(rows
50 .into_iter()
51 .map(|e| EmojiDto {
52 id: e.id,
53 space_id: e.space_id,
54 name: e.name,
55 animated: e.animated,
56 disabled: e.disabled,
57 created_at: e.created_at,
58 })
59 .collect())
60 }
61
62 pub async fn upload_emoji(
63 &self,
64 caller: Uuid,
65 space_name: &str,
66 cmd: UploadEmojiCmd,
67 ) -> Result<EmojiDto, ChatError> {
68 let space = lookup_space_by_name(self, space_name).await?;
69 require_space_member(self, &space, caller).await?;
70 if space.owner_id != caller {
71 return Err(ChatError::Forbidden);
72 }
73
74 let name = cmd.name.trim().to_string();
75 let lower = name.to_lowercase();
76 if name.is_empty()
77 || name.chars().count() > self.config().chat.max_emoji_name_len
78 || !lower
79 .chars()
80 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
81 {
82 return Err(ChatError::BadRequest("invalid emoji name".to_string()));
83 }
84 if !matches!(
85 cmd.content_type.as_str(),
86 "image/png" | "image/jpeg" | "image/gif" | "image/webp"
87 ) {
88 return Err(ChatError::BadRequest("unsupported emoji content_type".to_string()));
89 }
90 let bytes = base64::Engine::decode(
91 &base64::engine::general_purpose::STANDARD,
92 cmd.data_base64.as_bytes(),
93 )
94 .map_err(|e| ChatError::BadRequest(format!("invalid base64 payload: {e}")))?;
95 if bytes.is_empty() || bytes.len() > MAX_EMOJI_BYTES {
96 return Err(ChatError::BadRequest("emoji payload size invalid".to_string()));
97 }
98
99 let duplicate = emojis::Entity::find()
100 .filter(emojis::Column::SpaceId.eq(space.id))
101 .filter(emojis::Column::LowerName.eq(&lower))
102 .one(self.db().read())
103 .await?;
104 if duplicate.is_some() {
105 return Err(ChatError::Conflict("emoji name already exists".to_string()));
106 }
107
108 let now = OffsetDateTime::now_utc();
109 let id = Uuid::now_v7();
110 let ext = match cmd.content_type.as_str() {
111 "image/png" => "png",
112 "image/jpeg" => "jpg",
113 "image/gif" => "gif",
114 _ => "webp",
115 };
116 let key = format!("{EMOJI_PREFIX}/{}/{id}.{ext}", space.id);
117 self.storage()
118 .client()
119 .put_with_content_type(&key, &bytes, &cmd.content_type)
120 .await
121 .map_err(ChatError::from)?;
122
123 let row = emojis::ActiveModel {
124 id: Set(id),
125 space_id: Set(space.id),
126 name: Set(name),
127 lower_name: Set(lower),
128 user_id: Set(caller),
129 bucket: Set(self.storage().client().bucket_name().to_string()),
130 s3_key: Set(key),
131 animated: Set(cmd.animated),
132 disabled: Set(false),
133 created_at: Set(now),
134 }
135 .insert(self.db().write())
136 .await?;
137
138 Ok(EmojiDto {
139 id: row.id,
140 space_id: row.space_id,
141 name: row.name,
142 animated: row.animated,
143 disabled: row.disabled,
144 created_at: row.created_at,
145 })
146 }
147
148 pub async fn delete_emoji(
149 &self,
150 caller: Uuid,
151 space_name: &str,
152 emoji_id: Uuid,
153 ) -> Result<(), ChatError> {
154 let space = lookup_space_by_name(self, space_name).await?;
155 require_space_member(self, &space, caller).await?;
156 if space.owner_id != caller {
157 return Err(ChatError::Forbidden);
158 }
159 let row = emojis::Entity::find_by_id(emoji_id)
160 .filter(emojis::Column::SpaceId.eq(space.id))
161 .one(self.db().write())
162 .await?
163 .ok_or(ChatError::NotFound)?;
164 emojis::Entity::delete_by_id(row.id)
165 .exec(self.db().write())
166 .await?;
167 if let Err(error) = self.storage().client().delete(&row.s3_key).await {
168 tracing::warn!(%error, "failed to delete emoji object");
169 }
170 Ok(())
171 }
172
173 pub(crate) async fn emoji_image(
174 &self,
175 space: &spaces::Model,
176 emoji_id: Uuid,
177 ) -> Result<Vec<u8>, ChatError> {
178 let row = emojis::Entity::find_by_id(emoji_id)
179 .filter(emojis::Column::SpaceId.eq(space.id))
180 .one(self.db().read())
181 .await?
182 .ok_or(ChatError::NotFound)?;
183 Ok(self.storage().client().get(&row.s3_key).await?)
184 }
185}
Added lib/chat/src/services/messages.rs lib/chat/src/services/messages.rs +935 −0 View lib/chat/src/services/messages.rs
@@ -0,0 +1,935 @@
1use models::chat::attachments::{self as attachment_rows};
2use models::chat::message_edits;
3use models::chat::messages::{self, AuthorType, ChatMessageType};
4use models::chat::reactions;
5use models::chat::threads;
6use models::room::rooms;
7use models::users::user_notifications::NotificationType;
8use sea_orm::sea_query::Expr;
9use sea_orm::{
10 ActiveModelTrait, ColumnTrait, Condition, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder,
11 QuerySelect, Set, TransactionTrait,
12};
13use serde::{Deserialize, Serialize};
14use serde_json::json;
15use space::services::spaces::UserBriefDto;
16use time::OffsetDateTime;
17use utoipa::{IntoParams, ToSchema};
18use uuid::Uuid;
19
20use crate::errors::ChatError;
21use crate::services::attachments::AttachmentDto;
22use crate::services::reactions::ReactionAggDto;
23use crate::services::service::ChatService;
24use crate::services::threads::maintain_thread_on_reply;
25use crate::share::authz::can_manage_space;
26use crate::share::lookup::{RoomContext, require_room_readable};
27use crate::share::mention::parse_mentions;
28use crate::share::notify::{
29 advance_read_state, next_room_seq, notify_user, publish_message_event, record_mentions,
30};
31use crate::share::realtime::RoomEvent;
32
33#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
34pub struct MessageDto {
35 pub id: Uuid,
36 pub room_id: Uuid,
37 pub author: Option<UserBriefDto>,
38 pub author_id: Uuid,
39 pub author_type: AuthorType,
40 pub webhook_id: Option<Uuid>,
41 pub thread_id: Option<Uuid>,
42 pub thread_broadcast: bool,
43 pub reply_to_id: Option<Uuid>,
44 pub forwarded_from_id: Option<Uuid>,
45 pub message_type: ChatMessageType,
46 pub content: String,
47 pub mentions: Vec<Uuid>,
48 pub mention_everyone: bool,
49 pub attachments: Vec<AttachmentDto>,
50 pub flags: i64,
51 pub pinned: bool,
52 pub pinned_by: Option<Uuid>,
53 pub pinned_at: Option<OffsetDateTime>,
54 pub edited_at: Option<OffsetDateTime>,
55 pub deleted_at: Option<OffsetDateTime>,
56 pub seq: i64,
57 pub created_at: OffsetDateTime,
58 #[serde(default)]
59 pub reactions: Vec<ReactionAggDto>,
60}
61
62#[derive(Debug, Clone, Serialize, ToSchema)]
63pub struct MessagePage {
64 pub items: Vec<MessageDto>,
65 pub oldest_seq: Option<i64>,
66 pub newest_seq: Option<i64>,
67 pub has_more: bool,
68}
69
70#[derive(Debug, Clone, Deserialize, ToSchema)]
71pub struct SendMessageCmd {
72 #[serde(default)]
73 pub content: String,
74 #[serde(default)]
75 pub reply_to_id: Option<Uuid>,
76 #[serde(default)]
77 pub thread_id: Option<Uuid>,
78 #[serde(default)]
79 pub thread_broadcast: bool,
80 #[serde(default)]
81 pub attachments: Vec<Uuid>,
82 #[serde(default)]
83 pub nonce: Option<String>,
84 #[serde(default)]
85 pub urgent: bool,
86}
87
88#[derive(Debug, Clone, Deserialize, ToSchema)]
89pub struct UpdateMessageCmd {
90 pub content: String,
91}
92
93#[derive(Debug, Clone, Deserialize, ToSchema)]
94pub struct BulkDeleteCmd {
95 pub message_ids: Vec<Uuid>,
96}
97
98#[derive(Debug, Clone, Deserialize, ToSchema)]
99pub struct PinCmd {
100 pub pinned: bool,
101}
102
103#[derive(Debug, Clone, Deserialize, ToSchema)]
104pub struct ForwardMessageCmd {
105 pub target_room_id: Uuid,
106 #[serde(default)]
107 pub comment: Option<String>,
108}
109
110#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
111pub struct ListMessageQuery {
112 pub before_seq: Option<i64>,
113 pub after_seq: Option<i64>,
114 pub limit: Option<u64>,
115 pub thread_id: Option<Uuid>,
116 pub author_id: Option<Uuid>,
117}
118
119#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
120pub struct MessagePath {
121 pub room_id: Uuid,
122 pub message_id: Uuid,
123}
124
125#[derive(Debug, Clone, Serialize, ToSchema)]
126pub struct MessageEditDto {
127 pub id: Uuid,
128 pub message_id: Uuid,
129 pub editor: Option<UserBriefDto>,
130 pub content: String,
131 pub created_at: OffsetDateTime,
132}
133
134const MAX_BULK_DELETE: usize = 100;
135
136impl ChatService {
137 pub(crate) async fn build_message_dto(
138 &self,
139 rows: Vec<messages::Model>,
140 viewer: Uuid,
141 ) -> Result<Vec<MessageDto>, ChatError> {
142 if rows.is_empty() {
143 return Ok(Vec::new());
144 }
145 let author_ids: Vec<Uuid> = rows.iter().map(|m| m.author_id).collect();
146 let user_map = self.load_user_briefs(&author_ids).await?;
147
148 let message_ids: Vec<Uuid> = rows.iter().map(|m| m.id).collect();
149 let attachment_rows = attachment_rows::Entity::find()
150 .filter(attachment_rows::Column::MessageId.is_in(message_ids.clone()))
151 .all(self.db().read())
152 .await?;
153 let mut attachments_by_msg: std::collections::HashMap<Uuid, Vec<AttachmentDto>> =
154 std::collections::HashMap::new();
155 for a in attachment_rows {
156 attachments_by_msg
157 .entry(a.message_id.unwrap_or(Uuid::nil()))
158 .or_default()
159 .push(AttachmentDto::from(a));
160 }
161
162 let reaction_rows = reactions::Entity::find()
163 .filter(reactions::Column::MessageId.is_in(message_ids.clone()))
164 .all(self.db().read())
165 .await?;
166 let mut reaction_agg: std::collections::HashMap<
167 (Uuid, String),
168 (i64, bool),
169 > = std::collections::HashMap::new();
170 for r in reaction_rows {
171 let entry = reaction_agg.entry((r.message_id, r.emoji.clone())).or_insert((0, false));
172 entry.0 += 1;
173 if r.user_id == viewer {
174 entry.1 = true;
175 }
176 }
177
178 let items = rows
179 .into_iter()
180 .map(|m| {
181 let mentions: Vec<Uuid> = serde_json::from_value(m.mentions.clone())
182 .unwrap_or_default();
183 MessageDto {
184 id: m.id,
185 room_id: m.room_id,
186 author: user_map.get(&m.author_id).cloned(),
187 author_id: m.author_id,
188 author_type: m.author_type,
189 webhook_id: m.webhook_id,
190 thread_id: m.thread_id,
191 thread_broadcast: m.thread_broadcast,
192 reply_to_id: m.reply_to_id,
193 forwarded_from_id: m.forwarded_from_id,
194 message_type: m.message_type,
195 content: m.content,
196 mentions,
197 mention_everyone: m.mention_everyone,
198 attachments: attachments_by_msg.remove(&m.id).unwrap_or_default(),
199 flags: m.flags,
200 pinned: m.pinned,
201 pinned_by: m.pinned_by,
202 pinned_at: m.pinned_at,
203 edited_at: m.edited_at,
204 deleted_at: m.deleted_at,
205 seq: m.seq,
206 created_at: m.created_at,
207 reactions: reaction_agg
208 .iter()
209 .filter(|((mid, _), _)| *mid == m.id)
210 .map(|((_, emoji), (count, me))| ReactionAggDto {
211 emoji: emoji.clone(),
212 count: *count,
213 me: *me,
214 })
215 .collect(),
216 }
217 })
218 .collect();
219 Ok(items)
220 }
221
222 async fn check_slow_mode(
223 &self,
224 ctx: &RoomContext,
225 caller: Uuid,
226 ) -> Result<(), ChatError> {
227 let Some(limit) = ctx.room.rate_limit_per_user else {
228 return Ok(());
229 };
230 if limit <= 0 {
231 return Ok(());
232 }
233 if let Some(space) = &ctx.space
234 && can_manage_space(self, space, caller).await?
235 {
236 return Ok(());
237 }
238 let last = messages::Entity::find()
239 .filter(messages::Column::RoomId.eq(ctx.room.id))
240 .filter(messages::Column::AuthorId.eq(caller))
241 .filter(messages::Column::DeletedAt.is_null())
242 .order_by_desc(messages::Column::Seq)
243 .one(self.db().read())
244 .await?;
245 if let Some(last) = last {
246 let elapsed = OffsetDateTime::now_utc() - last.created_at;
247 if elapsed.whole_seconds() < limit as i64 {
248 return Err(ChatError::Forbidden);
249 }
250 }
251 Ok(())
252 }
253
254 pub async fn send_message(
255 &self,
256 caller: Uuid,
257 room_id: Uuid,
258 cmd: SendMessageCmd,
259 ) -> Result<MessageDto, ChatError> {
260 let ctx = require_room_readable(self, room_id, caller).await?;
261 self.check_slow_mode(&ctx, caller).await?;
262
263 let content_len = cmd.content.chars().count();
264 if cmd.content.trim().is_empty() && cmd.attachments.is_empty() {
265 return Err(ChatError::BadRequest(
266 "message content is empty".to_string(),
267 ));
268 }
269 if content_len > self.config().chat.max_message_len {
270 return Err(ChatError::BadRequest("message too long".to_string()));
271 }
272
273 if let Some(nonce) = &cmd.nonce {
274 let existing = messages::Entity::find()
275 .filter(messages::Column::RoomId.eq(room_id))
276 .filter(messages::Column::Nonce.eq(nonce.as_str()))
277 .one(self.db().read())
278 .await?;
279 if let Some(existing) = existing {
280 let items = self.build_message_dto(vec![existing], caller).await?;
281 return Ok(items.into_iter().next().unwrap());
282 }
283 }
284
285 let mut thread: Option<threads::Model> = None;
286 if let Some(thread_id) = cmd.thread_id {
287 let t = threads::Entity::find_by_id(thread_id)
288 .one(self.db().read())
289 .await?
290 .filter(|t| t.room_id == room_id)
291 .ok_or(ChatError::NotFound)?;
292 if t.archived || t.locked {
293 return Err(ChatError::Forbidden);
294 }
295 thread = Some(t);
296 }
297
298 if let Some(reply_to) = cmd.reply_to_id {
299 let parent = messages::Entity::find_by_id(reply_to)
300 .one(self.db().read())
301 .await?
302 .filter(|p| p.room_id == room_id && p.deleted_at.is_none())
303 .ok_or(ChatError::NotFound)?;
304 let _ = parent;
305 }
306
307 let parsed = parse_mentions(self, &ctx, &cmd.content, caller).await?;
308 let mention_uids = parsed.users.clone();
309
310 if !cmd.attachments.is_empty() {
311 let owned = attachment_rows::Entity::find()
312 .filter(attachment_rows::Column::Id.is_in(cmd.attachments.clone()))
313 .filter(attachment_rows::Column::UserId.eq(caller))
314 .filter(attachment_rows::Column::MessageId.is_null())
315 .count(self.db().read())
316 .await?;
317 if owned != cmd.attachments.len() as u64 {
318 return Err(ChatError::BadRequest(
319 "attachment not found or already used".to_string(),
320 ));
321 }
322 }
323
324 let now = OffsetDateTime::now_utc();
325 let message_id = Uuid::now_v7();
326 let content = cmd.content.clone();
327 let mentions_json = json!(parsed.users);
328 let groups_json = json!(parsed.groups);
329 let attachments_json = json!(cmd.attachments);
330 let embeds_json = json!([]);
331 let mention_everyone = parsed.everyone;
332 let flags = if cmd.urgent {
333 models::chat::messages::FLAG_URGENT
334 } else {
335 0
336 };
337 let message_type = if cmd.thread_id.is_some() && cmd.reply_to_id.is_none() {
338 ChatMessageType::Default
339 } else if cmd.reply_to_id.is_some() {
340 ChatMessageType::Reply
341 } else {
342 ChatMessageType::Default
343 };
344 let room = ctx.room.clone();
345 let thread_clone = thread.clone();
346 let mention_uids_for_txn = mention_uids.clone();
347 let content_for_email = content.clone();
348
349 self.db()
350 .primary()
351 .transaction::<_, (), ChatError>(move |txn| {
352 Box::pin(async move {
353 let seq = next_room_seq(txn, room.id).await?;
354
355 let row = messages::ActiveModel {
356 id: Set(message_id),
357 room_id: Set(room.id),
358 author_id: Set(caller),
359 author_type: Set(AuthorType::User),
360 webhook_id: Set(None),
361 thread_id: Set(cmd.thread_id),
362 thread_broadcast: Set(cmd.thread_broadcast),
363 reply_to_id: Set(cmd.reply_to_id),
364 forwarded_from_id: Set(None),
365 message_type: Set(message_type),
366 content: Set(content.clone()),
367 mentions: Set(mentions_json),
368 mention_everyone: Set(mention_everyone),
369 mention_group_ids: Set(groups_json),
370 attachments: Set(attachments_json),
371 embeds: Set(embeds_json),
372 flags: Set(flags),
373 nonce: Set(cmd.nonce.clone()),
374 pinned: Set(false),
375 pinned_by: Set(None),
376 pinned_at: Set(None),
377 edited_at: Set(None),
378 deleted_at: Set(None),
379 seq: Set(seq),
380 created_at: Set(now),
381 };
382 row.insert(txn).await?;
383
384 if !cmd.attachments.is_empty() {
385 attachment_rows::Entity::update_many()
386 .filter(attachment_rows::Column::Id.is_in(cmd.attachments.clone()))
387 .filter(attachment_rows::Column::UserId.eq(caller))
388 .col_expr(
389 attachment_rows::Column::MessageId,
390 Expr::value(message_id),
391 )
392 .col_expr(attachment_rows::Column::RoomId, Expr::value(room.id))
393 .col_expr(attachment_rows::Column::UpdatedAt, Expr::value(now))
394 .exec(txn)
395 .await?;
396 }
397
398 rooms::Entity::update_many()
399 .filter(rooms::Column::Id.eq(room.id))
400 .col_expr(rooms::Column::LastMessageId, Expr::value(message_id))
401 .col_expr(rooms::Column::UpdatedAt, Expr::value(now))
402 .exec(txn)
403 .await?;
404
405 if let Some(t) = &thread_clone {
406 maintain_thread_on_reply(txn, t, message_id, caller, now).await?;
407 }
408
409 record_mentions(txn, room.id, message_id, &mention_uids_for_txn).await?;
410
411 let data = json!({
412 "room_id": room.id,
413 "message_id": message_id,
414 "seq": seq,
415 "thread_id": cmd.thread_id,
416 });
417 let body = if content.chars().count() > 120 {
418 content.chars().take(120).collect::<String>()
419 } else {
420 content.clone()
421 };
422 for &uid in &mention_uids_for_txn {
423 notify_user(
424 txn,
425 uid,
426 NotificationType::Mention,
427 "You were mentioned in a message".to_string(),
428 Some(body.clone()),
429 Some(data.clone()),
430 Some(caller),
431 )
432 .await?;
433 }
434
435 Ok(())
436 })
437 })
438 .await?;
439
440 let row = messages::Entity::find_by_id(message_id)
441 .one(self.db().read())
442 .await?
443 .ok_or(ChatError::Internal("message vanished after insert".into()))?;
444 let seq = row.seq;
445 let items = self.build_message_dto(vec![row.clone()], caller).await?;
446 let dto = items.into_iter().next().unwrap();
447 let dto_value = serde_json::to_value(&dto)?;
448
449 publish_message_event(self, room_id, seq, dto_value).await;
450 let _ = self
451 .sender_mark_read(caller, room_id, message_id, seq)
452 .await;
453
454 let recipients: Vec<Uuid> = mention_uids
455 .iter()
456 .copied()
457 .filter(|uid| *uid != caller)
458 .collect();
459 for uid in recipients {
460 self.realtime
461 .publish_user(uid, &json!({"kind": "mention", "room_id": room_id, "message_id": message_id}))
462 .await;
463 }
464
465 if !mention_uids.is_empty() {
466 let room_name = ctx.room.name.clone();
467 if let Ok(emails) = self.primary_emails(&mention_uids).await {
468 for (_uid, addr) in emails {
469 let subject = format!("You were mentioned in #{room_name}");
470 let link = format!(
471 "{}/chat/rooms/{room_id}",
472 self.config().web.web_base_url
473 );
474 let body = if content_for_email.chars().count() > 200 {
475 content_for_email.chars().take(200).collect::<String>()
476 } else {
477 content_for_email.clone()
478 };
479 let text = format!("{subject}\n\n{body}\n\nView channel: {link}");
480 let html = format!(
481 r#"<p>{subject}</p><p>{body}</p><p><a href="{link}">View channel</a></p>"#
482 );
483 if let Err(err) = self.send_email(&addr, &subject, &text, &html).await {
484 tracing::warn!(error = %err, "failed to send mention email");
485 }
486 }
487 }
488 }
489
490 Ok(dto)
491 }
492
493 pub async fn list_messages(
494 &self,
495 caller: Uuid,
496 room_id: Uuid,
497 q: ListMessageQuery,
498 ) -> Result<MessagePage, ChatError> {
499 let _ctx = require_room_readable(self, room_id, caller).await?;
500
501 let limit = q
502 .limit
503 .unwrap_or(self.config().chat.default_pagination)
504 .clamp(1, self.config().chat.max_pagination);
505
506 let mut select = messages::Entity::find()
507 .filter(messages::Column::RoomId.eq(room_id))
508 .filter(messages::Column::DeletedAt.is_null());
509
510 match q.thread_id {
511 Some(thread_id) => {
512 select = select.filter(messages::Column::ThreadId.eq(thread_id));
513 }
514 None => {
515 select = select.filter(
516 Condition::any()
517 .add(messages::Column::ThreadId.is_null())
518 .add(messages::Column::ThreadBroadcast.eq(true)),
519 );
520 }
521 }
522
523 if let Some(author_id) = q.author_id {
524 select = select.filter(messages::Column::AuthorId.eq(author_id));
525 }
526
527 let (rows, has_more) = if let Some(after_seq) = q.after_seq {
528 let rows = select
529 .filter(messages::Column::Seq.gt(after_seq))
530 .order_by_asc(messages::Column::Seq)
531 .limit(limit + 1)
532 .all(self.db().read())
533 .await?;
534 let has_more = rows.len() as u64 > limit;
535 let rows: Vec<_> = rows.into_iter().take(limit as usize).collect();
536 (rows, has_more)
537 } else {
538 let mut desc = select.order_by_desc(messages::Column::Seq).limit(limit + 1);
539 if let Some(before_seq) = q.before_seq {
540 desc = desc.filter(messages::Column::Seq.lt(before_seq));
541 }
542 let mut rows = desc.all(self.db().read()).await?;
543 let has_more = rows.len() as u64 > limit;
544 rows.truncate(limit as usize);
545 rows.reverse();
546 (rows, has_more)
547 };
548
549 let oldest_seq = rows.first().map(|m| m.seq);
550 let newest_seq = rows.last().map(|m| m.seq);
551 let items = self.build_message_dto(rows, caller).await?;
552
553 Ok(MessagePage {
554 items,
555 oldest_seq,
556 newest_seq,
557 has_more,
558 })
559 }
560
561 pub async fn get_message(
562 &self,
563 caller: Uuid,
564 room_id: Uuid,
565 message_id: Uuid,
566 ) -> Result<MessageDto, ChatError> {
567 let _ctx = require_room_readable(self, room_id, caller).await?;
568 let row = messages::Entity::find_by_id(message_id)
569 .filter(messages::Column::RoomId.eq(room_id))
570 .one(self.db().read())
571 .await?
572 .filter(|m| m.deleted_at.is_none())
573 .ok_or(ChatError::NotFound)?;
574 let items = self.build_message_dto(vec![row], caller).await?;
575 Ok(items.into_iter().next().unwrap())
576 }
577
578 pub async fn update_message(
579 &self,
580 caller: Uuid,
581 room_id: Uuid,
582 message_id: Uuid,
583 cmd: UpdateMessageCmd,
584 ) -> Result<MessageDto, ChatError> {
585 let ctx = require_room_readable(self, room_id, caller).await?;
586 let row = messages::Entity::find_by_id(message_id)
587 .filter(messages::Column::RoomId.eq(room_id))
588 .one(self.db().read())
589 .await?
590 .filter(|m| m.deleted_at.is_none())
591 .ok_or(ChatError::NotFound)?;
592
593 let is_manager = match &ctx.space {
594 Some(space) => can_manage_space(self, space, caller).await?,
595 None => false,
596 };
597 if row.author_id != caller && !is_manager {
598 return Err(ChatError::Forbidden);
599 }
600
601 let content = cmd.content.trim().to_string();
602 if content.is_empty() {
603 return Err(ChatError::BadRequest("content is empty".to_string()));
604 }
605 if content.chars().count() > self.config().chat.max_message_len {
606 return Err(ChatError::BadRequest("message too long".to_string()));
607 }
608 if content == row.content {
609 let items = self.build_message_dto(vec![row], caller).await?;
610 return Ok(items.into_iter().next().unwrap());
611 }
612
613 let old_content = row.content.clone();
614 let old_attachments = row.attachments.clone();
615 let now = OffsetDateTime::now_utc();
616
617 let this = self.clone();
618 self.db()
619 .primary()
620 .transaction::<_, (), ChatError>(move |txn| {
621 Box::pin(async move {
622 let seq = next_room_seq(txn, room_id).await?;
623
624 message_edits::ActiveModel {
625 id: Set(Uuid::now_v7()),
626 message_id: Set(message_id),
627 editor_id: Set(caller),
628 content: Set(old_content),
629 attachments: Set(old_attachments),
630 created_at: Set(now),
631 }
632 .insert(txn)
633 .await?;
634
635 messages::Entity::update_many()
636 .filter(messages::Column::Id.eq(message_id))
637 .col_expr(messages::Column::Content, Expr::value(content))
638 .col_expr(messages::Column::EditedAt, Expr::value(now))
639 .col_expr(messages::Column::Seq, Expr::value(seq))
640 .exec(txn)
641 .await?;
642
643 Ok(())
644 })
645 })
646 .await?;
647
648 let row = messages::Entity::find_by_id(message_id)
649 .one(self.db().read())
650 .await?
651 .ok_or(ChatError::Internal("message vanished after update".into()))?;
652 let seq = row.seq;
653 let items = self.build_message_dto(vec![row], caller).await?;
654 let dto = items.into_iter().next().unwrap();
655 let dto_value = serde_json::to_value(&dto)?;
656 let event = RoomEvent::MessageEdited {
657 room_id,
658 seq,
659 message: dto_value,
660 };
661 this.realtime.publish_room(&event).await;
662
663 Ok(dto)
664 }
665
666 pub async fn delete_message(
667 &self,
668 caller: Uuid,
669 room_id: Uuid,
670 message_id: Uuid,
671 ) -> Result<(), ChatError> {
672 self.delete_messages_internal(caller, room_id, vec![message_id])
673 .await
674 }
675
676 pub async fn bulk_delete_messages(
677 &self,
678 caller: Uuid,
679 room_id: Uuid,
680 cmd: BulkDeleteCmd,
681 ) -> Result<(), ChatError> {
682 if cmd.message_ids.is_empty() || cmd.message_ids.len() > MAX_BULK_DELETE {
683 return Err(ChatError::BadRequest(format!(
684 "message_ids must contain 1..={MAX_BULK_DELETE} entries"
685 )));
686 }
687 let ctx = require_room_readable(self, room_id, caller).await?;
688 let is_manager = match &ctx.space {
689 Some(space) => can_manage_space(self, space, caller).await?,
690 None => false,
691 };
692 if !is_manager {
693 return Err(ChatError::Forbidden);
694 }
695 self.delete_messages_internal(caller, room_id, cmd.message_ids)
696 .await
697 }
698
699 async fn delete_messages_internal(
700 &self,
701 caller: Uuid,
702 room_id: Uuid,
703 message_ids: Vec<Uuid>,
704 ) -> Result<(), ChatError> {
705 let ctx = require_room_readable(self, room_id, caller).await?;
706 let is_manager = match &ctx.space {
707 Some(space) => can_manage_space(self, space, caller).await?,
708 None => false,
709 };
710
711 let rows = messages::Entity::find()
712 .filter(messages::Column::Id.is_in(message_ids.clone()))
713 .filter(messages::Column::RoomId.eq(room_id))
714 .filter(messages::Column::DeletedAt.is_null())
715 .all(self.db().read())
716 .await?;
717 if rows.is_empty() {
718 return Ok(());
719 }
720 for row in &rows {
721 if row.author_id != caller && !is_manager {
722 return Err(ChatError::Forbidden);
723 }
724 }
725
726 let row_ids: Vec<Uuid> = rows.iter().map(|r| r.id).collect();
727 let single = row_ids.len() == 1;
728 let first_id = row_ids[0];
729 let thread_roots: Vec<Uuid> = threads::Entity::find()
730 .filter(threads::Column::RootMessageId.is_in(row_ids.clone()))
731 .all(self.db().read())
732 .await?
733 .into_iter()
734 .map(|t| t.id)
735 .collect();
736
737 let now = OffsetDateTime::now_utc();
738 let ids = row_ids.clone();
739 let seq = self
740 .db()
741 .primary()
742 .transaction::<_, i64, ChatError>(move |txn| {
743 Box::pin(async move {
744 let seq = next_room_seq(txn, room_id).await?;
745
746 messages::Entity::update_many()
747 .filter(messages::Column::Id.is_in(ids.clone()))
748 .col_expr(messages::Column::Content, Expr::value(String::new()))
749 .col_expr(messages::Column::Attachments, Expr::value(json!([])))
750 .col_expr(messages::Column::DeletedAt, Expr::value(now))
751 .col_expr(messages::Column::Seq, Expr::value(seq))
752 .exec(txn)
753 .await?;
754
755 if !thread_roots.is_empty() {
756 threads::Entity::update_many()
757 .filter(threads::Column::Id.is_in(thread_roots.clone()))
758 .col_expr(threads::Column::Archived, Expr::value(true))
759 .col_expr(threads::Column::UpdatedAt, Expr::value(now))
760 .exec(txn)
761 .await?;
762 }
763
764 Ok(seq)
765 })
766 })
767 .await?;
768
769 let event = if single {
770 RoomEvent::MessageDeleted {
771 room_id,
772 seq,
773 message_id: first_id,
774 }
775 } else {
776 RoomEvent::MessagesDeleted {
777 room_id,
778 seq,
779 message_ids: row_ids.clone(),
780 }
781 };
782 self.realtime.publish_room(&event).await;
783 let _ = ctx;
784
785 Ok(())
786 }
787
788 pub async fn set_message_pin(
789 &self,
790 caller: Uuid,
791 room_id: Uuid,
792 message_id: Uuid,
793 cmd: PinCmd,
794 ) -> Result<MessageDto, ChatError> {
795 let _ctx = require_room_readable(self, room_id, caller).await?;
796 let row = messages::Entity::find_by_id(message_id)
797 .filter(messages::Column::RoomId.eq(room_id))
798 .one(self.db().read())
799 .await?
800 .filter(|m| m.deleted_at.is_none())
801 .ok_or(ChatError::NotFound)?;
802
803 if row.pinned == cmd.pinned {
804 let items = self.build_message_dto(vec![row], caller).await?;
805 return Ok(items.into_iter().next().unwrap());
806 }
807
808 let now = OffsetDateTime::now_utc();
809 let seq = self
810 .db()
811 .primary()
812 .transaction::<_, i64, ChatError>(move |txn| {
813 Box::pin(async move {
814 let seq = next_room_seq(txn, room_id).await?;
815 let mut update = messages::Entity::update_many()
816 .filter(messages::Column::Id.eq(message_id))
817 .col_expr(messages::Column::Pinned, Expr::value(cmd.pinned))
818 .col_expr(messages::Column::Seq, Expr::value(seq));
819 if cmd.pinned {
820 update = update
821 .col_expr(messages::Column::PinnedBy, Expr::value(caller))
822 .col_expr(messages::Column::PinnedAt, Expr::value(now));
823 } else {
824 update = update
825 .col_expr(messages::Column::PinnedBy, Expr::value(None::<Uuid>))
826 .col_expr(messages::Column::PinnedAt, Expr::value(None::<OffsetDateTime>));
827 }
828 update.exec(txn).await?;
829 Ok(seq)
830 })
831 })
832 .await?;
833
834 let event = RoomEvent::MessagePinned {
835 room_id,
836 seq,
837 message_id,
838 pinned: cmd.pinned,
839 };
840 self.realtime.publish_room(&event).await;
841
842 self.get_message(caller, room_id, message_id).await
843 }
844
845 pub async fn list_pins(
846 &self,
847 caller: Uuid,
848 room_id: Uuid,
849 ) -> Result<Vec<MessageDto>, ChatError> {
850 let _ctx = require_room_readable(self, room_id, caller).await?;
851 let rows = messages::Entity::find()
852 .filter(messages::Column::RoomId.eq(room_id))
853 .filter(messages::Column::Pinned.eq(true))
854 .filter(messages::Column::DeletedAt.is_null())
855 .order_by_desc(messages::Column::PinnedAt)
856 .all(self.db().read())
857 .await?;
858 self.build_message_dto(rows, caller).await
859 }
860
861 pub async fn forward_message(
862 &self,
863 caller: Uuid,
864 room_id: Uuid,
865 message_id: Uuid,
866 cmd: ForwardMessageCmd,
867 ) -> Result<MessageDto, ChatError> {
868 let _ctx = require_room_readable(self, room_id, caller).await?;
869 let original = messages::Entity::find_by_id(message_id)
870 .filter(messages::Column::RoomId.eq(room_id))
871 .one(self.db().read())
872 .await?
873 .filter(|m| m.deleted_at.is_none())
874 .ok_or(ChatError::NotFound)?;
875
876 let send_cmd = SendMessageCmd {
877 content: cmd.comment.unwrap_or_default(),
878 reply_to_id: None,
879 thread_id: None,
880 thread_broadcast: false,
881 attachments: Vec::new(),
882 nonce: None,
883 urgent: false,
884 };
885 let mut dto = self.send_message(caller, cmd.target_room_id, send_cmd).await?;
886
887 messages::Entity::update_many()
888 .filter(messages::Column::Id.eq(dto.id))
889 .col_expr(
890 messages::Column::ForwardedFromId,
891 Expr::value(original.id),
892 )
893 .exec(self.db().write())
894 .await?;
895 dto.forwarded_from_id = Some(original.id);
896 Ok(dto)
897 }
898
899 pub async fn list_message_history(
900 &self,
901 caller: Uuid,
902 room_id: Uuid,
903 message_id: Uuid,
904 ) -> Result<Vec<MessageEditDto>, ChatError> {
905 let _ctx = require_room_readable(self, room_id, caller).await?;
906 let rows = message_edits::Entity::find()
907 .filter(message_edits::Column::MessageId.eq(message_id))
908 .order_by_desc(message_edits::Column::CreatedAt)
909 .all(self.db().read())
910 .await?;
911 let editor_ids: Vec<Uuid> = rows.iter().map(|r| r.editor_id).collect();
912 let user_map = self.load_user_briefs(&editor_ids).await?;
913 Ok(rows
914 .into_iter()
915 .map(|r| MessageEditDto {
916 id: r.id,
917 message_id: r.message_id,
918 editor: user_map.get(&r.editor_id).cloned(),
919 content: r.content,
920 created_at: r.created_at,
921 })
922 .collect())
923 }
924
925 pub(crate) async fn sender_mark_read(
926 &self,
927 caller: Uuid,
928 room_id: Uuid,
929 message_id: Uuid,
930 seq: i64,
931 ) -> Result<(), ChatError> {
932 advance_read_state(self.db().write(), caller, room_id, Some(message_id), seq).await?;
933 Ok(())
934 }
935}
Added lib/chat/src/services/mod.rs lib/chat/src/services/mod.rs +16 −0 View lib/chat/src/services/mod.rs
@@ -0,0 +1,16 @@
1pub mod attachments;
2pub mod bookmarks;
3pub mod drafts;
4pub mod dms;
5pub mod emojis;
6pub mod messages;
7pub mod presence;
8pub mod reactions;
9pub mod read_state;
10pub mod reminders;
11pub mod rooms;
12pub mod search;
13pub mod service;
14pub mod settings;
15pub mod threads;
16pub mod webhooks;
Added lib/chat/src/services/presence.rs lib/chat/src/services/presence.rs +173 −0 View lib/chat/src/services/presence.rs
@@ -0,0 +1,173 @@
1use models::chat::presence_states::{self, PresenceStatus};
2use models::space::space_members::{self, MemberStatus};
3use redis::AsyncCommands;
4use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
5use serde::{Deserialize, Serialize};
6use time::OffsetDateTime;
7use utoipa::ToSchema;
8use uuid::Uuid;
9
10use crate::errors::ChatError;
11use crate::services::service::ChatService;
12use crate::share::lookup::{lookup_space_by_name, require_space_member};
13
14#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
15pub struct PresenceDto {
16 pub user_id: Uuid,
17 pub status: PresenceStatus,
18 pub activities: serde_json::Value,
19 pub last_seen_at: Option<OffsetDateTime>,
20 pub online: bool,
21}
22
23#[derive(Debug, Clone, Deserialize, ToSchema)]
24pub struct SetPresenceCmd {
25 pub status: PresenceStatus,
26 #[serde(default)]
27 pub activities: Option<serde_json::Value>,
28}
29
30fn presence_key(uid: Uuid) -> String {
31 format!("chat:presence:{uid}")
32}
33
34#[derive(Debug, Serialize, Deserialize)]
35struct PresencePayload {
36 status: PresenceStatus,
37 activities: serde_json::Value,
38 at: i64,
39}
40
41impl ChatService {
42 pub async fn set_presence(
43 &self,
44 caller: Uuid,
45 cmd: SetPresenceCmd,
46 ) -> Result<PresenceDto, ChatError> {
47 let now = OffsetDateTime::now_utc();
48 let activities = cmd.activities.unwrap_or(serde_json::json!([]));
49 let payload = PresencePayload {
50 status: cmd.status,
51 activities: activities.clone(),
52 at: now.unix_timestamp(),
53 };
54 let ttl = self.config().chat.presence_ttl_secs;
55
56 let mut conn = self.state.redis.connection_clone();
57 let key = presence_key(caller);
58 let value = serde_json::to_string(&payload)?;
59 let result: Result<(), redis::RedisError> = match &mut conn {
60 export::RedisConnection::Standalone(c) => {
61 c.set_ex(&key, &value, ttl).await
62 }
63 export::RedisConnection::Cluster(c) => {
64 c.set_ex(&key, &value, ttl).await
65 }
66 };
67 if let Err(error) = result {
68 tracing::warn!(%error, "failed to write presence to redis");
69 }
70
71 let existing = presence_states::Entity::find_by_id(caller)
72 .one(self.db().write())
73 .await?;
74 match existing {
75 Some(row) => {
76 let mut am: presence_states::ActiveModel = row.into();
77 am.status = Set(cmd.status);
78 am.activities = Set(activities.clone());
79 am.last_seen_at = Set(Some(now));
80 am.updated_at = Set(now);
81 am.update(self.db().write()).await?;
82 }
83 None => {
84 presence_states::ActiveModel {
85 user_id: Set(caller),
86 status: Set(cmd.status),
87 activities: Set(activities.clone()),
88 last_seen_at: Set(Some(now)),
89 updated_at: Set(now),
90 }
91 .insert(self.db().write())
92 .await?;
93 }
94 }
95
96 self.realtime
97 .publish_user(
98 caller,
99 &serde_json::json!({"kind": "presence", "status": cmd.status}),
100 )
101 .await;
102
103 Ok(PresenceDto {
104 user_id: caller,
105 status: cmd.status,
106 activities,
107 last_seen_at: Some(now),
108 online: !matches!(cmd.status, PresenceStatus::Offline),
109 })
110 }
111
112 pub async fn get_presence(&self, uid: Uuid) -> Result<PresenceDto, ChatError> {
113 let mut conn = self.state.redis.connection_clone();
114 let key = presence_key(uid);
115 let value: Result<Option<String>, redis::RedisError> = match &mut conn {
116 export::RedisConnection::Standalone(c) => c.get(&key).await,
117 export::RedisConnection::Cluster(c) => c.get(&key).await,
118 };
119 if let Ok(Some(value)) = value
120 && let Ok(payload) = serde_json::from_str::<PresencePayload>(&value)
121 {
122 return Ok(PresenceDto {
123 user_id: uid,
124 status: payload.status,
125 activities: payload.activities,
126 last_seen_at: Some(OffsetDateTime::from_unix_timestamp(payload.at).unwrap_or_else(|_| OffsetDateTime::now_utc())),
127 online: !matches!(payload.status, PresenceStatus::Offline),
128 });
129 }
130 let row = presence_states::Entity::find_by_id(uid)
131 .one(self.db().read())
132 .await?;
133 match row {
134 Some(row) => Ok(PresenceDto {
135 user_id: uid,
136 status: PresenceStatus::Offline,
137 activities: serde_json::json!([]),
138 last_seen_at: row.last_seen_at,
139 online: false,
140 }),
141 None => Ok(PresenceDto {
142 user_id: uid,
143 status: PresenceStatus::Offline,
144 activities: serde_json::json!([]),
145 last_seen_at: None,
146 online: false,
147 }),
148 }
149 }
150
151 pub async fn list_space_presences(
152 &self,
153 caller: Uuid,
154 space_name: &str,
155 ) -> Result<Vec<PresenceDto>, ChatError> {
156 let space = lookup_space_by_name(self, space_name).await?;
157 require_space_member(self, &space, caller).await?;
158 let members = space_members::Entity::find()
159 .filter(space_members::Column::SpaceId.eq(space.id))
160 .filter(space_members::Column::Status.eq(MemberStatus::Active))
161 .all(self.db().read())
162 .await?;
163 let mut uids: Vec<Uuid> = members.into_iter().map(|m| m.user_id).collect();
164 if !uids.contains(&space.owner_id) {
165 uids.push(space.owner_id);
166 }
167 let mut result = Vec::new();
168 for uid in uids {
169 result.push(self.get_presence(uid).await?);
170 }
171 Ok(result)
172 }
173}
Added lib/chat/src/services/reactions.rs lib/chat/src/services/reactions.rs +155 −0 View lib/chat/src/services/reactions.rs
@@ -0,0 +1,155 @@
1use models::chat::messages;
2use models::chat::reactions;
3use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set};
4use serde::{Deserialize, Serialize};
5use time::OffsetDateTime;
6use utoipa::ToSchema;
7use uuid::Uuid;
8
9use crate::errors::ChatError;
10use crate::services::service::ChatService;
11use crate::share::lookup::require_room_readable;
12use crate::share::realtime::RoomEvent;
13
14#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
15pub struct ReactionAggDto {
16 pub emoji: String,
17 pub count: i64,
18 pub me: bool,
19}
20
21#[derive(Debug, Clone, Serialize, ToSchema)]
22pub struct ReactionUserDto {
23 pub emoji: String,
24 pub user: space::services::spaces::UserBriefDto,
25 pub created_at: OffsetDateTime,
26}
27
28#[derive(Debug, Clone, Deserialize, ToSchema)]
29pub struct ReactionCmd {
30 pub emoji: String,
31}
32
33impl ChatService {
34 pub async fn add_reaction(
35 &self,
36 caller: Uuid,
37 room_id: Uuid,
38 message_id: Uuid,
39 cmd: ReactionCmd,
40 ) -> Result<Vec<ReactionAggDto>, ChatError> {
41 let emoji = cmd.emoji.trim().to_string();
42 if emoji.is_empty() || emoji.chars().count() > self.config().chat.max_reaction_emoji_len {
43 return Err(ChatError::BadRequest("invalid emoji".to_string()));
44 }
45 let _ctx = require_room_readable(self, room_id, caller).await?;
46 let message = messages::Entity::find_by_id(message_id)
47 .filter(messages::Column::RoomId.eq(room_id))
48 .one(self.db().read())
49 .await?
50 .filter(|m| m.deleted_at.is_none())
51 .ok_or(ChatError::NotFound)?;
52 let _ = message;
53
54 let now = OffsetDateTime::now_utc();
55 let result = reactions::Entity::insert(reactions::ActiveModel {
56 message_id: Set(message_id),
57 emoji: Set(emoji.clone()),
58 user_id: Set(caller),
59 created_at: Set(now),
60 })
61 .exec(self.db().write())
62 .await;
63 match result {
64 Ok(_) => {}
65 Err(err) => {
66 let msg = err.to_string().to_lowercase();
67 if !(msg.contains("unique") || msg.contains("duplicate")) {
68 return Err(ChatError::from(err));
69 }
70 }
71 }
72
73 self.publish_reactions(room_id, message_id, caller).await
74 }
75
76 pub async fn remove_reaction(
77 &self,
78 caller: Uuid,
79 room_id: Uuid,
80 message_id: Uuid,
81 cmd: ReactionCmd,
82 ) -> Result<Vec<ReactionAggDto>, ChatError> {
83 let emoji = cmd.emoji.trim().to_string();
84 let _ctx = require_room_readable(self, room_id, caller).await?;
85 reactions::Entity::delete_many()
86 .filter(reactions::Column::MessageId.eq(message_id))
87 .filter(reactions::Column::Emoji.eq(&emoji))
88 .filter(reactions::Column::UserId.eq(caller))
89 .exec(self.db().write())
90 .await?;
91 self.publish_reactions(room_id, message_id, caller).await
92 }
93
94 pub async fn list_message_reactions(
95 &self,
96 caller: Uuid,
97 room_id: Uuid,
98 message_id: Uuid,
99 ) -> Result<Vec<ReactionUserDto>, ChatError> {
100 let _ctx = require_room_readable(self, room_id, caller).await?;
101 let rows = reactions::Entity::find()
102 .filter(reactions::Column::MessageId.eq(message_id))
103 .order_by_asc(reactions::Column::Emoji)
104 .order_by_asc(reactions::Column::CreatedAt)
105 .all(self.db().read())
106 .await?;
107 let uids: Vec<Uuid> = rows.iter().map(|r| r.user_id).collect();
108 let user_map = self.load_user_briefs(&uids).await?;
109 Ok(rows
110 .into_iter()
111 .filter_map(|r| {
112 user_map.get(&r.user_id).map(|u| ReactionUserDto {
113 emoji: r.emoji,
114 user: u.clone(),
115 created_at: r.created_at,
116 })
117 })
118 .collect())
119 }
120
121 async fn publish_reactions(
122 &self,
123 room_id: Uuid,
124 message_id: Uuid,
125 viewer: Uuid,
126 ) -> Result<Vec<ReactionAggDto>, ChatError> {
127 let rows = reactions::Entity::find()
128 .filter(reactions::Column::MessageId.eq(message_id))
129 .all(self.db().read())
130 .await?;
131 let mut agg: std::collections::HashMap<String, (i64, bool)> =
132 std::collections::HashMap::new();
133 for r in rows {
134 let entry = agg.entry(r.emoji).or_insert((0, false));
135 entry.0 += 1;
136 if r.user_id == viewer {
137 entry.1 = true;
138 }
139 }
140 let mut result: Vec<ReactionAggDto> = agg
141 .into_iter()
142 .map(|(emoji, (count, me))| ReactionAggDto { emoji, count, me })
143 .collect();
144 result.sort_by(|a, b| a.emoji.cmp(&b.emoji));
145
146 let value = serde_json::to_value(&result)?;
147 let event = RoomEvent::ReactionUpdated {
148 room_id,
149 message_id,
150 reactions: value,
151 };
152 self.realtime.publish_room(&event).await;
153 Ok(result)
154 }
155}
Added lib/chat/src/services/read_state.rs lib/chat/src/services/read_state.rs +201 −0 View lib/chat/src/services/read_state.rs
@@ -0,0 +1,201 @@
1use models::chat::messages;
2use models::chat::read_states;
3use models::chat::room_seq;
4use models::room::rooms;
5use models::users::user_recent_mentions;
6use sea_orm::{ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter};
7use serde::{Deserialize, Serialize};
8use time::OffsetDateTime;
9use utoipa::{IntoParams, ToSchema};
10use uuid::Uuid;
11
12use crate::errors::ChatError;
13use crate::services::service::ChatService;
14use crate::share::lookup::{lookup_room, require_room_readable, require_space_member, lookup_space_by_name};
15use crate::share::notify::advance_read_state;
16
17#[derive(Debug, Clone, Serialize, ToSchema)]
18pub struct ReadStateDto {
19 pub room_id: Uuid,
20 pub last_read_message_id: Option<Uuid>,
21 pub last_read_seq: i64,
22 pub last_viewed_at: Option<OffsetDateTime>,
23 pub tip_seq: i64,
24 pub unread_count: i64,
25 pub unread_mentions: i64,
26}
27
28#[derive(Debug, Clone, Serialize, ToSchema)]
29pub struct SpaceReadStatePage {
30 pub items: Vec<ReadStateDto>,
31}
32
33#[derive(Debug, Clone, Deserialize, ToSchema)]
34pub struct MarkReadCmd {
35 #[serde(default)]
36 pub message_id: Option<Uuid>,
37 #[serde(default)]
38 pub seq: Option<i64>,
39}
40
41#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
42pub struct ThreadReadCmd {
43 pub thread_id: Uuid,
44}
45
46impl ChatService {
47 pub async fn mark_read(
48 &self,
49 caller: Uuid,
50 room_id: Uuid,
51 cmd: MarkReadCmd,
52 ) -> Result<ReadStateDto, ChatError> {
53 let _ctx = require_room_readable(self, room_id, caller).await?;
54
55 let (message_id, seq) = match (cmd.message_id, cmd.seq) {
56 (Some(message_id), _) => {
57 let msg = messages::Entity::find_by_id(message_id)
58 .filter(messages::Column::RoomId.eq(room_id))
59 .one(self.db().read())
60 .await?
61 .ok_or(ChatError::NotFound)?;
62 (Some(message_id), msg.seq)
63 }
64 (None, Some(seq)) => (None, seq),
65 (None, None) => {
66 let tip = room_seq::Entity::find_by_id(room_id)
67 .one(self.db().read())
68 .await?
69 .map(|s| s.seq)
70 .unwrap_or(0);
71 let last_msg = rooms::Entity::find_by_id(room_id)
72 .one(self.db().read())
73 .await?
74 .and_then(|r| r.last_message_id);
75 (last_msg, tip)
76 }
77 };
78
79 let changed = advance_read_state(
80 self.db().write(),
81 caller,
82 room_id,
83 message_id,
84 seq,
85 )
86 .await?;
87 if changed {
88 self.realtime
89 .publish_user(
90 caller,
91 &serde_json::json!({
92 "kind": "read_marker",
93 "room_id": room_id,
94 "last_read_message_id": message_id,
95 "last_read_seq": seq,
96 }),
97 )
98 .await;
99 }
100 self.get_read_state(caller, room_id).await
101 }
102
103 pub async fn get_read_state(
104 &self,
105 caller: Uuid,
106 room_id: Uuid,
107 ) -> Result<ReadStateDto, ChatError> {
108 let _ = lookup_room(self, room_id).await?;
109 let tip = room_seq::Entity::find_by_id(room_id)
110 .one(self.db().read())
111 .await?
112 .map(|s| s.seq)
113 .unwrap_or(0);
114 let state = read_states::Entity::find_by_id((caller, room_id))
115 .one(self.db().read())
116 .await?;
117 let last_viewed_at = state.as_ref().and_then(|s| s.last_viewed_at);
118 let unread_mentions = match last_viewed_at {
119 Some(at) => user_recent_mentions::Entity::find()
120 .filter(user_recent_mentions::Column::UserId.eq(caller))
121 .filter(user_recent_mentions::Column::ChannelId.eq(room_id))
122 .filter(user_recent_mentions::Column::CreatedAt.gt(at))
123 .count(self.db().read())
124 .await? as i64,
125 None => user_recent_mentions::Entity::find()
126 .filter(user_recent_mentions::Column::UserId.eq(caller))
127 .filter(user_recent_mentions::Column::ChannelId.eq(room_id))
128 .count(self.db().read())
129 .await? as i64,
130 };
131 let last_read_seq = state.as_ref().map(|s| s.last_read_seq).unwrap_or(0);
132 Ok(ReadStateDto {
133 room_id,
134 last_read_message_id: state.as_ref().and_then(|s| s.last_read_message_id),
135 last_read_seq,
136 last_viewed_at,
137 tip_seq: tip,
138 unread_count: (tip - last_read_seq).max(0),
139 unread_mentions,
140 })
141 }
142
143 pub async fn list_space_read_states(
144 &self,
145 caller: Uuid,
146 space_name: &str,
147 ) -> Result<SpaceReadStatePage, ChatError> {
148 let space = lookup_space_by_name(self, space_name).await?;
149 require_space_member(self, &space, caller).await?;
150
151 let space_rooms = rooms::Entity::find()
152 .filter(rooms::Column::SpaceId.eq(space.id))
153 .filter(rooms::Column::IsActive.eq(true))
154 .all(self.db().read())
155 .await?;
156 let mut items = Vec::new();
157 for room in space_rooms {
158 if room.is_private
159 && !crate::share::lookup::is_room_member(self, room.id, caller).await?
160 {
161 continue;
162 }
163 items.push(self.get_read_state(caller, room.id).await?);
164 }
165 Ok(SpaceReadStatePage { items })
166 }
167
168 pub async fn mark_thread_read(
169 &self,
170 caller: Uuid,
171 thread_id: Uuid,
172 message_id: Option<Uuid>,
173 ) -> Result<(), ChatError> {
174 let thread = models::chat::threads::Entity::find_by_id(thread_id)
175 .one(self.db().read())
176 .await?
177 .ok_or(ChatError::NotFound)?;
178 let _ctx = require_room_readable(self, thread.room_id, caller).await?;
179 let (mid, seq) = match message_id {
180 Some(message_id) => {
181 let msg = messages::Entity::find_by_id(message_id)
182 .one(self.db().read())
183 .await?
184 .filter(|m| m.thread_id == Some(thread_id))
185 .ok_or(ChatError::NotFound)?;
186 (message_id, msg.seq)
187 }
188 None => match thread.last_reply_id {
189 Some(last) => {
190 let msg = messages::Entity::find_by_id(last)
191 .one(self.db().read())
192 .await?
193 .ok_or(ChatError::NotFound)?;
194 (last, msg.seq)
195 }
196 None => return Ok(()),
197 },
198 };
199 self.upsert_thread_read(caller, thread_id, mid, seq).await
200 }
201}
Added lib/chat/src/services/reminders.rs lib/chat/src/services/reminders.rs +146 −0 View lib/chat/src/services/reminders.rs
@@ -0,0 +1,146 @@
1use models::chat::message_reminders::{self, ReminderStatus};
2use models::chat::messages;
3use models::users::user_notifications::NotificationType;
4use sea_orm::{
5 ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect, Set,
6};
7use serde::{Deserialize, Serialize};
8use time::OffsetDateTime;
9use utoipa::ToSchema;
10use uuid::Uuid;
11
12use crate::errors::ChatError;
13use crate::services::service::ChatService;
14use crate::share::lookup::require_room_readable;
15use crate::share::notify::notify_user;
16
17#[derive(Debug, Clone, Serialize, ToSchema)]
18pub struct ReminderDto {
19 pub id: Uuid,
20 pub room_id: Uuid,
21 pub message_id: Uuid,
22 pub remind_at: OffsetDateTime,
23 pub status: ReminderStatus,
24 pub created_at: OffsetDateTime,
25}
26
27#[derive(Debug, Clone, Deserialize, ToSchema)]
28pub struct CreateReminderCmd {
29 pub remind_at: OffsetDateTime,
30}
31
32impl From<message_reminders::Model> for ReminderDto {
33 fn from(r: message_reminders::Model) -> Self {
34 Self {
35 id: r.id,
36 room_id: r.room_id,
37 message_id: r.message_id,
38 remind_at: r.remind_at,
39 status: r.status,
40 created_at: r.created_at,
41 }
42 }
43}
44
45impl ChatService {
46 pub async fn create_reminder(
47 &self,
48 caller: Uuid,
49 room_id: Uuid,
50 message_id: Uuid,
51 cmd: CreateReminderCmd,
52 ) -> Result<ReminderDto, ChatError> {
53 let _ctx = require_room_readable(self, room_id, caller).await?;
54 let _message = messages::Entity::find_by_id(message_id)
55 .filter(messages::Column::RoomId.eq(room_id))
56 .one(self.db().read())
57 .await?
58 .filter(|m| m.deleted_at.is_none())
59 .ok_or(ChatError::NotFound)?;
60 if cmd.remind_at <= OffsetDateTime::now_utc() {
61 return Err(ChatError::BadRequest("remind_at must be in the future".to_string()));
62 }
63 let row = message_reminders::ActiveModel {
64 id: Set(Uuid::now_v7()),
65 user_id: Set(caller),
66 room_id: Set(room_id),
67 message_id: Set(message_id),
68 remind_at: Set(cmd.remind_at),
69 status: Set(ReminderStatus::Pending),
70 created_at: Set(OffsetDateTime::now_utc()),
71 }
72 .insert(self.db().write())
73 .await?;
74 Ok(ReminderDto::from(row))
75 }
76
77 pub async fn list_reminders(&self, caller: Uuid) -> Result<Vec<ReminderDto>, ChatError> {
78 let rows = message_reminders::Entity::find()
79 .filter(message_reminders::Column::UserId.eq(caller))
80 .filter(message_reminders::Column::Status.eq(ReminderStatus::Pending))
81 .order_by_asc(message_reminders::Column::RemindAt)
82 .all(self.db().read())
83 .await?;
84 Ok(rows.into_iter().map(ReminderDto::from).collect())
85 }
86
87 pub async fn cancel_reminder(&self, caller: Uuid, reminder_id: Uuid) -> Result<(), ChatError> {
88 let row = message_reminders::Entity::find_by_id(reminder_id)
89 .filter(message_reminders::Column::UserId.eq(caller))
90 .one(self.db().write())
91 .await?
92 .ok_or(ChatError::NotFound)?;
93 if row.status != ReminderStatus::Pending {
94 return Err(ChatError::Conflict("reminder not pending".to_string()));
95 }
96 let mut am: message_reminders::ActiveModel = row.into();
97 am.status = Set(ReminderStatus::Cancelled);
98 am.update(self.db().write()).await?;
99 Ok(())
100 }
101
102 pub async fn deliver_due_reminders(&self) -> Result<u64, ChatError> {
103 let now = OffsetDateTime::now_utc();
104 let due = message_reminders::Entity::find()
105 .filter(message_reminders::Column::Status.eq(ReminderStatus::Pending))
106 .filter(message_reminders::Column::RemindAt.lte(now))
107 .limit(200)
108 .all(self.db().write())
109 .await?;
110 let count = due.len() as u64;
111 for reminder in due {
112 let mut am: message_reminders::ActiveModel = reminder.clone().into();
113 am.status = Set(ReminderStatus::Done);
114 am.update(self.db().write()).await?;
115
116 let data = serde_json::json!({
117 "room_id": reminder.room_id,
118 "message_id": reminder.message_id,
119 });
120 if let Err(error) = notify_user(
121 self.db().write(),
122 reminder.user_id,
123 NotificationType::System,
124 "Message reminder".to_string(),
125 None,
126 Some(data),
127 None,
128 )
129 .await
130 {
131 tracing::warn!(%error, "failed to write reminder notification");
132 }
133 self.realtime
134 .publish_user(
135 reminder.user_id,
136 &serde_json::json!({
137 "kind": "reminder",
138 "room_id": reminder.room_id,
139 "message_id": reminder.message_id,
140 }),
141 )
142 .await;
143 }
144 Ok(count)
145 }
146}
Added lib/chat/src/services/rooms.rs lib/chat/src/services/rooms.rs +680 −0 View lib/chat/src/services/rooms.rs
@@ -0,0 +1,680 @@
1use models::chat::messages::{self, ChatMessageType};
2use models::chat::read_states;
3use models::chat::room_seq;
4use models::chat::room_user_settings;
5use models::room::room_invites;
6use models::room::room_members;
7use models::room::rooms::{self, RoomKind};
8use models::users::user_notifications::NotificationType;
9use rand::Rng;
10use sea_orm::sea_query::Expr;
11use sea_orm::{
12 ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect, Set,
13 TransactionTrait,
14};
15use serde::{Deserialize, Serialize};
16use time::OffsetDateTime;
17use utoipa::{IntoParams, ToSchema};
18use uuid::Uuid;
19
20use crate::errors::ChatError;
21use crate::services::service::ChatService;
22use crate::share::authz::can_manage_space;
23use crate::share::lookup::{
24 is_room_member, load_room_context, lookup_room, lookup_space_by_name, require_room_readable,
25 require_space_member,
26};
27use crate::share::notify::{next_room_seq, notify_user};
28use crate::share::realtime::RoomEvent;
29
30#[derive(Debug, Clone, Serialize, ToSchema)]
31pub struct RoomBriefDto {
32 pub id: Uuid,
33 pub space_id: Option<Uuid>,
34 pub kind: RoomKind,
35 pub name: String,
36 pub topic: Option<String>,
37 pub position: i64,
38 pub parent_id: Option<Uuid>,
39 pub is_private: bool,
40 pub nsfw: bool,
41 pub rate_limit_per_user: Option<i32>,
42 pub last_message_id: Option<Uuid>,
43 pub unread_count: i64,
44 pub unread_mentions: i64,
45 pub favorite: bool,
46 pub muted: bool,
47 pub hidden: bool,
48 pub member_count: i64,
49 pub created_at: OffsetDateTime,
50}
51
52#[derive(Debug, Clone, Deserialize, ToSchema)]
53pub struct UpdateRoomCmd {
54 #[serde(default)]
55 pub name: Option<String>,
56 #[serde(default)]
57 pub topic: Option<String>,
58 #[serde(default)]
59 pub is_private: Option<bool>,
60 #[serde(default)]
61 pub nsfw: Option<bool>,
62 #[serde(default)]
63 pub rate_limit_per_user: Option<i32>,
64 #[serde(default)]
65 pub position: Option<i64>,
66}
67
68#[derive(Debug, Clone, Deserialize, ToSchema)]
69pub struct AddMembersCmd {
70 pub user_ids: Vec<Uuid>,
71}
72
73#[derive(Debug, Clone, Serialize, ToSchema)]
74pub struct RoomMemberDto {
75 pub user: space::services::spaces::UserBriefDto,
76 pub nick: Option<String>,
77 pub joined_at: OffsetDateTime,
78}
79
80#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
81pub struct RoomMemberQuery {
82 pub limit: Option<u64>,
83 pub offset: Option<u64>,
84}
85
86#[derive(Debug, Clone, Deserialize, ToSchema)]
87pub struct CreateRoomInviteCmd {
88 #[serde(default)]
89 pub invitee_user_id: Option<Uuid>,
90 #[serde(default)]
91 pub expires_secs: Option<i64>,
92}
93
94#[derive(Debug, Clone, Serialize, ToSchema)]
95pub struct RoomInviteDto {
96 pub id: Uuid,
97 pub room_id: Uuid,
98 pub inviter_id: Uuid,
99 pub invitee_user_id: Option<Uuid>,
100 pub accept_code: String,
101 pub created_at: OffsetDateTime,
102 pub expires_at: Option<OffsetDateTime>,
103}
104
105fn random_accept_code() -> String {
106 const CHARSET: &[u8] = b"abcdefghjkmnpqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789";
107 let mut rng = rand::thread_rng();
108 (0..12)
109 .map(|_| CHARSET[rng.gen_range(0..CHARSET.len())] as char)
110 .collect()
111}
112
113impl ChatService {
114 pub async fn list_space_rooms(
115 &self,
116 caller: Uuid,
117 space_name: &str,
118 ) -> Result<Vec<RoomBriefDto>, ChatError> {
119 let space = lookup_space_by_name(self, space_name).await?;
120 require_space_member(self, &space, caller).await?;
121
122 let rooms = rooms::Entity::find()
123 .filter(rooms::Column::SpaceId.eq(space.id))
124 .filter(rooms::Column::IsActive.eq(true))
125 .order_by_asc(rooms::Column::Position)
126 .all(self.db().read())
127 .await?;
128
129 let room_ids: Vec<Uuid> = rooms.iter().map(|r| r.id).collect();
130 let memberships = room_members::Entity::find()
131 .filter(room_members::Column::RoomId.is_in(room_ids.clone()))
132 .filter(room_members::Column::UserId.eq(caller))
133 .all(self.db().read())
134 .await?;
135 let member_of: std::collections::HashSet<Uuid> = memberships
136 .into_iter()
137 .filter(|m| m.left_at.is_none())
138 .map(|m| m.room_id)
139 .collect();
140
141 let settings = room_user_settings::Entity::find()
142 .filter(room_user_settings::Column::UserId.eq(caller))
143 .filter(room_user_settings::Column::RoomId.is_in(room_ids.clone()))
144 .all(self.db().read())
145 .await?;
146 let settings_map: std::collections::HashMap<Uuid, room_user_settings::Model> = settings
147 .into_iter()
148 .map(|s| (s.room_id, s))
149 .collect();
150
151 let read = read_states::Entity::find()
152 .filter(read_states::Column::UserId.eq(caller))
153 .filter(read_states::Column::RoomId.is_in(room_ids.clone()))
154 .all(self.db().read())
155 .await?;
156 let read_map: std::collections::HashMap<Uuid, i64> = read
157 .into_iter()
158 .map(|r| (r.room_id, r.last_read_seq))
159 .collect();
160
161 let seqs = room_seq::Entity::find()
162 .filter(room_seq::Column::RoomId.is_in(room_ids.clone()))
163 .all(self.db().read())
164 .await?;
165 let seq_map: std::collections::HashMap<Uuid, i64> =
166 seqs.into_iter().map(|s| (s.room_id, s.seq)).collect();
167
168 let mention_counts = models::users::user_recent_mentions::Entity::find()
169 .filter(models::users::user_recent_mentions::Column::UserId.eq(caller))
170 .filter(models::users::user_recent_mentions::Column::ChannelId.is_in(room_ids.clone()))
171 .all(self.db().read())
172 .await?;
173
174 let member_counts = room_members::Entity::find()
175 .filter(room_members::Column::RoomId.is_in(room_ids.clone()))
176 .filter(room_members::Column::LeftAt.is_null())
177 .all(self.db().read())
178 .await?;
179 let mut count_map: std::collections::HashMap<Uuid, i64> = std::collections::HashMap::new();
180 for m in member_counts {
181 *count_map.entry(m.room_id).or_default() += 1;
182 }
183
184 let mut result = Vec::new();
185 for room in rooms {
186 if room.is_private && !member_of.contains(&room.id) && room.owner_id != caller {
187 continue;
188 }
189 let last_read = read_map.get(&room.id).copied().unwrap_or(0);
190 let tip = seq_map.get(&room.id).copied().unwrap_or(0);
191 let unread_count = (tip - last_read).max(0);
192 let unread_mentions = mention_counts
193 .iter()
194 .filter(|m| m.channel_id == room.id)
195 .count() as i64;
196 let setting = settings_map.get(&room.id);
197 result.push(RoomBriefDto {
198 id: room.id,
199 space_id: room.space_id,
200 kind: room.kind,
201 name: room.name,
202 topic: room.topic,
203 position: room.position,
204 parent_id: room.parent_id,
205 is_private: room.is_private,
206 nsfw: room.nsfw,
207 rate_limit_per_user: room.rate_limit_per_user,
208 last_message_id: room.last_message_id,
209 unread_count,
210 unread_mentions,
211 favorite: setting.is_some_and(|s| s.favorite),
212 muted: setting.is_some_and(|s| s.muted),
213 hidden: setting.is_some_and(|s| s.hidden),
214 member_count: count_map.get(&room.id).copied().unwrap_or(0),
215 created_at: room.created_at,
216 });
217 }
218 Ok(result)
219 }
220
221 pub async fn get_room(
222 &self,
223 caller: Uuid,
224 room_id: Uuid,
225 ) -> Result<RoomBriefDto, ChatError> {
226 let ctx = require_room_readable(self, room_id, caller).await?;
227 let room = ctx.room;
228 let tip = room_seq::Entity::find_by_id(room.id)
229 .one(self.db().read())
230 .await?
231 .map(|s| s.seq)
232 .unwrap_or(0);
233 let last_read = read_states::Entity::find_by_id((caller, room.id))
234 .one(self.db().read())
235 .await?
236 .map(|r| r.last_read_seq)
237 .unwrap_or(0);
238 let setting = room_user_settings::Entity::find_by_id((caller, room.id))
239 .one(self.db().read())
240 .await?;
241 let member_count = self.room_member_count(room.id).await? as i64;
242 Ok(RoomBriefDto {
243 id: room.id,
244 space_id: room.space_id,
245 kind: room.kind,
246 name: room.name,
247 topic: room.topic,
248 position: room.position,
249 parent_id: room.parent_id,
250 is_private: room.is_private,
251 nsfw: room.nsfw,
252 rate_limit_per_user: room.rate_limit_per_user,
253 last_message_id: room.last_message_id,
254 unread_count: (tip - last_read).max(0),
255 unread_mentions: 0,
256 favorite: setting.as_ref().is_some_and(|s| s.favorite),
257 muted: setting.as_ref().is_some_and(|s| s.muted),
258 hidden: setting.as_ref().is_some_and(|s| s.hidden),
259 member_count,
260 created_at: room.created_at,
261 })
262 }
263
264 pub async fn update_room(
265 &self,
266 caller: Uuid,
267 room_id: Uuid,
268 cmd: UpdateRoomCmd,
269 ) -> Result<RoomBriefDto, ChatError> {
270 let ctx = require_room_readable(self, room_id, caller).await?;
271 let needs_manage = cmd.name.is_some() || cmd.is_private.is_some() || cmd.position.is_some();
272 if needs_manage {
273 match &ctx.space {
274 Some(space) => {
275 if !can_manage_space(self, space, caller).await? {
276 return Err(ChatError::Forbidden);
277 }
278 }
279 None => return Err(ChatError::Forbidden),
280 }
281 }
282
283 let now = OffsetDateTime::now_utc();
284 let room = ctx.room.clone();
285 let mut am: rooms::ActiveModel = room.clone().into();
286 let mut topic_changed: Option<(Option<String>, String)> = None;
287
288 if let Some(name) = cmd.name {
289 let name = name.trim().to_string();
290 if name.is_empty() || name.chars().count() > 100 {
291 return Err(ChatError::BadRequest("invalid room name".to_string()));
292 }
293 let lower = name.to_lowercase();
294 if !lower
295 .chars()
296 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
297 {
298 return Err(ChatError::BadRequest("invalid room name".to_string()));
299 }
300 am.name = Set(name);
301 am.lower_name = Set(lower);
302 }
303 if let Some(topic) = cmd.topic {
304 if topic.chars().count() > self.config().chat.max_topic_len {
305 return Err(ChatError::BadRequest("topic too long".to_string()));
306 }
307 if topic != room.topic.clone().unwrap_or_default() {
308 topic_changed = Some((room.topic.clone(), topic.clone()));
309 }
310 am.topic = Set(if topic.is_empty() { None } else { Some(topic) });
311 }
312 if let Some(is_private) = cmd.is_private {
313 am.is_private = Set(is_private);
314 }
315 if let Some(nsfw) = cmd.nsfw {
316 am.nsfw = Set(nsfw);
317 }
318 if let Some(rate) = cmd.rate_limit_per_user {
319 am.rate_limit_per_user = Set(Some(rate));
320 }
321 if let Some(position) = cmd.position {
322 am.position = Set(position);
323 }
324 am.updated_at = Set(now);
325 am.update(self.db().write()).await?;
326
327 if let Some((_old, new_topic)) = topic_changed {
328 let body = format!("set the channel topic to: {new_topic}");
329 let _ = self
330 .post_system_message(caller, room_id, ChatMessageType::ChannelTopic, body)
331 .await;
332 }
333
334 let event = RoomEvent::RoomUpdated { room_id };
335 self.realtime.publish_room(&event).await;
336
337 self.get_room(caller, room_id).await
338 }
339
340 pub(crate) async fn post_system_message(
341 &self,
342 author: Uuid,
343 room_id: Uuid,
344 mtype: ChatMessageType,
345 content: String,
346 ) -> Result<(), ChatError> {
347 let now = OffsetDateTime::now_utc();
348 let message_id = Uuid::now_v7();
349 let seq = self
350 .db()
351 .primary()
352 .transaction::<_, i64, ChatError>(move |txn| {
353 Box::pin(async move {
354 let seq = next_room_seq(txn, room_id).await?;
355 messages::ActiveModel {
356 id: Set(message_id),
357 room_id: Set(room_id),
358 author_id: Set(author),
359 author_type: Set(models::chat::messages::AuthorType::User),
360 webhook_id: Set(None),
361 thread_id: Set(None),
362 thread_broadcast: Set(false),
363 reply_to_id: Set(None),
364 forwarded_from_id: Set(None),
365 message_type: Set(mtype),
366 content: Set(content),
367 mentions: Set(serde_json::json!([])),
368 mention_everyone: Set(false),
369 mention_group_ids: Set(serde_json::json!([])),
370 attachments: Set(serde_json::json!([])),
371 embeds: Set(serde_json::json!([])),
372 flags: Set(0),
373 nonce: Set(None),
374 pinned: Set(false),
375 pinned_by: Set(None),
376 pinned_at: Set(None),
377 edited_at: Set(None),
378 deleted_at: Set(None),
379 seq: Set(seq),
380 created_at: Set(now),
381 }
382 .insert(txn)
383 .await?;
384 rooms::Entity::update_many()
385 .filter(rooms::Column::Id.eq(room_id))
386 .col_expr(rooms::Column::LastMessageId, Expr::value(message_id))
387 .col_expr(rooms::Column::UpdatedAt, Expr::value(now))
388 .exec(txn)
389 .await?;
390 Ok(seq)
391 })
392 })
393 .await?;
394 let row = messages::Entity::find_by_id(message_id)
395 .one(self.db().read())
396 .await?;
397 if let Some(row) = row {
398 let items = self.build_message_dto(vec![row], author).await?;
399 if let Some(dto) = items.into_iter().next() {
400 let value = serde_json::to_value(&dto)?;
401 crate::share::notify::publish_message_event(self, room_id, seq, value).await;
402 }
403 }
404 Ok(())
405 }
406
407 pub async fn join_room(&self, caller: Uuid, room_id: Uuid) -> Result<(), ChatError> {
408 let room = lookup_room(self, room_id).await?;
409 let ctx = load_room_context(self, room).await?;
410 match &ctx.space {
411 Some(space) => {
412 require_space_member(self, space, caller).await?;
413 if ctx.room.is_private {
414 return Err(ChatError::Forbidden);
415 }
416 }
417 None => return Err(ChatError::Forbidden),
418 }
419
420 let now = OffsetDateTime::now_utc();
421 let existing = room_members::Entity::find_by_id((room_id, caller))
422 .one(self.db().write())
423 .await?;
424 match existing {
425 Some(row) if row.left_at.is_some() => {
426 let mut am: room_members::ActiveModel = row.into();
427 am.left_at = Set(None);
428 am.updated_at = Set(Some(now));
429 am.update(self.db().write()).await?;
430 }
431 Some(_) => return Ok(()),
432 None => {
433 room_members::ActiveModel {
434 room_id: Set(room_id),
435 user_id: Set(caller),
436 nick: Set(None),
437 joined_at: Set(now),
438 left_at: Set(None),
439 created_at: Set(now),
440 updated_at: Set(Some(now)),
441 }
442 .insert(self.db().write())
443 .await?;
444 }
445 }
446 let event = RoomEvent::MemberJoined {
447 room_id,
448 user_id: caller,
449 };
450 self.realtime.publish_room(&event).await;
451 Ok(())
452 }
453
454 pub async fn leave_room(&self, caller: Uuid, room_id: Uuid) -> Result<(), ChatError> {
455 let room = lookup_room(self, room_id).await?;
456 if room.space_id.is_none() {
457 return Err(ChatError::BadRequest("use DM close instead".to_string()));
458 }
459 if room.owner_id == caller {
460 return Err(ChatError::Forbidden);
461 }
462 let now = OffsetDateTime::now_utc();
463 let row = room_members::Entity::find_by_id((room_id, caller))
464 .one(self.db().write())
465 .await?
466 .ok_or(ChatError::NotFound)?;
467 if row.left_at.is_none() {
468 let mut am: room_members::ActiveModel = row.into();
469 am.left_at = Set(Some(now));
470 am.updated_at = Set(Some(now));
471 am.update(self.db().write()).await?;
472 let event = RoomEvent::MemberLeft {
473 room_id,
474 user_id: caller,
475 };
476 self.realtime.publish_room(&event).await;
477 }
478 Ok(())
479 }
480
481 pub async fn add_room_members(
482 &self,
483 caller: Uuid,
484 room_id: Uuid,
485 cmd: AddMembersCmd,
486 ) -> Result<(), ChatError> {
487 let ctx = require_room_readable(self, room_id, caller).await?;
488 match &ctx.space {
489 Some(space) => {
490 if !can_manage_space(self, space, caller).await? {
491 return Err(ChatError::Forbidden);
492 }
493 }
494 None => {
495 if !is_room_member(self, room_id, caller).await? {
496 return Err(ChatError::Forbidden);
497 }
498 }
499 }
500
501 let now = OffsetDateTime::now_utc();
502 for uid in cmd.user_ids {
503 if let Some(space) = &ctx.space
504 && require_space_member(self, space, uid).await.is_err()
505 {
506 continue;
507 }
508 let existing = room_members::Entity::find_by_id((room_id, uid))
509 .one(self.db().write())
510 .await?;
511 match existing {
512 Some(row) if row.left_at.is_some() => {
513 let mut am: room_members::ActiveModel = row.into();
514 am.left_at = Set(None);
515 am.updated_at = Set(Some(now));
516 am.update(self.db().write()).await?;
517 }
518 Some(_) => {}
519 None => {
520 room_members::ActiveModel {
521 room_id: Set(room_id),
522 user_id: Set(uid),
523 nick: Set(None),
524 joined_at: Set(now),
525 left_at: Set(None),
526 created_at: Set(now),
527 updated_at: Set(Some(now)),
528 }
529 .insert(self.db().write())
530 .await?;
531 }
532 }
533 let event = RoomEvent::MemberJoined {
534 room_id,
535 user_id: uid,
536 };
537 self.realtime.publish_room(&event).await;
538 self.realtime
539 .publish_user(uid, &serde_json::json!({"kind": "room_added", "room_id": room_id}))
540 .await;
541 }
542 Ok(())
543 }
544
545 pub async fn list_room_members(
546 &self,
547 caller: Uuid,
548 room_id: Uuid,
549 q: RoomMemberQuery,
550 ) -> Result<Vec<RoomMemberDto>, ChatError> {
551 let _ctx = require_room_readable(self, room_id, caller).await?;
552 let limit = q
553 .limit
554 .unwrap_or(self.config().chat.default_pagination)
555 .clamp(1, self.config().chat.max_pagination);
556 let rows = room_members::Entity::find()
557 .filter(room_members::Column::RoomId.eq(room_id))
558 .filter(room_members::Column::LeftAt.is_null())
559 .order_by_asc(room_members::Column::JoinedAt)
560 .limit(limit)
561 .offset(q.offset.unwrap_or(0))
562 .all(self.db().read())
563 .await?;
564 let uids: Vec<Uuid> = rows.iter().map(|r| r.user_id).collect();
565 let user_map = self.load_user_briefs(&uids).await?;
566 Ok(rows
567 .into_iter()
568 .filter_map(|r| {
569 user_map.get(&r.user_id).map(|u| RoomMemberDto {
570 user: u.clone(),
571 nick: r.nick,
572 joined_at: r.joined_at,
573 })
574 })
575 .collect())
576 }
577
578 pub async fn create_room_invite(
579 &self,
580 caller: Uuid,
581 room_id: Uuid,
582 cmd: CreateRoomInviteCmd,
583 ) -> Result<RoomInviteDto, ChatError> {
584 let ctx = require_room_readable(self, room_id, caller).await?;
585 if ctx.room.space_id.is_none() {
586 return Err(ChatError::BadRequest("cannot invite to a DM".to_string()));
587 }
588 let now = OffsetDateTime::now_utc();
589 let id = Uuid::now_v7();
590 let accept_code = random_accept_code();
591 let expires_at = cmd.expires_secs.map(|secs| now + time::Duration::seconds(secs));
592 let row = room_invites::ActiveModel {
593 id: Set(id),
594 room_id: Set(room_id),
595 inviter_id: Set(caller),
596 invitee_user_id: Set(cmd.invitee_user_id),
597 invitee_email: Set(None),
598 accept_code: Set(accept_code.clone()),
599 created_at: Set(now),
600 expires_at: Set(expires_at),
601 accepted_at: Set(None),
602 revoked_at: Set(None),
603 }
604 .insert(self.db().write())
605 .await?;
606 if let Some(invitee) = cmd.invitee_user_id {
607 let data = serde_json::json!({"room_id": room_id, "invite_id": id});
608 notify_user(
609 self.db().write(),
610 invitee,
611 NotificationType::Invite,
612 "You were invited to a channel".to_string(),
613 None,
614 Some(data),
615 Some(caller),
616 )
617 .await?;
618 }
619 Ok(RoomInviteDto {
620 id: row.id,
621 room_id: row.room_id,
622 inviter_id: row.inviter_id,
623 invitee_user_id: row.invitee_user_id,
624 accept_code,
625 created_at: row.created_at,
626 expires_at: row.expires_at,
627 })
628 }
629
630 pub async fn accept_room_invite(
631 &self,
632 caller: Uuid,
633 accept_code: &str,
634 ) -> Result<RoomBriefDto, ChatError> {
635 let invite = room_invites::Entity::find()
636 .filter(room_invites::Column::AcceptCode.eq(accept_code))
637 .one(self.db().write())
638 .await?
639 .ok_or(ChatError::NotFound)?;
640 if invite.accepted_at.is_some() || invite.revoked_at.is_some() {
641 return Err(ChatError::NotFound);
642 }
643 if invite.expires_at.is_some_and(|at| at < OffsetDateTime::now_utc()) {
644 return Err(ChatError::NotFound);
645 }
646 if invite.invitee_user_id.is_some_and(|uid| uid != caller) {
647 return Err(ChatError::Forbidden);
648 }
649 let room = lookup_room(self, invite.room_id).await?;
650 if let Some(space_id) = room.space_id
651 && require_space_member(
652 self,
653 &models::space::spaces::Entity::find_by_id(space_id)
654 .one(self.db().read())
655 .await?
656 .ok_or(ChatError::NotFound)?,
657 caller,
658 )
659 .await
660 .is_err()
661 {
662 return Err(ChatError::Forbidden);
663 }
664
665 let now = OffsetDateTime::now_utc();
666 let mut am: room_invites::ActiveModel = invite.into();
667 am.accepted_at = Set(Some(now));
668 am.update(self.db().write()).await?;
669
670 self.add_room_members(
671 caller,
672 room.id,
673 AddMembersCmd {
674 user_ids: vec![caller],
675 },
676 )
677 .await?;
678 self.get_room(caller, room.id).await
679 }
680}
Added lib/chat/src/services/search.rs lib/chat/src/services/search.rs +138 −0 View lib/chat/src/services/search.rs
@@ -0,0 +1,138 @@
1use sea_orm::{DatabaseBackend, FromQueryResult, Statement};
2use serde::{Deserialize, Serialize};
3use time::OffsetDateTime;
4use utoipa::{IntoParams, ToSchema};
5use uuid::Uuid;
6
7use crate::errors::ChatError;
8use crate::services::service::ChatService;
9use crate::share::lookup::{lookup_space_by_name, require_space_member};
10
11#[derive(Debug, Clone, Serialize, ToSchema)]
12pub struct SearchHitDto {
13 pub message_id: Uuid,
14 pub room_id: Uuid,
15 pub author_id: Uuid,
16 pub seq: i64,
17 pub content: String,
18 pub created_at: OffsetDateTime,
19}
20
21#[derive(Debug, Clone, Serialize, ToSchema)]
22pub struct SearchPage {
23 pub items: Vec<SearchHitDto>,
24 pub total: i64,
25}
26
27#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
28pub struct SearchQuery {
29 pub q: String,
30 pub room_id: Option<Uuid>,
31 pub author_id: Option<Uuid>,
32 pub limit: Option<u64>,
33 pub offset: Option<u64>,
34}
35
36#[derive(Debug, FromQueryResult)]
37struct SearchRow {
38 message_id: Uuid,
39 room_id: Uuid,
40 author_id: Uuid,
41 seq: i64,
42 content: String,
43 created_at: OffsetDateTime,
44}
45
46#[derive(Debug, FromQueryResult)]
47struct CountRow {
48 total: i64,
49}
50
51impl ChatService {
52 pub async fn search_messages(
53 &self,
54 caller: Uuid,
55 space_name: &str,
56 q: SearchQuery,
57 ) -> Result<SearchPage, ChatError> {
58 let space = lookup_space_by_name(self, space_name).await?;
59 require_space_member(self, &space, caller).await?;
60
61 let query = q.q.trim();
62 if query.is_empty() {
63 return Err(ChatError::BadRequest("query is empty".to_string()));
64 }
65 let limit = q
66 .limit
67 .unwrap_or(self.config().chat.default_pagination)
68 .clamp(1, self.config().chat.max_pagination);
69 let offset = q.offset.unwrap_or(0);
70
71 let room_filter = q
72 .room_id
73 .map(|id| format!("AND m.room_id = '{}'", id.as_simple()))
74 .unwrap_or_default();
75 let author_filter = q
76 .author_id
77 .map(|id| format!("AND m.author_id = '{}'", id.as_simple()))
78 .unwrap_or_default();
79
80 let count_sql = format!(
81 r#"SELECT count(*)::bigint AS total
82 FROM chat_message m
83 JOIN rooms r ON r.id = m.room_id
84 WHERE r.space_id = $1
85 AND m.deleted_at IS NULL
86 AND m.search_vector @@ plainto_tsquery('simple', $2)
87 {room_filter} {author_filter}"#
88 );
89 let count_row = CountRow::find_by_statement(Statement::from_sql_and_values(
90 DatabaseBackend::Postgres,
91 &count_sql,
92 [space.id.into(), query.into()],
93 ))
94 .one(self.db().read())
95 .await?
96 .ok_or(ChatError::Internal("count query returned no row".into()))?;
97
98 let sql = format!(
99 r#"SELECT m.id AS message_id, m.room_id, m.author_id, m.seq, m.content, m.created_at
100 FROM chat_message m
101 JOIN rooms r ON r.id = m.room_id
102 WHERE r.space_id = $1
103 AND m.deleted_at IS NULL
104 AND m.search_vector @@ plainto_tsquery('simple', $2)
105 {room_filter} {author_filter}
106 ORDER BY ts_rank(m.search_vector, plainto_tsquery('simple', $2)) DESC, m.created_at DESC
107 LIMIT $3 OFFSET $4"#
108 );
109 let rows = SearchRow::find_by_statement(Statement::from_sql_and_values(
110 DatabaseBackend::Postgres,
111 &sql,
112 [
113 space.id.into(),
114 query.into(),
115 (limit as i64).into(),
116 (offset as i64).into(),
117 ],
118 ))
119 .all(self.db().read())
120 .await?;
121
122 let items = rows
123 .into_iter()
124 .map(|r| SearchHitDto {
125 message_id: r.message_id,
126 room_id: r.room_id,
127 author_id: r.author_id,
128 seq: r.seq,
129 content: r.content,
130 created_at: r.created_at,
131 })
132 .collect();
133 Ok(SearchPage {
134 items,
135 total: count_row.total,
136 })
137 }
138}
Added lib/chat/src/services/service.rs lib/chat/src/services/service.rs +132 −0 View lib/chat/src/services/service.rs
@@ -0,0 +1,132 @@
1use std::collections::HashMap;
2use std::sync::Arc;
3
4use async_trait::async_trait;
5use config::AppConfig;
6use export::AppState;
7use export::database::AppDatabase;
8use export::storage::AppStorage;
9use models::users::user;
10use models::users::user_emails;
11use models::users::user_sessions;
12use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
13use session::SessionError;
14use session::share::guard::{SessionGuard, SessionWithUser};
15use space::services::spaces::UserBriefDto;
16use uuid::Uuid;
17
18use crate::errors::ChatError;
19use crate::share::realtime::Realtime;
20
21#[derive(Clone)]
22pub struct ChatService {
23 pub state: AppState,
24 pub realtime: Realtime,
25}
26
27impl ChatService {
28 pub fn new(state: AppState) -> Self {
29 let realtime = Realtime::new(state.redis.clone()).expect("chat realtime client");
30 Self { state, realtime }
31 }
32
33 pub fn config(&self) -> &AppConfig {
34 &self.state.config
35 }
36
37 pub fn db(&self) -> &Arc<AppDatabase> {
38 &self.state.database
39 }
40
41 pub fn storage(&self) -> &AppStorage {
42 &self.state.storage
43 }
44
45 pub fn ticket_secret(&self) -> String {
46 if !self.config().chat.ticket_secret.is_empty() {
47 return self.config().chat.ticket_secret.clone();
48 }
49 self.config().session.key.clone().unwrap_or_default()
50 }
51
52 pub async fn load_user_briefs(
53 &self,
54 uids: &[Uuid],
55 ) -> Result<HashMap<Uuid, UserBriefDto>, ChatError> {
56 if uids.is_empty() {
57 return Ok(HashMap::new());
58 }
59 let mut set: Vec<Uuid> = uids.to_vec();
60 set.sort();
61 set.dedup();
62 let users = user::Entity::find()
63 .filter(user::Column::Uid.is_in(set))
64 .all(self.db().read())
65 .await?;
66 let map = users
67 .into_iter()
68 .map(|u| (u.uid, UserBriefDto::from(u)))
69 .collect();
70 Ok(map)
71 }
72
73 pub(crate) async fn primary_emails(
74 &self,
75 uids: &[Uuid],
76 ) -> Result<HashMap<Uuid, String>, ChatError> {
77 if uids.is_empty() {
78 return Ok(HashMap::new());
79 }
80 let rows = user_emails::Entity::find()
81 .filter(user_emails::Column::UserId.is_in(uids.to_vec()))
82 .filter(user_emails::Column::IsPrimary.eq(true))
83 .filter(user_emails::Column::IsActivated.eq(true))
84 .filter(user_emails::Column::DeletedAt.is_null())
85 .all(self.db().read())
86 .await?;
87 let map = rows
88 .into_iter()
89 .map(|r| (r.user_id, r.lower_email))
90 .collect();
91 Ok(map)
92 }
93
94 pub(crate) async fn send_email(
95 &self,
96 to: &str,
97 subject: &str,
98 text: &str,
99 html: &str,
100 ) -> Result<(), ChatError> {
101 self.state
102 .email
103 .send_mail(to, subject, text, html)
104 .await
105 .map_err(|error| ChatError::Internal(format!("email send failed: {error}")))
106 }
107}
108
109#[async_trait]
110impl SessionGuard for ChatService {
111 async fn lookup_session_with_user(
112 &self,
113 token_hash: &str,
114 ) -> Result<Option<SessionWithUser>, SessionError> {
115 let outcome = user_sessions::Entity::find()
116 .filter(user_sessions::Column::TokenHash.eq(token_hash))
117 .find_also_related(user::Entity)
118 .one(self.db().read())
119 .await?;
120 Ok(outcome.and_then(|(row, current)| {
121 current.map(|c| SessionWithUser {
122 user_id: row.user_id,
123 is_revoked: row.is_revoked,
124 expires_at: row.expires_at,
125 is_active: c.is_active,
126 prohibit_login: c.prohibit_login,
127 is_restricted: c.is_restricted,
128 passwd_version: c.passwd_version,
129 })
130 }))
131 }
132}
Added lib/chat/src/services/settings.rs lib/chat/src/services/settings.rs +129 −0 View lib/chat/src/services/settings.rs
@@ -0,0 +1,129 @@
1use models::chat::room_user_settings::{self, RoomNotificationLevel};
2use sea_orm::{ActiveModelTrait, EntityTrait, Set};
3use serde::{Deserialize, Serialize};
4use time::OffsetDateTime;
5use utoipa::ToSchema;
6use uuid::Uuid;
7
8use crate::errors::ChatError;
9use crate::services::service::ChatService;
10use crate::share::lookup::require_room_readable;
11
12#[derive(Debug, Clone, Serialize, ToSchema)]
13pub struct RoomSettingDto {
14 pub room_id: Uuid,
15 pub notification_level: RoomNotificationLevel,
16 pub muted: bool,
17 pub mute_until: Option<OffsetDateTime>,
18 pub favorite: bool,
19 pub hidden: bool,
20}
21
22#[derive(Debug, Clone, Deserialize, ToSchema)]
23pub struct UpdateRoomSettingCmd {
24 #[serde(default)]
25 pub notification_level: Option<RoomNotificationLevel>,
26 #[serde(default)]
27 pub muted: Option<bool>,
28 #[serde(default)]
29 pub mute_minutes: Option<i64>,
30 #[serde(default)]
31 pub favorite: Option<bool>,
32 #[serde(default)]
33 pub hidden: Option<bool>,
34}
35
36impl From<room_user_settings::Model> for RoomSettingDto {
37 fn from(s: room_user_settings::Model) -> Self {
38 Self {
39 room_id: s.room_id,
40 notification_level: s.notification_level,
41 muted: s.muted,
42 mute_until: s.mute_until,
43 favorite: s.favorite,
44 hidden: s.hidden,
45 }
46 }
47}
48
49impl ChatService {
50 pub async fn get_room_setting(
51 &self,
52 caller: Uuid,
53 room_id: Uuid,
54 ) -> Result<RoomSettingDto, ChatError> {
55 let _ctx = require_room_readable(self, room_id, caller).await?;
56 let row = room_user_settings::Entity::find_by_id((caller, room_id))
57 .one(self.db().read())
58 .await?;
59 match row {
60 Some(row) => Ok(RoomSettingDto::from(row)),
61 None => Ok(RoomSettingDto {
62 room_id,
63 notification_level: RoomNotificationLevel::Default,
64 muted: false,
65 mute_until: None,
66 favorite: false,
67 hidden: false,
68 }),
69 }
70 }
71
72 pub async fn update_room_setting(
73 &self,
74 caller: Uuid,
75 room_id: Uuid,
76 cmd: UpdateRoomSettingCmd,
77 ) -> Result<RoomSettingDto, ChatError> {
78 let _ctx = require_room_readable(self, room_id, caller).await?;
79 let now = OffsetDateTime::now_utc();
80 let existing = room_user_settings::Entity::find_by_id((caller, room_id))
81 .one(self.db().write())
82 .await?;
83
84 let mut am: room_user_settings::ActiveModel = match existing {
85 Some(row) => row.into(),
86 None => room_user_settings::ActiveModel {
87 user_id: Set(caller),
88 room_id: Set(room_id),
89 notification_level: Set(RoomNotificationLevel::Default),
90 muted: Set(false),
91 mute_until: Set(None),
92 favorite: Set(false),
93 hidden: Set(false),
94 updated_at: Set(now),
95 },
96 };
97 if let Some(level) = cmd.notification_level {
98 am.notification_level = Set(level);
99 }
100 if let Some(muted) = cmd.muted {
101 am.muted = Set(muted);
102 if !muted {
103 am.mute_until = Set(None);
104 }
105 }
106 if let Some(minutes) = cmd.mute_minutes {
107 am.mute_until = Set(if minutes > 0 {
108 Some(now + time::Duration::minutes(minutes))
109 } else {
110 None
111 });
112 am.muted = Set(minutes > 0);
113 }
114 if let Some(favorite) = cmd.favorite {
115 am.favorite = Set(favorite);
116 }
117 if let Some(hidden) = cmd.hidden {
118 am.hidden = Set(hidden);
119 }
120 am.updated_at = Set(now);
121 am.save(self.db().write()).await?;
122
123 let row = room_user_settings::Entity::find_by_id((caller, room_id))
124 .one(self.db().read())
125 .await?
126 .ok_or(ChatError::Internal("setting vanished".into()))?;
127 Ok(RoomSettingDto::from(row))
128 }
129}
Added lib/chat/src/services/threads.rs lib/chat/src/services/threads.rs +497 −0 View lib/chat/src/services/threads.rs
@@ -0,0 +1,497 @@
1use models::chat::messages::{self, FLAG_HAS_THREAD};
2use models::chat::thread_members::{self, ThreadNotifyLevel};
3use models::chat::threads;
4use models::room::room_members;
5use sea_orm::sea_query::Expr;
6use sea_orm::{
7 ActiveModelTrait, ColumnTrait, ConnectionTrait, EntityTrait, ExprTrait, PaginatorTrait,
8 QueryFilter, QueryOrder, QuerySelect, Set, TransactionTrait,
9};
10use serde::{Deserialize, Serialize};
11use space::services::spaces::UserBriefDto;
12use time::OffsetDateTime;
13use utoipa::{IntoParams, ToSchema};
14use uuid::Uuid;
15
16use crate::errors::ChatError;
17use crate::services::service::ChatService;
18use crate::share::authz::can_manage_space;
19use crate::share::lookup::require_room_readable;
20use crate::share::realtime::RoomEvent;
21
22#[derive(Debug, Clone, Serialize, ToSchema)]
23pub struct ThreadDto {
24 pub id: Uuid,
25 pub room_id: Uuid,
26 pub root_message_id: Uuid,
27 pub owner: Option<UserBriefDto>,
28 pub owner_id: Uuid,
29 pub name: Option<String>,
30 pub reply_count: i64,
31 pub participant_count: i64,
32 pub last_reply_id: Option<Uuid>,
33 pub last_reply_at: Option<OffsetDateTime>,
34 pub archived: bool,
35 pub locked: bool,
36 pub created_at: OffsetDateTime,
37 pub updated_at: OffsetDateTime,
38}
39
40#[derive(Debug, Clone, Serialize, ToSchema)]
41pub struct ThreadPage {
42 pub items: Vec<ThreadDto>,
43 pub total: i64,
44}
45
46#[derive(Debug, Clone, Deserialize, ToSchema)]
47pub struct CreateThreadCmd {
48 #[serde(default)]
49 pub name: Option<String>,
50}
51
52#[derive(Debug, Clone, Deserialize, ToSchema)]
53pub struct UpdateThreadCmd {
54 #[serde(default)]
55 pub name: Option<String>,
56 #[serde(default)]
57 pub archived: Option<bool>,
58 #[serde(default)]
59 pub locked: Option<bool>,
60}
61
62#[derive(Debug, Clone, Deserialize, ToSchema)]
63pub struct ThreadMemberCmd {
64 #[serde(default)]
65 pub notify_level: Option<ThreadNotifyLevel>,
66}
67
68#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
69pub struct ListThreadQuery {
70 pub archived: Option<bool>,
71 pub limit: Option<u64>,
72 pub offset: Option<u64>,
73}
74
75pub(crate) async fn maintain_thread_on_reply(
76 txn: &impl ConnectionTrait,
77 thread: &threads::Model,
78 message_id: Uuid,
79 author: Uuid,
80 now: OffsetDateTime,
81) -> Result<(), ChatError> {
82 threads::Entity::update_many()
83 .filter(threads::Column::Id.eq(thread.id))
84 .col_expr(
85 threads::Column::ReplyCount,
86 Expr::col(threads::Column::ReplyCount).add(1),
87 )
88 .col_expr(threads::Column::LastReplyId, Expr::value(message_id))
89 .col_expr(threads::Column::LastReplyAt, Expr::value(now))
90 .col_expr(threads::Column::UpdatedAt, Expr::value(now))
91 .exec(txn)
92 .await?;
93
94 let member = thread_members::Entity::find_by_id((thread.id, author))
95 .one(txn)
96 .await?;
97 match member {
98 Some(_) => {}
99 None => {
100 thread_members::ActiveModel {
101 thread_id: Set(thread.id),
102 user_id: Set(author),
103 notify_level: Set(ThreadNotifyLevel::All),
104 last_read_message_id: Set(None),
105 last_read_seq: Set(0),
106 created_at: Set(now),
107 updated_at: Set(now),
108 }
109 .insert(txn)
110 .await?;
111 threads::Entity::update_many()
112 .filter(threads::Column::Id.eq(thread.id))
113 .col_expr(
114 threads::Column::ParticipantCount,
115 Expr::col(threads::Column::ParticipantCount).add(1),
116 )
117 .exec(txn)
118 .await?;
119 }
120 }
121 Ok(())
122}
123
124impl ChatService {
125 async fn build_thread_dto(
126 &self,
127 rows: Vec<threads::Model>,
128 ) -> Result<Vec<ThreadDto>, ChatError> {
129 let owner_ids: Vec<Uuid> = rows.iter().map(|t| t.owner_id).collect();
130 let user_map = self.load_user_briefs(&owner_ids).await?;
131 Ok(rows
132 .into_iter()
133 .map(|t| ThreadDto {
134 id: t.id,
135 room_id: t.room_id,
136 root_message_id: t.root_message_id,
137 owner: user_map.get(&t.owner_id).cloned(),
138 owner_id: t.owner_id,
139 name: t.name,
140 reply_count: t.reply_count,
141 participant_count: t.participant_count,
142 last_reply_id: t.last_reply_id,
143 last_reply_at: t.last_reply_at,
144 archived: t.archived,
145 locked: t.locked,
146 created_at: t.created_at,
147 updated_at: t.updated_at,
148 })
149 .collect())
150 }
151
152 pub async fn create_thread(
153 &self,
154 caller: Uuid,
155 room_id: Uuid,
156 message_id: Uuid,
157 cmd: CreateThreadCmd,
158 ) -> Result<ThreadDto, ChatError> {
159 let _ctx = require_room_readable(self, room_id, caller).await?;
160
161 let root = messages::Entity::find_by_id(message_id)
162 .filter(messages::Column::RoomId.eq(room_id))
163 .one(self.db().read())
164 .await?
165 .filter(|m| m.deleted_at.is_none())
166 .ok_or(ChatError::NotFound)?;
167
168 let existing = threads::Entity::find()
169 .filter(threads::Column::RootMessageId.eq(message_id))
170 .one(self.db().read())
171 .await?;
172 if let Some(existing) = existing {
173 let items = self.build_thread_dto(vec![existing]).await?;
174 return Ok(items.into_iter().next().unwrap());
175 }
176
177 let now = OffsetDateTime::now_utc();
178 let thread_id = Uuid::now_v7();
179 let name = cmd
180 .name
181 .clone()
182 .map(|n| n.trim().to_string())
183 .filter(|n| !n.is_empty());
184 if name
185 .as_ref()
186 .is_some_and(|n| n.chars().count() > self.config().chat.max_thread_name_len)
187 {
188 return Err(ChatError::BadRequest("thread name too long".to_string()));
189 }
190
191 self.db()
192 .primary()
193 .transaction::<_, (), ChatError>(move |txn| {
194 Box::pin(async move {
195 threads::ActiveModel {
196 id: Set(thread_id),
197 room_id: Set(room_id),
198 root_message_id: Set(message_id),
199 owner_id: Set(caller),
200 name: Set(name),
201 reply_count: Set(0),
202 participant_count: Set(1),
203 last_reply_id: Set(None),
204 last_reply_at: Set(None),
205 archived: Set(false),
206 locked: Set(false),
207 auto_archive_minutes: Set(None),
208 created_at: Set(now),
209 updated_at: Set(now),
210 }
211 .insert(txn)
212 .await?;
213
214 thread_members::ActiveModel {
215 thread_id: Set(thread_id),
216 user_id: Set(caller),
217 notify_level: Set(ThreadNotifyLevel::All),
218 last_read_message_id: Set(None),
219 last_read_seq: Set(0),
220 created_at: Set(now),
221 updated_at: Set(now),
222 }
223 .insert(txn)
224 .await?;
225
226 let new_flags = root.flags | FLAG_HAS_THREAD;
227 messages::Entity::update_many()
228 .filter(messages::Column::Id.eq(message_id))
229 .col_expr(messages::Column::Flags, Expr::value(new_flags))
230 .exec(txn)
231 .await?;
232
233 Ok(())
234 })
235 })
236 .await?;
237
238 let thread = threads::Entity::find_by_id(thread_id)
239 .one(self.db().read())
240 .await?
241 .ok_or(ChatError::Internal("thread vanished after insert".into()))?;
242 let event = RoomEvent::ThreadUpdated { room_id, thread_id };
243 self.realtime.publish_room(&event).await;
244
245 let items = self.build_thread_dto(vec![thread]).await?;
246 Ok(items.into_iter().next().unwrap())
247 }
248
249 pub async fn list_threads(
250 &self,
251 caller: Uuid,
252 room_id: Uuid,
253 q: ListThreadQuery,
254 ) -> Result<ThreadPage, ChatError> {
255 let _ctx = require_room_readable(self, room_id, caller).await?;
256 let limit = q
257 .limit
258 .unwrap_or(self.config().chat.default_pagination)
259 .clamp(1, self.config().chat.max_pagination);
260 let offset = q.offset.unwrap_or(0);
261
262 let mut select = threads::Entity::find().filter(threads::Column::RoomId.eq(room_id));
263 if let Some(archived) = q.archived {
264 select = select.filter(threads::Column::Archived.eq(archived));
265 }
266 let total = select.clone().count(self.db().read()).await? as i64;
267 let rows = select
268 .order_by_desc(threads::Column::LastReplyAt)
269 .order_by_desc(threads::Column::CreatedAt)
270 .limit(limit)
271 .offset(offset)
272 .all(self.db().read())
273 .await?;
274 let items = self.build_thread_dto(rows).await?;
275 Ok(ThreadPage { items, total })
276 }
277
278 pub async fn list_my_threads(&self, caller: Uuid) -> Result<Vec<ThreadDto>, ChatError> {
279 let memberships = thread_members::Entity::find()
280 .filter(thread_members::Column::UserId.eq(caller))
281 .order_by_desc(thread_members::Column::UpdatedAt)
282 .limit(100)
283 .all(self.db().read())
284 .await?;
285 let thread_ids: Vec<Uuid> = memberships.into_iter().map(|m| m.thread_id).collect();
286 if thread_ids.is_empty() {
287 return Ok(Vec::new());
288 }
289 let rows = threads::Entity::find()
290 .filter(threads::Column::Id.is_in(thread_ids))
291 .all(self.db().read())
292 .await?;
293 self.build_thread_dto(rows).await
294 }
295
296 pub async fn get_thread(
297 &self,
298 caller: Uuid,
299 thread_id: Uuid,
300 ) -> Result<ThreadDto, ChatError> {
301 let thread = threads::Entity::find_by_id(thread_id)
302 .one(self.db().read())
303 .await?
304 .ok_or(ChatError::NotFound)?;
305 let _ctx = require_room_readable(self, thread.room_id, caller).await?;
306 let items = self.build_thread_dto(vec![thread]).await?;
307 Ok(items.into_iter().next().unwrap())
308 }
309
310 pub async fn update_thread(
311 &self,
312 caller: Uuid,
313 thread_id: Uuid,
314 cmd: UpdateThreadCmd,
315 ) -> Result<ThreadDto, ChatError> {
316 let thread = threads::Entity::find_by_id(thread_id)
317 .one(self.db().read())
318 .await?
319 .ok_or(ChatError::NotFound)?;
320 let ctx = require_room_readable(self, thread.room_id, caller).await?;
321
322 let is_manager = match &ctx.space {
323 Some(space) => can_manage_space(self, space, caller).await?,
324 None => false,
325 };
326 if cmd.locked.is_some() && thread.owner_id != caller && !is_manager {
327 return Err(ChatError::Forbidden);
328 }
329
330 let now = OffsetDateTime::now_utc();
331 let mut am: threads::ActiveModel = thread.clone().into();
332 if let Some(name) = cmd.name {
333 let name = name.trim().to_string();
334 if name.chars().count() > self.config().chat.max_thread_name_len {
335 return Err(ChatError::BadRequest("thread name too long".to_string()));
336 }
337 am.name = Set(if name.is_empty() { None } else { Some(name) });
338 }
339 if let Some(archived) = cmd.archived {
340 am.archived = Set(archived);
341 }
342 if let Some(locked) = cmd.locked {
343 am.locked = Set(locked);
344 }
345 am.updated_at = Set(now);
346 am.update(self.db().write()).await?;
347
348 let event = RoomEvent::ThreadUpdated {
349 room_id: thread.room_id,
350 thread_id,
351 };
352 self.realtime.publish_room(&event).await;
353
354 let updated = threads::Entity::find_by_id(thread_id)
355 .one(self.db().read())
356 .await?
357 .ok_or(ChatError::NotFound)?;
358 let items = self.build_thread_dto(vec![updated]).await?;
359 Ok(items.into_iter().next().unwrap())
360 }
361
362 pub async fn set_thread_membership(
363 &self,
364 caller: Uuid,
365 thread_id: Uuid,
366 cmd: ThreadMemberCmd,
367 ) -> Result<(), ChatError> {
368 let thread = threads::Entity::find_by_id(thread_id)
369 .one(self.db().read())
370 .await?
371 .ok_or(ChatError::NotFound)?;
372 let _ctx = require_room_readable(self, thread.room_id, caller).await?;
373
374 let now = OffsetDateTime::now_utc();
375 let notify_level = cmd.notify_level.unwrap_or(ThreadNotifyLevel::All);
376 let member = thread_members::Entity::find_by_id((thread_id, caller))
377 .one(self.db().write())
378 .await?;
379 match member {
380 Some(row) => {
381 let mut am: thread_members::ActiveModel = row.into();
382 am.notify_level = Set(notify_level);
383 am.updated_at = Set(now);
384 am.update(self.db().write()).await?;
385 }
386 None => {
387 thread_members::ActiveModel {
388 thread_id: Set(thread_id),
389 user_id: Set(caller),
390 notify_level: Set(notify_level),
391 last_read_message_id: Set(None),
392 last_read_seq: Set(0),
393 created_at: Set(now),
394 updated_at: Set(now),
395 }
396 .insert(self.db().write())
397 .await?;
398 threads::Entity::update_many()
399 .filter(threads::Column::Id.eq(thread_id))
400 .col_expr(
401 threads::Column::ParticipantCount,
402 Expr::col(threads::Column::ParticipantCount).add(1),
403 )
404 .exec(self.db().write())
405 .await?;
406 }
407 }
408 Ok(())
409 }
410
411 pub async fn leave_thread(&self, caller: Uuid, thread_id: Uuid) -> Result<(), ChatError> {
412 let thread = threads::Entity::find_by_id(thread_id)
413 .one(self.db().read())
414 .await?
415 .ok_or(ChatError::NotFound)?;
416 let _ctx = require_room_readable(self, thread.room_id, caller).await?;
417
418 let deleted = thread_members::Entity::delete_by_id((thread_id, caller))
419 .exec(self.db().write())
420 .await?;
421 if deleted.rows_affected > 0 {
422 threads::Entity::update_many()
423 .filter(threads::Column::Id.eq(thread_id))
424 .col_expr(
425 threads::Column::ParticipantCount,
426 Expr::col(threads::Column::ParticipantCount).sub(1),
427 )
428 .exec(self.db().write())
429 .await?;
430 }
431 Ok(())
432 }
433
434 pub(crate) async fn upsert_thread_read(
435 &self,
436 caller: Uuid,
437 thread_id: Uuid,
438 message_id: Uuid,
439 seq: i64,
440 ) -> Result<(), ChatError> {
441 let now = OffsetDateTime::now_utc();
442 let member = thread_members::Entity::find_by_id((thread_id, caller))
443 .one(self.db().write())
444 .await?;
445 match member {
446 Some(row) if row.last_read_seq < seq => {
447 let mut am: thread_members::ActiveModel = row.into();
448 am.last_read_message_id = Set(Some(message_id));
449 am.last_read_seq = Set(seq);
450 am.updated_at = Set(now);
451 am.update(self.db().write()).await?;
452 }
453 Some(_) => {}
454 None => {
455 let am = thread_members::ActiveModel {
456 thread_id: Set(thread_id),
457 user_id: Set(caller),
458 notify_level: Set(ThreadNotifyLevel::All),
459 last_read_message_id: Set(Some(message_id)),
460 last_read_seq: Set(seq),
461 created_at: Set(now),
462 updated_at: Set(now),
463 };
464 match am.insert(self.db().write()).await {
465 Ok(_) => {
466 threads::Entity::update_many()
467 .filter(threads::Column::Id.eq(thread_id))
468 .col_expr(
469 threads::Column::ParticipantCount,
470 Expr::col(threads::Column::ParticipantCount).add(1),
471 )
472 .exec(self.db().write())
473 .await?;
474 }
475 Err(err) => {
476 let msg = err.to_string().to_lowercase();
477 if !(msg.contains("unique") || msg.contains("duplicate")) {
478 return Err(ChatError::from(err));
479 }
480 }
481 }
482 }
483 }
484 Ok(())
485 }
486
487 pub(crate) async fn room_member_count(
488 &self,
489 room_id: Uuid,
490 ) -> Result<u64, ChatError> {
491 Ok(room_members::Entity::find()
492 .filter(room_members::Column::RoomId.eq(room_id))
493 .filter(room_members::Column::LeftAt.is_null())
494 .count(self.db().read())
495 .await?)
496 }
497}
Added lib/chat/src/services/webhooks.rs lib/chat/src/services/webhooks.rs +305 −0 View lib/chat/src/services/webhooks.rs
@@ -0,0 +1,305 @@
1use models::chat::room_webhooks;
2use rand::Rng;
3use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
4use serde::{Deserialize, Serialize};
5use sha2::{Digest, Sha256};
6use time::OffsetDateTime;
7use utoipa::ToSchema;
8use uuid::Uuid;
9
10use crate::errors::ChatError;
11use crate::services::messages::SendMessageCmd;
12use crate::services::service::ChatService;
13use crate::share::authz::can_manage_space;
14use crate::share::lookup::{lookup_room, require_room_readable};
15
16#[derive(Debug, Clone, Serialize, ToSchema)]
17pub struct WebhookDto {
18 pub id: Uuid,
19 pub room_id: Uuid,
20 pub name: String,
21 pub disabled: bool,
22 pub last_used_at: Option<OffsetDateTime>,
23 pub created_at: OffsetDateTime,
24}
25
26#[derive(Debug, Clone, Serialize, ToSchema)]
27pub struct WebhookSecretDto {
28 pub id: Uuid,
29 pub room_id: Uuid,
30 pub name: String,
31 pub token: String,
32 pub disabled: bool,
33 pub created_at: OffsetDateTime,
34}
35
36#[derive(Debug, Clone, Deserialize, ToSchema)]
37pub struct CreateWebhookCmd {
38 pub name: String,
39}
40
41#[derive(Debug, Clone, Deserialize, ToSchema)]
42pub struct WebhookPostCmd {
43 #[serde(default)]
44 pub content: String,
45 #[serde(default)]
46 pub nonce: Option<String>,
47}
48
49fn webhook_token() -> String {
50 const CHARSET: &[u8] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
51 let mut rng = rand::thread_rng();
52 (0..48)
53 .map(|_| CHARSET[rng.gen_range(0..CHARSET.len())] as char)
54 .collect()
55}
56
57pub fn token_hash(token: &str) -> String {
58 let mut hasher = Sha256::new();
59 hasher.update(token.as_bytes());
60 base64::Engine::encode(
61 &base64::engine::general_purpose::URL_SAFE_NO_PAD,
62 hasher.finalize(),
63 )
64}
65
66impl From<room_webhooks::Model> for WebhookDto {
67 fn from(w: room_webhooks::Model) -> Self {
68 Self {
69 id: w.id,
70 room_id: w.room_id,
71 name: w.name,
72 disabled: w.disabled,
73 last_used_at: w.last_used_at,
74 created_at: w.created_at,
75 }
76 }
77}
78
79impl ChatService {
80 pub async fn create_webhook(
81 &self,
82 caller: Uuid,
83 room_id: Uuid,
84 cmd: CreateWebhookCmd,
85 ) -> Result<WebhookSecretDto, ChatError> {
86 let ctx = require_room_readable(self, room_id, caller).await?;
87 match &ctx.space {
88 Some(space) => {
89 if !can_manage_space(self, space, caller).await? {
90 return Err(ChatError::Forbidden);
91 }
92 }
93 None => return Err(ChatError::Forbidden),
94 }
95 let name = cmd.name.trim().to_string();
96 if name.is_empty() || name.chars().count() > self.config().chat.max_webhook_name_len {
97 return Err(ChatError::BadRequest("invalid webhook name".to_string()));
98 }
99
100 let now = OffsetDateTime::now_utc();
101 let id = Uuid::now_v7();
102 let token = webhook_token();
103 let hash = token_hash(&token);
104 room_webhooks::ActiveModel {
105 id: Set(id),
106 room_id: Set(room_id),
107 user_id: Set(caller),
108 name: Set(name.clone()),
109 token_hash: Set(hash),
110 disabled: Set(false),
111 last_used_at: Set(None),
112 created_at: Set(now),
113 }
114 .insert(self.db().write())
115 .await?;
116
117 Ok(WebhookSecretDto {
118 id,
119 room_id,
120 name,
121 token,
122 disabled: false,
123 created_at: now,
124 })
125 }
126
127 pub async fn list_webhooks(
128 &self,
129 caller: Uuid,
130 room_id: Uuid,
131 ) -> Result<Vec<WebhookDto>, ChatError> {
132 let ctx = require_room_readable(self, room_id, caller).await?;
133 match &ctx.space {
134 Some(space) => {
135 if !can_manage_space(self, space, caller).await? {
136 return Err(ChatError::Forbidden);
137 }
138 }
139 None => return Err(ChatError::Forbidden),
140 }
141 let rows = room_webhooks::Entity::find()
142 .filter(room_webhooks::Column::RoomId.eq(room_id))
143 .all(self.db().read())
144 .await?;
145 Ok(rows.into_iter().map(WebhookDto::from).collect())
146 }
147
148 pub async fn delete_webhook(
149 &self,
150 caller: Uuid,
151 room_id: Uuid,
152 webhook_id: Uuid,
153 ) -> Result<(), ChatError> {
154 let ctx = require_room_readable(self, room_id, caller).await?;
155 match &ctx.space {
156 Some(space) => {
157 if !can_manage_space(self, space, caller).await? {
158 return Err(ChatError::Forbidden);
159 }
160 }
161 None => return Err(ChatError::Forbidden),
162 }
163 let deleted = room_webhooks::Entity::delete_many()
164 .filter(room_webhooks::Column::Id.eq(webhook_id))
165 .filter(room_webhooks::Column::RoomId.eq(room_id))
166 .exec(self.db().write())
167 .await?;
168 if deleted.rows_affected == 0 {
169 return Err(ChatError::NotFound);
170 }
171 Ok(())
172 }
173
174 pub async fn post_via_webhook(
175 &self,
176 token: &str,
177 cmd: WebhookPostCmd,
178 ) -> Result<serde_json::Value, ChatError> {
179 let hash = token_hash(token);
180 let webhook = room_webhooks::Entity::find()
181 .filter(room_webhooks::Column::TokenHash.eq(&hash))
182 .one(self.db().write())
183 .await?
184 .ok_or(ChatError::Unauthorized)?;
185 if webhook.disabled {
186 return Err(ChatError::Forbidden);
187 }
188 let room = lookup_room(self, webhook.room_id).await?;
189 if room.space_id.is_none() {
190 return Err(ChatError::Forbidden);
191 }
192
193 let now = OffsetDateTime::now_utc();
194 let mut am: room_webhooks::ActiveModel = webhook.clone().into();
195 am.last_used_at = Set(Some(now));
196 am.update(self.db().write()).await?;
197
198 let message = self
199 .post_webhook_message(webhook.room_id, &webhook, SendMessageCmd {
200 content: cmd.content,
201 reply_to_id: None,
202 thread_id: None,
203 thread_broadcast: false,
204 attachments: Vec::new(),
205 nonce: cmd.nonce,
206 urgent: false,
207 })
208 .await?;
209 Ok(serde_json::to_value(message)?)
210 }
211
212 async fn post_webhook_message(
213 &self,
214 room_id: Uuid,
215 webhook: &room_webhooks::Model,
216 cmd: SendMessageCmd,
217 ) -> Result<crate::services::messages::MessageDto, ChatError> {
218 use models::chat::messages::{self as chat_messages, AuthorType, ChatMessageType};
219 use models::room::rooms;
220 use sea_orm::sea_query::Expr;
221 use sea_orm::TransactionTrait;
222
223 let content = cmd.content.trim().to_string();
224 if content.is_empty() {
225 return Err(ChatError::BadRequest("content is empty".to_string()));
226 }
227 if content.chars().count() > self.config().chat.max_message_len {
228 return Err(ChatError::BadRequest("message too long".to_string()));
229 }
230
231 if let Some(nonce) = &cmd.nonce {
232 let existing = chat_messages::Entity::find()
233 .filter(chat_messages::Column::RoomId.eq(room_id))
234 .filter(chat_messages::Column::Nonce.eq(nonce.as_str()))
235 .one(self.db().read())
236 .await?;
237 if let Some(existing) = existing {
238 let items = self.build_message_dto(vec![existing], webhook.user_id).await?;
239 return Ok(items.into_iter().next().unwrap());
240 }
241 }
242
243 let now = OffsetDateTime::now_utc();
244 let message_id = Uuid::now_v7();
245 let content_clone = content.clone();
246 let webhook_id = webhook.id;
247 let owner = webhook.user_id;
248
249 let seq = self
250 .db()
251 .primary()
252 .transaction::<_, i64, ChatError>(move |txn| {
253 Box::pin(async move {
254 let seq = crate::share::notify::next_room_seq(txn, room_id).await?;
255 chat_messages::ActiveModel {
256 id: Set(message_id),
257 room_id: Set(room_id),
258 author_id: Set(owner),
259 author_type: Set(AuthorType::Webhook),
260 webhook_id: Set(Some(webhook_id)),
261 thread_id: Set(None),
262 thread_broadcast: Set(false),
263 reply_to_id: Set(None),
264 forwarded_from_id: Set(None),
265 message_type: Set(ChatMessageType::Default),
266 content: Set(content_clone),
267 mentions: Set(serde_json::json!([])),
268 mention_everyone: Set(false),
269 mention_group_ids: Set(serde_json::json!([])),
270 attachments: Set(serde_json::json!([])),
271 embeds: Set(serde_json::json!([])),
272 flags: Set(0),
273 nonce: Set(cmd.nonce),
274 pinned: Set(false),
275 pinned_by: Set(None),
276 pinned_at: Set(None),
277 edited_at: Set(None),
278 deleted_at: Set(None),
279 seq: Set(seq),
280 created_at: Set(now),
281 }
282 .insert(txn)
283 .await?;
284 rooms::Entity::update_many()
285 .filter(rooms::Column::Id.eq(room_id))
286 .col_expr(rooms::Column::LastMessageId, Expr::value(message_id))
287 .col_expr(rooms::Column::UpdatedAt, Expr::value(now))
288 .exec(txn)
289 .await?;
290 Ok(seq)
291 })
292 })
293 .await?;
294
295 let row = chat_messages::Entity::find_by_id(message_id)
296 .one(self.db().read())
297 .await?
298 .ok_or(ChatError::Internal("message vanished".into()))?;
299 let items = self.build_message_dto(vec![row], owner).await?;
300 let dto = items.into_iter().next().unwrap();
301 let value = serde_json::to_value(&dto)?;
302 crate::share::notify::publish_message_event(self, room_id, seq, value).await;
303 Ok(dto)
304 }
305}
Added lib/chat/src/share/authz.rs lib/chat/src/share/authz.rs +30 −0 View lib/chat/src/share/authz.rs
@@ -0,0 +1,30 @@
1use models::space::spaces;
2use space::share::authz::{MANAGE_CHANNELS, effective_permissions};
3use uuid::Uuid;
4
5use crate::errors::ChatError;
6use crate::services::service::ChatService;
7
8pub async fn can_manage_space(
9 service: &ChatService,
10 space: &spaces::Model,
11 uid: Uuid,
12) -> Result<bool, ChatError> {
13 if space.owner_id == uid {
14 return Ok(true);
15 }
16 let perms = effective_permissions(service.db().read(), space, uid).await?;
17 Ok(perms.has(MANAGE_CHANNELS))
18}
19
20pub async fn require_space_manager(
21 service: &ChatService,
22 space: &spaces::Model,
23 uid: Uuid,
24) -> Result<(), ChatError> {
25 if can_manage_space(service, space, uid).await? {
26 Ok(())
27 } else {
28 Err(ChatError::Forbidden)
29 }
30}
Added lib/chat/src/share/guard.rs lib/chat/src/share/guard.rs +16 −0 View lib/chat/src/share/guard.rs
@@ -0,0 +1,16 @@
1use axum::extract::{Request, State};
2use axum::middleware::Next;
3use axum::response::Response;
4use session::Session;
5use session::share::guard::session_guard;
6
7use crate::services::service::ChatService;
8
9pub async fn chat_guard(
10 State(service): State<ChatService>,
11 session: Session,
12 request: Request,
13 next: Next,
14) -> Response {
15 session_guard::<ChatService>(State(service), session, request, next).await
16}
Added lib/chat/src/share/lookup.rs lib/chat/src/share/lookup.rs +138 −0 View lib/chat/src/share/lookup.rs
@@ -0,0 +1,138 @@
1use models::room::room_members;
2use models::room::rooms::{self, RoomKind};
3use models::space::space_members::{self, MemberStatus};
4use models::space::spaces;
5use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
6use uuid::Uuid;
7
8use crate::errors::ChatError;
9use crate::services::service::ChatService;
10
11pub struct RoomContext {
12 pub room: rooms::Model,
13 pub space: Option<spaces::Model>,
14 pub is_dm: bool,
15}
16
17impl RoomContext {
18 pub fn is_thread_room(&self) -> bool {
19 matches!(self.room.kind, RoomKind::Thread)
20 }
21}
22
23pub async fn lookup_room(service: &ChatService, room_id: Uuid) -> Result<rooms::Model, ChatError> {
24 rooms::Entity::find_by_id(room_id)
25 .one(service.db().read())
26 .await?
27 .filter(|r| r.is_active)
28 .ok_or(ChatError::NotFound)
29}
30
31pub async fn load_room_context(
32 service: &ChatService,
33 room: rooms::Model,
34) -> Result<RoomContext, ChatError> {
35 let is_dm = room.space_id.is_none();
36 let space = match room.space_id {
37 Some(space_id) => Some(
38 spaces::Entity::find_by_id(space_id)
39 .one(service.db().read())
40 .await?
41 .ok_or(ChatError::NotFound)?,
42 ),
43 None => None,
44 };
45 Ok(RoomContext { room, space, is_dm })
46}
47
48pub async fn lookup_room_context(
49 service: &ChatService,
50 room_id: Uuid,
51) -> Result<RoomContext, ChatError> {
52 let room = lookup_room(service, room_id).await?;
53 load_room_context(service, room).await
54}
55
56pub async fn is_room_member(
57 service: &ChatService,
58 room_id: Uuid,
59 uid: Uuid,
60) -> Result<bool, ChatError> {
61 Ok(room_members::Entity::find_by_id((room_id, uid))
62 .one(service.db().read())
63 .await?
64 .is_some_and(|m| m.left_at.is_none()))
65}
66
67pub async fn lookup_space_by_name(
68 service: &ChatService,
69 space_name: &str,
70) -> Result<spaces::Model, ChatError> {
71 let lower_name = space_name.to_lowercase();
72 spaces::Entity::find()
73 .filter(spaces::Column::LowerName.eq(&lower_name))
74 .one(service.db().read())
75 .await?
76 .filter(|s| s.is_active)
77 .ok_or(ChatError::NotFound)
78}
79
80pub async fn require_space_member(
81 service: &ChatService,
82 space: &spaces::Model,
83 uid: Uuid,
84) -> Result<space_members::Model, ChatError> {
85 if space.owner_id == uid {
86 return Ok(space_members::Model {
87 space_id: space.id,
88 user_id: uid,
89 alias_name: None,
90 alias_avatar_url: None,
91 status: MemberStatus::Active,
92 last_active_at: None,
93 left_at: None,
94 invited_at: None,
95 invited_by: None,
96 joined_at: space.created_at,
97 created_at: space.created_at,
98 updated_at: None,
99 message_notifications: Default::default(),
100 muted: false,
101 mute_until: None,
102 mobile_push: false,
103 suppress_everyone: false,
104 suppress_roles: false,
105 hide_muted_channels: false,
106 });
107 }
108 let member = space_members::Entity::find_by_id((space.id, uid))
109 .one(service.db().read())
110 .await?
111 .ok_or(ChatError::Forbidden)?;
112 if member.status != MemberStatus::Active {
113 return Err(ChatError::Forbidden);
114 }
115 Ok(member)
116}
117
118pub async fn require_room_readable(
119 service: &ChatService,
120 room_id: Uuid,
121 uid: Uuid,
122) -> Result<RoomContext, ChatError> {
123 let ctx = lookup_room_context(service, room_id).await?;
124 match &ctx.space {
125 Some(space) => {
126 require_space_member(service, space, uid).await?;
127 if ctx.room.is_private && !is_room_member(service, room_id, uid).await? {
128 return Err(ChatError::Forbidden);
129 }
130 }
131 None => {
132 if !is_room_member(service, room_id, uid).await? {
133 return Err(ChatError::Forbidden);
134 }
135 }
136 }
137 Ok(ctx)
138}
Added lib/chat/src/share/mention.rs lib/chat/src/share/mention.rs +136 −0 View lib/chat/src/share/mention.rs
@@ -0,0 +1,136 @@
1use std::collections::HashMap;
2
3use models::room::room_members;
4use models::space::space_groups;
5use models::space::space_members::{self, MemberStatus};
6use models::users::user;
7use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QuerySelect};
8use uuid::Uuid;
9
10use crate::errors::ChatError;
11use crate::services::service::ChatService;
12use crate::share::lookup::RoomContext;
13
14#[derive(Debug, Default)]
15pub struct ParsedMentions {
16 pub users: Vec<Uuid>,
17 pub groups: Vec<Uuid>,
18 pub everyone: bool,
19}
20
21pub async fn parse_mentions(
22 service: &ChatService,
23 ctx: &RoomContext,
24 content: &str,
25 exclude: Uuid,
26) -> Result<ParsedMentions, ChatError> {
27 if !content.contains('@') {
28 return Ok(ParsedMentions::default());
29 }
30
31 let max = service.config().chat.max_mentions_per_message;
32 let mut parsed = ParsedMentions::default();
33
34 for token in content.split_whitespace() {
35 let Some(rest) = token.strip_prefix('@') else {
36 continue;
37 };
38 let clean = rest.trim_matches(|c: char| !c.is_alphanumeric() && c != '-' && c != '_');
39 if clean.is_empty() {
40 continue;
41 }
42 let lower = clean.to_lowercase();
43 if lower == "channel" || lower == "here" || lower == "everyone" {
44 parsed.everyone = true;
45 continue;
46 }
47 if let Ok(uid) = Uuid::parse_str(clean) {
48 if uid != exclude && !parsed.users.contains(&uid) && parsed.users.len() < max {
49 parsed.users.push(uid);
50 }
51 continue;
52 }
53 if parsed.users.len() + parsed.groups.len() >= max {
54 continue;
55 }
56 match resolve_name(service, ctx, &lower).await? {
57 Resolved::User(uid) if uid != exclude && !parsed.users.contains(&uid) => {
58 parsed.users.push(uid)
59 }
60 Resolved::Group(gid) if !parsed.groups.contains(&gid) => parsed.groups.push(gid),
61 _ => {}
62 }
63 }
64
65 Ok(parsed)
66}
67
68enum Resolved {
69 User(Uuid),
70 Group(Uuid),
71 None,
72}
73
74async fn resolve_name(
75 service: &ChatService,
76 ctx: &RoomContext,
77 lower: &str,
78) -> Result<Resolved, ChatError> {
79 if let Some(space) = &ctx.space {
80 let members = space_members::Entity::find()
81 .filter(space_members::Column::SpaceId.eq(space.id))
82 .filter(space_members::Column::Status.eq(MemberStatus::Active))
83 .select_only()
84 .column(space_members::Column::UserId)
85 .into_tuple::<Uuid>()
86 .all(service.db().read())
87 .await?;
88 let mut uids = members;
89 if !uids.contains(&space.owner_id) {
90 uids.push(space.owner_id);
91 }
92 if !uids.is_empty() {
93 let users = user::Entity::find()
94 .filter(user::Column::Uid.is_in(uids))
95 .all(service.db().read())
96 .await?;
97 let mut name_map: HashMap<String, Uuid> = HashMap::new();
98 for u in users {
99 name_map.insert(u.lower_name.to_lowercase(), u.uid);
100 name_map.insert(u.username.to_lowercase(), u.uid);
101 }
102 if let Some(&uid) = name_map.get(lower) {
103 return Ok(Resolved::User(uid));
104 }
105 }
106 let group = space_groups::Entity::find()
107 .filter(space_groups::Column::SpaceId.eq(space.id))
108 .filter(space_groups::Column::Name.eq(lower))
109 .one(service.db().read())
110 .await?;
111 if let Some(group) = group {
112 return Ok(Resolved::Group(group.id));
113 }
114 return Ok(Resolved::None);
115 }
116
117 let peers = room_members::Entity::find()
118 .filter(room_members::Column::RoomId.eq(ctx.room.id))
119 .filter(room_members::Column::LeftAt.is_null())
120 .all(service.db().read())
121 .await?;
122 let uids: Vec<Uuid> = peers.into_iter().map(|m| m.user_id).collect();
123 if uids.is_empty() {
124 return Ok(Resolved::None);
125 }
126 let users = user::Entity::find()
127 .filter(user::Column::Uid.is_in(uids))
128 .all(service.db().read())
129 .await?;
130 for u in users {
131 if u.lower_name.to_lowercase() == lower || u.username.to_lowercase() == lower {
132 return Ok(Resolved::User(u.uid));
133 }
134 }
135 Ok(Resolved::None)
136}
Added lib/chat/src/share/mod.rs lib/chat/src/share/mod.rs +6 −0 View lib/chat/src/share/mod.rs
@@ -0,0 +1,6 @@
1pub mod authz;
2pub mod guard;
3pub mod lookup;
4pub mod mention;
5pub mod notify;
6pub mod realtime;
Added lib/chat/src/share/notify.rs lib/chat/src/share/notify.rs +199 −0 View lib/chat/src/share/notify.rs
@@ -0,0 +1,199 @@
1use models::chat::read_states;
2use models::chat::room_user_settings::{self, RoomNotificationLevel};
3use models::space::space_members::{self, MessageNotifications};
4use models::users::user_notifications::{self, NotificationType};
5use models::users::user_recent_mentions;
6use sea_orm::sea_query::Expr;
7use sea_orm::{ActiveModelTrait, ColumnTrait, ConnectionTrait, DatabaseBackend, EntityTrait, QueryFilter, Set, Statement};
8use time::OffsetDateTime;
9use uuid::Uuid;
10
11use crate::errors::ChatError;
12use crate::services::service::ChatService;
13use crate::share::realtime::RoomEvent;
14
15pub async fn next_room_seq(
16 db: &impl ConnectionTrait,
17 room_id: Uuid,
18) -> Result<i64, ChatError> {
19 let now = OffsetDateTime::now_utc();
20 let row = db
21 .query_one_raw(Statement::from_sql_and_values(
22 DatabaseBackend::Postgres,
23 r#"INSERT INTO chat_room_seq (room_id, seq, updated_at)
24 VALUES ($1, 1, $2)
25 ON CONFLICT (room_id)
26 DO UPDATE SET seq = chat_room_seq.seq + 1, updated_at = $2
27 RETURNING seq"#,
28 [room_id.into(), now.into()],
29 ))
30 .await?;
31 let row = row.ok_or_else(|| ChatError::Internal("seq allocation returned no row".into()))?;
32 let seq: i64 = row.try_get("", "seq")?;
33 Ok(seq)
34}
35
36pub async fn record_mentions(
37 db: &impl ConnectionTrait,
38 room_id: Uuid,
39 message_id: Uuid,
40 uids: &[Uuid],
41) -> Result<(), ChatError> {
42 let now = OffsetDateTime::now_utc();
43 for &uid in uids {
44 user_recent_mentions::ActiveModel {
45 id: Set(Uuid::now_v7()),
46 user_id: Set(uid),
47 channel_id: Set(room_id),
48 message_id: Set(message_id),
49 created_at: Set(now),
50 }
51 .insert(db)
52 .await?;
53 }
54 Ok(())
55}
56
57pub async fn notify_user(
58 db: &impl ConnectionTrait,
59 recipient: Uuid,
60 ntype: NotificationType,
61 title: String,
62 body: Option<String>,
63 data: Option<serde_json::Value>,
64 actor_uid: Option<Uuid>,
65) -> Result<(), ChatError> {
66 user_notifications::ActiveModel {
67 id: Set(Uuid::now_v7()),
68 user_id: Set(recipient),
69 notification_type: Set(ntype),
70 title: Set(title),
71 body: Set(body),
72 data_json: Set(data),
73 actor_uid: Set(actor_uid),
74 read_at: Set(None),
75 created_at: Set(OffsetDateTime::now_utc()),
76 }
77 .insert(db)
78 .await?;
79 Ok(())
80}
81
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83pub enum NotifyDecision {
84 Notify,
85 Skip,
86}
87
88fn mute_active(muted: bool, mute_until: Option<OffsetDateTime>) -> bool {
89 muted && mute_until.is_none_or(|until| until > OffsetDateTime::now_utc())
90}
91
92pub async fn notification_decision(
93 service: &ChatService,
94 room_id: Uuid,
95 space_id: Option<Uuid>,
96 uid: Uuid,
97 mentioned: bool,
98) -> Result<NotifyDecision, ChatError> {
99 if let Some(setting) = room_user_settings::Entity::find_by_id((uid, room_id))
100 .one(service.db().read())
101 .await?
102 {
103 if mute_active(setting.muted, setting.mute_until) {
104 return Ok(if mentioned {
105 NotifyDecision::Notify
106 } else {
107 NotifyDecision::Skip
108 });
109 }
110 match setting.notification_level {
111 RoomNotificationLevel::All => return Ok(NotifyDecision::Notify),
112 RoomNotificationLevel::Mentions | RoomNotificationLevel::Nothing => {
113 return Ok(if mentioned {
114 NotifyDecision::Notify
115 } else {
116 NotifyDecision::Skip
117 });
118 }
119 RoomNotificationLevel::Default => {}
120 }
121 }
122
123 if let Some(space_id) = space_id
124 && let Some(member) = space_members::Entity::find_by_id((space_id, uid))
125 .one(service.db().read())
126 .await?
127 {
128 if mute_active(member.muted, member.mute_until) && !mentioned {
129 return Ok(NotifyDecision::Skip);
130 }
131 return Ok(match member.message_notifications {
132 MessageNotifications::All => NotifyDecision::Notify,
133 MessageNotifications::Mentions | MessageNotifications::Nothing => {
134 if mentioned {
135 NotifyDecision::Notify
136 } else {
137 NotifyDecision::Skip
138 }
139 }
140 });
141 }
142
143 Ok(NotifyDecision::Notify)
144}
145
146pub async fn publish_message_event(
147 service: &ChatService,
148 room_id: Uuid,
149 seq: i64,
150 message: serde_json::Value,
151) {
152 let event = RoomEvent::MessagePublished {
153 room_id,
154 seq,
155 message,
156 };
157 service.realtime.publish_room(&event).await;
158}
159
160pub async fn advance_read_state(
161 db: &impl ConnectionTrait,
162 uid: Uuid,
163 room_id: Uuid,
164 message_id: Option<Uuid>,
165 seq: i64,
166) -> Result<bool, ChatError> {
167 let now = OffsetDateTime::now_utc();
168 let result = read_states::Entity::update_many()
169 .filter(read_states::Column::UserId.eq(uid))
170 .filter(read_states::Column::RoomId.eq(room_id))
171 .filter(read_states::Column::LastReadSeq.lt(seq))
172 .col_expr(read_states::Column::LastReadSeq, Expr::value(seq))
173 .col_expr(read_states::Column::LastReadMessageId, Expr::value(message_id))
174 .col_expr(read_states::Column::LastViewedAt, Expr::value(now))
175 .col_expr(read_states::Column::UpdatedAt, Expr::value(now))
176 .exec(db)
177 .await?;
178 if result.rows_affected > 0 {
179 return Ok(true);
180 }
181 let exists = read_states::Entity::find_by_id((uid, room_id))
182 .one(db)
183 .await?
184 .is_some();
185 if !exists {
186 read_states::ActiveModel {
187 user_id: Set(uid),
188 room_id: Set(room_id),
189 last_read_message_id: Set(message_id),
190 last_read_seq: Set(seq),
191 last_viewed_at: Set(Some(now)),
192 updated_at: Set(now),
193 }
194 .insert(db)
195 .await?;
196 return Ok(true);
197 }
198 Ok(false)
199}
Added lib/chat/src/share/realtime.rs lib/chat/src/share/realtime.rs +259 −0 View lib/chat/src/share/realtime.rs
@@ -0,0 +1,259 @@
1use std::collections::HashMap;
2use std::sync::Arc;
3
4use serde::{Deserialize, Serialize};
5use tokio::sync::{broadcast, Mutex};
6use tokio_util::sync::CancellationToken;
7use uuid::Uuid;
8
9use crate::errors::ChatError;
10
11pub fn room_channel(room_id: Uuid) -> String {
12 format!("chat:room:{room_id}")
13}
14
15pub fn user_channel(uid: Uuid) -> String {
16 format!("chat:user:{uid}")
17}
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
20#[serde(tag = "kind", rename_all = "snake_case")]
21pub enum RoomEvent {
22 MessagePublished {
23 room_id: Uuid,
24 seq: i64,
25 message: serde_json::Value,
26 },
27 MessageEdited {
28 room_id: Uuid,
29 seq: i64,
30 message: serde_json::Value,
31 },
32 MessageDeleted {
33 room_id: Uuid,
34 seq: i64,
35 message_id: Uuid,
36 },
37 MessagesDeleted {
38 room_id: Uuid,
39 seq: i64,
40 message_ids: Vec<Uuid>,
41 },
42 MessagePinned {
43 room_id: Uuid,
44 seq: i64,
45 message_id: Uuid,
46 pinned: bool,
47 },
48 ReactionUpdated {
49 room_id: Uuid,
50 message_id: Uuid,
51 reactions: serde_json::Value,
52 },
53 Typing {
54 room_id: Uuid,
55 user_id: Uuid,
56 thread_id: Option<Uuid>,
57 },
58 RoomUpdated {
59 room_id: Uuid,
60 },
61 MemberJoined {
62 room_id: Uuid,
63 user_id: Uuid,
64 },
65 MemberLeft {
66 room_id: Uuid,
67 user_id: Uuid,
68 },
69 ThreadUpdated {
70 room_id: Uuid,
71 thread_id: Uuid,
72 },
73}
74
75impl RoomEvent {
76 pub fn room_id(&self) -> Uuid {
77 match self {
78 Self::MessagePublished { room_id, .. }
79 | Self::MessageEdited { room_id, .. }
80 | Self::MessageDeleted { room_id, .. }
81 | Self::MessagesDeleted { room_id, .. }
82 | Self::MessagePinned { room_id, .. }
83 | Self::ReactionUpdated { room_id, .. }
84 | Self::Typing { room_id, .. }
85 | Self::RoomUpdated { room_id, .. }
86 | Self::MemberJoined { room_id, .. }
87 | Self::MemberLeft { room_id, .. }
88 | Self::ThreadUpdated { room_id, .. } => *room_id,
89 }
90 }
91
92 pub fn seq(&self) -> Option<i64> {
93 match self {
94 Self::MessagePublished { seq, .. }
95 | Self::MessageEdited { seq, .. }
96 | Self::MessageDeleted { seq, .. }
97 | Self::MessagesDeleted { seq, .. }
98 | Self::MessagePinned { seq, .. } => Some(*seq),
99 _ => None,
100 }
101 }
102}
103
104struct ChannelEntry {
105 tx: broadcast::Sender<String>,
106 cancel: CancellationToken,
107}
108
109struct Inner {
110 redis: export::AppRedis,
111 pubsub_client: redis::Client,
112 rooms: Mutex<HashMap<Uuid, ChannelEntry>>,
113 users: Mutex<HashMap<Uuid, ChannelEntry>>,
114}
115
116#[derive(Clone)]
117pub struct Realtime {
118 inner: Arc<Inner>,
119}
120
121impl Realtime {
122 pub fn new(redis: export::AppRedis) -> Result<Self, ChatError> {
123 let url = redis.config().url().to_string();
124 let first = url
125 .split(',')
126 .map(str::trim)
127 .find(|s| !s.is_empty())
128 .ok_or_else(|| ChatError::Internal("empty redis url".to_string()))?;
129 let pubsub_client = redis::Client::open(first)?;
130 Ok(Self {
131 inner: Arc::new(Inner {
132 redis,
133 pubsub_client,
134 rooms: Mutex::new(HashMap::new()),
135 users: Mutex::new(HashMap::new()),
136 }),
137 })
138 }
139
140 pub async fn publish_room(&self, event: &RoomEvent) {
141 self.publish(&room_channel(event.room_id()), event).await;
142 }
143
144 pub async fn publish_user<T: Serialize>(&self, uid: Uuid, event: &T) {
145 self.publish(&user_channel(uid), event).await;
146 }
147
148 async fn publish<T: Serialize>(&self, channel: &str, event: &T) {
149 let payload = match serde_json::to_vec(event) {
150 Ok(bytes) => bytes,
151 Err(error) => {
152 tracing::warn!(%error, %channel, "chat realtime serialize failed");
153 return;
154 }
155 };
156 let mut conn = self.inner.redis.connection_clone();
157 let result: Result<i64, redis::RedisError> = match &mut conn {
158 export::RedisConnection::Standalone(c) => redis::cmd("PUBLISH")
159 .arg(channel)
160 .arg(payload.as_slice())
161 .query_async(c)
162 .await,
163 export::RedisConnection::Cluster(c) => redis::cmd("PUBLISH")
164 .arg(channel)
165 .arg(payload.as_slice())
166 .query_async(c)
167 .await,
168 };
169 if let Err(error) = result {
170 tracing::warn!(%error, %channel, "chat realtime publish failed");
171 }
172 }
173
174 pub async fn subscribe_room(
175 &self,
176 room_id: Uuid,
177 ) -> Result<broadcast::Receiver<String>, ChatError> {
178 let channel = room_channel(room_id);
179 let mut map = self.inner.rooms.lock().await;
180 if let Some(entry) = map.get(&room_id) {
181 return Ok(entry.tx.subscribe());
182 }
183 let (tx, cancel) = spawn_pump(&self.inner.pubsub_client, &channel).await?;
184 let rx = tx.subscribe();
185 map.insert(room_id, ChannelEntry { tx, cancel });
186 Ok(rx)
187 }
188
189 pub async fn unsubscribe_room(&self, room_id: Uuid) {
190 let mut map = self.inner.rooms.lock().await;
191 let remove = map
192 .get(&room_id)
193 .is_some_and(|entry| entry.tx.receiver_count() == 0);
194 if remove
195 && let Some(entry) = map.remove(&room_id)
196 {
197 entry.cancel.cancel();
198 }
199 }
200
201 pub async fn subscribe_user(
202 &self,
203 uid: Uuid,
204 ) -> Result<broadcast::Receiver<String>, ChatError> {
205 let channel = user_channel(uid);
206 let mut map = self.inner.users.lock().await;
207 if let Some(entry) = map.get(&uid) {
208 return Ok(entry.tx.subscribe());
209 }
210 let (tx, cancel) = spawn_pump(&self.inner.pubsub_client, &channel).await?;
211 let rx = tx.subscribe();
212 map.insert(uid, ChannelEntry { tx, cancel });
213 Ok(rx)
214 }
215
216 pub async fn unsubscribe_user(&self, uid: Uuid) {
217 let mut map = self.inner.users.lock().await;
218 let remove = map.get(&uid).is_some_and(|entry| entry.tx.receiver_count() == 0);
219 if remove
220 && let Some(entry) = map.remove(&uid)
221 {
222 entry.cancel.cancel();
223 }
224 }
225}
226
227async fn spawn_pump(
228 client: &redis::Client,
229 channel: &str,
230) -> Result<(broadcast::Sender<String>, CancellationToken), ChatError> {
231 let mut pubsub = client.get_async_pubsub().await?;
232 pubsub.subscribe(channel).await?;
233 let (tx, _) = broadcast::channel::<String>(4096);
234 let cancel = CancellationToken::new();
235 let pump_tx = tx.clone();
236 let pump_cancel = cancel.clone();
237 let channel_name = channel.to_string();
238 tokio::spawn(async move {
239 use futures::StreamExt;
240 let mut stream = pubsub.on_message();
241 loop {
242 tokio::select! {
243 _ = pump_cancel.cancelled() => break,
244 msg = stream.next() => {
245 let Some(msg) = msg else { break };
246 let payload: Result<String, _> = msg.get_payload();
247 if let Ok(payload) = payload
248 && pump_tx.send(payload).is_err()
249 && pump_tx.receiver_count() == 0
250 {
251 tracing::debug!(channel = %channel_name, "chat realtime channel idle");
252 }
253 }
254 }
255 }
256 tracing::debug!(channel = %channel_name, "chat realtime pump exited");
257 });
258 Ok((tx, cancel))
259}
Added lib/chat/src/ws/frames.rs lib/chat/src/ws/frames.rs +70 −0 View lib/chat/src/ws/frames.rs
@@ -0,0 +1,70 @@
1use serde::{Deserialize, Serialize};
2use uuid::Uuid;
3
4use crate::services::messages::MessageDto;
5
6#[derive(Debug, Clone, Deserialize)]
7#[serde(tag = "op", rename_all = "snake_case")]
8pub enum ClientFrame {
9 Subscribe {
10 room_id: Uuid,
11 #[serde(default)]
12 resync: Option<bool>,
13 },
14 Unsubscribe {
15 room_id: Uuid,
16 },
17 Resync {
18 room_id: Uuid,
19 after_seq: i64,
20 },
21 MarkRead {
22 room_id: Uuid,
23 #[serde(default)]
24 message_id: Option<Uuid>,
25 #[serde(default)]
26 seq: Option<i64>,
27 },
28 Typing {
29 room_id: Uuid,
30 #[serde(default)]
31 thread_id: Option<Uuid>,
32 },
33 Ping,
34}
35
36#[derive(Debug, Clone, Serialize)]
37#[serde(tag = "type", rename_all = "snake_case")]
38pub enum ServerFrame {
39 Ready {
40 user_id: Uuid,
41 session_id: Uuid,
42 },
43 Event {
44 room_id: Uuid,
45 event: serde_json::Value,
46 },
47 UserEvent {
48 event: serde_json::Value,
49 },
50 History {
51 room_id: Uuid,
52 messages: Vec<MessageDto>,
53 },
54 Gap {
55 room_id: Uuid,
56 from: i64,
57 to: i64,
58 },
59 ResyncRequired {
60 room_id: Uuid,
61 reason: String,
62 },
63 Error {
64 message: String,
65 },
66 Pong,
67 Bye {
68 message: String,
69 },
70}
Added lib/chat/src/ws/mod.rs lib/chat/src/ws/mod.rs +413 −0 View lib/chat/src/ws/mod.rs
@@ -0,0 +1,413 @@
1pub mod frames;
2pub mod ticket;
3
4use std::collections::HashMap;
5use std::time::Duration;
6
7use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
8use axum::extract::{Query, State};
9use axum::response::Response;
10use futures::{SinkExt, StreamExt};
11use serde::Deserialize;
12use tokio::sync::mpsc;
13use tokio_util::sync::CancellationToken;
14use uuid::Uuid;
15
16use crate::errors::ChatError;
17use crate::services::messages::ListMessageQuery;
18use crate::services::read_state::MarkReadCmd;
19use crate::services::service::ChatService;
20use crate::share::realtime::RoomEvent;
21use frames::{ClientFrame, ServerFrame};
22use ticket::verify_ticket;
23
24#[derive(Debug, Deserialize)]
25pub struct WsQuery {
26 pub ticket: String,
27}
28
29enum Inbox {
30 Room(Uuid, Result<String, ()>),
31 User(String),
32}
33
34struct Subscription {
35 cancel: CancellationToken,
36}
37
38pub async fn ws_upgrade(
39 State(service): State<ChatService>,
40 Query(query): Query<WsQuery>,
41 ws: WebSocketUpgrade,
42) -> Response {
43 let (uid, session_id) = match verify_ticket(&service.ticket_secret(), &query.ticket) {
44 Ok(value) => value,
45 Err(_) => return ws.on_upgrade(|socket| async move {
46 let _ = close_with(socket, "invalid ticket").await;
47 }),
48 };
49
50 ws.max_message_size(service.config().chat.ws.max_frame_bytes)
51 .on_upgrade(move |socket| handle_socket(socket, service, uid, session_id))
52}
53
54async fn close_with(mut socket: WebSocket, message: &str) -> Result<(), axum::Error> {
55 let frame = ServerFrame::Bye {
56 message: message.to_string(),
57 };
58 let payload = serde_json::to_string(&frame).unwrap_or_default();
59 socket.send(Message::Text(payload.into())).await?;
60 socket.close().await
61}
62
63async fn handle_socket(
64 socket: WebSocket,
65 service: ChatService,
66 uid: Uuid,
67 session_id: Uuid,
68) {
69 let (mut sink, mut stream) = socket.split();
70
71 let (inbox_tx, mut inbox_rx) = mpsc::channel::<Inbox>(1024);
72
73 let mut user_rx = match service.realtime.subscribe_user(uid).await {
74 Ok(rx) => rx,
75 Err(error) => {
76 tracing::warn!(%error, "ws user subscribe failed");
77 let _ = close_with_unsplit(sink, "internal error").await;
78 return;
79 }
80 };
81 {
82 let tx = inbox_tx.clone();
83 tokio::spawn(async move {
84 while let Ok(payload) = user_rx.recv().await {
85 if tx.send(Inbox::User(payload)).await.is_err() {
86 break;
87 }
88 }
89 });
90 }
91
92 let ready = ServerFrame::Ready {
93 user_id: uid,
94 session_id,
95 };
96 if send_frame(&mut sink, &ready).await.is_err() {
97 return;
98 }
99
100 let heartbeat_secs = service.config().chat.ws.heartbeat_secs.max(5);
101 let idle_limit = Duration::from_secs(heartbeat_secs * 3);
102 let mut last_activity = tokio::time::Instant::now();
103 let mut ticker = tokio::time::interval(Duration::from_secs(heartbeat_secs));
104 ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
105
106 let mut subscriptions: HashMap<Uuid, Subscription> = HashMap::new();
107 let mut last_seq: HashMap<Uuid, i64> = HashMap::new();
108 let max_subscriptions = service.config().chat.ws.max_subscriptions;
109
110 loop {
111 tokio::select! {
112 frame = stream.next() => {
113 let Some(frame) = frame else { break };
114 let Ok(frame) = frame else { break };
115 last_activity = tokio::time::Instant::now();
116 match frame {
117 Message::Text(text) => {
118 let frame: Result<ClientFrame, _> = serde_json::from_str(&text);
119 match frame {
120 Ok(frame) => {
121 handle_client_frame(
122 &mut sink,
123 &service,
124 uid,
125 frame,
126 &mut subscriptions,
127 &mut last_seq,
128 &inbox_tx,
129 max_subscriptions,
130 )
131 .await;
132 }
133 Err(_) => {
134 let _ = send_frame(&mut sink, &ServerFrame::Error {
135 message: "invalid frame".to_string(),
136 }).await;
137 }
138 }
139 }
140 Message::Ping(payload) => {
141 let _ = sink.send(Message::Pong(payload)).await;
142 }
143 Message::Close(_) => break,
144 _ => {}
145 }
146 }
147 inbox = inbox_rx.recv() => {
148 let Some(inbox) = inbox else { break };
149 match inbox {
150 Inbox::Room(room_id, Ok(payload)) => {
151 let gap = check_seq_gap(&payload, &mut last_seq, room_id);
152 if let Some(frame) = gap
153 && send_frame(&mut sink, &frame).await.is_err()
154 {
155 break;
156 }
157 let frame = ServerFrame::Event {
158 room_id,
159 event: serde_json::from_str(&payload)
160 .unwrap_or(serde_json::Value::Null),
161 };
162 if send_frame(&mut sink, &frame).await.is_err() {
163 break;
164 }
165 }
166 Inbox::Room(room_id, Err(())) => {
167 let frame = ServerFrame::ResyncRequired {
168 room_id,
169 reason: "lagged".to_string(),
170 };
171 last_seq.remove(&room_id);
172 if send_frame(&mut sink, &frame).await.is_err() {
173 break;
174 }
175 }
176 Inbox::User(payload) => {
177 let frame = ServerFrame::UserEvent {
178 event: serde_json::from_str(&payload)
179 .unwrap_or(serde_json::Value::Null),
180 };
181 if send_frame(&mut sink, &frame).await.is_err() {
182 break;
183 }
184 }
185 }
186 }
187 _ = ticker.tick() => {
188 if last_activity.elapsed() > idle_limit {
189 let _ = send_frame(&mut sink, &ServerFrame::Bye {
190 message: "idle timeout".to_string(),
191 }).await;
192 break;
193 }
194 }
195 }
196 }
197
198 for (_, sub) in subscriptions {
199 sub.cancel.cancel();
200 }
201 service.realtime.unsubscribe_user(uid).await;
202}
203
204fn check_seq_gap(
205 payload: &str,
206 last_seq: &mut HashMap<Uuid, i64>,
207 room_id: Uuid,
208) -> Option<ServerFrame> {
209 let event: serde_json::Value = serde_json::from_str(payload).ok()?;
210 let seq = event.get("seq")?.as_i64()?;
211 let last = last_seq.get(&room_id).copied();
212 last_seq.insert(room_id, seq);
213 match last {
214 Some(last) if seq > last + 1 => Some(ServerFrame::Gap {
215 room_id,
216 from: last + 1,
217 to: seq,
218 }),
219 _ => None,
220 }
221}
222
223#[allow(clippy::too_many_arguments)]
224async fn handle_client_frame<S>(
225 sink: &mut S,
226 service: &ChatService,
227 uid: Uuid,
228 frame: ClientFrame,
229 subscriptions: &mut HashMap<Uuid, Subscription>,
230 last_seq: &mut HashMap<Uuid, i64>,
231 inbox_tx: &mpsc::Sender<Inbox>,
232 max_subscriptions: usize,
233) where
234 S: futures::Sink<Message, Error = axum::Error> + Unpin,
235{
236 match frame {
237 ClientFrame::Subscribe { room_id, resync } => {
238 if subscriptions.contains_key(&room_id) {
239 return;
240 }
241 if subscriptions.len() >= max_subscriptions {
242 let _ = send_frame(sink, &ServerFrame::Error {
243 message: "subscription limit reached".to_string(),
244 })
245 .await;
246 return;
247 }
248 if service_can_read(service, uid, room_id).await.is_err() {
249 let _ = send_frame(sink, &ServerFrame::Error {
250 message: "forbidden".to_string(),
251 })
252 .await;
253 return;
254 }
255 let rx = match service.realtime.subscribe_room(room_id).await {
256 Ok(rx) => rx,
257 Err(_) => {
258 let _ = send_frame(sink, &ServerFrame::Error {
259 message: "subscribe failed".to_string(),
260 })
261 .await;
262 return;
263 }
264 };
265 let cancel = CancellationToken::new();
266 {
267 let tx = inbox_tx.clone();
268 let child = cancel.clone();
269 let mut rx = rx;
270 tokio::spawn(async move {
271 loop {
272 tokio::select! {
273 _ = child.cancelled() => break,
274 result = rx.recv() => {
275 match result {
276 Ok(payload) => {
277 if tx.send(Inbox::Room(room_id, Ok(payload))).await.is_err() {
278 break;
279 }
280 }
281 Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
282 if tx.send(Inbox::Room(room_id, Err(()))).await.is_err() {
283 break;
284 }
285 }
286 Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
287 }
288 }
289 }
290 }
291 });
292 }
293 subscriptions.insert(room_id, Subscription { cancel });
294
295 let history_limit = service.config().chat.ws.history_limit;
296 if let Ok(page) = service
297 .list_messages(
298 uid,
299 room_id,
300 ListMessageQuery {
301 before_seq: None,
302 after_seq: None,
303 limit: Some(history_limit),
304 thread_id: None,
305 author_id: None,
306 },
307 )
308 .await
309 {
310 if resync.unwrap_or(true) {
311 let frame = ServerFrame::History {
312 room_id,
313 messages: page.items,
314 };
315 let _ = send_frame(sink, &frame).await;
316 }
317 if let Some(newest) = page.newest_seq {
318 last_seq.insert(room_id, newest);
319 }
320 }
321 }
322 ClientFrame::Unsubscribe { room_id } => {
323 if let Some(sub) = subscriptions.remove(&room_id) {
324 sub.cancel.cancel();
325 last_seq.remove(&room_id);
326 service.realtime.unsubscribe_room(room_id).await;
327 }
328 }
329 ClientFrame::Resync { room_id, after_seq } => {
330 if service_can_read(service, uid, room_id).await.is_err() {
331 return;
332 }
333 if let Ok(page) = service
334 .list_messages(
335 uid,
336 room_id,
337 ListMessageQuery {
338 before_seq: None,
339 after_seq: Some(after_seq),
340 limit: Some(service.config().chat.ws.history_limit),
341 thread_id: None,
342 author_id: None,
343 },
344 )
345 .await
346 {
347 if let Some(newest) = page.newest_seq {
348 last_seq.insert(room_id, newest);
349 }
350 let frame = ServerFrame::History {
351 room_id,
352 messages: page.items,
353 };
354 let _ = send_frame(sink, &frame).await;
355 }
356 }
357 ClientFrame::MarkRead {
358 room_id,
359 message_id,
360 seq,
361 } => {
362 let _ = service
363 .mark_read(uid, room_id, MarkReadCmd { message_id, seq })
364 .await;
365 }
366 ClientFrame::Typing { room_id, thread_id } => {
367 if service_can_read(service, uid, room_id).await.is_ok() {
368 let event = RoomEvent::Typing {
369 room_id,
370 user_id: uid,
371 thread_id,
372 };
373 service.realtime.publish_room(&event).await;
374 }
375 }
376 ClientFrame::Ping => {
377 let _ = send_frame(sink, &ServerFrame::Pong).await;
378 }
379 }
380}
381
382async fn service_can_read(
383 service: &ChatService,
384 uid: Uuid,
385 room_id: Uuid,
386) -> Result<(), ChatError> {
387 crate::share::lookup::require_room_readable(service, room_id, uid)
388 .await
389 .map(|_| ())
390}
391
392async fn send_frame<S>(sink: &mut S, frame: &ServerFrame) -> Result<(), axum::Error>
393where
394 S: futures::Sink<Message, Error = axum::Error> + Unpin,
395{
396 let payload = match serde_json::to_string(frame) {
397 Ok(payload) => payload,
398 Err(_) => return Ok(()),
399 };
400 sink.send(Message::Text(payload.into())).await
401}
402
403async fn close_with_unsplit<S>(mut sink: S, message: &str) -> Result<(), axum::Error>
404where
405 S: futures::Sink<Message, Error = axum::Error> + Unpin,
406{
407 let frame = ServerFrame::Bye {
408 message: message.to_string(),
409 };
410 let payload = serde_json::to_string(&frame).unwrap_or_default();
411 sink.send(Message::Text(payload.into())).await?;
412 sink.send(Message::Close(None)).await
413}
Added lib/chat/src/ws/ticket.rs lib/chat/src/ws/ticket.rs +61 −0 View lib/chat/src/ws/ticket.rs
@@ -0,0 +1,61 @@
1use base64::Engine;
2use hmac::{Hmac, KeyInit, Mac};
3use serde::{Deserialize, Serialize};
4use sha2::Sha256;
5use time::OffsetDateTime;
6use uuid::Uuid;
7
8use crate::errors::ChatError;
9
10type HmacSha256 = Hmac<Sha256>;
11
12#[derive(Debug, Serialize, Deserialize)]
13struct TicketPayload {
14 uid: Uuid,
15 sid: Uuid,
16 exp: i64,
17}
18
19pub fn issue_ticket(secret: &str, uid: Uuid, ttl_secs: u64) -> Result<String, ChatError> {
20 if secret.is_empty() {
21 return Err(ChatError::Internal("chat ticket_secret not configured".into()));
22 }
23 let payload = TicketPayload {
24 uid,
25 sid: Uuid::now_v7(),
26 exp: OffsetDateTime::now_utc().unix_timestamp() + ttl_secs as i64,
27 };
28 let payload_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD
29 .encode(serde_json::to_vec(&payload)?);
30 let mut mac = HmacSha256::new_from_slice(secret.as_bytes())
31 .map_err(|e| ChatError::Internal(format!("hmac init failed: {e}")))?;
32 mac.update(payload_b64.as_bytes());
33 let sig = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes());
34 Ok(format!("{payload_b64}.{sig}"))
35}
36
37pub fn verify_ticket(secret: &str, ticket: &str) -> Result<(Uuid, Uuid), ChatError> {
38 if secret.is_empty() {
39 return Err(ChatError::Unauthorized);
40 }
41 let Some((payload_b64, sig_b64)) = ticket.split_once('.') else {
42 return Err(ChatError::Unauthorized);
43 };
44 let mut mac = HmacSha256::new_from_slice(secret.as_bytes())
45 .map_err(|_| ChatError::Unauthorized)?;
46 mac.update(payload_b64.as_bytes());
47 let sig = base64::engine::general_purpose::URL_SAFE_NO_PAD
48 .decode(sig_b64)
49 .map_err(|_| ChatError::Unauthorized)?;
50 mac.verify_slice(&sig).map_err(|_| ChatError::Unauthorized)?;
51
52 let payload_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
53 .decode(payload_b64)
54 .map_err(|_| ChatError::Unauthorized)?;
55 let payload: TicketPayload =
56 serde_json::from_slice(&payload_bytes).map_err(|_| ChatError::Unauthorized)?;
57 if payload.exp < OffsetDateTime::now_utc().unix_timestamp() {
58 return Err(ChatError::Unauthorized);
59 }
60 Ok((payload.uid, payload.sid))
61}
Added lib/chat/src/errors.rs lib/chat/src/errors.rs +202 −0 View lib/chat/src/errors.rs
@@ -0,0 +1,202 @@
1use std::fmt;
2
3use axum::Json;
4use axum::extract::rejection::{PathRejection, QueryRejection};
5use axum::http::StatusCode;
6use axum::response::{IntoResponse, Response};
7use export::rest::{AppResponseData, NoData};
8use utoipa::IntoResponses;
9
10#[derive(Debug)]
11pub enum ChatError {
12 BadRequest(String),
13 Unauthorized,
14 Forbidden,
15 NotFound,
16 Conflict(String),
17 Internal(String),
18}
19
20impl fmt::Display for ChatError {
21 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
22 match self {
23 Self::BadRequest(message) => write!(f, "{message}"),
24 Self::Unauthorized => write!(f, "unauthorized"),
25 Self::Forbidden => write!(f, "forbidden"),
26 Self::NotFound => write!(f, "not found"),
27 Self::Conflict(message) => write!(f, "{message}"),
28 Self::Internal(message) => write!(f, "{message}"),
29 }
30 }
31}
32
33impl std::error::Error for ChatError {}
34
35impl From<sea_orm::DbErr> for ChatError {
36 fn from(error: sea_orm::DbErr) -> Self {
37 if is_unique_violation(&error) {
38 return Self::Conflict("resource already exists".to_string());
39 }
40 Self::Internal(format!("database error: {error}"))
41 }
42}
43
44impl From<sea_orm::TransactionError<sea_orm::DbErr>> for ChatError {
45 fn from(error: sea_orm::TransactionError<sea_orm::DbErr>) -> Self {
46 match &error {
47 sea_orm::TransactionError::Transaction(db_err) if is_unique_violation(db_err) => {
48 Self::Conflict("resource already exists".to_string())
49 }
50 _ => Self::Internal(format!("database transaction error: {error}")),
51 }
52 }
53}
54
55impl From<sea_orm::TransactionError<ChatError>> for ChatError {
56 fn from(error: sea_orm::TransactionError<ChatError>) -> Self {
57 match error {
58 sea_orm::TransactionError::Connection(db_err) => Self::from(db_err),
59 sea_orm::TransactionError::Transaction(chat_err) => chat_err,
60 }
61 }
62}
63
64fn is_unique_violation(err: &sea_orm::DbErr) -> bool {
65 let msg = err.to_string().to_lowercase();
66 msg.contains("unique") || msg.contains("duplicate") || msg.contains("23505")
67}
68
69impl From<space::errors::SpaceError> for ChatError {
70 fn from(error: space::errors::SpaceError) -> Self {
71 match error {
72 space::errors::SpaceError::BadRequest(msg) => Self::BadRequest(msg),
73 space::errors::SpaceError::Unauthorized => Self::Unauthorized,
74 space::errors::SpaceError::Forbidden => Self::Forbidden,
75 space::errors::SpaceError::NotFound => Self::NotFound,
76 space::errors::SpaceError::Conflict(msg) => Self::Conflict(msg),
77 space::errors::SpaceError::Internal(msg) => Self::Internal(msg),
78 }
79 }
80}
81
82impl From<export::ExportError> for ChatError {
83 fn from(error: export::ExportError) -> Self {
84 Self::Internal(format!("infrastructure error: {error}"))
85 }
86}
87
88impl From<storage::StorageError> for ChatError {
89 fn from(error: storage::StorageError) -> Self {
90 Self::Internal(format!("storage error: {error}"))
91 }
92}
93
94impl From<redis::RedisError> for ChatError {
95 fn from(error: redis::RedisError) -> Self {
96 Self::Internal(format!("redis error: {error}"))
97 }
98}
99
100impl From<serde_json::Error> for ChatError {
101 fn from(error: serde_json::Error) -> Self {
102 Self::BadRequest(format!("invalid payload: {error}"))
103 }
104}
105
106#[derive(Debug, IntoResponses)]
107pub enum ApiError {
108 #[response(status = 400, description = "request failed validation")]
109 BadRequest(#[to_schema] AppResponseData<NoData>),
110 #[response(status = 401, description = "authentication required")]
111 Unauthorized(#[to_schema] AppResponseData<NoData>),
112 #[response(status = 403, description = "operation not allowed")]
113 Forbidden(#[to_schema] AppResponseData<NoData>),
114 #[response(status = 404, description = "resource not found")]
115 NotFound(#[to_schema] AppResponseData<NoData>),
116 #[response(status = 409, description = "resource conflict")]
117 Conflict(#[to_schema] AppResponseData<NoData>),
118 #[response(status = 500, description = "internal server error")]
119 Internal(#[to_schema] AppResponseData<NoData>),
120}
121
122impl ApiError {
123 fn payload(code: i32, message: &'static str) -> AppResponseData<NoData> {
124 AppResponseData::err(code, message)
125 }
126
127 fn decompose(self) -> (u16, AppResponseData<NoData>) {
128 match self {
129 Self::BadRequest(envelope) => (400, envelope),
130 Self::Unauthorized(envelope) => (401, envelope),
131 Self::Forbidden(envelope) => (403, envelope),
132 Self::NotFound(envelope) => (404, envelope),
133 Self::Conflict(envelope) => (409, envelope),
134 Self::Internal(envelope) => (500, envelope),
135 }
136 }
137}
138
139impl IntoResponse for ApiError {
140 fn into_response(self) -> Response {
141 let (code, envelope) = self.decompose();
142 let status = match StatusCode::from_u16(code) {
143 Ok(status) => status,
144 Err(_) => StatusCode::INTERNAL_SERVER_ERROR,
145 };
146 (status, Json(envelope)).into_response()
147 }
148}
149
150impl From<ChatError> for ApiError {
151 fn from(error: ChatError) -> Self {
152 match error {
153 ChatError::BadRequest(msg) => Self::BadRequest(AppResponseData::err(400, msg)),
154 ChatError::Unauthorized => Self::Unauthorized(Self::payload(401, "unauthorized")),
155 ChatError::Forbidden => Self::Forbidden(Self::payload(403, "forbidden")),
156 ChatError::NotFound => Self::NotFound(Self::payload(404, "not found")),
157 ChatError::Conflict(msg) => Self::Conflict(AppResponseData::err(409, msg)),
158 ChatError::Internal(message) => {
159 tracing::warn!(error = %message, "chat handler failed");
160 Self::Internal(Self::payload(500, "internal server error"))
161 }
162 }
163 }
164}
165
166impl From<Box<ApiError>> for ApiError {
167 fn from(error: Box<ApiError>) -> Self {
168 *error
169 }
170}
171
172impl From<session::SessionError> for ApiError {
173 fn from(error: session::SessionError) -> Self {
174 match error {
175 session::SessionError::Unauthorized => {
176 Self::Unauthorized(Self::payload(401, "unauthorized"))
177 }
178 session::SessionError::Database(message) => {
179 tracing::warn!(error = %message, "chat handler failed");
180 Self::Internal(Self::payload(500, "internal server error"))
181 }
182 }
183 }
184}
185
186impl From<PathRejection> for ApiError {
187 fn from(_error: PathRejection) -> Self {
188 Self::BadRequest(Self::payload(400, "bad request"))
189 }
190}
191
192impl From<serde_json::Error> for ApiError {
193 fn from(_error: serde_json::Error) -> Self {
194 Self::BadRequest(Self::payload(400, "bad request"))
195 }
196}
197
198impl From<QueryRejection> for ApiError {
199 fn from(_error: QueryRejection) -> Self {
200 Self::BadRequest(Self::payload(400, "bad request"))
201 }
202}
Added lib/chat/src/lib.rs lib/chat/src/lib.rs +8 −0 View lib/chat/src/lib.rs
@@ -0,0 +1,8 @@
1pub mod errors;
2pub mod handlers;
3pub mod openapi;
4pub mod routes;
5pub mod services;
6pub mod share;
7pub mod worker;
8pub mod ws;
Added lib/chat/src/openapi.rs lib/chat/src/openapi.rs +145 −0 View lib/chat/src/openapi.rs
@@ -0,0 +1,145 @@
1use export::rest::AppResponseData;
2use utoipa::OpenApi;
3
4#[derive(OpenApi)]
5#[openapi(
6 paths(
7 crate::handlers::rooms::list_space_rooms,
8 crate::handlers::rooms::get_room,
9 crate::handlers::rooms::update_room,
10 crate::handlers::rooms::join_room,
11 crate::handlers::rooms::leave_room,
12 crate::handlers::rooms::add_room_members,
13 crate::handlers::rooms::list_room_members,
14 crate::handlers::rooms::create_room_invite,
15 crate::handlers::rooms::accept_room_invite,
16 crate::handlers::messages::send_message,
17 crate::handlers::messages::list_messages,
18 crate::handlers::messages::get_message,
19 crate::handlers::messages::update_message,
20 crate::handlers::messages::delete_message,
21 crate::handlers::messages::bulk_delete_messages,
22 crate::handlers::messages::set_message_pin,
23 crate::handlers::messages::list_pins,
24 crate::handlers::messages::forward_message,
25 crate::handlers::messages::list_message_history,
26 crate::handlers::threads::create_thread,
27 crate::handlers::threads::list_threads,
28 crate::handlers::threads::list_my_threads,
29 crate::handlers::threads::get_thread,
30 crate::handlers::threads::update_thread,
31 crate::handlers::threads::follow_thread,
32 crate::handlers::threads::unfollow_thread,
33 crate::handlers::dms::open_dm,
34 crate::handlers::dms::list_dms,
35 crate::handlers::dms::close_dm,
36 crate::handlers::reactions::add_reaction,
37 crate::handlers::reactions::remove_reaction,
38 crate::handlers::reactions::list_reactions,
39 crate::handlers::read_state::mark_read,
40 crate::handlers::read_state::get_read_state,
41 crate::handlers::read_state::list_space_read_states,
42 crate::handlers::read_state::mark_thread_read,
43 crate::handlers::attachments::create_attachment,
44 crate::handlers::attachments::finalize_attachment,
45 crate::handlers::attachments::get_attachment,
46 crate::handlers::attachments::download_attachment,
47 crate::handlers::emojis::list_emojis,
48 crate::handlers::emojis::upload_emoji,
49 crate::handlers::emojis::delete_emoji,
50 crate::handlers::emojis::emoji_image,
51 crate::handlers::bookmarks::list_bookmarks,
52 crate::handlers::bookmarks::create_bookmark,
53 crate::handlers::bookmarks::update_bookmark,
54 crate::handlers::bookmarks::delete_bookmark,
55 crate::handlers::reminders::create_reminder,
56 crate::handlers::reminders::list_reminders,
57 crate::handlers::reminders::cancel_reminder,
58 crate::handlers::drafts::upsert_draft,
59 crate::handlers::drafts::get_draft,
60 crate::handlers::drafts::list_my_drafts,
61 crate::handlers::presence::set_presence,
62 crate::handlers::presence::get_presence,
63 crate::handlers::presence::list_space_presences,
64 crate::handlers::search::search_messages,
65 crate::handlers::settings::get_room_setting,
66 crate::handlers::settings::update_room_setting,
67 crate::handlers::webhooks::create_webhook,
68 crate::handlers::webhooks::list_webhooks,
69 crate::handlers::webhooks::delete_webhook,
70 crate::handlers::webhooks::post_via_webhook,
71 crate::handlers::ws::issue_ws_ticket,
72 ),
73 components(schemas(
74 AppResponseData<export::rest::NoData>,
75 export::rest::NoData,
76 space::services::spaces::UserBriefDto,
77 models::chat::messages::AuthorType,
78 models::chat::messages::ChatMessageType,
79 models::chat::attachments::AttachmentStatus,
80 models::chat::room_user_settings::RoomNotificationLevel,
81 models::chat::thread_members::ThreadNotifyLevel,
82 models::chat::presence_states::PresenceStatus,
83 models::chat::message_reminders::ReminderStatus,
84 models::room::rooms::RoomKind,
85 crate::services::messages::MessageDto,
86 crate::services::messages::MessagePage,
87 crate::services::messages::MessageEditDto,
88 crate::services::messages::SendMessageCmd,
89 crate::services::messages::UpdateMessageCmd,
90 crate::services::messages::BulkDeleteCmd,
91 crate::services::messages::PinCmd,
92 crate::services::messages::ForwardMessageCmd,
93 crate::services::messages::ListMessageQuery,
94 crate::services::messages::MessagePath,
95 crate::services::rooms::RoomBriefDto,
96 crate::services::rooms::UpdateRoomCmd,
97 crate::services::rooms::AddMembersCmd,
98 crate::services::rooms::RoomMemberDto,
99 crate::services::rooms::RoomMemberQuery,
100 crate::services::rooms::CreateRoomInviteCmd,
101 crate::services::rooms::RoomInviteDto,
102 crate::services::threads::ThreadDto,
103 crate::services::threads::ThreadPage,
104 crate::services::threads::CreateThreadCmd,
105 crate::services::threads::UpdateThreadCmd,
106 crate::services::threads::ThreadMemberCmd,
107 crate::services::threads::ListThreadQuery,
108 crate::services::dms::DmDto,
109 crate::services::dms::OpenDmCmd,
110 crate::services::reactions::ReactionAggDto,
111 crate::services::reactions::ReactionUserDto,
112 crate::services::reactions::ReactionCmd,
113 crate::services::read_state::ReadStateDto,
114 crate::services::read_state::SpaceReadStatePage,
115 crate::services::read_state::MarkReadCmd,
116 crate::handlers::read_state::MarkThreadReadQuery,
117 crate::services::attachments::AttachmentDto,
118 crate::services::attachments::CreateAttachmentCmd,
119 crate::services::attachments::UploadIntentDto,
120 crate::services::attachments::FinalizeAttachmentCmd,
121 crate::services::emojis::EmojiDto,
122 crate::services::emojis::UploadEmojiCmd,
123 crate::services::bookmarks::BookmarkDto,
124 crate::services::bookmarks::CreateBookmarkCmd,
125 crate::services::bookmarks::UpdateBookmarkCmd,
126 crate::services::reminders::ReminderDto,
127 crate::services::reminders::CreateReminderCmd,
128 crate::services::drafts::DraftDto,
129 crate::services::drafts::UpsertDraftCmd,
130 crate::handlers::drafts::DraftQuery,
131 crate::services::presence::PresenceDto,
132 crate::services::presence::SetPresenceCmd,
133 crate::services::search::SearchHitDto,
134 crate::services::search::SearchPage,
135 crate::services::search::SearchQuery,
136 crate::services::settings::RoomSettingDto,
137 crate::services::settings::UpdateRoomSettingCmd,
138 crate::services::webhooks::WebhookDto,
139 crate::services::webhooks::WebhookSecretDto,
140 crate::services::webhooks::CreateWebhookCmd,
141 crate::services::webhooks::WebhookPostCmd,
142 crate::handlers::ws::WsTicketDto,
143 ))
144)]
145pub struct ChatApiDoc;
Added lib/chat/src/routes.rs lib/chat/src/routes.rs +201 −0 View lib/chat/src/routes.rs
@@ -0,0 +1,201 @@
1use axum::Router;
2use axum::routing::{get, post};
3
4use crate::handlers::attachments::{
5 create_attachment, download_attachment, finalize_attachment, get_attachment,
6};
7use crate::handlers::bookmarks::{
8 create_bookmark, delete_bookmark, list_bookmarks, update_bookmark,
9};
10use crate::handlers::drafts::{get_draft, list_my_drafts, upsert_draft};
11use crate::handlers::dms::{close_dm, list_dms, open_dm};
12use crate::handlers::emojis::{delete_emoji, emoji_image, list_emojis, upload_emoji};
13use crate::handlers::messages::{
14 bulk_delete_messages, delete_message, forward_message, get_message, list_message_history,
15 list_messages, list_pins, send_message, set_message_pin, update_message,
16};
17use crate::handlers::presence::{get_presence, list_space_presences, set_presence};
18use crate::handlers::reactions::{add_reaction, list_reactions, remove_reaction};
19use crate::handlers::read_state::{
20 get_read_state, list_space_read_states, mark_read, mark_thread_read,
21};
22use crate::handlers::reminders::{cancel_reminder, create_reminder, list_reminders};
23use crate::handlers::rooms::{
24 accept_room_invite, add_room_members, create_room_invite, get_room, join_room, leave_room,
25 list_room_members, list_space_rooms, update_room,
26};
27use crate::handlers::search::search_messages;
28use crate::handlers::settings::{get_room_setting, update_room_setting};
29use crate::handlers::threads::{
30 create_thread, follow_thread, get_thread, list_my_threads, list_threads, unfollow_thread,
31 update_thread,
32};
33use crate::handlers::webhooks::{
34 create_webhook, delete_webhook, list_webhooks, post_via_webhook,
35};
36use crate::handlers::ws::issue_ws_ticket;
37use crate::services::service::ChatService;
38use crate::share::guard::chat_guard;
39use crate::ws::ws_upgrade;
40
41pub fn router(service: ChatService) -> Router<ChatService> {
42 let guarded = Router::new()
43 .route("/chat/spaces/{space_name}/rooms", get(list_space_rooms))
44 .route(
45 "/chat/rooms/{room_id}",
46 get(get_room).patch(update_room),
47 )
48 .route(
49 "/chat/rooms/{room_id}/members/@me",
50 axum::routing::put(join_room).delete(leave_room),
51 )
52 .route(
53 "/chat/rooms/{room_id}/members",
54 get(list_room_members).post(add_room_members),
55 )
56 .route("/chat/rooms/{room_id}/invites", post(create_room_invite))
57 .route("/chat/invites/{accept_code}/accept", post(accept_room_invite))
58 .route(
59 "/chat/rooms/{room_id}/messages",
60 get(list_messages).post(send_message),
61 )
62 .route(
63 "/chat/rooms/{room_id}/messages/bulk-delete",
64 post(bulk_delete_messages),
65 )
66 .route(
67 "/chat/rooms/{room_id}/messages/{message_id}",
68 get(get_message)
69 .patch(update_message)
70 .delete(delete_message),
71 )
72 .route(
73 "/chat/rooms/{room_id}/messages/{message_id}/pin",
74 axum::routing::put(set_message_pin),
75 )
76 .route("/chat/rooms/{room_id}/pins", get(list_pins))
77 .route(
78 "/chat/rooms/{room_id}/messages/{message_id}/forward",
79 post(forward_message),
80 )
81 .route(
82 "/chat/rooms/{room_id}/messages/{message_id}/history",
83 get(list_message_history),
84 )
85 .route(
86 "/chat/rooms/{room_id}/messages/{message_id}/threads",
87 post(create_thread),
88 )
89 .route("/chat/rooms/{room_id}/threads", get(list_threads))
90 .route("/chat/threads", get(list_my_threads))
91 .route(
92 "/chat/threads/{thread_id}",
93 get(get_thread).patch(update_thread),
94 )
95 .route(
96 "/chat/threads/{thread_id}/members/@me",
97 axum::routing::put(follow_thread).delete(unfollow_thread),
98 )
99 .route(
100 "/chat/threads/{thread_id}/read-state",
101 axum::routing::put(mark_thread_read),
102 )
103 .route(
104 "/chat/rooms/{room_id}/messages/{message_id}/reactions",
105 get(list_reactions),
106 )
107 .route(
108 "/chat/rooms/{room_id}/messages/{message_id}/reactions/@me",
109 axum::routing::put(add_reaction).delete(remove_reaction),
110 )
111 .route(
112 "/chat/rooms/{room_id}/messages/{message_id}/reminders",
113 post(create_reminder),
114 )
115 .route("/chat/reminders", get(list_reminders))
116 .route(
117 "/chat/reminders/{reminder_id}",
118 axum::routing::delete(cancel_reminder),
119 )
120 .route(
121 "/chat/rooms/{room_id}/read-state",
122 get(get_read_state).put(mark_read),
123 )
124 .route(
125 "/chat/spaces/{space_name}/read-states",
126 get(list_space_read_states),
127 )
128 .route("/chat/dms", get(list_dms).post(open_dm))
129 .route("/chat/dms/{room_id}", axum::routing::delete(close_dm))
130 .route("/chat/attachments", post(create_attachment))
131 .route(
132 "/chat/attachments/{attachment_id}",
133 get(get_attachment),
134 )
135 .route(
136 "/chat/attachments/{attachment_id}/finalize",
137 post(finalize_attachment),
138 )
139 .route(
140 "/chat/attachments/{attachment_id}/file",
141 get(download_attachment),
142 )
143 .route(
144 "/chat/spaces/{space_name}/emojis",
145 get(list_emojis).post(upload_emoji),
146 )
147 .route(
148 "/chat/spaces/{space_name}/emojis/{emoji_id}",
149 axum::routing::delete(delete_emoji),
150 )
151 .route(
152 "/chat/spaces/{space_name}/emojis/{emoji_id}/image",
153 get(emoji_image),
154 )
155 .route(
156 "/chat/rooms/{room_id}/bookmarks",
157 get(list_bookmarks).post(create_bookmark),
158 )
159 .route(
160 "/chat/rooms/{room_id}/bookmarks/{bookmark_id}",
161 axum::routing::patch(update_bookmark).delete(delete_bookmark),
162 )
163 .route(
164 "/chat/rooms/{room_id}/draft",
165 get(get_draft).put(upsert_draft),
166 )
167 .route("/chat/drafts", get(list_my_drafts))
168 .route("/chat/presence", axum::routing::put(set_presence))
169 .route("/chat/users/{user_id}/presence", get(get_presence))
170 .route(
171 "/chat/spaces/{space_name}/presences",
172 get(list_space_presences),
173 )
174 .route(
175 "/chat/spaces/{space_name}/messages/search",
176 get(search_messages),
177 )
178 .route(
179 "/chat/rooms/{room_id}/settings",
180 get(get_room_setting).put(update_room_setting),
181 )
182 .route(
183 "/chat/rooms/{room_id}/webhooks",
184 get(list_webhooks).post(create_webhook),
185 )
186 .route(
187 "/chat/rooms/{room_id}/webhooks/{webhook_id}",
188 axum::routing::delete(delete_webhook),
189 )
190 .route("/chat/ws/ticket", post(issue_ws_ticket))
191 .route_layer(axum::middleware::from_fn_with_state(
192 service.clone(),
193 chat_guard,
194 ));
195
196 let open = Router::new()
197 .route("/chat/ws", get(ws_upgrade))
198 .route("/chat/hooks/{token}", post(post_via_webhook));
199
200 guarded.merge(open)
201}
Added lib/chat/src/worker.rs lib/chat/src/worker.rs +64 −0 View lib/chat/src/worker.rs
@@ -0,0 +1,64 @@
1use std::time::Duration;
2
3use export::AppState;
4use models::users::user_scheduled_messages::{self, ScheduledMessageStatus};
5use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QuerySelect, Set};
6use time::OffsetDateTime;
7
8use crate::errors::ChatError;
9use crate::services::messages::SendMessageCmd;
10use crate::services::service::ChatService;
11
12impl ChatService {
13 pub async fn deliver_due_scheduled_messages(&self) -> Result<u64, ChatError> {
14 let now = OffsetDateTime::now_utc();
15 let due = user_scheduled_messages::Entity::find()
16 .filter(user_scheduled_messages::Column::Status.eq(ScheduledMessageStatus::Pending))
17 .filter(user_scheduled_messages::Column::ScheduledAt.lte(now))
18 .limit(100)
19 .all(self.db().write())
20 .await?;
21 let count = due.len() as u64;
22 for scheduled in due {
23 let cmd: SendMessageCmd = match serde_json::from_value(scheduled.payload.clone()) {
24 Ok(cmd) => cmd,
25 Err(error) => {
26 tracing::warn!(%error, id = %scheduled.id, "invalid scheduled message payload");
27 let mut am: user_scheduled_messages::ActiveModel = scheduled.into();
28 am.status = Set(ScheduledMessageStatus::Cancelled);
29 am.update(self.db().write()).await?;
30 continue;
31 }
32 };
33 match self
34 .send_message(scheduled.user_id, scheduled.target_channel_id, cmd)
35 .await
36 {
37 Ok(_) => {
38 let mut am: user_scheduled_messages::ActiveModel = scheduled.into();
39 am.status = Set(ScheduledMessageStatus::Sent);
40 am.update(self.db().write()).await?;
41 }
42 Err(error) => {
43 tracing::warn!(%error, id = %scheduled.id, "scheduled message delivery failed");
44 }
45 }
46 }
47 Ok(count)
48 }
49}
50
51pub async fn run_background_tasks(state: AppState) {
52 let service = ChatService::new(state);
53 let mut interval = tokio::time::interval(Duration::from_secs(30));
54 interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
55 loop {
56 interval.tick().await;
57 if let Err(error) = service.deliver_due_reminders().await {
58 tracing::warn!(%error, "chat reminder delivery failed");
59 }
60 if let Err(error) = service.deliver_due_scheduled_messages().await {
61 tracing::warn!(%error, "chat scheduled message delivery failed");
62 }
63 }
64}
Added lib/chat/Cargo.toml lib/chat/Cargo.toml +30 −0 View lib/chat/Cargo.toml
@@ -0,0 +1,30 @@
1[package]
2name = "chat"
3version.workspace = true
4edition.workspace = true
5
6[dependencies]
7async-trait = { workspace = true }
8config = { workspace = true }
9export = { workspace = true }
10models = { workspace = true }
11session = { workspace = true }
12space = { workspace = true }
13storage = { workspace = true }
14axum = { workspace = true }
15sea-orm = { workspace = true }
16redis = { workspace = true }
17serde = { workspace = true, features = ["derive"] }
18serde_json = { workspace = true }
19utoipa = { workspace = true }
20uuid = { workspace = true }
21time = { workspace = true }
22base64 = { workspace = true }
23rand = { workspace = true }
24sha2 = { workspace = true }
25hmac = { workspace = true }
26tracing = { workspace = true }
27tokio = { workspace = true }
28tokio-util = { workspace = true }
29futures = { workspace = true }
30bytes = { workspace = true }
Added lib/config/src/chat.rs lib/config/src/chat.rs +96 −0 View lib/config/src/chat.rs
@@ -0,0 +1,96 @@
1use serde::Deserialize;
2
3#[derive(Clone, Debug, Deserialize)]
4#[serde(default)]
5pub struct ChatWsConfig {
6 pub ticket_ttl_secs: u64,
7 pub heartbeat_secs: u64,
8 pub max_subscriptions: usize,
9 pub max_frame_bytes: usize,
10 pub history_limit: u64,
11}
12
13impl Default for ChatWsConfig {
14 fn default() -> Self {
15 Self {
16 ticket_ttl_secs: 60,
17 heartbeat_secs: 30,
18 max_subscriptions: 64,
19 max_frame_bytes: 262_144,
20 history_limit: 200,
21 }
22 }
23}
24
25#[derive(Clone, Debug, Deserialize)]
26#[serde(default)]
27pub struct ChatUploadConfig {
28 pub max_size: i64,
29 pub presign_ttl_secs: u64,
30}
31
32impl Default for ChatUploadConfig {
33 fn default() -> Self {
34 Self {
35 max_size: 104_857_600,
36 presign_ttl_secs: 3600,
37 }
38 }
39}
40
41#[derive(Clone, Debug, Deserialize)]
42#[serde(default)]
43pub struct ChatConfig {
44 pub max_pagination: u64,
45 pub default_pagination: u64,
46 pub max_message_len: usize,
47 pub max_topic_len: usize,
48 pub max_thread_name_len: usize,
49 pub max_reaction_emoji_len: usize,
50 pub max_emoji_name_len: usize,
51 pub max_bookmark_title_len: usize,
52 pub max_webhook_name_len: usize,
53 pub max_mentions_per_message: usize,
54 pub typing_ttl_secs: u64,
55 pub presence_ttl_secs: u64,
56 pub ticket_secret: String,
57 pub ws: ChatWsConfig,
58 pub upload: ChatUploadConfig,
59}
60
61impl Default for ChatConfig {
62 fn default() -> Self {
63 Self {
64 max_pagination: 200,
65 default_pagination: 50,
66 max_message_len: 4000,
67 max_topic_len: 1024,
68 max_thread_name_len: 255,
69 max_reaction_emoji_len: 64,
70 max_emoji_name_len: 64,
71 max_bookmark_title_len: 255,
72 max_webhook_name_len: 100,
73 max_mentions_per_message: 20,
74 typing_ttl_secs: 5,
75 presence_ttl_secs: 300,
76 ticket_secret: String::new(),
77 ws: ChatWsConfig::default(),
78 upload: ChatUploadConfig::default(),
79 }
80 }
81}
82
83impl ChatConfig {
84 pub fn is_valid(&self) -> Result<(), String> {
85 if self.max_pagination == 0 || self.default_pagination == 0 {
86 return Err("chat pagination must be positive".to_string());
87 }
88 if self.max_message_len == 0 {
89 return Err("chat max_message_len must be positive".to_string());
90 }
91 if self.ws.ticket_ttl_secs == 0 {
92 return Err("chat ws ticket_ttl_secs must be positive".to_string());
93 }
94 Ok(())
95 }
96}
Modified lib/config/src/lib.rs lib/config/src/lib.rs +4 −0 View lib/config/src/lib.rs
@@ -1,6 +1,7 @@
11pub mod apihost;
22pub mod auth;
33pub mod cache;
4pub mod chat;
45pub mod database;
56pub mod email;
67pub mod email_queue;
@@ -23,6 +24,7 @@
2324use crate::apihost::ApihostConfig;
2425use crate::auth::AuthConfig;
2526use crate::cache::CacheConfig;
27use crate::chat::ChatConfig;
2628use crate::database::DatabaseConfig;
2729use crate::email::EmailConfig;
2830use crate::email_queue::EmailQueueConfig;
@@ -44,6 +46,7 @@
4446 pub s3: S3Config,
4547 pub redis: RedisConfig,
4648 pub cache: CacheConfig,
49 pub chat: ChatConfig,
4750 pub database: DatabaseConfig,
4851 pub session: SessionConfig,
4952 pub pow: PowConfig,
@@ -70,6 +73,7 @@
7073 self.pow.validate().map_err(ConfigError::Validation)?;
7174 self.auth.validate().map_err(ConfigError::Validation)?;
7275 self.cache.validate().map_err(ConfigError::Validation)?;
76 self.chat.is_valid().map_err(ConfigError::Validation)?;
7377 self.email.is_valid().map_err(ConfigError::Validation)?;
7478 self.email_queue
7579 .is_valid()
Added lib/models/src/chat/attachments.rs lib/models/src/chat/attachments.rs +64 −0 View lib/models/src/chat/attachments.rs
@@ -0,0 +1,64 @@
1use sea_orm::JsonValue;
2use sea_orm::entity::prelude::*;
3use serde::{Deserialize, Serialize};
4use time::OffsetDateTime;
5use uuid::Uuid;
6
7#[derive(
8 Debug,
9 Clone,
10 Copy,
11 PartialEq,
12 Eq,
13 Default,
14 EnumIter,
15 DeriveActiveEnum,
16 Serialize,
17 Deserialize,
18 utoipa::ToSchema,
19)]
20#[sea_orm(rs_type = "i16", db_type = "SmallInteger")]
21pub enum AttachmentStatus {
22 #[default]
23 #[sea_orm(num_value = 0)]
24 Pending,
25 #[sea_orm(num_value = 1)]
26 Uploaded,
27 #[sea_orm(num_value = 2)]
28 Ready,
29 #[sea_orm(num_value = 3)]
30 Failed,
31}
32
33#[sea_orm::model]
34#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
35#[sea_orm(table_name = "chat_attachment")]
36pub struct Model {
37 #[sea_orm(primary_key, auto_increment = false)]
38 pub id: Uuid,
39 #[sea_orm(indexed)]
40 pub user_id: Uuid,
41 #[sea_orm(indexed)]
42 pub room_id: Option<Uuid>,
43 #[sea_orm(indexed)]
44 pub message_id: Option<Uuid>,
45 pub filename: String,
46 pub description: Option<String>,
47 pub content_type: String,
48 pub size: i64,
49 pub bucket: String,
50 pub s3_key: String,
51 #[sea_orm(default_value = 0)]
52 pub status: AttachmentStatus,
53 pub width: Option<i32>,
54 pub height: Option<i32>,
55 pub duration_secs: Option<i32>,
56 #[sea_orm(column_type = "JsonBinary", default_value = "[]")]
57 pub variants: JsonValue,
58 pub created_at: OffsetDateTime,
59 pub updated_at: OffsetDateTime,
60 #[sea_orm(belongs_to, from = "user_id", to = "uid")]
61 pub user: BelongsTo<super::super::users::user::Entity>,
62}
63
64impl ActiveModelBehavior for ActiveModel {}
Added lib/models/src/chat/emojis.rs lib/models/src/chat/emojis.rs +30 −0 View lib/models/src/chat/emojis.rs
@@ -0,0 +1,30 @@
1use sea_orm::entity::prelude::*;
2use time::OffsetDateTime;
3use uuid::Uuid;
4
5#[sea_orm::model]
6#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
7#[sea_orm(table_name = "chat_emoji")]
8pub struct Model {
9 #[sea_orm(primary_key, auto_increment = false)]
10 pub id: Uuid,
11 #[sea_orm(unique_key = "space_emoji_name")]
12 pub space_id: Uuid,
13 pub name: String,
14 #[sea_orm(unique_key = "space_emoji_name")]
15 pub lower_name: String,
16 pub user_id: Uuid,
17 pub bucket: String,
18 pub s3_key: String,
19 #[sea_orm(default_value = "false")]
20 pub animated: bool,
21 #[sea_orm(default_value = "false")]
22 pub disabled: bool,
23 pub created_at: OffsetDateTime,
24 #[sea_orm(belongs_to, from = "space_id", to = "id")]
25 pub space: BelongsTo<super::super::space::spaces::Entity>,
26 #[sea_orm(belongs_to, from = "user_id", to = "uid")]
27 pub user: BelongsTo<super::super::users::user::Entity>,
28}
29
30impl ActiveModelBehavior for ActiveModel {}
Added lib/models/src/chat/link_unfurls.rs lib/models/src/chat/link_unfurls.rs +45 −0 View lib/models/src/chat/link_unfurls.rs
@@ -0,0 +1,45 @@
1use sea_orm::entity::prelude::*;
2use serde::{Deserialize, Serialize};
3use time::OffsetDateTime;
4
5#[derive(
6 Debug,
7 Clone,
8 Copy,
9 PartialEq,
10 Eq,
11 Default,
12 EnumIter,
13 DeriveActiveEnum,
14 Serialize,
15 Deserialize,
16 utoipa::ToSchema,
17)]
18#[sea_orm(rs_type = "i16", db_type = "SmallInteger")]
19pub enum UnfurlStatus {
20 #[default]
21 #[sea_orm(num_value = 0)]
22 Pending,
23 #[sea_orm(num_value = 1)]
24 Ready,
25 #[sea_orm(num_value = 2)]
26 Failed,
27}
28
29#[sea_orm::model]
30#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
31#[sea_orm(table_name = "chat_link_unfurl")]
32pub struct Model {
33 #[sea_orm(primary_key, auto_increment = false)]
34 pub url_hash: String,
35 pub url: String,
36 pub title: Option<String>,
37 pub description: Option<String>,
38 pub image_url: Option<String>,
39 pub site_name: Option<String>,
40 #[sea_orm(default_value = 0)]
41 pub status: UnfurlStatus,
42 pub fetched_at: OffsetDateTime,
43}
44
45impl ActiveModelBehavior for ActiveModel {}
Added lib/models/src/chat/message_edits.rs lib/models/src/chat/message_edits.rs +25 −0 View lib/models/src/chat/message_edits.rs
@@ -0,0 +1,25 @@
1use sea_orm::JsonValue;
2use sea_orm::entity::prelude::*;
3use time::OffsetDateTime;
4use uuid::Uuid;
5
6#[sea_orm::model]
7#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
8#[sea_orm(table_name = "chat_message_edit")]
9pub struct Model {
10 #[sea_orm(primary_key, auto_increment = false)]
11 pub id: Uuid,
12 #[sea_orm(indexed)]
13 pub message_id: Uuid,
14 pub editor_id: Uuid,
15 pub content: String,
16 #[sea_orm(column_type = "JsonBinary", default_value = "[]")]
17 pub attachments: JsonValue,
18 pub created_at: OffsetDateTime,
19 #[sea_orm(belongs_to, from = "message_id", to = "id")]
20 pub message: BelongsTo<super::messages::Entity>,
21 #[sea_orm(belongs_to, from = "editor_id", to = "uid")]
22 pub editor: BelongsTo<super::super::users::user::Entity>,
23}
24
25impl ActiveModelBehavior for ActiveModel {}
Added lib/models/src/chat/message_reminders.rs lib/models/src/chat/message_reminders.rs +48 −0 View lib/models/src/chat/message_reminders.rs
@@ -0,0 +1,48 @@
1use sea_orm::entity::prelude::*;
2use serde::{Deserialize, Serialize};
3use time::OffsetDateTime;
4use uuid::Uuid;
5
6#[derive(
7 Debug,
8 Clone,
9 Copy,
10 PartialEq,
11 Eq,
12 Default,
13 EnumIter,
14 DeriveActiveEnum,
15 Serialize,
16 Deserialize,
17 utoipa::ToSchema,
18)]
19#[sea_orm(rs_type = "i16", db_type = "SmallInteger")]
20pub enum ReminderStatus {
21 #[default]
22 #[sea_orm(num_value = 0)]
23 Pending,
24 #[sea_orm(num_value = 1)]
25 Done,
26 #[sea_orm(num_value = 2)]
27 Cancelled,
28}
29
30#[sea_orm::model]
31#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
32#[sea_orm(table_name = "chat_message_reminder")]
33pub struct Model {
34 #[sea_orm(primary_key, auto_increment = false)]
35 pub id: Uuid,
36 #[sea_orm(indexed)]
37 pub user_id: Uuid,
38 pub room_id: Uuid,
39 pub message_id: Uuid,
40 pub remind_at: OffsetDateTime,
41 #[sea_orm(default_value = 0)]
42 pub status: ReminderStatus,
43 pub created_at: OffsetDateTime,
44 #[sea_orm(belongs_to, from = "user_id", to = "uid")]
45 pub user: BelongsTo<super::super::users::user::Entity>,
46}
47
48impl ActiveModelBehavior for ActiveModel {}
Added lib/models/src/chat/messages.rs lib/models/src/chat/messages.rs +124 −0 View lib/models/src/chat/messages.rs
@@ -0,0 +1,124 @@
1use sea_orm::JsonValue;
2use sea_orm::entity::prelude::*;
3use serde::{Deserialize, Serialize};
4use time::OffsetDateTime;
5use uuid::Uuid;
6
7#[derive(
8 Debug,
9 Clone,
10 Copy,
11 PartialEq,
12 Eq,
13 Default,
14 EnumIter,
15 DeriveActiveEnum,
16 Serialize,
17 Deserialize,
18 utoipa::ToSchema,
19)]
20#[sea_orm(rs_type = "i16", db_type = "SmallInteger")]
21pub enum AuthorType {
22 #[default]
23 #[sea_orm(num_value = 0)]
24 User,
25 #[sea_orm(num_value = 1)]
26 Bot,
27 #[sea_orm(num_value = 2)]
28 Webhook,
29}
30
31#[derive(
32 Debug,
33 Clone,
34 Copy,
35 PartialEq,
36 Eq,
37 Default,
38 EnumIter,
39 DeriveActiveEnum,
40 Serialize,
41 Deserialize,
42 utoipa::ToSchema,
43)]
44#[sea_orm(rs_type = "i16", db_type = "SmallInteger")]
45pub enum ChatMessageType {
46 #[default]
47 #[sea_orm(num_value = 0)]
48 Default,
49 #[sea_orm(num_value = 1)]
50 Reply,
51 #[sea_orm(num_value = 2)]
52 ThreadCreated,
53 #[sea_orm(num_value = 3)]
54 ChannelJoin,
55 #[sea_orm(num_value = 4)]
56 ChannelLeave,
57 #[sea_orm(num_value = 5)]
58 ChannelTopic,
59 #[sea_orm(num_value = 6)]
60 ChannelPinnedItem,
61 #[sea_orm(num_value = 7)]
62 Reminder,
63 #[sea_orm(num_value = 8)]
64 Scheduled,
65 #[sea_orm(num_value = 9)]
66 System,
67}
68
69pub const FLAG_EPHEMERAL: i64 = 1;
70pub const FLAG_URGENT: i64 = 1 << 4;
71pub const FLAG_HAS_THREAD: i64 = 1 << 5;
72pub const FLAG_SUPPRESS_EMBED: i64 = 1 << 2;
73
74#[sea_orm::model]
75#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
76#[sea_orm(table_name = "chat_message")]
77pub struct Model {
78 #[sea_orm(primary_key, auto_increment = false)]
79 pub id: Uuid,
80 #[sea_orm(indexed, unique_key = "chat_message_room_seq")]
81 pub room_id: Uuid,
82 #[sea_orm(indexed)]
83 pub author_id: Uuid,
84 #[sea_orm(default_value = 0)]
85 pub author_type: AuthorType,
86 pub webhook_id: Option<Uuid>,
87 #[sea_orm(indexed)]
88 pub thread_id: Option<Uuid>,
89 #[sea_orm(default_value = "false")]
90 pub thread_broadcast: bool,
91 pub reply_to_id: Option<Uuid>,
92 pub forwarded_from_id: Option<Uuid>,
93 #[sea_orm(default_value = 0)]
94 pub message_type: ChatMessageType,
95 pub content: String,
96 #[sea_orm(column_type = "JsonBinary", default_value = "[]")]
97 pub mentions: JsonValue,
98 #[sea_orm(default_value = "false")]
99 pub mention_everyone: bool,
100 #[sea_orm(column_type = "JsonBinary", default_value = "[]")]
101 pub mention_group_ids: JsonValue,
102 #[sea_orm(column_type = "JsonBinary", default_value = "[]")]
103 pub attachments: JsonValue,
104 #[sea_orm(column_type = "JsonBinary", default_value = "[]")]
105 pub embeds: JsonValue,
106 #[sea_orm(default_value = 0)]
107 pub flags: i64,
108 pub nonce: Option<String>,
109 #[sea_orm(default_value = "false")]
110 pub pinned: bool,
111 pub pinned_by: Option<Uuid>,
112 pub pinned_at: Option<OffsetDateTime>,
113 pub edited_at: Option<OffsetDateTime>,
114 pub deleted_at: Option<OffsetDateTime>,
115 #[sea_orm(unique_key = "chat_message_room_seq")]
116 pub seq: i64,
117 pub created_at: OffsetDateTime,
118 #[sea_orm(belongs_to, from = "room_id", to = "id")]
119 pub room: BelongsTo<super::super::room::rooms::Entity>,
120 #[sea_orm(belongs_to, from = "author_id", to = "uid")]
121 pub author: BelongsTo<super::super::users::user::Entity>,
122}
123
124impl ActiveModelBehavior for ActiveModel {}
Added lib/models/src/chat/mod.rs lib/models/src/chat/mod.rs +18 −0 View lib/models/src/chat/mod.rs
@@ -0,0 +1,18 @@
1pub mod attachments;
2pub mod emojis;
3pub mod link_unfurls;
4pub mod message_edits;
5pub mod message_reminders;
6pub mod messages;
7pub mod presence_states;
8pub mod reactions;
9pub mod read_states;
10pub mod room_bookmarks;
11pub mod room_seq;
12pub mod room_user_settings;
13pub mod room_webhooks;
14pub mod thread_members;
15pub mod threads;
16pub mod user_drafts;
17
18pub use messages::*;
Added lib/models/src/chat/presence_states.rs lib/models/src/chat/presence_states.rs +49 −0 View lib/models/src/chat/presence_states.rs
@@ -0,0 +1,49 @@
1use sea_orm::JsonValue;
2use sea_orm::entity::prelude::*;
3use serde::{Deserialize, Serialize};
4use time::OffsetDateTime;
5use uuid::Uuid;
6
7#[derive(
8 Debug,
9 Clone,
10 Copy,
11 PartialEq,
12 Eq,
13 Default,
14 EnumIter,
15 DeriveActiveEnum,
16 Serialize,
17 Deserialize,
18 utoipa::ToSchema,
19)]
20#[sea_orm(rs_type = "i16", db_type = "SmallInteger")]
21pub enum PresenceStatus {
22 #[default]
23 #[sea_orm(num_value = 0)]
24 Active,
25 #[sea_orm(num_value = 1)]
26 Away,
27 #[sea_orm(num_value = 2)]
28 Dnd,
29 #[sea_orm(num_value = 3)]
30 Offline,
31}
32
33#[sea_orm::model]
34#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
35#[sea_orm(table_name = "chat_presence")]
36pub struct Model {
37 #[sea_orm(primary_key, auto_increment = false)]
38 pub user_id: Uuid,
39 #[sea_orm(default_value = 3)]
40 pub status: PresenceStatus,
41 #[sea_orm(column_type = "JsonBinary", default_value = "[]")]
42 pub activities: JsonValue,
43 pub last_seen_at: Option<OffsetDateTime>,
44 pub updated_at: OffsetDateTime,
45 #[sea_orm(belongs_to, from = "user_id", to = "uid")]
46 pub user: BelongsTo<super::super::users::user::Entity>,
47}
48
49impl ActiveModelBehavior for ActiveModel {}
Added lib/models/src/chat/reactions.rs lib/models/src/chat/reactions.rs +22 −0 View lib/models/src/chat/reactions.rs
@@ -0,0 +1,22 @@
1use sea_orm::entity::prelude::*;
2use time::OffsetDateTime;
3use uuid::Uuid;
4
5#[sea_orm::model]
6#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
7#[sea_orm(table_name = "chat_reaction")]
8pub struct Model {
9 #[sea_orm(primary_key, auto_increment = false)]
10 pub message_id: Uuid,
11 #[sea_orm(primary_key, auto_increment = false)]
12 pub emoji: String,
13 #[sea_orm(primary_key, auto_increment = false)]
14 pub user_id: Uuid,
15 pub created_at: OffsetDateTime,
16 #[sea_orm(belongs_to, from = "message_id", to = "id")]
17 pub message: BelongsTo<super::messages::Entity>,
18 #[sea_orm(belongs_to, from = "user_id", to = "uid")]
19 pub user: BelongsTo<super::super::users::user::Entity>,
20}
21
22impl ActiveModelBehavior for ActiveModel {}
Added lib/models/src/chat/read_states.rs lib/models/src/chat/read_states.rs +24 −0 View lib/models/src/chat/read_states.rs
@@ -0,0 +1,24 @@
1use sea_orm::entity::prelude::*;
2use time::OffsetDateTime;
3use uuid::Uuid;
4
5#[sea_orm::model]
6#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
7#[sea_orm(table_name = "chat_read_state")]
8pub struct Model {
9 #[sea_orm(primary_key, auto_increment = false)]
10 pub user_id: Uuid,
11 #[sea_orm(primary_key, auto_increment = false)]
12 pub room_id: Uuid,
13 pub last_read_message_id: Option<Uuid>,
14 #[sea_orm(default_value = 0)]
15 pub last_read_seq: i64,
16 pub last_viewed_at: Option<OffsetDateTime>,
17 pub updated_at: OffsetDateTime,
18 #[sea_orm(belongs_to, from = "user_id", to = "uid")]
19 pub user: BelongsTo<super::super::users::user::Entity>,
20 #[sea_orm(belongs_to, from = "room_id", to = "id")]
21 pub room: BelongsTo<super::super::room::rooms::Entity>,
22}
23
24impl ActiveModelBehavior for ActiveModel {}
Added lib/models/src/chat/room_bookmarks.rs lib/models/src/chat/room_bookmarks.rs +27 −0 View lib/models/src/chat/room_bookmarks.rs
@@ -0,0 +1,27 @@
1use sea_orm::entity::prelude::*;
2use time::OffsetDateTime;
3use uuid::Uuid;
4
5#[sea_orm::model]
6#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
7#[sea_orm(table_name = "chat_room_bookmark")]
8pub struct Model {
9 #[sea_orm(primary_key, auto_increment = false)]
10 pub id: Uuid,
11 #[sea_orm(indexed)]
12 pub room_id: Uuid,
13 pub user_id: Uuid,
14 pub title: String,
15 pub url: Option<String>,
16 pub emoji: Option<String>,
17 #[sea_orm(default_value = 0)]
18 pub position: i64,
19 pub created_at: OffsetDateTime,
20 pub updated_at: OffsetDateTime,
21 #[sea_orm(belongs_to, from = "room_id", to = "id")]
22 pub room: BelongsTo<super::super::room::rooms::Entity>,
23 #[sea_orm(belongs_to, from = "user_id", to = "uid")]
24 pub user: BelongsTo<super::super::users::user::Entity>,
25}
26
27impl ActiveModelBehavior for ActiveModel {}
Added lib/models/src/chat/room_seq.rs lib/models/src/chat/room_seq.rs +16 −0 View lib/models/src/chat/room_seq.rs
@@ -0,0 +1,16 @@
1use sea_orm::entity::prelude::*;
2use time::OffsetDateTime;
3use uuid::Uuid;
4
5#[sea_orm::model]
6#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
7#[sea_orm(table_name = "chat_room_seq")]
8pub struct Model {
9 #[sea_orm(primary_key, auto_increment = false)]
10 pub room_id: Uuid,
11 #[sea_orm(default_value = 0)]
12 pub seq: i64,
13 pub updated_at: OffsetDateTime,
14}
15
16impl ActiveModelBehavior for ActiveModel {}
Added lib/models/src/chat/room_user_settings.rs lib/models/src/chat/room_user_settings.rs +56 −0 View lib/models/src/chat/room_user_settings.rs
@@ -0,0 +1,56 @@
1use sea_orm::entity::prelude::*;
2use serde::{Deserialize, Serialize};
3use time::OffsetDateTime;
4use uuid::Uuid;
5
6#[derive(
7 Debug,
8 Clone,
9 Copy,
10 PartialEq,
11 Eq,
12 Default,
13 EnumIter,
14 DeriveActiveEnum,
15 Serialize,
16 Deserialize,
17 utoipa::ToSchema,
18)]
19#[sea_orm(rs_type = "i16", db_type = "SmallInteger")]
20pub enum RoomNotificationLevel {
21 #[default]
22 #[sea_orm(num_value = 0)]
23 Default,
24 #[sea_orm(num_value = 1)]
25 All,
26 #[sea_orm(num_value = 2)]
27 Mentions,
28 #[sea_orm(num_value = 3)]
29 Nothing,
30}
31
32#[sea_orm::model]
33#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
34#[sea_orm(table_name = "chat_room_user_setting")]
35pub struct Model {
36 #[sea_orm(primary_key, auto_increment = false)]
37 pub user_id: Uuid,
38 #[sea_orm(primary_key, auto_increment = false)]
39 pub room_id: Uuid,
40 #[sea_orm(default_value = 0)]
41 pub notification_level: RoomNotificationLevel,
42 #[sea_orm(default_value = "false")]
43 pub muted: bool,
44 pub mute_until: Option<OffsetDateTime>,
45 #[sea_orm(default_value = "false")]
46 pub favorite: bool,
47 #[sea_orm(default_value = "false")]
48 pub hidden: bool,
49 pub updated_at: OffsetDateTime,
50 #[sea_orm(belongs_to, from = "user_id", to = "uid")]
51 pub user: BelongsTo<super::super::users::user::Entity>,
52 #[sea_orm(belongs_to, from = "room_id", to = "id")]
53 pub room: BelongsTo<super::super::room::rooms::Entity>,
54}
55
56impl ActiveModelBehavior for ActiveModel {}
Added lib/models/src/chat/room_webhooks.rs lib/models/src/chat/room_webhooks.rs +26 −0 View lib/models/src/chat/room_webhooks.rs
@@ -0,0 +1,26 @@
1use sea_orm::entity::prelude::*;
2use time::OffsetDateTime;
3use uuid::Uuid;
4
5#[sea_orm::model]
6#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
7#[sea_orm(table_name = "chat_room_webhook")]
8pub struct Model {
9 #[sea_orm(primary_key, auto_increment = false)]
10 pub id: Uuid,
11 #[sea_orm(indexed)]
12 pub room_id: Uuid,
13 pub user_id: Uuid,
14 pub name: String,
15 pub token_hash: String,
16 #[sea_orm(default_value = "false")]
17 pub disabled: bool,
18 pub last_used_at: Option<OffsetDateTime>,
19 pub created_at: OffsetDateTime,
20 #[sea_orm(belongs_to, from = "room_id", to = "id")]
21 pub room: BelongsTo<super::super::room::rooms::Entity>,
22 #[sea_orm(belongs_to, from = "user_id", to = "uid")]
23 pub user: BelongsTo<super::super::users::user::Entity>,
24}
25
26impl ActiveModelBehavior for ActiveModel {}
Added lib/models/src/chat/thread_members.rs lib/models/src/chat/thread_members.rs +49 −0 View lib/models/src/chat/thread_members.rs
@@ -0,0 +1,49 @@
1use sea_orm::entity::prelude::*;
2use serde::{Deserialize, Serialize};
3use time::OffsetDateTime;
4use uuid::Uuid;
5
6#[derive(
7 Debug,
8 Clone,
9 Copy,
10 PartialEq,
11 Eq,
12 Default,
13 EnumIter,
14 DeriveActiveEnum,
15 Serialize,
16 Deserialize,
17 utoipa::ToSchema,
18)]
19#[sea_orm(rs_type = "i16", db_type = "SmallInteger")]
20pub enum ThreadNotifyLevel {
21 #[sea_orm(num_value = 0)]
22 None,
23 #[default]
24 #[sea_orm(num_value = 1)]
25 All,
26}
27
28#[sea_orm::model]
29#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
30#[sea_orm(table_name = "chat_thread_member")]
31pub struct Model {
32 #[sea_orm(primary_key, auto_increment = false)]
33 pub thread_id: Uuid,
34 #[sea_orm(primary_key, auto_increment = false)]
35 pub user_id: Uuid,
36 #[sea_orm(default_value = 1)]
37 pub notify_level: ThreadNotifyLevel,
38 pub last_read_message_id: Option<Uuid>,
39 #[sea_orm(default_value = 0)]
40 pub last_read_seq: i64,
41 pub created_at: OffsetDateTime,
42 pub updated_at: OffsetDateTime,
43 #[sea_orm(belongs_to, from = "thread_id", to = "id")]
44 pub thread: BelongsTo<super::threads::Entity>,
45 #[sea_orm(belongs_to, from = "user_id", to = "uid")]
46 pub user: BelongsTo<super::super::users::user::Entity>,
47}
48
49impl ActiveModelBehavior for ActiveModel {}
Added lib/models/src/chat/threads.rs lib/models/src/chat/threads.rs +36 −0 View lib/models/src/chat/threads.rs
@@ -0,0 +1,36 @@
1use sea_orm::entity::prelude::*;
2use time::OffsetDateTime;
3use uuid::Uuid;
4
5#[sea_orm::model]
6#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
7#[sea_orm(table_name = "chat_thread")]
8pub struct Model {
9 #[sea_orm(primary_key, auto_increment = false)]
10 pub id: Uuid,
11 #[sea_orm(indexed)]
12 pub room_id: Uuid,
13 #[sea_orm(unique_key = "chat_thread_root_message")]
14 pub root_message_id: Uuid,
15 pub owner_id: Uuid,
16 pub name: Option<String>,
17 #[sea_orm(default_value = 0)]
18 pub reply_count: i64,
19 #[sea_orm(default_value = 0)]
20 pub participant_count: i64,
21 pub last_reply_id: Option<Uuid>,
22 pub last_reply_at: Option<OffsetDateTime>,
23 #[sea_orm(default_value = "false")]
24 pub archived: bool,
25 #[sea_orm(default_value = "false")]
26 pub locked: bool,
27 pub auto_archive_minutes: Option<i32>,
28 pub created_at: OffsetDateTime,
29 pub updated_at: OffsetDateTime,
30 #[sea_orm(belongs_to, from = "room_id", to = "id")]
31 pub room: BelongsTo<super::super::room::rooms::Entity>,
32 #[sea_orm(belongs_to, from = "owner_id", to = "uid")]
33 pub owner: BelongsTo<super::super::users::user::Entity>,
34}
35
36impl ActiveModelBehavior for ActiveModel {}
Added lib/models/src/chat/user_drafts.rs lib/models/src/chat/user_drafts.rs +25 −0 View lib/models/src/chat/user_drafts.rs
@@ -0,0 +1,25 @@
1use sea_orm::entity::prelude::*;
2use time::OffsetDateTime;
3use uuid::Uuid;
4
5#[sea_orm::model]
6#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
7#[sea_orm(table_name = "chat_user_draft")]
8pub struct Model {
9 #[sea_orm(primary_key, auto_increment = false)]
10 pub id: Uuid,
11 #[sea_orm(indexed)]
12 pub user_id: Uuid,
13 #[sea_orm(indexed)]
14 pub room_id: Uuid,
15 pub thread_id: Option<Uuid>,
16 pub content: String,
17 pub created_at: OffsetDateTime,
18 pub updated_at: OffsetDateTime,
19 #[sea_orm(belongs_to, from = "user_id", to = "uid")]
20 pub user: BelongsTo<super::super::users::user::Entity>,
21 #[sea_orm(belongs_to, from = "room_id", to = "id")]
22 pub room: BelongsTo<super::super::room::rooms::Entity>,
23}
24
25impl ActiveModelBehavior for ActiveModel {}
Modified lib/models/src/migrate/mod.rs lib/models/src/migrate/mod.rs +4 −0 View lib/models/src/migrate/mod.rs
@@ -1,6 +1,10 @@
11use sea_orm::{ConnectionTrait, DatabaseBackend, DatabaseConnection};
22
33pub async fn sync_schema(db: &DatabaseConnection) -> Result<(), sea_orm::DbErr> {
4 if db.get_database_backend() == DatabaseBackend::Postgres {
5 db.execute_unprepared(include_str!("../../schema_pre_sync.sql"))
6 .await?;
7 }
48 db.get_schema_registry("models::").sync(db).await?;
59 if db.get_database_backend() == DatabaseBackend::Postgres {
610 db.execute_unprepared(include_str!("../../schema_extras.sql"))
Modified lib/models/src/room/rooms.rs lib/models/src/room/rooms.rs +2 −2 View lib/models/src/room/rooms.rs
@@ -48,7 +48,7 @@
4848 #[sea_orm(primary_key, auto_increment = false)]
4949 pub id: Uuid,
5050 #[sea_orm(unique_key = "space_lower_name")]
51 pub space_id: Uuid,
51 pub space_id: Option<Uuid>,
5252 #[sea_orm(default_value = 0)]
5353 pub kind: RoomKind,
5454 #[sea_orm(unique_key = "space_lower_name")]
@@ -73,7 +73,7 @@
7373 pub created_at: OffsetDateTime,
7474 pub updated_at: OffsetDateTime,
7575 #[sea_orm(belongs_to, from = "space_id", to = "id")]
76 pub space: BelongsTo<super::super::space::spaces::Entity>,
76 pub space: BelongsTo<Option<super::super::space::spaces::Entity>>,
7777 #[sea_orm(belongs_to, from = "owner_id", to = "uid")]
7878 pub owner: BelongsTo<super::super::users::user::Entity>,
7979 #[sea_orm(
Modified lib/models/src/lib.rs lib/models/src/lib.rs +1 −0 View lib/models/src/lib.rs
@@ -1,3 +1,4 @@
1pub mod chat;
12pub mod common;
23pub mod dir;
34pub mod git;
Modified lib/models/schema_extras.sql lib/models/schema_extras.sql +68 −1 View lib/models/schema_extras.sql
@@ -113,4 +113,71 @@
113113 ADD CONSTRAINT products_price_non_negative
114114 CHECK (price IS NULL OR price >= 0);
115115 END IF;
116END $$;
No newline at end of file
116END $$;
117
118-- ---------------------------------------------------------------------------
119-- Chat domain (lib/chat): message ordering, idempotency, FTS, and composite
120-- indexes that entity attributes cannot express.
121-- ---------------------------------------------------------------------------
122CREATE UNIQUE INDEX IF NOT EXISTS idx_chat_message_room_nonce
123 ON chat_message (room_id, nonce) WHERE nonce IS NOT NULL;
124CREATE INDEX IF NOT EXISTS idx_chat_message_thread_seq
125 ON chat_message (thread_id, seq) WHERE thread_id IS NOT NULL;
126CREATE INDEX IF NOT EXISTS idx_chat_message_author_created
127 ON chat_message (author_id, created_at DESC);
128CREATE INDEX IF NOT EXISTS idx_chat_message_mentions
129 ON chat_message USING gin (mentions);
130CREATE INDEX IF NOT EXISTS idx_chat_message_pinned
131 ON chat_message (room_id, seq DESC) WHERE pinned;
132
133CREATE INDEX IF NOT EXISTS idx_chat_message_edit_message
134 ON chat_message_edit (message_id, created_at DESC);
135
136CREATE INDEX IF NOT EXISTS idx_chat_thread_room
137 ON chat_thread (room_id, archived, last_reply_at DESC NULLS LAST);
138
139CREATE INDEX IF NOT EXISTS idx_chat_thread_member_user
140 ON chat_thread_member (user_id);
141
142CREATE INDEX IF NOT EXISTS idx_chat_read_state_user
143 ON chat_read_state (user_id);
144
145CREATE INDEX IF NOT EXISTS idx_chat_room_setting_user
146 ON chat_room_user_setting (user_id);
147
148CREATE INDEX IF NOT EXISTS idx_chat_reaction_message
149 ON chat_reaction (message_id);
150
151CREATE INDEX IF NOT EXISTS idx_chat_attachment_user_created
152 ON chat_attachment (user_id, created_at DESC);
153
154CREATE INDEX IF NOT EXISTS idx_chat_bookmark_room
155 ON chat_room_bookmark (room_id, position);
156
157CREATE INDEX IF NOT EXISTS idx_chat_reminder_due
158 ON chat_message_reminder (status, remind_at);
159
160CREATE UNIQUE INDEX IF NOT EXISTS idx_chat_draft_unique
161 ON chat_user_draft (user_id, room_id, thread_id) NULLS NOT DISTINCT;
162
163CREATE INDEX IF NOT EXISTS idx_chat_webhook_token
164 ON chat_room_webhook (token_hash);
165
166CREATE UNIQUE INDEX IF NOT EXISTS idx_chat_dm_pair
167 ON rooms (lower_name) WHERE kind = 15 AND space_id IS NULL;
168
169ALTER TABLE chat_message ADD COLUMN IF NOT EXISTS search_vector tsvector;
170CREATE INDEX IF NOT EXISTS idx_chat_message_fts
171 ON chat_message USING gin (search_vector);
172CREATE OR REPLACE FUNCTION chat_message_search_vector_update() RETURNS trigger AS $$
173BEGIN
174 NEW.search_vector := to_tsvector('simple', coalesce(NEW.content, ''));
175 RETURN NEW;
176END;
177$$ LANGUAGE plpgsql;
178DROP TRIGGER IF EXISTS chat_message_search_vector_trigger ON chat_message;
179CREATE TRIGGER chat_message_search_vector_trigger
180 BEFORE INSERT OR UPDATE OF content ON chat_message
181 FOR EACH ROW EXECUTE FUNCTION chat_message_search_vector_update();
182UPDATE chat_message SET search_vector = to_tsvector('simple', coalesce(content, ''))
183 WHERE search_vector IS NULL;
No newline at end of file
Added lib/models/schema_pre_sync.sql lib/models/schema_pre_sync.sql +17 −0 View lib/models/schema_pre_sync.sql
@@ -0,0 +1,17 @@
1-- schema_pre_sync.sql
2-- Drops partial unique indexes created by schema_extras.sql before every
3-- sea-orm schema sync. The sync layer cannot express partial predicates via
4-- entity attributes, and treats these manually-created unique indexes as
5-- stale constraints (DROP CONSTRAINT), which fails because they are plain
6-- indexes. schema_extras.sql recreates all of them after the sync succeeds.
7
8DROP INDEX IF EXISTS idx_issue_watch_issue_user;
9DROP INDEX IF EXISTS idx_issue_watch_project_user;
10DROP INDEX IF EXISTS idx_issue_reaction_issue;
11DROP INDEX IF EXISTS idx_issue_reaction_comment;
12DROP INDEX IF EXISTS idx_issue_reaction_project;
13DROP INDEX IF EXISTS idx_branch_protection_whitelist_user;
14DROP INDEX IF EXISTS idx_branch_protection_whitelist_group;
15DROP INDEX IF EXISTS idx_chat_message_room_nonce;
16DROP INDEX IF EXISTS idx_chat_dm_pair;
17DROP INDEX IF EXISTS idx_chat_draft_unique;
Modified lib/space/src/services/rooms.rs lib/space/src/services/rooms.rs +2 −2 View lib/space/src/services/rooms.rs
@@ -36,7 +36,7 @@
3636#[derive(Debug, Clone, Serialize, ToSchema)]
3737pub struct RoomDto {
3838 pub id: Uuid,
39 pub space_id: Uuid,
39 pub space_id: Option<Uuid>,
4040 pub kind: RoomKind,
4141 pub name: String,
4242 pub topic: Option<String>,
@@ -140,7 +140,7 @@
140140
141141 let room_row = rooms::ActiveModel {
142142 id: Set(room_id),
143 space_id: Set(space.id),
143 space_id: Set(Some(space.id)),
144144 kind: Set(cmd.kind),
145145 lower_name: Set(lower.clone()),
146146 name: Set(name.to_string()),
Modified lib/webapi/src/lib.rs lib/webapi/src/lib.rs +2 −0 View lib/webapi/src/lib.rs
@@ -10,6 +10,7 @@
1010 let store = store::connect(state.redis.clone(), &state.config.session).await?;
1111
1212 let auth = auth::services::AuthService::new(state.clone());
13 let chat = chat::services::service::ChatService::new(state.clone());
1314 let issues = issues::services::service::IssueService::new(state.clone());
1415 let space = space::services::service::SpaceService::new(state.clone());
1516 let user = user::services::service::UserService::new(state.clone());
@@ -17,6 +18,7 @@
1718
1819 let router: Router = Router::new()
1920 .merge(auth::routes::router(auth.clone()).with_state(auth))
21 .merge(chat::routes::router(chat.clone()).with_state(chat))
2022 .merge(issues::routes::router(issues.clone()).with_state(issues))
2123 .merge(space::routes::router(space.clone()).with_state(space))
2224 .merge(user::routes::router(user.clone()).with_state(user))
Modified lib/webapi/src/openapi.rs lib/webapi/src/openapi.rs +1 −0 View lib/webapi/src/openapi.rs
@@ -7,6 +7,7 @@
77pub fn api_doc() -> utoipa::openapi::OpenApi {
88 let mut doc = WebApiDoc::openapi();
99 doc.merge(auth::openapi::AuthApiDoc::openapi());
10 doc.merge(chat::openapi::ChatApiDoc::openapi());
1011 doc.merge(issues::openapi::IssuesApiDoc::openapi());
1112 doc.merge(space::openapi::SpaceApiDoc::openapi());
1213 doc.merge(user::openapi::UserApiDoc::openapi());
Modified lib/webapi/Cargo.toml lib/webapi/Cargo.toml +1 −0 View lib/webapi/Cargo.toml
@@ -5,6 +5,7 @@
55
66[dependencies]
77auth = { workspace = true }
8chat = { workspace = true }
89issues = { workspace = true }
910space = { workspace = true }
1011user = { workspace = true }
Modified Cargo.lock Cargo.lock +217 −911 View Cargo.lock
@@ -159,6 +159,7 @@
159159dependencies = [
160160 "anyhow",
161161 "axum",
162 "chat",
162163 "config",
163164 "export",
164165 "session",
@@ -666,6 +667,7 @@
666667checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
667668dependencies = [
668669 "axum-core",
670 "base64 0.22.1",
669671 "bytes",
670672 "form_urlencoded",
671673 "futures-util",
@@ -684,8 +686,10 @@
684686 "serde_json",
685687 "serde_path_to_error",
686688 "serde_urlencoded",
689 "sha1 0.10.7",
687690 "sync_wrapper",
688691 "tokio",
692 "tokio-tungstenite",
689693 "tower",
690694 "tower-layer",
691695 "tower-service",
@@ -1070,6 +1074,36 @@
10701074]
10711075
10721076[[package]]
1077name = "chat"
1078version = "1.0.0"
1079dependencies = [
1080 "async-trait",
1081 "axum",
1082 "base64 0.23.1",
1083 "bytes",
1084 "config",
1085 "export",
1086 "futures",
1087 "hmac 0.13.0",
1088 "models",
1089 "rand 0.8.7",
1090 "redis",
1091 "sea-orm",
1092 "serde",
1093 "serde_json",
1094 "session",
1095 "sha2 0.11.0",
1096 "space",
1097 "storage",
1098 "time 0.3.55",
1099 "tokio",
1100 "tokio-util",
1101 "tracing",
1102 "utoipa",
1103 "uuid",
1104]
1105
1106[[package]]
10731107name = "chrono"
10741108version = "0.4.45"
10751109source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1314,16 +1348,6 @@
13141348source = "registry+https://github.com/rust-lang/crates.io-index"
13151349checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e"
13161350dependencies = [
1317 "crossbeam-utils",
1318]
1319
1320[[package]]
1321name = "crossbeam-deque"
1322version = "0.8.7"
1323source = "registry+https://github.com/rust-lang/crates.io-index"
1324checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb"
1325dependencies = [
1326 "crossbeam-epoch",
13271351 "crossbeam-utils",
13281352]
13291353
@@ -2563,7 +2587,7 @@
25632587 "flate2",
25642588 "futures",
25652589 "gitbaby",
2566 "gix 0.87.1",
2590 "gix",
25672591 "globset",
25682592 "http-body-util",
25692593 "maud",
@@ -2594,7 +2618,7 @@
25942618dependencies = [
25952619 "async-trait",
25962620 "foyer",
2597 "gix 0.86.0",
2621 "gix",
25982622 "serde",
25992623 "serde_json",
26002624 "time 0.3.55",
@@ -2621,126 +2645,60 @@
26212645source = "registry+https://github.com/rust-lang/crates.io-index"
26222646checksum = "bb3790fd8981cba7949f1ba924ef865d902df731627bc5998d14164063892fce"
26232647dependencies = [
2624 "gix-actor 0.41.2",
2625 "gix-archive 0.35.0",
2626 "gix-attributes 0.34.0",
2627 "gix-blame 0.16.0",
2628 "gix-command 0.9.2",
2629 "gix-commitgraph 0.38.0",
2630 "gix-config 0.59.0",
2631 "gix-credentials 0.39.1",
2632 "gix-date 0.15.6",
2633 "gix-diff 0.66.0",
2634 "gix-dir 0.28.0",
2635 "gix-discover 0.54.0",
2636 "gix-error 0.2.5",
2648 "gix-actor",
2649 "gix-archive",
2650 "gix-attributes",
2651 "gix-blame",
2652 "gix-command",
2653 "gix-commitgraph",
2654 "gix-config",
2655 "gix-credentials",
2656 "gix-date",
2657 "gix-diff",
2658 "gix-dir",
2659 "gix-discover",
2660 "gix-error",
26372661 "gix-features",
2638 "gix-filter 0.33.0",
2662 "gix-filter",
26392663 "gix-fs",
26402664 "gix-glob",
26412665 "gix-hash",
26422666 "gix-hashtable",
26432667 "gix-ignore",
2644 "gix-index 0.54.0",
2668 "gix-index",
26452669 "gix-lock",
2646 "gix-mailmap 0.33.2",
2647 "gix-negotiate 0.34.0",
2648 "gix-object 0.63.0",
2649 "gix-odb 0.83.0",
2650 "gix-pack 0.73.0",
2670 "gix-mailmap",
2671 "gix-negotiate",
2672 "gix-object",
2673 "gix-odb",
2674 "gix-pack",
26512675 "gix-path",
2652 "gix-pathspec 0.19.0",
2653 "gix-prompt 0.16.0",
2654 "gix-protocol 0.64.0",
2655 "gix-ref 0.66.0",
2656 "gix-refspec 0.44.0",
2657 "gix-revision 0.48.0",
2658 "gix-revwalk 0.34.0",
2676 "gix-pathspec",
2677 "gix-prompt",
2678 "gix-protocol",
2679 "gix-ref",
2680 "gix-refspec",
2681 "gix-revision",
2682 "gix-revwalk",
26592683 "gix-sec",
26602684 "gix-shallow",
2661 "gix-status 0.33.0",
2662 "gix-submodule 0.33.0",
2685 "gix-status",
2686 "gix-submodule",
26632687 "gix-tempfile",
26642688 "gix-trace",
2665 "gix-transport 0.58.1",
2666 "gix-traverse 0.60.0",
2667 "gix-url 0.37.1",
2689 "gix-transport",
2690 "gix-traverse",
2691 "gix-url",
26682692 "gix-utils",
26692693 "gix-validate",
2670 "gix-worktree 0.55.0",
2671 "gix-worktree-state 0.33.0",
2672 "gix-worktree-stream 0.35.0",
2694 "gix-worktree",
2695 "gix-worktree-state",
2696 "gix-worktree-stream",
26732697 "gix-zlib",
26742698 "nonempty 0.12.0",
26752699 "parking_lot",
26762700 "regex",
26772701 "serde",
2678 "signal-hook",
2679 "smallvec",
2680 "thiserror 2.0.20",
2681]
2682
2683[[package]]
2684name = "gix"
2685version = "0.87.1"
2686source = "registry+https://github.com/rust-lang/crates.io-index"
2687checksum = "fdefc1465d8631807deaf504dacdeff628b978de515653b47976ca7c28ab2a7a"
2688dependencies = [
2689 "gix-actor 0.42.0",
2690 "gix-archive 0.36.1",
2691 "gix-attributes 0.35.0",
2692 "gix-blame 0.17.1",
2693 "gix-command 0.10.0",
2694 "gix-commitgraph 0.39.0",
2695 "gix-config 0.60.0",
2696 "gix-credentials 0.40.0",
2697 "gix-date 0.16.0",
2698 "gix-diff 0.67.1",
2699 "gix-dir 0.29.1",
2700 "gix-discover 0.55.0",
2701 "gix-error 0.3.1",
2702 "gix-features",
2703 "gix-filter 0.34.0",
2704 "gix-fs",
2705 "gix-glob",
2706 "gix-hash",
2707 "gix-hashtable",
2708 "gix-ignore",
2709 "gix-index 0.55.0",
2710 "gix-lock",
2711 "gix-mailmap 0.34.1",
2712 "gix-merge",
2713 "gix-negotiate 0.35.1",
2714 "gix-note",
2715 "gix-object 0.64.1",
2716 "gix-odb 0.84.0",
2717 "gix-pack 0.74.2",
2718 "gix-path",
2719 "gix-pathspec 0.20.0",
2720 "gix-prompt 0.17.0",
2721 "gix-protocol 0.65.1",
2722 "gix-quote 0.8.0",
2723 "gix-ref 0.67.1",
2724 "gix-refspec 0.45.1",
2725 "gix-revision 0.49.1",
2726 "gix-revwalk 0.35.0",
2727 "gix-sec",
2728 "gix-shallow",
2729 "gix-status 0.34.1",
2730 "gix-submodule 0.34.0",
2731 "gix-tempfile",
2732 "gix-trace",
2733 "gix-traverse 0.61.0",
2734 "gix-url 0.38.0",
2735 "gix-utils",
2736 "gix-validate",
2737 "gix-worktree 0.56.0",
2738 "gix-worktree-state 0.34.1",
2739 "gix-worktree-stream 0.36.1",
2740 "gix-zlib",
2741 "nonempty 0.12.0",
2742 "parking_lot",
2743 "regex",
27442702 "signal-hook",
27452703 "smallvec",
27462704 "thiserror 2.0.20",
@@ -2753,20 +2711,9 @@
27532711checksum = "33f9308ad6fd35b2a865cbe4117ac61b2be59e4a9ef1621c7a9794f7c8e52c5b"
27542712dependencies = [
27552713 "bstr",
2756 "gix-date 0.15.6",
2757 "gix-error 0.2.5",
2714 "gix-date",
2715 "gix-error",
27582716 "serde",
2759]
2760
2761[[package]]
2762name = "gix-actor"
2763version = "0.42.0"
2764source = "registry+https://github.com/rust-lang/crates.io-index"
2765checksum = "e37efa99929ac62f980fb1a7dcd09dea85b3aa6ead6ba0498362add3f4e698de"
2766dependencies = [
2767 "bstr",
2768 "gix-date 0.16.0",
2769 "gix-error 0.3.1",
27702717]
27712718
27722719[[package]]
@@ -2776,23 +2723,10 @@
27762723checksum = "6760ef6ef4edfa6449918977d59518ff209676f47e89bdce87a36a50be35e834"
27772724dependencies = [
27782725 "bstr",
2779 "gix-date 0.15.6",
2780 "gix-error 0.2.5",
2781 "gix-object 0.63.0",
2782 "gix-worktree-stream 0.35.0",
2783]
2784
2785[[package]]
2786name = "gix-archive"
2787version = "0.36.1"
2788source = "registry+https://github.com/rust-lang/crates.io-index"
2789checksum = "1c4ef97efd3920d3525104c5f253dec94dc935f953a50055954939177aed2cdd"
2790dependencies = [
2791 "bstr",
2792 "gix-date 0.16.0",
2793 "gix-error 0.3.1",
2794 "gix-object 0.64.1",
2795 "gix-worktree-stream 0.36.1",
2726 "gix-date",
2727 "gix-error",
2728 "gix-object",
2729 "gix-worktree-stream",
27962730]
27972731
27982732[[package]]
@@ -2805,26 +2739,9 @@
28052739 "gix-features",
28062740 "gix-glob",
28072741 "gix-path",
2808 "gix-quote 0.7.2",
2742 "gix-quote",
28092743 "gix-trace",
28102744 "serde",
2811 "smallvec",
2812 "thiserror 2.0.20",
2813 "unicode-bom",
2814]
2815
2816[[package]]
2817name = "gix-attributes"
2818version = "0.35.0"
2819source = "registry+https://github.com/rust-lang/crates.io-index"
2820checksum = "76b83782ac69ae28921a6e8fbcf2e24069613f1518030b97ae622b079abffa54"
2821dependencies = [
2822 "bstr",
2823 "gix-features",
2824 "gix-glob",
2825 "gix-path",
2826 "gix-quote 0.8.0",
2827 "gix-trace",
28282745 "smallvec",
28292746 "thiserror 2.0.20",
28302747 "unicode-bom",
@@ -2836,16 +2753,7 @@
28362753source = "registry+https://github.com/rust-lang/crates.io-index"
28372754checksum = "7cd1d118d0f5d88b96e6f6e13b566475fef4797ead4a02c26fed36c1375066f7"
28382755dependencies = [
2839 "gix-error 0.2.5",
2840]
2841
2842[[package]]
2843name = "gix-bitmap"
2844version = "0.4.0"
2845source = "registry+https://github.com/rust-lang/crates.io-index"
2846checksum = "f17013c7ef5cb95ebb4bf4978201e6a8a42ffea9d666fd5388f1b7e742307d80"
2847dependencies = [
2848 "gix-error 0.3.1",
2756 "gix-error",
28492757]
28502758
28512759[[package]]
@@ -2854,36 +2762,16 @@
28542762source = "registry+https://github.com/rust-lang/crates.io-index"
28552763checksum = "b06d20ff0e88ada2dd852b3852727b664ec9baefdf653d1d4e722e858c52cd17"
28562764dependencies = [
2857 "gix-commitgraph 0.38.0",
2858 "gix-date 0.15.6",
2859 "gix-diff 0.66.0",
2860 "gix-error 0.2.5",
2861 "gix-hash",
2862 "gix-object 0.63.0",
2863 "gix-revwalk 0.34.0",
2864 "gix-trace",
2865 "gix-traverse 0.60.0",
2866 "gix-worktree 0.55.0",
2867 "smallvec",
2868 "thiserror 2.0.20",
2869]
2870
2871[[package]]
2872name = "gix-blame"
2873version = "0.17.1"
2874source = "registry+https://github.com/rust-lang/crates.io-index"
2875checksum = "54fe27f8037c58cb4b6d60fe0aacf7ee2c998633cedf4e989351c07d9a3a575d"
2876dependencies = [
2877 "gix-commitgraph 0.39.0",
2878 "gix-date 0.16.0",
2879 "gix-diff 0.67.1",
2880 "gix-error 0.3.1",
2765 "gix-commitgraph",
2766 "gix-date",
2767 "gix-diff",
2768 "gix-error",
28812769 "gix-hash",
2882 "gix-object 0.64.1",
2883 "gix-revwalk 0.35.0",
2770 "gix-object",
2771 "gix-revwalk",
28842772 "gix-trace",
2885 "gix-traverse 0.61.0",
2886 "gix-worktree 0.56.0",
2773 "gix-traverse",
2774 "gix-worktree",
28872775 "smallvec",
28882776 "thiserror 2.0.20",
28892777]
@@ -2894,19 +2782,10 @@
28942782source = "registry+https://github.com/rust-lang/crates.io-index"
28952783checksum = "b2a871e5cab12ba568845714473505deefffb3c04eb47f4708ce344cd459c1cc"
28962784dependencies = [
2897 "gix-error 0.2.5",
2785 "gix-error",
28982786]
28992787
29002788[[package]]
2901name = "gix-chunk"
2902version = "0.8.0"
2903source = "registry+https://github.com/rust-lang/crates.io-index"
2904checksum = "00e32f938b3745ac93d73bc7490b6a15a661b2fc81973bfe90e275cc53c908e1"
2905dependencies = [
2906 "gix-error 0.3.1",
2907]
2908
2909[[package]]
29102789name = "gix-command"
29112790version = "0.9.2"
29122791source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2914,20 +2793,7 @@
29142793dependencies = [
29152794 "bstr",
29162795 "gix-path",
2917 "gix-quote 0.7.2",
2918 "gix-trace",
2919 "shell-words",
2920]
2921
2922[[package]]
2923name = "gix-command"
2924version = "0.10.0"
2925source = "registry+https://github.com/rust-lang/crates.io-index"
2926checksum = "0d96b8e8423ce2665232bda1b682a62541f07ab801ffad69a034ef962bed3244"
2927dependencies = [
2928 "bstr",
2929 "gix-path",
2930 "gix-quote 0.8.0",
2796 "gix-quote",
29312797 "gix-trace",
29322798 "shell-words",
29332799]
@@ -2939,26 +2805,12 @@
29392805checksum = "a2cd7f054ae2727223fe46dd39c012f066b12f532962d336d29ee193261787da"
29402806dependencies = [
29412807 "bstr",
2942 "gix-chunk 0.7.3",
2943 "gix-error 0.2.5",
2808 "gix-chunk",
2809 "gix-error",
29442810 "gix-hash",
29452811 "memmap2",
29462812 "nonempty 0.12.0",
29472813 "serde",
2948]
2949
2950[[package]]
2951name = "gix-commitgraph"
2952version = "0.39.0"
2953source = "registry+https://github.com/rust-lang/crates.io-index"
2954checksum = "6b93c9fb1f5be01bba54a7a3b7455d359efc68555539c75ef74b8021bb6db497"
2955dependencies = [
2956 "bstr",
2957 "gix-chunk 0.8.0",
2958 "gix-error 0.3.1",
2959 "gix-hash",
2960 "memmap2",
2961 "nonempty 0.12.0",
29622814]
29632815
29642816[[package]]
@@ -2972,26 +2824,7 @@
29722824 "gix-features",
29732825 "gix-glob",
29742826 "gix-path",
2975 "gix-ref 0.66.0",
2976 "gix-sec",
2977 "gix-utils",
2978 "smallvec",
2979 "thiserror 2.0.20",
2980 "unicode-bom",
2981]
2982
2983[[package]]
2984name = "gix-config"
2985version = "0.60.0"
2986source = "registry+https://github.com/rust-lang/crates.io-index"
2987checksum = "f973c28c0a4871a7926fc90c8377d4458fd6cbb19692f56582b4d2022313ae90"
2988dependencies = [
2989 "bstr",
2990 "gix-config-value",
2991 "gix-features",
2992 "gix-glob",
2993 "gix-path",
2994 "gix-ref 0.67.1",
2827 "gix-ref",
29952828 "gix-sec",
29962829 "gix-utils",
29972830 "smallvec",
@@ -3019,35 +2852,16 @@
30192852checksum = "f9fbdb1c417980d05d547c19fc2b63344b5257c2387048d69ffe957bda142b59"
30202853dependencies = [
30212854 "bstr",
3022 "gix-command 0.9.2",
2855 "gix-command",
30232856 "gix-config-value",
3024 "gix-date 0.15.6",
2857 "gix-date",
30252858 "gix-path",
3026 "gix-prompt 0.16.0",
3027 "gix-quote 0.7.2",
2859 "gix-prompt",
2860 "gix-quote",
30282861 "gix-sec",
30292862 "gix-trace",
3030 "gix-url 0.37.1",
2863 "gix-url",
30312864 "serde",
3032 "thiserror 2.0.20",
3033]
3034
3035[[package]]
3036name = "gix-credentials"
3037version = "0.40.0"
3038source = "registry+https://github.com/rust-lang/crates.io-index"
3039checksum = "16c11822054fd4b80a1ab722b703f2e7c691b20484d898374289b742c1f49e50"
3040dependencies = [
3041 "bstr",
3042 "gix-command 0.10.0",
3043 "gix-config-value",
3044 "gix-date 0.16.0",
3045 "gix-path",
3046 "gix-prompt 0.17.0",
3047 "gix-quote 0.8.0",
3048 "gix-sec",
3049 "gix-trace",
3050 "gix-url 0.38.0",
30512865 "thiserror 2.0.20",
30522866]
30532867
@@ -3058,22 +2872,10 @@
30582872checksum = "7e47b9e8cdc688296609b706428de570f88b1e0eed7156dde7b4a89d26fa4567"
30592873dependencies = [
30602874 "bstr",
3061 "gix-error 0.2.5",
2875 "gix-error",
30622876 "itoa",
30632877 "jiff",
30642878 "serde",
3065]
3066
3067[[package]]
3068name = "gix-date"
3069version = "0.16.0"
3070source = "registry+https://github.com/rust-lang/crates.io-index"
3071checksum = "e63d9aa18f29facd571c8953e66224ee0075b9e16622024794555ed4acceea85"
3072dependencies = [
3073 "bstr",
3074 "gix-error 0.3.1",
3075 "itoa",
3076 "jiff",
30772879]
30782880
30792881[[package]]
@@ -3083,44 +2885,20 @@
30832885checksum = "fee7d89a3c507491cdfc57a1d1e0e300214720b4f7709ebc253e422f99822bfc"
30842886dependencies = [
30852887 "bstr",
3086 "gix-attributes 0.34.0",
3087 "gix-command 0.9.2",
3088 "gix-filter 0.33.0",
2888 "gix-attributes",
2889 "gix-command",
2890 "gix-filter",
30892891 "gix-fs",
30902892 "gix-hash",
30912893 "gix-imara-diff",
3092 "gix-index 0.54.0",
3093 "gix-object 0.63.0",
2894 "gix-index",
2895 "gix-object",
30942896 "gix-path",
3095 "gix-pathspec 0.19.0",
2897 "gix-pathspec",
30962898 "gix-tempfile",
30972899 "gix-trace",
3098 "gix-traverse 0.60.0",
3099 "gix-worktree 0.55.0",
3100 "thiserror 2.0.20",
3101]
3102
3103[[package]]
3104name = "gix-diff"
3105version = "0.67.1"
3106source = "registry+https://github.com/rust-lang/crates.io-index"
3107checksum = "1b1689ff5ddeee4acfb2a43e875a1072a76d208624b15a9065c938b39cb0da2a"
3108dependencies = [
3109 "bstr",
3110 "gix-attributes 0.35.0",
3111 "gix-command 0.10.0",
3112 "gix-filter 0.34.0",
3113 "gix-fs",
3114 "gix-hash",
3115 "gix-imara-diff",
3116 "gix-index 0.55.0",
3117 "gix-object 0.64.1",
3118 "gix-path",
3119 "gix-pathspec 0.20.0",
3120 "gix-tempfile",
3121 "gix-trace",
3122 "gix-traverse 0.61.0",
3123 "gix-worktree 0.56.0",
2900 "gix-traverse",
2901 "gix-worktree",
31242902 "thiserror 2.0.20",
31252903]
31262904
@@ -3131,36 +2909,16 @@
31312909checksum = "f24bc78f283946ac64757c8a61ffa71f0230aa1e8d98cbb0771db479871dd5b6"
31322910dependencies = [
31332911 "bstr",
3134 "gix-discover 0.54.0",
2912 "gix-discover",
31352913 "gix-fs",
31362914 "gix-ignore",
3137 "gix-index 0.54.0",
3138 "gix-object 0.63.0",
2915 "gix-index",
2916 "gix-object",
31392917 "gix-path",
3140 "gix-pathspec 0.19.0",
2918 "gix-pathspec",
31412919 "gix-trace",
31422920 "gix-utils",
3143 "gix-worktree 0.55.0",
3144 "thiserror 2.0.20",
3145]
3146
3147[[package]]
3148name = "gix-dir"
3149version = "0.29.1"
3150source = "registry+https://github.com/rust-lang/crates.io-index"
3151checksum = "8342d5eb0ea054ed6ef807e628e38f06dd51eceeec9529767d8b23e33eff9c09"
3152dependencies = [
3153 "bstr",
3154 "gix-discover 0.55.0",
3155 "gix-fs",
3156 "gix-ignore",
3157 "gix-index 0.55.0",
3158 "gix-object 0.64.1",
3159 "gix-path",
3160 "gix-pathspec 0.20.0",
3161 "gix-trace",
3162 "gix-utils",
3163 "gix-worktree 0.56.0",
2921 "gix-worktree",
31642922 "thiserror 2.0.20",
31652923]
31662924
@@ -3174,22 +2932,7 @@
31742932 "dunce",
31752933 "gix-fs",
31762934 "gix-path",
3177 "gix-ref 0.66.0",
3178 "gix-sec",
3179 "thiserror 2.0.20",
3180]
3181
3182[[package]]
3183name = "gix-discover"
3184version = "0.55.0"
3185source = "registry+https://github.com/rust-lang/crates.io-index"
3186checksum = "ec32a30ec2934735599c2545f30067e80bd255fd3454b52a5d59bc02b52874a3"
3187dependencies = [
3188 "bstr",
3189 "dunce",
3190 "gix-fs",
3191 "gix-path",
3192 "gix-ref 0.67.1",
2935 "gix-ref",
31932936 "gix-sec",
31942937 "thiserror 2.0.20",
31952938]
@@ -3199,15 +2942,6 @@
31992942version = "0.2.5"
32002943source = "registry+https://github.com/rust-lang/crates.io-index"
32012944checksum = "4a9292309fd944e71b2a3c96d3c03a6feb8852db646febdde7cbb9f79cb5f329"
3202dependencies = [
3203 "bstr",
3204]
3205
3206[[package]]
3207name = "gix-error"
3208version = "0.3.1"
3209source = "registry+https://github.com/rust-lang/crates.io-index"
3210checksum = "e73b2794a6a746953c24d4ed51e4d408b83d8d599ed4e5c39a47736d75687cf0"
32112945dependencies = [
32122946 "bstr",
32132947]
@@ -3240,13 +2974,13 @@
32402974dependencies = [
32412975 "bstr",
32422976 "encoding_rs",
3243 "gix-attributes 0.34.0",
3244 "gix-command 0.9.2",
2977 "gix-attributes",
2978 "gix-command",
32452979 "gix-hash",
3246 "gix-object 0.63.0",
2980 "gix-object",
32472981 "gix-packetline",
32482982 "gix-path",
3249 "gix-quote 0.7.2",
2983 "gix-quote",
32502984 "gix-trace",
32512985 "gix-utils",
32522986 "smallvec",
@@ -3254,40 +2988,19 @@
32542988]
32552989
32562990[[package]]
3257name = "gix-filter"
3258version = "0.34.0"
2991name = "gix-fs"
2992version = "0.22.1"
32592993source = "registry+https://github.com/rust-lang/crates.io-index"
3260checksum = "9c30b28d957e2e6f1e1bbfbfd26b8e0bb0aab68d8a5e730fb4fd3049943575e3"
2994checksum = "ebcfa9fd253f25350a3b21b3dd74034a446098e373c6123d4cee3519894f12ef"
32612995dependencies = [
32622996 "bstr",
3263 "encoding_rs",
3264 "gix-attributes 0.35.0",
3265 "gix-command 0.10.0",
3266 "gix-hash",
3267 "gix-object 0.64.1",
3268 "gix-packetline",
2997 "gix-features",
32692998 "gix-path",
3270 "gix-quote 0.8.0",
3271 "gix-trace",
32722999 "gix-utils",
3273 "smallvec",
32743000 "thiserror 2.0.20",
32753001]
32763002
32773003[[package]]
3278name = "gix-fs"
3279version = "0.22.1"
3280source = "registry+https://github.com/rust-lang/crates.io-index"
3281checksum = "ebcfa9fd253f25350a3b21b3dd74034a446098e373c6123d4cee3519894f12ef"
3282dependencies = [
3283 "bstr",
3284 "gix-features",
3285 "gix-path",
3286 "gix-utils",
3287 "thiserror 2.0.20",
3288]
3289
3290[[package]]
32913004name = "gix-glob"
32923005version = "0.27.1"
32933006source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3358,13 +3071,13 @@
33583071 "bstr",
33593072 "filetime",
33603073 "fnv",
3361 "gix-bitmap 0.3.3",
3074 "gix-bitmap",
33623075 "gix-features",
33633076 "gix-fs",
33643077 "gix-hash",
33653078 "gix-lock",
3366 "gix-object 0.63.0",
3367 "gix-traverse 0.60.0",
3079 "gix-object",
3080 "gix-traverse",
33683081 "gix-utils",
33693082 "gix-validate",
33703083 "hashbrown 0.17.1",
@@ -3378,34 +3091,6 @@
33783091]
33793092
33803093[[package]]
3381name = "gix-index"
3382version = "0.55.0"
3383source = "registry+https://github.com/rust-lang/crates.io-index"
3384checksum = "632e16cb48b0e88a747e106924cb2765194105d8070a7a961b0d080ed806c632"
3385dependencies = [
3386 "bitflags 2.13.1",
3387 "bstr",
3388 "filetime",
3389 "fnv",
3390 "gix-bitmap 0.4.0",
3391 "gix-features",
3392 "gix-fs",
3393 "gix-hash",
3394 "gix-lock",
3395 "gix-object 0.64.1",
3396 "gix-traverse 0.61.0",
3397 "gix-utils",
3398 "gix-validate",
3399 "hashbrown 0.17.1",
3400 "itoa",
3401 "libc",
3402 "memmap2",
3403 "rustix",
3404 "smallvec",
3405 "thiserror 2.0.20",
3406]
3407
3408[[package]]
34093094name = "gix-lock"
34103095version = "24.0.0"
34113096source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3417,105 +3102,30 @@
34173102]
34183103
34193104[[package]]
3420name = "gix-macros"
3421version = "0.1.6"
3422source = "registry+https://github.com/rust-lang/crates.io-index"
3423checksum = "d3836b4b051393464a753c5a08fe19c7ce0d8b77574a92bd558972941d4553cb"
3424dependencies = [
3425 "proc-macro2",
3426 "quote",
3427 "syn 2.0.119",
3428]
3429
3430[[package]]
34313105name = "gix-mailmap"
34323106version = "0.33.2"
34333107source = "registry+https://github.com/rust-lang/crates.io-index"
34343108checksum = "a824767d38b81475059cb01f5020a13fb96e7ed6bbf9851c7112b46ada78db48"
34353109dependencies = [
34363110 "bstr",
3437 "gix-actor 0.41.2",
3438 "gix-date 0.15.6",
3439 "gix-error 0.2.5",
3111 "gix-actor",
3112 "gix-date",
3113 "gix-error",
34403114 "serde",
34413115]
34423116
34433117[[package]]
3444name = "gix-mailmap"
3445version = "0.34.1"
3446source = "registry+https://github.com/rust-lang/crates.io-index"
3447checksum = "1a1c6ca2f9481e5e98b3600b859bc595d46cdf3b600709d5f91b8c118ae452a1"
3448dependencies = [
3449 "bstr",
3450 "gix-actor 0.42.0",
3451 "gix-date 0.16.0",
3452 "gix-error 0.3.1",
3453]
3454
3455[[package]]
3456name = "gix-merge"
3457version = "0.20.1"
3458source = "registry+https://github.com/rust-lang/crates.io-index"
3459checksum = "72fad4279c940a0fa460ffdf3a3c0e48b7ca5ccb2b08f51a02b22ffcd1efb900"
3460dependencies = [
3461 "bstr",
3462 "gix-command 0.10.0",
3463 "gix-diff 0.67.1",
3464 "gix-filter 0.34.0",
3465 "gix-fs",
3466 "gix-hash",
3467 "gix-imara-diff",
3468 "gix-index 0.55.0",
3469 "gix-object 0.64.1",
3470 "gix-path",
3471 "gix-quote 0.8.0",
3472 "gix-revision 0.49.1",
3473 "gix-revwalk 0.35.0",
3474 "gix-tempfile",
3475 "gix-trace",
3476 "gix-worktree 0.56.0",
3477 "nonempty 0.12.0",
3478 "thiserror 2.0.20",
3479]
3480
3481[[package]]
34823118name = "gix-negotiate"
34833119version = "0.34.0"
34843120source = "registry+https://github.com/rust-lang/crates.io-index"
34853121checksum = "2fa5aa789990f124e4f559b142cecfb60662cb4ae17006684ea9d668f4f07689"
34863122dependencies = [
34873123 "bitflags 2.13.1",
3488 "gix-commitgraph 0.38.0",
3489 "gix-date 0.15.6",
3490 "gix-hash",
3491 "gix-object 0.63.0",
3492 "gix-revwalk 0.34.0",
3493]
3494
3495[[package]]
3496name = "gix-negotiate"
3497version = "0.35.1"
3498source = "registry+https://github.com/rust-lang/crates.io-index"
3499checksum = "91068ecee956121d663635b873ae87ad9c5d3f2d6788facfd12829140b39c897"
3500dependencies = [
3501 "bitflags 2.13.1",
3502 "gix-commitgraph 0.39.0",
3503 "gix-date 0.16.0",
3124 "gix-commitgraph",
3125 "gix-date",
35043126 "gix-hash",
3505 "gix-object 0.64.1",
3506 "gix-revwalk 0.35.0",
3507]
3508
3509[[package]]
3510name = "gix-note"
3511version = "0.1.1"
3512source = "registry+https://github.com/rust-lang/crates.io-index"
3513checksum = "9af6bbdb3f3c62b4920407c479af1cc0beb27fd75a762782d48baed53d330958"
3514dependencies = [
3515 "gix-error 0.3.1",
3516 "gix-hash",
3517 "gix-hashtable",
3518 "gix-object 0.64.1",
3127 "gix-object",
3128 "gix-revwalk",
35193129]
35203130
35213131[[package]]
@@ -3525,8 +3135,8 @@
35253135checksum = "0e48c235e7f886eb819fc878af75be889333dd3c38bee02ed7af48ae2cf596c4"
35263136dependencies = [
35273137 "bstr",
3528 "gix-actor 0.41.2",
3529 "gix-date 0.15.6",
3138 "gix-actor",
3139 "gix-date",
35303140 "gix-features",
35313141 "gix-hash",
35323142 "gix-hashtable",
@@ -3539,27 +3149,6 @@
35393149]
35403150
35413151[[package]]
3542name = "gix-object"
3543version = "0.64.1"
3544source = "registry+https://github.com/rust-lang/crates.io-index"
3545checksum = "56fef799ca40cdeab4de5a3e74a696d8fa9b9c6264592646bf022e026f13ce2c"
3546dependencies = [
3547 "bstr",
3548 "gix-actor 0.42.0",
3549 "gix-command 0.10.0",
3550 "gix-date 0.16.0",
3551 "gix-features",
3552 "gix-hash",
3553 "gix-hashtable",
3554 "gix-tempfile",
3555 "gix-utils",
3556 "gix-validate",
3557 "itoa",
3558 "smallvec",
3559 "thiserror 2.0.20",
3560]
3561
3562[[package]]
35633152name = "gix-odb"
35643153version = "0.83.0"
35653154source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3570,10 +3159,10 @@
35703159 "gix-fs",
35713160 "gix-hash",
35723161 "gix-hashtable",
3573 "gix-object 0.63.0",
3574 "gix-pack 0.73.0",
3162 "gix-object",
3163 "gix-pack",
35753164 "gix-path",
3576 "gix-quote 0.7.2",
3165 "gix-quote",
35773166 "gix-zlib",
35783167 "memmap2",
35793168 "parking_lot",
@@ -3583,40 +3172,18 @@
35833172]
35843173
35853174[[package]]
3586name = "gix-odb"
3587version = "0.84.0"
3588source = "registry+https://github.com/rust-lang/crates.io-index"
3589checksum = "cf45d195b71cb6363886e7b466821bf4dffd7aba1b6b814ff75488e0061d72a7"
3590dependencies = [
3591 "arc-swap",
3592 "gix-features",
3593 "gix-fs",
3594 "gix-hash",
3595 "gix-hashtable",
3596 "gix-object 0.64.1",
3597 "gix-pack 0.74.2",
3598 "gix-path",
3599 "gix-quote 0.8.0",
3600 "gix-zlib",
3601 "memmap2",
3602 "parking_lot",
3603 "tempfile",
3604 "thiserror 2.0.20",
3605]
3606
3607[[package]]
36083175name = "gix-pack"
36093176version = "0.73.0"
36103177source = "registry+https://github.com/rust-lang/crates.io-index"
36113178checksum = "6d5446127b269706e85998065267ddd2ccc3550179da6780b22fe496175ccb20"
36123179dependencies = [
36133180 "clru",
3614 "gix-chunk 0.7.3",
3615 "gix-error 0.2.5",
3181 "gix-chunk",
3182 "gix-error",
36163183 "gix-features",
36173184 "gix-hash",
36183185 "gix-hashtable",
3619 "gix-object 0.63.0",
3186 "gix-object",
36203187 "gix-path",
36213188 "gix-zlib",
36223189 "memmap2",
@@ -3627,28 +3194,6 @@
36273194]
36283195
36293196[[package]]
3630name = "gix-pack"
3631version = "0.74.2"
3632source = "registry+https://github.com/rust-lang/crates.io-index"
3633checksum = "3d199d515cbdcf05f531f3859be9771fc12acac7b7fe0f5b581b2c1ab1c3a61e"
3634dependencies = [
3635 "clru",
3636 "crossbeam-deque",
3637 "gix-chunk 0.8.0",
3638 "gix-error 0.3.1",
3639 "gix-features",
3640 "gix-hash",
3641 "gix-hashtable",
3642 "gix-object 0.64.1",
3643 "gix-path",
3644 "gix-zlib",
3645 "memmap2",
3646 "smallvec",
3647 "thiserror 2.0.20",
3648 "uluru",
3649]
3650
3651[[package]]
36523197name = "gix-packetline"
36533198version = "0.22.2"
36543199source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3680,22 +3225,7 @@
36803225dependencies = [
36813226 "bitflags 2.13.1",
36823227 "bstr",
3683 "gix-attributes 0.34.0",
3684 "gix-config-value",
3685 "gix-glob",
3686 "gix-path",
3687 "thiserror 2.0.20",
3688]
3689
3690[[package]]
3691name = "gix-pathspec"
3692version = "0.20.0"
3693source = "registry+https://github.com/rust-lang/crates.io-index"
3694checksum = "c92e44c63ba53bb55aa88ee48847664beae9446417621582fb14dfc69b2f8c72"
3695dependencies = [
3696 "bitflags 2.13.1",
3697 "bstr",
3698 "gix-attributes 0.35.0",
3228 "gix-attributes",
36993229 "gix-config-value",
37003230 "gix-glob",
37013231 "gix-path",
@@ -3708,20 +3238,7 @@
37083238source = "registry+https://github.com/rust-lang/crates.io-index"
37093239checksum = "5cb1f1eb92d6f4c9d4c105a6ca912cff637fbd5cacbcbafa5deccd88bfaa3565"
37103240dependencies = [
3711 "gix-command 0.9.2",
3712 "gix-config-value",
3713 "parking_lot",
3714 "rustix",
3715 "thiserror 2.0.20",
3716]
3717
3718[[package]]
3719name = "gix-prompt"
3720version = "0.17.0"
3721source = "registry+https://github.com/rust-lang/crates.io-index"
3722checksum = "802256e13ecb2133ba5f9e6acbaa6b08bac027b1c5929f85f6adbb56ba3a4f28"
3723dependencies = [
3724 "gix-command 0.10.0",
3241 "gix-command",
37253242 "gix-config-value",
37263243 "parking_lot",
37273244 "rustix",
@@ -3736,12 +3253,12 @@
37363253dependencies = [
37373254 "bisync",
37383255 "bstr",
3739 "gix-date 0.15.6",
3256 "gix-date",
37403257 "gix-features",
37413258 "gix-hash",
3742 "gix-ref 0.66.0",
3259 "gix-ref",
37433260 "gix-shallow",
3744 "gix-transport 0.58.1",
3261 "gix-transport",
37453262 "gix-utils",
37463263 "nonempty 0.12.0",
37473264 "serde",
@@ -3749,43 +3266,13 @@
37493266]
37503267
37513268[[package]]
3752name = "gix-protocol"
3753version = "0.65.1"
3754source = "registry+https://github.com/rust-lang/crates.io-index"
3755checksum = "68878c37ae168b474c762d70adc8a8e0f9323ed0d80444a672e2eb561fa16691"
3756dependencies = [
3757 "bstr",
3758 "gix-date 0.16.0",
3759 "gix-features",
3760 "gix-hash",
3761 "gix-macros",
3762 "gix-ref 0.67.1",
3763 "gix-shallow",
3764 "gix-transport 0.59.1",
3765 "gix-utils",
3766 "nonempty 0.12.0",
3767 "thiserror 2.0.20",
3768]
3769
3770[[package]]
37713269name = "gix-quote"
37723270version = "0.7.2"
37733271source = "registry+https://github.com/rust-lang/crates.io-index"
37743272checksum = "a6e541fc33cc2b783b7979040d445a0c86a2eca747c8faea4ca84230d06ae6ef"
37753273dependencies = [
37763274 "bstr",
3777 "gix-error 0.2.5",
3778 "gix-utils",
3779]
3780
3781[[package]]
3782name = "gix-quote"
3783version = "0.8.0"
3784source = "registry+https://github.com/rust-lang/crates.io-index"
3785checksum = "f74795eb76eab8313063849f9cbd2bbcdc6e4692f71c1d7d0244ab11cd777329"
3786dependencies = [
3787 "bstr",
3788 "gix-error 0.3.1",
3275 "gix-error",
37893276 "gix-utils",
37903277]
37913278
@@ -3795,12 +3282,12 @@
37953282source = "registry+https://github.com/rust-lang/crates.io-index"
37963283checksum = "eeb0c90a8f6202ceaaa22996cbf837c943ccb2d8af9ff3490f0758305e6b7883"
37973284dependencies = [
3798 "gix-actor 0.41.2",
3285 "gix-actor",
37993286 "gix-features",
38003287 "gix-fs",
38013288 "gix-hash",
38023289 "gix-lock",
3803 "gix-object 0.63.0",
3290 "gix-object",
38043291 "gix-path",
38053292 "gix-tempfile",
38063293 "gix-utils",
@@ -3811,49 +3298,16 @@
38113298]
38123299
38133300[[package]]
3814name = "gix-ref"
3815version = "0.67.1"
3816source = "registry+https://github.com/rust-lang/crates.io-index"
3817checksum = "8328387e7ab354e1dc3afb63e6b4d1866f82d7ce035222bc9d5baaf36bc88020"
3818dependencies = [
3819 "gix-actor 0.42.0",
3820 "gix-features",
3821 "gix-fs",
3822 "gix-hash",
3823 "gix-lock",
3824 "gix-object 0.64.1",
3825 "gix-path",
3826 "gix-tempfile",
3827 "gix-utils",
3828 "gix-validate",
3829 "memmap2",
3830 "thiserror 2.0.20",
3831]
3832
3833[[package]]
38343301name = "gix-refspec"
38353302version = "0.44.0"
38363303source = "registry+https://github.com/rust-lang/crates.io-index"
38373304checksum = "7406282cc0259b51f6aee299ca3d31279a020530363152a2e6c96e8a7f7bbc83"
38383305dependencies = [
38393306 "bstr",
3840 "gix-error 0.2.5",
3307 "gix-error",
38413308 "gix-glob",
38423309 "gix-hash",
3843 "gix-revision 0.48.0",
3844 "gix-validate",
3845 "smallvec",
3846 "thiserror 2.0.20",
3847]
3848
3849[[package]]
3850name = "gix-refspec"
3851version = "0.45.1"
3852source = "registry+https://github.com/rust-lang/crates.io-index"
3853checksum = "7de280d46e8fd9e4d3e7e2ab4276b965e377ba68b2b9a8fce56371015e8bb059"
3854dependencies = [
3855 "bstr",
3856 "gix-hash",
3310 "gix-revision",
38573311 "gix-validate",
38583312 "smallvec",
38593313 "thiserror 2.0.20",
@@ -3867,35 +3321,16 @@
38673321dependencies = [
38683322 "bitflags 2.13.1",
38693323 "bstr",
3870 "gix-commitgraph 0.38.0",
3871 "gix-date 0.15.6",
3872 "gix-error 0.2.5",
3873 "gix-hash",
3874 "gix-hashtable",
3875 "gix-object 0.63.0",
3876 "gix-revwalk 0.34.0",
3877 "gix-trace",
3878 "nonempty 0.12.0",
3879 "serde",
3880]
3881
3882[[package]]
3883name = "gix-revision"
3884version = "0.49.1"
3885source = "registry+https://github.com/rust-lang/crates.io-index"
3886checksum = "4577b864c3e134697e91564553e43230c2e401bb1bcc51cfa998edca21c95078"
3887dependencies = [
3888 "bitflags 2.13.1",
3889 "bstr",
3890 "gix-commitgraph 0.39.0",
3891 "gix-date 0.16.0",
3892 "gix-error 0.3.1",
3324 "gix-commitgraph",
3325 "gix-date",
3326 "gix-error",
38933327 "gix-hash",
38943328 "gix-hashtable",
3895 "gix-object 0.64.1",
3896 "gix-revwalk 0.35.0",
3329 "gix-object",
3330 "gix-revwalk",
38973331 "gix-trace",
38983332 "nonempty 0.12.0",
3333 "serde",
38993334]
39003335
39013336[[package]]
@@ -3904,28 +3339,12 @@
39043339source = "registry+https://github.com/rust-lang/crates.io-index"
39053340checksum = "36c113c0a53294dc6280ffc06cbcc4f50f820397e97d6a00b429a44b8db26e29"
39063341dependencies = [
3907 "gix-commitgraph 0.38.0",
3908 "gix-date 0.15.6",
3909 "gix-error 0.2.5",
3342 "gix-commitgraph",
3343 "gix-date",
3344 "gix-error",
39103345 "gix-hash",
39113346 "gix-hashtable",
3912 "gix-object 0.63.0",
3913 "smallvec",
3914 "thiserror 2.0.20",
3915]
3916
3917[[package]]
3918name = "gix-revwalk"
3919version = "0.35.0"
3920source = "registry+https://github.com/rust-lang/crates.io-index"
3921checksum = "248823eefe405e2c0754b74f6f43ab6faab68670cdbf2d6e114cb0471f766450"
3922dependencies = [
3923 "gix-commitgraph 0.39.0",
3924 "gix-date 0.16.0",
3925 "gix-error 0.3.1",
3926 "gix-hash",
3927 "gix-hashtable",
3928 "gix-object 0.64.1",
3347 "gix-object",
39293348 "smallvec",
39303349 "thiserror 2.0.20",
39313350]
@@ -3965,42 +3384,17 @@
39653384dependencies = [
39663385 "bstr",
39673386 "filetime",
3968 "gix-diff 0.66.0",
3969 "gix-dir 0.28.0",
3387 "gix-diff",
3388 "gix-dir",
39703389 "gix-features",
3971 "gix-filter 0.33.0",
3390 "gix-filter",
39723391 "gix-fs",
39733392 "gix-hash",
3974 "gix-index 0.54.0",
3975 "gix-object 0.63.0",
3393 "gix-index",
3394 "gix-object",
39763395 "gix-path",
3977 "gix-pathspec 0.19.0",
3978 "gix-worktree 0.55.0",
3979 "hashbrown 0.16.1",
3980 "portable-atomic",
3981 "thiserror 2.0.20",
3982 "windows-sys 0.61.2",
3983]
3984
3985[[package]]
3986name = "gix-status"
3987version = "0.34.1"
3988source = "registry+https://github.com/rust-lang/crates.io-index"
3989checksum = "1401d871c01d82add5a9f654439b00722d0c632b332cd2cb63193f94327c8458"
3990dependencies = [
3991 "bstr",
3992 "filetime",
3993 "gix-diff 0.67.1",
3994 "gix-dir 0.29.1",
3995 "gix-features",
3996 "gix-filter 0.34.0",
3997 "gix-fs",
3998 "gix-hash",
3999 "gix-index 0.55.0",
4000 "gix-object 0.64.1",
4001 "gix-path",
4002 "gix-pathspec 0.20.0",
4003 "gix-worktree 0.56.0",
3396 "gix-pathspec",
3397 "gix-worktree",
40043398 "hashbrown 0.16.1",
40053399 "portable-atomic",
40063400 "thiserror 2.0.20",
@@ -4014,26 +3408,11 @@
40143408checksum = "5fd98077a56d08886112e6b08dc94076d03539f4bc0b9d7880e4be2b8a640d8c"
40153409dependencies = [
40163410 "bstr",
4017 "gix-config 0.59.0",
3411 "gix-config",
40183412 "gix-path",
4019 "gix-pathspec 0.19.0",
4020 "gix-refspec 0.44.0",
4021 "gix-url 0.37.1",
4022 "thiserror 2.0.20",
4023]
4024
4025[[package]]
4026name = "gix-submodule"
4027version = "0.34.0"
4028source = "registry+https://github.com/rust-lang/crates.io-index"
4029checksum = "da85564d6725c483b8d95d7b31d1db0b78c29e462a2b481949e2f18e1ed55a6a"
4030dependencies = [
4031 "bstr",
4032 "gix-config 0.60.0",
4033 "gix-path",
4034 "gix-pathspec 0.20.0",
4035 "gix-refspec 0.45.1",
4036 "gix-url 0.38.0",
3413 "gix-pathspec",
3414 "gix-refspec",
3415 "gix-url",
40373416 "thiserror 2.0.20",
40383417]
40393418
@@ -4065,31 +3444,14 @@
40653444checksum = "3f36d045b840f8aeee1a527e677eab1fbebfbbe94bf2e708fa81d0b4b742d5fc"
40663445dependencies = [
40673446 "bstr",
4068 "gix-command 0.9.2",
3447 "gix-command",
40693448 "gix-features",
40703449 "gix-packetline",
40713450 "gix-path",
4072 "gix-quote 0.7.2",
3451 "gix-quote",
40733452 "gix-sec",
4074 "gix-url 0.37.1",
3453 "gix-url",
40753454 "serde",
4076 "thiserror 2.0.20",
4077]
4078
4079[[package]]
4080name = "gix-transport"
4081version = "0.59.1"
4082source = "registry+https://github.com/rust-lang/crates.io-index"
4083checksum = "b1295b6ada647b5b4ac6eb123d95a82ee80aef11a7d9db6ea9cb38dcbd411362"
4084dependencies = [
4085 "bstr",
4086 "gix-command 0.10.0",
4087 "gix-features",
4088 "gix-packetline",
4089 "gix-path",
4090 "gix-quote 0.8.0",
4091 "gix-sec",
4092 "gix-url 0.38.0",
40933455 "thiserror 2.0.20",
40943456]
40953457
@@ -4100,29 +3462,12 @@
41003462checksum = "008c5cd879e46e86b5c2469e633611978b18775d53d05668d691bc13088bd409"
41013463dependencies = [
41023464 "bitflags 2.13.1",
4103 "gix-commitgraph 0.38.0",
4104 "gix-date 0.15.6",
3465 "gix-commitgraph",
3466 "gix-date",
41053467 "gix-hash",
41063468 "gix-hashtable",
4107 "gix-object 0.63.0",
4108 "gix-revwalk 0.34.0",
4109 "smallvec",
4110 "thiserror 2.0.20",
4111]
4112
4113[[package]]
4114name = "gix-traverse"
4115version = "0.61.0"
4116source = "registry+https://github.com/rust-lang/crates.io-index"
4117checksum = "9af3503668739f4de5dba57fe15cfd9adc443b08bcbbf195e5de16b648872245"
4118dependencies = [
4119 "bitflags 2.13.1",
4120 "gix-commitgraph 0.39.0",
4121 "gix-date 0.16.0",
4122 "gix-hash",
4123 "gix-hashtable",
4124 "gix-object 0.64.1",
4125 "gix-revwalk 0.35.0",
3469 "gix-object",
3470 "gix-revwalk",
41263471 "smallvec",
41273472 "thiserror 2.0.20",
41283473]
@@ -4138,19 +3483,6 @@
41383483 "gix-utils",
41393484 "percent-encoding 2.3.2",
41403485 "serde",
4141 "thiserror 2.0.20",
4142]
4143
4144[[package]]
4145name = "gix-url"
4146version = "0.38.0"
4147source = "registry+https://github.com/rust-lang/crates.io-index"
4148checksum = "5bda80ccb4bc81fb9fb07a975916eb0c7a889bcbcb0c8a239c3f82a9cc2abdda"
4149dependencies = [
4150 "bstr",
4151 "gix-path",
4152 "gix-utils",
4153 "percent-encoding 2.3.2",
41543486 "thiserror 2.0.20",
41553487]
41563488
@@ -4182,36 +3514,17 @@
41823514checksum = "31eb8e675122e83585e461fe28f68ff8c5ed55b49017b697e7e76423ff973424"
41833515dependencies = [
41843516 "bstr",
4185 "gix-attributes 0.34.0",
3517 "gix-attributes",
41863518 "gix-features",
41873519 "gix-fs",
41883520 "gix-glob",
41893521 "gix-hash",
41903522 "gix-ignore",
4191 "gix-index 0.54.0",
4192 "gix-object 0.63.0",
3523 "gix-index",
3524 "gix-object",
41933525 "gix-path",
41943526 "gix-validate",
41953527 "serde",
4196]
4197
4198[[package]]
4199name = "gix-worktree"
4200version = "0.56.0"
4201source = "registry+https://github.com/rust-lang/crates.io-index"
4202checksum = "3ed36b627476e2072c129900a17c977a38052b5497e27308159bc881fbf4eecf"
4203dependencies = [
4204 "bstr",
4205 "gix-attributes 0.35.0",
4206 "gix-features",
4207 "gix-fs",
4208 "gix-glob",
4209 "gix-hash",
4210 "gix-ignore",
4211 "gix-index 0.55.0",
4212 "gix-object 0.64.1",
4213 "gix-path",
4214 "gix-validate",
42153528]
42163529
42173530[[package]]
@@ -4222,30 +3535,12 @@
42223535dependencies = [
42233536 "bstr",
42243537 "gix-features",
4225 "gix-filter 0.33.0",
3538 "gix-filter",
42263539 "gix-fs",
4227 "gix-index 0.54.0",
4228 "gix-object 0.63.0",
3540 "gix-index",
3541 "gix-object",
42293542 "gix-path",
4230 "gix-worktree 0.55.0",
4231 "io-close",
4232 "thiserror 2.0.20",
4233]
4234
4235[[package]]
4236name = "gix-worktree-state"
4237version = "0.34.1"
4238source = "registry+https://github.com/rust-lang/crates.io-index"
4239checksum = "4c14faa5b9527823c656a033e841dde11ca5b774f1bca7a9cdc23db677e55d56"
4240dependencies = [
4241 "bstr",
4242 "gix-features",
4243 "gix-filter 0.34.0",
4244 "gix-fs",
4245 "gix-index 0.55.0",
4246 "gix-object 0.64.1",
4247 "gix-path",
4248 "gix-worktree 0.56.0",
3543 "gix-worktree",
42493544 "io-close",
42503545 "thiserror 2.0.20",
42513546]
@@ -4256,33 +3551,15 @@
42563551source = "registry+https://github.com/rust-lang/crates.io-index"
42573552checksum = "3b088c8724e7be120c4798dd86925cf05332c9d356a463542578600c50c7a549"
42583553dependencies = [
4259 "gix-attributes 0.34.0",
4260 "gix-error 0.2.5",
3554 "gix-attributes",
3555 "gix-error",
42613556 "gix-features",
4262 "gix-filter 0.33.0",
3557 "gix-filter",
42633558 "gix-fs",
42643559 "gix-hash",
4265 "gix-object 0.63.0",
3560 "gix-object",
42663561 "gix-path",
4267 "gix-traverse 0.60.0",
4268 "parking_lot",
4269]
4270
4271[[package]]
4272name = "gix-worktree-stream"
4273version = "0.36.1"
4274source = "registry+https://github.com/rust-lang/crates.io-index"
4275checksum = "de5ec0ccbab39c9994656ea031ded8ffd90b370a3cc8e360fbe3c4ebe7c64964"
4276dependencies = [
4277 "gix-attributes 0.35.0",
4278 "gix-error 0.3.1",
4279 "gix-features",
4280 "gix-filter 0.34.0",
4281 "gix-fs",
4282 "gix-hash",
4283 "gix-object 0.64.1",
4284 "gix-path",
4285 "gix-traverse 0.61.0",
3562 "gix-traverse",
42863563 "parking_lot",
42873564]
42883565
@@ -8734,6 +8011,18 @@
87348011]
87358012
87368013[[package]]
8014name = "tokio-tungstenite"
8015version = "0.29.0"
8016source = "registry+https://github.com/rust-lang/crates.io-index"
8017checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c"
8018dependencies = [
8019 "futures-util",
8020 "log 0.4.33",
8021 "tokio",
8022 "tungstenite",
8023]
8024
8025[[package]]
87378026name = "tokio-util"
87388027version = "0.7.19"
87398028source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -8938,6 +8227,22 @@
89388227checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
89398228
89408229[[package]]
8230name = "tungstenite"
8231version = "0.29.0"
8232source = "registry+https://github.com/rust-lang/crates.io-index"
8233checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8"
8234dependencies = [
8235 "bytes",
8236 "data-encoding",
8237 "http 1.5.0",
8238 "httparse",
8239 "log 0.4.33",
8240 "rand 0.9.5",
8241 "sha1 0.10.7",
8242 "thiserror 2.0.20",
8243]
8244
8245[[package]]
89418246name = "twox-hash"
89428247version = "2.1.3"
89438248source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -9325,6 +8630,7 @@
93258630 "auth",
93268631 "axum",
93278632 "axum_session",
8633 "chat",
93288634 "export",
93298635 "git",
93308636 "issues",
Modified Cargo.toml Cargo.toml +4 −2 View Cargo.toml
@@ -2,6 +2,7 @@
22members = [
33 "lib/auth",
44 "lib/cache",
5 "lib/chat",
56 "lib/config",
67 "lib/email",
78 "lib/export",
@@ -32,6 +33,7 @@
3233[workspace.dependencies]
3334auth = { path = "lib/auth" }
3435cache = { path = "lib/cache" }
36chat = { path = "lib/chat" }
3537config = { path = "lib/config" }
3638email = { path = "lib/email" }
3739export = { path = "lib/export" }
@@ -50,7 +52,7 @@
5052tokio = "1.53.1"
5153serde = "1.0.229"
5254serde_json = "1.0.151"
53axum = "0.8.9"
55axum = { version = "0.8.9", features = ["ws"] }
5456axum_session = { version = "0.21.0", features = ["key-store"] }
5557tower-http = { version = "0.7.0", features = ["trace"] }
5658tracing = "0.1.44"
@@ -84,7 +86,7 @@
8486utoipa = { version = "5.5", features = ["axum_extras", "time", "uuid"] }
8587utoipa-swagger-ui = { version = "9", features = ["axum"] }
8688thiserror = "2.0.20"
87gix = "0.87.1"
89gix = "0.86.0"
8890gitbaby = { git="https://gitea.gitdata.me/GitDataAi/GitBaby.git", rev = "69bd99a" }
8991
9092wasm-bindgen = "0.2"
Modified openapi.json openapi.json View openapi.json
Diff too large to display.