#[macro_use(anyhow)] extern crate anyhow; #[macro_use] extern crate log; #[macro_use] extern crate serde; mod config; mod event; use std::{env, net::IpAddr, process::exit, sync::Arc, time::Duration}; use anyhow::Context; use constant_time_eq::constant_time_eq; use event::{GitlabEvent, GitlabEventExt}; use http::StatusCode; use matrix_sdk::{ config::SyncSettings, room::Joined, ruma::{events::room::message::RoomMessageEventContent, OwnedRoomOrAliasId}, BaseRoom, Client, }; use tokio::sync::mpsc; use warp::Filter; use crate::event::{MergeRequestAction, PipelineStatus}; async fn build_sync_settings(matrix_client: &Client) -> SyncSettings { let mut settings = SyncSettings::default().timeout(Duration::from_secs(30)); if let Some(token) = matrix_client.sync_token().await { settings = settings.token(token); } settings } async fn matrix_connect(config: &config::Config) -> anyhow::Result { let client = Client::builder() .server_name(config.user_id.server_name()) .user_agent(format!("{}/{}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION"))) .build() .await?; client .login_username(&config.user_id, &config.password) .initial_device_display_name("Bebot") .send() .await?; info!("Connected to matrix as {}; waiting for first sync", config.user_id); let settings = build_sync_settings(&client).await; client.sync_once(settings).await?; info!("First matrix sync complete"); let sync_client = client.clone(); tokio::spawn(async move { let settings = build_sync_settings(&sync_client).await; if let Err(err) = sync_client.sync(settings).await { error!("Matrix sync failed: {}", err); exit(1); } }); Ok(client) } async fn ensure_matrix_room_joined(matrix_client: &Client, room_id: &OwnedRoomOrAliasId) -> anyhow::Result { fn room_matches(a_room: &BaseRoom, our_room: &OwnedRoomOrAliasId) -> bool { let our_room_str = our_room.as_str(); a_room.room_id().as_str() == our_room_str || a_room .canonical_alias() .iter() .find(|alias| alias.as_str() == our_room_str) .is_some() || a_room .alt_aliases() .iter() .find(|alias| alias.as_str() == our_room_str) .is_some() } let mut room = matrix_client .joined_rooms() .iter() .find(|a_room| room_matches(*a_room, room_id)) .map(|room| room.clone()); if room.is_none() { if let Some(invited) = matrix_client .invited_rooms() .iter() .find(|a_room| room_matches(*a_room, room_id)) { invited.accept_invitation().await?; } else { matrix_client.join_room_by_id_or_alias(room_id, &[]).await?; } let settings = build_sync_settings(&matrix_client).await; matrix_client.sync_once(settings).await?; room = matrix_client .joined_rooms() .iter() .find(|a_room| room_matches(*a_room, room_id)) .map(|room| room.clone()); } room.ok_or_else(|| anyhow!("Unable to join room {}", room_id)) } fn build_gitlab_messages(event: &GitlabEvent) -> Vec { let project = event.project(); let refname = event::parse_ref(event.r#ref()); event .titles() .iter() .map(|title| { format!( "\\[{}\\] `{}` *{}* {}", project.path_with_namespace, refname, event.user(), title, ) }) .collect() } async fn handle_gitlab_event( event: GitlabEvent, room_id: &OwnedRoomOrAliasId, matrix_client: &Client, ) -> anyhow::Result<()> { if let GitlabEvent::MergeRequest { object_attributes, .. } = &event { if object_attributes.action == MergeRequestAction::Other { return Ok(()); } } else if let GitlabEvent::Pipeline { object_attributes, .. } = &event { if object_attributes.status == PipelineStatus::Other { return Ok(()); } } let room = ensure_matrix_room_joined(matrix_client, room_id).await?; for msg in build_gitlab_messages(&event) { debug!("Sending message to {}: {}", room_id, msg); let msg_content = RoomMessageEventContent::text_markdown(&msg); room.send(msg_content, None).await?; } Ok(()) } async fn run() -> anyhow::Result<()> { let config_path = env::args() .nth(1) .ok_or_else(|| anyhow!("Config file should be passed as only parameter"))?; let config = Arc::new(config::load(config_path).await?); let addr = config .bind_address .as_ref() .map(|ba| ba.clone()) .unwrap_or_else(|| "127.0.0.1".to_string()) .parse::() .context("Failed to parse bind_address")?; let port = config.bind_port.unwrap_or(3000); let matrix_client = matrix_connect(&config).await.context("Failed to connect to Matrix")?; let (event_tx, mut event_rx) = mpsc::channel::<(GitlabEvent, OwnedRoomOrAliasId)>(100); tokio::spawn(async move { while let Some((event, room)) = event_rx.recv().await { if let Err(err) = handle_gitlab_event(event, &room, &matrix_client).await { warn!("Failed to handle payload: {}", err); } } }); let gitlab_root_path = if let Some(url_prefix) = config.url_prefix.as_ref() { url_prefix.split('/').fold(warp::any().boxed(), |last, segment| { if segment.is_empty() { last } else { last.and(warp::path(segment.to_string())).boxed() } }) } else { warp::any().boxed() }; let gitlab = gitlab_root_path .and(warp::path!("hooks" / "gitlab")) .and(warp::post()) .and(warp::header::("x-gitlab-token")) .and(warp::body::json()) .then(move |token: String, event: event::GitlabEvent| { let config = Arc::clone(&config); let event_tx = event_tx.clone(); async move { let project = event.project(); let config_key = project.web_url.replace("http://", "").replace("https://", ""); if let Some(repo_config) = config.repo_configs.get(&config_key) { if !constant_time_eq(token.as_bytes(), repo_config.token.as_bytes()) { warn!("Invalid token for repo '{}'", config_key); warp::reply::with_status("Invalid token", StatusCode::FORBIDDEN) } else { debug!("payload: {:?}", event); if let Some(room) = repo_config.room.as_ref().or(config.default_room.as_ref()) { if let Err(err) = event_tx.send((event, room.clone())).await { warn!("Failed to enqueue payload: {}", err); } warp::reply::with_status("OK", StatusCode::OK) } else { info!("Channel not configured for repo '{}'", config_key); warp::reply::with_status("Matrix room not configured for repo", StatusCode::NOT_FOUND) } } } else { info!("Repo '{}' unconfigured", config_key); warp::reply::with_status("Repo not configured", StatusCode::NOT_FOUND) } } }); let routes = gitlab.with(warp::log("bebot")); warp::serve(routes).run((addr, port)).await; Ok(()) } #[tokio::main] async fn main() { let lenv = env_logger::Env::new() .filter("BEBOT_LOG") .write_style("BEBOT_LOG_STYLE"); env_logger::init_from_env(lenv); if let Err(err) = run().await { error!("{}", err); exit(1); } }