lab_resource_manager/interface/slack/
commands.rs

1use crate::application::usecases::grant_user_resource_access::GrantUserResourceAccessUseCase;
2use crate::domain::aggregates::identity_link::value_objects::ExternalSystem;
3use crate::domain::common::EmailAddress;
4use slack_morphism::prelude::*;
5use std::sync::Arc;
6use tokio_util::task::TaskTracker;
7use tracing::{error, info};
8
9/// Slackコマンドハンドラ
10pub struct SlackCommandHandler {
11    grant_access_usecase: Arc<GrantUserResourceAccessUseCase>,
12    task_tracker: TaskTracker,
13    http_client: reqwest::Client,
14}
15
16impl SlackCommandHandler {
17    pub fn new(grant_access_usecase: Arc<GrantUserResourceAccessUseCase>) -> Self {
18        Self {
19            grant_access_usecase,
20            task_tracker: TaskTracker::new(),
21            http_client: reqwest::Client::new(),
22        }
23    }
24
25    /// バックグラウンドタスクの完了を待機
26    ///
27    /// シャットダウン時に呼び出して、全てのバックグラウンドタスクの完了を待つ
28    pub async fn shutdown(&self) {
29        self.task_tracker.close();
30        self.task_tracker.wait().await;
31    }
32
33    /// Slashコマンドをルーティング
34    pub async fn route_slash_command(
35        &self,
36        event: SlackCommandEvent,
37    ) -> Result<SlackCommandEventResponse, Box<dyn std::error::Error + Send + Sync>> {
38        let command = event.command.0.as_str();
39        let text = event.text.as_deref().unwrap_or("");
40        let slack_user_id = event.user_id.to_string();
41        let response_url = event.response_url.clone();
42
43        match command {
44            "/register-calendar" => {
45                self.handle_register_calendar(text, slack_user_id, response_url)
46                    .await
47            }
48            "/link-user" => self.handle_link_user(text, response_url).await,
49            _ => Ok(SlackCommandEventResponse::new(
50                SlackMessageContent::new().with_text(format!("不明なコマンド: {}", command)),
51            )),
52        }
53    }
54
55    async fn handle_register_calendar(
56        &self,
57        text: &str,
58        slack_user_id: String,
59        response_url: SlackResponseUrl,
60    ) -> Result<SlackCommandEventResponse, Box<dyn std::error::Error + Send + Sync>> {
61        if text.is_empty() {
62            return Ok(SlackCommandEventResponse::new(
63                SlackMessageContent::new()
64                    .with_text("使い方: `/register-calendar <your-email@gmail.com>`".to_string()),
65            ));
66        }
67
68        let grant_access_usecase = self.grant_access_usecase.clone();
69        let email_str = text.to_string();
70
71        self.execute_with_background_response(response_url, || async move {
72            let email = EmailAddress::new(email_str.trim().to_string())
73                .map_err(|e| format!("❌ メールアドレスの形式が不正です: {}", e))?;
74
75            grant_access_usecase
76                .execute(ExternalSystem::Slack, slack_user_id, email.clone())
77                .await
78                .map_err(|e| format!("❌ カレンダー登録に失敗: {}", e))?;
79
80            Ok(format!(
81                "✅ 登録完了!カレンダーへのアクセス権を付与しました: {}",
82                email.as_str()
83            ))
84        })
85        .await
86    }
87
88    async fn handle_link_user(
89        &self,
90        text: &str,
91        response_url: SlackResponseUrl,
92    ) -> Result<SlackCommandEventResponse, Box<dyn std::error::Error + Send + Sync>> {
93        let parts: Vec<&str> = text.split_whitespace().collect();
94        if parts.len() != 2 {
95            return Ok(SlackCommandEventResponse::new(
96                SlackMessageContent::new()
97                    .with_text("使い方: `/link-user <@slack_user> <email@gmail.com>`".to_string()),
98            ));
99        }
100
101        let grant_access_usecase = self.grant_access_usecase.clone();
102
103        // Slackメンション形式のバリデーションとパース
104        let slack_mention = parts[0].trim();
105        let target_slack_user_id = slack_mention
106            .strip_prefix("<@")
107            .and_then(|s| s.strip_suffix(">"))
108            .filter(|id| !id.is_empty())
109            .map(|id| id.to_string());
110
111        let target_slack_user_id = match target_slack_user_id {
112            Some(id) => id,
113            None => {
114                return Ok(SlackCommandEventResponse::new(
115                    SlackMessageContent::new()
116                        .with_text("❌ Slackユーザーの形式が不正です。".to_string()),
117                ));
118            }
119        };
120
121        let email_str = parts[1].to_string();
122
123        self.execute_with_background_response(response_url, || async move {
124            let email = EmailAddress::new(email_str.trim().to_string())
125                .map_err(|e| format!("❌ メールアドレスの形式が不正です: {}", e))?;
126
127            grant_access_usecase
128                .execute(
129                    ExternalSystem::Slack,
130                    target_slack_user_id.clone(),
131                    email.clone(),
132                )
133                .await
134                .map_err(|e| format!("❌ ユーザー紐付けに失敗: {}", e))?;
135
136            Ok(format!(
137                "✅ 紐付け完了!<@{}> に {} のカレンダーアクセス権を付与しました。",
138                target_slack_user_id,
139                email.as_str()
140            ))
141        })
142        .await
143    }
144
145    /// バックグラウンドで処理を実行し、結果をSlackに送信する共通ヘルパー
146    ///
147    /// TaskTrackerを使用してタスクを追跡し、シャットダウン時のグレースフル終了を可能にする
148    async fn execute_with_background_response<F, Fut>(
149        &self,
150        response_url: SlackResponseUrl,
151        operation: F,
152    ) -> Result<SlackCommandEventResponse, Box<dyn std::error::Error + Send + Sync>>
153    where
154        F: FnOnce() -> Fut + Send + 'static,
155        Fut: std::future::Future<Output = Result<String, String>> + Send + 'static,
156    {
157        let http_client = self.http_client.clone();
158        self.task_tracker.spawn(async move {
159            let message = match operation().await {
160                Ok(msg) => msg,
161                Err(err) => err,
162            };
163
164            Self::send_followup_message_static(&http_client, &response_url, message).await;
165        });
166
167        Ok(SlackCommandEventResponse::new(
168            SlackMessageContent::new().with_text("⏳ 処理中...".to_string()),
169        ))
170    }
171
172    /// Slackにフォローアップメッセージを送信
173    ///
174    /// バックグラウンドタスクから呼び出すための静的メソッド
175    async fn send_followup_message_static(
176        http_client: &reqwest::Client,
177        response_url: &SlackResponseUrl,
178        message: String,
179    ) {
180        let payload = serde_json::json!({
181            "text": message,
182            "response_type": "in_channel"
183        });
184
185        match http_client
186            .post(response_url.0.as_str())
187            .json(&payload)
188            .send()
189            .await
190        {
191            Ok(_) => info!("✅ フォローアップメッセージを送信しました"),
192            Err(e) => error!("フォローアップメッセージの送信に失敗: {}", e),
193        }
194    }
195}