dingtalk_stream_sdk_rust/
up.rs

1//! Types and methods that handle up to DingTalk server
2
3use crate::Client;
4use anyhow::{bail, Result};
5use futures::{stream::SplitSink, SinkExt};
6use log::debug;
7use reqwest::{
8    multipart::{Form, Part},
9    Response,
10};
11use serde::{de::DeserializeOwned, Deserialize, Serialize};
12use serde_json::Value;
13use std::{ffi::OsStr, path::Path, sync::Arc};
14use strum::Display;
15use tokio::{fs::File, net::TcpStream};
16use tokio_tungstenite::{tungstenite::Message, MaybeTlsStream, WebSocketStream};
17
18pub(crate) type Sink = SplitSink<WebSocketStream<MaybeTlsStream<TcpStream>>, Message>;
19impl Client {
20    pub(crate) async fn send<T: Serialize>(&self, msg: T) -> Result<()> {
21        let msg = serde_json::to_string(&msg)?;
22        self.send_message(Message::text(msg)).await
23    }
24
25    pub(crate) async fn ping(&self) -> Result<()> {
26        self.send_message(Message::Ping(Vec::new())).await
27    }
28
29    pub(crate) async fn send_message(&self, msg: Message) -> Result<()> {
30        let mut sink = self.sink.lock().await;
31        let Some(sink) = sink.as_mut() else {
32            bail!("stream not connected");
33        };
34        sink.send(msg).await?;
35
36        Ok(())
37    }
38
39    pub(crate) async fn post_raw<T: Serialize>(
40        &self,
41        url: impl AsRef<str>,
42        data: T,
43    ) -> Result<Response> {
44        let access_token = self.token().await?;
45        debug!("post with access token: {}", access_token);
46        let response = self
47            .client
48            .post(url.as_ref())
49            .header("x-acs-dingtalk-access-token", access_token)
50            .json(&data)
51            .send()
52            .await?;
53
54        if !response.status().is_success() {
55            bail!(
56                "post error: [{}] {:?}",
57                response.status(),
58                response.text().await?
59            );
60        }
61
62        Ok(response)
63    }
64
65    pub(crate) async fn post<T, U>(&self, url: impl AsRef<str>, data: T) -> Result<U>
66    where
67        T: Serialize,
68        U: DeserializeOwned,
69    {
70        let response = self.post_raw(url, data).await?;
71        let status = response.status();
72        let text = response.text().await?;
73        debug!("post ok: [{}] {}", status, text);
74        Ok(serde_json::from_str(&text)?)
75    }
76
77    /// upload file and return media id for
78    /// - [`MessageTemplate::SampleFile`]
79    /// - [`MessageTemplate::SampleVideo`]
80    /// - [`MessageTemplate::SampleAudio`]
81    pub async fn upload(&self, file: impl AsRef<Path>, file_type: UploadType) -> Result<String> {
82        let access_token = self.token().await?;
83        let file = file.as_ref();
84        let filename = file
85            .file_name()
86            .unwrap_or(OsStr::new("<unknown>"))
87            .to_string_lossy()
88            .to_string();
89        let file = File::open(file).await?;
90        let form = Form::new()
91            .part("media", Part::stream(file).file_name(filename))
92            .text("type", file_type.to_string());
93        let response = self
94            .client
95            .post(format!("{}?access_token={}", UPLOAD_URL, access_token))
96            .multipart(form)
97            .send()
98            .await?;
99
100        if !response.status().is_success() {
101            bail!(
102                "upload http error: {} - {}",
103                response.status(),
104                response.text().await?
105            );
106        }
107
108        let res: UploadResult = response.json().await?;
109        if res.errcode != 0 {
110            bail!("upload error: {} - {}", res.errcode, res.errmsg);
111        }
112
113        Ok(res.media_id)
114    }
115}
116
117#[derive(Deserialize)]
118struct UploadResult {
119    errcode: u32,
120    errmsg: String,
121    #[serde(default)]
122    media_id: String,
123    #[allow(dead_code)]
124    #[serde(default)]
125    created_at: u64,
126    #[allow(dead_code)]
127    #[serde(default)]
128    r#type: String,
129}
130
131/// Upload enum for [`Client::upload`]
132#[derive(Display)]
133#[strum(serialize_all = "snake_case")]
134pub enum UploadType {
135    Image,
136    Voice,
137    Video,
138    File,
139}
140
141#[derive(Debug, Default, Serialize)]
142#[serde(rename_all = "camelCase")]
143pub(crate) struct ClientUpStream {
144    pub code: u32,
145    pub headers: StreamUpHeader,
146    pub message: String,
147    pub data: String,
148}
149
150impl ClientUpStream {
151    pub fn new(data: impl Into<String>, message_id: impl Into<String>) -> Self {
152        let data = data.into();
153        let message_id = message_id.into();
154
155        Self {
156            code: 200,
157            headers: StreamUpHeader {
158                message_id,
159                content_type: "application/json".to_owned(),
160            },
161            message: "OK".to_owned(),
162            data,
163        }
164    }
165}
166
167#[derive(Debug, Default, Serialize)]
168#[serde(rename_all = "camelCase")]
169pub(crate) struct StreamUpHeader {
170    pub content_type: String, // always application/json
171    pub message_id: String,   // same StreamDownHeaders::message_id
172}
173
174/// Message type to be sent to DingTalk server
175///
176/// Please refer to the official document [batches](https://open.dingtalk.com/document/orgapp/chatbots-send-one-on-one-chat-messages-in-batches) and
177/// [group](https://open.dingtalk.com/document/orgapp/the-robot-sends-a-group-message) for more detail
178#[derive(Serialize)]
179#[serde(rename_all = "camelCase")]
180pub struct RobotSendMessage {
181    robot_code: String,
182    #[serde(flatten)]
183    target: SendMessageTarget,
184    msg_key: String,
185    msg_param: String,
186
187    #[serde(skip_serializing)]
188    client: Arc<Client>,
189}
190
191const BATCH_SEND_URL: &str = "https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend";
192const GROUP_SEND_URL: &str = "https://api.dingtalk.com/v1.0/robot/groupMessages/send";
193const UPLOAD_URL: &str = "https://oapi.dingtalk.com/media/upload";
194
195impl RobotSendMessage {
196    /// construct message to group chat
197    pub fn group(
198        client: Arc<Client>,
199        conversation_id: impl Into<String>,
200        message: MessageTemplate,
201    ) -> Result<Self> {
202        let client_id = client.config.lock().unwrap().client_id.clone();
203        Ok(Self {
204            robot_code: client_id,
205            target: SendMessageTarget::Group {
206                open_conversation_id: conversation_id.into(),
207            },
208            msg_key: message.to_string(),
209            msg_param: message.try_into()?,
210            client,
211        })
212    }
213
214    /// send to constructed message
215    pub async fn send(&self) -> Result<()> {
216        debug!("send: {}", serde_json::to_string(self).unwrap());
217        let _: Value = self
218            .client
219            .post(
220                {
221                    match self.target {
222                        SendMessageTarget::Batch { .. } => BATCH_SEND_URL,
223                        SendMessageTarget::Group { .. } => GROUP_SEND_URL,
224                    }
225                },
226                self,
227            )
228            .await?;
229
230        Ok(())
231    }
232
233    /// construct batch message to multiple users
234    pub fn batch(
235        client: Arc<Client>,
236        user_ids: Vec<String>,
237        message: MessageTemplate,
238    ) -> Result<Self> {
239        let client_id = client.config.lock().unwrap().client_id.clone();
240        Ok(Self {
241            robot_code: client_id,
242            target: SendMessageTarget::Batch { user_ids },
243            msg_key: message.to_string(),
244            msg_param: message.try_into()?,
245            client,
246        })
247    }
248
249    /// construct message to single user
250    pub fn single(
251        client: Arc<Client>,
252        user_id: impl Into<String>,
253        message: MessageTemplate,
254    ) -> Result<Self> {
255        Self::batch(client, vec![user_id.into()], message)
256    }
257}
258
259/// Event ack message type
260///
261/// Found it in other programming language's SDK, not found in any official document though.
262#[derive(Serialize)]
263pub struct EventAckData {
264    pub status: &'static str,
265    #[serde(default)]
266    pub message: String,
267}
268
269impl Default for EventAckData {
270    fn default() -> Self {
271        Self {
272            status: EventAckData::SUCCESS,
273            message: Default::default(),
274        }
275    }
276}
277
278impl EventAckData {
279    pub const SUCCESS: &'static str = "SUCCESS";
280    pub const LATER: &'static str = "LATER";
281}
282
283#[derive(Serialize)]
284#[serde(rename_all = "camelCase", untagged)]
285enum SendMessageTarget {
286    #[serde(rename_all = "camelCase")]
287    Group { open_conversation_id: String },
288    #[serde(rename_all = "camelCase")]
289    Batch { user_ids: Vec<String> },
290}
291
292/// Message enum to be sent to DingTalk server
293///
294/// Please refer to the [official document](https://open.dingtalk.com/document/orgapp/types-of-messages-sent-by-robots) for the definition of each field
295#[derive(Serialize, strum::Display, Clone)]
296#[serde(rename_all = "camelCase", untagged)]
297#[strum(serialize_all = "camelCase")]
298pub enum MessageTemplate {
299    #[serde(rename_all = "camelCase")]
300    SampleText { content: String },
301    #[serde(rename_all = "camelCase")]
302    SampleMarkdown { title: String, text: String },
303    #[serde(rename_all = "camelCase")]
304    SampleImageMsg {
305        #[serde(rename = "photoURL")]
306        photo_url: String,
307    },
308    #[serde(rename_all = "camelCase")]
309    SampleLink {
310        text: String,
311        title: String,
312        pic_url: String,
313        message_url: String,
314    },
315    #[serde(rename_all = "camelCase")]
316    SampleActionCard {
317        title: String,
318        text: String,
319        single_title: String,
320        #[serde(rename = "singleURL")]
321        single_url: String,
322    },
323    #[serde(rename_all = "camelCase")]
324    SampleActionCard2 {
325        title: String,
326        text: String,
327        action_title_1: String,
328        #[serde(rename = "actionURL1")]
329        action_url_1: String,
330        action_title_2: String,
331        #[serde(rename = "actionURL2")]
332        action_url_2: String,
333    },
334    #[serde(rename_all = "camelCase")]
335    SampleActionCard3 {
336        title: String,
337        text: String,
338        action_title_1: String,
339        #[serde(rename = "actionURL1")]
340        action_url_1: String,
341        action_title_2: String,
342        #[serde(rename = "actionURL2")]
343        action_url_2: String,
344        action_title_3: String,
345        #[serde(rename = "actionURL3")]
346        action_url_3: String,
347    },
348    #[serde(rename_all = "camelCase")]
349    SampleActionCard4 {
350        title: String,
351        text: String,
352        action_title_1: String,
353        #[serde(rename = "actionURL1")]
354        action_url_1: String,
355        action_title_2: String,
356        #[serde(rename = "actionURL2")]
357        action_url_2: String,
358        action_title_3: String,
359        #[serde(rename = "actionURL3")]
360        action_url_3: String,
361        action_title_4: String,
362        #[serde(rename = "actionURL4")]
363        action_url_4: String,
364    },
365    #[serde(rename_all = "camelCase")]
366    SampleActionCard5 {
367        title: String,
368        text: String,
369        action_title_1: String,
370        #[serde(rename = "actionURL1")]
371        action_url_1: String,
372        action_title_2: String,
373        #[serde(rename = "actionURL2")]
374        action_url_2: String,
375        action_title_3: String,
376        #[serde(rename = "actionURL3")]
377        action_url_3: String,
378        action_title_4: String,
379        #[serde(rename = "actionURL4")]
380        action_url_4: String,
381        action_title_5: String,
382        #[serde(rename = "actionURL5")]
383        action_url_5: String,
384    },
385    #[serde(rename_all = "camelCase")]
386    SampleActionCard6 {
387        title: String,
388        text: String,
389        button_title_1: String,
390        button_url_1: String,
391        button_title_2: String,
392        button_url_2: String,
393    },
394    #[serde(rename_all = "camelCase")]
395    SampleAudio { media_id: String, duration: String },
396    #[serde(rename_all = "camelCase")]
397    SampleFile {
398        media_id: String,
399        file_name: String,
400        file_type: String,
401    },
402    #[serde(rename_all = "camelCase")]
403    SampleVideo {
404        duration: String,
405        video_media_id: String,
406        video_type: String,
407        pic_media_id: String,
408    },
409}
410
411impl TryInto<String> for MessageTemplate {
412    type Error = serde_json::Error;
413
414    fn try_into(self) -> std::result::Result<String, Self::Error> {
415        serde_json::to_string(&self)
416    }
417}