diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5c7f11d0..edc60168 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -94,14 +94,25 @@ jobs: strategy: matrix: name: - - linux / appservice / stable - - macOS / appservice / stable + - linux / appservice / stable / actix + - macOS / appservice / stable / actix + - linux / appservice / stable / warp + - macOS / appservice / stable / warp include: - - name: linux / appservice / stable + - name: linux / appservice / stable / actix + cargo_args: --features actix - - name: macOS / appservice / stable + - name: macOS / appservice / stable / actix os: macOS-latest + cargo_args: --features actix + + - name: linux / appservice / stable / warp + cargo_args: --features warp + + - name: macOS / appservice / stable / warp + os: macOS-latest + cargo_args: --features warp steps: - name: Checkout @@ -119,19 +130,19 @@ jobs: uses: actions-rs/cargo@v1 with: command: clippy - args: --manifest-path matrix_sdk_appservice/Cargo.toml -- -D warnings + args: --manifest-path matrix_sdk_appservice/Cargo.toml ${{ matrix.cargo_args }} -- -D warnings - name: Build uses: actions-rs/cargo@v1 with: command: build - args: --manifest-path matrix_sdk_appservice/Cargo.toml + args: --manifest-path matrix_sdk_appservice/Cargo.toml ${{ matrix.cargo_args }} - name: Test uses: actions-rs/cargo@v1 with: command: test - args: --manifest-path matrix_sdk_appservice/Cargo.toml + args: --manifest-path matrix_sdk_appservice/Cargo.toml ${{ matrix.cargo_args }} test-features: name: ${{ matrix.name }} diff --git a/matrix_sdk/src/client.rs b/matrix_sdk/src/client.rs index 2545d75c..47227b47 100644 --- a/matrix_sdk/src/client.rs +++ b/matrix_sdk/src/client.rs @@ -480,8 +480,8 @@ impl RequestConfig { /// Force sending authorization even if the endpoint does not require it. /// Default is only sending authorization if it is required - #[cfg(feature = "require_auth_for_profile_requests")] - #[cfg_attr(feature = "docs", doc(cfg(require_auth_for_profile_requests)))] + #[cfg(any(feature = "require_auth_for_profile_requests", feature = "appservice"))] + #[cfg_attr(feature = "docs", doc(cfg(any(require_auth_for_profile_requests, appservice))))] pub(crate) fn force_auth(mut self) -> Self { self.force_auth = true; self @@ -1363,8 +1363,14 @@ impl Client { ) -> Result { info!("Registering to {}", self.homeserver().await); + #[cfg(not(feature = "appservice"))] + let config = None; + + #[cfg(feature = "appservice")] + let config = Some(self.http_client.request_config.force_auth()); + let request = registration.into(); - self.send(request, None).await + self.send(request, config).await } /// Get or upload a sync filter. diff --git a/matrix_sdk_appservice/Cargo.toml b/matrix_sdk_appservice/Cargo.toml index 59e34327..143b1afb 100644 --- a/matrix_sdk_appservice/Cargo.toml +++ b/matrix_sdk_appservice/Cargo.toml @@ -8,10 +8,10 @@ name = "matrix-sdk-appservice" version = "0.1.0" [features] -default = ["actix"] +default = [] actix = ["actix-rt", "actix-web"] -docs = [] +docs = ["actix", "warp"] [dependencies] actix-rt = { version = "2", optional = true } @@ -21,11 +21,13 @@ futures = "0.3" futures-util = "0.3" http = "0.2" regex = "1" -serde = "1.0.126" +serde = "1" +serde_json = "1" serde_yaml = "0.8" thiserror = "1.0" tracing = "0.1" url = "2" +warp = { git = "https://github.com/seanmonstar/warp.git", rev = "629405", optional = true, default-features = false, features = ["multipart", "websocket"] } matrix-sdk = { version = "0.2", path = "../matrix_sdk", default-features = false, features = ["appservice", "native-tls"] } @@ -36,12 +38,11 @@ features = ["client-api-c", "appservice-api-s", "unstable-pre-spec"] [dev-dependencies] env_logger = "0.8" mockito = "0.30" -serde_json = "1" tokio = { version = "1", default-features = false, features = ["rt-multi-thread", "macros"] } tracing-subscriber = "0.2" -matrix-sdk-test = { version = "0.2", path = "../matrix_sdk_test" } +matrix-sdk-test = { version = "0.2", path = "../matrix_sdk_test", features = ["appservice"] } [[example]] -name = "actix_autojoin" -required-features = ["actix"] +name = "autojoin" +required-features = ["warp"] diff --git a/matrix_sdk_appservice/examples/actix_autojoin.rs b/matrix_sdk_appservice/examples/actix_autojoin.rs deleted file mode 100644 index 4c91dd3d..00000000 --- a/matrix_sdk_appservice/examples/actix_autojoin.rs +++ /dev/null @@ -1,67 +0,0 @@ -use std::{convert::TryFrom, env}; - -use actix_web::{App, HttpServer}; -use matrix_sdk::{ - async_trait, - events::{ - room::member::{MemberEventContent, MembershipState}, - SyncStateEvent, - }, - identifiers::UserId, - room::Room, - EventHandler, -}; -use matrix_sdk_appservice::{Appservice, AppserviceRegistration}; - -struct AppserviceEventHandler { - appservice: Appservice, -} - -impl AppserviceEventHandler { - pub fn new(appservice: Appservice) -> Self { - Self { appservice } - } -} - -#[async_trait] -impl EventHandler for AppserviceEventHandler { - async fn on_room_member(&self, room: Room, event: &SyncStateEvent) { - if !self.appservice.user_id_is_in_namespace(&event.state_key).unwrap() { - dbg!("not an appservice user"); - return; - } - - if let MembershipState::Invite = event.content.membership { - let user_id = UserId::try_from(event.state_key.clone()).unwrap(); - - let mut appservice = self.appservice.clone(); - appservice.register(user_id.localpart()).await.unwrap(); - - let client = appservice.virtual_user(user_id.localpart()).await.unwrap(); - - client.join_room_by_id(room.room_id()).await.unwrap(); - } - } -} - -#[actix_web::main] -pub async fn main() -> std::io::Result<()> { - env::set_var("RUST_LOG", "actix_web=debug,actix_server=info,matrix_sdk=debug"); - tracing_subscriber::fmt::init(); - - let homeserver_url = "http://localhost:8008"; - let server_name = "localhost"; - let registration = - AppserviceRegistration::try_from_yaml_file("./tests/registration.yaml").unwrap(); - - let mut appservice = Appservice::new(homeserver_url, server_name, registration).await.unwrap(); - - let event_handler = AppserviceEventHandler::new(appservice.clone()); - - appservice.set_event_handler(Box::new(event_handler)).await.unwrap(); - - HttpServer::new(move || App::new().service(appservice.actix_service())) - .bind(("0.0.0.0", 8090))? - .run() - .await -} diff --git a/matrix_sdk_appservice/examples/autojoin.rs b/matrix_sdk_appservice/examples/autojoin.rs new file mode 100644 index 00000000..0fbd99f7 --- /dev/null +++ b/matrix_sdk_appservice/examples/autojoin.rs @@ -0,0 +1,75 @@ +use std::{convert::TryFrom, env}; + +use matrix_sdk_appservice::{ + matrix_sdk::{ + async_trait, + events::{ + room::member::{MemberEventContent, MembershipState}, + SyncStateEvent, + }, + identifiers::UserId, + room::Room, + EventHandler, + }, + Appservice, AppserviceRegistration, +}; +use tracing::{error, trace}; + +struct AppserviceEventHandler { + appservice: Appservice, +} + +impl AppserviceEventHandler { + pub fn new(appservice: Appservice) -> Self { + Self { appservice } + } + + pub async fn handle_room_member( + &self, + room: Room, + event: &SyncStateEvent, + ) -> Result<(), Box> { + if !self.appservice.user_id_is_in_namespace(&event.state_key)? { + trace!("not an appservice user: {}", event.state_key); + } else if let MembershipState::Invite = event.content.membership { + let user_id = UserId::try_from(event.state_key.clone())?; + + let appservice = self.appservice.clone(); + appservice.register_virtual_user(user_id.localpart()).await?; + + let client = appservice.virtual_user_client(user_id.localpart()).await?; + + client.join_room_by_id(room.room_id()).await?; + } + + Ok(()) + } +} + +#[async_trait] +impl EventHandler for AppserviceEventHandler { + async fn on_room_member(&self, room: Room, event: &SyncStateEvent) { + match self.handle_room_member(room, event).await { + Ok(_) => (), + Err(error) => error!("{:?}", error), + } + } +} + +#[tokio::main] +pub async fn main() -> Result<(), Box> { + env::set_var("RUST_LOG", "matrix_sdk=debug,matrix_sdk_appservice=debug"); + tracing_subscriber::fmt::init(); + + let homeserver_url = "http://localhost:8008"; + let server_name = "localhost"; + let registration = AppserviceRegistration::try_from_yaml_file("./tests/registration.yaml")?; + + let mut appservice = Appservice::new(homeserver_url, server_name, registration).await?; + appservice.set_event_handler(Box::new(AppserviceEventHandler::new(appservice.clone()))).await?; + + let (host, port) = appservice.registration().get_host_and_port()?; + appservice.run(host, port).await?; + + Ok(()) +} diff --git a/matrix_sdk_appservice/src/error.rs b/matrix_sdk_appservice/src/error.rs index 3b59bef0..1e08c192 100644 --- a/matrix_sdk_appservice/src/error.rs +++ b/matrix_sdk_appservice/src/error.rs @@ -31,6 +31,9 @@ pub enum Error { #[error("no client for localpart found")] NoClientForLocalpart, + #[error("could not convert host:port to socket addr")] + HostPortToSocketAddrs, + #[error(transparent)] HttpRequest(#[from] ruma::api::error::FromHttpRequestError), @@ -61,6 +64,13 @@ pub enum Error { #[error(transparent)] SerdeYaml(#[from] serde_yaml::Error), + #[error(transparent)] + SerdeJson(#[from] serde_json::Error), + + #[cfg(feature = "warp")] + #[error("warp rejection: {0}")] + WarpRejection(String), + #[cfg(feature = "actix")] #[error(transparent)] Actix(#[from] actix_web::Error), @@ -72,3 +82,13 @@ pub enum Error { #[cfg(feature = "actix")] impl actix_web::error::ResponseError for Error {} + +#[cfg(feature = "warp")] +impl warp::reject::Reject for Error {} + +#[cfg(feature = "warp")] +impl From for Error { + fn from(rejection: warp::Rejection) -> Self { + Self::WarpRejection(format!("{:?}", rejection)) + } +} diff --git a/matrix_sdk_appservice/src/lib.rs b/matrix_sdk_appservice/src/lib.rs index 2b27fb57..3a445460 100644 --- a/matrix_sdk_appservice/src/lib.rs +++ b/matrix_sdk_appservice/src/lib.rs @@ -36,10 +36,10 @@ //! # //! # use matrix_sdk::{async_trait, EventHandler}; //! # -//! # struct AppserviceEventHandler; +//! # struct MyEventHandler; //! # //! # #[async_trait] -//! # impl EventHandler for AppserviceEventHandler {} +//! # impl EventHandler for MyEventHandler {} //! # //! use matrix_sdk_appservice::{Appservice, AppserviceRegistration}; //! @@ -59,7 +59,7 @@ //! ")?; //! //! let mut appservice = Appservice::new(homeserver_url, server_name, registration).await?; -//! appservice.set_event_handler(Box::new(AppserviceEventHandler)).await?; +//! appservice.set_event_handler(Box::new(MyEventHandler)).await?; //! //! let (host, port) = appservice.registration().get_host_and_port()?; //! appservice.run(host, port).await?; @@ -74,8 +74,8 @@ //! [matrix-org/matrix-rust-sdk#228]: https://github.com/matrix-org/matrix-rust-sdk/issues/228 //! [examples directory]: https://github.com/matrix-org/matrix-rust-sdk/tree/master/matrix_sdk_appservice/examples -#[cfg(not(any(feature = "actix",)))] -compile_error!("one webserver feature must be enabled. available ones: `actix`"); +#[cfg(not(any(feature = "actix", feature = "warp")))] +compile_error!("one webserver feature must be enabled. available ones: `actix`, `warp`"); use std::{ convert::{TryFrom, TryInto}, @@ -86,32 +86,28 @@ use std::{ }; use dashmap::DashMap; -use http::Uri; -use matrix_sdk::{reqwest::Url, Client, ClientConfig, EventHandler, HttpError, Session}; +pub use error::Error; +use http::{uri::PathAndQuery, Uri}; +pub use matrix_sdk; +use matrix_sdk::{reqwest::Url, Bytes, Client, ClientConfig, EventHandler, HttpError, Session}; use regex::Regex; #[doc(inline)] -pub use ruma::api::appservice as api; +pub use ruma::api::{appservice as api, appservice::Registration}; use ruma::{ api::{ - appservice::Registration, client::{ error::ErrorKind, - r0::{ - account::register::{LoginType, Request as RegistrationRequest}, - uiaa::UiaaResponse, - }, + r0::{account::register, uiaa::UiaaResponse}, }, error::{FromHttpResponseError, ServerError}, }, assign, identifiers, DeviceId, ServerNameBox, UserId, }; -use tracing::warn; +use tracing::{info, warn}; -#[cfg(feature = "actix")] -mod actix; mod error; +mod webserver; -pub use error::Error; pub type Result = std::result::Result; pub type Host = String; pub type Port = u16; @@ -250,8 +246,8 @@ impl Appservice { let appservice = Appservice { homeserver_url, server_name, registration, clients }; - // we cache the [`MainUser`] by default - appservice.virtual_user_with_config(sender_localpart, client_config).await?; + // we create and cache the [`MainUser`] by default + appservice.create_and_cache_client(&sender_localpart, client_config).await?; Ok(appservice) } @@ -267,24 +263,29 @@ impl Appservice { /// by calling this method again or by calling [`Self::get_cached_client()`] /// which is non-async convenience wrapper. /// + /// Note that if you want to do actions like joining rooms with a virtual + /// user it needs to be registered first. `Self::register_virtual_user()` + /// can be used for that purpose. + /// /// # Arguments /// /// * `localpart` - The localpart of the user we want assert our identity to /// /// [registration]: https://matrix.org/docs/spec/application_service/r0.1.2#registration /// [assert the identity]: https://matrix.org/docs/spec/application_service/r0.1.2#identity-assertion - pub async fn virtual_user(&self, localpart: impl AsRef) -> Result { - let client = self.virtual_user_with_config(localpart, ClientConfig::default()).await?; + pub async fn virtual_user_client(&self, localpart: impl AsRef) -> Result { + let client = + self.virtual_user_client_with_config(localpart, ClientConfig::default()).await?; Ok(client) } - /// Same as [`Self::virtual_user()`] but with the ability to pass in a - /// [`ClientConfig`] + /// Same as [`Self::virtual_user_client()`] but with the ability to pass in + /// a [`ClientConfig`] /// /// Since this method is a singleton follow-up calls with different /// [`ClientConfig`]s will be ignored. - pub async fn virtual_user_with_config( + pub async fn virtual_user_client_with_config( &self, localpart: impl AsRef, config: ClientConfig, @@ -295,38 +296,49 @@ impl Appservice { let client = if let Some(client) = self.clients.get(localpart) { client.clone() } else { - let user_id = UserId::parse_with_server_name(localpart, &self.server_name)?; - - // The `as_token` in the `Session` maps to the [`MainUser`] - // (`sender_localpart`) by default, so we don't need to assert identity - // in that case - if localpart != self.registration.sender_localpart { - config.get_request_config().assert_identity(); - } - - let client = Client::new_with_config(self.homeserver_url.clone(), config)?; - - let session = Session { - access_token: self.registration.as_token.clone(), - user_id: user_id.clone(), - // TODO: expose & proper E2EE - device_id: DeviceId::new(), - }; - - client.restore_login(session).await?; - self.clients.insert(localpart.to_owned(), client.clone()); - - client + self.create_and_cache_client(localpart, config).await? }; Ok(client) } + async fn create_and_cache_client( + &self, + localpart: &str, + config: ClientConfig, + ) -> Result { + let user_id = UserId::parse_with_server_name(localpart, &self.server_name)?; + + // The `as_token` in the `Session` maps to the [`MainUser`] + // (`sender_localpart`) by default, so we don't need to assert identity + // in that case + let config = if localpart != self.registration.sender_localpart { + let request_config = config.get_request_config().assert_identity(); + config.request_config(request_config) + } else { + config + }; + + let client = Client::new_with_config(self.homeserver_url.clone(), config)?; + + let session = Session { + access_token: self.registration.as_token.clone(), + user_id: user_id.clone(), + // TODO: expose & proper E2EE + device_id: DeviceId::new(), + }; + + client.restore_login(session).await?; + self.clients.insert(localpart.to_owned(), client.clone()); + + Ok(client) + } + /// Get cached [`Client`] /// /// Will return the client for the given `localpart` if previously - /// constructed with [`Self::virtual_user()`] or - /// [`Self::virtual_user_with_config()`]. + /// constructed with [`Self::virtual_user_client()`] or + /// [`Self::virtual_user_client_with_config()`]. /// /// If no `localpart` is given it assumes the [`MainUser`]'s `localpart`. If /// no client for `localpart` is found it will return an Error. @@ -338,9 +350,22 @@ impl Appservice { Ok(entry.value().clone()) } - /// Convenience wrapper around [`Client::set_event_handler()`] + /// Convenience wrapper around [`Client::set_event_handler()`] that attaches + /// the event handler to the [`MainUser`]'s [`Client`] /// - /// Attaches the event handler to the [`MainUser`]'s [`Client`] + /// Note that the event handler in the [`Appservice`] context only triggers + /// [`join` room `timeline` events], so no state events or events from the + /// `invite`, `knock` or `leave` scope. The rationale behind that is + /// that incoming Appservice transactions from the homeserver are not + /// necessarily bound to a specific user but can cover a multitude of + /// namespaces, and as such the Appservice basically only "observes + /// joined rooms". Also currently homeservers only push PDUs to appservices, + /// no EDUs. There's the open [MSC2409] regarding supporting EDUs in the + /// future, though it seems to be planned to put EDUs into a different + /// JSON key than `events` to stay backwards compatible. + /// + /// [`join` room `timeline` events]: https://spec.matrix.org/unstable/client-server-api/#get_matrixclientr0sync + /// [MSC2409]: https://github.com/matrix-org/matrix-doc/pull/2409 pub async fn set_event_handler(&mut self, handler: Box) -> Result<()> { let client = self.get_cached_client(None)?; @@ -349,17 +374,17 @@ impl Appservice { Ok(()) } - /// Register a virtual user by sending a [`RegistrationRequest`] to the + /// Register a virtual user by sending a [`register::Request`] to the /// homeserver /// /// # Arguments /// /// * `localpart` - The localpart of the user to register. Must be covered /// by the namespaces in the [`Registration`] in order to succeed. - pub async fn register(&mut self, localpart: impl AsRef) -> Result<()> { - let request = assign!(RegistrationRequest::new(), { + pub async fn register_virtual_user(&self, localpart: impl AsRef) -> Result<()> { + let request = assign!(register::Request::new(), { username: Some(localpart.as_ref()), - login_type: Some(&LoginType::ApplicationService), + login_type: Some(®ister::LoginType::ApplicationService), }); let client = self.get_cached_client(None)?; @@ -412,11 +437,35 @@ impl Appservice { Ok(false) } - /// Service to register on an Actix `App` + /// Returns a closure to be used with [`actix_web::App::configure()`] + /// + /// Note that if you handle any of the [application-service-specific + /// routes], including the legacy routes, you will break the appservice + /// functionality. + /// + /// [application-service-specific routes]: https://spec.matrix.org/unstable/application-service-api/#legacy-routes #[cfg(feature = "actix")] #[cfg_attr(docs, doc(cfg(feature = "actix")))] - pub fn actix_service(&self) -> actix::Scope { - actix::get_scope().data(self.clone()) + pub fn actix_configure(&self) -> impl FnOnce(&mut actix_web::web::ServiceConfig) { + let appservice = self.clone(); + + move |config| { + config.data(appservice); + webserver::actix::configure(config); + } + } + + /// Returns a [`warp::Filter`] to be used as [`warp::serve()`] route + /// + /// Note that if you handle any of the [application-service-specific + /// routes], including the legacy routes, you will break the appservice + /// functionality. + /// + /// [application-service-specific routes]: https://spec.matrix.org/unstable/application-service-api/#legacy-routes + #[cfg(feature = "warp")] + #[cfg_attr(docs, doc(cfg(feature = "warp")))] + pub fn warp_filter(&self) -> warp::filters::BoxedFilter<(impl warp::Reply,)> { + webserver::warp::warp_filter(self.clone()) } /// Convenience method that runs an http server depending on the selected @@ -424,14 +473,50 @@ impl Appservice { /// /// This is a blocking call that tries to listen on the provided host and /// port - pub async fn run(&self, host: impl AsRef, port: impl Into) -> Result<()> { + pub async fn run(&self, host: impl Into, port: impl Into) -> Result<()> { + let host = host.into(); + let port = port.into(); + info!("Starting Appservice on {}:{}", &host, &port); + #[cfg(feature = "actix")] { - actix::run_server(self.clone(), host, port).await?; + webserver::actix::run_server(self.clone(), host, port).await?; Ok(()) } - #[cfg(not(any(feature = "actix",)))] + #[cfg(feature = "warp")] + { + webserver::warp::run_server(self.clone(), host, port).await?; + Ok(()) + } + + #[cfg(not(any(feature = "actix", feature = "warp",)))] unreachable!() } } + +/// Transforms [legacy routes] to the correct route so ruma can parse them +/// properly +/// +/// [legacy routes]: https://matrix.org/docs/spec/application_service/r0.1.2#legacy-routes +pub(crate) fn transform_legacy_route( + mut request: http::Request, +) -> Result> { + let uri = request.uri().to_owned(); + + if !uri.path().starts_with("/_matrix/app/v1") { + // rename legacy routes + let mut parts = uri.into_parts(); + let path_and_query = match parts.path_and_query { + Some(path_and_query) => format!("/_matrix/app/v1{}", path_and_query), + None => "/_matrix/app/v1".to_owned(), + }; + parts.path_and_query = + Some(PathAndQuery::try_from(path_and_query).map_err(http::Error::from)?); + let uri = parts.try_into().map_err(http::Error::from)?; + + *request.uri_mut() = uri; + } + + Ok(request) +} diff --git a/matrix_sdk_appservice/src/actix.rs b/matrix_sdk_appservice/src/webserver/actix.rs similarity index 73% rename from matrix_sdk_appservice/src/actix.rs rename to matrix_sdk_appservice/src/webserver/actix.rs index 7ee09183..b983f92e 100644 --- a/matrix_sdk_appservice/src/actix.rs +++ b/matrix_sdk_appservice/src/webserver/actix.rs @@ -12,47 +12,43 @@ // See the License for the specific language governing permissions and // limitations under the License. -use std::{ - convert::{TryFrom, TryInto}, - pin::Pin, -}; +use std::pin::Pin; pub use actix_web::Scope; use actix_web::{ dev::Payload, error::PayloadError, - get, - http::PathAndQuery, - put, + get, put, web::{self, BytesMut, Data}, App, FromRequest, HttpRequest, HttpResponse, HttpServer, }; use futures::Future; -use futures_util::{TryFutureExt, TryStreamExt}; +use futures_util::TryStreamExt; use ruma::api::appservice as api; use crate::{error::Error, Appservice}; pub async fn run_server( appservice: Appservice, - host: impl AsRef, + host: impl Into, port: impl Into, ) -> Result<(), Error> { - HttpServer::new(move || App::new().service(appservice.actix_service())) - .bind((host.as_ref(), port.into()))? + HttpServer::new(move || App::new().configure(appservice.actix_configure())) + .bind((host.into(), port.into()))? .run() .await?; Ok(()) } -pub fn get_scope() -> Scope { - gen_scope("/"). // handle legacy routes - service(gen_scope("/_matrix/app/v1")) -} - -fn gen_scope(scope: &str) -> Scope { - web::scope(scope).service(push_transactions).service(query_user_id).service(query_room_alias) +pub fn configure(config: &mut actix_web::web::ServiceConfig) { + // also handles legacy routes + config.service(push_transactions).service(query_user_id).service(query_room_alias).service( + web::scope("/_matrix/app/v1") + .service(push_transactions) + .service(query_user_id) + .service(query_room_alias), + ); } #[tracing::instrument] @@ -112,23 +108,8 @@ impl FromRequest for IncomingRequest { let payload = payload.take(); Box::pin(async move { - let uri = request.uri().to_owned(); - - let uri = if !uri.path().starts_with("/_matrix/app/v1") { - // rename legacy routes - let mut parts = uri.into_parts(); - let path_and_query = match parts.path_and_query { - Some(path_and_query) => format!("/_matrix/app/v1{}", path_and_query), - None => "/_matrix/app/v1".to_owned(), - }; - parts.path_and_query = - Some(PathAndQuery::try_from(path_and_query).map_err(http::Error::from)?); - parts.try_into().map_err(http::Error::from)? - } else { - uri - }; - - let mut builder = http::request::Builder::new().method(request.method()).uri(uri); + let mut builder = + http::request::Builder::new().method(request.method()).uri(request.uri()); let headers = builder.headers_mut().ok_or(Error::UnknownHttpRequestBuilder)?; for (key, value) in request.headers().iter() { @@ -140,8 +121,8 @@ impl FromRequest for IncomingRequest { body.extend_from_slice(&chunk); Ok::<_, PayloadError>(body) }) - .and_then(|bytes| async move { Ok::, _>(bytes.into_iter().collect()) }) - .await?; + .await? + .into(); let access_token = match request.uri().query() { Some(query) => { @@ -157,6 +138,7 @@ impl FromRequest for IncomingRequest { }; let request = builder.body(bytes)?; + let request = crate::transform_legacy_route(request)?; Ok(IncomingRequest { access_token, diff --git a/matrix_sdk_appservice/src/webserver/mod.rs b/matrix_sdk_appservice/src/webserver/mod.rs new file mode 100644 index 00000000..a0880ec6 --- /dev/null +++ b/matrix_sdk_appservice/src/webserver/mod.rs @@ -0,0 +1,4 @@ +#[cfg(feature = "actix")] +pub mod actix; +#[cfg(feature = "warp")] +pub mod warp; diff --git a/matrix_sdk_appservice/src/webserver/warp.rs b/matrix_sdk_appservice/src/webserver/warp.rs new file mode 100644 index 00000000..9a4f3504 --- /dev/null +++ b/matrix_sdk_appservice/src/webserver/warp.rs @@ -0,0 +1,210 @@ +// Copyright 2021 Famedly GmbH +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use std::{net::ToSocketAddrs, result::Result as StdResult}; + +use futures::TryFutureExt; +use matrix_sdk::Bytes; +use serde::Serialize; +use warp::{filters::BoxedFilter, path::FullPath, Filter, Rejection, Reply}; + +use crate::{Appservice, Error, Result}; + +pub async fn run_server( + appservice: Appservice, + host: impl Into, + port: impl Into, +) -> Result<()> { + let routes = warp_filter(appservice); + + let mut addr = format!("{}:{}", host.into(), port.into()).to_socket_addrs()?; + if let Some(addr) = addr.next() { + warp::serve(routes).run(addr).await; + Ok(()) + } else { + Err(Error::HostPortToSocketAddrs) + } +} + +pub fn warp_filter(appservice: Appservice) -> BoxedFilter<(impl Reply,)> { + // TODO: try to use a struct instead of needlessly cloning appservice multiple + // times on every request + warp::any() + .and(filters::transactions(appservice.clone())) + .or(filters::users(appservice.clone())) + .or(filters::rooms(appservice)) + .recover(handle_rejection) + .boxed() +} + +mod filters { + use super::*; + + pub fn users(appservice: Appservice) -> BoxedFilter<(impl Reply,)> { + warp::get() + .and( + warp::path!("_matrix" / "app" / "v1" / "users" / String) + // legacy route + .or(warp::path!("users" / String)) + .unify(), + ) + .and(warp::path::end()) + .and(common(appservice)) + .and_then(handlers::user) + .boxed() + } + + pub fn rooms(appservice: Appservice) -> BoxedFilter<(impl Reply,)> { + warp::get() + .and( + warp::path!("_matrix" / "app" / "v1" / "rooms" / String) + // legacy route + .or(warp::path!("rooms" / String)) + .unify(), + ) + .and(warp::path::end()) + .and(common(appservice)) + .and_then(handlers::room) + .boxed() + } + + pub fn transactions(appservice: Appservice) -> BoxedFilter<(impl Reply,)> { + warp::put() + .and( + warp::path!("_matrix" / "app" / "v1" / "transactions" / String) + // legacy route + .or(warp::path!("transactions" / String)) + .unify(), + ) + .and(warp::path::end()) + .and(common(appservice)) + .and_then(handlers::transaction) + .boxed() + } + + fn common(appservice: Appservice) -> BoxedFilter<(Appservice, http::Request)> { + warp::any() + .and(filters::valid_access_token(appservice.registration().hs_token.clone())) + .map(move || appservice.clone()) + .and(http_request().and_then(|request| async move { + let request = crate::transform_legacy_route(request).map_err(Error::from)?; + Ok::, Rejection>(request) + })) + .boxed() + } + + pub fn valid_access_token(token: String) -> BoxedFilter<()> { + warp::any() + .map(move || token.clone()) + .and(warp::query::raw()) + .and_then(|token: String, query: String| async move { + let query: Vec<(String, String)> = + matrix_sdk::urlencoded::from_str(&query).map_err(Error::from)?; + + if query.into_iter().any(|(key, value)| key == "access_token" && value == token) { + Ok::<(), Rejection>(()) + } else { + Err(warp::reject::custom(Unauthorized)) + } + }) + .untuple_one() + .boxed() + } + + pub fn http_request() -> impl Filter,), Error = Rejection> + Copy + { + // TODO: extract `hyper::Request` instead + // blocked by https://github.com/seanmonstar/warp/issues/139 + warp::any() + .and(warp::method()) + .and(warp::filters::path::full()) + .and(warp::filters::query::raw()) + .and(warp::header::headers_cloned()) + .and(warp::body::bytes()) + .and_then(|method, path: FullPath, query, headers, bytes| async move { + let uri = http::uri::Builder::new() + .path_and_query(format!("{}?{}", path.as_str(), query)) + .build() + .map_err(Error::from)?; + + let mut request = http::Request::builder() + .method(method) + .uri(uri) + .body(bytes) + .map_err(Error::from)?; + + *request.headers_mut() = headers; + + Ok::, Rejection>(request) + }) + } +} + +mod handlers { + use super::*; + + pub async fn user( + _user_id: String, + _appservice: Appservice, + _request: http::Request, + ) -> StdResult { + Ok(warp::reply::json(&String::from("{}"))) + } + + pub async fn room( + _room_id: String, + _appservice: Appservice, + _request: http::Request, + ) -> StdResult { + Ok(warp::reply::json(&String::from("{}"))) + } + + pub async fn transaction( + _txn_id: String, + appservice: Appservice, + request: http::Request, + ) -> StdResult { + let incoming_transaction: matrix_sdk::api_appservice::event::push_events::v1::IncomingRequest = + matrix_sdk::IncomingRequest::try_from_http_request(request).map_err(Error::from)?; + + let client = appservice.get_cached_client(None)?; + client.receive_transaction(incoming_transaction).map_err(Error::from).await?; + + Ok(warp::reply::json(&String::from("{}"))) + } +} + +#[derive(Debug)] +struct Unauthorized; + +impl warp::reject::Reject for Unauthorized {} + +#[derive(Serialize)] +struct ErrorMessage { + code: u16, + message: String, +} + +pub async fn handle_rejection(err: Rejection) -> std::result::Result { + if err.find::().is_some() || err.find::().is_some() { + let code = http::StatusCode::UNAUTHORIZED; + let message = "UNAUTHORIZED"; + + let json = + warp::reply::json(&ErrorMessage { code: code.as_u16(), message: message.into() }); + Ok(warp::reply::with_status(json, code)) + } else { + Err(err) + } +} diff --git a/matrix_sdk_appservice/tests/actix.rs b/matrix_sdk_appservice/tests/actix.rs deleted file mode 100644 index f7186698..00000000 --- a/matrix_sdk_appservice/tests/actix.rs +++ /dev/null @@ -1,114 +0,0 @@ -#[cfg(feature = "actix")] -mod actix { - use std::env; - - use actix_web::{test, App}; - use matrix_sdk_appservice::*; - - async fn appservice() -> Appservice { - env::set_var("RUST_LOG", "mockito=debug,matrix_sdk=debug,ruma=debug,actix_web=debug"); - let _ = tracing_subscriber::fmt::try_init(); - - Appservice::new( - mockito::server_url().as_ref(), - "test.local", - AppserviceRegistration::try_from_yaml_str(include_str!("./registration.yaml")).unwrap(), - ) - .await - .unwrap() - } - - #[actix_rt::test] - async fn test_transactions() { - let appservice = appservice().await; - let app = test::init_service(App::new().service(appservice.actix_service())).await; - - let transactions = r#"{ - "events": [ - { - "content": {}, - "type": "m.dummy" - } - ] - }"#; - - let transactions: serde_json::Value = serde_json::from_str(transactions).unwrap(); - - let req = test::TestRequest::put() - .uri("/_matrix/app/v1/transactions/1?access_token=hs_token") - .set_json(&transactions) - .to_request(); - - let resp = test::call_service(&app, req).await; - - assert_eq!(resp.status(), 200); - } - - #[actix_rt::test] - async fn test_users() { - let appservice = appservice().await; - let app = test::init_service(App::new().service(appservice.actix_service())).await; - - let req = test::TestRequest::get() - .uri("/_matrix/app/v1/users/%40_botty_1%3Adev.famedly.local?access_token=hs_token") - .to_request(); - - let resp = test::call_service(&app, req).await; - - assert_eq!(resp.status(), 200); - } - - #[actix_rt::test] - async fn test_invalid_access_token() { - let appservice = appservice().await; - let app = test::init_service(App::new().service(appservice.actix_service())).await; - - let transactions = r#"{ - "events": [ - { - "content": {}, - "type": "m.dummy" - } - ] - }"#; - - let transactions: serde_json::Value = serde_json::from_str(transactions).unwrap(); - - let req = test::TestRequest::put() - .uri("/_matrix/app/v1/transactions/1?access_token=invalid_token") - .set_json(&transactions) - .to_request(); - - let resp = test::call_service(&app, req).await; - - assert_eq!(resp.status(), 401); - } - - #[actix_rt::test] - async fn test_no_access_token() { - let appservice = appservice().await; - let app = test::init_service(App::new().service(appservice.actix_service())).await; - - let transactions = r#"{ - "events": [ - { - "content": {}, - "type": "m.dummy" - } - ] - }"#; - - let transactions: serde_json::Value = serde_json::from_str(transactions).unwrap(); - - let req = test::TestRequest::put() - .uri("/_matrix/app/v1/transactions/1") - .set_json(&transactions) - .to_request(); - - let resp = test::call_service(&app, req).await; - - // TODO: this should actually return a 401 but is 500 because something in the - // extractor fails - assert_eq!(resp.status(), 500); - } -} diff --git a/matrix_sdk_appservice/tests/tests.rs b/matrix_sdk_appservice/tests/tests.rs index dd7f0dcb..f407b1c0 100644 --- a/matrix_sdk_appservice/tests/tests.rs +++ b/matrix_sdk_appservice/tests/tests.rs @@ -1,23 +1,29 @@ -use std::env; +use std::sync::{Arc, Mutex}; +#[cfg(feature = "actix")] +use actix_web::{test as actix_test, App as ActixApp, HttpResponse}; use matrix_sdk::{ - api_appservice, api_appservice::Registration, async_trait, - events::{room::member::MemberEventContent, AnyRoomEvent, AnyStateEvent, SyncStateEvent}, + events::{room::member::MemberEventContent, SyncStateEvent}, room::Room, - EventHandler, Raw, + ClientConfig, EventHandler, RequestConfig, }; use matrix_sdk_appservice::*; -use matrix_sdk_test::async_test; +use matrix_sdk_test::{appservice::TransactionBuilder, async_test, EventsJson}; use serde_json::json; +#[cfg(feature = "warp")] +use warp::{Filter, Reply}; fn registration_string() -> String { include_str!("../tests/registration.yaml").to_owned() } async fn appservice(registration: Option) -> Result { - env::set_var("RUST_LOG", "mockito=debug,matrix_sdk=debug"); + // env::set_var( + // "RUST_LOG", + // "mockito=debug,matrix_sdk=debug,ruma=debug,actix_web=debug,warp=debug", + // ); let _ = tracing_subscriber::fmt::try_init(); let registration = match registration { @@ -28,95 +34,317 @@ async fn appservice(registration: Option) -> Result { let homeserver_url = mockito::server_url(); let server_name = "localhost"; - Ok(Appservice::new(homeserver_url.as_ref(), server_name, registration).await?) + let client_config = + ClientConfig::default().request_config(RequestConfig::default().disable_retry()); + + Ok(Appservice::new_with_config( + homeserver_url.as_ref(), + server_name, + registration, + client_config, + ) + .await?) } -fn member_json() -> serde_json::Value { - json!({ - "content": { - "avatar_url": null, - "displayname": "example", - "membership": "join" - }, - "event_id": "$151800140517rfvjc:localhost", - "membership": "join", - "origin_server_ts": 151800140, - "room_id": "!ahpSDaDUPCCqktjUEF:localhost", - "sender": "@example:localhost", - "state_key": "@example:localhost", - "type": "m.room.member", - "prev_content": { - "avatar_url": null, - "displayname": "example", - "membership": "invite" - }, - "unsigned": { - "age": 297036, - "replaces_state": "$151800111315tsynI:localhost" - } - }) +#[async_test] +async fn test_register_virtual_user() -> Result<()> { + let appservice = appservice(None).await?; + + let localpart = "someone"; + let _mock = mockito::mock("POST", "/_matrix/client/r0/register") + .match_query(mockito::Matcher::Missing) + .match_header( + "authorization", + mockito::Matcher::Exact(format!("Bearer {}", appservice.registration().as_token)), + ) + .match_body(mockito::Matcher::Json(json!({ + "username": localpart.to_owned(), + "type": "m.login.application_service" + }))) + .with_body(format!( + r#"{{ + "access_token": "abc123", + "device_id": "GHTYAJCE", + "user_id": "@{localpart}:localhost" + }}"#, + localpart = localpart + )) + .create(); + + appservice.register_virtual_user(localpart).await?; + + Ok(()) +} + +#[async_test] +async fn test_put_transaction() -> Result<()> { + let uri = "/_matrix/app/v1/transactions/1?access_token=hs_token"; + + let mut transaction_builder = TransactionBuilder::new(); + transaction_builder.add_room_event(EventsJson::Member); + let transaction = transaction_builder.build_json_transaction(); + + let appservice = appservice(None).await?; + + #[cfg(feature = "warp")] + let status = warp::test::request() + .method("PUT") + .path(uri) + .json(&transaction) + .filter(&appservice.warp_filter()) + .await + .unwrap() + .into_response() + .status(); + + #[cfg(feature = "actix")] + let status = { + let app = + actix_test::init_service(ActixApp::new().configure(appservice.actix_configure())).await; + + let req = actix_test::TestRequest::put().uri(uri).set_json(&transaction).to_request(); + + actix_test::call_service(&app, req).await.status() + }; + + assert_eq!(status, 200); + + Ok(()) +} + +#[async_test] +async fn test_get_user() -> Result<()> { + let appservice = appservice(None).await?; + + let uri = "/_matrix/app/v1/users/%40_botty_1%3Adev.famedly.local?access_token=hs_token"; + + #[cfg(feature = "warp")] + let status = warp::test::request() + .method("GET") + .path(uri) + .filter(&appservice.warp_filter()) + .await + .unwrap() + .into_response() + .status(); + + #[cfg(feature = "actix")] + let status = { + let app = + actix_test::init_service(ActixApp::new().configure(appservice.actix_configure())).await; + + let req = actix_test::TestRequest::get().uri(uri).to_request(); + + actix_test::call_service(&app, req).await.status() + }; + + assert_eq!(status, 200); + + Ok(()) +} + +#[async_test] +async fn test_get_room() -> Result<()> { + let appservice = appservice(None).await?; + + let uri = "/_matrix/app/v1/rooms/%23magicforest%3Aexample.com?access_token=hs_token"; + + #[cfg(feature = "warp")] + let status = warp::test::request() + .method("GET") + .path(uri) + .filter(&appservice.warp_filter()) + .await + .unwrap() + .into_response() + .status(); + + #[cfg(feature = "actix")] + let status = { + let app = + actix_test::init_service(ActixApp::new().configure(appservice.actix_configure())).await; + + let req = actix_test::TestRequest::get().uri(uri).to_request(); + + actix_test::call_service(&app, req).await.status() + }; + + assert_eq!(status, 200); + + Ok(()) +} + +#[async_test] +async fn test_invalid_access_token() -> Result<()> { + let uri = "/_matrix/app/v1/transactions/1?access_token=invalid_token"; + + let mut transaction_builder = TransactionBuilder::new(); + let transaction = + transaction_builder.add_room_event(EventsJson::Member).build_json_transaction(); + + let appservice = appservice(None).await?; + + #[cfg(feature = "warp")] + let status = warp::test::request() + .method("PUT") + .path(uri) + .json(&transaction) + .filter(&appservice.warp_filter()) + .await + .unwrap() + .into_response() + .status(); + + #[cfg(feature = "actix")] + let status = { + let app = + actix_test::init_service(ActixApp::new().configure(appservice.actix_configure())).await; + + let req = actix_test::TestRequest::put().uri(uri).set_json(&transaction).to_request(); + + actix_test::call_service(&app, req).await.status() + }; + + assert_eq!(status, 401); + + Ok(()) +} + +#[async_test] +async fn test_no_access_token() -> Result<()> { + let uri = "/_matrix/app/v1/transactions/1"; + + let mut transaction_builder = TransactionBuilder::new(); + transaction_builder.add_room_event(EventsJson::Member); + let transaction = transaction_builder.build_json_transaction(); + + let appservice = appservice(None).await?; + + #[cfg(feature = "warp")] + { + let status = warp::test::request() + .method("PUT") + .path(uri) + .json(&transaction) + .filter(&appservice.warp_filter()) + .await + .unwrap() + .into_response() + .status(); + + assert_eq!(status, 401); + } + + #[cfg(feature = "actix")] + { + let app = + actix_test::init_service(ActixApp::new().configure(appservice.actix_configure())).await; + + let req = actix_test::TestRequest::put().uri(uri).set_json(&transaction).to_request(); + + let resp = actix_test::call_service(&app, req).await; + + // TODO: this should actually return a 401 but is 500 because something in the + // extractor fails + assert_eq!(resp.status(), 500); + } + + Ok(()) } #[async_test] async fn test_event_handler() -> Result<()> { let mut appservice = appservice(None).await?; - struct Example {} + #[derive(Clone)] + struct Example { + pub on_state_member: Arc>, + } impl Example { pub fn new() -> Self { - Self {} + #[allow(clippy::mutex_atomic)] + Self { on_state_member: Arc::new(Mutex::new(false)) } } } #[async_trait] impl EventHandler for Example { - async fn on_state_member(&self, room: Room, event: &SyncStateEvent) { - dbg!(room, event); + async fn on_room_member(&self, _: Room, _: &SyncStateEvent) { + let on_state_member = self.on_state_member.clone(); + *on_state_member.lock().unwrap() = true; } } - appservice.set_event_handler(Box::new(Example::new())).await?; + let example = Example::new(); + appservice.set_event_handler(Box::new(example.clone())).await?; - let event = serde_json::from_value::(member_json()).unwrap(); - let event: Raw = AnyRoomEvent::State(event).into(); - let events = vec![event]; + let uri = "/_matrix/app/v1/transactions/1?access_token=hs_token"; - let incoming = api_appservice::event::push_events::v1::IncomingRequest::new( - "any_txn_id".to_owned(), - events, - ); + let mut transaction_builder = TransactionBuilder::new(); + transaction_builder.add_room_event(EventsJson::Member); + let transaction = transaction_builder.build_json_transaction(); - appservice.get_cached_client(None)?.receive_transaction(incoming).await?; + #[cfg(feature = "warp")] + warp::test::request() + .method("PUT") + .path(uri) + .json(&transaction) + .filter(&appservice.warp_filter()) + .await + .unwrap(); + + #[cfg(feature = "actix")] + { + let app = + actix_test::init_service(ActixApp::new().configure(appservice.actix_configure())).await; + + let req = actix_test::TestRequest::put().uri(uri).set_json(&transaction).to_request(); + + actix_test::call_service(&app, req).await; + }; + + let on_room_member_called = *example.on_state_member.lock().unwrap(); + assert!(on_room_member_called); Ok(()) } #[async_test] -async fn test_transaction() -> Result<()> { +async fn test_unrelated_path() -> Result<()> { let appservice = appservice(None).await?; - let event = serde_json::from_value::(member_json()).unwrap(); - let event: Raw = AnyRoomEvent::State(event).into(); - let events = vec![event]; + #[cfg(feature = "warp")] + let status = { + let consumer_filter = warp::any() + .and(appservice.warp_filter()) + .or(warp::get().and(warp::path("unrelated").map(warp::reply))); - let incoming = api_appservice::event::push_events::v1::IncomingRequest::new( - "any_txn_id".to_owned(), - events, - ); + let response = warp::test::request() + .method("GET") + .path("/unrelated") + .filter(&consumer_filter) + .await? + .into_response(); - appservice.get_cached_client(None)?.receive_transaction(incoming).await?; + response.status() + }; - Ok(()) -} + #[cfg(feature = "actix")] + let status = { + let app = actix_test::init_service( + ActixApp::new() + .configure(appservice.actix_configure()) + .route("/unrelated", actix_web::web::get().to(HttpResponse::Ok)), + ) + .await; -#[async_test] -async fn test_verify_hs_token() -> Result<()> { - let appservice = appservice(None).await?; + let req = actix_test::TestRequest::get().uri("/unrelated").to_request(); - let registration = appservice.registration(); + actix_test::call_service(&app, req).await.status() + }; - assert!(appservice.compare_hs_token(®istration.hs_token)); + assert_eq!(status, 200); Ok(()) } diff --git a/matrix_sdk_test/Cargo.toml b/matrix_sdk_test/Cargo.toml index 41ffbeda..f640d00f 100644 --- a/matrix_sdk_test/Cargo.toml +++ b/matrix_sdk_test/Cargo.toml @@ -10,6 +10,9 @@ readme = "README.md" repository = "https://github.com/matrix-org/matrix-rust-sdk" version = "0.2.0" +[features] +appservice = [] + [dependencies] http = "0.2.3" lazy_static = "1.4.0" diff --git a/matrix_sdk_test/src/appservice.rs b/matrix_sdk_test/src/appservice.rs new file mode 100644 index 00000000..23d889b2 --- /dev/null +++ b/matrix_sdk_test/src/appservice.rs @@ -0,0 +1,69 @@ +use std::convert::TryFrom; + +use ruma::{events::AnyRoomEvent, identifiers::room_id}; +use serde_json::Value; + +use crate::{test_json, EventsJson}; + +/// Clones the given [`Value`] and adds a `room_id` to it +/// +/// Adding the `room_id` conditionally with `cfg` directly to the lazy_static +/// test_json values is blocked by "experimental attributes on expressions, see +/// issue #15701 for more information" +pub fn value_with_room_id(value: &Value) -> Value { + let mut val = value.clone(); + let room_id = + Value::try_from(room_id!("!SVkFJHzfwvuaIEawgC:localhost").to_string()).expect("room_id"); + val.as_object_mut().expect("mutable test_json").insert("room_id".to_owned(), room_id); + + val +} + +/// The `TransactionBuilder` struct can be used to easily generate valid +/// incoming appservice transactions in json value format for testing. +/// +/// Usage is similar to [`super::EventBuilder`] +#[derive(Debug, Default)] +pub struct TransactionBuilder { + events: Vec, +} + +impl TransactionBuilder { + pub fn new() -> Self { + Default::default() + } + + /// Add a room event. + pub fn add_room_event(&mut self, json: EventsJson) -> &mut Self { + let val: &Value = match json { + EventsJson::Member => &test_json::MEMBER, + EventsJson::MemberNameChange => &test_json::MEMBER_NAME_CHANGE, + EventsJson::PowerLevels => &test_json::POWER_LEVELS, + _ => panic!("unknown event json {:?}", json), + }; + + let val = value_with_room_id(val); + + let event = serde_json::from_value::(val).unwrap(); + + self.events.push(event); + self + } + + /// Build the transaction + #[cfg(feature = "appservice")] + #[cfg_attr(feature = "docs", doc(cfg(appservice)))] + pub fn build_json_transaction(&self) -> Value { + let body = serde_json::json! { + { + "events": self.events + } + }; + + body + } + + pub fn clear(&mut self) { + self.events.clear(); + } +} diff --git a/matrix_sdk_test/src/lib.rs b/matrix_sdk_test/src/lib.rs index f0bf97e9..c559085f 100644 --- a/matrix_sdk_test/src/lib.rs +++ b/matrix_sdk_test/src/lib.rs @@ -12,6 +12,8 @@ use ruma::{ }; use serde_json::Value as JsonValue; +#[cfg(feature = "appservice")] +pub mod appservice; pub mod test_json; /// Embedded event files