diff --git a/HELP.md b/HELP.md deleted file mode 100644 index 4611895..0000000 --- a/HELP.md +++ /dev/null @@ -1,22 +0,0 @@ -# Getting Started - -### Reference Documentation -For further reference, please consider the following sections: - -* [Official Apache Maven documentation](https://maven.apache.org/guides/index.html) -* [Spring Boot Maven Plugin Reference Guide](https://docs.spring.io/spring-boot/docs/3.3.1/maven-plugin/reference/html/) -* [Create an OCI image](https://docs.spring.io/spring-boot/docs/3.3.1/maven-plugin/reference/html/#build-image) -* [Spring Data MongoDB](https://docs.spring.io/spring-boot/docs/3.3.1/reference/htmlsingle/index.html#data.nosql.mongodb) - -### Guides -The following guides illustrate how to use some features concretely: - -* [Accessing Data with MongoDB](https://spring.io/guides/gs/accessing-data-mongodb/) - -### Maven Parent overrides - -Due to Maven's design, elements are inherited from the parent POM to the project POM. -While most of the inheritance is fine, it also inherits unwanted elements like `` and `` from the parent. -To prevent this, the project POM contains empty overrides for these elements. -If you manually switch to a different parent and actually want the inheritance, you need to remove those overrides. - diff --git a/pom.xml b/pom.xml index ff18efe..aed68c7 100644 --- a/pom.xml +++ b/pom.xml @@ -29,17 +29,16 @@ + + + org.springframework.boot + spring-boot-starter-web + org.springframework.boot spring-boot-starter-data-mongodb - - org.springframework.boot - spring-boot-starter-test - test - - org.projectlombok @@ -47,6 +46,11 @@ 1.18.32 provided + + org.apache.httpcomponents.client5 + httpclient5 + 5.3.1 + @@ -54,5 +58,12 @@ JDA 5.0.0-beta.24 + + + + org.springframework.boot + spring-boot-starter-test + test + diff --git a/src/main/java/cc/fascinated/bat/BatApplication.java b/src/main/java/cc/fascinated/bat/BatApplication.java index 2f400ca..aac61b1 100644 --- a/src/main/java/cc/fascinated/bat/BatApplication.java +++ b/src/main/java/cc/fascinated/bat/BatApplication.java @@ -9,7 +9,6 @@ import org.springframework.boot.autoconfigure.SpringBootApplication; import java.io.File; import java.nio.file.Files; import java.nio.file.StandardCopyOption; -import java.util.EnumSet; import java.util.Objects; @SpringBootApplication() diff --git a/src/main/java/cc/fascinated/bat/command/BatCommand.java b/src/main/java/cc/fascinated/bat/command/BatCommand.java new file mode 100644 index 0000000..d58fc62 --- /dev/null +++ b/src/main/java/cc/fascinated/bat/command/BatCommand.java @@ -0,0 +1,89 @@ +package cc.fascinated.bat.command; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NonNull; +import lombok.Setter; +import net.dv8tion.jda.api.Permission; +import net.dv8tion.jda.api.interactions.commands.OptionMapping; +import net.dv8tion.jda.api.interactions.commands.SlashCommandInteraction; +import net.dv8tion.jda.internal.interactions.CommandDataImpl; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * @author Fascinated (fascinated7) + */ +@Getter @Setter +public abstract class BatCommand implements BatCommandExecutor { + /** + * The name of the command + */ + private final String name; + + /** + * The description of the command + */ + private String description; + + /** + * The category of the command + */ + private final Category category; + + /** + * The command data for the slash command + */ + private CommandDataImpl commandData; + + /** + * The sub commands of the command + */ + private Map subCommands = new HashMap<>(); + + public BatCommand(@NonNull String name) { + this.name = name; + + // Default values + this.description = "No description provided."; + this.category = Category.GENERAL; + } + + /** + * Adds a sub command to the command + * + * @param name The name of the sub command + * @param subCommand The sub command + */ + public BatCommand addSubCommand(@NonNull String name, @NonNull BatSubCommand subCommand) { + this.subCommands.put(name.toLowerCase(), subCommand); + return this; + } + + /** + * Gets all the options for the command + * + * @param interaction The slash command interaction + * @return The option strings + */ + public List getOptions(SlashCommandInteraction interaction) { + return interaction.getOptions().stream().map(OptionMapping::getName).toList(); + } + + /** + * The category of the command + */ + @AllArgsConstructor @Getter + private enum Category { + GENERAL("General"), + MODERATION("Moderation"), + SERVER("Server"); + + /** + * The name of the category + */ + private final String name; + } +} diff --git a/src/main/java/cc/fascinated/bat/command/BatCommandExecutor.java b/src/main/java/cc/fascinated/bat/command/BatCommandExecutor.java new file mode 100644 index 0000000..e0202e8 --- /dev/null +++ b/src/main/java/cc/fascinated/bat/command/BatCommandExecutor.java @@ -0,0 +1,36 @@ +package cc.fascinated.bat.command; + +import cc.fascinated.bat.model.BatGuild; +import cc.fascinated.bat.model.user.BatUser; +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.channel.concrete.TextChannel; +import net.dv8tion.jda.api.interactions.commands.OptionMapping; +import net.dv8tion.jda.api.interactions.commands.SlashCommandInteraction; + +/** + * @author Fascinated (fascinated7) + */ +public interface BatCommandExecutor { + + /** + * Executes the command using a slash command interaction. + * + * @param guild the bat guild the command was executed in + * @param user the bat user that executed the command + * @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 + ) {} +} diff --git a/src/main/java/cc/fascinated/bat/command/BatSubCommand.java b/src/main/java/cc/fascinated/bat/command/BatSubCommand.java new file mode 100644 index 0000000..12d8002 --- /dev/null +++ b/src/main/java/cc/fascinated/bat/command/BatSubCommand.java @@ -0,0 +1,18 @@ +package cc.fascinated.bat.command; + +import cc.fascinated.bat.model.BatGuild; +import cc.fascinated.bat.model.user.BatUser; +import cc.fascinated.bat.service.GuildService; +import cc.fascinated.bat.service.UserService; +import lombok.AllArgsConstructor; +import lombok.Getter; +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.SlashCommandInteraction; + +/** + * @author Fascinated (fascinated7) + */ +@AllArgsConstructor @Getter +public class BatSubCommand implements BatCommandExecutor { } 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 new file mode 100644 index 0000000..bf356ff --- /dev/null +++ b/src/main/java/cc/fascinated/bat/command/impl/global/beatsaber/scoresaber/LinkSubCommand.java @@ -0,0 +1,64 @@ +package cc.fascinated.bat.command.impl.global.beatsaber.scoresaber; + +import cc.fascinated.bat.command.BatSubCommand; +import cc.fascinated.bat.common.Profile; +import cc.fascinated.bat.model.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 cc.fascinated.bat.service.UserService; +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; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import java.util.Map; + +/** + * @author Fascinated (fascinated7) + */ +@Component +public class LinkSubCommand extends BatSubCommand { + + private final ScoreSaberService scoreSaberService; + private final UserService userService; + + @Autowired + public LinkSubCommand(@NonNull ScoreSaberService scoreSaberService, @NonNull UserService userService) { + this.scoreSaberService = scoreSaberService; + this.userService = userService; + } + + @Override + public void execute(@NonNull BatGuild guild, @NonNull BatUser user, @NonNull TextChannel channel, @NonNull Member member, @NonNull SlashCommandInteraction interaction, OptionMapping option) { + if (option == null) { + interaction.reply("You must provide a ScoreSaber profile link to link your profile").queue(); + return; + } + + String link = option.getAsString(); + if (!link.contains("scoresaber.com/u/")) { + interaction.reply("Invalid ScoreSaber profile link").queue(); + return; + } + + String id = link.split("scoresaber.com/u/")[1]; + if (id.contains("/")) { + id = id.split("/")[0]; + } + + ScoreSaberAccountToken account = scoreSaberService.getAccount(id); + if (account == null) { + interaction.reply("Invalid ScoreSaber profile link").queue(); + return; + } + + ((ScoreSaberProfile) user.getProfile(ScoreSaberProfile.class)).setId(id); + userService.saveUser(user); + interaction.reply("Successfully linked your ScoreSaber profile").queue(); + } +} 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 new file mode 100644 index 0000000..d2beedc --- /dev/null +++ b/src/main/java/cc/fascinated/bat/command/impl/global/beatsaber/scoresaber/ScoreSaberCommand.java @@ -0,0 +1,72 @@ +package cc.fascinated.bat.command.impl.global.beatsaber.scoresaber; + +import cc.fascinated.bat.command.BatCommand; +import cc.fascinated.bat.model.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.GuildService; +import cc.fascinated.bat.service.ScoreSaberService; +import cc.fascinated.bat.service.UserService; +import lombok.NonNull; +import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.entities.Member; +import net.dv8tion.jda.api.entities.MessageEmbed; +import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; +import net.dv8tion.jda.api.interactions.commands.OptionMapping; +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.internal.interactions.CommandDataImpl; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +/** + * @author Fascinated (fascinated7) + */ +@Component +public class ScoreSaberCommand extends BatCommand { + private final ScoreSaberService scoreSaberService; + + @Autowired + public ScoreSaberCommand(@NonNull ScoreSaberService scoreSaberService) { + super("scoresaber"); + this.scoreSaberService = scoreSaberService; + + super.setDescription("View a user's ScoreSaber profile"); + 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)) + ); + } + + @Override + public void execute(@NonNull BatGuild guild, @NonNull BatUser user, @NonNull TextChannel channel, @NonNull Member member, @NonNull SlashCommandInteraction interaction, OptionMapping option) { + ScoreSaberProfile profile = user.getProfile(ScoreSaberProfile.class); + if (profile.getId() == null) { + interaction.reply("You must link your ScoreSaber profile first").queue(); + return; + } + + // todo: handle rate limits + ScoreSaberAccountToken account = scoreSaberService.getAccount(profile.getId()); + if (account == null) { + interaction.reply("Invalid ScoreSaber profile, please re-link your account.").queue(); + return; + } + + interaction.replyEmbeds(buildProfileEmbed(account)).queue(); + } + + /** + * Builds the profile embed for the ScoreSaber profile + * + * @param account The account to build the embed for + * @return The built embed + */ + public static MessageEmbed buildProfileEmbed(ScoreSaberAccountToken account) { + return new EmbedBuilder() + .addField("Name", account.getName(), true) + .build(); + } +} 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 new file mode 100644 index 0000000..00af7df --- /dev/null +++ b/src/main/java/cc/fascinated/bat/command/impl/global/beatsaber/scoresaber/UserSubCommand.java @@ -0,0 +1,25 @@ +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.model.user.BatUser; +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.channel.concrete.TextChannel; +import net.dv8tion.jda.api.interactions.commands.OptionMapping; +import net.dv8tion.jda.api.interactions.commands.SlashCommandInteraction; +import org.springframework.stereotype.Component; + +/** + * @author Fascinated (fascinated7) + */ +@Component +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) { + interaction.reply("view someone elses profile").queue(); + } +} diff --git a/src/main/java/cc/fascinated/bat/common/DateUtils.java b/src/main/java/cc/fascinated/bat/common/DateUtils.java new file mode 100644 index 0000000..2b01724 --- /dev/null +++ b/src/main/java/cc/fascinated/bat/common/DateUtils.java @@ -0,0 +1,23 @@ +package cc.fascinated.bat.common; + +import lombok.experimental.UtilityClass; + +import java.time.Instant; +import java.time.format.DateTimeFormatter; +import java.util.Date; + +@UtilityClass +public class DateUtils { + + private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ISO_INSTANT; + + /** + * Gets the date from a string. + * + * @param date The date string. + * @return The date. + */ + public static Date getDateFromString(String date) { + return Date.from(Instant.from(FORMATTER.parse(date))); + } +} diff --git a/src/main/java/cc/fascinated/bat/common/EmbedUtils.java b/src/main/java/cc/fascinated/bat/common/EmbedUtils.java new file mode 100644 index 0000000..f326acc --- /dev/null +++ b/src/main/java/cc/fascinated/bat/common/EmbedUtils.java @@ -0,0 +1,50 @@ +package cc.fascinated.bat.common; + +import net.dv8tion.jda.api.EmbedBuilder; + +import java.time.LocalDateTime; + +/** + * @author Fascinated (fascinated7) + */ +public class EmbedUtils { + + /** + * Builds a generic embed + * + * @param description the description of the embed + * @return the embed builder + */ + public static EmbedBuilder buildGenericEmbed(String description) { + return new EmbedBuilder() + .setDescription(description) + .setTimestamp(LocalDateTime.now()) + .setColor(0x2F3136); + } + + /** + * Builds an error embed + * + * @param description the description of the embed + * @return the embed builder + */ + public static EmbedBuilder buildErrorEmbed(String description) { + return new EmbedBuilder() + .setDescription(description) + .setTimestamp(LocalDateTime.now()) + .setColor(0xFF0000); + } + + /** + * Builds a success embed + * + * @param description the description of the embed + * @return the embed builder + */ + public static EmbedBuilder buildSuccessEmbed(String description) { + return new EmbedBuilder() + .setDescription(description) + .setTimestamp(LocalDateTime.now()) + .setColor(0x00FF00); + } +} diff --git a/src/main/java/cc/fascinated/bat/common/Profile.java b/src/main/java/cc/fascinated/bat/common/Profile.java new file mode 100644 index 0000000..eb312ce --- /dev/null +++ b/src/main/java/cc/fascinated/bat/common/Profile.java @@ -0,0 +1,16 @@ +package cc.fascinated.bat.common; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.data.annotation.Transient; + +/** + * @author Fascinated (fascinated7) + */ +@AllArgsConstructor @Getter +public class Profile { + /** + * The key of the profile. + */ + @Transient private final String profileKey; +} diff --git a/src/main/java/cc/fascinated/bat/common/WebRequest.java b/src/main/java/cc/fascinated/bat/common/WebRequest.java new file mode 100644 index 0000000..6f4eecc --- /dev/null +++ b/src/main/java/cc/fascinated/bat/common/WebRequest.java @@ -0,0 +1,77 @@ +package cc.fascinated.bat.common; + +import cc.fascinated.bat.exception.RateLimitException; +import lombok.experimental.UtilityClass; +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.ResponseEntity; +import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; +import org.springframework.web.client.RestClient; + +@UtilityClass +public class WebRequest { + + /** + * The web client. + */ + private static final RestClient CLIENT; + + static { + HttpComponentsClientHttpRequestFactory requestFactory = new HttpComponentsClientHttpRequestFactory(); + requestFactory.setConnectTimeout(2500); // 2.5 seconds + CLIENT = RestClient.builder() + .requestFactory(requestFactory) + .build(); + } + + /** + * Gets a response from the given URL. + * + * @param url the url + * @return the response + * @param the type of the response + */ + public static T getAsEntity(String url, Class clazz) throws RateLimitException { + ResponseEntity responseEntity = CLIENT.get() + .uri(url) + .retrieve() + .onStatus(HttpStatusCode::isError, (request, response) -> {}) // Don't throw exceptions on error + .toEntity(clazz); + + if (responseEntity.getStatusCode().isError()) { + return null; + } + if (responseEntity.getStatusCode().isSameCodeAs(HttpStatus.TOO_MANY_REQUESTS)) { + throw new RateLimitException("Rate limit reached"); + } + return responseEntity.getBody(); + } + + /** + * Gets a response from the given URL. + * + * @param url the url + * @return the response + */ + public static ResponseEntity get(String url, Class clazz) { + return CLIENT.get() + .uri(url) + .retrieve() + .onStatus(HttpStatusCode::isError, (request, response) -> {}) // Don't throw exceptions on error + .toEntity(clazz); + } + + /** + * Gets a response from the given URL. + * + * @param url the url + * @return the response + */ + public static ResponseEntity head(String url, Class clazz) { + return CLIENT.head() + .uri(url) + .retrieve() + .onStatus(HttpStatusCode::isError, (request, response) -> {}) // Don't throw exceptions on error + .toEntity(clazz); + } +} diff --git a/src/main/java/cc/fascinated/bat/config/AppConfig.java b/src/main/java/cc/fascinated/bat/config/AppConfig.java new file mode 100644 index 0000000..58ba464 --- /dev/null +++ b/src/main/java/cc/fascinated/bat/config/AppConfig.java @@ -0,0 +1,11 @@ +package cc.fascinated.bat.config; + +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; + +/** + * @author Fascinated (fascinated7) + */ +@Configuration +@ComponentScan(basePackages = "cc.fascinated.bat") +public class AppConfig { } \ No newline at end of file diff --git a/src/main/java/cc/fascinated/bat/config/MongoConfig.java b/src/main/java/cc/fascinated/bat/config/MongoConfig.java new file mode 100644 index 0000000..14cb512 --- /dev/null +++ b/src/main/java/cc/fascinated/bat/config/MongoConfig.java @@ -0,0 +1,32 @@ +package cc.fascinated.bat.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.mongodb.MongoDatabaseFactory; +import org.springframework.data.mongodb.core.convert.DbRefResolver; +import org.springframework.data.mongodb.core.convert.DefaultDbRefResolver; +import org.springframework.data.mongodb.core.convert.MappingMongoConverter; +import org.springframework.data.mongodb.core.convert.MongoCustomConversions; +import org.springframework.data.mongodb.core.mapping.MongoMappingContext; +import org.springframework.data.mongodb.core.mapping.event.BeforeConvertCallback; +import org.springframework.data.mongodb.core.mapping.event.MongoMappingEvent; +import org.springframework.data.mongodb.core.mapping.event.ValidatingMongoEventListener; +import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * @author Fascinated (fascinated7) + */ +@Configuration +public class MongoConfig { + @Bean + public MappingMongoConverter mongoConverter(MongoDatabaseFactory mongoFactory, MongoMappingContext mongoMappingContext) { + DbRefResolver dbRefResolver = new DefaultDbRefResolver(mongoFactory); + MappingMongoConverter mongoConverter = new MappingMongoConverter(dbRefResolver, mongoMappingContext); + mongoConverter.setMapKeyDotReplacement("-DOT"); + return mongoConverter; + } +} \ No newline at end of file diff --git a/src/main/java/cc/fascinated/bat/exception/BadRequestException.java b/src/main/java/cc/fascinated/bat/exception/BadRequestException.java new file mode 100644 index 0000000..f629d9f --- /dev/null +++ b/src/main/java/cc/fascinated/bat/exception/BadRequestException.java @@ -0,0 +1,9 @@ +package cc.fascinated.bat.exception; + +import lombok.experimental.StandardException; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +@StandardException +@ResponseStatus(HttpStatus.BAD_REQUEST) +public class BadRequestException extends RuntimeException { } diff --git a/src/main/java/cc/fascinated/bat/exception/RateLimitException.java b/src/main/java/cc/fascinated/bat/exception/RateLimitException.java new file mode 100644 index 0000000..4724b07 --- /dev/null +++ b/src/main/java/cc/fascinated/bat/exception/RateLimitException.java @@ -0,0 +1,12 @@ +package cc.fascinated.bat.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +@ResponseStatus(HttpStatus.TOO_MANY_REQUESTS) +public class RateLimitException extends RuntimeException { + + public RateLimitException(String message) { + super(message); + } +} diff --git a/src/main/java/cc/fascinated/bat/exception/ResourceNotFoundException.java b/src/main/java/cc/fascinated/bat/exception/ResourceNotFoundException.java new file mode 100644 index 0000000..f864cef --- /dev/null +++ b/src/main/java/cc/fascinated/bat/exception/ResourceNotFoundException.java @@ -0,0 +1,9 @@ +package cc.fascinated.bat.exception; + +import lombok.experimental.StandardException; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +@StandardException +@ResponseStatus(HttpStatus.NOT_FOUND) +public class ResourceNotFoundException extends RuntimeException { } diff --git a/src/main/java/cc/fascinated/bat/model/BatGuild.java b/src/main/java/cc/fascinated/bat/model/BatGuild.java new file mode 100644 index 0000000..7a860b0 --- /dev/null +++ b/src/main/java/cc/fascinated/bat/model/BatGuild.java @@ -0,0 +1,33 @@ +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.java b/src/main/java/cc/fascinated/bat/model/Guild.java deleted file mode 100644 index 1f32057..0000000 --- a/src/main/java/cc/fascinated/bat/model/Guild.java +++ /dev/null @@ -1,19 +0,0 @@ -package cc.fascinated.bat.model; - -import lombok.Getter; -import lombok.NonNull; -import lombok.RequiredArgsConstructor; -import org.springframework.data.annotation.Id; - -/** - * @author Fascinated (fascinated7) - */ -@RequiredArgsConstructor -@Getter -public class Guild { - - /** - * The ID of the guild - */ - @NonNull @Id private final String id; -} diff --git a/src/main/java/cc/fascinated/bat/model/beatsaber/scoresaber/ScoreSaberAccountToken.java b/src/main/java/cc/fascinated/bat/model/beatsaber/scoresaber/ScoreSaberAccountToken.java new file mode 100644 index 0000000..6e5dcea --- /dev/null +++ b/src/main/java/cc/fascinated/bat/model/beatsaber/scoresaber/ScoreSaberAccountToken.java @@ -0,0 +1,139 @@ +package cc.fascinated.bat.model.beatsaber.scoresaber; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +public class ScoreSaberAccountToken { + /** + * The id for this ScoreSaber account. + */ + private String id; + + /** + * The name for this account. + */ + private String name; + + /** + * The profile picture for this account. + */ + private String profilePicture; + + /** + * The bio for this account. + */ + private String bio; + + /** + * The country for this account. + */ + private String country; + + /** + * The PP for this account. + */ + private double pp; + + /** + * The rank for this account. + */ + private int rank; + + /** + * The country rank for this account. + */ + private int countryRank; + + /** + * The role for this account. + */ + private String role; + + /** + * The badges for this account. + */ + private Badge[] badges; + + /** + * The history of the rank for this account. + */ + private String histories; + + /** + * The permissions for this account. + */ + private int permissions; + + /** + * The banned status for this account. + */ + private boolean banned; + + /** + * The inactive status for this account. + */ + private boolean inactive; + + /** + * The score stats for this account. + */ + private ScoreStats scoreStats; + + /** + * The first time this account was seen. + */ + private String firstSeen; + + /** + * The badge for this account. + */ + @AllArgsConstructor @Getter + public static class Badge { + /** + * The image for this badge. + */ + private String image; + + /** + * The description for this badge. + */ + private String description; + } + + /** + * The score stats for this account. + */ + @AllArgsConstructor @Getter + public static class ScoreStats { + /** + * The total score for this account. + */ + private long totalScore; + + /** + * The total ranked score for this account. + */ + private long totalRankedScore; + + /** + * The average ranked accuracy for this account. + */ + private double averageRankedAccuracy; + + /** + * The total play count for this account. + */ + private int totalPlayCount; + + /** + * The ranked play count for this account. + */ + private int rankedPlayCount; + + /** + * The replays watched for this account. + */ + private int replaysWatched; + } +} diff --git a/src/main/java/cc/fascinated/bat/model/beatsaber/scoresaber/ScoreSaberLeaderboardToken.java b/src/main/java/cc/fascinated/bat/model/beatsaber/scoresaber/ScoreSaberLeaderboardToken.java new file mode 100644 index 0000000..c3005ce --- /dev/null +++ b/src/main/java/cc/fascinated/bat/model/beatsaber/scoresaber/ScoreSaberLeaderboardToken.java @@ -0,0 +1,145 @@ +package cc.fascinated.bat.model.beatsaber.scoresaber; + +import lombok.Getter; +import lombok.ToString; + +import java.util.List; + +@Getter @ToString +public class ScoreSaberLeaderboardToken { + /** + * The ID of the leaderboard. + */ + private String id; + + /** + * The hash of the song. + */ + private String songHash; + + /** + * The name of the song. + */ + private String songName; + + /** + * The sub name of the song. + */ + private String songSubName; + + /** + * The author of the song. + */ + private String songAuthorName; + + /** + * The mapper of the song. + */ + private String levelAuthorName; + + /** + * The difficulty of the song. + */ + private Difficulty difficulty; + + /** + * The maximum score of the song. + */ + private int maxScore; + + /** + * The date the leaderboard was created. + */ + private String createdDate; + + /** + * The date the song was ranked. + */ + private String rankedDate; + + /** + * The date the song was qualified. + */ + private String qualifiedDate; + + /** + * The date the song's status was changed to loved. + */ + private String lovedDate; + + /** + * Whether this leaderboard is ranked. + */ + private boolean ranked; + + /** + * Whether this leaderboard is qualified to be ranked. + */ + private boolean qualified; + + /** + * Whether this leaderboard is in a loved state. + */ + private boolean loved; + + /** + * The maximum PP for this leaderboard. + */ + private int maxPP; + + /** + * The star rating for this leaderboard. + */ + private double stars; + + /** + * The amount of plays for this leaderboard. + */ + private int plays; + + /** + * The amount of daily plays for this leaderboard. + */ + private int dailyPlays; + + /** + * Whether this leaderboard has positive modifiers. + */ + private boolean positiveModifiers; + + /** + * The cover image for this leaderboard. + */ + private String coverImage; + + /** + * The difficulties for this leaderboard. + */ + private List difficulties; + + /** + * The difficulty of the leaderboard. + */ + @Getter + public static class Difficulty { + /** + * The leaderboard ID. + */ + private int leaderboardId; + + /** + * The difficulty of the leaderboard. + */ + private int difficulty; + + /** + * The game mode of the leaderboard. + */ + private String gameMode; + + /** + * The difficulty raw of the leaderboard. + */ + private String difficultyRaw; + } +} diff --git a/src/main/java/cc/fascinated/bat/model/beatsaber/scoresaber/ScoreSaberPageMetadataToken.java b/src/main/java/cc/fascinated/bat/model/beatsaber/scoresaber/ScoreSaberPageMetadataToken.java new file mode 100644 index 0000000..d3790e5 --- /dev/null +++ b/src/main/java/cc/fascinated/bat/model/beatsaber/scoresaber/ScoreSaberPageMetadataToken.java @@ -0,0 +1,22 @@ +package cc.fascinated.bat.model.beatsaber.scoresaber; + +import lombok.Getter; +import lombok.ToString; + +@Getter @ToString +public class ScoreSaberPageMetadataToken { + /** + * The total amount of scores. + */ + private int total; + + /** + * The current page. + */ + private int page; + + /** + * The amount of scores per page. + */ + private int itemsPerPage; +} diff --git a/src/main/java/cc/fascinated/bat/model/beatsaber/scoresaber/ScoreSaberPlayerScoreToken.java b/src/main/java/cc/fascinated/bat/model/beatsaber/scoresaber/ScoreSaberPlayerScoreToken.java new file mode 100644 index 0000000..d30a991 --- /dev/null +++ b/src/main/java/cc/fascinated/bat/model/beatsaber/scoresaber/ScoreSaberPlayerScoreToken.java @@ -0,0 +1,17 @@ +package cc.fascinated.bat.model.beatsaber.scoresaber; + +import lombok.Getter; +import lombok.ToString; + +@Getter @ToString +public class ScoreSaberPlayerScoreToken { + /** + * The score that was set. + */ + private ScoreSaberScoreToken score; + + /** + * The leaderboard that the score was set on. + */ + private ScoreSaberLeaderboardToken leaderboard; +} diff --git a/src/main/java/cc/fascinated/bat/model/beatsaber/scoresaber/ScoreSaberScoreToken.java b/src/main/java/cc/fascinated/bat/model/beatsaber/scoresaber/ScoreSaberScoreToken.java new file mode 100644 index 0000000..d70a116 --- /dev/null +++ b/src/main/java/cc/fascinated/bat/model/beatsaber/scoresaber/ScoreSaberScoreToken.java @@ -0,0 +1,135 @@ +package cc.fascinated.bat.model.beatsaber.scoresaber; + +import lombok.Getter; +import lombok.ToString; + +@Getter @ToString +public class ScoreSaberScoreToken { + /** + * The id for this score. + */ + private String id; + + /** + * The player info for this score. + */ + private LeaderboardPlayerInfo leaderboardPlayerInfo; + + /** + * The rank of this score. + */ + private int rank; + + /** + * The base score for this score. + */ + private int baseScore; + + /** + * The modified score for this score. + */ + private int modifiedScore; + + /** + * The PP for this score. + */ + private double pp; + + /** + * The weight for this score. + */ + private int weight; + + /** + * The modifiers for this score. + */ + private String modifiers; + + /** + * The multiplier for this score. + */ + private int multiplier; + + /** + * How many bad cuts this score has. + */ + private int badCuts; + + /** + * How many misses this score has. + */ + private int missedNotes; + + /** + * The maximum combo for this score. + */ + private int maxCombo; + + /** + * Whether this score was a full combo. + */ + private boolean fullCombo; + + /** + * The HMD that was used to set this score. + */ + private int hmd; + + /** + * The time set for this score. + */ + private String timeSet; + + /** + * Whether this score has a replay. + */ + private boolean hasReplay; + + /** + * The full HMD name that was used to set this score. + */ + private String deviceHmd; + + /** + * The controller that was used on the left hand. + */ + private String deviceControllerLeft; + + /** + * The controller that was used on the right hand. + */ + private String deviceControllerRight; + + @Getter + public class LeaderboardPlayerInfo { + /** + * The ID of the player. + */ + private String id; + + /** + * The name of the player. + */ + private String name; + + /** + * The profile picture of the player. + */ + private String profilePicture; + + /** + * The country of the player. + */ + private String country; + + /** + * The permissions for the player. + */ + private int permissions; + + /** + * The role for the player. + */ + private String role; + } +} diff --git a/src/main/java/cc/fascinated/bat/model/beatsaber/scoresaber/ScoreSaberScoresPageToken.java b/src/main/java/cc/fascinated/bat/model/beatsaber/scoresaber/ScoreSaberScoresPageToken.java new file mode 100644 index 0000000..4ebc4d3 --- /dev/null +++ b/src/main/java/cc/fascinated/bat/model/beatsaber/scoresaber/ScoreSaberScoresPageToken.java @@ -0,0 +1,19 @@ +package cc.fascinated.bat.model.beatsaber.scoresaber; + +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +@Getter @Setter +@ToString +public class ScoreSaberScoresPageToken { + /** + * The scores on this page. + */ + private ScoreSaberPlayerScoreToken[] playerScores; + + /** + * The metadata for this page. + */ + private ScoreSaberPageMetadataToken metadata; +} diff --git a/src/main/java/cc/fascinated/bat/model/user/BatUser.java b/src/main/java/cc/fascinated/bat/model/user/BatUser.java new file mode 100644 index 0000000..ea3ca0b --- /dev/null +++ b/src/main/java/cc/fascinated/bat/model/user/BatUser.java @@ -0,0 +1,66 @@ +package cc.fascinated.bat.model.user; + +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.User; +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 = "users") +public class BatUser { + + /** + * The ID of the user + */ + @NonNull @Id private final String id; + + /** + * The profiles for this user + */ + private Map profiles; + + /** + * Gets the profile for the user + * + * @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 + */ + private User getDiscordUser() { + return DiscordService.JDA.getUserById(id); + } +} diff --git a/src/main/java/cc/fascinated/bat/model/user/profiles/ScoreSaberProfile.java b/src/main/java/cc/fascinated/bat/model/user/profiles/ScoreSaberProfile.java new file mode 100644 index 0000000..bbc1305 --- /dev/null +++ b/src/main/java/cc/fascinated/bat/model/user/profiles/ScoreSaberProfile.java @@ -0,0 +1,20 @@ +package cc.fascinated.bat.model.user.profiles; + +import cc.fascinated.bat.common.Profile; +import lombok.Getter; +import lombok.Setter; + +/** + * @author Fascinated (fascinated7) + */ +@Setter @Getter +public class ScoreSaberProfile extends Profile { + /** + * The Account ID of the ScoreSaber profile + */ + private String id; + + public ScoreSaberProfile() { + super("scoresaber"); + } +} diff --git a/src/main/java/cc/fascinated/bat/repository/GuildRepository.java b/src/main/java/cc/fascinated/bat/repository/GuildRepository.java index 026e515..415891d 100644 --- a/src/main/java/cc/fascinated/bat/repository/GuildRepository.java +++ b/src/main/java/cc/fascinated/bat/repository/GuildRepository.java @@ -1,9 +1,9 @@ package cc.fascinated.bat.repository; -import cc.fascinated.bat.model.Guild; +import cc.fascinated.bat.model.BatGuild; import org.springframework.data.mongodb.repository.MongoRepository; /** * @author Fascinated (fascinated7) */ -public interface GuildRepository extends MongoRepository { } \ No newline at end of file +public interface GuildRepository extends MongoRepository { } \ No newline at end of file diff --git a/src/main/java/cc/fascinated/bat/repository/UserRepository.java b/src/main/java/cc/fascinated/bat/repository/UserRepository.java new file mode 100644 index 0000000..3f8a69c --- /dev/null +++ b/src/main/java/cc/fascinated/bat/repository/UserRepository.java @@ -0,0 +1,9 @@ +package cc.fascinated.bat.repository; + +import cc.fascinated.bat.model.user.BatUser; +import org.springframework.data.mongodb.repository.MongoRepository; + +/** + * @author Fascinated (fascinated7) + */ +public interface UserRepository extends MongoRepository { } \ No newline at end of file diff --git a/src/main/java/cc/fascinated/bat/service/CommandService.java b/src/main/java/cc/fascinated/bat/service/CommandService.java new file mode 100644 index 0000000..3bff266 --- /dev/null +++ b/src/main/java/cc/fascinated/bat/service/CommandService.java @@ -0,0 +1,156 @@ +package cc.fascinated.bat.service; + +import cc.fascinated.bat.command.BatCommand; +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.common.EmbedUtils; +import cc.fascinated.bat.model.BatGuild; +import cc.fascinated.bat.model.user.BatUser; +import lombok.NonNull; +import lombok.extern.log4j.Log4j2; +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; +import org.springframework.context.annotation.DependsOn; +import org.springframework.stereotype.Service; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * @author Fascinated (fascinated7) + */ +@Service @Log4j2 +@DependsOn("discordService") +public class CommandService extends ListenerAdapter { + /** + * The registered commands + */ + private final Map commands = new HashMap<>(); + + /** + * The guild service to use + */ + private final GuildService guildService; + + /** + * The user service to use + */ + private final UserService userService; + + /** + * The application context to use + */ + private final ApplicationContext context; + + @Autowired + public CommandService(@NonNull GuildService guildService, @NonNull UserService userService, @NonNull ApplicationContext context) { + this.guildService = guildService; + this.userService = userService; + this.context = context; + DiscordService.JDA.addEventListener(this); + + // Guild commands + // todo: add some, duh + + // Global commands + registerCommand(context.getBean(ScoreSaberCommand.class) + .addSubCommand("link", context.getBean(LinkSubCommand.class)) + .addSubCommand("user", context.getBean(UserSubCommand.class) + )); + + registerSlashCommands(); // Register all slash commands + } + + /** + * Registers a command + * + * @param command The command to register + */ + public void registerCommand(@NonNull BatCommand command) { + commands.put(command.getName().toLowerCase(), command); + } + + /** + * Registers all slash commands + */ + public void registerSlashCommands() { + log.info("Registering all slash commands"); + JDA jda = DiscordService.JDA; + long before = System.currentTimeMillis(); + + // Unregister all commands that Discord has but we don't + jda.retrieveCommands().complete().forEach(command -> { + if (commands.containsKey(command.getName())) { + return; + } + + jda.deleteCommandById(command.getId()).complete(); // Unregister the command on Discord + log.info("Unregistered unknown command \"{}\" from Discord", command.getName()); + }); + + // Register all commands + for (BatCommand command : commands.values()) { + if (command.getCommandData() == null) { + continue; + } + + jda.upsertCommand(command.getCommandData()).complete(); // Register the command on Discord + } + log.info("Registered all slash commands in {}ms", System.currentTimeMillis() - before); + } + + @Override + public void onSlashCommandInteraction(@NotNull SlashCommandInteractionEvent event) { + Guild discordGuild = event.getGuild(); + if (discordGuild == null) { + return; + } + if (event.getUser().isBot()) { + return; + } + if (event.getMember() == null) { + return; + } + + String commandName = event.getName(); + BatCommand command = commands.get(commandName); + if (command == null) { + return; + } + + BatGuild guild = guildService.getGuild(discordGuild.getId()); + 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); + break; + } + } + } + } catch (Exception ex) { + log.error("An error occurred while executing command \"{}\"", commandName, ex); + event.replyEmbeds(EmbedUtils.buildErrorEmbed("An error occurred while executing the command\n\n" + + ex.getLocalizedMessage()).build()).queue(); + } + } +} diff --git a/src/main/java/cc/fascinated/bat/service/DiscordService.java b/src/main/java/cc/fascinated/bat/service/DiscordService.java index 9e5f4c8..fca202a 100644 --- a/src/main/java/cc/fascinated/bat/service/DiscordService.java +++ b/src/main/java/cc/fascinated/bat/service/DiscordService.java @@ -6,6 +6,7 @@ import net.dv8tion.jda.api.JDA; import net.dv8tion.jda.api.JDABuilder; import net.dv8tion.jda.api.entities.Activity; import net.dv8tion.jda.api.requests.GatewayIntent; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; @@ -19,10 +20,13 @@ public class DiscordService { /** * The JDA instance */ - private final JDA jda; + public static JDA JDA; - public DiscordService(@Value("${discord.token}") String token) throws Exception { - jda = JDABuilder.createLight(token, EnumSet.of( + @Autowired + public DiscordService( + @Value("${discord.token}") String token + ) throws Exception { + JDA = JDABuilder.createLight(token, EnumSet.of( GatewayIntent.GUILD_MESSAGES, GatewayIntent.MESSAGE_CONTENT )).build() @@ -32,11 +36,12 @@ public class DiscordService { TimerUtils.scheduleRepeating(this::updateActivity, 0, 1000 * 60 * 5); } + /** * Updates the activity of the bot */ public void updateActivity() { - int guildCount = jda.getGuilds().size(); - jda.getPresence().setActivity(Activity.playing("with %s guilds".formatted(guildCount))); + int guildCount = JDA.getGuilds().size(); + JDA.getPresence().setActivity(Activity.playing("with %s guilds".formatted(guildCount))); } } diff --git a/src/main/java/cc/fascinated/bat/service/GuildService.java b/src/main/java/cc/fascinated/bat/service/GuildService.java index 3d6e950..9f2b400 100644 --- a/src/main/java/cc/fascinated/bat/service/GuildService.java +++ b/src/main/java/cc/fascinated/bat/service/GuildService.java @@ -1,12 +1,13 @@ package cc.fascinated.bat.service; -import cc.fascinated.bat.model.Guild; +import cc.fascinated.bat.model.BatGuild; import cc.fascinated.bat.repository.GuildRepository; import lombok.NonNull; import lombok.extern.log4j.Log4j2; import net.dv8tion.jda.api.events.guild.GuildJoinEvent; import net.dv8tion.jda.api.hooks.ListenerAdapter; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.DependsOn; import org.springframework.stereotype.Service; import java.util.Optional; @@ -15,23 +16,17 @@ import java.util.Optional; * @author Fascinated (fascinated7) */ @Service @Log4j2 +@DependsOn("discordService") public class GuildService extends ListenerAdapter { /** * The guild repository to use */ private final GuildRepository guildRepository; - /** - * The discord service to use - */ - private final DiscordService discordService; - @Autowired - public GuildService(@NonNull GuildRepository guildRepository, @NonNull DiscordService discordService) { + public GuildService(@NonNull GuildRepository guildRepository) { this.guildRepository = guildRepository; - this.discordService = discordService; - - discordService.getJda().addEventListener(this); + DiscordService.JDA.addEventListener(this); } /** @@ -40,19 +35,28 @@ public class GuildService extends ListenerAdapter { * @param id The ID of the guild * @return The guild */ - public Guild getGuild(@NonNull String id) { + public BatGuild getGuild(@NonNull String id) { long start = System.currentTimeMillis(); - Optional optionalGuild = guildRepository.findById(id); + Optional optionalGuild = guildRepository.findById(id); if (optionalGuild.isPresent()) { return optionalGuild.get(); } - Guild guild = guildRepository.save(new Guild(id)); + BatGuild guild = guildRepository.save(new BatGuild(id)); log.info("Created guild \"{}\" in {}ms", id, System.currentTimeMillis() - start); return guild; } + /** + * Saves a guild + * + * @param guild The guild to save + */ + public void saveGuild(@NonNull BatGuild guild) { + guildRepository.save(guild); + } + @Override - public void onGuildJoin(GuildJoinEvent event) { + public final void onGuildJoin(GuildJoinEvent event) { log.info("Joined guild \"{}\"", event.getGuild().getId()); getGuild(event.getGuild().getId()); // Ensure the guild is in the database } diff --git a/src/main/java/cc/fascinated/bat/service/ScoreSaberService.java b/src/main/java/cc/fascinated/bat/service/ScoreSaberService.java new file mode 100644 index 0000000..0c5d610 --- /dev/null +++ b/src/main/java/cc/fascinated/bat/service/ScoreSaberService.java @@ -0,0 +1,85 @@ +package cc.fascinated.bat.service; + +import cc.fascinated.bat.common.DateUtils; +import cc.fascinated.bat.common.WebRequest; +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.user.profiles.ScoreSaberProfile; +import lombok.extern.log4j.Log4j2; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +@Service @Log4j2(topic = "ScoreSaber Service") +public class ScoreSaberService { + 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"; + + /** + * Gets the account from the ScoreSaber API. + * + * @param id The id of the account. + * @return The account. + * @throws ResourceNotFoundException If the account is not found. + * @throws cc.fascinated.bat.exception.RateLimitException If the ScoreSaber rate limit is reached. + */ + public ScoreSaberAccountToken getAccount(String id) { + ScoreSaberAccountToken account = WebRequest.getAsEntity(String.format(GET_PLAYER_ENDPOINT, id), ScoreSaberAccountToken.class); + if (account == null) { // Check if the account doesn't exist. + log.info("Account with id '{}' not found.", id); + throw new ResourceNotFoundException("Account with id '%s' not found.".formatted(id)); + } + if (account.isBanned()) { + throw new BadRequestException("Account with id '%s' is banned.".formatted(id)); + } + if (account.isInactive()) { + throw new BadRequestException("Account with id '%s' is inactive.".formatted(id)); + } + return account; + } + + /** + * Gets the scores for the account. + * + * @param profile The profile. + * @param page The page to get the scores from. + * @return The scores. + */ + public ScoreSaberScoresPageToken getPageScores(ScoreSaberProfile profile, int page) { + log.info("Fetching scores for account '{}' from page {}.", profile.getId(), page); + ScoreSaberScoresPageToken pageToken = WebRequest.getAsEntity(String.format(GET_PLAYER_SCORES_ENDPOINT, profile.getId(), "recent", page), ScoreSaberScoresPageToken.class); + if (pageToken == null) { // Check if the page doesn't exist. + return null; + } + // Sort the scores by newest time set. + pageToken.setPlayerScores(Arrays.stream(pageToken.getPlayerScores()) + .sorted((a, b) -> DateUtils.getDateFromString(b.getScore().getTimeSet()).compareTo(DateUtils.getDateFromString(a.getScore().getTimeSet()))) + .toArray(ScoreSaberPlayerScoreToken[]::new)); + return pageToken; + } + + /** + * Gets the scores for the account. + * + * @param profile The profile. + * @return The scores. + */ + public List getScores(ScoreSaberProfile profile) { + List scores = new ArrayList<>(List.of(getPageScores(profile, 1))); + ScoreSaberPageMetadataToken metadata = scores.get(0).getMetadata(); + int totalPages = (int) Math.ceil((double) metadata.getTotal() / metadata.getItemsPerPage()); + log.info("Fetching {} pages of scores for account '{}'.", totalPages, profile.getId()); + for (int i = 2; i <= totalPages; i++) { + scores.add(getPageScores(profile, i)); + } + + return scores; + } +} diff --git a/src/main/java/cc/fascinated/bat/service/UserService.java b/src/main/java/cc/fascinated/bat/service/UserService.java new file mode 100644 index 0000000..759ec69 --- /dev/null +++ b/src/main/java/cc/fascinated/bat/service/UserService.java @@ -0,0 +1,54 @@ +package cc.fascinated.bat.service; + +import cc.fascinated.bat.model.user.BatUser; +import cc.fascinated.bat.repository.UserRepository; +import lombok.NonNull; +import lombok.extern.log4j.Log4j2; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.DependsOn; +import org.springframework.stereotype.Service; + +import java.util.Optional; + +/** + * @author Fascinated (fascinated7) + */ +@Service @Log4j2 +@DependsOn("discordService") +public class UserService { + /** + * The user repository to use + */ + private final UserRepository userRepository; + + @Autowired + public UserService(@NonNull UserRepository userRepository) { + this.userRepository = userRepository; + } + + /** + * Gets a user by their ID + * + * @param id The ID of the user + * @return The user + */ + public BatUser getUser(@NonNull String id) { + long start = System.currentTimeMillis(); + Optional optionalUser = userRepository.findById(id); + if (optionalUser.isPresent()) { + return optionalUser.get(); + } + BatUser user = userRepository.save(new BatUser(id)); + log.info("Created user \"{}\" in {}ms", id, System.currentTimeMillis() - start); + return user; + } + + /** + * Saves a user + * + * @param user The user to save + */ + public void saveUser(@NonNull BatUser user) { + userRepository.save(user); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 6064ea4..9dcfa00 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -3,6 +3,9 @@ discord: # Spring Configuration spring: + # Disable the Spring Web Server + main: + web-application-type: none data: # MongoDB Configuration mongodb: