Skip to content
Clone

feat(chat): add chat module with messaging, presence, and reactions functionality

ZhenYi committed
parent 4208993 commit df6eca4 Browse files

85 files changed

+9168 −927

This change is very large, so some diffs are not shown. Clone the repository to see the full change.

File tree 85
Modified app/apihost/src/main.rs 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 View openapi.json

Diff too large to display.