zulip.rs 23 KB

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