Compare commits

...

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

42 changed files with 1195 additions and 5173 deletions

12
.editorconfig Normal file
View File

@ -0,0 +1,12 @@
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 +1,2 @@
/target
/Cargo.lock

3377
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,26 +1,7 @@
[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"]
[workspace]
members = [
"phoebe",
"phoebe-main",
"mid-chat",
"services/*"
]

View File

@ -1,3 +1,11 @@
# phoebe
bridgers
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

4
data/.gitignore vendored
View File

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

6
mid-chat/Cargo.toml Normal file
View File

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

30
mid-chat/src/content.rs Normal file
View File

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

8
mid-chat/src/event.rs Normal file
View File

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

44
mid-chat/src/lib.rs Normal file
View File

@ -0,0 +1,44 @@
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;

20
mid-chat/src/reference.rs Normal file
View File

@ -0,0 +1,20 @@
#[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(),
}
}
}

13
phoebe-main/Cargo.toml Normal file
View File

@ -0,0 +1,13 @@
[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"

137
phoebe-main/src/main.rs Normal file
View File

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

1
phoebe/.env Normal file
View File

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

14
phoebe/Cargo.toml Normal file
View File

@ -0,0 +1,14 @@
[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"

3
phoebe/build.rs Normal file
View File

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

View File

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

@ -0,0 +1,8 @@
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;

25
phoebe/src/attachments.rs Normal file
View File

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

26
phoebe/src/db.rs Normal file
View File

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

179
phoebe/src/lib.rs Normal file
View File

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

7
phoebe/src/prelude.rs Normal file
View File

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

18
phoebe/src/service.rs Normal file
View File

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

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

View File

@ -0,0 +1,13 @@
[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

@ -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
)
}
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,147 @@
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

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

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

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

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

View File

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

View File

@ -1,37 +0,0 @@
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;

View File

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