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![
431                self.config
432                    .default_channel
433                    .clone()
434                    .unwrap_or_else(|| "general".to_string()),
435            ]
436        } else {
437            self.config.allowed_channels.clone()
438        };
439
440        let mut all = Vec::new();
441        for ch in &channels {
442            let slack_msgs = self
443                .http_client
444                .conversations_history(ch, 25)
445                .await
446                .map_err(|e| {
447                    RustantError::Channel(ChannelError::ConnectionFailed {
448                        name: self.name.clone(),
449                        message: e,
450                    })
451                })?;
452
453            for sm in slack_msgs {
454                let sender = ChannelUser::new(&sm.user, ChannelType::Slack);
455                let msg = ChannelMessage::text(ChannelType::Slack, &sm.channel, sender, &sm.text);
456                all.push(msg);
457            }
458        }
459        Ok(all)
460    }
461
462    fn status(&self) -> ChannelStatus {
463        self.status
464    }
465
466    fn capabilities(&self) -> ChannelCapabilities {
467        ChannelCapabilities {
468            supports_threads: true,
469            supports_reactions: true,
470            supports_files: true,
471            supports_voice: false,
472            supports_video: false,
473            max_message_length: Some(40000),
474            supports_editing: false,
475            supports_deletion: false,
476        }
477    }
478
479    fn streaming_mode(&self) -> StreamingMode {
480        StreamingMode::WebSocket
481    }
482}
483
484// ── Real HTTP client ───────────────────────────────────────────────────────
485
486/// Real Slack HTTP client using the Slack Web API via reqwest.
487pub struct RealSlackHttp {
488    client: reqwest::Client,
489    bot_token: String,
490}
491
492impl RealSlackHttp {
493    pub fn new(bot_token: String) -> Self {
494        Self {
495            client: reqwest::Client::new(),
496            bot_token,
497        }
498    }
499
500    fn auth_header(&self) -> String {
501        format!("Bearer {}", self.bot_token)
502    }
503
504    /// Make a GET request to a Slack API endpoint and parse the JSON response.
505    async fn slack_get(&self, url: &str) -> Result<serde_json::Value, String> {
506        let resp = self
507            .client
508            .get(url)
509            .header("Authorization", self.auth_header())
510            .send()
511            .await
512            .map_err(|e| format!("HTTP request failed: {}", e))?;
513
514        let status = resp.status();
515        let body = resp
516            .text()
517            .await
518            .map_err(|e| format!("Failed to read response: {}", e))?;
519
520        if !status.is_success() {
521            return Err(format!("HTTP {}: {}", status, body));
522        }
523
524        let json: serde_json::Value =
525            serde_json::from_str(&body).map_err(|e| format!("Invalid JSON: {}", e))?;
526
527        if json.get("ok") != Some(&serde_json::Value::Bool(true)) {
528            let error = json
529                .get("error")
530                .and_then(|e| e.as_str())
531                .unwrap_or("unknown_error");
532            return Err(format!("Slack API error: {}", error));
533        }
534
535        Ok(json)
536    }
537
538    /// Make a POST request to a Slack API endpoint with a JSON body.
539    async fn slack_post(
540        &self,
541        url: &str,
542        body: &serde_json::Value,
543    ) -> Result<serde_json::Value, String> {
544        let resp = self
545            .client
546            .post(url)
547            .header("Authorization", self.auth_header())
548            .header("Content-Type", "application/json; charset=utf-8")
549            .json(body)
550            .send()
551            .await
552            .map_err(|e| format!("HTTP request failed: {}", e))?;
553
554        let status = resp.status();
555        let body_text = resp
556            .text()
557            .await
558            .map_err(|e| format!("Failed to read response: {}", e))?;
559
560        if !status.is_success() {
561            return Err(format!("HTTP {}: {}", status, body_text));
562        }
563
564        let json: serde_json::Value =
565            serde_json::from_str(&body_text).map_err(|e| format!("Invalid JSON: {}", e))?;
566
567        if json.get("ok") != Some(&serde_json::Value::Bool(true)) {
568            let error = json
569                .get("error")
570                .and_then(|e| e.as_str())
571                .unwrap_or("unknown_error");
572            return Err(format!("Slack API error: {}", error));
573        }
574
575        Ok(json)
576    }
577}
578
579#[async_trait]
580impl SlackHttpClient for RealSlackHttp {
581    async fn post_message(&self, channel: &str, text: &str) -> Result<String, String> {
582        let body = serde_json::json!({ "channel": channel, "text": text });
583        let json = self
584            .slack_post("https://slack.com/api/chat.postMessage", &body)
585            .await?;
586        json.get("ts")
587            .and_then(|ts| ts.as_str())
588            .map(|s| s.to_string())
589            .ok_or_else(|| "Missing 'ts' in response".to_string())
590    }
591
592    async fn post_thread_reply(
593        &self,
594        channel: &str,
595        thread_ts: &str,
596        text: &str,
597    ) -> Result<String, String> {
598        let body = serde_json::json!({ "channel": channel, "thread_ts": thread_ts, "text": text });
599        let json = self
600            .slack_post("https://slack.com/api/chat.postMessage", &body)
601            .await?;
602        json.get("ts")
603            .and_then(|ts| ts.as_str())
604            .map(|s| s.to_string())
605            .ok_or_else(|| "Missing 'ts' in response".to_string())
606    }
607
608    async fn conversations_history(
609        &self,
610        channel: &str,
611        limit: usize,
612    ) -> Result<Vec<SlackMessage>, String> {
613        let url = format!(
614            "https://slack.com/api/conversations.history?channel={}&limit={}",
615            channel, limit
616        );
617        let json = self.slack_get(&url).await?;
618
619        let messages = json
620            .get("messages")
621            .and_then(|m| m.as_array())
622            .map(|arr| {
623                arr.iter()
624                    .filter_map(|msg| {
625                        let ts = msg.get("ts")?.as_str()?.to_string();
626                        let user = msg
627                            .get("user")
628                            .and_then(|u| u.as_str())
629                            .unwrap_or("unknown")
630                            .to_string();
631                        let text = msg
632                            .get("text")
633                            .and_then(|t| t.as_str())
634                            .unwrap_or("")
635                            .to_string();
636                        let thread_ts = msg
637                            .get("thread_ts")
638                            .and_then(|t| t.as_str())
639                            .map(|s| s.to_string());
640                        Some(SlackMessage {
641                            ts,
642                            channel: channel.to_string(),
643                            user,
644                            text,
645                            thread_ts,
646                        })
647                    })
648                    .collect()
649            })
650            .unwrap_or_default();
651
652        Ok(messages)
653    }
654
655    async fn auth_test(&self) -> Result<String, String> {
656        let json = self
657            .slack_post("https://slack.com/api/auth.test", &serde_json::json!({}))
658            .await?;
659        json.get("user_id")
660            .and_then(|u| u.as_str())
661            .map(|s| s.to_string())
662            .ok_or_else(|| "Missing 'user_id' in auth.test response".to_string())
663    }
664
665    async fn conversations_list(
666        &self,
667        types: &str,
668        limit: usize,
669    ) -> Result<Vec<SlackChannelInfo>, String> {
670        let url = format!(
671            "https://slack.com/api/conversations.list?types={}&limit={}&exclude_archived=true",
672            types, limit
673        );
674        let json = self.slack_get(&url).await?;
675
676        let channels = json
677            .get("channels")
678            .and_then(|c| c.as_array())
679            .map(|arr| {
680                arr.iter()
681                    .filter_map(|ch| {
682                        Some(SlackChannelInfo {
683                            id: ch.get("id")?.as_str()?.to_string(),
684                            name: ch.get("name")?.as_str()?.to_string(),
685                            is_private: ch
686                                .get("is_private")
687                                .and_then(|v| v.as_bool())
688                                .unwrap_or(false),
689                            is_member: ch
690                                .get("is_member")
691                                .and_then(|v| v.as_bool())
692                                .unwrap_or(false),
693                            num_members: ch
694                                .get("num_members")
695                                .and_then(|v| v.as_u64())
696                                .unwrap_or(0),
697                            topic: ch
698                                .get("topic")
699                                .and_then(|t| t.get("value"))
700                                .and_then(|v| v.as_str())
701                                .unwrap_or("")
702                                .to_string(),
703                            purpose: ch
704                                .get("purpose")
705                                .and_then(|p| p.get("value"))
706                                .and_then(|v| v.as_str())
707                                .unwrap_or("")
708                                .to_string(),
709                        })
710                    })
711                    .collect()
712            })
713            .unwrap_or_default();
714
715        Ok(channels)
716    }
717
718    async fn conversations_join(&self, channel_id: &str) -> Result<(), String> {
719        let body = serde_json::json!({ "channel": channel_id });
720        self.slack_post("https://slack.com/api/conversations.join", &body)
721            .await?;
722        Ok(())
723    }
724
725    async fn conversations_info(&self, channel_id: &str) -> Result<SlackChannelInfo, String> {
726        let url = format!(
727            "https://slack.com/api/conversations.info?channel={}",
728            channel_id
729        );
730        let json = self.slack_get(&url).await?;
731
732        let ch = json.get("channel").ok_or("Missing 'channel' in response")?;
733        Ok(SlackChannelInfo {
734            id: ch
735                .get("id")
736                .and_then(|v| v.as_str())
737                .unwrap_or("")
738                .to_string(),
739            name: ch
740                .get("name")
741                .and_then(|v| v.as_str())
742                .unwrap_or("")
743                .to_string(),
744            is_private: ch
745                .get("is_private")
746                .and_then(|v| v.as_bool())
747                .unwrap_or(false),
748            is_member: ch
749                .get("is_member")
750                .and_then(|v| v.as_bool())
751                .unwrap_or(false),
752            num_members: ch.get("num_members").and_then(|v| v.as_u64()).unwrap_or(0),
753            topic: ch
754                .get("topic")
755                .and_then(|t| t.get("value"))
756                .and_then(|v| v.as_str())
757                .unwrap_or("")
758                .to_string(),
759            purpose: ch
760                .get("purpose")
761                .and_then(|p| p.get("value"))
762                .and_then(|v| v.as_str())
763                .unwrap_or("")
764                .to_string(),
765        })
766    }
767
768    async fn users_list(&self, limit: usize) -> Result<Vec<SlackUserInfo>, String> {
769        let url = format!("https://slack.com/api/users.list?limit={}", limit);
770        let json = self.slack_get(&url).await?;
771
772        let users = json
773            .get("members")
774            .and_then(|m| m.as_array())
775            .map(|arr| {
776                arr.iter()
777                    .filter_map(|u| {
778                        let profile = u.get("profile")?;
779                        Some(SlackUserInfo {
780                            id: u.get("id")?.as_str()?.to_string(),
781                            name: u
782                                .get("name")
783                                .and_then(|v| v.as_str())
784                                .unwrap_or("")
785                                .to_string(),
786                            real_name: profile
787                                .get("real_name")
788                                .and_then(|v| v.as_str())
789                                .unwrap_or("")
790                                .to_string(),
791                            display_name: profile
792                                .get("display_name")
793                                .and_then(|v| v.as_str())
794                                .unwrap_or("")
795                                .to_string(),
796                            is_bot: u.get("is_bot").and_then(|v| v.as_bool()).unwrap_or(false),
797                            is_admin: u.get("is_admin").and_then(|v| v.as_bool()).unwrap_or(false),
798                            email: profile
799                                .get("email")
800                                .and_then(|v| v.as_str())
801                                .map(|s| s.to_string()),
802                            status_text: profile
803                                .get("status_text")
804                                .and_then(|v| v.as_str())
805                                .unwrap_or("")
806                                .to_string(),
807                            status_emoji: profile
808                                .get("status_emoji")
809                                .and_then(|v| v.as_str())
810                                .unwrap_or("")
811                                .to_string(),
812                        })
813                    })
814                    .collect()
815            })
816            .unwrap_or_default();
817
818        Ok(users)
819    }
820
821    async fn users_info(&self, user_id: &str) -> Result<SlackUserInfo, String> {
822        let url = format!("https://slack.com/api/users.info?user={}", user_id);
823        let json = self.slack_get(&url).await?;
824
825        let u = json.get("user").ok_or("Missing 'user' in response")?;
826        let profile = u.get("profile").ok_or("Missing 'profile'")?;
827
828        Ok(SlackUserInfo {
829            id: u
830                .get("id")
831                .and_then(|v| v.as_str())
832                .unwrap_or("")
833                .to_string(),
834            name: u
835                .get("name")
836                .and_then(|v| v.as_str())
837                .unwrap_or("")
838                .to_string(),
839            real_name: profile
840                .get("real_name")
841                .and_then(|v| v.as_str())
842                .unwrap_or("")
843                .to_string(),
844            display_name: profile
845                .get("display_name")
846                .and_then(|v| v.as_str())
847                .unwrap_or("")
848                .to_string(),
849            is_bot: u.get("is_bot").and_then(|v| v.as_bool()).unwrap_or(false),
850            is_admin: u.get("is_admin").and_then(|v| v.as_bool()).unwrap_or(false),
851            email: profile
852                .get("email")
853                .and_then(|v| v.as_str())
854                .map(|s| s.to_string()),
855            status_text: profile
856                .get("status_text")
857                .and_then(|v| v.as_str())
858                .unwrap_or("")
859                .to_string(),
860            status_emoji: profile
861                .get("status_emoji")
862                .and_then(|v| v.as_str())
863                .unwrap_or("")
864                .to_string(),
865        })
866    }
867
868    async fn reactions_add(
869        &self,
870        channel: &str,
871        timestamp: &str,
872        name: &str,
873    ) -> Result<(), String> {
874        let body = serde_json::json!({ "channel": channel, "timestamp": timestamp, "name": name });
875        self.slack_post("https://slack.com/api/reactions.add", &body)
876            .await?;
877        Ok(())
878    }
879
880    async fn reactions_get(
881        &self,
882        channel: &str,
883        timestamp: &str,
884    ) -> Result<Vec<SlackReaction>, String> {
885        let url = format!(
886            "https://slack.com/api/reactions.get?channel={}&timestamp={}&full=true",
887            channel, timestamp
888        );
889        let json = self.slack_get(&url).await?;
890
891        let reactions = json
892            .get("message")
893            .and_then(|m| m.get("reactions"))
894            .and_then(|r| r.as_array())
895            .map(|arr| {
896                arr.iter()
897                    .filter_map(|r| {
898                        Some(SlackReaction {
899                            name: r.get("name")?.as_str()?.to_string(),
900                            count: r.get("count").and_then(|c| c.as_u64()).unwrap_or(0),
901                            users: r
902                                .get("users")
903                                .and_then(|u| u.as_array())
904                                .map(|a| {
905                                    a.iter()
906                                        .filter_map(|v| v.as_str().map(|s| s.to_string()))
907                                        .collect()
908                                })
909                                .unwrap_or_default(),
910                        })
911                    })
912                    .collect()
913            })
914            .unwrap_or_default();
915
916        Ok(reactions)
917    }
918
919    async fn files_list(
920        &self,
921        channel: Option<&str>,
922        limit: usize,
923    ) -> Result<Vec<SlackFile>, String> {
924        let mut url = format!("https://slack.com/api/files.list?count={}", limit);
925        if let Some(ch) = channel {
926            url.push_str(&format!("&channel={}", ch));
927        }
928
929        let json = self.slack_get(&url).await?;
930
931        let files = json
932            .get("files")
933            .and_then(|f| f.as_array())
934            .map(|arr| {
935                arr.iter()
936                    .filter_map(|f| {
937                        Some(SlackFile {
938                            id: f.get("id")?.as_str()?.to_string(),
939                            name: f
940                                .get("name")
941                                .and_then(|v| v.as_str())
942                                .unwrap_or("unnamed")
943                                .to_string(),
944                            filetype: f
945                                .get("filetype")
946                                .and_then(|v| v.as_str())
947                                .unwrap_or("")
948                                .to_string(),
949                            size: f.get("size").and_then(|v| v.as_u64()).unwrap_or(0),
950                            url_private: f
951                                .get("url_private")
952                                .and_then(|v| v.as_str())
953                                .unwrap_or("")
954                                .to_string(),
955                            user: f
956                                .get("user")
957                                .and_then(|v| v.as_str())
958                                .unwrap_or("")
959                                .to_string(),
960                            timestamp: f.get("timestamp").and_then(|v| v.as_u64()).unwrap_or(0),
961                        })
962                    })
963                    .collect()
964            })
965            .unwrap_or_default();
966
967        Ok(files)
968    }
969
970    async fn team_info(&self) -> Result<SlackTeamInfo, String> {
971        let json = self.slack_get("https://slack.com/api/team.info").await?;
972
973        let team = json.get("team").ok_or("Missing 'team' in response")?;
974        Ok(SlackTeamInfo {
975            id: team
976                .get("id")
977                .and_then(|v| v.as_str())
978                .unwrap_or("")
979                .to_string(),
980            name: team
981                .get("name")
982                .and_then(|v| v.as_str())
983                .unwrap_or("")
984                .to_string(),
985            domain: team
986                .get("domain")
987                .and_then(|v| v.as_str())
988                .unwrap_or("")
989                .to_string(),
990            icon_url: team
991                .get("icon")
992                .and_then(|i| i.get("image_132"))
993                .and_then(|v| v.as_str())
994                .map(|s| s.to_string()),
995        })
996    }
997
998    async fn usergroups_list(&self) -> Result<Vec<SlackUserGroup>, String> {
999        let json = self
1000            .slack_get("https://slack.com/api/usergroups.list?include_count=true")
1001            .await?;
1002
1003        let groups = json
1004            .get("usergroups")
1005            .and_then(|g| g.as_array())
1006            .map(|arr| {
1007                arr.iter()
1008                    .filter_map(|g| {
1009                        Some(SlackUserGroup {
1010                            id: g.get("id")?.as_str()?.to_string(),
1011                            name: g
1012                                .get("name")
1013                                .and_then(|v| v.as_str())
1014                                .unwrap_or("")
1015                                .to_string(),
1016                            handle: g
1017                                .get("handle")
1018                                .and_then(|v| v.as_str())
1019                                .unwrap_or("")
1020                                .to_string(),
1021                            description: g
1022                                .get("description")
1023                                .and_then(|v| v.as_str())
1024                                .unwrap_or("")
1025                                .to_string(),
1026                            user_count: g.get("user_count").and_then(|v| v.as_u64()).unwrap_or(0),
1027                        })
1028                    })
1029                    .collect()
1030            })
1031            .unwrap_or_default();
1032
1033        Ok(groups)
1034    }
1035
1036    async fn conversations_open(&self, user_ids: &[&str]) -> Result<String, String> {
1037        let body = serde_json::json!({ "users": user_ids.join(",") });
1038        let json = self
1039            .slack_post("https://slack.com/api/conversations.open", &body)
1040            .await?;
1041
1042        json.get("channel")
1043            .and_then(|c| c.get("id"))
1044            .and_then(|id| id.as_str())
1045            .map(|s| s.to_string())
1046            .ok_or_else(|| "Missing 'channel.id' in response".to_string())
1047    }
1048}
1049
1050/// Create a SlackChannel with a real HTTP client.
1051pub fn create_slack_channel(config: SlackConfig) -> SlackChannel {
1052    let resolved_token = config.resolve_bot_token().unwrap_or_else(|e| {
1053        tracing::warn!(
1054            "Failed to resolve Slack bot token: {}. Falling back to raw value.",
1055            e
1056        );
1057        config.bot_token.as_str().to_string()
1058    });
1059    let http = RealSlackHttp::new(resolved_token);
1060    SlackChannel::new(config, Box::new(http))
1061}
1062
1063// ── Tests ──────────────────────────────────────────────────────────────────
1064
1065#[cfg(test)]
1066mod tests {
1067    use super::*;
1068    use crate::channels::ChannelUser;
1069    use std::sync::{Arc, Mutex};
1070
1071    struct MockSlackHttp {
1072        sent: Arc<Mutex<Vec<(String, String)>>>,
1073        messages: Vec<SlackMessage>,
1074        auth_ok: bool,
1075    }
1076
1077    impl MockSlackHttp {
1078        fn new() -> Self {
1079            Self {
1080                sent: Arc::new(Mutex::new(Vec::new())),
1081                messages: Vec::new(),
1082                auth_ok: true,
1083            }
1084        }
1085
1086        fn with_messages(mut self, messages: Vec<SlackMessage>) -> Self {
1087            self.messages = messages;
1088            self
1089        }
1090    }
1091
1092    #[async_trait]
1093    impl SlackHttpClient for MockSlackHttp {
1094        async fn post_message(&self, channel: &str, text: &str) -> Result<String, String> {
1095            self.sent
1096                .lock()
1097                .unwrap()
1098                .push((channel.to_string(), text.to_string()));
1099            Ok("1234567890.123456".to_string())
1100        }
1101
1102        async fn post_thread_reply(
1103            &self,
1104            channel: &str,
1105            _thread_ts: &str,
1106            text: &str,
1107        ) -> Result<String, String> {
1108            self.sent
1109                .lock()
1110                .unwrap()
1111                .push((channel.to_string(), text.to_string()));
1112            Ok("1234567890.654321".to_string())
1113        }
1114
1115        async fn conversations_history(
1116            &self,
1117            _channel: &str,
1118            _limit: usize,
1119        ) -> Result<Vec<SlackMessage>, String> {
1120            Ok(self.messages.clone())
1121        }
1122
1123        async fn auth_test(&self) -> Result<String, String> {
1124            if self.auth_ok {
1125                Ok("bot-user-id".to_string())
1126            } else {
1127                Err("invalid_auth".to_string())
1128            }
1129        }
1130
1131        async fn conversations_list(
1132            &self,
1133            _types: &str,
1134            _limit: usize,
1135        ) -> Result<Vec<SlackChannelInfo>, String> {
1136            Ok(vec![
1137                SlackChannelInfo {
1138                    id: "C001".into(),
1139                    name: "general".into(),
1140                    is_private: false,
1141                    is_member: true,
1142                    num_members: 42,
1143                    topic: "General chat".into(),
1144                    purpose: "Company-wide".into(),
1145                },
1146                SlackChannelInfo {
1147                    id: "C002".into(),
1148                    name: "random".into(),
1149                    is_private: false,
1150                    is_member: true,
1151                    num_members: 38,
1152                    topic: "".into(),
1153                    purpose: "Random stuff".into(),
1154                },
1155            ])
1156        }
1157
1158        async fn conversations_join(&self, _channel_id: &str) -> Result<(), String> {
1159            Ok(())
1160        }
1161
1162        async fn conversations_info(&self, channel_id: &str) -> Result<SlackChannelInfo, String> {
1163            Ok(SlackChannelInfo {
1164                id: channel_id.to_string(),
1165                name: "general".into(),
1166                is_private: false,
1167                is_member: true,
1168                num_members: 42,
1169                topic: "General chat".into(),
1170                purpose: "Company-wide".into(),
1171            })
1172        }
1173
1174        async fn users_list(&self, _limit: usize) -> Result<Vec<SlackUserInfo>, String> {
1175            Ok(vec![SlackUserInfo {
1176                id: "U001".into(),
1177                name: "alice".into(),
1178                real_name: "Alice Smith".into(),
1179                display_name: "alice".into(),
1180                is_bot: false,
1181                is_admin: true,
1182                email: Some("alice@example.com".into()),
1183                status_text: "Working".into(),
1184                status_emoji: ":computer:".into(),
1185            }])
1186        }
1187
1188        async fn users_info(&self, user_id: &str) -> Result<SlackUserInfo, String> {
1189            Ok(SlackUserInfo {
1190                id: user_id.to_string(),
1191                name: "alice".into(),
1192                real_name: "Alice Smith".into(),
1193                display_name: "alice".into(),
1194                is_bot: false,
1195                is_admin: true,
1196                email: Some("alice@example.com".into()),
1197                status_text: "Working".into(),
1198                status_emoji: ":computer:".into(),
1199            })
1200        }
1201
1202        async fn reactions_add(
1203            &self,
1204            _channel: &str,
1205            _timestamp: &str,
1206            _name: &str,
1207        ) -> Result<(), String> {
1208            Ok(())
1209        }
1210
1211        async fn reactions_get(
1212            &self,
1213            _channel: &str,
1214            _timestamp: &str,
1215        ) -> Result<Vec<SlackReaction>, String> {
1216            Ok(vec![SlackReaction {
1217                name: "thumbsup".into(),
1218                count: 3,
1219                users: vec!["U001".into(), "U002".into(), "U003".into()],
1220            }])
1221        }
1222
1223        async fn files_list(
1224            &self,
1225            _channel: Option<&str>,
1226            _limit: usize,
1227        ) -> Result<Vec<SlackFile>, String> {
1228            Ok(vec![SlackFile {
1229                id: "F001".into(),
1230                name: "report.pdf".into(),
1231                filetype: "pdf".into(),
1232                size: 1024,
1233                url_private: "https://files.slack.com/report.pdf".into(),
1234                user: "U001".into(),
1235                timestamp: 1700000000,
1236            }])
1237        }
1238
1239        async fn team_info(&self) -> Result<SlackTeamInfo, String> {
1240            Ok(SlackTeamInfo {
1241                id: "T001".into(),
1242                name: "Test Workspace".into(),
1243                domain: "test-workspace".into(),
1244                icon_url: None,
1245            })
1246        }
1247
1248        async fn usergroups_list(&self) -> Result<Vec<SlackUserGroup>, String> {
1249            Ok(vec![SlackUserGroup {
1250                id: "S001".into(),
1251                name: "Engineering".into(),
1252                handle: "engineering".into(),
1253                description: "Engineering team".into(),
1254                user_count: 15,
1255            }])
1256        }
1257
1258        async fn conversations_open(&self, _user_ids: &[&str]) -> Result<String, String> {
1259            Ok("D001".to_string())
1260        }
1261    }
1262
1263    // ── Existing tests ─────────────────────────────────────────────────
1264
1265    #[tokio::test]
1266    async fn test_slack_connect() {
1267        let config = SlackConfig {
1268            bot_token: "xoxb-123".into(),
1269            ..Default::default()
1270        };
1271        let mut ch = SlackChannel::new(config, Box::new(MockSlackHttp::new()));
1272        ch.connect().await.unwrap();
1273        assert!(ch.is_connected());
1274    }
1275
1276    #[tokio::test]
1277    async fn test_slack_send_message() {
1278        let config = SlackConfig {
1279            bot_token: "xoxb-123".into(),
1280            default_channel: Some("general".into()),
1281            ..Default::default()
1282        };
1283        let http = MockSlackHttp::new();
1284        let sent = http.sent.clone();
1285        let mut ch = SlackChannel::new(config, Box::new(http));
1286        ch.connect().await.unwrap();
1287
1288        let sender = ChannelUser::new("bot", ChannelType::Slack);
1289        let msg = ChannelMessage::text(ChannelType::Slack, "random", sender, "Hello Slack!");
1290        ch.send_message(msg).await.unwrap();
1291
1292        let sent = sent.lock().unwrap();
1293        assert_eq!(sent[0].0, "random");
1294        assert_eq!(sent[0].1, "Hello Slack!");
1295    }
1296
1297    #[tokio::test]
1298    async fn test_slack_receive_messages() {
1299        let config = SlackConfig {
1300            bot_token: "xoxb-123".into(),
1301            allowed_channels: vec!["general".into()],
1302            ..Default::default()
1303        };
1304        let http = MockSlackHttp::new().with_messages(vec![SlackMessage {
1305            ts: "123.456".into(),
1306            channel: "general".into(),
1307            user: "U123".into(),
1308            text: "hey".into(),
1309            thread_ts: None,
1310        }]);
1311        let mut ch = SlackChannel::new(config, Box::new(http));
1312        ch.connect().await.unwrap();
1313
1314        let msgs = ch.receive_messages().await.unwrap();
1315        assert_eq!(msgs.len(), 1);
1316        assert_eq!(msgs[0].content.as_text(), Some("hey"));
1317    }
1318
1319    #[test]
1320    fn test_slack_capabilities() {
1321        let ch = SlackChannel::new(SlackConfig::default(), Box::new(MockSlackHttp::new()));
1322        let caps = ch.capabilities();
1323        assert!(caps.supports_threads);
1324        assert!(caps.supports_reactions);
1325        assert!(caps.supports_files);
1326        assert!(!caps.supports_voice);
1327        assert_eq!(caps.max_message_length, Some(40000));
1328    }
1329
1330    #[test]
1331    fn test_slack_streaming_mode() {
1332        let ch = SlackChannel::new(SlackConfig::default(), Box::new(MockSlackHttp::new()));
1333        assert_eq!(ch.streaming_mode(), StreamingMode::WebSocket);
1334    }
1335
1336    #[tokio::test]
1337    async fn test_slack_oauth_config_connect() {
1338        let config = SlackConfig {
1339            bot_token: "xoxb-oauth-token-from-oauth-flow".into(),
1340            auth_method: AuthMethod::OAuth,
1341            ..Default::default()
1342        };
1343        let mut ch = SlackChannel::new(config, Box::new(MockSlackHttp::new()));
1344        ch.connect().await.unwrap();
1345        assert!(ch.is_connected());
1346    }
1347
1348    #[test]
1349    fn test_slack_config_auth_method_default() {
1350        let config = SlackConfig::default();
1351        assert_eq!(config.auth_method, AuthMethod::ApiKey);
1352    }
1353
1354    #[test]
1355    fn test_slack_config_auth_method_serde() {
1356        let config = SlackConfig {
1357            bot_token: "xoxb-test".into(),
1358            auth_method: AuthMethod::OAuth,
1359            ..Default::default()
1360        };
1361        let json = serde_json::to_string(&config).unwrap();
1362        assert!(json.contains("\"oauth\""));
1363        let parsed: SlackConfig = serde_json::from_str(&json).unwrap();
1364        assert_eq!(parsed.auth_method, AuthMethod::OAuth);
1365    }
1366
1367    // ── New feature tests ──────────────────────────────────────────────
1368
1369    #[tokio::test]
1370    async fn test_slack_list_channels() {
1371        let ch = SlackChannel::new(SlackConfig::default(), Box::new(MockSlackHttp::new()));
1372        let channels = ch.list_channels().await.unwrap();
1373        assert_eq!(channels.len(), 2);
1374        assert_eq!(channels[0].name, "general");
1375        assert_eq!(channels[1].name, "random");
1376        assert_eq!(channels[0].num_members, 42);
1377    }
1378
1379    #[tokio::test]
1380    async fn test_slack_channel_info() {
1381        let ch = SlackChannel::new(SlackConfig::default(), Box::new(MockSlackHttp::new()));
1382        let info = ch.channel_info("C001").await.unwrap();
1383        assert_eq!(info.id, "C001");
1384        assert_eq!(info.name, "general");
1385    }
1386
1387    #[tokio::test]
1388    async fn test_slack_join_channel() {
1389        let ch = SlackChannel::new(SlackConfig::default(), Box::new(MockSlackHttp::new()));
1390        ch.join_channel("C002").await.unwrap();
1391    }
1392
1393    #[tokio::test]
1394    async fn test_slack_list_users() {
1395        let ch = SlackChannel::new(SlackConfig::default(), Box::new(MockSlackHttp::new()));
1396        let users = ch.list_users().await.unwrap();
1397        assert_eq!(users.len(), 1);
1398        assert_eq!(users[0].name, "alice");
1399        assert_eq!(users[0].real_name, "Alice Smith");
1400        assert!(users[0].is_admin);
1401        assert!(!users[0].is_bot);
1402    }
1403
1404    #[tokio::test]
1405    async fn test_slack_get_user_info() {
1406        let ch = SlackChannel::new(SlackConfig::default(), Box::new(MockSlackHttp::new()));
1407        let user = ch.get_user_info("U001").await.unwrap();
1408        assert_eq!(user.id, "U001");
1409        assert_eq!(user.email, Some("alice@example.com".into()));
1410    }
1411
1412    #[tokio::test]
1413    async fn test_slack_add_reaction() {
1414        let ch = SlackChannel::new(SlackConfig::default(), Box::new(MockSlackHttp::new()));
1415        ch.add_reaction("C001", "123.456", "thumbsup")
1416            .await
1417            .unwrap();
1418    }
1419
1420    #[tokio::test]
1421    async fn test_slack_get_reactions() {
1422        let ch = SlackChannel::new(SlackConfig::default(), Box::new(MockSlackHttp::new()));
1423        let reactions = ch.get_reactions("C001", "123.456").await.unwrap();
1424        assert_eq!(reactions.len(), 1);
1425        assert_eq!(reactions[0].name, "thumbsup");
1426        assert_eq!(reactions[0].count, 3);
1427        assert_eq!(reactions[0].users.len(), 3);
1428    }
1429
1430    #[tokio::test]
1431    async fn test_slack_list_files() {
1432        let ch = SlackChannel::new(SlackConfig::default(), Box::new(MockSlackHttp::new()));
1433        let files = ch.list_files(None).await.unwrap();
1434        assert_eq!(files.len(), 1);
1435        assert_eq!(files[0].name, "report.pdf");
1436        assert_eq!(files[0].size, 1024);
1437    }
1438
1439    #[tokio::test]
1440    async fn test_slack_team_info() {
1441        let ch = SlackChannel::new(SlackConfig::default(), Box::new(MockSlackHttp::new()));
1442        let team = ch.get_team_info().await.unwrap();
1443        assert_eq!(team.name, "Test Workspace");
1444        assert_eq!(team.domain, "test-workspace");
1445    }
1446
1447    #[tokio::test]
1448    async fn test_slack_list_usergroups() {
1449        let ch = SlackChannel::new(SlackConfig::default(), Box::new(MockSlackHttp::new()));
1450        let groups = ch.list_usergroups().await.unwrap();
1451        assert_eq!(groups.len(), 1);
1452        assert_eq!(groups[0].handle, "engineering");
1453        assert_eq!(groups[0].user_count, 15);
1454    }
1455
1456    #[tokio::test]
1457    async fn test_slack_open_dm() {
1458        let ch = SlackChannel::new(SlackConfig::default(), Box::new(MockSlackHttp::new()));
1459        let dm_channel = ch.open_dm(&["U001"]).await.unwrap();
1460        assert_eq!(dm_channel, "D001");
1461    }
1462
1463    #[tokio::test]
1464    async fn test_slack_thread_reply() {
1465        let http = MockSlackHttp::new();
1466        let sent = http.sent.clone();
1467        let ch = SlackChannel::new(SlackConfig::default(), Box::new(http));
1468        let id = ch
1469            .reply_in_thread("C001", "123.456", "reply text")
1470            .await
1471            .unwrap();
1472        assert!(!id.0.is_empty());
1473        let sent = sent.lock().unwrap();
1474        assert_eq!(sent[0].1, "reply text");
1475    }
1476
1477    #[tokio::test]
1478    async fn test_slack_read_history() {
1479        let http = MockSlackHttp::new().with_messages(vec![SlackMessage {
1480            ts: "999.888".into(),
1481            channel: "test".into(),
1482            user: "U001".into(),
1483            text: "history msg".into(),
1484            thread_ts: None,
1485        }]);
1486        let ch = SlackChannel::new(SlackConfig::default(), Box::new(http));
1487        let msgs = ch.read_history("test", 10).await.unwrap();
1488        assert_eq!(msgs.len(), 1);
1489        assert_eq!(msgs[0].text, "history msg");
1490    }
1491}