From e806786a1c9db3c2754312febcfe622de0d0fca0 Mon Sep 17 00:00:00 2001 From: videogame hacker Date: Mon, 11 Apr 2022 12:52:29 +0100 Subject: [PATCH] Wire up enough to connect to Discord --- mid-chat/src/lib.rs | 3 +- phoebe-main/Cargo.toml | 2 + phoebe-main/src/main.rs | 17 ++++- phoebe/src/lib.rs | 6 +- services/phoebe-discord/Cargo.toml | 3 +- services/phoebe-discord/src/chat_conv.rs | 86 ++++++++++++++++++++++++ services/phoebe-discord/src/handler.rs | 75 +++++++++++++++++++++ services/phoebe-discord/src/lib.rs | 53 +++++++-------- 8 files changed, 209 insertions(+), 36 deletions(-) create mode 100644 services/phoebe-discord/src/chat_conv.rs create mode 100644 services/phoebe-discord/src/handler.rs diff --git a/mid-chat/src/lib.rs b/mid-chat/src/lib.rs index 1d6f22f..5c96a07 100644 --- a/mid-chat/src/lib.rs +++ b/mid-chat/src/lib.rs @@ -16,7 +16,8 @@ pub struct ChatMessage { pub origin: ChatReference, pub author: ChatAuthor, pub content: ChatMessageContent, - // TODO: Attachments + pub attachments: Vec<()>, + pub replying: Option, } pub mod event; diff --git a/phoebe-main/Cargo.toml b/phoebe-main/Cargo.toml index 2deee91..b20f121 100644 --- a/phoebe-main/Cargo.toml +++ b/phoebe-main/Cargo.toml @@ -9,3 +9,5 @@ tracing-subscriber = { version = "0.3.10", features = ["env-filter"] } color-eyre = "0.6.1" phoebe = { path = "../phoebe" } phoebe-discord = { path = "../services/phoebe-discord" } +tracing = "0.1.33" +futures = "0.3.21" diff --git a/phoebe-main/src/main.rs b/phoebe-main/src/main.rs index 8b97599..c1a5934 100644 --- a/phoebe-main/src/main.rs +++ b/phoebe-main/src/main.rs @@ -1,4 +1,5 @@ use color_eyre::Result; +use tracing::info; use tracing_subscriber::EnvFilter; use phoebe::service::Service; @@ -12,12 +13,24 @@ async fn main() -> Result<()> { .with_env_filter(EnvFilter::from_default_env()) .init(); - let (tx, rx) = tokio::sync::broadcast::channel(512); + let (tx, _) = tokio::sync::broadcast::channel(512); let db = phoebe::open_core_db().await?; - let services: Vec> = vec![Box::new( + let services: Vec> = vec![Box::new( phoebe_discord::setup(db.clone(), tx.clone()).await?, )]; + let handles = services.into_iter().map(|mut srv| { + let mut rx = tx.subscribe(); + tokio::spawn(async move { + info!("Handling events for {}…", srv.get_service_tag()); + while let Ok(event) = rx.recv().await { + srv.handle_chat_event(&event).await; + } + }) + }); + + let _ = futures::future::join_all(handles).await; + Ok(()) } diff --git a/phoebe/src/lib.rs b/phoebe/src/lib.rs index ceb3bbc..390b2ec 100644 --- a/phoebe/src/lib.rs +++ b/phoebe/src/lib.rs @@ -1,11 +1,11 @@ -use mid_chat::event::ChatEvent; +pub use mid_chat; pub mod db; pub mod prelude; pub mod service; -pub type ChatEventSender = tokio::sync::broadcast::Sender; -pub type ChatEventReceiver = tokio::sync::broadcast::Receiver; +pub type ChatEventSender = tokio::sync::broadcast::Sender; +pub type ChatEventReceiver = tokio::sync::broadcast::Receiver; pub async fn open_core_db() -> sqlx::Result { let db = db::open("main").await?; diff --git a/services/phoebe-discord/Cargo.toml b/services/phoebe-discord/Cargo.toml index abea4ab..7d48610 100644 --- a/services/phoebe-discord/Cargo.toml +++ b/services/phoebe-discord/Cargo.toml @@ -5,7 +5,8 @@ edition = "2021" [dependencies] phoebe = { path = "../../phoebe" } -serenity = "0.10.10" +serenity = { version = "0.10.10", default-features = false, features = ["builder", "cache", "client", "gateway", "model", "http", "utils", "rustls_backend"] } sqlx = { version = "0.5", features = ["runtime-tokio-native-tls", "sqlite"] } tracing = "0.1" tokio = { version = "1", features = ["full"] } +discord_message_format = { git = "https://git.lavender.software/charlotte/discord-message-format.git" } diff --git a/services/phoebe-discord/src/chat_conv.rs b/services/phoebe-discord/src/chat_conv.rs new file mode 100644 index 0000000..647afa8 --- /dev/null +++ b/services/phoebe-discord/src/chat_conv.rs @@ -0,0 +1,86 @@ +use discord_message_format::DiscordComponent; +use phoebe::mid_chat::{ChatContentComponent, ChatContentComponent::*, ChatMessageContent}; + +pub fn convert(discord_message: &[DiscordComponent<'_>]) -> ChatMessageContent { + discord_message + .iter() + .map(discord_to_mid) + .collect::() +} + +pub fn format(message_content: &[ChatContentComponent]) -> String { + message_content.iter().map(mid_to_discord).collect() +} + +fn discord_to_mid(discord: &DiscordComponent<'_>) -> ChatContentComponent { + match discord { + DiscordComponent::Plain(text) => Plain(text.to_string()), + DiscordComponent::Literal(char) => Plain(char.to_string()), + DiscordComponent::Link(link) => Link { + target: link.to_string(), + text: vec![Plain(link.to_string())], + }, + + DiscordComponent::Bold(content) => Bold(convert(content)), + DiscordComponent::Italic(content) => Italic(convert(content)), + DiscordComponent::Strikethrough(content) => Strikethrough(convert(content)), + DiscordComponent::Underline(content) => Underline(convert(content)), + + DiscordComponent::Code(code) => Code(code.to_string()), + DiscordComponent::CodeBlock { lang, source } => CodeBlock { + lang: lang.map(|s| s.to_string()), + source: source.to_string(), + }, + + DiscordComponent::Spoiler(content) => Spoiler { + reason: None, + content: convert(content), + }, + + DiscordComponent::LineBreak => HardBreak, + DiscordComponent::Quote(content) => BlockQuote(convert(content)), + } +} + +fn mid_to_discord(component: &ChatContentComponent) -> String { + match component { + Plain(text) => text.to_string(), // TODO: Escape + + Link { target, text } => { + let formatted_text = format(text); + + // TODO: Maybe tolerate a missing http(s) URL scheme? + if &formatted_text == target { + formatted_text + } else { + format!("{} ({})", formatted_text, target) + } + } + + Italic(inner) => format!("*{}*", format(inner)), + Bold(inner) => format!("**{}**", format(inner)), + Strikethrough(inner) => format!("~~{}~~", format(inner)), + Underline(inner) => format!("__{}__", format(inner)), + + Code(code) => format!("`{}`", code), // TODO: Double-grave delimiting when code contains '`' + CodeBlock { lang, source } => { + format!( + "```{}\n{}\n```", + lang.as_ref() + .map(|s| s.to_string()) + .unwrap_or_else(|| "".to_string()), + source.to_string() + ) + } + + Spoiler { content, .. } => { + format!("||{}||", format(content)) + } // TODO: Spoiler reason + + HardBreak => "\n".to_string(), + BlockQuote(inner) => format(inner) + .lines() + .map(|l| format!("> {}\n", l)) + .collect(), + } +} diff --git a/services/phoebe-discord/src/handler.rs b/services/phoebe-discord/src/handler.rs new file mode 100644 index 0000000..6f00054 --- /dev/null +++ b/services/phoebe-discord/src/handler.rs @@ -0,0 +1,75 @@ +use phoebe::{mid_chat::*, prelude::*}; +use serenity::{ + client::{Context, EventHandler}, + model::prelude::*, +}; +use tracing::debug; + +use crate::discord_reference; + +pub struct DiscordHandler { + pub core_db: SqlitePool, + pub discord_media_db: SqlitePool, + pub chat_event_tx: ChatEventSender, + pub ctx_tx: tokio::sync::mpsc::UnboundedSender, +} + +impl DiscordHandler { + async fn get_author(&self, ctx: &Context, message: &Message) -> ChatAuthor { + let display_name = message + .author_nick(ctx) + .await + .unwrap_or_else(|| message.author.name.clone()); + + async fn tag_color(ctx: &Context, message: &Message) -> Option<[u8; 3]> { + let color = message.member(ctx).await.ok()?.colour(ctx).await?; + Some([color.r(), color.b(), color.g()]) + } + + let display_color = tag_color(ctx, message).await; + + ChatAuthor { + reference: discord_reference(message.author.id), + display_name, + display_color, + } + } +} + +#[async_trait] +impl EventHandler for DiscordHandler { + async fn ready(&self, ctx: Context, _ready: Ready) { + debug!("Discord signalled ready!"); + let _ = self.ctx_tx.send(ctx); + } + + async fn message(&self, ctx: Context, message: Message) { + let origin = ChatReference { + service: "discord", + id: message.id.to_string(), + }; + + let author = self.get_author(&ctx, &message).await; + + let content = discord_message_format::parse(&message.content); + let content = super::chat_conv::convert(&content); + + let replies_to = message.referenced_message.as_ref().map(|m| ChatReference { + service: "discord", + id: m.id.to_string(), + }); + + let chat_message = ChatMessage { + origin, + author, + content, + attachments: vec![], + replying: replies_to, + }; + + let _ = self + .chat_event_tx + .send(ChatEvent::NewMessage(chat_message)) + .expect("Failed to dispatch incoming Discord chat message"); + } +} diff --git a/services/phoebe-discord/src/lib.rs b/services/phoebe-discord/src/lib.rs index dafea4d..f5d37fc 100644 --- a/services/phoebe-discord/src/lib.rs +++ b/services/phoebe-discord/src/lib.rs @@ -1,40 +1,30 @@ -use phoebe::prelude::*; -use serenity::{ - client::{Context, EventHandler}, - model::prelude::*, - prelude::*, - Client, -}; +use phoebe::{mid_chat, prelude::*}; +use serenity::{client::Context, Client}; use tracing::{debug, info}; -pub struct DiscordService { - discord_client: Client, - discord_ctx: Context, -} +mod chat_conv; +mod handler; -struct DiscordHandler { - core_db: SqlitePool, - discord_media_db: SqlitePool, - chat_event_tx: ChatEventSender, - ctx_tx: tokio::sync::mpsc::Sender, -} - -#[async_trait] -impl EventHandler for DiscordHandler { - async fn ready(&self, ctx: Context, _data_about_bot: Ready) { - let _ = self.ctx_tx.send(ctx).await; +pub fn discord_reference(id: impl ToString) -> mid_chat::ChatReference { + mid_chat::ChatReference { + service: "discord", + id: id.to_string(), } } +pub struct DiscordService { + pub discord_ctx: Context, +} + pub async fn setup(core_db: SqlitePool, tx: ChatEventSender) -> Result { info!("Setting up Discord service…"); let discord_media_db = phoebe::db::open("discord_media").await?; sqlx::migrate!().run(&discord_media_db).await?; - let (ctx_tx, mut ctx_rx) = tokio::sync::mpsc::channel::(1); + let (ctx_tx, mut ctx_rx) = tokio::sync::mpsc::unbounded_channel::(); - let discord_handler = DiscordHandler { + let discord_handler = handler::DiscordHandler { core_db, discord_media_db, chat_event_tx: tx, @@ -45,17 +35,22 @@ pub async fn setup(core_db: SqlitePool, tx: ChatEventSender) -> Result