zulip.rs 19 KB

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