312 lines
16 KiB
Rust
312 lines
16 KiB
Rust
|
|
// 專屬號碼、打電話、X — with the person's own accounts (the original uses its own phone and X
|
|||
|
|
// services; here: a Twilio number and an X developer app).
|
|||
|
|
//
|
|||
|
|
// • 簡訊 (Twilio): the bot answers texts sent to the number (polled, no public address needed) and
|
|||
|
|
// replies from it.
|
|||
|
|
// • 打電話 (Twilio): the bot can call someone and read out a message (asked every time).
|
|||
|
|
// • X: the bot answers @mentions as replies, and can post (posting is asked every time).
|
|||
|
|
//
|
|||
|
|
// Test servers can stand in: ANYBOT_TWILIO_API and ANYBOT_X_API.
|
|||
|
|
|
|||
|
|
use base64::Engine;
|
|||
|
|
use serde_json::{json, Value};
|
|||
|
|
use std::sync::{Mutex, OnceLock};
|
|||
|
|
use std::time::Duration;
|
|||
|
|
|
|||
|
|
pub const SMS_MAX: usize = 1_600;
|
|||
|
|
pub const X_MAX: usize = 280;
|
|||
|
|
|
|||
|
|
fn twilio() -> String {
|
|||
|
|
std::env::var("ANYBOT_TWILIO_API").unwrap_or_else(|_| "https://api.twilio.com".into())
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fn x_api() -> String {
|
|||
|
|
std::env::var("ANYBOT_X_API").unwrap_or_else(|_| "https://api.x.com".into())
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fn agent() -> ureq::Agent {
|
|||
|
|
ureq::Agent::config_builder().timeout_global(Some(Duration::from_secs(30))).http_status_as_error(false).build().into()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// "AccountSID:AuthToken" → (sid, Basic header).
|
|||
|
|
pub fn twilio_auth(token: &str) -> Result<(String, String), String> {
|
|||
|
|
let (sid, secret) = token.trim().split_once(':').ok_or("Twilio 金鑰要填「Account SID:Auth Token」")?;
|
|||
|
|
Ok((sid.trim().to_string(), format!("Basic {}", base64::engine::general_purpose::STANDARD.encode(format!("{}:{}", sid.trim(), secret.trim())))))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fn form(pairs: &[(&str, &str)]) -> String {
|
|||
|
|
fn enc(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()
|
|||
|
|
}
|
|||
|
|
pairs.iter().map(|(k, v)| format!("{}={}", enc(k), enc(v))).collect::<Vec<_>>().join("&")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fn read(mut r: ureq::http::Response<ureq::Body>) -> Result<Value, String> {
|
|||
|
|
let st = r.status().as_u16();
|
|||
|
|
let text = r.body_mut().read_to_string().unwrap_or_default();
|
|||
|
|
let v: Value = serde_json::from_str(&text).unwrap_or(Value::Null);
|
|||
|
|
if !(200..300).contains(&st) {
|
|||
|
|
let msg = v["message"].as_str().or(v["detail"].as_str()).or(v["title"].as_str()).map(String::from).unwrap_or_else(|| text.chars().take(160).collect());
|
|||
|
|
return Err(format!("HTTP {st}:{msg}"));
|
|||
|
|
}
|
|||
|
|
Ok(v)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fn twilio_post(token: &str, path: &str, pairs: &[(&str, &str)]) -> Result<Value, String> {
|
|||
|
|
let (sid, auth) = twilio_auth(token)?;
|
|||
|
|
let r = agent()
|
|||
|
|
.post(&format!("{}/2010-04-01/Accounts/{sid}/{path}", twilio()))
|
|||
|
|
.header("Authorization", &auth)
|
|||
|
|
.header("Content-Type", "application/x-www-form-urlencoded")
|
|||
|
|
.send(form(pairs).as_bytes())
|
|||
|
|
.map_err(|e| format!("連不到 Twilio:{e}"))?;
|
|||
|
|
read(r)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn twilio_get(token: &str, path_query: &str) -> Result<Value, String> {
|
|||
|
|
let (sid, auth) = twilio_auth(token)?;
|
|||
|
|
let r = agent().get(&format!("{}/2010-04-01/Accounts/{sid}/{path_query}", twilio())).header("Authorization", &auth).call().map_err(|e| format!("連不到 Twilio:{e}"))?;
|
|||
|
|
read(r)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn twilio_test(token: &str) -> Result<String, String> {
|
|||
|
|
let v = twilio_get(token, ".json")?;
|
|||
|
|
Ok(format!("已連上 Twilio:{}", v["friendly_name"].as_str().unwrap_or("帳號")))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn send_sms(token: &str, from: &str, to: &str, text: &str) -> Result<(), String> {
|
|||
|
|
for part in crate::channels::split(text, SMS_MAX) {
|
|||
|
|
twilio_post(token, "Messages.json", &[("From", from), ("To", to), ("Body", &part)])?;
|
|||
|
|
}
|
|||
|
|
Ok(())
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// New texts to `number` (oldest first), skipping ones already seen. (sid, from, body)
|
|||
|
|
pub fn sms_new(list: &Value, seen: &mut std::collections::HashSet<String>) -> Vec<(String, String, String)> {
|
|||
|
|
let mut v: Vec<(String, String, String)> = list["messages"]
|
|||
|
|
.as_array()
|
|||
|
|
.into_iter()
|
|||
|
|
.flatten()
|
|||
|
|
.filter(|m| m["direction"].as_str() == Some("inbound"))
|
|||
|
|
.filter_map(|m| Some((m["sid"].as_str()?.to_string(), m["from"].as_str().unwrap_or("").to_string(), m["body"].as_str().unwrap_or("").to_string())))
|
|||
|
|
.filter(|(sid, _, _)| seen.insert(sid.clone()))
|
|||
|
|
.collect();
|
|||
|
|
// Twilio lists newest first.
|
|||
|
|
v.reverse();
|
|||
|
|
v
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fn xml_escape(s: &str) -> String {
|
|||
|
|
s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Call `to` from the Twilio number and read `message` aloud (Traditional Chinese voice).
|
|||
|
|
pub fn call(token: &str, from: &str, to: &str, message: &str) -> Result<String, String> {
|
|||
|
|
let twiml = format!("<Response><Say language=\"zh-TW\">{}</Say><Pause length=\"1\"/><Say language=\"zh-TW\">{}</Say></Response>", xml_escape(message), xml_escape(message));
|
|||
|
|
let v = twilio_post(token, "Calls.json", &[("From", from), ("To", to), ("Twiml", &twiml)])?;
|
|||
|
|
Ok(format!("已撥給 {to}(通話編號 {},對方接起後會聽到兩次)。", v["sid"].as_str().unwrap_or("?")))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---------- X ----------
|
|||
|
|
|
|||
|
|
fn x_get(token: &str, path: &str) -> Result<Value, String> {
|
|||
|
|
let r = agent().get(&format!("{}{path}", x_api())).header("Authorization", &format!("Bearer {}", token.trim())).call().map_err(|e| format!("連不到 X:{e}"))?;
|
|||
|
|
read(r)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn x_me(token: &str) -> Result<(String, String), String> {
|
|||
|
|
let v = x_get(token, "/2/users/me")?;
|
|||
|
|
Ok((v["data"]["id"].as_str().unwrap_or("").to_string(), v["data"]["username"].as_str().unwrap_or("").to_string()))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Post (optionally as a reply). Returns the new post's address.
|
|||
|
|
pub fn x_post(token: &str, text: &str, reply_to: &str) -> Result<String, String> {
|
|||
|
|
let mut body = json!({"text": text.chars().take(X_MAX).collect::<String>()});
|
|||
|
|
if !reply_to.is_empty() {
|
|||
|
|
body["reply"] = json!({"in_reply_to_tweet_id": reply_to});
|
|||
|
|
}
|
|||
|
|
let r = agent()
|
|||
|
|
.post(&format!("{}/2/tweets", x_api()))
|
|||
|
|
.header("Authorization", &format!("Bearer {}", token.trim()))
|
|||
|
|
.header("Content-Type", "application/json")
|
|||
|
|
.send(body.to_string().as_bytes())
|
|||
|
|
.map_err(|e| format!("連不到 X:{e}"))?;
|
|||
|
|
let v = read(r)?;
|
|||
|
|
Ok(format!("https://x.com/i/status/{}", v["data"]["id"].as_str().unwrap_or("")))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Mentions newer than `since_id`, oldest first: (id, author username, text). Returns the newest id.
|
|||
|
|
pub fn x_mentions(token: &str, user_id: &str, since_id: &str) -> Result<(String, Vec<(String, String, String)>), String> {
|
|||
|
|
let q = if since_id.is_empty() { String::new() } else { format!("&since_id={since_id}") };
|
|||
|
|
let v = x_get(token, &format!("/2/users/{user_id}/mentions?max_results=20&expansions=author_id&user.fields=username{q}"))?;
|
|||
|
|
Ok(mentions_new(&v, since_id))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn mentions_new(v: &Value, since_id: &str) -> (String, Vec<(String, String, String)>) {
|
|||
|
|
let names: std::collections::HashMap<String, String> = v["includes"]["users"].as_array().into_iter().flatten().map(|u| (u["id"].as_str().unwrap_or("").to_string(), u["username"].as_str().unwrap_or("").to_string())).collect();
|
|||
|
|
let newest = v["meta"]["newest_id"].as_str().unwrap_or(since_id).to_string();
|
|||
|
|
let mut items: Vec<(String, String, String)> = v["data"]
|
|||
|
|
.as_array()
|
|||
|
|
.into_iter()
|
|||
|
|
.flatten()
|
|||
|
|
.map(|t| (t["id"].as_str().unwrap_or("").to_string(), names.get(t["author_id"].as_str().unwrap_or("")).cloned().unwrap_or_default(), t["text"].as_str().unwrap_or("").to_string()))
|
|||
|
|
.collect();
|
|||
|
|
items.reverse();
|
|||
|
|
(newest, items)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---------- what bots can use ----------
|
|||
|
|
|
|||
|
|
/// The configured accounts (set when the channels start): (Twilio token, Twilio number), X token.
|
|||
|
|
#[derive(Clone, Default)]
|
|||
|
|
pub struct Accounts {
|
|||
|
|
pub twilio: Option<(String, String)>,
|
|||
|
|
pub x: Option<String>,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn accounts() -> &'static Mutex<Accounts> {
|
|||
|
|
static A: OnceLock<Mutex<Accounts>> = OnceLock::new();
|
|||
|
|
A.get_or_init(|| Mutex::new(Accounts::default()))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn specs() -> Vec<Value> {
|
|||
|
|
let a = accounts().lock().unwrap().clone();
|
|||
|
|
let mut v = vec![];
|
|||
|
|
if a.twilio.is_some() {
|
|||
|
|
v.push(json!({"name": "place_call", "description": "Phone someone from the person's own number and read a short message aloud (Traditional Chinese voice). The person is asked every time.",
|
|||
|
|
"parameters": {"type": "object", "properties": {"to": {"type": "string", "description": "Phone number in international format, e.g. +886912345678"}, "message": {"type": "string"}}, "required": ["to", "message"]}}));
|
|||
|
|
v.push(json!({"name": "send_sms", "description": "Send a text message from the person's own number. The person is asked every time.",
|
|||
|
|
"parameters": {"type": "object", "properties": {"to": {"type": "string"}, "text": {"type": "string"}}, "required": ["to", "text"]}}));
|
|||
|
|
}
|
|||
|
|
if a.x.is_some() {
|
|||
|
|
v.push(json!({"name": "post_to_x", "description": "Post on X (Twitter) as the person, optionally as a reply. At most 280 characters. The person is asked every time.",
|
|||
|
|
"parameters": {"type": "object", "properties": {"text": {"type": "string"}, "reply_to": {"type": "string", "description": "Post id to reply to (optional)"}}, "required": ["text"]}}));
|
|||
|
|
}
|
|||
|
|
v
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn is_phone_tool(name: &str) -> bool {
|
|||
|
|
matches!(name, "place_call" | "send_sms" | "post_to_x")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// What the person is asked.
|
|||
|
|
pub fn describe(name: &str, args: &str) -> String {
|
|||
|
|
let v = crate::tools::args_of(args);
|
|||
|
|
let s = |k: &str| v[k].as_str().unwrap_or("").to_string();
|
|||
|
|
match name {
|
|||
|
|
"place_call" => format!("打電話給 {},唸:「{}」", s("to"), s("message")),
|
|||
|
|
"send_sms" => format!("傳簡訊給 {}:「{}」", s("to"), s("text")),
|
|||
|
|
"post_to_x" => format!("用你的帳號在 X 發文{}:「{}」", if s("reply_to").is_empty() { String::new() } else { "(回覆)".into() }, s("text")),
|
|||
|
|
_ => String::new(),
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn run(name: &str, args: &str) -> (String, bool) {
|
|||
|
|
let v = crate::tools::args_of(args);
|
|||
|
|
let s = |k: &str| v[k].as_str().unwrap_or("").trim().to_string();
|
|||
|
|
let a = accounts().lock().unwrap().clone();
|
|||
|
|
let r = match (name, &a.twilio, &a.x) {
|
|||
|
|
("place_call", Some((t, from)), _) => call(t, from, &s("to"), &s("message")),
|
|||
|
|
("send_sms", Some((t, from)), _) => send_sms(t, from, &s("to"), &s("text")).map(|_| format!("已傳簡訊給 {}。", s("to"))),
|
|||
|
|
("post_to_x", _, Some(t)) => x_post(t, &s("text"), &s("reply_to")).map(|u| format!("已發文:{u}")),
|
|||
|
|
_ => Err("這個帳號還沒設定(設定 › 訊息管道)".into()),
|
|||
|
|
};
|
|||
|
|
match r {
|
|||
|
|
Ok(t) => (t, true),
|
|||
|
|
Err(e) => (e, false),
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[cfg(test)]
|
|||
|
|
mod tests {
|
|||
|
|
use super::*;
|
|||
|
|
use std::io::{Read, Write};
|
|||
|
|
|
|||
|
|
/// A stand-in for Twilio and X: records requests, answers like they do.
|
|||
|
|
fn fake() -> (u16, std::sync::Arc<Mutex<Vec<(String, String, String)>>>) {
|
|||
|
|
let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
|||
|
|
let port = l.local_addr().unwrap().port();
|
|||
|
|
let log = std::sync::Arc::new(Mutex::new(vec![]));
|
|||
|
|
let lg = log.clone();
|
|||
|
|
std::thread::spawn(move || {
|
|||
|
|
for c in l.incoming().flatten() {
|
|||
|
|
let mut c = c;
|
|||
|
|
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, b)) = t.split_once("\r\n\r\n") {
|
|||
|
|
let len: usize = h.lines().find_map(|l| l.to_lowercase().strip_prefix("content-length:").map(|v| v.trim().parse().unwrap_or(0))).unwrap_or(0);
|
|||
|
|
if b.len() >= len || n == 0 {
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if n == 0 {
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
let t = String::from_utf8_lossy(&got).to_string();
|
|||
|
|
let (head, body) = t.split_once("\r\n\r\n").unwrap_or((&t, ""));
|
|||
|
|
let line = head.lines().next().unwrap_or("").to_string();
|
|||
|
|
let auth = head.lines().find_map(|l| l.strip_prefix("Authorization: ").or(l.strip_prefix("authorization: "))).unwrap_or("").to_string();
|
|||
|
|
lg.lock().unwrap().push((line.clone(), auth, body.to_string()));
|
|||
|
|
let resp = if line.contains("/Messages.json") && line.starts_with("GET") {
|
|||
|
|
json!({"messages": [{"sid": "SM2", "direction": "inbound", "from": "+886900000002", "body": "第二則"}, {"sid": "SM9", "direction": "outbound-api", "from": "+1555", "body": "我發的"}, {"sid": "SM1", "direction": "inbound", "from": "+886900000001", "body": "第一則"}]})
|
|||
|
|
} else if line.contains("/Calls.json") {
|
|||
|
|
json!({"sid": "CA123"})
|
|||
|
|
} else if line.contains("/users/me") {
|
|||
|
|
json!({"data": {"id": "42", "username": "daniel"}})
|
|||
|
|
} else if line.contains("/tweets") {
|
|||
|
|
json!({"data": {"id": "777", "text": "x"}})
|
|||
|
|
} else {
|
|||
|
|
json!({"sid": "SMx", "friendly_name": "My account"})
|
|||
|
|
};
|
|||
|
|
let b = resp.to_string();
|
|||
|
|
let _ = c.write_all(format!("HTTP/1.1 201 Created\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{b}", b.len()).as_bytes());
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
(port, log)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn twilio_and_x_requests_look_right() {
|
|||
|
|
let (port, log) = fake();
|
|||
|
|
std::env::set_var("ANYBOT_TWILIO_API", format!("http://127.0.0.1:{port}"));
|
|||
|
|
std::env::set_var("ANYBOT_X_API", format!("http://127.0.0.1:{port}"));
|
|||
|
|
let tok = "AC123:secret";
|
|||
|
|
assert!(twilio_test(tok).unwrap().contains("My account"));
|
|||
|
|
send_sms(tok, "+15550001", "+886912345678", "你好 & 再見").unwrap();
|
|||
|
|
let out = call(tok, "+15550001", "+886912345678", "會議改到 <三點>").unwrap();
|
|||
|
|
assert!(out.contains("CA123"));
|
|||
|
|
let mut seen = Default::default();
|
|||
|
|
let list = twilio_get(tok, "Messages.json?To=%2B15550001&PageSize=20").unwrap();
|
|||
|
|
let new = sms_new(&list, &mut seen);
|
|||
|
|
assert_eq!(new.iter().map(|m| m.2.as_str()).collect::<Vec<_>>(), vec!["第一則", "第二則"]);
|
|||
|
|
assert!(sms_new(&list, &mut seen).is_empty());
|
|||
|
|
assert_eq!(x_me("xtok").unwrap().1, "daniel");
|
|||
|
|
assert_eq!(x_post("xtok", "嗨", "555").unwrap(), "https://x.com/i/status/777");
|
|||
|
|
let log = log.lock().unwrap();
|
|||
|
|
let sms = log.iter().find(|r| r.0.starts_with("POST") && r.0.contains("Messages.json")).unwrap();
|
|||
|
|
assert!(sms.0.contains("/2010-04-01/Accounts/AC123/Messages.json"));
|
|||
|
|
assert_eq!(sms.1, format!("Basic {}", base64::engine::general_purpose::STANDARD.encode("AC123:secret")));
|
|||
|
|
assert!(sms.2.contains("To=%2B886912345678") && sms.2.contains("Body=%E4%BD%A0%E5%A5%BD%20%26%20"));
|
|||
|
|
let callr = log.iter().find(|r| r.0.contains("Calls.json")).unwrap();
|
|||
|
|
assert!(callr.2.contains("Twiml=") && callr.2.contains("%26lt%3B"), "TwiML must be escaped: {}", callr.2);
|
|||
|
|
let tw = log.iter().find(|r| r.0.contains("/2/tweets")).unwrap();
|
|||
|
|
assert_eq!(tw.1, "Bearer xtok");
|
|||
|
|
let body: Value = serde_json::from_str(&tw.2).unwrap();
|
|||
|
|
assert_eq!(body["reply"]["in_reply_to_tweet_id"], "555");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn mentions_come_oldest_first_with_names() {
|
|||
|
|
let v = json!({"data": [{"id": "9", "author_id": "1", "text": "@daniel 新的"}, {"id": "8", "author_id": "2", "text": "@daniel 舊的"}], "includes": {"users": [{"id": "1", "username": "amy"}, {"id": "2", "username": "bob"}]}, "meta": {"newest_id": "9"}});
|
|||
|
|
let (newest, items) = mentions_new(&v, "7");
|
|||
|
|
assert_eq!(newest, "9");
|
|||
|
|
assert_eq!(items[0], ("8".into(), "bob".into(), "@daniel 舊的".into()));
|
|||
|
|
assert_eq!(mentions_new(&json!({"meta": {"result_count": 0}}), "9").0, "9");
|
|||
|
|
}
|
|||
|
|
}
|