Skip to content

Commit f60a6ae

Browse files
committed
feat(suggestions): add cleanup command and poller sweep for redundant suggestions
1 parent 0c1c6de commit f60a6ae

2 files changed

Lines changed: 182 additions & 9 deletions

File tree

src/command/playmatch.rs

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,8 @@ pub async fn r#match(_: CommandContext<'_>) -> CommandResult {
5656
subcommands(
5757
"create_platform_suggestion",
5858
"create_company_suggestion",
59-
"create_game_suggestion"
59+
"create_game_suggestion",
60+
"cleanup_suggestions"
6061
)
6162
)]
6263
pub async fn suggest(_: CommandContext<'_>) -> CommandResult {
@@ -616,6 +617,53 @@ pub async fn create_platform_suggestion(
616617
Ok(())
617618
}
618619

620+
/// Dismisses pending game suggestions whose game is already mapped to that provider.
621+
// Handles the race where an external tool like RomM matches a game after the
622+
// suggestion card has already been posted to Discord.
623+
#[poise::command(
624+
slash_command,
625+
category = "Playmatch",
626+
rename = "cleanup",
627+
check = "is_user_trusted_or_above"
628+
)]
629+
pub async fn cleanup_suggestions(ctx: CommandContext<'_>) -> CommandResult {
630+
ctx.defer().await?;
631+
632+
let data = ctx.data();
633+
let http = ctx.serenity_context().http.clone();
634+
let pending = data.playmatch_client.get_all_suggestions().await?;
635+
let report = crate::events::suggestion_poller::sweep_redundant_suggestions(
636+
http.as_ref(),
637+
&data,
638+
&pending,
639+
)
640+
.await;
641+
642+
let mut card = Card::new(Status::Success, "Suggestion Cleanup".to_string())
643+
.row("Checked", format!("`{}`", report.checked))
644+
.row("Cleaned", format!("`{}`", report.cleaned.len()));
645+
646+
if !report.cleaned.is_empty() {
647+
const PREVIEW_MAX: usize = 10;
648+
let preview = report
649+
.cleaned
650+
.iter()
651+
.take(PREVIEW_MAX)
652+
.map(|u| format!("`{u}`"))
653+
.collect::<Vec<_>>()
654+
.join("\n");
655+
card = card.section("Cleaned Suggestions").text(preview);
656+
if report.cleaned.len() > PREVIEW_MAX {
657+
let extra = report.cleaned.len() - PREVIEW_MAX;
658+
card = card.text(format!("-# …and {extra} more not shown"));
659+
}
660+
}
661+
662+
ctx.send(card.into_reply()).await?;
663+
664+
Ok(())
665+
}
666+
619667
/// Manually matches a Platform by name.
620668
#[poise::command(slash_command, category = "Playmatch", rename = "platform", check = is_user_trusted_or_above)]
621669
pub async fn manual_match_platform(

src/events/suggestion_poller.rs

Lines changed: 133 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ use crate::command::playmatch::{
66
};
77
use lazy_static::lazy_static;
88
use log::{info, warn};
9-
use playmatch_client::types::Suggestion;
9+
use playmatch_client::types::{ExternalMetadata, MetadataMatchType, MetadataProvider, Suggestion};
1010
use serenity::all::{
1111
ActionRowComponent, ButtonKind, ChannelId, Component, ContainerComponent, Context, GetMessages,
1212
Http, Message, MessageId, TeamMemberRole, UserId,
@@ -119,17 +119,32 @@ async fn seed_from_channel_if_needed(http: &Http, channel_id: ChannelId, data: &
119119
);
120120
}
121121

122-
/// Compare playmatch's pending queue against Redis. Post anything new, edit anything
123-
/// that's been resolved outside Discord. Does not spawn collectors for new posts: the
124-
/// `handle_suggestion_message` call wraps both the post and the collector wait.
122+
/// Compare playmatch's pending queue against Redis. First tears down any suggestions
123+
/// whose target game already has an active mapping for the suggested provider, then
124+
/// posts anything new and edits anything that's been resolved outside Discord. Does
125+
/// not spawn collectors for new posts: the `handle_suggestion_message` call wraps
126+
/// both the post and the collector wait.
125127
async fn reconcile(
126128
ctx: &Context,
127129
data: &Arc<CommandData>,
128130
owners: &HashSet<UserId>,
129131
) -> anyhow::Result<()> {
132+
let http = ctx.http.clone();
130133
let pending = data.playmatch_client.get_all_suggestions().await?;
131-
let pending_external: Vec<Suggestion> =
132-
pending.into_iter().filter(|s| s.source.is_some()).collect();
134+
135+
let cleanup = sweep_redundant_suggestions(&http, data, &pending).await;
136+
if !cleanup.cleaned.is_empty() {
137+
info!(
138+
"suggestion poller: cleaned up {} redundant suggestion(s) during reconcile",
139+
cleanup.cleaned.len()
140+
);
141+
}
142+
let cleaned: HashSet<Uuid> = cleanup.cleaned.iter().copied().collect();
143+
144+
let pending_external: Vec<Suggestion> = pending
145+
.into_iter()
146+
.filter(|s| s.source.is_some() && !cleaned.contains(&s.id))
147+
.collect();
133148
let pending_uuids: HashSet<Uuid> = pending_external.iter().map(|s| s.id).collect();
134149

135150
let posted = data.suggestion_store.list_all().await?;
@@ -141,9 +156,8 @@ async fn reconcile(
141156
spawn_new_post(ctx.clone(), data.clone(), owners.clone(), s);
142157
}
143158

144-
let http = ctx.http.clone();
145159
for (uuid, msg_id) in &posted {
146-
if pending_uuids.contains(uuid) {
160+
if pending_uuids.contains(uuid) || cleaned.contains(uuid) {
147161
continue;
148162
}
149163
if let Err(e) = mark_external_resolved(&http, *msg_id).await {
@@ -157,6 +171,117 @@ async fn reconcile(
157171
Ok(())
158172
}
159173

174+
/// Outcome of one sweep pass: how many game suggestions were inspected and which
175+
/// ones got torn down because the target game already had an active mapping for
176+
/// the suggested provider.
177+
pub struct CleanupReport {
178+
pub checked: usize,
179+
pub cleaned: Vec<Uuid>,
180+
}
181+
182+
/// Tear down any game-mapping suggestions in `pending` whose target game already
183+
/// has an `Automatic` or `Manual` mapping for the suggested provider. For each
184+
/// redundant suggestion: delete it on playmatch, remove the Redis entry, and edit
185+
/// the Discord card to the "resolved externally" state.
186+
///
187+
/// Game lookups are cached so the same `game_id` only costs one round trip per
188+
/// sweep. Individual failures are logged and skipped so one bad suggestion does
189+
/// not abort the rest.
190+
pub async fn sweep_redundant_suggestions(
191+
http: &Http,
192+
data: &CommandData,
193+
pending: &[Suggestion],
194+
) -> CleanupReport {
195+
let game_suggestions: Vec<&Suggestion> =
196+
pending.iter().filter(|s| s.game_id.is_some()).collect();
197+
let checked = game_suggestions.len();
198+
let mut game_cache: HashMap<Uuid, Vec<ExternalMetadata>> = HashMap::new();
199+
let mut cleaned: Vec<Uuid> = Vec::new();
200+
201+
for suggestion in game_suggestions {
202+
let game_id = match suggestion.game_id {
203+
Some(id) => id,
204+
None => continue,
205+
};
206+
207+
let already_active = match game_cache.get(&game_id) {
208+
Some(metas) => has_active_provider_match(metas, suggestion.provider),
209+
None => match data
210+
.playmatch_client
211+
.get_playmatch_game_with_relations_by_id(game_id)
212+
.await
213+
{
214+
Ok(rel) => {
215+
let active =
216+
has_active_provider_match(&rel.external_metadata, suggestion.provider);
217+
game_cache.insert(game_id, rel.external_metadata);
218+
active
219+
}
220+
Err(e) => {
221+
warn!(
222+
"suggestion cleanup: fetch game {game_id} for suggestion {} failed: {e}",
223+
suggestion.id
224+
);
225+
continue;
226+
}
227+
},
228+
};
229+
230+
if !already_active {
231+
continue;
232+
}
233+
234+
// Playmatch is the source of truth, tear that down first.
235+
if let Err(e) = data.playmatch_client.delete_suggestion(suggestion.id).await {
236+
warn!(
237+
"suggestion cleanup: delete_suggestion({}) failed: {e}",
238+
suggestion.id
239+
);
240+
continue;
241+
}
242+
243+
match data.suggestion_store.get(suggestion.id).await {
244+
Ok(Some(msg_id)) => {
245+
if let Err(e) = mark_external_resolved(http, msg_id).await {
246+
warn!(
247+
"suggestion cleanup: edit message {msg_id} for {} failed: {e}",
248+
suggestion.id
249+
);
250+
}
251+
if let Err(e) = data.suggestion_store.remove(suggestion.id).await {
252+
warn!(
253+
"suggestion cleanup: redis remove {} failed: {e}",
254+
suggestion.id
255+
);
256+
}
257+
}
258+
Ok(None) => {}
259+
Err(e) => warn!(
260+
"suggestion cleanup: redis lookup {} failed: {e}",
261+
suggestion.id
262+
),
263+
}
264+
265+
info!(
266+
"suggestion cleanup: dismissed {} (game {game_id}, provider {})",
267+
suggestion.id, suggestion.provider
268+
);
269+
cleaned.push(suggestion.id);
270+
}
271+
272+
CleanupReport { checked, cleaned }
273+
}
274+
275+
fn has_active_provider_match(metas: &[ExternalMetadata], provider: MetadataProvider) -> bool {
276+
metas.iter().any(|m| {
277+
m.provider_name == provider
278+
&& matches!(
279+
m.match_type,
280+
MetadataMatchType::Automatic | MetadataMatchType::Manual
281+
)
282+
})
283+
}
284+
160285
fn spawn_new_post(
161286
ctx: Context,
162287
data: Arc<CommandData>,

0 commit comments

Comments
 (0)