fix concurrency and ack
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
2025-09-05 12:49:37 +02:00
parent 5015e82700
commit 0c575378f3
6 changed files with 146 additions and 84 deletions

View File

@@ -8,6 +8,7 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.system.ApplicationHome;
import org.springframework.context.ConfigurableApplicationContext;
import wtf.beatrice.hidekobot.commands.completer.ProfileImageCommandCompleter;
import wtf.beatrice.hidekobot.commands.message.HelloCommand;
@@ -68,6 +69,8 @@ public class HidekoBot
return;
}
ApplicationHome home = new ApplicationHome(HidekoBot.class);
System.setProperty("APP_HOME", home.getDir().getAbsolutePath());
ConfigurableApplicationContext context = SpringApplication.run(HidekoBot.class, args);
CommandService commandService = context.getBean(CommandService.class);

View File

@@ -9,6 +9,7 @@ import net.dv8tion.jda.api.interactions.components.buttons.Button;
import wtf.beatrice.hidekobot.Cache;
import wtf.beatrice.hidekobot.util.RandomUtil;
import java.util.ArrayList;
import java.util.List;
public class CoinFlip
@@ -43,27 +44,46 @@ public class CoinFlip
public static void buttonReFlip(ButtonInteractionEvent event)
{
// check if the user interacting is the same one who ran the command
if (!(Cache.getServices().databaseService().isUserTrackedFor(event.getUser().getId(), event.getMessageId())))
{
event.reply("❌ You did not run this command!").setEphemeral(true).queue();
return;
}
// Ack ASAP to avoid 3s timeout
event.deferEdit().queue(hook -> {
// Permission check **after** ack
if (!Cache.getServices().databaseService().isUserTrackedFor(event.getUser().getId(), event.getMessageId()))
{
hook.sendMessage("❌ You did not run this command!").setEphemeral(true).queue();
return;
}
// set old message's button as disabled
List<ActionRow> actionRows = event.getMessage().getActionRows();
actionRows.set(0, actionRows.get(0).asDisabled());
event.editComponents(actionRows).queue();
// Disable all components on the original message
List<ActionRow> oldRows = event.getMessage().getActionRows();
List<ActionRow> disabledRows = new ArrayList<>(oldRows.size());
for (ActionRow row : oldRows)
{
disabledRows.add(row.asDisabled());
}
hook.editOriginalComponents(disabledRows).queue();
// perform coin flip
event.getHook().sendMessage(genRandom())
.addActionRow(getReflipButton())
.queue((message) ->
{
// set the command as expiring and restrict it to the user who ran it
trackAndRestrict(message, event.getUser());
}, (error) -> {
});
// Send a follow-up with a fresh button
hook.sendMessage(genRandom())
.addActionRow(getReflipButton())
.queue(msg -> trackAndRestrict(msg, event.getUser()), err -> {
});
}, failure -> {
// Rare: if we couldn't ack, try best-effort fallbacks
try
{
List<ActionRow> oldRows = event.getMessage().getActionRows();
List<ActionRow> disabledRows = new ArrayList<>(oldRows.size());
for (ActionRow row : oldRows) disabledRows.add(row.asDisabled());
event.getMessage().editMessageComponents(disabledRows).queue();
} catch (Exception ignored)
{
}
event.getChannel().sendMessage(genRandom())
.addActionRow(getReflipButton())
.queue(msg -> trackAndRestrict(msg, event.getUser()), err -> {
});
});
}
public static void trackAndRestrict(Message replyMessage, User user)

View File

