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
|
/target
|
||||||
/Cargo.lock
|
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
33
Cargo.toml
33
Cargo.toml
|
@ -1,7 +1,26 @@
|
||||||
[workspace]
|
[package]
|
||||||
members = [
|
name = "phoebe"
|
||||||
"phoebe",
|
version = "0.1.0"
|
||||||
"phoebe-main",
|
edition = "2018"
|
||||||
"mid-chat",
|
|
||||||
"services/*"
|
[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
|
# phoebe
|
||||||
|
|
||||||
bridgers [primarily, discord ↔ matrix]
|
bridgers
|
||||||
|
|
||||||
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
|
|
||||||
|
|
|
@ -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