feat(chat): unfurl message links into embeds
Message DTOs now carry an `embeds` array (type/url/platform/category/kind /aspect/src/title/description/image_url/site_name/html) built on the read path from the `social` registry, so a bare link to a bilibili video, a GitHub issue or a YouTube clip renders inline instead of as a URL. Unrecognised links fall back to the OpenGraph row. outbound gains one shared SSRF-guarded GET (public addresses only, no redirects, byte and time caps, content-type assertion before the body is read) which both the OpenGraph crawler and the new oembed service use. oembed keeps third-party markup at arm's length: it takes only the iframe src out of the returned html, re-checks the host against the provider's declared domains on the social side, and a failure yields None so the OpenGraph path still runs and nothing poisoned is cached. unfurl additionally: caches rows through one upsert helper, records Failed rather than retrying forever, truncates fetched HTML on a char boundary (byte caps used to split UTF-8), and exposes link_embed_json for the DTO. The client renders embeds in place of the matching inline link (parts.tsx), matching by URL with trailing punctuation/slashes stripped, and treats server HTML as untrusted input: DOMPurify with an explicit tag/attribute allowlist and script/style/object/form/base/link refused.
ZhenYi committed
Modified lib/chat/src/services/messages.rs lib/chat/src/services/messages.rs +32 −0 View lib/chat/src/services/messages.rs
@@ -53,6 +53,8 @@
5353 pub mentions: Vec<Uuid>,
5454 pub mention_everyone: bool,
5555 pub attachments: Vec<AttachmentDto>,
56 #[serde(default)]
57 pub embeds: Vec<MessageEmbedDto>,
5658 pub has_thread: bool,
5759 pub pinned: bool,
5860 pub pinned_by_id: Option<Uuid>,
@@ -65,6 +67,35 @@
6567 pub reactions: Vec<ReactionAggDto>,
6668}
6769
70#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
71pub struct MessageEmbedDto {
72 #[serde(rename = "type")]
73 pub embed_type: String,
74 pub url: String,
75 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub platform: Option<String>,
77 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub platform_name: Option<String>,
79 #[serde(default, skip_serializing_if = "Option::is_none")]
80 pub category: Option<String>,
81 #[serde(default, skip_serializing_if = "Option::is_none")]
82 pub kind: Option<String>,
83 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub aspect: Option<String>,
85 #[serde(default, skip_serializing_if = "Option::is_none")]
86 pub src: Option<String>,
87 #[serde(default, skip_serializing_if = "Option::is_none")]
88 pub title: Option<String>,
89 #[serde(default, skip_serializing_if = "Option::is_none")]
90 pub description: Option<String>,
91 #[serde(default, skip_serializing_if = "Option::is_none")]
92 pub image_url: Option<String>,
93 #[serde(default, skip_serializing_if = "Option::is_none")]
94 pub site_name: Option<String>,
95 #[serde(default, skip_serializing_if = "Option::is_none")]
96 pub html: Option<String>,
97}
98
6899#[derive(Debug, Clone, Serialize, ToSchema)]
69100pub struct MessagePage {
70101 pub items: Vec<MessageDto>,
@@ -206,6 +237,7 @@
206237 mentions,
207238 mention_everyone: m.mention_everyone,
208239 attachments: attachments_by_msg.remove(&m.id).unwrap_or_default(),
240 embeds: serde_json::from_value(m.embeds.clone()).unwrap_or_default(),
209241 has_thread: m.flags & messages::FLAG_HAS_THREAD != 0,
210242 pinned: m.pinned,
211243 pinned_by_id: m.pinned_by,
Modified lib/chat/src/services/mod.rs lib/chat/src/services/mod.rs +1 −0 View lib/chat/src/services/mod.rs
@@ -4,6 +4,7 @@
44pub mod drafts;
55pub mod emojis;
66pub mod messages;
7pub mod oembed;
78pub mod outbound;
89pub mod presence;
910pub mod reactions;
Added lib/chat/src/services/oembed.rs lib/chat/src/services/oembed.rs +104 −0 View lib/chat/src/services/oembed.rs
@@ -0,0 +1,104 @@
1//! oEmbed 抓取:把平台声明的端点返回的 JSON 收敛成 [`social::Enrichment`]。
2//!
3//! 第三方返回的 HTML 不会被注入消息流:只从 `html` 里抠出 iframe 的 `src`,而且
4//! `social` 侧还会再校验一次,只有落在该平台自己声明的域名里才会被采用。抓取失败
5//! 一律返回 `None`,交给 OpenGraph 兜底,也不会写进 `chat_link_unfurl` 缓存。
6
7use social::Enrichment;
8
9use crate::services::outbound::fetch_text;
10
11const MAX_BYTES: usize = 262_144;
12
13/// 抓取 URL 所属平台的 oEmbed 数据;平台没声明端点或抓取失败时返回 `None`。
14pub async fn fetch(url: &str) -> Option<Enrichment> {
15 let endpoint = social::oembed_endpoint(url)?;
16 let body = fetch_text(
17 &endpoint.endpoint,
18 "application/json",
19 Some("json"),
20 MAX_BYTES,
21 )
22 .await
23 .ok()?;
24 parse(&body)
25}
26
27fn string_field(value: &serde_json::Value, key: &str) -> Option<String> {
28 let text = value.get(key)?.as_str()?.trim();
29 (!text.is_empty()).then(|| text.to_string())
30}
31
32/// 从 oEmbed 的 `html` 字段里取出 iframe 的 `src`。
33fn iframe_src(html: &str) -> Option<String> {
34 let start = html.to_ascii_lowercase().find("<iframe")?;
35 let tail = &html[start..];
36 let index = tail.to_ascii_lowercase().find("src=")? + 4;
37 let after = tail[index..].trim_start();
38 let quote = after.chars().next()?;
39 if quote != '"' && quote != '\'' {
40 return None;
41 }
42 let rest = &after[1..];
43 let end = rest.find(quote)?;
44 let src = rest[..end].trim();
45 (!src.is_empty()).then(|| src.to_string())
46}
47
48/// 解析 oEmbed 响应体;没有任何可用字段时返回 `None`。
49pub fn parse(body: &str) -> Option<Enrichment> {
50 let value: serde_json::Value = serde_json::from_str(body).ok()?;
51 if !value.is_object() {
52 return None;
53 }
54 let enrichment = Enrichment {
55 title: string_field(&value, "title"),
56 description: string_field(&value, "description")
57 .or_else(|| string_field(&value, "author_name")),
58 image_url: string_field(&value, "thumbnail_url"),
59 frame_src: string_field(&value, "html").as_deref().and_then(iframe_src),
60 };
61 (enrichment.title.is_some()
62 || enrichment.description.is_some()
63 || enrichment.image_url.is_some()
64 || enrichment.frame_src.is_some())
65 .then_some(enrichment)
66}
67
68#[cfg(test)]
69mod tests {
70 use super::*;
71
72 #[test]
73 fn parses_the_youtube_payload() {
74 let body = r#"{"title":"Rick Astley - Never Gonna Give You Up (Official Video)","author_name":"Rick Astley","author_url":"https://www.youtube.com/@RickAstleyYT","thumbnail_url":"https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg","type":"video","version":"1.0"}"#;
75 let enrichment = parse(body).unwrap();
76 assert_eq!(
77 enrichment.title.as_deref(),
78 Some("Rick Astley - Never Gonna Give You Up (Official Video)")
79 );
80 assert_eq!(enrichment.description.as_deref(), Some("Rick Astley"));
81 assert_eq!(
82 enrichment.image_url.as_deref(),
83 Some("https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg")
84 );
85 assert!(enrichment.frame_src.is_none());
86 }
87
88 #[test]
89 fn pulls_the_frame_out_of_the_spotify_payload() {
90 let body = r#"{"title":"Never Gonna Give You Up","html":"<iframe style=\"border-radius: 12px\" width=\"100%\" height=\"152\" title=\"Spotify Embed: Never Gonna Give You Up\" frameborder=\"0\" allowfullscreen allow=\"autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture\" loading=\"lazy\" src=\"https://open.spotify.com/embed/track/4cOdK2wGLETKBW3PvgPWqT?utm_source=oembed\"></iframe>"}"#;
91 let enrichment = parse(body).unwrap();
92 assert_eq!(
93 enrichment.frame_src.as_deref(),
94 Some("https://open.spotify.com/embed/track/4cOdK2wGLETKBW3PvgPWqT?utm_source=oembed")
95 );
96 }
97
98 #[test]
99 fn rejects_bodies_without_useful_fields() {
100 assert!(parse("<html>nope</html>").is_none());
101 assert!(parse("{}").is_none());
102 assert!(parse(r#"{"type":"video","html":"<blockquote>quote</blockquote>"}"#).is_none());
103 }
104}
Modified lib/chat/src/services/outbound.rs lib/chat/src/services/outbound.rs +62 −0 View lib/chat/src/services/outbound.rs
@@ -124,6 +124,68 @@
124124 }
125125}
126126
127/// SSRF 防护后的 GET:只允许公网地址、不跟随重定向、限时限量。
128///
129/// `expect_content_type` 非空时,响应 `content-type` 必须包含该片段(读 body 之前检查)。
130pub(crate) async fn fetch_text(
131 url: &str,
132 accept: &str,
133 expect_content_type: Option<&str>,
134 max_bytes: usize,
135) -> Result<String, String> {
136 let uri = validate_outbound_url(url).map_err(|e| e.to_string())?;
137 let host = uri.host().unwrap_or("").to_string();
138 let port = uri.port_u16();
139 let addrs = tokio::net::lookup_host((host.as_str(), port.unwrap_or(443)))
140 .await
141 .map_err(|e| format!("dns resolution failed: {e}"))?
142 .collect::<Vec<_>>();
143 if addrs.is_empty() {
144 return Err("dns resolution returned no address".to_string());
145 }
146 if addrs.iter().any(|addr| !is_public_ip(&addr.ip())) {
147 return Err("url resolves to a private or loopback address".to_string());
148 }
149 let mut client_builder = reqwest::Client::builder()
150 .timeout(std::time::Duration::from_secs(10))
151 .redirect(reqwest::redirect::Policy::none())
152 .user_agent("gitdata-chat-fetch/1.0");
153 for addr in &addrs {
154 client_builder = client_builder.resolve(&host, *addr);
155 }
156 let client = client_builder.build().map_err(|e| e.to_string())?;
157 let response = client
158 .get(url)
159 .header("accept", accept)
160 .send()
161 .await
162 .map_err(|e| format!("request failed: {e}"))?;
163 if !response.status().is_success() {
164 return Err(format!("endpoint returned {}", response.status().as_u16()));
165 }
166 let content_type = response
167 .headers()
168 .get("content-type")
169 .and_then(|value| value.to_str().ok())
170 .unwrap_or("")
171 .to_lowercase();
172 if let Some(expected) = expect_content_type
173 && !content_type.is_empty()
174 && !content_type.contains(expected)
175 {
176 return Err(format!("unexpected content-type {content_type}"));
177 }
178 let mut body: Vec<u8> = Vec::new();
179 let mut stream = response;
180 while let Some(chunk) = stream.chunk().await.map_err(|e| e.to_string())? {
181 body.extend_from_slice(&chunk);
182 if body.len() >= max_bytes {
183 break;
184 }
185 }
186 Ok(String::from_utf8_lossy(&body).to_string())
187}
188
127189async fn require_room_manager(
128190 service: &ChatService,
129191 room_id: Uuid,
Modified lib/chat/src/services/unfurl.rs lib/chat/src/services/unfurl.rs +144 −73 View lib/chat/src/services/unfurl.rs
@@ -5,7 +5,8 @@
55use time::OffsetDateTime;
66use uuid::Uuid;
77
8use crate::services::outbound::is_public_ip;
8use crate::services::oembed;
9use crate::services::outbound::fetch_text;
910use crate::services::service::ChatService;
1011use crate::share::notify::current_room_seq;
1112use crate::share::realtime::RoomEvent;
@@ -112,61 +113,10 @@
112113}
113114
114115async fn fetch_meta(url: &str) -> Result<FetchedMeta, String> {
115 use crate::services::outbound::validate_outbound_url;
116
117 let uri = validate_outbound_url(url).map_err(|e| e.to_string())?;
118 let host = uri.host().unwrap_or("").to_string();
119 let port = uri.port_u16();
120 let addrs = tokio::net::lookup_host((host.as_str(), port.unwrap_or(443)))
121 .await
122 .map_err(|e| format!("dns resolution failed: {e}"))?
123 .collect::<Vec<_>>();
124 if addrs.is_empty() {
125 return Err("dns resolution returned no address".to_string());
126 }
127 if addrs.iter().any(|addr| !is_public_ip(&addr.ip())) {
128 return Err("url resolves to a private or loopback address".to_string());
129 }
130 let mut client_builder = reqwest::Client::builder()
131 .timeout(std::time::Duration::from_secs(10))
132 .redirect(reqwest::redirect::Policy::none())
133 .user_agent("gitdata-chat-unfurl/1.0");
134 for addr in &addrs {
135 client_builder = client_builder.resolve(&host, *addr);
136 }
137 let client = client_builder.build().map_err(|e| e.to_string())?;
138
139 let response = client
140 .get(url)
141 .header("accept", "text/html")
142 .send()
143 .await
144 .map_err(|e| format!("request failed: {e}"))?;
145 if !response.status().is_success() {
146 return Err(format!("endpoint returned {}", response.status().as_u16()));
147 }
148 let content_type = response
149 .headers()
150 .get("content-type")
151 .and_then(|v| v.to_str().ok())
152 .unwrap_or("")
153 .to_lowercase();
154 if !content_type.is_empty() && !content_type.contains("text/html") {
155 return Err("not an html document".to_string());
156 }
157
158 let mut body: Vec<u8> = Vec::new();
159 let mut stream = response;
160 while let Some(chunk) = stream.chunk().await.map_err(|e| e.to_string())? {
161 body.extend_from_slice(&chunk);
162 if body.len() >= MAX_FETCH_BYTES {
163 break;
164 }
165 }
166 let text = String::from_utf8_lossy(&body);
116 let text = fetch_text(url, "text/html", Some("text/html"), MAX_FETCH_BYTES).await?;
167117 let head = match text.find("</head>") {
168118 Some(idx) => &text[..idx],
169 None => &text[..text.len().min(MAX_FETCH_BYTES)],
119 None => safe_prefix(&text, MAX_FETCH_BYTES),
170120 };
171121
172122 Ok(FetchedMeta {
@@ -178,7 +128,16 @@
178128 })
179129}
180130
181fn embed_json(url: &str, row: &link_unfurls::Model) -> serde_json::Value {
131/// 按字节上限截断,且保证不切断多字节字符。
132fn safe_prefix(text: &str, max_bytes: usize) -> &str {
133 let mut end = text.len().min(max_bytes);
134 while !text.is_char_boundary(end) {
135 end -= 1;
136 }
137 &text[..end]
138}
139
140fn link_embed_json(url: &str, row: &link_unfurls::Model) -> serde_json::Value {
182141 serde_json::json!({
183142 "type": "link",
184143 "url": url,
@@ -190,7 +149,8 @@
190149}
191150
192151impl ChatService {
193 async fn resolve_unfurl(&self, url: &str) -> Option<serde_json::Value> {
152 /// 带缓存的 OpenGraph 抓取:命中新鲜缓存直接返回,抓取失败记 Failed 并返回 `None`。
153 async fn unfurl_row(&self, url: &str) -> Option<link_unfurls::Model> {
194154 let hash = url_hash(url);
195155 let now = OffsetDateTime::now_utc();
196156 let cached = link_unfurls::Entity::find_by_id(hash.clone())
@@ -204,7 +164,7 @@
204164 let retryable_failed = row.status == UnfurlStatus::Failed
205165 && row.fetched_at < now - time::Duration::minutes(FAILED_RETRY_MINUTES);
206166 if fresh {
207 return Some(embed_json(url, row));
167 return Some(row.clone());
208168 }
209169 if row.status == UnfurlStatus::Failed && !retryable_failed {
210170 return None;
@@ -222,22 +182,39 @@
222182 self.upsert_unfurl_row(&hash, url, None, now).await;
223183 return None;
224184 }
225 let row = self
226 .upsert_unfurl_row(
227 &hash,
228 url,
229 Some((
230 meta.title.clone(),
231 meta.description.clone(),
232 meta.image_url.clone(),
233 meta.site_name.clone(),
234 )),
235 now,
236 )
237 .await?;
238 Some(embed_json(url, &row))
185 self.upsert_unfurl_row(
186 &hash,
187 url,
188 Some((
189 meta.title.clone(),
190 meta.description.clone(),
191 meta.image_url.clone(),
192 meta.site_name.clone(),
193 )),
194 now,
195 )
196 .await
239197 }
240198
199 async fn resolve_unfurl(&self, url: &str) -> Option<serde_json::Value> {
200 let row = self.unfurl_row(url).await?;
201 Some(link_embed_json(url, &row))
202 }
203
204 /// 卡片类平台联网补全:优先平台自己声明的 oEmbed,否则复用 OpenGraph 抓取。
205 async fn enrich_card(&self, url: &str) -> Option<social::Enrichment> {
206 if let Some(enrichment) = oembed::fetch(url).await {
207 return Some(enrichment);
208 }
209 let row = self.unfurl_row(url).await?;
210 Some(social::Enrichment {
211 title: row.title,
212 description: row.description,
213 image_url: row.image_url,
214 frame_src: None,
215 })
216 }
217
241218 async fn upsert_unfurl_row(
242219 &self,
243220 hash: &str,
@@ -298,8 +275,24 @@
298275 }
299276 let mut embeds = Vec::new();
300277 for url in urls {
301 if let Some(embed) = self.resolve_unfurl(&url).await {
302 embeds.push(embed);
278 match social::embed_json(&url) {
279 // 能直接内嵌的平台(iframe / 媒体)不联网。
280 Some(value) if value["kind"] != "link" => embeds.push(value),
281 // 只能出卡片的平台联网补全标题、简介和封面。
282 Some(value) => {
283 let merged = match self.enrich_card(&url).await {
284 Some(enrichment) => {
285 social::embed_json_enriched(&url, &enrichment).unwrap_or(value)
286 }
287 None => value,
288 };
289 embeds.push(merged);
290 }
291 None => {
292 if let Some(embed) = self.resolve_unfurl(&url).await {
293 embeds.push(embed);
294 }
295 }
303296 }
304297 }
305298 if embeds.is_empty() {
@@ -360,3 +353,81 @@
360353 service.unfurl_message(room_id, message_id, content).await;
361354 });
362355}
356
357#[cfg(test)]
358mod tests {
359 use super::*;
360 use crate::services::messages::MessageEmbedDto;
361
362 /// 平台 embed 必须能原样填进 `MessageDto::embeds`,否则聊天里收到的一直是
363 /// 空数组 —— 这正是此前链接卡片链路断掉的位置。
364 #[test]
365 fn platform_embed_fits_message_dto() {
366 let cases = [
367 "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
368 "https://open.spotify.com/track/4cOdK2wGLETKBW3PvgPWqT",
369 "https://mp.weixin.qq.com/s/AbCdEfGhIjKlMn",
370 "https://www.bilibili.com/video/BV1xx411c7mD",
371 "https://cdn.example.com/clip.mp4",
372 "https://item.taobao.com/item.htm?id=123456",
373 ];
374 for url in cases {
375 let value = social::embed_json(url).unwrap_or_else(|| panic!("{url} did not resolve"));
376 let dto: MessageEmbedDto = serde_json::from_value(value)
377 .unwrap_or_else(|error| panic!("{url} did not fit the DTO: {error}"));
378 assert_eq!(dto.url, url);
379 assert!(
380 dto.html
381 .as_deref()
382 .is_some_and(|html| html.contains("gd-embed")),
383 "{url} produced no embed html"
384 );
385 }
386 }
387
388 #[test]
389 fn link_embed_fits_message_dto() {
390 let row = link_unfurls::Model {
391 url_hash: "hash".into(),
392 url: "https://example.com/".into(),
393 title: Some("Example".into()),
394 description: Some("desc".into()),
395 image_url: None,
396 site_name: Some("Example".into()),
397 status: UnfurlStatus::Ready,
398 fetched_at: time::OffsetDateTime::UNIX_EPOCH,
399 };
400 let dto: MessageEmbedDto =
401 serde_json::from_value(link_embed_json("https://example.com/", &row)).unwrap();
402 assert_eq!(dto.embed_type, "link");
403 assert_eq!(dto.title.as_deref(), Some("Example"));
404 assert!(dto.html.is_none());
405 }
406
407 /// 卡片类平台联网补全后要塞进同一条 embed:标题 / 简介 / 封面进卡片,
408 /// 外部域名的 iframe 不会被采用。
409 #[test]
410 fn card_embeds_take_over_the_fetched_metadata() {
411 let url = "https://item.taobao.com/item.htm?id=123456";
412 let enrichment = social::Enrichment {
413 title: Some("商品标题".into()),
414 description: Some("商品简介".into()),
415 image_url: Some("https://img.alicdn.com/cover.jpg".into()),
416 frame_src: Some("https://evil.example/frame".into()),
417 };
418 let value = social::embed_json_enriched(url, &enrichment).expect("taobao resolves");
419 let dto: MessageEmbedDto = serde_json::from_value(value).expect("fits the dto");
420 assert_eq!(dto.embed_type, "platform");
421 assert_eq!(dto.kind.as_deref(), Some("link"));
422 assert_eq!(dto.title.as_deref(), Some("商品标题"));
423 assert_eq!(dto.description.as_deref(), Some("商品简介"));
424 assert_eq!(
425 dto.image_url.as_deref(),
426 Some("https://img.alicdn.com/cover.jpg")
427 );
428 let html = dto.html.expect("card html");
429 assert!(html.contains("gd-embed-thumb"), "{html}");
430 assert!(html.contains("商品简介"), "{html}");
431 assert!(!html.contains("evil.example"), "{html}");
432 }
433}
Modified lib/chat/Cargo.toml lib/chat/Cargo.toml +1 −0 View lib/chat/Cargo.toml
@@ -10,6 +10,7 @@
1010i18n = { workspace = true }
1111models = { workspace = true }
1212search = { workspace = true }
13social = { workspace = true }
1314session = { workspace = true }
1415space = { workspace = true }
1516storage = { workspace = true }
Modified web/src/features/chat/ui/parts.tsx web/src/features/chat/ui/parts.tsx +131 −7 View web/src/features/chat/ui/parts.tsx
@@ -13,6 +13,7 @@
1313 type ReactNode,
1414} from 'react'
1515import { Download, FileText, Maximize2, X } from 'lucide-react'
16import DOMPurify from 'dompurify'
1617import {
1718 attachmentKind,
1819 attachmentUrl,
@@ -270,7 +271,12 @@
270271}
271272
272273/** Inline markdown + @mention chips. Returns plain strings where nothing matched. */
273function renderInline(text: string, mentionIds: Set<string>, isSelf: boolean): ReactNode[] {
274function renderInline(
275 text: string,
276 mentionIds: Set<string>,
277 isSelf: boolean,
278 embeds: Map<string, MessageEmbed> = new Map(),
279): ReactNode[] {
274280 const nodes: ReactNode[] = []
275281 let last = 0
276282 let key = 0
@@ -294,9 +300,9 @@
294300 } else if (value.startsWith('*')) {
295301 nodes.push(<em key={`i${(key += 1)}`}>{value.slice(1, -1)}</em>)
296302 } else if (/^https?:\/\//.test(value)) {
297 nodes.push(
298 <InlineLink key={`i${(key += 1)}`} url={value} />,
299 )
303 const embed = embeds.get(value) ?? embeds.get(embedKey(value))
304 if (embed) nodes.push(<EmbedView key={`i${(key += 1)}`} embed={embed} />)
305 else nodes.push(<InlineLink key={`i${(key += 1)}`} url={value} />)
300306 } else {
301307 nodes.push(value)
302308 }
@@ -320,6 +326,114 @@
320326 )
321327}
322328
329/* --------------------------------------------------------------- embeds */
330
331/**
332 * 服务端 `lib/social` 生成的一条 embed。平台命中时带 `html`(iframe/video/
333 * audio/img/link card),未命中平台的普通链接只有 OpenGraph 字段。
334 */
335export interface MessageEmbed {
336 type?: string | null
337 url: string
338 platform?: string | null
339 platform_name?: string | null
340 category?: string | null
341 kind?: string | null
342 title?: string | null
343 description?: string | null
344 site_name?: string | null
345 image_url?: string | null
346 aspect?: string | null
347 src?: string | null
348 html?: string | null
349}
350
351const EMBED_TAGS = ['iframe', 'video', 'audio', 'source', 'img', 'figure', 'figcaption']
352const EMBED_ATTRS = [
353 'allow',
354 'allowfullscreen',
355 'frameborder',
356 'referrerpolicy',
357 'sandbox',
358 'loading',
359 'controls',
360 'playsinline',
361 'preload',
362 'poster',
363 'autoplay',
364 'loop',
365 'muted',
366 'data-platform',
367 'data-category',
368]
369
370/** 服务端 HTML 同样属于不可信输入,注入前一律清洗。 */
371function sanitizeEmbed(html: string): string {
372 return DOMPurify.sanitize(html, {
373 ADD_TAGS: EMBED_TAGS,
374 ADD_ATTR: EMBED_ATTRS,
375 FORBID_TAGS: ['script', 'style', 'object', 'embed', 'form', 'input', 'textarea', 'button', 'base', 'link'],
376 })
377}
378
379function embedHost(url: string): string {
380 try {
381 return new URL(url).hostname.replace(/^www\./, '')
382 } catch {
383 return url
384 }
385}
386
387/** 句尾标点/尾斜杠不参与匹配:`...see https://youtu.be/x.` 也要能命中。 */
388function embedKey(url: string): string {
389 return url.replace(/[.,;:!?]+$/, '').replace(/\/+$/, '')
390}
391
392/** 平台 embed:直接展示服务端生成的 HTML;普通链接退化为 OpenGraph 卡片。 */
393export function EmbedView({ embed }: { embed: MessageEmbed }) {
394 const html = useMemo(() => (embed.html ? sanitizeEmbed(embed.html) : ''), [embed.html])
395 if (html) {
396 return (
397 <span
398 className="chat-embed my-1.5 block min-w-0 max-w-full select-text"
399 data-embed-platform={embed.platform ?? ''}
400 data-embed-kind={embed.kind ?? ''}
401 dangerouslySetInnerHTML={{ __html: html }}
402 />
403 )
404 }
405 // 无 HTML 且 OpenGraph 字段全空时不能吞掉链接,退回普通链接展示。
406 if (!embed.title && !embed.description && !embed.image_url && !embed.site_name) {
407 return <InlineLink url={embed.url} />
408 }
409 return (
410 <button
411 type="button"
412 onClick={() => window.open(linkTarget(embed.url), '_blank', 'noopener,noreferrer')}
413 className="chat-embed-card my-1.5 flex w-full max-w-[480px] gap-2.5 overflow-hidden rounded-lg border border-shell-hairline bg-shell-surface text-left hover:border-shell-ink-muted"
414 >
415 {embed.image_url ? (
416 <img src={embed.image_url} alt="" loading="lazy" className="h-20 w-20 shrink-0 object-cover" />
417 ) : null}
418 <span className="min-w-0 flex-1 p-2 pr-3">
419 <span className="block truncate text-[11px] font-semibold uppercase tracking-wide text-shell-ink-muted">
420 {embed.site_name || embedHost(embed.url)}
421 </span>
422 {embed.title ? (
423 <span className="mt-0.5 line-clamp-2 block text-[13px] font-semibold text-shell-ink">
424 {embed.title}
425 </span>
426 ) : null}
427 {embed.description ? (
428 <span className="mt-0.5 line-clamp-2 block text-[12px] leading-[1.4] text-shell-ink-muted">
429 {embed.description}
430 </span>
431 ) : null}
432 </span>
433 </button>
434 )
435}
436
323437const MENTION = /@[\w.\-\u4e00-\u9fa5]+/g
324438
325439/** Replace bare @tokens inside text nodes with highlighted chips. */
@@ -374,6 +488,16 @@
374488 const mentionIds = useMemo(() => new Set(message.mentions ?? []), [message.mentions])
375489 const isSelf = Boolean(viewerId && mentionIds.has(viewerId)) || message.mention_everyone
376490 const tokens = useMemo(() => splitFences(message.content), [message.content])
491 const embeds = useMemo(() => {
492 const list: MessageEmbed[] = message.embeds ?? []
493 const map = new Map<string, MessageEmbed>()
494 for (const item of list) {
495 if (!item || typeof item.url !== 'string') continue
496 map.set(item.url, item)
497 map.set(embedKey(item.url), item)
498 }
499 return map
500 }, [message])
377501 return (
378502 <div className={`chat-message-body min-w-0 break-words ${compact ? 'text-[13px]' : 'text-[15px]'} leading-[1.5]`}>
379503 {tokens.map((token, index) =>
@@ -388,9 +512,9 @@
388512 <code>{token.value}</code>
389513 </pre>
390514 ) : (
391 <span key={`t${index}`} className="whitespace-pre-wrap break-words">
392 {renderInline(token.value, mentionIds, isSelf)}
393 </span>
515 <div key={`t${index}`} className="whitespace-pre-wrap break-words">
516 {renderInline(token.value, mentionIds, isSelf, embeds)}
517 </div>
394518 ),
395519 )}
396520 </div>