slack_hooked/
slack.rs

1use crate::error::{ErrorKind, Result};
2use crate::Payload;
3use chrono::NaiveDateTime;
4use reqwest::{blocking::Client, Url};
5use serde::{Serialize, Serializer};
6use std::fmt;
7
8/// Handles sending messages to slack
9#[derive(Debug, Clone)]
10pub struct Slack {
11    hook: Url,
12    client: Client,
13}
14
15impl Slack {
16    /// Construct a new instance of slack for a specific incoming url endpoint.
17    pub fn new<T: reqwest::IntoUrl>(hook: T) -> Result<Slack> {
18        Ok(Slack {
19            hook: hook.into_url()?,
20            client: Client::new(),
21        })
22    }
23
24    /// Send payload to slack service
25    pub fn send(&self, payload: &Payload) -> Result<()> {
26        let response = self.client.post(self.hook.clone()).json(payload).send()?;
27
28        if response.status().is_success() {
29            Ok(())
30        } else {
31            Err(ErrorKind::Slack(format!("HTTP error {}", response.status())).into())
32        }
33    }
34}
35
36/// Slack timestamp
37#[derive(Debug, Clone, PartialEq, PartialOrd)]
38pub struct SlackTime(NaiveDateTime);
39
40impl SlackTime {
41    /// Construct a new `SlackTime`
42    pub fn new(time: &NaiveDateTime) -> SlackTime {
43        SlackTime(*time)
44    }
45}
46
47impl Serialize for SlackTime {
48    fn serialize<S>(&self, serializer: S) -> ::std::result::Result<S::Ok, S::Error>
49    where
50        S: Serializer,
51    {
52        serializer.serialize_i64(self.0.timestamp())
53    }
54}
55
56/// Representation of any text sent through slack
57/// the text must be processed to escape specific characters
58#[derive(Serialize, Debug, Default, Clone, PartialEq)]
59pub struct SlackText(String);
60
61impl SlackText {
62    /// Construct slack text with escaping
63    /// Escape &, <, and > in any slack text
64    /// https://api.slack.com/docs/formatting
65    pub fn new<S: Into<String>>(text: S) -> SlackText {
66        let s = text.into().chars().fold(String::new(), |mut s, c| {
67            match c {
68                '&' => s.push_str("&amp;"),
69                '<' => s.push_str("&lt;"),
70                '>' => s.push_str("&gt;"),
71                _ => s.push(c),
72            }
73            s
74        });
75        SlackText(s)
76    }
77
78    fn new_raw<S: Into<String>>(text: S) -> SlackText {
79        SlackText(text.into())
80    }
81}
82
83impl<'a> From<&'a str> for SlackText {
84    fn from(s: &'a str) -> SlackText {
85        SlackText::new(String::from(s))
86    }
87}
88
89impl<'a> From<String> for SlackText {
90    fn from(s: String) -> SlackText {
91        SlackText::new(s)
92    }
93}
94
95/// Enum used for constructing a text field having both `SlackText`(s) and `SlackLink`(s). The
96/// variants should be used together in a `Vec` on any function having a `Into<SlackText>` trait
97/// bound. The combined text will be space-separated.
98#[derive(Debug, Clone, PartialEq)]
99pub enum SlackTextContent {
100    /// Text that will be escaped via slack api rules
101    Text(SlackText),
102    /// Link
103    Link(SlackLink),
104    /// User Link
105    User(SlackUserLink),
106}
107
108impl<'a> From<&'a [SlackTextContent]> for SlackText {
109    fn from(v: &[SlackTextContent]) -> SlackText {
110        let st = v
111            .iter()
112            .map(|item| match *item {
113                SlackTextContent::Text(ref s) => format!("{}", s),
114                SlackTextContent::Link(ref link) => format!("{}", link),
115                SlackTextContent::User(ref u) => format!("{}", u),
116            })
117            .collect::<Vec<String>>()
118            .join(" ");
119        SlackText::new_raw(st)
120    }
121}
122
123impl fmt::Display for SlackText {
124    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
125        write!(f, "{}", self.0)
126    }
127}
128
129/// Representation of a link sent in slack
130#[derive(Debug, Clone, PartialEq)]
131pub struct SlackLink {
132    /// URL for link.
133    ///
134    /// NOTE: this is NOT a `Url` type because some of the slack "urls", don't conform to standard
135    /// url parsing scheme, which are enforced by the `url` crate.
136    pub url: String,
137    /// Anchor text for link
138    pub text: SlackText,
139}
140
141impl SlackLink {
142    /// Construct new SlackLink with string slices
143    pub fn new(url: &str, text: &str) -> SlackLink {
144        SlackLink {
145            url: url.to_owned(),
146            text: SlackText::new(text),
147        }
148    }
149}
150
151impl fmt::Display for SlackLink {
152    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
153        write!(f, "<{}|{}>", self.url, self.text)
154    }
155}
156
157impl Serialize for SlackLink {
158    fn serialize<S>(&self, serializer: S) -> ::std::result::Result<S::Ok, S::Error>
159    where
160        S: Serializer,
161    {
162        serializer.serialize_str(&format!("{}", self)[..])
163    }
164}
165
166/// Representation of a user id link sent in slack
167///
168/// Cannot do @UGUID|handle links using SlackLink in the future due to
169/// https://api.slack.com/changelog/2017-09-the-one-about-usernames
170#[derive(Debug, Clone, PartialEq, PartialOrd)]
171pub struct SlackUserLink {
172    /// User ID (U1231232123) style
173    pub uid: String,
174}
175
176impl SlackUserLink {
177    /// Construct new `SlackUserLink` with a string slice
178    pub fn new(uid: &str) -> SlackUserLink {
179        SlackUserLink {
180            uid: uid.to_owned(),
181        }
182    }
183}
184
185impl fmt::Display for SlackUserLink {
186    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
187        write!(f, "<{}>", self.uid)
188    }
189}
190
191impl Serialize for SlackUserLink {
192    fn serialize<S>(&self, serializer: S) -> ::std::result::Result<S::Ok, S::Error>
193    where
194        S: Serializer,
195    {
196        serializer.serialize_str(&format!("{}", self)[..])
197    }
198}
199
200#[cfg(test)]
201mod test {
202    use crate::slack::{Slack, SlackLink};
203    use crate::{AttachmentBuilder, Field, Parse, PayloadBuilder, SlackText};
204    use chrono::NaiveDateTime;
205    #[cfg(feature = "unstable")]
206    use test::Bencher;
207
208    #[test]
209    fn slack_incoming_url_test() {
210        let s = Slack::new("https://hooks.slack.com/services/abc/123/45z").unwrap();
211        assert_eq!(
212            s.hook.to_string(),
213            "https://hooks.slack.com/services/abc/123/45z".to_owned()
214        );
215    }
216
217    #[test]
218    fn slack_text_test() {
219        let s = SlackText::new("moo <&> moo");
220        assert_eq!(format!("{}", s), "moo &lt;&amp;&gt; moo".to_owned());
221    }
222
223    #[test]
224    fn slack_link_test() {
225        let s = SlackLink {
226            text: SlackText::new("moo <&> moo"),
227            url: "http://google.com".to_owned(),
228        };
229        assert_eq!(
230            format!("{}", s),
231            "<http://google.com|moo &lt;&amp;&gt; moo>".to_owned()
232        );
233    }
234
235    #[test]
236    fn json_slacklink_test() {
237        let s = SlackLink {
238            text: SlackText::new("moo <&> moo"),
239            url: "http://google.com".to_owned(),
240        };
241        assert_eq!(
242            serde_json::to_string(&s).unwrap(),
243            "\"<http://google.com|moo &lt;&amp;&gt; moo>\"".to_owned()
244        )
245    }
246
247    #[test]
248    fn json_complete_payload_test() {
249        let a = vec![AttachmentBuilder::new("fallback <&>")
250            .text("text <&>")
251            .color("#6800e8")
252            .fields(vec![Field::new("title", "value", None)])
253            .title_link("https://title_link.com/")
254            .ts(&NaiveDateTime::from_timestamp(123_456_789, 0))
255            .build()
256            .unwrap()];
257
258        let p = PayloadBuilder::new()
259            .text("test message")
260            .channel("#abc")
261            .username("Bot")
262            .icon_emoji(":chart_with_upwards_trend:")
263            .icon_url("https://example.com")
264            .attachments(a)
265            .unfurl_links(false)
266            .link_names(true)
267            .parse(Parse::Full)
268            .build()
269            .unwrap();
270
271        assert_eq!(serde_json::to_string(&p).unwrap(),
272            r##"{"text":"test message","channel":"#abc","username":"Bot","icon_url":"https://example.com/","icon_emoji":":chart_with_upwards_trend:","attachments":[{"fallback":"fallback &lt;&amp;&gt;","text":"text &lt;&amp;&gt;","color":"#6800e8","fields":[{"title":"title","value":"value"}],"title_link":"https://title_link.com/","ts":123456789}],"unfurl_links":false,"link_names":1,"parse":"full"}"##.to_owned())
273    }
274
275    #[test]
276    fn json_message_payload_test() {
277        let p = PayloadBuilder::new().text("test message").build().unwrap();
278
279        assert_eq!(
280            serde_json::to_string(&p).unwrap(),
281            r##"{"text":"test message"}"##.to_owned()
282        )
283    }
284
285    #[test]
286    fn slack_text_content_test() {
287        use super::SlackTextContent;
288        use super::SlackTextContent::{Link, Text};
289        let message: Vec<SlackTextContent> = vec![
290            Text("moo <&> moo".into()),
291            Link(SlackLink::new("@USER", "M<E>")),
292            Text("wow.".into()),
293        ];
294        let st: SlackText = SlackText::from(&message[..]);
295        assert_eq!(
296            format!("{}", st),
297            "moo &lt;&amp;&gt; moo <@USER|M&lt;E&gt;> wow."
298        );
299    }
300}