zulip.rs 16 KB

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