diff --git a/crates/x-media/src/site/bsky/interface.rs b/crates/x-media/src/site/bsky/interface.rs
index c8ed335..606d753 100644
--- a/crates/x-media/src/site/bsky/interface.rs
+++ b/crates/x-media/src/site/bsky/interface.rs
@@ -1,7 +1,7 @@
use super::model;
use crate::media::Media;
use crate::site::{FetchError, Fetched};
-use html_escape::encode_text;
+use html_escape::{encode_double_quoted_attribute, encode_text};
use regex::Regex;
use std::sync::LazyLock;
@@ -224,8 +224,8 @@ impl Post {
pub fn caption(&self) -> String {
format!(
"{url}\n{author}: {text}",
- url = self.url(),
- author_url = self.author_url(),
+ url = encode_double_quoted_attribute(&self.url()),
+ author_url = encode_double_quoted_attribute(&self.author_url()),
author = encode_text(&self.author),
text = encode_text(&self.text),
)
diff --git a/crates/x-media/src/site/pixiv/interface.rs b/crates/x-media/src/site/pixiv/interface.rs
index 506bced..6a68680 100644
--- a/crates/x-media/src/site/pixiv/interface.rs
+++ b/crates/x-media/src/site/pixiv/interface.rs
@@ -1,7 +1,7 @@
use super::model::{IllustrationModel, TypeModel};
use crate::media::Media;
use crate::site::{FetchError, Fetched};
-use html_escape::encode_text;
+use html_escape::{encode_double_quoted_attribute, encode_text};
use regex::Regex;
use std::sync::LazyLock;
@@ -48,9 +48,9 @@ impl Illustration {
pub fn caption(&self) -> String {
format!(
"{title} / {author}\n{tags}",
- url = self.url(),
+ url = encode_double_quoted_attribute(&self.url()),
title = encode_text(&self.title),
- author_url = self.author_url(),
+ author_url = encode_double_quoted_attribute(&self.author_url()),
author = encode_text(&self.author),
tags = encode_text(
&self
diff --git a/crates/x-media/src/site/twitter/interface.rs b/crates/x-media/src/site/twitter/interface.rs
index 70baf87..90b2829 100644
--- a/crates/x-media/src/site/twitter/interface.rs
+++ b/crates/x-media/src/site/twitter/interface.rs
@@ -1,7 +1,7 @@
use super::model;
use crate::media::Media;
use crate::site::{FetchError, Fetched};
-use html_escape::encode_text;
+use html_escape::{encode_double_quoted_attribute, encode_text};
use regex::Regex;
use std::sync::LazyLock;
@@ -48,7 +48,9 @@ pub async fn fetch_from_url(url: &str) -> Result {
fn empty_fetched(url: &str) -> Fetched {
Fetched {
source_url: url.to_string(),
- caption: url.to_string(),
+ // The raw user-supplied URL goes into an HTML caption; escape it so
+ // crafted links cannot break the parse (Telegram 400).
+ caption: encode_text(url).into_owned(),
title: String::new(),
media: vec![],
sensitive: true,
@@ -151,8 +153,8 @@ impl Tweet {
pub fn caption(&self) -> String {
format!(
"{url}\n{author}: {text}",
- url = self.url(),
- author_url = self.author_url(),
+ url = encode_double_quoted_attribute(&self.url()),
+ author_url = encode_double_quoted_attribute(&self.author_url()),
author = encode_text(&self.author),
text = encode_text(&self.text),
)
diff --git a/crates/xmedia-bot/src/handlers.rs b/crates/xmedia-bot/src/handlers.rs
index e695dee..3bdf887 100644
--- a/crates/xmedia-bot/src/handlers.rs
+++ b/crates/xmedia-bot/src/handlers.rs
@@ -162,7 +162,7 @@ async fn edit_message_handler(bot: &Bot, message: &Message) -> bool {
};
let link = format!(
"{1}",
- edit.url,
+ html_escape::encode_double_quoted_attribute(&edit.url),
html_escape::encode_text(text)
);
let new_text = if edit.template.is_empty() {