// 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, urlencode(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())
}
pub fn urlencode(s: &str) -> String {
s.bytes().map(|b| if b.is_ascii_alphanumeric() || b"-_.~".contains(&b) { (b as char).to_string() } else { format!("%{b:02X}") }).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!(urlencode("a b/c"), "a%20b%2Fc");
}
}