slack_http_client/
lib.rs

1use reqwest::Client;
2use serde::Deserialize;
3
4/// Simple Slack client
5///
6/// - `token`: your Slack bot token (e.g. "xoxb-xxxx-....")
7/// - `http_client`: a Reqwest Client for making API calls
8pub struct SlackClient {
9    token: String,
10    http_client: Client,
11}
12
13impl SlackClient {
14    /// Create a new SlackClient with the given bot token
15    pub fn new(token: impl Into<String>) -> Self {
16        Self {
17            token: token.into(),
18            http_client: Client::new(),
19        }
20    }
21
22    // ----------------------------------------------------------------
23    //  1) Send a normal channel message (with optional Block Kit)
24    // ----------------------------------------------------------------
25    /// Posts a message (with optional blocks) to a channel.
26    ///
27    /// - `channel`: channel ID or name (e.g. "#general" or "C12345")
28    /// - `text`: fallback text for notifications, loggers, etc.
29    /// - `blocks`: optional JSON array of Block Kit blocks
30    ///
31    /// Returns the Slack API response or an error.
32    pub async fn post_message(
33        &self,
34        channel: &str,
35        text: &str,
36        blocks: Option<serde_json::Value>,
37    ) -> Result<SlackPostMessageResponse, reqwest::Error> {
38        let url = "https://slack.com/api/chat.postMessage";
39
40        // Construct the payload
41        let mut body = serde_json::json!({
42            "channel": channel,
43            "text": text,
44        });
45
46        // If we have a JSON array of blocks, insert into the body
47        if let Some(blocks_json) = blocks {
48            body["blocks"] = blocks_json;
49        }
50
51        let resp = self
52            .http_client
53            .post(url)
54            .bearer_auth(&self.token) // pass token as Bearer
55            .json(&body)
56            .send()
57            .await?;
58
59        // Deserialize Slack's JSON response
60        let slack_resp = resp.json::<SlackPostMessageResponse>().await?;
61        Ok(slack_resp)
62    }
63
64    // ----------------------------------------------------------------
65    //  2) Send an ephemeral message (visible only to one user)
66    // ----------------------------------------------------------------
67    /// Posts an ephemeral message in a channel, visible only to `user_id`.
68    ///
69    /// - `channel`: The channel to post in
70    /// - `user_id`: The user who will see the ephemeral message
71    /// - `text`: fallback text
72    /// - `blocks`: optional JSON array of Block Kit blocks
73    ///
74    /// Returns the Slack API response or an error.
75    pub async fn post_ephemeral(
76        &self,
77        channel: &str,
78        user_id: &str,
79        text: &str,
80        blocks: Option<serde_json::Value>,
81    ) -> Result<SlackEphemeralResponse, reqwest::Error> {
82        let url = "https://slack.com/api/chat.postEphemeral";
83
84        let mut body = serde_json::json!({
85            "channel": channel,
86            "user": user_id,
87            "text": text,
88        });
89
90        if let Some(blocks_json) = blocks {
91            body["blocks"] = blocks_json;
92        }
93
94        let resp = self
95            .http_client
96            .post(url)
97            .bearer_auth(&self.token)
98            .json(&body)
99            .send()
100            .await?;
101
102        let slack_resp = resp.json::<SlackEphemeralResponse>().await?;
103        Ok(slack_resp)
104    }
105
106    // ----------------------------------------------------------------
107    //  3) Open a modal (view) in Slack
108    // ----------------------------------------------------------------
109    /// Opens a modal (View) in Slack.
110    ///
111    /// NOTE: The `trigger_id` comes from interactive payloads or slash commands.
112    ///
113    /// - `trigger_id`: Provided by Slack when a user invokes an action (e.g. button click)
114    /// - `view`: a JSON object describing the modal’s structure
115    ///
116    /// Returns the Slack API response or an error.
117    pub async fn open_modal(
118        &self,
119        trigger_id: &str,
120        view: serde_json::Value,
121    ) -> Result<SlackViewOpenResponse, reqwest::Error> {
122        let url = "https://slack.com/api/views.open";
123
124        let body = serde_json::json!({
125            "trigger_id": trigger_id,
126            "view": view
127        });
128
129        let resp = self
130            .http_client
131            .post(url)
132            .bearer_auth(&self.token)
133            .json(&body)
134            .send()
135            .await?;
136
137        let slack_resp = resp.json::<SlackViewOpenResponse>().await?;
138        Ok(slack_resp)
139    }
140}
141
142// --------------------------------------------------------------------
143//  Response Structs
144// --------------------------------------------------------------------
145
146/// Slack's top-level response object for `chat.postMessage`
147#[derive(Debug, Deserialize)]
148pub struct SlackPostMessageResponse {
149    pub ok: bool,
150    pub channel: Option<String>,
151    pub ts: Option<String>,
152    pub error: Option<String>,
153    // If needed, you can add more fields here
154}
155
156/// Slack's top-level response object for `chat.postEphemeral`
157#[derive(Debug, Deserialize)]
158pub struct SlackEphemeralResponse {
159    pub ok: bool,
160    pub message_ts: Option<String>,
161    pub error: Option<String>,
162    // ...
163}
164
165/// Slack's top-level response for `views.open`
166#[derive(Debug, Deserialize)]
167pub struct SlackViewOpenResponse {
168    pub ok: bool,
169    pub view: Option<SlackView>,
170    pub error: Option<String>,
171}
172
173/// Minimal struct to reflect a Slack View object
174#[derive(Debug, Deserialize)]
175pub struct SlackView {
176    pub id: String,
177    // Add other fields as needed
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183    use std::env;
184
185    /// A helper to see if we have a Slack token available.
186    fn maybe_get_slack_token() -> Option<String> {
187        env::var("SLACK_BOT_TOKEN").ok()
188    }
189
190    #[tokio::test]
191    async fn test_post_message() {
192        // This test attempts a real Slack API call if SLACK_BOT_TOKEN is set.
193        match maybe_get_slack_token() {
194            Some(token) => {
195                let slack = SlackClient::new(token);
196                // Use a channel you can safely post test messages into, e.g., "#random" or a private channel
197                let channel = "#orign-bot";
198                let text = "dlrow olleh";
199
200                let result = slack.post_message(channel, text, None).await;
201                assert!(
202                    result.is_ok(),
203                    "post_message returned an error: {:?}",
204                    result
205                );
206
207                let resp = result.unwrap();
208                println!("resp: {:?}", resp);
209                assert!(resp.ok, "Slack returned an error: {:?}", resp.error);
210                assert!(resp.ts.is_some());
211            }
212            None => {
213                println!("Skipping test_post_message because SLACK_BOT_TOKEN is not set.");
214            }
215        }
216    }
217
218    #[tokio::test]
219    async fn test_post_interactive_message() {
220        // This test attempts a real Slack API call if SLACK_BOT_TOKEN is set.
221        match maybe_get_slack_token() {
222            Some(token) => {
223                let slack = SlackClient::new(token);
224
225                // Use a channel you can safely post test messages into
226                let channel = "#orign-bot";
227                let text = "Do you want to continue?";
228
229                // Inline JSON blocks with two buttons: "Yes" and "No"
230                let blocks = serde_json::json!([
231                    {
232                        "type": "section",
233                        "text": {
234                            "type": "mrkdwn",
235                            "text": text
236                        }
237                    },
238                    {
239                        "type": "actions",
240                        "elements": [
241                            {
242                                "type": "button",
243                                "text": {
244                                    "type": "plain_text",
245                                    "text": "Yes"
246                                },
247                                "value": "yes",
248                                "action_id": "test_button_yes"
249                            },
250                            {
251                                "type": "button",
252                                "text": {
253                                    "type": "plain_text",
254                                    "text": "No"
255                                },
256                                "value": "no",
257                                "action_id": "test_button_no"
258                            }
259                        ]
260                    }
261                ]);
262
263                // Use the existing post_message method, passing in the blocks
264                let result = slack.post_message(channel, text, Some(blocks)).await;
265
266                // Ensure the request didn’t fail
267                assert!(
268                    result.is_ok(),
269                    "Sending interactive message returned an error: {:?}",
270                    result
271                );
272
273                // Confirm Slack's JSON response
274                let resp = result.unwrap();
275                println!("Interactive message response: {:?}", resp);
276
277                // Confirm Slack's "ok" field
278                assert!(resp.ok, "Slack returned an error: {:?}", resp.error);
279
280                // Confirm we got a valid timestamp
281                assert!(
282                    resp.ts.is_some(),
283                    "No timestamp was returned for interactive message."
284                );
285            }
286            None => {
287                println!("Skipping test_post_interactive_message_inline because SLACK_BOT_TOKEN is not set.");
288            }
289        }
290    }
291
292    // #[tokio::test]
293    // async fn test_post_ephemeral() {
294    //     // This test also attempts a real Slack API call if SLACK_BOT_TOKEN is set.
295    //     match maybe_get_slack_token() {
296    //         Some(token) => {
297    //             let slack = SlackClient::new(token);
298    //             // Replace with your known channel and test user ID
299    //             let channel = "#general";
300    //             let user_id = "U123456";
301    //             let text = "Hello ephemeral test!";
302
303    //             let result = slack.post_ephemeral(channel, user_id, text, None).await;
304    //             assert!(
305    //                 result.is_ok(),
306    //                 "post_ephemeral returned an error: {:?}",
307    //                 result
308    //             );
309
310    //             let resp = result.unwrap();
311    //             assert!(resp.ok, "Slack returned an error: {:?}", resp.error);
312    //             // ephemeral responses won't necessarily have a channel in the same format,
313    //             // so we'll just check if we at least didn't get an error.
314    //         }
315    //         None => {
316    //             println!("Skipping test_post_ephemeral because SLACK_BOT_TOKEN is not set.");
317    //         }
318    //     }
319    // }
320
321    // #[tokio::test]
322    // async fn test_open_modal() {
323    //     // This test attempts to open a modal if SLACK_BOT_TOKEN is set.
324    //     match maybe_get_slack_token() {
325    //         Some(token) => {
326    //             let slack = SlackClient::new(token);
327    //             // Real "trigger_id" is needed from an interactive Slack action
328    //             // For demonstration purposes, it will likely fail unless you
329    //             // provide a real short-lived trigger_id from Slack
330    //             let fake_trigger_id = "12345.98765.abcd2358f";
331
332    //             let my_modal_view = serde_json::json!({
333    //                 "type": "modal",
334    //                 "title": {
335    //                     "type": "plain_text",
336    //                     "text": "Example Modal"
337    //                 },
338    //                 "close": {
339    //                     "type": "plain_text",
340    //                     "text": "Close"
341    //                 },
342    //                 "blocks": [
343    //                     {
344    //                         "type": "section",
345    //                         "text": {
346    //                             "type": "mrkdwn",
347    //                             "text": "This is a test modal."
348    //                         }
349    //                     }
350    //                 ]
351    //             });
352
353    //             let result = slack.open_modal(fake_trigger_id, my_modal_view).await;
354    //             // In a real scenario, you'd have a genuine trigger_id from Slack. This will likely fail without it.
355    //             // For demonstration:
356    //             println!("Modal call result: {:?}", result);
357    //         }
358    //         None => {
359    //             println!("Skipping test_open_modal because SLACK_BOT_TOKEN is not set.");
360    //         }
361    //     }
362    // }
363}