diff --git a/ruma-events/.builds/beta.yml b/ruma-events/.builds/beta.yml new file mode 100644 index 00000000..39c4b574 --- /dev/null +++ b/ruma-events/.builds/beta.yml @@ -0,0 +1,27 @@ +image: archlinux +packages: + - rustup +sources: + - https://github.com/ruma/ruma-events +tasks: + - rustup: | + # We specify --profile minimal because we'd otherwise download docs + rustup toolchain install beta --profile minimal -c rustfmt -c clippy + rustup default beta + - test: | + cd ruma-events + + # We don't want the build to stop on individual failure of independent + # tools, so capture tool exit codes and set the task exit code manually + set +e + + cargo fmt -- --check + fmt_exit=$? + + cargo clippy --all-targets --all-features -- -D warnings + clippy_exit=$? + + cargo test --verbose + test_exit=$? + + exit $(( $fmt_exit || $clippy_exit || $test_exit )) diff --git a/ruma-events/.builds/msrv.yml b/ruma-events/.builds/msrv.yml new file mode 100644 index 00000000..3557d9a4 --- /dev/null +++ b/ruma-events/.builds/msrv.yml @@ -0,0 +1,16 @@ +image: archlinux +packages: + - rustup +sources: + - https://github.com/ruma/ruma-events +tasks: + - rustup: | + # We specify --profile minimal because we'd otherwise download docs + rustup toolchain install 1.40.0 --profile minimal + rustup default 1.40.0 + - test: | + cd ruma-events + + # Only make sure the code builds with the MSRV. Tests can require later + # Rust versions, don't compile or run them. + cargo build --verbose diff --git a/ruma-events/.builds/nightly.yml b/ruma-events/.builds/nightly.yml new file mode 100644 index 00000000..b9c6bac9 --- /dev/null +++ b/ruma-events/.builds/nightly.yml @@ -0,0 +1,32 @@ +image: archlinux +packages: + - rustup +sources: + - https://github.com/ruma/ruma-events +tasks: + - rustup: | + rustup toolchain install nightly --profile minimal + rustup default nightly + + # Try installing rustfmt & clippy for nightly, but don't fail the build + # if they are not available + rustup component add rustfmt || true + rustup component add clippy || true + - test: | + cd ruma-events + + # We don't want the build to stop on individual failure of independent + # tools, so capture tool exit codes and set the task exit code manually + set +e + + if ( rustup component list | grep -q rustfmt ); then + cargo fmt -- --check + fi + fmt_exit=$? + + if ( rustup component list | grep -q clippy ); then + cargo clippy --all-targets --all-features -- -D warnings + fi + clippy_exit=$? + + exit $(( $fmt_exit || $clippy_exit )) diff --git a/ruma-events/.builds/stable.yml b/ruma-events/.builds/stable.yml new file mode 100644 index 00000000..598d69f1 --- /dev/null +++ b/ruma-events/.builds/stable.yml @@ -0,0 +1,29 @@ +image: archlinux +packages: + - rustup +sources: + - https://github.com/ruma/ruma-events +tasks: + - rustup: | + # We specify --profile minimal because we'd otherwise download docs + rustup toolchain install stable --profile minimal -c rustfmt -c clippy + rustup default stable + - test: | + cd ruma-events + + # We don't want the build to stop on individual failure of independent + # tools, so capture tool exit codes and set the task exit code manually + set +e + + cargo fmt -- --check + fmt_exit=$? + + cargo clippy --all-targets --all-features -- -D warnings + clippy_exit=$? + + cargo test --verbose + test_exit=$? + + exit $(( $fmt_exit || $clippy_exit || $test_exit )) + # TODO: Add audit task once cargo-audit binary releases are available. + # See https://github.com/RustSec/cargo-audit/issues/66 diff --git a/ruma-events/.gitignore b/ruma-events/.gitignore new file mode 100644 index 00000000..50e961f4 --- /dev/null +++ b/ruma-events/.gitignore @@ -0,0 +1,5 @@ +Cargo.lock +target + +# trybuild generates a `wip` folder when creating or updating a test +wip diff --git a/ruma-events/CHANGELOG.md b/ruma-events/CHANGELOG.md new file mode 100644 index 00000000..c945dedf --- /dev/null +++ b/ruma-events/CHANGELOG.md @@ -0,0 +1,312 @@ +# [unreleased] + +Breaking changes: + +* Add `alt_aliases` to `CanonicalAliasEventContent` +* Replace `format` and `formatted_body` fields in `TextMessagEventContent`, + `NoticeMessageEventContent` and `EmoteMessageEventContent` with `formatted: FormattedBody` +* Rename `override_rules` in `push_rules::Ruleset` to `override_` +* Change `push_rules::PushCondition` variants from newtype variants with separate inner types to + struct variants + * This change removes the types `EventMatchCondition`, `RoomMemberCountCondition` and + `SenderNotificationPermissionCondition` + +Improvements: + +* Add `room::MessageFormat` and `room::FormattedBody` + +# 0.21.3 + +Bug fixes: + +* Fix `m.room.message` event serialization + +Improvements: + +* Skip serialization of `federate` field in `room::create::CreateEventContent` + if it is `true` (the default value) +* `room::power_levels::PowerLevelsEventContent` now implements `Default` + +# 0.21.2 + +Improvements: + +* Update dependencies + +# 0.21.1 + +Improvements: + +* Add `EventJson::into_json` + +# 0.21.0 + +Breaking changes: + +* Replace `EventResult` with a new construct, `EventJson` + * Instead of only capturing the json value if deserialization failed, we now + now always capture it. To improve deserialization performance at the same + time, we no longer use `serde_json::Value` internally and instead + deserialize events as `Box`. `EventJson` is + simply a wrapper around that owned value type that additionally holds a + generic argument: the type as which clients will usually want to deserialize + the raw value. +* Add `struct UnsignedData` and update all `unsigned` fields types from + `BTreeMap` to this new type. + * To access any additional fields of the `unsigned` property of an event, + deserialize the `EventJson` to another type that captures the field(s) you + are interested in. +* Add fields `format` and `formatted_body` to `room::message::NoticeMessageEventContent` +* Remove `room::message::MessageType` +* Remove useless `algorithm` fields from encrypted event content structs +* Remove `PartialEq` implementations for most types + * Since we're now using `serde_json::value::RawValue`, deriving no longer works +* Update the representation of `push_rules::Tweak` +* Raise minimum supported Rust version to 1.40.0 + +# 0.20.0 + +Improvements: + +* Update ruma-identifiers to 0.16.0 + +# 0.19.0 + +Breaking changes: + +* Update ruma-identifiers to 0.15.1 +* Change timestamps, including `origin_server_rs` from `UInt` to `SystemTime` +* Change all usages of `HashMap` to `BTreeMap` + * To support this, `EventType` now implements `PartialOrd` and `Ord` + +# 0.18.0 + +Breaking changes: + +* Update unsigned field's type from `Option` to `Map` + +Improvements: + +* Add a convenience constructor to create a plain-text `TextMessageEventContent` +* Add `m.dummy` events to the to-device event collection + +# 0.17.0 + +Breaking changes: + +* `collections::only` no longer exports a `raw` submodule. It was never meant ot be exported in the first place. +* Renamed `stripped::{StrippedState => AnyStrippedStateEvent, StrippedStateContent => StrippedStateEvent}` + +Improvements: + +* Added `to_device` module with to-device variants of events (as found in the `to_device` section of a sync response) +* Added a helper method for computing the membership change from a `MemberEvent` + +Bug fixes: + +* Fixed missing `m.` in `m.relates_to` field of room messages +* Fixed (de)serialization of encrypted events using `m.olm.v1.curve25519-aes-sha2` + +# 0.16.0 + +Breaking changes: + +* `TryFromRaw::try_from_raw`'s signature has been simplified. The previous signature was a relict that was no longer sensible. +* All remaining non-optional `room_id` event fields (not event content fields) have been made optional + +Improvements: + +* `NameEvent`s are now validated properly and will be rejected if the `name` field is longer than 255 bytes. + +# 0.15.1 + +Bug fixes: + +* Deserialization of custom events as part of the types from `ruma_events::collections::{all, only}` was implemented (this was missing after the big fallible deserializion rewrite in 0.15.0) + +# 0.15.0 + +Improvements: + +* `ruma-events` now exports a new type, `EventResult` + * For any event or event content type `T` inside a larger type that should support deserialization you can use `EventResult` instead + * Conceptually, it is the same as `Result` + * `InvalidEvent` can represent either a deserialization error (the event's structure did not match) or a validation error (some additional constraints defined in the matrix spec were violated) + * It also contains the original value that was attempted to be deserialized into `T` in `serde_json::Value` form + +Breaking changes: + +* The `FromStr` implementations for event types were removed (they were the previous implementation of fallible deserialization, but were never integrated in ruma-client-api because they didn't interoperate well with serde derives) + +# 0.14.0 + +Breaking changes: + +* Updated to ruma-identifiers 0.14.0. + +Improvements: + +* ruma-events is now checked against the RustSec advisory database. + +# 0.13.0 + +Breaking changes: + +* Events and their content types no longer implement `Deserialize` and instead implement `FromStr` and `TryFrom<&str>`, which take a `&str` of JSON data and return a new `InvalidEvent` type on error. +* Integers are now represented using the `Int` and `UInt` types from the `js_int` crate to ensure they are within the JavaScript-interoperable range mandated by the Matrix specification. +* Some event types have new fields or new default values for previous fields to bring them up to date with version r0.5.0 of the client-server specification. +* Some event types no longer have public fields and instead use a constructor function to perform validations not represented by the type system. +* All enums now include a "nonexhaustive" variant to prevent exhaustive pattern matching. This will change to use the `#[nonexhaustive]` attribute when it is stabilized. +* `ParseError` has been renamed `FromStrError`. + +New features: + +* This release brings ruma-events completely up to date with version r0.5.0 of the client-server specification. All previously supported events have been updated as necessary and the following events have newly added support: + * m.dummy + * m.forwarded_room_key + * m.fully_read + * m.ignored_user_list + * m.key.verification.accept + * m.key.verification.cancel + * m.key.verification.key + * m.key.verification.mac + * m.key.verification.request + * m.key.verification.start + * m.push_rules + * m.key.encrypted + * m.key.encryption + * m.key.server_acl + * m.key.tombstone + * m.room_key + * m.room_key_request + * m.sticker + +Improvements: + +* Improved documentation for the crate and for many types. +* Added many new tests. +* rustfmt and clippy are now used to ensure consistent formatting and improved code quality. + +# 0.12.0 + +Improvements: + +* ruma-events now runs on stable Rust, requiring version 1.34 or higher. + +Bug fixes: + +* `CanonicalAliasEvent` and `NameEvent` now allow content being absent, null, or empty, as per the spec. + +# 0.11.1 + +Breaking changes: + +* `RoomId` is now optional in certain places where it may be absent, notably the responses of the `/sync` API endpoint. +* A `sender` field has been added to the `StrippedStateContent` type. + +Improvements: + +* Depend on serde's derive feature rather than serde_derive directly for simplified imports. +* Update to Rust 2018 idioms. + +# 0.11.0 + +Breaking changes: + +* The presence event has been modified to match the latest version of the spec. The spec was corrected to match the behavior of the Synapse homeserver. + +Improvements: + +* Dependencies have been updated to the latest versions. + +# 0.10.0 + +Breaking changes: + +* The `EventType`, and collections enums have new variants to support new events. +* The `extra_content` method has been removed from the Event trait. +* The `user_id` method from the `RoomEvent` trait has been renamed `sender` to match the specification. +* The `origin_server_ts` value is now required for room events and is supported via a new `origin_server_ts` method on the `RoomEvent` trait. +* `MemberEventContent` has a new `is_direct` field. +* `FileMessageEventContent` has a new `filename` field. +* File and thumbnail info have been moved from several message types to dedicated `FileInfo`, `ImageInfo`, and `ThumbnailInfo` types. +* `LocationMessageEventContent` has a new info field. +* `PresenceEventContent`'s `currently_active` field has changed from `bool` to `Option`. +* `TypingEventContent` contains a vector of `UserId`s instead of `EventId`s. +* Height and width fields named `h` and `w` in the spec now use the full names `height` and `width` for their struct field names, but continue to serialize to the single-letter names. + +New features: + +* ruma-events now supports all events according to r0.3.0 of the Matrix client-server specification. +* Added new event: `m.room.pinned_events`. +* Added new event: `m.direct`. + +Bug fixes: + +* Several places where struct fields used the wrong key when serialized to JSON have been corrected. +* Fixed grammar issues in documentation. + +# 0.9.0 + +Improvements: + +* Added default values for various power level attributes. +* Removed Serde trait bounds on `StrippedStateContent`'s generic parameter. +* Updated to version 0.4 of ruma-signatures. + +# 0.8.0 + +Breaking changes + +* Updated serde to the 1.0 series. + +# 0.7.0 + +Bug fixes: + +* Make the `federate` field optional when creating a room. + +# 0.6.0 + +Breaking changes: + +* Updated ruma-identifiers to the 0.9 series. + + +# 0.5.0 + +Breaking changes: + +* Updated ruma-identifiers to the 0.8 series. + +# 0.4.1 + +Improvements: + +* Relaxed version constraints on dependent crates to allow updating to new patch level versions. + +# 0.4.0 + +Breaking changes: + +* Updated serde to the 0.9 series. + +The public API remains the same. + +# 0.3.0 + +Improvements: + +* `ruma_events::presence::PresenceState` now implements `Display` and `FromStr`. + +# 0.2.0 + +Improvements: + +* Added missing "stripped" versions of some state events. +* All "stripped" versions of state events are now serializable. + + +# 0.1.0 + +Initial release. diff --git a/ruma-events/Cargo.toml b/ruma-events/Cargo.toml new file mode 100644 index 00000000..633d0056 --- /dev/null +++ b/ruma-events/Cargo.toml @@ -0,0 +1,34 @@ +[package] +authors = ["Jimmy Cuadra "] +categories = ["api-bindings"] +description = "Serializable types for the events in the Matrix specification." +documentation = "https://docs.rs/ruma-events" +homepage = "https://github.com/ruma/ruma-events" +keywords = ["matrix", "chat", "messaging", "ruma"] +license = "MIT" +name = "ruma-events" +readme = "README.md" +repository = "https://github.com/ruma/ruma-events" +version = "0.21.3" +edition = "2018" + +[dependencies] +js_int = { version = "0.1.5", features = ["serde"] } +ruma-common = "0.1.3" +ruma-events-macros = { path = "ruma-events-macros", version = "=0.21.3" } +ruma-identifiers = "0.16.2" +ruma-serde = "0.2.2" +serde = { version = "1.0.111", features = ["derive"] } +serde_json = { version = "1.0.53", features = ["raw_value"] } +strum = { version = "0.18.0", features = ["derive"] } + +[dev-dependencies] +maplit = "1.0.2" +matches = "0.1.8" +ruma-identifiers = { version = "0.16.2", features = ["rand"] } +trybuild = "1.0.28" + +[workspace] +members = [ + "ruma-events-macros", +] diff --git a/ruma-events/LICENSE b/ruma-events/LICENSE new file mode 100644 index 00000000..6225a6dc --- /dev/null +++ b/ruma-events/LICENSE @@ -0,0 +1,20 @@ +Copyright (c) 2015 Jimmy Cuadra + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + diff --git a/ruma-events/README.md b/ruma-events/README.md new file mode 100644 index 00000000..347aedb8 --- /dev/null +++ b/ruma-events/README.md @@ -0,0 +1,15 @@ +# ruma-events + +[![crates.io page](https://img.shields.io/crates/v/ruma-events.svg)](https://crates.io/crates/ruma-events) +[![docs.rs page](https://docs.rs/ruma-events/badge.svg)](https://docs.rs/ruma-events/) +![license: MIT](https://img.shields.io/crates/l/ruma-events.svg) + +**ruma-events** contains serializable types for the events in the [Matrix](https://matrix.org/) specification that can be shared by client and server code. + +## Minimum Rust version + +ruma-events requires Rust 1.40.0 or later. + +## Documentation + +ruma-events has [comprehensive documentation](https://docs.rs/ruma-events) available on docs.rs. diff --git a/ruma-events/ruma-events-macros/CHANGELOG.md b/ruma-events/ruma-events-macros/CHANGELOG.md new file mode 100644 index 00000000..53a7bc73 --- /dev/null +++ b/ruma-events/ruma-events-macros/CHANGELOG.md @@ -0,0 +1,20 @@ +# [unreleased] + +# 0.3.0 + +Breaking changes: + +* Update `event_type` in `ruma_events!` to refer to the serialized form of the + event type, not the variant of `ruma_events::EventType` + +Improvements: + +* Split `FromRaw` implementation generation from `ruma_event!` into a separate + proc-macro + +# 0.2.0 + +Improvements: + +* Code generation was updated to account for the changes in ruma-events 0.15 +* Dependencies were updated (notably to syn 1.0 and quote 1.0) diff --git a/ruma-events/ruma-events-macros/Cargo.toml b/ruma-events/ruma-events-macros/Cargo.toml new file mode 100644 index 00000000..b8ff54af --- /dev/null +++ b/ruma-events/ruma-events-macros/Cargo.toml @@ -0,0 +1,27 @@ +[package] +authors = ["Jimmy Cuadra "] +categories = ["api-bindings", "web-programming"] +description = "A procedural macro used by the ruma-events crate." +documentation = "https://docs.rs/ruma-events-macros" +edition = "2018" +homepage = "https://github.com/ruma/ruma-events-macros" +keywords = ["matrix", "chat", "messaging", "ruma"] +license = "MIT" +name = "ruma-events-macros" +readme = "README.md" +repository = "https://github.com/ruma/ruma-api-macros" +version = "0.21.3" + +[dependencies] +syn = { version = "1.0.25", features = ["full"] } +quote = "1.0.6" +proc-macro2 = "1.0.17" + +[lib] +proc-macro = true + +[dev-dependencies] +ruma-identifiers = "0.16.1" +serde_json = "1.0.53" +js_int = { version = "0.1.5", features = ["serde"] } +serde = { version = "1.0.110", features = ["derive"] } diff --git a/ruma-events/ruma-events-macros/README.md b/ruma-events/ruma-events-macros/README.md new file mode 100644 index 00000000..bc81d6df --- /dev/null +++ b/ruma-events/ruma-events-macros/README.md @@ -0,0 +1,13 @@ +# ruma-events-macros + +[![Build Status](https://travis-ci.org/ruma/ruma-events-macros.svg?branch=master)](https://travis-ci.org/ruma/ruma-events-macros) + +**ruma-events-macros** provides a procedural macro for easily generating event types for [ruma-events](https://github.com/ruma/ruma-events). + +## Documentation + +ruma-events-macros has [comprehensive documentation](https://docs.rs/ruma-events-macros) available on docs.rs. + +## License + +[MIT](http://opensource.org/licenses/MIT) diff --git a/ruma-events/ruma-events-macros/src/content_enum.rs b/ruma-events/ruma-events-macros/src/content_enum.rs new file mode 100644 index 00000000..11403f62 --- /dev/null +++ b/ruma-events/ruma-events-macros/src/content_enum.rs @@ -0,0 +1,192 @@ +//! Implementation of the content_enum type macro. + +use proc_macro2::TokenStream; +use quote::quote; +use syn::{ + parse::{self, Parse, ParseStream}, + Attribute, Expr, ExprLit, Ident, Lit, LitStr, Token, +}; + +/// Create a content enum from `ContentEnumInput`. +pub fn expand_content_enum(input: ContentEnumInput) -> syn::Result { + let attrs = &input.attrs; + let ident = &input.name; + let event_type_str = &input.events; + + let variants = input.events.iter().map(to_camel_case).collect::>(); + let content = input + .events + .iter() + .map(to_event_content_path) + .collect::>(); + + let content_enum = quote! { + #( #attrs )* + #[derive(Clone, Debug, ::serde::Serialize)] + #[serde(untagged)] + #[allow(clippy::large_enum_variant)] + pub enum #ident { + #( + #[doc = #event_type_str] + #variants(#content) + ),* + } + }; + + let event_content_impl = quote! { + impl ::ruma_events::EventContent for #ident { + fn event_type(&self) -> &str { + match self { + #( Self::#variants(content) => content.event_type() ),* + } + } + + fn from_parts(event_type: &str, input: Box<::serde_json::value::RawValue>) -> Result { + match event_type { + #( + #event_type_str => { + let content = #content::from_parts(event_type, input)?; + Ok(#ident::#variants(content)) + }, + )* + ev => Err(format!("event not supported {}", ev)), + } + } + } + }; + + let marker_trait_impls = marker_traits(ident); + + Ok(quote! { + #content_enum + + #event_content_impl + + #marker_trait_impls + }) +} + +fn marker_traits(ident: &Ident) -> TokenStream { + match ident.to_string().as_str() { + "AnyStateEventContent" => quote! { + impl ::ruma_events::RoomEventContent for #ident {} + impl ::ruma_events::StateEventContent for #ident {} + }, + "AnyMessageEventContent" => quote! { + impl ::ruma_events::RoomEventContent for #ident {} + impl ::ruma_events::MessageEventContent for #ident {} + }, + "AnyEphemeralRoomEventContent" => quote! { + impl ::ruma_events::EphemeralRoomEventContent for #ident {} + }, + "AnyBasicEventContent" => quote! { + impl ::ruma_events::BasicEventContent for #ident {} + }, + _ => TokenStream::new(), + } +} + +fn to_event_content_path( + name: &LitStr, +) -> syn::punctuated::Punctuated { + let span = name.span(); + let name = name.value(); + + assert_eq!(&name[..2], "m."); + + let path = name[2..].split('.').collect::>(); + + let event_str = path.last().unwrap(); + let event = event_str + .split('_') + .map(|s| s.chars().next().unwrap().to_uppercase().to_string() + &s[1..]) + .collect::(); + + let content_str = Ident::new(&format!("{}EventContent", event), span); + let path = path.iter().map(|s| Ident::new(s, span)); + syn::parse_quote! { + ::ruma_events::#( #path )::*::#content_str + } +} + +/// Splits the given `event_type` string on `.` and `_` removing the `m.room.` then +/// camel casing to give the `EventContent` struct name. +pub(crate) fn to_camel_case(name: &LitStr) -> Ident { + let span = name.span(); + let name = name.value(); + + if &name[..2] != "m." { + panic!( + "well-known matrix events have to start with `m.` found `{}`", + name, + ) + } + + let s = name[2..] + .split(&['.', '_'] as &[char]) + .map(|s| s.chars().next().unwrap().to_uppercase().to_string() + &s[1..]) + .collect::(); + Ident::new(&s, span) +} + +/// Custom keywords for the `event_content_content_enum!` macro +mod kw { + syn::custom_keyword!(name); + syn::custom_keyword!(events); +} + +/// The entire `event_content_content_enum!` macro structure directly as it appears in the source code.. +pub struct ContentEnumInput { + /// Outer attributes on the field, such as a docstring. + pub attrs: Vec, + + /// The name of the event. + pub name: Ident, + + /// An array of valid matrix event types. This will generate the variants of the event content type "name". + /// There needs to be a corresponding variant in `ruma_events::EventType` for + /// this event (converted to a valid Rust-style type name by stripping `m.`, replacing the + /// remaining dots by underscores and then converting from snake_case to CamelCase). + pub events: Vec, +} + +impl Parse for ContentEnumInput { + fn parse(input: ParseStream<'_>) -> parse::Result { + let attrs = input.call(Attribute::parse_outer)?; + // name field + input.parse::()?; + input.parse::()?; + // the name of our content_enum enum + let name: Ident = input.parse()?; + input.parse::()?; + + // events field + input.parse::()?; + input.parse::()?; + + // an array of event names `["m.room.whatever"]` + let ev_array = input.parse::()?; + let events = ev_array + .elems + .into_iter() + .map(|item| { + if let Expr::Lit(ExprLit { + lit: Lit::Str(lit_str), + .. + }) = item + { + Ok(lit_str) + } else { + let msg = "values of field `events` are required to be a string literal"; + Err(syn::Error::new_spanned(item, msg)) + } + }) + .collect::>()?; + + Ok(Self { + attrs, + name, + events, + }) + } +} diff --git a/ruma-events/ruma-events-macros/src/event.rs b/ruma-events/ruma-events-macros/src/event.rs new file mode 100644 index 00000000..b58ee9ca --- /dev/null +++ b/ruma-events/ruma-events-macros/src/event.rs @@ -0,0 +1,287 @@ +//! Implementation of the top level `*Event` derive macro. + +use proc_macro2::{Span, TokenStream}; +use quote::quote; +use syn::{Data, DataStruct, DeriveInput, Field, Fields, FieldsNamed, Ident}; + +/// Derive `Event` macro code generation. +pub fn expand_event(input: DeriveInput) -> syn::Result { + let ident = &input.ident; + let (impl_gen, ty_gen, where_clause) = input.generics.split_for_impl(); + let is_generic = !input.generics.params.is_empty(); + + let fields = if let Data::Struct(DataStruct { fields, .. }) = input.data.clone() { + if let Fields::Named(FieldsNamed { named, .. }) = fields { + if !named.iter().any(|f| f.ident.as_ref().unwrap() == "content") { + return Err(syn::Error::new( + Span::call_site(), + "struct must contain a `content` field", + )); + } + + named.into_iter().collect::>() + } else { + return Err(syn::Error::new_spanned( + fields, + "the `Event` derive only supports named fields", + )); + } + } else { + return Err(syn::Error::new_spanned( + input.ident, + "the `Event` derive only supports structs with named fields", + )); + }; + + let serialize_fields = fields + .iter() + .map(|field| { + let name = field.ident.as_ref().unwrap(); + if name == "prev_content" { + quote! { + if let Some(content) = self.prev_content.as_ref() { + state.serialize_field("prev_content", content)?; + } + } + } else if name == "origin_server_ts" { + quote! { + let time_since_epoch = + self.origin_server_ts.duration_since(::std::time::UNIX_EPOCH).unwrap(); + + let timestamp = <::js_int::UInt as ::std::convert::TryFrom<_>>::try_from(time_since_epoch.as_millis()) + .map_err(S::Error::custom)?; + + state.serialize_field("origin_server_ts", ×tamp)?; + } + } else if name == "unsigned" { + quote! { + if !self.unsigned.is_empty() { + state.serialize_field("unsigned", &self.unsigned)?; + } + } + } else { + quote! { + state.serialize_field(stringify!(#name), &self.#name)?; + } + } + }) + .collect::>(); + + let serialize_impl = quote! { + impl #impl_gen ::serde::ser::Serialize for #ident #ty_gen #where_clause { + fn serialize(&self, serializer: S) -> Result + where + S: ::serde::ser::Serializer, + { + use ::serde::ser::{SerializeStruct as _, Error as _}; + + let event_type = ::ruma_events::EventContent::event_type(&self.content); + + let mut state = serializer.serialize_struct(stringify!(#ident), 7)?; + + state.serialize_field("type", event_type)?; + #( #serialize_fields )* + state.end() + } + } + }; + + let deserialize_impl = expand_deserialize_event(is_generic, input, fields)?; + + Ok(quote! { + #serialize_impl + + #deserialize_impl + }) +} + +fn expand_deserialize_event( + is_generic: bool, + input: DeriveInput, + fields: Vec, +) -> syn::Result { + let ident = &input.ident; + // we know there is a content field already + let content_type = fields + .iter() + // we also know that the fields are named and have an ident + .find(|f| f.ident.as_ref().unwrap() == "content") + .map(|f| f.ty.clone()) + .unwrap(); + + let (impl_generics, ty_gen, where_clause) = input.generics.split_for_impl(); + + let enum_variants = fields + .iter() + .map(|field| { + let name = field.ident.as_ref().unwrap(); + to_camel_case(name) + }) + .collect::>(); + + let deserialize_var_types = fields + .iter() + .map(|field| { + let name = field.ident.as_ref().unwrap(); + let ty = &field.ty; + if name == "content" || name == "prev_content" { + if is_generic { + quote! { Box<::serde_json::value::RawValue> } + } else { + quote! { #content_type } + } + } else if name == "origin_server_ts" { + quote! { ::js_int::UInt } + } else { + quote! { #ty } + } + }) + .collect::>(); + + let ok_or_else_fields = fields + .iter() + .map(|field| { + let name = field.ident.as_ref().unwrap(); + if name == "content" { + if is_generic { + quote! { + let json = content.ok_or_else(|| ::serde::de::Error::missing_field("content"))?; + let content = C::from_parts(&event_type, json).map_err(A::Error::custom)?; + } + } else { + quote! { + let content = content.ok_or_else(|| ::serde::de::Error::missing_field("content"))?; + } + } + } else if name == "prev_content" { + if is_generic { + quote! { + let prev_content = if let Some(json) = prev_content { + Some(C::from_parts(&event_type, json).map_err(A::Error::custom)?) + } else { + None + }; + } + } else { + quote! { + let prev_content = if let Some(content) = prev_content { + Some(content) + } else { + None + }; + } + } + } else if name == "origin_server_ts" { + quote! { + let origin_server_ts = origin_server_ts + .map(|time| { + let t = time.into(); + ::std::time::UNIX_EPOCH + ::std::time::Duration::from_millis(t) + }) + .ok_or_else(|| ::serde::de::Error::missing_field("origin_server_ts"))?; + } + } else if name == "unsigned" { + quote! { let unsigned = unsigned.unwrap_or_default(); } + } else { + quote! { + let #name = #name.ok_or_else(|| { + ::serde::de::Error::missing_field(stringify!(#name)) + })?; + } + } + }) + .collect::>(); + + let field_names = fields.iter().flat_map(|f| &f.ident).collect::>(); + + let deserialize_impl_gen = if is_generic { + let gen = &input.generics.params; + quote! { <'de, #gen> } + } else { + quote! { <'de> } + }; + let deserialize_phantom_type = if is_generic { + quote! { ::std::marker::PhantomData } + } else { + quote! {} + }; + + Ok(quote! { + impl #deserialize_impl_gen ::serde::de::Deserialize<'de> for #ident #ty_gen #where_clause { + fn deserialize(deserializer: D) -> Result + where + D: ::serde::de::Deserializer<'de>, + { + #[derive(serde::Deserialize)] + #[serde(field_identifier, rename_all = "snake_case")] + enum Field { + // since this is represented as an enum we have to add it so the JSON picks it up + Type, + #( #enum_variants ),* + } + + /// Visits the fields of an event struct to handle deserialization of + /// the `content` and `prev_content` fields. + struct EventVisitor #impl_generics (#deserialize_phantom_type #ty_gen); + + impl #deserialize_impl_gen ::serde::de::Visitor<'de> for EventVisitor #ty_gen #where_clause { + type Value = #ident #ty_gen; + + fn expecting(&self, formatter: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + write!(formatter, "struct implementing {}", stringify!(#content_type)) + } + + fn visit_map(self, mut map: A) -> Result + where + A: ::serde::de::MapAccess<'de>, + { + use ::serde::de::Error as _; + + let mut event_type: Option = None; + #( let mut #field_names: Option<#deserialize_var_types> = None; )* + + while let Some(key) = map.next_key()? { + match key { + Field::Type => { + if event_type.is_some() { + return Err(::serde::de::Error::duplicate_field("type")); + } + event_type = Some(map.next_value()?); + } + #( + Field::#enum_variants => { + if #field_names.is_some() { + return Err(::serde::de::Error::duplicate_field(stringify!(#field_names))); + } + #field_names = Some(map.next_value()?); + } + )* + } + } + + let event_type = event_type.ok_or_else(|| ::serde::de::Error::missing_field("type"))?; + #( #ok_or_else_fields )* + + Ok(#ident { + #( #field_names ),* + }) + } + } + + deserializer.deserialize_map(EventVisitor(#deserialize_phantom_type)) + } + } + }) +} + +/// CamelCase's a field ident like "foo_bar" to "FooBar". +fn to_camel_case(name: &Ident) -> Ident { + let span = name.span(); + let name = name.to_string(); + + let s = name + .split('_') + .map(|s| s.chars().next().unwrap().to_uppercase().to_string() + &s[1..]) + .collect::(); + Ident::new(&s, span) +} diff --git a/ruma-events/ruma-events-macros/src/event_content.rs b/ruma-events/ruma-events-macros/src/event_content.rs new file mode 100644 index 00000000..ed4e2922 --- /dev/null +++ b/ruma-events/ruma-events-macros/src/event_content.rs @@ -0,0 +1,126 @@ +//! Implementations of the MessageEventContent and StateEventContent derive macro. + +use proc_macro2::{Span, TokenStream}; +use quote::quote; +use syn::{ + parse::{Parse, ParseStream}, + DeriveInput, LitStr, Token, +}; + +/// Parses attributes for `*EventContent` derives. +/// +/// `#[ruma_event(type = "m.room.alias")]` +enum EventMeta { + /// Variant holds the "m.whatever" event type. + Type(LitStr), +} + +impl Parse for EventMeta { + fn parse(input: ParseStream) -> syn::Result { + input.parse::()?; + input.parse::()?; + Ok(EventMeta::Type(input.parse::()?)) + } +} + +/// Create an `EventContent` implementation for a struct. +pub fn expand_event_content(input: DeriveInput) -> syn::Result { + let ident = &input.ident; + + let event_type_attr = input + .attrs + .iter() + .find(|attr| attr.path.is_ident("ruma_event")) + .ok_or_else(|| { + let msg = "no event type attribute found, \ + add `#[ruma_event(type = \"any.room.event\")]` \ + below the event content derive"; + + syn::Error::new(Span::call_site(), msg) + })?; + + let event_type = { + let event_meta = event_type_attr.parse_args::()?; + let EventMeta::Type(lit) = event_meta; + lit + }; + + Ok(quote! { + impl ::ruma_events::EventContent for #ident { + fn event_type(&self) -> &str { + #event_type + } + + fn from_parts( + ev_type: &str, + content: Box<::serde_json::value::RawValue> + ) -> Result { + if ev_type != #event_type { + return Err(format!("expected `{}` found {}", #event_type, ev_type)); + } + + ::serde_json::from_str(content.get()).map_err(|e| e.to_string()) + } + } + }) +} + +/// Create a `BasicEventContent` implementation for a struct +pub fn expand_basic_event_content(input: DeriveInput) -> syn::Result { + let ident = input.ident.clone(); + let event_content_impl = expand_event_content(input)?; + + Ok(quote! { + #event_content_impl + + impl ::ruma_events::BasicEventContent for #ident { } + }) +} + +/// Create a `EphemeralRoomEventContent` implementation for a struct +pub fn expand_ephemeral_room_event_content(input: DeriveInput) -> syn::Result { + let ident = input.ident.clone(); + let event_content_impl = expand_event_content(input)?; + + Ok(quote! { + #event_content_impl + + impl ::ruma_events::EphemeralRoomEventContent for #ident { } + }) +} + +/// Create a `RoomEventContent` implementation for a struct. +pub fn expand_room_event_content(input: DeriveInput) -> syn::Result { + let ident = input.ident.clone(); + let event_content_impl = expand_event_content(input)?; + + Ok(quote! { + #event_content_impl + + impl ::ruma_events::RoomEventContent for #ident { } + }) +} + +/// Create a `MessageEventContent` implementation for a struct +pub fn expand_message_event_content(input: DeriveInput) -> syn::Result { + let ident = input.ident.clone(); + let room_ev_content = expand_room_event_content(input)?; + + Ok(quote! { + #room_ev_content + + impl ::ruma_events::MessageEventContent for #ident { } + }) +} + +/// Create a `StateEventContent` implementation for a struct +pub fn expand_state_event_content(input: DeriveInput) -> syn::Result { + let ident = input.ident.clone(); + let room_ev_content = expand_room_event_content(input)?; + + Ok(quote! { + #room_ev_content + + impl ::ruma_events::StateEventContent for #ident { } + }) +} diff --git a/ruma-events/ruma-events-macros/src/lib.rs b/ruma-events/ruma-events-macros/src/lib.rs new file mode 100644 index 00000000..fa8efe6c --- /dev/null +++ b/ruma-events/ruma-events-macros/src/lib.rs @@ -0,0 +1,102 @@ +//! Crate `ruma_events_macros` provides a procedural macro for generating +//! [ruma-events](https://github.com/ruma/ruma-events) events. +//! +//! See the documentation for the individual macros for usage details. +#![deny( + missing_copy_implementations, + missing_debug_implementations, + missing_docs +)] + +extern crate proc_macro; + +use proc_macro::TokenStream; +use syn::{parse_macro_input, DeriveInput}; + +use self::{ + content_enum::{expand_content_enum, ContentEnumInput}, + event::expand_event, + event_content::{ + expand_basic_event_content, expand_ephemeral_room_event_content, expand_event_content, + expand_message_event_content, expand_room_event_content, expand_state_event_content, + }, +}; + +mod content_enum; +mod event; +mod event_content; + +/// Generates a content enum to represent the various Matrix event types. +/// +/// This macro also implements the necessary traits for the type to serialize and deserialize +/// itself. +#[proc_macro] +pub fn event_content_enum(input: TokenStream) -> TokenStream { + let content_enum_input = syn::parse_macro_input!(input as ContentEnumInput); + expand_content_enum(content_enum_input) + .unwrap_or_else(|err| err.to_compile_error()) + .into() +} + +/// Generates an implementation of `ruma_events::EventContent`. +#[proc_macro_derive(EventContent, attributes(ruma_event))] +pub fn derive_event_content(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as DeriveInput); + expand_event_content(input) + .unwrap_or_else(|err| err.to_compile_error()) + .into() +} + +/// Generates an implementation of `ruma_events::BasicEventContent` and it's super traits. +#[proc_macro_derive(BasicEventContent, attributes(ruma_event))] +pub fn derive_basic_event_content(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as DeriveInput); + expand_basic_event_content(input) + .unwrap_or_else(|err| err.to_compile_error()) + .into() +} + +/// Generates an implementation of `ruma_events::RoomEventContent` and it's super traits. +#[proc_macro_derive(RoomEventContent, attributes(ruma_event))] +pub fn derive_room_event_content(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as DeriveInput); + expand_room_event_content(input) + .unwrap_or_else(|err| err.to_compile_error()) + .into() +} + +/// Generates an implementation of `ruma_events::MessageEventContent` and it's super traits. +#[proc_macro_derive(MessageEventContent, attributes(ruma_event))] +pub fn derive_message_event_content(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as DeriveInput); + expand_message_event_content(input) + .unwrap_or_else(|err| err.to_compile_error()) + .into() +} + +/// Generates an implementation of `ruma_events::StateEventContent` and it's super traits. +#[proc_macro_derive(StateEventContent, attributes(ruma_event))] +pub fn derive_state_event_content(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as DeriveInput); + expand_state_event_content(input) + .unwrap_or_else(|err| err.to_compile_error()) + .into() +} + +/// Generates an implementation of `ruma_events::EphemeralRoomEventContent` and it's super traits. +#[proc_macro_derive(EphemeralRoomEventContent, attributes(ruma_event))] +pub fn derive_ephemeral_room_event_content(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as DeriveInput); + expand_ephemeral_room_event_content(input) + .unwrap_or_else(|err| err.to_compile_error()) + .into() +} + +/// Generates implementations needed to serialize and deserialize Matrix events. +#[proc_macro_derive(Event, attributes(ruma_event))] +pub fn derive_state_event(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as DeriveInput); + expand_event(input) + .unwrap_or_else(|err| err.to_compile_error()) + .into() +} diff --git a/ruma-events/src/algorithm.rs b/ruma-events/src/algorithm.rs new file mode 100644 index 00000000..2406be08 --- /dev/null +++ b/ruma-events/src/algorithm.rs @@ -0,0 +1,70 @@ +use std::fmt::{Display, Formatter, Result as FmtResult}; + +use serde::{Deserialize, Serialize}; + +/// An encryption algorithm to be used to encrypt messages sent to a room. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[non_exhaustive] +#[serde(from = "String", into = "String")] +pub enum Algorithm { + /// Olm version 1 using Curve25519, AES-256, and SHA-256. + OlmV1Curve25519AesSha2, + + /// Megolm version 1 using AES-256 and SHA-256. + MegolmV1AesSha2, + + /// Any algorithm that is not part of the specification. + Custom(String), +} + +impl Display for Algorithm { + fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { + let algorithm_str = match *self { + Algorithm::OlmV1Curve25519AesSha2 => "m.olm.v1.curve25519-aes-sha2", + Algorithm::MegolmV1AesSha2 => "m.megolm.v1.aes-sha2", + Algorithm::Custom(ref algorithm) => algorithm, + }; + + write!(f, "{}", algorithm_str) + } +} + +impl From for Algorithm +where + T: Into + AsRef, +{ + fn from(s: T) -> Algorithm { + match s.as_ref() { + "m.olm.v1.curve25519-aes-sha2" => Algorithm::OlmV1Curve25519AesSha2, + "m.megolm.v1.aes-sha2" => Algorithm::MegolmV1AesSha2, + _ => Algorithm::Custom(s.into()), + } + } +} + +impl From for String { + fn from(algorithm: Algorithm) -> String { + algorithm.to_string() + } +} + +#[cfg(test)] +mod tests { + use ruma_serde::test::serde_json_eq; + use serde_json::json; + + use super::*; + + #[test] + fn serialize_and_deserialize_from_display_form() { + serde_json_eq(Algorithm::MegolmV1AesSha2, json!("m.megolm.v1.aes-sha2")); + serde_json_eq( + Algorithm::OlmV1Curve25519AesSha2, + json!("m.olm.v1.curve25519-aes-sha2"), + ); + serde_json_eq( + Algorithm::Custom("io.ruma.test".to_string()), + json!("io.ruma.test"), + ); + } +} diff --git a/ruma-events/src/call.rs b/ruma-events/src/call.rs new file mode 100644 index 00000000..ef90f3f1 --- /dev/null +++ b/ruma-events/src/call.rs @@ -0,0 +1,35 @@ +//! Modules for events in the *m.call* namespace. +//! +//! This module also contains types shared by events in its child namespaces. + +use serde::{Deserialize, Serialize}; +use strum::{Display, EnumString}; + +pub mod answer; +pub mod candidates; +pub mod hangup; +pub mod invite; + +/// A VoIP session description. +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] +pub struct SessionDescription { + /// The type of session description. + #[serde(rename = "type")] + pub session_type: SessionDescriptionType, + + /// The SDP text of the session description. + pub sdp: String, +} + +/// The type of VoIP session description. +#[derive(Clone, Copy, Debug, PartialEq, Display, EnumString, Deserialize, Serialize)] +#[non_exhaustive] +#[serde(rename_all = "snake_case")] +#[strum(serialize_all = "snake_case")] +pub enum SessionDescriptionType { + /// An answer. + Answer, + + /// An offer. + Offer, +} diff --git a/ruma-events/src/call/answer.rs b/ruma-events/src/call/answer.rs new file mode 100644 index 00000000..fd5c733c --- /dev/null +++ b/ruma-events/src/call/answer.rs @@ -0,0 +1,25 @@ +//! Types for the *m.call.answer* event. + +use js_int::UInt; +use ruma_events_macros::MessageEventContent; +use serde::{Deserialize, Serialize}; + +use super::SessionDescription; +use crate::MessageEvent; + +/// This event is sent by the callee when they wish to answer the call. +pub type AnswerEvent = MessageEvent; + +/// The payload for `AnswerEvent`. +#[derive(Clone, Debug, Deserialize, Serialize, MessageEventContent)] +#[ruma_event(type = "m.call.answer")] +pub struct AnswerEventContent { + /// The VoIP session description object. The session description type must be *answer*. + pub answer: SessionDescription, + + /// The ID of the call this event relates to. + pub call_id: String, + + /// The version of the VoIP specification this messages adheres to. + pub version: UInt, +} diff --git a/ruma-events/src/call/candidates.rs b/ruma-events/src/call/candidates.rs new file mode 100644 index 00000000..befc3d65 --- /dev/null +++ b/ruma-events/src/call/candidates.rs @@ -0,0 +1,39 @@ +//! Types for the *m.call.candidates* event. + +use js_int::UInt; +use ruma_events_macros::MessageEventContent; +use serde::{Deserialize, Serialize}; + +use crate::MessageEvent; + +/// This event is sent by callers after sending an invite and by the callee after answering. Its +/// purpose is to give the other party additional ICE candidates to try using to communicate. +pub type CandidatesEvent = MessageEvent; + +/// The payload for `CandidatesEvent`. +#[derive(Clone, Debug, Deserialize, Serialize, MessageEventContent)] +#[ruma_event(type = "m.call.candidates")] +pub struct CandidatesEventContent { + /// The ID of the call this event relates to. + pub call_id: String, + + /// A list of candidates. + pub candidates: Vec, + + /// The version of the VoIP specification this messages adheres to. + pub version: UInt, +} + +/// An ICE (Interactive Connectivity Establishment) candidate. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Candidate { + /// The SDP "a" line of the candidate. + pub candidate: String, + + /// The SDP media type this candidate is intended for. + pub sdp_mid: String, + + /// The index of the SDP "m" line this candidate is intended for. + pub sdp_m_line_index: UInt, +} diff --git a/ruma-events/src/call/hangup.rs b/ruma-events/src/call/hangup.rs new file mode 100644 index 00000000..4dfe9c7c --- /dev/null +++ b/ruma-events/src/call/hangup.rs @@ -0,0 +1,43 @@ +//! Types for the *m.call.hangup* event. + +use js_int::UInt; +use ruma_events_macros::MessageEventContent; +use serde::{Deserialize, Serialize}; +use strum::{Display, EnumString}; + +use crate::MessageEvent; + +/// Sent by either party to signal their termination of the call. This can be sent either once the +/// call has has been established or before to abort the call. +pub type HangupEvent = MessageEvent; + +/// The payload for `HangupEvent`. +#[derive(Clone, Debug, Deserialize, Serialize, MessageEventContent)] +#[ruma_event(type = "m.call.hangup")] +pub struct HangupEventContent { + /// The ID of the call this event relates to. + pub call_id: String, + + /// The version of the VoIP specification this messages adheres to. + pub version: UInt, + + /// Optional error reason for the hangup. + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option, +} + +/// A reason for a hangup. +/// +/// This should not be provided when the user naturally ends or rejects the call. When there was an +/// error in the call negotiation, this should be `ice_failed` for when ICE negotiation fails or +/// `invite_timeout` for when the other party did not answer in time. +#[derive(Clone, Copy, Debug, PartialEq, Display, EnumString, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +#[strum(serialize_all = "snake_case")] +pub enum Reason { + /// ICE negotiation failure. + IceFailed, + + /// Party did not answer in time. + InviteTimeout, +} diff --git a/ruma-events/src/call/invite.rs b/ruma-events/src/call/invite.rs new file mode 100644 index 00000000..48f9157b --- /dev/null +++ b/ruma-events/src/call/invite.rs @@ -0,0 +1,30 @@ +//! Types for the *m.call.invite* event. + +use js_int::UInt; +use ruma_events_macros::MessageEventContent; +use serde::{Deserialize, Serialize}; + +use super::SessionDescription; +use crate::MessageEvent; + +/// This event is sent by the caller when they wish to establish a call. +pub type InviteEvent = MessageEvent; + +/// The payload for `InviteEvent`. +#[derive(Clone, Debug, Deserialize, Serialize, MessageEventContent)] +#[ruma_event(type = "m.call.invite")] +pub struct InviteEventContent { + /// A unique identifer for the call. + pub call_id: String, + + /// The time in milliseconds that the invite is valid for. Once the invite age exceeds this + /// value, clients should discard it. They should also no longer show the call as awaiting an + /// answer in the UI. + pub lifetime: UInt, + + /// The session description object. The session description type must be *offer*. + pub offer: SessionDescription, + + /// The version of the VoIP specification this messages adheres to. + pub version: UInt, +} diff --git a/ruma-events/src/custom.rs b/ruma-events/src/custom.rs new file mode 100644 index 00000000..8f2e88e3 --- /dev/null +++ b/ruma-events/src/custom.rs @@ -0,0 +1,76 @@ +//! Types for custom events outside of the Matrix specification. + +use std::time::SystemTime; + +use ruma_identifiers::{EventId, RoomId, UserId}; +use serde::Serialize; +use serde_json::Value as JsonValue; + +use crate::UnsignedData; + +// TODO: (De)serialization + +/// A custom event's type and `content` JSON object. +#[derive(Clone, Debug, Serialize)] +pub struct CustomEventContent { + /// The event type string. + #[serde(skip)] + pub event_type: String, + + /// The actual `content` JSON object. + pub json: JsonValue, +} + +/// A custom event not covered by the Matrix specification. +#[derive(Clone, Debug)] +pub struct CustomBasicEvent { + /// The event's content. + pub content: CustomEventContent, +} + +/// A custom message event not covered by the Matrix specification. +#[derive(Clone, Debug)] +pub struct CustomMessageEvent { + /// The event's content. + pub content: CustomEventContent, + + /// Time on originating homeserver when this event was sent. + pub origin_server_ts: SystemTime, + + /// The unique identifier for the room associated with this event. + pub room_id: Option, + + /// The unique identifier for the user who sent this event. + pub sender: UserId, + + /// Additional key-value pairs not signed by the homeserver. + pub unsigned: UnsignedData, +} + +/// A custom state event not covered by the Matrix specification. +#[derive(Clone, Debug)] +pub struct CustomStateEvent { + /// The event's content. + pub content: CustomEventContent, + + /// The unique identifier for the event. + pub event_id: EventId, + + /// Time on originating homeserver when this event was sent. + pub origin_server_ts: SystemTime, + + /// The previous content for this state key, if any. + pub prev_content: Option, + + /// The unique identifier for the room associated with this event. + pub room_id: Option, + + /// The unique identifier for the user who sent this event. + pub sender: UserId, + + /// A key that determines which piece of room state the event represents. + pub state_key: String, + + /// Additional key-value pairs not signed by the homeserver. + pub unsigned: UnsignedData, +} diff --git a/ruma-events/src/direct.rs b/ruma-events/src/direct.rs new file mode 100644 index 00000000..4c433a27 --- /dev/null +++ b/ruma-events/src/direct.rs @@ -0,0 +1,90 @@ +//! Types for the *m.direct* event. + +use std::{ + collections::BTreeMap, + ops::{Deref, DerefMut}, +}; + +use ruma_events_macros::BasicEventContent; +use ruma_identifiers::{RoomId, UserId}; +use serde::{Deserialize, Serialize}; + +/// Informs the client about the rooms that are considered direct by a user. +pub type DirectEvent = crate::BasicEvent; + +/// The payload for `DirectEvent`. +/// +/// A mapping of `UserId`s to a list of `RoomId`s which are considered *direct* for that +/// particular user. +#[derive(Clone, Debug, Deserialize, Serialize, BasicEventContent)] +#[ruma_event(type = "m.direct")] +pub struct DirectEventContent(pub BTreeMap>); + +impl Deref for DirectEventContent { + type Target = BTreeMap>; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for DirectEventContent { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use ruma_identifiers::{RoomId, UserId}; + use serde_json::{from_value as from_json_value, json, to_value as to_json_value}; + + use super::{DirectEvent, DirectEventContent}; + use crate::EventJson; + + #[test] + fn serialization() { + let mut content = DirectEventContent(BTreeMap::new()); + let alice = UserId::new("ruma.io").unwrap(); + let room = vec![RoomId::new("ruma.io").unwrap()]; + + content.insert(alice.clone(), room.clone()); + + let event = DirectEvent { content }; + let json_data = json!({ + "content": { + alice.to_string(): vec![room[0].to_string()], + }, + "type": "m.direct" + }); + + assert_eq!(to_json_value(&event).unwrap(), json_data); + } + + #[test] + fn deserialization() { + let alice = UserId::new("ruma.io").unwrap(); + let rooms = vec![ + RoomId::new("ruma.io").unwrap(), + RoomId::new("ruma.io").unwrap(), + ]; + + let json_data = json!({ + "content": { + alice.to_string(): vec![rooms[0].to_string(), rooms[1].to_string()], + }, + "type": "m.direct" + }); + + let event: DirectEvent = from_json_value::>(json_data) + .unwrap() + .deserialize() + .unwrap(); + let direct_rooms = event.content.get(&alice).unwrap(); + + assert!(direct_rooms.contains(&rooms[0])); + assert!(direct_rooms.contains(&rooms[1])); + } +} diff --git a/ruma-events/src/dummy.rs b/ruma-events/src/dummy.rs new file mode 100644 index 00000000..c2d70a1c --- /dev/null +++ b/ruma-events/src/dummy.rs @@ -0,0 +1,75 @@ +//! Types for the *m.dummy* event. + +use std::ops::{Deref, DerefMut}; + +use ruma_events_macros::BasicEventContent; +use ruma_serde::empty::Empty; +use serde::{Deserialize, Serialize}; + +use crate::BasicEvent; + +/// This event type is used to indicate new Olm sessions for end-to-end encryption. +/// +/// Typically it is encrypted as an *m.room.encrypted* event, then sent as a to-device event. +/// +/// The event does not have any content associated with it. The sending client is expected to +/// send a key share request shortly after this message, causing the receiving client to process +/// this *m.dummy* event as the most recent event and using the keyshare request to set up the +/// session. The keyshare request and *m.dummy* combination should result in the original +/// sending client receiving keys over the newly established session. +pub type DummyEvent = BasicEvent; + +#[derive(Clone, Debug, Deserialize, Serialize, BasicEventContent)] +#[ruma_event(type = "m.dummy")] +/// The payload for `DummyEvent`. +pub struct DummyEventContent(pub Empty); + +impl Deref for DummyEventContent { + type Target = Empty; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for DummyEventContent { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +#[cfg(test)] +mod tests { + use super::{DummyEvent, DummyEventContent, Empty}; + use crate::EventJson; + + use serde_json::{from_value as from_json_value, json, to_value as to_json_value}; + + #[test] + fn serialization() { + let dummy_event = DummyEvent { + content: DummyEventContent(Empty), + }; + let actual = to_json_value(dummy_event).unwrap(); + + let expected = json!({ + "content": {}, + "type": "m.dummy" + }); + + assert_eq!(actual, expected); + } + + #[test] + fn deserialization() { + let json = json!({ + "content": {}, + "type": "m.dummy" + }); + + assert!(from_json_value::>(json) + .unwrap() + .deserialize() + .is_ok()); + } +} diff --git a/ruma-events/src/enums.rs b/ruma-events/src/enums.rs new file mode 100644 index 00000000..6664d7c7 --- /dev/null +++ b/ruma-events/src/enums.rs @@ -0,0 +1,157 @@ +use ruma_events_macros::event_content_enum; +use serde::{Deserialize, Serialize}; + +use crate::{ + event_kinds::{ + BasicEvent, EphemeralRoomEvent, MessageEvent, MessageEventStub, StateEvent, StateEventStub, + StrippedStateEventStub, ToDeviceEvent, + }, + presence::PresenceEvent, + room::redaction::{RedactionEvent, RedactionEventStub}, +}; + +event_content_enum! { + /// Any basic event. + name: AnyBasicEventContent, + events: [ + "m.direct", + "m.dummy", + "m.ignored_user_list", + "m.push_rules", + "m.room_key", + "m.tag", + ] +} + +event_content_enum! { + /// Any ephemeral room event. + name: AnyEphemeralRoomEventContent, + events: [ + "m.fully_read", + "m.receipt", + "m.typing", + ] +} + +event_content_enum! { + /// Any message event. + name: AnyMessageEventContent, + events: [ + "m.call.answer", + "m.call.invite", + "m.call.hangup", + "m.call.candidates", + "m.room.message", + "m.room.message.feedback", + "m.sticker", + ] +} + +event_content_enum! { + /// Any state event. + name: AnyStateEventContent, + events: [ + "m.room.aliases", + "m.room.avatar", + "m.room.canonical_alias", + "m.room.create", + "m.room.encryption", + "m.room.guest_access", + "m.room.history_visibility", + "m.room.join_rules", + "m.room.member", + "m.room.name", + "m.room.pinned_events", + "m.room.power_levels", + "m.room.redaction", + "m.room.server_acl", + "m.room.third_party_invite", + "m.room.tombstone", + "m.room.topic", + ] +} + +event_content_enum! { + /// Any to-device event. + name: AnyToDeviceEventContent, + events: [ + "m.dummy", + "m.room_key", + "m.room_key_request", + "m.forwarded_room_key", + "m.key.verification.request", + "m.key.verification.start", + "m.key.verification.cancel", + "m.key.verification.accept", + "m.key.verification.key", + "m.key.verification.mac", + "m.room.encrypted", + ] +} + +/// Any basic event, one that has no (well-known) fields outside of `content`. +pub type AnyBasicEvent = BasicEvent; + +/// Any ephemeral room event. +pub type AnyEphemeralRoomEvent = EphemeralRoomEvent; + +/// Any message event. +pub type AnyMessageEvent = MessageEvent; + +/// Any message event stub (message event without a `room_id`, as returned in `/sync` responses) +pub type AnyMessageEventStub = MessageEventStub; + +/// Any state event. +pub type AnyStateEvent = StateEvent; + +/// Any state event stub (state event without a `room_id`, as returned in `/sync` responses) +pub type AnyStateEventStub = StateEventStub; + +/// Any stripped state event stub (stripped-down state event, as returned for rooms the user has +/// been invited to in `/sync` responses) +pub type AnyStrippedStateEventStub = StrippedStateEventStub; + +/// Any to-device event. +pub type AnyToDeviceEvent = ToDeviceEvent; + +/// Any event. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(untagged)] +pub enum AnyEvent { + /// Any basic event. + Basic(AnyBasicEvent), + /// `"m.presence"`, the only non-room event with a `sender` field. + Presence(PresenceEvent), + /// Any ephemeral room event. + Ephemeral(AnyEphemeralRoomEvent), + /// Any message event. + Message(AnyMessageEvent), + /// `"m.room.redaction"`, the only room event with a `redacts` field. + Redaction(RedactionEvent), + /// Any state event. + State(AnyStateEvent), +} + +/// Any room event. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(untagged)] +pub enum AnyRoomEvent { + /// Any message event. + Message(AnyMessageEvent), + /// `"m.room.redaction"`, the only room event with a `redacts` field. + Redaction(RedactionEvent), + /// Any state event. + State(AnyStateEvent), +} + +/// Any room event stub (room event without a `room_id`, as returned in `/sync` responses) +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(untagged)] +pub enum AnyRoomEventStub { + /// Any message event stub + Message(AnyMessageEventStub), + /// `"m.room.redaction"` stub + Redaction(RedactionEventStub), + /// Any state event stub + StateEvent(AnyStateEventStub), +} diff --git a/ruma-events/src/error.rs b/ruma-events/src/error.rs new file mode 100644 index 00000000..af49bb9e --- /dev/null +++ b/ruma-events/src/error.rs @@ -0,0 +1,89 @@ +use std::{ + error::Error, + fmt::{self, Display, Formatter}, +}; + +/// An event that is malformed or otherwise invalid. +/// +/// When attempting to deserialize an [`EventJson`](enum.EventJson.html), an error in the input +/// data may cause deserialization to fail, or the JSON structure may be correct, but additional +/// constraints defined in the matrix specification are not upheld. This type provides an error +/// message and a flag for which type of error was encountered. +#[derive(Clone, Debug)] +pub struct InvalidEvent { + /// A description of the error that occurred. + pub(crate) message: String, + /// The kind of error that occurred. + pub(crate) kind: InvalidEventKind, +} + +/// The kind of error that occurred. +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub(crate) enum InvalidEventKind { + /// A deserialization error from malformed input. + Deserialization, + /// An error occurred validating input according the the matrix spec. + Validation, +} + +impl InvalidEvent { + /// Constructor used in the event content macros. + /// + /// This has to be public to allow the macros to be used outside of ruma-events. + #[doc(hidden)] + pub fn wrong_event_type(expected: &str, found: &str) -> Self { + Self { + message: format!("expected `{}` found {}", expected, found), + kind: InvalidEventKind::Deserialization, + } + } + /// A message describing why the event is invalid. + pub fn message(&self) -> String { + self.message.clone() + } + + /// Returns whether this is a deserialization error. + pub fn is_deserialization(&self) -> bool { + self.kind == InvalidEventKind::Deserialization + } + + /// Returns whether this is a validation error. + pub fn is_validation(&self) -> bool { + self.kind == InvalidEventKind::Validation + } +} + +impl Display for InvalidEvent { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.message()) + } +} + +impl Error for InvalidEvent {} + +/// An error returned when attempting to create an event with data that would make it invalid. +/// +/// This type is similar to [`InvalidEvent`](struct.InvalidEvent.html), but used during the +/// construction of a new event, as opposed to deserialization of an existing event from JSON. +#[derive(Clone, Debug, PartialEq)] +pub struct InvalidInput(pub(crate) String); + +impl Display for InvalidInput { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl Error for InvalidInput {} + +/// An error when attempting to create a value from a string via the `FromStr` trait. +#[derive(Clone, Copy, Eq, Debug, Hash, PartialEq)] +pub struct FromStrError; + +impl Display for FromStrError { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "failed to parse type from string") + } +} + +impl Error for FromStrError {} diff --git a/ruma-events/src/event_kinds.rs b/ruma-events/src/event_kinds.rs new file mode 100644 index 00000000..347f6ff8 --- /dev/null +++ b/ruma-events/src/event_kinds.rs @@ -0,0 +1,160 @@ +use std::time::SystemTime; + +use ruma_events_macros::Event; +use ruma_identifiers::{EventId, RoomId, UserId}; + +use crate::{ + BasicEventContent, EphemeralRoomEventContent, EventContent, MessageEventContent, + StateEventContent, UnsignedData, +}; + +/// A basic event – one that consists only of it's type and the `content` object. +#[derive(Clone, Debug, Event)] +pub struct BasicEvent { + /// Data specific to the event type. + pub content: C, +} + +/// Ephemeral room event. +#[derive(Clone, Debug, Event)] +pub struct EphemeralRoomEvent { + /// Data specific to the event type. + pub content: C, + + /// The ID of the room associated with this event. + pub room_id: RoomId, +} + +/// An ephemeral room event without a `room_id`. +#[derive(Clone, Debug, Event)] +pub struct EphemeralRoomEventStub { + /// Data specific to the event type. + pub content: C, +} + +/// Message event. +#[derive(Clone, Debug, Event)] +pub struct MessageEvent { + /// Data specific to the event type. + pub content: C, + + /// The globally unique event identifier for the user who sent the event. + pub event_id: EventId, + + /// The fully-qualified ID of the user who sent this event. + pub sender: UserId, + + /// Timestamp in milliseconds on originating homeserver when this event was sent. + pub origin_server_ts: SystemTime, + + /// The ID of the room associated with this event. + pub room_id: RoomId, + + /// Additional key-value pairs not signed by the homeserver. + pub unsigned: UnsignedData, +} + +/// A message event without a `room_id`. +#[derive(Clone, Debug, Event)] +pub struct MessageEventStub { + /// Data specific to the event type. + pub content: C, + + /// The globally unique event identifier for the user who sent the event. + pub event_id: EventId, + + /// The fully-qualified ID of the user who sent this event. + pub sender: UserId, + + /// Timestamp in milliseconds on originating homeserver when this event was sent. + pub origin_server_ts: SystemTime, + + /// Additional key-value pairs not signed by the homeserver. + pub unsigned: UnsignedData, +} + +/// State event. +#[derive(Clone, Debug, Event)] +pub struct StateEvent { + /// Data specific to the event type. + pub content: C, + + /// The globally unique event identifier for the user who sent the event. + pub event_id: EventId, + + /// The fully-qualified ID of the user who sent this event. + pub sender: UserId, + + /// Timestamp in milliseconds on originating homeserver when this event was sent. + pub origin_server_ts: SystemTime, + + /// The ID of the room associated with this event. + pub room_id: RoomId, + + /// A unique key which defines the overwriting semantics for this piece of room state. + /// + /// This is often an empty string, but some events send a `UserId` to show + /// which user the event affects. + pub state_key: String, + + /// Optional previous content for this event. + pub prev_content: Option, + + /// Additional key-value pairs not signed by the homeserver. + pub unsigned: UnsignedData, +} + +/// A state event without a `room_id`. +#[derive(Clone, Debug, Event)] +pub struct StateEventStub { + /// Data specific to the event type. + pub content: C, + + /// The globally unique event identifier for the user who sent the event. + pub event_id: EventId, + + /// The fully-qualified ID of the user who sent this event. + pub sender: UserId, + + /// Timestamp in milliseconds on originating homeserver when this event was sent. + pub origin_server_ts: SystemTime, + + /// A unique key which defines the overwriting semantics for this piece of room state. + /// + /// This is often an empty string, but some events send a `UserId` to show + /// which user the event affects. + pub state_key: String, + + /// Optional previous content for this event. + pub prev_content: Option, + + /// Additional key-value pairs not signed by the homeserver. + pub unsigned: UnsignedData, +} + +/// A stripped-down state event, used for previews of rooms the user has been +/// invited to. +#[derive(Clone, Debug, Event)] +pub struct StrippedStateEventStub { + /// Data specific to the event type. + pub content: C, + + /// The fully-qualified ID of the user who sent this event. + pub sender: UserId, + + /// A unique key which defines the overwriting semantics for this piece of room state. + /// + /// This is often an empty string, but some events send a `UserId` to show + /// which user the event affects. + pub state_key: String, +} + +/// An event sent using send-to-device messaging. +#[derive(Clone, Debug, Event)] +pub struct ToDeviceEvent { + /// Data specific to the event type. + pub content: C, + + /// The fully-qualified ID of the user who sent this event. + pub sender: UserId, +} diff --git a/ruma-events/src/event_type.rs b/ruma-events/src/event_type.rs new file mode 100644 index 00000000..715026bd --- /dev/null +++ b/ruma-events/src/event_type.rs @@ -0,0 +1,344 @@ +use std::fmt::{Display, Formatter, Result as FmtResult}; + +use serde::{Deserialize, Serialize}; + +/// The type of an event. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[non_exhaustive] +#[serde(from = "String", into = "String")] +pub enum EventType { + /// m.call.answer + CallAnswer, + + /// m.call.candidates + CallCandidates, + + /// m.call.hangup + CallHangup, + + /// m.call.invite + CallInvite, + + /// m.direct + Direct, + + /// m.dummy + Dummy, + + /// m.forwarded_room_key + ForwardedRoomKey, + + /// m.fully_read + FullyRead, + + /// m.key.verification.accept + KeyVerificationAccept, + + /// m.key.verification.cancel + KeyVerificationCancel, + + /// m.key.verification.key + KeyVerificationKey, + + /// m.key.verification.mac + KeyVerificationMac, + + /// m.key.verification.request + KeyVerificationRequest, + + /// m.key.verification.start + KeyVerificationStart, + + /// m.ignored_user_list + IgnoredUserList, + + /// m.presence + Presence, + + /// m.push_rules + PushRules, + + /// m.receipt + Receipt, + + /// m.room.aliases + RoomAliases, + + /// m.room.avatar + RoomAvatar, + + /// m.room.canonical_alias + RoomCanonicalAlias, + + /// m.room.create + RoomCreate, + + /// m.room.encrypted + RoomEncrypted, + + /// m.room.encryption + RoomEncryption, + + /// m.room.guest_access + RoomGuestAccess, + + /// m.room.history_visibility + RoomHistoryVisibility, + + /// m.room.join_rules + RoomJoinRules, + + /// m.room.member + RoomMember, + + /// m.room.message + RoomMessage, + + /// m.room.message.feedback + RoomMessageFeedback, + + /// m.room.name + RoomName, + + /// m.room.pinned_events + RoomPinnedEvents, + + /// m.room.power_levels + RoomPowerLevels, + + /// m.room.redaction + RoomRedaction, + + /// m.room.server_acl + RoomServerAcl, + + /// m.room.third_party_invite + RoomThirdPartyInvite, + + /// m.room.tombstone + RoomTombstone, + + /// m.room.topic + RoomTopic, + + /// m.room_key + RoomKey, + + /// m.room_key_request + RoomKeyRequest, + + /// m.sticker + Sticker, + + /// m.tag + Tag, + + /// m.typing + Typing, + + /// Any event that is not part of the specification. + Custom(String), +} + +impl Display for EventType { + fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { + let event_type_str = match *self { + EventType::CallAnswer => "m.call.answer", + EventType::CallCandidates => "m.call.candidates", + EventType::CallHangup => "m.call.hangup", + EventType::CallInvite => "m.call.invite", + EventType::Direct => "m.direct", + EventType::Dummy => "m.dummy", + EventType::ForwardedRoomKey => "m.forwarded_room_key", + EventType::FullyRead => "m.fully_read", + EventType::KeyVerificationAccept => "m.key.verification.accept", + EventType::KeyVerificationCancel => "m.key.verification.cancel", + EventType::KeyVerificationKey => "m.key.verification.key", + EventType::KeyVerificationMac => "m.key.verification.mac", + EventType::KeyVerificationRequest => "m.key.verification.request", + EventType::KeyVerificationStart => "m.key.verification.start", + EventType::IgnoredUserList => "m.ignored_user_list", + EventType::Presence => "m.presence", + EventType::PushRules => "m.push_rules", + EventType::Receipt => "m.receipt", + EventType::RoomAliases => "m.room.aliases", + EventType::RoomAvatar => "m.room.avatar", + EventType::RoomCanonicalAlias => "m.room.canonical_alias", + EventType::RoomCreate => "m.room.create", + EventType::RoomEncrypted => "m.room.encrypted", + EventType::RoomEncryption => "m.room.encryption", + EventType::RoomGuestAccess => "m.room.guest_access", + EventType::RoomHistoryVisibility => "m.room.history_visibility", + EventType::RoomJoinRules => "m.room.join_rules", + EventType::RoomMember => "m.room.member", + EventType::RoomMessage => "m.room.message", + EventType::RoomMessageFeedback => "m.room.message.feedback", + EventType::RoomName => "m.room.name", + EventType::RoomPinnedEvents => "m.room.pinned_events", + EventType::RoomPowerLevels => "m.room.power_levels", + EventType::RoomRedaction => "m.room.redaction", + EventType::RoomServerAcl => "m.room.server_acl", + EventType::RoomThirdPartyInvite => "m.room.third_party_invite", + EventType::RoomTombstone => "m.room.tombstone", + EventType::RoomTopic => "m.room.topic", + EventType::RoomKey => "m.room_key", + EventType::RoomKeyRequest => "m.room_key_request", + EventType::Sticker => "m.sticker", + EventType::Tag => "m.tag", + EventType::Typing => "m.typing", + EventType::Custom(ref event_type) => event_type, + }; + + write!(f, "{}", event_type_str) + } +} + +impl From for EventType +where + T: Into + AsRef, +{ + fn from(s: T) -> EventType { + match s.as_ref() { + "m.call.answer" => EventType::CallAnswer, + "m.call.candidates" => EventType::CallCandidates, + "m.call.hangup" => EventType::CallHangup, + "m.call.invite" => EventType::CallInvite, + "m.direct" => EventType::Direct, + "m.dummy" => EventType::Dummy, + "m.forwarded_room_key" => EventType::ForwardedRoomKey, + "m.fully_read" => EventType::FullyRead, + "m.key.verification.accept" => EventType::KeyVerificationAccept, + "m.key.verification.cancel" => EventType::KeyVerificationCancel, + "m.key.verification.key" => EventType::KeyVerificationKey, + "m.key.verification.mac" => EventType::KeyVerificationMac, + "m.key.verification.request" => EventType::KeyVerificationRequest, + "m.key.verification.start" => EventType::KeyVerificationStart, + "m.ignored_user_list" => EventType::IgnoredUserList, + "m.presence" => EventType::Presence, + "m.push_rules" => EventType::PushRules, + "m.receipt" => EventType::Receipt, + "m.room.aliases" => EventType::RoomAliases, + "m.room.avatar" => EventType::RoomAvatar, + "m.room.canonical_alias" => EventType::RoomCanonicalAlias, + "m.room.create" => EventType::RoomCreate, + "m.room.encrypted" => EventType::RoomEncrypted, + "m.room.encryption" => EventType::RoomEncryption, + "m.room.guest_access" => EventType::RoomGuestAccess, + "m.room.history_visibility" => EventType::RoomHistoryVisibility, + "m.room.join_rules" => EventType::RoomJoinRules, + "m.room.member" => EventType::RoomMember, + "m.room.message" => EventType::RoomMessage, + "m.room.message.feedback" => EventType::RoomMessageFeedback, + "m.room.name" => EventType::RoomName, + "m.room.pinned_events" => EventType::RoomPinnedEvents, + "m.room.power_levels" => EventType::RoomPowerLevels, + "m.room.redaction" => EventType::RoomRedaction, + "m.room.server_acl" => EventType::RoomServerAcl, + "m.room.third_party_invite" => EventType::RoomThirdPartyInvite, + "m.room.tombstone" => EventType::RoomTombstone, + "m.room.topic" => EventType::RoomTopic, + "m.room_key" => EventType::RoomKey, + "m.room_key_request" => EventType::RoomKeyRequest, + "m.sticker" => EventType::Sticker, + "m.tag" => EventType::Tag, + "m.typing" => EventType::Typing, + _ => EventType::Custom(s.into()), + } + } +} + +impl From for String { + fn from(event_type: EventType) -> String { + event_type.to_string() + } +} + +#[cfg(test)] +mod tests { + use ruma_serde::test::serde_json_eq; + use serde_json::json; + + use super::*; + + #[allow(clippy::cognitive_complexity)] + #[test] + fn serialize_and_deserialize_from_display_form() { + serde_json_eq(EventType::CallAnswer, json!("m.call.answer")); + serde_json_eq(EventType::CallCandidates, json!("m.call.candidates")); + serde_json_eq(EventType::CallHangup, json!("m.call.hangup")); + serde_json_eq(EventType::CallInvite, json!("m.call.invite")); + serde_json_eq(EventType::Direct, json!("m.direct")); + serde_json_eq(EventType::Dummy, json!("m.dummy")); + serde_json_eq(EventType::ForwardedRoomKey, json!("m.forwarded_room_key")); + serde_json_eq(EventType::FullyRead, json!("m.fully_read")); + serde_json_eq( + EventType::KeyVerificationAccept, + json!("m.key.verification.accept"), + ); + serde_json_eq( + EventType::KeyVerificationCancel, + json!("m.key.verification.cancel"), + ); + serde_json_eq( + EventType::KeyVerificationKey, + json!("m.key.verification.key"), + ); + serde_json_eq( + EventType::KeyVerificationMac, + json!("m.key.verification.mac"), + ); + serde_json_eq( + EventType::KeyVerificationRequest, + json!("m.key.verification.request"), + ); + serde_json_eq( + EventType::KeyVerificationStart, + json!("m.key.verification.start"), + ); + serde_json_eq(EventType::IgnoredUserList, json!("m.ignored_user_list")); + serde_json_eq(EventType::Presence, json!("m.presence")); + serde_json_eq(EventType::PushRules, json!("m.push_rules")); + serde_json_eq(EventType::Receipt, json!("m.receipt")); + serde_json_eq(EventType::RoomAliases, json!("m.room.aliases")); + serde_json_eq(EventType::RoomAvatar, json!("m.room.avatar")); + serde_json_eq( + EventType::RoomCanonicalAlias, + json!("m.room.canonical_alias"), + ); + serde_json_eq(EventType::RoomCreate, json!("m.room.create")); + serde_json_eq(EventType::RoomEncrypted, json!("m.room.encrypted")); + serde_json_eq(EventType::RoomEncryption, json!("m.room.encryption")); + serde_json_eq(EventType::RoomGuestAccess, json!("m.room.guest_access")); + serde_json_eq( + EventType::RoomHistoryVisibility, + json!("m.room.history_visibility"), + ); + serde_json_eq(EventType::RoomJoinRules, json!("m.room.join_rules")); + serde_json_eq(EventType::RoomMember, json!("m.room.member")); + serde_json_eq(EventType::RoomMessage, json!("m.room.message")); + serde_json_eq( + EventType::RoomMessageFeedback, + json!("m.room.message.feedback"), + ); + serde_json_eq(EventType::RoomName, json!("m.room.name")); + serde_json_eq(EventType::RoomPinnedEvents, json!("m.room.pinned_events")); + serde_json_eq(EventType::RoomPowerLevels, json!("m.room.power_levels")); + serde_json_eq(EventType::RoomRedaction, json!("m.room.redaction")); + serde_json_eq(EventType::RoomServerAcl, json!("m.room.server_acl")); + serde_json_eq( + EventType::RoomThirdPartyInvite, + json!("m.room.third_party_invite"), + ); + serde_json_eq(EventType::RoomTombstone, json!("m.room.tombstone")); + serde_json_eq(EventType::RoomTopic, json!("m.room.topic")); + serde_json_eq(EventType::RoomKey, json!("m.room_key")); + serde_json_eq(EventType::RoomKeyRequest, json!("m.room_key_request")); + serde_json_eq(EventType::Sticker, json!("m.sticker")); + serde_json_eq(EventType::Tag, json!("m.tag")); + serde_json_eq(EventType::Typing, json!("m.typing")); + serde_json_eq( + EventType::Custom("io.ruma.test".to_string()), + json!("io.ruma.test"), + ); + } +} diff --git a/ruma-events/src/forwarded_room_key.rs b/ruma-events/src/forwarded_room_key.rs new file mode 100644 index 00000000..c8edd0b8 --- /dev/null +++ b/ruma-events/src/forwarded_room_key.rs @@ -0,0 +1,48 @@ +//! Types for the *m.forwarded_room_key* event. + +use ruma_events_macros::BasicEventContent; +use ruma_identifiers::RoomId; +use serde::{Deserialize, Serialize}; + +use super::Algorithm; +use crate::BasicEvent; + +/// This event type is used to forward keys for end-to-end encryption. +/// +/// Typically it is encrypted as an *m.room.encrypted* event, then sent as a to-device event. +pub type ForwardedRoomKeyEvent = BasicEvent; + +/// The payload for `ForwardedRoomKeyEvent`. +#[derive(Clone, Debug, Deserialize, Serialize, BasicEventContent)] +#[ruma_event(type = "m.forwarded_room_key")] +pub struct ForwardedRoomKeyEventContent { + /// The encryption algorithm the key in this event is to be used with. + pub algorithm: Algorithm, + + /// The room where the key is used. + pub room_id: RoomId, + + /// The Curve25519 key of the device which initiated the session originally. + pub sender_key: String, + + /// The ID of the session that the key is for. + pub session_id: String, + + /// The key to be exchanged. + pub session_key: String, + + /// The Ed25519 key of the device which initiated the session originally. + /// + /// It is "claimed" because the receiving device has no way to tell that the original + /// room_key actually came from a device which owns the private part of this key unless + /// they have done device verification. + pub sender_claimed_ed25519_key: String, + + /// Chain of Curve25519 keys. + /// + /// It starts out empty, but each time the key is forwarded to another device, the + /// previous sender in the chain is added to the end of the list. For example, if the + /// key is forwarded from A to B to C, this field is empty between A and B, and contains + /// A's Curve25519 key between B and C. + pub forwarding_curve25519_key_chain: Vec, +} diff --git a/ruma-events/src/fully_read.rs b/ruma-events/src/fully_read.rs new file mode 100644 index 00000000..27e77db9 --- /dev/null +++ b/ruma-events/src/fully_read.rs @@ -0,0 +1,21 @@ +//! Types for the *m.fully_read* event. + +use ruma_events_macros::EphemeralRoomEventContent; +use ruma_identifiers::EventId; +use serde::{Deserialize, Serialize}; + +use crate::EphemeralRoomEvent; + +/// The current location of the user's read marker in a room. +/// +/// This event appears in the user's room account data for the room the marker is applicable +/// for. +pub type FullyReadEvent = EphemeralRoomEvent; + +/// The payload for `FullyReadEvent`. +#[derive(Clone, Debug, Deserialize, Serialize, EphemeralRoomEventContent)] +#[ruma_event(type = "m.fully_read")] +pub struct FullyReadEventContent { + /// The event the user's read marker is located at in the room. + pub event_id: EventId, +} diff --git a/ruma-events/src/ignored_user_list.rs b/ruma-events/src/ignored_user_list.rs new file mode 100644 index 00000000..a77eb6d4 --- /dev/null +++ b/ruma-events/src/ignored_user_list.rs @@ -0,0 +1,73 @@ +//! Types for the *m.ignored_user_list* event. + +use ruma_events_macros::BasicEventContent; +use ruma_identifiers::UserId; +use serde::{Deserialize, Serialize}; + +use crate::BasicEvent; + +/// A list of users to ignore. +pub type IgnoredUserListEvent = BasicEvent; + +/// The payload for `IgnoredUserListEvent`. +#[derive(Clone, Debug, Deserialize, Serialize, BasicEventContent)] +#[ruma_event(type = "m.ignored_user_list")] +pub struct IgnoredUserListEventContent { + /// A list of users to ignore. + #[serde(with = "ruma_serde::vec_as_map_of_empty")] + pub ignored_users: Vec, +} + +#[cfg(test)] +mod tests { + use std::convert::TryFrom; + + use matches::assert_matches; + use ruma_identifiers::UserId; + use serde_json::{from_value as from_json_value, json, to_value as to_json_value}; + + use super::IgnoredUserListEventContent; + use crate::{AnyBasicEventContent, BasicEvent, EventJson}; + + #[test] + fn serialization() { + let ignored_user_list_event = BasicEvent { + content: IgnoredUserListEventContent { + ignored_users: vec![UserId::try_from("@carl:example.com").unwrap()], + }, + }; + + let json = json!({ + "content": { + "ignored_users": { + "@carl:example.com": {} + } + }, + "type": "m.ignored_user_list" + }); + + assert_eq!(to_json_value(ignored_user_list_event).unwrap(), json); + } + + #[test] + fn deserialization() { + let json = json!({ + "content": { + "ignored_users": { + "@carl:example.com": {} + } + }, + "type": "m.ignored_user_list" + }); + + assert_matches!( + from_json_value::>>(json) + .unwrap() + .deserialize() + .unwrap(), + BasicEvent { + content: AnyBasicEventContent::IgnoredUserList(IgnoredUserListEventContent { ignored_users, }), + } if ignored_users == vec![UserId::try_from("@carl:example.com").unwrap()] + ); + } +} diff --git a/ruma-events/src/json.rs b/ruma-events/src/json.rs new file mode 100644 index 00000000..99591fca --- /dev/null +++ b/ruma-events/src/json.rs @@ -0,0 +1,124 @@ +use std::{ + clone::Clone, + fmt::{self, Debug, Formatter}, + marker::PhantomData, +}; + +use serde::{ + de::{Deserialize, DeserializeOwned, Deserializer}, + ser::{Serialize, Serializer}, +}; +use serde_json::value::RawValue; + +use crate::{ + error::{InvalidEvent, InvalidEventKind}, + EventContent, +}; + +/// A wrapper around `Box`, to be used in place of event [content] [collection] types in +/// Matrix endpoint definition to allow request and response types to contain unknown events in +/// addition to the known event(s) represented by the generic argument `Ev`. +pub struct EventJson { + json: Box, + _ev: PhantomData, +} + +impl EventJson { + fn new(json: Box) -> Self { + Self { + json, + _ev: PhantomData, + } + } + + /// Create an `EventJson` from a boxed `RawValue`. + pub fn from_json(raw: Box) -> Self { + Self::new(raw) + } + + /// Access the underlying json value. + pub fn json(&self) -> &RawValue { + &self.json + } + + /// Convert `self` into the underlying json value. + pub fn into_json(self) -> Box { + self.json + } +} + +impl EventJson +where + T: DeserializeOwned, +{ + /// Try to deserialize the JSON into the expected event type. + pub fn deserialize(&self) -> Result { + match serde_json::from_str(self.json.get()) { + Ok(value) => Ok(value), + Err(err) => Err(InvalidEvent { + message: err.to_string(), + kind: InvalidEventKind::Validation, + }), + } + } +} + +impl EventJson +where + T: EventContent, +{ + /// Try to deserialize the JSON as event content + pub fn deserialize_content(self, event_type: &str) -> Result { + T::from_parts(event_type, self.json).map_err(|err| InvalidEvent { + message: err, + kind: InvalidEventKind::Deserialization, + }) + } +} + +impl From<&T> for EventJson { + fn from(val: &T) -> Self { + Self::new(serde_json::value::to_raw_value(val).unwrap()) + } +} + +// With specialization a fast path from impl for `impl From From for EventJson { + fn from(val: T) -> Self { + Self::from(&val) + } +} + +impl Clone for EventJson { + fn clone(&self) -> Self { + Self::new(self.json.clone()) + } +} + +impl Debug for EventJson { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + use std::any::type_name; + f.debug_struct(&format!("EventJson::<{}>", type_name::())) + .field("json", &self.json) + .finish() + } +} + +impl<'de, T> Deserialize<'de> for EventJson { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + Box::::deserialize(deserializer).map(Self::new) + } +} + +impl Serialize for EventJson { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + self.json.serialize(serializer) + } +} diff --git a/ruma-events/src/key.rs b/ruma-events/src/key.rs new file mode 100644 index 00000000..ccc5d32e --- /dev/null +++ b/ruma-events/src/key.rs @@ -0,0 +1,3 @@ +//! Modules for events in the *m.key* namespace. + +pub mod verification; diff --git a/ruma-events/src/key/verification.rs b/ruma-events/src/key/verification.rs new file mode 100644 index 00000000..888c232c --- /dev/null +++ b/ruma-events/src/key/verification.rs @@ -0,0 +1,61 @@ +//! Modules for events in the *m.key.verification* namespace. +//! +//! This module also contains types shared by events in its child namespaces. + +use serde::{Deserialize, Serialize}; +use strum::{Display, EnumString}; + +pub mod accept; +pub mod cancel; +pub mod key; +pub mod mac; +pub mod request; +pub mod start; + +/// A hash algorithm. +#[derive(Clone, Copy, Debug, PartialEq, Display, EnumString, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[strum(serialize_all = "snake_case")] +pub enum HashAlgorithm { + /// The SHA256 hash algorithm. + Sha256, +} + +/// A key agreement protocol. +#[derive(Clone, Copy, Debug, PartialEq, Display, EnumString, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[strum(serialize_all = "snake_case")] +pub enum KeyAgreementProtocol { + /// The [Curve25519](https://cr.yp.to/ecdh.html) key agreement protocol. + Curve25519, +} + +/// A message authentication code algorithm. +#[derive(Clone, Copy, Debug, PartialEq, Display, EnumString, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +#[strum(serialize_all = "kebab-case")] +pub enum MessageAuthenticationCode { + /// The HKDF-HMAC-SHA256 MAC. + HkdfHmacSha256, +} + +/// A Short Authentication String method. +#[derive(Clone, Copy, Debug, PartialEq, Display, EnumString, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[strum(serialize_all = "snake_case")] +pub enum ShortAuthenticationString { + /// The decimal method. + Decimal, + + /// The emoji method. + Emoji, +} + +/// A Short Authentication String (SAS) verification method. +#[derive(Clone, Copy, Debug, PartialEq, Display, EnumString, Deserialize, Serialize)] +pub enum VerificationMethod { + /// The *m.sas.v1* verification method. + #[serde(rename = "m.sas.v1")] + #[strum(serialize = "m.sas.v1")] + MSasV1, +} diff --git a/ruma-events/src/key/verification/accept.rs b/ruma-events/src/key/verification/accept.rs new file mode 100644 index 00000000..f9a6c8be --- /dev/null +++ b/ruma-events/src/key/verification/accept.rs @@ -0,0 +1,52 @@ +//! Types for the *m.key.verification.accept* event. + +use ruma_events_macros::BasicEventContent; +use serde::{Deserialize, Serialize}; + +use super::{ + HashAlgorithm, KeyAgreementProtocol, MessageAuthenticationCode, ShortAuthenticationString, + VerificationMethod, +}; +use crate::BasicEvent; + +/// Accepts a previously sent *m.key.verification.start* message. +/// +/// Typically sent as a to-device event. +pub type AcceptEvent = BasicEvent; + +/// The payload for `AcceptEvent`. +#[derive(Clone, Debug, Deserialize, Serialize, BasicEventContent)] +#[ruma_event(type = "m.key.verification.accept")] +pub struct AcceptEventContent { + /// An opaque identifier for the verification process. + /// + /// Must be the same as the one used for the *m.key.verification.start* message. + pub transaction_id: String, + + /// The verification method to use. + /// + /// Must be `m.sas.v1`. + pub method: VerificationMethod, + + /// The key agreement protocol the device is choosing to use, out of the options in the + /// *m.key.verification.start* message. + pub key_agreement_protocol: KeyAgreementProtocol, + + /// The hash method the device is choosing to use, out of the options in the + /// *m.key.verification.start* message. + pub hash: HashAlgorithm, + + /// The message authentication code the device is choosing to use, out of the options in the + /// *m.key.verification.start* message. + pub message_authentication_code: MessageAuthenticationCode, + + /// The SAS methods both devices involved in the verification process understand. + /// + /// Must be a subset of the options in the *m.key.verification.start* message. + pub short_authentication_string: Vec, + + /// The hash (encoded as unpadded base64) of the concatenation of the device's ephemeral public + /// key (encoded as unpadded base64) and the canonical JSON representation of the + /// *m.key.verification.start* message. + pub commitment: String, +} diff --git a/ruma-events/src/key/verification/cancel.rs b/ruma-events/src/key/verification/cancel.rs new file mode 100644 index 00000000..1847d786 --- /dev/null +++ b/ruma-events/src/key/verification/cancel.rs @@ -0,0 +1,155 @@ +//! Types for the *m.key.verification.cancel* event. + +use std::fmt::{Display, Formatter, Result as FmtResult}; + +use ruma_events_macros::BasicEventContent; +use serde::{Deserialize, Serialize}; + +use crate::BasicEvent; + +/// Cancels a key verification process/request. +/// +/// Typically sent as a to-device event. +pub type CancelEvent = BasicEvent; + +/// The payload for `CancelEvent`. +#[derive(Clone, Debug, Deserialize, Serialize, BasicEventContent)] +#[ruma_event(type = "m.key.verification.cancel")] +pub struct CancelEventContent { + /// The opaque identifier for the verification process/request. + pub transaction_id: String, + + /// A human readable description of the `code`. + /// + /// The client should only rely on this string if it does not understand the `code`. + pub reason: String, + + /// The error code for why the process/request was cancelled by the user. + pub code: CancelCode, +} + +/// An error code for why the process/request was cancelled by the user. +/// +/// Custom error codes should use the Java package naming convention. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(from = "String", into = "String")] +pub enum CancelCode { + /// The user cancelled the verification. + User, + + /// The verification process timed out. Verification processes can define their own timeout + /// parameters. + Timeout, + + /// The device does not know about the given transaction ID. + UnknownTransaction, + + /// The device does not know how to handle the requested method. + /// + /// This should be sent for *m.key.verification.start* messages and messages defined by + /// individual verification processes. + UnknownMethod, + + /// The device received an unexpected message. + /// + /// Typically raised when one of the parties is handling the verification out of order. + UnexpectedMessage, + + /// The key was not verified. + KeyMismatch, + + /// The expected user did not match the user verified. + UserMismatch, + + /// The message received was invalid. + InvalidMessage, + + /// An *m.key.verification.request* was accepted by a different device. + /// + /// The device receiving this error can ignore the verification request. + Accepted, + + /// Any code that is not part of the specification. + Custom(String), +} + +impl Display for CancelCode { + fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { + let cancel_code_str = match *self { + CancelCode::User => "m.user", + CancelCode::Timeout => "m.timeout", + CancelCode::UnknownTransaction => "m.unknown_transaction", + CancelCode::UnknownMethod => "m.unknown_method", + CancelCode::UnexpectedMessage => "m.unexpected_message", + CancelCode::KeyMismatch => "m.key_mismatch", + CancelCode::UserMismatch => "m.user_mismatch", + CancelCode::InvalidMessage => "m.invalid_message", + CancelCode::Accepted => "m.accepted", + CancelCode::Custom(ref cancel_code) => cancel_code, + }; + + write!(f, "{}", cancel_code_str) + } +} + +impl From for CancelCode +where + T: Into + AsRef, +{ + fn from(s: T) -> CancelCode { + match s.as_ref() { + "m.user" => CancelCode::User, + "m.timeout" => CancelCode::Timeout, + "m.unknown_transaction" => CancelCode::UnknownTransaction, + "m.unknown_method" => CancelCode::UnknownMethod, + "m.unexpected_message" => CancelCode::UnexpectedMessage, + "m.key_mismatch" => CancelCode::KeyMismatch, + "m.user_mismatch" => CancelCode::UserMismatch, + "m.invalid_message" => CancelCode::InvalidMessage, + "m.accepted" => CancelCode::Accepted, + _ => CancelCode::Custom(s.into()), + } + } +} + +impl From for String { + fn from(cancel_code: CancelCode) -> String { + cancel_code.to_string() + } +} + +#[cfg(test)] +mod tests { + use serde_json::{from_value as from_json_value, json, to_value as to_json_value}; + + use super::CancelCode; + + #[test] + fn cancel_codes_serialize_to_display_form() { + assert_eq!(to_json_value(&CancelCode::User).unwrap(), json!("m.user")); + } + + #[test] + fn custom_cancel_codes_serialize_to_display_form() { + assert_eq!( + to_json_value(&CancelCode::Custom("io.ruma.test".to_string())).unwrap(), + json!("io.ruma.test") + ); + } + + #[test] + fn cancel_codes_deserialize_from_display_form() { + assert_eq!( + from_json_value::(json!("m.user")).unwrap(), + CancelCode::User + ); + } + + #[test] + fn custom_cancel_codes_deserialize_from_display_form() { + assert_eq!( + from_json_value::(json!("io.ruma.test")).unwrap(), + CancelCode::Custom("io.ruma.test".to_string()) + ) + } +} diff --git a/ruma-events/src/key/verification/key.rs b/ruma-events/src/key/verification/key.rs new file mode 100644 index 00000000..f4cb18ca --- /dev/null +++ b/ruma-events/src/key/verification/key.rs @@ -0,0 +1,24 @@ +//! Types for the *m.key.verification.key* event. + +use ruma_events_macros::BasicEventContent; +use serde::{Deserialize, Serialize}; + +use crate::BasicEvent; + +/// Sends the ephemeral public key for a device to the partner device. +/// +/// Typically sent as a to-device event. +pub type KeyEvent = BasicEvent; + +/// The payload for `KeyEvent`. +#[derive(Clone, Debug, Deserialize, Serialize, BasicEventContent)] +#[ruma_event(type = "m.key.verification.key")] +pub struct KeyEventContent { + /// An opaque identifier for the verification process. + /// + /// Must be the same as the one used for the *m.key.verification.start* message. + pub transaction_id: String, + + /// The device's ephemeral public key, encoded as unpadded Base64. + pub key: String, +} diff --git a/ruma-events/src/key/verification/mac.rs b/ruma-events/src/key/verification/mac.rs new file mode 100644 index 00000000..98b90c53 --- /dev/null +++ b/ruma-events/src/key/verification/mac.rs @@ -0,0 +1,32 @@ +//! Types for the *m.key.verification.mac* event. + +use std::collections::BTreeMap; + +use ruma_events_macros::BasicEventContent; +use serde::{Deserialize, Serialize}; + +use crate::BasicEvent; + +/// Sends the MAC of a device's key to the partner device. +/// +/// Typically sent as a to-device event. +pub type MacEvent = BasicEvent; + +/// The payload for `MacEvent`. +#[derive(Clone, Debug, Deserialize, Serialize, BasicEventContent)] +#[ruma_event(type = "m.key.verification.mac")] +pub struct MacEventContent { + /// An opaque identifier for the verification process. + /// + /// Must be the same as the one used for the *m.key.verification.start* message. + pub transaction_id: String, + + /// A map of the key ID to the MAC of the key, using the algorithm in the verification process. + /// + /// The MAC is encoded as unpadded Base64. + pub mac: BTreeMap, + + /// The MAC of the comma-separated, sorted, list of key IDs given in the `mac` property, encoded + /// as unpadded Base64. + pub keys: String, +} diff --git a/ruma-events/src/key/verification/request.rs b/ruma-events/src/key/verification/request.rs new file mode 100644 index 00000000..14ddea75 --- /dev/null +++ b/ruma-events/src/key/verification/request.rs @@ -0,0 +1,38 @@ +//! Types for the *m.key.verification.request* event. + +use std::time::SystemTime; + +use ruma_events_macros::BasicEventContent; +use ruma_identifiers::DeviceId; +use serde::{Deserialize, Serialize}; + +use super::VerificationMethod; +use crate::BasicEvent; + +/// Requests a key verification with another user's devices. +/// +/// Typically sent as a to-device event. +pub type RequestEvent = BasicEvent; + +/// The payload for `RequestEvent`. +#[derive(Clone, Debug, Deserialize, Serialize, BasicEventContent)] +#[ruma_event(type = "m.key.verification.request")] +pub struct RequestEventContent { + /// The device ID which is initiating the request. + pub from_device: DeviceId, + + /// An opaque identifier for the verification request. + /// + /// Must be unique with respect to the devices involved. + pub transaction_id: String, + + /// The verification methods supported by the sender. + pub methods: Vec, + + /// The time in milliseconds for when the request was made. + /// + /// If the request is in the future by more than 5 minutes or more than 10 minutes in + /// the past, the message should be ignored by the receiver. + #[serde(with = "ruma_serde::time::ms_since_unix_epoch")] + pub timestamp: SystemTime, +} diff --git a/ruma-events/src/key/verification/start.rs b/ruma-events/src/key/verification/start.rs new file mode 100644 index 00000000..80adc664 --- /dev/null +++ b/ruma-events/src/key/verification/start.rs @@ -0,0 +1,464 @@ +//! Types for the *m.key.verification.start* event. + +use ruma_events_macros::BasicEventContent; +use ruma_identifiers::DeviceId; +use serde::{Deserialize, Serialize}; + +use super::{ + HashAlgorithm, KeyAgreementProtocol, MessageAuthenticationCode, ShortAuthenticationString, +}; +use crate::{BasicEvent, InvalidInput}; + +/// Begins an SAS key verification process. +/// +/// Typically sent as a to-device event. +pub type StartEvent = BasicEvent; + +/// The payload of an *m.key.verification.start* event. +#[derive(Clone, Debug, Deserialize, Serialize, BasicEventContent)] +#[ruma_event(type = "m.key.verification.start")] +#[serde(tag = "method")] +pub enum StartEventContent { + /// The *m.sas.v1* verification method. + #[serde(rename = "m.sas.v1")] + MSasV1(MSasV1Content), +} + +/// The payload of an *m.key.verification.start* event using the *m.sas.v1* method. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct MSasV1Content { + /// The device ID which is initiating the process. + pub(crate) from_device: DeviceId, + + /// An opaque identifier for the verification process. + /// + /// Must be unique with respect to the devices involved. Must be the same as the + /// `transaction_id` given in the *m.key.verification.request* if this process is originating + /// from a request. + pub(crate) transaction_id: String, + + /// The key agreement protocols the sending device understands. + /// + /// Must include at least `curve25519`. + pub(crate) key_agreement_protocols: Vec, + + /// The hash methods the sending device understands. + /// + /// Must include at least `sha256`. + pub(crate) hashes: Vec, + + /// The message authentication codes that the sending device understands. + /// + /// Must include at least `hkdf-hmac-sha256`. + pub(crate) message_authentication_codes: Vec, + + /// The SAS methods the sending device (and the sending device's user) understands. + /// + /// Must include at least `decimal`. Optionally can include `emoji`. + pub(crate) short_authentication_string: Vec, +} + +/// Options for creating an `MSasV1Content` with `MSasV1Content::new`. +#[derive(Clone, Debug, Deserialize)] +pub struct MSasV1ContentOptions { + /// The device ID which is initiating the process. + pub from_device: DeviceId, + + /// An opaque identifier for the verification process. + /// + /// Must be unique with respect to the devices involved. Must be the same as the + /// `transaction_id` given in the *m.key.verification.request* if this process is originating + /// from a request. + pub transaction_id: String, + + /// The key agreement protocols the sending device understands. + /// + /// Must include at least `curve25519`. + pub key_agreement_protocols: Vec, + + /// The hash methods the sending device understands. + /// + /// Must include at least `sha256`. + pub hashes: Vec, + + /// The message authentication codes that the sending device understands. + /// + /// Must include at least `hkdf-hmac-sha256`. + pub message_authentication_codes: Vec, + + /// The SAS methods the sending device (and the sending device's user) understands. + /// + /// Must include at least `decimal`. Optionally can include `emoji`. + pub short_authentication_string: Vec, +} + +impl MSasV1Content { + /// Create a new `MSasV1Content` with the given values. + /// + /// # Errors + /// + /// `InvalidInput` will be returned in the following cases: + /// + /// * `key_agreement_protocols` does not include `KeyAgreementProtocol::Curve25519`. + /// * `hashes` does not include `HashAlgorithm::Sha256`. + /// * `message_authentication_codes` does not include + /// `MessageAuthenticationCode::HkdfHmacSha256`. + /// * `short_authentication_string` does not include `ShortAuthenticationString::Decimal`. + pub fn new(options: MSasV1ContentOptions) -> Result { + if !options + .key_agreement_protocols + .contains(&KeyAgreementProtocol::Curve25519) + { + return Err(InvalidInput("`key_agreement_protocols` must contain at least `KeyAgreementProtocol::Curve25519`".to_string())); + } + + if !options.hashes.contains(&HashAlgorithm::Sha256) { + return Err(InvalidInput( + "`hashes` must contain at least `HashAlgorithm::Sha256`".to_string(), + )); + } + + if !options + .message_authentication_codes + .contains(&MessageAuthenticationCode::HkdfHmacSha256) + { + return Err(InvalidInput("`message_authentication_codes` must contain at least `MessageAuthenticationCode::HkdfHmacSha256`".to_string())); + } + + if !options + .short_authentication_string + .contains(&ShortAuthenticationString::Decimal) + { + return Err(InvalidInput("`short_authentication_string` must contain at least `ShortAuthenticationString::Decimal`".to_string())); + } + + Ok(Self { + from_device: options.from_device, + transaction_id: options.transaction_id, + key_agreement_protocols: options.key_agreement_protocols, + hashes: options.hashes, + message_authentication_codes: options.message_authentication_codes, + short_authentication_string: options.short_authentication_string, + }) + } +} + +#[cfg(test)] +mod tests { + use matches::assert_matches; + use serde_json::{from_value as from_json_value, json, to_value as to_json_value}; + + use super::{ + HashAlgorithm, KeyAgreementProtocol, MSasV1Content, MSasV1ContentOptions, + MessageAuthenticationCode, ShortAuthenticationString, StartEvent, StartEventContent, + }; + use crate::EventJson; + + #[test] + fn invalid_m_sas_v1_content_missing_required_key_agreement_protocols() { + let error = MSasV1Content::new(MSasV1ContentOptions { + from_device: "123".to_string(), + transaction_id: "456".to_string(), + hashes: vec![HashAlgorithm::Sha256], + key_agreement_protocols: vec![], + message_authentication_codes: vec![MessageAuthenticationCode::HkdfHmacSha256], + short_authentication_string: vec![ShortAuthenticationString::Decimal], + }) + .err() + .unwrap(); + + assert!(error.to_string().contains("key_agreement_protocols")); + } + + #[test] + fn invalid_m_sas_v1_content_missing_required_hashes() { + let error = MSasV1Content::new(MSasV1ContentOptions { + from_device: "123".to_string(), + transaction_id: "456".to_string(), + hashes: vec![], + key_agreement_protocols: vec![KeyAgreementProtocol::Curve25519], + message_authentication_codes: vec![MessageAuthenticationCode::HkdfHmacSha256], + short_authentication_string: vec![ShortAuthenticationString::Decimal], + }) + .err() + .unwrap(); + + assert!(error.to_string().contains("hashes")); + } + + #[test] + fn invalid_m_sas_v1_content_missing_required_message_authentication_codes() { + let error = MSasV1Content::new(MSasV1ContentOptions { + from_device: "123".to_string(), + transaction_id: "456".to_string(), + hashes: vec![HashAlgorithm::Sha256], + key_agreement_protocols: vec![KeyAgreementProtocol::Curve25519], + message_authentication_codes: vec![], + short_authentication_string: vec![ShortAuthenticationString::Decimal], + }) + .err() + .unwrap(); + + assert!(error.to_string().contains("message_authentication_codes")); + } + + #[test] + fn invalid_m_sas_v1_content_missing_required_short_authentication_string() { + let error = MSasV1Content::new(MSasV1ContentOptions { + from_device: "123".to_string(), + transaction_id: "456".to_string(), + hashes: vec![HashAlgorithm::Sha256], + key_agreement_protocols: vec![KeyAgreementProtocol::Curve25519], + message_authentication_codes: vec![MessageAuthenticationCode::HkdfHmacSha256], + short_authentication_string: vec![], + }) + .err() + .unwrap(); + + assert!(error.to_string().contains("short_authentication_string")); + } + + #[test] + fn serialization() { + let key_verification_start_content = StartEventContent::MSasV1( + MSasV1Content::new(MSasV1ContentOptions { + from_device: "123".to_string(), + transaction_id: "456".to_string(), + hashes: vec![HashAlgorithm::Sha256], + key_agreement_protocols: vec![KeyAgreementProtocol::Curve25519], + message_authentication_codes: vec![MessageAuthenticationCode::HkdfHmacSha256], + short_authentication_string: vec![ShortAuthenticationString::Decimal], + }) + .unwrap(), + ); + + let key_verification_start = StartEvent { + content: key_verification_start_content, + }; + + let json_data = json!({ + "content": { + "from_device": "123", + "transaction_id": "456", + "method": "m.sas.v1", + "key_agreement_protocols": ["curve25519"], + "hashes": ["sha256"], + "message_authentication_codes": ["hkdf-hmac-sha256"], + "short_authentication_string": ["decimal"] + }, + "type": "m.key.verification.start" + }); + + assert_eq!(to_json_value(&key_verification_start).unwrap(), json_data); + } + + #[test] + fn deserialization() { + let json = json!({ + "from_device": "123", + "transaction_id": "456", + "method": "m.sas.v1", + "hashes": ["sha256"], + "key_agreement_protocols": ["curve25519"], + "message_authentication_codes": ["hkdf-hmac-sha256"], + "short_authentication_string": ["decimal"] + }); + + // Deserialize the content struct separately to verify `TryFromRaw` is implemented for it. + assert_matches!( + from_json_value::>(json) + .unwrap() + .deserialize() + .unwrap(), + StartEventContent::MSasV1(MSasV1Content { + from_device, + transaction_id, + hashes, + key_agreement_protocols, + message_authentication_codes, + short_authentication_string, + }) if from_device == "123" + && transaction_id == "456" + && hashes == vec![HashAlgorithm::Sha256] + && key_agreement_protocols == vec![KeyAgreementProtocol::Curve25519] + && message_authentication_codes == vec![MessageAuthenticationCode::HkdfHmacSha256] + && short_authentication_string == vec![ShortAuthenticationString::Decimal] + ); + + let json = json!({ + "content": { + "from_device": "123", + "transaction_id": "456", + "method": "m.sas.v1", + "key_agreement_protocols": ["curve25519"], + "hashes": ["sha256"], + "message_authentication_codes": ["hkdf-hmac-sha256"], + "short_authentication_string": ["decimal"] + }, + "type": "m.key.verification.start" + }); + + assert_matches!( + from_json_value::>(json) + .unwrap() + .deserialize() + .unwrap(), + StartEvent { + content: StartEventContent::MSasV1(MSasV1Content { + from_device, + transaction_id, + hashes, + key_agreement_protocols, + message_authentication_codes, + short_authentication_string, + }) + } if from_device == "123" + && transaction_id == "456" + && hashes == vec![HashAlgorithm::Sha256] + && key_agreement_protocols == vec![KeyAgreementProtocol::Curve25519] + && message_authentication_codes == vec![MessageAuthenticationCode::HkdfHmacSha256] + && short_authentication_string == vec![ShortAuthenticationString::Decimal] + ) + } + + #[test] + fn deserialization_failure() { + // Ensure that invalid JSON creates a `serde_json::Error` and not `InvalidEvent` + assert!(serde_json::from_str::>("{").is_err()); + } + + // TODO this fails because the error is a Validation error not deserialization? + /* + #[test] + fn deserialization_structure_mismatch() { + // Missing several required fields. + let error = + from_json_value::>(json!({ "from_device": "123" })) + .unwrap() + .deserialize() + .unwrap_err(); + + assert!(error.message().contains("missing field")); + assert!(error.is_deserialization()); + } + */ + + // TODO re implement validation done in TryFromRaw else where + /* + #[test] + fn deserialization_validation_missing_required_key_agreement_protocols() { + let json_data = json!({ + "from_device": "123", + "transaction_id": "456", + "method": "m.sas.v1", + "key_agreement_protocols": [], + "hashes": ["sha256"], + "message_authentication_codes": ["hkdf-hmac-sha256"], + "short_authentication_string": ["decimal"] + }); + + let error = from_json_value::>(json_data) + .unwrap() + .deserialize() + .unwrap_err(); + + assert!(error.message().contains("key_agreement_protocols")); + assert!(error.is_validation()); + } + */ + + // TODO re implement validation done in TryFromRaw else where + /* + #[test] + fn deserialization_validation_missing_required_hashes() { + let json_data = json!({ + "from_device": "123", + "transaction_id": "456", + "method": "m.sas.v1", + "key_agreement_protocols": ["curve25519"], + "hashes": [], + "message_authentication_codes": ["hkdf-hmac-sha256"], + "short_authentication_string": ["decimal"] + }); + let error = from_json_value::>(json_data) + .unwrap() + .deserialize() + .unwrap_err(); + + assert!(error.message().contains("hashes")); + assert!(error.is_validation()); + } + */ + + // TODO re implement validation done in TryFromRaw else where + /* + #[test] + fn deserialization_validation_missing_required_message_authentication_codes() { + let json_data = json!({ + "from_device": "123", + "transaction_id": "456", + "method": "m.sas.v1", + "key_agreement_protocols": ["curve25519"], + "hashes": ["sha256"], + "message_authentication_codes": [], + "short_authentication_string": ["decimal"] + }); + let error = from_json_value::>(json_data) + .unwrap() + .deserialize() + .unwrap_err(); + + assert!(error.message().contains("message_authentication_codes")); + assert!(error.is_validation()); + } + */ + + /* + #[test] + fn deserialization_validation_missing_required_short_authentication_string() { + let json_data = json!({ + "from_device": "123", + "transaction_id": "456", + "method": "m.sas.v1", + "key_agreement_protocols": ["curve25519"], + "hashes": ["sha256"], + "message_authentication_codes": ["hkdf-hmac-sha256"], + "short_authentication_string": [] + }); + let error = from_json_value::>(json_data) + .unwrap() + .deserialize() + .unwrap_err(); + + assert!(error.message().contains("short_authentication_string")); + assert!(error.is_validation()); + } + */ + + // TODO re implement validation done in TryFromRaw else where + /* + #[test] + fn deserialization_of_event_validates_content() { + // This JSON is missing the required value of "curve25519" for "key_agreement_protocols". + let json_data = json!({ + "content": { + "from_device": "123", + "transaction_id": "456", + "method": "m.sas.v1", + "key_agreement_protocols": [], + "hashes": ["sha256"], + "message_authentication_codes": ["hkdf-hmac-sha256"], + "short_authentication_string": ["decimal"] + }, + "type": "m.key.verification.start" + }); + let error = from_json_value::>(json_data) + .unwrap() + .deserialize() + .unwrap_err(); + + assert!(error.message().contains("key_agreement_protocols")); + assert!(error.is_validation()); + } + **/ +} diff --git a/ruma-events/src/lib.rs b/ruma-events/src/lib.rs new file mode 100644 index 00000000..93a71b0f --- /dev/null +++ b/ruma-events/src/lib.rs @@ -0,0 +1,234 @@ +//! Crate `ruma_events` contains serializable types for the events in the [Matrix](https://matrix.org) +//! specification that can be shared by client and server code. +//! +//! All data exchanged over Matrix is expressed as an event. +//! Different event types represent different actions, such as joining a room or sending a message. +//! Events are stored and transmitted as simple JSON structures. +//! While anyone can create a new event type for their own purposes, the Matrix specification +//! defines a number of event types which are considered core to the protocol, and Matrix clients +//! and servers must understand their semantics. +//! ruma-events contains Rust types for each of the event types defined by the specification and +//! facilities for extending the event system for custom event types. +//! +//! # Event types +//! +//! ruma-events includes a Rust enum called `EventType`, which provides a simple enumeration of +//! all the event types defined by the Matrix specification. Matrix event types are serialized to +//! JSON strings in [reverse domain name +//! notation](https://en.wikipedia.org/wiki/Reverse_domain_name_notation), although the core event +//! types all use the special "m" TLD, e.g. *m.room.message*. +//! `EventType` also includes a variant called `Custom`, which is a catch-all that stores a string +//! containing the name of any event type that isn't part of the specification. +//! `EventType` is used throughout ruma-events to identify and differentiate between events of +//! different types. +//! +//! # Event kinds +//! +//! Matrix defines three "kinds" of events: +//! +//! 1. **Events**, which are arbitrary JSON structures that have two required keys: +//! * `type`, which specifies the event's type +//! * `content`, which is a JSON object containing the "payload" of the event +//! 2. **Room events**, which are a superset of events and represent actions that occurred within +//! the context of a Matrix room. +//! They have at least the following additional keys: +//! * `event_id`, which is a unique identifier for the event +//! * `room_id`, which is a unique identifier for the room in which the event occurred +//! * `sender`, which is the unique identifier of the Matrix user who created the event +//! * Optionally, `unsigned`, which is a JSON object containing arbitrary additional metadata +//! that is not digitally signed by Matrix homeservers. +//! 3. **State events**, which are a superset of room events and represent persistent state +//! specific to a room, such as the room's member list or topic. +//! Within a single room, state events of the same type and with the same "state key" will +//! effectively "replace" the previous one, updating the room's state. +//! They have at least the following additional keys: +//! * `state_key`, a string which serves as a sort of "sub-type." +//! The state key allows a room to persist multiple state events of the same type. +//! You can think of a room's state events as being a `BTreeMap` where the keys are the tuple +//! `(event_type, state_key)`. +//! * Optionally, `prev_content`, a JSON object containing the `content` object from the +//! previous event of the given `(event_type, state_key)` tuple in the given room. +//! +//! ruma-events represents these three event kinds as traits, allowing any Rust type to serve as a +//! Matrix event so long as it upholds the contract expected of its kind. +//! +//! # Core event types +//! +//! ruma-events includes Rust types for every one of the event types in the Matrix specification. +//! To better organize the crate, these types live in separate modules with a hierarchy that +//! matches the reverse domain name notation of the event type. +//! For example, the *m.room.message* event lives at `ruma_events::room::message::MessageEvent`. +//! Each type's module also contains a Rust type for that event type's `content` field, and any +//! other supporting types required by the event's other fields. +//! +//! # Custom event types +//! +//! Although any Rust type that implements `Event`, `RoomEvent`, or `StateEvent` can serve as a +//! Matrix event type, ruma-events also includes a few convenience types for representing events +//! that are not covered by the spec and not otherwise known by the application. +//! `CustomEvent`, `CustomRoomEvent`, and `CustomStateEvent` are simple implementations of their +//! respective event traits whose `content` field is simply a `serde_json::Value` value, which +//! represents arbitrary JSON. +//! +//! # Serialization and deserialization +//! +//! All concrete event types in ruma-events can be serialized via the `Serialize` trait from +//! [serde](https://serde.rs/) and can be deserialized from as `EventJson`. In order to +//! handle incoming data that may not conform to `ruma-events`' strict definitions of event +//! structures, deserialization will return `EventJson::Err` on error. This error covers both +//! structurally invalid JSON data as well as structurally valid JSON that doesn't fulfill +//! additional constraints the matrix specification defines for some event types. The error exposes +//! the deserialized `serde_json::Value` so that developers can still work with the received +//! event data. This makes it possible to deserialize a collection of events without the entire +//! collection failing to deserialize due to a single invalid event. The "content" type for each +//! event also implements `Serialize` and either `TryFromRaw` (enabling usage as +//! `EventJson` for dedicated content types) or `Deserialize` (when the content is a +//! type alias), allowing content to be converted to and from JSON indepedently of the surrounding +//! event structure, if needed. +//! +//! # Collections +//! +//! With the trait-based approach to events, it's easy to write generic collection types like +//! `Vec>`. +//! However, there are APIs in the Matrix specification that involve heterogeneous collections of +//! events, i.e. a list of events of different event types. +//! Because Rust does not have a facility for arrays, vectors, or slices containing multiple +//! concrete types, ruma-events provides special collection types for this purpose. +//! The collection types are enums which effectively "wrap" each possible event type of a +//! particular event "kind." +//! +//! Because of the hierarchical nature of event kinds in Matrix, these collection types are divied +//! into two modules, `ruma_events::collections::all` and `ruma_events::collections::only`. +//! The "all" versions include every event type that implements the relevant event trait as well as +//! more specific event traits. +//! The "only" versions include only the event types that implement "at most" the relevant event +//! trait. +//! +//! For example, the `ruma_events::collections::all::Event` enum includes *m.room.message*, because +//! that event type is both an event and a room event. +//! However, the `ruma_events::collections::only::Event` enum does *not* include *m.room.message*, +//! because *m.room.message* implements a *more specific* event trait than `Event`. + +#![recursion_limit = "1024"] +#![warn(missing_debug_implementations, missing_docs, rust_2018_idioms)] +// Since we support Rust 1.36.0, we can't apply this suggestion yet +#![allow(clippy::use_self)] + +use std::fmt::Debug; + +use js_int::Int; +use serde::{Deserialize, Serialize}; +use serde_json::value::RawValue as RawJsonValue; + +use self::room::redaction::RedactionEvent; + +#[deprecated = "Use ruma_serde::empty::Empty directly instead."] +pub use ruma_serde::empty::Empty; + +mod algorithm; +mod enums; +mod error; +mod event_kinds; +mod event_type; +mod json; +#[doc(hidden)] // only public for external tests +pub mod util; + +// Hack to allow both ruma-events itself and external crates (or tests) to use procedural macros +// that expect `ruma_events` to exist in the prelude. +extern crate self as ruma_events; + +pub mod call; +pub mod custom; +pub mod direct; +pub mod dummy; +pub mod forwarded_room_key; +pub mod fully_read; +pub mod ignored_user_list; +pub mod key; +pub mod presence; +pub mod push_rules; +pub mod receipt; +pub mod room; +pub mod room_key; +pub mod room_key_request; +pub mod sticker; +pub mod tag; +pub mod typing; + +pub use self::{ + algorithm::Algorithm, + custom::{CustomBasicEvent, CustomMessageEvent, CustomStateEvent}, + enums::{ + AnyBasicEvent, AnyBasicEventContent, AnyEphemeralRoomEvent, AnyEphemeralRoomEventContent, + AnyEvent, AnyMessageEvent, AnyMessageEventContent, AnyMessageEventStub, AnyRoomEvent, + AnyRoomEventStub, AnyStateEvent, AnyStateEventContent, AnyStateEventStub, + AnyStrippedStateEventStub, AnyToDeviceEvent, AnyToDeviceEventContent, + }, + error::{FromStrError, InvalidEvent, InvalidInput}, + event_kinds::{ + BasicEvent, EphemeralRoomEvent, EphemeralRoomEventStub, MessageEvent, MessageEventStub, + StateEvent, StateEventStub, StrippedStateEventStub, ToDeviceEvent, + }, + event_type::EventType, + json::EventJson, +}; + +/// Extra information about an event that is not incorporated into the event's +/// hash. +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub struct UnsignedData { + /// The time in milliseconds that has elapsed since the event was sent. This + /// field is generated by the local homeserver, and may be incorrect if the + /// local time on at least one of the two servers is out of sync, which can + /// cause the age to either be negative or greater than it actually is. + #[serde(skip_serializing_if = "Option::is_none")] + pub age: Option, + + /// The event that redacted this event, if any. + #[serde(skip_serializing_if = "Option::is_none")] + pub redacted_because: Option>, + + /// The client-supplied transaction ID, if the client being given the event + /// is the same one which sent it. + #[serde(skip_serializing_if = "Option::is_none")] + pub transaction_id: Option, +} + +impl UnsignedData { + /// Whether this unsigned data is empty (all fields are `None`). + /// + /// This method is used to determine whether to skip serializing the + /// `unsigned` field in room events. Do not use it to determine whether + /// an incoming `unsigned` field was present - it could still have been + /// present but contained none of the known fields. + pub fn is_empty(&self) -> bool { + self.age.is_none() && self.transaction_id.is_none() && self.redacted_because.is_none() + } +} + +/// The base trait that all event content types implement. +/// +/// Implementing this trait allows content types to be serialized as well as deserialized. +pub trait EventContent: Sized + Serialize { + /// A matrix event identifier, like `m.room.message`. + fn event_type(&self) -> &str; + + /// Constructs the given event content. + fn from_parts(event_type: &str, content: Box) -> Result; +} + +/// Marker trait for the content of an ephemeral room event. +pub trait EphemeralRoomEventContent: EventContent {} + +/// Marker trait for the content of a basic event. +pub trait BasicEventContent: EventContent {} + +/// Marker trait for the content of a room event. +pub trait RoomEventContent: EventContent {} + +/// Marker trait for the content of a message event. +pub trait MessageEventContent: RoomEventContent {} + +/// Marker trait for the content of a state event. +pub trait StateEventContent: RoomEventContent {} diff --git a/ruma-events/src/presence.rs b/ruma-events/src/presence.rs new file mode 100644 index 00000000..8e829258 --- /dev/null +++ b/ruma-events/src/presence.rs @@ -0,0 +1,128 @@ +//! A presence event is represented by a struct with a set content field. +//! +//! The only content valid for this event is `PresenceEventContent. + +use js_int::UInt; +pub use ruma_common::presence::PresenceState; +use ruma_events_macros::{Event, EventContent}; +use ruma_identifiers::UserId; +use serde::{Deserialize, Serialize}; + +/// Presence event. +#[derive(Clone, Debug, Event)] +pub struct PresenceEvent { + /// Data specific to the event type. + pub content: PresenceEventContent, + + /// Contains the fully-qualified ID of the user who sent this event. + pub sender: UserId, +} + +/// Informs the room of members presence. +/// +/// This is the only event content a `PresenceEvent` can contain as it's +/// `content` field. +#[derive(Clone, Debug, Deserialize, Serialize, EventContent)] +#[ruma_event(type = "m.presence")] +pub struct PresenceEventContent { + /// The current avatar URL for this user. + #[serde(skip_serializing_if = "Option::is_none")] + pub avatar_url: Option, + + /// Whether or not the user is currently active. + #[serde(skip_serializing_if = "Option::is_none")] + pub currently_active: Option, + + /// The current display name for this user. + #[serde(skip_serializing_if = "Option::is_none")] + pub displayname: Option, + + /// The last time since this user performed some action, in milliseconds. + #[serde(skip_serializing_if = "Option::is_none")] + pub last_active_ago: Option, + + /// The presence state for this user. + pub presence: PresenceState, + + /// An optional description to accompany the presence. + #[serde(skip_serializing_if = "Option::is_none")] + pub status_msg: Option, +} + +#[cfg(test)] +mod tests { + use std::convert::TryFrom; + + use js_int::UInt; + use matches::assert_matches; + use ruma_identifiers::UserId; + use serde_json::{from_value as from_json_value, json, to_value as to_json_value}; + + use super::{PresenceEvent, PresenceEventContent, PresenceState}; + use crate::EventJson; + + #[test] + fn serialization() { + let event = PresenceEvent { + content: PresenceEventContent { + avatar_url: Some("mxc://localhost:wefuiwegh8742w".to_string()), + currently_active: Some(false), + displayname: None, + last_active_ago: Some(UInt::try_from(2_478_593).unwrap()), + presence: PresenceState::Online, + status_msg: Some("Making cupcakes".to_string()), + }, + sender: UserId::try_from("@example:localhost").unwrap(), + }; + + let json = json!({ + "content": { + "avatar_url": "mxc://localhost:wefuiwegh8742w", + "currently_active": false, + "last_active_ago": 2_478_593, + "presence": "online", + "status_msg": "Making cupcakes" + }, + "sender": "@example:localhost", + "type": "m.presence" + }); + + assert_eq!(to_json_value(&event).unwrap(), json); + } + + #[test] + fn deserialization() { + let json = json!({ + "content": { + "avatar_url": "mxc://localhost:wefuiwegh8742w", + "currently_active": false, + "last_active_ago": 2_478_593, + "presence": "online", + "status_msg": "Making cupcakes" + }, + "sender": "@example:localhost", + "type": "m.presence" + }); + + assert_matches!( + from_json_value::>(json) + .unwrap() + .deserialize() + .unwrap(), + PresenceEvent { + content: PresenceEventContent { + avatar_url: Some(avatar_url), + currently_active: Some(false), + displayname: None, + last_active_ago: Some(last_active_ago), + presence: PresenceState::Online, + status_msg: Some(status_msg), + }, + sender, + } if avatar_url == "mxc://localhost:wefuiwegh8742w" + && status_msg == "Making cupcakes" + && sender == "@example:localhost" + && last_active_ago == UInt::from(2_478_593u32) + ); + } +} diff --git a/ruma-events/src/push_rules.rs b/ruma-events/src/push_rules.rs new file mode 100644 index 00000000..908f4349 --- /dev/null +++ b/ruma-events/src/push_rules.rs @@ -0,0 +1,464 @@ +//! Types for the the *m.push_rules* event. + +use ruma_events_macros::BasicEventContent; +use serde::{Deserialize, Serialize}; + +use crate::BasicEvent; + +/// Describes all push rules for a user. +pub type PushRulesEvent = BasicEvent; + +/// The payload for `PushRulesEvent`. +#[derive(Clone, Debug, Deserialize, Serialize, BasicEventContent)] +#[ruma_event(type = "m.push_rules")] +pub struct PushRulesEventContent { + /// The global ruleset. + pub global: Ruleset, +} + +pub use ruma_common::push::Action; + +/// A push ruleset scopes a set of rules according to some criteria. +/// +/// For example, some rules may only be applied for messages from a particular sender, a particular +/// room, or by default. The push ruleset contains the entire set of scopes and rules. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct Ruleset { + /// These rules configure behavior for (unencrypted) messages that match certain patterns. + pub content: Vec, + + /// These user-configured rules are given the highest priority. + /// + /// This field is named `override_` instead of `override` because the latter is a reserved + /// keyword in Rust. + #[serde(rename = "override")] + pub override_: Vec, + + /// These rules change the behavior of all messages for a given room. + pub room: Vec, + + /// These rules configure notification behavior for messages from a specific Matrix user ID. + pub sender: Vec, + + /// These rules are identical to override rules, but have a lower priority than `content`, + /// `room` and `sender` rules. + pub underride: Vec, +} + +/// A push rule is a single rule that states under what conditions an event should be passed onto a +/// push gateway and how the notification should be presented. +/// +/// These rules are stored on the user's homeserver. They are manually configured by the user, who +/// can create and view them via the Client/Server API. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct PushRule { + /// Actions to determine if and how a notification is delivered for events matching this rule. + pub actions: Vec, + + /// Whether this is a default rule, or has been set explicitly. + pub default: bool, + + /// Whether the push rule is enabled or not. + pub enabled: bool, + + /// The ID of this rule. + pub rule_id: String, +} + +/// Like `PushRule`, but with an additional `conditions` field. +/// +/// Only applicable to underride and override rules. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct ConditionalPushRule { + /// Actions to determine if and how a notification is delivered for events matching this rule. + pub actions: Vec, + + /// Whether this is a default rule, or has been set explicitly. + pub default: bool, + + /// Whether the push rule is enabled or not. + pub enabled: bool, + + /// The ID of this rule. + pub rule_id: String, + + /// The conditions that must hold true for an event in order for a rule to be applied to an event. + /// + /// A rule with no conditions always matches. + pub conditions: Vec, +} + +/// Like `PushRule`, but with an additional `pattern` field. +/// +/// Only applicable to content rules. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct PatternedPushRule { + /// Actions to determine if and how a notification is delivered for events matching this rule. + pub actions: Vec, + + /// Whether this is a default rule, or has been set explicitly. + pub default: bool, + + /// Whether the push rule is enabled or not. + pub enabled: bool, + + /// The ID of this rule. + pub rule_id: String, + + /// The glob-style pattern to match against. + pub pattern: String, +} + +/// A condition that must apply for an associated push rule's action to be taken. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum PushCondition { + /// This is a glob pattern match on a field of the event. + EventMatch { + /// The dot-separated field of the event to match. + key: String, + + /// The glob-style pattern to match against. + /// + /// Patterns with no special glob characters should be treated as having asterisks prepended + /// and appended when testing the condition. + pattern: String, + }, + + /// This matches unencrypted messages where `content.body` contains the owner's display name in + /// that room. + ContainsDisplayName, + + /// This matches the current number of members in the room. + RoomMemberCount { + /// A decimal integer optionally prefixed by one of `==`, `<`, `>`, `>=` or `<=`. + /// + /// A prefix of `<` matches rooms where the member count is strictly less than the given + /// number and so forth. If no prefix is present, this parameter defaults to `==`. + is: String, + }, + + /// This takes into account the current power levels in the room, ensuring the sender of the + /// event has high enough power to trigger the notification. + SenderNotificationPermission { + /// The field in the power level event the user needs a minimum power level for. + /// + /// Fields must be specified under the `notifications` property in the power level event's + /// `content`. + key: String, + }, +} + +#[cfg(test)] +mod tests { + use matches::assert_matches; + use serde_json::{from_value as from_json_value, json, to_value as to_json_value}; + + use super::{PushCondition, PushRulesEvent}; + use crate::EventJson; + + #[test] + fn serialize_event_match_condition() { + let json_data = json!({ + "key": "content.msgtype", + "kind": "event_match", + "pattern": "m.notice" + }); + assert_eq!( + to_json_value(&PushCondition::EventMatch { + key: "content.msgtype".to_string(), + pattern: "m.notice".to_string(), + }) + .unwrap(), + json_data + ); + } + + #[test] + fn serialize_contains_display_name_condition() { + assert_eq!( + to_json_value(&PushCondition::ContainsDisplayName).unwrap(), + json!({ "kind": "contains_display_name" }) + ); + } + + #[test] + fn serialize_room_member_count_condition() { + let json_data = json!({ + "is": "2", + "kind": "room_member_count" + }); + assert_eq!( + to_json_value(&PushCondition::RoomMemberCount { + is: "2".to_string(), + }) + .unwrap(), + json_data + ); + } + + #[test] + fn serialize_sender_notification_permission_condition() { + let json_data = json!({ + "key": "room", + "kind": "sender_notification_permission" + }); + assert_eq!( + json_data, + to_json_value(&PushCondition::SenderNotificationPermission { + key: "room".to_string(), + }) + .unwrap() + ); + } + + #[test] + fn deserialize_event_match_condition() { + let json_data = json!({ + "key": "content.msgtype", + "kind": "event_match", + "pattern": "m.notice" + }); + assert_matches!( + from_json_value::(json_data).unwrap(), + PushCondition::EventMatch { key, pattern } + if key == "content.msgtype" && pattern == "m.notice" + ); + } + + #[test] + fn deserialize_contains_display_name_condition() { + assert_matches!( + from_json_value::(json!({ "kind": "contains_display_name" })).unwrap(), + PushCondition::ContainsDisplayName + ); + } + + #[test] + fn deserialize_room_member_count_condition() { + let json_data = json!({ + "is": "2", + "kind": "room_member_count" + }); + assert_matches!( + from_json_value::(json_data).unwrap(), + PushCondition::RoomMemberCount { is } + if is == "2" + ); + } + + #[test] + fn deserialize_sender_notification_permission_condition() { + let json_data = json!({ + "key": "room", + "kind": "sender_notification_permission" + }); + assert_matches!( + from_json_value::(json_data).unwrap(), + PushCondition::SenderNotificationPermission { + key + } if key == "room" + ); + } + + #[test] + fn sanity_check() { + // This is a full example of a push rules event from the specification. + let json_data = json!({ + "content": { + "global": { + "content": [ + { + "actions": [ + "notify", + { + "set_tweak": "sound", + "value": "default" + }, + { + "set_tweak": "highlight" + } + ], + "default": true, + "enabled": true, + "pattern": "alice", + "rule_id": ".m.rule.contains_user_name" + } + ], + "override": [ + { + "actions": [ + "dont_notify" + ], + "conditions": [], + "default": true, + "enabled": false, + "rule_id": ".m.rule.master" + }, + { + "actions": [ + "dont_notify" + ], + "conditions": [ + { + "key": "content.msgtype", + "kind": "event_match", + "pattern": "m.notice" + } + ], + "default": true, + "enabled": true, + "rule_id": ".m.rule.suppress_notices" + } + ], + "room": [], + "sender": [], + "underride": [ + { + "actions": [ + "notify", + { + "set_tweak": "sound", + "value": "ring" + }, + { + "set_tweak": "highlight", + "value": false + } + ], + "conditions": [ + { + "key": "type", + "kind": "event_match", + "pattern": "m.call.invite" + } + ], + "default": true, + "enabled": true, + "rule_id": ".m.rule.call" + }, + { + "actions": [ + "notify", + { + "set_tweak": "sound", + "value": "default" + }, + { + "set_tweak": "highlight" + } + ], + "conditions": [ + { + "kind": "contains_display_name" + } + ], + "default": true, + "enabled": true, + "rule_id": ".m.rule.contains_display_name" + }, + { + "actions": [ + "notify", + { + "set_tweak": "sound", + "value": "default" + }, + { + "set_tweak": "highlight", + "value": false + } + ], + "conditions": [ + { + "is": "2", + "kind": "room_member_count" + } + ], + "default": true, + "enabled": true, + "rule_id": ".m.rule.room_one_to_one" + }, + { + "actions": [ + "notify", + { + "set_tweak": "sound", + "value": "default" + }, + { + "set_tweak": "highlight", + "value": false + } + ], + "conditions": [ + { + "key": "type", + "kind": "event_match", + "pattern": "m.room.member" + }, + { + "key": "content.membership", + "kind": "event_match", + "pattern": "invite" + }, + { + "key": "state_key", + "kind": "event_match", + "pattern": "@alice:example.com" + } + ], + "default": true, + "enabled": true, + "rule_id": ".m.rule.invite_for_me" + }, + { + "actions": [ + "notify", + { + "set_tweak": "highlight", + "value": false + } + ], + "conditions": [ + { + "key": "type", + "kind": "event_match", + "pattern": "m.room.member" + } + ], + "default": true, + "enabled": true, + "rule_id": ".m.rule.member_event" + }, + { + "actions": [ + "notify", + { + "set_tweak": "highlight", + "value": false + } + ], + "conditions": [ + { + "key": "type", + "kind": "event_match", + "pattern": "m.room.message" + } + ], + "default": true, + "enabled": true, + "rule_id": ".m.rule.message" + } + ] + } + }, + "type": "m.push_rules" + }); + + let _ = from_json_value::>(json_data) + .unwrap() + .deserialize() + .unwrap(); + } +} diff --git a/ruma-events/src/receipt.rs b/ruma-events/src/receipt.rs new file mode 100644 index 00000000..651f1e4a --- /dev/null +++ b/ruma-events/src/receipt.rs @@ -0,0 +1,63 @@ +//! Types for the *m.receipt* event. + +use std::{ + collections::BTreeMap, + ops::{Deref, DerefMut}, + time::SystemTime, +}; + +use ruma_events_macros::EphemeralRoomEventContent; +use ruma_identifiers::{EventId, UserId}; +use serde::{Deserialize, Serialize}; + +use crate::EphemeralRoomEvent; + +/// Informs the client who has read a message specified by it's event id. +pub type ReceiptEvent = EphemeralRoomEvent; + +/// The payload for `ReceiptEvent`. +/// +/// A mapping of event ID to a collection of receipts for this event ID. The event ID is the ID of +/// the event being acknowledged and *not* an ID for the receipt itself. +#[derive(Clone, Debug, Deserialize, Serialize, EphemeralRoomEventContent)] +#[ruma_event(type = "m.receipt")] +pub struct ReceiptEventContent(pub BTreeMap); + +impl Deref for ReceiptEventContent { + type Target = BTreeMap; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for ReceiptEventContent { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +/// A collection of receipts. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct Receipts { + /// A collection of users who have sent *m.read* receipts for this event. + #[serde(default, rename = "m.read")] + pub read: Option, +} + +/// A mapping of user ID to receipt. +/// +/// The user ID is the entity who sent this receipt. +pub type UserReceipts = BTreeMap; + +/// An acknowledgement of an event. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct Receipt { + /// The time when the receipt was sent. + #[serde( + with = "ruma_serde::time::opt_ms_since_unix_epoch", + default, + skip_serializing_if = "Option::is_none" + )] + pub ts: Option, +} diff --git a/ruma-events/src/room.rs b/ruma-events/src/room.rs new file mode 100644 index 00000000..5e864d5b --- /dev/null +++ b/ruma-events/src/room.rs @@ -0,0 +1,120 @@ +//! Modules for events in the *m.room* namespace. +//! +//! This module also contains types shared by events in its child namespaces. + +use std::collections::BTreeMap; + +use js_int::UInt; +use serde::{Deserialize, Serialize}; + +pub mod aliases; +pub mod avatar; +pub mod canonical_alias; +pub mod create; +pub mod encrypted; +pub mod encryption; +pub mod guest_access; +pub mod history_visibility; +pub mod join_rules; +pub mod member; +pub mod message; +pub mod name; +pub mod pinned_events; +pub mod power_levels; +pub mod redaction; +pub mod server_acl; +pub mod third_party_invite; +pub mod tombstone; +pub mod topic; + +/// Metadata about an image. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct ImageInfo { + /// The height of the image in pixels. + #[serde(rename = "h", skip_serializing_if = "Option::is_none")] + pub height: Option, + + /// The width of the image in pixels. + #[serde(rename = "w", skip_serializing_if = "Option::is_none")] + pub width: Option, + + /// The MIME type of the image, e.g. "image/png." + #[serde(skip_serializing_if = "Option::is_none")] + pub mimetype: Option, + + /// The file size of the image in bytes. + #[serde(skip_serializing_if = "Option::is_none")] + pub size: Option, + + /// Metadata about the image referred to in `thumbnail_url`. + #[serde(skip_serializing_if = "Option::is_none")] + pub thumbnail_info: Option>, + + /// The URL to the thumbnail of the image. Only present if the thumbnail is unencrypted. + #[serde(skip_serializing_if = "Option::is_none")] + pub thumbnail_url: Option, + + /// Information on the encrypted thumbnail image. Only present if the thumbnail is encrypted. + #[serde(skip_serializing_if = "Option::is_none")] + pub thumbnail_file: Option>, +} + +/// Metadata about a thumbnail. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct ThumbnailInfo { + /// The height of the thumbnail in pixels. + #[serde(rename = "h", skip_serializing_if = "Option::is_none")] + pub height: Option, + + /// The width of the thumbnail in pixels. + #[serde(rename = "w", skip_serializing_if = "Option::is_none")] + pub width: Option, + + /// The MIME type of the thumbnail, e.g. "image/png." + #[serde(skip_serializing_if = "Option::is_none")] + pub mimetype: Option, + + /// The file size of the thumbnail in bytes. + #[serde(skip_serializing_if = "Option::is_none")] + pub size: Option, +} + +/// A file sent to a room with end-to-end encryption enabled. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct EncryptedFile { + /// The URL to the file. + pub url: String, + + /// A [JSON Web Key](https://tools.ietf.org/html/rfc7517#appendix-A.3) object. + pub key: JsonWebKey, + + /// The 128-bit unique counter block used by AES-CTR, encoded as unpadded base64. + pub iv: String, + + /// A map from an algorithm name to a hash of the ciphertext, encoded as unpadded base64. + /// Clients should support the SHA-256 hash, which uses the key sha256. + pub hashes: BTreeMap, + + /// Version of the encrypted attachments protocol. Must be `v2`. + pub v: String, +} + +/// A [JSON Web Key](https://tools.ietf.org/html/rfc7517#appendix-A.3) object. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct JsonWebKey { + /// Key type. Must be `oct`. + pub kty: String, + + /// Key operations. Must at least contain `encrypt` and `decrypt`. + pub key_ops: Vec, + + /// Required. Algorithm. Must be `A256CTR`. + pub alg: String, + + /// The key, encoded as urlsafe unpadded base64. + pub k: String, + + /// Extractable. Must be `true`. This is a + /// [W3C extension](https://w3c.github.io/webcrypto/#iana-section-jwk). + pub ext: bool, +} diff --git a/ruma-events/src/room/aliases.rs b/ruma-events/src/room/aliases.rs new file mode 100644 index 00000000..5dbe5673 --- /dev/null +++ b/ruma-events/src/room/aliases.rs @@ -0,0 +1,18 @@ +//! Types for the *m.room.aliases* event. + +use ruma_events_macros::StateEventContent; +use ruma_identifiers::RoomAliasId; +use serde::{Deserialize, Serialize}; + +use crate::StateEvent; + +/// Informs the room about what room aliases it has been given. +pub type AliasesEvent = StateEvent; + +/// The payload for `AliasesEvent`. +#[derive(Clone, Debug, Deserialize, Serialize, StateEventContent)] +#[ruma_event(type = "m.room.aliases")] +pub struct AliasesEventContent { + /// A list of room aliases. + pub aliases: Vec, +} diff --git a/ruma-events/src/room/avatar.rs b/ruma-events/src/room/avatar.rs new file mode 100644 index 00000000..5cd3539f --- /dev/null +++ b/ruma-events/src/room/avatar.rs @@ -0,0 +1,25 @@ +//! Types for the *m.room.avatar* event. + +use ruma_events_macros::StateEventContent; +use serde::{Deserialize, Serialize}; + +use super::ImageInfo; +use crate::StateEvent; + +/// A picture that is associated with the room. +/// +/// This can be displayed alongside the room information. +pub type AvatarEvent = StateEvent; + +/// The payload for `AvatarEvent`. +#[derive(Clone, Debug, Deserialize, Serialize, StateEventContent)] +#[ruma_event(type = "m.room.avatar")] +pub struct AvatarEventContent { + /// Information about the avatar image. + #[serde(skip_serializing_if = "Option::is_none")] + pub info: Option>, + + /// Information about the avatar thumbnail image. + /// URL of the avatar image. + pub url: String, +} diff --git a/ruma-events/src/room/canonical_alias.rs b/ruma-events/src/room/canonical_alias.rs new file mode 100644 index 00000000..bdf74747 --- /dev/null +++ b/ruma-events/src/room/canonical_alias.rs @@ -0,0 +1,172 @@ +//! Types for the *m.room.canonical_alias* event. + +use ruma_events_macros::StateEventContent; +use ruma_identifiers::RoomAliasId; +use serde::{Deserialize, Serialize}; + +use crate::StateEvent; + +/// Informs the room as to which alias is the canonical one. +pub type CanonicalAliasEvent = StateEvent; + +/// The payload for `CanonicalAliasEvent`. +#[derive(Clone, Debug, Deserialize, Serialize, StateEventContent)] +#[ruma_event(type = "m.room.canonical_alias")] +pub struct CanonicalAliasEventContent { + /// The canonical alias. + /// + /// Rooms with `alias: None` should be treated the same as a room + /// with no canonical alias. + #[serde( + default, + deserialize_with = "ruma_serde::empty_string_as_none", + skip_serializing_if = "Option::is_none" + )] + pub alias: Option, + + /// List of alternative aliases to the room. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub alt_aliases: Vec, +} + +#[cfg(test)] +mod tests { + use std::{ + convert::TryFrom, + time::{Duration, UNIX_EPOCH}, + }; + + use ruma_identifiers::{EventId, RoomAliasId, RoomId, UserId}; + use serde_json::{from_value as from_json_value, json, to_value as to_json_value}; + + use super::CanonicalAliasEventContent; + use crate::{EventJson, StateEvent, UnsignedData}; + + #[test] + fn serialization_with_optional_fields_as_none() { + let canonical_alias_event = StateEvent { + content: CanonicalAliasEventContent { + alias: Some(RoomAliasId::try_from("#somewhere:localhost").unwrap()), + alt_aliases: Vec::new(), + }, + event_id: EventId::try_from("$h29iv0s8:example.com").unwrap(), + origin_server_ts: UNIX_EPOCH + Duration::from_millis(1), + prev_content: None, + room_id: RoomId::try_from("!dummy:example.com").unwrap(), + sender: UserId::try_from("@carl:example.com").unwrap(), + state_key: "".to_string(), + unsigned: UnsignedData::default(), + }; + + let actual = to_json_value(&canonical_alias_event).unwrap(); + let expected = json!({ + "content": { + "alias": "#somewhere:localhost" + }, + "event_id": "$h29iv0s8:example.com", + "origin_server_ts": 1, + "room_id": "!dummy:example.com", + "sender": "@carl:example.com", + "state_key": "", + "type": "m.room.canonical_alias" + }); + + assert_eq!(actual, expected); + } + + #[test] + fn absent_field_as_none() { + let json_data = json!({ + "content": {}, + "event_id": "$h29iv0s8:example.com", + "origin_server_ts": 1, + "room_id": "!dummy:example.com", + "sender": "@carl:example.com", + "state_key": "", + "type": "m.room.canonical_alias" + }); + + assert_eq!( + from_json_value::>>(json_data) + .unwrap() + .deserialize() + .unwrap() + .content + .alias, + None + ); + } + + #[test] + fn null_field_as_none() { + let json_data = json!({ + "content": { + "alias": null + }, + "event_id": "$h29iv0s8:example.com", + "origin_server_ts": 1, + "room_id": "!dummy:example.com", + "sender": "@carl:example.com", + "state_key": "", + "type": "m.room.canonical_alias" + }); + assert_eq!( + from_json_value::>>(json_data) + .unwrap() + .deserialize() + .unwrap() + .content + .alias, + None + ); + } + + #[test] + fn empty_field_as_none() { + let json_data = json!({ + "content": { + "alias": "" + }, + "event_id": "$h29iv0s8:example.com", + "origin_server_ts": 1, + "room_id": "!dummy:example.com", + "sender": "@carl:example.com", + "state_key": "", + "type": "m.room.canonical_alias" + }); + assert_eq!( + from_json_value::>>(json_data) + .unwrap() + .deserialize() + .unwrap() + .content + .alias, + None + ); + } + + #[test] + fn nonempty_field_as_some() { + let alias = Some(RoomAliasId::try_from("#somewhere:localhost").unwrap()); + let json_data = json!({ + "content": { + "alias": "#somewhere:localhost" + }, + "event_id": "$h29iv0s8:example.com", + "origin_server_ts": 1, + "room_id": "!dummy:example.com", + "sender": "@carl:example.com", + "state_key": "", + "type": "m.room.canonical_alias" + }); + assert_eq!( + from_json_value::>>(json_data) + .unwrap() + .deserialize() + .unwrap() + .content + .alias, + alias + ); + } +} diff --git a/ruma-events/src/room/create.rs b/ruma-events/src/room/create.rs new file mode 100644 index 00000000..7895e9cd --- /dev/null +++ b/ruma-events/src/room/create.rs @@ -0,0 +1,105 @@ +//! Types for the *m.room.create* event. + +use std::convert::TryFrom; + +use ruma_events_macros::StateEventContent; +use ruma_identifiers::{EventId, RoomId, RoomVersionId, UserId}; +use serde::{Deserialize, Serialize}; + +use crate::StateEvent; + +/// This is the first event in a room and cannot be changed. It acts as the root of all other +/// events. +pub type CreateEvent = StateEvent; + +/// The payload for `CreateEvent`. +#[derive(Clone, Debug, Deserialize, Serialize, StateEventContent)] +#[ruma_event(type = "m.room.create")] +pub struct CreateEventContent { + /// The `user_id` of the room creator. This is set by the homeserver. + pub creator: UserId, + + /// Whether or not this room's data should be transferred to other homeservers. + #[serde( + rename = "m.federate", + default = "ruma_serde::default_true", + skip_serializing_if = "ruma_serde::is_true" + )] + pub federate: bool, + + /// The version of the room. Defaults to "1" if the key does not exist. + #[serde(default = "default_room_version_id")] + pub room_version: RoomVersionId, + + /// A reference to the room this room replaces, if the previous room was upgraded. + #[serde(skip_serializing_if = "Option::is_none")] + pub predecessor: Option, +} + +/// A reference to an old room replaced during a room version upgrade. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct PreviousRoom { + /// The ID of the old room. + pub room_id: RoomId, + + /// The event ID of the last known event in the old room. + pub event_id: EventId, +} + +/// Used to default the `room_version` field to room version 1. +fn default_room_version_id() -> RoomVersionId { + RoomVersionId::try_from("1").unwrap() +} + +#[cfg(test)] +mod tests { + use std::convert::TryFrom; + + use matches::assert_matches; + use ruma_identifiers::{RoomVersionId, UserId}; + use serde_json::{from_value as from_json_value, json, to_value as to_json_value}; + + use super::CreateEventContent; + use crate::EventJson; + + #[test] + fn serialization() { + let content = CreateEventContent { + creator: UserId::try_from("@carl:example.com").unwrap(), + federate: false, + room_version: RoomVersionId::version_4(), + predecessor: None, + }; + + let json = json!({ + "creator": "@carl:example.com", + "m.federate": false, + "room_version": "4" + }); + + assert_eq!(to_json_value(&content).unwrap(), json); + } + + #[test] + fn deserialization() { + let json = json!({ + "creator": "@carl:example.com", + "m.federate": true, + "room_version": "4" + }); + + assert_matches!( + from_json_value::>(json) + .unwrap() + .deserialize() + .unwrap(), + CreateEventContent { + creator, + federate: true, + room_version, + predecessor: None, + } if creator == "@carl:example.com" + && room_version == RoomVersionId::version_4() + ); + } +} diff --git a/ruma-events/src/room/encrypted.rs b/ruma-events/src/room/encrypted.rs new file mode 100644 index 00000000..840d5c5a --- /dev/null +++ b/ruma-events/src/room/encrypted.rs @@ -0,0 +1,165 @@ +//! Types for the *m.room.encrypted* event. + +use std::collections::BTreeMap; + +use js_int::UInt; +use ruma_events_macros::StateEventContent; +use ruma_identifiers::DeviceId; +use serde::{Deserialize, Serialize}; + +use crate::StateEvent; + +/// An event that defines how messages sent in this room should be encrypted. +pub type EncryptedEvent = StateEvent; + +/// The payload for `EncryptedEvent`. +#[derive(Clone, Debug, Deserialize, Serialize, StateEventContent)] +#[non_exhaustive] +#[ruma_event(type = "m.room.encrypted")] +#[serde(tag = "algorithm")] +pub enum EncryptedEventContent { + /// An event encrypted with *m.olm.v1.curve25519-aes-sha2*. + #[serde(rename = "m.olm.v1.curve25519-aes-sha2")] + OlmV1Curve25519AesSha2(OlmV1Curve25519AesSha2Content), + + /// An event encrypted with *m.megolm.v1.aes-sha2*. + #[serde(rename = "m.megolm.v1.aes-sha2")] + MegolmV1AesSha2(MegolmV1AesSha2Content), +} + +/// The payload for `EncryptedEvent` using the *m.olm.v1.curve25519-aes-sha2* algorithm. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct OlmV1Curve25519AesSha2Content { + /// A map from the recipient Curve25519 identity key to ciphertext information. + pub ciphertext: BTreeMap, + + /// The Curve25519 key of the sender. + pub sender_key: String, +} + +/// Ciphertext information holding the ciphertext and message type. +/// +/// Used for messages encrypted with the *m.olm.v1.curve25519-aes-sha2* algorithm. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct CiphertextInfo { + /// The encrypted payload. + pub body: String, + + /// The Olm message type. + #[serde(rename = "type")] + pub message_type: UInt, +} + +/// The payload for `EncryptedEvent` using the *m.megolm.v1.aes-sha2* algorithm. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct MegolmV1AesSha2Content { + /// The encrypted content of the event. + pub ciphertext: String, + + /// The Curve25519 key of the sender. + pub sender_key: String, + + /// The ID of the sending device. + pub device_id: DeviceId, + + /// The ID of the session used to encrypt the message. + pub session_id: String, +} + +#[cfg(test)] +mod tests { + use matches::assert_matches; + use serde_json::{from_value as from_json_value, json, to_value as to_json_value}; + + use super::{EncryptedEventContent, MegolmV1AesSha2Content}; + use crate::EventJson; + + #[test] + fn serialization() { + let key_verification_start_content = + EncryptedEventContent::MegolmV1AesSha2(MegolmV1AesSha2Content { + ciphertext: "ciphertext".to_string(), + sender_key: "sender_key".to_string(), + device_id: "device_id".to_string(), + session_id: "session_id".to_string(), + }); + + let json_data = json!({ + "algorithm": "m.megolm.v1.aes-sha2", + "ciphertext": "ciphertext", + "sender_key": "sender_key", + "device_id": "device_id", + "session_id": "session_id" + }); + + assert_eq!( + to_json_value(&key_verification_start_content).unwrap(), + json_data + ); + } + + #[test] + fn deserialization() { + let json_data = json!({ + "algorithm": "m.megolm.v1.aes-sha2", + "ciphertext": "ciphertext", + "sender_key": "sender_key", + "device_id": "device_id", + "session_id": "session_id" + }); + + assert_matches!( + from_json_value::>(json_data) + .unwrap() + .deserialize() + .unwrap(), + EncryptedEventContent::MegolmV1AesSha2(MegolmV1AesSha2Content { + ciphertext, + sender_key, + device_id, + session_id, + }) if ciphertext == "ciphertext" + && sender_key == "sender_key" + && device_id == "device_id" + && session_id == "session_id" + ); + } + + #[test] + fn deserialization_olm() { + let json_data = json!({ + "sender_key": "test_key", + "ciphertext": { + "test_curve_key": { + "body": "encrypted_body", + "type": 1 + } + }, + "algorithm": "m.olm.v1.curve25519-aes-sha2" + }); + let content = from_json_value::>(json_data) + .unwrap() + .deserialize() + .unwrap(); + + match content { + EncryptedEventContent::OlmV1Curve25519AesSha2(c) => { + assert_eq!(c.sender_key, "test_key"); + assert_eq!(c.ciphertext.len(), 1); + assert_eq!(c.ciphertext["test_curve_key"].body, "encrypted_body"); + assert_eq!(c.ciphertext["test_curve_key"].message_type, 1u16.into()); + } + _ => panic!("Wrong content type, expected a OlmV1 content"), + } + } + + #[test] + fn deserialization_failure() { + assert!(from_json_value::>( + json!({ "algorithm": "m.megolm.v1.aes-sha2" }) + ) + .unwrap() + .deserialize() + .is_err()); + } +} diff --git a/ruma-events/src/room/encryption.rs b/ruma-events/src/room/encryption.rs new file mode 100644 index 00000000..b92df634 --- /dev/null +++ b/ruma-events/src/room/encryption.rs @@ -0,0 +1,30 @@ +//! Types for the *m.room.encryption* event. + +use js_int::UInt; +use ruma_events_macros::StateEventContent; +use serde::{Deserialize, Serialize}; + +use crate::{Algorithm, StateEvent}; + +/// Defines how messages sent in this room should be encrypted. +pub type EncryptionEvent = StateEvent; + +/// The payload for `EncryptionEvent`. +#[derive(Clone, Debug, Deserialize, Serialize, StateEventContent)] +#[ruma_event(type = "m.room.encryption")] +pub struct EncryptionEventContent { + /// The encryption algorithm to be used to encrypt messages sent in this room. + /// + /// Must be `m.megolm.v1.aes-sha2`. + pub algorithm: Algorithm, + + /// How long the session should be used before changing it. + /// + /// 604800000 (a week) is the recommended default. + pub rotation_period_ms: Option, + + /// How many messages should be sent before changing the session. + /// + /// 100 is the recommended default. + pub rotation_period_msgs: Option, +} diff --git a/ruma-events/src/room/guest_access.rs b/ruma-events/src/room/guest_access.rs new file mode 100644 index 00000000..ed6d72ae --- /dev/null +++ b/ruma-events/src/room/guest_access.rs @@ -0,0 +1,34 @@ +//! Types for the *m.room.guest_access* event. + +use ruma_events_macros::StateEventContent; +use serde::{Deserialize, Serialize}; +use strum::{Display, EnumString}; + +use crate::StateEvent; + +/// Controls whether guest users are allowed to join rooms. +/// +/// This event controls whether guest users are allowed to join rooms. If this event is absent, +/// servers should act as if it is present and has the value `GuestAccess::Forbidden`. +pub type GuestAccessEvent = StateEvent; + +/// The payload for `GuestAccessEvent`. +#[derive(Clone, Debug, Deserialize, Serialize, StateEventContent)] +#[ruma_event(type = "m.room.guest_access")] +pub struct GuestAccessEventContent { + /// A policy for guest user access to a room. + pub guest_access: GuestAccess, +} + +/// A policy for guest user access to a room. +#[derive(Clone, Copy, Debug, PartialEq, Display, EnumString, Deserialize, Serialize)] +#[non_exhaustive] +#[serde(rename_all = "snake_case")] +#[strum(serialize_all = "snake_case")] +pub enum GuestAccess { + /// Guests are allowed to join the room. + CanJoin, + + /// Guests are not allowed to join the room. + Forbidden, +} diff --git a/ruma-events/src/room/history_visibility.rs b/ruma-events/src/room/history_visibility.rs new file mode 100644 index 00000000..e518f54c --- /dev/null +++ b/ruma-events/src/room/history_visibility.rs @@ -0,0 +1,43 @@ +//! Types for the *m.room.history_visibility* event. + +use ruma_events_macros::StateEventContent; +use serde::{Deserialize, Serialize}; +use strum::{Display, EnumString}; + +use crate::StateEvent; + +/// This event controls whether a member of a room can see the events that happened in a room +/// from before they joined. +pub type HistoryVisibilityEvent = StateEvent; + +/// The payload for `HistoryVisibilityEvent`. +#[derive(Clone, Debug, Deserialize, Serialize, StateEventContent)] +#[ruma_event(type = "m.room.history_visibility")] +pub struct HistoryVisibilityEventContent { + /// Who can see the room history. + pub history_visibility: HistoryVisibility, +} + +/// Who can see a room's history. +#[derive(Clone, Copy, Debug, PartialEq, Display, EnumString, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +#[strum(serialize_all = "snake_case")] +pub enum HistoryVisibility { + /// Previous events are accessible to newly joined members from the point they were invited + /// onwards. Events stop being accessible when the member's state changes to something other + /// than *invite* or *join*. + Invited, + + /// Previous events are accessible to newly joined members from the point they joined the room + /// onwards. Events stop being accessible when the member's state changes to something other + /// than *join*. + Joined, + + /// Previous events are always accessible to newly joined members. All events in the room are + /// accessible, even those sent when the member was not a part of the room. + Shared, + + /// All events while this is the `HistoryVisibility` value may be shared by any + /// participating homeserver with anyone, regardless of whether they have ever joined the room. + WorldReadable, +} diff --git a/ruma-events/src/room/join_rules.rs b/ruma-events/src/room/join_rules.rs new file mode 100644 index 00000000..1df547dd --- /dev/null +++ b/ruma-events/src/room/join_rules.rs @@ -0,0 +1,37 @@ +//! Types for the *m.room.join_rules* event. + +use ruma_events_macros::StateEventContent; +use serde::{Deserialize, Serialize}; +use strum::{Display, EnumString}; + +use crate::StateEvent; + +/// Describes how users are allowed to join the room. +pub type JoinRulesEvent = StateEvent; + +/// The payload for `JoinRulesEvent`. +#[derive(Clone, Debug, Deserialize, Serialize, StateEventContent)] +#[ruma_event(type = "m.room.join_rules")] +pub struct JoinRulesEventContent { + /// The type of rules used for users wishing to join this room. + pub join_rule: JoinRule, +} + +/// The rule used for users wishing to join this room. +#[derive(Clone, Copy, Debug, PartialEq, Display, EnumString, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase")] +pub enum JoinRule { + /// A user who wishes to join the room must first receive an invite to the room from someone + /// already inside of the room. + Invite, + + /// Reserved but not yet implemented by the Matrix specification. + Knock, + + /// Reserved but not yet implemented by the Matrix specification. + Private, + + /// Anyone can join the room without any prior action. + Public, +} diff --git a/ruma-events/src/room/member.rs b/ruma-events/src/room/member.rs new file mode 100644 index 00000000..f6993430 --- /dev/null +++ b/ruma-events/src/room/member.rs @@ -0,0 +1,468 @@ +//! Types for the *m.room.member* event. + +use std::collections::BTreeMap; + +use ruma_events_macros::StateEventContent; +use ruma_identifiers::UserId; +use serde::{Deserialize, Serialize}; +use strum::{Display, EnumString}; + +use crate::StateEvent; + +/// The current membership state of a user in the room. +/// +/// Adjusts the membership state for a user in a room. It is preferable to use the membership +/// APIs (`/rooms//invite` etc) when performing membership actions rather than +/// adjusting the state directly as there are a restricted set of valid transformations. For +/// example, user A cannot force user B to join a room, and trying to force this state change +/// directly will fail. +/// +/// The `third_party_invite` property will be set if this invite is an *invite* event and is the +/// successor of an *m.room.third_party_invite* event, and absent otherwise. +/// +/// This event may also include an `invite_room_state` key inside the event's unsigned data. If +/// present, this contains an array of `StrippedState` events. These events provide information +/// on a subset of state events such as the room name. Note that ruma-events treats unsigned +/// data on events as arbitrary JSON values, and the ruma-events types for this event don't +/// provide direct access to `invite_room_state`. If you need this data, you must extract and +/// convert it from a `serde_json::Value` yourself. +/// +/// The user for which a membership applies is represented by the `state_key`. Under some +/// conditions, the `sender` and `state_key` may not match - this may be interpreted as the +/// `sender` affecting the membership state of the `state_key` user. +/// +/// The membership for a given user can change over time. Previous membership can be retrieved +/// from the `prev_content` object on an event. If not present, the user's previous membership +/// must be assumed as leave. +pub type MemberEvent = StateEvent; + +/// The payload for `MemberEvent`. +#[derive(Clone, Debug, Deserialize, Serialize, StateEventContent)] +#[ruma_event(type = "m.room.member")] +pub struct MemberEventContent { + /// The avatar URL for this user, if any. This is added by the homeserver. + #[serde(skip_serializing_if = "Option::is_none")] + pub avatar_url: Option, + + /// The display name for this user, if any. This is added by the homeserver. + #[serde(skip_serializing_if = "Option::is_none")] + pub displayname: Option, + + /// Flag indicating if the room containing this event was created + /// with the intention of being a direct chat. + #[serde(skip_serializing_if = "Option::is_none")] + pub is_direct: Option, + + /// The membership state of this user. + pub membership: MembershipState, + + /// If this member event is the successor to a third party invitation, this field will + /// contain information about that invitation. + #[serde(skip_serializing_if = "Option::is_none")] + pub third_party_invite: Option, +} + +/// The membership state of a user. +#[derive(Clone, Copy, Debug, PartialEq, Display, EnumString, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase")] +pub enum MembershipState { + /// The user is banned. + Ban, + + /// The user has been invited. + Invite, + + /// The user has joined. + Join, + + /// The user has requested to join. + Knock, + + /// The user has left. + Leave, +} + +/// Information about a third party invitation. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct ThirdPartyInvite { + /// A name which can be displayed to represent the user instead of their third party + /// identifier. + pub display_name: String, + + /// A block of content which has been signed, which servers can use to verify the event. + /// Clients should ignore this. + pub signed: SignedContent, +} + +/// A block of content which has been signed, which servers can use to verify a third party +/// invitation. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct SignedContent { + /// The invited Matrix user ID. + /// + /// Must be equal to the user_id property of the event. + pub mxid: UserId, + + /// A single signature from the verifying server, in the format specified by the Signing Events + /// section of the server-server API. + pub signatures: BTreeMap>, + + /// The token property of the containing third_party_invite object. + pub token: String, +} + +/// Translation of the membership change in `m.room.member` event. +#[derive(Clone, Copy, Debug, Deserialize, Serialize)] +pub enum MembershipChange { + /// No change. + None, + + /// Must never happen. + Error, + + /// User joined the room. + Joined, + + /// User left the room. + Left, + + /// User was banned. + Banned, + + /// User was unbanned. + Unbanned, + + /// User was kicked. + Kicked, + + /// User was invited. + Invited, + + /// User was kicked and banned. + KickedAndBanned, + + /// User rejected the invite. + InvitationRejected, + + /// User had their invite revoked. + InvitationRevoked, + + /// `displayname` or `avatar_url` changed. + ProfileChanged { + /// Whether the `displayname` changed. + displayname_changed: bool, + /// Whether the `avatar_url` changed. + avatar_url_changed: bool, + }, + + /// Not implemented. + NotImplemented, +} + +impl MemberEvent { + /// Helper function for membership change. Check [the specification][spec] for details. + /// + /// [spec]: https://matrix.org/docs/spec/client_server/latest#m-room-member + pub fn membership_change(&self) -> MembershipChange { + use MembershipState::*; + let prev_content = if let Some(prev_content) = &self.prev_content { + prev_content + } else { + &MemberEventContent { + avatar_url: None, + displayname: None, + is_direct: None, + membership: Leave, + third_party_invite: None, + } + }; + + match (prev_content.membership, &self.content.membership) { + (Invite, Invite) | (Leave, Leave) | (Ban, Ban) => MembershipChange::None, + (Invite, Join) | (Leave, Join) => MembershipChange::Joined, + (Invite, Leave) => { + if self.sender == self.state_key { + MembershipChange::InvitationRevoked + } else { + MembershipChange::InvitationRejected + } + } + (Invite, Ban) | (Leave, Ban) => MembershipChange::Banned, + (Join, Invite) | (Ban, Invite) | (Ban, Join) => MembershipChange::Error, + (Join, Join) => MembershipChange::ProfileChanged { + displayname_changed: prev_content.displayname != self.content.displayname, + avatar_url_changed: prev_content.avatar_url != self.content.avatar_url, + }, + (Join, Leave) => { + if self.sender == self.state_key { + MembershipChange::Left + } else { + MembershipChange::Kicked + } + } + (Join, Ban) => MembershipChange::KickedAndBanned, + (Leave, Invite) => MembershipChange::Invited, + (Ban, Leave) => MembershipChange::Unbanned, + (Knock, _) | (_, Knock) => MembershipChange::NotImplemented, + } + } +} + +#[cfg(test)] +mod tests { + use std::time::{Duration, UNIX_EPOCH}; + + use maplit::btreemap; + use matches::assert_matches; + use serde_json::{from_value as from_json_value, json}; + + use super::{MemberEventContent, MembershipState, SignedContent, ThirdPartyInvite}; + use crate::{EventJson, StateEvent}; + + #[test] + fn serde_with_no_prev_content() { + let json = json!({ + "type": "m.room.member", + "content": { + "membership": "join" + }, + "event_id": "$h29iv0s8:example.com", + "origin_server_ts": 1, + "room_id": "!n8f893n9:example.com", + "sender": "@carl:example.com", + "state_key": "example.com" + }); + + assert_matches!( + from_json_value::>>(json) + .unwrap() + .deserialize() + .unwrap(), + StateEvent:: { + content: MemberEventContent { + avatar_url: None, + displayname: None, + is_direct: None, + membership: MembershipState::Join, + third_party_invite: None, + }, + event_id, + origin_server_ts, + room_id, + sender, + state_key, + unsigned, + prev_content: None, + } if event_id == "$h29iv0s8:example.com" + && origin_server_ts == UNIX_EPOCH + Duration::from_millis(1) + && room_id == "!n8f893n9:example.com" + && sender == "@carl:example.com" + && state_key == "example.com" + && unsigned.is_empty() + ); + } + + #[test] + fn serde_with_prev_content() { + let json = json!({ + "type": "m.room.member", + "content": { + "membership": "join" + }, + "event_id": "$h29iv0s8:example.com", + "origin_server_ts": 1, + "prev_content": { + "membership": "join" + }, + "room_id": "!n8f893n9:example.com", + "sender": "@carl:example.com", + "state_key": "example.com" + }); + + assert_matches!( + from_json_value::>>(json) + .unwrap() + .deserialize() + .unwrap(), + StateEvent:: { + content: MemberEventContent { + avatar_url: None, + displayname: None, + is_direct: None, + membership: MembershipState::Join, + third_party_invite: None, + }, + event_id, + origin_server_ts, + room_id, + sender, + state_key, + unsigned, + prev_content: Some(MemberEventContent { + avatar_url: None, + displayname: None, + is_direct: None, + membership: MembershipState::Join, + third_party_invite: None, + }), + } if event_id == "$h29iv0s8:example.com" + && origin_server_ts == UNIX_EPOCH + Duration::from_millis(1) + && room_id == "!n8f893n9:example.com" + && sender == "@carl:example.com" + && state_key == "example.com" + && unsigned.is_empty() + ); + } + + #[test] + fn serde_with_content_full() { + let json = json!({ + "type": "m.room.member", + "content": { + "avatar_url": "mxc://example.org/SEsfnsuifSDFSSEF", + "displayname": "Alice Margatroid", + "is_direct": true, + "membership": "invite", + "third_party_invite": { + "display_name": "alice", + "signed": { + "mxid": "@alice:example.org", + "signatures": { + "magic.forest": { + "ed25519:3": "foobar" + } + }, + "token": "abc123" + } + } + }, + "event_id": "$143273582443PhrSn:example.org", + "origin_server_ts": 233, + "room_id": "!jEsUZKDJdhlrceRyVU:example.org", + "sender": "@alice:example.org", + "state_key": "@alice:example.org" + }); + + assert_matches!( + from_json_value::>>(json) + .unwrap() + .deserialize() + .unwrap(), + StateEvent:: { + content: MemberEventContent { + avatar_url: Some(avatar_url), + displayname: Some(displayname), + is_direct: Some(true), + membership: MembershipState::Invite, + third_party_invite: Some(ThirdPartyInvite { + display_name: third_party_displayname, + signed: SignedContent { mxid, signatures, token }, + }), + }, + event_id, + origin_server_ts, + room_id, + sender, + state_key, + unsigned, + prev_content: None, + } if avatar_url == "mxc://example.org/SEsfnsuifSDFSSEF" + && displayname == "Alice Margatroid" + && third_party_displayname == "alice" + && mxid == "@alice:example.org" + && signatures == btreemap! { + "magic.forest".to_owned() => btreemap! { + "ed25519:3".to_owned() => "foobar".to_owned() + } + } + && token == "abc123" + && event_id == "$143273582443PhrSn:example.org" + && origin_server_ts == UNIX_EPOCH + Duration::from_millis(233) + && room_id == "!jEsUZKDJdhlrceRyVU:example.org" + && sender == "@alice:example.org" + && state_key == "@alice:example.org" + && unsigned.is_empty() + ) + } + + #[test] + fn serde_with_prev_content_full() { + let json = json!({ + "type": "m.room.member", + "content": { + "membership": "join" + }, + "event_id": "$143273582443PhrSn:example.org", + "origin_server_ts": 233, + "prev_content": { + "avatar_url": "mxc://example.org/SEsfnsuifSDFSSEF", + "displayname": "Alice Margatroid", + "is_direct": true, + "membership": "invite", + "third_party_invite": { + "display_name": "alice", + "signed": { + "mxid": "@alice:example.org", + "signatures": { + "magic.forest": { + "ed25519:3": "foobar" + } + }, + "token": "abc123" + } + } + }, + "room_id": "!jEsUZKDJdhlrceRyVU:example.org", + "sender": "@alice:example.org", + "state_key": "@alice:example.org" + }); + + assert_matches!( + from_json_value::>>(json) + .unwrap() + .deserialize() + .unwrap(), + StateEvent:: { + content: MemberEventContent { + avatar_url: None, + displayname: None, + is_direct: None, + membership: MembershipState::Join, + third_party_invite: None, + }, + event_id, + origin_server_ts, + room_id, + sender, + state_key, + unsigned, + prev_content: Some(MemberEventContent { + avatar_url: Some(avatar_url), + displayname: Some(displayname), + is_direct: Some(true), + membership: MembershipState::Invite, + third_party_invite: Some(ThirdPartyInvite { + display_name: third_party_displayname, + signed: SignedContent { mxid, signatures, token }, + }), + }), + } if event_id == "$143273582443PhrSn:example.org" + && origin_server_ts == UNIX_EPOCH + Duration::from_millis(233) + && room_id == "!jEsUZKDJdhlrceRyVU:example.org" + && sender == "@alice:example.org" + && state_key == "@alice:example.org" + && unsigned.is_empty() + && avatar_url == "mxc://example.org/SEsfnsuifSDFSSEF" + && displayname == "Alice Margatroid" + && third_party_displayname == "alice" + && mxid == "@alice:example.org" + && signatures == btreemap! { + "magic.forest".to_owned() => btreemap! { + "ed25519:3".to_owned() => "foobar".to_owned() + } + } + && token == "abc123" + ); + } +} diff --git a/ruma-events/src/room/message.rs b/ruma-events/src/room/message.rs new file mode 100644 index 00000000..b99e584b --- /dev/null +++ b/ruma-events/src/room/message.rs @@ -0,0 +1,559 @@ +//! Types for the *m.room.message* event. + +use js_int::UInt; +use ruma_events_macros::MessageEventContent; +use ruma_identifiers::EventId; +use serde::{Deserialize, Serialize}; + +use super::{EncryptedFile, ImageInfo, ThumbnailInfo}; + +pub mod feedback; + +use crate::MessageEvent as OuterMessageEvent; + +/// This event is used when sending messages in a room. +/// +/// Messages are not limited to be text. +pub type MessageEvent = OuterMessageEvent; + +/// The payload for `MessageEvent`. +#[derive(Clone, Debug, Deserialize, Serialize, MessageEventContent)] +#[ruma_event(type = "m.room.message")] +#[serde(tag = "msgtype")] +pub enum MessageEventContent { + /// An audio message. + #[serde(rename = "m.audio")] + Audio(AudioMessageEventContent), + + /// An emote message. + #[serde(rename = "m.emote")] + Emote(EmoteMessageEventContent), + + /// A file message. + #[serde(rename = "m.file")] + File(FileMessageEventContent), + + /// An image message. + #[serde(rename = "m.image")] + Image(ImageMessageEventContent), + + /// A location message. + #[serde(rename = "m.location")] + Location(LocationMessageEventContent), + + /// A notice message. + #[serde(rename = "m.notice")] + Notice(NoticeMessageEventContent), + + /// A server notice message. + #[serde(rename = "m.server_notice")] + ServerNotice(ServerNoticeMessageEventContent), + + /// A text message. + #[serde(rename = "m.text")] + Text(TextMessageEventContent), + + /// A video message. + #[serde(rename = "m.video")] + Video(VideoMessageEventContent), +} + +/// The payload for an audio message. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct AudioMessageEventContent { + /// The textual representation of this message. + pub body: String, + + /// Metadata for the audio clip referred to in `url`. + #[serde(skip_serializing_if = "Option::is_none")] + pub info: Option>, + + /// The URL to the audio clip. Required if the file is unencrypted. The URL (typically + /// [MXC URI](https://matrix.org/docs/spec/client_server/r0.6.1#mxc-uri)) to the audio clip. + #[serde(skip_serializing_if = "Option::is_none")] + pub url: Option, + + /// Required if the audio clip is encrypted. Information on the encrypted audio clip. + #[serde(skip_serializing_if = "Option::is_none")] + pub file: Option>, +} + +/// Metadata about an audio clip. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct AudioInfo { + /// The duration of the audio in milliseconds. + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + /// The mimetype of the audio, e.g. "audio/aac." + #[serde(skip_serializing_if = "Option::is_none")] + pub mimetype: Option, + + /// The size of the audio clip in bytes. + #[serde(skip_serializing_if = "Option::is_none")] + pub size: Option, +} + +/// The payload for an emote message. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct EmoteMessageEventContent { + /// The emote action to perform. + pub body: String, + + /// Formatted form of the message `body`. + #[serde(flatten)] + pub formatted: Option, +} + +/// The payload for a file message. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct FileMessageEventContent { + /// A human-readable description of the file. This is recommended to be the filename of the + /// original upload. + pub body: String, + + /// The original filename of the uploaded file. + #[serde(skip_serializing_if = "Option::is_none")] + pub filename: Option, + + /// Metadata about the file referred to in `url`. + #[serde(skip_serializing_if = "Option::is_none")] + pub info: Option>, + + /// The URL to the file. Required if the file is unencrypted. The URL (typically + /// [MXC URI](https://matrix.org/docs/spec/client_server/r0.6.1#mxc-uri)) to the file. + #[serde(skip_serializing_if = "Option::is_none")] + pub url: Option, + + /// Required if file is encrypted. Information on the encrypted file. + #[serde(skip_serializing_if = "Option::is_none")] + pub file: Option>, +} + +/// Metadata about a file. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct FileInfo { + /// The mimetype of the file, e.g. "application/msword." + #[serde(skip_serializing_if = "Option::is_none")] + pub mimetype: Option, + + /// The size of the file in bytes. + #[serde(skip_serializing_if = "Option::is_none")] + pub size: Option, + + /// Metadata about the image referred to in `thumbnail_url`. + #[serde(skip_serializing_if = "Option::is_none")] + pub thumbnail_info: Option>, + + /// The URL to the thumbnail of the file. Only present if the thumbnail is unencrypted. + #[serde(skip_serializing_if = "Option::is_none")] + pub thumbnail_url: Option, + + /// Information on the encrypted thumbnail file. Only present if the thumbnail is encrypted. + #[serde(skip_serializing_if = "Option::is_none")] + pub thumbnail_file: Option>, +} + +/// The payload for an image message. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct ImageMessageEventContent { + /// A textual representation of the image. This could be the alt text of the image, the filename + /// of the image, or some kind of content description for accessibility e.g. "image attachment." + pub body: String, + + /// Metadata about the image referred to in `url`. + #[serde(skip_serializing_if = "Option::is_none")] + pub info: Option>, + + /// The URL to the image. Required if the file is unencrypted. The URL (typically + /// [MXC URI](https://matrix.org/docs/spec/client_server/r0.6.1#mxc-uri)) to the image. + #[serde(skip_serializing_if = "Option::is_none")] + pub url: Option, + + /// Required if image is encrypted. Information on the encrypted image. + #[serde(skip_serializing_if = "Option::is_none")] + pub file: Option>, +} + +/// The payload for a location message. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct LocationMessageEventContent { + /// A description of the location e.g. "Big Ben, London, UK,"or some kind of content description + /// for accessibility, e.g. "location attachment." + pub body: String, + + /// A geo URI representing the location. + pub geo_uri: String, + + /// Info about the location being represented. + #[serde(skip_serializing_if = "Option::is_none")] + pub info: Option>, +} + +/// Thumbnail info associated with a location. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct LocationInfo { + /// Metadata about the image referred to in `thumbnail_url` or `thumbnail_file`. + #[serde(skip_serializing_if = "Option::is_none")] + pub thumbnail_info: Option>, + + /// The URL to a thumbnail of the location being represented. Only present if the thumbnail is + /// unencrypted. + #[serde(skip_serializing_if = "Option::is_none")] + pub thumbnail_url: Option, + + /// Information on an encrypted thumbnail of the location being represented. Only present if the + /// thumbnail is encrypted. + #[serde(skip_serializing_if = "Option::is_none")] + pub thumbnail_file: Option>, +} + +/// The payload for a notice message. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct NoticeMessageEventContent { + /// The notice text to send. + pub body: String, + + /// Formatted form of the message `body`. + #[serde(flatten)] + pub formatted: Option, + + /// Information about related messages for + /// [rich replies](https://matrix.org/docs/spec/client_server/r0.6.1#rich-replies). + #[serde(rename = "m.relates_to", skip_serializing_if = "Option::is_none")] + pub relates_to: Option, +} + +/// The payload for a server notice message. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct ServerNoticeMessageEventContent { + /// A human-readable description of the notice. + pub body: String, + + /// The type of notice being represented. + pub server_notice_type: ServerNoticeType, + + /// A URI giving a contact method for the server administrator. + /// + /// Required if the notice type is `m.server_notice.usage_limit_reached`. + #[serde(skip_serializing_if = "Option::is_none")] + pub admin_contact: Option, + + /// The kind of usage limit the server has exceeded. + /// + /// Required if the notice type is `m.server_notice.usage_limit_reached`. + #[serde(skip_serializing_if = "Option::is_none")] + pub limit_type: Option, +} + +/// Types of server notices. +#[derive(Clone, Copy, Debug, Deserialize, Serialize)] +pub enum ServerNoticeType { + /// The server has exceeded some limit which requires the server administrator to intervene. + #[serde(rename = "m.server_notice.usage_limit_reached")] + UsageLimitReached, +} + +/// Types of usage limits. +#[derive(Clone, Copy, Debug, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum LimitType { + /// The server's number of active users in the last 30 days has exceeded the maximum. + /// + /// New connections are being refused by the server. What defines "active" is left as an + /// implementation detail, however servers are encouraged to treat syncing users as "active". + MonthlyActiveUser, +} + +/// The format for the formatted representation of a message body. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[non_exhaustive] +pub enum MessageFormat { + /// HTML. + #[serde(rename = "org.matrix.custom.html")] + Html, + + /// A custom message format. + Custom(String), +} + +/// Common message event content fields for message types that have separate plain-text and +/// formatted representations. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct FormattedBody { + /// The format used in the `formatted_body`. + pub format: MessageFormat, + + /// The formatted version of the `body`. + #[serde(rename = "formatted_body")] + pub body: String, +} + +/// The payload for a text message. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct TextMessageEventContent { + /// The body of the message. + pub body: String, + + /// Formatted form of the message `body`. + #[serde(flatten)] + pub formatted: Option, + + /// Information about related messages for + /// [rich replies](https://matrix.org/docs/spec/client_server/r0.6.1#rich-replies). + #[serde(rename = "m.relates_to", skip_serializing_if = "Option::is_none")] + pub relates_to: Option, +} + +/// The payload for a video message. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct VideoMessageEventContent { + /// A description of the video, e.g. "Gangnam Style," or some kind of content description for + /// accessibility, e.g. "video attachment." + pub body: String, + + /// Metadata about the video clip referred to in `url`. + #[serde(skip_serializing_if = "Option::is_none")] + pub info: Option>, + + /// The URL to the video clip. Required if the file is unencrypted. The URL (typically + /// [MXC URI](https://matrix.org/docs/spec/client_server/r0.6.1#mxc-uri)) to the video clip. + #[serde(skip_serializing_if = "Option::is_none")] + pub url: Option, + + /// Required if video clip is encrypted. Information on the encrypted video clip. + #[serde(skip_serializing_if = "Option::is_none")] + pub file: Option>, +} + +/// Metadata about a video. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct VideoInfo { + /// The duration of the video in milliseconds. + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, + + /// The height of the video in pixels. + #[serde(rename = "h")] + #[serde(skip_serializing_if = "Option::is_none")] + pub height: Option, + + /// The width of the video in pixels. + #[serde(rename = "w")] + #[serde(skip_serializing_if = "Option::is_none")] + pub width: Option, + + /// The mimetype of the video, e.g. "video/mp4." + #[serde(skip_serializing_if = "Option::is_none")] + pub mimetype: Option, + + /// The size of the video in bytes. + #[serde(skip_serializing_if = "Option::is_none")] + pub size: Option, + + /// Metadata about an image. + #[serde(skip_serializing_if = "Option::is_none")] + pub thumbnail_info: Option>, + + /// The URL (typically [MXC URI](https://matrix.org/docs/spec/client_server/r0.6.1#mxc-uri)) to + /// an image thumbnail of the video clip. Only present if the thumbnail is unencrypted. + #[serde(skip_serializing_if = "Option::is_none")] + pub thumbnail_url: Option, + + /// Information on the encrypted thumbnail file. Only present if the thumbnail is encrypted. + #[serde(skip_serializing_if = "Option::is_none")] + pub thumbnail_file: Option>, +} + +/// Information about related messages for +/// [rich replies](https://matrix.org/docs/spec/client_server/r0.6.1#rich-replies). +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct RelatesTo { + /// Information about another message being replied to. + #[serde(rename = "m.in_reply_to")] + pub in_reply_to: InReplyTo, +} + +/// Information about the event a "rich reply" is replying to. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct InReplyTo { + /// The event being replied to. + pub event_id: EventId, +} + +impl TextMessageEventContent { + /// A convenience constructor to create a plain text message + pub fn new_plain(body: impl Into) -> TextMessageEventContent { + TextMessageEventContent { + body: body.into(), + formatted: None, + relates_to: None, + } + } +} + +#[cfg(test)] +mod tests { + use std::{ + convert::TryFrom, + time::{Duration, UNIX_EPOCH}, + }; + + use matches::assert_matches; + use ruma_identifiers::{EventId, RoomId, UserId}; + use serde_json::{from_value as from_json_value, json, to_value as to_json_value}; + + use super::{AudioMessageEventContent, FormattedBody, MessageEventContent, MessageFormat}; + use crate::{ + room::message::{InReplyTo, RelatesTo, TextMessageEventContent}, + EventJson, MessageEvent, UnsignedData, + }; + + #[test] + fn serialization() { + let ev = MessageEvent { + content: MessageEventContent::Audio(AudioMessageEventContent { + body: "test".to_string(), + info: None, + url: Some("http://example.com/audio.mp3".to_string()), + file: None, + }), + event_id: EventId::try_from("$143273582443PhrSn:example.org").unwrap(), + origin_server_ts: UNIX_EPOCH + Duration::from_millis(10_000), + room_id: RoomId::try_from("!testroomid:example.org").unwrap(), + sender: UserId::try_from("@user:example.org").unwrap(), + unsigned: UnsignedData::default(), + }; + + assert_eq!( + to_json_value(ev).unwrap(), + json!({ + "type": "m.room.message", + "event_id": "$143273582443PhrSn:example.org", + "origin_server_ts": 10_000, + "room_id": "!testroomid:example.org", + "sender": "@user:example.org", + "content": { + "body": "test", + "msgtype": "m.audio", + "url": "http://example.com/audio.mp3", + } + }) + ); + } + + #[test] + fn content_serialization() { + let message_event_content = MessageEventContent::Audio(AudioMessageEventContent { + body: "test".to_string(), + info: None, + url: Some("http://example.com/audio.mp3".to_string()), + file: None, + }); + + assert_eq!( + to_json_value(&message_event_content).unwrap(), + json!({ + "body": "test", + "msgtype": "m.audio", + "url": "http://example.com/audio.mp3" + }) + ); + } + + #[test] + fn formatted_body_serialization() { + let message_event_content = MessageEventContent::Text(TextMessageEventContent { + body: "Hello, World!".into(), + formatted: Some(FormattedBody { + format: MessageFormat::Html, + body: "Hello, World!".into(), + }), + relates_to: None, + }); + + assert_eq!( + to_json_value(&message_event_content).unwrap(), + json!({ + "body": "Hello, World!", + "msgtype": "m.text", + "format": "org.matrix.custom.html", + "formatted_body": "Hello, World!", + }) + ); + } + + #[test] + fn plain_text_content_serialization() { + let message_event_content = MessageEventContent::Text(TextMessageEventContent::new_plain( + "> <@test:example.com> test\n\ntest reply", + )); + + assert_eq!( + to_json_value(&message_event_content).unwrap(), + json!({ + "body": "> <@test:example.com> test\n\ntest reply", + "msgtype": "m.text" + }) + ); + } + + #[test] + fn relates_to_content_serialization() { + let message_event_content = MessageEventContent::Text(TextMessageEventContent { + body: "> <@test:example.com> test\n\ntest reply".to_owned(), + formatted: None, + relates_to: Some(RelatesTo { + in_reply_to: InReplyTo { + event_id: EventId::try_from("$15827405538098VGFWH:example.com").unwrap(), + }, + }), + }); + + let json_data = json!({ + "body": "> <@test:example.com> test\n\ntest reply", + "msgtype": "m.text", + "m.relates_to": { + "m.in_reply_to": { + "event_id": "$15827405538098VGFWH:example.com" + } + } + }); + + assert_eq!(to_json_value(&message_event_content).unwrap(), json_data); + } + + #[test] + fn content_deserialization() { + let json_data = json!({ + "body": "test", + "msgtype": "m.audio", + "url": "http://example.com/audio.mp3" + }); + + assert_matches!( + from_json_value::>(json_data) + .unwrap() + .deserialize() + .unwrap(), + MessageEventContent::Audio(AudioMessageEventContent { + body, + info: None, + url: Some(url), + file: None, + }) if body == "test" && url == "http://example.com/audio.mp3" + ); + } + + #[test] + fn content_deserialization_failure() { + let json_data = json!({ + "body": "test","msgtype": "m.location", + "url": "http://example.com/audio.mp3" + }); + assert!(from_json_value::>(json_data) + .unwrap() + .deserialize() + .is_err()); + } +} diff --git a/ruma-events/src/room/message/feedback.rs b/ruma-events/src/room/message/feedback.rs new file mode 100644 index 00000000..d36a2e97 --- /dev/null +++ b/ruma-events/src/room/message/feedback.rs @@ -0,0 +1,38 @@ +//! Types for the *m.room.message.feedback* event. + +use ruma_events_macros::MessageEventContent; +use ruma_identifiers::EventId; +use serde::{Deserialize, Serialize}; +use strum::{Display, EnumString}; + +use crate::MessageEvent; + +/// An acknowledgement of a message. +/// +/// N.B.: Usage of this event is discouraged in favor of the receipts module. Most clients will +/// not recognize this event. +pub type FeedbackEvent = MessageEvent; + +/// The payload for `FeedbackEvent`. +#[derive(Clone, Debug, Deserialize, Serialize, MessageEventContent)] +#[ruma_event(type = "m.room.message.feedback")] +pub struct FeedbackEventContent { + /// The event that this feedback is related to. + pub target_event_id: EventId, + + /// The type of feedback. + #[serde(rename = "type")] + pub feedback_type: FeedbackType, +} + +/// A type of feedback. +#[derive(Clone, Copy, Debug, PartialEq, Display, EnumString, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +#[strum(serialize_all = "snake_case")] +pub enum FeedbackType { + /// Sent when a message is received. + Delivered, + + /// Sent when a message has been observed by the end user. + Read, +} diff --git a/ruma-events/src/room/name.rs b/ruma-events/src/room/name.rs new file mode 100644 index 00000000..54d07535 --- /dev/null +++ b/ruma-events/src/room/name.rs @@ -0,0 +1,275 @@ +//! Types for the *m.room.name* event. + +use ruma_events_macros::StateEventContent; +use serde::{Deserialize, Serialize}; + +use crate::{InvalidInput, StateEvent}; + +/// The room name is a human-friendly string designed to be displayed to the end-user. +pub type NameEvent = StateEvent; + +/// The payload for `NameEvent`. +#[derive(Clone, Debug, Deserialize, Serialize, StateEventContent)] +#[ruma_event(type = "m.room.name")] +pub struct NameEventContent { + /// The name of the room. This MUST NOT exceed 255 bytes. + #[serde(default, deserialize_with = "room_name")] + pub(crate) name: Option, +} + +impl NameEventContent { + /// Create a new `NameEventContent` with the given name. + /// + /// # Errors + /// + /// `InvalidInput` will be returned if the name is more than 255 bytes. + pub fn new(name: String) -> Result { + match name.len() { + 0 => Ok(Self { name: None }), + 1..=255 => Ok(Self { name: Some(name) }), + _ => Err(InvalidInput( + "a room name cannot be more than 255 bytes".to_string(), + )), + } + } + + /// The name of the room, if any. + pub fn name(&self) -> Option<&str> { + self.name.as_deref() + } +} + +fn room_name<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::de::Deserializer<'de>, +{ + use serde::de::Error; + + // this handles the null case and the empty string or nothing case + match Option::::deserialize(deserializer)? { + Some(name) => match name.len() { + 0 => Ok(None), + 1..=255 => Ok(Some(name)), + _ => Err(D::Error::custom( + "a room name cannot be more than 255 bytes", + )), + }, + None => Ok(None), + } +} + +#[cfg(test)] +mod tests { + use std::{ + convert::TryFrom, + iter::FromIterator, + time::{Duration, UNIX_EPOCH}, + }; + + use js_int::Int; + use matches::assert_matches; + use ruma_identifiers::{EventId, RoomId, UserId}; + use serde_json::{from_value as from_json_value, json, to_value as to_json_value}; + + use crate::{EventJson, StateEvent, UnsignedData}; + + use super::NameEventContent; + + #[test] + fn serialization_with_optional_fields_as_none() { + let name_event = StateEvent { + content: NameEventContent { + name: Some("The room name".to_string()), + }, + event_id: EventId::try_from("$h29iv0s8:example.com").unwrap(), + origin_server_ts: UNIX_EPOCH + Duration::from_millis(1), + prev_content: None, + room_id: RoomId::try_from("!n8f893n9:example.com").unwrap(), + sender: UserId::try_from("@carl:example.com").unwrap(), + state_key: "".to_string(), + unsigned: UnsignedData::default(), + }; + + let actual = to_json_value(&name_event).unwrap(); + let expected = json!({ + "content": { + "name": "The room name" + }, + "event_id": "$h29iv0s8:example.com", + "origin_server_ts": 1, + "room_id": "!n8f893n9:example.com", + "sender": "@carl:example.com", + "state_key": "", + "type": "m.room.name" + }); + + assert_eq!(actual, expected); + } + + #[test] + fn serialization_with_all_fields() { + let name_event = StateEvent { + content: NameEventContent { + name: Some("The room name".to_string()), + }, + event_id: EventId::try_from("$h29iv0s8:example.com").unwrap(), + origin_server_ts: UNIX_EPOCH + Duration::from_millis(1), + prev_content: Some(NameEventContent { + name: Some("The old name".to_string()), + }), + room_id: RoomId::try_from("!n8f893n9:example.com").unwrap(), + sender: UserId::try_from("@carl:example.com").unwrap(), + state_key: "".to_string(), + unsigned: UnsignedData { + age: Some(Int::from(100)), + ..UnsignedData::default() + }, + }; + + let actual = to_json_value(&name_event).unwrap(); + let expected = json!({ + "content": { + "name": "The room name" + }, + "event_id": "$h29iv0s8:example.com", + "origin_server_ts": 1, + "prev_content": { "name": "The old name" }, + "room_id": "!n8f893n9:example.com", + "sender": "@carl:example.com", + "state_key": "", + "type": "m.room.name", + "unsigned": { + "age": 100 + } + }); + + assert_eq!(actual, expected); + } + + #[test] + fn absent_field_as_none() { + let json_data = json!({ + "content": {}, + "event_id": "$h29iv0s8:example.com", + "origin_server_ts": 1, + "room_id": "!n8f893n9:example.com", + "sender": "@carl:example.com", + "state_key": "", + "type": "m.room.name" + }); + assert_eq!( + from_json_value::>>(json_data) + .unwrap() + .deserialize() + .unwrap() + .content + .name, + None + ); + } + + #[test] + fn name_fails_validation_when_too_long() { + // "XXXX .." 256 times + let long_string: String = String::from_iter(std::iter::repeat('X').take(256)); + assert_eq!(long_string.len(), 256); + + let long_content_json = json!({ "name": &long_string }); + let from_raw: EventJson = from_json_value(long_content_json).unwrap(); + + let result = from_raw.deserialize(); + assert!(result.is_err(), "Result should be invalid: {:?}", result); + } + + #[test] + fn json_with_empty_name_creates_content_as_none() { + let long_content_json = json!({ "name": "" }); + let from_raw: EventJson = from_json_value(long_content_json).unwrap(); + assert_matches!( + from_raw.deserialize().unwrap(), + NameEventContent { name: None } + ); + } + + #[test] + fn new_with_empty_name_creates_content_as_none() { + assert_matches!( + NameEventContent::new(String::new()).unwrap(), + NameEventContent { name: None } + ); + } + + #[test] + fn null_field_as_none() { + let json_data = json!({ + "content": { + "name": null + }, + "event_id": "$h29iv0s8:example.com", + "origin_server_ts": 1, + "room_id": "!n8f893n9:example.com", + "sender": "@carl:example.com", + "state_key": "", + "type": "m.room.name" + }); + assert_eq!( + from_json_value::>>(json_data) + .unwrap() + .deserialize() + .unwrap() + .content + .name, + None + ); + } + + #[test] + fn empty_string_as_none() { + let json_data = json!({ + "content": { + "name": "" + }, + "event_id": "$h29iv0s8:example.com", + "origin_server_ts": 1, + "room_id": "!n8f893n9:example.com", + "sender": "@carl:example.com", + "state_key": "", + "type": "m.room.name" + }); + assert_eq!( + from_json_value::>>(json_data) + .unwrap() + .deserialize() + .unwrap() + .content + .name, + None + ); + } + + #[test] + fn nonempty_field_as_some() { + let name = Some("The room name".to_string()); + let json_data = json!({ + "content": { + "name": "The room name" + }, + "event_id": "$h29iv0s8:example.com", + "origin_server_ts": 1, + "room_id": "!n8f893n9:example.com", + "sender": "@carl:example.com", + "state_key": "", + "type": "m.room.name" + }); + + assert_eq!( + from_json_value::>>(json_data) + .unwrap() + .deserialize() + .unwrap() + .content + .name, + name + ); + } +} diff --git a/ruma-events/src/room/pinned_events.rs b/ruma-events/src/room/pinned_events.rs new file mode 100644 index 00000000..2cd55177 --- /dev/null +++ b/ruma-events/src/room/pinned_events.rs @@ -0,0 +1,66 @@ +//! Types for the *m.room.pinned_events* event. + +use ruma_events_macros::StateEventContent; +use ruma_identifiers::EventId; +use serde::{Deserialize, Serialize}; + +use crate::StateEvent; + +/// Used to "pin" particular events in a room for other participants to review later. +pub type PinnedEventsEvent = StateEvent; + +/// The payload for `PinnedEventsEvent`. +#[derive(Clone, Debug, Deserialize, Serialize, StateEventContent)] +#[ruma_event(type = "m.room.pinned_events")] +pub struct PinnedEventsEventContent { + /// An ordered list of event IDs to pin. + pub pinned: Vec, +} + +#[cfg(test)] +mod tests { + use std::time::{Duration, UNIX_EPOCH}; + + use ruma_identifiers::{EventId, RoomId, UserId}; + use serde_json::to_string; + + use super::PinnedEventsEventContent; + use crate::{EventJson, StateEvent, UnsignedData}; + + #[test] + fn serialization_deserialization() { + let mut content: PinnedEventsEventContent = PinnedEventsEventContent { pinned: Vec::new() }; + + content.pinned.push(EventId::new("example.com").unwrap()); + content.pinned.push(EventId::new("example.com").unwrap()); + + let event = StateEvent { + content: content.clone(), + event_id: EventId::new("example.com").unwrap(), + origin_server_ts: UNIX_EPOCH + Duration::from_millis(1_432_804_485_886u64), + prev_content: None, + room_id: RoomId::new("example.com").unwrap(), + sender: UserId::new("example.com").unwrap(), + state_key: "".to_string(), + unsigned: UnsignedData::default(), + }; + + let serialized_event = to_string(&event).unwrap(); + let parsed_event = serde_json::from_str::>>( + &serialized_event, + ) + .unwrap() + .deserialize() + .unwrap(); + + assert_eq!(parsed_event.event_id, event.event_id); + assert_eq!(parsed_event.room_id, event.room_id); + assert_eq!(parsed_event.sender, event.sender); + assert_eq!(parsed_event.state_key, event.state_key); + assert_eq!(parsed_event.origin_server_ts, event.origin_server_ts); + + assert_eq!(parsed_event.content.pinned, event.content.pinned); + assert_eq!(parsed_event.content.pinned[0], content.pinned[0]); + assert_eq!(parsed_event.content.pinned[1], content.pinned[1]); + } +} diff --git a/ruma-events/src/room/power_levels.rs b/ruma-events/src/room/power_levels.rs new file mode 100644 index 00000000..8efacf45 --- /dev/null +++ b/ruma-events/src/room/power_levels.rs @@ -0,0 +1,286 @@ +//! Types for the *m.room.power_levels* event. + +use std::collections::BTreeMap; + +use js_int::Int; +use ruma_events_macros::StateEventContent; +use ruma_identifiers::UserId; +use serde::{Deserialize, Serialize}; + +use crate::{EventType, StateEvent}; + +/// Defines the power levels (privileges) of users in the room. +pub type PowerLevelsEvent = StateEvent; + +/// The payload for `PowerLevelsEvent`. +#[derive(Clone, Debug, Deserialize, Serialize, StateEventContent)] +#[ruma_event(type = "m.room.power_levels")] +pub struct PowerLevelsEventContent { + /// The level required to ban a user. + #[serde( + default = "default_power_level", + skip_serializing_if = "is_default_power_level" + )] + pub ban: Int, + + /// The level required to send specific event types. + /// + /// This is a mapping from event type to power level required. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub events: BTreeMap, + + /// The default level required to send message events. + #[serde(default, skip_serializing_if = "ruma_serde::is_default")] + pub events_default: Int, + + /// The level required to invite a user. + #[serde( + default = "default_power_level", + skip_serializing_if = "is_default_power_level" + )] + pub invite: Int, + + /// The level required to kick a user. + #[serde( + default = "default_power_level", + skip_serializing_if = "is_default_power_level" + )] + pub kick: Int, + + /// The level required to redact an event. + #[serde( + default = "default_power_level", + skip_serializing_if = "is_default_power_level" + )] + pub redact: Int, + + /// The default level required to send state events. + #[serde( + default = "default_power_level", + skip_serializing_if = "is_default_power_level" + )] + pub state_default: Int, + + /// The power levels for specific users. + /// + /// This is a mapping from `user_id` to power level for that user. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub users: BTreeMap, + + /// The default power level for every user in the room. + #[serde(default, skip_serializing_if = "ruma_serde::is_default")] + pub users_default: Int, + + /// The power level requirements for specific notification types. + /// + /// This is a mapping from `key` to power level for that notifications key. + #[serde(default, skip_serializing_if = "ruma_serde::is_default")] + pub notifications: NotificationPowerLevels, +} + +impl Default for PowerLevelsEventContent { + fn default() -> Self { + // events_default and users_default having a default of 0 while the others have a default + // of 50 is not an oversight, these defaults are from the Matrix specification. + Self { + ban: default_power_level(), + events: BTreeMap::new(), + events_default: Int::default(), + invite: default_power_level(), + kick: default_power_level(), + redact: default_power_level(), + state_default: default_power_level(), + users: BTreeMap::new(), + users_default: Int::default(), + notifications: NotificationPowerLevels::default(), + } + } +} + +/// The power level requirements for specific notification types. +#[derive(Clone, Copy, Debug, PartialEq, Deserialize, Serialize)] +pub struct NotificationPowerLevels { + /// The level required to trigger an `@room` notification. + #[serde(default = "default_power_level")] + pub room: Int, +} + +impl Default for NotificationPowerLevels { + fn default() -> Self { + Self { + room: default_power_level(), + } + } +} + +/// Used to default power levels to 50 during deserialization. +fn default_power_level() -> Int { + Int::from(50) +} + +/// Used with #[serde(skip_serializing_if)] to omit default power levels. +#[allow(clippy::trivially_copy_pass_by_ref)] +fn is_default_power_level(l: &Int) -> bool { + *l == Int::from(50) +} + +#[cfg(test)] +mod tests { + use std::{ + collections::BTreeMap, + convert::TryFrom, + time::{Duration, UNIX_EPOCH}, + }; + + use js_int::Int; + use maplit::btreemap; + use ruma_identifiers::{EventId, RoomId, UserId}; + use serde_json::{json, to_value as to_json_value}; + + use super::{default_power_level, NotificationPowerLevels, PowerLevelsEventContent}; + use crate::{EventType, StateEvent, UnsignedData}; + + #[test] + fn serialization_with_optional_fields_as_none() { + let default = default_power_level(); + + let power_levels_event = StateEvent { + content: PowerLevelsEventContent { + ban: default, + events: BTreeMap::new(), + events_default: Int::from(0), + invite: default, + kick: default, + redact: default, + state_default: default, + users: BTreeMap::new(), + users_default: Int::from(0), + notifications: NotificationPowerLevels::default(), + }, + event_id: EventId::try_from("$h29iv0s8:example.com").unwrap(), + origin_server_ts: UNIX_EPOCH + Duration::from_millis(1), + prev_content: None, + room_id: RoomId::try_from("!n8f893n9:example.com").unwrap(), + unsigned: UnsignedData::default(), + sender: UserId::try_from("@carl:example.com").unwrap(), + state_key: "".to_string(), + }; + + let actual = to_json_value(&power_levels_event).unwrap(); + let expected = json!({ + "content": {}, + "event_id": "$h29iv0s8:example.com", + "origin_server_ts": 1, + "room_id": "!n8f893n9:example.com", + "sender": "@carl:example.com", + "state_key": "", + "type": "m.room.power_levels" + }); + + assert_eq!(actual, expected); + } + + #[test] + fn serialization_with_all_fields() { + let user = UserId::try_from("@carl:example.com").unwrap(); + let power_levels_event = StateEvent { + content: PowerLevelsEventContent { + ban: Int::from(23), + events: btreemap! { + EventType::Dummy => Int::from(23) + }, + events_default: Int::from(23), + invite: Int::from(23), + kick: Int::from(23), + redact: Int::from(23), + state_default: Int::from(23), + users: btreemap! { + user.clone() => Int::from(23) + }, + users_default: Int::from(23), + notifications: NotificationPowerLevels { + room: Int::from(23), + }, + }, + event_id: EventId::try_from("$h29iv0s8:example.com").unwrap(), + origin_server_ts: UNIX_EPOCH + Duration::from_millis(1), + prev_content: Some(PowerLevelsEventContent { + // Make just one field different so we at least know they're two different objects. + ban: Int::from(42), + events: btreemap! { + EventType::Dummy => Int::from(42) + }, + events_default: Int::from(42), + invite: Int::from(42), + kick: Int::from(42), + redact: Int::from(42), + state_default: Int::from(42), + users: btreemap! { + user.clone() => Int::from(42) + }, + users_default: Int::from(42), + notifications: NotificationPowerLevels { + room: Int::from(42), + }, + }), + room_id: RoomId::try_from("!n8f893n9:example.com").unwrap(), + unsigned: UnsignedData { + age: Some(Int::from(100)), + ..UnsignedData::default() + }, + sender: user, + state_key: "".to_string(), + }; + + let actual = to_json_value(&power_levels_event).unwrap(); + let expected = json!({ + "content": { + "ban": 23, + "events": { + "m.dummy": 23 + }, + "events_default": 23, + "invite": 23, + "kick": 23, + "redact": 23, + "state_default": 23, + "users": { + "@carl:example.com": 23 + }, + "users_default": 23, + "notifications": { + "room": 23 + } + }, + "event_id": "$h29iv0s8:example.com", + "origin_server_ts": 1, + "prev_content": { + "ban": 42, + "events": { + "m.dummy": 42 + }, + "events_default": 42, + "invite": 42, + "kick": 42, + "redact": 42, + "state_default": 42, + "users": { + "@carl:example.com": 42 + }, + "users_default": 42, + "notifications": { + "room": 42 + } + }, + "room_id": "!n8f893n9:example.com", + "sender": "@carl:example.com", + "state_key": "", + "type": "m.room.power_levels", + "unsigned": { + "age": 100 + } + }); + + assert_eq!(actual, expected); + } +} diff --git a/ruma-events/src/room/redaction.rs b/ruma-events/src/room/redaction.rs new file mode 100644 index 00000000..41be23aa --- /dev/null +++ b/ruma-events/src/room/redaction.rs @@ -0,0 +1,145 @@ +//! Types for the *m.room.redaction* event. + +use std::time::SystemTime; + +use ruma_events_macros::{Event, EventContent}; +use ruma_identifiers::{EventId, RoomId, UserId}; +use serde::{Deserialize, Serialize}; + +use crate::UnsignedData; + +/// Redaction event. +#[derive(Clone, Debug, Event)] +pub struct RedactionEvent { + /// Data specific to the event type. + pub content: RedactionEventContent, + + /// The ID of the event that was redacted. + pub redacts: EventId, + + /// The globally unique event identifier for the user who sent the event. + pub event_id: EventId, + + /// The fully-qualified ID of the user who sent this event. + pub sender: UserId, + + /// Timestamp in milliseconds on originating homeserver when this event was sent. + pub origin_server_ts: SystemTime, + + /// The ID of the room associated with this event. + pub room_id: RoomId, + + /// Additional key-value pairs not signed by the homeserver. + pub unsigned: UnsignedData, +} + +/// Redaction event without a `room_id`. +#[derive(Clone, Debug, Event)] +pub struct RedactionEventStub { + /// Data specific to the event type. + pub content: RedactionEventContent, + + /// The ID of the event that was redacted. + pub redacts: EventId, + + /// The globally unique event identifier for the user who sent the event. + pub event_id: EventId, + + /// The fully-qualified ID of the user who sent this event. + pub sender: UserId, + + /// Timestamp in milliseconds on originating homeserver when this event was sent. + pub origin_server_ts: SystemTime, + + /// Additional key-value pairs not signed by the homeserver. + pub unsigned: UnsignedData, +} + +/// A redaction of an event. +#[derive(Clone, Debug, Deserialize, Serialize, EventContent)] +#[ruma_event(type = "m.room.redaction")] +pub struct RedactionEventContent { + /// The reason for the redaction, if any. + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option, +} + +#[cfg(test)] +mod tests { + use std::{ + convert::TryFrom, + time::{Duration, UNIX_EPOCH}, + }; + + use matches::assert_matches; + use ruma_identifiers::{EventId, RoomId, UserId}; + use serde_json::{from_value as from_json_value, json, to_value as to_json_value}; + + use super::{RedactionEvent, RedactionEventContent}; + use crate::{EventJson, UnsignedData}; + + #[test] + fn serialization() { + let event = RedactionEvent { + content: RedactionEventContent { + reason: Some("redacted because".into()), + }, + redacts: EventId::try_from("$h29iv0s8:example.com").unwrap(), + event_id: EventId::try_from("$h29iv0s8:example.com").unwrap(), + origin_server_ts: UNIX_EPOCH + Duration::from_millis(1), + room_id: RoomId::try_from("!roomid:room.com").unwrap(), + sender: UserId::try_from("@carl:example.com").unwrap(), + unsigned: UnsignedData::default(), + }; + + let json = json!({ + "content": { + "reason": "redacted because" + }, + "event_id": "$h29iv0s8:example.com", + "origin_server_ts": 1, + "redacts": "$h29iv0s8:example.com", + "room_id": "!roomid:room.com", + "sender": "@carl:example.com", + "type": "m.room.redaction", + }); + + assert_eq!(to_json_value(&event).unwrap(), json); + } + + #[test] + fn deserialization() { + let e_id = EventId::try_from("$h29iv0s8:example.com").unwrap(); + + let json = json!({ + "content": { + "reason": "redacted because" + }, + "event_id": "$h29iv0s8:example.com", + "origin_server_ts": 1, + "redacts": "$h29iv0s8:example.com", + "room_id": "!roomid:room.com", + "sender": "@carl:example.com", + "type": "m.room.redaction", + }); + + assert_matches!( + from_json_value::>(json) + .unwrap() + .deserialize() + .unwrap(), + RedactionEvent { + content: RedactionEventContent { + reason: Some(reason), + }, + sender, + event_id, origin_server_ts, redacts, room_id, unsigned, + } if reason == "redacted because" && redacts == e_id + && event_id == e_id + && sender == "@carl:example.com" + && origin_server_ts == UNIX_EPOCH + Duration::from_millis(1) + && room_id == RoomId::try_from("!roomid:room.com").unwrap() + && unsigned.is_empty() + ); + } +} diff --git a/ruma-events/src/room/server_acl.rs b/ruma-events/src/room/server_acl.rs new file mode 100644 index 00000000..70d446fa --- /dev/null +++ b/ruma-events/src/room/server_acl.rs @@ -0,0 +1,73 @@ +//! Types for the *m.room.server_acl* event. + +use ruma_events_macros::StateEventContent; +use serde::{Deserialize, Serialize}; + +use crate::StateEvent; + +/// An event to indicate which servers are permitted to participate in the room. +pub type ServerAclEvent = StateEvent; + +/// The payload for `ServerAclEvent`. +#[derive(Clone, Debug, Deserialize, Serialize, StateEventContent)] +#[ruma_event(type = "m.room.server_acl")] +pub struct ServerAclEventContent { + /// True to allow server names that are IP address literals. False to deny. + /// + /// This is strongly recommended to be set to false as servers running with IP literal + /// names are strongly discouraged in order to require legitimate homeservers to be + /// backed by a valid registered domain name. + #[serde( + default = "ruma_serde::default_true", + skip_serializing_if = "ruma_serde::is_true" + )] + pub allow_ip_literals: bool, + + /// The server names to allow in the room, excluding any port information. Wildcards may + /// be used to cover a wider range of hosts, where `*` matches zero or more characters + /// and `?` matches exactly one character. + /// + /// **This defaults to an empty list when not provided, effectively disallowing every + /// server.** + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub allow: Vec, + + /// The server names to disallow in the room, excluding any port information. Wildcards may + /// be used to cover a wider range of hosts, where * matches zero or more characters and ? + /// matches exactly one character. + /// + /// This defaults to an empty list when not provided. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub deny: Vec, +} + +#[cfg(test)] +mod tests { + use serde_json::{from_value as from_json_value, json}; + + use super::ServerAclEventContent; + use crate::{EventJson, StateEvent}; + + #[test] + fn default_values() { + let json_data = json!({ + "content": {}, + "event_id": "$h29iv0s8:example.com", + "origin_server_ts": 1, + "room_id": "!n8f893n9:example.com", + "sender": "@carl:example.com", + "state_key": "", + "type": "m.room.server_acl" + }); + + let server_acl_event = + from_json_value::>>(json_data) + .unwrap() + .deserialize() + .unwrap(); + + assert_eq!(server_acl_event.content.allow_ip_literals, true); + assert!(server_acl_event.content.allow.is_empty()); + assert!(server_acl_event.content.deny.is_empty()); + } +} diff --git a/ruma-events/src/room/third_party_invite.rs b/ruma-events/src/room/third_party_invite.rs new file mode 100644 index 00000000..f32657f7 --- /dev/null +++ b/ruma-events/src/room/third_party_invite.rs @@ -0,0 +1,45 @@ +//! Types for the *m.room.third_party_invite* event. + +use ruma_events_macros::StateEventContent; +use serde::{Deserialize, Serialize}; + +use crate::StateEvent; + +/// An invitation to a room issued to a third party identifier, rather than a matrix user ID. +/// +/// Acts as an *m.room.member* invite event, where there isn't a target user_id to invite. This +/// event contains a token and a public key whose private key must be used to sign the token. +/// Any user who can present that signature may use this invitation to join the target room. +pub type ThirdPartyInviteEvent = StateEvent; + +/// The payload for `ThirdPartyInviteEvent`. +#[derive(Clone, Debug, Deserialize, Serialize, StateEventContent)] +#[ruma_event(type = "m.room.third_party_invite")] +pub struct ThirdPartyInviteEventContent { + /// A user-readable string which represents the user who has been invited. + pub display_name: String, + + /// A URL which can be fetched to validate whether the key has been revoked. + pub key_validity_url: String, + + /// A Base64-encoded Ed25519 key with which the token must be signed. + pub public_key: String, + + /// Keys with which the token may be signed. + #[serde(skip_serializing_if = "Option::is_none")] + pub public_keys: Option>, +} + +/// A public key for signing a third party invite token. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct PublicKey { + /// An optional URL which can be fetched to validate whether the key has been revoked. + /// + /// The URL must return a JSON object containing a boolean property named 'valid'. + /// If this URL is absent, the key must be considered valid indefinitely. + #[serde(skip_serializing_if = "Option::is_none")] + pub key_validity_url: Option, + + /// A Base64-encoded Ed25519 key with which the token must be signed. + pub public_key: String, +} diff --git a/ruma-events/src/room/tombstone.rs b/ruma-events/src/room/tombstone.rs new file mode 100644 index 00000000..2ab25eef --- /dev/null +++ b/ruma-events/src/room/tombstone.rs @@ -0,0 +1,22 @@ +//! Types for the *m.room.tombstone* event. + +use ruma_events_macros::StateEventContent; +use ruma_identifiers::RoomId; +use serde::{Deserialize, Serialize}; + +use crate::StateEvent; + +/// A state event signifying that a room has been upgraded to a different room version, and that +/// clients should go there. +pub type TombstoneEvent = StateEvent; + +/// The payload for `TombstoneEvent`. +#[derive(Clone, Debug, Deserialize, Serialize, StateEventContent)] +#[ruma_event(type = "m.room.tombstone")] +pub struct TombstoneEventContent { + /// A server-defined message. + pub body: String, + + /// The new room the client should be visiting. + pub replacement_room: RoomId, +} diff --git a/ruma-events/src/room/topic.rs b/ruma-events/src/room/topic.rs new file mode 100644 index 00000000..434f66f9 --- /dev/null +++ b/ruma-events/src/room/topic.rs @@ -0,0 +1,17 @@ +//! Types for the *m.room.topic* event. + +use ruma_events_macros::StateEventContent; +use serde::{Deserialize, Serialize}; + +use crate::StateEvent; + +/// A topic is a short message detailing what is currently being discussed in the room. +pub type TopicEvent = StateEvent; + +/// The payload for `TopicEvent`. +#[derive(Clone, Debug, Deserialize, Serialize, StateEventContent)] +#[ruma_event(type = "m.room.topic")] +pub struct TopicEventContent { + /// The topic text. + pub topic: String, +} diff --git a/ruma-events/src/room_key.rs b/ruma-events/src/room_key.rs new file mode 100644 index 00000000..dc7f7c77 --- /dev/null +++ b/ruma-events/src/room_key.rs @@ -0,0 +1,68 @@ +//! Types for the *m.room_key* event. + +use ruma_events_macros::BasicEventContent; +use ruma_identifiers::RoomId; +use serde::{Deserialize, Serialize}; + +use super::Algorithm; +use crate::BasicEvent; + +/// This event type is used to exchange keys for end-to-end encryption. +/// +/// Typically it is encrypted as an *m.room.encrypted* event, then sent as a to-device event. +pub type RoomKeyEvent = BasicEvent; + +/// The payload for `RoomKeyEvent`. +#[derive(Clone, Debug, Deserialize, Serialize, BasicEventContent)] +#[ruma_event(type = "m.room_key")] +pub struct RoomKeyEventContent { + /// The encryption algorithm the key in this event is to be used with. + /// + /// Must be `m.megolm.v1.aes-sha2`. + pub algorithm: Algorithm, + + /// The room where the key is used. + pub room_id: RoomId, + + /// The ID of the session that the key is for. + pub session_id: String, + + /// The key to be exchanged. + pub session_key: String, +} + +#[cfg(test)] +mod tests { + use std::convert::TryFrom; + + use ruma_identifiers::RoomId; + use serde_json::{json, to_value as to_json_value}; + + use super::RoomKeyEventContent; + use crate::{Algorithm, BasicEvent}; + + #[test] + fn serialization() { + let ev = BasicEvent { + content: RoomKeyEventContent { + algorithm: Algorithm::MegolmV1AesSha2, + room_id: RoomId::try_from("!testroomid:example.org").unwrap(), + session_id: "SessId".into(), + session_key: "SessKey".into(), + }, + }; + + assert_eq!( + to_json_value(ev).unwrap(), + json!({ + "type": "m.room_key", + "content": { + "algorithm": "m.megolm.v1.aes-sha2", + "room_id": "!testroomid:example.org", + "session_id": "SessId", + "session_key": "SessKey", + }, + }) + ); + } +} diff --git a/ruma-events/src/room_key_request.rs b/ruma-events/src/room_key_request.rs new file mode 100644 index 00000000..b8510965 --- /dev/null +++ b/ruma-events/src/room_key_request.rs @@ -0,0 +1,66 @@ +//! Types for the *m.room_key_request* event. + +use ruma_events_macros::BasicEventContent; +use ruma_identifiers::{DeviceId, RoomId}; +use serde::{Deserialize, Serialize}; +use strum::{Display, EnumString}; + +use super::Algorithm; +use crate::BasicEvent; + +/// This event type is used to request keys for end-to-end encryption. +/// +/// It is sent as an unencrypted to-device event. +pub type RoomKeyRequestEvent = BasicEvent; + +/// The payload for `RoomKeyRequestEvent`. +#[derive(Clone, Debug, Deserialize, Serialize, BasicEventContent)] +#[ruma_event(type = "m.room_key_request")] +pub struct RoomKeyRequestEventContent { + /// Whether this is a new key request or a cancellation of a previous request. + pub action: Action, + + /// Information about the requested key. + /// + /// Required when action is `request`. + pub body: Option, + + /// ID of the device requesting the key. + pub requesting_device_id: DeviceId, + + /// A random string uniquely identifying the request for a key. + /// + /// If the key is requested multiple times, it should be reused. It should also reused + /// in order to cancel a request. + pub request_id: String, +} + +/// A new key request or a cancellation of a previous request. +#[derive(Clone, Copy, Debug, PartialEq, Display, EnumString, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +#[strum(serialize_all = "snake_case")] +pub enum Action { + /// Request a key. + Request, + + /// Cancel a request for a key. + #[serde(rename = "request_cancellation")] + #[strum(serialize = "request_cancellation")] + CancelRequest, +} + +/// Information about a requested key. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct RequestedKeyInfo { + /// The encryption algorithm the requested key in this event is to be used with. + pub algorithm: Algorithm, + + /// The room where the key is used. + pub room_id: RoomId, + + /// The Curve25519 key of the device which initiated the session originally. + pub sender_key: String, + + /// The ID of the session that the key is for. + pub session_id: String, +} diff --git a/ruma-events/src/sticker.rs b/ruma-events/src/sticker.rs new file mode 100644 index 00000000..df6a4868 --- /dev/null +++ b/ruma-events/src/sticker.rs @@ -0,0 +1,25 @@ +//! Types for the *m.sticker* event. + +use ruma_events_macros::MessageEventContent; +use serde::{Deserialize, Serialize}; + +use crate::{room::ImageInfo, MessageEvent}; + +/// A sticker message. +pub type StickerEvent = MessageEvent; + +/// The payload for `StickerEvent`. +#[derive(Clone, Debug, Deserialize, Serialize, MessageEventContent)] +#[ruma_event(type = "m.sticker")] +pub struct StickerEventContent { + /// A textual representation or associated description of the sticker image. This could + /// be the alt text of the original image, or a message to accompany and further + /// describe the sticker. + pub body: String, + + /// Metadata about the image referred to in `url` including a thumbnail representation. + pub info: ImageInfo, + + /// The URL to the sticker image. This must be a valid `mxc://` URI. + pub url: String, +} diff --git a/ruma-events/src/stripped.rs b/ruma-events/src/stripped.rs new file mode 100644 index 00000000..d331060d --- /dev/null +++ b/ruma-events/src/stripped.rs @@ -0,0 +1,310 @@ +//! "Stripped-down" versions of the core state events. +//! +//! Each "stripped" event includes only the `content`, `type`, and `state_key` fields of its full +//! version. These stripped types are useful for APIs where the user is providing the content of a +//! state event to be created, when the other fields can be inferred from a larger context, or where +//! the other fields are otherwise inapplicable. + +use ruma_identifiers::UserId; +use serde::{de::DeserializeOwned, Deserialize, Deserializer, Serialize}; +use serde_json::Value as JsonValue; + +use crate::{ + room::{ + aliases::AliasesEventContent, avatar::AvatarEventContent, + canonical_alias::CanonicalAliasEventContent, create::CreateEventContent, + guest_access::GuestAccessEventContent, history_visibility::HistoryVisibilityEventContent, + join_rules::JoinRulesEventContent, member::MemberEventContent, name::NameEventContent, + power_levels::PowerLevelsEventContent, third_party_invite::ThirdPartyInviteEventContent, + topic::TopicEventContent, + }, + util::get_field, + EventType, TryFromRaw, +}; + +/// A stripped-down version of a state event that is included along with some other events. +#[derive(Clone, Debug, Serialize)] +#[serde(untagged)] +#[allow(clippy::large_enum_variant)] +pub enum AnyStrippedStateEvent { + /// A stripped-down version of the *m.room.aliases* event. + RoomAliases(StrippedRoomAliases), + + /// A stripped-down version of the *m.room.avatar* event. + RoomAvatar(StrippedRoomAvatar), + + /// A stripped-down version of the *m.room.canonical_alias* event. + RoomCanonicalAlias(StrippedRoomCanonicalAlias), + + /// A striped-down version of the *m.room.create* event. + RoomCreate(StrippedRoomCreate), + + /// A stripped-down version of the *m.room.guest_access* event. + RoomGuestAccess(StrippedRoomGuestAccess), + + /// A stripped-down version of the *m.room.history_visibility* event. + RoomHistoryVisibility(StrippedRoomHistoryVisibility), + + /// A stripped-down version of the *m.room.join_rules* event. + RoomJoinRules(StrippedRoomJoinRules), + + /// A stripped-down version of the *m.room.member* event. + RoomMember(StrippedRoomMember), + + /// A stripped-down version of the *m.room.name* event. + RoomName(StrippedRoomName), + + /// A stripped-down version of the *m.room.power_levels* event. + RoomPowerLevels(StrippedRoomPowerLevels), + + /// A stripped-down version of the *m.room.third_party_invite* event. + RoomThirdPartyInvite(StrippedRoomThirdPartyInvite), + + /// A stripped-down version of the *m.room.topic* event. + RoomTopic(StrippedRoomTopic), +} + +/// A "stripped-down" version of a core state event. +#[derive(Clone, Debug, Serialize)] +pub struct StrippedStateEvent { + /// Data specific to the event type. + pub content: C, + + // FIXME(jplatte): It's unclear to me why this is stored + /// The type of the event. + #[serde(rename = "type")] + pub event_type: EventType, + + /// A key that determines which piece of room state the event represents. + pub state_key: String, + + /// The unique identifier for the user who sent this event. + pub sender: UserId, +} + +/// A stripped-down version of the *m.room.aliases* event. +pub type StrippedRoomAliases = StrippedStateEvent; + +/// A stripped-down version of the *m.room.avatar* event. +pub type StrippedRoomAvatar = StrippedStateEvent; + +/// A stripped-down version of the *m.room.canonical_alias* event. +pub type StrippedRoomCanonicalAlias = StrippedStateEvent; + +/// A stripped-down version of the *m.room.create* event. +pub type StrippedRoomCreate = StrippedStateEvent; + +/// A stripped-down version of the *m.room.guest_access* event. +pub type StrippedRoomGuestAccess = StrippedStateEvent; + +/// A stripped-down version of the *m.room.history_visibility* event. +pub type StrippedRoomHistoryVisibility = StrippedStateEvent; + +/// A stripped-down version of the *m.room.join_rules* event. +pub type StrippedRoomJoinRules = StrippedStateEvent; + +/// A stripped-down version of the *m.room.member* event. +pub type StrippedRoomMember = StrippedStateEvent; + +/// A stripped-down version of the *m.room.name* event. +pub type StrippedRoomName = StrippedStateEvent; + +/// A stripped-down version of the *m.room.power_levels* event. +pub type StrippedRoomPowerLevels = StrippedStateEvent; + +/// A stripped-down version of the *m.room.third_party_invite* event. +pub type StrippedRoomThirdPartyInvite = StrippedStateEvent; + +/// A stripped-down version of the *m.room.topic* event. +pub type StrippedRoomTopic = StrippedStateEvent; + +impl TryFromRaw for AnyStrippedStateEvent { + type Raw = raw::StrippedState; + type Err = String; + + fn try_from_raw(raw: raw::StrippedState) -> Result { + use crate::util::try_convert_variant as conv; + use raw::StrippedState::*; + + match raw { + RoomAliases(c) => conv(AnyStrippedStateEvent::RoomAliases, c), + RoomAvatar(c) => conv(AnyStrippedStateEvent::RoomAvatar, c), + RoomCanonicalAlias(c) => conv(AnyStrippedStateEvent::RoomCanonicalAlias, c), + RoomCreate(c) => conv(AnyStrippedStateEvent::RoomCreate, c), + RoomGuestAccess(c) => conv(AnyStrippedStateEvent::RoomGuestAccess, c), + RoomHistoryVisibility(c) => conv(AnyStrippedStateEvent::RoomHistoryVisibility, c), + RoomJoinRules(c) => conv(AnyStrippedStateEvent::RoomJoinRules, c), + RoomMember(c) => conv(AnyStrippedStateEvent::RoomMember, c), + RoomName(c) => conv(AnyStrippedStateEvent::RoomName, c), + RoomPowerLevels(c) => conv(AnyStrippedStateEvent::RoomPowerLevels, c), + RoomThirdPartyInvite(c) => conv(AnyStrippedStateEvent::RoomThirdPartyInvite, c), + RoomTopic(c) => conv(AnyStrippedStateEvent::RoomTopic, c), + } + } +} + +impl TryFromRaw for StrippedStateEvent +where + C: TryFromRaw, +{ + type Raw = StrippedStateEvent; + type Err = C::Err; + + fn try_from_raw(raw: StrippedStateEvent) -> Result { + Ok(Self { + content: C::try_from_raw(raw.content)?, + event_type: raw.event_type, + state_key: raw.state_key, + sender: raw.sender, + }) + } +} + +impl<'de, C> Deserialize<'de> for StrippedStateEvent +where + C: DeserializeOwned, +{ + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + // TODO: Optimize + let value = JsonValue::deserialize(deserializer)?; + + Ok(Self { + content: get_field(&value, "content")?, + event_type: get_field(&value, "type")?, + state_key: get_field(&value, "state_key")?, + sender: get_field(&value, "sender")?, + }) + } +} + +mod raw { + use serde::{Deserialize, Deserializer}; + use serde_json::Value as JsonValue; + + use super::StrippedStateEvent; + use crate::{ + room::{ + aliases::raw::AliasesEventContent, avatar::raw::AvatarEventContent, + canonical_alias::raw::CanonicalAliasEventContent, create::raw::CreateEventContent, + guest_access::raw::GuestAccessEventContent, + history_visibility::raw::HistoryVisibilityEventContent, + join_rules::raw::JoinRulesEventContent, member::raw::MemberEventContent, + name::raw::NameEventContent, power_levels::raw::PowerLevelsEventContent, + third_party_invite::raw::ThirdPartyInviteEventContent, topic::raw::TopicEventContent, + }, + util::get_field, + }; + + /// A stripped-down version of the *m.room.aliases* event. + pub type StrippedRoomAliases = StrippedStateEvent; + + /// A stripped-down version of the *m.room.avatar* event. + pub type StrippedRoomAvatar = StrippedStateEvent; + + /// A stripped-down version of the *m.room.canonical_alias* event. + pub type StrippedRoomCanonicalAlias = StrippedStateEvent; + + /// A stripped-down version of the *m.room.create* event. + pub type StrippedRoomCreate = StrippedStateEvent; + + /// A stripped-down version of the *m.room.guest_access* event. + pub type StrippedRoomGuestAccess = StrippedStateEvent; + + /// A stripped-down version of the *m.room.history_visibility* event. + pub type StrippedRoomHistoryVisibility = StrippedStateEvent; + + /// A stripped-down version of the *m.room.join_rules* event. + pub type StrippedRoomJoinRules = StrippedStateEvent; + + /// A stripped-down version of the *m.room.member* event. + pub type StrippedRoomMember = StrippedStateEvent; + + /// A stripped-down version of the *m.room.name* event. + pub type StrippedRoomName = StrippedStateEvent; + + /// A stripped-down version of the *m.room.power_levels* event. + pub type StrippedRoomPowerLevels = StrippedStateEvent; + + /// A stripped-down version of the *m.room.third_party_invite* event. + pub type StrippedRoomThirdPartyInvite = StrippedStateEvent; + + /// A stripped-down version of the *m.room.topic* event. + pub type StrippedRoomTopic = StrippedStateEvent; + + /// A stripped-down version of a state event that is included along with some other events. + #[derive(Clone, Debug)] + #[allow(clippy::large_enum_variant)] + pub enum StrippedState { + /// A stripped-down version of the *m.room.aliases* event. + RoomAliases(StrippedRoomAliases), + + /// A stripped-down version of the *m.room.avatar* event. + RoomAvatar(StrippedRoomAvatar), + + /// A stripped-down version of the *m.room.canonical_alias* event. + RoomCanonicalAlias(StrippedRoomCanonicalAlias), + + /// A striped-down version of the *m.room.create* event. + RoomCreate(StrippedRoomCreate), + + /// A stripped-down version of the *m.room.guest_access* event. + RoomGuestAccess(StrippedRoomGuestAccess), + + /// A stripped-down version of the *m.room.history_visibility* event. + RoomHistoryVisibility(StrippedRoomHistoryVisibility), + + /// A stripped-down version of the *m.room.join_rules* event. + RoomJoinRules(StrippedRoomJoinRules), + + /// A stripped-down version of the *m.room.member* event. + RoomMember(StrippedRoomMember), + + /// A stripped-down version of the *m.room.name* event. + RoomName(StrippedRoomName), + + /// A stripped-down version of the *m.room.power_levels* event. + RoomPowerLevels(StrippedRoomPowerLevels), + + /// A stripped-down version of the *m.room.third_party_invite* event. + RoomThirdPartyInvite(StrippedRoomThirdPartyInvite), + + /// A stripped-down version of the *m.room.topic* event. + RoomTopic(StrippedRoomTopic), + } + + impl<'de> Deserialize<'de> for StrippedState { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + use crate::{util::try_variant_from_value as from_value, EventType::*}; + use serde::de::Error as _; + + // TODO: Optimize + let value = JsonValue::deserialize(deserializer)?; + let event_type = get_field(&value, "type")?; + + match event_type { + RoomAliases => from_value(value, StrippedState::RoomAliases), + RoomAvatar => from_value(value, StrippedState::RoomAvatar), + RoomCanonicalAlias => from_value(value, StrippedState::RoomCanonicalAlias), + RoomCreate => from_value(value, StrippedState::RoomCreate), + RoomGuestAccess => from_value(value, StrippedState::RoomGuestAccess), + RoomHistoryVisibility => from_value(value, StrippedState::RoomHistoryVisibility), + RoomJoinRules => from_value(value, StrippedState::RoomJoinRules), + RoomMember => from_value(value, StrippedState::RoomMember), + RoomName => from_value(value, StrippedState::RoomName), + RoomPowerLevels => from_value(value, StrippedState::RoomPowerLevels), + RoomThirdPartyInvite => from_value(value, StrippedState::RoomThirdPartyInvite), + RoomTopic => from_value(value, StrippedState::RoomTopic), + _ => Err(D::Error::custom("not a state event")), + } + } + } +} + +#[cfg(test)] +mod tests {} diff --git a/ruma-events/src/tag.rs b/ruma-events/src/tag.rs new file mode 100644 index 00000000..3b396445 --- /dev/null +++ b/ruma-events/src/tag.rs @@ -0,0 +1,27 @@ +//! Types for the *m.tag* event. + +use std::collections::BTreeMap; + +use ruma_events_macros::BasicEventContent; +use serde::{Deserialize, Serialize}; + +use crate::BasicEvent; + +/// Informs the client of tags on a room. +pub type TagEvent = BasicEvent; + +/// The payload for `TagEvent`. +#[derive(Clone, Debug, Deserialize, Serialize, BasicEventContent)] +#[ruma_event(type = "m.tag")] +pub struct TagEventContent { + /// A map of tag names to tag info. + pub tags: BTreeMap, +} + +/// Information about a tag. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct TagInfo { + /// Value to use for lexicographically ordering rooms with this tag. + #[serde(skip_serializing_if = "Option::is_none")] + pub order: Option, +} diff --git a/ruma-events/src/typing.rs b/ruma-events/src/typing.rs new file mode 100644 index 00000000..a6fe6f7d --- /dev/null +++ b/ruma-events/src/typing.rs @@ -0,0 +1,18 @@ +//! Types for the *m.typing* event. + +use ruma_events_macros::EphemeralRoomEventContent; +use ruma_identifiers::UserId; +use serde::{Deserialize, Serialize}; + +use crate::EphemeralRoomEvent; + +/// Informs the client who is currently typing in a given room. +pub type TypingEvent = EphemeralRoomEvent; + +/// The payload for `TypingEvent`. +#[derive(Clone, Debug, Deserialize, Serialize, EphemeralRoomEventContent)] +#[ruma_event(type = "m.typing")] +pub struct TypingEventContent { + /// The list of user IDs typing in this room, if any. + pub user_ids: Vec, +} diff --git a/ruma-events/src/util.rs b/ruma-events/src/util.rs new file mode 100644 index 00000000..5736af7b --- /dev/null +++ b/ruma-events/src/util.rs @@ -0,0 +1,30 @@ +use serde::de::DeserializeOwned; +use serde_json::Value as JsonValue; + +pub fn try_variant_from_value(value: JsonValue, variant: fn(T) -> U) -> Result +where + T: DeserializeOwned, + E: serde::de::Error, +{ + serde_json::from_value(value) + .map(variant) + .map_err(serde_json_error_to_generic_de_error) +} + +pub fn serde_json_error_to_generic_de_error(error: serde_json::Error) -> E { + E::custom(error.to_string()) +} + +pub fn get_field(value: &JsonValue, field: &'static str) -> Result +where + T: DeserializeOwned, + E: serde::de::Error, +{ + serde_json::from_value( + value + .get(field) + .cloned() + .ok_or_else(|| E::missing_field(field))?, + ) + .map_err(serde_json_error_to_generic_de_error) +} diff --git a/ruma-events/tests/ephemeral_event.rs b/ruma-events/tests/ephemeral_event.rs new file mode 100644 index 00000000..1074e4bf --- /dev/null +++ b/ruma-events/tests/ephemeral_event.rs @@ -0,0 +1,129 @@ +use std::{ + convert::TryFrom, + time::{Duration, UNIX_EPOCH}, +}; + +use maplit::btreemap; +use matches::assert_matches; +use ruma_identifiers::{EventId, RoomId, UserId}; +use serde_json::{from_value as from_json_value, json, to_value as to_json_value}; + +use ruma_events::{ + receipt::{Receipt, ReceiptEventContent, Receipts}, + typing::TypingEventContent, + AnyEphemeralRoomEventContent, EphemeralRoomEvent, EventJson, +}; + +#[test] +fn ephemeral_serialize_typing() { + let aliases_event = EphemeralRoomEvent { + content: AnyEphemeralRoomEventContent::Typing(TypingEventContent { + user_ids: vec![UserId::try_from("@carl:example.com").unwrap()], + }), + room_id: RoomId::try_from("!roomid:room.com").unwrap(), + }; + + let actual = to_json_value(&aliases_event).unwrap(); + let expected = json!({ + "content": { + "user_ids": [ "@carl:example.com" ] + }, + "room_id": "!roomid:room.com", + "type": "m.typing", + }); + + assert_eq!(actual, expected); +} + +#[test] +fn deserialize_ephemeral_typing() { + let json_data = json!({ + "content": { + "user_ids": [ "@carl:example.com" ] + }, + "room_id": "!roomid:room.com", + "type": "m.typing" + }); + + assert_matches!( + from_json_value::>>(json_data) + .unwrap() + .deserialize() + .unwrap(), + EphemeralRoomEvent { + content: AnyEphemeralRoomEventContent::Typing(TypingEventContent { + user_ids, + }), + room_id, + } if user_ids[0] == UserId::try_from("@carl:example.com").unwrap() + && room_id == RoomId::try_from("!roomid:room.com").unwrap() + ); +} + +#[test] +fn ephemeral_serialize_receipt() { + let event_id = EventId::try_from("$h29iv0s8:example.com").unwrap(); + let user_id = UserId::try_from("@carl:example.com").unwrap(); + + let aliases_event = EphemeralRoomEvent { + content: AnyEphemeralRoomEventContent::Receipt(ReceiptEventContent(btreemap! { + event_id => Receipts { + read: Some(btreemap! { + user_id => Receipt { ts: Some(UNIX_EPOCH + Duration::from_millis(1)) }, + }), + }, + })), + room_id: RoomId::try_from("!roomid:room.com").unwrap(), + }; + + let actual = to_json_value(&aliases_event).unwrap(); + let expected = json!({ + "content": { + "$h29iv0s8:example.com": { + "m.read": { + "@carl:example.com": { "ts": 1 } + } + } + }, + "room_id": "!roomid:room.com", + "type": "m.receipt" + }); + + assert_eq!(actual, expected); +} + +#[test] +fn deserialize_ephemeral_receipt() { + let event_id = EventId::try_from("$h29iv0s8:example.com").unwrap(); + let user_id = UserId::try_from("@carl:example.com").unwrap(); + + let json_data = json!({ + "content": { + "$h29iv0s8:example.com": { + "m.read": { + "@carl:example.com": { "ts": 1 } + } + } + }, + "room_id": "!roomid:room.com", + "type": "m.receipt" + }); + + assert_matches!( + from_json_value::>>(json_data) + .unwrap() + .deserialize() + .unwrap(), + EphemeralRoomEvent { + content: AnyEphemeralRoomEventContent::Receipt(ReceiptEventContent(receipts)), + room_id, + } if !receipts.is_empty() && receipts.contains_key(&event_id) + && room_id == RoomId::try_from("!roomid:room.com").unwrap() + && receipts + .get(&event_id) + .map(|r| r.read.as_ref().unwrap().get(&user_id).unwrap().clone()) + .map(|r| r.ts) + .unwrap() + == Some(UNIX_EPOCH + Duration::from_millis(1)) + ); +} diff --git a/ruma-events/tests/event.rs b/ruma-events/tests/event.rs new file mode 100644 index 00000000..d9dfa4fa --- /dev/null +++ b/ruma-events/tests/event.rs @@ -0,0 +1,10 @@ +#[test] +fn ui() { + let t = trybuild::TestCases::new(); + // rustc overflows when compiling this see: + // https://github.com/rust-lang/rust/issues/55779 + // there is a workaround in the file. + t.pass("tests/ui/04-event-sanity-check.rs"); + t.compile_fail("tests/ui/05-named-fields.rs"); + t.compile_fail("tests/ui/06-no-content-field.rs"); +} diff --git a/ruma-events/tests/event_content.rs b/ruma-events/tests/event_content.rs new file mode 100644 index 00000000..ba0426f1 --- /dev/null +++ b/ruma-events/tests/event_content.rs @@ -0,0 +1,7 @@ +#[test] +fn ui() { + let t = trybuild::TestCases::new(); + t.pass("tests/ui/01-content-sanity-check.rs"); + t.compile_fail("tests/ui/02-no-event-type.rs"); + t.compile_fail("tests/ui/03-invalid-event-type.rs"); +} diff --git a/ruma-events/tests/event_content_enum.rs b/ruma-events/tests/event_content_enum.rs new file mode 100644 index 00000000..37dd3176 --- /dev/null +++ b/ruma-events/tests/event_content_enum.rs @@ -0,0 +1,6 @@ +#[test] +fn ui() { + let t = trybuild::TestCases::new(); + t.pass("tests/ui/07-enum-sanity-check.rs"); + t.compile_fail("tests/ui/08-enum-invalid-path.rs"); +} diff --git a/ruma-events/tests/message_event.rs b/ruma-events/tests/message_event.rs new file mode 100644 index 00000000..384acb68 --- /dev/null +++ b/ruma-events/tests/message_event.rs @@ -0,0 +1,223 @@ +use std::{ + convert::TryFrom, + time::{Duration, UNIX_EPOCH}, +}; + +use js_int::UInt; +use matches::assert_matches; +use ruma_events::{ + call::{answer::AnswerEventContent, SessionDescription, SessionDescriptionType}, + room::{ImageInfo, ThumbnailInfo}, + sticker::StickerEventContent, + AnyMessageEventContent, EventJson, MessageEvent, UnsignedData, +}; +use ruma_identifiers::{EventId, RoomId, UserId}; +use serde_json::{from_value as from_json_value, json, to_value as to_json_value}; + +#[test] +fn message_serialize_sticker() { + let aliases_event = MessageEvent { + content: AnyMessageEventContent::Sticker(StickerEventContent { + body: "Hello".into(), + info: ImageInfo { + height: UInt::new(423), + width: UInt::new(1011), + mimetype: Some("image/png".into()), + size: UInt::new(84242), + thumbnail_info: Some(Box::new(ThumbnailInfo { + width: UInt::new(800), + height: UInt::new(334), + mimetype: Some("image/png".into()), + size: UInt::new(82595), + })), + thumbnail_url: Some("mxc://matrix.org".into()), + thumbnail_file: None, + }, + url: "http://www.matrix.org".into(), + }), + event_id: EventId::try_from("$h29iv0s8:example.com").unwrap(), + origin_server_ts: UNIX_EPOCH + Duration::from_millis(1), + room_id: RoomId::try_from("!roomid:room.com").unwrap(), + sender: UserId::try_from("@carl:example.com").unwrap(), + unsigned: UnsignedData::default(), + }; + + let actual = to_json_value(&aliases_event).unwrap(); + let expected = json!({ + "content": { + "body": "Hello", + "info": { + "h": 423, + "mimetype": "image/png", + "size": 84242, + "thumbnail_info": { + "h": 334, + "mimetype": "image/png", + "size": 82595, + "w": 800 + }, + "thumbnail_url": "mxc://matrix.org", + "w": 1011 + }, + "url": "http://www.matrix.org" + }, + "event_id": "$h29iv0s8:example.com", + "origin_server_ts": 1, + "room_id": "!roomid:room.com", + "sender": "@carl:example.com", + "type": "m.sticker", + }); + + assert_eq!(actual, expected); +} + +#[test] +fn deserialize_message_call_answer_content() { + let json_data = json!({ + "answer": { + "type": "answer", + "sdp": "Hello" + }, + "call_id": "foofoo", + "version": 1 + }); + + assert_matches!( + from_json_value::>(json_data) + .unwrap() + .deserialize_content("m.call.answer") + .unwrap(), + AnyMessageEventContent::CallAnswer(AnswerEventContent { + answer: SessionDescription { + session_type: SessionDescriptionType::Answer, + sdp, + }, + call_id, + version, + }) if sdp == "Hello" && call_id == "foofoo" && version == UInt::new(1).unwrap() + ); +} + +#[test] +fn deserialize_message_call_answer() { + let json_data = json!({ + "content": { + "answer": { + "type": "answer", + "sdp": "Hello" + }, + "call_id": "foofoo", + "version": 1 + }, + "event_id": "$h29iv0s8:example.com", + "origin_server_ts": 1, + "room_id": "!roomid:room.com", + "sender": "@carl:example.com", + "type": "m.call.answer" + }); + + assert_matches!( + from_json_value::>>(json_data) + .unwrap() + .deserialize() + .unwrap(), + MessageEvent { + content: AnyMessageEventContent::CallAnswer(AnswerEventContent { + answer: SessionDescription { + session_type: SessionDescriptionType::Answer, + sdp, + }, + call_id, + version, + }), + event_id, + origin_server_ts, + room_id, + sender, + unsigned, + } if sdp == "Hello" && call_id == "foofoo" && version == UInt::new(1).unwrap() + && event_id == EventId::try_from("$h29iv0s8:example.com").unwrap() + && origin_server_ts == UNIX_EPOCH + Duration::from_millis(1) + && room_id == RoomId::try_from("!roomid:room.com").unwrap() + && sender == UserId::try_from("@carl:example.com").unwrap() + && unsigned.is_empty() + ); +} + +#[test] +fn deserialize_message_sticker() { + let json_data = json!({ + "content": { + "body": "Hello", + "info": { + "h": 423, + "mimetype": "image/png", + "size": 84242, + "thumbnail_info": { + "h": 334, + "mimetype": "image/png", + "size": 82595, + "w": 800 + }, + "thumbnail_url": "mxc://matrix.org", + "w": 1011 + }, + "url": "http://www.matrix.org" + }, + "event_id": "$h29iv0s8:example.com", + "origin_server_ts": 1, + "room_id": "!roomid:room.com", + "sender": "@carl:example.com", + "type": "m.sticker" + }); + + assert_matches!( + from_json_value::>>(json_data) + .unwrap() + .deserialize() + .unwrap(), + MessageEvent { + content: AnyMessageEventContent::Sticker(StickerEventContent { + body, + info: ImageInfo { + height, + width, + mimetype: Some(mimetype), + size, + thumbnail_info: Some(thumbnail_info), + thumbnail_url: Some(thumbnail_url), + thumbnail_file: None, + }, + url, + }), + event_id, + origin_server_ts, + room_id, + sender, + unsigned + } if event_id == EventId::try_from("$h29iv0s8:example.com").unwrap() + && body == "Hello" + && origin_server_ts == UNIX_EPOCH + Duration::from_millis(1) + && room_id == RoomId::try_from("!roomid:room.com").unwrap() + && sender == UserId::try_from("@carl:example.com").unwrap() + && height == UInt::new(423) + && width == UInt::new(1011) + && mimetype == "image/png" + && size == UInt::new(84242) + && thumbnail_url == "mxc://matrix.org" + && matches!( + thumbnail_info.as_ref(), + ThumbnailInfo { + width: thumb_width, + height: thumb_height, + mimetype: thumb_mimetype, + size: thumb_size, + } if *thumb_width == UInt::new(800) + && *thumb_height == UInt::new(334) + && *thumb_mimetype == Some("image/png".to_string()) + && *thumb_size == UInt::new(82595) + ) + && url == "http://www.matrix.org" + && unsigned.is_empty() + ); +} diff --git a/ruma-events/tests/state_event.rs b/ruma-events/tests/state_event.rs new file mode 100644 index 00000000..e19559cf --- /dev/null +++ b/ruma-events/tests/state_event.rs @@ -0,0 +1,220 @@ +use std::{ + convert::TryFrom, + time::{Duration, UNIX_EPOCH}, +}; + +use js_int::UInt; +use matches::assert_matches; +use ruma_events::{ + room::{aliases::AliasesEventContent, avatar::AvatarEventContent, ImageInfo, ThumbnailInfo}, + AnyStateEventContent, EventJson, StateEvent, UnsignedData, +}; +use ruma_identifiers::{EventId, RoomAliasId, RoomId, UserId}; +use serde_json::{from_value as from_json_value, json, to_value as to_json_value}; + +#[test] +fn serialize_aliases_with_prev_content() { + let aliases_event = StateEvent { + content: AnyStateEventContent::RoomAliases(AliasesEventContent { + aliases: vec![RoomAliasId::try_from("#somewhere:localhost").unwrap()], + }), + event_id: EventId::try_from("$h29iv0s8:example.com").unwrap(), + origin_server_ts: UNIX_EPOCH + Duration::from_millis(1), + prev_content: Some(AnyStateEventContent::RoomAliases(AliasesEventContent { + aliases: vec![RoomAliasId::try_from("#somewhere:localhost").unwrap()], + })), + room_id: RoomId::try_from("!roomid:room.com").unwrap(), + sender: UserId::try_from("@carl:example.com").unwrap(), + state_key: "".to_string(), + unsigned: UnsignedData::default(), + }; + + let actual = to_json_value(&aliases_event).unwrap(); + let expected = json!({ + "content": { + "aliases": [ "#somewhere:localhost" ] + }, + "event_id": "$h29iv0s8:example.com", + "origin_server_ts": 1, + "prev_content": { + "aliases": [ "#somewhere:localhost" ] + }, + "room_id": "!roomid:room.com", + "sender": "@carl:example.com", + "state_key": "", + "type": "m.room.aliases", + }); + + assert_eq!(actual, expected); +} + +#[test] +fn serialize_aliases_without_prev_content() { + let aliases_event = StateEvent { + content: AnyStateEventContent::RoomAliases(AliasesEventContent { + aliases: vec![RoomAliasId::try_from("#somewhere:localhost").unwrap()], + }), + event_id: EventId::try_from("$h29iv0s8:example.com").unwrap(), + origin_server_ts: UNIX_EPOCH + Duration::from_millis(1), + prev_content: None, + room_id: RoomId::try_from("!roomid:room.com").unwrap(), + sender: UserId::try_from("@carl:example.com").unwrap(), + state_key: "".to_string(), + unsigned: UnsignedData::default(), + }; + + let actual = to_json_value(&aliases_event).unwrap(); + let expected = json!({ + "content": { + "aliases": [ "#somewhere:localhost" ] + }, + "event_id": "$h29iv0s8:example.com", + "origin_server_ts": 1, + "room_id": "!roomid:room.com", + "sender": "@carl:example.com", + "state_key": "", + "type": "m.room.aliases", + }); + + assert_eq!(actual, expected); +} + +#[test] +fn deserialize_aliases_content() { + let json_data = json!({ + "aliases": [ "#somewhere:localhost" ] + }); + + assert_matches!( + from_json_value::>(json_data) + .unwrap() + .deserialize_content("m.room.aliases") + .unwrap(), + AnyStateEventContent::RoomAliases(content) + if content.aliases == vec![RoomAliasId::try_from("#somewhere:localhost").unwrap()] + ); +} + +#[test] +fn deserialize_aliases_with_prev_content() { + let json_data = json!({ + "content": { + "aliases": [ "#somewhere:localhost" ] + }, + "event_id": "$h29iv0s8:example.com", + "origin_server_ts": 1, + "prev_content": { + "aliases": [ "#inner:localhost" ] + }, + "room_id": "!roomid:room.com", + "sender": "@carl:example.com", + "state_key": "", + "type": "m.room.aliases" + }); + + assert_matches!( + from_json_value::>>(json_data) + .unwrap() + .deserialize() + .unwrap(), + StateEvent { + content: AnyStateEventContent::RoomAliases(content), + event_id, + origin_server_ts, + prev_content: Some(AnyStateEventContent::RoomAliases(prev_content)), + room_id, + sender, + state_key, + unsigned, + } if content.aliases == vec![RoomAliasId::try_from("#somewhere:localhost").unwrap()] + && event_id == EventId::try_from("$h29iv0s8:example.com").unwrap() + && origin_server_ts == UNIX_EPOCH + Duration::from_millis(1) + && prev_content.aliases == vec![RoomAliasId::try_from("#inner:localhost").unwrap()] + && room_id == RoomId::try_from("!roomid:room.com").unwrap() + && sender == UserId::try_from("@carl:example.com").unwrap() + && state_key == "" + && unsigned.is_empty() + ); +} + +#[test] +fn deserialize_avatar_without_prev_content() { + let json_data = json!({ + "content": { + "info": { + "h": 423, + "mimetype": "image/png", + "size": 84242, + "thumbnail_info": { + "h": 334, + "mimetype": "image/png", + "size": 82595, + "w": 800 + }, + "thumbnail_url": "mxc://matrix.org", + "w": 1011 + }, + "url": "http://www.matrix.org" + }, + "event_id": "$h29iv0s8:example.com", + "origin_server_ts": 1, + "room_id": "!roomid:room.com", + "sender": "@carl:example.com", + "state_key": "", + "type": "m.room.avatar" + }); + + assert_matches!( + from_json_value::>>(json_data) + .unwrap() + .deserialize() + .unwrap(), + StateEvent { + content: AnyStateEventContent::RoomAvatar(AvatarEventContent { + info: Some(info), + url, + }), + event_id, + origin_server_ts, + prev_content: None, + room_id, + sender, + state_key, + unsigned + } if event_id == EventId::try_from("$h29iv0s8:example.com").unwrap() + && origin_server_ts == UNIX_EPOCH + Duration::from_millis(1) + && room_id == RoomId::try_from("!roomid:room.com").unwrap() + && sender == UserId::try_from("@carl:example.com").unwrap() + && state_key == "" + && matches!( + info.as_ref(), + ImageInfo { + height, + width, + mimetype: Some(mimetype), + size, + thumbnail_info: Some(thumbnail_info), + thumbnail_url: Some(thumbnail_url), + thumbnail_file: None, + } if *height == UInt::new(423) + && *width == UInt::new(1011) + && *mimetype == "image/png" + && *size == UInt::new(84242) + && matches!( + thumbnail_info.as_ref(), + ThumbnailInfo { + width: thumb_width, + height: thumb_height, + mimetype: thumb_mimetype, + size: thumb_size, + } if *thumb_width == UInt::new(800) + && *thumb_height == UInt::new(334) + && *thumb_mimetype == Some("image/png".to_string()) + && *thumb_size == UInt::new(82595) + && *thumbnail_url == "mxc://matrix.org" + ) + ) + && url == "http://www.matrix.org" + && unsigned.is_empty() + ); +} diff --git a/ruma-events/tests/stripped.rs.bk b/ruma-events/tests/stripped.rs.bk new file mode 100644 index 00000000..389eb05b --- /dev/null +++ b/ruma-events/tests/stripped.rs.bk @@ -0,0 +1,138 @@ +use std::convert::TryFrom; + +use js_int::UInt; +use ruma_events::{ + room::{join_rules::JoinRule, topic::TopicEventContent}, + AnyStrippedStateEvent, EventJson, EventType, +}; +use ruma_identifiers::UserId; +use serde_json::{from_value as from_json_value, json, to_value as to_json_value}; + +#[test] +fn serialize_stripped_state_event() { + let content = StrippedRoomTopic { + content: TopicEventContent { + topic: "Testing room".to_string(), + }, + state_key: "".to_string(), + event_type: EventType::RoomTopic, + sender: UserId::try_from("@example:localhost").unwrap(), + }; + + let event = AnyStrippedStateEvent::RoomTopic(content); + + let json_data = json!({ + "content": { + "topic": "Testing room" + }, + "type": "m.room.topic", + "state_key": "", + "sender": "@example:localhost" + }); + + assert_eq!(to_json_value(&event).unwrap(), json_data); +} + +#[test] +fn deserialize_stripped_state_events() { + let name_event = json!({ + "type": "m.room.name", + "state_key": "", + "sender": "@example:localhost", + "content": { "name": "Ruma" } + }); + + let join_rules_event = json!({ + "type": "m.room.join_rules", + "state_key": "", + "sender": "@example:localhost", + "content": { "join_rule": "public" } + }); + + let avatar_event = json!({ + "type": "m.room.avatar", + "state_key": "", + "sender": "@example:localhost", + "content": { + "info": { + "h": 128, + "w": 128, + "mimetype": "image/jpeg", + "size": 1024, + "thumbnail_info": { + "h": 16, + "w": 16, + "mimetype": "image/jpeg", + "size": 32 + }, + "thumbnail_url": "https://example.com/image-thumbnail.jpg" + }, + "thumbnail_info": { + "h": 16, + "w": 16, + "mimetype": "image/jpeg", + "size": 32 + }, + "thumbnail_url": "https://example.com/image-thumbnail.jpg", + "url": "https://example.com/image.jpg" + } + }); + + match from_json_value::>(name_event.clone()) + .unwrap() + .deserialize() + .unwrap() + { + AnyStrippedStateEvent::RoomName(event) => { + assert_eq!(event.content.name, Some("Ruma".to_string())); + assert_eq!(event.event_type, EventType::RoomName); + assert_eq!(event.state_key, ""); + assert_eq!(event.sender.to_string(), "@example:localhost"); + } + _ => unreachable!(), + }; + + // Ensure `StrippedStateContent` can be parsed, not just `StrippedState`. + assert!(from_json_value::>(name_event) + .unwrap() + .deserialize() + .is_ok()); + + match from_json_value::>(join_rules_event) + .unwrap() + .deserialize() + .unwrap() + { + AnyStrippedStateEvent::RoomJoinRules(event) => { + assert_eq!(event.content.join_rule, JoinRule::Public); + assert_eq!(event.event_type, EventType::RoomJoinRules); + assert_eq!(event.state_key, ""); + assert_eq!(event.sender.to_string(), "@example:localhost"); + } + _ => unreachable!(), + }; + + match from_json_value::>(avatar_event) + .unwrap() + .deserialize() + .unwrap() + { + AnyStrippedStateEvent::RoomAvatar(event) => { + let image_info = event.content.info.unwrap(); + + assert_eq!(image_info.height.unwrap(), UInt::try_from(128).unwrap()); + assert_eq!(image_info.width.unwrap(), UInt::try_from(128).unwrap()); + assert_eq!(image_info.mimetype.unwrap(), "image/jpeg"); + assert_eq!(image_info.size.unwrap(), UInt::try_from(1024).unwrap()); + assert_eq!( + image_info.thumbnail_info.unwrap().size.unwrap(), + UInt::try_from(32).unwrap() + ); + assert_eq!(event.content.url, "https://example.com/image.jpg"); + assert_eq!(event.event_type, EventType::RoomAvatar); + assert_eq!(event.state_key, ""); + assert_eq!(event.sender.to_string(), "@example:localhost"); + } + _ => unreachable!(), + }; +} diff --git a/ruma-events/tests/to_device.rs b/ruma-events/tests/to_device.rs new file mode 100644 index 00000000..09d9a012 --- /dev/null +++ b/ruma-events/tests/to_device.rs @@ -0,0 +1,34 @@ +use std::convert::TryFrom; + +use ruma_events::{ + room_key::RoomKeyEventContent, Algorithm, AnyToDeviceEventContent, ToDeviceEvent, +}; +use ruma_identifiers::{RoomId, UserId}; +use serde_json::{json, to_value as to_json_value}; + +#[test] +fn serialization() { + let ev = ToDeviceEvent { + sender: UserId::try_from("@example:example.org").unwrap(), + content: AnyToDeviceEventContent::RoomKey(RoomKeyEventContent { + algorithm: Algorithm::MegolmV1AesSha2, + room_id: RoomId::try_from("!testroomid:example.org").unwrap(), + session_id: "SessId".into(), + session_key: "SessKey".into(), + }), + }; + + assert_eq!( + to_json_value(ev).unwrap(), + json!({ + "type": "m.room_key", + "sender": "@example:example.org", + "content": { + "algorithm": "m.megolm.v1.aes-sha2", + "room_id": "!testroomid:example.org", + "session_id": "SessId", + "session_key": "SessKey", + }, + }) + ); +} diff --git a/ruma-events/tests/ui/01-content-sanity-check.rs b/ruma-events/tests/ui/01-content-sanity-check.rs new file mode 100644 index 00000000..14a249d6 --- /dev/null +++ b/ruma-events/tests/ui/01-content-sanity-check.rs @@ -0,0 +1,10 @@ +use ruma_events_macros::StateEventContent; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Deserialize, Serialize, StateEventContent)] +#[ruma_event(type = "m.macro.test")] +pub struct MacroTest { + pub url: String, +} + +fn main() {} diff --git a/ruma-events/tests/ui/02-no-event-type.rs b/ruma-events/tests/ui/02-no-event-type.rs new file mode 100644 index 00000000..ed48296a --- /dev/null +++ b/ruma-events/tests/ui/02-no-event-type.rs @@ -0,0 +1,9 @@ +use ruma_events_macros::StateEventContent; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Deserialize, Serialize, StateEventContent)] +pub struct MacroTest { + pub url: String, +} + +fn main() {} diff --git a/ruma-events/tests/ui/02-no-event-type.stderr b/ruma-events/tests/ui/02-no-event-type.stderr new file mode 100644 index 00000000..3ec4901f --- /dev/null +++ b/ruma-events/tests/ui/02-no-event-type.stderr @@ -0,0 +1,7 @@ +error: no event type attribute found, add `#[ruma_event(type = "any.room.event")]` below the event content derive + --> $DIR/02-no-event-type.rs:4:48 + | +4 | #[derive(Clone, Debug, Deserialize, Serialize, StateEventContent)] + | ^^^^^^^^^^^^^^^^^ + | + = note: this error originates in a derive macro (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/ruma-events/tests/ui/03-invalid-event-type.rs b/ruma-events/tests/ui/03-invalid-event-type.rs new file mode 100644 index 00000000..4c1c835f --- /dev/null +++ b/ruma-events/tests/ui/03-invalid-event-type.rs @@ -0,0 +1,16 @@ +use ruma_events_macros::StateEventContent; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Deserialize, Serialize, StateEventContent)] +#[not_ruma_event(type = "m.macro.test")] +pub struct MacroTest { + pub test: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize, StateEventContent)] +#[ruma_event(event = "m.macro.test")] +pub struct MoreMacroTest { + pub test: String, +} + +fn main() {} diff --git a/ruma-events/tests/ui/03-invalid-event-type.stderr b/ruma-events/tests/ui/03-invalid-event-type.stderr new file mode 100644 index 00000000..d4fc4a3b --- /dev/null +++ b/ruma-events/tests/ui/03-invalid-event-type.stderr @@ -0,0 +1,19 @@ +error: expected `type` + --> $DIR/03-invalid-event-type.rs:11:14 + | +11 | #[ruma_event(event = "m.macro.test")] + | ^^^^^ + +error: no event type attribute found, add `#[ruma_event(type = "any.room.event")]` below the event content derive + --> $DIR/03-invalid-event-type.rs:4:48 + | +4 | #[derive(Clone, Debug, Deserialize, Serialize, StateEventContent)] + | ^^^^^^^^^^^^^^^^^ + | + = note: this error originates in a derive macro (in Nightly builds, run with -Z macro-backtrace for more info) + +error: cannot find attribute `not_ruma_event` in this scope + --> $DIR/03-invalid-event-type.rs:5:3 + | +5 | #[not_ruma_event(type = "m.macro.test")] + | ^^^^^^^^^^^^^^ help: a derive helper attribute with a similar name exists: `ruma_event` diff --git a/ruma-events/tests/ui/04-event-sanity-check.rs b/ruma-events/tests/ui/04-event-sanity-check.rs new file mode 100644 index 00000000..4bcd45b9 --- /dev/null +++ b/ruma-events/tests/ui/04-event-sanity-check.rs @@ -0,0 +1,16 @@ +// rustc overflows when compiling this see: +// https://github.com/rust-lang/rust/issues/55779 +extern crate serde; + +use ruma_events::StateEventContent; +use ruma_events_macros::Event; + +/// State event. +#[derive(Clone, Debug, Event)] +pub struct StateEvent { + pub content: C, + pub state_key: String, + pub prev_content: Option, +} + +fn main() {} diff --git a/ruma-events/tests/ui/05-named-fields.rs b/ruma-events/tests/ui/05-named-fields.rs new file mode 100644 index 00000000..42434448 --- /dev/null +++ b/ruma-events/tests/ui/05-named-fields.rs @@ -0,0 +1,8 @@ +use ruma_events::StateEventContent; +use ruma_events_macros::Event; + +/// State event. +#[derive(Clone, Debug, Event)] +pub struct StateEvent(C); + +fn main() {} diff --git a/ruma-events/tests/ui/05-named-fields.stderr b/ruma-events/tests/ui/05-named-fields.stderr new file mode 100644 index 00000000..a999c51b --- /dev/null +++ b/ruma-events/tests/ui/05-named-fields.stderr @@ -0,0 +1,5 @@ +error: the `Event` derive only supports named fields + --> $DIR/05-named-fields.rs:6:44 + | +6 | pub struct StateEvent(C); + | ^^^ diff --git a/ruma-events/tests/ui/06-no-content-field.rs b/ruma-events/tests/ui/06-no-content-field.rs new file mode 100644 index 00000000..0795d6b3 --- /dev/null +++ b/ruma-events/tests/ui/06-no-content-field.rs @@ -0,0 +1,10 @@ +use ruma_events::StateEventContent; +use ruma_events_macros::Event; + +/// State event. +#[derive(Clone, Debug, Event)] +pub struct StateEvent { + pub not_content: C, +} + +fn main() {} diff --git a/ruma-events/tests/ui/06-no-content-field.stderr b/ruma-events/tests/ui/06-no-content-field.stderr new file mode 100644 index 00000000..361f94a0 --- /dev/null +++ b/ruma-events/tests/ui/06-no-content-field.stderr @@ -0,0 +1,7 @@ +error: struct must contain a `content` field + --> $DIR/06-no-content-field.rs:5:24 + | +5 | #[derive(Clone, Debug, Event)] + | ^^^^^ + | + = note: this error originates in a derive macro (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/ruma-events/tests/ui/07-enum-sanity-check.rs b/ruma-events/tests/ui/07-enum-sanity-check.rs new file mode 100644 index 00000000..916d7570 --- /dev/null +++ b/ruma-events/tests/ui/07-enum-sanity-check.rs @@ -0,0 +1,15 @@ +use ruma_events_macros::event_content_enum; + +event_content_enum! { + /// Any basic event. + name: AnyBasicEventContent, + events: [ + "m.direct", + "m.dummy", + "m.ignored_user_list", + "m.push_rules", + "m.room_key", + ] +} + +fn main() {} diff --git a/ruma-events/tests/ui/08-enum-invalid-path.rs b/ruma-events/tests/ui/08-enum-invalid-path.rs new file mode 100644 index 00000000..8d9a6bf1 --- /dev/null +++ b/ruma-events/tests/ui/08-enum-invalid-path.rs @@ -0,0 +1,17 @@ +use ruma_events_macros::event_content_enum; + +event_content_enum! { + name: InvalidEvent, + events: [ + "m.not.a.path", + ] +} + +event_content_enum! { + name: InvalidEvent, + events: [ + "not.a.path", + ] +} + +fn main() {} diff --git a/ruma-events/tests/ui/08-enum-invalid-path.stderr b/ruma-events/tests/ui/08-enum-invalid-path.stderr new file mode 100644 index 00000000..fe3b34c6 --- /dev/null +++ b/ruma-events/tests/ui/08-enum-invalid-path.stderr @@ -0,0 +1,18 @@ +error: proc macro panicked + --> $DIR/08-enum-invalid-path.rs:10:1 + | +10 | / event_content_enum! { +11 | | name: InvalidEvent, +12 | | events: [ +13 | | "not.a.path", +14 | | ] +15 | | } + | |_^ + | + = help: message: well-known matrix events have to start with `m.` found `not.a.path` + +error[E0433]: failed to resolve: could not find `not` in `ruma_events` + --> $DIR/08-enum-invalid-path.rs:6:9 + | +6 | "m.not.a.path", + | ^^^^^^^^^^^^^^ could not find `not` in `ruma_events`