diff --git a/README.md b/README.md index 713de9a..e2405b4 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,6 @@ # Tether -An API designed to provide real-time access to a user's Discord data. \ No newline at end of file +An API designed to provide real-time access to a user's Discord data. + +## TODO +- [ ] Caching +- [ ] User account for extra account? (about me, connections, etc) \ No newline at end of file diff --git a/pom.xml b/pom.xml index 9571425..2d73586 100644 --- a/pom.xml +++ b/pom.xml @@ -3,6 +3,12 @@ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 3.3.2 + + me.braydon @@ -13,6 +19,8 @@ 21 + ${java.version} + ${java.version} UTF-8 @@ -59,6 +67,17 @@ 1.18.34 provided + + net.dv8tion + JDA + 5.1.0 + + + club.minnced + opus-java + + + com.github.ben-manes.caffeine caffeine diff --git a/src/main/java/me/braydon/tether/Tether.java b/src/main/java/me/braydon/tether/Tether.java index 564fd57..a7ada3c 100644 --- a/src/main/java/me/braydon/tether/Tether.java +++ b/src/main/java/me/braydon/tether/Tether.java @@ -1,10 +1,36 @@ package me.braydon.tether; +import lombok.NonNull; +import lombok.SneakyThrows; +import lombok.extern.log4j.Log4j2; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.StandardCopyOption; +import java.util.Objects; + /** * @author Braydon */ -public final class Tether { - public static void main(String[] args) { +@SpringBootApplication +@Log4j2(topic = "Tether") +public class Tether { + @SneakyThrows + public static void main(@NonNull String[] args) { + // Load the application.yml configuration file + File config = new File("application.yml"); + if (!config.exists()) { // Saving the default config if it doesn't exist locally + Files.copy(Objects.requireNonNull(Tether.class.getResourceAsStream("/application.yml")), config.toPath(), StandardCopyOption.REPLACE_EXISTING); + log.info("Saved the default configuration to '{}', please re-launch the application", + config.getAbsolutePath() + ); + return; + } + log.info("Found configuration at '{}'", config.getAbsolutePath()); + // Start the app + SpringApplication.run(Tether.class, args); } } \ No newline at end of file diff --git a/src/main/java/me/braydon/tether/common/EnvironmentUtils.java b/src/main/java/me/braydon/tether/common/EnvironmentUtils.java new file mode 100644 index 0000000..8ad865d --- /dev/null +++ b/src/main/java/me/braydon/tether/common/EnvironmentUtils.java @@ -0,0 +1,19 @@ +package me.braydon.tether.common; + +import lombok.Getter; +import lombok.experimental.UtilityClass; + +/** + * @author Braydon + */ +@UtilityClass +public final class EnvironmentUtils { + /** + * Is the app running in a production environment? + */ + @Getter private static final boolean production; + static { + String appEnv = System.getenv("APP_ENV"); + production = appEnv != null && (appEnv.equals("production")); + } +} \ No newline at end of file diff --git a/src/main/java/me/braydon/tether/controller/AppController.java b/src/main/java/me/braydon/tether/controller/AppController.java new file mode 100644 index 0000000..cec7740 --- /dev/null +++ b/src/main/java/me/braydon/tether/controller/AppController.java @@ -0,0 +1,47 @@ +package me.braydon.tether.controller; + +import lombok.NonNull; +import me.braydon.tether.common.EnvironmentUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.info.BuildProperties; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; + +/** + * The root controller for this app. + * + * @author Braydon + */ +@RestController +@RequestMapping(value = "/", produces = MediaType.APPLICATION_JSON_VALUE) +public final class AppController { + /** + * The build properties for this app, null if not available. + */ + private final BuildProperties buildProperties; + + @Autowired + public AppController(BuildProperties buildProperties) { + this.buildProperties = buildProperties; + } + + /** + * A GET endpoint to get info about this app. + * + * @return the info response + */ + @GetMapping @ResponseBody @NonNull + public ResponseEntity> getAppInfo() { + return ResponseEntity.ok(Map.of( + "app", buildProperties == null ? "N/A" : buildProperties.getName(), + "version", buildProperties == null ? "N/A" : buildProperties.getVersion(), + "environment", EnvironmentUtils.isProduction() ? "production" : "staging" + )); + } +} \ No newline at end of file diff --git a/src/main/java/me/braydon/tether/controller/UserController.java b/src/main/java/me/braydon/tether/controller/UserController.java new file mode 100644 index 0000000..70f790f --- /dev/null +++ b/src/main/java/me/braydon/tether/controller/UserController.java @@ -0,0 +1,43 @@ +package me.braydon.tether.controller; + +import lombok.NonNull; +import me.braydon.tether.exception.impl.BadRequestException; +import me.braydon.tether.exception.impl.ResourceNotFoundException; +import me.braydon.tether.exception.impl.ServiceUnavailableException; +import me.braydon.tether.model.response.DiscordUserResponse; +import me.braydon.tether.service.DiscordService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +/** + * This controller is responsible for + * handling Discord user related requests. + * + * @author Braydon + */ +@RestController +@RequestMapping(value = "/user", produces = MediaType.APPLICATION_JSON_VALUE) +public final class UserController { + @NonNull private final DiscordService discordService; + + @Autowired + public UserController(@NonNull DiscordService discordService) { + this.discordService = discordService; + } + + /** + * A GET endpoint to get info about a + * Discord user by their snowflake. + * + * @param snowflake the user snowflake + * @return the retrieved user + */ + @GetMapping("/{snowflake}") @ResponseBody @NonNull + public ResponseEntity getUserBySnowflake(@PathVariable @NonNull String snowflake) + throws BadRequestException, ServiceUnavailableException, ResourceNotFoundException + { + return ResponseEntity.ok(discordService.getUserBySnowflake(snowflake)); + } +} \ No newline at end of file diff --git a/src/main/java/me/braydon/tether/exception/ExceptionController.java b/src/main/java/me/braydon/tether/exception/ExceptionController.java new file mode 100644 index 0000000..bd83e13 --- /dev/null +++ b/src/main/java/me/braydon/tether/exception/ExceptionController.java @@ -0,0 +1,38 @@ +package me.braydon.tether.exception; + +import jakarta.servlet.http.HttpServletRequest; +import lombok.NonNull; +import me.braydon.tether.model.ErrorResponse; +import org.springframework.boot.autoconfigure.web.servlet.error.AbstractErrorController; +import org.springframework.boot.web.error.ErrorAttributeOptions; +import org.springframework.boot.web.servlet.error.ErrorAttributes; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; + +/** + * The route to handle errors for this app. + * + * @author Braydon + */ +@RestController +@RequestMapping(value = "/error", produces = MediaType.APPLICATION_JSON_VALUE) +public final class ExceptionController extends AbstractErrorController { + public ExceptionController(@NonNull ErrorAttributes errorAttributes) { + super(errorAttributes); + } + + @RequestMapping @ResponseBody @NonNull + public ResponseEntity onError(@NonNull HttpServletRequest request) { + Map error = getErrorAttributes(request, ErrorAttributeOptions.of( + ErrorAttributeOptions.Include.MESSAGE + )); + HttpStatus status = getStatus(request); // The status code + return new ResponseEntity<>(new ErrorResponse(status, (String) error.get("message")), status); + } +} \ No newline at end of file diff --git a/src/main/java/me/braydon/tether/exception/impl/BadRequestException.java b/src/main/java/me/braydon/tether/exception/impl/BadRequestException.java new file mode 100644 index 0000000..d346fc1 --- /dev/null +++ b/src/main/java/me/braydon/tether/exception/impl/BadRequestException.java @@ -0,0 +1,18 @@ +package me.braydon.tether.exception.impl; + +import lombok.NonNull; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +/** + * This exception is raised + * when a bad request is made. + * + * @author Braydon + */ +@ResponseStatus(HttpStatus.BAD_REQUEST) +public final class BadRequestException extends RuntimeException { + public BadRequestException(@NonNull String message) { + super(message); + } +} \ No newline at end of file diff --git a/src/main/java/me/braydon/tether/exception/impl/ResourceNotFoundException.java b/src/main/java/me/braydon/tether/exception/impl/ResourceNotFoundException.java new file mode 100644 index 0000000..84e057a --- /dev/null +++ b/src/main/java/me/braydon/tether/exception/impl/ResourceNotFoundException.java @@ -0,0 +1,18 @@ +package me.braydon.tether.exception.impl; + +import lombok.NonNull; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +/** + * This exception is raised + * when a resource is not found. + * + * @author Braydon + */ +@ResponseStatus(HttpStatus.NOT_FOUND) +public final class ResourceNotFoundException extends RuntimeException { + public ResourceNotFoundException(@NonNull String message) { + super(message); + } +} \ No newline at end of file diff --git a/src/main/java/me/braydon/tether/exception/impl/ServiceUnavailableException.java b/src/main/java/me/braydon/tether/exception/impl/ServiceUnavailableException.java new file mode 100644 index 0000000..9defd4a --- /dev/null +++ b/src/main/java/me/braydon/tether/exception/impl/ServiceUnavailableException.java @@ -0,0 +1,20 @@ +package me.braydon.tether.exception.impl; + +import lombok.NonNull; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +/** + * This exception is raised when a + * service is unavailable. Such as + * when trying to interact with Discord + * and the bot is not connected. + * + * @author Braydon + */ +@ResponseStatus(HttpStatus.SERVICE_UNAVAILABLE) +public final class ServiceUnavailableException extends RuntimeException { + public ServiceUnavailableException(@NonNull String message) { + super(message); + } +} \ No newline at end of file diff --git a/src/main/java/me/braydon/tether/model/DiscordUser.java b/src/main/java/me/braydon/tether/model/DiscordUser.java new file mode 100644 index 0000000..59ef721 --- /dev/null +++ b/src/main/java/me/braydon/tether/model/DiscordUser.java @@ -0,0 +1,180 @@ +package me.braydon.tether.model; + +import lombok.*; +import net.dv8tion.jda.api.OnlineStatus; +import net.dv8tion.jda.api.entities.*; + +import java.text.SimpleDateFormat; +import java.time.OffsetDateTime; +import java.util.EnumSet; +import java.util.List; +import java.util.Objects; + +/** + * A model of a Discord user. + * + * @author Braydon + */ +@AllArgsConstructor @Getter @EqualsAndHashCode(onlyExplicitlyIncluded = true) @ToString +public final class DiscordUser { + /** + * The unique snowflake of this user. + */ + @EqualsAndHashCode.Include private final long snowflake; + + /** + * The username of this user. + */ + @NonNull private final String username; + + /** + * The display name of this user, if any. + */ + private final String displayName; + + /** + * The flags of this user. + */ + @NonNull private final UserFlags flags; + + /** + * The avatar of this user. + */ + @NonNull private final Avatar avatar; + + /** + * The banner of this user, if any. + */ + private final Banner banner; + + /** + * The accent color of this user. + */ + @NonNull private final String accentColor; + + /** + * The online status of this user, if known. + */ + private final OnlineStatus onlineStatus; + + /** + * The clients this user is active on, if known. + */ + private final EnumSet activeClients; + + /** + * The activities of this user, if known. + */ + private final List activities; + + /** + * The Spotify activity of this user, if known. + */ + private final SpotifyActivity spotify; + + /** + * Is this user a bot? + */ + private final boolean bot; + + /** + * The user creation date. + */ + @NonNull private final OffsetDateTime createdAt; + + /** + * Builds a Discord user from the + * raw entities returned from Discord. + * + * @param user the raw user entity + * @param profile the raw profile entity + * @param member the raw member entity, if any + * @return the built user + */ + @NonNull + public static DiscordUser buildFromEntity(@NonNull User user, @NonNull User.Profile profile, Member member) { + Avatar avatar = new Avatar(user.getAvatarId() == null ? user.getDefaultAvatarId() : user.getAvatarId(), user.getEffectiveAvatarUrl()); + Banner banner = profile.getBannerId() == null || profile.getBannerUrl() == null ? null : new Banner(profile.getBannerId(), profile.getBannerUrl()); + String accentColor = String.format("#%06X", (0xFFFFFF & profile.getAccentColorRaw())); + + OnlineStatus onlineStatus = member == null ? null : member.getOnlineStatus(); + EnumSet activeClients = member == null ? null : member.getActiveClients(); + List activities = member == null ? null : member.getActivities(); + SpotifyActivity spotify = null; + if (activities != null) { + for (Activity activity : activities) { + if (!activity.getName().equals("Spotify") || !activity.isRich()) { + continue; + } + spotify = SpotifyActivity.fromActivity(Objects.requireNonNull(activity.asRichPresence())); + break; + } + } + return new DiscordUser( + user.getIdLong(), user.getName(), user.getGlobalName(), new UserFlags(user.getFlags(), user.getFlagsRaw()), + avatar, banner, accentColor, onlineStatus, activeClients, activities, spotify, user.isBot(), user.getTimeCreated() + ); + } + + /** + * A user's flags. + */ + @AllArgsConstructor @Getter + public static class UserFlags { + private final EnumSet list; + private final int raw; + } + + /** + * A user's avatar. + */ + @AllArgsConstructor @Getter + public static class Avatar { + @NonNull private final String id; + @NonNull private final String url; + } + + /** + * A user's banner. + */ + @AllArgsConstructor @Getter + public static class Banner { + @NonNull private final String id; + @NonNull private final String url; + } + + /** + * A user's Spotify activity data. + */ + @AllArgsConstructor @Getter + public static class SpotifyActivity { + @NonNull private final String song; + @NonNull private final String artist; + @NonNull private final String trackProgress; + @NonNull private final String trackLength; + private final long started; + private final long ends; + + /** + * Build a Spotify activity from the raw Discord data. + * + * @param richPresence the raw Discord data + * @return the built Spotify activity + */ + @NonNull + public static SpotifyActivity fromActivity(@NonNull RichPresence richPresence) { + SimpleDateFormat dateFormat = new SimpleDateFormat("m:ss"); + long started = Objects.requireNonNull(richPresence.getTimestamps()).getStart(); + long ends = richPresence.getTimestamps().getEnd(); + + long trackLength = ends - started; + long trackProgress = Math.min(System.currentTimeMillis() - started, trackLength); + + return new SpotifyActivity( + Objects.requireNonNull(richPresence.getDetails()), Objects.requireNonNull(richPresence.getState()).replace(";", ","), + dateFormat.format(trackProgress), dateFormat.format(trackLength), + started, ends + ); + } + } +} \ No newline at end of file diff --git a/src/main/java/me/braydon/tether/model/ErrorResponse.java b/src/main/java/me/braydon/tether/model/ErrorResponse.java new file mode 100644 index 0000000..a7d5e35 --- /dev/null +++ b/src/main/java/me/braydon/tether/model/ErrorResponse.java @@ -0,0 +1,43 @@ +package me.braydon.tether.model; + +import lombok.Getter; +import lombok.NonNull; +import lombok.ToString; +import org.springframework.http.HttpStatus; + +import java.util.Date; + +/** + * A basic response model. + * + * @author Braydon + */ +@Getter @ToString +public class ErrorResponse { + /** + * The status code of this error. + */ + @NonNull private final HttpStatus status; + + /** + * The HTTP code of this error. + */ + private final int code; + + /** + * The message of this error. + */ + @NonNull private final String message; + + /** + * The timestamp this error occurred. + */ + @NonNull private final Date timestamp; + + public ErrorResponse(@NonNull HttpStatus status, @NonNull String message) { + this.status = status; + this.message = message; + this.code = status.value(); + timestamp = new Date(); + } +} \ No newline at end of file diff --git a/src/main/java/me/braydon/tether/model/response/DiscordUserResponse.java b/src/main/java/me/braydon/tether/model/response/DiscordUserResponse.java new file mode 100644 index 0000000..2b72e82 --- /dev/null +++ b/src/main/java/me/braydon/tether/model/response/DiscordUserResponse.java @@ -0,0 +1,25 @@ +package me.braydon.tether.model.response; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NonNull; +import me.braydon.tether.model.DiscordUser; + +/** + * A response for a successful Discord user request. + * + * @author Braydon + */ +@AllArgsConstructor @Getter +public final class DiscordUserResponse { + /** + * The user that was retrieved. + */ + @NonNull private final DiscordUser user; + + /** + * The unix timestamp of when this + * user was cached, -1 if fresh. + */ + private final long cached; +} \ No newline at end of file diff --git a/src/main/java/me/braydon/tether/service/DiscordService.java b/src/main/java/me/braydon/tether/service/DiscordService.java new file mode 100644 index 0000000..73999c2 --- /dev/null +++ b/src/main/java/me/braydon/tether/service/DiscordService.java @@ -0,0 +1,104 @@ +package me.braydon.tether.service; + +import jakarta.annotation.PostConstruct; +import lombok.NonNull; +import lombok.SneakyThrows; +import lombok.extern.log4j.Log4j2; +import me.braydon.tether.exception.impl.BadRequestException; +import me.braydon.tether.exception.impl.ResourceNotFoundException; +import me.braydon.tether.exception.impl.ServiceUnavailableException; +import me.braydon.tether.model.DiscordUser; +import me.braydon.tether.model.response.DiscordUserResponse; +import net.dv8tion.jda.api.JDA; +import net.dv8tion.jda.api.JDABuilder; +import net.dv8tion.jda.api.entities.Activity; +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.Member; +import net.dv8tion.jda.api.entities.User; +import net.dv8tion.jda.api.exceptions.ErrorResponseException; +import net.dv8tion.jda.api.requests.GatewayIntent; +import net.dv8tion.jda.api.utils.cache.CacheFlag; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +/** + * This service is responsible for + * interacting with the Discord API. + * + * @author Braydon + */ +@Service +@Log4j2(topic = "Discord") +public final class DiscordService { + @Value("${discord.bot-token}") + private String botToken; + + /** + * The current instance of the Discord bot. + */ + private JDA jda; + + @PostConstruct + public void onInitialize() { + connectBot(); + } + + /** + * Get a Discord user by their snowflake. + * + * @param rawSnowflake the user snowflake + * @return the user response + * @throws ServiceUnavailableException if the bot is not connected + * @throws ResourceNotFoundException if the user is not found + */ + @NonNull + public DiscordUserResponse getUserBySnowflake(@NonNull String rawSnowflake) throws BadRequestException, ServiceUnavailableException, ResourceNotFoundException { + if (jda == null || (jda.getStatus() != JDA.Status.CONNECTED)) { // Ensure bot is connected + throw new ServiceUnavailableException("Not connected to Discord."); + } + long snowflake; + try { + snowflake = Long.parseLong(rawSnowflake); + } catch (NumberFormatException ex) { + throw new BadRequestException("Not a valid snowflake"); + } + try { + // First try to locate the user in a guild + Member member = null; + for (Guild guild : jda.getGuilds()) { + if ((member = guild.getMemberById(snowflake)) != null) { + break; + } + } + User user = jda.retrieveUserById(snowflake).complete(); + User.Profile profile = user.retrieveProfile().complete(); + return new DiscordUserResponse(DiscordUser.buildFromEntity(user, profile, member), -1L); + } catch (ErrorResponseException ex) { + // Failed to lookup the user, handle appropriately + if (ex.getErrorCode() == 10013) { + throw new ResourceNotFoundException("User not found."); + } + throw ex; + } + } + + /** + * Connects the bot to the Discord API. + */ + @SneakyThrows + private void connectBot() { + log.info("Connecting bot..."); + jda = JDABuilder.createDefault(botToken, GatewayIntent.GUILD_PRESENCES, GatewayIntent.GUILD_MEMBERS) + .enableCache(CacheFlag.getPrivileged()) + .setActivity(Activity.watching("you")) + .build(); + jda.getRestPing().queue(ping -> { + log.info("The latency to Discord is {}ms", ping); + }); + jda.awaitReady(); + log.info("Bot connected! Logged in as {}, invite me using {}", + jda.getSelfUser().getAsTag(), + "https://discord.com/oauth2/authorize?client_id=" + jda.getSelfUser().getId() + ); + } +} \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..6d1bf5e --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,19 @@ +# Server Configuration +server: + address: 0.0.0.0 + port: 7500 + +# Log Configuration +logging: + file: + path: "./logs" + +# Discord Configuration +discord: + bot-token: "CHANGE_ME" + +# Spring Configuration +spring: + # Ignore + banner: + location: "classpath:banner.txt" \ No newline at end of file diff --git a/src/main/resources/banner.txt b/src/main/resources/banner.txt new file mode 100644 index 0000000..74fb525 --- /dev/null +++ b/src/main/resources/banner.txt @@ -0,0 +1,10 @@ + _______ _ _ + |__ __| | | | | + | | ___| |_| |__ ___ _ __ + | |/ _ \ __| '_ \ / _ \ '__| + | | __/ |_| | | | __/ | + |_|\___|\__|_| |_|\___|_| + + | API Version - v${application.version} + | Spring Version - ${spring-boot.formatted-version} +_______________________________________