zulip.rs 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497
  1. use crate::db::notifications::add_metadata;
  2. use crate::db::notifications::{self, delete_ping, move_indices, record_ping, Identifier};
  3. use crate::github::{self, GithubClient};
  4. use crate::handlers::Context;
  5. use anyhow::Context as _;
  6. use std::convert::TryInto;
  7. use std::env;
  8. #[derive(Debug, serde::Deserialize)]
  9. pub struct Request {
  10. /// Markdown body of the sent message.
  11. data: String,
  12. /// Metadata about this request.
  13. message: Message,
  14. /// Authentication token. The same for all Zulip messages.
  15. token: String,
  16. }
  17. #[derive(Debug, serde::Deserialize)]
  18. struct Message {
  19. sender_id: usize,
  20. sender_short_name: String,
  21. sender_full_name: String,
  22. }
  23. #[derive(serde::Serialize, serde::Deserialize)]
  24. struct Response<'a> {
  25. content: &'a str,
  26. }
  27. #[derive(serde::Serialize, serde::Deserialize)]
  28. struct ResponseOwned {
  29. content: String,
  30. }
  31. pub const BOT_EMAIL: &str = "triage-rust-lang-bot@zulipchat.com";
  32. pub async fn to_github_id(client: &GithubClient, zulip_id: usize) -> anyhow::Result<Option<i64>> {
  33. let map = crate::team_data::zulip_map(client).await?;
  34. Ok(map.users.get(&zulip_id).map(|v| *v as i64))
  35. }
  36. pub async fn to_zulip_id(client: &GithubClient, github_id: i64) -> anyhow::Result<Option<usize>> {
  37. let map = crate::team_data::zulip_map(client).await?;
  38. Ok(map
  39. .users
  40. .iter()
  41. .find(|(_, github)| **github == github_id as usize)
  42. .map(|v| *v.0))
  43. }
  44. pub async fn respond(ctx: &Context, req: Request) -> String {
  45. let expected_token = std::env::var("ZULIP_TOKEN").expect("`ZULIP_TOKEN` set for authorization");
  46. if !openssl::memcmp::eq(req.token.as_bytes(), expected_token.as_bytes()) {
  47. return serde_json::to_string(&Response {
  48. content: "Invalid authorization.",
  49. })
  50. .unwrap();
  51. }
  52. log::trace!("zulip hook: {:?}", req);
  53. let gh_id = match to_github_id(&ctx.github, req.message.sender_id).await {
  54. Ok(Some(gh_id)) => Ok(gh_id),
  55. Ok(None) => Err(serde_json::to_string(&Response {
  56. content: &format!(
  57. "Unknown Zulip user. Please add `zulip-id = {}` to your file in rust-lang/team.",
  58. req.message.sender_id
  59. ),
  60. })
  61. .unwrap()),
  62. Err(e) => {
  63. return serde_json::to_string(&Response {
  64. content: &format!("Failed to query team API: {:?}", e),
  65. })
  66. .unwrap();
  67. }
  68. };
  69. handle_command(ctx, gh_id, &req.data, &req.message).await
  70. }
  71. fn handle_command<'a>(
  72. ctx: &'a Context,
  73. gh_id: Result<i64, String>,
  74. words: &'a str,
  75. message_data: &'a Message,
  76. ) -> std::pin::Pin<Box<dyn std::future::Future<Output = String> + Send + 'a>> {
  77. Box::pin(async move {
  78. let mut words = words.split_whitespace();
  79. let next = words.next();
  80. if let Some("as") = next {
  81. return match execute_for_other_user(&ctx, words, message_data).await {
  82. Ok(r) => r,
  83. Err(e) => serde_json::to_string(&Response {
  84. content: &format!(
  85. "Failed to parse; expected `as <username> <command...>`: {:?}.",
  86. e
  87. ),
  88. })
  89. .unwrap(),
  90. };
  91. }
  92. let gh_id = match gh_id {
  93. Ok(id) => id,
  94. Err(e) => return e,
  95. };
  96. match next {
  97. Some("acknowledge") | Some("ack") => match acknowledge(gh_id, words).await {
  98. Ok(r) => r,
  99. Err(e) => serde_json::to_string(&Response {
  100. content: &format!(
  101. "Failed to parse acknowledgement, expected `(acknowledge|ack) <identifier>`: {:?}.",
  102. e
  103. ),
  104. })
  105. .unwrap(),
  106. },
  107. Some("add") => match add_notification(&ctx, gh_id, words).await {
  108. Ok(r) => r,
  109. Err(e) => serde_json::to_string(&Response {
  110. content: &format!(
  111. "Failed to parse description addition, expected `add <url> <description (multiple words)>`: {:?}.",
  112. e
  113. ),
  114. })
  115. .unwrap(),
  116. },
  117. Some("move") => match move_notification(gh_id, words).await {
  118. Ok(r) => r,
  119. Err(e) => serde_json::to_string(&Response {
  120. content: &format!(
  121. "Failed to parse movement, expected `move <from> <to>`: {:?}.",
  122. e
  123. ),
  124. })
  125. .unwrap(),
  126. },
  127. Some("meta") => match add_meta_notification(gh_id, words).await {
  128. Ok(r) => r,
  129. Err(e) => serde_json::to_string(&Response {
  130. content: &format!(
  131. "Failed to parse movement, expected `move <idx> <meta...>`: {:?}.",
  132. e
  133. ),
  134. })
  135. .unwrap(),
  136. },
  137. _ => serde_json::to_string(&Response {
  138. content: "Unknown command.",
  139. })
  140. .unwrap(),
  141. }
  142. })
  143. }
  144. // This does two things:
  145. // * execute the command for the other user
  146. // * tell the user executed for that a command was run as them by the user
  147. // given.
  148. async fn execute_for_other_user(
  149. ctx: &Context,
  150. mut words: impl Iterator<Item = &str>,
  151. message_data: &Message,
  152. ) -> anyhow::Result<String> {
  153. // username is a GitHub username, not a Zulip username
  154. let username = match words.next() {
  155. Some(username) => username,
  156. None => anyhow::bail!("no username provided"),
  157. };
  158. let user_id = match (github::User {
  159. login: username.to_owned(),
  160. id: None,
  161. })
  162. .get_id(&ctx.github)
  163. .await
  164. .context("getting ID of github user")?
  165. {
  166. Some(id) => id.try_into().unwrap(),
  167. None => {
  168. return Ok(serde_json::to_string(&Response {
  169. content: "Can only authorize for other GitHub users.",
  170. })
  171. .unwrap());
  172. }
  173. };
  174. let mut command = words.fold(String::new(), |mut acc, piece| {
  175. acc.push_str(piece);
  176. acc.push(' ');
  177. acc
  178. });
  179. let command = if command.is_empty() {
  180. anyhow::bail!("no command provided")
  181. } else {
  182. assert_eq!(command.pop(), Some(' ')); // pop trailing space
  183. command
  184. };
  185. let bot_api_token = env::var("ZULIP_API_TOKEN").expect("ZULIP_API_TOKEN");
  186. let members = ctx
  187. .github
  188. .raw()
  189. .get("https://rust-lang.zulipchat.com/api/v1/users")
  190. .basic_auth(BOT_EMAIL, Some(&bot_api_token))
  191. .send()
  192. .await;
  193. let members = match members {
  194. Ok(members) => members,
  195. Err(e) => {
  196. return Ok(serde_json::to_string(&Response {
  197. content: &format!("Failed to get list of zulip users: {:?}.", e),
  198. })
  199. .unwrap());
  200. }
  201. };
  202. let members = members.json::<MembersApiResponse>().await;
  203. let members = match members {
  204. Ok(members) => members.members,
  205. Err(e) => {
  206. return Ok(serde_json::to_string(&Response {
  207. content: &format!("Failed to get list of zulip users: {:?}.", e),
  208. })
  209. .unwrap());
  210. }
  211. };
  212. // Map GitHub `user_id` to `zulip_user_id`.
  213. let zulip_user_id = match to_zulip_id(&ctx.github, user_id).await {
  214. Ok(Some(id)) => id as i64,
  215. Ok(None) => {
  216. return Ok(serde_json::to_string(&Response {
  217. content: &format!("Could not find Zulip ID for GitHub ID: {}", user_id),
  218. })
  219. .unwrap());
  220. }
  221. Err(e) => {
  222. return Ok(serde_json::to_string(&Response {
  223. content: &format!("Could not find Zulip ID for GitHub id {}: {:?}", user_id, e),
  224. })
  225. .unwrap());
  226. }
  227. };
  228. let user_email = match members.iter().find(|m| m.user_id == zulip_user_id) {
  229. Some(m) => &m.email,
  230. None => {
  231. return Ok(serde_json::to_string(&Response {
  232. content: &format!("Could not find Zulip user email."),
  233. })
  234. .unwrap());
  235. }
  236. };
  237. let output = handle_command(ctx, Ok(user_id as i64), &command, message_data).await;
  238. let output_msg: ResponseOwned =
  239. serde_json::from_str(&output).expect("result should always be JSON");
  240. let output_msg = output_msg.content;
  241. // At this point, the command has been run (FIXME: though it may have
  242. // errored, it's hard to determine that currently, so we'll just give the
  243. // output fromt he command as well as the command itself).
  244. let message = format!(
  245. "{} ({}) ran `{}` with output `{}` as you.",
  246. message_data.sender_full_name, message_data.sender_short_name, command, output_msg
  247. );
  248. let res = MessageApiRequest {
  249. type_: "private",
  250. to: &user_email,
  251. topic: None,
  252. content: &message,
  253. }
  254. .send(ctx.github.raw())
  255. .await;
  256. match res {
  257. Ok(resp) => {
  258. if !resp.status().is_success() {
  259. log::error!(
  260. "Failed to notify real user about command: response: {:?}",
  261. resp
  262. );
  263. }
  264. }
  265. Err(err) => {
  266. log::error!("Failed to notify real user about command: {:?}", err);
  267. }
  268. }
  269. Ok(output)
  270. }
  271. #[derive(serde::Deserialize)]
  272. struct MembersApiResponse {
  273. members: Vec<Member>,
  274. }
  275. #[derive(serde::Deserialize)]
  276. struct Member {
  277. email: String,
  278. user_id: i64,
  279. }
  280. #[derive(serde::Serialize)]
  281. pub struct MessageApiRequest<'a> {
  282. #[serde(rename = "type")]
  283. pub type_: &'a str,
  284. pub to: &'a str,
  285. #[serde(skip_serializing_if = "Option::is_none")]
  286. pub topic: Option<&'a str>,
  287. pub content: &'a str,
  288. }
  289. impl MessageApiRequest<'_> {
  290. pub async fn send(&self, client: &reqwest::Client) -> anyhow::Result<reqwest::Response> {
  291. if self.type_ == "stream" {
  292. assert!(self.topic.is_some());
  293. }
  294. let bot_api_token = env::var("ZULIP_API_TOKEN").expect("ZULIP_API_TOKEN");
  295. Ok(client
  296. .post("https://rust-lang.zulipchat.com/api/v1/messages")
  297. .basic_auth(BOT_EMAIL, Some(&bot_api_token))
  298. .form(&self)
  299. .send()
  300. .await?)
  301. }
  302. }
  303. async fn acknowledge(gh_id: i64, mut words: impl Iterator<Item = &str>) -> anyhow::Result<String> {
  304. let url = match words.next() {
  305. Some(url) => {
  306. if words.next().is_some() {
  307. anyhow::bail!("too many words");
  308. }
  309. url
  310. }
  311. None => anyhow::bail!("not enough words"),
  312. };
  313. let ident = if let Ok(number) = url.parse::<usize>() {
  314. Identifier::Index(
  315. std::num::NonZeroUsize::new(number)
  316. .ok_or_else(|| anyhow::anyhow!("index must be at least 1"))?,
  317. )
  318. } else {
  319. Identifier::Url(url)
  320. };
  321. match delete_ping(&mut crate::db::make_client().await?, gh_id, ident).await {
  322. Ok(deleted) => {
  323. let mut resp = format!("Acknowledged:\n");
  324. for deleted in deleted {
  325. resp.push_str(&format!(
  326. " * [{}]({}){}\n",
  327. deleted
  328. .short_description
  329. .as_deref()
  330. .unwrap_or(&deleted.origin_url),
  331. deleted.origin_url,
  332. deleted
  333. .metadata
  334. .map_or(String::new(), |m| format!(" ({})", m)),
  335. ));
  336. }
  337. Ok(serde_json::to_string(&Response { content: &resp }).unwrap())
  338. }
  339. Err(e) => Ok(serde_json::to_string(&Response {
  340. content: &format!("Failed to acknowledge {}: {:?}.", url, e),
  341. })
  342. .unwrap()),
  343. }
  344. }
  345. async fn add_notification(
  346. ctx: &Context,
  347. gh_id: i64,
  348. mut words: impl Iterator<Item = &str>,
  349. ) -> anyhow::Result<String> {
  350. let url = match words.next() {
  351. Some(idx) => idx,
  352. None => anyhow::bail!("url not present"),
  353. };
  354. let mut description = words.fold(String::new(), |mut acc, piece| {
  355. acc.push_str(piece);
  356. acc.push(' ');
  357. acc
  358. });
  359. let description = if description.is_empty() {
  360. None
  361. } else {
  362. assert_eq!(description.pop(), Some(' ')); // pop trailing space
  363. Some(description)
  364. };
  365. match record_ping(
  366. &ctx.db,
  367. &notifications::Notification {
  368. user_id: gh_id,
  369. origin_url: url.to_owned(),
  370. origin_html: String::new(),
  371. short_description: description,
  372. time: chrono::Utc::now().into(),
  373. team_name: None,
  374. },
  375. )
  376. .await
  377. {
  378. Ok(()) => Ok(serde_json::to_string(&Response {
  379. content: "Created!",
  380. })
  381. .unwrap()),
  382. Err(e) => Ok(serde_json::to_string(&Response {
  383. content: &format!("Failed to create: {:?}", e),
  384. })
  385. .unwrap()),
  386. }
  387. }
  388. async fn add_meta_notification(
  389. gh_id: i64,
  390. mut words: impl Iterator<Item = &str>,
  391. ) -> anyhow::Result<String> {
  392. let idx = match words.next() {
  393. Some(idx) => idx,
  394. None => anyhow::bail!("idx not present"),
  395. };
  396. let idx = idx
  397. .parse::<usize>()
  398. .context("index")?
  399. .checked_sub(1)
  400. .ok_or_else(|| anyhow::anyhow!("1-based indexes"))?;
  401. let mut description = words.fold(String::new(), |mut acc, piece| {
  402. acc.push_str(piece);
  403. acc.push(' ');
  404. acc
  405. });
  406. let description = if description.is_empty() {
  407. None
  408. } else {
  409. assert_eq!(description.pop(), Some(' ')); // pop trailing space
  410. Some(description)
  411. };
  412. match add_metadata(
  413. &mut crate::db::make_client().await?,
  414. gh_id,
  415. idx,
  416. description.as_deref(),
  417. )
  418. .await
  419. {
  420. Ok(()) => Ok(serde_json::to_string(&Response {
  421. content: "Added metadata!",
  422. })
  423. .unwrap()),
  424. Err(e) => Ok(serde_json::to_string(&Response {
  425. content: &format!("Failed to add: {:?}", e),
  426. })
  427. .unwrap()),
  428. }
  429. }
  430. async fn move_notification(
  431. gh_id: i64,
  432. mut words: impl Iterator<Item = &str>,
  433. ) -> anyhow::Result<String> {
  434. let from = match words.next() {
  435. Some(idx) => idx,
  436. None => anyhow::bail!("from idx not present"),
  437. };
  438. let to = match words.next() {
  439. Some(idx) => idx,
  440. None => anyhow::bail!("from idx not present"),
  441. };
  442. let from = from
  443. .parse::<usize>()
  444. .context("from index")?
  445. .checked_sub(1)
  446. .ok_or_else(|| anyhow::anyhow!("1-based indexes"))?;
  447. let to = to
  448. .parse::<usize>()
  449. .context("to index")?
  450. .checked_sub(1)
  451. .ok_or_else(|| anyhow::anyhow!("1-based indexes"))?;
  452. match move_indices(&mut crate::db::make_client().await?, gh_id, from, to).await {
  453. Ok(()) => Ok(serde_json::to_string(&Response {
  454. // to 1-base indices
  455. content: &format!("Moved {} to {}.", from + 1, to + 1),
  456. })
  457. .unwrap()),
  458. Err(e) => Ok(serde_json::to_string(&Response {
  459. content: &format!("Failed to move: {:?}.", e),
  460. })
  461. .unwrap()),
  462. }
  463. }