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.
|
//! Site fetching dispatcher and unified result types.
|
||||||
//!
|
//!
|
||||||
//! Dispatch order: twitter → bsky → pixiv. Each site module exports a
|
//! Dispatch order: twitter → bsky → misskey → pixiv. Each site module
|
||||||
//! `PATTERN`, `enabled()` and `fetch_from_url()`; a future site plugs in by
|
//! exports a `PATTERN`, `enabled()` and `fetch_from_url()`; a future site
|
||||||
//! adding one guarded entry in [`fetch_once`].
|
//! plugs in by adding one guarded entry in [`fetch_once`].
|
||||||
|
|
||||||
use std::future::Future;
|
use std::future::Future;
|
||||||
use std::pin::Pin;
|
use std::pin::Pin;
|
||||||
@@ -14,6 +14,7 @@ use regex::Regex;
|
|||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
pub mod bsky;
|
pub mod bsky;
|
||||||
|
pub mod misskey;
|
||||||
pub mod pixiv;
|
pub mod pixiv;
|
||||||
pub mod twitter;
|
pub mod twitter;
|
||||||
|
|
||||||
@@ -329,6 +330,7 @@ static SITES: LazyLock<Vec<Box<dyn Site>>> = LazyLock::new(|| {
|
|||||||
vec![
|
vec![
|
||||||
Box::new(twitter::TwitterSite),
|
Box::new(twitter::TwitterSite),
|
||||||
Box::new(bsky::BskySite),
|
Box::new(bsky::BskySite),
|
||||||
|
Box::new(misskey::MisskeySite),
|
||||||
Box::new(pixiv::PixivSite),
|
Box::new(pixiv::PixivSite),
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
@@ -531,10 +533,10 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn registry_lists_all_sites_in_dispatch_order() {
|
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.
|
// Enabled sites dispatch; unsupported URLs never match.
|
||||||
assert!(find_site("https://x.com/u/status/1").is_some());
|
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());
|
assert!(find_site("https://example.com/x").is_none());
|
||||||
// Cache keys are pattern-driven, independent of the enabled() gate
|
// Cache keys are pattern-driven, independent of the enabled() gate
|
||||||
// (pixiv is disabled in tests without PIXIV_REFRESH_TOKEN).
|
// (pixiv is disabled in tests without PIXIV_REFRESH_TOKEN).
|
||||||
|
|||||||
@@ -253,7 +253,7 @@ pub(crate) async fn execute_command(
|
|||||||
bot,
|
bot,
|
||||||
message.chat.id.0,
|
message.chat.id.0,
|
||||||
message.id,
|
message.id,
|
||||||
"Unknown site. Use twitter, bsky or pixiv.",
|
"Unknown site. Use twitter, bsky, pixiv or misskey.",
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -294,7 +294,7 @@ pub(crate) async fn execute_command(
|
|||||||
bot,
|
bot,
|
||||||
message.chat.id.0,
|
message.chat.id.0,
|
||||||
message.id,
|
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?;
|
.await?;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -337,7 +337,7 @@ pub(crate) async fn execute_command(
|
|||||||
bot,
|
bot,
|
||||||
message.chat.id.0,
|
message.chat.id.0,
|
||||||
message.id,
|
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?;
|
.await?;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -135,7 +135,12 @@ pub fn extract_urls(message: &Message) -> Vec<String> {
|
|||||||
fn thumbnail_for(media: &Media) -> Option<String> {
|
fn thumbnail_for(media: &Media) -> Option<String> {
|
||||||
let url = media.url();
|
let url = media.url();
|
||||||
if url.starts_with("http://") || url.starts_with("https://") {
|
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 {
|
} else {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user