Compare commits

...

No commits in common. "main" and "legacy" have entirely different histories.
main ... legacy

42 changed files with 5173 additions and 1195 deletions

View File

@ -1,12 +0,0 @@
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = false
insert_final_newline = true
[*.rs]
indent_size = 4

1
.gitignore vendored
View File

@ -1,2 +1 @@
/target
/Cargo.lock

3377
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,26 @@
[workspace]
members = [
"phoebe",
"phoebe-main",
"mid-chat",
"services/*"
]
[package]
name = "phoebe"
version = "0.1.0"
edition = "2018"
[dependencies]
bincode = "1.3.3"
discord_message_format = { git = "https://git.lavender.software/charlotte/discord-message-format.git" }
matrix-sdk = { git = "https://git.lavender.software/charlotte/matrix-rust-sdk", rev = "d83d8b959c" }
serde = { version = "1.0.130", features = ["derive"] }
sled = "0.34.7"
tokio = { version = "1.11.0", features = ["full"] }
url = "2.2.2"
log = "0.4.14"
env_logger = "0.9.0"
html-escape = "0.2.9"
html5ever = "0.25.1"
kuchiki = "0.8.1"
serde_json = "1.0.68"
mime_guess = "2.0.3"
reqwest = { version = "0.11.6", features = ["blocking"] }
[dependencies.serenity]
version = "0.10.9"
default-features = false
features = ["builder", "cache", "client", "gateway", "model", "http", "utils", "rustls_backend"]

View File

@ -1,11 +1,3 @@
# phoebe
bridgers [primarily, discord ↔ matrix]
A [Charlotte Som](https://som.codes/) project.
## Architecture
- `mid-chat` - An intermediate representation for chat messages. Best-effort common denomination
- `services/*` - Handling for individual chat services & conversion to and from the common-denominator chat message IR
- `phoebe` - Main: Database, message dispatch, service orchestration, etc
bridgers

4
data/.gitignore vendored
View File

@ -1,2 +1,2 @@
*
!/.gitignore
*
!.gitignore

View File

@ -1,6 +0,0 @@
[package]
name = "mid-chat"
version = "0.1.0"
edition = "2021"
[dependencies]

View File

@ -1,30 +0,0 @@
pub type ChatMessageContent = Vec<ChatContentComponent>;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ChatContentComponent {
Plain(String),
Link {
target: String,
text: ChatMessageContent,
},
Italic(ChatMessageContent),
Bold(ChatMessageContent),
Strikethrough(ChatMessageContent),
Underline(ChatMessageContent),
Code(String),
CodeBlock {
lang: Option<String>,
source: String,
},
Spoiler {
reason: Option<String>,
content: ChatMessageContent,
},
HardBreak,
BlockQuote(ChatMessageContent),
}

View File

@ -1,8 +0,0 @@
use crate::{ChatMessage, ChatMessageEdit, ChatMessageReference};
#[derive(Debug, Clone)]
pub enum ChatEvent {
NewMessage(Box<ChatMessage>),
DeleteMessage(ChatMessageReference),
EditMessage(ChatMessageReference, Box<ChatMessageEdit>),
}

View File

@ -1,44 +0,0 @@
pub mod reference;
pub use reference::*;
#[derive(Debug, Clone)]
pub enum ChatAttachment {
Online {
media_type: Option<String>,
url: String,
},
InMemory {
media_type: Option<String>,
file_name: String,
data: Vec<u8>,
},
}
#[derive(Debug, Clone)]
pub struct ChatAuthor {
pub reference: ChatReference,
pub display_name: String,
pub display_color: Option<[u8; 3]>,
pub avatar: ChatAttachment,
}
mod content;
pub use content::*;
#[derive(Debug, Clone)]
pub struct ChatMessage {
pub origin: ChatMessageReference,
pub author: ChatAuthor,
pub content: ChatMessageContent,
pub attachments: Vec<ChatAttachment>,
pub replying: Option<ChatMessageReference>,
}
#[derive(Debug, Clone)]
pub struct ChatMessageEdit {
pub origin: ChatMessageReference,
pub author: ChatAuthor,
pub new_content: ChatMessageContent,
}
pub mod event;

View File

@ -1,20 +0,0 @@
#[derive(Debug, Clone, Hash, PartialEq, Eq)]
pub struct ChatReference {
pub service: &'static str,
pub id: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ChatMessageReference {
pub channel: ChatReference,
pub message_id: String,
}
impl ChatMessageReference {
pub fn new(channel: ChatReference, message_id: impl ToString) -> Self {
Self {
channel,
message_id: message_id.to_string(),
}
}
}

View File

@ -1,13 +0,0 @@
[package]
name = "phoebe-main"
version = "0.1.0"
edition = "2021"
[dependencies]
tokio = { version = "1", features = ["full"] }
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,137 +0,0 @@
use color_eyre::Result;
use futures::{future, StreamExt};
use tracing::info;
use tracing_subscriber::EnvFilter;
use phoebe::{
get_linked_channels, get_linked_messages, link_messages,
prelude::{ChatEvent, SqlitePool},
service::Service,
unlink_message, DynServiceLookup,
};
async fn handle_events(
dyn_service: DynServiceLookup,
db: SqlitePool,
mut service: Box<dyn Service + Send + Sync>,
mut rx: tokio::sync::broadcast::Receiver<ChatEvent>,
) {
info!("Handling events for {}…", service.tag());
let mut conn = db
.acquire()
.await
.expect("Failed to acquire core DB connection");
while let Ok(event) = rx.recv().await {
match event {
ChatEvent::NewMessage(message) => {
let linked_channels =
get_linked_channels(&mut conn, dyn_service, &message.origin.channel).await;
let mut resulting_messages = vec![];
for destination_channel in linked_channels {
resulting_messages.extend(
service
.send_chat_message(&message, destination_channel)
.await,
)
}
if !resulting_messages.is_empty() {
if let Err(e) =
link_messages(&mut conn, &message.origin, &resulting_messages).await
{
tracing::error!("Failed to link messages: {e}");
}
}
}
ChatEvent::DeleteMessage(origin) => {
let messages = if let Ok(message_stream) =
get_linked_messages(&mut conn, dyn_service, &origin).await
{
message_stream
.filter(|r| future::ready(r.channel.service == service.tag()))
.collect::<Vec<_>>()
.await
} else {
vec![]
};
if !messages.is_empty() {
if let Err(e) = unlink_message(&mut conn, &origin).await {
tracing::error!("Failed to unlink origin message: {e}");
}
for message in messages {
if service.delete_message(&message).await {
if let Err(e) = unlink_message(&mut conn, &message).await {
tracing::error!("Failed to unlink related message: {e}");
}
}
}
}
}
ChatEvent::EditMessage(prev_origin, message_edit) => {
let messages = if let Ok(message_stream) =
get_linked_messages(&mut conn, dyn_service, &prev_origin).await
{
message_stream
.filter(|r| future::ready(r.channel.service == service.tag()))
.collect::<Vec<_>>()
.await
} else {
vec![]
};
let mut resulting_messages = vec![];
for message in messages {
resulting_messages.extend(service.edit_message(&message, &message_edit).await)
}
if !resulting_messages.is_empty() {
if let Err(e) =
link_messages(&mut conn, &prev_origin, &resulting_messages).await
{
tracing::error!("Failed to link messages: {e}");
}
}
}
}
}
}
#[tokio::main]
async fn main() -> Result<()> {
color_eyre::install()?;
tracing_subscriber::fmt()
.with_target(true)
.with_env_filter(EnvFilter::from_default_env())
.init();
let (tx, _) = tokio::sync::broadcast::channel(512);
let db = phoebe::open_core_db().await?;
fn dyn_service(service: &str) -> &'static str {
match service {
"discord" => "discord",
"matrix" => "matrix",
_ => panic!("Unsupported service: {}", service),
}
}
let services: Vec<Box<dyn Service + Send + Sync>> = vec![Box::new(
phoebe_discord::setup(db.clone(), tx.clone(), dyn_service).await?,
)];
let handles = services
.into_iter()
.map(|srv| tokio::spawn(handle_events(dyn_service, db.clone(), srv, tx.subscribe())));
let _ = future::join_all(handles).await;
Ok(())
}

View File

@ -1 +0,0 @@
DATABASE_URL="sqlite://${PHOEBE_DB_ROOT}/main.db"

View File

@ -1,14 +0,0 @@
[package]
name = "phoebe"
version = "0.1.0"
edition = "2021"
authors = ["videogame hacker <half-kh-hacker@hackery.site>"]
[dependencies]
mid-chat = { path = "../mid-chat" }
tokio = { version = "1", features = ["full"] }
sqlx = { version = "0.5", features = ["runtime-tokio-native-tls", "sqlite"] }
tracing = "0.1"
async-trait = "0.1.53"
eyre = "0.6.8"
futures = "0.3.21"

View File

@ -1,3 +0,0 @@
fn main() {
println!("cargo:rerun-if-changed=migrations");
}

View File

@ -1,12 +0,0 @@
CREATE TABLE message_links (
id INTEGER PRIMARY KEY AUTOINCREMENT
) STRICT;
CREATE TABLE messages (
message_id INTEGER PRIMARY KEY,
link_id INTEGER NOT NULL REFERENCES message_links(id),
service TEXT NOT NULL,
channel TEXT NOT NULL,
message TEXT NOT NULL,
original INTEGER
) STRICT;

View File

@ -1,8 +0,0 @@
CREATE TABLE channel_links (
from_service TEXT NOT NULL,
from_channel TEXT NOT NULL,
to_service TEXT NOT NULL,
to_channel TEXT NOT NULL,
PRIMARY KEY (from_service, from_channel, to_service, to_channel)
) STRICT;

View File

@ -1,25 +0,0 @@
use mid_chat::ChatAttachment;
/*
use tokio::sync::OnceCell;
static PHOEBE_MEDIA_BASE_URL: OnceCell<String> = OnceCell::const_new();
async fn get_base_url() -> &'static str {
PHOEBE_MEDIA_BASE_URL
.get_or_init(|| async {
std::env::var("PHOEBE_MEDIA_BASE_URL")
.expect("PHOEBE_MEDIA_BASE_URL environment variable was not set!")
})
.await
}
*/
pub async fn attachment_to_url(attachment: &ChatAttachment) -> String {
match attachment {
ChatAttachment::Online { url, .. } => url.clone(),
ChatAttachment::InMemory { .. } => {
todo!("Store in-memory attachment inside webroot")
}
}
}

View File

@ -1,26 +0,0 @@
use sqlx::{sqlite::SqliteConnectOptions, SqlitePool};
use tokio::sync::OnceCell;
use tracing::debug;
static PHOEBE_DB_ROOT: OnceCell<String> = OnceCell::const_new();
async fn get_db_root() -> &'static str {
PHOEBE_DB_ROOT
.get_or_init(|| async {
std::env::var("PHOEBE_DB_ROOT")
.expect("PHOEBE_DB_ROOT environment variable was not set!")
})
.await
}
async fn create_or_open_sqlite(url: &str) -> sqlx::Result<SqlitePool> {
let options = url.parse::<SqliteConnectOptions>()?.create_if_missing(true);
SqlitePool::connect_with(options).await
}
pub async fn open(name: &str) -> sqlx::Result<SqlitePool> {
debug!("Opening {name} database…");
let db_root = get_db_root().await;
let db_url = format!("sqlite://{}/{}.db", db_root, name);
create_or_open_sqlite(&db_url).await
}

