Skip to main content

slack_web_api/
upload.rs

1// ファイルのアップロード(`files.upload` の後継の手順をまとめたもの)。
2//
3// 1. ファイルごとに `files.getUploadURLExternal` でアップロード先を受け取る
4// 2. その URL へ中身をそのまま POST する
5// 3. `files.completeUploadExternal` でまとめて確定し、チャンネルに共有する
6//
7// 1〜2 はファイルごとに並行して行う。
8
9use crate::api::{
10    FilesCompleteUploadExternalRequest, FilesCompleteUploadExternalResponse,
11    FilesGetUploadUrlExternalRequest,
12};
13use crate::blocks::Block;
14use crate::{SlackClient, SlackError};
15use bytes::Bytes;
16use serde_json::json;
17use tokio::task::JoinSet;
18
19/// A file to upload with [`SlackClient::upload_files`].
20#[derive(Debug, Clone, PartialEq)]
21pub struct FileUpload {
22    filename: String,
23    content: Bytes,
24    title: Option<String>,
25    alt_txt: Option<String>,
26    snippet_type: Option<String>,
27}
28
29impl FileUpload {
30    pub fn new(filename: impl Into<String>, content: impl Into<Bytes>) -> Self {
31        Self {
32            filename: filename.into(),
33            content: content.into(),
34            title: None,
35            alt_txt: None,
36            snippet_type: None,
37        }
38    }
39
40    /// Title shown in Slack (defaults to the file name).
41    pub fn title(mut self, title: impl Into<String>) -> Self {
42        self.title = Some(title.into());
43        self
44    }
45
46    /// Alt text for images.
47    pub fn alt_txt(mut self, alt_txt: impl Into<String>) -> Self {
48        self.alt_txt = Some(alt_txt.into());
49        self
50    }
51
52    /// Syntax type when the file is shown as a snippet (`python`, `rust`, ...).
53    pub fn snippet_type(mut self, snippet_type: impl Into<String>) -> Self {
54        self.snippet_type = Some(snippet_type.into());
55        self
56    }
57}
58
59/// Where uploaded files are shared. With nothing set, the files stay private and unshared.
60#[derive(Debug, Clone, Default, PartialEq)]
61pub struct UploadDestination {
62    channel_id: Option<String>,
63    thread_ts: Option<String>,
64    initial_comment: Option<String>,
65    blocks: Option<Vec<Block>>,
66}
67
68impl UploadDestination {
69    pub fn channel(channel_id: impl Into<String>) -> Self {
70        Self {
71            channel_id: Some(channel_id.into()),
72            ..Self::default()
73        }
74    }
75
76    /// Posts the files as a reply in this thread.
77    pub fn thread_ts(mut self, thread_ts: impl Into<String>) -> Self {
78        self.thread_ts = Some(thread_ts.into());
79        self
80    }
81
82    /// Message posted with the files.
83    pub fn initial_comment(mut self, initial_comment: impl Into<String>) -> Self {
84        self.initial_comment = Some(initial_comment.into());
85        self
86    }
87
88    /// Block Kit message posted with the files.
89    pub fn blocks(mut self, blocks: Vec<Block>) -> Self {
90        self.blocks = Some(blocks);
91        self
92    }
93}
94
95impl SlackClient {
96    /// Uploads files and shares them to `destination`, replacing the retired `files.upload`.
97    ///
98    /// Runs `files.getUploadURLExternal` and the upload for every file concurrently, then
99    /// `files.completeUploadExternal` once with the files in the given order.
100    ///
101    /// # Errors
102    ///
103    /// Fails on the first API, transport or upload (non-2xx) error. Files uploaded before the
104    /// error are not completed and are discarded by Slack.
105    pub async fn upload_files(
106        &self,
107        files: Vec<FileUpload>,
108        destination: UploadDestination,
109    ) -> Result<FilesCompleteUploadExternalResponse, SlackError> {
110        let mut tasks = JoinSet::new();
111        for (index, file) in files.into_iter().enumerate() {
112            let client = self.clone();
113            tasks.spawn(async move { client.upload_one(file).await.map(|entry| (index, entry)) });
114        }
115        let mut uploaded = Vec::with_capacity(tasks.len());
116        while let Some(joined) = tasks.join_next().await {
117            uploaded.push(joined.map_err(|e| SlackError::Task(e.to_string()))??);
118        }
119        uploaded.sort_by_key(|(index, _)| *index);
120
121        let mut request =
122            FilesCompleteUploadExternalRequest::new(uploaded.into_iter().map(|(_, e)| e).collect());
123        request.channel_id = destination.channel_id;
124        request.thread_ts = destination.thread_ts;
125        request.initial_comment = destination.initial_comment;
126        request.blocks = destination.blocks;
127        self.files_complete_upload_external(&request).await
128    }
129
130    // 1ファイル分のアップロード先の取得と送信。completeUploadExternal に渡す `{id, title}` を返す
131    async fn upload_one(&self, file: FileUpload) -> Result<serde_json::Value, SlackError> {
132        let mut request =
133            FilesGetUploadUrlExternalRequest::new(file.content.len() as i64, file.filename.clone());
134        request.alt_txt = file.alt_txt;
135        request.snippet_type = file.snippet_type;
136        let target = self.files_get_upload_url_external(&request).await?;
137        let (Some(upload_url), Some(file_id)) = (target.upload_url, target.file_id) else {
138            return Err(SlackError::MissingField(
139                "files.getUploadURLExternal: upload_url / file_id",
140            ));
141        };
142
143        let response = self
144            .http()
145            .post(&upload_url)
146            .body(file.content)
147            .send()
148            .await?;
149        let status = response.status();
150        if !status.is_success() {
151            let body = response.text().await.unwrap_or_default();
152            return Err(SlackError::Http {
153                status: status.as_u16(),
154                body,
155            });
156        }
157        Ok(json!({ "id": file_id, "title": file.title.unwrap_or(file.filename) }))
158    }
159}