├── .gitignore ├── scripts ├── install_commit_hook.sh └── commit_hook ├── src ├── main.rs ├── args.rs ├── matrix │ ├── time.rs │ ├── outgoing.rs │ ├── login.rs │ ├── mod.rs │ ├── sync_room_member.rs │ ├── invite.rs │ ├── sync_reaction.rs │ ├── sync_room_message.rs │ ├── verification.rs │ └── room_mappings.rs ├── ircd │ ├── chan.rs │ ├── client.rs │ ├── mod.rs │ ├── proto.rs │ └── login.rs ├── matrirc.rs └── state.rs ├── .github └── workflows │ └── ci.yml ├── README.md └── Cargo.toml /.gitignore: -------------------------------------------------------------------------------- 1 | /target/ 2 | **/*.rs.bk 3 | config.toml 4 | -------------------------------------------------------------------------------- /scripts/install_commit_hook.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | command -v rustfmt >/dev/null || error "Install rustfmt first" 4 | 5 | src="$(git rev-parse --show-toplevel)/scripts/commit_hook" 6 | dest="$(git rev-parse --git-dir)/hooks/pre-commit" 7 | 8 | cp "$src" "$dest" || exit 9 | chmod +x "$dest" 10 | echo "Installed $dest" 11 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | 3 | mod args; 4 | mod ircd; 5 | mod matrirc; 6 | mod matrix; 7 | mod state; 8 | 9 | #[tokio::main] 10 | async fn main() -> Result<()> { 11 | env_logger::init(); 12 | // ensure args parse early 13 | let _ = args::args(); 14 | 15 | let ircd = ircd::listen().await; 16 | 17 | ircd.await?; 18 | 19 | Ok(()) 20 | } 21 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: simple CI 2 | on: 3 | pull_request_target: 4 | types: 5 | - opened 6 | - edited 7 | - synchronize 8 | push: 9 | branches: ["master", "test"] 10 | 11 | env: 12 | RUSTFLAGS: "-Dwarnings" 13 | 14 | jobs: 15 | clippy: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v4 19 | - name: Run Clippy 20 | run: cargo clippy --all-targets --all-features 21 | rust_tests: 22 | runs-on: ubuntu-latest 23 | steps: 24 | - uses: actions/checkout@v4 25 | - name: Run tests 26 | run: cargo test 27 | rustfmt: 28 | runs-on: ubuntu-latest 29 | steps: 30 | - uses: actions/checkout@v4 31 | - name: Run rustfmt 32 | run: cargo fmt 33 | -------------------------------------------------------------------------------- /src/args.rs: -------------------------------------------------------------------------------- 1 | use clap::Parser; 2 | use lazy_static::lazy_static; 3 | use std::net::SocketAddr; 4 | 5 | #[derive(Parser, Debug)] 6 | #[command(author, version, about, long_about = None)] 7 | pub struct Args { 8 | #[arg(short = 'l', long, default_value = "[::1]:6667")] 9 | pub ircd_listen: SocketAddr, 10 | 11 | #[arg(long, default_value_t = false)] 12 | pub allow_register: bool, 13 | 14 | #[arg(long, default_value = "/var/lib/matrirc")] 15 | pub state_dir: String, 16 | 17 | #[arg(long, default_value = None)] 18 | pub media_dir: Option, 19 | 20 | #[arg(long, default_value = None)] 21 | pub media_url: Option, 22 | } 23 | 24 | pub fn args() -> &'static Args { 25 | lazy_static! { 26 | static ref ARGS: Args = Args::parse(); 27 | } 28 | &ARGS 29 | } 30 | -------------------------------------------------------------------------------- /scripts/commit_hook: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | rs_files=() 4 | 5 | while read -r file; do 6 | file="./$file" 7 | if [ ! -f "$file" ]; then 8 | continue 9 | fi 10 | if [[ "$file" == *.rs ]]; then 11 | rs_files+=( "$file" ) 12 | fi 13 | done < <(git diff-index --cached --name-only HEAD) 14 | 15 | if [ ${#rs_files[@]} -ne 0 ]; then 16 | # cargo fmt can only run on all files 17 | # and even calling rustfmt manually automatically 18 | # reformats children (used submodules) unless disabled by 19 | # a flag only available in nightly, so just give up and format 20 | # everything... Can be reworked in rustfmt 2.0 maybe someday. 21 | echo "Running cargo fmt" 22 | cargo fmt || exit 23 | fi 24 | 25 | if [ ${#rs_files[@]} -ne 0 ]; then 26 | echo "Formatting done, re-add required parts if required (or ^C to abort commit):" 27 | git add -p "${rs_files[@]}" < /dev/tty || exit 28 | else 29 | echo "No rust file changed" 30 | fi 31 | -------------------------------------------------------------------------------- /src/matrix/time.rs: -------------------------------------------------------------------------------- 1 | use chrono::{offset::Local, DateTime, Duration}; 2 | use matrix_sdk::ruma::MilliSecondsSinceUnixEpoch; 3 | use std::time::SystemTime; 4 | 5 | pub trait ToLocal { 6 | fn localtime(&self) -> Option; 7 | } 8 | impl ToLocal for MilliSecondsSinceUnixEpoch { 9 | fn localtime(&self) -> Option { 10 | let datetime: DateTime = self 11 | .to_system_time() 12 | .unwrap_or(SystemTime::UNIX_EPOCH) 13 | .into(); 14 | // empty if within 10s, just hour/min/sec if < 12h from now, else full date 15 | let now = Local::now(); 16 | if datetime < now - Duration::hours(12) { 17 | Some(datetime.format("%Y-%m-%d %H:%M:%S").to_string()) 18 | } else if datetime < now - Duration::seconds(10) { 19 | Some(datetime.format("%H:%M:%S").to_string()) 20 | } else if datetime < now + Duration::seconds(10) { 21 | None 22 | } else { 23 | // date in the future?! 24 | Some(datetime.format("%Y-%m-%d %H:%M:%S").to_string()) 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # matrirc 2 | 3 | simple ircd bridging to matrix 4 | 5 | # Features 6 | 7 | - e2e encryption 8 | - client verification 9 | - properly prompts on room invitations 10 | - can accept encrypted files to local directory (`--media-dir`) and give links if configured (`--media-url`, prefix up to file name). 11 | You'll need to configure cleanup yourself at this point. 12 | 13 | # Usage 14 | 15 | - Run server with `--allow-register`, connect from an irc client with a password set 16 | - Follow prompt to login to your account 17 | - Once logged in, we remember you from nick/password: you can reconnect without `--allow-register` and get your session back 18 | 19 | # TODO 20 | 21 | Things known not to work, planned: 22 | - notification on topic/icon change 23 | 24 | Not planned short term, but would accept PR: 25 | - initiate joining room from irc (add metacommand through 'matrirc' queries, like verification) 26 | - mentions (look for @nick in messages -> search nick in room members -> translate to real userId for highlight) 27 | - mentions, other way around (translate @userId to @nick) 28 | 29 | Not planned ever?: 30 | - calls/video 31 | -------------------------------------------------------------------------------- /src/ircd/chan.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | 3 | use crate::ircd::{ 4 | proto::{join, raw_msg}, 5 | IrcClient, 6 | }; 7 | 8 | pub async fn join_irc_chan(irc: &IrcClient, chan: &str) -> Result<()> { 9 | irc.send(join( 10 | Some(format!("{}!{}@matrirc", irc.nick, irc.user)), 11 | chan, 12 | )) 13 | .await 14 | } 15 | 16 | pub async fn join_irc_chan_finish( 17 | irc: &IrcClient, 18 | chan: String, 19 | members: Vec, 20 | ) -> Result<()> { 21 | let names_list_header = format!(":matrirc 353 {} = {} :", irc.nick, chan); 22 | let mut names_list = names_list_header.clone(); 23 | for member in members { 24 | names_list.push_str(&member); 25 | if names_list.len() > 400 { 26 | irc.send(raw_msg(names_list)).await?; 27 | names_list = names_list_header.clone(); 28 | } else { 29 | names_list.push(' '); 30 | } 31 | } 32 | if names_list != names_list_header { 33 | irc.send(raw_msg(names_list)).await?; 34 | } 35 | irc.send(raw_msg(format!(":matrirc 366 {} {} :End", irc.nick, chan))) 36 | .await?; 37 | Ok(()) 38 | } 39 | -------------------------------------------------------------------------------- /src/ircd/client.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use irc::client::prelude::Message; 3 | use std::sync::Arc; 4 | use tokio::sync::{mpsc, Mutex}; 5 | 6 | use crate::ircd::proto; 7 | 8 | #[derive(Debug, Clone)] 9 | pub struct IrcClient { 10 | /// Avoid waiting on network: queue messages for another task 11 | /// to actually do the sending. 12 | /// read in one place and kept private 13 | pub sink: Arc>>, 14 | pub nick: String, 15 | pub user: String, 16 | } 17 | 18 | impl IrcClient { 19 | pub fn new(sink: mpsc::Sender, nick: String, user: String) -> IrcClient { 20 | IrcClient { 21 | sink: Arc::new(Mutex::new(sink)), 22 | nick, 23 | user, 24 | } 25 | } 26 | 27 | pub async fn send(&self, msg: Message) -> Result<()> { 28 | self.sink.lock().await.send(msg).await?; 29 | Ok(()) 30 | } 31 | 32 | pub async fn send_privmsg(&self, from: S, target: T, msg: U) -> Result<()> 33 | where 34 | S: Into, 35 | T: Into, 36 | U: Into, 37 | { 38 | self.send(proto::privmsg(from, target, msg)).await 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "matrirc" 3 | edition = "2021" 4 | version = "0.1.0" 5 | description = "An ircd to matrix gateway" 6 | authors = ["Dominique Martinet "] 7 | license = "WTFPLv2" 8 | keywords = ["irc", "matrix"] 9 | repository = "https://github.com/martinetd/matrirc" 10 | readme = "README.md" 11 | rust-version = "1.76" # MSRV 12 | 13 | [dependencies] 14 | anyhow = "1.0" 15 | argon2 = { version = "0.5", features = ["std"] } 16 | async-trait = "0.1.68" 17 | base64 = "0.22" 18 | base64-serde = "0.8" 19 | chacha20poly1305 = { version = "0.10", features = ["alloc"], default-features = false } 20 | chrono = { version = "0.4.26", default-features = false, features = ["std"] } 21 | clap = { version = "4.3.0", features = ["derive"] } 22 | emoji = "0.2" 23 | env_logger = "0.11" 24 | futures = "0.3" 25 | irc = "1.0" 26 | lazy_static = "1.4" 27 | log = "0.4" 28 | lru = "0.13" 29 | matrix-sdk = { version = "0.8", features = ["anyhow", "sso-login"] } 30 | percent-encoding = "2.3.1" 31 | rand_core = { version = "0.9", features = ["os_rng"] } 32 | regex = "1.8" 33 | serde = "1.0" 34 | serde_json = "1.0" 35 | tempfile = "3.19.1" 36 | tokio = { version = "1.0.0", features = ["full"] } 37 | tokio-util = { version = "0.7", features = ["codec"] } 38 | -------------------------------------------------------------------------------- /src/matrix/outgoing.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Error, Result}; 2 | use async_trait::async_trait; 3 | use matrix_sdk::{ 4 | room::Room, 5 | ruma::events::room::message::{MessageType, RoomMessageEventContent}, 6 | RoomState, 7 | }; 8 | 9 | use crate::matrix::room_mappings::{MatrixMessageType, MessageHandler, RoomTarget}; 10 | 11 | #[async_trait] 12 | impl MessageHandler for Room { 13 | async fn handle_message(&self, message_type: MatrixMessageType, message: String) -> Result<()> { 14 | if self.state() != RoomState::Joined { 15 | Err(Error::msg(format!( 16 | "Room {} was not joined", 17 | self.room_id() 18 | )))?; 19 | }; 20 | let content = match message_type { 21 | MatrixMessageType::Text => RoomMessageEventContent::text_plain(message), 22 | MatrixMessageType::Emote => RoomMessageEventContent::new(MessageType::new( 23 | "m.emote", 24 | message, 25 | serde_json::map::Map::new(), 26 | )?), 27 | MatrixMessageType::Notice => RoomMessageEventContent::notice_plain(message), 28 | }; 29 | self.send(content).await?; 30 | Ok(()) 31 | } 32 | // can't remove room from irc, we don't want (and can't anyway) keep target in room 33 | async fn set_target(&self, _target: RoomTarget) {} 34 | } 35 | -------------------------------------------------------------------------------- /src/matrix/login.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Context, Result}; 2 | use log::debug; 3 | use matrix_sdk::{ 4 | matrix_auth::{MatrixSession, MatrixSessionTokens}, 5 | Client, SessionMeta, 6 | }; 7 | use std::path::Path; 8 | 9 | use crate::{args::args, state::SerializedMatrixSession}; 10 | 11 | pub async fn client(homeserver: &str, db_nick: &str, db_pass: &str) -> Result { 12 | let db_path = Path::new(&args().state_dir) 13 | .join(db_nick) 14 | .join("sqlite_store"); 15 | debug!("Connection to matrix for {}", db_nick); 16 | // note: error 'Building matrix client' is matched as a string to get next error 17 | // to user on irc 18 | Client::builder() 19 | .homeserver_url(homeserver) 20 | .sqlite_store(db_path, Some(db_pass)) 21 | .build() 22 | .await 23 | .context("Building matrix client") 24 | } 25 | 26 | pub async fn restore_session( 27 | homeserver: &str, 28 | serialized_session: SerializedMatrixSession, 29 | db_nick: &str, 30 | db_pass: &str, 31 | ) -> Result { 32 | let client = client(homeserver, db_nick, db_pass).await?; 33 | debug!("Restoring session for {}", db_nick); 34 | let session = MatrixSession { 35 | meta: SessionMeta { 36 | user_id: serialized_session.user_id.try_into()?, 37 | device_id: serialized_session.device_id.into(), 38 | }, 39 | tokens: MatrixSessionTokens { 40 | access_token: serialized_session.access_token, 41 | refresh_token: serialized_session.refresh_token, 42 | }, 43 | }; 44 | client.restore_session(session).await?; 45 | Ok(client) 46 | } 47 | -------------------------------------------------------------------------------- /src/matrix/mod.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use log::warn; 3 | use matrix_sdk::{config::SyncSettings, LoopCtrl}; 4 | 5 | use crate::matrirc::{Matrirc, Running}; 6 | 7 | mod invite; 8 | pub mod login; 9 | mod outgoing; 10 | pub mod room_mappings; 11 | mod sync_reaction; 12 | mod sync_room_member; 13 | mod sync_room_message; 14 | pub mod time; 15 | mod verification; 16 | 17 | pub use room_mappings::MatrixMessageType; 18 | 19 | pub async fn matrix_sync(matrirc: Matrirc) -> Result<()> { 20 | // add filter like with_lazy_loading() ? 21 | let sync_settings = SyncSettings::default(); 22 | let client = matrirc.matrix(); 23 | client.add_event_handler_context(matrirc.clone()); 24 | client.add_event_handler(sync_room_message::on_room_message); 25 | client.add_event_handler(sync_reaction::on_sync_reaction); 26 | client.add_event_handler(sync_reaction::on_sync_room_redaction); 27 | client.add_event_handler(verification::on_device_key_verification_request); 28 | client.add_event_handler(invite::on_stripped_state_member); 29 | client.add_event_handler(sync_room_member::on_room_member); 30 | 31 | let loop_matrirc = &matrirc.clone(); 32 | client 33 | .sync_with_result_callback(sync_settings, |_| async move { 34 | match loop_matrirc.running().await { 35 | Running::First => { 36 | if let Err(e) = loop_matrirc.mappings().sync_rooms(loop_matrirc).await { 37 | warn!("Got an error syncing rooms on first loop: {}", e); 38 | // XXX send to irc 39 | Ok(LoopCtrl::Break) 40 | } else { 41 | Ok(LoopCtrl::Continue) 42 | } 43 | } 44 | Running::Continue => Ok(LoopCtrl::Continue), 45 | Running::Break => Ok(LoopCtrl::Break), 46 | } 47 | }) 48 | .await?; 49 | Ok(()) 50 | } 51 | -------------------------------------------------------------------------------- /src/matrix/sync_room_member.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use log::{info, trace}; 3 | use matrix_sdk::{ 4 | event_handler::Ctx, 5 | room::Room, 6 | ruma::events::room::member::{MembershipChange, OriginalSyncRoomMemberEvent}, 7 | RoomState, 8 | }; 9 | 10 | use crate::ircd::proto::IrcMessageType; 11 | use crate::matrirc::Matrirc; 12 | 13 | pub async fn on_room_member( 14 | event: OriginalSyncRoomMemberEvent, 15 | room: Room, 16 | matrirc: Ctx, 17 | ) -> Result<()> { 18 | // ignore events from our own client (transaction set) 19 | if event.unsigned.transaction_id.is_some() { 20 | trace!("Ignored member event with transaction id (coming from self)"); 21 | return Ok(()); 22 | }; 23 | // ignore non-joined rooms 24 | if room.state() != RoomState::Joined { 25 | trace!("Ignored member event in non-joined room"); 26 | return Ok(()); 27 | }; 28 | 29 | trace!("Processing event {:?} to room {}", event, room.room_id()); 30 | let target = matrirc.mappings().room_target(&room).await; 31 | 32 | let user = &event.sender; 33 | info!("Ok test user {}", user); 34 | 35 | let prev = event.unsigned.prev_content; 36 | 37 | let mchange = event.content.membership_change( 38 | prev.as_ref().map(|c| c.details()), 39 | &event.sender, 40 | &event.state_key, 41 | ); 42 | info!("changed {:?}", mchange); 43 | match mchange { 44 | MembershipChange::Invited => { 45 | trace!( 46 | "{:?} was invited to {} by {}", 47 | event.content.displayname, 48 | target.target().await, 49 | event.sender 50 | ); 51 | target 52 | .send_text_to_irc( 53 | matrirc.irc(), 54 | IrcMessageType::Notice, 55 | &event.sender.into(), 56 | format!( 57 | "", 58 | event 59 | .content 60 | .displayname 61 | .unwrap_or_else(|| "???".to_string()) 62 | ), 63 | ) 64 | .await?; 65 | } 66 | MembershipChange::Joined | MembershipChange::InvitationAccepted => { 67 | target 68 | .member_join(matrirc.irc(), event.sender, event.content.displayname) 69 | .await?; 70 | } 71 | MembershipChange::Left => { 72 | target.member_part(matrirc.irc(), event.sender).await?; 73 | } 74 | _ => (), 75 | } 76 | 77 | Ok(()) 78 | } 79 | -------------------------------------------------------------------------------- /src/matrirc.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Context, Result}; 2 | use lru::LruCache; 3 | use matrix_sdk::{ 4 | ruma::{EventId, OwnedEventId}, 5 | Client, 6 | }; 7 | use std::sync::Arc; 8 | use tokio::sync::RwLock; 9 | 10 | use crate::matrix::room_mappings::Mappings; 11 | use crate::{ircd, ircd::IrcClient}; 12 | 13 | /// client state struct 14 | #[derive(Clone)] 15 | pub struct Matrirc { 16 | /// wrap in Arc for clone 17 | inner: Arc, 18 | } 19 | 20 | struct MatrircInner { 21 | matrix: Client, 22 | /// stop indicator 23 | running: RwLock, 24 | /// room mappings in both directions 25 | /// implementation in matrix/room_mappings.rs 26 | mappings: Mappings, 27 | /// recent messages (for reactions, redactions) 28 | recent_messages: RwLock>, 29 | } 30 | 31 | #[derive(Clone, Copy)] 32 | pub enum Running { 33 | First, 34 | Continue, 35 | Break, 36 | } 37 | 38 | impl Matrirc { 39 | pub fn new(matrix: Client, irc: IrcClient) -> Matrirc { 40 | Matrirc { 41 | inner: Arc::new(MatrircInner { 42 | matrix, 43 | running: RwLock::new(Running::First), 44 | mappings: Mappings::new(irc), 45 | recent_messages: RwLock::new(LruCache::new( 46 | std::num::NonZeroUsize::new(1000).unwrap(), 47 | )), 48 | }), 49 | } 50 | } 51 | 52 | pub fn irc(&self) -> &IrcClient { 53 | &self.mappings().irc 54 | } 55 | pub fn matrix(&self) -> &Client { 56 | &self.inner.matrix 57 | } 58 | pub fn mappings(&self) -> &Mappings { 59 | &self.inner.mappings 60 | } 61 | pub async fn running(&self) -> Running { 62 | // need let to drop read lock 63 | let v = *self.inner.running.read().await; 64 | match v { 65 | Running::First => { 66 | let mut lock = self.inner.running.write().await; 67 | match *lock { 68 | Running::First => { 69 | *lock = Running::Continue; 70 | Running::First 71 | } 72 | run => run, 73 | } 74 | } 75 | run => run, 76 | } 77 | } 78 | pub async fn stop>(&self, reason: S) -> Result<()> { 79 | *self.inner.running.write().await = Running::Break; 80 | self.irc() 81 | .send(ircd::proto::error(reason)) 82 | .await 83 | .context("stop quit message") 84 | } 85 | pub async fn message_get(&self, id: &EventId) -> Option { 86 | self.inner.recent_messages.read().await.peek(id).cloned() 87 | } 88 | pub async fn message_put(&self, id: OwnedEventId, message: String) { 89 | let _ = self.inner.recent_messages.write().await.put(id, message); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/ircd/mod.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Context, Result}; 2 | use futures::{SinkExt, StreamExt}; 3 | use irc::client::prelude::Message; 4 | use irc::proto::IrcCodec; 5 | use log::{debug, info}; 6 | use std::net::SocketAddr; 7 | use tokio::net::{TcpListener, TcpStream}; 8 | use tokio::sync::mpsc; 9 | use tokio_util::codec::Framed; 10 | 11 | use crate::args::args; 12 | use crate::matrirc::Matrirc; 13 | use crate::matrix; 14 | 15 | mod chan; 16 | mod client; 17 | mod login; 18 | pub mod proto; 19 | 20 | pub use chan::{join_irc_chan, join_irc_chan_finish}; 21 | pub use client::IrcClient; 22 | 23 | pub async fn listen() -> tokio::task::JoinHandle<()> { 24 | info!("listening to {}", args().ircd_listen); 25 | let listener = TcpListener::bind(args().ircd_listen) 26 | .await 27 | .context("bind ircd port") 28 | .unwrap(); 29 | tokio::spawn(async move { 30 | while let Ok((socket, addr)) = listener.accept().await { 31 | info!("Accepted connection from {}", addr); 32 | if let Err(e) = handle_connection(socket, addr).await { 33 | info!("Could not spawn worker: {}", e); 34 | } 35 | } 36 | }) 37 | } 38 | 39 | async fn handle_connection(socket: TcpStream, addr: SocketAddr) -> Result<()> { 40 | let codec = IrcCodec::new("utf-8")?; 41 | let stream = Framed::new(socket, codec); 42 | tokio::spawn(async move { 43 | if let Err(e) = handle_client(stream).await { 44 | info!("Terminating {}: {}", addr, e); 45 | } 46 | }); 47 | Ok(()) 48 | } 49 | 50 | async fn handle_client(mut stream: Framed) -> Result<()> { 51 | debug!("Awaiting auth"); 52 | let (nick, user, matrix) = match login::auth_loop(&mut stream).await { 53 | Ok(data) => data, 54 | Err(e) => { 55 | // keep original error, but try to tell client we're not ok 56 | let _ = stream 57 | .send(proto::error(format!("Closing session: {}", e))) 58 | .await; 59 | return Err(e); 60 | } 61 | }; 62 | info!("Authenticated {}!{}", nick, user); 63 | let (writer, reader_stream) = stream.split(); 64 | let (irc_sink, irc_sink_rx) = mpsc::channel::(100); 65 | let irc = IrcClient::new(irc_sink, nick, user); 66 | let matrirc = Matrirc::new(matrix, irc); 67 | 68 | let writer_matrirc = matrirc.clone(); 69 | tokio::spawn(async move { 70 | if let Err(e) = proto::ircd_sync_write(writer, irc_sink_rx).await { 71 | info!("irc write task failed: {:?}", e); 72 | } else { 73 | info!("irc write task done"); 74 | } 75 | let _ = writer_matrirc.stop("irc writer task stopped").await; 76 | }); 77 | 78 | let matrix_matrirc = matrirc.clone(); 79 | tokio::spawn(async move { 80 | if let Err(e) = matrix::matrix_sync(matrix_matrirc.clone()).await { 81 | info!("Error in matrix_sync: {:?}", e); 82 | } else { 83 | info!("Stopped matrix sync task"); 84 | } 85 | let _ = matrix_matrirc.stop("matrix sync task stopped").await; 86 | }); 87 | 88 | let reader_matrirc = matrirc.clone(); 89 | matrirc 90 | .irc() 91 | .send_privmsg("matrirc", &matrirc.irc().nick, "okay") 92 | .await?; 93 | if let Err(e) = proto::ircd_sync_read(reader_stream, reader_matrirc).await { 94 | info!("irc read task failed: {:?}", e); 95 | } 96 | matrirc.stop("Reached end of handle_client").await?; 97 | Ok(()) 98 | } 99 | -------------------------------------------------------------------------------- /src/matrix/invite.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Context, Result}; 2 | use async_trait::async_trait; 3 | use log::{trace, warn}; 4 | use matrix_sdk::{ 5 | event_handler::Ctx, room::Room, ruma::events::room::member::StrippedRoomMemberEvent, RoomState, 6 | }; 7 | use std::sync::Arc; 8 | use tokio::sync::RwLock; 9 | use tokio::time::{sleep, Duration}; 10 | 11 | use crate::matrirc::Matrirc; 12 | use crate::matrix::room_mappings::{room_name, MatrixMessageType, MessageHandler, RoomTarget}; 13 | 14 | #[derive(Clone)] 15 | struct InvitationContext { 16 | inner: Arc, 17 | } 18 | struct InvitationContextInner { 19 | matrirc: Matrirc, 20 | room: Room, 21 | room_name: String, 22 | target: RwLock>, 23 | } 24 | 25 | impl InvitationContext { 26 | async fn new(matrirc: Matrirc, room: Room) -> Self { 27 | InvitationContext { 28 | inner: Arc::new(InvitationContextInner { 29 | matrirc, 30 | room_name: room_name(&room), 31 | room, 32 | target: RwLock::new(None), 33 | }), 34 | } 35 | } 36 | async fn to_irc>(&self, message: S) -> Result<()> { 37 | let message: String = message.into(); 38 | trace!("{}", &message); 39 | self.inner 40 | .target 41 | .read() 42 | .await 43 | .as_ref() 44 | .context("target should always be set")? 45 | .send_simple_query(self.inner.matrirc.irc(), message) 46 | .await 47 | } 48 | async fn stop(&self) -> Result<()> { 49 | self.inner 50 | .matrirc 51 | .mappings() 52 | .remove_target( 53 | &self 54 | .inner 55 | .target 56 | .read() 57 | .await 58 | .as_ref() 59 | .context("target should always be set")? 60 | .target() 61 | .await, 62 | ) 63 | .await; 64 | Ok(()) 65 | } 66 | } 67 | 68 | #[async_trait] 69 | impl MessageHandler for InvitationContext { 70 | async fn handle_message( 71 | &self, 72 | _message_type: MatrixMessageType, 73 | message: String, 74 | ) -> Result<()> { 75 | match message.as_str() { 76 | "yes" => { 77 | let clone = self.clone(); 78 | tokio::spawn(async move { 79 | let room = clone.inner.room.clone(); 80 | if let Err(e) = clone 81 | .to_irc(format!("Joining room {}", clone.inner.room_name)) 82 | .await 83 | { 84 | warn!("Couldn't send message: {}", e) 85 | } 86 | let mut delay = 2; 87 | if loop { 88 | match room.join().await { 89 | Ok(()) => break true, 90 | Err(err) => { 91 | // example retries accepting a few times... 92 | if delay > 1800 { 93 | let _ = clone 94 | .to_irc(format!( 95 | "Gave up joining room {}: {}", 96 | clone.inner.room_name, err 97 | )) 98 | .await; 99 | break false; 100 | } 101 | warn!( 102 | "Invite join room {} failed, retrying in {}: {}", 103 | clone.inner.room_name, delay, err 104 | ); 105 | sleep(Duration::from_secs(delay)).await; 106 | delay *= 2; 107 | } 108 | }; 109 | } { 110 | let matrirc = &clone.inner.matrirc; 111 | let new_target = matrirc.mappings().room_target(&room).await; 112 | let _ = new_target 113 | .send_simple_query( 114 | matrirc.irc(), 115 | format!("Joined room {}", clone.inner.room_name), 116 | ) 117 | .await; 118 | } 119 | let _ = clone.stop().await; 120 | }); 121 | } 122 | "no" => { 123 | self.to_irc("Okay").await?; 124 | // XXX log failure? 125 | self.inner.room.leave().await?; 126 | self.stop().await?; 127 | } 128 | _ => { 129 | self.to_irc("expecting yes or no").await?; 130 | } 131 | }; 132 | Ok(()) 133 | } 134 | 135 | async fn set_target(&self, target: RoomTarget) { 136 | *self.inner.target.write().await = Some(target) 137 | } 138 | } 139 | 140 | pub async fn on_stripped_state_member( 141 | room_member: StrippedRoomMemberEvent, 142 | room: Room, 143 | matrirc: Ctx, 144 | ) -> Result<()> { 145 | // not for us 146 | if room_member.state_key 147 | != matrirc 148 | .matrix() 149 | .user_id() 150 | .context("Matrix client without user_id?")? 151 | { 152 | return Ok(()); 153 | } 154 | // not an invite 155 | if room.state() != RoomState::Invited { 156 | return Ok(()); 157 | }; 158 | let invite = InvitationContext::new(matrirc.clone(), room.clone()).await; 159 | matrirc.mappings().insert_deduped("invite", &invite).await; 160 | // XXX add reason and whatever else to message 161 | invite 162 | .to_irc(format!( 163 | "Got an invitation for {}, accept? [yes/no]", 164 | invite.inner.room_name 165 | )) 166 | .await?; 167 | Ok(()) 168 | } 169 | -------------------------------------------------------------------------------- /src/matrix/sync_reaction.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use log::trace; 3 | use matrix_sdk::{ 4 | event_handler::Ctx, 5 | room::Room, 6 | ruma::events::{ 7 | reaction::OriginalSyncReactionEvent, room::message::MessageType, 8 | room::redaction::OriginalSyncRoomRedactionEvent, AnySyncMessageLikeEvent, 9 | AnySyncTimelineEvent, SyncMessageLikeEvent, 10 | }, 11 | ruma::EventId, 12 | RoomState, 13 | }; 14 | 15 | use crate::ircd::proto::IrcMessageType; 16 | use crate::matrirc::Matrirc; 17 | use crate::matrix::time::ToLocal; 18 | 19 | // OriginalRoomRedactionEvent for redactions 20 | pub fn message_like_to_str(event: &AnySyncMessageLikeEvent) -> String { 21 | let AnySyncMessageLikeEvent::RoomMessage(event) = event else { 22 | return "(not a message)".to_string(); 23 | }; 24 | let SyncMessageLikeEvent::Original(event) = event else { 25 | return "(redacted)".to_string(); 26 | }; 27 | 28 | match &event.content.msgtype { 29 | MessageType::Text(text_content) => text_content.body.clone(), 30 | MessageType::Emote(emote_content) => format!("emote: {}", emote_content.body), 31 | MessageType::Notice(notice_content) => notice_content.body.clone(), 32 | MessageType::ServerNotice(snotice_content) => snotice_content.body.clone(), 33 | MessageType::File(file_content) => format!("file: {}", &file_content.body), 34 | MessageType::Image(image_content) => format!("image: {}", &image_content.body,), 35 | MessageType::Video(video_content) => format!("video: {}", &video_content.body,), 36 | MessageType::VerificationRequest(_verif_content) => "(verification request)".to_string(), 37 | msg => { 38 | let data = if !msg.data().is_empty() { 39 | " (has data)" 40 | } else { 41 | "" 42 | }; 43 | format!("{}{}: {}", msg.msgtype(), data, msg.body()) 44 | } 45 | } 46 | } 47 | async fn get_message_from_event_id( 48 | matrirc: &Matrirc, 49 | room: &Room, 50 | event_id: &EventId, 51 | ) -> Result { 52 | if let Some(message) = matrirc.message_get(event_id).await { 53 | return Ok(message); 54 | }; 55 | let raw_event = room.event(event_id, None).await?; 56 | 57 | Ok(match raw_event.raw().deserialize()? { 58 | AnySyncTimelineEvent::MessageLike(m) => { 59 | trace!("Got related message event: {:?}", m); 60 | 61 | let message = message_like_to_str(&m); 62 | format!( 63 | "message from {} @ {}: {}", 64 | m.sender(), 65 | m.origin_server_ts() 66 | .localtime() 67 | .unwrap_or_else(|| "just now".to_string()), 68 | message 69 | ) 70 | } 71 | AnySyncTimelineEvent::State(s) => { 72 | trace!("Got related state event: {:?}", s); 73 | 74 | format!( 75 | "not a message from {} @ {}", 76 | s.sender(), 77 | s.origin_server_ts() 78 | .localtime() 79 | .unwrap_or_else(|| "just now".to_string()), 80 | ) 81 | } 82 | }) 83 | //match event { 84 | // happy path: 85 | // AnyTimelineEvent 86 | // MessageLike(AnyMessageLikeEvent), 87 | // (for redaction of reactions...) Reaction(ReactionEvent), 88 | // RoomMessage(RoomMessageEvent), 89 | // RoomMessageEvent = MessageLikeEvent; 90 | // same as sync_room_message... 91 | } 92 | 93 | pub async fn on_sync_reaction( 94 | event: OriginalSyncReactionEvent, 95 | room: Room, 96 | matrirc: Ctx, 97 | ) -> Result<()> { 98 | // ignore events from our own client (transaction set) 99 | if event.unsigned.transaction_id.is_some() { 100 | trace!("Ignored reaction with transaction id (coming from self)"); 101 | return Ok(()); 102 | }; 103 | // ignore non-joined rooms 104 | if room.state() != RoomState::Joined { 105 | trace!("Ignored reaction in non-joined room"); 106 | return Ok(()); 107 | }; 108 | 109 | trace!( 110 | "Processing reaction event {:?} to room {}", 111 | event, 112 | room.room_id() 113 | ); 114 | let target = matrirc.mappings().room_target(&room).await; 115 | 116 | let time_prefix = event 117 | .origin_server_ts 118 | .localtime() 119 | .map(|d| format!("<{}> ", d)) 120 | .unwrap_or_default(); 121 | let reaction = event.content.relates_to; 122 | let reaction_text = emoji::lookup_by_glyph::lookup(&reaction.key) 123 | .map(|e| format!("{} ({})", reaction.key, e.name)) 124 | .unwrap_or(reaction.key.clone()); 125 | let reacting_to = match get_message_from_event_id(&matrirc, &room, &reaction.event_id).await { 126 | Err(e) => format!("", e), 127 | Ok(m) => m, 128 | }; 129 | let message = format!( 130 | "{}: {}", 131 | time_prefix, reacting_to, reaction_text 132 | ); 133 | matrirc 134 | .message_put(event.event_id.clone(), message.clone()) 135 | .await; 136 | // get error if any (warn/matrirc channel?) 137 | target 138 | .send_text_to_irc( 139 | matrirc.irc(), 140 | IrcMessageType::Privmsg, 141 | &event.sender.into(), 142 | message, 143 | ) 144 | .await?; 145 | 146 | Ok(()) 147 | } 148 | pub async fn on_sync_room_redaction( 149 | event: OriginalSyncRoomRedactionEvent, 150 | room: Room, 151 | matrirc: Ctx, 152 | ) -> Result<()> { 153 | // ignore events from our own client (transaction set) 154 | if event.unsigned.transaction_id.is_some() { 155 | trace!("Ignored reaction with transaction id (coming from self)"); 156 | return Ok(()); 157 | }; 158 | // ignore non-joined rooms 159 | if room.state() != RoomState::Joined { 160 | trace!("Ignored reaction in non-joined room"); 161 | return Ok(()); 162 | }; 163 | 164 | trace!( 165 | "Processing redaction event {:?} to room {}", 166 | event, 167 | room.room_id() 168 | ); 169 | let target = matrirc.mappings().room_target(&room).await; 170 | 171 | let time_prefix = event 172 | .origin_server_ts 173 | .localtime() 174 | .map(|d| format!("<{}> ", d)) 175 | .unwrap_or_default(); 176 | let reason = event.content.reason.as_deref().unwrap_or("(no reason)"); 177 | let reacting_to = { 178 | match &event.redacts { 179 | None => "".to_string(), 180 | Some(redacts) => match get_message_from_event_id(&matrirc, &room, redacts).await { 181 | Err(e) => format!("", e), 182 | Ok(m) => m, 183 | }, 184 | } 185 | }; 186 | // get error if any (warn/matrirc channel?) 187 | target 188 | .send_text_to_irc( 189 | matrirc.irc(), 190 | IrcMessageType::Privmsg, 191 | &event.sender.into(), 192 | format!("{}: {}", time_prefix, reacting_to, reason), 193 | ) 194 | .await?; 195 | 196 | Ok(()) 197 | } 198 | -------------------------------------------------------------------------------- /src/state.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Context, Error, Result}; 2 | use argon2::{ 3 | password_hash::rand_core::{OsRng, RngCore}, 4 | Argon2, 5 | }; 6 | use base64_serde::base64_serde_type; 7 | use chacha20poly1305::{aead::Aead, KeyInit, XChaCha20Poly1305}; 8 | use log::info; 9 | use matrix_sdk::AuthSession; 10 | use std::fs; 11 | use std::io::Write; 12 | use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt}; 13 | use std::path::{Path, PathBuf}; 14 | 15 | base64_serde_type!(Base64, base64::engine::general_purpose::STANDARD); 16 | 17 | use crate::args::args; 18 | 19 | /// data we want to keep around 20 | #[derive(Debug, PartialEq, serde::Serialize, serde::Deserialize)] 21 | pub struct Session { 22 | pub homeserver: String, 23 | pub matrix_session: SerializedMatrixSession, 24 | } 25 | 26 | /// matrix-rust-sdk's "Session" struct as we used to serialize it 27 | /// as of matrix-rust-sdk commit 0b9c082e11955f49f99acd21542f62b40f11c418 28 | #[derive(Debug, PartialEq, serde::Serialize, serde::Deserialize)] 29 | pub struct SerializedMatrixSession { 30 | /// The access token used for this session. 31 | pub access_token: String, 32 | /// The token used for [refreshing the access token], if any. 33 | /// 34 | /// [refreshing the access token]: https://spec.matrix.org/v1.3/client-server-api/#refreshing-access-tokens 35 | #[serde(skip_serializing_if = "Option::is_none")] 36 | pub refresh_token: Option, 37 | /// The user the access token was issued for. 38 | pub user_id: String, 39 | /// The ID of the client device. 40 | pub device_id: String, 41 | } 42 | 43 | /// data required for decryption 44 | #[derive(serde::Serialize, serde::Deserialize)] 45 | struct Blob { 46 | version: String, 47 | #[serde(with = "Base64")] 48 | ciphertext: Vec, 49 | #[serde(with = "Base64")] 50 | salt: Vec, 51 | #[serde(with = "Base64")] 52 | nonce: Vec, 53 | } 54 | 55 | /// try to decrypt session and return it 56 | fn check_pass(session_file: PathBuf, pass: &str) -> Result { 57 | let blob_text = fs::read(session_file).context("Could not read user session file")?; 58 | decrypt_blob(pass, &blob_text) 59 | } 60 | 61 | fn decrypt_blob(pass: &str, blob_text: &[u8]) -> Result { 62 | let blob = serde_json::from_slice::(blob_text) 63 | .context("Could not deserialize session file content.")?; 64 | if blob.version != "argon2+chacha20poly1305" { 65 | return Err(Error::msg( 66 | "This version only supports argon2+chacha20poly1305", 67 | )); 68 | } 69 | let mut key = [0u8; 32]; 70 | Argon2::default() 71 | .hash_password_into(pass.as_bytes(), &blob.salt, &mut key) 72 | .context("Could not hash password")?; 73 | let cipher = XChaCha20Poly1305::new(&key.into()); 74 | let plaintext = cipher 75 | .decrypt(blob.nonce.as_slice().into(), &*blob.ciphertext) 76 | .map_err(|_| Error::msg("Could not decrypt blob: bad password?"))?; 77 | 78 | let session = serde_json::from_slice::(&plaintext) 79 | .context("Could not deserialize stored session")?; 80 | info!("Decrypted {}", session.homeserver); 81 | Ok(session) 82 | } 83 | 84 | fn encrypt_blob(pass: &str, homeserver: &str, auth_session: AuthSession) -> Result> { 85 | let session_meta = auth_session.meta(); 86 | let session = Session { 87 | homeserver: homeserver.into(), 88 | matrix_session: SerializedMatrixSession { 89 | access_token: auth_session.access_token().into(), 90 | refresh_token: auth_session.get_refresh_token().map(str::to_string), 91 | user_id: session_meta.user_id.as_str().into(), 92 | device_id: session_meta.device_id.as_str().into(), 93 | }, 94 | }; 95 | let mut key = [0u8; 32]; 96 | let mut salt = vec![0u8; 32]; 97 | let mut nonce = vec![0u8; 24]; 98 | OsRng.fill_bytes(&mut salt); 99 | OsRng.fill_bytes(&mut nonce); 100 | Argon2::default() 101 | .hash_password_into(pass.as_bytes(), &salt, &mut key) 102 | .context("Could not hash password")?; 103 | 104 | let cipher = XChaCha20Poly1305::new(&key.into()); 105 | let ciphertext = cipher 106 | .encrypt( 107 | nonce.as_slice().into(), 108 | &*serde_json::to_vec(&session).context("could not serialize session")?, 109 | ) 110 | .map_err(|_| Error::msg("Could not encrypt blob"))?; 111 | let blob = Blob { 112 | version: "argon2+chacha20poly1305".to_string(), 113 | ciphertext, 114 | salt, 115 | nonce, 116 | }; 117 | serde_json::to_vec(&blob).context("could not serialize blob") 118 | } 119 | 120 | /// encrypt session and store it 121 | pub fn create_user( 122 | nick: &str, 123 | pass: &str, 124 | homeserver: &str, 125 | auth_session: AuthSession, 126 | ) -> Result<()> { 127 | let blob_text = encrypt_blob(pass, homeserver, auth_session)?; 128 | 129 | let user_dir = Path::new(&args().state_dir).join(nick); 130 | if !user_dir.is_dir() { 131 | fs::DirBuilder::new() 132 | .mode(0o700) 133 | .recursive(true) 134 | .create(&user_dir) 135 | .context("mkdir of user dir failed")? 136 | } 137 | let mut file = fs::OpenOptions::new() 138 | .mode(0o400) 139 | .write(true) 140 | .create_new(true) 141 | .open(user_dir.join("session")) 142 | .context("creating user session file failed")?; 143 | file.write_all(&blob_text) 144 | .context("Writing to user session file failed")?; 145 | Ok(()) 146 | } 147 | 148 | /// Initial "log in": if user exists validate its password, 149 | /// otherwise just let it through iff we allow new users 150 | pub fn login(nick: &str, pass: &str) -> Result> { 151 | let session_file = Path::new(&args().state_dir).join(nick).join("session"); 152 | if session_file.is_file() { 153 | Ok(Some(check_pass(session_file, pass)?)) 154 | } else if args().allow_register { 155 | Ok(None) 156 | } else { 157 | Err(Error::msg(format!("unknown user {}", nick))) 158 | } 159 | } 160 | 161 | #[cfg(test)] 162 | mod tests { 163 | use super::*; 164 | use matrix_sdk::{ 165 | matrix_auth::{MatrixSession, MatrixSessionTokens}, 166 | SessionMeta, 167 | }; 168 | 169 | /// ensure on disk format is stable 170 | #[test] 171 | fn check_state_storage() -> Result<()> { 172 | //{"homeserver":"https://matrix.codewreck.org","matrix_session":{"access_token":"syt_dGVzdDI_MsvRmWOsfnSDZMCycFUK_3UNGcT","user_id":"@test2:codewreck.org","device_id":"MSPYQMJBVG"}} 173 | let session = AuthSession::Matrix(MatrixSession { 174 | meta: SessionMeta { 175 | user_id: "@test:domain.tld".try_into()?, 176 | device_id: "ABCDEFGHIJ".into(), 177 | }, 178 | tokens: MatrixSessionTokens { 179 | access_token: "abc_abcdefg_abcdefgh_abcdef".into(), 180 | refresh_token: None, 181 | }, 182 | }); 183 | // can serialize/encrypt 184 | let blob_string = &encrypt_blob("pass", "domain.tld", session)?; 185 | 186 | // can decrypt what we just encrypted 187 | let session = decrypt_blob("pass", blob_string)?; 188 | assert_eq!(session.homeserver, "domain.tld"); 189 | assert_eq!(session.matrix_session.user_id, "@test:domain.tld"); 190 | assert_eq!(session.matrix_session.device_id, "ABCDEFGHIJ"); 191 | assert_eq!( 192 | session.matrix_session.access_token, 193 | "abc_abcdefg_abcdefgh_abcdef" 194 | ); 195 | assert!(session.matrix_session.refresh_token.is_none()); 196 | 197 | // can decrypt something we encrypted ages ago (format stability check) 198 | let old_blob = r#"{"version":"argon2+chacha20poly1305","ciphertext":"jTMm0N+nAl9jTD6sdppn+9w5B93QpGzng7YNyR+oDcFdHs3EEAUYKKBPTQlkJovthypQ+eDSrS9Vd9WJAdsa9NqGgyx+XoijMPL4LG+K88CnlKE/0GbNbGLH4r1QqGif5aimVJOmgI5rTgRAb+ZhfEGx5nmk1CNmCW5nCzLmWfdvjHJssMJt4JJFN82hJoVn2RHNwFY3q+MQ08E0zTvG1CA=","salt":"c9fUuFFl0Q1bzaBKAyvOcy+x1alIJ2mr/eZow4ut+58=","nonce":"QgY2eb3OGc7VCzw76t4b9kSPWx4pmZCG"}"#; 199 | let old_session = decrypt_blob("pass", old_blob.as_bytes())?; 200 | assert_eq!(session, old_session); 201 | 202 | Ok(()) 203 | } 204 | } 205 | -------------------------------------------------------------------------------- /src/matrix/sync_room_message.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Context, Error, Result}; 2 | use async_trait::async_trait; 3 | use log::{info, trace, warn}; 4 | use matrix_sdk::{ 5 | event_handler::Ctx, 6 | media::{MediaFormat, MediaRequestParameters}, 7 | room::Room, 8 | ruma::events::room::{ 9 | message::{MessageType, OriginalSyncRoomMessageEvent, Relation}, 10 | MediaSource, 11 | }, 12 | Client, RoomState, 13 | }; 14 | use percent_encoding::{utf8_percent_encode, AsciiSet, CONTROLS}; 15 | use std::ffi::OsStr; 16 | use std::path::{Path, PathBuf}; 17 | 18 | use tokio::fs; 19 | use tokio::io::AsyncWriteExt; 20 | 21 | use crate::args::args; 22 | use crate::ircd::proto::IrcMessageType; 23 | use crate::matrirc::Matrirc; 24 | use crate::matrix::time::ToLocal; 25 | use crate::matrix::verification::handle_verification_request; 26 | 27 | /// https://url.spec.whatwg.org/#fragment-percent-encode-set 28 | const FRAGMENT: &AsciiSet = &CONTROLS.add(b' ').add(b'"').add(b'<').add(b'>').add(b'`'); 29 | 30 | async fn generate_fresh_file(dir: PathBuf, filename: &str) -> Result<(tokio::fs::File, String)> { 31 | let filename = Path::new(filename); 32 | let prefix = filename 33 | .file_stem() 34 | .and_then(OsStr::to_str) 35 | .map(|s| format!("{}-", s)) 36 | .unwrap_or_else(|| "noname-".to_string()); 37 | let suffix = filename 38 | .extension() 39 | .and_then(OsStr::to_str) 40 | .map(|s| format!(".{}", s)) 41 | .unwrap_or_default(); 42 | 43 | tokio::task::spawn_blocking(move || { 44 | let (filehandle, filepath) = tempfile::Builder::new() 45 | .prefix(&prefix) 46 | .suffix(&suffix) 47 | .tempfile_in(dir)? 48 | .keep()?; 49 | 50 | let fresh_filename = filepath 51 | .file_name() 52 | .context("Internal error: No filename component")? 53 | .to_str() 54 | .context("Internal error: Non-utf8 filename")? 55 | .to_owned(); 56 | Ok((tokio::fs::File::from(filehandle), fresh_filename)) 57 | }) 58 | .await? 59 | } 60 | 61 | #[async_trait] 62 | pub trait SourceUri { 63 | async fn to_uri(&self, client: &Client, body: &str) -> Result; 64 | } 65 | #[async_trait] 66 | impl SourceUri for MediaSource { 67 | async fn to_uri(&self, client: &Client, body: &str) -> Result { 68 | let Some(dir_path) = &args().media_dir else { 69 | return Err(Error::msg("")); 70 | }; 71 | let media_request = MediaRequestParameters { 72 | source: self.clone(), 73 | format: MediaFormat::File, 74 | }; 75 | let content = client 76 | .media() 77 | .get_media_content(&media_request, false) 78 | .await 79 | .context("Could not get decrypted data")?; 80 | let filename = body.rsplit_once('/').map(|(_, f)| f).unwrap_or(body); 81 | let dir = PathBuf::from(dir_path); 82 | if !dir.is_dir() { 83 | fs::DirBuilder::new() 84 | .mode(0o700) 85 | .recursive(true) 86 | .create(&dir) 87 | .await? 88 | } 89 | let file = dir.join(filename); 90 | let plainfh = fs::OpenOptions::new() 91 | .write(true) 92 | .create_new(true) 93 | .open(file) 94 | .await; 95 | let outfiletuple = match plainfh { 96 | Ok(fh) => Ok((fh, filename.to_owned())), 97 | Err(err) => { 98 | if err.kind() == std::io::ErrorKind::AlreadyExists { 99 | generate_fresh_file(dir, filename).await 100 | } else { 101 | Err(anyhow::Error::from(err)) 102 | } 103 | } 104 | }; 105 | let (mut fh, filename) = outfiletuple?; 106 | fh.write_all(&content).await?; 107 | let url = args().media_url.as_ref().unwrap_or(dir_path); 108 | Ok(format!( 109 | "{}/{}", 110 | url, 111 | utf8_percent_encode(&filename, FRAGMENT) 112 | )) 113 | } 114 | } 115 | 116 | async fn process_message_like_to_str( 117 | event: &OriginalSyncRoomMessageEvent, 118 | matrirc: &Matrirc, 119 | ) -> (String, IrcMessageType) { 120 | let time_prefix = event 121 | .origin_server_ts 122 | .localtime() 123 | .map(|d| format!("<{}> ", d)) 124 | .unwrap_or_default(); 125 | let thread = match &event.content.relates_to { 126 | Some(Relation::Thread(_)) => " ", 127 | _ => "", 128 | }; 129 | let prefix = time_prefix + thread; 130 | 131 | match &event.content.msgtype { 132 | MessageType::Text(text_content) => { 133 | (prefix + text_content.body.as_str(), IrcMessageType::Privmsg) 134 | } 135 | MessageType::Emote(emote_content) => ( 136 | format!("\u{001}ACTION {}{}", prefix, emote_content.body), 137 | IrcMessageType::Privmsg, 138 | ), 139 | MessageType::Notice(notice_content) => ( 140 | prefix + notice_content.body.as_str(), 141 | IrcMessageType::Notice, 142 | ), 143 | MessageType::ServerNotice(snotice_content) => ( 144 | prefix + snotice_content.body.as_str(), 145 | IrcMessageType::Notice, 146 | ), 147 | MessageType::File(file_content) => { 148 | let url = file_content 149 | .source 150 | .to_uri(matrirc.matrix(), file_content.filename()) 151 | .await 152 | .unwrap_or_else(|e| format!("{}", e)); 153 | ( 154 | format!("{}Sent a file, {}: {}", prefix, &file_content.body, url), 155 | IrcMessageType::Notice, 156 | ) 157 | } 158 | MessageType::Image(image_content) => { 159 | let url = image_content 160 | .source 161 | .to_uri(matrirc.matrix(), image_content.filename()) 162 | .await 163 | .unwrap_or_else(|e| format!("{}", e)); 164 | ( 165 | format!("{}Sent an image, {}: {}", prefix, &image_content.body, url), 166 | IrcMessageType::Notice, 167 | ) 168 | } 169 | MessageType::Video(video_content) => { 170 | let url = video_content 171 | .source 172 | .to_uri(matrirc.matrix(), video_content.filename()) 173 | .await 174 | .unwrap_or_else(|e| format!("{}", e)); 175 | ( 176 | format!("{}Sent a video, {}: {}", prefix, &video_content.body, url), 177 | IrcMessageType::Notice, 178 | ) 179 | } 180 | MessageType::Audio(audio_content) => { 181 | let url = audio_content 182 | .source 183 | .to_uri(matrirc.matrix(), audio_content.filename()) 184 | .await 185 | .unwrap_or_else(|e| format!("{}", e)); 186 | ( 187 | format!("{}Sent audio, {}: {}", prefix, &audio_content.body, url), 188 | IrcMessageType::Notice, 189 | ) 190 | } 191 | MessageType::VerificationRequest(verif_content) => { 192 | info!("Initiating verif content {:?}", verif_content); 193 | if let Err(e) = 194 | handle_verification_request(matrirc, &event.sender, &event.event_id).await 195 | { 196 | warn!("Verif failed: {}", e); 197 | ( 198 | format!("{}Sent a verification request, but failed: {}", prefix, e), 199 | IrcMessageType::Notice, 200 | ) 201 | } else { 202 | ( 203 | format!("{}Sent a verification request", prefix), 204 | IrcMessageType::Notice, 205 | ) 206 | } 207 | } 208 | msg => { 209 | info!("Unhandled message: {:?}", event); 210 | let data = if !msg.data().is_empty() { 211 | " (has data)" 212 | } else { 213 | "" 214 | }; 215 | ( 216 | format!("{}Sent {}{}: {}", prefix, msg.msgtype(), data, msg.body()), 217 | IrcMessageType::Privmsg, 218 | ) 219 | } 220 | } 221 | } 222 | 223 | pub async fn on_room_message( 224 | event: OriginalSyncRoomMessageEvent, 225 | room: Room, 226 | matrirc: Ctx, 227 | ) -> Result<()> { 228 | // ignore events from our own client (transaction set) 229 | if event.unsigned.transaction_id.is_some() { 230 | trace!("Ignored message with transaction id (coming from self)"); 231 | return Ok(()); 232 | }; 233 | // ignore non-joined rooms 234 | if room.state() != RoomState::Joined { 235 | trace!("Ignored message in non-joined room"); 236 | return Ok(()); 237 | }; 238 | 239 | trace!("Processing event {:?} to room {}", event, room.room_id()); 240 | let target = matrirc.mappings().room_target(&room).await; 241 | 242 | let (message, message_type) = process_message_like_to_str(&event, &matrirc).await; 243 | matrirc 244 | .message_put(event.event_id.clone(), message.clone()) 245 | .await; 246 | 247 | target 248 | .send_text_to_irc(matrirc.irc(), message_type, &event.sender.into(), message) 249 | .await?; 250 | 251 | Ok(()) 252 | } 253 | -------------------------------------------------------------------------------- /src/ircd/proto.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use futures::stream::{SplitSink, SplitStream}; 3 | use futures::{SinkExt, StreamExt}; 4 | use irc::client::prelude::{Command, Message, Prefix}; 5 | use irc::proto::{ChannelMode, IrcCodec, Mode}; 6 | use log::{info, trace, warn}; 7 | use std::cmp::min; 8 | use std::time::SystemTime; 9 | use tokio::net::TcpStream; 10 | use tokio::sync::mpsc; 11 | use tokio_util::codec::Framed; 12 | 13 | use crate::{matrirc::Matrirc, matrix::MatrixMessageType}; 14 | 15 | /// it's a bit of a pain to redo the work twice for notice/privmsg, 16 | /// so these types wrap it around a bit 17 | #[derive(Debug, Clone)] 18 | pub enum IrcMessageType { 19 | Privmsg, 20 | Notice, 21 | } 22 | #[derive(Debug, Clone)] 23 | pub struct IrcMessage { 24 | pub message_type: IrcMessageType, 25 | /// source to use for privmsg/similar 26 | /// (member name for chan, query name for query) 27 | pub from: String, 28 | /// target to use for privmsg/similar 29 | /// (channel name for chan, None for query: in this case use own nick) 30 | pub target: String, 31 | /// message content 32 | pub text: String, 33 | } 34 | 35 | impl IntoIterator for IrcMessage { 36 | type Item = Message; 37 | // XXX would skip the collect, but cannot return 38 | // because lifetime: IrcMessage would need to be IrcMessage<'a> with &'a str 39 | // core::iter::Map, Self::Item>; 40 | type IntoIter = std::vec::IntoIter; 41 | 42 | fn into_iter(self) -> Self::IntoIter { 43 | let IrcMessage { 44 | text, 45 | message_type, 46 | from, 47 | target, 48 | } = self; 49 | text.split('\n') 50 | .map(|line| match message_type { 51 | IrcMessageType::Privmsg => privmsg(from.clone(), target.clone(), line), 52 | IrcMessageType::Notice => notice(from.clone(), target.clone(), line), 53 | }) 54 | .collect::>() 55 | .into_iter() 56 | } 57 | } 58 | 59 | fn message_of(prefix: S, command: Command) -> Message 60 | where 61 | S: Into, 62 | { 63 | Message { 64 | tags: None, 65 | prefix: { 66 | let p: String = prefix.into(); 67 | // XXX don't compute user from prefix, but use something like 68 | // matrix id when available? 69 | let user = p[..min(p.len(), 6)].to_string(); 70 | Some(Prefix::Nickname(p, user, "matrirc".to_string())) 71 | }, 72 | command, 73 | } 74 | } 75 | 76 | fn message_of_noprefix(command: Command) -> Message { 77 | Message { 78 | tags: None, 79 | prefix: None, 80 | command, 81 | } 82 | } 83 | 84 | fn message_of_option(prefix: Option, command: Command) -> Message 85 | where 86 | S: Into, 87 | { 88 | match prefix { 89 | None => message_of_noprefix(command), 90 | Some(p) => message_of(p, command), 91 | } 92 | } 93 | 94 | /// msg to client as is without any formatting 95 | pub fn raw_msg>(msg: S) -> Message { 96 | message_of_noprefix(Command::Raw(msg.into(), vec![])) 97 | } 98 | 99 | pub fn join(who: Option, chan: T) -> Message 100 | where 101 | S: Into, 102 | T: Into, 103 | { 104 | message_of_option(who, Command::JOIN(chan.into(), None, None)) 105 | } 106 | 107 | pub fn part(who: Option, chan: T) -> Message 108 | where 109 | S: Into, 110 | T: Into, 111 | { 112 | message_of_option(who, Command::PART(chan.into(), None)) 113 | } 114 | 115 | pub fn pong(server: String, server2: Option) -> Message { 116 | message_of_noprefix(Command::PONG(server, server2)) 117 | } 118 | 119 | /// privmsg to target, coming as from, with given content. 120 | /// target should be user's nick for private messages or channel name 121 | pub fn privmsg(from: S, target: T, msg: U) -> Message 122 | where 123 | S: Into, 124 | T: Into, 125 | U: Into, 126 | { 127 | message_of(from, Command::PRIVMSG(target.into(), msg.into())) 128 | } 129 | 130 | pub fn notice(from: S, target: T, msg: U) -> Message 131 | where 132 | S: Into, 133 | T: Into, 134 | U: Into, 135 | { 136 | message_of(from, Command::NOTICE(target.into(), msg.into())) 137 | } 138 | 139 | pub fn error(reason: S) -> Message 140 | where 141 | S: Into, 142 | { 143 | message_of_noprefix(Command::ERROR(reason.into())) 144 | } 145 | 146 | pub async fn ircd_sync_write( 147 | mut writer: SplitSink, Message>, 148 | mut irc_sink_rx: mpsc::Receiver, 149 | ) -> Result<()> { 150 | while let Some(message) = irc_sink_rx.recv().await { 151 | match message.command { 152 | Command::ERROR(_) => { 153 | writer.send(message).await?; 154 | writer.close().await?; 155 | info!("Stopping write task to quit"); 156 | return Ok(()); 157 | } 158 | _ => writer.send(message).await?, 159 | } 160 | } 161 | info!("Stopping write task to sink closed"); 162 | Ok(()) 163 | } 164 | 165 | pub async fn ircd_sync_read( 166 | mut reader: SplitStream>, 167 | matrirc: Matrirc, 168 | ) -> Result<()> { 169 | while let Some(input) = reader.next().await { 170 | let message = match input { 171 | Err(e) => { 172 | info!("Ignoring error message {:?}", e); 173 | continue; 174 | } 175 | Ok(m) => m, 176 | }; 177 | trace!("Got message {}", message); 178 | match message.command.clone() { 179 | Command::PING(server, server2) => matrirc.irc().send(pong(server, server2)).await?, 180 | Command::PRIVMSG(target, msg) => { 181 | let (message_type, msg) = if let Some(emote) = msg.strip_prefix("\u{001}ACTION ") { 182 | (MatrixMessageType::Emote, emote.to_string()) 183 | } else { 184 | (MatrixMessageType::Text, msg) 185 | }; 186 | if let Err(e) = matrirc 187 | .mappings() 188 | .to_matrix(&target, message_type, msg) 189 | .await 190 | { 191 | warn!("Could not forward message: {:?}", e); 192 | if let Err(e2) = matrirc 193 | .irc() 194 | .send(notice( 195 | &matrirc.irc().nick, 196 | message.response_target().unwrap_or("matrirc"), 197 | format!("Could not forward: {}", e), 198 | )) 199 | .await 200 | { 201 | warn!("Furthermore, reply errored too: {:?}", e2); 202 | } 203 | } 204 | } 205 | Command::NOTICE(target, msg) => { 206 | if let Err(e) = matrirc 207 | .mappings() 208 | .to_matrix(&target, MatrixMessageType::Notice, msg) 209 | .await 210 | { 211 | warn!("Could not forward message: {:?}", e); 212 | if let Err(e2) = matrirc 213 | .irc() 214 | .send(notice( 215 | &matrirc.irc().nick, 216 | message.response_target().unwrap_or("matrirc"), 217 | format!("Could not forward: {}", e), 218 | )) 219 | .await 220 | { 221 | warn!("Furthermore, reply errored too: {:?}", e2); 222 | } 223 | } 224 | } 225 | Command::QUIT(msg) => { 226 | info!("QUIT {}", msg.unwrap_or_default()); 227 | break; 228 | } 229 | Command::ChannelMODE(chan, modes) if modes.is_empty() => { 230 | if let Err(e) = matrirc 231 | .irc() 232 | .send(raw_msg(format!( 233 | ":matrirc 329 {} {} {}", 234 | matrirc.irc().nick, 235 | chan, 236 | // normally chan creation timestamp 237 | SystemTime::now() 238 | .duration_since(SystemTime::UNIX_EPOCH) 239 | .map(|d| d.as_secs()) 240 | .unwrap_or_default() 241 | ))) 242 | .await 243 | { 244 | warn!("Could not reply to mode: {:?}", e) 245 | } 246 | } 247 | Command::ChannelMODE(chan, modes) 248 | if modes.contains(&Mode::NoPrefix(ChannelMode::Ban)) => 249 | { 250 | if let Err(e) = matrirc 251 | .irc() 252 | .send(raw_msg(format!( 253 | ":matrirc 368 {} {} :End", 254 | matrirc.irc().nick, 255 | chan 256 | ))) 257 | .await 258 | { 259 | warn!("Could not reply to mode: {:?}", e) 260 | } 261 | } 262 | Command::WHO(Some(chan), _) => { 263 | if let Err(e) = matrirc 264 | .irc() 265 | .send(raw_msg(format!( 266 | ":matrirc 315 {} {} :End", 267 | matrirc.irc().nick, 268 | chan 269 | ))) 270 | .await 271 | { 272 | warn!("Could not reply to mode: {:?}", e) 273 | } 274 | } 275 | _ => info!("Unhandled message {:?}", message), 276 | } 277 | } 278 | info!("Stopping read task to stream closed"); 279 | Ok(()) 280 | } 281 | -------------------------------------------------------------------------------- /src/matrix/verification.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Context, Result}; 2 | use async_trait::async_trait; 3 | use futures::StreamExt; 4 | use log::warn; 5 | use matrix_sdk::{ 6 | encryption::verification::{ 7 | format_emojis, SasState, SasVerification, Verification, VerificationRequest, 8 | VerificationRequestState, 9 | }, 10 | event_handler::Ctx, 11 | ruma::{events::key::verification::request::ToDeviceKeyVerificationRequestEvent, UserId}, 12 | }; 13 | use std::sync::Arc; 14 | use tokio::sync::RwLock; 15 | 16 | use crate::matrirc::Matrirc; 17 | use crate::matrix::room_mappings::{MatrixMessageType, MessageHandler, RoomTarget}; 18 | 19 | #[derive(Clone)] 20 | struct VerificationContext { 21 | inner: Arc>, 22 | } 23 | struct VerificationContextInner { 24 | matrirc: Matrirc, 25 | request: VerificationRequest, 26 | sas: Option, 27 | target: Option, 28 | step: VerifState, 29 | stop: bool, 30 | } 31 | #[derive(Clone)] 32 | enum VerifState { 33 | ConfirmStart, 34 | WaitingSas, 35 | ConfirmEmoji, 36 | WaitingDone, 37 | } 38 | 39 | impl VerificationContext { 40 | fn new(matrirc: Matrirc, request: VerificationRequest) -> Self { 41 | VerificationContext { 42 | inner: Arc::new(RwLock::new(VerificationContextInner { 43 | matrirc, 44 | request, 45 | sas: None, 46 | target: None, 47 | step: VerifState::ConfirmStart, 48 | stop: false, 49 | })), 50 | } 51 | } 52 | async fn to_irc>(&self, message: S) -> Result<()> { 53 | let guard = self.inner.read().await; 54 | guard 55 | .target 56 | .as_ref() 57 | .context("target should always be set")? 58 | .send_simple_query(guard.matrirc.irc(), message) 59 | .await 60 | } 61 | async fn stop(&self) -> Result<()> { 62 | let mut guard = self.inner.write().await; 63 | guard 64 | .matrirc 65 | .mappings() 66 | .remove_target( 67 | &guard 68 | .target 69 | .as_ref() 70 | .context("target should always be set")? 71 | .target() 72 | .await, 73 | ) 74 | .await; 75 | guard.stop = true; 76 | Ok(()) 77 | } 78 | 79 | async fn sas_verification_handler_(&self, sas: SasVerification) -> Result<()> { 80 | self.inner.write().await.sas = Some(sas.clone()); 81 | sas.accept().await?; 82 | let mut stream = sas.changes(); 83 | while !self.inner.read().await.stop { 84 | let Some(state) = stream.next().await else { 85 | break; 86 | }; 87 | // XXX add messages to irc? 88 | match state { 89 | SasState::KeysExchanged { 90 | emojis, 91 | decimals: _, 92 | } => { 93 | let emojis = emojis.context("Only support verification with emojis")?; 94 | self.inner.write().await.step = VerifState::ConfirmEmoji; 95 | self.to_irc(format!( 96 | "Got the following emojis:\n{}\nOk? [yes/no]", 97 | format_emojis(emojis.emojis) 98 | )) 99 | .await?; 100 | } 101 | SasState::Done { .. } => { 102 | let device = sas.other_device(); 103 | 104 | self.to_irc(format!( 105 | "Successfully verified device {} {} {:?}", 106 | device.user_id(), 107 | device.device_id(), 108 | device.local_trust_state() 109 | )) 110 | .await?; 111 | self.stop().await?; 112 | break; 113 | } 114 | SasState::Cancelled(cancel_info) => { 115 | self.to_irc(format!( 116 | "The verification has been cancelled, reason: {}", 117 | cancel_info.reason() 118 | )) 119 | .await?; 120 | break; 121 | } 122 | SasState::Created { .. } 123 | | SasState::Started { .. } 124 | | SasState::Accepted { .. } 125 | | SasState::Confirmed => (), 126 | } 127 | } 128 | Ok(()) 129 | } 130 | async fn sas_verification_handler(self, sas: SasVerification) { 131 | if let Err(e) = self.sas_verification_handler_(sas).await { 132 | let _ = self.to_irc(format!("Error handling sas: {}", e)).await; 133 | } 134 | } 135 | 136 | async fn request_verification_handler_(&self) -> Result<()> { 137 | let request = self.inner.read().await.request.clone(); 138 | request.accept().await?; 139 | 140 | let mut stream = request.changes(); 141 | 142 | while !self.inner.read().await.stop { 143 | let Some(state) = stream.next().await else { 144 | break; 145 | }; 146 | // XXX add messages to irc? 147 | match state { 148 | VerificationRequestState::Created { .. } 149 | | VerificationRequestState::Requested { .. } 150 | | VerificationRequestState::Ready { .. } => (), 151 | VerificationRequestState::Transitioned { verification } => match verification { 152 | Verification::SasV1(s) => { 153 | tokio::spawn(self.clone().sas_verification_handler(s)); 154 | break; 155 | } 156 | _ => { 157 | let _ = self.to_irc("Unsupported non-SasV1 verification").await; 158 | } 159 | }, 160 | VerificationRequestState::Done | VerificationRequestState::Cancelled(_) => break, 161 | } 162 | } 163 | Ok(()) 164 | } 165 | async fn request_verification_handler(self) { 166 | if let Err(e) = self.request_verification_handler_().await { 167 | let _ = self.to_irc(format!("Error handling verif: {}", e)).await; 168 | } 169 | } 170 | async fn handle_confirm_start(&self, message: String) -> Result<()> { 171 | match message.as_str() { 172 | "yes" => { 173 | self.to_irc("Ok, starting...").await?; 174 | self.inner.write().await.step = VerifState::WaitingSas; 175 | tokio::spawn(self.clone().request_verification_handler()); 176 | } 177 | "no" => { 178 | let _ = self.to_irc("Ok, bye").await; 179 | self.stop().await?; 180 | } 181 | _ => { 182 | self.to_irc("Bad message, expecting yes or no").await?; 183 | } 184 | } 185 | Ok(()) 186 | } 187 | async fn handle_confirm_emoji(&self, message: String) -> Result<()> { 188 | match message.as_str() { 189 | "yes" => { 190 | self.to_irc("Ok, accepting...").await?; 191 | self.inner.write().await.step = VerifState::WaitingDone; 192 | self.inner 193 | .read() 194 | .await 195 | .sas 196 | .as_ref() 197 | .context("Sas should be set at this point")? 198 | .confirm() 199 | .await?; 200 | } 201 | "no" => { 202 | let _ = self.to_irc("Ok, aborting").await; 203 | self.inner 204 | .read() 205 | .await 206 | .sas 207 | .as_ref() 208 | .context("Sas should be set at this point")? 209 | .cancel() 210 | .await?; 211 | self.stop().await?; 212 | } 213 | _ => { 214 | self.to_irc("Bad message, expecting yes or no").await?; 215 | } 216 | } 217 | Ok(()) 218 | } 219 | } 220 | 221 | #[async_trait] 222 | impl MessageHandler for VerificationContext { 223 | async fn handle_message( 224 | &self, 225 | _message_type: MatrixMessageType, 226 | message: String, 227 | ) -> Result<()> { 228 | let state = &self.inner.read().await.step.clone(); 229 | match state { 230 | VerifState::ConfirmStart => self.handle_confirm_start(message).await, 231 | VerifState::ConfirmEmoji => self.handle_confirm_emoji(message).await, 232 | _ => { 233 | self.to_irc("not expecting any message at this point".to_string()) 234 | .await 235 | } 236 | } 237 | } 238 | 239 | async fn set_target(&self, target: RoomTarget) { 240 | self.inner.write().await.target = Some(target) 241 | } 242 | } 243 | 244 | pub async fn on_device_key_verification_request( 245 | event: ToDeviceKeyVerificationRequestEvent, 246 | matrirc: Ctx, 247 | ) { 248 | if let Err(e) = 249 | handle_verification_request(&matrirc, &event.sender, &event.content.transaction_id).await 250 | { 251 | warn!("Verif failed: {}", e); 252 | } 253 | } 254 | 255 | pub async fn handle_verification_request( 256 | matrirc: &Matrirc, 257 | sender: &UserId, 258 | event_id: impl AsRef, 259 | ) -> Result<()> { 260 | let request = matrirc 261 | .matrix() 262 | .encryption() 263 | .get_verification_request(sender, event_id) 264 | .await 265 | .context("Could not find verification request")?; 266 | let verif = VerificationContext::new(matrirc.clone(), request); 267 | matrirc.mappings().insert_deduped("verif", &verif).await; 268 | verif 269 | .to_irc(format!( 270 | "Got a verification request from {}, accept? [yes/no]", 271 | sender 272 | )) 273 | .await?; 274 | Ok(()) 275 | } 276 | -------------------------------------------------------------------------------- /src/ircd/login.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Context, Error, Result}; 2 | use irc::{client::prelude::Command, proto::IrcCodec}; 3 | use log::{debug, info, trace, warn}; 4 | use tokio::net::TcpStream; 5 | use tokio::sync::oneshot; 6 | use tokio_util::codec::Framed; 7 | // for Framed.tryNext() 8 | // Note there's also a StreamExt in tokio-stream which covers 9 | // streams, but we it's not the same and we don't care about the 10 | // difference here 11 | use futures::{SinkExt, TryStreamExt}; 12 | use matrix_sdk::{ 13 | ruma::api::client::session::get_login_types::v3::LoginType, Client as MatrixClient, 14 | }; 15 | 16 | use crate::{ircd::proto, matrix, state}; 17 | 18 | pub async fn auth_loop( 19 | stream: &mut Framed, 20 | ) -> Result<(String, String, MatrixClient)> { 21 | let mut client_nick = None; 22 | let mut client_user = None; 23 | let mut client_pass = None; 24 | while let Some(event) = stream.try_next().await? { 25 | trace!("auth loop: got {:?}", event); 26 | match event.command { 27 | Command::NICK(nick) => client_nick = Some(nick), 28 | Command::PASS(pass) => client_pass = Some(pass), 29 | Command::USER(user, _, _) => { 30 | client_user = Some(user); 31 | break; 32 | } 33 | Command::PING(server, server2) => stream.send(proto::pong(server, server2)).await?, 34 | Command::CAP(_, _, Some(code), _) => { 35 | // required for recent-ish versions of irssi 36 | if code == "302" { 37 | stream.send(proto::raw_msg(":matrirc CAP * LS :")).await?; 38 | } 39 | } 40 | _ => (), // ignore 41 | } 42 | } 43 | 44 | let (Some(nick), Some(user), Some(pass)) = (client_nick, client_user, client_pass) else { 45 | return Err(Error::msg("nick or pass wasn't set for client!")); 46 | }; 47 | // need this to be able to interact with irssi: send welcome before any 48 | // privmsg exchange even if login isn't over. 49 | stream 50 | .send(proto::raw_msg(format!( 51 | ":matrirc 001 {} :Welcome to matrirc", 52 | nick 53 | ))) 54 | .await?; 55 | info!("Processing login from {}!{}", nick, user); 56 | let client = match state::login(&nick, &pass)? { 57 | Some(session) => matrix_restore_session(stream, &nick, &pass, session).await?, 58 | None => matrix_login_loop(stream, &nick, &pass).await?, 59 | }; 60 | Ok((nick, user, client)) 61 | } 62 | 63 | /// equivalent to ruma's LoginType, we need our own type for partialeq later 64 | #[derive(Debug, PartialEq)] 65 | enum LoginChoice { 66 | Password, 67 | Sso(Option), 68 | } 69 | 70 | enum LoginFlow { 71 | /// just connected 72 | Init, 73 | /// got homeserver, letting user pick auth method 74 | Homeserver(String, MatrixClient, Vec), 75 | /// Done, login types is no longer used but 76 | Complete(String, MatrixClient), 77 | } 78 | 79 | struct LoginState<'a> { 80 | stream: &'a mut Framed, 81 | nick: &'a str, 82 | irc_pass: &'a str, 83 | } 84 | 85 | async fn matrix_login_choices( 86 | state: &mut LoginState<'_>, 87 | client: MatrixClient, 88 | homeserver: &str, 89 | ) -> Result { 90 | debug!("Querying {} auths", homeserver); 91 | let login_types = client.matrix_auth().get_login_types().await?.flows; 92 | 93 | state 94 | .stream 95 | .send(proto::privmsg( 96 | "matrirc", 97 | state.nick, 98 | format!( 99 | "Found server at {}; complete connection with one the following:", 100 | &homeserver 101 | ), 102 | )) 103 | .await?; 104 | 105 | state 106 | .stream 107 | .send(proto::privmsg("matrirc", state.nick, "reset (start over)")) 108 | .await?; 109 | 110 | let mut choices = vec![]; 111 | for login_type in &login_types { 112 | debug!("Got login_type {:?}", login_type); 113 | match login_type { 114 | LoginType::Password(_) => { 115 | choices.push(LoginChoice::Password); 116 | state 117 | .stream 118 | .send(proto::privmsg( 119 | "matrirc", 120 | state.nick, 121 | "password ", 122 | )) 123 | .await? 124 | } 125 | LoginType::Sso(sso) => { 126 | if sso.identity_providers.is_empty() { 127 | choices.push(LoginChoice::Sso(None)); 128 | state 129 | .stream 130 | .send(proto::privmsg("matrirc", state.nick, "sso")) 131 | .await?; 132 | } else { 133 | for idp in &sso.identity_providers { 134 | choices.push(LoginChoice::Sso(Some(idp.id.clone()))); 135 | state 136 | .stream 137 | .send(proto::privmsg( 138 | "matrirc", 139 | state.nick, 140 | format!("sso {}", &idp.id), 141 | )) 142 | .await?; 143 | } 144 | } 145 | } 146 | _ => (), 147 | } 148 | } 149 | // XXX reset immediately if choices.is_empty() ? 150 | Ok(LoginFlow::Homeserver( 151 | homeserver.to_string(), 152 | client, 153 | choices, 154 | )) 155 | } 156 | 157 | async fn matrix_login_password( 158 | state: &mut LoginState<'_>, 159 | client: MatrixClient, 160 | homeserver: &str, 161 | user: &str, 162 | pass: &str, 163 | ) -> Result { 164 | state 165 | .stream 166 | .send(proto::privmsg( 167 | "matrirc", 168 | state.nick, 169 | format!("Attempting to login to {} with {}", homeserver, user), 170 | )) 171 | .await?; 172 | debug!("Logging in to matrix for {} (user {})", state.nick, user); 173 | client 174 | .matrix_auth() 175 | .login_username(user, pass) 176 | .initial_device_display_name("matrirc") 177 | .send() 178 | .await?; 179 | Ok(LoginFlow::Complete(homeserver.to_string(), client)) 180 | } 181 | 182 | async fn matrix_login_sso( 183 | state: &mut LoginState<'_>, 184 | homeserver: String, 185 | client: MatrixClient, 186 | choices: Vec, 187 | idp: Option<&str>, 188 | ) -> Result { 189 | // XXX check with &str somehow? 190 | if !choices.contains(&LoginChoice::Sso(idp.map(str::to_string))) { 191 | state 192 | .stream 193 | .send(proto::privmsg( 194 | "matrirc", 195 | state.nick, 196 | "invalid idp for sso, try again", 197 | )) 198 | .await?; 199 | return Ok(LoginFlow::Homeserver(homeserver, client, choices)); 200 | } 201 | 202 | let (url_tx, url_rx) = oneshot::channel(); 203 | let mut login_builder = client.matrix_auth().login_sso(|url| async move { 204 | if let Err(e) = url_tx.send(url) { 205 | warn!("Could not send privmsg: {e:?}"); 206 | } 207 | Ok(()) 208 | }); 209 | 210 | if let Some(idp) = idp { 211 | login_builder = login_builder.identity_provider_id(idp); 212 | } 213 | 214 | let builder_future = tokio::spawn(async move { login_builder.await }); 215 | 216 | match url_rx.await { 217 | Ok(url) => { 218 | state 219 | .stream 220 | .send(proto::privmsg( 221 | "matrirc", 222 | state.nick, 223 | format!("Login at this URL: {url}"), 224 | )) 225 | .await? 226 | } 227 | Err(e) => { 228 | state 229 | .stream 230 | .send(proto::privmsg( 231 | "matrirc", 232 | state.nick, 233 | format!("Could not get login url: {e:?}"), 234 | )) 235 | .await?; 236 | return Ok(LoginFlow::Homeserver(homeserver, client, choices)); 237 | // let spawned task finish in background... 238 | } 239 | } 240 | builder_future.await??; 241 | 242 | Ok(LoginFlow::Complete(homeserver, client)) 243 | } 244 | 245 | async fn matrix_login_state( 246 | state: &mut LoginState<'_>, 247 | flow: LoginFlow, 248 | message: String, 249 | ) -> Result { 250 | match flow { 251 | LoginFlow::Init => { 252 | // accept either single word (homeserver) or three words (homeserver user pass) 253 | match &message.split(' ').collect::>()[..] { 254 | [homeserver] => { 255 | let client = 256 | matrix::login::client(homeserver, state.nick, state.irc_pass).await?; 257 | matrix_login_choices(state, client, homeserver).await 258 | } 259 | [homeserver, user, pass] => { 260 | let client = 261 | matrix::login::client(homeserver, state.nick, state.irc_pass).await?; 262 | matrix_login_password(state, client, homeserver, user, pass).await 263 | } 264 | _ => { 265 | state 266 | .stream 267 | .send(proto::privmsg( 268 | "matrirc", 269 | state.nick, 270 | "Message not in [ ] format, ignoring.", 271 | )) 272 | .await?; 273 | Ok(LoginFlow::Init) 274 | } 275 | } 276 | } 277 | LoginFlow::Homeserver(homeserver, client, choices) => { 278 | match &message.split(' ').collect::>()[..] { 279 | ["reset"] => { 280 | state 281 | .stream 282 | .send(proto::privmsg( 283 | "matrirc", 284 | state.nick, 285 | "Start over from: [ ]", 286 | )) 287 | .await?; 288 | Ok(LoginFlow::Init) 289 | } 290 | ["password", user, pass] | ["pass", user, pass] 291 | if choices.contains(&LoginChoice::Password) => 292 | { 293 | matrix_login_password(state, client, &homeserver, user, pass).await 294 | } 295 | ["sso"] => matrix_login_sso(state, homeserver, client, choices, None).await, 296 | ["sso", idp] => { 297 | matrix_login_sso(state, homeserver, client, choices, Some(idp)).await 298 | } 299 | _ => { 300 | state 301 | .stream 302 | .send(proto::privmsg( 303 | "matrirc", 304 | state.nick, 305 | "Message not in recognized login format, try again (or 'reset')", 306 | )) 307 | .await?; 308 | Ok(LoginFlow::Init) 309 | } 310 | } 311 | } 312 | _ => Err(Error::msg("Should never be called with complete type")), 313 | } 314 | } 315 | 316 | async fn matrix_login_loop( 317 | stream: &mut Framed, 318 | nick: &str, 319 | irc_pass: &str, 320 | ) -> Result { 321 | stream.send(proto::privmsg( 322 | "matrirc", 323 | nick, 324 | "Welcome to matrirc. Please login to matrix by replying with: [ ]", 325 | )) 326 | .await?; 327 | let mut state = LoginState { 328 | stream, 329 | nick, 330 | irc_pass, 331 | }; 332 | let mut flow = LoginFlow::Init; 333 | while let Some(event) = state.stream.try_next().await? { 334 | trace!("matrix connection loop: got {:?}", event); 335 | match event.command { 336 | Command::PING(server, server2) => { 337 | state.stream.send(proto::pong(server, server2)).await? 338 | } 339 | Command::PRIVMSG(_, body) => { 340 | flow = match matrix_login_state(&mut state, flow, body).await { 341 | Ok(LoginFlow::Complete(homeserver, client)) => { 342 | state::create_user( 343 | nick, 344 | irc_pass, 345 | &homeserver, 346 | client.session().context("client has no auth session")?, 347 | )?; 348 | return Ok(client); 349 | } 350 | Ok(f) => f, 351 | Err(e) => { 352 | debug!("Login error: {e:?}"); 353 | let mut err_string = e.to_string(); 354 | if err_string == "Building matrix client" { 355 | err_string = format!( 356 | "Could not build client: {}", 357 | e.chain() 358 | .nth(1) 359 | .map(|e| e.to_string()) 360 | .unwrap_or_else(|| "???".to_string()) 361 | ) 362 | } 363 | state 364 | .stream 365 | .send(proto::privmsg( 366 | "matrirc", 367 | nick, 368 | format!("Error: {err_string}."), 369 | )) 370 | .await?; 371 | state 372 | .stream 373 | .send(proto::privmsg( 374 | "matrirc", 375 | nick, 376 | "Try again from [ ]", 377 | )) 378 | .await?; 379 | LoginFlow::Init 380 | } 381 | }; 382 | } 383 | _ => (), // ignore 384 | } 385 | } 386 | Err(Error::msg("Stream finished in matrix login loop?")) 387 | } 388 | 389 | async fn matrix_restore_session( 390 | stream: &mut Framed, 391 | nick: &str, 392 | irc_pass: &str, 393 | session: state::Session, 394 | ) -> Result { 395 | stream 396 | .send(proto::privmsg( 397 | "matrirc", 398 | nick, 399 | format!( 400 | "Welcome to matrirc. Restoring session to {}", 401 | session.homeserver 402 | ), 403 | )) 404 | .await?; 405 | match matrix::login::restore_session( 406 | &session.homeserver, 407 | session.matrix_session, 408 | nick, 409 | irc_pass, 410 | ) 411 | .await 412 | { 413 | // XXX can't make TryFutureExt's or_else work, give up 414 | Ok(client) => Ok(client), 415 | Err(e) => { 416 | stream.send(proto::privmsg( 417 | "matrirc", 418 | nick, 419 | format!( 420 | "Restoring session failed: {}. Login again as follow or try to reconnect later.", 421 | e 422 | ), 423 | )) 424 | .await?; 425 | 426 | matrix_login_loop(stream, nick, irc_pass).await 427 | } 428 | } 429 | } 430 | -------------------------------------------------------------------------------- /src/matrix/room_mappings.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Error, Result}; 2 | use async_trait::async_trait; 3 | use lazy_static::lazy_static; 4 | use log::{trace, warn}; 5 | use matrix_sdk::{ 6 | room::Room, 7 | ruma::{OwnedRoomId, OwnedUserId}, 8 | RoomMemberships, 9 | }; 10 | use regex::Regex; 11 | use std::borrow::Cow; 12 | use std::collections::{ 13 | hash_map::{Entry, HashMap}, 14 | VecDeque, 15 | }; 16 | use std::sync::Arc; 17 | use tokio::sync::{RwLock, RwLockWriteGuard}; 18 | 19 | use crate::ircd; 20 | use crate::ircd::{ 21 | join_irc_chan, join_irc_chan_finish, 22 | proto::{IrcMessage, IrcMessageType}, 23 | IrcClient, 24 | }; 25 | use crate::matrirc::Matrirc; 26 | 27 | pub enum MatrixMessageType { 28 | Text, 29 | Emote, 30 | Notice, 31 | } 32 | 33 | #[derive(Debug, Clone)] 34 | struct TargetMessage { 35 | /// privmsg or notice 36 | message_type: IrcMessageType, 37 | /// will be either from in channel, or added as prefix if different from query name 38 | from: String, 39 | /// actual message 40 | text: String, 41 | } 42 | 43 | impl TargetMessage { 44 | fn new(message_type: IrcMessageType, from: String, text: String) -> Self { 45 | TargetMessage { 46 | message_type, 47 | from, 48 | text, 49 | } 50 | } 51 | } 52 | 53 | #[derive(Debug, Clone)] 54 | pub struct RoomTarget { 55 | /// the Arc/RwLock let us return/modify it without holding the mappings lock 56 | inner: Arc>, 57 | } 58 | 59 | #[derive(Debug, PartialEq)] 60 | enum RoomTargetType { 61 | /// room maps to a query e.g. single other member (or alone!) 62 | Query, 63 | /// room maps to a chan, and irc side has it joined 64 | Chan, 65 | /// room maps to a chan, but we're not joined: will force join 66 | /// on next message or user can join if they want 67 | LeftChan, 68 | /// Join in progress 69 | JoiningChan, 70 | } 71 | 72 | #[derive(Debug)] 73 | struct RoomTargetInner { 74 | /// channel name or query target 75 | target: String, 76 | /// query, channel joined, or... 77 | target_type: RoomTargetType, 78 | /// matrix user -> nick for channel. 79 | /// display names is a per-channel property, so we need to 80 | /// remember this for each user individually. 81 | /// In queries case, any non-trivial member is expanded as at 82 | /// the start of the message 83 | members: HashMap, 84 | /// list of irc names in channel 85 | /// used to enforce unicity, and perhaps later to convert 86 | /// `mentions:` to matric mentions 87 | names: HashMap, 88 | /// used for error messages, and to queue messages in joinin chan: 89 | /// if someone tries to grab a chan we're currently joining they just 90 | /// append to it instead of sending message to irc -- it needs its own lock 91 | /// because we'll modify it while holding read lock on room target (to get target type) 92 | /// XXX: If there are any pending messages left when we exit (because e.g. client exited while 93 | /// we weren't done with join yet), these messages will have been ack'd on matrix side and 94 | /// won't ever be sent to irc. This should be rare enough but probably worth fixing somehow... 95 | pending_messages: RwLock>, 96 | } 97 | 98 | pub struct Mappings { 99 | inner: RwLock, 100 | pub irc: IrcClient, 101 | mt: RoomTarget, 102 | } 103 | 104 | #[derive(Default)] 105 | struct MappingsInner { 106 | /// matrix room id to either chan or query 107 | rooms: HashMap, 108 | /// chan/query name to something that'll eat our message. 109 | /// For matrix rooms, it'll just send to the room as appropriate. 110 | /// 111 | /// Note that since we might want to promote/demote chans to query, 112 | /// targets does NOT include the hash: foobar = #foobar as far as 113 | /// dedup and received (irc -> matrirc) messages go 114 | /// TODO: add a metacommand to force iterating Matrirc.matrix().rooms() ? 115 | /// (probably want this to list available query targets too...) 116 | /// TODO: also reserve 'matrirc', irc.nick()... 117 | targets: HashMap>, 118 | } 119 | 120 | #[async_trait] 121 | pub trait MessageHandler { 122 | async fn handle_message(&self, message_type: MatrixMessageType, message: String) -> Result<()>; 123 | async fn set_target(&self, target: RoomTarget); 124 | } 125 | 126 | fn sanitize>(str: S) -> String { 127 | // replace with rust 1.70 OnceCell? eventually 128 | lazy_static! { 129 | static ref SANITIZE: Regex = Regex::new("[^a-zA-Z_-]+").unwrap(); 130 | } 131 | SANITIZE.replace_all(&str.into(), "").into() 132 | } 133 | 134 | pub fn room_name(room: &matrix_sdk::BaseRoom) -> String { 135 | if let Some(name) = room.cached_display_name() { 136 | return name.to_string(); 137 | } 138 | if let Some(name) = room.name() { 139 | return name.to_string(); 140 | } 141 | room.room_id().to_string() 142 | } 143 | 144 | trait InsertDedup { 145 | fn insert_deduped(&mut self, orig_key: &str, value: V) -> String; 146 | } 147 | 148 | impl InsertDedup for HashMap { 149 | fn insert_deduped(&mut self, orig_key: &str, value: V) -> String { 150 | let mut key: String = orig_key.to_string(); 151 | let mut count = 1; 152 | loop { 153 | if let Entry::Vacant(entry) = self.entry(key) { 154 | let found = entry.key().clone(); 155 | entry.insert(value); 156 | return found; 157 | } 158 | count += 1; 159 | key = format!("{}_{}", orig_key, count); 160 | } 161 | } 162 | } 163 | 164 | async fn fill_room_members( 165 | mut target_lock: RwLockWriteGuard<'_, RoomTargetInner>, 166 | room: Room, 167 | room_name: String, 168 | ) -> Result<()> { 169 | let members = room.members(RoomMemberships::ACTIVE).await?; 170 | match members.len() { 171 | 0 => { 172 | // XXX remove room from mappings, but this should never happen anyway 173 | return Err(Error::msg(format!("Message in empty room {}?", room_name))); 174 | } 175 | // promote to chan if other member name isn't room name 176 | 1 | 2 => { 177 | if members.iter().all(|m| m.name() != room_name) { 178 | target_lock.target_type = RoomTargetType::LeftChan; 179 | } 180 | } 181 | _ => target_lock.target_type = RoomTargetType::LeftChan, 182 | } 183 | for member in members { 184 | // XXX qol improvement: rename own user id to irc.nick 185 | // ensure we preseve room target's name to simplify member's nick in queries 186 | let member_name = match member.name() { 187 | n if n == room_name => target_lock.target.clone(), 188 | n => sanitize(n), 189 | }; 190 | let name = target_lock 191 | .names 192 | .insert_deduped(&member_name, member.user_id().to_owned()); 193 | target_lock.members.insert(member.user_id().into(), name); 194 | } 195 | Ok(()) 196 | } 197 | 198 | impl RoomTarget { 199 | fn new>(target_type: RoomTargetType, target: S) -> Self { 200 | RoomTarget { 201 | inner: Arc::new(RwLock::new(RoomTargetInner { 202 | target: target.into(), 203 | target_type, 204 | members: HashMap::new(), 205 | names: HashMap::new(), 206 | pending_messages: RwLock::new(VecDeque::new()), 207 | })), 208 | } 209 | } 210 | fn query>(target: S) -> Self { 211 | RoomTarget::new(RoomTargetType::Query, target) 212 | } 213 | pub async fn target(&self) -> String { 214 | self.inner.read().await.target.clone() 215 | } 216 | 217 | async fn join_chan(&self, irc: &IrcClient) -> bool { 218 | let mut lock = self.inner.write().await; 219 | match &lock.target_type { 220 | RoomTargetType::LeftChan => (), 221 | RoomTargetType::Query => (), 222 | // got raced or already joined 223 | RoomTargetType::JoiningChan => return false, 224 | RoomTargetType::Chan => return false, 225 | }; 226 | lock.target_type = RoomTargetType::JoiningChan; 227 | let chan = format!("#{}", lock.target); 228 | drop(lock); 229 | 230 | // we need to initate the join before getting members in another task 231 | if let Err(e) = join_irc_chan(irc, &chan).await { 232 | warn!("Could not join irc: {e}"); 233 | // XXX send message to irc through matrirc query 234 | return false; 235 | } 236 | let target = self.clone(); 237 | let irc = irc.clone(); 238 | tokio::spawn(async move { 239 | let names_list = target.names_list().await; 240 | if let Err(e) = join_irc_chan_finish(&irc, chan, names_list).await { 241 | warn!("Could not join irc: {e}"); 242 | // XXX send message to irc through matrirc query 243 | return; 244 | } 245 | if let Err(e) = target.finish_join(&irc).await { 246 | warn!("Could not finish join: {e}"); 247 | // XXX irc message 248 | } 249 | }); 250 | true 251 | } 252 | 253 | async fn names_list(&self) -> Vec { 254 | // need to clone because of lock -- could do better? 255 | self.inner.read().await.names.keys().cloned().collect() 256 | } 257 | 258 | async fn finish_join(&self, irc: &IrcClient) -> Result<()> { 259 | self.flush_pending_messages(irc).await?; 260 | self.inner.write().await.target_type = RoomTargetType::Chan; 261 | // recheck in case some new message was stashed before we got write lock 262 | self.flush_pending_messages(irc).await?; 263 | Ok(()) 264 | } 265 | 266 | pub async fn member_join( 267 | &self, 268 | irc: &IrcClient, 269 | member: OwnedUserId, 270 | name: Option, 271 | ) -> Result<()> { 272 | let mut guard = self.inner.write().await; 273 | let chan = format!("#{}", guard.target); 274 | trace!("{:?} ({}) joined {}", name, member, chan); 275 | // XXX wait a bit and list room members if name is none? 276 | let name = sanitize(name.unwrap_or_else(|| member.to_string())); 277 | let name = guard.names.insert_deduped(&name, member.clone()); 278 | guard.members.insert(member.into(), name.clone()); 279 | drop(guard); 280 | if !self.join_chan(irc).await { 281 | // already joined chan, send join to irc 282 | irc.send(ircd::proto::join(Some(name), chan)).await?; 283 | } 284 | Ok(()) 285 | } 286 | 287 | pub async fn member_part(&self, irc: &IrcClient, member: OwnedUserId) -> Result<()> { 288 | let mut guard = self.inner.write().await; 289 | let Some(name) = guard.members.remove(member.as_str()) else { 290 | // not in chan 291 | return Ok(()); 292 | }; 293 | let chan = format!("#{}", guard.target); 294 | trace!("{:?} ({}) part {}", name, member, chan); 295 | let _ = guard.names.remove(&name); 296 | drop(guard); 297 | irc.send(ircd::proto::part(Some(name), chan)).await?; 298 | Ok(()) 299 | } 300 | 301 | /// error will be sent next time a message from channel is sent 302 | /// (or when it's finished joining in case of chan trying to join) 303 | async fn set_error(self, error: String) -> Self { 304 | self.inner 305 | .read() 306 | .await 307 | .pending_messages 308 | .write() 309 | .await 310 | .push_back(TargetMessage::new( 311 | IrcMessageType::Notice, 312 | "matrirc".to_string(), 313 | error, 314 | )); 315 | self 316 | } 317 | 318 | async fn target_message_to_irc(&self, irc: &IrcClient, message: TargetMessage) -> IrcMessage { 319 | match &*self.inner.read().await { 320 | RoomTargetInner { 321 | target, 322 | target_type: RoomTargetType::Query, 323 | .. 324 | } => IrcMessage { 325 | message_type: message.message_type, 326 | from: target.clone(), 327 | target: irc.nick.clone(), 328 | text: if &message.from == target { 329 | message.text 330 | } else { 331 | format!("<{}> {}", message.from, message.text) 332 | }, 333 | }, 334 | // mostly normal chan, but finish_join can also use ths on JoningChan 335 | // we could error on LeftChan but what's the point? 336 | RoomTargetInner { target, .. } => IrcMessage { 337 | message_type: message.message_type, 338 | from: message.from, 339 | target: format!("#{}", target), 340 | text: message.text, 341 | }, 342 | } 343 | } 344 | 345 | pub async fn flush_pending_messages(&self, irc: &IrcClient) -> Result<()> { 346 | let inner = self.inner.read().await; 347 | if !inner.pending_messages.read().await.is_empty() { 348 | while let Some(target_message) = inner.pending_messages.write().await.pop_front() { 349 | for irc_message in self.target_message_to_irc(irc, target_message).await { 350 | irc.send(irc_message).await? 351 | } 352 | } 353 | }; 354 | Ok(()) 355 | } 356 | 357 | pub async fn send_text_to_irc( 358 | &self, 359 | irc: &IrcClient, 360 | message_type: IrcMessageType, 361 | sender: &String, 362 | text: S, 363 | ) -> Result<()> 364 | where 365 | S: Into, 366 | { 367 | let inner = self.inner.read().await; 368 | let message = TargetMessage { 369 | message_type, 370 | from: inner 371 | .members 372 | .get(sender) 373 | .map(Cow::Borrowed) 374 | .unwrap_or_else(|| Cow::Owned(sender.clone())) 375 | .to_string(), 376 | text: text.into(), 377 | }; 378 | match inner.target_type { 379 | RoomTargetType::LeftChan => { 380 | trace!("Queueing message and joining chan"); 381 | inner.pending_messages.write().await.push_back(message); 382 | drop(inner); 383 | self.join_chan(irc).await; 384 | return Ok(()); 385 | } 386 | RoomTargetType::JoiningChan => { 387 | trace!("Queueing message (join in progress)"); 388 | inner.pending_messages.write().await.push_back(message); 389 | return Ok(()); 390 | } 391 | _ => (), 392 | } 393 | drop(inner); 394 | 395 | // really send -- start with pending messages if any 396 | self.flush_pending_messages(irc).await?; 397 | 398 | for irc_message in self.target_message_to_irc(irc, message).await { 399 | irc.send(irc_message).await? 400 | } 401 | Ok(()) 402 | } 403 | pub async fn send_simple_query(&self, irc: &IrcClient, text: S) -> Result<()> 404 | where 405 | S: Into, 406 | { 407 | self.send_text_to_irc(irc, IrcMessageType::Privmsg, &self.target().await, text) 408 | .await 409 | } 410 | } 411 | 412 | impl Mappings { 413 | pub fn new(irc: IrcClient) -> Self { 414 | Mappings { 415 | inner: MappingsInner::default().into(), 416 | irc, 417 | mt: RoomTarget::query("matrirc"), 418 | } 419 | } 420 | pub async fn room_target(&self, room: &Room) -> RoomTarget { 421 | match self.try_room_target(room).await { 422 | Ok(target) => target, 423 | Err(e) => { 424 | // return error into matrirc channel instead 425 | self.mt 426 | .clone() 427 | .set_error(format!("Could not find or create target: {}", e)) 428 | .await 429 | } 430 | } 431 | } 432 | pub async fn matrirc_query(&self, message: S) -> Result<()> 433 | where 434 | S: Into, 435 | { 436 | self.mt.send_simple_query(&self.irc, message).await 437 | } 438 | 439 | pub async fn insert_deduped( 440 | &self, 441 | candidate: &str, 442 | target: &(impl MessageHandler + Send + Sync + Clone + 'static), 443 | ) -> RoomTarget { 444 | let mut guard = self.inner.write().await; 445 | let name = guard 446 | .targets 447 | .insert_deduped(candidate, Box::new(target.clone())); 448 | let room_target = RoomTarget::query(name); 449 | target.set_target(room_target.clone()).await; 450 | room_target 451 | } 452 | 453 | pub async fn remove_target(&self, name: &str) { 454 | self.inner.write().await.targets.remove(name); 455 | } 456 | 457 | // note this cannot use insert_free_target because we want to keep write lock 458 | // long enough to check for deduplicate and it's a bit of a mess; it could be done 459 | // with a more generic 'insert_free_target' that takes a couple of callbacks but 460 | // it's just not worth it 461 | async fn try_room_target(&self, room: &Room) -> Result { 462 | // happy case first 463 | if let Some(target) = self.inner.read().await.rooms.get(room.room_id()) { 464 | return Ok(target.clone()); 465 | } 466 | 467 | // create a new and try to insert it... 468 | let desired_name = sanitize(room_name(room)); 469 | 470 | // lock mappings and insert into hashs 471 | let mut mappings = self.inner.write().await; 472 | if let Some(target) = mappings.rooms.get(room.room_id()) { 473 | // got raced 474 | return Ok(target.clone()); 475 | } 476 | // find unique irc name 477 | let name = mappings 478 | .targets 479 | .insert_deduped(&desired_name, Box::new(room.clone())); 480 | trace!("Creating room {}", name); 481 | // create a query anyway, we'll promote it when we get members 482 | let target = RoomTarget::query(&name); 483 | mappings.rooms.insert(room.room_id().into(), target.clone()); 484 | 485 | // lock target and release mapping lock we no longer need 486 | let target_lock = target.inner.write().await; 487 | drop(mappings); 488 | 489 | let room_clone = room.clone(); 490 | // XXX do this in a tokio::spawn task: 491 | // can't seem to pass target_lock as its lifetime depends on target (or 492 | // its clone), but we can't pass target and target lock because target can't be used while 493 | // target_lock is alive... 494 | fill_room_members(target_lock, room_clone, desired_name).await?; 495 | Ok(target) 496 | } 497 | 498 | pub async fn to_matrix( 499 | &self, 500 | name: &str, 501 | message_type: MatrixMessageType, 502 | message: String, 503 | ) -> Result<()> { 504 | let name = match name.strip_prefix('#') { 505 | Some(suffix) => suffix, 506 | None => name, 507 | }; 508 | if let Some(target) = self.inner.read().await.targets.get(name) { 509 | target.handle_message(message_type, message).await 510 | } else { 511 | Err(Error::msg(format!("No such target {}", name))) 512 | } 513 | } 514 | 515 | pub async fn sync_rooms(&self, matrirc: &Matrirc) -> Result<()> { 516 | let client = matrirc.matrix(); 517 | for joined in client.joined_rooms() { 518 | if joined.is_tombstoned() { 519 | trace!( 520 | "Skipping tombstoned {}", 521 | joined 522 | .name() 523 | .unwrap_or_else(|| joined.room_id().to_string()) 524 | ); 525 | continue; 526 | } 527 | self.try_room_target(&joined).await?; 528 | } 529 | self.matrirc_query("Finished initial room sync").await?; 530 | Ok(()) 531 | } 532 | } 533 | --------------------------------------------------------------------------------