diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 419924e..362e7c5 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -22,6 +22,7 @@ jobs: - '.github/workflows/**' code: - 'src/**' + - 'scripts/**' - 'Cargo.lock' - 'Cargo.toml' - 'Cross.toml' diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 17d207a..d3e099d 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -55,7 +55,8 @@ jobs: - name: 🔨 - Build run: > cross build --release --target ${{ matrix.target }} && \ - mv target/**/release/alertaemcena alertaemcena-${{ matrix.build }} + mv target/**/release/alertaemcena alertaemcena-${{ matrix.build }} && \ + mv target/**/release/backfill_reviews backfill_reviews-${{ matrix.build }} - name: 📦 - Copy artifact uses: actions/upload-artifact@v4 @@ -64,6 +65,13 @@ jobs: path: alertaemcena-${{ matrix.build }} if-no-files-found: error + - name: 📦 - Copy backfill_reviews artifact + uses: actions/upload-artifact@v4 + with: + name: backfill_reviews-${{ matrix.build }} + path: backfill_reviews-${{ matrix.build }} + if-no-files-found: error + release: needs: [build-code] runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index e38e5a4..f06c90e 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,5 @@ test-locally.sh run-locally.sh .idea vote_backups +scripts/reviews_data.json +docs/superpowers diff --git a/Cargo.toml b/Cargo.toml index 88aea3a..1be9b40 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,10 @@ name = "alertaemcena" version = "0.1.0" edition = "2021" +[[bin]] +name = "backfill_reviews" +path = "scripts/backfill_reviews.rs" + [dependencies] # Rust++ voca_rs = "1.15.2" diff --git a/scripts/backfill_reviews.rs b/scripts/backfill_reviews.rs new file mode 100644 index 0000000..722c1c3 --- /dev/null +++ b/scripts/backfill_reviews.rs @@ -0,0 +1,71 @@ +use alertaemcena::config::env_loader::load_config; +use alertaemcena::discord::api::DiscordAPI; +use alertaemcena::tracing::setup_tracing; +use serde::Deserialize; +use serenity::all::UserId; +use std::fs; +use tracing::{error, info, info_span, warn, Instrument}; + +#[derive(Deserialize)] +struct ReviewRecord { + url: String, + rating: u8, + comment: String, +} + +#[tokio::main] +async fn main() { + let tracing_handles = setup_tracing().await; + + let user_id: UserId = std::env::var("BACKFILL_USER_ID") + .expect("BACKFILL_USER_ID not set") + .parse() + .expect("BACKFILL_USER_ID must be a valid Discord user ID"); + + let input_path = std::env::args() + .nth(1) + .unwrap_or_else(|| "scripts/reviews_data.json".to_string()); + + let raw = fs::read_to_string(&input_path) + .unwrap_or_else(|e| panic!("Failed to read '{}': {}", input_path, e)); + let records: Vec = + serde_json::from_str(&raw).expect("Failed to parse input JSON"); + + let config = load_config(); + let discord = DiscordAPI::default().await; + let channel_ids = [config.teatro_channel_id, config.artes_channel_id]; + let total = records.len(); + + async { + for record in records { + if !(1..=5).contains(&record.rating) { + warn!( + "Skipping '{}': invalid rating {}", + record.url, record.rating + ); + continue; + } + + let comment = record.comment.trim(); + let comment = if comment.is_empty() { + None + } else { + Some(comment) + }; + let vote_emoji = &config.voting_emojis[(record.rating - 1) as usize]; + + match discord + .send_backfill_review(user_id, &record.url, vote_emoji, comment, &channel_ids) + .await + { + Ok(true) => info!("Sent review for {}", record.url), + Ok(false) => warn!("Skipped (already sent or event not found): {}", record.url), + Err(_) => error!("Failed to send review for {}", record.url), + } + } + } + .instrument(info_span!("backfill_reviews", user_id = %user_id, total)) + .await; + + tracing_handles.shutdown().await; +} diff --git a/src/discord/api.rs b/src/discord/api.rs index faa2f89..4929076 100644 --- a/src/discord/api.rs +++ b/src/discord/api.rs @@ -1,6 +1,6 @@ use crate::agenda_cultural::model::Event; use crate::config::model::EmojiConfig; -use crate::metrics::{record_dm_review_sent, MetricResult}; +use crate::metrics::{record_dm_review_rewrite, record_dm_review_sent, MetricResult}; use chrono::{Datelike, NaiveDate}; use futures::{StreamExt, TryStreamExt}; use itertools::Itertools; @@ -10,7 +10,7 @@ use serenity::all::ReactionType::{Custom, Unicode}; use serenity::all::{ AutoArchiveDuration, ChannelType, Colour, CreateEmbedAuthor, CreateThread, CurrentUser, EditThread, Embed, GatewayIntents, GetMessages, GuildChannel, Message, MessageId, - MessageReaction, PartialGuild, PrivateChannel, ReactionType, User, UserId, + MessageReaction, MessageType, PartialGuild, PrivateChannel, ReactionType, User, UserId, }; use serenity::builder::{CreateEmbed, CreateMessage, EditMessage}; use serenity::cache::Settings; @@ -38,6 +38,7 @@ const PORTUGUESE_MONTHS: [&str; 12] = [ ]; const CHILDREN_LABEL: &str = "🧸 para crianças"; +const PROCESSED_COMMENT_EMOJI: char = '✅'; lazy_static! { static ref USER_MENTION_REGEX: Regex = @@ -205,7 +206,12 @@ impl DiscordAPI { } } - pub async fn tag_save_for_later_reactions(&self, message: &mut Message, emoji_char: char) { + /// Returns whether this call resulted in the message being newly pinned. + pub async fn tag_save_for_later_reactions( + &self, + message: &mut Message, + emoji_char: char, + ) -> bool { let save_for_later_reaction = ReactionType::from(emoji_char); // Is empty ensures no one has ever saved for later, @@ -215,7 +221,7 @@ impl DiscordAPI { && Self::has_no_user_emoji_reaction(message, &emoji_char.to_string()) { trace!("No user has ever saved for later"); - return; + return false; } let saved_for_later_user_ids: Vec = match message @@ -229,13 +235,13 @@ impl DiscordAPI { .collect(), Err(e) => { error!("Failed to get save-for-later reaction users: {}", e); - return; + return false; } }; if saved_for_later_user_ids.is_empty() && message.content.is_empty() { trace!("No users saved for later"); - return; + return false; } let mentions = saved_for_later_user_ids @@ -245,6 +251,8 @@ impl DiscordAPI { .join(" "); let message_content = format!("Interessados: {}", mentions); + let mut newly_pinned = false; + if saved_for_later_user_ids.is_empty() && message.pinned { if let Err(e) = message.unpin(&self.client.http).await { error!("Failed to unpin message {}: {}", message.id, e); @@ -252,14 +260,15 @@ impl DiscordAPI { } if !saved_for_later_user_ids.is_empty() && !message.pinned { - if let Err(e) = message.pin(&self.client.http).await { - error!("Failed to pin message {}: {}", message.id, e); + match message.pin(&self.client.http).await { + Ok(_) => newly_pinned = true, + Err(e) => error!("Failed to pin message {}: {}", message.id, e), } } if message_content.trim() == message.content.trim() { trace!("No new users saved for later"); - return; + return newly_pinned; } info!("Saved for later changed to '{}'", mentions); @@ -276,6 +285,59 @@ impl DiscordAPI { message.id, e ); } + + newly_pinned + } + + /// Deletes the "X pinned a message" system message(s) left behind after pinning, + /// for a thread where `pin_count` pins were performed in this run. + pub async fn delete_pin_notifications(&self, channel_id: ChannelId, pin_count: usize) { + if pin_count == 0 { + return; + } + + let mut pin_notifications = self.find_pin_notifications(channel_id, pin_count).await; + + if pin_notifications.is_empty() { + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + pin_notifications = self.find_pin_notifications(channel_id, pin_count).await; + } + + if pin_notifications.is_empty() { + warn!( + "Could not find pin notification message(s) in channel {} after retry, ignoring", + channel_id + ); + return; + } + + for message in pin_notifications { + if let Err(e) = message.delete(&self.client.http).await { + error!( + "Failed to delete pin notification message {}: {}", + message.id, e + ); + } + } + } + + async fn find_pin_notifications(&self, channel_id: ChannelId, limit: usize) -> Vec { + match channel_id + .messages(&self.client.http, GetMessages::new().limit(limit as u8)) + .await + { + Ok(messages) => messages + .into_iter() + .filter(|m| m.kind == MessageType::PinsAdd) + .collect(), + Err(e) => { + error!( + "Failed to fetch messages from channel {} to find pin notification: {}", + channel_id, e + ); + Vec::new() + } + } } pub async fn send_privately_users_review( @@ -422,6 +484,16 @@ impl DiscordAPI { } } + fn message_has_bot_reaction(reactions: &[MessageReaction], emoji_char: &str) -> bool { + reactions.iter().any(|reaction| { + if let Unicode(char) = &reaction.reaction_type { + *char == emoji_char && reaction.me + } else { + false + } + }) + } + fn has_no_user_emoji_reaction(event_message: &Message, emoji_char: &str) -> bool { let reaction = event_message.reactions.iter().find(|reaction| { if let Unicode(char) = &reaction.reaction_type { @@ -466,7 +538,11 @@ impl DiscordAPI { let comment = self.get_user_last_comment(dm).await; - let embed = Self::create_user_review_embed(vote_emoji, event_embed, &comment); + let embed = Self::create_user_review_embed( + vote_emoji, + event_embed, + comment.as_ref().map(|m| m.content.as_str()), + ); match dm .send_message(&self.client.http, CreateMessage::new().embed(embed)) @@ -475,7 +551,8 @@ impl DiscordAPI { Ok(_) => { record_dm_review_sent(MetricResult::Ok); if let Some(comment) = comment { - self.add_reaction_to_message(&comment, '✅').await; + self.add_reaction_to_message(&comment, PROCESSED_COMMENT_EMOJI) + .await; } } Err(e) => { @@ -488,14 +565,238 @@ impl DiscordAPI { fn create_user_review_embed( vote_emoji: &EmojiConfig, event_embed: Embed, - comment: &Option, + comment: Option<&str>, ) -> CreateEmbed { - match &comment { + match comment { None => CreateEmbed::from(event_embed).field("Voto", vote_emoji.to_string(), true), Some(comment) => CreateEmbed::from(event_embed) .field("Voto", vote_emoji.to_string(), true) - .field("Comentários", &comment.content, true), + .field("Comentários", comment, true), + } + } + + /// Sends a review DM identical in format to `send_user_review_in_dm`, but with an + /// explicit comment string instead of reading the user's last DM message. + /// Used to backfill historical reviews that predate (or bypassed) the normal + /// reaction -> DM flow. + pub async fn send_backfill_review( + &self, + user_id: UserId, + event_url: &str, + vote_emoji: &EmojiConfig, + comment: Option<&str>, + channel_ids: &[ChannelId], + ) -> Result { + let mut event_embed = None; + + for channel_id in channel_ids { + if let Some(found) = self.find_event_embed(*channel_id, event_url).await { + event_embed = Some(found); + break; + } + } + + let Some(event_embed) = event_embed else { + warn!("Could not find event message for url '{}'", event_url); + return Ok(false); + }; + + let dm = match user_id.create_dm_channel(&self.client.http).await { + Ok(dm) => dm, + Err(e) => { + error!("Couldn't create DM channel for user '{}': {}", user_id, e); + return Err(()); + } + }; + + match self.is_event_sent_in_dm(event_url, &dm).await { + Ok(true) => { + warn!("Event already sent to user {}", user_id); + return Ok(false); + } + Err(_) => return Err(()), + Ok(false) => {} + } + + let embed = Self::create_user_review_embed(vote_emoji, event_embed, comment); + + match dm + .send_message(&self.client.http, CreateMessage::new().embed(embed)) + .await + { + Ok(_) => { + record_dm_review_sent(MetricResult::Ok); + info!("Backfilled review for event '{}'", event_url); + Ok(true) + } + Err(e) => { + record_dm_review_sent(MetricResult::Error); + error!("Failed to send backfill review DM to {}: {}", user_id, e); + Err(()) + } + } + } + + pub async fn rewrite_reviews_from_dm_replies(&self, user_id: UserId) -> usize { + let dm = match user_id.create_dm_channel(&self.client.http).await { + Ok(dm) => dm, + Err(e) => { + warn!("Couldn't create DM channel for user '{}': {}", user_id, e); + return 0; + } + }; + + let mut messages = match self.fetch_all_dm_messages(&dm).await { + Ok(messages) => messages, + Err(_) => return 0, + }; + + // Snowflake IDs are chronologically ordered; sort ascending so the latest + // reply to any given review embed is always processed last. + messages.sort_by_key(|message| message.id); + + let mut rewritten_count = 0; + + for reply in &messages { + if !Self::is_message_a_rewrite_request(self.own_user.id, reply) { + continue; + } + + if self.rewrite_review_from_reply(&dm, reply).await { + rewritten_count += 1; + } + } + + rewritten_count + } + + fn is_message_a_rewrite_request(own_user_id: UserId, reply: &Message) -> bool { + let is_a_user_message = reply.author.id != own_user_id; + + is_a_user_message + && reply.referenced_message.as_ref().is_some_and(|referenced| { + let is_a_reply_to_bot_message = referenced.author.id == own_user_id; + let has_vote = referenced + .embeds + .first() + .is_some_and(|embed| embed.fields.iter().any(|field| field.name == "Voto")); + + is_a_reply_to_bot_message && has_vote + }) + && !Self::message_has_bot_reaction( + &reply.reactions, + &PROCESSED_COMMENT_EMOJI.to_string(), + ) + } + + async fn fetch_all_dm_messages( + &self, + dm: &PrivateChannel, + ) -> Result, serenity::Error> { + let mut all_messages = Vec::new(); + let mut last_message_id: Option = None; + + loop { + let mut filter = GetMessages::default(); + + if let Some(id) = last_message_id { + filter = filter.before(id); + } + + let page = dm.messages(&self.client.http, filter).await.map_err(|e| { + error!( + "Failed to fetch DM messages for '{}': {}", + dm.recipient.name, e + ); + e + })?; + + match page.first() { + None => break, + Some(message) => last_message_id = Some(message.id), + } + + all_messages.extend(page); + } + + Ok(all_messages) + } + + async fn rewrite_review_from_reply(&self, dm: &PrivateChannel, reply: &Message) -> bool { + let referenced = reply + .referenced_message + .as_ref() + .expect("should not have landed here"); + + let embed = referenced + .embeds + .first() + .expect("should not have landed here"); + + let voto_value = embed + .fields + .iter() + .find(|field| field.name == "Voto") + .expect("should not have landed here") + .value + .clone(); + + let mut fresh = match self.client.http.get_message(dm.id, referenced.id).await { + Ok(message) => message, + Err(e) => { + error!( + "Failed to refetch review message {} for user {}: {}", + referenced.id, reply.author.id, e + ); + return false; + } + }; + + let Some(mut fresh_embed) = fresh.embeds.first().cloned() else { + return false; + }; + fresh_embed.fields = Vec::new(); + + let new_embed = CreateEmbed::from(fresh_embed) + .field("Voto", voto_value, true) + .field("Comentários", reply.content.clone(), true); + + match fresh + .edit(&self.client.http, EditMessage::new().embed(new_embed)) + .await + { + Ok(_) => { + record_dm_review_rewrite(MetricResult::Ok); + self.add_reaction_to_message(reply, PROCESSED_COMMENT_EMOJI) + .await; + true + } + Err(e) => { + record_dm_review_rewrite(MetricResult::Error); + error!( + "Failed to rewrite review message {} for user {}: {}", + referenced.id, reply.author.id, e + ); + false + } + } + } + + async fn find_event_embed(&self, channel_id: ChannelId, event_url: &str) -> Option { + let guild = self.get_guild(channel_id).await; + let threads = self.get_channel_threads(&guild, channel_id).await; + + for thread in threads { + let messages = self.get_all_messages(thread.id).await; + + if let Some(message) = messages.into_iter().find(|m| { + m.embeds.first().and_then(|e| e.url.clone()).as_deref() == Some(event_url) + }) { + return message.embeds.into_iter().next(); + } } + + None } async fn get_user_last_comment(&self, dm: &PrivateChannel) -> Option { @@ -733,6 +1034,190 @@ impl DiscordAPI { mod tests { use super::*; + const BOT_USER_ID: u64 = 1; + const OTHER_USER_ID: u64 = 2; + + fn build_message(author_id: u64, referenced_message: Option) -> Message { + let json = serde_json::json!({ + "id": "1", + "channel_id": "1", + "author": { "id": author_id.to_string(), "username": "user" }, + "content": "", + "timestamp": "2024-01-01T00:00:00.000000+00:00", + "tts": false, + "mention_everyone": false, + "mentions": [], + "mention_roles": [], + "attachments": [], + "embeds": [], + "pinned": false, + "type": 0, + "referenced_message": referenced_message, + }); + + serde_json::from_value(json).expect("Failed to build test message") + } + + fn build_review_embed(has_vote_field: bool) -> serde_json::Value { + let fields = if has_vote_field { + serde_json::json!([{ "name": "Voto", "value": "🟩", "inline": true }]) + } else { + serde_json::json!([]) + }; + + serde_json::json!({ "fields": fields }) + } + + fn build_reply_to_bot_review( + reply_author_id: u64, + has_vote_field: bool, + already_processed: bool, + ) -> Message { + let mut referenced = build_message(BOT_USER_ID, None); + referenced.embeds = vec![serde_json::from_value(build_review_embed(has_vote_field)) + .expect("Failed to build test embed")]; + + let mut reply = build_message(reply_author_id, None); + reply.referenced_message = Some(Box::new(referenced)); + + if already_processed { + reply.reactions = serde_json::from_str( + r#" + [{ + "count": 1, + "count_details": { "burst": 0, "normal": 1 }, + "me": true, + "me_burst": false, + "emoji": { "id": null, "name": "✅" }, + "burst_colors": [] + }] + "#, + ) + .expect("Failed to build test reaction"); + } + + reply + } + + #[test_log::test] + fn when_reply_is_from_bot_itself_should_return_false() { + let reply = build_reply_to_bot_review(BOT_USER_ID, true, false); + + assert!(!DiscordAPI::is_message_a_rewrite_request( + UserId::from(BOT_USER_ID), + &reply + )); + } + + #[test_log::test] + fn when_message_is_not_a_reply_should_return_false() { + let reply = build_message(OTHER_USER_ID, None); + + assert!(!DiscordAPI::is_message_a_rewrite_request( + UserId::from(BOT_USER_ID), + &reply + )); + } + + #[test_log::test] + fn when_reply_is_not_to_a_bot_message_should_return_false() { + let referenced = build_message(OTHER_USER_ID, None); + let mut reply = build_message(OTHER_USER_ID, None); + reply.referenced_message = Some(Box::new(referenced)); + + assert!(!DiscordAPI::is_message_a_rewrite_request( + UserId::from(BOT_USER_ID), + &reply + )); + } + + #[test_log::test] + fn when_referenced_bot_message_has_no_vote_field_should_return_false() { + let reply = build_reply_to_bot_review(OTHER_USER_ID, false, false); + + assert!(!DiscordAPI::is_message_a_rewrite_request( + UserId::from(BOT_USER_ID), + &reply + )); + } + + #[test_log::test] + fn when_reply_already_processed_should_return_false() { + let reply = build_reply_to_bot_review(OTHER_USER_ID, true, true); + + assert!(!DiscordAPI::is_message_a_rewrite_request( + UserId::from(BOT_USER_ID), + &reply + )); + } + + #[test_log::test] + fn when_reply_is_a_valid_unprocessed_rewrite_request_should_return_true() { + let reply = build_reply_to_bot_review(OTHER_USER_ID, true, false); + + assert!(DiscordAPI::is_message_a_rewrite_request( + UserId::from(BOT_USER_ID), + &reply + )); + } + + #[test_log::test] + fn when_bot_has_reacted_with_emoji_should_return_true() { + let reactions: Vec = serde_json::from_str( + r#" + [{ + "count": 1, + "count_details": { "burst": 0, "normal": 1 }, + "me": true, + "me_burst": false, + "emoji": { "id": null, "name": "✅" }, + "burst_colors": [] + }] + "#, + ) + .unwrap(); + + assert!(DiscordAPI::message_has_bot_reaction(&reactions, "✅")); + } + + #[test_log::test] + fn when_bot_has_not_reacted_with_emoji_should_return_false() { + let reactions: Vec = serde_json::from_str( + r#" + [{ + "count": 1, + "count_details": { "burst": 0, "normal": 1 }, + "me": false, + "me_burst": false, + "emoji": { "id": null, "name": "✅" }, + "burst_colors": [] + }] + "#, + ) + .unwrap(); + + assert!(!DiscordAPI::message_has_bot_reaction(&reactions, "✅")); + } + + #[test_log::test] + fn when_no_matching_emoji_reaction_should_return_false() { + let reactions: Vec = serde_json::from_str( + r#" + [{ + "count": 1, + "count_details": { "burst": 0, "normal": 1 }, + "me": true, + "me_burst": false, + "emoji": { "id": null, "name": "🔖" }, + "burst_colors": [] + }] + "#, + ) + .unwrap(); + + assert!(!DiscordAPI::message_has_bot_reaction(&reactions, "✅")); + } + #[test_log::test] fn when_no_user_has_voted_other_than_bot_should_return_true() { let reaction = serde_json::from_str( diff --git a/src/main.rs b/src/main.rs index 27d1f0c..074496e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -80,6 +80,8 @@ async fn main() { } }); + rewrite_reviews_from_replies(&discord, &users_to_backup).await; + backup_votes(&discord, users_to_backup, &config.voting_emojis).await; info!("Starting app"); } @@ -157,6 +159,20 @@ async fn run( users_with_reactions } +#[instrument(skip(discord, user_ids))] +async fn rewrite_reviews_from_replies(discord: &DiscordAPI, user_ids: &[UserId]) { + for user_id in user_ids { + let rewritten = discord.rewrite_reviews_from_dm_replies(*user_id).await; + + if rewritten > 0 { + info!( + "Rewrote {} review comment(s) from DM replies for user {}", + rewritten, user_id + ); + } + } +} + #[instrument(skip(discord, vote_emojis))] pub async fn backup_votes(discord: &DiscordAPI, vec: Vec, vote_emojis: &[EmojiConfig; 5]) { let backup_started_at = Instant::now(); @@ -293,6 +309,8 @@ async fn handle_reaction_features( messages.len() ); + let mut pin_count = 0usize; + for mut message in messages { if message.author != *discord.own_user { debug!( @@ -315,9 +333,12 @@ async fn handle_reaction_features( continue; } - discord + if discord .tag_save_for_later_reactions(&mut message, *SAVE_FOR_LATER_EMOJI) - .await; + .await + { + pin_count += 1; + } discord .send_privately_users_review(&message, vote_emojis) @@ -329,6 +350,8 @@ async fn handle_reaction_features( } }); } + + discord.delete_pin_notifications(thread.id, pin_count).await; } .instrument(thread_span) .await; diff --git a/src/metrics.rs b/src/metrics.rs index 0332716..a4bacfc 100644 --- a/src/metrics.rs +++ b/src/metrics.rs @@ -144,6 +144,10 @@ lazy_static! { .u64_counter("aec_dm_review_sent_total") .with_description("Total DM review send attempts") .init(); + static ref DM_REVIEW_REWRITE_TOTAL: Counter = METER + .u64_counter("aec_dm_review_rewrite_total") + .with_description("Total DM review rewrite attempts") + .init(); static ref VOTE_BACKUP_RECORDS_TOTAL: Counter = METER .u64_counter("aec_vote_backup_records_total") .with_description("Total vote records written to backups") @@ -196,6 +200,10 @@ pub fn record_dm_review_sent(result: MetricResult) { DM_REVIEW_SENT_TOTAL.add(1, &[result.into()]); } +pub fn record_dm_review_rewrite(result: MetricResult) { + DM_REVIEW_REWRITE_TOTAL.add(1, &[result.into()]); +} + pub fn record_vote_backup_records(count: u64) { VOTE_BACKUP_RECORDS_TOTAL.add(count, &[]); } diff --git a/tests/discord_api_tests.rs b/tests/discord_api_tests.rs index a662da7..3b999a3 100644 --- a/tests/discord_api_tests.rs +++ b/tests/discord_api_tests.rs @@ -181,6 +181,37 @@ mod discord { assert!(!message.pinned); } + #[test_log::test(tokio::test)] + async fn should_delete_pin_notification_after_pinning() { + let api = build_api().await; + let (thread_id, _, mut message) = + send_random_event(&api, "should_delete_pin_notification_after_pinning").await; + + let tester_api = build_tester_api().await; + + tester_api + .add_reaction_to_message(&message, *SAVE_FOR_LATER_EMOJI) + .await; + + let pinned = api + .tag_save_for_later_reactions(&mut message, *SAVE_FOR_LATER_EMOJI) + .await; + + assert!(pinned); + + api.delete_pin_notifications(thread_id, 1).await; + + let pin_notifications = thread_id + .messages(&api.client.http, serenity::all::GetMessages::new().limit(1)) + .await + .expect("Failed to fetch thread messages") + .into_iter() + .filter(|msg| msg.kind == serenity::all::MessageType::PinsAdd) + .count(); + + assert_eq!(pin_notifications, 0); + } + #[test_log::test(tokio::test)] async fn should_send_the_voted_event_message_via_dm_only_once() { let api = build_api().await; @@ -204,6 +235,39 @@ mod discord { .await; } + #[test_log::test(tokio::test)] + async fn should_rewrite_review_comment_from_dm_reply() { + let api = build_api().await; + let (_, _, message) = + send_random_event(&api, "should_rewrite_review_comment_from_dm_reply").await; + + let voting_emojis = load_voting_emojis_config("VOTING_EMOJIS"); + + add_feature_reactions(&api, &message, &voting_emojis, *SAVE_FOR_LATER_EMOJI).await; + + let tester_api = build_tester_api().await; + + tester_api + .add_custom_reaction(&message, &voting_emojis[3]) + .await; + + // allows manual testing - bots can't vote on each other + tokio::time::sleep(Duration::from_secs(10)).await; + + api.send_privately_users_review(&message, &voting_emojis) + .await; + + // allows manual testing - bots can't vote on each other + tokio::time::sleep(Duration::from_secs(15)).await; + + // No asserts possible: bots can't DM other bots, so send_privately_users_review + // only DMs real (non-bot) users who reacted - none in CI. Manually verifiable: + // react as a real account, reply to the review DM, rerun, and check the + // Comentários field on the review embed was rewritten with the reply's content. + api.rewrite_reviews_from_dm_replies(serenity::all::UserId::from(*user_id)) + .await; + } + #[test_log::test(tokio::test)] async fn should_get_threads_of_only_the_specified_channel() { let api = build_api().await; diff --git a/tests/metrics_label_tests.rs b/tests/metrics_label_tests.rs index 0f5de3d..2c9479f 100644 --- a/tests/metrics_label_tests.rs +++ b/tests/metrics_label_tests.rs @@ -1,6 +1,7 @@ use alertaemcena::agenda_cultural::model::Category; use alertaemcena::metrics::{ - record_get_events_by_month_duration, MetricResult, PipelineErrorKind, PipelineStage, + record_dm_review_rewrite, record_get_events_by_month_duration, MetricResult, PipelineErrorKind, + PipelineStage, }; use opentelemetry::KeyValue; use std::time::Duration; @@ -41,3 +42,9 @@ fn should_convert_metric_dimensions_into_key_value() { fn should_record_get_events_by_month_duration_metric() { record_get_events_by_month_duration(&Category::Teatro, Duration::from_millis(250)); } + +#[test] +fn should_record_dm_review_rewrite_metric() { + record_dm_review_rewrite(MetricResult::Ok); + record_dm_review_rewrite(MetricResult::Error); +}