//! Site adapter for misskey.io notes: URL pattern, API fetch and //! normalization into [`Fetched`] (see [`crate::site::Site`]). use super::model; use crate::media::Media; use crate::site::{FetchError, Fetched, RenderData, Site, SiteFuture}; use html_escape::encode_text; use regex::Regex; use std::sync::LazyLock; const API_URL: &str = "https://misskey.io/api/notes/show"; /// Registry entry for the misskey.io adapter (see [`crate::site::Site`]). pub struct MisskeySite; impl Site for MisskeySite { fn id(&self) -> &'static str { "misskey" } fn pattern(&self) -> &'static Regex { &PATTERN } fn cache_key(&self, url: &str) -> Option { cache_key(url) } fn fetch_from_url<'a>(&'a self, url: &'a str) -> SiteFuture<'a, Fetched> { Box::pin(async move { fetch_from_url(url).await }) } } pub static PATTERN: LazyLock = LazyLock::new(|| Regex::new(r"^(?:https?://)?misskey\.io/notes/([\w.\-~]+)").unwrap()); pub async fn fetch_from_url(url: &str) -> Result { let caps = PATTERN.captures(url).ok_or(FetchError::NotFound)?; let note_id = caps.get(1).ok_or(FetchError::NotFound)?.as_str(); let note = fetch(note_id).await?; Ok(note.into()) } /// Cache key for a misskey URL: `"misskey:"`. The prefix is the /// site id used for caption-format lookup and link-cache keys. pub fn cache_key(url: &str) -> Option { PATTERN .captures(url) .map(|caps| format!("misskey:{}", &caps[1])) } /// Fetches a note from misskey.io by id. The API answers client failures /// with HTTP 400 + `{"error":{"code":...}}` (NO_SUCH_NOTE → NotFound); /// every other non-success status falls through to the shared classes in /// [`crate::site::status_error`] — persistent 4xx permanent, 429/5xx retried. pub async fn fetch(note_id: &str) -> Result { let response = crate::site::CLIENT .post(API_URL) .json(&serde_json::json!({ "noteId": note_id })) .send() .await?; let status = response.status(); if !status.is_success() { return Err(match status.as_u16() { 400 => not_found_or_invalid(response).await, // The local fallback used to disagree with the center: a misskey // 404 came back Transient here and was fetched three more times // for a note that is simply gone. _ => crate::site::status_error("misskey", &response), }); } crate::site::response_json(response, "misskey").await } /// Maps a 400 response: NO_SUCH_NOTE is permanent NotFound, any other 400 is /// a site error (permanent — retrying a rejected request cannot succeed). async fn not_found_or_invalid(response: reqwest::Response) -> FetchError { match crate::site::response_json::(response, "misskey").await { Ok(v) if v["error"]["code"] == "NO_SUCH_NOTE" => FetchError::NotFound, _ => FetchError::Site { site: "misskey", error: "note rejected (invalid param or private note)".into(), }, } } /// The note whose content matters: a renote shell has no text/files of its /// own — the embedded renote carries them. fn effective(note: &model::Note) -> &model::Note { match ¬e.renote { Some(renote) if note.files.is_empty() => renote, _ => note, } } impl From for Fetched { fn from(note: model::Note) -> Self { let note = ¬e; let content = effective(note); let url = format!("https://misskey.io/notes/{}", note.id); let author = content .user .name .as_deref() .filter(|n| !n.is_empty()) .unwrap_or(&content.user.username) .to_string(); let author_url = format!("https://misskey.io/@{}", content.user.username); let cw = content.cw.as_deref().unwrap_or_default(); // Notes carry hashtags inline in the text (no structured tags array); // a CW note gets the marker prefixed so recipients see the spoiler. let mut text = cw.to_string(); if !cw.is_empty() && !text.ends_with(' ') { text.push(' '); } text.push_str(content.text.as_deref().unwrap_or_default().trim()); let text = text.trim().to_string(); let caption = crate::site::caption(&url, &author_url, &author, &text); let sensitive = content.cw.is_some() || content.files.iter().any(|f| f.is_sensitive); let media: Vec = content.files.iter().filter_map(media_from_file).collect(); Fetched { source_url: url.clone(), caption, // A note has no title: its text (CW marker included) is content. title: String::new(), content: text.clone(), media, sensitive, site_id: "misskey", render_data: Some(RenderData { author: encode_text(&author).into_owned(), author_url: author_url.clone(), title: String::new(), content: encode_text(&text).into_owned(), tags: String::new(), }), _keep_alive: None, } } } /// Maps a Misskey DriveFile to a [`Media`] item; unknown/audio/other types /// are skipped (twitter's `_ => {}` precedent). GIF must be matched before /// the generic image arm. fn media_from_file(file: &model::DriveFile) -> Option { match file.mime_type.as_str() { "image/gif" => Some(Media::Animated { url: file.url.clone(), thumbnail_url: file.thumbnail_url.clone().unwrap_or_default(), }), mime if mime.starts_with("image/") => Some(Media::Illustration { url: file.url.clone(), thumbnail_url: file.thumbnail_url.clone(), fallback_url: None, }), mime if mime.starts_with("video/") => Some(Media::Video { url: file.url.clone(), thumbnail_url: file.thumbnail_url.clone().unwrap_or_default(), }), _ => None, } } #[cfg(test)] mod tests { use super::*; fn note_json(json: serde_json::Value) -> model::Note { serde_json::from_value(json).unwrap() } fn base_note() -> serde_json::Value { serde_json::json!({ "id": "aotihl10lqrs015s", "text": "hello", "user": { "name": "ミロン", "username": "donyan47897", "host": null }, "files": [] }) } #[test] fn pattern_matches_misskey_note_urls() { for url in [ "https://misskey.io/notes/aotihl10lqrs015s", "http://misskey.io/notes/aotihl10lqrs015s", "misskey.io/notes/aotihl10lqrs015s", ] { assert!(PATTERN.is_match(url), "{url}"); } for url in [ "https://misskey.io/", "https://misskey.io/@user", "https://misskey.io/notes/", "https://x.com/user/status/123", ] { assert!(!PATTERN.is_match(url), "{url}"); } } #[test] fn cache_key_normalizes_variants() { assert_eq!( cache_key("https://misskey.io/notes/aotihl10lqrs015s"), Some("misskey:aotihl10lqrs015s".to_string()) ); } #[test] fn from_json_image_file() { let mut note = base_note(); note["files"] = serde_json::json!([{ "type": "image/webp", "url": "https://media.misskeyusercontent.jp/io/a.webp", "thumbnailUrl": "https://media.misskeyusercontent.jp/io/t.webp", "isSensitive": true, "name": "pic.webp" }]); let fetched: Fetched = note_json(note).into(); assert_eq!( fetched.source_url, "https://misskey.io/notes/aotihl10lqrs015s" ); assert_eq!(fetched.site_id, "misskey"); assert_eq!(fetched.title, ""); assert_eq!(fetched.content, "hello"); assert!(fetched.sensitive); assert_eq!(fetched.media.len(), 1); match &fetched.media[0] { Media::Illustration { url, thumbnail_url, fallback_url, } => { assert_eq!(url, "https://media.misskeyusercontent.jp/io/a.webp"); assert_eq!( thumbnail_url.as_deref(), Some("https://media.misskeyusercontent.jp/io/t.webp") ); assert!(fallback_url.is_none()); } other => panic!("expected illustration, got {other:?}"), } } #[test] fn from_json_gif_video_and_skip_audio() { let mut note = base_note(); note["files"] = serde_json::json!([ { "type": "audio/mpeg", "url": "https://m/a.mp3", "isSensitive": false }, { "type": "image/gif", "url": "https://m/a.gif", "isSensitive": false }, { "type": "video/webm", "url": "https://m/a.webm", "isSensitive": false } ]); let fetched: Fetched = note_json(note).into(); assert_eq!(fetched.media.len(), 2); assert!( matches!(&fetched.media[0], Media::Animated { url, .. } if url == "https://m/a.gif") ); assert!(matches!(&fetched.media[1], Media::Video { url, .. } if url == "https://m/a.webm")); // No thumbnailUrl → empty string, not a broken URL. match &fetched.media[1] { Media::Video { thumbnail_url, .. } => assert_eq!(thumbnail_url, ""), other => panic!("expected video, got {other:?}"), } assert!(!fetched.sensitive); } #[test] fn from_json_cw_marks_sensitive_and_prefixes_title() { let mut note = base_note(); note["cw"] = serde_json::json!("spoiler"); note["text"] = serde_json::json!("body"); let fetched: Fetched = note_json(note).into(); assert!(fetched.sensitive); assert_eq!(fetched.title, ""); assert_eq!(fetched.content, "spoiler body"); } #[test] fn from_json_author_falls_back_to_username() { let mut note = base_note(); note["user"] = serde_json::json!({ "name": null, "username": "donyan47897", "host": null }); let fetched: Fetched = note_json(note).into(); assert!( fetched.caption.contains("donyan47897"), "{}", fetched.caption ); assert!(fetched.caption.contains("https://misskey.io/@donyan47897")); } #[test] fn from_json_renote_uses_embedded_content() { let note = serde_json::json!({ "id": "shell0000000000", "text": null, "user": { "name": "shell", "username": "shelluser", "host": null }, "files": [], "renote": { "id": "inner000000000", "text": "inner text", "user": { "name": "inner", "username": "inneruser", "host": null }, "files": [ { "type": "image/png", "url": "https://m/i.png", "isSensitive": false } ] } }); let fetched: Fetched = note_json(note).into(); assert_eq!(fetched.title, ""); assert_eq!(fetched.content, "inner text"); assert_eq!(fetched.media.len(), 1); // The source URL still points at the renote shell the user posted. assert_eq!( fetched.source_url, "https://misskey.io/notes/shell0000000000" ); } #[test] fn caption_layout_matches_bsky() { let fetched: Fetched = note_json(base_note()).into(); assert_eq!( fetched.caption, "https://misskey.io/notes/aotihl10lqrs015s\nミロン: hello" ); } #[test] fn caption_without_text_has_no_dangling_colon() { let mut note = base_note(); note["text"] = serde_json::json!(null); let fetched: Fetched = note_json(note).into(); assert_eq!( fetched.caption, "https://misskey.io/notes/aotihl10lqrs015s\nミロン" ); } #[tokio::test] #[ignore = "live network: requires outbound HTTPS to misskey.io"] async fn live_fetch_reference_note() { let fetched = fetch_from_url("https://misskey.io/notes/aotihl10lqrs015s") .await .unwrap(); assert_eq!(fetched.site_id, "misskey"); assert_eq!(fetched.media.len(), 1); assert!(fetched.sensitive); assert!(!fetched.caption.is_empty()); } }