From e284fa6d6dead42fa503a9b8cb8bb9ec0c88008b Mon Sep 17 00:00:00 2001 From: lily Date: Fri, 31 Jul 2026 22:39:01 -0400 Subject: [PATCH] push push push --- ..env.un~ | Bin 0 -> 4648 bytes .env.example | 11 + .gitignore | 2 + Cargo.lock | 650 ++++++++++++++++++++++++++++++++++++++++++-- Cargo.toml | 4 +- docker-compose.yaml | 4 +- src/bridge.rs | 58 ++++ src/db.rs | 53 ++++ src/discord.rs | 43 +++ src/main.rs | 72 ++++- src/matrix.rs | 15 + 11 files changed, 887 insertions(+), 25 deletions(-) create mode 100644 ..env.un~ create mode 100644 .env.example create mode 100644 src/bridge.rs create mode 100644 src/db.rs create mode 100644 src/discord.rs diff --git a/..env.un~ b/..env.un~ new file mode 100644 index 0000000000000000000000000000000000000000..b8337f70cb1fe075b81986533d0188a0d3ffe65f GIT binary patch literal 4648 zcmeI0K}!Nb6vszhwE`8iLzhURF43jJI)n#tg(P*sEGz=Mg~sNgt{sd{9XoaF-Zkhu zbo0`&=n{Q^h>rFCv-@aFje_Pj51!+^nH`4rd;i_p-DG$Sp=t^X2ssEJNJ0k z4WC_vPH#Gs_nFAf;`_tpYiViqTnG^*Pn2`DYO&s^I!?J!w6h$hHb6c`4nxZ>TJG+O zg;7g1x)>!3dX4<`hWx+>nOsVlVE+DR23l@?U*ZtrZGslIl*NIfKPoaE2@aznWi}v& z$zzBaaL9sABXG^1z%AItjl5H}8#QOWP?=A2H@}vvR0<`M>wLObzCKrQRI31tBji$~ zhO17dSev*W4C4v}F$O8QQec_`!^uizLz`^S?#v%*0yUsskNQAOjFTI!BMDlKYQj}( zLPY_TzbFA;+zgJliKLL#6z_ Result { + if let Some((mxid, joined)) = state.db.get_ghost(discord_user_id).await? { + if !joined { + state.matrix.join_room(&state.matrix_room_id, &mxid).await?; + state.db.mark_joined(discord_user_id).await?; + } + return Ok(mxid); + } + + let localpart = format!("_discord_{}", discord_user_id); + let mxid = format!("@{}:{}", localpart, state.matrix_domain); + + state.matrix.register_ghost(&localpart).await?; + if let Err(e) = state.matrix.set_display_name(&mxid, display_name).await { + tracing::warn!("failed to set ghost display name for {}: {}", mxid, e); + } + state.matrix.join_room(&state.matrix_room_id, &mxid).await?; + + state.db.insert_ghost(discord_user_id, &mxid).await?; + state.db.mark_joined(discord_user_id).await?; + + tracing::info!( + "registered ghost {} for discord user {}", + mxid, + discord_user_id + ); + Ok(mxid) +} + +/// Discord -> Matrix: puppet the sending discord user via their ghost. +pub async fn forward_to_matrix(state: &AppState, msg: &DiscordMessage) -> Result<()> { + let mxid = get_or_create_ghost(state, &msg.author.id.to_string(), &msg.author.name).await?; + state + .matrix + .send_text(&state.matrix_room_id, &mxid, &msg.content) + .await?; + Ok(()) +} + +/// Matrix -> Discord: relay as the bridge bot, prefixed with the matrix sender. +pub async fn forward_to_discord(state: &AppState, sender: &str, body: &str) -> Result<()> { + state + .discord_channel_id + .say(&state.discord_http, format!("**{}**: {}", sender, body)) + .await?; + Ok(()) +} diff --git a/src/db.rs b/src/db.rs new file mode 100644 index 0000000..745a3d2 --- /dev/null +++ b/src/db.rs @@ -0,0 +1,53 @@ +use rusqlite::{Connection, OptionalExtension, params}; +use tokio::sync::Mutex; + +pub struct Db { + conn: Mutex, +} + +impl Db { + pub fn open(path: &str) -> rusqlite::Result { + let conn = Connection::open(path)?; + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS ghosts ( + discord_user_id TEXT PRIMARY KEY, + mxid TEXT NOT NULL, + joined_room INTEGER NOT NULL DEFAULT 0 + );", + )?; + Ok(Self { + conn: Mutex::new(conn), + }) + } + + pub async fn get_ghost( + &self, + discord_user_id: &str, + ) -> rusqlite::Result> { + let conn = self.conn.lock().await; + conn.query_row( + "SELECT mxid, joined_room FROM ghosts WHERE discord_user_id = ?1", + params![discord_user_id], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)? != 0)), + ) + .optional() + } + + pub async fn insert_ghost(&self, discord_user_id: &str, mxid: &str) -> rusqlite::Result<()> { + let conn = self.conn.lock().await; + conn.execute( + "INSERT OR IGNORE INTO ghosts (discord_user_id, mxid, joined_room) VALUES (?1, ?2, 0)", + params![discord_user_id, mxid], + )?; + Ok(()) + } + + pub async fn mark_joined(&self, discord_user_id: &str) -> rusqlite::Result<()> { + let conn = self.conn.lock().await; + conn.execute( + "UPDATE ghosts SET joined_room = 1 WHERE discord_user_id = ?1", + params![discord_user_id], + )?; + Ok(()) + } +} diff --git a/src/discord.rs b/src/discord.rs new file mode 100644 index 0000000..69a7bf5 --- /dev/null +++ b/src/discord.rs @@ -0,0 +1,43 @@ +use std::sync::Arc; + +use serenity::async_trait; +use serenity::model::channel::Message; +use serenity::model::gateway::Ready; +use serenity::prelude::*; + +use crate::AppState; +use crate::bridge; + +pub struct Handler { + pub state: Arc, +} + +#[async_trait] +impl EventHandler for Handler { + async fn ready(&self, _ctx: Context, ready: Ready) { + tracing::info!("discord bot connected as {}", ready.user.name); + } + + async fn message(&self, _ctx: Context, msg: Message) { + if msg.author.bot { + return; + } + tracing::info!( + "discord [#{}] {}: {}", + msg.channel_id, + msg.author.name, + msg.content + ); + + if msg.channel_id != self.state.discord_channel_id { + return; + } + + let state = self.state.clone(); + tokio::spawn(async move { + if let Err(e) = bridge::forward_to_matrix(&state, &msg).await { + tracing::error!("failed to bridge discord->matrix: {}", e); + } + }); + } +} diff --git a/src/main.rs b/src/main.rs index c4d10f2..9a53116 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,6 +6,9 @@ use axum::{ }; use serde::Deserialize; use serde_json::{Value, json}; +use serenity::http::Http; +use serenity::model::id::ChannelId; +use serenity::prelude::*; use std::sync::Arc; #[derive(Deserialize)] @@ -18,6 +21,9 @@ struct Transaction { events: Vec, } +mod bridge; +mod db; +mod discord; mod matrix; use matrix::MatrixClient; @@ -31,6 +37,11 @@ struct AppState { hs_token: String, bot_mxid: String, matrix: MatrixClient, + matrix_room_id: String, + matrix_domain: String, + discord_http: Arc, + discord_channel_id: ChannelId, + db: db::Db, } #[tokio::main] @@ -40,11 +51,45 @@ async fn main() { let hs_token = env_var("HS_TOKEN"); let as_token = env_var("AS_TOKEN"); let bot_mxid = env_var("BOT_MXID"); + let discord_token = env_var("DISCORD_TOKEN"); + let discord_channel_id: ChannelId = env_var("DISCORD_CHANNEL_ID") + .parse() + .expect("DISCORD_CHANNEL_ID must be a numeric discord channel id"); + let matrix_room_id = env_var("MATRIX_ROOM_ID"); + let db_path = env::var("DB_PATH").unwrap_or_else(|_| "bridge.db".to_string()); + + let matrix_domain = bot_mxid + .split(':') + .nth(1) + .expect("BOT_MXID must be in the form @user:domain") + .to_string(); + + let db = db::Db::open(&db_path).expect("failed to open sqlite db"); + let discord_http = Arc::new(Http::new(&discord_token)); let state = Arc::new(AppState { hs_token, bot_mxid, matrix: MatrixClient::new(hs_url, as_token), + matrix_room_id, + matrix_domain, + discord_http, + discord_channel_id, + db, + }); + + let intents = GatewayIntents::GUILD_MESSAGES | GatewayIntents::MESSAGE_CONTENT; + let mut discord_client = Client::builder(&discord_token, intents) + .event_handler(discord::Handler { + state: state.clone(), + }) + .await + .expect("failed to build discord client"); + + tokio::spawn(async move { + if let Err(e) = discord_client.start().await { + tracing::error!("discord client error: {}", e); + } }); let app = Router::new() @@ -74,7 +119,7 @@ async fn transactions( check_auth(&auth, &state.hs_token)?; tracing::info!("txn {} with {} events", txn_id, body.events.len()); for ev in &body.events { - if let Err(e) = handle_event(&state.matrix, &state.bot_mxid, ev).await { + if let Err(e) = handle_event(&state, ev).await { tracing::error!("error handling event: {}", e); } } @@ -101,17 +146,36 @@ async fn query_alias( Err(StatusCode::NOT_FOUND) } -async fn handle_event(client: &MatrixClient, bot_mxid: &str, ev: &Value) -> anyhow::Result<()> { +async fn handle_event(state: &AppState, ev: &Value) -> anyhow::Result<()> { let ev_type = ev["type"].as_str().unwrap_or(""); let room_id = ev["room_id"].as_str().unwrap_or(""); + let sender = ev["sender"].as_str().unwrap_or(""); + let bot_mxid = state.bot_mxid.as_str(); if ev_type == "m.room.member" && ev["state_key"].as_str() == Some(bot_mxid) && ev["content"]["membership"].as_str() == Some("invite") { tracing::info!("joining {} as {}", room_id, bot_mxid); - client.join_room(room_id, bot_mxid).await?; - client.send_text(room_id, bot_mxid, "hi, i'm alive").await?; + state.matrix.join_room(room_id, bot_mxid).await?; + state + .matrix + .send_text(room_id, bot_mxid, "hi, i'm alive") + .await?; } + + // Matrix -> Discord: relay messages from the bridged room, skipping our own + // ghosts and the bridge bot itself to avoid an echo loop. + if ev_type == "m.room.message" + && room_id == state.matrix_room_id + && sender != bot_mxid + && !sender.starts_with("@_discord_") + { + let body = ev["content"]["body"].as_str().unwrap_or(""); + if !body.is_empty() { + bridge::forward_to_discord(state, sender, body).await?; + } + } + Ok(()) } diff --git a/src/matrix.rs b/src/matrix.rs index f0d3374..1c8cf25 100644 --- a/src/matrix.rs +++ b/src/matrix.rs @@ -71,6 +71,21 @@ impl MatrixClient { Ok(res["event_id"].as_str().unwrap_or("").to_string()) } + pub async fn set_display_name(&self, user_id: &str, name: &str) -> Result<()> { + let path = format!( + "/_matrix/client/v3/profile/{}/displayname", + urlencoding::encode(user_id) + ); + self.req( + reqwest::Method::PUT, + &path, + Some(user_id), + Some(json!({ "displayname": name })), + ) + .await?; + Ok(()) + } + pub async fn register_ghost(&self, localpart: &str) -> Result<()> { let body = json!({ "type": "m.login.application_service",