use std::{borrow::Cow, fmt};
use ruma_macros::EventContent;
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use serde_json::Value as JsonValue;
use crate::{
serde::{JsonObject, StringEnum},
OwnedEventId, PrivOwnedStr,
};
mod audio;
mod content_serde;
mod emote;
mod file;
mod image;
mod key_verification_request;
mod location;
mod notice;
mod relation_serde;
mod reply;
pub mod sanitize;
mod server_notice;
mod text;
mod video;
pub use audio::{AudioInfo, AudioMessageEventContent};
pub use emote::EmoteMessageEventContent;
pub use file::{FileInfo, FileMessageEventContent};
pub use image::ImageMessageEventContent;
pub use key_verification_request::KeyVerificationRequestEventContent;
pub use location::{LocationInfo, LocationMessageEventContent};
pub use notice::NoticeMessageEventContent;
#[cfg(feature = "unstable-sanitize")]
use sanitize::{
remove_plain_reply_fallback, sanitize_html, HtmlSanitizerMode, RemoveReplyFallback,
};
pub use server_notice::{LimitType, ServerNoticeMessageEventContent, ServerNoticeType};
pub use text::TextMessageEventContent;
pub use video::{VideoInfo, VideoMessageEventContent};
#[derive(Clone, Debug, Serialize, EventContent)]
#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)]
#[ruma_event(type = "m.room.message", kind = MessageLike)]
pub struct RoomMessageEventContent {
#[serde(flatten)]
pub msgtype: MessageType,
#[serde(flatten, skip_serializing_if = "Option::is_none")]
pub relates_to: Option<Relation>,
}
impl RoomMessageEventContent {
pub fn new(msgtype: MessageType) -> Self {
Self { msgtype, relates_to: None }
}
pub fn text_plain(body: impl Into<String>) -> Self {
Self::new(MessageType::Text(TextMessageEventContent::plain(body)))
}
pub fn text_html(body: impl Into<String>, html_body: impl Into<String>) -> Self {
Self::new(MessageType::Text(TextMessageEventContent::html(body, html_body)))
}
#[cfg(feature = "markdown")]
pub fn text_markdown(body: impl AsRef<str> + Into<String>) -> Self {
Self::new(MessageType::Text(TextMessageEventContent::markdown(body)))
}
pub fn notice_plain(body: impl Into<String>) -> Self {
Self::new(MessageType::Notice(NoticeMessageEventContent::plain(body)))
}
pub fn notice_html(body: impl Into<String>, html_body: impl Into<String>) -> Self {
Self::new(MessageType::Notice(NoticeMessageEventContent::html(body, html_body)))
}
#[cfg(feature = "markdown")]
pub fn notice_markdown(body: impl AsRef<str> + Into<String>) -> Self {
Self::new(MessageType::Notice(NoticeMessageEventContent::markdown(body)))
}
#[doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/doc/rich_reply.md"))]
#[track_caller]
pub fn make_reply_to(mut self, original_message: &OriginalRoomMessageEvent) -> Self {
let empty_formatted_body = || FormattedBody::html(String::new());
let (body, formatted) = {
match &mut self.msgtype {
MessageType::Emote(m) => {
(&mut m.body, Some(m.formatted.get_or_insert_with(empty_formatted_body)))
}
MessageType::Notice(m) => {
(&mut m.body, Some(m.formatted.get_or_insert_with(empty_formatted_body)))
}
MessageType::Text(m) => {
(&mut m.body, Some(m.formatted.get_or_insert_with(empty_formatted_body)))
}
MessageType::Audio(m) => (&mut m.body, None),
MessageType::File(m) => (&mut m.body, None),
MessageType::Image(m) => (&mut m.body, None),
MessageType::Location(m) => (&mut m.body, None),
MessageType::ServerNotice(m) => (&mut m.body, None),
MessageType::Video(m) => (&mut m.body, None),
MessageType::VerificationRequest(m) => (&mut m.body, None),
MessageType::_Custom(m) => (&mut m.body, None),
}
};
if let Some(f) = formatted {
assert_eq!(
f.format,
MessageFormat::Html,
"make_reply_to can't handle non-HTML formatted messages"
);
let formatted_body = &mut f.body;
(*body, *formatted_body) = reply::plain_and_formatted_reply_body(
body.as_str(),
(!formatted_body.is_empty()).then(|| formatted_body.as_str()),
original_message,
);
}
self.relates_to = Some(Relation::Reply {
in_reply_to: InReplyTo { event_id: original_message.event_id.to_owned() },
});
self
}
#[doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/doc/rich_reply.md"))]
#[deprecated = "\
use [`Self::text_plain`](#method.text_plain)`(reply).`\
[`make_reply_to`](#method.make_reply_to)`(original_message)` instead\
"]
pub fn text_reply_plain(
reply: impl fmt::Display,
original_message: &OriginalRoomMessageEvent,
) -> Self {
Self::text_plain(reply.to_string()).make_reply_to(original_message)
}
#[doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/doc/rich_reply.md"))]
#[deprecated = "\
use [`Self::text_html`](#method.text_html)`(reply, html_reply).`\
[`make_reply_to`](#method.make_reply_to)`(original_message)` instead\
"]
pub fn text_reply_html(
reply: impl fmt::Display,
html_reply: impl fmt::Display,
original_message: &OriginalRoomMessageEvent,
) -> Self {
Self::text_html(reply.to_string(), html_reply.to_string()).make_reply_to(original_message)
}
#[doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/doc/rich_reply.md"))]
#[deprecated = "\
use [`Self::notice_plain`](#method.notice_plain)`(reply).`\
[`make_reply_to`](#method.make_reply_to)`(original_message)` instead\
"]
pub fn notice_reply_plain(
reply: impl fmt::Display,
original_message: &OriginalRoomMessageEvent,
) -> Self {
Self::notice_plain(reply.to_string()).make_reply_to(original_message)
}
#[doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/doc/rich_reply.md"))]
#[deprecated = "\
use [`Self::notice_html`](#method.notice_html)`(reply, html_reply).`\
[`make_reply_to`](#method.make_reply_to)`(original_message)` instead\
"]
pub fn notice_reply_html(
reply: impl fmt::Display,
html_reply: impl fmt::Display,
original_message: &OriginalRoomMessageEvent,
) -> Self {
Self::notice_html(reply.to_string(), html_reply.to_string()).make_reply_to(original_message)
}
#[doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/doc/rich_reply.md"))]
#[cfg(feature = "unstable-msc3440")]
pub fn reply(
message: MessageType,
original_message: &OriginalRoomMessageEvent,
forward_thread: ForwardThread,
) -> Self {
let make_reply = |body, formatted: Option<FormattedBody>| {
reply::plain_and_formatted_reply_body(body, formatted.map(|f| f.body), original_message)
};
let msgtype = match message {
MessageType::Text(TextMessageEventContent { body, formatted, .. }) => {
let (body, html_body) = make_reply(body, formatted);
MessageType::Text(TextMessageEventContent::html(body, html_body))
}
MessageType::Emote(EmoteMessageEventContent { body, formatted, .. }) => {
let (body, html_body) = make_reply(body, formatted);
MessageType::Emote(EmoteMessageEventContent::html(body, html_body))
}
MessageType::Notice(NoticeMessageEventContent { body, formatted, .. }) => {
let (body, html_body) = make_reply(body, formatted);
MessageType::Notice(NoticeMessageEventContent::html(body, html_body))
}
_ => message,
};
let relates_to = if let Some(Relation::Thread(Thread { event_id, .. })) = original_message
.content
.relates_to
.as_ref()
.filter(|_| forward_thread == ForwardThread::Yes)
{
Relation::Thread(Thread::reply(event_id.clone(), original_message.event_id.clone()))
} else {
Relation::Reply {
in_reply_to: InReplyTo { event_id: original_message.event_id.clone() },
}
};
Self { msgtype, relates_to: Some(relates_to) }
}
#[doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/doc/rich_reply.md"))]
#[cfg(feature = "unstable-msc3440")]
pub fn for_thread(
message: MessageType,
previous_message: &OriginalRoomMessageEvent,
is_reply: ReplyInThread,
) -> Self {
let make_reply = |body, formatted: Option<FormattedBody>| {
reply::plain_and_formatted_reply_body(body, formatted.map(|f| f.body), previous_message)
};
let msgtype = if is_reply == ReplyInThread::Yes {
match message {
MessageType::Text(TextMessageEventContent { body, formatted, .. }) => {
let (body, html_body) = make_reply(body, formatted);
MessageType::Text(TextMessageEventContent::html(body, html_body))
}
MessageType::Emote(EmoteMessageEventContent { body, formatted, .. }) => {
let (body, html_body) = make_reply(body, formatted);
MessageType::Emote(EmoteMessageEventContent::html(body, html_body))
}
MessageType::Notice(NoticeMessageEventContent { body, formatted, .. }) => {
let (body, html_body) = make_reply(body, formatted);
MessageType::Notice(NoticeMessageEventContent::html(body, html_body))
}
_ => message,
}
} else {
message
};
let thread_root = if let Some(Relation::Thread(Thread { event_id, .. })) =
&previous_message.content.relates_to
{
event_id.clone()
} else {
previous_message.event_id.clone()
};
Self {
msgtype,
relates_to: Some(Relation::Thread(Thread {
event_id: thread_root,
in_reply_to: InReplyTo { event_id: previous_message.event_id.clone() },
is_falling_back: is_reply == ReplyInThread::No,
})),
}
}
pub fn msgtype(&self) -> &str {
self.msgtype.msgtype()
}
pub fn body(&self) -> &str {
self.msgtype.body()
}
#[cfg(feature = "unstable-sanitize")]
pub fn sanitize(
&mut self,
mode: HtmlSanitizerMode,
remove_reply_fallback: RemoveReplyFallback,
) {
if let MessageType::Emote(EmoteMessageEventContent { body, formatted, .. })
| MessageType::Notice(NoticeMessageEventContent { body, formatted, .. })
| MessageType::Text(TextMessageEventContent { body, formatted, .. }) = &mut self.msgtype
{
if let Some(formatted) = formatted {
formatted.sanitize_html(mode, remove_reply_fallback);
}
if remove_reply_fallback == RemoveReplyFallback::Yes
&& matches!(self.relates_to, Some(Relation::Reply { .. }))
{
*body = remove_plain_reply_fallback(body).to_owned();
}
}
}
}
#[cfg(feature = "unstable-msc3440")]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[allow(clippy::exhaustive_enums)]
pub enum ForwardThread {
Yes,
No,
}
#[cfg(feature = "unstable-msc3440")]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[allow(clippy::exhaustive_enums)]
pub enum ReplyInThread {
Yes,
No,
}
#[derive(Clone, Debug, Serialize)]
#[serde(untagged)]
#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)]
pub enum MessageType {
Audio(AudioMessageEventContent),
Emote(EmoteMessageEventContent),
File(FileMessageEventContent),
Image(ImageMessageEventContent),
Location(LocationMessageEventContent),
Notice(NoticeMessageEventContent),
ServerNotice(ServerNoticeMessageEventContent),
Text(TextMessageEventContent),
Video(VideoMessageEventContent),
VerificationRequest(KeyVerificationRequestEventContent),
#[doc(hidden)]
_Custom(CustomEventContent),
}
impl MessageType {
pub fn new(msgtype: &str, body: String, data: JsonObject) -> serde_json::Result<Self> {
fn deserialize_variant<T: DeserializeOwned>(
body: String,
mut obj: JsonObject,
) -> serde_json::Result<T> {
obj.insert("body".into(), body.into());
serde_json::from_value(JsonValue::Object(obj))
}
Ok(match msgtype {
"m.audio" => Self::Audio(deserialize_variant(body, data)?),
"m.emote" => Self::Emote(deserialize_variant(body, data)?),
"m.file" => Self::File(deserialize_variant(body, data)?),
"m.image" => Self::Image(deserialize_variant(body, data)?),
"m.location" => Self::Location(deserialize_variant(body, data)?),
"m.notice" => Self::Notice(deserialize_variant(body, data)?),
"m.server_notice" => Self::ServerNotice(deserialize_variant(body, data)?),
"m.text" => Self::Text(deserialize_variant(body, data)?),
"m.video" => Self::Video(deserialize_variant(body, data)?),
"m.key.verification.request" => {
Self::VerificationRequest(deserialize_variant(body, data)?)
}
_ => Self::_Custom(CustomEventContent { msgtype: msgtype.to_owned(), body, data }),
})
}
pub fn msgtype(&self) -> &str {
match self {
Self::Audio(_) => "m.audio",
Self::Emote(_) => "m.emote",
Self::File(_) => "m.file",
Self::Image(_) => "m.image",
Self::Location(_) => "m.location",
Self::Notice(_) => "m.notice",
Self::ServerNotice(_) => "m.server_notice",
Self::Text(_) => "m.text",
Self::Video(_) => "m.video",
Self::VerificationRequest(_) => "m.key.verification.request",
Self::_Custom(c) => &c.msgtype,
}
}
pub fn body(&self) -> &str {
match self {
MessageType::Audio(m) => &m.body,
MessageType::Emote(m) => &m.body,
MessageType::File(m) => &m.body,
MessageType::Image(m) => &m.body,
MessageType::Location(m) => &m.body,
MessageType::Notice(m) => &m.body,
MessageType::ServerNotice(m) => &m.body,
MessageType::Text(m) => &m.body,
MessageType::Video(m) => &m.body,
MessageType::VerificationRequest(m) => &m.body,
MessageType::_Custom(m) => &m.body,
}
}
pub fn data(&self) -> Cow<'_, JsonObject> {
fn serialize<T: Serialize>(obj: &T) -> JsonObject {
match serde_json::to_value(obj).expect("message type serialization to succeed") {
JsonValue::Object(mut obj) => {
obj.remove("body");
obj
}
_ => panic!("all message types must serialize to objects"),
}
}
match self {
Self::Audio(d) => Cow::Owned(serialize(d)),
Self::Emote(d) => Cow::Owned(serialize(d)),
Self::File(d) => Cow::Owned(serialize(d)),
Self::Image(d) => Cow::Owned(serialize(d)),
Self::Location(d) => Cow::Owned(serialize(d)),
Self::Notice(d) => Cow::Owned(serialize(d)),
Self::ServerNotice(d) => Cow::Owned(serialize(d)),
Self::Text(d) => Cow::Owned(serialize(d)),
Self::Video(d) => Cow::Owned(serialize(d)),
Self::VerificationRequest(d) => Cow::Owned(serialize(d)),
Self::_Custom(c) => Cow::Borrowed(&c.data),
}
}
}
impl From<MessageType> for RoomMessageEventContent {
fn from(msgtype: MessageType) -> Self {
Self::new(msgtype)
}
}
#[derive(Clone, Debug)]
#[allow(clippy::manual_non_exhaustive)]
#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)]
pub enum Relation {
Reply {
in_reply_to: InReplyTo,
},
#[cfg(feature = "unstable-msc2676")]
Replacement(Replacement),
#[cfg(feature = "unstable-msc3440")]
Thread(Thread),
#[doc(hidden)]
_Custom,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)]
pub struct InReplyTo {
pub event_id: OwnedEventId,
}
impl InReplyTo {
pub fn new(event_id: OwnedEventId) -> Self {
Self { event_id }
}
}
#[derive(Clone, Debug)]
#[cfg(feature = "unstable-msc2676")]
#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)]
pub struct Replacement {
pub event_id: OwnedEventId,
pub new_content: Box<RoomMessageEventContent>,
}
#[cfg(feature = "unstable-msc2676")]
impl Replacement {
pub fn new(event_id: OwnedEventId, new_content: Box<RoomMessageEventContent>) -> Self {
Self { event_id, new_content }
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[cfg(feature = "unstable-msc3440")]
#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)]
pub struct Thread {
pub event_id: OwnedEventId,
pub in_reply_to: InReplyTo,
pub is_falling_back: bool,
}
#[cfg(feature = "unstable-msc3440")]
impl Thread {
pub fn plain(event_id: OwnedEventId, latest_event_id: OwnedEventId) -> Self {
Self { event_id, in_reply_to: InReplyTo::new(latest_event_id), is_falling_back: true }
}
pub fn reply(event_id: OwnedEventId, reply_to_event_id: OwnedEventId) -> Self {
Self { event_id, in_reply_to: InReplyTo::new(reply_to_event_id), is_falling_back: false }
}
}
#[doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/doc/string_enum.md"))]
#[derive(Clone, Debug, PartialEq, Eq, StringEnum)]
#[non_exhaustive]
pub enum MessageFormat {
#[ruma_enum(rename = "org.matrix.custom.html")]
Html,
#[doc(hidden)]
_Custom(PrivOwnedStr),
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[allow(clippy::exhaustive_structs)]
pub struct FormattedBody {
pub format: MessageFormat,
#[serde(rename = "formatted_body")]
pub body: String,
}
impl FormattedBody {
pub fn html(body: impl Into<String>) -> Self {
Self { format: MessageFormat::Html, body: body.into() }
}
#[cfg(feature = "markdown")]
pub fn markdown(body: impl AsRef<str>) -> Option<Self> {
let body = body.as_ref();
let mut html_body = String::new();
pulldown_cmark::html::push_html(&mut html_body, pulldown_cmark::Parser::new(body));
(html_body != format!("<p>{}</p>\n", body)).then(|| Self::html(html_body))
}
#[cfg(feature = "unstable-sanitize")]
pub fn sanitize_html(
&mut self,
mode: HtmlSanitizerMode,
remove_reply_fallback: RemoveReplyFallback,
) {
if self.format == MessageFormat::Html {
self.body = sanitize_html(&self.body, mode, remove_reply_fallback);
}
}
}
#[doc(hidden)]
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct CustomEventContent {
msgtype: String,
body: String,
#[serde(flatten)]
data: JsonObject,
}