Skip to main content

teams_cli/
graph.rs

1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3
4use crate::error::AppError;
5
6const GRAPH: &str = "https://graph.microsoft.com/v1.0";
7
8#[derive(Debug, Serialize)]
9pub struct Page {
10    pub items: Vec<Value>,
11    #[serde(skip_serializing_if = "Option::is_none")]
12    pub next_cursor: Option<String>,
13    #[serde(skip_serializing_if = "Option::is_none")]
14    pub next_offset: Option<usize>,
15    pub truncated: bool,
16    /// Set when items come from a local cache rather than the live service.
17    #[serde(skip_serializing_if = "Option::is_none")]
18    pub source: Option<&'static str>,
19    /// The signed-in user's id when the backend knows it, so text output can say "You".
20    #[serde(skip)]
21    pub viewer: Option<String>,
22}
23
24#[derive(Debug, Deserialize)]
25struct GraphPage {
26    value: Vec<Value>,
27    #[serde(rename = "@odata.nextLink")]
28    next_link: Option<String>,
29}
30
31pub struct GraphClient {
32    http: reqwest::Client,
33    token: String,
34}
35
36impl GraphClient {
37    pub fn new(token: String) -> Self {
38        Self {
39            http: reqwest::Client::new(),
40            token,
41        }
42    }
43
44    pub async fn me(&self) -> Result<Value, AppError> {
45        self.get_value(&format!(
46            "{GRAPH}/me?$select=id,displayName,userPrincipalName,mail"
47        ))
48        .await
49    }
50
51    pub async fn teams(&self, limit: u16, offset: usize) -> Result<Page, AppError> {
52        self.get_offset_page(&format!("{GRAPH}/me/joinedTeams"), limit, offset)
53            .await
54    }
55
56    pub async fn teams_available(&self) -> Result<(), AppError> {
57        self.get_value(&format!("{GRAPH}/me/joinedTeams"))
58            .await
59            .map(|_| ())
60    }
61
62    pub async fn channels(&self, team: &str, limit: u16, offset: usize) -> Result<Page, AppError> {
63        self.get_offset_page(
64            &format!(
65                "{GRAPH}/teams/{team}/channels?$select=id,displayName,description,membershipType"
66            ),
67            limit,
68            offset,
69        )
70        .await
71    }
72
73    pub async fn chats(&self, limit: u16, cursor: Option<&str>) -> Result<Page, AppError> {
74        self.get_page(
75            cursor,
76            &format!("{GRAPH}/me/chats?$expand=members&$top={limit}"),
77            limit,
78        )
79        .await
80    }
81
82    pub async fn messages(
83        &self,
84        chat: Option<&str>,
85        team: Option<&str>,
86        channel: Option<&str>,
87        limit: u16,
88        cursor: Option<&str>,
89    ) -> Result<Page, AppError> {
90        let base = match (chat, team, channel) {
91            (Some(chat), None, None) => format!("{GRAPH}/chats/{chat}/messages?$top={limit}"),
92            (None, Some(team), Some(channel)) => {
93                format!("{GRAPH}/teams/{team}/channels/{channel}/messages?$top={limit}")
94            }
95            _ => {
96                return Err(AppError::InvalidInput(
97                    "choose either --chat, or both --team and --channel".into(),
98                ));
99            }
100        };
101        self.get_page(cursor, &base, limit).await
102    }
103
104    pub async fn send(
105        &self,
106        chat: Option<&str>,
107        team: Option<&str>,
108        channel: Option<&str>,
109        body: &str,
110    ) -> Result<Value, AppError> {
111        let url = match (chat, team, channel) {
112            (Some(chat), None, None) => format!("{GRAPH}/chats/{chat}/messages"),
113            (None, Some(team), Some(channel)) => {
114                format!("{GRAPH}/teams/{team}/channels/{channel}/messages")
115            }
116            _ => {
117                return Err(AppError::InvalidInput(
118                    "choose either --chat, or both --team and --channel".into(),
119                ));
120            }
121        };
122        let response = self
123            .http
124            .post(url)
125            .bearer_auth(&self.token)
126            .json(&serde_json::json!({"body": {"contentType": "text", "content": body}}))
127            .send()
128            .await
129            .map_err(|e| {
130                if e.is_connect() || e.is_builder() {
131                    AppError::Unexpected(format!("Microsoft Graph is unreachable: {e}"))
132                } else {
133                    unconfirmed()
134                }
135            })?;
136        // Once the request has left, a server fault or an unreadable answer can hide a
137        // delivered message; never let a caller treat that as safe to resend.
138        if matches!(response.status().as_u16(), 500 | 502 | 504) {
139            return Err(unconfirmed());
140        }
141        self.checked(response)
142            .await?
143            .json()
144            .await
145            .map_err(|_| unconfirmed())
146    }
147
148    async fn get_value(&self, url: &str) -> Result<Value, AppError> {
149        let response = self
150            .http
151            .get(url)
152            .bearer_auth(&self.token)
153            .send()
154            .await
155            .map_err(|e| AppError::Unexpected(format!("Microsoft Graph is unreachable: {e}")))?;
156        self.response_value(response).await
157    }
158
159    async fn get_page(
160        &self,
161        cursor: Option<&str>,
162        first: &str,
163        limit: u16,
164    ) -> Result<Page, AppError> {
165        let url = cursor.unwrap_or(first);
166        if !is_graph_url(url) {
167            return Err(AppError::InvalidInput(
168                "--cursor must be an opaque Microsoft Graph v1.0 continuation URL".into(),
169            ));
170        }
171        let response = self
172            .http
173            .get(url)
174            .bearer_auth(&self.token)
175            .send()
176            .await
177            .map_err(|e| AppError::Unexpected(format!("Microsoft Graph is unreachable: {e}")))?;
178        let response = self.checked(response).await?;
179        let mut page: GraphPage = response
180            .json()
181            .await
182            .map_err(|e| AppError::Api(format!("invalid Graph response: {e}")))?;
183        let over_limit = page.value.len() > limit as usize;
184        page.value.truncate(limit as usize);
185        let truncated = over_limit || page.next_link.is_some();
186        Ok(Page {
187            items: page.value,
188            next_cursor: page.next_link,
189            next_offset: None,
190            truncated,
191            source: None,
192            viewer: None,
193        })
194    }
195
196    async fn get_offset_page(
197        &self,
198        first: &str,
199        limit: u16,
200        offset: usize,
201    ) -> Result<Page, AppError> {
202        let mut url = Some(first.to_string());
203        let mut all = Vec::new();
204        while let Some(current) = url {
205            if !is_graph_url(&current) {
206                return Err(AppError::Api(
207                    "Graph returned an invalid continuation URL".into(),
208                ));
209            }
210            let response = self
211                .http
212                .get(&current)
213                .bearer_auth(&self.token)
214                .send()
215                .await
216                .map_err(|e| {
217                    AppError::Unexpected(format!("Microsoft Graph is unreachable: {e}"))
218                })?;
219            let page: GraphPage = self
220                .checked(response)
221                .await?
222                .json()
223                .await
224                .map_err(|e| AppError::Api(format!("invalid Graph response: {e}")))?;
225            all.extend(page.value);
226            url = page.next_link;
227            if all.len() > 10_000 {
228                return Err(AppError::Api(
229                    "Graph returned more than 10,000 records while preparing the requested page"
230                        .into(),
231                ));
232            }
233        }
234        let total = all.len();
235        let items = all.into_iter().skip(offset).take(limit as usize).collect();
236        let consumed = offset.saturating_add(limit as usize);
237        let truncated = consumed < total;
238        Ok(Page {
239            items,
240            next_cursor: None,
241            next_offset: truncated.then_some(consumed),
242            truncated,
243            source: None,
244            viewer: None,
245        })
246    }
247
248    async fn response_value(&self, response: reqwest::Response) -> Result<Value, AppError> {
249        self.checked(response)
250            .await?
251            .json()
252            .await
253            .map_err(|e| AppError::Api(format!("invalid Graph response: {e}")))
254    }
255
256    async fn checked(&self, response: reqwest::Response) -> Result<reqwest::Response, AppError> {
257        let status = response.status();
258        if status.is_success() {
259            return Ok(response);
260        }
261        let retry_after = response
262            .headers()
263            .get("retry-after")
264            .and_then(|v| v.to_str().ok())
265            .and_then(|v| v.parse().ok());
266        if status.as_u16() == 429 {
267            return Err(AppError::RateLimit(retry_after));
268        }
269        let body: Value = response.json().await.unwrap_or(Value::Null);
270        let message = body
271            .pointer("/error/message")
272            .and_then(Value::as_str)
273            .unwrap_or("Microsoft Graph rejected the request");
274        match status.as_u16() {
275            401 => Err(AppError::Auth(format!("{message}; run `teams auth login`"))),
276            403 => Err(permission_error(message)),
277            404 => Err(AppError::NotFound(message.into())),
278            _ => Err(AppError::Api(format!("{message} ({status})"))),
279        }
280    }
281}
282
283fn unconfirmed() -> AppError {
284    AppError::Unconfirmed(
285        "Microsoft Graph did not confirm the message; it may still be delivered. Check the conversation before sending again".into(),
286    )
287}
288
289fn permission_error(message: &str) -> AppError {
290    let message = message.trim();
291    let lower = message.to_ascii_lowercase();
292    let hint = if lower.contains("hasn't been provisioned")
293        || lower.contains("has not been provisioned")
294        || lower.contains("valid office365 subscription")
295        || lower.contains("valid microsoft 365 subscription")
296    {
297        "sign in with a work or school account that has Microsoft Teams enabled"
298    } else {
299        "check delegated scopes and tenant consent with `teams doctor`"
300    };
301    let separator = if matches!(message.chars().last(), Some('.' | '!' | '?')) {
302        " "
303    } else {
304        "; "
305    };
306    AppError::Permission(format!("{message}{separator}{hint}"))
307}
308
309fn is_graph_url(raw: &str) -> bool {
310    url::Url::parse(raw).is_ok_and(|url| {
311        url.scheme() == "https"
312            && url.host_str() == Some("graph.microsoft.com")
313            && url.path().starts_with("/v1.0/")
314    })
315}
316
317pub fn select_fields(page: &mut Page, fields: Option<&str>) -> Result<(), AppError> {
318    let Some(fields) = fields else {
319        return Ok(());
320    };
321    let names: Vec<&str> = fields
322        .split(',')
323        .map(str::trim)
324        .filter(|s| !s.is_empty())
325        .collect();
326    if names.is_empty() {
327        return Err(AppError::InvalidInput(
328            "--fields must name at least one field".into(),
329        ));
330    }
331    for item in &mut page.items {
332        let source = item
333            .as_object()
334            .ok_or_else(|| AppError::Api("Graph returned a non-object collection item".into()))?;
335        let filtered = names
336            .iter()
337            .filter_map(|name| {
338                source
339                    .get(*name)
340                    .cloned()
341                    .map(|value| ((*name).to_string(), value))
342            })
343            .collect();
344        *item = Value::Object(filtered);
345    }
346    Ok(())
347}
348
349#[cfg(test)]
350mod tests {
351    #[test]
352    fn continuation_urls_cannot_exfiltrate_bearer_tokens() {
353        assert!(super::is_graph_url(
354            "https://graph.microsoft.com/v1.0/me/chats?$skiptoken=abc"
355        ));
356        assert!(!super::is_graph_url(
357            "https://graph.microsoft.com.evil.example/v1.0/me/chats"
358        ));
359        assert!(!super::is_graph_url(
360            "http://graph.microsoft.com/v1.0/me/chats"
361        ));
362    }
363
364    #[test]
365    fn unprovisioned_tenants_get_a_teams_specific_hint() {
366        let error = super::permission_error(
367            "Microsoft Teams hasn't been provisioned on the tenant. Ensure the tenant has a valid Office365 subscription.",
368        );
369        assert!(
370            error
371                .to_string()
372                .contains("account that has Microsoft Teams enabled")
373        );
374        assert!(!error.to_string().contains(".;"));
375    }
376}