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}