diff --git a/Cargo.lock b/Cargo.lock index 9914e13..b4393a3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -72,11 +72,13 @@ dependencies = [ "anyhow", "dotenvy", "once_cell", + "regex", "sentry", "serenity", "tokio", "tracing", "tracing-subscriber", + "typed-builder", ] [[package]] @@ -1683,6 +1685,26 @@ dependencies = [ "webpki", ] +[[package]] +name = "typed-builder" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6605aaa56cce0947127ffa0675a8a1b181f87773364390174de60a86ab9085f1" +dependencies = [ + "typed-builder-macro", +] + +[[package]] +name = "typed-builder-macro" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a6a6884f6a890a012adcc20ce498f30ebdc70fb1ea242c333cc5f435b0b3871" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "typemap_rev" version = "0.1.5" diff --git a/Cargo.toml b/Cargo.toml index 136a744..9d6cb20 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,9 +12,11 @@ enable_sentry = [] anyhow = { version = "1.0" } dotenvy = { version = "0.15" } once_cell = { version = "1.18" } +regex = { version = "1.9" } sentry = { version = "0.31" } tracing = { version = "0.1" } tracing-subscriber = { version = "0.3" } +typed-builder = { version = "0.16" } [dependencies.serenity] version = "0.11" diff --git a/README.md b/README.md index f56353b..94d4bfb 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,10 @@ A citation message Discord bot ### Todo -- [ ] 添付ファイルのサポート +- [ ] 添付ファイルのサポート ( [#5](https://github.com/m1sk9/babyrite/issues/5) ) +- [ ] チャンネルリストのキャッシュ ( [#6](https://github.com/m1sk9/babyrite/issues/6) ) + +[babyrite v1.0.0 リリースのためのマイルストーンはこちら](https://github.com/m1sk9/babyrite2/milestone/1) ## Installation diff --git a/src/adapters/embed.rs b/src/adapters/embed.rs new file mode 100644 index 0000000..9bdcb67 --- /dev/null +++ b/src/adapters/embed.rs @@ -0,0 +1,73 @@ +use crate::model::embed::EmbedMessage; +use serenity::{builder::CreateEmbed, utils::Colour}; + +pub fn convert_embed( + EmbedMessage { + title, + author, + description, + fields, + footer, + color, + url, + thumbnail, + timestamp, + }: EmbedMessage, +) -> CreateEmbed { + let mut create_embed = CreateEmbed::default(); + + if let Some(title) = title { + create_embed.title(title); + } + + if let Some(author) = author { + create_embed.author(|a| { + a.name(author.name); + if let Some(url) = author.url { + a.url(url); + } + if let Some(icon_url) = author.icon_url { + a.icon_url(icon_url); + }; + a + }); + } + + if let Some(description) = description { + create_embed.description(description); + } + + if let Some(fields) = fields { + fields.into_iter().for_each(|x| { + create_embed.field(x.name, x.value, x.inline.unwrap_or(false)); + }) + } + + if let Some(footer) = footer { + create_embed.footer(|f| { + f.text(footer.text); + if let Some(icon_url) = footer.icon_url { + f.icon_url(icon_url); + }; + f + }); + } + + if let Some(color) = color { + create_embed.color(Colour(color)); + } + + if let Some(url) = url { + create_embed.url(url); + } + + if let Some(thumbnail) = thumbnail { + create_embed.thumbnail(thumbnail.url); + } + + if let Some(timestamp) = timestamp { + create_embed.timestamp(timestamp); + } + + create_embed +} diff --git a/src/adapters/message.rs b/src/adapters/message.rs new file mode 100644 index 0000000..6d52b5f --- /dev/null +++ b/src/adapters/message.rs @@ -0,0 +1,150 @@ +use std::sync::Arc; + +use anyhow::{Context, Error, Ok}; +use serenity::{http::Http, model::channel::Message}; + +use crate::adapters::embed::convert_embed; +use crate::model::{ + embed::{EmbedMessage, EmbedMessageAuthor, EmbedMessageFooter}, + id::DiscordIds, + message::CitationMessage, +}; + +const PERSONAL_COLOR: u32 = 0xb586f7; +const WARN_COLOR: u32 = 0xfff700; +const ERROR_COLOR: u32 = 0xFF0012; + +pub async fn send_citation_embed( + ids: DiscordIds, + http: &Arc, + target_message: &Message, +) -> anyhow::Result<()> { + let citation_message = get_citation_message(ids, &http).await?; + + let embed_footer = EmbedMessageFooter::builder() + .text(citation_message.channel_name) + .build(); + let embed_author = EmbedMessageAuthor::builder() + .name(citation_message.author_name) + .icon_url(citation_message.author_avatar_url) + .build(); + let embed_object = EmbedMessage::builder() + .description(Some(citation_message.content)) + .footer(Some(embed_footer)) + .author(Some(embed_author)) + .timestamp(Some(citation_message.create_at)) + .color(Some(PERSONAL_COLOR)) + .build(); + + target_message + .channel_id + .send_message(http, |m| { + m.reference_message(target_message); + m.allowed_mentions(|mention| { + mention.replied_user(true); + mention + }); + m.set_embed(convert_embed(embed_object)) + }) + .await + .context("引用メッセージの送信に失敗しました")?; + + Ok(()) +} + +async fn get_citation_message( + DiscordIds { + guild_id, + channel_id, + message_id, + }: DiscordIds, + http: &Arc, +) -> anyhow::Result { + let guild_channels = guild_id + .channels(&http) + .await + .context("チャンネルリストの取得に失敗しました")?; + + match guild_channels.get(&channel_id) { + Some(channel) => { + if channel.is_nsfw() || !channel.is_text_based() { + return Err(anyhow::anyhow!("[ID: {}]のチャンネルはNSFWに指定されているか, テキストベースのチャンネルではありません", channel_id)); + } + + let message = channel + .message(http, message_id) + .await + .context("メッセージの取得に失敗しました")?; + + let author = message.clone().author; + // アバター画像が存在していなくても埋め込み生成時に無視される + let author_icon_url = author.avatar_url(); + + Ok(CitationMessage::builder() + .content(message.content) + .author_name(author.name) + .author_avatar_url(author_icon_url) + .channel_name(channel.clone().name) + .create_at(message.timestamp) + .build()) + } + None => Err(anyhow::anyhow!( + "[ID: {}]のチャンネルを見つけることが出来ませんでした", + channel_id + )), + } +} + +pub async fn send_warn_embed( + http: &Arc, + message: &Message, + error_reason: &str, +) -> anyhow::Result<()> { + let embed_object = EmbedMessage::builder() + .title(Some("警告".to_string())) + .description(Some(format!("```\n{}\n```", error_reason))) + .color(Some(WARN_COLOR)) + .build(); + + message + .channel_id + .send_message(http, |m| { + m.reference_message(message); + m.allowed_mentions(|mention| { + mention.replied_user(true); + mention + }); + m.set_embed(convert_embed(embed_object)) + }) + .await + .context("警告メッセージの送信に失敗しました")?; + + Ok(()) +} + +pub async fn send_error_embed( + http: &Arc, + message: &Message, + error_reason: &Error, +) -> anyhow::Result<()> { + let embed_object = EmbedMessage::builder() + .title(Some("エラー".to_string())) + .description(Some(format!("```\n{}\n```", error_reason))) + .color(Some(ERROR_COLOR)) + .build(); + + message + .channel_id + .send_message(http, |m| { + m.reference_message(message); + m.allowed_mentions(|mention| { + mention.replied_user(true); + mention + }); + m.set_embed(convert_embed(embed_object)) + }) + .await + .context("エラーメッセージの送信に失敗しました")?; + + Ok(()) +} diff --git a/src/adapters/mod.rs b/src/adapters/mod.rs new file mode 100644 index 0000000..0a635bd --- /dev/null +++ b/src/adapters/mod.rs @@ -0,0 +1,2 @@ +pub mod embed; +pub mod message; diff --git a/src/client.rs b/src/client.rs index b4bfe6b..90b65df 100644 --- a/src/client.rs +++ b/src/client.rs @@ -4,7 +4,7 @@ use serenity::{prelude::GatewayIntents, Client}; use crate::event::EvHandler; pub async fn discord_client(token: String) -> anyhow::Result<()> { - let intents = GatewayIntents::MESSAGE_CONTENT; + let intents = GatewayIntents::MESSAGE_CONTENT | GatewayIntents::GUILD_MESSAGES; let mut client = Client::builder(token, intents) .event_handler(EvHandler) diff --git a/src/event.rs b/src/event.rs index 0d03995..cc3ee0c 100644 --- a/src/event.rs +++ b/src/event.rs @@ -1,15 +1,51 @@ -use crate::VERSION; +use crate::{ + adapters::message::{send_citation_embed, send_error_embed, send_warn_embed}, + model::id::DiscordIds, + VERSION, +}; +use once_cell::sync::Lazy; +use regex::Regex; use serenity::{ async_trait, - model::prelude::Ready, + model::prelude::{ChannelId, GuildId, Message, MessageId, Ready}, prelude::{Context, EventHandler}, }; -use tracing::info; +use tracing::{error, info, warn}; pub struct EvHandler; +const MESSAGE_LINK_REGEX: Lazy = Lazy::new(|| { + Regex::new(r"https://(?:ptb\.|canary\.)?discord\.com/channels/(\d+)/(\d+)/(\d+)").unwrap() +}); +// 引用スキップ機能の正規表現 +const SKIP_MESSAGE_LINK_REGEX: Lazy = Lazy::new(|| { + Regex::new(r"").unwrap() +}); + #[async_trait] impl EventHandler for EvHandler { + async fn message(&self, ctx: Context, message: Message) { + if message.is_private() || message.author.bot { + return; + } + + let content = &message.content; + if !MESSAGE_LINK_REGEX.is_match(&content) || SKIP_MESSAGE_LINK_REGEX.is_match(&content) { + return; + } + let matched_str = MESSAGE_LINK_REGEX.find(content).unwrap().as_str(); + + if let Some(triple) = extract_ids_from_link(matched_str) { + if let Err(why) = send_citation_embed(triple, &ctx.http, &message).await { + let _ = send_error_embed(&ctx.http, &message, &why).await; + error!("{:?}", why) + } + } else { + let _ = send_warn_embed(&ctx.http, &message, "IDの取り出しに失敗しました").await; + warn!("IDの取り出しに失敗したため、引用をキャンセルしました") + } + } + async fn ready(&self, _: Context, bot: Ready) { info!( "Connected to {name}(ID:{id}). (Using babyrite v{version}).", @@ -19,3 +55,28 @@ impl EventHandler for EvHandler { ) } } + +fn extract_ids_from_link(message_link: &str) -> Option { + let captures = MESSAGE_LINK_REGEX.captures(message_link)?; + + // GuildId + let first = captures + .get(1) + .and_then(|m| m.as_str().parse::().ok())?; + // ChannelId + let second = captures + .get(2) + .and_then(|m| m.as_str().parse::().ok())?; + // MessageId + let third = captures + .get(3) + .and_then(|m| m.as_str().parse::().ok())?; + + Some( + DiscordIds::builder() + .guild_id(GuildId(first)) + .channel_id(ChannelId(second)) + .message_id(MessageId(third)) + .build(), + ) +} diff --git a/src/main.rs b/src/main.rs index 4841afd..3ffd6ec 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,9 +8,11 @@ use crate::{ env::{env_var, load_dotenv}, }; +mod adapters; mod client; mod env; mod event; +mod model; #[allow(dead_code)] pub static DEFAULT_TIMEOUT_DURATION: Duration = Duration::from_secs(10); diff --git a/src/model/embed.rs b/src/model/embed.rs new file mode 100644 index 0000000..3bf2c16 --- /dev/null +++ b/src/model/embed.rs @@ -0,0 +1,66 @@ +use serenity::model::Timestamp; +use typed_builder::TypedBuilder; + +#[derive(TypedBuilder)] +pub struct EmbedMessageAuthor { + pub name: String, + + #[builder(default)] + pub icon_url: Option, + + #[builder(default)] + pub url: Option, +} + +#[derive(TypedBuilder)] +pub struct EmbedMessageField { + pub name: String, + + pub value: String, + + #[builder(default)] + pub inline: Option, +} + +#[derive(TypedBuilder)] +pub struct EmbedMessageFooter { + pub text: String, + + #[builder(default)] + pub icon_url: Option, +} + +#[derive(TypedBuilder)] +pub struct EmbedMessageThumbnail { + pub url: String, +} + +#[derive(TypedBuilder)] +pub struct EmbedMessage { + #[builder(default)] + pub author: Option, + + #[builder(default)] + pub title: Option, + + #[builder(default)] + pub description: Option, + + #[builder(default)] + pub fields: Option>, + + #[builder(default)] + pub footer: Option, + + #[builder(default)] + pub color: Option, + + #[builder(default)] + pub url: Option, + + #[builder(default)] + pub thumbnail: Option, + + #[builder(default)] + pub timestamp: Option, +} diff --git a/src/model/id.rs b/src/model/id.rs new file mode 100644 index 0000000..dfca412 --- /dev/null +++ b/src/model/id.rs @@ -0,0 +1,9 @@ +use serenity::model::prelude::{ChannelId, GuildId, MessageId}; +use typed_builder::TypedBuilder; + +#[derive(TypedBuilder)] +pub struct DiscordIds { + pub guild_id: GuildId, + pub channel_id: ChannelId, + pub message_id: MessageId, +} diff --git a/src/model/message.rs b/src/model/message.rs new file mode 100644 index 0000000..a161538 --- /dev/null +++ b/src/model/message.rs @@ -0,0 +1,20 @@ +use serenity::model::Timestamp; +use typed_builder::TypedBuilder; + +#[derive(TypedBuilder)] +pub struct CitationMessage { + // メッセージ内容 + pub content: String, + + // メッセージ送信者のユーザーネーム + pub author_name: String, + + // メッセージ送信者のアイコンURL + pub author_avatar_url: Option, + + // メッセージを送信したチャンネルの名前 + pub channel_name: String, + + // メッセージの送信日時 + pub create_at: Timestamp, +} diff --git a/src/model/mod.rs b/src/model/mod.rs new file mode 100644 index 0000000..95935e0 --- /dev/null +++ b/src/model/mod.rs @@ -0,0 +1,3 @@ +pub mod embed; +pub mod id; +pub mod message;