// Microsoft Teams through Microsoft Graph (the original: a Teams app answering in channels and // chats). Sign-in uses the device-code flow, so no public address is needed: AnyBot shows a code, // the person enters it at microsoft.com/devicelogin, and the refresh token is kept. use serde_json::{json, Value}; use std::collections::HashMap; use std::sync::{Mutex, OnceLock}; use std::time::{Duration, Instant}; pub const SCOPES: &str = "offline_access User.Read Chat.ReadWrite ChannelMessage.Read.All ChannelMessage.Send"; const GRAPH: &str = "https://graph.microsoft.com/v1.0"; /// Access tokens last about an hour; refresh a little early (estimated). const ACCESS_REFRESH_SECS: u64 = 50 * 60; fn login_base() -> String { std::env::var("ANYBOT_MS_LOGIN").unwrap_or_else(|_| "https://login.microsoftonline.com".into()) } fn graph_base() -> String { std::env::var("ANYBOT_MS_GRAPH").unwrap_or_else(|_| GRAPH.into()) } fn agent() -> ureq::Agent { ureq::Agent::config_builder().timeout_global(Some(Duration::from_secs(30))).http_status_as_error(false).build().into() } fn form(url: &str, fields: &[(&str, &str)]) -> Result { let body: String = fields.iter().map(|(k, v)| format!("{}={}", k, crate::httpd::url_encode(v))).collect::>().join("&"); let mut resp = agent().post(url).header("Content-Type", "application/x-www-form-urlencoded").send(body.as_bytes()).map_err(|e| e.to_string())?; let text = resp.body_mut().read_to_string().unwrap_or_default(); serde_json::from_str(&text).map_err(|_| text.chars().take(200).collect()) } /// Step 1: ask for a code. Returns (user code, address, device code, poll seconds). pub fn device_code(client_id: &str, tenant: &str) -> Result<(String, String, String, u64), String> { let v = form(&format!("{}/{}/oauth2/v2.0/devicecode", login_base(), tenant_or_default(tenant)), &[("client_id", client_id.trim()), ("scope", SCOPES)])?; match (v["user_code"].as_str(), v["verification_uri"].as_str(), v["device_code"].as_str()) { (Some(u), Some(a), Some(d)) => Ok((u.into(), a.into(), d.into(), v["interval"].as_u64().unwrap_or(5))), _ => Err(v["error_description"].as_str().unwrap_or("拿不到登入代碼").lines().next().unwrap_or("").to_string()), } } /// Step 2: wait until the person signed in. Returns the refresh token. pub fn wait_for_login(client_id: &str, tenant: &str, device: &str, interval: u64, max: Duration) -> Result { let start = Instant::now(); while start.elapsed() < max { std::thread::sleep(Duration::from_secs(interval.max(1))); let v = form(&format!("{}/{}/oauth2/v2.0/token", login_base(), tenant_or_default(tenant)), &[("grant_type", "urn:ietf:params:oauth:grant-type:device_code"), ("client_id", client_id.trim()), ("device_code", device)])?; if let Some(r) = v["refresh_token"].as_str() { return Ok(r.to_string()); } match v["error"].as_str().unwrap_or("") { "authorization_pending" | "slow_down" => continue, e => return Err(format!("登入沒有完成:{}", v["error_description"].as_str().unwrap_or(e).lines().next().unwrap_or(e))), } } Err("等太久了,請再登入一次".into()) } fn tenant_or_default(t: &str) -> String { if t.trim().is_empty() { "organizations".into() } else { t.trim().into() } } struct Cached { access: String, at: Instant, } fn cache() -> &'static Mutex> { static C: OnceLock>> = OnceLock::new(); C.get_or_init(|| Mutex::new(HashMap::new())) } /// New refresh tokens (Microsoft rotates them), for the window to save: channel id → token. pub fn rotated() -> &'static Mutex> { static R: OnceLock>> = OnceLock::new(); R.get_or_init(|| Mutex::new(HashMap::new())) } /// An access token for a channel's refresh token. pub fn access(channel_id: u64, client_id: &str, tenant: &str, refresh: &str) -> Result { let key = format!("{channel_id}"); if let Some(c) = cache().lock().unwrap().get(&key) { if c.at.elapsed().as_secs() < ACCESS_REFRESH_SECS { return Ok(c.access.clone()); } } let current = rotated().lock().unwrap().get(&channel_id).cloned().unwrap_or_else(|| refresh.to_string()); let v = form(&format!("{}/{}/oauth2/v2.0/token", login_base(), tenant_or_default(tenant)), &[("grant_type", "refresh_token"), ("client_id", client_id.trim()), ("refresh_token", ¤t), ("scope", SCOPES)])?; let a = v["access_token"].as_str().ok_or_else(|| format!("Microsoft 登入過期了,請重新登入({})", v["error"].as_str().unwrap_or("")))?.to_string(); if let Some(r) = v["refresh_token"].as_str() { rotated().lock().unwrap().insert(channel_id, r.to_string()); } cache().lock().unwrap().insert(key, Cached { access: a.clone(), at: Instant::now() }); Ok(a) } pub fn graph_get(access: &str, path: &str) -> Result { let mut resp = agent().get(&format!("{}{path}", graph_base())).header("Authorization", &format!("Bearer {access}")).call().map_err(|e| e.to_string())?; let st = resp.status().as_u16(); let text = resp.body_mut().read_to_string().unwrap_or_default(); if st == 429 { return Err("rate".into()); } if !(200..300).contains(&st) { return Err(format!("Teams:HTTP {st} {}", text.chars().take(150).collect::())); } serde_json::from_str(&text).map_err(|e| e.to_string()) } pub fn graph_post(access: &str, path: &str, body: &Value) -> Result { let mut resp = agent().post(&format!("{}{path}", graph_base())).header("Authorization", &format!("Bearer {access}")).header("Content-Type", "application/json").send(body.to_string().as_bytes()).map_err(|e| e.to_string())?; let st = resp.status().as_u16(); let text = resp.body_mut().read_to_string().unwrap_or_default(); if !(200..300).contains(&st) { return Err(format!("Teams:HTTP {st} {}", text.chars().take(150).collect::())); } Ok(serde_json::from_str(&text).unwrap_or(Value::Null)) } /// Where a target points: a chat ("chat:") or a channel ("/"). pub fn messages_path(target: &str) -> String { match target.trim().strip_prefix("chat:") { Some(chat) => format!("/chats/{chat}/messages?$top=20"), None => { let (team, ch) = target.trim().split_once('/').unwrap_or((target.trim(), "")); format!("/teams/{team}/channels/{ch}/messages?$top=20") } } } pub fn reply_path(target: &str, thread: &str) -> String { match target.trim().strip_prefix("chat:") { Some(chat) => format!("/chats/{chat}/messages"), None => { let (team, ch) = target.trim().split_once('/').unwrap_or((target.trim(), "")); if thread.is_empty() { format!("/teams/{team}/channels/{ch}/messages") } else { format!("/teams/{team}/channels/{ch}/messages/{thread}/replies") } } } } /// Teams message HTML → plain text. pub fn plain(html: &str) -> String { let mut out = String::new(); let mut in_tag = false; for c in html.replace("
", "\n").replace("

