zulip.rs 14 KB

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