much stuff

This commit is contained in:
Lee 2024-06-24 13:56:01 +01:00
parent eeb09ee1fd
commit 0b176c3b2a
36 changed files with 1493 additions and 69 deletions

22
HELP.md

@ -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 `<license>` and `<developers>` 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.

23
pom.xml

@ -29,17 +29,16 @@
</build>
<dependencies>
<!-- Spring -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-mongodb</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- Libraries -->
<dependency>
<groupId>org.projectlombok</groupId>
@ -47,6 +46,11 @@
<version>1.18.32</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.apache.httpcomponents.client5</groupId>
<artifactId>httpclient5</artifactId>
<version>5.3.1</version>
</dependency>
<!-- Dependencies -->
<dependency>
@ -54,5 +58,12 @@
<artifactId>JDA</artifactId>
<version>5.0.0-beta.24</version>
</dependency>
<!-- Test Dependencies -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>

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

@ -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<String, BatSubCommand> 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<String> 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;
}
}

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

@ -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 { }

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

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

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

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

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

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

@ -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 <T> the type of the response
*/
public static <T> T getAsEntity(String url, Class<T> clazz) throws RateLimitException {
ResponseEntity<T> 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);
}
}

@ -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 { }

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

@ -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 { }

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

@ -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 { }

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

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

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

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

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

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

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

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

@ -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<String, Profile> profiles;
/**
* Gets the profile for the user
*
* @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
*/
private User getDiscordUser() {
return DiscordService.JDA.getUserById(id);
}
}

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

@ -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<Guild, String> { }
public interface GuildRepository extends MongoRepository<BatGuild, String> { }

@ -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<BatUser, String> { }

@ -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<String, BatCommand> 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<OptionMapping> 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<String, BatSubCommand> 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();
}
}
}

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

@ -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<Guild> optionalGuild = guildRepository.findById(id);
Optional<BatGuild> 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
}

@ -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<ScoreSaberScoresPageToken> getScores(ScoreSaberProfile profile) {
List<ScoreSaberScoresPageToken> 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;
}
}

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

@ -3,6 +3,9 @@ discord:
# Spring Configuration
spring:
# Disable the Spring Web Server
main:
web-application-type: none
data:
# MongoDB Configuration
mongodb: