Compare commits
No commits in common. "main" and "legacy" have entirely different histories.
|
@ -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,2 +1 @@
|
|||
/target
|
||||
/Cargo.lock
|
||||
|
|
File diff suppressed because it is too large
Load Diff
33
Cargo.toml
33
Cargo.toml
|
@ -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"]
|
||||
|
|
10
README.md
10
README.md
|
@ -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
|
||||
|
|
|
@ -1,2 +1,2 @@
|
|||
*
|
||||
!/.gitignore
|
||||
*
|
||||
!.gitignore
|
||||
|
|
|
@ -1,6 +0,0 @@
|
|||
[package]
|
||||
name = "mid-chat"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
|
@ -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),
|
||||
}
|
|
@ -1,8 +0,0 @@
|
|||
use crate::{ChatMessage, ChatMessageEdit, ChatMessageReference};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ChatEvent {
|
||||
NewMessage(Box<ChatMessage>),
|
||||
DeleteMessage(ChatMessageReference),
|
||||
EditMessage(ChatMessageReference, Box<ChatMessageEdit>),
|
||||
}
|
|
@ -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;
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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"
|
|
@ -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(())
|
||||
}
|
|
@ -1 +0,0 @@
|
|||
DATABASE_URL="sqlite://${PHOEBE_DB_ROOT}/main.db"
|
|
@ -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"
|
|
@ -1,3 +0,0 @@
|
|||
fn main() {
|
||||
println!("cargo:rerun-if-changed=migrations");
|
||||
}
|
|
@ -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;
|
|
@ -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;
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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};
|
|
@ -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>;
|
||||
}
|
|
@ -1 +0,0 @@
|
|||
DATABASE_URL="sqlite://${PHOEBE_DB_ROOT}/discord_media.db"
|
|
@ -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"
|
|
@ -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(),
|
||||
}
|
||||
}
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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![]
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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(())
|
||||
}
|
|
@ -1,7 +0,0 @@
|
|||
[package]
|
||||
name = "phoebe-matrix"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
phoebe = { path = "../../phoebe" }
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, PartialEq)]
|
||||
pub enum ChannelReference {
|
||||
Discord(u64),
|
||||
Matrix(String),
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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()
|
||||
}
|
|
@ -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><></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(),
|
||||
}]
|
||||
);
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
use super::{MessageComponent, MessageContent};
|
||||
|
||||
pub fn convert_plain(message: &str) -> MessageContent {
|
||||
vec![MessageComponent::Plain(message.to_string())]
|
||||
}
|
|
@ -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;
|
|
@ -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>),
|
||||
}
|
Loading…
Reference in New Issue