Skip to content

Commit 04480ed

Browse files
authored
Add message slash commands (#317)
1 parent 6532874 commit 04480ed

File tree

7 files changed

+352
-101
lines changed

7 files changed

+352
-101
lines changed

Cargo.lock

+2
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

+2
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ default-features = false
2626
features = ["build", "git", "gitcl",]
2727

2828
[dependencies]
29+
anyhow = "1.0"
2930
bitflags = "^2.3"
3031
chrono = "0.4"
3132
clap = {version = "~4.3", features = ["derive"]}
@@ -41,6 +42,7 @@ libc = "0.2"
4142
markup5ever_rcdom = "0.2.0"
4243
mime = "^0.3.16"
4344
mime_guess = "^2.0.4"
45+
nom = "7.0.0"
4446
notify-rust = { version = "4.10.0", default-features = false, features = ["zbus", "serde"] }
4547
open = "3.2.0"
4648
rand = "0.8.5"

docs/iamb.1

+37
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,43 @@ Close all but one tab.
188188
Go to the preview tab.
189189
.El
190190

191+
.Sh "SLASH COMMANDS"
192+
.Bl -tag -width Ds
193+
.It Sy "/markdown" , Sy "/md"
194+
Interpret the message body as Markdown markup.
195+
This is the default behaviour.
196+
.It Sy "/html" , Sy "/h"
197+
Send the message body as literal HTML.
198+
.It Sy "/plaintext" , Sy "/plain" , Sy "/p"
199+
Do not interpret any markup in the message body and send it as it is.
200+
.It Sy "/me"
201+
Send an emote message.
202+
.It Sy "/confetti"
203+
Produces no effect in
204+
.Nm ,
205+
but will display confetti in Matrix clients that support doing so.
206+
.It Sy "/fireworks"
207+
Produces no effect in
208+
.Nm ,
209+
but will display fireworks in Matrix clients that support doing so.
210+
.It Sy "/hearts"
211+
Produces no effect in
212+
.Nm ,
213+
but will display floating hearts in Matrix clients that support doing so.
214+
.It Sy "/rainfall"
215+
Produces no effect in
216+
.Nm ,
217+
but will display rainfall in Matrix clients that support doing so.
218+
.It Sy "/snowfall"
219+
Produces no effect in
220+
.Nm ,
221+
but will display snowfall in Matrix clients that support doing so.
222+
.It Sy "/spaceinvaders"
223+
Produces no effect in
224+
.Nm ,
225+
but will display aliens from Space Invaders in Matrix clients that support doing so.
226+
.El
227+
191228
.Sh EXAMPLES
192229
.Ss Example 1: Starting with a specific profile
193230
To start with a profile named

src/message/compose.rs

+290
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,290 @@
1+
//! Code for converting composed messages into content to send to the homeserver.
2+
use comrak::{markdown_to_html, ComrakOptions};
3+
use nom::{
4+
branch::alt,
5+
bytes::complete::tag,
6+
character::complete::space0,
7+
combinator::value,
8+
IResult,
9+
};
10+
11+
use matrix_sdk::ruma::events::room::message::{
12+
EmoteMessageEventContent,
13+
MessageType,
14+
RoomMessageEventContent,
15+
TextMessageEventContent,
16+
};
17+
18+
#[derive(Clone, Debug, Default)]
19+
enum SlashCommand {
20+
/// Send an emote message.
21+
Emote,
22+
23+
/// Send a message as literal HTML.
24+
Html,
25+
26+
/// Send a message without parsing any markup.
27+
Plaintext,
28+
29+
/// Send a Markdown message (the default message markup).
30+
#[default]
31+
Markdown,
32+
33+
/// Send a message with confetti effects in clients that show them.
34+
Confetti,
35+
36+
/// Send a message with fireworks effects in clients that show them.
37+
Fireworks,
38+
39+
/// Send a message with heart effects in clients that show them.
40+
Hearts,
41+
42+
/// Send a message with rainfall effects in clients that show them.
43+
Rainfall,
44+
45+
/// Send a message with snowfall effects in clients that show them.
46+
Snowfall,
47+
48+
/// Send a message with heart effects in clients that show them.
49+
SpaceInvaders,
50+
}
51+
52+
impl SlashCommand {
53+
fn to_message(&self, input: &str) -> anyhow::Result<MessageType> {
54+
let msgtype = match self {
55+
SlashCommand::Emote => {
56+
let html = text_to_html(input);
57+
let msg = EmoteMessageEventContent::html(input, html);
58+
MessageType::Emote(msg)
59+
},
60+
SlashCommand::Html => {
61+
let msg = TextMessageEventContent::html(input, input);
62+
MessageType::Text(msg)
63+
},
64+
SlashCommand::Plaintext => {
65+
let msg = TextMessageEventContent::plain(input);
66+
MessageType::Text(msg)
67+
},
68+
SlashCommand::Markdown => {
69+
let html = text_to_html(input);
70+
let msg = TextMessageEventContent::html(input, html);
71+
MessageType::Text(msg)
72+
},
73+
SlashCommand::Confetti => {
74+
MessageType::new("nic.custom.confetti", input.into(), Default::default())?
75+
},
76+
SlashCommand::Fireworks => {
77+
MessageType::new("nic.custom.fireworks", input.into(), Default::default())?
78+
},
79+
SlashCommand::Hearts => {
80+
MessageType::new("io.element.effect.hearts", input.into(), Default::default())?
81+
},
82+
SlashCommand::Rainfall => {
83+
MessageType::new("io.element.effect.rainfall", input.into(), Default::default())?
84+
},
85+
SlashCommand::Snowfall => {
86+
MessageType::new("io.element.effect.snowfall", input.into(), Default::default())?
87+
},
88+
SlashCommand::SpaceInvaders => {
89+
MessageType::new(
90+
"io.element.effects.space_invaders",
91+
input.into(),
92+
Default::default(),
93+
)?
94+
},
95+
};
96+
97+
Ok(msgtype)
98+
}
99+
}
100+
101+
fn parse_slash_command_inner(input: &str) -> IResult<&str, SlashCommand> {
102+
let (input, _) = space0(input)?;
103+
let (input, slash) = alt((
104+
value(SlashCommand::Emote, tag("/me ")),
105+
value(SlashCommand::Html, tag("/h ")),
106+
value(SlashCommand::Html, tag("/html ")),
107+
value(SlashCommand::Plaintext, tag("/p ")),
108+
value(SlashCommand::Plaintext, tag("/plain ")),
109+
value(SlashCommand::Plaintext, tag("/plaintext ")),
110+
value(SlashCommand::Markdown, tag("/md ")),
111+
value(SlashCommand::Markdown, tag("/markdown ")),
112+
value(SlashCommand::Confetti, tag("/confetti ")),
113+
value(SlashCommand::Fireworks, tag("/fireworks ")),
114+
value(SlashCommand::Hearts, tag("/hearts ")),
115+
value(SlashCommand::Rainfall, tag("/rainfall ")),
116+
value(SlashCommand::Snowfall, tag("/snowfall ")),
117+
value(SlashCommand::SpaceInvaders, tag("/spaceinvaders ")),
118+
))(input)?;
119+
let (input, _) = space0(input)?;
120+
121+
Ok((input, slash))
122+
}
123+
124+
fn parse_slash_command(input: &str) -> anyhow::Result<(&str, SlashCommand)> {
125+
match parse_slash_command_inner(input) {
126+
Ok(input) => Ok(input),
127+
Err(e) => Err(anyhow::anyhow!("Failed to parse slash command: {e}")),
128+
}
129+
}
130+
131+
fn text_to_html(input: &str) -> String {
132+
let mut options = ComrakOptions::default();
133+
options.extension.autolink = true;
134+
options.extension.shortcodes = true;
135+
options.extension.strikethrough = true;
136+
options.render.hardbreaks = true;
137+
markdown_to_html(input, &options)
138+
}
139+
140+
fn text_to_message_content(input: String) -> TextMessageEventContent {
141+
let html = text_to_html(input.as_str());
142+
TextMessageEventContent::html(input, html)
143+
}
144+
145+
pub fn text_to_message(input: String) -> RoomMessageEventContent {
146+
let msg = parse_slash_command(input.as_str())
147+
.and_then(|(input, slash)| slash.to_message(input))
148+
.unwrap_or_else(|_| MessageType::Text(text_to_message_content(input)));
149+
150+
RoomMessageEventContent::new(msg)
151+
}
152+
153+
#[cfg(test)]
154+
pub mod tests {
155+
use super::*;
156+
157+
#[test]
158+
fn test_markdown_autolink() {
159+
let input = "http://example.com\n";
160+
let content = text_to_message_content(input.into());
161+
assert_eq!(content.body, input);
162+
assert_eq!(
163+
content.formatted.unwrap().body,
164+
"<p><a href=\"http://example.com\">http://example.com</a></p>\n"
165+
);
166+
167+
let input = "www.example.com\n";
168+
let content = text_to_message_content(input.into());
169+
assert_eq!(content.body, input);
170+
assert_eq!(
171+
content.formatted.unwrap().body,
172+
"<p><a href=\"http://www.example.com\">www.example.com</a></p>\n"
173+
);
174+
175+
let input = "See docs (they're at https://iamb.chat)\n";
176+
let content = text_to_message_content(input.into());
177+
assert_eq!(content.body, input);
178+
assert_eq!(
179+
content.formatted.unwrap().body,
180+
"<p>See docs (they're at <a href=\"https://iamb.chat\">https://iamb.chat</a>)</p>\n"
181+
);
182+
}
183+
184+
#[test]
185+
fn test_markdown_message() {
186+
let input = "**bold**\n";
187+
let content = text_to_message_content(input.into());
188+
assert_eq!(content.body, input);
189+
assert_eq!(content.formatted.unwrap().body, "<p><strong>bold</strong></p>\n");
190+
191+
let input = "*emphasis*\n";
192+
let content = text_to_message_content(input.into());
193+
assert_eq!(content.body, input);
194+
assert_eq!(content.formatted.unwrap().body, "<p><em>emphasis</em></p>\n");
195+
196+
let input = "`code`\n";
197+
let content = text_to_message_content(input.into());
198+
assert_eq!(content.body, input);
199+
assert_eq!(content.formatted.unwrap().body, "<p><code>code</code></p>\n");
200+
201+
let input = "```rust\nconst A: usize = 1;\n```\n";
202+
let content = text_to_message_content(input.into());
203+
assert_eq!(content.body, input);
204+
assert_eq!(
205+
content.formatted.unwrap().body,
206+
"<pre><code class=\"language-rust\">const A: usize = 1;\n</code></pre>\n"
207+
);
208+
209+
let input = ":heart:\n";
210+
let content = text_to_message_content(input.into());
211+
assert_eq!(content.body, input);
212+
assert_eq!(content.formatted.unwrap().body, "<p>\u{2764}\u{FE0F}</p>\n");
213+
214+
let input = "para 1\n\npara 2\n";
215+
let content = text_to_message_content(input.into());
216+
assert_eq!(content.body, input);
217+
assert_eq!(content.formatted.unwrap().body, "<p>para 1</p>\n<p>para 2</p>\n");
218+
219+
let input = "line 1\nline 2\n";
220+
let content = text_to_message_content(input.into());
221+
assert_eq!(content.body, input);
222+
assert_eq!(content.formatted.unwrap().body, "<p>line 1<br />\nline 2</p>\n");
223+
224+
let input = "# Heading\n## Subheading\n\ntext\n";
225+
let content = text_to_message_content(input.into());
226+
assert_eq!(content.body, input);
227+
assert_eq!(
228+
content.formatted.unwrap().body,
229+
"<h1>Heading</h1>\n<h2>Subheading</h2>\n<p>text</p>\n"
230+
);
231+
}
232+
233+
#[test]
234+
fn text_to_message_slash_commands() {
235+
let MessageType::Text(content) = text_to_message("/html <b>bold</b>".into()).msgtype else {
236+
panic!("Expected MessageType::Text");
237+
};
238+
assert_eq!(content.body, "<b>bold</b>");
239+
assert_eq!(content.formatted.unwrap().body, "<b>bold</b>");
240+
241+
let MessageType::Text(content) = text_to_message("/h <b>bold</b>".into()).msgtype else {
242+
panic!("Expected MessageType::Text");
243+
};
244+
assert_eq!(content.body, "<b>bold</b>");
245+
assert_eq!(content.formatted.unwrap().body, "<b>bold</b>");
246+
247+
let MessageType::Text(content) = text_to_message("/plain <b>bold</b>".into()).msgtype
248+
else {
249+
panic!("Expected MessageType::Text");
250+
};
251+
assert_eq!(content.body, "<b>bold</b>");
252+
assert!(content.formatted.is_none(), "{:?}", content.formatted);
253+
254+
let MessageType::Text(content) = text_to_message("/p <b>bold</b>".into()).msgtype else {
255+
panic!("Expected MessageType::Text");
256+
};
257+
assert_eq!(content.body, "<b>bold</b>");
258+
assert!(content.formatted.is_none(), "{:?}", content.formatted);
259+
260+
let MessageType::Emote(content) = text_to_message("/me *bold*".into()).msgtype else {
261+
panic!("Expected MessageType::Emote");
262+
};
263+
assert_eq!(content.body, "*bold*");
264+
assert_eq!(content.formatted.unwrap().body, "<p><em>bold</em></p>\n");
265+
266+
let content = text_to_message("/confetti hello".into()).msgtype;
267+
assert_eq!(content.msgtype(), "nic.custom.confetti");
268+
assert_eq!(content.body(), "hello");
269+
270+
let content = text_to_message("/fireworks hello".into()).msgtype;
271+
assert_eq!(content.msgtype(), "nic.custom.fireworks");
272+
assert_eq!(content.body(), "hello");
273+
274+
let content = text_to_message("/hearts hello".into()).msgtype;
275+
assert_eq!(content.msgtype(), "io.element.effect.hearts");
276+
assert_eq!(content.body(), "hello");
277+
278+
let content = text_to_message("/rainfall hello".into()).msgtype;
279+
assert_eq!(content.msgtype(), "io.element.effect.rainfall");
280+
assert_eq!(content.body(), "hello");
281+
282+
let content = text_to_message("/snowfall hello".into()).msgtype;
283+
assert_eq!(content.msgtype(), "io.element.effect.snowfall");
284+
assert_eq!(content.body(), "hello");
285+
286+
let content = text_to_message("/spaceinvaders hello".into()).msgtype;
287+
assert_eq!(content.msgtype(), "io.element.effects.space_invaders");
288+
assert_eq!(content.body(), "hello");
289+
}
290+
}

0 commit comments

Comments
 (0)