From 12d0c6533d209cf19dcd0baf221c052658bc641f Mon Sep 17 00:00:00 2001 From: davidgomesdev <10091092+davidgomesdev@users.noreply.github.com> Date: Wed, 24 Jun 2026 19:42:03 +0100 Subject: [PATCH 1/6] feat: remove "user has pinned" discord message --- src/discord/api.rs | 77 +++++++++++++++++++++++++++++++++++++++++----- src/main.rs | 13 ++++++-- 2 files changed, 80 insertions(+), 10 deletions(-) diff --git a/src/discord/api.rs b/src/discord/api.rs index faa2f89..9c4ad84 100644 --- a/src/discord/api.rs +++ b/src/discord/api.rs @@ -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; @@ -205,7 +205,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 +220,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 +234,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 +250,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 +259,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 +284,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( diff --git a/src/main.rs b/src/main.rs index 27d1f0c..b4ab578 100644 --- a/src/main.rs +++ b/src/main.rs @@ -293,6 +293,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 +317,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 +334,10 @@ async fn handle_reaction_features( } }); } + + discord + .delete_pin_notifications(thread.id, pin_count) + .await; } .instrument(thread_span) .await; From 7d2dcadad1a00d1201d00914ccb9b3fc52507acf Mon Sep 17 00:00:00 2001 From: davidgomesdev <10091092+davidgomesdev@users.noreply.github.com> Date: Wed, 24 Jun 2026 19:58:08 +0100 Subject: [PATCH 2/6] feat: add backfill for reviews --- .github/workflows/build.yaml | 1 + .github/workflows/release.yaml | 10 +++- .gitignore | 1 + Cargo.toml | 4 ++ scripts/backfill_reviews.rs | 64 ++++++++++++++++++++++++ src/discord/api.rs | 91 ++++++++++++++++++++++++++++++++-- 6 files changed, 166 insertions(+), 5 deletions(-) create mode 100644 scripts/backfill_reviews.rs 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..654b8c2 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,4 @@ test-locally.sh run-locally.sh .idea vote_backups +scripts/reviews_data.json 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..2c3141a --- /dev/null +++ b/scripts/backfill_reviews.rs @@ -0,0 +1,64 @@ +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 9c4ad84..5654f49 100644 --- a/src/discord/api.rs +++ b/src/discord/api.rs @@ -527,7 +527,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)) @@ -549,14 +553,93 @@ 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(()) + } + } + } + + 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 { From bd843f0f3df6ce5d60912ec2a01e7b667cfe3acb Mon Sep 17 00:00:00 2001 From: davidgomesdev <10091092+davidgomesdev@users.noreply.github.com> Date: Wed, 24 Jun 2026 20:05:47 +0100 Subject: [PATCH 3/6] chore: add superpowers to gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 654b8c2..f06c90e 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,4 @@ run-locally.sh .idea vote_backups scripts/reviews_data.json +docs/superpowers From fb82f50948947b987bc8f1eb2d72bd3cef317444 Mon Sep 17 00:00:00 2001 From: davidgomesdev <10091092+davidgomesdev@users.noreply.github.com> Date: Wed, 24 Jun 2026 20:06:00 +0100 Subject: [PATCH 4/6] test: add test for the delete pin notif --- tests/discord_api_tests.rs | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tests/discord_api_tests.rs b/tests/discord_api_tests.rs index a662da7..6943c95 100644 --- a/tests/discord_api_tests.rs +++ b/tests/discord_api_tests.rs @@ -181,6 +181,40 @@ 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; From f7e2d0deaa14b258db8f03442489cd8bbea2efa3 Mon Sep 17 00:00:00 2001 From: davidgomesdev <10091092+davidgomesdev@users.noreply.github.com> Date: Wed, 24 Jun 2026 20:11:11 +0100 Subject: [PATCH 5/6] feat: add review edit reply --- scripts/backfill_reviews.rs | 11 +- src/discord/api.rs | 345 ++++++++++++++++++++++++++++++++++- src/main.rs | 20 +- src/metrics.rs | 8 + tests/discord_api_tests.rs | 86 ++++++++- tests/metrics_label_tests.rs | 9 +- 6 files changed, 466 insertions(+), 13 deletions(-) diff --git a/scripts/backfill_reviews.rs b/scripts/backfill_reviews.rs index 2c3141a..722c1c3 100644 --- a/scripts/backfill_reviews.rs +++ b/scripts/backfill_reviews.rs @@ -39,12 +39,19 @@ async fn main() { async { for record in records { if !(1..=5).contains(&record.rating) { - warn!("Skipping '{}': invalid rating {}", record.url, 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 comment = if comment.is_empty() { + None + } else { + Some(comment) + }; let vote_emoji = &config.voting_emojis[(record.rating - 1) as usize]; match discord diff --git a/src/discord/api.rs b/src/discord/api.rs index 5654f49..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; @@ -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 = @@ -483,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 { @@ -540,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) => { @@ -625,6 +637,151 @@ impl DiscordAPI { } } + 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; @@ -877,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 b4ab578..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(); @@ -335,9 +351,7 @@ async fn handle_reaction_features( }); } - discord - .delete_pin_notifications(thread.id, pin_count) - .await; + 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 6943c95..ad0e0f3 100644 --- a/tests/discord_api_tests.rs +++ b/tests/discord_api_tests.rs @@ -184,11 +184,8 @@ mod discord { #[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 (thread_id, _, mut message) = + send_random_event(&api, "should_delete_pin_notification_after_pinning").await; let tester_api = build_tester_api().await; @@ -238,6 +235,85 @@ 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(5)).await; + + api.send_privately_users_review(&message, &voting_emojis) + .await; + + let tester_dm = api + .own_user + .id + .create_dm_channel(&tester_api.client.http) + .await + .expect("Tester failed to open DM channel with bot"); + + let review_message = tester_dm + .messages(&tester_api.client.http, serenity::all::GetMessages::new()) + .await + .expect("Tester failed to fetch DM messages") + .into_iter() + .find(|msg| { + msg.embeds + .first() + .map(|embed| embed.fields.iter().any(|field| field.name == "Voto")) + .unwrap_or(false) + }) + .expect("Could not find review message in tester's DM"); + + let new_comment = format!("Updated comment via reply - {}", uuid::Uuid::new_v4()); + + review_message + .reply(&tester_api.client.http, new_comment.clone()) + .await + .expect("Tester failed to reply to review message"); + + let user_discord_id = serenity::all::UserId::from(*user_id); + + let rewritten = api.rewrite_reviews_from_dm_replies(user_discord_id).await; + + assert_eq!(rewritten, 1); + + let bot_dm = user_discord_id + .create_dm_channel(&api.client.http) + .await + .expect("Bot failed to open DM channel with tester"); + + let updated_message = api + .client + .http + .get_message(bot_dm.id, review_message.id) + .await + .expect("Failed to refetch review message"); + + let comentarios_field = updated_message + .embeds + .first() + .expect("Updated message lost its embed") + .fields + .iter() + .find(|field| field.name == "Comentários") + .expect("Updated embed has no Comentários field"); + + assert_eq!(comentarios_field.value, new_comment); + } + #[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); +} From 4daaa8ca4892bb4c7ccd978e7d2e24a85f9915f8 Mon Sep 17 00:00:00 2001 From: davidgomesdev <10091092+davidgomesdev@users.noreply.github.com> Date: Wed, 24 Jun 2026 20:47:16 +0100 Subject: [PATCH 6/6] test: fix review-rewrite test to avoid bot-to-bot DM in CI --- tests/discord_api_tests.rs | 64 ++++++-------------------------------- 1 file changed, 9 insertions(+), 55 deletions(-) diff --git a/tests/discord_api_tests.rs b/tests/discord_api_tests.rs index ad0e0f3..3b999a3 100644 --- a/tests/discord_api_tests.rs +++ b/tests/discord_api_tests.rs @@ -252,66 +252,20 @@ mod discord { .await; // allows manual testing - bots can't vote on each other - tokio::time::sleep(Duration::from_secs(5)).await; + tokio::time::sleep(Duration::from_secs(10)).await; api.send_privately_users_review(&message, &voting_emojis) .await; - let tester_dm = api - .own_user - .id - .create_dm_channel(&tester_api.client.http) - .await - .expect("Tester failed to open DM channel with bot"); - - let review_message = tester_dm - .messages(&tester_api.client.http, serenity::all::GetMessages::new()) - .await - .expect("Tester failed to fetch DM messages") - .into_iter() - .find(|msg| { - msg.embeds - .first() - .map(|embed| embed.fields.iter().any(|field| field.name == "Voto")) - .unwrap_or(false) - }) - .expect("Could not find review message in tester's DM"); - - let new_comment = format!("Updated comment via reply - {}", uuid::Uuid::new_v4()); - - review_message - .reply(&tester_api.client.http, new_comment.clone()) - .await - .expect("Tester failed to reply to review message"); - - let user_discord_id = serenity::all::UserId::from(*user_id); - - let rewritten = api.rewrite_reviews_from_dm_replies(user_discord_id).await; - - assert_eq!(rewritten, 1); - - let bot_dm = user_discord_id - .create_dm_channel(&api.client.http) - .await - .expect("Bot failed to open DM channel with tester"); + // allows manual testing - bots can't vote on each other + tokio::time::sleep(Duration::from_secs(15)).await; - let updated_message = api - .client - .http - .get_message(bot_dm.id, review_message.id) - .await - .expect("Failed to refetch review message"); - - let comentarios_field = updated_message - .embeds - .first() - .expect("Updated message lost its embed") - .fields - .iter() - .find(|field| field.name == "Comentários") - .expect("Updated embed has no Comentários field"); - - assert_eq!(comentarios_field.value, new_comment); + // 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)]