~goorzhel/sota-slack-spotter

ref: c9e946f297851f760d8e9b3819e7076363fafb56 sota-slack-spotter/src/slack.rs -rw-r--r-- 3.9 KiB
c9e946f2 — Antonio Gurgel Add missing .context()s 4 months ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
use std::{env::var, fmt::Display};

use anyhow::{anyhow, Context, Result};
use itertools::join;
use lazy_static::lazy_static;
use log::debug;
use regex::Regex;
use slack::api::{
    chat::{PostMessageRequest, PostMessageResponse},
    default_client,
    users::{self, ListRequest},
    User,
};

use crate::callsign::{Callsign, Callsigns};

/// A Slack client bound to a channel.
#[derive(Debug)]
pub struct Slack {
    token: String,
    pub channel: String,
    /// Bound to this object to avoid instantiating a new one
    /// on every function call.
    http_client: reqwest::blocking::Client,
}

impl Slack {
    #[rustfmt::skip]
    /// Instantiates a client using the values of `$SLACK_BOT_TOKEN` and `$SLACK_CHANNEL`.
    pub fn from_env() -> Result<Self> {
        let token = var("SLACK_BOT_TOKEN").context("Couldn't read $SLACK_BOT_TOKEN")?;
        let channel = var("SLACK_CHANNEL").context("Couldn't read $SLACK_CHANNEL")?;
        let client = default_client().context("Couldn't attach reqwest client")?;
        Ok(Self{token, channel, http_client: client})
    }

    /// Fetch users from Slack API.
    pub fn users(&self) -> Result<Vec<User>> {
        let resp = users::list(
            &self.http_client,
            &self.token,
            &ListRequest { presence: None },
        )
        .context("Slack API call failed")?;
        if resp.members.is_none() {
            Err(anyhow!("Somehow, no users found"))
        } else {
            Ok(resp.members.unwrap())
        }
    }

    /// Fetch users from Slack API and return [their callsigns](extract_callsigns).
    pub fn callsigns(&self) -> Result<Callsigns> {
        debug!("Loading callsigns from Slack.");
        Ok(extract_callsigns(
            self.users().context("Couldn't fetch Slack users")?,
        ))
    }

    /// Post a message to the client's channel.
    pub fn message(&self, text: &str, thread_ts: Option<&str>) -> Result<PostMessageResponse> {
        let thread_ts = match thread_ts {
            Some(ts) => serde_json::from_str(ts)?,
            None => None,
        };
        slack::api::chat::post_message(
            &self.http_client,
            &self.token,
            &PostMessageRequest {
                channel: &self.channel,
                text,
                thread_ts,
                ..Default::default()
            },
        )
        .map_err(anyhow::Error::new)
        .context("Couldn't send Slack message")
    }

    /// Post a message to the client's channel consisting of various items (usually alerts).
    pub fn message_batch<I: IntoIterator<Item = D>, D: Display>(&self, data: I) -> Result<()> {
        let data = join(data, "\n");
        match data.is_empty() {
            true => Ok(()),
            false => self.message(&data, None).map(|_| {}),
        }
    }
}

/// Extract callsigns from Slack user profiles.
pub fn extract_callsigns<U: IntoIterator<Item = User>>(users: U) -> Callsigns {
    lazy_static! {
        // This will match some non-callsigns, e.g.: numbers three digits or longer, but false
        // positives are acceptable. They'll be compared to the SOTA spots anyway (which all have
        // valid callsigns).
        static ref CALLSIGN_RE: Regex = Regex::new(r"[A-Z0-9]{1,3}[0-9][A-Z0-9]+").unwrap();
    }
    users
        .into_iter()
        .filter_map(|user| {
            user.profile.as_ref()?;
            let profile = user.profile.unwrap();
            [profile.real_name, profile.display_name]
                .into_iter()
                .filter_map(|opt| opt.map(|s| s.to_uppercase()))
                .flat_map(|s| {
                    s.split(|c: char| !c.is_alphanumeric())
                        // .map.collect: ugly, but only way to avoid E0515.
                        .map(str::to_owned)
                        .collect::<Vec<_>>()
                })
                .find(|element| CALLSIGN_RE.is_match(element))
                .map(Callsign::from)
        })
        .collect()
}