View File

@ -1,179 +0,0 @@
use futures::{stream::BoxStream, Future};
pub use mid_chat;
use mid_chat::{ChatMessageReference, ChatReference};
use futures::StreamExt;
use sqlx::{Row, SqliteConnection, SqlitePool};
use tokio::sync::broadcast::*;
pub mod attachments;
pub mod db;
pub mod prelude;
pub mod service;
pub type ChatEventSender = Sender<mid_chat::event::ChatEvent>;
pub type ChatEventReceiver = Receiver<mid_chat::event::ChatEvent>;
pub type DynServiceLookup = fn(&str) -> &'static str;
pub async fn open_core_db() -> sqlx::Result<SqlitePool> {
let db = db::open("main").await?;
sqlx::migrate!().run(&db).await?;
Ok(db)
}
pub async fn get_linked_channels(
conn: &mut SqliteConnection,
dyn_service: DynServiceLookup,
channel: &ChatReference,
) -> Vec<ChatReference> {
let from_service = channel.service;
let from_channel = &channel.id;
let query = sqlx::query!(
"SELECT * FROM channel_links WHERE from_service = ? AND from_channel = ?",
from_service,
from_channel
);
query
.fetch(&mut *conn)
.filter_map(|r| async { r.ok() })
.map(|r| ChatReference {
service: dyn_service(&r.to_service),
id: r.to_channel,
})
.collect()
.await
}
pub async fn link_messages(
conn: &mut SqliteConnection,
origin: &ChatMessageReference,
messages: &[ChatMessageReference],
) -> sqlx::Result<()> {
let message_link = sqlx::query!("INSERT INTO message_links DEFAULT VALUES")
.execute(&mut *conn)
.await?
.last_insert_rowid();
let original_id = {
let service = &origin.channel.service;
let channel = &origin.channel.id;
let message = &origin.message_id;
let query = sqlx::query!(
"INSERT INTO messages (link_id, service, channel, message, original) VALUES (?, ?, ?, ?, NULL)",
message_link,
service,
channel,
message
);
query.execute(&mut *conn).await?.last_insert_rowid()
};
for resultant in messages {
let service = &resultant.channel.service;
let channel = &resultant.channel.id;
let message = &resultant.message_id;
let query = sqlx::query!(
"INSERT INTO messages (link_id, service, channel, message, original) VALUES (?, ?, ?, ?, ?)",
message_link,
service,
channel,
message,
original_id
);
let _ = query.execute(&mut *conn).await?;
}
Ok(())
}
pub async fn unlink_message(
conn: &mut SqliteConnection,
message: &ChatMessageReference,
) -> sqlx::Result<()> {
let service = &message.channel.service;
let channel = &message.channel.id;
let message_id = &message.message_id;
let query = sqlx::query!(
"DELETE FROM messages WHERE service = ? AND channel = ? AND message = ?",
service,
channel,
message_id
);
if query.execute(&mut *conn).await?.rows_affected() == 0 {
return Err(sqlx::Error::RowNotFound);
}
Ok(())
}
pub async fn get_message_link_id(
conn: &mut SqliteConnection,
message: &ChatMessageReference,
) -> sqlx::Result<i64> {
let service = &message.channel.service;
let channel = &message.channel.id;
let message_id = &message.message_id;
let query = sqlx::query!(
"SELECT link_id FROM messages WHERE service = ? AND channel = ? AND message = ?",
service,
channel,
message_id
);
let r = query.fetch_one(&mut *conn).await?;
Ok(r.link_id)
}
pub async fn get_linked_messages<'a>(
conn: &'a mut SqliteConnection,
dyn_service: DynServiceLookup,
message: &ChatMessageReference,
) -> sqlx::Result<BoxStream<'a, ChatMessageReference>> {
let link_id = get_message_link_id(&mut *conn, message).await?;
let stream = sqlx::query("SELECT * FROM messages WHERE link_id = ?")
.bind(link_id)
.fetch(&mut *conn)
.filter_map(|r| futures::future::ready(r.ok()))
.map(move |r| {
ChatMessageReference::new(
ChatReference {
service: dyn_service(&r.get::<String, _>("service")),
id: r.get("channel"),
},
r.get::<String, _>("message"),
)
});
Ok(Box::pin(stream))
}
pub async fn lookup_message<F, Fut>(
conn: &mut SqliteConnection,
dyn_service: DynServiceLookup,
linked_message: &ChatMessageReference,
filter: F,
) -> Option<ChatMessageReference>
where
F: FnMut(&ChatMessageReference) -> Fut,
Fut: Future<Output = bool>,
{
let references = get_linked_messages(&mut *conn, dyn_service, linked_message)
.await
.ok()?
.filter(filter)
.collect::<Vec<_>>()
.await;
if let [reference] = references.as_slice() {
Some(reference.clone())
} else {
None
}
}

