From 07424274df987f1979419b598d9ad5e797571767 Mon Sep 17 00:00:00 2001 From: jel <25802745+jelni@users.noreply.github.com> Date: Sat, 28 Dec 2024 00:59:08 +0100 Subject: [PATCH] rewrite /cobalt_download to support v10 and custom instances --- .env.example | 19 +- docker-compose.yml => compose.yml | 1 + src/apis/cobalt.rs | 182 ++++------ src/commands.rs | 8 + src/commands/cobalt_download.rs | 502 +++++++++++++++------------- src/commands/delete.rs | 2 +- src/commands/makersuite.rs | 8 +- src/commands/mevo.rs | 2 +- src/commands/sex.rs | 2 +- src/commands/stablehorde.rs | 2 +- src/utilities/api_utils.rs | 4 +- src/utilities/command_dispatcher.rs | 11 + src/utilities/file_download.rs | 29 +- src/utilities/google_translate.rs | 2 +- src/utilities/image_utils.rs | 7 +- src/utilities/message_entities.rs | 2 +- src/utilities/parsed_command.rs | 2 +- src/utilities/telegram_utils.rs | 2 +- src/utilities/text_utils.rs | 4 +- 19 files changed, 391 insertions(+), 400 deletions(-) rename docker-compose.yml => compose.yml (94%) diff --git a/.env.example b/.env.example index 6cf694b..42f0f4a 100644 --- a/.env.example +++ b/.env.example @@ -1,10 +1,11 @@ USER_AGENT="telegram-bot (t.me/bot; me )" -TELEGRAM_TOKEN=123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11 -API_ID=YOUR_TELEGRAM_API_ID -API_HASH=YOUR_TELEGRAM_API_HASH -DB_ENCRYPTION_KEY=12345678 -FAL_API_KEY=YOUR_API_KEY -STABLEHORDE_TOKEN=0000000000 -STABLEHORDE_CLIENT=name:version:contact -MAKERSUITE_API_KEY=YOUR_API_KEY -GROQ_API_KEY=YOUR_API_KEY +TELEGRAM_TOKEN="123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11" +API_ID="YOUR_TELEGRAM_API_ID" +API_HASH="YOUR_TELEGRAM_API_HASH" +DB_ENCRYPTION_KEY="12345678" +COBALT_INSTANCES="[{\"name\": \"example.com\", \"url\": \"http://localhost:9000/\"}, {\"name\": \"cobalt.tools\", \"url\": \"https://api.cobalt.tools/\", \"api_key\": \"api_key\"}]" +FAL_API_KEY="YOUR_API_KEY" +STABLEHORDE_TOKEN="0000000000" +STABLEHORDE_CLIENT="name:version:contact" +MAKERSUITE_API_KEY="YOUR_API_KEY" +GROQ_API_KEY="YOUR_API_KEY" diff --git a/docker-compose.yml b/compose.yml similarity index 94% rename from docker-compose.yml rename to compose.yml index e72dcc6..42d8519 100644 --- a/docker-compose.yml +++ b/compose.yml @@ -8,6 +8,7 @@ services: API_ID: API_HASH: DB_ENCRYPTION_KEY: + COBALT_INSTANCES: FAL_API_KEY: STABLEHORDE_TOKEN: STABLEHORDE_CLIENT: diff --git a/src/apis/cobalt.rs b/src/apis/cobalt.rs index 1827174..f77a7a9 100644 --- a/src/apis/cobalt.rs +++ b/src/apis/cobalt.rs @@ -1,156 +1,106 @@ use std::collections::HashMap; -use reqwest::header::ACCEPT; +use reqwest::header::{ACCEPT, AUTHORIZATION}; use serde::{Deserialize, Serialize}; -use crate::commands::CommandError; use crate::utilities::api_utils::{DetectServerError, ServerError}; #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct Payload<'a> { url: &'a str, - v_quality: &'a str, - a_format: &'a str, - is_audio_only: bool, - #[serde(rename = "tiktokH265")] - tiktok_h265: bool, + video_quality: &'a str, + audio_format: &'a str, + download_mode: &'a str, + tiktok_full_audio: bool, + twitter_gif: bool, } #[derive(Deserialize)] -struct Response { - status: Status, - text: Option, - url: Option, - picker: Option>, +#[serde(rename_all = "kebab-case")] +#[serde(tag = "status")] +pub enum Response { + Redirect(File), + Tunnel(File), + Picker(Picker), + Error(CobaltError), } #[derive(Deserialize)] -#[serde(rename_all = "kebab-case")] -enum Status { - Stream, - Redirect, - Picker, - Success, - Error, - RateLimit, +pub struct File { + pub url: String, + pub filename: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Picker { + pub audio: Option, + pub audio_filename: Option, + #[expect(clippy::struct_field_names)] + pub picker: Vec, +} + +#[derive(Deserialize)] +pub struct PickerItem { + pub url: String, + pub thumb: Option, +} + +#[derive(Deserialize)] +pub struct CobaltError { + pub error: ErrorContext, } #[derive(Deserialize)] -struct PickerItem { - url: String, +pub struct ErrorContext { + pub code: String, } pub enum Error { - Cobalt(String), Server(ServerError), Network(reqwest::Error), } pub async fn query( - http_client: reqwest::Client, - domain: &str, + http_client: &reqwest::Client, + instance: &str, + api_key: Option<&str>, url: &str, audio_only: bool, -) -> Result, Error> { - let response = http_client - .post(format!("https://{domain}/api/json")) +) -> Result { + let mut request = http_client + .post(instance) .json(&Payload { url, - v_quality: "1080", - a_format: "best", - is_audio_only: audio_only, - tiktok_h265: true, + video_quality: "1080", + audio_format: "best", + download_mode: if audio_only { "audio" } else { "auto" }, + tiktok_full_audio: true, + twitter_gif: false, }) - .header(ACCEPT, "application/json") - .send() - .await - .map_err(Error::Network)? - .server_error() - .map_err(Error::Server)? - .json::() - .await - .map_err(Error::Network)?; - - match response.status { - Status::Stream | Status::Redirect => Ok(vec![response.url.unwrap()]), - Status::Picker => Ok(response.picker.unwrap().into_iter().map(|i| i.url).collect()), - Status::Success | Status::Error | Status::RateLimit => { - Err(Error::Cobalt(response.text.unwrap())) - } - } -} + .header(ACCEPT, "application/json"); -#[derive(Deserialize)] -pub struct Instance { - pub api_online: bool, - pub services: HashMap, - pub score: f32, - pub protocol: String, - pub api: String, -} + if let Some(api_key) = api_key { + request = request.header(AUTHORIZATION, format!("Api-Key {api_key}")); + } -#[derive(Clone, Copy, PartialEq, Eq, Hash, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum Service { - Youtube, - Rutube, - Tumblr, - Bilibili, - Pinterest, - Instagram, - Soundcloud, - YoutubeMusic, - Odnoklassniki, - Dailymotion, - Twitter, - Loom, - Vimeo, - Streamable, - Vk, - Tiktok, - Reddit, - TwitchClips, - YoutubeShorts, - Vine, - #[serde(other)] - Unknown, -} + let response = + request.send().await.map_err(Error::Network)?.server_error().map_err(Error::Server)?; -impl Service { - pub const fn name(self) -> Option<&'static str> { - match self { - Self::Youtube => Some("YouTube"), - Self::Rutube => Some("RUTUBE"), - Self::Tumblr => Some("Tumblr"), - Self::Bilibili => Some("BiliBili"), - Self::Pinterest => Some("Pinterest"), - Self::Instagram => Some("Instagram"), - Self::Soundcloud => Some("SoundCloud"), - Self::YoutubeMusic => Some("YouTube Music"), - Self::Odnoklassniki => Some("Odnoklassniki"), - Self::Dailymotion => Some("Dailymotion"), - Self::Twitter => Some("Twitter"), - Self::Loom => Some("Loom"), - Self::Vimeo => Some("Vimeo"), - Self::Streamable => Some("Streamable"), - Self::Vk => Some("VK"), - Self::Tiktok => Some("TikTok"), - Self::Reddit => Some("Reddit"), - Self::TwitchClips => Some("Twitch (Clips)"), - Self::YoutubeShorts => Some("YouTube (Shorts)"), - Self::Vine => Some("Vine"), - Self::Unknown => None, - } - } + response.json::().await.map_err(Error::Network) } -pub async fn instances(http_client: reqwest::Client) -> Result, CommandError> { - let response = http_client - .get("https://instances.hyper.lol/instances.json") +pub async fn get_error_localization( + http_client: &reqwest::Client, +) -> reqwest::Result> { + let request = http_client + .get(concat!( + "https://raw.githubusercontent.com", + "/imputnet/cobalt/refs/heads/main/web/i18n/en/error.json" + )) .send() - .await? - .server_error()?; + .await?; - Ok(response.json().await?) + request.json().await } diff --git a/src/commands.rs b/src/commands.rs index 52ab82e..e659dd9 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -7,6 +7,7 @@ use crate::bot::TdError; use crate::utilities::api_utils::ServerError; use crate::utilities::command_context::CommandContext; use crate::utilities::convert_argument::ConversionError; +use crate::utilities::file_download::DownloadError; use crate::utilities::rate_limit::RateLimiter; pub mod autocomplete; @@ -66,6 +67,7 @@ pub enum CommandError { Telegram(TdError), Server(StatusCode), Reqwest(reqwest::Error), + Download(DownloadError), } impl From for CommandError { @@ -114,3 +116,9 @@ impl From for CommandError { Self::Reqwest(value) } } + +impl From for CommandError { + fn from(value: DownloadError) -> Self { + Self::Download(value) + } +} diff --git a/src/commands/cobalt_download.rs b/src/commands/cobalt_download.rs index 77a9357..3c8bf6b 100644 --- a/src/commands/cobalt_download.rs +++ b/src/commands/cobalt_download.rs @@ -1,77 +1,50 @@ -use std::borrow::Cow; +use std::env; +use std::path::Path; use async_trait::async_trait; -use rand::seq::IteratorRandom; +use serde::Deserialize; use tdlib::enums::{InputFile, InputMessageContent, InputMessageReplyTo, Messages}; use tdlib::functions; use tdlib::types::{ - InputFileLocal, InputMessageAudio, InputMessageDocument, InputMessageReplyToMessage, - InputMessageVideo, + InputFileLocal, InputFileRemote, InputMessageAudio, InputMessageDocument, + InputMessageReplyToMessage, InputMessageVideo, InputThumbnail, }; -use super::{CommandError, CommandResult, CommandTrait}; -use crate::apis::cobalt::{self, Error, Instance, Service}; +use super::{CommandResult, CommandTrait}; +use crate::apis::cobalt::{self, Error, Response}; use crate::utilities::command_context::CommandContext; use crate::utilities::convert_argument::{ConvertArgument, StringGreedyOrReply}; -use crate::utilities::file_download::{DownloadError, NetworkFile}; +use crate::utilities::file_download::NetworkFile; use crate::utilities::message_entities::{self, ToEntity}; use crate::utilities::telegram_utils; -const MAIN_INSTANCE: &str = "api.cobalt.tools"; - -const SERVICE_URLS: &[(Service, &[&str])] = &[ - (Service::Youtube, &["youtu.be/", "youtube.com/watch?", "www.youtube.com/watch?"]), - (Service::Rutube, &["rutube.ru/video/"]), - (Service::Tumblr, &["www.tumblr.com/", "tumblr.com/"]), - (Service::Bilibili, &["www.bilibili.com/video/", "bilibili.com/video/"]), - (Service::Pinterest, &["pinterest.com/pin/"]), - ( - Service::Instagram, - &[ - "www.instagram.com/p/", - "instagram.com/p/", - "www.instagram.com/reels/", - "instagram.com/reels/", - "www.instagram.com/reel/", - "instagram.com/reel/", - ], - ), - (Service::Soundcloud, &["soundcloud.com/"]), - (Service::YoutubeMusic, &["music.youtube.com/watch?"]), - (Service::Odnoklassniki, &["ok.ru/"]), - (Service::Dailymotion, &["www.dailymotion.com/video/", "dailymotion.com/video/"]), - (Service::Twitter, &["x.com/", "twitter.com/"]), - (Service::Loom, &["www.loom.com/share/", "loom.com/share/"]), - (Service::Vimeo, &["vimeo.com/"]), - (Service::Streamable, &["streamable.com/"]), - (Service::Vk, &["vk.com/video-", "vk.com/clip-"]), - (Service::Tiktok, &["www.tiktok.com/", "tiktok.com/", "vm.tiktok.com/"]), - (Service::Reddit, &["www.reddit.com/", "reddit.com/"]), - (Service::TwitchClips, &["clips.twitch.tv/"]), - (Service::YoutubeShorts, &["www.youtube.com/shorts/", "youtube.com/shorts/"]), - (Service::Vine, &["vine.co/v/"]), -]; +#[derive(Deserialize)] +struct CobaltInstance<'a> { + name: &'a str, + url: &'a str, + api_key: Option<&'a str>, +} pub struct CobaltDownload { - audio_only: bool, command_names: &'static [&'static str], description: &'static str, + audio_only: bool, } impl CobaltDownload { pub const fn auto() -> Self { Self { - audio_only: false, command_names: &["cobalt_download", "cobalt", "download", "dl"], description: "download online media using ≫ cobalt", + audio_only: false, } } pub const fn audio() -> Self { Self { - audio_only: true, command_names: &["cobalt_download_audio", "cobalt_audio", "download_audio", "dla"], description: "download audio using ≫ cobalt", + audio_only: true, } } } @@ -91,227 +64,276 @@ impl CommandTrait for CobaltDownload { ctx.send_typing().await?; - let (instance, mut urls) = - get_urls(ctx.bot_state.http_client.clone(), &media_url, self.audio_only).await?; - - urls.truncate(10); - - let status_msg = ctx - .bot_state - .message_queue - .wait_for_message( - ctx.reply_formatted_text(message_entities::formatted_text(vec![ - "downloading from ".text(), - instance.as_ref().code(), - "…".text(), - ])) - .await? - .id, - ) - .await?; - - let mut files = Vec::with_capacity(urls.len()); - - for url in urls { - match NetworkFile::download(ctx.bot_state.http_client.clone(), &url, ctx.client_id) - .await - { - Ok(file) => files.push(file), - Err(err) => match err { - DownloadError::RequestError(err) => { - log::warn!("cobalt download failed: {err}"); - Err(format!("≫ cobalt download failed: {}", err.without_url()))?; - } - DownloadError::FilesystemError => { - Err("failed to save the file to the hard drive.")?; + let (instance, result) = + match get_result(&ctx.bot_state.http_client, &media_url, self.audio_only).await { + Ok(result) => result, + Err(err) => { + return match err { + Error::Server(error) => Err(error.into()), + Error::Network(error) => Err(error.into()), } - DownloadError::InvalidResponse => { - Err("got invalid HTTP response while downloading the file")?; - } - }, - } - } - - ctx.edit_message(status_msg.id, "uploading…".into()).await?; + } + }; - Box::pin(send_files(ctx, &files)).await?; - - ctx.delete_message(status_msg.id).await.ok(); - - for file in files { - file.close().unwrap(); - } + Box::pin(send_files(ctx, &instance, result)).await?; Ok(()) } } -async fn get_urls( - http_client: reqwest::Client, +async fn get_result( + http_client: &reqwest::Client, url: &str, audio_only: bool, -) -> Result<(Cow, Vec), CommandError> { - match cobalt::query(http_client.clone(), MAIN_INSTANCE, url, audio_only).await { - Ok(urls) => Ok((Cow::Borrowed(MAIN_INSTANCE), urls)), - Err(err) => { - let mut instances = cobalt::instances(http_client.clone()).await?; - - instances.retain(|instance| { - instance.api_online && instance.protocol == "https" && instance.api != MAIN_INSTANCE - }); - - let service = filter_instances_for_url(url, &mut instances); - - let instance = - instances.into_iter().choose(&mut rand::thread_rng()).ok_or_else(|| { - service.and_then(Service::name).map_or_else( - || { - CommandError::Custom( - "no fully healthy ≫ cobalt instances are online.".into(), - ) - }, - |service_name| { - CommandError::Custom(format!( - "none of the online ≫ cobalt instances support {service_name}." - )) - }, - ) - })?; - - if let Ok(urls) = cobalt::query(http_client, &instance.api, url, audio_only).await { - Ok((Cow::Owned(instance.api), urls)) - } else { - Err(match err { - Error::Cobalt(err) => err.into(), - Error::Server(err) => err.into(), - Error::Network(err) => err.into(), - }) +) -> Result<(String, Response), cobalt::Error> { + let instances = env::var("COBALT_INSTANCES").unwrap(); + let instances = serde_json::from_str::>(&instances).unwrap(); + + let mut error = None; + + for instance in instances { + match cobalt::query(http_client, instance.url, instance.api_key, url, audio_only).await { + Ok(result) => return Ok((instance.name.into(), result)), + Err(err) => { + if error.is_none() { + error = Some(err); + } } } } -} -fn filter_instances_for_url(url: &str, instances: &mut Vec) -> Option { - let url = - url.strip_prefix("https://").unwrap_or_else(|| url.strip_prefix("http://").unwrap_or(url)); - - let service = SERVICE_URLS - .iter() - .find(|(_, service_urls)| { - service_urls.iter().any(|service_url| url.starts_with(service_url)) - }) - .map(|service| service.0); - - if let Some(service) = service { - instances.retain(|instance| instance.services.get(&service).copied().unwrap_or_default()); - } else { - #[allow(clippy::float_cmp)] - instances.retain(|instance| instance.score == 100.); - }; - - service + Err(error.unwrap()) } -fn get_message_content(file: &NetworkFile) -> InputMessageContent { - let input_file = - InputFile::Local(InputFileLocal { path: file.file_path.to_str().unwrap().into() }); +#[expect(clippy::too_many_lines, clippy::large_stack_frames)] +async fn send_files(ctx: &CommandContext, instance: &str, result: Response) -> CommandResult { + match result { + Response::Redirect(file) | Response::Tunnel(file) => { + let status_msg = ctx + .bot_state + .message_queue + .wait_for_message( + ctx.reply_formatted_text(message_entities::formatted_text(vec![ + "downloading from ".text(), + instance.code(), + "…".text(), + ])) + .await? + .id, + ) + .await?; - if file.content_type.as_ref().is_some_and(|content_type| content_type == "video/mp4") - || file.file_path.extension().is_some_and(|extension| extension.eq_ignore_ascii_case("mp4")) - { - InputMessageContent::InputMessageVideo(InputMessageVideo { - video: input_file, - thumbnail: None, - added_sticker_file_ids: Vec::new(), - duration: 0, - width: 0, - height: 0, - supports_streaming: true, - caption: None, - show_caption_above_media: false, - self_destruct_type: None, - has_spoiler: false, - }) - } else if file - .content_type - .as_ref() - .is_some_and(|content_type| ["audio/mpeg", "audio/webm"].contains(&content_type.as_str())) - || file.file_path.extension().is_some_and(|extension| { - extension.eq_ignore_ascii_case("mp3") || extension.eq_ignore_ascii_case("opus") - }) - { - InputMessageContent::InputMessageAudio(InputMessageAudio { - audio: input_file, - album_cover_thumbnail: None, - duration: 0, - title: String::new(), - performer: String::new(), - caption: None, - }) - } else { - InputMessageContent::InputMessageDocument(InputMessageDocument { - document: input_file, - thumbnail: None, - disable_content_type_detection: false, - caption: None, - }) - } -} + let network_file = NetworkFile::download( + &ctx.bot_state.http_client, + &file.url, + Some(file.filename.clone()), + ctx.client_id, + ) + .await?; -async fn send_files(ctx: &CommandContext, files: &[NetworkFile]) -> CommandResult { - if let [file] = files { - ctx.bot_state - .message_queue - .wait_for_message( - ctx.reply_custom( - get_message_content(file), - Some(telegram_utils::donate_markup("≫ cobalt", "https://boosty.to/wukko")), + ctx.edit_message(status_msg.id, "uploading…".into()).await?; + + ctx.bot_state + .message_queue + .wait_for_message( + ctx.reply_custom( + get_message_content(&file.filename, &network_file), + Some(telegram_utils::donate_markup( + "≫ cobalt", + "https://cobalt.tools/donate", + )), + ) + .await? + .id, ) - .await? - .id, + .await?; + + ctx.delete_message(status_msg.id).await.ok(); + + network_file.close().unwrap(); + } + Response::Picker(picker) => { + let status_msg = ctx + .bot_state + .message_queue + .wait_for_message( + ctx.reply_formatted_text(message_entities::formatted_text(vec![ + "downloading from ".text(), + instance.code(), + "…".text(), + ])) + .await? + .id, + ) + .await?; + + let mut picker_items = picker.picker; + picker_items.truncate(10); + let mut files = Vec::with_capacity(picker_items.len()); + + for item in &picker_items { + files.push( + NetworkFile::download( + &ctx.bot_state.http_client, + &item.url, + None, + ctx.client_id, + ) + .await?, + ); + } + + let audio_file = if let Some(url) = picker.audio { + Some( + NetworkFile::download( + &ctx.bot_state.http_client, + &url, + picker.audio_filename, + ctx.client_id, + ) + .await?, + ) + } else { + None + }; + + ctx.edit_message(status_msg.id, "uploading…".into()).await?; + + let messages = files + .iter() + .zip(picker_items) + .map(|(file, item)| { + InputMessageContent::InputMessageDocument(InputMessageDocument { + document: InputFile::Local(InputFileLocal { + path: file.file_path.to_str().unwrap().into(), + }), + thumbnail: item.thumb.map(|thumbnail| InputThumbnail { + thumbnail: InputFile::Remote(InputFileRemote { id: thumbnail }), + width: 0, + height: 0, + }), + disable_content_type_detection: true, + caption: None, + }) + }) + .collect::>(); + + let Messages::Messages(messages) = functions::send_message_album( + ctx.message.chat_id, + ctx.message.message_thread_id, + Some(InputMessageReplyTo::Message(InputMessageReplyToMessage { + message_id: ctx.message.id, + ..Default::default() + })), + None, + messages, + ctx.client_id, ) .await?; - return Ok(()); + for result in ctx + .bot_state + .message_queue + .wait_for_messages( + &messages + .messages + .into_iter() + .flatten() + .map(|message| message.id) + .collect::>(), + ) + .await + { + result?; + } + + for file in files { + file.close().unwrap(); + } + + if let Some(audio_file) = audio_file { + ctx.bot_state + .message_queue + .wait_for_message( + ctx.reply_custom( + InputMessageContent::InputMessageAudio(InputMessageAudio { + audio: InputFile::Local(InputFileLocal { + path: audio_file.file_path.to_str().unwrap().into(), + }), + album_cover_thumbnail: None, + duration: 0, + title: String::new(), + performer: String::new(), + caption: None, + }), + None, + ) + .await? + .id, + ) + .await?; + + audio_file.close().unwrap(); + } + + ctx.delete_message(status_msg.id).await.ok(); + } + Response::Error(error) => { + let text = match cobalt::get_error_localization(&ctx.bot_state.http_client).await { + Ok(mut localization) => localization + .remove(&error.error.code["error.".len()..]) + .unwrap_or(error.error.code), + Err(err) => { + log::warn!("failed to get cobalt localization: {err}"); + error.error.code + } + }; + + return Err(text.into()); + } } - let messages = files - .iter() - .map(|file| { - InputMessageContent::InputMessageDocument(InputMessageDocument { - document: InputFile::Local(InputFileLocal { - path: file.file_path.to_str().unwrap().into(), - }), + Ok(()) +} + +fn get_message_content(filename: &str, file: &NetworkFile) -> InputMessageContent { + let input_file = + InputFile::Local(InputFileLocal { path: file.file_path.to_str().unwrap().into() }); + + if let Some(file_extension) = Path::new(filename).extension() { + if file_extension.eq_ignore_ascii_case("mp4") { + return InputMessageContent::InputMessageVideo(InputMessageVideo { + video: input_file, thumbnail: None, - disable_content_type_detection: true, + added_sticker_file_ids: Vec::new(), + duration: 0, + width: 0, + height: 0, + supports_streaming: true, + caption: None, + show_caption_above_media: false, + self_destruct_type: None, + has_spoiler: false, + }); + } else if ["mp3", "opus", "weba"] + .into_iter() + .any(|extension| file_extension.eq_ignore_ascii_case(extension)) + { + return InputMessageContent::InputMessageAudio(InputMessageAudio { + audio: input_file, + album_cover_thumbnail: None, + duration: 0, + title: String::new(), + performer: String::new(), caption: None, - }) - }) - .collect::>(); - - let Messages::Messages(messages) = functions::send_message_album( - ctx.message.chat_id, - ctx.message.message_thread_id, - Some(InputMessageReplyTo::Message(InputMessageReplyToMessage { - message_id: ctx.message.id, - ..Default::default() - })), - None, - messages, - ctx.client_id, - ) - .await?; - - for result in ctx - .bot_state - .message_queue - .wait_for_messages( - &messages.messages.into_iter().flatten().map(|message| message.id).collect::>(), - ) - .await - { - result?; + }); + } } - Ok(()) + InputMessageContent::InputMessageDocument(InputMessageDocument { + document: input_file, + thumbnail: None, + disable_content_type_detection: true, + caption: None, + }) } diff --git a/src/commands/delete.rs b/src/commands/delete.rs index fd833fb..1f053a0 100644 --- a/src/commands/delete.rs +++ b/src/commands/delete.rs @@ -5,7 +5,7 @@ use tdlib::types::MessageReplyToMessage; use super::{CommandResult, CommandTrait}; use crate::utilities::command_context::CommandContext; -#[allow(clippy::unreadable_literal)] +#[expect(clippy::unreadable_literal)] const OWNER_ID: i64 = 807128293; pub struct Delete; diff --git a/src/commands/makersuite.rs b/src/commands/makersuite.rs index 8e121f6..998b21f 100644 --- a/src/commands/makersuite.rs +++ b/src/commands/makersuite.rs @@ -60,7 +60,7 @@ impl CommandTrait for GoogleGemini { RateLimiter::new(3, 45) } - #[allow(clippy::too_many_lines)] + #[expect(clippy::too_many_lines)] async fn execute(&self, ctx: &CommandContext, arguments: String) -> CommandResult { let prompt = Option::::convert(ctx, &arguments).await?.0; ctx.send_typing().await?; @@ -96,11 +96,7 @@ impl CommandTrait for GoogleGemini { parts.push(Part::FileData(FileData { file_uri: file.uri })); - ( - self.model, - Some([Part::Text(Cow::Borrowed(SYSTEM_INSTRUCTION))].as_slice()), - parts, - ) + (self.model, Some([Part::Text(Cow::Borrowed(SYSTEM_INSTRUCTION))].as_slice()), parts) } else { let mut parts = vec![Part::Text(Cow::Borrowed(SYSTEM_INSTRUCTION))]; diff --git a/src/commands/mevo.rs b/src/commands/mevo.rs index c5dadad..ef6b94f 100644 --- a/src/commands/mevo.rs +++ b/src/commands/mevo.rs @@ -18,7 +18,7 @@ impl CommandTrait for Mevo { let stats = urbansharing::system_stats(ctx.bot_state.http_client.clone(), "inurba-gdansk").await?; - #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + #[expect(clippy::cast_possible_truncation, clippy::cast_sign_loss)] ctx.reply_formatted_text(message_entities::formatted_text(vec![ "jadący teraz: ".text(), stats.system_active_trip_count.count.to_string().bold(), diff --git a/src/commands/sex.rs b/src/commands/sex.rs index 55875a0..72ad5e6 100644 --- a/src/commands/sex.rs +++ b/src/commands/sex.rs @@ -21,7 +21,7 @@ impl CommandTrait for Sex { async fn execute(&self, ctx: &CommandContext, arguments: String) -> CommandResult { let argument = Option::::convert(ctx, &arguments).await?.0; - let question_mark = argument.map_or(false, |argument| argument.0.starts_with('?')); + let question_mark = argument.is_some_and(|argument| argument.0.starts_with('?')); ctx.reply_custom( InputMessageContent::InputMessageSticker(InputMessageSticker { diff --git a/src/commands/stablehorde.rs b/src/commands/stablehorde.rs index adfd35b..21e95c2 100644 --- a/src/commands/stablehorde.rs +++ b/src/commands/stablehorde.rs @@ -218,7 +218,7 @@ async fn wait_for_generation( if last_status.as_ref() != Some(&status) { // the message doesn't exist yet or was edited more than 12 seconds ago - if last_edit.map_or(true, |last_edit| last_edit.elapsed() >= Duration::from_secs(12)) { + if last_edit.is_none_or(|last_edit| last_edit.elapsed() >= Duration::from_secs(12)) { let formatted_text = format_status_text(&status, escaped_prompt, show_volunteer_notice); status_msg_id = Some(match status_msg_id { diff --git a/src/utilities/api_utils.rs b/src/utilities/api_utils.rs index 54e21df..bf13dac 100644 --- a/src/utilities/api_utils.rs +++ b/src/utilities/api_utils.rs @@ -13,8 +13,8 @@ pub trait DetectServerError { impl DetectServerError for Response { fn server_error(self) -> Result { if self.status().is_server_error() - && self.headers().get(CONTENT_TYPE).map_or(false, |header| { - header.to_str().map_or(false, |header| header.starts_with("text/html")) + && self.headers().get(CONTENT_TYPE).is_some_and(|header| { + header.to_str().is_ok_and(|header| header.starts_with("text/html")) }) { return Err(ServerError(self.status())); diff --git a/src/utilities/command_dispatcher.rs b/src/utilities/command_dispatcher.rs index 17b401b..4fc8f68 100644 --- a/src/utilities/command_dispatcher.rs +++ b/src/utilities/command_dispatcher.rs @@ -4,6 +4,7 @@ use std::time::{Duration, Instant}; use super::command_context::CommandContext; use super::command_manager::CommandInstance; +use super::file_download::DownloadError; use crate::bot::TdResult; use crate::commands::CommandError; use crate::utilities::text_utils; @@ -90,6 +91,7 @@ async fn report_rate_limit(context: &CommandContext, cooldown: u64) -> TdResult< Ok(()) } +#[expect(clippy::large_stack_frames)] async fn report_command_error( command: Arc, context: &CommandContext, @@ -119,6 +121,15 @@ async fn report_command_error( log::error!("HTTP error in the {command} command: {text}"); context.reply(text).await? } + CommandError::Download(err) => match err { + DownloadError::RequestError(err) => { + log::warn!("cobalt download failed: {err}"); + context.reply(format!("≫ cobalt download failed: {}", err.without_url())).await? + } + DownloadError::FilesystemError => { + context.reply("failed to save the file to the hard drive.".into()).await? + } + }, }; Ok(()) diff --git a/src/utilities/file_download.rs b/src/utilities/file_download.rs index f5b191e..cc8cd56 100644 --- a/src/utilities/file_download.rs +++ b/src/utilities/file_download.rs @@ -4,28 +4,28 @@ use std::path::PathBuf; use std::time::Duration; use futures_util::StreamExt; -use reqwest::header::{CONTENT_DISPOSITION, CONTENT_TYPE}; +use reqwest::header::CONTENT_DISPOSITION; use tdlib::{enums, functions}; use tempfile::TempDir; pub const MEBIBYTE: i64 = 1024 * 1024; +#[derive(Debug)] pub enum DownloadError { RequestError(reqwest::Error), FilesystemError, - InvalidResponse, } pub struct NetworkFile { temp_dir: TempDir, pub file_path: PathBuf, - pub content_type: Option, } impl NetworkFile { pub async fn download( - http_client: reqwest::Client, + http_client: &reqwest::Client, url: &str, + filename: Option, client_id: i32, ) -> Result { let response = http_client @@ -37,26 +37,23 @@ impl NetworkFile { .error_for_status() .map_err(DownloadError::RequestError)?; - let content_type = match response.headers().get(CONTENT_TYPE) { - Some(header) => { - Some(header.to_str().map_err(|_| DownloadError::InvalidResponse)?.to_string()) - } - None => None, - }; - - let enums::Text::Text(filename) = - functions::clean_file_name(get_filename(&response).to_string(), client_id) - .await - .unwrap(); + let enums::Text::Text(filename) = functions::clean_file_name( + filename.unwrap_or_else(|| get_filename(&response).to_string()), + client_id, + ) + .await + .unwrap(); let temp_dir = TempDir::new().map_err(|_| DownloadError::FilesystemError)?; let file_path = temp_dir.path().join(filename.text); + let mut file = BufWriter::with_capacity( 4 * 1024 * 1024, File::create(&file_path).map_err(|_| DownloadError::FilesystemError)?, ); let mut stream = response.bytes_stream(); + while let Some(bytes) = stream.next().await { let bytes = bytes.map_err(DownloadError::RequestError)?; file.write_all(&bytes).map_err(|_| DownloadError::FilesystemError)?; @@ -64,7 +61,7 @@ impl NetworkFile { file.flush().map_err(|_| DownloadError::FilesystemError)?; - Ok(Self { temp_dir, file_path, content_type }) + Ok(Self { temp_dir, file_path }) } pub fn close(self) -> io::Result<()> { diff --git a/src/utilities/google_translate.rs b/src/utilities/google_translate.rs index cda7dcc..cff986c 100644 --- a/src/utilities/google_translate.rs +++ b/src/utilities/google_translate.rs @@ -170,7 +170,7 @@ impl ConvertArgument for Language { for prefix in [language_code, &language.to_ascii_lowercase()] { if lowercase.starts_with(prefix) { let rest = &arguments[prefix.len()..]; - if rest.chars().next().map_or(true, |char| char.is_ascii_whitespace()) { + if rest.chars().next().is_none_or(|char| char.is_ascii_whitespace()) { return Ok((Self(language_code), rest)); } } diff --git a/src/utilities/image_utils.rs b/src/utilities/image_utils.rs index d3da7a5..8cc8d2b 100644 --- a/src/utilities/image_utils.rs +++ b/src/utilities/image_utils.rs @@ -1,6 +1,11 @@ use image::{imageops, DynamicImage}; -#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss, clippy::cast_sign_loss)] // multiple lossy numeric conversions +#[expect( + clippy::cast_possible_truncation, + clippy::cast_precision_loss, + clippy::cast_sign_loss, + reason = "multiple lossy numeric conversions" +)] pub fn collage(images: Vec, image_size: (u32, u32), gap: u32) -> DynamicImage { let image_count_x = (images.len() as f32).sqrt().ceil() as u32; let image_count_y = (images.len() as f32 / image_count_x as f32).ceil() as u32; diff --git a/src/utilities/message_entities.rs b/src/utilities/message_entities.rs index ccb79b7..d5eba1e 100644 --- a/src/utilities/message_entities.rs +++ b/src/utilities/message_entities.rs @@ -55,7 +55,7 @@ impl<'a> ToEntity<'a> for str { } } -#[allow(dead_code)] +#[expect(dead_code)] pub trait ToEntityOwned<'a> { fn text_owned(self) -> Entity<'a>; fn bold_owned(self) -> Entity<'a>; diff --git a/src/utilities/parsed_command.rs b/src/utilities/parsed_command.rs index e07e39c..18ec951 100644 --- a/src/utilities/parsed_command.rs +++ b/src/utilities/parsed_command.rs @@ -14,7 +14,7 @@ impl ParsedCommand { .iter() .find(|e| e.r#type == TextEntityType::BotCommand && e.offset == 0)?; - #[allow(clippy::cast_sign_loss)] + #[expect(clippy::cast_sign_loss)] let command_name_range = { let start = entity.offset as usize + 1; start..entity.length as usize diff --git a/src/utilities/telegram_utils.rs b/src/utilities/telegram_utils.rs index b2eee78..664dd41 100644 --- a/src/utilities/telegram_utils.rs +++ b/src/utilities/telegram_utils.rs @@ -107,7 +107,7 @@ pub const fn get_message_text(content: &MessageContent) -> Option<&FormattedText Some(formatted_text) } -#[allow(clippy::too_many_lines)] // this code duplication is horrible +#[expect(clippy::too_many_lines, reason = "this code duplication is horrible")] pub async fn get_message_attachment( content: Cow<'_, MessageContent>, not_only_images: bool, diff --git a/src/utilities/text_utils.rs b/src/utilities/text_utils.rs index 378ac14..631bdee 100644 --- a/src/utilities/text_utils.rs +++ b/src/utilities/text_utils.rs @@ -36,9 +36,9 @@ pub fn progress_bar(current: u32, max: u32) -> String { return "[====================]".into(); } - #[allow(clippy::cast_precision_loss)] + #[expect(clippy::cast_precision_loss)] let step = max as f32 / 20.; - #[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss, clippy::cast_sign_loss)] + #[expect(clippy::cast_possible_truncation, clippy::cast_precision_loss, clippy::cast_sign_loss)] let char_count = (current as f32 / step) as usize; let mut progress = String::with_capacity(22);