From c0ae0fc59679bb1fec6ac316f403b000f6ef9506 Mon Sep 17 00:00:00 2001 From: Liam Date: Mon, 24 Jun 2024 17:42:57 +0100 Subject: [PATCH] add scoresaber feed command and websocket impl --- pom.xml | 19 ++- .../cc/fascinated/bat/BatApplication.java | 4 + .../bat/command/BatCommandExecutor.java | 7 +- .../beatsaber/scoresaber/LinkSubCommand.java | 5 +- .../scoresaber/ScoreSaberCommand.java | 30 +++- .../beatsaber/scoresaber/UserSubCommand.java | 21 ++- .../scoresaber/ScoreFeedChannelCommand.java | 58 ++++++++ .../ScoreFeedClearUsersCommand.java | 40 ++++++ .../scoresaber/ScoreFeedUserCommand.java | 72 ++++++++++ .../cc/fascinated/bat/common/NumberUtils.java | 1 + .../bat/common/ScoreSaberUtils.java | 23 +++ .../bat/common/TextChannelUtils.java | 31 +++++ .../cc/fascinated/bat/model/BatGuild.java | 33 ----- .../fascinated/bat/model/guild/BatGuild.java | 66 +++++++++ .../profiles/ScoreSaberScoreFeedProfile.java | 75 ++++++++++ .../bat/repository/GuildRepository.java | 2 +- .../bat/service/CommandService.java | 31 ++--- .../fascinated/bat/service/GuildService.java | 2 +- .../bat/service/ScoreSaberService.java | 131 +++++++++++++++++- 19 files changed, 570 insertions(+), 81 deletions(-) create mode 100644 src/main/java/cc/fascinated/bat/command/impl/guild/beatsaber/scoresaber/ScoreFeedChannelCommand.java create mode 100644 src/main/java/cc/fascinated/bat/command/impl/guild/beatsaber/scoresaber/ScoreFeedClearUsersCommand.java create mode 100644 src/main/java/cc/fascinated/bat/command/impl/guild/beatsaber/scoresaber/ScoreFeedUserCommand.java create mode 100644 src/main/java/cc/fascinated/bat/common/ScoreSaberUtils.java create mode 100644 src/main/java/cc/fascinated/bat/common/TextChannelUtils.java delete mode 100644 src/main/java/cc/fascinated/bat/model/BatGuild.java create mode 100644 src/main/java/cc/fascinated/bat/model/guild/BatGuild.java create mode 100644 src/main/java/cc/fascinated/bat/model/guild/profiles/ScoreSaberScoreFeedProfile.java diff --git a/pom.xml b/pom.xml index c1a1fe6..afe8af3 100644 --- a/pom.xml +++ b/pom.xml @@ -81,6 +81,10 @@ org.springframework.boot spring-boot-starter-data-mongodb + + org.springframework.boot + spring-boot-starter-websocket + @@ -89,11 +93,6 @@ 1.18.32 provided - - org.apache.httpcomponents.client5 - httpclient5 - 5.3.1 - @@ -101,6 +100,16 @@ JDA 5.0.0-beta.24 + + com.google.code.gson + gson + 2.10.1 + + + org.apache.httpcomponents.client5 + httpclient5 + 5.3.1 + diff --git a/src/main/java/cc/fascinated/bat/BatApplication.java b/src/main/java/cc/fascinated/bat/BatApplication.java index aac61b1..a5ca114 100644 --- a/src/main/java/cc/fascinated/bat/BatApplication.java +++ b/src/main/java/cc/fascinated/bat/BatApplication.java @@ -1,5 +1,7 @@ package cc.fascinated.bat; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; import lombok.NonNull; import lombok.SneakyThrows; import lombok.extern.log4j.Log4j2; @@ -14,6 +16,8 @@ import java.util.Objects; @SpringBootApplication() @Log4j2(topic = "Ember") public class BatApplication { + public static Gson GSON = new GsonBuilder().create(); + @SneakyThrows public static void main(@NonNull String[] args) { // Handle loading of our configuration file diff --git a/src/main/java/cc/fascinated/bat/command/BatCommandExecutor.java b/src/main/java/cc/fascinated/bat/command/BatCommandExecutor.java index 1136d7c..8aa1b23 100644 --- a/src/main/java/cc/fascinated/bat/command/BatCommandExecutor.java +++ b/src/main/java/cc/fascinated/bat/command/BatCommandExecutor.java @@ -1,11 +1,10 @@ package cc.fascinated.bat.command; -import cc.fascinated.bat.model.BatGuild; +import cc.fascinated.bat.model.guild.BatGuild; import cc.fascinated.bat.model.user.BatUser; import lombok.NonNull; import net.dv8tion.jda.api.entities.Member; import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; -import net.dv8tion.jda.api.interactions.commands.OptionMapping; import net.dv8tion.jda.api.interactions.commands.SlashCommandInteraction; /** @@ -21,14 +20,12 @@ public interface BatCommandExecutor { * @param channel the channel the command was executed in * @param member the member that executed the command * @param interaction the slash command interaction - * @param option the option that was used in the command, or null if no option was used */ default void execute( @NonNull BatGuild guild, @NonNull BatUser user, @NonNull TextChannel channel, @NonNull Member member, - @NonNull SlashCommandInteraction interaction, - OptionMapping option + @NonNull SlashCommandInteraction interaction ) {} } diff --git a/src/main/java/cc/fascinated/bat/command/impl/global/beatsaber/scoresaber/LinkSubCommand.java b/src/main/java/cc/fascinated/bat/command/impl/global/beatsaber/scoresaber/LinkSubCommand.java index 3cfbc25..e18d4f5 100644 --- a/src/main/java/cc/fascinated/bat/command/impl/global/beatsaber/scoresaber/LinkSubCommand.java +++ b/src/main/java/cc/fascinated/bat/command/impl/global/beatsaber/scoresaber/LinkSubCommand.java @@ -2,7 +2,7 @@ package cc.fascinated.bat.command.impl.global.beatsaber.scoresaber; import cc.fascinated.bat.command.BatSubCommand; import cc.fascinated.bat.common.EmbedUtils; -import cc.fascinated.bat.model.BatGuild; +import cc.fascinated.bat.model.guild.BatGuild; import cc.fascinated.bat.model.beatsaber.scoresaber.ScoreSaberAccountToken; import cc.fascinated.bat.model.user.BatUser; import cc.fascinated.bat.model.user.profiles.ScoreSaberProfile; @@ -31,7 +31,8 @@ public class LinkSubCommand extends BatSubCommand { } @Override - public void execute(@NonNull BatGuild guild, @NonNull BatUser user, @NonNull TextChannel channel, @NonNull Member member, @NonNull SlashCommandInteraction interaction, OptionMapping option) { + public void execute(@NonNull BatGuild guild, @NonNull BatUser user, @NonNull TextChannel channel, @NonNull Member member, @NonNull SlashCommandInteraction interaction) { + OptionMapping option = interaction.getOption("link"); if (option == null) { interaction.replyEmbeds(EmbedUtils.buildErrorEmbed("Please provide a ScoreSaber profile link").build()).queue(); return; diff --git a/src/main/java/cc/fascinated/bat/command/impl/global/beatsaber/scoresaber/ScoreSaberCommand.java b/src/main/java/cc/fascinated/bat/command/impl/global/beatsaber/scoresaber/ScoreSaberCommand.java index 169990a..6a4fb9f 100644 --- a/src/main/java/cc/fascinated/bat/command/impl/global/beatsaber/scoresaber/ScoreSaberCommand.java +++ b/src/main/java/cc/fascinated/bat/command/impl/global/beatsaber/scoresaber/ScoreSaberCommand.java @@ -5,19 +5,21 @@ import cc.fascinated.bat.common.Colors; import cc.fascinated.bat.common.DateUtils; import cc.fascinated.bat.common.EmbedUtils; import cc.fascinated.bat.common.NumberUtils; -import cc.fascinated.bat.model.BatGuild; +import cc.fascinated.bat.model.guild.BatGuild; import cc.fascinated.bat.model.beatsaber.scoresaber.ScoreSaberAccountToken; import cc.fascinated.bat.model.user.BatUser; import cc.fascinated.bat.model.user.profiles.ScoreSaberProfile; import cc.fascinated.bat.service.ScoreSaberService; import lombok.NonNull; import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.Permission; import net.dv8tion.jda.api.entities.Member; import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; -import net.dv8tion.jda.api.interactions.commands.OptionMapping; +import net.dv8tion.jda.api.interactions.commands.DefaultMemberPermissions; import net.dv8tion.jda.api.interactions.commands.OptionType; import net.dv8tion.jda.api.interactions.commands.SlashCommandInteraction; import net.dv8tion.jda.api.interactions.commands.build.OptionData; +import net.dv8tion.jda.api.interactions.commands.build.SubcommandData; import net.dv8tion.jda.internal.interactions.CommandDataImpl; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; @@ -37,15 +39,31 @@ public class ScoreSaberCommand extends BatCommand { super.setCategory(Category.BEAT_SABER); this.scoreSaberService = scoreSaberService; - super.setDescription("View a user's ScoreSaber profile"); + super.setDescription("General ScoreSaber commands"); super.setCommandData(new CommandDataImpl(this.getName(), this.getDescription()) - .addOptions(new OptionData(OptionType.STRING, "link", "Link your ScoreSaber profile", false)) - .addOptions(new OptionData(OptionType.USER, "user", "The user to view the ScoreSaber profile of", false)) + .addSubcommands(new SubcommandData("link", "Link your ScoreSaber profile") + .addOptions(new OptionData(OptionType.STRING, "link", "Link your ScoreSaber profile", true)) + ) + + .addSubcommands(new SubcommandData("user", "View a user's ScoreSaber profile") + .addOptions(new OptionData(OptionType.USER, "user", "The user to view the ScoreSaber profile of", true)) + ) + + .addSubcommands(new SubcommandData("score-feed-user", "Edit your ScoreSaber score feed settings") + .addOptions(new OptionData(OptionType.USER, "user", "Add or remove a user from the score feed", false)) + ).setDefaultPermissions(DefaultMemberPermissions.enabledFor(Permission.MANAGE_SERVER)) + + .addSubcommands(new SubcommandData("score-feed-clear-users", "Remove all users from the score feed")) + .setDefaultPermissions(DefaultMemberPermissions.enabledFor(Permission.MANAGE_SERVER)) + + .addSubcommands(new SubcommandData("score-feed-channel", "Edit your ScoreSaber score feed settings") + .addOptions(new OptionData(OptionType.CHANNEL, "channel", "Set the channel to send the score feed in", false)) + ).setDefaultPermissions(DefaultMemberPermissions.enabledFor(Permission.MANAGE_SERVER)) ); } @Override - public void execute(@NonNull BatGuild guild, @NonNull BatUser user, @NonNull TextChannel channel, @NonNull Member member, @NonNull SlashCommandInteraction interaction, OptionMapping option) { + public void execute(@NonNull BatGuild guild, @NonNull BatUser user, @NonNull TextChannel channel, @NonNull Member member, @NonNull SlashCommandInteraction interaction) { sendProfileEmbed(true, user, scoreSaberService, interaction); } diff --git a/src/main/java/cc/fascinated/bat/command/impl/global/beatsaber/scoresaber/UserSubCommand.java b/src/main/java/cc/fascinated/bat/command/impl/global/beatsaber/scoresaber/UserSubCommand.java index a647e82..7e2f58e 100644 --- a/src/main/java/cc/fascinated/bat/command/impl/global/beatsaber/scoresaber/UserSubCommand.java +++ b/src/main/java/cc/fascinated/bat/command/impl/global/beatsaber/scoresaber/UserSubCommand.java @@ -1,7 +1,8 @@ package cc.fascinated.bat.command.impl.global.beatsaber.scoresaber; import cc.fascinated.bat.command.BatSubCommand; -import cc.fascinated.bat.model.BatGuild; +import cc.fascinated.bat.common.EmbedUtils; +import cc.fascinated.bat.model.guild.BatGuild; import cc.fascinated.bat.model.user.BatUser; import cc.fascinated.bat.service.ScoreSaberService; import cc.fascinated.bat.service.UserService; @@ -28,13 +29,23 @@ public class UserSubCommand extends BatSubCommand { } @Override - public void execute(@NonNull BatGuild guild, @NonNull BatUser user, @NonNull TextChannel channel, @NonNull Member member, @NonNull SlashCommandInteraction interaction, OptionMapping option) { - BatUser target = userService.getUser(option.getAsUser().getId()); - if (target == null) { - interaction.reply("User not found").queue(); + public void execute(@NonNull BatGuild guild, @NonNull BatUser user, @NonNull TextChannel channel, @NonNull Member member, @NonNull SlashCommandInteraction interaction) { + OptionMapping option = interaction.getOption("user"); + if (option == null) { + interaction.replyEmbeds(EmbedUtils.buildErrorEmbed("Please provide a user to view the ScoreSaber profile of").build()).queue(); return; } + if (option.getAsUser().isBot()) { + interaction.replyEmbeds(EmbedUtils.buildErrorEmbed("You cannot view the ScoreSaber profile for a Bot").build()).queue(); + return; + } + + BatUser target = userService.getUser(option.getAsUser().getId()); + if (target == null) { + interaction.replyEmbeds(EmbedUtils.buildErrorEmbed("Unknown user").build()).queue(); + return; + } ScoreSaberCommand.sendProfileEmbed(false, target, scoreSaberService, interaction); } } diff --git a/src/main/java/cc/fascinated/bat/command/impl/guild/beatsaber/scoresaber/ScoreFeedChannelCommand.java b/src/main/java/cc/fascinated/bat/command/impl/guild/beatsaber/scoresaber/ScoreFeedChannelCommand.java new file mode 100644 index 0000000..99371c8 --- /dev/null +++ b/src/main/java/cc/fascinated/bat/command/impl/guild/beatsaber/scoresaber/ScoreFeedChannelCommand.java @@ -0,0 +1,58 @@ +package cc.fascinated.bat.command.impl.guild.beatsaber.scoresaber; + +import cc.fascinated.bat.command.BatSubCommand; +import cc.fascinated.bat.common.EmbedUtils; +import cc.fascinated.bat.common.TextChannelUtils; +import cc.fascinated.bat.model.guild.BatGuild; +import cc.fascinated.bat.model.guild.profiles.ScoreSaberScoreFeedProfile; +import cc.fascinated.bat.model.user.BatUser; +import cc.fascinated.bat.service.GuildService; +import lombok.NonNull; +import net.dv8tion.jda.api.entities.Member; +import net.dv8tion.jda.api.entities.channel.ChannelType; +import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; +import net.dv8tion.jda.api.entities.channel.unions.GuildChannelUnion; +import net.dv8tion.jda.api.interactions.commands.OptionMapping; +import net.dv8tion.jda.api.interactions.commands.SlashCommandInteraction; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +/** + * @author Fascinated (fascinated7) + */ +@Component +public class ScoreFeedChannelCommand extends BatSubCommand { + private final GuildService guildService; + + @Autowired + public ScoreFeedChannelCommand(GuildService guildService) { + this.guildService = guildService; + } + + @Override + public void execute(@NonNull BatGuild guild, @NonNull BatUser user, @NonNull TextChannel channel, @NonNull Member member, @NonNull SlashCommandInteraction interaction) { + ScoreSaberScoreFeedProfile profile = guild.getProfile(ScoreSaberScoreFeedProfile.class); + OptionMapping option = interaction.getOption("channel"); + if (option == null) { + if (!TextChannelUtils.isValidChannel(profile.getChannelId())) { + interaction.replyEmbeds(EmbedUtils.buildErrorEmbed("Please provide a channel to set the ScoreSaber feed channel to").build()).queue(); + return; + } + interaction.replyEmbeds(EmbedUtils.buildSuccessEmbed("The current ScoreSaber feed channel is %s" + .formatted(TextChannelUtils.getChannelMention(profile.getChannelId()))).build()).queue(); + return; + } + + GuildChannelUnion targetChannel = option.getAsChannel(); + if (targetChannel.getType() != ChannelType.TEXT) { + interaction.replyEmbeds(EmbedUtils.buildErrorEmbed("Invalid channel type, please provide a text channel").build()).queue(); + return; + } + + profile.setChannelId(targetChannel.getId()); + guildService.saveGuild(guild); + + interaction.replyEmbeds(EmbedUtils.buildSuccessEmbed("Successfully set the ScoreSaber feed channel to %s" + .formatted(targetChannel.asTextChannel().getAsMention())).build()).queue(); + } +} diff --git a/src/main/java/cc/fascinated/bat/command/impl/guild/beatsaber/scoresaber/ScoreFeedClearUsersCommand.java b/src/main/java/cc/fascinated/bat/command/impl/guild/beatsaber/scoresaber/ScoreFeedClearUsersCommand.java new file mode 100644 index 0000000..b672bad --- /dev/null +++ b/src/main/java/cc/fascinated/bat/command/impl/guild/beatsaber/scoresaber/ScoreFeedClearUsersCommand.java @@ -0,0 +1,40 @@ +package cc.fascinated.bat.command.impl.guild.beatsaber.scoresaber; + +import cc.fascinated.bat.command.BatSubCommand; +import cc.fascinated.bat.common.EmbedUtils; +import cc.fascinated.bat.model.guild.BatGuild; +import cc.fascinated.bat.model.guild.profiles.ScoreSaberScoreFeedProfile; +import cc.fascinated.bat.model.user.BatUser; +import cc.fascinated.bat.model.user.profiles.ScoreSaberProfile; +import cc.fascinated.bat.service.GuildService; +import cc.fascinated.bat.service.UserService; +import lombok.NonNull; +import net.dv8tion.jda.api.entities.Member; +import net.dv8tion.jda.api.entities.User; +import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; +import net.dv8tion.jda.api.interactions.commands.OptionMapping; +import net.dv8tion.jda.api.interactions.commands.SlashCommandInteraction; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +/** + * @author Fascinated (fascinated7) + */ +@Component +public class ScoreFeedClearUsersCommand extends BatSubCommand { + private final GuildService guildService; + + @Autowired + public ScoreFeedClearUsersCommand(GuildService guildService, UserService userService) { + this.guildService = guildService; + } + + @Override + public void execute(@NonNull BatGuild guild, @NonNull BatUser user, @NonNull TextChannel channel, @NonNull Member member, @NonNull SlashCommandInteraction interaction) { + ScoreSaberScoreFeedProfile profile = guild.getProfile(ScoreSaberScoreFeedProfile.class); + profile.getTrackedUsers().clear(); + guildService.saveGuild(guild); + + interaction.replyEmbeds(EmbedUtils.buildSuccessEmbed("Successfully cleared all users from the ScoreSaber feed").build()).queue(); + } +} diff --git a/src/main/java/cc/fascinated/bat/command/impl/guild/beatsaber/scoresaber/ScoreFeedUserCommand.java b/src/main/java/cc/fascinated/bat/command/impl/guild/beatsaber/scoresaber/ScoreFeedUserCommand.java new file mode 100644 index 0000000..0c52800 --- /dev/null +++ b/src/main/java/cc/fascinated/bat/command/impl/guild/beatsaber/scoresaber/ScoreFeedUserCommand.java @@ -0,0 +1,72 @@ +package cc.fascinated.bat.command.impl.guild.beatsaber.scoresaber; + +import cc.fascinated.bat.command.BatSubCommand; +import cc.fascinated.bat.common.EmbedUtils; +import cc.fascinated.bat.common.Profile; +import cc.fascinated.bat.model.guild.BatGuild; +import cc.fascinated.bat.model.guild.profiles.ScoreSaberScoreFeedProfile; +import cc.fascinated.bat.model.user.BatUser; +import cc.fascinated.bat.model.user.profiles.ScoreSaberProfile; +import cc.fascinated.bat.service.GuildService; +import cc.fascinated.bat.service.UserService; +import lombok.NonNull; +import net.dv8tion.jda.api.entities.Member; +import net.dv8tion.jda.api.entities.User; +import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; +import net.dv8tion.jda.api.interactions.commands.OptionMapping; +import net.dv8tion.jda.api.interactions.commands.SlashCommandInteraction; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +/** + * @author Fascinated (fascinated7) + */ +@Component +public class ScoreFeedUserCommand extends BatSubCommand { + private final GuildService guildService; + private final UserService userService; + + @Autowired + public ScoreFeedUserCommand(GuildService guildService, UserService userService) { + this.guildService = guildService; + this.userService = userService; + } + + @Override + public void execute(@NonNull BatGuild guild, @NonNull BatUser user, @NonNull TextChannel channel, @NonNull Member member, @NonNull SlashCommandInteraction interaction) { + ScoreSaberScoreFeedProfile profile = guild.getProfile(ScoreSaberScoreFeedProfile.class); + OptionMapping option = interaction.getOption("user"); + if (option == null){ + if (profile.getTrackedUsers().isEmpty()) { + interaction.replyEmbeds(EmbedUtils.buildGenericEmbed("There are no users being tracked in the ScoreSaber feed").build()).queue(); + return; + } + StringBuilder stringBuilder = new StringBuilder(); + for (String accountId : profile.getTrackedUsers()) { + stringBuilder.append("[%s](%s)".formatted( + accountId, + "https://scoresaber.com/u/%s".formatted(accountId) + )); + } + interaction.replyEmbeds(EmbedUtils.buildGenericEmbed("The current users being tracked in the ScoreSaber feed are:\n%s".formatted(stringBuilder.toString())).build()).queue(); + return; + } + + User target = option.getAsUser(); + BatUser targetUser = userService.getUser(target.getId()); + ScoreSaberProfile targetProfile = targetUser.getProfile(ScoreSaberProfile.class); + if (targetProfile.getId() == null) { + interaction.replyEmbeds(EmbedUtils.buildErrorEmbed("The user you are trying to track does not have a linked ScoreSaber profile").build()).queue(); + return; + } + + if (profile.getTrackedUsers().contains(targetProfile.getId())) { + profile.getTrackedUsers().remove(targetProfile.getId()); + interaction.replyEmbeds(EmbedUtils.buildSuccessEmbed("Successfully removed %s from the ScoreSaber feed".formatted(target.getAsMention())).build()).queue(); + } else { + profile.getTrackedUsers().add(targetProfile.getId()); + interaction.replyEmbeds(EmbedUtils.buildSuccessEmbed("Successfully added %s to the ScoreSaber feed".formatted(target.getAsMention())).build()).queue(); + } + guildService.saveGuild(guild); + } +} diff --git a/src/main/java/cc/fascinated/bat/common/NumberUtils.java b/src/main/java/cc/fascinated/bat/common/NumberUtils.java index 426e1fa..d3c6f12 100644 --- a/src/main/java/cc/fascinated/bat/common/NumberUtils.java +++ b/src/main/java/cc/fascinated/bat/common/NumberUtils.java @@ -19,6 +19,7 @@ public class NumberUtils { public static String formatNumberCommas(double number) { NumberFormat format = NumberFormat.getNumberInstance(); format.setGroupingUsed(true); + format.setMaximumFractionDigits(2); return format.format(number); } } diff --git a/src/main/java/cc/fascinated/bat/common/ScoreSaberUtils.java b/src/main/java/cc/fascinated/bat/common/ScoreSaberUtils.java new file mode 100644 index 0000000..ba41d74 --- /dev/null +++ b/src/main/java/cc/fascinated/bat/common/ScoreSaberUtils.java @@ -0,0 +1,23 @@ +package cc.fascinated.bat.common; + +/** + * @author Fascinated (fascinated7) + */ +public class ScoreSaberUtils { + /** + * Gets the formatted difficulty of a song. + * + * @param difficulty the difficulty to format + * @return the formatted difficulty + */ + public static String getFormattedDifficulty(int difficulty) { + return switch (difficulty) { + case 1 -> "Easy"; + case 3 -> "Normal"; + case 5 -> "Hard"; + case 7 -> "Expert"; + case 8 -> "Expert+"; + default -> "Unknown"; + }; + } +} diff --git a/src/main/java/cc/fascinated/bat/common/TextChannelUtils.java b/src/main/java/cc/fascinated/bat/common/TextChannelUtils.java new file mode 100644 index 0000000..073e7f3 --- /dev/null +++ b/src/main/java/cc/fascinated/bat/common/TextChannelUtils.java @@ -0,0 +1,31 @@ +package cc.fascinated.bat.common; + +import cc.fascinated.bat.service.DiscordService; + +/** + * @author Fascinated (fascinated7) + */ +public class TextChannelUtils { + /** + * Checks if a channel is valid + * + * @param id the id of the channel + * @return if the channel is valid + */ + public static boolean isValidChannel(String id) { + if (id == null) { + return false; + } + return DiscordService.JDA.getTextChannelById(id) != null; + } + + /** + * Gets the mention of a channel + * + * @param id the id of the channel + * @return the mention of the channel + */ + public static String getChannelMention(String id) { + return "<#" + id + ">"; + } +} diff --git a/src/main/java/cc/fascinated/bat/model/BatGuild.java b/src/main/java/cc/fascinated/bat/model/BatGuild.java deleted file mode 100644 index 7a860b0..0000000 --- a/src/main/java/cc/fascinated/bat/model/BatGuild.java +++ /dev/null @@ -1,33 +0,0 @@ -package cc.fascinated.bat.model; - -import cc.fascinated.bat.service.DiscordService; -import lombok.Getter; -import lombok.NonNull; -import lombok.RequiredArgsConstructor; -import lombok.Setter; -import net.dv8tion.jda.api.entities.Guild; -import org.springframework.data.annotation.Id; -import org.springframework.data.mongodb.core.mapping.Document; - -/** - * @author Fascinated (fascinated7) - */ -@RequiredArgsConstructor -@Getter @Setter -@Document(collection = "guilds") -public class BatGuild { - - /** - * The ID of the guild - */ - @NonNull @Id private final String id; - - /** - * Gets the guild as the JDA Guild - * - * @return the guild - */ - private Guild getDiscordGuild() { - return DiscordService.JDA.getGuildById(id); - } -} diff --git a/src/main/java/cc/fascinated/bat/model/guild/BatGuild.java b/src/main/java/cc/fascinated/bat/model/guild/BatGuild.java new file mode 100644 index 0000000..6301458 --- /dev/null +++ b/src/main/java/cc/fascinated/bat/model/guild/BatGuild.java @@ -0,0 +1,66 @@ +package cc.fascinated.bat.model.guild; + +import cc.fascinated.bat.common.Profile; +import cc.fascinated.bat.service.DiscordService; +import lombok.Getter; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import lombok.Setter; +import net.dv8tion.jda.api.entities.Guild; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; + +import java.util.HashMap; +import java.util.Map; + +/** + * @author Fascinated (fascinated7) + */ +@RequiredArgsConstructor +@Getter @Setter +@Document(collection = "guilds") +public class BatGuild { + + /** + * The ID of the guild + */ + @NonNull @Id private final String id; + + /** + * The profiles for this guild + */ + private Map profiles; + + /** + * Gets the profile for the guild + * + * @param clazz The class of the profile + * @param The type of the profile + * @return The profile + */ + public T getProfile(Class clazz) { + if (profiles == null) { + profiles = new HashMap<>(); + } + + Profile profile = profiles.values().stream().filter(p -> p.getClass().equals(clazz)).findFirst().orElse(null); + if (profile == null) { + try { + profile = (Profile) clazz.newInstance(); + profiles.put(profile.getProfileKey(), profile); + } catch (InstantiationException | IllegalAccessException e) { + e.printStackTrace(); + } + } + return (T) profile; + } + + /** + * Gets the guild as the JDA Guild + * + * @return the guild + */ + public Guild getDiscordGuild() { + return DiscordService.JDA.getGuildById(id); + } +} diff --git a/src/main/java/cc/fascinated/bat/model/guild/profiles/ScoreSaberScoreFeedProfile.java b/src/main/java/cc/fascinated/bat/model/guild/profiles/ScoreSaberScoreFeedProfile.java new file mode 100644 index 0000000..ab7c5d4 --- /dev/null +++ b/src/main/java/cc/fascinated/bat/model/guild/profiles/ScoreSaberScoreFeedProfile.java @@ -0,0 +1,75 @@ +package cc.fascinated.bat.model.guild.profiles; + +import cc.fascinated.bat.common.Profile; +import cc.fascinated.bat.service.DiscordService; +import lombok.Getter; +import lombok.Setter; +import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; + +import java.util.ArrayList; +import java.util.List; + +/** + * @author Fascinated (fascinated7) + */ +@Getter @Setter +public class ScoreSaberScoreFeedProfile extends Profile { + public ScoreSaberScoreFeedProfile() { + super("scoresaber-score-feed"); + } + + /** + * The channel ID of the score feed + */ + private String channelId; + + /** + * The users that are being tracked + */ + private List trackedUsers; + + /** + * Gets the tracked users + * + * @return the tracked users + */ + public List getTrackedUsers() { + if (this.trackedUsers == null) { + this.trackedUsers = new ArrayList<>(); + } + return this.trackedUsers; + } + + /** + * Adds a user to be tracked + * + * @param userId the user ID to add + */ + public void addTrackedUser(String userId) { + if (this.trackedUsers == null) { + this.trackedUsers = new ArrayList<>(); + } + trackedUsers.add(userId); + } + + /** + * Removes a user from being tracked + * + * @param userId the user ID to remove + */ + public void removeTrackedUser(String userId) { + if (this.trackedUsers == null) { + this.trackedUsers = new ArrayList<>(); + } + trackedUsers.remove(userId); + } + + /** + * Gets the channel as a TextChannel + * + * @return the channel as a TextChannel + */ + public TextChannel getAsTextChannel() { + return DiscordService.JDA.getTextChannelById(channelId); + } +} diff --git a/src/main/java/cc/fascinated/bat/repository/GuildRepository.java b/src/main/java/cc/fascinated/bat/repository/GuildRepository.java index 415891d..34e6101 100644 --- a/src/main/java/cc/fascinated/bat/repository/GuildRepository.java +++ b/src/main/java/cc/fascinated/bat/repository/GuildRepository.java @@ -1,6 +1,6 @@ package cc.fascinated.bat.repository; -import cc.fascinated.bat.model.BatGuild; +import cc.fascinated.bat.model.guild.BatGuild; import org.springframework.data.mongodb.repository.MongoRepository; /** diff --git a/src/main/java/cc/fascinated/bat/service/CommandService.java b/src/main/java/cc/fascinated/bat/service/CommandService.java index 6e69402..35de761 100644 --- a/src/main/java/cc/fascinated/bat/service/CommandService.java +++ b/src/main/java/cc/fascinated/bat/service/CommandService.java @@ -5,8 +5,11 @@ import cc.fascinated.bat.command.BatSubCommand; import cc.fascinated.bat.command.impl.global.beatsaber.scoresaber.LinkSubCommand; import cc.fascinated.bat.command.impl.global.beatsaber.scoresaber.ScoreSaberCommand; import cc.fascinated.bat.command.impl.global.beatsaber.scoresaber.UserSubCommand; +import cc.fascinated.bat.command.impl.guild.beatsaber.scoresaber.ScoreFeedChannelCommand; +import cc.fascinated.bat.command.impl.guild.beatsaber.scoresaber.ScoreFeedClearUsersCommand; +import cc.fascinated.bat.command.impl.guild.beatsaber.scoresaber.ScoreFeedUserCommand; import cc.fascinated.bat.common.EmbedUtils; -import cc.fascinated.bat.model.BatGuild; +import cc.fascinated.bat.model.guild.BatGuild; import cc.fascinated.bat.model.user.BatUser; import lombok.NonNull; import lombok.extern.log4j.Log4j2; @@ -14,7 +17,6 @@ import net.dv8tion.jda.api.JDA; import net.dv8tion.jda.api.entities.Guild; import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; import net.dv8tion.jda.api.hooks.ListenerAdapter; -import net.dv8tion.jda.api.interactions.commands.OptionMapping; import org.jetbrains.annotations.NotNull; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.ApplicationContext; @@ -22,7 +24,6 @@ import org.springframework.context.annotation.DependsOn; import org.springframework.stereotype.Service; import java.util.HashMap; -import java.util.List; import java.util.Map; /** @@ -64,8 +65,11 @@ public class CommandService extends ListenerAdapter { // Global commands registerCommand(context.getBean(ScoreSaberCommand.class) .addSubCommand("link", context.getBean(LinkSubCommand.class)) - .addSubCommand("user", context.getBean(UserSubCommand.class) - )); + .addSubCommand("user", context.getBean(UserSubCommand.class)) + .addSubCommand("score-feed-user", context.getBean(ScoreFeedUserCommand.class)) + .addSubCommand("score-feed-channel", context.getBean(ScoreFeedChannelCommand.class)) + .addSubCommand("score-feed-clear-users", context.getBean(ScoreFeedClearUsersCommand.class)) + ); registerSlashCommands(); // Register all slash commands } @@ -125,18 +129,13 @@ public class CommandService extends ListenerAdapter { BatUser user = userService.getUser(event.getUser().getId()); // No args provided, use the main command executor - List options = event.getInteraction().getOptions(); - try { - if (options.isEmpty()) { - command.execute(guild, user, event.getChannel().asTextChannel(), event.getMember(), event.getInteraction(), null); - } - - // Check if the sub command exists - for (Map.Entry subCommand : command.getSubCommands().entrySet()) { - for (OptionMapping option : options) { - if (subCommand.getKey().equalsIgnoreCase(option.getName())) { - subCommand.getValue().execute(guild, user, event.getChannel().asTextChannel(), event.getMember(), event.getInteraction(), option); + if (event.getInteraction().getSubcommandName() == null) { + command.execute(guild, user, event.getChannel().asTextChannel(), event.getMember(), event.getInteraction()); + } else { + for (Map.Entry subCommand : command.getSubCommands().entrySet()) { + if (subCommand.getKey().equalsIgnoreCase(event.getInteraction().getSubcommandName())) { + subCommand.getValue().execute(guild, user, event.getChannel().asTextChannel(), event.getMember(), event.getInteraction()); break; } } diff --git a/src/main/java/cc/fascinated/bat/service/GuildService.java b/src/main/java/cc/fascinated/bat/service/GuildService.java index 9f2b400..350dcac 100644 --- a/src/main/java/cc/fascinated/bat/service/GuildService.java +++ b/src/main/java/cc/fascinated/bat/service/GuildService.java @@ -1,6 +1,6 @@ package cc.fascinated.bat.service; -import cc.fascinated.bat.model.BatGuild; +import cc.fascinated.bat.model.guild.BatGuild; import cc.fascinated.bat.repository.GuildRepository; import lombok.NonNull; import lombok.extern.log4j.Log4j2; diff --git a/src/main/java/cc/fascinated/bat/service/ScoreSaberService.java b/src/main/java/cc/fascinated/bat/service/ScoreSaberService.java index 0c5d610..0adb9bc 100644 --- a/src/main/java/cc/fascinated/bat/service/ScoreSaberService.java +++ b/src/main/java/cc/fascinated/bat/service/ScoreSaberService.java @@ -1,27 +1,47 @@ package cc.fascinated.bat.service; -import cc.fascinated.bat.common.DateUtils; -import cc.fascinated.bat.common.WebRequest; +import cc.fascinated.bat.BatApplication; +import cc.fascinated.bat.common.*; import cc.fascinated.bat.exception.BadRequestException; import cc.fascinated.bat.exception.ResourceNotFoundException; -import cc.fascinated.bat.model.beatsaber.scoresaber.ScoreSaberAccountToken; -import cc.fascinated.bat.model.beatsaber.scoresaber.ScoreSaberPageMetadataToken; -import cc.fascinated.bat.model.beatsaber.scoresaber.ScoreSaberPlayerScoreToken; -import cc.fascinated.bat.model.beatsaber.scoresaber.ScoreSaberScoresPageToken; +import cc.fascinated.bat.model.beatsaber.scoresaber.*; +import cc.fascinated.bat.model.guild.BatGuild; +import cc.fascinated.bat.model.guild.profiles.ScoreSaberScoreFeedProfile; import cc.fascinated.bat.model.user.profiles.ScoreSaberProfile; +import com.google.gson.JsonObject; +import lombok.NonNull; +import lombok.SneakyThrows; import lombok.extern.log4j.Log4j2; +import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.MessageEmbed; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketSession; +import org.springframework.web.socket.client.standard.StandardWebSocketClient; +import org.springframework.web.socket.handler.TextWebSocketHandler; import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.Optional; @Service @Log4j2(topic = "ScoreSaber Service") -public class ScoreSaberService { +public class ScoreSaberService extends TextWebSocketHandler { + private final GuildService guildService; + private static final String SCORESABER_API = "https://scoresaber.com/api/"; private static final String GET_PLAYER_ENDPOINT = SCORESABER_API + "player/%s/full"; private static final String GET_PLAYER_SCORES_ENDPOINT = SCORESABER_API + "player/%s/scores?limit=100&sort=%s&page=%s&withMetadata=true"; + @Autowired + public ScoreSaberService(@NonNull GuildService guildService) { + this.guildService = guildService; + connectWebSocket(); + } + /** * Gets the account from the ScoreSaber API. * @@ -82,4 +102,101 @@ public class ScoreSaberService { return scores; } + + /** + * Builds an embed for a score. + * + * @param score The score. + * @return The embed. + */ + public MessageEmbed buildScoreEmbed(ScoreSaberPlayerScoreToken score) { + ScoreSaberScoreToken scoreToken = score.getScore(); + ScoreSaberLeaderboardToken leaderboardToken = score.getLeaderboard(); + ScoreSaberScoreToken.LeaderboardPlayerInfo playerInfo = scoreToken.getLeaderboardPlayerInfo(); + return new EmbedBuilder() + .setAuthor(playerInfo.getName() + " just set a new score!", "https://scoresaber.com/u/%s".formatted(playerInfo.getId()), + "https://cdn.scoresaber.com/avatars/%s.jpg".formatted(playerInfo.getId())) + .setDescription("**%s** (%s%s)\n[[Map Link]](%s) [[SS Profile]](%s)".formatted( + leaderboardToken.getSongName(), + ScoreSaberUtils.getFormattedDifficulty(leaderboardToken.getDifficulty().getDifficulty()), + leaderboardToken.isRanked() ? " " + leaderboardToken.getStars() + "⭐" : "", + "https://scoresaber.com/leaderboard/%s".formatted(leaderboardToken.getId()), + "https://scoresaber.com/u/%s".formatted(playerInfo.getId()) + )) + .addField("Accuracy", "%s%%".formatted( + leaderboardToken.getMaxScore() == 0 ? "N/A" : NumberUtils.formatNumberCommas(((double) scoreToken.getBaseScore() / leaderboardToken.getMaxScore()) * 100) + ), true) + .addField("Raw PP", scoreToken.getPp() == 0 ? "Unranked" : NumberUtils.formatNumberCommas(scoreToken.getPp()), true) + .addField("Global Rank", "#%s".formatted(NumberUtils.formatNumberCommas(scoreToken.getRank())), true) + .addField("Misses", "%s".formatted(scoreToken.getMissedNotes()), true) + .addField("Bad Cuts", "%s".formatted(scoreToken.getBadCuts()), true) + .addField("Max Combo", "%s %s".formatted( + scoreToken.getMaxCombo(), + scoreToken.getMaxCombo() == leaderboardToken.getMaxScore() ? "(FC)" : "" + ), true) + .setColor(Colors.DEFAULT) + .setTimestamp(DateUtils.getDateFromString(scoreToken.getTimeSet()).toInstant()) + .build(); + } + + /** + * Connects to the ScoreSaber WebSocket. + */ + @SneakyThrows + private void connectWebSocket() { + log.info("Connecting to the ScoreSaber WSS."); + new StandardWebSocketClient().execute(this, "wss://scoresaber.com/ws").get(); + } + + @Override + public void afterConnectionEstablished(@NonNull WebSocketSession session) { + log.info("Connected to the ScoreSaber WSS."); + } + + @Override + public void afterConnectionClosed(@NonNull WebSocketSession session, @NonNull CloseStatus status) { + log.info("Disconnected from the ScoreSaber WSS."); + connectWebSocket(); // Reconnect to the WebSocket. + } + + @Override @SneakyThrows + protected void handleTextMessage(@NonNull WebSocketSession session, @NonNull TextMessage message) { + // Ignore the connection message. + if (message.getPayload().equals("Connected to the ScoreSaber WSS")) { + return; + } + + try { + JsonObject json = BatApplication.GSON.fromJson(message.getPayload(), JsonObject.class); + String command = json.get("commandName").getAsString(); + JsonObject data = json.get("commandData").getAsJsonObject(); + + if (command.equals("score")) { + ScoreSaberPlayerScoreToken score = BatApplication.GSON.fromJson(data, ScoreSaberPlayerScoreToken.class); + ScoreSaberScoreToken.LeaderboardPlayerInfo playerInfo = score.getScore().getLeaderboardPlayerInfo(); + + for (Guild guild : DiscordService.JDA.getGuilds()) { + BatGuild batGuild = guildService.getGuild(guild.getId()); + if (batGuild == null) { + continue; + } + + ScoreSaberScoreFeedProfile profile = batGuild.getProfile(ScoreSaberScoreFeedProfile.class); + if (profile == null) { + continue; + } + if (profile.getChannelId() == null) { + continue; + } + if (!profile.getTrackedUsers().contains(playerInfo.getId())) { + continue; + } + + profile.getAsTextChannel().sendMessageEmbeds(this.buildScoreEmbed(score)).queue(); + } + } + } catch (Exception ex) { + log.error("An error occurred while handling the message.", ex); + } + } }