Wire up enough to connect to Discord

main
Charlotte Som 2022-04-11 12:52:29 +01:00
parent fbc6270a0a
commit e806786a1c
8 changed files with 209 additions and 36 deletions

View File

@ -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;

View File

@ -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"

View File

@ -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(())
}

View File

@ -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?;

View File

@ -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" }

View 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(),
}
}

View 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");
}
}

View File

@ -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]