View File

@ -1,7 +0,0 @@
pub use crate::{service::Service, ChatEventReceiver, ChatEventSender};
pub use async_trait::async_trait;
pub use eyre::{self, Result};
pub use futures::{self, prelude::*};
pub use mid_chat::event::ChatEvent;
pub use sqlx::{self, SqliteConnection, SqlitePool};

View File

@ -1,18 +0,0 @@
use mid_chat::*;
#[async_trait::async_trait]
pub trait Service {
fn tag(&self) -> &'static str;
async fn send_chat_message(
&mut self,
source: &ChatMessage,
destination_channel: ChatReference,
) -> Vec<ChatMessageReference>;
async fn delete_message(&mut self, message: &ChatMessageReference) -> bool;
async fn edit_message(
&mut self,
old_origin: &ChatMessageReference,
edit: &ChatMessageEdit,
) -> Vec<ChatMessageReference>;
}

View File

@ -1 +0,0 @@
DATABASE_URL="sqlite://${PHOEBE_DB_ROOT}/discord_media.db"

View File

@ -1,13 +0,0 @@
[package]
name = "phoebe-discord"
version = "0.1.0"
edition = "2021"
[dependencies]
phoebe = { path = "../../phoebe" }
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" }
serde_json = "1.0.79"

View File

@ -1,86 +0,0 @@
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
)
}
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

@ -1,147 +0,0 @@
use phoebe::{get_message_link_id, 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;
let avatar_url = message
.author
.static_avatar_url()
.unwrap_or_else(|| message.author.default_avatar_url());
ChatAuthor {
reference: discord_reference(message.author.id),
display_name,
display_color,
avatar: ChatAttachment::Online {
media_type: None,
url: avatar_url,
},
}
}
}
#[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 = ChatMessageReference::new(discord_reference(message.channel_id), message.id);
// skip messages linked to ones we have already seen
if let Ok(mut conn) = self.core_db.acquire().await {
if get_message_link_id(&mut conn, &origin).await.is_ok() {
return;
}
}
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| ChatMessageReference::new(discord_reference(m.channel_id), m.id));
let attachments = message
.attachments
.into_iter()
.map(|a| ChatAttachment::Online {
media_type: a.content_type,
url: a.url,
})
.collect::<Vec<_>>();
let chat_message = ChatMessage {
origin,
author,
content,
attachments,
replying: replies_to,
};
let _ = self
.chat_event_tx
.send(ChatEvent::NewMessage(Box::new(chat_message)))
.expect("Failed to dispatch incoming Discord chat message");
}
async fn message_delete(
&self,
_ctx: Context,
channel_id: ChannelId,
deleted_message_id: MessageId,
_guild_id: Option<GuildId>,
) {
let origin = ChatMessageReference::new(discord_reference(channel_id), deleted_message_id);
let _ = self
.chat_event_tx
.send(ChatEvent::DeleteMessage(origin))
.expect("Failed to dispatch incoming Discord chat message deletion");
}
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 origin = ChatMessageReference::new(
discord_reference(new_message.channel_id),
new_message.id,
);
let author = self.get_author(&ctx, &new_message).await;
let content = discord_message_format::parse(&new_message.content);
let content = super::chat_conv::convert(&content);
let edit = ChatMessageEdit {
origin: origin.clone(),
author,
new_content: content,
};
let _ = self
.chat_event_tx
.send(ChatEvent::EditMessage(origin, Box::new(edit)))
.expect("Failed to dispatch incoming Discord chat message update");
}
}
}

View File

@ -1,106 +0,0 @@
use phoebe::{
mid_chat::{self, ChatMessage, ChatMessageEdit, ChatMessageReference, ChatReference},
prelude::*,
DynServiceLookup,
};
use serenity::{client::Context, Client};
use tracing::{debug, info};
mod chat_conv;
mod handler;
mod lookup;
mod sender;
pub fn discord_reference(id: impl ToString) -> mid_chat::ChatReference {
mid_chat::ChatReference {
service: "discord",
id: id.to_string(),
}
}
pub struct DiscordService {
pub core_db: SqlitePool,
pub discord_media_db: SqlitePool,
pub ctx: Context,
pub dyn_service: DynServiceLookup,
}
pub async fn setup(
core_db: SqlitePool,
tx: ChatEventSender,
dyn_service: DynServiceLookup,
) -> 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::unbounded_channel::<Context>();
let discord_handler = handler::DiscordHandler {
core_db: core_db.clone(),
discord_media_db: discord_media_db.clone(),
chat_event_tx: tx,
ctx_tx,
};
debug!("Logging in…");
let discord_token = std::env::var("PHOEBE_DISCORD_TOKEN")
.expect("PHOEBE_DISCORD_TOKEN environment variable was not set!");
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 {
core_db,
discord_media_db,
ctx: discord_ctx,
dyn_service,
})
}
#[async_trait]
impl Service for DiscordService {
fn tag(&self) -> &'static str {
"discord"
}
async fn send_chat_message(
&mut self,
source: &ChatMessage,
destination_channel: ChatReference,
) -> Vec<ChatMessageReference> {
assert_eq!(destination_channel.service, "discord");
sender::send_discord_message(self, source, destination_channel)
.await
.ok()
.into_iter()
.collect()
}
async fn delete_message(&mut self, message: &ChatMessageReference) -> bool {
assert_eq!(message.channel.service, "discord");
sender::delete_discord_message(self, message).await.is_ok()
}
async fn edit_message(
&mut self,
prev_origin: &ChatMessageReference,
new_message: &ChatMessageEdit,
) -> Vec<ChatMessageReference> {
assert_eq!(prev_origin.channel.service, "discord");
let _ = sender::edit_discord_message(self, prev_origin, new_message).await;
vec![]
}
}

View File

@ -1,18 +0,0 @@
use phoebe::{lookup_message, mid_chat::ChatMessageReference, prelude::*};
use crate::DiscordService;
impl DiscordService {
pub async fn lookup_message<F, Fut>(
&self,
linked_message: &ChatMessageReference,
filter: F,
) -> Option<ChatMessageReference>
where
F: FnMut(&ChatMessageReference) -> Fut,
Fut: Future<Output = bool>,
{
let mut conn = self.core_db.acquire().await.ok()?;
lookup_message(&mut conn, self.dyn_service, linked_message, filter).await
}
}

View File

