phoebe/src/discord.rs

386 lines
11 KiB
Rust

use log::info;
use serenity::{async_trait, http::AttachmentType, model::prelude::*, prelude::*};
use tokio::sync::mpsc;
use crate::{
channels::ChannelReference,
message_ast::{self, format_discord},
messages::{
DeletedMessage, EditedMessage, MessageAuthor, MessageEvent, MessageReference,
SentImageMessage, SentMessage,
},
};
pub use serenity::client::Context;
pub use serenity::model::id::{ChannelId, MessageId};
impl From<&Message> for MessageReference {
fn from(message: &Message) -> Self {
Self::Discord(message.channel_id.0, message.id.0)
}
}
struct DiscordHandler {
ctx_tx: mpsc::UnboundedSender<Context>,
event_tx: mpsc::UnboundedSender<MessageEvent>,
}
async fn get_message_author(ctx: &Context, message: &Message) -> MessageAuthor {
async fn user_tag_color(ctx: &Context, message: &Message) -> Option<String> {
Some(
message
.member(ctx)
.await
.ok()?
.colour(ctx)
.await?
.hex()
.to_ascii_lowercase(),
)
}
MessageAuthor {
display_name: message
.author_nick(ctx)
.await
.unwrap_or_else(|| message.author.name.clone()),
avatar_url: message
.author
.static_avatar_url()
.unwrap_or_else(|| message.author.default_avatar_url()),
service_name: "discord".to_string(),
display_color: user_tag_color(ctx, message).await,
}
}
#[async_trait]
impl EventHandler for DiscordHandler {
async fn ready(&self, ctx: Context, _ready: Ready) {
let _ = self.ctx_tx.send(ctx);
info!("Discord ready!");
}
async fn message(&self, ctx: Context, message: Message) {
// TODO: Replace with proper management system for linking channels together
if let Some(target) = message.content.strip_prefix("phoebe!link ") {
if message
.member(&ctx)
.await
.unwrap()
.roles(&ctx)
.await
.unwrap()
.iter()
.any(|r| r.name == "Phoebe")
{
let _ = self.event_tx.send(MessageEvent::AdminLinkChannels(vec![
ChannelReference::Discord(message.channel_id.0),
ChannelReference::Matrix(target.to_string()),
]));
message.reply(&ctx, "Linking with matrix.").await.unwrap();
return;
}
}
let message_ref = MessageReference::from(&message);
let content = discord_message_format::parse(&message.content);
let content = message_ast::convert_discord(&content);
let replies_to = message
.referenced_message
.as_ref()
.map(|m| MessageReference::from(m.as_ref()));
let _ = self
.event_tx
.send(MessageEvent::SendText(Box::new(SentMessage {
source: message_ref,
content,
author: get_message_author(&ctx, &message).await,
replies_to,
})));
for attachment in message.attachments.iter() {
let _ = self
.event_tx
.send(MessageEvent::SendImage(Box::new(SentImageMessage {
source: MessageReference::from(&message),
author: get_message_author(&ctx, &message).await,
image_url: attachment.proxy_url.clone(),
})));
}
}
async fn message_update(
&self,
ctx: Context,
_old_if_available: Option<Message>,
new: Option<Message>,
event: MessageUpdateEvent,
) {
if let Ok(new_message) = {
if let Some(m) = new {
Ok(m)
} else {
event.channel_id.message(&ctx, event.id).await
}
} {
let message_ref = MessageReference::from(&new_message);
let content = discord_message_format::parse(&new_message.content);
let content = message_ast::convert_discord(&content);
let _ = self
.event_tx
.send(MessageEvent::EditText(Box::new(EditedMessage {
replacing: message_ref,
content,
author: get_message_author(&ctx, &new_message).await,
})));
}
}
async fn message_delete(
&self,
_ctx: Context,
channel_id: ChannelId,
deleted_message_id: MessageId,
_guild_id: Option<GuildId>,
) {
let message_ref = MessageReference::Discord(channel_id.0, deleted_message_id.0);
let _ = self
.event_tx
.send(MessageEvent::Delete(Box::new(DeletedMessage {
reference: message_ref,
})));
}
}
async fn get_webhook_for_channel(discord_ctx: &Context, channel: &ChannelId) -> Option<Webhook> {
if let Ok(webhooks) = channel.webhooks(discord_ctx).await {
for webhook in webhooks {
if matches!(webhook.name.as_deref(), Some("phoebe")) {
return Some(webhook);
}
}
}
None
}
async fn get_or_create_webhook_for_channel(
discord_ctx: &Context,
channel: &ChannelId,
) -> Option<Webhook> {
if let Some(webhook) = get_webhook_for_channel(discord_ctx, channel).await {
return Some(webhook);
}
if let Ok(webhook) = channel.create_webhook(discord_ctx, "phoebe").await {
return Some(webhook);
}
None
}
async fn create_webhook_reply_embeds(
discord_ctx: &Context,
reply: Option<(u64, u64)>,
) -> Vec<serde_json::Value> {
if let Some((channel_id, message_id)) = reply {
if let Ok(replied_message) = ChannelId(channel_id)
.message(discord_ctx, MessageId(message_id))
.await
{
let replied_author_name = format!(
"{} ↩️",
replied_message
.author_nick(discord_ctx)
.await
.as_ref()
.unwrap_or(&replied_message.author.name)
);
let reply_description = format!(
"**[Reply to:]({})**\n{}",
replied_message.id.link(
ChannelId(channel_id),
discord_ctx
.cache
.guild_channel(channel_id)
.await
.map(|gc| gc.guild_id)
),
&replied_message.content
);
return vec![Embed::fake(|e| {
e.author(|a| {
a.icon_url(
&replied_message
.author
.static_avatar_url()
.unwrap_or_else(|| replied_message.author.default_avatar_url()),
)
.name(replied_author_name)
})
.description(reply_description)
})];
}
}
vec![]
}
pub async fn forward_to_discord(
discord_ctx: &Context,
channel: ChannelId,
message: &SentMessage,
reply: Option<(u64, u64)>,
) -> Option<MessageReference> {
if let Some(webhook) = get_or_create_webhook_for_channel(discord_ctx, &channel).await {
let reply_embeds = create_webhook_reply_embeds(discord_ctx, reply).await;
return webhook
.execute(discord_ctx, true, |w| {
w.content(format_discord(&message.content))
.username(format!(
"{} ({})",
&message.author.display_name, &message.author.service_name
))
.avatar_url(&message.author.avatar_url)
.embeds(reply_embeds)
})
.await
.ok()
.flatten()
.as_ref()
.map(MessageReference::from);
}
channel
.send_message(discord_ctx, |m| {
let content = format!(
"{} ({}): {}",
&message.author.display_name,
&message.author.service_name,
format_discord(&message.content)
);
if let Some((channel_id, message_id)) = reply {
m.content(&content)
.reference_message((ChannelId(channel_id), MessageId(message_id)))
} else {
m.content(&content)
}
})
.await
.as_ref()
.ok()
.map(MessageReference::from)
}
pub async fn edit_on_discord(
discord_ctx: &Context,
channel_id: ChannelId,
message_id: MessageId,
message: &EditedMessage,
) -> Option<MessageReference> {
if let Some(webhook) = get_or_create_webhook_for_channel(discord_ctx, &channel_id).await {
return webhook
.edit_message(discord_ctx, message_id, |w| {
w.content(format_discord(&message.content))
})
.await
.as_ref()
.ok()
.map(MessageReference::from);
}
channel_id
.edit_message(&discord_ctx, &message_id, |m| {
m.content(format_discord(&message.content))
})
.await
.as_ref()
.ok()
.map(MessageReference::from)
}
pub async fn delete_on_discord(
discord_ctx: &Context,
channel_id: ChannelId,
message_id: MessageId,
_message: &DeletedMessage,
) -> bool {
if let Some(webhook) = get_or_create_webhook_for_channel(discord_ctx, &channel_id).await {
return webhook
.delete_message(discord_ctx, message_id)
.await
.is_ok();
}
channel_id
.delete_message(&discord_ctx, &message_id)
.await
.is_ok()
}
pub async fn forward_image_to_discord(
discord_ctx: &Context,
channel_id: ChannelId,
image: &SentImageMessage,
) -> Option<MessageReference> {
if let Some(webhook) = get_or_create_webhook_for_channel(discord_ctx, &channel_id).await {
return webhook
.execute(discord_ctx, true, |w| {
w.add_file(AttachmentType::Image(&image.image_url))
.username(format!(
"{} ({})",
&image.author.display_name, &image.author.service_name
))
.avatar_url(&image.author.avatar_url)
})
.await
.ok()
.flatten()
.as_ref()
.map(MessageReference::from);
}
channel_id
.send_message(discord_ctx, |m| {
m.add_file(AttachmentType::Image(&image.image_url))
.content(format!(
"{} ({}):",
&image.author.display_name, &image.author.service_name
))
})
.await
.as_ref()
.ok()
.map(MessageReference::from)
}
pub async fn create_discord_client(
ctx_tx: mpsc::UnboundedSender<Context>,
message_tx: mpsc::UnboundedSender<MessageEvent>,
token: &str,
) -> Client {
let handler = DiscordHandler {
ctx_tx,
event_tx: message_tx,
};
info!("Discord logging in…");
let client = Client::builder(token)
.event_handler(handler)
.await
.expect("Failed to create discord client");
info!("Discord starting…");
client
}