", "\n").chars() { match c { '<' => in_tag = true, '>' => in_tag = false, _ if !in_tag => out.push(c), _ => {} } } out.replace(" ", " ").replace("&", "&").replace("<", "<").replace(">", ">").trim().to_string() } /// New messages (by created time) from people other than `me`, oldest first: (who, text, id). pub fn new_messages(list: &Value, cursor: &str, me: &str) -> (String, Vec<(String, String, String)>) { let mut newest = cursor.to_string(); let mut items = vec![]; let mut msgs: Vec<&Value> = list["value"].as_array().map(|a| a.iter().collect()).unwrap_or_default(); msgs.sort_by(|a, b| a["createdDateTime"].as_str().unwrap_or("").cmp(b["createdDateTime"].as_str().unwrap_or(""))); for m in msgs { let t = m["createdDateTime"].as_str().unwrap_or("").to_string(); if t.as_str() <= cursor { continue; } if t > newest { newest = t.clone(); } if m["messageType"].as_str().unwrap_or("message") != "message" || m["from"]["user"]["id"].as_str() == Some(me) || m["from"]["application"].is_object() { continue; } let text = plain(m["body"]["content"].as_str().unwrap_or("")); if !text.is_empty() { items.push((m["from"]["user"]["displayName"].as_str().unwrap_or("someone").to_string(), text, m["id"].as_str().unwrap_or("").to_string())); } } (newest, items) } pub fn send(access: &str, target: &str, thread: &str, text: &str) -> Result<(), String> { let html = text.replace('&', "&").replace('<', "<").replace('>', ">").replace('\n', "
"); graph_post(access, &reply_path(target, thread), &json!({"body": {"contentType": "html", "content": html}})).map(|_| ()) } #[cfg(test)] mod tests { use super::*; /// Sign in with a device code, get an access token, read new chat messages and answer, /// against a stand-in for Microsoft's servers. #[test] fn teams_round_trip() { use std::io::{Read, Write}; let server = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); let port = server.local_addr().unwrap().port(); std::env::set_var("ANYBOT_MS_LOGIN", format!("http://127.0.0.1:{port}/login")); std::env::set_var("ANYBOT_MS_GRAPH", format!("http://127.0.0.1:{port}/graph")); let posted = std::sync::Arc::new(Mutex::new(Vec::::new())); let p2 = posted.clone(); std::thread::spawn(move || { let mut polls = 0; for conn in server.incoming().flatten() { let mut c = conn; c.set_read_timeout(Some(Duration::from_secs(3))).ok(); let mut got = vec![]; let mut buf = [0u8; 8192]; loop { let n = c.read(&mut buf).unwrap_or(0); got.extend_from_slice(&buf[..n]); let t = String::from_utf8_lossy(&got).to_string(); if let Some(h) = t.find("\r\n\r\n") { let len = t[..h].lines().find_map(|l| l.to_lowercase().strip_prefix("content-length:").map(|v| v.trim().parse::().unwrap_or(0))).unwrap_or(0); if got.len() >= h + 4 + len { break; } } if n == 0 { break; } } let req = String::from_utf8_lossy(&got).to_string(); let body = if req.contains("/devicecode") { r#"{"user_code":"ABCD-1234","verification_uri":"https://microsoft.com/devicelogin","device_code":"DEV","interval":1}"#.to_string() } else if req.contains("device_code") { polls += 1; if polls < 2 { r#"{"error":"authorization_pending"}"#.into() } else { r#"{"refresh_token":"R1","access_token":"A0"}"#.into() } } else if req.contains("grant_type=refresh_token") { r#"{"access_token":"A1","refresh_token":"R2"}"#.into() } else if req.starts_with("GET /graph/chats/") { r#"{"value":[{"id":"m5","createdDateTime":"2026-11-04T02:00:00Z","messageType":"message","from":{"user":{"id":"U9","displayName":"阿德"}},"body":{"content":"

