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 #[serde(skip_serializing_if = "Option::is_none")]
18 pub source: Option<&'static str>,
19 #[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 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(¤t) {
206 return Err(AppError::Api(
207 "Graph returned an invalid continuation URL".into(),
208 ));
209 }
210 let response = self
211 .http
212 .get(¤t)
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}