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