AnyBot/src/teams.rs

268 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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<Value, String> {
let body: String = fields.iter().map(|(k, v)| format!("{}={}", k, crate::httpd::url_encode(v))).collect::<Vec<_>>().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<String, String> {
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<HashMap<String, Cached>> {
static C: OnceLock<Mutex<HashMap<String, Cached>>> = 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<HashMap<u64, String>> {
static R: OnceLock<Mutex<HashMap<u64, String>>> = 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<String, String> {
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", &current), ("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<Value, String> {
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::<String>()));
}
serde_json::from_str(&text).map_err(|e| e.to_string())
}
pub fn graph_post(access: &str, path: &str, body: &Value) -> Result<Value, String> {
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::<String>()));
}
Ok(serde_json::from_str(&text).unwrap_or(Value::Null))
}
/// Where a target points: a chat ("chat:<id>") or a channel ("<team id>/<channel id>").
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("<br>", "\n").replace("</p>", "\n").chars() {
match c {
'<' => in_tag = true,
'>' => in_tag = false,
_ if !in_tag => out.push(c),
_ => {}
}
}
out.replace("&nbsp;", " ").replace("&amp;", "&").replace("&lt;", "<").replace("&gt;", ">").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('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('\n', "<br>");
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::<String>::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::<usize>().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":"<p>今天會議幾點?</p>"}}]}"#.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("&lt;在 101&gt;")), "{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": "<p>週報&nbsp;好了嗎?</p>"}},
{"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");
}
}