add scoresaber feed command and websocket impl
All checks were successful
Deploy to Dokku / docker (ubuntu-latest) (push) Successful in 53s

This commit is contained in:
Lee 2024-06-24 17:42:57 +01:00
parent 39cdab27ce
commit c0ae0fc596
19 changed files with 570 additions and 81 deletions

19
pom.xml

@ -81,6 +81,10 @@
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-mongodb</artifactId> <artifactId>spring-boot-starter-data-mongodb</artifactId>
</dependency> </dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<!-- Libraries --> <!-- Libraries -->
<dependency> <dependency>
@ -89,11 +93,6 @@
<version>1.18.32</version> <version>1.18.32</version>
<scope>provided</scope> <scope>provided</scope>
</dependency> </dependency>
<dependency>
<groupId>org.apache.httpcomponents.client5</groupId>
<artifactId>httpclient5</artifactId>
<version>5.3.1</version>
</dependency>
<!-- Dependencies --> <!-- Dependencies -->
<dependency> <dependency>
@ -101,6 +100,16 @@
<artifactId>JDA</artifactId> <artifactId>JDA</artifactId>
<version>5.0.0-beta.24</version> <version>5.0.0-beta.24</version>
</dependency> </dependency>
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
<version>2.10.1</version>
</dependency>
<dependency>
<groupId>org.apache.httpcomponents.client5</groupId>
<artifactId>httpclient5</artifactId>
<version>5.3.1</version>
</dependency>
<!-- Test Dependencies --> <!-- Test Dependencies -->
<dependency> <dependency>

