From 32dd897fda0f714103628881bd01a74b11af8754 Mon Sep 17 00:00:00 2001 From: "hinto.janai" Date: Mon, 24 Jun 2024 11:03:20 -0400 Subject: [PATCH] v0.0.3 --- Cargo.lock | 3 +- Cargo.toml | 3 +- README.md | 29 ++-- moo.toml | 11 +- src/README.md | 1 + src/command/command.rs | 37 +++++ src/command/handle.rs | 51 +++++- src/command/parse.rs | 75 +++++++++ src/config.rs | 17 +- src/constants.rs | 48 +++++- src/github.rs | 363 ++++++++++++++++++++++++++++++++++++++++- src/main.rs | 11 ++ src/meeting.rs | 84 ++++++++++ 13 files changed, 710 insertions(+), 23 deletions(-) create mode 100644 src/meeting.rs diff --git a/Cargo.lock b/Cargo.lock index f720cc5..d818923 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1939,9 +1939,10 @@ dependencies = [ [[package]] name = "moo" -version = "0.0.2" +version = "0.0.3" dependencies = [ "anyhow", + "chrono", "const_format", "dirs", "matrix-sdk", diff --git a/Cargo.toml b/Cargo.toml index 7e92147..5d0ebf7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "moo" -version = "0.0.2" +version = "0.0.3" edition = "2021" authors = ["hinto-janai"] repository = "https://github.com/Cuprate/moo" @@ -11,6 +11,7 @@ description = "Matrix bot for Cuprate" [dependencies] anyhow = { version = "1.0.86" } +chrono = { version = "0.4.38" } const_format = { version = "0.2.32" } dirs = { version = "5.0.1" } matrix-sdk = { version = "0.7.1", features = ["markdown", "anyhow"] } diff --git a/README.md b/README.md index cbcb60a..744d3a5 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,15 @@ If you don't want to save a password unencrypted to disk, set this environment v MOO_PASSWORD="$correct_password" ./moo ``` -### 4. Start +### 4. (optional) Add correct `@moo900` GitHub API token to `moo.toml` +If you don't want to save this to disk, set this environment variable (leading with a space): +```bash +# There's a space leading this command so +# it isn't saved in your shell's history file. + MOO_GITHUB_TOKEN="$moo900_github_token" ./moo +``` + +### 5. Start ```bash ./moo ``` @@ -61,7 +69,9 @@ sudo systemctl start moo - Reply to commands (if you're in the allowed list of users) ## Commands -`moo` is currently only used as priority merge queue bot. +`moo` is currently used as: +- Priority merge queue bot +- [Cuprate meeting bot](https://github.com/monero-project/meta/issues) The below commands read/write PR numbers to the queue. @@ -80,6 +90,8 @@ The below commands read/write PR numbers to the queue. | `!sweep` | Remove any PRs in the queue that can be removed (since they were merged). | `!sweeper` | Report how long before an automatic `!sweep` occurs. | `!clear` | Clear the entire queue. +| `!meeting` | Begin/end Cuprate meetings. Issues/logs will be handled automatically after ending. +| `!agenda ` | Re-write the current Cuprate meeting's extra agenda items. | `!status` | Report `moo` status. | `!help` | Print all `moo` commands. | `!shutdown` | Shutdown `moo`. @@ -88,6 +100,10 @@ Parameters are delimited by spaces, e.g.: ``` !add 3 123 52 low ``` +`!agenda` expects a JSON array containing JSON strings: +``` +!agenda ["Greetings", "Updates", "New Agenda Item"]; +``` ## Config For configuration, see [`moo.toml`](moo.toml). @@ -100,11 +116,4 @@ For configuration, see [`moo.toml`](moo.toml). | Config | `~/.config/moo/moo.toml` ## Forking -`moo` is hardcoded for Cuprate but it _probably_ works with any account in any room, just edit these [constants](https://github.com/Cuprate/moo/blob/2e2be1abecfac8c75a5a1942dae1f40d880f4756/src/constants.rs): -- [`MOO_MATRIX_ID`](https://github.com/Cuprate/moo/blob/2e2be1abecfac8c75a5a1942dae1f40d880f4756/src/constants.rs#L62-L64) (your bot's username) -- [`CUPRATE_GITHUB_PULL`](https://github.com/Cuprate/moo/blob/2e2be1abecfac8c75a5a1942dae1f40d880f4756/src/constants.rs#L18) -- [`CUPRATE_GITHUB_PULL_API`](https://github.com/Cuprate/moo/blob/2e2be1abecfac8c75a5a1942dae1f40d880f4756/src/constants.rs#L21) -- [`CUPRATE_MATRIX_ROOM_ID`](https://github.com/Cuprate/moo/blob/2e2be1abecfac8c75a5a1942dae1f40d880f4756/src/constants.rs#L53-L55) -- [`ALLOWED_MATRIX_IDS_DEFAULT`](https://github.com/Cuprate/moo/blob/2e2be1abecfac8c75a5a1942dae1f40d880f4756/src/constants.rs#L78-L85) - -and remove the `allowed_users` in `moo.toml`. +`moo` is hardcoded for Cuprate but it _probably_ works with any account in any room, just edit some of these [constants](https://github.com/Cuprate/moo/blob/2e2be1abecfac8c75a5a1942dae1f40d880f4756/src/constants.rs), and remove the `allowed_users` in `moo.toml`. diff --git a/moo.toml b/moo.toml index 72e4359..afc8350 100644 --- a/moo.toml +++ b/moo.toml @@ -8,7 +8,7 @@ #----------------------------------------------------------# -# API # +# AUTHENTICATION # #----------------------------------------------------------# # The correct pass for the `@moo:monero.social` account. # @@ -18,6 +18,15 @@ # TYPE | string password = "" +# A valid GitHub API token (with all permissions) +# for . +# +# Any value within the `MOO_GITHUB_TOKEN` environment +# variable will override this. +# +# TYPE | string +token = "" + #----------------------------------------------------------# # AUTHORIZATION # diff --git a/src/README.md b/src/README.md index e953666..e4215e3 100644 --- a/src/README.md +++ b/src/README.md @@ -35,6 +35,7 @@ The structure of folders & files. | `handler.rs` | Matrix room event handler, i.e. when a message is received, what to do? | `logger.rs` | Logging init. | `main.rs` | Init and `main()`. +| `meeting.rs` | Cuprate meeting handler. | `panic.rs` | Custom panic handler. | `priority.rs` | `Priority` enum. | `pull_request.rs` | Pull request types. diff --git a/src/command/command.rs b/src/command/command.rs index 95d4658..ea0e48b 100644 --- a/src/command/command.rs +++ b/src/command/command.rs @@ -3,6 +3,7 @@ //---------------------------------------------------------------------------------------------------- Use use std::fmt::{Display, Write}; +use serde_json::Value; use strum::{AsRefStr, EnumCount, EnumIs, EnumIter, FromRepr, IntoStaticStr, VariantNames}; use crate::{priority::Priority, pull_request::PullRequest}; @@ -44,6 +45,10 @@ pub enum Command { Sweeper, /// `!clear` Clear, + /// `!meeting` + Meeting, + /// `!agenda` + Agenda(Vec), /// `!status` Status, /// `!help` @@ -69,6 +74,7 @@ impl Display for Command { Self::Sweep => f.write_str("sweep"), Self::Sweeper => f.write_str("sweeper"), Self::Clear => f.write_str("clear"), + Self::Meeting => f.write_str("meeting"), Self::Status => f.write_str("status"), Self::Help => f.write_str("help"), Self::Shutdown => f.write_str("shutdown"), @@ -93,6 +99,32 @@ impl Display for Command { } Ok(()) } + + // Agenda + Self::Agenda(items) => { + f.write_str("agenda")?; + + if items.is_empty() { + return Ok(()); + } + + f.write_str(" ")?; + + let items = items + .iter() + .map(|item| Value::String(item.to_string())) + .collect::>(); + + let array = Value::Array(items); + + let Ok(json) = serde_json::to_string(&array) else { + return Err(std::fmt::Error); + }; + + f.write_str(&json)?; + + Ok(()) + } } } } @@ -120,6 +152,11 @@ mod test { (Command::Remove(vec![45, 2]), "!remove 45 2"), (Command::Sweep, "!sweep"), (Command::Sweeper, "!sweeper"), + (Command::Meeting, "!meeting"), + ( + Command::Agenda(vec!["hello".into(), "world".into()]), + r#"!agenda ["hello","world"]"#, + ), (Command::Clear, "!clear"), (Command::Status, "!status"), (Command::Help, "!help"), diff --git a/src/command/handle.rs b/src/command/handle.rs index ec0e9a6..06ff289 100644 --- a/src/command/handle.rs +++ b/src/command/handle.rs @@ -2,7 +2,7 @@ //---------------------------------------------------------------------------------------------------- Use use std::{ - sync::Arc, + sync::{atomic::Ordering, Arc}, time::{SystemTime, UNIX_EPOCH}, }; @@ -12,9 +12,12 @@ use tracing::{info, instrument, trace}; use crate::{ command::Command, - constants::{CONFIG, CUPRATE_GITHUB_PULL, HELP, INIT_INSTANT, MOO, TXT_EMPTY}, + constants::{ + CONFIG, CUPRATE_GITHUB_PULL, HELP, INIT_INSTANT, MOO, TXT_EMPTY, TXT_MEETING_START_IDENT, + }, database::Database, github::pr_is_open, + meeting::{MEETING_DATABASE, MEETING_ONGOING}, priority::Priority, pull_request::{PullRequest, PullRequestMetadata}, }; @@ -58,6 +61,8 @@ impl Command { Self::Sweep => Self::handle_sweep(db).await, Self::Sweeper => Self::handle_sweeper(db).await, Self::Clear => Self::handle_clear(db).await, + Self::Meeting => Self::handle_meeting().await, + Self::Agenda(items) => Self::handle_agenda(items).await, Self::Status => Self::handle_status(), Self::Help => Self::handle_help(), Self::Shutdown => Self::handle_shutdown(db).await, @@ -306,13 +311,51 @@ impl Command { RoomMessageEventContent::text_plain(msg) } + /// TODO + #[instrument] + async fn handle_meeting() -> RoomMessageEventContent { + let msg = if MEETING_ONGOING.load(Ordering::Acquire) { + let mut logs = String::new(); + let mut db = MEETING_DATABASE.lock().await; + std::mem::swap(&mut logs, &mut db); + + MEETING_ONGOING.store(false, Ordering::Release); + + match crate::github::finish_cuprate_meeting(logs).await { + Ok((logs, next_meeting)) => { + format!("- Logs: {logs}\n - Next meeting: {next_meeting}") + } + Err(e) => e.to_string(), + } + } else { + let mut db = MEETING_DATABASE.lock().await; + *db = String::from("## Meeting logs"); + MEETING_ONGOING.store(true, Ordering::Release); + TXT_MEETING_START_IDENT.to_string() + }; + + trace!(msg); + RoomMessageEventContent::text_markdown(msg) + } + + /// TODO + async fn handle_agenda(items: Vec) -> RoomMessageEventContent { + let msg = match crate::github::edit_cuprate_meeting_agenda(items).await { + Ok(url) => format!("Updated: {url}"), + Err(e) => e.to_string(), + }; + trace!(msg); + RoomMessageEventContent::text_plain(msg) + } + /// TODO #[instrument] fn handle_status() -> RoomMessageEventContent { let elapsed = INIT_INSTANT.elapsed().as_secs_f32(); - let uptime = UptimeFull::from(elapsed).to_string(); - let msg = format!("{MOO}, uptime: {uptime}"); + let meeting = MEETING_ONGOING.load(Ordering::Acquire); + + let msg = format!("{MOO}, meeting: {meeting}, uptime: {uptime}"); trace!(msg); RoomMessageEventContent::text_markdown(msg) diff --git a/src/command/parse.rs b/src/command/parse.rs index 57840af..f6ecb79 100644 --- a/src/command/parse.rs +++ b/src/command/parse.rs @@ -3,6 +3,7 @@ //---------------------------------------------------------------------------------------------------- Use use std::str::FromStr; +use serde_json::Value; use strum::VariantNames; use tracing::debug; @@ -92,6 +93,47 @@ impl Command { Ok(Self::Remove(vec)) } + + /// TODO + #[inline] + fn from_str_agenda<'a, I: Iterator>( + iter: I, + ) -> Result { + let mut json = String::new(); + for item in iter { + json += " "; + json += item; + } + + if json.is_empty() { + return Err(CommandParseError::MissingParameter); + } + + let Ok(items) = serde_json::from_str::>(&json) else { + return Err(CommandParseError::IncorrectParameter); + }; + + let mut vec = vec![]; + + for item in items { + let Value::String(item) = item else { + return Err(CommandParseError::IncorrectParameter); + }; + + vec.push(item); + } + + if vec.is_empty() { + return Err(CommandParseError::MissingParameter); + } + + // Error on duplicate parameters. + if slice_contains_duplicates(&vec) { + return Err(CommandParseError::DuplicateParameter); + } + + Ok(Self::Agenda(vec)) + } } impl FromStr for Command { @@ -120,6 +162,8 @@ impl FromStr for Command { "!sweep" => Self::Sweep, "!sweeper" => Self::Sweeper, "!clear" => Self::Clear, + "!meeting" => Self::Meeting, + "!agenda" => Self::from_str_agenda(iter)?, "!status" => Self::Status, "!help" => Self::Help, "!shutdown" => Self::Shutdown, @@ -254,4 +298,35 @@ mod test { fn parse_remove_dup_param() { Command::from_str("!remove 2 2").unwrap(); } + + /// Test `FromStr` for `Command::Agenda`. + #[test] + fn parse_agenda() { + let command = Command::from_str(r#"!agenda ["hello", "world"]"#).unwrap(); + let expected = Command::Agenda(vec!["hello".into(), "world".into()]); + assert_eq!(command, expected); + + let command = Command::from_str(r#"!agenda ["item 1", "item 2 3"]"#).unwrap(); + let expected = Command::Agenda(vec!["item 1".into(), "item 2 3".into()]); + assert_eq!(command, expected); + + let command = Command::from_str(r#"!agenda ["item 1"]"#).unwrap(); + let expected = Command::Agenda(vec!["item 1".into()]); + assert_eq!(command, expected); + + let command = Command::from_str("!agenda"); + let expected = Err(CommandParseError::MissingParameter); + assert_eq!(command, expected); + + let command = Command::from_str("!agenda []"); + let expected = Err(CommandParseError::MissingParameter); + assert_eq!(command, expected); + } + + /// Test `FromStr` for `Command::Agenda` fails with duplicate parameters. + #[test] + #[should_panic(expected = "called `Result::unwrap()` on an `Err` value: DuplicateParameter")] + fn parse_agenda_dup_param() { + Command::from_str(r#"!agenda ["a", "a"]"#).unwrap(); + } } diff --git a/src/config.rs b/src/config.rs index 9e05c22..bcabdff 100644 --- a/src/config.rs +++ b/src/config.rs @@ -6,7 +6,8 @@ use matrix_sdk::ruma::OwnedUserId; use serde::{Deserialize, Serialize}; use crate::constants::{ - ALLOWED_MATRIX_IDS_DEFAULT, CONFIG_PATH, MOO_CONFIG_PATH, MOO_MATRIX_ID, MOO_PASSWORD_ENV_VAR, + ALLOWED_MATRIX_IDS_DEFAULT, CONFIG_PATH, MOO_CONFIG_PATH, MOO_GITHUB_TOKEN_ENV_VAR, + MOO_MATRIX_ID, MOO_PASSWORD_ENV_VAR, }; //---------------------------------------------------------------------------------------------------- @@ -17,6 +18,10 @@ pub struct Config { #[serde(default = "default_password")] pub password: String, + /// TODO + #[serde(default = "default_password")] + pub token: String, + /// TODO #[serde(default = "default_allowed_users")] pub allowed_users: Vec, @@ -34,6 +39,7 @@ impl Default for Config { fn default() -> Self { Self { password: default_password(), + token: default_password(), allowed_users: default_allowed_users(), sweeper: default_sweeper(), log_level: default_log_level(), @@ -74,6 +80,15 @@ impl Config { return Err(anyhow!("`{}` password was empty", &*MOO_MATRIX_ID)); } + if let Ok(token) = std::env::var(MOO_GITHUB_TOKEN_ENV_VAR) { + println!("Using environment variable: `{MOO_GITHUB_TOKEN_ENV_VAR}`"); + this.token = token; + } + + if this.token.is_empty() { + eprintln!("GitHub token was empty, API access will not work...!"); + } + Ok(this) } } diff --git a/src/constants.rs b/src/constants.rs index 3234065..19374c3 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -20,6 +20,20 @@ pub const CUPRATE_GITHUB_PULL: &str = "https://github.com/Cuprate/cuprate/pull"; /// TODO pub const CUPRATE_GITHUB_PULL_API: &str = "https://api.github.com/repos/Cuprate/cuprate/pulls"; +/// TODO +pub const MONERO_META_GITHUB_ISSUE_API: &str = + "https://api.github.com/repos/monero-project/meta/issues"; + +/// TODO +pub const MONERO_META_GITHUB_ISSUE: &str = "https://github.com/monero-project/meta/issues"; + +// /// TODO +// pub const MONERO_META_GITHUB_ISSUE_API: &str = +// "https://api.github.com/repos/hinto-janai/labeler-test/issues"; + +// /// TODO +// pub const MONERO_META_GITHUB_ISSUE: &str = "https://github.com/hinto-janai/labeler-test/issues"; + //---------------------------------------------------------------------------------------------------- Version /// Build commit. /// @@ -54,7 +68,7 @@ pub const MOO_USER_AGENT: &str = concat!("moo", "/", env!("CARGO_PKG_VERSION"),) pub static CUPRATE_MATRIX_ROOM_ID: Lazy = Lazy::new(|| RoomId::parse("!zPLCnZSsyeFFxUiqUZ:monero.social").unwrap()); -// // Test Matrix room ID. +// /// Test Matrix room ID. // pub static CUPRATE_MATRIX_ROOM_ID: Lazy = // Lazy::new(|| RoomId::parse("!SrjNVhHuHOWcFfYRfj:monero.social").unwrap()); @@ -88,6 +102,9 @@ pub static ALLOWED_MATRIX_IDS_DEFAULT: Lazy> = Lazy::new(|| { /// TODO pub const MOO_PASSWORD_ENV_VAR: &str = "MOO_PASSWORD"; +/// TODO +pub const MOO_GITHUB_TOKEN_ENV_VAR: &str = "MOO_GITHUB_TOKEN"; + /// TODO pub const DEFAULT_LOG_LEVEL: tracing::Level = tracing::Level::TRACE; @@ -154,10 +171,39 @@ pub const HELP: &str = r"| Command | Description | | `!sweep` | Remove any PRs in the queue that can be removed (since they were merged). | `!sweeper` | Report how long before an automatic `!sweep` occurs. | `!clear` | Clear the entire queue. +| `!meeting` | Begin/end Cuprate meetings. Issues/logs will be handled automatically after ending. +| `!agenda ` | Re-write the current Cuprate meeting's extra agenda items. | `!status` | Report `moo` status. | `!help` | Print all `moo` commands. | `!shutdown` | Shutdown `moo`."; +/// TODO +pub const TXT_CUPRATE_MEETING_PREFIX: &str = "[Cuprate](https://github.com/Cuprate/cuprate) is an effort to create an alternative Monero node implementation. + +Location: [Libera.chat, #cuprate](https://libera.chat/) | [Matrix](https://matrix.to/#/#cuprate:monero.social?via=matrix.org&via=monero.social) + +Note that there are currently communication issues with Matrix accounts created on the matrix.org server, consider using a different homeserver to see messages. + +[Join the Monero Matrix server if you don't already have a Matrix account](https://www.getmonero.org/resources/user-guides/join-monero-matrix.html) + +Time: 18:00 UTC [Check in your timezone](https://www.timeanddate.com/worldclock/converter.html) + +Moderator: @boog900 + +Please comment on GitHub in advance of the meeting if you would like to propose a discussion topic. + +Main discussion topics: + +- Greetings +- Updates: What is everyone working on? +- Project: What is next for Cuprate?"; + +/// TODO +pub const TXT_CUPRATE_MEETING_SUFFIX: &str = "- Any other business"; + +/// TODO +pub const TXT_MEETING_START_IDENT: &str = "Recording meeting logs..."; + //---------------------------------------------------------------------------------------------------- Statics // These are accessed everywhere and replace function inputs. diff --git a/src/github.rs b/src/github.rs index ae309bb..47cc407 100644 --- a/src/github.rs +++ b/src/github.rs @@ -1,14 +1,17 @@ //! TODO //---------------------------------------------------------------------------------------------------- Use - use anyhow::anyhow; +use reqwest::Client; use serde::{Deserialize, Serialize}; -use serde_json::from_str; -use tracing::{instrument, trace}; +use serde_json::{from_str, json}; +use tracing::{info, instrument, trace}; use crate::{ - constants::{CUPRATE_GITHUB_PULL_API, MOO_USER_AGENT}, + constants::{ + CONFIG, CUPRATE_GITHUB_PULL_API, MONERO_META_GITHUB_ISSUE_API, MOO_USER_AGENT, + TXT_CUPRATE_MEETING_PREFIX, TXT_CUPRATE_MEETING_SUFFIX, + }, pull_request::{PullRequest, PullRequestError}, }; @@ -81,3 +84,355 @@ pub async fn pr_is_open(pr: PullRequest) -> Result { )), } } + +//---------------------------------------------------------------------------------------------------- Issues +/// TODO +/// +/// # Errors +/// TODO +#[instrument] +#[inline] +pub async fn finish_cuprate_meeting( + meeting_logs: String, +) -> Result<(String, String), anyhow::Error> { + let client = reqwest::ClientBuilder::new() + .gzip(true) + .user_agent(MOO_USER_AGENT) + .build()?; + + let (issue, title) = find_cuprate_meeting_issue(&client, false).await?; + let logs = post_comment_in_issue(&client, issue, meeting_logs).await?; + let next_meeting = post_cuprate_meeting_issue(&client, title, issue).await?; + close_issue(&client, issue).await?; + + Ok((logs, next_meeting)) +} + +/// TODO +/// +/// # Errors +/// TODO +#[instrument] +#[inline] +pub async fn find_cuprate_meeting_issue( + client: &Client, + find_last_issue: bool, +) -> Result<(u64, String), anyhow::Error> { + trace!("Finding Cuprate meeting issue on: {MONERO_META_GITHUB_ISSUE_API}"); + + let body = client + .get(MONERO_META_GITHUB_ISSUE_API) + .header("Accept", "application/vnd.github+json") + .header("X-GitHub-Api-Version", "2022-11-28") + .header("Authorization", format!("Bearer {}", CONFIG.token)) + .query(&[("state", "all")]) + .send() + .await? + .text() + .await?; + + trace!("reply: {body}"); + + /// TODO + #[derive(Debug, Serialize, Deserialize)] + struct Response { + /// TODO + number: u64, + /// TODO + title: String, + /// TODO + user: User, + } + + /// TODO + #[derive(Debug, Serialize, Deserialize)] + struct User { + /// TODO + login: String, + } + + let responses = from_str::>(&body)?; + trace!("responses: {responses:#?}"); + + let mut second = false; + for resp in responses { + if ["boog900", "moo900"].contains(&resp.user.login.as_str()) + && resp.title.contains("Cuprate Meeting") + { + if find_last_issue { + if second { + return Ok((resp.number, resp.title)); + } + second = true; + continue; + } + + return Ok((resp.number, resp.title)); + } + } + + Err(anyhow!("Error: couldn't find Cuprate Meeting issue")) +} + +/// TODO +/// +/// # Errors +/// TODO +#[instrument] +#[inline] +pub async fn post_cuprate_meeting_issue( + client: &Client, + previous_meeting_title: String, + last_issue: u64, +) -> Result { + trace!("Posting Cuprate meeting issue on: {MONERO_META_GITHUB_ISSUE_API}"); + + let next_meeting_iso_8601 = { + use chrono::{prelude::*, Days, Weekday}; + + let mut today = Utc::now().date_naive(); + + while today.weekday() == Weekday::Tue { + println!("{today}"); + today = today + Days::new(1); + } + + while today.weekday() != Weekday::Tue { + println!("{today}"); + today = today + Days::new(1); + } + + today.format("%Y-%m-%d").to_string() + }; + + let next_meeting_number = { + let mut iter = previous_meeting_title.split_whitespace(); + + let err = || anyhow!("Failed to parse previous meeting title: {previous_meeting_title}"); + + if !iter.next().is_some_and(|s| s == "Cuprate") { + return Err(err()); + } + + if !iter.next().is_some_and(|s| s == "Meeting") { + return Err(err()); + } + + let Some(number_with_hash) = iter.next() else { + return Err(err()); + }; + + let Some(number) = number_with_hash.get(1..) else { + return Err(err()); + }; + + let Ok(number) = number.parse::() else { + return Err(err()); + }; + + number + 1 + }; + + let title = format!( + "Cuprate Meeting #{next_meeting_number} - Tuesday, {next_meeting_iso_8601}, UTC 18:00" + ); + + info!("Meeting title: {title}"); + + let body = format!("{TXT_CUPRATE_MEETING_PREFIX}\n{TXT_CUPRATE_MEETING_SUFFIX}\n\nPrevious meeting with logs: #{last_issue}"); + + let body = json!({ + "title": title, + "body": body, + }); + + info!("Posting issue: {body}"); + + let body = client + .post(MONERO_META_GITHUB_ISSUE_API) + .header("Accept", "application/vnd.github+json") + .header("X-GitHub-Api-Version", "2022-11-28") + .header("Authorization", format!("Bearer {}", CONFIG.token)) + .body(body.to_string()) + .send() + .await? + .text() + .await?; + + trace!("reply: {body}"); + + /// TODO + #[derive(Serialize, Deserialize)] + struct Response { + /// TODO + html_url: String, + } + + match from_str::(&body) { + Ok(resp) => Ok(resp.html_url), + Err(e) => Err(anyhow!("Posting issue error: {e}")), + } +} + +/// TODO +/// +/// # Errors +/// TODO +#[instrument] +#[inline] +pub async fn post_comment_in_issue( + client: &Client, + issue: u64, + comment: String, +) -> Result { + let url = format!("{MONERO_META_GITHUB_ISSUE_API}/{issue}/comments"); + + trace!("Posting comment on: {url}"); + + let body = client + .post(url) + .header("Accept", "application/vnd.github+json") + .header("X-GitHub-Api-Version", "2022-11-28") + .header("Authorization", format!("Bearer {}", CONFIG.token)) + .body(json!({"body":comment}).to_string()) + .send() + .await? + .text() + .await?; + + trace!("Issue comment: {body}"); + + /// TODO + #[derive(Serialize, Deserialize)] + struct Response { + /// TODO + html_url: String, + } + + match from_str::(&body) { + Ok(resp) => { + if resp.html_url.is_empty() { + Err(anyhow!("Issue comment error: {body:#?}")) + } else { + Ok(resp.html_url) + } + } + Err(e) => return Err(anyhow!("Issue comment error: {e}")), + } +} + +/// TODO +/// +/// # Errors +/// TODO +#[instrument] +#[inline] +pub async fn close_issue(client: &Client, issue: u64) -> Result<(), anyhow::Error> { + let url = format!("{MONERO_META_GITHUB_ISSUE_API}/{issue}"); + + trace!("Closing issue: {url}"); + + let body = json!({ + "state": "closed" + }); + + let body = client + .patch(url) + .header("Accept", "application/vnd.github+json") + .header("X-GitHub-Api-Version", "2022-11-28") + .header("Authorization", format!("Bearer {}", CONFIG.token)) + .body(body.to_string()) + .send() + .await? + .text() + .await?; + + trace!("reply: {body}"); + + /// TODO + #[derive(Serialize, Deserialize)] + struct Response { + /// TODO + number: u64, + /// TODO + state: String, + } + + match from_str::(&body) { + Ok(resp) => { + if resp.state == "closed" { + Ok(()) + } else { + Err(anyhow!("Issue close error: {body}")) + } + } + Err(e) => Err(anyhow!("Issue close error: {e}")), + } +} + +/// TODO +/// +/// # Errors +/// TODO +#[instrument] +#[inline] +pub async fn edit_cuprate_meeting_agenda(new_items: Vec) -> Result { + let client = reqwest::ClientBuilder::new() + .gzip(true) + .user_agent(MOO_USER_AGENT) + .build()?; + + let current_issue = find_cuprate_meeting_issue(&client, false).await?.0; + let last_issue = find_cuprate_meeting_issue(&client, true).await?.0; + + let url = format!("{MONERO_META_GITHUB_ISSUE_API}/{current_issue}"); + + trace!("Editing Cuprate meeting agenda on: {url}"); + + let new_agenda = { + let mut buf = String::new(); + + for item in new_items { + buf += "- "; + buf += item.trim(); + buf += "\n"; + } + + buf + }; + + info!("New meeting agenda: {new_agenda}"); + + let body = json!({ + "body": format!("{TXT_CUPRATE_MEETING_PREFIX}\n{new_agenda}\n\nPrevious meeting with logs: #{last_issue}"), + }); + + info!("New meeting agenda: {body}"); + + let body = client + .patch(&url) + .header("Accept", "application/vnd.github+json") + .header("X-GitHub-Api-Version", "2022-11-28") + .header("Authorization", format!("Bearer {}", CONFIG.token)) + .body(body.to_string()) + .send() + .await? + .text() + .await?; + + trace!("reply: {body}"); + + /// TODO + #[derive(Serialize, Deserialize)] + struct Response { + /// TODO + number: u64, + /// TODO + html_url: String, + } + + match from_str::(&body) { + Ok(resp) => Ok(resp.html_url), + Err(e) => Err(anyhow!("Issue edit error: {e}")), + } +} diff --git a/src/main.rs b/src/main.rs index 5bb5f2f..5964bcb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -107,6 +107,7 @@ pub mod free; pub mod github; pub mod handler; pub mod logger; +pub mod meeting; pub mod panic; pub mod priority; pub mod pull_request; @@ -157,6 +158,16 @@ async fn main() { sweeper::spawn_sweeper(Arc::clone(&db), CONFIG.sweeper); } + // Spawn meeting. + if CONFIG.token.is_empty() { + info!("Skipping meeting handler"); + } else { + info!("Registering meeting handler"); + CLIENT.add_event_handler(move |event: SyncRoomMessageEvent| { + meeting::meeting_handler(startup, event) + }); + } + // Hang forever (until error). match CLIENT.sync(sync::sync_settings()).await { Ok(()) => shutdown::graceful_shutdown(db), diff --git a/src/meeting.rs b/src/meeting.rs new file mode 100644 index 0000000..c3eb369 --- /dev/null +++ b/src/meeting.rs @@ -0,0 +1,84 @@ +//! TODO + +//---------------------------------------------------------------------------------------------------- Use +use std::{sync::atomic::AtomicBool, time::SystemTime}; + +use matrix_sdk::ruma::events::{ + room::message::{MessageType, SyncRoomMessageEvent}, + MessageLikeEventType, SyncMessageLikeEvent, +}; +use once_cell::sync::Lazy; +use tokio::sync::Mutex; +use tracing::{info, instrument, trace, warn}; + +use crate::{ + command::Command, + constants::{CONFIG, TXT_MEETING_START_IDENT}, +}; + +//---------------------------------------------------------------------------------------------------- Event +/// TODO +pub static MEETING_ONGOING: AtomicBool = AtomicBool::new(false); + +/// TODO +pub static MEETING_DATABASE: Lazy> = Lazy::new(|| Mutex::new(String::new())); + +//---------------------------------------------------------------------------------------------------- Event +/// TODO +#[instrument] +#[inline] +pub async fn meeting_handler(startup: SystemTime, event: SyncRoomMessageEvent) { + trace!("meeting_handler()"); + + if !MEETING_ONGOING.load(std::sync::atomic::Ordering::Acquire) { + return; + } + + if event.event_type() != MessageLikeEventType::RoomMessage { + trace!("Ignoring non-message event"); + } + + let SyncMessageLikeEvent::Original(event) = event else { + info!("Redacted event, skipping: {event:#?}"); + return; + }; + + let sender = event.sender; + let origin_server_ts = event.origin_server_ts; + + let Some(origin_server_ts) = origin_server_ts.to_system_time() else { + warn!("Event UNIX time could not be parsed: {origin_server_ts:#?}"); + return; + }; + + if let Ok(duration) = startup.duration_since(origin_server_ts) { + let s = duration.as_secs_f32(); + info!("Ignoring previous session message: {s}s ago"); + return; + } + + { + let body = event.content.body(); + if CONFIG.allowed_users.contains(&sender) && body == Command::Meeting.as_ref() + || body == TXT_MEETING_START_IDENT + { + info!("Ignoring meeting ident"); + return; + } + } + + let text = match &event.content.msgtype { + MessageType::Text(t) => &t.body, + MessageType::Audio(_) => "