mirror of
https://github.com/revanced/revanced-discord-bot.git
synced 2025-06-13 05:37:45 +02:00
feat(misc): embed code links
This commit is contained in:
@ -1,10 +1,16 @@
|
||||
use super::*;
|
||||
use crate::utils::autorespond::auto_respond;
|
||||
use crate::utils::code_embed::utils::handle_code_url;
|
||||
use crate::utils::media_channel::handle_media_channel;
|
||||
|
||||
pub async fn message_create(ctx: &serenity::Context, new_message: &serenity::Message) {
|
||||
let is_media_channel = handle_media_channel(ctx, new_message).await;
|
||||
if !is_media_channel {
|
||||
auto_respond(ctx, new_message).await;
|
||||
}
|
||||
|
||||
if is_media_channel {
|
||||
return;
|
||||
};
|
||||
|
||||
auto_respond(ctx, new_message).await;
|
||||
|
||||
handle_code_url(ctx, new_message).await;
|
||||
}
|
||||
|
25
src/utils/code_embed/macros.rs
Normal file
25
src/utils/code_embed/macros.rs
Normal file
@ -0,0 +1,25 @@
|
||||
macro_rules! assert_correct_domain {
|
||||
($url:expr, $expected:expr) => {{
|
||||
if let Some(domain) = $url.domain() {
|
||||
if domain != $expected {
|
||||
return Err(ParserError::WrongParserError(
|
||||
$expected.to_string(),
|
||||
domain.to_string(),
|
||||
));
|
||||
}
|
||||
} else {
|
||||
return Err(ParserError::Error("No domain found".to_string()));
|
||||
}
|
||||
}};
|
||||
}
|
||||
|
||||
macro_rules! parse_segment {
|
||||
($segments:expr, $segment:tt) => {
|
||||
$segments.next().ok_or(ParserError::ConversionError(format!(
|
||||
"Failed to parse {}",
|
||||
$segment.to_string()
|
||||
)))
|
||||
};
|
||||
}
|
||||
|
||||
pub(crate) use {assert_correct_domain, parse_segment};
|
5
src/utils/code_embed/mod.rs
Normal file
5
src/utils/code_embed/mod.rs
Normal file
@ -0,0 +1,5 @@
|
||||
pub mod macros;
|
||||
pub mod url_parser;
|
||||
pub mod utils;
|
||||
|
||||
use super::*;
|
205
src/utils/code_embed/url_parser.rs
Normal file
205
src/utils/code_embed/url_parser.rs
Normal file
@ -0,0 +1,205 @@
|
||||
use std::ffi::OsStr;
|
||||
use std::fmt;
|
||||
use std::path::Path;
|
||||
|
||||
use poise::async_trait;
|
||||
use reqwest::Url;
|
||||
|
||||
use super::macros::parse_segment;
|
||||
use crate::utils::code_embed::macros::assert_correct_domain;
|
||||
|
||||
/// A struct that represents a GitHub code URL.
|
||||
///
|
||||
/// **Note**: The domain of the url has to be github.com.
|
||||
pub struct GitHubCodeUrl {
|
||||
pub url: Url,
|
||||
}
|
||||
|
||||
/// A struct that holds details on code.
|
||||
#[derive(Default, std::fmt::Debug)]
|
||||
pub struct CodeUrl {
|
||||
pub raw_code_url: String,
|
||||
pub original_code_url: String,
|
||||
pub repo: String,
|
||||
pub user: String,
|
||||
pub branch_or_sha: String,
|
||||
pub relevant_lines: Option<(usize, usize)>,
|
||||
pub language: Option<String>,
|
||||
}
|
||||
|
||||
/// A struct that holds details on code and a code preview.
|
||||
#[derive(Default, std::fmt::Debug)]
|
||||
pub struct CodePreview {
|
||||
pub code: CodeUrl,
|
||||
pub preview: Option<String>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait CodeUrlParser {
|
||||
fn kind(&self) -> &'static str;
|
||||
async fn parse(&self) -> Result<CodePreview, ParserError>;
|
||||
fn parse_code_url(&self) -> Result<CodeUrl, ParserError>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl CodeUrlParser for GitHubCodeUrl {
|
||||
fn kind(&self) -> &'static str {
|
||||
"github.com"
|
||||
}
|
||||
|
||||
fn parse_code_url(&self) -> Result<CodeUrl, ParserError> {
|
||||
let mut segments = self
|
||||
.url
|
||||
.path_segments()
|
||||
.ok_or(ParserError::ConversionError(
|
||||
"Failed to convert path segments".to_string(),
|
||||
))?;
|
||||
|
||||
// parse the segments
|
||||
|
||||
let user = parse_segment!(segments, "user")?;
|
||||
let repo = parse_segment!(segments, "repo")?;
|
||||
let _blob_segment = parse_segment!(segments, "blob"); // GitHub specific segment
|
||||
let branch_or_sha = parse_segment!(segments, "branch or sha")?;
|
||||
|
||||
let mut path = String::new();
|
||||
while let Ok(segment) = parse_segment!(segments, "path") {
|
||||
if segment == "" {
|
||||
continue;
|
||||
}
|
||||
path.push('/');
|
||||
path.push_str(segment);
|
||||
}
|
||||
|
||||
let raw_url = format!(
|
||||
"https://raw.githubusercontent.com/{}/{}/{}{}",
|
||||
user, repo, branch_or_sha, path
|
||||
);
|
||||
|
||||
let mut code_url = CodeUrl {
|
||||
raw_code_url: raw_url,
|
||||
original_code_url: self.url.to_string(),
|
||||
repo: repo.to_string(),
|
||||
user: user.to_string(),
|
||||
branch_or_sha: branch_or_sha.to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
if let Some(fragment) = self.url.fragment() {
|
||||
let mut numbers = fragment
|
||||
.split('-')
|
||||
.map(|s| s.trim_matches('L'))
|
||||
.map(|s| s.parse::<usize>())
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|_| ParserError::InvalidFragment(fragment.to_string()))?;
|
||||
|
||||
if numbers.len() > 2 {
|
||||
return Err(ParserError::InvalidFragment(fragment.to_string()));
|
||||
}
|
||||
|
||||
let start = numbers.remove(0);
|
||||
let end = numbers.pop().unwrap_or_else(|| start);
|
||||
code_url.relevant_lines = Some((start, end));
|
||||
}
|
||||
|
||||
let mut segments = self.url.path_segments().unwrap();
|
||||
while let Some(segment) = segments.next_back() {
|
||||
if !segment.is_empty() {
|
||||
let extension = Path::new(segment)
|
||||
.extension()
|
||||
.and_then(OsStr::to_str)
|
||||
.map(str::to_string);
|
||||
code_url.language = extension;
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(code_url)
|
||||
}
|
||||
|
||||
async fn parse(&self) -> Result<CodePreview, ParserError> {
|
||||
assert_correct_domain!(self.url, self.kind());
|
||||
|
||||
let code_url = self.parse_code_url()?;
|
||||
|
||||
// TODO: If the code is huge, downloading could take long. If code_url.relevant_lines is Some, only download up to the relevant lines.
|
||||
let code = reqwest::get(&code_url.raw_code_url)
|
||||
.await
|
||||
.map_err(|_| ParserError::FailedToGetCode("Can't make a request".to_string()))?
|
||||
.text()
|
||||
.await
|
||||
.map_err(|_| ParserError::FailedToGetCode("Can't parse body".to_string()))?;
|
||||
|
||||
let preview = if let Some((start, end)) = code_url.relevant_lines.clone() {
|
||||
let lines = code.lines().collect::<Vec<_>>();
|
||||
let start = start - 1;
|
||||
let end = end - 1;
|
||||
|
||||
if start > end || start >= lines.len() || end >= lines.len() {
|
||||
return Err(ParserError::InvalidFragment(format!("{}-{}", start, end)));
|
||||
}
|
||||
|
||||
let mut code_block = String::new();
|
||||
|
||||
code_block.push_str("```");
|
||||
|
||||
if let Some(language) = code_url.language.clone() {
|
||||
code_block.push_str(&language);
|
||||
code_block.push('\n');
|
||||
}
|
||||
|
||||
code_block.push_str(&lines[start..=end].join("\n"));
|
||||
code_block.push_str("```");
|
||||
|
||||
Some(code_block)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let code_preview = CodePreview {
|
||||
code: code_url,
|
||||
preview,
|
||||
};
|
||||
|
||||
Ok(code_preview)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ParserError {
|
||||
Error(String),
|
||||
WrongParserError(String, String),
|
||||
ConversionError(String),
|
||||
InvalidFragment(String),
|
||||
FailedToGetCode(String),
|
||||
}
|
||||
|
||||
impl std::error::Error for ParserError {}
|
||||
|
||||
impl fmt::Display for ParserError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
ParserError::Error(e) => {
|
||||
write!(f, "Error: {}", e)
|
||||
},
|
||||
ParserError::WrongParserError(expected, got) => {
|
||||
write!(f, "Expected parser {}, got {}", expected, got)
|
||||
},
|
||||
ParserError::ConversionError(conversion_error) => {
|
||||
write!(f, "Conversion error: {}", conversion_error)
|
||||
},
|
||||
ParserError::InvalidFragment(fragment) => {
|
||||
write!(f, "Invalid fragment: {}", fragment)
|
||||
},
|
||||
ParserError::FailedToGetCode(error) => {
|
||||
write!(f, "Failed to get code: {}", error)
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Box<dyn std::error::Error>> for ParserError {
|
||||
fn from(e: Box<dyn std::error::Error>) -> Self {
|
||||
Self::Error(e.to_string())
|
||||
}
|
||||
}
|
128
src/utils/code_embed/utils.rs
Normal file
128
src/utils/code_embed/utils.rs
Normal file
@ -0,0 +1,128 @@
|
||||
use chrono::Utc;
|
||||
use poise::serenity_prelude::{ButtonStyle, ReactionType};
|
||||
use reqwest::Url;
|
||||
use tracing::{debug, error, trace};
|
||||
|
||||
use super::*;
|
||||
use crate::utils::bot::get_data_lock;
|
||||
use crate::utils::code_embed::url_parser::{CodePreview, CodeUrlParser, GitHubCodeUrl};
|
||||
|
||||
pub async fn handle_code_url(ctx: &serenity::Context, new_message: &serenity::Message) {
|
||||
let data_lock = get_data_lock(ctx).await;
|
||||
let configuration = &data_lock.read().await.configuration;
|
||||
|
||||
let mut urls: Vec<Url> = Vec::new();
|
||||
|
||||
fn get_all_http_urls(string: &str, out: &mut Vec<Url>) {
|
||||
fn get_http_url(slice: &str) -> Option<(&str, usize)> {
|
||||
if let Some(start) = slice.find("http") {
|
||||
debug!("HTTP url start: {}", start);
|
||||
|
||||
let new_slice = &slice[start..];
|
||||
|
||||
if let Some(end) = new_slice
|
||||
.find(" ")
|
||||
.or(new_slice.find("\n"))
|
||||
.and_then(|slice_end| Some(start + slice_end))
|
||||
{
|
||||
debug!("HTTP url end: {}", end);
|
||||
|
||||
let url = &slice[start..end];
|
||||
return Some((url, end));
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
if let Some((url, next_start_index)) = get_http_url(string) {
|
||||
if let Ok(url) = Url::parse(url) {
|
||||
out.push(url);
|
||||
} else {
|
||||
error!("Failed to parse url: {}", url);
|
||||
}
|
||||
|
||||
get_all_http_urls(&string[next_start_index..], out);
|
||||
}
|
||||
}
|
||||
get_all_http_urls(&new_message.content, &mut urls);
|
||||
|
||||
let mut code_previews: Vec<CodePreview> = Vec::new();
|
||||
|
||||
for url in urls {
|
||||
// TODO: Add support for other domains by using the provider pattern
|
||||
let code_url = GitHubCodeUrl {
|
||||
url: url.clone(),
|
||||
};
|
||||
|
||||
match code_url.parse().await {
|
||||
Err(e) => error!("Failed to parse url: {} ({:?})", url, e),
|
||||
Ok(code_preview) => code_previews.push(code_preview),
|
||||
}
|
||||
}
|
||||
|
||||
if code_previews.is_empty() {
|
||||
return; // Nothing to do
|
||||
}
|
||||
|
||||
if let Err(err) = new_message
|
||||
.channel_id
|
||||
.send_message(&ctx.http, |m| {
|
||||
let mut message = m;
|
||||
|
||||
for code_preview in code_previews {
|
||||
message = message.add_embed(|e| {
|
||||
let mut e = e
|
||||
.title("Code preview")
|
||||
.url(code_preview.code.original_code_url)
|
||||
.color(configuration.general.embed_color)
|
||||
.field(
|
||||
"Raw link",
|
||||
format!("[Click here]({})", code_preview.code.raw_code_url),
|
||||
true,
|
||||
)
|
||||
.field("Branch/Sha", code_preview.code.branch_or_sha, true);
|
||||
|
||||
if let Some(preview) = code_preview.preview {
|
||||
e = e.field("Preview", preview, false)
|
||||
}
|
||||
|
||||
let guild = &new_message.guild(&ctx.cache).unwrap();
|
||||
if let Some(url) = &guild.icon_url() {
|
||||
e = e.footer(|f| {
|
||||
f.icon_url(url).text(format!(
|
||||
"{} • {}",
|
||||
guild.name,
|
||||
Utc::today().format("%Y/%m/%d")
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
e.field(
|
||||
format!("Original message by {}", new_message.author.tag()),
|
||||
new_message.content.clone(),
|
||||
false,
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
message.content(
|
||||
new_message
|
||||
.mentions
|
||||
.iter()
|
||||
.map(|m| format!("<@{}>", m.id))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" "),
|
||||
)
|
||||
})
|
||||
.await
|
||||
{
|
||||
error!(
|
||||
"Failed to reply to the message from {}. Error: {:?}",
|
||||
new_message.author.tag(),
|
||||
err
|
||||
);
|
||||
}
|
||||
|
||||
new_message.delete(&ctx.http).await;
|
||||
}
|
@ -2,6 +2,7 @@ use poise::serenity_prelude::{self as serenity, Member, RoleId};
|
||||
|
||||
pub mod autorespond;
|
||||
pub mod bot;
|
||||
pub mod code_embed;
|
||||
pub mod decancer;
|
||||
pub mod embed;
|
||||
pub mod macros;
|
||||
|
Reference in New Issue
Block a user