今天會議幾點?

"}}]}"#.into() } else { p2.lock().unwrap().push(req.clone()); "{}".into() }; let _ = c.write_all(format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).as_bytes()); } }); let (code, _url, device, interval) = device_code("CLIENT", "").unwrap(); assert_eq!(code, "ABCD-1234"); let refresh = wait_for_login("CLIENT", "", &device, interval, Duration::from_secs(10)).unwrap(); assert_eq!(refresh, "R1"); let a = access(77, "CLIENT", "", &refresh).unwrap(); assert_eq!(a, "A1"); assert_eq!(rotated().lock().unwrap().get(&77).cloned(), Some("R2".into())); let list = graph_get(&a, &messages_path("chat:C1")).unwrap(); let (_, items) = new_messages(&list, "2026-11-04T01:00:00Z", "ME"); assert_eq!(items[0].1, "今天會議幾點?"); send(&a, "chat:C1", "", "下午 3 點 <在 101>").unwrap(); let reqs = posted.lock().unwrap().clone(); assert!(reqs.iter().any(|r| r.starts_with("POST /graph/chats/C1/messages") && r.contains("Bearer A1") && r.contains("<在 101>")), "{reqs:?}"); std::env::remove_var("ANYBOT_MS_LOGIN"); std::env::remove_var("ANYBOT_MS_GRAPH"); } #[test] fn paths_and_messages() { assert_eq!(messages_path("chat:19:abc@thread.v2"), "/chats/19:abc@thread.v2/messages?$top=20"); assert_eq!(reply_path("T1/C1", "m9"), "/teams/T1/channels/C1/messages/m9/replies"); assert_eq!(reply_path("chat:X", "m9"), "/chats/X/messages"); let v = json!({"value": [ {"id": "3", "createdDateTime": "2026-11-04T01:00:03Z", "messageType": "message", "from": {"user": {"id": "ME", "displayName": "我"}}, "body": {"content": "mine"}}, {"id": "2", "createdDateTime": "2026-11-04T01:00:02Z", "messageType": "message", "from": {"user": {"id": "U2", "displayName": "小美"}}, "body": {"content": "

週報 好了嗎?

"}}, {"id": "1", "createdDateTime": "2026-11-04T01:00:01Z", "messageType": "message", "from": {"user": {"id": "U3", "displayName": "old"}}, "body": {"content": "old"}} ]}); let (cur, items) = new_messages(&v, "2026-11-04T01:00:01Z", "ME"); assert_eq!(cur, "2026-11-04T01:00:03Z"); assert_eq!(items, vec![("小美".to_string(), "週報 好了嗎?".to_string(), "2".to_string())]); assert_eq!(crate::httpd::url_encode("a b/c"), "a%20b%2Fc"); } }