Skip to main content

slackrs/
slack.rs

1use chrono::prelude::*;
2use lazy_static::lazy_static;
3use regex::Regex;
4use serde::Deserialize;
5use std::fs::File;
6use std::io::Read;
7use std::path::PathBuf;
8use zip::ZipArchive;
9
10lazy_static! {
11    /// The file pattern of the JSON files with the slack messages (there are other JSON files in the export ZIP).
12    static ref JSON_FILE_NAME: Regex = Regex::new(r".*\/\d{4}-\d{2}-\d{2}.json$").unwrap();
13}
14
15/// Represents a user profile, part of a Slack `Message`.
16#[derive(Deserialize, Debug)]
17#[allow(dead_code)]
18pub struct UserProfile {
19    avatar_hash: String,
20    /** URL to avatar image. */
21    image_72: String,
22    first_name: String,
23    real_name: String,
24    display_name: String,
25    team: String,
26    name: String,
27    is_restricted: bool,
28    is_ultra_restricted: bool,
29}
30
31/// Represents a Slack message.
32#[derive(Deserialize, Debug)]
33#[allow(dead_code)]
34pub struct Message {
35    user: Option<String>,
36    #[serde(rename = "type")]
37    json_type: String,
38    ts: String,
39    client_msg_id: Option<String>,
40    pub text: String,
41    team: Option<String>,
42    user_team: Option<String>,
43    source_team: Option<String>,
44    user_profile: Option<UserProfile>,
45    thread_ts: Option<String>,
46    parent_user_id: Option<String>,
47    attachments: Option<Vec<MessageAttachment>>,
48    blocks: Option<Vec<MessageBlock>>,
49}
50impl Message {
51    #[cfg(test)]
52    fn new(user: &str, timestamp: &str, text: &str) -> Message {
53        Message {
54            user: Option::Some(user.into()),
55            json_type: "message".into(),
56            ts: timestamp.into(),
57            client_msg_id: Option::None,
58            text: text.into(),
59            team: Option::None,
60            user_team: Option::None,
61            source_team: Option::None,
62            user_profile: Option::None,
63            thread_ts: Option::None,
64            parent_user_id: Option::None,
65            attachments: Option::None,
66            blocks: Option::None,
67        }
68    }
69
70    /// Returns the timestamp of the message as a `chrono::DateTime<Utc>`.
71    /// We ignore the partial seconds of the timestamp, as we are interested in longer time scales.
72    pub fn time(&self) -> chrono::DateTime<chrono::Utc> {
73        let seconds: i64 = self
74            .ts
75            .split(".")
76            .next()
77            .expect("Invalid timestamp format.")
78            .parse::<i64>()
79            .expect("First part of timestamp is not an integer.");
80        DateTime::from_timestamp(seconds, 0).unwrap()
81    }
82
83    /// Checks if the message contains a given pattern in its text or in any of its `MessageAttachment`s.
84    pub fn contains(&self, pattern: &str) -> bool {
85        if self.text.contains(pattern) {
86            return true;
87        }
88        for attachment in self.attachments.iter().flatten() {
89            if attachment.contains(pattern) {
90                return true;
91            }
92        }
93        for block in self.blocks.iter().flatten() {
94            if block.contains(pattern) {
95                return true;
96            }
97        }
98        return false;
99    }
100}
101
102/// Represents a message attachment, part of a Slack `Message`.
103#[derive(Deserialize, Debug)]
104#[allow(dead_code)]
105pub struct MessageAttachment {
106    id: Option<u64>,
107    text: Option<String>,
108}
109impl MessageAttachment {
110    /// Returns true if the attachment text contains the given pattern.
111    pub fn contains(&self, pattern: &str) -> bool {
112        if let Some(text) = &self.text {
113            return text.contains(pattern);
114        }
115        false
116    }
117}
118
119/// Represents a message block, part of a Slack `Message`. Blocks can be nested.
120#[derive(Deserialize, Debug)]
121#[allow(dead_code)]
122pub struct MessageBlock {
123    #[serde(rename = "type")]
124    json_type: String,
125    block_id: Option<String>,
126    text: Option<String>,
127    elements: Option<Vec<MessageBlock>>,
128}
129impl MessageBlock {
130    /// Returns true if block (or any sub-block) contains the given pattern in its text.
131    pub fn contains(&self, pattern: &str) -> bool {
132        if let Some(text) = &self.text {
133            return text.contains(pattern);
134        }
135        if let Some(elements) = &self.elements {
136            for element in elements {
137                if element.contains(pattern) {
138                    return true;
139                }
140            }
141        }
142        false
143    }
144}
145
146/// Represents a message in a channel.
147///
148/// Channels can only be inferred from the file path in the ZIP,
149/// so this needs to be added to a message after reading the file.
150#[derive(Debug)]
151pub struct MessageInChannel {
152    pub channel: String,
153    pub message: Message,
154}
155impl MessageInChannel {
156    pub fn new(channel: &str, message: Message) -> MessageInChannel {
157        MessageInChannel {
158            channel: channel.into(),
159            message,
160        }
161    }
162}
163
164fn read_file(file_name: &str, file_content: &str) -> Vec<Message> {
165    match serde_json::from_str(file_content) {
166        Ok(x) => x,
167        Err(x) => {
168            eprint!("Could not deserialize '{}': {}.", file_name, x.to_string());
169            Vec::new()
170        }
171    }
172}
173
174/// Read ZIP contents.
175pub fn read_zip_contents(zip_path: &PathBuf) -> Vec<MessageInChannel> {
176    let file = File::open(zip_path).expect("Cannot open file");
177    let mut archive: ZipArchive<File> = ZipArchive::new(file).expect("ZIP file invalid.");
178    let mut result: Vec<MessageInChannel> = Vec::new();
179    println!("Number of files in archive: {}", archive.len());
180    let mut counter: u32 = 0;
181
182    for i in 0..archive.len() {
183        let mut file: zip::read::ZipFile<'_, File> =
184            archive.by_index(i).expect("ZIP file invalid.");
185        if !file.is_dir() && JSON_FILE_NAME.is_match(file.name()) {
186            counter += 1;
187            println!("Analyzing file #{}: {}", counter, file.name());
188            let mut buffer: String = String::new();
189            let read_result = file.read_to_string(&mut buffer);
190            if read_result.is_ok() {
191                let messages: Vec<Message> = read_file(file.name(), buffer.as_str());
192                println!(
193                    "Read {:?} bytes into {} messages.",
194                    read_result.unwrap_or(0),
195                    messages.len()
196                );
197                let messages_in_channel: Vec<MessageInChannel> = messages
198                    .into_iter()
199                    .map(|x| MessageInChannel::new(file.name(), x))
200                    .collect();
201                result.extend(messages_in_channel);
202            }
203        }
204    }
205    println!(
206        "Read {} messages from {} files in archive at '{}', sorting by time.",
207        result.len(),
208        counter,
209        zip_path.to_str().unwrap()
210    );
211    let mut sorted_results: Vec<MessageInChannel> = result.into_iter().collect();
212    sorted_results.sort_by_key(|x| x.message.time().timestamp_micros());
213    return sorted_results;
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219
220    #[test]
221    fn ts_to_datetime_ok() {
222        let msg1 = Message::new("tester", "123.456", "");
223        assert_eq!(
224            msg1.time(),
225            Utc.with_ymd_and_hms(1970, 1, 1, 0, 2, 3).unwrap()
226        );
227        let msg2 = Message::new("tester", "1234567", "");
228        assert_eq!(
229            msg2.time(),
230            Utc.with_ymd_and_hms(1970, 1, 15, 6, 56, 7).unwrap()
231        );
232    }
233
234    #[test]
235    #[should_panic(expected = "First part of timestamp is not an integer")]
236    fn ts_to_datetime_err() {
237        let invalid_time = Message::new("tester", "", "");
238        invalid_time.time();
239    }
240}