zulip.rs 25 KB

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