806 lines
37 KiB
Rust
806 lines
37 KiB
Rust
|
|
// 訊息管道: a bot answers people in Slack, Discord or Telegram (the original: Slack / Microsoft
|
|||
|
|
// Teams / Discord apps, plus Telegram-style messaging channels). The original's servers receive
|
|||
|
|
// those messages; a desktop app has no public address, so here each channel is polled with the
|
|||
|
|
// person's own bot token, and the bot's final answer of that turn is posted back (in a thread /
|
|||
|
|
// as a reply to the message).
|
|||
|
|
|
|||
|
|
use serde::{Deserialize, Serialize};
|
|||
|
|
use serde_json::{json, Value};
|
|||
|
|
use std::collections::HashMap;
|
|||
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|||
|
|
use std::sync::mpsc::Sender;
|
|||
|
|
use std::sync::{Arc, Mutex, OnceLock};
|
|||
|
|
use std::time::Duration;
|
|||
|
|
|
|||
|
|
/// How often Slack and Discord are checked (estimated; within both APIs' rate limits).
|
|||
|
|
pub const POLL_SECS: u64 = 3;
|
|||
|
|
/// Telegram long-poll wait.
|
|||
|
|
pub const TELEGRAM_WAIT_SECS: u64 = 25;
|
|||
|
|
/// Longest single message each service takes.
|
|||
|
|
pub const DISCORD_MAX: usize = 2000;
|
|||
|
|
pub const TELEGRAM_MAX: usize = 4096;
|
|||
|
|
pub const SLACK_MAX: usize = 39_000;
|
|||
|
|
|
|||
|
|
#[derive(Clone, Copy, Serialize, Deserialize, Debug, PartialEq, Eq, Hash)]
|
|||
|
|
pub enum Kind {
|
|||
|
|
Slack,
|
|||
|
|
Discord,
|
|||
|
|
Telegram,
|
|||
|
|
Teams,
|
|||
|
|
/// Mac「訊息」(iMessage / SMS).
|
|||
|
|
IMessage,
|
|||
|
|
/// Mail on this Mac.
|
|||
|
|
Email,
|
|||
|
|
/// 專屬號碼: texts to the person's Twilio number.
|
|||
|
|
Sms,
|
|||
|
|
/// X: @mentions, answered as replies.
|
|||
|
|
X,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
impl Kind {
|
|||
|
|
pub fn label(self) -> &'static str {
|
|||
|
|
match self {
|
|||
|
|
Kind::Slack => "Slack",
|
|||
|
|
Kind::Discord => "Discord",
|
|||
|
|
Kind::Telegram => "Telegram",
|
|||
|
|
Kind::Teams => "Teams",
|
|||
|
|
Kind::IMessage => "訊息",
|
|||
|
|
Kind::Email => "郵件",
|
|||
|
|
Kind::Sms => "簡訊(Twilio)",
|
|||
|
|
Kind::X => "X",
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
pub fn token_hint(self) -> &'static str {
|
|||
|
|
match self {
|
|||
|
|
Kind::Slack => "Bot User OAuth Token(xoxb-…)。在 api.slack.com/apps 建立 App,加上 channels:history、groups:history、im:history、chat:write 權限並安裝到工作區,再把 App 邀請進頻道。",
|
|||
|
|
Kind::Discord => "Bot Token。在 discord.com/developers 建立 Application → Bot,打開 Message Content Intent,再用 OAuth2 把 Bot 加進你的伺服器。",
|
|||
|
|
Kind::Telegram => "跟 @BotFather 說 /newbot 拿到的 Token(123456:ABC…)。",
|
|||
|
|
Kind::IMessage => "不用 Token:用這台 Mac 的「訊息」。讀新訊息要給 AnyBot「完全取用磁碟」權限(系統設定 → 隱私權與安全性),傳訊息時 macOS 會問一次能否控制「訊息」。",
|
|||
|
|
Kind::Email => "不用 Token:用這台 Mac 的「郵件」收信和回信。macOS 會問一次能否控制「郵件」。",
|
|||
|
|
Kind::Sms => "「Account SID:Auth Token」(在 console.twilio.com 首頁)。先在 Twilio 買一個號碼;Bot 會回覆傳到這個號碼的簡訊,也能用它打電話和傳簡訊(每次都先問你)。",
|
|||
|
|
Kind::X => "X 的使用者存取權杖(OAuth 2.0,需要 tweet.read、tweet.write、users.read 權限;在 developer.x.com 建立 App 取得)。Bot 會回覆提到你的貼文,也能用你的帳號發文(每次都先問你)。讀取提及需要 X API 付費方案。",
|
|||
|
|
Kind::Teams => "不用 Token:填你的 Microsoft Entra 應用程式(用戶端)ID,按「登入 Microsoft」用裝置碼登入。應用程式要在「驗證」打開「允許公用用戶端流程」,並加上 Chat.ReadWrite、ChannelMessage.Read.All、ChannelMessage.Send 權限(頻道訊息可能要管理員同意)。",
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
pub fn target_hint(self) -> &'static str {
|
|||
|
|
match self {
|
|||
|
|
Kind::Slack => "頻道 ID(例如 C0123ABCD,在頻道資訊最下面)",
|
|||
|
|
Kind::Discord => "頻道 ID(開發者模式下對頻道按右鍵 → 複製頻道 ID)",
|
|||
|
|
Kind::Telegram => "只回這個聊天 ID(留空 = 任何人私訊 Bot 都回)",
|
|||
|
|
Kind::IMessage => "只回這個電話或 Apple ID(例如 +886912345678;留空 = 任何人)",
|
|||
|
|
Kind::Email => "只看這個信箱名稱(留空 = 收件匣)",
|
|||
|
|
Kind::Sms => "你的 Twilio 號碼(例如 +15551234567)",
|
|||
|
|
Kind::X => "(留空)",
|
|||
|
|
Kind::Teams => "chat:<聊天 ID>,或 <團隊 ID>/<頻道 ID>(在 Teams 對聊天或頻道「取得連結」可以看到)",
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq)]
|
|||
|
|
pub struct Channel {
|
|||
|
|
pub id: u64,
|
|||
|
|
pub kind: Kind,
|
|||
|
|
pub name: String,
|
|||
|
|
/// The AnyBot bot that answers.
|
|||
|
|
pub bot_id: u64,
|
|||
|
|
pub token: String,
|
|||
|
|
/// Slack / Discord channel id; Telegram chat id (empty = any).
|
|||
|
|
#[serde(default)]
|
|||
|
|
pub target: String,
|
|||
|
|
#[serde(default = "yes")]
|
|||
|
|
pub enabled: bool,
|
|||
|
|
/// Teams: the Entra app (client) id and tenant (empty = organizations); `token` is the refresh token.
|
|||
|
|
#[serde(default)]
|
|||
|
|
pub client_id: String,
|
|||
|
|
#[serde(default)]
|
|||
|
|
pub tenant: String,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fn yes() -> bool {
|
|||
|
|
true
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Where an answer goes back.
|
|||
|
|
#[derive(Clone, Debug, PartialEq)]
|
|||
|
|
pub struct ReplyTo {
|
|||
|
|
pub kind: Kind,
|
|||
|
|
pub token: String,
|
|||
|
|
pub target: String,
|
|||
|
|
/// Slack thread ts / Discord message id / Telegram message id.
|
|||
|
|
pub thread: String,
|
|||
|
|
pub channel_name: String,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[derive(Clone, Debug)]
|
|||
|
|
pub struct Incoming {
|
|||
|
|
pub bot_id: u64,
|
|||
|
|
pub from: String,
|
|||
|
|
pub text: String,
|
|||
|
|
pub reply: ReplyTo,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[derive(Clone, Default, Debug)]
|
|||
|
|
pub struct Status {
|
|||
|
|
pub ok: bool,
|
|||
|
|
pub note: String,
|
|||
|
|
pub received: u64,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn statuses() -> &'static Mutex<HashMap<u64, Status>> {
|
|||
|
|
static S: OnceLock<Mutex<HashMap<u64, Status>>> = OnceLock::new();
|
|||
|
|
S.get_or_init(|| Mutex::new(HashMap::new()))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fn set_status(id: u64, ok: bool, note: &str, got: u64) {
|
|||
|
|
let mut m = statuses().lock().unwrap();
|
|||
|
|
let s = m.entry(id).or_default();
|
|||
|
|
s.ok = ok;
|
|||
|
|
s.note = note.to_string();
|
|||
|
|
s.received += got;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fn agent(timeout: u64) -> ureq::Agent {
|
|||
|
|
ureq::Agent::config_builder().timeout_global(Some(Duration::from_secs(timeout))).http_status_as_error(false).build().into()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fn get_json(url: &str, auth: Option<&str>, timeout: u64) -> Result<Value, String> {
|
|||
|
|
let mut r = agent(timeout).get(url);
|
|||
|
|
if let Some(a) = auth {
|
|||
|
|
r = r.header("Authorization", a);
|
|||
|
|
}
|
|||
|
|
let mut resp = r.call().map_err(|e| e.to_string())?;
|
|||
|
|
let status = resp.status().as_u16();
|
|||
|
|
let text = resp.body_mut().read_to_string().unwrap_or_default();
|
|||
|
|
if status == 429 {
|
|||
|
|
return Err("rate".into());
|
|||
|
|
}
|
|||
|
|
if !(200..300).contains(&status) {
|
|||
|
|
return Err(format!("HTTP {status}:{}", text.chars().take(160).collect::<String>()));
|
|||
|
|
}
|
|||
|
|
serde_json::from_str(&text).map_err(|e| e.to_string())
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fn post_json(url: &str, auth: Option<&str>, body: &Value) -> Result<Value, String> {
|
|||
|
|
let mut r = agent(30).post(url).header("Content-Type", "application/json; charset=utf-8");
|
|||
|
|
if let Some(a) = auth {
|
|||
|
|
r = r.header("Authorization", a);
|
|||
|
|
}
|
|||
|
|
let mut resp = r.send(body.to_string().as_bytes()).map_err(|e| e.to_string())?;
|
|||
|
|
let status = resp.status().as_u16();
|
|||
|
|
let text = resp.body_mut().read_to_string().unwrap_or_default();
|
|||
|
|
if !(200..300).contains(&status) {
|
|||
|
|
return Err(format!("HTTP {status}:{}", text.chars().take(160).collect::<String>()));
|
|||
|
|
}
|
|||
|
|
Ok(serde_json::from_str(&text).unwrap_or(Value::Null))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Split long answers on line breaks so each piece fits.
|
|||
|
|
pub fn split(text: &str, max: usize) -> Vec<String> {
|
|||
|
|
let mut out = vec![];
|
|||
|
|
let mut cur = String::new();
|
|||
|
|
for line in text.split_inclusive('\n') {
|
|||
|
|
if cur.chars().count() + line.chars().count() > max && !cur.is_empty() {
|
|||
|
|
out.push(std::mem::take(&mut cur));
|
|||
|
|
}
|
|||
|
|
if line.chars().count() > max {
|
|||
|
|
let chars: Vec<char> = line.chars().collect();
|
|||
|
|
for c in chars.chunks(max) {
|
|||
|
|
out.push(c.iter().collect());
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
cur.push_str(line);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if !cur.trim().is_empty() {
|
|||
|
|
out.push(cur);
|
|||
|
|
}
|
|||
|
|
out
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Telegram's address (a test server can stand in for it).
|
|||
|
|
fn tg() -> String {
|
|||
|
|
std::env::var("ANYBOT_TELEGRAM_API").unwrap_or_else(|_| "https://api.telegram.org".into())
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Check a token and say who it is.
|
|||
|
|
pub fn test(kind: Kind, token: &str) -> Result<String, String> {
|
|||
|
|
let t = token.trim();
|
|||
|
|
match kind {
|
|||
|
|
Kind::Slack => {
|
|||
|
|
let v = get_json("https://slack.com/api/auth.test", Some(&format!("Bearer {t}")), 15)?;
|
|||
|
|
if v["ok"].as_bool() == Some(true) {
|
|||
|
|
Ok(format!("已連上 Slack:{}({})", v["user"].as_str().unwrap_or(""), v["team"].as_str().unwrap_or("")))
|
|||
|
|
} else {
|
|||
|
|
Err(format!("Slack 說:{}", v["error"].as_str().unwrap_or("不明錯誤")))
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
Kind::Discord => {
|
|||
|
|
let v = get_json("https://discord.com/api/v10/users/@me", Some(&format!("Bot {t}")), 15)?;
|
|||
|
|
Ok(format!("已連上 Discord:{}", v["username"].as_str().unwrap_or("")))
|
|||
|
|
}
|
|||
|
|
Kind::Teams => Err("Teams 請用「登入 Microsoft」".into()),
|
|||
|
|
Kind::Sms => crate::phone::twilio_test(t),
|
|||
|
|
Kind::X => crate::phone::x_me(t).map(|(_, u)| format!("已連上 X:@{u}")),
|
|||
|
|
Kind::IMessage => imessage_rows(0).map(|_| "可以讀「訊息」了。".to_string()),
|
|||
|
|
Kind::Email => {
|
|||
|
|
let (o, ok) = crate::macapps::run("mac_mail_accounts", "{}");
|
|||
|
|
if ok { Ok("可以用「郵件」了。".into()) } else { Err(o) }
|
|||
|
|
}
|
|||
|
|
Kind::Telegram => {
|
|||
|
|
let v = get_json(&format!("{}/bot{t}/getMe", tg()), None, 15)?;
|
|||
|
|
if v["ok"].as_bool() == Some(true) {
|
|||
|
|
Ok(format!("已連上 Telegram:@{}", v["result"]["username"].as_str().unwrap_or("")))
|
|||
|
|
} else {
|
|||
|
|
Err(format!("Telegram 說:{}", v["description"].as_str().unwrap_or("不明錯誤")))
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Post the bot's answer back where the message came from.
|
|||
|
|
pub fn reply(to: &ReplyTo, text: &str) -> Result<(), String> {
|
|||
|
|
let t = to.token.trim();
|
|||
|
|
match to.kind {
|
|||
|
|
// `thread` carries our own number (texts) or the post being answered (X).
|
|||
|
|
Kind::Sms => crate::phone::send_sms(t, &to.thread, &to.target, text)?,
|
|||
|
|
Kind::X => {
|
|||
|
|
let mut reply_to = to.thread.clone();
|
|||
|
|
for part in split(text, crate::phone::X_MAX - 20) {
|
|||
|
|
let url = crate::phone::x_post(t, &part, &reply_to)?;
|
|||
|
|
reply_to = url.rsplit('/').next().unwrap_or("").to_string();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
Kind::IMessage => {
|
|||
|
|
let (o, ok) = crate::macapps::run(crate::macapps::SEND_MESSAGE, &json!({"to": to.target, "text": text}).to_string());
|
|||
|
|
if !ok {
|
|||
|
|
return Err(o);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
Kind::Email => {
|
|||
|
|
// `thread` carries the subject.
|
|||
|
|
let subject = if to.thread.to_lowercase().starts_with("re:") { to.thread.clone() } else { format!("Re: {}", to.thread) };
|
|||
|
|
let (o, ok) = crate::macapps::run("mac_send_mail", &json!({"to": [to.target], "subject": subject, "body": text}).to_string());
|
|||
|
|
if !ok {
|
|||
|
|
return Err(o);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
Kind::Teams => {
|
|||
|
|
// `token` carries the access token for Teams replies.
|
|||
|
|
for part in split(text, 25_000) {
|
|||
|
|
crate::teams::send(t, &to.target, &to.thread, &part)?;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
Kind::Slack => {
|
|||
|
|
for part in split(text, SLACK_MAX) {
|
|||
|
|
let v = post_json("https://slack.com/api/chat.postMessage", Some(&format!("Bearer {t}")), &json!({"channel": to.target, "text": part, "thread_ts": to.thread}))?;
|
|||
|
|
if v["ok"].as_bool() != Some(true) {
|
|||
|
|
return Err(format!("Slack 說:{}", v["error"].as_str().unwrap_or("不明錯誤")));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
Kind::Discord => {
|
|||
|
|
for (i, part) in split(text, DISCORD_MAX).into_iter().enumerate() {
|
|||
|
|
let mut body = json!({"content": part, "allowed_mentions": {"parse": []}});
|
|||
|
|
if i == 0 && !to.thread.is_empty() {
|
|||
|
|
body["message_reference"] = json!({"message_id": to.thread, "fail_if_not_exists": false});
|
|||
|
|
}
|
|||
|
|
post_json(&format!("https://discord.com/api/v10/channels/{}/messages", to.target), Some(&format!("Bot {t}")), &body)?;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
Kind::Telegram => {
|
|||
|
|
for (i, part) in split(text, TELEGRAM_MAX).into_iter().enumerate() {
|
|||
|
|
let mut body = json!({"chat_id": to.target, "text": part});
|
|||
|
|
if i == 0 && !to.thread.is_empty() {
|
|||
|
|
body["reply_to_message_id"] = json!(to.thread.parse::<i64>().unwrap_or(0));
|
|||
|
|
}
|
|||
|
|
let v = post_json(&format!("{}/bot{t}/sendMessage", tg()), None, &body)?;
|
|||
|
|
if v["ok"].as_bool() == Some(false) {
|
|||
|
|
return Err(format!("Telegram 說:{}", v["description"].as_str().unwrap_or("")));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
Ok(())
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---------- reading new messages ----------
|
|||
|
|
|
|||
|
|
/// Slack: messages newer than `cursor` (a ts), oldest first, people only. Returns (new cursor, items).
|
|||
|
|
pub fn slack_new(history: &Value, cursor: &str, own_user: &str) -> (String, Vec<(String, String, String)>) {
|
|||
|
|
let mut items: Vec<(String, String, String)> = vec![];
|
|||
|
|
let mut newest = cursor.to_string();
|
|||
|
|
if let Some(msgs) = history["messages"].as_array() {
|
|||
|
|
for m in msgs.iter().rev() {
|
|||
|
|
let ts = m["ts"].as_str().unwrap_or("").to_string();
|
|||
|
|
if ts.parse::<f64>().unwrap_or(0.0) <= cursor.parse::<f64>().unwrap_or(0.0) {
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
if ts.parse::<f64>().unwrap_or(0.0) > newest.parse::<f64>().unwrap_or(0.0) {
|
|||
|
|
newest = ts.clone();
|
|||
|
|
}
|
|||
|
|
let is_bot = m.get("bot_id").is_some() || m["subtype"].is_string() || m["user"].as_str() == Some(own_user);
|
|||
|
|
if is_bot {
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
let text = m["text"].as_str().unwrap_or("").replace(&format!("<@{own_user}>"), "").trim().to_string();
|
|||
|
|
if !text.is_empty() {
|
|||
|
|
items.push((m["user"].as_str().unwrap_or("someone").to_string(), text, m["thread_ts"].as_str().unwrap_or(&ts).to_string()));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
(newest, items)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Discord: messages after `cursor` (a snowflake), oldest first, people only.
|
|||
|
|
pub fn discord_new(list: &Value, cursor: &str) -> (String, Vec<(String, String, String)>) {
|
|||
|
|
let mut items = vec![];
|
|||
|
|
let mut newest = cursor.to_string();
|
|||
|
|
let n = |s: &str| s.parse::<u128>().unwrap_or(0);
|
|||
|
|
if let Some(msgs) = list.as_array() {
|
|||
|
|
let mut msgs: Vec<&Value> = msgs.iter().collect();
|
|||
|
|
msgs.sort_by_key(|m| n(m["id"].as_str().unwrap_or("0")));
|
|||
|
|
for m in msgs {
|
|||
|
|
let id = m["id"].as_str().unwrap_or("0").to_string();
|
|||
|
|
if n(&id) <= n(cursor) {
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
if n(&id) > n(&newest) {
|
|||
|
|
newest = id.clone();
|
|||
|
|
}
|
|||
|
|
if m["author"]["bot"].as_bool() == Some(true) {
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
let text = m["content"].as_str().unwrap_or("").trim().to_string();
|
|||
|
|
if !text.is_empty() {
|
|||
|
|
let who = m["author"]["global_name"].as_str().or(m["author"]["username"].as_str()).unwrap_or("someone").to_string();
|
|||
|
|
items.push((who, text, id));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
(newest, items)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Telegram: (next offset, [(from, text, chat id, message id)]).
|
|||
|
|
pub fn telegram_new(updates: &Value, offset: i64, only_chat: &str) -> (i64, Vec<(String, String, String, String)>) {
|
|||
|
|
let mut next = offset;
|
|||
|
|
let mut items = vec![];
|
|||
|
|
if let Some(us) = updates["result"].as_array() {
|
|||
|
|
for u in us {
|
|||
|
|
next = next.max(u["update_id"].as_i64().unwrap_or(0) + 1);
|
|||
|
|
let m = &u["message"];
|
|||
|
|
let chat = m["chat"]["id"].as_i64().map(|c| c.to_string()).unwrap_or_default();
|
|||
|
|
if chat.is_empty() || (!only_chat.trim().is_empty() && only_chat.trim() != chat) {
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
if let Some(text) = m["text"].as_str() {
|
|||
|
|
let who = m["from"]["first_name"].as_str().or(m["from"]["username"].as_str()).unwrap_or("someone").to_string();
|
|||
|
|
items.push((who, text.to_string(), chat, m["message_id"].as_i64().unwrap_or(0).to_string()));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
(next, items)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// "王小明 <ming@example.com>" → "ming@example.com"
|
|||
|
|
pub fn address_of(sender: &str) -> String {
|
|||
|
|
match (sender.rfind('<'), sender.rfind('>')) {
|
|||
|
|
(Some(a), Some(b)) if b > a => sender[a + 1..b].trim().to_string(),
|
|||
|
|
_ => sender.trim().to_string(),
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fn chat_db() -> String {
|
|||
|
|
dirs::home_dir().unwrap_or_default().join("Library/Messages/chat.db").display().to_string()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Messages after `after` (ROWID): (id, text, handle, from me).
|
|||
|
|
pub fn imessage_rows(after: i64) -> Result<Vec<(i64, String, String, bool)>, String> {
|
|||
|
|
let q = format!("SELECT m.ROWID AS id, m.text AS text, h.id AS handle, m.is_from_me AS me FROM message m LEFT JOIN handle h ON m.handle_id = h.ROWID WHERE m.ROWID > {after} ORDER BY m.ROWID DESC LIMIT 50");
|
|||
|
|
let out = std::process::Command::new("sqlite3").args(["-readonly", "-json", &chat_db(), &q]).output().map_err(|e| e.to_string())?;
|
|||
|
|
if !out.status.success() {
|
|||
|
|
let e = String::from_utf8_lossy(&out.stderr).to_string();
|
|||
|
|
return Err(if e.contains("authorization denied") || e.contains("unable to open") {
|
|||
|
|
"讀不到「訊息」:請給 AnyBot(或「終端機」)「完全取用磁碟」權限。".into()
|
|||
|
|
} else {
|
|||
|
|
e
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
let v: Value = serde_json::from_slice(&out.stdout).unwrap_or(json!([]));
|
|||
|
|
let mut rows: Vec<(i64, String, String, bool)> = v
|
|||
|
|
.as_array()
|
|||
|
|
.into_iter()
|
|||
|
|
.flatten()
|
|||
|
|
.map(|r| (r["id"].as_i64().unwrap_or(0), r["text"].as_str().unwrap_or("").to_string(), r["handle"].as_str().unwrap_or("").to_string(), r["me"].as_i64() == Some(1)))
|
|||
|
|
.collect();
|
|||
|
|
rows.reverse();
|
|||
|
|
Ok(rows)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---------- the pollers ----------
|
|||
|
|
|
|||
|
|
static GENERATION: AtomicU64 = AtomicU64::new(0);
|
|||
|
|
|
|||
|
|
/// (Re)start one poller per enabled channel; older pollers stop by themselves.
|
|||
|
|
pub fn restart(channels: Vec<Channel>, tx: Sender<Incoming>, repaint: Arc<dyn Fn() + Send + Sync>) {
|
|||
|
|
let gen = GENERATION.fetch_add(1, Ordering::SeqCst) + 1;
|
|||
|
|
// The bot's phone and X tools use the same accounts.
|
|||
|
|
{
|
|||
|
|
let mut a = crate::phone::accounts().lock().unwrap();
|
|||
|
|
a.twilio = channels.iter().find(|c| c.enabled && c.kind == Kind::Sms && !c.token.trim().is_empty()).map(|c| (c.token.trim().to_string(), c.target.trim().to_string()));
|
|||
|
|
a.x = channels.iter().find(|c| c.enabled && c.kind == Kind::X && !c.token.trim().is_empty()).map(|c| c.token.trim().to_string());
|
|||
|
|
}
|
|||
|
|
for c in channels.into_iter().filter(|c| c.enabled && !c.token.trim().is_empty()) {
|
|||
|
|
let tx = tx.clone();
|
|||
|
|
let repaint = repaint.clone();
|
|||
|
|
std::thread::spawn(move || poll(c, gen, tx, repaint));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fn alive(gen: u64) -> bool {
|
|||
|
|
GENERATION.load(Ordering::SeqCst) == gen
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fn poll(c: Channel, gen: u64, tx: Sender<Incoming>, repaint: Arc<dyn Fn() + Send + Sync>) {
|
|||
|
|
let token = c.token.trim().to_string();
|
|||
|
|
let deliver = |from: String, text: String, target: String, thread: String| {
|
|||
|
|
let _ = tx.send(Incoming { bot_id: c.bot_id, from, text, reply: ReplyTo { kind: c.kind, token: token.clone(), target, thread, channel_name: c.name.clone() } });
|
|||
|
|
set_status(c.id, true, "收到新訊息", 1);
|
|||
|
|
repaint();
|
|||
|
|
};
|
|||
|
|
match c.kind {
|
|||
|
|
Kind::Slack => {
|
|||
|
|
let auth = format!("Bearer {token}");
|
|||
|
|
let own = get_json("https://slack.com/api/auth.test", Some(&auth), 15).ok().and_then(|v| v["user_id"].as_str().map(String::from)).unwrap_or_default();
|
|||
|
|
let mut cursor = String::new();
|
|||
|
|
while alive(gen) {
|
|||
|
|
let url = format!("https://slack.com/api/conversations.history?channel={}&limit=20{}", c.target.trim(), if cursor.is_empty() { String::new() } else { format!("&oldest={cursor}") });
|
|||
|
|
match get_json(&url, Some(&auth), 20) {
|
|||
|
|
Ok(v) if v["ok"].as_bool() == Some(true) => {
|
|||
|
|
if cursor.is_empty() {
|
|||
|
|
// Start from now: older messages aren't answered.
|
|||
|
|
cursor = v["messages"][0]["ts"].as_str().unwrap_or("0").to_string();
|
|||
|
|
set_status(c.id, true, "已連上,等待新訊息", 0);
|
|||
|
|
} else {
|
|||
|
|
let (next, items) = slack_new(&v, &cursor, &own);
|
|||
|
|
cursor = next;
|
|||
|
|
for (from, text, thread) in items {
|
|||
|
|
deliver(from, text, c.target.trim().to_string(), thread);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
Ok(v) => set_status(c.id, false, &format!("Slack 說:{}", v["error"].as_str().unwrap_or("不明錯誤")), 0),
|
|||
|
|
Err(e) if e == "rate" => std::thread::sleep(Duration::from_secs(20)),
|
|||
|
|
Err(e) => set_status(c.id, false, &e, 0),
|
|||
|
|
}
|
|||
|
|
std::thread::sleep(Duration::from_secs(POLL_SECS));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
Kind::Discord => {
|
|||
|
|
let auth = format!("Bot {token}");
|
|||
|
|
let mut cursor = String::new();
|
|||
|
|
while alive(gen) {
|
|||
|
|
let url = if cursor.is_empty() {
|
|||
|
|
format!("https://discord.com/api/v10/channels/{}/messages?limit=1", c.target.trim())
|
|||
|
|
} else {
|
|||
|
|
format!("https://discord.com/api/v10/channels/{}/messages?after={cursor}&limit=50", c.target.trim())
|
|||
|
|
};
|
|||
|
|
match get_json(&url, Some(&auth), 20) {
|
|||
|
|
Ok(v) => {
|
|||
|
|
if cursor.is_empty() {
|
|||
|
|
cursor = v[0]["id"].as_str().unwrap_or("0").to_string();
|
|||
|
|
set_status(c.id, true, "已連上,等待新訊息", 0);
|
|||
|
|
} else {
|
|||
|
|
let (next, items) = discord_new(&v, &cursor);
|
|||
|
|
cursor = next;
|
|||
|
|
for (from, text, id) in items {
|
|||
|
|
deliver(from, text, c.target.trim().to_string(), id);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
Err(e) if e == "rate" => std::thread::sleep(Duration::from_secs(10)),
|
|||
|
|
Err(e) => set_status(c.id, false, &e, 0),
|
|||
|
|
}
|
|||
|
|
std::thread::sleep(Duration::from_secs(POLL_SECS));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
Kind::Teams => {
|
|||
|
|
let mut cursor = String::new();
|
|||
|
|
let mut me = String::new();
|
|||
|
|
while alive(gen) {
|
|||
|
|
match crate::teams::access(c.id, &c.client_id, &c.tenant, &token) {
|
|||
|
|
Ok(access) => {
|
|||
|
|
if me.is_empty() {
|
|||
|
|
me = crate::teams::graph_get(&access, "/me").ok().and_then(|v| v["id"].as_str().map(String::from)).unwrap_or_default();
|
|||
|
|
}
|
|||
|
|
match crate::teams::graph_get(&access, &crate::teams::messages_path(&c.target)) {
|
|||
|
|
Ok(v) => {
|
|||
|
|
if cursor.is_empty() {
|
|||
|
|
cursor = crate::teams::new_messages(&v, "", "").0;
|
|||
|
|
if cursor.is_empty() {
|
|||
|
|
cursor = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S").to_string();
|
|||
|
|
}
|
|||
|
|
set_status(c.id, true, "已連上,等待新訊息", 0);
|
|||
|
|
} else {
|
|||
|
|
let (next, items) = crate::teams::new_messages(&v, &cursor, &me);
|
|||
|
|
cursor = next;
|
|||
|
|
for (from, text, id) in items {
|
|||
|
|
let _ = tx.send(Incoming { bot_id: c.bot_id, from, text, reply: ReplyTo { kind: Kind::Teams, token: access.clone(), target: c.target.trim().to_string(), thread: if c.target.starts_with("chat:") { String::new() } else { id }, channel_name: c.name.clone() } });
|
|||
|
|
set_status(c.id, true, "收到新訊息", 1);
|
|||
|
|
repaint();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
Err(e) if e == "rate" => std::thread::sleep(Duration::from_secs(20)),
|
|||
|
|
Err(e) => set_status(c.id, false, &e, 0),
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
Err(e) => set_status(c.id, false, &e, 0),
|
|||
|
|
}
|
|||
|
|
std::thread::sleep(Duration::from_secs(POLL_SECS + 2));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
Kind::IMessage => {
|
|||
|
|
let mut cursor: Option<i64> = None;
|
|||
|
|
while alive(gen) {
|
|||
|
|
match imessage_rows(cursor.unwrap_or(0)) {
|
|||
|
|
Ok(rows) => {
|
|||
|
|
let newest = rows.iter().map(|r| r.0).max();
|
|||
|
|
match cursor {
|
|||
|
|
None => {
|
|||
|
|
cursor = Some(newest.unwrap_or(0));
|
|||
|
|
set_status(c.id, true, "已連上,等待新訊息", 0);
|
|||
|
|
}
|
|||
|
|
Some(_) => {
|
|||
|
|
for (id, text, handle, me) in rows {
|
|||
|
|
cursor = Some(id.max(cursor.unwrap_or(0)));
|
|||
|
|
if me || text.trim().is_empty() || (!c.target.trim().is_empty() && handle != c.target.trim()) {
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
deliver(handle.clone(), text, handle, String::new());
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
Err(e) => set_status(c.id, false, &e, 0),
|
|||
|
|
}
|
|||
|
|
std::thread::sleep(Duration::from_secs(POLL_SECS));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
Kind::Email => {
|
|||
|
|
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
|
|||
|
|
let mut first = true;
|
|||
|
|
while alive(gen) {
|
|||
|
|
let (o, ok) = crate::macapps::run("mac_read_mail", &json!({"mailbox": c.target.trim(), "unreadOnly": true, "days": 3, "limit": 20}).to_string());
|
|||
|
|
if ok {
|
|||
|
|
let list: Value = serde_json::from_str(o.trim_start_matches("郵件:")).unwrap_or(json!([]));
|
|||
|
|
for m in list.as_array().into_iter().flatten() {
|
|||
|
|
let id = m["id"].as_str().unwrap_or("").to_string();
|
|||
|
|
if !seen.insert(id.clone()) || first {
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
// Read the whole message to answer it.
|
|||
|
|
let (full, ok) = crate::macapps::run("mac_read_mail", &json!({"id": id}).to_string());
|
|||
|
|
let body: Value = if ok { serde_json::from_str(&full).unwrap_or(Value::Null) } else { Value::Null };
|
|||
|
|
let from = m["from"].as_str().unwrap_or("").to_string();
|
|||
|
|
let subject = m["subject"].as_str().unwrap_or("").to_string();
|
|||
|
|
let text = format!("主旨:{subject}\n{}", body["body"].as_str().unwrap_or(""));
|
|||
|
|
deliver(from.clone(), text, address_of(&from), subject);
|
|||
|
|
}
|
|||
|
|
if first {
|
|||
|
|
set_status(c.id, true, "已連上,等待新郵件", 0);
|
|||
|
|
}
|
|||
|
|
first = false;
|
|||
|
|
} else {
|
|||
|
|
set_status(c.id, false, &o, 0);
|
|||
|
|
}
|
|||
|
|
std::thread::sleep(Duration::from_secs(30));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
Kind::Sms => {
|
|||
|
|
let number = c.target.trim().to_string();
|
|||
|
|
let mut seen = std::collections::HashSet::new();
|
|||
|
|
let mut first = true;
|
|||
|
|
while alive(gen) {
|
|||
|
|
let path = format!("Messages.json?To={}&PageSize=20", number.replace('+', "%2B"));
|
|||
|
|
match crate::phone::twilio_get(&token, &path) {
|
|||
|
|
Ok(v) => {
|
|||
|
|
let items = crate::phone::sms_new(&v, &mut seen);
|
|||
|
|
if first {
|
|||
|
|
// Older texts aren't answered.
|
|||
|
|
first = false;
|
|||
|
|
set_status(c.id, true, "已連上,等待簡訊", 0);
|
|||
|
|
} else {
|
|||
|
|
for (_, from, body) in items {
|
|||
|
|
deliver(from.clone(), body, from, number.clone());
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
Err(e) => set_status(c.id, false, &e, 0),
|
|||
|
|
}
|
|||
|
|
std::thread::sleep(Duration::from_secs(POLL_SECS));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
Kind::X => {
|
|||
|
|
let me = match crate::phone::x_me(&token) {
|
|||
|
|
Ok(m) => m,
|
|||
|
|
Err(e) => {
|
|||
|
|
set_status(c.id, false, &e, 0);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
let mut since = String::new();
|
|||
|
|
let mut first = true;
|
|||
|
|
while alive(gen) {
|
|||
|
|
match crate::phone::x_mentions(&token, &me.0, &since) {
|
|||
|
|
Ok((newest, items)) => {
|
|||
|
|
since = newest;
|
|||
|
|
if first {
|
|||
|
|
first = false;
|
|||
|
|
set_status(c.id, true, &format!("已連上 @{},等待提及", me.1), 0);
|
|||
|
|
} else {
|
|||
|
|
for (id, who, text) in items {
|
|||
|
|
deliver(format!("@{who}"), text, who, id);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
Err(e) => set_status(c.id, false, &e, 0),
|
|||
|
|
}
|
|||
|
|
// X's free and basic plans allow few reads: once a minute.
|
|||
|
|
for _ in 0..60 {
|
|||
|
|
if !alive(gen) {
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
std::thread::sleep(Duration::from_secs(1));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
Kind::Telegram => {
|
|||
|
|
// Skip what's waiting from before: start after the latest update.
|
|||
|
|
let mut offset = get_json(&format!("{}/bot{token}/getUpdates?offset=-1", tg()), None, 15)
|
|||
|
|
.ok()
|
|||
|
|
.and_then(|v| v["result"][0]["update_id"].as_i64())
|
|||
|
|
.map(|u| u + 1)
|
|||
|
|
.unwrap_or(0);
|
|||
|
|
set_status(c.id, true, "已連上,等待新訊息", 0);
|
|||
|
|
while alive(gen) {
|
|||
|
|
let url = format!("{}/bot{token}/getUpdates?offset={offset}&timeout={TELEGRAM_WAIT_SECS}", tg());
|
|||
|
|
match get_json(&url, None, TELEGRAM_WAIT_SECS + 10) {
|
|||
|
|
Ok(v) => {
|
|||
|
|
let (next, items) = telegram_new(&v, offset, &c.target);
|
|||
|
|
offset = next;
|
|||
|
|
if !alive(gen) {
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
for (from, text, chat, mid) in items {
|
|||
|
|
deliver(from, text, chat, mid);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
Err(e) => {
|
|||
|
|
set_status(c.id, false, &e, 0);
|
|||
|
|
std::thread::sleep(Duration::from_secs(5));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[cfg(test)]
|
|||
|
|
mod tests {
|
|||
|
|
use super::*;
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn slack_skips_bots_and_old_messages() {
|
|||
|
|
let v = json!({"ok": true, "messages": [
|
|||
|
|
{"ts": "1700000003.000", "user": "UBOT", "text": "my own"},
|
|||
|
|
{"ts": "1700000002.500", "bot_id": "B1", "text": "other bot"},
|
|||
|
|
{"ts": "1700000002.000", "user": "U1", "text": "<@UBOT> 幫我查天氣", "thread_ts": "1700000001.000"},
|
|||
|
|
{"ts": "1700000001.500", "user": "U2", "text": "嗨"},
|
|||
|
|
{"ts": "1700000000.000", "user": "U3", "text": "old"}
|
|||
|
|
]});
|
|||
|
|
let (cur, items) = slack_new(&v, "1700000001.000", "UBOT");
|
|||
|
|
assert_eq!(cur, "1700000003.000");
|
|||
|
|
assert_eq!(items, vec![("U2".into(), "嗨".into(), "1700000001.500".into()), ("U1".into(), "幫我查天氣".into(), "1700000001.000".into())]);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn discord_orders_and_skips_bots() {
|
|||
|
|
let v = json!([
|
|||
|
|
{"id": "30", "content": "second", "author": {"username": "amy"}},
|
|||
|
|
{"id": "25", "content": "bot", "author": {"username": "b", "bot": true}},
|
|||
|
|
{"id": "20", "content": "first", "author": {"username": "bob", "global_name": "Bob"}}
|
|||
|
|
]);
|
|||
|
|
let (cur, items) = discord_new(&v, "10");
|
|||
|
|
assert_eq!(cur, "30");
|
|||
|
|
assert_eq!(items.iter().map(|i| i.1.as_str()).collect::<Vec<_>>(), vec!["first", "second"]);
|
|||
|
|
assert_eq!(items[0].0, "Bob");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn telegram_filters_chat() {
|
|||
|
|
let v = json!({"ok": true, "result": [
|
|||
|
|
{"update_id": 7, "message": {"message_id": 1, "chat": {"id": 42}, "from": {"first_name": "小明"}, "text": "hi"}},
|
|||
|
|
{"update_id": 8, "message": {"message_id": 2, "chat": {"id": 99}, "from": {"first_name": "x"}, "text": "no"}}
|
|||
|
|
]});
|
|||
|
|
let (next, items) = telegram_new(&v, 0, "42");
|
|||
|
|
assert_eq!(next, 9);
|
|||
|
|
assert_eq!(items, vec![("小明".into(), "hi".into(), "42".into(), "1".into())]);
|
|||
|
|
assert_eq!(telegram_new(&v, 0, "").1.len(), 2);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// A whole round trip against a stand-in Telegram server: a message arrives, the answer goes back.
|
|||
|
|
#[test]
|
|||
|
|
fn telegram_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_TELEGRAM_API", format!("http://127.0.0.1:{port}"));
|
|||
|
|
let sent = Arc::new(Mutex::new(Vec::<String>::new()));
|
|||
|
|
let s2 = sent.clone();
|
|||
|
|
std::thread::spawn(move || {
|
|||
|
|
let mut served = 0;
|
|||
|
|
for conn in server.incoming().flatten() {
|
|||
|
|
let mut c = conn;
|
|||
|
|
// Read the whole request: head, then as much body as Content-Length says.
|
|||
|
|
c.set_read_timeout(Some(Duration::from_secs(3))).ok();
|
|||
|
|
let mut got = vec![];
|
|||
|
|
let mut buf = vec![0u8; 16384];
|
|||
|
|
loop {
|
|||
|
|
let n = c.read(&mut buf).unwrap_or(0);
|
|||
|
|
got.extend_from_slice(&buf[..n]);
|
|||
|
|
let text = String::from_utf8_lossy(&got).to_string();
|
|||
|
|
if let Some(h) = text.find("\r\n\r\n") {
|
|||
|
|
let len = text[..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("offset=-1") {
|
|||
|
|
r#"{"ok":true,"result":[{"update_id":100,"message":{"message_id":1,"chat":{"id":42},"text":"old"}}]}"#.to_string()
|
|||
|
|
} else if req.contains("getUpdates") {
|
|||
|
|
served += 1;
|
|||
|
|
if served == 1 {
|
|||
|
|
r#"{"ok":true,"result":[{"update_id":101,"message":{"message_id":5,"chat":{"id":42},"from":{"first_name":"小明"},"text":"明天天氣?"}}]}"#.to_string()
|
|||
|
|
} else {
|
|||
|
|
std::thread::sleep(Duration::from_millis(300));
|
|||
|
|
r#"{"ok":true,"result":[]}"#.to_string()
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
s2.lock().unwrap().push(req.clone());
|
|||
|
|
r#"{"ok":true,"result":{}}"#.to_string()
|
|||
|
|
};
|
|||
|
|
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 (tx, rx) = std::sync::mpsc::channel();
|
|||
|
|
let ch = Channel { id: 1, kind: Kind::Telegram, name: "測試".into(), bot_id: 7, token: "T0K".into(), target: String::new(), enabled: true, client_id: String::new(), tenant: String::new() };
|
|||
|
|
restart(vec![ch], tx, Arc::new(|| {}));
|
|||
|
|
let got = rx.recv_timeout(Duration::from_secs(10)).expect("message arrives");
|
|||
|
|
GENERATION.fetch_add(1, Ordering::SeqCst);
|
|||
|
|
assert_eq!((got.bot_id, got.from.as_str(), got.text.as_str(), got.reply.target.as_str(), got.reply.thread.as_str()), (7, "小明", "明天天氣?", "42", "5"));
|
|||
|
|
reply(&got.reply, "明天晴天").unwrap();
|
|||
|
|
let reqs = sent.lock().unwrap().clone();
|
|||
|
|
assert!(reqs.iter().any(|r| r.contains("/botT0K/sendMessage") && r.contains("明天晴天") && r.contains("\"reply_to_message_id\":5")), "{reqs:?}");
|
|||
|
|
std::env::remove_var("ANYBOT_TELEGRAM_API");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn email_addresses_are_found() {
|
|||
|
|
assert_eq!(address_of("王小明 <ming@example.com>"), "ming@example.com");
|
|||
|
|
assert_eq!(address_of("ming@example.com"), "ming@example.com");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn long_answers_are_split() {
|
|||
|
|
let text = "a\n".repeat(3000);
|
|||
|
|
let parts = split(&text, DISCORD_MAX);
|
|||
|
|
assert!(parts.len() >= 3 && parts.iter().all(|p| p.chars().count() <= DISCORD_MAX));
|
|||
|
|
assert_eq!(parts.concat(), text);
|
|||
|
|
}
|
|||
|
|
}
|