main
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<serde_json::Value> {
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());
}
}