Let a Sneedchat file go where the setting says

The uploads setting promised it covered "anything Sneedchat's own
uploader refuses", and reached IRC only. A Sneedchat send called straight
into the postimg.cc path, whose signature had nowhere to put a host at
all, so attaching a video failed with "postimg.cc only accepts images"
and no way to pick something that would take it.

send_attachment now takes the host the caller chose. postimg keeps its
own path - two requests and a [url=][img][/url] wrapper, which is the
site's own habit and gives a click-through the others cannot. Anything
else uploads through the shared hosts and posts the link, wrapped in
[img] for a picture and bare for everything else, because [img] around an
mp4 draws a broken image instead of something you can click.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-08-29 10:08:53 -04:00
co-authored by Claude Opus 5
parent 4c3c13f48f
commit d0b3adb642
2 changed files with 79 additions and 4 deletions
+71 -2
View File
@@ -985,11 +985,32 @@ fn extract_postimg_thumb_name(html: &str) -> Option<(String, String)> {
/// upload, so there's nothing to gain from reusing the Sneedchat session's
/// cookies, and building a new client here means this doesn't need any new
/// account-level state threaded through Runtime just for this one feature.
pub async fn send_attachment(state: &AppState, account_id: &str, buffer_name: &str, caption: &str, file_path: &str) -> Result<()> {
/// Which host to put it on. `None` keeps postimg.cc, which is what this
/// always did and what the site's own regulars use.
///
/// Honouring the caller's choice is the whole point: the uploads setting
/// promised it covered "anything Sneedchat's own uploader refuses", and it
/// reached IRC only - a Sneedchat send called straight into the postimg path
/// below, whose signature had nowhere to put a host. Attaching a video there
/// failed with "postimg.cc only accepts images" and no way to pick something
/// that would take it.
pub async fn send_attachment(
state: &AppState,
account_id: &str,
buffer_name: &str,
caption: &str,
file_path: &str,
host: Option<crate::upload::Host>,
) -> Result<()> {
// Only used to confirm the account is real before spending any time on
// the upload - the transport below is deliberately unrelated to it.
state.accounts.get_sockchat(account_id).ok_or_else(|| anyhow!("no such account"))?;
match host {
None | Some(crate::upload::Host::Postimg) => {}
Some(host) => return send_via_upload_host(state, account_id, buffer_name, caption, file_path, host).await,
}
let bytes = tokio::fs::read(file_path).await.with_context(|| format!("reading {file_path}"))?;
if bytes.is_empty() {
bail!("file is empty");
@@ -1048,6 +1069,41 @@ pub async fn send_attachment(state: &AppState, account_id: &str, buffer_name: &s
send_message(state, account_id, buffer_name, &text, None)
}
/// Posting a file that went to one of the shared upload hosts.
///
/// The link still has to be wrapped in `[img]` for a picture to render
/// rather than sit there as a URL - that is the site's own markup, and what
/// find_attachment_url already unwraps on the way back in. Anything that is
/// not a picture goes as a bare link, because `[img]` around a video would
/// render as a broken image instead of something clickable.
///
/// No `[url=]` wrapper here, unlike the postimg path: these hosts serve the
/// file itself rather than a page about it, so there is nothing to click
/// through to that the image is not already showing.
async fn send_via_upload_host(
state: &AppState,
account_id: &str,
buffer_name: &str,
caption: &str,
file_path: &str,
host: crate::upload::Host,
) -> Result<()> {
let link = crate::upload::upload(host, file_path, None).await?;
let file_name = std::path::Path::new(file_path).file_name().and_then(|n| n.to_str()).unwrap_or("");
let posted = posted_markup(file_name, &link);
let text = if caption.trim().is_empty() { posted } else { format!("{caption}\n{posted}") };
send_message(state, account_id, buffer_name, &text, None)
}
/// How a finished upload is written into a message.
fn posted_markup(file_name: &str, link: &str) -> String {
if guess_postimg_content_type(file_name).is_some() {
format!("[img]{link}[/img]")
} else {
link.to_string()
}
}
/// `editMessage`'s SockChat branch (see rpc/methods.rs). The server has no
/// direct "edit accepted" reply - the edited message just comes back
/// through the normal live stream with a bumped message_edit_date, which
@@ -1142,6 +1198,19 @@ async fn try_login(state: &AppState, login_id: &str, config: &SockChatAccountCon
#[cfg(test)]
mod tests {
/// A picture has to be wrapped for the site to render it; a video must
/// not be, because [img] around an .mp4 draws a broken image instead of
/// something you can click.
#[test]
fn only_pictures_are_wrapped_for_display() {
assert_eq!(posted_markup("cat.png", "https://files.catbox.moe/a.png"), "[img]https://files.catbox.moe/a.png[/img]");
assert_eq!(posted_markup("cat.JPEG", "https://x/a.jpeg"), "[img]https://x/a.jpeg[/img]");
assert_eq!(posted_markup("clip.mp4", "https://files.catbox.moe/b.mp4"), "https://files.catbox.moe/b.mp4");
assert_eq!(posted_markup("notes.pdf", "https://x/c.pdf"), "https://x/c.pdf");
// No extension at all is not a picture.
assert_eq!(posted_markup("README", "https://x/d"), "https://x/d");
}
#[test]
fn a_reply_opens_with_the_mention_sneedchat_understands() {
// Sneedchat has no reply field; answering somebody by name is what
@@ -1166,7 +1235,7 @@ mod tests {
assert_eq!(super::as_reply("plain", Some("")), "plain");
}
use super::{cached_avatar_file, find_attachment_url, sniff_image_ext};
use super::{cached_avatar_file, find_attachment_url, posted_markup, sniff_image_ext};
#[test]
fn matches_the_real_reported_url() {
+8 -2
View File
@@ -1027,9 +1027,15 @@ pub async fn dispatch(
// Unlike Discord, Sneedchat's own chat protocol has no
// upload endpoint at all - see backend::sockchat::
// send_attachment's own doc comment for how this
// still ends up posting a real image.
// still ends up posting a real image. The host choice
// reaches it the same way it reaches IRC below; without
// that it always went to postimg.cc, which takes images
// and nothing else.
let result = match attachment_path {
Some(path) => backend::sockchat::send_attachment(state, &buffer.account_id, &buffer.name, body, path).await,
Some(path) => {
let host = p_str_opt(params, "uploadHost").and_then(crate::upload::Host::parse);
backend::sockchat::send_attachment(state, &buffer.account_id, &buffer.name, body, path, host).await
}
None => backend::sockchat::send_message(state, &buffer.account_id, &buffer.name, body, reply_to_nick.as_deref()),
};
match result {