fix concurrency and ack
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
This commit is contained in:
@@ -8,6 +8,7 @@ import org.slf4j.Logger;
|
|||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
import org.springframework.boot.system.ApplicationHome;
|
||||||
import org.springframework.context.ConfigurableApplicationContext;
|
import org.springframework.context.ConfigurableApplicationContext;
|
||||||
import wtf.beatrice.hidekobot.commands.completer.ProfileImageCommandCompleter;
|
import wtf.beatrice.hidekobot.commands.completer.ProfileImageCommandCompleter;
|
||||||
import wtf.beatrice.hidekobot.commands.message.HelloCommand;
|
import wtf.beatrice.hidekobot.commands.message.HelloCommand;
|
||||||
@@ -68,6 +69,8 @@ public class HidekoBot
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ApplicationHome home = new ApplicationHome(HidekoBot.class);
|
||||||
|
System.setProperty("APP_HOME", home.getDir().getAbsolutePath());
|
||||||
ConfigurableApplicationContext context = SpringApplication.run(HidekoBot.class, args);
|
ConfigurableApplicationContext context = SpringApplication.run(HidekoBot.class, args);
|
||||||
|
|
||||||
CommandService commandService = context.getBean(CommandService.class);
|
CommandService commandService = context.getBean(CommandService.class);
|
||||||
|
@@ -9,6 +9,7 @@ import net.dv8tion.jda.api.interactions.components.buttons.Button;
|
|||||||
import wtf.beatrice.hidekobot.Cache;
|
import wtf.beatrice.hidekobot.Cache;
|
||||||
import wtf.beatrice.hidekobot.util.RandomUtil;
|
import wtf.beatrice.hidekobot.util.RandomUtil;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
public class CoinFlip
|
public class CoinFlip
|
||||||
@@ -43,27 +44,46 @@ public class CoinFlip
|
|||||||
|
|
||||||
public static void buttonReFlip(ButtonInteractionEvent event)
|
public static void buttonReFlip(ButtonInteractionEvent event)
|
||||||
{
|
{
|
||||||
// check if the user interacting is the same one who ran the command
|
// Ack ASAP to avoid 3s timeout
|
||||||
if (!(Cache.getServices().databaseService().isUserTrackedFor(event.getUser().getId(), event.getMessageId())))
|
event.deferEdit().queue(hook -> {
|
||||||
{
|
// Permission check **after** ack
|
||||||
event.reply("❌ You did not run this command!").setEphemeral(true).queue();
|
if (!Cache.getServices().databaseService().isUserTrackedFor(event.getUser().getId(), event.getMessageId()))
|
||||||
return;
|
{
|
||||||
}
|
hook.sendMessage("❌ You did not run this command!").setEphemeral(true).queue();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// set old message's button as disabled
|
// Disable all components on the original message
|
||||||
List<ActionRow> actionRows = event.getMessage().getActionRows();
|
List<ActionRow> oldRows = event.getMessage().getActionRows();
|
||||||
actionRows.set(0, actionRows.get(0).asDisabled());
|
List<ActionRow> disabledRows = new ArrayList<>(oldRows.size());
|
||||||
event.editComponents(actionRows).queue();
|
for (ActionRow row : oldRows)
|
||||||
|
{
|
||||||
|
disabledRows.add(row.asDisabled());
|
||||||
|
}
|
||||||
|
hook.editOriginalComponents(disabledRows).queue();
|
||||||
|
|
||||||
// perform coin flip
|
// Send a follow-up with a fresh button
|
||||||
event.getHook().sendMessage(genRandom())
|
hook.sendMessage(genRandom())
|
||||||
.addActionRow(getReflipButton())
|
.addActionRow(getReflipButton())
|
||||||
.queue((message) ->
|
.queue(msg -> trackAndRestrict(msg, event.getUser()), err -> {
|
||||||
{
|
});
|
||||||
// set the command as expiring and restrict it to the user who ran it
|
}, failure -> {
|
||||||
trackAndRestrict(message, event.getUser());
|
// Rare: if we couldn't ack, try best-effort fallbacks
|
||||||
}, (error) -> {
|
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)
|
public static void trackAndRestrict(Message replyMessage, User user)
|
||||||
|
@@ -26,9 +26,10 @@ import java.io.InputStreamReader;
|
|||||||
import java.net.URL;
|
import java.net.URL;
|
||||||
import java.net.URLConnection;
|
import java.net.URLConnection;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.HashMap;
|
import java.util.Collections;
|
||||||
import java.util.LinkedList;
|
import java.util.LinkedList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
import java.util.concurrent.ScheduledFuture;
|
import java.util.concurrent.ScheduledFuture;
|
||||||
import java.util.concurrent.TimeUnit;
|
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_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";
|
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
|
// 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
|
// 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)
|
public static String getTriviaLink(int categoryId)
|
||||||
{
|
{
|
||||||
@@ -180,53 +181,63 @@ public class Trivia
|
|||||||
|
|
||||||
public static void handleAnswer(ButtonInteractionEvent event, AnswerType answerType)
|
public static void handleAnswer(ButtonInteractionEvent event, AnswerType answerType)
|
||||||
{
|
{
|
||||||
User user = event.getUser();
|
// Ack immediately with an ephemeral deferral to avoid 3s timeout
|
||||||
String channelId = event.getChannel().getId();
|
event.deferReply(true).queue(hook -> {
|
||||||
|
User user = event.getUser();
|
||||||
|
String channelId = event.getChannel().getId();
|
||||||
|
|
||||||
if (trackResponse(user, event.getChannel()))
|
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 (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;
|
if (score.getUser().equals(user))
|
||||||
scores.remove(score);
|
{
|
||||||
break;
|
currentUserScore = score;
|
||||||
|
scores.remove(score);
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (currentUserScore == null)
|
if (currentUserScore == null)
|
||||||
{
|
{
|
||||||
currentUserScore = new TriviaScore(user);
|
currentUserScore = new TriviaScore(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (answerType.equals(AnswerType.CORRECT))
|
if (answerType.equals(AnswerType.CORRECT))
|
||||||
{
|
{
|
||||||
|
// Public message in channel
|
||||||
event.reply(user.getAsMention() + " got it right! \uD83E\uDD73 (**+3**)").queue();
|
event.getChannel().sendMessage(user.getAsMention() + " got it right! \uD83E\uDD73 (**+3**)").queue();
|
||||||
currentUserScore.changeScore(3);
|
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
|
} else
|
||||||
{
|
{
|
||||||
event.reply("❌ " + user.getAsMention() + ", that's not the right answer! (**-1**)").queue();
|
// Show the warning **in the original ephemeral message**, then delete it after 5s.
|
||||||
currentUserScore.changeScore(-1);
|
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);
|
// Clean up the ephemeral deferral (no visible ephemeral message left) for the normal path
|
||||||
channelAndScores.put(channelId, scores);
|
hook.deleteOriginal().queue(null, __ -> {
|
||||||
} else
|
});
|
||||||
{
|
}, __ -> {
|
||||||
event.reply("☹️ " + user.getAsMention() + ", you can't answer twice!")
|
});
|
||||||
.queue(interaction ->
|
|
||||||
Cache.getTaskScheduler().schedule(() ->
|
|
||||||
interaction.deleteOriginal().queue(), 3, TimeUnit.SECONDS));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static boolean trackResponse(User user, MessageChannel channel)
|
private static synchronized boolean trackResponse(User user, MessageChannel channel)
|
||||||
{
|
{
|
||||||
String userId = user.getId();
|
String userId = user.getId();
|
||||||
String channelId = channel.getId();
|
String channelId = channel.getId();
|
||||||
@@ -251,24 +262,32 @@ public class Trivia
|
|||||||
|
|
||||||
public static void handleMenuSelection(StringSelectInteractionEvent event)
|
public static void handleMenuSelection(StringSelectInteractionEvent event)
|
||||||
{
|
{
|
||||||
// check if the user interacting is the same one who ran the command
|
// Ack immediately (ephemeral) so we can safely do DB/work
|
||||||
if (!(Cache.getServices().databaseService().isUserTrackedFor(event.getUser().getId(), event.getMessageId())))
|
event.deferReply(true).queue(hook -> {
|
||||||
{
|
// check if the user interacting is the same one who ran the command
|
||||||
event.reply("❌ You did not run this command!").setEphemeral(true).queue();
|
if (!(Cache.getServices().databaseService().isUserTrackedFor(event.getUser().getId(), event.getMessageId())))
|
||||||
return;
|
{
|
||||||
}
|
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
|
// Disable buttons on the original message via service (uses separate REST calls)
|
||||||
Cache.getServices().commandService().disableExpired(event.getMessageId());
|
Cache.getServices().commandService().disableExpired(event.getMessageId());
|
||||||
|
|
||||||
SelectOption pickedOption = event.getInteraction().getSelectedOptions().get(0);
|
SelectOption pickedOption = event.getInteraction().getSelectedOptions().get(0);
|
||||||
String categoryName = pickedOption.getLabel();
|
String categoryName = pickedOption.getLabel();
|
||||||
String categoryIdString = pickedOption.getValue();
|
String categoryIdString = pickedOption.getValue();
|
||||||
Integer categoryId = Integer.parseInt(categoryIdString);
|
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)
|
public static void startTrivia(StringSelectInteractionEvent event, TriviaCategory category)
|
||||||
@@ -279,19 +298,17 @@ public class Trivia
|
|||||||
|
|
||||||
if (Trivia.channelsRunningTrivia.contains(channel.getId()))
|
if (Trivia.channelsRunningTrivia.contains(channel.getId()))
|
||||||
{
|
{
|
||||||
// todo nicer looking
|
// Already running: inform ephemerally via hook (the interaction was deferred in the caller)
|
||||||
// todo: also what if the bot stops (database...?)
|
event.getHook().sendMessage(Trivia.getTriviaAlreadyRunningError())
|
||||||
// todo: also what if the message is already deleted
|
.setEphemeral(true)
|
||||||
Message err = event.reply("Trivia is already running here!").complete().retrieveOriginal().complete();
|
.queue(msg -> Cache.getTaskScheduler().schedule(() -> msg.delete().queue(), 10, TimeUnit.SECONDS));
|
||||||
Cache.getTaskScheduler().schedule(() -> err.delete().queue(), 10, TimeUnit.SECONDS);
|
|
||||||
return;
|
return;
|
||||||
} else
|
} else
|
||||||
{
|
{
|
||||||
// todo nicer looking
|
// Public info that a new session is starting
|
||||||
event.reply("Starting new Trivia session!").queue();
|
channel.sendMessage("Starting new Trivia session!").queue();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
TriviaTask triviaTask = new TriviaTask(author, channel, category,
|
TriviaTask triviaTask = new TriviaTask(author, channel, category,
|
||||||
Cache.getServices().databaseService(), Cache.getServices().commandService());
|
Cache.getServices().databaseService(), Cache.getServices().commandService());
|
||||||
ScheduledFuture<?> future =
|
ScheduledFuture<?> future =
|
||||||
|
@@ -119,6 +119,7 @@ public class UrbanDictionary
|
|||||||
|
|
||||||
public static void changePage(ButtonInteractionEvent event, ChangeType changeType)
|
public static void changePage(ButtonInteractionEvent event, ChangeType changeType)
|
||||||
{
|
{
|
||||||
|
event.deferEdit().queue();
|
||||||
String messageId = event.getMessageId();
|
String messageId = event.getMessageId();
|
||||||
DatabaseService database = Cache.getServices().databaseService();
|
DatabaseService database = Cache.getServices().databaseService();
|
||||||
|
|
||||||
@@ -180,7 +181,9 @@ public class UrbanDictionary
|
|||||||
ActionRow currentRow = ActionRow.of(components);
|
ActionRow currentRow = ActionRow.of(components);
|
||||||
|
|
||||||
// update the message
|
// update the message
|
||||||
event.editComponents(currentRow).setEmbeds(updatedEmbed).queue();
|
event.getHook().editOriginalEmbeds(updatedEmbed)
|
||||||
|
.setComponents(currentRow)
|
||||||
|
.queue();
|
||||||
database.setUrbanPage(messageId, page);
|
database.setUrbanPage(messageId, page);
|
||||||
database.resetExpiryTimestamp(messageId);
|
database.resetExpiryTimestamp(messageId);
|
||||||
}
|
}
|
||||||
|
@@ -51,9 +51,28 @@ public class CommandService
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// delete the message
|
// Acknowledge immediately so the interaction token stays valid
|
||||||
event.getInteraction().getMessage().delete().queue();
|
event.deferEdit().queue(hook -> {
|
||||||
// no need to manually untrack it from database, it will be purged on the next planned check.
|
// 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 */ }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@@ -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
|
spring.datasource.driver-class-name=org.sqlite.JDBC
|
||||||
# let Hibernate create/update tables for you during the migration
|
# let Hibernate create/update tables for you during the migration
|
||||||
spring.jpa.hibernate.ddl-auto=update
|
spring.jpa.hibernate.ddl-auto=update
|
||||||
|
Reference in New Issue
Block a user