zulip.rs 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805
  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. use tracing as log;
  10. #[derive(Debug, serde::Deserialize)]
  11. pub struct Request {
  12. /// Markdown body of the sent message.
  13. data: String,
  14. /// Metadata about this request.
  15. message: Message,
  16. /// Authentication token. The same for all Zulip messages.
  17. token: String,
  18. }
  19. #[derive(Debug, serde::Deserialize)]
  20. struct Message {
  21. sender_id: u64,
  22. #[allow(unused)]
  23. recipient_id: u64,
  24. sender_short_name: Option<String>,
  25. sender_full_name: String,
  26. stream_id: Option<u64>,
  27. // The topic of the incoming message. Not the stream name.
  28. subject: Option<String>,
  29. #[allow(unused)]
  30. #[serde(rename = "type")]
  31. type_: String,
  32. }
  33. #[derive(serde::Serialize, serde::Deserialize)]
  34. struct Response<'a> {
  35. content: &'a str,
  36. }
  37. #[derive(serde::Serialize, serde::Deserialize)]
  38. struct ResponseOwned {
  39. content: String,
  40. }
  41. pub const BOT_EMAIL: &str = "triage-rust-lang-bot@zulipchat.com";
  42. pub async fn to_github_id(client: &GithubClient, zulip_id: usize) -> anyhow::Result<Option<i64>> {
  43. let map = crate::team_data::zulip_map(client).await?;
  44. Ok(map.users.get(&zulip_id).map(|v| *v as i64))
  45. }
  46. pub async fn to_zulip_id(client: &GithubClient, github_id: i64) -> anyhow::Result<Option<usize>> {
  47. let map = crate::team_data::zulip_map(client).await?;
  48. Ok(map
  49. .users
  50. .iter()
  51. .find(|(_, github)| **github == github_id as usize)
  52. .map(|v| *v.0))
  53. }
  54. pub async fn respond(ctx: &Context, req: Request) -> String {
  55. let expected_token = std::env::var("ZULIP_TOKEN").expect("`ZULIP_TOKEN` set for authorization");
  56. if !openssl::memcmp::eq(req.token.as_bytes(), expected_token.as_bytes()) {
  57. return serde_json::to_string(&Response {
  58. content: "Invalid authorization.",
  59. })
  60. .unwrap();
  61. }
  62. log::trace!("zulip hook: {:?}", req);
  63. let gh_id = match to_github_id(&ctx.github, req.message.sender_id as usize).await {
  64. Ok(Some(gh_id)) => Ok(gh_id),
  65. Ok(None) => Err(serde_json::to_string(&Response {
  66. content: &format!(
  67. "Unknown Zulip user. Please add `zulip-id = {}` to your file in \
  68. [rust-lang/team](https://github.com/rust-lang/team).",
  69. req.message.sender_id
  70. ),
  71. })
  72. .unwrap()),
  73. Err(e) => {
  74. return serde_json::to_string(&Response {
  75. content: &format!("Failed to query team API: {:?}", e),
  76. })
  77. .unwrap();
  78. }
  79. };
  80. handle_command(ctx, gh_id, &req.data, &req.message).await
  81. }
  82. fn handle_command<'a>(
  83. ctx: &'a Context,
  84. gh_id: Result<i64, String>,
  85. words: &'a str,
  86. message_data: &'a Message,
  87. ) -> std::pin::Pin<Box<dyn std::future::Future<Output = String> + Send + 'a>> {
  88. Box::pin(async move {
  89. log::trace!("handling zulip command {:?}", words);
  90. let mut words = words.split_whitespace();
  91. let mut next = words.next();
  92. if let Some("as") = next {
  93. return match execute_for_other_user(&ctx, words, message_data).await {
  94. Ok(r) => r,
  95. Err(e) => serde_json::to_string(&Response {
  96. content: &format!(
  97. "Failed to parse; expected `as <username> <command...>`: {:?}.",
  98. e
  99. ),
  100. })
  101. .unwrap(),
  102. };
  103. }
  104. let gh_id = match gh_id {
  105. Ok(id) => id,
  106. Err(e) => return e,
  107. };
  108. match next {
  109. Some("acknowledge") | Some("ack") => match acknowledge(&ctx, gh_id, words).await {
  110. Ok(r) => r,
  111. Err(e) => serde_json::to_string(&Response {
  112. content: &format!(
  113. "Failed to parse acknowledgement, expected `(acknowledge|ack) <identifier>`: {:?}.",
  114. e
  115. ),
  116. })
  117. .unwrap(),
  118. },
  119. Some("add") => match add_notification(&ctx, gh_id, words).await {
  120. Ok(r) => r,
  121. Err(e) => serde_json::to_string(&Response {
  122. content: &format!(
  123. "Failed to parse description addition, expected `add <url> <description (multiple words)>`: {:?}.",
  124. e
  125. ),
  126. })
  127. .unwrap(),
  128. },
  129. Some("move") => match move_notification(&ctx, gh_id, words).await {
  130. Ok(r) => r,
  131. Err(e) => serde_json::to_string(&Response {
  132. content: &format!(
  133. "Failed to parse movement, expected `move <from> <to>`: {:?}.",
  134. e
  135. ),
  136. })
  137. .unwrap(),
  138. },
  139. Some("meta") => match add_meta_notification(&ctx, gh_id, words).await {
  140. Ok(r) => r,
  141. Err(e) => serde_json::to_string(&Response {
  142. content: &format!(
  143. "Failed to parse movement, expected `move <idx> <meta...>`: {:?}.",
  144. e
  145. ),
  146. })
  147. .unwrap(),
  148. },
  149. _ => {
  150. while let Some(word) = next {
  151. if word == "@**triagebot**" {
  152. let next = words.next();
  153. match next {
  154. Some("end-topic") | Some("await") => return match post_waiter(&ctx, message_data, WaitingMessage::end_topic()).await {
  155. Ok(r) => r,
  156. Err(e) => serde_json::to_string(&Response {
  157. content: &format!("Failed to await at this time: {:?}", e),
  158. })
  159. .unwrap(),
  160. },
  161. Some("end-meeting") => return match post_waiter(&ctx, message_data, WaitingMessage::end_meeting()).await {
  162. Ok(r) => r,
  163. Err(e) => serde_json::to_string(&Response {
  164. content: &format!("Failed to await at this time: {:?}", e),
  165. })
  166. .unwrap(),
  167. },
  168. Some("read") => return match post_waiter(&ctx, message_data, WaitingMessage::start_reading()).await {
  169. Ok(r) => r,
  170. Err(e) => serde_json::to_string(&Response {
  171. content: &format!("Failed to await at this time: {:?}", e),
  172. })
  173. .unwrap(),
  174. },
  175. _ => {}
  176. }
  177. }
  178. next = words.next();
  179. }
  180. serde_json::to_string(&Response {
  181. content: "Unknown command.",
  182. })
  183. .unwrap()
  184. },
  185. }
  186. })
  187. }
  188. // This does two things:
  189. // * execute the command for the other user
  190. // * tell the user executed for that a command was run as them by the user
  191. // given.
  192. async fn execute_for_other_user(
  193. ctx: &Context,
  194. mut words: impl Iterator<Item = &str>,
  195. message_data: &Message,
  196. ) -> anyhow::Result<String> {
  197. // username is a GitHub username, not a Zulip username
  198. let username = match words.next() {
  199. Some(username) => username,
  200. None => anyhow::bail!("no username provided"),
  201. };
  202. let user_id = match (github::User {
  203. login: username.to_owned(),
  204. id: None,
  205. })
  206. .get_id(&ctx.github)
  207. .await
  208. .context("getting ID of github user")?
  209. {
  210. Some(id) => id.try_into().unwrap(),
  211. None => {
  212. return Ok(serde_json::to_string(&Response {
  213. content: "Can only authorize for other GitHub users.",
  214. })
  215. .unwrap());
  216. }
  217. };
  218. let mut command = words.fold(String::new(), |mut acc, piece| {
  219. acc.push_str(piece);
  220. acc.push(' ');
  221. acc
  222. });
  223. let command = if command.is_empty() {
  224. anyhow::bail!("no command provided")
  225. } else {
  226. assert_eq!(command.pop(), Some(' ')); // pop trailing space
  227. command
  228. };
  229. let bot_api_token = env::var("ZULIP_API_TOKEN").expect("ZULIP_API_TOKEN");
  230. let members = ctx
  231. .github
  232. .raw()
  233. .get("https://rust-lang.zulipchat.com/api/v1/users")
  234. .basic_auth(BOT_EMAIL, Some(&bot_api_token))
  235. .send()
  236. .await;
  237. let members = match members {
  238. Ok(members) => members,
  239. Err(e) => {
  240. return Ok(serde_json::to_string(&Response {
  241. content: &format!("Failed to get list of zulip users: {:?}.", e),
  242. })
  243. .unwrap());
  244. }
  245. };
  246. let members = members.json::<MembersApiResponse>().await;
  247. let members = match members {
  248. Ok(members) => members.members,
  249. Err(e) => {
  250. return Ok(serde_json::to_string(&Response {
  251. content: &format!("Failed to get list of zulip users: {:?}.", e),
  252. })
  253. .unwrap());
  254. }
  255. };
  256. // Map GitHub `user_id` to `zulip_user_id`.
  257. let zulip_user_id = match to_zulip_id(&ctx.github, user_id).await {
  258. Ok(Some(id)) => id as u64,
  259. Ok(None) => {
  260. return Ok(serde_json::to_string(&Response {
  261. content: &format!("Could not find Zulip ID for GitHub ID: {}", user_id),
  262. })
  263. .unwrap());
  264. }
  265. Err(e) => {
  266. return Ok(serde_json::to_string(&Response {
  267. content: &format!("Could not find Zulip ID for GitHub id {}: {:?}", user_id, e),
  268. })
  269. .unwrap());
  270. }
  271. };
  272. let user = match members.iter().find(|m| m.user_id == zulip_user_id) {
  273. Some(m) => m,
  274. None => {
  275. return Ok(serde_json::to_string(&Response {
  276. content: &format!("Could not find Zulip user email."),
  277. })
  278. .unwrap());
  279. }
  280. };
  281. let output = handle_command(ctx, Ok(user_id as i64), &command, message_data).await;
  282. let output_msg: ResponseOwned =
  283. serde_json::from_str(&output).expect("result should always be JSON");
  284. let output_msg = output_msg.content;
  285. // At this point, the command has been run (FIXME: though it may have
  286. // errored, it's hard to determine that currently, so we'll just give the
  287. // output from the command as well as the command itself).
  288. let sender = match &message_data.sender_short_name {
  289. Some(short_name) => format!("{} ({})", message_data.sender_full_name, short_name),
  290. None => message_data.sender_full_name.clone(),
  291. };
  292. let message = format!(
  293. "{} ran `{}` with output `{}` as you.",
  294. sender, command, output_msg
  295. );
  296. let res = MessageApiRequest {
  297. recipient: Recipient::Private {
  298. id: user.user_id,
  299. email: &user.email,
  300. },
  301. content: &message,
  302. }
  303. .send(ctx.github.raw())
  304. .await;
  305. match res {
  306. Ok(resp) => {
  307. if !resp.status().is_success() {
  308. log::error!(
  309. "Failed to notify real user about command: response: {:?}",
  310. resp
  311. );
  312. }
  313. }
  314. Err(err) => {
  315. log::error!("Failed to notify real user about command: {:?}", err);
  316. }
  317. }
  318. Ok(output)
  319. }
  320. #[derive(serde::Deserialize)]
  321. struct MembersApiResponse {
  322. members: Vec<Member>,
  323. }
  324. #[derive(serde::Deserialize)]
  325. struct Member {
  326. email: String,
  327. user_id: u64,
  328. }
  329. #[derive(serde::Serialize)]
  330. #[serde(tag = "type")]
  331. #[serde(rename_all = "snake_case")]
  332. pub enum Recipient<'a> {
  333. Stream {
  334. #[serde(rename = "to")]
  335. id: u64,
  336. topic: &'a str,
  337. },
  338. Private {
  339. #[serde(skip)]
  340. id: u64,
  341. #[serde(rename = "to")]
  342. email: &'a str,
  343. },
  344. }
  345. impl Recipient<'_> {
  346. pub fn narrow(&self) -> String {
  347. match self {
  348. Recipient::Stream { id, topic } => {
  349. // See
  350. // https://github.com/zulip/zulip/blob/46247623fc279/zerver/lib/url_encoding.py#L9
  351. // ALWAYS_SAFE without `.` from
  352. // https://github.com/python/cpython/blob/113e2b0a07c/Lib/urllib/parse.py#L772-L775
  353. //
  354. // ALWAYS_SAFE doesn't contain `.` because Zulip actually encodes them to be able
  355. // to use `.` instead of `%` in the encoded strings
  356. const ALWAYS_SAFE: &str =
  357. "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-~";
  358. let mut encoded_topic = String::new();
  359. for ch in topic.bytes() {
  360. if !(ALWAYS_SAFE.contains(ch as char)) {
  361. write!(encoded_topic, ".{:02X}", ch).unwrap();
  362. } else {
  363. encoded_topic.push(ch as char);
  364. }
  365. }
  366. format!("stream/{}-xxx/topic/{}", id, encoded_topic)
  367. }
  368. Recipient::Private { id, .. } => format!("pm-with/{}-xxx", id),
  369. }
  370. }
  371. pub fn url(&self) -> String {
  372. format!("https://rust-lang.zulipchat.com/#narrow/{}", self.narrow())
  373. }
  374. }
  375. #[cfg(test)]
  376. fn check_encode(topic: &str, expected: &str) {
  377. const PREFIX: &str = "stream/0-xxx/topic/";
  378. let computed = Recipient::Stream { id: 0, topic }.narrow();
  379. assert_eq!(&computed[..PREFIX.len()], PREFIX);
  380. assert_eq!(&computed[PREFIX.len()..], expected);
  381. }
  382. #[test]
  383. fn test_encode() {
  384. check_encode("some text with spaces", "some.20text.20with.20spaces");
  385. check_encode(
  386. " !\"#$%&'()*+,-./",
  387. ".20.21.22.23.24.25.26.27.28.29.2A.2B.2C-.2E.2F",
  388. );
  389. check_encode("0123456789:;<=>?", "0123456789.3A.3B.3C.3D.3E.3F");
  390. check_encode(
  391. "@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_",
  392. ".40ABCDEFGHIJKLMNOPQRSTUVWXYZ.5B.5C.5D.5E_",
  393. );
  394. check_encode(
  395. "`abcdefghijklmnopqrstuvwxyz{|}~",
  396. ".60abcdefghijklmnopqrstuvwxyz.7B.7C.7D~.7F",
  397. );
  398. check_encode("áé…", ".C3.A1.C3.A9.E2.80.A6");
  399. }
  400. #[derive(serde::Serialize)]
  401. pub struct MessageApiRequest<'a> {
  402. pub recipient: Recipient<'a>,
  403. pub content: &'a str,
  404. }
  405. impl<'a> MessageApiRequest<'a> {
  406. pub fn url(&self) -> String {
  407. self.recipient.url()
  408. }
  409. pub async fn send(&self, client: &reqwest::Client) -> anyhow::Result<reqwest::Response> {
  410. let bot_api_token = env::var("ZULIP_API_TOKEN").expect("ZULIP_API_TOKEN");
  411. #[derive(serde::Serialize)]
  412. struct SerializedApi<'a> {
  413. #[serde(rename = "type")]
  414. type_: &'static str,
  415. to: String,
  416. #[serde(skip_serializing_if = "Option::is_none")]
  417. topic: Option<&'a str>,
  418. content: &'a str,
  419. }
  420. Ok(client
  421. .post("https://rust-lang.zulipchat.com/api/v1/messages")
  422. .basic_auth(BOT_EMAIL, Some(&bot_api_token))
  423. .form(&SerializedApi {
  424. type_: match self.recipient {
  425. Recipient::Stream { .. } => "stream",
  426. Recipient::Private { .. } => "private",
  427. },
  428. to: match self.recipient {
  429. Recipient::Stream { id, .. } => id.to_string(),
  430. Recipient::Private { email, .. } => email.to_string(),
  431. },
  432. topic: match self.recipient {
  433. Recipient::Stream { topic, .. } => Some(topic),
  434. Recipient::Private { .. } => None,
  435. },
  436. content: self.content,
  437. })
  438. .send()
  439. .await?)
  440. }
  441. }
  442. #[derive(serde::Deserialize)]
  443. pub struct MessageApiResponse {
  444. #[serde(rename = "id")]
  445. pub message_id: u64,
  446. }
  447. #[derive(Debug)]
  448. pub struct UpdateMessageApiRequest<'a> {
  449. pub message_id: u64,
  450. pub topic: Option<&'a str>,
  451. pub propagate_mode: Option<&'a str>,
  452. pub content: Option<&'a str>,
  453. }
  454. impl<'a> UpdateMessageApiRequest<'a> {
  455. pub async fn send(&self, client: &reqwest::Client) -> anyhow::Result<reqwest::Response> {
  456. let bot_api_token = env::var("ZULIP_API_TOKEN").expect("ZULIP_API_TOKEN");
  457. #[derive(serde::Serialize)]
  458. struct SerializedApi<'a> {
  459. #[serde(skip_serializing_if = "Option::is_none")]
  460. pub topic: Option<&'a str>,
  461. #[serde(skip_serializing_if = "Option::is_none")]
  462. pub propagate_mode: Option<&'a str>,
  463. #[serde(skip_serializing_if = "Option::is_none")]
  464. pub content: Option<&'a str>,
  465. }
  466. Ok(client
  467. .patch(&format!(
  468. "https://rust-lang.zulipchat.com/api/v1/messages/{}",
  469. self.message_id
  470. ))
  471. .basic_auth(BOT_EMAIL, Some(&bot_api_token))
  472. .form(&SerializedApi {
  473. topic: self.topic,
  474. propagate_mode: self.propagate_mode,
  475. content: self.content,
  476. })
  477. .send()
  478. .await?)
  479. }
  480. }
  481. async fn acknowledge(
  482. ctx: &Context,
  483. gh_id: i64,
  484. mut words: impl Iterator<Item = &str>,
  485. ) -> anyhow::Result<String> {
  486. let filter = match words.next() {
  487. Some(filter) => {
  488. if words.next().is_some() {
  489. anyhow::bail!("too many words");
  490. }
  491. filter
  492. }
  493. None => anyhow::bail!("not enough words"),
  494. };
  495. let ident = if let Ok(number) = filter.parse::<usize>() {
  496. Identifier::Index(
  497. std::num::NonZeroUsize::new(number)
  498. .ok_or_else(|| anyhow::anyhow!("index must be at least 1"))?,
  499. )
  500. } else if filter == "all" || filter == "*" {
  501. Identifier::All
  502. } else {
  503. Identifier::Url(filter)
  504. };
  505. let mut db = ctx.db.get().await;
  506. match delete_ping(&mut *db, gh_id, ident).await {
  507. Ok(deleted) => {
  508. let resp = if deleted.is_empty() {
  509. format!(
  510. "No notifications matched `{}`, so none were deleted.",
  511. filter
  512. )
  513. } else {
  514. let mut resp = String::from("Acknowledged:\n");
  515. for deleted in deleted {
  516. resp.push_str(&format!(
  517. " * [{}]({}){}\n",
  518. deleted
  519. .short_description
  520. .as_deref()
  521. .unwrap_or(&deleted.origin_url),
  522. deleted.origin_url,
  523. deleted
  524. .metadata
  525. .map_or(String::new(), |m| format!(" ({})", m)),
  526. ));
  527. }
  528. resp
  529. };
  530. Ok(serde_json::to_string(&Response { content: &resp }).unwrap())
  531. }
  532. Err(e) => Ok(serde_json::to_string(&Response {
  533. content: &format!("Failed to acknowledge {}: {:?}.", filter, e),
  534. })
  535. .unwrap()),
  536. }
  537. }
  538. async fn add_notification(
  539. ctx: &Context,
  540. gh_id: i64,
  541. mut words: impl Iterator<Item = &str>,
  542. ) -> anyhow::Result<String> {
  543. let url = match words.next() {
  544. Some(idx) => idx,
  545. None => anyhow::bail!("url not present"),
  546. };
  547. let mut description = words.fold(String::new(), |mut acc, piece| {
  548. acc.push_str(piece);
  549. acc.push(' ');
  550. acc
  551. });
  552. let description = if description.is_empty() {
  553. None
  554. } else {
  555. assert_eq!(description.pop(), Some(' ')); // pop trailing space
  556. Some(description)
  557. };
  558. match record_ping(
  559. &*ctx.db.get().await,
  560. &notifications::Notification {
  561. user_id: gh_id,
  562. origin_url: url.to_owned(),
  563. origin_html: String::new(),
  564. short_description: description,
  565. time: chrono::Utc::now().into(),
  566. team_name: None,
  567. },
  568. )
  569. .await
  570. {
  571. Ok(()) => Ok(serde_json::to_string(&Response {
  572. content: "Created!",
  573. })
  574. .unwrap()),
  575. Err(e) => Ok(serde_json::to_string(&Response {
  576. content: &format!("Failed to create: {:?}", e),
  577. })
  578. .unwrap()),
  579. }
  580. }
  581. async fn add_meta_notification(
  582. ctx: &Context,
  583. gh_id: i64,
  584. mut words: impl Iterator<Item = &str>,
  585. ) -> anyhow::Result<String> {
  586. let idx = match words.next() {
  587. Some(idx) => idx,
  588. None => anyhow::bail!("idx not present"),
  589. };
  590. let idx = idx
  591. .parse::<usize>()
  592. .context("index")?
  593. .checked_sub(1)
  594. .ok_or_else(|| anyhow::anyhow!("1-based indexes"))?;
  595. let mut description = words.fold(String::new(), |mut acc, piece| {
  596. acc.push_str(piece);
  597. acc.push(' ');
  598. acc
  599. });
  600. let description = if description.is_empty() {
  601. None
  602. } else {
  603. assert_eq!(description.pop(), Some(' ')); // pop trailing space
  604. Some(description)
  605. };
  606. let mut db = ctx.db.get().await;
  607. match add_metadata(&mut db, gh_id, idx, description.as_deref()).await {
  608. Ok(()) => Ok(serde_json::to_string(&Response {
  609. content: "Added metadata!",
  610. })
  611. .unwrap()),
  612. Err(e) => Ok(serde_json::to_string(&Response {
  613. content: &format!("Failed to add: {:?}", e),
  614. })
  615. .unwrap()),
  616. }
  617. }
  618. async fn move_notification(
  619. ctx: &Context,
  620. gh_id: i64,
  621. mut words: impl Iterator<Item = &str>,
  622. ) -> anyhow::Result<String> {
  623. let from = match words.next() {
  624. Some(idx) => idx,
  625. None => anyhow::bail!("from idx not present"),
  626. };
  627. let to = match words.next() {
  628. Some(idx) => idx,
  629. None => anyhow::bail!("from idx not present"),
  630. };
  631. let from = from
  632. .parse::<usize>()
  633. .context("from index")?
  634. .checked_sub(1)
  635. .ok_or_else(|| anyhow::anyhow!("1-based indexes"))?;
  636. let to = to
  637. .parse::<usize>()
  638. .context("to index")?
  639. .checked_sub(1)
  640. .ok_or_else(|| anyhow::anyhow!("1-based indexes"))?;
  641. match move_indices(&mut *ctx.db.get().await, gh_id, from, to).await {
  642. Ok(()) => Ok(serde_json::to_string(&Response {
  643. // to 1-base indices
  644. content: &format!("Moved {} to {}.", from + 1, to + 1),
  645. })
  646. .unwrap()),
  647. Err(e) => Ok(serde_json::to_string(&Response {
  648. content: &format!("Failed to move: {:?}.", e),
  649. })
  650. .unwrap()),
  651. }
  652. }
  653. #[derive(serde::Serialize, Debug)]
  654. struct ResponseNotRequired {
  655. response_not_required: bool,
  656. }
  657. #[derive(serde::Deserialize, Debug)]
  658. struct SentMessage {
  659. id: u64,
  660. }
  661. #[derive(serde::Serialize, Debug, Copy, Clone)]
  662. struct AddReaction<'a> {
  663. message_id: u64,
  664. emoji_name: &'a str,
  665. }
  666. impl<'a> AddReaction<'a> {
  667. pub async fn send(self, client: &reqwest::Client) -> anyhow::Result<reqwest::Response> {
  668. let bot_api_token = env::var("ZULIP_API_TOKEN").expect("ZULIP_API_TOKEN");
  669. Ok(client
  670. .post(&format!(
  671. "https://rust-lang.zulipchat.com/api/v1/messages/{}/reactions",
  672. self.message_id
  673. ))
  674. .basic_auth(BOT_EMAIL, Some(&bot_api_token))
  675. .form(&self)
  676. .send()
  677. .await?)
  678. }
  679. }
  680. struct WaitingMessage<'a> {
  681. primary: &'a str,
  682. emoji: &'a [&'a str],
  683. }
  684. impl WaitingMessage<'static> {
  685. fn end_topic() -> Self {
  686. WaitingMessage {
  687. primary: "Does anyone have something to add on the current topic?\n\
  688. React with :working_on_it: if you have something to say.\n\
  689. React with :all_good: if not.",
  690. emoji: &["working_on_it", "all_good"],
  691. }
  692. }
  693. fn end_meeting() -> Self {
  694. WaitingMessage {
  695. primary: "Does anyone have something to bring up?\n\
  696. React with :working_on_it: if you have something to say.\n\
  697. React with :all_good: if you're ready to end the meeting.",
  698. emoji: &["working_on_it", "all_good"],
  699. }
  700. }
  701. fn start_reading() -> Self {
  702. WaitingMessage {
  703. primary: "Click on the :book: when you start reading (and leave it clicked).\n\
  704. Click on the :checkered_flag: when you finish reading.",
  705. emoji: &["book", "checkered_flag"],
  706. }
  707. }
  708. }
  709. async fn post_waiter(
  710. ctx: &Context,
  711. message: &Message,
  712. waiting: WaitingMessage<'_>,
  713. ) -> anyhow::Result<String> {
  714. let posted = MessageApiRequest {
  715. recipient: Recipient::Stream {
  716. id: message.stream_id.ok_or_else(|| {
  717. anyhow::format_err!("private waiting not supported, missing stream id")
  718. })?,
  719. topic: message.subject.as_deref().ok_or_else(|| {
  720. anyhow::format_err!("private waiting not supported, missing topic")
  721. })?,
  722. },
  723. content: waiting.primary,
  724. }
  725. .send(ctx.github.raw())
  726. .await?;
  727. let body = posted.text().await?;
  728. let message_id = serde_json::from_str::<SentMessage>(&body)
  729. .with_context(|| format!("{:?} did not deserialize as SentMessage", body))?
  730. .id;
  731. for reaction in waiting.emoji {
  732. AddReaction {
  733. message_id,
  734. emoji_name: reaction,
  735. }
  736. .send(&ctx.github.raw())
  737. .await
  738. .context("emoji reaction failed")?;
  739. }
  740. Ok(serde_json::to_string(&ResponseNotRequired {
  741. response_not_required: true,
  742. })
  743. .unwrap())
  744. }