use models::chat::messages; use sea_orm::{ActiveModelTrait, EntityTrait, Set}; use uuid::Uuid; use crate::services::service::ChatService; use crate::share::notify; use crate::share::realtime::RoomEvent; const MAX_URLS_PER_MESSAGE: usize = 3; /// 裁掉句尾标点,但保留括号配对时的右括号。 /// /// `https://en.wikipedia.org/wiki/Rust_(programming_language)` 末尾的 `)` 属于路径; /// `(详见 https://example.com/a)` 末尾的 `)` 不属于。按括号计数决定去留。 fn trim_trailing_punctuation(value: &str) -> String { let mut end = value.len(); while end > 0 { let Some(ch) = value[..end].chars().next_back() else { break; }; let drop = match ch { ')' => value[..end].matches('(').count() < value[..end].matches(')').count(), ']' => value[..end].matches('[').count() < value[..end].matches(']').count(), '}' => value[..end].matches('{').count() < value[..end].matches('}').count(), '.' | ',' | '>' | '"' | '\'' | ';' | '!' | '?' | '。' | ',' | '、' | ')' | '】' | '》' | '」' | '』' => true, _ => false, }; if !drop { break; } end -= ch.len_utf8(); } value[..end].to_string() } impl ChatService { pub async fn unfurl_message(&self, room_id: Uuid, message_id: Uuid, content: String) { let urls = { let content = &content; let mut urls = Vec::new(); for token in content.split_whitespace() { // 只裁掉 URL 前面的引用符号:括号可能是路径的一部分 // (`.../Rust_(programming_language)`),先剥掉会把链接截断。 let trimmed = token.trim_start_matches(|c: char| { matches!(c, '(' | '[' | '{' | '<' | '"' | '\'' | '「' | '『' | '《') }); if !(trimmed.starts_with("http://") || trimmed.starts_with("https://")) { continue; } if trimmed.len() < 12 || trimmed.len() > 2048 { continue; } let cleaned = trim_trailing_punctuation(trimmed); if cleaned.len() < 12 || cleaned.len() > 2048 { continue; } if !urls.contains(&cleaned) { urls.push(cleaned); } if urls.len() >= MAX_URLS_PER_MESSAGE { break; } } urls }; if urls.is_empty() { return; } let mut embeds = Vec::new(); for url in urls { if let Some(value) = self.resolve_link(&url).await { embeds.push(value) } } if embeds.is_empty() { return; } let Some(row) = messages::Entity::find_by_id(message_id) .one(self.database.write()) .await .ok() .flatten() else { return; }; if row.deleted_at.is_some() { return; } if row.embeds.as_array().is_some_and(|arr| !arr.is_empty()) { return; } let mut am: messages::ActiveModel = row.into(); am.embeds = Set(serde_json::json!(embeds)); if am.update(self.database.write()).await.is_err() { return; } let Some(updated) = messages::Entity::find_by_id(message_id) .one(self.database.read()) .await .ok() .flatten() else { return; }; let Ok(items) = self.build_message_dto(vec![updated], 0).await else { return; }; let Some(dto) = items.into_iter().next() else { return; }; let Ok(payload) = serde_json::to_value(&dto) else { return; }; let seq = notify::current_room_seq(self.database.read(), room_id).await; let event = RoomEvent::MessageEdited { room_id, seq, message: payload, }; self.audience.realtime.publish_room(&event).await; } /// 解析单条链接,返回可直接塞进 `MessageDto::embeds` 的一项。 /// /// `social` 的 resolver 链先跑静态平台注册表,未命中再走通用 /// HTML / oEmbed / 直链媒体解析;这里只负责把结果转成消息 DTO。 async fn resolve_link(&self, url: &str) -> Option { let embed = social::unfurl(url).await.ok()?.into_iter().next()?; serde_json::to_value(embed).ok() } } pub fn spawn_unfurl(service: &ChatService, room_id: Uuid, message_id: Uuid, content: String) { if !content.contains("http://") && !content.contains("https://") { return; } let service = service.clone(); let request_id = api::middleware::request_id(); tokio::spawn(async move { api::middleware::with_request_id( request_id.as_deref(), service.unfurl_message(room_id, message_id, content), ) .await; }); } #[cfg(test)] mod tests { use crate::services::messages::MessageEmbedDto; /// 平台 embed 必须能原样填进 `MessageDto::embeds`,否则聊天里收到的一直是 /// 空数组 —— 这正是此前链接卡片链路断掉的位置。 #[test] fn platform_embed_fits_message_dto() { let cases = [ "https://www.youtube.com/watch?v=dQw4w9WgXcQ", "https://open.spotify.com/track/4cOdK2wGLETKBW3PvgPWqT", "https://mp.weixin.qq.com/s/AbCdEfGhIjKlMn", "https://www.bilibili.com/video/BV1xx411c7mD", "https://cdn.example.com/clip.mp4", "https://item.taobao.com/item.htm?id=123456", ]; for url in cases { let value = social::embed_json(url).unwrap_or_else(|| panic!("{url} did not resolve")); let dto: MessageEmbedDto = serde_json::from_value(value) .unwrap_or_else(|error| panic!("{url} did not fit the DTO: {error}")); assert_eq!(dto.url.as_deref(), Some(url)); assert!( dto.html .as_deref() .is_some_and(|html| html.contains("gd-embed")), "{url} produced no embed html" ); } } #[test] fn legacy_link_embed_still_deserializes() { let value = serde_json::json!({ "type": "link", "url": "https://example.com/", "title": "Example", "description": "desc", "site_name": "Example", }); let dto: MessageEmbedDto = serde_json::from_value(value).unwrap(); assert_eq!(dto.embed_type, "link"); assert_eq!(dto.title.as_deref(), Some("Example")); assert!(dto.html.is_none()); } }