@ -1,5 +1,7 @@
package cc.fascinated.bat; package cc.fascinated.bat;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import lombok.NonNull; import lombok.NonNull;
import lombok.SneakyThrows; import lombok.SneakyThrows;
import lombok.extern.log4j.Log4j2; import lombok.extern.log4j.Log4j2;
@ -14,6 +16,8 @@ import java.util.Objects;
@SpringBootApplication() @SpringBootApplication()
@Log4j2(topic = "Ember") @Log4j2(topic = "Ember")
public class BatApplication { public class BatApplication {
public static Gson GSON = new GsonBuilder().create();
@SneakyThrows @SneakyThrows
public static void main(@NonNull String[] args) { public static void main(@NonNull String[] args) {
// Handle loading of our configuration file // Handle loading of our configuration file

@ -1,11 +1,10 @@
package cc.fascinated.bat.command; 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 cc.fascinated.bat.model.user.BatUser;
import lombok.NonNull; import lombok.NonNull;
import net.dv8tion.jda.api.entities.Member; import net.dv8tion.jda.api.entities.Member;
import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; 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 net.dv8tion.jda.api.interactions.commands.SlashCommandInteraction;
/** /**
@ -21,14 +20,12 @@ public interface BatCommandExecutor {
* @param channel the channel the command was executed in * @param channel the channel the command was executed in
* @param member the member that executed the command * @param member the member that executed the command
* @param interaction the slash command interaction * @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( default void execute(
@NonNull BatGuild guild, @NonNull BatGuild guild,
@NonNull BatUser user, @NonNull BatUser user,
@NonNull TextChannel channel, @NonNull TextChannel channel,
@NonNull Member member, @NonNull Member member,
@NonNull SlashCommandInteraction interaction, @NonNull SlashCommandInteraction interaction
OptionMapping option
) {} ) {}
} }

@ -2,7 +2,7 @@ package cc.fascinated.bat.command.impl.global.beatsaber.scoresaber;
import cc.fascinated.bat.command.BatSubCommand; import cc.fascinated.bat.command.BatSubCommand;
import cc.fascinated.bat.common.EmbedUtils; 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.beatsaber.scoresaber.ScoreSaberAccountToken;
import cc.fascinated.bat.model.user.BatUser; import cc.fascinated.bat.model.user.BatUser;
import cc.fascinated.bat.model.user.profiles.ScoreSaberProfile; import cc.fascinated.bat.model.user.profiles.ScoreSaberProfile;
@ -31,7 +31,8 @@ public class LinkSubCommand extends BatSubCommand {
} }
@Override @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) { if (option == null) {
interaction.replyEmbeds(EmbedUtils.buildErrorEmbed("Please provide a ScoreSaber profile link").build()).queue(); interaction.replyEmbeds(EmbedUtils.buildErrorEmbed("Please provide a ScoreSaber profile link").build()).queue();
return; return;

@ -5,19 +5,21 @@ import cc.fascinated.bat.common.Colors;
import cc.fascinated.bat.common.DateUtils; import cc.fascinated.bat.common.DateUtils;
import cc.fascinated.bat.common.EmbedUtils; import cc.fascinated.bat.common.EmbedUtils;
import cc.fascinated.bat.common.NumberUtils; 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.beatsaber.scoresaber.ScoreSaberAccountToken;
import cc.fascinated.bat.model.user.BatUser; import cc.fascinated.bat.model.user.BatUser;
import cc.fascinated.bat.model.user.profiles.ScoreSaberProfile; import cc.fascinated.bat.model.user.profiles.ScoreSaberProfile;
import cc.fascinated.bat.service.ScoreSaberService; import cc.fascinated.bat.service.ScoreSaberService;
import lombok.NonNull; import lombok.NonNull;
import net.dv8tion.jda.api.EmbedBuilder; 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.Member;
import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; 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.OptionType;
import net.dv8tion.jda.api.interactions.commands.SlashCommandInteraction; 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.OptionData;
import net.dv8tion.jda.api.interactions.commands.build.SubcommandData;
import net.dv8tion.jda.internal.interactions.CommandDataImpl; import net.dv8tion.jda.internal.interactions.CommandDataImpl;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@ -37,15 +39,31 @@ public class ScoreSaberCommand extends BatCommand {
super.setCategory(Category.BEAT_SABER); super.setCategory(Category.BEAT_SABER);
this.scoreSaberService = scoreSaberService; this.scoreSaberService = scoreSaberService;
super.setDescription("View a user's ScoreSaber profile"); super.setDescription("General ScoreSaber commands");
super.setCommandData(new CommandDataImpl(this.getName(), this.getDescription()) super.setCommandData(new CommandDataImpl(this.getName(), this.getDescription())
.addOptions(new OptionData(OptionType.STRING, "link", "Link your ScoreSaber profile", false)) .addSubcommands(new SubcommandData("link", "Link your ScoreSaber profile")
.addOptions(new OptionData(OptionType.USER, "user", "The user to view the ScoreSaber profile of", false)) .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 @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); sendProfileEmbed(true, user, scoreSaberService, interaction);
} }

@ -1,7 +1,8 @@
package cc.fascinated.bat.command.impl.global.beatsaber.scoresaber; package cc.fascinated.bat.command.impl.global.beatsaber.scoresaber;
import cc.fascinated.bat.command.BatSubCommand; 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.model.user.BatUser;
import cc.fascinated.bat.service.ScoreSaberService; import cc.fascinated.bat.service.ScoreSaberService;
import cc.fascinated.bat.service.UserService; import cc.fascinated.bat.service.UserService;
@ -28,13 +29,23 @@ public class UserSubCommand extends BatSubCommand {
} }
@Override @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) {
BatUser target = userService.getUser(option.getAsUser().getId()); OptionMapping option = interaction.getOption("user");
if (target == null) { if (option == null) {
interaction.reply("User not found").queue(); interaction.replyEmbeds(EmbedUtils.buildErrorEmbed("Please provide a user to view the ScoreSaber profile of").build()).queue();
return; 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); ScoreSaberCommand.sendProfileEmbed(false, target, scoreSaberService, interaction);
} }
} }

@ -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();
}
}

@ -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();
}
}

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

@ -19,6 +19,7 @@ public class NumberUtils {
public static String formatNumberCommas(double number) { public static String formatNumberCommas(double number) {
NumberFormat format = NumberFormat.getNumberInstance(); NumberFormat format = NumberFormat.getNumberInstance();
format.setGroupingUsed(true); format.setGroupingUsed(true);
format.setMaximumFractionDigits(2);
return format.format(number); return format.format(number);
} }
} }

@ -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";
};
}
}

@ -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 + ">";
}
}

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

@ -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<String, Profile> profiles;
/**
* Gets the profile for the guild
*
* @param clazz The class of the profile
* @param <T> The type of the profile
* @return The profile
*/
public <T extends Profile> 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);
}
}

@ -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<String> trackedUsers;
/**
* Gets the tracked users
*
* @return the tracked users
*/
public List<String> 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);
}
}

