From 11c04b66dc811ea2afc71f1ccd79808a9430a007 Mon Sep 17 00:00:00 2001 From: YoursFunny Date: Mon, 7 Sep 2026 21:25:52 +0800 Subject: [PATCH] feat: add Misskey (misskey.io) fetch support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fourth site adapter: POST /api/notes/show, renote-aware caption and media normalization, DriveFile type → Illustration/Animated/Video. Empty thumbnailUrl strings filtered out in thumbnail_for. --- crates/x-media/src/site/misskey/interface.rs | 384 +++++++++++++++++++ crates/x-media/src/site/misskey/mod.rs | 6 + crates/x-media/src/site/misskey/model.rs | 35 ++ crates/x-media/src/site/mod.rs | 12 +- crates/xmedia-bot/src/handlers/commands.rs | 6 +- crates/xmedia-bot/src/handlers/urls.rs | 7 +- 6 files changed, 441 insertions(+), 9 deletions(-) create mode 100644 crates/x-media/src/site/misskey/interface.rs create mode 100644 crates/x-media/src/site/misskey/mod.rs create mode 100644 crates/x-media/src/site/misskey/model.rs diff --git a/crates/x-media/src/site/misskey/interface.rs b/crates/x-media/src/site/misskey/interface.rs new file mode 100644 index 0000000..d59c232 --- /dev/null +++ b/crates/x-media/src/site/misskey/interface.rs @@ -0,0 +1,384 @@ +//! 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_double_quoted_attribute, 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 fn enabled() -> bool { + true +} + +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])) +} + +/// Misskey's fetch-retry policy: transient classes only. Not-found, blocked +/// and parse failures are permanent. +pub fn is_retryable(err: &FetchError) -> bool { + matches!(err, FetchError::Http(_) | FetchError::Transient(_)) +} + +/// misskey.io media hosts need no extra headers (verified: direct GET works). +pub fn media_headers(_url: &str) -> Option> { + None +} + +/// Fetches a note from misskey.io by id. The API answers client failures +/// with HTTP 400 + `{"error":{"code":...}}` (NO_SUCH_NOTE → NotFound); +/// everything else non-success is transient and retried by [`crate::site::fetch`]. +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, + _ => FetchError::Transient(format!("misskey status {status}")), + }); + } + response.json().await.map_err(|e| FetchError::Site { + site: "misskey", + error: Box::new(e), + }) +} + +/// 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 response.json::().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 title = cw.to_string(); + if !cw.is_empty() && !title.ends_with(' ') { + title.push(' '); + } + title.push_str(content.text.as_deref().unwrap_or_default().trim()); + let title = title.trim().to_string(); + + let caption = caption(&url, &author_url, &author, &title); + 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, + title: title.clone(), + media, + sensitive, + site_id: "misskey", + render_data: Some(RenderData { + url, + author: encode_text(&author).into_owned(), + author_url: author_url.clone(), + title: encode_text(&title).into_owned(), + tags: String::new(), + }), + _keep_alive: None, + } + } +} + +fn caption(url: &str, author_url: &str, author: &str, text: &str) -> String { + let url = encode_double_quoted_attribute(url); + let author_url = encode_double_quoted_attribute(author_url); + let author = encode_text(author); + if text.is_empty() { + return format!("{url}\n{author}"); + } + format!( + "{url}\n{author}: {text}", + text = encode_text(text), + ) +} + +/// 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 { + let title = file.name.clone(); + match file.mime_type.as_str() { + "image/gif" => Some(Media::Animated { + title, + url: file.url.clone(), + thumbnail_url: file.thumbnail_url.clone().unwrap_or_default(), + }), + mime if mime.starts_with("image/") => Some(Media::Illustration { + title, + url: file.url.clone(), + thumbnail_url: file.thumbnail_url.clone(), + fallback_url: None, + }), + mime if mime.starts_with("video/") => Some(Media::Video { + title, + 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()) + ); + assert_eq!(x_media_site_id("misskey:abc"), "misskey"); + } + + fn x_media_site_id(key: &str) -> &'static str { + crate::site::site_id_from_key(key) + } + + #[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, "hello"); + assert!(fetched.sensitive); + assert_eq!(fetched.media.len(), 1); + match &fetched.media[0] { + Media::Illustration { + title, + url, + thumbnail_url, + fallback_url, + } => { + assert_eq!(title.as_deref(), Some("pic.webp")); + 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, "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, "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()); + } +} diff --git a/crates/x-media/src/site/misskey/mod.rs b/crates/x-media/src/site/misskey/mod.rs new file mode 100644 index 0000000..325cdb3 --- /dev/null +++ b/crates/x-media/src/site/misskey/mod.rs @@ -0,0 +1,6 @@ +mod interface; +mod model; + +pub use interface::{ + MisskeySite, PATTERN, cache_key, enabled, fetch_from_url, is_retryable, media_headers, +}; diff --git a/crates/x-media/src/site/misskey/model.rs b/crates/x-media/src/site/misskey/model.rs new file mode 100644 index 0000000..8c4963b --- /dev/null +++ b/crates/x-media/src/site/misskey/model.rs @@ -0,0 +1,35 @@ +use serde::Deserialize; + +#[derive(Deserialize, Debug)] +pub(crate) struct Note { + pub(crate) id: String, + pub(crate) text: Option, + #[serde(default)] + pub(crate) cw: Option, + pub(crate) user: User, + #[serde(default)] + pub(crate) files: Vec, + /// Embedded original note when this note is a renote; the shell's own + /// text/files are usually empty and the content lives here. + #[serde(default)] + pub(crate) renote: Option>, +} + +#[derive(Deserialize, Debug)] +pub(crate) struct User { + pub(crate) name: Option, + pub(crate) username: String, +} + +#[derive(Deserialize, Debug)] +pub(crate) struct DriveFile { + #[serde(rename = "type")] + pub(crate) mime_type: String, + pub(crate) url: String, + #[serde(default, rename = "thumbnailUrl")] + pub(crate) thumbnail_url: Option, + #[serde(default, rename = "isSensitive")] + pub(crate) is_sensitive: bool, + #[serde(default)] + pub(crate) name: Option, +} diff --git a/crates/x-media/src/site/mod.rs b/crates/x-media/src/site/mod.rs index e0aa42c..c6d3e0f 100644 --- a/crates/x-media/src/site/mod.rs +++ b/crates/x-media/src/site/mod.rs @@ -1,8 +1,8 @@ //! Site fetching dispatcher and unified result types. //! -//! Dispatch order: twitter → bsky → pixiv. Each site module exports a -//! `PATTERN`, `enabled()` and `fetch_from_url()`; a future site plugs in by -//! adding one guarded entry in [`fetch_once`]. +//! Dispatch order: twitter → bsky → misskey → pixiv. Each site module +//! exports a `PATTERN`, `enabled()` and `fetch_from_url()`; a future site +//! plugs in by adding one guarded entry in [`fetch_once`]. use std::future::Future; use std::pin::Pin; @@ -14,6 +14,7 @@ use regex::Regex; use thiserror::Error; pub mod bsky; +pub mod misskey; pub mod pixiv; pub mod twitter; @@ -329,6 +330,7 @@ static SITES: LazyLock>> = LazyLock::new(|| { vec![ Box::new(twitter::TwitterSite), Box::new(bsky::BskySite), + Box::new(misskey::MisskeySite), Box::new(pixiv::PixivSite), ] }); @@ -531,10 +533,10 @@ mod tests { #[test] fn registry_lists_all_sites_in_dispatch_order() { - assert_eq!(site_ids(), vec!["twitter", "bsky", "pixiv"]); + assert_eq!(site_ids(), vec!["twitter", "bsky", "misskey", "pixiv"]); // Enabled sites dispatch; unsupported URLs never match. assert!(find_site("https://x.com/u/status/1").is_some()); - assert!(find_site("https://bsky.app/profile/u/post/3x").is_some()); + assert!(find_site("https://misskey.io/notes/abc").is_some()); assert!(find_site("https://example.com/x").is_none()); // Cache keys are pattern-driven, independent of the enabled() gate // (pixiv is disabled in tests without PIXIV_REFRESH_TOKEN). diff --git a/crates/xmedia-bot/src/handlers/commands.rs b/crates/xmedia-bot/src/handlers/commands.rs index d40948d..4ecfc17 100644 --- a/crates/xmedia-bot/src/handlers/commands.rs +++ b/crates/xmedia-bot/src/handlers/commands.rs @@ -253,7 +253,7 @@ pub(crate) async fn execute_command( bot, message.chat.id.0, message.id, - "Unknown site. Use twitter, bsky or pixiv.", + "Unknown site. Use twitter, bsky, pixiv or misskey.", ) .await?; return Ok(()); @@ -294,7 +294,7 @@ pub(crate) async fn execute_command( bot, message.chat.id.0, message.id, - "Unrecognized link. Use a twitter/x, pixiv or bsky post URL.", + "Unrecognized link. Use a twitter/x, pixiv, bsky or misskey post URL.", ) .await?; return Ok(()); @@ -337,7 +337,7 @@ pub(crate) async fn execute_command( bot, message.chat.id.0, message.id, - "No enabled site matches this link (twitter/x, pixiv or bsky).", + "No enabled site matches this link (twitter/x, pixiv, bsky or misskey).", ) .await?; } diff --git a/crates/xmedia-bot/src/handlers/urls.rs b/crates/xmedia-bot/src/handlers/urls.rs index b66603d..d8dc802 100644 --- a/crates/xmedia-bot/src/handlers/urls.rs +++ b/crates/xmedia-bot/src/handlers/urls.rs @@ -135,7 +135,12 @@ pub fn extract_urls(message: &Message) -> Vec { fn thumbnail_for(media: &Media) -> Option { let url = media.url(); if url.starts_with("http://") || url.starts_with("https://") { - media.thumbnail_url().map(str::to_string) + // An empty thumbnail string (misskey video/gif files without a + // thumbnailUrl) must not reach Telegram; let it generate its own. + media + .thumbnail_url() + .map(str::to_string) + .filter(|t| !t.is_empty()) } else { None }