@@ -6,7 +6,7 @@ use crate::command::playmatch::{
66} ;
77use lazy_static:: lazy_static;
88use log:: { info, warn} ;
9- use playmatch_client:: types:: Suggestion ;
9+ use playmatch_client:: types:: { ExternalMetadata , MetadataMatchType , MetadataProvider , Suggestion } ;
1010use 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.
125127async 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+
160285fn spawn_new_post (
161286 ctx : Context ,
162287 data : Arc < CommandData > ,
0 commit comments