Skip to main content

rustant_core/channels/
slack.rs

1//! Slack Web API channel implementation.
2//!
3//! Uses the Slack Web API via reqwest for messaging, channel listing,
4//! user lookup, reactions, file metadata, and more.
5
6use super::{
7    Channel, ChannelCapabilities, ChannelMessage, ChannelStatus, ChannelType, ChannelUser,
8    MessageId, StreamingMode,
9};
10use crate::error::{ChannelError, RustantError};
11use crate::oauth::AuthMethod;
12use crate::secret_ref::{SecretRef, SecretResolver};
13use async_trait::async_trait;
14use serde::{Deserialize, Serialize};
15
16/// Configuration for a Slack channel.
17#[derive(Debug, Clone, Default, Serialize, Deserialize)]
18pub struct SlackConfig {
19    /// Bot token (xoxb-...) or OAuth access token.
20    /// Supports `SecretRef` format: `"keychain:channel:slack:bot_token"`, `"env:SLACK_BOT_TOKEN"`,
21    /// or inline plaintext (deprecated).
22    pub bot_token: SecretRef,
23    pub app_token: Option<String>,
24    pub default_channel: Option<String>,
25    pub allowed_channels: Vec<String>,
26    /// Authentication method. When `OAuth`, the `bot_token` field holds
27    /// the OAuth 2.0 access token obtained via `slack_oauth_config()`.
28    #[serde(default)]
29    pub auth_method: AuthMethod,
30}
31
32impl SlackConfig {
33    /// Resolve the bot token from its `SecretRef` to an actual token string.
34    pub fn resolve_bot_token(&self) -> Result<String, crate::secret_ref::SecretResolveError> {
35        let store = crate::credentials::KeyringCredentialStore::new();
36        SecretResolver::resolve(&self.bot_token, &store)
37    }
38}
39
40// ── Slack API response types ───────────────────────────────────────────────
41
42/// A Slack message from the API.
43#[derive(Debug, Clone)]
44pub struct SlackMessage {
45    pub ts: String,
46    pub channel: String,
47    pub user: String,
48    pub text: String,
49    pub thread_ts: Option<String>,
50}
51
52/// Metadata about a Slack channel (public or private).
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct SlackChannelInfo {
55    pub id: String,
56    pub name: String,
57    pub is_private: bool,
58    pub is_member: bool,
59    pub num_members: u64,
60    pub topic: String,
61    pub purpose: String,
62}
63
64/// Metadata about a Slack workspace user.
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct SlackUserInfo {
67    pub id: String,
68    pub name: String,
69    pub real_name: String,
70    pub display_name: String,
71    pub is_bot: bool,
72    pub is_admin: bool,
73    pub email: Option<String>,
74    pub status_text: String,
75    pub status_emoji: String,
76}
77
78/// A reaction on a Slack message.
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct SlackReaction {
81    pub name: String,
82    pub count: u64,
83    pub users: Vec<String>,
84}
85
86/// A file shared in Slack.
87#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct SlackFile {
89    pub id: String,
90    pub name: String,
91    pub filetype: String,
92    pub size: u64,
93    pub url_private: String,
94    pub user: String,
95    pub timestamp: u64,
96}
97
98/// Workspace team information.
99#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct SlackTeamInfo {
101    pub id: String,
102    pub name: String,
103    pub domain: String,
104    pub icon_url: Option<String>,
105}
106
107/// User group information.
108#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct SlackUserGroup {
110    pub id: String,
111    pub name: String,
112    pub handle: String,
113    pub description: String,
114    pub user_count: u64,
115}
116
117// ── HTTP client trait ──────────────────────────────────────────────────────
118
119/// Trait for Slack API interactions.
120#[async_trait]
121pub trait SlackHttpClient: Send + Sync {
122    // Messaging
123    async fn post_message(&self, channel: &str, text: &str) -> Result<String, String>;
124    async fn post_thread_reply(
125        &self,
126        channel: &str,
127        thread_ts: &str,
128        text: &str,
129    ) -> Result<String, String>;
130    async fn conversations_history(
131        &self,
132        channel: &str,
133        limit: usize,
134    ) -> Result<Vec<SlackMessage>, String>;
135    async fn auth_test(&self) -> Result<String, String>;
136
137    // Channels
138    async fn conversations_list(
139        &self,
140        types: &str,
141        limit: usize,
142    ) -> Result<Vec<SlackChannelInfo>, String>;
143    async fn conversations_join(&self, channel_id: &str) -> Result<(), String>;
144    async fn conversations_info(&self, channel_id: &str) -> Result<SlackChannelInfo, String>;
145
146    // Users
147    async fn users_list(&self, limit: usize) -> Result<Vec<SlackUserInfo>, String>;
148    async fn users_info(&self, user_id: &str) -> Result<SlackUserInfo, String>;
149
150    // Reactions
151    async fn reactions_add(&self, channel: &str, timestamp: &str, name: &str)
152        -> Result<(), String>;
153    async fn reactions_get(
154        &self,
155        channel: &str,
156        timestamp: &str,
157    ) -> Result<Vec<SlackReaction>, String>;
158
159    // Files
160    async fn files_list(
161        &self,
162        channel: Option<&str>,
163        limit: usize,
164    ) -> Result<Vec<SlackFile>, String>;
165
166    // Team / Workspace
167    async fn team_info(&self) -> Result<SlackTeamInfo, String>;
168    async fn usergroups_list(&self) -> Result<Vec<SlackUserGroup>, String>;
169
170    // DMs
171    async fn conversations_open(&self, user_ids: &[&str]) -> Result<String, String>;
172}
173
174// ── SlackChannel ───────────────────────────────────────────────────────────
175
176/// Slack channel.
177pub struct SlackChannel {
178    config: SlackConfig,
179    status: ChannelStatus,
180    http_client: Box<dyn SlackHttpClient>,
181    name: String,
182}
183
184impl SlackChannel {
185    pub fn new(config: SlackConfig, http_client: Box<dyn SlackHttpClient>) -> Self {
186        Self {
187            config,
188            status: ChannelStatus::Disconnected,
189            http_client,
190            name: "slack".to_string(),
191        }
192    }
193
194    pub fn with_name(mut self, name: impl Into<String>) -> Self {
195        self.name = name.into();
196        self
197    }
198
199    // ── Convenience methods for direct Slack API access ────────────────
200
201    /// List public and private channels visible to the bot.
202    pub async fn list_channels(&self) -> Result<Vec<SlackChannelInfo>, RustantError> {
203        self.http_client
204            .conversations_list("public_channel,private_channel", 200)
205            .await
206            .map_err(|e| {
207                RustantError::Channel(ChannelError::ConnectionFailed {
208                    name: self.name.clone(),
209                    message: e,
210                })
211            })
212    }
213
214    /// Get info about a specific channel by ID.
215    pub async fn channel_info(&self, channel_id: &str) -> Result<SlackChannelInfo, RustantError> {
216        self.http_client
217            .conversations_info(channel_id)
218            .await
219            .map_err(|e| {
220                RustantError::Channel(ChannelError::ConnectionFailed {
221                    name: self.name.clone(),
222                    message: e,
223                })
224            })
225    }
226
227    /// Join a channel by ID.
228    pub async fn join_channel(&self, channel_id: &str) -> Result<(), RustantError> {
229        self.http_client
230            .conversations_join(channel_id)
231            .await
232            .map_err(|e| {
233                RustantError::Channel(ChannelError::ConnectionFailed {
234                    name: self.name.clone(),
235                    message: e,
236                })
237            })
238    }
239
240    /// List workspace users.
241    pub async fn list_users(&self) -> Result<Vec<SlackUserInfo>, RustantError> {
242        self.http_client.users_list(200).await.map_err(|e| {
243            RustantError::Channel(ChannelError::ConnectionFailed {
244                name: self.name.clone(),
245                message: e,
246            })
247        })
248    }
249
250    /// Look up a user by ID.
251    pub async fn get_user_info(&self, user_id: &str) -> Result<SlackUserInfo, RustantError> {
252        self.http_client.users_info(user_id).await.map_err(|e| {
253            RustantError::Channel(ChannelError::ConnectionFailed {
254                name: self.name.clone(),
255                message: e,
256            })
257        })
258    }
259
260    /// Add a reaction to a message.
261    pub async fn add_reaction(
262        &self,
263        channel: &str,
264        timestamp: &str,
265        emoji: &str,
266    ) -> Result<(), RustantError> {
267        self.http_client
268            .reactions_add(channel, timestamp, emoji)
269            .await
270            .map_err(|e| {
271                RustantError::Channel(ChannelError::SendFailed {
272                    name: self.name.clone(),
273                    message: e,
274                })
275            })
276    }
277
278    /// Get reactions on a message.
279    pub async fn get_reactions(
280        &self,
281        channel: &str,
282        timestamp: &str,
283    ) -> Result<Vec<SlackReaction>, RustantError> {
284        self.http_client
285            .reactions_get(channel, timestamp)
286            .await
287            .map_err(|e| {
288                RustantError::Channel(ChannelError::ConnectionFailed {
289                    name: self.name.clone(),
290                    message: e,
291                })
292            })
293    }
294
295    /// List files, optionally filtered by channel.
296    pub async fn list_files(&self, channel: Option<&str>) -> Result<Vec<SlackFile>, RustantError> {
297        self.http_client
298            .files_list(channel, 100)
299            .await
300            .map_err(|e| {
301                RustantError::Channel(ChannelError::ConnectionFailed {
302                    name: self.name.clone(),
303                    message: e,
304                })
305            })
306    }
307
308    /// Get workspace/team info.
309    pub async fn get_team_info(&self) -> Result<SlackTeamInfo, RustantError> {
310        self.http_client.team_info().await.map_err(|e| {
311            RustantError::Channel(ChannelError::ConnectionFailed {
312                name: self.name.clone(),
313                message: e,
314            })
315        })
316    }
317
318    /// List user groups.
319    pub async fn list_usergroups(&self) -> Result<Vec<SlackUserGroup>, RustantError> {
320        self.http_client.usergroups_list().await.map_err(|e| {
321            RustantError::Channel(ChannelError::ConnectionFailed {
322                name: self.name.clone(),
323                message: e,
324            })
325        })
326    }
327
328    /// Open a DM conversation with one or more users. Returns the channel ID.
329    pub async fn open_dm(&self, user_ids: &[&str]) -> Result<String, RustantError> {
330        self.http_client
331            .conversations_open(user_ids)
332            .await
333            .map_err(|e| {
334                RustantError::Channel(ChannelError::ConnectionFailed {
335                    name: self.name.clone(),
336                    message: e,
337                })
338            })
339    }
340
341    /// Send a threaded reply to a message.
342    pub async fn reply_in_thread(
343        &self,
344        channel: &str,
345        thread_ts: &str,
346        text: &str,
347    ) -> Result<MessageId, RustantError> {
348        self.http_client
349            .post_thread_reply(channel, thread_ts, text)
350            .await
351            .map(MessageId::new)
352            .map_err(|e| {
353                RustantError::Channel(ChannelError::SendFailed {
354                    name: self.name.clone(),
355                    message: e,
356                })
357            })
358    }
359
360    /// Read recent messages from a channel. Wraps `conversations_history`.
361    pub async fn read_history(
362        &self,
363        channel: &str,
364        limit: usize,
365    ) -> Result<Vec<SlackMessage>, RustantError> {
366        self.http_client
367            .conversations_history(channel, limit)
368            .await
369            .map_err(|e| {
370                RustantError::Channel(ChannelError::ConnectionFailed {
371                    name: self.name.clone(),
372                    message: e,
373                })
374            })
375    }
376}
377
378#[async_trait]
379impl Channel for SlackChannel {
380    fn name(&self) -> &str {
381        &self.name
382    }
383
384    fn channel_type(&self) -> ChannelType {
385        ChannelType::Slack
386    }
387
388    async fn connect(&mut self) -> Result<(), RustantError> {
389        if self.config.bot_token.is_empty() {
390            return Err(RustantError::Channel(ChannelError::AuthFailed {
391                name: self.name.clone(),
392            }));
393        }
394        self.http_client.auth_test().await.map_err(|_e| {
395            RustantError::Channel(ChannelError::AuthFailed {
396                name: self.name.clone(),
397            })
398        })?;
399        self.status = ChannelStatus::Connected;
400        Ok(())
401    }
402
403    async fn disconnect(&mut self) -> Result<(), RustantError> {
404        self.status = ChannelStatus::Disconnected;
405        Ok(())
406    }
407
408    async fn send_message(&self, msg: ChannelMessage) -> Result<MessageId, RustantError> {
409        let text = msg.content.as_text().unwrap_or("");
410        let channel = if msg.channel_id.is_empty() {
411            self.config.default_channel.as_deref().unwrap_or("general")
412        } else {
413            &msg.channel_id
414        };
415
416        self.http_client
417            .post_message(channel, text)
418            .await
419            .map(MessageId::new)
420            .map_err(|e| {
421                RustantError::Channel(ChannelError::SendFailed {
422                    name: self.name.clone(),
423                    message: e,
424                })
425            })
426    }
427
428    async fn receive_messages(&self) -> Result<Vec<ChannelMessage>, RustantError> {
429        let channels = if self.config.allowed_channels.is_empty() {
430            vec![self
431                .config
432                .default_channel
433                .clone()
434                .unwrap_or_else(|| "general".to_string())]
435        } else {
436            self.config.allowed_channels.clone()
437        };
438
439        let mut all = Vec::new();
440        for ch in &channels {
441            let slack_msgs = self
442                .http_client
443                .conversations_history(ch, 25)
444                .await
445                .map_err(|e| {
446                    RustantError::Channel(ChannelError::ConnectionFailed {
447                        name: self.name.clone(),
448                        message: e,
449                    })
450                })?;
451
452            for sm in slack_msgs {
453                let sender = ChannelUser::new(&sm.user, ChannelType::Slack);
454                let msg = ChannelMessage::text(ChannelType::Slack, &sm.channel, sender, &sm.text);
455                all.push(msg);
456            }
457        }
458        Ok(all)
459    }
460
461    fn status(&self) -> ChannelStatus {
462        self.status
463    }
464
465    fn capabilities(&self) -> ChannelCapabilities {
466        ChannelCapabilities {
467            supports_threads: true,
468            supports_reactions: true,
469            supports_files: true,
470            supports_voice: false,
471            supports_video: false,
472            max_message_length: Some(40000),
473            supports_editing: false,
474            supports_deletion: false,
475        }
476    }
477
478    fn streaming_mode(&self) -> StreamingMode {
479        StreamingMode::WebSocket
480    }
481}
482
483// ── Real HTTP client ───────────────────────────────────────────────────────
484
485/// Real Slack HTTP client using the Slack Web API via reqwest.
486pub struct RealSlackHttp {
487    client: reqwest::Client,
488    bot_token: String,
489}
490
491impl RealSlackHttp {
492    pub fn new(bot_token: String) -> Self {
493        Self {
494            client: reqwest::Client::new(),
495            bot_token,
496        }
497    }
498
499    fn auth_header(&self) -> String {
500        format!("Bearer {}", self.bot_token)
501    }
502
503    /// Make a GET request to a Slack API endpoint and parse the JSON response.
504    async fn slack_get(&self, url: &str) -> Result<serde_json::Value, String> {
505        let resp = self
506            .client
507            .get(url)
508            .header("Authorization", self.auth_header())
509            .send()
510            .await
511            .map_err(|e| format!("HTTP request failed: {}", e))?;
512
513        let status = resp.status();
514        let body = resp
515            .text()
516            .await
517            .map_err(|e| format!("Failed to read response: {}", e))?;
518
519        if !status.is_success() {
520            return Err(format!("HTTP {}: {}", status, body));
521        }
522
523        let json: serde_json::Value =
524            serde_json::from_str(&body).map_err(|e| format!("Invalid JSON: {}", e))?;
525
526        if json.get("ok") != Some(&serde_json::Value::Bool(true)) {
527            let error = json
528                .get("error")
529                .and_then(|e| e.as_str())
530                .unwrap_or("unknown_error");
531            return Err(format!("Slack API error: {}", error));
532        }
533
534        Ok(json)
535    }
536
537    /// Make a POST request to a Slack API endpoint with a JSON body.
538    async fn slack_post(
539        &self,
540        url: &str,
541        body: &serde_json::Value,
542    ) -> Result<serde_json::Value, String> {
543        let resp = self
544            .client
545            .post(url)
546            .header("Authorization", self.auth_header())
547            .header("Content-Type", "application/json; charset=utf-8")
548            .json(body)
549            .send()
550            .await
551            .map_err(|e| format!("HTTP request failed: {}", e))?;
552
553        let status = resp.status();
554        let body_text = resp
555            .text()
556            .await
557            .map_err(|e| format!("Failed to read response: {}", e))?;
558
559        if !status.is_success() {
560            return Err(format!("HTTP {}: {}", status, body_text));
561        }
562
563        let json: serde_json::Value =
564            serde_json::from_str(&body_text).map_err(|e| format!("Invalid JSON: {}", e))?;
565
566        if json.get("ok") != Some(&serde_json::Value::Bool(true)) {
567            let error = json
568                .get("error")
569                .and_then(|e| e.as_str())
570                .unwrap_or("unknown_error");
571            return Err(format!("Slack API error: {}", error));
572        }
573
574        Ok(json)
575    }
576}
577
578#[async_trait]
579impl SlackHttpClient for RealSlackHttp {
580    async fn post_message(&self, channel: &str, text: &str) -> Result<String, String> {
581        let body = serde_json::json!({ "channel": channel, "text": text });
582        let json = self
583            .slack_post("https://slack.com/api/chat.postMessage", &body)
584            .await?;
585        json.get("ts")
586            .and_then(|ts| ts.as_str())
587            .map(|s| s.to_string())
588            .ok_or_else(|| "Missing 'ts' in response".to_string())
589    }
590
591    async fn post_thread_reply(
592        &self,
593        channel: &str,
594        thread_ts: &str,
595        text: &str,
596    ) -> Result<String, String> {
597        let body = serde_json::json!({ "channel": channel, "thread_ts": thread_ts, "text": text });
598        let json = self
599            .slack_post("https://slack.com/api/chat.postMessage", &body)
600            .await?;
601        json.get("ts")
602            .and_then(|ts| ts.as_str())
603            .map(|s| s.to_string())
604            .ok_or_else(|| "Missing 'ts' in response".to_string())
605    }
606
607    async fn conversations_history(
608        &self,
609        channel: &str,
610        limit: usize,
611    ) -> Result<Vec<SlackMessage>, String> {
612        let url = format!(
613            "https://slack.com/api/conversations.history?channel={}&limit={}",
614            channel, limit
615        );
616        let json = self.slack_get(&url).await?;
617
618        let messages = json
619            .get("messages")
620            .and_then(|m| m.as_array())
621            .map(|arr| {
622                arr.iter()
623                    .filter_map(|msg| {
624                        let ts = msg.get("ts")?.as_str()?.to_string();
625                        let user = msg
626                            .get("user")
627                            .and_then(|u| u.as_str())
628                            .unwrap_or("unknown")
629                            .to_string();
630                        let text = msg
631                            .get("text")
632                            .and_then(|t| t.as_str())
633                            .unwrap_or("")
634                            .to_string();
635                        let thread_ts = msg
636                            .get("thread_ts")
637                            .and_then(|t| t.as_str())
638                            .map(|s| s.to_string());
639                        Some(SlackMessage {
640                            ts,
641                            channel: channel.to_string(),
642                            user,
643                            text,
644                            thread_ts,
645                        })
646                    })
647                    .collect()
648            })
649            .unwrap_or_default();
650
651        Ok(messages)
652    }
653
654    async fn auth_test(&self) -> Result<String, String> {
655        let json = self
656            .slack_post("https://slack.com/api/auth.test", &serde_json::json!({}))
657            .await?;
658        json.get("user_id")
659            .and_then(|u| u.as_str())
660            .map(|s| s.to_string())
661            .ok_or_else(|| "Missing 'user_id' in auth.test response".to_string())
662    }
663
664    async fn conversations_list(
665        &self,
666        types: &str,
667        limit: usize,
668    ) -> Result<Vec<SlackChannelInfo>, String> {
669        let url = format!(
670            "https://slack.com/api/conversations.list?types={}&limit={}&exclude_archived=true",
671            types, limit
672        );
673        let json = self.slack_get(&url).await?;
674
675        let channels = json
676            .get("channels")
677            .and_then(|c| c.as_array())
678            .map(|arr| {
679                arr.iter()
680                    .filter_map(|ch| {
681                        Some(SlackChannelInfo {
682                            id: ch.get("id")?.as_str()?.to_string(),
683                            name: ch.get("name")?.as_str()?.to_string(),
684                            is_private: ch
685                                .get("is_private")
686                                .and_then(|v| v.as_bool())
687                                .unwrap_or(false),
688                            is_member: ch
689                                .get("is_member")
690                                .and_then(|v| v.as_bool())
691                                .unwrap_or(false),
692                            num_members: ch
693                                .get("num_members")
694                                .and_then(|v| v.as_u64())
695                                .unwrap_or(0),
696                            topic: ch
697                                .get("topic")
698                                .and_then(|t| t.get("value"))
699                                .and_then(|v| v.as_str())
700                                .unwrap_or("")
701                                .to_string(),
702                            purpose: ch
703                                .get("purpose")
704                                .and_then(|p| p.get("value"))
705                                .and_then(|v| v.as_str())
706                                .unwrap_or("")
707                                .to_string(),
708                        })
709                    })
710                    .collect()
711            })
712            .unwrap_or_default();
713
714        Ok(channels)
715    }
716
717    async fn conversations_join(&self, channel_id: &str) -> Result<(), String> {
718        let body = serde_json::json!({ "channel": channel_id });
719        self.slack_post("https://slack.com/api/conversations.join", &body)
720            .await?;
721        Ok(())
722    }
723
724    async fn conversations_info(&self, channel_id: &str) -> Result<SlackChannelInfo, String> {
725        let url = format!(
726            "https://slack.com/api/conversations.info?channel={}",
727            channel_id
728        );
729        let json = self.slack_get(&url).await?;
730
731        let ch = json.get("channel").ok_or("Missing 'channel' in response")?;
732        Ok(SlackChannelInfo {
733            id: ch
734                .get("id")
735                .and_then(|v| v.as_str())
736                .unwrap_or("")
737                .to_string(),
738            name: ch
739                .get("name")
740                .and_then(|v| v.as_str())
741                .unwrap_or("")
742                .to_string(),
743            is_private: ch
744                .get("is_private")
745                .and_then(|v| v.as_bool())
746                .unwrap_or(false),
747            is_member: ch
748                .get("is_member")
749                .and_then(|v| v.as_bool())
750                .unwrap_or(false),
751            num_members: ch.get("num_members").and_then(|v| v.as_u64()).unwrap_or(0),
752            topic: ch
753                .get("topic")
754                .and_then(|t| t.get("value"))
755                .and_then(|v| v.as_str())
756                .unwrap_or("")
757                .to_string(),
758            purpose: ch
759                .get("purpose")
760                .and_then(|p| p.get("value"))
761                .and_then(|v| v.as_str())
762                .unwrap_or("")
763                .to_string(),
764        })
765    }
766
767    async fn users_list(&self, limit: usize) -> Result<Vec<SlackUserInfo>, String> {
768        let url = format!("https://slack.com/api/users.list?limit={}", limit);
769        let json = self.slack_get(&url).await?;
770
771        let users = json
772            .get("members")
773            .and_then(|m| m.as_array())
774            .map(|arr| {
775                arr.iter()
776                    .filter_map(|u| {
777                        let profile = u.get("profile")?;
778                        Some(SlackUserInfo {
779                            id: u.get("id")?.as_str()?.to_string(),
780                            name: u
781                                .get("name")
782                                .and_then(|v| v.as_str())
783                                .unwrap_or("")
784                                .to_string(),
785                            real_name: profile
786                                .get("real_name")
787                                .and_then(|v| v.as_str())
788                                .unwrap_or("")
789                                .to_string(),
790                            display_name: profile
791                                .get("display_name")
792                                .and_then(|v| v.as_str())
793                                .unwrap_or("")
794                                .to_string(),
795                            is_bot: u.get("is_bot").and_then(|v| v.as_bool()).unwrap_or(false),
796                            is_admin: u.get("is_admin").and_then(|v| v.as_bool()).unwrap_or(false),
797                            email: profile
798                                .get("email")
799                                .and_then(|v| v.as_str())
800                                .map(|s| s.to_string()),
801                            status_text: profile
802                                .get("status_text")
803                                .and_then(|v| v.as_str())
804                                .unwrap_or("")
805                                .to_string(),
806                            status_emoji: profile
807                                .get("status_emoji")
808                                .and_then(|v| v.as_str())
809                                .unwrap_or("")
810                                .to_string(),
811                        })
812                    })
813                    .collect()
814            })
815            .unwrap_or_default();
816
817        Ok(users)
818    }
819
820    async fn users_info(&self, user_id: &str) -> Result<SlackUserInfo, String> {
821        let url = format!("https://slack.com/api/users.info?user={}", user_id);
822        let json = self.slack_get(&url).await?;
823
824        let u = json.get("user").ok_or("Missing 'user' in response")?;
825        let profile = u.get("profile").ok_or("Missing 'profile'")?;
826
827        Ok(SlackUserInfo {
828            id: u
829                .get("id")
830                .and_then(|v| v.as_str())
831                .unwrap_or("")
832                .to_string(),
833            name: u
834                .get("name")
835                .and_then(|v| v.as_str())
836                .unwrap_or("")
837                .to_string(),
838            real_name: profile
839                .get("real_name")
840                .and_then(|v| v.as_str())
841                .unwrap_or("")
842                .to_string(),
843            display_name: profile
844                .get("display_name")
845                .and_then(|v| v.as_str())
846                .unwrap_or("")
847                .to_string(),
848            is_bot: u.get("is_bot").and_then(|v| v.as_bool()).unwrap_or(false),
849            is_admin: u.get("is_admin").and_then(|v| v.as_bool()).unwrap_or(false),
850            email: profile
851                .get("email")
852                .and_then(|v| v.as_str())
853                .map(|s| s.to_string()),
854            status_text: profile
855                .get("status_text")
856                .and_then(|v| v.as_str())
857                .unwrap_or("")
858                .to_string(),
859            status_emoji: profile
860                .get("status_emoji")
861                .and_then(|v| v.as_str())
862                .unwrap_or("")
863                .to_string(),
864        })
865    }
866
867    async fn reactions_add(
868        &self,
869        channel: &str,
870        timestamp: &str,
871        name: &str,
872    ) -> Result<(), String> {
873        let body = serde_json::json!({ "channel": channel, "timestamp": timestamp, "name": name });
874        self.slack_post("https://slack.com/api/reactions.add", &body)
875            .await?;
876        Ok(())
877    }
878
879    async fn reactions_get(
880        &self,
881        channel: &str,
882        timestamp: &str,
883    ) -> Result<Vec<SlackReaction>, String> {
884        let url = format!(
885            "https://slack.com/api/reactions.get?channel={}&timestamp={}&full=true",
886            channel, timestamp
887        );
888        let json = self.slack_get(&url).await?;
889
890        let reactions = json
891            .get("message")
892            .and_then(|m| m.get("reactions"))
893            .and_then(|r| r.as_array())
894            .map(|arr| {
895                arr.iter()
896                    .filter_map(|r| {
897                        Some(SlackReaction {
898                            name: r.get("name")?.as_str()?.to_string(),
899                            count: r.get("count").and_then(|c| c.as_u64()).unwrap_or(0),
900                            users: r
901                                .get("users")
902                                .and_then(|u| u.as_array())
903                                .map(|a| {
904                                    a.iter()
905                                        .filter_map(|v| v.as_str().map(|s| s.to_string()))
906                                        .collect()
907                                })
908                                .unwrap_or_default(),
909                        })
910                    })
911                    .collect()
912            })
913            .unwrap_or_default();
914
915        Ok(reactions)
916    }
917
918    async fn files_list(
919        &self,
920        channel: Option<&str>,
921        limit: usize,
922    ) -> Result<Vec<SlackFile>, String> {
923        let mut url = format!("https://slack.com/api/files.list?count={}", limit);
924        if let Some(ch) = channel {
925            url.push_str(&format!("&channel={}", ch));
926        }
927
928        let json = self.slack_get(&url).await?;
929
930        let files = json
931            .get("files")
932            .and_then(|f| f.as_array())
933            .map(|arr| {
934                arr.iter()
935                    .filter_map(|f| {
936                        Some(SlackFile {
937                            id: f.get("id")?.as_str()?.to_string(),
938                            name: f
939                                .get("name")
940                                .and_then(|v| v.as_str())
941                                .unwrap_or("unnamed")
942                                .to_string(),
943                            filetype: f
944                                .get("filetype")
945                                .and_then(|v| v.as_str())
946                                .unwrap_or("")
947                                .to_string(),
948                            size: f.get("size").and_then(|v| v.as_u64()).unwrap_or(0),
949                            url_private: f
950                                .get("url_private")
951                                .and_then(|v| v.as_str())
952                                .unwrap_or("")
953                                .to_string(),
954                            user: f
955                                .get("user")
956                                .and_then(|v| v.as_str())
957                                .unwrap_or("")
958                                .to_string(),
959                            timestamp: f.get("timestamp").and_then(|v| v.as_u64()).unwrap_or(0),
960                        })
961                    })
962                    .collect()
963            })
964            .unwrap_or_default();
965
966        Ok(files)
967    }
968
969    async fn team_info(&self) -> Result<SlackTeamInfo, String> {
970        let json = self.slack_get("https://slack.com/api/team.info").await?;
971
972        let team = json.get("team").ok_or("Missing 'team' in response")?;
973        Ok(SlackTeamInfo {
974            id: team
975                .get("id")
976                .and_then(|v| v.as_str())
977                .unwrap_or("")
978                .to_string(),
979            name: team
980                .get("name")
981                .and_then(|v| v.as_str())
982                .unwrap_or("")
983                .to_string(),
984            domain: team
985                .get("domain")
986                .and_then(|v| v.as_str())
987                .unwrap_or("")
988                .to_string(),
989            icon_url: team
990                .get("icon")
991                .and_then(|i| i.get("image_132"))
992                .and_then(|v| v.as_str())
993                .map(|s| s.to_string()),
994        })
995    }
996
997    async fn usergroups_list(&self) -> Result<Vec<SlackUserGroup>, String> {
998        let json = self
999            .slack_get("https://slack.com/api/usergroups.list?include_count=true")
1000            .await?;
1001
1002        let groups = json
1003            .get("usergroups")
1004            .and_then(|g| g.as_array())
1005            .map(|arr| {
1006                arr.iter()
1007                    .filter_map(|g| {
1008                        Some(SlackUserGroup {
1009                            id: g.get("id")?.as_str()?.to_string(),
1010                            name: g
1011                                .get("name")
1012                                .and_then(|v| v.as_str())
1013                                .unwrap_or("")
1014                                .to_string(),
1015                            handle: g
1016                                .get("handle")
1017                                .and_then(|v| v.as_str())
1018                                .unwrap_or("")
1019                                .to_string(),
1020                            description: g
1021                                .get("description")
1022                                .and_then(|v| v.as_str())
1023                                .unwrap_or("")
1024                                .to_string(),
1025                            user_count: g.get("user_count").and_then(|v| v.as_u64()).unwrap_or(0),
1026                        })
1027                    })
1028                    .collect()
1029            })
1030            .unwrap_or_default();
1031
1032        Ok(groups)
1033    }
1034
1035    async fn conversations_open(&self, user_ids: &[&str]) -> Result<String, String> {
1036        let body = serde_json::json!({ "users": user_ids.join(",") });
1037        let json = self
1038            .slack_post("https://slack.com/api/conversations.open", &body)
1039            .await?;
1040
1041        json.get("channel")
1042            .and_then(|c| c.get("id"))
1043            .and_then(|id| id.as_str())
1044            .map(|s| s.to_string())
1045            .ok_or_else(|| "Missing 'channel.id' in response".to_string())
1046    }
1047}
1048
1049/// Create a SlackChannel with a real HTTP client.
1050pub fn create_slack_channel(config: SlackConfig) -> SlackChannel {
1051    let resolved_token = config.resolve_bot_token().unwrap_or_else(|e| {
1052        tracing::warn!(
1053            "Failed to resolve Slack bot token: {}. Falling back to raw value.",
1054            e
1055        );
1056        config.bot_token.as_str().to_string()
1057    });
1058    let http = RealSlackHttp::new(resolved_token);
1059    SlackChannel::new(config, Box::new(http))
1060}
1061
1062// ── Tests ──────────────────────────────────────────────────────────────────
1063
1064#[cfg(test)]
1065mod tests {
1066    use super::*;
1067    use crate::channels::ChannelUser;
1068    use std::sync::{Arc, Mutex};
1069
1070    struct MockSlackHttp {
1071        sent: Arc<Mutex<Vec<(String, String)>>>,
1072        messages: Vec<SlackMessage>,
1073        auth_ok: bool,
1074    }
1075
1076    impl MockSlackHttp {
1077        fn new() -> Self {
1078            Self {
1079                sent: Arc::new(Mutex::new(Vec::new())),
1080                messages: Vec::new(),
1081                auth_ok: true,
1082            }
1083        }
1084
1085        fn with_messages(mut self, messages: Vec<SlackMessage>) -> Self {
1086            self.messages = messages;
1087            self
1088        }
1089    }
1090
1091    #[async_trait]
1092    impl SlackHttpClient for MockSlackHttp {
1093        async fn post_message(&self, channel: &str, text: &str) -> Result<String, String> {
1094            self.sent
1095                .lock()
1096                .unwrap()
1097                .push((channel.to_string(), text.to_string()));
1098            Ok("1234567890.123456".to_string())
1099        }
1100
1101        async fn post_thread_reply(
1102            &self,
1103            channel: &str,
1104            _thread_ts: &str,
1105            text: &str,
1106        ) -> Result<String, String> {
1107            self.sent
1108                .lock()
1109                .unwrap()
1110                .push((channel.to_string(), text.to_string()));
1111            Ok("1234567890.654321".to_string())
1112        }
1113
1114        async fn conversations_history(
1115            &self,
1116            _channel: &str,
1117            _limit: usize,
1118        ) -> Result<Vec<SlackMessage>, String> {
1119            Ok(self.messages.clone())
1120        }
1121
1122        async fn auth_test(&self) -> Result<String, String> {
1123            if self.auth_ok {
1124                Ok("bot-user-id".to_string())
1125            } else {
1126                Err("invalid_auth".to_string())
1127            }
1128        }
1129
1130        async fn conversations_list(
1131            &self,
1132            _types: &str,
1133            _limit: usize,
1134        ) -> Result<Vec<SlackChannelInfo>, String> {
1135            Ok(vec![
1136                SlackChannelInfo {
1137                    id: "C001".into(),
1138                    name: "general".into(),
1139                    is_private: false,
1140                    is_member: true,
1141                    num_members: 42,
1142                    topic: "General chat".into(),
1143                    purpose: "Company-wide".into(),
1144                },
1145                SlackChannelInfo {
1146                    id: "C002".into(),
1147                    name: "random".into(),
1148                    is_private: false,
1149                    is_member: true,
1150                    num_members: 38,
1151                    topic: "".into(),
1152                    purpose: "Random stuff".into(),
1153                },
1154            ])
1155        }
1156
1157        async fn conversations_join(&self, _channel_id: &str) -> Result<(), String> {
1158            Ok(())
1159        }
1160
1161        async fn conversations_info(&self, channel_id: &str) -> Result<SlackChannelInfo, String> {
1162            Ok(SlackChannelInfo {
1163                id: channel_id.to_string(),
1164                name: "general".into(),
1165                is_private: false,
1166                is_member: true,
1167                num_members: 42,
1168                topic: "General chat".into(),
1169                purpose: "Company-wide".into(),
1170            })
1171        }
1172
1173        async fn users_list(&self, _limit: usize) -> Result<Vec<SlackUserInfo>, String> {
1174            Ok(vec![SlackUserInfo {
1175                id: "U001".into(),
1176                name: "alice".into(),
1177                real_name: "Alice Smith".into(),
1178                display_name: "alice".into(),
1179                is_bot: false,
1180                is_admin: true,
1181                email: Some("alice@example.com".into()),
1182                status_text: "Working".into(),
1183                status_emoji: ":computer:".into(),
1184            }])
1185        }
1186
1187        async fn users_info(&self, user_id: &str) -> Result<SlackUserInfo, String> {
1188            Ok(SlackUserInfo {
1189                id: user_id.to_string(),
1190                name: "alice".into(),
1191                real_name: "Alice Smith".into(),
1192                display_name: "alice".into(),
1193                is_bot: false,
1194                is_admin: true,
1195                email: Some("alice@example.com".into()),
1196                status_text: "Working".into(),
1197                status_emoji: ":computer:".into(),
1198            })
1199        }
1200
1201        async fn reactions_add(
1202            &self,
1203            _channel: &str,
1204            _timestamp: &str,
1205            _name: &str,
1206        ) -> Result<(), String> {
1207            Ok(())
1208        }
1209
1210        async fn reactions_get(
1211            &self,
1212            _channel: &str,
1213            _timestamp: &str,
1214        ) -> Result<Vec<SlackReaction>, String> {
1215            Ok(vec![SlackReaction {
1216                name: "thumbsup".into(),
1217                count: 3,
1218                users: vec!["U001".into(), "U002".into(), "U003".into()],
1219            }])
1220        }
1221
1222        async fn files_list(
1223            &self,
1224            _channel: Option<&str>,
1225            _limit: usize,
1226        ) -> Result<Vec<SlackFile>, String> {
1227            Ok(vec![SlackFile {
1228                id: "F001".into(),
1229                name: "report.pdf".into(),
1230                filetype: "pdf".into(),
1231                size: 1024,
1232                url_private: "https://files.slack.com/report.pdf".into(),
1233                user: "U001".into(),
1234                timestamp: 1700000000,
1235            }])
1236        }
1237
1238        async fn team_info(&self) -> Result<SlackTeamInfo, String> {
1239            Ok(SlackTeamInfo {
1240                id: "T001".into(),
1241                name: "Test Workspace".into(),
1242                domain: "test-workspace".into(),
1243                icon_url: None,
1244            })
1245        }
1246
1247        async fn usergroups_list(&self) -> Result<Vec<SlackUserGroup>, String> {
1248            Ok(vec![SlackUserGroup {
1249                id: "S001".into(),
1250                name: "Engineering".into(),
1251                handle: "engineering".into(),
1252                description: "Engineering team".into(),
1253                user_count: 15,
1254            }])
1255        }
1256
1257        async fn conversations_open(&self, _user_ids: &[&str]) -> Result<String, String> {
1258            Ok("D001".to_string())
1259        }
1260    }
1261
1262    // ── Existing tests ─────────────────────────────────────────────────
1263
1264    #[tokio::test]
1265    async fn test_slack_connect() {
1266        let config = SlackConfig {
1267            bot_token: "xoxb-123".into(),
1268            ..Default::default()
1269        };
1270        let mut ch = SlackChannel::new(config, Box::new(MockSlackHttp::new()));
1271        ch.connect().await.unwrap();
1272        assert!(ch.is_connected());
1273    }
1274
1275    #[tokio::test]
1276    async fn test_slack_send_message() {
1277        let config = SlackConfig {
1278            bot_token: "xoxb-123".into(),
1279            default_channel: Some("general".into()),
1280            ..Default::default()
1281        };
1282        let http = MockSlackHttp::new();
1283        let sent = http.sent.clone();
1284        let mut ch = SlackChannel::new(config, Box::new(http));
1285        ch.connect().await.unwrap();
1286
1287        let sender = ChannelUser::new("bot", ChannelType::Slack);
1288        let msg = ChannelMessage::text(ChannelType::Slack, "random", sender, "Hello Slack!");
1289        ch.send_message(msg).await.unwrap();
1290
1291        let sent = sent.lock().unwrap();
1292        assert_eq!(sent[0].0, "random");
1293        assert_eq!(sent[0].1, "Hello Slack!");
1294    }
1295
1296    #[tokio::test]
1297    async fn test_slack_receive_messages() {
1298        let config = SlackConfig {
1299            bot_token: "xoxb-123".into(),
1300            allowed_channels: vec!["general".into()],
1301            ..Default::default()
1302        };
1303        let http = MockSlackHttp::new().with_messages(vec![SlackMessage {
1304            ts: "123.456".into(),
1305            channel: "general".into(),
1306            user: "U123".into(),
1307            text: "hey".into(),
1308            thread_ts: None,
1309        }]);
1310        let mut ch = SlackChannel::new(config, Box::new(http));
1311        ch.connect().await.unwrap();
1312
1313        let msgs = ch.receive_messages().await.unwrap();
1314        assert_eq!(msgs.len(), 1);
1315        assert_eq!(msgs[0].content.as_text(), Some("hey"));
1316    }
1317
1318    #[test]
1319    fn test_slack_capabilities() {
1320        let ch = SlackChannel::new(SlackConfig::default(), Box::new(MockSlackHttp::new()));
1321        let caps = ch.capabilities();
1322        assert!(caps.supports_threads);
1323        assert!(caps.supports_reactions);
1324        assert!(caps.supports_files);
1325        assert!(!caps.supports_voice);
1326        assert_eq!(caps.max_message_length, Some(40000));
1327    }
1328
1329    #[test]
1330    fn test_slack_streaming_mode() {
1331        let ch = SlackChannel::new(SlackConfig::default(), Box::new(MockSlackHttp::new()));
1332        assert_eq!(ch.streaming_mode(), StreamingMode::WebSocket);
1333    }
1334
1335    #[tokio::test]
1336    async fn test_slack_oauth_config_connect() {
1337        let config = SlackConfig {
1338            bot_token: "xoxb-oauth-token-from-oauth-flow".into(),
1339            auth_method: AuthMethod::OAuth,
1340            ..Default::default()
1341        };
1342        let mut ch = SlackChannel::new(config, Box::new(MockSlackHttp::new()));
1343        ch.connect().await.unwrap();
1344        assert!(ch.is_connected());
1345    }
1346
1347    #[test]
1348    fn test_slack_config_auth_method_default() {
1349        let config = SlackConfig::default();
1350        assert_eq!(config.auth_method, AuthMethod::ApiKey);
1351    }
1352
1353    #[test]
1354    fn test_slack_config_auth_method_serde() {
1355        let config = SlackConfig {
1356            bot_token: "xoxb-test".into(),
1357            auth_method: AuthMethod::OAuth,
1358            ..Default::default()
1359        };
1360        let json = serde_json::to_string(&config).unwrap();
1361        assert!(json.contains("\"oauth\""));
1362        let parsed: SlackConfig = serde_json::from_str(&json).unwrap();
1363        assert_eq!(parsed.auth_method, AuthMethod::OAuth);
1364    }
1365
1366    // ── New feature tests ──────────────────────────────────────────────
1367
1368    #[tokio::test]
1369    async fn test_slack_list_channels() {
1370        let ch = SlackChannel::new(SlackConfig::default(), Box::new(MockSlackHttp::new()));
1371        let channels = ch.list_channels().await.unwrap();
1372        assert_eq!(channels.len(), 2);
1373        assert_eq!(channels[0].name, "general");
1374        assert_eq!(channels[1].name, "random");
1375        assert_eq!(channels[0].num_members, 42);
1376    }
1377
1378    #[tokio::test]
1379    async fn test_slack_channel_info() {
1380        let ch = SlackChannel::new(SlackConfig::default(), Box::new(MockSlackHttp::new()));
1381        let info = ch.channel_info("C001").await.unwrap();
1382        assert_eq!(info.id, "C001");
1383        assert_eq!(info.name, "general");
1384    }
1385
1386    #[tokio::test]
1387    async fn test_slack_join_channel() {
1388        let ch = SlackChannel::new(SlackConfig::default(), Box::new(MockSlackHttp::new()));
1389        ch.join_channel("C002").await.unwrap();
1390    }
1391
1392    #[tokio::test]
1393    async fn test_slack_list_users() {
1394        let ch = SlackChannel::new(SlackConfig::default(), Box::new(MockSlackHttp::new()));
1395        let users = ch.list_users().await.unwrap();
1396        assert_eq!(users.len(), 1);
1397        assert_eq!(users[0].name, "alice");
1398        assert_eq!(users[0].real_name, "Alice Smith");
1399        assert!(users[0].is_admin);
1400        assert!(!users[0].is_bot);
1401    }
1402
1403    #[tokio::test]
1404    async fn test_slack_get_user_info() {
1405        let ch = SlackChannel::new(SlackConfig::default(), Box::new(MockSlackHttp::new()));
1406        let user = ch.get_user_info("U001").await.unwrap();
1407        assert_eq!(user.id, "U001");
1408        assert_eq!(user.email, Some("alice@example.com".into()));
1409    }
1410
1411    #[tokio::test]
1412    async fn test_slack_add_reaction() {
1413        let ch = SlackChannel::new(SlackConfig::default(), Box::new(MockSlackHttp::new()));
1414        ch.add_reaction("C001", "123.456", "thumbsup")
1415            .await
1416            .unwrap();
1417    }
1418
1419    #[tokio::test]
1420    async fn test_slack_get_reactions() {
1421        let ch = SlackChannel::new(SlackConfig::default(), Box::new(MockSlackHttp::new()));
1422        let reactions = ch.get_reactions("C001", "123.456").await.unwrap();
1423        assert_eq!(reactions.len(), 1);
1424        assert_eq!(reactions[0].name, "thumbsup");
1425        assert_eq!(reactions[0].count, 3);
1426        assert_eq!(reactions[0].users.len(), 3);
1427    }
1428
1429    #[tokio::test]
1430    async fn test_slack_list_files() {
1431        let ch = SlackChannel::new(SlackConfig::default(), Box::new(MockSlackHttp::new()));
1432        let files = ch.list_files(None).await.unwrap();
1433        assert_eq!(files.len(), 1);
1434        assert_eq!(files[0].name, "report.pdf");
1435        assert_eq!(files[0].size, 1024);
1436    }
1437
1438    #[tokio::test]
1439    async fn test_slack_team_info() {
1440        let ch = SlackChannel::new(SlackConfig::default(), Box::new(MockSlackHttp::new()));
1441        let team = ch.get_team_info().await.unwrap();
1442        assert_eq!(team.name, "Test Workspace");
1443        assert_eq!(team.domain, "test-workspace");
1444    }
1445
1446    #[tokio::test]
1447    async fn test_slack_list_usergroups() {
1448        let ch = SlackChannel::new(SlackConfig::default(), Box::new(MockSlackHttp::new()));
1449        let groups = ch.list_usergroups().await.unwrap();
1450        assert_eq!(groups.len(), 1);
1451        assert_eq!(groups[0].handle, "engineering");
1452        assert_eq!(groups[0].user_count, 15);
1453    }
1454
1455    #[tokio::test]
1456    async fn test_slack_open_dm() {
1457        let ch = SlackChannel::new(SlackConfig::default(), Box::new(MockSlackHttp::new()));
1458        let dm_channel = ch.open_dm(&["U001"]).await.unwrap();
1459        assert_eq!(dm_channel, "D001");
1460    }
1461
1462    #[tokio::test]
1463    async fn test_slack_thread_reply() {
1464        let http = MockSlackHttp::new();
1465        let sent = http.sent.clone();
1466        let ch = SlackChannel::new(SlackConfig::default(), Box::new(http));
1467        let id = ch
1468            .reply_in_thread("C001", "123.456", "reply text")
1469            .await
1470            .unwrap();
1471        assert!(!id.0.is_empty());
1472        let sent = sent.lock().unwrap();
1473        assert_eq!(sent[0].1, "reply text");
1474    }
1475
1476    #[tokio::test]
1477    async fn test_slack_read_history() {
1478        let http = MockSlackHttp::new().with_messages(vec![SlackMessage {
1479            ts: "999.888".into(),
1480            channel: "test".into(),
1481            user: "U001".into(),
1482            text: "history msg".into(),
1483            thread_ts: None,
1484        }]);
1485        let ch = SlackChannel::new(SlackConfig::default(), Box::new(http));
1486        let msgs = ch.read_history("test", 10).await.unwrap();
1487        assert_eq!(msgs.len(), 1);
1488        assert_eq!(msgs[0].text, "history msg");
1489    }
1490}