@ -1,6 +1,6 @@
package cc.fascinated.bat.repository; 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; import org.springframework.data.mongodb.repository.MongoRepository;
/** /**

@ -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.LinkSubCommand;
import cc.fascinated.bat.command.impl.global.beatsaber.scoresaber.ScoreSaberCommand; 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.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.common.EmbedUtils;
import cc.fascinated.bat.model.BatGuild; import cc.fascinated.bat.model.guild.BatGuild;
import cc.fascinated.bat.model.user.BatUser; import cc.fascinated.bat.model.user.BatUser;
import lombok.NonNull; import lombok.NonNull;
import lombok.extern.log4j.Log4j2; 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.entities.Guild;
import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent;
import net.dv8tion.jda.api.hooks.ListenerAdapter; import net.dv8tion.jda.api.hooks.ListenerAdapter;
import net.dv8tion.jda.api.interactions.commands.OptionMapping;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationContext;
@ -22,7 +24,6 @@ import org.springframework.context.annotation.DependsOn;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.HashMap; import java.util.HashMap;
import java.util.List;
import java.util.Map; import java.util.Map;
/** /**
@ -64,8 +65,11 @@ public class CommandService extends ListenerAdapter {
// Global commands // Global commands
registerCommand(context.getBean(ScoreSaberCommand.class) registerCommand(context.getBean(ScoreSaberCommand.class)
.addSubCommand("link", context.getBean(LinkSubCommand.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 registerSlashCommands(); // Register all slash commands
} }
@ -125,18 +129,13 @@ public class CommandService extends ListenerAdapter {
BatUser user = userService.getUser(event.getUser().getId()); BatUser user = userService.getUser(event.getUser().getId());
// No args provided, use the main command executor // No args provided, use the main command executor
List<OptionMapping> options = event.getInteraction().getOptions();
try { try {
if (options.isEmpty()) { if (event.getInteraction().getSubcommandName() == null) {
command.execute(guild, user, event.getChannel().asTextChannel(), event.getMember(), event.getInteraction(), null); command.execute(guild, user, event.getChannel().asTextChannel(), event.getMember(), event.getInteraction());
} } else {
// Check if the sub command exists
for (Map.Entry<String, BatSubCommand> subCommand : command.getSubCommands().entrySet()) { for (Map.Entry<String, BatSubCommand> subCommand : command.getSubCommands().entrySet()) {
for (OptionMapping option : options) { if (subCommand.getKey().equalsIgnoreCase(event.getInteraction().getSubcommandName())) {
if (subCommand.getKey().equalsIgnoreCase(option.getName())) { subCommand.getValue().execute(guild, user, event.getChannel().asTextChannel(), event.getMember(), event.getInteraction());
subCommand.getValue().execute(guild, user, event.getChannel().asTextChannel(), event.getMember(), event.getInteraction(), option);
break; break;
} }
} }

@ -1,6 +1,6 @@
package cc.fascinated.bat.service; 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 cc.fascinated.bat.repository.GuildRepository;
import lombok.NonNull; import lombok.NonNull;
import lombok.extern.log4j.Log4j2; import lombok.extern.log4j.Log4j2;

@ -1,27 +1,47 @@
package cc.fascinated.bat.service; package cc.fascinated.bat.service;
import cc.fascinated.bat.common.DateUtils; import cc.fascinated.bat.BatApplication;
import cc.fascinated.bat.common.WebRequest; import cc.fascinated.bat.common.*;
import cc.fascinated.bat.exception.BadRequestException; import cc.fascinated.bat.exception.BadRequestException;
import cc.fascinated.bat.exception.ResourceNotFoundException; import cc.fascinated.bat.exception.ResourceNotFoundException;
import cc.fascinated.bat.model.beatsaber.scoresaber.ScoreSaberAccountToken; import cc.fascinated.bat.model.beatsaber.scoresaber.*;
import cc.fascinated.bat.model.beatsaber.scoresaber.ScoreSaberPageMetadataToken; import cc.fascinated.bat.model.guild.BatGuild;
import cc.fascinated.bat.model.beatsaber.scoresaber.ScoreSaberPlayerScoreToken; import cc.fascinated.bat.model.guild.profiles.ScoreSaberScoreFeedProfile;
import cc.fascinated.bat.model.beatsaber.scoresaber.ScoreSaberScoresPageToken;
import cc.fascinated.bat.model.user.profiles.ScoreSaberProfile; 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 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.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.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.List; import java.util.List;
import java.util.Optional;
@Service @Log4j2(topic = "ScoreSaber Service") @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 SCORESABER_API = "https://scoresaber.com/api/";
private static final String GET_PLAYER_ENDPOINT = SCORESABER_API + "player/%s/full"; 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"; 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. * Gets the account from the ScoreSaber API.
* *
@ -82,4 +102,101 @@ public class ScoreSaberService {
return scores; 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);
}
}
} }