slack_hook/
slack.rs

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