mirror of
https://github.com/TheFunny/TelegramTwitterMediaBot.git
synced 2026-09-23 23:32:05 +00:00
feat: add Misskey (misskey.io) fetch support
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.
This commit is contained in:
@@ -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<String> {
|
||||
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<Regex> =
|
||||
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<Fetched, FetchError> {
|
||||
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:<note id>"`. The prefix is the
|
||||
/// site id used for caption-format lookup and link-cache keys.
|
||||
pub fn cache_key(url: &str) -> Option<String> {
|
||||
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<Vec<(&'static str, String)>> {
|
||||
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<model::Note, FetchError> {
|
||||
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::<serde_json::Value>().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<model::Note> 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<Media> = 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<a href=\"{author_url}\">{author}</a>");
|
||||
}
|
||||
format!(
|
||||
"{url}\n<a href=\"{author_url}\">{author}</a>: {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<Media> {
|
||||
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<a href=\"https://misskey.io/@donyan47897\">ミロン</a>: 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<a href=\"https://misskey.io/@donyan47897\">ミロン</a>"
|
||||
);
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
mod interface;
|
||||
mod model;
|
||||
|
||||
pub use interface::{
|
||||
MisskeySite, PATTERN, cache_key, enabled, fetch_from_url, is_retryable, media_headers,
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub(crate) struct Note {
|
||||
pub(crate) id: String,
|
||||
pub(crate) text: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(crate) cw: Option<String>,
|
||||
pub(crate) user: User,
|
||||
#[serde(default)]
|
||||
pub(crate) files: Vec<DriveFile>,
|
||||
/// 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<Box<Note>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub(crate) struct User {
|
||||
pub(crate) name: Option<String>,
|
||||
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<String>,
|
||||
#[serde(default, rename = "isSensitive")]
|
||||
pub(crate) is_sensitive: bool,
|
||||
#[serde(default)]
|
||||
pub(crate) name: Option<String>,
|
||||
}
|
||||
@@ -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<Vec<Box<dyn Site>>> = 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).
|
||||
|
||||
@@ -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?;
|
||||
}
|
||||
|
||||
@@ -135,7 +135,12 @@ pub fn extract_urls(message: &Message) -> Vec<String> {
|
||||
fn thumbnail_for(media: &Media) -> Option<String> {
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user