zulip.rs 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286
  1. use crate::db::notifications::add_metadata;
  2. use crate::db::notifications::{self, delete_ping, move_indices, record_ping, Identifier};
  3. use crate::github::GithubClient;
  4. use crate::handlers::Context;
  5. use anyhow::Context as _;
  6. #[derive(Debug, serde::Deserialize)]
  7. pub struct Request {
  8. /// Markdown body of the sent message.
  9. data: String,
  10. /// Metadata about this request.
  11. message: Message,
  12. /// Authentication token. The same for all Zulip messages.
  13. token: String,
  14. }
  15. #[derive(Debug, serde::Deserialize)]
  16. struct Message {
  17. sender_id: usize,
  18. sender_full_name: String,
  19. }
  20. #[derive(serde::Serialize)]
  21. struct Response<'a> {
  22. content: &'a str,
  23. }
  24. pub async fn to_github_id(client: &GithubClient, zulip_id: usize) -> anyhow::Result<Option<i64>> {
  25. let url = format!("{}/zulip-map.json", rust_team_data::v1::BASE_URL);
  26. let map: rust_team_data::v1::ZulipMapping = client
  27. .json(client.raw().get(&url))
  28. .await
  29. .context("could not get team data")?;
  30. Ok(map.users.get(&zulip_id).map(|v| *v as i64))
  31. }
  32. pub async fn respond(ctx: &Context, req: Request) -> String {
  33. let expected_token = std::env::var("ZULIP_TOKEN").expect("`ZULIP_TOKEN` set for authorization");
  34. if !openssl::memcmp::eq(req.token.as_bytes(), expected_token.as_bytes()) {
  35. return serde_json::to_string(&Response {
  36. content: "Invalid authorization.",
  37. })
  38. .unwrap();
  39. }
  40. log::trace!("zulip hook: {:?}", req);
  41. let gh_id = match to_github_id(&ctx.github, req.message.sender_id).await {
  42. Ok(Some(gh_id)) => gh_id,
  43. Ok(None) => {
  44. return serde_json::to_string(&Response {
  45. content: &format!(
  46. "Unknown Zulip user. Please add `zulip-id = {}` to your file in rust-lang/team.",
  47. req.message.sender_id),
  48. })
  49. .unwrap();
  50. }
  51. Err(e) => {
  52. return serde_json::to_string(&Response {
  53. content: &format!("Failed to query team API: {:?}", e),
  54. })
  55. .unwrap();
  56. }
  57. };
  58. let mut words = req.data.split_whitespace();
  59. match words.next() {
  60. Some("acknowledge") => match acknowledge(&ctx, gh_id, words).await {
  61. Ok(r) => r,
  62. Err(e) => serde_json::to_string(&Response {
  63. content: &format!(
  64. "Failed to parse acknowledgement, expected `acknowledge <identifier>`: {:?}.",
  65. e
  66. ),
  67. })
  68. .unwrap(),
  69. },
  70. Some("add") => match add_notification(&ctx, gh_id, words).await {
  71. Ok(r) => r,
  72. Err(e) => serde_json::to_string(&Response {
  73. content: &format!(
  74. "Failed to parse movement, expected `add <url> <description (multiple words)>`: {:?}.",
  75. e
  76. ),
  77. })
  78. .unwrap(),
  79. },
  80. Some("move") => match move_notification(&ctx, gh_id, words).await {
  81. Ok(r) => r,
  82. Err(e) => serde_json::to_string(&Response {
  83. content: &format!(
  84. "Failed to parse movement, expected `move <from> <to>`: {:?}.",
  85. e
  86. ),
  87. })
  88. .unwrap(),
  89. },
  90. Some("meta") => match add_meta_notification(&ctx, gh_id, words).await {
  91. Ok(r) => r,
  92. Err(e) => serde_json::to_string(&Response {
  93. content: &format!(
  94. "Failed to parse movement, expected `move <idx> <meta...>`: {:?}.",
  95. e
  96. ),
  97. })
  98. .unwrap(),
  99. },
  100. _ => serde_json::to_string(&Response {
  101. content: "Unknown command.",
  102. })
  103. .unwrap(),
  104. }
  105. }
  106. async fn acknowledge(
  107. ctx: &Context,
  108. gh_id: i64,
  109. mut words: impl Iterator<Item = &str>,
  110. ) -> anyhow::Result<String> {
  111. let url = match words.next() {
  112. Some(url) => {
  113. if words.next().is_some() {
  114. anyhow::bail!("too many words");
  115. }
  116. url
  117. }
  118. None => anyhow::bail!("not enough words"),
  119. };
  120. let ident = if let Ok(number) = url.parse::<usize>() {
  121. Identifier::Index(
  122. std::num::NonZeroUsize::new(number)
  123. .ok_or_else(|| anyhow::anyhow!("index must be at least 1"))?,
  124. )
  125. } else {
  126. Identifier::Url(url)
  127. };
  128. match delete_ping(
  129. &mut Context::make_db_client(&ctx.github.raw()).await?,
  130. gh_id,
  131. ident,
  132. )
  133. .await
  134. {
  135. Ok(()) => Ok(serde_json::to_string(&Response {
  136. content: &format!("Acknowledged {}.", url),
  137. })
  138. .unwrap()),
  139. Err(e) => Ok(serde_json::to_string(&Response {
  140. content: &format!("Failed to acknowledge {}: {:?}.", url, e),
  141. })
  142. .unwrap()),
  143. }
  144. }
  145. async fn add_notification(
  146. ctx: &Context,
  147. gh_id: i64,
  148. mut words: impl Iterator<Item = &str>,
  149. ) -> anyhow::Result<String> {
  150. let url = match words.next() {
  151. Some(idx) => idx,
  152. None => anyhow::bail!("url not present"),
  153. };
  154. let mut description = words.fold(String::new(), |mut acc, piece| {
  155. acc.push_str(piece);
  156. acc.push(' ');
  157. acc
  158. });
  159. let description = if description.is_empty() {
  160. None
  161. } else {
  162. assert_eq!(description.pop(), Some(' ')); // pop trailing space
  163. Some(description)
  164. };
  165. match record_ping(
  166. &ctx.db,
  167. &notifications::Notification {
  168. user_id: gh_id,
  169. origin_url: url.to_owned(),
  170. origin_html: String::new(),
  171. short_description: description,
  172. time: chrono::Utc::now().into(),
  173. team_name: None,
  174. },
  175. )
  176. .await
  177. {
  178. Ok(()) => Ok(serde_json::to_string(&Response {
  179. content: "Created!",
  180. })
  181. .unwrap()),
  182. Err(e) => Ok(serde_json::to_string(&Response {
  183. content: &format!("Failed to create: {:?}", e),
  184. })
  185. .unwrap()),
  186. }
  187. }
  188. async fn add_meta_notification(
  189. ctx: &Context,
  190. gh_id: i64,
  191. mut words: impl Iterator<Item = &str>,
  192. ) -> anyhow::Result<String> {
  193. let idx = match words.next() {
  194. Some(idx) => idx,
  195. None => anyhow::bail!("idx not present"),
  196. };
  197. let idx = idx
  198. .parse::<usize>()
  199. .context("index")?
  200. .checked_sub(1)
  201. .ok_or_else(|| anyhow::anyhow!("1-based indexes"))?;
  202. let mut description = words.fold(String::new(), |mut acc, piece| {
  203. acc.push_str(piece);
  204. acc.push(' ');
  205. acc
  206. });
  207. let description = if description.is_empty() {
  208. None
  209. } else {
  210. assert_eq!(description.pop(), Some(' ')); // pop trailing space
  211. Some(description)
  212. };
  213. match add_metadata(
  214. &mut Context::make_db_client(&ctx.github.raw()).await?,
  215. gh_id,
  216. idx,
  217. description.as_deref(),
  218. )
  219. .await
  220. {
  221. Ok(()) => Ok(serde_json::to_string(&Response {
  222. content: "Added metadata!",
  223. })
  224. .unwrap()),
  225. Err(e) => Ok(serde_json::to_string(&Response {
  226. content: &format!("Failed to add: {:?}", e),
  227. })
  228. .unwrap()),
  229. }
  230. }
  231. async fn move_notification(
  232. ctx: &Context,
  233. gh_id: i64,
  234. mut words: impl Iterator<Item = &str>,
  235. ) -> anyhow::Result<String> {
  236. let from = match words.next() {
  237. Some(idx) => idx,
  238. None => anyhow::bail!("from idx not present"),
  239. };
  240. let to = match words.next() {
  241. Some(idx) => idx,
  242. None => anyhow::bail!("from idx not present"),
  243. };
  244. let from = from
  245. .parse::<usize>()
  246. .context("from index")?
  247. .checked_sub(1)
  248. .ok_or_else(|| anyhow::anyhow!("1-based indexes"))?;
  249. let to = to
  250. .parse::<usize>()
  251. .context("to index")?
  252. .checked_sub(1)
  253. .ok_or_else(|| anyhow::anyhow!("1-based indexes"))?;
  254. match move_indices(
  255. &mut Context::make_db_client(&ctx.github.raw()).await?,
  256. gh_id,
  257. from,
  258. to,
  259. )
  260. .await
  261. {
  262. Ok(()) => Ok(serde_json::to_string(&Response {
  263. // to 1-base indices
  264. content: &format!("Moved {} to {}.", from + 1, to + 1),
  265. })
  266. .unwrap()),
  267. Err(e) => Ok(serde_json::to_string(&Response {
  268. content: &format!("Failed to move: {:?}.", e),
  269. })
  270. .unwrap()),
  271. }
  272. }