Wire up enough to connect to Discord
This commit is contained in:
		
							parent
							
								
									fbc6270a0a
								
							
						
					
					
						commit
						e806786a1c
					
				
					 8 changed files with 209 additions and 36 deletions
				
			
		|  | @ -16,7 +16,8 @@ pub struct ChatMessage { | |||
|     pub origin: ChatReference, | ||||
|     pub author: ChatAuthor, | ||||
|     pub content: ChatMessageContent, | ||||
|     // TODO: Attachments
 | ||||
|     pub attachments: Vec<()>, | ||||
|     pub replying: Option<ChatReference>, | ||||
| } | ||||
| 
 | ||||
| pub mod event; | ||||
|  |  | |||
|  | @ -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" | ||||
|  |  | |||
|  | @ -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<Box<dyn Service>> = vec![Box::new( | ||||
|     let services: Vec<Box<dyn Service + Send + Sync>> = 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(()) | ||||
| } | ||||
|  |  | |||
|  | @ -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<ChatEvent>; | ||||
| pub type ChatEventReceiver = tokio::sync::broadcast::Receiver<ChatEvent>; | ||||
| pub type ChatEventSender = tokio::sync::broadcast::Sender<mid_chat::event::ChatEvent>; | ||||
| pub type ChatEventReceiver = tokio::sync::broadcast::Receiver<mid_chat::event::ChatEvent>; | ||||
| 
 | ||||
| pub async fn open_core_db() -> sqlx::Result<sqlx::SqlitePool> { | ||||
|     let db = db::open("main").await?; | ||||
|  |  | |||
|  | @ -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" }  | ||||
|  |  | |||
							
								
								
									
										86
									
								
								services/phoebe-discord/src/chat_conv.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										86
									
								
								services/phoebe-discord/src/chat_conv.rs
									
									
									
									
									
										Normal file
									
								
							|  | @ -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::<ChatMessageContent>() | ||||
| } | ||||
| 
 | ||||
| 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(), | ||||
|     } | ||||
| } | ||||
							
								
								
									
										75
									
								
								services/phoebe-discord/src/handler.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										75
									
								
								services/phoebe-discord/src/handler.rs
									
									
									
									
									
										Normal file
									
								
							|  | @ -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<Context>, | ||||
| } | ||||
| 
 | ||||
| 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"); | ||||
|     } | ||||
| } | ||||
|  | @ -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<Context>, | ||||
| } | ||||
| 
 | ||||
| #[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<DiscordService> { | ||||
|     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::<Context>(1); | ||||
|     let (ctx_tx, mut ctx_rx) = tokio::sync::mpsc::unbounded_channel::<Context>(); | ||||
| 
 | ||||
|     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<DiscordSe | |||
|     debug!("Logging in…"); | ||||
|     let discord_token = std::env::var("PHOEBE_DISCORD_TOKEN") | ||||
|         .expect("PHOEBE_DISCORD_TOKEN environment variable was not set!"); | ||||
|     let client = Client::builder(&discord_token) | ||||
| 
 | ||||
|     let mut client = Client::builder(&discord_token) | ||||
|         .event_handler(discord_handler) | ||||
|         .await?; | ||||
| 
 | ||||
|     tokio::spawn(async move { | ||||
|         client | ||||
|             .start() | ||||
|             .await | ||||
|             .expect("Failed to start Discord client") | ||||
|     }); | ||||
| 
 | ||||
|     let discord_ctx = ctx_rx.recv().await.expect("Couldn't get Discord context"); | ||||
|     debug!("Logged in!"); | ||||
| 
 | ||||
|     Ok(DiscordService { | ||||
|         discord_client: client, | ||||
|         discord_ctx, | ||||
|     }) | ||||
|     Ok(DiscordService { discord_ctx }) | ||||
| } | ||||
| 
 | ||||
| #[async_trait] | ||||
|  |  | |||
		Loading…
	
		Reference in a new issue