@@ -26,9 +26,10 @@ import java.io.InputStreamReader;
import java.net.URL;
import java.net.URLConnection;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Collections;
import java.util.LinkedList;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
@@ -44,13 +45,13 @@ public class Trivia
private static final String TRIVIA_API_LINK = "https://opentdb.com/api.php?amount=10&type=multiple&category=";
private static final String TRIVIA_API_CATEGORIES_LINK = "https://opentdb.com/api_category.php";
public static List<String> channelsRunningTrivia = new ArrayList<>();
public static List<String> channelsRunningTrivia = Collections.synchronizedList(new ArrayList<>());
// first string is the channelId, the list contain all users who responded there
public static HashMap<String, List<String>> channelAndWhoResponded = new HashMap<>();
public static ConcurrentHashMap<String, List<String>> channelAndWhoResponded = new ConcurrentHashMap<>();
// first string is the channelId, the list contain all score records for that channel
public static HashMap<String, LinkedList<TriviaScore>> channelAndScores = new HashMap<>();
public static ConcurrentHashMap<String, LinkedList<TriviaScore>> channelAndScores = new ConcurrentHashMap<>();
public static String getTriviaLink(int categoryId)
{
@@ -180,53 +181,63 @@ public class Trivia
public static void handleAnswer(ButtonInteractionEvent event, AnswerType answerType)
{
User user = event.getUser();
String channelId = event.getChannel().getId();
// Ack immediately with an ephemeral deferral to avoid 3s timeout
event.deferReply(true).queue(hook -> {
User user = event.getUser();
String channelId = event.getChannel().getId();
if (trackResponse(user, event.getChannel()))
{
LinkedList<TriviaScore> scores = channelAndScores.get(channelId);
if (scores == null) scores = new LinkedList<>();
TriviaScore currentUserScore = null;
for (TriviaScore score : scores)
if (trackResponse(user, event.getChannel()))
{
if (score.getUser().equals(user))
LinkedList<TriviaScore> scores = channelAndScores.get(channelId);
if (scores == null) scores = new LinkedList<>();
TriviaScore currentUserScore = null;
for (TriviaScore score : scores)
{
currentUserScore = score;
scores.remove(score);
break;
if (score.getUser().equals(user))
{
currentUserScore = score;
scores.remove(score);
break;
}
}
}
if (currentUserScore == null)
{
currentUserScore = new TriviaScore(user);
}
if (currentUserScore == null)
{
currentUserScore = new TriviaScore(user);
}
if (answerType.equals(AnswerType.CORRECT))
{
event.reply(user.getAsMention() + " got it right! \uD83E\uDD73 (**+3**)").queue();
currentUserScore.changeScore(3);
if (answerType.equals(AnswerType.CORRECT))
{
// Public message in channel
event.getChannel().sendMessage(user.getAsMention() + " got it right! \uD83E\uDD73 (**+3**)").queue();
currentUserScore.changeScore(3);
} else
{
event.getChannel().sendMessage("" + user.getAsMention() + ", that's not the right answer! (**-1**)").queue();
currentUserScore.changeScore(-1);
}
scores.add(currentUserScore);
channelAndScores.put(channelId, scores);
} else
{
event.reply("" + user.getAsMention() + ", that's not the right answer! (**-1**)").queue();
currentUserScore.changeScore(-1);
// Show the warning **in the original ephemeral message**, then delete it after 5s.
hook.editOriginal("☹️ " + user.getAsMention() + ", you can't answer twice!").queue(v ->
hook.deleteOriginal().queueAfter(3, TimeUnit.SECONDS, null, __ -> {
})
);
return; // don't run the generic cleanup below; we want the message visible for ~5s
}
scores.add(currentUserScore);
channelAndScores.put(channelId, scores);
} else
{
event.reply("☹️ " + user.getAsMention() + ", you can't answer twice!")
.queue(interaction ->
Cache.getTaskScheduler().schedule(() ->
interaction.deleteOriginal().queue(), 3, TimeUnit.SECONDS));
}
// Clean up the ephemeral deferral (no visible ephemeral message left) for the normal path
hook.deleteOriginal().queue(null, __ -> {
});
}, __ -> {
});
}
private static boolean trackResponse(User user, MessageChannel channel)
private static synchronized boolean trackResponse(User user, MessageChannel channel)
{
String userId = user.getId();
String channelId = channel.getId();
@@ -251,24 +262,32 @@ public class Trivia
public static void handleMenuSelection(StringSelectInteractionEvent event)
{
// check if the user interacting is the same one who ran the command
if (!(Cache.getServices().databaseService().isUserTrackedFor(event.getUser().getId(), event.getMessageId())))
{
event.reply("❌ You did not run this command!").setEphemeral(true).queue();
return;
}
// Ack immediately (ephemeral) so we can safely do DB/work
event.deferReply(true).queue(hook -> {
// check if the user interacting is the same one who ran the command
if (!(Cache.getServices().databaseService().isUserTrackedFor(event.getUser().getId(), event.getMessageId())))
{
hook.sendMessage("❌ You did not run this command!").setEphemeral(true).queue();
return;
}
// todo: we shouldn't use this method, since it messes with the database... look at coin reflip
Cache.getServices().commandService().disableExpired(event.getMessageId());
// Disable buttons on the original message via service (uses separate REST calls)
Cache.getServices().commandService().disableExpired(event.getMessageId());
SelectOption pickedOption = event.getInteraction().getSelectedOptions().get(0);
String categoryName = pickedOption.getLabel();
String categoryIdString = pickedOption.getValue();
Integer categoryId = Integer.parseInt(categoryIdString);
SelectOption pickedOption = event.getInteraction().getSelectedOptions().get(0);
String categoryName = pickedOption.getLabel();
String categoryIdString = pickedOption.getValue();
Integer categoryId = Integer.parseInt(categoryIdString);
TriviaCategory category = new TriviaCategory(categoryName, categoryId);
TriviaCategory category = new TriviaCategory(categoryName, categoryId);
startTrivia(event, category);
startTrivia(event, category);
// remove the ephemeral deferral to keep things clean
hook.deleteOriginal().queue(null, __ -> {
});
}, __ -> {
});
}
public static void startTrivia(StringSelectInteractionEvent event, TriviaCategory category)
@@ -279,19 +298,17 @@ public class Trivia
if (Trivia.channelsRunningTrivia.contains(channel.getId()))
{
// todo nicer looking
// todo: also what if the bot stops (database...?)
// todo: also what if the message is already deleted
Message err = event.reply("Trivia is already running here!").complete().retrieveOriginal().complete();
Cache.getTaskScheduler().schedule(() -> err.delete().queue(), 10, TimeUnit.SECONDS);
// Already running: inform ephemerally via hook (the interaction was deferred in the caller)
event.getHook().sendMessage(Trivia.getTriviaAlreadyRunningError())
.setEphemeral(true)
.queue(msg -> Cache.getTaskScheduler().schedule(() -> msg.delete().queue(), 10, TimeUnit.SECONDS));
return;
} else
{
// todo nicer looking
event.reply("Starting new Trivia session!").queue();
// Public info that a new session is starting
channel.sendMessage("Starting new Trivia session!").queue();
}
TriviaTask triviaTask = new TriviaTask(author, channel, category,
Cache.getServices().databaseService(), Cache.getServices().commandService());
ScheduledFuture<?> future =

View File

@@ -119,6 +119,7 @@ public class UrbanDictionary
public static void changePage(ButtonInteractionEvent event, ChangeType changeType)
{
event.deferEdit().queue();
String messageId = event.getMessageId();
DatabaseService database = Cache.getServices().databaseService();
@@ -180,7 +181,9 @@ public class UrbanDictionary
ActionRow currentRow = ActionRow.of(components);
// update the message
event.editComponents(currentRow).setEmbeds(updatedEmbed).queue();
event.getHook().editOriginalEmbeds(updatedEmbed)
.setComponents(currentRow)
.queue();
database.setUrbanPage(messageId, page);
database.resetExpiryTimestamp(messageId);
}

View File

@@ -51,9 +51,28 @@ public class CommandService
return;
}
// delete the message
event.getInteraction().getMessage().delete().queue();
// no need to manually untrack it from database, it will be purged on the next planned check.
// Acknowledge immediately so the interaction token stays valid
event.deferEdit().queue(hook -> {
// Try deleting via the interaction webhook (works for original interaction responses)
hook.deleteOriginal().queue(
success -> { /* optional: databaseService.untrackExpiredMessage(event.getMessageId()); */ },
failure -> {
// Fallback to channel delete (works even if webhook token expired)
event.getChannel().deleteMessageById(event.getMessageId()).queue(
null,
__ -> { /* ignore if already deleted */ }
);
}
);
},
failure -> {
// If we failed to acknowledge (interaction already expired), try channel delete anyway
event.getChannel().deleteMessageById(event.getMessageId()).queue(
null,
__ -> { /* ignore if already deleted */ }
);
}
);
}

View File

@@ -1,4 +1,4 @@
spring.datasource.url=jdbc:sqlite:/absolute/path/to/db.sqlite
spring.datasource.url=jdbc:sqlite:${APP_HOME}/db.sqlite
spring.datasource.driver-class-name=org.sqlite.JDBC
# let Hibernate create/update tables for you during the migration
spring.jpa.hibernate.ddl-auto=update