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);
+ }
+ }
}