@ -1,235 +0,0 @@
use phoebe::{
attachments::attachment_to_url,
mid_chat::{ChatAttachment, ChatMessage, ChatMessageEdit, ChatMessageReference, ChatReference},
prelude::*,
};
use serenity::{http::AttachmentType, model::prelude::*, prelude::*};
use crate::{chat_conv, discord_reference, DiscordService};
async fn get_or_create_webhook_for_channel(
discord: &mut DiscordService,
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);
}
}
}
if let Ok(webhook) = channel.create_webhook(&discord.ctx, "Phoebe").await {
return Some(webhook);
}
None
}
async fn create_webhook_reply_embeds(
discord_ctx: &Context,
channel_id: ChannelId,
message_id: MessageId,
) -> Vec<serde_json::Value> {
if let Ok(replied_message) = channel_id.message(discord_ctx, 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(
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![]
}
async fn create_discord_attachments(source: &'_ ChatMessage) -> Vec<AttachmentType<'_>> {
source
.attachments
.iter()
.map(|a| match a {
ChatAttachment::Online {
url,
media_type: Some(media_type),
} => {
if media_type.starts_with("image/") {
AttachmentType::Image(url)
} else {
todo!("Handle non-image online attachment")
}
}
ChatAttachment::Online { .. } => {
todo!("Handle online attachment with no media_type")
}
ChatAttachment::InMemory {
file_name, data, ..
} => AttachmentType::Bytes {
filename: file_name.clone(),
data: data.into(),
},
})
.collect()
}
pub async fn send_discord_message(
discord: &mut DiscordService,
source: &ChatMessage,
destination_channel: ChatReference,
) -> Result<ChatMessageReference> {
let channel_id = destination_channel.id.parse::<ChannelId>()?;
let discord_reply = if let Some(reply) = &source.replying {
if let Some(reply_ref) = discord
.lookup_message(reply, |r| future::ready(r.channel == destination_channel))
.await
{
assert_eq!(reply_ref.channel.service, "discord");
let channel_id: ChannelId = reply_ref.channel.id.parse().unwrap();
let message_id: MessageId = reply_ref.message_id.parse::<u64>().unwrap().into();
Some((channel_id, message_id))
} else {
None
}
} else {
None
};
let files = create_discord_attachments(source).await;
if let Some(webhook) = get_or_create_webhook_for_channel(&mut *discord, &channel_id).await {
let reply_embeds = if let Some((channel, message)) = discord_reply {
create_webhook_reply_embeds(&discord.ctx, channel, message).await
} else {
vec![]
};
let avatar_url = attachment_to_url(&source.author.avatar).await;
if let Some(sent_message) = webhook
.execute(&discord.ctx, true, |w| {
w.content(chat_conv::format(&source.content))
.username(format!(
"{} ({})",
&source.author.display_name, &source.author.reference.service
))
.avatar_url(&avatar_url)
.embeds(reply_embeds)
.add_files(files.clone())
})
.await?
{
return Ok(ChatMessageReference::new(
discord_reference(sent_message.channel_id),
sent_message.id,
));
}
}
let content = format!(
"{} ({}): {}",
source.author.display_name,
source.author.reference.service,
chat_conv::format(&source.content)
);
let sent_message = channel_id
.send_message(&discord.ctx, move |m| {
let m = m.content(content);
let m = m.add_files(files);
if let Some(reply) = discord_reply {
m.reference_message(reply)
} else {
m
}
})
.await?;
Ok(ChatMessageReference::new(
discord_reference(sent_message.channel_id),
sent_message.id,
))
}
pub async fn delete_discord_message(
discord: &mut DiscordService,
message: &ChatMessageReference,
) -> Result<()> {
let channel_id = message.channel.id.parse::<ChannelId>()?;
let message_id: MessageId = message.message_id.parse::<u64>()?.into();
if let Some(webhook) = get_or_create_webhook_for_channel(&mut *discord, &channel_id).await {
if webhook
.delete_message(&discord.ctx, message_id)
.await
.is_ok()
{
return Ok(());
}
}
channel_id.delete_message(&discord.ctx, message_id).await?;
Ok(())
}
pub async fn edit_discord_message(
discord: &mut DiscordService,
prev_origin: &ChatMessageReference,
edit: &ChatMessageEdit,
) -> Result<()> {
let channel_id = prev_origin.channel.id.parse::<ChannelId>()?;
let message_id: MessageId = prev_origin.message_id.parse::<u64>()?.into();
if let Some(webhook) = get_or_create_webhook_for_channel(&mut *discord, &channel_id).await {
if webhook
.edit_message(&discord.ctx, message_id, |w| {
w.content(chat_conv::format(&edit.new_content))
})
.await
.is_ok()
{
return Ok(());
}
}
let content = format!(
"{} ({}): {}",
edit.author.display_name,
edit.author.reference.service,
chat_conv::format(&edit.new_content)
);
channel_id
.edit_message(&discord.ctx, message_id, |m| m.content(content))
.await?;
Ok(())
}

View File

@ -1,7 +0,0 @@
[package]
name = "phoebe-matrix"
version = "0.1.0"
edition = "2021"
[dependencies]
phoebe = { path = "../../phoebe" }

309
src/bridgers.rs Normal file
View File

@ -0,0 +1,309 @@
use std::{cell::RefCell, collections::HashSet, str::FromStr, sync::Mutex};
use sled::Db;
use crate::{
channels::ChannelReference,
discord::{
self, delete_on_discord, edit_on_discord, forward_image_to_discord, forward_to_discord,
},
matrix::{self, delete_on_matrix, edit_on_matrix, forward_image_to_matrix, forward_to_matrix},
messages::{DeletedMessage, EditedMessage, MessageReference, SentImageMessage, SentMessage},
};
pub struct Bridgers {
pub db: Db,
pub discord: Mutex<RefCell<Option<discord::Context>>>,
pub matrix: Mutex<RefCell<Option<matrix::Client>>>,
}
impl Bridgers {
pub fn new() -> Self {
let db = sled::open("data/phoebe.sled").expect("Failed to open database");
Self {
db,
discord: Mutex::new(RefCell::new(None)),
matrix: Mutex::new(RefCell::new(None)),
}
}
fn get_linked_discord_channel(&self, source: &MessageReference) -> Option<discord::ChannelId> {
let discord_channels = self
.db
.open_tree("discord_channels")
.expect("Failed to open discord channels tree");
match source {
MessageReference::Matrix(room_id, _) => {
let channel_ref = ChannelReference::Matrix(room_id.to_string());
let channel_ref = bincode::serialize(&channel_ref).unwrap();
discord_channels
.get(channel_ref)
.unwrap()
.map(|bytes| bincode::deserialize::<u64>(&bytes).unwrap())
.map(discord::ChannelId)
}
_ => None,
}
}
fn get_linked_matrix_room(&self, source: &MessageReference) -> Option<matrix::RoomId> {
let matrix_channels = self
.db
.open_tree("matrix_channels")
.expect("Failed to open matrix channels tree");
match source {
MessageReference::Discord(channel_id, _) => {
let channel_ref = ChannelReference::Discord(*channel_id);
let channel_ref = bincode::serialize(&channel_ref).unwrap();
matrix_channels
.get(channel_ref)
.unwrap()
.map(|bytes| bincode::deserialize::<String>(&bytes).unwrap())
.as_deref()
.map(matrix::RoomId::from_str)
.map(Result::unwrap)
}
_ => None,
}
}
fn get_related_messages(&self, source: &MessageReference) -> Option<Vec<MessageReference>> {
let message_relations = self
.db
.open_tree("message_relations")
.expect("Failed to open relations tree");
let key = bincode::serialize(source).expect("Failed to serialize message reference");
message_relations
.get(key)
.expect("Failed to retrieve message references")
.map(|r| {
bincode::deserialize::<Vec<MessageReference>>(&r)
.expect("Failed to deserialize message references")
})
}
fn get_related_discord_message(&self, source: &MessageReference) -> Option<(u64, u64)> {
if let MessageReference::Discord(channel_id, message_id) = source {
return Some((*channel_id, *message_id));
}
if let Some(relations) = self.get_related_messages(source) {
for relation in relations {
if let MessageReference::Discord(channel_id, message_id) = relation {
return Some((channel_id, message_id));
}
}
}
None
}
fn get_related_matrix_message(&self, source: &MessageReference) -> Option<matrix::EventId> {
if let MessageReference::Matrix(_, event_id) = source {
return Some(matrix::EventId::from_str(event_id).unwrap());
}
if let Some(relations) = self.get_related_messages(source) {
for relation in relations {
if let MessageReference::Matrix(_, event_id) = relation {
return Some(matrix::EventId::from_str(&event_id).unwrap());
}
}
}
None
}
pub fn link_channels(&self, channels: &[ChannelReference]) {
let discord_channels = self
.db
.open_tree("discord_channels")
.expect("Failed to open discord channels tree");
let matrix_channels = self
.db
.open_tree("matrix_channels")
.expect("Failed to open matrix channels tree");
for channel in channels.iter() {
let other_channels = channels
.iter()
.filter(|r| r != &channel)
.collect::<Vec<_>>();
match channel {
ChannelReference::Discord(channel_id) => {
for other_channel in other_channels {
let key = bincode::serialize(other_channel).unwrap();
let value = bincode::serialize(channel_id).unwrap();
discord_channels.insert(key, value).unwrap();
}
}
ChannelReference::Matrix(room_id) => {
for other_channel in other_channels {
let key = bincode::serialize(other_channel).unwrap();
let value = bincode::serialize(room_id.as_str()).unwrap();
matrix_channels.insert(key, value).unwrap();
}
}
}
}
}
fn store_related_messages(&self, related_messages: &[MessageReference]) {
let tree = self
.db
.open_tree("message_relations")
.expect("Failed to open relations tree");
for source in related_messages {
let relations = related_messages
.iter()
.filter(|r| r != &source)
.collect::<Vec<_>>();
let key = bincode::serialize(source).expect("Failed to serialize message reference");
let value =
bincode::serialize(&relations).expect("Failed to serialize message relations");
tree.insert(key, value)
.expect("Failed to store message relations");
}
}
pub async fn send_message(&self, message: SentMessage) {
if self.get_related_messages(&message.source).is_some() {
return;
}
let mut related_messages = vec![message.source.clone()];
if let Some(discord) = self.discord.lock().unwrap().borrow().as_ref() {
if let Some(channel) = self.get_linked_discord_channel(&message.source) {
let reply = message
.replies_to
.as_ref()
.and_then(|r| self.get_related_discord_message(r));
if let Some(m) = forward_to_discord(discord, channel, &message, reply).await {
related_messages.push(m);
}
}
}
if let Some(matrix) = self.matrix.lock().unwrap().borrow().as_ref() {
if let Some(room_id) = self.get_linked_matrix_room(&message.source) {
let reply = message
.replies_to
.as_ref()
.and_then(|r| self.get_related_matrix_message(r));
if let Some(m) = forward_to_matrix(matrix, room_id, &message, reply).await {
related_messages.push(m);
}
}
}
self.store_related_messages(&related_messages);
}
pub async fn edit_message(&self, message: EditedMessage) {
if let Some(related_messages) = self.get_related_messages(&message.replacing) {
let mut new_related_messages = HashSet::new();
related_messages.iter().for_each(|r| {
new_related_messages.insert(r.clone());
});
for related_message in related_messages.iter() {
match related_message {
MessageReference::Discord(channel_id, message_id) => {
if let Some(discord) = self.discord.lock().unwrap().borrow().as_ref() {
let channel_id = discord::ChannelId(*channel_id);
let message_id = discord::MessageId(*message_id);
if let Some(m) =
edit_on_discord(discord, channel_id, message_id, &message).await
{
new_related_messages.insert(m);
}
}
}
MessageReference::Matrix(room_id, event_id) => {
if let Some(matrix) = self.matrix.lock().unwrap().borrow().as_ref() {
let room_id = matrix::RoomId::from_str(room_id).unwrap();
let event_id = matrix::EventId::from_str(event_id).unwrap();
if let Some(m) =
edit_on_matrix(matrix, room_id, event_id, &message).await
{
new_related_messages.insert(m);
}
}
}
}
}
self.store_related_messages(&new_related_messages.into_iter().collect::<Vec<_>>())
}
}
pub async fn delete_message(&self, message: DeletedMessage) {
if let Some(related_messages) = self.get_related_messages(&message.reference) {
for related_message in related_messages.iter() {
match related_message {
MessageReference::Discord(channel_id, message_id) => {
if let Some(discord) = self.discord.lock().unwrap().borrow().as_ref() {
let channel_id = discord::ChannelId(*channel_id);
let message_id = discord::MessageId(*message_id);
let _success =
delete_on_discord(discord, channel_id, message_id, &message).await;
}
}
MessageReference::Matrix(room_id, event_id) => {
if let Some(matrix) = self.matrix.lock().unwrap().borrow().as_ref() {
let room_id = matrix::RoomId::from_str(room_id).unwrap();
let event_id = matrix::EventId::from_str(event_id).unwrap();
let _success =
delete_on_matrix(matrix, room_id, event_id, &message).await;
}
}
}
}
}
}
pub async fn send_image(&self, message: SentImageMessage) {
if self.get_related_messages(&message.source).is_some() {
return;
}
let mut related_messages = vec![message.source.clone()];
if let Some(discord) = self.discord.lock().unwrap().borrow().as_ref() {
if let Some(discord_channel) = self.get_linked_discord_channel(&message.source) {
if let Some(m) = forward_image_to_discord(discord, discord_channel, &message).await
{
related_messages.push(m);
}
}
}
if let Some(matrix) = self.matrix.lock().unwrap().borrow().as_ref() {
if let Some(room_id) = self.get_linked_matrix_room(&message.source) {
if let Some(m) = forward_image_to_matrix(matrix, room_id, &message).await {
related_messages.push(m);
}
}
}
self.store_related_messages(&related_messages);
}
}

7
src/channels.rs Normal file
View File

@ -0,0 +1,7 @@
use serde::{Deserialize, Serialize};
#[derive(Deserialize, Serialize, Clone, PartialEq)]
pub enum ChannelReference {
Discord(u64),
Matrix(String),
}

385
src/discord.rs Normal file
View File

@ -0,0 +1,385 @@
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
}

112
src/main.rs Normal file
View File

@ -0,0 +1,112 @@
use std::sync::Arc;
use discord::create_discord_client;
use matrix::create_matrix_client;
mod bridgers;
mod channels;
mod message_ast;
mod messages;
pub mod discord;
pub mod matrix;
use bridgers::Bridgers;
use tokio::sync::mpsc;
use crate::{channels::ChannelReference, messages::MessageEvent};
async fn setup_discord(
token: String,
bridgers: Arc<Bridgers>,
discord_tx: mpsc::UnboundedSender<MessageEvent>,
) {
let (discord_ctx_tx, mut discord_ctx_rx) = mpsc::unbounded_channel::<discord::Context>();
tokio::spawn(async move {
let mut discord = create_discord_client(discord_ctx_tx, discord_tx, &token).await;
discord.start().await.unwrap();
});
// Hack to grab the Context object when discord is ready
tokio::spawn(async move {
while let Some(discord) = discord_ctx_rx.recv().await {
bridgers.discord.lock().unwrap().replace(Some(discord));
}
});
}
async fn setup_matrix(
homeserver_url: String,
username: String,
password: String,
bridgers: Arc<Bridgers>,
event_tx: mpsc::UnboundedSender<MessageEvent>,
) {
let client = create_matrix_client(homeserver_url, username, password, event_tx).await;
bridgers
.matrix
.lock()
.unwrap()
.replace(Some(client.clone()));
let settings = matrix::SyncSettings::default().token(client.sync_token().await.unwrap());
tokio::spawn(async move {
client.sync(settings).await;
});
}
#[tokio::main]
async fn main() {
env_logger::init();
let bridgers = Arc::new(Bridgers::new());
bridgers.link_channels(&[
ChannelReference::Discord(844290936942755903),
ChannelReference::Matrix("!NdTsEObHNFnwLyoVvp:lu.gl".to_string()),
]);
let (event_tx, mut event_rx) = tokio::sync::mpsc::unbounded_channel::<MessageEvent>();
#[inline]
fn get_env_var(key: &str) -> String {
std::env::var(key).expect("DISCORD_TOKEN not set in environment")
}
setup_discord(
get_env_var("DISCORD_TOKEN"),
Arc::clone(&bridgers),
event_tx.clone(),
)
.await;
setup_matrix(
get_env_var("MATRIX_HOMESERVER"),
get_env_var("MATRIX_USERNAME"),
get_env_var("MATRIX_PASSWORD"),
Arc::clone(&bridgers),
event_tx.clone(),
)
.await;
while let Some(event) = event_rx.recv().await {
match event {
MessageEvent::SendText(sent_message) => {
bridgers.send_message(*sent_message).await;
}
MessageEvent::EditText(edited_message) => {
bridgers.edit_message(*edited_message).await;
}
MessageEvent::Delete(deleted_message) => {
bridgers.delete_message(*deleted_message).await;
}
MessageEvent::SendImage(sent_media) => {
bridgers.send_image(*sent_media).await;
}
MessageEvent::AdminLinkChannels(channels) => {
bridgers.link_channels(&channels);
}
}
}
}

487
src/matrix.rs Normal file
View File

@ -0,0 +1,487 @@
use std::sync::Arc;
use matrix_sdk::{
config::ClientConfig,
media::MediaEventContent,
room::{Joined, Room},
ruma::{
api,
events::{
self,
room::{
message::{
FormattedBody, MessageEventContent, MessageFormat, MessageType, Relation,
Replacement,
},
redaction::{RedactionEventContent, SyncRedactionEvent},
},
AnyMessageEvent, AnyMessageEventContent, AnyRoomEvent, AnySyncRoomEvent,
SyncMessageEvent,
},
UserId,
},
};
pub use matrix_sdk::{
config::SyncSettings,
ruma::{EventId, RoomId},
Client,
};
use log::info;
use tokio::sync::mpsc;
use url::Url;
use crate::{
message_ast::{
convert_matrix, convert_plain, format_discord, format_matrix, MessageComponent,
MessageContent,
},
messages::{
DeletedMessage, EditedMessage, MessageAuthor, MessageEvent, MessageReference,
SentImageMessage, SentMessage,
},
};
impl From<(&RoomId, &EventId)> for MessageReference {
fn from((room_id, event_id): (&RoomId, &EventId)) -> Self {
let room_string = room_id.as_str().to_string();
let event_string = event_id.as_str().to_string();
Self::Matrix(room_string, event_string)
}
}
fn _find_content(event: &AnySyncRoomEvent) -> Option<AnyMessageEventContent> {
match event {
AnySyncRoomEvent::Message(message) => Some(message.content()),
AnySyncRoomEvent::RedactedMessage(message) => {
if let Some(ref redaction_event) = message.unsigned().redacted_because {
Some(AnyMessageEventContent::RoomRedaction(
redaction_event.content.clone(),
))
} else {
Some(AnyMessageEventContent::RoomRedaction(
RedactionEventContent::new(),
))
}
}
AnySyncRoomEvent::RedactedState(state) => {
if let Some(ref redaction_event) = state.unsigned().redacted_because {
Some(AnyMessageEventContent::RoomRedaction(
redaction_event.content.clone(),
))
} else {
Some(AnyMessageEventContent::RoomRedaction(
RedactionEventContent::new(),
))
}
}
_ => None,
}
}
struct MatrixHandler {
message_tx: mpsc::UnboundedSender<MessageEvent>,
}
impl MatrixHandler {
async fn get_message_author(&self, room: &Joined, user_id: &UserId) -> Option<MessageAuthor> {
if let Ok(Some(sender)) = room.get_member(user_id).await {
Some(MessageAuthor {
display_name: sender
.display_name()
.unwrap_or_else(|| sender.name())
.to_string(),
avatar_url: sender
.avatar_url()
.map(|u| {
format!(
"https://matrix.org/_matrix/media/r0/thumbnail/{}?width=500&height=500",
u.as_str().to_string().replace("mxc://", "")
)
})
.unwrap_or_else(|| {
"data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=="
.to_string()
}),
service_name: "matrix".to_string(),
display_color: None,
})
} else {
None
}
}
fn get_content(&self, body: &str, formatted_body: &Option<FormattedBody>) -> MessageContent {
if let Some(html) = formatted_body
.as_ref()
.filter(|f| f.format == MessageFormat::Html)
.map(|f| &f.body)
{
convert_matrix(html)
} else {
convert_plain(body)
}
}
}
async fn get_room_message_event(
room: &Joined,
event_id: &EventId,
) -> Option<events::MessageEvent<MessageEventContent>> {
let event = room
.event(api::client::r0::room::get_room_event::Request::new(
room.room_id(),
event_id,
))
.await
.ok()?
.event
.deserialize()
.ok()?;
if let AnyRoomEvent::Message(AnyMessageEvent::RoomMessage(message_event)) = event {
Some(message_event)
} else {
None
}
}
async fn on_room_message_event(
client: Client,
ctx: Arc<MatrixHandler>,
event: SyncMessageEvent<MessageEventContent>,
room: Room,
) {
if let Room::Joined(room) = room {
if let Some(Relation::Replacement(replacement)) = &event.content.relates_to {
on_message_edited(ctx, &event, room, replacement).await;
} else {
on_message_sent(client, ctx, &event, room).await;
}
}
}
async fn on_message_sent(
client: Client,
ctx: Arc<MatrixHandler>,
event: &SyncMessageEvent<MessageEventContent>,
room: Joined,
) {
let message_ref = MessageReference::from((room.room_id(), &event.event_id));
if let Some(author) = ctx.get_message_author(&room, &event.sender).await {
let message_event = match &event.content.msgtype {
MessageType::Text(text) => {
let content = ctx.get_content(&text.body, &text.formatted);
let replies_to =
if let Some(Relation::Reply { in_reply_to }) = &event.content.relates_to {
Some(MessageReference::from((
room.room_id(),
&in_reply_to.event_id,
)))
} else {
None
};
Some(MessageEvent::SendText(Box::new(SentMessage {
source: message_ref,
content,
author,
replies_to,
})))
}
MessageType::Emote(emote) => {
let mut content = ctx.get_content(&emote.body, &emote.formatted);
content.insert(0, MessageComponent::Plain("* ".to_string()));
Some(MessageEvent::SendText(Box::new(SentMessage {
source: message_ref,
content,
author,
replies_to: None,
})))
}
MessageType::Image(content) => {
if let Some(file) = content.file() {
match file {
matrix_sdk::media::MediaType::Uri(uri) => {
if let Some((server_name, hash)) = uri.parts() {
let server_name = server_name.as_str();
let image_url = format!(
"https://{}/_matrix/media/r0/download/{}/{}",
server_name, server_name, hash
);
Some(MessageEvent::SendImage(Box::new(SentImageMessage {
source: message_ref,
author,
image_url,
})))
} else {
None
}
}
matrix_sdk::media::MediaType::Encrypted(encrypted_file) => {
if let Some(file_data) =
client.get_file(content.clone(), true).await.ok().flatten()
{
todo!("Reupload encrypted file to publicly accessible URL")
} else {
None
}
}
}
} else {
None
}
}
// TODO: Handle reactions, more uploads (audio, video, file), and any other types of event
_ => None,
};
if let Some(e) = message_event {
let _ = ctx.message_tx.send(e);
}
}
let _ = room.read_receipt(&event.event_id).await;
}
async fn on_message_edited(
ctx: Arc<MatrixHandler>,
event: &SyncMessageEvent<MessageEventContent>,
room: Joined,
replacement: &Replacement,
) {
let message_ref = MessageReference::from((room.room_id(), &replacement.event_id));
if let MessageType::Text(text) = &replacement.new_content.msgtype {
let content = ctx.get_content(&text.body, &text.formatted);
if let Some(author) = ctx.get_message_author(&room, &event.sender).await {
let _ = ctx
.message_tx
.send(MessageEvent::EditText(Box::new(EditedMessage {
replacing: message_ref,
content,
author,
})));
}
}
}
async fn on_redact_event(ctx: Arc<MatrixHandler>, event: SyncRedactionEvent, room: Room) {
let message_ref = MessageReference::from((room.room_id(), &event.redacts));
let _ = ctx
.message_tx
.send(MessageEvent::Delete(Box::new(DeletedMessage {
reference: message_ref,
})));
}
fn generate_html_content(content: &[MessageComponent], author: &MessageAuthor) -> (String, String) {
let plaintext_message = format!(
"{} ({}): {}",
&author.display_name,
&author.service_name,
format_discord(content)
);
let html_message = format!(
r##"<strong><font data-mx-color="#{}">{} <small>({})</small></font>:</strong> {}"##,
author.display_color.as_deref().unwrap_or("ffffff"),
&author.display_name,
&author.service_name,
format_matrix(content),
);
(plaintext_message, html_message)
}
fn generate_event_content_struct(
replied_message_event: Option<&events::MessageEvent<MessageEventContent>>,
plaintext_content: String,
html_content: String,
) -> MessageEventContent {
if let Some(replied_message_event) = replied_message_event {
MessageEventContent::text_reply_html(plaintext_content, html_content, replied_message_event)
} else {
MessageEventContent::text_html(plaintext_content, html_content)
}
}
pub async fn forward_to_matrix(
client: &Client,
room_id: RoomId,
message: &SentMessage,
replying_to: Option<EventId>,
) -> Option<MessageReference> {
if let Some(room) = client.get_joined_room(&room_id) {
let replied_message_event = if let Some(event_id) = replying_to {
get_room_message_event(&room, &event_id).await
} else {
None
};
let (plaintext_content, html_content) =
generate_html_content(&message.content, &message.author);
let content = generate_event_content_struct(
replied_message_event.as_ref(),
plaintext_content,
html_content,
);
let event = room
.send(AnyMessageEventContent::RoomMessage(content), None)
.await
.ok()?;
return Some(MessageReference::from((&room_id, &event.event_id)));
}
None
}
pub async fn forward_image_to_matrix(
client: &Client,
room_id: RoomId,
message: &SentImageMessage,
) -> Option<MessageReference> {
if let Some(room) = client.get_joined_room(&room_id) {
let image_url = message.image_url.clone();
let mut response = tokio::task::spawn_blocking(|| reqwest::blocking::get(image_url).ok())
.await
.ok()
.flatten()?;
let event = room
.send_attachment(
&message.image_url,
&mime_guess::from_path(&message.image_url).first_or_octet_stream(),
&mut response,
None,
)
.await
.ok()?;
return Some(MessageReference::from((&room_id, &event.event_id)));
}
None
}
pub async fn edit_on_matrix(
client: &Client,
room_id: RoomId,
event_id: EventId,
message: &EditedMessage,
) -> Option<MessageReference> {
if let Some(room) = client.get_joined_room(&room_id) {
if let Some(original_message_event) = get_room_message_event(&room, &event_id).await {
if let Some(Relation::Replacement(_)) = &original_message_event.content.relates_to {
return None;
}
let replied_message_event = if let Some(Relation::Reply { in_reply_to }) =
&original_message_event.content.relates_to
{
get_room_message_event(&room, &in_reply_to.event_id).await
} else {
None
};
let (plaintext_content, html_content) =
generate_html_content(&message.content, &message.author);
let mut edit_content = generate_event_content_struct(
replied_message_event.as_ref(),
format!("* {}", &plaintext_content),
format!("* {}", &html_content),
);
let basic_content = generate_event_content_struct(
replied_message_event.as_ref(),
plaintext_content,
html_content,
);
edit_content.relates_to = Some(Relation::Replacement(Replacement::new(
event_id,
Box::new(basic_content),
)));
let new_event = room
.send(AnyMessageEventContent::RoomMessage(edit_content), None)
.await
.ok()?;
return Some(MessageReference::from((&room_id, &new_event.event_id)));
}
}
None
}
pub async fn delete_on_matrix(
client: &Client,
room_id: RoomId,
event_id: EventId,
_message: &DeletedMessage,
) -> bool {
if let Some(room) = client.get_joined_room(&room_id) {
room.redact(&event_id, None, None).await.is_ok()
} else {
false
}
}
pub async fn create_matrix_client(
homeserver_url: String,
username: String,
password: String,
message_tx: mpsc::UnboundedSender<MessageEvent>,
) -> Client {
let client_config = ClientConfig::new().store_path("./data/matrix_state");
let homeserver_url =
Url::parse(&homeserver_url).expect("Failed to parse the matrix homeserver URL");
let client = Client::new_with_config(homeserver_url, client_config).unwrap();
info!("Matrix logging in…");
client
.login(&username, &password, None, Some("phoebe"))
.await
.expect("Failed to log in");
info!("Matrix starting…");
client.sync_once(SyncSettings::default()).await.unwrap();
let event_handler = Arc::new(MatrixHandler { message_tx });
{
let on_msg_ctx = event_handler.clone();
let client_2 = client.clone();
client
.register_event_handler(move |ev, room| {
on_room_message_event(client_2.clone(), on_msg_ctx.clone(), ev, room)
})
.await;
}
let on_redact_ctx = event_handler.clone();
client
.register_event_handler(move |ev, room| on_redact_event(on_redact_ctx.clone(), ev, room))
.await;
client
}

View File

@ -0,0 +1,87 @@
use super::{MessageComponent, MessageContent};
use discord_message_format::DiscordComponent;
impl<'a> From<&DiscordComponent<'a>> for MessageComponent {
fn from(discord: &DiscordComponent<'a>) -> Self {
match discord {
DiscordComponent::Plain(text) => Self::Plain(text.to_string()),
DiscordComponent::Literal(char) => Self::Plain(char.to_string()),
DiscordComponent::Link(link) => Self::Link {
target: link.to_string(),
text: vec![Self::Plain(link.to_string())],
},
DiscordComponent::Bold(content) => Self::Bold(convert_discord(content)),
DiscordComponent::Italic(content) => Self::Italic(convert_discord(content)),
DiscordComponent::Strikethrough(content) => {
Self::Strikethrough(convert_discord(content))
}
DiscordComponent::Underline(content) => Self::Underline(convert_discord(content)),
DiscordComponent::Code(code) => Self::Code(code.to_string()),
DiscordComponent::CodeBlock { lang, source } => Self::CodeBlock {
lang: lang.map(|s| s.to_string()),
source: source.to_string(),
},
DiscordComponent::Spoiler(content) => Self::Spoiler {
reason: None,
content: convert_discord(content),
},
DiscordComponent::LineBreak => Self::HardBreak,
DiscordComponent::Quote(content) => Self::BlockQuote(convert_discord(content)),
}
}
}
pub fn convert_discord(discord_message: &[DiscordComponent<'_>]) -> MessageContent {
discord_message
.iter()
.map(MessageComponent::from)
.collect::<MessageContent>()
}
pub fn format_discord(message_content: &[MessageComponent]) -> String {
message_content
.iter()
.map(|component| match component {
MessageComponent::Plain(text) => text.to_string(), // TODO: Escape
MessageComponent::Link { target, text } => {
let formatted_text = format_discord(text);
// TODO: Maybe tolerate a missing http(s) URL scheme?
if &formatted_text == target {
formatted_text
} else {
format!("{} ({})", formatted_text, target)
}
}
MessageComponent::Italic(inner) => format!("*{}*", format_discord(inner)),
MessageComponent::Bold(inner) => format!("**{}**", format_discord(inner)),
MessageComponent::Strikethrough(inner) => format!("~~{}~~", format_discord(inner)),
MessageComponent::Underline(inner) => format!("__{}__", format_discord(inner)),
MessageComponent::Code(code) => format!("`{}`", code), // TODO: Double-grave delimiting when code contains '`'
MessageComponent::CodeBlock { lang, source } => {
format!(
"```{}\n{}\n```",
lang.as_ref()
.map(|s| s.to_string())
.unwrap_or_else(|| "".to_string()),
source.to_string()
)
}
MessageComponent::Spoiler { content, .. } => format!("||{}||", format_discord(content)), // TODO: Spoiler reason
MessageComponent::HardBreak => "\n".to_string(),
MessageComponent::BlockQuote(inner) => format_discord(inner)
.lines()
.map(|l| format!("> {}\n", l))
.collect(),
})
.collect()
}

View File

@ -0,0 +1,291 @@
use html5ever::{local_name, namespace_url, ns, LocalName, QualName};
use kuchiki::{iter::NodeEdge, traits::*, NodeData};
use super::{MessageComponent, MessageContent};
pub fn convert_matrix(message: &str) -> MessageContent {
let dom = kuchiki::parse_fragment(
QualName::new(None, ns!(html), LocalName::from("div")),
vec![],
)
.one(message);
let mut parents = vec![];
let mut components = vec![];
let mut skip_text = 0;
let mut skip_all = 0;
for edge in dom.traverse() {
match edge {
NodeEdge::Start(node) => {
if let NodeData::Element(element) = node.data() {
if element.name.local == *"mx-reply" {
skip_all += 1;
}
if skip_all > 0 {
continue;
}
if element.name.ns == ns!(html) {
match element.name.local {
local_name!("strong")
| local_name!("b")
| local_name!("em")
| local_name!("i")
| local_name!("s")
| local_name!("u")
| local_name!("a")
| local_name!("blockquote") => {
parents.push(components);
components = vec![];
}
local_name!("span") => {
let attrs = element.attributes.borrow();
if attrs.get("data-mx-spoiler").is_some() {
parents.push(components);
components = vec![];
}
}
local_name!("code") => {
skip_text += 1;
}
_ => {}
}
}
}
}
NodeEdge::End(node) => match node.data() {
NodeData::Text(text) => {
if skip_text <= 0 && skip_all <= 0 {
let text = text.borrow().lines().collect::<Vec<_>>().join(" ");
components.push(MessageComponent::Plain(text));
}
}
NodeData::Element(element) => {
if element.name.local == *"mx-reply" {
skip_all -= 1;
}
if skip_all > 0 {
continue;
}
macro_rules! construct_component {
($f:expr) => {{
let component_type = $f;
if let Some(mut parent_components) = parents.pop() {
parent_components.push((component_type)(components));
components = parent_components;
}
}};
}
if element.name.ns == ns!(html) {
match element.name.local {
local_name!("strong") | local_name!("b") => {
construct_component!(MessageComponent::Bold)
}
local_name!("em") | local_name!("i") => {
construct_component!(MessageComponent::Italic)
}
local_name!("s") => {
construct_component!(MessageComponent::Strikethrough)
}
local_name!("u") => {
construct_component!(MessageComponent::Underline)
}
local_name!("a") => {
if let Some(mut parent_components) = parents.pop() {
let attrs = element.attributes.borrow();
if let Some(href) = attrs.get(local_name!("href")) {
parent_components.push(MessageComponent::Link {
target: href.to_string(),
text: components,
});
} else {
parent_components.append(&mut components);
}
components = parent_components;
}
}
local_name!("br") | local_name!("p") => {
components.push(MessageComponent::HardBreak);
}
local_name!("blockquote") => {
construct_component!(MessageComponent::BlockQuote)
}
local_name!("span") => {
let attrs = element.attributes.borrow();
if let Some(spoiler_reason) = attrs.get("data-mx-spoiler") {
construct_component!(|inner| MessageComponent::Spoiler {
reason: (!spoiler_reason.is_empty())
.then(|| spoiler_reason.to_string()),
content: inner,
})
}
}
local_name!("code") => {
// is_code_block = whether we are the child of a <pre> tag
let is_code_block = node
.parent()
.as_ref()
.map(|p| p.data())
.and_then(|d| match d {
NodeData::Element(e) => {
Some(e.name.local == local_name!("pre"))
}
_ => None,
})
.unwrap_or(false);
components.push(if is_code_block {
let attrs = element.attributes.borrow();
let lang = attrs
.get(local_name!("class"))
.and_then(|lang| lang.strip_prefix("language-"))
.map(|s| s.to_string());
MessageComponent::CodeBlock {
lang,
source: node.text_contents(),
}
} else {
MessageComponent::Code(node.text_contents())
});
skip_text -= 1;
}
_ => {}
}
}
}
_ => {}
},
};
}
components
}
pub fn format_matrix(message_content: &[MessageComponent]) -> String {
message_content
.iter()
.map(|component| match component {
MessageComponent::Plain(text) => html_escape::encode_text(text).to_string(),
MessageComponent::Link { target, text } => format!(
r#"<a href="{}">{}</a>"#,
html_escape::encode_quoted_attribute(target),
format_matrix(text)
),
MessageComponent::Italic(inner) => format!("<em>{}</em>", format_matrix(inner)),
MessageComponent::Bold(inner) => format!("<strong>{}</strong>", format_matrix(inner)),
MessageComponent::Strikethrough(inner) => {
format!("<del>{}</del>", format_matrix(inner))
}
MessageComponent::Underline(inner) => format!("<u>{}</u>", format_matrix(inner)),
MessageComponent::Code(code) => {
format!("<code>{}</code>", html_escape::encode_text(code))
}
MessageComponent::CodeBlock { lang, source } => {
format!(
r#"<pre><code{}>{}</code></pre>"#,
lang.as_ref()
.map(|lang| format!(
r#" class="language-{}""#,
html_escape::encode_quoted_attribute(lang)
))
.unwrap_or_else(|| "".to_string()),
source,
)
}
MessageComponent::Spoiler { reason, content } => format!(
"<span data-mx-spoiler{}>{}</span>",
reason
.as_ref()
.map(|reason| format!(r#"="{}""#, html_escape::encode_quoted_attribute(reason)))
.unwrap_or_else(|| "".to_string()),
format_matrix(content)
),
MessageComponent::HardBreak => "<br>".to_string(),
MessageComponent::BlockQuote(inner) => {
format!("<blockquote>{}</blockquote>", format_matrix(inner))
}
})
.collect()
}
#[test]
fn simple_parsing() {
use MessageComponent::*;
let html =
r#"<strong>hello! <i>&lt;&gt;</i></strong> <a href="https://example.com/">example</a>"#;
assert_eq!(
convert_matrix(html),
vec![
Bold(vec![
Plain("hello! ".to_string(),),
Italic(vec![Plain("<>".to_string())]),
]),
Plain(" ".to_string()),
Link {
target: "https://example.com/".to_string(),
text: vec![Plain("example".to_string())]
},
]
)
}
#[test]
fn spoiler_parsing() {
use MessageComponent::*;
let html = r#"<span data-mx-spoiler>the <em>whole</em> island is populated by lesbians</span>"#;
assert_eq!(
convert_matrix(html),
vec![Spoiler {
reason: None,
content: vec![
Plain("the ".to_string()),
Italic(vec![Plain("whole".to_string())]),
Plain(" island is populated by lesbians".to_string())
]
}]
);
}
#[test]
fn code_parsing() {
use MessageComponent::*;
let html = r#"<code>hello_world();</code>"#;
assert_eq!(
convert_matrix(html),
vec![Code("hello_world();".to_string())]
);
let html = r#"<pre><code class="language-javascript">console.log("hello, world!");
console.table({ a: 1, b: 2, c: 3 });
</code></pre>"#;
assert_eq!(
convert_matrix(html),
vec![CodeBlock {
lang: Some("javascript".to_string()),
source: "console.log(\"hello, world!\");\nconsole.table({ a: 1, b: 2, c: 3 });\n"
.to_string(),
}]
);
}

View File

@ -0,0 +1,5 @@
use super::{MessageComponent, MessageContent};
pub fn convert_plain(message: &str) -> MessageContent {
vec![MessageComponent::Plain(message.to_string())]
}

37
src/message_ast/mod.rs Normal file
View File

@ -0,0 +1,37 @@
mod convert_discord;
mod convert_matrix;
mod convert_plain;
pub type MessageContent = Vec<MessageComponent>;
#[derive(Debug, PartialEq, Eq)]
pub enum MessageComponent {
Plain(String),
Link {
target: String,
text: MessageContent,
},
Italic(MessageContent),
Bold(MessageContent),
Strikethrough(MessageContent),
Underline(MessageContent),
Code(String),
CodeBlock {
lang: Option<String>,
source: String,
},
Spoiler {
reason: Option<String>,
content: MessageContent,
},
HardBreak,
BlockQuote(MessageContent),
}
pub use convert_discord::{convert_discord, format_discord};
pub use convert_matrix::{convert_matrix, format_matrix};
pub use convert_plain::convert_plain;

47
src/messages.rs Normal file
View File

@ -0,0 +1,47 @@
use serde::{Deserialize, Serialize};
use crate::{channels::ChannelReference, message_ast::MessageContent};
#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Hash)]
pub enum MessageReference {
Discord(u64, u64),
Matrix(String, String),
}
pub struct MessageAuthor {
pub display_name: String,
pub avatar_url: String,
pub service_name: String,
pub display_color: Option<String>,
}
pub struct SentMessage {
pub source: MessageReference,
pub content: MessageContent,
pub author: MessageAuthor,
pub replies_to: Option<MessageReference>,
}
pub struct EditedMessage {
pub replacing: MessageReference,
pub content: MessageContent,
pub author: MessageAuthor,
}
pub struct SentImageMessage {
pub source: MessageReference,
pub author: MessageAuthor,
pub image_url: String,
}
pub struct DeletedMessage {
pub reference: MessageReference,
}
pub enum MessageEvent {
AdminLinkChannels(Vec<ChannelReference>),
SendText(Box<SentMessage>),
EditText(Box<EditedMessage>),
SendImage(Box<SentImageMessage>),
Delete(Box<DeletedMessage>),
}