zulip.rs 22 KB

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